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:
1 parent
d89fc86740
commit
5c5ff37936
8 files changed
+901
-72
No files matched your search
@@ -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(());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user