diff --git a/Cargo.lock b/Cargo.lock index ab1ad792fb..5ab20e6db9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -288,6 +288,7 @@ dependencies = [ "quick-xml 0.38.3", "rand 0.9.4", "regex", + "release_channel", "reqwest_client", "rust-embed", "sandbox", @@ -11441,6 +11442,7 @@ dependencies = [ "cfg-if", "cfg_aliases 0.2.1", "libc", + "memoffset", ] [[package]] @@ -15972,6 +15974,7 @@ dependencies = [ "http_proxy", "libc", "log", + "nix 0.29.0", "serde", "serde_json", "smol", diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 2f495df96a..d060ecc26a 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -3952,14 +3952,15 @@ impl AcpThread { let (task_command, task_args) = builder .redirect_stdin_to_dev_null() .build(Some(command.clone()), &args); - let (task_command, task_args, task_env, sandbox) = prepare_sandbox_wrap( - task_command, - task_args, - cwd.clone(), - sandbox_wrap, - env, - ) - .await?; + let (task_command, task_args, task_env, sandbox) = cx + .background_spawn(prepare_sandbox_wrap( + task_command, + task_args, + cwd.clone(), + sandbox_wrap, + env, + )) + .await?; (task_command, task_args, task_env, sandbox, cwd.clone()) }; let terminal = project diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index b39d1e52a4..7526ad4a1b 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -59,6 +59,13 @@ pub struct SandboxWrap { /// enforcing proxy binds a loopback port on this host, so it can only /// confine local commands; a remote terminal can't reach it. pub is_local: bool, + /// Windows/WSL only: `(release channel, version)` of the Linux `zed` to + /// provision inside WSL as the sandbox helper (version `latest` for dev + /// builds). Resolved by the agent (which can read the running app's release + /// info) and forwarded to the sandbox. `None` on other platforms, or when + /// the release can't be determined, in which case the WSL backend falls back + /// to running bwrap without in-sandbox bind validation. + pub wsl_zed_release: Option<(String, String)>, } #[derive(Clone, Debug, Default)] @@ -137,26 +144,32 @@ impl SandboxWrap { /// (fail-open), or refuse (fail-closed). It runs a brief probe subprocess on /// Linux, so call it off the main thread. On platforms whose sandbox can't /// fail to set up this way it always returns `Ok`. - pub fn can_create_sandbox( - &self, - cwd: Option<&std::path::Path>, - ) -> Result<(), LinuxWslSandboxError> { - sandbox::Sandbox::can_create(&self.to_policy(), cwd).map_err(LinuxWslSandboxError::from) + pub fn can_create_sandbox(&self) -> Result<(), LinuxWslSandboxError> { + sandbox::Sandbox::can_create(&self.to_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. fn to_policy(&self) -> sandbox::SandboxPolicy { let fs = if self.allow_fs_write { sandbox::SandboxFsPolicy::Unrestricted } else { - sandbox::SandboxFsPolicy::Restricted { - writable_paths: self - .writable_paths - .iter() - .cloned() - .chain(self.extra_write_paths.iter().cloned()) - .collect(), - } + let writable_paths = self + .writable_paths + .iter() + .chain(self.extra_write_paths.iter()) + .filter_map(|path| { + // Create not-yet-existing writable grants (e.g. an approved + // scratch dir) so they can be captured and bound; best-effort. + let _ = std::fs::create_dir_all(path); + sandbox::HostFilesystemLocation::new(path).ok() + }) + .collect(); + sandbox::SandboxFsPolicy::Restricted { writable_paths } }; let network = match &self.network { SandboxNetworkAccess::None => sandbox::SandboxNetPolicy::Blocked, @@ -169,7 +182,11 @@ impl SandboxWrap { .collect(), }, }; - let git_dirs = self.git_dirs.clone(); + let git_dirs = self + .git_dirs + .iter() + .filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok()) + .collect(); let git = if self.allow_git_access { sandbox::GitSandboxPolicy::Allowed { git_dirs } } else { @@ -238,6 +255,12 @@ pub(crate) async fn prepare_sandbox_wrap( let mut sandbox = 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")] + if let Some((channel, version)) = sandbox_wrap.wsl_zed_release.clone() { + sandbox.set_wsl_zed_release(channel, version); + } let command = sandbox::CommandAndArgs { program, args, diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 8420cbede1..30eb959298 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -79,6 +79,11 @@ web_search.workspace = true zed_env_vars.workspace = true zstd.workspace = true +# Used only on Windows to resolve the running release channel/version so the WSL +# sandbox helper can fetch a matching Linux `zed`. +[target.'cfg(target_os = "windows")'.dependencies] +release_channel.workspace = true + [dev-dependencies] assets.workspace = true async-io.workspace = true diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs index 2c5e32a02d..e8597a9412 100644 --- a/crates/agent/src/sandboxing.rs +++ b/crates/agent/src/sandboxing.rs @@ -30,7 +30,9 @@ use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag}; use gpui::App; use http_proxy::HostPattern; use project::Project; -use sandbox::{GitSandboxPolicy, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy}; +use sandbox::{ + GitSandboxPolicy, HostFilesystemLocation, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy, +}; use settings::Settings; use std::path::PathBuf; @@ -126,6 +128,13 @@ impl ThreadSandbox { match self { ThreadSandbox::Unsandboxed => ThreadSandbox::Unsandboxed, ThreadSandbox::Sandboxed(policy) => { + // Capture each `.git` location (pinning its inode / canonical + // path). A location that can't be captured (e.g. doesn't exist) + // is dropped — fail-closed. + let git_dirs = git_dirs + .into_iter() + .filter_map(|path| HostFilesystemLocation::new(path).ok()) + .collect(); let git = if allowed { GitSandboxPolicy::Allowed { git_dirs } } else { @@ -158,7 +167,11 @@ pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy SandboxFsPolicy::Unrestricted } else { SandboxFsPolicy::Restricted { - writable_paths: persistent.write_paths.clone(), + writable_paths: persistent + .write_paths + .iter() + .filter_map(|path| HostFilesystemLocation::new(path).ok()) + .collect(), } }; let network = if persistent.allow_all_hosts { @@ -437,7 +450,11 @@ impl ThreadSandboxGrants { SandboxFsPolicy::Unrestricted } else { SandboxFsPolicy::Restricted { - writable_paths: self.write_paths.clone(), + writable_paths: self + .write_paths + .iter() + .filter_map(|path| HostFilesystemLocation::new(path).ok()) + .collect(), } }; let network = if self.network_any_host { @@ -613,6 +630,7 @@ pub(crate) fn insert_host_pattern(set: &mut Vec, pattern: HostPatte #[cfg(test)] mod tests { use super::*; + use std::path::Path; fn hosts(list: &[&str]) -> NetworkRequest { NetworkRequest::Hosts( @@ -644,9 +662,19 @@ mod tests { #[test] fn thread_sandbox_merge_unsandboxed_wins_else_unions_scopes() { - let policy = |paths: &[&str], hosts: &[&str]| SandboxPolicy { + // Writable paths are captured as real `HostFilesystemLocation`s (which + // open an fd and key on the inode), so the test uses real directories. + let dir_a = tempfile::tempdir().expect("create temp dir a"); + let dir_b = tempfile::tempdir().expect("create temp dir b"); + let path_a = dir_a.path(); + let path_b = dir_b.path(); + + let policy = |paths: &[&Path], hosts: &[&str]| SandboxPolicy { fs: SandboxFsPolicy::Restricted { - writable_paths: paths.iter().map(PathBuf::from).collect(), + writable_paths: paths + .iter() + .map(|p| HostFilesystemLocation::new(p).expect("capture temp dir")) + .collect(), }, network: if hosts.is_empty() { SandboxNetPolicy::Blocked @@ -661,20 +689,20 @@ mod tests { // Unsandboxed on either side wins — the agent runs with ambient access. assert!( ThreadSandbox::Unsandboxed - .merge(ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"]))) + .merge(ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"]))) .is_unsandboxed() ); assert!( - ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"])) + ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"])) .merge(ThreadSandbox::Unsandboxed) .is_unsandboxed() ); // Two sandboxed layers union their scopes. assert_eq!( - ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"])) - .merge(ThreadSandbox::Sandboxed(policy(&["/b"], &["b.com"]))), - ThreadSandbox::Sandboxed(policy(&["/a", "/b"], &["a.com", "b.com"])) + ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"])) + .merge(ThreadSandbox::Sandboxed(policy(&[path_b], &["b.com"]))), + ThreadSandbox::Sandboxed(policy(&[path_a, path_b], &["a.com", "b.com"])) ); } @@ -743,13 +771,19 @@ 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. + 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, &["/tmp/build"])); + grants.record(&request(hosts(&["github.com"]), false, &[build_path])); let policy = grants.to_policy(); assert_eq!( policy.fs, SandboxFsPolicy::Restricted { - writable_paths: vec![PathBuf::from("/tmp/build")] + writable_paths: vec![ + HostFilesystemLocation::new(build_dir.path()).expect("capture temp dir") + ] } ); assert_eq!( @@ -781,8 +815,10 @@ mod tests { fn settings_policy_maps_persistent_permissions() { use sandbox::{SandboxFsPolicy, SandboxNetPolicy}; + // `settings_sandbox_policy` captures real `HostFilesystemLocation`s. + let log_dir = tempfile::tempdir().expect("create temp log dir"); let persistent = SandboxPermissions { - write_paths: vec![PathBuf::from("/var/log")], + write_paths: vec![log_dir.path().to_path_buf()], network_hosts: vec!["*.npmjs.org".to_string()], ..Default::default() }; @@ -790,7 +826,9 @@ mod tests { assert_eq!( policy.fs, SandboxFsPolicy::Restricted { - writable_paths: vec![PathBuf::from("/var/log")] + writable_paths: vec![ + HostFilesystemLocation::new(log_dir.path()).expect("capture temp dir") + ] } ); assert_eq!( diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 704e48eef5..980ed067a3 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -18,8 +18,8 @@ use crate::sandboxing::{ use crate::tools::{SandboxGitPathCandidates, sandbox_git_paths}; use agent_client_protocol::schema::v1 as acp; use agent_settings::{ - AgentProfileId, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT, - SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, + AgentProfileId, AgentProfileSettings, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT, + SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, builtin_profiles, }; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Local, Utc}; @@ -46,7 +46,7 @@ use language_model::{ LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, SelectedModel, Speed, StopReason, TokenUsage, ZED_CLOUD_PROVIDER_ID, }; -use project::Project; +use project::{Project, trusted_worktrees::TrustedWorktrees}; use prompt_store::ProjectContext; use schemars::{JsonSchema, Schema}; use serde::de::DeserializeOwned; @@ -1226,6 +1226,9 @@ pub struct Thread { initial_project_snapshot: Shared>>>, pub(crate) context_server_registry: Entity, profile_id: AgentProfileId, + /// Whether `profile_id` was downgraded to `minimal` at thread start because + /// the workspace is restricted. Used purely to surface a warning in the UI. + profile_downgraded_for_restricted_workspace: bool, project_context: Entity, pub(crate) templates: Arc, model: ThreadModel, @@ -1320,7 +1323,8 @@ impl Thread { cx: &mut Context, ) -> Self { let settings = AgentSettings::get_global(cx); - let profile_id = settings.default_profile.clone(); + let (profile_id, profile_downgraded_for_restricted_workspace) = + Self::profile_for_restricted_workspace(settings.default_profile.clone(), &project, cx); let enable_thinking = settings .default_model .as_ref() @@ -1363,6 +1367,7 @@ impl Thread { }, context_server_registry, profile_id, + profile_downgraded_for_restricted_workspace, project_context, templates, model, @@ -1395,6 +1400,8 @@ impl Thread { self.thinking_effort = parent.thinking_effort.clone(); self.summarization_model = parent.summarization_model.clone(); self.profile_id = parent.profile_id.clone(); + self.profile_downgraded_for_restricted_workspace = + parent.profile_downgraded_for_restricted_workspace; } fn apply_model_selection( @@ -1738,6 +1745,7 @@ impl Thread { initial_project_snapshot: Task::ready(db_thread.initial_project_snapshot).shared(), context_server_registry, profile_id, + profile_downgraded_for_restricted_workspace: false, project_context, templates, model, @@ -2195,7 +2203,44 @@ impl Thread { &self.profile_id } + /// Whether this thread's profile was downgraded to `minimal` at thread start + /// because the workspace is restricted. + pub fn profile_was_downgraded(&self) -> bool { + self.profile_downgraded_for_restricted_workspace + } + + /// Computes the profile a thread should start with, given the user's chosen + /// profile. In a restricted workspace, the built-in `write`/`ask` profiles + /// are downgraded to `minimal` — but only when both the chosen profile and + /// `minimal` are unmodified, shipped defaults, so we never override a user's + /// custom or customized profiles. + /// + /// Returns the (possibly downgraded) profile and whether a downgrade + /// happened. + fn profile_for_restricted_workspace( + profile_id: AgentProfileId, + project: &Entity, + cx: &App, + ) -> (AgentProfileId, bool) { + let is_write_or_ask = profile_id.as_str() == builtin_profiles::WRITE + || profile_id.as_str() == builtin_profiles::ASK; + let minimal = AgentProfileId(builtin_profiles::MINIMAL.into()); + if is_write_or_ask + && TrustedWorktrees::has_restricted_worktrees(&project.read(cx).worktree_store(), cx) + && AgentProfileSettings::is_unmodified_default(&profile_id, cx) + && AgentProfileSettings::is_unmodified_default(&minimal, cx) + { + (minimal, true) + } else { + (profile_id, false) + } + } + pub fn set_profile(&mut self, profile_id: AgentProfileId, cx: &mut Context) { + // An explicit selection means any earlier automatic downgrade no longer + // applies, even if the user re-selects the same profile. + self.profile_downgraded_for_restricted_workspace = false; + if self.profile_id == profile_id { return; } @@ -3458,6 +3503,26 @@ impl Thread { cancellation_rx: watch::Receiver, cx: &mut Context, ) -> Task { + // A workspace can become restricted after a thread has already started. + // Tools that aren't allowed in restricted workspaces must never run in + // that state, even though they were exposed to the model earlier. + if !tool.allow_in_restricted_mode() + && TrustedWorktrees::has_restricted_worktrees( + &self.project.read(cx).worktree_store(), + cx, + ) + { + return Task::ready(LanguageModelToolResult { + tool_use_id, + tool_name, + is_error: true, + content: vec![LanguageModelToolResultContent::Text(Arc::from( + "workspace has become restricted", + ))], + output: None, + }); + } + let fs = self.project.read(cx).fs().clone(); let tool_event_stream = ToolCallEventStream::new( tool_use_id.clone(), @@ -3936,9 +4001,16 @@ impl Thread { // to the model under that name. let use_sandboxed_terminal = sandboxing_enabled_for_project(self.project.read(cx), cx); + // Tools that aren't allowed in restricted workspaces must never be + // provided to the model while the workspace is restricted, regardless + // of what the active profile enables. + let is_restricted = + TrustedWorktrees::has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx); + let mut tools = self .tools .iter() + .filter(|(_, tool)| !is_restricted || tool.allow_in_restricted_mode()) .filter_map(|(tool_name, tool)| { let terminal_variant = matches!( tool_name.as_ref(), @@ -4904,6 +4976,14 @@ where true } + /// Whether this tool may be provided to an agent in a restricted workspace. + /// + /// Tools that return `false` are never exposed to the model while the + /// workspace is restricted, and will fail if invoked in that state. + fn allow_in_restricted_mode() -> bool { + true + } + /// Runs the tool with the provided input. /// /// Returns `Result` rather than `Result` @@ -4967,6 +5047,9 @@ pub trait AnyAgentTool { fn supports_provider(&self, _provider: &LanguageModelProviderId) -> bool { true } + fn allow_in_restricted_mode(&self) -> bool { + true + } /// See [`AgentTool::run`] for why this returns `Result`. fn run( self: Arc, @@ -5018,6 +5101,10 @@ where T::supports_provider(provider) } + fn allow_in_restricted_mode(&self) -> bool { + T::allow_in_restricted_mode() + } + fn run( self: Arc, input: ToolInput, diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 42d47a1327..f55c16ae93 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -138,6 +138,18 @@ macro_rules! tools { false } + /// Returns whether the tool with the given name may be provided to an + /// agent in a restricted workspace. Unknown tools (e.g. MCP tools) are + /// considered allowed. + pub fn tool_allowed_in_restricted_mode(name: &str) -> bool { + $( + if name == <$tool>::NAME { + return <$tool>::allow_in_restricted_mode(); + } + )* + true + } + /// A list of all built-in tools pub fn built_in_tools() -> impl Iterator { fn language_model_tool() -> LanguageModelRequestTool { @@ -219,3 +231,25 @@ pub fn tool_feature_flag_enabled(tool_name: &str, cx: &App) -> bool { _ => true, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fetch_and_terminal_are_forbidden_in_restricted_mode() { + assert!(!tool_allowed_in_restricted_mode(FetchTool::NAME)); + assert!(!tool_allowed_in_restricted_mode(TerminalTool::NAME)); + + // Every other built-in tool, and unknown (e.g. MCP) tools, are allowed. + for name in ALL_TOOL_NAMES { + let expected = *name != FetchTool::NAME && *name != TerminalTool::NAME; + assert_eq!( + tool_allowed_in_restricted_mode(name), + expected, + "unexpected restricted-mode policy for tool `{name}`" + ); + } + assert!(tool_allowed_in_restricted_mode("some_mcp_tool")); + } +} diff --git a/crates/agent/src/tools/fetch_tool.rs b/crates/agent/src/tools/fetch_tool.rs index 7bc93740af..5d6cc82dbb 100644 --- a/crates/agent/src/tools/fetch_tool.rs +++ b/crates/agent/src/tools/fetch_tool.rs @@ -124,6 +124,10 @@ impl AgentTool for FetchTool { acp::ToolKind::Fetch } + fn allow_in_restricted_mode() -> bool { + false + } + fn initial_title( &self, input: Result, diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 87d334689c..9dba295126 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -298,6 +298,10 @@ impl AgentTool for TerminalTool { acp::ToolKind::Execute } + fn allow_in_restricted_mode() -> bool { + false + } + fn initial_title( &self, input: Result, @@ -336,6 +340,10 @@ impl AgentTool for SandboxedTerminalTool { acp::ToolKind::Execute } + fn allow_in_restricted_mode() -> bool { + false + } + fn initial_title( &self, input: Result, @@ -372,6 +380,32 @@ fn terminal_initial_title(input: Result) -> SharedStr } } +/// Windows only: resolve the `(release channel, version)` of the Linux `zed` to +/// provision inside WSL as the sandbox helper. Dev (source) builds have no +/// matching release, so they pull the latest nightly; every other channel pins +/// its exact running version (stripped of pre-release/build metadata, which the +/// release API doesn't key on). +#[cfg(target_os = "windows")] +fn wsl_zed_release(cx: &App) -> Option<(String, String)> { + use release_channel::{AppVersion, ReleaseChannel}; + match *release_channel::RELEASE_CHANNEL { + ReleaseChannel::Dev => Some(("nightly".to_string(), "latest".to_string())), + channel => { + let version = AppVersion::global(cx); + Some(( + channel.dev_name().to_string(), + format!("{}.{}.{}", version.major, version.minor, version.patch), + )) + } + } +} + +/// Non-Windows platforms don't route through WSL, so there's no helper to fetch. +#[cfg(not(target_os = "windows"))] +fn wsl_zed_release(_cx: &App) -> Option<(String, String)> { + None +} + async fn run_terminal_tool( project: Entity, environment: Rc, @@ -382,17 +416,26 @@ async fn run_terminal_tool( let selection = input.selection; let sandbox_input = input.sandbox.clone().unwrap_or_default(); - let (working_dir, authorize, sandboxing, is_local_project) = cx.update(|cx| { - let working_dir = working_dir(&input.cd, &project, cx).map_err(|err| err.to_string())?; - let context = - crate::ToolPermissionContext::new(TerminalTool::NAME, vec![input.command.clone()]); - let authorize = - event_stream.authorize(SharedString::new(input.command.clone()), context, cx); - let sandboxing = - input.sandbox.is_some() && sandboxing_enabled_for_project(project.read(cx), cx); - let is_local_project = project.read(cx).is_local(); - Result::<_, String>::Ok((working_dir, authorize, sandboxing, is_local_project)) - })?; + let (working_dir, authorize, sandboxing, is_local_project, wsl_zed_release) = + cx.update(|cx| { + let working_dir = + working_dir(&input.cd, &project, cx).map_err(|err| err.to_string())?; + let context = + crate::ToolPermissionContext::new(TerminalTool::NAME, vec![input.command.clone()]); + let authorize = + event_stream.authorize(SharedString::new(input.command.clone()), context, cx); + let sandboxing = + input.sandbox.is_some() && sandboxing_enabled_for_project(project.read(cx), cx); + let is_local_project = project.read(cx).is_local(); + let wsl_zed_release = wsl_zed_release(cx); + Result::<_, String>::Ok(( + working_dir, + authorize, + sandboxing, + is_local_project, + wsl_zed_release, + )) + })?; authorize.await.map_err(|e| e.to_string())?; @@ -621,6 +664,7 @@ async fn run_terminal_tool( network: network_request_to_sandbox_network_access(&effective.network), allow_fs_write: effective.allow_fs_write_all, is_local: is_local_project, + wsl_zed_release: wsl_zed_release.clone(), }; // The viability check runs a brief probe subprocess, so do it off @@ -637,10 +681,9 @@ async fn run_terminal_tool( let mut retries = 0usize; loop { let probe_wrap = wrap.clone(); - let probe_cwd = working_dir.clone(); let error = match cx .background_executor() - .spawn(async move { probe_wrap.can_create_sandbox(probe_cwd.as_deref()) }) + .spawn(async move { probe_wrap.can_create_sandbox() }) .await { Ok(()) => break Some(wrap), @@ -686,10 +729,9 @@ async fn run_terminal_tool( #[cfg(not(target_os = "linux"))] { let probe_wrap = wrap.clone(); - let probe_cwd = working_dir.clone(); match cx .background_executor() - .spawn(async move { probe_wrap.can_create_sandbox(probe_cwd.as_deref()) }) + .spawn(async move { probe_wrap.can_create_sandbox() }) .await { Ok(()) => Some(wrap), diff --git a/crates/agent_settings/src/agent_profile.rs b/crates/agent_settings/src/agent_profile.rs index ca448ca857..1283dbf1ea 100644 --- a/crates/agent_settings/src/agent_profile.rs +++ b/crates/agent_settings/src/agent_profile.rs @@ -7,7 +7,7 @@ use fs::Fs; use gpui::{App, SharedString}; use settings::{ AgentProfileContent, ContextServerPresetContent, LanguageModelSelection, Settings as _, - SettingsContent, update_settings_file, + SettingsContent, SettingsStore, update_settings_file, }; use util::ResultExt as _; @@ -116,6 +116,32 @@ impl AgentProfileSettings { self.tools.get(tool_name) == Some(&true) } + /// Whether the built-in profile with the given id still matches the shipped + /// default — i.e. the user has neither customized the built-in profile nor + /// shadowed it with a custom profile of the same id. Custom profile ids are + /// never considered unmodified defaults. + pub fn is_unmodified_default(profile_id: &AgentProfileId, cx: &App) -> bool { + if !builtin_profiles::is_builtin(profile_id) { + return false; + } + let store = cx.global::(); + let profile_in = |content: &SettingsContent| { + content + .agent + .as_ref() + .and_then(|agent| agent.profiles.as_ref()) + .and_then(|profiles| profiles.get(profile_id.as_str())) + .cloned() + }; + match ( + profile_in(store.merged_settings()), + profile_in(store.raw_default_settings()), + ) { + (Some(merged), Some(default)) => merged == default, + _ => false, + } + } + pub fn is_context_server_tool_enabled(&self, server_id: &str, tool_name: &str) -> bool { self.context_servers .get(server_id) @@ -247,4 +273,37 @@ mod tests { assert!(!profile.is_context_server_tool_enabled("server", "other_tool")); assert!(!profile.is_context_server_tool_enabled("other_server", "any_tool")); } + + #[gpui::test] + fn unmodified_default_detection(cx: &mut gpui::App) { + use gpui::UpdateGlobal as _; + + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + + let write = AgentProfileId(builtin_profiles::WRITE.into()); + let minimal = AgentProfileId(builtin_profiles::MINIMAL.into()); + let custom = AgentProfileId("custom".into()); + + // Fresh defaults: the shipped built-in profiles are unmodified. + assert!(AgentProfileSettings::is_unmodified_default(&write, cx)); + assert!(AgentProfileSettings::is_unmodified_default(&minimal, cx)); + // Custom (non-built-in) ids are never considered unmodified defaults. + assert!(!AgentProfileSettings::is_unmodified_default(&custom, cx)); + + // The user customizes the `write` profile; `minimal` stays untouched. + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + r#"{ "agent": { "profiles": { "write": { "name": "Write", "tools": { "fetch": false } } } } }"#, + cx, + ) + .unwrap(); + }); + + assert!(!AgentProfileSettings::is_unmodified_default(&write, cx)); + assert!(AgentProfileSettings::is_unmodified_default(&minimal, cx)); + } } diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index a101920102..43b10a4bd0 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -244,6 +244,17 @@ impl ProfileProvider for Entity { fn model_selected(&self, cx: &App) -> bool { self.read(cx).model().is_some() } + + fn is_restricted(&self, cx: &App) -> bool { + project::trusted_worktrees::TrustedWorktrees::has_restricted_worktrees( + &self.read(cx).project().read(cx).worktree_store(), + cx, + ) + } + + fn profile_downgraded(&self, cx: &App) -> bool { + self.read(cx).profile_was_downgraded() + } } #[derive(Default)] diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index b81340e7cc..16b6b60875 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -4645,7 +4645,7 @@ impl ThreadView { // Sandboxed by settings, but disabled for this thread: show the // settings scope (greyed) for context above the disabled status. (ThreadSandbox::Sandboxed(settings_policy), ThreadSandbox::Unsandboxed) => { - let settings = augment_settings_sandbox_policy(settings_policy, baseline); + let settings = augment_settings_sandbox_policy(&settings_policy, baseline); SandboxStatusTooltip::disabled_for_thread(sandbox_section( "Defined in your settings:", &settings, @@ -4656,10 +4656,11 @@ impl ThreadView { ThreadSandbox::Sandboxed(settings_policy), ThreadSandbox::Sandboxed(thread_policy), ) => { - let settings = augment_settings_sandbox_policy(settings_policy, baseline); + let settings = augment_settings_sandbox_policy(&settings_policy, baseline); + let thread = SandboxPolicyDisplay::from_policy(&thread_policy); // Omit the per-thread section when it grants nothing extra. - let thread = (!sandbox_policy_grants_nothing(&thread_policy)) - .then(|| sandbox_section("Allowed for this thread:", &thread_policy, false)); + let thread = (!sandbox_policy_grants_nothing(&thread)) + .then(|| sandbox_section("Allowed for this thread:", &thread, false)); SandboxStatusTooltip::enabled( sandbox_section("Defined in your settings:", &settings, true), thread, @@ -5604,6 +5605,83 @@ impl Render for TokenUsageTooltip { } } +/// A display-ready snapshot of a sandbox policy for the status tooltip. +/// +/// The opaque `HostFilesystemLocation`s in a policy are stringified up front, +/// when this is built, so the tooltip state (which outlives the build and is +/// captured by the lazy tooltip closure) never holds the locations' fds open. +#[derive(Clone)] +struct SandboxPolicyDisplay { + fs: SandboxFsDisplay, + network: SandboxNetPolicy, + git: SandboxGitDisplay, +} + +/// The filesystem write-access portion of a [`SandboxPolicyDisplay`]. +#[derive(Clone)] +enum SandboxFsDisplay { + Unrestricted, + Restricted(Vec), +} + +/// A single writable entry to display in the sandbox tooltip: either a real host +/// location (already stringified for display) or the Linux-only host-isolated +/// `/tmp` overlay, which has no backing host path and is purely a label. +#[derive(Clone)] +enum WritableEntryDisplay { + Path(String), + // Only ever constructed on Linux (the bwrap `--tmpfs /tmp` overlay), so the + // variant is gated to match and avoid dead-code warnings elsewhere. + #[cfg(target_os = "linux")] + IsolatedTmp, +} + +/// The Git-access portion of a [`SandboxPolicyDisplay`]: whether `.git` writes +/// are granted, and the (display-only) `.git` directories the policy governs. +#[derive(Clone)] +struct SandboxGitDisplay { + allowed: bool, + git_dirs: Vec, +} + +impl SandboxPolicyDisplay { + /// Display a policy verbatim (used for the per-thread overrides, which carry + /// no implicit baseline grants). Takes the policy by reference and stringifies + /// its locations immediately, so no fd is retained past this call. + fn from_policy(policy: &SandboxPolicy) -> Self { + let fs = match &policy.fs { + SandboxFsPolicy::Unrestricted => SandboxFsDisplay::Unrestricted, + SandboxFsPolicy::Restricted { writable_paths } => SandboxFsDisplay::Restricted( + writable_paths + .iter() + .map(|location| { + WritableEntryDisplay::Path(location.untrusted_path_display().to_string()) + }) + .collect(), + ), + }; + SandboxPolicyDisplay { + fs, + network: policy.network.clone(), + git: SandboxGitDisplay::from_policy(&policy.git), + } + } +} + +impl SandboxGitDisplay { + /// Stringify a Git policy's `.git` directories for display, retaining no fds. + fn from_policy(git: &GitSandboxPolicy) -> Self { + SandboxGitDisplay { + allowed: git.allows_writes(), + git_dirs: git + .git_dirs() + .iter() + .map(|location| location.untrusted_path_display().to_string()) + .collect(), + } + } +} + /// Fold the always-granted baseline writable paths (the project's worktree /// roots, derived from the same source the terminal tool uses) and, on Linux, /// the host-isolated `/tmp` overlay into a settings policy for display. These @@ -5612,27 +5690,51 @@ impl Render for TokenUsageTooltip { /// section rather than stored. A no-op when the fs is unrestricted (rendered as /// "All paths"), since there's nothing to scope. fn augment_settings_sandbox_policy( - mut policy: SandboxPolicy, + policy: &SandboxPolicy, baseline: Vec, -) -> SandboxPolicy { - if let SandboxFsPolicy::Restricted { writable_paths } = &mut policy.fs { - let mut merged = baseline; - for path in writable_paths.drain(..) { - if !merged.contains(&path) { - merged.push(path); +) -> SandboxPolicyDisplay { + let fs = match &policy.fs { + SandboxFsPolicy::Unrestricted => SandboxFsDisplay::Unrestricted, + SandboxFsPolicy::Restricted { writable_paths } => { + // Dedup by display string. We deliberately don't open the locations' + // fds to dedup by inode here: this is a display-only tooltip and the + // string is the location's identity for that purpose. The string can + // only diverge from the captured inode while a symlink-swap is + // actively in progress, and in that case the bind validator refuses + // to run the command at all (see the `sandbox` crate) — so showing the + // requested path is always safe, and not worth a blocking syscall on + // the render path. + let mut merged: Vec = Vec::new(); + let baseline_paths = baseline.iter().map(|path| path.display().to_string()); + let granted_paths = writable_paths + .iter() + .map(|location| location.untrusted_path_display().to_string()); + for path in baseline_paths.chain(granted_paths) { + if !merged.contains(&path) { + merged.push(path); + } } + // `mut` is only needed on Linux, where the isolated `/tmp` entry is + // pushed below. + #[cfg_attr(not(target_os = "linux"), allow(unused_mut))] + let mut entries: Vec = + merged.into_iter().map(WritableEntryDisplay::Path).collect(); + // The ephemeral, host-isolated tmpfs at /tmp is Linux-specific (the + // bwrap `--tmpfs /tmp` overlay). It's a display-only label, not a + // real host path, so it can't be a captured location. + #[cfg(target_os = "linux")] + entries.push(WritableEntryDisplay::IsolatedTmp); + SandboxFsDisplay::Restricted(entries) } - // The ephemeral, host-isolated tmpfs at /tmp is Linux-specific (the - // bwrap `--tmpfs /tmp` overlay). It's a display-only label, not a real - // host path, so it can't come from the path source above. - #[cfg(target_os = "linux")] - merged.push(PathBuf::from("/tmp (isolated)")); - *writable_paths = merged; + }; + SandboxPolicyDisplay { + fs, + network: policy.network.clone(), + git: SandboxGitDisplay::from_policy(&policy.git), } - policy } -fn sandbox_section(title: &str, policy: &SandboxPolicy, show_empty: bool) -> SandboxSection { +fn sandbox_section(title: &str, policy: &SandboxPolicyDisplay, show_empty: bool) -> SandboxSection { let write_empty = fs_grants_nothing(&policy.fs); let network_empty = network_grants_nothing(&policy.network); let git_empty = git_grants_nothing(&policy.git); @@ -5659,7 +5761,7 @@ fn sandbox_section(title: &str, policy: &SandboxPolicy, show_empty: bool) -> San /// Whether a policy grants nothing worth surfacing, used to decide whether to /// show the per-thread overrides section at all. -fn sandbox_policy_grants_nothing(policy: &SandboxPolicy) -> bool { +fn sandbox_policy_grants_nothing(policy: &SandboxPolicyDisplay) -> bool { fs_grants_nothing(&policy.fs) && network_grants_nothing(&policy.network) && git_grants_nothing(&policy.git) @@ -5667,24 +5769,26 @@ fn sandbox_policy_grants_nothing(policy: &SandboxPolicy) -> bool { /// Git access grants nothing to surface unless `.git` writes are allowed *and* /// at least one `.git` directory is known. -fn git_grants_nothing(git: &GitSandboxPolicy) -> bool { - !git.allows_writes() || git.git_dirs().is_empty() +fn git_grants_nothing(git: &SandboxGitDisplay) -> bool { + !git.allowed || git.git_dirs.is_empty() } /// Rows for the Git-access group: one row per writable `.git` directory (these /// may live outside the project for a linked worktree). -fn sandbox_git_rows(git: &GitSandboxPolicy) -> Vec { - match git { - GitSandboxPolicy::Allowed { git_dirs } if !git_dirs.is_empty() => git_dirs - .iter() - .map(|path| SandboxRow::git(path.clone())) - .collect(), - _ => Vec::new(), +fn sandbox_git_rows(git: &SandboxGitDisplay) -> Vec { + if !git.allowed { + return Vec::new(); } + git.git_dirs + .iter() + // The display string was captured up front; reconstruct a throwaway path + // here purely to render a label + icon (never used as an identity). + .map(|dir| SandboxRow::git(PathBuf::from(dir))) + .collect() } -fn fs_grants_nothing(fs: &SandboxFsPolicy) -> bool { - matches!(fs, SandboxFsPolicy::Restricted { writable_paths } if writable_paths.is_empty()) +fn fs_grants_nothing(fs: &SandboxFsDisplay) -> bool { + matches!(fs, SandboxFsDisplay::Restricted(entries) if entries.is_empty()) } fn network_grants_nothing(network: &SandboxNetPolicy) -> bool { @@ -5697,15 +5801,22 @@ fn network_grants_nothing(network: &SandboxNetPolicy) -> bool { /// Rows for the write-access group: a message for the "all"/"none" cases, or one /// row per granted path. -fn sandbox_fs_rows(fs: &SandboxFsPolicy) -> Vec { +fn sandbox_fs_rows(fs: &SandboxFsDisplay) -> Vec { match fs { - SandboxFsPolicy::Unrestricted => vec![SandboxRow::message("All paths (unrestricted)")], - SandboxFsPolicy::Restricted { writable_paths } if writable_paths.is_empty() => { + SandboxFsDisplay::Unrestricted => vec![SandboxRow::message("All paths (unrestricted)")], + SandboxFsDisplay::Restricted(entries) if entries.is_empty() => { vec![SandboxRow::message("None")] } - SandboxFsPolicy::Restricted { writable_paths } => writable_paths + SandboxFsDisplay::Restricted(entries) => entries .iter() - .map(|path| SandboxRow::path(path.clone())) + .map(|entry| match entry { + // The display string was captured up front; see `sandbox_git_rows`. + WritableEntryDisplay::Path(path) => SandboxRow::path(PathBuf::from(path)), + #[cfg(target_os = "linux")] + WritableEntryDisplay::IsolatedTmp => { + SandboxRow::path(PathBuf::from("/tmp (isolated)")) + } + }) .collect(), } } @@ -8265,27 +8376,23 @@ impl ThreadView { }), ) .when(has_host_list && is_open, |this| { - this.child( - v_flex() - .id(("sandbox-network-hosts-list", entry_ix)) - .max_h_40() - .overflow_y_scroll() - .children(hosts.iter().enumerate().map(|(host_ix, host)| { - h_flex() - .min_w_0() - .px_2() - .py_1p5() - .bg(cx.theme().colors().editor_background) - .when(host_ix < hosts.len() - 1, |this| { - this.border_b_1().border_color(cx.theme().colors().border) - }) - .child( - Label::new(host.clone()) - .size(LabelSize::XSmall) - .buffer_font(cx), - ) - })), - ) + this.child(v_flex().children(hosts.iter().enumerate().map( + |(host_ix, host)| { + h_flex() + .min_w_0() + .px_2() + .py_1p5() + .bg(cx.theme().colors().editor_background) + .when(host_ix < hosts.len() - 1, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + Label::new(host.clone()) + .size(LabelSize::XSmall) + .buffer_font(cx), + ) + }, + ))) }) }); @@ -8368,21 +8475,17 @@ impl ThreadView { }), ) .when(has_path_list && is_open, |this| { - this.child( - v_flex() - .id(("sandbox-authorization-paths-list", entry_ix)) - .max_h_40() - .overflow_y_scroll() - .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, - ) - })), - ) + 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, + ) + }, + ))) }) }); @@ -8439,9 +8542,9 @@ impl ThreadView { v_flex() .border_t_1() .border_color(self.tool_card_border_color(cx)) + .children(git_access_section) .children(network_section) .children(write_section) - .children(git_access_section) .children(unsandboxed_section) .children(reason_section) .into_any_element() diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 00f989b501..3007b5fa3e 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -18,8 +18,9 @@ use std::{ }; use ui::{ DocumentationAside, HighlightedLabel, KeyBinding, LabelSize, ListItem, ListItemSpacing, - PopoverMenuHandle, Tooltip, prelude::*, + PopoverMenuHandle, TintColor, Tooltip, prelude::*, }; +use workspace::ToggleWorktreeSecurity; /// Trait for types that can provide and manage agent profiles pub trait ProfileProvider { @@ -34,6 +35,22 @@ pub trait ProfileProvider { /// Check if there is a model selected in the current context. fn model_selected(&self, cx: &App) -> bool; + + /// Whether the current workspace is restricted (has untrusted worktrees). + /// + /// In a restricted workspace, profiles that enable tools forbidden in + /// restricted mode are flagged, and the active built-in `write`/`ask` + /// profiles are downgraded to `minimal`. + fn is_restricted(&self, _cx: &App) -> bool { + false + } + + /// Whether the active profile has been downgraded to `minimal` because the + /// workspace is restricted (i.e. the user selected `write`/`ask`, but those + /// profiles aren't honored while restricted). + fn profile_downgraded(&self, _cx: &App) -> bool { + false + } } pub struct ProfileSelector { @@ -188,9 +205,23 @@ impl Render for ProfileSelector { IconName::ChevronDown }; + // Warn when the active profile is affected by a restricted workspace: + // either it was downgraded to `minimal`, or it still enables tools that + // are forbidden while restricted. + let show_warning = self.provider.is_restricted(cx) + && (self.provider.profile_downgraded(cx) + || !ProfilePickerDelegate::restricted_forbidden_tools(&profile_id, cx).is_empty()); + let trigger_button = Button::new("profile-selector", selected_profile) .label_size(LabelSize::Small) .color(Color::Muted) + .when(show_warning, |this| { + this.start_icon( + Icon::new(IconName::Warning) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + }) .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)); let tooltip: Box AnyView> = Box::new(Tooltip::element({ @@ -339,6 +370,22 @@ impl ProfilePickerDelegate { .collect() } + /// Tools enabled by a profile that are forbidden while the workspace is + /// restricted. Returns an empty list for profiles that are safe to use. + fn restricted_forbidden_tools(profile_id: &AgentProfileId, cx: &App) -> Vec { + let Some(profile) = AgentSettings::get_global(cx).profiles.get(profile_id) else { + return Vec::new(); + }; + profile + .tools + .iter() + .filter(|(name, enabled)| { + **enabled && !agent::tool_allowed_in_restricted_mode(name.as_ref()) + }) + .map(|(name, _)| SharedString::from(name.to_string())) + .collect() + } + fn documentation(candidate: &ProfileCandidate) -> Option<&'static str> { match candidate.id.as_str() { builtin_profiles::WRITE => Some("Get help to write anything."), @@ -594,10 +641,17 @@ impl PickerDelegate for ProfilePickerDelegate { let is_active = active_id == candidate.id; let has_documentation = Self::documentation(candidate).is_some(); + let has_warning = self.provider.is_restricted(cx) + && !Self::restricted_forbidden_tools(&candidate.id, cx).is_empty(); + // The warning details are merged into the documentation aside, + // so hovering either the row or the icon shows a single popup. + let track_hover = has_documentation || has_warning; + let has_end_slot = is_active || has_warning; + Some( div() .id(("profile-picker-item", ix)) - .when(has_documentation, |this| { + .when(track_hover, |this| { this.on_hover(cx.listener(move |picker, hovered, _, cx| { if *hovered { picker.delegate.hovered_index = Some(ix); @@ -616,11 +670,23 @@ impl PickerDelegate for ProfilePickerDelegate { candidate.name.clone(), entry.positions.clone(), )) - .when(is_active, |this| { + .when(has_end_slot, |this| { this.end_slot( - div() + h_flex() + .gap_1() .pr_2() - .child(Icon::new(IconName::Check).color(Color::Accent)), + .when(has_warning, |this| { + this.child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + }) + .when(is_active, |this| { + this.child( + Icon::new(IconName::Check).color(Color::Accent), + ) + }), ) }), ) @@ -644,13 +710,61 @@ impl PickerDelegate for ProfilePickerDelegate { }; let candidate = self.candidates.get(entry.candidate_index)?; - let docs_aside = Self::documentation(candidate)?.to_string(); + let description = Self::documentation(candidate).map(|docs| docs.to_string()); + let forbidden_tools = if self.provider.is_restricted(cx) { + Self::restricted_forbidden_tools(&candidate.id, cx) + } else { + Vec::new() + }; + + // Nothing to show: no description and no restricted-tool warning. + if description.is_none() && forbidden_tools.is_empty() { + return None; + } let side = documentation_aside_side(cx); Some(DocumentationAside { side, - render: Rc::new(move |_| Label::new(docs_aside.clone()).into_any_element()), + render: Rc::new(move |cx| { + v_flex() + .gap_1p5() + .when_some(description.clone(), |this, description| { + this.child(Label::new(description)) + }) + .when(!forbidden_tools.is_empty(), |this| { + this.when(description.is_some(), |this| { + this.child( + div() + .border_t_1() + .border_color(cx.theme().colors().border_variant), + ) + }) + .child( + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Warning) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + .child( + Label::new("Disabled in Restricted Mode") + .size(LabelSize::Small), + ), + ) + .children(forbidden_tools.iter().map(|tool| { + Label::new(format!("• {tool}")) + .size(LabelSize::Small) + .color(Color::Muted) + })), + ) + }) + .into_any_element() + }), }) } @@ -664,29 +778,66 @@ impl PickerDelegate for ProfilePickerDelegate { cx: &mut Context>, ) -> Option { let focus_handle = self.focus_handle.clone(); + let is_restricted = self.provider.is_restricted(cx); Some( - h_flex() + v_flex() .w_full() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .p_1p5() .child( - Button::new("configure", "Configure") - .full_width() - .style(ButtonStyle::Outlined) - .key_binding( - KeyBinding::for_action_in( - &ManageProfiles::default(), - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(ManageProfiles::default().boxed_clone(), cx); - }), + h_flex() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .p_1p5() + .child( + Button::new("configure", "Configure") + .full_width() + .style(ButtonStyle::Outlined) + .key_binding( + KeyBinding::for_action_in( + &ManageProfiles::default(), + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action( + ManageProfiles::default().boxed_clone(), + cx, + ); + }), + ), ) + .when(is_restricted, |this| { + this.child( + h_flex() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .p_1p5() + .child( + Button::new("restricted-mode", "Restricted Mode") + .full_width() + .style(ButtonStyle::Tinted(TintColor::Warning)) + .color(Color::Warning) + .start_icon( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .tooltip(Tooltip::text( + "Some tools are disabled. Click to review trust settings.", + )) + .on_click(|_, window, cx| { + window.dispatch_action( + ToggleWorktreeSecurity.boxed_clone(), + cx, + ); + }), + ), + ) + }) .into_any(), ) } diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index 5b860ba16c..df3b556d6a 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -395,10 +395,21 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet
 {
-                if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
-                    snippet_json_fixed.insert(0, '[');
-                    snippet_json_fixed.push_str("\n]");
-                }
+                if let Some(keymap_validator) = &keymap_validator {
+                    if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
+                        snippet_json_fixed.insert(0, '[');
+                        snippet_json_fixed.push_str("\n]");
+                    }
 
-                let value =
-                    settings::parse_json_with_comments::(&snippet_json_fixed)?;
-                let validation_errors: Vec = keymap_validator
-                    .iter_errors(&value)
-                    .map(|err| err.to_string())
-                    .collect();
-                if !validation_errors.is_empty() {
-                    anyhow::bail!("{}", validation_errors.join("\n"));
+                    let value = settings::parse_json_with_comments::(
+                        &snippet_json_fixed,
+                    )?;
+                    let validation_errors: Vec = keymap_validator
+                        .iter_errors(&value)
+                        .map(|err| err.to_string())
+                        .collect();
+                    if !validation_errors.is_empty() {
+                        anyhow::bail!("{}", validation_errors.join("\n"));
+                    }
                 }
             }
             "debug" => {
diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml
index 6a65e2a098..c9211dfa99 100644
--- a/crates/sandbox/Cargo.toml
+++ b/crates/sandbox/Cargo.toml
@@ -55,6 +55,9 @@ serde_json = { workspace = true, optional = true }
 
 [target.'cfg(target_os = "linux")'.dependencies]
 libc.workspace = true
+# Safe wrappers for the SCM_RIGHTS fd-passing and `fstat` the bind validator
+# needs, so that code doesn't hand-roll `msghdr`/`CMSG_*`/`mem::zeroed` unsafe.
+nix = { workspace = true, features = ["fs", "socket", "uio"] }
 
 [target.'cfg(target_os = "linux")'.dev-dependencies]
 tempfile.workspace = true
diff --git a/crates/sandbox/README.md b/crates/sandbox/README.md
new file mode 100644
index 0000000000..d26bbedd20
--- /dev/null
+++ b/crates/sandbox/README.md
@@ -0,0 +1,261 @@
+# `sandbox`
+
+Cross-platform sandboxing for shell commands.
+
+## Overview
+
+This crate allows creating a `Sandbox` according to some `SandboxPolicy`. A
+`SandboxPolicy` expresses:
+- what filesystem operations are allowed
+- which kinds of networking operations are allowed
+- whether git metadata is protected
+
+Once you have a `Sandbox`, you can use it to run commands that are constrained
+by that policy.
+
+## Security model
+
+The sandbox itself assumes all untrusted code is maximally hostile. It does
+*not* assume that the untrusted code is written by a
+well-meaning-but-perhaps-marginally-unaligned AI agent.
+
+However, practical limitations make the default profile in Zed not secure
+against attacks. An attacker with read/write access to the current directory can:
+- create a new Rust project in the current dir
+- create a proc macro library containing malicious code
+- use that macro in the project somewhere
+- rust-analyzer will run that proc macro outside the sandbox
+
+This can be mitigated by:
+- disabling any language servers with the capability to run untrusted code
+- not granting git access (since write access to `.git` can similarly be
+  escalated to unsandboxed code execution via `$EDITOR` and various other
+  methods)
+
+## Implementation
+
+The implementations are highly platform-specific:
+- Mac support comes from Seatbelt
+- Linux support comes from [bubblewrap], implemented via Linux [namespaces].
+- Windows:
+  - WSL: same as Linux
+  - non-WSL: not supported
+
+Note that WSL shells can be used on all Windows projects, regardless of whether
+the files are stored in the Linux filesystem or not.
+
+## Architecture
+
+Filesystem restrictions are different on all platforms. Network restrictions
+however largely follow a similar approach (details omitted):
+- Disable networking in the sandbox, except for one localhost port
+- Within the sandbox, set `HTTP_PROXY` and friends to tell programs to
+  communicate with that socket
+- On the Zed host side, there is a proxy that listens to that port that enforces
+  domain filtering
+
+On Linux specifically, there is an intermediate socket that allows data to flow
+out of the sandbox. This is required because, unlike seatbelt, bubblewrap runs
+sandboxed programs in an entirely separate network stack (i.e. it has a
+different `localhost`).
+
+### Linux
+
+A naive implementation on Linux would work roughly like:
+- Figure out which paths are read-only and which are read/write
+- Run the sandboxed program through `bwrap` with `--ro-bind` for read-only and
+  `--bind` for read/write
+
+However, this fails because of a nasty TOCTOU.
+
+#### The nasty TOCTOU
+
+Consider the following case:
+- an attacker has convinced the user to open `project`, which contains an evil
+  `AGENTS.md`
+- They have also convinced the user to allow git access
+- This means that the user will have given the following permissions to the sandbox:
+  - read/write access to `project`
+  - read/write access to `project/.git`
+  - read/write access to an isolated `/tmp`
+  - read-only access to `/`
+- The `AGENTS.md` instructs the LLM to do the following:
+  - spawn two subagents
+  - the first subagent tries to swap `project/.git` with a symlink to
+    `/home/alice` [`renameat2(2)`][renameat2] with the `RENAME_EXCHANGE`
+    flag set
+  - the second subagent tries to run `echo 'export PATH="proj/obfuscated.../evil_eavesdropping_sudo/bin:$PATH"' >> proj/.git/.bashrc`
+- The user sends a prompt, we pick up the evil `AGENTS.md` instructions, and the
+  agent does them
+- Zed checks whether paths are symlinks outside the allowable paths before
+  passing them to bubblewrap, but there is a **time delay** between this check
+  and when bubblewrap mounts them.
+- In this delay, the `renameat2` may succeed, which means that:
+  - At check time, `proj/.git` is a subdirectory of `proj`
+  - At bind time, `proj/.git` is a symlink to `/home/alice`
+- The attacker is now running code in a sandbox which has **read/write** access
+  to `/home/alice`, and so the second command to inject the malicious
+  credential-stealing sudo succeeds.
+
+
+```mermaid
+sequenceDiagram
+    participant Agent as Zed Agent
+    participant S1 as Subagent 1 swapper
+    participant S2 as Subagent 2 writer
+    participant Zed as Zed path validation
+    participant BW as bubblewrap
+
+    Note over Agent: Evil AGENTS.md picked up, git access granted
+    Note over Agent: Grants rw project, rw project/.git, rw /tmp, ro /
+    Agent->>S1: spawn swap project/.git for a symlink to /home/alice
+    Agent->>S2: spawn append PATH hijack to project/.git/.bashrc
+    Zed->>Zed: check project/.git is not an out-of-bounds symlink
+    Note over Zed: at check time it is a real subdirectory, so OK
+    Note over Zed,BW: time delay, time-of-check to time-of-use
+    S1->>S1: renameat2 RENAME_EXCHANGE wins the race
+    Note over S1: project/.git is now a symlink to /home/alice
+    Zed->>BW: bind project/.git into the sandbox
+    BW->>BW: re-resolve project/.git, following symlink to /home/alice
+    S2->>BW: write project/.git/.bashrc
+    BW-->>S2: write lands in /home/alice/.bashrc
+    Note over S2: escalation, project-scoped grant becomes arbitrary write
+```
+
+Note that this attack requires *two nested directories*, each with read/write
+grants. A single grant is insufficient, because you must mutate *a path which is
+used as a `--bind` argument*. If you cannot mutate a parent (because we are
+assuming no nested directories), then the only part you can mutate is the the
+read/write grant path itself (i.e. `/home/alice/project`). But, in bubblewrap's
+model, doing this requires write access to the *parent* (i.e. `/home/alice`),
+which we have assumed is not present.
+
+`./bind_source_toctou_test.sh` is a small bash script demonstrating this
+behaviour. It tries to replace the current dir with a symlink, and it fails due
+to invalid permissions.
+
+#### The naive (and incorrect) fix
+
+It is tempting to read the previous paragraph and think "that's simple, just
+disallow nested directories". In theory, this would work. A read/write grant to `/foo` and `/foo/bar` is logically equivalent to a read/write grant to just `/foo`. And the following is *true*: 
+
+> If there is no pair of read/write grants such that one is an ancestor of the
+> other, this TOCTOU attack is impossible.
+
+However, this is not a viable countermeasure for two reasons:
+
+1. It requires that no two grants of this kind ever exist at the same time
+   *globally across the whole system*. For example, opening `/foo` in one zed
+   window and `/foo/bar` in another would re-open this exploit. Even if we did
+   mitigate this by widening `/foo/bar` to have access to `/foo` (which in
+   itself is an unacceptable privilege escalation), we still wouldn't be able to
+   control non-Zed processes.
+2. It prevents the potentially useful pattern of:
+  - read/write access to `/foo`
+  - read-only access to `/foo/bar`
+  - read/write access to `/foo/bar/baz`
+
+Because of this, we need something more robust.
+
+#### The correct fix
+
+The correct fix involves using file descriptors as the source of truth, rather
+than paths. This is important because file descriptors are stable once opened,
+regardless of what happens to the path. The symlink swap attack will not change
+which inode the FD points to.
+
+This leads to a different question: how do we tell `bwrap` to use FDs instead of paths?
+
+`bwrap` does support `--bind-fd`, but this has another issue: "how do you get
+FDs into the `bwrap` process?
+
+There are two options:
+1. open the FDs in zed, clear `CLOEXEC`, then fork/exec into bwrap with the FD arguments
+2. send them into a helper process inside the sandbox using an `SCM_RIGHTS`
+  socket, and validate from the inside of the sandbox.
+
+We chose option 2 because we already have a helper process inside the sandbox
+(to set up the HTTP proxy).
+
+The flow for this approach in detail is:
+- open each *writable* path we `--bind` and get an `O_PATH` FD (which pins the
+  inode without granting read/write on its contents)
+- create an `SCM_RIGHTS` socket over which we can send the FDs
+- run `bwrap --bind /path1 /path1 ... -- zed --zed-linux-sandbox-launcher `
+  - note: we use (potentially swapped) paths
+  - we also mount the socket in the sandbox
+- the sandbox bridge reads the FDs from the socket, does the following for each
+  read/write bind:
+  - `fstat` the FD to get the `(device, inode)`
+  - `lstat` the corresponding mount path to get its `(device, inode)`
+  - check that they match
+
+  Note that this is essentially the check that `bwrap --bind-fd` does internally.
+- if all binds match, run the untrusted command, otherwise refuse to execute
+
+```mermaid
+sequenceDiagram
+    participant Zed as Zed host
+    participant BW as bubblewrap
+    participant Bridge as sandbox-bridge in sandbox
+    participant Prog as untrusted program
+
+    Zed->>Zed: open O_PATH FD per writable path, pinning the inode
+    Zed->>Zed: create SCM_RIGHTS socket
+    Zed->>BW: exec bwrap, binding paths, then zed --zed-linux-sandbox-launcher
+    Note over Zed,BW: binds use possibly-swapped paths, socket mounted in sandbox
+    Zed->>Bridge: send FDs over the SCM_RIGHTS socket
+    loop each writable bind
+        Bridge->>Bridge: fstat the FD to get device and inode
+        Bridge->>Bridge: lstat the mount path to get device and inode
+        Bridge->>Bridge: compare the two pairs
+    end
+    alt all binds match
+        Bridge->>Prog: exec the untrusted command
+    else any mismatch, a path was swapped
+        Bridge-->>Zed: refuse to execute
+    end
+```
+
+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.
+
+### Windows
+
+> [!NOTE] The Windows implementation depends heavily on the details of the Linux
+  implementation. 
+
+The Linux approach works perfectly on WSL in theory (WSL uses a "regular linux
+kernel"), but there is one practical thorn: the zed host code that creates the
+FD is now running on Windows, but we need Linux file descriptors.
+
+To work around this, we launch `zed --wsl-sandbox-helper` in WSL, which is a
+shim that captures the FDs and sets up the socket. We download this to
+`~/.local/libexec/zed`, so that it does not conflict with the Windows `zed.exe`
+binary that WSL will inject into the Linux `$PATH` (yes the `.exe` is stripped).
+## Code design
+
+### `HostFilesystemLocation`
+
+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. 
+
+To mitigate this, sensitive APIs take a `HostFilesystemLocation`. This is:
+- an `Arc` on Linux
+- a `PathBuf` on MacOS
+
+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.
+
+### `SandboxFilesystemLocation`
+
+A thin wrapper around a `PathBuf` representing a location *inside* the sandbox.
+No hardening is required - the worst a tampered in-sandbox path can do is expose
+already-granted host files at a different in-sandbox path.
+
+
+[bubblewrap]: https://github.com/containers/bubblewrap
+[namespaces]: https://en.wikipedia.org/wiki/Linux_namespaces
+[renameat2]: https://man.archlinux.org/man/renameat2.2.en
diff --git a/crates/sandbox/bind_source_toctou_test.sh b/crates/sandbox/bind_source_toctou_test.sh
new file mode 100755
index 0000000000..9addfa0467
--- /dev/null
+++ b/crates/sandbox/bind_source_toctou_test.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+set -u
+command -v bwrap >/dev/null || { echo "bwrap not found" >&2; exit 2; }
+
+ROOT="$(mktemp -d)"; trap 'rm -rf "$ROOT"' EXIT
+READ_WRITE_DIR="$ROOT/foo/bar"
+ATTACK_TARGET="$ROOT/baz"
+mkdir -p "$READ_WRITE_DIR" "$ATTACK_TARGET"
+
+# /foo (parent of the grant) is read-only; only /foo/bar is writable, so staging
+# then renaming a symlink over /foo/bar fails.
+# 
+# Writable access to `/foo/bar` should not result in the ability to make
+# `/foo/bar` into a symlink.
+bwrap \
+  --ro-bind / / \
+  --bind "$READ_WRITE_DIR" "$READ_WRITE_DIR" \
+  --unshare-user \
+  --setenv READ_WRITE_DIR "$READ_WRITE_DIR" \
+  --setenv ATTACK_TARGET "$ATTACK_TARGET" \
+  /bin/sh -c '
+    ln -s "$ATTACK_TARGET" "$READ_WRITE_DIR.link" && mv -fT "$READ_WRITE_DIR.link" "$READ_WRITE_DIR" && exit 7
+    exit 0
+  '
+rc=$?
+
+if [ "$rc" -eq 7 ] || [ -L "$READ_WRITE_DIR" ]; then echo "FAIL: grant redirected"; exit 1; fi
+[ "$rc" -eq 0 ] || { echo "bwrap error (rc=$rc)" >&2; exit 2; }
+echo "PASS: swap blocked"
diff --git a/crates/sandbox/src/bwrap_test_helper.rs b/crates/sandbox/src/bwrap_test_helper.rs
index 27c8523ce3..d0aaa29a83 100644
--- a/crates/sandbox/src/bwrap_test_helper.rs
+++ b/crates/sandbox/src/bwrap_test_helper.rs
@@ -30,12 +30,13 @@ fn main() {
 mod imp {
     use std::io::{Read as _, Write as _};
     use std::net::TcpStream;
-    use std::path::{Path, PathBuf};
+    use std::path::Path;
     use std::time::Duration;
 
     use anyhow::{Context as _, Result, bail};
     use sandbox::{
-        CommandAndArgs, Sandbox, SandboxError, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
+        CommandAndArgs, GitSandboxPolicy, HostFilesystemLocation, Sandbox, SandboxError,
+        SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
     };
     use serde::Deserialize;
 
@@ -111,6 +112,14 @@ mod imp {
         network_access: NetMode,
         #[serde(default)]
         allowed_domains: Vec,
+        /// `.git` directories to protect (contents read-only on Linux). Selects a
+        /// `Denied` Git policy. Mutually exclusive with `gitAllowed`.
+        #[serde(default)]
+        git_disabled: Vec,
+        /// `.git` directories to make writable. Selects an `Allowed` Git policy.
+        /// Mutually exclusive with `gitDisabled`.
+        #[serde(default)]
+        git_allowed: Vec,
 
         // ---- operation (exactly one) ----
         /// Read this host path from inside the sandbox.
@@ -169,12 +178,24 @@ mod imp {
         checks.finish()
     }
 
-    fn policy_of(check: &Check) -> SandboxPolicy {
+    fn policy_of(check: &Check) -> Result {
         let fs = match check.fs {
             FsMode::Unrestricted => SandboxFsPolicy::Unrestricted,
-            FsMode::Restricted => SandboxFsPolicy::Restricted {
-                writable_paths: check.writable_paths.iter().map(PathBuf::from).collect(),
-            },
+            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.
+                    std::fs::create_dir_all(path)
+                        .with_context(|| format!("failed to create writable path {path}"))?;
+                    writable_paths.push(
+                        HostFilesystemLocation::new(path)
+                            .with_context(|| format!("failed to capture writable path {path}"))?,
+                    );
+                }
+                SandboxFsPolicy::Restricted { writable_paths }
+            }
         };
         let network = match check.network_access {
             NetMode::Unrestricted => SandboxNetPolicy::Unrestricted,
@@ -183,18 +204,47 @@ mod imp {
                 allowed_domains: check.allowed_domains.clone(),
             },
         };
-        SandboxPolicy {
-            fs,
-            network,
-            git: sandbox::GitSandboxPolicy::default(),
-        }
+        // `gitAllowed` and `gitDisabled` are mutually exclusive; `gitAllowed`
+        // wins if both are (mistakenly) set. With neither, the default protects
+        // an empty set of dirs, which is a no-op.
+        let git = if !check.git_allowed.is_empty() {
+            GitSandboxPolicy::Allowed {
+                git_dirs: capture_git_dirs(&check.git_allowed),
+            }
+        } else if !check.git_disabled.is_empty() {
+            GitSandboxPolicy::Denied {
+                git_dirs: capture_git_dirs(&check.git_disabled),
+            }
+        } else {
+            GitSandboxPolicy::default()
+        };
+        Ok(SandboxPolicy { fs, network, git })
+    }
+
+    /// Capture each already-existing `.git` directory, mirroring production's
+    /// fail-closed `filter_map(HostFilesystemLocation::new(..).ok())`: a `.git`
+    /// that does not yet exist can't be pinned and is simply skipped (the
+    /// documented Linux gap). Unlike writable paths, these are never created
+    /// here — whether one exists is exactly what several checks turn on.
+    fn capture_git_dirs(paths: &[String]) -> Vec {
+        paths
+            .iter()
+            .filter_map(|path| HostFilesystemLocation::new(path).ok())
+            .collect()
     }
 
     fn describe(check: &Check) -> String {
         if let Some(name) = &check.name {
             return name.clone();
         }
-        let policy = format!("fs={:?},net={:?}", check.fs, check.network_access);
+        let git = if !check.git_allowed.is_empty() {
+            format!(",git_allowed={:?}", check.git_allowed)
+        } else if !check.git_disabled.is_empty() {
+            format!(",git_disabled={:?}", check.git_disabled)
+        } else {
+            String::new()
+        };
+        let policy = format!("fs={:?},net={:?}{git}", check.fs, check.network_access);
         let op = if let Some(path) = &check.read {
             format!("read {path}")
         } else if let Some(path) = &check.write {
@@ -211,10 +261,10 @@ mod imp {
 
     fn run_check(check: &Check, echo_port: &str, checks: &mut Checks) -> Result<()> {
         let label = describe(check);
-        let policy = policy_of(check);
 
         if let Some(expect_can_create) = check.can_create {
-            let outcome = Sandbox::can_create(&policy, None);
+            let policy = policy_of(check)?;
+            let outcome = Sandbox::can_create(&policy);
             let passed = match (&outcome, expect_can_create) {
                 (Ok(()), true) => true,
                 (Ok(()), false) => false,
@@ -234,11 +284,11 @@ mod imp {
             .with_context(|| format!("check {label:?} has an operation but no `succeeds`"))?;
 
         let actual = if let Some(path) = &check.read {
-            run_read(&policy, path)?
+            run_read(check, path)?
         } else if let Some(path) = &check.write {
-            run_write(&policy, path)?
+            run_write(check, path)?
         } else if let Some(host) = &check.network {
-            run_network(&policy, host, echo_port)?
+            run_network(check, host, echo_port)?
         } else {
             bail!("check {label:?} has no operation");
         };
@@ -250,7 +300,7 @@ mod imp {
     /// Seed a host file, then `cat` it from inside the sandbox. Reads are always
     /// granted (root is bound read-only), so this proves the sandbox doesn't
     /// *block* reads of existing host files.
-    fn run_read(policy: &SandboxPolicy, path: &str) -> Result {
+    fn run_read(check: &Check, path: &str) -> Result {
         let path = Path::new(path);
         if let Some(parent) = path.parent() {
             std::fs::create_dir_all(parent)
@@ -259,7 +309,10 @@ mod imp {
         std::fs::write(path, b"sandbox-test\n")
             .with_context(|| format!("failed to seed readable file {}", path.display()))?;
 
-        let mut sandbox = Sandbox::new(policy.clone()).map_err(sandbox_err)?;
+        // Build the policy only after the fixtures exist: capturing a
+        // `HostFilesystemLocation` pins the inode, so the path must be present.
+        let policy = policy_of(check)?;
+        let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?;
         run_command(
             &mut sandbox,
             "sh",
@@ -271,18 +324,22 @@ mod imp {
     /// the command exited 0 *and* the bytes actually landed on the host file —
     /// a write that only hits the sandbox's ephemeral tmpfs counts as blocked,
     /// since it never escaped the sandbox.
-    fn run_write(policy: &SandboxPolicy, path: &str) -> Result {
+    fn run_write(check: &Check, path: &str) -> Result {
         let path = Path::new(path);
         if let Some(parent) = path.parent() {
             // Create the parent on the host so the only thing under test is the
-            // sandbox's write permission, not a missing directory.
+            // sandbox's write permission, not a missing directory. This also
+            // makes a `.git` parent exist before the policy captures it.
             std::fs::create_dir_all(parent)
                 .with_context(|| format!("failed to create parent of {}", path.display()))?;
         }
         // Start from a clean slate so `exists()` afterwards is meaningful.
         let _ = std::fs::remove_file(path);
 
-        let mut sandbox = Sandbox::new(policy.clone()).map_err(sandbox_err)?;
+        // Build the policy only after the fixtures exist: capturing a
+        // `HostFilesystemLocation` pins the inode, so the path must be present.
+        let policy = policy_of(check)?;
+        let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?;
         let command_ok = run_command(
             &mut sandbox,
             "sh",
@@ -297,14 +354,15 @@ mod imp {
     /// Connect to `host` (`hostname` or `hostname:port`) from inside the
     /// sandbox via the `__echo_check` subcommand, which honors `HTTP_PROXY` for
     /// the restricted-network case.
-    fn run_network(policy: &SandboxPolicy, host: &str, echo_port: &str) -> Result {
+    fn run_network(check: &Check, host: &str, echo_port: &str) -> Result {
         let target = if host.contains(':') {
             host.to_string()
         } else {
             format!("{host}:{echo_port}")
         };
         let exe = current_exe_str()?;
-        let mut sandbox = Sandbox::new(policy.clone()).map_err(sandbox_err)?;
+        let policy = policy_of(check)?;
+        let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?;
         run_command(&mut sandbox, &exe, &[SUBCOMMAND_ECHO_CHECK, &target])
     }
 
diff --git a/crates/sandbox/src/linux_bubblewrap.rs b/crates/sandbox/src/linux_bubblewrap.rs
index 781a90f84d..18e445e02b 100644
--- a/crates/sandbox/src/linux_bubblewrap.rs
+++ b/crates/sandbox/src/linux_bubblewrap.rs
@@ -23,21 +23,41 @@
 //! eliminates a race condition involving BPF user notifications.
 
 use anyhow::{Context as _, Result, anyhow, bail};
-use std::ffi::OsString;
+use std::ffi::{OsStr, OsString};
 use std::io::{Read, Write};
 use std::net::{Ipv4Addr, Shutdown, TcpListener, TcpStream};
+use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd, RawFd};
 use std::os::unix::fs::MetadataExt as _;
-use std::os::unix::net::UnixStream;
-use std::os::unix::process::ExitStatusExt as _;
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::os::unix::process::{CommandExt as _, ExitStatusExt as _};
 use std::path::{Path, PathBuf};
 use std::process::{Command, Stdio};
-use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
 use std::thread;
 
-const BRIDGE_FLAG: &str = "--zed-linux-sandbox-bridge";
+/// Re-exec marker for the in-sandbox launcher: it runs inside the sandbox before
+/// the real command to (a) validate that bwrap bound the writable grants to the
+/// inodes we captured (the bind-source TOCTOU backstop) and (b) run the
+/// restricted-network HTTP bridge. See `README.md` for the design.
+const LAUNCHER_FLAG: &str = "--zed-linux-sandbox-launcher";
+/// Re-exec marker for the WSL-side helper. This runs *inside WSL* (a Linux
+/// process) and does what `Sandbox::wrap` + the validation-fd sender do
+/// in-process on native Linux: capture the writable binds' `O_PATH` fds, stand
+/// up the validation socket, assemble the bwrap invocation, and spawn it. The
+/// 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;
+/// Sentinel argv token meaning "this optional field is absent".
+const LAUNCHER_NONE: &str = "-";
 const PROXY_SOCKET_SANDBOX_PATH_PREFIX: &str = "/tmp/zed-sandbox";
+const VALIDATION_SOCKET_SANDBOX_PATH_PREFIX: &str = "/tmp/zed-sandbox-validate";
 const SANDBOX_SETUP_FAILED_EXIT_CODE: i32 = 126;
 const PUMP_BUFFER_SIZE: usize = 64 * 1024;
+/// Upper bound on writable binds validated in a single `SCM_RIGHTS` message,
+/// kept comfortably below the kernel's per-message fd limit (`SCM_MAX_FD`, 253).
+/// Exceeding it fails closed rather than silently validating a subset.
+const MAX_VALIDATED_BINDS: usize = 200;
 
 /// Network-access setting for a sandboxed command.
 ///
@@ -157,16 +177,40 @@ fn is_setuid_root(path: &Path) -> bool {
     reason = "the probe is a short-lived background operation that must block on bwrap"
 )]
 fn probe_bwrap(bwrap: &Path, bwrap_args: &[String]) -> bool {
-    Command::new(bwrap)
+    // Capture stderr (rather than discarding it) so a failed probe can report
+    // *why* bwrap refused — the difference between "user namespaces disabled",
+    // "chdir target missing", "no permission", etc. — instead of a bare
+    // `SandboxProbeFailed`.
+    let output = Command::new(bwrap)
         .args(bwrap_args)
         .arg("--")
         .arg("true")
         .stdin(Stdio::null())
         .stdout(Stdio::null())
-        .stderr(Stdio::null())
-        .status()
-        .map(|status| status.success())
-        .unwrap_or(false)
+        .stderr(Stdio::piped())
+        .output();
+
+    match output {
+        Ok(output) if output.status.success() => true,
+        Ok(output) => {
+            log::warn!(
+                "[sandbox] bwrap probe failed ({}). command: {} {}\nbwrap stderr: {}",
+                output.status,
+                bwrap.display(),
+                bwrap_args.join(" "),
+                String::from_utf8_lossy(&output.stderr).trim()
+            );
+            false
+        }
+        Err(error) => {
+            log::warn!(
+                "[sandbox] bwrap probe could not be spawned: {error}. command: {} {}",
+                bwrap.display(),
+                bwrap_args.join(" ")
+            );
+            false
+        }
+    }
 }
 
 /// Build the `bwrap` argument list (everything after the `bwrap` program and
@@ -185,23 +229,31 @@ pub fn build_bwrap_args(
     let proxy_socket_sandbox_path = proxy_socket_path
         .filter(|_| matches!(permissions.network, NetworkAccess::LocalhostPort(_)))
         .map(|_| unique_proxy_socket_sandbox_path());
-    build_bwrap_args_with_proxy_socket_sandbox_path(
+    build_bwrap_args_with_sandbox_paths(
         writable_directories,
         protected_git_dirs,
         permissions,
         cwd,
         proxy_socket_path,
         proxy_socket_sandbox_path.as_deref(),
+        None,
+        None,
     )
 }
 
-fn build_bwrap_args_with_proxy_socket_sandbox_path(
+#[allow(
+    clippy::too_many_arguments,
+    reason = "a flat arg list mirrors the bwrap flags this assembles"
+)]
+fn build_bwrap_args_with_sandbox_paths(
     writable_directories: &[&Path],
     protected_git_dirs: &[&Path],
     permissions: SandboxPermissions,
     cwd: Option<&Path>,
     proxy_socket_path: Option<&Path>,
     proxy_socket_sandbox_path: Option<&Path>,
+    validation_socket_path: Option<&Path>,
+    validation_socket_sandbox_path: Option<&Path>,
 ) -> Vec {
     let mut args = Vec::new();
 
@@ -222,20 +274,20 @@ fn build_bwrap_args_with_proxy_socket_sandbox_path(
         args.push("/tmp".to_string());
 
         for directory in writable_directories {
-            // Bind each writable directory at its *exact* path. We must never
-            // fall back to an existing ancestor: that would grant write access
-            // to a broader tree than was requested and approved (e.g. binding
-            // `$HOME` for a not-yet-created `~/.cache/...`, or re-binding the
-            // host `/tmp` over the tmpfs established above). `wrap_invocation`
-            // creates the requested directories before launch so they exist
-            // here; any that still don't are skipped rather than widened.
+            // Bind each writable directory at its *exact* path, **verbatim** —
+            // never re-`canonicalize`d here. The path was already resolved once,
+            // at capture time, to the pinned inode's current path (`readlink` of
+            // the `O_PATH` fd); re-resolving it now would reopen the
+            // time-of-check-to-time-of-use gap a malicious swap exploits. We must
+            // also never widen to an existing ancestor, so a path that doesn't
+            // exist is skipped rather than falling back to a parent. Whatever
+            // inode bwrap actually binds is verified after the mounts by the
+            // in-sandbox launcher (see `validate_binds`), which fails closed on a
+            // mismatch.
             if !directory.exists() {
                 continue;
             }
-            let canonical = directory
-                .canonicalize()
-                .unwrap_or_else(|_| directory.to_path_buf());
-            let path = canonical.to_string_lossy().into_owned();
+            let path = directory.to_string_lossy().into_owned();
             push_bind(&mut args, "--bind", &path, &path);
         }
 
@@ -243,18 +295,17 @@ fn build_bwrap_args_with_proxy_socket_sandbox_path(
         // worktree binds above (order matters: later binds win). Unlike
         // Seatbelt, bwrap can't deny content reads while keeping metadata, so on
         // Linux a protected `.git` is read-only — its contents stay readable but
-        // can't be written. A not-yet-existing `.git` can't be bound, so it's
-        // skipped (a documented gap vs. macOS, which denies it even before it
-        // exists). When Git access is granted these dirs are in
-        // `writable_directories` instead and this list is empty.
+        // can't be written. A read-only re-bind needs no TOCTOU check: the whole
+        // root is already read-only, so re-exposing a path read-only grants
+        // nothing new even if its source was swapped. A not-yet-existing `.git`
+        // can't be bound, so it's skipped (a documented gap vs. macOS). When Git
+        // access is granted these dirs are in `writable_directories` instead and
+        // this list is empty.
         for git_dir in protected_git_dirs {
             if !git_dir.exists() {
                 continue;
             }
-            let canonical = git_dir
-                .canonicalize()
-                .unwrap_or_else(|_| git_dir.to_path_buf());
-            let path = canonical.to_string_lossy().into_owned();
+            let path = git_dir.to_string_lossy().into_owned();
             push_bind(&mut args, "--ro-bind", &path, &path);
         }
     }
@@ -285,6 +336,17 @@ fn build_bwrap_args_with_proxy_socket_sandbox_path(
         NetworkAccess::All => {}
     }
 
+    // The validation socket is filesystem-based, so it works regardless of the
+    // network policy (an `--unshare-net`'d sandbox can't reach an abstract
+    // socket, but a bind-mounted one is fine). Bind it after the `/tmp` tmpfs so
+    // it isn't shadowed by the overlay.
+    if let Some((source, destination)) = validation_socket_path.zip(validation_socket_sandbox_path)
+    {
+        let source = source.to_string_lossy().into_owned();
+        let destination = destination.to_string_lossy().into_owned();
+        push_bind(&mut args, "--bind", &source, &destination);
+    }
+
     if let Some(cwd) = cwd {
         args.push("--chdir".to_string());
         args.push(cwd.to_string_lossy().into_owned());
@@ -308,6 +370,160 @@ fn unique_proxy_socket_sandbox_path() -> PathBuf {
     ))
 }
 
+/// In-sandbox bind destination for the validation socket. Each sandboxed command
+/// runs in its own mount namespace, so this only needs to avoid colliding with
+/// other binds in the same namespace.
+fn unique_validation_socket_sandbox_path() -> PathBuf {
+    static COUNTER: AtomicU64 = AtomicU64::new(0);
+    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
+    PathBuf::from(format!(
+        "{VALIDATION_SOCKET_SANDBOX_PATH_PREFIX}-{}-{counter}.sock",
+        std::process::id()
+    ))
+}
+
+/// Create a private, owner-only directory for the validation listener socket and
+/// return `(directory, socket path)`. The socket is a host path (it lives
+/// outside the sandbox's `/tmp` tmpfs) and is bind-mounted in at
+/// [`unique_validation_socket_sandbox_path`].
+///
+/// The socket carries the captured `O_PATH` descriptors over `SCM_RIGHTS`, so
+/// connect access must be confined to us. Rather than bind in shared `/tmp` and
+/// `chmod` after (which leaves a window where another user could connect before
+/// the `chmod` lands), we create a `0700` directory up front: `create` is atomic
+/// and fails if the path already exists, and `mode(0o700)` makes it
+/// owner-only from creation, so no other user can even traverse to the socket.
+fn unique_validation_socket_host_path() -> std::io::Result<(PathBuf, PathBuf)> {
+    use std::os::unix::fs::DirBuilderExt as _;
+    static COUNTER: AtomicU64 = AtomicU64::new(0);
+    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
+    let dir = std::env::temp_dir().join(format!(
+        "zed-sandbox-validate-{}-{counter}",
+        std::process::id()
+    ));
+    // Clear a stale directory left by a previous run that reused this pid; this
+    // only succeeds for a directory we own, so it can't be used to displace
+    // another user's squatted path (in which case `create` below fails closed).
+    let _ = std::fs::remove_dir_all(&dir);
+    std::fs::DirBuilder::new().mode(0o700).create(&dir)?;
+    let socket_path = dir.join("validate.sock");
+    Ok((dir, socket_path))
+}
+
+/// Host endpoint that hands the in-sandbox validator the captured `O_PATH`
+/// descriptors for the writable binds.
+///
+/// Runs in-process: `spawn` starts a short-lived background **thread** (never a
+/// separate process) that listens on an owner-only `AF_UNIX` socket and serves
+/// the descriptors to **exactly one** client (the in-sandbox validator) via
+/// `SCM_RIGHTS`, then closes and removes the socket. The validator connects
+/// once, after bwrap has mounted the binds; `SCM_RIGHTS` keeps the descriptors
+/// alive in flight, so once the single send completes there is nothing left to
+/// serve. Holding the descriptors until that send keeps the captured inodes
+/// pinned (so they can't be recycled) up to the moment they're handed off.
+///
+/// It is owned by the per-command [`Sandbox`](crate::Sandbox); `Drop` wakes a
+/// still-blocked accept and removes the socket and its private directory (a
+/// no-op if the thread already tore them down). The socket is bind-mounted into
+/// the sandbox at [`Self::sandbox_socket_path`].
+pub(crate) struct ValidationFdSender {
+    host_socket_dir: PathBuf,
+    host_socket_path: PathBuf,
+    sandbox_socket_path: PathBuf,
+    shutdown: Arc,
+}
+
+impl ValidationFdSender {
+    /// Start serving `fds` (one per writable bind, in bind order). The caller
+    /// must pass the same order to the launcher so each fd lines up with its
+    /// bind-destination path.
+    pub(crate) fn spawn(fds: Vec) -> std::io::Result {
+        use std::os::unix::fs::PermissionsExt as _;
+
+        if fds.len() > MAX_VALIDATED_BINDS {
+            return Err(std::io::Error::other(format!(
+                "too many writable binds to validate ({} > {MAX_VALIDATED_BINDS})",
+                fds.len()
+            )));
+        }
+        let (host_socket_dir, host_socket_path) = unique_validation_socket_host_path()?;
+        let listener = UnixListener::bind(&host_socket_path)?;
+        // The enclosing directory is already `0700`, so this is defense in depth:
+        // connect permission on an `AF_UNIX` socket is governed by write
+        // permission on the socket file, so keep it owner-only too.
+        std::fs::set_permissions(&host_socket_path, std::fs::Permissions::from_mode(0o600))?;
+        let sandbox_socket_path = unique_validation_socket_sandbox_path();
+        let shutdown = Arc::new(AtomicBool::new(false));
+
+        // We use std threading APIs here because this code is run from both the
+        // Linux zed binary and also the WSL helper, which does not have a GPUI
+        // runtime.
+        thread::Builder::new()
+            .name("zed-sandbox-validation".to_string())
+            .spawn({
+                let shutdown = shutdown.clone();
+                let host_socket_path = host_socket_path.clone();
+                move || {
+                    // `fds` is held until the single send below, keeping the
+                    // pinned inodes alive until they're handed to the validator.
+                    let raw_fds: Vec = fds.iter().map(|fd| fd.as_raw_fd()).collect();
+                    // Serve exactly one client: the in-sandbox validator connects
+                    // once. A second connection (e.g. the wake from `Drop`) is
+                    // distinguished by the `shutdown` flag and never served.
+                    match listener.accept() {
+                        Ok((stream, _)) => {
+                            if !shutdown.load(Ordering::SeqCst) {
+                                if let Err(error) = send_fds(&stream, &raw_fds) {
+                                    log::warn!("[sandbox] failed to send validation fds: {error}");
+                                }
+                            }
+                        }
+                        Err(error) => {
+                            if !shutdown.load(Ordering::SeqCst) {
+                                log::warn!("[sandbox] sandbox validation accept failed: {error}");
+                            }
+                        }
+                    }
+                    drop(fds);
+                    // Close the listener and remove the socket so it can't be
+                    // connected to again. The directory is removed by `Drop`.
+                    drop(listener);
+                    let _ = std::fs::remove_file(&host_socket_path);
+                }
+            })?;
+
+        Ok(Self {
+            host_socket_dir,
+            host_socket_path,
+            sandbox_socket_path,
+            shutdown,
+        })
+    }
+
+    pub(crate) fn host_socket_path(&self) -> &Path {
+        &self.host_socket_path
+    }
+
+    pub(crate) fn sandbox_socket_path(&self) -> &Path {
+        &self.sandbox_socket_path
+    }
+}
+
+impl Drop for ValidationFdSender {
+    fn drop(&mut self) {
+        // If the validator never connected (e.g. bwrap failed before the
+        // launcher ran), the thread is still blocked in `accept`. Flag shutdown
+        // and poke the socket so the accept returns and the thread exits without
+        // serving the descriptors, then remove the socket and its private
+        // directory (a no-op if the thread already removed the socket after its
+        // single send).
+        self.shutdown.store(true, Ordering::SeqCst);
+        let _ = UnixStream::connect(&self.host_socket_path);
+        let _ = std::fs::remove_file(&self.host_socket_path);
+        let _ = std::fs::remove_dir_all(&self.host_socket_dir);
+    }
+}
+
 fn push_bind(args: &mut Vec, flag: &str, source: &str, destination: &str) {
     args.push(flag.to_string());
     args.push(source.to_string());
@@ -323,38 +539,58 @@ fn resolve_bwrap() -> std::result::Result {
 }
 
 fn prepare_sandbox(
-    writable_dirs: &[&Path],
     permissions: SandboxPermissions,
-    cwd: Option<&Path>,
-    proxy_socket_path: Option<&Path>,
 ) -> std::result::Result<(PathBuf, Vec), LauncherStatus> {
-    let bwrap = resolve_bwrap()?;
-    // The probe only checks whether a sandbox can be created at all, so Git
-    // protection (which doesn't affect createability) is irrelevant here.
-    let bwrap_args = build_bwrap_args(writable_dirs, &[], permissions, cwd, proxy_socket_path);
+    let bwrap = match resolve_bwrap() {
+        Ok(bwrap) => bwrap,
+        Err(status) => {
+            log::warn!("[sandbox] cannot create sandbox: {}", status.describe());
+            return Err(status);
+        }
+    };
+    // The probe only answers "can a sandbox be created on this host at all", so
+    // it runs a bare, representative sandbox: no writable grants, no protected
+    // Git dirs, no proxy socket, and no `--chdir` into a command's working
+    // directory. None of those affect createability, and binding them would make
+    // the probe depend on per-command layout (e.g. a worktree under the `/tmp`
+    // tmpfs that `--chdir` then can't reach).
+    let bwrap_args = build_bwrap_args(&[], &[], permissions, None, None);
     if !probe_bwrap(&bwrap, &bwrap_args) {
         return Err(LauncherStatus::SandboxProbeFailed);
     }
     Ok((bwrap, bwrap_args))
 }
 
-/// Check whether an OS sandbox can be created for this policy.
+/// Check whether an OS sandbox can be created on this host for this policy.
 pub fn check_can_create_sandbox(
-    writable_dirs: &[&Path],
     permissions: SandboxPermissions,
-    cwd: Option<&Path>,
 ) -> std::result::Result<(), LauncherStatus> {
-    prepare_sandbox(writable_dirs, permissions, cwd, None).map(|_| ())
+    prepare_sandbox(permissions).map(|_| ())
+}
+
+/// The host (Zed-side) socket paths for the in-sandbox bind validator.
+#[derive(Clone, Copy)]
+pub struct ValidationSocket<'a> {
+    /// Host pathname of the listener the validator connects back to.
+    pub host_socket_path: &'a Path,
+    /// In-sandbox path the host socket is bind-mounted to (where the validator
+    /// actually connects).
+    pub sandbox_socket_path: &'a Path,
 }
 
 /// Build the final command line that runs `program` inside Bubblewrap.
 ///
-/// `bridge_program` should be the current Zed executable. It is only used for
-/// [`NetworkAccess::LocalhostPort`], where it runs in bridge mode inside the
-/// sandbox before spawning the real command.
+/// `bridge_program` should be the current Zed executable; it is re-exec'd inside
+/// the sandbox as the launcher whenever bind validation and/or the
+/// restricted-network bridge are needed, running before the real command.
 ///
-/// The host `proxy_socket_path` is bind-mounted to a per-invocation unique path
-/// inside the sandbox, and that same in-sandbox path is handed to the bridge.
+/// The host `proxy_socket_path` and `validation_socket` are each bind-mounted to
+/// a per-invocation path inside the sandbox, and those in-sandbox paths are
+/// handed to the launcher.
+#[allow(
+    clippy::too_many_arguments,
+    reason = "assembling a bwrap command line is inherently parameter-heavy"
+)]
 pub fn wrap_invocation(
     bridge_program: &str,
     permissions: SandboxPermissions,
@@ -364,11 +600,18 @@ pub fn wrap_invocation(
     program: &str,
     args: &[String],
     proxy_socket_path: Option<&Path>,
+    validation_socket: Option>,
 ) -> Result<(String, Vec)> {
     if matches!(permissions.network, NetworkAccess::LocalhostPort(_)) && proxy_socket_path.is_none()
     {
         bail!("restricted Linux network access requires a proxy Unix socket path");
     }
+    if writable_dirs.len() > MAX_VALIDATED_BINDS {
+        bail!(
+            "too many writable binds to validate ({} > {MAX_VALIDATED_BINDS})",
+            writable_dirs.len()
+        );
+    }
 
     // Create the requested writable directories up front, with the agent's
     // ambient permissions, so each can be bind-mounted at its exact path (see
@@ -392,28 +635,62 @@ pub fn wrap_invocation(
         NetworkAccess::LocalhostPort(_) => Some(unique_proxy_socket_sandbox_path()),
         NetworkAccess::None | NetworkAccess::All => None,
     };
-    let mut bwrap_args = build_bwrap_args_with_proxy_socket_sandbox_path(
+    let mut bwrap_args = build_bwrap_args_with_sandbox_paths(
         writable_dirs,
         protected_git_dirs,
         permissions,
         cwd,
         proxy_socket_path,
         proxy_socket_sandbox_path.as_deref(),
+        validation_socket.map(|socket| socket.host_socket_path),
+        validation_socket.map(|socket| socket.sandbox_socket_path),
     );
     bwrap_args.push("--".to_string());
 
-    match permissions.network {
+    let bridge = match permissions.network {
         NetworkAccess::LocalhostPort(port) => {
             let proxy_socket_sandbox_path = proxy_socket_sandbox_path
                 .as_ref()
                 .context("missing in-sandbox proxy socket path")?;
-            bwrap_args.push(bridge_program.to_string());
-            bwrap_args.push(BRIDGE_FLAG.to_string());
-            bwrap_args.push(proxy_socket_sandbox_path.to_string_lossy().into_owned());
-            bwrap_args.push(port.to_string());
-            bwrap_args.push("--".to_string());
+            Some((proxy_socket_sandbox_path.clone(), port))
         }
-        NetworkAccess::None | NetworkAccess::All => {}
+        NetworkAccess::None | NetworkAccess::All => None,
+    };
+
+    // The launcher is only needed when there is something for it to do: validate
+    // writable binds, and/or run the restricted-network bridge. Otherwise the
+    // command runs directly under bwrap.
+    if validation_socket.is_some() || bridge.is_some() {
+        bwrap_args.push(bridge_program.to_string());
+        bwrap_args.push(LAUNCHER_FLAG.to_string());
+        // Field 1: validation socket (in-sandbox path) or sentinel.
+        bwrap_args.push(match validation_socket {
+            Some(socket) => socket.sandbox_socket_path.to_string_lossy().into_owned(),
+            None => LAUNCHER_NONE.to_string(),
+        });
+        // Fields 2-3: bridge socket (in-sandbox path) + port, or sentinels.
+        match &bridge {
+            Some((socket, port)) => {
+                bwrap_args.push(socket.to_string_lossy().into_owned());
+                bwrap_args.push(port.to_string());
+            }
+            None => {
+                bwrap_args.push(LAUNCHER_NONE.to_string());
+                bwrap_args.push(LAUNCHER_NONE.to_string());
+            }
+        }
+        // Field 4: the writable bind-destination paths to validate (count, then
+        // the paths), in the same order the host sends their fds.
+        let validation_paths: &[&Path] = if validation_socket.is_some() {
+            writable_dirs
+        } else {
+            &[]
+        };
+        bwrap_args.push(validation_paths.len().to_string());
+        for path in validation_paths {
+            bwrap_args.push(path.to_string_lossy().into_owned());
+        }
+        bwrap_args.push("--".to_string());
     }
 
     bwrap_args.push(program.to_string());
@@ -425,66 +702,182 @@ pub fn wrap_invocation(
     Ok((bwrap.to_string(), bwrap_args))
 }
 
-/// Handle a possible re-exec of this binary as the in-sandbox proxy bridge.
+/// Handle a possible re-exec of this binary as the in-sandbox launcher (bind
+/// validator + network bridge). Does not return if it was invoked as one.
 pub fn run_launcher_if_invoked() {
-    let Some(invocation) = parse_bridge_args(std::env::args_os()) else {
+    let Some(invocation) = parse_launcher_args(std::env::args_os()) else {
         return;
     };
     let invocation = match invocation {
         Ok(invocation) => invocation,
         Err(error) => {
-            eprintln!("zed: malformed sandbox bridge invocation: {error:#}");
+            eprintln!("zed: malformed sandbox launcher invocation: {error:#}");
             std::process::exit(127);
         }
     };
-    run_bridge(invocation);
+    run_launcher(invocation);
 }
 
-struct BridgeInvocation {
-    socket_path: PathBuf,
-    port: u16,
+/// A decoded in-sandbox launcher invocation (the `--zed-linux-sandbox-launcher`
+/// re-exec). All fields are produced by the trusted host side and parsed before
+/// any untrusted command runs.
+struct LauncherInvocation {
+    /// In-sandbox path of the validation socket, if bind validation is required.
+    validation_socket: Option,
+    /// Writable bind-destination paths to validate, in the order the host sends
+    /// their fds. Empty when validation isn't required.
+    validation_paths: Vec,
+    /// `(in-sandbox proxy socket path, loopback port)` if the restricted-network
+    /// bridge is required.
+    bridge: Option<(PathBuf, u16)>,
     program: OsString,
     args: Vec,
 }
 
-fn parse_bridge_args(args: impl IntoIterator) -> Option> {
+fn parse_launcher_args(
+    args: impl IntoIterator,
+) -> Option> {
     let mut args = args.into_iter();
     args.next()?;
-    if args.next()?.to_str() != Some(BRIDGE_FLAG) {
+    if args.next()?.to_str() != Some(LAUNCHER_FLAG) {
         return None;
     }
-    Some(decode_bridge_args(args))
+    Some(decode_launcher_args(args))
 }
 
-fn decode_bridge_args(mut args: impl Iterator) -> Result {
-    let socket_path = PathBuf::from(args.next().context("missing proxy socket path")?);
-    let port = args
+/// Parse an optional field encoded as either a real value or the `-` sentinel.
+fn optional_field(value: OsString) -> Option {
+    if value == LAUNCHER_NONE {
+        None
+    } else {
+        Some(value)
+    }
+}
+
+fn decode_launcher_args(mut args: impl Iterator) -> Result {
+    let validation_socket =
+        optional_field(args.next().context("missing validation socket field")?).map(PathBuf::from);
+    let bridge_socket =
+        optional_field(args.next().context("missing bridge socket field")?).map(PathBuf::from);
+    let bridge_port = optional_field(args.next().context("missing bridge port field")?)
+        .map(|value| {
+            value
+                .to_str()
+                .context("bridge port is not valid UTF-8")?
+                .parse::()
+                .context("invalid bridge port")
+        })
+        .transpose()?;
+    let bridge = match (bridge_socket, bridge_port) {
+        (Some(socket), Some(port)) => Some((socket, port)),
+        (None, None) => None,
+        _ => bail!("bridge socket and port must be set together"),
+    };
+
+    let path_count = args
         .next()
-        .context("missing proxy bridge port")?
+        .context("missing validation path count")?
         .to_str()
-        .context("proxy bridge port is not valid UTF-8")?
-        .parse::()
-        .context("invalid proxy bridge port")?;
-    let separator = args.next().context("missing bridge argument separator")?;
+        .context("validation path count is not valid UTF-8")?
+        .parse::()
+        .context("invalid validation path count")?;
+    if path_count > MAX_VALIDATED_BINDS {
+        bail!("validation path count {path_count} exceeds {MAX_VALIDATED_BINDS}");
+    }
+    let mut validation_paths = Vec::with_capacity(path_count);
+    for _ in 0..path_count {
+        validation_paths.push(PathBuf::from(
+            args.next().context("missing validation path")?,
+        ));
+    }
+
+    let separator = args.next().context("missing launcher argument separator")?;
     if separator != "--" {
-        bail!("missing bridge argument separator");
+        bail!("missing launcher argument separator");
     }
     let program = args.next().context("missing program to run")?;
     let args = args.collect();
-    Ok(BridgeInvocation {
-        socket_path,
-        port,
+    Ok(LauncherInvocation {
+        validation_socket,
+        validation_paths,
+        bridge,
         program,
         args,
     })
 }
 
+/// The in-sandbox launcher entry point. Runs after bwrap's mounts and before the
+/// real command: it verifies the writable binds weren't redirected, optionally
+/// starts the restricted-network bridge, then runs the command. Never returns.
+fn run_launcher(invocation: LauncherInvocation) -> ! {
+    if let Some(socket) = &invocation.validation_socket {
+        if let Err(error) = validate_binds(socket, &invocation.validation_paths) {
+            // Fail closed: a redirected (or unverifiable) writable bind means the
+            // command must not run at all.
+            eprintln!("zed: sandbox bind validation failed: {error:#}");
+            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+        }
+    }
+
+    match invocation.bridge {
+        Some((socket_path, port)) => {
+            run_bridge(socket_path, port, &invocation.program, &invocation.args)
+        }
+        // No bridge to keep alive, so `exec` the command directly rather than
+        // lingering as a parent process.
+        None => exec_command(&invocation.program, &invocation.args),
+    }
+}
+
+/// Verify each writable bind resolves, inside the sandbox, to the exact inode the
+/// host captured. Receives the captured `O_PATH` fds over `socket_path` via
+/// `SCM_RIGHTS` (in the same order as `paths`), then compares `fstat(received
+/// fd)` against `lstat(mounted path)`. Both stats run in this process inside the
+/// sandbox, so the comparison needs no cross-namespace assumption about device
+/// numbers. Any mismatch — or any failure to obtain the expected number of fds —
+/// is an error (the caller fails closed).
+fn validate_binds(socket_path: &Path, paths: &[PathBuf]) -> Result<()> {
+    let stream = UnixStream::connect(socket_path)
+        .with_context(|| format!("connecting to validation socket {}", socket_path.display()))?;
+    let fds = recv_fds(&stream).context("receiving validation descriptors")?;
+    if fds.len() != paths.len() {
+        bail!(
+            "expected {} validation descriptor(s), received {}",
+            paths.len(),
+            fds.len()
+        );
+    }
+    for (fd, path) in fds.iter().zip(paths) {
+        let expected = fd_dev_ino(fd.as_raw_fd())
+            .with_context(|| format!("fstat of captured descriptor for {}", path.display()))?;
+        let mounted = lstat_dev_ino(path)
+            .with_context(|| format!("lstat of mounted bind {}", path.display()))?;
+        if expected != mounted {
+            bail!(
+                "writable bind {} was redirected (captured inode {:?}, mounted inode {:?})",
+                path.display(),
+                expected,
+                mounted
+            );
+        }
+    }
+    Ok(())
+}
+
+/// Replace this process with the sandboxed command. Only returns (after logging)
+/// if `exec` itself fails.
+fn exec_command(program: &OsStr, args: &[OsString]) -> ! {
+    let error = Command::new(program).args(args).exec();
+    eprintln!("zed: failed to exec sandboxed command: {error}");
+    std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+}
+
 #[allow(
     clippy::disallowed_methods,
     reason = "the bridge is an in-sandbox process that must synchronously spawn and wait for the command"
 )]
-fn run_bridge(invocation: BridgeInvocation) -> ! {
-    let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, invocation.port)) {
+fn run_bridge(socket_path: PathBuf, port: u16, program: &OsStr, program_args: &[OsString]) -> ! {
+    let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, port)) {
         Ok(listener) => listener,
         Err(error) => {
             eprintln!("zed: failed to bind sandbox proxy bridge: {error}");
@@ -492,7 +885,6 @@ fn run_bridge(invocation: BridgeInvocation) -> ! {
         }
     };
 
-    let socket_path = invocation.socket_path.clone();
     if let Err(error) = thread::Builder::new()
         .name("zed-sandbox-bridge".to_string())
         .stack_size(128 * 1024)
@@ -502,10 +894,7 @@ fn run_bridge(invocation: BridgeInvocation) -> ! {
         std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
     }
 
-    let mut child = match Command::new(&invocation.program)
-        .args(&invocation.args)
-        .spawn()
-    {
+    let mut child = match Command::new(program).args(program_args).spawn() {
         Ok(child) => child,
         Err(error) => {
             eprintln!("zed: failed to spawn sandboxed command: {error}");
@@ -528,6 +917,281 @@ fn run_bridge(invocation: BridgeInvocation) -> ! {
     }
 }
 
+/// Send `fds` over `stream` in a single message carrying one byte of payload and
+/// the descriptors as `SCM_RIGHTS` ancillary data.
+fn send_fds(stream: &UnixStream, fds: &[RawFd]) -> std::io::Result<()> {
+    use nix::sys::socket::{ControlMessage, MsgFlags, sendmsg};
+    let payload = [0u8; 1];
+    let iov = [std::io::IoSlice::new(&payload)];
+    let control = [ControlMessage::ScmRights(fds)];
+    sendmsg::<()>(
+        stream.as_raw_fd(),
+        &iov,
+        &control,
+        MsgFlags::MSG_NOSIGNAL,
+        None,
+    )
+    .map_err(std::io::Error::from)?;
+    Ok(())
+}
+
+/// Receive the descriptors sent via `SCM_RIGHTS` on `stream`. They arrive with
+/// `O_CLOEXEC` (via `MSG_CMSG_CLOEXEC`) so they don't leak into the command
+/// that's `exec`'d after validation. The ancillary buffer is sized for the
+/// protocol maximum ([`MAX_VALIDATED_BINDS`]); a sender exceeding it trips
+/// `MSG_CTRUNC` and a mismatched count is rejected by the caller.
+fn recv_fds(stream: &UnixStream) -> std::io::Result> {
+    use nix::sys::socket::{ControlMessageOwned, MsgFlags, recvmsg};
+    let mut payload = [0u8; 1];
+    let mut iov = [std::io::IoSliceMut::new(&mut payload)];
+    let mut control = nix::cmsg_space!([RawFd; MAX_VALIDATED_BINDS]);
+    let message = recvmsg::<()>(
+        stream.as_raw_fd(),
+        &mut iov,
+        Some(&mut control),
+        MsgFlags::MSG_CMSG_CLOEXEC,
+    )
+    .map_err(std::io::Error::from)?;
+    if message.flags.contains(MsgFlags::MSG_CTRUNC) {
+        return Err(std::io::Error::other(
+            "validation descriptors were truncated in transit",
+        ));
+    }
+
+    let mut fds = Vec::new();
+    for control_message in message.cmsgs().map_err(std::io::Error::from)? {
+        if let ControlMessageOwned::ScmRights(raw_fds) = control_message {
+            for raw in raw_fds {
+                // SAFETY: the kernel installs each `SCM_RIGHTS` descriptor as a
+                // freshly-allocated, open fd in *our* table for this message
+                // (with `O_CLOEXEC` via `MSG_CMSG_CLOEXEC`), so it's valid and
+                // can't alias an fd we already hold. `nix`'s `ScmRights` does not
+                // take ownership of received fds (closing them is the caller's
+                // responsibility), so wrapping each exactly once here makes us
+                // their sole owner. Both hold for any peer, which is why this
+                // (and `recv_fds`) is sound as a safe fn rather than needing an
+                // `unsafe fn` precondition on the caller.
+                fds.push(unsafe { OwnedFd::from_raw_fd(raw) });
+            }
+        }
+    }
+    Ok(fds)
+}
+
+/// `(device, inode)` of the object an already-open descriptor refers to.
+fn fd_dev_ino(fd: RawFd) -> std::io::Result<(u64, u64)> {
+    let stat = nix::sys::stat::fstat(fd).map_err(std::io::Error::from)?;
+    Ok((stat.st_dev as u64, stat.st_ino as u64))
+}
+
+/// `(device, inode)` of `path` without following a final symlink.
+fn lstat_dev_ino(path: &Path) -> std::io::Result<(u64, u64)> {
+    // `symlink_metadata` is `lstat(2)` (it does not follow a final symlink).
+    let metadata = std::fs::symlink_metadata(path)?;
+    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 {
+    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.
+struct WslHelperInvocation {
+    /// Absolute path of `bwrap` inside WSL (located by the Windows-side probe).
+    bwrap_path: PathBuf,
+    /// Pre-built bwrap *options* — everything before the trailing `-- cmd` —
+    /// assembled on the Windows side (`windows_wsl::build_bwrap_args`): root
+    /// bind, `/tmp` tmpfs, writable binds, Windows-interop blocking, `--setenv`s,
+    /// `--chdir`, namespace flags, etc.
+    base_args: Vec,
+    /// The writable bind destinations to validate — exactly the ones `base_args`
+    /// binds read-write. Each is captured here (WSL-side) and checked in-sandbox.
+    writable_paths: Vec,
+    program: OsString,
+    args: Vec,
+}
+
+/// 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 {
+        return;
+    };
+    let invocation = match invocation {
+        Ok(invocation) => invocation,
+        Err(error) => {
+            eprintln!("zed: malformed WSL sandbox helper invocation: {error:#}");
+            std::process::exit(127);
+        }
+    };
+    run_wsl_helper(invocation);
+}
+
+fn parse_wsl_helper_args(
+    args: impl IntoIterator,
+) -> Option> {
+    let mut args = args.into_iter();
+    args.next()?;
+    if args.next()?.to_str() != Some(WSL_HELPER_FLAG) {
+        return None;
+    }
+    Some(decode_wsl_helper_args(args))
+}
+
+fn decode_wsl_helper_args(mut args: impl Iterator) -> Result {
+    let bwrap_path = PathBuf::from(args.next().context("missing bwrap path")?);
+    let base_count = parse_count(
+        args.next().context("missing base-arg count")?,
+        "base-arg count",
+    )?;
+    let mut base_args = Vec::with_capacity(base_count);
+    for _ in 0..base_count {
+        base_args.push(args.next().context("missing base arg")?);
+    }
+    let writable_count = parse_count(
+        args.next().context("missing writable count")?,
+        "writable count",
+    )?;
+    if writable_count > MAX_VALIDATED_BINDS {
+        bail!("writable count {writable_count} exceeds {MAX_VALIDATED_BINDS}");
+    }
+    let mut writable_paths = Vec::with_capacity(writable_count);
+    for _ in 0..writable_count {
+        writable_paths.push(PathBuf::from(args.next().context("missing writable path")?));
+    }
+    let separator = args.next().context("missing argument separator")?;
+    if separator != "--" {
+        bail!("missing argument separator");
+    }
+    let program = args.next().context("missing program to run")?;
+    let args = args.collect();
+    Ok(WslHelperInvocation {
+        bwrap_path,
+        base_args,
+        writable_paths,
+        program,
+        args,
+    })
+}
+
+fn parse_count(value: OsString, what: &str) -> Result {
+    value
+        .to_str()
+        .with_context(|| format!("{what} is not valid UTF-8"))?
+        .parse::()
+        .with_context(|| format!("invalid {what}"))
+}
+
+/// The WSL-side helper entry point. Mirrors the native-Linux host side: capture
+/// the writable binds' inodes (here, inside WSL), stand up the validation socket,
+/// finish assembling the bwrap invocation (validation socket bind + in-sandbox
+/// validator re-exec), spawn bwrap, and propagate its exit. Never returns.
+#[allow(
+    clippy::disallowed_methods,
+    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) {
+            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}",
+                    path.display()
+                );
+                std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+            }
+        }
+    }
+
+    let validation = if fds.is_empty() {
+        None
+    } else {
+        match ValidationFdSender::spawn(fds) {
+            Ok(sender) => Some(sender),
+            Err(error) => {
+                eprintln!("zed: WSL sandbox helper could not start the bind validator: {error}");
+                std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+            }
+        }
+    };
+
+    let current_exe = match std::env::current_exe() {
+        Ok(path) => path,
+        Err(error) => {
+            eprintln!("zed: WSL sandbox helper could not resolve its own path: {error}");
+            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+        }
+    };
+
+    let mut args = invocation.base_args.clone();
+    if let Some(sender) = &validation {
+        // Bind the validation socket in (after the base args' tmpfs and writable
+        // binds so it isn't shadowed), then re-exec ourselves inside the sandbox
+        // as the validator before the real command. WSL has no restricted-network
+        // bridge, so both bridge fields are the absent sentinel.
+        args.push(OsString::from("--bind"));
+        args.push(sender.host_socket_path().as_os_str().to_os_string());
+        args.push(sender.sandbox_socket_path().as_os_str().to_os_string());
+        args.push(OsString::from("--"));
+        args.push(current_exe.into_os_string());
+        args.push(OsString::from(LAUNCHER_FLAG));
+        args.push(sender.sandbox_socket_path().as_os_str().to_os_string());
+        args.push(OsString::from(LAUNCHER_NONE));
+        args.push(OsString::from(LAUNCHER_NONE));
+        args.push(OsString::from(invocation.writable_paths.len().to_string()));
+        for path in &invocation.writable_paths {
+            args.push(path.clone().into_os_string());
+        }
+        args.push(OsString::from("--"));
+    } else {
+        // Nothing to validate — run the command directly under bwrap.
+        args.push(OsString::from("--"));
+    }
+    args.push(invocation.program.clone());
+    args.extend(invocation.args.iter().cloned());
+
+    let mut child = match Command::new(&invocation.bwrap_path).args(&args).spawn() {
+        Ok(child) => child,
+        Err(error) => {
+            eprintln!("zed: WSL sandbox helper could not spawn bwrap: {error}");
+            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+        }
+    };
+
+    let status = child.wait();
+    // Hold the validator open until bwrap (and thus the in-sandbox validator that
+    // connects to it during startup) has finished.
+    drop(validation);
+    match status {
+        Ok(status) => {
+            if let Some(code) = status.code() {
+                std::process::exit(code);
+            }
+            let signal = status.signal().unwrap_or(1);
+            std::process::exit(128 + signal);
+        }
+        Err(error) => {
+            eprintln!("zed: WSL sandbox helper failed waiting for bwrap: {error}");
+            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+        }
+    }
+}
+
 fn run_bridge_listener(listener: TcpListener, socket_path: PathBuf) {
     for stream in listener.incoming() {
         match stream {
@@ -630,8 +1294,8 @@ fn copy_one_way(mut from: impl Read, mut to: impl BridgeStream) {
 mod tests {
     use super::*;
 
-    fn bridge_argv(launcher: &str, args: Vec<&str>) -> Vec {
-        std::iter::once(launcher)
+    fn launcher_argv(program: &str, args: Vec<&str>) -> Vec {
+        std::iter::once(program)
             .chain(args)
             .map(OsString::from)
             .collect()
@@ -678,8 +1342,9 @@ mod tests {
         );
 
         assert!(windows_contains(&args, &["--ro-bind", "/", "/"]));
-        let writable_path = writable.path().canonicalize().unwrap();
-        let writable_str = writable_path.to_string_lossy().into_owned();
+        // The writable dir is bound verbatim at the exact path given (never
+        // re-canonicalized, which would reopen the bind-source TOCTOU gap).
+        let writable_str = writable.path().to_string_lossy().into_owned();
         assert!(windows_contains(
             &args,
             &["--bind", &writable_str, &writable_str]
@@ -736,14 +1401,19 @@ mod tests {
     }
 
     #[test]
-    fn test_bridge_args_round_trip() {
-        let sandbox_socket_path = "/tmp/zed-sandbox-1234-0.sock";
-        let argv = bridge_argv(
+    fn test_launcher_args_round_trip_bridge_and_validation() {
+        let bridge_socket = "/tmp/zed-sandbox-1234-0.sock";
+        let validate_socket = "/tmp/zed-sandbox-validate-1234-0.sock";
+        let argv = launcher_argv(
             "/path/to/zed",
             vec![
-                BRIDGE_FLAG,
-                sandbox_socket_path,
+                LAUNCHER_FLAG,
+                validate_socket,
+                bridge_socket,
                 "8080",
+                "2",
+                "/work/a",
+                "/work/b",
                 "--",
                 "/bin/sh",
                 "-c",
@@ -751,12 +1421,22 @@ mod tests {
             ],
         );
 
-        let decoded = parse_bridge_args(argv)
-            .expect("should be recognized as bridge invocation")
+        let decoded = parse_launcher_args(argv)
+            .expect("should be recognized as launcher invocation")
             .expect("should decode successfully");
 
-        assert_eq!(decoded.socket_path, PathBuf::from(sandbox_socket_path));
-        assert_eq!(decoded.port, 8080);
+        assert_eq!(
+            decoded.validation_socket,
+            Some(PathBuf::from(validate_socket))
+        );
+        assert_eq!(
+            decoded.validation_paths,
+            vec![PathBuf::from("/work/a"), PathBuf::from("/work/b")]
+        );
+        assert_eq!(
+            decoded.bridge,
+            Some((PathBuf::from(bridge_socket), 8080u16))
+        );
         assert_eq!(decoded.program, OsString::from("/bin/sh"));
         assert_eq!(
             decoded.args,
@@ -764,6 +1444,80 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_wsl_helper_args_round_trip() {
+        let argv = launcher_argv(
+            "/path/to/zed",
+            vec![
+                WSL_HELPER_FLAG,
+                "/usr/bin/bwrap",
+                // base bwrap options (3 tokens)
+                "3",
+                "--ro-bind",
+                "/",
+                "/",
+                // writable binds to validate (1)
+                "1",
+                "/work/a",
+                "--",
+                "/bin/sh",
+                "-c",
+                "echo hi",
+            ],
+        );
+
+        let decoded = parse_wsl_helper_args(argv)
+            .expect("should be recognized as a WSL helper invocation")
+            .expect("should decode successfully");
+
+        assert_eq!(decoded.bwrap_path, PathBuf::from("/usr/bin/bwrap"));
+        assert_eq!(
+            decoded.base_args,
+            vec![
+                OsString::from("--ro-bind"),
+                OsString::from("/"),
+                OsString::from("/")
+            ]
+        );
+        assert_eq!(decoded.writable_paths, vec![PathBuf::from("/work/a")]);
+        assert_eq!(decoded.program, OsString::from("/bin/sh"));
+        assert_eq!(
+            decoded.args,
+            vec![OsString::from("-c"), OsString::from("echo hi")]
+        );
+    }
+
+    #[test]
+    fn test_launcher_args_round_trip_no_bridge() {
+        let validate_socket = "/tmp/zed-sandbox-validate-1234-0.sock";
+        let argv = launcher_argv(
+            "/path/to/zed",
+            vec![
+                LAUNCHER_FLAG,
+                validate_socket,
+                LAUNCHER_NONE,
+                LAUNCHER_NONE,
+                "1",
+                "/work/a",
+                "--",
+                "/bin/true",
+            ],
+        );
+
+        let decoded = parse_launcher_args(argv)
+            .expect("should be recognized as launcher invocation")
+            .expect("should decode successfully");
+
+        assert_eq!(
+            decoded.validation_socket,
+            Some(PathBuf::from(validate_socket))
+        );
+        assert_eq!(decoded.validation_paths, vec![PathBuf::from("/work/a")]);
+        assert_eq!(decoded.bridge, None);
+        assert_eq!(decoded.program, OsString::from("/bin/true"));
+        assert!(decoded.args.is_empty());
+    }
+
     #[test]
     fn test_wrap_invocation_uses_bridge_for_restricted_network() {
         let socket = PathBuf::from("/tmp/zed-proxy.sock");
@@ -780,7 +1534,8 @@ mod tests {
         );
 
         // The bind destination inside the sandbox and the path handed to the
-        // bridge must be the same unique path.
+        // launcher's bridge fields must be the same unique path. With no writable
+        // binds, the validation field is the `-` sentinel and the path count is 0.
         let sandbox_destination = proxy_socket_bind_destination(&args)
             .expect("restricted run should bind the proxy socket into the sandbox");
         assert!(sandbox_destination.starts_with(PROXY_SOCKET_SANDBOX_PATH_PREFIX));
@@ -788,14 +1543,19 @@ mod tests {
             &args,
             &[
                 "/path/to/zed",
-                BRIDGE_FLAG,
+                LAUNCHER_FLAG,
+                LAUNCHER_NONE,
                 &sandbox_destination,
                 "8080",
-                "--"
+                "0",
+                "--",
             ]
         ));
     }
 
+    /// Reconstruct the argv `wrap_invocation` would produce for the bridge-only
+    /// (no writable binds, no validation socket) case, without needing a real
+    /// `bwrap` on the test host.
     fn build_wrapped_args_for_test(
         bridge_program: &str,
         permissions: SandboxPermissions,
@@ -807,22 +1567,26 @@ mod tests {
             NetworkAccess::LocalhostPort(_) => Some(unique_proxy_socket_sandbox_path()),
             NetworkAccess::None | NetworkAccess::All => None,
         };
-        let mut bwrap_args = build_bwrap_args_with_proxy_socket_sandbox_path(
+        let mut bwrap_args = build_bwrap_args_with_sandbox_paths(
             &[],
             &[],
             permissions,
             None,
             proxy_socket_path,
             proxy_socket_sandbox_path.as_deref(),
+            None,
+            None,
         );
         bwrap_args.push("--".to_string());
         if let NetworkAccess::LocalhostPort(port) = permissions.network {
             let proxy_socket_sandbox_path =
                 proxy_socket_sandbox_path.expect("restricted network needs a sandbox socket path");
             bwrap_args.push(bridge_program.to_string());
-            bwrap_args.push(BRIDGE_FLAG.to_string());
+            bwrap_args.push(LAUNCHER_FLAG.to_string());
+            bwrap_args.push(LAUNCHER_NONE.to_string());
             bwrap_args.push(proxy_socket_sandbox_path.to_string_lossy().into_owned());
             bwrap_args.push(port.to_string());
+            bwrap_args.push("0".to_string());
             bwrap_args.push("--".to_string());
         }
         bwrap_args.push(program.to_string());
@@ -846,4 +1610,61 @@ mod tests {
             .windows(needle.len())
             .any(|window| window.iter().map(String::as_str).eq(needle.iter().copied()))
     }
+
+    /// Open an `O_PATH` descriptor to `path`, mirroring how the policy layer
+    /// captures a `HostFilesystemLocation`.
+    fn open_o_path(path: &Path) -> 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)
+            .expect("open O_PATH");
+        OwnedFd::from(file)
+    }
+
+    // End-to-end check of the bind validator's core, without a real sandbox:
+    // the server hands over the captured fd via SCM_RIGHTS and `validate_binds`
+    // compares it against the path it's told was mounted. A matching path passes;
+    // a *different* directory (as a redirected bind would produce) is rejected,
+    // proving the validator fails closed rather than no-ops.
+    //
+    // Each `ValidationFdSender` serves a single client and then tears down, so
+    // the two cases use separate senders.
+    #[test]
+    fn test_validate_binds_accepts_match_and_rejects_redirect() {
+        let captured = tempfile::tempdir().unwrap();
+        let other = tempfile::tempdir().unwrap();
+
+        // The mounted path is the captured inode -> validation passes.
+        let sender = ValidationFdSender::spawn(vec![open_o_path(captured.path())])
+            .expect("spawn validation fd sender");
+        validate_binds(sender.host_socket_path(), &[captured.path().to_path_buf()])
+            .expect("matching bind must validate");
+        drop(sender);
+
+        // The mounted path is a *different* inode (a redirected bind) -> rejected.
+        let sender = ValidationFdSender::spawn(vec![open_o_path(captured.path())])
+            .expect("spawn validation fd sender");
+        let error = validate_binds(sender.host_socket_path(), &[other.path().to_path_buf()])
+            .expect_err("a redirected bind must be rejected");
+        assert!(
+            error.to_string().contains("redirected"),
+            "unexpected error: {error:#}"
+        );
+    }
+
+    // A wrong fd count from the server (here: zero fds for one path) must fail
+    // closed too — the validator never assumes an unvalidated bind is fine.
+    #[test]
+    fn test_validate_binds_rejects_missing_descriptors() {
+        let captured = tempfile::tempdir().unwrap();
+        let sender = ValidationFdSender::spawn(Vec::new()).expect("spawn validation fd sender");
+        let error = validate_binds(sender.host_socket_path(), &[captured.path().to_path_buf()])
+            .expect_err("missing descriptors must be rejected");
+        assert!(
+            error.to_string().contains("descriptor"),
+            "unexpected error: {error:#}"
+        );
+    }
 }
diff --git a/crates/sandbox/src/macos_seatbelt.rs b/crates/sandbox/src/macos_seatbelt.rs
index ec4605f198..91efe4467f 100644
--- a/crates/sandbox/src/macos_seatbelt.rs
+++ b/crates/sandbox/src/macos_seatbelt.rs
@@ -209,21 +209,19 @@ fn generate_seatbelt_config(
     allowed_unix_socket_paths: &[&Path],
     permissions: SandboxPermissions,
 ) -> Result {
-    // Canonicalize each writable path to resolve symlinks (e.g.,
-    // /var -> /private/var on macOS). Fall back to the original path if
-    // canonicalization fails.
+    // These paths are already the canonical identities captured once, at
+    // validation time, inside each `HostFilesystemLocation` (resolving symlinks
+    // and, for a not-yet-created `.git`, its existing parent). We deliberately do
+    // NOT re-canonicalize here: re-resolving a path at profile-generation time
+    // is the time-of-check-to-time-of-use hole this design closes. Use them
+    // verbatim as Seatbelt rule literals.
     let canonical_writable_directories: Vec = writable_directories
         .iter()
-        .map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf()))
+        .map(|path| path.to_path_buf())
         .collect();
-    // Use `canonicalize_allowing_missing_leaf` rather than a plain
-    // `canonicalize` so a not-yet-created `.git` (before `git init`) still
-    // resolves through its existing parent and matches the canonicalized
-    // writable worktree above; otherwise the deny rule would miss the real path
-    // on a symlinked root (`/tmp` -> `/private/tmp`).
     let canonical_protected_paths: Vec = protected_paths
         .iter()
-        .map(|path| crate::canonicalize_allowing_missing_leaf(path))
+        .map(|path| path.to_path_buf())
         .collect();
     // Unlike file paths, Unix socket literals are emitted verbatim: it isn't
     // guaranteed whether Seatbelt resolves symlinks before matching a
@@ -564,7 +562,11 @@ mod tests {
         use std::process::Command;
 
         let temp_dir = tempfile::tempdir().unwrap();
-        let protected_file = temp_dir.path().join(".gitignore");
+        // Canonicalize so the policy paths resolve the macOS `/var` -> `/private/var`
+        // symlink; Seatbelt matches rules against the resolved path, as production
+        // does via `HostFilesystemLocation`.
+        let dir = std::fs::canonicalize(temp_dir.path()).unwrap();
+        let protected_file = dir.join(".gitignore");
         std::fs::write(&protected_file, "target\n").unwrap();
 
         let (program, args, _config_file) = wrap_invocation(
@@ -578,7 +580,7 @@ mod tests {
                     protected_file.display(),
                 ),
             ],
-            &[temp_dir.path()],
+            &[dir.as_path()],
             &[protected_file.as_path()],
             &[],
             SandboxPermissions::default(),
@@ -607,7 +609,10 @@ mod tests {
         use std::process::Command;
 
         let temp_dir = tempfile::tempdir().unwrap();
-        let protected_file = temp_dir.path().join(".gitignore");
+        // See the sibling protected-path test: canonicalize so policy paths resolve
+        // the macOS `/var` -> `/private/var` symlink that Seatbelt matches against.
+        let dir = std::fs::canonicalize(temp_dir.path()).unwrap();
+        let protected_file = dir.join(".gitignore");
         std::fs::write(&protected_file, "target\n").unwrap();
 
         let (program, args, _config_file) = wrap_invocation(
@@ -620,7 +625,7 @@ mod tests {
                     protected_file.display(),
                 ),
             ],
-            &[temp_dir.path()],
+            &[dir.as_path()],
             &[protected_file.as_path()],
             &[],
             SandboxPermissions {
@@ -870,7 +875,11 @@ mod tests {
 
         let project_dir = tempfile::tempdir().unwrap();
         let scratch_dir = tempfile::tempdir().unwrap();
-        let test_file = scratch_dir.path().join("test_write.txt");
+        // Canonicalize so the writable subpaths resolve the macOS `/var` ->
+        // `/private/var` symlink that Seatbelt matches against.
+        let project_path = std::fs::canonicalize(project_dir.path()).unwrap();
+        let scratch_path = std::fs::canonicalize(scratch_dir.path()).unwrap();
+        let test_file = scratch_path.join("test_write.txt");
 
         let (program, args, _config_file) = wrap_invocation(
             "/bin/sh",
@@ -878,7 +887,7 @@ mod tests {
                 "-c".to_string(),
                 format!("echo 'hello' > '{}'", test_file.display()),
             ],
-            &[project_dir.path(), scratch_dir.path()],
+            &[project_path.as_path(), scratch_path.as_path()],
             &[],
             &[],
             SandboxPermissions::default(),
@@ -903,7 +912,10 @@ mod tests {
         use std::process::Command;
 
         let temp_dir = tempfile::tempdir().unwrap();
-        let test_file = temp_dir.path().join("test_write.txt");
+        // Canonicalize so the writable subpath resolves the macOS `/var` ->
+        // `/private/var` symlink that Seatbelt matches against.
+        let dir = std::fs::canonicalize(temp_dir.path()).unwrap();
+        let test_file = dir.join("test_write.txt");
 
         let (program, args, _config_file) = wrap_invocation(
             "/bin/sh",
@@ -911,7 +923,7 @@ mod tests {
                 "-c".to_string(),
                 format!("echo 'hello' > '{}'", test_file.display()),
             ],
-            &[temp_dir.path()],
+            &[dir.as_path()],
             &[],
             &[],
             SandboxPermissions::default(),
diff --git a/crates/sandbox/src/sandbox.rs b/crates/sandbox/src/sandbox.rs
index 390fd4ed55..4873c46ed5 100644
--- a/crates/sandbox/src/sandbox.rs
+++ b/crates/sandbox/src/sandbox.rs
@@ -29,6 +29,249 @@ mod windows_wsl;
 #[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 (a writable subtree, a `.git` directory, …).
+///
+/// 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 ` 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,
+    /// 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 that a `.git` is not a symlink) — 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) -> std::io::Result {
+        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 (e.g. a `.git` before `git init`)
+            // 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 {
+        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), linux_fd_identity(&other.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::OwnedFd) -> Option<(u64, u64)> {
+    use std::os::fd::AsRawFd as _;
+    let stat = nix::sys::stat::fstat(fd.as_raw_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).
+///
+/// Unlike [`HostFilesystemLocation`], this needs no hardening and is just a thin
+/// wrapper around a [`PathBuf`]. It only names a location within the sandbox's
+/// own namespace: the worst a tampered sandbox-side path can do is expose the
+/// (already-granted) host files at a *different* path inside the sandbox — it can
+/// never widen which host files are reachable. It is therefore fine to build one
+/// from an ordinary, even attacker-influenced, path.
+#[derive(Clone, Debug, Eq, PartialEq, Hash)]
+pub struct SandboxFilesystemLocation(PathBuf);
+
+impl SandboxFilesystemLocation {
+    /// Name a location inside the sandbox's filesystem view.
+    pub fn new(path: impl Into) -> Self {
+        Self(path.into())
+    }
+
+    /// The in-sandbox path. Safe to read: this is not a trusted host identity.
+    pub fn as_path(&self) -> &Path {
+        &self.0
+    }
+
+    /// Consume this wrapper, yielding the underlying in-sandbox path.
+    pub fn into_path_buf(self) -> PathBuf {
+        self.0
+    }
+}
+
+impl From for SandboxFilesystemLocation {
+    fn from(path: PathBuf) -> Self {
+        Self(path)
+    }
+}
+
 /// What a command is allowed to do, expressed as intent. This is the entire
 /// public configuration surface; how each policy is enforced (Seatbelt rules,
 /// Bubblewrap flags, a loopback proxy, …) is an implementation detail.
@@ -44,9 +287,13 @@ pub struct SandboxPolicy {
 pub enum SandboxFsPolicy {
     /// Allow unrestricted filesystem writes.
     Unrestricted,
-    /// Reads are allowed everywhere; writes are confined to these directory
-    /// subtrees (and the standard ephemeral locations the platform provides).
-    Restricted { writable_paths: Vec },
+    /// Reads are allowed everywhere; writes are confined to these locations
+    /// (and the standard ephemeral locations the platform provides). Each is a
+    /// [`HostFilesystemLocation`] captured at validation time, never a bare path
+    /// the enforcement layer would re-resolve.
+    Restricted {
+        writable_paths: Vec,
+    },
 }
 
 /// Outbound-network policy for a sandboxed command.
@@ -72,9 +319,13 @@ pub enum GitSandboxPolicy {
     /// `.git` contents are protected: read-only on Linux and Windows/WSL;
     /// content reads and writes denied on macOS (metadata stays visible either
     /// way).
-    Denied { git_dirs: Vec },
+    Denied {
+        git_dirs: Vec,
+    },
     /// `.git` contents are writable (these dirs are made writable).
-    Allowed { git_dirs: Vec },
+    Allowed {
+        git_dirs: Vec,
+    },
 }
 
 impl Default for GitSandboxPolicy {
@@ -88,7 +339,7 @@ impl Default for GitSandboxPolicy {
 
 impl GitSandboxPolicy {
     /// The `.git` directories this policy governs, regardless of variant.
-    pub fn git_dirs(&self) -> &[PathBuf] {
+    pub fn git_dirs(&self) -> &[HostFilesystemLocation] {
         match self {
             GitSandboxPolicy::Denied { git_dirs } | GitSandboxPolicy::Allowed { git_dirs } => {
                 git_dirs
@@ -285,7 +536,7 @@ impl std::error::Error for SandboxError {}
 /// Resolved filesystem setup derived from [`SandboxFsPolicy`].
 struct FsSetup {
     allow_fs_write: bool,
-    writable_paths: Vec,
+    writable_paths: Vec,
 }
 
 /// Resolved network plan derived from [`SandboxNetPolicy`]. For the restricted
@@ -317,6 +568,23 @@ pub struct Sandbox {
     /// In-process network proxy for the restricted-network case, spawned on the
     /// first `wrap`. Dropped on a background thread (the join blocks).
     proxy: Option,
+    /// Linux only: the host endpoint that hands the in-sandbox validator the
+    /// captured `O_PATH` fds over a unix socket. Runs entirely in-process (a
+    /// short-lived background thread, never a separate process) and is owned by
+    /// this `Sandbox` — which is created per command — so it comes up when the
+    /// command is wrapped and is torn down (thread stopped, socket removed) when
+    /// the command finishes. Holds the fds, keeping their inodes pinned until
+    /// then. Created lazily on the wrap that first needs it (a restricted-fs run
+    /// with writable binds); a `Sandbox` normally wraps a single command.
+    #[cfg(target_os = "linux")]
+    validation_fd_sender: Option,
+    /// Windows only: `(release channel, version)` of the Linux `zed` to
+    /// provision inside WSL as the `--wsl-sandbox-helper` (version `latest` for
+    /// dev builds). Set by the caller (which has the running release info);
+    /// `None` falls back to exec'ing bwrap directly without in-sandbox bind
+    /// validation.
+    #[cfg(target_os = "windows")]
+    wsl_zed_release: Option<(String, String)>,
     #[cfg(target_os = "macos")]
     seatbelt_config: Option,
 }
@@ -353,29 +621,48 @@ impl Sandbox {
             network,
             git: policy.git,
             proxy: None,
+            #[cfg(target_os = "linux")]
+            validation_fd_sender: None,
+            #[cfg(target_os = "windows")]
+            wsl_zed_release: None,
             #[cfg(target_os = "macos")]
             seatbelt_config: None,
         })
     }
 
-    /// Check whether the platform sandbox can be created for `policy` without
+    /// Windows only: record the `(release channel, version)` of the Linux `zed`
+    /// to provision inside WSL as the sandbox helper (version `latest` for dev
+    /// builds). The caller resolves these from the running app's release info
+    /// (which this low-level crate can't read) and sets them before `wrap`. When
+    /// unset, the WSL backend falls back to exec'ing bwrap directly without
+    /// in-sandbox bind validation.
+    #[cfg(target_os = "windows")]
+    pub fn set_wsl_zed_release(&mut self, channel: String, version: String) {
+        self.wsl_zed_release = Some((channel, version));
+    }
+
+    /// Check whether the platform sandbox can be created on this host without
     /// actually building a command or spawning the proxy. On Linux this runs a
     /// brief `bwrap` probe (call it off the main thread).
-    pub fn can_create(policy: &SandboxPolicy, cwd: Option<&Path>) -> Result<(), SandboxError> {
+    ///
+    /// This answers a purely *environmental* question — is a bwrap sandbox
+    /// possible here at all (a usable `bwrap`, plus the unprivileged user
+    /// namespace and mount scaffolding we rely on)? It deliberately does **not**
+    /// depend on any command's writable grants or working directory: the probe
+    /// runs a bare, representative sandbox and runs `true` in it. (Unlike the
+    /// former Landlock probe, bwrap needs no ABI-matching against the real
+    /// ruleset, so there's no reason to mirror the real command's mounts.)
+    pub fn can_create(policy: &SandboxPolicy) -> Result<(), SandboxError> {
         #[cfg(target_os = "linux")]
         {
-            let writable = policy_writable_paths(policy);
-            let writable: Vec<&Path> = writable.iter().map(PathBuf::as_path).collect();
             let permissions = linux_bubblewrap::SandboxPermissions {
                 network: linux_probe_network(&policy.network),
                 allow_fs_write: matches!(policy.fs, SandboxFsPolicy::Unrestricted),
             };
-            linux_bubblewrap::check_can_create_sandbox(&writable, permissions, cwd)
-                .map_err(map_linux_status)
+            linux_bubblewrap::check_can_create_sandbox(permissions).map_err(map_linux_status)
         }
         #[cfg(target_os = "windows")]
         {
-            let _ = cwd;
             if matches!(policy.network, SandboxNetPolicy::Restricted { .. }) {
                 return Err(unsupported_restricted_network_on_windows());
             }
@@ -383,7 +670,7 @@ impl Sandbox {
         }
         #[cfg(not(any(target_os = "linux", target_os = "windows")))]
         {
-            let _ = (policy, cwd);
+            let _ = policy;
             Ok(())
         }
     }
@@ -491,7 +778,7 @@ impl Sandbox {
     /// 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", target_os = "windows"))]
-    fn git_path_split(&self) -> (Vec, Vec) {
+    fn git_path_split(&self) -> (Vec, Vec) {
         if self.fs.allow_fs_write {
             return (Vec::new(), Vec::new());
         }
@@ -520,14 +807,61 @@ impl Sandbox {
             allow_fs_write: self.fs.allow_fs_write,
         };
         let (git_writable, git_protected) = self.git_path_split();
-        let mut writable: Vec<&Path> = self
-            .fs
-            .writable_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.
+        let mut writable_owned: Vec = Vec::new();
+        let mut writable_fds: Vec = Vec::new();
+        for location in self.fs.writable_paths.iter().chain(git_writable.iter()) {
+            let Some(path) = linux_location_path(location) else {
+                continue;
+            };
+            match location.linux_dup_fd() {
+                Ok(fd) => {
+                    writable_owned.push(path);
+                    writable_fds.push(fd);
+                }
+                Err(error) => {
+                    // Fail closed: a bind we can't pin a verifiable fd for is
+                    // dropped rather than bound unverified.
+                    log::warn!(
+                        "[sandbox] could not duplicate fd for writable bind {}: {error}",
+                        path.display()
+                    );
+                }
+            }
+        }
+        let writable: Vec<&Path> = writable_owned.iter().map(PathBuf::as_path).collect();
+        let protected_owned: Vec = git_protected
             .iter()
-            .map(PathBuf::as_path)
+            .filter_map(linux_location_path)
             .collect();
-        writable.extend(git_writable.iter().map(PathBuf::as_path));
-        let protected_git_dirs: Vec<&Path> = git_protected.iter().map(PathBuf::as_path).collect();
+        let protected_git_dirs: Vec<&Path> = protected_owned.iter().map(PathBuf::as_path).collect();
+
+        // Stand up the host endpoint that sends the captured fds to the
+        // in-sandbox validator, when this run has writable binds to verify. It's
+        // an in-process background thread owned by this (per-command) `Sandbox`,
+        // so it lives only for the command's duration. The sender serves its
+        // descriptors to exactly one client and then tears itself down, so each
+        // wrap creates a fresh one (replacing any from a prior wrap); a
+        // `Sandbox` normally wraps a single command.
+        if !self.fs.allow_fs_write && !writable_fds.is_empty() {
+            let sender =
+                linux_bubblewrap::ValidationFdSender::spawn(writable_fds).map_err(|error| {
+                    SandboxError::Io(format!("failed to start sandbox bind validator: {error}"))
+                })?;
+            self.validation_fd_sender = Some(sender);
+        }
+        let validation_socket =
+            self.validation_fd_sender
+                .as_ref()
+                .map(|sender| linux_bubblewrap::ValidationSocket {
+                    host_socket_path: sender.host_socket_path(),
+                    sandbox_socket_path: sender.sandbox_socket_path(),
+                });
 
         let bridge_program = std::env::current_exe()
             .map_err(|error| SandboxError::BridgeExecutableUnavailable(error.to_string()))?;
@@ -547,6 +881,7 @@ impl Sandbox {
             &command.program,
             &command.args,
             proxy_socket_path.as_deref(),
+            validation_socket,
         )
         .map_err(map_anyhow_error)?;
 
@@ -576,14 +911,28 @@ impl Sandbox {
             allow_fs_write: self.fs.allow_fs_write,
         };
         let (git_writable, git_protected) = self.git_path_split();
+        // Each location's canonical path was resolved exactly once at capture
+        // time; pass it straight through as the Seatbelt rule literal. The
+        // profile generator must NOT re-canonicalize (that reopened the
+        // verify-vs-enforce gap); see `generate_seatbelt_config`.
         let mut writable: Vec<&Path> = self
             .fs
             .writable_paths
             .iter()
-            .map(PathBuf::as_path)
+            .map(HostFilesystemLocation::macos_canonical_path)
             .collect();
-        writable.extend(git_writable.iter().map(PathBuf::as_path));
-        let protected: Vec<&Path> = git_protected.iter().map(PathBuf::as_path).collect();
+        writable.extend(
+            git_writable
+                .iter()
+                .map(HostFilesystemLocation::macos_canonical_path),
+        );
+        let protected: Vec<&Path> = git_protected
+            .iter()
+            .map(HostFilesystemLocation::macos_canonical_path)
+            .collect();
+
+        // SSH-agent socket handling (commit signing) is deferred, so no unix
+        // sockets are allowed for now.
         let (program, args, config) = macos_seatbelt::wrap_invocation(
             &command.program,
             &command.args,
@@ -614,16 +963,34 @@ impl Sandbox {
             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();
+        // 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 = self
+            .fs
+            .writable_paths
+            .iter()
+            .map(|location| location.windows_path().to_path_buf())
+            .collect();
+        let (writable_git_locations, protected_git_locations) = self.git_path_split();
+        let writable_git_paths: Vec = writable_git_locations
+            .iter()
+            .map(|location| location.windows_path().to_path_buf())
+            .collect();
+        let protected_git_paths: Vec = protected_git_locations
+            .iter()
+            .map(|location| location.windows_path().to_path_buf())
+            .collect();
         let (program, args) = windows_wsl::wrap_invocation(
             command.program.clone(),
             command.args.clone(),
-            self.fs.writable_paths.clone(),
+            writable_paths,
             writable_git_paths,
             protected_git_paths,
             permissions,
             command.cwd.clone(),
             command.env.clone(),
+            self.wsl_zed_release.clone(),
         )
         .await
         .map_err(map_anyhow_error)?;
@@ -650,23 +1017,47 @@ impl Drop for Sandbox {
     }
 }
 
-/// Handle a possible re-exec of this binary as an in-sandbox helper.
+/// Argv flag that marks the WSL-side sandbox-helper re-exec. Shared so the
+/// Windows side (`windows_wsl`, which builds the `wsl.exe` invocation) and the
+/// Linux side (`linux_bubblewrap`, which parses it inside WSL) can't drift.
 ///
-/// Linux restricted-network runs launch this binary in bridge mode inside the
-/// sandbox network namespace before spawning the real command. Call this at the
-/// top of `main`, before normal argument parsing.
+/// Only referenced by those two cfg-gated modules, so it's gated to match;
+/// 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";
+
+/// Handle a possible re-exec of this binary as a sandbox helper.
+///
+/// Two Linux re-exec modes funnel through here, neither of which returns if it
+/// matches:
+/// - the in-sandbox launcher (bind validator + restricted-network bridge), run
+///   by bwrap before the real command; and
+/// - the WSL-side helper, run inside WSL to capture fds + drive bwrap (the moral
+///   equivalent of `Sandbox::wrap` on native Linux).
+///
+/// Call this at the top of `main`, before normal argument parsing.
 #[doc(hidden)]
 pub fn run_sandbox_launcher_if_invoked() {
     #[cfg(target_os = "linux")]
-    linux_bubblewrap::run_launcher_if_invoked();
+    {
+        linux_bubblewrap::run_launcher_if_invoked();
+        linux_bubblewrap::run_wsl_helper_if_invoked();
+    }
 }
 
+// The createability probe only needs to know whether a sandbox *can* be built
+// (namespaces, `bwrap` availability); it does not enforce any grant, so it runs
+// 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 policy_writable_paths(policy: &SandboxPolicy) -> Vec {
-    match &policy.fs {
-        SandboxFsPolicy::Unrestricted => Vec::new(),
-        SandboxFsPolicy::Restricted { writable_paths } => writable_paths.clone(),
-    }
+fn linux_location_path(location: &HostFilesystemLocation) -> Option {
+    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"))]
@@ -842,20 +1233,25 @@ mod tests {
 
     #[test]
     fn fs_merge_unrestricted_dominates_else_unions_paths() {
+        // Writable paths are captured as real `HostFilesystemLocation`s (keyed on
+        // the inode), so the union/dedup test needs three distinct real dirs.
+        let dir_a = tempfile::tempdir().expect("create temp dir a");
+        let dir_b = tempfile::tempdir().expect("create temp dir b");
+        let dir_c = tempfile::tempdir().expect("create temp dir c");
+        let location = |dir: &tempfile::TempDir| {
+            HostFilesystemLocation::new(dir.path()).expect("capture temp dir")
+        };
+
         let a = SandboxFsPolicy::Restricted {
-            writable_paths: vec![PathBuf::from("/a"), PathBuf::from("/b")],
+            writable_paths: vec![location(&dir_a), location(&dir_b)],
         };
         let b = SandboxFsPolicy::Restricted {
-            writable_paths: vec![PathBuf::from("/b"), PathBuf::from("/c")],
+            writable_paths: vec![location(&dir_b), location(&dir_c)],
         };
         assert_eq!(
             a.clone().merge(b),
             SandboxFsPolicy::Restricted {
-                writable_paths: vec![
-                    PathBuf::from("/a"),
-                    PathBuf::from("/b"),
-                    PathBuf::from("/c")
-                ],
+                writable_paths: vec![location(&dir_a), location(&dir_b), location(&dir_c)],
             }
         );
         assert_eq!(
@@ -864,7 +1260,7 @@ mod tests {
         );
         assert_eq!(
             SandboxFsPolicy::Unrestricted.merge(SandboxFsPolicy::Restricted {
-                writable_paths: vec![PathBuf::from("/a")],
+                writable_paths: vec![location(&dir_a)],
             }),
             SandboxFsPolicy::Unrestricted
         );
diff --git a/crates/sandbox/src/windows_wsl.rs b/crates/sandbox/src/windows_wsl.rs
index 03c1c45721..212df7d47c 100644
--- a/crates/sandbox/src/windows_wsl.rs
+++ b/crates/sandbox/src/windows_wsl.rs
@@ -58,6 +58,101 @@ const BWRAP_UNUSABLE_EXIT_CODE: i32 = 42;
 /// of any stdout noise printed by the login shell's profile scripts.
 const PROBE_RESULT_PREFIX: &str = "zed-wsl-probe:";
 
+/// Prefix of the helper-provisioning script's single result line (the absolute
+/// in-WSL path of the Linux `zed` to run as the sandbox helper), picked out of
+/// login-shell stdout noise just like [`PROBE_RESULT_PREFIX`].
+const HELPER_RESULT_PREFIX: &str = "zed-wsl-helper:";
+
+/// Ensures a Linux `zed` matching the running release is available inside WSL to
+/// act as the sandbox helper (`--wsl-sandbox-helper`), and prints its absolute
+/// in-WSL path on a [`HELPER_RESULT_PREFIX`] line. `$1` is the release channel,
+/// `$2` the version (`latest` for dev builds, which have no matching release and
+/// so track the latest nightly); both are passed as argv, never interpolated, so
+/// a version/channel string can't inject shell.
+///
+/// Unlike a normal Linux install, this deliberately does **not** consult the WSL
+/// `PATH`: inside WSL `zed` typically resolves to the *Windows* `zed.exe` via
+/// interop, which is not a Linux binary and so can't be the helper. It also does
+/// not use the public install script (`install.sh`), which puts `zed` on the
+/// user's `PATH` and writes desktop entries we don't want. Instead the Windows
+/// side resolves the exact channel+version (see `wsl_zed_release`) and this
+/// script downloads that release's Linux tarball straight from
+/// `cloud.zed.dev/releases` and unpacks it into a private, off-`PATH` location
+/// (`~/.local/libexec/zed/`, the conventional spot for executables run
+/// by other programs rather than directly by the user). One managed copy per
+/// channel is kept, tracked by a marker file so an exact channel+version match
+/// is reused rather than re-downloaded.
+///
+/// We ship no `zed` (nor `bwrap`) into WSL ourselves; this downloads `zed` on
+/// demand. A missing `curl`/`wget` (or a failed download) is a hard error the
+/// caller surfaces to the user, exactly like a missing `bwrap`.
+const HELPER_PROVISION_SCRIPT: &str = r#"
+set -eu
+channel="$1"
+version="$2"
+dest="$HOME/.local/libexec/zed/$channel"
+marker="$dest/.zed-wsl-helper-version"
+want="$channel $version"
+
+# Reuse an exact, already-installed channel+version.
+if [ "$(cat "$marker" 2>/dev/null || true)" = "$want" ]; then
+    helper=$(find "$dest" -type f -path '*/bin/zed' -print 2>/dev/null | head -n 1 || true)
+    if [ -n "$helper" ] && [ -x "$helper" ]; then
+        printf 'zed-wsl-helper: %s\n' "$helper"
+        exit 0
+    fi
+fi
+
+arch=$(uname -m)
+case "$arch" in
+    x86_64 | amd64) arch="x86_64" ;;
+    aarch64 | arm64) arch="aarch64" ;;
+    *) echo "unsupported WSL architecture for the zed sandbox helper: $arch" >&2; exit 1 ;;
+esac
+url="https://cloud.zed.dev/releases/$channel/$version/download?asset=zed&arch=$arch&os=linux&source=zed-wsl-sandbox"
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/zed-wsl-helper-XXXXXX")
+trap 'rm -rf "$tmp"' EXIT
+tarball="$tmp/zed.tar.gz"
+if command -v curl >/dev/null 2>&1; then
+    curl -fL "$url" -o "$tarball"
+elif command -v wget >/dev/null 2>&1; then
+    wget -O "$tarball" "$url"
+else
+    echo 'neither curl nor wget is available in WSL to download zed' >&2
+    exit 1
+fi
+
+mkdir -p "$tmp/unpacked"
+tar -xzf "$tarball" -C "$tmp/unpacked"
+helper_src=$(find "$tmp/unpacked" -type f -path '*/bin/zed' -print 2>/dev/null | head -n 1 || true)
+if [ -z "$helper_src" ]; then
+    echo 'the downloaded zed tarball did not contain a bin/zed binary' >&2
+    exit 1
+fi
+app=$(dirname "$(dirname "$helper_src")")
+
+# Install atomically: stage the unpacked app next to the destination on the same
+# filesystem, then swap it into place so a concurrent run never sees (or execs) a
+# partially-written install. The whole app dir is kept so the binary's bundled
+# libraries ($ORIGIN/../lib) remain alongside it.
+mkdir -p "$(dirname "$dest")"
+rm -rf "$dest.new" "$dest.old"
+cp -a "$app" "$dest.new"
+if [ -e "$dest" ]; then mv "$dest" "$dest.old"; fi
+mv "$dest.new" "$dest"
+rm -rf "$dest.old"
+printf '%s' "$want" > "$marker"
+
+helper=$(find "$dest" -type f -path '*/bin/zed' -print 2>/dev/null | head -n 1 || true)
+if [ -z "$helper" ] || [ ! -x "$helper" ]; then
+    echo "the installed zed sandbox helper is missing or not executable under $dest" >&2
+    exit 1
+fi
+printf 'zed-wsl-helper: %s\n' "$helper"
+exit 0
+"#;
+
 /// Marks a failure of the Windows WSL sandboxing *environment*: WSL is missing
 /// or won't start, there's no usable `bwrap`, or the probe / path-resolution
 /// stdout protocol broke down. Returned as the root of the `anyhow::Error` so
@@ -177,6 +272,13 @@ pub async fn wrap_invocation(
     permissions: SandboxPermissions,
     cwd: Option,
     env: HashMap,
+    // `(release channel, version)` of the Linux `zed` to provision inside WSL as
+    // the `--wsl-sandbox-helper` (the version is `latest` for dev builds). When
+    // `None`, no helper is used and bwrap is exec'd directly — the legacy path,
+    // which binds writable paths by string and so carries the bind-source TOCTOU
+    // the helper closes. Callers that can determine the running release should
+    // always pass `Some`.
+    wsl_zed_release: Option<(String, String)>,
 ) -> Result<(String, Vec)> {
     // Mapping failures are bad requests (a path that doesn't exist or has a
     // shape WSL can't address), not environment problems, so no
@@ -279,21 +381,50 @@ pub async fn wrap_invocation(
     if let Some(cwd) = &cwd {
         wsl_args.extend(["--cd".to_string(), cwd.clone()]);
     }
-    // Use the absolute path the probe validated: `wsl --exec` searches only
-    // the default WSL PATH, which may not include a profile-managed location
-    // where the probe's login shell found `bwrap`.
-    wsl_args.extend(["--exec".to_string(), environment.bwrap_path.clone()]);
-    wsl_args.extend(build_bwrap_args(
+
+    // The bwrap *options* (everything before the trailing `-- cmd`): root bind,
+    // `/tmp` tmpfs, writable binds, interop blocking, `--setenv`s, `--chdir`,
+    // namespace flags. Identical whether or not we route through the helper.
+    let bwrap_options = build_bwrap_args(
         &writable_paths,
         &protected_git_paths,
         permissions,
         cwd.as_deref(),
         environment.mask_interop_dir,
         &env,
-    ));
-    wsl_args.push("--".to_string());
-    wsl_args.push(program);
-    wsl_args.extend(args);
+    );
+
+    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).
+        Some((channel, version)) => {
+            let helper =
+                ensure_wsl_zed_helper(&wsl_exe, distro.as_deref(), &channel, &version).await?;
+            wsl_args.extend(["--exec".to_string(), helper]);
+            // Protocol (decoded by `linux_bubblewrap::decode_wsl_helper_args`):
+            //         --  
+            wsl_args.push(crate::WSL_SANDBOX_HELPER_FLAG.to_string());
+            wsl_args.push(environment.bwrap_path.clone());
+            wsl_args.push(bwrap_options.len().to_string());
+            wsl_args.extend(bwrap_options);
+            wsl_args.push(writable_paths.len().to_string());
+            wsl_args.extend(writable_paths.iter().cloned());
+            wsl_args.push("--".to_string());
+            wsl_args.push(program);
+            wsl_args.extend(args);
+        }
+        // Legacy path: exec bwrap directly (no in-sandbox bind validation). Use
+        // the absolute path the probe validated, since `wsl --exec` searches
+        // only the default WSL PATH.
+        None => {
+            wsl_args.extend(["--exec".to_string(), environment.bwrap_path.clone()]);
+            wsl_args.extend(bwrap_options);
+            wsl_args.push("--".to_string());
+            wsl_args.push(program);
+            wsl_args.extend(args);
+        }
+    }
 
     Ok((wsl_exe.to_string_lossy().into_owned(), wsl_args))
 }
@@ -509,6 +640,94 @@ fn parse_probe_output(stdout: &str) -> Result {
     })
 }
 
+/// Ensure a Linux `zed` of the given release `channel`/`version` is available
+/// inside WSL and return its absolute in-WSL path, to be `--exec`'d as the
+/// `--wsl-sandbox-helper`. Runs [`HELPER_PROVISION_SCRIPT`] (which downloads the
+/// matching release tarball into an off-`PATH` location on first use).
+///
+/// Successful resolutions are cached per `(distro, channel, version)` for the
+/// life of the process — once provisioned, the path won't change. Failures are
+/// not cached, so a user who installs `curl` (or fixes networking) after an
+/// error can retry without restarting Zed.
+async fn ensure_wsl_zed_helper(
+    wsl_exe: &Path,
+    distro: Option<&str>,
+    channel: &str,
+    version: &str,
+) -> Result {
+    type HelperCache = HashMap<(Option, String, String), String>;
+    static CACHE: OnceLock> = OnceLock::new();
+    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
+
+    let key = (
+        distro.map(str::to_string),
+        channel.to_string(),
+        version.to_string(),
+    );
+    if let Some(path) = cache
+        .lock()
+        .unwrap_or_else(|poisoned| poisoned.into_inner())
+        .get(&key)
+    {
+        return Ok(path.clone());
+    }
+
+    // A login shell (`-lc`) is used so a profile-managed PATH (where `zed` or
+    // `curl` may live) is honored. `channel`/`version` are passed as positional
+    // args (`$1`/`$2`), never interpolated into the script body.
+    let output = run_wsl_command(
+        wsl_exe,
+        distro,
+        [
+            "--exec",
+            "sh",
+            "-lc",
+            HELPER_PROVISION_SCRIPT,
+            "zed-wsl-sandbox-helper",
+            channel,
+            version,
+        ],
+        "provision the Linux `zed` sandbox helper",
+    )
+    .await?;
+    if !output.status.success() {
+        let stderr = String::from_utf8_lossy(&output.stderr);
+        let stderr = stderr.trim();
+        return Err(unavailable(format!(
+            "failed to provision a Linux `zed` sandbox helper in {}{}",
+            wsl_distro_label(distro),
+            if stderr.is_empty() {
+                String::new()
+            } else {
+                format!(": {stderr}")
+            }
+        )));
+    }
+
+    let stdout = String::from_utf8_lossy(&output.stdout);
+    let path = stdout
+        .lines()
+        .rev()
+        .find_map(|line| line.strip_prefix(HELPER_RESULT_PREFIX))
+        .map(|path| path.trim().to_string())
+        .with_context(|| {
+            unavailable(format!(
+                "no helper result line in sandbox-helper provisioning output from {}: {stdout:?}",
+                wsl_distro_label(distro)
+            ))
+        })?;
+    ensure!(
+        path.starts_with('/'),
+        "the WSL `zed` sandbox helper resolved to {path:?} rather than an absolute path"
+    );
+
+    cache
+        .lock()
+        .unwrap_or_else(|poisoned| poisoned.into_inner())
+        .insert(key, path.clone());
+    Ok(path)
+}
+
 /// Shell script that resolves and existence-checks paths in a single WSL
 /// round-trip. Arguments come in triples `(kind, path, fallback)`: kind `W`
 /// is a native Windows path to translate with `wslpath -u` (falling back to
@@ -1105,6 +1324,7 @@ mod tests {
             SandboxPermissions::default(),
             None,
             HashMap::::new(),
+            None,
         ));
     }
 
diff --git a/crates/sandbox/src/wsl_sandbox_test_helper.rs b/crates/sandbox/src/wsl_sandbox_test_helper.rs
index b4fab4cbcd..d184d09ef4 100644
--- a/crates/sandbox/src/wsl_sandbox_test_helper.rs
+++ b/crates/sandbox/src/wsl_sandbox_test_helper.rs
@@ -48,8 +48,8 @@ mod imp {
 
     use anyhow::{Context as _, Result, bail, ensure};
     use sandbox::{
-        CommandAndArgs, GitSandboxPolicy, Sandbox, SandboxError, SandboxFsPolicy, SandboxNetPolicy,
-        SandboxPolicy,
+        CommandAndArgs, GitSandboxPolicy, HostFilesystemLocation, Sandbox, SandboxError,
+        SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
     };
 
     /// Network access for a helper run, translated into a `SandboxNetPolicy` in
@@ -513,7 +513,12 @@ mod imp {
                 SandboxFsPolicy::Unrestricted
             } else {
                 SandboxFsPolicy::Restricted {
-                    writable_paths: writable_paths.to_vec(),
+                    // On Windows the location only records the requested path;
+                    // the real capture happens WSL-side in the helper.
+                    writable_paths: writable_paths
+                        .iter()
+                        .filter_map(|path| HostFilesystemLocation::new(path).ok())
+                        .collect(),
                 }
             },
             network: match permissions.network {
diff --git a/docs/README.md b/docs/README.md
index 65c4699cb6..faf443bef5 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -13,6 +13,17 @@ mdbook serve docs
 
 The first command dumps an action manifest to `crates/docs_preprocessor/actions.json`. Without it, the preprocessor cannot validate keybinding and action references in the docs and will report errors. You only need to re-run it when actions change.
 
+If you use Nix, the development shell provides a pinned `mdbook` (0.4.40) and a
+prebuilt docs preprocessor, so you can build the docs without installing anything
+or compiling the preprocessor on every run:
+
+```sh
+nix develop -c mdbook build docs
+```
+
+(When `actions.json` has not been generated, action/keybinding validation is
+skipped with a warning rather than failing the build.)
+
 It's important to note the version number above. For an unknown reason, as of 2025-04-23, running 0.4.48 will cause odd URL behavior that breaks things.
 
 Before committing, verify that the docs are formatted in the way Prettier expects with:
@@ -25,7 +36,7 @@ cd docs && pnpm dlx prettier@3.5.0 . --write && cd ..
 
 We have a custom mdBook preprocessor for interfacing with our crates (`crates/docs_preprocessor`).
 
-If for some reason you need to bypass the docs preprocessor, you can comment out `[preprocessor.zed_docs_preprocessor]` from the `book.toml`.
+If for some reason you need to bypass the docs preprocessor, you can comment out `[preprocessor.zed-docs-preprocessor]` from the `book.toml`.
 
 ## Images and videos
 
diff --git a/docs/book.toml b/docs/book.toml
index f3ebf4187e..2eed5f7907 100644
--- a/docs/book.toml
+++ b/docs/book.toml
@@ -106,6 +106,6 @@ enable = false
 # and other docs-related functions.
 #
 # Comment the below section out if you need to bypass the preprocessor for some reason.
-[preprocessor.zed_docs_preprocessor]
+[preprocessor.zed-docs-preprocessor]
 command = "cargo run -p docs_preprocessor --"
 renderer = ["html", "zed-html"]
diff --git a/nix/build.nix b/nix/build.nix
index 0375b0de5f..c01d869fdd 100644
--- a/nix/build.nix
+++ b/nix/build.nix
@@ -311,6 +311,13 @@ craneLib.buildPackage (
   lib.recursiveUpdate commonArgs {
     inherit cargoArtifacts;
 
+    # Expose the crane builder and shared arguments so other derivations (e.g.
+    # the docs preprocessor in the devshell) can build sibling workspace crates
+    # without duplicating all of the build inputs and environment setup.
+    passthru = {
+      inherit craneLib commonArgs cargoArtifacts;
+    };
+
     dontUseCmakeConfigure = true;
 
     # without the env var generate-licenses fails due to crane's fetchCargoVendor, see:
diff --git a/nix/dev/flake.lock b/nix/dev/flake.lock
index bdb585dde4..775903fce8 100644
--- a/nix/dev/flake.lock
+++ b/nix/dev/flake.lock
@@ -16,8 +16,25 @@
         "type": "github"
       }
     },
+    "nixpkgs-mdbook": {
+      "locked": {
+        "lastModified": 1721153957,
+        "narHash": "sha256-3xc3Tvk+AivI7TKctzgg3Q/4cLeqtarUuWLgTv2rPPM=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "6ecabf9e3f617aeec1a23a27d0080cab066a9d5b",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "6ecabf9e3f617aeec1a23a27d0080cab066a9d5b",
+        "type": "github"
+      }
+    },
     "root": {
       "inputs": {
+        "nixpkgs-mdbook": "nixpkgs-mdbook",
         "treefmt-nix": "treefmt-nix"
       }
     },
diff --git a/nix/dev/flake.nix b/nix/dev/flake.nix
index b508417b62..d430020226 100644
--- a/nix/dev/flake.nix
+++ b/nix/dev/flake.nix
@@ -3,6 +3,10 @@
 
   inputs = {
     treefmt-nix.url = "github:numtide/treefmt-nix";
+    # Pinned to a nixpkgs revision that packages mdBook 0.4.40, the version the
+    # docs require (see `crates/docs_preprocessor/Cargo.toml`). Newer mdBook
+    # releases break the docs' double-nested subdirectories.
+    nixpkgs-mdbook.url = "github:NixOS/nixpkgs/6ecabf9e3f617aeec1a23a27d0080cab066a9d5b";
   };
 
   # This flake is only used for its inputs.
diff --git a/nix/modules/devshells.nix b/nix/modules/devshells.nix
index da6ba6fd11..62b80a8d9e 100644
--- a/nix/modules/devshells.nix
+++ b/nix/modules/devshells.nix
@@ -1,13 +1,41 @@
 { inputs, ... }:
 {
   perSystem =
-    { pkgs, ... }:
+    { pkgs, system, ... }:
     let
       # NOTE: Duplicated because this is in a separate flake-parts partition
       # than ./packages.nix
       mkZed = import ../toolchain.nix { inherit inputs; };
       zed-editor = mkZed pkgs;
 
+      # mdBook pinned to 0.4.40 via a dedicated nixpkgs input, because the docs
+      # rely on behavior that newer mdBook releases break (see
+      # `crates/docs_preprocessor/Cargo.toml`).
+      mdbook = (import inputs.nixpkgs-mdbook { inherit system; }).mdbook;
+
+      # Prebuilt docs preprocessor/postprocessor binary. `docs/book.toml`
+      # defaults to `cargo run -p docs_preprocessor` so non-Nix contributors are
+      # unaffected; in the devshell we point mdBook at this prebuilt binary via
+      # the `MDBOOK_*` env vars below so `mdbook build docs` doesn't have to
+      # compile the preprocessor on every run.
+      #
+      # We reuse `zed-editor`'s crane builder and shared arguments (exposed via
+      # `passthru`) rather than `overrideAttrs`, because crane bakes
+      # `cargoExtraArgs` into the build command at evaluation time.
+      docs-preprocessor = zed-editor.passthru.craneLib.buildPackage (
+        zed-editor.passthru.commonArgs
+        // {
+          inherit (zed-editor.passthru) cargoArtifacts;
+          pname = "zed-docs-preprocessor";
+          cargoExtraArgs = "-p docs_preprocessor --locked";
+          dontUseCmakeConfigure = true;
+          meta = {
+            description = "mdBook preprocessor and postprocessor for the Zed docs";
+            mainProgram = "docs_preprocessor";
+          };
+        }
+      );
+
       rustBin = inputs.rust-overlay.lib.mkRustBin { } pkgs;
       rustToolchain = rustBin.fromRustupToolchainFile ../../rust-toolchain.toml;
 
@@ -54,6 +82,10 @@
             nodejs_22
             zig
 
+            # Documentation tooling: `nix develop -c mdbook build docs`
+            mdbook
+            docs-preprocessor
+
             # A11y testing infra
             gobject-introspection
             at-spi2-core
@@ -81,6 +113,14 @@
               ];
             };
             PROTOC = "${pkgs.protobuf}/bin/protoc";
+
+            # Point mdBook at the prebuilt preprocessor/postprocessor binary
+            # instead of `cargo run`. mdBook lowercases these keys and turns `_`
+            # into `-`, so they map to `preprocessor.zed-docs-preprocessor.command`
+            # and `output.zed-html.command` in `docs/book.toml`.
+            MDBOOK_PREPROCESSOR__ZED_DOCS_PREPROCESSOR__COMMAND = "${docs-preprocessor}/bin/docs_preprocessor";
+            MDBOOK_OUTPUT__ZED_HTML__COMMAND = "${docs-preprocessor}/bin/docs_preprocessor postprocess";
+
             ZED_ZSTD_MUSL_LIB = "${pkgs.pkgsCross.musl64.pkgsStatic.zstd.out}/lib";
             # For aws-lc-sys musl cross-compilation
             CC_x86_64_unknown_linux_musl = "${muslCross.stdenv.cc}/bin/x86_64-unknown-linux-musl-gcc";
diff --git a/nix/tests/sandboxing/default.nix b/nix/tests/sandboxing/default.nix
index 22eaf10357..eb4fbad82b 100644
--- a/nix/tests/sandboxing/default.nix
+++ b/nix/tests/sandboxing/default.nix
@@ -31,6 +31,11 @@
 #   writablePaths = [ ];        # writable subtrees when fs = "restricted"
 #   networkAccess = "blocked";  # or "unrestricted" / "restricted"
 #   allowedDomains = [ ];       # allowed hosts when networkAccess = "restricted"
+#   gitDisabled = [ ];          # `.git` dirs whose contents are protected
+#                               # (read-only on Linux). Mutually exclusive with
+#                               # gitAllowed.
+#   gitAllowed = [ ];           # `.git` dirs whose contents are made writable.
+#                               # Mutually exclusive with gitDisabled.
 #
 # Two echo servers (`echo1`, `echo2`) on separate nodes give the network checks
 # real peers, so a restricted-network policy that allowlists `echo1` can be
@@ -232,6 +237,138 @@ in
         succeeds = true;
       }
 
+      # ---- Git protection ----------------------------------------------------
+      # The worktree is writable, but the `.git` directory inside it can be
+      # protected (Git disabled) or opened up (Git allowed) independently. On
+      # Linux a protected `.git` is re-bound read-only *over* the writable
+      # worktree, so its contents stay readable but writes are denied.
+
+      # Git disabled: the worktree is writable but `.git` is protected, so a
+      # write into `.git` is denied. This is the core case the feature exists
+      # for — an agent editing the project must not be able to corrupt Git
+      # metadata.
+      {
+        fs = "restricted";
+        writablePaths = [ "/sandbox-test/repo" ];
+        gitDisabled = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo/.git/test";
+        succeeds = false;
+      }
+
+      # Git allowed: the same write into `.git` now succeeds because the policy
+      # makes `.git` writable.
+      {
+        fs = "restricted";
+        writablePaths = [ "/sandbox-test/repo" ];
+        gitAllowed = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo/.git/test";
+        succeeds = true;
+      }
+
+      # Git disabled protects only `.git`: ordinary writes elsewhere in the
+      # writable worktree are unaffected.
+      {
+        fs = "restricted";
+        writablePaths = [ "/sandbox-test/repo" ];
+        gitDisabled = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo/src/main.rs";
+        succeeds = true;
+      }
+
+      # Git disabled is a subtree protection: a write to something nested deep
+      # inside `.git` is denied too, not just a top-level file.
+      {
+        fs = "restricted";
+        writablePaths = [ "/sandbox-test/repo" ];
+        gitDisabled = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo/.git/hooks/pre-commit";
+        succeeds = false;
+      }
+
+      # Git disabled blocks writes but NOT reads: on Linux a protected `.git` is
+      # read-only, so its contents remain readable from inside the sandbox
+      # (Git status, log, diff, … must keep working).
+      {
+        fs = "restricted";
+        writablePaths = [ "/sandbox-test/repo" ];
+        gitDisabled = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        read = "/sandbox-test/repo/.git/HEAD";
+        succeeds = true;
+      }
+
+      # Git allowed grants writes to `.git` on its own, even when the worktree
+      # itself is not in `writablePaths`. The policy's Git dirs are made
+      # writable directly.
+      {
+        fs = "restricted";
+        writablePaths = [ ];
+        gitAllowed = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo/.git/test";
+        succeeds = true;
+      }
+
+      # ...and that grant is scoped to `.git`: it does not make the surrounding
+      # worktree writable. A write next to `.git` (not in `writablePaths`) is
+      # still denied.
+      {
+        fs = "restricted";
+        writablePaths = [ ];
+        gitAllowed = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo/README.md";
+        succeeds = false;
+      }
+
+      # Multiple repositories: each protected `.git` is independently denied.
+      # This exercises the list handling — a write into the *second* repo's
+      # `.git` must still be blocked.
+      {
+        fs = "restricted";
+        writablePaths = [
+          "/sandbox-test/repo-a"
+          "/sandbox-test/repo-b"
+        ];
+        gitDisabled = [
+          "/sandbox-test/repo-a/.git"
+          "/sandbox-test/repo-b/.git"
+        ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo-b/.git/test";
+        succeeds = false;
+      }
+
+      # The fs escape hatch supersedes Git protection: when filesystem writes
+      # are unrestricted there is no read-only bind to protect `.git`, so the
+      # write succeeds. This documents that `gitDisabled` only has teeth under a
+      # restricted filesystem policy.
+      {
+        fs = "unrestricted";
+        gitDisabled = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo/.git/test";
+        succeeds = true;
+      }
+
+      # Documented Linux gap: a `.git` that does not yet exist when the sandbox
+      # is built cannot be re-bound read-only, so it is skipped. Writing the
+      # `.git` entry itself (its parent worktree is writable, and `.git` does
+      # not exist beforehand) therefore succeeds. macOS denies this even before
+      # `.git` exists; bwrap cannot, and this test pins that difference.
+      {
+        fs = "restricted";
+        writablePaths = [ "/sandbox-test/fresh-repo" ];
+        gitDisabled = [ "/sandbox-test/fresh-repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/fresh-repo/.git";
+        succeeds = true;
+      }
+
       # Blocked network: the echo server is unreachable.
       {
         fs = "restricted";