Add sandbox crate with macOS Seatbelt integration (#57429)

Adds a new `sandbox` crate that wraps shell invocations under macOS's
`sandbox-exec(1)` with a Seatbelt policy built from per-command
permissions:

- Reads are unrestricted.
- Writes are restricted to a caller-provided list of directories (plus
the standard `/dev/*` write targets).
- Network access and unrestricted filesystem writes must be opted into
per command.

`wrap_invocation(program, args, writable_dirs, permissions)` returns the
new program/args plus a `SeatbeltConfigFile` RAII handle that deletes
the on-disk policy file when dropped — callers hold it for the lifetime
of the spawned command.

No callers yet — this is the first of three stacked PRs. The second
wires the sandbox state into the agent's system prompt behind a feature
flag; the third wires the actual wrapping into the agent terminal tool.

The macOS-only dependencies (`tempfile`, `anyhow`) are gated by
`target.'cfg(target_os = "macos")'` so the crate is empty on other
platforms.

Includes 14 tests covering both the generated Seatbelt policy text and
end-to-end behavior (actually invoking `sandbox-exec` and asserting
reads/writes succeed or fail per policy).

Release Notes:

- N/A
This commit is contained in:
Richard Feldman 2026-05-27 16:35:32 -04:00 committed by GitHub
parent 6e58a92713
commit 5ec1ce7cd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 639 additions and 0 deletions

8
Cargo.lock generated
View file

@ -16417,6 +16417,14 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "sandbox"
version = "0.1.0"
dependencies = [
"anyhow",
"tempfile",
]
[[package]] [[package]]
name = "scc" name = "scc"
version = "3.5.6" version = "3.5.6"

View file

@ -173,6 +173,7 @@ members = [
"crates/rope", "crates/rope",
"crates/rpc", "crates/rpc",
"crates/rules_library", "crates/rules_library",
"crates/sandbox",
"crates/skill_creator", "crates/skill_creator",
"crates/scheduler", "crates/scheduler",
"crates/schema_generator", "crates/schema_generator",
@ -436,6 +437,7 @@ rpc = { path = "crates/rpc" }
rules_library = { path = "crates/rules_library" } rules_library = { path = "crates/rules_library" }
skill_creator = { path = "crates/skill_creator" } skill_creator = { path = "crates/skill_creator" }
scheduler = { path = "crates/scheduler" } scheduler = { path = "crates/scheduler" }
sandbox = { path = "crates/sandbox" }
search = { path = "crates/search" } search = { path = "crates/search" }
session = { path = "crates/session" } session = { path = "crates/session" }
sidebar = { path = "crates/sidebar" } sidebar = { path = "crates/sidebar" }

16
crates/sandbox/Cargo.toml Normal file
View file

@ -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

1
crates/sandbox/LICENSE-GPL Symbolic link
View file

@ -0,0 +1 @@
../../LICENSE-GPL

View file

@ -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 <config-path>`.
//!
//! 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 <path>`. 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<Self> {
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<String>, 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<String> {
// 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<PathBuf> = 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<String> {
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"
);
}
}

View file

@ -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;