Consolidate the browser task runtime around the callback path, add safer artifact opening for Zhihu exports, and cover the new service/browser flows with focused tests and supporting docs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
209 lines
6.3 KiB
Rust
209 lines
6.3 KiB
Rust
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<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PipeActionRules {
|
|
pub allowed: Vec<String>,
|
|
#[serde(default)]
|
|
pub blocked: Vec<String>,
|
|
}
|
|
|
|
const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
|
|
|
|
impl MacPolicy {
|
|
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, SecurityError> {
|
|
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<Self, SecurityError> {
|
|
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<String, SecurityError> {
|
|
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))
|
|
}
|