diff --git a/crates/chidori/src/runtime/isolate/mod.rs b/crates/chidori/src/runtime/isolate/mod.rs index a1332ca1..51b59fe5 100644 --- a/crates/chidori/src/runtime/isolate/mod.rs +++ b/crates/chidori/src/runtime/isolate/mod.rs @@ -67,21 +67,22 @@ pub fn default_on_if_unset() { } /// A one-line, human-readable description of the isolation posture, for startup -/// banners. Describes *intent*: the worker applies each layer best-effort and -/// logs to stderr what actually stuck on this host. +/// banners. Describes *intent*: the worker logs to stderr what actually stuck +/// on this host. The platform's core layer (seccomp / Seatbelt) fails closed by +/// default; the auxiliary layers are best-effort. pub fn describe() -> String { if !enabled() { return "off (agents run in-process; pass --isolate or unset CHIDORI_ISOLATE to sandbox)" .to_string(); } let layers = if cfg!(target_os = "linux") { - "Linux: network namespace + Landlock + seccomp" + "Linux: network namespace + Landlock + seccomp; fails closed if seccomp can't apply" } else if cfg!(target_os = "macos") { - "macOS: Seatbelt profile" + "macOS: Seatbelt profile; fails closed if it can't apply" } else { - "no OS sandbox layer on this platform" + "no OS sandbox layer on this platform — process separation only" }; - format!("on — process-per-run worker ({layers}; best-effort)") + format!("on — process-per-run worker ({layers})") } /// If untrusted code is being run without OS isolation, nudge the operator that diff --git a/crates/chidori/src/runtime/isolate/sandbox.rs b/crates/chidori/src/runtime/isolate/sandbox.rs index a2a18a15..32e6ad8d 100644 --- a/crates/chidori/src/runtime/isolate/sandbox.rs +++ b/crates/chidori/src/runtime/isolate/sandbox.rs @@ -18,11 +18,12 @@ //! the engine's watchdog thread needs them and a fork that cannot `exec` gains no //! new code — so the `exec*` denial is what actually forecloses code execution. -/// What each best-effort confinement layer achieved for a worker. Layers that -/// could not be applied (older kernel, rootless container, …) leave their flag -/// `false` and append a human-readable reason to `notes`; the worker logs the -/// notes and, under `CHIDORI_ISOLATE_REQUIRE_SANDBOX`, fails closed if the -/// portable core (seccomp) did not apply. +/// What each confinement layer achieved for a worker. Layers that could not be +/// applied (older kernel, rootless container, …) leave their flag `false` and +/// append a human-readable reason to `notes`; the worker logs the notes and +/// **fails closed by default** if the platform's core layer (seccomp on Linux, +/// Seatbelt on macOS) did not apply — `CHIDORI_ISOLATE_REQUIRE_SANDBOX=0` is +/// the explicit opt-in to a degraded, loudly-announced run. #[derive(Debug, Default)] pub struct SandboxOutcome { /// The worker runs in its own (empty) network namespace (Linux). @@ -40,9 +41,10 @@ pub struct SandboxOutcome { } impl SandboxOutcome { - /// Whether the platform's *primary* confinement is active — the gate for - /// `CHIDORI_ISOLATE_REQUIRE_SANDBOX` (seccomp on Linux, Seatbelt on macOS). - /// The namespace/Landlock layers are defense-in-depth on top of this. + /// Whether the platform's *primary* confinement is active (seccomp on + /// Linux, Seatbelt on macOS) — the fail-closed gate, applied by default and + /// waived only by `CHIDORI_ISOLATE_REQUIRE_SANDBOX=0`. The + /// namespace/Landlock layers are defense-in-depth on top of this. pub fn core_confined(&self) -> bool { #[cfg(target_os = "linux")] { diff --git a/crates/chidori/src/runtime/isolate/worker.rs b/crates/chidori/src/runtime/isolate/worker.rs index e21725b7..505755f0 100644 --- a/crates/chidori/src/runtime/isolate/worker.rs +++ b/crates/chidori/src/runtime/isolate/worker.rs @@ -7,9 +7,10 @@ //! filesystem, the network, or a clock of its own — those live behind the seam. //! //! Before running the agent the worker seals itself in: the `setrlimit` floor -//! ([`super::limits`]) then the best-effort confinement layers — network -//! namespace, Landlock, and the seccomp denylist ([`super::sandbox`]). See -//! `docs/os-isolation-plan.md`. +//! ([`super::limits`]) then the confinement layers — network namespace, +//! Landlock, and the seccomp denylist ([`super::sandbox`]). The platform's core +//! layer (seccomp / Seatbelt) fails closed by default; the auxiliary layers are +//! best-effort. See `docs/os-isolation-plan.md`. use std::cell::RefCell; use std::io::{self, Read, Write}; @@ -128,37 +129,44 @@ fn serve_inner( // Slam the resource floor shut, then the confinement layers — before any agent // code runs, and only in a real worker process (see `serve_inner`'s // `apply_limits`; these mutate the *current* process, which is sound only when - // it is a dedicated worker). Every layer is best-effort: one that can't be - // installed degrades isolation but never fails the run, unless the operator - // demands `CHIDORI_ISOLATE_REQUIRE_SANDBOX`, in which case a missing seccomp - // core fails closed. - let sandbox = if apply_limits { + // it is a dedicated worker). The defense-in-depth layers (network namespace, + // Landlock) are best-effort: one that can't be installed degrades isolation + // with a logged note. The platform's *core* confinement (seccomp on Linux, + // Seatbelt on macOS) fails **closed** by default — a run that advertises + // isolation must not quietly execute with process separation only. The + // operator can accept that degraded posture explicitly with + // `CHIDORI_ISOLATE_REQUIRE_SANDBOX=0`, in which case the downgrade is still + // announced loudly on stderr. + let mut sandbox = if apply_limits { limits.apply_to_self(); super::sandbox::apply() } else { let _ = &limits; super::sandbox::SandboxOutcome::default() }; + // Test-only: pretend the platform's core layer failed to apply, so the + // fail-closed gate below can be exercised end-to-end on hosts where + // seccomp/Seatbelt install fine (see `isolate_limits`). The real filter (if + // any) stays active — only the reported outcome is falsified. Inert unless + // the env var is set. + if apply_limits && std::env::var_os("CHIDORI_ISOLATE_TEST_FORCE_UNCONFINED").is_some() { + sandbox.seccomp_applied = false; + sandbox.seatbelt_applied = false; + sandbox.notes.push( + "core confinement reported as unapplied by CHIDORI_ISOLATE_TEST_FORCE_UNCONFINED \ + (test hook)" + .to_string(), + ); + } + let sandbox = sandbox; for note in &sandbox.notes { eprintln!("isolate worker: sandbox: {note}"); } - if apply_limits && env_truthy("CHIDORI_ISOLATE_REQUIRE_SANDBOX") && !sandbox.core_confined() { - let mut guard = io.borrow_mut(); - return write_frame( - &mut guard.writer, - &FromChild::Done { - outcome: Outcome::Err( - "isolation sandbox required but the platform's core confinement \ - (seccomp on Linux, Seatbelt on macOS) could not be applied" - .to_string(), - ), - }, - ); - } // Test-only probes: once the sandbox is in place, attempt an operation a given // layer must forbid, to prove it does. Gated behind an env var so it is inert - // in normal operation. + // in normal operation. Runs before the fail-closed gate so a probe can report + // its layer as unavailable (the skip-aware tests depend on that marker). #[cfg(unix)] if apply_limits { if let Some(mode) = std::env::var_os("CHIDORI_ISOLATE_SELFTEST") { @@ -166,6 +174,36 @@ fn serve_inner( } } + if apply_limits && !sandbox.core_confined() { + if sandbox_required() { + let mut guard = io.borrow_mut(); + let reasons = if sandbox.notes.is_empty() { + String::new() + } else { + format!(": {}", sandbox.notes.join("; ")) + }; + return write_frame( + &mut guard.writer, + &FromChild::Done { + outcome: Outcome::Err(format!( + "the platform's core sandbox confinement (seccomp on Linux, \ + Seatbelt on macOS) could not be applied, and isolated runs \ + fail closed without it{reasons}. Set \ + CHIDORI_ISOLATE_REQUIRE_SANDBOX=0 to explicitly accept a \ + degraded run with process separation and brokered effects \ + but no syscall/filesystem/network confinement." + )), + }, + ); + } + eprintln!( + "isolate worker: WARNING: running WITHOUT the platform's core sandbox \ + confinement (CHIDORI_ISOLATE_REQUIRE_SANDBOX is disabled or this \ + platform has no sandbox layer): this run has process separation and \ + brokered effects only — no syscall/filesystem/network confinement." + ); + } + let host: Rc = Rc::new(BrokeredHost { io: io.clone(), prelude, @@ -187,14 +225,36 @@ fn serve_inner( write_frame(&mut guard.writer, &FromChild::Done { outcome }) } -/// Whether an env var holds a truthy value (set and not `0`/`off`/`false`/`no`). -fn env_truthy(key: &str) -> bool { - match std::env::var(key) { - Ok(v) => { +/// Whether a missing core sandbox must fail the run, per +/// `CHIDORI_ISOLATE_REQUIRE_SANDBOX`. +fn sandbox_required() -> bool { + sandbox_required_from( + std::env::var("CHIDORI_ISOLATE_REQUIRE_SANDBOX") + .ok() + .as_deref(), + ) +} + +/// The `CHIDORI_ISOLATE_REQUIRE_SANDBOX` policy, factored over the raw env value +/// so it is unit-testable. Unset (or empty) means **fail closed by default** on +/// platforms that implement a core layer (seccomp on Linux, Seatbelt on macOS); +/// on platforms with no sandbox implementation at all the startup banner already +/// announces "no OS sandbox layer", so the default there is the loud downgrade +/// rather than an unconditional refusal. An explicit falsy value +/// (`0`/`off`/`false`/`no`) opts into degraded runs anywhere; any other explicit +/// value demands confinement even on platforms without a sandbox layer. +fn sandbox_required_from(value: Option<&str>) -> bool { + let platform_default = cfg!(any(target_os = "linux", target_os = "macos")); + match value { + Some(v) => { let v = v.trim().to_ascii_lowercase(); - !v.is_empty() && !matches!(v.as_str(), "0" | "off" | "false" | "no") + if v.is_empty() { + platform_default + } else { + !matches!(v.as_str(), "0" | "off" | "false" | "no") + } } - Err(_) => false, + None => platform_default, } } @@ -254,3 +314,30 @@ fn run_selftest(mode: &str, sandbox: &crate::runtime::isolate::sandbox::SandboxO } } } + +#[cfg(test)] +mod tests { + use super::sandbox_required_from; + + #[test] + fn sandbox_is_required_by_default_where_a_core_layer_exists() { + let platform_has_core = cfg!(any(target_os = "linux", target_os = "macos")); + assert_eq!(sandbox_required_from(None), platform_has_core); + assert_eq!(sandbox_required_from(Some("")), platform_has_core); + assert_eq!(sandbox_required_from(Some(" ")), platform_has_core); + } + + #[test] + fn explicit_falsy_value_opts_into_degraded_runs() { + for v in ["0", "off", "false", "no", " OFF ", "False"] { + assert!(!sandbox_required_from(Some(v)), "value {v:?}"); + } + } + + #[test] + fn explicit_truthy_value_always_requires_the_sandbox() { + for v in ["1", "on", "true", "yes", "require"] { + assert!(sandbox_required_from(Some(v)), "value {v:?}"); + } + } +} diff --git a/crates/chidori/tests/isolate_limits.rs b/crates/chidori/tests/isolate_limits.rs index e5c2f71c..99ced9a1 100644 --- a/crates/chidori/tests/isolate_limits.rs +++ b/crates/chidori/tests/isolate_limits.rs @@ -212,6 +212,69 @@ fn seatbelt_loads_and_enforces_on_macos() { let _ = fs::remove_dir_all(agent.parent().unwrap()); } +#[test] +fn missing_core_sandbox_fails_closed_by_default() { + // If the platform's core confinement (seccomp on Linux, Seatbelt on macOS) + // cannot be applied, an isolated run must refuse — not quietly execute with + // process separation only. The test hook makes the worker report the core + // layer as unapplied so the gate is exercised on hosts where the sandbox + // genuinely installs. + let agent = write_agent( + "fail-closed", + r#" + import { run } from "chidori:agent"; + run(async () => ({ ok: true })); + "#, + ); + let out = run_isolated(&agent, &[("CHIDORI_ISOLATE_TEST_FORCE_UNCONFINED", "1")]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success(), + "run without core confinement should fail closed by default; stderr={stderr}" + ); + assert!( + stderr.contains("fail closed") || stderr.contains("could not be applied"), + "error should explain the fail-closed refusal; stderr={stderr}" + ); + assert!( + stderr.contains("CHIDORI_ISOLATE_REQUIRE_SANDBOX"), + "error should name the explicit opt-out; stderr={stderr}" + ); + let _ = fs::remove_dir_all(agent.parent().unwrap()); +} + +#[test] +fn degraded_run_needs_an_explicit_opt_out_and_stays_loud() { + // With CHIDORI_ISOLATE_REQUIRE_SANDBOX=0 the operator accepts a degraded + // run — it must succeed, but the downgrade must be announced on stderr. + let agent = write_agent( + "degraded", + r#" + import { run } from "chidori:agent"; + run(async () => ({ ok: true })); + "#, + ); + let out = run_isolated( + &agent, + &[ + ("CHIDORI_ISOLATE_TEST_FORCE_UNCONFINED", "1"), + ("CHIDORI_ISOLATE_REQUIRE_SANDBOX", "0"), + ], + ); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "opted-out degraded run should succeed; stdout={stdout} stderr={stderr}" + ); + assert!(stdout.contains("\"ok\""), "stdout missing result: {stdout}"); + assert!( + stderr.contains("WARNING") && stderr.contains("WITHOUT"), + "degraded run must announce the downgrade loudly; stderr={stderr}" + ); + let _ = fs::remove_dir_all(agent.parent().unwrap()); +} + #[test] fn cpu_limit_terminates_a_busy_worker() { // With compute bounds disabled, a busy loop burns CPU until RLIMIT_CPU fires diff --git a/docs/deployment.md b/docs/deployment.md index 9be86e29..cc970f54 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -101,9 +101,12 @@ CHIDORI_DURABILITY=strict # refuse side effects the journal hasn't rec `CHIDORI_MAX_CONCURRENT_SESSIONS` (default 8) to cap parallel runs; `CHIDORI_SECRET_ENV` to pass secrets as placeholder tokens the journal never sees. OS isolation (`CHIDORI_ISOLATE=process`) is the **default on - Unix**; opt out with `--no-isolate` / `CHIDORI_ISOLATE=off`. In containers, - set `CHIDORI_ISOLATE_REQUIRE_SANDBOX=1` to fail closed — the - network-namespace layer needs `CAP_SYS_ADMIN` and is skipped without it. + Unix**; opt out with `--no-isolate` / `CHIDORI_ISOLATE=off`. Runs **fail + closed by default** if the platform's core confinement (seccomp on Linux, + Seatbelt on macOS) can't be applied; set `CHIDORI_ISOLATE_REQUIRE_SANDBOX=0` + only if you accept degraded (process-separation-only) runs. The + network-namespace layer is auxiliary — it needs `CAP_SYS_ADMIN` and is + skipped (with a logged note) in rootless containers without failing the run. ## Decision 1: where the journal lives diff --git a/docs/os-isolation-plan.md b/docs/os-isolation-plan.md index e32a9011..e959455a 100644 --- a/docs/os-isolation-plan.md +++ b/docs/os-isolation-plan.md @@ -333,7 +333,8 @@ error frame the child writes from its `catch_unwind` boundary before exiting: namespace/mount, privilege-change, kernel-module/`bpf`/`perf_event_open`, and keyring syscalls). `apply_filter` sets `NO_NEW_PRIVS`, so it works rootless. Best-effort by default (degrades to brokering + rlimits where seccomp is - unavailable); `CHIDORI_ISOLATE_REQUIRE_SANDBOX=1` fails closed. A SIGSYS kill + unavailable — but only with the explicit `CHIDORI_ISOLATE_REQUIRE_SANDBOX=0` + opt-out; by default a missing seccomp core fails the run closed). A SIGSYS kill maps to a precise "blocked syscall (seccomp/SIGSYS)" error. Verified: a normal isolated run is unaffected (no false positives), and a worker probing `socket()` post-filter is killed (`isolate_limits::seccomp_blocks_a_denied_syscall`, @@ -349,8 +350,9 @@ error frame the child writes from its `catch_unwind` boundary before exiting: access, leave reads for the C runtime; closes the `openat`-write surface seccomp leaves open, and unlike `RLIMIT_FSIZE` it spares inherited fds like a redirected `stderr`). Both best-effort with graceful skip + a `notes` log; a - single `SandboxOutcome` drives `REQUIRE_SANDBOX` (seccomp is the required - core) and the skip-aware self-tests (`isolate_limits::landlock_blocks_file_creation`). + single `SandboxOutcome` drives the default fail-closed gate (seccomp is the + required core; `CHIDORI_ISOLATE_REQUIRE_SANDBOX=0` waives it) and the + skip-aware self-tests (`isolate_limits::landlock_blocks_file_creation`). **Deferred:** cgroup v2 `memory.max` (needs delegation — the per-process heap watchdog from phase 2 is the graceful stand-in), rootless net-ns via an intermediate user namespace, and mount/pid namespaces. @@ -360,13 +362,13 @@ error frame the child writes from its `catch_unwind` boundary before exiting: Chromium's renderer uses). The SBPL is **allow-default with targeted denies** (`(deny network*)` + `(deny file-write*)`) — the same posture as the Linux seccomp denylist + Landlock read-only, and low-risk: a brokered compute worker - still reads files and allocates freely. Best-effort with the same graceful-skip - contract; `SandboxOutcome::core_confined()` abstracts the per-OS "primary - layer" (seccomp on Linux, Seatbelt on macOS) for the `REQUIRE_SANDBOX` gate. - The FFI is type-checked on the Linux host (via `cargo check`, which doesn't - link) but **runtime-unverified** — no macOS host in this environment; the - best-effort design means a profile/load failure degrades to a logged skip - rather than breaking a run. + still reads files and allocates freely. `SandboxOutcome::core_confined()` + abstracts the per-OS "primary layer" (seccomp on Linux, Seatbelt on macOS) + for the fail-closed gate, which is **on by default** — a profile/load + failure fails the run unless the operator opts into a degraded run with + `CHIDORI_ISOLATE_REQUIRE_SANDBOX=0`. The FFI is type-checked on the Linux + host (via `cargo check`, which doesn't link) but **runtime-unverified** — + no macOS host in this environment. 5. **Polish.** ✅ **Done (warm pool deliberately skipped)** — `--isolate` on both `chidori run` and `chidori serve` (and `CHIDORI_ISOLATE=process`); a startup `Isolation:` banner line describing the posture; and an `--untrusted`→isolation @@ -385,7 +387,7 @@ error frame the child writes from its `catch_unwind` boundary before exiting: | Env var | Default | Effect | |---|---|---| | `CHIDORI_ISOLATE` | unset (on for the CLI on Unix; off for embedders) | `process` runs each agent in a confined child worker. Set by `--isolate`. | -| `CHIDORI_ISOLATE_REQUIRE_SANDBOX` | off | Fail the run closed if the platform's core confinement (seccomp/Seatbelt) can't be applied. | +| `CHIDORI_ISOLATE_REQUIRE_SANDBOX` | **on** (on Linux/macOS) | Fail the run closed if the platform's core confinement (seccomp/Seatbelt) can't be applied. Set `0` to explicitly accept a degraded (process-separation-only) run; the downgrade is still announced on stderr. | | `CHIDORI_ISOLATE_DEADLINE_MS` | off | Parent-side wall-clock `SIGKILL` of a wedged worker. | | `CHIDORI_ISOLATE_CPU_SECS` | off | Hard `RLIMIT_CPU` ceiling on worker compute. | | `CHIDORI_ISOLATE_NOFILE` | 256 | `RLIMIT_NOFILE` (clamped to the inherited hard limit). | diff --git a/docs/sandbox-model.md b/docs/sandbox-model.md index bea882eb..abb05af1 100644 --- a/docs/sandbox-model.md +++ b/docs/sandbox-model.md @@ -291,10 +291,14 @@ the parent refuses `http` destinations that resolve to non-public addresses (loopback, RFC 1918, the 169.254.169.254 cloud-metadata range, and their IPv6 equivalents), checked at DNS-resolution time and on every redirect hop, with `CHIDORI_HTTP_ALLOW_HOSTS` as the deliberate allowlist. -Each layer is **best-effort**: a layer that cannot be applied (older -kernel, rootless container) logs a skip note and degrades rather than breaking -the run. Set `CHIDORI_ISOLATE_REQUIRE_SANDBOX=1` to **fail closed** if the -platform's core layer (seccomp on Linux, Seatbelt on macOS) cannot be applied. +The auxiliary layers (network namespace, Landlock) are **best-effort**: one +that cannot be applied (older kernel, rootless container) logs a skip note and +degrades rather than breaking the run. The platform's **core layer** (seccomp +on Linux, Seatbelt on macOS) **fails closed by default**: if it cannot be +applied, the run refuses with an actionable error instead of quietly executing +with process separation only. Set `CHIDORI_ISOLATE_REQUIRE_SANDBOX=0` to +explicitly accept that degraded posture — the worker still announces the +downgrade loudly on stderr. **Linux** (`apply_linux`), layered before seccomp so `unshare`/`landlock_*` remain legal: @@ -323,8 +327,9 @@ remain legal: seccomp+Landlock pair. The feature's `unsafe` FFI (Seatbelt, `unshare`, `setrlimit`, `kill`) stays in the host-side `isolate/` module, out of the engine crate. (Type-checked on the Linux host but - runtime-unverified — no macOS CI host yet; the best-effort design degrades a - load failure to a logged skip.) + runtime-unverified — no macOS CI host yet; a load failure fails the run + closed by default, or degrades to a logged, loud downgrade under + `CHIDORI_ISOLATE_REQUIRE_SANDBOX=0`.) **Guarantee matrix:** @@ -371,7 +376,7 @@ line describing the active posture. Isolation (process sandbox) and | Env var | Default | Effect | |---|---|---| | `CHIDORI_ISOLATE` | unset (on for the CLI on Unix; off for embedders) | `process` runs each agent in a confined child worker; `off` disables. Set by `--isolate` / `--no-isolate`. | -| `CHIDORI_ISOLATE_REQUIRE_SANDBOX` | off | Fail the run closed if the platform's core confinement (seccomp/Seatbelt) can't be applied. | +| `CHIDORI_ISOLATE_REQUIRE_SANDBOX` | **on** (on Linux/macOS) | Fail the run closed if the platform's core confinement (seccomp/Seatbelt) can't be applied. Set `0` to explicitly accept a degraded (process-separation-only) run; the downgrade is still announced on stderr. | | `CHIDORI_ISOLATE_DEADLINE_MS` | off | Parent-side wall-clock `SIGKILL` of a wedged worker. | | `CHIDORI_ISOLATE_CPU_SECS` | off | Hard `RLIMIT_CPU` ceiling on worker compute. | | `CHIDORI_ISOLATE_NOFILE` | 256 | `RLIMIT_NOFILE` (clamped to the inherited hard limit). | @@ -576,9 +581,8 @@ If you intend to run code you do not trust on this engine today: 3. Add OS isolation with `--isolate` (`chidori run --isolate`, `chidori serve --isolate`, or `CHIDORI_ISOLATE=process`): each run executes in a confined child process and brokers its effects back over a pipe, so a breakout has no - ambient process to land in. Layers are best-effort — set - `CHIDORI_ISOLATE_REQUIRE_SANDBOX=1` to fail closed if the platform's core - confinement can't be applied. See + ambient process to land in. The run fails closed by default if the + platform's core confinement (seccomp/Seatbelt) can't be applied. See [OS-level isolation](#os-level-isolation-opt-in---isolate) for the full posture. (Running each agent in its own container is still complementary.) 4. Keep `node:fs` on `FsPolicy::Captured` (the VFS) and avoid `workspace.*`.