agent: More sandboxing changes (#60111)

Large change to sandboxing:
- fixes a nasty TOCTOU relating to a symlink swap attack, documented in
the `sandboxing/README.md`
- Adds UI and restrictions when in an untrusted workspace
- Adds tests for (soon to be removed) git support


---

Release Notes:

- N/A or Added/Fixed/Improved ...
This commit is contained in:
Cameron Mcloughlin 2026-06-30 12:10:04 +01:00 committed by GitHub
parent 3856272188
commit 4a99aa870e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2970 additions and 374 deletions

3
Cargo.lock generated
View file

@ -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",

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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<HostPattern>, 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!(

View file

@ -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<Task<Option<Arc<ProjectSnapshot>>>>,
pub(crate) context_server_registry: Entity<ContextServerRegistry>,
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<ProjectContext>,
pub(crate) templates: Arc<Templates>,
model: ThreadModel,
@ -1320,7 +1323,8 @@ impl Thread {
cx: &mut Context<Self>,
) -> 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<Project>,
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<Self>) {
// 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<bool>,
cx: &mut Context<Self>,
) -> Task<LanguageModelToolResult> {
// 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<Self::Output, Self::Output>` rather than `Result<Self::Output, anyhow::Error>`
@ -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<AgentToolOutput, AgentToolOutput>`.
fn run(
self: Arc<Self>,
@ -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<Self>,
input: ToolInput<serde_json::Value>,

View file

@ -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<Item = LanguageModelRequestTool> {
fn language_model_tool<T: AgentTool>() -> 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"));
}
}

View file

@ -124,6 +124,10 @@ impl AgentTool for FetchTool {
acp::ToolKind::Fetch
}
fn allow_in_restricted_mode() -> bool {
false
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,

View file

@ -298,6 +298,10 @@ impl AgentTool for TerminalTool {
acp::ToolKind::Execute
}
fn allow_in_restricted_mode() -> bool {
false
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,
@ -336,6 +340,10 @@ impl AgentTool for SandboxedTerminalTool {
acp::ToolKind::Execute
}
fn allow_in_restricted_mode() -> bool {
false
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,
@ -372,6 +380,32 @@ fn terminal_initial_title(input: Result<String, serde_json::Value>) -> SharedStr
}
}
/// Windows only: resolve the `(release channel, version)` of the Linux `zed` to
/// provision inside WSL as the sandbox helper. Dev (source) builds have no
/// matching release, so they pull the latest nightly; every other channel pins
/// its exact running version (stripped of pre-release/build metadata, which the
/// release API doesn't key on).
#[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<Project>,
environment: Rc<dyn ThreadEnvironment>,
@ -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),

View file

@ -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::<SettingsStore>();
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));
}
}

View file

@ -244,6 +244,17 @@ impl ProfileProvider for Entity<agent::Thread> {
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)]

View file

@ -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<WritableEntryDisplay>),
}
/// 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<String>,
}
impl SandboxPolicyDisplay {
/// Display a policy verbatim (used for the per-thread overrides, which carry
/// no implicit baseline grants). Takes the policy by reference and stringifies
/// its locations immediately, so no fd is retained past this call.
fn from_policy(policy: &SandboxPolicy) -> Self {
let fs = match &policy.fs {
SandboxFsPolicy::Unrestricted => SandboxFsDisplay::Unrestricted,
SandboxFsPolicy::Restricted { writable_paths } => SandboxFsDisplay::Restricted(
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<PathBuf>,
) -> 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<String> = 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<WritableEntryDisplay> =
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<SandboxRow> {
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<SandboxRow> {
if !git.allowed {
return Vec::new();
}
git.git_dirs
.iter()
// The display string was captured up front; reconstruct a throwaway path
// here purely to render a label + icon (never used as an identity).
.map(|dir| SandboxRow::git(PathBuf::from(dir)))
.collect()
}
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<SandboxRow> {
fn sandbox_fs_rows(fs: &SandboxFsDisplay) -> Vec<SandboxRow> {
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()

View file

@ -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<dyn Fn(&mut Window, &mut App) -> 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<SharedString> {
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<Picker<Self>>,
) -> Option<gpui::AnyElement> {
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(),
)
}

View file

@ -395,10 +395,21 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
let settings_validator = jsonschema::validator_for(&settings_schema)
.expect("failed to compile settings JSON schema");
let keymap_schema =
keymap_schema_for_actions(&ALL_ACTIONS.actions, &ALL_ACTIONS.schema_definitions);
let keymap_validator =
jsonschema::validator_for(&keymap_schema).expect("failed to compile keymap JSON schema");
// The keymap schema is built from the action manifest. When `actions.json`
// is unavailable (e.g. when running outside of CI without first generating
// it) there are no actions, which produces an invalid schema (an empty
// `anyOf`). In that case we skip keymap snippet validation rather than
// panicking, consistent with the action validation skipped above.
let keymap_validator = if actions_available() {
let keymap_schema =
keymap_schema_for_actions(&ALL_ACTIONS.actions, &ALL_ACTIONS.schema_definitions);
Some(
jsonschema::validator_for(&keymap_schema)
.expect("failed to compile keymap JSON schema"),
)
} else {
None
};
fn for_each_labeled_code_block_mut(
book: &mut Book,
@ -499,19 +510,22 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
}
}
"keymap" => {
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::<serde_json::Value>(&snippet_json_fixed)?;
let validation_errors: Vec<String> = 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::<serde_json::Value>(
&snippet_json_fixed,
)?;
let validation_errors: Vec<String> = keymap_validator
.iter_errors(&value)
.map(|err| err.to_string())
.collect();
if !validation_errors.is_empty() {
anyhow::bail!("{}", validation_errors.join("\n"));
}
}
}
"debug" => {

View file

@ -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

261
crates/sandbox/README.md Normal file
View file

@ -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 <untrusted program args>`
- 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<OwnedFd>` 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

View file

@ -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"

View file

@ -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<String>,
/// `.git` directories to protect (contents read-only on Linux). Selects a
/// `Denied` Git policy. Mutually exclusive with `gitAllowed`.
#[serde(default)]
git_disabled: Vec<String>,
/// `.git` directories to make writable. Selects an `Allowed` Git policy.
/// Mutually exclusive with `gitDisabled`.
#[serde(default)]
git_allowed: Vec<String>,
// ---- 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<SandboxPolicy> {
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<HostFilesystemLocation> {
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<bool> {
fn run_read(check: &Check, path: &str) -> Result<bool> {
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<bool> {
fn run_write(check: &Check, path: &str) -> Result<bool> {
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<bool> {
fn run_network(check: &Check, host: &str, echo_port: &str) -> Result<bool> {
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])
}

File diff suppressed because it is too large Load diff

View file

@ -209,21 +209,19 @@ fn generate_seatbelt_config(
allowed_unix_socket_paths: &[&Path],
permissions: SandboxPermissions,
) -> Result<String> {
// 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<PathBuf> = 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<PathBuf> = 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(),

View file

@ -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 <path>` and an
/// in-sandbox validator compares `fstat` of this descriptor against `lstat` of
/// the mounted path after the mounts, failing closed on a post-capture swap
/// (see `linux_bubblewrap::validate_binds` and `README.md`).
/// - **Windows**: nothing — a Windows process holds no Linux fds, so the real
/// capture-at-validation happens inside WSL (in the `--wsl-sandbox-helper`),
/// and the value here carries only the requested path as untrusted intent.
///
/// The type is deliberately **opaque**: it does not `Deref`, and it never hands
/// back its trusted value. The only thing readable is a *display-only* path via
/// [`HostFilesystemLocation::untrusted_path_display`], suitable for showing a
/// human but which must never be passed back into a sandbox API as the
/// location's identity. Equality reflects the actual filesystem object (same
/// inode), not the textual path.
#[derive(Clone)]
pub struct HostFilesystemLocation {
/// macOS: the canonicalized path, resolved exactly once at capture time and
/// used directly as the Seatbelt rule literal.
#[cfg(target_os = "macos")]
canonical_path: PathBuf,
/// Linux: an `O_PATH` descriptor pinned to the captured inode. Wrapped in an
/// `Arc` only so the surrounding policy types can stay `Clone`; cloning
/// shares the same underlying descriptor.
#[cfg(target_os = "linux")]
fd: std::sync::Arc<std::os::fd::OwnedFd>,
/// The path exactly as the caller requested it. Kept **only** so the UI can
/// show the user which location is being granted. This is never consulted by
/// any enforcement path — treat it as untrusted, attacker-influenced text.
untrusted_path_for_display: PathBuf,
}
impl HostFilesystemLocation {
/// Capture `path` as a host sandbox location, resolving its identity up front.
///
/// On macOS this canonicalizes the path; on Linux it opens an `O_PATH`
/// descriptor to it; on Windows it records nothing. The caller is
/// responsible for having already *validated* `path` (e.g. confirmed it is
/// inside the project, or 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<Path>) -> std::io::Result<Self> {
let path = path.as_ref();
let untrusted_path_for_display = path.to_path_buf();
#[cfg(target_os = "macos")]
{
// `canonicalize_allowing_missing_leaf` resolves through the existing
// parent so a not-yet-created leaf (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<std::os::fd::OwnedFd> {
use std::os::fd::AsFd as _;
self.fd.as_fd().try_clone_to_owned()
}
/// Windows: the requested path, to be mapped into WSL and handed to the
/// in-WSL helper. Windows captures no identity itself (it holds no Linux
/// fds); the real capture-at-validation happens WSL-side in the helper, so
/// here the requested path *is* the location.
#[cfg(target_os = "windows")]
pub(crate) fn windows_path(&self) -> &Path {
&self.untrusted_path_for_display
}
}
impl fmt::Debug for HostFilesystemLocation {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
// Only the display path is shown; the trusted identity stays opaque.
formatter
.debug_struct("HostFilesystemLocation")
.field(
"untrusted_path_for_display",
&self.untrusted_path_for_display,
)
.finish_non_exhaustive()
}
}
impl PartialEq for HostFilesystemLocation {
/// Two locations are equal when they refer to the **same filesystem object**,
/// determined from the captured identity (the inode behind the `O_PATH` fd on
/// Linux, the canonical path on macOS) — never from the textual
/// display path. This is what lets policy bookkeeping dedupe "the same
/// location named two different ways," and refuse to treat "two different
/// objects that happen to share a path string" as one.
fn eq(&self, other: &Self) -> bool {
#[cfg(target_os = "linux")]
{
match (linux_fd_identity(&self.fd), 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<PathBuf>) -> 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<PathBuf> 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<PathBuf> },
/// Reads are allowed everywhere; writes are confined to these locations
/// (and the standard ephemeral locations the platform provides). Each is a
/// [`HostFilesystemLocation`] captured at validation time, never a bare path
/// the enforcement layer would re-resolve.
Restricted {
writable_paths: Vec<HostFilesystemLocation>,
},
}
/// 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<PathBuf> },
Denied {
git_dirs: Vec<HostFilesystemLocation>,
},
/// `.git` contents are writable (these dirs are made writable).
Allowed { git_dirs: Vec<PathBuf> },
Allowed {
git_dirs: Vec<HostFilesystemLocation>,
},
}
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<PathBuf>,
writable_paths: Vec<HostFilesystemLocation>,
}
/// 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<ProxyHandle>,
/// 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<linux_bubblewrap::ValidationFdSender>,
/// 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<macos_seatbelt::SeatbeltConfigFile>,
}
@ -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<PathBuf>, Vec<PathBuf>) {
fn git_path_split(&self) -> (Vec<HostFilesystemLocation>, Vec<HostFilesystemLocation>) {
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<PathBuf> = Vec::new();
let mut writable_fds: Vec<std::os::fd::OwnedFd> = Vec::new();
for location in self.fs.writable_paths.iter().chain(git_writable.iter()) {
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<PathBuf> = 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<PathBuf> = 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<PathBuf> = writable_git_locations
.iter()
.map(|location| location.windows_path().to_path_buf())
.collect();
let protected_git_paths: Vec<PathBuf> = 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<PathBuf> {
match &policy.fs {
SandboxFsPolicy::Unrestricted => Vec::new(),
SandboxFsPolicy::Restricted { writable_paths } => writable_paths.clone(),
}
fn linux_location_path(location: &HostFilesystemLocation) -> Option<PathBuf> {
use std::os::fd::AsRawFd as _;
std::fs::read_link(format!("/proc/self/fd/{}", location.linux_fd().as_raw_fd())).ok()
}
#[cfg(not(target_os = "windows"))]
@ -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
);

View file

@ -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/<channel>`, 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<S: std::hash::BuildHasher>(
permissions: SandboxPermissions,
cwd: Option<PathBuf>,
env: HashMap<String, String, S>,
// `(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<String>)> {
// 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<S: std::hash::BuildHasher>(
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`):
// <flag> <bwrap_path> <n_base> <base...> <n_writable> <writable...> -- <prog> <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<EnvironmentProbe> {
})
}
/// 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<String> {
type HelperCache = HashMap<(Option<String>, String, String), String>;
static CACHE: OnceLock<Mutex<HelperCache>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
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::<String, String>::new(),
None,
));
}

View file

@ -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 {

View file

@ -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

View file

@ -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"]

View file

@ -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:

17
nix/dev/flake.lock generated
View file

@ -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"
}
},

View file

@ -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.

View file

@ -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";

View file

@ -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";