Skip to repository content580 lines · 23.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:39:43.698Z 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
bwrap_test_helper.rs
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