mirror of
https://github.com/zed-industries/zed.git
synced 2026-10-19 02:54:11 +00:00
agent: Sandboxing polish (#60173)
Removes git sandbox feature The reason is essentially: - write access to a `.git` dir can be trivially escalated to unsandboxed access - therefore, it is misleading to offer git access separate from unsandboxed access - instead, we encourage the model to use `--no-optional-locks` to avoid needing write access to `git status`, etc. Adds sandboxing to fetch tool --- Release Notes: - N/A or Added/Fixed/Improved ... --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
This commit is contained in:
parent
c35650a884
commit
3648fe6f19
28 changed files with 950 additions and 2332 deletions
|
|
@ -176,9 +176,7 @@ pub struct SandboxAuthorizationDetails {
|
|||
/// builds still render the network request.
|
||||
#[serde(default, alias = "network")]
|
||||
pub network_all_hosts: bool,
|
||||
/// Whether the command requested access to protected `.git` directories.
|
||||
#[serde(default)]
|
||||
pub allow_git_access: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub allow_fs_write_all: bool,
|
||||
#[serde(default)]
|
||||
|
|
|
|||
|
|
@ -46,14 +46,11 @@ pub struct SandboxWrap {
|
|||
pub extra_write_paths: Vec<PathBuf>,
|
||||
/// Outbound network access explicitly approved for this command.
|
||||
pub network: SandboxNetworkAccess,
|
||||
/// The project's `.git` directories (worktree `.git`, linked-worktree common
|
||||
/// dirs, discovered repos). Protected by default; made writable when
|
||||
/// `allow_git_access` is set. Computed by the agent because locating them
|
||||
/// needs Git knowledge the sandbox layer can't derive itself.
|
||||
pub git_dirs: Vec<PathBuf>,
|
||||
/// Whether the user approved access to the protected `.git` directories.
|
||||
pub allow_git_access: bool,
|
||||
/// Allow unrestricted filesystem writes (ignores all writable paths).
|
||||
/// Additional paths that should remain readable but not writable, even when
|
||||
/// they fall under writable paths.
|
||||
pub protected_paths: Vec<PathBuf>,
|
||||
/// Allow unrestricted filesystem writes except for protected paths (ignores
|
||||
/// ordinary writable paths).
|
||||
pub allow_fs_write: bool,
|
||||
/// Whether the project (and therefore this terminal) is local. The
|
||||
/// enforcing proxy binds a loopback port on this host, so it can only
|
||||
|
|
@ -155,8 +152,13 @@ impl SandboxWrap {
|
|||
/// path) rather than passing a re-resolvable path. A location that can't be
|
||||
/// captured (e.g. it doesn't exist) is dropped from the grant — fail-closed.
|
||||
fn to_policy(&self) -> sandbox::SandboxPolicy {
|
||||
let protected_paths = self
|
||||
.protected_paths
|
||||
.iter()
|
||||
.filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok())
|
||||
.collect();
|
||||
let fs = if self.allow_fs_write {
|
||||
sandbox::SandboxFsPolicy::Unrestricted
|
||||
sandbox::SandboxFsPolicy::Unrestricted { protected_paths }
|
||||
} else {
|
||||
let writable_paths = self
|
||||
.writable_paths
|
||||
|
|
@ -169,7 +171,10 @@ impl SandboxWrap {
|
|||
sandbox::HostFilesystemLocation::new(path).ok()
|
||||
})
|
||||
.collect();
|
||||
sandbox::SandboxFsPolicy::Restricted { writable_paths }
|
||||
sandbox::SandboxFsPolicy::Restricted {
|
||||
writable_paths,
|
||||
protected_paths,
|
||||
}
|
||||
};
|
||||
let network = match &self.network {
|
||||
SandboxNetworkAccess::None => sandbox::SandboxNetPolicy::Blocked,
|
||||
|
|
@ -182,17 +187,7 @@ impl SandboxWrap {
|
|||
.collect(),
|
||||
},
|
||||
};
|
||||
let git_dirs = self
|
||||
.git_dirs
|
||||
.iter()
|
||||
.filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok())
|
||||
.collect();
|
||||
let git = if self.allow_git_access {
|
||||
sandbox::GitSandboxPolicy::Allowed { git_dirs }
|
||||
} else {
|
||||
sandbox::GitSandboxPolicy::Denied { git_dirs }
|
||||
};
|
||||
sandbox::SandboxPolicy { fs, network, git }
|
||||
sandbox::SandboxPolicy { fs, network }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -107,9 +107,7 @@ pub struct DbSandboxGrants {
|
|||
/// granted.
|
||||
#[serde(default)]
|
||||
pub allow_fs_write_all: bool,
|
||||
/// Whether access to protected Git directories (`.git`) was granted.
|
||||
#[serde(default)]
|
||||
pub allow_git_access: bool,
|
||||
|
||||
/// Whether the model-requested fully-unsandboxed escape was granted.
|
||||
#[serde(default)]
|
||||
pub unsandboxed: bool,
|
||||
|
|
@ -950,7 +948,6 @@ mod tests {
|
|||
write_paths: vec![PathBuf::from("/tmp/build")],
|
||||
network_hosts: vec!["github.com".to_string(), "*.npmjs.org".to_string()],
|
||||
network_any_host: false,
|
||||
allow_git_access: true,
|
||||
allow_fs_write_all: false,
|
||||
unsandboxed: true,
|
||||
sandbox_fallback: true,
|
||||
|
|
|
|||
|
|
@ -30,9 +30,7 @@ use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag};
|
|||
use gpui::App;
|
||||
use http_proxy::HostPattern;
|
||||
use project::Project;
|
||||
use sandbox::{
|
||||
GitSandboxPolicy, HostFilesystemLocation, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
|
||||
};
|
||||
use sandbox::{HostFilesystemLocation, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy};
|
||||
use settings::Settings;
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
|
@ -48,14 +46,10 @@ pub fn sandbox_worktree_writable_paths(project: &Project, cx: &App) -> Vec<PathB
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// The candidate `.git` directories the sandbox protects for a project. Locating
|
||||
/// these requires Git knowledge the sandbox layer can't derive itself: a
|
||||
/// worktree's `.git`, a linked worktree's common dir (which lives outside the
|
||||
/// worktree), and every discovered repository's git/common dirs.
|
||||
///
|
||||
/// When Git metadata access is granted, terminal enforcement and the status UI
|
||||
/// both run these candidates through the verifier in `sandbox_git_paths` before
|
||||
/// treating external Git metadata as writable.
|
||||
/// The candidate `.git` paths the sandbox protects for a project. Locating these
|
||||
/// requires Git knowledge the sandbox layer can't derive itself: a worktree's
|
||||
/// `.git`, a linked worktree's common dir (which lives outside the worktree),
|
||||
/// and every discovered repository's git/common dirs.
|
||||
pub fn sandbox_git_dirs(project: &Project, cx: &App) -> Vec<PathBuf> {
|
||||
let mut git_dirs = Vec::new();
|
||||
|
||||
|
|
@ -86,9 +80,9 @@ pub fn sandbox_git_dirs(project: &Project, cx: &App) -> Vec<PathBuf> {
|
|||
/// UI renders and enforcement builds from. "No sandbox" is its own variant
|
||||
/// rather than a maximally-permissive [`SandboxPolicy`] so that a wide-open but
|
||||
/// real sandbox (e.g. `allow_fs_write_all` + `allow_all_hosts`) stays
|
||||
/// distinguishable from running with no sandbox at all — the two grant the same
|
||||
/// filesystem/network reach but only the latter means the command runs with
|
||||
/// ambient permissions.
|
||||
/// distinguishable from running with no sandbox at all. The sandbox still
|
||||
/// enforces invariants such as read-only Git metadata, while unsandboxed commands
|
||||
/// run with ambient permissions.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ThreadSandbox {
|
||||
/// No OS sandbox is applied; commands run with ambient permissions.
|
||||
|
|
@ -120,27 +114,20 @@ impl ThreadSandbox {
|
|||
matches!(self, ThreadSandbox::Unsandboxed)
|
||||
}
|
||||
|
||||
/// Attach the project's Git policy to a sandboxed layer. The settings/grants
|
||||
/// don't know the project's `.git` locations, so the caller computes them
|
||||
/// (via [`sandbox_git_dirs`]) and passes whether this layer grants Git
|
||||
/// access. A no-op for the `Unsandboxed` variant.
|
||||
pub fn with_git(self, allowed: bool, git_dirs: Vec<PathBuf>) -> ThreadSandbox {
|
||||
/// Attach the project's protected paths to a sandboxed layer. The settings
|
||||
/// and grants don't know the project's `.git` locations, so the caller
|
||||
/// computes them via [`sandbox_git_dirs`]. A no-op for `Unsandboxed`.
|
||||
pub fn with_protected_paths(self, protected_paths: Vec<PathBuf>) -> ThreadSandbox {
|
||||
match self {
|
||||
ThreadSandbox::Unsandboxed => ThreadSandbox::Unsandboxed,
|
||||
ThreadSandbox::Sandboxed(policy) => {
|
||||
// Capture each `.git` location (pinning its inode / canonical
|
||||
// path). A location that can't be captured (e.g. doesn't exist)
|
||||
// is dropped — fail-closed.
|
||||
let git_dirs = git_dirs
|
||||
// Capture each protected location (pinning its inode / canonical
|
||||
// path). A location that can't be captured is dropped — fail-closed.
|
||||
let protected_paths = protected_paths
|
||||
.into_iter()
|
||||
.filter_map(|path| HostFilesystemLocation::new(path).ok())
|
||||
.collect();
|
||||
let git = if allowed {
|
||||
GitSandboxPolicy::Allowed { git_dirs }
|
||||
} else {
|
||||
GitSandboxPolicy::Denied { git_dirs }
|
||||
};
|
||||
ThreadSandbox::Sandboxed(policy.with_git(git))
|
||||
ThreadSandbox::Sandboxed(policy.with_protected_paths(protected_paths))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -164,7 +151,9 @@ pub fn settings_thread_sandbox(persistent: &SandboxPermissions) -> ThreadSandbox
|
|||
/// from [`ThreadSandboxGrants::to_policy`].
|
||||
pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy {
|
||||
let fs = if persistent.allow_fs_write_all {
|
||||
SandboxFsPolicy::Unrestricted
|
||||
SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
} else {
|
||||
SandboxFsPolicy::Restricted {
|
||||
writable_paths: persistent
|
||||
|
|
@ -172,6 +161,7 @@ pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy
|
|||
.iter()
|
||||
.filter_map(|path| HostFilesystemLocation::new(path).ok())
|
||||
.collect(),
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
};
|
||||
let network = if persistent.allow_all_hosts {
|
||||
|
|
@ -183,13 +173,7 @@ pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy
|
|||
allowed_domains: persistent.network_hosts.clone(),
|
||||
}
|
||||
};
|
||||
// The persistent settings don't know the project's `.git` locations; the UI
|
||||
// layer attaches the real Git policy via `SandboxPolicy::with_git`.
|
||||
SandboxPolicy {
|
||||
fs,
|
||||
network,
|
||||
git: GitSandboxPolicy::default(),
|
||||
}
|
||||
SandboxPolicy { fs, network }
|
||||
}
|
||||
|
||||
/// Whether agent-run terminal commands should be wrapped in an OS-level
|
||||
|
|
@ -275,8 +259,7 @@ impl NetworkRequest {
|
|||
pub(crate) struct SandboxRequest {
|
||||
/// Outbound network access requested for this command.
|
||||
pub network: NetworkRequest,
|
||||
/// Allow access to protected Git metadata paths.
|
||||
pub allow_git_access: bool,
|
||||
|
||||
/// Allow unrestricted filesystem writes (the broad escape hatch).
|
||||
pub allow_fs_write_all: bool,
|
||||
/// Run the command fully outside the sandbox.
|
||||
|
|
@ -291,7 +274,6 @@ impl SandboxRequest {
|
|||
/// scope, and therefore needs user approval.
|
||||
pub fn needs_escalation(&self) -> bool {
|
||||
self.network.is_requested()
|
||||
|| self.allow_git_access
|
||||
|| self.allow_fs_write_all
|
||||
|| self.unsandboxed
|
||||
|| !self.write_paths.is_empty()
|
||||
|
|
@ -312,7 +294,6 @@ pub(crate) struct ThreadSandboxGrants {
|
|||
/// 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_git_access: bool,
|
||||
allow_fs_write_all: bool,
|
||||
unsandboxed: bool,
|
||||
/// Whether the user approved running commands *without* a sandbox for the
|
||||
|
|
@ -353,14 +334,13 @@ impl ThreadSandboxGrants {
|
|||
if !self.network_covered(&request.network, persistent) {
|
||||
return false;
|
||||
}
|
||||
if request.allow_git_access && !(self.allow_git_access || persistent.allow_git_access) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if request.allow_fs_write_all && !(self.allow_fs_write_all || persistent.allow_fs_write_all)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// A full-access write grant covers any concrete write request.
|
||||
// A full-access write grant covers any concrete write request at the
|
||||
// authorization layer; protected paths are enforced by the sandbox.
|
||||
if self.allow_fs_write_all || persistent.allow_fs_write_all {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -412,12 +392,6 @@ impl ThreadSandboxGrants {
|
|||
self.unsandboxed
|
||||
}
|
||||
|
||||
/// Whether the user approved access to protected Git directories for the
|
||||
/// rest of the thread.
|
||||
pub fn git_access_granted(&self) -> bool {
|
||||
self.allow_git_access
|
||||
}
|
||||
|
||||
/// Record that the user approved running commands unsandboxed for the rest
|
||||
/// of the thread when the sandbox can't be created. Only the Bubblewrap
|
||||
/// sandboxes (Linux directly, Windows via WSL) can fail to create a
|
||||
|
|
@ -447,7 +421,9 @@ impl ThreadSandboxGrants {
|
|||
/// from [`settings_sandbox_policy`].
|
||||
pub fn to_policy(&self) -> SandboxPolicy {
|
||||
let fs = if self.allow_fs_write_all {
|
||||
SandboxFsPolicy::Unrestricted
|
||||
SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
} else {
|
||||
SandboxFsPolicy::Restricted {
|
||||
writable_paths: self
|
||||
|
|
@ -455,6 +431,7 @@ impl ThreadSandboxGrants {
|
|||
.iter()
|
||||
.filter_map(|path| HostFilesystemLocation::new(path).ok())
|
||||
.collect(),
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
};
|
||||
let network = if self.network_any_host {
|
||||
|
|
@ -470,13 +447,7 @@ impl ThreadSandboxGrants {
|
|||
.collect(),
|
||||
}
|
||||
};
|
||||
// Grants don't carry the project's `.git` locations; the UI layer
|
||||
// attaches the real Git policy via `SandboxPolicy::with_git`.
|
||||
SandboxPolicy {
|
||||
fs,
|
||||
network,
|
||||
git: GitSandboxPolicy::default(),
|
||||
}
|
||||
SandboxPolicy { fs, network }
|
||||
}
|
||||
|
||||
/// Serialize these grants for persistence in the thread's database row.
|
||||
|
|
@ -491,7 +462,6 @@ impl ThreadSandboxGrants {
|
|||
.map(|host| host.to_string())
|
||||
.collect(),
|
||||
network_any_host: self.network_any_host,
|
||||
allow_git_access: self.allow_git_access,
|
||||
allow_fs_write_all: self.allow_fs_write_all,
|
||||
unsandboxed: self.unsandboxed,
|
||||
sandbox_fallback: self.sandbox_fallback,
|
||||
|
|
@ -514,7 +484,6 @@ impl ThreadSandboxGrants {
|
|||
Self {
|
||||
network_any_host: db.network_any_host,
|
||||
network_hosts,
|
||||
allow_git_access: db.allow_git_access,
|
||||
allow_fs_write_all: db.allow_fs_write_all,
|
||||
unsandboxed: db.unsandboxed,
|
||||
sandbox_fallback: db.sandbox_fallback,
|
||||
|
|
@ -534,7 +503,6 @@ impl ThreadSandboxGrants {
|
|||
}
|
||||
}
|
||||
}
|
||||
self.allow_git_access |= request.allow_git_access;
|
||||
self.allow_fs_write_all |= request.allow_fs_write_all;
|
||||
self.unsandboxed |= request.unsandboxed;
|
||||
for path in &request.write_paths {
|
||||
|
|
@ -586,9 +554,6 @@ impl ThreadSandboxGrants {
|
|||
}
|
||||
SandboxRequest {
|
||||
network,
|
||||
allow_git_access: persistent.allow_git_access
|
||||
|| self.allow_git_access
|
||||
|| request.allow_git_access,
|
||||
allow_fs_write_all: persistent.allow_fs_write_all
|
||||
|| self.allow_fs_write_all
|
||||
|| request.allow_fs_write_all,
|
||||
|
|
@ -643,7 +608,6 @@ mod tests {
|
|||
fn request(network: NetworkRequest, all: bool, paths: &[&str]) -> SandboxRequest {
|
||||
SandboxRequest {
|
||||
network,
|
||||
allow_git_access: false,
|
||||
allow_fs_write_all: all,
|
||||
unsandboxed: false,
|
||||
write_paths: paths.iter().map(PathBuf::from).collect(),
|
||||
|
|
@ -653,7 +617,6 @@ mod tests {
|
|||
fn unsandboxed_request() -> SandboxRequest {
|
||||
SandboxRequest {
|
||||
network: NetworkRequest::None,
|
||||
allow_git_access: false,
|
||||
allow_fs_write_all: false,
|
||||
unsandboxed: true,
|
||||
write_paths: Vec::new(),
|
||||
|
|
@ -675,6 +638,7 @@ mod tests {
|
|||
.iter()
|
||||
.map(|p| HostFilesystemLocation::new(p).expect("capture temp dir"))
|
||||
.collect(),
|
||||
protected_paths: Vec::new(),
|
||||
},
|
||||
network: if hosts.is_empty() {
|
||||
SandboxNetPolicy::Blocked
|
||||
|
|
@ -683,7 +647,6 @@ mod tests {
|
|||
allowed_domains: hosts.iter().map(|h| h.to_string()).collect(),
|
||||
}
|
||||
},
|
||||
git: GitSandboxPolicy::default(),
|
||||
};
|
||||
|
||||
// Unsandboxed on either side wins — the agent runs with ambient access.
|
||||
|
|
@ -783,7 +746,8 @@ mod tests {
|
|||
SandboxFsPolicy::Restricted {
|
||||
writable_paths: vec![
|
||||
HostFilesystemLocation::new(build_dir.path()).expect("capture temp dir")
|
||||
]
|
||||
],
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
|
|
@ -798,7 +762,8 @@ mod tests {
|
|||
assert_eq!(
|
||||
empty.fs,
|
||||
SandboxFsPolicy::Restricted {
|
||||
writable_paths: Vec::new()
|
||||
writable_paths: Vec::new(),
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
);
|
||||
assert_eq!(empty.network, SandboxNetPolicy::Blocked);
|
||||
|
|
@ -807,7 +772,12 @@ mod tests {
|
|||
let mut broad = ThreadSandboxGrants::default();
|
||||
broad.record(&request(NetworkRequest::AnyHost, true, &[]));
|
||||
let policy = broad.to_policy();
|
||||
assert_eq!(policy.fs, SandboxFsPolicy::Unrestricted);
|
||||
assert_eq!(
|
||||
policy.fs,
|
||||
SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
);
|
||||
assert_eq!(policy.network, SandboxNetPolicy::Unrestricted);
|
||||
}
|
||||
|
||||
|
|
@ -828,7 +798,8 @@ mod tests {
|
|||
SandboxFsPolicy::Restricted {
|
||||
writable_paths: vec![
|
||||
HostFilesystemLocation::new(log_dir.path()).expect("capture temp dir")
|
||||
]
|
||||
],
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
|
|
@ -844,7 +815,12 @@ mod tests {
|
|||
..Default::default()
|
||||
};
|
||||
let policy = settings_sandbox_policy(&unrestricted);
|
||||
assert_eq!(policy.fs, SandboxFsPolicy::Unrestricted);
|
||||
assert_eq!(
|
||||
policy.fs,
|
||||
SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
);
|
||||
assert_eq!(policy.network, SandboxNetPolicy::Unrestricted);
|
||||
}
|
||||
|
||||
|
|
@ -1030,33 +1006,6 @@ mod tests {
|
|||
assert!(!covers(&grants, &request(NetworkRequest::None, true, &[])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_access_grant_tracked_independently() {
|
||||
let mut git_request = request(NetworkRequest::None, false, &[]);
|
||||
git_request.allow_git_access = true;
|
||||
|
||||
let mut grants = ThreadSandboxGrants::default();
|
||||
assert!(!covers(&grants, &git_request));
|
||||
|
||||
grants.record(&git_request);
|
||||
assert!(covers(&grants, &git_request));
|
||||
assert!(!covers(
|
||||
&grants,
|
||||
&request(NetworkRequest::AnyHost, false, &[])
|
||||
));
|
||||
assert!(!covers(&grants, &request(NetworkRequest::None, true, &[])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrestricted_writes_do_not_cover_git_access() {
|
||||
let mut grants = ThreadSandboxGrants::default();
|
||||
grants.record(&request(NetworkRequest::None, true, &[]));
|
||||
|
||||
let mut git_request = request(NetworkRequest::None, false, &[]);
|
||||
git_request.allow_git_access = true;
|
||||
assert!(!covers(&grants, &git_request));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_grants_combine_with_thread_grants() {
|
||||
let mut grants = ThreadSandboxGrants::default();
|
||||
|
|
@ -1189,7 +1138,6 @@ mod tests {
|
|||
let grants = ThreadSandboxGrants::default();
|
||||
let persistent = SandboxPermissions {
|
||||
allow_all_hosts: true,
|
||||
allow_git_access: true,
|
||||
write_paths: vec![PathBuf::from("/tmp/always")],
|
||||
..Default::default()
|
||||
};
|
||||
|
|
@ -1197,7 +1145,6 @@ mod tests {
|
|||
let effective = grants
|
||||
.effective_with_persistent(&request(NetworkRequest::None, false, &[]), &persistent);
|
||||
assert_eq!(effective.network, NetworkRequest::AnyHost);
|
||||
assert!(effective.allow_git_access);
|
||||
assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/always")]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -209,13 +209,12 @@ mod tests {
|
|||
assert!(rendered.contains("allow_hosts"));
|
||||
assert!(rendered.contains("allow_all_hosts: true"));
|
||||
assert!(rendered.contains("fs_write_paths"));
|
||||
assert!(rendered.contains("allow_git_access: true"));
|
||||
assert!(rendered.contains("allow_fs_write_all: true"));
|
||||
assert!(rendered.contains("unsandboxed: true"));
|
||||
assert!(rendered.contains("file contents under `.git` directories"));
|
||||
assert!(
|
||||
rendered.contains("worktree metadata that may live outside the project directories")
|
||||
);
|
||||
assert!(rendered.contains("`.git` directories remain protected"));
|
||||
assert!(rendered.contains("Git metadata writes are never grantable inside the sandbox"));
|
||||
assert!(rendered.contains("request `unsandboxed: true` with a reason"));
|
||||
assert!(rendered.contains("git --no-optional-locks status"));
|
||||
assert!(rendered.contains("for the rest of the thread"));
|
||||
}
|
||||
|
||||
|
|
@ -249,6 +248,37 @@ mod tests {
|
|||
assert!(rendered.contains("`/tmp/alpha`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_windows_sandbox_section_rejects_host_specific_network() {
|
||||
use prompt_store::{ProjectContext, WorktreeContext};
|
||||
|
||||
let worktrees = vec![WorktreeContext {
|
||||
root_name: "alpha".to_string(),
|
||||
abs_path: std::path::Path::new("C:/Users/me/project").into(),
|
||||
rules_file: None,
|
||||
}];
|
||||
let project = ProjectContext::new(worktrees);
|
||||
let template = SystemPromptTemplate {
|
||||
project: &project,
|
||||
available_tools: vec!["echo".into()],
|
||||
model_name: Some("test-model".to_string()),
|
||||
date: "2026-01-01".to_string(),
|
||||
user_agents_md: None,
|
||||
sandboxing: true,
|
||||
is_linux: false,
|
||||
is_windows: true,
|
||||
};
|
||||
let templates = Templates::new();
|
||||
let rendered = template.render(&templates).unwrap();
|
||||
|
||||
assert!(rendered.contains("commands run inside WSL under Bubblewrap"));
|
||||
assert!(rendered.contains("Protected Git metadata remains read-only"));
|
||||
assert!(rendered.contains("do not use this on Windows"));
|
||||
assert!(rendered.contains("such requests are rejected"));
|
||||
assert!(rendered.contains("allow_all_hosts: true"));
|
||||
assert!(rendered.contains("git --no-optional-locks status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_sandbox_section_handles_zero_worktrees() {
|
||||
let project = prompt_store::ProjectContext::default();
|
||||
|
|
|
|||
|
|
@ -158,13 +158,13 @@ The current project contains the following root directories:
|
|||
|
||||
The `terminal` tool runs commands inside a sandbox with these permissions:
|
||||
|
||||
- Reads: any path on the filesystem is readable, except that file contents under `.git` directories are blocked by default (their metadata stays visible). See `allow_git_access` below.
|
||||
- Reads: any path on the filesystem is readable, including Git metadata.
|
||||
{{#if is_linux}}
|
||||
- Writes: `/tmp` is writable but is cleared between `terminal` calls{{#if worktrees}}. These project directories are also writable and persist across calls:
|
||||
{{#each worktrees}}
|
||||
- `{{abs_path}}`
|
||||
{{/each}}
|
||||
`.git` directories are not writable by default. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}}
|
||||
`.git` directories remain protected. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}}
|
||||
{{else}}
|
||||
{{#if is_windows}}
|
||||
- Execution: commands run inside WSL under Bubblewrap. Native Windows project paths are routed through WSL's `/mnt/<drive>/...` filesystem view.
|
||||
|
|
@ -172,13 +172,13 @@ The `terminal` tool runs commands inside a sandbox with these permissions:
|
|||
{{#each worktrees}}
|
||||
- `{{abs_path}}`
|
||||
{{/each}}
|
||||
Writes anywhere else on the WSL filesystem and mounted Windows drives are blocked.{{else}}. No project directories are currently writable.{{/if}}
|
||||
Protected Git metadata remains read-only. Writes anywhere else on the WSL filesystem and mounted Windows drives are blocked.{{else}}. No project directories are currently writable.{{/if}}
|
||||
{{else}}
|
||||
- Writes: a per-thread temporary directory exposed via `$TMPDIR`, `$TMP`, and `$TEMP` is writable and persists across `terminal` calls in this thread{{#if worktrees}}, along with these project directories:
|
||||
{{#each worktrees}}
|
||||
- `{{abs_path}}`
|
||||
{{/each}}
|
||||
`.git` directories are not writable by default. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}}
|
||||
`.git` directories remain protected. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
- Network: outbound network access is blocked.
|
||||
|
|
@ -189,7 +189,7 @@ The sandbox can only allow or block outbound network access as a whole — it ca
|
|||
You can request elevated permissions on individual `terminal` calls:
|
||||
|
||||
- `allow_all_hosts: true` — allow unrestricted outbound network access. On this platform this is the only way to grant network access.
|
||||
- `allow_hosts: ["github.com", ...]` — accepted, but on this platform listing hosts grants unrestricted outbound network access (identical to `allow_all_hosts`), because per-host restriction can't be enforced. Prefer `allow_all_hosts` so the request is explicit.
|
||||
- `allow_hosts: ["github.com", ...]` — do not use this on Windows. Host-specific network grants cannot be enforced, and such requests are rejected; use `allow_all_hosts: true` when the command genuinely needs network access.
|
||||
{{else}}
|
||||
Host-scoped network access works through an HTTP/HTTPS proxy (standard proxy environment variables are set for the command). When access is scoped to specific hosts, tools that don't honor proxy environment variables (SSH, FTP, raw sockets, etc.) can't reach them, so use `https://` URLs instead of `git@`/`ssh://` when cloning or pushing.
|
||||
|
||||
|
|
@ -198,10 +198,11 @@ You can request elevated permissions on individual `terminal` calls:
|
|||
- `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` — lift the network restriction entirely: outbound access to any host over any protocol, so SSH, FTP, and raw sockets work too (unlike `allow_hosts`, which is HTTP/HTTPS-only). Use only when the specific hosts can't be enumerated up front.
|
||||
{{/if}}
|
||||
- `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_git_access: true` — lift the default block on Git metadata, allowing reads and writes of file contents under `.git` directories (including worktree metadata that may live outside the project directories). Any command that touches `.git` needs this, including ones that invoke Git only incidentally (e.g. build scripts calling `git describe`/`git rev-parse`, or commit hooks).
|
||||
- `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.
|
||||
- `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. Git metadata paths cannot be requested and will never be made writable while sandboxed.
|
||||
- `allow_fs_write_all: true` — allow unrestricted filesystem writes except protected Git metadata. 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, including when a command must write Git metadata.
|
||||
|
||||
Git metadata writes are never grantable inside the sandbox. If a command needs to update `.git`, linked worktree metadata, refs, the index, hooks, local Git config, or other Git metadata, request `unsandboxed: true` with a reason. For read-only Git operations, prefer flags that avoid optional metadata writes where possible, such as `git --no-optional-locks status` instead of `git status`.
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -7147,6 +7147,12 @@ async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext)
|
|||
invalid_patterns: vec![],
|
||||
},
|
||||
);
|
||||
// The fetch tool also gates on the shared per-host network grant, so
|
||||
// grant docs.rs to keep this URL fully silent.
|
||||
settings
|
||||
.sandbox_permissions
|
||||
.network_hosts
|
||||
.push("docs.rs".into());
|
||||
agent_settings::AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
|
||||
|
|
@ -7166,7 +7172,181 @@ async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext)
|
|||
let event = rx.try_recv();
|
||||
assert!(
|
||||
!matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))),
|
||||
"expected no authorization request for allowed docs.rs URL"
|
||||
"expected no authorization request for allowed and granted docs.rs URL"
|
||||
);
|
||||
}
|
||||
|
||||
/// A fetch to a host that hasn't been granted network access prompts for the
|
||||
/// shared per-host sandbox grant, even when the tool itself is allowed.
|
||||
#[gpui::test]
|
||||
async fn test_fetch_tool_prompts_for_ungranted_host(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.tool_permissions.tools.insert(
|
||||
FetchTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![],
|
||||
always_confirm: vec![],
|
||||
invalid_patterns: vec![],
|
||||
},
|
||||
);
|
||||
agent_settings::AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
|
||||
let http_client = gpui::http_client::FakeHttpClient::with_200_response();
|
||||
|
||||
#[allow(clippy::arc_with_non_send_sync)]
|
||||
let tool = Arc::new(crate::FetchTool::new(http_client));
|
||||
let (event_stream, mut rx) = crate::ToolCallEventStream::test();
|
||||
|
||||
let input: crate::FetchToolInput =
|
||||
serde_json::from_value(json!({"url": "https://example.com/page"})).unwrap();
|
||||
|
||||
let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let authorization = rx.expect_authorization().await;
|
||||
let details =
|
||||
acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta)
|
||||
.expect("an ungranted host should request a sandbox network grant");
|
||||
assert_eq!(details.network_hosts, vec!["example.com".to_string()]);
|
||||
assert!(!details.network_all_hosts);
|
||||
}
|
||||
|
||||
/// A host already present in the shared sandbox grants lets a fetch proceed
|
||||
/// without any prompt — the same grant the terminal tool records and consults.
|
||||
#[gpui::test]
|
||||
async fn test_fetch_tool_granted_host_skips_prompt(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
// Allow the tool itself so only the shared per-host grant is under test.
|
||||
settings.tool_permissions.tools.insert(
|
||||
FetchTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![],
|
||||
always_confirm: vec![],
|
||||
invalid_patterns: vec![],
|
||||
},
|
||||
);
|
||||
settings
|
||||
.sandbox_permissions
|
||||
.network_hosts
|
||||
.push("example.com".into());
|
||||
agent_settings::AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
|
||||
let http_client = gpui::http_client::FakeHttpClient::with_200_response();
|
||||
|
||||
#[allow(clippy::arc_with_non_send_sync)]
|
||||
let tool = Arc::new(crate::FetchTool::new(http_client));
|
||||
let (event_stream, mut rx) = crate::ToolCallEventStream::test();
|
||||
|
||||
let input: crate::FetchToolInput =
|
||||
serde_json::from_value(json!({"url": "https://example.com/page"})).unwrap();
|
||||
|
||||
let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let event = rx.try_recv();
|
||||
assert!(
|
||||
!matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))),
|
||||
"expected no authorization request for an already-granted host"
|
||||
);
|
||||
}
|
||||
|
||||
/// Loopback / IP-literal hosts can't be granted individually, so without
|
||||
/// unsandboxed access a fetch to them is refused with guidance to grant it.
|
||||
#[gpui::test]
|
||||
async fn test_fetch_tool_refuses_loopback_without_unsandboxed(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
// Allow the tool itself so the request reaches the per-host gate.
|
||||
settings.tool_permissions.tools.insert(
|
||||
FetchTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![],
|
||||
always_confirm: vec![],
|
||||
invalid_patterns: vec![],
|
||||
},
|
||||
);
|
||||
agent_settings::AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
|
||||
let http_client = gpui::http_client::FakeHttpClient::with_200_response();
|
||||
|
||||
#[allow(clippy::arc_with_non_send_sync)]
|
||||
let tool = Arc::new(crate::FetchTool::new(http_client));
|
||||
let (event_stream, _rx) = crate::ToolCallEventStream::test();
|
||||
|
||||
let input: crate::FetchToolInput =
|
||||
serde_json::from_value(json!({"url": "http://localhost:3000/api"})).unwrap();
|
||||
|
||||
let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
|
||||
let result = task.await;
|
||||
assert!(result.is_err(), "expected a loopback fetch to be refused");
|
||||
assert!(
|
||||
result.unwrap_err().contains("unsandboxed"),
|
||||
"error should point at unsandboxed access as the way to reach loopback hosts"
|
||||
);
|
||||
}
|
||||
|
||||
/// Granting unsandboxed access lifts every fetch restriction, matching the
|
||||
/// terminal: even loopback hosts become reachable and no per-host prompt is
|
||||
/// requested.
|
||||
#[gpui::test]
|
||||
async fn test_fetch_tool_unsandboxed_lifts_restrictions(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.sandbox_permissions.allow_unsandboxed = true;
|
||||
// Allow the tool itself so only the per-host gate is under test.
|
||||
settings.tool_permissions.tools.insert(
|
||||
FetchTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![],
|
||||
always_confirm: vec![],
|
||||
invalid_patterns: vec![],
|
||||
},
|
||||
);
|
||||
agent_settings::AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
|
||||
let http_client = gpui::http_client::FakeHttpClient::with_200_response();
|
||||
|
||||
#[allow(clippy::arc_with_non_send_sync)]
|
||||
let tool = Arc::new(crate::FetchTool::new(http_client));
|
||||
let (event_stream, mut rx) = crate::ToolCallEventStream::test();
|
||||
|
||||
// A loopback host that could never be granted individually is reachable,
|
||||
// and no per-host authorization is requested.
|
||||
let input: crate::FetchToolInput =
|
||||
serde_json::from_value(json!({"url": "http://localhost:3000/api"})).unwrap();
|
||||
|
||||
let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let event = rx.try_recv();
|
||||
assert!(
|
||||
!matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))),
|
||||
"expected no authorization request when unsandboxed access is granted"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ use action_log::ActionLog;
|
|||
use agent_settings::UserAgentsMd;
|
||||
|
||||
use crate::sandboxing::{
|
||||
SandboxRequest, ThreadSandbox, ThreadSandboxGrants, sandboxing_available_for_project,
|
||||
SandboxRequest, ThreadSandbox, ThreadSandboxGrants, sandbox_git_dirs,
|
||||
sandbox_worktree_writable_paths, sandboxing_available_for_project,
|
||||
sandboxing_enabled_for_project,
|
||||
};
|
||||
use crate::tools::{SandboxGitPathCandidates, sandbox_git_paths};
|
||||
use agent_client_protocol::schema::v1 as acp;
|
||||
use agent_settings::{
|
||||
AgentProfileId, AgentProfileSettings, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT,
|
||||
|
|
@ -99,9 +99,6 @@ pub struct SandboxStatusKey {
|
|||
pub thread_sandbox: ThreadSandbox,
|
||||
pub baseline_writable_paths: Vec<PathBuf>,
|
||||
pub git_paths: Vec<PathBuf>,
|
||||
pub repository_paths: Vec<(PathBuf, PathBuf, PathBuf, PathBuf)>,
|
||||
pub settings_allow_git_access: bool,
|
||||
pub thread_allow_git_access: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
|
|
@ -1794,21 +1791,16 @@ impl Thread {
|
|||
}
|
||||
}
|
||||
|
||||
/// The sandbox grants configured for this thread, using unverified Git path
|
||||
/// candidates. Use [`Self::refresh_verified_sandbox_status`] for UI or other
|
||||
/// surfaces that need to match terminal enforcement.
|
||||
pub fn sandbox_status(&self, cx: &App) -> Option<(ThreadSandbox, ThreadSandbox)> {
|
||||
if !self.sandboxing_available(cx) {
|
||||
return None;
|
||||
}
|
||||
let persistent = AgentSettings::get_global(cx).sandbox_permissions.clone();
|
||||
let git_dirs = crate::sandboxing::sandbox_git_dirs(self.project.read(cx), cx);
|
||||
let git_dirs = sandbox_git_dirs(self.project.read(cx), cx);
|
||||
let grants = self.sandbox_grants.borrow();
|
||||
let settings = crate::sandboxing::settings_thread_sandbox(&persistent)
|
||||
.with_git(persistent.allow_git_access, git_dirs.clone());
|
||||
let thread = grants
|
||||
.thread_sandbox()
|
||||
.with_git(grants.git_access_granted(), git_dirs);
|
||||
.with_protected_paths(git_dirs.clone());
|
||||
let thread = grants.thread_sandbox().with_protected_paths(git_dirs);
|
||||
Some((settings, thread))
|
||||
}
|
||||
|
||||
|
|
@ -1824,69 +1816,27 @@ impl Thread {
|
|||
let settings_sandbox = crate::sandboxing::settings_thread_sandbox(&persistent);
|
||||
let grants = self.sandbox_grants.borrow();
|
||||
let thread_sandbox = grants.thread_sandbox();
|
||||
let thread_allow_git_access = grants.git_access_granted();
|
||||
drop(grants);
|
||||
|
||||
let (sandbox_path_candidates, fs) = {
|
||||
let project = self.project.read(cx);
|
||||
(
|
||||
SandboxGitPathCandidates::from_project(project, cx),
|
||||
project.fs().clone(),
|
||||
)
|
||||
};
|
||||
let baseline_writable_paths = sandbox_path_candidates.writable_paths.clone();
|
||||
let git_paths = sandbox_path_candidates.git_paths.clone();
|
||||
let repository_paths = sandbox_path_candidates.cache_key_repositories();
|
||||
let project = self.project.read(cx);
|
||||
let baseline_writable_paths = sandbox_worktree_writable_paths(project, cx);
|
||||
let git_paths = sandbox_git_dirs(project, cx);
|
||||
|
||||
let key = SandboxStatusKey {
|
||||
settings_sandbox: settings_sandbox.clone(),
|
||||
thread_sandbox: thread_sandbox.clone(),
|
||||
baseline_writable_paths: baseline_writable_paths.clone(),
|
||||
git_paths: git_paths.clone(),
|
||||
repository_paths,
|
||||
settings_allow_git_access: persistent.allow_git_access,
|
||||
thread_allow_git_access,
|
||||
};
|
||||
|
||||
if settings_sandbox.is_unsandboxed() || thread_sandbox.is_unsandboxed() {
|
||||
return Some((
|
||||
key,
|
||||
SandboxStatusRefresh::Ready(VerifiedSandboxStatus {
|
||||
settings_sandbox,
|
||||
thread_sandbox,
|
||||
baseline_writable_paths,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let git_access_requested = persistent.allow_git_access || thread_allow_git_access;
|
||||
if !git_access_requested {
|
||||
return Some((
|
||||
key,
|
||||
SandboxStatusRefresh::Ready(VerifiedSandboxStatus {
|
||||
settings_sandbox: settings_sandbox.with_git(false, git_paths.clone()),
|
||||
thread_sandbox: thread_sandbox.with_git(false, git_paths),
|
||||
baseline_writable_paths,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let task = cx.spawn(async move |_this, _cx| {
|
||||
let sandbox_paths = sandbox_git_paths(sandbox_path_candidates, fs.as_ref(), true).await;
|
||||
VerifiedSandboxStatus {
|
||||
settings_sandbox: settings_sandbox.with_git(
|
||||
persistent.allow_git_access && sandbox_paths.allow_git_access,
|
||||
sandbox_paths.git_dirs.clone(),
|
||||
),
|
||||
thread_sandbox: thread_sandbox.with_git(
|
||||
thread_allow_git_access && sandbox_paths.allow_git_access,
|
||||
sandbox_paths.git_dirs,
|
||||
),
|
||||
Some((
|
||||
key,
|
||||
SandboxStatusRefresh::Ready(VerifiedSandboxStatus {
|
||||
settings_sandbox: settings_sandbox.with_protected_paths(git_paths.clone()),
|
||||
thread_sandbox: thread_sandbox.with_protected_paths(git_paths),
|
||||
baseline_writable_paths,
|
||||
}
|
||||
});
|
||||
|
||||
Some((key, SandboxStatusRefresh::Pending(task)))
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether agent terminal commands are sandboxed for this thread's project,
|
||||
|
|
@ -5650,7 +5600,6 @@ impl ToolCallEventStream {
|
|||
command: None,
|
||||
network_hosts,
|
||||
network_all_hosts,
|
||||
allow_git_access: request.allow_git_access,
|
||||
allow_fs_write_all: request.allow_fs_write_all,
|
||||
unsandboxed: request.unsandboxed,
|
||||
write_paths: request.write_paths.clone(),
|
||||
|
|
@ -5855,9 +5804,7 @@ impl ToolCallEventStream {
|
|||
agent.set_sandbox_network_hosts(host_strings);
|
||||
}
|
||||
}
|
||||
if request.allow_git_access {
|
||||
agent.allow_sandbox_git_access();
|
||||
}
|
||||
|
||||
if request.allow_fs_write_all {
|
||||
agent.allow_sandbox_fs_write_all();
|
||||
}
|
||||
|
|
@ -5901,6 +5848,20 @@ impl ToolCallEventStream {
|
|||
self.sandbox_grants.borrow().unsandboxed_granted()
|
||||
}
|
||||
|
||||
/// Whether unsandboxed access is currently in effect: granted for this
|
||||
/// thread (a model-requested `unsandboxed` escape or a sandbox-creation
|
||||
/// fallback) or configured persistently via `allow_unsandboxed`. When true,
|
||||
/// commands already run without any OS sandbox, so per-host network grants
|
||||
/// no longer provide isolation and callers may skip host authorization
|
||||
/// entirely.
|
||||
pub(crate) fn unsandboxed_access_granted(&self, cx: &App) -> bool {
|
||||
self.unsandboxed_granted_for_thread()
|
||||
|| self.sandbox_fallback_granted_for_thread()
|
||||
|| AgentSettings::get_global(cx)
|
||||
.sandbox_permissions
|
||||
.allow_unsandboxed
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
|
@ -7592,7 +7553,6 @@ mod tests {
|
|||
let (event_stream, mut receiver) = ToolCallEventStream::test();
|
||||
let request = SandboxRequest {
|
||||
network: crate::sandboxing::NetworkRequest::None,
|
||||
allow_git_access: false,
|
||||
allow_fs_write_all: false,
|
||||
unsandboxed: false,
|
||||
write_paths: vec![
|
||||
|
|
@ -7616,7 +7576,6 @@ mod tests {
|
|||
.expect("sandbox authorization should include request details");
|
||||
assert!(details.network_hosts.is_empty());
|
||||
assert!(!details.network_all_hosts);
|
||||
assert_eq!(details.allow_git_access, request.allow_git_access);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ pub use rename_tool::*;
|
|||
pub use skill_tool::*;
|
||||
pub use spawn_agent_tool::*;
|
||||
pub use symbol_locator::*;
|
||||
pub(crate) use terminal_tool::sandbox_git_paths::{SandboxGitPathCandidates, sandbox_git_paths};
|
||||
|
||||
pub use terminal_tool::*;
|
||||
pub use tool_permissions::*;
|
||||
pub use web_search_tool::*;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize};
|
|||
use ui::SharedString;
|
||||
use util::markdown::{MarkdownEscaped, MarkdownInlineCode};
|
||||
|
||||
use crate::sandboxing::{NetworkRequest, SandboxRequest};
|
||||
use crate::{AgentTool, ToolCallEventStream, ToolInput};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
|
||||
|
|
@ -23,6 +24,14 @@ enum ContentType {
|
|||
}
|
||||
|
||||
/// Fetches a URL and returns the content as Markdown.
|
||||
///
|
||||
/// This tool is not run inside the terminal OS sandbox, but it still refuses to
|
||||
/// reach any host that hasn't been granted network access. It shares the same
|
||||
/// per-host grants as the `terminal` tool: approving a host for one authorizes
|
||||
/// it for the other, whether the grant is for this thread or saved permanently.
|
||||
/// When unsandboxed access has been granted, these restrictions are lifted
|
||||
/// entirely, matching the terminal, which is also how loopback and IP-literal
|
||||
/// hosts (which can't be granted individually) become reachable.
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct FetchToolInput {
|
||||
/// The URL to fetch.
|
||||
|
|
@ -114,6 +123,30 @@ impl FetchTool {
|
|||
}
|
||||
}
|
||||
|
||||
/// Extracts the host from a fetch URL as a [`http_proxy::HostPattern`] so it can
|
||||
/// be matched against the shared network grants. Mirrors the scheme handling in
|
||||
/// [`FetchTool::build_message`] (defaulting to `https://` when none is given).
|
||||
fn host_pattern_for_url(url: &str) -> Result<http_proxy::HostPattern> {
|
||||
let normalized = if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||
Cow::Owned(format!("https://{url}"))
|
||||
} else {
|
||||
Cow::Borrowed(url)
|
||||
};
|
||||
let parsed =
|
||||
url::Url::parse(&normalized).with_context(|| format!("could not parse URL {url:?}"))?;
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.with_context(|| format!("URL {url:?} has no host to authorize network access for"))?;
|
||||
http_proxy::HostPattern::parse(host).map_err(|error| match error {
|
||||
http_proxy::HostPatternError::IpLiteral(_) => anyhow::anyhow!(
|
||||
"cannot fetch {host:?}: loopback and IP-literal hosts can't be granted network \
|
||||
access individually. They are only reachable once unsandboxed access has been \
|
||||
granted (for example, via a terminal command that requests it)."
|
||||
),
|
||||
error => anyhow::anyhow!("cannot authorize network access to {host:?}: {error}"),
|
||||
})
|
||||
}
|
||||
|
||||
impl AgentTool for FetchTool {
|
||||
type Input = FetchToolInput;
|
||||
type Output = String;
|
||||
|
|
@ -149,6 +182,8 @@ impl AgentTool for FetchTool {
|
|||
cx.spawn(async move |cx| {
|
||||
let input: FetchToolInput = input.recv().await.map_err(|e| e.to_string())?;
|
||||
|
||||
// First, the standard tool-permission gate (honors the fetch tool's
|
||||
// allow/deny/confirm rules).
|
||||
let authorize = cx.update(|cx| {
|
||||
let context =
|
||||
crate::ToolPermissionContext::new(Self::NAME, vec![input.url.clone()]);
|
||||
|
|
@ -159,14 +194,45 @@ impl AgentTool for FetchTool {
|
|||
cx,
|
||||
)
|
||||
});
|
||||
futures::select! {
|
||||
result = authorize.fuse() => result.map_err(|e| e.to_string())?,
|
||||
_ = event_stream.cancelled_by_user().fuse() => {
|
||||
return Err("Fetch cancelled by user".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
// Then, unless unsandboxed access is already in effect, the per-host
|
||||
// network grant shared with the terminal tool. If the host isn't
|
||||
// already granted (for this thread or in saved settings) the user is
|
||||
// shown the same escalation prompt the terminal uses; a denial
|
||||
// aborts the fetch. This tool never runs inside the OS sandbox, so
|
||||
// the grant is only consulted to decide whether the request may
|
||||
// proceed. When unsandboxed access has been granted the terminal
|
||||
// already runs without isolation, so we drop fetch's restrictions
|
||||
// too — including reaching hosts that can't be granted individually
|
||||
// (loopback and IP literals).
|
||||
let unsandboxed = cx.update(|cx| event_stream.unsandboxed_access_granted(cx));
|
||||
if !unsandboxed {
|
||||
let host = host_pattern_for_url(&input.url).map_err(|e| e.to_string())?;
|
||||
let authorize_host = cx.update(|cx| {
|
||||
let request = SandboxRequest {
|
||||
network: NetworkRequest::Hosts(vec![host]),
|
||||
..Default::default()
|
||||
};
|
||||
event_stream.authorize_sandbox(request, String::new(), cx)
|
||||
});
|
||||
futures::select! {
|
||||
result = authorize_host.fuse() => result.map_err(|e| e.to_string())?,
|
||||
_ = event_stream.cancelled_by_user().fuse() => {
|
||||
return Err("Fetch cancelled by user".to_string());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let fetch_task = cx.background_spawn({
|
||||
let http_client = http_client.clone();
|
||||
let url = input.url.clone();
|
||||
async move {
|
||||
authorize.await?;
|
||||
Self::build_message(http_client, &url).await
|
||||
}
|
||||
async move { Self::build_message(http_client, &url).await }
|
||||
});
|
||||
|
||||
let text = futures::select! {
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ use std::{
|
|||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
use crate::SandboxFallbackDecision;
|
||||
use crate::sandboxing::{NetworkRequest, sandboxing_enabled_for_project};
|
||||
use crate::sandboxing::{
|
||||
NetworkRequest, sandbox_git_dirs, sandbox_worktree_writable_paths,
|
||||
sandboxing_enabled_for_project,
|
||||
};
|
||||
use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream, ToolInput};
|
||||
use sandbox_git_paths::{SandboxGitPathCandidates, sandbox_git_paths};
|
||||
|
||||
pub(crate) mod sandbox_git_paths;
|
||||
|
||||
const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024;
|
||||
|
||||
|
|
@ -44,13 +44,14 @@ const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024;
|
|||
/// The terminal is an interactive pty, so any command that blocks waiting for input will hang the tool until it times out. To avoid this:
|
||||
///
|
||||
/// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`).
|
||||
/// - Prefer Git flags that avoid optional metadata writes when possible, such as `git --no-optional-locks status` instead of `git status`.
|
||||
/// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`).
|
||||
/// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TerminalToolInput {
|
||||
/// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user for the literal value to use.
|
||||
///
|
||||
/// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang.
|
||||
/// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Prefer `git --no-optional-locks status` over `git status` to avoid optional metadata writes. Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang.
|
||||
pub command: String,
|
||||
/// Working directory for the command. This must be one of the root directories of the project.
|
||||
pub cd: String,
|
||||
|
|
@ -85,13 +86,14 @@ pub struct TerminalToolInput {
|
|||
/// The terminal is an interactive pty, so any command that blocks waiting for input will hang the tool until it times out. To avoid this:
|
||||
///
|
||||
/// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`).
|
||||
/// - Prefer Git flags that avoid optional metadata writes when possible, such as `git --no-optional-locks status` instead of `git status`.
|
||||
/// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`).
|
||||
/// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SandboxedTerminalToolInput {
|
||||
/// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user for the literal value to use.
|
||||
///
|
||||
/// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang.
|
||||
/// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Prefer `git --no-optional-locks status` over `git status` to avoid optional metadata writes. Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang.
|
||||
pub command: String,
|
||||
/// Working directory for the command. This must be one of the root directories of the project.
|
||||
pub cd: String,
|
||||
|
|
@ -129,9 +131,10 @@ pub struct SandboxedTerminalToolInput {
|
|||
/// Set to `true` only if the command needs outbound network access to
|
||||
/// hosts you can't enumerate up front.
|
||||
///
|
||||
/// This grants unrestricted outbound network access. Prefer `allow_hosts`
|
||||
/// with specific hostnames whenever possible, so the user knows what's
|
||||
/// being approved. Requesting it triggers a user approval prompt.
|
||||
/// This grants unrestricted outbound network access. On platforms that
|
||||
/// support host-specific grants, 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
|
||||
|
|
@ -146,7 +149,8 @@ pub struct SandboxedTerminalToolInput {
|
|||
/// Provide absolute or worktree-relative paths; each
|
||||
/// directory grants write access to its whole subtree. Prefer this over
|
||||
/// `allow_fs_write_all` whenever you can enumerate the paths. Requesting
|
||||
/// paths triggers a user approval prompt.
|
||||
/// paths triggers a user approval prompt. Git metadata paths cannot be
|
||||
/// requested and will never be made writable while sandboxed.
|
||||
#[cfg_attr(
|
||||
target_os = "linux",
|
||||
doc = "\nOn Linux, every path here must be a directory that already exists. \
|
||||
|
|
@ -160,25 +164,18 @@ pub struct SandboxedTerminalToolInput {
|
|||
/// enumerated up front.
|
||||
///
|
||||
/// This is a broad escape hatch — prefer `fs_write_paths` whenever the
|
||||
/// set of paths is known. Requesting it triggers a user approval prompt.
|
||||
/// set of paths is known. Protected Git metadata remains read-only.
|
||||
/// Requesting it triggers a user approval prompt.
|
||||
#[serde(default, alias = "allow_fs_write")]
|
||||
pub allow_fs_write_all: Option<bool>,
|
||||
/// Set to `true` when the command needs access to protected Git metadata.
|
||||
///
|
||||
/// By default sandboxed commands can't write to the `.git` directories of
|
||||
/// opened worktrees and discovered repositories. On macOS, `.git` file
|
||||
/// contents are also hidden while metadata stays visible; on Linux and
|
||||
/// Windows/WSL, `.git` contents remain readable but are mounted read-only.
|
||||
/// Set this for Git operations that need to write those paths (commit,
|
||||
/// fetch, rebase, …). Requesting it triggers a user approval prompt.
|
||||
#[serde(default)]
|
||||
pub allow_git_access: Option<bool>,
|
||||
|
||||
/// Set to `true` only as a last resort, to run the command fully outside
|
||||
/// the sandbox.
|
||||
///
|
||||
/// First try the narrower options (`allow_hosts`, `fs_write_paths`,
|
||||
/// `allow_fs_write_all`, `allow_git_access`); use this only when the command
|
||||
/// needs behavior the sandbox can't grant on a per-permission basis.
|
||||
/// First try the narrower options (`allow_hosts`, `fs_write_paths`, or
|
||||
/// `allow_fs_write_all`); use this only when the command
|
||||
/// needs behavior the sandbox can't grant on a per-permission basis,
|
||||
/// including commands that must write Git metadata.
|
||||
/// Requesting it triggers a user approval prompt.
|
||||
#[cfg_attr(
|
||||
target_os = "windows",
|
||||
|
|
@ -192,8 +189,8 @@ pub struct SandboxedTerminalToolInput {
|
|||
#[serde(default)]
|
||||
pub unsandboxed: Option<bool>,
|
||||
/// A short justification for why this command needs the sandbox
|
||||
/// permission(s) it requests (`allow_network`, `fs_write_paths`,
|
||||
/// `allow_fs_write_all`, or `unsandboxed`).
|
||||
/// permission(s) it requests (`allow_hosts`, `allow_all_hosts`,
|
||||
/// `fs_write_paths`, `allow_fs_write_all`, or `unsandboxed`).
|
||||
///
|
||||
/// Required whenever you request any of those permissions; omit it for
|
||||
/// ordinary commands that request none. Write it in your own voice — it
|
||||
|
|
@ -209,7 +206,6 @@ struct TerminalSandboxInput {
|
|||
allow_all_hosts: Option<bool>,
|
||||
fs_write_paths: Vec<String>,
|
||||
allow_fs_write_all: Option<bool>,
|
||||
allow_git_access: Option<bool>,
|
||||
unsandboxed: Option<bool>,
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
|
@ -252,7 +248,6 @@ impl From<SandboxedTerminalToolInput> for TerminalToolRequest {
|
|||
allow_all_hosts: input.allow_all_hosts,
|
||||
fs_write_paths: input.fs_write_paths,
|
||||
allow_fs_write_all: input.allow_fs_write_all,
|
||||
allow_git_access: input.allow_git_access,
|
||||
unsandboxed: input.unsandboxed,
|
||||
reason: input.reason,
|
||||
}),
|
||||
|
|
@ -382,14 +377,19 @@ fn terminal_initial_title(input: Result<String, serde_json::Value>) -> SharedStr
|
|||
|
||||
/// Windows only: resolve the `(release channel, version)` of the Linux `zed` to
|
||||
/// provision inside WSL as the sandbox helper. Dev (source) builds have no
|
||||
/// matching release, so they pull the latest nightly; every other channel pins
|
||||
/// its exact running version (stripped of pre-release/build metadata, which the
|
||||
/// release API doesn't key on).
|
||||
/// matching release, so they pull the latest nightly. Nightly builds also track
|
||||
/// `latest`: nightly assets are keyed by their full build metadata
|
||||
/// (`X.Y.Z+nightly.<n>.<sha>`), which `AppVersion` strips, so a bare `X.Y.Z`
|
||||
/// never resolves on the nightly host. Preview and stable pin their exact
|
||||
/// running version (stripped of pre-release/build metadata, which the release
|
||||
/// API doesn't key on).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn wsl_zed_release(cx: &App) -> Option<(String, String)> {
|
||||
use release_channel::{AppVersion, ReleaseChannel};
|
||||
match *release_channel::RELEASE_CHANNEL {
|
||||
ReleaseChannel::Dev => Some(("nightly".to_string(), "latest".to_string())),
|
||||
ReleaseChannel::Dev | ReleaseChannel::Nightly => {
|
||||
Some(("nightly".to_string(), "latest".to_string()))
|
||||
}
|
||||
channel => {
|
||||
let version = AppVersion::global(cx);
|
||||
Some((
|
||||
|
|
@ -442,7 +442,6 @@ async fn run_terminal_tool(
|
|||
let want_fs_write_all = sandboxing && sandbox_input.allow_fs_write_all == Some(true);
|
||||
let want_unsandboxed = sandboxing && sandbox_input.unsandboxed == Some(true);
|
||||
let want_all_hosts = sandboxing && sandbox_input.allow_all_hosts == Some(true);
|
||||
let want_git_access = sandboxing && sandbox_input.allow_git_access == Some(true);
|
||||
|
||||
let persistent = cx.update(|cx| {
|
||||
agent_settings::AgentSettings::get_global(cx)
|
||||
|
|
@ -498,8 +497,8 @@ async fn run_terminal_tool(
|
|||
{
|
||||
return Err(
|
||||
"Unrestricted filesystem writes are enabled for this thread, so every command \
|
||||
can already write anywhere; `fs_write_paths` cannot narrow that. Remove \
|
||||
`fs_write_paths`."
|
||||
can already write anywhere except protected Git metadata; `fs_write_paths` \
|
||||
cannot narrow that. Remove `fs_write_paths`."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
|
@ -569,7 +568,6 @@ async fn run_terminal_tool(
|
|||
|
||||
let request = crate::sandboxing::SandboxRequest {
|
||||
network,
|
||||
allow_git_access: !want_unsandboxed && want_git_access,
|
||||
allow_fs_write_all: !want_unsandboxed && want_fs_write_all,
|
||||
unsandboxed: want_unsandboxed,
|
||||
write_paths,
|
||||
|
|
@ -618,7 +616,7 @@ async fn run_terminal_tool(
|
|||
allow(unused_mut)
|
||||
)]
|
||||
let mut sandbox_not_applied: Option<acp_thread::SandboxNotAppliedReason> = None;
|
||||
let mut git_access_downgrade_note = None;
|
||||
|
||||
let sandbox_wrap = if sandboxing && !want_unsandboxed {
|
||||
if unsandboxed_floor {
|
||||
// Every command in this thread runs unsandboxed because the user
|
||||
|
|
@ -635,32 +633,16 @@ async fn run_terminal_tool(
|
|||
.to_string(),
|
||||
);
|
||||
}
|
||||
let (fs, sandbox_path_candidates) = cx.update(|cx| {
|
||||
let (writable_paths, protected_paths) = cx.update(|cx| {
|
||||
(
|
||||
project.read(cx).fs().clone(),
|
||||
SandboxGitPathCandidates::from_project(project.read(cx), cx),
|
||||
sandbox_worktree_writable_paths(project.read(cx), cx),
|
||||
sandbox_git_dirs(project.read(cx), cx),
|
||||
)
|
||||
});
|
||||
let sandbox_paths = sandbox_git_paths(
|
||||
sandbox_path_candidates,
|
||||
fs.as_ref(),
|
||||
effective.allow_git_access,
|
||||
)
|
||||
.await;
|
||||
if effective.allow_git_access && !sandbox_paths.allow_git_access {
|
||||
log::warn!(
|
||||
"Downgrading requested agent terminal Git metadata access because one or more external Git metadata paths could not be verified"
|
||||
);
|
||||
git_access_downgrade_note = Some(
|
||||
"Note: Git metadata access was requested or already allowed, but Zed could not verify one or more external Git metadata paths for this project. The command ran with Git metadata protected, so Git operations that read or write `.git` may fail with sandbox permission errors."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let wrap = acp_thread::SandboxWrap {
|
||||
writable_paths: sandbox_paths.writable_paths,
|
||||
writable_paths,
|
||||
extra_write_paths: effective.write_paths,
|
||||
git_dirs: sandbox_paths.git_dirs,
|
||||
allow_git_access: sandbox_paths.allow_git_access,
|
||||
protected_paths,
|
||||
network: network_request_to_sandbox_network_access(&effective.network),
|
||||
allow_fs_write: effective.allow_fs_write_all,
|
||||
is_local: is_local_project,
|
||||
|
|
@ -944,13 +926,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);
|
||||
let git_access_downgrade_note = (sandbox_wrap.is_some() && sandbox_not_applied.is_none())
|
||||
.then_some(git_access_downgrade_note)
|
||||
.flatten();
|
||||
let notes = sandbox_note
|
||||
.into_iter()
|
||||
.chain(git_access_downgrade_note)
|
||||
.collect::<Vec<_>>();
|
||||
let notes = sandbox_note.into_iter().collect::<Vec<_>>();
|
||||
Ok(if notes.is_empty() {
|
||||
result
|
||||
} else {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -422,8 +422,6 @@ pub struct SandboxPermissions {
|
|||
/// hostnames or leading-`*.` subdomain wildcards). Parsed/validated where
|
||||
/// consumed (`agent::sandboxing`).
|
||||
pub network_hosts: Vec<String>,
|
||||
/// Allow sandboxed commands to access protected Git metadata paths.
|
||||
pub allow_git_access: bool,
|
||||
pub allow_fs_write_all: bool,
|
||||
/// Persistently run agent terminal commands outside the OS sandbox. This is
|
||||
/// the model-facing "off switch": when set, the sandboxed terminal tool is
|
||||
|
|
@ -820,7 +818,6 @@ fn compile_sandbox_permissions(
|
|||
SandboxPermissions {
|
||||
allow_all_hosts: content.allow_all_hosts.unwrap_or(false),
|
||||
network_hosts,
|
||||
allow_git_access: content.allow_git_access.unwrap_or(false),
|
||||
allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false),
|
||||
allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false),
|
||||
write_paths,
|
||||
|
|
@ -1108,7 +1105,6 @@ mod tests {
|
|||
let json = json!({
|
||||
"allow_all_hosts": true,
|
||||
"network_hosts": ["github.com", "*.npmjs.org"],
|
||||
"allow_git_access": true,
|
||||
"allow_unsandboxed": true,
|
||||
"write_paths": [
|
||||
"/tmp/build/cache",
|
||||
|
|
@ -1125,7 +1121,6 @@ mod tests {
|
|||
permissions.network_hosts,
|
||||
vec!["github.com".to_string(), "*.npmjs.org".to_string()]
|
||||
);
|
||||
assert!(permissions.allow_git_access);
|
||||
assert!(!permissions.allow_fs_write_all);
|
||||
assert!(permissions.allow_unsandboxed);
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use agent_skills::MAX_SKILL_DESCRIPTION_LEN;
|
|||
use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody};
|
||||
use editor::actions::OpenExcerpts;
|
||||
use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
|
||||
use sandbox::{GitSandboxPolicy, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy};
|
||||
use sandbox::{SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy};
|
||||
|
||||
use crate::completion_provider::AvailableSkill;
|
||||
use crate::message_editor::SharedSessionCapabilities;
|
||||
|
|
@ -5788,7 +5788,6 @@ impl Render for TokenUsageTooltip {
|
|||
struct SandboxPolicyDisplay {
|
||||
fs: SandboxFsDisplay,
|
||||
network: SandboxNetPolicy,
|
||||
git: SandboxGitDisplay,
|
||||
}
|
||||
|
||||
/// The filesystem write-access portion of a [`SandboxPolicyDisplay`].
|
||||
|
|
@ -5810,22 +5809,14 @@ enum WritableEntryDisplay {
|
|||
IsolatedTmp,
|
||||
}
|
||||
|
||||
/// The Git-access portion of a [`SandboxPolicyDisplay`]: whether `.git` writes
|
||||
/// are granted, and the (display-only) `.git` directories the policy governs.
|
||||
#[derive(Clone)]
|
||||
struct SandboxGitDisplay {
|
||||
allowed: bool,
|
||||
git_dirs: Vec<String>,
|
||||
}
|
||||
|
||||
impl SandboxPolicyDisplay {
|
||||
/// Display a policy verbatim (used for the per-thread overrides, which carry
|
||||
/// no implicit baseline grants). Takes the policy by reference and stringifies
|
||||
/// its locations immediately, so no fd is retained past this call.
|
||||
fn from_policy(policy: &SandboxPolicy) -> Self {
|
||||
let fs = match &policy.fs {
|
||||
SandboxFsPolicy::Unrestricted => SandboxFsDisplay::Unrestricted,
|
||||
SandboxFsPolicy::Restricted { writable_paths } => SandboxFsDisplay::Restricted(
|
||||
SandboxFsPolicy::Unrestricted { .. } => SandboxFsDisplay::Unrestricted,
|
||||
SandboxFsPolicy::Restricted { writable_paths, .. } => SandboxFsDisplay::Restricted(
|
||||
writable_paths
|
||||
.iter()
|
||||
.map(|location| {
|
||||
|
|
@ -5837,21 +5828,6 @@ impl SandboxPolicyDisplay {
|
|||
SandboxPolicyDisplay {
|
||||
fs,
|
||||
network: policy.network.clone(),
|
||||
git: SandboxGitDisplay::from_policy(&policy.git),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SandboxGitDisplay {
|
||||
/// Stringify a Git policy's `.git` directories for display, retaining no fds.
|
||||
fn from_policy(git: &GitSandboxPolicy) -> Self {
|
||||
SandboxGitDisplay {
|
||||
allowed: git.allows_writes(),
|
||||
git_dirs: git
|
||||
.git_dirs()
|
||||
.iter()
|
||||
.map(|location| location.untrusted_path_display().to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5868,8 +5844,8 @@ fn augment_settings_sandbox_policy(
|
|||
baseline: Vec<PathBuf>,
|
||||
) -> SandboxPolicyDisplay {
|
||||
let fs = match &policy.fs {
|
||||
SandboxFsPolicy::Unrestricted => SandboxFsDisplay::Unrestricted,
|
||||
SandboxFsPolicy::Restricted { writable_paths } => {
|
||||
SandboxFsPolicy::Unrestricted { .. } => SandboxFsDisplay::Unrestricted,
|
||||
SandboxFsPolicy::Restricted { writable_paths, .. } => {
|
||||
// Dedup by display string. We deliberately don't open the locations'
|
||||
// fds to dedup by inode here: this is a display-only tooltip and the
|
||||
// string is the location's identity for that purpose. The string can
|
||||
|
|
@ -5904,15 +5880,12 @@ fn augment_settings_sandbox_policy(
|
|||
SandboxPolicyDisplay {
|
||||
fs,
|
||||
network: policy.network.clone(),
|
||||
git: SandboxGitDisplay::from_policy(&policy.git),
|
||||
}
|
||||
}
|
||||
|
||||
fn sandbox_section(title: &str, policy: &SandboxPolicyDisplay, show_empty: bool) -> SandboxSection {
|
||||
let write_empty = fs_grants_nothing(&policy.fs);
|
||||
let network_empty = network_grants_nothing(&policy.network);
|
||||
let git_empty = git_grants_nothing(&policy.git);
|
||||
|
||||
let mut section = SandboxSection::new(title.to_string());
|
||||
|
||||
if show_empty || !write_empty {
|
||||
|
|
@ -5925,40 +5898,13 @@ fn sandbox_section(title: &str, policy: &SandboxPolicyDisplay, show_empty: bool)
|
|||
.group(SandboxGroup::new("Network Access").rows(sandbox_network_rows(&policy.network)));
|
||||
}
|
||||
|
||||
if !git_empty {
|
||||
section = section
|
||||
.group(SandboxGroup::new("Git Metadata Access").rows(sandbox_git_rows(&policy.git)));
|
||||
}
|
||||
|
||||
section
|
||||
}
|
||||
|
||||
/// Whether a policy grants nothing worth surfacing, used to decide whether to
|
||||
/// show the per-thread overrides section at all.
|
||||
fn sandbox_policy_grants_nothing(policy: &SandboxPolicyDisplay) -> bool {
|
||||
fs_grants_nothing(&policy.fs)
|
||||
&& network_grants_nothing(&policy.network)
|
||||
&& git_grants_nothing(&policy.git)
|
||||
}
|
||||
|
||||
/// Git access grants nothing to surface unless `.git` writes are allowed *and*
|
||||
/// at least one `.git` directory is known.
|
||||
fn git_grants_nothing(git: &SandboxGitDisplay) -> bool {
|
||||
!git.allowed || git.git_dirs.is_empty()
|
||||
}
|
||||
|
||||
/// Rows for the Git-access group: one row per writable `.git` directory (these
|
||||
/// may live outside the project for a linked worktree).
|
||||
fn sandbox_git_rows(git: &SandboxGitDisplay) -> Vec<SandboxRow> {
|
||||
if !git.allowed {
|
||||
return Vec::new();
|
||||
}
|
||||
git.git_dirs
|
||||
.iter()
|
||||
// The display string was captured up front; reconstruct a throwaway path
|
||||
// here purely to render a label + icon (never used as an identity).
|
||||
.map(|dir| SandboxRow::git(PathBuf::from(dir)))
|
||||
.collect()
|
||||
fs_grants_nothing(&policy.fs) && network_grants_nothing(&policy.network)
|
||||
}
|
||||
|
||||
fn fs_grants_nothing(fs: &SandboxFsDisplay) -> bool {
|
||||
|
|
@ -5977,14 +5923,16 @@ fn network_grants_nothing(network: &SandboxNetPolicy) -> bool {
|
|||
/// row per granted path.
|
||||
fn sandbox_fs_rows(fs: &SandboxFsDisplay) -> Vec<SandboxRow> {
|
||||
match fs {
|
||||
SandboxFsDisplay::Unrestricted => vec![SandboxRow::message("All paths (unrestricted)")],
|
||||
SandboxFsDisplay::Unrestricted => vec![SandboxRow::message(
|
||||
"All paths except protected Git metadata",
|
||||
)],
|
||||
SandboxFsDisplay::Restricted(entries) if entries.is_empty() => {
|
||||
vec![SandboxRow::message("None")]
|
||||
}
|
||||
SandboxFsDisplay::Restricted(entries) => entries
|
||||
.iter()
|
||||
.map(|entry| match entry {
|
||||
// The display string was captured up front; see `sandbox_git_rows`.
|
||||
// The display string was captured up front.
|
||||
WritableEntryDisplay::Path(path) => SandboxRow::path(PathBuf::from(path)),
|
||||
#[cfg(target_os = "linux")]
|
||||
WritableEntryDisplay::IsolatedTmp => {
|
||||
|
|
@ -8592,12 +8540,7 @@ impl ThreadView {
|
|||
) -> AnyElement {
|
||||
let has_network = details.network_all_hosts || !details.network_hosts.is_empty();
|
||||
let has_write = details.allow_fs_write_all || !details.write_paths.is_empty();
|
||||
if !has_network
|
||||
&& !has_write
|
||||
&& !details.allow_git_access
|
||||
&& !details.unsandboxed
|
||||
&& details.reason.is_empty()
|
||||
{
|
||||
if !has_network && !has_write && !details.unsandboxed && details.reason.is_empty() {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
|
|
@ -8701,7 +8644,7 @@ impl ThreadView {
|
|||
|
||||
let write_section = has_write.then(|| {
|
||||
let summary = if details.allow_fs_write_all {
|
||||
"unrestricted".to_string()
|
||||
"unrestricted except Git metadata".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{} {}",
|
||||
|
|
@ -8809,23 +8752,6 @@ impl ThreadView {
|
|||
)
|
||||
});
|
||||
|
||||
let git_access_section = details.allow_git_access.then(|| {
|
||||
v_flex().px_2().py_1().gap_0p5().child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Icon::new(IconName::GitBranch)
|
||||
.color(Color::Muted)
|
||||
.size(IconSize::Small),
|
||||
)
|
||||
.child(
|
||||
Label::new("Git metadata access")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
let reason_section = (!details.reason.is_empty()).then(|| {
|
||||
v_flex()
|
||||
.px_2()
|
||||
|
|
@ -8845,7 +8771,6 @@ impl ThreadView {
|
|||
v_flex()
|
||||
.border_t_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
.children(git_access_section)
|
||||
.children(network_section)
|
||||
.children(write_section)
|
||||
.children(unsandboxed_section)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ use ui::{Divider, prelude::*};
|
|||
pub enum SandboxRow {
|
||||
Message(SharedString),
|
||||
Path(PathBuf),
|
||||
Git(PathBuf),
|
||||
Domain(SharedString),
|
||||
}
|
||||
|
||||
|
|
@ -21,10 +20,6 @@ impl SandboxRow {
|
|||
Self::Path(path.into())
|
||||
}
|
||||
|
||||
pub fn git(path: impl Into<PathBuf>) -> Self {
|
||||
Self::Git(path.into())
|
||||
}
|
||||
|
||||
pub fn domain(domain: impl Into<SharedString>) -> Self {
|
||||
Self::Domain(domain.into())
|
||||
}
|
||||
|
|
@ -53,7 +48,6 @@ impl SandboxRow {
|
|||
.unwrap_or_else(|| icon_basic(IconName::Folder));
|
||||
(icon, path.display().to_string())
|
||||
}
|
||||
SandboxRow::Git(path) => (icon_basic(IconName::GitBranch), path.display().to_string()),
|
||||
SandboxRow::Domain(domain) => (icon_basic(IconName::Public), domain.to_string()),
|
||||
};
|
||||
|
||||
|
|
@ -234,17 +228,12 @@ impl Component for SandboxStatusTooltip {
|
|||
.group(
|
||||
SandboxGroup::new("Write Access").row(SandboxRow::path("/Users/you/project/build")),
|
||||
)
|
||||
.group(SandboxGroup::new("Network Access").row(SandboxRow::message("None")))
|
||||
.group(
|
||||
SandboxGroup::new("Git Metadata Access")
|
||||
.row(SandboxRow::git("/Users/you/project/.git")),
|
||||
);
|
||||
.group(SandboxGroup::new("Network Access").row(SandboxRow::message("None")));
|
||||
|
||||
let unrestricted_section = SandboxSection::new("Defined in your settings:")
|
||||
.group(
|
||||
SandboxGroup::new("Write Access")
|
||||
.row(SandboxRow::message("All paths (unrestricted)")),
|
||||
)
|
||||
.group(SandboxGroup::new("Write Access").row(SandboxRow::message(
|
||||
"All paths except protected Git metadata",
|
||||
)))
|
||||
.group(
|
||||
SandboxGroup::new("Network Access")
|
||||
.row(SandboxRow::message("All domains (unrestricted)")),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ This crate allows creating a `Sandbox` according to some `SandboxPolicy`. A
|
|||
`SandboxPolicy` expresses:
|
||||
- what filesystem operations are allowed
|
||||
- which kinds of networking operations are allowed
|
||||
- whether git metadata is protected
|
||||
- which paths stay protected even under writable subtrees
|
||||
|
||||
Once you have a `Sandbox`, you can use it to run commands that are constrained
|
||||
by that policy.
|
||||
|
|
@ -28,9 +28,9 @@ against attacks. An attacker with read/write access to the current directory can
|
|||
|
||||
This can be mitigated by:
|
||||
- disabling any language servers with the capability to run untrusted code
|
||||
- not granting git access (since write access to `.git` can similarly be
|
||||
escalated to unsandboxed code execution via `$EDITOR` and various other
|
||||
methods)
|
||||
- keeping sensitive project metadata such as `.git` protected, since write
|
||||
access to those paths can be escalated to unsandboxed code execution via hooks,
|
||||
`$EDITOR`, and other mechanisms
|
||||
|
||||
## Implementation
|
||||
|
||||
|
|
@ -44,6 +44,13 @@ The implementations are highly platform-specific:
|
|||
Note that WSL shells can be used on all Windows projects, regardless of whether
|
||||
the files are stored in the Linux filesystem or not.
|
||||
|
||||
Though not defined in this crate, the default grants provided by the Zed agent is:
|
||||
- read-only access to all files
|
||||
- read/write access to current project directories
|
||||
- read-only access to any Git metadata, including those in project directories
|
||||
- read/write access to an isolated tempdir, cleared between terminals
|
||||
- on MacOS, this is set via the `$TMPDIR` variable and is not at `/tmp`
|
||||
|
||||
## Architecture
|
||||
|
||||
Filesystem restrictions are different on all platforms. Network restrictions
|
||||
|
|
@ -73,26 +80,26 @@ However, this fails because of a nasty TOCTOU.
|
|||
Consider the following case:
|
||||
- an attacker has convinced the user to open `project`, which contains an evil
|
||||
`AGENTS.md`
|
||||
- They have also convinced the user to allow git access
|
||||
- They have also convinced the user to grant a writable path inside `project`
|
||||
- This means that the user will have given the following permissions to the sandbox:
|
||||
- read/write access to `project`
|
||||
- read/write access to `project/.git`
|
||||
- read/write access to `project/cache`
|
||||
- read/write access to an isolated `/tmp`
|
||||
- read-only access to `/`
|
||||
- The `AGENTS.md` instructs the LLM to do the following:
|
||||
- spawn two subagents
|
||||
- the first subagent tries to swap `project/.git` with a symlink to
|
||||
`/home/alice` [`renameat2(2)`][renameat2] with the `RENAME_EXCHANGE`
|
||||
- the first subagent tries to swap `project/cache` with a symlink to
|
||||
`/home/alice` using [`renameat2(2)`][renameat2] with the `RENAME_EXCHANGE`
|
||||
flag set
|
||||
- the second subagent tries to run `echo 'export PATH="proj/obfuscated.../evil_eavesdropping_sudo/bin:$PATH"' >> proj/.git/.bashrc`
|
||||
- the second subagent tries to run `echo 'export PATH="proj/obfuscated.../evil_eavesdropping_sudo/bin:$PATH"' >> proj/cache/.bashrc`
|
||||
- The user sends a prompt, we pick up the evil `AGENTS.md` instructions, and the
|
||||
agent does them
|
||||
- Zed checks whether paths are symlinks outside the allowable paths before
|
||||
passing them to bubblewrap, but there is a **time delay** between this check
|
||||
and when bubblewrap mounts them.
|
||||
- In this delay, the `renameat2` may succeed, which means that:
|
||||
- At check time, `proj/.git` is a subdirectory of `proj`
|
||||
- At bind time, `proj/.git` is a symlink to `/home/alice`
|
||||
- At check time, `proj/cache` is a subdirectory of `proj`
|
||||
- At bind time, `proj/cache` is a symlink to `/home/alice`
|
||||
- The attacker is now running code in a sandbox which has **read/write** access
|
||||
to `/home/alice`, and so the second command to inject the malicious
|
||||
credential-stealing sudo succeeds.
|
||||
|
|
@ -106,18 +113,18 @@ sequenceDiagram
|
|||
participant Zed as Zed path validation
|
||||
participant BW as bubblewrap
|
||||
|
||||
Note over Agent: Evil AGENTS.md picked up, git access granted
|
||||
Note over Agent: Grants rw project, rw project/.git, rw /tmp, ro /
|
||||
Agent->>S1: spawn swap project/.git for a symlink to /home/alice
|
||||
Agent->>S2: spawn append PATH hijack to project/.git/.bashrc
|
||||
Zed->>Zed: check project/.git is not an out-of-bounds symlink
|
||||
Note over Agent: Evil AGENTS.md picked up, writable path granted
|
||||
Note over Agent: Grants rw project, rw project/cache, rw /tmp, ro /
|
||||
Agent->>S1: spawn swap project/cache for a symlink to /home/alice
|
||||
Agent->>S2: spawn append PATH hijack to project/cache/.bashrc
|
||||
Zed->>Zed: check project/cache is not an out-of-bounds symlink
|
||||
Note over Zed: at check time it is a real subdirectory, so OK
|
||||
Note over Zed,BW: time delay, time-of-check to time-of-use
|
||||
S1->>S1: renameat2 RENAME_EXCHANGE wins the race
|
||||
Note over S1: project/.git is now a symlink to /home/alice
|
||||
Zed->>BW: bind project/.git into the sandbox
|
||||
BW->>BW: re-resolve project/.git, following symlink to /home/alice
|
||||
S2->>BW: write project/.git/.bashrc
|
||||
Note over S1: project/cache is now a symlink to /home/alice
|
||||
Zed->>BW: bind project/cache into the sandbox
|
||||
BW->>BW: re-resolve project/cache, following symlink to /home/alice
|
||||
S2->>BW: write project/cache/.bashrc
|
||||
BW-->>S2: write lands in /home/alice/.bashrc
|
||||
Note over S2: escalation, project-scoped grant becomes arbitrary write
|
||||
```
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ mod imp {
|
|||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use sandbox::{
|
||||
CommandAndArgs, GitSandboxPolicy, HostFilesystemLocation, Sandbox, SandboxError,
|
||||
SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
|
||||
CommandAndArgs, HostFilesystemLocation, Sandbox, SandboxError, SandboxFsPolicy,
|
||||
SandboxNetPolicy, SandboxPolicy,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
|
|
@ -112,14 +112,9 @@ mod imp {
|
|||
network_access: NetMode,
|
||||
#[serde(default)]
|
||||
allowed_domains: Vec<String>,
|
||||
/// `.git` directories to protect (contents read-only on Linux). Selects a
|
||||
/// `Denied` Git policy. Mutually exclusive with `gitAllowed`.
|
||||
/// Paths to protect from writes even if they fall under a writable path.
|
||||
#[serde(default)]
|
||||
git_disabled: Vec<String>,
|
||||
/// `.git` directories to make writable. Selects an `Allowed` Git policy.
|
||||
/// Mutually exclusive with `gitDisabled`.
|
||||
#[serde(default)]
|
||||
git_allowed: Vec<String>,
|
||||
protected_paths: Vec<String>,
|
||||
|
||||
// ---- operation (exactly one) ----
|
||||
/// Read this host path from inside the sandbox.
|
||||
|
|
@ -180,7 +175,9 @@ mod imp {
|
|||
|
||||
fn policy_of(check: &Check) -> Result<SandboxPolicy> {
|
||||
let fs = match check.fs {
|
||||
FsMode::Unrestricted => SandboxFsPolicy::Unrestricted,
|
||||
FsMode::Unrestricted => SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: capture_protected_paths(&check.protected_paths),
|
||||
},
|
||||
FsMode::Restricted => {
|
||||
let mut writable_paths = Vec::new();
|
||||
for path in &check.writable_paths {
|
||||
|
|
@ -194,7 +191,11 @@ mod imp {
|
|||
.with_context(|| format!("failed to capture writable path {path}"))?,
|
||||
);
|
||||
}
|
||||
SandboxFsPolicy::Restricted { writable_paths }
|
||||
let protected_paths = capture_protected_paths(&check.protected_paths);
|
||||
SandboxFsPolicy::Restricted {
|
||||
writable_paths,
|
||||
protected_paths,
|
||||
}
|
||||
}
|
||||
};
|
||||
let network = match check.network_access {
|
||||
|
|
@ -204,29 +205,15 @@ mod imp {
|
|||
allowed_domains: check.allowed_domains.clone(),
|
||||
},
|
||||
};
|
||||
// `gitAllowed` and `gitDisabled` are mutually exclusive; `gitAllowed`
|
||||
// wins if both are (mistakenly) set. With neither, the default protects
|
||||
// an empty set of dirs, which is a no-op.
|
||||
let git = if !check.git_allowed.is_empty() {
|
||||
GitSandboxPolicy::Allowed {
|
||||
git_dirs: capture_git_dirs(&check.git_allowed),
|
||||
}
|
||||
} else if !check.git_disabled.is_empty() {
|
||||
GitSandboxPolicy::Denied {
|
||||
git_dirs: capture_git_dirs(&check.git_disabled),
|
||||
}
|
||||
} else {
|
||||
GitSandboxPolicy::default()
|
||||
};
|
||||
Ok(SandboxPolicy { fs, network, git })
|
||||
Ok(SandboxPolicy { fs, network })
|
||||
}
|
||||
|
||||
/// Capture each already-existing `.git` directory, mirroring production's
|
||||
/// fail-closed `filter_map(HostFilesystemLocation::new(..).ok())`: a `.git`
|
||||
/// that does not yet exist can't be pinned and is simply skipped (the
|
||||
/// documented Linux gap). Unlike writable paths, these are never created
|
||||
/// here — whether one exists is exactly what several checks turn on.
|
||||
fn capture_git_dirs(paths: &[String]) -> Vec<HostFilesystemLocation> {
|
||||
/// Capture each already-existing protected path, mirroring production's
|
||||
/// fail-closed `filter_map(HostFilesystemLocation::new(..).ok())`: a path
|
||||
/// that does not yet exist can't be pinned and is simply skipped. Unlike
|
||||
/// writable paths, these are never created here — whether one exists is
|
||||
/// exactly what several checks turn on.
|
||||
fn capture_protected_paths(paths: &[String]) -> Vec<HostFilesystemLocation> {
|
||||
paths
|
||||
.iter()
|
||||
.filter_map(|path| HostFilesystemLocation::new(path).ok())
|
||||
|
|
@ -237,14 +224,15 @@ mod imp {
|
|||
if let Some(name) = &check.name {
|
||||
return name.clone();
|
||||
}
|
||||
let git = if !check.git_allowed.is_empty() {
|
||||
format!(",git_allowed={:?}", check.git_allowed)
|
||||
} else if !check.git_disabled.is_empty() {
|
||||
format!(",git_disabled={:?}", check.git_disabled)
|
||||
} else {
|
||||
let protected = if check.protected_paths.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(",protected_paths={:?}", check.protected_paths)
|
||||
};
|
||||
let policy = format!("fs={:?},net={:?}{git}", check.fs, check.network_access);
|
||||
let policy = format!(
|
||||
"fs={:?},net={:?}{protected}",
|
||||
check.fs, check.network_access
|
||||
);
|
||||
let op = if let Some(path) = &check.read {
|
||||
format!("read {path}")
|
||||
} else if let Some(path) = &check.write {
|
||||
|
|
@ -329,7 +317,7 @@ mod imp {
|
|||
if let Some(parent) = path.parent() {
|
||||
// Create the parent on the host so the only thing under test is the
|
||||
// sandbox's write permission, not a missing directory. This also
|
||||
// makes a `.git` parent exist before the policy captures it.
|
||||
// makes a protected parent exist before the policy captures it.
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create parent of {}", path.display()))?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ fn probe_bwrap(bwrap: &Path, bwrap_args: &[String]) -> bool {
|
|||
/// the sandbox where the bridge connects to it.
|
||||
pub fn build_bwrap_args(
|
||||
writable_directories: &[&Path],
|
||||
protected_git_dirs: &[&Path],
|
||||
protected_paths: &[&Path],
|
||||
permissions: SandboxPermissions,
|
||||
cwd: Option<&Path>,
|
||||
proxy_socket_path: Option<&Path>,
|
||||
|
|
@ -231,7 +231,7 @@ pub fn build_bwrap_args(
|
|||
.map(|_| unique_proxy_socket_sandbox_path());
|
||||
build_bwrap_args_with_sandbox_paths(
|
||||
writable_directories,
|
||||
protected_git_dirs,
|
||||
protected_paths,
|
||||
permissions,
|
||||
cwd,
|
||||
proxy_socket_path,
|
||||
|
|
@ -247,7 +247,7 @@ pub fn build_bwrap_args(
|
|||
)]
|
||||
fn build_bwrap_args_with_sandbox_paths(
|
||||
writable_directories: &[&Path],
|
||||
protected_git_dirs: &[&Path],
|
||||
protected_paths: &[&Path],
|
||||
permissions: SandboxPermissions,
|
||||
cwd: Option<&Path>,
|
||||
proxy_socket_path: Option<&Path>,
|
||||
|
|
@ -290,24 +290,19 @@ fn build_bwrap_args_with_sandbox_paths(
|
|||
let path = directory.to_string_lossy().into_owned();
|
||||
push_bind(&mut args, "--bind", &path, &path);
|
||||
}
|
||||
}
|
||||
|
||||
// Protect Git directories by re-binding them read-only *over* the rw
|
||||
// worktree binds above (order matters: later binds win). Unlike
|
||||
// Seatbelt, bwrap can't deny content reads while keeping metadata, so on
|
||||
// Linux a protected `.git` is read-only — its contents stay readable but
|
||||
// can't be written. A read-only re-bind needs no TOCTOU check: the whole
|
||||
// root is already read-only, so re-exposing a path read-only grants
|
||||
// nothing new even if its source was swapped. A not-yet-existing `.git`
|
||||
// can't be bound, so it's skipped (a documented gap vs. macOS). When Git
|
||||
// access is granted these dirs are in `writable_directories` instead and
|
||||
// this list is empty.
|
||||
for git_dir in protected_git_dirs {
|
||||
if !git_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
let path = git_dir.to_string_lossy().into_owned();
|
||||
push_bind(&mut args, "--ro-bind", &path, &path);
|
||||
// Protect requested paths by re-binding them read-only *over* any broader rw
|
||||
// bind (order matters: later binds win). Unlike Seatbelt, bwrap can't deny
|
||||
// writes while allowing content reads with a policy rule, so a protected path
|
||||
// is read-only — its contents stay readable but can't be written. A
|
||||
// not-yet-existing path can't be bound, so it's skipped.
|
||||
for protected_path in protected_paths {
|
||||
if !protected_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let path = protected_path.to_string_lossy().into_owned();
|
||||
push_bind(&mut args, "--ro-bind", &path, &path);
|
||||
}
|
||||
|
||||
for flag in [
|
||||
|
|
@ -595,7 +590,7 @@ pub fn wrap_invocation(
|
|||
bridge_program: &str,
|
||||
permissions: SandboxPermissions,
|
||||
writable_dirs: &[&Path],
|
||||
protected_git_dirs: &[&Path],
|
||||
protected_paths: &[&Path],
|
||||
cwd: Option<&Path>,
|
||||
program: &str,
|
||||
args: &[String],
|
||||
|
|
@ -637,7 +632,7 @@ pub fn wrap_invocation(
|
|||
};
|
||||
let mut bwrap_args = build_bwrap_args_with_sandbox_paths(
|
||||
writable_dirs,
|
||||
protected_git_dirs,
|
||||
protected_paths,
|
||||
permissions,
|
||||
cwd,
|
||||
proxy_socket_path,
|
||||
|
|
@ -1394,9 +1389,11 @@ mod tests {
|
|||
network: NetworkAccess::None,
|
||||
allow_fs_write: true,
|
||||
};
|
||||
let args = build_bwrap_args(&[], &[], permissions, None, None);
|
||||
let protected = Path::new("/tmp");
|
||||
let args = build_bwrap_args(&[], &[protected], permissions, None, None);
|
||||
assert!(windows_contains(&args, &["--bind", "/", "/"]));
|
||||
assert!(!windows_contains(&args, &["--ro-bind", "/", "/"]));
|
||||
assert!(windows_contains(&args, &["--ro-bind", "/tmp", "/tmp"]));
|
||||
assert!(!windows_contains(&args, &["--tmpfs", "/tmp"]));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,9 +88,8 @@ impl SeatbeltConfigFile {
|
|||
/// directory of the command, since that is model-controlled and would
|
||||
/// let the model widen its own writable scope.
|
||||
///
|
||||
/// `protected_paths` lists paths whose file data reads and writes should
|
||||
/// be blocked even if they fall under a readable or writable directory.
|
||||
/// File metadata remains readable.
|
||||
/// `protected_paths` lists paths whose writes should be blocked even if they
|
||||
/// fall under a writable directory. Contents remain readable.
|
||||
///
|
||||
/// `allowed_unix_socket_paths` lists Unix domain sockets the command may
|
||||
/// connect to for local IPC even when IP network access is otherwise
|
||||
|
|
@ -138,9 +137,8 @@ impl SeatbeltConfigFile {
|
|||
/// the project's worktree paths here, not the working directory of the
|
||||
/// command (the working directory is model-controlled, and using it as
|
||||
/// the writable scope would let the model write outside the project).
|
||||
/// * `protected_paths` - Paths whose file data reads and writes should be
|
||||
/// denied even if they fall under a readable or writable directory. File
|
||||
/// metadata remains readable.
|
||||
/// * `protected_paths` - Paths whose writes should be denied even if they fall
|
||||
/// under a writable directory. Contents remain readable.
|
||||
/// * `allowed_unix_socket_paths` - Unix domain sockets the command may
|
||||
/// connect to for local IPC even when IP network access is otherwise
|
||||
/// disabled. This does not permit sending packets to other machines.
|
||||
|
|
@ -191,9 +189,8 @@ pub fn wrap_invocation(
|
|||
/// Writes to each entry in `writable_directories` (typically the project's
|
||||
/// worktree paths plus any per-command scratch directory the caller wants
|
||||
/// allowed) and the standard `/dev/*` write targets are also allowed by
|
||||
/// default. File data reads and writes to paths in `protected_paths` are
|
||||
/// denied even when they would otherwise be readable or writable; file
|
||||
/// metadata remains readable. Unix domain socket paths in
|
||||
/// default. Writes to paths in `protected_paths` are denied even when they
|
||||
/// would otherwise be writable; contents remain readable. Unix domain socket paths in
|
||||
/// `allowed_unix_socket_paths` are reachable for local IPC even when IP
|
||||
/// network access is otherwise blocked. This does not permit sending packets
|
||||
/// to other machines.
|
||||
|
|
@ -211,7 +208,7 @@ fn generate_seatbelt_config(
|
|||
) -> Result<String> {
|
||||
// These paths are already the canonical identities captured once, at
|
||||
// validation time, inside each `HostFilesystemLocation` (resolving symlinks
|
||||
// and, for a not-yet-created `.git`, its existing parent). We deliberately do
|
||||
// and, for a not-yet-created leaf, its existing parent). We deliberately do
|
||||
// NOT re-canonicalize here: re-resolving a path at profile-generation time
|
||||
// is the time-of-check-to-time-of-use hole this design closes. Use them
|
||||
// verbatim as Seatbelt rule literals.
|
||||
|
|
@ -311,12 +308,11 @@ fn generate_seatbelt_config(
|
|||
for protected_path in &canonical_protected_paths {
|
||||
let escaped_path = escape_sandbox_path(protected_path)?;
|
||||
// `subpath` already matches the path itself plus everything beneath it,
|
||||
// so it covers both a `.git` directory and a linked worktree's `.git`
|
||||
// gitlink file without a redundant `literal` rule.
|
||||
// so no redundant `literal` rule is needed.
|
||||
config.push_str(&format!(
|
||||
r#"
|
||||
; Block Git metadata content access unless Git access is approved
|
||||
(deny file-read-data file-write*
|
||||
; Block protected path writes
|
||||
(deny file-write*
|
||||
(subpath "{escaped_path}"))
|
||||
"#
|
||||
));
|
||||
|
|
@ -540,7 +536,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_seatbelt_config_denies_protected_path_data_and_writes() {
|
||||
fn test_generate_seatbelt_config_denies_protected_path_writes() {
|
||||
let dir = PathBuf::from("/Users/test/projects/myproject");
|
||||
let protected = dir.join(".gitignore");
|
||||
let config = generate_seatbelt_config(
|
||||
|
|
@ -551,14 +547,15 @@ mod tests {
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(config.contains("(deny file-read-data file-write*"));
|
||||
assert!(config.contains("(deny file-write*"));
|
||||
assert!(!config.contains("file-read-data"));
|
||||
assert!(config.contains("(subpath \"/Users/test/projects/myproject/.gitignore\")"));
|
||||
// `subpath` already covers the path itself, so no redundant `literal`.
|
||||
assert!(!config.contains("(literal \"/Users/test/projects/myproject/.gitignore\")"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_blocks_protected_path_contents_but_allows_metadata() {
|
||||
fn test_sandbox_allows_protected_path_reads_but_blocks_writes() {
|
||||
use std::process::Command;
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -574,7 +571,7 @@ mod tests {
|
|||
&[
|
||||
"-c".to_string(),
|
||||
format!(
|
||||
"test -e '{}' && ! cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'",
|
||||
"test -e '{}' && cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'",
|
||||
protected_file.display(),
|
||||
protected_file.display(),
|
||||
protected_file.display(),
|
||||
|
|
@ -594,7 +591,7 @@ mod tests {
|
|||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"sandbox should allow metadata but deny protected data reads and writes: stderr={} stdout={}",
|
||||
"sandbox should allow protected reads but deny protected writes: stderr={} stdout={}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
);
|
||||
|
|
@ -620,7 +617,7 @@ mod tests {
|
|||
&[
|
||||
"-c".to_string(),
|
||||
format!(
|
||||
"! cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'",
|
||||
"cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'",
|
||||
protected_file.display(),
|
||||
protected_file.display(),
|
||||
),
|
||||
|
|
@ -642,7 +639,7 @@ mod tests {
|
|||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"protected paths should stay blocked even with unrestricted writes: stderr={} stdout={}",
|
||||
"protected paths should stay readable but not writable even with unrestricted writes: stderr={} stdout={}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ use std::{
|
|||
use http_proxy::ProxyHandle;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use http_proxy::{Allowlist, HostPattern, ProxyConfig, ProxyEvent, UpstreamProxy};
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::os::fd::AsRawFd as _;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_bubblewrap;
|
||||
|
|
@ -30,7 +32,7 @@ mod windows_wsl;
|
|||
pub(crate) const WSL_SANDBOX_UNAVAILABLE_PREFIX: &str = "Windows sandboxing via WSL is unavailable";
|
||||
|
||||
/// An opaque handle to a location on the **host** filesystem the sandbox may
|
||||
/// grant access to (a writable subtree, a `.git` directory, …).
|
||||
/// grant access to or protect (for example, a writable or protected subtree).
|
||||
///
|
||||
/// The entire purpose of this type is to capture the *security-relevant identity*
|
||||
/// of a host location once, up front, in a form the enforcement layer can use
|
||||
|
|
@ -83,7 +85,7 @@ impl HostFilesystemLocation {
|
|||
/// On macOS this canonicalizes the path; on Linux it opens an `O_PATH`
|
||||
/// descriptor to it; on Windows it records nothing. The caller is
|
||||
/// responsible for having already *validated* `path` (e.g. confirmed it is
|
||||
/// inside the project, or that a `.git` is not a symlink) — capturing it here
|
||||
/// inside the project, or safe to treat as a protected path) — capturing it here
|
||||
/// pins that decision against later tampering. To be race-free, capture
|
||||
/// should happen as part of, or immediately after, that validation, and the
|
||||
/// resulting value should be passed around unchanged from then on (never
|
||||
|
|
@ -95,8 +97,8 @@ impl HostFilesystemLocation {
|
|||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// `canonicalize_allowing_missing_leaf` resolves through the existing
|
||||
// parent so a not-yet-created leaf (e.g. a `.git` before `git init`)
|
||||
// still yields the real path Seatbelt will match against.
|
||||
// parent so a not-yet-created leaf still yields the real path
|
||||
// Seatbelt will match against.
|
||||
let canonical_path = canonicalize_allowing_missing_leaf(path);
|
||||
Ok(Self {
|
||||
canonical_path,
|
||||
|
|
@ -196,7 +198,10 @@ impl PartialEq for HostFilesystemLocation {
|
|||
fn eq(&self, other: &Self) -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
match (linux_fd_identity(&self.fd), linux_fd_identity(&other.fd)) {
|
||||
match (
|
||||
linux_fd_identity(self.fd.as_raw_fd()),
|
||||
linux_fd_identity(other.fd.as_raw_fd()),
|
||||
) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
// An `fstat` on an `O_PATH` fd we own should never fail; if it
|
||||
// somehow does we can't prove identity, so report "not equal"
|
||||
|
|
@ -230,9 +235,8 @@ impl Eq for HostFilesystemLocation {}
|
|||
/// The `(device, inode)` pair behind an `O_PATH` descriptor, used to decide
|
||||
/// whether two [`HostFilesystemLocation`]s refer to the same filesystem object.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_fd_identity(fd: &std::os::fd::OwnedFd) -> Option<(u64, u64)> {
|
||||
use std::os::fd::AsRawFd as _;
|
||||
let stat = nix::sys::stat::fstat(fd.as_raw_fd()).ok()?;
|
||||
fn linux_fd_identity(fd: std::os::fd::RawFd) -> Option<(u64, u64)> {
|
||||
let stat = nix::sys::stat::fstat(fd).ok()?;
|
||||
Some((stat.st_dev as u64, stat.st_ino as u64))
|
||||
}
|
||||
|
||||
|
|
@ -279,20 +283,23 @@ impl From<PathBuf> for SandboxFilesystemLocation {
|
|||
pub struct SandboxPolicy {
|
||||
pub fs: SandboxFsPolicy,
|
||||
pub network: SandboxNetPolicy,
|
||||
pub git: GitSandboxPolicy,
|
||||
}
|
||||
|
||||
/// Filesystem policy for a sandboxed command.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SandboxFsPolicy {
|
||||
/// Allow unrestricted filesystem writes.
|
||||
Unrestricted,
|
||||
/// Allow unrestricted filesystem writes except for protected paths, which
|
||||
/// remain readable but not writable.
|
||||
Unrestricted {
|
||||
protected_paths: Vec<HostFilesystemLocation>,
|
||||
},
|
||||
/// Reads are allowed everywhere; writes are confined to these locations
|
||||
/// (and the standard ephemeral locations the platform provides). Each is a
|
||||
/// [`HostFilesystemLocation`] captured at validation time, never a bare path
|
||||
/// the enforcement layer would re-resolve.
|
||||
Restricted {
|
||||
writable_paths: Vec<HostFilesystemLocation>,
|
||||
protected_paths: Vec<HostFilesystemLocation>,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -308,117 +315,198 @@ pub enum SandboxNetPolicy {
|
|||
Restricted { allowed_domains: Vec<String> },
|
||||
}
|
||||
|
||||
/// Policy for the project's Git directories (`.git`). The agent computes the
|
||||
/// directory list because locating it requires Git knowledge the sandbox layer
|
||||
/// can't derive itself — a linked worktree's `.git` is a gitlink pointing at a
|
||||
/// common dir elsewhere, and discovered repositories can live outside the
|
||||
/// project. Both variants carry the same dirs; the variant selects how they're
|
||||
/// treated.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum GitSandboxPolicy {
|
||||
/// `.git` contents are protected: read-only on Linux and Windows/WSL;
|
||||
/// content reads and writes denied on macOS (metadata stays visible either
|
||||
/// way).
|
||||
Denied {
|
||||
git_dirs: Vec<HostFilesystemLocation>,
|
||||
},
|
||||
/// `.git` contents are writable (these dirs are made writable).
|
||||
Allowed {
|
||||
git_dirs: Vec<HostFilesystemLocation>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for GitSandboxPolicy {
|
||||
/// Git directories protected, none known — the safe default.
|
||||
fn default() -> Self {
|
||||
GitSandboxPolicy::Denied {
|
||||
git_dirs: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GitSandboxPolicy {
|
||||
/// The `.git` directories this policy governs, regardless of variant.
|
||||
pub fn git_dirs(&self) -> &[HostFilesystemLocation] {
|
||||
match self {
|
||||
GitSandboxPolicy::Denied { git_dirs } | GitSandboxPolicy::Allowed { git_dirs } => {
|
||||
git_dirs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `.git` contents are writable.
|
||||
pub fn allows_writes(&self) -> bool {
|
||||
matches!(self, GitSandboxPolicy::Allowed { .. })
|
||||
}
|
||||
|
||||
/// Combine two layers: `Allowed` (more permissive) wins, and the governed
|
||||
/// `.git` directories union.
|
||||
pub fn merge(self, other: GitSandboxPolicy) -> GitSandboxPolicy {
|
||||
let allowed = self.allows_writes() || other.allows_writes();
|
||||
let (GitSandboxPolicy::Denied { mut git_dirs }
|
||||
| GitSandboxPolicy::Allowed { mut git_dirs }) = self;
|
||||
let (GitSandboxPolicy::Denied {
|
||||
git_dirs: other_dirs,
|
||||
}
|
||||
| GitSandboxPolicy::Allowed {
|
||||
git_dirs: other_dirs,
|
||||
}) = other;
|
||||
for path in other_dirs {
|
||||
if !git_dirs.contains(&path) {
|
||||
git_dirs.push(path);
|
||||
}
|
||||
}
|
||||
if allowed {
|
||||
GitSandboxPolicy::Allowed { git_dirs }
|
||||
} else {
|
||||
GitSandboxPolicy::Denied { git_dirs }
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Host paths that should remain readable but not writable, even when they fall
|
||||
/// under a writable subtree.
|
||||
///
|
||||
/// The caller computes this list because the sandbox layer does not know which
|
||||
/// application-specific paths need stronger protection.
|
||||
pub type ProtectedPaths = Vec<HostFilesystemLocation>;
|
||||
|
||||
impl SandboxPolicy {
|
||||
/// Combine two policies into the least-restrictive policy that satisfies
|
||||
/// both (a set union per dimension). Used to layer the user's persistent
|
||||
/// settings, this thread's grants, and a command's request into the single
|
||||
/// policy that is actually enforced.
|
||||
/// Combine two policy layers. Filesystem/network grants are unioned into the
|
||||
/// least-restrictive policy that satisfies both layers, while protected paths
|
||||
/// within restricted filesystem policies are unioned so every layer's
|
||||
/// protected subtrees remain protected.
|
||||
pub fn merge(self, other: SandboxPolicy) -> SandboxPolicy {
|
||||
SandboxPolicy {
|
||||
fs: self.fs.merge(other.fs),
|
||||
network: self.network.merge(other.network),
|
||||
git: self.git.merge(other.git),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the Git policy, keeping the fs/network policy. The UI builds the
|
||||
/// fs/network halves from settings/grants (which don't know the project's
|
||||
/// `.git` locations) and then attaches the agent-computed Git policy here.
|
||||
pub fn with_git(mut self, git: GitSandboxPolicy) -> Self {
|
||||
self.git = git;
|
||||
/// Replace the protected paths in the filesystem policy, keeping writable
|
||||
/// paths and network policy unchanged.
|
||||
pub fn with_protected_paths(mut self, protected_paths: Vec<HostFilesystemLocation>) -> Self {
|
||||
match &mut self.fs {
|
||||
SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: existing,
|
||||
}
|
||||
| SandboxFsPolicy::Restricted {
|
||||
protected_paths: existing,
|
||||
..
|
||||
} => *existing = protected_paths,
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_locations(
|
||||
mut locations: Vec<HostFilesystemLocation>,
|
||||
other: Vec<HostFilesystemLocation>,
|
||||
) -> Vec<HostFilesystemLocation> {
|
||||
for location in other {
|
||||
if !locations.contains(&location) {
|
||||
locations.push(location);
|
||||
}
|
||||
}
|
||||
locations
|
||||
}
|
||||
|
||||
fn validate_writable_paths_do_not_overlap_protected_paths(
|
||||
writable_paths: &[HostFilesystemLocation],
|
||||
protected_paths: &[HostFilesystemLocation],
|
||||
) -> Result<(), SandboxError> {
|
||||
for writable_path in writable_paths {
|
||||
for protected_path in protected_paths {
|
||||
if writable_path_overlaps_protected_path(writable_path, protected_path) {
|
||||
return Err(SandboxError::InvalidRequest(format!(
|
||||
"writable sandbox path `{}` overlaps protected path `{}`",
|
||||
writable_path.untrusted_path_display(),
|
||||
protected_path.untrusted_path_display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn writable_path_overlaps_protected_path(
|
||||
writable_path: &HostFilesystemLocation,
|
||||
protected_path: &HostFilesystemLocation,
|
||||
) -> bool {
|
||||
linux_location_is_equal_or_descendant(writable_path, protected_path)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn writable_path_overlaps_protected_path(
|
||||
writable_path: &HostFilesystemLocation,
|
||||
protected_path: &HostFilesystemLocation,
|
||||
) -> bool {
|
||||
writable_path
|
||||
.macos_canonical_path()
|
||||
.starts_with(protected_path.macos_canonical_path())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
fn writable_path_overlaps_protected_path(
|
||||
writable_path: &HostFilesystemLocation,
|
||||
protected_path: &HostFilesystemLocation,
|
||||
) -> bool {
|
||||
writable_path
|
||||
.untrusted_path_for_display
|
||||
.starts_with(&protected_path.untrusted_path_for_display)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_location_is_equal_or_descendant(
|
||||
location: &HostFilesystemLocation,
|
||||
ancestor: &HostFilesystemLocation,
|
||||
) -> bool {
|
||||
use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd};
|
||||
|
||||
if location == ancestor {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Ok(mut current) = location.linux_dup_fd() else {
|
||||
log::warn!("failed to duplicate sandbox location fd while checking protected path overlap");
|
||||
return false;
|
||||
};
|
||||
|
||||
loop {
|
||||
let parent = unsafe {
|
||||
let fd = libc::openat(
|
||||
current.as_raw_fd(),
|
||||
c"..".as_ptr(),
|
||||
libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
|
||||
);
|
||||
if fd < 0 {
|
||||
return false;
|
||||
}
|
||||
OwnedFd::from_raw_fd(fd)
|
||||
};
|
||||
|
||||
let Some(parent_identity) = linux_fd_identity(parent.as_raw_fd()) else {
|
||||
log::warn!("failed to fstat parent fd while checking protected path overlap");
|
||||
return false;
|
||||
};
|
||||
let Some(current_identity) = linux_fd_identity(current.as_raw_fd()) else {
|
||||
log::warn!("failed to fstat current fd while checking protected path overlap");
|
||||
return false;
|
||||
};
|
||||
let Some(ancestor_identity) = linux_fd_identity(ancestor.linux_fd().as_raw_fd()) else {
|
||||
log::warn!("failed to fstat protected fd while checking protected path overlap");
|
||||
return false;
|
||||
};
|
||||
|
||||
if parent_identity == ancestor_identity {
|
||||
return true;
|
||||
}
|
||||
if parent_identity == current_identity {
|
||||
return false;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
impl SandboxFsPolicy {
|
||||
/// Unrestricted access dominates; otherwise the writable subtrees union.
|
||||
pub fn merge(self, other: SandboxFsPolicy) -> SandboxFsPolicy {
|
||||
match (self, other) {
|
||||
(SandboxFsPolicy::Unrestricted, _) | (_, SandboxFsPolicy::Unrestricted) => {
|
||||
SandboxFsPolicy::Unrestricted
|
||||
}
|
||||
(
|
||||
SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: protected_a,
|
||||
},
|
||||
SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: protected_b,
|
||||
},
|
||||
) => SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: merge_locations(protected_a, protected_b),
|
||||
},
|
||||
(
|
||||
SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: protected_a,
|
||||
},
|
||||
SandboxFsPolicy::Restricted {
|
||||
protected_paths: protected_b,
|
||||
..
|
||||
},
|
||||
)
|
||||
| (
|
||||
SandboxFsPolicy::Restricted {
|
||||
protected_paths: protected_a,
|
||||
..
|
||||
},
|
||||
SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: protected_b,
|
||||
},
|
||||
) => SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: merge_locations(protected_a, protected_b),
|
||||
},
|
||||
(
|
||||
SandboxFsPolicy::Restricted {
|
||||
writable_paths: mut a,
|
||||
writable_paths: writable_a,
|
||||
protected_paths: protected_a,
|
||||
},
|
||||
SandboxFsPolicy::Restricted { writable_paths: b },
|
||||
) => {
|
||||
for path in b {
|
||||
if !a.contains(&path) {
|
||||
a.push(path);
|
||||
}
|
||||
}
|
||||
SandboxFsPolicy::Restricted { writable_paths: a }
|
||||
}
|
||||
SandboxFsPolicy::Restricted {
|
||||
writable_paths: writable_b,
|
||||
protected_paths: protected_b,
|
||||
},
|
||||
) => SandboxFsPolicy::Restricted {
|
||||
writable_paths: merge_locations(writable_a, writable_b),
|
||||
protected_paths: merge_locations(protected_a, protected_b),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -537,6 +625,7 @@ impl std::error::Error for SandboxError {}
|
|||
struct FsSetup {
|
||||
allow_fs_write: bool,
|
||||
writable_paths: Vec<HostFilesystemLocation>,
|
||||
protected_paths: Vec<HostFilesystemLocation>,
|
||||
}
|
||||
|
||||
/// Resolved network plan derived from [`SandboxNetPolicy`]. For the restricted
|
||||
|
|
@ -563,8 +652,6 @@ enum NetSetup {
|
|||
pub struct Sandbox {
|
||||
fs: FsSetup,
|
||||
network: NetSetup,
|
||||
/// The project's `.git` directories and whether they're writable.
|
||||
git: GitSandboxPolicy,
|
||||
/// In-process network proxy for the restricted-network case, spawned on the
|
||||
/// first `wrap`. Dropped on a background thread (the join blocks).
|
||||
proxy: Option<ProxyHandle>,
|
||||
|
|
@ -598,14 +685,25 @@ impl Sandbox {
|
|||
/// availability is checked separately via [`Sandbox::can_create`].
|
||||
pub fn new(policy: SandboxPolicy) -> Result<Self, SandboxError> {
|
||||
let fs = match policy.fs {
|
||||
SandboxFsPolicy::Unrestricted => FsSetup {
|
||||
SandboxFsPolicy::Unrestricted { protected_paths } => FsSetup {
|
||||
allow_fs_write: true,
|
||||
writable_paths: Vec::new(),
|
||||
protected_paths,
|
||||
},
|
||||
SandboxFsPolicy::Restricted { writable_paths } => FsSetup {
|
||||
allow_fs_write: false,
|
||||
SandboxFsPolicy::Restricted {
|
||||
writable_paths,
|
||||
},
|
||||
protected_paths,
|
||||
} => {
|
||||
validate_writable_paths_do_not_overlap_protected_paths(
|
||||
&writable_paths,
|
||||
&protected_paths,
|
||||
)?;
|
||||
FsSetup {
|
||||
allow_fs_write: false,
|
||||
writable_paths,
|
||||
protected_paths,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let network = match policy.network {
|
||||
|
|
@ -619,7 +717,6 @@ impl Sandbox {
|
|||
Ok(Self {
|
||||
fs,
|
||||
network,
|
||||
git: policy.git,
|
||||
proxy: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
validation_fd_sender: None,
|
||||
|
|
@ -657,7 +754,7 @@ impl Sandbox {
|
|||
{
|
||||
let permissions = linux_bubblewrap::SandboxPermissions {
|
||||
network: linux_probe_network(&policy.network),
|
||||
allow_fs_write: matches!(policy.fs, SandboxFsPolicy::Unrestricted),
|
||||
allow_fs_write: matches!(policy.fs, SandboxFsPolicy::Unrestricted { .. }),
|
||||
};
|
||||
linux_bubblewrap::check_can_create_sandbox(permissions).map_err(map_linux_status)
|
||||
}
|
||||
|
|
@ -773,19 +870,11 @@ impl Sandbox {
|
|||
Ok(Some((port, proxy.socket_path().map(PathBuf::from))))
|
||||
}
|
||||
|
||||
/// Split the policy's `.git` directories into (writable, protected) sets for
|
||||
/// the enforcement layers. When the whole filesystem is writable the split
|
||||
/// is empty (Git protection is moot); otherwise `Allowed` git dirs are
|
||||
/// writable and `Denied` git dirs are protected.
|
||||
/// Return the protected paths for the enforcement layer. Even with broad
|
||||
/// filesystem writes, protected paths remain readable but not writable.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
|
||||
fn git_path_split(&self) -> (Vec<HostFilesystemLocation>, Vec<HostFilesystemLocation>) {
|
||||
if self.fs.allow_fs_write {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
match &self.git {
|
||||
GitSandboxPolicy::Allowed { git_dirs } => (git_dirs.clone(), Vec::new()),
|
||||
GitSandboxPolicy::Denied { git_dirs } => (Vec::new(), git_dirs.clone()),
|
||||
}
|
||||
fn protected_paths(&self) -> Vec<HostFilesystemLocation> {
|
||||
self.fs.protected_paths.clone()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
|
@ -806,7 +895,7 @@ impl Sandbox {
|
|||
network,
|
||||
allow_fs_write: self.fs.allow_fs_write,
|
||||
};
|
||||
let (git_writable, git_protected) = self.git_path_split();
|
||||
let protected_paths = self.protected_paths();
|
||||
// Build the writable binds as (captured fd, bind path) pairs in lockstep.
|
||||
// The bind *path* is derived from the pinned inode (readlink of the
|
||||
// captured `O_PATH` fd), never from an attacker-influenceable string; the
|
||||
|
|
@ -815,7 +904,7 @@ impl Sandbox {
|
|||
// its path on the validator side.
|
||||
let mut writable_owned: Vec<PathBuf> = Vec::new();
|
||||
let mut writable_fds: Vec<std::os::fd::OwnedFd> = Vec::new();
|
||||
for location in self.fs.writable_paths.iter().chain(git_writable.iter()) {
|
||||
for location in &self.fs.writable_paths {
|
||||
let Some(path) = linux_location_path(location) else {
|
||||
continue;
|
||||
};
|
||||
|
|
@ -835,11 +924,11 @@ impl Sandbox {
|
|||
}
|
||||
}
|
||||
let writable: Vec<&Path> = writable_owned.iter().map(PathBuf::as_path).collect();
|
||||
let protected_owned: Vec<PathBuf> = git_protected
|
||||
let protected_owned: Vec<PathBuf> = protected_paths
|
||||
.iter()
|
||||
.filter_map(linux_location_path)
|
||||
.collect();
|
||||
let protected_git_dirs: Vec<&Path> = protected_owned.iter().map(PathBuf::as_path).collect();
|
||||
let protected_paths: Vec<&Path> = protected_owned.iter().map(PathBuf::as_path).collect();
|
||||
|
||||
// Stand up the host endpoint that sends the captured fds to the
|
||||
// in-sandbox validator, when this run has writable binds to verify. It's
|
||||
|
|
@ -876,7 +965,7 @@ impl Sandbox {
|
|||
bridge_program,
|
||||
permissions,
|
||||
&writable,
|
||||
&protected_git_dirs,
|
||||
&protected_paths,
|
||||
command.cwd.as_deref(),
|
||||
&command.program,
|
||||
&command.args,
|
||||
|
|
@ -910,23 +999,18 @@ impl Sandbox {
|
|||
network,
|
||||
allow_fs_write: self.fs.allow_fs_write,
|
||||
};
|
||||
let (git_writable, git_protected) = self.git_path_split();
|
||||
let protected_paths = self.protected_paths();
|
||||
// Each location's canonical path was resolved exactly once at capture
|
||||
// time; pass it straight through as the Seatbelt rule literal. The
|
||||
// profile generator must NOT re-canonicalize (that reopened the
|
||||
// verify-vs-enforce gap); see `generate_seatbelt_config`.
|
||||
let mut writable: Vec<&Path> = self
|
||||
let writable: Vec<&Path> = self
|
||||
.fs
|
||||
.writable_paths
|
||||
.iter()
|
||||
.map(HostFilesystemLocation::macos_canonical_path)
|
||||
.collect();
|
||||
writable.extend(
|
||||
git_writable
|
||||
.iter()
|
||||
.map(HostFilesystemLocation::macos_canonical_path),
|
||||
);
|
||||
let protected: Vec<&Path> = git_protected
|
||||
let protected: Vec<&Path> = protected_paths
|
||||
.iter()
|
||||
.map(HostFilesystemLocation::macos_canonical_path)
|
||||
.collect();
|
||||
|
|
@ -972,12 +1056,8 @@ impl Sandbox {
|
|||
.iter()
|
||||
.map(|location| location.windows_path().to_path_buf())
|
||||
.collect();
|
||||
let (writable_git_locations, protected_git_locations) = self.git_path_split();
|
||||
let writable_git_paths: Vec<PathBuf> = writable_git_locations
|
||||
.iter()
|
||||
.map(|location| location.windows_path().to_path_buf())
|
||||
.collect();
|
||||
let protected_git_paths: Vec<PathBuf> = protected_git_locations
|
||||
let protected_paths: Vec<PathBuf> = self
|
||||
.protected_paths()
|
||||
.iter()
|
||||
.map(|location| location.windows_path().to_path_buf())
|
||||
.collect();
|
||||
|
|
@ -985,8 +1065,7 @@ impl Sandbox {
|
|||
command.program.clone(),
|
||||
command.args.clone(),
|
||||
writable_paths,
|
||||
writable_git_paths,
|
||||
protected_git_paths,
|
||||
protected_paths,
|
||||
permissions,
|
||||
command.cwd.clone(),
|
||||
command.env.clone(),
|
||||
|
|
@ -1231,6 +1310,61 @@ fn map_anyhow_error(error: anyhow::Error) -> SandboxError {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn restricted_fs_rejects_writable_paths_inside_protected_paths() {
|
||||
let temp_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let repo_dir = temp_dir.path().join("repo");
|
||||
let git_dir = repo_dir.join(".git");
|
||||
let hooks_dir = git_dir.join("hooks");
|
||||
std::fs::create_dir_all(&hooks_dir).expect("create git hooks dir");
|
||||
|
||||
let protected_git = HostFilesystemLocation::new(&git_dir).expect("capture git dir");
|
||||
let writable_git = HostFilesystemLocation::new(&git_dir).expect("capture git dir");
|
||||
let writable_hooks = HostFilesystemLocation::new(&hooks_dir).expect("capture hooks dir");
|
||||
|
||||
for writable_path in [writable_git, writable_hooks] {
|
||||
let result = Sandbox::new(SandboxPolicy {
|
||||
fs: SandboxFsPolicy::Restricted {
|
||||
writable_paths: vec![writable_path],
|
||||
protected_paths: vec![protected_git.clone()],
|
||||
},
|
||||
network: SandboxNetPolicy::Blocked,
|
||||
});
|
||||
|
||||
assert!(matches!(result, Err(SandboxError::InvalidRequest(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn restricted_fs_protected_overlap_check_uses_captured_fds_not_path_text() {
|
||||
use std::os::unix::fs as unix_fs;
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let repo_dir = temp_dir.path().join("repo");
|
||||
let git_dir = repo_dir.join(".git");
|
||||
let real_git_dir = repo_dir.join("real-git");
|
||||
let outside_dir = temp_dir.path().join("outside");
|
||||
let outside_hooks_dir = outside_dir.join("hooks");
|
||||
std::fs::create_dir_all(&git_dir).expect("create git dir");
|
||||
std::fs::create_dir_all(&outside_hooks_dir).expect("create outside hooks dir");
|
||||
|
||||
let protected_git = HostFilesystemLocation::new(&git_dir).expect("capture git dir");
|
||||
std::fs::rename(&git_dir, &real_git_dir).expect("move git dir aside");
|
||||
unix_fs::symlink(&outside_dir, &git_dir).expect("replace git dir with symlink");
|
||||
let writable_displayed_inside_git =
|
||||
HostFilesystemLocation::new(git_dir.join("hooks")).expect("capture symlink target");
|
||||
|
||||
Sandbox::new(SandboxPolicy {
|
||||
fs: SandboxFsPolicy::Restricted {
|
||||
writable_paths: vec![writable_displayed_inside_git],
|
||||
protected_paths: vec![protected_git],
|
||||
},
|
||||
network: SandboxNetPolicy::Blocked,
|
||||
})
|
||||
.expect("captured writable fd is outside protected metadata");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_merge_unrestricted_dominates_else_unions_paths() {
|
||||
// Writable paths are captured as real `HostFilesystemLocation`s (keyed on
|
||||
|
|
@ -1238,31 +1372,45 @@ mod tests {
|
|||
let dir_a = tempfile::tempdir().expect("create temp dir a");
|
||||
let dir_b = tempfile::tempdir().expect("create temp dir b");
|
||||
let dir_c = tempfile::tempdir().expect("create temp dir c");
|
||||
let dir_d = tempfile::tempdir().expect("create temp dir d");
|
||||
let location = |dir: &tempfile::TempDir| {
|
||||
HostFilesystemLocation::new(dir.path()).expect("capture temp dir")
|
||||
};
|
||||
|
||||
let a = SandboxFsPolicy::Restricted {
|
||||
writable_paths: vec![location(&dir_a), location(&dir_b)],
|
||||
protected_paths: vec![location(&dir_c)],
|
||||
};
|
||||
let b = SandboxFsPolicy::Restricted {
|
||||
writable_paths: vec![location(&dir_b), location(&dir_c)],
|
||||
protected_paths: vec![location(&dir_c), location(&dir_d)],
|
||||
};
|
||||
assert_eq!(
|
||||
a.clone().merge(b),
|
||||
SandboxFsPolicy::Restricted {
|
||||
writable_paths: vec![location(&dir_a), location(&dir_b), location(&dir_c)],
|
||||
protected_paths: vec![location(&dir_c), location(&dir_d)],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
a.merge(SandboxFsPolicy::Unrestricted),
|
||||
SandboxFsPolicy::Unrestricted
|
||||
a.merge(SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: vec![location(&dir_a)],
|
||||
}),
|
||||
SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: vec![location(&dir_c), location(&dir_a)],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
SandboxFsPolicy::Unrestricted.merge(SandboxFsPolicy::Restricted {
|
||||
SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: vec![location(&dir_d)],
|
||||
}
|
||||
.merge(SandboxFsPolicy::Restricted {
|
||||
writable_paths: vec![location(&dir_a)],
|
||||
protected_paths: vec![location(&dir_c)],
|
||||
}),
|
||||
SandboxFsPolicy::Unrestricted
|
||||
SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: vec![location(&dir_d), location(&dir_c)],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1343,8 +1491,8 @@ mod tests {
|
|||
/// doesn't exist yet.
|
||||
///
|
||||
/// `std::fs::canonicalize` fails if any component is missing, which would leave
|
||||
/// a not-yet-created path (e.g. a `.git` directory before `git init`) in a
|
||||
/// non-canonical form. The sandbox layers canonicalize the writable parent
|
||||
/// a not-yet-created path in a non-canonical form. The sandbox layers
|
||||
/// canonicalize the writable parent
|
||||
/// (the worktree root) but, with a plain `canonicalize`, fall back to the raw
|
||||
/// path for a missing child; the two then disagree when a component is a
|
||||
/// symlink (`/tmp` -> `/private/tmp` on macOS), and the protection rule for the
|
||||
|
|
@ -1353,9 +1501,9 @@ mod tests {
|
|||
/// consistent with its parent. If neither the path nor its parent can be
|
||||
/// canonicalized, the path is returned unchanged.
|
||||
//
|
||||
// Only the macOS Seatbelt layer uses this (Linux skips not-yet-existing `.git`
|
||||
// dirs rather than emitting a rule for them), so it's gated to macOS to avoid a
|
||||
// dead-code warning elsewhere.
|
||||
// Only the macOS Seatbelt layer uses this (Linux skips not-yet-existing
|
||||
// protected paths rather than emitting a rule for them), so it's gated to macOS
|
||||
// to avoid a dead-code warning elsewhere.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn canonicalize_allowing_missing_leaf(path: &std::path::Path) -> std::path::PathBuf {
|
||||
if let Ok(canonical) = path.canonicalize() {
|
||||
|
|
@ -1386,7 +1534,7 @@ mod macos_tests {
|
|||
|
||||
// A path whose leaf doesn't exist yet still resolves through its parent,
|
||||
// so it stays consistent with how the parent directory canonicalizes
|
||||
// (this is the `.git`-before-`git init` case).
|
||||
// (for example, when protecting a not-yet-created child path).
|
||||
let missing = dir.path().join("not-created-yet");
|
||||
assert_eq!(
|
||||
canonicalize_allowing_missing_leaf(&missing),
|
||||
|
|
|
|||
|
|
@ -236,9 +236,9 @@ impl PathMapping {
|
|||
/// into a Linux shell invocation (typically `/bin/sh -c ...`) before calling
|
||||
/// this function.
|
||||
///
|
||||
/// All writable paths, Git paths, and the cwd must be paths that can be mapped
|
||||
/// into WSL. The cwd and ordinary writable paths must exist; Git metadata paths
|
||||
/// may be missing because Bubblewrap cannot bind a missing source. WSL UNC paths
|
||||
/// All writable paths, protected paths, and the cwd must be paths that can be
|
||||
/// mapped into WSL. The cwd and writable paths must exist; protected paths may
|
||||
/// be missing because Bubblewrap cannot bind a missing source. WSL UNC paths
|
||||
/// may specify a distro; native drive-letter paths are translated with `wslpath`
|
||||
/// inside either that distro or the default distro (falling back to
|
||||
/// `/mnt/<drive>/...` if translation fails).
|
||||
|
|
@ -267,8 +267,7 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
|
|||
program: String,
|
||||
args: Vec<String>,
|
||||
writable_paths: Vec<PathBuf>,
|
||||
writable_git_paths: Vec<PathBuf>,
|
||||
protected_git_paths: Vec<PathBuf>,
|
||||
protected_paths: Vec<PathBuf>,
|
||||
permissions: SandboxPermissions,
|
||||
cwd: Option<PathBuf>,
|
||||
env: HashMap<String, String, S>,
|
||||
|
|
@ -299,35 +298,18 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
|
|||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let writable_git_mappings = writable_git_paths
|
||||
let protected_mappings = protected_paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
path_to_wsl_allowing_missing(path).with_context(|| {
|
||||
format!(
|
||||
"failed to map writable Git metadata path `{}` into WSL",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let protected_git_mappings = protected_git_paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
path_to_wsl_allowing_missing(path).with_context(|| {
|
||||
format!(
|
||||
"failed to map protected Git metadata path `{}` into WSL",
|
||||
path.display()
|
||||
)
|
||||
format!("failed to map protected path `{}` into WSL", path.display())
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
let distro = select_distro(
|
||||
cwd_mapping.as_ref(),
|
||||
writable_mappings
|
||||
.iter()
|
||||
.chain(writable_git_mappings.iter())
|
||||
.chain(protected_git_mappings.iter()),
|
||||
writable_mappings.iter().chain(protected_mappings.iter()),
|
||||
)?;
|
||||
let wsl_exe = wsl_exe_path();
|
||||
if !wsl_exe.is_file() {
|
||||
|
|
@ -340,14 +322,11 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
|
|||
|
||||
// Resolve all paths (translating native drive-letter paths with `wslpath`
|
||||
// now that the distro is known) in a single WSL round-trip. The cwd and
|
||||
// ordinary writable paths must exist; Git paths may be missing because, as
|
||||
// with Linux bwrap, a not-yet-created `.git` placeholder can't be overlaid.
|
||||
// ordinary writable paths must exist; protected paths may be missing because,
|
||||
// as with Linux bwrap, a not-yet-created path can't be overlaid.
|
||||
let has_cwd = cwd_mapping.is_some();
|
||||
let writable_path_count = writable_mappings.len();
|
||||
let writable_git_path_count = writable_git_mappings.len();
|
||||
let mut mappings = Vec::with_capacity(
|
||||
writable_mappings.len() + writable_git_mappings.len() + protected_git_mappings.len() + 1,
|
||||
);
|
||||
let mut mappings = Vec::with_capacity(writable_mappings.len() + protected_mappings.len() + 1);
|
||||
if let Some(mapping) = cwd_mapping {
|
||||
mappings.push((mapping, "terminal cwd", true));
|
||||
}
|
||||
|
|
@ -357,22 +336,13 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
|
|||
.map(|mapping| (mapping, "writable path", true)),
|
||||
);
|
||||
mappings.extend(
|
||||
writable_git_mappings
|
||||
protected_mappings
|
||||
.into_iter()
|
||||
.map(|mapping| (mapping, "writable Git metadata path", false)),
|
||||
);
|
||||
mappings.extend(
|
||||
protected_git_mappings
|
||||
.into_iter()
|
||||
.map(|mapping| (mapping, "protected Git metadata path", false)),
|
||||
.map(|mapping| (mapping, "protected path", false)),
|
||||
);
|
||||
let resolved = resolve_paths(&wsl_exe, distro.as_deref(), &mappings).await?;
|
||||
let (cwd, writable_paths, protected_git_paths) = split_resolved_paths(
|
||||
has_cwd,
|
||||
writable_path_count,
|
||||
writable_git_path_count,
|
||||
resolved,
|
||||
)?;
|
||||
let (cwd, writable_paths, protected_paths) =
|
||||
split_resolved_paths(has_cwd, writable_path_count, resolved)?;
|
||||
|
||||
let mut wsl_args = Vec::new();
|
||||
if let Some(distro) = distro.as_deref() {
|
||||
|
|
@ -387,7 +357,7 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
|
|||
// namespace flags. Identical whether or not we route through the helper.
|
||||
let bwrap_options = build_bwrap_args(
|
||||
&writable_paths,
|
||||
&protected_git_paths,
|
||||
&protected_paths,
|
||||
permissions,
|
||||
cwd.as_deref(),
|
||||
environment.mask_interop_dir,
|
||||
|
|
@ -432,7 +402,6 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
|
|||
fn split_resolved_paths(
|
||||
has_cwd: bool,
|
||||
writable_path_count: usize,
|
||||
writable_git_path_count: usize,
|
||||
resolved: Vec<Option<String>>,
|
||||
) -> Result<(Option<String>, Vec<String>, Vec<String>)> {
|
||||
let mut resolved = resolved.into_iter();
|
||||
|
|
@ -447,7 +416,7 @@ fn split_resolved_paths(
|
|||
None
|
||||
};
|
||||
|
||||
let mut writable_paths = Vec::with_capacity(writable_path_count + writable_git_path_count);
|
||||
let mut writable_paths = Vec::with_capacity(writable_path_count);
|
||||
for _ in 0..writable_path_count {
|
||||
writable_paths.push(
|
||||
resolved
|
||||
|
|
@ -456,14 +425,6 @@ fn split_resolved_paths(
|
|||
.context("bug: required writable path resolved as missing")?,
|
||||
);
|
||||
}
|
||||
for _ in 0..writable_git_path_count {
|
||||
if let Some(path) = resolved
|
||||
.next()
|
||||
.context("bug: missing resolved writable Git metadata path")?
|
||||
{
|
||||
writable_paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok((cwd, writable_paths, resolved.flatten().collect()))
|
||||
}
|
||||
|
|
@ -1029,7 +990,7 @@ fn wsl_exe_path() -> PathBuf {
|
|||
|
||||
fn build_bwrap_args<S: std::hash::BuildHasher>(
|
||||
writable_paths: &[String],
|
||||
protected_git_paths: &[String],
|
||||
protected_paths: &[String],
|
||||
permissions: SandboxPermissions,
|
||||
cwd: Option<&str>,
|
||||
mask_interop_dir: bool,
|
||||
|
|
@ -1045,12 +1006,12 @@ fn build_bwrap_args<S: std::hash::BuildHasher>(
|
|||
for path in writable_paths {
|
||||
push_bind(&mut args, "--bind", path, path);
|
||||
}
|
||||
// Protect Git metadata by re-binding it read-only over the writable
|
||||
// worktree binds above (order matters: later binds win). When Git access
|
||||
// is granted these paths are included in `writable_paths` instead.
|
||||
for path in protected_git_paths {
|
||||
push_bind(&mut args, "--ro-bind", path, path);
|
||||
}
|
||||
}
|
||||
|
||||
// Protect requested paths by re-binding them read-only over any writable
|
||||
// binds above (order matters: later binds win).
|
||||
for path in protected_paths {
|
||||
push_bind(&mut args, "--ro-bind", path, path);
|
||||
}
|
||||
|
||||
// Block WSL's Windows interop, regardless of the requested permissions.
|
||||
|
|
@ -1320,7 +1281,6 @@ mod tests {
|
|||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
SandboxPermissions::default(),
|
||||
None,
|
||||
HashMap::<String, String>::new(),
|
||||
|
|
@ -1462,39 +1422,27 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn split_resolved_paths_keeps_existing_writable_git_paths_and_skips_missing_ones() {
|
||||
let (cwd, writable_paths, protected_git_paths) = split_resolved_paths(
|
||||
fn split_resolved_paths_keeps_existing_protected_paths_and_skips_missing_ones() {
|
||||
let (cwd, writable_paths, protected_paths) = split_resolved_paths(
|
||||
true,
|
||||
1,
|
||||
2,
|
||||
vec![
|
||||
Some("/home/me/project".to_string()),
|
||||
Some("/home/me/project".to_string()),
|
||||
None,
|
||||
Some("/home/me/project/.git".to_string()),
|
||||
None,
|
||||
Some("/mnt/c/external/.git".to_string()),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(cwd.as_deref(), Some("/home/me/project"));
|
||||
assert_eq!(
|
||||
writable_paths,
|
||||
vec![
|
||||
"/home/me/project".to_string(),
|
||||
"/home/me/project/.git".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
protected_git_paths,
|
||||
vec!["/mnt/c/external/.git".to_string()]
|
||||
);
|
||||
assert_eq!(writable_paths, vec!["/home/me/project".to_string()]);
|
||||
assert_eq!(protected_paths, vec!["/mnt/c/external/.git".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_resolved_paths_rejects_missing_required_writable_paths() {
|
||||
let error = split_resolved_paths(false, 1, 0, vec![None]).unwrap_err();
|
||||
let error = split_resolved_paths(false, 1, vec![None]).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
|
|
@ -1555,7 +1503,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn bwrap_protects_git_paths_after_writable_paths() {
|
||||
fn bwrap_protects_paths_after_writable_paths() {
|
||||
let args = build_bwrap_args(
|
||||
&["/home/me/project".to_string()],
|
||||
&["/home/me/project/.git".to_string()],
|
||||
|
|
@ -1578,12 +1526,12 @@ mod tests {
|
|||
"/home/me/project/.git",
|
||||
]
|
||||
})
|
||||
.expect("Git metadata should be protected read-only");
|
||||
.expect("protected path should be bound read-only");
|
||||
assert!(protected_index > writable_index);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwrap_skips_git_protection_when_fs_writes_are_unrestricted() {
|
||||
fn bwrap_protects_paths_when_fs_writes_are_unrestricted() {
|
||||
let args = build_bwrap_args(
|
||||
&[],
|
||||
&["/home/me/project/.git".to_string()],
|
||||
|
|
@ -1595,12 +1543,22 @@ mod tests {
|
|||
true,
|
||||
&HashMap::new(),
|
||||
);
|
||||
assert!(!args.windows(3).any(|window| window
|
||||
== [
|
||||
"--ro-bind",
|
||||
"/home/me/project/.git",
|
||||
"/home/me/project/.git"
|
||||
]));
|
||||
let unrestricted_write_index = args
|
||||
.windows(3)
|
||||
.position(|window| window == ["--bind", "/", "/"])
|
||||
.expect("root should be writable");
|
||||
let protected_index = args
|
||||
.windows(3)
|
||||
.position(|window| {
|
||||
window
|
||||
== [
|
||||
"--ro-bind",
|
||||
"/home/me/project/.git",
|
||||
"/home/me/project/.git",
|
||||
]
|
||||
})
|
||||
.expect("protected path should be bound read-only");
|
||||
assert!(protected_index > unrestricted_write_index);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ mod imp {
|
|||
|
||||
use anyhow::{Context as _, Result, bail, ensure};
|
||||
use sandbox::{
|
||||
CommandAndArgs, GitSandboxPolicy, HostFilesystemLocation, Sandbox, SandboxError,
|
||||
SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
|
||||
CommandAndArgs, HostFilesystemLocation, Sandbox, SandboxError, SandboxFsPolicy,
|
||||
SandboxNetPolicy, SandboxPolicy,
|
||||
};
|
||||
|
||||
/// Network access for a helper run, translated into a `SandboxNetPolicy` in
|
||||
|
|
@ -510,7 +510,9 @@ mod imp {
|
|||
) -> Result<Outcome> {
|
||||
let policy = SandboxPolicy {
|
||||
fs: if permissions.allow_fs_write {
|
||||
SandboxFsPolicy::Unrestricted
|
||||
SandboxFsPolicy::Unrestricted {
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
} else {
|
||||
SandboxFsPolicy::Restricted {
|
||||
// On Windows the location only records the requested path;
|
||||
|
|
@ -519,13 +521,13 @@ mod imp {
|
|||
.iter()
|
||||
.filter_map(|path| HostFilesystemLocation::new(path).ok())
|
||||
.collect(),
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
},
|
||||
network: match permissions.network {
|
||||
NetworkAccess::None => SandboxNetPolicy::Blocked,
|
||||
NetworkAccess::All => SandboxNetPolicy::Unrestricted,
|
||||
},
|
||||
git: GitSandboxPolicy::default(),
|
||||
};
|
||||
let command = CommandAndArgs {
|
||||
program: program.to_string(),
|
||||
|
|
|
|||
|
|
@ -486,12 +486,6 @@ impl AgentSettingsContent {
|
|||
.allow_fs_write_all = Some(true);
|
||||
}
|
||||
|
||||
pub fn allow_sandbox_git_access(&mut self) {
|
||||
self.sandbox_permissions
|
||||
.get_or_insert_default()
|
||||
.allow_git_access = Some(true);
|
||||
}
|
||||
|
||||
pub fn allow_sandbox_unsandboxed(&mut self) {
|
||||
self.sandbox_permissions
|
||||
.get_or_insert_default()
|
||||
|
|
@ -800,11 +794,6 @@ pub struct SandboxPermissionsContent {
|
|||
/// Default: []
|
||||
pub network_hosts: Option<ExtendingVec<String>>,
|
||||
|
||||
/// Whether sandboxed terminal commands may always access protected Git
|
||||
/// metadata paths without prompting.
|
||||
/// Default: false
|
||||
pub allow_git_access: Option<bool>,
|
||||
|
||||
/// Whether sandboxed terminal commands may always write anywhere on the
|
||||
/// filesystem without prompting.
|
||||
/// Default: false
|
||||
|
|
@ -1145,7 +1134,6 @@ mod tests {
|
|||
&["github.com".to_string(), "*.npmjs.org".to_string()]
|
||||
);
|
||||
settings.allow_sandbox_fs_write_all();
|
||||
settings.allow_sandbox_git_access();
|
||||
settings.allow_sandbox_unsandboxed();
|
||||
settings.add_sandbox_write_path(PathBuf::from("/tmp/build"));
|
||||
|
||||
|
|
@ -1160,7 +1148,6 @@ mod tests {
|
|||
.as_slice(),
|
||||
&["github.com".to_string(), "*.npmjs.org".to_string()]
|
||||
);
|
||||
assert_eq!(sandbox_permissions.allow_git_access, Some(true));
|
||||
assert_eq!(sandbox_permissions.allow_fs_write_all, Some(true));
|
||||
assert_eq!(sandbox_permissions.allow_unsandboxed, Some(true));
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ use crate::components::{SettingsInputField, SettingsSectionHeader};
|
|||
|
||||
const DOMAINS_DESCRIPTION: &str = "Each entry is an exact domain (github.com) or a leading-*. subdomain wildcard (*.npmjs.org). IP addresses and local domains are not allowed.";
|
||||
|
||||
const WRITE_PATHS_DESCRIPTION: &str =
|
||||
"Each entry must be an absolute path and grants write access to the whole subtree.";
|
||||
const WRITE_PATHS_DESCRIPTION: &str = "Each entry must be an absolute path and grants write access to the whole subtree, except protected Git metadata.";
|
||||
|
||||
pub(crate) fn render_sandbox_settings_page(
|
||||
settings_window: &SettingsWindow,
|
||||
|
|
@ -117,27 +116,7 @@ pub(crate) fn render_sandbox_settings_page(
|
|||
empty_border,
|
||||
)),
|
||||
)
|
||||
.child(Divider::horizontal())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(SettingsSectionHeader::new("Git").no_padding(true))
|
||||
.child(
|
||||
SwitchField::new(
|
||||
"sandbox-allow-git-access",
|
||||
Some("Allow Git Metadata Access"),
|
||||
Some(
|
||||
"Let sandboxed commands access protected Git metadata, including .git directories and linked worktree metadata, without prompting."
|
||||
.into(),
|
||||
),
|
||||
permissions.allow_git_access,
|
||||
move |state, _window, cx| {
|
||||
set_allow_git_access(*state == ToggleState::Selected, cx);
|
||||
},
|
||||
)
|
||||
.tab_index(0),
|
||||
),
|
||||
)
|
||||
|
||||
.child(Divider::horizontal())
|
||||
.child(
|
||||
v_flex()
|
||||
|
|
@ -148,7 +127,7 @@ pub(crate) fn render_sandbox_settings_page(
|
|||
"sandbox-allow-fs-write-all",
|
||||
Some("Allow All File System Writes"),
|
||||
Some(
|
||||
"Let sandboxed commands write anywhere on the file system without prompting."
|
||||
"Let sandboxed commands write anywhere except protected Git metadata without prompting."
|
||||
.into(),
|
||||
),
|
||||
permissions.allow_fs_write_all,
|
||||
|
|
@ -433,12 +412,6 @@ fn set_allow_all_hosts(value: bool, cx: &mut App) {
|
|||
});
|
||||
}
|
||||
|
||||
fn set_allow_git_access(value: bool, cx: &mut App) {
|
||||
update_sandbox_permissions(cx, move |permissions| {
|
||||
permissions.allow_git_access = Some(value);
|
||||
});
|
||||
}
|
||||
|
||||
fn set_allow_fs_write_all(value: bool, cx: &mut App) {
|
||||
update_sandbox_permissions(cx, move |permissions| {
|
||||
permissions.allow_fs_write_all = Some(value);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ When no path is provided, shows a summary of error and warning counts for all fi
|
|||
|
||||
Fetches a URL and returns the content as Markdown. Useful for providing docs as context.
|
||||
|
||||
`fetch` is governed by tool permissions, agent profiles, and project trust. It is not run inside the terminal OS sandbox, so terminal sandbox network grants such as `allow_hosts` and `allow_all_hosts` do not apply to it.
|
||||
|
||||
### `find_path` {#find-path}
|
||||
|
||||
Quickly finds files by matching glob patterns (like `**/*.js`), returning matching file paths alphabetically.
|
||||
|
|
|
|||
|
|
@ -9,10 +9,9 @@ You can restrict what operations the [Zed Agent](./zed-agent.md) can run in mult
|
|||
[Tool Permissions](./tool-permissions.md), but these are of limited use when the agent wants to do things like run a
|
||||
complicated script in a terminal.
|
||||
|
||||
Sandboxing runs certain tool actions in an OS-level sandbox which limits filesystem access, network access, and access to
|
||||
protected Git metadata. This way, even if the agent wants to run an arbitrary script, that script will only be able to
|
||||
write to the files and folders you have allowed it to. You can similarly restrict network and Git metadata access in
|
||||
sandboxed tool actions.
|
||||
Sandboxing runs certain tool actions in an OS-level sandbox which limits filesystem access and network access, while
|
||||
protecting Git metadata such as `.git` directories. This way, even if the agent wants to run an arbitrary script, that
|
||||
script will only be able to write to the files and folders you have allowed it to.
|
||||
|
||||
[Tool Permissions](./tool-permissions.md) can be used in addition to sandboxing:
|
||||
|
||||
|
|
@ -26,9 +25,9 @@ terminal tabs, [External Agents](./external-agents.md), or [Terminal Threads](./
|
|||
|
||||
Zed Agent sandboxing currently applies to the `terminal` tool.
|
||||
|
||||
| Tool | What sandboxing limits |
|
||||
| ---------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `terminal` | Filesystem writes, protected Git metadata, and outbound network access for commands the agent runs. |
|
||||
| Tool | What sandboxing limits |
|
||||
| ---------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `terminal` | Filesystem writes and outbound network access for commands the agent runs; Git metadata is protected. |
|
||||
|
||||
Other built-in tools, including `fetch`, are still governed by [Tool Permissions](./tool-permissions.md),
|
||||
[Agent Profiles](./agent-profiles.md), and project trust, but they are not currently run inside this OS sandbox.
|
||||
|
|
@ -39,9 +38,9 @@ By default, sandboxed Zed Agent tool actions have these restrictions:
|
|||
|
||||
| Access type | Default behavior |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Filesystem reads | Terminal commands can read most of the filesystem. Protected Git metadata file contents are hidden on macOS; on Linux and Windows/WSL they remain readable. |
|
||||
| Filesystem reads | Terminal commands can read most of the filesystem, including protected Git metadata. |
|
||||
| Project writes | Terminal commands can write inside open project directories, except for protected Git metadata. |
|
||||
| Git metadata | `.git` directories and linked worktree Git metadata are protected unless you approve Git metadata access. Protection details differ by platform, described below. |
|
||||
| Git metadata | `.git` directories and linked worktree Git metadata remain readable but are not writable while sandboxed. |
|
||||
| Temporary files | Terminal commands receive a writable temporary location. The exact behavior differs by platform. |
|
||||
| Other writes | Writes outside the default writable locations are blocked unless you approve a broader sandbox request. |
|
||||
| Outbound networking | Network access is blocked unless you approve a host-specific or unrestricted network sandbox request. Host-specific enforcement is not available on every platform. |
|
||||
|
|
@ -53,9 +52,8 @@ Depending on what the tool requested, the prompt can ask you to allow:
|
|||
|
||||
- network access to specific hosts, such as `github.com` or `*.npmjs.org`
|
||||
- network access to any host
|
||||
- access to protected Git metadata, such as `.git` directories and linked worktree metadata
|
||||
- write access to specific filesystem paths
|
||||
- unrestricted filesystem writes
|
||||
- unrestricted filesystem writes, except protected Git metadata
|
||||
- running a terminal command without the sandbox
|
||||
|
||||
You can grant a sandbox request for:
|
||||
|
|
@ -76,7 +74,6 @@ If you want to pre-approve common sandbox requests, add persistent permissions t
|
|||
"agent": {
|
||||
"sandbox_permissions": {
|
||||
"network_hosts": ["github.com", "*.npmjs.org"],
|
||||
"allow_git_access": true,
|
||||
"write_paths": ["/Users/you/.cache/my-tool"]
|
||||
}
|
||||
}
|
||||
|
|
@ -89,13 +86,22 @@ The available options are:
|
|||
| -------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `network_hosts` | Hosts that sandboxed tools may reach without prompting. Entries can be exact hostnames or leading-`*.` wildcards. |
|
||||
| `allow_all_hosts` | Allow sandboxed tools to reach any host without prompting. |
|
||||
| `allow_git_access` | Allow sandboxed terminal commands to access protected Git metadata without prompting. |
|
||||
| `write_paths` | Directory subtrees that sandboxed terminal commands may write to without prompting. Paths are absolute. |
|
||||
| `allow_fs_write_all` | Allow sandboxed terminal commands to write anywhere without prompting. |
|
||||
| `allow_fs_write_all` | Allow sandboxed terminal commands to write anywhere except protected Git metadata without prompting. |
|
||||
| `allow_unsandboxed` | Allow terminal commands to run outside the sandbox without prompting when the agent explicitly requests it. |
|
||||
|
||||
Prefer narrow grants, such as a specific host, Git metadata access, or write path, over `allow_all_hosts`,
|
||||
`allow_fs_write_all`, or `allow_unsandboxed`.
|
||||
Prefer narrow grants, such as a specific host or write path, over `allow_all_hosts`, `allow_fs_write_all`, or
|
||||
`allow_unsandboxed`.
|
||||
|
||||
## Git Metadata {#git-metadata}
|
||||
|
||||
Git metadata writes are not grantable while a terminal command is sandboxed. This includes writes to `.git` directories,
|
||||
linked worktree metadata, refs, the index, hooks, local Git config, and other Git-controlled metadata files. Approving a
|
||||
specific writable path or `allow_fs_write_all` does not make Git metadata writable.
|
||||
|
||||
When a Git command genuinely needs to update Git metadata, such as `git commit`, `git fetch`, `git checkout`, or `git
|
||||
rebase`, approve unsandboxed execution instead. For read-only operations, prefer Git flags that avoid optional metadata
|
||||
writes when possible. For example, use `git --no-optional-locks status` instead of `git status`.
|
||||
|
||||
## Platform Support {#platform-support}
|
||||
|
||||
|
|
@ -111,7 +117,8 @@ Sandboxed terminal commands:
|
|||
- can read the filesystem
|
||||
- can write inside open project directories, except protected Git metadata
|
||||
- can write to a per-thread temporary directory exposed through `$TMPDIR`, `$TMP`, and `$TEMP`
|
||||
- cannot read or write protected Git metadata unless you approve Git metadata access
|
||||
- can read protected Git metadata
|
||||
- cannot write protected Git metadata, even if you approve broader write access
|
||||
- cannot write elsewhere unless you approve additional paths or broader write access
|
||||
- cannot reach the network unless you approve network access
|
||||
|
||||
|
|
@ -133,7 +140,7 @@ Sandboxed terminal commands:
|
|||
- can read the filesystem, including protected Git metadata contents
|
||||
- can write inside open project directories, except protected Git metadata
|
||||
- can write to `/tmp`, which is backed by a fresh temporary filesystem and is cleared between terminal tool calls
|
||||
- cannot write protected Git metadata unless you approve Git metadata access
|
||||
- cannot write protected Git metadata
|
||||
- cannot write elsewhere unless you approve additional paths or broader write access
|
||||
- cannot reach the network unless you approve network access
|
||||
|
||||
|
|
@ -155,9 +162,9 @@ command cannot be confined by Zed Agent's OS sandbox in the same way.
|
|||
When running inside WSL, the Linux sandboxing behavior applies, including the requirement that `bwrap` not be setuid-root:
|
||||
|
||||
- filesystem isolation is provided by Bubblewrap
|
||||
- protected Git metadata contents remain readable, but writes are blocked unless you approve Git metadata access
|
||||
- protected Git metadata contents remain readable, but writes are blocked
|
||||
- `/tmp` is temporary for sandboxed terminal calls
|
||||
- network access is all-or-nothing rather than host-specific
|
||||
- network access is all-or-nothing rather than host-specific, so host-specific network requests are rejected and the agent must request unrestricted network access when network access is needed
|
||||
|
||||
If WSL is not installed, or if you choose to run a command without the sandbox, Zed falls back to the standard terminal
|
||||
behavior of running in your native shell. It selects the shell using the usual preference order: Git Bash (or scoop's
|
||||
|
|
@ -170,8 +177,6 @@ rather than WSL's `/mnt/c/...`), so a command written for the sandboxed WSL shel
|
|||
When reviewing a sandbox prompt, prefer the narrowest permission that lets the task proceed:
|
||||
|
||||
- approve a specific host instead of all hosts when the destination is known
|
||||
- approve Git metadata access when the command needs to run Git operations such as `git fetch`, `git commit`, or
|
||||
`git status`
|
||||
- approve a specific write path instead of unrestricted filesystem writes
|
||||
- approve unsandboxed execution only when the command cannot work inside the sandbox
|
||||
- use one-time approvals for unfamiliar commands
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ When no path is provided, shows a summary of error and warning counts for all fi
|
|||
|
||||
Fetches a URL and returns the content as Markdown. Useful for providing docs as context.
|
||||
|
||||
`fetch` is governed by tool permissions, agent profiles, and project trust. It is not run inside the terminal OS sandbox, so terminal sandbox network grants such as `allow_hosts` and `allow_all_hosts` do not apply to it.
|
||||
|
||||
**Example:** Fetching a library's changelog page to check whether a breaking API change was introduced in a recent version before writing integration code.
|
||||
|
||||
### `find_path`
|
||||
|
|
|
|||
|
|
@ -31,11 +31,8 @@
|
|||
# writablePaths = [ ]; # writable subtrees when fs = "restricted"
|
||||
# networkAccess = "blocked"; # or "unrestricted" / "restricted"
|
||||
# allowedDomains = [ ]; # allowed hosts when networkAccess = "restricted"
|
||||
# gitDisabled = [ ]; # `.git` dirs whose contents are protected
|
||||
# # (read-only on Linux). Mutually exclusive with
|
||||
# # gitAllowed.
|
||||
# gitAllowed = [ ]; # `.git` dirs whose contents are made writable.
|
||||
# # Mutually exclusive with gitDisabled.
|
||||
# protectedPaths = [ ]; # paths that remain readable but not writable,
|
||||
# # even if they fall under a writable subtree.
|
||||
#
|
||||
# Two echo servers (`echo1`, `echo2`) on separate nodes give the network checks
|
||||
# real peers, so a restricted-network policy that allowlists `echo1` can be
|
||||
|
|
@ -237,136 +234,49 @@ in
|
|||
succeeds = true;
|
||||
}
|
||||
|
||||
# ---- Git protection ----------------------------------------------------
|
||||
# The worktree is writable, but the `.git` directory inside it can be
|
||||
# protected (Git disabled) or opened up (Git allowed) independently. On
|
||||
# Linux a protected `.git` is re-bound read-only *over* the writable
|
||||
# worktree, so its contents stay readable but writes are denied.
|
||||
|
||||
# Git disabled: the worktree is writable but `.git` is protected, so a
|
||||
# write into `.git` is denied. This is the core case the feature exists
|
||||
# for — an agent editing the project must not be able to corrupt Git
|
||||
# metadata.
|
||||
# ---- Protected paths ---------------------------------------------------
|
||||
# A path inside a writable subtree can be re-bound read-only over the
|
||||
# writable bind (order matters: later binds win). On Linux, protected paths
|
||||
# remain readable but writes are denied.
|
||||
{
|
||||
fs = "restricted";
|
||||
writablePaths = [ "/sandbox-test/repo" ];
|
||||
gitDisabled = [ "/sandbox-test/repo/.git" ];
|
||||
protectedPaths = [ "/sandbox-test/repo/protected" ];
|
||||
networkAccess = "blocked";
|
||||
write = "/sandbox-test/repo/.git/test";
|
||||
write = "/sandbox-test/repo/protected/test";
|
||||
succeeds = false;
|
||||
}
|
||||
|
||||
# Git allowed: the same write into `.git` now succeeds because the policy
|
||||
# makes `.git` writable.
|
||||
# Protected paths affect only the protected subtree: ordinary writes
|
||||
# elsewhere in the writable worktree are unaffected.
|
||||
{
|
||||
fs = "restricted";
|
||||
writablePaths = [ "/sandbox-test/repo" ];
|
||||
gitAllowed = [ "/sandbox-test/repo/.git" ];
|
||||
networkAccess = "blocked";
|
||||
write = "/sandbox-test/repo/.git/test";
|
||||
succeeds = true;
|
||||
}
|
||||
|
||||
# Git disabled protects only `.git`: ordinary writes elsewhere in the
|
||||
# writable worktree are unaffected.
|
||||
{
|
||||
fs = "restricted";
|
||||
writablePaths = [ "/sandbox-test/repo" ];
|
||||
gitDisabled = [ "/sandbox-test/repo/.git" ];
|
||||
protectedPaths = [ "/sandbox-test/repo/protected" ];
|
||||
networkAccess = "blocked";
|
||||
write = "/sandbox-test/repo/src/main.rs";
|
||||
succeeds = true;
|
||||
}
|
||||
|
||||
# Git disabled is a subtree protection: a write to something nested deep
|
||||
# inside `.git` is denied too, not just a top-level file.
|
||||
# Protected paths block writes but NOT reads on Linux because the protected
|
||||
# subtree is read-only.
|
||||
{
|
||||
fs = "restricted";
|
||||
writablePaths = [ "/sandbox-test/repo" ];
|
||||
gitDisabled = [ "/sandbox-test/repo/.git" ];
|
||||
protectedPaths = [ "/sandbox-test/repo/protected" ];
|
||||
networkAccess = "blocked";
|
||||
write = "/sandbox-test/repo/.git/hooks/pre-commit";
|
||||
succeeds = false;
|
||||
}
|
||||
|
||||
# Git disabled blocks writes but NOT reads: on Linux a protected `.git` is
|
||||
# read-only, so its contents remain readable from inside the sandbox
|
||||
# (Git status, log, diff, … must keep working).
|
||||
{
|
||||
fs = "restricted";
|
||||
writablePaths = [ "/sandbox-test/repo" ];
|
||||
gitDisabled = [ "/sandbox-test/repo/.git" ];
|
||||
networkAccess = "blocked";
|
||||
read = "/sandbox-test/repo/.git/HEAD";
|
||||
read = "/sandbox-test/repo/protected/HEAD";
|
||||
succeeds = true;
|
||||
}
|
||||
|
||||
# Git allowed grants writes to `.git` on its own, even when the worktree
|
||||
# itself is not in `writablePaths`. The policy's Git dirs are made
|
||||
# writable directly.
|
||||
{
|
||||
fs = "restricted";
|
||||
writablePaths = [ ];
|
||||
gitAllowed = [ "/sandbox-test/repo/.git" ];
|
||||
networkAccess = "blocked";
|
||||
write = "/sandbox-test/repo/.git/test";
|
||||
succeeds = true;
|
||||
}
|
||||
|
||||
# ...and that grant is scoped to `.git`: it does not make the surrounding
|
||||
# worktree writable. A write next to `.git` (not in `writablePaths`) is
|
||||
# still denied.
|
||||
{
|
||||
fs = "restricted";
|
||||
writablePaths = [ ];
|
||||
gitAllowed = [ "/sandbox-test/repo/.git" ];
|
||||
networkAccess = "blocked";
|
||||
write = "/sandbox-test/repo/README.md";
|
||||
succeeds = false;
|
||||
}
|
||||
|
||||
# Multiple repositories: each protected `.git` is independently denied.
|
||||
# This exercises the list handling — a write into the *second* repo's
|
||||
# `.git` must still be blocked.
|
||||
{
|
||||
fs = "restricted";
|
||||
writablePaths = [
|
||||
"/sandbox-test/repo-a"
|
||||
"/sandbox-test/repo-b"
|
||||
];
|
||||
gitDisabled = [
|
||||
"/sandbox-test/repo-a/.git"
|
||||
"/sandbox-test/repo-b/.git"
|
||||
];
|
||||
networkAccess = "blocked";
|
||||
write = "/sandbox-test/repo-b/.git/test";
|
||||
succeeds = false;
|
||||
}
|
||||
|
||||
# The fs escape hatch supersedes Git protection: when filesystem writes
|
||||
# are unrestricted there is no read-only bind to protect `.git`, so the
|
||||
# write succeeds. This documents that `gitDisabled` only has teeth under a
|
||||
# restricted filesystem policy.
|
||||
# Protected paths stay read-only even when ordinary filesystem writes are
|
||||
# otherwise unrestricted.
|
||||
{
|
||||
fs = "unrestricted";
|
||||
gitDisabled = [ "/sandbox-test/repo/.git" ];
|
||||
protectedPaths = [ "/sandbox-test/repo/protected" ];
|
||||
networkAccess = "blocked";
|
||||
write = "/sandbox-test/repo/.git/test";
|
||||
succeeds = true;
|
||||
}
|
||||
|
||||
# Documented Linux gap: a `.git` that does not yet exist when the sandbox
|
||||
# is built cannot be re-bound read-only, so it is skipped. Writing the
|
||||
# `.git` entry itself (its parent worktree is writable, and `.git` does
|
||||
# not exist beforehand) therefore succeeds. macOS denies this even before
|
||||
# `.git` exists; bwrap cannot, and this test pins that difference.
|
||||
{
|
||||
fs = "restricted";
|
||||
writablePaths = [ "/sandbox-test/fresh-repo" ];
|
||||
gitDisabled = [ "/sandbox-test/fresh-repo/.git" ];
|
||||
networkAccess = "blocked";
|
||||
write = "/sandbox-test/fresh-repo/.git";
|
||||
succeeds = true;
|
||||
write = "/sandbox-test/repo/protected/test";
|
||||
succeeds = false;
|
||||
}
|
||||
|
||||
# Blocked network: the echo server is unreachable.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue