Allow sandboxed Git writes from worktrees (#57981)

Agent terminal sandboxing now protects `.git` metadata by default and
exposes an explicit `allow_git_access` approval. Without it, file
contents of the `.git` directories for opened worktrees and discovered
repositories (including a linked worktree's common `.git`) cannot be
read or written, though their metadata stays visible; when approved,
those Git directories become writable so commands like fetch/commit
work. SSH commit signing keeps working because the inherited
`SSH_AUTH_SOCK` is allowed as local Unix-socket IPC, which does not let
sandboxed commands send network packets to other machines. The Seatbelt
profile also allows PTY terminal-control ioctls so signing/passphrase
prompts can manage terminal state.

This intentionally restricts only `.git` itself (whose location we know
exactly, including the worktree case) rather than
`.gitignore`/`.gitattributes`/`.gitmodules`, since those can be nested
arbitrarily and the goal is to keep the policy expressible as a plain
deny-by-default allowlist that will port to Linux and Windows sandboxes
later.

Closes AI-334

Release Notes:

- Improved agent terminal sandboxing for Git metadata, Git worktrees,
and SSH commit signing.
This commit is contained in:
Richard Feldman authored and GitHub committed 2026-06-23 15:20:21 +00:00
1 parent c9a39298a5
commit e2b15e11e2
17 files changed
+1168 -77

No files matched your search

Generated
+1
View File
@@ -127,6 +127,7 @@ dependencies = [
"settings",
"task",
"telemetry",
"tempfile",
"terminal",
"text",
"ui",
+1
View File
@@ -61,5 +61,6 @@ indoc.workspace = true
parking_lot.workspace = true
project = { workspace = true, "features" = ["test-support"] }
rand.workspace = true
tempfile.workspace = true
util.workspace = true
settings.workspace = true
+84
View File
@@ -60,6 +60,45 @@ impl std::fmt::Display for MaxOutputTokensError {
impl std::error::Error for MaxOutputTokensError {}
/// Resolve the socket path(s) the sandbox may allow for the inherited
/// `SSH_AUTH_SOCK`. The value is validated against the resolved directory
/// environment the child command will actually run with, not Zed's own process
/// environment, since the two can differ (direnv, a login shell, etc.).
///
/// Returns both the path as the child will pass it to `connect()` and its
/// canonical form, because the two diverge when `SSH_AUTH_SOCK` points through
/// a symlink (e.g. `/tmp` -> `/private/tmp` on macOS) and it isn't guaranteed
/// which form Seatbelt matches the `remote unix-socket` literal against.
#[cfg(unix)]
fn trusted_ssh_auth_socket_paths(path: impl Into<PathBuf>) -> Vec<PathBuf> {
let path = path.into();
if !path.is_absolute() {
return Vec::new();
}
let Ok(canonical) = path.canonicalize() else {
return Vec::new();
};
let Ok(metadata) = std::fs::metadata(&canonical) else {
return Vec::new();
};
use std::os::unix::fs::FileTypeExt as _;
if !metadata.file_type().is_socket() {
return Vec::new();
}
if canonical == path {
vec![canonical]
} else {
vec![path, canonical]
}
}
#[cfg(not(unix))]
fn trusted_ssh_auth_socket_paths(_path: impl Into<PathBuf>) -> Vec<PathBuf> {
Vec::new()
}
/// Key used in ACP ToolCall meta to store the tool's programmatic name.
/// This is a workaround since ACP's ToolCall doesn't have a dedicated name field.
pub const TOOL_NAME_META_KEY: &str = "tool_name";
@@ -176,6 +215,8 @@ pub struct SandboxAuthorizationDetails {
#[serde(default, alias = "network")]
pub network_all_hosts: bool,
#[serde(default)]
pub allow_git_access: bool,
#[serde(default)]
pub allow_fs_write_all: bool,
#[serde(default)]
pub unsandboxed: bool,
@@ -3489,6 +3530,21 @@ impl AcpThread {
let terminal_id = terminal_id.clone();
async move |_this, cx| {
let mut env = env.await;
let mut sandbox_wrap = sandbox_wrap;
// Only expose the inherited SSH agent socket once Git access has
// been approved. The workflow that needs it (commit signing)
// already requires writing to `.git`, so gating it here keeps the
// agent socket out of reach of ordinary sandboxed commands.
// Validate the value the child will actually connect to, taken
// from its resolved environment rather than Zed's own.
if let Some(sandbox_wrap) = &mut sandbox_wrap
&& sandbox_wrap.allow_git_access
&& let Some(ssh_auth_socket) = env.get("SSH_AUTH_SOCK")
{
sandbox_wrap
.allowed_unix_socket_paths
.extend(trusted_ssh_auth_socket_paths(ssh_auth_socket.clone()));
}
let shell = project
.update(cx, |project, cx| {
project
@@ -3898,6 +3954,34 @@ mod tests {
});
}
#[cfg(unix)]
#[test]
fn test_trusted_ssh_auth_socket_path_requires_socket() {
use std::os::unix::net::UnixListener;
// Bind under `/tmp`: the default temp dir on macOS (`/var/folders/...`)
// overflows the `sun_path` limit for Unix sockets. `TempDir` still
// cleans up on drop, even if the test panics.
let temp_dir = tempfile::Builder::new()
.prefix("zed-sock-")
.tempdir_in("/tmp")
.expect("temporary socket directory should be created");
let socket_path = temp_dir.path().join("agent.sock");
let listener = UnixListener::bind(&socket_path).expect("test socket should bind");
// A real socket resolves to (at least) its canonical path.
let canonical = socket_path
.canonicalize()
.expect("socket path should canonicalize");
assert!(trusted_ssh_auth_socket_paths(socket_path).contains(&canonical));
// A directory is not a socket, and a relative path is rejected outright.
assert!(trusted_ssh_auth_socket_paths(temp_dir.path().to_path_buf()).is_empty());
assert!(trusted_ssh_auth_socket_paths("relative.sock").is_empty());
drop(listener);
}
#[test]
fn sandbox_authorization_details_deserialize_legacy_network_bool() {
// Older builds persisted `network: bool`; the `alias` on
+71 -15
View File
@@ -35,9 +35,10 @@ use util::get_default_system_shell_preferring_bash;
#[derive(Clone, Debug, Default)]
pub struct SandboxWrap {
/// Directory subtrees the sandbox should allow writes to. Pass the
/// project's worktree paths (and any per-command scratch directory)
/// here — *not* the command's working directory, which is model-
/// controlled and would let the model widen its own writable scope.
/// project's worktree paths, Git metadata directories only when Git access
/// has been approved, and any per-command scratch directory here — *not*
/// the command's working directory, which is model-controlled and would
/// let the model widen its own writable scope.
pub writable_paths: Vec<PathBuf>,
/// Additional write subtrees the user explicitly approved for this
/// command (per-path write grants). Kept separate from `writable_paths`
@@ -45,6 +46,17 @@ pub struct SandboxWrap {
/// model-requested paths that passed a user-approval prompt. They are
/// merged with `writable_paths` when generating the sandbox policy.
pub extra_write_paths: Vec<PathBuf>,
/// Paths whose file data reads and writes should be blocked even when they
/// are inside a writable project directory. Metadata reads remain allowed.
pub protected_paths: Vec<PathBuf>,
/// Unix domain sockets the sandbox should allow local IPC to. These come
/// from trusted process environment, not from the model-controlled command.
/// This does not permit IP networking or sending packets to other machines.
pub allowed_unix_socket_paths: Vec<PathBuf>,
/// Whether the user approved Git metadata access for this command. When set,
/// `.git` directories are writable (they are absent from `protected_paths`)
/// and the inherited SSH agent socket may be exposed for commit signing.
pub allow_git_access: bool,
/// Outbound network access explicitly approved for this command.
pub network: SandboxNetworkAccess,
/// Allow unrestricted filesystem writes (ignores all writable paths).
@@ -143,21 +155,37 @@ impl SandboxWrap {
.chain(self.extra_write_paths.iter())
.map(|path| path.as_path())
.collect();
let protected: Vec<&std::path::Path> = self
.protected_paths
.iter()
.map(|path| path.as_path())
.collect();
let allowed_unix_sockets: Vec<&std::path::Path> = self
.allowed_unix_socket_paths
.iter()
.map(|path| path.as_path())
.collect();
let allow_network = !matches!(self.network, SandboxNetworkAccess::None);
let permissions = sandbox::SandboxPermissions {
allow_network,
allow_fs_write: self.allow_fs_write,
};
sandbox::linux_bubblewrap::check_can_create_sandbox(&writable, permissions, cwd)
.map_err(|status| match status {
LauncherStatus::BwrapNotFound => LinuxWslSandboxError::BwrapNotFound,
LauncherStatus::SetuidRejected => LinuxWslSandboxError::SetuidRejected,
LauncherStatus::SandboxProbeFailed => LinuxWslSandboxError::SandboxProbeFailed,
// `Success` never appears in the `Err` arm; map defensively.
LauncherStatus::Success => {
LinuxWslSandboxError::Other(status.describe().to_string())
}
})
sandbox::linux_bubblewrap::check_can_create_sandbox(
&writable,
&protected,
&allowed_unix_sockets,
permissions,
cwd,
)
.map_err(|status| match status {
LauncherStatus::BwrapNotFound => LinuxWslSandboxError::BwrapNotFound,
LauncherStatus::SetuidRejected => LinuxWslSandboxError::SetuidRejected,
LauncherStatus::SandboxProbeFailed => LinuxWslSandboxError::SandboxProbeFailed,
// `Success` never appears in the `Err` arm; map defensively.
LauncherStatus::Success => {
LinuxWslSandboxError::Other(status.describe().to_string())
}
})
}
#[cfg(not(target_os = "linux"))]
{
@@ -252,6 +280,16 @@ pub(crate) fn apply_sandbox_wrap(
.chain(sandbox_wrap.extra_write_paths.iter())
.map(|p| p.as_path())
.collect();
let protected: Vec<&std::path::Path> = sandbox_wrap
.protected_paths
.iter()
.map(|path| path.as_path())
.collect();
let allowed_unix_sockets: Vec<&std::path::Path> = sandbox_wrap
.allowed_unix_socket_paths
.iter()
.map(|path| path.as_path())
.collect();
let network = match network_policy {
NetworkPolicy::Proxied(port) => NetworkAccess::LocalhostPort(port),
NetworkPolicy::Unrestricted => NetworkAccess::All,
@@ -261,8 +299,14 @@ pub(crate) fn apply_sandbox_wrap(
network,
allow_fs_write: sandbox_wrap.allow_fs_write,
};
let (new_program, new_args, config_file) =
sandbox::macos_seatbelt::wrap_invocation(&program, &args, &writable, permissions)?;
let (new_program, new_args, config_file) = sandbox::macos_seatbelt::wrap_invocation(
&program,
&args,
&writable,
&protected,
&allowed_unix_sockets,
permissions,
)?;
Ok((
new_program,
new_args,
@@ -280,6 +324,16 @@ pub(crate) fn apply_sandbox_wrap(
.chain(sandbox_wrap.extra_write_paths.iter())
.map(|p| p.as_path())
.collect();
let protected: Vec<&std::path::Path> = sandbox_wrap
.protected_paths
.iter()
.map(|p| p.as_path())
.collect();
let allowed_unix_sockets: Vec<&std::path::Path> = sandbox_wrap
.allowed_unix_socket_paths
.iter()
.map(|p| p.as_path())
.collect();
let allow_network = match network_policy {
NetworkPolicy::Denied => false,
NetworkPolicy::Unrestricted => true,
@@ -318,6 +372,8 @@ pub(crate) fn apply_sandbox_wrap(
Some(channel.name()),
permissions,
&writable,
&protected,
&allowed_unix_sockets,
cwd,
&program,
&args,
+42
View File
@@ -107,6 +107,8 @@ impl NetworkRequest {
pub(crate) struct SandboxRequest {
/// Outbound network access requested for this command.
pub network: NetworkRequest,
/// Allow access to protected Git metadata paths.
pub allow_git_access: bool,
/// Allow unrestricted filesystem writes (the broad escape hatch).
pub allow_fs_write_all: bool,
/// Run the command fully outside the sandbox.
@@ -121,6 +123,7 @@ impl SandboxRequest {
/// scope, and therefore needs user approval.
pub fn needs_escalation(&self) -> bool {
self.network.is_requested()
|| self.allow_git_access
|| self.allow_fs_write_all
|| self.unsandboxed
|| !self.write_paths.is_empty()
@@ -141,6 +144,7 @@ pub(crate) struct ThreadSandboxGrants {
/// Host patterns granted network access for the thread. Each covers its
/// whole subdomain space; redundant entries are pruned on insert.
network_hosts: Vec<HostPattern>,
allow_git_access: bool,
allow_fs_write_all: bool,
unsandboxed: bool,
/// Whether the user approved running commands *without* a sandbox for the
@@ -181,6 +185,9 @@ impl ThreadSandboxGrants {
if !self.network_covered(&request.network, persistent) {
return false;
}
if request.allow_git_access && !(self.allow_git_access || persistent.allow_git_access) {
return false;
}
if request.allow_fs_write_all && !(self.allow_fs_write_all || persistent.allow_fs_write_all)
{
return false;
@@ -250,6 +257,7 @@ impl ThreadSandboxGrants {
}
}
}
self.allow_git_access |= request.allow_git_access;
self.allow_fs_write_all |= request.allow_fs_write_all;
self.unsandboxed |= request.unsandboxed;
for path in &request.write_paths {
@@ -301,6 +309,9 @@ impl ThreadSandboxGrants {
}
SandboxRequest {
network,
allow_git_access: persistent.allow_git_access
|| self.allow_git_access
|| request.allow_git_access,
allow_fs_write_all: persistent.allow_fs_write_all
|| self.allow_fs_write_all
|| request.allow_fs_write_all,
@@ -354,6 +365,7 @@ mod tests {
fn request(network: NetworkRequest, all: bool, paths: &[&str]) -> SandboxRequest {
SandboxRequest {
network,
allow_git_access: false,
allow_fs_write_all: all,
unsandboxed: false,
write_paths: paths.iter().map(PathBuf::from).collect(),
@@ -363,6 +375,7 @@ mod tests {
fn unsandboxed_request() -> SandboxRequest {
SandboxRequest {
network: NetworkRequest::None,
allow_git_access: false,
allow_fs_write_all: false,
unsandboxed: true,
write_paths: Vec::new(),
@@ -537,6 +550,33 @@ mod tests {
assert!(!covers(&grants, &request(NetworkRequest::None, true, &[])));
}
#[test]
fn git_access_grant_tracked_independently() {
let mut git_request = request(NetworkRequest::None, false, &[]);
git_request.allow_git_access = true;
let mut grants = ThreadSandboxGrants::default();
assert!(!covers(&grants, &git_request));
grants.record(&git_request);
assert!(covers(&grants, &git_request));
assert!(!covers(
&grants,
&request(NetworkRequest::AnyHost, false, &[])
));
assert!(!covers(&grants, &request(NetworkRequest::None, true, &[])));
}
#[test]
fn unrestricted_writes_do_not_cover_git_access() {
let mut grants = ThreadSandboxGrants::default();
grants.record(&request(NetworkRequest::None, true, &[]));
let mut git_request = request(NetworkRequest::None, false, &[]);
git_request.allow_git_access = true;
assert!(!covers(&grants, &git_request));
}
#[test]
fn persistent_grants_combine_with_thread_grants() {
let mut grants = ThreadSandboxGrants::default();
@@ -669,6 +709,7 @@ mod tests {
let grants = ThreadSandboxGrants::default();
let persistent = SandboxPermissions {
allow_all_hosts: true,
allow_git_access: true,
write_paths: vec![PathBuf::from("/tmp/always")],
..Default::default()
};
@@ -676,6 +717,7 @@ mod tests {
let effective = grants
.effective_with_persistent(&request(NetworkRequest::None, false, &[]), &persistent);
assert_eq!(effective.network, NetworkRequest::AnyHost);
assert!(effective.allow_git_access);
assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/always")]);
}
+7
View File
@@ -209,8 +209,15 @@ mod tests {
assert!(rendered.contains("allow_hosts"));
assert!(rendered.contains("allow_all_hosts: true"));
assert!(rendered.contains("fs_write_paths"));
assert!(rendered.contains("allow_git_access: true"));
assert!(rendered.contains("allow_fs_write_all: true"));
assert!(rendered.contains("unsandboxed: true"));
assert!(rendered.contains("file contents under `.git` directories"));
assert!(
rendered.contains("worktree metadata that may live outside the project directories")
);
assert!(rendered.contains("inherited SSH agent socket"));
assert!(rendered.contains("does not allow packet-sending network access"));
assert!(rendered.contains("for the rest of the thread"));
}
+4 -3
View File
@@ -158,13 +158,13 @@ The current project contains the following root directories:
The `terminal` tool runs commands inside a sandbox with these permissions:
- Reads: any path on the filesystem is readable.
- Reads: any path on the filesystem is readable, except that file contents under `.git` directories are blocked by default (their metadata stays visible). See `allow_git_access` below.
{{#if is_linux}}
- Writes: `/tmp` is writable but is cleared between `terminal` calls{{#if worktrees}}. These project directories are also writable and persist across calls:
{{#each worktrees}}
- `{{abs_path}}`
{{/each}}
Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}}
`.git` directories are not writable by default. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}}
{{else}}
{{#if is_windows}}
- Execution: commands run inside WSL under Bubblewrap. Native Windows project paths are routed through WSL's `/mnt/<drive>/...` filesystem view.
@@ -178,7 +178,7 @@ The `terminal` tool runs commands inside a sandbox with these permissions:
{{#each worktrees}}
- `{{abs_path}}`
{{/each}}
Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}}
`.git` directories are not writable by default. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}}
{{/if}}
{{/if}}
- Network: outbound network access is blocked.
@@ -208,6 +208,7 @@ You can request elevated permissions on individual `terminal` calls:
{{/if}}
{{/if}}
- `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write.
- `allow_git_access: true` — lift the default block on Git metadata, allowing reads and writes of file contents under `.git` directories (including worktree metadata that may live outside the project directories). Any command that touches `.git` needs this, including ones that invoke Git only incidentally (e.g. build scripts calling `git describe`/`git rev-parse`, or commit hooks). When approved, the inherited SSH agent socket also becomes reachable for SSH-based Git commit signing (local Unix-socket IPC only — it does not allow packet-sending network access); GPG commit signing via gpg-agent is not available in the sandbox.
- `allow_fs_write_all: true` — allow unrestricted filesystem writes. Only use this when the specific paths can't be enumerated up front.
- `unsandboxed: true` — run the command with no sandbox at all. Use only when none of the above suffice.
+6
View File
@@ -5371,6 +5371,7 @@ impl ToolCallEventStream {
command,
network_hosts,
network_all_hosts,
allow_git_access: request.allow_git_access,
allow_fs_write_all: request.allow_fs_write_all,
unsandboxed: request.unsandboxed,
write_paths: request.write_paths.clone(),
@@ -5564,6 +5565,9 @@ impl ToolCallEventStream {
agent.set_sandbox_network_hosts(host_strings);
}
}
if request.allow_git_access {
agent.allow_sandbox_git_access();
}
if request.allow_fs_write_all {
agent.allow_sandbox_fs_write_all();
}
@@ -7179,6 +7183,7 @@ mod tests {
let (event_stream, mut receiver) = ToolCallEventStream::test();
let request = SandboxRequest {
network: crate::sandboxing::NetworkRequest::None,
allow_git_access: false,
allow_fs_write_all: false,
unsandboxed: false,
write_paths: vec![
@@ -7204,6 +7209,7 @@ mod tests {
.expect("sandbox authorization should include request details");
assert!(details.network_hosts.is_empty());
assert!(!details.network_all_hosts);
assert_eq!(details.allow_git_access, request.allow_git_access);
assert_eq!(details.allow_fs_write_all, request.allow_fs_write_all);
assert_eq!(details.unsandboxed, request.unsandboxed);
assert_eq!(details.write_paths, request.write_paths);
+181 -13
View File
@@ -161,13 +161,21 @@ pub struct SandboxedTerminalToolInput {
/// set of paths is known. Requesting it triggers a user approval prompt.
#[serde(default, alias = "allow_fs_write")]
pub allow_fs_write_all: Option<bool>,
/// Set to `true` when the command needs Git metadata access.
///
/// Sandboxed commands cannot read file contents from, or write to, protected
/// `.git` paths for opened worktrees and discovered repositories by default.
/// Set this for Git operations that need those paths. Requesting it
/// triggers a user approval prompt.
#[serde(default)]
pub allow_git_access: Option<bool>,
/// Set to `true` only as a last resort, to run the command fully outside
/// the sandbox.
///
/// First try the narrower options (`allow_hosts`, `fs_write_paths`,
/// `allow_fs_write_all`); use this only when the command needs behavior
/// the sandbox can't grant on a per-permission basis. Requesting it
/// triggers a user approval prompt.
/// `allow_fs_write_all`, `allow_git_access`); use this only when the command
/// needs behavior the sandbox can't grant on a per-permission basis.
/// Requesting it triggers a user approval prompt.
#[cfg_attr(
target_os = "windows",
doc = "\nOn Windows, running unsandboxed also switches the shell. Sandboxed \
@@ -180,8 +188,9 @@ pub struct SandboxedTerminalToolInput {
#[serde(default)]
pub unsandboxed: Option<bool>,
/// A short justification for why this command needs the sandbox
/// permission(s) it requests (`allow_network`, `fs_write_paths`,
/// `allow_fs_write_all`, or `unsandboxed`).
/// permission(s) it requests (`allow_hosts`, `allow_all_hosts`,
/// `fs_write_paths`, `allow_fs_write_all`, `allow_git_access`, or
/// `unsandboxed`).
///
/// Required whenever you request any of those permissions; omit it for
/// ordinary commands that request none. Write it in your own voice — it
@@ -197,6 +206,7 @@ struct TerminalSandboxInput {
allow_all_hosts: Option<bool>,
fs_write_paths: Vec<String>,
allow_fs_write_all: Option<bool>,
allow_git_access: Option<bool>,
unsandboxed: Option<bool>,
reason: Option<String>,
}
@@ -239,6 +249,7 @@ impl From<SandboxedTerminalToolInput> for TerminalToolRequest {
allow_all_hosts: input.allow_all_hosts,
fs_write_paths: input.fs_write_paths,
allow_fs_write_all: input.allow_fs_write_all,
allow_git_access: input.allow_git_access,
unsandboxed: input.unsandboxed,
reason: input.reason,
}),
@@ -382,6 +393,7 @@ async fn run_terminal_tool(
authorize.await.map_err(|e| e.to_string())?;
let want_git_access = sandboxing && sandbox_input.allow_git_access == Some(true);
let want_fs_write_all = sandboxing && sandbox_input.allow_fs_write_all == Some(true);
let want_unsandboxed = sandboxing && sandbox_input.unsandboxed == Some(true);
@@ -441,6 +453,7 @@ async fn run_terminal_tool(
let request = crate::sandboxing::SandboxRequest {
network,
allow_git_access: !want_unsandboxed && want_git_access,
allow_fs_write_all: !want_unsandboxed && want_fs_write_all,
unsandboxed: want_unsandboxed,
write_paths,
@@ -510,16 +523,14 @@ async fn run_terminal_tool(
None
} else {
let effective = event_stream.effective_sandbox_request(&request, &sandbox_permissions);
let writable_paths: Vec<PathBuf> = cx.update(|cx| {
project
.read(cx)
.worktrees(cx)
.map(|w| w.read(cx).abs_path().to_path_buf())
.collect::<Vec<_>>()
});
let sandbox_paths =
cx.update(|cx| sandbox_paths(project.read(cx), effective.allow_git_access, cx));
let wrap = acp_thread::SandboxWrap {
writable_paths,
writable_paths: sandbox_paths.writable_paths,
extra_write_paths: effective.write_paths,
protected_paths: sandbox_paths.protected_paths,
allowed_unix_socket_paths: Vec::new(),
allow_git_access: effective.allow_git_access,
network: network_request_to_sandbox_network_access(&effective.network),
allow_fs_write: effective.allow_fs_write_all,
is_local: is_local_project,
@@ -969,6 +980,9 @@ fn sandbox_approval_title(request: &crate::sandboxing::SandboxRequest) -> String
if let Some(network_clause) = network_clause(&request.network) {
parts.push(network_clause);
}
if request.allow_git_access {
parts.push("Git metadata access".to_string());
}
if request.allow_fs_write_all {
parts.push("unrestricted filesystem writes".to_string());
} else if !request.write_paths.is_empty() {
@@ -1166,6 +1180,59 @@ fn process_content(
content
}
struct SandboxPaths {
writable_paths: Vec<PathBuf>,
protected_paths: Vec<PathBuf>,
}
fn sandbox_paths(project: &Project, allow_git_access: bool, cx: &App) -> SandboxPaths {
let mut writable_paths = Vec::new();
let mut git_paths = Vec::new();
for worktree in project.worktrees(cx) {
let worktree = worktree.read(cx);
let worktree_abs_path = worktree.abs_path();
writable_paths.push(worktree_abs_path.to_path_buf());
// Protect `<worktree>/.git` even when it doesn't exist yet, so a command
// can't `git init` and then write to the freshly created metadata.
git_paths.push(worktree_abs_path.join(".git"));
// `Worktree` derefs to `Snapshot`; read the field directly instead of
// cloning the whole snapshot just for this path.
if let Some(root_repo_common_dir) = worktree.root_repo_common_dir() {
git_paths.push(root_repo_common_dir.to_path_buf());
}
}
// `Repository` derefs to `RepositorySnapshot`, so read the few path fields
// directly rather than cloning the entire snapshot (which carries the
// per-path status tree) for each repository.
for repository in project.git_store().read(cx).repositories().values() {
let repository = repository.read(cx);
git_paths.push(repository.dot_git_abs_path.to_path_buf());
git_paths.push(repository.repository_dir_abs_path.to_path_buf());
git_paths.push(repository.common_dir_abs_path.to_path_buf());
}
git_paths.sort();
git_paths.dedup();
let protected_paths = if allow_git_access {
writable_paths.extend(git_paths);
Vec::new()
} else {
git_paths
};
writable_paths.sort();
writable_paths.dedup();
SandboxPaths {
writable_paths,
protected_paths,
}
}
fn working_dir(cd: &str, project: &Entity<Project>, cx: &mut App) -> Result<Option<PathBuf>> {
let project = project.read(cx);
@@ -1203,6 +1270,7 @@ fn working_dir(cd: &str, project: &Entity<Project>, cx: &mut App) -> Result<Opti
#[cfg(test)]
mod tests {
use super::*;
use fs::Fs;
#[test]
fn test_initial_title_shows_full_multiline_command() {
@@ -1234,6 +1302,91 @@ mod tests {
)
}
#[gpui::test]
async fn test_sandbox_paths_protect_git_paths_until_git_access_is_allowed(
cx: &mut gpui::TestAppContext,
) {
crate::tests::init_test(cx);
let fs = fs::FakeFs::new(cx.executor());
fs.insert_tree(
"/main_repo",
serde_json::json!({
".git": {},
"file.txt": "content",
}),
)
.await;
fs.add_linked_worktree_for_repo(
Path::new("/main_repo/.git"),
false,
git::repository::Worktree {
path: PathBuf::from("/linked_worktree"),
ref_name: Some("refs/heads/feature".into()),
sha: "abc123".into(),
is_main: false,
is_bare: false,
},
)
.await;
fs.write(Path::new("/linked_worktree/file.txt"), b"content")
.await
.expect("linked worktree file should be written");
let project = project::Project::test(fs, [Path::new("/linked_worktree")], cx).await;
let paths_without_git_access = cx.update(|cx| sandbox_paths(project.read(cx), false, cx));
assert!(
paths_without_git_access
.writable_paths
.contains(&PathBuf::from("/linked_worktree"))
);
assert!(
paths_without_git_access
.protected_paths
.contains(&PathBuf::from("/linked_worktree/.git"))
);
assert!(
!paths_without_git_access
.protected_paths
.contains(&PathBuf::from("/linked_worktree/.gitignore"))
);
assert!(
paths_without_git_access
.protected_paths
.contains(&PathBuf::from("/main_repo/.git"))
);
assert!(
paths_without_git_access
.protected_paths
.contains(&PathBuf::from("/main_repo/.git/worktrees/feature"))
);
let paths_with_git_access = cx.update(|cx| sandbox_paths(project.read(cx), true, cx));
assert!(paths_with_git_access.protected_paths.is_empty());
assert!(
paths_with_git_access
.writable_paths
.contains(&PathBuf::from("/linked_worktree"))
);
assert!(
paths_with_git_access
.writable_paths
.contains(&PathBuf::from("/linked_worktree/.git"))
);
assert!(
paths_with_git_access
.writable_paths
.contains(&PathBuf::from("/main_repo/.git"))
);
assert!(
paths_with_git_access
.writable_paths
.contains(&PathBuf::from("/main_repo/.git/worktrees/feature"))
);
}
#[test]
fn test_process_content_user_stopped() {
let output = acp::TerminalOutputResponse::new("partial output".to_string(), false);
@@ -2760,6 +2913,7 @@ mod tests {
) -> crate::sandboxing::SandboxRequest {
crate::sandboxing::SandboxRequest {
network,
allow_git_access: false,
allow_fs_write_all: all,
unsandboxed: false,
write_paths: paths.iter().map(PathBuf::from).collect(),
@@ -2922,6 +3076,10 @@ mod tests {
schema.contains("allow_fs_write_all"),
"schema should advertise allow_fs_write_all: {schema}"
);
assert!(
schema.contains("allow_git_access"),
"schema should advertise allow_git_access: {schema}"
);
assert!(
schema.contains("unsandboxed"),
"schema should advertise unsandboxed: {schema}"
@@ -2943,6 +3101,7 @@ mod tests {
assert_eq!(input.allow_all_hosts, None);
assert!(input.fs_write_paths.is_empty());
assert_eq!(input.allow_fs_write_all, None);
assert_eq!(input.allow_git_access, None);
assert_eq!(input.unsandboxed, None);
}
@@ -3002,6 +3161,7 @@ mod tests {
.expect("legacy allow_fs_write should request sandbox authorization details");
assert!(details.network_hosts.is_empty());
assert!(!details.network_all_hosts);
assert!(!details.allow_git_access);
assert!(details.allow_fs_write_all);
assert!(!details.unsandboxed);
assert!(details.write_paths.is_empty());
@@ -3100,6 +3260,7 @@ mod tests {
.expect("unsandboxed should request sandbox authorization details");
assert!(details.network_hosts.is_empty());
assert!(!details.network_all_hosts);
assert!(!details.allow_git_access);
assert!(!details.allow_fs_write_all);
assert!(details.unsandboxed);
assert!(details.write_paths.is_empty());
@@ -3297,6 +3458,13 @@ mod tests {
sandbox_approval_title(&sandbox_request(NetworkRequest::None, true, &[])),
"Allow unrestricted filesystem writes?"
);
let mut request = sandbox_request(NetworkRequest::AnyHost, false, &[]);
request.allow_git_access = true;
assert_eq!(
sandbox_approval_title(&request),
"Allow arbitrary network access and Git metadata access?"
);
}
#[test]
@@ -421,6 +421,8 @@ pub struct SandboxPermissions {
/// hostnames or leading-`*.` subdomain wildcards). Parsed/validated where
/// consumed (`agent::sandboxing`).
pub network_hosts: Vec<String>,
/// Allow sandboxed commands to access protected Git metadata paths.
pub allow_git_access: bool,
pub allow_fs_write_all: bool,
/// Persistently run agent terminal commands outside the OS sandbox. This is
/// the model-facing "off switch": when set, the sandboxed terminal tool is
@@ -814,6 +816,7 @@ fn compile_sandbox_permissions(
SandboxPermissions {
allow_all_hosts: content.allow_all_hosts.unwrap_or(false),
network_hosts,
allow_git_access: content.allow_git_access.unwrap_or(false),
allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false),
allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false),
write_paths,
@@ -1101,6 +1104,7 @@ mod tests {
let json = json!({
"allow_all_hosts": true,
"network_hosts": ["github.com", "*.npmjs.org"],
"allow_git_access": true,
"allow_unsandboxed": true,
"write_paths": [
"/tmp/build/cache",
@@ -1117,6 +1121,7 @@ mod tests {
permissions.network_hosts,
vec!["github.com".to_string(), "*.npmjs.org".to_string()]
);
assert!(permissions.allow_git_access);
assert!(!permissions.allow_fs_write_all);
assert!(permissions.allow_unsandboxed);
assert_eq!(
@@ -7928,12 +7928,14 @@ impl ThreadView {
cx: &Context<Self>,
) -> AnyElement {
let has_network = details.network_all_hosts || !details.network_hosts.is_empty();
let has_git_access = details.allow_git_access;
let command = details
.command
.as_deref()
.filter(|command| !command.is_empty());
if details.write_paths.is_empty()
&& !has_network
&& !has_git_access
&& command.is_none()
&& details.reason.is_empty()
{
@@ -8044,6 +8046,34 @@ impl ThreadView {
})
});
let git_access_section = has_git_access.then(|| {
v_flex()
.p_1()
.gap_0p5()
.child(
h_flex()
.gap_1()
.child(
Icon::new(IconName::GitBranch)
.color(Color::Muted)
.size(IconSize::Small),
)
.child(
Label::new("Git metadata access")
.size(LabelSize::Small)
.color(Color::Muted),
),
)
.child(
Label::new(
"Allows reading and writing .git directories, which may include repositories outside this project, and exposes the inherited SSH agent for commit signing.",
)
.size(LabelSize::XSmall)
.color(Color::Muted),
)
.into_any_element()
});
if details.write_paths.is_empty() {
return v_flex()
.border_t_1()
@@ -8054,6 +8084,7 @@ impl ThreadView {
))
})
.children(network_section)
.children(git_access_section)
.into_any_element();
}
@@ -8072,6 +8103,7 @@ impl ThreadView {
))
})
.children(network_section)
.children(git_access_section)
.child(
h_flex()
.id(("sandbox-authorization-details-header", entry_ix))
+2
View File
@@ -116,6 +116,8 @@ mod imp {
Some(channel.name()),
permissions,
writable_dirs,
&[],
&[],
cwd,
program,
args,
+271 -38
View File
@@ -294,8 +294,22 @@ fn probe_bwrap(bwrap: &Path, bwrap_args: &[String]) -> bool {
/// `writable_directories` should be the project's worktree paths (plus any
/// user-approved paths), *not* the command's working directory, which is
/// model-controlled and would let the model widen its own writable scope.
///
/// `protected_paths` are paths whose contents must stay inaccessible even when
/// they sit under a writable directory or the root is bound read-write (e.g.
/// `.git` metadata when Git access hasn't been approved). Each is shadowed: a
/// directory with an ephemeral `tmpfs` (real contents hidden, writes land in
/// throwaway storage) and a file with an empty read-only bind of `/dev/null`.
///
/// `allowed_unix_socket_paths` are Unix domain sockets to re-expose on top of
/// any overlay that would otherwise hide them (notably `--tmpfs /tmp`), so
/// local IPC such as the inherited SSH agent socket keeps working. AF_UNIX
/// connectivity is unaffected by `--unshare-net`, so this grants no IP network
/// access.
pub fn build_bwrap_args(
writable_directories: &[&Path],
protected_paths: &[&Path],
allowed_unix_socket_paths: &[&Path],
permissions: SandboxPermissions,
cwd: Option<&Path>,
) -> Vec<String> {
@@ -335,6 +349,51 @@ pub fn build_bwrap_args(
}
}
// Shadow protected paths last among the filesystem binds so the overlay
// wins over the root bind and any writable bind above it. Unlike the macOS
// Seatbelt policy (which denies `file-read-data` while leaving metadata
// readable), bwrap hides the real contents entirely behind an empty
// overlay; the security goal — keeping the real path unreadable and
// unwritable — is the same.
for protected_path in protected_paths {
// Resolve through an existing parent when the leaf is missing so a
// not-yet-created `.git` (before `git init`) overlays the same real path
// the writable bind above uses, even on a symlinked root.
let canonical = crate::canonicalize_allowing_missing_leaf(protected_path);
let destination = canonical.to_string_lossy().into_owned();
match std::fs::symlink_metadata(&canonical) {
Ok(metadata) if metadata.is_dir() => {
args.push("--tmpfs".to_string());
args.push(destination);
}
Ok(_) => {
// A file (e.g. a linked worktree's `.git` gitlink): mask it
// with an empty, read-only `/dev/null`.
push_bind(&mut args, "--ro-bind", "/dev/null", &destination);
}
Err(_) => {
// Doesn't exist yet: still overlay a tmpfs so a command can't
// `git init` and write real metadata into a writable worktree.
args.push("--tmpfs".to_string());
args.push(destination);
}
}
}
// Re-expose explicitly allowed Unix domain sockets on top of any overlay
// that would otherwise hide them. Done after the protected overlays and the
// `/tmp` tmpfs so the socket always wins.
for socket_path in allowed_unix_socket_paths {
let canonical = socket_path
.canonicalize()
.unwrap_or_else(|_| socket_path.to_path_buf());
if !canonical.exists() {
continue;
}
let path = canonical.to_string_lossy().into_owned();
push_bind(&mut args, "--bind", &path, &path);
}
for flag in [
"--unshare-user",
"--unshare-ipc",
@@ -392,6 +451,8 @@ struct LauncherInvocation {
permissions: SandboxPermissions,
cwd: Option<PathBuf>,
writable_dirs: Vec<PathBuf>,
protected_paths: Vec<PathBuf>,
allowed_unix_socket_paths: Vec<PathBuf>,
program: OsString,
args: Vec<OsString>,
}
@@ -405,18 +466,29 @@ struct LauncherInvocation {
/// `status_socket_name` (when given).
///
/// The encoding is positional, one value per argv entry, so paths and
/// arguments containing spaces round-trip without escaping:
/// `[FLAG, socket, allow_network, allow_fs_write, cwd, N, writable_1.., program, args..]`.
/// arguments containing spaces round-trip without escaping. Each path list is a
/// count followed by that many entries:
/// `[FLAG, socket, allow_network, allow_fs_write, cwd,
/// N_writable, writable.., N_protected, protected.., N_sockets, sockets..,
/// program, args..]`.
pub fn wrap_invocation(
launcher_program: &str,
status_socket_name: Option<&str>,
permissions: SandboxPermissions,
writable_dirs: &[&Path],
protected_paths: &[&Path],
allowed_unix_socket_paths: &[&Path],
cwd: Option<&Path>,
program: &str,
args: &[String],
) -> (String, Vec<String>) {
let mut launcher_args = Vec::with_capacity(writable_dirs.len() + args.len() + 7);
let mut launcher_args = Vec::with_capacity(
writable_dirs.len()
+ protected_paths.len()
+ allowed_unix_socket_paths.len()
+ args.len()
+ 9,
);
launcher_args.push(SANDBOX_LAUNCHER_FLAG.to_string());
launcher_args.push(status_socket_name.unwrap_or("").to_string());
launcher_args.push(encode_bool(permissions.allow_network));
@@ -425,15 +497,22 @@ pub fn wrap_invocation(
cwd.map(|cwd| cwd.to_string_lossy().into_owned())
.unwrap_or_default(),
);
launcher_args.push(writable_dirs.len().to_string());
for directory in writable_dirs {
launcher_args.push(directory.to_string_lossy().into_owned());
}
push_path_list(&mut launcher_args, writable_dirs);
push_path_list(&mut launcher_args, protected_paths);
push_path_list(&mut launcher_args, allowed_unix_socket_paths);
launcher_args.push(program.to_string());
launcher_args.extend(args.iter().cloned());
(launcher_program.to_string(), launcher_args)
}
/// Encode a path list as a count followed by one argv entry per path.
fn push_path_list(launcher_args: &mut Vec<String>, paths: &[&Path]) {
launcher_args.push(paths.len().to_string());
for path in paths {
launcher_args.push(path.to_string_lossy().into_owned());
}
}
/// If this process was re-executed as a sandbox launcher (its first argument
/// is [`SANDBOX_LAUNCHER_FLAG`]), set up the sandbox and `exec` the wrapped
/// command. This never returns when the marker is present.
@@ -473,19 +552,34 @@ fn run_launcher(invocation: LauncherInvocation) -> ! {
.iter()
.map(PathBuf::as_path)
.collect();
let protected: Vec<&Path> = invocation
.protected_paths
.iter()
.map(PathBuf::as_path)
.collect();
let allowed_unix_sockets: Vec<&Path> = invocation
.allowed_unix_socket_paths
.iter()
.map(PathBuf::as_path)
.collect();
let (bwrap, bwrap_args) =
match prepare_sandbox(&writable, invocation.permissions, invocation.cwd.as_deref()) {
Ok(prepared) => prepared,
Err(status) => {
report_status(socket.as_deref(), status);
eprintln!(
"zed: could not create sandbox: {}; aborting",
status.describe()
);
std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
}
};
let (bwrap, bwrap_args) = match prepare_sandbox(
&writable,
&protected,
&allowed_unix_sockets,
invocation.permissions,
invocation.cwd.as_deref(),
) {
Ok(prepared) => prepared,
Err(status) => {
report_status(socket.as_deref(), status);
eprintln!(
"zed: could not create sandbox: {}; aborting",
status.describe()
);
std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
}
};
// Everything is in place: report success, then `exec` the sandboxed command.
// A failure of the final `exec` is fail-closed (a hard error, never a silent
@@ -512,6 +606,8 @@ fn run_launcher(invocation: LauncherInvocation) -> ! {
/// fall back to an unsandboxed run).
fn prepare_sandbox(
writable_dirs: &[&Path],
protected_paths: &[&Path],
allowed_unix_socket_paths: &[&Path],
permissions: SandboxPermissions,
cwd: Option<&Path>,
) -> Result<(PathBuf, Vec<String>), LauncherStatus> {
@@ -520,7 +616,13 @@ fn prepare_sandbox(
BwrapLocation::OnlySetuid => return Err(LauncherStatus::SetuidRejected),
BwrapLocation::NotFound => return Err(LauncherStatus::BwrapNotFound),
};
let bwrap_args = build_bwrap_args(writable_dirs, permissions, cwd);
let bwrap_args = build_bwrap_args(
writable_dirs,
protected_paths,
allowed_unix_socket_paths,
permissions,
cwd,
);
if !probe_bwrap(&bwrap, &bwrap_args) {
return Err(LauncherStatus::SandboxProbeFailed);
}
@@ -537,10 +639,19 @@ fn prepare_sandbox(
/// never silently skipped on the strength of this result alone.
pub fn check_can_create_sandbox(
writable_dirs: &[&Path],
protected_paths: &[&Path],
allowed_unix_socket_paths: &[&Path],
permissions: SandboxPermissions,
cwd: Option<&Path>,
) -> std::result::Result<(), LauncherStatus> {
prepare_sandbox(writable_dirs, permissions, cwd).map(|_| ())
prepare_sandbox(
writable_dirs,
protected_paths,
allowed_unix_socket_paths,
permissions,
cwd,
)
.map(|_| ())
}
/// Send a one-shot `status` datagram to the status channel (if one was given).
@@ -599,19 +710,9 @@ fn decode_launcher_args(mut args: impl Iterator<Item = OsString>) -> Result<Laun
Some(PathBuf::from(value))
}
};
let count = args
.next()
.context("missing writable directory count")?
.to_str()
.context("writable directory count is not valid UTF-8")?
.parse::<usize>()
.context("invalid writable directory count")?;
let mut writable_dirs = Vec::with_capacity(count);
for _ in 0..count {
writable_dirs.push(PathBuf::from(
args.next().context("missing writable directory")?,
));
}
let writable_dirs = decode_path_list(&mut args, "writable directory")?;
let protected_paths = decode_path_list(&mut args, "protected path")?;
let allowed_unix_socket_paths = decode_path_list(&mut args, "allowed unix socket")?;
let program = args.next().context("missing program to run")?;
let args: Vec<OsString> = args.collect();
@@ -623,11 +724,33 @@ fn decode_launcher_args(mut args: impl Iterator<Item = OsString>) -> Result<Laun
},
cwd,
writable_dirs,
protected_paths,
allowed_unix_socket_paths,
program,
args,
})
}
/// Decode a path list (a count followed by that many entries) produced by
/// [`push_path_list`]. `what` names the list for error messages.
fn decode_path_list(args: &mut impl Iterator<Item = OsString>, what: &str) -> Result<Vec<PathBuf>> {
let count = args
.next()
.with_context(|| format!("missing {what} count"))?
.to_str()
.with_context(|| format!("{what} count is not valid UTF-8"))?
.parse::<usize>()
.with_context(|| format!("invalid {what} count"))?;
let mut paths = Vec::with_capacity(count);
for _ in 0..count {
paths.push(PathBuf::from(
args.next()
.with_context(|| format!("missing {what} entry"))?,
));
}
Ok(paths)
}
fn encode_bool(value: bool) -> String {
if value { "1" } else { "0" }.to_string()
}
@@ -685,6 +808,8 @@ mod tests {
let writable = tempfile::tempdir().unwrap();
let args = build_bwrap_args(
&[writable.path()],
&[],
&[],
SandboxPermissions::default(),
Some(writable.path()),
);
@@ -705,10 +830,12 @@ mod tests {
#[test]
fn test_build_bwrap_args_network_namespace_follows_permission() {
let denied = build_bwrap_args(&[], SandboxPermissions::default(), None);
let denied = build_bwrap_args(&[], &[], &[], SandboxPermissions::default(), None);
assert!(denied.iter().any(|arg| arg == "--unshare-net"));
let allowed = build_bwrap_args(
&[],
&[],
&[],
SandboxPermissions {
allow_network: true,
@@ -725,7 +852,7 @@ mod tests {
allow_network: false,
allow_fs_write: true,
};
let args = build_bwrap_args(&[], permissions, None);
let args = build_bwrap_args(&[], &[], &[], permissions, None);
assert!(windows_contains(&args, &["--bind", "/", "/"]));
assert!(!windows_contains(&args, &["--ro-bind", "/", "/"]));
// With unrestricted writes the host's real `/tmp` stays writable, so
@@ -733,6 +860,95 @@ mod tests {
assert!(!windows_contains(&args, &["--tmpfs", "/tmp"]));
}
#[test]
fn test_build_bwrap_args_shadows_protected_directory_with_tmpfs() {
let project = tempfile::tempdir().unwrap();
let git_dir = project.path().join(".git");
std::fs::create_dir(&git_dir).unwrap();
let args = build_bwrap_args(
&[project.path()],
&[git_dir.as_path()],
&[],
SandboxPermissions::default(),
None,
);
let git_canonical = git_dir.canonicalize().unwrap();
let git_str = git_canonical.to_string_lossy().into_owned();
// The writable project is bound rw, then the `.git` directory is shadowed
// with a tmpfs on top so its real contents can't be read or written.
assert!(windows_contains(&args, &["--tmpfs", &git_str]));
let project_canonical = project.path().canonicalize().unwrap();
let project_str = project_canonical.to_string_lossy().into_owned();
let tmpfs_index = args
.windows(2)
.position(|w| w == ["--tmpfs".to_string(), git_str.clone()])
.unwrap();
let bind_index = args
.windows(3)
.position(|w| {
w == [
"--bind".to_string(),
project_str.clone(),
project_str.clone(),
]
})
.unwrap();
assert!(
bind_index < tmpfs_index,
"the protected tmpfs must be applied after the writable bind so it wins"
);
}
#[test]
fn test_build_bwrap_args_masks_protected_file_with_dev_null() {
let project = tempfile::tempdir().unwrap();
let gitlink = project.path().join(".git");
std::fs::write(&gitlink, "gitdir: /elsewhere\n").unwrap();
let args = build_bwrap_args(
&[project.path()],
&[gitlink.as_path()],
&[],
SandboxPermissions::default(),
None,
);
let gitlink_canonical = gitlink.canonicalize().unwrap();
let gitlink_str = gitlink_canonical.to_string_lossy().into_owned();
assert!(windows_contains(
&args,
&["--ro-bind", "/dev/null", &gitlink_str]
));
}
#[test]
fn test_build_bwrap_args_rebinds_allowed_unix_socket() {
let dir = tempfile::tempdir().unwrap();
// `build_bwrap_args` only checks that the path exists, so a regular file
// stands in for a socket here (binding a real socket under a long temp
// path would overflow `sun_path`).
let socket = dir.path().join("agent.sock");
std::fs::write(&socket, b"").unwrap();
let args = build_bwrap_args(
&[],
&[],
&[socket.as_path()],
SandboxPermissions::default(),
None,
);
let socket_canonical = socket.canonicalize().unwrap();
let socket_str = socket_canonical.to_string_lossy().into_owned();
assert!(windows_contains(
&args,
&["--bind", &socket_str, &socket_str]
));
}
#[test]
fn test_launcher_args_round_trip() {
let writable = PathBuf::from("/home/user/project dir");
@@ -744,11 +960,15 @@ mod tests {
allow_fs_write: false,
};
let protected = PathBuf::from("/home/user/project dir/.git");
let socket = PathBuf::from("/tmp/ssh agent/sock");
let (launcher, launcher_args) = wrap_invocation(
"/path/to/zed",
Some("zed-sandbox-status-abc"),
permissions,
&[writable.as_path()],
&[protected.as_path()],
&[socket.as_path()],
Some(cwd.as_path()),
&program,
&args,
@@ -766,6 +986,8 @@ mod tests {
assert_eq!(decoded.permissions, permissions);
assert_eq!(decoded.cwd.as_deref(), Some(cwd.as_path()));
assert_eq!(decoded.writable_dirs, vec![writable]);
assert_eq!(decoded.protected_paths, vec![protected]);
assert_eq!(decoded.allowed_unix_socket_paths, vec![socket]);
assert_eq!(decoded.program, OsString::from(program));
assert_eq!(
decoded.args,
@@ -780,8 +1002,17 @@ mod tests {
allow_network: true,
allow_fs_write: true,
};
let (launcher, launcher_args) =
wrap_invocation("/path/to/zed", None, permissions, &[], None, &program, &[]);
let (launcher, launcher_args) = wrap_invocation(
"/path/to/zed",
None,
permissions,
&[],
&[],
&[],
None,
&program,
&[],
);
let decoded = parse_launcher_args(launcher_argv(launcher, launcher_args))
.unwrap()
@@ -791,6 +1022,8 @@ mod tests {
assert_eq!(decoded.permissions, permissions);
assert_eq!(decoded.cwd, None);
assert!(decoded.writable_dirs.is_empty());
assert!(decoded.protected_paths.is_empty());
assert!(decoded.allowed_unix_socket_paths.is_empty());
assert_eq!(decoded.program, OsString::from("/bin/true"));
assert!(decoded.args.is_empty());
}
+365 -8
View File
@@ -16,8 +16,10 @@
//! under `sandbox-exec -f <config-path>`.
//!
//! Reads are permitted by default; writes are restricted to a caller-
//! provided list of directories; network access and unrestricted writes
//! must be opted into per command.
//! provided list of directories; IP network access and unrestricted writes
//! must be opted into per command. Callers may separately allow specific
//! Unix domain sockets for local IPC; those do not permit sending packets to
//! other machines.
use std::path::Path;
use std::{io::Write, path::PathBuf};
@@ -85,11 +87,29 @@ impl SeatbeltConfigFile {
/// false. Pass the project's worktree paths here — not the working
/// directory of the command, since that is model-controlled and would
/// let the model widen its own writable scope.
pub fn new(writable_directories: &[&Path], permissions: SandboxPermissions) -> Result<Self> {
///
/// `protected_paths` lists paths whose file data reads and writes should
/// be blocked even if they fall under a readable or writable directory.
/// File metadata remains readable.
///
/// `allowed_unix_socket_paths` lists Unix domain sockets the command may
/// connect to for local IPC even when IP network access is otherwise
/// disabled. This does not permit sending packets to other machines.
pub fn new(
writable_directories: &[&Path],
protected_paths: &[&Path],
allowed_unix_socket_paths: &[&Path],
permissions: SandboxPermissions,
) -> Result<Self> {
let mut file =
NamedTempFile::new().context("failed to create temporary Seatbelt config file")?;
let config = generate_seatbelt_config(writable_directories, permissions)?;
let config = generate_seatbelt_config(
writable_directories,
protected_paths,
allowed_unix_socket_paths,
permissions,
)?;
file.write_all(config.as_bytes())
.context("failed to write Seatbelt config")?;
file.flush().context("failed to flush Seatbelt config")?;
@@ -118,6 +138,12 @@ impl SeatbeltConfigFile {
/// the project's worktree paths here, not the working directory of the
/// command (the working directory is model-controlled, and using it as
/// the writable scope would let the model write outside the project).
/// * `protected_paths` - Paths whose file data reads and writes should be
/// denied even if they fall under a readable or writable directory. File
/// metadata remains readable.
/// * `allowed_unix_socket_paths` - Unix domain sockets the command may
/// connect to for local IPC even when IP network access is otherwise
/// disabled. This does not permit sending packets to other machines.
/// * `permissions` - Sandbox relaxations requested for this command.
///
/// # Returns
@@ -127,9 +153,16 @@ pub fn wrap_invocation(
program: &str,
args: &[String],
writable_directories: &[&Path],
protected_paths: &[&Path],
allowed_unix_socket_paths: &[&Path],
permissions: SandboxPermissions,
) -> Result<(String, Vec<String>, SeatbeltConfigFile)> {
let config_file = SeatbeltConfigFile::new(writable_directories, permissions)?;
let config_file = SeatbeltConfigFile::new(
writable_directories,
protected_paths,
allowed_unix_socket_paths,
permissions,
)?;
let mut wrapped_args = vec![
"-f".to_string(),
@@ -158,13 +191,23 @@ pub fn wrap_invocation(
/// Writes to each entry in `writable_directories` (typically the project's
/// worktree paths plus any per-command scratch directory the caller wants
/// allowed) and the standard `/dev/*` write targets are also allowed by
/// default; network access and unrestricted filesystem writes must be
/// requested via [`SandboxPermissions`].
/// default. File data reads and writes to paths in `protected_paths` are
/// denied even when they would otherwise be readable or writable; file
/// metadata remains readable. Unix domain socket paths in
/// `allowed_unix_socket_paths` are reachable for local IPC even when IP
/// network access is otherwise blocked; callers use this for trusted sockets
/// inherited from the process environment, such as `SSH_AUTH_SOCK`. This does
/// not permit sending packets to other machines.
///
/// Network access and unrestricted filesystem writes must be requested via
/// [`SandboxPermissions`].
///
/// The returned string is the textual content to write to the
/// [`SeatbeltConfigFile`] passed to `sandbox-exec -f`.
fn generate_seatbelt_config(
writable_directories: &[&Path],
protected_paths: &[&Path],
allowed_unix_socket_paths: &[&Path],
permissions: SandboxPermissions,
) -> Result<String> {
// Canonicalize each writable path to resolve symlinks (e.g.,
@@ -174,6 +217,19 @@ fn generate_seatbelt_config(
.iter()
.map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf()))
.collect();
// Use `canonicalize_allowing_missing_leaf` rather than a plain
// `canonicalize` so a not-yet-created `.git` (before `git init`) still
// resolves through its existing parent and matches the canonicalized
// writable worktree above; otherwise the deny rule would miss the real path
// on a symlinked root (`/tmp` -> `/private/tmp`).
let canonical_protected_paths: Vec<PathBuf> = protected_paths
.iter()
.map(|path| crate::canonicalize_allowing_missing_leaf(path))
.collect();
// Unlike file paths, Unix socket literals are emitted verbatim: it isn't
// guaranteed whether Seatbelt resolves symlinks before matching a
// `remote unix-socket` literal, so the caller passes both the path the
// child connects to and its canonical form, and we keep them as given.
let mut config = r#"(version 1)
@@ -198,6 +254,26 @@ fn generate_seatbelt_config(
; Allow pseudo-terminal operations
(allow pseudo-tty)
(allow file-read* file-write* file-ioctl
(literal "/dev/ptmx"))
(allow file-read* file-write*
(require-all
(regex #"^/dev/ttys[0-9]+$")
(extension "com.apple.sandbox.pty")))
; The command's PTY is allocated after this profile is generated, so its slave
; TTY path isn't known here and may lack the `com.apple.sandbox.pty` extension.
; Allow ioctls on slave TTYs so interactive shells and signing prompts can
; manipulate terminal state. Seatbelt can't filter by ioctl request number, so
; this can't exclude input-injection ioctls (e.g. TIOCSTI) specifically. The
; residual risk is bounded by the kernel, not by this profile: XNU's TIOCSTI
; handler (bsd/kern/tty.c) rejects a non-root caller unless the target TTY is
; the caller's own controlling terminal (EACCES otherwise). Each agent command
; runs in its own dedicated PTY, so the only TTY it can inject into is that
; throwaway PTY, not the user's interactive terminal. The regex is also anchored
; so it matches only `/dev/ttysNNN` device nodes.
(allow file-ioctl
(regex #"^/dev/ttys[0-9]+$"))
"#
.to_string();
@@ -235,6 +311,20 @@ fn generate_seatbelt_config(
);
}
for protected_path in &canonical_protected_paths {
let escaped_path = escape_sandbox_path(protected_path)?;
// `subpath` already matches the path itself plus everything beneath it,
// so it covers both a `.git` directory and a linked worktree's `.git`
// gitlink file without a redundant `literal` rule.
config.push_str(&format!(
r#"
; Block Git metadata content access unless Git access is approved
(deny file-read-data file-write*
(subpath "{escaped_path}"))
"#
));
}
match permissions.network {
NetworkAccess::None => {}
NetworkAccess::All => {
@@ -262,6 +352,34 @@ fn generate_seatbelt_config(
}
}
if !allowed_unix_socket_paths.is_empty() {
config.push_str(
r#"
; Allow local IPC to inherited Unix domain sockets. Seatbelt models this as
; network-outbound, but this does not permit IP networking or sending packets
; to other machines.
;
; `system-socket` only governs the `socket()` syscall (creating an AF_UNIX
; socket), which is harmless on its own. The capability that matters,
; `connect()`, stays gated by the per-path `network-outbound (remote
; unix-socket ...)` rules below, so `(deny default)` still blocks connecting to
; any socket not explicitly allow-listed.
(allow system-socket
(socket-domain AF_UNIX))
"#,
);
for socket_path in allowed_unix_socket_paths {
let escaped_path = escape_sandbox_path(socket_path)?;
config.push_str(&format!(
r#"(allow network-outbound
(remote unix-socket
(literal "{escaped_path}")))
"#
));
}
}
Ok(config)
}
@@ -289,7 +407,8 @@ mod tests {
fn test_generate_seatbelt_config_contains_read_and_project_write_permissions_by_default() {
let dir = PathBuf::from("/Users/test/projects/myproject");
let config =
generate_seatbelt_config(&[dir.as_path()], SandboxPermissions::default()).unwrap();
generate_seatbelt_config(&[dir.as_path()], &[], &[], SandboxPermissions::default())
.unwrap();
assert!(config.contains("(allow file-read*)"));
assert!(config.contains("/Users/test/projects/myproject"));
@@ -303,6 +422,8 @@ mod tests {
let dir = PathBuf::from("/Users/test/projects/myproject");
let config = generate_seatbelt_config(
&[dir.as_path()],
&[],
&[],
SandboxPermissions {
network: NetworkAccess::None,
allow_fs_write: true,
@@ -317,11 +438,225 @@ mod tests {
assert!(!config.contains("(allow network*)"));
}
#[test]
fn test_generate_seatbelt_config_allows_terminal_ioctls_by_default() {
let dir = PathBuf::from("/Users/test/projects/myproject");
let config =
generate_seatbelt_config(&[dir.as_path()], &[], &[], SandboxPermissions::default())
.unwrap();
assert!(config.contains("(allow file-ioctl"));
assert!(config.contains("/dev/ptmx"));
assert!(config.contains("^/dev/ttys[0-9]+"));
}
#[test]
fn test_generate_seatbelt_config_allows_unix_socket_paths_without_network() {
let dir = PathBuf::from("/Users/test/projects/myproject");
let ssh_auth_socket = PathBuf::from("/private/tmp/com.apple.launchd.test/Listeners");
let config = generate_seatbelt_config(
&[dir.as_path()],
&[],
&[ssh_auth_socket.as_path()],
SandboxPermissions::default(),
)
.unwrap();
assert!(config.contains("(allow system-socket"));
assert!(config.contains("AF_UNIX"));
assert!(config.contains("(allow network-outbound"));
assert!(config.contains("remote unix-socket"));
assert!(config.contains("(literal \"/private/tmp/com.apple.launchd.test/Listeners\")"));
assert!(!config.contains("(subpath \"/private/tmp/com.apple.launchd.test/Listeners\")"));
assert!(!config.contains("(allow network*)"));
}
#[test]
fn test_sandbox_allows_connecting_to_allowed_unix_socket() {
use std::io::ErrorKind;
use std::os::unix::net::UnixListener;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
// Bind under `/tmp` rather than the default temp dir: macOS temp paths
// (`/var/folders/...`) overflow the `sun_path` limit for Unix sockets.
// `TempDir` still cleans up on drop, even if the test panics.
let temp_dir = tempfile::Builder::new()
.prefix("zed-sock-")
.tempdir_in("/tmp")
.unwrap();
let socket_path = temp_dir.path().join("agent.sock");
let listener = UnixListener::bind(&socket_path).expect("test socket should bind");
listener
.set_nonblocking(true)
.expect("listener should switch to non-blocking");
// Allow both the path the command connects to and its canonical form,
// mirroring how the real caller resolves `SSH_AUTH_SOCK`.
let canonical_socket_path = socket_path
.canonicalize()
.expect("bound socket path should canonicalize");
// Accept (and immediately drop) a single connection, with a bounded wait
// so the test can't hang if the sandbox blocks the connection instead.
let accepted = std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
match listener.accept() {
Ok(_) => return true,
Err(error) if error.kind() == ErrorKind::WouldBlock => {
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(20));
}
Err(_) => return false,
}
}
});
let (program, args, _config_file) = wrap_invocation(
"/usr/bin/nc",
&[
"-U".to_string(),
"-w".to_string(),
"5".to_string(),
socket_path.display().to_string(),
],
&[temp_dir.path()],
&[],
&[socket_path.as_path(), canonical_socket_path.as_path()],
SandboxPermissions::default(),
)
.unwrap();
let output = Command::new(&program)
.args(&args)
.stdin(Stdio::null())
.output()
.expect("failed to execute sandbox-exec");
assert!(
accepted.join().unwrap(),
"sandbox should allow connecting to the allow-listed unix socket: stderr={} stdout={}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout),
);
}
#[test]
fn test_generate_seatbelt_config_denies_protected_path_data_and_writes() {
let dir = PathBuf::from("/Users/test/projects/myproject");
let protected = dir.join(".gitignore");
let config = generate_seatbelt_config(
&[dir.as_path()],
&[protected.as_path()],
&[],
SandboxPermissions::default(),
)
.unwrap();
assert!(config.contains("(deny file-read-data file-write*"));
assert!(config.contains("(subpath \"/Users/test/projects/myproject/.gitignore\")"));
// `subpath` already covers the path itself, so no redundant `literal`.
assert!(!config.contains("(literal \"/Users/test/projects/myproject/.gitignore\")"));
}
#[test]
fn test_sandbox_blocks_protected_path_contents_but_allows_metadata() {
use std::process::Command;
let temp_dir = tempfile::tempdir().unwrap();
let protected_file = temp_dir.path().join(".gitignore");
std::fs::write(&protected_file, "target\n").unwrap();
let (program, args, _config_file) = wrap_invocation(
"/bin/sh",
&[
"-c".to_string(),
format!(
"test -e '{}' && ! cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'",
protected_file.display(),
protected_file.display(),
protected_file.display(),
),
],
&[temp_dir.path()],
&[protected_file.as_path()],
&[],
SandboxPermissions::default(),
)
.unwrap();
let output = Command::new(&program)
.args(&args)
.output()
.expect("failed to execute sandbox-exec");
assert!(
output.status.success(),
"sandbox should allow metadata but deny protected data reads and writes: stderr={} stdout={}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout),
);
assert_eq!(
std::fs::read_to_string(&protected_file).unwrap(),
"target\n"
);
}
#[test]
fn test_sandbox_blocks_protected_paths_even_when_fs_writes_allowed() {
use std::process::Command;
let temp_dir = tempfile::tempdir().unwrap();
let protected_file = temp_dir.path().join(".gitignore");
std::fs::write(&protected_file, "target\n").unwrap();
let (program, args, _config_file) = wrap_invocation(
"/bin/sh",
&[
"-c".to_string(),
format!(
"! cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'",
protected_file.display(),
protected_file.display(),
),
],
&[temp_dir.path()],
&[protected_file.as_path()],
&[],
SandboxPermissions {
network: NetworkAccess::None,
allow_fs_write: true,
},
)
.unwrap();
let output = Command::new(&program)
.args(&args)
.output()
.expect("failed to execute sandbox-exec");
assert!(
output.status.success(),
"protected paths should stay blocked even with unrestricted writes: stderr={} stdout={}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout),
);
assert_eq!(
std::fs::read_to_string(&protected_file).unwrap(),
"target\n"
);
}
#[test]
fn test_generate_seatbelt_config_contains_network_when_allowed() {
let dir = PathBuf::from("/Users/test/projects/myproject");
let config = generate_seatbelt_config(
&[dir.as_path()],
&[],
&[],
SandboxPermissions {
network: NetworkAccess::All,
allow_fs_write: false,
@@ -340,6 +675,8 @@ mod tests {
let dir = PathBuf::from("/Users/test/projects/myproject");
let config = generate_seatbelt_config(
&[dir.as_path()],
&[],
&[],
SandboxPermissions {
network: NetworkAccess::LocalhostPort(54321),
allow_fs_write: false,
@@ -359,6 +696,8 @@ mod tests {
let scratch_dir = PathBuf::from("/private/tmp/zed-agent-command");
let config = generate_seatbelt_config(
&[project_dir.as_path(), scratch_dir.as_path()],
&[],
&[],
SandboxPermissions::default(),
)
.unwrap();
@@ -394,6 +733,8 @@ mod tests {
"/bin/sh",
&["-c".to_string(), "echo hello".to_string()],
&[temp_dir.path()],
&[],
&[],
SandboxPermissions::default(),
)
.unwrap();
@@ -415,6 +756,8 @@ mod tests {
"/bin/sh",
&["-c".to_string(), "cat /etc/hosts".to_string()],
&[temp_dir.path()],
&[],
&[],
SandboxPermissions::default(),
)
.unwrap();
@@ -440,6 +783,8 @@ mod tests {
"/bin/sh",
&["-c".to_string(), "echo test 2>/dev/null".to_string()],
&[temp_dir.path()],
&[],
&[],
SandboxPermissions::default(),
)
.unwrap();
@@ -465,6 +810,8 @@ mod tests {
"/bin/sh",
&["-c".to_string(), "echo test 2>/dev/null".to_string()],
&[temp_dir.path()],
&[],
&[],
SandboxPermissions {
network: NetworkAccess::None,
allow_fs_write: true,
@@ -498,6 +845,8 @@ mod tests {
format!("echo 'hello' > '{}'", test_file.display()),
],
&[temp_dir.path()],
&[],
&[],
SandboxPermissions {
network: NetworkAccess::None,
allow_fs_write: true,
@@ -533,6 +882,8 @@ mod tests {
format!("echo 'hello' > '{}'", test_file.display()),
],
&[project_dir.path(), scratch_dir.path()],
&[],
&[],
SandboxPermissions::default(),
)
.unwrap();
@@ -564,6 +915,8 @@ mod tests {
format!("echo 'hello' > '{}'", test_file.display()),
],
&[temp_dir.path()],
&[],
&[],
SandboxPermissions::default(),
)
.unwrap();
@@ -596,6 +949,8 @@ mod tests {
format!("echo 'hello' > '{}'", test_file.display()),
],
&[project_dir.path()],
&[],
&[],
SandboxPermissions {
network: NetworkAccess::None,
allow_fs_write: true,
@@ -634,6 +989,8 @@ mod tests {
format!("echo 'hello' > '{}'", forbidden_file.display()),
],
&[project_dir.path()],
&[],
&[],
SandboxPermissions::default(),
)
.unwrap();
+56
View File
@@ -70,3 +70,59 @@ pub fn run_sandbox_launcher_if_invoked() {
#[cfg(target_os = "linux")]
linux_bubblewrap::run_launcher_if_invoked();
}
/// Canonicalize `path`, resolving symlinks, even when its final component
/// doesn't exist yet.
///
/// `std::fs::canonicalize` fails if any component is missing, which would leave
/// a not-yet-created path (e.g. a `.git` directory before `git init`) in a
/// non-canonical form. The sandbox layers canonicalize the writable parent
/// (the worktree root) but, with a plain `canonicalize`, fall back to the raw
/// path for a missing child; the two then disagree when a component is a
/// symlink (`/tmp` -> `/private/tmp` on macOS), and the protection rule for the
/// child misses the real path the command ends up writing. Canonicalizing the
/// existing parent and re-appending the final component keeps the child
/// consistent with its parent. If neither the path nor its parent can be
/// canonicalized, the path is returned unchanged.
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn canonicalize_allowing_missing_leaf(path: &std::path::Path) -> std::path::PathBuf {
if let Ok(canonical) = path.canonicalize() {
return canonical;
}
if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name())
&& let Ok(canonical_parent) = parent.canonicalize()
{
return canonical_parent.join(file_name);
}
path.to_path_buf()
}
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
mod tests {
use super::canonicalize_allowing_missing_leaf;
#[test]
fn canonicalize_allowing_missing_leaf_resolves_existing_parent() {
let dir = tempfile::tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
// A fully existing path is canonicalized outright.
assert_eq!(
canonicalize_allowing_missing_leaf(dir.path()),
canonical_dir
);
// A path whose leaf doesn't exist yet still resolves through its parent,
// so it stays consistent with how the parent directory canonicalizes
// (this is the `.git`-before-`git init` case).
let missing = dir.path().join("not-created-yet");
assert_eq!(
canonicalize_allowing_missing_leaf(&missing),
canonical_dir.join("not-created-yet"),
);
// A path whose parent also doesn't exist is returned unchanged.
let deeper = missing.join(".git");
assert_eq!(canonicalize_allowing_missing_leaf(&deeper), deeper);
}
}
+13
View File
@@ -481,6 +481,12 @@ impl AgentSettingsContent {
.allow_fs_write_all = Some(true);
}
pub fn allow_sandbox_git_access(&mut self) {
self.sandbox_permissions
.get_or_insert_default()
.allow_git_access = Some(true);
}
pub fn allow_sandbox_unsandboxed(&mut self) {
self.sandbox_permissions
.get_or_insert_default()
@@ -739,6 +745,11 @@ pub struct SandboxPermissionsContent {
/// Default: []
pub network_hosts: Option<ExtendingVec<String>>,
/// Whether sandboxed terminal commands may always access protected Git
/// metadata paths without prompting.
/// Default: false
pub allow_git_access: Option<bool>,
/// Whether sandboxed terminal commands may always write anywhere on the
/// filesystem without prompting.
/// Default: false
@@ -1039,6 +1050,7 @@ mod tests {
&["github.com".to_string(), "*.npmjs.org".to_string()]
);
settings.allow_sandbox_fs_write_all();
settings.allow_sandbox_git_access();
settings.allow_sandbox_unsandboxed();
settings.add_sandbox_write_path(PathBuf::from("/tmp/build"));
@@ -1053,6 +1065,7 @@ mod tests {
.as_slice(),
&["github.com".to_string(), "*.npmjs.org".to_string()]
);
assert_eq!(sandbox_permissions.allow_git_access, Some(true));
assert_eq!(sandbox_permissions.allow_fs_write_all, Some(true));
assert_eq!(sandbox_permissions.allow_unsandboxed, Some(true));
assert_eq!(
@@ -128,6 +128,27 @@ pub(crate) fn render_sandbox_settings_page(
)),
)
.child(Divider::horizontal())
.child(
v_flex()
.gap_3()
.child(SettingsSectionHeader::new("Git").no_padding(true))
.child(
SwitchField::new(
"sandbox-allow-git-access",
Some("Allow Git Metadata Access"),
Some(
"Let sandboxed commands access protected Git metadata, including .git directories and linked worktree metadata, without prompting."
.into(),
),
permissions.allow_git_access,
move |state, _window, cx| {
set_allow_git_access(*state == ToggleState::Selected, cx);
},
)
.tab_index(0),
),
)
.child(Divider::horizontal())
.child(
v_flex()
.gap_3()
@@ -422,6 +443,12 @@ fn set_allow_all_hosts(value: bool, cx: &mut App) {
});
}
fn set_allow_git_access(value: bool, cx: &mut App) {
update_sandbox_permissions(cx, move |permissions| {
permissions.allow_git_access = Some(value);
});
}
fn set_allow_fs_write_all(value: bool, cx: &mut App) {
update_sandbox_permissions(cx, move |permissions| {
permissions.allow_fs_write_all = Some(value);