diff --git a/Cargo.lock b/Cargo.lock index 10ad05d6611..eea3e113f85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16417,6 +16417,14 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sandbox" +version = "0.1.0" +dependencies = [ + "anyhow", + "tempfile", +] + [[package]] name = "scc" version = "3.5.6" diff --git a/Cargo.toml b/Cargo.toml index 05f551db006..d3ba378098e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -173,6 +173,7 @@ members = [ "crates/rope", "crates/rpc", "crates/rules_library", + "crates/sandbox", "crates/skill_creator", "crates/scheduler", "crates/schema_generator", @@ -436,6 +437,7 @@ rpc = { path = "crates/rpc" } rules_library = { path = "crates/rules_library" } skill_creator = { path = "crates/skill_creator" } scheduler = { path = "crates/scheduler" } +sandbox = { path = "crates/sandbox" } search = { path = "crates/search" } session = { path = "crates/session" } sidebar = { path = "crates/sidebar" } diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml new file mode 100644 index 00000000000..9e7e4aa1e0d --- /dev/null +++ b/crates/sandbox/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "sandbox" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/sandbox.rs" + +[target.'cfg(target_os = "macos")'.dependencies] +anyhow.workspace = true +tempfile.workspace = true diff --git a/crates/sandbox/LICENSE-GPL b/crates/sandbox/LICENSE-GPL new file mode 120000 index 00000000000..89e542f750c --- /dev/null +++ b/crates/sandbox/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/sandbox/src/macos_seatbelt.rs b/crates/sandbox/src/macos_seatbelt.rs new file mode 100644 index 00000000000..0357ecb361b --- /dev/null +++ b/crates/sandbox/src/macos_seatbelt.rs @@ -0,0 +1,600 @@ +//! macOS Seatbelt sandbox integration. +//! +//! This module is specifically about Apple's Seatbelt sandbox API — the +//! macOS-only kernel-level sandboxing framework, accessed via the +//! `sandbox-exec(1)` command-line tool and a Seatbelt-specific config +//! file (a Scheme-like policy language documented in Apple's +//! `sandbox.h` and the `sandbox-exec` man page). +//! +//! The integration wraps a shell invocation by: +//! +//! 1. Generating a Seatbelt config file (a string of Scheme-like rules) +//! from the requested [`SandboxPermissions`]. +//! 2. Writing it to a temporary file on disk (a [`SeatbeltConfigFile`], +//! which cleans itself up when dropped). +//! 3. Returning the program/args needed to launch the original command +//! under `sandbox-exec -f `. +//! +//! Reads are permitted by default; writes are restricted to a caller- +//! provided list of directories; network access and unrestricted writes +//! must be opted into per command. + +use std::path::Path; +use std::{io::Write, path::PathBuf}; + +use anyhow::{Context, Result}; +use tempfile::NamedTempFile; + +/// Per-command relaxations of the default Seatbelt sandbox. +/// +/// All-false is the default, fully-sandboxed run. Setting any field +/// requires user approval before the command is launched. +/// +/// There are some baseline OS operations (e.g. arbitrary hardware access) +/// that are disallowed by Seatbelt's baseline policy regardless of these +/// flags; even with everything `true` here those operations stay denied. +/// The only way to allow them is to skip the sandbox entirely (which this +/// module deliberately doesn't expose). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SandboxPermissions { + /// Allow network access for the command. + pub allow_network: bool, + /// Allow unrestricted filesystem writes. + pub allow_fs_write: bool, +} + +/// A Seatbelt config file written to a temporary path on disk, suitable +/// for `sandbox-exec -f `. The file is deleted when this is dropped. +/// +/// The config-file content is the Scheme-like Seatbelt policy language +/// (see `sandbox-exec(1)` and the comments in macOS's `sandbox.h`); it's +/// generated from a [`SandboxPermissions`] by [`generate_seatbelt_config`]. +pub struct SeatbeltConfigFile { + /// The temporary file containing the Seatbelt config. + /// Kept alive so the file exists for the duration of the command. + _file: NamedTempFile, + /// Path to the temporary config file on disk. + path: PathBuf, +} + +impl SeatbeltConfigFile { + /// Generate a Seatbelt config from `permissions` and write it to a + /// fresh temporary file. + /// + /// `writable_directories` lists every directory subtree where the + /// command is allowed to write when `permissions.allow_fs_write` is + /// false. Pass the project's worktree paths here — not the working + /// directory of the command, since that is model-controlled and would + /// let the model widen its own writable scope. + pub fn new(writable_directories: &[&Path], permissions: SandboxPermissions) -> Result { + let mut file = + NamedTempFile::new().context("failed to create temporary Seatbelt config file")?; + + let config = generate_seatbelt_config(writable_directories, permissions)?; + file.write_all(config.as_bytes()) + .context("failed to write Seatbelt config")?; + file.flush().context("failed to flush Seatbelt config")?; + + let path = file.path().to_path_buf(); + + Ok(Self { _file: file, path }) + } +} + +/// Wrap a process invocation so it runs under macOS's `sandbox-exec(1)` +/// with a Seatbelt config built from `permissions`. +/// +/// Returns the new program and arguments to execute, along with a +/// [`SeatbeltConfigFile`] that **must** be kept alive for the duration of +/// the command (the file is deleted when dropped, and `sandbox-exec` reads +/// it lazily when the child process starts up). +/// +/// # Arguments +/// * `program` - The program to invoke (typically a shell, e.g. `"/bin/sh"`, +/// but anything that takes its arguments via `argv` works). +/// * `args` - The full argument list that would have been passed to +/// `program`. +/// * `writable_directories` - Directory subtrees where the command is +/// allowed to write when `permissions.allow_fs_write` is false. Pass +/// the project's worktree paths here, not the working directory of the +/// command (the working directory is model-controlled, and using it as +/// the writable scope would let the model write outside the project). +/// * `permissions` - Sandbox relaxations requested for this command. +/// +/// # Returns +/// A tuple of `(program, args, config_file)` where `config_file` must be +/// kept alive. +pub fn wrap_invocation( + program: &str, + args: &[String], + writable_directories: &[&Path], + permissions: SandboxPermissions, +) -> Result<(String, Vec, SeatbeltConfigFile)> { + let config_file = SeatbeltConfigFile::new(writable_directories, permissions)?; + + let mut wrapped_args = vec![ + "-f".to_string(), + config_file + .path + .to_str() + .with_context(|| { + format!( + "Seatbelt config file path contains invalid UTF-8: {}", + config_file.path.display() + ) + })? + .to_string(), + program.to_string(), + ]; + wrapped_args.extend(args.iter().cloned()); + + Ok(( + "/usr/bin/sandbox-exec".to_string(), + wrapped_args, + config_file, + )) +} + +/// Generate a Seatbelt config string that reads everywhere by default. +/// Writes to each entry in `writable_directories` (typically the project's +/// worktree paths plus any per-command scratch directory the caller wants +/// allowed) and the standard `/dev/*` write targets are also allowed by +/// default; network access and unrestricted filesystem writes must be +/// requested via [`SandboxPermissions`]. +/// +/// The returned string is the textual content to write to the +/// [`SeatbeltConfigFile`] passed to `sandbox-exec -f`. +fn generate_seatbelt_config( + writable_directories: &[&Path], + permissions: SandboxPermissions, +) -> Result { + // Canonicalize each writable path to resolve symlinks (e.g., + // /var -> /private/var on macOS). Fall back to the original path if + // canonicalization fails. + let canonical_writable_directories: Vec = writable_directories + .iter() + .map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf())) + .collect(); + + let mut config = r#"(version 1) + +; Start by denying everything +(deny default) + +; Allow reading from the entire filesystem +(allow file-read*) + +; Allow process execution +(allow process-exec*) +(allow process-fork) + +; Allow signal handling +(allow signal) + +; Allow sysctl reads (needed for many system calls) +(allow sysctl-read) + +; Allow mach lookups (needed for IPC) +(allow mach-lookup) + +; Allow pseudo-terminal operations +(allow pseudo-tty) +"# + .to_string(); + + if permissions.allow_fs_write { + config.push_str( + r#" +; Allow unrestricted filesystem writes +(allow file-write*) +"#, + ); + } else { + for canonical_path in &canonical_writable_directories { + let escaped_path = escape_sandbox_path(canonical_path)?; + config.push_str(&format!( + r#" +; Allow writing to a permitted directory +(allow file-write* + (subpath "{escaped_path}")) +"# + )); + } + + config.push_str( + r#" +; Allow writing to common /dev paths (needed for redirections like 2>/dev/null) +(allow file-write* + (literal "/dev/null") + (literal "/dev/zero") + (literal "/dev/tty") + (literal "/dev/stdin") + (literal "/dev/stdout") + (literal "/dev/stderr") + (subpath "/dev/fd")) +"#, + ); + } + + if permissions.allow_network { + config.push_str( + r#" +; Allow network access +(allow network*) +"#, + ); + } + + Ok(config) +} + +/// Escape a path for use in a Seatbelt config string. +/// +/// Seatbelt configs use a Scheme-like syntax where certain characters need +/// to be handled carefully. +fn escape_sandbox_path(path: &Path) -> Result { + let path_str = path + .to_str() + .with_context(|| format!("path contains invalid UTF-8: {}", path.display()))?; + Ok(path_str.replace('\\', "\\\\").replace('"', "\\\"")) +} + +#[cfg(test)] +#[allow( + clippy::disallowed_methods, + reason = "tests run sandbox-exec synchronously to verify the generated Seatbelt config" +)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_generate_seatbelt_config_contains_read_and_project_write_permissions_by_default() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let config = + generate_seatbelt_config(&[dir.as_path()], SandboxPermissions::default()).unwrap(); + + assert!(config.contains("(allow file-read*)")); + assert!(config.contains("/Users/test/projects/myproject")); + assert!(config.contains("(allow file-write*")); + assert!(!config.contains("; Allow unrestricted filesystem writes")); + assert!(!config.contains("(allow network*)")); + } + + #[test] + fn test_generate_seatbelt_config_allows_unrestricted_writes_when_fs_writes_allowed() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let config = generate_seatbelt_config( + &[dir.as_path()], + SandboxPermissions { + allow_network: false, + allow_fs_write: true, + }, + ) + .unwrap(); + + assert!(config.contains("(allow file-read*)")); + assert!(config.contains("; Allow unrestricted filesystem writes")); + assert!(config.contains("(allow file-write*)")); + assert!(!config.contains("/Users/test/projects/myproject")); + assert!(!config.contains("(allow network*)")); + } + + #[test] + fn test_generate_seatbelt_config_contains_network_when_allowed() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let config = generate_seatbelt_config( + &[dir.as_path()], + SandboxPermissions { + allow_network: true, + allow_fs_write: false, + }, + ) + .unwrap(); + + assert!(config.contains("(allow network*)")); + assert!(config.contains("/Users/test/projects/myproject")); + assert!(config.contains("(allow file-write*")); + assert!(!config.contains("; Allow unrestricted filesystem writes")); + } + + #[test] + fn test_generate_seatbelt_config_emits_one_subpath_per_writable_directory() { + let project_dir = PathBuf::from("/Users/test/projects/myproject"); + let scratch_dir = PathBuf::from("/private/tmp/zed-agent-command"); + let config = generate_seatbelt_config( + &[project_dir.as_path(), scratch_dir.as_path()], + SandboxPermissions::default(), + ) + .unwrap(); + + assert!(config.contains("/Users/test/projects/myproject")); + assert!(config.contains("/private/tmp/zed-agent-command")); + assert!(!config.contains("; Allow unrestricted filesystem writes")); + assert!(!config.contains("(allow network*)")); + } + + #[test] + fn test_escape_sandbox_path_handles_special_chars() { + let path = PathBuf::from("/path/with\"quotes"); + let escaped = escape_sandbox_path(&path).unwrap(); + assert_eq!(escaped, "/path/with\\\"quotes"); + } + + #[cfg(unix)] + #[test] + fn test_escape_sandbox_path_rejects_invalid_utf8() { + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + let path = PathBuf::from(OsString::from_vec(b"/path/with/invalid/\xFF".to_vec())); + let error = escape_sandbox_path(&path).unwrap_err(); + + assert!(error.to_string().contains("invalid UTF-8")); + } + + #[test] + fn test_wrap_invocation_structure() { + let temp_dir = tempfile::tempdir().unwrap(); + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &["-c".to_string(), "echo hello".to_string()], + &[temp_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + assert_eq!(program, "/usr/bin/sandbox-exec"); + assert_eq!(args[0], "-f"); + // args[1] is the temp file path + assert_eq!(args[2], "/bin/sh"); + assert_eq!(args[3], "-c"); + assert_eq!(args[4], "echo hello"); + } + + #[test] + fn test_sandbox_allows_read_everywhere() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &["-c".to_string(), "cat /etc/hosts".to_string()], + &[temp_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow reading /etc/hosts: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn test_sandbox_allows_dev_null_redirection_by_default() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &["-c".to_string(), "echo test 2>/dev/null".to_string()], + &[temp_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow redirecting to /dev/null by default: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn test_sandbox_allows_dev_null_redirection_when_fs_writes_allowed() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &["-c".to_string(), "echo test 2>/dev/null".to_string()], + &[temp_dir.path()], + SandboxPermissions { + allow_network: false, + allow_fs_write: true, + }, + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow redirecting to /dev/null: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn test_sandbox_allows_write_to_project_directory_when_fs_writes_allowed() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let test_file = temp_dir.path().join("test_write.txt"); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", test_file.display()), + ], + &[temp_dir.path()], + SandboxPermissions { + allow_network: false, + allow_fs_write: true, + }, + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow writing to project dir: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(test_file.exists(), "file should have been created"); + } + + #[test] + fn test_sandbox_allows_write_to_any_listed_writable_directory() { + use std::process::Command; + + let project_dir = tempfile::tempdir().unwrap(); + let scratch_dir = tempfile::tempdir().unwrap(); + let test_file = scratch_dir.path().join("test_write.txt"); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", test_file.display()), + ], + &[project_dir.path(), scratch_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow writing to a non-first writable directory: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(test_file.exists(), "file should have been created"); + } + + #[test] + fn test_sandbox_allows_write_to_project_directory_by_default() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let test_file = temp_dir.path().join("test_write.txt"); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", test_file.display()), + ], + &[temp_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow writing to project dir by default: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(test_file.exists(), "file should have been created"); + } + + #[test] + fn test_sandbox_allows_write_to_system_tmp_when_fs_writes_allowed() { + use std::process::Command; + + let project_dir = tempfile::tempdir().unwrap(); + let test_file = PathBuf::from("/tmp/zed-sandbox-write-test"); + let _ = std::fs::remove_file(&test_file); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", test_file.display()), + ], + &[project_dir.path()], + SandboxPermissions { + allow_network: false, + allow_fs_write: true, + }, + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow writing to system tmp when filesystem writes are allowed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(test_file.exists(), "file should have been created"); + let _ = std::fs::remove_file(&test_file); + } + + #[test] + fn test_sandbox_denies_write_outside_project_directory_by_default() { + use std::process::Command; + + let project_dir = tempfile::tempdir().unwrap(); + let forbidden_file = std::env::home_dir() + .unwrap() + .join(".zed-sandbox-forbidden-write-test"); + let _ = std::fs::remove_file(&forbidden_file); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", forbidden_file.display()), + ], + &[project_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + !output.status.success(), + "sandbox should deny writing outside project dir when filesystem writes are not allowed" + ); + assert!( + !forbidden_file.exists(), + "file should not have been created" + ); + } +} diff --git a/crates/sandbox/src/sandbox.rs b/crates/sandbox/src/sandbox.rs new file mode 100644 index 00000000000..64095d11da2 --- /dev/null +++ b/crates/sandbox/src/sandbox.rs @@ -0,0 +1,12 @@ +//! Per-OS sandbox integrations for terminal commands run on behalf of the +//! agent. +//! +//! Each supported operating system has its own module here, gated behind +//! its `target_os` cfg so callers reach for the right one explicitly and +//! non-host targets don't carry dead code. +//! +//! Today only macOS has an integration ([`macos_seatbelt`]), wrapping +//! Apple's Seatbelt / `sandbox-exec` framework. + +#[cfg(target_os = "macos")] +pub mod macos_seatbelt;