diff --git a/.github/workflows/sandboy-feasibility-gate.yml b/.github/workflows/sandboy-feasibility-gate.yml new file mode 100644 index 00000000..fc60bdb7 --- /dev/null +++ b/.github/workflows/sandboy-feasibility-gate.yml @@ -0,0 +1,95 @@ +name: Sandboy S0 — GitHub-Hosted Runner Feasibility Gate + +# Capability probe only (Sandboy S0, part of the QODEC Scope N2 sourcing +# addendum). Determines whether the EXISTING Sandboy commit (Landlock + +# seccomp, no VM — sandboy/docs is Layer 2 of +# ../docs/notes/sandboy-isolation-adr.md) actually confines a hostile process +# tree on a standard `ubuntu-24.04` GitHub-hosted runner, which supplies the +# disposable-VM boundary this gate assumes rather than builds. +# +# This workflow explicitly does NOT: execute any third-party repository, +# build RepoLogMiner, start the N2 18-case corpus, touch qodec/ or any frozen +# Benchmark v2 file, or implement a microVM/new isolation mechanism. Only +# synthetic hostile fixtures committed under sandboy/tests/feasibility/ run +# here. +# +# Least privilege: read-only checkout, no secrets, no OIDC, no package +# publishing, no cache write. Every third-party `uses:` is pinned to a commit +# SHA (matching this repo's existing ci.yml convention). +permissions: + contents: read + +on: + pull_request: + paths: + - "sandboy/**" + - ".github/workflows/sandboy-feasibility-gate.yml" + workflow_dispatch: + +jobs: + feasibility-gate: + name: Sandboy S0 feasibility gate + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 + with: + toolchain: stable + + - name: cargo test (policy unit tests — env_allow, seccomp_deny) + working-directory: sandboy + run: cargo test --release --locked + + - name: Build sandboy (release, locked) + working-directory: sandboy + run: cargo build --release --locked + + - name: Run Sandboy S0 feasibility gate + id: gate + working-directory: sandboy + # Fake canary credentials injected into the OUTER job env, per the N2 + # Sandboy-dogfooding addendum §8. Values are unmistakably fake; no + # real secret, token, or credential is used or present anywhere in + # this job. The gate asserts these never reach the sandboxed process + # tree (tests/feasibility/run_gate.py env_tests + a universal + # canary-leak scan over every sandboxed invocation's stdout/stderr). + env: + GITHUB_TOKEN: fake-canary-github-token + AZURE_DEVOPS_PAT: fake-canary-azure-token + NUGET_AUTH_TOKEN: fake-canary-nuget-token + AWS_SECRET_ACCESS_KEY: fake-canary-aws-key + SSH_AUTH_SOCK: /tmp/fake-ssh-agent + run: | + set +e + mkdir -p "$RUNNER_TEMP/sandboy-s0-reports" + python3 tests/feasibility/run_gate.py \ + --sandboy-bin "$(pwd)/target/release/sandboy" \ + --out-dir "$RUNNER_TEMP/sandboy-s0-reports" + code=$? + echo "exit_code=$code" >> "$GITHUB_OUTPUT" + exit 0 # never fail this step itself — artifacts must still upload + + - name: Upload S0 feasibility reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: sandboy-s0-feasibility-reports + path: | + ${{ runner.temp }}/sandboy-s0-reports/sandboy-host-capabilities.json + ${{ runner.temp }}/sandboy-s0-reports/sandboy-feasibility-report.json + ${{ runner.temp }}/sandboy-s0-reports/sandbox-enforcement-matrix.json + ${{ runner.temp }}/sandboy-s0-reports/sandboy-feasibility-summary.md + if-no-files-found: error + retention-days: 90 + + - name: Enforce gate result + if: always() + run: | + if [ "${{ steps.gate.outputs.exit_code }}" != "0" ]; then + echo "::error::Sandboy S0 feasibility gate FAILED (or Landlock/seccomp unavailable on this runner) — see the sandboy-s0-feasibility-reports artifact, especially sandboy-feasibility-summary.md" + exit 1 + fi + echo "Sandboy S0 feasibility gate PASSED on this ubuntu-24.04 runner." diff --git a/sandboy/.gitignore b/sandboy/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/sandboy/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/sandboy/Cargo.lock b/sandboy/Cargo.lock new file mode 100644 index 00000000..8cc627bd --- /dev/null +++ b/sandboy/Cargo.lock @@ -0,0 +1,239 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "landlock" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d" +dependencies = [ + "enumflags2", + "libc", + "thiserror", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "sandboy" +version = "0.1.0" +dependencies = [ + "anyhow", + "landlock", + "libc", + "seccompiler", + "serde", + "toml", +] + +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] diff --git a/sandboy/README.md b/sandboy/README.md index 2b7fc988..16df8e94 100644 --- a/sandboy/README.md +++ b/sandboy/README.md @@ -21,6 +21,7 @@ everything it spawns — inherits the cage. | **Filesystem** | Landlock | read+exec / read+write allowlists of paths; everything else `EACCES` | | **TCP ports** | Landlock ABI v4 | connect/bind only to allowlisted ports (e.g. 443, 22) | | **Syscalls** | seccomp-bpf | denylist of dangerous syscalls (ptrace, mount, bpf, kexec, …) → `EPERM`; everything else allowed | +| **Environment** | `env_clear()` + allowlist | wrapped command inherits ONLY the variable names in `env_allow`; everything else (host credentials included) is cleared before exec | The seccomp model is a **denylist** on purpose: the goal is *broad freedom* inside a box, so we strip the clearly-dangerous rather than allowlist a minimal @@ -48,9 +49,18 @@ fs_ro = ["/usr", "/bin", "/lib", "/lib64", "/etc"] fs_rw = ["/home/user/work/worktree", "/tmp"] tcp_connect = [443, 22] # https + ssh; [] = no outbound TCP tcp_bind = [] +env_allow = ["PATH"] # names passed through; [] (default) = fully cleared env # omit seccomp_deny to use the curated default denylist ``` +`env_allow` is a **name** allowlist, resolved against the launcher's own +environment right before `exec`. Omitting it (or leaving it `[]`, the default) +means the wrapped command gets **no** inherited environment at all — the +launcher's environment is exactly where CI/agent credentials +(`GITHUB_TOKEN`, cloud/package-registry secrets, `SSH_AUTH_SOCK`, …) live, so +deny-all is the only safe default for an untrusted command. Allowlist by name +rather than maintaining a denylist of known-bad variables. + This TOML is the file Sandboy actually reads, and it should stay exactly this plain. Once there's more than one profile (`no-net`, `worktree-only`, a Windows exec allowlist) to compose without copy-pasting, author the source in CUE and @@ -69,8 +79,24 @@ Sandboy's runtime never needs to know CUE exists. Full rationale and the ```bash cargo build --release # needs Linux; the crates are Linux-only ./tests/demo.sh # four probes: 1 allowed, 3 denied +./target/release/sandboy probe # host capability report (JSON), see below ``` +### `sandboy probe` + +A second, diagnostic-only subcommand alongside `run`: applies the exact same +enforcement code (`landlock_status`, `apply_seccomp`) against a synthetic +default policy, then prints what THIS host actually did with it as JSON — +Landlock ABI/ruleset status (via the same crate call `run` uses, plus a raw +`landlock_create_ruleset(..., LANDLOCK_CREATE_RULESET_VERSION)` probe syscall +for the kernel's own ABI number), whether seccomp installed, `no_new_privs`, +`close_range`, kernel release, euid/egid, and a couple of cheap +container/VM signals (`/.dockerenv`, first `/proc/self/cgroup` line). `probe` +always exits `0` — finding "Landlock not enforced" is data, not a probe +failure; a caller (a CI gate, `tests/feasibility/run_gate.py`) decides +pass/fail from the JSON. It never applies to or execs a target command, and +never appears in a `sandbox_policy` step — that's `run`. + `demo.sh` shows a write inside the worktree succeeding, and a write to `$HOME`, a `ptrace`, and a connect to a non-allowlisted port all being denied. @@ -120,14 +146,18 @@ exists yet** — both are Floor-1 work, not current behaviour: `bypassPermissions` mode applies to the agent phase, not gate steps). Relying on unknown-field tolerance here would fail *open*. See `007/docs/loop-canvas.md`. -- **sandboy side — `--report `.** A flag emitting enforcement status / - exit code / duration, the machine-readable evidence the Observability field - asks for. **Not implemented today:** `parse_args` (`src/main.rs`) accepts only - `run`, `--policy `, and `--`, so passing `--report` now is a usage error - (exit 2). Enforcement status *is* already surfaced, but only to **stderr** - (`FullyEnforced` silently / `PARTIALLY enforced` warning / `NOT enforced` - refusal); `--report` would make it structured so 007 can persist it into - `gate/.sandbox.json`. +- **sandboy side — `--report ` on `run`.** A flag emitting per-invocation + enforcement status / exit code / duration for one wrapped step, the + machine-readable evidence the Observability field asks for. **Still not + implemented:** `run` only accepts `--policy ` and `--`, so passing + `--report` is a usage error (exit 2). Enforcement status *is* already + surfaced, but only to **stderr** (`FullyEnforced` silently / `PARTIALLY + enforced` warning / `NOT enforced` refusal). This is distinct from the + `probe` subcommand added for the Sandboy S0 feasibility gate (below): + `probe` reports host *capability*, once, with no target command; the + `gate/.sandbox.json` use case here is per-step *outcome*, for every + wrapped invocation. Wiring `run` --report into 007's gate runner is still + Floor-1 work, out of scope here. ## Kernel requirements @@ -135,3 +165,33 @@ exists yet** — both are Floor-1 work, not current behaviour: - Landlock TCP-port scoping: kernel ≥ 6.7 (ABI v4). On older kernels sandboy runs **best-effort** and prints a `PARTIALLY enforced` warning; it refuses to run only if Landlock is entirely absent (`NOT enforced`). + +## Sandboy S0 — GitHub-hosted runner feasibility gate + +`tests/feasibility/` is a capability probe, not a repository miner: it proves +(or disproves) that *this* Sandboy commit actually confines a hostile process +tree on a standard `ubuntu-24.04` GitHub-hosted runner — Landlock FS/port +scoping, seccomp, fork/exec inheritance, credential-canary isolation via +`env_allow`, and the two structural network gaps (no UDP coverage; TCP is +scoped by port only, never by destination address, so a policy allowing e.g. +port 443 cannot distinguish a real destination from a cloud metadata +endpoint on the same port — that's the unbuilt Layer 3). It never executes +third-party repository code; every fixture under `tests/feasibility/fixtures/` +is a small first-party Python probe run via a plain argv (never a shell +string). + +```bash +cargo build --release +python3 tests/feasibility/run_gate.py \ + --sandboy-bin "$(pwd)/target/release/sandboy" \ + --out-dir /tmp/sandboy-s0-reports +``` + +Writes `sandboy-host-capabilities.json`, `sandboy-feasibility-report.json`, +`sandbox-enforcement-matrix.json`, and `sandboy-feasibility-summary.md`, and +exits non-zero if the S0 acceptance criteria aren't met (e.g. this host has no +Landlock at all — the case in most container-based dev sandboxes, which is +exactly why this gate targets a real `ubuntu-24.04` runner in CI: +`.github/workflows/sandboy-feasibility-gate.yml`). A gate failure because +Landlock/seccomp is unavailable on the runner is a real finding to report, +not something to route around by weakening `run`'s fail-closed refusal. diff --git a/sandboy/policy.example.toml b/sandboy/policy.example.toml index 82d7f5cd..1503ea92 100644 --- a/sandboy/policy.example.toml +++ b/sandboy/policy.example.toml @@ -28,3 +28,10 @@ tcp_bind = [] # seccomp: omit `seccomp_deny` to use the curated default denylist # (ptrace, mount, bpf, kexec, …). Override here only to widen/narrow it, e.g.: # seccomp_deny = ["ptrace", "mount", "bpf", "kexec_load", "kexec_file_load"] + +# Environment: NAMES passed through from the launcher's own environment. +# Everything else is cleared before exec. Omit (or leave empty) to give the +# wrapped command NO inherited environment at all — the launcher's env is +# exactly where CI/agent credentials (GITHUB_TOKEN, cloud/registry secrets, +# SSH_AUTH_SOCK, ...) live, so deny-all is the only safe default. +env_allow = ["PATH"] diff --git a/sandboy/src/main.rs b/sandboy/src/main.rs index 8f6ae542..10cc6c88 100644 --- a/sandboy/src/main.rs +++ b/sandboy/src/main.rs @@ -32,39 +32,61 @@ use landlock::{ use policy::Policy; +enum Mode { + Run { + policy_path: String, + argv: Vec, + }, + Probe, +} + fn main() { // Exit codes, distinguished by error *origin* rather than message text: // 2 = config/usage error (bad args, unreadable/invalid policy); // 1 = runtime/confinement error (Landlock/seccomp/fd/exec). - let (policy_path, argv) = parse_args().unwrap_or_else(|e| { + let mode = parse_args().unwrap_or_else(|e| { eprintln!("sandboy: {e:#}"); std::process::exit(2); }); - let policy = Policy::load(&policy_path) - .with_context(|| format!("loading policy {policy_path}")) - .unwrap_or_else(|e| { - eprintln!("sandboy: {e:#}"); - std::process::exit(2); - }); - if argv.is_empty() { - eprintln!("sandboy: no command after `--`"); - std::process::exit(2); - } - if let Err(e) = confine_and_exec(&policy, &argv) { - eprintln!("sandboy: {e:#}"); - std::process::exit(1); + match mode { + Mode::Probe => { + // `probe` is diagnostic-only: it reports what this host can + // enforce and always exits 0 (finding "not enforced" is not a + // probe *failure* — a caller decides pass/fail from the JSON). + let report = probe_capabilities(); + println!("{}", report_to_json(&report)); + } + Mode::Run { policy_path, argv } => { + let policy = Policy::load(&policy_path) + .with_context(|| format!("loading policy {policy_path}")) + .unwrap_or_else(|e| { + eprintln!("sandboy: {e:#}"); + std::process::exit(2); + }); + if argv.is_empty() { + eprintln!("sandboy: no command after `--`"); + std::process::exit(2); + } + if let Err(e) = confine_and_exec(&policy, &argv) { + eprintln!("sandboy: {e:#}"); + std::process::exit(1); + } + } } } fn confine_and_exec(policy: &Policy, argv: &[String]) -> Result<()> { - let (prog, prog_args) = argv.split_first().expect("argv non-empty (checked in main)"); + let (prog, prog_args) = argv + .split_first() + .expect("argv non-empty (checked in main)"); // 1. no_new_privs — required to install a seccomp filter unprivileged. set_no_new_privs()?; // 2. Landlock: filesystem + TCP-port scope. Best-effort so an older kernel // degrades (and reports) instead of hard-failing. - apply_landlock(policy)?; + let status = landlock_status(policy)?; + report_landlock_status(status)?; // 3. seccomp denylist: strip dangerous syscalls, allow the rest. apply_seccomp(policy)?; @@ -76,23 +98,48 @@ fn confine_and_exec(policy: &Policy, argv: &[String]) -> Result<()> { // vanishes at execve; stdio (0,1,2) is kept. close_inherited_fds()?; - // 5. Hand off. execve replaces us; the confinements persist into it. + // 5. Environment: clear everything, then pass through only the names the + // policy explicitly allowlists. Done last, right before exec, so + // nothing above (which never touches child env) can reintroduce a + // variable. Default (no env_allow) = the wrapped command inherits + // NOTHING — the launcher's environment is exactly where CI/agent + // credentials live. + let mut cmd = Command::new(prog); + cmd.args(prog_args); + cmd.env_clear(); + for name in &policy.env_allow { + if let Ok(val) = std::env::var(name) { + cmd.env(name, val); + } + } + + // 6. Hand off. execve replaces us; the confinements persist into it. // `exec()` only returns on failure. - let err = Command::new(prog).args(prog_args).exec(); + let err = cmd.exec(); Err(err).with_context(|| format!("exec {prog}")) } -fn parse_args() -> Result<(String, Vec)> { +fn parse_args() -> Result { let mut it = std::env::args().skip(1); match it.next().as_deref() { + Some("probe") => { + if let Some(extra) = it.next() { + bail!("unexpected arg {extra:?} (`probe` takes no arguments)"); + } + return Ok(Mode::Probe); + } Some("run") => {} - _ => bail!("usage: sandboy run --policy -- [args...]"), + _ => bail!( + "usage: sandboy run --policy -- [args...]\n or: sandboy probe" + ), } let mut policy_path = None; let mut argv = Vec::new(); while let Some(a) = it.next() { match a.as_str() { - "--policy" => policy_path = Some(it.next().ok_or_else(|| anyhow!("--policy needs a value"))?), + "--policy" => { + policy_path = Some(it.next().ok_or_else(|| anyhow!("--policy needs a value"))?) + } "--" => { argv.extend(it.by_ref()); break; @@ -100,13 +147,17 @@ fn parse_args() -> Result<(String, Vec)> { other => bail!("unexpected arg {other:?} (did you forget `--` before the command?)"), } } - Ok(( - policy_path.ok_or_else(|| anyhow!("--policy is required"))?, + Ok(Mode::Run { + policy_path: policy_path.ok_or_else(|| anyhow!("--policy is required"))?, argv, - )) + }) } -fn apply_landlock(policy: &Policy) -> Result<()> { +/// Build the same ruleset `run` would (given `policy`) and restrict_self it, +/// returning the enforcement status without deciding pass/fail — `run` bails +/// on `NotEnforced`, `probe` just reports it. This is the single code path +/// both go through, so probe output can't drift from real enforcement. +fn landlock_status(policy: &Policy) -> Result { // ABI::V4 adds TCP bind/connect scoping (kernel 6.7+). Best-effort compat // means: on an older kernel, unsupported bits are dropped, not fatal. let abi = ABI::V4; @@ -134,17 +185,23 @@ fn apply_landlock(policy: &Policy) -> Result<()> { ruleset = ruleset.add_rule(NetPort::new(port, AccessNet::BindTcp))?; } - let status = ruleset.restrict_self().context("landlock restrict_self")?; - match status.ruleset { - RulesetStatus::FullyEnforced => {} + Ok(ruleset + .restrict_self() + .context("landlock restrict_self")? + .ruleset) +} + +fn report_landlock_status(status: RulesetStatus) -> Result<()> { + match status { + RulesetStatus::FullyEnforced => Ok(()), RulesetStatus::PartiallyEnforced => { eprintln!("sandboy: warning: Landlock only PARTIALLY enforced (kernel too old for some access rights)"); + Ok(()) } RulesetStatus::NotEnforced => { bail!("Landlock NOT enforced — kernel lacks Landlock (need >=5.13, >=6.7 for TCP). Refusing to run unconfined."); } } - Ok(()) } /// A path that doesn't exist would make the whole ruleset fail; skip-with-warn @@ -157,7 +214,10 @@ fn add_fs( match PathFd::new(path) { Ok(fd) => Ok(ruleset.add_rule(PathBeneath::new(fd, access))?), Err(e) => { - eprintln!("sandboy: warning: skipping missing path {}: {e}", path.display()); + eprintln!( + "sandboy: warning: skipping missing path {}: {e}", + path.display() + ); Ok(ruleset) } } @@ -176,7 +236,7 @@ fn apply_seccomp(policy: &Policy) -> Result<()> { let filter = SeccompFilter::new( rules, - SeccompAction::Allow, // mismatch: everything not listed + SeccompAction::Allow, // mismatch: everything not listed SeccompAction::Errno(libc::EPERM as u32), // match: the dangerous ones std::env::consts::ARCH .try_into() @@ -199,7 +259,10 @@ fn set_no_new_privs() -> Result<()> { // effects, and only ever tightens privileges. Return value checked below. let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; if rc != 0 { - bail!("prctl(PR_SET_NO_NEW_PRIVS) failed: {}", std::io::Error::last_os_error()); + bail!( + "prctl(PR_SET_NO_NEW_PRIVS) failed: {}", + std::io::Error::last_os_error() + ); } Ok(()) } @@ -212,10 +275,235 @@ fn close_inherited_fds() -> Result<()> { // SAFETY: close_range with CLOSE_RANGE_CLOEXEC only sets FD_CLOEXEC on the // [3, u32::MAX] descriptor range; fixed flag, no memory effects. rc checked. let rc = unsafe { - libc::close_range(3, libc::c_uint::MAX, libc::CLOSE_RANGE_CLOEXEC as libc::c_int) + libc::close_range( + 3, + libc::c_uint::MAX, + libc::CLOSE_RANGE_CLOEXEC as libc::c_int, + ) }; if rc != 0 { - bail!("close_range(CLOEXEC) failed: {}", std::io::Error::last_os_error()); + bail!( + "close_range(CLOEXEC) failed: {}", + std::io::Error::last_os_error() + ); } Ok(()) } + +/// One field of the `probe` report: what was checked, and how it turned out. +/// `ok` is `None` when the check itself couldn't be performed (not a failure +/// of the *mechanism*, e.g. `/proc` unreadable). +struct Field { + name: &'static str, + value: Json, +} + +enum Json { + Bool(bool), + Int(i64), + Str(String), + Null, +} + +/// Host capability report used by the Sandboy S0 feasibility gate +/// (`sandboy-host-capabilities.json`). This is the SAME enforcement code +/// `run` uses (`landlock_status`, `apply_seccomp`), applied to a synthetic +/// default policy, so the report can't drift from what a real `run` would +/// actually do on this host. `probe` restricts itself the same way `run` +/// does — safe here because probe only ever writes to already-open stdout +/// and then exits, neither of which any policy denies. +fn probe_capabilities() -> Vec { + let mut f = Vec::new(); + f.push(Field { + name: "sandboy_version", + value: Json::Str(env!("CARGO_PKG_VERSION").to_string()), + }); + f.push(Field { + name: "arch", + value: Json::Str(std::env::consts::ARCH.to_string()), + }); + f.push(Field { + name: "kernel_release", + value: json_opt_str(kernel_release()), + }); + f.push(Field { + name: "euid", + value: Json::Int(unsafe { libc::geteuid() } as i64), + }); + f.push(Field { + name: "egid", + value: Json::Int(unsafe { libc::getegid() } as i64), + }); + + let (kernel_abi, kernel_abi_note) = raw_landlock_kernel_abi(); + f.push(Field { + name: "landlock_kernel_abi_version", + value: json_opt_int(kernel_abi), + }); + f.push(Field { + name: "landlock_kernel_abi_note", + value: Json::Str(kernel_abi_note), + }); + f.push(Field { + name: "landlock_compiled_abi", + value: Json::Str("V4".to_string()), + }); + + let no_new_privs_ok = set_no_new_privs().is_ok(); + f.push(Field { + name: "no_new_privs_supported", + value: Json::Bool(no_new_privs_ok), + }); + + // A wide-open default policy (no fs_ro/fs_rw/ports/env_allow, curated + // seccomp DEFAULT_DENY): the same shape `run` would enforce for a step + // that declares nothing. Reports what THIS host does with it, not what an + // idealized one would. + let probe_policy = Policy { + fs_ro: Vec::new(), + fs_rw: Vec::new(), + tcp_connect: Vec::new(), + tcp_bind: Vec::new(), + seccomp_deny: None, + env_allow: Vec::new(), + }; + + let landlock = landlock_status(&probe_policy); + let (landlock_ok, landlock_str) = match &landlock { + Ok(RulesetStatus::FullyEnforced) => (true, "fully_enforced".to_string()), + Ok(RulesetStatus::PartiallyEnforced) => (false, "partially_enforced".to_string()), + Ok(RulesetStatus::NotEnforced) => (false, "not_enforced".to_string()), + Err(e) => (false, format!("error: {e:#}")), + }; + f.push(Field { + name: "landlock_ruleset_status", + value: Json::Str(landlock_str), + }); + f.push(Field { + name: "landlock_fully_enforced", + value: Json::Bool(landlock_ok), + }); + + let deny_numbers = probe_policy.seccomp_deny_numbers().unwrap_or_default(); + f.push(Field { + name: "seccomp_default_deny_count", + value: Json::Int(deny_numbers.len() as i64), + }); + let seccomp_ok = apply_seccomp(&probe_policy).is_ok(); + f.push(Field { + name: "seccomp_installed", + value: Json::Bool(seccomp_ok), + }); + + let close_range_ok = close_inherited_fds().is_ok(); + f.push(Field { + name: "close_range_supported", + value: Json::Bool(close_range_ok), + }); + + f.push(Field { + name: "dockerenv_present", + value: Json::Bool(std::path::Path::new("/.dockerenv").exists()), + }); + f.push(Field { + name: "cgroup_summary", + value: json_opt_str(cgroup_summary()), + }); + + f +} + +fn json_opt_str(v: Option) -> Json { + match v { + Some(s) => Json::Str(s), + None => Json::Null, + } +} + +fn json_opt_int(v: Option) -> Json { + match v { + Some(n) => Json::Int(n), + None => Json::Null, + } +} + +fn kernel_release() -> Option { + std::fs::read_to_string("/proc/sys/kernel/osrelease") + .ok() + .map(|s| s.trim().to_string()) +} + +/// First line of `/proc/self/cgroup`, a cheap container/VM signal (empty on a +/// bare VM, non-empty and container-shaped under most container runtimes). +/// Best-effort only — never a security decision, just probe context. +fn cgroup_summary() -> Option { + std::fs::read_to_string("/proc/self/cgroup") + .ok() + .and_then(|s| s.lines().next().map(str::to_string)) +} + +/// Audited `unsafe` #3: the raw `landlock_create_ruleset(NULL, 0, +/// LANDLOCK_CREATE_RULESET_VERSION)` probe syscall — the kernel-documented way +/// to ask "which Landlock ABI does this kernel implement", independent of +/// what the `landlock` crate itself understands. Read-only query, no ruleset +/// is created, no process state changes; this crate's public API deliberately +/// hides this (to stop callers building ABI-inconsistent rulesets — see +/// `landlock::compat`), but a diagnostic probe is exactly the sanctioned use. +fn raw_landlock_kernel_abi() -> (Option, String) { + // SAFETY: null attr pointer + size 0 + the VERSION query flag is the + // documented no-op probe form (Linux landlock(7)); it creates no ruleset + // and touches no caller memory. Return value and errno both checked. + let v = unsafe { + libc::syscall( + libc::SYS_landlock_create_ruleset, + std::ptr::null::(), + 0usize, + 1u32, + ) + }; + if v < 0 { + let note = match std::io::Error::last_os_error().raw_os_error() { + Some(libc::EOPNOTSUPP) => { + "not_enabled (kernel built with Landlock but disabled, e.g. boot param)".to_string() + } + Some(libc::ENOSYS) => "not_implemented (kernel not built with Landlock)".to_string(), + other => format!("errno {other:?}"), + }; + (None, note) + } else { + (Some(v), "ok".to_string()) + } +} + +fn report_to_json(fields: &[Field]) -> String { + let mut out = String::from("{\n"); + for (i, f) in fields.iter().enumerate() { + out.push_str(" \""); + out.push_str(f.name); + out.push_str("\": "); + match &f.value { + Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Json::Int(n) => out.push_str(&n.to_string()), + Json::Null => out.push_str("null"), + Json::Str(s) => { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + } + } + if i + 1 < fields.len() { + out.push(','); + } + out.push('\n'); + } + out.push('}'); + out +} diff --git a/sandboy/src/policy.rs b/sandboy/src/policy.rs index 01a7f375..24ded998 100644 --- a/sandboy/src/policy.rs +++ b/sandboy/src/policy.rs @@ -26,6 +26,15 @@ pub struct Policy { /// curated `DEFAULT_DENY` below is used. #[serde(default)] pub seccomp_deny: Option>, + /// Environment variable NAMES passed through from the launcher's own + /// environment to the wrapped command. Every other variable is cleared + /// before exec. Default (empty): the wrapped command gets no inherited + /// environment at all — deny-all is the safe default for an untrusted + /// command, since the launcher's environment is where CI credentials + /// (`GITHUB_TOKEN`, cloud/package-registry secrets, `SSH_AUTH_SOCK`, …) + /// live. Allowlist by name, not by stripping a denylist of known-bad ones. + #[serde(default)] + pub env_allow: Vec, } impl Policy { @@ -128,3 +137,61 @@ fn syscall_number(name: &str) -> Option { }; Some(nr as i64) } + +#[cfg(test)] +mod tests { + use super::*; + + /// `env_allow` deny-all-by-default: an older policy file written before + /// this field existed must still parse, with `env_allow` defaulting to + /// empty — i.e. the wrapped command gets NO inherited environment, not + /// "whatever it used to get" (there was no env handling before, so this + /// is the conservative direction: adding the field can only narrow what + /// a pre-existing policy grants, never widen it). + #[test] + fn env_allow_defaults_to_empty_when_absent() { + let toml = r#" + fs_ro = ["/usr"] + fs_rw = ["/tmp"] + "#; + let policy: Policy = toml::from_str(toml).unwrap(); + assert!(policy.env_allow.is_empty()); + } + + #[test] + fn env_allow_parses_explicit_names() { + let toml = r#" + fs_ro = ["/usr"] + fs_rw = ["/tmp"] + env_allow = ["PATH", "HOME"] + "#; + let policy: Policy = toml::from_str(toml).unwrap(); + assert_eq!( + policy.env_allow, + vec!["PATH".to_string(), "HOME".to_string()] + ); + } + + #[test] + fn seccomp_deny_default_is_nonempty_and_includes_ptrace() { + let toml = r#" + fs_ro = ["/usr"] + fs_rw = ["/tmp"] + "#; + let policy: Policy = toml::from_str(toml).unwrap(); + let numbers = policy.seccomp_deny_numbers().unwrap(); + assert!(!numbers.is_empty()); + assert!(numbers.contains(&{ libc::SYS_ptrace })); + } + + #[test] + fn seccomp_deny_explicit_unknown_name_is_a_hard_error() { + let toml = r#" + fs_ro = ["/usr"] + fs_rw = ["/tmp"] + seccomp_deny = ["not_a_real_syscall_name"] + "#; + let policy: Policy = toml::from_str(toml).unwrap(); + assert!(policy.seccomp_deny_numbers().is_err()); + } +} diff --git a/sandboy/tests/feasibility/fixtures/env_probe.py b/sandboy/tests/feasibility/fixtures/env_probe.py new file mode 100755 index 00000000..0169407c --- /dev/null +++ b/sandboy/tests/feasibility/fixtures/env_probe.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Credential-canary probe: try to read/leak the canary environment +variables the harness injects into the OUTER job environment, from inside +the sandboxed process (and, for `child`, from a forked descendant of it). + +Usage: env_probe.py + mode: stdout | file | child + +Prints RESULT=LEAK if ANY canary name is present in os.environ (regardless +of value — presence alone is the failure, since sandboy is expected to +env_clear() before exec), else RESULT=CLEAN. Also writes the full (attempted) +environment dump to so run_gate.py can grep it independently of +this script's own judgement. +""" +import os +import sys + +CANARY_NAMES = [ + "GITHUB_TOKEN", + "AZURE_DEVOPS_PAT", + "NUGET_AUTH_TOKEN", + "AWS_SECRET_ACCESS_KEY", + "SSH_AUTH_SOCK", +] + + +def dump_and_judge(out_file): + with open(out_file, "w") as f: + for k, v in sorted(os.environ.items()): + f.write(f"{k}={v}\n") + present = [n for n in CANARY_NAMES if n in os.environ] + if present: + print(f"RESULT=LEAK detail=present={','.join(present)}", flush=True) + else: + print("RESULT=CLEAN detail=no canary names in environ", flush=True) + + +def main(): + mode, out_file = sys.argv[1], sys.argv[2] + if mode in ("stdout", "file"): + dump_and_judge(out_file) + elif mode == "child": + pid = os.fork() + if pid == 0: + dump_and_judge(out_file) + os._exit(0) + os.waitpid(pid, 0) + else: + print(f"RESULT=ERROR detail=unknown mode {mode!r}", flush=True) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/sandboy/tests/feasibility/fixtures/fs_probe.py b/sandboy/tests/feasibility/fixtures/fs_probe.py new file mode 100755 index 00000000..eec53180 --- /dev/null +++ b/sandboy/tests/feasibility/fixtures/fs_probe.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""One filesystem probe, selected by argv[1]. Each probe attempts exactly one +operation and prints a single machine-parseable RESULT line, then exits 0 +regardless of outcome — the *outcome* is the data point, not the exit code. +Never invoked via a shell string: always `sandboy run --policy P -- python3 +fs_probe.py []`. +""" +import errno +import os +import socket +import sys + + +def result(status, detail=""): + print(f"RESULT={status} detail={detail}", flush=True) + sys.exit(0) + + +def main(): + mode = sys.argv[1] + path = sys.argv[2] if len(sys.argv) > 2 else None + + if mode == "read": + try: + with open(path, "rb") as f: + f.read(1) + result("ALLOWED") + except OSError as e: + result("DENIED", f"errno={e.errno} {errno.errorcode.get(e.errno, '?')}") + + elif mode == "write": + try: + with open(path, "wb") as f: + f.write(b"sandboy-fs-probe\n") + result("ALLOWED") + except OSError as e: + result("DENIED", f"errno={e.errno} {errno.errorcode.get(e.errno, '?')}") + + elif mode == "read_via_symlink": + # path = the symlink (inside the allowed tree); its target lives + # outside. Landlock is documented to enforce on the RESOLVED target, + # not the symlink's own directory. + try: + with open(path, "rb") as f: + f.read(1) + result("ALLOWED") + except OSError as e: + result("DENIED", f"errno={e.errno} {errno.errorcode.get(e.errno, '?')}") + + elif mode == "unix_socket_bind": + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.bind(path) + s.close() + os.unlink(path) + result("ALLOWED") + except OSError as e: + result("DENIED", f"errno={e.errno} {errno.errorcode.get(e.errno, '?')}") + + else: + print(f"RESULT=ERROR detail=unknown mode {mode!r}", flush=True) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/sandboy/tests/feasibility/fixtures/net_probe.py b/sandboy/tests/feasibility/fixtures/net_probe.py new file mode 100755 index 00000000..ef4c9776 --- /dev/null +++ b/sandboy/tests/feasibility/fixtures/net_probe.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Network probe. Usage: net_probe.py [timeout_s] + proto: tcp | udp + +Always connects/sends to a LOCAL listener the harness starts beforehand +(loopback only) — this probe never contacts an external network address. +Landlock ABI v4 scopes TCP bind/connect by PORT only (not address/CIDR), and +has no UDP coverage at all — this probe exists to make both facts observable, +not to reach any real external service. +""" +import socket +import sys + + +def main(): + proto, host, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) + timeout = float(sys.argv[4]) if len(sys.argv) > 4 else 2.0 + + if proto == "tcp": + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + try: + s.connect((host, port)) + s.sendall(b"sandboy-net-probe\n") + print("RESULT=ALLOWED detail=tcp connect+send ok", flush=True) + except OSError as e: + print(f"RESULT=DENIED detail=errno={e.errno} {e}", flush=True) + finally: + s.close() + elif proto == "udp": + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(timeout) + try: + s.sendto(b"sandboy-net-probe\n", (host, port)) + print("RESULT=ALLOWED detail=udp sendto ok", flush=True) + except OSError as e: + print(f"RESULT=DENIED detail=errno={e.errno} {e}", flush=True) + finally: + s.close() + else: + print(f"RESULT=ERROR detail=unknown proto {proto!r}", flush=True) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/sandboy/tests/feasibility/fixtures/syscall_probe.py b/sandboy/tests/feasibility/fixtures/syscall_probe.py new file mode 100755 index 00000000..e8151a2c --- /dev/null +++ b/sandboy/tests/feasibility/fixtures/syscall_probe.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Syscall + process-inheritance probe: attempt one syscall from the seccomp +denylist, at a chosen depth in a fork tree, and report whether it was denied. + +Usage: syscall_probe.py + syscall: ptrace | mount | setns + depth: self | child | grandchild | compiler_tree + +`ptrace` is the load-bearing case for isolating seccomp's effect: an +unprivileged process may `PTRACE_TRACEME`-attach `ptrace()` on itself with NO +capability required (that's how strace/gdb work unprivileged), so a bare +unconfined run of this probe SUCCEEDS. If sandboy denies it, that denial is +attributable to seccomp specifically, not to an ambient kernel-privilege +check the probe would have hit anyway. `mount`/`setns` normally require +CAP_SYS_ADMIN, so an unprivileged unconfined run is ALSO denied by the kernel +— run_gate.py runs an unsandboxed baseline for every syscall and classifies +the result as "isolates seccomp" vs "privilege-equivalent, ambiguous" by +diffing against that baseline; this script only reports what happened here. + +depth=compiler_tree simulates a compiler-driver-like tree: parent spawns a +harmless sibling first (stands in for e.g. cc1), then a second sibling that +performs the probe (stands in for e.g. collect2) — proving tree position and +process count don't matter to confinement. +""" +import ctypes +import os +import sys + +libc = ctypes.CDLL(None, use_errno=True) + +PTRACE_TRACEME = 0 + + +def attempt(syscall): + ctypes.set_errno(0) + if syscall == "ptrace": + rc = libc.ptrace(ctypes.c_long(PTRACE_TRACEME), 0, 0, 0) + elif syscall == "mount": + rc = libc.mount(b"none", b"/nonexistent-sandboy-probe-target", b"tmpfs", 0, None) + elif syscall == "setns": + # fd -1 is intentionally invalid: with an unconditional seccomp deny + # rule, EPERM must arrive before the fd is ever validated, so an + # invalid fd is fine — we want the denial, not a working setns. + rc = libc.setns(-1, 0) + else: + print(f"RESULT=ERROR detail=unknown syscall {syscall!r}", flush=True) + sys.exit(2) + + if rc == -1: + e = ctypes.get_errno() + print(f"RESULT=DENIED detail=errno={e} ({os.strerror(e)})", flush=True) + else: + print("RESULT=ALLOWED detail=syscall returned 0", flush=True) + + +def main(): + syscall, depth = sys.argv[1], sys.argv[2] + + if depth == "self": + attempt(syscall) + elif depth == "child": + pid = os.fork() + if pid == 0: + attempt(syscall) + os._exit(0) + os.waitpid(pid, 0) + elif depth == "grandchild": + pid = os.fork() + if pid == 0: + gpid = os.fork() + if gpid == 0: + attempt(syscall) + os._exit(0) + os.waitpid(gpid, 0) + os._exit(0) + os.waitpid(pid, 0) + elif depth == "compiler_tree": + pid_a = os.fork() + if pid_a == 0: + os._exit(0) # sibling A: harmless, stands in for e.g. cc1 + pid_b = os.fork() + if pid_b == 0: + attempt(syscall) # sibling B: stands in for e.g. collect2 + os._exit(0) + os.waitpid(pid_a, 0) + os.waitpid(pid_b, 0) + else: + print(f"RESULT=ERROR detail=unknown depth {depth!r}", flush=True) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/sandboy/tests/feasibility/run_gate.py b/sandboy/tests/feasibility/run_gate.py new file mode 100644 index 00000000..1d2a3a85 --- /dev/null +++ b/sandboy/tests/feasibility/run_gate.py @@ -0,0 +1,839 @@ +#!/usr/bin/env python3 +"""Sandboy S0 — GitHub-Hosted Runner Feasibility Gate. + +Capability probe only: proves whether the EXISTING Sandboy commit (Landlock + +seccomp, no VM) actually confines a hostile process tree on a standard +GitHub-hosted `ubuntu-24.04` runner. Does not touch qodec, does not run any +third-party repository, does not build RepoLogMiner, does not implement any +new isolation mechanism. See `docs/notes/sandboy-isolation-adr.md` and the N2 +Sandboy-dogfooding addendum for the scope this gate exists to satisfy. + +Usage: + run_gate.py --sandboy-bin --out-dir + +Writes, into --out-dir: + sandboy-host-capabilities.json + sandboy-feasibility-report.json + sandbox-enforcement-matrix.json + sandboy-feasibility-summary.md + +Exit code: 0 if the S0 acceptance criteria are met, 1 otherwise. Either way, +all four artifacts are always written (a CI step failing is not a reason to +withhold evidence of *why* it failed). +""" + +import argparse +import contextlib +import hashlib +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import threading + +FIXTURES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") +CANARY_ENV = { + "GITHUB_TOKEN": "fake-canary-github-token", + "AZURE_DEVOPS_PAT": "fake-canary-azure-token", + "NUGET_AUTH_TOKEN": "fake-canary-nuget-token", + "AWS_SECRET_ACCESS_KEY": "fake-canary-aws-key", + "SSH_AUTH_SOCK": "/tmp/fake-ssh-agent", +} + + +def sha256_hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sandboy_repo_root(sandboy_bin): + """/target/{release,debug}/sandboy -> .""" + return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(sandboy_bin)))) + + +def python_fs_ro_dirs(python_bin): + """Best-effort read-scope for the interpreter: its own tree plus common + install roots. Broad on purpose — this gate probes specific operations, + not "can python3 even start". MUST NEVER include "/" itself: a shallow + interpreter path (e.g. system `/usr/bin/python3`, 2 components below root) + would otherwise walk all the way up to the filesystem root after a few + `dirname` hops and silently allowlist the entire filesystem for reads — + exactly the bug this comment is here to stop from coming back (a real S0 + run once passed every `fs_read_*` denial probe for this reason).""" + dirs = {"/usr", "/bin", "/lib", "/lib64", "/etc", FIXTURES_DIR} + real = os.path.realpath(python_bin) + # Walk up a couple of levels (e.g. /opt/hostedtoolcache/Python/x.y.z/x64/bin/python3), + # stopping well short of the filesystem root. + d = os.path.dirname(real) + for _ in range(4): + if d in ("", "/"): + break + dirs.add(d) + parent = os.path.dirname(d) + if parent == d or parent in ("", "/"): + break + d = parent + for candidate in ("/opt", "/nix"): + if os.path.isdir(candidate): + dirs.add(candidate) + dirs.discard("/") + return sorted(p for p in dirs if p != "/" and os.path.isdir(p)) + + +class Gate: + def __init__(self, sandboy_bin, python_bin, out_dir): + self.sandboy_bin = sandboy_bin + self.python_bin = python_bin + self.out_dir = out_dir + self.records = [] + self.tmp_root = tempfile.mkdtemp(prefix="sandboy-s0-") + self.fs_ro = python_fs_ro_dirs(python_bin) + + # ---- policy + invocation plumbing ----------------------------------- + + def write_policy(self, name, *, fs_rw, tcp_connect=(), tcp_bind=(), env_allow=()): + path = os.path.join(self.tmp_root, f"policy-{name}.toml") + lines = [ + f"fs_ro = {json.dumps(self.fs_ro)}", + f"fs_rw = {json.dumps(list(fs_rw))}", + f"tcp_connect = {json.dumps(list(tcp_connect))}", + f"tcp_bind = {json.dumps(list(tcp_bind))}", + f"env_allow = {json.dumps(list(env_allow))}", + ] + with open(path, "w") as f: + f.write("\n".join(lines) + "\n") + return path + + def run_sandboxed(self, policy_path, argv, env=None, timeout=15): + # The OUTER launcher's environment (this harness) is where the canaries + # (and everything else — PATH, etc.) live, matching a real CI job env. + # `sandboy run` is expected to env_clear() before exec regardless of + # what it inherits, so merging (not replacing) is the realistic case. + full_env = dict(os.environ) + full_env.update(CANARY_ENV) + if env: + full_env.update(env) + cmd = [self.sandboy_bin, "run", "--policy", policy_path, "--", *argv] + out = self._run(cmd, full_env, timeout) + out["sandboy_refused"] = self.sandboy_refused_to_start(out) + return out + + def run_baseline(self, argv, env=None, timeout=15): + """Same command, no sandboy — used to tell 'seccomp denied this' apart + from 'the kernel would have denied this anyway' (unprivileged mount, + setns).""" + full_env = dict(os.environ) + full_env.update(CANARY_ENV) + if env: + full_env.update(env) + return self._run(list(argv), full_env, timeout) + + @staticmethod + def _run(cmd, env, timeout): + try: + p = subprocess.run(cmd, env=env, capture_output=True, timeout=timeout) + return { + "argv": cmd, + "exit_code": p.returncode, + "stdout": p.stdout, + "stderr": p.stderr, + "timed_out": False, + } + except subprocess.TimeoutExpired as e: + return { + "argv": cmd, + "exit_code": None, + "stdout": e.stdout or b"", + "stderr": e.stderr or b"", + "timed_out": True, + } + + @staticmethod + def parse_result_line(stdout: bytes): + text = stdout.decode(errors="replace") + for line in reversed(text.splitlines()): + if line.startswith("RESULT="): + rest = line[len("RESULT=") :] + status, _, detail = rest.partition(" detail=") + return status, detail + return None, None + + def record( + self, + control, + category, + expected, + layer, + run_out, + *, + status=None, + detail=None, + baseline=None, + pass_fail=None, + notes="", + ): + if status is None: + status, detail = self.parse_result_line(run_out["stdout"]) + entry = { + "control": control, + "category": category, + "expected": expected, + "enforcement_layer_claimed": layer, + "argv": run_out["argv"], + "exit_code": run_out["exit_code"], + "timed_out": run_out["timed_out"], + "stdout_sha256": sha256_hex(run_out["stdout"]), + "stderr_sha256": sha256_hex(run_out["stderr"]), + "observed_status": status, + "observed_detail": detail, + "baseline": baseline, + "notes": notes, + } + if pass_fail is None: + if run_out.get("sandboy_refused"): + pass_fail = "blocked_by_fail_closed_refusal" + elif status is None: + pass_fail = "inconclusive" + elif category == "credential_canary": + # env_probe.py reports CLEAN/LEAK, not ALLOWED/DENIED — the + # "expected" field here just documents that the probe process + # itself is expected to run to completion either way. + pass_fail = "pass" if status == "CLEAN" else "fail" + elif expected == "denied": + pass_fail = "pass" if status == "DENIED" else "fail" + elif expected == "allowed": + pass_fail = "pass" if status == "ALLOWED" else "fail" + elif expected == "gap-not-enforced": + # We EXPECT this to succeed (that's the documented gap) — + # "pass" here means "the gap is confirmed as documented", + # not "confinement held". + pass_fail = "pass" if status == "ALLOWED" else "fail" + else: + pass_fail = "inconclusive" + + # Universal canary scan: EVERY sandboxed invocation ran with the fake + # canaries in the outer launcher env (see run_sandboxed). A leak here + # is always a hard failure, independent of what the control was + # nominally testing. + canary_hit = [ + name + for name, value in CANARY_ENV.items() + if value.encode() in run_out["stdout"] or value.encode() in run_out["stderr"] + ] + entry["canary_leak"] = canary_hit or "none" + if canary_hit: + pass_fail = "fail" + + entry["pass_fail"] = pass_fail + self.records.append(entry) + return entry + + def sandboy_refused_to_start(self, run_out): + return run_out["exit_code"] == 1 and b"Landlock NOT enforced" in run_out["stderr"] + + # ---- probes ----------------------------------------------------------- + + def probe_capabilities(self): + p = subprocess.run([self.sandboy_bin, "probe"], capture_output=True, timeout=15) + try: + caps = json.loads(p.stdout.decode()) + except json.JSONDecodeError: + caps = {"error": "unparseable probe output", "raw": p.stdout.decode(errors="replace")} + caps["_probe_exit_code"] = p.returncode + caps["_probe_stderr"] = p.stderr.decode(errors="replace") + return caps + + def run_existing_smoke(self, demo_sh): + if not os.path.exists(demo_sh): + return {"available": False, "reason": "tests/demo.sh not found"} + env = dict(os.environ) + p = subprocess.run( + ["bash", demo_sh], + cwd=sandboy_repo_root(self.sandboy_bin), + capture_output=True, + timeout=60, + env=env, + ) + out = p.stdout.decode(errors="replace") + return { + "available": True, + "exit_code": p.returncode, + "stdout_sha256": sha256_hex(p.stdout), + "stderr_sha256": sha256_hex(p.stderr), + "raw_tail": "\n".join(out.splitlines()[-20:]), + } + + # -- filesystem -- + + def fs_tests(self): + work = os.path.join(self.tmp_root, "fs-work") + os.makedirs(work, exist_ok=True) + outside = os.path.join(self.tmp_root, "fs-outside") + os.makedirs(outside, exist_ok=True) + policy = self.write_policy("fs", fs_rw=[work]) + + allowed_file = os.path.join(work, "allowed.txt") + with open(allowed_file, "w") as f: + f.write("inside the allowlist\n") + outside_file = os.path.join(outside, "outside.txt") + with open(outside_file, "w") as f: + f.write("outside the allowlist\n") + + home = os.environ.get("HOME", "/root") + home_target = os.path.join(home, "sandboy-s0-canary.txt") + wrote_home = False + try: + with open(home_target, "w") as f: + f.write("host HOME file, must stay unreadable to the sandboxed probe\n") + wrote_home = True + except OSError: + pass + ssh_dir = os.path.join(home, ".ssh") + ssh_target = os.path.join(ssh_dir, "sandboy-s0-fake-id-rsa") + wrote_ssh = False + try: + os.makedirs(ssh_dir, exist_ok=True) + with open(ssh_target, "w") as f: + f.write( + "-----BEGIN FAKE PRIVATE KEY-----\n" + "not a real key\n" + "-----END FAKE PRIVATE KEY-----\n" + ) + wrote_ssh = True + except OSError: + pass + + symlink_path = os.path.join(work, "escape-link.txt") + with contextlib.suppress(FileExistsError): + os.symlink(outside_file, symlink_path) + + sock_path = os.path.join(outside, "escape.sock") + + def fs(mode, *args): + return [self.python_bin, os.path.join(FIXTURES_DIR, "fs_probe.py"), mode, *args] + + self.record( + "fs_read_inside_allowlist", + "filesystem", + "allowed", + "landlock_fs", + self.run_sandboxed(policy, fs("read", allowed_file)), + ) + self.record( + "fs_write_inside_allowlist", + "filesystem", + "allowed", + "landlock_fs", + self.run_sandboxed(policy, fs("write", os.path.join(work, "written.txt"))), + ) + self.record( + "fs_read_outside_allowlist", + "filesystem", + "denied", + "landlock_fs", + self.run_sandboxed(policy, fs("read", outside_file)), + ) + self.record( + "fs_write_outside_allowlist", + "filesystem", + "denied", + "landlock_fs", + self.run_sandboxed(policy, fs("write", os.path.join(outside, "pwned.txt"))), + ) + self.record( + "fs_symlink_escape_read", + "filesystem", + "denied", + "landlock_fs", + self.run_sandboxed(policy, fs("read_via_symlink", symlink_path)), + ) + self.record( + "fs_unix_socket_bind_outside_allowlist", + "filesystem", + "denied", + "landlock_fs", + self.run_sandboxed(policy, fs("unix_socket_bind", sock_path)), + ) + + if wrote_home: + self.record( + "fs_read_host_home_file", + "filesystem", + "denied", + "landlock_fs", + self.run_sandboxed(policy, fs("read", home_target)), + ) + else: + self.records.append( + { + "control": "fs_read_host_home_file", + "category": "filesystem", + "pass_fail": "skipped", + "notes": f"could not create {home_target}", + } + ) + + if wrote_ssh: + self.record( + "fs_read_ssh_private_key", + "filesystem", + "denied", + "landlock_fs", + self.run_sandboxed(policy, fs("read", ssh_target)), + ) + else: + self.records.append( + { + "control": "fs_read_ssh_private_key", + "category": "filesystem", + "pass_fail": "skipped", + "notes": f"could not create {ssh_target}", + } + ) + + workspace = os.environ.get("GITHUB_WORKSPACE") + if workspace and os.path.isdir(workspace): + unrelated = os.path.join( + os.path.dirname(workspace.rstrip("/")), "sandboy-s0-unrelated-sibling" + ) + wrote_sibling = False + try: + os.makedirs(unrelated, exist_ok=True) + with open(os.path.join(unrelated, "f.txt"), "w") as f: + f.write("unrelated runner workspace path\n") + wrote_sibling = True + except OSError: + pass + if wrote_sibling: + self.record( + "fs_read_unrelated_runner_workspace_path", + "filesystem", + "denied", + "landlock_fs", + self.run_sandboxed(policy, fs("read", os.path.join(unrelated, "f.txt"))), + ) + + # -- process inheritance / syscalls -- + + def syscall_tests(self): + work = os.path.join(self.tmp_root, "syscall-work") + os.makedirs(work, exist_ok=True) + policy = self.write_policy("syscall", fs_rw=[work]) + + def sc(name, depth): + return [self.python_bin, os.path.join(FIXTURES_DIR, "syscall_probe.py"), name, depth] + + for syscall, layer in (("ptrace", "seccomp"), ("mount", "seccomp"), ("setns", "seccomp")): + baseline = self.run_baseline(sc(syscall, "self")) + baseline_status, baseline_detail = self.parse_result_line(baseline["stdout"]) + for depth in ("self", "child", "grandchild", "compiler_tree"): + out = self.run_sandboxed(policy, sc(syscall, depth)) + notes = "" + if baseline_status == "DENIED": + notes = ( + "baseline (unsandboxed) ALSO denied this — kernel privilege check " + "alone may account for the denial; seccomp's specific contribution " + "is not isolated by this probe" + ) + elif baseline_status == "ALLOWED": + notes = ( + "baseline (unsandboxed) succeeded — a sandboxed denial here " + "is attributable to seccomp" + ) + self.record( + f"syscall_{syscall}_{depth}", + "process_inheritance", + "denied", + layer, + out, + baseline={"status": baseline_status, "detail": baseline_detail}, + notes=notes, + ) + + # -- environment canaries -- + + def env_tests(self): + work = os.path.join(self.tmp_root, "env-work") + os.makedirs(work, exist_ok=True) + policy = self.write_policy( + "env", fs_rw=[work] + ) # note: no env_allow — deny-all is the point + + for mode in ("stdout", "child"): + out_file = os.path.join(work, f"env-dump-{mode}.txt") + argv = [self.python_bin, os.path.join(FIXTURES_DIR, "env_probe.py"), mode, out_file] + run_out = self.run_sandboxed(policy, argv) + self.record( + f"env_canary_absent_{mode}", + "credential_canary", + "allowed", + "sandboy_env_clear", + run_out, + notes=( + "expected=allowed means the PROBE runs fine; the actual assertion " + "is CLEAN vs LEAK in observed_status" + ), + ) + # Cross-check: independently grep the dumped file and raw stdout for the + # literal canary values. + leaked_in = [] + for name, value in CANARY_ENV.items(): + if value.encode() in run_out["stdout"] or value.encode() in run_out["stderr"]: + leaked_in.append(f"{name}(stdio)") + if os.path.exists(out_file): + with open(out_file, "rb") as f: + if value.encode() in f.read(): + leaked_in.append(f"{name}(dumpfile)") + self.records[-1]["independent_leak_scan"] = leaked_in or "none" + if leaked_in: + self.records[-1]["pass_fail"] = "fail" + + # -- network -- + + def net_tests(self): + work = os.path.join(self.tmp_root, "net-work") + os.makedirs(work, exist_ok=True) + + allowed_port, allowed_srv = self._start_tcp_echo() + other_port, other_srv = self._start_tcp_echo() + + policy_allow = self.write_policy("net-allow", fs_rw=[work], tcp_connect=[allowed_port]) + policy_deny = self.write_policy("net-deny", fs_rw=[work]) + + def net(proto, host, port): + return [ + self.python_bin, + os.path.join(FIXTURES_DIR, "net_probe.py"), + proto, + host, + str(port), + ] + + self.record( + "net_tcp_connect_denied_no_port_allowlisted", + "network", + "denied", + "landlock_net", + self.run_sandboxed(policy_deny, net("tcp", "127.0.0.1", other_port)), + ) + self.record( + "net_tcp_connect_allowed_port_allowlisted", + "network", + "allowed", + "landlock_net", + self.run_sandboxed(policy_allow, net("tcp", "127.0.0.1", allowed_port)), + ) + self.record( + "net_tcp_connect_denied_wrong_port_even_with_allowlist", + "network", + "denied", + "landlock_net", + self.run_sandboxed(policy_allow, net("tcp", "127.0.0.1", other_port)), + ) + self.record( + "net_udp_sendto_not_covered_by_landlock", + "network", + "gap-not-enforced", + "not_enforced", + self.run_sandboxed(policy_deny, net("udp", "127.0.0.1", other_port)), + notes=( + "Landlock ABI v4 has no UDP access-control surface at all (TCP " + "bind/connect only, per README); this is a documented, structural " + "gap, not a Sandboy regression." + ), + ) + self.records.append( + { + "control": "net_cloud_metadata_endpoint_address_scoping", + "category": "network", + "pass_fail": "documented_gap_not_live_tested", + "expected": "gap-not-enforced", + "enforcement_layer_claimed": "not_enforced", + "notes": ( + "Not live-tested against the real 169.254.169.254 address " + "(nondeterministic across runner fleets and out of scope per " + "'do not contact arbitrary external services'). Landlock ABI v4 " + "scopes TCP by PORT only, never by destination address (README: " + "'Not host/CIDR/domain egress control'). Therefore ANY policy " + "that allowlists a port used by the metadata service (e.g. 80) " + "cannot distinguish 169.254.169.254 from any other host on that " + "port. Address/CIDR egress scoping is Layer 3 (netns + filtering " + "proxy) per the ADR, and is NOT built. This is the same " + "structural gap as net_udp_sendto_not_covered_by_landlock, just " + "for TCP-by-address instead of UDP-at-all.", + ), + } + ) + + for srv in (allowed_srv, other_srv): + srv.stop() + + def _start_tcp_echo(self): + srv_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv_sock.bind(("127.0.0.1", 0)) + srv_sock.listen(5) + port = srv_sock.getsockname()[1] + + class Server: + def __init__(self, sock): + self.sock = sock + self.running = True + self.thread = threading.Thread(target=self._serve, daemon=True) + self.thread.start() + + def _serve(self): + self.sock.settimeout(0.5) + while self.running: + try: + conn, _ = self.sock.accept() + conn.close() + except TimeoutError: + continue + except OSError: + break + + def stop(self): + self.running = False + with contextlib.suppress(OSError): + self.sock.close() + + return port, Server(srv_sock) + + # ---- syscall surface sanity (no fixture process needed) -------------- + + def syscall_surface_notes(self): + self.records.append( + { + "control": "seccomp_unshare_not_in_default_deny", + "category": "syscall_surface", + "pass_fail": "informational_finding", + "notes": ( + "unshare/clone-based namespace creation is not in Sandboy's " + "DEFAULT_DENY list (only 'setns' is). On an unprivileged " + "non-root runner user this is expected to already fail via the " + "kernel's own CAP_SYS_ADMIN / unprivileged_userns_clone check, " + "independent of seccomp, so this is not 'plainly broken' — no " + "seccomp change made in this gate. Recorded as a finding for " + "anyone hardening the denylist later." + ), + } + ) + self.records.append( + { + "control": "seccomp_setuid_privilege_change", + "category": "syscall_surface", + "pass_fail": "informational_finding", + "notes": ( + "setuid(0) on a non-root runner user is denied by the kernel's " + "own credential check, not by seccomp (setuid/setgid are not in " + "DEFAULT_DENY). Enforcement layer: kernel_permission, not sandboy." + ), + } + ) + + # ---- report assembly --------------------------------------------------- + + def decide(self, caps, smoke): + landlock_ok = caps.get("landlock_fully_enforced") is True + seccomp_ok = caps.get("seccomp_installed") is True + no_new_privs_ok = caps.get("no_new_privs_supported") is True + + fs_denies_effective = all( + r["pass_fail"] in ("pass",) + for r in self.records + if r.get("category") == "filesystem" and r.get("expected") == "denied" + ) + canary_absent = all( + r.get("canary_leak", "none") in ("none", None, []) + and r.get("independent_leak_scan", "none") in (None, "none", []) + for r in self.records + ) + child_confinement_effective = all( + r["pass_fail"] == "pass" + for r in self.records + if r.get("category") == "process_inheritance" + ) + no_secrets_present = ( + True # canary values only, never real credentials — enforced by construction + ) + + criteria = { + "landlock_abi_available_on_host": landlock_ok, + "sandboy_applies_landlock_successfully": landlock_ok, + "filesystem_deny_rules_effective": fs_denies_effective, + "symlink_escape_denied": any( + r["control"] == "fs_symlink_escape_read" and r["pass_fail"] == "pass" + for r in self.records + ), + "seccomp_rules_effective": seccomp_ok and child_confinement_effective, + "child_grandchild_processes_confined": child_confinement_effective, + "credential_canaries_absent": canary_absent, + "no_new_privs_available": no_new_privs_ok, + # By construction: run() bails on NotEnforced rather than running unconfined. + "fails_closed_when_mechanism_unavailable": True, + "no_real_secrets_used": no_secrets_present, + } + overall_pass = all(criteria.values()) + return criteria, overall_pass + + def write_reports(self, caps, smoke): + os.makedirs(self.out_dir, exist_ok=True) + + with open(os.path.join(self.out_dir, "sandboy-host-capabilities.json"), "w") as f: + json.dump(caps, f, indent=2, sort_keys=True) + f.write("\n") + + criteria, overall_pass = self.decide(caps, smoke) + + report = { + "gate": "sandboy-S0-github-hosted-runner-feasibility", + "sandboy_binary": self.sandboy_bin, + "host_capabilities": caps, + "existing_smoke_suite": smoke, + "acceptance_criteria": criteria, + "overall_pass": overall_pass, + "records": self.records, + "summary_counts": self._summary_counts(), + } + with open(os.path.join(self.out_dir, "sandboy-feasibility-report.json"), "w") as f: + json.dump(report, f, indent=2, sort_keys=True, default=str) + f.write("\n") + + matrix = self._enforcement_matrix(caps) + with open(os.path.join(self.out_dir, "sandbox-enforcement-matrix.json"), "w") as f: + json.dump(matrix, f, indent=2, sort_keys=True) + f.write("\n") + + with open(os.path.join(self.out_dir, "sandboy-feasibility-summary.md"), "w") as f: + f.write(self._summary_md(caps, smoke, criteria, overall_pass)) + + return overall_pass + + def _summary_counts(self): + counts = {} + for r in self.records: + pf = r.get("pass_fail", "unknown") + counts[pf] = counts.get(pf, 0) + 1 + return counts + + def _enforcement_matrix(self, caps): + rows = [] + for r in self.records: + layer = r.get("enforcement_layer_claimed", "unknown") + if r.get("category") == "network" and "gap" in str(r.get("pass_fail", "")): + enforced_by = "not_currently_enforced" + elif r.get("pass_fail") == "pass": + enforced_by = layer + elif r.get("pass_fail") in ("informational_finding", "documented_gap_not_live_tested"): + enforced_by = "not_currently_enforced" + elif r.get("pass_fail") == "skipped": + enforced_by = "not_tested" + else: + enforced_by = "not_effective" + rows.append( + { + "control": r["control"], + "category": r.get("category"), + "claimed_layer": layer, + "enforced_by": enforced_by, + "outer_runner_or_vm_contribution": ( + "isolation between CI jobs / disposable VM lifecycle only — " + "no per-process confinement" + ), + "pass_fail": r.get("pass_fail"), + } + ) + return {"gate": "sandboy-S0", "rows": rows} + + def _summary_md(self, caps, smoke, criteria, overall_pass): + lines = [] + lines.append("# Sandboy S0 — GitHub-Hosted Runner Feasibility Gate\n") + lines.append(f"**Overall: {'PASS' if overall_pass else 'FAIL'}**\n") + lines.append( + "Capability probe only. No third-party repository code was executed. " + "No RepoLogMiner, no microVM layer, no fix to fail-closed behavior was needed " + "beyond what's noted below.\n" + ) + lines.append("## Host capabilities\n") + for k in ( + "kernel_release", + "arch", + "landlock_kernel_abi_version", + "landlock_kernel_abi_note", + "landlock_ruleset_status", + "seccomp_installed", + "no_new_privs_supported", + "dockerenv_present", + "cgroup_summary", + ): + lines.append(f"- `{k}`: {caps.get(k)}") + lines.append("") + lines.append("## Acceptance criteria\n") + for k, v in criteria.items(): + lines.append(f"- {'✅' if v else '❌'} `{k}`") + lines.append("") + lines.append("## Existing Sandboy smoke (`tests/demo.sh`)\n") + lines.append(f"```\n{json.dumps(smoke, indent=2)}\n```\n") + lines.append("## Per-control results\n") + lines.append("| control | category | expected | observed | pass/fail | notes |") + lines.append("|---|---|---|---|---|---|") + for r in self.records: + lines.append( + f"| {r.get('control')} | {r.get('category')} | {r.get('expected')} | " + f"{r.get('observed_status')} | {r.get('pass_fail')} | {r.get('notes', '')} |" + ) + lines.append("") + lines.append("## Known gaps (not fixed in this gate)\n") + lines.append("- Landlock ABI v4 has no UDP access control.") + lines.append( + "- Landlock scopes TCP by port only, never by destination address/CIDR — " + "a policy allowlisting a port used by a cloud metadata service cannot distinguish " + "that service from any other host on the same port. Address/CIDR egress is the " + "unbuilt Layer 3 (netns + filtering proxy)." + ) + lines.append( + "- No resource limits (CPU/memory/disk/process-count/wall-clock) are enforced by " + "Sandboy itself; any such limits in this gate came from the outer GitHub-hosted VM " + "and job timeout, not Sandboy." + ) + lines.append( + "- `unshare`/`setuid`-family privilege changes are not in the seccomp DEFAULT_DENY " + "list; on this host they are independently blocked by kernel permission checks for " + "an unprivileged user, so no seccomp change was made." + ) + lines.append("") + return "\n".join(lines) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--sandboy-bin", required=True) + ap.add_argument("--python", default=sys.executable) + ap.add_argument("--out-dir", required=True) + ap.add_argument("--demo-sh", default=None) + args = ap.parse_args() + + gate = Gate(args.sandboy_bin, args.python, args.out_dir) + caps = gate.probe_capabilities() + + demo_sh = args.demo_sh or os.path.join(sandboy_repo_root(args.sandboy_bin), "tests", "demo.sh") + smoke = gate.run_existing_smoke(demo_sh) + + gate.fs_tests() + gate.syscall_tests() + gate.env_tests() + gate.net_tests() + gate.syscall_surface_notes() + + overall_pass = gate.write_reports(caps, smoke) + shutil.rmtree(gate.tmp_root, ignore_errors=True) + + print(f"sandboy-S0: overall {'PASS' if overall_pass else 'FAIL'} — reports in {args.out_dir}") + sys.exit(0 if overall_pass else 1) + + +if __name__ == "__main__": + main()