use agent_client_protocol::schema::v1 as acp;use anyhow::Result;use collections::HashMap;use futures::{FutureExt as _, future::Shared};use gpui::{App, AppContext, AsyncApp, Context, Entity, Task};use http_proxy::Allowlist;use language::LanguageRegistry;use markdown::Markdown;use project::Project;use serde::{Deserialize, Serialize};use std::{ collections::HashMap as StdHashMap, path::PathBuf, process::ExitStatus, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, time::Instant,};use task::Shell;use util::get_default_system_shell_preferring_bash; /// Request to run a terminal command inside an OS-level sandbox.////// Passed to [`super::AcpThread::create_terminal`]. The actual sandboxing/// mechanism is platform-specific (macOS Seatbelt; Linux Bubblewrap; Windows/// via Bubblewrap inside WSL), so callers describe the *intent* with plain data/// here rather than constructing platform-specific types directly.////// Default is the fully-sandboxed run (no network, project-only writes)./// Setting `network` / `allow_fs_write` requests a relaxation; the caller is/// responsible for having obtained user approval before reaching this point.#[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. pub writable_paths: Vec<PathBuf>, /// Additional write subtrees the user explicitly approved for this /// command (per-path write grants). Kept separate from `writable_paths` /// to make the trust boundary explicit: these originate from /// model-requested paths that passed a user-approval prompt. They are /// merged with `writable_paths` when generating the sandbox policy. /// /// Each grant carries the canonical target it resolved to at approval /// time; enforcement rebuilds the location via a verifying reopen (see /// [`granted_write_path_to_location`]) rather than re-resolving the bare /// requested path, which closes a symlink TOCTOU. pub extra_write_paths: Vec<settings::GrantedWritePath>, /// Outbound network access explicitly approved for this command. pub network: SandboxNetworkAccess, /// Additional paths that should remain readable but not writable, even when /// they fall under writable paths. pub protected_paths: Vec<PathBuf>, /// Allow unrestricted filesystem writes except for protected paths (ignores /// ordinary writable paths). pub allow_fs_write: bool, /// Whether the project (and therefore this terminal) is local. The /// enforcing proxy binds a loopback port on this host, so it can only /// confine local commands; a remote terminal can't reach it. pub is_local: bool, /// Windows/WSL only: `(release channel, version)` of the Linux `zed` to /// provision inside WSL as the sandbox helper (version `latest` for dev /// builds). Resolved by the agent (which can read the running app's release /// info) and forwarded to the sandbox. `None` on other platforms, or when /// the release can't be determined, in which case the WSL backend falls back /// to running bwrap without in-sandbox bind validation. pub wsl_zed_release: Option<(String, String)>,} #[derive(Clone, Debug, Default)]pub enum SandboxNetworkAccess { /// Block all outbound network access. #[default] None, /// Allow only hosts in this allowlist, enforced by routing HTTP/HTTPS /// through an in-process proxy and confining the command to the proxy's /// loopback port. Restricted(Allowlist), /// Allow unrestricted outbound network access. All,} /// A structured, serializable reason the OS sandbox could not be created for a/// command. Mirrors the Linux/WSL Bubblewrap failure modes; surfaced to the user/// (and persisted in tool-call metadata) so the UI can/// explain what went wrong.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub enum LinuxWslSandboxError { /// No usable `bwrap` binary was found on `PATH`. BwrapNotFound, /// The only `bwrap` found is setuid-root, which Zed refuses to run. SetuidRejected, /// `bwrap` is present but couldn't set up the sandbox (typically because /// unprivileged user namespaces are disabled). SandboxProbeFailed, /// Any other failure, with a human-readable description. Other(String),} impl From<sandbox::SandboxError> for LinuxWslSandboxError { fn from(error: sandbox::SandboxError) -> Self { match error { sandbox::SandboxError::BwrapNotFound => Self::BwrapNotFound, sandbox::SandboxError::BwrapSetuidRejected => Self::SetuidRejected, sandbox::SandboxError::SandboxProbeFailed => Self::SandboxProbeFailed, error => Self::Other(error.to_string()), } }} impl LinuxWslSandboxError { /// A short, user-facing explanation of why the sandbox couldn't be created, /// suitable for display in the agent panel. pub fn user_facing_message(&self) -> String { match self { LinuxWslSandboxError::BwrapNotFound => { "No usable `bwrap` binary was found on your PATH. Install Bubblewrap to let \ the agent sandbox terminal commands." .to_string() } LinuxWslSandboxError::SetuidRejected => { "The only `bwrap` available is setuid-root, which Zed refuses to run. Install \ a non-setuid Bubblewrap to let the agent sandbox terminal commands." .to_string() } LinuxWslSandboxError::SandboxProbeFailed => { "`bwrap` is installed but couldn't create a sandbox, likely because \ unprivileged user namespaces are disabled on this system." .to_string() } LinuxWslSandboxError::Other(message) => message.clone(), } } /// The slug of the sandboxing docs section that best explains how to resolve /// this failure, for deep-linking from the UI. Pair with /// `client::zed_urls::sandboxing_docs`. pub fn docs_section(&self) -> &'static str { match self { // Both "no bwrap" and "only a setuid-root bwrap" are resolved by // installing a non-setuid Bubblewrap. LinuxWslSandboxError::BwrapNotFound | LinuxWslSandboxError::SetuidRejected => { "installing-bubblewrap" } // A failed probe on Linux is almost always disabled unprivileged // user namespaces, which the Ubuntu-specific section covers. LinuxWslSandboxError::SandboxProbeFailed => "installing-bubblewrap-ubuntu", // Catch-all (includes WSL/Windows messages): point at the platform // overview for the current OS. LinuxWslSandboxError::Other(_) => { if cfg!(target_os = "windows") { "windows" } else { "linux" } } } }} /// Rebuild a user-approved write grant into an enforceable/// [`sandbox::HostFilesystemLocation`].////// When the grant carries a resolved canonical (the normal case, established at/// approval time), the location is rebuilt via a verifying/// [`sandbox::HostFilesystemLocation::reopen`] — the load-bearing step of the/// TOCTOU fix. A legacy bare-string grant (no resolved canonical) falls back to/// a fresh [`sandbox::HostFilesystemLocation::capture`].pub fn granted_write_path_to_location( granted: &settings::GrantedWritePath,) -> std::io::Result<sandbox::HostFilesystemLocation> { match &granted.resolved { Some(resolved) => sandbox::HostFilesystemLocation::reopen(&granted.requested, resolved), None => sandbox::HostFilesystemLocation::capture(&granted.requested), }} /// Rebuild a grant for enforcement, or log and drop it (fail-closed) if it/// can't be verified.////// A failure here is frequently the symlink-TOCTOU defense firing: the grant's/// canonical was redirected or replaced by a symlink since approval, so/// [`sandbox::HostFilesystemLocation::reopen`] refuses it. That is a/// security-relevant event, so it must be logged rather than silently/// swallowed. The grant is dropped (the command runs without it) rather than/// bound unverified.////// Only for **display** policies (the sandbox-status UI), where a stale grant/// should simply not be shown. Enforcement must not drop grants silently — a/// command would run with less access than the user approved with no signal —/// so [`SandboxWrap::to_policy`] uses the erroring/// [`granted_write_path_to_location`] instead.pub fn granted_write_path_to_location_or_log( granted: &settings::GrantedWritePath,) -> Option<sandbox::HostFilesystemLocation> { granted_write_path_to_location(granted) .inspect_err(|error| { log::warn!( "dropping sandbox write grant {}: {error}", granted.requested.display() ); }) .ok()} impl SandboxWrap { /// Whether the OS sandbox for this request can actually be created right now, /// returning a structured [`LinuxWslSandboxError`] when it can't. /// /// The sandbox implementation never runs a command unsandboxed on its own — /// it aborts if it can't create the sandbox. This lets a caller decide, up /// front, whether to run sandboxed, fall back to an unsandboxed run /// (fail-open), or refuse (fail-closed). It runs a brief probe subprocess on /// Linux, so call it off the main thread. On platforms whose sandbox can't /// fail to set up this way it always returns `Ok`. pub fn can_create_sandbox(&self) -> Result<(), LinuxWslSandboxError> { let policy = self .to_policy() .map_err(|error| LinuxWslSandboxError::Other(format!("{error:#}")))?; sandbox::Sandbox::can_create(&policy).map_err(LinuxWslSandboxError::from) } /// Translate this request into the cross-platform [`sandbox::SandboxPolicy`]. /// /// This is the enforcement-policy construction point, so it **captures** each /// grant as a [`sandbox::HostFilesystemLocation`] (pinning the inode / canonical /// path) rather than passing a re-resolvable path. /// /// This function has **no filesystem side effects**: it never creates paths, /// and it **fails** (rather than silently narrowing the policy) when a /// writable path or approved grant can't be captured — running anyway would /// give the command silently less access than the model and user were told /// it has. On Linux a writable grant that doesn't exist can't be captured /// (bwrap can't bind a missing path); the sanctioned way to get a grant to a /// new directory is the `create_directory` tool, which creates it (pinning /// the inode) before the grant is recorded. On macOS a missing leaf still /// canonicalizes, so such grants are captured directly. /// /// It is used both by the side-effect-free [`Self::can_create_sandbox`] probe /// and by real sandbox construction, and must behave identically. /// /// A grant failure here can also be the symlink-TOCTOU defense firing: the /// grant's canonical was redirected or replaced by a symlink since approval, /// so the verifying reopen refuses it. Failing the command surfaces that /// security-relevant event instead of running with the grant quietly /// missing. /// /// Protected paths, by contrast, are **best-effort**: we protect only the /// ones that exist at creation time (`capture` succeeding *is* the existence /// check), and silently drop the rest. Unlike a writable grant, a protection /// can't be materialized — you can't pin the inode of a path that isn't /// there — and there is an inherent, *accepted* loophole regardless: a /// command in a non-git directory can `git init` and write hooks into a /// `.git` that didn't exist when the sandbox was built. Since the protection /// is defeatable that way no matter what, failing sandbox creation over a /// currently-absent (or otherwise uncapturable) `.git` would only break /// legitimate cases — non-git projects, single-file worktrees whose /// synthesized `settings.json/.git` routes through a file — without closing /// the hole. So we drop and move on. fn to_policy(&self) -> Result<sandbox::SandboxPolicy> { let protected_paths = self .protected_paths .iter() .filter_map(|path| sandbox::HostFilesystemLocation::capture(path).ok()) .collect::<Vec<_>>(); let fs = if self.allow_fs_write { sandbox::SandboxFsPolicy::Unrestricted { protected_paths } } else { // Project worktree paths are captured fresh; user-approved grants are // rebuilt via the verifying reopen (or captured when legacy bare // strings) through `granted_write_path_to_location`. A path that // can't be captured fails the whole construction (never created). let mut locations = Vec::new(); for path in &self.writable_paths { let location = sandbox::HostFilesystemLocation::capture(path).map_err(|error| { anyhow::anyhow!(error).context(format!( "cannot capture writable sandbox path `{}`", path.display() )) })?; locations.push(location); } for granted in &self.extra_write_paths { let location = granted_write_path_to_location(granted).map_err(|error| { anyhow::anyhow!(error).context(format!( "cannot re-verify approved sandbox write grant `{}` (if the \ directory was removed, remove the grant or recreate the \ directory)", granted.requested.display() )) })?; locations.push(location); } // Dedupe to a minimal cover on the captured canonical paths, so a // grant nested under a worktree root (or another grant) is dropped // rather than bound redundantly. let writable_paths = sandbox::normalize_host_filesystem_locations(locations.into_iter()); sandbox::SandboxFsPolicy::Restricted { writable_paths, protected_paths, } }; let network = match &self.network { SandboxNetworkAccess::None => sandbox::SandboxNetPolicy::Blocked, SandboxNetworkAccess::All => sandbox::SandboxNetPolicy::Unrestricted, SandboxNetworkAccess::Restricted(allowlist) => sandbox::SandboxNetPolicy::Restricted { allowed_domains: allowlist .patterns() .iter() .map(|pattern| pattern.to_string()) .collect(), }, }; Ok(sandbox::SandboxPolicy { fs, network }) }} /// Why the OS sandbox was *not* applied to a terminal command, even though/// sandboxing is active for the thread. Persisted in tool-call metadata so the/// UI can explain the situation after the fact.////// This is deliberately platform-agnostic — every variant exists on every/// platform — so the serialized form stored in the thread database never/// depends on which OS wrote it. Today only Linux/WSL can fail to create a/// sandbox (`ErrorLinuxWsl`), but the variant is named so macOS/Windows can/// grow their own failure cases later without a migration.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub enum SandboxNotAppliedReason { /// The user disabled the sandbox for the rest of this thread, so the command /// ran without one. This happens either when the user approved a /// model-requested `unsandboxed: true` escape "for this thread", or when /// they chose to run unsandboxed for the thread after a sandbox-creation /// failure (in which case a preceding tool call's reason is /// [`SandboxNotAppliedReason::ErrorLinuxWsl`]). DisabledForThisThread, /// The Linux/WSL (Bubblewrap) sandbox could not be created for this command. ErrorLinuxWsl(LinuxWslSandboxError),} /// The live sandbox kept alive for its per-command resources (the network proxy/// and, on macOS, the Seatbelt policy file) until the terminal exits.type SandboxConfigHandle = sandbox::Sandbox; /// Upper bound on preparing a WSL-sandboxed command. Deliberately generous:/// the first invocation after the WSL utility VM has shut down (or after boot)/// has to start the VM and the distro, which routinely takes 10-30 seconds on/// slow disks or under antivirus scanning.#[cfg(target_os = "windows")]pub(crate) const WSL_SANDBOX_WRAP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); /// Wrap `(program, args)` for sandboxed execution, returning the wrapped/// invocation (program, argv, env) plus the live [`sandbox::Sandbox`] that must/// be kept alive for the command's duration. When `sandbox_wrap` is `None` the/// command is returned unchanged.////// The sandbox owns the network proxy (for restricted-network policies) and any/// per-command policy file; the env it returns already routes through that/// proxy when applicable.pub(crate) async fn prepare_sandbox_wrap( program: String, args: Vec<String>, cwd: Option<PathBuf>, sandbox_wrap: Option<SandboxWrap>, env: HashMap<String, String>,) -> anyhow::Result<( String, Vec<String>, HashMap<String, String>, Option<SandboxConfigHandle>,)> { let Some(sandbox_wrap) = sandbox_wrap else { return Ok((program, args, env, None)); }; let mut sandbox = sandbox::Sandbox::new(sandbox_wrap.to_policy()?).map_err(anyhow::Error::new)?; // Windows/WSL only: tell the sandbox which Linux `zed` to provision inside // WSL as its `--wsl-sandbox-helper`. A no-op (and a no-op setter) elsewhere. #[cfg(target_os = "windows")] if let Some((channel, version)) = sandbox_wrap.wsl_zed_release.clone() { sandbox.set_wsl_zed_release(channel, version); } let command = sandbox::CommandAndArgs { program, args, env: env.into_iter().collect::<StdHashMap<_, _>>(), cwd, }; let wrapped = sandbox.wrap(&command).await.map_err(anyhow::Error::new)?; Ok(( wrapped.program, wrapped.args, wrapped.env.into_iter().collect(), Some(sandbox), ))} pub struct Terminal { id: acp::TerminalId, command: Entity<Markdown>, working_dir: Option<PathBuf>, terminal: Entity<terminal::Terminal>, started_at: Instant, output: Option<TerminalOutput>, output_byte_limit: Option<usize>, _output_task: Shared<Task<acp::TerminalExitStatus>>, /// Flag indicating whether this terminal was stopped by explicit user action /// (e.g., clicking the Stop button). This is set before kill() is called /// so that code awaiting wait_for_exit() can check it deterministically. user_stopped: Arc<AtomicBool>, /// The live sandbox (Seatbelt policy file and/or network proxy) kept alive /// until the sandboxed command exits. `None` when the command isn't /// sandboxed or after it finishes. Dropping it tears down the proxy on a /// background thread (see `sandbox::Sandbox`'s `Drop`). _sandbox: Option<SandboxConfigHandle>,} pub struct TerminalOutput { pub ended_at: Instant, pub exit_status: Option<ExitStatus>, pub content: String, pub original_content_len: usize, pub content_line_count: usize,} impl Terminal { pub fn new( id: acp::TerminalId, command_label: &str, working_dir: Option<PathBuf>, output_byte_limit: Option<usize>, terminal: Entity<terminal::Terminal>, language_registry: Arc<LanguageRegistry>, sandbox: Option<SandboxConfigHandle>, cx: &mut Context<Self>, ) -> Self { let command_task = terminal.read(cx).wait_for_completed_task(cx); // Tear the sandbox down on a GPUI background thread when this entity is // released, rather than relying on `Sandbox`'s `Drop` (which would spawn // a throwaway thread) on whatever thread releases us. `on_release` hands // us an `App`, so we can drive the teardown through the background // executor with `drop_on_current_thread`. cx.on_release(|this, cx| { if let Some(sandbox) = this._sandbox.take() { cx.background_executor() .spawn(async move { sandbox.drop_on_current_thread() }) .detach(); } }) .detach(); Self { id, _sandbox: sandbox, command: cx.new(|cx| { Markdown::new( format!("```\n{}\n```", command_label).into(), Some(language_registry.clone()), None, cx, ) }), working_dir, terminal, started_at: Instant::now(), output: None, output_byte_limit, user_stopped: Arc::new(AtomicBool::new(false)), _output_task: cx .spawn(async move |this, cx| { let exit_status = command_task.await; this.update(cx, |this, cx| { let (content, original_content_len) = this.truncated_output(cx); let content_line_count = this.terminal.read(cx).total_lines(); this.output = Some(TerminalOutput { ended_at: Instant::now(), exit_status, content, original_content_len, content_line_count, }); // Free the sandbox (and its network proxy) as soon as // the command finishes, rather than holding it until // this entity is released. The proxy's teardown joins a // listener thread, so run it on the background executor // to keep it off the foreground thread. if let Some(sandbox) = this._sandbox.take() { cx.background_executor() .spawn(async move { sandbox.drop_on_current_thread() }) .detach(); } cx.notify(); }) .ok(); Lines 1–500 of 754. Load more
use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use collections::HashMap; use futures::{FutureExt as _, future::Shared}; use gpui::{App, AppContext, AsyncApp, Context, Entity, Task}; use http_proxy::Allowlist; use language::LanguageRegistry; use markdown::Markdown; use project::Project; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap as StdHashMap, path::PathBuf, process::ExitStatus, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, time::Instant, }; use task::Shell; use util::get_default_system_shell_preferring_bash; /// Request to run a terminal command inside an OS-level sandbox. /// /// Passed to [`super::AcpThread::create_terminal`]. The actual sandboxing /// mechanism is platform-specific (macOS Seatbelt; Linux Bubblewrap; Windows /// via Bubblewrap inside WSL), so callers describe the *intent* with plain data /// here rather than constructing platform-specific types directly. /// /// Default is the fully-sandboxed run (no network, project-only writes). /// Setting `network` / `allow_fs_write` requests a relaxation; the caller is /// responsible for having obtained user approval before reaching this point. #[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. pub writable_paths: Vec<PathBuf>, /// Additional write subtrees the user explicitly approved for this /// command (per-path write grants). Kept separate from `writable_paths` /// to make the trust boundary explicit: these originate from /// model-requested paths that passed a user-approval prompt. They are /// merged with `writable_paths` when generating the sandbox policy. /// /// Each grant carries the canonical target it resolved to at approval /// time; enforcement rebuilds the location via a verifying reopen (see /// [`granted_write_path_to_location`]) rather than re-resolving the bare /// requested path, which closes a symlink TOCTOU. pub extra_write_paths: Vec<settings::GrantedWritePath>, /// Outbound network access explicitly approved for this command. pub network: SandboxNetworkAccess, /// Additional paths that should remain readable but not writable, even when /// they fall under writable paths. pub protected_paths: Vec<PathBuf>, /// Allow unrestricted filesystem writes except for protected paths (ignores /// ordinary writable paths). pub allow_fs_write: bool, /// Whether the project (and therefore this terminal) is local. The /// enforcing proxy binds a loopback port on this host, so it can only /// confine local commands; a remote terminal can't reach it. pub is_local: bool, /// Windows/WSL only: `(release channel, version)` of the Linux `zed` to /// provision inside WSL as the sandbox helper (version `latest` for dev /// builds). Resolved by the agent (which can read the running app's release /// info) and forwarded to the sandbox. `None` on other platforms, or when /// the release can't be determined, in which case the WSL backend falls back /// to running bwrap without in-sandbox bind validation. pub wsl_zed_release: Option<(String, String)>, } #[derive(Clone, Debug, Default)] pub enum SandboxNetworkAccess { /// Block all outbound network access. #[default] None, /// Allow only hosts in this allowlist, enforced by routing HTTP/HTTPS /// through an in-process proxy and confining the command to the proxy's /// loopback port. Restricted(Allowlist), /// Allow unrestricted outbound network access. All, } /// A structured, serializable reason the OS sandbox could not be created for a /// command. Mirrors the Linux/WSL Bubblewrap failure modes; surfaced to the user /// (and persisted in tool-call metadata) so the UI can /// explain what went wrong. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum LinuxWslSandboxError { /// No usable `bwrap` binary was found on `PATH`. BwrapNotFound, /// The only `bwrap` found is setuid-root, which Zed refuses to run. SetuidRejected, /// `bwrap` is present but couldn't set up the sandbox (typically because /// unprivileged user namespaces are disabled). SandboxProbeFailed, /// Any other failure, with a human-readable description. Other(String), } impl From<sandbox::SandboxError> for LinuxWslSandboxError { fn from(error: sandbox::SandboxError) -> Self { match error { sandbox::SandboxError::BwrapNotFound => Self::BwrapNotFound, sandbox::SandboxError::BwrapSetuidRejected => Self::SetuidRejected, sandbox::SandboxError::SandboxProbeFailed => Self::SandboxProbeFailed, error => Self::Other(error.to_string()), } } } impl LinuxWslSandboxError { /// A short, user-facing explanation of why the sandbox couldn't be created, /// suitable for display in the agent panel. pub fn user_facing_message(&self) -> String { match self { LinuxWslSandboxError::BwrapNotFound => { "No usable `bwrap` binary was found on your PATH. Install Bubblewrap to let \ the agent sandbox terminal commands." .to_string() } LinuxWslSandboxError::SetuidRejected => { "The only `bwrap` available is setuid-root, which Zed refuses to run. Install \ a non-setuid Bubblewrap to let the agent sandbox terminal commands." .to_string() } LinuxWslSandboxError::SandboxProbeFailed => { "`bwrap` is installed but couldn't create a sandbox, likely because \ unprivileged user namespaces are disabled on this system." .to_string() } LinuxWslSandboxError::Other(message) => message.clone(), } } /// The slug of the sandboxing docs section that best explains how to resolve /// this failure, for deep-linking from the UI. Pair with /// `client::zed_urls::sandboxing_docs`. pub fn docs_section(&self) -> &'static str { match self { // Both "no bwrap" and "only a setuid-root bwrap" are resolved by // installing a non-setuid Bubblewrap. LinuxWslSandboxError::BwrapNotFound | LinuxWslSandboxError::SetuidRejected => { "installing-bubblewrap" } // A failed probe on Linux is almost always disabled unprivileged // user namespaces, which the Ubuntu-specific section covers. LinuxWslSandboxError::SandboxProbeFailed => "installing-bubblewrap-ubuntu", // Catch-all (includes WSL/Windows messages): point at the platform // overview for the current OS. LinuxWslSandboxError::Other(_) => { if cfg!(target_os = "windows") { "windows" } else { "linux" } } } } } /// Rebuild a user-approved write grant into an enforceable /// [`sandbox::HostFilesystemLocation`]. /// /// When the grant carries a resolved canonical (the normal case, established at /// approval time), the location is rebuilt via a verifying /// [`sandbox::HostFilesystemLocation::reopen`] — the load-bearing step of the /// TOCTOU fix. A legacy bare-string grant (no resolved canonical) falls back to /// a fresh [`sandbox::HostFilesystemLocation::capture`]. pub fn granted_write_path_to_location( granted: &settings::GrantedWritePath, ) -> std::io::Result<sandbox::HostFilesystemLocation> { match &granted.resolved { Some(resolved) => sandbox::HostFilesystemLocation::reopen(&granted.requested, resolved), None => sandbox::HostFilesystemLocation::capture(&granted.requested), } } /// Rebuild a grant for enforcement, or log and drop it (fail-closed) if it /// can't be verified. /// /// A failure here is frequently the symlink-TOCTOU defense firing: the grant's /// canonical was redirected or replaced by a symlink since approval, so /// [`sandbox::HostFilesystemLocation::reopen`] refuses it. That is a /// security-relevant event, so it must be logged rather than silently /// swallowed. The grant is dropped (the command runs without it) rather than /// bound unverified. /// /// Only for **display** policies (the sandbox-status UI), where a stale grant /// should simply not be shown. Enforcement must not drop grants silently — a /// command would run with less access than the user approved with no signal — /// so [`SandboxWrap::to_policy`] uses the erroring /// [`granted_write_path_to_location`] instead. pub fn granted_write_path_to_location_or_log( granted: &settings::GrantedWritePath, ) -> Option<sandbox::HostFilesystemLocation> { granted_write_path_to_location(granted) .inspect_err(|error| { log::warn!( "dropping sandbox write grant {}: {error}", granted.requested.display() ); }) .ok() } impl SandboxWrap { /// Whether the OS sandbox for this request can actually be created right now, /// returning a structured [`LinuxWslSandboxError`] when it can't. /// /// The sandbox implementation never runs a command unsandboxed on its own — /// it aborts if it can't create the sandbox. This lets a caller decide, up /// front, whether to run sandboxed, fall back to an unsandboxed run /// (fail-open), or refuse (fail-closed). It runs a brief probe subprocess on /// Linux, so call it off the main thread. On platforms whose sandbox can't /// fail to set up this way it always returns `Ok`. pub fn can_create_sandbox(&self) -> Result<(), LinuxWslSandboxError> { let policy = self .to_policy() .map_err(|error| LinuxWslSandboxError::Other(format!("{error:#}")))?; sandbox::Sandbox::can_create(&policy).map_err(LinuxWslSandboxError::from) } /// Translate this request into the cross-platform [`sandbox::SandboxPolicy`]. /// /// This is the enforcement-policy construction point, so it **captures** each /// grant as a [`sandbox::HostFilesystemLocation`] (pinning the inode / canonical /// path) rather than passing a re-resolvable path. /// /// This function has **no filesystem side effects**: it never creates paths, /// and it **fails** (rather than silently narrowing the policy) when a /// writable path or approved grant can't be captured — running anyway would /// give the command silently less access than the model and user were told /// it has. On Linux a writable grant that doesn't exist can't be captured /// (bwrap can't bind a missing path); the sanctioned way to get a grant to a /// new directory is the `create_directory` tool, which creates it (pinning /// the inode) before the grant is recorded. On macOS a missing leaf still /// canonicalizes, so such grants are captured directly. /// /// It is used both by the side-effect-free [`Self::can_create_sandbox`] probe /// and by real sandbox construction, and must behave identically. /// /// A grant failure here can also be the symlink-TOCTOU defense firing: the /// grant's canonical was redirected or replaced by a symlink since approval, /// so the verifying reopen refuses it. Failing the command surfaces that /// security-relevant event instead of running with the grant quietly /// missing. /// /// Protected paths, by contrast, are **best-effort**: we protect only the /// ones that exist at creation time (`capture` succeeding *is* the existence /// check), and silently drop the rest. Unlike a writable grant, a protection /// can't be materialized — you can't pin the inode of a path that isn't /// there — and there is an inherent, *accepted* loophole regardless: a /// command in a non-git directory can `git init` and write hooks into a /// `.git` that didn't exist when the sandbox was built. Since the protection /// is defeatable that way no matter what, failing sandbox creation over a /// currently-absent (or otherwise uncapturable) `.git` would only break /// legitimate cases — non-git projects, single-file worktrees whose /// synthesized `settings.json/.git` routes through a file — without closing /// the hole. So we drop and move on. fn to_policy(&self) -> Result<sandbox::SandboxPolicy> { let protected_paths = self .protected_paths .iter() .filter_map(|path| sandbox::HostFilesystemLocation::capture(path).ok()) .collect::<Vec<_>>(); let fs = if self.allow_fs_write { sandbox::SandboxFsPolicy::Unrestricted { protected_paths } } else { // Project worktree paths are captured fresh; user-approved grants are // rebuilt via the verifying reopen (or captured when legacy bare // strings) through `granted_write_path_to_location`. A path that // can't be captured fails the whole construction (never created). let mut locations = Vec::new(); for path in &self.writable_paths { let location = sandbox::HostFilesystemLocation::capture(path).map_err(|error| { anyhow::anyhow!(error).context(format!( "cannot capture writable sandbox path `{}`", path.display() )) })?; locations.push(location); } for granted in &self.extra_write_paths { let location = granted_write_path_to_location(granted).map_err(|error| { anyhow::anyhow!(error).context(format!( "cannot re-verify approved sandbox write grant `{}` (if the \ directory was removed, remove the grant or recreate the \ directory)", granted.requested.display() )) })?; locations.push(location); } // Dedupe to a minimal cover on the captured canonical paths, so a // grant nested under a worktree root (or another grant) is dropped // rather than bound redundantly. let writable_paths = sandbox::normalize_host_filesystem_locations(locations.into_iter()); sandbox::SandboxFsPolicy::Restricted { writable_paths, protected_paths, } }; let network = match &self.network { SandboxNetworkAccess::None => sandbox::SandboxNetPolicy::Blocked, SandboxNetworkAccess::All => sandbox::SandboxNetPolicy::Unrestricted, SandboxNetworkAccess::Restricted(allowlist) => sandbox::SandboxNetPolicy::Restricted { allowed_domains: allowlist .patterns() .iter() .map(|pattern| pattern.to_string()) .collect(), }, }; Ok(sandbox::SandboxPolicy { fs, network }) } } /// Why the OS sandbox was *not* applied to a terminal command, even though /// sandboxing is active for the thread. Persisted in tool-call metadata so the /// UI can explain the situation after the fact. /// /// This is deliberately platform-agnostic — every variant exists on every /// platform — so the serialized form stored in the thread database never /// depends on which OS wrote it. Today only Linux/WSL can fail to create a /// sandbox (`ErrorLinuxWsl`), but the variant is named so macOS/Windows can /// grow their own failure cases later without a migration. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum SandboxNotAppliedReason { /// The user disabled the sandbox for the rest of this thread, so the command /// ran without one. This happens either when the user approved a /// model-requested `unsandboxed: true` escape "for this thread", or when /// they chose to run unsandboxed for the thread after a sandbox-creation /// failure (in which case a preceding tool call's reason is /// [`SandboxNotAppliedReason::ErrorLinuxWsl`]). DisabledForThisThread, /// The Linux/WSL (Bubblewrap) sandbox could not be created for this command. ErrorLinuxWsl(LinuxWslSandboxError), } /// The live sandbox kept alive for its per-command resources (the network proxy /// and, on macOS, the Seatbelt policy file) until the terminal exits. type SandboxConfigHandle = sandbox::Sandbox; /// Upper bound on preparing a WSL-sandboxed command. Deliberately generous: /// the first invocation after the WSL utility VM has shut down (or after boot) /// has to start the VM and the distro, which routinely takes 10-30 seconds on /// slow disks or under antivirus scanning. #[cfg(target_os = "windows")] pub(crate) const WSL_SANDBOX_WRAP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); /// Wrap `(program, args)` for sandboxed execution, returning the wrapped /// invocation (program, argv, env) plus the live [`sandbox::Sandbox`] that must /// be kept alive for the command's duration. When `sandbox_wrap` is `None` the /// command is returned unchanged. /// /// The sandbox owns the network proxy (for restricted-network policies) and any /// per-command policy file; the env it returns already routes through that /// proxy when applicable. pub(crate) async fn prepare_sandbox_wrap( program: String, args: Vec<String>, cwd: Option<PathBuf>, sandbox_wrap: Option<SandboxWrap>, env: HashMap<String, String>, ) -> anyhow::Result<( String, Vec<String>, HashMap<String, String>, Option<SandboxConfigHandle>, )> { let Some(sandbox_wrap) = sandbox_wrap else { return Ok((program, args, env, None)); }; let mut sandbox = sandbox::Sandbox::new(sandbox_wrap.to_policy()?).map_err(anyhow::Error::new)?; // Windows/WSL only: tell the sandbox which Linux `zed` to provision inside // WSL as its `--wsl-sandbox-helper`. A no-op (and a no-op setter) elsewhere. #[cfg(target_os = "windows")] if let Some((channel, version)) = sandbox_wrap.wsl_zed_release.clone() { sandbox.set_wsl_zed_release(channel, version); } let command = sandbox::CommandAndArgs { program, args, env: env.into_iter().collect::<StdHashMap<_, _>>(), cwd, }; let wrapped = sandbox.wrap(&command).await.map_err(anyhow::Error::new)?; Ok(( wrapped.program, wrapped.args, wrapped.env.into_iter().collect(), Some(sandbox), )) } pub struct Terminal { id: acp::TerminalId, command: Entity<Markdown>, working_dir: Option<PathBuf>, terminal: Entity<terminal::Terminal>, started_at: Instant, output: Option<TerminalOutput>, output_byte_limit: Option<usize>, _output_task: Shared<Task<acp::TerminalExitStatus>>, /// Flag indicating whether this terminal was stopped by explicit user action /// (e.g., clicking the Stop button). This is set before kill() is called /// so that code awaiting wait_for_exit() can check it deterministically. user_stopped: Arc<AtomicBool>, /// The live sandbox (Seatbelt policy file and/or network proxy) kept alive /// until the sandboxed command exits. `None` when the command isn't /// sandboxed or after it finishes. Dropping it tears down the proxy on a /// background thread (see `sandbox::Sandbox`'s `Drop`). _sandbox: Option<SandboxConfigHandle>, } pub struct TerminalOutput { pub ended_at: Instant, pub exit_status: Option<ExitStatus>, pub content: String, pub original_content_len: usize, pub content_line_count: usize, } impl Terminal { pub fn new( id: acp::TerminalId, command_label: &str, working_dir: Option<PathBuf>, output_byte_limit: Option<usize>, terminal: Entity<terminal::Terminal>, language_registry: Arc<LanguageRegistry>, sandbox: Option<SandboxConfigHandle>, cx: &mut Context<Self>, ) -> Self { let command_task = terminal.read(cx).wait_for_completed_task(cx); // Tear the sandbox down on a GPUI background thread when this entity is // released, rather than relying on `Sandbox`'s `Drop` (which would spawn // a throwaway thread) on whatever thread releases us. `on_release` hands // us an `App`, so we can drive the teardown through the background // executor with `drop_on_current_thread`. cx.on_release(|this, cx| { if let Some(sandbox) = this._sandbox.take() { cx.background_executor() .spawn(async move { sandbox.drop_on_current_thread() }) .detach(); } }) .detach(); Self { id, _sandbox: sandbox, command: cx.new(|cx| { Markdown::new( format!("```\n{}\n```", command_label).into(), Some(language_registry.clone()), None, cx, ) }), working_dir, terminal, started_at: Instant::now(), output: None, output_byte_limit, user_stopped: Arc::new(AtomicBool::new(false)), _output_task: cx .spawn(async move |this, cx| { let exit_status = command_task.await; this.update(cx, |this, cx| { let (content, original_content_len) = this.truncated_output(cx); let content_line_count = this.terminal.read(cx).total_lines(); this.output = Some(TerminalOutput { ended_at: Instant::now(), exit_status, content, original_content_len, content_line_count, }); // Free the sandbox (and its network proxy) as soon as // the command finishes, rather than holding it until // this entity is released. The proxy's teardown joins a // listener thread, so run it on the background executor // to keep it off the foreground thread. if let Some(sandbox) = this._sandbox.take() { cx.background_executor() .spawn(async move { sandbox.drop_on_current_thread() }) .detach(); } cx.notify(); }) .ok();