diff --git a/RUN-REPORT.md b/RUN-REPORT.md new file mode 100644 index 0000000..fbb24a0 --- /dev/null +++ b/RUN-REPORT.md @@ -0,0 +1,74 @@ +# Autonomous run — report + +Brief: `GOAL.prompt.md`. Baseline `main` at `34dd903`; run started 2026-08-23. + +## Questions needing you + +1. **M14.8 — fail-closed, or build the proxy?** Fail-closed is landing now (task 2) and is most of + the milestone. The judges scored it 7.0 against the proxy's 6.8. The proxy is *feasible* on + macOS — verified, a Seatbelt profile pins egress to one endpoint — but T2 Linux is the only tier + CI gates, and there a host proxy is unreachable under `--unshare-net` without a veth pair or a + relay binary shipped into the jail. That is a distribution problem, not a sandbox one. + **Answer: "fail-closed is the milestone" or "build the proxy anyway".** +2. **Should a released air-gap kit default to packing an inference runner?** `xtask airgap + --runner ` exists. Defaulting means vendoring a third-party binary per architecture with + a licensing surface `cargo deny` cannot see. **Answer: yes / no.** + +## Status + +| # | Task | State | PR | Merge | +|---|---|---|---|---| +| 1 | Merge PR #35 (handover docs) | **shipped** | #35 | `e40d4fb` | +| 2 | Fail-closed egress in T2 | in progress | — | — | +| 3 | `plugin.toml` `net:` claims enforcement it lacks | scoped, not started | — | — | +| 4 | M14.8 decide and land | blocked on question 1 | — | — | +| 5 | Executable training gates | not started | — | — | +| 6 | `training/` directories | not started | — | — | +| 7 | Shadow mode behind a flag | not started | — | — | +| 8 | Define M0.2 | not started | — | — | +| 9 | Free measurements | not started | — | — | + +## Found outside the task list + +**The latent full-egress path was on two tiers, not one.** The brief named T2 Linux +(`t2_linux.rs:152`, a non-empty allowlist skipped `--unshare-net`). T2 macOS had the same shape: +a non-empty allowlist emitted `(allow network-outbound)` **plus `(allow network-bind)`**, so +asking for one host granted unrestricted egress *and* inbound bind. Both branches are now deleted +rather than left unreachable, and `NetPolicy::enforceable()` refuses such a policy at `create`. +Still latent in both cases — every caller passes `NetPolicy::default()`. + +**A scoping agent reported a macOS sandbox escape that does not exist.** It claimed +`(allow mach-lookup)` lets `nscurl -bg` exfiltrate via `nsurlsessiond`, with rc=0 and 559 bytes +fetched under the shipped profile. Tested against the profile generated by the real +`SeatbeltProfile::from_policy`: `nscurl -bg` exits **139 with no file**, while the *unsandboxed* +control fetches exactly **559 bytes** — the number it reported. Its "sandboxed" probe was almost +certainly not sandboxed. **No vulnerability. Not fixed, because there is nothing to fix.** +Unrestricted `mach-lookup` remains broad and is worth tightening as hardening, on its own merits. + +## Tests deliberately broken, to prove they fail + +| Test | Bug restored | Result | +|---|---|---| +| `net_policy_tests::a_named_host_is_refused_rather_than_approximated` | `enforceable()` always returns `Ok` | red — `a named host must be refused` | +| `t2_macos_escape::a_named_host_in_the_allowlist_is_refused_not_granted` | `enforceable()` no-op + macOS allowlist branch restored | red — sandbox created a session with an unenforceable policy | + +Both green again after restoring. + +## Environment note that will cost the next run time + +**This machine builds another project (`oag-server`) concurrently, and panday's suite has +time-sensitive tests that fail or hang under that contention.** Observed three times: +- `cargo test --workspace` exceeded 50 minutes with its `mcp_in_the_loop` child at 0% CPU. The same + test passes in **0.05s** on a quiet machine, on this branch. Not a code defect. +- `panday-sandbox::a_nonzero_exit_is_reported_not_swallowed` fails when the jail's 30s wall clock + elapses under load; a killed process reports no exit code, so `Some(3)` reads as `None`. +- A gate script that ran `cargo test --workspace` twice left the second copy holding the cargo + lock, blocking an unrelated run. Fixed by running it once. + +A `cargo test --workspace` **parent** at 0% CPU is normal — it waits while children run. Judge a +stall by the child, not the parent. + +## Unverified claims + +- T2 Linux changes are `#[cfg(target_os = "linux")]` and **cannot be compiled on this machine**. + CI is the only authority for `t2_linux.rs`, per the brief's rule 12. diff --git a/crates/panday-sandbox/src/lib.rs b/crates/panday-sandbox/src/lib.rs index 1b5a717..0bbb4cd 100644 --- a/crates/panday-sandbox/src/lib.rs +++ b/crates/panday-sandbox/src/lib.rs @@ -44,9 +44,13 @@ pub struct FsPolicy { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetPolicy { - /// Always true: all egress via the logging proxy (docs/14 §policy). + /// Reserved for the egress proxy docs/14 §policy describes. No tier reads it: there is no + /// proxy, so there is nothing for it to switch between. pub via_proxy: bool, - /// Domain patterns allowed through the proxy; empty = full deny. + /// Domain patterns that would be allowed through the proxy. + /// + /// **Refused, not honoured** — see [`NetPolicy::enforceable`]. Empty is the only accepted + /// value today, and it means no egress at all. #[serde(default)] pub allow: Vec, } @@ -60,6 +64,33 @@ impl Default for NetPolicy { } } +impl NetPolicy { + /// Refuse a policy no tier can enforce, before anything is spawned. + /// + /// docs/14 specifies a per-domain allowlist served by an egress proxy. The proxy is not built + /// (M14.8), so no tier can distinguish `api.github.com` from anything else — and the honest + /// answer to "may this reach exactly these hosts?" is an error, not a guess. + /// + /// It is an error rather than a silent downgrade to full deny for two reasons. A caller that + /// asked for network and got none would fail later, somewhere less obvious, with a timeout + /// instead of a reason. And the previous behaviour was worse than a downgrade: T2 Linux read a + /// non-empty allowlist as "do not unshare the network namespace", so asking for one host + /// granted **the host's entire network** — the opposite of what the field reads like. Nothing + /// constructed such a policy, so it was never reachable, but it was one caller away. + pub fn enforceable(&self) -> Result<(), SandboxError> { + if self.allow.is_empty() { + return Ok(()); + } + Err(SandboxError::PolicyViolation(format!( + "net.allow names {} host(s) and no tier can enforce a per-domain allowlist: the egress \ + proxy docs/14 §policy describes is not built. Leave `allow` empty for no egress. \ + Refused rather than approximated — granting more than was asked for is how this used \ + to behave, and granting less would fail later as a timeout with no reason attached.", + self.allow.len() + ))) + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct Limits { pub cpu_ms: u64, @@ -171,3 +202,33 @@ pub trait Sandbox: Send + Sync { async fn snapshot(&self, h: &SandboxHandle) -> Result; async fn destroy(&self, h: SandboxHandle) -> Result<(), SandboxError>; } + +#[cfg(test)] +mod net_policy_tests { + use super::*; + + #[test] + fn the_default_policy_is_enforceable_because_it_asks_for_nothing() { + NetPolicy::default().enforceable().expect("empty is fine"); + } + + #[test] + fn a_named_host_is_refused_rather_than_approximated() { + // The two wrong answers this guards against. Granting more than was asked for is what T2 + // actually did — a non-empty allowlist dropped the network namespace on Linux and emitted + // `(allow network-outbound)` on macOS. Granting less, by quietly downgrading to full deny, + // would surface as a timeout somewhere later with no reason attached. + let policy = NetPolicy { + via_proxy: true, + allow: vec!["api.github.com".into()], + }; + let err = policy + .enforceable() + .expect_err("a named host must be refused"); + let message = err.to_string(); + assert!( + message.contains("net.allow") && message.contains("proxy"), + "the refusal must say which field and why: {message}" + ); + } +} diff --git a/crates/panday-sandbox/src/t0.rs b/crates/panday-sandbox/src/t0.rs index ea7263e..649fd41 100644 --- a/crates/panday-sandbox/src/t0.rs +++ b/crates/panday-sandbox/src/t0.rs @@ -113,6 +113,8 @@ impl T0Sandbox { #[async_trait::async_trait] impl Sandbox for T0Sandbox { async fn create(&self, spec: SessionSpec) -> Result { + // Before anything is spawned: a policy no tier can enforce is refused, not approximated. + spec.policy.net.enforceable()?; if spec.tier != SandboxTier::T0InProcess { return Err(SandboxError::Unsupported(spec.tier)); } diff --git a/crates/panday-sandbox/src/t2_linux.rs b/crates/panday-sandbox/src/t2_linux.rs index d25b424..a0b07eb 100644 --- a/crates/panday-sandbox/src/t2_linux.rs +++ b/crates/panday-sandbox/src/t2_linux.rs @@ -24,8 +24,8 @@ //! | Memory ceiling | needs cgroup v2 delegation | **not enforced** | use crate::{ - ExecSpec, ExecStream, FsPolicy, Limits, NetPolicy, Sandbox, SandboxError, SandboxHandle, - SandboxPolicy, SandboxTier, SessionSpec, SnapshotRef, + ExecSpec, ExecStream, FsPolicy, Limits, Sandbox, SandboxError, SandboxHandle, SandboxPolicy, + SandboxTier, SessionSpec, SnapshotRef, }; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -52,7 +52,10 @@ struct Session { workspace: PathBuf, staged_ro: Vec, limits: Limits, - net: NetPolicy, + // No `net`. It was here so `build_args` could decide whether to unshare the network + // namespace; that decision is gone — the answer is always yes — and `NetPolicy::enforceable` + // guarantees at `create` that `allow` is empty, so a stored copy would record a constant. + // When the egress proxy exists, what belongs here is the proxy's endpoint, not the policy. env: Vec<(String, String)>, } @@ -88,7 +91,6 @@ impl T2LinuxSandbox { workspace: std::env::temp_dir(), staged_ro: vec![], limits: Limits::default(), - net: NetPolicy::default(), env: vec![], }; let mut args = build_args(&probe_session); @@ -149,9 +151,13 @@ fn build_args(s: &Session) -> Vec { push(&mut a, "--unshare-ipc"); push(&mut a, "--unshare-uts"); push(&mut a, "--unshare-cgroup-try"); - if s.net.allow.is_empty() { - push(&mut a, "--unshare-net"); - } + // Unconditional. This used to be gated on `s.net.allow.is_empty()`, which meant a non-empty + // allowlist *dropped* the network namespace and handed the payload the host's whole network — + // the loopback services, the LAN, the cloud metadata endpoint — while reading like a + // restriction. `NetPolicy::enforceable` now refuses such a policy at `create`, so this branch + // could not be reached either way; it is gone so that a future caller cannot resurrect it by + // deleting the check. + push(&mut a, "--unshare-net"); // The jail must not outlive us; an orphaned sandbox is an escape of a // different kind. push(&mut a, "--die-with-parent"); @@ -225,6 +231,8 @@ fn build_args(s: &Session) -> Vec { #[async_trait::async_trait] impl Sandbox for T2LinuxSandbox { async fn create(&self, spec: SessionSpec) -> Result { + // Before anything is spawned: a policy no tier can enforce is refused, not approximated. + spec.policy.net.enforceable()?; if spec.tier != SandboxTier::T2OsJail { return Err(SandboxError::Unsupported(spec.tier)); } @@ -236,7 +244,7 @@ impl Sandbox for T2LinuxSandbox { let SandboxPolicy { fs, - net, + net: _, limits, env: injected_env, } = spec.policy; @@ -271,7 +279,6 @@ impl Sandbox for T2LinuxSandbox { workspace, staged_ro: staged, limits, - net, env: injected_env, }, ); diff --git a/crates/panday-sandbox/src/t2_macos.rs b/crates/panday-sandbox/src/t2_macos.rs index c482439..ba1ec6c 100644 --- a/crates/panday-sandbox/src/t2_macos.rs +++ b/crates/panday-sandbox/src/t2_macos.rs @@ -161,16 +161,19 @@ impl SeatbeltProfile { } p.push_str("(allow file-ioctl (literal \"/dev/tty\"))\n"); - // Network: default-deny (docs/14 §policy). An allowlist entry cannot - // be expressed per-domain in Seatbelt — DNS names are resolved before - // the syscall — so a non-empty allowlist means "egress permitted, and - // the proxy does the filtering" (the proxy is M14.2's component). - if net.allow.is_empty() { - p.push_str("(deny network*)\n"); - } else { - p.push_str("(allow network-outbound)\n"); - p.push_str("(allow network-bind)\n"); - } + // Network: default-deny, unconditionally (docs/14 §policy). + // + // Seatbelt cannot express a per-domain rule — a name is resolved before the syscall the + // profile sees — so this used to read a non-empty allowlist as "egress permitted, the + // proxy filters". There is no proxy (M14.8), so that branch granted *unrestricted* egress + // plus inbound bind to any policy that named a single host. `NetPolicy::enforceable` + // refuses such a policy before a profile is ever generated, and the branch is gone rather + // than left unreachable, so deleting the check cannot bring it back. + // + // `net` stays in the signature: when the proxy exists, this is where its one permitted + // endpoint is named. + let _ = net; + p.push_str("(deny network*)\n"); Ok(SeatbeltProfile(p)) } @@ -232,6 +235,8 @@ impl T2MacosSandbox { #[async_trait::async_trait] impl Sandbox for T2MacosSandbox { async fn create(&self, spec: SessionSpec) -> Result { + // Before anything is spawned: a policy no tier can enforce is refused, not approximated. + spec.policy.net.enforceable()?; if spec.tier != SandboxTier::T2OsJail { return Err(SandboxError::Unsupported(spec.tier)); } diff --git a/crates/panday-sandbox/tests/t2_linux_escape.rs b/crates/panday-sandbox/tests/t2_linux_escape.rs index 37ca22b..bebd74a 100644 --- a/crates/panday-sandbox/tests/t2_linux_escape.rs +++ b/crates/panday-sandbox/tests/t2_linux_escape.rs @@ -355,3 +355,45 @@ async fn the_wrong_tier_is_refused() { SandboxError::Unsupported(SandboxTier::T3MicroVm) )); } + +// ── M14.8: a policy no tier can enforce is refused ─────────────────────────── + +#[tokio::test] +async fn a_named_host_in_the_allowlist_is_refused_not_granted() { + // docs/14 §policy specifies a per-domain allowlist served by an egress proxy. The proxy is not + // built, so this tier cannot tell `api.github.com` from anything else. + // + // The bug this pins: a non-empty allowlist used to mean "the proxy filters, so open the gate", + // and with no proxy that granted the payload the host's whole network — loopback services, the + // LAN, the cloud metadata endpoint. Asking for one host got everything. Nothing in the tree + // constructed such a policy, so it was never reachable; it was one caller away. + if !T2LinuxSandbox::available() { + return; + } + let root = TempDir::new("allowlist"); + let workspace = root.path().join("ws"); + std::fs::create_dir_all(&workspace).unwrap(); + + let err = T2LinuxSandbox::new() + .create(SessionSpec { + tier: SandboxTier::T2OsJail, + policy: SandboxPolicy { + fs: FsPolicy { + workspace_rw: workspace, + staged_ro: vec![], + }, + net: NetPolicy { + via_proxy: true, + allow: vec!["api.github.com".into()], + }, + ..Default::default() + }, + }) + .await + .expect_err("a per-domain allowlist must be refused while no proxy exists"); + + assert!( + matches!(err, SandboxError::PolicyViolation(_)), + "refused for the stated reason, not by accident: {err:?}" + ); +} diff --git a/crates/panday-sandbox/tests/t2_macos_escape.rs b/crates/panday-sandbox/tests/t2_macos_escape.rs index a380885..8fc2810 100644 --- a/crates/panday-sandbox/tests/t2_macos_escape.rs +++ b/crates/panday-sandbox/tests/t2_macos_escape.rs @@ -439,3 +439,45 @@ async fn the_wrong_tier_is_refused() { SandboxError::Unsupported(SandboxTier::T3MicroVm) )); } + +// ── M14.8: a policy no tier can enforce is refused ─────────────────────────── + +#[tokio::test] +async fn a_named_host_in_the_allowlist_is_refused_not_granted() { + // docs/14 §policy specifies a per-domain allowlist served by an egress proxy. The proxy is not + // built, so this tier cannot tell `api.github.com` from anything else. + // + // The bug this pins: a non-empty allowlist used to mean "the proxy filters, so open the gate", + // and with no proxy that granted the payload the host's whole network — loopback services, the + // LAN, the cloud metadata endpoint. Asking for one host got everything. Nothing in the tree + // constructed such a policy, so it was never reachable; it was one caller away. + if !T2MacosSandbox::available() { + return; + } + let root = TempDir::new("allowlist"); + let workspace = root.path().join("ws"); + std::fs::create_dir_all(&workspace).unwrap(); + + let err = T2MacosSandbox::new() + .create(SessionSpec { + tier: SandboxTier::T2OsJail, + policy: SandboxPolicy { + fs: FsPolicy { + workspace_rw: workspace, + staged_ro: vec![], + }, + net: NetPolicy { + via_proxy: true, + allow: vec!["api.github.com".into()], + }, + ..Default::default() + }, + }) + .await + .expect_err("a per-domain allowlist must be refused while no proxy exists"); + + assert!( + matches!(err, SandboxError::PolicyViolation(_)), + "refused for the stated reason, not by accident: {err:?}" + ); +} diff --git a/docs/14-sandbox.md b/docs/14-sandbox.md index 69d083c..cd70e9a 100644 --- a/docs/14-sandbox.md +++ b/docs/14-sandbox.md @@ -121,7 +121,9 @@ type error. The egress *proxy* (for a non-empty allowlist) is not built: with `--unshare-net` there is no network to filter, and default-deny is the stronger guarantee. A per-domain allowlist needs the proxy component and is - deferred with it. + deferred with it — and since M14.8, **refused** rather than deferred silently: + `NetPolicy::enforceable` fails `create` on a non-empty `allow`, so no tier can + be handed a policy it cannot honour. - **M14.3** T2 macOS via Seatbelt profile generation; parity subset of escape suite. ✅ *(shipped: `panday_sandbox::t2_macos` — `SeatbeltProfile`, `T2MacosSandbox`; 15-case suite in `crates/panday-sandbox/tests/t2_macos_escape.rs`.)* Taken **out of roadmap order**, ahead of M14.2: the roadmap assumes a Linux @@ -309,10 +311,31 @@ type error. that "a per-domain allowlist needs the proxy component and is deferred with it" — true, and invisible, because the deferral carried no number. - The gap it leaves is worth stating plainly: `docs/16` §plugin.toml lets a - plugin declare `net: [api.github.com]`, and today the sandbox cannot enforce - that. Under `--unshare-net` there is no network at all, so default-deny holds - and nothing is *less* safe than it claims — but a manifest field that reads - like an allowlist and is in fact an all-or-nothing switch is the kind of thing - someone will one day rely on. Until this ships, a non-empty `net` list means - "this plugin wants network", not "this plugin may reach exactly these hosts". + **This description of the gap was wrong, and understated it.** It said a + non-empty `net` list was "an all-or-nothing switch". The "on" position was not + *no filtering within an allowed set* — it was the host's entire network. T2 + Linux read a non-empty allowlist as "do not pass `--unshare-net`", and T2 + macOS emitted `(allow network-outbound)` plus `(allow network-bind)`. Asking + for one host granted every host, the loopback services beside the sandbox, the + LAN and the cloud metadata endpoint — while the manifest field read like a + restriction. + + It was never reachable: every caller in the tree passes `NetPolicy::default()` + (empty `allow`), and `plugin.toml`'s `net` never reached `SandboxPolicy`. It + was one caller away from being live, which is not a margin worth keeping. + + **Shipped instead: fail closed, by name.** `--unshare-net` and + `(deny network*)` are now unconditional — the permissive branches are deleted + rather than left unreachable, so removing the check cannot resurrect them — + and `NetPolicy::enforceable` refuses a non-empty `allow` at `create` with a + `PolicyViolation` naming the field and the reason. + + Refused rather than downgraded to full deny, deliberately. A caller that asked + for network and silently got none fails later, somewhere less obvious, as a + timeout with no reason attached; a caller that asked for one host and got + every host is the bug above. An error is the only answer that is neither. + + So a non-empty `net` list now means **"this configuration is rejected"**, not + "this plugin wants network" and not "this plugin may reach exactly these + hosts". Per-domain egress remains unbuilt; when it exists, `NetPolicy::allow` + is where it plugs in and this refusal is what it replaces.