Protect Git metadata in Windows sandboxes (#59799)

Summary:

- Protect Git metadata in Windows/WSL sandboxes by passing Git policy
into the WSL Bubblewrap wrapper.
- Map protected Git paths and overlay them read-only, while allowing
approved Git metadata paths to be writable.
- Update tool descriptions and sandboxing docs for platform-specific Git
metadata behavior.

Validation:

- cargo check -p sandbox
- cargo check -p sandbox --tests --target x86_64-pc-windows-msvc
- cargo check -p agent -p acp_thread
- git --no-pager diff --check

Release Notes:

- Improved Windows agent sandboxing to protect Git metadata unless
access is approved
This commit is contained in:
MartinYe1234 authored and GitHub committed 2026-06-29 09:54:36 +00:00
1 parent ce732be991
commit 9089a7235e
5 files changed
+335 -107

No files matched your search

+3 -3
View File
@@ -24,9 +24,9 @@ use util::get_default_system_shell_preferring_bash;
/// Request to run a terminal command inside an OS-level sandbox.
///
/// Passed to [`super::AcpThread::create_terminal`]. The actual sandboxing
/// mechanism is platform-specific (macOS Seatbelt; Linux Bubblewrap; a no-op
/// on other platforms), so callers describe the *intent* with plain data here
/// rather than constructing platform-specific types directly.
/// mechanism is platform-specific (macOS Seatbelt; Linux Bubblewrap; Windows
/// via Bubblewrap inside WSL), so callers describe the *intent* with plain data
/// here rather than constructing platform-specific types directly.
///
/// Default is the fully-sandboxed run (no network, project-only writes).
/// Setting `network` / `allow_fs_write` requests a relaxation; the caller is
+7 -7
View File
@@ -163,14 +163,14 @@ pub struct SandboxedTerminalToolInput {
/// set of paths is known. Requesting it triggers a user approval prompt.
#[serde(default, alias = "allow_fs_write")]
pub allow_fs_write_all: Option<bool>,
/// Set to `true` when the command needs to read or write protected Git
/// metadata.
/// Set to `true` when the command needs access to protected Git metadata.
///
/// By default sandboxed commands can't read the file contents of, or write
/// to, the `.git` directories of opened worktrees and discovered
/// repositories (their metadata stays visible). Set this for Git operations
/// that need those paths (commit, fetch, rebase, …). Requesting it triggers
/// a user approval prompt.
/// By default sandboxed commands can't write to the `.git` directories of
/// opened worktrees and discovered repositories. On macOS, `.git` file
/// contents are also hidden while metadata stays visible; on Linux and
/// Windows/WSL, `.git` contents remain readable but are mounted read-only.
/// Set this for Git operations that need to write those paths (commit,
/// fetch, rebase, …). Requesting it triggers a user approval prompt.
#[serde(default)]
pub allow_git_access: Option<bool>,
/// Set to `true` only as a last resort, to run the command fully outside
+8 -8
View File
@@ -69,8 +69,9 @@ pub enum SandboxNetPolicy {
/// treated.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GitSandboxPolicy {
/// `.git` contents are protected: read-only on Linux; content reads and
/// writes denied on macOS (metadata stays visible either way).
/// `.git` contents are protected: read-only on Linux and Windows/WSL;
/// content reads and writes denied on macOS (metadata stays visible either
/// way).
Denied { git_dirs: Vec<PathBuf> },
/// `.git` contents are writable (these dirs are made writable).
Allowed { git_dirs: Vec<PathBuf> },
@@ -311,8 +312,7 @@ enum NetSetup {
pub struct Sandbox {
fs: FsSetup,
network: NetSetup,
/// The project's `.git` directories and whether they're writable. Enforced
/// on macOS/Linux; ignored by the WSL sandbox for now.
/// The project's `.git` directories and whether they're writable.
git: GitSandboxPolicy,
/// In-process network proxy for the restricted-network case, spawned on the
/// first `wrap`. Dropped on a background thread (the join blocks).
@@ -490,7 +490,7 @@ impl Sandbox {
/// the enforcement layers. When the whole filesystem is writable the split
/// is empty (Git protection is moot); otherwise `Allowed` git dirs are
/// writable and `Denied` git dirs are protected.
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
fn git_path_split(&self) -> (Vec<PathBuf>, Vec<PathBuf>) {
if self.fs.allow_fs_write {
return (Vec::new(), Vec::new());
@@ -610,17 +610,17 @@ impl Sandbox {
command: &CommandAndArgs,
) -> Result<WrappedCommand, SandboxError> {
// Restricted host network access is rejected at `new` time on Windows.
// Git-dir protection isn't enforced for the WSL sandbox yet, so the
// policy's Git directories are intentionally ignored here.
let _ = self.git.git_dirs();
let permissions = windows_wsl::SandboxPermissions {
allow_network: matches!(self.network, NetSetup::Unrestricted),
allow_fs_write: self.fs.allow_fs_write,
};
let (writable_git_paths, protected_git_paths) = self.git_path_split();
let (program, args) = windows_wsl::wrap_invocation(
command.program.clone(),
command.args.clone(),
self.fs.writable_paths.clone(),
writable_git_paths,
protected_git_paths,
permissions,
command.cwd.clone(),
command.env.clone(),
+271 -56
View File
@@ -141,10 +141,12 @@ impl PathMapping {
/// into a Linux shell invocation (typically `/bin/sh -c ...`) before calling
/// this function.
///
/// All writable paths and the cwd must be paths that can be mapped into WSL.
/// WSL UNC paths may specify a distro; native drive-letter paths are
/// translated with `wslpath` inside either that distro or the default distro
/// (falling back to `/mnt/<drive>/...` if translation fails).
/// All writable paths, Git paths, and the cwd must be paths that can be mapped
/// into WSL. The cwd and ordinary writable paths must exist; Git metadata paths
/// may be missing because Bubblewrap cannot bind a missing source. WSL UNC paths
/// may specify a distro; native drive-letter paths are translated with `wslpath`
/// inside either that distro or the default distro (falling back to
/// `/mnt/<drive>/...` if translation fails).
///
/// `env` is forwarded into the sandboxed command via `bwrap --setenv` rather
/// than being set on the `wsl.exe` process. Windows environment variables
@@ -170,6 +172,8 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
program: String,
args: Vec<String>,
writable_paths: Vec<PathBuf>,
writable_git_paths: Vec<PathBuf>,
protected_git_paths: Vec<PathBuf>,
permissions: SandboxPermissions,
cwd: Option<PathBuf>,
env: HashMap<String, String, S>,
@@ -193,8 +197,36 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
})
})
.collect::<Result<Vec<_>>>()?;
let writable_git_mappings = writable_git_paths
.iter()
.map(|path| {
path_to_wsl_allowing_missing(path).with_context(|| {
format!(
"failed to map writable Git metadata path `{}` into WSL",
path.display()
)
})
})
.collect::<Result<Vec<_>>>()?;
let protected_git_mappings = protected_git_paths
.iter()
.map(|path| {
path_to_wsl_allowing_missing(path).with_context(|| {
format!(
"failed to map protected Git metadata path `{}` into WSL",
path.display()
)
})
})
.collect::<Result<Vec<_>>>()?;
let distro = select_distro(cwd_mapping.as_ref(), &writable_mappings)?;
let distro = select_distro(
cwd_mapping.as_ref(),
writable_mappings
.iter()
.chain(writable_git_mappings.iter())
.chain(protected_git_mappings.iter()),
)?;
let wsl_exe = wsl_exe_path();
if !wsl_exe.is_file() {
return Err(unavailable(format!(
@@ -205,23 +237,40 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
let environment = probe_environment(&wsl_exe, distro.as_deref()).await?;
// Resolve all paths (translating native drive-letter paths with `wslpath`
// now that the distro is known) and confirm they exist, in a single WSL
// round-trip.
// now that the distro is known) in a single WSL round-trip. The cwd and
// ordinary writable paths must exist; Git paths may be missing because, as
// with Linux bwrap, a not-yet-created `.git` placeholder can't be overlaid.
let has_cwd = cwd_mapping.is_some();
let mut mappings = Vec::with_capacity(writable_mappings.len() + 1);
let writable_path_count = writable_mappings.len();
let writable_git_path_count = writable_git_mappings.len();
let mut mappings = Vec::with_capacity(
writable_mappings.len() + writable_git_mappings.len() + protected_git_mappings.len() + 1,
);
if let Some(mapping) = cwd_mapping {
mappings.push((mapping, "terminal cwd"));
mappings.push((mapping, "terminal cwd", true));
}
mappings.extend(
writable_mappings
.into_iter()
.map(|mapping| (mapping, "writable path")),
.map(|mapping| (mapping, "writable path", true)),
);
let mut resolved = resolve_paths(&wsl_exe, distro.as_deref(), &mappings)
.await?
.into_iter();
let cwd = if has_cwd { resolved.next() } else { None };
let writable_paths: Vec<String> = resolved.collect();
mappings.extend(
writable_git_mappings
.into_iter()
.map(|mapping| (mapping, "writable Git metadata path", false)),
);
mappings.extend(
protected_git_mappings
.into_iter()
.map(|mapping| (mapping, "protected Git metadata path", false)),
);
let resolved = resolve_paths(&wsl_exe, distro.as_deref(), &mappings).await?;
let (cwd, writable_paths, protected_git_paths) = split_resolved_paths(
has_cwd,
writable_path_count,
writable_git_path_count,
resolved,
)?;
let mut wsl_args = Vec::new();
if let Some(distro) = distro.as_deref() {
@@ -236,6 +285,7 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
wsl_args.extend(["--exec".to_string(), environment.bwrap_path.clone()]);
wsl_args.extend(build_bwrap_args(
&writable_paths,
&protected_git_paths,
permissions,
cwd.as_deref(),
environment.mask_interop_dir,
@@ -248,12 +298,51 @@ pub async fn wrap_invocation<S: std::hash::BuildHasher>(
Ok((wsl_exe.to_string_lossy().into_owned(), wsl_args))
}
fn select_distro(
fn split_resolved_paths(
has_cwd: bool,
writable_path_count: usize,
writable_git_path_count: usize,
resolved: Vec<Option<String>>,
) -> Result<(Option<String>, Vec<String>, Vec<String>)> {
let mut resolved = resolved.into_iter();
let cwd = if has_cwd {
Some(
resolved
.next()
.context("bug: missing resolved terminal cwd")?
.context("bug: required terminal cwd resolved as missing")?,
)
} else {
None
};
let mut writable_paths = Vec::with_capacity(writable_path_count + writable_git_path_count);
for _ in 0..writable_path_count {
writable_paths.push(
resolved
.next()
.context("bug: missing resolved writable path")?
.context("bug: required writable path resolved as missing")?,
);
}
for _ in 0..writable_git_path_count {
if let Some(path) = resolved
.next()
.context("bug: missing resolved writable Git metadata path")?
{
writable_paths.push(path);
}
}
Ok((cwd, writable_paths, resolved.flatten().collect()))
}
fn select_distro<'a>(
cwd: Option<&PathMapping>,
writable_paths: &[PathMapping],
paths: impl IntoIterator<Item = &'a PathMapping>,
) -> Result<Option<String>> {
let mut distro = cwd.and_then(|mapping| mapping.distro().map(str::to_string));
for mapping in writable_paths {
for mapping in paths {
let Some(path_distro) = mapping.distro() else {
continue;
};
@@ -444,7 +533,7 @@ struct ResolvedPath {
exists: bool,
}
/// Resolve path mappings into final WSL paths and confirm they exist.
/// Resolve path mappings into final WSL paths and confirm required paths exist.
/// Native drive-letter paths are translated with `wslpath -u` inside the
/// chosen distro so its actual automount configuration is honored, falling
/// back to the structural `/mnt/<drive>` mapping when translation fails
@@ -457,33 +546,37 @@ struct ResolvedPath {
/// handles all cache misses ([`resolve_uncached_paths`]). A hit reuses the
/// translation — which only changes if the distro's automount configuration
/// is edited and the distro restarted — and also skips the WSL-side
/// existence re-check. That staleness is acceptable: native and UNC paths
/// are still stat'ed on the Windows side on every command (see
/// [`path_to_wsl`] / [`directory_to_wsl`]), and if a cached path disappears
/// mid-session bwrap fails closed on the missing bind source rather than
/// running the command unsandboxed. Failures are not cached, so a missing
/// path can be created and retried.
/// existence re-check. That staleness is acceptable: if a cached path
/// disappears mid-session bwrap fails closed on the missing bind source rather
/// than running the command unsandboxed. Optional missing paths are not cached,
/// so a protected Git path can be created and then included by a later command.
///
/// Each mapping is paired with a human-readable description used in errors.
/// The returned paths are in the same order as `mappings`.
/// Each mapping is paired with a human-readable description used in errors and
/// a flag for whether the path is required to exist. The returned paths are in
/// the same order as `mappings`; optional missing paths are returned as `None`.
async fn resolve_paths(
wsl_exe: &Path,
distro: Option<&str>,
mappings: &[(PathMapping, &str)],
) -> Result<Vec<String>> {
mappings: &[(PathMapping, &str, bool)],
) -> Result<Vec<Option<String>>> {
type ResolutionCache = HashMap<Option<String>, HashMap<PathMapping, String>>;
static CACHE: OnceLock<Mutex<ResolutionCache>> = OnceLock::new();
let cache = CACHE.get_or_init(Default::default);
let distro_key = distro.map(str::to_string);
let mut resolved: Vec<Option<String>> = {
let mut resolved: Vec<Option<Option<String>>> = {
let cache = cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let per_distro = cache.get(&distro_key);
mappings
.iter()
.map(|(mapping, _)| per_distro.and_then(|cached| cached.get(mapping)).cloned())
.map(|(mapping, _, _)| {
per_distro
.and_then(|cached| cached.get(mapping))
.cloned()
.map(Some)
})
.collect()
};
@@ -491,7 +584,7 @@ async fn resolve_paths(
.filter(|&index| resolved[index].is_none())
.collect();
if !misses.is_empty() {
let miss_mappings: Vec<&(PathMapping, &str)> =
let miss_mappings: Vec<&(PathMapping, &str, bool)> =
misses.iter().map(|&index| &mappings[index]).collect();
let miss_resolved = resolve_uncached_paths(wsl_exe, distro, &miss_mappings).await?;
let mut cache = cache
@@ -499,15 +592,18 @@ async fn resolve_paths(
.unwrap_or_else(|poisoned| poisoned.into_inner());
let per_distro = cache.entry(distro_key).or_default();
for (&index, path) in misses.iter().zip(miss_resolved) {
per_distro.insert(mappings[index].0.clone(), path.clone());
if let Some(path) = &path {
per_distro.insert(mappings[index].0.clone(), path.clone());
}
resolved[index] = Some(path);
}
}
resolved
.into_iter()
.collect::<Option<Vec<_>>>()
.context("bug: a path mapping was left unresolved")
let mut paths = Vec::with_capacity(resolved.len());
for path in resolved {
paths.push(path.context("bug: a path mapping was left unresolved")?);
}
Ok(paths)
}
/// Resolve and existence-check mappings that weren't in the cache, in a
@@ -516,8 +612,8 @@ async fn resolve_paths(
async fn resolve_uncached_paths(
wsl_exe: &Path,
distro: Option<&str>,
mappings: &[&(PathMapping, &str)],
) -> Result<Vec<String>> {
mappings: &[&(PathMapping, &str, bool)],
) -> Result<Vec<Option<String>>> {
let mut args = vec![
"--exec".to_string(),
"sh".to_string(),
@@ -527,7 +623,7 @@ async fn resolve_uncached_paths(
"zed-resolve-paths".to_string(),
];
args.extend(path_resolution_args(
mappings.iter().map(|(mapping, _)| mapping),
mappings.iter().map(|mapping| &mapping.0),
));
let output = run_wsl_command(wsl_exe, distro, &args, "resolve sandbox paths").await?;
if !output.status.success() {
@@ -549,7 +645,7 @@ async fn resolve_uncached_paths(
mappings
.iter()
.zip(resolved)
.map(|((mapping, description), resolved)| {
.map(|((mapping, description, required), resolved)| {
if resolved.used_fallback
&& let PathMapping::NativeDrive { windows_path, .. } = mapping
{
@@ -560,16 +656,21 @@ async fn resolve_uncached_paths(
resolved.path
);
}
// A bad request (the path simply isn't there), not an
// environment problem — the model can create it or fix the path
// and retry, so no `WSL_SANDBOX_UNAVAILABLE_PREFIX`.
ensure!(
resolved.exists,
"mapped {description} `{}` does not exist in {}",
resolved.path,
wsl_distro_label(distro)
);
Ok(resolved.path)
if !resolved.exists {
// A bad request (the path simply isn't there), not an
// environment problem — the model can create it or fix the path
// and retry, so no `WSL_SANDBOX_UNAVAILABLE_PREFIX`. Protected
// Git paths are allowed to be absent, matching Linux bwrap's
// behavior: a missing path cannot be overlaid, so it is skipped.
ensure!(
!required,
"mapped {description} `{}` does not exist in {}",
resolved.path,
wsl_distro_label(distro)
);
return Ok(None);
}
Ok(Some(resolved.path))
})
.collect()
}
@@ -709,6 +810,7 @@ fn wsl_exe_path() -> PathBuf {
fn build_bwrap_args<S: std::hash::BuildHasher>(
writable_paths: &[String],
protected_git_paths: &[String],
permissions: SandboxPermissions,
cwd: Option<&str>,
mask_interop_dir: bool,
@@ -724,6 +826,12 @@ fn build_bwrap_args<S: std::hash::BuildHasher>(
for path in writable_paths {
push_bind(&mut args, "--bind", path, path);
}
// Protect Git metadata by re-binding it read-only over the writable
// worktree binds above (order matters: later binds win). When Git access
// is granted these paths are included in `writable_paths` instead.
for path in protected_git_paths {
push_bind(&mut args, "--ro-bind", path, path);
}
}
// Block WSL's Windows interop, regardless of the requested permissions.
@@ -893,6 +1001,14 @@ fn path_to_wsl(path: &Path) -> Result<PathMapping> {
map_path_to_wsl(path)
}
fn path_to_wsl_allowing_missing(path: &Path) -> Result<PathMapping> {
let path_string = path.to_string_lossy();
if let Ok(path) = parse_wsl_absolute_path(&path_string) {
return Ok(PathMapping::Wsl(path));
}
map_path_to_wsl(path)
}
fn map_path_to_wsl(path: &Path) -> Result<PathMapping> {
let path_string = path.to_string_lossy();
if let Ok(path) = parse_wsl_unc_path(&path_string) {
@@ -984,6 +1100,8 @@ mod tests {
String::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
SandboxPermissions::default(),
None,
HashMap::<String, String>::new(),
@@ -1123,10 +1241,53 @@ mod tests {
assert!(guard_index < smoke_test_index);
}
#[test]
fn split_resolved_paths_keeps_existing_writable_git_paths_and_skips_missing_ones() {
let (cwd, writable_paths, protected_git_paths) = split_resolved_paths(
true,
1,
2,
vec![
Some("/home/me/project".to_string()),
Some("/home/me/project".to_string()),
None,
Some("/home/me/project/.git".to_string()),
None,
Some("/mnt/c/external/.git".to_string()),
],
)
.unwrap();
assert_eq!(cwd.as_deref(), Some("/home/me/project"));
assert_eq!(
writable_paths,
vec![
"/home/me/project".to_string(),
"/home/me/project/.git".to_string()
]
);
assert_eq!(
protected_git_paths,
vec!["/mnt/c/external/.git".to_string()]
);
}
#[test]
fn split_resolved_paths_rejects_missing_required_writable_paths() {
let error = split_resolved_paths(false, 1, 0, vec![None]).unwrap_err();
assert!(
error
.to_string()
.contains("required writable path resolved as missing"),
"unexpected error: {error:#}"
);
}
#[test]
fn bwrap_denies_network_by_default() {
let args = build_bwrap_args(
&["/home/me/project".to_string()],
&[],
SandboxPermissions::default(),
Some("/home/me/project"),
true,
@@ -1142,6 +1303,7 @@ mod tests {
#[test]
fn bwrap_allows_network_when_requested() {
let args = build_bwrap_args(
&[],
&[],
SandboxPermissions {
allow_network: true,
@@ -1158,6 +1320,7 @@ mod tests {
fn bwrap_binds_explicit_writable_file_paths() {
let args = build_bwrap_args(
&["/mnt/c/Users/me/AppData/Roaming/Zed/AGENTS.md".to_string()],
&[],
SandboxPermissions::default(),
None,
true,
@@ -1171,10 +1334,60 @@ mod tests {
]));
}
#[test]
fn bwrap_protects_git_paths_after_writable_paths() {
let args = build_bwrap_args(
&["/home/me/project".to_string()],
&["/home/me/project/.git".to_string()],
SandboxPermissions::default(),
Some("/home/me/project"),
true,
&HashMap::new(),
);
let writable_index = args
.windows(3)
.position(|window| window == ["--bind", "/home/me/project", "/home/me/project"])
.expect("project should be writable");
let protected_index = args
.windows(3)
.position(|window| {
window
== [
"--ro-bind",
"/home/me/project/.git",
"/home/me/project/.git",
]
})
.expect("Git metadata should be protected read-only");
assert!(protected_index > writable_index);
}
#[test]
fn bwrap_skips_git_protection_when_fs_writes_are_unrestricted() {
let args = build_bwrap_args(
&[],
&["/home/me/project/.git".to_string()],
SandboxPermissions {
allow_network: false,
allow_fs_write: true,
},
None,
true,
&HashMap::new(),
);
assert!(!args.windows(3).any(|window| window
== [
"--ro-bind",
"/home/me/project/.git",
"/home/me/project/.git"
]));
}
#[test]
fn bwrap_blocks_wsl_interop_by_default() {
let args = build_bwrap_args(
&["/home/me/project".to_string()],
&[],
SandboxPermissions::default(),
Some("/home/me/project"),
true,
@@ -1193,6 +1406,7 @@ mod tests {
#[test]
fn bwrap_blocks_wsl_interop_even_with_fs_write() {
let args = build_bwrap_args(
&[],
&[],
SandboxPermissions {
allow_network: true,
@@ -1222,6 +1436,7 @@ mod tests {
// so the mount must be omitted. Unsetting the variable is harmless and
// stays.
let args = build_bwrap_args(
&[],
&[],
SandboxPermissions::default(),
None,
@@ -1241,7 +1456,7 @@ mod tests {
("PAGER".to_string(), String::new()),
("CARGO_TERM_COLOR".to_string(), "always".to_string()),
]);
let args = build_bwrap_args(&[], SandboxPermissions::default(), None, false, &env);
let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
assert!(
args.windows(3)
.any(|window| window == ["--setenv", "PAGER", ""])
@@ -1262,7 +1477,7 @@ mod tests {
("WsLeNv".to_string(), "WSL_INTEROP/u".to_string()),
("PAGER".to_string(), String::new()),
]);
let args = build_bwrap_args(&[], SandboxPermissions::default(), None, false, &env);
let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
assert!(
args.windows(2)
@@ -1333,7 +1548,7 @@ mod tests {
("PROCESSOR_ARCHITECTURE".to_string(), "AMD64".to_string()),
("NUMBER_OF_PROCESSORS".to_string(), "16".to_string()),
]);
let args = build_bwrap_args(&[], SandboxPermissions::default(), None, false, &env);
let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
assert!(!args.iter().any(|arg| arg == "--setenv"));
}
@@ -1346,7 +1561,7 @@ mod tests {
("LANG".to_string(), "en_US.UTF-8".to_string()),
("CARGO_TERM_COLOR".to_string(), "always".to_string()),
]);
let args = build_bwrap_args(&[], SandboxPermissions::default(), None, false, &env);
let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
assert!(
args.windows(3)
.any(|window| window == ["--setenv", "LANG", "en_US.UTF-8"])
@@ -1373,7 +1588,7 @@ mod tests {
(String::new(), "value".to_string()),
("OK".to_string(), "value".to_string()),
]);
let args = build_bwrap_args(&[], SandboxPermissions::default(), None, false, &env);
let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
assert!(
args.windows(3)
.any(|window| window == ["--setenv", "OK", "value"])
+46 -33
View File
@@ -9,14 +9,18 @@ You can restrict what operations the [Zed Agent](./zed-agent.md) can run in mult
[Tool Permissions](./tool-permissions.md), but these are of limited use when the agent wants to do things like run a
complicated script in a terminal.
Sandboxing runs certain tool actions in an OS-level sandbox which limits filesystem access, network access, and access to protected Git metadata. This way, even if the agent wants to run an arbitrary script, that script will only be able to write to the files and folders you have allowed it to. You can similarly restrict network and Git metadata access in sandboxed tool actions.
Sandboxing runs certain tool actions in an OS-level sandbox which limits filesystem access, network access, and access to
protected Git metadata. This way, even if the agent wants to run an arbitrary script, that script will only be able to
write to the files and folders you have allowed it to. You can similarly restrict network and Git metadata access in
sandboxed tool actions.
[Tool Permissions](./tool-permissions.md) can be used in addition to sandboxing:
- Tool permissions restrict the agent's ability to run certain tool actions in the first place
- Once a tool action is actually running, sandboxing restricts what it can do
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).
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).
## Sandboxed Tools {#sandboxed-tools}
@@ -26,20 +30,21 @@ Zed Agent sandboxing currently applies to the `terminal` tool.
| ---------- | --------------------------------------------------------------------------------------------------- |
| `terminal` | Filesystem writes, protected Git metadata, and outbound network access for commands the agent runs. |
Other built-in tools, including `fetch`, are still governed by [Tool Permissions](./tool-permissions.md), [Agent Profiles](./agent-profiles.md), and project trust, but they are not currently run inside this OS sandbox.
Other built-in tools, including `fetch`, are still governed by [Tool Permissions](./tool-permissions.md),
[Agent Profiles](./agent-profiles.md), and project trust, but they are not currently run inside this OS sandbox.
## Default Access {#default-access}
By default, sandboxed Zed Agent tool actions have these restrictions:
| Access type | Default behavior |
| ------------------- | --------------------------------------------------------------------------------------------------------- |
| Filesystem reads | Terminal commands can read the filesystem, except for protected Git metadata file contents. |
| Project writes | Terminal commands can write inside open project directories, except for protected Git metadata. |
| Git metadata | `.git` directories and linked worktree Git metadata are protected unless you approve Git metadata access. |
| Temporary files | Terminal commands receive a writable temporary location. The exact behavior differs by platform. |
| Other writes | Writes outside the default writable locations are blocked unless you approve a broader sandbox request. |
| Outbound networking | Network access is blocked unless you approve a host-specific or unrestricted network sandbox request. |
| Access type | Default behavior |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Filesystem reads | Terminal commands can read most of the filesystem. Protected Git metadata file contents are hidden on macOS; on Linux and Windows/WSL they remain readable. |
| Project writes | Terminal commands can write inside open project directories, except for protected Git metadata. |
| Git metadata | `.git` directories and linked worktree Git metadata are protected unless you approve Git metadata access. Protection details differ by platform, described below. |
| Temporary files | Terminal commands receive a writable temporary location. The exact behavior differs by platform. |
| Other writes | Writes outside the default writable locations are blocked unless you approve a broader sandbox request. |
| Outbound networking | Network access is blocked unless you approve a host-specific or unrestricted network sandbox request. Host-specific enforcement is not available on every platform. |
## Approval Prompts {#approval-prompts}
@@ -59,8 +64,8 @@ You can grant a sandbox request for:
- the rest of the current thread
- always
Approvals for the rest of the thread are remembered only for that thread.
Approvals granted with “always” are saved in `settings.json` under `agent.sandbox_permissions`.
Approvals for the rest of the thread are remembered only for that thread. Approvals granted with “always” are saved in
`settings.json` under `agent.sandbox_permissions`.
## Persistent Sandbox Permissions {#persistent-sandbox-permissions}
@@ -89,12 +94,13 @@ The available options are:
| `allow_fs_write_all` | Allow sandboxed terminal commands to write anywhere without prompting. |
| `allow_unsandboxed` | Allow terminal commands to run outside the sandbox without prompting when the agent explicitly requests it. |
Prefer narrow grants, such as a specific host, Git metadata access, or write path, over `allow_all_hosts`, `allow_fs_write_all`, or `allow_unsandboxed`.
Prefer narrow grants, such as a specific host, Git metadata access, or write path, over `allow_all_hosts`,
`allow_fs_write_all`, or `allow_unsandboxed`.
## Platform Support {#platform-support}
Sandboxing uses different operating system mechanisms on each platform.
The user-facing prompts are similar, but the enforcement details vary.
Sandboxing uses different operating system mechanisms on each platform. The user-facing prompts are similar, but the
enforcement details vary.
### macOS {#macos}
@@ -117,52 +123,59 @@ For networked terminal commands, prefer HTTPS URLs over SSH URLs when possible.
On Linux, Zed uses Bubblewrap (`bwrap`) for sandboxing.
Zed only uses a non-setuid `bwrap` binary.
Its sandbox is built entirely on unprivileged user namespaces, so a setuid-root `bwrap` provides no extra functionality, and running one would mean executing root-privileged setup with arguments partly derived from model-influenced input.
If the only `bwrap` found on your `PATH` is setuid-root, Zed refuses to run it; install a non-setuid Bubblewrap to enable sandboxing.
Zed only uses a non-setuid `bwrap` binary. Its sandbox is built entirely on unprivileged user namespaces, so a setuid-root
`bwrap` provides no extra functionality, and running one would mean executing root-privileged setup with arguments partly
derived from model-influenced input. If the only `bwrap` found on your `PATH` is setuid-root, Zed refuses to run it;
install a non-setuid Bubblewrap to enable sandboxing.
Sandboxed terminal commands:
- can read the filesystem
- can read the filesystem, including protected Git metadata contents
- can write inside open project directories, except protected Git metadata
- can write to `/tmp`, which is backed by a fresh temporary filesystem and is cleared between terminal tool calls
- cannot read or write protected Git metadata unless you approve Git metadata access
- cannot write protected Git metadata unless you approve Git metadata access
- cannot write elsewhere unless you approve additional paths or broader write access
- cannot reach the network unless you approve network access
Linux network sandboxing can allow or block outbound networking as a whole, but cannot enforce a per-host allowlist.
If you approve network access for one host on Linux, the sandbox must grant unrestricted outbound network access for that tool action.
Zed still asks for the narrower request when that is what the agent asked for, but the platform enforcement is all-or-nothing.
When host-specific network access is approved on Linux, Zed uses an HTTP/HTTPS proxy so access can be limited to approved
hosts. Tools that do not honor proxy environment variables, such as SSH, FTP, and raw socket clients, may not work even
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.
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.
### Windows {#windows}
On Windows, Zed Agent sandboxing is supported only when the agent action runs inside WSL.
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.
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.
When running inside WSL, the Linux sandboxing behavior applies, including the requirement that `bwrap` not be setuid-root:
- filesystem isolation is provided by Bubblewrap
- protected Git metadata requires Git metadata access approval
- protected Git metadata contents remain readable, but writes are blocked unless you approve Git metadata access
- `/tmp` is temporary for sandboxed terminal calls
- network access is all-or-nothing rather than host-specific
If WSL is not installed, or if you choose to run a command without the sandbox, Zed falls back to the standard terminal behavior of running in your native shell.
It selects the shell using the usual preference order: Git Bash (or scoop's bash) when one is installed, otherwise PowerShell, and finally `cmd.exe`.
Because the command then runs against native 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.
If WSL is not installed, or if you choose to run a command without the sandbox, Zed falls back to the standard terminal
behavior of running in your native shell. It selects the shell using the usual preference order: Git Bash (or scoop's
bash) when one is installed, otherwise PowerShell, and finally `cmd.exe`. Because the command then runs against native
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.
## Choosing What to Approve {#choosing-what-to-approve}
When reviewing a sandbox prompt, prefer the narrowest permission that lets the task proceed:
- approve a specific host instead of all hosts when the destination is known
- approve Git metadata access when the command needs to run Git operations such as `git fetch`, `git commit`, or `git status`
- approve Git metadata access when the command needs to run Git operations such as `git fetch`, `git commit`, or
`git status`
- approve a specific write path instead of unrestricted filesystem writes
- approve unsandboxed execution only when the command cannot work inside the sandbox
- use one-time approvals for unfamiliar commands
- use thread or always approvals only for access you expect to reuse
If a command fails because the sandbox blocked access, ask the agent why it needs that access before approving a broader request.
If a command fails because the sandbox blocked access, ask the agent why it needs that access before approving a broader
request.