Skip to repository content1146 lines · 43.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:59:35.721Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
macos_seatbelt.rs
1//! macOS Seatbelt sandbox integration.
2//!
3//! This module is specifically about Apple's Seatbelt sandbox API — the
4//! macOS-only kernel-level sandboxing framework, accessed via the
5//! `sandbox-exec(1)` command-line tool and a Seatbelt-specific config
6//! file (a Scheme-like policy language documented in Apple's
7//! `sandbox.h` and the `sandbox-exec` man page).
8//!
9//! The integration wraps a shell invocation by:
10//!
11//! 1. Generating a Seatbelt config file (a string of Scheme-like rules)
12//! from the requested [`SandboxPermissions`].
13//! 2. Writing it to a temporary file on disk (a [`SeatbeltConfigFile`],
14//! which cleans itself up when dropped).
15//! 3. Returning the program/args needed to launch the original command
16//! under `sandbox-exec -f <config-path>`.
17//!
18//! Reads are permitted by default; writes are restricted to a caller-
19//! provided list of directories; IP network access and unrestricted writes
20//! must be opted into per command. Callers may separately allow specific
21//! Unix domain sockets for local IPC; those do not permit sending packets to
22//! other machines.
23
24use std::path::Path;
25use std::{io::Write, path::PathBuf};
26
27use anyhow::{Context, Result};
28use tempfile::NamedTempFile;
29
30/// Per-command relaxations of the default Seatbelt sandbox.
31///
32/// All-false is the default, fully-sandboxed run. Setting any field
33/// requires user approval before the command is launched.
34///
35/// There are some baseline OS operations (e.g. arbitrary hardware access)
36/// that are disallowed by Seatbelt's baseline policy regardless of these
37/// flags; even with everything `true` here those operations stay denied.
38/// The only way to allow them is to skip the sandbox entirely (which this
39/// module deliberately doesn't expose).
40#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
41pub struct SandboxPermissions {
42 /// Network access policy for the command.
43 pub network: NetworkAccess,
44 /// Allow unrestricted filesystem writes.
45 pub allow_fs_write: bool,
46}
47
48/// Network-access setting for a sandboxed command.
49///
50/// The default ([`NetworkAccess::None`]) blocks all outbound network at the
51/// Seatbelt layer. [`NetworkAccess::LocalhostPort`] confines the command to a
52/// single loopback port — used to force all egress through the in-process
53/// HTTP/HTTPS proxy that enforces a hostname allowlist (see the `http_proxy`
54/// crate). [`NetworkAccess::All`] lifts the restriction entirely.
55#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
56pub enum NetworkAccess {
57 /// All outbound network blocked.
58 #[default]
59 None,
60 /// Outbound TCP allowed only to `localhost:<port>`. Used to confine
61 /// sandboxed commands to the in-process HTTP/HTTPS proxy.
62 LocalhostPort(u16),
63 /// All outbound network allowed.
64 All,
65}
66
67/// A Seatbelt config file written to a temporary path on disk, suitable
68/// for `sandbox-exec -f <path>`. The file is deleted when this is dropped.
69///
70/// The config-file content is the Scheme-like Seatbelt policy language
71/// (see `sandbox-exec(1)` and the comments in macOS's `sandbox.h`); it's
72/// generated from a [`SandboxPermissions`] by [`generate_seatbelt_config`].
73pub struct SeatbeltConfigFile {
74 /// The temporary file containing the Seatbelt config.
75 /// Kept alive so the file exists for the duration of the command.
76 _file: NamedTempFile,
77 /// Path to the temporary config file on disk.
78 path: PathBuf,
79}
80
81impl SeatbeltConfigFile {
82 /// Generate a Seatbelt config from `permissions` and write it to a
83 /// fresh temporary file.
84 ///
85 /// `writable_directories` lists every directory subtree where the
86 /// command is allowed to write when `permissions.allow_fs_write` is
87 /// false. Pass the project's worktree paths here — not the working
88 /// directory of the command, since that is model-controlled and would
89 /// let the model widen its own writable scope.
90 ///
91 /// `protected_paths` lists paths whose writes should be blocked even if they
92 /// fall under a writable directory. Contents remain readable.
93 ///
94 /// `allowed_unix_socket_paths` lists Unix domain sockets the command may
95 /// connect to for local IPC even when IP network access is otherwise
96 /// disabled. This does not permit sending packets to other machines.
97 pub fn new(
98 writable_directories: &[&Path],
99 protected_paths: &[&Path],
100 allowed_unix_socket_paths: &[&Path],
101 permissions: SandboxPermissions,
102 ) -> Result<Self> {
103 let mut file =
104 NamedTempFile::new().context("failed to create temporary Seatbelt config file")?;
105
106 let config = generate_seatbelt_config(
107 writable_directories,
108 protected_paths,
109 allowed_unix_socket_paths,
110 permissions,
111 )?;
112 file.write_all(config.as_bytes())
113 .context("failed to write Seatbelt config")?;
114 file.flush().context("failed to flush Seatbelt config")?;
115
116 let path = file.path().to_path_buf();
117
118 Ok(Self { _file: file, path })
119 }
120}
121
122/// Wrap a process invocation so it runs under macOS's `sandbox-exec(1)`
123/// with a Seatbelt config built from `permissions`.
124///
125/// Returns the new program and arguments to execute, along with a
126/// [`SeatbeltConfigFile`] that **must** be kept alive for the duration of
127/// the command (the file is deleted when dropped, and `sandbox-exec` reads
128/// it lazily when the child process starts up).
129///
130/// # Arguments
131/// * `program` - The program to invoke (typically a shell, e.g. `"/bin/sh"`,
132/// but anything that takes its arguments via `argv` works).
133/// * `args` - The full argument list that would have been passed to
134/// `program`.
135/// * `writable_directories` - Directory subtrees where the command is
136/// allowed to write when `permissions.allow_fs_write` is false. Pass
137/// the project's worktree paths here, not the working directory of the
138/// command (the working directory is model-controlled, and using it as
139/// the writable scope would let the model write outside the project).
140/// * `protected_paths` - Paths whose writes should be denied even if they fall
141/// under a writable directory. Contents remain readable.
142/// * `allowed_unix_socket_paths` - Unix domain sockets the command may
143/// connect to for local IPC even when IP network access is otherwise
144/// disabled. This does not permit sending packets to other machines.
145/// * `permissions` - Sandbox relaxations requested for this command.
146///
147/// # Returns
148/// A tuple of `(program, args, config_file)` where `config_file` must be
149/// kept alive.
150pub fn wrap_invocation(
151 program: &str,
152 args: &[String],
153 writable_directories: &[&Path],
154 protected_paths: &[&Path],
155 allowed_unix_socket_paths: &[&Path],
156 permissions: SandboxPermissions,
157) -> Result<(String, Vec<String>, SeatbeltConfigFile)> {
158 let config_file = SeatbeltConfigFile::new(
159 writable_directories,
160 protected_paths,
161 allowed_unix_socket_paths,
162 permissions,
163 )?;
164
165 let mut wrapped_args = vec![
166 "-f".to_string(),
167 config_file
168 .path
169 .to_str()
170 .with_context(|| {
171 format!(
172 "Seatbelt config file path contains invalid UTF-8: {}",
173 config_file.path.display()
174 )
175 })?
176 .to_string(),
177 program.to_string(),
178 ];
179 wrapped_args.extend(args.iter().cloned());
180
181 Ok((
182 "/usr/bin/sandbox-exec".to_string(),
183 wrapped_args,
184 config_file,
185 ))
186}
187
188/// Generate a Seatbelt config string that reads everywhere by default.
189/// Writes to each entry in `writable_directories` (typically the project's
190/// worktree paths plus any per-command scratch directory the caller wants
191/// allowed) and the standard `/dev/*` write targets are also allowed by
192/// default. Writes to paths in `protected_paths` are denied even when they
193/// would otherwise be writable; contents remain readable. Unix domain socket paths in
194/// `allowed_unix_socket_paths` are reachable for local IPC even when IP
195/// network access is otherwise blocked. This does not permit sending packets
196/// to other machines.
197///
198/// Network access and unrestricted filesystem writes must be requested via
199/// [`SandboxPermissions`].
200///
201/// The returned string is the textual content to write to the
202/// [`SeatbeltConfigFile`] passed to `sandbox-exec -f`.
203fn generate_seatbelt_config(
204 writable_directories: &[&Path],
205 protected_paths: &[&Path],
206 allowed_unix_socket_paths: &[&Path],
207 permissions: SandboxPermissions,
208) -> Result<String> {
209 // These paths are already the canonical identities captured once, at
210 // validation time, inside each `HostFilesystemLocation` (resolving symlinks
211 // and, for a not-yet-created leaf, its existing parent). We deliberately do
212 // NOT re-canonicalize here: re-resolving a path at profile-generation time
213 // is the time-of-check-to-time-of-use hole this design closes. Use them
214 // verbatim as Seatbelt rule literals.
215 let canonical_writable_directories: Vec<PathBuf> = writable_directories
216 .iter()
217 .map(|path| path.to_path_buf())
218 .collect();
219 let canonical_protected_paths: Vec<PathBuf> = protected_paths
220 .iter()
221 .map(|path| path.to_path_buf())
222 .collect();
223 // Unlike file paths, Unix socket literals are emitted verbatim: it isn't
224 // guaranteed whether Seatbelt resolves symlinks before matching a
225 // `remote unix-socket` literal, so the caller passes both the path the
226 // child connects to and its canonical form, and we keep them as given.
227
228 let mut config = r#"(version 1)
229
230; Start by denying everything
231(deny default)
232
233; Allow reading from the entire filesystem
234(allow file-read*)
235
236; Allow process execution
237(allow process-exec*)
238(allow process-fork)
239
240; Allow signal handling
241(allow signal)
242
243; Allow sysctl reads (needed for many system calls)
244(allow sysctl-read)
245
246; Mach service lookups. This is an ALLOWLIST, not a blanket `(allow mach-lookup)`.
247; An unrestricted mach-lookup lets a sandboxed command reach LaunchServices /
248; launchd and have a process spawned *outside* the sandbox (e.g. `open -a
249; Terminal`, or opening a crafted `.app`) — the launched process does not inherit
250; this profile, so that is a full sandbox escape. So we allow only the services
251; ordinary dev tooling needs and deliberately EXCLUDE the LaunchServices/launchd
252; endpoints (closing that escape), the pasteboard (silent clipboard theft), and
253; audio (mic/privacy). Curated from Codex's and Chromium's Seatbelt policies; add
254; an entry here (with a comment) if a legitimate toolchain needs another service.
255; A non-existent name is simply never matched, so erring toward including
256; plausible infrastructure services is safe.
257(allow mach-lookup
258 ; identity: user & group resolution (getpwuid, id, whoami, perm checks)
259 (global-name "com.apple.system.opendirectoryd.libinfo")
260 (global-name "com.apple.system.opendirectoryd.membership")
261 (global-name "com.apple.system.DirectoryService.libinfo_v1")
262 ; per-user temp/cache dir resolution ($TMPDIR, /var/folders/...)
263 (global-name "com.apple.bsd.dirhelper")
264 ; CFPreferences (pervasive in Apple frameworks linked by dev tools).
265 ; Chromium denies cfprefsd.daemon to force in-process prefs; we follow Codex
266 ; and allow it since dev commands legitimately use many prefs domains — it's
267 ; a prefs read/write, not an escape.
268 (global-name "com.apple.cfprefsd.daemon")
269 (global-name "com.apple.cfprefsd.agent")
270 (local-name "com.apple.cfprefsd.agent")
271 ; logging / diagnostics (os_log, ASL, Darwin notifications)
272 (global-name "com.apple.logd")
273 (global-name "com.apple.logd.events")
274 (global-name "com.apple.system.logger")
275 (global-name "com.apple.diagnosticd")
276 (global-name "com.apple.system.notification_center")
277 ; Apple telemetry (data goes to Apple only; harmless, avoids init latency)
278 (global-name "com.apple.analyticsd")
279 (global-name "com.apple.analyticsd.messagetracer")
280 ; power assertions (caffeinate / prevent idle sleep during long builds)
281 (global-name "com.apple.PowerManagement.control")
282 ; developer-tools automation-mode flag (our workload is dev tooling)
283 (global-name "com.apple.dt.automationmode.reader"))
284
285; Allow pseudo-terminal operations
286(allow pseudo-tty)
287(allow file-read* file-write* file-ioctl
288 (literal "/dev/ptmx"))
289(allow file-read* file-write*
290 (require-all
291 (regex #"^/dev/ttys[0-9]+$")
292 (extension "com.apple.sandbox.pty")))
293
294; The command's PTY is allocated after this profile is generated, so its slave
295; TTY path isn't known here and may lack the `com.apple.sandbox.pty` extension.
296; Allow ioctls on slave TTYs so interactive shells and signing prompts can
297; manipulate terminal state. Seatbelt can't filter by ioctl request number, so
298; this can't exclude input-injection ioctls (e.g. TIOCSTI) specifically. The
299; residual risk is bounded by the kernel, not by this profile: XNU's TIOCSTI
300; handler (bsd/kern/tty.c) rejects a non-root caller unless the target TTY is
301; the caller's own controlling terminal (EACCES otherwise). Each agent command
302; runs in its own dedicated PTY, so the only TTY it can inject into is that
303; throwaway PTY, not the user's interactive terminal. The regex is also anchored
304; so it matches only `/dev/ttysNNN` device nodes.
305(allow file-ioctl
306 (regex #"^/dev/ttys[0-9]+$"))
307"#
308 .to_string();
309
310 if permissions.allow_fs_write {
311 config.push_str(
312 r#"
313; Allow unrestricted filesystem writes
314(allow file-write*)
315"#,
316 );
317 } else {
318 for canonical_path in &canonical_writable_directories {
319 let escaped_path = escape_sandbox_path(canonical_path)?;
320 config.push_str(&format!(
321 r#"
322; Allow writing to a permitted directory
323(allow file-write*
324 (subpath "{escaped_path}"))
325"#
326 ));
327 }
328
329 config.push_str(
330 r#"
331; Allow writing to common /dev paths (needed for redirections like 2>/dev/null)
332(allow file-write*
333 (literal "/dev/null")
334 (literal "/dev/zero")
335 (literal "/dev/tty")
336 (literal "/dev/stdin")
337 (literal "/dev/stdout")
338 (literal "/dev/stderr")
339 (subpath "/dev/fd"))
340"#,
341 );
342 }
343
344 for protected_path in &canonical_protected_paths {
345 let escaped_path = escape_sandbox_path(protected_path)?;
346 // `subpath` already matches the path itself plus everything beneath it,
347 // so no redundant `literal` rule is needed.
348 config.push_str(&format!(
349 r#"
350; Block protected path writes
351(deny file-write*
352 (subpath "{escaped_path}"))
353"#
354 ));
355 }
356
357 match permissions.network {
358 NetworkAccess::None => {}
359 NetworkAccess::All => {
360 config.push_str(
361 r#"
362; Allow network access
363(allow network*)
364"#,
365 );
366 }
367 NetworkAccess::LocalhostPort(port) => {
368 // Seatbelt rejects IP literals in `(remote tcp ...)` rules — it
369 // only accepts `*` or `localhost` as the host part. The runtime
370 // resolves correctly when the sandboxed process connects to
371 // `127.0.0.1:<port>`, so this is a syntactic constraint, not a
372 // routing one.
373 config.push_str(&format!(
374 r#"
375; Allow outbound network only to the in-process proxy on localhost
376(allow network-outbound (remote tcp "localhost:{port}"))
377; Network binds (sandboxed process picking its own ephemeral source port)
378(allow network-bind (local ip "localhost:*"))
379"#,
380 ));
381 }
382 }
383
384 // When outbound network is permitted at all, tools that do their own DNS
385 // resolution, TLS trust evaluation, and network-configuration lookups need a
386 // few more Mach services. Kept out of the base allowlist so a no-network
387 // command can't reach them. Still an allowlist (mirrors Codex's Seatbelt
388 // network policy) that excludes LaunchServices/launchd.
389 if !matches!(permissions.network, NetworkAccess::None) {
390 config.push_str(
391 r#"
392; Extra Mach services for DNS / TLS-trust / network configuration, needed only
393; when outbound network is permitted. Still an allowlist that excludes
394; LaunchServices/launchd. If hostname resolution fails, add
395; `com.apple.mDNSResponder`; if offline code-signature verification of loaded
396; dylibs/plugins fails, move the trust services into the base block above.
397(allow mach-lookup
398 ; network / DNS configuration
399 (global-name "com.apple.SystemConfiguration.configd")
400 (global-name "com.apple.SystemConfiguration.DNSConfiguration")
401 (global-name "com.apple.networkd")
402 ; TLS certificate trust / keychain / revocation
403 (global-name "com.apple.SecurityServer")
404 (global-name "com.apple.trustd")
405 (global-name "com.apple.trustd.agent")
406 (global-name "com.apple.ocspd"))
407"#,
408 );
409 }
410
411 if !allowed_unix_socket_paths.is_empty() {
412 config.push_str(
413 r#"
414; Allow local IPC to inherited Unix domain sockets. Seatbelt models this as
415; network-outbound, but this does not permit IP networking or sending packets
416; to other machines.
417;
418; `system-socket` only governs the `socket()` syscall (creating an AF_UNIX
419; socket), which is harmless on its own. The capability that matters,
420; `connect()`, stays gated by the per-path `network-outbound (remote
421; unix-socket ...)` rules below, so `(deny default)` still blocks connecting to
422; any socket not explicitly allow-listed.
423(allow system-socket
424 (socket-domain AF_UNIX))
425"#,
426 );
427
428 for socket_path in allowed_unix_socket_paths {
429 let escaped_path = escape_sandbox_path(socket_path)?;
430 config.push_str(&format!(
431 r#"(allow network-outbound
432 (remote unix-socket
433 (literal "{escaped_path}")))
434"#
435 ));
436 }
437 }
438
439 Ok(config)
440}
441
442/// Escape a path for use in a Seatbelt config string.
443///
444/// Seatbelt configs use a Scheme-like syntax where certain characters need
445/// to be handled carefully.
446fn escape_sandbox_path(path: &Path) -> Result<String> {
447 let path_str = path
448 .to_str()
449 .with_context(|| format!("path contains invalid UTF-8: {}", path.display()))?;
450 Ok(path_str.replace('\\', "\\\\").replace('"', "\\\""))
451}
452
453#[cfg(test)]
454#[allow(
455 clippy::disallowed_methods,
456 reason = "tests run sandbox-exec synchronously to verify the generated Seatbelt config"
457)]
458mod tests {
459 use super::*;
460 use std::path::PathBuf;
461
462 /// Strip SBPL comment lines (`;`-prefixed) from a generated Seatbelt profile
463 /// so assertions match on the actual rules rather than on documentation.
464 /// Several comments legitimately mention rule syntax (for example the
465 /// blanket `(allow mach-lookup)` form they explain we avoid), which would
466 /// otherwise cause a naive substring check to spuriously match.
467 fn seatbelt_rules_only(config: &str) -> String {
468 config
469 .lines()
470 .filter(|line| !line.trim_start().starts_with(';'))
471 .collect::<Vec<_>>()
472 .join("\n")
473 }
474
475 #[test]
476 fn test_generate_seatbelt_config_contains_read_and_project_write_permissions_by_default() {
477 let dir = PathBuf::from("/Users/test/projects/myproject");
478 let config =
479 generate_seatbelt_config(&[dir.as_path()], &[], &[], SandboxPermissions::default())
480 .unwrap();
481
482 assert!(config.contains("(allow file-read*)"));
483 assert!(config.contains("/Users/test/projects/myproject"));
484 assert!(config.contains("(allow file-write*"));
485 assert!(!config.contains("; Allow unrestricted filesystem writes"));
486 assert!(!config.contains("(allow network*)"));
487 }
488
489 #[test]
490 fn test_generate_seatbelt_config_allows_unrestricted_writes_when_fs_writes_allowed() {
491 let dir = PathBuf::from("/Users/test/projects/myproject");
492 let config = generate_seatbelt_config(
493 &[dir.as_path()],
494 &[],
495 &[],
496 SandboxPermissions {
497 network: NetworkAccess::None,
498 allow_fs_write: true,
499 },
500 )
501 .unwrap();
502
503 assert!(config.contains("(allow file-read*)"));
504 assert!(config.contains("; Allow unrestricted filesystem writes"));
505 assert!(config.contains("(allow file-write*)"));
506 assert!(!config.contains("/Users/test/projects/myproject"));
507 assert!(!config.contains("(allow network*)"));
508 }
509
510 #[test]
511 fn test_generate_seatbelt_config_allows_terminal_ioctls_by_default() {
512 let dir = PathBuf::from("/Users/test/projects/myproject");
513 let config =
514 generate_seatbelt_config(&[dir.as_path()], &[], &[], SandboxPermissions::default())
515 .unwrap();
516
517 assert!(config.contains("(allow file-ioctl"));
518 assert!(config.contains("/dev/ptmx"));
519 assert!(config.contains("^/dev/ttys[0-9]+"));
520 }
521
522 #[test]
523 fn test_generate_seatbelt_config_scopes_mach_lookup_and_excludes_escape_services() {
524 let dir = PathBuf::from("/Users/test/projects/myproject");
525 let config =
526 generate_seatbelt_config(&[dir.as_path()], &[], &[], SandboxPermissions::default())
527 .unwrap();
528 // Assert on the rules only: the mach-lookup comment intentionally spells
529 // out the blanket `(allow mach-lookup)` form it avoids, which a raw
530 // `config.contains` would match.
531 let rules = seatbelt_rules_only(&config);
532
533 // A scoped allowlist, never the blanket form — a blanket `(allow
534 // mach-lookup)` would let a command reach LaunchServices/launchd and
535 // escape the sandbox via `open`.
536 assert!(rules.contains("(allow mach-lookup"));
537 assert!(!rules.contains("(allow mach-lookup)"));
538 assert!(rules.contains("com.apple.cfprefsd.daemon"));
539
540 // The escape/abuse endpoints must fall through to `(deny default)`.
541 assert!(!rules.contains("launchservicesd"));
542 assert!(!rules.contains("com.apple.lsd"));
543 assert!(!rules.contains("com.apple.pasteboard"));
544
545 // Network-only services must not be granted without network.
546 assert!(!rules.contains("com.apple.SecurityServer"));
547 assert!(!rules.contains("com.apple.SystemConfiguration.configd"));
548 }
549
550 #[test]
551 fn test_generate_seatbelt_config_adds_network_mach_services_when_network_allowed() {
552 let dir = PathBuf::from("/Users/test/projects/myproject");
553 let config = generate_seatbelt_config(
554 &[dir.as_path()],
555 &[],
556 &[],
557 SandboxPermissions {
558 network: NetworkAccess::All,
559 allow_fs_write: false,
560 },
561 )
562 .unwrap();
563
564 // DNS / TLS / network-config services appear only when network is allowed.
565 assert!(config.contains("com.apple.SystemConfiguration.configd"));
566 assert!(config.contains("com.apple.SecurityServer"));
567 assert!(config.contains("com.apple.trustd"));
568 assert!(config.contains("com.apple.trustd.agent"));
569 // ...but never the escape endpoints.
570 assert!(!config.contains("launchservicesd"));
571 }
572
573 #[test]
574 fn test_generate_seatbelt_config_allows_unix_socket_paths_without_network() {
575 let dir = PathBuf::from("/Users/test/projects/myproject");
576 let socket_path = PathBuf::from("/private/tmp/com.example.test/Listeners");
577 let config = generate_seatbelt_config(
578 &[dir.as_path()],
579 &[],
580 &[socket_path.as_path()],
581 SandboxPermissions::default(),
582 )
583 .unwrap();
584
585 assert!(config.contains("(allow system-socket"));
586 assert!(config.contains("AF_UNIX"));
587 assert!(config.contains("(allow network-outbound"));
588 assert!(config.contains("remote unix-socket"));
589 assert!(config.contains("(literal \"/private/tmp/com.example.test/Listeners\")"));
590 assert!(!config.contains("(subpath \"/private/tmp/com.example.test/Listeners\")"));
591 assert!(!config.contains("(allow network*)"));
592 }
593
594 #[test]
595 fn test_sandbox_allows_connecting_to_allowed_unix_socket() {
596 use std::io::ErrorKind;
597 use std::os::unix::net::UnixListener;
598 use std::process::{Command, Stdio};
599 use std::time::{Duration, Instant};
600
601 // Bind under `/tmp` rather than the default temp dir: macOS temp paths
602 // (`/var/folders/...`) overflow the `sun_path` limit for Unix sockets.
603 // `TempDir` still cleans up on drop, even if the test panics.
604 let temp_dir = tempfile::Builder::new()
605 .prefix("zed-sock-")
606 .tempdir_in("/tmp")
607 .unwrap();
608 let socket_path = temp_dir.path().join("agent.sock");
609 let listener = UnixListener::bind(&socket_path).expect("test socket should bind");
610 listener
611 .set_nonblocking(true)
612 .expect("listener should switch to non-blocking");
613
614 let canonical_socket_path = socket_path
615 .canonicalize()
616 .expect("bound socket path should canonicalize");
617
618 // Accept (and immediately drop) a single connection, with a bounded wait
619 // so the test can't hang if the sandbox blocks the connection instead.
620 let accepted = std::thread::spawn(move || {
621 let deadline = Instant::now() + Duration::from_secs(10);
622 loop {
623 match listener.accept() {
624 Ok(_) => return true,
625 Err(error) if error.kind() == ErrorKind::WouldBlock => {
626 if Instant::now() >= deadline {
627 return false;
628 }
629 std::thread::sleep(Duration::from_millis(20));
630 }
631 Err(_) => return false,
632 }
633 }
634 });
635
636 let (program, args, _config_file) = wrap_invocation(
637 "/usr/bin/nc",
638 &[
639 "-U".to_string(),
640 "-w".to_string(),
641 "5".to_string(),
642 socket_path.display().to_string(),
643 ],
644 &[temp_dir.path()],
645 &[],
646 &[socket_path.as_path(), canonical_socket_path.as_path()],
647 SandboxPermissions::default(),
648 )
649 .unwrap();
650
651 let output = Command::new(&program)
652 .args(&args)
653 .stdin(Stdio::null())
654 .output()
655 .expect("failed to execute sandbox-exec");
656
657 assert!(
658 accepted.join().unwrap(),
659 "sandbox should allow connecting to the allow-listed unix socket: stderr={} stdout={}",
660 String::from_utf8_lossy(&output.stderr),
661 String::from_utf8_lossy(&output.stdout),
662 );
663 }
664
665 #[test]
666 fn test_generate_seatbelt_config_denies_protected_path_writes() {
667 let dir = PathBuf::from("/Users/test/projects/myproject");
668 let protected = dir.join(".gitignore");
669 let config = generate_seatbelt_config(
670 &[dir.as_path()],
671 &[protected.as_path()],
672 &[],
673 SandboxPermissions::default(),
674 )
675 .unwrap();
676
677 assert!(config.contains("(deny file-write*"));
678 assert!(!config.contains("file-read-data"));
679 assert!(config.contains("(subpath \"/Users/test/projects/myproject/.gitignore\")"));
680 // `subpath` already covers the path itself, so no redundant `literal`.
681 assert!(!config.contains("(literal \"/Users/test/projects/myproject/.gitignore\")"));
682 }
683
684 #[test]
685 fn test_sandbox_allows_protected_path_reads_but_blocks_writes() {
686 use std::process::Command;
687
688 let temp_dir = tempfile::tempdir().unwrap();
689 // Canonicalize so the policy paths resolve the macOS `/var` -> `/private/var`
690 // symlink; Seatbelt matches rules against the resolved path, as production
691 // does via `HostFilesystemLocation`.
692 let dir = std::fs::canonicalize(temp_dir.path()).unwrap();
693 let protected_file = dir.join(".gitignore");
694 std::fs::write(&protected_file, "target\n").unwrap();
695
696 let (program, args, _config_file) = wrap_invocation(
697 "/bin/sh",
698 &[
699 "-c".to_string(),
700 format!(
701 "test -e '{}' && cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'",
702 protected_file.display(),
703 protected_file.display(),
704 protected_file.display(),
705 ),
706 ],
707 &[dir.as_path()],
708 &[protected_file.as_path()],
709 &[],
710 SandboxPermissions::default(),
711 )
712 .unwrap();
713
714 let output = Command::new(&program)
715 .args(&args)
716 .output()
717 .expect("failed to execute sandbox-exec");
718
719 assert!(
720 output.status.success(),
721 "sandbox should allow protected reads but deny protected writes: stderr={} stdout={}",
722 String::from_utf8_lossy(&output.stderr),
723 String::from_utf8_lossy(&output.stdout),
724 );
725 assert_eq!(
726 std::fs::read_to_string(&protected_file).unwrap(),
727 "target\n"
728 );
729 }
730
731 #[test]
732 fn test_sandbox_blocks_protected_paths_even_when_fs_writes_allowed() {
733 use std::process::Command;
734
735 let temp_dir = tempfile::tempdir().unwrap();
736 // See the sibling protected-path test: canonicalize so policy paths resolve
737 // the macOS `/var` -> `/private/var` symlink that Seatbelt matches against.
738 let dir = std::fs::canonicalize(temp_dir.path()).unwrap();
739 let protected_file = dir.join(".gitignore");
740 std::fs::write(&protected_file, "target\n").unwrap();
741
742 let (program, args, _config_file) = wrap_invocation(
743 "/bin/sh",
744 &[
745 "-c".to_string(),
746 format!(
747 "cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'",
748 protected_file.display(),
749 protected_file.display(),
750 ),
751 ],
752 &[dir.as_path()],
753 &[protected_file.as_path()],
754 &[],
755 SandboxPermissions {
756 network: NetworkAccess::None,
757 allow_fs_write: true,
758 },
759 )
760 .unwrap();
761
762 let output = Command::new(&program)
763 .args(&args)
764 .output()
765 .expect("failed to execute sandbox-exec");
766
767 assert!(
768 output.status.success(),
769 "protected paths should stay readable but not writable even with unrestricted writes: stderr={} stdout={}",
770 String::from_utf8_lossy(&output.stderr),
771 String::from_utf8_lossy(&output.stdout),
772 );
773 assert_eq!(
774 std::fs::read_to_string(&protected_file).unwrap(),
775 "target\n"
776 );
777 }
778
779 #[test]
780 fn test_generate_seatbelt_config_contains_network_when_allowed() {
781 let dir = PathBuf::from("/Users/test/projects/myproject");
782 let config = generate_seatbelt_config(
783 &[dir.as_path()],
784 &[],
785 &[],
786 SandboxPermissions {
787 network: NetworkAccess::All,
788 allow_fs_write: false,
789 },
790 )
791 .unwrap();
792
793 assert!(config.contains("(allow network*)"));
794 assert!(config.contains("/Users/test/projects/myproject"));
795 assert!(config.contains("(allow file-write*"));
796 assert!(!config.contains("; Allow unrestricted filesystem writes"));
797 }
798
799 #[test]
800 fn test_generate_seatbelt_config_localhost_port_narrows_network() {
801 let dir = PathBuf::from("/Users/test/projects/myproject");
802 let config = generate_seatbelt_config(
803 &[dir.as_path()],
804 &[],
805 &[],
806 SandboxPermissions {
807 network: NetworkAccess::LocalhostPort(54321),
808 allow_fs_write: false,
809 },
810 )
811 .unwrap();
812
813 // Only the loopback proxy port is reachable; no blanket network rule.
814 assert!(config.contains("(allow network-outbound (remote tcp \"localhost:54321\"))"));
815 assert!(config.contains("(allow network-bind (local ip \"localhost:*\"))"));
816 assert!(!config.contains("(allow network*)"));
817 }
818
819 #[test]
820 fn test_generate_seatbelt_config_emits_one_subpath_per_writable_directory() {
821 let project_dir = PathBuf::from("/Users/test/projects/myproject");
822 let scratch_dir = PathBuf::from("/private/tmp/omega-agent-command");
823 let config = generate_seatbelt_config(
824 &[project_dir.as_path(), scratch_dir.as_path()],
825 &[],
826 &[],
827 SandboxPermissions::default(),
828 )
829 .unwrap();
830
831 assert!(config.contains("/Users/test/projects/myproject"));
832 assert!(config.contains("/private/tmp/omega-agent-command"));
833 assert!(!config.contains("; Allow unrestricted filesystem writes"));
834 assert!(!config.contains("(allow network*)"));
835 }
836
837 #[test]
838 fn test_escape_sandbox_path_handles_special_chars() {
839 let path = PathBuf::from("/path/with\"quotes");
840 let escaped = escape_sandbox_path(&path).unwrap();
841 assert_eq!(escaped, "/path/with\\\"quotes");
842 }
843
844 #[cfg(unix)]
845 #[test]
846 fn test_escape_sandbox_path_rejects_invalid_utf8() {
847 use std::{ffi::OsString, os::unix::ffi::OsStringExt};
848
849 let path = PathBuf::from(OsString::from_vec(b"/path/with/invalid/\xFF".to_vec()));
850 let error = escape_sandbox_path(&path).unwrap_err();
851
852 assert!(error.to_string().contains("invalid UTF-8"));
853 }
854
855 #[test]
856 fn test_wrap_invocation_structure() {
857 let temp_dir = tempfile::tempdir().unwrap();
858 let (program, args, _config_file) = wrap_invocation(
859 "/bin/sh",
860 &["-c".to_string(), "echo hello".to_string()],
861 &[temp_dir.path()],
862 &[],
863 &[],
864 SandboxPermissions::default(),
865 )
866 .unwrap();
867
868 assert_eq!(program, "/usr/bin/sandbox-exec");
869 assert_eq!(args[0], "-f");
870 // args[1] is the temp file path
871 assert_eq!(args[2], "/bin/sh");
872 assert_eq!(args[3], "-c");
873 assert_eq!(args[4], "echo hello");
874 }
875
876 #[test]
877 fn test_sandbox_allows_read_everywhere() {
878 use std::process::Command;
879
880 let temp_dir = tempfile::tempdir().unwrap();
881 let (program, args, _config_file) = wrap_invocation(
882 "/bin/sh",
883 &["-c".to_string(), "cat /etc/hosts".to_string()],
884 &[temp_dir.path()],
885 &[],
886 &[],
887 SandboxPermissions::default(),
888 )
889 .unwrap();
890
891 let output = Command::new(&program)
892 .args(&args)
893 .output()
894 .expect("failed to execute sandbox-exec");
895
896 assert!(
897 output.status.success(),
898 "sandbox should allow reading /etc/hosts: {}",
899 String::from_utf8_lossy(&output.stderr)
900 );
901 }
902
903 #[test]
904 fn test_sandbox_allows_dev_null_redirection_by_default() {
905 use std::process::Command;
906
907 let temp_dir = tempfile::tempdir().unwrap();
908 let (program, args, _config_file) = wrap_invocation(
909 "/bin/sh",
910 &["-c".to_string(), "echo test 2>/dev/null".to_string()],
911 &[temp_dir.path()],
912 &[],
913 &[],
914 SandboxPermissions::default(),
915 )
916 .unwrap();
917
918 let output = Command::new(&program)
919 .args(&args)
920 .output()
921 .expect("failed to execute sandbox-exec");
922
923 assert!(
924 output.status.success(),
925 "sandbox should allow redirecting to /dev/null by default: {}",
926 String::from_utf8_lossy(&output.stderr)
927 );
928 }
929
930 #[test]
931 fn test_sandbox_allows_dev_null_redirection_when_fs_writes_allowed() {
932 use std::process::Command;
933
934 let temp_dir = tempfile::tempdir().unwrap();
935 let (program, args, _config_file) = wrap_invocation(
936 "/bin/sh",
937 &["-c".to_string(), "echo test 2>/dev/null".to_string()],
938 &[temp_dir.path()],
939 &[],
940 &[],
941 SandboxPermissions {
942 network: NetworkAccess::None,
943 allow_fs_write: true,
944 },
945 )
946 .unwrap();
947
948 let output = Command::new(&program)
949 .args(&args)
950 .output()
951 .expect("failed to execute sandbox-exec");
952
953 assert!(
954 output.status.success(),
955 "sandbox should allow redirecting to /dev/null: {}",
956 String::from_utf8_lossy(&output.stderr)
957 );
958 }
959
960 #[test]
961 fn test_sandbox_allows_write_to_project_directory_when_fs_writes_allowed() {
962 use std::process::Command;
963
964 let temp_dir = tempfile::tempdir().unwrap();
965 let test_file = temp_dir.path().join("test_write.txt");
966
967 let (program, args, _config_file) = wrap_invocation(
968 "/bin/sh",
969 &[
970 "-c".to_string(),
971 format!("echo 'hello' > '{}'", test_file.display()),
972 ],
973 &[temp_dir.path()],
974 &[],
975 &[],
976 SandboxPermissions {
977 network: NetworkAccess::None,
978 allow_fs_write: true,
979 },
980 )
981 .unwrap();
982
983 let output = Command::new(&program)
984 .args(&args)
985 .output()
986 .expect("failed to execute sandbox-exec");
987
988 assert!(
989 output.status.success(),
990 "sandbox should allow writing to project dir: {}",
991 String::from_utf8_lossy(&output.stderr)
992 );
993 assert!(test_file.exists(), "file should have been created");
994 }
995
996 #[test]
997 fn test_sandbox_allows_write_to_any_listed_writable_directory() {
998 use std::process::Command;
999
1000 let project_dir = tempfile::tempdir().unwrap();
1001 let scratch_dir = tempfile::tempdir().unwrap();
1002 // Canonicalize so the writable subpaths resolve the macOS `/var` ->
1003 // `/private/var` symlink that Seatbelt matches against.
1004 let project_path = std::fs::canonicalize(project_dir.path()).unwrap();
1005 let scratch_path = std::fs::canonicalize(scratch_dir.path()).unwrap();
1006 let test_file = scratch_path.join("test_write.txt");
1007
1008 let (program, args, _config_file) = wrap_invocation(
1009 "/bin/sh",
1010 &[
1011 "-c".to_string(),
1012 format!("echo 'hello' > '{}'", test_file.display()),
1013 ],
1014 &[project_path.as_path(), scratch_path.as_path()],
1015 &[],
1016 &[],
1017 SandboxPermissions::default(),
1018 )
1019 .unwrap();
1020
1021 let output = Command::new(&program)
1022 .args(&args)
1023 .output()
1024 .expect("failed to execute sandbox-exec");
1025
1026 assert!(
1027 output.status.success(),
1028 "sandbox should allow writing to a non-first writable directory: {}",
1029 String::from_utf8_lossy(&output.stderr)
1030 );
1031 assert!(test_file.exists(), "file should have been created");
1032 }
1033
1034 #[test]
1035 fn test_sandbox_allows_write_to_project_directory_by_default() {
1036 use std::process::Command;
1037
1038 let temp_dir = tempfile::tempdir().unwrap();
1039 // Canonicalize so the writable subpath resolves the macOS `/var` ->
1040 // `/private/var` symlink that Seatbelt matches against.
1041 let dir = std::fs::canonicalize(temp_dir.path()).unwrap();
1042 let test_file = dir.join("test_write.txt");
1043
1044 let (program, args, _config_file) = wrap_invocation(
1045 "/bin/sh",
1046 &[
1047 "-c".to_string(),
1048 format!("echo 'hello' > '{}'", test_file.display()),
1049 ],
1050 &[dir.as_path()],
1051 &[],
1052 &[],
1053 SandboxPermissions::default(),
1054 )
1055 .unwrap();
1056
1057 let output = Command::new(&program)
1058 .args(&args)
1059 .output()
1060 .expect("failed to execute sandbox-exec");
1061
1062 assert!(
1063 output.status.success(),
1064 "sandbox should allow writing to project dir by default: {}",
1065 String::from_utf8_lossy(&output.stderr)
1066 );
1067 assert!(test_file.exists(), "file should have been created");
1068 }
1069
1070 #[test]
1071 fn test_sandbox_allows_write_to_system_tmp_when_fs_writes_allowed() {
1072 use std::process::Command;
1073
1074 let project_dir = tempfile::tempdir().unwrap();
1075 let test_file = PathBuf::from("/tmp/zed-sandbox-write-test");
1076 let _ = std::fs::remove_file(&test_file);
1077
1078 let (program, args, _config_file) = wrap_invocation(
1079 "/bin/sh",
1080 &[
1081 "-c".to_string(),
1082 format!("echo 'hello' > '{}'", test_file.display()),
1083 ],
1084 &[project_dir.path()],
1085 &[],
1086 &[],
1087 SandboxPermissions {
1088 network: NetworkAccess::None,
1089 allow_fs_write: true,
1090 },
1091 )
1092 .unwrap();
1093
1094 let output = Command::new(&program)
1095 .args(&args)
1096 .output()
1097 .expect("failed to execute sandbox-exec");
1098
1099 assert!(
1100 output.status.success(),
1101 "sandbox should allow writing to system tmp when filesystem writes are allowed: {}",
1102 String::from_utf8_lossy(&output.stderr)
1103 );
1104 assert!(test_file.exists(), "file should have been created");
1105 let _ = std::fs::remove_file(&test_file);
1106 }
1107
1108 #[test]
1109 fn test_sandbox_denies_write_outside_project_directory_by_default() {
1110 use std::process::Command;
1111
1112 let project_dir = tempfile::tempdir().unwrap();
1113 let forbidden_file = std::env::home_dir()
1114 .unwrap()
1115 .join(".zed-sandbox-forbidden-write-test");
1116 let _ = std::fs::remove_file(&forbidden_file);
1117
1118 let (program, args, _config_file) = wrap_invocation(
1119 "/bin/sh",
1120 &[
1121 "-c".to_string(),
1122 format!("echo 'hello' > '{}'", forbidden_file.display()),
1123 ],
1124 &[project_dir.path()],
1125 &[],
1126 &[],
1127 SandboxPermissions::default(),
1128 )
1129 .unwrap();
1130
1131 let output = Command::new(&program)
1132 .args(&args)
1133 .output()
1134 .expect("failed to execute sandbox-exec");
1135
1136 assert!(
1137 !output.status.success(),
1138 "sandbox should deny writing outside project dir when filesystem writes are not allowed"
1139 );
1140 assert!(
1141 !forbidden_file.exists(),
1142 "file should not have been created"
1143 );
1144 }
1145}
1146