From e2b15e11e281039344d26c1dfdcf7f6d04877bb4 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 23 Jun 2026 11:20:21 -0400 Subject: [PATCH] Allow sandboxed Git writes from worktrees (#57981) Agent terminal sandboxing now protects `.git` metadata by default and exposes an explicit `allow_git_access` approval. Without it, file contents of the `.git` directories for opened worktrees and discovered repositories (including a linked worktree's common `.git`) cannot be read or written, though their metadata stays visible; when approved, those Git directories become writable so commands like fetch/commit work. SSH commit signing keeps working because the inherited `SSH_AUTH_SOCK` is allowed as local Unix-socket IPC, which does not let sandboxed commands send network packets to other machines. The Seatbelt profile also allows PTY terminal-control ioctls so signing/passphrase prompts can manage terminal state. This intentionally restricts only `.git` itself (whose location we know exactly, including the worktree case) rather than `.gitignore`/`.gitattributes`/`.gitmodules`, since those can be nested arbitrarily and the goal is to keep the policy expressible as a plain deny-by-default allowlist that will port to Linux and Windows sandboxes later. Closes AI-334 Release Notes: - Improved agent terminal sandboxing for Git metadata, Git worktrees, and SSH commit signing. --- Cargo.lock | 1 + crates/acp_thread/Cargo.toml | 1 + crates/acp_thread/src/acp_thread.rs | 84 ++++ crates/acp_thread/src/terminal.rs | 86 +++- crates/agent/src/sandboxing.rs | 42 ++ crates/agent/src/templates.rs | 7 + crates/agent/src/templates/system_prompt.hbs | 7 +- crates/agent/src/thread.rs | 6 + crates/agent/src/tools/terminal_tool.rs | 194 ++++++++- crates/agent_settings/src/agent_settings.rs | 5 + .../src/conversation_view/thread_view.rs | 32 ++ crates/sandbox/src/bwrap_test_helper.rs | 2 + crates/sandbox/src/linux_bubblewrap.rs | 309 +++++++++++++-- crates/sandbox/src/macos_seatbelt.rs | 373 +++++++++++++++++- crates/sandbox/src/sandbox.rs | 56 +++ crates/settings_content/src/agent.rs | 13 + .../settings_ui/src/pages/sandbox_settings.rs | 27 ++ 17 files changed, 1168 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 539dfa0857..56940017e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,6 +127,7 @@ dependencies = [ "settings", "task", "telemetry", + "tempfile", "terminal", "text", "ui", diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index ebdd6aa979..d92fd6b1fe 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -61,5 +61,6 @@ indoc.workspace = true parking_lot.workspace = true project = { workspace = true, "features" = ["test-support"] } rand.workspace = true +tempfile.workspace = true util.workspace = true settings.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 0bf2f0e49f..e11e9cbe59 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -60,6 +60,45 @@ impl std::fmt::Display for MaxOutputTokensError { impl std::error::Error for MaxOutputTokensError {} +/// Resolve the socket path(s) the sandbox may allow for the inherited +/// `SSH_AUTH_SOCK`. The value is validated against the resolved directory +/// environment the child command will actually run with, not Zed's own process +/// environment, since the two can differ (direnv, a login shell, etc.). +/// +/// Returns both the path as the child will pass it to `connect()` and its +/// canonical form, because the two diverge when `SSH_AUTH_SOCK` points through +/// a symlink (e.g. `/tmp` -> `/private/tmp` on macOS) and it isn't guaranteed +/// which form Seatbelt matches the `remote unix-socket` literal against. +#[cfg(unix)] +fn trusted_ssh_auth_socket_paths(path: impl Into) -> Vec { + let path = path.into(); + if !path.is_absolute() { + return Vec::new(); + } + + let Ok(canonical) = path.canonicalize() else { + return Vec::new(); + }; + let Ok(metadata) = std::fs::metadata(&canonical) else { + return Vec::new(); + }; + use std::os::unix::fs::FileTypeExt as _; + if !metadata.file_type().is_socket() { + return Vec::new(); + } + + if canonical == path { + vec![canonical] + } else { + vec![path, canonical] + } +} + +#[cfg(not(unix))] +fn trusted_ssh_auth_socket_paths(_path: impl Into) -> Vec { + Vec::new() +} + /// Key used in ACP ToolCall meta to store the tool's programmatic name. /// This is a workaround since ACP's ToolCall doesn't have a dedicated name field. pub const TOOL_NAME_META_KEY: &str = "tool_name"; @@ -176,6 +215,8 @@ pub struct SandboxAuthorizationDetails { #[serde(default, alias = "network")] pub network_all_hosts: bool, #[serde(default)] + pub allow_git_access: bool, + #[serde(default)] pub allow_fs_write_all: bool, #[serde(default)] pub unsandboxed: bool, @@ -3489,6 +3530,21 @@ impl AcpThread { let terminal_id = terminal_id.clone(); async move |_this, cx| { let mut env = env.await; + let mut sandbox_wrap = sandbox_wrap; + // Only expose the inherited SSH agent socket once Git access has + // been approved. The workflow that needs it (commit signing) + // already requires writing to `.git`, so gating it here keeps the + // agent socket out of reach of ordinary sandboxed commands. + // Validate the value the child will actually connect to, taken + // from its resolved environment rather than Zed's own. + if let Some(sandbox_wrap) = &mut sandbox_wrap + && sandbox_wrap.allow_git_access + && let Some(ssh_auth_socket) = env.get("SSH_AUTH_SOCK") + { + sandbox_wrap + .allowed_unix_socket_paths + .extend(trusted_ssh_auth_socket_paths(ssh_auth_socket.clone())); + } let shell = project .update(cx, |project, cx| { project @@ -3898,6 +3954,34 @@ mod tests { }); } + #[cfg(unix)] + #[test] + fn test_trusted_ssh_auth_socket_path_requires_socket() { + use std::os::unix::net::UnixListener; + + // Bind under `/tmp`: the default temp dir on macOS (`/var/folders/...`) + // overflows the `sun_path` limit for Unix sockets. `TempDir` still + // cleans up on drop, even if the test panics. + let temp_dir = tempfile::Builder::new() + .prefix("zed-sock-") + .tempdir_in("/tmp") + .expect("temporary socket directory should be created"); + let socket_path = temp_dir.path().join("agent.sock"); + let listener = UnixListener::bind(&socket_path).expect("test socket should bind"); + + // A real socket resolves to (at least) its canonical path. + let canonical = socket_path + .canonicalize() + .expect("socket path should canonicalize"); + assert!(trusted_ssh_auth_socket_paths(socket_path).contains(&canonical)); + + // A directory is not a socket, and a relative path is rejected outright. + assert!(trusted_ssh_auth_socket_paths(temp_dir.path().to_path_buf()).is_empty()); + assert!(trusted_ssh_auth_socket_paths("relative.sock").is_empty()); + + drop(listener); + } + #[test] fn sandbox_authorization_details_deserialize_legacy_network_bool() { // Older builds persisted `network: bool`; the `alias` on diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index d3e6a7bbfe..975d360e1a 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -35,9 +35,10 @@ use util::get_default_system_shell_preferring_bash; #[derive(Clone, Debug, Default)] pub struct SandboxWrap { /// Directory subtrees the sandbox should allow writes to. Pass the - /// project's worktree paths (and any per-command scratch directory) - /// here — *not* the command's working directory, which is model- - /// controlled and would let the model widen its own writable scope. + /// project's worktree paths, Git metadata directories only when Git access + /// has been approved, and any per-command scratch directory here — *not* + /// the command's working directory, which is model-controlled and would + /// let the model widen its own writable scope. pub writable_paths: Vec, /// Additional write subtrees the user explicitly approved for this /// command (per-path write grants). Kept separate from `writable_paths` @@ -45,6 +46,17 @@ pub struct SandboxWrap { /// model-requested paths that passed a user-approval prompt. They are /// merged with `writable_paths` when generating the sandbox policy. pub extra_write_paths: Vec, + /// Paths whose file data reads and writes should be blocked even when they + /// are inside a writable project directory. Metadata reads remain allowed. + pub protected_paths: Vec, + /// Unix domain sockets the sandbox should allow local IPC to. These come + /// from trusted process environment, not from the model-controlled command. + /// This does not permit IP networking or sending packets to other machines. + pub allowed_unix_socket_paths: Vec, + /// Whether the user approved Git metadata access for this command. When set, + /// `.git` directories are writable (they are absent from `protected_paths`) + /// and the inherited SSH agent socket may be exposed for commit signing. + pub allow_git_access: bool, /// Outbound network access explicitly approved for this command. pub network: SandboxNetworkAccess, /// Allow unrestricted filesystem writes (ignores all writable paths). @@ -143,21 +155,37 @@ impl SandboxWrap { .chain(self.extra_write_paths.iter()) .map(|path| path.as_path()) .collect(); + let protected: Vec<&std::path::Path> = self + .protected_paths + .iter() + .map(|path| path.as_path()) + .collect(); + let allowed_unix_sockets: Vec<&std::path::Path> = self + .allowed_unix_socket_paths + .iter() + .map(|path| path.as_path()) + .collect(); let allow_network = !matches!(self.network, SandboxNetworkAccess::None); let permissions = sandbox::SandboxPermissions { allow_network, allow_fs_write: self.allow_fs_write, }; - sandbox::linux_bubblewrap::check_can_create_sandbox(&writable, permissions, cwd) - .map_err(|status| match status { - LauncherStatus::BwrapNotFound => LinuxWslSandboxError::BwrapNotFound, - LauncherStatus::SetuidRejected => LinuxWslSandboxError::SetuidRejected, - LauncherStatus::SandboxProbeFailed => LinuxWslSandboxError::SandboxProbeFailed, - // `Success` never appears in the `Err` arm; map defensively. - LauncherStatus::Success => { - LinuxWslSandboxError::Other(status.describe().to_string()) - } - }) + sandbox::linux_bubblewrap::check_can_create_sandbox( + &writable, + &protected, + &allowed_unix_sockets, + permissions, + cwd, + ) + .map_err(|status| match status { + LauncherStatus::BwrapNotFound => LinuxWslSandboxError::BwrapNotFound, + LauncherStatus::SetuidRejected => LinuxWslSandboxError::SetuidRejected, + LauncherStatus::SandboxProbeFailed => LinuxWslSandboxError::SandboxProbeFailed, + // `Success` never appears in the `Err` arm; map defensively. + LauncherStatus::Success => { + LinuxWslSandboxError::Other(status.describe().to_string()) + } + }) } #[cfg(not(target_os = "linux"))] { @@ -252,6 +280,16 @@ pub(crate) fn apply_sandbox_wrap( .chain(sandbox_wrap.extra_write_paths.iter()) .map(|p| p.as_path()) .collect(); + let protected: Vec<&std::path::Path> = sandbox_wrap + .protected_paths + .iter() + .map(|path| path.as_path()) + .collect(); + let allowed_unix_sockets: Vec<&std::path::Path> = sandbox_wrap + .allowed_unix_socket_paths + .iter() + .map(|path| path.as_path()) + .collect(); let network = match network_policy { NetworkPolicy::Proxied(port) => NetworkAccess::LocalhostPort(port), NetworkPolicy::Unrestricted => NetworkAccess::All, @@ -261,8 +299,14 @@ pub(crate) fn apply_sandbox_wrap( network, allow_fs_write: sandbox_wrap.allow_fs_write, }; - let (new_program, new_args, config_file) = - sandbox::macos_seatbelt::wrap_invocation(&program, &args, &writable, permissions)?; + let (new_program, new_args, config_file) = sandbox::macos_seatbelt::wrap_invocation( + &program, + &args, + &writable, + &protected, + &allowed_unix_sockets, + permissions, + )?; Ok(( new_program, new_args, @@ -280,6 +324,16 @@ pub(crate) fn apply_sandbox_wrap( .chain(sandbox_wrap.extra_write_paths.iter()) .map(|p| p.as_path()) .collect(); + let protected: Vec<&std::path::Path> = sandbox_wrap + .protected_paths + .iter() + .map(|p| p.as_path()) + .collect(); + let allowed_unix_sockets: Vec<&std::path::Path> = sandbox_wrap + .allowed_unix_socket_paths + .iter() + .map(|p| p.as_path()) + .collect(); let allow_network = match network_policy { NetworkPolicy::Denied => false, NetworkPolicy::Unrestricted => true, @@ -318,6 +372,8 @@ pub(crate) fn apply_sandbox_wrap( Some(channel.name()), permissions, &writable, + &protected, + &allowed_unix_sockets, cwd, &program, &args, diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs index 2ea5dcf3e7..fe6d37a4c2 100644 --- a/crates/agent/src/sandboxing.rs +++ b/crates/agent/src/sandboxing.rs @@ -107,6 +107,8 @@ impl NetworkRequest { pub(crate) struct SandboxRequest { /// Outbound network access requested for this command. pub network: NetworkRequest, + /// Allow access to protected Git metadata paths. + pub allow_git_access: bool, /// Allow unrestricted filesystem writes (the broad escape hatch). pub allow_fs_write_all: bool, /// Run the command fully outside the sandbox. @@ -121,6 +123,7 @@ impl SandboxRequest { /// scope, and therefore needs user approval. pub fn needs_escalation(&self) -> bool { self.network.is_requested() + || self.allow_git_access || self.allow_fs_write_all || self.unsandboxed || !self.write_paths.is_empty() @@ -141,6 +144,7 @@ pub(crate) struct ThreadSandboxGrants { /// Host patterns granted network access for the thread. Each covers its /// whole subdomain space; redundant entries are pruned on insert. network_hosts: Vec, + allow_git_access: bool, allow_fs_write_all: bool, unsandboxed: bool, /// Whether the user approved running commands *without* a sandbox for the @@ -181,6 +185,9 @@ impl ThreadSandboxGrants { if !self.network_covered(&request.network, persistent) { return false; } + if request.allow_git_access && !(self.allow_git_access || persistent.allow_git_access) { + return false; + } if request.allow_fs_write_all && !(self.allow_fs_write_all || persistent.allow_fs_write_all) { return false; @@ -250,6 +257,7 @@ impl ThreadSandboxGrants { } } } + self.allow_git_access |= request.allow_git_access; self.allow_fs_write_all |= request.allow_fs_write_all; self.unsandboxed |= request.unsandboxed; for path in &request.write_paths { @@ -301,6 +309,9 @@ impl ThreadSandboxGrants { } SandboxRequest { network, + allow_git_access: persistent.allow_git_access + || self.allow_git_access + || request.allow_git_access, allow_fs_write_all: persistent.allow_fs_write_all || self.allow_fs_write_all || request.allow_fs_write_all, @@ -354,6 +365,7 @@ mod tests { fn request(network: NetworkRequest, all: bool, paths: &[&str]) -> SandboxRequest { SandboxRequest { network, + allow_git_access: false, allow_fs_write_all: all, unsandboxed: false, write_paths: paths.iter().map(PathBuf::from).collect(), @@ -363,6 +375,7 @@ mod tests { fn unsandboxed_request() -> SandboxRequest { SandboxRequest { network: NetworkRequest::None, + allow_git_access: false, allow_fs_write_all: false, unsandboxed: true, write_paths: Vec::new(), @@ -537,6 +550,33 @@ mod tests { assert!(!covers(&grants, &request(NetworkRequest::None, true, &[]))); } + #[test] + fn git_access_grant_tracked_independently() { + let mut git_request = request(NetworkRequest::None, false, &[]); + git_request.allow_git_access = true; + + let mut grants = ThreadSandboxGrants::default(); + assert!(!covers(&grants, &git_request)); + + grants.record(&git_request); + assert!(covers(&grants, &git_request)); + assert!(!covers( + &grants, + &request(NetworkRequest::AnyHost, false, &[]) + )); + assert!(!covers(&grants, &request(NetworkRequest::None, true, &[]))); + } + + #[test] + fn unrestricted_writes_do_not_cover_git_access() { + let mut grants = ThreadSandboxGrants::default(); + grants.record(&request(NetworkRequest::None, true, &[])); + + let mut git_request = request(NetworkRequest::None, false, &[]); + git_request.allow_git_access = true; + assert!(!covers(&grants, &git_request)); + } + #[test] fn persistent_grants_combine_with_thread_grants() { let mut grants = ThreadSandboxGrants::default(); @@ -669,6 +709,7 @@ mod tests { let grants = ThreadSandboxGrants::default(); let persistent = SandboxPermissions { allow_all_hosts: true, + allow_git_access: true, write_paths: vec![PathBuf::from("/tmp/always")], ..Default::default() }; @@ -676,6 +717,7 @@ mod tests { let effective = grants .effective_with_persistent(&request(NetworkRequest::None, false, &[]), &persistent); assert_eq!(effective.network, NetworkRequest::AnyHost); + assert!(effective.allow_git_access); assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/always")]); } diff --git a/crates/agent/src/templates.rs b/crates/agent/src/templates.rs index a88c5171d7..6ab4cdd3f1 100644 --- a/crates/agent/src/templates.rs +++ b/crates/agent/src/templates.rs @@ -209,8 +209,15 @@ mod tests { assert!(rendered.contains("allow_hosts")); assert!(rendered.contains("allow_all_hosts: true")); assert!(rendered.contains("fs_write_paths")); + assert!(rendered.contains("allow_git_access: true")); assert!(rendered.contains("allow_fs_write_all: true")); assert!(rendered.contains("unsandboxed: true")); + assert!(rendered.contains("file contents under `.git` directories")); + assert!( + rendered.contains("worktree metadata that may live outside the project directories") + ); + assert!(rendered.contains("inherited SSH agent socket")); + assert!(rendered.contains("does not allow packet-sending network access")); assert!(rendered.contains("for the rest of the thread")); } diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index 30a22792e5..025fe46424 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -158,13 +158,13 @@ The current project contains the following root directories: The `terminal` tool runs commands inside a sandbox with these permissions: -- Reads: any path on the filesystem is readable. +- Reads: any path on the filesystem is readable, except that file contents under `.git` directories are blocked by default (their metadata stays visible). See `allow_git_access` below. {{#if is_linux}} - Writes: `/tmp` is writable but is cleared between `terminal` calls{{#if worktrees}}. These project directories are also writable and persist across calls: {{#each worktrees}} - `{{abs_path}}` {{/each}} - Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} + `.git` directories are not writable by default. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} {{else}} {{#if is_windows}} - Execution: commands run inside WSL under Bubblewrap. Native Windows project paths are routed through WSL's `/mnt//...` filesystem view. @@ -178,7 +178,7 @@ The `terminal` tool runs commands inside a sandbox with these permissions: {{#each worktrees}} - `{{abs_path}}` {{/each}} - Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} + `.git` directories are not writable by default. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} {{/if}} {{/if}} - Network: outbound network access is blocked. @@ -208,6 +208,7 @@ You can request elevated permissions on individual `terminal` calls: {{/if}} {{/if}} - `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write. +- `allow_git_access: true` — lift the default block on Git metadata, allowing reads and writes of file contents under `.git` directories (including worktree metadata that may live outside the project directories). Any command that touches `.git` needs this, including ones that invoke Git only incidentally (e.g. build scripts calling `git describe`/`git rev-parse`, or commit hooks). When approved, the inherited SSH agent socket also becomes reachable for SSH-based Git commit signing (local Unix-socket IPC only — it does not allow packet-sending network access); GPG commit signing via gpg-agent is not available in the sandbox. - `allow_fs_write_all: true` — allow unrestricted filesystem writes. Only use this when the specific paths can't be enumerated up front. - `unsandboxed: true` — run the command with no sandbox at all. Use only when none of the above suffice. diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index f0045aa479..9e4adfc39b 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -5371,6 +5371,7 @@ impl ToolCallEventStream { command, network_hosts, network_all_hosts, + allow_git_access: request.allow_git_access, allow_fs_write_all: request.allow_fs_write_all, unsandboxed: request.unsandboxed, write_paths: request.write_paths.clone(), @@ -5564,6 +5565,9 @@ impl ToolCallEventStream { agent.set_sandbox_network_hosts(host_strings); } } + if request.allow_git_access { + agent.allow_sandbox_git_access(); + } if request.allow_fs_write_all { agent.allow_sandbox_fs_write_all(); } @@ -7179,6 +7183,7 @@ mod tests { let (event_stream, mut receiver) = ToolCallEventStream::test(); let request = SandboxRequest { network: crate::sandboxing::NetworkRequest::None, + allow_git_access: false, allow_fs_write_all: false, unsandboxed: false, write_paths: vec![ @@ -7204,6 +7209,7 @@ mod tests { .expect("sandbox authorization should include request details"); assert!(details.network_hosts.is_empty()); assert!(!details.network_all_hosts); + assert_eq!(details.allow_git_access, request.allow_git_access); assert_eq!(details.allow_fs_write_all, request.allow_fs_write_all); assert_eq!(details.unsandboxed, request.unsandboxed); assert_eq!(details.write_paths, request.write_paths); diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 5590a52482..9974642d5e 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -161,13 +161,21 @@ pub struct SandboxedTerminalToolInput { /// set of paths is known. Requesting it triggers a user approval prompt. #[serde(default, alias = "allow_fs_write")] pub allow_fs_write_all: Option, + /// Set to `true` when the command needs Git metadata access. + /// + /// Sandboxed commands cannot read file contents from, or write to, protected + /// `.git` paths for opened worktrees and discovered repositories by default. + /// Set this for Git operations that need those paths. Requesting it + /// triggers a user approval prompt. + #[serde(default)] + pub allow_git_access: Option, /// Set to `true` only as a last resort, to run the command fully outside /// the sandbox. /// /// First try the narrower options (`allow_hosts`, `fs_write_paths`, - /// `allow_fs_write_all`); use this only when the command needs behavior - /// the sandbox can't grant on a per-permission basis. Requesting it - /// triggers a user approval prompt. + /// `allow_fs_write_all`, `allow_git_access`); use this only when the command + /// needs behavior the sandbox can't grant on a per-permission basis. + /// Requesting it triggers a user approval prompt. #[cfg_attr( target_os = "windows", doc = "\nOn Windows, running unsandboxed also switches the shell. Sandboxed \ @@ -180,8 +188,9 @@ pub struct SandboxedTerminalToolInput { #[serde(default)] pub unsandboxed: Option, /// A short justification for why this command needs the sandbox - /// permission(s) it requests (`allow_network`, `fs_write_paths`, - /// `allow_fs_write_all`, or `unsandboxed`). + /// permission(s) it requests (`allow_hosts`, `allow_all_hosts`, + /// `fs_write_paths`, `allow_fs_write_all`, `allow_git_access`, or + /// `unsandboxed`). /// /// Required whenever you request any of those permissions; omit it for /// ordinary commands that request none. Write it in your own voice — it @@ -197,6 +206,7 @@ struct TerminalSandboxInput { allow_all_hosts: Option, fs_write_paths: Vec, allow_fs_write_all: Option, + allow_git_access: Option, unsandboxed: Option, reason: Option, } @@ -239,6 +249,7 @@ impl From for TerminalToolRequest { allow_all_hosts: input.allow_all_hosts, fs_write_paths: input.fs_write_paths, allow_fs_write_all: input.allow_fs_write_all, + allow_git_access: input.allow_git_access, unsandboxed: input.unsandboxed, reason: input.reason, }), @@ -382,6 +393,7 @@ async fn run_terminal_tool( authorize.await.map_err(|e| e.to_string())?; + let want_git_access = sandboxing && sandbox_input.allow_git_access == Some(true); let want_fs_write_all = sandboxing && sandbox_input.allow_fs_write_all == Some(true); let want_unsandboxed = sandboxing && sandbox_input.unsandboxed == Some(true); @@ -441,6 +453,7 @@ async fn run_terminal_tool( let request = crate::sandboxing::SandboxRequest { network, + allow_git_access: !want_unsandboxed && want_git_access, allow_fs_write_all: !want_unsandboxed && want_fs_write_all, unsandboxed: want_unsandboxed, write_paths, @@ -510,16 +523,14 @@ async fn run_terminal_tool( None } else { let effective = event_stream.effective_sandbox_request(&request, &sandbox_permissions); - let writable_paths: Vec = cx.update(|cx| { - project - .read(cx) - .worktrees(cx) - .map(|w| w.read(cx).abs_path().to_path_buf()) - .collect::>() - }); + let sandbox_paths = + cx.update(|cx| sandbox_paths(project.read(cx), effective.allow_git_access, cx)); let wrap = acp_thread::SandboxWrap { - writable_paths, + writable_paths: sandbox_paths.writable_paths, extra_write_paths: effective.write_paths, + protected_paths: sandbox_paths.protected_paths, + allowed_unix_socket_paths: Vec::new(), + allow_git_access: effective.allow_git_access, network: network_request_to_sandbox_network_access(&effective.network), allow_fs_write: effective.allow_fs_write_all, is_local: is_local_project, @@ -969,6 +980,9 @@ fn sandbox_approval_title(request: &crate::sandboxing::SandboxRequest) -> String if let Some(network_clause) = network_clause(&request.network) { parts.push(network_clause); } + if request.allow_git_access { + parts.push("Git metadata access".to_string()); + } if request.allow_fs_write_all { parts.push("unrestricted filesystem writes".to_string()); } else if !request.write_paths.is_empty() { @@ -1166,6 +1180,59 @@ fn process_content( content } +struct SandboxPaths { + writable_paths: Vec, + protected_paths: Vec, +} + +fn sandbox_paths(project: &Project, allow_git_access: bool, cx: &App) -> SandboxPaths { + let mut writable_paths = Vec::new(); + let mut git_paths = Vec::new(); + + for worktree in project.worktrees(cx) { + let worktree = worktree.read(cx); + let worktree_abs_path = worktree.abs_path(); + writable_paths.push(worktree_abs_path.to_path_buf()); + // Protect `/.git` even when it doesn't exist yet, so a command + // can't `git init` and then write to the freshly created metadata. + git_paths.push(worktree_abs_path.join(".git")); + + // `Worktree` derefs to `Snapshot`; read the field directly instead of + // cloning the whole snapshot just for this path. + if let Some(root_repo_common_dir) = worktree.root_repo_common_dir() { + git_paths.push(root_repo_common_dir.to_path_buf()); + } + } + + // `Repository` derefs to `RepositorySnapshot`, so read the few path fields + // directly rather than cloning the entire snapshot (which carries the + // per-path status tree) for each repository. + for repository in project.git_store().read(cx).repositories().values() { + let repository = repository.read(cx); + git_paths.push(repository.dot_git_abs_path.to_path_buf()); + git_paths.push(repository.repository_dir_abs_path.to_path_buf()); + git_paths.push(repository.common_dir_abs_path.to_path_buf()); + } + + git_paths.sort(); + git_paths.dedup(); + + let protected_paths = if allow_git_access { + writable_paths.extend(git_paths); + Vec::new() + } else { + git_paths + }; + + writable_paths.sort(); + writable_paths.dedup(); + + SandboxPaths { + writable_paths, + protected_paths, + } +} + fn working_dir(cd: &str, project: &Entity, cx: &mut App) -> Result> { let project = project.read(cx); @@ -1203,6 +1270,7 @@ fn working_dir(cd: &str, project: &Entity, cx: &mut App) -> Result crate::sandboxing::SandboxRequest { crate::sandboxing::SandboxRequest { network, + allow_git_access: false, allow_fs_write_all: all, unsandboxed: false, write_paths: paths.iter().map(PathBuf::from).collect(), @@ -2922,6 +3076,10 @@ mod tests { schema.contains("allow_fs_write_all"), "schema should advertise allow_fs_write_all: {schema}" ); + assert!( + schema.contains("allow_git_access"), + "schema should advertise allow_git_access: {schema}" + ); assert!( schema.contains("unsandboxed"), "schema should advertise unsandboxed: {schema}" @@ -2943,6 +3101,7 @@ mod tests { assert_eq!(input.allow_all_hosts, None); assert!(input.fs_write_paths.is_empty()); assert_eq!(input.allow_fs_write_all, None); + assert_eq!(input.allow_git_access, None); assert_eq!(input.unsandboxed, None); } @@ -3002,6 +3161,7 @@ mod tests { .expect("legacy allow_fs_write should request sandbox authorization details"); assert!(details.network_hosts.is_empty()); assert!(!details.network_all_hosts); + assert!(!details.allow_git_access); assert!(details.allow_fs_write_all); assert!(!details.unsandboxed); assert!(details.write_paths.is_empty()); @@ -3100,6 +3260,7 @@ mod tests { .expect("unsandboxed should request sandbox authorization details"); assert!(details.network_hosts.is_empty()); assert!(!details.network_all_hosts); + assert!(!details.allow_git_access); assert!(!details.allow_fs_write_all); assert!(details.unsandboxed); assert!(details.write_paths.is_empty()); @@ -3297,6 +3458,13 @@ mod tests { sandbox_approval_title(&sandbox_request(NetworkRequest::None, true, &[])), "Allow unrestricted filesystem writes?" ); + + let mut request = sandbox_request(NetworkRequest::AnyHost, false, &[]); + request.allow_git_access = true; + assert_eq!( + sandbox_approval_title(&request), + "Allow arbitrary network access and Git metadata access?" + ); } #[test] diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 90bec44640..9b7970ba2c 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -421,6 +421,8 @@ pub struct SandboxPermissions { /// hostnames or leading-`*.` subdomain wildcards). Parsed/validated where /// consumed (`agent::sandboxing`). pub network_hosts: Vec, + /// Allow sandboxed commands to access protected Git metadata paths. + pub allow_git_access: bool, pub allow_fs_write_all: bool, /// Persistently run agent terminal commands outside the OS sandbox. This is /// the model-facing "off switch": when set, the sandboxed terminal tool is @@ -814,6 +816,7 @@ fn compile_sandbox_permissions( SandboxPermissions { allow_all_hosts: content.allow_all_hosts.unwrap_or(false), network_hosts, + allow_git_access: content.allow_git_access.unwrap_or(false), allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false), allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false), write_paths, @@ -1101,6 +1104,7 @@ mod tests { let json = json!({ "allow_all_hosts": true, "network_hosts": ["github.com", "*.npmjs.org"], + "allow_git_access": true, "allow_unsandboxed": true, "write_paths": [ "/tmp/build/cache", @@ -1117,6 +1121,7 @@ mod tests { permissions.network_hosts, vec!["github.com".to_string(), "*.npmjs.org".to_string()] ); + assert!(permissions.allow_git_access); assert!(!permissions.allow_fs_write_all); assert!(permissions.allow_unsandboxed); assert_eq!( diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index b6fbcdefda..d0e17015b0 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -7928,12 +7928,14 @@ impl ThreadView { cx: &Context, ) -> AnyElement { let has_network = details.network_all_hosts || !details.network_hosts.is_empty(); + let has_git_access = details.allow_git_access; let command = details .command .as_deref() .filter(|command| !command.is_empty()); if details.write_paths.is_empty() && !has_network + && !has_git_access && command.is_none() && details.reason.is_empty() { @@ -8044,6 +8046,34 @@ impl ThreadView { }) }); + let git_access_section = has_git_access.then(|| { + v_flex() + .p_1() + .gap_0p5() + .child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::GitBranch) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child( + Label::new("Git metadata access") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + Label::new( + "Allows reading and writing .git directories, which may include repositories outside this project, and exposes the inherited SSH agent for commit signing.", + ) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element() + }); + if details.write_paths.is_empty() { return v_flex() .border_t_1() @@ -8054,6 +8084,7 @@ impl ThreadView { )) }) .children(network_section) + .children(git_access_section) .into_any_element(); } @@ -8072,6 +8103,7 @@ impl ThreadView { )) }) .children(network_section) + .children(git_access_section) .child( h_flex() .id(("sandbox-authorization-details-header", entry_ix)) diff --git a/crates/sandbox/src/bwrap_test_helper.rs b/crates/sandbox/src/bwrap_test_helper.rs index 021fa6df05..7a5e962fb9 100644 --- a/crates/sandbox/src/bwrap_test_helper.rs +++ b/crates/sandbox/src/bwrap_test_helper.rs @@ -116,6 +116,8 @@ mod imp { Some(channel.name()), permissions, writable_dirs, + &[], + &[], cwd, program, args, diff --git a/crates/sandbox/src/linux_bubblewrap.rs b/crates/sandbox/src/linux_bubblewrap.rs index 254f9f9602..d20790b6f3 100644 --- a/crates/sandbox/src/linux_bubblewrap.rs +++ b/crates/sandbox/src/linux_bubblewrap.rs @@ -294,8 +294,22 @@ fn probe_bwrap(bwrap: &Path, bwrap_args: &[String]) -> bool { /// `writable_directories` should be the project's worktree paths (plus any /// user-approved paths), *not* the command's working directory, which is /// model-controlled and would let the model widen its own writable scope. +/// +/// `protected_paths` are paths whose contents must stay inaccessible even when +/// they sit under a writable directory or the root is bound read-write (e.g. +/// `.git` metadata when Git access hasn't been approved). Each is shadowed: a +/// directory with an ephemeral `tmpfs` (real contents hidden, writes land in +/// throwaway storage) and a file with an empty read-only bind of `/dev/null`. +/// +/// `allowed_unix_socket_paths` are Unix domain sockets to re-expose on top of +/// any overlay that would otherwise hide them (notably `--tmpfs /tmp`), so +/// local IPC such as the inherited SSH agent socket keeps working. AF_UNIX +/// connectivity is unaffected by `--unshare-net`, so this grants no IP network +/// access. pub fn build_bwrap_args( writable_directories: &[&Path], + protected_paths: &[&Path], + allowed_unix_socket_paths: &[&Path], permissions: SandboxPermissions, cwd: Option<&Path>, ) -> Vec { @@ -335,6 +349,51 @@ pub fn build_bwrap_args( } } + // Shadow protected paths last among the filesystem binds so the overlay + // wins over the root bind and any writable bind above it. Unlike the macOS + // Seatbelt policy (which denies `file-read-data` while leaving metadata + // readable), bwrap hides the real contents entirely behind an empty + // overlay; the security goal — keeping the real path unreadable and + // unwritable — is the same. + for protected_path in protected_paths { + // Resolve through an existing parent when the leaf is missing so a + // not-yet-created `.git` (before `git init`) overlays the same real path + // the writable bind above uses, even on a symlinked root. + let canonical = crate::canonicalize_allowing_missing_leaf(protected_path); + let destination = canonical.to_string_lossy().into_owned(); + match std::fs::symlink_metadata(&canonical) { + Ok(metadata) if metadata.is_dir() => { + args.push("--tmpfs".to_string()); + args.push(destination); + } + Ok(_) => { + // A file (e.g. a linked worktree's `.git` gitlink): mask it + // with an empty, read-only `/dev/null`. + push_bind(&mut args, "--ro-bind", "/dev/null", &destination); + } + Err(_) => { + // Doesn't exist yet: still overlay a tmpfs so a command can't + // `git init` and write real metadata into a writable worktree. + args.push("--tmpfs".to_string()); + args.push(destination); + } + } + } + + // Re-expose explicitly allowed Unix domain sockets on top of any overlay + // that would otherwise hide them. Done after the protected overlays and the + // `/tmp` tmpfs so the socket always wins. + for socket_path in allowed_unix_socket_paths { + let canonical = socket_path + .canonicalize() + .unwrap_or_else(|_| socket_path.to_path_buf()); + if !canonical.exists() { + continue; + } + let path = canonical.to_string_lossy().into_owned(); + push_bind(&mut args, "--bind", &path, &path); + } + for flag in [ "--unshare-user", "--unshare-ipc", @@ -392,6 +451,8 @@ struct LauncherInvocation { permissions: SandboxPermissions, cwd: Option, writable_dirs: Vec, + protected_paths: Vec, + allowed_unix_socket_paths: Vec, program: OsString, args: Vec, } @@ -405,18 +466,29 @@ struct LauncherInvocation { /// `status_socket_name` (when given). /// /// The encoding is positional, one value per argv entry, so paths and -/// arguments containing spaces round-trip without escaping: -/// `[FLAG, socket, allow_network, allow_fs_write, cwd, N, writable_1.., program, args..]`. +/// arguments containing spaces round-trip without escaping. Each path list is a +/// count followed by that many entries: +/// `[FLAG, socket, allow_network, allow_fs_write, cwd, +/// N_writable, writable.., N_protected, protected.., N_sockets, sockets.., +/// program, args..]`. pub fn wrap_invocation( launcher_program: &str, status_socket_name: Option<&str>, permissions: SandboxPermissions, writable_dirs: &[&Path], + protected_paths: &[&Path], + allowed_unix_socket_paths: &[&Path], cwd: Option<&Path>, program: &str, args: &[String], ) -> (String, Vec) { - let mut launcher_args = Vec::with_capacity(writable_dirs.len() + args.len() + 7); + let mut launcher_args = Vec::with_capacity( + writable_dirs.len() + + protected_paths.len() + + allowed_unix_socket_paths.len() + + args.len() + + 9, + ); launcher_args.push(SANDBOX_LAUNCHER_FLAG.to_string()); launcher_args.push(status_socket_name.unwrap_or("").to_string()); launcher_args.push(encode_bool(permissions.allow_network)); @@ -425,15 +497,22 @@ pub fn wrap_invocation( cwd.map(|cwd| cwd.to_string_lossy().into_owned()) .unwrap_or_default(), ); - launcher_args.push(writable_dirs.len().to_string()); - for directory in writable_dirs { - launcher_args.push(directory.to_string_lossy().into_owned()); - } + push_path_list(&mut launcher_args, writable_dirs); + push_path_list(&mut launcher_args, protected_paths); + push_path_list(&mut launcher_args, allowed_unix_socket_paths); launcher_args.push(program.to_string()); launcher_args.extend(args.iter().cloned()); (launcher_program.to_string(), launcher_args) } +/// Encode a path list as a count followed by one argv entry per path. +fn push_path_list(launcher_args: &mut Vec, paths: &[&Path]) { + launcher_args.push(paths.len().to_string()); + for path in paths { + launcher_args.push(path.to_string_lossy().into_owned()); + } +} + /// If this process was re-executed as a sandbox launcher (its first argument /// is [`SANDBOX_LAUNCHER_FLAG`]), set up the sandbox and `exec` the wrapped /// command. This never returns when the marker is present. @@ -473,19 +552,34 @@ fn run_launcher(invocation: LauncherInvocation) -> ! { .iter() .map(PathBuf::as_path) .collect(); + let protected: Vec<&Path> = invocation + .protected_paths + .iter() + .map(PathBuf::as_path) + .collect(); + let allowed_unix_sockets: Vec<&Path> = invocation + .allowed_unix_socket_paths + .iter() + .map(PathBuf::as_path) + .collect(); - let (bwrap, bwrap_args) = - match prepare_sandbox(&writable, invocation.permissions, invocation.cwd.as_deref()) { - Ok(prepared) => prepared, - Err(status) => { - report_status(socket.as_deref(), status); - eprintln!( - "zed: could not create sandbox: {}; aborting", - status.describe() - ); - std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); - } - }; + let (bwrap, bwrap_args) = match prepare_sandbox( + &writable, + &protected, + &allowed_unix_sockets, + invocation.permissions, + invocation.cwd.as_deref(), + ) { + Ok(prepared) => prepared, + Err(status) => { + report_status(socket.as_deref(), status); + eprintln!( + "zed: could not create sandbox: {}; aborting", + status.describe() + ); + std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); + } + }; // Everything is in place: report success, then `exec` the sandboxed command. // A failure of the final `exec` is fail-closed (a hard error, never a silent @@ -512,6 +606,8 @@ fn run_launcher(invocation: LauncherInvocation) -> ! { /// fall back to an unsandboxed run). fn prepare_sandbox( writable_dirs: &[&Path], + protected_paths: &[&Path], + allowed_unix_socket_paths: &[&Path], permissions: SandboxPermissions, cwd: Option<&Path>, ) -> Result<(PathBuf, Vec), LauncherStatus> { @@ -520,7 +616,13 @@ fn prepare_sandbox( BwrapLocation::OnlySetuid => return Err(LauncherStatus::SetuidRejected), BwrapLocation::NotFound => return Err(LauncherStatus::BwrapNotFound), }; - let bwrap_args = build_bwrap_args(writable_dirs, permissions, cwd); + let bwrap_args = build_bwrap_args( + writable_dirs, + protected_paths, + allowed_unix_socket_paths, + permissions, + cwd, + ); if !probe_bwrap(&bwrap, &bwrap_args) { return Err(LauncherStatus::SandboxProbeFailed); } @@ -537,10 +639,19 @@ fn prepare_sandbox( /// never silently skipped on the strength of this result alone. pub fn check_can_create_sandbox( writable_dirs: &[&Path], + protected_paths: &[&Path], + allowed_unix_socket_paths: &[&Path], permissions: SandboxPermissions, cwd: Option<&Path>, ) -> std::result::Result<(), LauncherStatus> { - prepare_sandbox(writable_dirs, permissions, cwd).map(|_| ()) + prepare_sandbox( + writable_dirs, + protected_paths, + allowed_unix_socket_paths, + permissions, + cwd, + ) + .map(|_| ()) } /// Send a one-shot `status` datagram to the status channel (if one was given). @@ -599,19 +710,9 @@ fn decode_launcher_args(mut args: impl Iterator) -> Result() - .context("invalid writable directory count")?; - let mut writable_dirs = Vec::with_capacity(count); - for _ in 0..count { - writable_dirs.push(PathBuf::from( - args.next().context("missing writable directory")?, - )); - } + let writable_dirs = decode_path_list(&mut args, "writable directory")?; + let protected_paths = decode_path_list(&mut args, "protected path")?; + let allowed_unix_socket_paths = decode_path_list(&mut args, "allowed unix socket")?; let program = args.next().context("missing program to run")?; let args: Vec = args.collect(); @@ -623,11 +724,33 @@ fn decode_launcher_args(mut args: impl Iterator) -> Result, what: &str) -> Result> { + let count = args + .next() + .with_context(|| format!("missing {what} count"))? + .to_str() + .with_context(|| format!("{what} count is not valid UTF-8"))? + .parse::() + .with_context(|| format!("invalid {what} count"))?; + let mut paths = Vec::with_capacity(count); + for _ in 0..count { + paths.push(PathBuf::from( + args.next() + .with_context(|| format!("missing {what} entry"))?, + )); + } + Ok(paths) +} + fn encode_bool(value: bool) -> String { if value { "1" } else { "0" }.to_string() } @@ -685,6 +808,8 @@ mod tests { let writable = tempfile::tempdir().unwrap(); let args = build_bwrap_args( &[writable.path()], + &[], + &[], SandboxPermissions::default(), Some(writable.path()), ); @@ -705,10 +830,12 @@ mod tests { #[test] fn test_build_bwrap_args_network_namespace_follows_permission() { - let denied = build_bwrap_args(&[], SandboxPermissions::default(), None); + let denied = build_bwrap_args(&[], &[], &[], SandboxPermissions::default(), None); assert!(denied.iter().any(|arg| arg == "--unshare-net")); let allowed = build_bwrap_args( + &[], + &[], &[], SandboxPermissions { allow_network: true, @@ -725,7 +852,7 @@ mod tests { allow_network: false, allow_fs_write: true, }; - let args = build_bwrap_args(&[], permissions, None); + let args = build_bwrap_args(&[], &[], &[], permissions, None); assert!(windows_contains(&args, &["--bind", "/", "/"])); assert!(!windows_contains(&args, &["--ro-bind", "/", "/"])); // With unrestricted writes the host's real `/tmp` stays writable, so @@ -733,6 +860,95 @@ mod tests { assert!(!windows_contains(&args, &["--tmpfs", "/tmp"])); } + #[test] + fn test_build_bwrap_args_shadows_protected_directory_with_tmpfs() { + let project = tempfile::tempdir().unwrap(); + let git_dir = project.path().join(".git"); + std::fs::create_dir(&git_dir).unwrap(); + + let args = build_bwrap_args( + &[project.path()], + &[git_dir.as_path()], + &[], + SandboxPermissions::default(), + None, + ); + + let git_canonical = git_dir.canonicalize().unwrap(); + let git_str = git_canonical.to_string_lossy().into_owned(); + // The writable project is bound rw, then the `.git` directory is shadowed + // with a tmpfs on top so its real contents can't be read or written. + assert!(windows_contains(&args, &["--tmpfs", &git_str])); + + let project_canonical = project.path().canonicalize().unwrap(); + let project_str = project_canonical.to_string_lossy().into_owned(); + let tmpfs_index = args + .windows(2) + .position(|w| w == ["--tmpfs".to_string(), git_str.clone()]) + .unwrap(); + let bind_index = args + .windows(3) + .position(|w| { + w == [ + "--bind".to_string(), + project_str.clone(), + project_str.clone(), + ] + }) + .unwrap(); + assert!( + bind_index < tmpfs_index, + "the protected tmpfs must be applied after the writable bind so it wins" + ); + } + + #[test] + fn test_build_bwrap_args_masks_protected_file_with_dev_null() { + let project = tempfile::tempdir().unwrap(); + let gitlink = project.path().join(".git"); + std::fs::write(&gitlink, "gitdir: /elsewhere\n").unwrap(); + + let args = build_bwrap_args( + &[project.path()], + &[gitlink.as_path()], + &[], + SandboxPermissions::default(), + None, + ); + + let gitlink_canonical = gitlink.canonicalize().unwrap(); + let gitlink_str = gitlink_canonical.to_string_lossy().into_owned(); + assert!(windows_contains( + &args, + &["--ro-bind", "/dev/null", &gitlink_str] + )); + } + + #[test] + fn test_build_bwrap_args_rebinds_allowed_unix_socket() { + let dir = tempfile::tempdir().unwrap(); + // `build_bwrap_args` only checks that the path exists, so a regular file + // stands in for a socket here (binding a real socket under a long temp + // path would overflow `sun_path`). + let socket = dir.path().join("agent.sock"); + std::fs::write(&socket, b"").unwrap(); + + let args = build_bwrap_args( + &[], + &[], + &[socket.as_path()], + SandboxPermissions::default(), + None, + ); + + let socket_canonical = socket.canonicalize().unwrap(); + let socket_str = socket_canonical.to_string_lossy().into_owned(); + assert!(windows_contains( + &args, + &["--bind", &socket_str, &socket_str] + )); + } + #[test] fn test_launcher_args_round_trip() { let writable = PathBuf::from("/home/user/project dir"); @@ -744,11 +960,15 @@ mod tests { allow_fs_write: false, }; + let protected = PathBuf::from("/home/user/project dir/.git"); + let socket = PathBuf::from("/tmp/ssh agent/sock"); let (launcher, launcher_args) = wrap_invocation( "/path/to/zed", Some("zed-sandbox-status-abc"), permissions, &[writable.as_path()], + &[protected.as_path()], + &[socket.as_path()], Some(cwd.as_path()), &program, &args, @@ -766,6 +986,8 @@ mod tests { assert_eq!(decoded.permissions, permissions); assert_eq!(decoded.cwd.as_deref(), Some(cwd.as_path())); assert_eq!(decoded.writable_dirs, vec![writable]); + assert_eq!(decoded.protected_paths, vec![protected]); + assert_eq!(decoded.allowed_unix_socket_paths, vec![socket]); assert_eq!(decoded.program, OsString::from(program)); assert_eq!( decoded.args, @@ -780,8 +1002,17 @@ mod tests { allow_network: true, allow_fs_write: true, }; - let (launcher, launcher_args) = - wrap_invocation("/path/to/zed", None, permissions, &[], None, &program, &[]); + let (launcher, launcher_args) = wrap_invocation( + "/path/to/zed", + None, + permissions, + &[], + &[], + &[], + None, + &program, + &[], + ); let decoded = parse_launcher_args(launcher_argv(launcher, launcher_args)) .unwrap() @@ -791,6 +1022,8 @@ mod tests { assert_eq!(decoded.permissions, permissions); assert_eq!(decoded.cwd, None); assert!(decoded.writable_dirs.is_empty()); + assert!(decoded.protected_paths.is_empty()); + assert!(decoded.allowed_unix_socket_paths.is_empty()); assert_eq!(decoded.program, OsString::from("/bin/true")); assert!(decoded.args.is_empty()); } diff --git a/crates/sandbox/src/macos_seatbelt.rs b/crates/sandbox/src/macos_seatbelt.rs index 52561db8e0..a5f15384f0 100644 --- a/crates/sandbox/src/macos_seatbelt.rs +++ b/crates/sandbox/src/macos_seatbelt.rs @@ -16,8 +16,10 @@ //! under `sandbox-exec -f `. //! //! Reads are permitted by default; writes are restricted to a caller- -//! provided list of directories; network access and unrestricted writes -//! must be opted into per command. +//! provided list of directories; IP network access and unrestricted writes +//! must be opted into per command. Callers may separately allow specific +//! Unix domain sockets for local IPC; those do not permit sending packets to +//! other machines. use std::path::Path; use std::{io::Write, path::PathBuf}; @@ -85,11 +87,29 @@ impl SeatbeltConfigFile { /// 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 { + /// + /// `protected_paths` lists paths whose file data reads and writes should + /// be blocked even if they fall under a readable or writable directory. + /// File metadata remains readable. + /// + /// `allowed_unix_socket_paths` lists Unix domain sockets the command may + /// connect to for local IPC even when IP network access is otherwise + /// disabled. This does not permit sending packets to other machines. + pub fn new( + writable_directories: &[&Path], + protected_paths: &[&Path], + allowed_unix_socket_paths: &[&Path], + permissions: SandboxPermissions, + ) -> Result { let mut file = NamedTempFile::new().context("failed to create temporary Seatbelt config file")?; - let config = generate_seatbelt_config(writable_directories, permissions)?; + let config = generate_seatbelt_config( + writable_directories, + protected_paths, + allowed_unix_socket_paths, + permissions, + )?; file.write_all(config.as_bytes()) .context("failed to write Seatbelt config")?; file.flush().context("failed to flush Seatbelt config")?; @@ -118,6 +138,12 @@ impl SeatbeltConfigFile { /// 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). +/// * `protected_paths` - Paths whose file data reads and writes should be +/// denied even if they fall under a readable or writable directory. File +/// metadata remains readable. +/// * `allowed_unix_socket_paths` - Unix domain sockets the command may +/// connect to for local IPC even when IP network access is otherwise +/// disabled. This does not permit sending packets to other machines. /// * `permissions` - Sandbox relaxations requested for this command. /// /// # Returns @@ -127,9 +153,16 @@ pub fn wrap_invocation( program: &str, args: &[String], writable_directories: &[&Path], + protected_paths: &[&Path], + allowed_unix_socket_paths: &[&Path], permissions: SandboxPermissions, ) -> Result<(String, Vec, SeatbeltConfigFile)> { - let config_file = SeatbeltConfigFile::new(writable_directories, permissions)?; + let config_file = SeatbeltConfigFile::new( + writable_directories, + protected_paths, + allowed_unix_socket_paths, + permissions, + )?; let mut wrapped_args = vec![ "-f".to_string(), @@ -158,13 +191,23 @@ pub fn wrap_invocation( /// 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`]. +/// default. File data reads and writes to paths in `protected_paths` are +/// denied even when they would otherwise be readable or writable; file +/// metadata remains readable. Unix domain socket paths in +/// `allowed_unix_socket_paths` are reachable for local IPC even when IP +/// network access is otherwise blocked; callers use this for trusted sockets +/// inherited from the process environment, such as `SSH_AUTH_SOCK`. This does +/// not permit sending packets to other machines. +/// +/// 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], + protected_paths: &[&Path], + allowed_unix_socket_paths: &[&Path], permissions: SandboxPermissions, ) -> Result { // Canonicalize each writable path to resolve symlinks (e.g., @@ -174,6 +217,19 @@ fn generate_seatbelt_config( .iter() .map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf())) .collect(); + // Use `canonicalize_allowing_missing_leaf` rather than a plain + // `canonicalize` so a not-yet-created `.git` (before `git init`) still + // resolves through its existing parent and matches the canonicalized + // writable worktree above; otherwise the deny rule would miss the real path + // on a symlinked root (`/tmp` -> `/private/tmp`). + let canonical_protected_paths: Vec = protected_paths + .iter() + .map(|path| crate::canonicalize_allowing_missing_leaf(path)) + .collect(); + // Unlike file paths, Unix socket literals are emitted verbatim: it isn't + // guaranteed whether Seatbelt resolves symlinks before matching a + // `remote unix-socket` literal, so the caller passes both the path the + // child connects to and its canonical form, and we keep them as given. let mut config = r#"(version 1) @@ -198,6 +254,26 @@ fn generate_seatbelt_config( ; Allow pseudo-terminal operations (allow pseudo-tty) +(allow file-read* file-write* file-ioctl + (literal "/dev/ptmx")) +(allow file-read* file-write* + (require-all + (regex #"^/dev/ttys[0-9]+$") + (extension "com.apple.sandbox.pty"))) + +; The command's PTY is allocated after this profile is generated, so its slave +; TTY path isn't known here and may lack the `com.apple.sandbox.pty` extension. +; Allow ioctls on slave TTYs so interactive shells and signing prompts can +; manipulate terminal state. Seatbelt can't filter by ioctl request number, so +; this can't exclude input-injection ioctls (e.g. TIOCSTI) specifically. The +; residual risk is bounded by the kernel, not by this profile: XNU's TIOCSTI +; handler (bsd/kern/tty.c) rejects a non-root caller unless the target TTY is +; the caller's own controlling terminal (EACCES otherwise). Each agent command +; runs in its own dedicated PTY, so the only TTY it can inject into is that +; throwaway PTY, not the user's interactive terminal. The regex is also anchored +; so it matches only `/dev/ttysNNN` device nodes. +(allow file-ioctl + (regex #"^/dev/ttys[0-9]+$")) "# .to_string(); @@ -235,6 +311,20 @@ fn generate_seatbelt_config( ); } + for protected_path in &canonical_protected_paths { + let escaped_path = escape_sandbox_path(protected_path)?; + // `subpath` already matches the path itself plus everything beneath it, + // so it covers both a `.git` directory and a linked worktree's `.git` + // gitlink file without a redundant `literal` rule. + config.push_str(&format!( + r#" +; Block Git metadata content access unless Git access is approved +(deny file-read-data file-write* + (subpath "{escaped_path}")) +"# + )); + } + match permissions.network { NetworkAccess::None => {} NetworkAccess::All => { @@ -262,6 +352,34 @@ fn generate_seatbelt_config( } } + if !allowed_unix_socket_paths.is_empty() { + config.push_str( + r#" +; Allow local IPC to inherited Unix domain sockets. Seatbelt models this as +; network-outbound, but this does not permit IP networking or sending packets +; to other machines. +; +; `system-socket` only governs the `socket()` syscall (creating an AF_UNIX +; socket), which is harmless on its own. The capability that matters, +; `connect()`, stays gated by the per-path `network-outbound (remote +; unix-socket ...)` rules below, so `(deny default)` still blocks connecting to +; any socket not explicitly allow-listed. +(allow system-socket + (socket-domain AF_UNIX)) +"#, + ); + + for socket_path in allowed_unix_socket_paths { + let escaped_path = escape_sandbox_path(socket_path)?; + config.push_str(&format!( + r#"(allow network-outbound + (remote unix-socket + (literal "{escaped_path}"))) +"# + )); + } + } + Ok(config) } @@ -289,7 +407,8 @@ mod tests { 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(); + generate_seatbelt_config(&[dir.as_path()], &[], &[], SandboxPermissions::default()) + .unwrap(); assert!(config.contains("(allow file-read*)")); assert!(config.contains("/Users/test/projects/myproject")); @@ -303,6 +422,8 @@ mod tests { let dir = PathBuf::from("/Users/test/projects/myproject"); let config = generate_seatbelt_config( &[dir.as_path()], + &[], + &[], SandboxPermissions { network: NetworkAccess::None, allow_fs_write: true, @@ -317,11 +438,225 @@ mod tests { assert!(!config.contains("(allow network*)")); } + #[test] + fn test_generate_seatbelt_config_allows_terminal_ioctls_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-ioctl")); + assert!(config.contains("/dev/ptmx")); + assert!(config.contains("^/dev/ttys[0-9]+")); + } + + #[test] + fn test_generate_seatbelt_config_allows_unix_socket_paths_without_network() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let ssh_auth_socket = PathBuf::from("/private/tmp/com.apple.launchd.test/Listeners"); + let config = generate_seatbelt_config( + &[dir.as_path()], + &[], + &[ssh_auth_socket.as_path()], + SandboxPermissions::default(), + ) + .unwrap(); + + assert!(config.contains("(allow system-socket")); + assert!(config.contains("AF_UNIX")); + assert!(config.contains("(allow network-outbound")); + assert!(config.contains("remote unix-socket")); + assert!(config.contains("(literal \"/private/tmp/com.apple.launchd.test/Listeners\")")); + assert!(!config.contains("(subpath \"/private/tmp/com.apple.launchd.test/Listeners\")")); + assert!(!config.contains("(allow network*)")); + } + + #[test] + fn test_sandbox_allows_connecting_to_allowed_unix_socket() { + use std::io::ErrorKind; + use std::os::unix::net::UnixListener; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + // Bind under `/tmp` rather than the default temp dir: macOS temp paths + // (`/var/folders/...`) overflow the `sun_path` limit for Unix sockets. + // `TempDir` still cleans up on drop, even if the test panics. + let temp_dir = tempfile::Builder::new() + .prefix("zed-sock-") + .tempdir_in("/tmp") + .unwrap(); + let socket_path = temp_dir.path().join("agent.sock"); + let listener = UnixListener::bind(&socket_path).expect("test socket should bind"); + listener + .set_nonblocking(true) + .expect("listener should switch to non-blocking"); + + // Allow both the path the command connects to and its canonical form, + // mirroring how the real caller resolves `SSH_AUTH_SOCK`. + let canonical_socket_path = socket_path + .canonicalize() + .expect("bound socket path should canonicalize"); + + // Accept (and immediately drop) a single connection, with a bounded wait + // so the test can't hang if the sandbox blocks the connection instead. + let accepted = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match listener.accept() { + Ok(_) => return true, + Err(error) if error.kind() == ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(20)); + } + Err(_) => return false, + } + } + }); + + let (program, args, _config_file) = wrap_invocation( + "/usr/bin/nc", + &[ + "-U".to_string(), + "-w".to_string(), + "5".to_string(), + socket_path.display().to_string(), + ], + &[temp_dir.path()], + &[], + &[socket_path.as_path(), canonical_socket_path.as_path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .stdin(Stdio::null()) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + accepted.join().unwrap(), + "sandbox should allow connecting to the allow-listed unix socket: stderr={} stdout={}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); + } + + #[test] + fn test_generate_seatbelt_config_denies_protected_path_data_and_writes() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let protected = dir.join(".gitignore"); + let config = generate_seatbelt_config( + &[dir.as_path()], + &[protected.as_path()], + &[], + SandboxPermissions::default(), + ) + .unwrap(); + + assert!(config.contains("(deny file-read-data file-write*")); + assert!(config.contains("(subpath \"/Users/test/projects/myproject/.gitignore\")")); + // `subpath` already covers the path itself, so no redundant `literal`. + assert!(!config.contains("(literal \"/Users/test/projects/myproject/.gitignore\")")); + } + + #[test] + fn test_sandbox_blocks_protected_path_contents_but_allows_metadata() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let protected_file = temp_dir.path().join(".gitignore"); + std::fs::write(&protected_file, "target\n").unwrap(); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!( + "test -e '{}' && ! cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'", + protected_file.display(), + protected_file.display(), + protected_file.display(), + ), + ], + &[temp_dir.path()], + &[protected_file.as_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 metadata but deny protected data reads and writes: stderr={} stdout={}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); + assert_eq!( + std::fs::read_to_string(&protected_file).unwrap(), + "target\n" + ); + } + + #[test] + fn test_sandbox_blocks_protected_paths_even_when_fs_writes_allowed() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let protected_file = temp_dir.path().join(".gitignore"); + std::fs::write(&protected_file, "target\n").unwrap(); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!( + "! cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'", + protected_file.display(), + protected_file.display(), + ), + ], + &[temp_dir.path()], + &[protected_file.as_path()], + &[], + SandboxPermissions { + network: NetworkAccess::None, + allow_fs_write: true, + }, + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "protected paths should stay blocked even with unrestricted writes: stderr={} stdout={}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); + assert_eq!( + std::fs::read_to_string(&protected_file).unwrap(), + "target\n" + ); + } + #[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 { network: NetworkAccess::All, allow_fs_write: false, @@ -340,6 +675,8 @@ mod tests { let dir = PathBuf::from("/Users/test/projects/myproject"); let config = generate_seatbelt_config( &[dir.as_path()], + &[], + &[], SandboxPermissions { network: NetworkAccess::LocalhostPort(54321), allow_fs_write: false, @@ -359,6 +696,8 @@ mod tests { 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(); @@ -394,6 +733,8 @@ mod tests { "/bin/sh", &["-c".to_string(), "echo hello".to_string()], &[temp_dir.path()], + &[], + &[], SandboxPermissions::default(), ) .unwrap(); @@ -415,6 +756,8 @@ mod tests { "/bin/sh", &["-c".to_string(), "cat /etc/hosts".to_string()], &[temp_dir.path()], + &[], + &[], SandboxPermissions::default(), ) .unwrap(); @@ -440,6 +783,8 @@ mod tests { "/bin/sh", &["-c".to_string(), "echo test 2>/dev/null".to_string()], &[temp_dir.path()], + &[], + &[], SandboxPermissions::default(), ) .unwrap(); @@ -465,6 +810,8 @@ mod tests { "/bin/sh", &["-c".to_string(), "echo test 2>/dev/null".to_string()], &[temp_dir.path()], + &[], + &[], SandboxPermissions { network: NetworkAccess::None, allow_fs_write: true, @@ -498,6 +845,8 @@ mod tests { format!("echo 'hello' > '{}'", test_file.display()), ], &[temp_dir.path()], + &[], + &[], SandboxPermissions { network: NetworkAccess::None, allow_fs_write: true, @@ -533,6 +882,8 @@ mod tests { format!("echo 'hello' > '{}'", test_file.display()), ], &[project_dir.path(), scratch_dir.path()], + &[], + &[], SandboxPermissions::default(), ) .unwrap(); @@ -564,6 +915,8 @@ mod tests { format!("echo 'hello' > '{}'", test_file.display()), ], &[temp_dir.path()], + &[], + &[], SandboxPermissions::default(), ) .unwrap(); @@ -596,6 +949,8 @@ mod tests { format!("echo 'hello' > '{}'", test_file.display()), ], &[project_dir.path()], + &[], + &[], SandboxPermissions { network: NetworkAccess::None, allow_fs_write: true, @@ -634,6 +989,8 @@ mod tests { format!("echo 'hello' > '{}'", forbidden_file.display()), ], &[project_dir.path()], + &[], + &[], SandboxPermissions::default(), ) .unwrap(); diff --git a/crates/sandbox/src/sandbox.rs b/crates/sandbox/src/sandbox.rs index f5923cfd34..2060d5d189 100644 --- a/crates/sandbox/src/sandbox.rs +++ b/crates/sandbox/src/sandbox.rs @@ -70,3 +70,59 @@ pub fn run_sandbox_launcher_if_invoked() { #[cfg(target_os = "linux")] linux_bubblewrap::run_launcher_if_invoked(); } + +/// Canonicalize `path`, resolving symlinks, even when its final component +/// doesn't exist yet. +/// +/// `std::fs::canonicalize` fails if any component is missing, which would leave +/// a not-yet-created path (e.g. a `.git` directory before `git init`) in a +/// non-canonical form. The sandbox layers canonicalize the writable parent +/// (the worktree root) but, with a plain `canonicalize`, fall back to the raw +/// path for a missing child; the two then disagree when a component is a +/// symlink (`/tmp` -> `/private/tmp` on macOS), and the protection rule for the +/// child misses the real path the command ends up writing. Canonicalizing the +/// existing parent and re-appending the final component keeps the child +/// consistent with its parent. If neither the path nor its parent can be +/// canonicalized, the path is returned unchanged. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn canonicalize_allowing_missing_leaf(path: &std::path::Path) -> std::path::PathBuf { + if let Ok(canonical) = path.canonicalize() { + return canonical; + } + if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) + && let Ok(canonical_parent) = parent.canonicalize() + { + return canonical_parent.join(file_name); + } + path.to_path_buf() +} + +#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] +mod tests { + use super::canonicalize_allowing_missing_leaf; + + #[test] + fn canonicalize_allowing_missing_leaf_resolves_existing_parent() { + let dir = tempfile::tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + // A fully existing path is canonicalized outright. + assert_eq!( + canonicalize_allowing_missing_leaf(dir.path()), + canonical_dir + ); + + // A path whose leaf doesn't exist yet still resolves through its parent, + // so it stays consistent with how the parent directory canonicalizes + // (this is the `.git`-before-`git init` case). + let missing = dir.path().join("not-created-yet"); + assert_eq!( + canonicalize_allowing_missing_leaf(&missing), + canonical_dir.join("not-created-yet"), + ); + + // A path whose parent also doesn't exist is returned unchanged. + let deeper = missing.join(".git"); + assert_eq!(canonicalize_allowing_missing_leaf(&deeper), deeper); + } +} diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 1ad5d7a766..e0ac05fcd6 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -481,6 +481,12 @@ impl AgentSettingsContent { .allow_fs_write_all = Some(true); } + pub fn allow_sandbox_git_access(&mut self) { + self.sandbox_permissions + .get_or_insert_default() + .allow_git_access = Some(true); + } + pub fn allow_sandbox_unsandboxed(&mut self) { self.sandbox_permissions .get_or_insert_default() @@ -739,6 +745,11 @@ pub struct SandboxPermissionsContent { /// Default: [] pub network_hosts: Option>, + /// Whether sandboxed terminal commands may always access protected Git + /// metadata paths without prompting. + /// Default: false + pub allow_git_access: Option, + /// Whether sandboxed terminal commands may always write anywhere on the /// filesystem without prompting. /// Default: false @@ -1039,6 +1050,7 @@ mod tests { &["github.com".to_string(), "*.npmjs.org".to_string()] ); settings.allow_sandbox_fs_write_all(); + settings.allow_sandbox_git_access(); settings.allow_sandbox_unsandboxed(); settings.add_sandbox_write_path(PathBuf::from("/tmp/build")); @@ -1053,6 +1065,7 @@ mod tests { .as_slice(), &["github.com".to_string(), "*.npmjs.org".to_string()] ); + assert_eq!(sandbox_permissions.allow_git_access, Some(true)); assert_eq!(sandbox_permissions.allow_fs_write_all, Some(true)); assert_eq!(sandbox_permissions.allow_unsandboxed, Some(true)); assert_eq!( diff --git a/crates/settings_ui/src/pages/sandbox_settings.rs b/crates/settings_ui/src/pages/sandbox_settings.rs index eb86182ef3..d5ee3c5490 100644 --- a/crates/settings_ui/src/pages/sandbox_settings.rs +++ b/crates/settings_ui/src/pages/sandbox_settings.rs @@ -128,6 +128,27 @@ pub(crate) fn render_sandbox_settings_page( )), ) .child(Divider::horizontal()) + .child( + v_flex() + .gap_3() + .child(SettingsSectionHeader::new("Git").no_padding(true)) + .child( + SwitchField::new( + "sandbox-allow-git-access", + Some("Allow Git Metadata Access"), + Some( + "Let sandboxed commands access protected Git metadata, including .git directories and linked worktree metadata, without prompting." + .into(), + ), + permissions.allow_git_access, + move |state, _window, cx| { + set_allow_git_access(*state == ToggleState::Selected, cx); + }, + ) + .tab_index(0), + ), + ) + .child(Divider::horizontal()) .child( v_flex() .gap_3() @@ -422,6 +443,12 @@ fn set_allow_all_hosts(value: bool, cx: &mut App) { }); } +fn set_allow_git_access(value: bool, cx: &mut App) { + update_sandbox_permissions(cx, move |permissions| { + permissions.allow_git_access = Some(value); + }); +} + fn set_allow_fs_write_all(value: bool, cx: &mut App) { update_sandbox_permissions(cx, move |permissions| { permissions.allow_fs_write_all = Some(value);