mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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:
parent
6e58a92713
commit
5ec1ce7cd0
6 changed files with 639 additions and 0 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
16
crates/sandbox/Cargo.toml
Normal 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
1
crates/sandbox/LICENSE-GPL
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../LICENSE-GPL
|
||||||
600
crates/sandbox/src/macos_seatbelt.rs
Normal file
600
crates/sandbox/src/macos_seatbelt.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/sandbox/src/sandbox.rs
Normal file
12
crates/sandbox/src/sandbox.rs
Normal 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;
|
||||||
Loading…
Reference in a new issue