agent: Re-enable sandbox (#61711)
Fixes the bug that made us remove the sandbox. The bug in question was very dumb: - there is sophisticated machinery for detecting whether a user-granted writable path is swapped out for a symlink in the timing gap between approval and sandbox creation - there was no equivalent machinery to do the same for the (much larger) gap between a user *persisting an approval* (either for the current thread or permanently via settings) - The fix is essentially to store canonical (i.e. absolute and symlink-free at all depths) paths as the source of truth, but retain the raw path for display purposes - On WSL, there is extra care needed becasue of the bidirectional mounting (i.e. `/mnt/c/...` and `\\wsl.localhost\Ubuntu\...`). In particular, `/mnt/c/...` paths, since their inodes do not necessarily pin NTFS file references, weaken the sandbox guarantees, and so we need some extra UI to call this out and docs etc... This also does not remove the feature flag, but just toggles it to "enabled_for_all" --- Release Notes: - N/A or Added/Fixed/Improved ... --------- Co-authored-by: Richard Feldman <oss@rtfeldman.com> Co-authored-by: Jakub Konka <kubkon@jakubkonka.com> Co-authored-by: Danilo Leal <daniloleal09@gmail.com> Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
This commit is contained in:
30 files changed
+3074
-604
No files matched your search
Generated
+1
@@ -16149,6 +16149,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures 0.3.32",
|
||||
"gpui",
|
||||
"http_proxy",
|
||||
"libc",
|
||||
"log",
|
||||
|
||||
@@ -182,7 +182,12 @@ pub struct SandboxAuthorizationDetails {
|
||||
#[serde(default)]
|
||||
pub unsandboxed: bool,
|
||||
#[serde(default)]
|
||||
pub write_paths: Vec<PathBuf>,
|
||||
pub write_paths: Vec<settings::GrantedWritePath>,
|
||||
/// Windows/WSL only: the command will write to a path on a Windows drive
|
||||
/// (DrvFs), whose sandbox-integrity guarantees are weaker. Drives the
|
||||
/// weaker-guarantee warning banner in the approval prompt.
|
||||
#[serde(default)]
|
||||
pub warn_windows_fs: bool,
|
||||
/// The agent-provided justification for requesting these permissions,
|
||||
/// shown to the user (attributed to the agent) in the approval prompt.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -43,7 +43,12 @@ pub struct SandboxWrap {
|
||||
/// to make the trust boundary explicit: these originate from
|
||||
/// model-requested paths that passed a user-approval prompt. They are
|
||||
/// merged with `writable_paths` when generating the sandbox policy.
|
||||
pub extra_write_paths: Vec<PathBuf>,
|
||||
///
|
||||
/// Each grant carries the canonical target it resolved to at approval
|
||||
/// time; enforcement rebuilds the location via a verifying reopen (see
|
||||
/// [`granted_write_path_to_location`]) rather than re-resolving the bare
|
||||
/// requested path, which closes a symlink TOCTOU.
|
||||
pub extra_write_paths: Vec<settings::GrantedWritePath>,
|
||||
/// Outbound network access explicitly approved for this command.
|
||||
pub network: SandboxNetworkAccess,
|
||||
/// Additional paths that should remain readable but not writable, even when
|
||||
@@ -156,6 +161,51 @@ impl LinuxWslSandboxError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild a user-approved write grant into an enforceable
|
||||
/// [`sandbox::HostFilesystemLocation`].
|
||||
///
|
||||
/// When the grant carries a resolved canonical (the normal case, established at
|
||||
/// approval time), the location is rebuilt via a verifying
|
||||
/// [`sandbox::HostFilesystemLocation::reopen`] — the load-bearing step of the
|
||||
/// TOCTOU fix. A legacy bare-string grant (no resolved canonical) falls back to
|
||||
/// a fresh [`sandbox::HostFilesystemLocation::capture`].
|
||||
pub fn granted_write_path_to_location(
|
||||
granted: &settings::GrantedWritePath,
|
||||
) -> std::io::Result<sandbox::HostFilesystemLocation> {
|
||||
match &granted.resolved {
|
||||
Some(resolved) => sandbox::HostFilesystemLocation::reopen(&granted.requested, resolved),
|
||||
None => sandbox::HostFilesystemLocation::capture(&granted.requested),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild a grant for enforcement, or log and drop it (fail-closed) if it
|
||||
/// can't be verified.
|
||||
///
|
||||
/// A failure here is frequently the symlink-TOCTOU defense firing: the grant's
|
||||
/// canonical was redirected or replaced by a symlink since approval, so
|
||||
/// [`sandbox::HostFilesystemLocation::reopen`] refuses it. That is a
|
||||
/// security-relevant event, so it must be logged rather than silently
|
||||
/// swallowed. The grant is dropped (the command runs without it) rather than
|
||||
/// bound unverified.
|
||||
///
|
||||
/// Only for **display** policies (the sandbox-status UI), where a stale grant
|
||||
/// should simply not be shown. Enforcement must not drop grants silently — a
|
||||
/// command would run with less access than the user approved with no signal —
|
||||
/// so [`SandboxWrap::to_policy`] uses the erroring
|
||||
/// [`granted_write_path_to_location`] instead.
|
||||
pub fn granted_write_path_to_location_or_log(
|
||||
granted: &settings::GrantedWritePath,
|
||||
) -> Option<sandbox::HostFilesystemLocation> {
|
||||
granted_write_path_to_location(granted)
|
||||
.inspect_err(|error| {
|
||||
log::warn!(
|
||||
"dropping sandbox write grant {}: {error}",
|
||||
granted.requested.display()
|
||||
);
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
impl SandboxWrap {
|
||||
/// Whether the OS sandbox for this request can actually be created right now,
|
||||
/// returning a structured [`LinuxWslSandboxError`] when it can't.
|
||||
@@ -167,43 +217,88 @@ impl SandboxWrap {
|
||||
/// Linux, so call it off the main thread. On platforms whose sandbox can't
|
||||
/// fail to set up this way it always returns `Ok`.
|
||||
pub fn can_create_sandbox(&self) -> Result<(), LinuxWslSandboxError> {
|
||||
sandbox::Sandbox::can_create(&self.to_policy()).map_err(LinuxWslSandboxError::from)
|
||||
let policy = self
|
||||
.to_policy()
|
||||
.map_err(|error| LinuxWslSandboxError::Other(format!("{error:#}")))?;
|
||||
sandbox::Sandbox::can_create(&policy).map_err(LinuxWslSandboxError::from)
|
||||
}
|
||||
|
||||
/// Translate this request into the cross-platform [`sandbox::SandboxPolicy`].
|
||||
///
|
||||
/// This is the enforcement-policy construction point, so it **captures** each
|
||||
/// grant as a [`sandbox::HostFilesystemLocation`] (pinning the inode / canonical
|
||||
/// 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.
|
||||
/// path) rather than passing a re-resolvable path.
|
||||
///
|
||||
/// This function has **no filesystem side effects**: it never creates paths,
|
||||
/// and it **fails** (rather than silently narrowing the policy) when a
|
||||
/// writable path or approved grant can't be captured — running anyway would
|
||||
/// give the command silently less access than the model and user were told
|
||||
/// it has. On Linux a writable grant that doesn't exist can't be captured
|
||||
/// (bwrap can't bind a missing path); the sanctioned way to get a grant to a
|
||||
/// new directory is the `create_directory` tool, which creates it (pinning
|
||||
/// the inode) before the grant is recorded. On macOS a missing leaf still
|
||||
/// canonicalizes, so such grants are captured directly.
|
||||
///
|
||||
/// This function has **no filesystem side effects**: it never creates paths.
|
||||
/// It is used both by the side-effect-free [`Self::can_create_sandbox`] probe
|
||||
/// and by real sandbox construction, and must behave identically. On Linux a
|
||||
/// writable grant that doesn't exist yet simply can't be captured (bwrap
|
||||
/// can't bind a missing path), so it's dropped here — the sanctioned way to
|
||||
/// get a grant to a new directory is the `create_directory` tool, which
|
||||
/// creates it (pinning the inode) before the grant is recorded. On macOS a
|
||||
/// missing leaf still canonicalizes, so such grants are captured directly.
|
||||
fn to_policy(&self) -> sandbox::SandboxPolicy {
|
||||
/// and by real sandbox construction, and must behave identically.
|
||||
///
|
||||
/// A grant failure here can also be the symlink-TOCTOU defense firing: the
|
||||
/// grant's canonical was redirected or replaced by a symlink since approval,
|
||||
/// so the verifying reopen refuses it. Failing the command surfaces that
|
||||
/// security-relevant event instead of running with the grant quietly
|
||||
/// missing.
|
||||
///
|
||||
/// Protected paths, by contrast, are **best-effort**: we protect only the
|
||||
/// ones that exist at creation time (`capture` succeeding *is* the existence
|
||||
/// check), and silently drop the rest. Unlike a writable grant, a protection
|
||||
/// can't be materialized — you can't pin the inode of a path that isn't
|
||||
/// there — and there is an inherent, *accepted* loophole regardless: a
|
||||
/// command in a non-git directory can `git init` and write hooks into a
|
||||
/// `.git` that didn't exist when the sandbox was built. Since the protection
|
||||
/// is defeatable that way no matter what, failing sandbox creation over a
|
||||
/// currently-absent (or otherwise uncapturable) `.git` would only break
|
||||
/// legitimate cases — non-git projects, single-file worktrees whose
|
||||
/// synthesized `settings.json/.git` routes through a file — without closing
|
||||
/// the hole. So we drop and move on.
|
||||
fn to_policy(&self) -> Result<sandbox::SandboxPolicy> {
|
||||
let protected_paths = self
|
||||
.protected_paths
|
||||
.iter()
|
||||
.filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok())
|
||||
.collect();
|
||||
.filter_map(|path| sandbox::HostFilesystemLocation::capture(path).ok())
|
||||
.collect::<Vec<_>>();
|
||||
let fs = if self.allow_fs_write {
|
||||
sandbox::SandboxFsPolicy::Unrestricted { protected_paths }
|
||||
} else {
|
||||
let writable_paths = self
|
||||
.writable_paths
|
||||
.iter()
|
||||
.chain(self.extra_write_paths.iter())
|
||||
// Capture only — never create anything here (see the doc comment):
|
||||
// materializing an approved-but-missing grant is deferred to
|
||||
// `Sandbox::new` so it can never happen during the `can_create`
|
||||
// probe, before the user has approved the grant.
|
||||
.filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok())
|
||||
.collect();
|
||||
// Project worktree paths are captured fresh; user-approved grants are
|
||||
// rebuilt via the verifying reopen (or captured when legacy bare
|
||||
// strings) through `granted_write_path_to_location`. A path that
|
||||
// can't be captured fails the whole construction (never created).
|
||||
let mut locations = Vec::new();
|
||||
for path in &self.writable_paths {
|
||||
let location = sandbox::HostFilesystemLocation::capture(path).map_err(|error| {
|
||||
anyhow::anyhow!(error).context(format!(
|
||||
"cannot capture writable sandbox path `{}`",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
locations.push(location);
|
||||
}
|
||||
for granted in &self.extra_write_paths {
|
||||
let location = granted_write_path_to_location(granted).map_err(|error| {
|
||||
anyhow::anyhow!(error).context(format!(
|
||||
"cannot re-verify approved sandbox write grant `{}` (if the \
|
||||
directory was removed, remove the grant or recreate the \
|
||||
directory)",
|
||||
granted.requested.display()
|
||||
))
|
||||
})?;
|
||||
locations.push(location);
|
||||
}
|
||||
// Dedupe to a minimal cover on the captured canonical paths, so a
|
||||
// grant nested under a worktree root (or another grant) is dropped
|
||||
// rather than bound redundantly.
|
||||
let writable_paths =
|
||||
sandbox::normalize_host_filesystem_locations(locations.into_iter());
|
||||
sandbox::SandboxFsPolicy::Restricted {
|
||||
writable_paths,
|
||||
protected_paths,
|
||||
@@ -220,7 +315,7 @@ impl SandboxWrap {
|
||||
.collect(),
|
||||
},
|
||||
};
|
||||
sandbox::SandboxPolicy { fs, network }
|
||||
Ok(sandbox::SandboxPolicy { fs, network })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +377,7 @@ pub(crate) async fn prepare_sandbox_wrap(
|
||||
};
|
||||
|
||||
let mut sandbox =
|
||||
sandbox::Sandbox::new(sandbox_wrap.to_policy()).map_err(anyhow::Error::new)?;
|
||||
sandbox::Sandbox::new(sandbox_wrap.to_policy()?).map_err(anyhow::Error::new)?;
|
||||
// Windows/WSL only: tell the sandbox which Linux `zed` to provision inside
|
||||
// WSL as its `--wsl-sandbox-helper`. A no-op (and a no-op setter) elsewhere.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -570,3 +665,90 @@ pub async fn create_terminal_entity(
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod linux_tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression test for the bug where enforcement-policy construction
|
||||
/// *created* missing write grants — famously turning a granted
|
||||
/// `~/.config/zed/AGENTS.md` file path into a directory. A grant whose
|
||||
/// target no longer exists must fail policy construction with an error
|
||||
/// naming it, and nothing may be created — a required safety grant that
|
||||
/// can't be honored must stop the command, not silently shrink its access.
|
||||
#[test]
|
||||
fn to_policy_fails_on_missing_grant_and_never_creates_it() {
|
||||
let temp_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let missing = temp_dir.path().join("AGENTS.md");
|
||||
|
||||
let wrap = SandboxWrap {
|
||||
extra_write_paths: vec![settings::GrantedWritePath::resolved(
|
||||
missing.clone(),
|
||||
missing.clone(),
|
||||
)],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let error = wrap
|
||||
.to_policy()
|
||||
.expect_err("a grant to a missing path must fail policy construction");
|
||||
assert!(
|
||||
format!("{error:#}").contains("AGENTS.md"),
|
||||
"error should name the failing grant: {error:#}"
|
||||
);
|
||||
assert!(
|
||||
!missing.exists(),
|
||||
"policy construction must never create the granted path"
|
||||
);
|
||||
}
|
||||
|
||||
/// A baseline writable path (worktree root / scratch dir) that doesn't
|
||||
/// exist must also fail: silently narrowing the sandbox would hand the
|
||||
/// command less access than the model was told it has.
|
||||
#[test]
|
||||
fn to_policy_fails_on_missing_writable_path() {
|
||||
let temp_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let missing = temp_dir.path().join("gone");
|
||||
|
||||
let wrap = SandboxWrap {
|
||||
writable_paths: vec![missing.clone()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
wrap.to_policy()
|
||||
.expect_err("a missing writable path must fail policy construction");
|
||||
assert!(
|
||||
!missing.exists(),
|
||||
"policy construction must never create a writable path"
|
||||
);
|
||||
}
|
||||
|
||||
/// Protected paths are best-effort: an uncapturable one is dropped, never
|
||||
/// fatal. That covers a missing path (`NotFound`) and one routed through a
|
||||
/// regular file (`NotADirectory`) — the latter is the synthesized `.git` of
|
||||
/// a single-file worktree (e.g. `settings.json/.git`). Unlike a writable
|
||||
/// grant, a protection can't be materialized, and `.git` protection has an
|
||||
/// inherent accepted loophole (`git init`), so failing here would only break
|
||||
/// legitimate cases. Unit-level companion to the `settings.json/.git` NixOS
|
||||
/// check.
|
||||
#[test]
|
||||
fn to_policy_skips_uncapturable_protected_paths() {
|
||||
let temp_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let writable = temp_dir.path().join("writable");
|
||||
std::fs::create_dir(&writable).expect("create writable dir");
|
||||
let single_file_root = temp_dir.path().join("settings.json");
|
||||
std::fs::write(&single_file_root, b"{}").expect("create single-file worktree root");
|
||||
|
||||
let wrap = SandboxWrap {
|
||||
writable_paths: vec![writable],
|
||||
protected_paths: vec![
|
||||
temp_dir.path().join("no-such-.git"),
|
||||
single_file_root.join(".git"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
wrap.to_policy()
|
||||
.expect("uncapturable protected paths must be dropped, not fail the policy");
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -93,9 +93,13 @@ pub struct DbThread {
|
||||
/// thread blob; round-trips with [`crate::sandboxing::ThreadSandboxGrants`].
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DbSandboxGrants {
|
||||
/// Canonicalized paths granted write access; each covers its whole subtree.
|
||||
/// Paths granted write access, each paired with the canonical
|
||||
/// (symlink-resolved) target established when the grant was approved; each
|
||||
/// covers its whole subtree. Legacy rows stored a bare path string per
|
||||
/// entry, which still deserializes (as a grant with no resolved canonical)
|
||||
/// via [`settings::GrantedWritePath`]'s string-or-object format.
|
||||
#[serde(default)]
|
||||
pub write_paths: Vec<PathBuf>,
|
||||
pub write_paths: Vec<settings::GrantedWritePath>,
|
||||
/// Host patterns granted network access, in canonical string form (e.g.
|
||||
/// `github.com`, `*.npmjs.org`). Parsed back into patterns on load.
|
||||
#[serde(default)]
|
||||
@@ -947,7 +951,16 @@ mod tests {
|
||||
Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
|
||||
);
|
||||
let grants = DbSandboxGrants {
|
||||
write_paths: vec![PathBuf::from("/tmp/build")],
|
||||
write_paths: vec![
|
||||
// A legacy bare-string grant (no resolved canonical) and a grant
|
||||
// carrying its resolved canonical, to exercise both forms of the
|
||||
// string-or-object round-trip.
|
||||
settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/build")),
|
||||
settings::GrantedWritePath::resolved(
|
||||
PathBuf::from("/tmp/link"),
|
||||
PathBuf::from("/tmp/real"),
|
||||
),
|
||||
],
|
||||
network_hosts: vec!["github.com".to_string(), "*.npmjs.org".to_string()],
|
||||
network_any_host: false,
|
||||
allow_fs_write_all: false,
|
||||
|
||||
+105
-43
@@ -31,7 +31,7 @@ use gpui::App;
|
||||
use http_proxy::HostPattern;
|
||||
use project::Project;
|
||||
use sandbox::{HostFilesystemLocation, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy};
|
||||
use settings::Settings;
|
||||
use settings::{GrantedWritePath, Settings};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The directory subtrees the sandbox always grants write access to for a
|
||||
@@ -58,6 +58,14 @@ pub fn sandbox_git_dirs(project: &Project, cx: &App) -> Vec<PathBuf> {
|
||||
let worktree_abs_path = worktree.abs_path();
|
||||
// Protect `<worktree>/.git` even when it doesn't exist yet, so a command
|
||||
// can't `git init` and then write to the freshly created metadata.
|
||||
//
|
||||
// We don't gate this on the worktree's scanned root being a directory:
|
||||
// that state can be stale/pending, and for a *single-file* worktree
|
||||
// (e.g. `settings.json` opened on its own) this synthesizes
|
||||
// `settings.json/.git`, which can never exist. That impossible path is
|
||||
// resolved authoritatively at capture time — the real filesystem
|
||||
// reports `NotADirectory`, and `SandboxWrap::to_policy` skips a
|
||||
// protected path that can't exist — so it never reaches enforcement.
|
||||
git_dirs.push(worktree_abs_path.join(".git"));
|
||||
if let Some(root_repo_common_dir) = worktree.root_repo_common_dir() {
|
||||
git_dirs.push(root_repo_common_dir.to_path_buf());
|
||||
@@ -125,7 +133,7 @@ impl ThreadSandbox {
|
||||
// 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())
|
||||
.filter_map(|path| HostFilesystemLocation::capture(path).ok())
|
||||
.collect();
|
||||
ThreadSandbox::Sandboxed(policy.with_protected_paths(protected_paths))
|
||||
}
|
||||
@@ -156,11 +164,17 @@ pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy
|
||||
}
|
||||
} else {
|
||||
SandboxFsPolicy::Restricted {
|
||||
writable_paths: persistent
|
||||
.write_paths
|
||||
.iter()
|
||||
.filter_map(|path| HostFilesystemLocation::new(path).ok())
|
||||
.collect(),
|
||||
// Route each grant through the verifying reopen (or a fresh capture
|
||||
// for legacy bare-string grants) so the enforced path is the one
|
||||
// vetted at approval time. A grant that fails to reopen is logged
|
||||
// and dropped (fail-closed) rather than silently swallowed, then the
|
||||
// survivors are reduced to a minimal cover on their canonical paths.
|
||||
writable_paths: sandbox::normalize_host_filesystem_locations(
|
||||
persistent
|
||||
.write_paths
|
||||
.iter()
|
||||
.filter_map(acp_thread::granted_write_path_to_location_or_log),
|
||||
),
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
};
|
||||
@@ -264,9 +278,10 @@ pub(crate) struct SandboxRequest {
|
||||
pub allow_fs_write_all: bool,
|
||||
/// Run the command fully outside the sandbox.
|
||||
pub unsandboxed: bool,
|
||||
/// Concrete paths the command needs to write to. Each grants its whole
|
||||
/// Concrete paths the command needs to write to, each paired with the
|
||||
/// canonical target resolved at approval time. Each grants its whole
|
||||
/// subtree. These are never globs — write access is always a concrete path subtree
|
||||
pub write_paths: Vec<PathBuf>,
|
||||
pub write_paths: Vec<GrantedWritePath>,
|
||||
}
|
||||
|
||||
impl SandboxRequest {
|
||||
@@ -302,9 +317,10 @@ pub(crate) struct ThreadSandboxGrants {
|
||||
/// `unsandboxed`, which records a model-requested escape; this is a
|
||||
/// user-acknowledged degradation because the sandbox is unavailable.
|
||||
sandbox_fallback: bool,
|
||||
/// Canonicalized paths granted write access for the thread. Each covers its
|
||||
/// whole subtree; redundant children are pruned on insert.
|
||||
write_paths: Vec<PathBuf>,
|
||||
/// Paths granted write access for the thread, each paired with the canonical
|
||||
/// target resolved at approval time. Each covers its whole subtree; redundant
|
||||
/// children are pruned on insert (by canonical target).
|
||||
write_paths: Vec<GrantedWritePath>,
|
||||
}
|
||||
|
||||
impl ThreadSandboxGrants {
|
||||
@@ -344,13 +360,13 @@ impl ThreadSandboxGrants {
|
||||
if self.allow_fs_write_all || persistent.allow_fs_write_all {
|
||||
return true;
|
||||
}
|
||||
request.write_paths.iter().all(|requested| {
|
||||
request.write_paths.iter().all(|request| {
|
||||
util::paths::path_within_subtree(
|
||||
requested,
|
||||
request.canonical_or_requested(),
|
||||
self.write_paths
|
||||
.iter()
|
||||
.chain(persistent.write_paths.iter())
|
||||
.map(PathBuf::as_path),
|
||||
.map(|granted| granted.canonical_or_requested()),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -426,11 +442,17 @@ impl ThreadSandboxGrants {
|
||||
}
|
||||
} else {
|
||||
SandboxFsPolicy::Restricted {
|
||||
writable_paths: self
|
||||
.write_paths
|
||||
.iter()
|
||||
.filter_map(|path| HostFilesystemLocation::new(path).ok())
|
||||
.collect(),
|
||||
// Route each grant through the verifying reopen (or a fresh
|
||||
// capture for legacy bare-string grants) so the enforced path is
|
||||
// the one vetted at approval time. A grant that fails to reopen
|
||||
// is logged and dropped (fail-closed) rather than silently
|
||||
// swallowed, then the survivors are reduced to a minimal cover
|
||||
// on their canonical paths.
|
||||
writable_paths: sandbox::normalize_host_filesystem_locations(
|
||||
self.write_paths
|
||||
.iter()
|
||||
.filter_map(acp_thread::granted_write_path_to_location_or_log),
|
||||
),
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
};
|
||||
@@ -505,8 +527,8 @@ impl ThreadSandboxGrants {
|
||||
}
|
||||
self.allow_fs_write_all |= request.allow_fs_write_all;
|
||||
self.unsandboxed |= request.unsandboxed;
|
||||
for path in &request.write_paths {
|
||||
util::paths::insert_subtree(&mut self.write_paths, path.clone());
|
||||
for granted in &request.write_paths {
|
||||
insert_granted_subtree(&mut self.write_paths, granted.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,8 +571,8 @@ impl ThreadSandboxGrants {
|
||||
};
|
||||
|
||||
let mut write_paths = persistent.write_paths.clone();
|
||||
for path in self.write_paths.iter().chain(request.write_paths.iter()) {
|
||||
util::paths::insert_subtree(&mut write_paths, path.clone());
|
||||
for granted in self.write_paths.iter().chain(request.write_paths.iter()) {
|
||||
insert_granted_subtree(&mut write_paths, granted.clone());
|
||||
}
|
||||
SandboxRequest {
|
||||
network,
|
||||
@@ -581,6 +603,26 @@ fn parse_persistent_hosts(raw: &[String]) -> Vec<HostPattern> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Insert `granted` into a write-grant set, keeping it minimal by comparing each
|
||||
/// entry's canonical (resolved) target. Mirrors [`util::paths::insert_subtree`]:
|
||||
/// skip the new grant if an existing entry's canonical is an ancestor-or-equal
|
||||
/// of it, and prune existing entries whose canonical descends from the new one.
|
||||
fn insert_granted_subtree(subtrees: &mut Vec<GrantedWritePath>, granted: GrantedWritePath) {
|
||||
if subtrees.iter().any(|existing| {
|
||||
granted
|
||||
.canonical_or_requested()
|
||||
.starts_with(existing.canonical_or_requested())
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
subtrees.retain(|existing| {
|
||||
!existing
|
||||
.canonical_or_requested()
|
||||
.starts_with(granted.canonical_or_requested())
|
||||
});
|
||||
subtrees.push(granted);
|
||||
}
|
||||
|
||||
/// Insert `pattern` into a host-pattern set, keeping it minimal: skip it if an
|
||||
/// existing entry already subsumes it, and drop existing entries it subsumes.
|
||||
/// The host-pattern analogue of [`util::paths::insert_subtree`].
|
||||
@@ -610,10 +652,28 @@ mod tests {
|
||||
network,
|
||||
allow_fs_write_all: all,
|
||||
unsandboxed: false,
|
||||
write_paths: paths.iter().map(PathBuf::from).collect(),
|
||||
write_paths: granted(paths),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bare-string grants (no resolved canonical) for the lexical
|
||||
/// coverage/record/dedup tests, which never build a real policy.
|
||||
fn granted(paths: &[&str]) -> Vec<GrantedWritePath> {
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| GrantedWritePath::from_requested(PathBuf::from(p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A grant carrying the resolved canonical of a real directory, for the
|
||||
/// tests that build an actual policy (which reopens/captures real fds).
|
||||
fn resolved_grant(dir: &Path) -> GrantedWritePath {
|
||||
GrantedWritePath::resolved(
|
||||
dir.to_path_buf(),
|
||||
sandbox::resolve_canonical(dir).expect("resolve canonical temp dir"),
|
||||
)
|
||||
}
|
||||
|
||||
fn unsandboxed_request() -> SandboxRequest {
|
||||
SandboxRequest {
|
||||
network: NetworkRequest::None,
|
||||
@@ -636,7 +696,7 @@ mod tests {
|
||||
fs: SandboxFsPolicy::Restricted {
|
||||
writable_paths: paths
|
||||
.iter()
|
||||
.map(|p| HostFilesystemLocation::new(p).expect("capture temp dir"))
|
||||
.map(|p| HostFilesystemLocation::capture(p).expect("capture temp dir"))
|
||||
.collect(),
|
||||
protected_paths: Vec::new(),
|
||||
},
|
||||
@@ -734,18 +794,23 @@ mod tests {
|
||||
fn thread_grants_to_policy_maps_paths_and_domains() {
|
||||
use sandbox::{SandboxFsPolicy, SandboxNetPolicy};
|
||||
|
||||
// `to_policy` captures real `HostFilesystemLocation`s, so use a real dir.
|
||||
// `to_policy` reopens/captures real `HostFilesystemLocation`s, so use a
|
||||
// real dir and a grant carrying its resolved canonical.
|
||||
let build_dir = tempfile::tempdir().expect("create temp build dir");
|
||||
let build_path = build_dir.path().to_str().expect("utf-8 temp path");
|
||||
|
||||
let mut grants = ThreadSandboxGrants::default();
|
||||
grants.record(&request(hosts(&["github.com"]), false, &[build_path]));
|
||||
grants.record(&SandboxRequest {
|
||||
network: hosts(&["github.com"]),
|
||||
allow_fs_write_all: false,
|
||||
unsandboxed: false,
|
||||
write_paths: vec![resolved_grant(build_dir.path())],
|
||||
});
|
||||
let policy = grants.to_policy();
|
||||
assert_eq!(
|
||||
policy.fs,
|
||||
SandboxFsPolicy::Restricted {
|
||||
writable_paths: vec![
|
||||
HostFilesystemLocation::new(build_dir.path()).expect("capture temp dir")
|
||||
HostFilesystemLocation::capture(build_dir.path()).expect("capture temp dir")
|
||||
],
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
@@ -788,7 +853,7 @@ mod tests {
|
||||
// `settings_sandbox_policy` captures real `HostFilesystemLocation`s.
|
||||
let log_dir = tempfile::tempdir().expect("create temp log dir");
|
||||
let persistent = SandboxPermissions {
|
||||
write_paths: vec![log_dir.path().to_path_buf()],
|
||||
write_paths: vec![resolved_grant(log_dir.path())],
|
||||
network_hosts: vec!["*.npmjs.org".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
@@ -797,7 +862,7 @@ mod tests {
|
||||
policy.fs,
|
||||
SandboxFsPolicy::Restricted {
|
||||
writable_paths: vec![
|
||||
HostFilesystemLocation::new(log_dir.path()).expect("capture temp dir")
|
||||
HostFilesystemLocation::capture(log_dir.path()).expect("capture temp dir")
|
||||
],
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
@@ -908,7 +973,7 @@ mod tests {
|
||||
let mut grants = ThreadSandboxGrants::default();
|
||||
grants.record(&request(NetworkRequest::None, false, &["/tmp/build/cache"]));
|
||||
grants.record(&request(NetworkRequest::None, false, &["/tmp/build"]));
|
||||
assert_eq!(grants.write_paths, vec![PathBuf::from("/tmp/build")]);
|
||||
assert_eq!(grants.write_paths, granted(&["/tmp/build"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -916,7 +981,7 @@ mod tests {
|
||||
let mut grants = ThreadSandboxGrants::default();
|
||||
grants.record(&request(NetworkRequest::None, false, &["/tmp/build"]));
|
||||
grants.record(&request(NetworkRequest::None, false, &["/tmp/build/cache"]));
|
||||
assert_eq!(grants.write_paths, vec![PathBuf::from("/tmp/build")]);
|
||||
assert_eq!(grants.write_paths, granted(&["/tmp/build"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1011,7 +1076,7 @@ mod tests {
|
||||
let mut grants = ThreadSandboxGrants::default();
|
||||
grants.record(&request(hosts(&["github.com"]), false, &[]));
|
||||
let persistent = SandboxPermissions {
|
||||
write_paths: vec![PathBuf::from("/tmp/build")],
|
||||
write_paths: granted(&["/tmp/build"]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -1103,7 +1168,7 @@ mod tests {
|
||||
grants.record(&request(NetworkRequest::None, false, &["/tmp/build"]));
|
||||
|
||||
let effective = effective(&grants, &request(NetworkRequest::None, false, &[]));
|
||||
assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/build")]);
|
||||
assert_eq!(effective.write_paths, granted(&["/tmp/build"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1118,10 +1183,7 @@ mod tests {
|
||||
&request(hosts(&["npmjs.org"]), false, &["/tmp/once"]),
|
||||
);
|
||||
assert_eq!(effective.network, hosts(&["github.com", "npmjs.org"]));
|
||||
assert_eq!(
|
||||
effective.write_paths,
|
||||
vec![PathBuf::from("/tmp/build"), PathBuf::from("/tmp/once")]
|
||||
);
|
||||
assert_eq!(effective.write_paths, granted(&["/tmp/build", "/tmp/once"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1138,14 +1200,14 @@ mod tests {
|
||||
let grants = ThreadSandboxGrants::default();
|
||||
let persistent = SandboxPermissions {
|
||||
allow_all_hosts: true,
|
||||
write_paths: vec![PathBuf::from("/tmp/always")],
|
||||
write_paths: granted(&["/tmp/always"]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let effective = grants
|
||||
.effective_with_persistent(&request(NetworkRequest::None, false, &[]), &persistent);
|
||||
assert_eq!(effective.network, NetworkRequest::AnyHost);
|
||||
assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/always")]);
|
||||
assert_eq!(effective.write_paths, granted(&["/tmp/always"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1157,6 +1219,6 @@ mod tests {
|
||||
&grants,
|
||||
&request(NetworkRequest::None, false, &["/tmp/build/cache"]),
|
||||
);
|
||||
assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/build")]);
|
||||
assert_eq!(effective.write_paths, granted(&["/tmp/build"]));
|
||||
}
|
||||
}
|
||||
@@ -44,10 +44,11 @@ pub struct SystemPromptTemplate<'a> {
|
||||
/// platform equivalent), if present and non-empty.
|
||||
pub user_agents_md: Option<SharedString>,
|
||||
/// Whether agent-run terminal commands are wrapped in an OS-level
|
||||
/// sandbox for this thread. When `true`, the rendered prompt
|
||||
/// describes the sandbox's read/write/network rules and the
|
||||
/// per-command flags the model can request to relax them. When
|
||||
/// `false`, the prompt omits the sandbox section entirely.
|
||||
/// sandbox for this thread. When `true` — and the `terminal` tool is
|
||||
/// in `available_tools` — the rendered prompt describes the sandbox's
|
||||
/// read/write/network rules and the per-command flags the model can
|
||||
/// request to relax them. Otherwise the prompt omits the sandbox
|
||||
/// section entirely.
|
||||
pub sandboxing: bool,
|
||||
/// Whether the host is Linux. The writable-temp story differs by
|
||||
/// platform (Linux exposes an ephemeral `tmpfs` over `/tmp`; other
|
||||
@@ -192,7 +193,7 @@ mod tests {
|
||||
let project = ProjectContext::new(worktrees);
|
||||
let template = SystemPromptTemplate {
|
||||
project: &project,
|
||||
available_tools: vec!["echo".into()],
|
||||
available_tools: vec!["echo".into(), "terminal".into()],
|
||||
model_name: Some("test-model".to_string()),
|
||||
date: "2026-01-01".to_string(),
|
||||
user_agents_md: None,
|
||||
@@ -216,6 +217,11 @@ mod tests {
|
||||
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"));
|
||||
// macOS tolerates granting a not-yet-existing path, so the
|
||||
// existing-directory requirement must not be stated there; the
|
||||
// `create_directory` flow is the preferred guidance instead.
|
||||
assert!(!rendered.contains("Each path must be an existing directory"));
|
||||
assert!(rendered.contains("first create it with the `create_directory` tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -230,7 +236,7 @@ mod tests {
|
||||
let project = ProjectContext::new(worktrees);
|
||||
let template = SystemPromptTemplate {
|
||||
project: &project,
|
||||
available_tools: vec!["echo".into()],
|
||||
available_tools: vec!["echo".into(), "terminal".into()],
|
||||
model_name: Some("test-model".to_string()),
|
||||
date: "2026-01-01".to_string(),
|
||||
user_agents_md: None,
|
||||
@@ -246,6 +252,9 @@ mod tests {
|
||||
assert!(!rendered.contains("$TMPDIR"));
|
||||
assert!(rendered.contains("`/tmp` is writable"));
|
||||
assert!(rendered.contains("`/tmp/alpha`"));
|
||||
// Linux write grants must already exist (bwrap binds existing paths).
|
||||
assert!(rendered.contains("Each path must be an existing directory"));
|
||||
assert!(rendered.contains("first create it with the `create_directory` tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -260,7 +269,7 @@ mod tests {
|
||||
let project = ProjectContext::new(worktrees);
|
||||
let template = SystemPromptTemplate {
|
||||
project: &project,
|
||||
available_tools: vec!["echo".into()],
|
||||
available_tools: vec!["echo".into(), "terminal".into()],
|
||||
model_name: Some("test-model".to_string()),
|
||||
date: "2026-01-01".to_string(),
|
||||
user_agents_md: None,
|
||||
@@ -277,10 +286,38 @@ mod tests {
|
||||
assert!(rendered.contains("such requests are rejected"));
|
||||
assert!(rendered.contains("allow_all_hosts: true"));
|
||||
assert!(rendered.contains("git --no-optional-locks status"));
|
||||
// Out-of-project `create_directory` grants aren't supported on Windows,
|
||||
// so the prompt must not recommend that flow; it suggests granting the
|
||||
// nearest existing parent instead.
|
||||
assert!(rendered.contains("Each path must be an existing directory"));
|
||||
assert!(rendered.contains("nearest existing parent directory"));
|
||||
assert!(!rendered.contains("first create it with the `create_directory` tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_sandbox_section_handles_zero_worktrees() {
|
||||
let project = prompt_store::ProjectContext::default();
|
||||
let template = SystemPromptTemplate {
|
||||
project: &project,
|
||||
available_tools: vec!["echo".into(), "terminal".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: false,
|
||||
};
|
||||
let templates = Templates::new();
|
||||
let rendered = template.render(&templates).unwrap();
|
||||
|
||||
assert!(rendered.contains("## Terminal sandbox"));
|
||||
assert!(rendered.contains("No project directories are currently writable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_omits_sandbox_section_when_terminal_tool_unavailable() {
|
||||
// A profile can disable the terminal tool entirely; the prompt must not
|
||||
// describe a sandboxed `terminal` tool the model doesn't have.
|
||||
let project = prompt_store::ProjectContext::default();
|
||||
let template = SystemPromptTemplate {
|
||||
project: &project,
|
||||
@@ -295,8 +332,8 @@ mod tests {
|
||||
let templates = Templates::new();
|
||||
let rendered = template.render(&templates).unwrap();
|
||||
|
||||
assert!(rendered.contains("## Terminal sandbox"));
|
||||
assert!(rendered.contains("No project directories are currently writable"));
|
||||
assert!(!rendered.contains("## Terminal sandbox"));
|
||||
assert!(!rendered.contains("allow_hosts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -154,6 +154,7 @@ The current project contains the following root directories:
|
||||
{{/each}}
|
||||
|
||||
{{#if sandboxing}}
|
||||
{{#if (contains available_tools 'terminal')}}
|
||||
## Terminal sandbox
|
||||
|
||||
The `terminal` tool runs commands inside a sandbox with these permissions:
|
||||
@@ -198,7 +199,7 @@ 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. Each path must be an existing directory. To write into a directory that doesn't exist yet, first create it with the `create_directory` tool (which creates it and grants write access to exactly that directory) rather than requesting write access to a broad existing parent. Git metadata paths cannot be requested and will never be made writable while sandboxed.
|
||||
- `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.{{#if is_windows}} Each path must be an existing directory; to write somewhere that doesn't exist yet, request write access to the nearest existing parent directory.{{else}}{{#if is_linux}} Each path must be an existing directory.{{/if}} To write into a directory that doesn't exist yet, first create it with the `create_directory` tool (which creates it and grants write access to exactly that directory) rather than requesting write access to a broad existing parent.{{/if}} 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.
|
||||
|
||||
@@ -208,6 +209,7 @@ The user will be prompted to approve before the command runs, and can grant a sa
|
||||
|
||||
These sandbox settings are guaranteed to remain in effect for the entire duration of this thread. If they ever change, you will be told.
|
||||
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if model_name}}
|
||||
## Model Information
|
||||
|
||||
@@ -283,9 +283,9 @@ fn always_allow_tools(cx: &mut TestAppContext) {
|
||||
|
||||
/// Turns terminal sandboxing off so the non-sandboxed `TerminalTool` is the
|
||||
/// variant exposed to the model as `terminal`. Tests that register
|
||||
/// `TerminalTool` directly need this because sandboxing is enabled by default
|
||||
/// for staff (and in debug builds), in which case `Thread::enabled_tools`
|
||||
/// would otherwise expose `SandboxedTerminalTool` under that name instead.
|
||||
/// `TerminalTool` directly need this because the sandboxing feature flag is
|
||||
/// enabled for all, in which case `Thread::enabled_tools` would otherwise
|
||||
/// expose `SandboxedTerminalTool` under that name instead.
|
||||
fn disable_sandboxing(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
@@ -7524,7 +7524,6 @@ async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext)
|
||||
/// 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]
|
||||
#[ignore]
|
||||
async fn test_fetch_tool_prompts_for_ungranted_host(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
@@ -7612,7 +7611,6 @@ async fn test_fetch_tool_granted_host_skips_prompt(cx: &mut TestAppContext) {
|
||||
|
||||
/// Loopback / IP-literal hosts can't be granted individually, so without
|
||||
/// unsandboxed access a fetch to them is refused with guidance to grant it.
|
||||
#[ignore]
|
||||
#[gpui::test]
|
||||
async fn test_fetch_tool_refuses_loopback_without_unsandboxed(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
@@ -7702,7 +7700,6 @@ async fn test_fetch_tool_unsandboxed_lifts_restrictions(cx: &mut TestAppContext)
|
||||
/// is refused just like a direct loopback fetch. This is the redirect variant of
|
||||
/// the SSRF protection — the approved domain can't be used to bounce the request
|
||||
/// onto the local machine.
|
||||
#[ignore]
|
||||
#[gpui::test]
|
||||
async fn test_fetch_tool_refuses_redirect_to_loopback(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
@@ -7761,7 +7758,6 @@ async fn test_fetch_tool_refuses_redirect_to_loopback(cx: &mut TestAppContext) {
|
||||
/// A granted host that redirects to a *different*, ungranted host triggers a
|
||||
/// fresh per-host authorization prompt for the redirect target — the redirect is
|
||||
/// not silently followed to a host the user never approved.
|
||||
#[ignore]
|
||||
#[gpui::test]
|
||||
async fn test_fetch_tool_reauthorizes_redirect_to_new_host(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
@@ -5793,6 +5793,10 @@ impl ToolCallEventStream {
|
||||
allow_fs_write_all: request.allow_fs_write_all,
|
||||
unsandboxed: request.unsandboxed,
|
||||
write_paths: request.write_paths.clone(),
|
||||
// The Windows-drive warning is a separate pre-prompt
|
||||
// (`authorize_windows_fs_warning`), never part of the escalation
|
||||
// prompt, so this stays off here.
|
||||
warn_windows_fs: false,
|
||||
reason,
|
||||
};
|
||||
let allow_thread_label = if self.is_subagent(cx) {
|
||||
@@ -5903,6 +5907,81 @@ impl ToolCallEventStream {
|
||||
})
|
||||
}
|
||||
|
||||
/// Confirm, before running a command whose sandbox will contain a Windows
|
||||
/// drive (DrvFs) path, that the user accepts the weaker integrity
|
||||
/// guarantees. This is a transient gate *in front of* the normal sandbox
|
||||
/// flow — it is never persisted or recorded as a grant; the only way to
|
||||
/// stop it recurring is to disable `warn_ntfs_grants` in settings (offered
|
||||
/// via the banner's gear). Returns `Ok(())` on "Continue" (after which the
|
||||
/// caller proceeds to any escalation prompt) and `Err` on "Abort".
|
||||
pub(crate) fn authorize_windows_fs_warning(&self, cx: &mut App) -> Task<Result<()>> {
|
||||
// If the warning is already disabled, don't prompt.
|
||||
if !AgentSettings::get_global(cx)
|
||||
.sandbox_permissions
|
||||
.warn_ntfs_grants
|
||||
{
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
|
||||
let details = acp_thread::SandboxAuthorizationDetails {
|
||||
command: None,
|
||||
network_hosts: Vec::new(),
|
||||
network_all_hosts: false,
|
||||
allow_fs_write_all: false,
|
||||
unsandboxed: false,
|
||||
write_paths: Vec::new(),
|
||||
warn_windows_fs: true,
|
||||
reason: String::new(),
|
||||
};
|
||||
let options = acp_thread::PermissionOptions::Flat(vec![
|
||||
acp::PermissionOption::new(
|
||||
acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowOnce.as_id()),
|
||||
"Continue",
|
||||
acp::PermissionOptionKind::AllowOnce,
|
||||
),
|
||||
acp::PermissionOption::new(
|
||||
acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()),
|
||||
"Abort",
|
||||
acp::PermissionOptionKind::RejectOnce,
|
||||
),
|
||||
]);
|
||||
|
||||
let stream = self.stream.clone();
|
||||
let tool_use_id = self.tool_use_id.clone();
|
||||
cx.spawn(async move |_cx| {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
if let Err(error) = stream
|
||||
.0
|
||||
.unbounded_send(Ok(ThreadEvent::ToolCallAuthorization(
|
||||
ToolCallAuthorization {
|
||||
tool_call: acp::ToolCallUpdate::new(
|
||||
tool_use_id.to_string(),
|
||||
acp::ToolCallUpdateFields::new(),
|
||||
)
|
||||
.meta(acp_thread::meta_with_sandbox_authorization(details)),
|
||||
options,
|
||||
response: response_tx,
|
||||
context: None,
|
||||
kind: acp_thread::AuthorizationKind::PermissionGrant,
|
||||
},
|
||||
)))
|
||||
{
|
||||
log::error!("Failed to send Windows-drive sandbox warning: {error}");
|
||||
return Err(anyhow!(
|
||||
"Failed to send Windows-drive sandbox warning: {error}"
|
||||
));
|
||||
}
|
||||
|
||||
let outcome = response_rx
|
||||
.await
|
||||
.map_err(|_| anyhow!("authorization channel closed"))?;
|
||||
match acp_thread::SandboxPermission::from_id(outcome.option_id.0.as_ref()) {
|
||||
Some(acp_thread::SandboxPermission::AllowOnce) => Ok(()),
|
||||
_ => Err(anyhow!("Windows-drive write aborted by user")),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn sandbox_request_covered_by_grants(
|
||||
request: &SandboxRequest,
|
||||
sandbox_grants: &Rc<RefCell<ThreadSandboxGrants>>,
|
||||
@@ -6001,8 +6080,15 @@ impl ToolCallEventStream {
|
||||
if request.unsandboxed {
|
||||
agent.allow_sandbox_unsandboxed();
|
||||
}
|
||||
for path in request.write_paths {
|
||||
agent.add_sandbox_write_path(path);
|
||||
for granted in request.write_paths {
|
||||
// Persist the full (requested, resolved-canonical) pair so a
|
||||
// persistent grant is rebuilt from the vetted canonical
|
||||
// across restarts, not re-resolved by path string.
|
||||
agent.add_sandbox_write_path(settings::GrantedWritePathContent {
|
||||
requested: granted.requested,
|
||||
resolved: granted.resolved,
|
||||
on_windows_fs: granted.on_windows_fs,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7947,10 +8033,10 @@ mod tests {
|
||||
allow_fs_write_all: false,
|
||||
unsandboxed: false,
|
||||
write_paths: vec![
|
||||
PathBuf::from("/tmp/build"),
|
||||
PathBuf::from("/tmp/cache"),
|
||||
PathBuf::from("/tmp/logs"),
|
||||
PathBuf::from("/tmp/secret"),
|
||||
settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/build")),
|
||||
settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/cache")),
|
||||
settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/logs")),
|
||||
settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/secret")),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,20 @@ use std::path::{Path, PathBuf};
|
||||
///
|
||||
/// Use this whenever you need to create new directories. Paths inside the project are created directly.
|
||||
///
|
||||
/// This tool can also create a directory **outside** the project. When agent terminal commands are sandboxed, doing so grants those commands write access to exactly that new directory — so, rather than requesting write access to a broad existing parent (e.g. your home directory) just to create something inside it, create the specific directory here first and then write into it. The only other supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills.
|
||||
#[cfg_attr(
|
||||
any(target_os = "linux", target_os = "macos"),
|
||||
doc = "This tool can also create a directory **outside** the project. When agent terminal \
|
||||
commands are sandboxed, doing so grants those commands write access to exactly that new \
|
||||
directory — so, rather than requesting write access to a broad existing parent (e.g. your \
|
||||
home directory) just to create something inside it, create the specific directory here \
|
||||
first and then write into it. The only other supported path outside the project is \
|
||||
`~/.agents/skills` or a descendant, for global agent skills."
|
||||
)]
|
||||
#[cfg_attr(
|
||||
not(any(target_os = "linux", target_os = "macos")),
|
||||
doc = "The only supported path outside the project is `~/.agents/skills` or a descendant, \
|
||||
for global agent skills."
|
||||
)]
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct CreateDirectoryToolInput {
|
||||
/// The path of the new directory.
|
||||
@@ -42,10 +55,17 @@ pub struct CreateDirectoryToolInput {
|
||||
/// </example>
|
||||
pub path: String,
|
||||
|
||||
/// Justification for creating a directory **outside** the project, shown to
|
||||
/// the user (attributed to you) in the approval prompt that grants sandboxed
|
||||
/// terminal commands write access to it. Required only for out-of-project
|
||||
/// paths; ignored for paths inside the project or the global skills dir.
|
||||
#[cfg_attr(
|
||||
any(target_os = "linux", target_os = "macos"),
|
||||
doc = "Justification for creating a directory **outside** the project, shown to the \
|
||||
user (attributed to you) in the approval prompt that grants sandboxed terminal \
|
||||
commands write access to it. Required only for out-of-project paths; ignored for \
|
||||
paths inside the project or the global skills dir."
|
||||
)]
|
||||
#[cfg_attr(
|
||||
not(any(target_os = "linux", target_os = "macos")),
|
||||
doc = "Unused on this platform."
|
||||
)]
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
@@ -262,8 +282,14 @@ async fn create_out_of_project_directory(
|
||||
.map_err(|error| format!("Creating directory {}: {error}", input.path))?;
|
||||
|
||||
let canonical = prepared.canonical_path().to_path_buf();
|
||||
// The directory was just created and its inode pinned, so persist the
|
||||
// resolved canonical alongside the raw request: enforcement rebuilds the
|
||||
// grant from the vetted canonical via a verifying reopen.
|
||||
let request = crate::sandboxing::SandboxRequest {
|
||||
write_paths: vec![canonical.clone()],
|
||||
write_paths: vec![settings::GrantedWritePath::resolved(
|
||||
prepared.untrusted_raw_path().to_path_buf(),
|
||||
canonical.clone(),
|
||||
)],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -695,7 +721,6 @@ mod tests {
|
||||
/// on approval, creates the *specific* new directory (not its broad parent).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[gpui::test]
|
||||
#[ignore]
|
||||
async fn test_create_directory_out_of_project_creates_and_grants(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
@@ -728,10 +753,13 @@ mod tests {
|
||||
let auth = event_rx.expect_authorization().await;
|
||||
let details = acp_thread::sandbox_authorization_details_from_meta(&auth.tool_call.meta)
|
||||
.expect("out-of-project create should request a sandbox write grant");
|
||||
// The grant is for exactly the new directory, not its parent.
|
||||
// The grant is for exactly the new directory, not its parent, and
|
||||
// carries the resolved canonical established when it was created.
|
||||
let expected_canonical = scratch.path().canonicalize().unwrap().join("new_grant_dir");
|
||||
assert_eq!(details.write_paths.len(), 1);
|
||||
assert_eq!(
|
||||
details.write_paths,
|
||||
vec![scratch.path().canonicalize().unwrap().join("new_grant_dir")]
|
||||
details.write_paths[0].canonical_or_requested(),
|
||||
expected_canonical
|
||||
);
|
||||
|
||||
auth.response
|
||||
@@ -753,7 +781,6 @@ mod tests {
|
||||
/// trace on the filesystem.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[gpui::test]
|
||||
#[ignore]
|
||||
async fn test_create_directory_out_of_project_denied_cleans_up(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
|
||||
@@ -143,8 +143,8 @@ pub struct SandboxedTerminalToolInput {
|
||||
#[cfg_attr(
|
||||
target_os = "macos",
|
||||
doc = "Sandboxed commands can already write to the project worktree \
|
||||
directories and a per-command temporary directory, so only list paths \
|
||||
outside those."
|
||||
directories and a per-thread temporary directory (exposed via \
|
||||
`$TMPDIR`), so only list paths outside those."
|
||||
)]
|
||||
/// Provide absolute or worktree-relative paths; each
|
||||
/// directory grants write access to its whole subtree. Prefer this over
|
||||
@@ -157,6 +157,13 @@ pub struct SandboxedTerminalToolInput {
|
||||
Requesting a file, or a path that does not exist yet, is an error. To create new \
|
||||
files, request write access to the existing directory that will contain them."
|
||||
)]
|
||||
#[cfg_attr(
|
||||
target_os = "windows",
|
||||
doc = "\nEvery path here must be an existing directory, given as a Windows drive \
|
||||
path (`C:\\...`) or a WSL absolute path (`/...`); a path that does not exist \
|
||||
cannot be granted. To write somewhere new, request write access to the nearest \
|
||||
existing parent directory."
|
||||
)]
|
||||
#[serde(default)]
|
||||
pub fs_write_paths: Vec<String>,
|
||||
/// Set to `true` only when the command needs to write outside the
|
||||
@@ -568,6 +575,51 @@ async fn run_terminal_tool(
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve each requested path to its canonical target now, at approval
|
||||
// intake, and carry the pair forward. Persisting the resolved canonical is
|
||||
// what lets enforcement rebuild the grant via a verifying reopen rather than
|
||||
// re-resolving the requested path by string (closing a symlink TOCTOU). A
|
||||
// path that can't be resolved is dropped — fail-closed.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let write_paths: Vec<settings::GrantedWritePath> = write_paths
|
||||
.into_iter()
|
||||
.filter_map(|requested| match sandbox::resolve_canonical(&requested) {
|
||||
Ok(resolved) => Some(settings::GrantedWritePath::resolved(requested, resolved)),
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"could not resolve sandbox write path {}: {error}",
|
||||
requested.display()
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
#[cfg(target_os = "windows")]
|
||||
let write_paths: Vec<settings::GrantedWritePath> = {
|
||||
let Some(release) = wsl_zed_release.clone() else {
|
||||
return Err("Could not select a Linux Zed release for WSL sandboxing".to_string());
|
||||
};
|
||||
let mut resolved_paths = Vec::with_capacity(write_paths.len());
|
||||
for requested in write_paths {
|
||||
match sandbox::resolve_canonical_for_grant(requested.clone(), release.clone()).await {
|
||||
Ok(resolved) => {
|
||||
resolved_paths.push(settings::GrantedWritePath::resolved_on_fs(
|
||||
requested,
|
||||
resolved.canonical,
|
||||
resolved.on_windows_fs,
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"could not resolve sandbox write path {} in WSL: {error:#}",
|
||||
requested.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved_paths
|
||||
};
|
||||
|
||||
let request = crate::sandboxing::SandboxRequest {
|
||||
network,
|
||||
allow_fs_write_all: !want_unsandboxed && want_fs_write_all,
|
||||
@@ -575,6 +627,41 @@ async fn run_terminal_tool(
|
||||
write_paths,
|
||||
};
|
||||
|
||||
// Before any escalation prompt: if this command's sandbox will contain a
|
||||
// path on a Windows drive (DrvFs) — from an explicit grant, a standing
|
||||
// grant, or the default project directory — its integrity guarantees are
|
||||
// weaker. When the warning is enabled, confirm with the user first. This
|
||||
// gate is transient (never persisted): on "Continue" the normal flow
|
||||
// (including any escalation prompt) proceeds; on "Abort" the command is
|
||||
// cancelled. It recurs until the warning is disabled in settings.
|
||||
if sandboxing && !want_unsandboxed && persistent.warn_ntfs_grants {
|
||||
let effective = event_stream.effective_sandbox_request(&request, &persistent);
|
||||
let contains_windows_fs = effective
|
||||
.write_paths
|
||||
.iter()
|
||||
.any(|granted| granted.on_windows_fs)
|
||||
|| cx.update(|cx| {
|
||||
let project = project.read(cx);
|
||||
working_dir
|
||||
.as_deref()
|
||||
.is_some_and(|path| sandbox::path_is_on_windows_drive(path))
|
||||
|| sandbox_worktree_writable_paths(project, cx)
|
||||
.iter()
|
||||
.any(|path| sandbox::path_is_on_windows_drive(path))
|
||||
});
|
||||
if contains_windows_fs
|
||||
&& cx
|
||||
.update(|cx| event_stream.authorize_windows_fs_warning(cx))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Ok(
|
||||
"Command cancelled: the user declined to run a command whose sandbox writes to a Windows drive."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if request.needs_escalation() {
|
||||
let reason = sandbox_input
|
||||
.reason
|
||||
@@ -721,12 +808,19 @@ async fn run_terminal_tool(
|
||||
{
|
||||
Ok(()) => Some(wrap),
|
||||
Err(error) => {
|
||||
// The probe can't fail off Linux; keep failing open just
|
||||
// in case a future platform's probe ever does.
|
||||
// Off Linux the probe only fails when the policy itself
|
||||
// can't be built (e.g. a required write grant or `.git`
|
||||
// protection no longer exists or fails its verifying
|
||||
// reopen). Running the command anyway would silently
|
||||
// drop access the user approved — or a safety-critical
|
||||
// protection — so fail closed with the reason instead.
|
||||
log::warn!(
|
||||
"Failed to create a sandbox for an agent terminal command: {error:?}"
|
||||
);
|
||||
None
|
||||
return Err(format!(
|
||||
"Cannot create a sandbox for this command: {}",
|
||||
error.user_facing_message()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3288,7 +3382,7 @@ mod tests {
|
||||
details
|
||||
.write_paths
|
||||
.iter()
|
||||
.any(|path| path.ends_with("build")),
|
||||
.any(|path| path.requested.ends_with("build")),
|
||||
"re-prompt should request the same write path: {:?}",
|
||||
details.write_paths
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ mod user_agents_md;
|
||||
|
||||
use std::cmp::Ordering::{Equal, Greater, Less};
|
||||
use std::fmt;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::path::{Component, Path};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use anyhow::Context as _;
|
||||
@@ -432,11 +432,17 @@ pub struct SandboxPermissions {
|
||||
/// approved "once" or "for this thread", which keeps the sandboxed
|
||||
/// tool/prompt in place — see `agent::sandboxing`.
|
||||
pub allow_unsandboxed: bool,
|
||||
pub write_paths: Vec<PathBuf>,
|
||||
/// Directory subtree grants, each paired with the canonical
|
||||
/// (symlink-resolved) target established when the grant was approved.
|
||||
pub write_paths: Vec<settings::GrantedWritePath>,
|
||||
/// Whether sandbox escalation prompts warn about domains or write paths
|
||||
/// that contain potentially confusable Unicode characters (homoglyphs,
|
||||
/// invisible characters, or bidirectional overrides). Enabled by default.
|
||||
pub warn_confusable_unicode: bool,
|
||||
/// Whether to warn (Windows/WSL only) when a sandbox grant targets a file on
|
||||
/// a Windows-hosted (DrvFs) filesystem, whose sandbox-integrity guarantees
|
||||
/// are weaker than a distro-native filesystem. Enabled by default.
|
||||
pub warn_ntfs_grants: bool,
|
||||
}
|
||||
|
||||
impl Default for SandboxPermissions {
|
||||
@@ -449,6 +455,8 @@ impl Default for SandboxPermissions {
|
||||
write_paths: Vec::new(),
|
||||
// The confusable-Unicode warning is a safety net, so it defaults on.
|
||||
warn_confusable_unicode: true,
|
||||
// The weaker-guarantee warning for Windows-hosted grants defaults on.
|
||||
warn_ntfs_grants: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -821,13 +829,24 @@ fn compile_sandbox_permissions(
|
||||
return SandboxPermissions::default();
|
||||
};
|
||||
|
||||
let mut write_paths = Vec::new();
|
||||
for path in content.write_paths.map(|paths| paths.0).unwrap_or_default() {
|
||||
let mut write_paths: Vec<settings::GrantedWritePath> = Vec::new();
|
||||
for entry in content.write_paths.map(|paths| paths.0).unwrap_or_default() {
|
||||
// Normalize away `..`/`.` before storing, since coverage checks are
|
||||
// purely lexical; drop paths that escape the filesystem root.
|
||||
if let Ok(normalized) = util::paths::normalize_lexically(&path) {
|
||||
util::paths::insert_subtree(&mut write_paths, normalized);
|
||||
}
|
||||
// purely lexical; drop entries whose requested (or resolved) path
|
||||
// escapes the filesystem root.
|
||||
let Ok(requested) = util::paths::normalize_lexically(&entry.requested) else {
|
||||
continue;
|
||||
};
|
||||
let granted = match entry.resolved {
|
||||
Some(resolved) => {
|
||||
let Ok(resolved) = util::paths::normalize_lexically(&resolved) else {
|
||||
continue;
|
||||
};
|
||||
settings::GrantedWritePath::resolved_on_fs(requested, resolved, entry.on_windows_fs)
|
||||
}
|
||||
None => settings::GrantedWritePath::from_requested(requested),
|
||||
};
|
||||
insert_granted_subtree(&mut write_paths, granted);
|
||||
}
|
||||
|
||||
let network_hosts = content
|
||||
@@ -842,9 +861,37 @@ fn compile_sandbox_permissions(
|
||||
allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false),
|
||||
write_paths,
|
||||
warn_confusable_unicode: content.warn_confusable_unicode.unwrap_or(true),
|
||||
warn_ntfs_grants: content.warn_ntfs_grants.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Subtree-insert mirroring [`util::paths::insert_subtree`], but over
|
||||
/// [`settings::GrantedWritePath`] entries compared by their canonical
|
||||
/// (symlink-resolved) grant path — the path actually enforced at write time.
|
||||
///
|
||||
/// Insertion is a no-op when the new grant's canonical path is already covered
|
||||
/// by an existing entry; otherwise the new grant is added and any existing
|
||||
/// entries whose canonical path is a descendant of it are pruned. Containment
|
||||
/// is purely lexical, so callers should normalize paths first.
|
||||
fn insert_granted_subtree(
|
||||
subtrees: &mut Vec<settings::GrantedWritePath>,
|
||||
granted: settings::GrantedWritePath,
|
||||
) {
|
||||
if subtrees.iter().any(|existing| {
|
||||
granted
|
||||
.canonical_or_requested()
|
||||
.starts_with(existing.canonical_or_requested())
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
subtrees.retain(|existing| {
|
||||
!existing
|
||||
.canonical_or_requested()
|
||||
.starts_with(granted.canonical_or_requested())
|
||||
});
|
||||
subtrees.push(granted);
|
||||
}
|
||||
|
||||
fn compile_tool_permissions(content: Option<settings::ToolPermissionsContent>) -> ToolPermissions {
|
||||
let Some(content) = content else {
|
||||
return ToolPermissions::default();
|
||||
@@ -946,6 +993,7 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use settings::ToolPermissionMode;
|
||||
use settings::ToolPermissionsContent;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_parse_auto_compact_threshold() {
|
||||
@@ -1162,7 +1210,10 @@ mod tests {
|
||||
assert!(permissions.allow_unsandboxed);
|
||||
assert_eq!(
|
||||
permissions.write_paths,
|
||||
vec![PathBuf::from("/tmp/build"), PathBuf::from("/var/log")]
|
||||
vec![
|
||||
settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/build")),
|
||||
settings::GrantedWritePath::from_requested(PathBuf::from("/var/log")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1180,7 +1231,77 @@ mod tests {
|
||||
|
||||
// `/tmp/build/../build/cache` normalizes to `/tmp/build/cache`, which is
|
||||
// then pruned as a redundant child of `/tmp/build`.
|
||||
assert_eq!(permissions.write_paths, vec![PathBuf::from("/tmp/build")]);
|
||||
assert_eq!(
|
||||
permissions.write_paths,
|
||||
vec![settings::GrantedWritePath::from_requested(PathBuf::from(
|
||||
"/tmp/build"
|
||||
))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_permissions_bare_string_has_no_resolved() {
|
||||
let json = json!({
|
||||
"write_paths": ["/tmp/build"]
|
||||
});
|
||||
|
||||
let content: settings::SandboxPermissionsContent = serde_json::from_value(json).unwrap();
|
||||
let permissions = compile_sandbox_permissions(Some(content));
|
||||
|
||||
assert_eq!(
|
||||
permissions.write_paths,
|
||||
vec![settings::GrantedWritePath::from_requested(PathBuf::from(
|
||||
"/tmp/build"
|
||||
))]
|
||||
);
|
||||
assert_eq!(permissions.write_paths[0].resolved, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_permissions_object_preserves_resolved() {
|
||||
let json = json!({
|
||||
"write_paths": [
|
||||
{ "requested": "/tmp/link", "resolved": "/tmp/real" }
|
||||
]
|
||||
});
|
||||
|
||||
let content: settings::SandboxPermissionsContent = serde_json::from_value(json).unwrap();
|
||||
let permissions = compile_sandbox_permissions(Some(content));
|
||||
|
||||
assert_eq!(
|
||||
permissions.write_paths,
|
||||
vec![settings::GrantedWritePath::resolved(
|
||||
PathBuf::from("/tmp/link"),
|
||||
PathBuf::from("/tmp/real"),
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
permissions.write_paths[0].resolved,
|
||||
Some(PathBuf::from("/tmp/real"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_permissions_dedup_keys_on_resolved_path() {
|
||||
// The requested paths are unrelated, but the resolved (canonical)
|
||||
// targets form a subtree, so dedup must prune by the resolved path.
|
||||
let json = json!({
|
||||
"write_paths": [
|
||||
{ "requested": "/tmp/link/cache", "resolved": "/tmp/real/cache" },
|
||||
{ "requested": "/tmp/other", "resolved": "/tmp/real" },
|
||||
]
|
||||
});
|
||||
|
||||
let content: settings::SandboxPermissionsContent = serde_json::from_value(json).unwrap();
|
||||
let permissions = compile_sandbox_permissions(Some(content));
|
||||
|
||||
assert_eq!(
|
||||
permissions.write_paths,
|
||||
vec![settings::GrantedWritePath::resolved(
|
||||
PathBuf::from("/tmp/other"),
|
||||
PathBuf::from("/tmp/real"),
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8669,22 +8669,20 @@ impl ThreadView {
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
let url = zed_urls::sandboxing_docs(section, cx);
|
||||
let tooltip = format!("Opens {url}");
|
||||
// Wrap in a row so the button shrinks to its content width instead of
|
||||
// stretching to fill the enclosing column.
|
||||
h_flex()
|
||||
.child(
|
||||
Button::new(id, "Learn more")
|
||||
.label_size(LabelSize::Small)
|
||||
|
||||
Button::new(id, "View Sandboxing Docs")
|
||||
.label_size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.end_icon(
|
||||
Icon::new(IconName::ArrowUpRight)
|
||||
.color(Color::Muted)
|
||||
.end_icon(
|
||||
Icon::new(IconName::ArrowUpRight)
|
||||
.color(Color::Muted)
|
||||
.size(IconSize::XSmall),
|
||||
)
|
||||
.tooltip(Tooltip::text(tooltip))
|
||||
.on_click(move |_, _, cx| cx.open_url(&url)),
|
||||
.size(IconSize::XSmall),
|
||||
)
|
||||
.tooltip({
|
||||
let url = url.clone();
|
||||
move |_, cx| Tooltip::with_meta("Open Docs", None, url.clone(), cx)
|
||||
})
|
||||
.on_click(move |_, _, cx| cx.open_url(&url))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -8698,7 +8696,17 @@ 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.unsandboxed && details.reason.is_empty() {
|
||||
// The dedicated Windows-drive warning prompt is only ever sent while the
|
||||
// warning is enabled, so key the banner on the prompt itself. Keeping it
|
||||
// visible even after the "Don't show again" checkbox flips the setting
|
||||
// avoids the card disappearing out from under the user mid-decision.
|
||||
let has_windows_fs_warning = details.warn_windows_fs;
|
||||
if !has_network
|
||||
&& !has_write
|
||||
&& !details.unsandboxed
|
||||
&& details.reason.is_empty()
|
||||
&& !has_windows_fs_warning
|
||||
{
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
@@ -8825,7 +8833,9 @@ impl ThreadView {
|
||||
.collapsed_sandbox_authorization_details
|
||||
.contains(tool_call_id);
|
||||
let mut paths = details.write_paths.clone();
|
||||
paths.sort();
|
||||
// Sort by the path that is actually granted (the resolved canonical
|
||||
// when present, else the requested path).
|
||||
paths.sort_by(|a, b| a.canonical_or_requested().cmp(b.canonical_or_requested()));
|
||||
|
||||
v_flex()
|
||||
.child(
|
||||
@@ -8858,7 +8868,7 @@ impl ThreadView {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Label::new("Write access")
|
||||
Label::new("Write Access")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
@@ -8887,13 +8897,7 @@ impl ThreadView {
|
||||
.when(has_path_list && is_open, |this| {
|
||||
this.child(v_flex().children(paths.iter().enumerate().map(
|
||||
|(path_ix, path)| {
|
||||
self.render_sandbox_authorization_path_row(
|
||||
entry_ix,
|
||||
path_ix,
|
||||
path,
|
||||
path_ix < paths.len() - 1,
|
||||
cx,
|
||||
)
|
||||
self.render_sandbox_authorization_path_row(entry_ix, path_ix, path, cx)
|
||||
},
|
||||
)))
|
||||
})
|
||||
@@ -8922,10 +8926,9 @@ impl ThreadView {
|
||||
.py_1()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
Label::new("Reason from agent")
|
||||
Label::new("Reason")
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted)
|
||||
.buffer_font(cx),
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(Label::new(details.reason.clone()).size(LabelSize::Small))
|
||||
});
|
||||
@@ -8935,6 +8938,9 @@ impl ThreadView {
|
||||
v_flex()
|
||||
.border_t_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
.when(has_windows_fs_warning, |this| {
|
||||
this.child(self.render_sandbox_windows_fs_warning(cx))
|
||||
})
|
||||
.when(!confusable_findings.is_empty(), |this| {
|
||||
this.child(self.render_sandbox_confusable_warning(
|
||||
tool_call_id,
|
||||
@@ -8947,16 +8953,20 @@ impl ThreadView {
|
||||
.children(write_section)
|
||||
.children(unsandboxed_section)
|
||||
.children(reason_section)
|
||||
.child(
|
||||
h_flex()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.child(self.render_sandbox_docs_link(
|
||||
"sandbox-authorization-docs-link",
|
||||
None,
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.when(!has_windows_fs_warning, |this| {
|
||||
// The Windows-drive warning banner carries its own docs link, so
|
||||
// skip the default one that every other sandbox prompt appends.
|
||||
this.child(
|
||||
h_flex()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.child(self.render_sandbox_docs_link(
|
||||
"sandbox-authorization-docs-link",
|
||||
None,
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -8976,11 +8986,20 @@ impl ThreadView {
|
||||
findings.push((decoded, suspicious));
|
||||
}
|
||||
}
|
||||
for path in &details.write_paths {
|
||||
let display = path.display().to_string();
|
||||
let suspicious = unicode_confusables::scan(&display);
|
||||
if !suspicious.is_empty() {
|
||||
findings.push((display, suspicious));
|
||||
for granted in &details.write_paths {
|
||||
// Scan both the requested path and the resolved target (when they
|
||||
// differ), so a confusable in either the shown request or the real
|
||||
// grant destination is surfaced.
|
||||
let requested = granted.requested.display().to_string();
|
||||
let resolved = granted.canonical_or_requested().display().to_string();
|
||||
for display in [requested, resolved]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
{
|
||||
let suspicious = unicode_confusables::scan(&display);
|
||||
if !suspicious.is_empty() {
|
||||
findings.push((display, suspicious));
|
||||
}
|
||||
}
|
||||
}
|
||||
findings
|
||||
@@ -9118,6 +9137,107 @@ impl ThreadView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Whether the Windows-drive (DrvFs) weaker-guarantee warning is enabled in
|
||||
/// settings (on by default). Windows-only in effect: `warn_windows_fs` is
|
||||
/// never set on other platforms.
|
||||
fn ntfs_warning_enabled(cx: &App) -> bool {
|
||||
AgentSettings::get_global(cx)
|
||||
.sandbox_permissions
|
||||
.warn_ntfs_grants
|
||||
}
|
||||
|
||||
/// Informational banner shown on a sandbox approval prompt when the command
|
||||
/// will write to a file on a Windows drive (reached inside WSL via DrvFs),
|
||||
/// whose sandbox-integrity guarantees are weaker than the distro's native
|
||||
/// filesystem. Unlike the confusable-Unicode banner this does not gate the
|
||||
/// allow buttons: the approval itself is the acknowledgement. A settings gear
|
||||
/// links to where the warning can be suppressed.
|
||||
fn render_sandbox_windows_fs_warning(&self, cx: &Context<Self>) -> AnyElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.p_2()
|
||||
.gap_1()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().status().warning_border)
|
||||
.bg(cx.theme().status().warning_background.opacity(0.15))
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.items_start()
|
||||
.child(
|
||||
Icon::new(IconName::Warning)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Warning),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.flex_1()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
Label::new("This command can write to a file on a Windows drive")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Warning),
|
||||
)
|
||||
.child(
|
||||
Label::new(
|
||||
"Sandboxes with write access to a location on a Windows \
|
||||
drive may not provide full filesystem isolation.",
|
||||
)
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(h_flex().child(self.render_sandbox_docs_link(
|
||||
"sandbox-windows-fs-docs-link",
|
||||
Some("windows"),
|
||||
cx,
|
||||
))),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("configure-ntfs-warning", IconName::Settings)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.tooltip(Tooltip::text("Configure Windows-drive warning"))
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(zed_actions::OpenSettingsAt {
|
||||
path: zed_actions::AGENT_SANDBOX_SETTINGS_PATH.to_string(),
|
||||
target: None,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Checkbox::new(
|
||||
"sandbox-windows-fs-dont-warn",
|
||||
if Self::ntfs_warning_enabled(cx) {
|
||||
ToggleState::Unselected
|
||||
} else {
|
||||
ToggleState::Selected
|
||||
},
|
||||
)
|
||||
.label("Don't show this warning again")
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener(|this, state: &ToggleState, _window, cx| {
|
||||
let disable = *state == ToggleState::Selected;
|
||||
let fs = this.thread.read(cx).project().read(cx).fs().clone();
|
||||
update_settings_file(fs, cx, move |settings, _| {
|
||||
settings
|
||||
.agent
|
||||
.get_or_insert_default()
|
||||
.sandbox_permissions
|
||||
.get_or_insert_default()
|
||||
.warn_ntfs_grants = Some(!disable);
|
||||
});
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_sandbox_fallback_authorization_details(
|
||||
&self,
|
||||
details: &SandboxFallbackAuthorizationDetails,
|
||||
@@ -9164,55 +9284,63 @@ impl ThreadView {
|
||||
&self,
|
||||
entry_ix: usize,
|
||||
path_ix: usize,
|
||||
path: &Path,
|
||||
show_border: bool,
|
||||
granted: &settings::GrantedWritePath,
|
||||
cx: &Context<Self>,
|
||||
) -> Stateful<Div> {
|
||||
let display_path = path.display().to_string();
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| display_path.clone());
|
||||
let parent_path = path.parent().and_then(|parent| {
|
||||
let parent = parent.display().to_string();
|
||||
(!parent.is_empty()).then_some(parent)
|
||||
});
|
||||
// The path that is actually granted is the resolved canonical target.
|
||||
// When the request went through a symlink to a *different* target, both
|
||||
// paths are shown, each explicitly captioned, so it's unmistakable which
|
||||
// string was requested and which location write access is really granted
|
||||
// to.
|
||||
let granted_path = granted.canonical_or_requested();
|
||||
let requested_path = granted.requested.clone();
|
||||
// Grants are stored in the request's own namespace (a Windows path stays
|
||||
// `C:\...`, a WSL path stays `/...`), so a genuine symlink/junction
|
||||
// redirect is just a plain inequality between the request and its
|
||||
// resolved canonical.
|
||||
let is_redirected = granted
|
||||
.resolved
|
||||
.as_deref()
|
||||
.is_some_and(|resolved| resolved != requested_path.as_path());
|
||||
|
||||
h_flex()
|
||||
.id(SharedString::from(format!(
|
||||
"sandbox-authorization-path-{entry_ix}-{path_ix}"
|
||||
)))
|
||||
let granted_display = granted_path.display().to_string();
|
||||
let requested_display = requested_path.display().to_string();
|
||||
|
||||
let captioned_path = |caption: SharedString, path: String, cx: &Context<Self>| {
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
Label::new(caption)
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(Label::new(path).size(LabelSize::Small).buffer_font(cx))
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.id(format!("sandbox-authorization-path-{entry_ix}-{path_ix}"))
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.when(show_border, |this| {
|
||||
this.border_b_1().border_color(cx.theme().colors().border)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.id(SharedString::from(format!(
|
||||
"sandbox-authorization-path-name-{entry_ix}-{path_ix}"
|
||||
)))
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
Label::new(file_name)
|
||||
.size(LabelSize::XSmall)
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.when_some(parent_path, |this, parent_path| {
|
||||
this.child(
|
||||
Label::new(format!(" {parent_path}"))
|
||||
.map(|this| {
|
||||
if is_redirected {
|
||||
this.child(captioned_path("Source".into(), requested_display, cx))
|
||||
.child(
|
||||
Icon::new(IconName::ArrowDown)
|
||||
.color(Color::Muted)
|
||||
.size(LabelSize::XSmall)
|
||||
.buffer_font(cx),
|
||||
.size(IconSize::Small),
|
||||
)
|
||||
})
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta("Requested write path", None, display_path.clone(), cx)
|
||||
}),
|
||||
)
|
||||
.child(captioned_path("Target".into(), granted_display, cx))
|
||||
} else {
|
||||
// Not a genuine redirect: show what the user asked for (e.g.
|
||||
// the `C:\...` path), not the internal Linux canonical.
|
||||
this.child(captioned_path("Write Path".into(), requested_display, cx))
|
||||
}
|
||||
})
|
||||
.child(Divider::horizontal())
|
||||
}
|
||||
|
||||
fn render_permission_buttons(
|
||||
|
||||
@@ -137,6 +137,10 @@ impl FeatureFlag for SandboxingFeatureFlag {
|
||||
const NAME: &'static str = "sandboxing";
|
||||
type Value = PresenceFlag;
|
||||
|
||||
fn enabled_for_all() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn enabled_for_staff() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ seccompiler.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
# For the `#[gpui::property_test]` in the writable-grant normalization tests.
|
||||
# `proptest` comes transitively via `gpui::proptest`.
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -32,6 +32,8 @@ This can be mitigated by:
|
||||
access to those paths can be escalated to unsandboxed code execution via hooks,
|
||||
`$EDITOR`, and other mechanisms
|
||||
|
||||
This is not foolproof though - for example, on linux, it's not possible to protect `.git` if it doesn't already exist, and so
|
||||
|
||||
## Implementation
|
||||
|
||||
The implementations are highly platform-specific:
|
||||
@@ -228,6 +230,50 @@ If the attacker managed to change a path to point to a different inode to when
|
||||
the FD was captured, the check will fail, and we don't run the untrusted
|
||||
command.
|
||||
|
||||
#### The subtle flaw: capturing is circular
|
||||
|
||||
The scheme above closes the swap-*after*-capture window, but the original
|
||||
capture step had a hidden circularity. It opened a fresh `O_PATH` FD each run
|
||||
(following symlinks), then *re-derived* the bind path from that same FD via
|
||||
`readlink(/proc/self/fd/N)`. Both the pinned inode and the bind path therefore
|
||||
came from one open of an attacker-influenced path.
|
||||
|
||||
That means a symlink swapped in **before** the capture is silently followed: the
|
||||
open pins the attacker's inode and the readlink reports the attacker's path. The
|
||||
in-sandbox validator then compares `fstat(fd)` against `lstat(mount)` — but both
|
||||
sides now agree on the same wrong, attacker-chosen inode, so the check passes.
|
||||
The validator can only prove the FD and the mount name the same object; it
|
||||
cannot prove that object is the one the user approved.
|
||||
|
||||
#### The complete fix: persist the approval-time canonical path
|
||||
|
||||
The fix pins the identity at *approval* time and re-proves it at *enforcement*
|
||||
time, rather than re-deriving it from the FD every run:
|
||||
|
||||
- At approval, we resolve the requested path to its canonical (symlink-free)
|
||||
target and persist that canonical path alongside the request.
|
||||
- At enforcement, we open the persisted canonical path with `O_PATH | O_NOFOLLOW`,
|
||||
reject a symlink leaf (an `S_IFLNK` check, since `readlink` of an
|
||||
`O_NOFOLLOW` symlink FD returns the symlink's own path and would spuriously
|
||||
match), and require `readlink(/proc/self/fd/N) == persisted canonical`. If any
|
||||
component became a symlink after approval, the FD resolves elsewhere and this
|
||||
diverges, so we fail closed.
|
||||
|
||||
This host-side, **pre-mount** readlink check composes with the existing
|
||||
in-sandbox, **post-mount** `fstat(fd) == lstat(mount)` check to cover every
|
||||
window:
|
||||
- a swap *before* the host open is caught by the readlink check;
|
||||
- a swap *between* the host check and the mount is caught in-sandbox.
|
||||
|
||||
The readlink half must run host-side: once the FD is bound, its inode is
|
||||
remapped to the mount destination inside the sandbox, so an in-sandbox readlink
|
||||
would just report the mount path and prove nothing.
|
||||
|
||||
Grants are persisted as `(requested, resolved-canonical)` pairs. FDs cannot
|
||||
survive a process restart, so even transient "allow for this thread" grants are
|
||||
rebuilt from these strings and re-verified. The `requested` path is retained for
|
||||
display and provenance only — it is never fed back into enforcement.
|
||||
|
||||
#### Blocking IPC-socket escapes (seccomp)
|
||||
|
||||
A read-only bind mount does **not** stop a process from `connect()`-ing to a
|
||||
@@ -309,14 +355,40 @@ want to revisit this.
|
||||
|
||||
As mentioned above, TOCTOUs are a real issue. MacOS is not vulnerable to the
|
||||
TOCTOU that affected Linux, but there is still a risk if we canonicalize paths
|
||||
twice with a time delay between.
|
||||
twice with a time delay between.
|
||||
|
||||
To mitigate this, sensitive APIs take a `HostFilesystemLocation`. This is:
|
||||
- an `Arc<OwnedFd>` on Linux
|
||||
- a `PathBuf` on MacOS
|
||||
To mitigate this, sensitive APIs take a `HostFilesystemLocation`. It wraps a
|
||||
fully `cfg`-ed, per-OS inner struct so each platform carries exactly the captured
|
||||
identity its enforcement layer needs:
|
||||
- Linux: `{ O_PATH fd, canonical_path, untrusted_raw_path }`
|
||||
- macOS: `{ canonical_path, untrusted_raw_path }`
|
||||
- other platforms: `{ untrusted_raw_path }` (the real capture happens WSL-side)
|
||||
|
||||
This type does not expose its inner value, and so this encourages the developer
|
||||
to capture and validate the path once, before passing it into this type.
|
||||
The type is opaque: it does not `Deref`, and its paths are readable only through
|
||||
a display view, never as a value that can be re-fed into a constructor by string.
|
||||
Equality reflects the actual filesystem object (the inode behind the FD on Linux,
|
||||
the canonical path on macOS), not the textual raw path.
|
||||
|
||||
There are two constructors:
|
||||
- `capture(raw)` resolves `raw` to its canonical target, **following** symlinks
|
||||
(Linux: `O_PATH` open, then `readlink(/proc/self/fd/N)`; macOS: canonicalize).
|
||||
Use it for locations that are *not* re-established from persisted state — the
|
||||
project's own worktree roots and protected paths (whose parents a sandboxed
|
||||
command can't tamper with) — and to resolve a user-requested path at approval
|
||||
time so the true target can be shown and persisted.
|
||||
- `reopen(raw, canonical)` rebuilds a persisted grant and **proves** the object
|
||||
now at `canonical` is the one that was approved. On Linux it opens `canonical`
|
||||
with `O_PATH | O_CLOEXEC | O_NOFOLLOW`, rejects a symlink leaf (`S_IFLNK`
|
||||
check), and requires `readlink(/proc/self/fd/N) == canonical`, failing closed
|
||||
otherwise. On macOS the persisted canonical is trusted verbatim, because
|
||||
Seatbelt matches the *resolved* access path: a post-approval component swap is
|
||||
denied at syscall time rather than redirecting, so no FD/readlink check is
|
||||
needed.
|
||||
|
||||
`display()` returns a `HostFilesystemLocationDisplay` exposing both the raw
|
||||
request and the canonical target (plus whether the request was redirected
|
||||
through a symlink) so the approval UI can render an informed "requested →
|
||||
granted" disclosure. The raw path is display/provenance only.
|
||||
|
||||
### `SandboxFilesystemLocation`
|
||||
|
||||
|
||||
@@ -117,6 +117,13 @@ mod imp {
|
||||
fs: FsMode,
|
||||
#[serde(default)]
|
||||
writable_paths: Vec<String>,
|
||||
/// Regular files to create before the policy is built. Used to set up a
|
||||
/// single-file-worktree fixture: a protected path like
|
||||
/// `<file>/settings.json/.git` then routes through a real file, so
|
||||
/// capturing it reports `NotADirectory` — the case production must skip
|
||||
/// rather than fail on (the `settings.json/.git` regression).
|
||||
#[serde(default)]
|
||||
seed_files: Vec<String>,
|
||||
#[serde(default)]
|
||||
network_access: NetMode,
|
||||
#[serde(default)]
|
||||
@@ -193,13 +200,12 @@ mod imp {
|
||||
FsMode::Restricted => {
|
||||
let mut writable_paths = Vec::new();
|
||||
for path in &check.writable_paths {
|
||||
// Mirror production (`acp_thread::SandboxWrap::to_policy`):
|
||||
// the directory must exist before its inode can be pinned, so
|
||||
// create it up front, then capture it.
|
||||
// A granted directory must exist before its inode can be
|
||||
// pinned, so create it up front, then capture it.
|
||||
std::fs::create_dir_all(path)
|
||||
.with_context(|| format!("failed to create writable path {path}"))?;
|
||||
writable_paths.push(
|
||||
HostFilesystemLocation::new(path)
|
||||
HostFilesystemLocation::capture(path)
|
||||
.with_context(|| format!("failed to capture writable path {path}"))?,
|
||||
);
|
||||
}
|
||||
@@ -220,15 +226,18 @@ mod imp {
|
||||
Ok(SandboxPolicy { fs, network })
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Capture protected paths best-effort, mirroring production
|
||||
/// (`SandboxWrap::to_policy`): protect only the ones that exist right now
|
||||
/// (`capture` succeeding is the existence check) and silently drop the rest.
|
||||
/// A protection can't be materialized, and `.git` protection has an inherent
|
||||
/// accepted loophole (`git init` in a non-git dir), so a currently-absent or
|
||||
/// otherwise-uncapturable protected path is dropped rather than fatal. 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())
|
||||
.filter_map(|path| HostFilesystemLocation::capture(path).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -241,8 +250,13 @@ mod imp {
|
||||
} else {
|
||||
format!(",protected_paths={:?}", check.protected_paths)
|
||||
};
|
||||
let seed = if check.seed_files.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(",seed_files={:?}", check.seed_files)
|
||||
};
|
||||
let policy = format!(
|
||||
"fs={:?},net={:?}{protected}",
|
||||
"fs={:?},net={:?}{protected}{seed}",
|
||||
check.fs, check.network_access
|
||||
);
|
||||
let op = if let Some(path) = &check.read {
|
||||
@@ -285,6 +299,20 @@ mod imp {
|
||||
.succeeds
|
||||
.with_context(|| format!("check {label:?} has an operation but no `succeeds`"))?;
|
||||
|
||||
// Create any regular-file fixtures before the policy is built, so a
|
||||
// protected path routed through one (e.g. `<file>/.git`) reports
|
||||
// `NotADirectory` at capture time.
|
||||
for path in &check.seed_files {
|
||||
let path = Path::new(path);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("failed to create parent of seed file {}", path.display())
|
||||
})?;
|
||||
}
|
||||
std::fs::write(path, b"seed\n")
|
||||
.with_context(|| format!("failed to create seed file {}", path.display()))?;
|
||||
}
|
||||
|
||||
let actual = if let Some(path) = &check.read {
|
||||
run_read(check, path)?
|
||||
} else if let Some(path) = &check.write {
|
||||
|
||||
@@ -48,6 +48,7 @@ const LAUNCHER_FLAG: &str = "--zed-linux-sandbox-launcher";
|
||||
/// capture must happen WSL-side because a Windows process holds no Linux fds.
|
||||
/// See `README.md`. Shared with the Windows side via `crate::WSL_SANDBOX_HELPER_FLAG`.
|
||||
const WSL_HELPER_FLAG: &str = crate::WSL_SANDBOX_HELPER_FLAG;
|
||||
const WSL_RESOLVE_FLAG: &str = crate::WSL_SANDBOX_RESOLVE_FLAG;
|
||||
/// Sentinel argv token meaning "this optional field is absent".
|
||||
const LAUNCHER_NONE: &str = "-";
|
||||
const PROXY_SOCKET_SANDBOX_PATH_PREFIX: &str = "/tmp/zed-sandbox";
|
||||
@@ -608,22 +609,21 @@ pub fn wrap_invocation(
|
||||
);
|
||||
}
|
||||
|
||||
// Create the requested writable directories up front, with the agent's
|
||||
// ambient permissions, so each can be bind-mounted at its exact path (see
|
||||
// `build_bwrap_args`): `bwrap` can't bind a nonexistent source, and the
|
||||
// command can't create it either (its parent is read-only inside the
|
||||
// sandbox). If a path still doesn't exist afterwards we can't grant the
|
||||
// write access the agent asked for, and running anyway would give the
|
||||
// command silently less access than it believes it has — so fail closed with
|
||||
// a clear error instead. (An existing *file* makes `create_dir_all` error
|
||||
// but is fine: it exists and the `--bind` below handles it.)
|
||||
// Every writable path must already exist: `bwrap` can't bind a nonexistent
|
||||
// source, and the command can't create it either (its parent is read-only
|
||||
// inside the sandbox). Callers hand us captured canonical paths whose
|
||||
// inodes were pinned at policy-construction time, so a missing path here
|
||||
// means the location vanished since capture. Never create it with the
|
||||
// agent's ambient permissions — recreating from path text would bind a
|
||||
// fresh, unapproved object in place of the pinned one (and historically
|
||||
// this materialized bogus paths, e.g. a granted *file* path springing into
|
||||
// existence as a directory). Running anyway would give the command silently
|
||||
// less access than it believes it has — so fail closed with a clear error.
|
||||
if !permissions.allow_fs_write {
|
||||
for directory in writable_dirs {
|
||||
if let Err(error) = std::fs::create_dir_all(directory)
|
||||
&& !directory.exists()
|
||||
{
|
||||
if !directory.exists() {
|
||||
bail!(
|
||||
"failed to provide writable sandbox path {}: {error}",
|
||||
"failed to provide writable sandbox path {}: it no longer exists",
|
||||
directory.display()
|
||||
);
|
||||
}
|
||||
@@ -1133,18 +1133,6 @@ fn lstat_dev_ino(path: &Path) -> std::io::Result<(u64, u64)> {
|
||||
Ok((metadata.dev(), metadata.ino()))
|
||||
}
|
||||
|
||||
/// Open an `O_PATH` descriptor pinning `path`'s inode (read/write on contents is
|
||||
/// not granted), the same capture the native-Linux policy layer performs in
|
||||
/// `HostFilesystemLocation::new`.
|
||||
fn open_o_path_fd(path: &Path) -> std::io::Result<OwnedFd> {
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_PATH | libc::O_CLOEXEC)
|
||||
.open(path)?;
|
||||
Ok(OwnedFd::from(file))
|
||||
}
|
||||
|
||||
/// A decoded WSL-helper invocation (`--wsl-sandbox-helper`). All fields are
|
||||
/// produced by the trusted Windows side and parsed before any untrusted command
|
||||
/// runs.
|
||||
@@ -1166,7 +1154,48 @@ struct WslHelperInvocation {
|
||||
/// Handle a possible re-exec of this binary as the WSL-side sandbox helper. Does
|
||||
/// not return if it was invoked as one.
|
||||
pub fn run_wsl_helper_if_invoked() {
|
||||
let Some(invocation) = parse_wsl_helper_args(std::env::args_os()) else {
|
||||
let args: Vec<OsString> = std::env::args_os().collect();
|
||||
if args.get(1).and_then(|arg| arg.to_str()) == Some(WSL_RESOLVE_FLAG) {
|
||||
let Some(path) = args.get(2) else {
|
||||
eprintln!("zed: malformed WSL canonical-path resolver invocation");
|
||||
std::process::exit(127);
|
||||
};
|
||||
if args.len() != 3 {
|
||||
eprintln!("zed: malformed WSL canonical-path resolver invocation");
|
||||
std::process::exit(127);
|
||||
}
|
||||
match crate::util::CanonicalPathBuf::resolve(PathBuf::from(path.as_os_str())) {
|
||||
Ok(canonical) => {
|
||||
let Some(canonical) = canonical.path().to_str() else {
|
||||
eprintln!("zed: canonical WSL sandbox grant is not valid UTF-8");
|
||||
std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
|
||||
};
|
||||
let Ok(distro) = std::env::var("WSL_DISTRO_NAME") else {
|
||||
eprintln!("zed: WSL_DISTRO_NAME is unavailable while resolving sandbox grant");
|
||||
std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
|
||||
};
|
||||
// Classify the backing filesystem of the *canonical* target (the
|
||||
// path that will actually be bound), so the Windows side can
|
||||
// persist whether this grant lives on a Windows-hosted (DrvFs)
|
||||
// filesystem, whose sandbox-integrity guarantees are weaker.
|
||||
let fs_class = if path_is_on_windows_fs(Path::new(canonical)) {
|
||||
"windows-fs"
|
||||
} else {
|
||||
"native-fs"
|
||||
};
|
||||
println!("{distro}");
|
||||
println!("{canonical}");
|
||||
println!("{fs_class}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("zed: could not resolve WSL sandbox grant: {error}");
|
||||
std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(invocation) = parse_wsl_helper_args(args) else {
|
||||
return;
|
||||
};
|
||||
let invocation = match invocation {
|
||||
@@ -1179,6 +1208,27 @@ pub fn run_wsl_helper_if_invoked() {
|
||||
run_wsl_helper(invocation);
|
||||
}
|
||||
|
||||
/// Whether `path` resides on a Windows-hosted filesystem exposed to WSL via
|
||||
/// DrvFs. Inside WSL the Windows drives (`/mnt/<letter>`) are served by either
|
||||
/// 9p or virtiofs, whereas the distro's own filesystems are native (ext4/btrfs/
|
||||
/// overlay/tmpfs). We use the backing filesystem type rather than a `/mnt/`
|
||||
/// path prefix so a reconfigured automount root, or a symlink that lands on a
|
||||
/// Windows drive, is still classified correctly. Fails safe: if the type can't
|
||||
/// be determined we report `true` (weaker guarantees ⇒ warn) rather than
|
||||
/// silently treating it as native.
|
||||
fn path_is_on_windows_fs(path: &Path) -> bool {
|
||||
// `statfs.f_type` magics for the DrvFs transports. virtiofs is FUSE-backed.
|
||||
const V9FS_MAGIC: i64 = 0x0102_1997;
|
||||
const FUSE_SUPER_MAGIC: i64 = 0x6573_5546;
|
||||
match nix::sys::statfs::statfs(path) {
|
||||
Ok(stat) => {
|
||||
let fs_type = stat.filesystem_type().0;
|
||||
fs_type == V9FS_MAGIC || fs_type == FUSE_SUPER_MAGIC
|
||||
}
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_wsl_helper_args(
|
||||
args: impl IntoIterator<Item = OsString>,
|
||||
) -> Option<Result<WslHelperInvocation>> {
|
||||
@@ -1243,16 +1293,15 @@ fn parse_count(value: OsString, what: &str) -> Result<usize> {
|
||||
reason = "the WSL helper is a dedicated per-command process that must spawn and wait for bwrap"
|
||||
)]
|
||||
fn run_wsl_helper(invocation: WslHelperInvocation) -> ! {
|
||||
// Capture an `O_PATH` fd per writable bind *here*, inside WSL — this is the
|
||||
// capture-at-validation step that on native Linux happens in the Zed process.
|
||||
let mut fds = Vec::with_capacity(invocation.writable_paths.len());
|
||||
for path in &invocation.writable_paths {
|
||||
match open_o_path_fd(path) {
|
||||
let fd = crate::util::CanonicalPathBuf::from_canonical(path.clone())
|
||||
.and_then(|canonical| canonical.dup_fd());
|
||||
match fd {
|
||||
Ok(fd) => fds.push(fd),
|
||||
Err(error) => {
|
||||
// Fail closed: a writable bind we can't pin can't be verified.
|
||||
eprintln!(
|
||||
"zed: WSL sandbox helper could not open writable bind {}: {error}",
|
||||
"zed: WSL sandbox helper could not verify canonical writable bind {}: {error}",
|
||||
path.display()
|
||||
);
|
||||
std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
|
||||
@@ -1891,11 +1940,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// A requested writable path that can't be created (here, under an existing
|
||||
// file, so `create_dir_all` errors and the path never exists) must fail the
|
||||
// whole invocation — not run the command with silently less write access
|
||||
// than the agent asked for. This check runs before `resolve_bwrap`, so the
|
||||
// test needs no real `bwrap`.
|
||||
// A requested writable path that doesn't exist must fail the whole
|
||||
// invocation — never be created with the agent's ambient permissions, and
|
||||
// never run the command with silently less write access than the agent
|
||||
// asked for. This check runs before `resolve_bwrap`, so the test needs no
|
||||
// real `bwrap`.
|
||||
#[test]
|
||||
fn test_wrap_invocation_fails_when_writable_path_cannot_be_provided() {
|
||||
let file = tempfile::NamedTempFile::new().unwrap();
|
||||
@@ -1919,5 +1968,9 @@ mod tests {
|
||||
error.to_string().contains("writable sandbox path"),
|
||||
"unexpected error: {error:#}"
|
||||
);
|
||||
assert!(
|
||||
!unbindable.exists(),
|
||||
"wrap_invocation must never create a missing writable path"
|
||||
);
|
||||
}
|
||||
}
|
||||
+74
-319
@@ -16,8 +16,6 @@ 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;
|
||||
@@ -28,218 +26,22 @@ mod macos_seatbelt;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_wsl;
|
||||
|
||||
mod util;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use util::canonicalize_allowing_missing_leaf;
|
||||
#[cfg(target_os = "linux")]
|
||||
use util::{CanonicalPathBuf, linux_fd_identity};
|
||||
pub use util::{
|
||||
HostFilesystemLocation, HostFilesystemLocationDisplay, normalize_host_filesystem_locations,
|
||||
resolve_canonical,
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use windows_wsl::{ResolvedGrant, resolve_canonical_for_grant};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
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 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
|
||||
/// without re-resolving a path string later. Re-resolving a path at enforcement
|
||||
/// time is the classic time-of-check-to-time-of-use hole: a path that was
|
||||
/// verified as safe can be swapped for a symlink before the sandbox actually
|
||||
/// binds/allows it, redirecting the grant to an arbitrary host location.
|
||||
///
|
||||
/// What is captured is platform-specific:
|
||||
/// - **macOS**: the fully-canonicalized path, used verbatim as the Seatbelt rule
|
||||
/// literal. Seatbelt matches the *resolved* access path against this literal,
|
||||
/// so a post-capture swap of a path component fails closed (denied) rather
|
||||
/// than redirecting the grant.
|
||||
/// - **Linux**: an `O_PATH` file descriptor pinned to the target inode. bwrap is
|
||||
/// launched by a PTY that can't inherit extra fds, so we can't use bwrap's own
|
||||
/// `--bind-fd`; instead the bind uses an ordinary `--bind <path>` and an
|
||||
/// in-sandbox validator compares `fstat` of this descriptor against `lstat` of
|
||||
/// the mounted path after the mounts, failing closed on a post-capture swap
|
||||
/// (see `linux_bubblewrap::validate_binds` and `README.md`).
|
||||
/// - **Windows**: nothing — a Windows process holds no Linux fds, so the real
|
||||
/// capture-at-validation happens inside WSL (in the `--wsl-sandbox-helper`),
|
||||
/// and the value here carries only the requested path as untrusted intent.
|
||||
///
|
||||
/// The type is deliberately **opaque**: it does not `Deref`, and it never hands
|
||||
/// back its trusted value. The only thing readable is a *display-only* path via
|
||||
/// [`HostFilesystemLocation::untrusted_path_display`], suitable for showing a
|
||||
/// human but which must never be passed back into a sandbox API as the
|
||||
/// location's identity. Equality reflects the actual filesystem object (same
|
||||
/// inode), not the textual path.
|
||||
#[derive(Clone)]
|
||||
pub struct HostFilesystemLocation {
|
||||
/// macOS: the canonicalized path, resolved exactly once at capture time and
|
||||
/// used directly as the Seatbelt rule literal.
|
||||
#[cfg(target_os = "macos")]
|
||||
canonical_path: PathBuf,
|
||||
/// Linux: an `O_PATH` descriptor pinned to the captured inode. Wrapped in an
|
||||
/// `Arc` only so the surrounding policy types can stay `Clone`; cloning
|
||||
/// shares the same underlying descriptor.
|
||||
#[cfg(target_os = "linux")]
|
||||
fd: std::sync::Arc<std::os::fd::OwnedFd>,
|
||||
/// The path exactly as the caller requested it. Kept **only** so the UI can
|
||||
/// show the user which location is being granted. This is never consulted by
|
||||
/// any enforcement path — treat it as untrusted, attacker-influenced text.
|
||||
untrusted_path_for_display: PathBuf,
|
||||
}
|
||||
|
||||
impl HostFilesystemLocation {
|
||||
/// Capture `path` as a host sandbox location, resolving its identity up front.
|
||||
///
|
||||
/// 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 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
|
||||
/// re-derived from a path).
|
||||
pub fn new(path: impl AsRef<Path>) -> std::io::Result<Self> {
|
||||
let path = path.as_ref();
|
||||
let untrusted_path_for_display = path.to_path_buf();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// `canonicalize_allowing_missing_leaf` resolves through the existing
|
||||
// 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,
|
||||
untrusted_path_for_display,
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
// `O_PATH` opens a handle that refers to the inode without granting
|
||||
// read/write on its contents, which is exactly what a bind source
|
||||
// needs. `O_CLOEXEC` keeps the descriptor from leaking into
|
||||
// unrelated children; the bind step re-publishes it deliberately
|
||||
// when launching bwrap.
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_PATH | libc::O_CLOEXEC)
|
||||
.open(path)?;
|
||||
Ok(Self {
|
||||
fd: std::sync::Arc::new(std::os::fd::OwnedFd::from(file)),
|
||||
untrusted_path_for_display,
|
||||
})
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
Ok(Self {
|
||||
untrusted_path_for_display,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The requested path, for **display only** (e.g. the permission-request UI).
|
||||
///
|
||||
/// This intentionally returns the untrusted, as-requested path — never the
|
||||
/// captured trusted identity. Do not feed the result back into any sandbox
|
||||
/// API as if it identified this location.
|
||||
pub fn untrusted_path_display(&self) -> std::path::Display<'_> {
|
||||
self.untrusted_path_for_display.display()
|
||||
}
|
||||
|
||||
/// macOS: the canonical path captured once at construction, used verbatim as
|
||||
/// the Seatbelt rule literal. Trusted — never re-resolved. Falls back to the
|
||||
/// requested path for a display-only location (which must never reach
|
||||
/// enforcement).
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn macos_canonical_path(&self) -> &Path {
|
||||
&self.canonical_path
|
||||
}
|
||||
|
||||
/// Linux: a borrowed handle to the pinned inode, for deriving a bind source
|
||||
/// path and for `fstat`-based identity checks.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn linux_fd(&self) -> std::os::fd::BorrowedFd<'_> {
|
||||
use std::os::fd::AsFd as _;
|
||||
self.fd.as_fd()
|
||||
}
|
||||
|
||||
/// Linux: an independent `O_PATH` descriptor to the same pinned inode,
|
||||
/// duplicated (with `O_CLOEXEC`) so the validation server can own and send it
|
||||
/// over `SCM_RIGHTS` without affecting this location's descriptor.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn linux_dup_fd(&self) -> std::io::Result<std::os::fd::OwnedFd> {
|
||||
use std::os::fd::AsFd as _;
|
||||
self.fd.as_fd().try_clone_to_owned()
|
||||
}
|
||||
|
||||
/// Windows: the requested path, to be mapped into WSL and handed to the
|
||||
/// in-WSL helper. Windows captures no identity itself (it holds no Linux
|
||||
/// fds); the real capture-at-validation happens WSL-side in the helper, so
|
||||
/// here the requested path *is* the location.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn windows_path(&self) -> &Path {
|
||||
&self.untrusted_path_for_display
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for HostFilesystemLocation {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Only the display path is shown; the trusted identity stays opaque.
|
||||
formatter
|
||||
.debug_struct("HostFilesystemLocation")
|
||||
.field(
|
||||
"untrusted_path_for_display",
|
||||
&self.untrusted_path_for_display,
|
||||
)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for HostFilesystemLocation {
|
||||
/// Two locations are equal when they refer to the **same filesystem object**,
|
||||
/// determined from the captured identity (the inode behind the `O_PATH` fd on
|
||||
/// Linux, the canonical path on macOS) — never from the textual
|
||||
/// display path. This is what lets policy bookkeeping dedupe "the same
|
||||
/// location named two different ways," and refuse to treat "two different
|
||||
/// objects that happen to share a path string" as one.
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
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"
|
||||
// (the safe answer) and leave a trace.
|
||||
_ => {
|
||||
log::error!(
|
||||
"failed to fstat an O_PATH descriptor while comparing sandbox locations"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Canonicalization is a bijection on real paths, so equal canonical
|
||||
// paths mean the same directory/file.
|
||||
self.canonical_path == other.canonical_path
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
// No enforcement and no captured identity on these platforms; fall
|
||||
// back to the requested path purely so the type can still be used in
|
||||
// collections.
|
||||
self.untrusted_path_for_display == other.untrusted_path_for_display
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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::RawFd) -> Option<(u64, u64)> {
|
||||
let stat = nix::sys::stat::fstat(fd).ok()?;
|
||||
Some((stat.st_dev as u64, stat.st_ino as u64))
|
||||
}
|
||||
|
||||
/// A path *inside the sandbox* — i.e. where a host location is exposed in the
|
||||
/// sandboxed process's view of the filesystem (for example, a bind-mount
|
||||
/// destination on Linux).
|
||||
@@ -322,6 +124,23 @@ pub enum SandboxNetPolicy {
|
||||
/// application-specific paths need stronger protection.
|
||||
pub type ProtectedPaths = Vec<HostFilesystemLocation>;
|
||||
|
||||
/// Whether `path` (a host path) resides on a Windows drive reached through WSL's
|
||||
/// DrvFs, where sandbox-integrity guarantees are weaker than on the distro's
|
||||
/// native filesystem. Always `false` off Windows, where no such filesystem
|
||||
/// exists. This is a cheap structural check on the path shape; the authoritative
|
||||
/// per-grant classification is done in WSL by [`resolve_canonical_for_grant`].
|
||||
pub fn path_is_on_windows_drive(path: &Path) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows_wsl::path_is_on_windows_drive(path)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = path;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl SandboxPolicy {
|
||||
/// Combine two policy layers. Filesystem/network grants are unioned into the
|
||||
/// least-restrictive policy that satisfies both layers, while protected paths
|
||||
@@ -351,15 +170,12 @@ impl SandboxPolicy {
|
||||
}
|
||||
|
||||
fn merge_locations(
|
||||
mut locations: Vec<HostFilesystemLocation>,
|
||||
locations: Vec<HostFilesystemLocation>,
|
||||
other: Vec<HostFilesystemLocation>,
|
||||
) -> Vec<HostFilesystemLocation> {
|
||||
for location in other {
|
||||
if !locations.contains(&location) {
|
||||
locations.push(location);
|
||||
}
|
||||
}
|
||||
locations
|
||||
// Union the two sets and reduce to a minimal cover, so a location nested
|
||||
// under another (in either input) is dropped rather than bound redundantly.
|
||||
normalize_host_filesystem_locations(locations.into_iter().chain(other))
|
||||
}
|
||||
|
||||
fn validate_writable_paths_do_not_overlap_protected_paths(
|
||||
@@ -404,8 +220,8 @@ fn writable_path_overlaps_protected_path(
|
||||
protected_path: &HostFilesystemLocation,
|
||||
) -> bool {
|
||||
writable_path
|
||||
.untrusted_path_for_display
|
||||
.starts_with(&protected_path.untrusted_path_for_display)
|
||||
.untrusted_raw_path()
|
||||
.starts_with(protected_path.untrusted_raw_path())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -897,17 +713,17 @@ impl Sandbox {
|
||||
};
|
||||
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
|
||||
// *fd* is what the in-sandbox validator compares the mounted inode
|
||||
// against. The two lists stay in the same order so each fd lines up with
|
||||
// its path on the validator side.
|
||||
// The bind *path* is the verified canonical path of each location — for a
|
||||
// persisted grant, `HostFilesystemLocation::reopen` already proved this
|
||||
// path's current inode is the approved one (`readlink(fd) == canonical`)
|
||||
// and holds the fd open pinning it. The *fd* is what the in-sandbox
|
||||
// validator compares the mounted inode against, catching any swap between
|
||||
// that host-side check and the mount. The two lists stay in the same
|
||||
// order so each fd lines up with 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 {
|
||||
let Some(path) = linux_location_path(location) else {
|
||||
continue;
|
||||
};
|
||||
let path = location.linux_canonical_path().to_path_buf();
|
||||
match location.linux_dup_fd() {
|
||||
Ok(fd) => {
|
||||
writable_owned.push(path);
|
||||
@@ -926,7 +742,7 @@ impl Sandbox {
|
||||
let writable: Vec<&Path> = writable_owned.iter().map(PathBuf::as_path).collect();
|
||||
let protected_owned: Vec<PathBuf> = protected_paths
|
||||
.iter()
|
||||
.filter_map(linux_location_path)
|
||||
.map(|location| location.linux_canonical_path().to_path_buf())
|
||||
.collect();
|
||||
let protected_paths: Vec<&Path> = protected_owned.iter().map(PathBuf::as_path).collect();
|
||||
|
||||
@@ -1047,19 +863,19 @@ impl Sandbox {
|
||||
allow_network: matches!(self.network, NetSetup::Unrestricted),
|
||||
allow_fs_write: self.fs.allow_fs_write,
|
||||
};
|
||||
// On Windows the location carries only the requested path; the in-WSL
|
||||
// helper performs the real capture-at-validation. These are mapped into
|
||||
// WSL by `wrap_invocation`.
|
||||
let writable_paths: Vec<PathBuf> = self
|
||||
let writable_paths = self
|
||||
.fs
|
||||
.writable_paths
|
||||
.iter()
|
||||
.map(|location| location.windows_path().to_path_buf())
|
||||
.map(|location| windows_wsl::WritablePath {
|
||||
requested: location.windows_requested_path().to_path_buf(),
|
||||
canonical: location.windows_canonical_path().to_path_buf(),
|
||||
})
|
||||
.collect();
|
||||
let protected_paths: Vec<PathBuf> = self
|
||||
.protected_paths()
|
||||
.iter()
|
||||
.map(|location| location.windows_path().to_path_buf())
|
||||
.map(|location| location.windows_requested_path().to_path_buf())
|
||||
.collect();
|
||||
let (program, args) = windows_wsl::wrap_invocation(
|
||||
command.program.clone(),
|
||||
@@ -1104,6 +920,8 @@ impl Drop for Sandbox {
|
||||
/// otherwise it's dead code on macOS.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub(crate) const WSL_SANDBOX_HELPER_FLAG: &str = "--wsl-sandbox-helper";
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub(crate) const WSL_SANDBOX_RESOLVE_FLAG: &str = "--wsl-sandbox-resolve-path";
|
||||
|
||||
/// Handle a possible re-exec of this binary as a sandbox helper.
|
||||
///
|
||||
@@ -1129,16 +947,6 @@ pub fn run_sandbox_launcher_if_invoked() {
|
||||
// with no writable binds rather than re-deriving paths from the opaque
|
||||
// locations.
|
||||
|
||||
/// The current path of the inode pinned by a [`HostFilesystemLocation`]'s
|
||||
/// `O_PATH` fd, via `/proc/self/fd`. This resolves to the *pinned inode*, so it
|
||||
/// reflects the object captured at validation even if its name was changed,
|
||||
/// rather than re-resolving an attacker-influenceable path string.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_location_path(location: &HostFilesystemLocation) -> Option<PathBuf> {
|
||||
use std::os::fd::AsRawFd as _;
|
||||
std::fs::read_link(format!("/proc/self/fd/{}", location.linux_fd().as_raw_fd())).ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn resolve_restricted_network(allowed_domains: &[String]) -> Result<NetSetup, SandboxError> {
|
||||
let mut patterns = Vec::with_capacity(allowed_domains.len());
|
||||
@@ -1318,9 +1126,10 @@ mod tests {
|
||||
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");
|
||||
let protected_git = HostFilesystemLocation::capture(&git_dir).expect("capture git dir");
|
||||
let writable_git = HostFilesystemLocation::capture(&git_dir).expect("capture git dir");
|
||||
let writable_hooks =
|
||||
HostFilesystemLocation::capture(&hooks_dir).expect("capture hooks dir");
|
||||
|
||||
for writable_path in [writable_git, writable_hooks] {
|
||||
let result = Sandbox::new(SandboxPolicy {
|
||||
@@ -1349,11 +1158,11 @@ mod tests {
|
||||
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");
|
||||
let protected_git = HostFilesystemLocation::capture(&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");
|
||||
HostFilesystemLocation::capture(git_dir.join("hooks")).expect("capture symlink target");
|
||||
|
||||
Sandbox::new(SandboxPolicy {
|
||||
fs: SandboxFsPolicy::Restricted {
|
||||
@@ -1374,7 +1183,7 @@ mod tests {
|
||||
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")
|
||||
HostFilesystemLocation::capture(dir.path()).expect("capture temp dir")
|
||||
};
|
||||
|
||||
let a = SandboxFsPolicy::Restricted {
|
||||
@@ -1508,6 +1317,9 @@ mod tests {
|
||||
/// (`HostFilesystemLocation`), which re-pins the inode when the command runs.
|
||||
pub struct GrantableWriteDir {
|
||||
canonical_path: PathBuf,
|
||||
/// The path exactly as requested, carried so the caller can persist and
|
||||
/// display the `(raw, canonical)` pair (see [`HostFilesystemLocation`]).
|
||||
untrusted_raw_path: PathBuf,
|
||||
/// Directories created eagerly to pin the inode, shallowest-first. Empty on
|
||||
/// platforms that defer creation to [`Self::finalize`].
|
||||
eagerly_created: Vec<PathBuf>,
|
||||
@@ -1516,6 +1328,7 @@ pub struct GrantableWriteDir {
|
||||
impl GrantableWriteDir {
|
||||
/// Prepare `path` for use as a sandbox write grant. `path` must be absolute.
|
||||
pub fn prepare(path: &Path) -> std::io::Result<Self> {
|
||||
let untrusted_raw_path = path.to_path_buf();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut eagerly_created = Vec::new();
|
||||
@@ -1529,6 +1342,7 @@ impl GrantableWriteDir {
|
||||
let canonical_path = pinned_canonical_path(path)?;
|
||||
Ok(Self {
|
||||
canonical_path,
|
||||
untrusted_raw_path,
|
||||
eagerly_created,
|
||||
})
|
||||
}
|
||||
@@ -1536,12 +1350,13 @@ impl GrantableWriteDir {
|
||||
{
|
||||
Ok(Self {
|
||||
canonical_path: canonicalize_allowing_missing_leaf(path),
|
||||
untrusted_raw_path,
|
||||
eagerly_created: Vec::new(),
|
||||
})
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
let _ = path;
|
||||
let _ = untrusted_raw_path;
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"granting a not-yet-existing write directory is not supported on this platform",
|
||||
@@ -1555,6 +1370,12 @@ impl GrantableWriteDir {
|
||||
&self.canonical_path
|
||||
}
|
||||
|
||||
/// The path exactly as requested, for persisting alongside the canonical
|
||||
/// path and for the "requested → granted" approval disclosure.
|
||||
pub fn untrusted_raw_path(&self) -> &Path {
|
||||
&self.untrusted_raw_path
|
||||
}
|
||||
|
||||
/// Materialize the directory once the grant is approved. A no-op on platforms
|
||||
/// that already created it eagerly.
|
||||
pub fn finalize(&self) -> std::io::Result<()> {
|
||||
@@ -1598,13 +1419,7 @@ fn create_missing_dirs(path: &Path, created: &mut Vec<PathBuf>) -> std::io::Resu
|
||||
/// when a component is a symlink.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pinned_canonical_path(path: &Path) -> std::io::Result<PathBuf> {
|
||||
use std::os::fd::AsRawFd as _;
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
let handle = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_PATH | libc::O_CLOEXEC)
|
||||
.open(path)?;
|
||||
std::fs::read_link(format!("/proc/self/fd/{}", handle.as_raw_fd()))
|
||||
Ok(CanonicalPathBuf::resolve(path)?.into_path())
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
@@ -1664,63 +1479,3 @@ mod grantable_write_dir_tests {
|
||||
assert!(!real.join("child").exists());
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonicalize `path`, resolving symlinks, even when its final component
|
||||
/// doesn't exist yet.
|
||||
///
|
||||
/// `std::fs::canonicalize` fails if any component is missing, which would leave
|
||||
/// 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
|
||||
/// child misses the real path the command ends up writing. Canonicalizing the
|
||||
/// existing parent and re-appending the final component keeps the child
|
||||
/// 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
|
||||
// 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() {
|
||||
return canonical;
|
||||
}
|
||||
if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name())
|
||||
&& let Ok(canonical_parent) = parent.canonicalize()
|
||||
{
|
||||
return canonical_parent.join(file_name);
|
||||
}
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod macos_tests {
|
||||
use super::canonicalize_allowing_missing_leaf;
|
||||
|
||||
#[test]
|
||||
fn canonicalize_allowing_missing_leaf_resolves_existing_parent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let canonical_dir = dir.path().canonicalize().unwrap();
|
||||
|
||||
// A fully existing path is canonicalized outright.
|
||||
assert_eq!(
|
||||
canonicalize_allowing_missing_leaf(dir.path()),
|
||||
canonical_dir
|
||||
);
|
||||
|
||||
// A path whose leaf doesn't exist yet still resolves through its parent,
|
||||
// so it stays consistent with how the parent directory canonicalizes
|
||||
// (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),
|
||||
canonical_dir.join("not-created-yet"),
|
||||
);
|
||||
|
||||
// A path whose parent also doesn't exist is returned unchanged.
|
||||
let deeper = missing.join(".git");
|
||||
assert_eq!(canonicalize_allowing_missing_leaf(&deeper), deeper);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Path-identity primitives shared by the sandbox enforcement layers.
|
||||
//!
|
||||
//! [`CanonicalPathBuf`] is the low-level primitive: a symlink-free canonical
|
||||
//! path that, on Linux, additionally pins the exact inode it names via a live
|
||||
//! `O_PATH` handle. [`HostFilesystemLocation`] wraps it into the opaque
|
||||
//! host-location handle the policy layer traffics in, pairing the trusted
|
||||
//! canonical identity with the untrusted, as-requested path (for display and
|
||||
//! provenance).
|
||||
|
||||
mod canonical_path;
|
||||
mod host_filesystem_location;
|
||||
|
||||
pub(crate) use canonical_path::CanonicalPathBuf;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) use canonical_path::linux_fd_identity;
|
||||
pub use host_filesystem_location::{
|
||||
HostFilesystemLocation, HostFilesystemLocationDisplay, normalize_host_filesystem_locations,
|
||||
};
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Resolve `path` to its canonical, symlink-free form, for recording as the
|
||||
/// `resolved` half of a persisted write grant (and for showing the user the true
|
||||
/// target at approval time).
|
||||
///
|
||||
/// This **follows** symlinks: it discovers the real target so it can be shown
|
||||
/// and persisted. Protection against a *later* swap comes from rebuilding the
|
||||
/// grant with [`HostFilesystemLocation::reopen`], which verifies this value.
|
||||
/// Mirrors what [`HostFilesystemLocation::capture`] computes internally.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub fn resolve_canonical(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
|
||||
Ok(CanonicalPathBuf::resolve(path)?.into_path())
|
||||
}
|
||||
|
||||
/// Non-Linux/macOS platforms capture no canonical identity of their own (the
|
||||
/// real resolution happens WSL-side), so the requested path is returned as-is.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
pub fn resolve_canonical(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
|
||||
Ok(path.as_ref().to_path_buf())
|
||||
}
|
||||
|
||||
/// Lexically normalize an absolute path (collapsing `.` and `..` and redundant
|
||||
/// separators) without touching the filesystem. Returns `None` for a relative
|
||||
/// path, which can't be compared to an absolute canonical path without a base.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub(crate) fn lexically_normalized_absolute(path: &Path) -> Option<PathBuf> {
|
||||
use std::path::Component;
|
||||
if !path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
// Never pop past the root: `normalized.pop()` is a no-op at root.
|
||||
normalized.pop();
|
||||
}
|
||||
other => normalized.push(other),
|
||||
}
|
||||
}
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
/// Canonicalize `path`, resolving symlinks, even when its final component
|
||||
/// doesn't exist yet.
|
||||
///
|
||||
/// `std::fs::canonicalize` fails if any component is missing, which would leave
|
||||
/// 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
|
||||
/// child misses the real path the command ends up writing. Canonicalizing the
|
||||
/// existing parent and re-appending the final component keeps the child
|
||||
/// 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
|
||||
// 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() {
|
||||
return canonical;
|
||||
}
|
||||
if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name())
|
||||
&& let Ok(canonical_parent) = parent.canonicalize()
|
||||
{
|
||||
return canonical_parent.join(file_name);
|
||||
}
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod macos_tests {
|
||||
use super::canonicalize_allowing_missing_leaf;
|
||||
|
||||
#[test]
|
||||
fn canonicalize_allowing_missing_leaf_resolves_existing_parent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let canonical_dir = dir.path().canonicalize().unwrap();
|
||||
|
||||
// A fully existing path is canonicalized outright.
|
||||
assert_eq!(
|
||||
canonicalize_allowing_missing_leaf(dir.path()),
|
||||
canonical_dir
|
||||
);
|
||||
|
||||
// A path whose leaf doesn't exist yet still resolves through its parent,
|
||||
// so it stays consistent with how the parent directory canonicalizes
|
||||
// (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),
|
||||
canonical_dir.join("not-created-yet"),
|
||||
);
|
||||
|
||||
// A path whose parent also doesn't exist is returned unchanged.
|
||||
let deeper = missing.join(".git");
|
||||
assert_eq!(canonicalize_allowing_missing_leaf(&deeper), deeper);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
//! The [`CanonicalPathBuf`] primitive: a symlink-free canonical path that, on
|
||||
//! Linux, is backed by a **live `O_PATH` handle pinning the exact inode** it
|
||||
//! names — not merely a path string.
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::os::fd::{AsFd as _, AsRawFd as _, BorrowedFd, OwnedFd};
|
||||
|
||||
/// A canonical, symlink-free filesystem path.
|
||||
///
|
||||
/// On **Linux** this is not merely a path string: it also owns a live `O_PATH`
|
||||
/// file descriptor pinning the exact inode the path named at construction time.
|
||||
/// The descriptor — not the text — is the security-relevant identity, so
|
||||
/// enforcement can prove the object living at the path *now* is still the one
|
||||
/// that was captured, closing the classic time-of-check-to-time-of-use hole
|
||||
/// where a verified path is swapped for a symlink before it is actually used.
|
||||
/// On **Windows** it is a plain path string, but constrained by construction to
|
||||
/// one of the two shapes a sandboxed WSL command can name: a Windows drive path
|
||||
/// (`C:\...`, on NTFS) or a Linux-absolute path (`/...`, inside the WSL distro).
|
||||
/// `\\wsl.localhost\...`, other UNC paths, and relative paths are rejected — a
|
||||
/// sandboxed project is always local (WSL projects are remote and unsandboxed),
|
||||
/// so a grant is either the project's own NTFS files or a WSL-native location.
|
||||
/// On other platforms it is a plain canonical path (see the constructors for the
|
||||
/// per-platform rationale).
|
||||
///
|
||||
/// Invariant: [`path`](Self::path) is an **absolute**, symlink-free realpath; on
|
||||
/// Linux this is *proven* at construction (`resolve` reads it back from
|
||||
/// `/proc/self/fd`, `from_canonical` re-verifies a claimed value against the
|
||||
/// reopened fd). Both constructors reject a non-absolute input on the platforms
|
||||
/// that resolve paths themselves (Linux/macOS): a relative path would otherwise
|
||||
/// be resolved against Zed's *own* process working directory, silently pinning
|
||||
/// an unpredictable location.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CanonicalPathBuf {
|
||||
path: PathBuf,
|
||||
/// An `O_PATH` descriptor pinning the inode of the canonical target. Wrapped
|
||||
/// in an `Arc` only so the surrounding policy types can stay `Clone`;
|
||||
/// cloning shares the same underlying descriptor.
|
||||
#[cfg(target_os = "linux")]
|
||||
fd: std::sync::Arc<OwnedFd>,
|
||||
}
|
||||
|
||||
impl CanonicalPathBuf {
|
||||
/// Resolve `path` to its canonical target, **following** symlinks to
|
||||
/// discover the real object it names.
|
||||
///
|
||||
/// Following symlinks is intentional: the goal is to find the true target so
|
||||
/// it can be shown, persisted, and pinned. Protection against a *later* swap
|
||||
/// comes from persisting the resolved path and rebuilding with
|
||||
/// [`Self::from_canonical`], which verifies it.
|
||||
pub(crate) fn resolve(path: impl AsRef<Path>) -> io::Result<Self> {
|
||||
let path = path.as_ref();
|
||||
// A relative path would be resolved against Zed's own process working
|
||||
// directory by the `open`/`canonicalize` below, silently pinning an
|
||||
// unpredictable location. Every grant flows through this constructor, so
|
||||
// rejecting here fails closed for all of them at once.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
require_absolute(path)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
require_windows_grant_shape(path)?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// `canonicalize_allowing_missing_leaf` resolves through the existing
|
||||
// parent so a not-yet-created leaf still yields the real path
|
||||
// Seatbelt will match against.
|
||||
Ok(Self {
|
||||
path: super::canonicalize_allowing_missing_leaf(path),
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
// `O_PATH` opens a handle that refers to the inode without granting
|
||||
// read/write on its contents, which is exactly what a bind source
|
||||
// needs. Symlinks are followed (no `O_NOFOLLOW`) so the fd pins the
|
||||
// real target; its canonical path is then read back from
|
||||
// `/proc/self/fd`.
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_PATH | libc::O_CLOEXEC)
|
||||
.open(path)?;
|
||||
let fd = OwnedFd::from(file);
|
||||
let path = std::fs::read_link(format!("/proc/self/fd/{}", fd.as_raw_fd()))?;
|
||||
Ok(Self {
|
||||
path,
|
||||
fd: std::sync::Arc::new(fd),
|
||||
})
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
Ok(Self {
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a *claimed* canonical `path`, proving the object now living there
|
||||
/// is the one that was approved.
|
||||
///
|
||||
/// This is the load-bearing reconstruction for user-approved write grants,
|
||||
/// which must survive process restarts and therefore can't keep the
|
||||
/// approval-time fd alive.
|
||||
///
|
||||
/// On **Linux** this opens an `O_PATH` fd with `O_NOFOLLOW`, rejects a
|
||||
/// symlink leaf (`S_IFLNK` check, [`io::ErrorKind::PermissionDenied`]), and
|
||||
/// requires the fd's real path to still equal `path` (also
|
||||
/// `PermissionDenied` otherwise) — so any component swapped for a symlink
|
||||
/// after approval fails closed. On **macOS/other** the claimed path is
|
||||
/// trusted verbatim: Seatbelt re-resolves the access path at syscall time
|
||||
/// (so a later swap is denied, not redirected), and WSL/other resolve
|
||||
/// elsewhere.
|
||||
pub(crate) fn from_canonical(path: PathBuf) -> io::Result<Self> {
|
||||
// Same absolute-path requirement as `resolve`: a persisted or
|
||||
// hand-authored relative grant must never be resolved against Zed's own
|
||||
// working directory (Linux) or emitted as a relative Seatbelt literal
|
||||
// (macOS).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
require_absolute(&path)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
require_windows_grant_shape(&path)?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Ok(Self { path })
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
// `O_NOFOLLOW` makes a symlink *leaf* open the symlink itself
|
||||
// (harmless with `O_PATH`) rather than its target, so we can detect
|
||||
// and reject it below; intermediate components are still traversed
|
||||
// and caught by the canonical-path comparison.
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW)
|
||||
.open(&path)?;
|
||||
let fd = OwnedFd::from(file);
|
||||
|
||||
// Reject a symlink leaf outright: a grant must name a real directory,
|
||||
// and `readlink` of an `O_PATH|O_NOFOLLOW` fd on a symlink returns
|
||||
// the symlink's *own* path (equal to `path`), so the comparison
|
||||
// below wouldn't catch it.
|
||||
let stat = nix::sys::stat::fstat(fd.as_raw_fd()).map_err(io::Error::from)?;
|
||||
if stat.st_mode & libc::S_IFMT == libc::S_IFLNK {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"sandbox write grant {} is a symlink, not a directory",
|
||||
path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// Load-bearing: the pinned inode's real path must still be exactly
|
||||
// the approved canonical path. If any component became a symlink
|
||||
// after approval, the fd resolves elsewhere and this diverges.
|
||||
let current = std::fs::read_link(format!("/proc/self/fd/{}", fd.as_raw_fd()))?;
|
||||
if current != path {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"sandbox write grant {} was redirected to {}",
|
||||
path.display(),
|
||||
current.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
path,
|
||||
fd: std::sync::Arc::new(fd),
|
||||
})
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
Ok(Self { path })
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical, symlink-free path.
|
||||
pub(crate) fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Consume this value, returning the canonical path.
|
||||
#[cfg_attr(target_os = "windows", expect(dead_code))]
|
||||
pub(crate) fn into_path(self) -> PathBuf {
|
||||
self.path
|
||||
}
|
||||
|
||||
/// Linux: a borrowed handle to the pinned inode, for `fstat`-based identity
|
||||
/// checks.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn fd(&self) -> BorrowedFd<'_> {
|
||||
self.fd.as_fd()
|
||||
}
|
||||
|
||||
/// Linux: an independent `O_PATH` descriptor to the same pinned inode,
|
||||
/// duplicated (with `O_CLOEXEC`) so a validation server can own and send it
|
||||
/// over `SCM_RIGHTS` without affecting this value's descriptor.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn dup_fd(&self) -> io::Result<OwnedFd> {
|
||||
self.fd.as_fd().try_clone_to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject a non-absolute grant path. Gated to the platforms whose constructors
|
||||
/// resolve paths against the process working directory; on WSL/other the path is
|
||||
/// a namespace-specific form that `Path::is_absolute` would misjudge, and its
|
||||
/// real resolution happens WSL-side (see `crate::windows_wsl`).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn require_absolute(path: &Path) -> io::Result<()> {
|
||||
if path.is_absolute() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("sandbox grant path {} is not absolute", path.display()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: enforce that a stored grant path is one of the two shapes a
|
||||
/// sandboxed WSL command can name — a Windows drive path (`C:\...` or `\\?\C:\...`,
|
||||
/// on NTFS) or a Linux-absolute path (`/...`, inside the WSL distro). Everything
|
||||
/// else (notably `\\wsl.localhost\...` and other UNC paths, and relative paths)
|
||||
/// is rejected, so an invalid grant shape can't be represented as a
|
||||
/// [`CanonicalPathBuf`]. `Path::is_absolute` isn't used: it would reject a
|
||||
/// perfectly valid Linux-absolute grant like `/home/me` on Windows.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn require_windows_grant_shape(path: &Path) -> io::Result<()> {
|
||||
let text = path.to_string_lossy();
|
||||
// Linux-absolute (WSL): exactly one leading '/'.
|
||||
let is_wsl = text.starts_with('/') && !text.starts_with("//");
|
||||
// Windows drive (NTFS): `X:...`, optionally behind the `\\?\` verbatim prefix.
|
||||
let drive = text.strip_prefix(r"\\?\").unwrap_or(&text);
|
||||
let drive = drive.as_bytes();
|
||||
let is_windows_drive = drive.len() >= 2 && drive[0].is_ascii_alphabetic() && drive[1] == b':';
|
||||
if is_wsl || is_windows_drive {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"sandbox grant path {} is neither a Windows drive path (`C:\\...`) \
|
||||
nor a WSL absolute path (`/...`)",
|
||||
path.display()
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for CanonicalPathBuf {
|
||||
/// Two values are equal when they refer to the **same filesystem object**:
|
||||
/// the inode behind the `O_PATH` fd on Linux, the canonical path on
|
||||
/// macOS/other — never merely equal path text where an fd is available. This
|
||||
/// is what lets policy bookkeeping dedupe "the same location named two
|
||||
/// different ways" and refuse to treat "two different objects that happen to
|
||||
/// share a path string" as one.
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
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"
|
||||
// (the safe answer) and leave a trace.
|
||||
_ => {
|
||||
log::error!(
|
||||
"failed to fstat an O_PATH descriptor while comparing sandbox locations"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
// Canonicalization is a bijection on real paths, so equal canonical
|
||||
// paths mean the same directory/file.
|
||||
self.path == other.path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for CanonicalPathBuf {}
|
||||
|
||||
/// The `(device, inode)` pair behind an `O_PATH` descriptor, used to decide
|
||||
/// whether two [`CanonicalPathBuf`]s (or their parents) refer to the same
|
||||
/// filesystem object.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) 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))
|
||||
}
|
||||
|
||||
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
|
||||
mod tests {
|
||||
use super::CanonicalPathBuf;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn constructors_reject_relative_paths() {
|
||||
// A relative grant would otherwise be resolved against Zed's own working
|
||||
// directory; both constructors must fail closed before touching the fs.
|
||||
// (`CanonicalPathBuf` is deliberately not `Debug`, so match rather than
|
||||
// `expect_err`.)
|
||||
match CanonicalPathBuf::resolve("relative/dir") {
|
||||
Ok(_) => panic!("resolve must reject a relative path"),
|
||||
Err(error) => assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput),
|
||||
}
|
||||
match CanonicalPathBuf::from_canonical(PathBuf::from("relative/dir")) {
|
||||
Ok(_) => panic!("from_canonical must reject a relative path"),
|
||||
Err(error) => assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "windows"))]
|
||||
mod windows_tests {
|
||||
use super::CanonicalPathBuf;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn constructors_enforce_windows_grant_shape() {
|
||||
// Accepted: Windows drive paths (NTFS) and WSL absolute paths.
|
||||
for ok in [
|
||||
r"C:\Users\me",
|
||||
r"\\?\C:\Users\me",
|
||||
"/home/me/proj",
|
||||
"/mnt/c/Users/me",
|
||||
] {
|
||||
assert!(
|
||||
CanonicalPathBuf::from_canonical(PathBuf::from(ok)).is_ok(),
|
||||
"{ok} should be accepted"
|
||||
);
|
||||
}
|
||||
// Rejected: `\\wsl.localhost\...`, other UNC, and relative paths.
|
||||
for bad in [
|
||||
r"\\wsl.localhost\Ubuntu\home\me",
|
||||
r"\\wsl$\Ubuntu\home\me",
|
||||
r"\\server\share\dir",
|
||||
r"relative\dir",
|
||||
"also/relative",
|
||||
] {
|
||||
match CanonicalPathBuf::from_canonical(PathBuf::from(bad)) {
|
||||
Ok(_) => panic!("{bad} should be rejected"),
|
||||
Err(error) => assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
//! [`HostFilesystemLocation`]: an opaque handle to a host-filesystem location
|
||||
//! the sandbox may grant access to or protect, backed by a [`CanonicalPathBuf`].
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::CanonicalPathBuf;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use super::lexically_normalized_absolute;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::os::fd::{BorrowedFd, OwnedFd};
|
||||
|
||||
/// An opaque handle to a location on the **host** filesystem the sandbox may
|
||||
/// 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
|
||||
/// without re-resolving a path string later. Re-resolving a path at enforcement
|
||||
/// time is the classic time-of-check-to-time-of-use hole: a path that was
|
||||
/// verified as safe can be swapped for a symlink before the sandbox actually
|
||||
/// binds/allows it, redirecting the grant to an arbitrary host location.
|
||||
///
|
||||
/// A grant carries two paths: the **raw** path exactly as requested (untrusted,
|
||||
/// display/provenance only) and the **canonical**, symlink-free path that is the
|
||||
/// *actual* grant (a [`CanonicalPathBuf`]). The canonical path is resolved once
|
||||
/// when the grant is established (and persisted across process restarts for
|
||||
/// grants that must outlive an fd), and enforcement proves the object now living
|
||||
/// at that path is still the one that was approved.
|
||||
///
|
||||
/// What the [`CanonicalPathBuf`] captures is platform-specific:
|
||||
/// - **macOS**: the canonical path, used verbatim as the Seatbelt rule literal.
|
||||
/// Seatbelt matches the *resolved* access path against this literal, so a
|
||||
/// post-approval swap of a path component fails closed (denied) rather than
|
||||
/// redirecting the grant — no re-resolution or fd is needed.
|
||||
/// - **Linux**: the canonical path *plus* an `O_PATH` file descriptor pinned to
|
||||
/// its inode. [`HostFilesystemLocation::reopen`] opens the fd on the persisted
|
||||
/// canonical path and requires `readlink("/proc/self/fd/N") == canonical`,
|
||||
/// failing closed if any component became a symlink after approval (the
|
||||
/// pre-mount, host-side half of the check). bwrap is launched by a PTY that
|
||||
/// can't inherit extra fds, so we can't use bwrap's own `--bind-fd`; instead
|
||||
/// the bind uses an ordinary `--bind <canonical>` and an in-sandbox validator
|
||||
/// compares `fstat` of this descriptor against `lstat` of the mounted path
|
||||
/// after the mounts, catching a swap between the host check and the mount (the
|
||||
/// post-mount half; see `linux_bubblewrap::validate_binds` and `README.md`).
|
||||
/// - **Windows**: the persisted canonical path is resolved inside WSL at
|
||||
/// approval time. The WSL helper reopens and verifies it before binding it.
|
||||
///
|
||||
/// The type is deliberately **opaque**: it does not `Deref`. Its paths are
|
||||
/// readable only through [`HostFilesystemLocation::display`] (for showing the
|
||||
/// user both what was requested and the true target) and the `pub(crate)`
|
||||
/// enforcement accessors. Nothing hands back a value that can be re-fed into a
|
||||
/// constructor by string except the verifying [`HostFilesystemLocation::reopen`]
|
||||
/// path. Equality reflects the actual filesystem object (same inode on Linux,
|
||||
/// same canonical path on macOS), not the textual raw path.
|
||||
#[derive(Clone)]
|
||||
pub struct HostFilesystemLocation {
|
||||
/// The captured, symlink-free identity of the location — the *actual* grant.
|
||||
canonical: CanonicalPathBuf,
|
||||
/// The path exactly as requested. Display/provenance only — never consulted
|
||||
/// by enforcement. Treat as untrusted, attacker-influenced text.
|
||||
untrusted_raw_path: PathBuf,
|
||||
}
|
||||
|
||||
/// A borrowed, display-only view of a [`HostFilesystemLocation`], exposing both
|
||||
/// the raw request and the canonical target so a UI can render an informed
|
||||
/// "requested → granted" disclosure. The fields are `impl Display`, so they drop
|
||||
/// straight into a format string; neither is a trusted identity a caller may
|
||||
/// feed back into a sandbox API.
|
||||
pub struct HostFilesystemLocationDisplay<'a> {
|
||||
/// The path exactly as requested (untrusted provenance; may be relative).
|
||||
pub untrusted_raw_path: std::path::Display<'a>,
|
||||
/// The canonical target — the path that is *actually* granted. Emphasize
|
||||
/// this when rendering.
|
||||
pub canonical_path: std::path::Display<'a>,
|
||||
/// `true` when the raw request resolved through a symlink to a different
|
||||
/// target (i.e. `untrusted_raw_path` and `canonical_path` name different
|
||||
/// objects). The approval UI should highlight the redirect in this case; a
|
||||
/// mere relative-vs-absolute or `.`/`..` difference does not count.
|
||||
pub is_redirected: bool,
|
||||
}
|
||||
|
||||
impl HostFilesystemLocation {
|
||||
/// Resolve `raw` to its canonical target and capture it, **following**
|
||||
/// symlinks.
|
||||
///
|
||||
/// Use this for locations that are *not* re-established from persisted state:
|
||||
/// the project's own worktree roots and protected paths (whose parents a
|
||||
/// sandboxed command can't tamper with), and to resolve a user-requested
|
||||
/// path at approval time so the real target can be shown and persisted.
|
||||
///
|
||||
/// Following symlinks is intentional here — the goal is to discover the true
|
||||
/// target. Protection against a *later* swap comes from persisting the
|
||||
/// resolved canonical path and rebuilding the grant with [`Self::reopen`],
|
||||
/// which verifies it.
|
||||
pub fn capture(raw: impl AsRef<Path>) -> std::io::Result<Self> {
|
||||
let raw = raw.as_ref();
|
||||
Ok(Self {
|
||||
canonical: CanonicalPathBuf::resolve(raw)?,
|
||||
untrusted_raw_path: raw.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Rebuild a grant from a persisted `(raw, canonical)` pair, proving the
|
||||
/// object now living at `canonical` is the one that was approved.
|
||||
///
|
||||
/// This is the load-bearing reconstruction for user-approved write grants,
|
||||
/// which must survive process restarts and therefore can't keep the
|
||||
/// approval-time fd alive. `raw` is carried for display only.
|
||||
///
|
||||
/// On Linux this opens an `O_PATH` fd on `canonical`, rejects a symlink leaf
|
||||
/// (`O_NOFOLLOW` + an `S_IFLNK` check), and requires the fd's real path to
|
||||
/// still equal `canonical` — so any component swapped for a symlink after
|
||||
/// approval fails closed. On macOS the persisted canonical is trusted
|
||||
/// verbatim (Seatbelt matches the resolved access path, so a later swap is
|
||||
/// denied at syscall time, not redirected).
|
||||
pub fn reopen(raw: impl AsRef<Path>, canonical: impl AsRef<Path>) -> std::io::Result<Self> {
|
||||
let raw = raw.as_ref();
|
||||
Ok(Self {
|
||||
canonical: CanonicalPathBuf::from_canonical(canonical.as_ref().to_path_buf())?,
|
||||
untrusted_raw_path: raw.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The requested path, for **display only** (e.g. in error messages).
|
||||
///
|
||||
/// This intentionally returns the untrusted, as-requested path — never the
|
||||
/// captured trusted identity. Do not feed the result back into any sandbox
|
||||
/// API as if it identified this location. Prefer [`Self::display`] for UI.
|
||||
pub fn untrusted_path_display(&self) -> std::path::Display<'_> {
|
||||
self.untrusted_raw_path.display()
|
||||
}
|
||||
|
||||
/// A borrowed view exposing both the raw request and the canonical target,
|
||||
/// plus whether the request was redirected through a symlink, for an
|
||||
/// informed "requested → granted" disclosure in the approval UI.
|
||||
pub fn display(&self) -> HostFilesystemLocationDisplay<'_> {
|
||||
HostFilesystemLocationDisplay {
|
||||
untrusted_raw_path: self.untrusted_raw_path.display(),
|
||||
canonical_path: self.canonical_or_raw_path().display(),
|
||||
is_redirected: self.is_redirected(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The captured canonical path, falling back to the raw path only on
|
||||
/// platforms that do not resolve a separate canonical identity.
|
||||
///
|
||||
/// Used both for display and as the subtree key in
|
||||
/// [`normalize_host_filesystem_locations`].
|
||||
fn canonical_or_raw_path(&self) -> &Path {
|
||||
self.canonical.path()
|
||||
}
|
||||
|
||||
/// Whether the raw request resolved through a symlink to a different target.
|
||||
/// Compares the *lexically*-normalized absolute raw path against the
|
||||
/// canonical path, so a purely relative or `.`/`..`-laden request that
|
||||
/// resolves to the same object does not count as a redirect.
|
||||
fn is_redirected(&self) -> bool {
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
{
|
||||
match lexically_normalized_absolute(&self.untrusted_raw_path) {
|
||||
Some(normalized) => normalized != self.canonical.path(),
|
||||
// A relative request can't be compared without a base; report no
|
||||
// redirect rather than a spurious one.
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The requested path, for enforcement branches on platforms with no
|
||||
/// captured identity of their own (the `not(linux/macos)` overlap check).
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
pub(crate) fn untrusted_raw_path(&self) -> &Path {
|
||||
&self.untrusted_raw_path
|
||||
}
|
||||
|
||||
/// macOS: the canonical path, used verbatim as the Seatbelt rule literal.
|
||||
/// Trusted — never re-resolved.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn macos_canonical_path(&self) -> &Path {
|
||||
self.canonical.path()
|
||||
}
|
||||
|
||||
/// Linux: the verified canonical path, used as the bind source/destination.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn linux_canonical_path(&self) -> &Path {
|
||||
self.canonical.path()
|
||||
}
|
||||
|
||||
/// Linux: a borrowed handle to the pinned inode, for `fstat`-based identity
|
||||
/// checks.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn linux_fd(&self) -> BorrowedFd<'_> {
|
||||
self.canonical.fd()
|
||||
}
|
||||
|
||||
/// Linux: an independent `O_PATH` descriptor to the same pinned inode,
|
||||
/// duplicated (with `O_CLOEXEC`) so the validation server can own and send it
|
||||
/// over `SCM_RIGHTS` without affecting this location's descriptor.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn linux_dup_fd(&self) -> std::io::Result<OwnedFd> {
|
||||
self.canonical.dup_fd()
|
||||
}
|
||||
|
||||
/// Windows: the requested path, retained for distro selection and display.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn windows_requested_path(&self) -> &Path {
|
||||
&self.untrusted_raw_path
|
||||
}
|
||||
|
||||
/// Windows: the canonical path resolved inside WSL at approval time.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn windows_canonical_path(&self) -> &Path {
|
||||
self.canonical.path()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reduce a set of writable/protected [`HostFilesystemLocation`]s to a minimal
|
||||
/// cover, silently dropping any location nested under another.
|
||||
///
|
||||
/// Containment is decided on each location's *captured canonical path* (the
|
||||
/// symlink-free path pinned at approval time), never on a freshly-resolved
|
||||
/// string, so the result is:
|
||||
/// - an antichain — no kept location is nested under (or equal to) another; and
|
||||
/// - coverage-preserving — every input location is still contained by some kept
|
||||
/// location, so the union of granted subtrees is unchanged.
|
||||
///
|
||||
/// # Why this can't widen a grant
|
||||
///
|
||||
/// The output is always a *subset* of the input: this only ever drops or keeps
|
||||
/// whole input locations, never synthesizes a path. Every input location was
|
||||
/// already captured/reopened and (for grants) approved and verified, so whatever
|
||||
/// survives was independently approved. Deduping can therefore only *reduce*
|
||||
/// access, never grant something new — which is exactly why it's safe to run even
|
||||
/// though an attacker might swap a component: we compare pinned canonical strings
|
||||
/// fixed at capture time (not the live filesystem), and even a stale containment
|
||||
/// decision can only drop a child in favor of a parent that was itself approved.
|
||||
pub fn normalize_host_filesystem_locations(
|
||||
locations: impl Iterator<Item = HostFilesystemLocation>,
|
||||
) -> Vec<HostFilesystemLocation> {
|
||||
let mut result: Vec<HostFilesystemLocation> = Vec::new();
|
||||
for location in locations {
|
||||
// Already covered by (equal to, or nested under) a kept location: it
|
||||
// grants nothing new, so drop it.
|
||||
if result.iter().any(|existing| {
|
||||
location
|
||||
.canonical_or_raw_path()
|
||||
.starts_with(existing.canonical_or_raw_path())
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
// This location is broader: evict any kept locations nested under it.
|
||||
result.retain(|existing| {
|
||||
!existing
|
||||
.canonical_or_raw_path()
|
||||
.starts_with(location.canonical_or_raw_path())
|
||||
});
|
||||
result.push(location);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
impl fmt::Debug for HostFilesystemLocation {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Only the display path is shown; the trusted identity stays opaque.
|
||||
formatter
|
||||
.debug_struct("HostFilesystemLocation")
|
||||
.field("untrusted_raw_path", &self.untrusted_raw_path)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for HostFilesystemLocation {
|
||||
/// Two locations are equal when they refer to the **same filesystem object**,
|
||||
/// determined from the captured identity (the inode behind the `O_PATH` fd on
|
||||
/// Linux, the canonical path on macOS) — never from the textual raw path.
|
||||
/// This is what lets policy bookkeeping dedupe "the same location named two
|
||||
/// different ways," and refuse to treat "two different objects that happen to
|
||||
/// share a path string" as one.
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.canonical == other.canonical
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for HostFilesystemLocation {}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod host_filesystem_location_tests {
|
||||
use super::{HostFilesystemLocation, normalize_host_filesystem_locations};
|
||||
use gpui::proptest::prelude::*;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
/// The canonical path of `path` after fully resolving it, for use as the
|
||||
/// persisted grant identity.
|
||||
fn canonical(path: &std::path::Path) -> std::path::PathBuf {
|
||||
path.canonicalize().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_resolves_symlink_and_reports_redirect() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let real = root.path().join("real");
|
||||
fs::create_dir(&real).unwrap();
|
||||
let link = root.path().join("link");
|
||||
symlink(&real, &link).unwrap();
|
||||
|
||||
let location = HostFilesystemLocation::capture(&link).expect("capture link");
|
||||
let display = location.display();
|
||||
// The canonical path is the resolved target, and the redirect is
|
||||
// surfaced so the approval UI can highlight it.
|
||||
assert_eq!(
|
||||
display.canonical_path.to_string(),
|
||||
canonical(&real).display().to_string()
|
||||
);
|
||||
assert!(display.is_redirected);
|
||||
assert_eq!(
|
||||
display.untrusted_raw_path.to_string(),
|
||||
link.display().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_of_plain_directory_is_not_a_redirect() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = root.path().join("dir");
|
||||
fs::create_dir(&dir).unwrap();
|
||||
|
||||
let location = HostFilesystemLocation::capture(&dir).expect("capture dir");
|
||||
assert!(!location.display().is_redirected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_accepts_unchanged_canonical_directory() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = root.path().join("grant");
|
||||
fs::create_dir(&dir).unwrap();
|
||||
let canonical_dir = canonical(&dir);
|
||||
|
||||
let location = HostFilesystemLocation::reopen(&dir, &canonical_dir)
|
||||
.expect("reopen unchanged directory");
|
||||
assert_eq!(location.linux_canonical_path(), canonical_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_accepts_legitimately_symlinked_parent() {
|
||||
// The consent flow persists the *resolved* canonical, so a grant whose
|
||||
// request legitimately went through a symlink still reopens cleanly:
|
||||
// there are no symlinks left in the canonical path.
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let real = root.path().join("real");
|
||||
fs::create_dir_all(real.join("child")).unwrap();
|
||||
let link = root.path().join("link");
|
||||
symlink(&real, &link).unwrap();
|
||||
|
||||
let raw = link.join("child");
|
||||
let canonical_child = canonical(&raw); // .../real/child
|
||||
let location =
|
||||
HostFilesystemLocation::reopen(&raw, &canonical_child).expect("reopen resolved child");
|
||||
assert_eq!(location.linux_canonical_path(), canonical_child);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_rejects_intermediate_component_swapped_for_symlink() {
|
||||
// This is the pre-capture TOCTOU the fix closes: the grant was approved
|
||||
// for a real directory, then an intermediate component is swapped for a
|
||||
// symlink pointing at a sensitive location before the command runs.
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let base = root.path().join("base");
|
||||
let real = base.join("real");
|
||||
fs::create_dir_all(real.join("child")).unwrap();
|
||||
let canonical_child = canonical(&real.join("child")); // approved identity
|
||||
|
||||
// Attacker plants a sensitive target and redirects `base/real` at it.
|
||||
let sensitive = base.join("sensitive");
|
||||
fs::create_dir_all(sensitive.join("child")).unwrap();
|
||||
fs::remove_dir_all(&real).unwrap();
|
||||
symlink(&sensitive, &real).unwrap();
|
||||
|
||||
let error = HostFilesystemLocation::reopen(real.join("child"), &canonical_child)
|
||||
.expect_err("swapped intermediate component must be rejected");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_rejects_symlink_leaf() {
|
||||
// The grant's leaf itself is replaced by a symlink after approval.
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let grant = root.path().join("grant");
|
||||
fs::create_dir(&grant).unwrap();
|
||||
let canonical_grant = canonical(&grant);
|
||||
|
||||
let elsewhere = root.path().join("elsewhere");
|
||||
fs::create_dir(&elsewhere).unwrap();
|
||||
fs::remove_dir(&grant).unwrap();
|
||||
symlink(&elsewhere, &grant).unwrap();
|
||||
|
||||
let error = HostFilesystemLocation::reopen(&grant, &canonical_grant)
|
||||
.expect_err("symlink leaf must be rejected");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_drops_nested_child() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let parent = root.path().join("parent");
|
||||
let child = parent.join("child");
|
||||
fs::create_dir_all(&child).unwrap();
|
||||
|
||||
let out = normalize_host_filesystem_locations(
|
||||
[
|
||||
HostFilesystemLocation::capture(&parent).unwrap(),
|
||||
HostFilesystemLocation::capture(&child).unwrap(),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].linux_canonical_path(), canonical(&parent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_evicts_children_when_a_broader_parent_arrives() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let parent = root.path().join("parent");
|
||||
let child = parent.join("child");
|
||||
let sibling = parent.join("sibling");
|
||||
fs::create_dir_all(&child).unwrap();
|
||||
fs::create_dir_all(&sibling).unwrap();
|
||||
|
||||
// The two children are inserted first, then the broader parent evicts
|
||||
// both.
|
||||
let out = normalize_host_filesystem_locations(
|
||||
[
|
||||
HostFilesystemLocation::capture(&child).unwrap(),
|
||||
HostFilesystemLocation::capture(&sibling).unwrap(),
|
||||
HostFilesystemLocation::capture(&parent).unwrap(),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].linux_canonical_path(), canonical(&parent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_keeps_unrelated_siblings() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let a = root.path().join("a");
|
||||
let b = root.path().join("b");
|
||||
fs::create_dir_all(&a).unwrap();
|
||||
fs::create_dir_all(&b).unwrap();
|
||||
|
||||
let out = normalize_host_filesystem_locations(
|
||||
[
|
||||
HostFilesystemLocation::capture(&a).unwrap(),
|
||||
HostFilesystemLocation::capture(&b).unwrap(),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
|
||||
/// A path of 1..=4 components drawn from a tiny alphabet, so generated sets
|
||||
/// contain frequent nesting, siblings, and duplicates.
|
||||
fn path_specs() -> impl Strategy<Value = Vec<Vec<String>>> {
|
||||
let component =
|
||||
prop::sample::select(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
|
||||
let path = prop::collection::vec(component, 1..=4);
|
||||
prop::collection::vec(path, 0..=8)
|
||||
}
|
||||
|
||||
#[gpui::property_test]
|
||||
fn normalize_is_a_minimal_cover(#[strategy = path_specs()] specs: Vec<Vec<String>>) {
|
||||
// Materialize each generated path under a fresh root and capture it, so
|
||||
// the property runs against real pinned canonical identities rather than
|
||||
// bare strings.
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut inputs = Vec::new();
|
||||
for spec in &specs {
|
||||
let mut path = root.path().to_path_buf();
|
||||
path.extend(spec);
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
inputs.push(HostFilesystemLocation::capture(&path).expect("capture generated dir"));
|
||||
}
|
||||
|
||||
let normalized = normalize_host_filesystem_locations(inputs.iter().cloned());
|
||||
|
||||
// Subset: every kept location came from the input (nothing synthesized).
|
||||
for kept in &normalized {
|
||||
assert!(
|
||||
inputs.contains(kept),
|
||||
"kept a location that wasn't an input"
|
||||
);
|
||||
}
|
||||
|
||||
// Antichain: no kept canonical is nested under (or equal to) another
|
||||
// (`starts_with` is reflexive, so this also rules out duplicates).
|
||||
for (i, a) in normalized.iter().enumerate() {
|
||||
for (j, b) in normalized.iter().enumerate() {
|
||||
if i != j {
|
||||
assert!(
|
||||
!a.linux_canonical_path()
|
||||
.starts_with(b.linux_canonical_path()),
|
||||
"{:?} covers {:?}",
|
||||
a.linux_canonical_path(),
|
||||
b.linux_canonical_path(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage preserved: every input is still contained by some kept
|
||||
// location, so the union of granted subtrees is unchanged.
|
||||
for input in &inputs {
|
||||
assert!(
|
||||
normalized.iter().any(|kept| input
|
||||
.linux_canonical_path()
|
||||
.starts_with(kept.linux_canonical_path())),
|
||||
"{:?} is no longer covered",
|
||||
input.linux_canonical_path(),
|
||||
);
|
||||
}
|
||||
|
||||
// Idempotent: normalizing an already-minimal set is a no-op.
|
||||
let again = normalize_host_filesystem_locations(normalized.iter().cloned());
|
||||
assert_eq!(again, normalized);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,11 @@ pub(crate) struct SandboxPermissions {
|
||||
pub(crate) allow_fs_write: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct WritablePath {
|
||||
pub(crate) requested: PathBuf,
|
||||
pub(crate) canonical: PathBuf,
|
||||
}
|
||||
|
||||
/// Exit code the environment probe script uses to signal that `bwrap` is not
|
||||
/// installed, distinguishing that from WSL itself failing to start a shell.
|
||||
/// Chosen to be unlikely to collide with `wsl.exe`'s own failure codes.
|
||||
@@ -233,6 +238,168 @@ impl PathMapping {
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of resolving a write grant inside WSL: its canonical target, plus
|
||||
/// whether that target lives on a Windows-hosted (DrvFs) filesystem whose
|
||||
/// sandbox-integrity guarantees are weaker than the distro's native filesystem.
|
||||
pub struct ResolvedGrant {
|
||||
pub canonical: PathBuf,
|
||||
pub on_windows_fs: bool,
|
||||
}
|
||||
|
||||
/// Whether `path` names a location on a Windows drive (reached inside WSL via
|
||||
/// DrvFs), as opposed to a WSL-native (ext4) path. This is a cheap *structural*
|
||||
/// classification of the path shape (a drive-letter/verbatim path maps to a
|
||||
/// `NativeDrive`; WSL UNC and Linux-absolute paths are distro-native). It is
|
||||
/// used to decide whether to warn about the weaker DrvFs sandbox guarantees for
|
||||
/// default project paths without a WSL round-trip. For explicit grants the
|
||||
/// authoritative filesystem class comes from the in-WSL resolver instead (see
|
||||
/// [`resolve_canonical_for_grant`]).
|
||||
pub fn path_is_on_windows_drive(path: &Path) -> bool {
|
||||
matches!(
|
||||
path_to_wsl_allowing_missing(path),
|
||||
Ok(PathMapping::NativeDrive { .. })
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve a requested write grant to the path we persist for it, in
|
||||
/// **platform-native** form: a Windows drive path stays `C:\...` (NTFS) and a
|
||||
/// WSL path stays a Linux-absolute `/...`. In both cases the path is resolved to
|
||||
/// its symlink/junction-free canonical target (so a granted junction or symlink
|
||||
/// is followed and shown/stored as its real target), and its backing filesystem
|
||||
/// is classified.
|
||||
///
|
||||
/// Crucially, *each side canonicalizes what it is authoritative about*:
|
||||
///
|
||||
/// * A **Windows drive path** is canonicalized on the **Windows host**. A
|
||||
/// junction (or symlink) on a Windows drive is a Windows filesystem object;
|
||||
/// WSL's DrvFs surfaces it as a plain symlink but cannot reliably resolve it
|
||||
/// (the `/proc/self/fd` readback that follows an ext4 symlink to its real
|
||||
/// target does not follow a DrvFs junction). Resolving it WSL-side would
|
||||
/// therefore store the junction unresolved and then reject it at enforcement,
|
||||
/// where [`crate::util::CanonicalPathBuf::from_canonical`] refuses a symlink
|
||||
/// leaf. `std::fs::canonicalize` follows the junction/symlink chain and
|
||||
/// confirms the target exists.
|
||||
/// * A **WSL path** is canonicalized inside **WSL** via the in-distro helper —
|
||||
/// Windows can't follow ext4 symlinks.
|
||||
///
|
||||
/// Either way the persisted canonical is re-verified in the enforcement
|
||||
/// namespace by `from_canonical` (which opens it `O_NOFOLLOW` and pins the
|
||||
/// inode), so a component swapped for a symlink/junction after approval still
|
||||
/// fails closed.
|
||||
///
|
||||
/// A local project (the only kind that is sandboxed) has NTFS files, so grants
|
||||
/// are either those `C:\...` paths or WSL-native scratch locations named with
|
||||
/// `/...`; `\\wsl.localhost\...` and other shapes are rejected. There is no
|
||||
/// distro to pin — a sandboxed command runs in exactly one distro (the project's
|
||||
/// or WSL's default), so a bare Linux path is unambiguous.
|
||||
pub async fn resolve_canonical_for_grant(
|
||||
requested: PathBuf,
|
||||
wsl_zed_release: (String, String),
|
||||
) -> Result<ResolvedGrant> {
|
||||
let path_string = requested.to_string_lossy();
|
||||
|
||||
// A Windows drive grant (NTFS) is resolved on the Windows host, which is
|
||||
// authoritative about junctions and symlinks (see the doc comment above).
|
||||
if parse_native_drive_path(&path_string).is_ok() {
|
||||
let canonical = smol::fs::canonicalize(&requested).await.with_context(|| {
|
||||
format!(
|
||||
"failed to resolve writable path `{}` on the Windows host",
|
||||
requested.display()
|
||||
)
|
||||
})?;
|
||||
// `canonicalize` yields a `\\?\` verbatim path; store the plain native
|
||||
// spelling so a non-junction grant compares equal to what was requested.
|
||||
// A junction resolving off the local drives (onto a UNC/WSL path) is
|
||||
// rejected — only a Windows drive path maps to a DrvFs bind.
|
||||
let canonical = strip_windows_verbatim_prefix(canonical);
|
||||
ensure!(
|
||||
parse_native_drive_path(&canonical.to_string_lossy()).is_ok(),
|
||||
"cannot grant sandbox write access to `{}`: it resolves to `{}`, \
|
||||
which is not a Windows drive path",
|
||||
requested.display(),
|
||||
canonical.display()
|
||||
);
|
||||
// A Windows drive is reached inside WSL via DrvFs (weaker guarantees), so
|
||||
// a native-drive grant is on a Windows filesystem by definition.
|
||||
return Ok(ResolvedGrant {
|
||||
canonical,
|
||||
on_windows_fs: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise the grant must be a Linux-absolute WSL path, which only WSL can
|
||||
// canonicalize. `\\wsl.localhost\...` and other shapes are rejected.
|
||||
let wsl_path = parse_wsl_absolute_path(&path_string).map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"cannot grant sandbox write access to `{}`: only Windows drive paths \
|
||||
(`C:\\...`) and WSL absolute paths (`/...`) are supported",
|
||||
requested.display()
|
||||
)
|
||||
})?;
|
||||
let mapping = PathMapping::Wsl(wsl_path);
|
||||
|
||||
let wsl_exe = wsl_exe_path();
|
||||
if !wsl_exe.is_file() {
|
||||
return Err(unavailable(format!(
|
||||
"WSL (`wsl.exe`) was not found at `{}`",
|
||||
wsl_exe.display()
|
||||
)));
|
||||
}
|
||||
|
||||
// Translate + existence-check the path inside WSL, then resolve its
|
||||
// symlink-free canonical (and classify its filesystem) via the helper. No
|
||||
// distro is pinned — a sandboxed command runs in one distro.
|
||||
let translated = resolve_paths(&wsl_exe, None, &[(mapping, "writable path", true)])
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.context("bug: missing resolved writable path")?
|
||||
.context("bug: required writable path resolved as missing")?;
|
||||
let (channel, version) = wsl_zed_release;
|
||||
let helper = ensure_wsl_zed_helper(&wsl_exe, None, &channel, &version).await?;
|
||||
let output = run_wsl_command(
|
||||
&wsl_exe,
|
||||
None,
|
||||
[
|
||||
"--exec",
|
||||
helper.as_str(),
|
||||
crate::WSL_SANDBOX_RESOLVE_FLAG,
|
||||
translated.as_str(),
|
||||
],
|
||||
"resolve a sandbox write grant",
|
||||
)
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"failed to resolve writable path `{}`{}",
|
||||
requested.display(),
|
||||
command_failure_details(output.status.code(), &output.stderr)
|
||||
));
|
||||
}
|
||||
let stdout = String::from_utf8(output.stdout)
|
||||
.context("WSL sandbox helper returned a non-UTF-8 canonical path")?;
|
||||
parse_canonical_grant_output(&stdout)
|
||||
}
|
||||
|
||||
/// Strip the `\\?\` verbatim prefix that `std::fs::canonicalize` prepends to a
|
||||
/// Windows drive path, leaving `\\?\UNC\...` untouched (it is not a drive path,
|
||||
/// and is rejected by the caller). We persist and display the plain `C:\...`
|
||||
/// spelling so a non-junction grant compares equal to the requested path.
|
||||
fn strip_windows_verbatim_prefix(path: PathBuf) -> PathBuf {
|
||||
let text = path.to_string_lossy();
|
||||
match text.strip_prefix(r"\\?\") {
|
||||
Some(rest) if !rest.starts_with("UNC\\") => PathBuf::from(rest),
|
||||
_ => path,
|
||||
}
|
||||
}
|
||||
|
||||
/// `on_windows_fs` word emitted by the resolver for a Windows-hosted (DrvFs)
|
||||
/// canonical target.
|
||||
const RESOLVE_WINDOWS_FS: &str = "windows-fs";
|
||||
/// `on_windows_fs` word emitted by the resolver for a distro-native canonical
|
||||
/// target.
|
||||
const RESOLVE_NATIVE_FS: &str = "native-fs";
|
||||
|
||||
/// Wrap a Linux process invocation so it runs under Bubblewrap inside WSL.
|
||||
///
|
||||
/// `program` and `args` must name a Linux executable and Linux argv, not a
|
||||
@@ -270,7 +437,7 @@ impl PathMapping {
|
||||
pub async fn wrap_invocation<S: std::hash::BuildHasher>(
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
writable_paths: Vec<PathBuf>,
|
||||
writable_paths: Vec<WritablePath>,
|
||||
protected_paths: Vec<PathBuf>,
|
||||
permissions: SandboxPermissions,
|
||||
cwd: Option<PathBuf>,
|
||||
@@ -294,11 +461,25 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
|
||||
None => None,
|
||||
};
|
||||
|
||||
let writable_requested_mappings = writable_paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
path_to_wsl(&path.requested).with_context(|| {
|
||||
format!(
|
||||
"failed to map writable path `{}` into WSL",
|
||||
path.requested.display()
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let writable_mappings = writable_paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
path_to_wsl(path).with_context(|| {
|
||||
format!("failed to map writable path `{}` into WSL", path.display())
|
||||
path_to_wsl(&path.canonical).with_context(|| {
|
||||
format!(
|
||||
"failed to map canonical writable path `{}` into WSL",
|
||||
path.canonical.display()
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
@@ -313,7 +494,10 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
|
||||
|
||||
let distro = select_distro(
|
||||
cwd_mapping.as_ref(),
|
||||
writable_mappings.iter().chain(protected_mappings.iter()),
|
||||
writable_requested_mappings
|
||||
.iter()
|
||||
.chain(writable_mappings.iter())
|
||||
.chain(protected_mappings.iter()),
|
||||
)?;
|
||||
let wsl_exe = wsl_exe_path();
|
||||
if !wsl_exe.is_file() {
|
||||
@@ -370,8 +554,8 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
|
||||
|
||||
match wsl_zed_release {
|
||||
// Preferred path: run the in-WSL `zed` as the sandbox helper, which
|
||||
// captures the writable binds' inodes WSL-side and validates them after
|
||||
// bwrap's mounts (the same in-sandbox check native Linux performs).
|
||||
// verifies and pins the persisted canonical writable paths WSL-side,
|
||||
// then validates them again after bwrap's mounts.
|
||||
Some((channel, version)) => {
|
||||
let helper =
|
||||
ensure_wsl_zed_helper(&wsl_exe, distro.as_deref(), &channel, &version).await?;
|
||||
@@ -403,6 +587,41 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
|
||||
Ok((wsl_exe.to_string_lossy().into_owned(), wsl_args))
|
||||
}
|
||||
|
||||
fn parse_canonical_grant_output(stdout: &str) -> Result<ResolvedGrant> {
|
||||
let mut lines = stdout.lines();
|
||||
let distro = lines
|
||||
.next()
|
||||
.context("WSL sandbox helper omitted the distro name")?;
|
||||
let canonical = lines
|
||||
.next()
|
||||
.context("WSL sandbox helper omitted the canonical path")?;
|
||||
let fs_class = lines
|
||||
.next()
|
||||
.context("WSL sandbox helper omitted the filesystem class")?;
|
||||
let on_windows_fs = match fs_class {
|
||||
RESOLVE_WINDOWS_FS => true,
|
||||
RESOLVE_NATIVE_FS => false,
|
||||
_ => bail!("WSL sandbox helper returned an invalid filesystem class: {fs_class:?}"),
|
||||
};
|
||||
ensure!(
|
||||
lines.next().is_none()
|
||||
&& !distro.is_empty()
|
||||
&& !distro.contains(['/', '\\'])
|
||||
&& canonical.starts_with('/'),
|
||||
"WSL sandbox helper returned invalid canonical-path output: {stdout:?}"
|
||||
);
|
||||
// Store the canonical as the Linux path bwrap actually binds. The distro is
|
||||
// pinned at enforcement from the *requested* path (see `wrap_invocation`),
|
||||
// so it is not re-encoded here: spelling the canonical as
|
||||
// `\\wsl.localhost\<distro>\...` would resurface the Windows-style path we
|
||||
// avoid and, because it names the same object as the request, show as a
|
||||
// spurious redirect in the approval UI.
|
||||
Ok(ResolvedGrant {
|
||||
canonical: PathBuf::from(canonical),
|
||||
on_windows_fs,
|
||||
})
|
||||
}
|
||||
|
||||
fn split_resolved_paths(
|
||||
has_cwd: bool,
|
||||
writable_path_count: usize,
|
||||
@@ -620,6 +839,18 @@ async fn ensure_wsl_zed_helper(
|
||||
channel: &str,
|
||||
version: &str,
|
||||
) -> Result<String> {
|
||||
// TODO: Remove this development override once WSL canonical-path handling is released.
|
||||
if let Some(helper) = std::env::var_os("ZED_WSL_SANDBOX_HELPER") {
|
||||
let helper = helper
|
||||
.into_string()
|
||||
.map_err(|_| anyhow::anyhow!("ZED_WSL_SANDBOX_HELPER is not valid UTF-8"))?;
|
||||
ensure!(
|
||||
helper.starts_with('/'),
|
||||
"ZED_WSL_SANDBOX_HELPER must be an absolute path inside WSL"
|
||||
);
|
||||
return Ok(helper);
|
||||
}
|
||||
|
||||
type HelperCache = HashMap<(Option<String>, String, String), String>;
|
||||
static CACHE: OnceLock<Mutex<HelperCache>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
@@ -1425,6 +1656,37 @@ mod tests {
|
||||
assert!(guard_index < smoke_test_index);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_grant_output_returns_the_linux_canonical_path() {
|
||||
let resolved =
|
||||
parse_canonical_grant_output("Ubuntu-24.04\n/home/me/real target\nnative-fs\n")
|
||||
.expect("valid helper output");
|
||||
// The bare Linux path, not a `\\wsl.localhost\...` spelling.
|
||||
assert_eq!(resolved.canonical, PathBuf::from("/home/me/real target"));
|
||||
assert!(!resolved.on_windows_fs);
|
||||
|
||||
let resolved = parse_canonical_grant_output("Ubuntu\n/mnt/c/Users/me/proj\nwindows-fs\n")
|
||||
.expect("valid helper output");
|
||||
assert_eq!(resolved.canonical, PathBuf::from("/mnt/c/Users/me/proj"));
|
||||
assert!(resolved.on_windows_fs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_grant_output_rejects_malformed_protocol() {
|
||||
// Missing filesystem-class line.
|
||||
assert!(parse_canonical_grant_output("Ubuntu\n/home/me/project\n").is_err());
|
||||
// Non-absolute canonical path.
|
||||
assert!(parse_canonical_grant_output("Ubuntu\nrelative\nnative-fs\n").is_err());
|
||||
// Distro name containing a separator.
|
||||
assert!(parse_canonical_grant_output("Ubuntu/bad\n/home/me/project\nnative-fs\n").is_err());
|
||||
// Unknown filesystem class.
|
||||
assert!(parse_canonical_grant_output("Ubuntu\n/home/me/project\nmaybe-fs\n").is_err());
|
||||
// Trailing junk after the filesystem class.
|
||||
assert!(
|
||||
parse_canonical_grant_output("Ubuntu\n/home/me/project\nnative-fs\nnoise\n").is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_resolved_paths_keeps_existing_protected_paths_and_skips_missing_ones() {
|
||||
let (cwd, writable_paths, protected_paths) = split_resolved_paths(
|
||||
@@ -1884,6 +2146,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_windows_verbatim_prefix_only_strips_drive_paths() {
|
||||
// `canonicalize` yields `\\?\C:\...`; the plain native spelling is stored.
|
||||
assert_eq!(
|
||||
strip_windows_verbatim_prefix(PathBuf::from(r"\\?\C:\Users\me\dir")),
|
||||
PathBuf::from(r"C:\Users\me\dir")
|
||||
);
|
||||
// A `\\?\UNC\...` path is not a drive path and is left intact (the caller
|
||||
// rejects it).
|
||||
assert_eq!(
|
||||
strip_windows_verbatim_prefix(PathBuf::from(r"\\?\UNC\wsl.localhost\Ubuntu\home")),
|
||||
PathBuf::from(r"\\?\UNC\wsl.localhost\Ubuntu\home")
|
||||
);
|
||||
// A path without the prefix is unchanged.
|
||||
assert_eq!(
|
||||
strip_windows_verbatim_prefix(PathBuf::from(r"C:\already\plain")),
|
||||
PathBuf::from(r"C:\already\plain")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_path_to_wsl_strips_verbatim_prefix_for_wslpath() {
|
||||
let mapping = map_path_to_wsl(Path::new(r"\\?\D:\workspace")).unwrap();
|
||||
|
||||
@@ -519,7 +519,7 @@ mod imp {
|
||||
// the real capture happens WSL-side in the helper.
|
||||
writable_paths: writable_paths
|
||||
.iter()
|
||||
.filter_map(|path| HostFilesystemLocation::new(path).ok())
|
||||
.filter_map(|path| HostFilesystemLocation::capture(path).ok())
|
||||
.collect(),
|
||||
protected_paths: Vec::new(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A sandbox writable-path grant, paired with the canonical target it was
|
||||
/// resolved to when it was approved.
|
||||
///
|
||||
/// Persisting the resolved canonical path (rather than re-resolving the
|
||||
/// requested path by string at enforcement time) closes a symlink TOCTOU hole:
|
||||
/// the path that is actually enforced is the one that was vetted at approval
|
||||
/// time.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GrantedWritePath {
|
||||
/// The path exactly as the user/model requested it (untrusted; display/provenance).
|
||||
pub requested: PathBuf,
|
||||
/// The canonical, symlink-resolved target established when the grant was
|
||||
/// approved. `None` for a legacy/hand-authored bare-string settings entry,
|
||||
/// which is resolved fresh at enforcement time.
|
||||
pub resolved: Option<PathBuf>,
|
||||
/// Windows/WSL only: whether the canonical target lives on a Windows-hosted
|
||||
/// (DrvFs) filesystem, whose sandbox-integrity guarantees are weaker than a
|
||||
/// distro-native filesystem. Recorded at approval time (after the user was
|
||||
/// shown the weaker-guarantee warning) so later commands can reuse the grant
|
||||
/// without re-classifying it. Always `false` on other platforms and for
|
||||
/// legacy/bare-string entries.
|
||||
pub on_windows_fs: bool,
|
||||
}
|
||||
|
||||
impl GrantedWritePath {
|
||||
/// A grant from a bare requested path with no resolved canonical target
|
||||
/// (resolved fresh at enforcement time).
|
||||
pub fn from_requested(requested: PathBuf) -> Self {
|
||||
Self {
|
||||
requested,
|
||||
resolved: None,
|
||||
on_windows_fs: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A grant whose canonical target was resolved when it was approved. Assumes
|
||||
/// a native (non-Windows-hosted) filesystem; use [`Self::resolved_on_fs`] to
|
||||
/// record the Windows/WSL DrvFs classification.
|
||||
pub fn resolved(requested: PathBuf, resolved: PathBuf) -> Self {
|
||||
Self::resolved_on_fs(requested, resolved, false)
|
||||
}
|
||||
|
||||
/// A grant whose canonical target was resolved when it was approved,
|
||||
/// recording whether that target lives on a Windows-hosted (DrvFs)
|
||||
/// filesystem.
|
||||
pub fn resolved_on_fs(requested: PathBuf, resolved: PathBuf, on_windows_fs: bool) -> Self {
|
||||
Self {
|
||||
requested,
|
||||
resolved: Some(resolved),
|
||||
on_windows_fs,
|
||||
}
|
||||
}
|
||||
|
||||
/// The path used for lexical subtree/coverage/dedup logic: the resolved
|
||||
/// canonical target when known (the real grant), otherwise the requested
|
||||
/// path.
|
||||
pub fn canonical_or_requested(&self) -> &Path {
|
||||
self.resolved.as_deref().unwrap_or(&self.requested)
|
||||
}
|
||||
}
|
||||
|
||||
// Hand-written serde so grants round-trip with legacy persisted data: a bare
|
||||
// JSON string deserializes to `{ requested, resolved: None }`, and a `None`
|
||||
// resolved serializes back as a bare string. This mirrors the string-or-object
|
||||
// approach used by `GrantedWritePathContent` in `settings_content`.
|
||||
impl serde::Serialize for GrantedWritePath {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
match &self.resolved {
|
||||
None => self.requested.serialize(serializer),
|
||||
Some(resolved) => {
|
||||
use serde::ser::SerializeStruct as _;
|
||||
// Emit `on_windows_fs` only when set, so native grants (the
|
||||
// common case, and every grant on non-Windows platforms) keep
|
||||
// the compact two-field form and legacy rows round-trip.
|
||||
let field_count = if self.on_windows_fs { 3 } else { 2 };
|
||||
let mut state = serializer.serialize_struct("GrantedWritePath", field_count)?;
|
||||
state.serialize_field("requested", &self.requested)?;
|
||||
state.serialize_field("resolved", resolved)?;
|
||||
if self.on_windows_fs {
|
||||
state.serialize_field("on_windows_fs", &self.on_windows_fs)?;
|
||||
}
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for GrantedWritePath {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Object {
|
||||
requested: PathBuf,
|
||||
#[serde(default)]
|
||||
resolved: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
on_windows_fs: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum StringOrObject {
|
||||
String(PathBuf),
|
||||
Object(Object),
|
||||
}
|
||||
|
||||
Ok(match StringOrObject::deserialize(deserializer)? {
|
||||
StringOrObject::String(requested) => Self {
|
||||
requested,
|
||||
resolved: None,
|
||||
on_windows_fs: false,
|
||||
},
|
||||
StringOrObject::Object(Object {
|
||||
requested,
|
||||
resolved,
|
||||
on_windows_fs,
|
||||
}) => Self {
|
||||
requested,
|
||||
resolved,
|
||||
on_windows_fs,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod base_keymap_setting;
|
||||
mod content_into_gpui;
|
||||
mod editable_setting_control;
|
||||
mod editorconfig_store;
|
||||
mod granted_write_path;
|
||||
mod keymap_file;
|
||||
mod settings_file;
|
||||
mod settings_store;
|
||||
@@ -37,6 +38,7 @@ pub use editable_setting_control::*;
|
||||
pub use editorconfig_store::{
|
||||
Editorconfig, EditorconfigEvent, EditorconfigProperties, EditorconfigStore,
|
||||
};
|
||||
pub use granted_write_path::GrantedWritePath;
|
||||
pub use keymap_file::{
|
||||
KeyBindingValidator, KeyBindingValidatorRegistration, KeybindSource, KeybindUpdateOperation,
|
||||
KeybindUpdateTarget, KeymapFile, KeymapFileLoadResult,
|
||||
|
||||
@@ -3,7 +3,10 @@ use schemars::{JsonSchema, json_schema};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings_macros::{MergeFrom, with_fallible_options};
|
||||
use std::sync::Arc;
|
||||
use std::{borrow::Cow, path::PathBuf};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::ExtendingVec;
|
||||
|
||||
@@ -497,7 +500,7 @@ impl AgentSettingsContent {
|
||||
.allow_unsandboxed = Some(true);
|
||||
}
|
||||
|
||||
pub fn add_sandbox_write_path(&mut self, path: PathBuf) {
|
||||
pub fn add_sandbox_write_path(&mut self, granted: GrantedWritePathContent) {
|
||||
let write_paths = &mut self
|
||||
.sandbox_permissions
|
||||
.get_or_insert_default()
|
||||
@@ -505,7 +508,17 @@ impl AgentSettingsContent {
|
||||
.get_or_insert_default()
|
||||
.0;
|
||||
|
||||
util::paths::insert_subtree(write_paths, path);
|
||||
// Mirror `util::paths::insert_subtree`, keeping the grant set minimal,
|
||||
// but compare by each entry's canonical (resolved) grant path.
|
||||
let canonical = granted.canonical_or_requested().to_path_buf();
|
||||
if write_paths
|
||||
.iter()
|
||||
.any(|existing| canonical.starts_with(existing.canonical_or_requested()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
write_paths.retain(|existing| !existing.canonical_or_requested().starts_with(&canonical));
|
||||
write_paths.push(granted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -785,6 +798,113 @@ pub enum CustomAgentServerSettings {
|
||||
},
|
||||
}
|
||||
|
||||
/// A persisted sandbox writable-path grant. Deserializes from either a bare
|
||||
/// path string (`"/tmp/x"`, a legacy/hand-authored entry with no resolved
|
||||
/// target) or an object (`{ "requested": "/tmp/x", "resolved": "/tmp/real" }`).
|
||||
/// Serializes back as a bare string when `resolved` is `None` and as an object
|
||||
/// otherwise, so hand-authored bare strings round-trip and Zed-written grants
|
||||
/// are objects.
|
||||
#[derive(Clone, Debug, Default, PartialEq, MergeFrom)]
|
||||
pub struct GrantedWritePathContent {
|
||||
/// The path exactly as the user/model requested it.
|
||||
pub requested: PathBuf,
|
||||
/// The canonical, symlink-resolved target established when the grant was
|
||||
/// approved. Absent for a bare-string entry.
|
||||
pub resolved: Option<PathBuf>,
|
||||
/// Windows/WSL only: whether the canonical target lives on a Windows-hosted
|
||||
/// (DrvFs) filesystem, whose sandbox-integrity guarantees are weaker. Absent
|
||||
/// (false) on other platforms and for bare-string entries.
|
||||
pub on_windows_fs: bool,
|
||||
}
|
||||
|
||||
impl GrantedWritePathContent {
|
||||
/// The path used for lexical subtree/coverage/dedup logic: the resolved
|
||||
/// canonical target when known, otherwise the requested path.
|
||||
fn canonical_or_requested(&self) -> &Path {
|
||||
self.resolved.as_deref().unwrap_or(&self.requested)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for GrantedWritePathContent {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
match &self.resolved {
|
||||
None => self.requested.serialize(serializer),
|
||||
Some(resolved) => {
|
||||
use serde::ser::SerializeStruct as _;
|
||||
let field_count = if self.on_windows_fs { 3 } else { 2 };
|
||||
let mut state =
|
||||
serializer.serialize_struct("GrantedWritePathContent", field_count)?;
|
||||
state.serialize_field("requested", &self.requested)?;
|
||||
state.serialize_field("resolved", resolved)?;
|
||||
if self.on_windows_fs {
|
||||
state.serialize_field("on_windows_fs", &self.on_windows_fs)?;
|
||||
}
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for GrantedWritePathContent {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
struct Object {
|
||||
requested: PathBuf,
|
||||
#[serde(default)]
|
||||
resolved: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
on_windows_fs: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum StringOrObject {
|
||||
String(PathBuf),
|
||||
Object(Object),
|
||||
}
|
||||
|
||||
Ok(match StringOrObject::deserialize(deserializer)? {
|
||||
StringOrObject::String(requested) => Self {
|
||||
requested,
|
||||
resolved: None,
|
||||
on_windows_fs: false,
|
||||
},
|
||||
StringOrObject::Object(Object {
|
||||
requested,
|
||||
resolved,
|
||||
on_windows_fs,
|
||||
}) => Self {
|
||||
requested,
|
||||
resolved,
|
||||
on_windows_fs,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for GrantedWritePathContent {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"GrantedWritePathContent".into()
|
||||
}
|
||||
|
||||
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||
json_schema!({
|
||||
"oneOf": [
|
||||
{ "type": "string" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requested": { "type": "string" },
|
||||
"resolved": { "type": ["string", "null"] },
|
||||
"on_windows_fs": { "type": "boolean" }
|
||||
},
|
||||
"required": ["requested"]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[with_fallible_options]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
|
||||
pub struct SandboxPermissionsContent {
|
||||
@@ -814,9 +934,13 @@ pub struct SandboxPermissionsContent {
|
||||
pub allow_unsandboxed: Option<bool>,
|
||||
|
||||
/// Directory subtrees that sandboxed terminal commands may always write
|
||||
/// to without prompting. Paths written by Zed are absolute.
|
||||
/// to without prompting. Each entry is either a bare path string or an
|
||||
/// object `{requested, resolved}`; Zed writes objects (the canonical,
|
||||
/// symlink-resolved target established at approval time), while
|
||||
/// hand-authored entries may be bare path strings. Paths written by Zed
|
||||
/// are absolute.
|
||||
/// Default: []
|
||||
pub write_paths: Option<ExtendingVec<PathBuf>>,
|
||||
pub write_paths: Option<ExtendingVec<GrantedWritePathContent>>,
|
||||
|
||||
/// Whether to warn when a sandbox escalation prompt requests a domain or
|
||||
/// write path that contains potentially confusable Unicode characters
|
||||
@@ -825,6 +949,14 @@ pub struct SandboxPermissionsContent {
|
||||
/// the request can be allowed.
|
||||
/// Default: true
|
||||
pub warn_confusable_unicode: Option<bool>,
|
||||
|
||||
/// Whether to warn (Windows/WSL only) when a sandbox grant targets a file on
|
||||
/// a Windows-hosted (DrvFs) filesystem. Such grants are enforced inside WSL
|
||||
/// via a translated path, and their sandbox-integrity guarantees are weaker
|
||||
/// than a distro-native filesystem. When enabled, such grants show a warning
|
||||
/// that must be acknowledged before the command runs.
|
||||
/// Default: true
|
||||
pub warn_ntfs_grants: Option<bool>,
|
||||
}
|
||||
|
||||
#[with_fallible_options]
|
||||
@@ -1148,7 +1280,11 @@ mod tests {
|
||||
);
|
||||
settings.allow_sandbox_fs_write_all();
|
||||
settings.allow_sandbox_unsandboxed();
|
||||
settings.add_sandbox_write_path(PathBuf::from("/tmp/build"));
|
||||
settings.add_sandbox_write_path(GrantedWritePathContent {
|
||||
requested: PathBuf::from("/tmp/build"),
|
||||
resolved: None,
|
||||
on_windows_fs: false,
|
||||
});
|
||||
|
||||
let sandbox_permissions = settings.sandbox_permissions.as_ref().unwrap();
|
||||
assert_eq!(sandbox_permissions.allow_all_hosts, Some(true));
|
||||
@@ -1170,7 +1306,11 @@ mod tests {
|
||||
.unwrap()
|
||||
.0
|
||||
.as_slice(),
|
||||
&[PathBuf::from("/tmp/build")]
|
||||
&[GrantedWritePathContent {
|
||||
requested: PathBuf::from("/tmp/build"),
|
||||
resolved: None,
|
||||
on_windows_fs: false,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1178,9 +1318,21 @@ mod tests {
|
||||
fn test_add_sandbox_write_path_prunes_redundant_paths() {
|
||||
let mut settings = AgentSettingsContent::default();
|
||||
|
||||
settings.add_sandbox_write_path(PathBuf::from("/tmp/build/cache"));
|
||||
settings.add_sandbox_write_path(PathBuf::from("/tmp/build"));
|
||||
settings.add_sandbox_write_path(PathBuf::from("/tmp/build/output"));
|
||||
settings.add_sandbox_write_path(GrantedWritePathContent {
|
||||
requested: PathBuf::from("/tmp/build/cache"),
|
||||
resolved: None,
|
||||
on_windows_fs: false,
|
||||
});
|
||||
settings.add_sandbox_write_path(GrantedWritePathContent {
|
||||
requested: PathBuf::from("/tmp/build"),
|
||||
resolved: None,
|
||||
on_windows_fs: false,
|
||||
});
|
||||
settings.add_sandbox_write_path(GrantedWritePathContent {
|
||||
requested: PathBuf::from("/tmp/build/output"),
|
||||
resolved: None,
|
||||
on_windows_fs: false,
|
||||
});
|
||||
|
||||
let write_paths = settings
|
||||
.sandbox_permissions
|
||||
@@ -1191,6 +1343,78 @@ mod tests {
|
||||
.unwrap()
|
||||
.0
|
||||
.as_slice();
|
||||
assert_eq!(write_paths, &[PathBuf::from("/tmp/build")]);
|
||||
assert_eq!(
|
||||
write_paths,
|
||||
&[GrantedWritePathContent {
|
||||
requested: PathBuf::from("/tmp/build"),
|
||||
resolved: None,
|
||||
on_windows_fs: false,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_granted_write_path_content_deserializes_string_or_object() {
|
||||
let from_string: GrantedWritePathContent =
|
||||
serde_json::from_value(serde_json::json!("/tmp/x")).unwrap();
|
||||
assert_eq!(
|
||||
from_string,
|
||||
GrantedWritePathContent {
|
||||
requested: PathBuf::from("/tmp/x"),
|
||||
resolved: None,
|
||||
on_windows_fs: false,
|
||||
}
|
||||
);
|
||||
|
||||
let from_object: GrantedWritePathContent = serde_json::from_value(
|
||||
serde_json::json!({ "requested": "/tmp/x", "resolved": "/tmp/real" }),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
from_object,
|
||||
GrantedWritePathContent {
|
||||
requested: PathBuf::from("/tmp/x"),
|
||||
resolved: Some(PathBuf::from("/tmp/real")),
|
||||
on_windows_fs: false,
|
||||
}
|
||||
);
|
||||
|
||||
// `resolved` is optional in the object form.
|
||||
let object_without_resolved: GrantedWritePathContent =
|
||||
serde_json::from_value(serde_json::json!({ "requested": "/tmp/x" })).unwrap();
|
||||
assert_eq!(
|
||||
object_without_resolved,
|
||||
GrantedWritePathContent {
|
||||
requested: PathBuf::from("/tmp/x"),
|
||||
resolved: None,
|
||||
on_windows_fs: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_granted_write_path_content_serializes_string_or_object() {
|
||||
// No resolved target serializes as a bare string, so hand-authored
|
||||
// bare strings round-trip.
|
||||
let bare = GrantedWritePathContent {
|
||||
requested: PathBuf::from("/tmp/x"),
|
||||
resolved: None,
|
||||
on_windows_fs: false,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&bare).unwrap(),
|
||||
serde_json::json!("/tmp/x")
|
||||
);
|
||||
|
||||
// A resolved target serializes as an object.
|
||||
let resolved = GrantedWritePathContent {
|
||||
requested: PathBuf::from("/tmp/x"),
|
||||
resolved: Some(PathBuf::from("/tmp/real")),
|
||||
on_windows_fs: false,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&resolved).unwrap(),
|
||||
serde_json::json!({ "requested": "/tmp/x", "resolved": "/tmp/real" })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,21 @@ pub(crate) fn render_sandbox_settings_page(
|
||||
},
|
||||
)
|
||||
.tab_index(0),
|
||||
)
|
||||
.child(
|
||||
SwitchField::new(
|
||||
"sandbox-warn-ntfs-grants",
|
||||
Some("Warn About Windows-Drive Grants"),
|
||||
Some(
|
||||
"Windows only: warn when a sandbox grant targets a file on a Windows drive (accessed inside WSL via DrvFs). Such grants are enforced through a translated path and their sandbox-integrity guarantees are weaker than files on the Linux distro's own filesystem."
|
||||
.into(),
|
||||
),
|
||||
permissions.warn_ntfs_grants,
|
||||
move |state, _window, cx| {
|
||||
set_warn_ntfs_grants(*state == ToggleState::Selected, cx);
|
||||
},
|
||||
)
|
||||
.tab_index(0),
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -393,9 +408,18 @@ fn raw_sandbox_lists(cx: &App) -> (Vec<String>, Vec<PathBuf>) {
|
||||
.and_then(|permissions| permissions.network_hosts.as_ref())
|
||||
.map(|hosts| hosts.0.clone())
|
||||
.unwrap_or_default();
|
||||
// Display and match on the requested path of each entry (the literal a
|
||||
// hand-edit types); Zed-written "allow always" grants also carry a resolved
|
||||
// canonical, but the settings row still keys off the requested path.
|
||||
let write_paths = permissions
|
||||
.and_then(|permissions| permissions.write_paths.as_ref())
|
||||
.map(|paths| paths.0.clone())
|
||||
.map(|paths| {
|
||||
paths
|
||||
.0
|
||||
.iter()
|
||||
.map(|entry| entry.requested.clone())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
(network_hosts, write_paths)
|
||||
@@ -464,6 +488,12 @@ fn set_warn_confusable_unicode(value: bool, cx: &mut App) {
|
||||
});
|
||||
}
|
||||
|
||||
fn set_warn_ntfs_grants(value: bool, cx: &mut App) {
|
||||
update_sandbox_permissions(cx, move |permissions| {
|
||||
permissions.warn_ntfs_grants = Some(value);
|
||||
});
|
||||
}
|
||||
|
||||
fn add_network_host(host: String, cx: &mut App) {
|
||||
update_sandbox_permissions(cx, move |permissions| {
|
||||
let hosts = &mut permissions.network_hosts.get_or_insert_default().0;
|
||||
@@ -493,17 +523,33 @@ fn remove_network_host(host: String, cx: &mut App) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Insert a hand-authored write-path entry (no resolved canonical) into the
|
||||
/// settings list as a minimal subtree, mirroring `util::paths::insert_subtree`
|
||||
/// but over [`settings::GrantedWritePathContent`] keyed on the requested path.
|
||||
fn insert_write_path_subtree(paths: &mut Vec<settings::GrantedWritePathContent>, path: PathBuf) {
|
||||
if paths.iter().any(|entry| path.starts_with(&entry.requested)) {
|
||||
return;
|
||||
}
|
||||
paths.retain(|entry| !entry.requested.starts_with(&path));
|
||||
paths.push(settings::GrantedWritePathContent {
|
||||
requested: path,
|
||||
resolved: None,
|
||||
on_windows_fs: false,
|
||||
});
|
||||
}
|
||||
|
||||
fn add_write_path(path: PathBuf, cx: &mut App) {
|
||||
// Normalize away `.`/`..` so the stored entry matches the form the runtime
|
||||
// uses for coverage checks (see `compile_sandbox_permissions`) and the form
|
||||
// persisted by the in-thread "Allow always" grant.
|
||||
// persisted by the in-thread "Allow always" grant. A hand-authored entry
|
||||
// records no resolved canonical, so enforcement resolves it fresh (see
|
||||
// `granted_write_path_to_location`).
|
||||
let Ok(path) = util::paths::normalize_lexically(&path) else {
|
||||
return;
|
||||
};
|
||||
update_sandbox_permissions(cx, move |permissions| {
|
||||
let paths = &mut permissions.write_paths.get_or_insert_default().0;
|
||||
// Store minimal subtrees so a parent path subsumes its descendants.
|
||||
util::paths::insert_subtree(paths, path);
|
||||
insert_write_path_subtree(paths, path);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -513,8 +559,8 @@ fn update_write_path(old_path: PathBuf, new_path: PathBuf, cx: &mut App) {
|
||||
};
|
||||
update_sandbox_permissions(cx, move |permissions| {
|
||||
if let Some(paths) = permissions.write_paths.as_mut() {
|
||||
paths.0.retain(|entry| *entry != old_path);
|
||||
util::paths::insert_subtree(&mut paths.0, new_path);
|
||||
paths.0.retain(|entry| entry.requested != old_path);
|
||||
insert_write_path_subtree(&mut paths.0, new_path);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -522,7 +568,7 @@ fn update_write_path(old_path: PathBuf, new_path: PathBuf, cx: &mut App) {
|
||||
fn remove_write_path(path: PathBuf, cx: &mut App) {
|
||||
update_sandbox_permissions(cx, move |permissions| {
|
||||
if let Some(paths) = permissions.write_paths.as_mut() {
|
||||
paths.0.retain(|entry| *entry != path);
|
||||
paths.0.retain(|entry| entry.requested != path);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,10 @@ sandbox?](#trust) for more details.
|
||||
Sandboxing applies only to Zed Agent. It does not sandbox Zed itself, language servers, extensions, tasks, your normal
|
||||
terminal tabs, [External Agents](./external-agents.md), or [Terminal Threads](./terminal-threads.md).
|
||||
|
||||
> **Note**: Under some conditions, sandboxes on Windows are weaker than those on
|
||||
> Linux and MacOS, and may not prevent all escape attempts. See
|
||||
> [Windows](#windows) for more detail.
|
||||
|
||||
## Sandboxed Tools {#sandboxed-tools}
|
||||
|
||||
Zed Agent sandboxing currently applies to the `terminal` and `fetch` tools.
|
||||
@@ -74,8 +78,9 @@ could allow privilege escalation - for example, allow an agent to write to a
|
||||
file that it should only have read access to.
|
||||
|
||||
Sandboxing also only applies the restrictions that the user requested. If an
|
||||
agent requests write access to your home directory, sandboxing will (and
|
||||
should!) do nothing to prevent an agent adding a malicious key to `$HOME/.ssh`.
|
||||
agent requests write access to your home directory, sandboxing will not (and
|
||||
should not!) do anything to prevent an agent adding a malicious key to
|
||||
`$HOME/.ssh`.
|
||||
|
||||
Be careful with what you grant the agent. At any point in time, you can view the
|
||||
state of the sandbox by hovering the padlock icon in the top right of the
|
||||
@@ -227,6 +232,12 @@ after host-specific network access is approved.
|
||||
If Bubblewrap is unavailable or cannot create a sandbox in the current environment, Zed may run the command without the OS
|
||||
sandbox and show a warning in the tool output.
|
||||
|
||||
> **Warning**: On Linux and WSL, restrictions on filesystem objects are
|
||||
> determined at _sandbox creation time_. Among other things, this means that an
|
||||
> agent given access to a non-Git-repo directory `/foo` could create
|
||||
> `/foo/.git`. See [How much can I trust the sandbox?](#trust) for more
|
||||
> information on the implications of this.
|
||||
|
||||
#### Installing Bubblewrap {#installing-bubblewrap}
|
||||
|
||||
Zed needs a runnable, non-setuid `bwrap` binary on your `$PATH`. Installing
|
||||
@@ -279,6 +290,14 @@ sudo apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict
|
||||
|
||||
On Windows, Zed Agent sandboxing is supported only when the agent action runs inside WSL.
|
||||
|
||||
> **Warning**: Due to limitations in WSL's implementation, it is possible that a
|
||||
> terminal command may write outside sandbox grants when the user has given
|
||||
> write access to any path that resides on the NTFS drive. This includes the
|
||||
> current-project grant for projects stored on NTFS. Zed will show a warning
|
||||
> when this happens. In practice, we believe this is difficult to exploit, but
|
||||
> security cannot be guaranteed. See [below](#why-ntfs-compromise) for a more
|
||||
> technical explanation.
|
||||
|
||||
Zed uses the Linux Bubblewrap sandbox inside WSL because WSL provides the Linux process and filesystem primitives that
|
||||
Bubblewrap needs. Native Windows processes do not currently have the same sandbox integration in Zed, so a native Windows
|
||||
command cannot be confined by Zed Agent's OS sandbox in the same way.
|
||||
@@ -296,6 +315,73 @@ Git Bash) when one is installed, otherwise PowerShell, and finally `cmd.exe`. Be
|
||||
Windows paths instead of WSL's Linux filesystem, path conventions change accordingly (for example `C:\...` or `/c/...`
|
||||
rather than WSL's `/mnt/c/...`), so a command written for the sandboxed WSL shell may behave differently.
|
||||
|
||||
#### Why do NTFS objects compromise the sandbox? {#why-ntfs-compromise}
|
||||
|
||||
In general, the security of the filesystem sandbox relies on two things
|
||||
matching:
|
||||
|
||||
- The path that the user was presented when they approved
|
||||
- The filesystem object to which access was granted
|
||||
|
||||
If a user believes they granted access to `/foo/hello`, but they actually granted
|
||||
access to `/bar/world`, then the sandbox has failed.
|
||||
|
||||
On Linux, this "filesystem object" is called an **inode**.
|
||||
|
||||
But `/foo/hello` doesn't refer to an inode. Loosely, it refers to a location
|
||||
where an inode might exist. It may refer to one inode at one point in time,
|
||||
and another inode later.
|
||||
|
||||
And because of symlinks, it's possible for an agent with write access to `/foo`
|
||||
to change `/foo/bar` to point to `/secret`, even if they have no write access to
|
||||
`/secret`. If this happens, even though the agent has access to `/foo`, we
|
||||
cannot also grant access to `/foo/bar`, since that would grant access to
|
||||
`/secret`.
|
||||
|
||||
This means that we must only refer to paths which are:
|
||||
|
||||
- canonical (i.e. contain no symlinks)
|
||||
- absolute
|
||||
- unchanged since we validated the above
|
||||
|
||||
On non-WSL-Linux, we can do this by simply opening the path to get a "file
|
||||
descriptor", which identifies the **inode** directly, so holding the file
|
||||
descriptor allows us to "pin" the correct inode. This also works just fine on
|
||||
WSL when the path in question refers to an object in the Linux filesystem.
|
||||
|
||||
However, WSL allows accessing files stored in the Windows drive. For example,
|
||||
the file `C:\foo\bar.txt` can be accessed from Linux at `/mnt/c/foo/bar.txt`,
|
||||
using a mechanism similar to a network drive. Unfortunately, inode pinning is
|
||||
not guaranteed to work for these. While we can still pin the inode for
|
||||
`/mnt/c/foo/bar.txt`, this doesn't pin the underlying Windows filesystem object,
|
||||
and so it's possible for this to change during the lifetime of a sandbox, which
|
||||
could potentially allow writes outside the confines of the sandbox.
|
||||
|
||||
#### How big a risk is this in practice? {#how-big-a-risk}
|
||||
|
||||
**In our testing, we have not been able to exploit this issue to escape the
|
||||
sandbox.**
|
||||
|
||||
In practice, the mapping tends to be quite stable. This is because the inode
|
||||
that gets generated in the Linux filesystem is derived from the _file reference_
|
||||
(very loosely, a "Windows inode"), which has similar stability to a Linux inode.
|
||||
The standard "rename a subcomponent" attack seems to produce a different inode
|
||||
number, and so the in-sandbox check would fail-closed.
|
||||
|
||||
But, crucially, _it is not guaranteed_. This is the behaviour we observed in
|
||||
testing, but could not find documentation guaranteeing this behaviour in all
|
||||
circumstances, for all Windows/WSL versions past and present, regardless of
|
||||
configuration options.
|
||||
|
||||
Zed's sandbox has been designed with the aim of being totally unbreakable, even
|
||||
in the presence of a motivated attacker with full control over the files in your
|
||||
project running as a standard user. It does _not_ assume a
|
||||
mostly-benevolent-but-sometimes-careless agent.
|
||||
|
||||
Given this, we cannot give the same guarantees about sandbox security as we do
|
||||
for more standard cases. However, even with this weakened guarantee, the sandbox
|
||||
still helps keep you much safer than without it, so it's worth keeping enabled.
|
||||
|
||||
## Choosing What to Approve {#choosing-what-to-approve}
|
||||
|
||||
When reviewing a sandbox prompt, prefer the narrowest permission that lets the task proceed:
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
#
|
||||
# fs = "restricted"; # or "unrestricted"
|
||||
# writablePaths = [ ]; # writable subtrees when fs = "restricted"
|
||||
# seedFiles = [ ]; # regular files to create before building the
|
||||
# # policy (e.g. a single-file-worktree root)
|
||||
# networkAccess = "blocked"; # or "unrestricted" / "restricted"
|
||||
# allowedDomains = [ ]; # allowed hosts when networkAccess = "restricted"
|
||||
# protectedPaths = [ ]; # paths that remain readable but not writable,
|
||||
@@ -251,6 +253,27 @@ in
|
||||
succeeds = true;
|
||||
}
|
||||
|
||||
# Regression: a protected `.git` path routed through a regular file must
|
||||
# not break sandbox creation. A single-file worktree (e.g. `settings.json`
|
||||
# opened on its own) is rooted at the file, so the agent synthesizes
|
||||
# `settings.json/.git` as a protected path; capturing it reports
|
||||
# `NotADirectory`. That case must be *skipped* (the path can't exist, so
|
||||
# there's nothing to protect), not treated as a fatal capture error — the
|
||||
# bug that surfaced as a "cannot capture protected sandbox path
|
||||
# .../settings.json/.git" popup. `seedFiles` makes `settings.json` a real
|
||||
# file so the protected path routes through it; the sandbox must still be
|
||||
# created and a normal write inside the worktree must succeed.
|
||||
{
|
||||
name = "protected .git routed through a regular file is skipped, not fatal";
|
||||
fs = "restricted";
|
||||
writablePaths = [ "/sandbox-test/single-file-worktree" ];
|
||||
seedFiles = [ "/sandbox-test/single-file-worktree/settings.json" ];
|
||||
protectedPaths = [ "/sandbox-test/single-file-worktree/settings.json/.git" ];
|
||||
networkAccess = "blocked";
|
||||
write = "/sandbox-test/single-file-worktree/ok.txt";
|
||||
succeeds = true;
|
||||
}
|
||||
|
||||
# The fs escape hatch lets a command write anywhere.
|
||||
{
|
||||
fs = "unrestricted";
|
||||
|
||||
Reference in new issue
Block a user