agent: Better sandboxing UI (#59437)

Adds UI for the various scenarios where sandboxing can fail on linux:
- no `bwrap` on the path
- `bwrap` is setuid
- `bwrap` exists but cannot create unprivileged userns

---

Release Notes:

- N/A or Added/Fixed/Improved ...

---------

Co-authored-by: Martin Ye <martin@zed.dev>
This commit is contained in:
Cameron McloughlinandMartin Ye authored and GitHub committed 2026-06-16 17:04:21 +00:00
1 parent d89fc86740
commit 5c5ff37936
8 files changed
+901 -72

No files matched your search

+79
View File
@@ -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
@@ -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 {
@@ -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(());
+81 -3
View File
@@ -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
+34
View File
@@ -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>,
@@ -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();
+316
View File
@@ -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<String>,
reason: String,
retries: usize,
cx: &mut App,
) -> Task<Result<SandboxFallbackDecision>> {
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<Arc<dyn Fs>>, 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::<Vec<_>>();
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![
+149 -39
View File
@@ -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<String> = 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<acp_thread::SandboxNotAppliedReason> = 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<PathBuf> = cx.update(|cx| {
project
.read(cx)
.worktrees(cx)
.map(|w| w.read(cx).abs_path().to_path_buf())
.collect::<Vec<_>>()
});
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;
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<PathBuf> = cx.update(|cx| {
project
.read(cx)
.worktrees(cx)
.map(|w| w.read(cx).abs_path().to_path_buf())
.collect::<Vec<_>>()
});
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,
};
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
// 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,
})
@@ -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<acp_thread::Terminal>,
cx: &Context<Self>,
) -> AnyElement {
// (title, optional detail line, whether to offer the settings shortcut)
let (title, detail, show_settings_button): (SharedString, Option<SharedString>, 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<acp_thread::LinuxWslSandboxError> {
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<Self>,
) -> 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);
+27
View File
@@ -7930,6 +7930,33 @@ fn ai_page(cx: &App) -> 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.",
+5
View File
@@ -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 {