Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions crates/acp_thread/src/acp_thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<acp::Meta>,
) -> Option<SandboxFallbackAuthorizationDetails> {
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<acp::Meta>) -> Option<SandboxNotAppliedReason> {
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
Expand Down Expand Up @@ -429,6 +487,11 @@ pub struct ToolCall {
pub tool_name: Option<SharedString>,
pub subagent_session_info: Option<SubagentSessionInfo>,
pub sandbox_authorization_details: Option<SandboxAuthorizationDetails>,
pub sandbox_fallback_authorization_details: Option<SandboxFallbackAuthorizationDetails>,
/// 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<SandboxNotAppliedReason>,
}

impl ToolCall {
Expand Down Expand Up @@ -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))
Expand All @@ -493,6 +559,8 @@ impl ToolCall {
tool_name,
subagent_session_info,
sandbox_authorization_details,
sandbox_fallback_authorization_details,
sandbox_not_applied,
};
Ok(result)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(());
Expand Down
84 changes: 81 additions & 3 deletions crates/acp_thread/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -76,19 +77,66 @@ 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
/// 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, 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()
Expand All @@ -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"))]
{
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions crates/agent/src/sandboxing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ pub(crate) struct ThreadSandboxGrants {
network_hosts: Vec<HostPattern>,
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<PathBuf>,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading