use crate::policy::sandbox_edit;
use crate::policy::sandbox_types::{Cap, NetworkPolicy, PathMatch, RuleEffect, SandboxPolicy};
use crate::sandbox;
use crate::style;
use anyhow::{Context, Result};
use clap::Subcommand;
use tracing::{Level, info, instrument};
#[derive(Subcommand, Debug)]
pub enum SandboxCmd {
Exec {
#[arg(long)]
sandbox: Option<String>,
#[arg(long, default_value = ".")]
cwd: String,
#[arg(long)]
session_id: Option<String>,
#[arg(long)]
tool_use_id: Option<String>,
#[arg(trailing_var_arg = true)]
command: Vec<String>,
},
Test {
#[arg(long)]
sandbox: Option<String>,
#[arg(long, default_value = ".")]
cwd: String,
#[arg(trailing_var_arg = true)]
command: Vec<String>,
},
Check,
Create {
name: String,
#[arg(long)]
default: String,
#[arg(long, default_value = "deny")]
network: String,
#[arg(long)]
doc: Option<String>,
#[arg(long)]
scope: Option<String>,
},
Delete {
name: String,
#[arg(long)]
scope: Option<String>,
},
#[command(name = "list")]
ListSandboxes {
#[arg(long)]
json: bool,
},
#[command(name = "add-rule")]
AddRule {
name: String,
#[arg(long, group = "effect_group")]
allow: Option<String>,
#[arg(long, group = "effect_group")]
deny: Option<String>,
#[arg(long)]
path: String,
#[arg(long, default_value = "subpath")]
path_match: String,
#[arg(long)]
doc: Option<String>,
#[arg(long)]
scope: Option<String>,
},
#[command(name = "remove-rule")]
RemoveRule {
name: String,
#[arg(long)]
path: String,
#[arg(long)]
scope: Option<String>,
},
}
pub(crate) fn resolve_cwd(cwd: &str) -> Result<String> {
let path = std::path::Path::new(cwd);
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.context("failed to determine current directory")?
.join(path)
};
Ok(abs.to_string_lossy().into_owned())
}
fn resolve_sandbox_policy(sandbox_arg: Option<&str>, cwd: &str) -> Result<SandboxPolicy> {
match sandbox_arg {
Some(val) if val.starts_with('{') => {
serde_json::from_str(val).context("failed to parse --sandbox JSON")
}
Some(name) => load_sandbox_for_profile(name, cwd),
None => load_sandbox_for_profile("", cwd),
}
}
fn load_sandbox_for_profile(profile_name: &str, _cwd: &str) -> Result<SandboxPolicy> {
use crate::settings::ClashSettings;
let path = ClashSettings::policy_file()?;
let json = crate::settings::evaluate_policy_file(&path)
.with_context(|| format!("failed to evaluate {}", path.display()))?;
let policy = crate::policy::compile::compile_to_tree(&json)
.with_context(|| format!("failed to compile {}", path.display()))?;
policy.sandboxes.get(profile_name).cloned().ok_or_else(|| {
anyhow::anyhow!(
"policy has no sandbox named '{}' (available: {:?})",
profile_name,
policy.sandboxes.keys().collect::<Vec<_>>()
)
})
}
#[instrument(level = Level::TRACE)]
pub fn run_sandbox(cmd: SandboxCmd) -> Result<()> {
match cmd {
SandboxCmd::Exec {
sandbox,
cwd,
session_id,
tool_use_id,
command,
} => {
let cwd = resolve_cwd(&cwd)?;
let sandbox_policy = resolve_sandbox_policy(sandbox.as_deref(), &cwd)?;
let cwd_path = std::path::PathBuf::from(&cwd);
run_sandboxed_command(
&sandbox_policy,
&cwd_path,
&command,
session_id.as_deref(),
tool_use_id.as_deref(),
)
}
SandboxCmd::Test {
sandbox,
cwd,
command,
} => {
let cwd = resolve_cwd(&cwd)?;
let sandbox_policy = resolve_sandbox_policy(sandbox.as_deref(), &cwd)?;
let cwd_path = std::path::PathBuf::from(&cwd);
eprintln!("Testing sandbox with policy:");
eprintln!(" default: {}", sandbox_policy.default.display());
eprintln!(" network: {:?}", sandbox_policy.network);
for rule in &sandbox_policy.rules {
eprintln!(" {:?} {} in {}", rule.effect, rule.caps.short(), rule.path);
}
eprintln!(" command: {:?}", command);
eprintln!("---");
run_sandboxed_command(&sandbox_policy, &cwd_path, &command, None, None)
}
SandboxCmd::Check => {
let support = sandbox::check_support();
match support {
sandbox::SupportLevel::Full => {
println!("Sandbox: fully supported");
}
sandbox::SupportLevel::Partial { missing } => {
println!("Sandbox: partially supported");
for m in &missing {
println!(" missing: {}", m);
}
}
sandbox::SupportLevel::Unsupported { reason } => {
println!("Sandbox: not supported ({})", reason);
std::process::exit(1);
}
}
Ok(())
}
SandboxCmd::Create {
name,
default,
network,
doc,
scope,
} => handle_create(&name, &default, &network, doc, scope),
SandboxCmd::Delete { name, scope } => handle_delete(&name, scope),
SandboxCmd::ListSandboxes { json } => handle_list_sandboxes(json),
SandboxCmd::AddRule {
name,
allow,
deny,
path,
path_match,
doc,
scope,
} => handle_add_rule(&name, allow, deny, &path, &path_match, doc, scope),
SandboxCmd::RemoveRule { name, path, scope } => handle_remove_rule(&name, &path, scope),
}
}
fn handle_create(
name: &str,
default: &str,
network: &str,
doc: Option<String>,
scope: Option<String>,
) -> Result<()> {
let path = crate::cmd::policy::resolve_manifest_path(scope)?;
let mut manifest = crate::policy_loader::read_manifest(&path)?;
let caps = Cap::parse(default).map_err(|e| anyhow::anyhow!("{e}"))?;
let net = parse_network_policy(network)?;
sandbox_edit::create_sandbox(&mut manifest, name, caps, net, doc)?;
crate::policy_loader::write_manifest(&path, &manifest)?;
println!("{} Sandbox '{}' created", style::green_bold("✓"), name);
println!(" {}", style::dim(&path.display().to_string()));
Ok(())
}
fn handle_delete(name: &str, scope: Option<String>) -> Result<()> {
let path = crate::cmd::policy::resolve_manifest_path(scope)?;
let mut manifest = crate::policy_loader::read_manifest(&path)?;
sandbox_edit::delete_sandbox(&mut manifest, name)?;
crate::policy_loader::write_manifest(&path, &manifest)?;
println!("{} Sandbox '{}' deleted", style::green_bold("✓"), name);
println!(" {}", style::dim(&path.display().to_string()));
Ok(())
}
fn handle_list_sandboxes(json: bool) -> Result<()> {
let settings = crate::settings::ClashSettings::load_or_create()?;
let policy = match settings.policy_tree() {
Some(t) => t,
None => {
if let Some(err) = settings.policy_error() {
anyhow::bail!("{}", err);
}
anyhow::bail!("no policy configured — run `clash init`");
}
};
if json {
let output = serde_json::to_string_pretty(&policy.sandboxes)?;
println!("{output}");
} else if policy.sandboxes.is_empty() {
println!("No sandboxes defined.");
} else {
for (name, sb) in &policy.sandboxes {
println!(
"{} default={}, network={:?}, {} rules",
style::cyan(name),
sb.default.display(),
sb.network,
sb.rules.len(),
);
if let Some(ref doc) = sb.doc {
println!(" {}", style::dim(doc));
}
for rule in &sb.rules {
println!(
" {:?} {} in {}{}",
rule.effect,
rule.caps.short(),
rule.path,
match rule.path_match {
PathMatch::Subpath => "",
PathMatch::Literal => " (literal)",
PathMatch::Regex => " (regex)",
},
);
}
}
}
Ok(())
}
fn handle_add_rule(
name: &str,
allow: Option<String>,
deny: Option<String>,
path: &str,
path_match: &str,
doc: Option<String>,
scope: Option<String>,
) -> Result<()> {
let (effect, caps_str) = match (allow, deny) {
(Some(caps), None) => (RuleEffect::Allow, caps),
(None, Some(caps)) => (RuleEffect::Deny, caps),
_ => anyhow::bail!("provide exactly one of --allow or --deny with capabilities"),
};
let caps = Cap::parse(&caps_str).map_err(|e| anyhow::anyhow!("{e}"))?;
let pm = parse_path_match(path_match)?;
let manifest_path = crate::cmd::policy::resolve_manifest_path(scope)?;
let mut manifest = crate::policy_loader::read_manifest(&manifest_path)?;
let result = sandbox_edit::add_rule(&mut manifest, name, effect, caps, path.into(), pm, doc)?;
crate::policy_loader::write_manifest(&manifest_path, &manifest)?;
match result {
sandbox_edit::UpsertResult::Inserted => {
println!(
"{} Rule added to sandbox '{}'",
style::green_bold("✓"),
name
)
}
sandbox_edit::UpsertResult::Replaced => println!(
"{} Rule updated in sandbox '{}'",
style::green_bold("✓"),
name
),
}
println!(" {}", style::dim(&manifest_path.display().to_string()));
Ok(())
}
fn handle_remove_rule(name: &str, path: &str, scope: Option<String>) -> Result<()> {
let manifest_path = crate::cmd::policy::resolve_manifest_path(scope)?;
let mut manifest = crate::policy_loader::read_manifest(&manifest_path)?;
if sandbox_edit::remove_rule(&mut manifest, name, path)? {
crate::policy_loader::write_manifest(&manifest_path, &manifest)?;
println!(
"{} Rule removed from sandbox '{}'",
style::green_bold("✓"),
name
);
println!(" {}", style::dim(&manifest_path.display().to_string()));
} else {
println!("No rule matching path '{}' in sandbox '{}'", path, name);
}
Ok(())
}
fn parse_network_policy(s: &str) -> Result<NetworkPolicy> {
match s {
"deny" => Ok(NetworkPolicy::Deny),
"allow" => Ok(NetworkPolicy::Allow),
"localhost" => Ok(NetworkPolicy::Localhost),
other => {
anyhow::bail!("unknown network policy '{other}' (expected: deny, allow, localhost)")
}
}
}
fn parse_path_match(s: &str) -> Result<PathMatch> {
match s {
"subpath" => Ok(PathMatch::Subpath),
"literal" => Ok(PathMatch::Literal),
"regex" => Ok(PathMatch::Regex),
other => anyhow::bail!("unknown path_match '{other}' (expected: subpath, literal, regex)"),
}
}
pub(crate) fn run_sandboxed_command(
policy: &SandboxPolicy,
cwd: &std::path::Path,
command: &[String],
session_id: Option<&str>,
tool_use_id: Option<&str>,
) -> Result<()> {
#[cfg(target_os = "macos")]
{
spawn_and_capture_macos(policy, cwd, command, session_id, tool_use_id)
}
#[cfg(not(target_os = "macos"))]
{
let _ = (session_id, tool_use_id);
exec_with_proxy(policy, cwd, command)
}
}
#[cfg(target_os = "macos")]
fn spawn_and_capture_macos(
policy: &SandboxPolicy,
cwd: &std::path::Path,
command: &[String],
session_id: Option<&str>,
tool_use_id: Option<&str>,
) -> Result<()> {
let profile = sandbox::compile_sandbox_profile(policy, cwd)
.context("failed to compile sandbox profile")?;
let proxy_handle = match &policy.network {
NetworkPolicy::AllowDomains(domains) => {
let handle = sandbox::proxy::start_proxy(sandbox::proxy::ProxyConfig {
allowed_domains: domains.clone(),
})
.context("failed to start domain-filtering proxy")?;
info!(addr = %handle.addr, "started domain-filtering proxy");
Some(handle)
}
_ => None,
};
let mut cmd = std::process::Command::new("sandbox-exec");
cmd.args(["-p", &profile, "--"]);
cmd.args(command);
cmd.current_dir(cwd);
cmd.stdin(std::process::Stdio::inherit());
cmd.stdout(std::process::Stdio::inherit());
cmd.stderr(std::process::Stdio::inherit());
if let Some(ref handle) = proxy_handle {
let proxy_url = format!("http://{}", handle.addr);
cmd.env("HTTP_PROXY", &proxy_url)
.env("HTTPS_PROXY", &proxy_url)
.env("http_proxy", &proxy_url)
.env("https_proxy", &proxy_url);
}
let start = std::time::Instant::now();
let mut child = cmd.spawn().context("failed to spawn sandbox-exec")?;
let child_pid = child.id();
let status = child
.wait()
.context("failed to wait for sandboxed process")?;
let elapsed = start.elapsed();
drop(proxy_handle);
if let (Some(sid), Some(tuid)) = (session_id, tool_use_id) {
capture_and_log_violations(child_pid, elapsed, sid, tuid, command);
}
let code = status.code().unwrap_or(1);
if code != 0 {
std::process::exit(code);
}
Ok(())
}
#[cfg(target_os = "macos")]
fn capture_and_log_violations(
_child_pid: u32,
elapsed: std::time::Duration,
session_id: &str,
tool_use_id: &str,
command: &[String],
) {
std::thread::sleep(std::time::Duration::from_millis(150));
let last_secs = elapsed.as_secs() + 3; let last_arg = format!("{}s", last_secs.max(5));
let predicate =
"eventMessage CONTAINS \"deny\" AND eventMessage CONTAINS \"file-\"".to_string();
info!(
predicate = %predicate,
last = %last_arg,
"Querying unified log for sandbox violations"
);
let output = match std::process::Command::new("log")
.args([
"show",
"--last",
&last_arg,
"--predicate",
&predicate,
"--style",
"compact",
"--no-pager",
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
{
Ok(o) if o.status.success() => o,
Ok(o) => {
info!(exit_code = ?o.status.code(), "log show returned non-zero");
return;
}
Err(e) => {
info!(error = %e, "Failed to run log show");
return;
}
};
let content = String::from_utf8_lossy(&output.stdout);
let violations = parse_log_violations(&content);
if violations.is_empty() {
return;
}
info!(
count = violations.len(),
"Captured sandbox violations from unified log"
);
let tool_input_summary = if command.len() <= 3 {
command.join(" ")
} else {
format!("{} {} {} ...", command[0], command[1], command[2])
};
crate::audit::log_sandbox_violations(
session_id,
"Bash",
tool_use_id,
&tool_input_summary,
&violations,
);
}
#[cfg(target_os = "macos")]
const NOISE_PATH_PREFIXES: &[&str] = &["/dev/dtrace", "/dev/dtracehelper", "/dev/oslog"];
#[cfg(target_os = "macos")]
fn parse_log_violations(content: &str) -> Vec<crate::audit::SandboxViolation> {
let re = match regex::Regex::new(r"deny\(\d+\)\s+(file-\S+)\s+(/\S+)") {
Ok(re) => re,
Err(_) => return Vec::new(),
};
let mut violations = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for line in content.lines() {
if !line.contains("deny") || !line.contains("file-") {
continue;
}
for cap in re.captures_iter(line) {
if let (Some(op), Some(path)) = (cap.get(1), cap.get(2)) {
let path_str = path.as_str().to_string();
if path_str.starts_with('/')
&& !NOISE_PATH_PREFIXES
.iter()
.any(|prefix| path_str.starts_with(prefix))
&& seen.insert(path_str.clone())
{
violations.push(crate::audit::SandboxViolation {
operation: op.as_str().to_string(),
path: path_str,
});
}
}
}
}
violations
}
#[cfg(not(target_os = "macos"))]
fn exec_with_proxy(
policy: &SandboxPolicy,
cwd: &std::path::Path,
command: &[String],
) -> Result<()> {
match &policy.network {
NetworkPolicy::Localhost => {
match sandbox::exec_sandboxed(policy, cwd, command, None) {
Err(e) => anyhow::bail!("sandbox exec failed: {}", e),
}
}
NetworkPolicy::AllowDomains(domains) => {
let proxy_handle = sandbox::proxy::start_proxy(sandbox::proxy::ProxyConfig {
allowed_domains: domains.clone(),
})
.context("failed to start domain-filtering proxy")?;
let proxy_url = format!("http://{}", proxy_handle.addr);
info!(addr = %proxy_handle.addr, "started domain-filtering proxy for exec");
let pid = unsafe { libc::fork() };
match pid {
-1 => {
anyhow::bail!("fork failed: {}", std::io::Error::last_os_error());
}
0 => {
unsafe {
set_env_cstr("HTTP_PROXY", &proxy_url);
set_env_cstr("HTTPS_PROXY", &proxy_url);
set_env_cstr("http_proxy", &proxy_url);
set_env_cstr("https_proxy", &proxy_url);
}
match sandbox::exec_sandboxed(policy, cwd, command, None) {
Err(e) => {
eprintln!("sandbox exec failed: {}", e);
std::process::exit(1);
}
}
}
child_pid => {
let mut status: libc::c_int = 0;
unsafe {
libc::waitpid(child_pid, &mut status, 0);
}
drop(proxy_handle);
if libc::WIFEXITED(status) {
let code = libc::WEXITSTATUS(status);
if code != 0 {
std::process::exit(code);
}
} else {
std::process::exit(1);
}
Ok(())
}
}
}
_ => {
match sandbox::exec_sandboxed(policy, cwd, command, None) {
Err(e) => anyhow::bail!("sandbox exec failed: {}", e),
}
}
}
}
#[cfg(not(target_os = "macos"))]
unsafe fn set_env_cstr(key: &str, val: &str) {
use std::ffi::CString;
if let (Ok(k), Ok(v)) = (CString::new(key), CString::new(val)) {
unsafe { libc::setenv(k.as_ptr(), v.as_ptr(), 1) };
}
}
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
#[test]
fn test_parse_log_violations_basic() {
let log = "2025-01-15 10:00:00.123 sandboxd Sandbox: bash(12345) deny(1) file-write-create /Users/user/.fly/perms.123";
let violations = parse_log_violations(log);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].operation, "file-write-create");
assert_eq!(violations[0].path, "/Users/user/.fly/perms.123");
}
#[test]
fn test_parse_log_violations_multiple() {
let log = "2025-01-15 10:00:00.123 sandboxd Sandbox: bash(12345) deny(1) file-write-create /Users/user/.fly/config\n\
2025-01-15 10:00:00.456 sandboxd Sandbox: bash(12345) deny(1) file-read-data /Users/user/.cache/db";
let violations = parse_log_violations(log);
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].operation, "file-write-create");
assert_eq!(violations[0].path, "/Users/user/.fly/config");
assert_eq!(violations[1].operation, "file-read-data");
assert_eq!(violations[1].path, "/Users/user/.cache/db");
}
#[test]
fn test_parse_log_violations_deduplicates() {
let log = "2025-01-15 10:00:00.123 sandboxd Sandbox: bash(12345) deny(1) file-write-create /tmp/foo\n\
2025-01-15 10:00:00.456 sandboxd Sandbox: bash(12345) deny(1) file-write-data /tmp/foo";
let violations = parse_log_violations(log);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].path, "/tmp/foo");
}
#[test]
fn test_parse_log_violations_no_denies() {
let log = "2025-01-15 10:00:00.123 sandboxd Sandbox: bash(12345) allow file-read-data /usr/lib/libSystem.B.dylib";
let violations = parse_log_violations(log);
assert!(violations.is_empty());
}
#[test]
fn test_parse_log_violations_ignores_non_file() {
let log = "2025-01-15 10:00:00.123 sandboxd Sandbox: bash(12345) deny(1) network-outbound 1.2.3.4:443\n\
2025-01-15 10:00:00.456 sandboxd Sandbox: bash(12345) deny(1) file-write-create /Users/user/.fly/perms";
let violations = parse_log_violations(log);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].path, "/Users/user/.fly/perms");
}
#[test]
fn test_parse_log_violations_empty_input() {
let violations = parse_log_violations("");
assert!(violations.is_empty());
}
#[test]
fn test_parse_log_violations_filters_noise_paths() {
let log = "2025-01-15 10:00:00.123 sandboxd Sandbox: bash(12345) deny(1) file-read-data /dev/dtracehelper\n\
2025-01-15 10:00:00.456 sandboxd Sandbox: bash(12345) deny(1) file-write-create /Users/user/.fly/config";
let violations = parse_log_violations(log);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].path, "/Users/user/.fly/config");
}
#[test]
fn test_parse_log_violations_filters_all_noise() {
let log = "2025-01-15 10:00:00.123 sandboxd Sandbox: bash(12345) deny(1) file-read-data /dev/dtracehelper\n\
2025-01-15 10:00:00.456 sandboxd Sandbox: bash(12345) deny(1) file-read-data /dev/oslog/foo";
let violations = parse_log_violations(log);
assert!(violations.is_empty());
}
}