agent: Model sandbox network escalation as a host allowlist (#59219)

Third of a four-PR stack adding hostname-allowlisted network access to
agent terminal sandboxing. Stacked on #59218 — review/merge #59217 and
#59218 first.

Replaces the boolean `allow_network` terminal-sandbox escalation with a
hostname allowlist. The `terminal` tool now takes `allow_hosts` (exact
hostnames or leading-`*.` subdomain wildcards, validated up front) and
`allow_all_hosts` as a broad escape hatch. Approval prompts name the
requested hosts, thread grants and persistent "allow always" settings
cover requests via host-pattern subsumption the same way write paths use
subtree containment, and the authorization card in the agent panel lists
the requested hosts.

Host allowlists aren't enforced yet in this PR: `SandboxWrap` carries
the allowlist, but any network request still maps to unrestricted egress
at the Seatbelt layer, matching previous behavior. Enforcement via the
in-process proxy lands in the final PR of the stack. All of this is
behind the existing sandboxing feature flag. For non-local projects
(where the loopback proxy won't apply), host requests are widened to
"arbitrary network access" before prompting so approvals match
enforcement. Old persisted `network: bool` authorization details still
deserialize via a serde alias.

Release Notes:

- N/A

---------

Co-authored-by: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com>
Co-authored-by: Martin Ye <martin@zed.dev>
This commit is contained in:
authored and GitHub committed 2026-06-15 22:06:33 +00:00
1 parent d002618cfc
commit d39cbcfc4c
13 files changed
+847 -187

No files matched your search

Generated
+2
View File
@@ -98,6 +98,7 @@ dependencies = [
"file_icons",
"futures 0.3.32",
"gpui",
"http_proxy",
"image",
"indoc",
"itertools 0.14.0",
@@ -258,6 +259,7 @@ dependencies = [
"heck 0.5.0",
"html_to_markdown",
"http_client",
"http_proxy",
"indoc",
"itertools 0.14.0",
"language",
+1
View File
@@ -29,6 +29,7 @@ multi_buffer.workspace = true
file_icons.workspace = true
futures.workspace = true
gpui.workspace = true
http_proxy.workspace = true
itertools.workspace = true
language.workspace = true
language_model.workspace = true
+25 -1
View File
@@ -164,8 +164,17 @@ impl SandboxPermission {
pub struct SandboxAuthorizationDetails {
#[serde(default)]
pub command: Option<String>,
/// Specific hosts the command requested network access to, in canonical
/// form (`github.com`, `*.npmjs.org`). Empty when no specific hosts were
/// requested (see `network_all_hosts`).
#[serde(default)]
pub network: bool,
pub network_hosts: Vec<String>,
/// Whether the command requested access to any host ("arbitrary network
/// access"). The `network` alias deserializes the field this replaced —
/// a plain bool meaning "network access" — so details persisted by older
/// builds still render the network request.
#[serde(default, alias = "network")]
pub network_all_hosts: bool,
#[serde(default)]
pub allow_fs_write_all: bool,
#[serde(default)]
@@ -3695,6 +3704,21 @@ mod tests {
});
}
#[test]
fn sandbox_authorization_details_deserialize_legacy_network_bool() {
// Older builds persisted `network: bool`; the `alias` on
// `network_all_hosts` must keep those details rendering as a
// network request rather than silently dropping it.
let details: SandboxAuthorizationDetails =
serde_json::from_value(json!({ "network": true })).unwrap();
assert!(details.network_all_hosts);
assert!(details.network_hosts.is_empty());
let details: SandboxAuthorizationDetails =
serde_json::from_value(json!({ "network": false })).unwrap();
assert!(!details.network_all_hosts);
}
#[gpui::test]
async fn test_terminal_output_buffered_before_created_renders(cx: &mut gpui::TestAppContext) {
init_test(cx);
+11 -8
View File
@@ -2,6 +2,7 @@ use agent_client_protocol::schema as acp;
use anyhow::Result;
use futures::{FutureExt as _, future::Shared};
use gpui::{App, AppContext, AsyncApp, Context, Entity, Task};
use http_proxy::Allowlist;
use language::LanguageRegistry;
use markdown::Markdown;
use project::Project;
@@ -25,9 +26,9 @@ use util::get_default_system_shell_preferring_bash;
/// *intent* with plain data here rather than constructing platform-specific
/// types directly.
///
/// All-zero defaults are the fully-sandboxed run. Setting `allow_network` /
/// `allow_fs_write` requests a relaxation; the caller is responsible for
/// having obtained user approval before reaching this point.
/// Default is the fully-sandboxed run (no network, project-only writes).
/// Setting `network` / `allow_fs_write` requests a relaxation; the caller is
/// responsible for having obtained user approval before reaching this point.
#[derive(Clone, Debug, Default)]
pub struct SandboxWrap {
/// Directory subtrees the sandbox should allow writes to. Pass the
@@ -41,8 +42,10 @@ pub struct SandboxWrap {
/// model-requested paths that passed a user-approval prompt. They are
/// merged with `writable_paths` when generating the sandbox policy.
pub extra_write_paths: Vec<PathBuf>,
/// Allow outbound network access for this command.
pub allow_network: bool,
/// Outbound network the command may reach, as a hostname allowlist. An
/// empty allowlist (the default) means no network. A non-empty allowlist
/// (or [`Allowlist::any`]) relaxes the sandbox's network policy.
pub network: Allowlist,
/// Allow unrestricted filesystem writes (ignores all writable paths).
pub allow_fs_write: bool,
}
@@ -81,10 +84,10 @@ pub(crate) fn apply_sandbox_wrap(
.chain(sandbox_wrap.extra_write_paths.iter())
.map(|p| p.as_path())
.collect();
let network = if sandbox_wrap.allow_network {
NetworkAccess::All
} else {
let network = if sandbox_wrap.network.is_deny_all() {
NetworkAccess::None
} else {
NetworkAccess::All
};
let permissions = sandbox::macos_seatbelt::SandboxPermissions {
network,
+1
View File
@@ -41,6 +41,7 @@ handlebars = { workspace = true, features = ["rust-embed"] }
heck.workspace = true
html_to_markdown.workspace = true
http_client.workspace = true
http_proxy.workspace = true
indoc.workspace = true
itertools.workspace = true
language.workspace = true
+328 -61
View File
@@ -18,6 +18,7 @@
use agent_settings::SandboxPermissions;
use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag};
use gpui::App;
use http_proxy::HostPattern;
use std::path::PathBuf;
/// Whether agent-run terminal commands should be wrapped in an OS-level
@@ -26,6 +27,41 @@ pub(crate) fn sandboxing_enabled(cx: &App) -> bool {
cfg!(target_os = "macos") && cx.has_flag::<SandboxingFeatureFlag>()
}
/// Network escalation requested for (or granted to) a sandboxed command.
///
/// Network access in the sandbox is allowlisted by hostname: by default
/// commands have no outbound network, and an escalation lifts that for a
/// specific set of hosts (or, as a broad escape hatch, every host). The host
/// patterns are exact hostnames (`github.com`) or leading-`*.` subdomain
/// wildcards (`*.npmjs.org`); they're validated when constructed so the
/// variants here always hold well-formed patterns.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) enum NetworkRequest {
/// No network escalation — the conversation's default (blocked) applies.
#[default]
None,
/// Allow connections only to these host patterns.
Hosts(Vec<HostPattern>),
/// Allow connections to any host ("arbitrary network access").
AnyHost,
}
impl NetworkRequest {
/// Whether this asks for any network access beyond the default (blocked).
pub fn is_requested(&self) -> bool {
!matches!(self, NetworkRequest::None)
}
/// The host patterns this request names, or an empty slice for the
/// `None`/`AnyHost` variants.
fn host_patterns(&self) -> &[HostPattern] {
match self {
NetworkRequest::Hosts(hosts) => hosts,
NetworkRequest::None | NetworkRequest::AnyHost => &[],
}
}
}
/// A request for elevated sandbox permissions for a single terminal command.
///
/// Built from the model-controlled `terminal` tool input after the user has
@@ -34,8 +70,8 @@ pub(crate) fn sandboxing_enabled(cx: &App) -> bool {
/// strings, and never the model-controlled working directory.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct SandboxRequest {
/// Allow outbound network access for this command.
pub network: bool,
/// Outbound network access requested for this command.
pub network: NetworkRequest,
/// Allow unrestricted filesystem writes (the broad escape hatch).
pub allow_fs_write_all: bool,
/// Run the command fully outside the sandbox.
@@ -49,7 +85,10 @@ impl SandboxRequest {
/// Whether this request asks for anything beyond the default sandbox
/// scope, and therefore needs user approval.
pub fn needs_escalation(&self) -> bool {
self.network || self.allow_fs_write_all || self.unsandboxed || !self.write_paths.is_empty()
self.network.is_requested()
|| self.allow_fs_write_all
|| self.unsandboxed
|| !self.write_paths.is_empty()
}
}
@@ -62,7 +101,11 @@ impl SandboxRequest {
/// are stored separately in [`SandboxPermissions`].
#[derive(Default)]
pub(crate) struct ThreadSandboxGrants {
network: bool,
/// Whether arbitrary-host network access has been granted for the thread.
network_any_host: bool,
/// Host patterns granted network access for the thread. Each covers its
/// whole subdomain space; redundant entries are pruned on insert.
network_hosts: Vec<HostPattern>,
allow_fs_write_all: bool,
unsandboxed: bool,
/// Canonicalized paths granted write access for the thread. Each covers its
@@ -75,9 +118,10 @@ impl ThreadSandboxGrants {
/// covers everything `request` asks for, so the command can run without
/// prompting again.
///
/// Write coverage is pure subtree containment: every requested path must
/// sit under some granted path. This is fully deterministic and never
/// widens scope, because grants are concrete paths rather than globs.
/// Network coverage uses host-pattern subsumption (`*.foo.com` covers
/// `api.foo.com`); write coverage is pure subtree containment. Both are
/// fully deterministic and never widen scope, because grants are concrete
/// patterns/paths rather than globs.
pub fn covers_with_persistent(
&self,
request: &SandboxRequest,
@@ -86,7 +130,7 @@ impl ThreadSandboxGrants {
if request.unsandboxed {
return self.unsandboxed || persistent.allow_unsandboxed;
}
if request.network && !(self.network || persistent.allow_network) {
if !self.network_covered(&request.network, persistent) {
return false;
}
if request.allow_fs_write_all && !(self.allow_fs_write_all || persistent.allow_fs_write_all)
@@ -108,10 +152,40 @@ impl ThreadSandboxGrants {
})
}
/// Whether the requested network escalation is already granted by the
/// thread grants unioned with persistent "allow always" grants.
fn network_covered(&self, request: &NetworkRequest, persistent: &SandboxPermissions) -> bool {
let any_host_granted = self.network_any_host || persistent.allow_all_hosts;
match request {
NetworkRequest::None => true,
NetworkRequest::AnyHost => any_host_granted,
NetworkRequest::Hosts(requested) => {
if any_host_granted {
return true;
}
let persistent_hosts = parse_persistent_hosts(&persistent.network_hosts);
requested.iter().all(|requested| {
self.network_hosts
.iter()
.chain(persistent_hosts.iter())
.any(|granted| granted.covers(requested))
})
}
}
}
/// Record everything in `request` as granted for the rest of the thread,
/// pruning paths that become redundant.
/// pruning entries that become redundant.
pub fn record(&mut self, request: &SandboxRequest) {
self.network |= request.network;
match &request.network {
NetworkRequest::None => {}
NetworkRequest::AnyHost => self.network_any_host = true,
NetworkRequest::Hosts(hosts) => {
for host in hosts {
insert_host_pattern(&mut self.network_hosts, host.clone());
}
}
}
self.allow_fs_write_all |= request.allow_fs_write_all;
self.unsandboxed |= request.unsandboxed;
for path in &request.write_paths {
@@ -125,20 +199,44 @@ impl ThreadSandboxGrants {
///
/// This is what makes standing grants "stick": every sandboxed command
/// applies the accumulated grants, so the model can write to a previously
/// approved path without re-requesting it. Passing the current `request` in
/// also covers "allow once" grants, which are enforced for this command
/// without being recorded for the thread.
/// approved path (or reach a previously approved host) without
/// re-requesting it. Passing the current `request` in also covers "allow
/// once" grants, which are enforced for this command without being recorded
/// for the thread.
pub fn effective_with_persistent(
&self,
request: &SandboxRequest,
persistent: &SandboxPermissions,
) -> SandboxRequest {
let network = if self.network_any_host
|| persistent.allow_all_hosts
|| matches!(request.network, NetworkRequest::AnyHost)
{
NetworkRequest::AnyHost
} else {
let mut hosts = Vec::new();
for host in self
.network_hosts
.iter()
.cloned()
.chain(parse_persistent_hosts(&persistent.network_hosts))
.chain(request.network.host_patterns().iter().cloned())
{
insert_host_pattern(&mut hosts, host);
}
if hosts.is_empty() {
NetworkRequest::None
} else {
NetworkRequest::Hosts(hosts)
}
};
let mut write_paths = persistent.write_paths.clone();
for path in self.write_paths.iter().chain(request.write_paths.iter()) {
util::paths::insert_subtree(&mut write_paths, path.clone());
}
SandboxRequest {
network: persistent.allow_network || self.network || request.network,
network,
allow_fs_write_all: persistent.allow_fs_write_all
|| self.allow_fs_write_all
|| request.allow_fs_write_all,
@@ -148,11 +246,48 @@ impl ThreadSandboxGrants {
}
}
/// Parse persisted host strings into patterns, dropping (and logging) any
/// that fail to validate. Persisted strings are written in canonical form
/// (see `persist_sandbox_always_permission`), so this normally succeeds; the
/// filter is defensive against hand-edited settings.
fn parse_persistent_hosts(raw: &[String]) -> Vec<HostPattern> {
raw.iter()
.filter_map(|host| match HostPattern::parse(host) {
Ok(pattern) => Some(pattern),
Err(error) => {
log::warn!(
"ignoring invalid network host pattern '{host}' in sandbox settings: {error}"
);
None
}
})
.collect()
}
/// Insert `pattern` into a host-pattern set, keeping it minimal: skip it if an
/// existing entry already subsumes it, and drop existing entries it subsumes.
/// The host-pattern analogue of [`util::paths::insert_subtree`].
pub(crate) fn insert_host_pattern(set: &mut Vec<HostPattern>, pattern: HostPattern) {
if set.iter().any(|existing| existing.covers(&pattern)) {
return;
}
set.retain(|existing| !pattern.covers(existing));
set.push(pattern);
}
#[cfg(test)]
mod tests {
use super::*;
fn request(network: bool, all: bool, paths: &[&str]) -> SandboxRequest {
fn hosts(list: &[&str]) -> NetworkRequest {
NetworkRequest::Hosts(
list.iter()
.map(|h| HostPattern::parse(h).unwrap())
.collect(),
)
}
fn request(network: NetworkRequest, all: bool, paths: &[&str]) -> SandboxRequest {
SandboxRequest {
network,
allow_fs_write_all: all,
@@ -163,7 +298,7 @@ mod tests {
fn unsandboxed_request() -> SandboxRequest {
SandboxRequest {
network: false,
network: NetworkRequest::None,
allow_fs_write_all: false,
unsandboxed: true,
write_paths: Vec::new(),
@@ -181,62 +316,136 @@ mod tests {
#[test]
fn empty_grants_cover_nothing() {
let grants = ThreadSandboxGrants::default();
assert!(!covers(&grants, &request(true, false, &[])));
assert!(!covers(&grants, &request(false, true, &[])));
assert!(!covers(
&grants,
&request(NetworkRequest::AnyHost, false, &[])
));
assert!(!covers(
&grants,
&request(hosts(&["github.com"]), false, &[])
));
assert!(!covers(&grants, &request(NetworkRequest::None, true, &[])));
assert!(!covers(&grants, &unsandboxed_request()));
assert!(!covers(&grants, &request(false, false, &["/tmp/build"])));
assert!(!covers(
&grants,
&request(NetworkRequest::None, false, &["/tmp/build"])
));
}
#[test]
fn subtree_containment_covers_children() {
let mut grants = ThreadSandboxGrants::default();
grants.record(&request(false, false, &["/tmp/build"]));
grants.record(&request(NetworkRequest::None, false, &["/tmp/build"]));
// Exact match and any descendant are covered.
assert!(covers(&grants, &request(false, false, &["/tmp/build"])));
assert!(covers(
&grants,
&request(false, false, &["/tmp/build/cache"])
&request(NetworkRequest::None, false, &["/tmp/build"])
));
assert!(covers(
&grants,
&request(NetworkRequest::None, false, &["/tmp/build/cache"])
));
// A sibling / parent is not.
assert!(!covers(&grants, &request(false, false, &["/tmp/other"])));
assert!(!covers(&grants, &request(false, false, &["/tmp"])));
assert!(!covers(
&grants,
&request(NetworkRequest::None, false, &["/tmp/other"])
));
assert!(!covers(
&grants,
&request(NetworkRequest::None, false, &["/tmp"])
));
}
#[test]
fn record_prunes_redundant_children() {
let mut grants = ThreadSandboxGrants::default();
grants.record(&request(false, false, &["/tmp/build/cache"]));
grants.record(&request(false, false, &["/tmp/build"]));
grants.record(&request(NetworkRequest::None, false, &["/tmp/build/cache"]));
grants.record(&request(NetworkRequest::None, false, &["/tmp/build"]));
assert_eq!(grants.write_paths, vec![PathBuf::from("/tmp/build")]);
}
#[test]
fn record_keeps_existing_broader_grant() {
let mut grants = ThreadSandboxGrants::default();
grants.record(&request(false, false, &["/tmp/build"]));
grants.record(&request(false, false, &["/tmp/build/cache"]));
grants.record(&request(NetworkRequest::None, false, &["/tmp/build"]));
grants.record(&request(NetworkRequest::None, false, &["/tmp/build/cache"]));
assert_eq!(grants.write_paths, vec![PathBuf::from("/tmp/build")]);
}
#[test]
fn all_access_covers_any_concrete_write() {
let mut grants = ThreadSandboxGrants::default();
grants.record(&request(false, true, &[]));
grants.record(&request(NetworkRequest::None, true, &[]));
assert!(covers(
&grants,
&request(false, false, &["/anywhere/at/all"])
&request(NetworkRequest::None, false, &["/anywhere/at/all"])
));
// But not network, which wasn't granted.
assert!(!covers(&grants, &request(true, false, &[])));
assert!(!covers(
&grants,
&request(NetworkRequest::AnyHost, false, &[])
));
}
#[test]
fn network_grant_tracked_independently() {
fn any_host_grant_covers_specific_and_any_host() {
let mut grants = ThreadSandboxGrants::default();
grants.record(&request(true, false, &[]));
assert!(covers(&grants, &request(true, false, &[])));
assert!(!covers(&grants, &request(true, false, &["/tmp/build"])));
grants.record(&request(NetworkRequest::AnyHost, false, &[]));
assert!(covers(
&grants,
&request(NetworkRequest::AnyHost, false, &[])
));
assert!(covers(
&grants,
&request(hosts(&["github.com"]), false, &[])
));
// ...but not an orthogonal write request.
assert!(!covers(
&grants,
&request(NetworkRequest::AnyHost, false, &["/tmp/build"])
));
}
#[test]
fn host_grant_covers_subdomains_but_not_any_host() {
let mut grants = ThreadSandboxGrants::default();
grants.record(&request(hosts(&["*.github.com"]), false, &[]));
assert!(covers(
&grants,
&request(hosts(&["api.github.com"]), false, &[])
));
assert!(covers(
&grants,
&request(hosts(&["*.github.com"]), false, &[])
));
// The bare parent isn't a subdomain, so it isn't covered.
assert!(!covers(
&grants,
&request(hosts(&["github.com"]), false, &[])
));
// A different host isn't covered.
assert!(!covers(
&grants,
&request(hosts(&["npmjs.org"]), false, &[])
));
// A specific grant never satisfies an any-host request.
assert!(!covers(
&grants,
&request(NetworkRequest::AnyHost, false, &[])
));
}
#[test]
fn record_prunes_redundant_hosts() {
let mut grants = ThreadSandboxGrants::default();
grants.record(&request(hosts(&["api.github.com"]), false, &[]));
grants.record(&request(hosts(&["*.github.com"]), false, &[]));
assert_eq!(
grants.network_hosts,
vec![HostPattern::parse("*.github.com").unwrap()]
);
}
#[test]
@@ -244,27 +453,53 @@ mod tests {
let mut grants = ThreadSandboxGrants::default();
grants.record(&unsandboxed_request());
assert!(covers(&grants, &unsandboxed_request()));
assert!(!covers(&grants, &request(true, false, &[])));
assert!(!covers(&grants, &request(false, true, &[])));
assert!(!covers(
&grants,
&request(NetworkRequest::AnyHost, false, &[])
));
assert!(!covers(&grants, &request(NetworkRequest::None, true, &[])));
}
#[test]
fn persistent_grants_combine_with_thread_grants() {
let mut grants = ThreadSandboxGrants::default();
grants.record(&request(true, false, &[]));
grants.record(&request(hosts(&["github.com"]), false, &[]));
let persistent = SandboxPermissions {
allow_network: false,
allow_all_hosts: false,
network_hosts: Vec::new(),
allow_fs_write_all: false,
allow_unsandboxed: false,
write_paths: vec![PathBuf::from("/tmp/build")],
};
assert!(grants.covers_with_persistent(
&request(hosts(&["github.com"]), false, &["/tmp/build/cache"]),
&persistent
));
assert!(!grants.covers_with_persistent(
&request(hosts(&["github.com"]), false, &["/tmp/other"]),
&persistent
));
}
#[test]
fn persistent_network_hosts_are_honored() {
let grants = ThreadSandboxGrants::default();
let persistent = SandboxPermissions {
allow_all_hosts: false,
network_hosts: vec!["*.npmjs.org".to_string()],
allow_fs_write_all: false,
allow_unsandboxed: false,
write_paths: Vec::new(),
};
assert!(grants.covers_with_persistent(
&request(hosts(&["registry.npmjs.org"]), false, &[]),
&persistent
));
assert!(
grants
.covers_with_persistent(&request(true, false, &["/tmp/build/cache"]), &persistent)
);
assert!(
!grants.covers_with_persistent(&request(true, false, &["/tmp/other"]), &persistent)
!grants
.covers_with_persistent(&request(hosts(&["github.com"]), false, &[]), &persistent)
);
}
@@ -272,30 +507,45 @@ mod tests {
fn persistent_all_access_covers_concrete_writes() {
let grants = ThreadSandboxGrants::default();
let persistent = SandboxPermissions {
allow_network: false,
allow_all_hosts: false,
network_hosts: Vec::new(),
allow_fs_write_all: true,
allow_unsandboxed: false,
write_paths: Vec::new(),
};
assert!(grants.covers_with_persistent(&request(false, false, &["/anywhere"]), &persistent));
assert!(grants.covers_with_persistent(&request(false, true, &[]), &persistent));
assert!(!grants.covers_with_persistent(&request(true, false, &[]), &persistent));
assert!(grants.covers_with_persistent(
&request(NetworkRequest::None, false, &["/anywhere"]),
&persistent
));
assert!(
grants.covers_with_persistent(&request(NetworkRequest::None, true, &[]), &persistent)
);
assert!(
!grants
.covers_with_persistent(&request(NetworkRequest::AnyHost, false, &[]), &persistent)
);
}
#[test]
fn persistent_unsandboxed_covers_unsandboxed_requests_only() {
let grants = ThreadSandboxGrants::default();
let persistent = SandboxPermissions {
allow_network: false,
allow_all_hosts: false,
network_hosts: Vec::new(),
allow_fs_write_all: false,
allow_unsandboxed: true,
write_paths: Vec::new(),
};
assert!(grants.covers_with_persistent(&unsandboxed_request(), &persistent));
assert!(!grants.covers_with_persistent(&request(true, false, &[]), &persistent));
assert!(!grants.covers_with_persistent(&request(false, true, &[]), &persistent));
assert!(
!grants
.covers_with_persistent(&request(NetworkRequest::AnyHost, false, &[]), &persistent)
);
assert!(
!grants.covers_with_persistent(&request(NetworkRequest::None, true, &[]), &persistent)
);
}
#[test]
@@ -303,9 +553,9 @@ mod tests {
// The core fix: a command that requests nothing still gets the
// thread's granted write paths in its enforced policy.
let mut grants = ThreadSandboxGrants::default();
grants.record(&request(false, false, &["/tmp/build"]));
grants.record(&request(NetworkRequest::None, false, &["/tmp/build"]));
let effective = effective(&grants, &request(false, false, &[]));
let effective = effective(&grants, &request(NetworkRequest::None, false, &[]));
assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/build")]);
}
@@ -314,37 +564,54 @@ mod tests {
// An "allow once" path (passed via `request`, never recorded) is
// enforced for this command alongside the standing grants.
let mut grants = ThreadSandboxGrants::default();
grants.record(&request(true, false, &["/tmp/build"]));
grants.record(&request(hosts(&["github.com"]), false, &["/tmp/build"]));
let effective = effective(&grants, &request(false, false, &["/tmp/once"]));
assert!(effective.network);
let effective = effective(
&grants,
&request(hosts(&["npmjs.org"]), false, &["/tmp/once"]),
);
assert_eq!(effective.network, hosts(&["github.com", "npmjs.org"]));
assert_eq!(
effective.write_paths,
vec![PathBuf::from("/tmp/build"), PathBuf::from("/tmp/once")]
);
}
#[test]
fn effective_any_host_subsumes_specific_hosts() {
let mut grants = ThreadSandboxGrants::default();
grants.record(&request(hosts(&["github.com"]), false, &[]));
let effective = effective(&grants, &request(NetworkRequest::AnyHost, false, &[]));
assert_eq!(effective.network, NetworkRequest::AnyHost);
}
#[test]
fn effective_applies_persistent_grants_to_empty_request() {
let grants = ThreadSandboxGrants::default();
let persistent = SandboxPermissions {
allow_network: true,
allow_all_hosts: true,
network_hosts: Vec::new(),
allow_fs_write_all: false,
allow_unsandboxed: false,
write_paths: vec![PathBuf::from("/tmp/always")],
};
let effective = grants.effective_with_persistent(&request(false, false, &[]), &persistent);
assert!(effective.network);
let effective = grants
.effective_with_persistent(&request(NetworkRequest::None, false, &[]), &persistent);
assert_eq!(effective.network, NetworkRequest::AnyHost);
assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/always")]);
}
#[test]
fn effective_dedupes_request_already_covered_by_grant() {
let mut grants = ThreadSandboxGrants::default();
grants.record(&request(false, false, &["/tmp/build"]));
grants.record(&request(NetworkRequest::None, false, &["/tmp/build"]));
let effective = effective(&grants, &request(false, false, &["/tmp/build/cache"]));
let effective = effective(
&grants,
&request(NetworkRequest::None, false, &["/tmp/build/cache"]),
);
assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/build")]);
}
}
+3 -2
View File
@@ -156,7 +156,7 @@ mod tests {
let templates = Templates::new();
let rendered = template.render(&templates).unwrap();
assert!(!rendered.contains("## Terminal sandbox"));
assert!(!rendered.contains("allow_network"));
assert!(!rendered.contains("allow_hosts"));
}
#[test]
@@ -190,7 +190,8 @@ mod tests {
assert!(rendered.contains("## Terminal sandbox"));
assert!(rendered.contains("`/tmp/alpha`"));
assert!(rendered.contains("`/tmp/beta`"));
assert!(rendered.contains("allow_network: true"));
assert!(rendered.contains("allow_hosts"));
assert!(rendered.contains("allow_all_hosts: true"));
assert!(rendered.contains("fs_write_paths"));
assert!(rendered.contains("allow_fs_write_all: true"));
assert!(rendered.contains("unsandboxed: true"));
+5 -2
View File
@@ -166,14 +166,17 @@ The `terminal` tool runs commands inside a sandbox with these permissions:
Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}}
- Network: outbound network access is blocked.
Network access works through an HTTP/HTTPS proxy (standard proxy environment variables are set for the command). Tools that don't honor proxy environment variables (SSH, FTP, raw sockets, etc.) can't reach the network even when access is granted, so use `https://` URLs instead of `git@`/`ssh://` when cloning or pushing.
You can request elevated permissions on individual `terminal` calls:
- `allow_network: true` — allow outbound network access.
- `allow_hosts: ["github.com", "*.npmjs.org"]` — allow outbound HTTP/HTTPS to specific hosts (exact hostnames or leading-`*.` subdomain wildcards; no IP literals). Prefer this whenever you know which hosts the command needs.
- `allow_all_hosts: true` — allow outbound HTTP/HTTPS to any host. Use only when the specific hosts can't be enumerated up front.
- `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write.
- `allow_fs_write_all: true` — allow unrestricted filesystem writes. Only use this when the specific paths can't be enumerated up front.
- `unsandboxed: true` — run the command with no sandbox at all. Use only when none of the above suffice.
The user will be prompted to approve before the command runs, and can grant a sandbox request for that command, for the rest of the thread, or always. Once a write path is granted for the thread or always, later commands in this thread writing under that path won't prompt again.
The user will be prompted to approve before the command runs, and can grant a sandbox request for that command, for the rest of the thread, or always. Once a host or write path is granted for the thread or always, later commands in this thread reaching that host or writing under that path won't prompt again.
These sandbox settings are guaranteed to remain in effect for the entire duration of this thread. If they ever change, you will be told.
+40 -6
View File
@@ -5251,9 +5251,17 @@ impl ToolCallEventStream {
}
let title = title.into();
let (network_hosts, network_all_hosts) = match &request.network {
crate::sandboxing::NetworkRequest::None => (Vec::new(), false),
crate::sandboxing::NetworkRequest::AnyHost => (Vec::new(), true),
crate::sandboxing::NetworkRequest::Hosts(hosts) => {
(hosts.iter().map(|host| host.to_string()).collect(), false)
}
};
let sandbox_authorization_details = acp_thread::SandboxAuthorizationDetails {
command,
network: request.network,
network_hosts,
network_all_hosts,
allow_fs_write_all: request.allow_fs_write_all,
unsandboxed: request.unsandboxed,
write_paths: request.write_paths.clone(),
@@ -5418,8 +5426,34 @@ impl ToolCallEventStream {
cx.update(|cx| {
update_settings_file(fs, cx, move |settings, _| {
let agent = settings.agent.get_or_insert_default();
if request.network {
agent.allow_sandbox_network();
match &request.network {
crate::sandboxing::NetworkRequest::None => {}
crate::sandboxing::NetworkRequest::AnyHost => {
agent.allow_sandbox_all_hosts();
}
crate::sandboxing::NetworkRequest::Hosts(hosts) => {
// Rebuild the persisted list with subsumption pruning
// so granting `*.github.com` retires a previously
// persisted `api.github.com` instead of accumulating
// redundant entries. Unparsable hand-edited entries
// are preserved untouched.
let mut patterns = Vec::new();
let mut unparsable = Vec::new();
for raw in agent.sandbox_network_hosts() {
match http_proxy::HostPattern::parse(raw) {
Ok(pattern) => {
crate::sandboxing::insert_host_pattern(&mut patterns, pattern)
}
Err(_) => unparsable.push(raw.clone()),
}
}
for host in hosts {
crate::sandboxing::insert_host_pattern(&mut patterns, host.clone());
}
let mut host_strings = unparsable;
host_strings.extend(patterns.iter().map(|pattern| pattern.to_string()));
agent.set_sandbox_network_hosts(host_strings);
}
}
if request.allow_fs_write_all {
agent.allow_sandbox_fs_write_all();
@@ -6812,7 +6846,7 @@ mod tests {
let (event_stream, mut receiver) = ToolCallEventStream::test();
let request = SandboxRequest {
network: false,
network: crate::sandboxing::NetworkRequest::None,
allow_fs_write_all: false,
unsandboxed: false,
write_paths: vec![
@@ -6830,8 +6864,8 @@ mod tests {
let details =
acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta)
.expect("sandbox authorization should include request details");
assert_eq!(details.command, None);
assert_eq!(details.network, request.network);
assert!(details.network_hosts.is_empty());
assert!(!details.network_all_hosts);
assert_eq!(details.allow_fs_write_all, request.allow_fs_write_all);
assert_eq!(details.unsandboxed, request.unsandboxed);
assert_eq!(details.write_paths, request.write_paths);
+217 -40
View File
@@ -13,7 +13,7 @@ use std::{
time::Duration,
};
use crate::sandboxing::sandboxing_enabled;
use crate::sandboxing::{NetworkRequest, sandboxing_enabled};
use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream, ToolInput};
const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024;
@@ -98,15 +98,26 @@ pub struct SandboxedTerminalToolInput {
/// Return only the last N lines of terminal output to the model after the command finishes. Do not pipe output to `tail`; use this parameter instead so the user can still see live output. Avoid requesting too many lines, or the response may waste tokens or exceed the context window.
#[serde(default)]
pub tail_lines: Option<usize>,
/// Set to `true` only if the command needs outbound network access.
/// Hosts the command needs outbound network access to.
///
/// Sandboxed commands cannot reach the network by default, so set this
/// when running commands that fetch or upload (installing dependencies,
/// cloning, pushing, downloading, etc.). Requesting it triggers a user
/// approval prompt, so only set it when you expect the command to need
/// network.
/// Sandboxed commands cannot reach the network by default. List the hosts
/// the command needs (e.g. `["github.com", "*.npmjs.org"]`) when running
/// commands that fetch or upload (installing dependencies, cloning,
/// pushing, downloading, etc.). Each entry must be a hostname or a
/// leading-`*.` subdomain wildcard; IP literals and other wildcards are
/// rejected. Only HTTP and HTTPS are proxied — use `https://` URLs rather
/// than `git@`/`ssh://`. Requesting hosts triggers a user approval prompt,
/// so only list hosts you expect the command to need.
#[serde(default)]
pub allow_network: Option<bool>,
pub allow_hosts: Vec<String>,
/// Set to `true` only if the command needs outbound network access to
/// hosts you can't enumerate up front.
///
/// This is a broad escape hatch — prefer `allow_hosts` with specific
/// hostnames whenever possible, so the user knows what's being approved.
/// Requesting it triggers a user approval prompt.
#[serde(default)]
pub allow_all_hosts: Option<bool>,
/// Paths the command needs to write to outside the default-writable
/// locations.
///
@@ -129,7 +140,7 @@ pub struct SandboxedTerminalToolInput {
/// Set to `true` only as a last resort, to run the command fully outside
/// the sandbox.
///
/// First try the narrower options (`allow_network`, `fs_write_paths`,
/// First try the narrower options (`allow_hosts`, `fs_write_paths`,
/// `allow_fs_write_all`); use this only when the command needs behavior
/// the sandbox can't grant on a per-permission basis. Requesting it
/// triggers a user approval prompt.
@@ -139,7 +150,8 @@ pub struct SandboxedTerminalToolInput {
#[derive(Clone, Debug, Default)]
struct TerminalSandboxInput {
allow_network: Option<bool>,
allow_hosts: Vec<String>,
allow_all_hosts: Option<bool>,
fs_write_paths: Vec<String>,
allow_fs_write_all: Option<bool>,
unsandboxed: Option<bool>,
@@ -179,7 +191,8 @@ impl From<SandboxedTerminalToolInput> for TerminalToolRequest {
tail_lines: input.tail_lines,
},
sandbox: Some(TerminalSandboxInput {
allow_network: input.allow_network,
allow_hosts: input.allow_hosts,
allow_all_hosts: input.allow_all_hosts,
fs_write_paths: input.fs_write_paths,
allow_fs_write_all: input.allow_fs_write_all,
unsandboxed: input.unsandboxed,
@@ -310,22 +323,40 @@ async fn run_terminal_tool(
let selection = input.selection;
let sandbox_input = input.sandbox.clone().unwrap_or_default();
let (working_dir, authorize, sandboxing) = cx.update(|cx| {
let (working_dir, authorize, sandboxing, is_local_project) = cx.update(|cx| {
let working_dir = working_dir(&input.cd, &project, cx).map_err(|err| err.to_string())?;
let context =
crate::ToolPermissionContext::new(TerminalTool::NAME, vec![input.command.clone()]);
let authorize =
event_stream.authorize(SharedString::new(input.command.clone()), context, cx);
let sandboxing = input.sandbox.is_some() && sandboxing_enabled(cx);
Result::<_, String>::Ok((working_dir, authorize, sandboxing))
let is_local_project = project.read(cx).is_local();
Result::<_, String>::Ok((working_dir, authorize, sandboxing, is_local_project))
})?;
authorize.await.map_err(|e| e.to_string())?;
let want_network = sandboxing && sandbox_input.allow_network == Some(true);
let want_fs_write_all = sandboxing && sandbox_input.allow_fs_write_all == Some(true);
let want_unsandboxed = sandboxing && sandbox_input.unsandboxed == Some(true);
// Validate the model-supplied host patterns up front. Malformed input is
// the model's responsibility, so surface it back as a tool-call error
// (the model retries) rather than letting the user approve a request that
// then fails.
let mut network = if sandboxing && !want_unsandboxed {
build_network_request(&sandbox_input)?
} else {
NetworkRequest::None
};
// The host allowlist is enforced by a loopback proxy, which can't serve
// a non-local project's terminal. Widen the request to "any host" before
// prompting, so the approval the user grants matches what's actually
// enforced (unrestricted egress) rather than naming hosts we can't pin.
if !is_local_project && network.is_requested() {
network = NetworkRequest::AnyHost;
}
let write_paths: Vec<PathBuf> = if sandboxing && !want_unsandboxed {
cx.update(|cx| {
resolve_write_paths(
@@ -340,7 +371,7 @@ async fn run_terminal_tool(
};
let request = crate::sandboxing::SandboxRequest {
network: !want_unsandboxed && want_network,
network,
allow_fs_write_all: !want_unsandboxed && want_fs_write_all,
unsandboxed: want_unsandboxed,
write_paths,
@@ -382,7 +413,7 @@ async fn run_terminal_tool(
Some(acp_thread::SandboxWrap {
writable_paths,
extra_write_paths: effective.write_paths,
allow_network: effective.network,
network: network_request_to_allowlist(&effective.network),
allow_fs_write: effective.allow_fs_write_all,
})
} else {
@@ -511,6 +542,42 @@ fn join_write_paths(raw_paths: &[String], base: Option<&Path>) -> Vec<PathBuf> {
.collect()
}
/// Convert a (validated) network request into the allowlist enforced by the
/// in-process proxy.
fn network_request_to_allowlist(network: &NetworkRequest) -> http_proxy::Allowlist {
match network {
NetworkRequest::None => http_proxy::Allowlist::empty(),
NetworkRequest::AnyHost => http_proxy::Allowlist::any(),
NetworkRequest::Hosts(hosts) => http_proxy::Allowlist::from_patterns(hosts.iter().cloned()),
}
}
/// Parse and validate the model's network escalation request. `allow_all_hosts`
/// subsumes any specific `allow_hosts` list. Returns an error string suitable
/// for showing back to the model when a host pattern is malformed.
fn build_network_request(sandbox: &TerminalSandboxInput) -> Result<NetworkRequest, String> {
if sandbox.allow_all_hosts == Some(true) {
return Ok(NetworkRequest::AnyHost);
}
if sandbox.allow_hosts.is_empty() {
return Ok(NetworkRequest::None);
}
let mut patterns = Vec::with_capacity(sandbox.allow_hosts.len());
for raw in &sandbox.allow_hosts {
match http_proxy::HostPattern::parse(raw) {
Ok(pattern) => patterns.push(pattern),
Err(error) => {
return Err(format!(
"`allow_hosts` contains an invalid pattern '{raw}': {error}. \
Hostnames only — no IP literals; leading-`*.` wildcards \
are supported (e.g. `*.example.com`)."
));
}
}
}
Ok(NetworkRequest::Hosts(patterns))
}
/// User-facing title for the sandbox-escalation approval prompt. Only called
/// when the request actually asks for something (see
/// [`crate::sandboxing::SandboxRequest::needs_escalation`]).
@@ -520,8 +587,8 @@ fn sandbox_approval_title(request: &crate::sandboxing::SandboxRequest) -> String
}
let mut parts: Vec<String> = Vec::new();
if request.network {
parts.push("network access".to_string());
if let Some(network_clause) = network_clause(&request.network) {
parts.push(network_clause);
}
if request.allow_fs_write_all {
parts.push("unrestricted filesystem writes".to_string());
@@ -539,6 +606,29 @@ fn sandbox_approval_title(request: &crate::sandboxing::SandboxRequest) -> String
}
}
/// The network clause for the approval title, or `None` when no network was
/// requested.
fn network_clause(network: &NetworkRequest) -> Option<String> {
match network {
NetworkRequest::None => None,
NetworkRequest::AnyHost => Some("arbitrary network access".to_string()),
NetworkRequest::Hosts(hosts) => Some(format_hosts_clause(hosts)),
}
}
fn format_hosts_clause(hosts: &[http_proxy::HostPattern]) -> String {
let names: Vec<String> = hosts.iter().map(|host| host.to_string()).collect();
match names.as_slice() {
[] => "network access".to_string(),
[single] => format!("network access to {single}"),
[first, second] => format!("network access to {first} and {second}"),
_ => {
let (last, init) = names.split_last().expect("non-empty");
format!("network access to {}, and {last}", init.join(", "))
}
}
}
fn write_path_summary(paths: &[PathBuf]) -> String {
match paths {
[] => "0 paths".to_string(),
@@ -2253,7 +2343,7 @@ mod tests {
}
fn sandbox_request(
network: bool,
network: NetworkRequest,
all: bool,
paths: &[&str],
) -> crate::sandboxing::SandboxRequest {
@@ -2265,6 +2355,14 @@ mod tests {
}
}
fn host_request(list: &[&str]) -> NetworkRequest {
NetworkRequest::Hosts(
list.iter()
.map(|h| http_proxy::HostPattern::parse(h).unwrap())
.collect(),
)
}
#[test]
fn test_join_write_paths_resolves_relative_and_absolute() {
let base = PathBuf::from(if cfg!(windows) {
@@ -2344,7 +2442,7 @@ mod tests {
#[test]
fn test_sandbox_approval_title_unsandboxed() {
let mut request = sandbox_request(true, true, &["/tmp/build"]);
let mut request = sandbox_request(NetworkRequest::AnyHost, true, &["/tmp/build"]);
request.unsandboxed = true;
assert_eq!(
sandbox_approval_title(&request),
@@ -2355,35 +2453,70 @@ mod tests {
#[test]
fn test_sandbox_approval_title_all_access_and_network() {
assert_eq!(
sandbox_approval_title(&sandbox_request(true, true, &[])),
"Allow network access and unrestricted filesystem writes?"
sandbox_approval_title(&sandbox_request(NetworkRequest::AnyHost, true, &[])),
"Allow arbitrary network access and unrestricted filesystem writes?"
);
assert_eq!(
sandbox_approval_title(&sandbox_request(true, false, &[])),
"Allow network access?"
sandbox_approval_title(&sandbox_request(NetworkRequest::AnyHost, false, &[])),
"Allow arbitrary network access?"
);
assert_eq!(
sandbox_approval_title(&sandbox_request(false, true, &[])),
sandbox_approval_title(&sandbox_request(NetworkRequest::None, true, &[])),
"Allow unrestricted filesystem writes?"
);
}
#[test]
fn test_sandbox_approval_title_specific_hosts() {
assert_eq!(
sandbox_approval_title(&sandbox_request(host_request(&["github.com"]), false, &[])),
"Allow network access to github.com?"
);
assert_eq!(
sandbox_approval_title(&sandbox_request(
host_request(&["github.com", "*.npmjs.org"]),
false,
&[]
)),
"Allow network access to github.com and *.npmjs.org?"
);
assert_eq!(
sandbox_approval_title(&sandbox_request(
host_request(&["github.com", "npmjs.org", "pypi.org"]),
false,
&[]
)),
"Allow network access to github.com, npmjs.org, and pypi.org?"
);
}
#[test]
fn test_sandbox_approval_title_per_path_writes() {
assert_eq!(
sandbox_approval_title(&sandbox_request(false, false, &["/tmp/build"])),
sandbox_approval_title(&sandbox_request(
NetworkRequest::None,
false,
&["/tmp/build"]
)),
"Allow write access to /tmp/build?"
);
assert_eq!(
sandbox_approval_title(&sandbox_request(true, false, &["/tmp/build"])),
"Allow network access and write access to /tmp/build?"
sandbox_approval_title(&sandbox_request(
host_request(&["github.com"]),
false,
&["/tmp/build"]
)),
"Allow network access to github.com and write access to /tmp/build?"
);
}
#[test]
fn test_sandbox_approval_title_summarizes_multiple_paths_by_count() {
let title =
sandbox_approval_title(&sandbox_request(false, false, &["/a", "/b", "/c", "/d"]));
let title = sandbox_approval_title(&sandbox_request(
NetworkRequest::None,
false,
&["/a", "/b", "/c", "/d"],
));
assert_eq!(title, "Allow write access to 4 paths?");
}
@@ -2392,11 +2525,50 @@ mod tests {
// When all-access is requested, the specific paths are redundant and
// should not be listed.
assert_eq!(
sandbox_approval_title(&sandbox_request(false, true, &["/tmp/build"])),
sandbox_approval_title(&sandbox_request(
NetworkRequest::None,
true,
&["/tmp/build"]
)),
"Allow unrestricted filesystem writes?"
);
}
#[test]
fn test_build_network_request_validates_and_classifies() {
// No fields -> None.
assert_eq!(
build_network_request(&TerminalSandboxInput::default()).unwrap(),
NetworkRequest::None
);
// allow_all_hosts -> AnyHost, even alongside specific hosts.
assert_eq!(
build_network_request(&TerminalSandboxInput {
allow_hosts: vec!["github.com".into()],
allow_all_hosts: Some(true),
..Default::default()
})
.unwrap(),
NetworkRequest::AnyHost
);
// Valid hosts parse to patterns.
assert_eq!(
build_network_request(&TerminalSandboxInput {
allow_hosts: vec!["github.com".into(), "*.npmjs.org".into()],
..Default::default()
})
.unwrap(),
host_request(&["github.com", "*.npmjs.org"])
);
// An IP literal is rejected with an actionable message.
let err = build_network_request(&TerminalSandboxInput {
allow_hosts: vec!["127.0.0.1".into()],
..Default::default()
})
.unwrap_err();
assert!(err.contains("127.0.0.1"), "unexpected error: {err}");
}
#[test]
fn test_input_schema_includes_sandbox_flags() {
// The sandboxed terminal tool advertises these fields so the model can
@@ -2405,8 +2577,12 @@ mod tests {
let schema = serde_json::to_string(&schemars::schema_for!(SandboxedTerminalToolInput))
.expect("input schema should serialize");
assert!(
schema.contains("allow_network"),
"schema should advertise allow_network: {schema}"
schema.contains("allow_hosts"),
"schema should advertise allow_hosts: {schema}"
);
assert!(
schema.contains("allow_all_hosts"),
"schema should advertise allow_all_hosts: {schema}"
);
assert!(
schema.contains("fs_write_paths"),
@@ -2426,14 +2602,15 @@ mod tests {
fn test_sandbox_flags_default_to_none_when_absent() {
// The model is expected to omit the sandbox fields entirely on most
// calls. Make sure deserialization doesn't reject the minimal
// payload and that the fields default to `None` (which the tool
// payload and that the fields default to empty/`None` (which the tool
// interprets as "no escalation requested").
let input: SandboxedTerminalToolInput = serde_json::from_value(serde_json::json!({
"command": "echo hi",
"cd": ".",
}))
.expect("minimal input should deserialize");
assert_eq!(input.allow_network, None);
assert!(input.allow_hosts.is_empty());
assert_eq!(input.allow_all_hosts, None);
assert!(input.fs_write_paths.is_empty());
assert_eq!(input.allow_fs_write_all, None);
assert_eq!(input.unsandboxed, None);
@@ -2492,8 +2669,8 @@ mod tests {
let details =
acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta)
.expect("legacy allow_fs_write should request sandbox authorization details");
assert_eq!(details.command.as_deref(), Some("echo hi"));
assert!(!details.network);
assert!(details.network_hosts.is_empty());
assert!(!details.network_all_hosts);
assert!(details.allow_fs_write_all);
assert!(!details.unsandboxed);
assert!(details.write_paths.is_empty());
@@ -2573,7 +2750,7 @@ mod tests {
let input: SandboxedTerminalToolInput = serde_json::from_value(serde_json::json!({
"command": "echo hi",
"cd": "root",
"allow_network": true,
"allow_all_hosts": true,
"allow_fs_write_all": true,
"unsandboxed": true,
}))
@@ -2589,8 +2766,8 @@ mod tests {
let details =
acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta)
.expect("unsandboxed should request sandbox authorization details");
assert_eq!(details.command.as_deref(), Some("echo hi"));
assert!(!details.network);
assert!(details.network_hosts.is_empty());
assert!(!details.network_all_hosts);
assert!(!details.allow_fs_write_all);
assert!(details.unsandboxed);
assert!(details.write_paths.is_empty());
+20 -4
View File
@@ -414,7 +414,12 @@ impl Default for AgentProfileId {
/// [`compile_sandbox_permissions`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SandboxPermissions {
pub allow_network: bool,
/// Allow sandboxed commands to reach any host over the network.
pub allow_all_hosts: bool,
/// Hosts sandboxed commands may always reach, in canonical form (exact
/// hostnames or leading-`*.` subdomain wildcards). Parsed/validated where
/// consumed (`agent::sandboxing`).
pub network_hosts: Vec<String>,
pub allow_fs_write_all: bool,
pub allow_unsandboxed: bool,
pub write_paths: Vec<PathBuf>,
@@ -790,8 +795,14 @@ fn compile_sandbox_permissions(
}
}
let network_hosts = content
.network_hosts
.map(|hosts| hosts.0)
.unwrap_or_default();
SandboxPermissions {
allow_network: content.allow_network.unwrap_or(false),
allow_all_hosts: content.allow_all_hosts.unwrap_or(false),
network_hosts,
allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false),
allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false),
write_paths,
@@ -1027,7 +1038,8 @@ mod tests {
#[test]
fn test_sandbox_permissions_parsing_and_pruning() {
let json = json!({
"allow_network": true,
"allow_all_hosts": true,
"network_hosts": ["github.com", "*.npmjs.org"],
"allow_unsandboxed": true,
"write_paths": [
"/tmp/build/cache",
@@ -1039,7 +1051,11 @@ mod tests {
let content: settings::SandboxPermissionsContent = serde_json::from_value(json).unwrap();
let permissions = compile_sandbox_permissions(Some(content));
assert!(permissions.allow_network);
assert!(permissions.allow_all_hosts);
assert_eq!(
permissions.network_hosts,
vec!["github.com".to_string(), "*.npmjs.org".to_string()]
);
assert!(!permissions.allow_fs_write_all);
assert!(permissions.allow_unsandboxed);
assert_eq!(
@@ -578,6 +578,7 @@ pub struct ThreadView {
pub expanded_tool_calls: HashSet<acp::ToolCallId>,
pub expanded_tool_call_raw_inputs: HashSet<acp::ToolCallId>,
collapsed_sandbox_authorization_details: HashSet<acp::ToolCallId>,
collapsed_sandbox_network_details: HashSet<acp::ToolCallId>,
pub expanded_thinking_blocks: HashSet<(usize, usize)>,
auto_expanded_thinking_block: Option<(usize, usize)>,
user_toggled_thinking_blocks: HashSet<(usize, usize)>,
@@ -963,6 +964,7 @@ impl ThreadView {
expanded_tool_calls: HashSet::default(),
expanded_tool_call_raw_inputs: HashSet::default(),
collapsed_sandbox_authorization_details: HashSet::default(),
collapsed_sandbox_network_details: HashSet::default(),
expanded_thinking_blocks: HashSet::default(),
auto_expanded_thinking_block: None,
user_toggled_thinking_blocks: HashSet::default(),
@@ -7789,46 +7791,63 @@ impl ThreadView {
details: &SandboxAuthorizationDetails,
cx: &Context<Self>,
) -> AnyElement {
let command = details
.command
.as_deref()
.filter(|command| !command.trim().is_empty());
if command.is_none() && details.write_paths.is_empty() {
let has_network = details.network_all_hosts || !details.network_hosts.is_empty();
if details.write_paths.is_empty() && !has_network {
return Empty.into_any_element();
}
let is_open = !self
.collapsed_sandbox_authorization_details
.contains(tool_call_id);
let mut paths = details.write_paths.clone();
paths.sort();
let has_command = command.is_some();
let network_section = has_network.then(|| {
let summary = if details.network_all_hosts {
"any host".to_string()
} else {
format!(
"{} {}",
details.network_hosts.len(),
if details.network_hosts.len() == 1 {
"host"
} else {
"hosts"
}
)
};
let has_host_list = !details.network_all_hosts && !details.network_hosts.is_empty();
let is_open = !self
.collapsed_sandbox_network_details
.contains(tool_call_id);
let mut hosts = details.network_hosts.clone();
hosts.sort();
v_flex()
.border_t_1()
.border_color(self.tool_card_border_color(cx))
.when_some(command, |this, command| {
this.child(Self::render_sandbox_authorization_command(
entry_ix, command, cx,
))
})
.when(!paths.is_empty(), |this| {
this.child(
v_flex()
.child(
h_flex()
.id(("sandbox-authorization-details-header", entry_ix))
.id(("sandbox-network-details-header", entry_ix))
.p_1()
.justify_between()
.cursor_pointer()
.hover(|style| style.bg(cx.theme().colors().element_hover))
.when(has_command, |this| {
this.border_t_1()
.border_color(self.tool_card_border_color(cx))
.when(has_host_list, |this| {
this.cursor_pointer()
.hover(|style| style.bg(cx.theme().colors().element_hover))
.on_click(cx.listener({
let tool_call_id = tool_call_id.clone();
move |this, _event, _window, cx| {
if this
.collapsed_sandbox_network_details
.remove(&tool_call_id)
{
cx.notify();
return;
}
this.collapsed_sandbox_network_details
.insert(tool_call_id.clone());
cx.notify();
}
}))
})
.child(
h_flex()
.gap_1()
.child(
Label::new("Write access")
Label::new("Network access")
.size(LabelSize::Small)
.color(Color::Muted),
)
@@ -7838,37 +7857,108 @@ impl ThreadView {
.color(Color::Disabled),
)
.child(
Label::new(format!(
"{} {}",
paths.len(),
if paths.len() == 1 { "path" } else { "paths" }
))
.size(LabelSize::Small)
.color(Color::Muted),
Label::new(summary)
.size(LabelSize::Small)
.color(Color::Muted),
),
)
.child(
Disclosure::new(("sandbox-authorization-details", entry_ix), is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown),
)
.on_click(cx.listener({
let tool_call_id = tool_call_id.clone();
move |this, _event, _window, cx| {
if this
.collapsed_sandbox_authorization_details
.remove(&tool_call_id)
{
cx.notify();
return;
}
this.collapsed_sandbox_authorization_details
.insert(tool_call_id.clone());
cx.notify();
}
})),
.when(has_host_list, |this| {
this.child(
Disclosure::new(("sandbox-network-details", entry_ix), is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown),
)
}),
)
.when(has_host_list && is_open, |this| {
this.child(
v_flex()
.id(("sandbox-network-hosts-list", entry_ix))
.max_h_40()
.overflow_y_scroll()
.children(hosts.iter().enumerate().map(|(host_ix, host)| {
h_flex()
.min_w_0()
.p_1p5()
.gap_2()
.bg(cx.theme().colors().editor_background)
.when(host_ix < hosts.len() - 1, |this| {
this.border_b_1().border_color(cx.theme().colors().border)
})
.child(
Icon::new(IconName::Public)
.color(Color::Muted)
.size(IconSize::Small),
)
.child(
Label::new(host.clone())
.size(LabelSize::XSmall)
.buffer_font(cx),
)
})),
)
})
});
if details.write_paths.is_empty() {
return v_flex()
.border_t_1()
.border_color(self.tool_card_border_color(cx))
.children(network_section)
.into_any_element();
}
let is_open = !self
.collapsed_sandbox_authorization_details
.contains(tool_call_id);
let mut paths = details.write_paths.clone();
paths.sort();
let command = details.command.as_deref();
v_flex()
.border_t_1()
.border_color(self.tool_card_border_color(cx))
.children(network_section)
.child(
h_flex()
.id(("sandbox-authorization-details-header", entry_ix))
.p_1()
.justify_between()
.cursor_pointer()
.hover(|style| style.bg(cx.theme().colors().element_hover))
.child(
h_flex().gap_1().child(
Label::new("Write access")
.size(LabelSize::Small)
.color(Color::Muted),
),
)
.child(
Disclosure::new(("sandbox-authorization-details", entry_ix), is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown),
)
.on_click(cx.listener({
let tool_call_id = tool_call_id.clone();
move |this, _event, _window, cx| {
if this
.collapsed_sandbox_authorization_details
.remove(&tool_call_id)
{
cx.notify();
return;
}
this.collapsed_sandbox_authorization_details
.insert(tool_call_id.clone());
cx.notify();
}
})),
)
.when_some(command.filter(|_| is_open), |this, command| {
this.child(Self::render_sandbox_authorization_command(
entry_ix, command, cx,
))
})
.when(is_open && !paths.is_empty(), |this| {
this.child(
+48 -7
View File
@@ -443,10 +443,29 @@ impl AgentSettingsContent {
}
}
pub fn allow_sandbox_network(&mut self) {
pub fn allow_sandbox_all_hosts(&mut self) {
self.sandbox_permissions
.get_or_insert_default()
.allow_network = Some(true);
.allow_all_hosts = Some(true);
}
/// The persisted sandbox network host patterns, as written (callers own
/// parsing/validation).
pub fn sandbox_network_hosts(&self) -> &[String] {
self.sandbox_permissions
.as_ref()
.and_then(|permissions| permissions.network_hosts.as_ref())
.map(|hosts| hosts.0.as_slice())
.unwrap_or_default()
}
/// Replace the persisted sandbox network host patterns. Callers compute
/// the new list (typically the old list plus newly granted hosts, pruned
/// of entries subsumed by wildcards) rather than appending blindly.
pub fn set_sandbox_network_hosts(&mut self, hosts: Vec<String>) {
self.sandbox_permissions
.get_or_insert_default()
.network_hosts = Some(ExtendingVec(hosts));
}
pub fn allow_sandbox_fs_write_all(&mut self) {
@@ -702,10 +721,16 @@ pub enum CustomAgentServerSettings {
#[with_fallible_options]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct SandboxPermissionsContent {
/// Whether sandboxed terminal commands may always use outbound network
/// access without prompting.
/// Whether sandboxed terminal commands may always reach any host over the
/// network without prompting.
/// Default: false
pub allow_network: Option<bool>,
pub allow_all_hosts: Option<bool>,
/// Hosts that sandboxed terminal commands may always reach over the
/// network without prompting. Each entry is an exact hostname
/// (`github.com`) or a leading-`*.` subdomain wildcard (`*.npmjs.org`).
/// Default: []
pub network_hosts: Option<ExtendingVec<String>>,
/// Whether sandboxed terminal commands may always write anywhere on the
/// filesystem without prompting.
@@ -994,13 +1019,29 @@ mod tests {
let mut settings = AgentSettingsContent::default();
assert!(settings.sandbox_permissions.is_none());
settings.allow_sandbox_network();
settings.allow_sandbox_all_hosts();
assert_eq!(settings.sandbox_network_hosts(), &[] as &[String]);
settings
.set_sandbox_network_hosts(vec!["github.com".to_string(), "*.npmjs.org".to_string()]);
assert_eq!(
settings.sandbox_network_hosts(),
&["github.com".to_string(), "*.npmjs.org".to_string()]
);
settings.allow_sandbox_fs_write_all();
settings.allow_sandbox_unsandboxed();
settings.add_sandbox_write_path(PathBuf::from("/tmp/build"));
let sandbox_permissions = settings.sandbox_permissions.as_ref().unwrap();
assert_eq!(sandbox_permissions.allow_network, Some(true));
assert_eq!(sandbox_permissions.allow_all_hosts, Some(true));
assert_eq!(
sandbox_permissions
.network_hosts
.as_ref()
.unwrap()
.0
.as_slice(),
&["github.com".to_string(), "*.npmjs.org".to_string()]
);
assert_eq!(sandbox_permissions.allow_fs_write_all, Some(true));
assert_eq!(sandbox_permissions.allow_unsandboxed, Some(true));
assert_eq!(