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:
1 parent
c9a39298a5
commit
e2b15e11e2
17 files changed
+1168
-77
No files matched your search
Generated
+1
@@ -127,6 +127,7 @@ dependencies = [
|
||||
"settings",
|
||||
"task",
|
||||
"telemetry",
|
||||
"tempfile",
|
||||
"terminal",
|
||||
"text",
|
||||
"ui",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -116,6 +116,8 @@ mod imp {
|
||||
Some(channel.name()),
|
||||
permissions,
|
||||
writable_dirs,
|
||||
&[],
|
||||
&[],
|
||||
cwd,
|
||||
program,
|
||||
args,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user