Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:49:44.667Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

macos_seatbelt.rs

1146 lines · 43.2 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.