feat(sandbox): default-on build jail#96
Conversation
New nub-sandbox crate: the OS-enforced sandbox engine whose build-jail
profile is the first consumer (default-on jail for dependency lifecycle
scripts). Implements the unified {env,fs,net,pid} SandboxPolicy schema,
the build-jail preset (env-allowlist scrub + tight fs-write + secret
read-deny set + tight egress allowlist), and per-OS backends: macOS
Seatbelt (fully implemented), Linux Landlock+seccomp (implemented,
CI/Docker-tested), Windows scaffolded with TODO.
Enforcement is deny-by-default on env/fs-write/net and fail-SAFE: a
missing OS primitive degrades the affected axis with a one-line warning,
never silently dropping a claimed guarantee, never hard-failing install.
Real e2e enforcement tests (macOS) prove containment: a malicious-shaped
script cannot read a seeded ~/.ssh secret or a project .env, cannot write
outside the package dir, and cannot egress — while a legit native-style
build (read project, write package dir) still succeeds.
Grounded in .fray/build-jail-design.md (§3/§4/§5/§8.5) and the
.fray/sandbox.md engine-architecture decision.
Claude-Session: https://claude.ai/code/session_01YRvztkcr4fzfg9rD5edUwa
…er, Linux honesty) Fixes from three adversarial Opus reviews of the first cut: - CRITICAL (macOS): the sandbox-exec wrap replayed only the scrubbed allowlist onto a fresh Command that still inherited the parent's full env, re-leaking every secret the scrub removed. Now env_clear() the wrapped command when the env axis is enforced so the allowlist is the whole child env. Regression test added (parent_env_secret_does_not_leak_into_jailed_script). - CRITICAL latent (macOS): the proxy carve-out emitted a `127.0.0.1` SBPL rule sandbox-exec rejects (parse error fails the whole profile). Use `localhost` only (covers both loopback families). - HIGH (net): host matcher now normalizes trailing-dot FQDNs (deny-bypass) and fails closed on non-leading `*` wildcards; dropped the broken `*.s3.*.amazonaws.com` (matched nothing) and replaced `*.githubusercontent.com` with the exact `objects.githubusercontent.com` (the wildcard admitted attacker-controlled raw.githubusercontent.com). - HIGH (env): added anchored deny-token matching (pat/pwd/seed/pem/cert/jwt/dsn) + more deny-substrings (mnemonic/cookie/private/...) so short secret markers are caught without false-positiving (compatible/cwd). Added bare native-build toolchain vars (CC/CXX/CFLAGS/MAKEFLAGS/PYTHON/...) to the allowlist so custom-toolchain compiles keep their settings. - HIGH/Linux honesty: Landlock is allow-only so the generous-read build-jail cannot deny secret subpaths the way macOS does — now surfaced as a `fs-read-deny` Degradation (reduced-mode warning fires) instead of falsely reporting full enforcement. Matched aube's full seccomp denied-family list. - breakage: pre-create confined write roots before applying the OS jail so a cold-cache ~/.cache/node-gyp write isn't silently denied (Landlock can't grant a missing path; macOS canonicalize fails on it). Regression test added. - Added a Linux e2e enforcement test (write-confine + net-deny, Landlock probe). Claude-Session: https://claude.ai/code/session_01YRvztkcr4fzfg9rD5edUwa
Linux e2e under Docker surfaced two real bugs: - seccomp apply_filter EINVAL'd the spawn when Landlock was skipped, because PR_SET_NO_NEW_PRIVS was only set inside apply_landlock. Set NNP unconditionally in the pre_exec before either layer. - the Landlock availability probe used Ruleset::create(), which succeeds on a no-Landlock kernel (degraded dummy) — a false positive that then EINVAL'd restrict_self in the child. Replaced with a direct, allocation-free, async-signal-safe landlock_create_ruleset(NULL,0,VERSION) ABI query (no fork, safe under nub's tokio runtime), so a Landlock-less kernel degrades cleanly. Also drop the now-unused `Access` import (the from_* assoc fns resolve without it). Verified on Linux under Docker: cargo clippy --all-targets -D warnings clean + the 3 e2e enforcement tests (write-confine + net-deny) green. Claude-Session: https://claude.ai/code/session_01YRvztkcr4fzfg9rD5edUwa
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
ℹ️ No critical issues — a few polish items inline. The engine is well-scoped, honestly phased, and the e2e tests prove real enforcement; nothing consumes it yet, so blast radius is contained.
Reviewed changes — the first cut of the nub-sandbox engine crate and its default-ON build-jail profile (engine only; not yet wired into the install lifecycle).
- New
crates/nub-sandboxcrate — unifiedSandboxPolicy { env, fs, net, pid }schema (policy.rs) driving both the build-jail and a future runtime profile. - Build-jail preset (
build_jail.rs) — env allowlist + secret-deny substrings/anchored-tokens, generous-read + secret-deny fs with tight allow-only write, tight egress allowlist, pid caps. - Deny-set DATA —
secrets.rs(ssh/aws/wallet/browser-profile read-deny,.env*glob, persistence write-deny) andnet_defaults.rs(egress allowlist deliberately excluding thegithub.comapex /*.github.io). - Per-OS backends — macOS Seatbelt/SBPL via
sandbox-exec(fully implemented), Linux Landlock v2 + seccomp with capability-probe + graceful-degrade (linux.rs), Windows/other env-scrub-only stub that reports the gap (stub.rs), all under the fail-safe-with-Degradationcontract (mod.rs). - Engine entry (
lib.rs) —apply_env_scrubspawn-boundary filter +apply_to_command(pre-creates write roots, applies the OS backend). - e2e tests — macOS + Linux spawn real
sandbox-exec/Landlock'd processes asserting a malicious-shaped script is contained while a legit native build still succeeds.
ℹ️ write_deny_relglobs() is defined but never enforced
secrets.rs exports a persistence-write deny-set (.bashrc, .github/workflows/**, .claude/**, .cursor/**, .git/config) but no backend consumes it. For the build-jail this is harmless — the write_allow set is tight enough that none of these sinks are writable anyway — but as shipped the function is unreferenced, and its doc comment reads as an active layer.
The honest scoping (it's for a future runtime profile whose writable roots overlap a project tree) is fine; the risk is that a future reader assumes the persistence sinks are already denied. A one-line tracking note or an issue link would prevent that.
Technical details
# `write_deny_relglobs()` is dead code as shipped
## Affected sites
- `crates/nub-sandbox/src/secrets.rs:88` — `pub fn write_deny_relglobs()` is exported but has no consumer in any backend (`macos.rs` / `linux.rs` / `stub.rs` never reference it).
## Required outcome
- Make it unambiguous that the persistence-write-deny is NOT yet enforced — either via a tracked follow-on note next to the function or by deferring the function until the runtime profile lands.
## Open questions for the human
- Is the runtime-profile consumer close enough that landing the data now is the right call, or should it land with its enforcement?Claude Opus | 𝕏
…eps) Pullfrog review of #96 (no critical issues) — 3 polish items: - Document secrets::write_deny_relglobs() as NOT YET ENFORCED: it's forward data for the runtime profile (writable roots overlapping a project tree); the build-jail's tight write-set already makes those persistence sinks unwritable, so no backend consumes the list yet. A future reader must not assume .bashrc / .github/workflows / .claude / .git/config are currently denied. - Drop unused deps (supply-chain hygiene): glob-match (the macOS .env* deny is SBPL regex, never globbed) and the macOS-target libc (macOS is pure SBPL via sandbox-exec; only the Linux backend calls libc). Linux-target libc stays. - Document the macOS read_deny_glob .env-only limitation: the glob axis is consumed as a boolean then a hardcoded .env regex is emitted, so a new glob would silently not be enforced on macOS until per-pattern glob->SBPL translation lands (tracked). The secret-DIR denies are fully enforced. Verified macOS (24 tests + clippy --all-targets -D warnings + fmt clean) and Linux under Docker (clippy --all-targets -D warnings clean). Claude-Session: https://claude.ai/code/session_01YRvztkcr4fzfg9rD5edUwa
Maintainer terminology decision: the feature is the "script sandbox" — a
shared umbrella spanning BOTH (a) install/lifecycle-script sandboxing (the
profile shipping now) and (b) future `nub run` script-execution sandboxing.
One engine, one {env,fs,net,pid} policy model, two profiles differing only in
default scope.
- build_jail module/preset/identifiers -> script_sandbox; BuildJailParams ->
ScriptSandboxParams; build_jail.rs -> script_sandbox.rs; run_jailed ->
run_sandboxed; jail_home -> sandbox_home; etc. Crate name nub-sandbox
(already the umbrella) unchanged.
- lib.rs + script_sandbox.rs headers reframed: "script sandbox" is the
umbrella; the module shipping now is its install/build-script profile, named
so the term reads naturally across both profiles (not install-only).
- Docs: site/content/docs/install (the "Build jail" section) + the blog mention
-> "script sandbox".
Mechanical/non-functional. Verified: clippy --all-targets --all-features
-D warnings clean, fmt --check clean, 24 macOS tests pass.
Claude-Session: https://claude.ai/code/session_01YRvztkcr4fzfg9rD5edUwa
Harden the script-sandbox test suite for the default-on security-critical enforcement paths. Test-only; no production behavior change. Each test was verified non-hollow by temporarily disabling the relevant enforcement and confirming it goes red, then restoring. - Linux egress: replace the /dev/tcp shell trick (dash/busybox has no /dev/tcp, so it passed even with seccomp removed) with a compiled C socket(AF_INET)+ connect client asserting SOCKET_EPERM, distinguished from ECONNREFUSED/CONNFAIL (the wrong reason), plus a live loopback-listener negative control proving the baseline connects unsandboxed. Verified under Docker: passes with seccomp on, red with seccomp disabled. - macOS egress: add a loopback-listener negative control (accept loop stays live through both dials) proving the baseline connects unsandboxed before asserting the sandboxed dial is blocked, so the test cannot pass vacuously. - macOS env-scrub: prove both halves through the sandbox-exec re-wrap. Deny half now also seeds a real-process-env secret to guard the wrap's env_clear regression site directly; allow half asserts an allowlisted npm_config_registry reaches the child and PATH is non-empty (an over-nuking scrub would pass a deny-only assertion while breaking builds). - Degradation: unit-test the fail-safe-not-silent contract (warning() is None when fully enforced, names every lost axis + reason when degraded, warns even without a reason) and a Windows-stub honesty test asserting it reports fs+net lost and never claims full enforcement. Refs #96 Claude-Session: https://claude.ai/code/session_01YRvztkcr4fzfg9rD5edUwa
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the single new commit since the prior pullfrog review (12b653c) is eda7a1e, a test-only hardening that closes four hollow-test gaps in nub-sandbox. No engine or executable-logic change.
Degradationfail-safe unit tests (backend/mod.rs) — assertwarning()isNoneunder full enforcement andSome(naming every lost axis plus the reason) when degraded, and that a lost axis still warns even whenreasonisNone. These are the negative control against a future regression that silently no-ops the reduced-mode banner.- Stub honesty test (
backend/stub.rs) — a#[cfg(not(any(linux, macos)))]test that the Windows/other-OS stub reportsfs+netlost and never reads asis_full(), guarding the "reports the gap rather than claiming enforcement" contract on the Windows CI leg. - Real
socket()egress test on Linux (tests/e2e_linux.rs) — replaces the/dev/tcpshell trick (which passed even with seccomp removed, sincedash/busyboxlacks/dev/tcp) with acc-compiled C client that genuinely issuessocket(AF_INET), plus a live loopback listener as negative control; assertsSOCKET_EPERMspecifically so a routing/ECONNREFUSEDfailure can't masquerade as a seccomp deny. - Two-sided macOS env-leak + loopback negative control (
tests/e2e_macos.rs) — the net test gains a baseline-must-CONNECTproof against a live loopback listener; the env-leak test now asserts both the deny half (seeded secrets and a real process-env secret absent) and the allow half (npm_config_registrypresent,PATHnon-empty), catching an over-nuking scrub that would pass a deny-only assertion while silently breaking builds.
All referenced APIs match the crate surface, the negative controls make each previously-hollow assertion real, and the prior pullfrog feedback remains addressed. The unsafe set_var in the macOS env test is acknowledged with a SAFETY note and its blast radius is nil — the variable name is unique and read only by its own test.
Claude Opus | 𝕏

What this is
The first cut of the
nub-sandboxengine crate — step 1 of the sandbox build sequence (.fray/sandbox.mdengine architecture): the OS-enforced script sandbox."Script sandbox" is the umbrella — it confines the SCRIPTS nub runs on the user's behalf, and spans two profiles of one engine: (a) dependency lifecycle / install scripts (the profile shipping in this PR), and (b)
nub runscript execution (the planned runtime profile). Same{env, fs, net, pid}policy model, same per-OS backends; the profiles differ only in default scope. The crate namenub-sandboxis the umbrella; this PR ships its install/build-script profile.That install-script profile is the drop-in successor to aube's bundled install-script sandbox, at or above parity. It is a default-ON sandbox over dependency lifecycle scripts (install/postinstall/preinstall/prepare): block the supply-chain attack canon (secret exfil, C2/beacon egress, out-of-package writes, persistence) while letting legitimate native builds (node-gyp / prebuild-install) through. Design + evidence base:
.fray/script-sandbox-design.md(§3 net, §4 fs-read, §5 fs-write, §8.5 attack→capability→default table).What's in this PR
crates/nub-sandboxworkspace crate. The unifiedSandboxPolicy {env, fs, net, pid}schema (one model, both profiles), the install/build-script profile preset (script_sandbox) assembling the §3/§4/§5/§8.5 defaults, the secret deny-sets (classic creds + wallet/keystore + browser profiles + IDE/agent/persistence write-deny + recursive.env*), and the egress allowlist as DATA.Verification
--all-targets -D warnings+ fmt clean.--all-targets -D warningsclean.~/.sshsecret or a project.env, cannot write outside the package dir / into project source / into HOME, cannot egress — while a legit native-style build (read project, write package dir, write cold-cache~/.cache/node-gyp) still succeeds. Includes a regression test that a parent-process secret env var does NOT leak into the sandboxed script.Adversarially reviewed
Three independent Opus reviewers (enforcement soundness, per-OS backend correctness, breakage axis) + a Pullfrog review. Every critical/high finding fixed in this PR, including two the e2e tests then locked in:
sandbox-execwrap was re-leaking the parent's full env past the scrub (fixed:env_clearthe wrapped command + regression test).*.s3.*wildcard + a*.githubusercontent.comentry that admitted attacker-controlledraw.githubusercontent.com(fixed: exact hosts, fail-closed multi-*, trailing-dot normalization).fs-read-denydegradation; recursive read-carve is the tracked follow-on).write_deny_relglobsas not-yet-enforced (runtime-profile forward data), dropped unused deps (glob-match, macOS-targetlibc), documented the macOS.env-only glob limitation.First-cut vs REMAINING (honest phasing)
This is the engine; it is intentionally not yet wired into the install lifecycle, and the default-ON flip is not in this PR:
vendor/aube/.../lifecycle.rsat this engine via the embedder hook (the.fray/sandbox.mdde-risk gate). Next PR.nub runruntime profile (the sibling profile of the umbrella), Linux recursive secret-read carve, full(deny default)macOS profile, Windows restricted-token write-jail, per-pattern glob→SBPL translation,setrlimitfrom the pid policy, the breakage audit against real native deps.Engine-only; safe to land independently (nothing consumes it yet).
https://claude.ai/code/session_01YRvztkcr4fzfg9rD5edUwa