use std::fs; use std::path::Path; use serde::{Deserialize, Serialize}; use crate::pipe::{Action, ExecutionSurfaceMetadata}; use crate::security::SecurityError; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MacPolicy { pub version: String, pub domains: DomainRules, pub pipe_actions: PipeActionRules, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DomainRules { pub allowed: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PipeActionRules { pub allowed: Vec, #[serde(default)] pub blocked: Vec, } const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__"; impl MacPolicy { pub fn load_from_path(path: impl AsRef) -> Result { let contents = fs::read_to_string(path)?; let policy: Self = serde_json::from_str(&contents)?; policy.validate_rules()?; Ok(policy) } pub fn from_json_str(contents: &str) -> Result { let policy: Self = serde_json::from_str(contents)?; policy.validate_rules()?; Ok(policy) } pub fn supports_pipe_action(&self, action: &Action) -> bool { let action_name = action.as_str(); !self .pipe_actions .blocked .iter() .any(|blocked| blocked == action_name) && self .pipe_actions .allowed .iter() .any(|allowed| allowed == action_name) } pub fn validate(&self, action: &Action, expected_domain: &str) -> Result<(), SecurityError> { let action_name = action.as_str(); if self .pipe_actions .blocked .iter() .any(|blocked| blocked == action_name) { return Err(SecurityError::ActionNotAllowed(action_name.to_string())); } if !self .pipe_actions .allowed .iter() .any(|allowed| allowed == action_name) { return Err(SecurityError::ActionNotAllowed(action_name.to_string())); } let normalized = normalize_domain(expected_domain); if normalized.is_empty() { return Err(SecurityError::DomainNotAllowed(expected_domain.to_string())); } if !self .domains .allowed .iter() .map(|domain| normalize_domain(domain)) .any(|allowed| allowed == normalized) { return Err(SecurityError::DomainNotAllowed(normalized)); } Ok(()) } pub fn validate_local_dashboard_presentation( &self, action: &Action, expected_domain: &str, presentation_url: &str, output_path: &str, ) -> Result<(), SecurityError> { let action_name = action.as_str(); if self .pipe_actions .blocked .iter() .any(|blocked| blocked == action_name) { return Err(SecurityError::ActionNotAllowed(action_name.to_string())); } if !self .pipe_actions .allowed .iter() .any(|allowed| allowed == action_name) { return Err(SecurityError::ActionNotAllowed(action_name.to_string())); } if action != &Action::Navigate { return Err(SecurityError::InvalidLocalDashboard( "local dashboard open only supports navigate".to_string(), )); } if expected_domain != LOCAL_DASHBOARD_EXPECTED_DOMAIN { return Err(SecurityError::InvalidLocalDashboard( "local dashboard expected_domain is invalid".to_string(), )); } if !presentation_url.starts_with("file:///") { return Err(SecurityError::InvalidLocalDashboard( "local dashboard presentation_url must be file:///".to_string(), )); } if !output_path.to_ascii_lowercase().ends_with(".html") { return Err(SecurityError::InvalidLocalDashboard( "local dashboard output_path must point to .html".to_string(), )); } let normalized_output = normalize_local_dashboard_path(output_path); let normalized_presentation = normalize_local_dashboard_file_url(presentation_url)?; if normalized_output != normalized_presentation { return Err(SecurityError::InvalidLocalDashboard( "local dashboard presentation_url does not match output_path".to_string(), )); } Ok(()) } pub fn privileged_surface_metadata(&self) -> ExecutionSurfaceMetadata { let mut metadata = ExecutionSurfaceMetadata::privileged_browser_pipe("mac_policy"); metadata.allowed_domains = self.domains.allowed.clone(); metadata.allowed_actions = self.pipe_actions.allowed.clone(); metadata } fn validate_rules(&self) -> Result<(), SecurityError> { if self.version.trim().is_empty() { return Err(SecurityError::InvalidRules( "rules version must not be empty".to_string(), )); } if self.domains.allowed.is_empty() { return Err(SecurityError::InvalidRules( "at least one allowed domain is required".to_string(), )); } if self.pipe_actions.allowed.is_empty() { return Err(SecurityError::InvalidRules( "at least one allowed action is required".to_string(), )); } Ok(()) } } fn normalize_domain(raw: &str) -> String { raw.trim() .trim_start_matches("https://") .trim_start_matches("http://") .split('/') .next() .unwrap_or_default() .split(':') .next() .unwrap_or_default() .to_ascii_lowercase() } fn normalize_local_dashboard_path(raw: &str) -> String { raw.trim().replace('\\', "/").to_ascii_lowercase() } fn normalize_local_dashboard_file_url(raw: &str) -> Result { let path = raw .trim() .strip_prefix("file:///") .ok_or_else(|| { SecurityError::InvalidLocalDashboard( "local dashboard presentation_url must be file:///".to_string(), ) })?; Ok(normalize_local_dashboard_path(path)) }