Skip to content

feat(sandbox): default-on build jail#96

Closed
colinhacks wants to merge 8 commits into
mainfrom
build-jail-engine
Closed

feat(sandbox): default-on build jail#96
colinhacks wants to merge 8 commits into
mainfrom
build-jail-engine

Conversation

@colinhacks

@colinhacks colinhacks commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

What this is

The first cut of the nub-sandbox engine crate — step 1 of the sandbox build sequence (.fray/sandbox.md engine 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 run script 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 name nub-sandbox is 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

  • New crates/nub-sandbox workspace crate. The unified SandboxPolicy {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.
  • Backends: macOS Seatbelt (fully implemented), Linux Landlock+seccomp (implemented; capability-probe + graceful-degrade, never bwrap), Windows (honestly scaffolded — env-scrub only, reports the gap).
  • Enforcement posture: deny-by-default on env/fs-write/net; fail-SAFE — a missing OS primitive degrades the affected axis with a one-line warning, never silently drops a claimed guarantee, never hard-fails an install.

Verification

  • 24 macOS tests green (17 unit + 7 e2e), clippy --all-targets -D warnings + fmt clean.
  • Linux verified under Docker: 16 unit + 3 e2e enforcement tests green; clippy --all-targets -D warnings clean.
  • Real e2e enforcement (not just policy-struct asserts): a malicious-shaped script cannot read a seeded ~/.ssh secret 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:

  • macOS sandbox-exec wrap was re-leaking the parent's full env past the scrub (fixed: env_clear the wrapped command + regression test).
  • a broken *.s3.* wildcard + a *.githubusercontent.com entry that admitted attacker-controlled raw.githubusercontent.com (fixed: exact hosts, fail-closed multi-*, trailing-dot normalization).
  • Linux falsely reported full fs enforcement while Landlock (allow-only) can't deny secret subpaths (fixed: surfaced as an honest fs-read-deny degradation; recursive read-carve is the tracked follow-on).
  • Linux seccomp EINVAL'd the spawn when Landlock was absent (fixed: NNP-first + a safe syscall-based Landlock probe).
  • Polish: documented write_deny_relglobs as not-yet-enforced (runtime-profile forward data), dropped unused deps (glob-match, macOS-target libc), 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:

  1. Egress proxy not wired — macOS/Linux currently deny ALL direct egress (fail-safe coarse). Per-host allowlisting needs the §3 localhost HTTP+SOCKS5 proxy. Consequence both breakage and enforcement reviewers flagged: default-ON with net-full-deny would break uncached native fetches (node-gyp headers, prebuild-install binaries). So the embedder-wiring phase decides the net default (proxy, cache-prewarm, or net-permissive-but-fs/env-enforced) — next PR + a maintainer-owned default call.
  2. aube embed-boundary spike — re-pointing vendor/aube/.../lifecycle.rs at this engine via the embedder hook (the .fray/sandbox.md de-risk gate). Next PR.
  3. The nub run runtime 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, setrlimit from 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

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
Copilot AI review requested due to automatic review settings June 23, 2026 22:05
@vercel

vercel Bot commented Jun 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview, Comment Jun 23, 2026 10:56pm

Request Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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-sandbox crate — unified SandboxPolicy { 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 DATAsecrets.rs (ssh/aws/wallet/browser-profile read-deny, .env* glob, persistence write-deny) and net_defaults.rs (egress allowlist deliberately excluding the github.com apex / *.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-Degradation contract (mod.rs).
  • Engine entry (lib.rs) — apply_env_scrub spawn-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?

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread crates/nub-sandbox/Cargo.toml Outdated
Comment thread crates/nub-sandbox/src/backend/macos.rs
…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
@colinhacks colinhacks changed the title feat(sandbox): nub-sandbox build-jail engine (default-on, first cut) feat(sandbox): nub-sandbox script-sandbox engine — install-script profile (default-on, first cut) Jun 23, 2026
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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

  • Degradation fail-safe unit tests (backend/mod.rs) — assert warning() is None under full enforcement and Some (naming every lost axis plus the reason) when degraded, and that a lost axis still warns even when reason is None. 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 reports fs+net lost and never reads as is_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/tcp shell trick (which passed even with seccomp removed, since dash/busybox lacks /dev/tcp) with a cc-compiled C client that genuinely issues socket(AF_INET), plus a live loopback listener as negative control; asserts SOCKET_EPERM specifically so a routing/ECONNREFUSED failure 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-CONNECT proof 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_registry present, PATH non-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.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@colinhacks colinhacks changed the title feat(sandbox): nub-sandbox script-sandbox engine — install-script profile (default-on, first cut) feat(sandbox): default-on build jail Jun 28, 2026
@colinhacks colinhacks closed this Jun 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants