Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:39:43.698Z 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

bwrap_test_helper.rs

580 lines · 23.5 KB · rust
1//! Test binary used exclusively by the NixOS integration tests for the
2//! Bubblewrap sandbox at `nix/tests/sandboxing`. See the comment there.
3//!
4//! It drives the sandbox crate's *public* API only (`Sandbox`, `SandboxPolicy`,
5//! …) — never platform internals — so it doubles as a check that the public API
6//! is sufficient to express and enforce the policies the agent needs.
7//!
8//! The list of checks to run is *data*, declared by the Nix test and handed to
9//! this binary as a JSON file (path in `ZED_SANDBOX_CHECKS`). Each check
10//! describes a sandbox policy, an operation to attempt under that policy, and
11//! the expected outcome; this binary executes each and asserts the result.
12
13#![allow(
14    clippy::disallowed_methods,
15    reason = "a single-threaded test helper that intentionally blocks on child processes"
16)]
17
18#[cfg(not(target_os = "linux"))]
19fn main() {
20    eprintln!("bwrap_test_helper is only supported on Linux");
21    std::process::exit(1);
22}
23
24#[cfg(target_os = "linux")]
25fn main() {
26    imp::main();
27}
28
29#[cfg(target_os = "linux")]
30mod imp {
31    use std::io::{Read as _, Write as _};
32    use std::net::TcpStream;
33    use std::path::Path;
34    use std::time::Duration;
35
36    use anyhow::{Context as _, Result, bail};
37    use sandbox::{
38        CommandAndArgs, HostFilesystemLocation, Sandbox, SandboxError, SandboxFsPolicy,
39        SandboxNetPolicy, SandboxPolicy,
40    };
41    use serde::Deserialize;
42
43    /// Internal subcommand: round-trip a byte through the echo server at the
44    /// given `host:port`, honoring `HTTP_PROXY` when set (the restricted-network
45    /// case routes through the sandbox proxy via HTTP CONNECT). Exits 0 on a
46    /// successful round-trip, non-zero otherwise. Run *inside* the sandbox.
47    const SUBCOMMAND_ECHO_CHECK: &str = "__echo_check";
48
49    /// Internal subcommand: connect to the unix-domain socket at the given path
50    /// and round-trip a byte through it. Exits 0 on a successful round-trip,
51    /// non-zero on any failure (including `socket(AF_UNIX)` being blocked once
52    /// the seccomp guard lands). Run *inside* the sandbox.
53    const SUBCOMMAND_UNIX_CONNECT_CHECK: &str = "__unix_connect_check";
54
55    /// Default port for echo targets given as a bare hostname (e.g. `echo1`).
56    const DEFAULT_ECHO_PORT: &str = "7000";
57
58    pub fn main() {
59        // If we were re-exec'd as the restricted-network bridge, this starts the
60        // bridge and execs the wrapped command without returning.
61        sandbox::run_sandbox_launcher_if_invoked();
62
63        let args: Vec<String> = std::env::args().collect();
64        let result = match args.get(1).map(String::as_str) {
65            Some(SUBCOMMAND_ECHO_CHECK) => run_echo_check(args.get(2).map(String::as_str)),
66            Some(SUBCOMMAND_UNIX_CONNECT_CHECK) => {
67                run_unix_connect_check(args.get(2).map(String::as_str))
68            }
69            _ => run_checks(),
70        };
71
72        if let Err(error) = result {
73            eprintln!("[sandbox_test]: FAILED: {error:#}");
74            std::process::exit(1);
75        }
76    }
77
78    /// Filesystem policy as declared in a check (`fs` field).
79    #[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)]
80    #[serde(rename_all = "lowercase")]
81    enum FsMode {
82        /// Reads allowed everywhere; writes confined to `writablePaths`.
83        #[default]
84        Restricted,
85        /// Writes allowed anywhere.
86        Unrestricted,
87    }
88
89    /// Network policy as declared in a check (`networkAccess` field).
90    #[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)]
91    #[serde(rename_all = "lowercase")]
92    enum NetMode {
93        /// All outbound network blocked.
94        #[default]
95        Blocked,
96        /// All outbound network allowed.
97        Unrestricted,
98        /// Outbound HTTP(S) allowed only to `allowedDomains`.
99        Restricted,
100    }
101
102    /// One declarative check: a sandbox policy, an operation, and the expected
103    /// result. Deserialized from the JSON the Nix test produces.
104    ///
105    /// Exactly one operation field (`read`, `write`, `network`, `socketPath`, or
106    /// `canCreate`) must be set. Policy fields default to the most-confined policy
107    /// (restricted filesystem with no writable paths, blocked network).
108    #[derive(Debug, Default, Deserialize)]
109    #[serde(rename_all = "camelCase", deny_unknown_fields)]
110    struct Check {
111        /// Optional human label; falls back to an auto-generated description.
112        #[serde(default)]
113        name: Option<String>,
114
115        // ---- policy ----
116        #[serde(default)]
117        fs: FsMode,
118        #[serde(default)]
119        writable_paths: Vec<String>,
120        #[serde(default)]
121        network_access: NetMode,
122        #[serde(default)]
123        allowed_domains: Vec<String>,
124        /// Paths to protect from writes even if they fall under a writable path.
125        #[serde(default)]
126        protected_paths: Vec<String>,
127
128        // ---- operation (exactly one) ----
129        /// Read this host path from inside the sandbox.
130        #[serde(default)]
131        read: Option<String>,
132        /// Write to this host path from inside the sandbox.
133        #[serde(default)]
134        write: Option<String>,
135        /// Connect to this echo host (`hostname` or `hostname:port`) from inside
136        /// the sandbox.
137        #[serde(default)]
138        network: Option<String>,
139        /// Connect to this unix-domain socket path from inside the sandbox.
140        #[serde(default)]
141        socket_path: Option<String>,
142        /// Assert that `Sandbox::can_create` for this policy matches the value:
143        /// `true` => the sandbox can be created, `false` => it cannot.
144        #[serde(default)]
145        can_create: Option<bool>,
146
147        // ---- expectation ----
148        /// Expected outcome for `read` / `write` / `network`.
149        #[serde(default)]
150        succeeds: Option<bool>,
151        /// Expected error kind for `canCreate = false`
152        /// (`bwrap_not_found` | `setuid_rejected` | `probe_failed`). When
153        /// omitted, any creation failure is accepted.
154        #[serde(default)]
155        error: Option<String>,
156    }
157
158    fn run_checks() -> Result<()> {
159        // The restricted-network proxy runs in *this* process and the echo
160        // servers live on the VM's private network, which the proxy's
161        // DNS-rebinding protection would otherwise reject. The proxy only honors
162        // this var when built with `http_proxy/nixos-integration-tests` (pulled
163        // in by `sandbox/nixos-test`, which this binary requires), so it has no
164        // effect in a real Zed build.
165        // SAFETY: single-threaded at this point.
166        unsafe {
167            std::env::set_var("ZED_SANDBOX_PROXY_ALLOW_LOCAL_IPS", "1");
168        }
169
170        let checks_path =
171            std::env::var("ZED_SANDBOX_CHECKS").context("ZED_SANDBOX_CHECKS must be set")?;
172        let raw = std::fs::read_to_string(&checks_path)
173            .with_context(|| format!("failed to read checks file {checks_path}"))?;
174        let specs: Vec<Check> =
175            serde_json::from_str(&raw).context("failed to parse checks JSON")?;
176        let echo_port =
177            std::env::var("ZED_TEST_ECHO_PORT").unwrap_or_else(|_| DEFAULT_ECHO_PORT.to_string());
178
179        println!("[sandbox_test]: running {} check(s)", specs.len());
180
181        let mut checks = Checks::new();
182        for spec in &specs {
183            run_check(spec, &echo_port, &mut checks)?;
184        }
185        checks.finish()
186    }
187
188    fn policy_of(check: &Check) -> Result<SandboxPolicy> {
189        let fs = match check.fs {
190            FsMode::Unrestricted => SandboxFsPolicy::Unrestricted {
191                protected_paths: capture_protected_paths(&check.protected_paths),
192            },
193            FsMode::Restricted => {
194                let mut writable_paths = Vec::new();
195                for path in &check.writable_paths {
196                    // Mirror production (`acp_thread::SandboxWrap::to_policy`):
197                    // the directory must exist before its inode can be pinned, so
198                    // create it up front, then capture it.
199                    std::fs::create_dir_all(path)
200                        .with_context(|| format!("failed to create writable path {path}"))?;
201                    writable_paths.push(
202                        HostFilesystemLocation::new(path)
203                            .with_context(|| format!("failed to capture writable path {path}"))?,
204                    );
205                }
206                let protected_paths = capture_protected_paths(&check.protected_paths);
207                SandboxFsPolicy::Restricted {
208                    writable_paths,
209                    protected_paths,
210                }
211            }
212        };
213        let network = match check.network_access {
214            NetMode::Unrestricted => SandboxNetPolicy::Unrestricted,
215            NetMode::Blocked => SandboxNetPolicy::Blocked,
216            NetMode::Restricted => SandboxNetPolicy::Restricted {
217                allowed_domains: check.allowed_domains.clone(),
218            },
219        };
220        Ok(SandboxPolicy { fs, network })
221    }
222
223    /// Capture each already-existing protected path, mirroring production's
224    /// fail-closed `filter_map(HostFilesystemLocation::new(..).ok())`: a path
225    /// that does not yet exist can't be pinned and is simply skipped. Unlike
226    /// writable paths, these are never created here — whether one exists is
227    /// exactly what several checks turn on.
228    fn capture_protected_paths(paths: &[String]) -> Vec<HostFilesystemLocation> {
229        paths
230            .iter()
231            .filter_map(|path| HostFilesystemLocation::new(path).ok())
232            .collect()
233    }
234
235    fn describe(check: &Check) -> String {
236        if let Some(name) = &check.name {
237            return name.clone();
238        }
239        let protected = if check.protected_paths.is_empty() {
240            String::new()
241        } else {
242            format!(",protected_paths={:?}", check.protected_paths)
243        };
244        let policy = format!(
245            "fs={:?},net={:?}{protected}",
246            check.fs, check.network_access
247        );
248        let op = if let Some(path) = &check.read {
249            format!("read {path}")
250        } else if let Some(path) = &check.write {
251            format!("write {path}")
252        } else if let Some(host) = &check.network {
253            format!("network {host}")
254        } else if let Some(path) = &check.socket_path {
255            format!("socket_connect {path}")
256        } else if let Some(expected) = check.can_create {
257            format!("can_create == {expected}")
258        } else {
259            "<no operation>".to_string()
260        };
261        format!("[{policy}] {op}")
262    }
263
264    fn run_check(check: &Check, echo_port: &str, checks: &mut Checks) -> Result<()> {
265        let label = describe(check);
266
267        if let Some(expect_can_create) = check.can_create {
268            let policy = policy_of(check)?;
269            let outcome = Sandbox::can_create(&policy);
270            let passed = match (&outcome, expect_can_create) {
271                (Ok(()), true) => true,
272                (Ok(()), false) => false,
273                (Err(_), true) => false,
274                (Err(error), false) => check
275                    .error
276                    .as_deref()
277                    .map(|expected| error_matches(error, expected))
278                    .unwrap_or(true),
279            };
280            checks.check(&format!("{label} (got {outcome:?})"), passed);
281            return Ok(());
282        }
283
284        let succeeds = check
285            .succeeds
286            .with_context(|| format!("check {label:?} has an operation but no `succeeds`"))?;
287
288        let actual = if let Some(path) = &check.read {
289            run_read(check, path)?
290        } else if let Some(path) = &check.write {
291            run_write(check, path)?
292        } else if let Some(host) = &check.network {
293            run_network(check, host, echo_port)?
294        } else if let Some(path) = &check.socket_path {
295            run_socket_connect(check, path)?
296        } else {
297            bail!("check {label:?} has no operation");
298        };
299
300        checks.check(&label, actual == succeeds);
301        Ok(())
302    }
303
304    /// Seed a host file, then `cat` it from inside the sandbox. Reads are always
305    /// granted (root is bound read-only), so this proves the sandbox doesn't
306    /// *block* reads of existing host files.
307    fn run_read(check: &Check, path: &str) -> Result<bool> {
308        let path = Path::new(path);
309        if let Some(parent) = path.parent() {
310            std::fs::create_dir_all(parent)
311                .with_context(|| format!("failed to create parent of {}", path.display()))?;
312        }
313        std::fs::write(path, b"sandbox-test\n")
314            .with_context(|| format!("failed to seed readable file {}", path.display()))?;
315
316        // Build the policy only after the fixtures exist: capturing a
317        // `HostFilesystemLocation` pins the inode, so the path must be present.
318        let policy = policy_of(check)?;
319        let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?;
320        run_command(
321            &mut sandbox,
322            "sh",
323            &["-c", &format!("cat {}", shell_quote(path))],
324        )
325    }
326
327    /// Attempt to write a host file from inside the sandbox. "Succeeded" means
328    /// the command exited 0 *and* the bytes actually landed on the host file —
329    /// a write that only hits the sandbox's ephemeral tmpfs counts as blocked,
330    /// since it never escaped the sandbox.
331    fn run_write(check: &Check, path: &str) -> Result<bool> {
332        let path = Path::new(path);
333        if let Some(parent) = path.parent() {
334            // Create the parent on the host so the only thing under test is the
335            // sandbox's write permission, not a missing directory. This also
336            // makes a protected parent exist before the policy captures it.
337            std::fs::create_dir_all(parent)
338                .with_context(|| format!("failed to create parent of {}", path.display()))?;
339        }
340        // Start from a clean slate so `exists()` afterwards is meaningful.
341        let _ = std::fs::remove_file(path);
342
343        // Build the policy only after the fixtures exist: capturing a
344        // `HostFilesystemLocation` pins the inode, so the path must be present.
345        let policy = policy_of(check)?;
346        let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?;
347        let command_ok = run_command(
348            &mut sandbox,
349            "sh",
350            &[
351                "-c",
352                &format!("printf sandbox-test > {}", shell_quote(path)),
353            ],
354        )?;
355        Ok(command_ok && path.exists())
356    }
357
358    /// Connect to `host` (`hostname` or `hostname:port`) from inside the
359    /// sandbox via the `__echo_check` subcommand, which honors `HTTP_PROXY` for
360    /// the restricted-network case.
361    fn run_network(check: &Check, host: &str, echo_port: &str) -> Result<bool> {
362        let target = if host.contains(':') {
363            host.to_string()
364        } else {
365            format!("{host}:{echo_port}")
366        };
367        let exe = current_exe_str()?;
368        let policy = policy_of(check)?;
369        let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?;
370        run_command(&mut sandbox, &exe, &[SUBCOMMAND_ECHO_CHECK, &target])
371    }
372
373    /// Attempt to connect to the unix-domain socket at `path` from inside the
374    /// sandbox via the `__unix_connect_check` subcommand, returning whether the
375    /// round-trip succeeded. A read-only bind mount of `/` leaves the socket
376    /// reachable, so a sandboxed command can currently `connect()` to a session
377    /// IPC socket owned by a process *outside* the sandbox — the escape a
378    /// `socket(AF_UNIX)` seccomp filter is meant to block. When that guard lands,
379    /// `socket(AF_UNIX)` returns `EPERM`, the subcommand fails, and this returns
380    /// `false`.
381    fn run_socket_connect(check: &Check, path: &str) -> Result<bool> {
382        let exe = current_exe_str()?;
383        let policy = policy_of(check)?;
384        let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?;
385        run_command(&mut sandbox, &exe, &[SUBCOMMAND_UNIX_CONNECT_CHECK, path])
386    }
387
388    fn error_matches(error: &SandboxError, expected: &str) -> bool {
389        matches!(
390            (error, expected),
391            (SandboxError::BwrapNotFound, "bwrap_not_found")
392                | (SandboxError::BwrapSetuidRejected, "setuid_rejected")
393                | (SandboxError::SandboxProbeFailed, "probe_failed")
394        )
395    }
396
397    fn sandbox_err(error: SandboxError) -> anyhow::Error {
398        anyhow::anyhow!("failed to create sandbox: {error}")
399    }
400
401    /// Inner command: prove (or fail to prove) reachability of an echo server.
402    ///
403    /// With `HTTP_PROXY` set we open an HTTP `CONNECT` tunnel through the proxy
404    /// and then echo a byte; a policy denial shows up as a non-200 status. With
405    /// no proxy we connect directly. Either way, a clean round-trip means the
406    /// destination was reachable under the active network policy.
407    fn run_echo_check(target: Option<&str>) -> Result<()> {
408        let target = target.context("echo check requires a host:port argument")?;
409        let proxy = std::env::var("HTTP_PROXY")
410            .ok()
411            .filter(|value| !value.trim().is_empty())
412            .or_else(|| {
413                std::env::var("http_proxy")
414                    .ok()
415                    .filter(|value| !value.trim().is_empty())
416            });
417
418        let mut stream = match proxy {
419            Some(proxy_url) => {
420                let proxy_addr = proxy_url
421                    .trim()
422                    .strip_prefix("http://")
423                    .unwrap_or(proxy_url.trim())
424                    .trim_end_matches('/')
425                    .to_string();
426                let mut stream = TcpStream::connect(&proxy_addr)
427                    .with_context(|| format!("failed to connect to proxy {proxy_addr}"))?;
428                stream.set_read_timeout(Some(Duration::from_secs(10)))?;
429                write!(
430                    stream,
431                    "CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n"
432                )
433                .context("failed to send CONNECT to proxy")?;
434                let status = read_status_line(&mut stream)?;
435                if !status.contains(" 200") {
436                    bail!("proxy refused CONNECT to {target}: {status:?}");
437                }
438                stream
439            }
440            None => {
441                let stream = TcpStream::connect(target)
442                    .with_context(|| format!("failed to connect to {target}"))?;
443                stream.set_read_timeout(Some(Duration::from_secs(10)))?;
444                stream
445            }
446        };
447
448        stream
449            .write_all(b"ping\n")
450            .context("failed to write to echo server")?;
451        let mut buffer = [0u8; 32];
452        let read = stream
453            .read(&mut buffer)
454            .context("failed to read from echo server")?;
455        let echoed = String::from_utf8_lossy(&buffer[..read]);
456        if echoed.contains("ping") {
457            Ok(())
458        } else {
459            bail!("echo server returned unexpected data: {echoed:?}");
460        }
461    }
462
463    /// Inner command: connect to the unix-domain socket at `path` and round-trip
464    /// a byte through it.
465    ///
466    /// Any failure — `socket(AF_UNIX)` being denied (how the seccomp guard will
467    /// manifest, as `EPERM`), `connect()` failing, or a bad round-trip — exits
468    /// non-zero, so the caller reads it as "not connected". A clean round-trip
469    /// (exit 0) means the socket outside the sandbox was reachable.
470    fn run_unix_connect_check(path: Option<&str>) -> Result<()> {
471        use std::os::unix::net::UnixStream;
472
473        let path = path.context("unix connect check requires a socket path argument")?;
474        let mut stream = UnixStream::connect(path)
475            .with_context(|| format!("failed to connect to unix socket {path}"))?;
476        stream.set_read_timeout(Some(Duration::from_secs(10)))?;
477        stream
478            .write_all(b"ping\n")
479            .context("failed to write to unix socket")?;
480        let mut buffer = [0u8; 32];
481        let read = stream
482            .read(&mut buffer)
483            .context("failed to read from unix socket")?;
484        let echoed = String::from_utf8_lossy(&buffer[..read]);
485        if echoed.contains("ping") {
486            Ok(())
487        } else {
488            bail!("unix socket returned unexpected data: {echoed:?}");
489        }
490    }
491
492    /// Read an HTTP status line (up to the first CRLF), then drain the rest of
493    /// the header block (up to the blank line) so the stream is positioned at
494    /// the tunneled body.
495    fn read_status_line(stream: &mut TcpStream) -> Result<String> {
496        let mut header = Vec::new();
497        let mut byte = [0u8; 1];
498        loop {
499            let read = stream
500                .read(&mut byte)
501                .context("failed reading proxy reply")?;
502            if read == 0 {
503                break;
504            }
505            header.push(byte[0]);
506            if header.ends_with(b"\r\n\r\n") {
507                break;
508            }
509            if header.len() > 64 * 1024 {
510                bail!("proxy reply headers too large");
511            }
512        }
513        let text = String::from_utf8_lossy(&header);
514        Ok(text.lines().next().unwrap_or_default().to_string())
515    }
516
517    /// Tracks assertion results so we can report a summary and a single exit code.
518    struct Checks {
519        passed: usize,
520        failed: usize,
521    }
522
523    impl Checks {
524        fn new() -> Self {
525            Self {
526                passed: 0,
527                failed: 0,
528            }
529        }
530
531        fn check(&mut self, description: &str, condition: bool) {
532            if condition {
533                self.passed += 1;
534                println!("[sandbox_test]: PASS: {description}");
535            } else {
536                self.failed += 1;
537                println!("[sandbox_test]: FAIL: {description}");
538            }
539        }
540
541        fn finish(self) -> Result<()> {
542            println!(
543                "[sandbox_test]: summary: {} passed, {} failed",
544                self.passed, self.failed
545            );
546            if self.failed > 0 {
547                bail!("{} sandbox assertion(s) failed", self.failed);
548            }
549            Ok(())
550        }
551    }
552
553    /// Wrap and run a command inside `sandbox`, returning whether it exited 0.
554    fn run_command(sandbox: &mut Sandbox, program: &str, args: &[&str]) -> Result<bool> {
555        let command = CommandAndArgs {
556            program: program.to_string(),
557            args: args.iter().map(|arg| arg.to_string()).collect(),
558            env: Default::default(),
559            cwd: None,
560        };
561        let output = futures::executor::block_on(sandbox.execute(&command))
562            .map_err(|error| anyhow::anyhow!("failed to run sandboxed command: {error}"))?;
563        Ok(output.status.success())
564    }
565
566    fn current_exe_str() -> Result<String> {
567        Ok(std::env::current_exe()
568            .context("failed to resolve current executable")?
569            .to_str()
570            .context("current executable path is not valid UTF-8")?
571            .to_string())
572    }
573
574    /// Single-quote a path for safe interpolation into an `sh -c` script.
575    fn shell_quote(path: &Path) -> String {
576        let raw = path.to_string_lossy();
577        format!("'{}'", raw.replace('\'', "'\\''"))
578    }
579}
580
Served at tenant.openagents/omega Member data and write actions are omitted.