mirror of
https://github.com/zed-industries/zed.git
synced 2026-07-09 16:00:35 +00:00
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:
parent
3856272188
commit
4a99aa870e
30 changed files with 2970 additions and 374 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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
261
crates/sandbox/README.md
Normal 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
|
||||
29
crates/sandbox/bind_source_toctou_test.sh
Executable file
29
crates/sandbox/bind_source_toctou_test.sh
Executable 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"
|
||||
|
|
@ -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
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
17
nix/dev/flake.lock
generated
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue