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:
1 parent
36a291b0e9
commit
647b78d5ef
30 files changed
+2970
-374
No files matched your search
Generated
+3
@@ -288,6 +288,7 @@ dependencies = [
|
|||||||
"quick-xml 0.38.3",
|
"quick-xml 0.38.3",
|
||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
"regex",
|
"regex",
|
||||||
|
"release_channel",
|
||||||
"reqwest_client",
|
"reqwest_client",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
"sandbox",
|
"sandbox",
|
||||||
@@ -11441,6 +11442,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases 0.2.1",
|
"cfg_aliases 0.2.1",
|
||||||
"libc",
|
"libc",
|
||||||
|
"memoffset",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -15972,6 +15974,7 @@ dependencies = [
|
|||||||
"http_proxy",
|
"http_proxy",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
|
"nix 0.29.0",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"smol",
|
"smol",
|
||||||
|
|||||||
@@ -3952,14 +3952,15 @@ impl AcpThread {
|
|||||||
let (task_command, task_args) = builder
|
let (task_command, task_args) = builder
|
||||||
.redirect_stdin_to_dev_null()
|
.redirect_stdin_to_dev_null()
|
||||||
.build(Some(command.clone()), &args);
|
.build(Some(command.clone()), &args);
|
||||||
let (task_command, task_args, task_env, sandbox) = prepare_sandbox_wrap(
|
let (task_command, task_args, task_env, sandbox) = cx
|
||||||
task_command,
|
.background_spawn(prepare_sandbox_wrap(
|
||||||
task_args,
|
task_command,
|
||||||
cwd.clone(),
|
task_args,
|
||||||
sandbox_wrap,
|
cwd.clone(),
|
||||||
env,
|
sandbox_wrap,
|
||||||
)
|
env,
|
||||||
.await?;
|
))
|
||||||
|
.await?;
|
||||||
(task_command, task_args, task_env, sandbox, cwd.clone())
|
(task_command, task_args, task_env, sandbox, cwd.clone())
|
||||||
};
|
};
|
||||||
let terminal = project
|
let terminal = project
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ pub struct SandboxWrap {
|
|||||||
/// enforcing proxy binds a loopback port on this host, so it can only
|
/// enforcing proxy binds a loopback port on this host, so it can only
|
||||||
/// confine local commands; a remote terminal can't reach it.
|
/// confine local commands; a remote terminal can't reach it.
|
||||||
pub is_local: bool,
|
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)]
|
#[derive(Clone, Debug, Default)]
|
||||||
@@ -137,26 +144,32 @@ impl SandboxWrap {
|
|||||||
/// (fail-open), or refuse (fail-closed). It runs a brief probe subprocess on
|
/// (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
|
/// Linux, so call it off the main thread. On platforms whose sandbox can't
|
||||||
/// fail to set up this way it always returns `Ok`.
|
/// fail to set up this way it always returns `Ok`.
|
||||||
pub fn can_create_sandbox(
|
pub fn can_create_sandbox(&self) -> Result<(), LinuxWslSandboxError> {
|
||||||
&self,
|
sandbox::Sandbox::can_create(&self.to_policy()).map_err(LinuxWslSandboxError::from)
|
||||||
cwd: Option<&std::path::Path>,
|
|
||||||
) -> Result<(), LinuxWslSandboxError> {
|
|
||||||
sandbox::Sandbox::can_create(&self.to_policy(), cwd).map_err(LinuxWslSandboxError::from)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Translate this request into the cross-platform [`sandbox::SandboxPolicy`].
|
/// 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 {
|
fn to_policy(&self) -> sandbox::SandboxPolicy {
|
||||||
let fs = if self.allow_fs_write {
|
let fs = if self.allow_fs_write {
|
||||||
sandbox::SandboxFsPolicy::Unrestricted
|
sandbox::SandboxFsPolicy::Unrestricted
|
||||||
} else {
|
} else {
|
||||||
sandbox::SandboxFsPolicy::Restricted {
|
let writable_paths = self
|
||||||
writable_paths: self
|
.writable_paths
|
||||||
.writable_paths
|
.iter()
|
||||||
.iter()
|
.chain(self.extra_write_paths.iter())
|
||||||
.cloned()
|
.filter_map(|path| {
|
||||||
.chain(self.extra_write_paths.iter().cloned())
|
// Create not-yet-existing writable grants (e.g. an approved
|
||||||
.collect(),
|
// 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 {
|
let network = match &self.network {
|
||||||
SandboxNetworkAccess::None => sandbox::SandboxNetPolicy::Blocked,
|
SandboxNetworkAccess::None => sandbox::SandboxNetPolicy::Blocked,
|
||||||
@@ -169,7 +182,11 @@ impl SandboxWrap {
|
|||||||
.collect(),
|
.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 {
|
let git = if self.allow_git_access {
|
||||||
sandbox::GitSandboxPolicy::Allowed { git_dirs }
|
sandbox::GitSandboxPolicy::Allowed { git_dirs }
|
||||||
} else {
|
} else {
|
||||||
@@ -238,6 +255,12 @@ pub(crate) async fn prepare_sandbox_wrap(
|
|||||||
|
|
||||||
let mut sandbox =
|
let mut sandbox =
|
||||||
sandbox::Sandbox::new(sandbox_wrap.to_policy()).map_err(anyhow::Error::new)?;
|
sandbox::Sandbox::new(sandbox_wrap.to_policy()).map_err(anyhow::Error::new)?;
|
||||||
|
// Windows/WSL only: tell the sandbox which Linux `zed` to provision inside
|
||||||
|
// WSL as its `--wsl-sandbox-helper`. A no-op (and a no-op setter) elsewhere.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
if let Some((channel, version)) = sandbox_wrap.wsl_zed_release.clone() {
|
||||||
|
sandbox.set_wsl_zed_release(channel, version);
|
||||||
|
}
|
||||||
let command = sandbox::CommandAndArgs {
|
let command = sandbox::CommandAndArgs {
|
||||||
program,
|
program,
|
||||||
args,
|
args,
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ web_search.workspace = true
|
|||||||
zed_env_vars.workspace = true
|
zed_env_vars.workspace = true
|
||||||
zstd.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]
|
[dev-dependencies]
|
||||||
assets.workspace = true
|
assets.workspace = true
|
||||||
async-io.workspace = true
|
async-io.workspace = true
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag};
|
|||||||
use gpui::App;
|
use gpui::App;
|
||||||
use http_proxy::HostPattern;
|
use http_proxy::HostPattern;
|
||||||
use project::Project;
|
use project::Project;
|
||||||
use sandbox::{GitSandboxPolicy, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy};
|
use sandbox::{
|
||||||
|
GitSandboxPolicy, HostFilesystemLocation, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
|
||||||
|
};
|
||||||
use settings::Settings;
|
use settings::Settings;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -126,6 +128,13 @@ impl ThreadSandbox {
|
|||||||
match self {
|
match self {
|
||||||
ThreadSandbox::Unsandboxed => ThreadSandbox::Unsandboxed,
|
ThreadSandbox::Unsandboxed => ThreadSandbox::Unsandboxed,
|
||||||
ThreadSandbox::Sandboxed(policy) => {
|
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 {
|
let git = if allowed {
|
||||||
GitSandboxPolicy::Allowed { git_dirs }
|
GitSandboxPolicy::Allowed { git_dirs }
|
||||||
} else {
|
} else {
|
||||||
@@ -158,7 +167,11 @@ pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy
|
|||||||
SandboxFsPolicy::Unrestricted
|
SandboxFsPolicy::Unrestricted
|
||||||
} else {
|
} else {
|
||||||
SandboxFsPolicy::Restricted {
|
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 {
|
let network = if persistent.allow_all_hosts {
|
||||||
@@ -437,7 +450,11 @@ impl ThreadSandboxGrants {
|
|||||||
SandboxFsPolicy::Unrestricted
|
SandboxFsPolicy::Unrestricted
|
||||||
} else {
|
} else {
|
||||||
SandboxFsPolicy::Restricted {
|
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 {
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
fn hosts(list: &[&str]) -> NetworkRequest {
|
fn hosts(list: &[&str]) -> NetworkRequest {
|
||||||
NetworkRequest::Hosts(
|
NetworkRequest::Hosts(
|
||||||
@@ -644,9 +662,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn thread_sandbox_merge_unsandboxed_wins_else_unions_scopes() {
|
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 {
|
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() {
|
network: if hosts.is_empty() {
|
||||||
SandboxNetPolicy::Blocked
|
SandboxNetPolicy::Blocked
|
||||||
@@ -661,20 +689,20 @@ mod tests {
|
|||||||
// Unsandboxed on either side wins — the agent runs with ambient access.
|
// Unsandboxed on either side wins — the agent runs with ambient access.
|
||||||
assert!(
|
assert!(
|
||||||
ThreadSandbox::Unsandboxed
|
ThreadSandbox::Unsandboxed
|
||||||
.merge(ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"])))
|
.merge(ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"])))
|
||||||
.is_unsandboxed()
|
.is_unsandboxed()
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"]))
|
ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"]))
|
||||||
.merge(ThreadSandbox::Unsandboxed)
|
.merge(ThreadSandbox::Unsandboxed)
|
||||||
.is_unsandboxed()
|
.is_unsandboxed()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Two sandboxed layers union their scopes.
|
// Two sandboxed layers union their scopes.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"]))
|
ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"]))
|
||||||
.merge(ThreadSandbox::Sandboxed(policy(&["/b"], &["b.com"]))),
|
.merge(ThreadSandbox::Sandboxed(policy(&[path_b], &["b.com"]))),
|
||||||
ThreadSandbox::Sandboxed(policy(&["/a", "/b"], &["a.com", "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() {
|
fn thread_grants_to_policy_maps_paths_and_domains() {
|
||||||
use sandbox::{SandboxFsPolicy, SandboxNetPolicy};
|
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();
|
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();
|
let policy = grants.to_policy();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
policy.fs,
|
policy.fs,
|
||||||
SandboxFsPolicy::Restricted {
|
SandboxFsPolicy::Restricted {
|
||||||
writable_paths: vec![PathBuf::from("/tmp/build")]
|
writable_paths: vec![
|
||||||
|
HostFilesystemLocation::new(build_dir.path()).expect("capture temp dir")
|
||||||
|
]
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -781,8 +815,10 @@ mod tests {
|
|||||||
fn settings_policy_maps_persistent_permissions() {
|
fn settings_policy_maps_persistent_permissions() {
|
||||||
use sandbox::{SandboxFsPolicy, SandboxNetPolicy};
|
use sandbox::{SandboxFsPolicy, SandboxNetPolicy};
|
||||||
|
|
||||||
|
// `settings_sandbox_policy` captures real `HostFilesystemLocation`s.
|
||||||
|
let log_dir = tempfile::tempdir().expect("create temp log dir");
|
||||||
let persistent = SandboxPermissions {
|
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()],
|
network_hosts: vec!["*.npmjs.org".to_string()],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -790,7 +826,9 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
policy.fs,
|
policy.fs,
|
||||||
SandboxFsPolicy::Restricted {
|
SandboxFsPolicy::Restricted {
|
||||||
writable_paths: vec![PathBuf::from("/var/log")]
|
writable_paths: vec![
|
||||||
|
HostFilesystemLocation::new(log_dir.path()).expect("capture temp dir")
|
||||||
|
]
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ use crate::sandboxing::{
|
|||||||
use crate::tools::{SandboxGitPathCandidates, sandbox_git_paths};
|
use crate::tools::{SandboxGitPathCandidates, sandbox_git_paths};
|
||||||
use agent_client_protocol::schema::v1 as acp;
|
use agent_client_protocol::schema::v1 as acp;
|
||||||
use agent_settings::{
|
use agent_settings::{
|
||||||
AgentProfileId, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT,
|
AgentProfileId, AgentProfileSettings, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT,
|
||||||
SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT,
|
SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, builtin_profiles,
|
||||||
};
|
};
|
||||||
use anyhow::{Context as _, Result, anyhow};
|
use anyhow::{Context as _, Result, anyhow};
|
||||||
use chrono::{DateTime, Local, Utc};
|
use chrono::{DateTime, Local, Utc};
|
||||||
@@ -46,7 +46,7 @@ use language_model::{
|
|||||||
LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, SelectedModel, Speed,
|
LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, SelectedModel, Speed,
|
||||||
StopReason, TokenUsage, ZED_CLOUD_PROVIDER_ID,
|
StopReason, TokenUsage, ZED_CLOUD_PROVIDER_ID,
|
||||||
};
|
};
|
||||||
use project::Project;
|
use project::{Project, trusted_worktrees::TrustedWorktrees};
|
||||||
use prompt_store::ProjectContext;
|
use prompt_store::ProjectContext;
|
||||||
use schemars::{JsonSchema, Schema};
|
use schemars::{JsonSchema, Schema};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
@@ -1226,6 +1226,9 @@ pub struct Thread {
|
|||||||
initial_project_snapshot: Shared<Task<Option<Arc<ProjectSnapshot>>>>,
|
initial_project_snapshot: Shared<Task<Option<Arc<ProjectSnapshot>>>>,
|
||||||
pub(crate) context_server_registry: Entity<ContextServerRegistry>,
|
pub(crate) context_server_registry: Entity<ContextServerRegistry>,
|
||||||
profile_id: AgentProfileId,
|
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>,
|
project_context: Entity<ProjectContext>,
|
||||||
pub(crate) templates: Arc<Templates>,
|
pub(crate) templates: Arc<Templates>,
|
||||||
model: ThreadModel,
|
model: ThreadModel,
|
||||||
@@ -1320,7 +1323,8 @@ impl Thread {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let settings = AgentSettings::get_global(cx);
|
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
|
let enable_thinking = settings
|
||||||
.default_model
|
.default_model
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1363,6 +1367,7 @@ impl Thread {
|
|||||||
},
|
},
|
||||||
context_server_registry,
|
context_server_registry,
|
||||||
profile_id,
|
profile_id,
|
||||||
|
profile_downgraded_for_restricted_workspace,
|
||||||
project_context,
|
project_context,
|
||||||
templates,
|
templates,
|
||||||
model,
|
model,
|
||||||
@@ -1395,6 +1400,8 @@ impl Thread {
|
|||||||
self.thinking_effort = parent.thinking_effort.clone();
|
self.thinking_effort = parent.thinking_effort.clone();
|
||||||
self.summarization_model = parent.summarization_model.clone();
|
self.summarization_model = parent.summarization_model.clone();
|
||||||
self.profile_id = parent.profile_id.clone();
|
self.profile_id = parent.profile_id.clone();
|
||||||
|
self.profile_downgraded_for_restricted_workspace =
|
||||||
|
parent.profile_downgraded_for_restricted_workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_model_selection(
|
fn apply_model_selection(
|
||||||
@@ -1738,6 +1745,7 @@ impl Thread {
|
|||||||
initial_project_snapshot: Task::ready(db_thread.initial_project_snapshot).shared(),
|
initial_project_snapshot: Task::ready(db_thread.initial_project_snapshot).shared(),
|
||||||
context_server_registry,
|
context_server_registry,
|
||||||
profile_id,
|
profile_id,
|
||||||
|
profile_downgraded_for_restricted_workspace: false,
|
||||||
project_context,
|
project_context,
|
||||||
templates,
|
templates,
|
||||||
model,
|
model,
|
||||||
@@ -2195,7 +2203,44 @@ impl Thread {
|
|||||||
&self.profile_id
|
&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>) {
|
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 {
|
if self.profile_id == profile_id {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3458,6 +3503,26 @@ impl Thread {
|
|||||||
cancellation_rx: watch::Receiver<bool>,
|
cancellation_rx: watch::Receiver<bool>,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Task<LanguageModelToolResult> {
|
) -> 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 fs = self.project.read(cx).fs().clone();
|
||||||
let tool_event_stream = ToolCallEventStream::new(
|
let tool_event_stream = ToolCallEventStream::new(
|
||||||
tool_use_id.clone(),
|
tool_use_id.clone(),
|
||||||
@@ -3936,9 +4001,16 @@ impl Thread {
|
|||||||
// to the model under that name.
|
// to the model under that name.
|
||||||
let use_sandboxed_terminal = sandboxing_enabled_for_project(self.project.read(cx), cx);
|
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
|
let mut tools = self
|
||||||
.tools
|
.tools
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|(_, tool)| !is_restricted || tool.allow_in_restricted_mode())
|
||||||
.filter_map(|(tool_name, tool)| {
|
.filter_map(|(tool_name, tool)| {
|
||||||
let terminal_variant = matches!(
|
let terminal_variant = matches!(
|
||||||
tool_name.as_ref(),
|
tool_name.as_ref(),
|
||||||
@@ -4904,6 +4976,14 @@ where
|
|||||||
true
|
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.
|
/// Runs the tool with the provided input.
|
||||||
///
|
///
|
||||||
/// Returns `Result<Self::Output, Self::Output>` rather than `Result<Self::Output, anyhow::Error>`
|
/// 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 {
|
fn supports_provider(&self, _provider: &LanguageModelProviderId) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
fn allow_in_restricted_mode(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
/// See [`AgentTool::run`] for why this returns `Result<AgentToolOutput, AgentToolOutput>`.
|
/// See [`AgentTool::run`] for why this returns `Result<AgentToolOutput, AgentToolOutput>`.
|
||||||
fn run(
|
fn run(
|
||||||
self: Arc<Self>,
|
self: Arc<Self>,
|
||||||
@@ -5018,6 +5101,10 @@ where
|
|||||||
T::supports_provider(provider)
|
T::supports_provider(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn allow_in_restricted_mode(&self) -> bool {
|
||||||
|
T::allow_in_restricted_mode()
|
||||||
|
}
|
||||||
|
|
||||||
fn run(
|
fn run(
|
||||||
self: Arc<Self>,
|
self: Arc<Self>,
|
||||||
input: ToolInput<serde_json::Value>,
|
input: ToolInput<serde_json::Value>,
|
||||||
|
|||||||
@@ -138,6 +138,18 @@ macro_rules! tools {
|
|||||||
false
|
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
|
/// A list of all built-in tools
|
||||||
pub fn built_in_tools() -> impl Iterator<Item = LanguageModelRequestTool> {
|
pub fn built_in_tools() -> impl Iterator<Item = LanguageModelRequestTool> {
|
||||||
fn language_model_tool<T: AgentTool>() -> 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,
|
_ => 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
|
acp::ToolKind::Fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn allow_in_restricted_mode() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn initial_title(
|
fn initial_title(
|
||||||
&self,
|
&self,
|
||||||
input: Result<Self::Input, serde_json::Value>,
|
input: Result<Self::Input, serde_json::Value>,
|
||||||
|
|||||||
@@ -298,6 +298,10 @@ impl AgentTool for TerminalTool {
|
|||||||
acp::ToolKind::Execute
|
acp::ToolKind::Execute
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn allow_in_restricted_mode() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn initial_title(
|
fn initial_title(
|
||||||
&self,
|
&self,
|
||||||
input: Result<Self::Input, serde_json::Value>,
|
input: Result<Self::Input, serde_json::Value>,
|
||||||
@@ -336,6 +340,10 @@ impl AgentTool for SandboxedTerminalTool {
|
|||||||
acp::ToolKind::Execute
|
acp::ToolKind::Execute
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn allow_in_restricted_mode() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn initial_title(
|
fn initial_title(
|
||||||
&self,
|
&self,
|
||||||
input: Result<Self::Input, serde_json::Value>,
|
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(
|
async fn run_terminal_tool(
|
||||||
project: Entity<Project>,
|
project: Entity<Project>,
|
||||||
environment: Rc<dyn ThreadEnvironment>,
|
environment: Rc<dyn ThreadEnvironment>,
|
||||||
@@ -382,17 +416,26 @@ async fn run_terminal_tool(
|
|||||||
let selection = input.selection;
|
let selection = input.selection;
|
||||||
let sandbox_input = input.sandbox.clone().unwrap_or_default();
|
let sandbox_input = input.sandbox.clone().unwrap_or_default();
|
||||||
|
|
||||||
let (working_dir, authorize, sandboxing, is_local_project) = cx.update(|cx| {
|
let (working_dir, authorize, sandboxing, is_local_project, wsl_zed_release) =
|
||||||
let working_dir = working_dir(&input.cd, &project, cx).map_err(|err| err.to_string())?;
|
cx.update(|cx| {
|
||||||
let context =
|
let working_dir =
|
||||||
crate::ToolPermissionContext::new(TerminalTool::NAME, vec![input.command.clone()]);
|
working_dir(&input.cd, &project, cx).map_err(|err| err.to_string())?;
|
||||||
let authorize =
|
let context =
|
||||||
event_stream.authorize(SharedString::new(input.command.clone()), context, cx);
|
crate::ToolPermissionContext::new(TerminalTool::NAME, vec![input.command.clone()]);
|
||||||
let sandboxing =
|
let authorize =
|
||||||
input.sandbox.is_some() && sandboxing_enabled_for_project(project.read(cx), cx);
|
event_stream.authorize(SharedString::new(input.command.clone()), context, cx);
|
||||||
let is_local_project = project.read(cx).is_local();
|
let sandboxing =
|
||||||
Result::<_, String>::Ok((working_dir, authorize, sandboxing, is_local_project))
|
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())?;
|
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),
|
network: network_request_to_sandbox_network_access(&effective.network),
|
||||||
allow_fs_write: effective.allow_fs_write_all,
|
allow_fs_write: effective.allow_fs_write_all,
|
||||||
is_local: is_local_project,
|
is_local: is_local_project,
|
||||||
|
wsl_zed_release: wsl_zed_release.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// The viability check runs a brief probe subprocess, so do it off
|
// 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;
|
let mut retries = 0usize;
|
||||||
loop {
|
loop {
|
||||||
let probe_wrap = wrap.clone();
|
let probe_wrap = wrap.clone();
|
||||||
let probe_cwd = working_dir.clone();
|
|
||||||
let error = match cx
|
let error = match cx
|
||||||
.background_executor()
|
.background_executor()
|
||||||
.spawn(async move { probe_wrap.can_create_sandbox(probe_cwd.as_deref()) })
|
.spawn(async move { probe_wrap.can_create_sandbox() })
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => break Some(wrap),
|
Ok(()) => break Some(wrap),
|
||||||
@@ -686,10 +729,9 @@ async fn run_terminal_tool(
|
|||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
{
|
{
|
||||||
let probe_wrap = wrap.clone();
|
let probe_wrap = wrap.clone();
|
||||||
let probe_cwd = working_dir.clone();
|
|
||||||
match cx
|
match cx
|
||||||
.background_executor()
|
.background_executor()
|
||||||
.spawn(async move { probe_wrap.can_create_sandbox(probe_cwd.as_deref()) })
|
.spawn(async move { probe_wrap.can_create_sandbox() })
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => Some(wrap),
|
Ok(()) => Some(wrap),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use fs::Fs;
|
|||||||
use gpui::{App, SharedString};
|
use gpui::{App, SharedString};
|
||||||
use settings::{
|
use settings::{
|
||||||
AgentProfileContent, ContextServerPresetContent, LanguageModelSelection, Settings as _,
|
AgentProfileContent, ContextServerPresetContent, LanguageModelSelection, Settings as _,
|
||||||
SettingsContent, update_settings_file,
|
SettingsContent, SettingsStore, update_settings_file,
|
||||||
};
|
};
|
||||||
use util::ResultExt as _;
|
use util::ResultExt as _;
|
||||||
|
|
||||||
@@ -116,6 +116,32 @@ impl AgentProfileSettings {
|
|||||||
self.tools.get(tool_name) == Some(&true)
|
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 {
|
pub fn is_context_server_tool_enabled(&self, server_id: &str, tool_name: &str) -> bool {
|
||||||
self.context_servers
|
self.context_servers
|
||||||
.get(server_id)
|
.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("server", "other_tool"));
|
||||||
assert!(!profile.is_context_server_tool_enabled("other_server", "any_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 {
|
fn model_selected(&self, cx: &App) -> bool {
|
||||||
self.read(cx).model().is_some()
|
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)]
|
#[derive(Default)]
|
||||||
|
|||||||
@@ -4645,7 +4645,7 @@ impl ThreadView {
|
|||||||
// Sandboxed by settings, but disabled for this thread: show the
|
// Sandboxed by settings, but disabled for this thread: show the
|
||||||
// settings scope (greyed) for context above the disabled status.
|
// settings scope (greyed) for context above the disabled status.
|
||||||
(ThreadSandbox::Sandboxed(settings_policy), ThreadSandbox::Unsandboxed) => {
|
(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(
|
SandboxStatusTooltip::disabled_for_thread(sandbox_section(
|
||||||
"Defined in your settings:",
|
"Defined in your settings:",
|
||||||
&settings,
|
&settings,
|
||||||
@@ -4656,10 +4656,11 @@ impl ThreadView {
|
|||||||
ThreadSandbox::Sandboxed(settings_policy),
|
ThreadSandbox::Sandboxed(settings_policy),
|
||||||
ThreadSandbox::Sandboxed(thread_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.
|
// Omit the per-thread section when it grants nothing extra.
|
||||||
let thread = (!sandbox_policy_grants_nothing(&thread_policy))
|
let thread = (!sandbox_policy_grants_nothing(&thread))
|
||||||
.then(|| sandbox_section("Allowed for this thread:", &thread_policy, false));
|
.then(|| sandbox_section("Allowed for this thread:", &thread, false));
|
||||||
SandboxStatusTooltip::enabled(
|
SandboxStatusTooltip::enabled(
|
||||||
sandbox_section("Defined in your settings:", &settings, true),
|
sandbox_section("Defined in your settings:", &settings, true),
|
||||||
thread,
|
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
|
/// Fold the always-granted baseline writable paths (the project's worktree
|
||||||
/// roots, derived from the same source the terminal tool uses) and, on Linux,
|
/// 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
|
/// 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
|
/// section rather than stored. A no-op when the fs is unrestricted (rendered as
|
||||||
/// "All paths"), since there's nothing to scope.
|
/// "All paths"), since there's nothing to scope.
|
||||||
fn augment_settings_sandbox_policy(
|
fn augment_settings_sandbox_policy(
|
||||||
mut policy: SandboxPolicy,
|
policy: &SandboxPolicy,
|
||||||
baseline: Vec<PathBuf>,
|
baseline: Vec<PathBuf>,
|
||||||
) -> SandboxPolicy {
|
) -> SandboxPolicyDisplay {
|
||||||
if let SandboxFsPolicy::Restricted { writable_paths } = &mut policy.fs {
|
let fs = match &policy.fs {
|
||||||
let mut merged = baseline;
|
SandboxFsPolicy::Unrestricted => SandboxFsDisplay::Unrestricted,
|
||||||
for path in writable_paths.drain(..) {
|
SandboxFsPolicy::Restricted { writable_paths } => {
|
||||||
if !merged.contains(&path) {
|
// Dedup by display string. We deliberately don't open the locations'
|
||||||
merged.push(path);
|
// 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
|
SandboxPolicyDisplay {
|
||||||
// host path, so it can't come from the path source above.
|
fs,
|
||||||
#[cfg(target_os = "linux")]
|
network: policy.network.clone(),
|
||||||
merged.push(PathBuf::from("/tmp (isolated)"));
|
git: SandboxGitDisplay::from_policy(&policy.git),
|
||||||
*writable_paths = merged;
|
|
||||||
}
|
}
|
||||||
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 write_empty = fs_grants_nothing(&policy.fs);
|
||||||
let network_empty = network_grants_nothing(&policy.network);
|
let network_empty = network_grants_nothing(&policy.network);
|
||||||
let git_empty = git_grants_nothing(&policy.git);
|
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
|
/// Whether a policy grants nothing worth surfacing, used to decide whether to
|
||||||
/// show the per-thread overrides section at all.
|
/// 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)
|
fs_grants_nothing(&policy.fs)
|
||||||
&& network_grants_nothing(&policy.network)
|
&& network_grants_nothing(&policy.network)
|
||||||
&& git_grants_nothing(&policy.git)
|
&& 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*
|
/// Git access grants nothing to surface unless `.git` writes are allowed *and*
|
||||||
/// at least one `.git` directory is known.
|
/// at least one `.git` directory is known.
|
||||||
fn git_grants_nothing(git: &GitSandboxPolicy) -> bool {
|
fn git_grants_nothing(git: &SandboxGitDisplay) -> bool {
|
||||||
!git.allows_writes() || git.git_dirs().is_empty()
|
!git.allowed || git.git_dirs.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rows for the Git-access group: one row per writable `.git` directory (these
|
/// Rows for the Git-access group: one row per writable `.git` directory (these
|
||||||
/// may live outside the project for a linked worktree).
|
/// may live outside the project for a linked worktree).
|
||||||
fn sandbox_git_rows(git: &GitSandboxPolicy) -> Vec<SandboxRow> {
|
fn sandbox_git_rows(git: &SandboxGitDisplay) -> Vec<SandboxRow> {
|
||||||
match git {
|
if !git.allowed {
|
||||||
GitSandboxPolicy::Allowed { git_dirs } if !git_dirs.is_empty() => git_dirs
|
return Vec::new();
|
||||||
.iter()
|
|
||||||
.map(|path| SandboxRow::git(path.clone()))
|
|
||||||
.collect(),
|
|
||||||
_ => 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 {
|
fn fs_grants_nothing(fs: &SandboxFsDisplay) -> bool {
|
||||||
matches!(fs, SandboxFsPolicy::Restricted { writable_paths } if writable_paths.is_empty())
|
matches!(fs, SandboxFsDisplay::Restricted(entries) if entries.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn network_grants_nothing(network: &SandboxNetPolicy) -> bool {
|
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
|
/// Rows for the write-access group: a message for the "all"/"none" cases, or one
|
||||||
/// row per granted path.
|
/// row per granted path.
|
||||||
fn sandbox_fs_rows(fs: &SandboxFsPolicy) -> Vec<SandboxRow> {
|
fn sandbox_fs_rows(fs: &SandboxFsDisplay) -> Vec<SandboxRow> {
|
||||||
match fs {
|
match fs {
|
||||||
SandboxFsPolicy::Unrestricted => vec![SandboxRow::message("All paths (unrestricted)")],
|
SandboxFsDisplay::Unrestricted => vec![SandboxRow::message("All paths (unrestricted)")],
|
||||||
SandboxFsPolicy::Restricted { writable_paths } if writable_paths.is_empty() => {
|
SandboxFsDisplay::Restricted(entries) if entries.is_empty() => {
|
||||||
vec![SandboxRow::message("None")]
|
vec![SandboxRow::message("None")]
|
||||||
}
|
}
|
||||||
SandboxFsPolicy::Restricted { writable_paths } => writable_paths
|
SandboxFsDisplay::Restricted(entries) => entries
|
||||||
.iter()
|
.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(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8265,27 +8376,23 @@ impl ThreadView {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.when(has_host_list && is_open, |this| {
|
.when(has_host_list && is_open, |this| {
|
||||||
this.child(
|
this.child(v_flex().children(hosts.iter().enumerate().map(
|
||||||
v_flex()
|
|(host_ix, host)| {
|
||||||
.id(("sandbox-network-hosts-list", entry_ix))
|
h_flex()
|
||||||
.max_h_40()
|
.min_w_0()
|
||||||
.overflow_y_scroll()
|
.px_2()
|
||||||
.children(hosts.iter().enumerate().map(|(host_ix, host)| {
|
.py_1p5()
|
||||||
h_flex()
|
.bg(cx.theme().colors().editor_background)
|
||||||
.min_w_0()
|
.when(host_ix < hosts.len() - 1, |this| {
|
||||||
.px_2()
|
this.border_b_1().border_color(cx.theme().colors().border)
|
||||||
.py_1p5()
|
})
|
||||||
.bg(cx.theme().colors().editor_background)
|
.child(
|
||||||
.when(host_ix < hosts.len() - 1, |this| {
|
Label::new(host.clone())
|
||||||
this.border_b_1().border_color(cx.theme().colors().border)
|
.size(LabelSize::XSmall)
|
||||||
})
|
.buffer_font(cx),
|
||||||
.child(
|
)
|
||||||
Label::new(host.clone())
|
},
|
||||||
.size(LabelSize::XSmall)
|
)))
|
||||||
.buffer_font(cx),
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8368,21 +8475,17 @@ impl ThreadView {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.when(has_path_list && is_open, |this| {
|
.when(has_path_list && is_open, |this| {
|
||||||
this.child(
|
this.child(v_flex().children(paths.iter().enumerate().map(
|
||||||
v_flex()
|
|(path_ix, path)| {
|
||||||
.id(("sandbox-authorization-paths-list", entry_ix))
|
self.render_sandbox_authorization_path_row(
|
||||||
.max_h_40()
|
entry_ix,
|
||||||
.overflow_y_scroll()
|
path_ix,
|
||||||
.children(paths.iter().enumerate().map(|(path_ix, path)| {
|
path,
|
||||||
self.render_sandbox_authorization_path_row(
|
path_ix < paths.len() - 1,
|
||||||
entry_ix,
|
cx,
|
||||||
path_ix,
|
)
|
||||||
path,
|
},
|
||||||
path_ix < paths.len() - 1,
|
)))
|
||||||
cx,
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8439,9 +8542,9 @@ impl ThreadView {
|
|||||||
v_flex()
|
v_flex()
|
||||||
.border_t_1()
|
.border_t_1()
|
||||||
.border_color(self.tool_card_border_color(cx))
|
.border_color(self.tool_card_border_color(cx))
|
||||||
|
.children(git_access_section)
|
||||||
.children(network_section)
|
.children(network_section)
|
||||||
.children(write_section)
|
.children(write_section)
|
||||||
.children(git_access_section)
|
|
||||||
.children(unsandboxed_section)
|
.children(unsandboxed_section)
|
||||||
.children(reason_section)
|
.children(reason_section)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ use std::{
|
|||||||
};
|
};
|
||||||
use ui::{
|
use ui::{
|
||||||
DocumentationAside, HighlightedLabel, KeyBinding, LabelSize, ListItem, ListItemSpacing,
|
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
|
/// Trait for types that can provide and manage agent profiles
|
||||||
pub trait ProfileProvider {
|
pub trait ProfileProvider {
|
||||||
@@ -34,6 +35,22 @@ pub trait ProfileProvider {
|
|||||||
|
|
||||||
/// Check if there is a model selected in the current context.
|
/// Check if there is a model selected in the current context.
|
||||||
fn model_selected(&self, cx: &App) -> bool;
|
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 {
|
pub struct ProfileSelector {
|
||||||
@@ -188,9 +205,23 @@ impl Render for ProfileSelector {
|
|||||||
IconName::ChevronDown
|
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)
|
let trigger_button = Button::new("profile-selector", selected_profile)
|
||||||
.label_size(LabelSize::Small)
|
.label_size(LabelSize::Small)
|
||||||
.color(Color::Muted)
|
.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));
|
.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({
|
let tooltip: Box<dyn Fn(&mut Window, &mut App) -> AnyView> = Box::new(Tooltip::element({
|
||||||
@@ -339,6 +370,22 @@ impl ProfilePickerDelegate {
|
|||||||
.collect()
|
.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> {
|
fn documentation(candidate: &ProfileCandidate) -> Option<&'static str> {
|
||||||
match candidate.id.as_str() {
|
match candidate.id.as_str() {
|
||||||
builtin_profiles::WRITE => Some("Get help to write anything."),
|
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 is_active = active_id == candidate.id;
|
||||||
let has_documentation = Self::documentation(candidate).is_some();
|
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(
|
Some(
|
||||||
div()
|
div()
|
||||||
.id(("profile-picker-item", ix))
|
.id(("profile-picker-item", ix))
|
||||||
.when(has_documentation, |this| {
|
.when(track_hover, |this| {
|
||||||
this.on_hover(cx.listener(move |picker, hovered, _, cx| {
|
this.on_hover(cx.listener(move |picker, hovered, _, cx| {
|
||||||
if *hovered {
|
if *hovered {
|
||||||
picker.delegate.hovered_index = Some(ix);
|
picker.delegate.hovered_index = Some(ix);
|
||||||
@@ -616,11 +670,23 @@ impl PickerDelegate for ProfilePickerDelegate {
|
|||||||
candidate.name.clone(),
|
candidate.name.clone(),
|
||||||
entry.positions.clone(),
|
entry.positions.clone(),
|
||||||
))
|
))
|
||||||
.when(is_active, |this| {
|
.when(has_end_slot, |this| {
|
||||||
this.end_slot(
|
this.end_slot(
|
||||||
div()
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
.pr_2()
|
.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 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);
|
let side = documentation_aside_side(cx);
|
||||||
|
|
||||||
Some(DocumentationAside {
|
Some(DocumentationAside {
|
||||||
side,
|
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>>,
|
cx: &mut Context<Picker<Self>>,
|
||||||
) -> Option<gpui::AnyElement> {
|
) -> Option<gpui::AnyElement> {
|
||||||
let focus_handle = self.focus_handle.clone();
|
let focus_handle = self.focus_handle.clone();
|
||||||
|
let is_restricted = self.provider.is_restricted(cx);
|
||||||
|
|
||||||
Some(
|
Some(
|
||||||
h_flex()
|
v_flex()
|
||||||
.w_full()
|
.w_full()
|
||||||
.border_t_1()
|
|
||||||
.border_color(cx.theme().colors().border_variant)
|
|
||||||
.p_1p5()
|
|
||||||
.child(
|
.child(
|
||||||
Button::new("configure", "Configure")
|
h_flex()
|
||||||
.full_width()
|
.w_full()
|
||||||
.style(ButtonStyle::Outlined)
|
.border_t_1()
|
||||||
.key_binding(
|
.border_color(cx.theme().colors().border_variant)
|
||||||
KeyBinding::for_action_in(
|
.p_1p5()
|
||||||
&ManageProfiles::default(),
|
.child(
|
||||||
&focus_handle,
|
Button::new("configure", "Configure")
|
||||||
cx,
|
.full_width()
|
||||||
)
|
.style(ButtonStyle::Outlined)
|
||||||
.map(|kb| kb.size(rems_from_px(12.))),
|
.key_binding(
|
||||||
)
|
KeyBinding::for_action_in(
|
||||||
.on_click(|_, window, cx| {
|
&ManageProfiles::default(),
|
||||||
window.dispatch_action(ManageProfiles::default().boxed_clone(), cx);
|
&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(),
|
.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)
|
let settings_validator = jsonschema::validator_for(&settings_schema)
|
||||||
.expect("failed to compile settings JSON schema");
|
.expect("failed to compile settings JSON schema");
|
||||||
|
|
||||||
let keymap_schema =
|
// The keymap schema is built from the action manifest. When `actions.json`
|
||||||
keymap_schema_for_actions(&ALL_ACTIONS.actions, &ALL_ACTIONS.schema_definitions);
|
// is unavailable (e.g. when running outside of CI without first generating
|
||||||
let keymap_validator =
|
// it) there are no actions, which produces an invalid schema (an empty
|
||||||
jsonschema::validator_for(&keymap_schema).expect("failed to compile keymap JSON schema");
|
// `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(
|
fn for_each_labeled_code_block_mut(
|
||||||
book: &mut Book,
|
book: &mut Book,
|
||||||
@@ -499,19 +510,22 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"keymap" => {
|
"keymap" => {
|
||||||
if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
|
if let Some(keymap_validator) = &keymap_validator {
|
||||||
snippet_json_fixed.insert(0, '[');
|
if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
|
||||||
snippet_json_fixed.push_str("\n]");
|
snippet_json_fixed.insert(0, '[');
|
||||||
}
|
snippet_json_fixed.push_str("\n]");
|
||||||
|
}
|
||||||
|
|
||||||
let value =
|
let value = settings::parse_json_with_comments::<serde_json::Value>(
|
||||||
settings::parse_json_with_comments::<serde_json::Value>(&snippet_json_fixed)?;
|
&snippet_json_fixed,
|
||||||
let validation_errors: Vec<String> = keymap_validator
|
)?;
|
||||||
.iter_errors(&value)
|
let validation_errors: Vec<String> = keymap_validator
|
||||||
.map(|err| err.to_string())
|
.iter_errors(&value)
|
||||||
.collect();
|
.map(|err| err.to_string())
|
||||||
if !validation_errors.is_empty() {
|
.collect();
|
||||||
anyhow::bail!("{}", validation_errors.join("\n"));
|
if !validation_errors.is_empty() {
|
||||||
|
anyhow::bail!("{}", validation_errors.join("\n"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"debug" => {
|
"debug" => {
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ serde_json = { workspace = true, optional = true }
|
|||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
libc.workspace = true
|
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]
|
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -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
|
||||||
Executable
+29
@@ -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 {
|
mod imp {
|
||||||
use std::io::{Read as _, Write as _};
|
use std::io::{Read as _, Write as _};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context as _, Result, bail};
|
use anyhow::{Context as _, Result, bail};
|
||||||
use sandbox::{
|
use sandbox::{
|
||||||
CommandAndArgs, Sandbox, SandboxError, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
|
CommandAndArgs, GitSandboxPolicy, HostFilesystemLocation, Sandbox, SandboxError,
|
||||||
|
SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -111,6 +112,14 @@ mod imp {
|
|||||||
network_access: NetMode,
|
network_access: NetMode,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
allowed_domains: Vec<String>,
|
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) ----
|
// ---- operation (exactly one) ----
|
||||||
/// Read this host path from inside the sandbox.
|
/// Read this host path from inside the sandbox.
|
||||||
@@ -169,12 +178,24 @@ mod imp {
|
|||||||
checks.finish()
|
checks.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn policy_of(check: &Check) -> SandboxPolicy {
|
fn policy_of(check: &Check) -> Result<SandboxPolicy> {
|
||||||
let fs = match check.fs {
|
let fs = match check.fs {
|
||||||
FsMode::Unrestricted => SandboxFsPolicy::Unrestricted,
|
FsMode::Unrestricted => SandboxFsPolicy::Unrestricted,
|
||||||
FsMode::Restricted => SandboxFsPolicy::Restricted {
|
FsMode::Restricted => {
|
||||||
writable_paths: check.writable_paths.iter().map(PathBuf::from).collect(),
|
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 {
|
let network = match check.network_access {
|
||||||
NetMode::Unrestricted => SandboxNetPolicy::Unrestricted,
|
NetMode::Unrestricted => SandboxNetPolicy::Unrestricted,
|
||||||
@@ -183,18 +204,47 @@ mod imp {
|
|||||||
allowed_domains: check.allowed_domains.clone(),
|
allowed_domains: check.allowed_domains.clone(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
SandboxPolicy {
|
// `gitAllowed` and `gitDisabled` are mutually exclusive; `gitAllowed`
|
||||||
fs,
|
// wins if both are (mistakenly) set. With neither, the default protects
|
||||||
network,
|
// an empty set of dirs, which is a no-op.
|
||||||
git: sandbox::GitSandboxPolicy::default(),
|
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 {
|
fn describe(check: &Check) -> String {
|
||||||
if let Some(name) = &check.name {
|
if let Some(name) = &check.name {
|
||||||
return name.clone();
|
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 {
|
let op = if let Some(path) = &check.read {
|
||||||
format!("read {path}")
|
format!("read {path}")
|
||||||
} else if let Some(path) = &check.write {
|
} 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<()> {
|
fn run_check(check: &Check, echo_port: &str, checks: &mut Checks) -> Result<()> {
|
||||||
let label = describe(check);
|
let label = describe(check);
|
||||||
let policy = policy_of(check);
|
|
||||||
|
|
||||||
if let Some(expect_can_create) = check.can_create {
|
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) {
|
let passed = match (&outcome, expect_can_create) {
|
||||||
(Ok(()), true) => true,
|
(Ok(()), true) => true,
|
||||||
(Ok(()), false) => false,
|
(Ok(()), false) => false,
|
||||||
@@ -234,11 +284,11 @@ mod imp {
|
|||||||
.with_context(|| format!("check {label:?} has an operation but no `succeeds`"))?;
|
.with_context(|| format!("check {label:?} has an operation but no `succeeds`"))?;
|
||||||
|
|
||||||
let actual = if let Some(path) = &check.read {
|
let actual = if let Some(path) = &check.read {
|
||||||
run_read(&policy, path)?
|
run_read(check, path)?
|
||||||
} else if let Some(path) = &check.write {
|
} else if let Some(path) = &check.write {
|
||||||
run_write(&policy, path)?
|
run_write(check, path)?
|
||||||
} else if let Some(host) = &check.network {
|
} else if let Some(host) = &check.network {
|
||||||
run_network(&policy, host, echo_port)?
|
run_network(check, host, echo_port)?
|
||||||
} else {
|
} else {
|
||||||
bail!("check {label:?} has no operation");
|
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
|
/// 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
|
/// granted (root is bound read-only), so this proves the sandbox doesn't
|
||||||
/// *block* reads of existing host files.
|
/// *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);
|
let path = Path::new(path);
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
@@ -259,7 +309,10 @@ mod imp {
|
|||||||
std::fs::write(path, b"sandbox-test\n")
|
std::fs::write(path, b"sandbox-test\n")
|
||||||
.with_context(|| format!("failed to seed readable file {}", path.display()))?;
|
.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(
|
run_command(
|
||||||
&mut sandbox,
|
&mut sandbox,
|
||||||
"sh",
|
"sh",
|
||||||
@@ -271,18 +324,22 @@ mod imp {
|
|||||||
/// the command exited 0 *and* the bytes actually landed on the host file —
|
/// 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,
|
/// a write that only hits the sandbox's ephemeral tmpfs counts as blocked,
|
||||||
/// since it never escaped the sandbox.
|
/// 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);
|
let path = Path::new(path);
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
// Create the parent on the host so the only thing under test is the
|
// 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)
|
std::fs::create_dir_all(parent)
|
||||||
.with_context(|| format!("failed to create parent of {}", path.display()))?;
|
.with_context(|| format!("failed to create parent of {}", path.display()))?;
|
||||||
}
|
}
|
||||||
// Start from a clean slate so `exists()` afterwards is meaningful.
|
// Start from a clean slate so `exists()` afterwards is meaningful.
|
||||||
let _ = std::fs::remove_file(path);
|
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(
|
let command_ok = run_command(
|
||||||
&mut sandbox,
|
&mut sandbox,
|
||||||
"sh",
|
"sh",
|
||||||
@@ -297,14 +354,15 @@ mod imp {
|
|||||||
/// Connect to `host` (`hostname` or `hostname:port`) from inside the
|
/// Connect to `host` (`hostname` or `hostname:port`) from inside the
|
||||||
/// sandbox via the `__echo_check` subcommand, which honors `HTTP_PROXY` for
|
/// sandbox via the `__echo_check` subcommand, which honors `HTTP_PROXY` for
|
||||||
/// the restricted-network case.
|
/// 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(':') {
|
let target = if host.contains(':') {
|
||||||
host.to_string()
|
host.to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("{host}:{echo_port}")
|
format!("{host}:{echo_port}")
|
||||||
};
|
};
|
||||||
let exe = current_exe_str()?;
|
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])
|
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],
|
allowed_unix_socket_paths: &[&Path],
|
||||||
permissions: SandboxPermissions,
|
permissions: SandboxPermissions,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
// Canonicalize each writable path to resolve symlinks (e.g.,
|
// These paths are already the canonical identities captured once, at
|
||||||
// /var -> /private/var on macOS). Fall back to the original path if
|
// validation time, inside each `HostFilesystemLocation` (resolving symlinks
|
||||||
// canonicalization fails.
|
// 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
|
let canonical_writable_directories: Vec<PathBuf> = writable_directories
|
||||||
.iter()
|
.iter()
|
||||||
.map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf()))
|
.map(|path| path.to_path_buf())
|
||||||
.collect();
|
.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
|
let canonical_protected_paths: Vec<PathBuf> = protected_paths
|
||||||
.iter()
|
.iter()
|
||||||
.map(|path| crate::canonicalize_allowing_missing_leaf(path))
|
.map(|path| path.to_path_buf())
|
||||||
.collect();
|
.collect();
|
||||||
// Unlike file paths, Unix socket literals are emitted verbatim: it isn't
|
// Unlike file paths, Unix socket literals are emitted verbatim: it isn't
|
||||||
// guaranteed whether Seatbelt resolves symlinks before matching a
|
// guaranteed whether Seatbelt resolves symlinks before matching a
|
||||||
@@ -564,7 +562,11 @@ mod tests {
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
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();
|
std::fs::write(&protected_file, "target\n").unwrap();
|
||||||
|
|
||||||
let (program, args, _config_file) = wrap_invocation(
|
let (program, args, _config_file) = wrap_invocation(
|
||||||
@@ -578,7 +580,7 @@ mod tests {
|
|||||||
protected_file.display(),
|
protected_file.display(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
&[temp_dir.path()],
|
&[dir.as_path()],
|
||||||
&[protected_file.as_path()],
|
&[protected_file.as_path()],
|
||||||
&[],
|
&[],
|
||||||
SandboxPermissions::default(),
|
SandboxPermissions::default(),
|
||||||
@@ -607,7 +609,10 @@ mod tests {
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
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();
|
std::fs::write(&protected_file, "target\n").unwrap();
|
||||||
|
|
||||||
let (program, args, _config_file) = wrap_invocation(
|
let (program, args, _config_file) = wrap_invocation(
|
||||||
@@ -620,7 +625,7 @@ mod tests {
|
|||||||
protected_file.display(),
|
protected_file.display(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
&[temp_dir.path()],
|
&[dir.as_path()],
|
||||||
&[protected_file.as_path()],
|
&[protected_file.as_path()],
|
||||||
&[],
|
&[],
|
||||||
SandboxPermissions {
|
SandboxPermissions {
|
||||||
@@ -870,7 +875,11 @@ mod tests {
|
|||||||
|
|
||||||
let project_dir = tempfile::tempdir().unwrap();
|
let project_dir = tempfile::tempdir().unwrap();
|
||||||
let scratch_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(
|
let (program, args, _config_file) = wrap_invocation(
|
||||||
"/bin/sh",
|
"/bin/sh",
|
||||||
@@ -878,7 +887,7 @@ mod tests {
|
|||||||
"-c".to_string(),
|
"-c".to_string(),
|
||||||
format!("echo 'hello' > '{}'", test_file.display()),
|
format!("echo 'hello' > '{}'", test_file.display()),
|
||||||
],
|
],
|
||||||
&[project_dir.path(), scratch_dir.path()],
|
&[project_path.as_path(), scratch_path.as_path()],
|
||||||
&[],
|
&[],
|
||||||
&[],
|
&[],
|
||||||
SandboxPermissions::default(),
|
SandboxPermissions::default(),
|
||||||
@@ -903,7 +912,10 @@ mod tests {
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
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(
|
let (program, args, _config_file) = wrap_invocation(
|
||||||
"/bin/sh",
|
"/bin/sh",
|
||||||
@@ -911,7 +923,7 @@ mod tests {
|
|||||||
"-c".to_string(),
|
"-c".to_string(),
|
||||||
format!("echo 'hello' > '{}'", test_file.display()),
|
format!("echo 'hello' > '{}'", test_file.display()),
|
||||||
],
|
],
|
||||||
&[temp_dir.path()],
|
&[dir.as_path()],
|
||||||
&[],
|
&[],
|
||||||
&[],
|
&[],
|
||||||
SandboxPermissions::default(),
|
SandboxPermissions::default(),
|
||||||
|
|||||||
+441
-45
@@ -29,6 +29,249 @@ mod windows_wsl;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(crate) const WSL_SANDBOX_UNAVAILABLE_PREFIX: &str = "Windows sandboxing via WSL is unavailable";
|
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
|
/// What a command is allowed to do, expressed as intent. This is the entire
|
||||||
/// public configuration surface; how each policy is enforced (Seatbelt rules,
|
/// public configuration surface; how each policy is enforced (Seatbelt rules,
|
||||||
/// Bubblewrap flags, a loopback proxy, …) is an implementation detail.
|
/// Bubblewrap flags, a loopback proxy, …) is an implementation detail.
|
||||||
@@ -44,9 +287,13 @@ pub struct SandboxPolicy {
|
|||||||
pub enum SandboxFsPolicy {
|
pub enum SandboxFsPolicy {
|
||||||
/// Allow unrestricted filesystem writes.
|
/// Allow unrestricted filesystem writes.
|
||||||
Unrestricted,
|
Unrestricted,
|
||||||
/// Reads are allowed everywhere; writes are confined to these directory
|
/// Reads are allowed everywhere; writes are confined to these locations
|
||||||
/// subtrees (and the standard ephemeral locations the platform provides).
|
/// (and the standard ephemeral locations the platform provides). Each is a
|
||||||
Restricted { writable_paths: Vec<PathBuf> },
|
/// [`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.
|
/// 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;
|
/// `.git` contents are protected: read-only on Linux and Windows/WSL;
|
||||||
/// content reads and writes denied on macOS (metadata stays visible either
|
/// content reads and writes denied on macOS (metadata stays visible either
|
||||||
/// way).
|
/// way).
|
||||||
Denied { git_dirs: Vec<PathBuf> },
|
Denied {
|
||||||
|
git_dirs: Vec<HostFilesystemLocation>,
|
||||||
|
},
|
||||||
/// `.git` contents are writable (these dirs are made writable).
|
/// `.git` contents are writable (these dirs are made writable).
|
||||||
Allowed { git_dirs: Vec<PathBuf> },
|
Allowed {
|
||||||
|
git_dirs: Vec<HostFilesystemLocation>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for GitSandboxPolicy {
|
impl Default for GitSandboxPolicy {
|
||||||
@@ -88,7 +339,7 @@ impl Default for GitSandboxPolicy {
|
|||||||
|
|
||||||
impl GitSandboxPolicy {
|
impl GitSandboxPolicy {
|
||||||
/// The `.git` directories this policy governs, regardless of variant.
|
/// The `.git` directories this policy governs, regardless of variant.
|
||||||
pub fn git_dirs(&self) -> &[PathBuf] {
|
pub fn git_dirs(&self) -> &[HostFilesystemLocation] {
|
||||||
match self {
|
match self {
|
||||||
GitSandboxPolicy::Denied { git_dirs } | GitSandboxPolicy::Allowed { git_dirs } => {
|
GitSandboxPolicy::Denied { git_dirs } | GitSandboxPolicy::Allowed { git_dirs } => {
|
||||||
git_dirs
|
git_dirs
|
||||||
@@ -285,7 +536,7 @@ impl std::error::Error for SandboxError {}
|
|||||||
/// Resolved filesystem setup derived from [`SandboxFsPolicy`].
|
/// Resolved filesystem setup derived from [`SandboxFsPolicy`].
|
||||||
struct FsSetup {
|
struct FsSetup {
|
||||||
allow_fs_write: bool,
|
allow_fs_write: bool,
|
||||||
writable_paths: Vec<PathBuf>,
|
writable_paths: Vec<HostFilesystemLocation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolved network plan derived from [`SandboxNetPolicy`]. For the restricted
|
/// 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
|
/// In-process network proxy for the restricted-network case, spawned on the
|
||||||
/// first `wrap`. Dropped on a background thread (the join blocks).
|
/// first `wrap`. Dropped on a background thread (the join blocks).
|
||||||
proxy: Option<ProxyHandle>,
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
seatbelt_config: Option<macos_seatbelt::SeatbeltConfigFile>,
|
seatbelt_config: Option<macos_seatbelt::SeatbeltConfigFile>,
|
||||||
}
|
}
|
||||||
@@ -353,29 +621,48 @@ impl Sandbox {
|
|||||||
network,
|
network,
|
||||||
git: policy.git,
|
git: policy.git,
|
||||||
proxy: None,
|
proxy: None,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
validation_fd_sender: None,
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
wsl_zed_release: None,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
seatbelt_config: None,
|
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
|
/// actually building a command or spawning the proxy. On Linux this runs a
|
||||||
/// brief `bwrap` probe (call it off the main thread).
|
/// 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")]
|
#[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 {
|
let permissions = linux_bubblewrap::SandboxPermissions {
|
||||||
network: linux_probe_network(&policy.network),
|
network: linux_probe_network(&policy.network),
|
||||||
allow_fs_write: matches!(policy.fs, SandboxFsPolicy::Unrestricted),
|
allow_fs_write: matches!(policy.fs, SandboxFsPolicy::Unrestricted),
|
||||||
};
|
};
|
||||||
linux_bubblewrap::check_can_create_sandbox(&writable, permissions, cwd)
|
linux_bubblewrap::check_can_create_sandbox(permissions).map_err(map_linux_status)
|
||||||
.map_err(map_linux_status)
|
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
let _ = cwd;
|
|
||||||
if matches!(policy.network, SandboxNetPolicy::Restricted { .. }) {
|
if matches!(policy.network, SandboxNetPolicy::Restricted { .. }) {
|
||||||
return Err(unsupported_restricted_network_on_windows());
|
return Err(unsupported_restricted_network_on_windows());
|
||||||
}
|
}
|
||||||
@@ -383,7 +670,7 @@ impl Sandbox {
|
|||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
{
|
{
|
||||||
let _ = (policy, cwd);
|
let _ = policy;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,7 +778,7 @@ impl Sandbox {
|
|||||||
/// is empty (Git protection is moot); otherwise `Allowed` git dirs are
|
/// is empty (Git protection is moot); otherwise `Allowed` git dirs are
|
||||||
/// writable and `Denied` git dirs are protected.
|
/// writable and `Denied` git dirs are protected.
|
||||||
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
|
#[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 {
|
if self.fs.allow_fs_write {
|
||||||
return (Vec::new(), Vec::new());
|
return (Vec::new(), Vec::new());
|
||||||
}
|
}
|
||||||
@@ -520,14 +807,61 @@ impl Sandbox {
|
|||||||
allow_fs_write: self.fs.allow_fs_write,
|
allow_fs_write: self.fs.allow_fs_write,
|
||||||
};
|
};
|
||||||
let (git_writable, git_protected) = self.git_path_split();
|
let (git_writable, git_protected) = self.git_path_split();
|
||||||
let mut writable: Vec<&Path> = self
|
// Build the writable binds as (captured fd, bind path) pairs in lockstep.
|
||||||
.fs
|
// The bind *path* is derived from the pinned inode (readlink of the
|
||||||
.writable_paths
|
// 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()
|
.iter()
|
||||||
.map(PathBuf::as_path)
|
.filter_map(linux_location_path)
|
||||||
.collect();
|
.collect();
|
||||||
writable.extend(git_writable.iter().map(PathBuf::as_path));
|
let protected_git_dirs: Vec<&Path> = protected_owned.iter().map(PathBuf::as_path).collect();
|
||||||
let protected_git_dirs: Vec<&Path> = git_protected.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()
|
let bridge_program = std::env::current_exe()
|
||||||
.map_err(|error| SandboxError::BridgeExecutableUnavailable(error.to_string()))?;
|
.map_err(|error| SandboxError::BridgeExecutableUnavailable(error.to_string()))?;
|
||||||
@@ -547,6 +881,7 @@ impl Sandbox {
|
|||||||
&command.program,
|
&command.program,
|
||||||
&command.args,
|
&command.args,
|
||||||
proxy_socket_path.as_deref(),
|
proxy_socket_path.as_deref(),
|
||||||
|
validation_socket,
|
||||||
)
|
)
|
||||||
.map_err(map_anyhow_error)?;
|
.map_err(map_anyhow_error)?;
|
||||||
|
|
||||||
@@ -576,14 +911,28 @@ impl Sandbox {
|
|||||||
allow_fs_write: self.fs.allow_fs_write,
|
allow_fs_write: self.fs.allow_fs_write,
|
||||||
};
|
};
|
||||||
let (git_writable, git_protected) = self.git_path_split();
|
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
|
let mut writable: Vec<&Path> = self
|
||||||
.fs
|
.fs
|
||||||
.writable_paths
|
.writable_paths
|
||||||
.iter()
|
.iter()
|
||||||
.map(PathBuf::as_path)
|
.map(HostFilesystemLocation::macos_canonical_path)
|
||||||
.collect();
|
.collect();
|
||||||
writable.extend(git_writable.iter().map(PathBuf::as_path));
|
writable.extend(
|
||||||
let protected: Vec<&Path> = git_protected.iter().map(PathBuf::as_path).collect();
|
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(
|
let (program, args, config) = macos_seatbelt::wrap_invocation(
|
||||||
&command.program,
|
&command.program,
|
||||||
&command.args,
|
&command.args,
|
||||||
@@ -614,16 +963,34 @@ impl Sandbox {
|
|||||||
allow_network: matches!(self.network, NetSetup::Unrestricted),
|
allow_network: matches!(self.network, NetSetup::Unrestricted),
|
||||||
allow_fs_write: self.fs.allow_fs_write,
|
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(
|
let (program, args) = windows_wsl::wrap_invocation(
|
||||||
command.program.clone(),
|
command.program.clone(),
|
||||||
command.args.clone(),
|
command.args.clone(),
|
||||||
self.fs.writable_paths.clone(),
|
writable_paths,
|
||||||
writable_git_paths,
|
writable_git_paths,
|
||||||
protected_git_paths,
|
protected_git_paths,
|
||||||
permissions,
|
permissions,
|
||||||
command.cwd.clone(),
|
command.cwd.clone(),
|
||||||
command.env.clone(),
|
command.env.clone(),
|
||||||
|
self.wsl_zed_release.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(map_anyhow_error)?;
|
.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
|
/// Only referenced by those two cfg-gated modules, so it's gated to match;
|
||||||
/// sandbox network namespace before spawning the real command. Call this at the
|
/// otherwise it's dead code on macOS.
|
||||||
/// top of `main`, before normal argument parsing.
|
#[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)]
|
#[doc(hidden)]
|
||||||
pub fn run_sandbox_launcher_if_invoked() {
|
pub fn run_sandbox_launcher_if_invoked() {
|
||||||
#[cfg(target_os = "linux")]
|
#[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")]
|
#[cfg(target_os = "linux")]
|
||||||
fn policy_writable_paths(policy: &SandboxPolicy) -> Vec<PathBuf> {
|
fn linux_location_path(location: &HostFilesystemLocation) -> Option<PathBuf> {
|
||||||
match &policy.fs {
|
use std::os::fd::AsRawFd as _;
|
||||||
SandboxFsPolicy::Unrestricted => Vec::new(),
|
std::fs::read_link(format!("/proc/self/fd/{}", location.linux_fd().as_raw_fd())).ok()
|
||||||
SandboxFsPolicy::Restricted { writable_paths } => writable_paths.clone(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
@@ -842,20 +1233,25 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fs_merge_unrestricted_dominates_else_unions_paths() {
|
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 {
|
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 {
|
let b = SandboxFsPolicy::Restricted {
|
||||||
writable_paths: vec![PathBuf::from("/b"), PathBuf::from("/c")],
|
writable_paths: vec![location(&dir_b), location(&dir_c)],
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
a.clone().merge(b),
|
a.clone().merge(b),
|
||||||
SandboxFsPolicy::Restricted {
|
SandboxFsPolicy::Restricted {
|
||||||
writable_paths: vec![
|
writable_paths: vec![location(&dir_a), location(&dir_b), location(&dir_c)],
|
||||||
PathBuf::from("/a"),
|
|
||||||
PathBuf::from("/b"),
|
|
||||||
PathBuf::from("/c")
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -864,7 +1260,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SandboxFsPolicy::Unrestricted.merge(SandboxFsPolicy::Restricted {
|
SandboxFsPolicy::Unrestricted.merge(SandboxFsPolicy::Restricted {
|
||||||
writable_paths: vec![PathBuf::from("/a")],
|
writable_paths: vec![location(&dir_a)],
|
||||||
}),
|
}),
|
||||||
SandboxFsPolicy::Unrestricted
|
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.
|
/// of any stdout noise printed by the login shell's profile scripts.
|
||||||
const PROBE_RESULT_PREFIX: &str = "zed-wsl-probe:";
|
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
|
/// 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
|
/// 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
|
/// 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,
|
permissions: SandboxPermissions,
|
||||||
cwd: Option<PathBuf>,
|
cwd: Option<PathBuf>,
|
||||||
env: HashMap<String, String, S>,
|
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>)> {
|
) -> Result<(String, Vec<String>)> {
|
||||||
// Mapping failures are bad requests (a path that doesn't exist or has a
|
// Mapping failures are bad requests (a path that doesn't exist or has a
|
||||||
// shape WSL can't address), not environment problems, so no
|
// 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 {
|
if let Some(cwd) = &cwd {
|
||||||
wsl_args.extend(["--cd".to_string(), cwd.clone()]);
|
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
|
// The bwrap *options* (everything before the trailing `-- cmd`): root bind,
|
||||||
// where the probe's login shell found `bwrap`.
|
// `/tmp` tmpfs, writable binds, interop blocking, `--setenv`s, `--chdir`,
|
||||||
wsl_args.extend(["--exec".to_string(), environment.bwrap_path.clone()]);
|
// namespace flags. Identical whether or not we route through the helper.
|
||||||
wsl_args.extend(build_bwrap_args(
|
let bwrap_options = build_bwrap_args(
|
||||||
&writable_paths,
|
&writable_paths,
|
||||||
&protected_git_paths,
|
&protected_git_paths,
|
||||||
permissions,
|
permissions,
|
||||||
cwd.as_deref(),
|
cwd.as_deref(),
|
||||||
environment.mask_interop_dir,
|
environment.mask_interop_dir,
|
||||||
&env,
|
&env,
|
||||||
));
|
);
|
||||||
wsl_args.push("--".to_string());
|
|
||||||
wsl_args.push(program);
|
match wsl_zed_release {
|
||||||
wsl_args.extend(args);
|
// 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))
|
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
|
/// Shell script that resolves and existence-checks paths in a single WSL
|
||||||
/// round-trip. Arguments come in triples `(kind, path, fallback)`: kind `W`
|
/// round-trip. Arguments come in triples `(kind, path, fallback)`: kind `W`
|
||||||
/// is a native Windows path to translate with `wslpath -u` (falling back to
|
/// is a native Windows path to translate with `wslpath -u` (falling back to
|
||||||
@@ -1105,6 +1324,7 @@ mod tests {
|
|||||||
SandboxPermissions::default(),
|
SandboxPermissions::default(),
|
||||||
None,
|
None,
|
||||||
HashMap::<String, String>::new(),
|
HashMap::<String, String>::new(),
|
||||||
|
None,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ mod imp {
|
|||||||
|
|
||||||
use anyhow::{Context as _, Result, bail, ensure};
|
use anyhow::{Context as _, Result, bail, ensure};
|
||||||
use sandbox::{
|
use sandbox::{
|
||||||
CommandAndArgs, GitSandboxPolicy, Sandbox, SandboxError, SandboxFsPolicy, SandboxNetPolicy,
|
CommandAndArgs, GitSandboxPolicy, HostFilesystemLocation, Sandbox, SandboxError,
|
||||||
SandboxPolicy,
|
SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Network access for a helper run, translated into a `SandboxNetPolicy` in
|
/// Network access for a helper run, translated into a `SandboxNetPolicy` in
|
||||||
@@ -513,7 +513,12 @@ mod imp {
|
|||||||
SandboxFsPolicy::Unrestricted
|
SandboxFsPolicy::Unrestricted
|
||||||
} else {
|
} else {
|
||||||
SandboxFsPolicy::Restricted {
|
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 {
|
network: match permissions.network {
|
||||||
|
|||||||
+12
-1
@@ -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.
|
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.
|
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:
|
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`).
|
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
|
## Images and videos
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -106,6 +106,6 @@ enable = false
|
|||||||
# and other docs-related functions.
|
# and other docs-related functions.
|
||||||
#
|
#
|
||||||
# Comment the below section out if you need to bypass the preprocessor for some reason.
|
# 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 --"
|
command = "cargo run -p docs_preprocessor --"
|
||||||
renderer = ["html", "zed-html"]
|
renderer = ["html", "zed-html"]
|
||||||
@@ -311,6 +311,13 @@ craneLib.buildPackage (
|
|||||||
lib.recursiveUpdate commonArgs {
|
lib.recursiveUpdate commonArgs {
|
||||||
inherit cargoArtifacts;
|
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;
|
dontUseCmakeConfigure = true;
|
||||||
|
|
||||||
# without the env var generate-licenses fails due to crane's fetchCargoVendor, see:
|
# without the env var generate-licenses fails due to crane's fetchCargoVendor, see:
|
||||||
|
|||||||
Generated
+17
@@ -16,8 +16,25 @@
|
|||||||
"type": "github"
|
"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": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"nixpkgs-mdbook": "nixpkgs-mdbook",
|
||||||
"treefmt-nix": "treefmt-nix"
|
"treefmt-nix": "treefmt-nix"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
treefmt-nix.url = "github:numtide/treefmt-nix";
|
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.
|
# This flake is only used for its inputs.
|
||||||
|
|||||||
@@ -1,13 +1,41 @@
|
|||||||
{ inputs, ... }:
|
{ inputs, ... }:
|
||||||
{
|
{
|
||||||
perSystem =
|
perSystem =
|
||||||
{ pkgs, ... }:
|
{ pkgs, system, ... }:
|
||||||
let
|
let
|
||||||
# NOTE: Duplicated because this is in a separate flake-parts partition
|
# NOTE: Duplicated because this is in a separate flake-parts partition
|
||||||
# than ./packages.nix
|
# than ./packages.nix
|
||||||
mkZed = import ../toolchain.nix { inherit inputs; };
|
mkZed = import ../toolchain.nix { inherit inputs; };
|
||||||
zed-editor = mkZed pkgs;
|
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;
|
rustBin = inputs.rust-overlay.lib.mkRustBin { } pkgs;
|
||||||
rustToolchain = rustBin.fromRustupToolchainFile ../../rust-toolchain.toml;
|
rustToolchain = rustBin.fromRustupToolchainFile ../../rust-toolchain.toml;
|
||||||
|
|
||||||
@@ -54,6 +82,10 @@
|
|||||||
nodejs_22
|
nodejs_22
|
||||||
zig
|
zig
|
||||||
|
|
||||||
|
# Documentation tooling: `nix develop -c mdbook build docs`
|
||||||
|
mdbook
|
||||||
|
docs-preprocessor
|
||||||
|
|
||||||
# A11y testing infra
|
# A11y testing infra
|
||||||
gobject-introspection
|
gobject-introspection
|
||||||
at-spi2-core
|
at-spi2-core
|
||||||
@@ -81,6 +113,14 @@
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
PROTOC = "${pkgs.protobuf}/bin/protoc";
|
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";
|
ZED_ZSTD_MUSL_LIB = "${pkgs.pkgsCross.musl64.pkgsStatic.zstd.out}/lib";
|
||||||
# For aws-lc-sys musl cross-compilation
|
# For aws-lc-sys musl cross-compilation
|
||||||
CC_x86_64_unknown_linux_musl = "${muslCross.stdenv.cc}/bin/x86_64-unknown-linux-musl-gcc";
|
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"
|
# writablePaths = [ ]; # writable subtrees when fs = "restricted"
|
||||||
# networkAccess = "blocked"; # or "unrestricted" / "restricted"
|
# networkAccess = "blocked"; # or "unrestricted" / "restricted"
|
||||||
# allowedDomains = [ ]; # allowed hosts when networkAccess = "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
|
# Two echo servers (`echo1`, `echo2`) on separate nodes give the network checks
|
||||||
# real peers, so a restricted-network policy that allowlists `echo1` can be
|
# real peers, so a restricted-network policy that allowlists `echo1` can be
|
||||||
@@ -232,6 +237,138 @@ in
|
|||||||
succeeds = true;
|
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.
|
# Blocked network: the echo server is unreachable.
|
||||||
{
|
{
|
||||||
fs = "restricted";
|
fs = "restricted";
|
||||||
|
|||||||
Reference in new issue
Block a user