Three reviewer blockers, all verified against source and by measurement before
acting on them.
C1 — the fixture's `tsx` was `apps/gateway/node_modules/.bin/tsx`, which is not
the TSX CLI but a pnpm-generated /bin/sh launcher that **exports NODE_PATH**
naming four repository module roots into the process it starts. A fixture whose
stated invariant is that the child receives no Node loader controls was shipping
a `tsx` that reinstated one. Resolving `tsx/dist/cli.mjs` directly deletes the
wrapper, the NODE_PATH injection, three coreutils symlinks and an off-PATH
package-layout symlink at once: PATH is now two names, `node` and `tsx`. Targets
are realpath-resolved and asserted to be executable files at module scope,
before the first child, and the symlinks derive from those checked values.
Observed child key set carries neither NODE_PATH nor NODE_OPTIONS.
C3 — the dev:local contract was a substring check followed by arbitrary shell
execution: the manifest case asserted the script *contained* src/local-dev.ts,
and the helper then handed the whole string to `sh -c`. "curl … | sh; tsx
src/local-dev.ts" satisfied it and ran. Now one module constant spelled once,
asserted with equality in the manifest case and re-asserted in the executing
helper against the value about to run. No metacharacter denylist — equality
already excludes them, and a denylist here would repeat the mistake the PATH
allowlist was built to correct.
C2 — the timeout bound is corrected rather than closed. `exec` removes the outer
shell, but the required route is itself multi-process: TSX spawns an esbuild
service child and a loader/eval child, and the latter is where route code runs.
Measured six out of six times under this exact spawn shape, that child survives
SIGKILL to its TSX parent, is reparented, and completes its work afterward —
while sandbox teardown removes directories it still holds. So exact equality
plus `exec` prohibits *injected* shell content and does not close the gap; the
launcher the contract *permits* is the one that leaks. The timeout control is
named and commented for the single-process property it actually measures and is
not offered as containment evidence.
Containment design is at architecture adjudication and is deliberately not
implemented here: every process in the TSX tree runs in the verifier's own
process group and session, so the obvious repair would signal the test runner.
RED unchanged: 83 failed | 17 passed -> 83 failed | 18 passed (101), the one
added case being the timeout control passing as a harness control. Prettier,
tsc and ESLint clean; zero /tmp/kbn-02a-* residue; casts unchanged at six.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
PATH was the last part of the child environment still built as a denylist:
the two workspace node_modules/.bin directories, dirname(process.execPath)
and the shell's directory. Measured on this host that is 4,093 distinct
executable names, and apps/gateway/node_modules/.bin carries real agent
runtimes this repo depends on directly — pi (@mariozechner/pi-coding-agent),
pi-ai, openai, anthropic-ai-sdk. The comment on that construction claimed it
prevented an unrelated same-name executable from answering, which is the
resolution it permitted.
PATH is now one fixture-owned directory in the per-run sandbox holding only
symlinks: node, tsx, and dirname/sed/uname (the pnpm tsx launcher is a
/bin/sh script that calls all three to locate its own entrypoint). The
launcher's relative ../tsx/dist/cli.mjs lookup is served by one directory
symlink inside the sandbox, off PATH. The shell is invoked absolutely and
needs no entry; env needs none because the shebang invokes it absolutely.
The fixture control now enumerates the directories on the child's observed
PATH and asserts the reachable executable names EQUAL the allowlist. The
seven harness-name assertions are kept and commented redundant-by-design: a
name enumeration finds pi and misses the other three actually present, which
is the empirical argument against the denylist shape. Symlink targets are
asserted to resolve to the exact expected binaries.
Measured: 4,093 -> 5 reachable names; `command -v pi` resolves the real
.bin/pi under the old construction and is not-found under the new one;
`tsx --version` still succeeds on the narrowed PATH.
Bounds recorded rather than overclaimed: this narrows command resolution,
not module resolution — the tsx launcher exports NODE_PATH naming four real
repository module roots, the last exposing 775 packages including the same
agent runtimes. And the shell-arm key-set measurement describes the
control's own arm, not the GREEN route.
RED unchanged: 83 failing / 17 passing (100 cases, 6 files), no Errors row,
zero /tmp/kbn-02a-* residue, dev:local still absent.
Three review rounds found the same defect in this guard: the value validated
and the value used came from two different reads. Each repair narrowed the
window and the next round reopened it -- a Proxy, then a getter replacing
Object.keys mid-enumeration. The single-entries-array form closes both, and a
fourth round showed it still is not a proof: the helper calls Object.fromEntries,
map and filter after the enumeration, and a getter can replace any of them.
That is not closable here. These cases share a closure with the guard, so an
author who can patch a global can patch the guard. The escalation is what was
wrong -- the previous round added a caller-supplied callback to the one spawn
site purely to make a mutation proof read better, and it had to be reverted.
So the claim is bounded rather than narrowed again:
- Freeze ALLOWED_CHILD_ENV, DECLARABLE_CASE_ENV_NAMES and SHELL_GENERATED_ENV.
The name list was mutable; a case could push a forbidden name before
declaring it. Verified: the push now throws and the spawn is still rejected.
- Say what the guard does. It rejects an ambient copy or a stray name passed
as a declaration, which is the mistake a maintainer makes by accident. The
comments claimed it closed the accessor gap; they no longer do.
- Correct the bootstrap() comment: main.ts constructs a logger before the
secret check. The load-bearing claim -- the throw precedes any config read --
is unchanged.
Document corrections, all found by review of claims written in earlier rounds:
- Withdraw "no second call to any replaceable global stands between the check
and the use". False, and stated while correcting a different overclaim.
- Both exploits are rejected, but by different assertions. The ambient copy is
named by the offending-keys message; the Proxy is stopped one assertion
earlier by isProxy, whose message names no keys.
- The sandbox is created in spawnHermetic before the spawn call, not inside
the spawn arguments. main.spec.ts:58 cleans up in a finally rather than by
registration, outside the kbn-02a- prefix the residue count measures.
- The package.json RED does not execute the route yet: the script is absent, so
the case stops on the missing manifest entry before any child is created.
- Three route cases, not two. Four XDG_*_HOME roots plus XDG_RUNTIME_DIR, not
"five XDG home roots". Not every failure is behavioral -- the structural
source guards are counted in the same 83.
RED unchanged: 83 failing / 17 passing (100 cases, 6 files), no Errors row,
zero /tmp/kbn-02a-* residue, fixture control green. Typecheck, lint, Prettier
clean.
The dev:local route case built its child environment as a denylist — copy the
ambient process.env, delete three keys — leaving the real HOME, every XDG root,
MOSAIC_GATEWAY_HOME, service endpoints, auth/credential families, the Node
loader controls and an ambient PATH in place. A faulty GREEN could read real
host state before an assertion caught it, and a refusal could be produced by
the host's own configuration rather than by the route, so the case would pass
while proving nothing about the code under test. The hard timeout bounds how
long a leak runs; it does not prevent one.
The child now receives exactly eight variables built from scratch — a private
HOME under a per-run temporary sandbox, all five XDG roots and TMPDIR beneath
it, and a PATH built from the two workspace .bin directories and the directory
of the running Node binary — plus the per-case declarations. Nothing is
inherited.
Two cases hold that rather than assume it: a fixture control asserting the
constructed key set, that HOME is neither the real home nor an ancestor of it,
and that PATH is the three intended directories (green by construction, a
harness negative control, not coverage); and an executed case that plants a
complete valid local declaration and a forbidden DSN in the parent environment
and requires the undeclared refusal — a leaky fixture would fire the DSN
refusal instead.
RED only. 83 failing / 17 passing (100 cases, 6 files), Errors 0, zero
/tmp/kbn-02a-* residue. No GREEN, no push, no 17th fenced path.
Second AMEND from the verifier. Five blockers, all inside the 16-path fence; no
new path taken and no GREEN implemented.
1. The run carried an out-of-band `process.exit unexpectedly called with "1"`,
raised by main.ts:71 self-invoking on import and rejecting after the importing
case had returned. Vitest attributes that to no case and warns it can produce
false positives; it also falsified the document's "zero non-assertion
failures". The import is now wrapped so `process.exit` is stubbed
non-throwing and the case awaits a settle turn — the behavioral red is
unchanged, the error is owned, and the run reports `Errors 0`.
2. Two inventory cases demanded the raw values 'standalone' and 'postgres',
which the value-free classification unions cannot represent, so no compliant
implementation could have turned them green. They assert 'non-local' and
'non-pglite'.
3. SourceReadability gained `absent`. Without it an absent source has to be
reported `readable` — the one value meaning "read, and clean" — collapsing
the distinction the refusal path is built on. The invariant
`present === false` iff `readability === 'absent'` is now asserted per state
and across all seven rows at once.
4. Acceptance #6 had no positive anchor: an empty `bootstrap()` and an
always-refusing `onModuleInit()` satisfied every case, so "safe" could have
meant "does not work". Two cases now fail against that — bootstrap must reach
NestFactory.create and listen under the frozen policy with zero independent
read, and the sterile local lifecycle must actually run both migrations with
no Postgres handle. Both in-process and substituted at their seams; no port
is bound and nothing connects.
5. Every mkdtemp site in the six files now registers teardown at the point of
creation, including the child route's root created inside the spawn
arguments. The matrix leaves zero /tmp/kbn-02a-* residue, measured.
Focused matrix: 82 failing / 16 passing (98 cases, 6 files), Errors 0.
Typecheck, lint, Prettier, diff --check clean.
Author phase only; still no implementation. Kept as a distinct fifth commit so
the RED baseline, the contract restoration, the span audit and this supplement
remain separately reviewable.
The finding that mattered: every frozen-policy case mocked ./main.js wholesale,
so nothing executed main.ts, AppModule or the ConfigModule provider, and the
three independent reads at main.ts:28, app.module.ts:39 and config.module.ts:12
could all have survived a fully green matrix. Fence revision 15 -> 16 (granted
solely for apps/gateway/src/main.spec.ts) is used to import the real modules
with no ./main.js mock and assert no independent loadConfig, no tier probe and
no NestFactory.create. The existing anchoring case is left red-in-place rather
than deleted: its import-time-exit premise is the opposite of the new contract,
so it must be migrated by whoever implements GREEN.
Also closes: the executed dev:local child-process route, which requires a
LOCAL_START_* code AND the absence of the BETTER_AUTH_SECRET throw, since a
nonzero exit alone is satisfied by a crash; the real database onModuleInit
lifecycle, asserting migrations never run; the full disposable state-root
contract (absent, not-a-directory, symlink, non-private, foreign-owner), with
ownership substituted at lstat because a filesystem-built case passes trivially
wherever CI runs as root; unreadable and malformed sources, which refuse rather
than being skipped, built as EISDIR so they cannot be vacuous under any uid;
per-source non-local tier detection for all seven labels; canary retention and
console cases; post-freeze source-mutation cases; and a three-form static-import
guard.
Two defects of my own are fixed rather than papered over. The non-canonical
state-root case normalized to the valid path before the policy saw it, so it
demanded a refusal on input identical to the accepted one — a red that could
never go green for its stated reason. And the first substitution of @nestjs/core
was wholesale, which dropped APP_GUARD and killed app.module.ts at import, so
two cases failed for a reason they did not state.
Classification types are unions, not strings: a declaredTier?: string is
value-free only by convention, and a two-member union cannot carry a canary.
77 failing / 15 passing (92 cases, 6 files). Typecheck, lint and Prettier clean.
Zero non-assertion failures. Seven of the fifteen greens are vacuous against the
stub, up from three; that rise is recorded in the document so it cannot later be
read as coverage. The first RED report's implication that the GREEN targets were
covered behaviorally is corrected there too.
Refs: KBN-101-02a (Stack #1178)
Scrappy's ruling, verified independently at
docs/scratchpads/771-kbn101-db-role-split.md:98, which records
apps/gateway/Dockerfile explicitly as 'a planned KBN-101-05 artifact'.
The row names something intended to exist, not a mis-naming of
docker/gateway.Dockerfile, so it is not the inverse defect I filed it as.
The correction sharpens the proposed control: a link check failing on any
absent ownership path would have flagged this row and been wrong. The
check needs a planned marker, and an unmarked absent path is the failure.
Negative existence evidence retained; classification corrected.
Commissioned with -02a on the board ruling. Four findings: the -02
local-start hold is carried by eight files not five (AGENTS.md and
README.md among them, so -02a's completion surface is wider than its
record); a candidate defect of the same shape at -05, whose scope row
contains neither 'process-exec' nor 'LoadCredential' though three guides
condition deployment on that interface; a negative result confirming the
-00/-03/-05 activation hold spans correctly; and the structural hole that
makes all of them unverifiable, whose inverse form is the -05 rows naming
apps/gateway/Dockerfile, a path that does not exist.
Additive to section 10 only. No other item's rows altered; the Dockerfile
discrepancy is reported, not corrected — that row is -05's.
b4c468b3 wrote the -02a path-ownership manifest OVER the frozen KBN-101
contract (#774, #902) rather than appending to it, deleting 272 lines
including the KBN-101-05 exclusive-ownership row, the K101-REQ-04
traceability row and the -03 runner/manifest contract.
Sections 1-9 are restored byte-identical to 216cd722. The -02a amendment
is re-added as additive section 10 with its headings demoted. No other
item's ownership rows are altered.
Self-reported to the verifier; the defect class is the same span/scope
failure -02a exists to close.
Author phase only. No implementation: the scaffolding is deliberately
permissive so every refusal assertion fails behaviorally rather than on a
missing module. A `not implemented` throw would have satisfied `.toThrow()`
and produced a green matrix that proved nothing.
Adds the path-ownership manifest and 60 behavioral cases across the nine
mutation->guard rows: inherited env, shared dotenv, daemon JSON, app JSON,
root JSON, MOSAIC_GATEWAY_HOME redirect, exact local tier, exact PGlite plus
disposable state root, and one-shot freeze with dynamic import.
50 red / 10 green. Zero non-assertion failures. The ten greens are itemized
in the manifest, including three that are vacuous until the implementation
exists.
Enforcement sits at the apps/gateway seam. Nothing in packages/db or
packages/config is touched: -02a proves the local entrypoint never reaches
those paths rather than modifying code owned by -01, -03 and -10.
Refs: KBN-101-02a (Stack #1178)
First-pass structural reference for the Vite SPA migration (apps/web): dual-app
tree during migration, shared lib/ networking layer, origin-relative/same-origin
serving model, build scripts, the P1-P6 increment map, and the #1145 P5 blocker.
Living doc — details to be fleshed out by follow-up.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
Docs-only plan PR. FRED_APPROVED_REF=0629361ca39a4dd7fb3e575d11c64bea9e545dae (review 147). Merged by fred (orchestrator) via API: pr-merge.sh policy predates the next lane (main-only hardcode) — wrapper fix tracked separately.
Co-authored-by: Velma <[email protected]>