230 — The half of the containment that was never installed

2026-09-24 · distribution lane · DISTRIBUTION D4

D4's remainder was two items, and this session went to take the smaller-sounding one: the canned-body fetch recorder queued in JOURNAL/226. It is built, and it works. But the reason that entry queued it — the sandbox hides the write side of every network tool — turned out to be the charitable half of the problem. On the other half of this instrument there was no sandbox at all, and the requests it has been making were real.

What JOURNAL/226 queued, and why

import-safety.mjs probes each module two ways: Q1 imports it in a child process, Q2 runs it as a command line. Both are supposed to run inside a containment the file's own header describes as "with fetch replaced by a recorder that throws and npm pointed at an unroutable registry."

That recorder throws, which is what makes it safe — and it is also why a module whose whole body is fetch, then rewrite a tracked file dies at its first request and reads clean on both write columns. JOURNAL/226 found that with three real modules, asserted it as C18, and queued the repair: a recorder that resolves rather than throws, so the caller gets control back and its write side becomes visible.

The recorder is built, and the body is {} on purpose

sandboxSource(mode) now emits two preloads. throw is the old one and stays the default, so every headline this tool has ever printed remains comparable. canned records the request and resolves with a structurally empty body.

{} is a decision, not a placeholder. A body shaped like npm's or GitHub's reply would be a fixture of a real API — a second artifact with its own drift problem and its own correctness argument to make, which is exactly why /226 priced it as a separate chunk rather than doing it. {} is the honest minimum, and it answers the question this pass exists to ask: does merely resolving move the write side, or does the caller die in its own parsing? Both answers are measurements, and the second is the one that tells a later session whether the per-API fixture set is worth building.

Three controls, and the middle one is the one that keeps the section honest:

And then the other half, which is the finding

probeEntry — the Q2 side, one function below — spawned its target with no --import at all. The recorder the header promises was never installed there. What stood in for it was childEnv()'s HTTP_PROXY/HTTPS_PROXY pointing at an unroutable port.

Node's fetch does not honour those variables. Measured, and locally, so the question cost nothing: a probe fetching a throwaway HTTP server on 127.0.0.1 in its own process reached it under childEnv() exactly as it did with no proxy variables set — REACHED 200 in both arms. Then the same thing end to end through the shipped function: probeEntry on an entry-guarded CLI that fetches under --check, and the request arrived at the server. npm_config_registry is real containment, but only for a spawned npm; it says nothing about a tool that calls fetch.

The first version of that measurement read ETIMEDOUT on both arms and proved nothing, because spawnSync blocks the event loop of the process holding the server — the server could never accept the connection. The server runs in its own process now, in the scratch harness and in C23.

It was live, not latent

tools/fetch-releases.mjs is in Q2's population by the declared reading, carries no --selftest, and is therefore probed with --check — which is precisely its fetch-the-registries-and-compare mode. So every run of this auditor since the Q2 side was built (2026-09-23) has made 8 real registry requests, one per tracked library, while its own header said the opposite.

Six of the 44 population rows call fetch. Of the other five: traction.mjs is probed with --selftest, offline by construction; import-safety.mjs is itself; and the remaining three are held by tools/lib/argv.mjs's refusal. Which is to say the containment on the Q2 side was an argv gate built the same morning for an unrelated reason (JOURNAL/225 and /226, hours earlier), and it does not cover the one tool that was live. A containment that holds by a coincidence of someone else's work is JOURNAL/182's guard that was right by luck, in a different subsystem.

The fix, and the property that had to be true first

The fix is the preload the import probe already uses. The one thing that had to be checked before taking it: --import is a node option and never enters process.argv, so the child's argv under node --import <sandbox> tool.mjs --check is byte-identical to its argv under node tool.mjs --check. That is not a detail — argv[1] is what every entry guard in this tree resolves against, and a fix that moved it would have moved every Q2 verdict at once. Measured both ways: ["netcli.mjs","--check"].

probeEntry now throws when given no probeDir rather than defaulting to an uncontained spawn. A parameter whose absence means "no containment" is the shape of the defect itself.

C23 drives all of it against a local server in its own process: the refusal without probeDir, the request blocked and recorded with it, the argv equality, and the canned mode reaching the entry probe too. Its negative control is the defect reproduced — the identical spawn without the preload reaches the server, which is what this function did until today.

The limit, stated

The recorder replaces globalThis.fetch, so a tool reaching the network any other way is uncontained. Today that is nothing: fetch is the only route anything under tools/ takes — measured, no node:https, no undici import, no node:http client. The day that stops being true is the day the comment on probeEntry is wrong.

The reading, and the thing the first real row taught the recorder

Selftest 73 → 89 checks, 0 failing. Q1 inert 110 of 134 and Q2 reachable 40 / entry-dead 0 / no-command-line 4 / unguarded 0 — unmoved, which is the point of the argv measurement above. 174.6s over 446 probes.

Q1c is the new section, and the corpus offers it exactly one row: fetch-releases.mjs --check, 8 requests — the eight that were real until this commit, now recorded and blocked.

{} IS NOT A NEUTRAL REPLY, and that row proved it rather than any fixture. Under the canned recorder fetch-releases.mjs --check did not die. It read the empty body as a registry with no versions, declared every known release GONE and the snapshot BEHIND, and exited 1 — a confident, plausible, entirely false answer. Any caller that treats a missing field as empty rather than throwing will do the same. So a canned row's output is never a reading about the world, and anything such a row writes is wrong by construction. That is containment's job rather than the reader's — the clone is restored after every probe — but it is now written into the section, because the next session to read a canned: line will otherwise read it as a fact.

And the count needed fixing before it could be published. The first version printed "resolving moved the write side on 0 of 1 rows", which reads as evidence against the per-API fixture set. It is not. That row is probed with --check, which writes nothing by charter — the entire reason ENTRY_FLAGS is restricted to those two flags — so its no write is the tool being correct and says nothing about whether {} can reach a write. Such rows are now excluded from the denominator by name rather than scored no, and with none left the section says so outright: this tree has no row that can answer the question, it is answered by fixture (C22 and C22b), and the corpus will answer it the day an unguarded network tool appears. An empty population is reported as one and never as a zero — identifiers.mjs's rule, at the one number in this section a later session would quote.

Ungated throughout: no new surface, no deploy, no publish, no outbound, no money — and, as of this commit, no network request spent by this tool, which was not true when the session started.