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
30 changes: 28 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -403,9 +403,31 @@ jobs:
# rejected before it can be killed. Same reasoning: the edit never
# becomes a tree, so it never had a chance to survive.
#
# The fork-bomb guard mutants are excluded because running them is the
# exact incident the guard exists to prevent. `delete ! in
# spawn_child`, `delete ! in may_spawn_child_given`, and `replace
# may_spawn_child[_given] -> bool with true` each let `spawn_child`
# reach `Command::new(current_exe())` from inside the test harness,
# where `current_exe` is libtest: the child re-runs the suite, every
# extract test spawns the next generation, and the runner is lost
# (2026-10-09, 529 processes, forced reboot). They cannot be killed
# honestly, because killing requires the suite to finish, and the
# suite cannot finish once the guard is gone. The guard is pinned the
# only way that is safe under a mutant: the fast assertion
# `the_test_harness_is_never_allowed_to_spawn_an_extraction_child`
# fails the moment `may_spawn_child` returns true, before any spawn,
# so a real regression to the decision still turns the shard red.
# What is given up is only the ability to execute the detonation.
#
# `may_spawn_child_given` holds two `!` (`!cfg!(test)` and
# `!marker_set`) that share one mutant name, so the exclusion covers
# both. Losing the `!marker_set` edit is acceptable: that branch is
# pinned by `tests/sandbox_spawns.rs`, which drives the true case from
# outside the harness where a fork cannot reach it.
#
# Every other genre still runs, and a mutant that fails to compile for
# any other reason is still reported rather than hidden.
BLUELINE_MUTANTS_EXCLUDE: 'with .*Default::default\(\)|replace && with \|\| in .*'
BLUELINE_MUTANTS_EXCLUDE: 'with .*Default::default\(\)|replace && with \|\| in .*|delete ! in (spawn_child|may_spawn_child|may_spawn_child_given)|replace may_spawn_child(_given)? -> bool with true'
run: |
cargo mutants --in-diff pr.diff --shard ${{ matrix.shard }}/32 \
--file 'src/**/*.rs' \
Expand Down Expand Up @@ -479,7 +501,11 @@ jobs:
# `replace <return type> with Default::default()` needs a `Default` impl
# several security types deliberately lack, and `||` is not valid inside a
# `let` chain, so neither edit ever becomes a tree that could be killed.
BLUELINE_MUTANTS_EXCLUDE: 'with .*Default::default\(\)|replace && with \|\| in .*'
# The fork-bomb guard mutants are excluded for the same reason as in
# mutants-pr: executing them re-execs the test harness and detonates the
# runner, so they can only be pinned by the fast `may_spawn_child`
# assertion, never by running the mutant to completion.
BLUELINE_MUTANTS_EXCLUDE: 'with .*Default::default\(\)|replace && with \|\| in .*|delete ! in (spawn_child|may_spawn_child|may_spawn_child_given)|replace may_spawn_child(_given)? -> bool with true'
steps:
- uses: actions/checkout@v4
with:
Expand Down
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,31 @@ in that file. Neither is installed by default, so `--mutants` and `--all` exit
- Skipping the CI gate. If CI breaks, fix it in the same branch.
- Shipping a fix you could not verify. A patch with no test that fails without
it is a guess. Re-introduce the bug, watch the test catch it, then restore.
- Executing a process from a test. See "No self-exec from a test" below. This
is the one rule in this file that can cost the user a reboot.

## No self-exec from a test

A test that runs `current_exe()` re-runs the test suite. `spawn_child` in
`src/sandbox.rs` once did exactly that: under `cargo test`, `current_exe()` is
the libtest harness, so each spawned child ran the whole suite and every
extract test spawned the next generation. One `cargo test --lib review::` run
produced 529 live processes, a load average of 733, exhausted RAM, and a
machine that had to be rebooted (2026-10-09).

- Never call `Command::new(current_exe())`, `spawn`, or `exec` from code that
`#[cfg(test)]` can reach. Guard it with `cfg!(test)` and an environment
marker, as `may_spawn_child` does, and take an in-process fallback instead.
- A test that must observe real process behaviour drives a **built binary**
through `assert_cmd::Command::cargo_bin`, never the harness.
- Never run a command that spawns processes without a bound. Add `timeout`,
run it in the background, and poll the process count. Stop if the count
grows: kill the tree and report it, do not let it finish and do not "wait it
out".
- The guard in `src/sandbox.rs` is pinned by
`the_test_harness_is_never_allowed_to_spawn_an_extraction_child` and
`extraction_in_a_test_never_re_execs_the_harness`. Do not weaken it, and do
not delete it to simplify `spawn_child`.

## Conventions
- The engine is Rust. `src/` is the security-critical path and is written
Expand Down
29 changes: 18 additions & 11 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ every release as a proof sheet and demands sign-off before the byte runs.
**Hard rule: nothing executes until judged.** Tarballs are fetched, **integrity-verified
against the registry's own per-lane checksum (fail closed)**, then extracted read-only into a
private temp dir
under hard size and entry caps — never executed, diffed, and scored. The temp dir
is not an OS-level sandbox; see §1. The package's own code is
under hard size and entry caps — never executed, diffed, and scored. On Linux
the extraction step itself runs inside a Landlock-confined child; see §1. The
package's own code is
never run: even on approve, install proceeds with `npm install --ignore-scripts`,
and any `postinstall`/`preinstall` script is surfaced for a *separate* human decision.

Expand Down Expand Up @@ -52,6 +53,7 @@ change should land — see the Feature Map at
│ baseline::store ── SQLite: installed/known-clean versions, │
│ approval overrides, policy │
│ extract ── verify hash → bounded temp-dir extract │
│ sandbox ── Landlock child around extraction (Linux) │
│ diff ── file-level + line-level (similar crate) │
│ install_ref ── scan payload for referenced installs │
│ recursive ── re-review referenced installs: depth caps,│
Expand Down Expand Up @@ -122,14 +124,19 @@ Every tarball and registry response is fully untrusted. The `extract` stage enfo
would otherwise overwrite the first); setuid/setgid bits are stripped. Pin
The manifest allows any `0.4.x`; `Cargo.lock` pins the exact version and CI
builds with `--locked`, so an unlocked local build can resolve a different one.
- **Sandbox the step (planned, not implemented):** the intent is to run extract
+ diff in a Landlock-restricted child (read-only host FS, write only to the
sandbox temp dir), capability-dropped, optionally seccomp-filtered, non-root,
with macOS/Windows falling back to the parser-level bounds above plus a
dedicated non-writable temp dir. No Landlock, seccomp, `cap-std` or
capability-drop code exists in `src/` today, and `Cargo.lock` carries none of
those crates. What actually bounds a hostile archive is the parser-level
budget above; there is no OS-level confinement behind it.
- **Sandbox the step:** extraction runs inside a Landlock-restricted child
(`src/sandbox.rs`). The child applies a ruleset granting write access only to
the private temp dir, on the highest ABI tier the kernel supports (V5, then
V3 for `Truncate`, then V1), under `CompatLevel::HardRequirement` so a
partly applied domain is refused instead of extracted from. macOS, Windows,
and a kernel without Landlock get the parser-level bounds above plus a
`P05_SANDBOX_UNAVAILABLE` disclosure — or a refusal, when
`[policy] require_sandbox = true`. seccomp and `cap-std` are still absent;
Landlock is the confinement layer that ships. It covers the extraction step
only: AUR review bytes are assembled by `git clone` and `git archive` in the
review process, which no Landlock domain can reach (no network control, and a
clone writes its own tree), so that lane refuses rather than discloses under
the same key.
- **Treat extracted bytes as hostile:** the extracted `package.json` (`scripts`,
`dependencies`) is diffed/flagged as attack surface, not trusted.

Expand All @@ -142,7 +149,7 @@ Every tarball and registry response is fully untrusted. The `extract` stage enfo
| D1 | Rust core + Node shim | Security-critical path in a memory-safe, single-binary language; Node only for `npx` ergonomics. |
| D2 | Local deterministic heuristic first | Transparent, auditable, offline. Hosted ML *refines* score when token present — never required. Keeps "the wedge stays open" honest. |
| D3 | One registry deep, `Registry` trait seam, then four | Deepen one registry first; avoid speculative multi-registry code. npm, crates.io, PyPI, and review-only AUR now share the seam. |
| D4 | Read-only extraction into a temp dir + integrity verify | Core safety invariant. Verify the registry's sha512/sha256 *before* extract; bound size/entry; reject symlinks/special files; Landlock-sandbox the step (planned, not implemented — §1). Package code never runs. |
| D4 | Read-only extraction into a temp dir + integrity verify | Core safety invariant. Verify the registry's sha512/sha256 *before* extract; bound size/entry; reject symlinks/special files; run the step inside a Landlock-confined child on Linux (§1). Package code never runs. |
| D5 | Baseline = last known-clean version | Source: locally installed version in `node_modules` → else previous version in registry list (neutral verdict on first sighting). Overrides persisted in SQLite. |
| D6 | Revocation = OSV + GitHub Advisory cache | Reuse the open vulnerability corpus; paid tier adds human-verified recall (hosted index). |
| D7 | Stable `Verdict` JSON schema | Same struct feeds CLI card, CI comment, and MCP tool. One source of truth.|
Expand Down
Loading
Loading