Skip to content
Merged
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
74 changes: 74 additions & 0 deletions RUN-REPORT.md
Original file line number Diff line number Diff line change
@@ -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 <path>` 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.
65 changes: 63 additions & 2 deletions crates/panday-sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}
Expand All @@ -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,
Expand Down Expand Up @@ -171,3 +202,33 @@ pub trait Sandbox: Send + Sync {
async fn snapshot(&self, h: &SandboxHandle) -> Result<SnapshotRef, SandboxError>;
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}"
);
}
}
2 changes: 2 additions & 0 deletions crates/panday-sandbox/src/t0.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ impl T0Sandbox {
#[async_trait::async_trait]
impl Sandbox for T0Sandbox {
async fn create(&self, spec: SessionSpec) -> Result<SandboxHandle, SandboxError> {
// 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));
}
Expand Down
25 changes: 16 additions & 9 deletions crates/panday-sandbox/src/t2_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -52,7 +52,10 @@ struct Session {
workspace: PathBuf,
staged_ro: Vec<PathBuf>,
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)>,
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -149,9 +151,13 @@ fn build_args(s: &Session) -> Vec<String> {
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");
Expand Down Expand Up @@ -225,6 +231,8 @@ fn build_args(s: &Session) -> Vec<String> {
#[async_trait::async_trait]
impl Sandbox for T2LinuxSandbox {
async fn create(&self, spec: SessionSpec) -> Result<SandboxHandle, SandboxError> {
// 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));
}
Expand All @@ -236,7 +244,7 @@ impl Sandbox for T2LinuxSandbox {

let SandboxPolicy {
fs,
net,
net: _,
limits,
env: injected_env,
} = spec.policy;
Expand Down Expand Up @@ -271,7 +279,6 @@ impl Sandbox for T2LinuxSandbox {
workspace,
staged_ro: staged,
limits,
net,
env: injected_env,
},
);
Expand Down
25 changes: 15 additions & 10 deletions crates/panday-sandbox/src/t2_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -232,6 +235,8 @@ impl T2MacosSandbox {
#[async_trait::async_trait]
impl Sandbox for T2MacosSandbox {
async fn create(&self, spec: SessionSpec) -> Result<SandboxHandle, SandboxError> {
// 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));
}
Expand Down
42 changes: 42 additions & 0 deletions crates/panday-sandbox/tests/t2_linux_escape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"
);
}
42 changes: 42 additions & 0 deletions crates/panday-sandbox/tests/t2_macos_escape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"
);
}
Loading