Skip to content

fix(isolation): close the two P1s in scenario-verify's isolation evidence boundary - #274

Closed
tnunamak wants to merge 16 commits into
mainfrom
waspflow/isolation-r10-p1-0902
Closed

fix(isolation): close the two P1s in scenario-verify's isolation evidence boundary#274
tnunamak wants to merge 16 commits into
mainfrom
waspflow/isolation-r10-p1-0902

Conversation

@tnunamak

@tnunamak tnunamak commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What this fixes

scenario-verify is 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 main at commit ab415be6c. Their full report is saved as local/reviewer-238-merged-ab415be6c-0902.mdlocal/ 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:

  1. Launcher trust gap. The isolation code spawns two external programs, unshare and bwrap (both are Linux sandboxing tools — unshare creates new OS namespaces so a process gets its own private view of the filesystem/network/process list, and bwrap, 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 $PATH environment variable — the same list of directories the shell searches for any command. If an attacker could get a program named unshare or bwrap onto an earlier $PATH directory than the real ones (for example, by controlling $PATH in 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.

  2. 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 by remount,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.conf and /etc/hostname inside /etc — that remount,ro,bind command 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: PASS could be printed while the isolation it claims to prove either never engaged (gap 1) or engaged incompletely (gap 2).

The fix, commit by commit

01cbef87e withholds the strong claim entirely (falls back to diagnostic_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.

7c829403a fixes gap 1: unshare and bwrap are 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.

21dfe6d63 fixes 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's pivot_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.

fff750d27 closes 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 /usr or /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_replay is 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 /usr or /etc at all, and found none already there on a representative container image.

ced8300be is a small follow-up that same verification reviewer asked for: the recorded_replay: PASS line printed to the terminal was terse and didn't say what had been checked — a reader had to already know to go read claims.ts (the source file that decides which of the two claims, recorded_replay or diagnostic_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 bwrap and unshare for 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 calls unshare/bwrap need — 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, as local/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 in ced8300be above).

This PR further rebases that reviewed work onto current main — it now includes two unrelated fixes merged to main after 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/agent or /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_replay or diagnostic_replay — a run is eligible for, including the new socket-scan tests): 86/86 pass.
  • The full pnpm test package suite (pnpm is this monorepo's package manager and task runner; pnpm test runs every test file under bin/, connectors/, src/ — roughly 5,300 individual tests) is not cleanly 0-failures under concurrent load in this container, and the previous commit message on fff750d27 incorrectly 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 in isolation-mechanism.test.ts and connectors/slack/slackdump-runtime.test.ts (a stall-timeout test that is inherently timing-sensitive) alongside connectors/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): only connectors/codex/coverage-truthful.test.ts failed 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 ced8300be and 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_replay stays 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).

  1. The trusted-launcher fix (Round 10) covered unshare/bwrap themselves, but not the shell they hand their setup script to. Both tools were told to run sh -c '<script>' — a bare name, not an absolute path — so sh still got resolved through whatever $PATH the calling process happened to have, before the script's own PATH= line (which only protects commands inside the script) could matter. Confirmed live: a fake sh planted earlier in $PATH ran instead of the real one, skipping the whole sandbox setup while still reporting success. Fixed by resolving sh through the same fixed, absolute allowlist as unshare/bwrap, at all three places a sh -c script gets built.

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

  3. 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.ts reported 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 find in filesystemBindPath, the writable evidence directory that the Bubblewrap sandbox exposes to the child, and put that directory first in the command-search PATH. The Bubblewrap inner script then resolved find from 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 sets PATH=/usr/sbin:/usr/bin:/sbin:/bin, so its find, grep, and echo commands 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 invokes sh. 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 find first 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 fake sh first 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, run pnpm --dir packages/polyfill-connectors test measured 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 in isolation-mechanism.test.ts, which pass as a non-root user. The prior 68/70 wording 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.ts passes 140/140 and scenario-cli.test.ts passes 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_replay remains 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.conf mount 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 \012 back into a real newline, and only then passed the result through a pipe that splits on newlines. A mount at <dir>/a<newline>b therefore arrived as the two entries <dir>/a and b, 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 (the pivot_root system 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 with unshare (the Linux utility that creates new namespaces directly) — and an earlier revision that used mktemp, 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 the unshare path's new root provides none either. And truncation is detected by inspecting the file's final byte rather than with RT, a record-terminator variable that exists in GNU awk but not in mawk, the awk actually installed in the target environment — an RT-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 \012 are each asserted to arrive as exactly one path that exists on disk. A sixth case mounts <root>/pre-fix alongside <root>/pre and asserts the first is not treated as living inside the second — the failure a naive string-prefix test invites, since <root>/pre is a prefix of <root>/pre-fix without 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-\012 cases, 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 the bwrap and unshare mechanisms 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 being recorded_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

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
pdpp Ready Ready Preview Sep 4, 2026 12:17am UTC

Request Review

@tnunamak

tnunamak commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Fixed the writable-file submount probe truncation hazard.

  • probe_ro now opens file submounts in append mode (>>) without O_TRUNC and performs no write.
  • The regression test bind-mounts a real host file, requires the writable-submount verdict, and asserts the source bytes are unchanged.
  • New head: 4bf2997e62cea76738b5556b19b99dbb60ae6452

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.

…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
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>
@tnunamak

tnunamak commented Sep 10, 2026

Copy link
Copy Markdown
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

@tnunamak tnunamak closed this Sep 10, 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.

1 participant