diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 8a94e0a366ea57..87ef3a5080809c 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -202,6 +202,64 @@ pub fn sandbox_authorization_details_from_meta( .and_then(|v| serde_json::from_value(v.clone()).ok()) } +pub const SANDBOX_FALLBACK_AUTHORIZATION_META_KEY: &str = "sandbox_fallback_authorization"; + +/// Stable `PermissionOption` id for the "Retry" choice in the sandbox +/// *fallback* prompt (shown when the OS sandbox can't be created on this +/// system). The remaining choices reuse the [`SandboxPermission`] ids. +pub const SANDBOX_FALLBACK_RETRY_OPTION_ID: &str = "retry"; + +/// Details shown when the OS sandbox could not be created for a command and +/// the user is asked whether to run it without a sandbox. Distinct from +/// [`SandboxAuthorizationDetails`] (a model-requested *escalation*): here the +/// sandbox itself failed, so the prompt explains why and offers a retry. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct SandboxFallbackAuthorizationDetails { + #[serde(default)] + pub command: Option, + /// Human-readable reason the OS sandbox could not be created (for example, + /// "bwrap not found on PATH"), shown to the user so they can decide + /// whether to run the command without a sandbox. + #[serde(default)] + pub reason: String, +} + +pub fn meta_with_sandbox_fallback_authorization( + details: SandboxFallbackAuthorizationDetails, +) -> acp::Meta { + acp::Meta::from_iter([( + SANDBOX_FALLBACK_AUTHORIZATION_META_KEY.into(), + serde_json::to_value(details).unwrap_or_default(), + )]) +} + +pub fn sandbox_fallback_authorization_details_from_meta( + meta: &Option, +) -> Option { + meta.as_ref() + .and_then(|m| m.get(SANDBOX_FALLBACK_AUTHORIZATION_META_KEY)) + .and_then(|v| serde_json::from_value(v.clone()).ok()) +} + +/// Meta key recording why the OS sandbox was not applied to a terminal tool +/// call, even though sandboxing was active for the thread. The value is a +/// serialized [`SandboxNotAppliedReason`]. Surfaced as a warning in the UI and +/// used to explain the situation to both the user and the agent. +pub const SANDBOX_NOT_APPLIED_META_KEY: &str = "sandbox_not_applied"; + +pub fn meta_with_sandbox_not_applied(reason: &SandboxNotAppliedReason) -> acp::Meta { + acp::Meta::from_iter([( + SANDBOX_NOT_APPLIED_META_KEY.into(), + serde_json::to_value(reason).unwrap_or_default(), + )]) +} + +pub fn sandbox_not_applied_from_meta(meta: &Option) -> Option { + meta.as_ref() + .and_then(|m| m.get(SANDBOX_NOT_APPLIED_META_KEY)) + .and_then(|v| serde_json::from_value(v.clone()).ok()) +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct SubagentSessionInfo { /// The session id of the subagent sessiont that was spawned @@ -429,6 +487,11 @@ pub struct ToolCall { pub tool_name: Option, pub subagent_session_info: Option, pub sandbox_authorization_details: Option, + pub sandbox_fallback_authorization_details: Option, + /// Why this terminal command ran without the OS sandbox even though + /// sandboxing was active (see [`SANDBOX_NOT_APPLIED_META_KEY`]). `None` when + /// the command was sandboxed normally (or sandboxing was off). + pub sandbox_not_applied: Option, } impl ToolCall { @@ -472,6 +535,9 @@ impl ToolCall { let subagent_session_info = subagent_session_info_from_meta(&tool_call.meta); let sandbox_authorization_details = sandbox_authorization_details_from_meta(&tool_call.meta); + let sandbox_fallback_authorization_details = + sandbox_fallback_authorization_details_from_meta(&tool_call.meta); + let sandbox_not_applied = sandbox_not_applied_from_meta(&tool_call.meta); let label = if tool_call.kind == acp::ToolKind::Execute { cx.new(|cx| Markdown::new_text(title.into(), cx)) @@ -493,6 +559,8 @@ impl ToolCall { tool_name, subagent_session_info, sandbox_authorization_details, + sandbox_fallback_authorization_details, + sandbox_not_applied, }; Ok(result) } @@ -532,6 +600,15 @@ impl ToolCall { { self.sandbox_authorization_details = Some(sandbox_authorization_details); } + if let Some(sandbox_fallback_authorization_details) = + sandbox_fallback_authorization_details_from_meta(&meta) + { + self.sandbox_fallback_authorization_details = + Some(sandbox_fallback_authorization_details); + } + if let Some(sandbox_not_applied) = sandbox_not_applied_from_meta(&meta) { + self.sandbox_not_applied = Some(sandbox_not_applied); + } if let Some(title) = title { if self.kind == acp::ToolKind::Execute { @@ -2300,6 +2377,8 @@ impl AcpThread { tool_name: None, subagent_session_info: None, sandbox_authorization_details: None, + sandbox_fallback_authorization_details: None, + sandbox_not_applied: None, }; self.push_entry(AgentThreadEntry::ToolCall(failed_tool_call), cx); return Ok(()); diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 186a9db42287ee..84880758888ba6 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -9,6 +9,7 @@ use http_proxy::{Allowlist, ProxyConfig, ProxyEvent, ProxyHandle, UpstreamProxy} use language::LanguageRegistry; use markdown::Markdown; use project::Project; +use serde::{Deserialize, Serialize}; use std::{ path::PathBuf, process::ExitStatus, @@ -76,9 +77,51 @@ impl SandboxNetworkAccess { } } +/// A structured, serializable reason the OS sandbox could not be created for a +/// command. Mirrors the Linux/WSL launcher's failure modes (Bubblewrap); +/// 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 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(), + } + } +} + impl SandboxWrap { /// Whether the OS sandbox for this request can actually be created right now, - /// returning a short human-readable reason when it can't. + /// 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 @@ -86,9 +129,14 @@ impl SandboxWrap { /// (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, cwd: Option<&std::path::Path>) -> Result<(), String> { + pub fn can_create_sandbox( + &self, + cwd: Option<&std::path::Path>, + ) -> Result<(), LinuxWslSandboxError> { #[cfg(target_os = "linux")] { + use sandbox::linux_bubblewrap::LauncherStatus; + let writable: Vec<&std::path::Path> = self .writable_paths .iter() @@ -101,7 +149,15 @@ impl SandboxWrap { allow_fs_write: self.allow_fs_write, }; sandbox::linux_bubblewrap::check_can_create_sandbox(&writable, permissions, cwd) - .map_err(|status| status.describe().to_string()) + .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"))] { @@ -111,6 +167,28 @@ impl SandboxWrap { } } +/// 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 { + /// Unsandboxed execution is permanently allowed via the `allow_unsandboxed` + /// setting. + DisabledForever, + /// The user allowed unsandboxed execution for the rest of this thread after + /// an earlier sandbox failure. There is always a preceding tool call whose + /// reason is [`SandboxNotAppliedReason::ErrorLinuxWsl`]. + DisabledForThisThread, + /// The Linux/WSL (Bubblewrap) sandbox could not be created for this command. + ErrorLinuxWsl(LinuxWslSandboxError), +} + /// Opaque RAII handle the sandbox implementation hands back to keep its /// per-command resources (e.g. an on-disk Seatbelt config file) alive for /// the duration of the spawned command. `Terminal` holds it in a field diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs index c9cc400e19c26c..e907a992a1563c 100644 --- a/crates/agent/src/sandboxing.rs +++ b/crates/agent/src/sandboxing.rs @@ -109,6 +109,12 @@ pub(crate) struct ThreadSandboxGrants { network_hosts: Vec, allow_fs_write_all: bool, unsandboxed: bool, + /// Whether the user approved running commands *without* a sandbox for the + /// rest of the thread when the OS sandbox could not be created (the + /// fallback prompt's "Allow for this thread"). Distinct from + /// `unsandboxed`, which records a model-requested escape; this is a + /// user-acknowledged degradation because the sandbox is unavailable. + sandbox_fallback: bool, /// Canonicalized paths granted write access for the thread. Each covers its /// whole subtree; redundant children are pruned on insert. write_paths: Vec, @@ -175,6 +181,21 @@ impl ThreadSandboxGrants { } } + /// Whether the user allowed running commands unsandboxed for the rest of + /// the thread (the fallback prompt's "Allow for this thread"). Distinct + /// from the persistent `allow_unsandboxed` setting. + pub fn fallback_granted_for_thread(&self) -> bool { + self.sandbox_fallback + } + + /// Record that the user approved running commands unsandboxed for the rest + /// of the thread when the sandbox can't be created. Only Linux can fail to + /// create a sandbox, so this is Linux-only. + #[cfg(target_os = "linux")] + pub fn record_fallback(&mut self) { + self.sandbox_fallback = true; + } + /// Record everything in `request` as granted for the rest of the thread, /// pruning entries that become redundant. pub fn record(&mut self, request: &SandboxRequest) { @@ -314,6 +335,19 @@ mod tests { grants.effective_with_persistent(request, &SandboxPermissions::default()) } + #[cfg(target_os = "linux")] + #[test] + fn fallback_granted_for_thread_tracks_record_fallback() { + let mut grants = ThreadSandboxGrants::default(); + assert!(!grants.fallback_granted_for_thread()); + + // The thread-scoped fallback grant is independent of the + // model-requested `unsandboxed` grant. + grants.record_fallback(); + assert!(grants.fallback_granted_for_thread()); + assert!(!covers(&grants, &unsandboxed_request())); + } + #[test] fn empty_grants_cover_nothing() { let grants = ThreadSandboxGrants::default(); diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 0bf1277405ce61..07359eeb012941 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -5000,6 +5000,20 @@ impl ThreadEventStream { } } +/// The user's choice when the OS sandbox could not be created for a command +/// (see [`ToolCallEventStream::authorize_sandbox_fallback`]). Only Linux can +/// fail to create a sandbox, so this is Linux-only. +#[cfg(target_os = "linux")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SandboxFallbackDecision { + /// Try creating the sandbox again (e.g. after the user installed `bwrap`). + Retry, + /// Run the command without a sandbox. + RunUnsandboxed, + /// Don't run the command at all. + Deny, +} + #[derive(Clone)] pub struct ToolCallEventStream { tool_use_id: LanguageModelToolUseId, @@ -5495,6 +5509,160 @@ impl ToolCallEventStream { .effective_with_persistent(request, persistent) } + /// Whether the user allowed running commands unsandboxed for the rest of + /// the thread (distinct from the persistent `allow_unsandboxed` setting). + pub(crate) fn sandbox_fallback_granted_for_thread(&self) -> bool { + self.sandbox_grants.borrow().fallback_granted_for_thread() + } + + /// Ask the user how to proceed when the OS sandbox could not be created + /// for a command (for example, `bwrap` is missing or user namespaces are + /// disabled). + /// + /// Unlike [`Self::authorize_sandbox`] — which gates a model-requested + /// *escalation* — this surfaces a *system limitation*: the sandbox failed, + /// so the prompt explains why (`reason`) and lets the user retry, run the + /// command unsandboxed (once / for this thread / always), or deny it. The + /// "for this thread" choice is recorded in the in-memory thread grants and + /// "always" is persisted as the `allow_unsandboxed` setting. Only Linux can + /// fail to create a sandbox, so this is Linux-only. + /// + /// `retries` is how many times the user has already pressed Retry for this + /// command; it's shown on the button so repeated presses visibly advance + /// ("Retry", then "Retry (attempt 1)", "Retry (attempt 2)", …). + #[cfg(target_os = "linux")] + pub(crate) fn authorize_sandbox_fallback( + &self, + command: Option, + reason: String, + retries: usize, + cx: &mut App, + ) -> Task> { + let details = acp_thread::SandboxFallbackAuthorizationDetails { command, reason }; + let retry_label = if retries == 0 { + "Retry".to_string() + } else { + format!("Retry (attempt {retries})") + }; + let options = acp_thread::PermissionOptions::Flat(vec![ + // Retry isn't an allow/deny choice; the UI renders it with its own + // icon and we dispatch on the option id, so the kind here only + // governs keybindings. Use `RejectAlways` (which has none) so the + // "allow once" shortcut maps to "Run without sandbox once" rather + // than to Retry. + acp::PermissionOption::new( + acp::PermissionOptionId::new(acp_thread::SANDBOX_FALLBACK_RETRY_OPTION_ID), + retry_label, + acp::PermissionOptionKind::RejectAlways, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowOnce.as_id()), + "Run without sandbox once", + acp::PermissionOptionKind::AllowOnce, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()), + "Run without sandbox for this thread", + acp::PermissionOptionKind::AllowAlways, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowAlways.as_id()), + "Always run without sandbox", + acp::PermissionOptionKind::AllowAlways, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()), + "Deny", + acp::PermissionOptionKind::RejectOnce, + ), + ]); + + let fs = self.fs.clone(); + let stream = self.stream.clone(); + let tool_use_id = self.tool_use_id.clone(); + let sandbox_grants = self.sandbox_grants.clone(); + cx.spawn(async move |cx| { + let (response_tx, response_rx) = oneshot::channel(); + if let Err(error) = stream + .0 + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + // Deliberately leave the tool-call title untouched so + // the card keeps showing the *command* (not the + // failure reason): it's critical the user can see what + // they're approving to run unsandboxed. The reason is + // surfaced separately by the fallback details / warning. + tool_call: acp::ToolCallUpdate::new( + tool_use_id.to_string(), + acp::ToolCallUpdateFields::new(), + ) + .meta( + acp_thread::meta_with_sandbox_fallback_authorization(details), + ), + options, + response: response_tx, + context: None, + kind: acp_thread::AuthorizationKind::ActionChoice, + }, + ))) + { + log::error!("Failed to send sandbox fallback authorization: {error}"); + return Err(anyhow!( + "Failed to send sandbox fallback authorization: {error}" + )); + } + + let outcome = response_rx + .await + .map_err(|_| anyhow!("authorization channel closed"))?; + + let option_id = outcome.option_id.0.as_ref(); + if option_id == acp_thread::SANDBOX_FALLBACK_RETRY_OPTION_ID { + return Ok(SandboxFallbackDecision::Retry); + } + match acp_thread::SandboxPermission::from_id(option_id) { + Some(acp_thread::SandboxPermission::AllowOnce) => { + Ok(SandboxFallbackDecision::RunUnsandboxed) + } + Some(acp_thread::SandboxPermission::AllowThread) => { + sandbox_grants.borrow_mut().record_fallback(); + Ok(SandboxFallbackDecision::RunUnsandboxed) + } + Some(acp_thread::SandboxPermission::AllowAlways) => { + sandbox_grants.borrow_mut().record_fallback(); + Self::persist_sandbox_unsandboxed_permission(fs, cx); + Ok(SandboxFallbackDecision::RunUnsandboxed) + } + Some(acp_thread::SandboxPermission::Deny) => Ok(SandboxFallbackDecision::Deny), + None => { + let other = option_id; + debug_assert!(false, "unexpected sandbox fallback option_id: {other}"); + Ok(SandboxFallbackDecision::Deny) + } + } + }) + } + + /// Persist the `allow_unsandboxed` setting so future commands skip the + /// sandbox when it can't be created, without prompting again. + #[cfg(target_os = "linux")] + fn persist_sandbox_unsandboxed_permission(fs: Option>, cx: &AsyncApp) { + let Some(fs) = fs else { + log::error!( + "Cannot persist \"allow always\" unsandboxed permission: no filesystem available" + ); + return; + }; + cx.update(|cx| { + update_settings_file(fs, cx, move |settings, _| { + settings + .agent + .get_or_insert_default() + .allow_sandbox_unsandboxed(); + }); + }); + } + /// Prompts the user to choose between an explicit set of actions and /// returns the chosen `option_id`. /// @@ -6942,6 +7110,154 @@ mod tests { ); } + #[cfg(target_os = "linux")] + #[gpui::test] + async fn test_authorize_sandbox_fallback_options_and_details(cx: &mut TestAppContext) { + crate::tests::init_test(cx); + + let (event_stream, mut receiver) = ToolCallEventStream::test(); + let authorize = cx.update(|cx| { + event_stream.authorize_sandbox_fallback( + Some("cargo build".to_string()), + "bwrap not found on PATH".to_string(), + 0, + cx, + ) + }); + let authorization = receiver.expect_authorization().await; + let details = acp_thread::sandbox_fallback_authorization_details_from_meta( + &authorization.tool_call.meta, + ) + .expect("fallback authorization should include details"); + assert_eq!(details.command.as_deref(), Some("cargo build")); + assert_eq!(details.reason, "bwrap not found on PATH"); + + let acp_thread::PermissionOptions::Flat(options) = &authorization.options else { + panic!("expected flat fallback permission options"); + }; + let options = options + .iter() + .map(|option| (option.option_id.0.as_ref(), option.name.as_ref())) + .collect::>(); + assert_eq!( + options, + vec![ + ("retry", "Retry"), + ("allow", "Run without sandbox once"), + ("allow_thread", "Run without sandbox for this thread"), + ("allow_always", "Always run without sandbox"), + ("deny", "Deny"), + ] + ); + + authorization + .response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new(acp_thread::SANDBOX_FALLBACK_RETRY_OPTION_ID), + acp::PermissionOptionKind::RejectAlways, + )) + .unwrap(); + assert_eq!(authorize.await.unwrap(), SandboxFallbackDecision::Retry); + } + + #[cfg(target_os = "linux")] + #[gpui::test] + async fn test_authorize_sandbox_fallback_retry_label_counts_attempts(cx: &mut TestAppContext) { + crate::tests::init_test(cx); + + async fn retry_label(cx: &mut TestAppContext, retries: usize) -> String { + let (event_stream, mut receiver) = ToolCallEventStream::test(); + let authorize = cx.update(|cx| { + event_stream.authorize_sandbox_fallback( + None, + "probe failed".to_string(), + retries, + cx, + ) + }); + let authorization = receiver.expect_authorization().await; + let acp_thread::PermissionOptions::Flat(options) = &authorization.options else { + panic!("expected flat fallback permission options"); + }; + let label = options + .iter() + .find(|option| { + option.option_id.0.as_ref() == acp_thread::SANDBOX_FALLBACK_RETRY_OPTION_ID + }) + .expect("retry option present") + .name + .to_string(); + authorization + .response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new(acp_thread::SANDBOX_FALLBACK_RETRY_OPTION_ID), + acp::PermissionOptionKind::RejectAlways, + )) + .unwrap(); + authorize.await.unwrap(); + label + } + + assert_eq!(retry_label(cx, 0).await, "Retry"); + assert_eq!(retry_label(cx, 1).await, "Retry (attempt 1)"); + assert_eq!(retry_label(cx, 2).await, "Retry (attempt 2)"); + } + + #[cfg(target_os = "linux")] + #[gpui::test] + async fn test_authorize_sandbox_fallback_allow_thread_records_grant(cx: &mut TestAppContext) { + crate::tests::init_test(cx); + + let (event_stream, mut receiver) = ToolCallEventStream::test(); + assert!(!event_stream.sandbox_fallback_granted_for_thread()); + + let authorize = cx.update(|cx| { + event_stream.authorize_sandbox_fallback( + Some("cargo build".to_string()), + "user namespaces are disabled".to_string(), + 0, + cx, + ) + }); + let authorization = receiver.expect_authorization().await; + authorization + .response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()), + acp::PermissionOptionKind::AllowAlways, + )) + .unwrap(); + assert_eq!( + authorize.await.unwrap(), + SandboxFallbackDecision::RunUnsandboxed + ); + + // The thread-scoped grant now lets later commands skip the sandbox + // without prompting again. + assert!(event_stream.sandbox_fallback_granted_for_thread()); + } + + #[cfg(target_os = "linux")] + #[gpui::test] + async fn test_authorize_sandbox_fallback_deny(cx: &mut TestAppContext) { + crate::tests::init_test(cx); + + let (event_stream, mut receiver) = ToolCallEventStream::test(); + let authorize = cx.update(|cx| { + event_stream.authorize_sandbox_fallback(None, "bwrap probe failed".to_string(), 0, cx) + }); + let authorization = receiver.expect_authorization().await; + authorization + .response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()), + acp::PermissionOptionKind::RejectOnce, + )) + .unwrap(); + assert_eq!(authorize.await.unwrap(), SandboxFallbackDecision::Deny); + assert!(!event_stream.sandbox_fallback_granted_for_thread()); + } + #[test] fn test_auto_resolve_permission_outcome_uses_once_only_options() { let options = acp_thread::PermissionOptions::Dropdown(vec![ diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index ceb949e1bf5767..4e1c8f488309eb 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -13,6 +13,8 @@ use std::{ time::Duration, }; +#[cfg(target_os = "linux")] +use crate::SandboxFallbackDecision; use crate::sandboxing::{NetworkRequest, sandboxing_enabled}; use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream, ToolInput}; @@ -468,55 +470,155 @@ async fn run_terminal_tool( // Build the sandbox request, then decide whether we can actually sandbox. // The sandbox itself never silently runs a command unsandboxed: if it can't - // create the sandbox it aborts. As the consumer we fail *open* for now — we - // re-run the command without a sandbox so a missing/blocked `bwrap` doesn't - // break the terminal — but we tell the model we did, via `sandbox_fallback`. - let mut sandbox_fallback: Option = None; + // create the sandbox it aborts. As the consumer we may still run the command + // without a sandbox (when the user has opted into that), but we record + // *why* in `sandbox_not_applied` so we can warn the user and tell the agent. + let mut sandbox_not_applied: Option = None; let sandbox_wrap = if sandboxing && !want_unsandboxed { let sandbox_permissions = cx.update(|cx| { agent_settings::AgentSettings::get_global(cx) .sandbox_permissions .clone() }); - 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 wrap = acp_thread::SandboxWrap { - writable_paths, - extra_write_paths: effective.write_paths, - network: network_request_to_sandbox_network_access(&effective.network), - allow_fs_write: effective.allow_fs_write_all, - is_local: is_local_project, - }; - // The viability check runs a brief probe subprocess, so do it off the - // main thread. - let probe_wrap = wrap.clone(); - let probe_cwd = working_dir.clone(); - let availability = cx - .background_executor() - .spawn(async move { probe_wrap.can_create_sandbox(probe_cwd.as_deref()) }) - .await; - - match availability { - Ok(()) => Some(wrap), - Err(reason) => { - sandbox_fallback = Some(format!( - "Note: failed to create the sandbox ({reason}); ran this command \ - WITHOUT a sandbox." - )); - None + if sandbox_permissions.allow_unsandboxed { + // Unsandboxed execution is permanently allowed in settings: skip + // the sandbox machinery entirely and run the command the same way + // the unsandboxed terminal tool does. + sandbox_not_applied = Some(acp_thread::SandboxNotAppliedReason::DisabledForever); + None + } else if event_stream.sandbox_fallback_granted_for_thread() { + // The user allowed unsandboxed execution for the rest of this + // thread after an earlier sandbox failure (Linux only). + #[cfg(target_os = "linux")] + { + sandbox_not_applied = + Some(acp_thread::SandboxNotAppliedReason::DisabledForThisThread); + } + 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 wrap = acp_thread::SandboxWrap { + writable_paths, + extra_write_paths: effective.write_paths, + network: network_request_to_sandbox_network_access(&effective.network), + allow_fs_write: effective.allow_fs_write_all, + is_local: is_local_project, + }; + + // The viability check runs a brief probe subprocess, so do it off + // the main thread. On Linux the sandbox can genuinely be unavailable + // (missing `bwrap`, disabled user namespaces, …); rather than + // silently failing open, we ask the user how to proceed and let them + // retry after fixing their environment. (On other platforms the + // probe never fails, so this prompt is Linux-only.) + // Each retry re-probes from scratch, so the failure reason shown to + // the user reflects the *current* environment (e.g. it can change + // from "no bwrap" to "bwrap is setuid" after they install one). + #[cfg(target_os = "linux")] + { + let mut retries = 0usize; + loop { + let probe_wrap = wrap.clone(); + let probe_cwd = working_dir.clone(); + let error = match cx + .background_executor() + .spawn(async move { probe_wrap.can_create_sandbox(probe_cwd.as_deref()) }) + .await + { + Ok(()) => break Some(wrap), + Err(error) => error, + }; + + // Distinct from the intentional skips above (settings / thread + // grant): the sandbox was requested but couldn't be created. + log::warn!( + "Failed to create a sandbox for an agent terminal command: {error:?}" + ); + + let decision = cx + .update(|cx| { + event_stream.authorize_sandbox_fallback( + Some(input.command.clone()), + error.user_facing_message(), + retries, + cx, + ) + }) + .await; + match decision { + Ok(SandboxFallbackDecision::Retry) => { + retries += 1; + continue; + } + Ok(SandboxFallbackDecision::RunUnsandboxed) => { + sandbox_not_applied = + Some(acp_thread::SandboxNotAppliedReason::ErrorLinuxWsl(error)); + break None; + } + Ok(SandboxFallbackDecision::Deny) | Err(_) => { + return Ok(format!( + "Command cancelled: the sandbox could not be created ({}) and \ + the user declined to run it without one.", + error.user_facing_message() + )); + } + } + } + } + #[cfg(not(target_os = "linux"))] + { + let probe_wrap = wrap.clone(); + let probe_cwd = working_dir.clone(); + match cx + .background_executor() + .spawn(async move { probe_wrap.can_create_sandbox(probe_cwd.as_deref()) }) + .await + { + Ok(()) => Some(wrap), + Err(error) => { + // The probe can't fail off Linux; keep failing open just + // in case a future platform's probe ever does. + log::warn!( + "Failed to create a sandbox for an agent terminal command: {error:?}" + ); + None + } + } } } } else { None }; + // When sandboxing was active but we ran without a sandbox, tell the agent + // so it can account for the weaker isolation. The message is self-contained + // per reason, so every affected command communicates the state. + let sandbox_note = sandbox_not_applied.as_ref().map(|reason| match reason { + acp_thread::SandboxNotAppliedReason::DisabledForever => { + "Note: this command ran WITHOUT an OS sandbox because unsandboxed execution is \ + enabled in settings." + .to_string() + } + acp_thread::SandboxNotAppliedReason::DisabledForThisThread => { + "Note: this command ran WITHOUT an OS sandbox because you allowed unsandboxed \ + execution for the rest of this thread." + .to_string() + } + acp_thread::SandboxNotAppliedReason::ErrorLinuxWsl(error) => format!( + "Note: I tried to run this command inside an OS sandbox, but it could not be \ + created ({}). It ran WITHOUT a sandbox.", + error.user_facing_message() + ), + }); + let output_byte_limit = if selection.is_enabled() { None } else { @@ -536,9 +638,17 @@ async fn run_terminal_tool( .map_err(|e| e.to_string())?; let terminal_id = terminal.id(cx).map_err(|e| e.to_string())?; - event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ - acp::ToolCallContent::Terminal(acp::Terminal::new(terminal_id)), - ])); + let fields = acp::ToolCallUpdateFields::new().content(vec![acp::ToolCallContent::Terminal( + acp::Terminal::new(terminal_id), + )]); + if let Some(reason) = &sandbox_not_applied { + event_stream.update_fields_with_meta( + fields, + Some(acp_thread::meta_with_sandbox_not_applied(reason)), + ); + } else { + event_stream.update_fields(fields); + } let timeout = input.timeout_ms.map(Duration::from_millis); @@ -583,7 +693,7 @@ async fn run_terminal_tool( let output = terminal.current_output(cx).map_err(|e| e.to_string())?; let result = process_content(output, &input.command, timed_out, user_stopped, selection); - Ok(match sandbox_fallback { + Ok(match sandbox_note { Some(note) => format!("{note}\n\n{result}"), None => result, }) diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index e37e115a4745db..d01c3f0305188f 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -7,7 +7,10 @@ use crate::{ use agent_client_protocol::schema as acp; use std::cell::RefCell; -use acp_thread::{ContentBlock, PlanEntry, SandboxAuthorizationDetails}; +use acp_thread::{ + ContentBlock, PlanEntry, SandboxAuthorizationDetails, SandboxFallbackAuthorizationDetails, + SandboxNotAppliedReason, +}; use agent::{SkillLoadingIssue, SkillLoadingIssueKind, SkillLoadingIssuesUpdated}; use agent_settings::UserAgentsMd; use agent_skills::MAX_SKILL_DESCRIPTION_LEN; @@ -7179,6 +7182,9 @@ impl ThreadView { .child(header) .child(command_element), ) + .when_some(tool_call.sandbox_not_applied.as_ref(), |this, reason| { + this.child(self.render_sandbox_not_applied_warning(reason, terminal, cx)) + }) .when(is_expanded && terminal_view.is_some(), |this| { this.child( div() @@ -7226,6 +7232,120 @@ impl ThreadView { .into_any() } + /// Render the "ran without sandbox" warning shown on a terminal tool card, + /// tailored to *why* the sandbox wasn't applied. + fn render_sandbox_not_applied_warning( + &self, + reason: &SandboxNotAppliedReason, + terminal: &Entity, + cx: &Context, + ) -> AnyElement { + // (title, optional detail line, whether to offer the settings shortcut) + let (title, detail, show_settings_button): (SharedString, Option, bool) = + match reason { + SandboxNotAppliedReason::DisabledForever => ( + "Ran without sandbox".into(), + Some("Unsandboxed execution is enabled in settings.".into()), + true, + ), + SandboxNotAppliedReason::ErrorLinuxWsl(error) => ( + "Couldn't create a sandbox".into(), + Some(error.user_facing_message().into()), + false, + ), + SandboxNotAppliedReason::DisabledForThisThread => { + // The grant only exists because an earlier command failed to + // create a sandbox; surface that same explanation here. + let detail = self + .find_thread_sandbox_error(cx) + .map(|error| { + SharedString::from(format!( + "Allowed for this thread after the sandbox failed: {}", + error.user_facing_message() + )) + }) + .unwrap_or_else(|| { + "Unsandboxed execution is allowed for the rest of this thread.".into() + }); + ("Ran without sandbox".into(), Some(detail), false) + } + }; + + h_flex() + .px_2() + .py_1() + .gap_1() + .justify_between() + .border_t_1() + .border_color(cx.theme().status().warning_border) + .bg(cx.theme().status().warning_background.opacity(0.5)) + .child( + h_flex() + .min_w_0() + .flex_1() + .gap_1p5() + .items_start() + .child( + Icon::new(IconName::Warning) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + .child( + v_flex() + .min_w_0() + .gap_0p5() + .child(Label::new(title).size(LabelSize::Small).color(Color::Muted)) + .when_some(detail, |this, detail| { + this.child( + Label::new(detail) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + }), + ), + ) + .when(show_settings_button, |this| { + this.child( + IconButton::new( + SharedString::from(format!( + "open-sandbox-setting-{}", + terminal.entity_id() + )), + IconName::Settings, + ) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Open the unsandboxed execution setting")) + .on_click(|_event, window, cx| { + window.dispatch_action( + Box::new(zed_actions::OpenSettingsAt { + path: zed_actions::AGENT_ALLOW_UNSANDBOXED_SETTINGS_PATH + .to_string(), + target: None, + }), + cx, + ); + }), + ) + }) + .into_any_element() + } + + /// Find the first terminal tool call in the thread whose sandbox couldn't be + /// created, so a later "disabled for this thread" warning can reuse the same + /// explanation of *why* the sandbox failed. + fn find_thread_sandbox_error(&self, cx: &App) -> Option { + self.thread.read(cx).entries().iter().find_map(|entry| { + if let AgentThreadEntry::ToolCall(tool_call) = entry + && let Some(SandboxNotAppliedReason::ErrorLinuxWsl(error)) = + &tool_call.sandbox_not_applied + { + return Some(error.clone()); + } + None + }) + } + fn is_first_tool_call( &self, active_session_id: &acp::SessionId, @@ -7391,6 +7511,14 @@ impl ThreadView { )) }, ) + .when_some( + tool_call.sandbox_fallback_authorization_details.as_ref(), + |this, details| { + this.child( + self.render_sandbox_fallback_authorization_details(details, cx), + ) + }, + ) .when(should_show_raw_input, |this| { let is_raw_input_expanded = self.expanded_tool_call_raw_inputs.contains(&tool_call.id); @@ -8059,6 +8187,43 @@ impl ThreadView { .into_any_element() } + fn render_sandbox_fallback_authorization_details( + &self, + details: &SandboxFallbackAuthorizationDetails, + cx: &Context, + ) -> AnyElement { + // The command itself is shown in the tool-call header (a collapsible + // command), so here we only explain *why* the sandbox couldn't be + // created — the user needs both to decide whether to run unsandboxed. + if details.reason.is_empty() { + return Empty.into_any_element(); + } + + h_flex() + .p_1p5() + .gap_1p5() + .items_start() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .child( + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::Small), + ) + .child( + v_flex() + .min_w_0() + .gap_0p5() + .child( + Label::new("Couldn't create a sandbox") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Label::new(details.reason.clone()).size(LabelSize::Small)), + ) + .into_any_element() + } + fn render_sandbox_authorization_command(entry_ix: usize, command: &str, cx: &App) -> Div { let group = SharedString::from(format!("sandbox-authorization-command-{entry_ix}")); let command = SharedString::from(command.to_string()); @@ -8623,37 +8788,52 @@ impl ThreadView { let option_id = SharedString::from(option.option_id.0.clone()); Button::new((option_id, entry_ix), option.name.clone()) .map(|this| { - let (icon, action) = match option.kind { - acp::PermissionOptionKind::AllowOnce => ( - Icon::new(IconName::Check) + // The sandbox-fallback prompt offers a "Retry" option + // that re-attempts creating the sandbox; it isn't an + // allow/deny choice, so give it its own icon and no + // keybinding. + let is_retry = option.option_id.0.as_ref() + == acp_thread::SANDBOX_FALLBACK_RETRY_OPTION_ID; + let (icon, action) = if is_retry { + ( + Icon::new(IconName::RotateCcw) .size(IconSize::XSmall) - .color(Color::Success), - Some(&AllowOnce as &dyn Action), - ), - acp::PermissionOptionKind::AllowAlways => ( - Icon::new(IconName::CheckDouble) - .size(IconSize::XSmall) - .color(Color::Success), - if option.option_id.0.as_ref() - == acp_thread::SandboxPermission::AllowThread.as_id() - { - None - } else { - Some(&AllowAlways as &dyn Action) - }, - ), - acp::PermissionOptionKind::RejectOnce => ( - Icon::new(IconName::Close) - .size(IconSize::XSmall) - .color(Color::Error), - Some(&RejectOnce as &dyn Action), - ), - acp::PermissionOptionKind::RejectAlways | _ => ( - Icon::new(IconName::Close) - .size(IconSize::XSmall) - .color(Color::Error), + .color(Color::Muted), None, - ), + ) + } else { + match option.kind { + acp::PermissionOptionKind::AllowOnce => ( + Icon::new(IconName::Check) + .size(IconSize::XSmall) + .color(Color::Success), + Some(&AllowOnce as &dyn Action), + ), + acp::PermissionOptionKind::AllowAlways => ( + Icon::new(IconName::CheckDouble) + .size(IconSize::XSmall) + .color(Color::Success), + if option.option_id.0.as_ref() + == acp_thread::SandboxPermission::AllowThread.as_id() + { + None + } else { + Some(&AllowAlways as &dyn Action) + }, + ), + acp::PermissionOptionKind::RejectOnce => ( + Icon::new(IconName::Close) + .size(IconSize::XSmall) + .color(Color::Error), + Some(&RejectOnce as &dyn Action), + ), + acp::PermissionOptionKind::RejectAlways | _ => ( + Icon::new(IconName::Close) + .size(IconSize::XSmall) + .color(Color::Error), + None, + ), + } }; let this = this.start_icon(icon); diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 57038d8a6ce393..65ce54aa67e39f 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -7884,6 +7884,33 @@ fn ai_page() -> SettingsPage { ]; items.extend([ + SettingsPageItem::SettingItem(SettingItem { + title: "Allow Unsandboxed Terminal Commands", + description: "When enabled, agent terminal commands run without the OS sandbox instead of prompting when the sandbox can't be created.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some(zed_actions::AGENT_ALLOW_UNSANDBOXED_SETTINGS_PATH), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .sandbox_permissions + .as_ref()? + .allow_unsandboxed + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .agent + .get_or_insert_default() + .sandbox_permissions + .get_or_insert_default() + .allow_unsandboxed = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Single File Review", description: "When enabled, agent edits will also be displayed in single-file buffers for review.", diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index b0ff0a7ae0fff6..376e6835fc4156 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -152,6 +152,11 @@ pub struct OpenSettingsAt { /// `OpenSettingsAt` path of the agent skills page in the settings UI. pub const AGENT_SKILLS_SETTINGS_PATH: &str = "agent.skills"; +/// `OpenSettingsAt` path of the "allow unsandboxed terminal commands" setting +/// in the settings UI. +pub const AGENT_ALLOW_UNSANDBOXED_SETTINGS_PATH: &str = + "agent.sandbox_permissions.allow_unsandboxed"; + #[derive(PartialEq, Clone, Debug, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum OpenSettingsAtTarget {