Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions crates/chidori/src/runtime/isolate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 10 additions & 8 deletions crates/chidori/src/runtime/isolate/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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")]
{
Expand Down
143 changes: 115 additions & 28 deletions crates/chidori/src/runtime/isolate/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -128,44 +129,81 @@ fn serve_inner<R: Read + 'static, W: Write + 'static>(
// 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") {
run_selftest(&mode.to_string_lossy(), &sandbox);
}
}

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<dyn RunHost> = Rc::new(BrokeredHost {
io: io.clone(),
prelude,
Expand All @@ -187,14 +225,36 @@ fn serve_inner<R: Read + 'static, W: Write + 'static>(
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,
}
}

Expand Down Expand Up @@ -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:?}");
}
}
}
63 changes: 63 additions & 0 deletions crates/chidori/tests/isolate_limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 13 additions & 11 deletions docs/os-isolation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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). |
Expand Down
Loading
Loading