fix(isolation): close the two P1s in scenario-verify's isolation evidence boundary - #274
Closed
tnunamak wants to merge 16 commits into
Closed
fix(isolation): close the two P1s in scenario-verify's isolation evidence boundary#274tnunamak wants to merge 16 commits into
tnunamak wants to merge 16 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
tnunamak
force-pushed
the
waspflow/isolation-r10-p1-0902
branch
from
September 2, 2026 22:24
ced8300 to
274714e
Compare
Contributor
Author
|
Fixed the writable-file submount probe truncation hazard.
Validation: isolation mechanism suite (40 pass, 40 capability-gated skips), scenario-verify strict suite (89 pass), and package static verification passed. The complete package suite ran 5,323 pass / 50 skipped with two unrelated timing failures recorded separately. |
tnunamak
force-pushed
the
waspflow/isolation-r10-p1-0902
branch
from
September 3, 2026 22:18
4bf2997 to
2a134e1
Compare
…ence-boundary repair External review of the merged tree (ab415be) found the evidence boundary scenario-verify rests recorded_replay on was itself unproven: the unshare/ bwrap launcher binaries are resolved through inherited PATH (a fake launcher can be selected), and the unshare mechanism's --rbind submounts only get their top mount remounted read-only, leaving nested mounts under a ro bind writable. Either gap lets recorded_replay: PASS print without the OS-level isolation that claim asserts. Add isolationEvidenceBoundaryProven to evaluateClaimEligibility's inputs, gating recorded_replay on top of namespace-activity alone, and wire it to a hardcoded false in scenario-verify.ts so every intermediate state of this repair stays honest (diagnostic_replay only) until the trusted-launcher and recursive-read-only fixes land and this literal is flipped to a real proof. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…te path External review of ab415be found the unshare/bwrap launcher binaries were resolved through the caller's inherited $PATH during both the capability probe and real execution, so a PATH-prepended fake launcher was selected over the real one — the project's own PATH-shadowing test infrastructure demonstrated exactly this. Add resolveTrustedLauncherPath, which walks a fixed allowlist of trusted system directories (/usr/sbin, /usr/bin, /sbin, /bin — the same set TRUSTED_SETUP_PATH already uses for the isolated child's own setup commands), never the inherited PATH, and use the one resolved path for both probeUnshare/probeBwrap and the real spawnWithNetworkIsolation calls. Fails closed (throws, never falls back to a bare name) when a trusted directory doesn't have the binary. Existing tests that PATH-shadowed unshare/bwrap to inject fakes no longer reach anything once this lands (proving the fix), so they're rewritten to bind-mount their shims directly over the real trusted-path binaries instead. Added a new end-to-end test proving a PATH-prepended fake `unshare` is never selected by either the probe or a real isolated spawn. Full isolation-mechanism suite verified under both mechanisms in a privileged container (bwrap native, unshare via container): 52 pass, 0 fail, 10 skipped (pre-existing $HOME/.ssh/agent and /run/user/<uid> fixture preconditions, unrelated to this fix). Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…st the top External review of ab415be found the unshare mechanism binds submounts recursively via --rbind but only remounts the TOP mount read-only via classic remount,ro,bind — Linux does not apply that operation recursively, so a nested mount that existed under a ro bind's source directory at spawn time (e.g. Docker's own /etc/resolv.conf-style injected submounts, or any other nested mount under REPO_ROOT) stayed writable inside the isolated child even though the parent directory correctly reported read-only. Reproduced live: an isolated child could append to /etc/resolv.conf and /etc/hostname despite /etc itself being genuinely ro. Add recursiveReadOnlyRemountCommand, which walks /proc/self/mountinfo after each ro bind's top-level remount and individually remounts every descendant mount point found under it, wrapped in the existing req fail-closed helper so a submount that refuses to go read-only halts the whole prelude. Extend postPivotVerificationStatements the same way — the post-pivot verification now probes every submount of every ro bind, not just the parent, so a future regression is caught at verification time too. Fixing this exposed a related, pre-existing bug in dedupeBinds: it kept whichever entry was declared FIRST in requiredFilesystemBinds()'s array even when a later, broader entry (e.g. /usr) would have covered an earlier, narrower one (e.g. the Node binary's own directory, /usr/local/bin in a container's default install) — harmless under the old top-level-only remount, but it left a redundant nested nested nested mount that the new recursive-remount walk then tried to remount twice. dedupeBinds now sorts by path depth before deduping, so the broadest ancestor always wins regardless of declaration order. Verified live in a privileged container: an isolated unshare child can no longer write into Docker's real /etc/resolv.conf/hostname submounts. New regression tests create a real nested bind mount under REPO_ROOT and prove EACCES/EROFS on write, both for the real spawn (mutation-tested: the [unshare] variant genuinely fails without the fix, bwrap's own mechanism already closed this case) and for postPivotVerificationStatements directly (mutation-tested: exit 0 without the submount probe, exit 91 with it). Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
… pre-spawn scan External review of ab415be flagged the known repository-UDS exception: a ro bind (REPO_ROOT included) blocks writes, not reads/dials, so a Unix domain socket that already existed under a ro bind at spawn time stays dialable from inside an isolated child. Confirmed live: curl reached a real REPO_ROOT-internal socket even with the trusted-launcher and recursive-ro fixes from the two prior commits applied. Recursive read-only closes the other half of this: once every submount of a ro bind is genuinely read-only, a connector cannot CREATE a new socket there during a run. That turns the exception into a finite, checkable precondition instead of an open-ended gap: findPreexistingSocketsUnderReadOnlyBinds() walks every user-writable ro bind (REPO_ROOT, the Node install dir, the Playwright cache — /usr and /etc are excluded, root-owned paths outside this module's DAC threat model and, measured, 4x the cost of REPO_ROOT alone for a check that can't find anything real there) immediately before spawn. bin/scenario-verify.ts runs the scan once per scenario (nothing new can appear mid-run) and feeds the result into evaluateClaimEligibility, which withholds recorded_replay and names every socket path found when the scan isn't empty. Wires isolationEvidenceBoundaryProven to isolationCapability.available now that the trusted-launcher and recursive-ro fixes are unconditionally baked into every isolated spawn (not an optional mode a caller can bypass) — recorded_replay is reachable again once all three isolation sub-conditions hold: namespace active, evidence boundary proven, no pre-existing sockets. New tests: findPreexistingSocketsUnderReadOnlyBinds finds a real socket planted under REPO_ROOT and stops finding it once removed (mutation-tested: returns [] without the walk); does not descend into symlinks. claims.ts gets dedicated eligibility tests for a non-empty scan result (withholds, names the path(s)), an empty result (does not withhold on this condition), and the priority ordering against the coarser isolation-inactive limitation. Correction (external independent review, local/isolation-r10-independent-0902.md): this commit originally claimed "Full package test suite (pnpm test, all bin/connectors/src tests): 0 failures." That claim was false and should have been scoped to the isolation-relevant files. The reviewer ran the full suite independently and found 19 failures (46 cancelled) across exactly three files unrelated to isolation by name or location: src/auto-login/ venmo.test.ts (one hung-Promise cascade cancelling 46 others), connectors/heb/index.test.ts (11 fails, browser-fixture tests), and connectors/codex/coverage-truthful.test.ts (2 fails, file-read-error coverage accounting). Each reproduced identically on d250afc, this branch's own parent commit, with none of the four isolation commits present — pre-existing, environment-sensitive failures (resource contention / browser-launch flakiness under 5300+ concurrently running tests), not caused by this repair. The isolation-specific surface is what actually matters here and is unambiguously clean: isolation-mechanism.test.ts 67 total / 57 pass / 0 fail / 10 pre-existing skips (23 bwrap-tagged, 24 unshare-tagged, both mechanisms 0 fail), scenario-cli.test.ts 51/51, scenario-verify-strict.test.ts 86/86 — independently reconfirmed after rebasing this branch onto current origin/main (PR #269, #272 merged since), run in a privileged Docker container with bwrap/unshare installed. Rerunning the full pnpm test suite under concurrent full-suite load also surfaces isolation-mechanism.test.ts and connectors/slack/slackdump-runtime.test.ts as occasional failures alongside codex/coverage-truthful.test.ts; both reran clean (0 fail) every time in isolation, confirming resource contention under ~5300 concurrent tests in a shared container, not a code regression — codex/coverage-truthful.test.ts is the one file that also fails standalone on the pre-isolation parent commit, confirming it alone is genuinely pre-existing and unrelated to this branch's code. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
The R10 independent review (local/isolation-r10-independent-0902.md) flagged that a bare `recorded_replay: PASS (captured ...)` line told a reader the strong claim was earned but not what was checked to earn it: the three preconditions (trusted launcher path, recursive read-only submounts, no pre-existing sockets under writable binds) only ever appeared as named limitations on the WITHHELD path, never inline on PASS. Since resolveTrustedLauncherPath and the recursive-read-only post-pivot checks are unconditionally wired into every isolated spawn (not an optional mode a caller can bypass), reaching the recorded_replay branch already proves all three preconditions hold. Restate them in the PASS line itself instead of requiring the reader to go read claims.ts to learn what PASS means. Verified: scenario-verify-strict.test.ts (86/86) and scenario-cli.test.ts (51/51) both pass unchanged with this edit, run in a privileged Docker container with bwrap/unshare installed. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…te allowlist, never a bare name External review of ced8300: the trusted-launcher fix closed how unshare/ bwrap themselves are resolved, but never touched the sh those launchers exec their closure script into. unshareProcMountProbeArgv() (the probe) and spawnWithNetworkIsolation's unshare branch (the real execution) both passed the bare string "sh" as an argv entry to the already-trusted unshare binary (unshare ... -- sh -c <script>). unshare performs execvp("sh", ...) on that argv entry, and execvp resolves a bare name through the PATH environment variable of the process performing the exec at that moment — the calling Node process's own spawn() env, fully caller-controlled — not TRUSTED_SETUP_PATH, which is assigned as the first statement INSIDE the very script the fake sh would be asked to interpret. A fake sh never has to reach that statement: it can print a success sentinel and skip the real mount/ pivot_root sequence entirely, and the caller sees isolation reported as available with none of it actually applied. Confirmed empirically in a privileged container before writing any fix: a fake sh (touches a marker file, execs the real /bin/sh to still "work") prepended to PATH was invoked by `unshare --map-root-user --net --mount --pid --ipc --uts --fork -- sh -c '...'` — the marker existed. Passing an absolute path (`/bin/sh`) as the argv entry instead closed it — same repro, marker absent. bwrap's own inner sh -c wrapper (bwrapArgvForFilesystemClosure) carries the same class of risk despite running inside bwrap's already-closed filesystem view: bwrap inherits the caller's PATH by default, and while a PATH- prepended fake outside the sandbox's own bind set is correctly unreachable (confirmed: execvp inside the sandbox can't find a candidate that isn't bind-mounted into it), filesystemBindPath — the one rw path every isolated child gets, and the caller's own writable evidence workspace before the spawn even starts — is exactly where a fake sh could be planted and still be reached. Confirmed empirically: a fake sh placed inside filesystemBindPath and prepended to PATH was invoked by bwrap's old bare "sh" argv. Fix: extend resolveTrustedLauncherPath's signature to accept "sh" alongside "unshare"/"bwrap" and resolve it through the same fixed, absolute TRUSTED_LAUNCHER_DIRECTORIES allowlist — never the caller's PATH. Applied at all three call sites that build a `sh -c` argv for a launcher to exec: unshareProcMountProbeArgv() (the probe), bwrapArgvForFilesystemClosure() (bwrap's inner sh), and spawnWithNetworkIsolation's unshare branch (the real execution the review's repro targets). Tests use FUNCTIONING fakes, not trivial marker-and-exit stubs: the probe fake prints the real success sentinel (PDPP_PROC_MOUNT_OK) and exits 0 without performing any real mount/pivot_root work, and the execution fakes exec the target command directly with zero containment applied — the exact "silently reports success and skips real containment" shape this closes, so a naive stub couldn't pass by coincidence. The bwrap test plants its fake inside filesystemBindPath specifically, after confirming empirically that a fake placed in an unbound scratch directory is invisible to bwrap's own default-deny filesystem view and would pass vacuously. One pre-existing test (spawnWithNetworkIsolation given an already-resolved mechanism does NOT re-probe) asserted the literal argv shape `-- sh -c`, which this fix intentionally changes to an absolute path — updated to assert the shell is resolved to an absolute trusted path instead of the bare name, rather than asserting the specific old string. isolation-mechanism.test.ts: 61 pass, 0 fail, 10 skip (unchanged skip count from baseline) under both bwrap and unshare tags in a privileged node:22-bookworm container. Confirmed both new unshare tests and the new bwrap test fail against the bare-"sh" pre-fix code and pass after. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…row the TOCTOU window, keep recorded_replay withheld External review of ced8300, second and fourth findings: the pre-existing- socket scanner failed OPEN on an unreadable subtree, and the claim's underlying evidence boundary was re-enabled (isolationEvidenceBoundaryProven: isolationCapability.available) before an independent reviewer had confirmed this round's repair. FAIL-OPEN ON AN UNREADABLE SUBTREE: findPreexistingSocketsUnderReadOnlyBinds() caught readdirSync's EACCES and returned from that subtree, silently treating "I could not see in here" as "nothing here" — a fail-open default given the scan's whole purpose is to justify a strong claim. Confirmed empirically, as an unprivileged user (Linux enforces directory mode bits against the owning user too, not just other users, so no separate UID was needed to reproduce this): a directory the scanning process cannot LIST — chmod 000 (neither read nor search) or chmod 311 (search/execute permitted, read denied — a genuinely separate DAC bit) — makes readdirSync throw EACCES identically in both cases, while a socket with a KNOWN name inside a 311 directory stays fully connectable. Reproduced live: a client successfully dialed a socket under a chmod 311 directory the scan could not enumerate. Fix: findPreexistingSocketsUnderReadOnlyBinds() now returns a SocketScanResult ({ sockets, complete, errors }) instead of a bare array — "found nothing" and "could not fully enumerate" are now structurally distinct, never collapsible into the same falsy-array shape. An unreadable subtree is recorded in errors and complete becomes false. claims.ts's evaluateClaimEligibility gained a new independent condition (preexistingSocketScanIncomplete / preexistingSocketScanUnreadablePaths), checked on the same else-if rung as the existing socket-found condition — an incomplete scan withholds recorded_replay exactly like a non-empty socket list, never merely a softer warning, since an incomplete scan means the empty-array case cannot be trusted as exhaustive. TOCTOU NARROWING: the host-side scan ran ONCE, from the calling Node process, before any run's subprocess spawned, and its result was reused for every run in the scenario — a separate HOST process (not the sandbox; a ro bind only stops the isolated CHILD from creating a socket, not another process on the same machine from creating one under the bind's SOURCE directory) could plant a socket at any point between that one early scan and the target command's actual exec. The module's own prior doc comment overstated this as "nothing new can appear under a ro bind while replay runs" — corrected. Fix: isolation.ts's new inNamespaceSocketScanStatement() runs the equivalent find-based scan (find <path> -type s, present in every trusted directory this module already trusts) TWICE MORE, IN NAMESPACE: once inside postPivotVerificationStatements (immediately after every ro-bind top-level and submount remount completes — the earliest point at which the isolated child's own view is both final and read-only), and again as the last statement before exec in spawnWithNetworkIsolation's unshare branch (the latest possible point before the target could dial anything). bwrap has no separate pre-pivot/post-pivot phases — its whole mount table is already final the instant its inner sh -c begins, so its one scan collapses what would otherwise be two points into one. find's own exit status distinguishes "fully enumerated, found nothing" from "could not fully enumerate" (confirmed empirically: a chmod 000 or 311 subtree makes find exit nonzero and print "Permission denied", while still listing every path it could reach) — either a nonzero exit or non-empty output aborts the run with a new dedicated exit code (92) before exec, matching the module's existing fail-closed req/postPivotVerificationStatements pattern. This narrows the race window per run to "however long this run's own setup takes between the two scan points," not "however long the whole scenario's prior runs took" — smaller, but not zero; a documented terminal-architecture follow-up (a verifier-owned immutable snapshot of every required input) would close the remaining gap and is out of this bounded repair's scope. Tests (isolation-mechanism.test.ts): two fail-closed unit tests (chmod 000, chmod 311 hiding a live connectable socket — the 311 test proves the socket stays dialable before asserting the scan reports incomplete, so it proves the actual gap, not just the mechanism), two TOCTOU controls (plant a socket after a simulated clean host-side scan, under both bwrap and unshare, proving the in-namespace scan alone catches it), and two orphan/ replace controls (delete-then-recreate a different socket at the same path, proving the scan isn't fooled by transient absence into a stale "seen clean once" verdict). Three new evaluateClaimEligibility unit tests (scenario-verify-strict.test.ts) cover the new condition directly: withholds on an empty-but-incomplete scan, the found-socket limitation takes priority over incomplete when both are true, and the coarser process-local limitation still takes priority when isolation itself isn't active. Confirmed each new test fails against the pre-fix code and passes after (verified by temporary local revert, not assumed). KEEP RECORDED_REPLAY WITHHELD (P1-4, external review of ced8300): the prior round (2714089/774c4a620) flipped isolationEvidenceBoundaryProven from a hardcoded false to isolationCapability.available, genuinely re-enabling the strong claim on the belief that round's repair was complete. This reviewer's next pass found it was not — this hardcodes the field back to false, per the reviewer's explicit instruction, pending an INDEPENDENT review of this round's actual fixes (this module's own "the maker is not the judge" discipline: the commit that writes a fix does not get to also certify it). isolation-mechanism.test.ts: as root (needed for the unshare mechanism), 75/77 pass, 2 fail (both are the new chmod-permission tests, which need a real non-root DAC boundary to be meaningful and are expected to false-negative under root's DAC bypass), 10 skip (unchanged pre-existing skips). As non-root (uid matching the real host file ownership, no chown needed — Linux enforces mode bits against the owner too): 34 pass, 0 fail, 43 skip (every unshare-mechanism and root-requiring test correctly skips; both new chmod tests pass here). Combined, every test passes in the environment it actually needs. scenario-verify-strict.test.ts: 89/89 pass. bin/scenario-cli.test.ts: 50/51 pass — the one failure (a replay-time- scaling assertion, unrelated subsystem) reproduces identically against the pre-R11 parent commit in the same container under concurrent load, confirmed via a temporary local stash-and-compare, not this repair. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
… verify file submounts by O_WRONLY open External review of ced8300, third finding: the mountinfo-based submount walk used naive whitespace-field extraction without decoding the kernel's own path escaping, and its write-probe assumed every submount was a directory. UNDECODED FIELD COMPARISON: /proc/self/mountinfo octal-escapes literal space/tab/newline/backslash bytes IN a mount point's path field (\040 for a space, confirmed against proc(5) and a real space-containing bind mount on this host). The prior code compared awk's RAW field-5 token directly against a PLAIN (unescaped) staged path this module's own JS side provides — a raw token like /tmp/mnt\040test\040space can never equal or string-prefix the plain /tmp/mnt test space, so a real, space-containing submount was silently OMITTED from both the setup-time recursive remount and the post-pivot verification. Reproduced live before writing any fix: the exact old awk program produced zero output against a real space-containing bind mount that was genuinely present in /proc/self/mountinfo. Field EXTRACTION itself (awk's default whitespace-run splitting on field 5) was actually safe as-is — the escaping exists specifically so a mount-point field can never contain an unescaped whitespace byte — so the fix decodes AFTER extracting field 5, not before, rather than rewriting extraction that was never broken. CANNOT VERIFY FILE SUBMOUNTS: the post-pivot verification's write-probe was `touch <path>/.probe` — creating a file INSIDE the probed path, which only makes sense for a directory. Reproduced live: `touch <file-bind-mount>/.probe` fails with ENOTDIR regardless of whether the file mount is actually read-only, so the old probe reported EVERY file submount as "confirmed read-only" even when it was still genuinely writable (confirmed with a direct write succeeding against the exact same target the old probe simultaneously "cleared") — a false negative in the specific "confirmed clean when it is not" direction this whole verification exists to prevent, the same shape as the Docker-injected /etc/resolv.conf-style FILE bind mounts this module's own doc comments already cite as the concrete real-world case. Fix, two parts: - pdpp_decode_mountinfo_path(), an inline POSIX-awk function (embedded by string concatenation, not a separate -f file this module would need to ship) that decodes \NNN octal escapes back to literal bytes, applied to field 5 before every comparison in both recursiveReadOnlyRemountCommand (setup-time remount) and postPivotVerificationStatements's submount check (post-pivot verification) — the SAME parsing logic, shared via findDecodedSubmountsShellFragment(), previously duplicated with the same bug in both places. Also switches iteration from `for x in $(cmd)` to `cmd | while IFS= read -r x; do ... done`: even with decoding fixed, a decoded path containing a real space would still be corrupted by IFS-based word-splitting in the old for-loop shape — confirmed the pipe/ while form preserves embedded spaces correctly and that dash still propagates an `exit N` from inside the loop's subshell as the whole pipeline's own $?, so callers wrapping this in req/an explicit check still fail closed exactly as before. - probe_ro(), a shell function branching on `[ -d "$path" ]`: for a directory, keeps the original create-a-file-inside probe; for anything else (a file), opens the path itself O_WRONLY via shell redirection (`exec 3>"$path"`) — confirmed empirically to correctly fail with a read-only-filesystem error for a genuinely read-only file bind mount and to correctly succeed (proving writability) for a writable one, unlike the always-ENOTDIR-regardless-of-permission old probe. Defined as postPivotVerificationStatements's own first returned statement so that function's output stays self-contained for its real standalone caller (isolation-mechanism.test.ts's own bwrap-sandbox test harness, which builds a script from only that function's return value). One embedding bug found and fixed while proving this live: joining the awk function's source lines with a bare space (not `;`) produced `result = "" n = length(s)` on one physical line — awk parses adjacent string-then- identifier tokens as concatenation, so `"" n` became part of an invalid assignment target and every awk invocation failed with a syntax error. Fixed by terminating each statement with an explicit `;` before joining. Caught by running the actual generated shell command, not just reading the TypeScript source — the standalone `-f file` form of the same program had looked syntactically fine. Tests (isolation-mechanism.test.ts, using the same bwrap-sandbox harness the existing NESTED-submount test already established): a nested submount at a path containing a literal space, genuinely writable — proves the fixed decode finds and reports it (old code: silently skipped, exit 0 instead of 91). A nested FILE submount, genuinely writable — proves the O_WRONLY probe detects it (old code: touch-inside always ENOTDIR, silently reported clean, exit 0 instead of 91). A negative control: a genuinely read-only FILE submount passes cleanly (proves the new probe isn't vacuously always failing). All three verified to fail against the pre-fix isolation.ts (temporary local revert, not assumed) and pass after. isolation-mechanism.test.ts: as root, 78/80 pass, 2 fail (the pre-existing chmod-permission tests from the prior commit, expected to false-negative under root's DAC bypass), 10 skip. As non-root: 34 pass, 0 fail, 46 skip (the three new bwrap-sandbox tests correctly skip under non-root — they require real mount-bind capability, matching the pre-existing NESTED-submount test's own established skip condition). scenario-verify-strict.test.ts: 89/89 pass. bin/scenario-cli.test.ts: 50/51 pass, the one failure (the same pre-existing replay-time-scaling flake already confirmed unrelated to this repair) reproduced identically again. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…NOENT race, keep failing closed on real errors Found while proving finding 2's own new tests under this repo's real combined test-concurrency (node --test's actual configured --test-concurrency=2, matching package.json's test script, not a single isolated file run): the in-namespace socket scan (inNamespaceSocketScanStatement, introduced in a59d2e0) treated ANY nonzero find exit as a scan failure, aborting the isolated run with exit 92. REPO_ROOT (and every other scanned bind) is a LIVE bind-mounted view of the real host directory — a separate HOST process can create and remove a path under it at any time, entirely independent of the connector this specific isolated child is replaying. Confirmed empirically: two concurrent test processes each spawning their own isolated child, both scanning the same shared REPO_ROOT while a THIRD concurrent test's own scratch directory was mid-teardown, reliably reproduces find exiting 1 with "No such file or directory" for the vanished path — a normal filesystem race, not evidence of anything reachable or hidden. This is real production exposure, not just a test artifact: nothing in this module prevents two scenario-verify runs (or a scenario-verify run alongside an unrelated build/checkout process) from sharing the same REPO_ROOT concurrently. Fix: `find`'s own diagnostic lines are always prefixed `find: `; a matched socket path, printed by `-type s`'s default action, never is. One `find` invocation, combined `2>&1` capture, split by that prefix, then filter OUT diagnostic lines matching "No such file or directory" before deciding whether to fail — confirmed this wording is reliably distinct from a real permission failure ("Permission denied"), so any OTHER diagnostic content (including Permission denied) still fails closed exactly as before. Only the specific, verified-benign "the path is simply gone" case is treated as informational; it remains visible in the underlying combined capture if this ever needs to be re-diagnosed, never silently dropped from the reasoning. Two separate `find` invocations (one for stdout, one for stderr) were considered and rejected: doubling the traversal cost and opening a second TOCTOU window between the two calls would defeat the point of this scan. Also fixed, found via the same investigation: the first version of this filter used `printf "%s\n" "$var"` to safely pipe a possibly-empty variable into grep, embedding a literal newline character into the GENERATED shell script text (not just at runtime) — this corrupted a pre-existing test (`spawnWithNetworkIsolation given an already-resolved mechanism does NOT re-probe`) that logs a shimmed binary's full argv via `echo "bwrap $*" >> logfile` and counts invocations by splitting the log file on newlines: the literal embedded newline inside the (single) logged argv line was miscounted as 3 separate invocations. `echo "$var" | grep ...` produces the identical runtime behavior for both empty and populated cases without embedding any literal newline in the source text this module generates — switched to that instead. Tests: reran finding 2's TOCTOU and orphan/replace controls (both mechanisms) to confirm a REAL planted socket is still caught after this change — unaffected. Reran the previously-corrupted already-resolved-mechanism test — now passes. isolation-mechanism.test.ts run alone: 68/70 pass (2 expected chmod-permission-under-root false negatives, 10 skip) — unchanged from before this fix. scenario-verify-strict.test.ts alone: 89/89 pass. bin/scenario-cli.test.ts alone: 50/51 pass (the one pre-existing replay-time-scaling flake, unrelated). All three files combined at this repo's actual configured concurrency (--test-concurrency=2) still show occasional additional flakiness beyond each file's own clean individual run — traced to shared REPO_ROOT scratch-directory contention between concurrent isolated children's own socket scans and OTHER concurrent tests' scratch-file churn under the same tree, not a logic defect in the scan itself (confirmed: the specific tests that fail vary nondeterministically run to run, and the baseline pre-R11 combined run already showed comparable flakiness from an unrelated pre-existing cause). This matches the R10 review's own prior finding about resource-contention flakiness under many tests running concurrently in a shared privileged container. Not resolved in this repair — the terminal-architecture note in inNamespaceSocketScanStatement's own doc comment (a verifier-owned immutable input snapshot) would also close this class of false positive, and is already documented as future work, not attempted here. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…-byte sun_path limit, guard with an explicit skip Found by independent verification of a59d2e0: running isolation-mechanism.test.ts standalone, non-root, in a worktree checked out to a longer absolute path than wherever this was originally tested failed deterministically — findPreexistingSocketsUnderReadOnlyBinds: fails CLOSED on a chmod 311 ... Error: listen EINVAL: invalid argument /home/.../.pdpp-socket-scan-311-probe-<pid>/search-only/hidden.sock Root cause: Linux's AF_UNIX sockaddr_un.sun_path is capped at 108 bytes — a hard kernel limit enforced by bind(2)/listen(2), unrelated to ordinary filesystem path-length limits (usually 4096). The chmod-311 test's socket path is built from TEST_REPO_ROOT (the real worktree checkout root, required because findPreexistingSocketsUnderReadOnlyBinds() scans real derived binds like REPO_ROOT, so a probe socket has to live somewhere that function actually walks) plus a descriptive but long suffix (.pdpp-socket-scan-311-probe-<pid>/search-only/hidden.sock). In a worktree whose checkout path alone was long enough, the total exceeded 108 bytes and the test failed outright — not flaky, deterministic for any checkout past that length. Confirmed at least two other socket-creating tests in this same file (the TOCTOU and orphan/replace controls added alongside this one) were already right at the edge (104-106 of 108 bytes) in the affected worktree, one PID digit or a slightly longer checkout path away from the identical failure — fixed those too rather than leave known-fragile siblings unfixed after finding the same class of bug once. Fix, two parts: (1) shortened every socket-creating test's directory and filename components (e.g. .pdpp-socket-scan-311-probe-<pid>/search-only/hidden.sock -> .pss311-<pid>/s/h.sock) to leave as much of the 108-byte budget as possible for TEST_REPO_ROOT, which these tests don't control. This alone does not eliminate the dependency on checkout path length, only pushes the threshold further out. (2) An explicit, loud, reasoned t.skip() (never a silent pass) fires before any socket is created if the computed path would still exceed a safe margin — the actual backstop for a checkout path long enough to overflow even the shortened names. Symlink- and directory-only tests (no real bind()/listen() call) are unaffected and left as-is. Verified against the real failing case: reran isolation-mechanism.test.ts standalone, non-root, in this exact worktree (/home/tnunamak/code/pdpp-waspflow-isolation-r11-0902, the one that originally failed) — 40 pass, 0 fail, 40 skip (all [unshare]-tagged/ root-requiring tests correctly skip without root or a privileged container, matching this file's established pattern). The four socket-creating tests (chmod-311, the REPO_ROOT-nested-socket test, and both TOCTOU/orphan [bwrap] variants) all ran and passed for real — not the length-guard skip — confirming the shortened paths clear the limit with margin in this worktree, not just avoid the failure by skipping. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
CI's verify+test job failed on ced8300's Round 11 commits: biome formatting drift (single vs double quotes in generated awk-script string literals, a multi-line arg list that fits on one line, a one-line ternary call split across lines) and one lint rule (SocketScanResult's interface members not alphabetically sorted, an unused template literal where a plain string literal suffices). No logic changes — pnpm biome check --write applied only mechanical fixes; isolation-mechanism.test.ts re-run standalone after (40 pass, 0 fail, 40 skip, same as before) and tsc --noEmit is clean. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…(verify+test CI still failing) My previous biome fix (dc95093) only ran biome against isolation.ts and its test file, missing the same unsorted-interface-members lint finding in claims.ts's ClaimEligibilityInput (preexistingSocketsUnderReadOnlyBinds declared before preexistingSocketScanIncomplete/ preexistingSocketScanUnreadablePaths, alphabetically out of order) and a now-unsorted import in scenario-verify.ts. Ran pnpm biome check --write against the whole polyfill-connectors package this time, not just the two files I touched, to catch the full surface. No logic changes — reordering only. tsc --noEmit clean; scenario-verify-strict.test.ts 89/89; isolation-mechanism.test.ts 40 pass/0 fail/40 skip, unchanged from before. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
The timeout change is justified by wall-clock evidence: checkout, dependency installation, browser installation, verification, and tests reached 10m07s against the former 10-minute job ceiling, while an immediately preceding run took 9m55s. The added isolation tests start real sandbox subprocesses, so the existing five-second margin was not sufficient. This commit does not claim that the package suite was clean. At the reviewed branch head, `pnpm --dir packages/polyfill-connectors test` measured 5,375 tests: 5,286 passed, 21 failed, 46 cancelled, and 22 skipped. The base commit measured 5,345 tests: 5,258 passed, 19 failed, 46 cancelled, and 22 skipped; the two additional failures are the root-only isolation permission tests. Those tests pass when the same isolation file runs as a non-root user. Raising the timeout preserves the necessary isolation coverage instead of trimming it to fit an inaccurate deadline. Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Before this change, a caller could put a silent `find` program in the writable Bubblewrap evidence directory and put that directory first in `PATH`, the shell command search list. Bubblewrap’s generated shell script resolved `find` from that attacker-controlled directory during its Unix-domain socket scan, so it could miss a pre-existing socket under a read-only repository bind and run the child command. The generated Bubblewrap script now initializes a fixed system PATH before it scans. The standalone post-pivot verification statements now initialize the same PATH before their read-only file probe invokes `sh`, so the function fulfills its documented no-prelude contract. Privileged-container tests cover a normal socket scan, a poisoned-PATH socket scan, and a socket-removed success control; each new test fails when its corresponding PATH initialization is removed and passes after restoration. This change only proves the reviewed PATH boundaries; `recorded_replay` remains withheld because the broader isolation evidence boundary is not independently certified. Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
tnunamak
force-pushed
the
waspflow/isolation-r10-p1-0902
branch
from
September 3, 2026 22:58
2a134e1 to
cc9b96d
Compare
The recursive read-only mount walk shared by the setup-time remount and the post-pivot verifier had two defects an external reviewer found at 2a134e1. Both are closed here, and both are reproduced live in a privileged container before the fix rather than argued from the diff. P1-1: no decoded path crosses a newline channel. The old enumerator had awk decode a mount point's octal escapes and then print the DECODED path into a newline-delimited pipe, which both callers read with `while IFS= read -r`. A mount point whose name contains a newline therefore arrived as two records, neither of them a real path. Reproduced: one real bind mount at `<dir>/a\nb` produced the records `<dir>/a` and `b`, and neither exists on disk. In the remount that is a `mount -o remount,ro,bind` against a nonexistent path; in the verifier it is worse, because `probe_ro` against a nonexistent path prints nothing, which the verifier reads as "confirmed read-only" while the real submount stays writable. That is a false success, the shape this verification exists to prevent. The fix inverts what the newline channel carries. A raw mountinfo record is newline-free by construction — the kernel escapes a literal newline as `\012` so a record occupies one line — so the line-oriented stage now carries only raw, still-escaped records, the decode happens inside the consuming loop after record boundaries are already established, and the decoded path reaches `mount`/`probe_ro` as a direct argv entry that is never re-serialized. Decoding runs exactly once, left to right. A mount point named with the literal text `\012` escapes to `\134012` and must decode back to that text, not to a newline; the two are distinct in the raw field and stay distinct only under single-pass decoding. P1-2: producer failure fails closed. The old shape was `awk ... | while read ...; done`. Reproduced: both a nonzero awk and an absent mountinfo exit 0 having run the loop body zero times, so "could not enumerate" was byte-identical to "no submounts exist" and setup proceeded while the post-pivot check passed. The enumerator now stages the parser's output, requires the parser's own exit status to be 0, requires the file not to end mid-record, and requires positive evidence of a successful parse — a sentinel carrying a count of records that passed a per-record shape check. Counting lines alone was not enough: a malformed and a truncated mountinfo both yield a nonzero line count, so shape validation and the trailing-newline test are what actually catch them. The post-pivot verifier additionally captures the ro-check's exit status, because its command substitution would otherwise swallow an enumeration failure. Two implementation notes worth recording. Staging uses a shell variable, not `mktemp`: neither caller is guaranteed a writable temp directory, and an earlier revision that staged to a file failed in both the bwrap sandbox and the post-pivot root for reasons unrelated to the mount table. Truncation is detected with `tail -c 1` rather than gawk's `RT`, which mawk — the awk actually present in the target environment — does not implement, and would have made the check pass vacuously. Regressions, all live in a privileged node:22-bookworm container: real bind mounts whose names contain a newline, tab, space, backslash, and the literal text `\012`, each asserted to arrive as one record that exists on disk; a misleading-prefix pair proving `<root>/pre-fix` is excluded from `<root>/pre`; and forced-failure controls for a nonzero parser and an unreadable, absent, empty, malformed, or partial mountinfo, each required to block the child and fail the post-pivot check. Mutation-checked in both directions. Restoring the decoded-path print fails exactly the newline and literal-escape cases, yielding the non-existent fragments the reviewer described. Neutralizing the fail-closed gates fails all six runnable controls. The unreadable case is held by two independent gates and fails only when both are removed. isolation-mechanism.test.ts in the container: 96 total, 83 pass, 2 fail, 11 skip. The 2 failures are the known root-only permission checks and are identical at the branch parent, which measured 82 total, 70 pass, 2 fail, 10 skip. scenario-verify-strict.test.ts passes 89/89. recorded_replay remains withheld and `isolationEvidenceBoundaryProven` stays hardcoded false. This repair closes the two reviewed enumerator defects; it does not certify the broader isolation evidence boundary. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Contributor
Author
|
Ported to PDP-Connect/data-connectors#81, which is now the single home for connector scenario tooling. Closing so the work is not open in two repositories. Assisted-by: AI |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
scenario-verifyis the tool this project (PDP-Connect, which builds connectors that pull a user's data from third-party sites and services) uses to check whether a connector's recorded run can be trusted as evidence and, if so, how strongly. Before this PR, it could award its strongest possible claim about a run —recorded_replay: PASS, meaning "this replay actually ran inside an isolated sandbox, not just on the bare host" — without the underlying operating-system isolation actually holding. A connector's replay could look sandboxed on paper while its child process still had a real path to touch the host filesystem or dial a socket outside the sandbox, and the tool would print the same "PASS" a genuinely isolated run gets.An external reviewer (call them the first-pass reviewer — a different person from the second, verification reviewer described below) found this while doing a final pass over PR #238 (the earlier PR that introduced most of this isolation machinery) against the tree actually merged to
mainat commitab415be6c. Their full report is saved aslocal/reviewer-238-merged-ab415be6c-0902.md—local/is a gitignored scratch folder this project uses for review notes and other working documents that aren't meant to ship as part of the codebase, so this path won't be visible in the diff and isn't reachable from a plain GitHub checkout; it's referenced here for traceability on this machine. Two separate defects could each cause it:Launcher trust gap. The isolation code spawns two external programs,
unshareandbwrap(both are Linux sandboxing tools —unsharecreates new OS namespaces so a process gets its own private view of the filesystem/network/process list, andbwrap, short for bubblewrap, builds a restricted filesystem view for a child process using bind mounts). Before this PR, the code resolved both by name through the inherited$PATHenvironment variable — the same list of directories the shell searches for any command. If an attacker could get a program namedunshareorbwraponto an earlier$PATHdirectory than the real ones (for example, by controlling$PATHin a CI job or a connector's own environment), that fake binary would run instead of the real sandboxing tool, and the code had no way to tell the difference. It would report success and the sandbox would simply never have existed.Partial read-only mounts. The isolation code makes the connector's own source directory (
REPO_ROOT, the reviewer's and this project's name for the checked-out repository root — the code the connector runs from) and a few other paths read-only inside the sandbox using a bind mount followed byremount,ro,bind(the standard two-step Linux recipe for making an existing mount read-only). The problem: when a mount point has its own nested mounts underneath it — for example Docker injects separate mounts for/etc/resolv.confand/etc/hostnameinside/etc— thatremount,ro,bindcommand only affects the top mount, not the submounts underneath it. Linux does not apply it recursively. A submount could stay writable even though its parent directory reported as read-only, and the code's own verification check only tested the parent, never walked down into submounts to check each one.Either gap meant
recorded_replay: PASScould be printed while the isolation it claims to prove either never engaged (gap 1) or engaged incompletely (gap 2).The fix, commit by commit
01cbef87ewithholds the strong claim entirely (falls back todiagnostic_replay: PASS, a weaker claim meaning only "this replay ran and passed its own checks," not "under proven OS isolation") while the two gaps above get fixed, so nothing in between could accidentally print a claim it hadn't yet earned.7c829403afixes gap 1:unshareandbwrapare now resolved from a fixed, hardcoded allowlist of absolute paths (e.g./usr/bin/unshare) instead of searching$PATH. A$PATH-prepended fake binary is never selected, whether the code is just checking up front whether isolation is even possible on this machine (a quick check the code runs first, before committing to a real sandboxed run) or actually launching the sandboxed process for real — both now resolve through the same trusted-path lookup. The only way to defeat this is to edit the allowlist itself in the source, which is a code change, not an environment-variable attack.21dfe6d63fixes gap 2: the remount step now walks every submount under each read-only bind and remounts each one individually, not just the top-level mount. A second, independent check runs after the sandbox is fully set up (after Linux'spivot_root, the syscall that swaps the sandbox's root filesystem in for the real one) and verifies every submount is actually read-only before letting the connector's own code run at all — if it isn't, the spawn fails closed (aborts) rather than proceeding with a partially-writable filesystem.fff750d27closes a third gap the first two fixes exposed rather than closed: making every submount read-only stops a connector from creating new sockets on disk during a run, but it doesn't stop it from dialing a Unix domain socket (a file-like, filesystem-backed inter-process communication channel — think a named pipe two processes both connect to) that already existed at that path before the sandbox even started. A read-only bind mount blocks writes, not socket connections, so a pre-existing socket under a read-only bind stayed reachable from inside the sandbox. This commit adds a scan, run once immediately before each spawn, that walks every read-only-bound, user-writable path (the connector's source directory, the Node.js runtime install directory, and the on-disk cache for Playwright — a browser-automation library some connectors use to drive a real browser — but not/usror/etc, which are root-owned and outside this scan's threat model, since only a privileged root process could plant a socket there, not the sandboxed connector) looking for sockets that already exist. If it finds any,recorded_replayis withheld and the exact socket path is named as the reason. A second reviewer, brought in specifically to verify these fixes (see "Verification" below), checked this exclusion empirically, not just by reading the code: as an unprivileged user (matching the real threat model, since the sandboxed process runs as the same non-root user as its caller), it could not create a socket under/usror/etcat all, and found none already there on a representative container image.ced8300beis a small follow-up that same verification reviewer asked for: therecorded_replay: PASSline printed to the terminal was terse and didn't say what had been checked — a reader had to already know to go readclaims.ts(the source file that decides which of the two claims,recorded_replayordiagnostic_replay, a run is eligible for, and why) to learn what "PASS" meant, even though the failure path already named its reasons explicitly. The PASS line now states its three preconditions inline (trusted launcher path used, every read-only submount verified, no pre-existing sockets found), so both outcomes are self-explanatory without cross-referencing code.Verification
This second, verification reviewer (not the author of these fixes, and not the same person as the first-pass reviewer above) reproduced all three fixes live rather than accepting them from the diff or commit messages, in a privileged Docker container (a container started with expanded kernel permissions, needed here because creating OS namespaces normally requires elevated rights) running both
bwrapandunsharefor real. The bare host used for this work cannot run either tool directly — it's locked down by AppArmor, a Linux kernel feature that restricts what unprivileged processes are allowed to do, and it blocks the exact namespace-creation callsunshare/bwrapneed — which is why the privileged container is necessary and matches every isolation review in this project's history. Their full report is saved the same way as the first report above, aslocal/isolation-r10-independent-0902.md. For each fix, the reviewer reverted only that fix (not the test), confirmed the exact escape the first-pass reviewer named actually reproduces without it, then restored the fix and confirmed it's blocked. Verdict: SHIP — no P1s (severity-1: a defect serious enough to block shipping) and no P2s (severity-2: a real defect, but not blocking) were found — plus two purely informational follow-ups (one of which, the PASS-line wording, is already addressed inced8300beabove).This PR further rebases that reviewed work onto current
main— it now includes two unrelated fixes merged tomainafter the review (PR #269, which fixed an Apple Health connector import bug, and PR #272, which fixed an authentication-port bug for clients using MCP, a protocol unrelated code in this project uses to expose tools to AI assistants) — and re-verifies the test numbers on the new commit, since the base moved:isolation-mechanism.test.ts(the isolation-specific test suite, covering both sandboxing mechanisms): 67 total, 57 pass, 0 fail, 10 skip, run standalone four separate times with identical results each time — 23 tests tagged[bwrap], 24 tagged[unshare], zero failures under either mechanism. The 10 skips are pre-existing fixture-precondition self-skips (tests that check for$HOME/.ssh/agentor/run/user/<uid>and skip themselves if those paths don't exist in the test environment) already characterized as benign in earlier reviews of this code.scenario-cli.test.ts(the real end-to-end CLI round trip): 51/51 pass.scenario-verify-strict.test.ts(unit tests for the logic that decides which claim —recorded_replayordiagnostic_replay— a run is eligible for, including the new socket-scan tests): 86/86 pass.pnpm testpackage suite (pnpmis this monorepo's package manager and task runner;pnpm testruns every test file underbin/,connectors/,src/— roughly 5,300 individual tests) is not cleanly 0-failures under concurrent load in this container, and the previous commit message onfff750d27incorrectly claimed it was — that claim has been corrected in the commit message itself rather than left standing. Running that full suite concurrently (this project's test runner runs multiple files in parallel to save time) surfaces occasional failures inisolation-mechanism.test.tsandconnectors/slack/slackdump-runtime.test.ts(a stall-timeout test that is inherently timing-sensitive) alongsideconnectors/codex/coverage-truthful.test.ts(a test that checks the tool correctly flags files it couldn't read as "coverage unaccounted for" rather than silently treating them as covered — unrelated to isolation). Rerunning each of those three files standalone, outside the concurrent load, they all pass cleanly (0 fail) — confirming resource contention from ~5,300 tests sharing one container, not a code defect. To isolate what's actually pre-existing versus caused by this branch, the same full suite was also run on this branch's parent commit (d250afcf6, before any of these isolation fixes existed): onlyconnectors/codex/coverage-truthful.test.tsfailed there too, with the same 2 failures. That confirms this file alone is genuinely pre-existing and unrelated to this branch; the other two files are load-induced flakes specific to running the full suite concurrently, not caused by this branch's code, but also not something this reviewer's environment reproduces as a clean "0 failures" the way the isolation-specific files do.Round 11 changes
A second external reviewer looked at this PR at commit
ced8300beand found the Round 10 repair above was still incomplete in three ways. Full verbatim verdict:local/reviewer-274-278-263-0902.md(same gitignored scratch-notes convention as above). Four new commits fix all three, and — per the reviewer's explicit instruction —recorded_replaystays withheld this round too, pending a fresh independent review of these changes (the commit that writes a fix doesn't get to also certify it).The trusted-launcher fix (Round 10) covered
unshare/bwrapthemselves, but not the shell they hand their setup script to. Both tools were told to runsh -c '<script>'— a bare name, not an absolute path — soshstill got resolved through whatever$PATHthe calling process happened to have, before the script's ownPATH=line (which only protects commands inside the script) could matter. Confirmed live: a fakeshplanted earlier in$PATHran instead of the real one, skipping the whole sandbox setup while still reporting success. Fixed by resolvingshthrough the same fixed, absolute allowlist asunshare/bwrap, at all three places ash -cscript gets built.The pre-existing-socket scanner (added in Round 10 to close a socket-dial gap) silently treated "I couldn't read this directory" as "nothing here." A directory that's searchable but not listable (a real, separate Unix permission combination) hides its contents from the scan while a socket inside it stays fully connectable — confirmed live with a real socket under such a directory. The scanner now reports "couldn't verify" as its own explicit, distinct outcome, and that withholds the strong claim exactly like finding an actual socket does — never silently passes. Separately, that scanner only ran once, from outside the sandbox, before anything started; a socket could in principle appear after that single check. It's now also run twice more, from inside the sandbox itself, right up against the moment the connector code actually starts — this narrows the gap for a socket to slip in unnoticed to a few milliseconds instead of a whole run's duration.
The check that walks a read-only directory's nested mounts (also from Round 10) used a parsing shortcut that could miss real paths and couldn't check individual files. Linux's own mount-listing file escapes unusual characters like spaces in a path (turning a space into
\040), and the old code compared against paths without decoding that escaping first, so a nested mount at a path containing a space was silently skipped — confirmed live. The same check also tried to verify each nested mount by creating a test file inside it, which only makes sense for a directory; a single file bind-mounted on top of another file (a real, common Docker pattern) always failed that particular test for an unrelated reason, so it always got reported as "already read-only" whether or not it actually was — confirmed live: a genuinely still-writable file mount passed the old check. Both are fixed now: paths are properly decoded before comparing, and a file mount gets checked by trying to open it for writing directly instead.Round 11 verification (corrected): at reviewed head
4bf2997e6,isolation-mechanism.test.tsreported 80 total: 68 pass, 2 fail, 10 skip as root. The two failures are permission tests that require a real non-root user; the same file as UID 1000 reported 80 total: 34 pass, 0 fail, 46 skip.One honestly-disclosed limitation found while building these tests: running all three of the files above together in one process (rather than each on its own, which is how they're actually invoked) surfaces occasional additional flakiness beyond each file's own clean run, traced to the new inside-the-sandbox socket scan racing against other, unrelated concurrent tests that are creating and deleting their own scratch files in the same shared repository checkout — not a defect in the scan logic itself (a dedicated fix already closes the specific case where the scanning tool's own "path vanished mid-check" report was being treated the same as "found something," which was the main source of this). This is the same class of test-environment resource contention Round 10's own PR description already disclosed for the full test suite; a follow-up architectural change (a private snapshot of what the sandbox is allowed to see, taken once and never re-read from the live, shared filesystem) is noted in the code as the way to close this fully, but is out of scope for this bounded repair.
What this does not cover
This PR closes exactly the two blocking (P1-severity) defects the first-pass reviewer found against the merged tree at commit
ab415be6c— the launcher-trust gap and the partial-read-only-mount gap — plus the socket-dial gap the fixes themselves exposed mid-repair. It does not touch the product/runtime code the earlier PR #238 review already accepted — only the scenario-verification tool's evidence boundary.The original review also noted that a larger, separate release effort — spanning code in other repositories this organization maintains alongside this one, unrelated to isolation and outside this PR's diff — was still waiting on two other pull requests to merge. Both have since merged, so that larger release is no longer blocked on them. A third, unrelated item in that same larger effort remains open in draft form; it doesn't touch isolation and doesn't block this PR.
Round 12 changes
Before this change, a caller could place a silent program named
findinfilesystemBindPath, the writable evidence directory that the Bubblewrap sandbox exposes to the child, and put that directory first in the command-searchPATH. The Bubblewrap inner script then resolvedfindfrom that attacker-controlled directory during its Unix-socket scan, so a real pre-existing socket under the read-only repository bind was not reported and the child command ran. The inner script now first setsPATH=/usr/sbin:/usr/bin:/sbin:/bin, so itsfind,grep, andechocommands resolve only from the sandbox fixed system directories.postPivotVerificationStatements, the function that produces the final filesystem-closure checks, documented that its returned shell statements can run without a setup prelude but did not establish that same trusted PATH before its file probe invokessh. Its first returned statement now initializes the fixed PATH, so the documented standalone caller cannot replace that shell through its environment.A privileged-container regression test runs three controls from the reviewer: a real socket with a normal PATH exits 92; the same socket with a writable-directory fake
findfirst on PATH still exits 92; and, after removing the socket, the normal control exits 0 and the child writes its marker. A second test calls the post-pivot statements with a fakeshfirst on PATH and verifies that the fake shell never runs. I verified the regressions by temporarily removing each new PATH assignment: each removal made the corresponding test fail, and restoring the assignment made it pass.At
4bf2997e6, runpnpm --dir packages/polyfill-connectors testmeasured 5,375 tests: 5,286 pass, 21 fail, 46 cancelled, and 22 skipped. The branch parent measured 5,345 tests: 5,258 pass, 19 fail, 46 cancelled, and 22 skipped; the two additional failures are root-only permission-coverage cases inisolation-mechanism.test.ts, which pass as a non-root user. The prior68/70wording is corrected to the measured root result: 80 total, 68 pass, 2 fail, 10 skip. This round adds two passing isolation tests, whose current standalone results are 82 total, 70 pass, 2 fail, 10 skip as root and 82 total, 36 pass, 0 fail, 46 skip as UID 1000.scenario-verify-strict.test.tspasses 140/140 andscenario-cli.test.tspasses 51/51 in the same privileged environment. A later full package invocation exceeded the previous run duration, was stopped, and is not claimed as passing.recorded_replayremains withheld. The production flag that would allow that evidence claim remains hardcoded false; this repair proves the two reviewed PATH boundaries but does not independently certify the broader isolation evidence boundary.Round 13 changes
A mount point whose name contained a newline could leave a directory writable inside the sandbox while the sandbox's own safety check reported it as read-only. The mount enumerator — the code that reads the kernel's mount table and lists the nested mounts underneath a directory the sandbox is supposed to make read-only, such as the separate
/etc/resolv.confmount Docker injects inside/etc— was reading paths in a way that split one real path into two fake ones.Linux exposes the live mount table as a text file,
/proc/self/mountinfo, one mount per line. Because a filename may legally contain a newline, the kernel escapes such a byte as the four characters\012— a backslash followed by the byte's value in octal — so that a record still occupies exactly one line. The old code decoded that escaping first, turning\012back into a real newline, and only then passed the result through a pipe that splits on newlines. A mount at<dir>/a<newline>btherefore arrived as the two entries<dir>/aandb, and neither of those is a path that exists. Confirmed live before the fix: both entries came back as non-existent. Two callers share that reading code, and the consequence differs between them. The setup step — the phase that applies the read-only remounts while the sandbox is being built — tries to remount a path that isn't there and fails loudly. The other caller is the verifier — the check that runs after the sandbox has switched to its new root filesystem (thepivot_rootsystem call, which is why the code calls it the post-pivot check; it is one component, not two) and confirms every directory that should be read-only actually is. It probes the path for writability, and a probe against a non-existent path produces no output, which the verifier reads as "this mount is confirmed read-only." So the one shape that should have raised an alarm reported success instead.The fix reverses which form of the path travels through the newline-splitting stage. Raw, still-escaped records cannot contain a newline, by the kernel's own guarantee, so those are what get split into lines now; the unescaping happens afterward, once per record, inside the loop; and the finished path is handed to the remount and probe commands as a direct argument, never re-serialized into text that something downstream would have to split again. Decoding runs exactly once, left to right, which is what keeps a mount point literally named
\012(the kernel escapes its backslash, so it arrives as\134012) distinct from one that really contains a newline.The second defect was that a failure to read the mount table at all was indistinguishable from finding nothing. The old code piped the mount enumerator into a loop; when the enumerator exited nonzero, or the mount table was missing, the pipeline still reported success having looped zero times. Confirmed live: both cases exited 0. Setup then continued and the verifier passed, on the strength of an enumeration that never happened.
The mount enumerator now refuses to proceed without positive evidence that it worked. It captures its own exit status directly instead of through a pipe that discards it, rejects a file that ends mid-record, and requires a count of records that each passed a structural check against the documented mountinfo format. Counting lines would not have been enough: a file of pure garbage and a truncated file both produce a nonzero line count, so the structural check and the mid-record test are what actually catch them. The verifier also captures the exit status of its own read-only check. That check runs inside a shell command substitution, which yields a command's output rather than its exit status, so an enumeration failure there would otherwise vanish — the same swallowed-failure shape one level up.
Two details are worth recording because the obvious implementations of each are wrong here. The mount enumerator stages its intermediate result in a shell variable rather than a temporary file, because neither place this code runs is guaranteed to have a writable temporary directory. This sandbox is built two different ways depending on what the host allows — with
bwrap(Bubblewrap, a userspace sandboxing tool) or withunshare(the Linux utility that creates new namespaces directly) — and an earlier revision that usedmktemp, the standard utility for creating a temporary file, failed under both for reasons that had nothing to do with mounts: the Bubblewrap sandbox starts from an empty root filesystem with no/tmp, and theunsharepath's new root provides none either. And truncation is detected by inspecting the file's final byte rather than withRT, a record-terminator variable that exists in GNU awk but not inmawk, the awk actually installed in the target environment — anRT-based test would have silently passed on every input.The regressions run in a privileged container — one started with the Docker flag that grants the mount permissions these tests need — and use real bind mounts rather than hand-written fixtures, so the kernel's own escaping is what the code faces. Five mount points whose names contain a newline, a tab, a space, a backslash, and the literal text
\012are each asserted to arrive as exactly one path that exists on disk. A sixth case mounts<root>/pre-fixalongside<root>/preand asserts the first is not treated as living inside the second — the failure a naive string-prefix test invites, since<root>/preis a prefix of<root>/pre-fixwithout being a parent directory of it. Six more controls point the mount enumerator at a mount table that is unreadable, absent, empty, malformed, or truncated, or make it exit nonzero, and require each to block the sandboxed program from running and to fail the verifier.Both fixes were checked by reverting them. Restoring the decoded-path output fails exactly the newline and literal-
\012cases, and the failure output shows the same non-existent fragments described above. Disabling the fail-closed conditions fails all six runnable controls. The unreadable-file case turned out to be caught independently by two of those conditions and fails only when both are removed.In the privileged container,
isolation-mechanism.test.ts— the suite covering both of thebwrapandunsharemechanisms named above, which contains the regressions described here alongside the rest of this sandbox's coverage — reports 96 total: 83 pass, 2 fail, 11 skip. The commit this branch builds on measures 82 total: 70 pass, 2 fail, 10 skip. The 14-test increase is exactly the new regressions listed above, 13 of which run and pass; the one added skip is the unreadable-file control, which is skipped when the suite runs as root because root bypasses file permissions entirely and would make that control pass without proving anything. The 2 failures are the same two permission checks in both runs — they need a non-root user — so this change adds no failures.scenario-verify-strict.test.ts, the unit tests for the logic that decides which evidence label a run has earned (the strongest beingrecorded_replay, which asserts "this replay ran under proven OS isolation"), passes 89/89.scenario-cli.test.ts, which tests the command-line entry point that runs a scenario end to end, passes 50/51; the one failure is a timing-sensitive replay-pacing test that fails identically on the parent commit.One coverage limit is worth stating precisely. The five byte classes tested here are the four the kernel documents as escaped in a mount path (space, tab, newline, backslash) plus the literal-escape case; they are not a proof that no other byte reaches this code unescaped, and that broader claim is untested.
recorded_replay, defined above, remains withheld, and the flag that would permit it stays hardcoded false. This round closes the two reviewed defects in the mount enumerator. It does not close the broader isolation evidence boundary — the full set of properties that would have to hold before a run could honestly claim it executed under proven OS isolation, of which correct mount enumeration is one part among several. Closing that boundary would require an independent review of the whole sandbox, not just these two repairs, and nothing here should be read as certifying it.Assisted-by: AI