Repository navigation
Tests: stateDir() refuses the real state dir in a test run; a lint that tests guard machine paths with onRealMachine (#815) [PROTECTED: owner merges] - #818
Conversation
…chine, as slotsDir() does #815. stateDir() fell through WORKLANE_STATE_DIR, then XDG_STATE_HOME, then the home, and nothing refused in a test run: a test file run without the preload, or an engine child given a real HOME, resolved the person's own state dir. It now throws TestStateRefused first (testStateRefusal), by the same rule as the slot dir (#669): only a directory inside the test machine, which must hold the preload's system root and its mark for this run. testSlotsRefusal and testStateRefusal share that code; the slot refusal's text is unchanged. A service mode a test starts runs without the system root (serviceModeEnv), so the machine shows it is this run's with a mark of its own (the same token, test/isolate.ts writes both). Only the state dir takes it; the slot dir still needs the root. serviceModeEnv keeps the token for that: without a root it moves nothing. Outside a test run nothing changes: the refusal returns before looking at anything. Test (fails without): test/state-dir-refusal.test.ts. Each fall-through (WORKLANE_STATE_DIR outside, XDG_STATE_HOME outside, HOME a stand-in real home) runs in a scratch child behind fs and child_process wrappers that refuse any path outside the test machine, with positive controls. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
… onRealMachine first, and the guards it found #815. owner-terminal-removed.test.ts wrote and removed MACHINE_PATHS.hubUnitFile and CONSOLE_SHELL with nothing checking they were the test machine's. The class: a lint over test/, through the compiler's symbols, that finds every call that creates, writes, moves or removes a path (node:fs, node:fs/promises, or a src/ or test/ function that writes one of its parameters) whose target traces to a machine location in src/ (anything built from systemPath on every branch: MACHINE_PATHS.*, SYSTEM_SLOTS.*, CONSOLE_SHELL, etcPath(), storeDir(), ...), through locals, later assignments, for-of variables, array callbacks, destructuring and test helpers. Each needs an `onRealMachine(x);` statement on the same name before it, in its block or one around it. The state and slot dirs can't take onRealMachine (neither is in the system root): their resolvers refuse in a test run, and the lint checks they still do. A write of what onRealMachine returns (the real path) is reported too. It found 45 unguarded writes in five files: owner-terminal-removed (7), managed-apps (25), init (1), plugin-approval (5) and plugin-run (7). Each now calls onRealMachine first. The two new tests are listed in engine-guards (review.yaml). Test (fails without): test/machine-path-writes.test.ts. On the tests before this commit it reports those writes; its fixture cases cover each form it traces and each guard it refuses. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…ads O_RDONLY opens as reads, and follows a parameter's default #815, after merging main (#806). test/slots.test.ts guards devSlotsPath() with assert.equal(onRealMachine(...), ...): that call runs whenever its statement does, so it counts as a guard. Behind a condition, a short-circuit, an optional call or in another function it still doesn't. An open whose flags name O_RDONLY and no write flag (readRegularFile, through src/slot-file.ts since #806) is a read, not a write. A parameter's default (`(p = CONSOLE_SHELL) => rmSync(p)`) is traced like an argument. Test (fails without): test/machine-path-writes.test.ts. On the merge before this commit, the lint over test/ reports test/slots.test.ts:91 (a guarded write). New fixture cases: a guard in a const's initialiser, a short-circuit and an optional call that are no guard, a parameter's default, a destructured name. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: CHANGES NEEDED (round 1)Reviewed head 55343d3 against #815. I built it in my own worktrees (head, origin/main 128453a with the new tests, and head with the five guarded files put back to main), with a scratch HOME, state dir and held-out dir and a stand-in Acceptance criteria (#815)
Fail-without (measured)
Production unchanged (measured)In a child with an env built from scratch (no test-run sign, stand-in HOME), I compared Attacks on the refusal (measured, with stand-ins, by calling
|
| Attack | Result |
|---|---|
| Each fall-through outside the machine | Refused |
<machine>/u/state/../../.. |
Refused |
<machine>/ (the machine itself) |
Refused |
<machine>/../<machine name>/u/state |
Allowed (correct: it is inside) |
| Relative dir, cwd outside / inside | Refused / allowed. The engine resolves it against the same cwd. |
| Root set but its mark has the wrong token, machine mark right | Refused. There is no fallback to the rootless mark. |
| Root outside the machine | Refused |
| Rootless: mark 0644, mark a symlink, machine a symlink, machine group-writable, another uid | Each refused |
| Rootless: another run's machine with this run's token | Refused |
| A symlink inside the machine pointing outside (dangling stand-in, never followed) | Allowed, for both state and slots (the gap the builder named) |
Forging, rootless: a stray 0755 dir (a stand-in home) plus .test-isolation holding the env's token |
Allowed. One file is enough. |
| Forging through the root route | Needs a root dir inside the stray dir as well, then allowed (the existing, documented threat model) |
Rootless with INVOCATION_ID set |
Allowed. The root route refuses it, through systemRootRefusal. |
| Not a test run, any dir | Allowed, and nothing looked at |
On the service-mode exception (principle 9). The rootless machine mark is an exception added to the shared guard outsideTestMachine, whose rule needs the system root and the root's mark. So it is a loosening by principle 9, even though main has no state refusal at all.
- It reopens nothing main had closed. The slot refusal is byte-identical and still needs the root (measured, by the PR's test and my attack run). Passing the refusal only returns the dir the env already names (inferred).
- Keeping
TEST_TOKEN_VARinserviceModeEnvwidens nothing else. Without a root, the token is read only bytestRunSign, whichTEST_RUN_VARalready satisfies, and by the new mark check (grep of src/, inferred). - The token comes from
randomBytes(32)per preloaded process (test/isolate.ts:89). Every child that inherits the env can read it. A child could plant a mark anywhere it can write, but only on purpose: no code in src/ or test/ writesTEST_MARK_FILEoutside a machine of its own (grep, inferred). - The PR body explains why service modes need it, but it doesn't present this as a loosening under principle 9. See R4.
Attacks on the lint (measured: fixture text fed to the PR's own lintFixtures, nothing in the repo edited)
Reported (correctly): the unguarded control, a guard in a try block whose throw is swallowed with the write after the try, spread arguments, and a callee chosen by a conditional.
Missed, and not in the builder's gap list:
for (const _ of [] as string[]) onRealMachine(CONSOLE_SHELL); rmSync(CONSOLE_SHELL);: a guard in a for-of body counts for a write after the loop, even when the loop runs zero times.for (const f of [CONSOLE_SHELL]) { if (f) break; onRealMachine(f); } rmSync(CONSOLE_SHELL);: same cause.ok ||= onRealMachine(CONSOLE_SHELL);andok2 ??= onRealMachine(...): logical assignments are short-circuits, butguardOfcounts them as plain operands.writeFileSync(asRoot.etc('x.json'), '')andmkdirSync(join(SYSTEM_ROOTS.varTmp, 'agent-slots')): these are the real, never-moved paths, and nothing is reported./var/tmp/agent-slotsis writable by any user.- Tracing gaps:
Promise.resolve(X).then((p) => rmSync(p))Array.from([X], (p) => rmSync(p))- a generator helper (
function* g() { yield X }, thenfor (const f of g()) rmSync(f)) - a destructuring assignment (
[d] = [X]; rmSync(d)) openSync(X, O_RDONLY | W)withW = O_WRONLYheld in a variable
Missed but already listed by the builder: a shell or child process, and a guard on another instance of the same function (etcPath('a') covering etcPath('b')).
The 45 guards: each is a bare onRealMachine(x) statement, which is pure unless it throws. Every affected file passes at head (measured below), so none changes test behaviour.
Rule 10, scope, protection
- Rule 10: no test or assertion is removed or weakened (diff read).
.worklane/review.yamlonly adds the two new tests to engine-guards, which is a tightening. Removingdelete out[TEST_TOKEN_VAR]belongs to the loosening above (R4). It is not a separate weakening. - Scope: the lint is the widened scope the owner asked for in the issue comment. The rootless mark is a design addition the issue didn't ask for (R4).
- Protection: the PR touches
src/os/**,.worklane/review.yaml,test/isolate.tsandtest/managed-apps.test.ts, which fall under engine and engine-guards in main's review.yaml. It is correctly marked: the[PROTECTED: owner merges]title,**Risk class:** protected, theneeds-ownerlabel, and "What this can never do" and "Compromised component" sections. One "can never do" claim is false: "Pass the lint with a guard that doesn't run" (lint misses 1 to 3 above, R1). - DCO and attribution: the three non-merge commits are signed off, and there are no attribution trailers or lines in the commits or the body (read). The
dcocheck passes (measured). - Adversarial pass section: present.
Tests, typecheck, denylist, CI (measured)
- Head, with a scratch HOME, state and held-out dir: state-dir-refusal 4/4 and machine-path-writes 6/6.
fullsuite.sh(418 files, mcp-* and plugin-fetch left out): 3713 pass, 3 fail, 57 skipped.- The 3 failures are bench (a wall-clock cap), comment-policy (
(( x 100000: 1164 ms) and dashboard-speed (read ECONNRESET), all under load. - Rerun alone, bench and comment-policy pass. dashboard-speed fails the same way on origin/main (measured), so it is environmental.
- isolate, slots, slot-path, slot-dir-writes, update-drain, hub-setup, dashboard-hub, owner-terminal-removed, managed-apps, init, plugin-approval and plugin-run all pass.
- The 3 failures are bench (a wall-clock cap), comment-policy (
npm run typecheck: clean.npm run check:denylist:denylist: 1307 files clean.- CI on 55343d3 (run 39280440743, pull_request): every check passes. bootstrap, bootstrap (windows-wsl) and full-ci were skipped.
- merge-tree (
--write-tree, read-only):- Clean with origin/main, Merge: a PR that removes or weakens a test, lint or guard waits for the owner (#783) [PROTECTED: owner merges] #820, Wizard: the Claude step matches the login to agent_runtime and offers an API key (#738) [PROTECTED: owner merges] #819, First run: a card follows the first task from File one to its PR; the Start card counts only steps before Start (#741) [PROTECTED: owner merges] #816, Approvals: one role rule on every surface, decide only as root or the coordinator user, money paths read from main (#715) [PROTECTED: owner merges] #805, Engine updater counts a required check only from its pinned workflow or App [PROTECTED: owner merges] #789, Project commands run as the agent user only in a sandbox that hides its credentials; their output is redacted (#713) [PROTECTED: owner merges] #787, docs/design: amend #698 decision 1: turning auto_merge on is root-only; off stays on every surface [PROTECTED: owner merges] #775, Dashboard policy changes go through root's helper (set-policy), never a loosening (#726 part 2) [PROTECTED: owner merges] #770 and Single machine: policy.yaml is root's, and the engine refuses one that isn't (#726 part 1) [PROTECTED: owner merges] #769.
- CHANGELOG.md conflicts with doctor prints a paste-ready required_check_sources block; a new project's config starts pinned (after #788) [PROTECTED: owner merges] #803, Pin this repo's required checks to the workflow files that post them (after #788 and the engine upgrade) [PROTECTED: owner merges] #794, Required checks count only from the App or workflow pinned for their name [PROTECTED: owner merges] #788 and Test runs: each run in a user namespace of its own; slirp4netns as the test user; a run can't join Docker's namespaces (#545) [PROTECTED: owner merges] #780. Remove the desktop app, its source, Tauri lockfile and docs; keep the desktop check one more release as a no-op (#744) [PROTECTED: owner merges] #746 conflicts in CHANGELOG.md, scripts/setup/lib.sh, test/engine-update.test.ts and test/update-checks.test.ts. This PR touches none of those files, so it can't cause those conflicts (inferred).
- No open PR adds a test line naming a known machine-location builder (grep of added test/ lines, inferred). The lint will say for sure once each merges.
Required changes
-
R1: close the lint's guard holes, or the "can never do" claim is false.
- A guard inside a for-of body may count only for writes inside that same for-of. Climbing out of a for-of that doesn't hold the write must return false.
- Treat
||=,&&=and??=as short-circuits inguardOf. - Add fixtures for the empty loop, the break before the guard, and both logical assignments.
-
R2: report writes of the never-moved real paths. Treat a value built from
asRoot.*orSYSTEM_ROOTS.*(src/os/system-paths.ts) likeonRealMachine's result,{ real: true }, with fixtures. No test imports either today (grep), so nothing existing should turn red (inferred). -
R3: name the tracing gaps. Add
.thenand other non-array callbacks, theArray.frommapper, generators, destructuring assignments and anO_RDONLY | <variable>open to the lint's "Not caught" header and the PR body's gap list, or trace them. -
R4: a principle-9 section in the PR body for the rootless machine mark. It must say:
- The check it exempts:
outsideTestMachine's requirement of the system root and its mark. That includes systemRootRefusal's refusals as root and under systemd markers, which the rootless path doesn't apply (measured: allowed withINVOCATION_IDset). - Why that check's finding would be wrong for a service-mode child: the root is withheld by design (serviceModeRefusal), while the child's state dir is still inside the test machine.
- That the machine mark can be forged with one file, on purpose only, under the existing accident threat model (measured above).
- That keeping the token in
serviceModeEnvexists only for this exception.
If the builder can make the rootless path also refuse systemd markers and uid 0, as the root path does, that narrows it further and should be stated.
- The check it exempts:
-
R5: fail closed on a symlink inside the machine (principle 1). In
outsideTestMachine, lstat each existing component from the machine down todir, only ever inside the machine and stopping at the first missing one. Refuse if any is a symlink. That keeps "nothing outside the machine is opened". Applying it to the slot dir too is a tightening. The case is real on macOS, where HOME isn't moved, so the real home is reachable from a test. Add a stand-in test with a dangling symlink. -
R6: correct the lint fail-without count. Measured at head's lint: 44 findings, with plugin-approval at 4, not 45 and 5. Correct the body, or show where the 45th comes from.
Owner trade-off
The rootless machine mark (R4) is a new loosening by principle 9. My assessment (inferred, backed by the measurements above): it reopens nothing main closed, the slot refusal is unchanged, and an accident can't pass it. Only a test that deliberately plants a mark gets through. Whether to accept the exception is the owner's call.
… the rootless mark never as root or under systemd #818 round 2 (R4, R5). In a test run, outsideTestMachine now lstats each part of the dir that exists inside the test machine, from the machine down, never following one and never looking outside the machine, and refuses a symlink among them: a link in the machine can't lead a test to the person's own state or slots (principle 1). A tightening for both dirs. The service-mode exception (the machine's own mark, state dir only; owner-accepted 2026-10-19) now refuses as the root's mark does: as root, and in a process systemd started (INVOCATION_ID, JOURNAL_STREAM). Test (fails without): test/state-dir-refusal.test.ts. On 55343d3 the symlink test fails ('a link out, then below it') and the rootless case with INVOCATION_ID set is allowed. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…first, and reports the never-moved real paths #818 round 2 (R1 to R3). - A guard counts only from a list of statements that holds the write, directly or through plain blocks: never out of a loop body, an if or else branch, a try, catch or finally block, another case, a labelled block or a function. The one loop let through is a for-of over a non-empty array literal whose body never breaks, continues or returns. Logical assignments (||=, &&=, ??=) are short-circuits. - asRoot.* and SYSTEM_ROOTS.* are the real machine's paths, reported like onRealMachine's result. - Traced: a callback's parameters as anything its call is given (.then, Array.from, new Promise), a generator's yields, destructuring assignments. An open is a read only when its flags are node's read-only constants (by symbol); anything else is a write. Test (fails without): test/machine-path-writes.test.ts. Fed to 55343d3's lint, the round-2 fixtures miss 8 control-flow shapes (empty loop, break, the other branch, ||=, &&=, ??=, continue, spread) and 8 traces (asRoot, SYSTEM_ROOTS, .then, Array.from, a generator, two destructuring assignments, flags with a variable). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…function declaration #818 round 2, adversarial pass. A function declaration is hoisted: a guard before it in its scope may run after the function is called (early(); onRealMachine(X); function early() { rmSync(X) }). A write inside a function declaration below the guard's statement list no longer counts that guard. No test in test/ relied on it (the lint over test/ passes). Test (fails without): test/machine-path-writes.test.ts, the s25 fixture (and fixture-b line 13 is now reported). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 2: answers to the round-1 review (head 692368f)Commits: ebf6dab (src: symlinks, rootless refusals), 7e89673 and 692368f (lint). The PR body has a new "Round 2" section and a principle-9 section, and its false claims are marked "false through 55343d3".
Fail-without (measured; the round-2 tests run in a scratch worktree at 55343d3, never committed):
Adversarial pass, round 2 (measured, fixtures fed to
Local (measured, scratch HOME, state, held-out dir and TMPDIR):
CI on 692368f: 25 pass, 2 skipped, none failing (measured, merge-tree at 692368f: clean with #787, #805, #769, #819, #820 and #816. #746 conflicts in CHANGELOG.md, scripts/setup/lib.sh, test/engine-update.test.ts and test/update-checks.test.ts; origin/main has the same conflicts with #746. |
Evaluator verdict: CHANGES NEEDED (round 2)I reviewed head 692368f against #815 and the round-1 review (issuecomment-6272854895). I used my own worktrees: the head, origin/main 128453a, and 55343d3 with head's new tests on top. Every run had a scratch HOME, state dir and held-out dir, with a stand-in Every refusal attack used fixtures made under the preload's test machine. Each "outside" path was a name that was never created, and only the refusal itself ran on it. The refusal lstats only inside the machine. Nothing read, statted or wrote a real state dir, R1, R2, R3, R4 and R6 are closed (measured). R5 is closed for a symlink at any depth, but not for a symlink followed by Round-1 required changes
Fail-without (measured)
Attacks on the new rules (measured: fixture text fed to head's
|
… run; the rootless root and systemd checks come first #818 round 3. linkInside walked the path after resolve(), which folds `link/..` away by text, while the kernel goes up from the link's target: `<machine>/u/<link>/../slots` passed the check and a raw mkdir of it (as src/slot-file.ts makes slot dirs) landed beside the link's target. A dir with any '..' part is now refused, for the state and the slot dir alike (fail closed; join() already folds the engine's own paths). The rootless path's refusals as root and under systemd now run before the machine is looked at, so the uid-0 case is reachable and tested (the machine-owner check said no first before). Test (fails without): test/state-dir-refusal.test.ts. On 692368f a `<link>/../state` dir is allowed, and the uid-0 rootless case is refused by the machine-owner check, not as root. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…surely is; a for-of let assigned in its body is traced #818 round 3. A guard's names were every location its argument may be, so a guard on `n ? X : Y`, on a local reassigned from X to Y, on a reassigned for-of `let`, or on a `yield` element counted for X while it ran on another path. A guard now counts only the locations its argument surely is: every branch and every value a local is given must agree, and a yield, an index or a parameter is surely nothing. A for-of variable never assigned in its body is each element in turn. Writes through a for-of `let` that its body assigns are traced too. Test (fails without): test/machine-path-writes.test.ts, the m1 to m5 fixtures. Fed to 692368f's lint they are missed (measured in round 3). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
29e8fc5 split the dir with String.prototype.split, which handed the slot dir to a String method: test/slot-path.test.ts's A5 check (#806: no patched String or Array method ever sees the slot dir) failed (`[ 'split' ]`). The check now tests a RegExp against it instead, with the same result. Test (fails without): test/slot-path.test.ts, "a patched String.prototype or Array.prototype method is never handed the slot dir" (fails on 29e8fc5 with [ 'split' ]). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
#818 merged with main d4b4503, with the dashboard UI built, packs to size 1,903,458 and unpacked 5,940,612 (npm pack --dry-run --json; main alone: 1,902,158 and 5,934,411, already over 1,900,000). Only two files grew: dist/src/os/system-paths.js +5,609 (the state-dir refusal, the machine's own mark, the symlink and '..' checks) and dist/src/os/index.js +592 (stateDir's refusal). Measured + 2%, rounded up to the next 10,000: 1,950,000 and 6,060,000. Test (fails without): test/package-files.test.ts, "the package stays within its size budget", on this tree with the old budget (1,903,458 > 1,900,000). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 3: answers to the round-2 review (head 5a20d35)Commits: The body has a "Round 3" section, and each false claim is marked "false through 692368f". The four required changes:
Fail-without on 692368f (measured, scratch worktree):
Package budget (measured,
Local (measured):
CI on 5a20d35: 28 pass, 3 skipped, none failing. merge-tree at 5a20d35:
|
Evaluator verdict: CHANGES NEEDED (round 3)I reviewed head 5a20d35 against #815, the round-2 review (issuecomment-6274220846) and the builder's reply (issuecomment-6275489434). I used my own worktrees:
Every run had a scratch HOME, state dir and held-out dir, with a stand-in Summary.
Round-2 required changes
R5:
|
| Shape | Fixture body |
|---|---|
| destructuring a branch | const [x] = c ? [CONSOLE_SHELL] : [unitPath('y')]; onRealMachine(x); rmSync(CONSOLE_SHELL); |
| destructuring another index | const [x] = [unitPath('y'), CONSOLE_SHELL]; onRealMachine(x); rmSync(CONSOLE_SHELL); |
| a for-of guard, a write to another element in the same body | for (const p of [unitPath('y'), CONSOLE_SHELL]) { onRealMachine(p); rmSync(CONSOLE_SHELL); } (also over a const array) |
| a property reassigned before the guard | const o = { p: CONSOLE_SHELL }; o.p = unitPath('y'); onRealMachine(o.p); rmSync(CONSOLE_SHELL); (also through an alias o2.p = …, Object.assign, Object.defineProperty) |
a src object's property mutated (MACHINE_PATHS isn't frozen) |
const s = MACHINE_PATHS.hubUnitFile; MACHINE_PATHS.hubUnitFile = unitPath('y'); onRealMachine(MACHINE_PATHS.hubUnitFile); rmSync(s); |
| a path builder that drops or climbs out of its first argument | onRealMachine(resolve(CONSOLE_SHELL, unitPath('y'))), onRealMachine(join(CONSOLE_SHELL, '..', … , unitPath('y'))) |
- Destructuring:
{ p: x } = c ? {…} : {…}and{ q: x } = { q: unitPath('y'), p: … }are reported. The array forms aren't, becausesurelyreads a binding element throughdeclOrigins(every origin it may have). - The for-of literal: "each element in turn, each surely checked, once" is true for a write after the loop. It is not true for a write inside the body: on the first pass the guard has checked only the first element.
- Path builders:
- The comment at the
PATH_TYPESbranch says "A path built on it (dirname(X),join(X, …)) is inside the root only if X is". That is false forresolve(X, <absolute>)and forjoin(X, '..', …). - The header's "a guard on a different value that mentions the same name" covers these in spirit. The comment and the fixture
v10make the opposite claim.
- The comment at the
- Reached in the repo? No test mutates
MACHINE_PATHS(grep of test/, inferred). Every shape above needs a deliberate form, so all of them sit inside the accident threat model (inferred). The finding is the claim: the header, the body's Round 3 section and the struck-through gap ("Both traced since 32f74e8") say this class is closed. It isn't (principle 5).
Non-blocking: onRealMachine(String(CONSOLE_SHELL)) isn't taken as a guard, though surely has a String branch (measured). That fails closed. The callee probably resolves to StringConstructor's call signature, which has no name (inferred).
Fail-without (measured, scratch worktree at 692368f with head's tests)
-
state-dir-refusal.test.ts: 3 pass, 2 fail.
AssertionError: …/link-…/out/../state(actual: '',expected: /has a '\.\.' part/).AssertionError: as root(actual: the machine-owner refusal).
This matches the builder's 3/2.
-
Head's marked fixtures fed to 692368f's
lintFixtures:- fixture-f: 30 R, 5 missed: m1 to m5 at lines 45 to 49. 11 OK, 0 reported, so v9, v10 and v11 stay unreported.
- fixture-g: 0 missed.
This matches the builder's 5.
-
At head: machine-path-writes, state-dir-refusal, slot-path, slots, slot-dir-writes, update-drain and package-files: 121/121 pass.
Budget (measured)
npm pack --dry-run --json --ignore-scripts, run through the npm wrapper, with build and build:web in each worktree:- File-list diff:
- Only
dist/src/os/index.js71,033 → 71,625 (+592) anddist/src/os/system-paths.js15,222 → 20,831 (+5,609) changed. No file was added or removed. - The deltas sum to +6,201, which equals the unpacked delta. Both files are this PR's intended code.
- Only
- Arithmetic: 1,903,458 × 1.02 = 1,941,527 → 1,950,000. 5,940,612 × 1.02 = 6,059,424 → 6,060,000. Both are correct.
- The test: against main,
test/package-files.test.tsdiffers only on thePACKAGE_BUDGETline. 5a20d35 touches only that line and the..RegExp commit is separate (diff). test: raise the package budget to main's size + 2% after #805 and #787 [PROTECTED: owner merges] #823 sets the same two numbers, so it merges cleanly with this PR.
Usual checks (measured unless marked)
- typecheck: clean. check:denylist:
denylist: 1310 files clean. - DCO and attribution:
- 29e8fc5, 32f74e8, 9168629 and 5a20d35 are signed off.
- No Co-Authored-By or vendor line in any round-3 commit (
git loggrep: 0) or in the body (read). - The merge e66f37d has no sign-off, like round 2's 6d68bd9. The
dcocheck passes, and e66f37d's tree is identical togit merge-tree 32f74e8d d4b45039, a clean automatic merge. Non-blocking.
- Rule 10: the only test lines removed in round 3 are replaced by stricter ones:
notEqual(…, null)became amatchon the exact refusal, and the guard keys moved fromoriginstosurely. Nothing is weakened (diff read). - Protection: the same paths: src/os/**, .worklane/review.yaml, test/isolate.ts, test/managed-apps.test.ts and test/package-files.test.ts (all engine-guards on main). It is correctly marked:
[PROTECTED: owner merges],needs-owner,**Risk class:** protected, and real "can never do" and "Compromised component" sections. The "can never do" line on symlinks and..is now true for every..form I tried. - No regression for existing installs: outside a test run nothing changes (measured:
not a test run, any dirallowed). In a test run, only tightened. - Rule A: the new test cases are pure calls on stand-ins under the machine (read). No test or script here touches real state (inferred, read).
- Adversarial pass section: present, but it has no round-3 attacks. The Round 3 section lists the fixtures m1 to m5 and v9 to v11, and nothing tried against the new
surelyrule or the..RegExp beyond them (required change 2). - CI on 5a20d35: runs 39307776160, 39307776326 and 39307776079 (
pull_request, headSha 5a20d35) succeeded. Every check passes. publish, bootstrap and bootstrap (windows-wsl) are skipped. - merge-tree (
--write-tree, read-only):- Clean with origin/main, test: raise the package budget to main's size + 2% after #805 and #787 [PROTECTED: owner merges] #823, Engine updater counts a required check only from its pinned workflow or App [PROTECTED: owner merges] #789, Test runs: each run in a user namespace of its own; slirp4netns as the test user; a run can't join Docker's namespaces (#545) [PROTECTED: owner merges] #780 and docs/design: amend #698 decision 1: turning auto_merge on is root-only; off stays on every surface [PROTECTED: owner merges] #775.
- Only the
PACKAGE_BUDGETline conflicts with Merge: a PR that removes or weakens a test, lint or guard waits for the owner (#783) [PROTECTED: owner merges] #820 (1,970,000/6,140,000), Wizard: the Claude step matches the login to agent_runtime and offers an API key (#738) [PROTECTED: owner merges] #819, First run: a card follows the first task from File one to its PR; the Start card counts only steps before Start (#741) [PROTECTED: owner merges] #816, doctor prints a paste-ready required_check_sources block; a new project's config starts pinned (after #788) [PROTECTED: owner merges] #803, Pin this repo's required checks to the workflow files that post them (after #788 and the engine upgrade) [PROTECTED: owner merges] #794, Required checks count only from the App or workflow pinned for their name [PROTECTED: owner merges] #788, Dashboard policy changes go through root's helper (set-policy), never a loosening (#726 part 2) [PROTECTED: owner merges] #770 and Single machine: policy.yaml is root's, and the engine refuses one that isn't (#726 part 1) [PROTECTED: owner merges] #769, each of which raises it too. Expected; whichever lands second re-measures. - Remove the desktop app, its source, Tauri lockfile and docs; keep the desktop check one more release as a no-op (#744) [PROTECTED: owner merges] #746 conflicts in CHANGELOG.md, scripts/setup/lib.sh, test/engine-update.test.ts and test/update-checks.test.ts. It conflicts the same way with main alone, and this PR touches none of those files.
- The body's merge-tree list is now stale: Test runs: each run in a user namespace of its own; slirp4netns as the test user; a run can't join Docker's namespaces (#545) [PROTECTED: owner merges] #780 is clean, and Required checks count only from the App or workflow pinned for their name [PROTECTED: owner merges] #788, Pin this repo's required checks to the workflow files that post them (after #788 and the engine upgrade) [PROTECTED: owner merges] #794 and doctor prints a paste-ready required_check_sources block; a new project's config starts pinned (after #788) [PROTECTED: owner merges] #803 now conflict on the budget line. Non-blocking.
Required changes
-
The "surely" rule (principles 1 and 5). For each shape in the table above, either trace it, or take it out of the claim and list it in the header's "Not caught" and the body's gaps. Add a fixture for each either way. Cheap fixes, inferred:
- A binding element:
surelyof the matching element or property when the initializer is a literal, else nothing. NotdeclOrigins. - A guard on a for-of variable: for a write inside the same body, take only what every element agrees on (
bothover the elements). - A property:
o.pcounts as surely nothing once anything assigns.pon it or an alias. ListObject.assign/definePropertyand src object mutation as gaps. - Path builders: take only
dirname/normalizeof X, or ajoin(X, …)whose other arguments are string literals with no..part and no leading/. Correct thePATH_TYPEScomment. - Getters: name writes through a getter in "Not caught", or trace them.
Then correct the body: the Round 3 section's "A guard counts only the locations its argument surely is", and the struck-through gap "Both traced since 32f74e8".
- A binding element:
-
Adversarial pass: add the round-3 attacks you ran against the
surelyrule and the..check, with results.
Non-blocking: refresh the body's merge-tree list. The merge commit has no sign-off (the dco check passes).
…sed today #818 round 4. The round-3 review measured six shapes where "a guard counts only what its argument surely is" is still a may, plus writes through a getter. Each needs deliberate code, so they stay outside the accident threat model and aren't traced. They are now named in the header's "Not caught" list, and each is a GAP fixture (with .call, Reflect.apply and another call of the same function) that the test expects to be missed, so a later fix shows up there: - array destructuring of a branch, and of another index; - a for-of guard covering a write to another element in the same body; - a property reassigned before the guard; - a src object's property mutated; - resolve(X, <absolute>) and join(X, '..', ...) as the guard; - a write through an object or class getter. The PATH_TYPES comment said a path built on X is inside the root only if X is: false through 5a20d35 for resolve and join with '..'; corrected. Test: test/machine-path-writes.test.ts, "the known gaps stay named" (12 GAP lines, none reported: measured). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: CHANGES NEEDED (final check, body only)I reviewed head d916730 against #815, the round-3 review (issuecomment-6275775507), the owner's acceptance of the service-mode exception, and one owner rule the coordinator relayed during this check (2026-10-19). I used my own worktrees at d916730 and at main d5674d0, plus scratch copies of head's build for fault injection and lint probes. Every run had a scratch HOME, state dir, held-out dir and TMPDIR, with a stand-in Summary.
The owner rule relayed today: protection by design, the lint as a backstop
Round-3 required changes
Not separately fixtured: the alias, The exception's tightenings (measured, fault injected)Each fault was injected into a scratch copy of head's built
So every tightening is real and pinned by a test, including "the state dir only". The rootless uid-0 and systemd checks run before the machine is looked at (src/os/system-paths.ts Gaps: new shapes tried (measured, fixture text fed to head's
|
| Tarball | Unpacked | Files | |
|---|---|---|---|
| main d5674d0 | 1,902,158 | 5,934,411 | 512 |
| head d916730 | 1,903,458 | 5,940,612 | 512 |
| Δ | +1,300 | +6,201 | 0 |
- Per-file diff: only
dist/src/os/index.js71,033 → 71,625 (+592) anddist/src/os/system-paths.js15,222 → 20,831 (+5,609) changed. No file was added or removed. The deltas sum to +6,201. This matches the body exactly. - Budget:
git diff d5674d04 d9167303 -- test/package-files.test.tsis empty, so the budget is main's. The budget test passes.
Tests and checks (measured, head, scratch env)
- machine-path-writes 9/9, state-dir-refusal 5/5, package-files 7/7: 21/21.
- The "16/16" in the hand-off is machine-path-writes plus package-files.
npm run typecheck: clean.npm run check:denylist:denylist: 1310 files clean.- fullsuite.sh (420 files; mcp-* and plugin-fetch excluded): 3829 tests, 3761 pass, 11 fail, 57 skipped (measured). None of the 11 is this PR's.
-
2, measured not this PR's:
- add-project
issue #711(did not match /sudo -u … claude auth login/); - dashboard-speed
event cache(read ECONNRESET).
Each fails the same way, re-run alone, on main d5674d0 and on head. A cause on this machine: the stand-in
claudeon PATH, or load (guess). - add-project
-
2 isolate tmpdir tests: the Chromium socket path is 179 and 191 bytes under my long scratch TMPDIR. This is the known environmental failure from earlier rounds (measured message; environmental, inferred).
-
7
listen EADDRINUSE …/docker.sock: bench 1, testrun-env-argv 4 and testrun-sandbox 2. Each socket path is under the same long scratch TMPDIR, over the 107-byte limit (inferred). -
CI passes every shard on this SHA (measured).
-
CI and hygiene
- CI on d916730 (measured,
gh pr checks): every check passes, including all 8 test shards and the 4 real-systemd jobs. bootstrap and bootstrap (windows-wsl) are skipped. Onenew-depsrun (39316069333) was cancelled and superseded on the same SHA by 39316119120, which passed (measured). That is a cause outside the PR: a duplicate trigger (inferred). - Commits (measured,
git logtrailers):- Every non-merge commit is signed off, and there are 0 attribution lines.
- The merge d916730 has no sign-off, like the earlier merges. The dco check passes, and d916730's tree equals
git merge-tree --write-tree 89218df3 d5674d04(1a1705e0), a clean automatic merge. - 89218df changes only test/machine-path-writes.test.ts. Its 6 removed lines are comments, rewritten. Nothing is weakened (rule 10, diff read).
- Protection (read):
- Protected paths: src/os/** and .worklane/review.yaml (engine), and test/isolate.ts and test/managed-apps.test.ts (engine-guards).
- Correctly marked:
[PROTECTED: owner merges],needs-owner,**Risk class:** protected, and real "What this can never do" and "Compromised component" sections. - The "can never do" lines on the slot dir and the stray machine are pinned by tests (fi4 above, and state-dir-refusal:267-268).
- No product name is added in src/ (0 hits). There are no new dependencies.
- No regression for existing installs: outside a test run nothing is refused or opened (measured: test 2 passes on main and on head).
- merge-tree at d916730 (measured, read-only):
- Clean with Merge: a PR that removes or weakens a test, lint or guard waits for the owner (#783) [PROTECTED: owner merges] #820, Wizard: the Claude step matches the login to agent_runtime and offers an API key (#738) [PROTECTED: owner merges] #819, First run: a card follows the first task from File one to its PR; the Start card counts only steps before Start (#741) [PROTECTED: owner merges] #816, doctor prints a paste-ready required_check_sources block; a new project's config starts pinned (after #788) [PROTECTED: owner merges] #803, Pin this repo's required checks to the workflow files that post them (after #788 and the engine upgrade) [PROTECTED: owner merges] #794, Engine updater counts a required check only from its pinned workflow or App [PROTECTED: owner merges] #789, Required checks count only from the App or workflow pinned for their name [PROTECTED: owner merges] #788, docs/design: amend #698 decision 1: turning auto_merge on is root-only; off stays on every surface [PROTECTED: owner merges] #775, Dashboard policy changes go through root's helper (set-policy), never a loosening (#726 part 2) [PROTECTED: owner merges] #770 and Single machine: policy.yaml is root's, and the engine refuses one that isn't (#726 part 1) [PROTECTED: owner merges] #769.
- Remove the desktop app, its source, Tauri lockfile and docs; keep the desktop check one more release as a no-op (#744) [PROTECTED: owner merges] #746 conflicts in CHANGELOG.md, scripts/setup/lib.sh, test/engine-update.test.ts and test/update-checks.test.ts. That is the same as against main alone.
- The body's list (budget-line conflicts) is stale now that the PR no longer touches the budget. Non-blocking.
- Body claims: the round-4 claims I checked are true: the size table, 9/9, 1310, the empty budget diff, 12 GAP lines and one commit. They are labelled.
Required changes (body only; no code)
-
Name the protection by design (owner rule, 2026-10-19, relayed). Add a short section saying:
- the protection is
stateDir()'s runtime refusal in a test run, with its..and symlink refusals; - for machine locations, the protection is main's preload system root;
- the machine-path-writes lint and its GAP fixtures are a backstop.
- the protection is
-
Name the routes with only the lint behind them, and the new misses, as gaps (header "Not caught", the body's gaps, and the Adversarial pass):
- writes to the never-moved real paths (
asRoot.*,SYSTEM_ROOTS.*,onRealMachine's result), where/var/tmpand/run/lockhave nothing else behind them; - a12/a13,
join('/', onRealMachine(X)); - s1/s2, a write that climbs out of
stateDir()/slotsDir()with..; - a7/a8, literal real paths.
A
// GAPfixture each for a12 and s1 would keep the "known gaps stay named" test honest (cheap, inferred). Literal paths need only a line. - writes to the never-moved real paths (
Non-blocking:
- Refresh the merge-tree list.
- A by-design guard for the never-moved paths is new scope, and so an owner decision. Two options (inferred):
- fs write wrappers in the preload that refuse a path outside the test machine, like the builder's probe wrappers;
- keep
asRoot/SYSTEM_ROOTSout of test code's reach.
…l-path and '..' gaps as GAP fixtures #818 final check. The owner's durability rule (2026-10-19): a protection is by design, and a source lint only a named backstop. The lint's header now says so: stateDir() and slotsDir() refuse at run time in a test run, and the preload's system root moves every engine-resolved machine location; the paths it never moves (asRoot.*, SYSTEM_ROOTS.*, onRealMachine's result) have only this lint. Named in "Not caught", and two as GAP fixtures (missed today): - the real path joined under '/' (`join('/', onRealMachine(X))`), past the "under a test's own root" exception; - a write that climbs out of stateDir() or slotsDir() with '..' parts; - literal real paths (not modelled). Test: test/machine-path-writes.test.ts, "the known gaps stay named": 14 GAP lines, none reported (measured). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…OwnEnv snapshot (#786) stateDir() read <PREFIX>_STATE_DIR, LOCALAPPDATA and XDG_STATE_HOME by plain property access, and called #818's testStateRefusal(dir) with the default process.env, whose marks (the test machine, the system root, the token) were read raw too. A value on Object.prototype moved the state dir, and a test run's child that lost its test machine was no longer refused when the prototype named one: the raw machine and root paired with the own-only systemRootRefusal, which reads "unset, nothing to refuse". stateDir() now takes one ownEnv() snapshot, reads the dir through get(), and hands the refusal env.asProcessEnv(), the pairing slotsDir already uses. The sandboxed project command's LANG is read the same way, so src/os/index.ts joins the backstop lint's list. Test (fails without): test/state-dir-refusal.test.ts "#786: a state-dir variable or a test-run mark on Object.prototype neither moves the state dir nor un-refuses it"; on 2797cfc, 1 of 6 fails, with all four of its checks wrong (the dir moved twice, two refusals lifted). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Intent: in a test run,
stateDir()now refuses a state dir outside the test machine, the wayslotsDir()already does (#669).testStateRefusalthrowsTestStateRefusedbefore anything is opened, so a test that lost its isolation fails loudly instead of reading or writing the person's own state. Outside a test run nothing changes. A new lint over test/ makes sure every test that creates, writes or removes an engine-resolved machine location callsonRealMachineon it first. The lint works through the compiler's symbols, and it follows loops, locals, helpers and destructuring. It found 44 unguarded writes in 5 test files,owner-terminal-removed.test.tsamong them, and each one now has a guard. (The first version of this body said 45, with plugin-approval at 5. False through 55343d3, corrected in round 2: 45 was measured with an earlier lint that counted anO_RDONLYopen inreadAllowlistas a write; head's lint counts 44, measured below.)By-design protection (and what is only a backstop)
Under the owner's durability rule (2026-10-19), every new protection must be by design, and a source lint may only be a named backstop.
stateDir()refuses at run time anything outside the test machine:testStateRefusal, including its refusals of a..part and of a symlink inside the machine.slotsDir()does the same throughtestSlotsRefusal(main's, now with the same..and symlink refusals). For machine locations (MACHINE_PATHS.*,SYSTEM_SLOTS.*,etcPath()…), the protection is main's existing preload system root, which moves each of them into the test machine.// GAPfixtures. It catches a test that writes a machine location without checking it withonRealMachinefirst. It is not the protection, and it has named gaps (below).asRoot.*,SYSTEM_ROOTS.*and whatonRealMachinereturns. Only the lint covers these./etc,/var/liband/opt(inferred)./var/tmp, home of the realagent-slotsdir, and/run/lockare world-writable on a default Ubuntu (inferred), so there the lint is the only barrier.join('/', onRealMachine(X)), also through a parameter defaulting to'/', slips past the lint's "under a test's own root" exception. GAP fixture g13, missed (measured).stateDir()orslotsDir()through..parts (join(stateDir(), '..', '..', 'x')). The run-time refusal checks the dir itself, not paths built on it, and the lint leaves the state and slot families to that refusal. GAP fixture g14, missed (measured).'/var/tmp/agent-slots/x',join(homedir(), …)). These are outside the lint's model. On Linux the preload's HOME move covers a8 (inferred); on macOS HOME isn't moved.Final check (head a0eb941)
Answers issuecomment-6276968449; the code itself is unchanged.
cb50296: the lint header says the lint is a backstop. "Not caught" names a12/a13, s1/s2 and literal paths. Two new
// GAPfixtures: g13 (a12) and g14 (s1). "The known gaps stay named" now holds 14 GAP lines, none reported (measured).a0eb941: merges main 6e0d7d8 (Test runs: each run in a user namespace of its own; slirp4netns as the test user; a run can't join Docker's namespaces (#545) [PROTECTED: owner merges] #780), with no conflicts.
Tests at a0eb941 (measured): 21/21: machine-path-writes 9, state-dir-refusal 5, package-files 7. The round-4 hand-off said "16/16", which was machine-path-writes plus package-files: false, corrected here.
Package size against main 6e0d7d8 (measured,
npm pack --dry-run --json, dashboard UI built in both trees):Only
dist/src/os/system-paths.js(+5,609) anddist/src/os/index.js(+592) grew. That fits main's budget, so there is no raise.merge-tree at cb50296 (measured): clean with every open PR (Release v0.5.3: changelog and version [PROTECTED: owner merges] #828, Dashboard: a viewer's /api/prs shows each PR's state, never the merge decision (#821) [PROTECTED: owner merges] #826, CI: a PR that moves the engine pin loads main's .worklane/ with the engine it pins (#814) [PROTECTED: owner merges] #825, Merge: a PR that removes or weakens a test, lint or guard waits for the owner (#783) [PROTECTED: owner merges] #820, Wizard: the Claude step matches the login to agent_runtime and offers an API key (#738) [PROTECTED: owner merges] #819, First run: a card follows the first task from File one to its PR; the Start card counts only steps before Start (#741) [PROTECTED: owner merges] #816, doctor prints a paste-ready required_check_sources block; a new project's config starts pinned (after #788) [PROTECTED: owner merges] #803, Pin this repo's required checks to the workflow files that post them (after #788 and the engine upgrade) [PROTECTED: owner merges] #794, Engine updater counts a required check only from its pinned workflow or App [PROTECTED: owner merges] #789, Required checks count only from the App or workflow pinned for their name [PROTECTED: owner merges] #788, docs/design: amend #698 decision 1: turning auto_merge on is root-only; off stays on every surface [PROTECTED: owner merges] #775, Dashboard policy changes go through root's helper (set-policy), never a loosening (#726 part 2) [PROTECTED: owner merges] #770, Single machine: policy.yaml is root's, and the engine refuses one that isn't (#726 part 1) [PROTECTED: owner merges] #769) except Remove the desktop app, its source, Tauri lockfile and docs; keep the desktop check one more release as a no-op (#744) [PROTECTED: owner merges] #746. Remove the desktop app, its source, Tauri lockfile and docs; keep the desktop check one more release as a no-op (#744) [PROTECTED: owner merges] #746 conflicts in CHANGELOG.md, scripts/setup/lib.sh, test/engine-update.test.ts and test/update-checks.test.ts, the same against main alone.
Round 4 (head d916730): claims, not a chase
Answers the round-3 review (issuecomment-6275775507). One commit, 89218df, which changes only test/machine-path-writes.test.ts.
Known gaps, named and pinned. The six "surely" shapes and writes through a getter are not traced. Each needs deliberate code, so they sit outside the accident threat model (inferred, as the review assessed). The same call was made for Slot dir: a slot path is an opaque SlotPath that only writeSlotFile and the slot readers take; tsc rejects it at any other writer [PROTECTED: owner merges] #806 and First run: a card follows the first task from File one to its PR; the Start card counts only steps before Start (#741) [PROTECTED: owner merges] #816.
// GAPfixture in a new test, "the known gaps stay named", together with.call,Reflect.applyand another call of the same function: 12 lines, none reported today (measured). A later fix that reports one fails that test until the line moves to// R.Corrections (principle 5). Each is marked "false through 5a20d35":
PATH_TYPEScomment, which said a path built on X "is inside the root only if X is". That is true fordirname/normalize, but not forresolve(X, <absolute>)orjoin(X, '..', …).After (measured, head 89218df): machine-path-writes 9/9; typecheck clean; check-denylist
1310 files clean.Package size. Under the owner's new practice (2026-10-19, late), a PR raises the budget only when it would exceed it. I merged main d5674d0 (test: raise the package budget to main's size + 2% after #805 and #787 [PROTECTED: owner merges] #823) in d916730 and re-measured with
npm pack --dry-run --json, the dashboard UI built in both trees (measured):dist/src/os/system-paths.js(+5,609) holds the state-dir refusal, the machine's own mark, and the symlink and..checks.dist/src/os/index.js(+592) isstateDir()'s refusal. No other packed file changed size.PACKAGE_BUDGETis main's line exactly:git diff origin/main -- test/package-files.test.tsis empty, because test: raise the package budget to main's size + 2% after #805 and #787 [PROTECTED: owner merges] #823 set the same numbers as 5a20d35. The budget test passes (measured).merge-tree at 89218df (measured, refreshed; d916730 adds only the main merge):
test/package-files.test.ts, thePACKAGE_BUDGETline, because each raises it: Merge: a PR that removes or weakens a test, lint or guard waits for the owner (#783) [PROTECTED: owner merges] #820, Wizard: the Claude step matches the login to agent_runtime and offers an API key (#738) [PROTECTED: owner merges] #819, First run: a card follows the first task from File one to its PR; the Start card counts only steps before Start (#741) [PROTECTED: owner merges] #816, doctor prints a paste-ready required_check_sources block; a new project's config starts pinned (after #788) [PROTECTED: owner merges] #803, Pin this repo's required checks to the workflow files that post them (after #788 and the engine upgrade) [PROTECTED: owner merges] #794, Required checks count only from the App or workflow pinned for their name [PROTECTED: owner merges] #788, Dashboard policy changes go through root's helper (set-policy), never a loosening (#726 part 2) [PROTECTED: owner merges] #770, Single machine: policy.yaml is root's, and the engine refuses one that isn't (#726 part 1) [PROTECTED: owner merges] #769. None of those conflicts with main alone; whichever lands second re-measures.Adversarial pass, round 3 (the attacks on the round-3 rules)
The
..check. Measured by the round-3 review (r5-attacks.mjs): 38 dirs × 3 calls (state with root, state rootless, slots)...form is refused:link/..,link/../,link/../slots,link/../../u/slots,link/x/../..;../with a relative dir, and a bare..;machine/../machine/u/state,real/../real, a trailing/..with no link...is refused as a symlink:link,link/,link/.,link/./slots,link//slots.u/.state,u/..state,u/state..,u/...andu/a..bare allowed for all 3 calls.uid 0. Measured by the round-3 review with fault injection: with the line disabled, the
as rootassertion fails, so the test reaches it.The "surely" rule:
let, ayieldelement, a write through a reassigned for-oflet{ p: x } = c ? {…} : {…}and{ q: x } = { q: Y, p: … }resolve(X, <absolute>),join(X, '..', …)as the guardonRealMachine(o.p))onRealMachine(String(X))StringConstructor's nameless signature, inferred).Round 3 (head 5a20d35)
Answers the round-2 review (issuecomment-6274220846). Commits:
Fail-without is on 692368f.
R5,
..after a link (fixed). In a test run, a state or slot dir written with any..part is now refused, before anything is looked at. This fails closed:join()already folds the engine's own paths, so nothing in src/ passes a raw..(inferred, grep). The check is a RegExp. My first version useddir.split(), which handed the slot dir to a String method, and Slot dir: a slot path is an opaque SlotPath that only writeSlotFile and the slot readers take; tsc rejects it at any other writer [PROTECTED: owner merges] #806's A5 test in slot-path.test.ts caught it:[ 'split' ], measured on e66f37d and fixed in 9168629.<machine>/…/link-…/out/../stateis allowed, so the new symlink test fails.uid 0 (fixed). The rootless path's root and systemd refusals now run before the machine is looked at, and a test covers uid 0.
as rootassertion fails.The stale count (fixed): plugin-approval is 4 in the What changed table.
The lint (traced for the round-2 shapes only). "Traced, not just named" was false through 5a20d35 for the class as a whole: the round-3 review measured six more "surely" shapes still missed (see Round 4). A guard counts only the locations its argument surely is: every branch and every value a local or for-of
letis given must agree, and ayield, an index or a parameter is surely nothing. A for-ofletassigned in its body is traced as a write target.let, ayieldelement, a write through a reassigned for-oflet), fed to 692368f'slintFixtures, are all missed. The 3 new valid shapes stay unreported at head.Package budget (superseded in round 4: main's test: raise the package budget to main's size + 2% after #805 and #787 [PROTECTED: owner merges] #823 now carries the same numbers, and the owner's new practice raises the budget only for a PR that exceeds it; see Round 4**).** Measured with
npm pack --dry-run --jsonand the dashboard UI built, using scratch helpers underwork/issue-815:dist/src/os/system-paths.js(+5,609) anddist/src/os/index.js(+592). Both are this PR's intended code; no other file changed size.PACKAGE_BUDGETis raised to measured + 2%, rounded up to the next 10,000: 1,950,000 and 6,060,000. The budget test passes (measured).After (measured, head 5a20d35, scratch env):
1310 files clean.merge-tree at 5a20d35 (measured):
PACKAGE_BUDGETline, because each raises it too. Whichever lands second re-measures.Round 2 (head 692368f)
Answers the round-1 review (issuecomment-6272854895). Commits: ebf6dab (src), 7e89673 and 692368f (lint). Fail-without is on 55343d3.
R1, guard holes (fixed). A guard counts only when it always runs before the write. It must come first in a list of statements that holds the write (a block, the file, a case clause), directly or inside plain
{ … }blocks of that list. It never counts out of:||=,&&=and??=are short-circuits. One loop is let through: a for-of over a non-empty array literal (no spread) whose body never breaks, continues or returns. It runs its body once per element, every time, as in Slot dir: a slot path is an opaque SlotPath that only writeSlotFile and the slot readers take; tsc rejects it at any other writer [PROTECTED: owner merges] #806'sfor (const p of [A, B, C]) onRealMachine(p);in slot-path.test.ts.R2, the never-moved real paths (fixed).
asRoot.*andSYSTEM_ROOTS.*are reported likeonRealMachine's result.R3, tracing (fixed).
.then,Array.from,new Promiseand array methods.yields are traced, and so are destructuring assignments ([d] = …,({ e } = …)).O_RDONLYwithO_NOFOLLOW,O_NONBLOCKand the like,|,??, 0). Anything else, a variable included, is a write: fail closed..call/.apply/Reflect.apply, and fs reached throughcreateRequireorimport().R4, the principle-9 section is below. The rootless path now also refuses as root and under systemd (
INVOCATION_ID,JOURNAL_STREAM), as the root's mark does.R5, symlinks (fixed, both dirs).
outsideTestMachinelstats each part of the dir that exists inside the test machine, from the machine down. It never follows one and never looks outside the machine, and it stops at the first part that doesn't exist. A symlink among them is refused, whether it points outside or inside. This is a tightening for the state and the slot dir alike.R6 (corrected). The count is 44, as above.
Fail-without, round 2 (measured). I ran the round-2 tests in a scratch worktree at 55343d3 (never committed).
The round-2 lint fixtures, fed to 55343d3's
lintFixtures, miss 16 cases:||=,&&=,??=, a continue first, a spread;.then,Array.from, a generator, two destructuring assignments, flags with a variable.The new state-dir-refusal.test.ts fails on 55343d3's src in two places:
a link out, then below it, the symlink test;INVOCATION_ID, the rootless refusal.Its other 3 tests pass there, as they should.
At head, the lint over test/ with the 5 guarded files put back to 178c44e fails with 44 findings: managed-apps 25, owner-terminal-removed 7, plugin-run 7, plugin-approval 4, init 1.
The s25 hoisting fixture failing on 7e89673 is inferred. What I measured is the change in behaviour: fixture-b line 13, a write in a function declaration guarded at module level, became reported.
After (measured, head 692368f, scratch HOME, state, held-out dir and TMPDIR):
1307 files clean.Principle 9: the service-mode machine mark (an exception, owner-accepted 2026-10-19)
outsideTestMachinerequires the system root inside the test machine, and the root's mark for this run's token (systemRootRefusal). That requirement includessystemRootRefusal's refusals as root (uid 0) and in a process systemd started (INVOCATION_ID,JOURNAL_STREAM). The exception applies to the state dir only:testSlotsRefusalstill requires the root, and its message is byte-identical (measured, by test).dashboard --hub,--hub-setup) never run with the system root, by design (serviceModeRefusal). So a test's service-mode child has no root to show, yet its state dir is still inside the test machine. Without the exception, hub-setup.test.ts's two CLI tests fail withTestStateRefused(measured in round 1).<machine>/.test-isolation, a file owned by this user alone, holding the token inWORKLANE_TEST_ISOLATION.test/isolate.tswrites it next to the root's mark.process.getuid, so there, as on the root's route, there is no root check. The owner and mode checks are also skipped on win32, as on the root's route (inferred, read). Native Windows runs no agents (canRunAgents) and isn't in CI.TEST_MARK_FILEoutside its own machine (inferred, grep).serviceModeEnvkeepsTEST_TOKEN_VARonly for this exception. Without a root, the token moves nothing else (inferred, grep of src/: read only bytestRunSignand the mark check).Risk class: protected. The diff touches
src/os/index.tsandsrc/os/system-paths.ts(engine),.worklane/review.yaml(engine), andtest/isolate.tsandtest/managed-apps.test.ts(engine-guards). The owner merges. Since round 4's merge of main d5674d0,test/package-files.test.tsis no longer in the diff: itsPACKAGE_BUDGETline is main's, unchanged.Closes #815
What changed
stateDir()(src/os/index.ts, only thestateDirhunk): it resolves the same way as before (now inunguardedStateDir), then callstestStateRefusal(dir)and throwsTestStateRefusedif that refuses.testStateRefusal(src/os/system-paths.ts): it applies the same rule astestSlotsRefusal, and both now shareoutsideTestMachine. The slot refusal's message is byte-identical: a test asserts the full string.dashboard --hub,--hub-setup) runs without the system root (serviceModeEnv), so it can't prove its test machine through the root's mark. In that case only, the machine can prove it with a mark of its own: the same token in<machine>/.test-isolation, a file owned by this user alone.test/isolate.tsnow writes that mark, andserviceModeEnvkeeps the token (without a root, the token moves nothing). The slot dir still requires the root.The lint (test/machine-path-writes.test.ts). It builds a
ts.Programover test/ and uses the type checker.node:fsornode:fs/promises, under any name, destructured or aliased). It is also a call to a src/ or test/ function that writes one of its parameters, or an option of one. Those functions are found from the code, including writers that call other writers.systemPathon every branch. The key names the declaration and the property, so aliases and namespace imports count as the same name. Examples:MACHINE_PATHS.hubUnitFile,SYSTEM_SLOTS.dir,CONSOLE_SHELL,etcPath(),storeDir(),allowlistPath().env ?? varLibPath(…)anddir === system.dir ? FILE : join(dir, …)don't count, because in a test they are the test's own paths, whichonRealMachinewould refuse.onRealMachine(x)call on the same name that always runs before the write. Since round 2 that means: first in a list of statements holding the write, directly or through plain blocks. It never counts out of a loop body (except a for-of over a non-empty literal with no break, continue or return), an if or else branch, a try, catch or finally block, another case, a labelled block or a function, or into a hoisted function declaration. A guard inside a condition, behind a short-circuit or logical assignment, or in an optional call doesn't count either. (Round 1 also let a guard out of a for-of body or a try block. That was false: the round-1 review measured holes, now fixed.)onRealMachine, because neither is inside the system root. Their resolvers refuse in a test run instead. The lint checks thatstateDir()andslotsDir()still call their refusal and throw (directly or through a function they call, asslotsDir→resolvedSlotsDirdoes since Slot dir: a slot path is an opaque SlotPath that only writeSlotFile and the slot readers take; tsc rejects it at any other writer [PROTECTED: owner merges] #806). If either stops, every write built from it is reported. This is a new lint, so it exempts nothing that existed before.onRealMachinereturns is reported, because that value is the real path. The exception is where it is only a later part ofjoin(root, …), i.e. under a test's own root.The guards it found (each
onRealMachine(...)placed before the write):oldInstalllines 39-44 (loop form) and 136asRootsFile,withUpdatesand two testssrvCheckoutPath.worklane/review.yaml: the two new tests are added to engine-guards, next to slot-dir-writes. This is a tightening.How it was verified
Fail-without, refusal (measured). I put the base commit 178c44e's
src/os/index.ts,src/os/system-paths.tsandtest/isolate.tsback, built, and ran test/state-dir-refusal.test.ts. 3 of its 4 tests fail:WORKLANE_STATE_DIR outside the test machine: no state dir, withstateDir()returning the stand-in…/tmp/not-the-test-machine-84527c33/state;Missing expected exception;TypeError: testStateRefusal is not a function.The production-path test passes there and here, which is the point: outside a test run nothing changed. In the same run, hub-setup.test.ts passes on the old code (12 of 12).
Fail-without, lint (measured). I put the 5 guarded test files back to their pre-guard versions, built, and ran the lint test. With round 1's first lint it failed with 45 findings, plugin-approval at 5. With head's lint it fails with 44 (owner-terminal-removed 7, managed-apps 25, init 1, plugin-approval 4, plugin-run 7; measured in round 2). The first one:
test/init.test.ts:328: mkdirSync(dirname(checkout)): srvCheckoutPath is a machine location (src/os/system-paths.ts): call onRealMachine on it first. After merging main (Slot dir: a slot path is an opaque SlotPath that only writeSlotFile and the slot readers take; tsc rejects it at any other writer [PROTECTED: owner merges] #806), the lint also reportedtest/slots.test.ts:91. That write was guarded byassert.equal(onRealMachine(devSlotsPath()), …), which the lint now accepts as a guard (55343d3).After (measured), at head 55343d3 with a scratch HOME, state, held-out dir and TMPDIR:
the temporary directory is inside the test machine…anda test run a test starts…). Those two fail only because my scratch TMPDIR is long: Chrome's socket path came to 163/175 bytes, over the 107-byte limit (measured). They are environmental, not caused by this change. CI uses /tmp.tsc -p tsconfig.json --noEmitandtsc -p web/tsconfig.json: clean.denylist: 1307 files clean.Full suite: not run locally. I started fullsuite.sh and then stopped it within about a minute. The log shows only the build step and no test result, and no process was left over. I stopped it because
npm testincludes test/mcp-broker-unit.test.ts, which spawns test/fixtures/plugins/escape/server.js, and the builder rules forbid running that on this machine. CI runs the full suite.Rule A. No test or probe here reads, stats or writes a real state dir, /var/lib or /etc.
node:fs,node:fs/promisesandnode:child_process. Before the real call, the wrappers refuse any path outside the test machine and the engine's own dist/src modules, and they record every path they see.existsSyncand the engine'sfileAccess(statSyncthrough its named import) are both recorded, and a path outside is refused.Engine spawns that build their env from scratch (item 3)
I found these with a scratch AST scan of src/, test/ and scripts/ for spawn/exec/fork calls whose
envis an object literal (or a same-file const) with no spread of an existing env. It found 75 (measured). Spawns of the engine itself:cli slots){PATH, HOME: mkdtemp, AGENT_SLOTS_DIR}cli init)WORKLANE_STATE_DIRboth mkdtemp{ skip: posix }).node -eimporting the engine)cli plugins){PATH, HOME, LANG, SUDO_USER}git,npm,tar,systemd-runorsudo <helper>. The test/ hits run bash scripts, stand-ins,node scripts/ci/batch.mjsornew-deps.mjs(neither imports dist/src), and the escape fixture's server.js (test/mcp-broker-unit.test.ts:208, not run here).os.homedir()falls back to the user database, i.e. the real home.unisolatedEnv,serviceModeEnv) keeps the parent's variables and isn't in the list.What this can never do
testRunSignis null for the engine, so the refusal returns before it looks at anything.WORKLANE_TEST_MACHINE(measured). A home directory without the preload's root, or without a mark of its own holding this run's token, is refused.||=/??=passing...part since 29e8fc5 (measured, by test). False through 692368f: the round-2 review measured<link>/../slotspassing, because the check walked the path afterresolve()had foldedlink/..away.Compromised component
WORKLANE_TEST_RUN=1on a real engine, which makesstateDir()andslotsDir()throw for the real paths. That fails closed: the engine refuses to start; it doesn't widen anything. It is the same asslotsDir()today (inferred).Adversarial pass
Every attack below was run as a fixture or probe (measured) unless marked otherwise.
for (const f of [A, B]) rmSync(f),[A].forEach((p) => writeOwnerOnly(p))const { appendFileSync: add } = fs),fs/promises, a namespace import (removed.CONSOLE_SHELL)let x; x = unitPath(...)), a test helper that removes its parameter, an object's property, an array element, a destructured name(p = SYSTEM_SLOTS.config) => rmSync(p))onRealMachine(...)'s resultjoin(root, onRealMachine(...))if, behind&&, in an optional call?.(…), in another function, after the write, or on another nameassert.equal(onRealMachine(x), …)O_RDONLY | O_NOFOLLOWopen (readRegularFile)stateDir()/slotsDir()without their refusals (fixture copies of src/os/index.ts, nothing in src/ edited)..in the dirjoin(testMachine, '..', 'x')measured)Round 2: guards that look valid but don't protect a later write. Every shape below is in the fixtures, fed to
lintFixtures(measured).||=,&&=,??=whiledo … while(runs once; not taken, conservative).thencallbackValid shapes stay unreported (measured): the same case, a plain block, the same branch, the same loop body, a plain assignment, a for-of over a non-empty literal (with or without braces), and an arrow defined after the guard.
Gaps left, each inferred:
A state dir that is a symlink inside the test machine pointing outside it.Closed in ebf6dab for the state and slot dirs (R5).Reflect.apply.markTestRun's LIMIT). Every such spawn found sets a scratch HOME (table above).createRequire(...)('node:fs'),.call/.apply, a class field (this.p), a Map, or a child process or shell script.unitPath('a')also coversunitPath('b'), since keys are per function.let, ayieldelement, and an assignment to a for-ofletas a write target. "Both traced since 32f74e8" was false through 5a20d35 for the class. Six shapes are still missed, each a GAP fixture since 89218df:Object.assignorObject.defineProperty);resolve(X, <absolute>)orjoin(X, '..', …)as the guard.stateDir()/slotsDir()and the open. It needs a deliberate shape, so it sits inside the accident threat model; the round-2 review accepted it.Overlaps
Slot dir: a slot path is an opaque SlotPath that only writeSlotFile and the slot readers take; tsc rejects it at any other writer [PROTECTED: owner merges] #806, Review levels: a .gitattributes mark can't hide a change's lines or content; adding one waits for the owner (#693) [PROTECTED: owner merges] #802, Release notes v0.5.3: every install-affecting change since v0.5.2, the v0.5.2 NVML correction, notes the release keeps [PROTECTED: owner merges] #809, ci: a .worklane/ change is loaded with the pinned engine release; a config it refuses fails an advisory check [PROTECTED: owner merges] #812 and bench: run driver, patch export, grading inputs, aggregates and setup README [PROTECTED: owner merges] #759 merged into main during this build. origin/main is merged into the branch (6d68bd9), with no conflicts.
git merge-tree --write-treeof this head with the open PRs that touch src/os/index.ts (measured):The Single machine: policy.yaml is root's, and the engine refuses one that isn't (#726 part 1) [PROTECTED: owner merges] #769 and Remove the desktop app, its source, Tauri lockfile and docs; keep the desktop check one more release as a no-op (#744) [PROTECTED: owner merges] #746 conflicts are the same against origin/main alone, so this PR doesn't cause them. My src/os/index.ts change is the
stateDirhunk plus one new import line. Project commands run as the agent user only in a sandbox that hides its credentials; their output is redacted (#713) [PROTECTED: owner merges] #787'sprojectCommandand Slot dir: a slot path is an opaque SlotPath that only writeSlotFile and the slot readers take; tsc rejects it at any other writer [PROTECTED: owner merges] #806'sslotsDirhunks are untouched.None of those PRs' test diffs writes a machine location (inferred, from a grep of their added lines). After they merge, this lint should stay green.
Size: about 950 lines, most of it the lint and its fixtures.
New dependencies
None.
Proposed follow-up (not in this PR)
A lint that every spawn of the engine in test/ either passes the parent's env (
childEnv()/process.env) or sets HOME andWORKLANE_STATE_DIRto scratch. That would close the from-scratch-env route by check instead of by survey.