Repository navigation
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
Conversation
…s read from main (#715) Who may approve a held change differed by surface: a listed writer's `/<cli> approve` comment landed a high-risk (`land`) decision or a held plan that the dashboard keeps for owners; `<cli> decide` appended decision.answered with no role check at all; a batched PR merged on its issue owner's review whatever their repo role, and while auto-merge was stopped. And the money-path list was read from the coordinator's working tree, which is as old as its clone, so a path main added later was L1, and a PR editing only the list was L0 docs. - mayAnswerKind (src/dashboard-auth.ts) is the one rule; the GitHub comment path, the batched-review path, the dashboard and the chat path all call it. `land` is the owners' unless review.yaml on the default branch says `approvals: { land: writers }`, and an instance's root-owned policy.yaml `writers_approve_land` allows it. The switch is read on every approval; off, the setting is ignored (never a config refusal, so turning it off can't stop an instance starting). - `decide` refuses anyone but root or the coordinator user before reading anything, and records the answer as the instance's owner through answerDecision, audited via `cli` with the OS user. - The batched-review approval checks autoMergeStopped(), as the Discord path does. - loadMoneyPaths reads the list at <remote>/<branch> with git; the list file itself is a money path. Every caller goes through one coordinator helper (base.ts moneyPaths) or the dashboard's main ref. - startConfig, with main's config invalid, falls back to the checkout's committed config, not its working tree. - Docs (design.md, separate-users.md, the review.yaml template) and CHANGELOG upgrade notes for writers who used to approve: they keep it only through the explicit review.yaml and policy.yaml settings. Test (fails without): test/decision-gates.test.ts (writer comment, CLI as another user, money list moved after the clone, config fallback) and test/pr-batch-coordinator.test.ts (stopped auto-merge, writer review). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: CHANGES NEEDED (round 1)Reviewed head Acceptance criteria
Fail-without (measured)I checked out the PR's three test files over origin/main Money-path probes on the head build (measured; a scratch repo in the scratchpad,
|
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…p re-checked before merge (#715) Review round 1 found three gaps: - `decide` took the caller's uid from `id -u` found through PATH, so a fake `id` could change who the gate thought was calling. It now comes from geteuid() through an OS-layer helper (os/index.ts currentUid). The local-site owner check stays on the very file then written, so steering logPath can only pick a log the caller already owns. - startConfig's no-config-on-main branch still ran the checkout's working tree. Both fallback branches now try the committed HEAD config first; the working tree runs only when nothing valid is committed (a first run). - mergeTested checked the auto-merge stop only when the approval was first seen. A stop raised during its checks or fetch now holds the merge right before trainMerge, for the batched review and Discord's batched path alike. Wording: the instance's policy.yaml is root's or the coordinator user's file today, root's alone once #769 lands, and never settable from the dashboard; the comments and CHANGELOG say so instead of "root-owned". Test (fails without): test/decision-gates.test.ts (a fake `id` first on PATH; no config on main) and test/pr-batch-coordinator.test.ts (a stop raised during an approved merge's checks). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 2: changes for the round-1 verdictHead
Fail-without on 875419a (measured): I checked out both test files over 875419a in a scratch worktree, built and ran them. Result: tests 28, pass 25, fail 3. The 3 failures are exactly the new cases above. On this head (measured; scratch HOME, state dirs and TMPDIR):
Adversarial pass (round 2):
Each is measured except the steering case, which is inferred. CI on |
…now runs the committed one (#715) CI shard 4 on 24c81b0 failed the four start-up tests: their example instance has no origin config and edited config.yaml (land_mode) in the working tree without committing it. Since round 2, with no config on the default branch, the coordinator starts on the checkout's committed config, never an uncommitted edit, so it started in pr mode. The fixture now commits the edit, as a real instance's config is; every assertion is unchanged. Test (fails without): test/direct-land.test.ts's four start-up tests fail on 24c81b0 with the old fixture. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 2: CI, and one fixture fixCI on
Fix (
CI on
Bootstrap was skipped as usual. Local: typecheck, |
Evaluator verdict: CHANGES NEEDED (round 2)Reviewed head
Round-1 required changes
Fail-without (measured)I checked out
On the head, 1.
|
| Attack | Result |
|---|---|
Fake id (prints 4242) first on PATH, caller owns the log |
recorded (the uid is geteuid) |
WORKLANE_PROJECT_STATE_DIR → a dir whose events.db is a symlink to a root-owned file, with a fake id printing 0 |
refused (statSync follows the link; the target is root's) |
State dir → a symlink to /etc (no events.db) |
refused (missing means fail closed) |
State dir → a symlinked dir whose events.db links to a root-owned file |
refused |
| State dir → a symlink to a dir the caller owns | passes the gate and writes only that log |
--instance x with USER, LOGNAME and SUDO_USER set to wl-x |
refused (the name comes from getpwuid) |
The INVARIANT holds (inferred, beyond the probes):
- A swap after the check: a caller could swap their own
events.dbfor a symlink to another instance's log betweenfileAccessandnew EventLog. That gains nothing. The CLI runs as the caller's own uid with no setuid, so the kernel refuses to open a log the caller can't write. A log the caller can write, they could already write with sqlite3. - A caller-owned log that the coordinator then reads: an instance's coordinator reads
<instance home>/state/events.db, in the coordinator user's 0700 home (add-project.sh), and the caller can't place a file there.instancesDir()followsWORKLANE_INSTANCES_DIR, but the--instancebranch passes only for root orwl-<name>by name, and those can only write what they already own. - Residual, not a regression: on a local site, the coordinator, its agents (which inherit
<PREFIX>_PROJECT_STATE_DIR,runner.ts:594) and the log's owner are one uid, so the local branch passes for all of them. On main anyone could rundecide, and file modes can't separate processes running as the same uid.
2. Config fallback (measured, startConfig probe on the head, then on 875419a1, whose no-config branch is main's)
| Case | Head | Before (main's code) |
|---|---|---|
| A: unborn HEAD, no origin, working tree only | working tree | working tree |
B: .worklane/ untracked, no origin |
working tree | working tree |
C: origin with no config, .worklane/ untracked (the auto-config PR not merged yet) |
working tree | working tree |
| D: config committed, no origin, working tree hand-edited | committed | working tree |
| E: committed config invalid, working tree valid | working tree | working tree |
| F: main valid, working tree hand-edited | main's | main's |
G: main dropped .worklane/ after the clone, hand edit |
committed | working tree |
It never blocks a legitimate first run (measured for A-C and E; inferred for the rest).
- A first run has nothing valid committed, so the working tree still runs.
- Every new step is inside a try/catch that falls back as before.
- Only D and G change.
Non-blocking nit: the start note says "started with the checkout's" in every fallback, so an operator can't tell whether HEAD or the working tree ran. Naming which one ran would help.
3. Wording and the PR body (inferred, by reading)
The corrections are true (see the table above).
Small inaccuracies, none of them blocking:
- design.md's Decisions line and "What this can never do" Add instances: a home per project with its own policy and credential references #2 say
decideruns only as root or the coordinator user. On a local site it also passes for the user whose event log it is. The CHANGELOG and the PR's "What changed" both say so correctly. - The PR body cites
merge-flow.ts:507. It is now 509. - "What this can never do" Run a coordinator per instance, with only that instance's logins #3 says the merge holds even for a stop raised while it is under way. After the new check,
trainMergestill awaitsbacklog.pullRequestonce beforemergeOurs, so one GitHub round-trip remains uncovered (inferred). Required change 2 closes it.
4. Every path into trainMerge (measured by grep and by a probe)
| Caller | Stop re-checked right before? |
|---|---|
mergeTested (batched review, Discord batched, batched auto), merge-flow.ts:594 |
yes, new |
mergeApproved (Discord, not batched), merge-flow.ts:658 |
yes, already on main |
mergeOrWait auto, not batched, merge-flow.ts:557-559, after combinedGate ("can take minutes", per its own comment) |
emergency only, not the auto-merge stop |
Probe on the head build (the repo's harness, with combinedGate wrapped so that it writes auto-merge-stopped.json and then returns merge): stop raised during combinedGate=true merged=true merge.done=1. The control run, with no stop raised, also merged.
Not a regression: main has the same code (origin/main:merge-flow.ts:615-618, inferred). It is still the same class this round fixed, and principle 2 decides it. trainMerge calls itself "the merge train's one door to mergeOurs", so a check there covers every caller at once (principle 4).
Safe to add (inferred): mergeDecision already refuses every auto decision while the stop is in force (merge-policy.ts:240), and both approval paths refuse it too. So a check inside trainMerge changes nothing except the race.
5. Rule 10: the direct-land.test.ts fixture (measured, then judged)
The change is safe, with no assertion changed (measured):
- The diff is 3 added lines (a comment and
git commit -am). - I ran the old fixture against the head's code: 4 of 8 fail. That is the same as CI run 39217174535.
- With the new fixture, all 8 pass.
It models a real instance better (inferred):
- A real instance's config arrives on main through the merged setup PR, so it is committed.
- The old fixture had no origin at all and relied on an uncommitted edit.
What existing installs see (judged): this is a function change, not a principle-3 reopening. An existing install changes only in cases D and G: main has no config (or an invalid one), something valid is committed at the checkout's HEAD, and the working tree has uncommitted edits. Such an install now starts on the committed config. Why I judge it this way (inferred):
- The owner asked for exactly this (Approvals: the same role gate on every surface (GitHub comment, dashboard, CLI), and money paths read from main #715: "Also cover the config fallback",
live.ts:157-175). - An uncommitted working-tree edit was never an off switch on a normal install: with a valid config on main, it never ran, before this PR or after.
- The real off switches are untouched:
policy.yamlauto_mergeandland_mode, the stops and the dashboard.
The upgrade note is there: CHANGELOG "Upgrading (from v0.5.2)" says: "the checkout's committed config runs, not a hand-edited working copy". The PR body discloses the fixture change. Suggestion, not blocking: add "commit an edit you want to run".
Protection (measured)
- Paths:
src/**(engine) and the self-modification parts (review.ts,decide.ts,dashboard-auth.ts,merge-flow.ts). This round addssrc/os/index.ts, which is engine. - Marking: the title has
[PROTECTED: owner merges], the body has**Risk class:** protected, the PR has theneeds-ownerlabel, and it has real "What this can never do" and "Compromised component" sections. - The "Coordinator user" bullet: it now says that user can set the key today. That is true (
add-project.sh:222).
Other checks
- DCO: all 4 commits signed off (measured).
- Attribution: no Co-Authored-By or "Generated with" in the commits or the PR body (measured, grep).
- Hygiene: no new dependencies and no
.claude/change.Closes #715is present. - Adversarial pass: present and updated for round 2. Its steering item was "inferred"; I measured it above.
- Rule A: the new tests run inside the isolate, and the fake
idgoes into its tmpdir (inferred from the code).
CI on ce43c049 (measured, gh pr checks 805)
Everything required passes: both test matrices on Node 22 and 24, all 8 shards, dco, denylist, push-gate, scan, scope, new-deps, agentshield and desktop. The real-systemd jobs pass too: add-project, egress firewall, test-run sandbox, the wizard end to end and the sign-in probe. Both bootstrap jobs were skipped, as usual.
merge-tree (measured, git merge-tree --write-tree)
3d24fc34: clean.- Current
origin/main178c44e6: CONFLICT insrc/coordinator/merge-flow.ts, about line 570. Merge: idempotent across a crash: merge.attempted before the API call, then reconciled, so the engine never reverts its own good merge (#733) [PROTECTED: owner merges] #793 (merged) changed the same batched-approval line: it addedtriesCapped, while this PR adds!this.autoMergeStopped(). - Single machine: policy.yaml is root's, and the engine refuses one that isn't (#726 part 1) [PROTECTED: owner merges] #769 (
10ca8a1d): conflicts in CHANGELOG.md only. - 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 (
56e97064): conflicts in CHANGELOG.md only.
Required changes
- Merge
origin/main(178c44e6) and resolve themerge-flow.tsconflict. Keep both conditions:batching && !this.emergencyInForce() && !this.autoMergeStopped() && (await this.ownerApproved(w, pr)) && !this.triesCapped(...). Re-runpr-batch-coordinator,merge-idempotentanddecision-gateson the merged head and report CI on it. - Fix the class of the stop re-check: put it in
trainMergeitself. Addif (this.emergencyInForce() || this.autoMergeStopped()) return null;immediately beforemergeOurs, after thebacklog.pullRequestawait. Keep the existing caller checks. This covers the autonomous non-batched path (measured above: a stop raised duringcombinedGatestill merges), and it closes the remainingpullRequestwindow on the two approval paths. Add a test that fails without it: a stop written duringcombinedGateon the non-batched auto path holds the merge. Then "What this can never do" Run a coordinator per instance, with only that instance's logins #3 and separate-users.md's "While it's stopped, every PR waits for a person" are true for every path.
Not blocking
- The start note could say which config ran (committed HEAD or the working tree).
- Two test messages still say "root".
- design.md's Decisions line and "can never do" Add instances: a home per project with its own policy and credential references #2 could mention the local site's log owner.
- The PR body's line reference is stale (507 should be 509).
New trade-offs for the owner
None. Item 5 is a function change the issue asked for, with an upgrade note.
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com> # Conflicts: # src/coordinator/merge-flow.ts
…l; start note names its config (#715) Review round 2 measured a stop raised while the combined gate runs on the autonomous, non-batched path still merging: only mergeTested and mergeApproved re-checked it. trainMerge is the one door to mergeOurs, so the emergency stop and the auto-merge stop are now checked there, immediately before the merge call, after its own GitHub read. That covers every path into it (the autonomous merge, mergeTested, mergeApproved). The earlier checks stay. Also: startConfig's note says which config ran (the checkout's committed config at HEAD, or its working tree); two test messages and design.md's Decisions line no longer call the policy file root's, and name the local site's log owner for `decide`. Test (fails without): test/decision-gates.test.ts "auto-merge, not batched: a stop raised while the combined gate runs holds the merge". Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 3: changes for the round-2 verdictHead
Fail-without on ce43c04 (measured): I checked
Checks (measured): typecheck passes for src and web. CI on f485333: every check passes (measured,
Bootstrap was skipped as usual. The PR body's Adversarial pass has the round-3 entries. |
Evaluator verdict: PASSFinal check of head How I ran things: scratch worktrees at Round-2 required changes
1. The merge with #793 (measured)
2. Stops at the one doorFail-without (measured). I checked
On the head all 14 pass. My round-2 probe, rerun (measured). In
A new probe for the window round 2 left open (measured).
Every path to the merge API (measured,
Is there an await between the check and the merge call?
3. Rule 10: the two config tests (measured, by diff)
4. Earlier rounds (measured)
Protection (measured)
Other checks (measured)
CI on
|
|
Round-3 final check (6263769924) non-blocking item applied by the orchestrator, body only: "What this can never do" #3 is narrowed to the check immediately before the merge call, and the backlog's token/rate-limit wait and in-flight request are listed under Left open. No code change. |
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com> # Conflicts: # CHANGELOG.md
Merge of main 128453a: CHANGELOG onlyHead
Checks (measured):
|
…quires (#715) Merging main brought #772's lint, which allows os/index.js only as a static named import; decide (#715) loaded currentUid, currentUser, fileAccess and isRoot with a dynamic import(). It was dynamic only to follow the lazy imports of the command modules around it: no cycle, and cli.ts already imports os/index.js statically, so the module was loaded at startup either way. Behaviour is unchanged (--help, status and decide give the same output before and after). Test (fails without): test/slot-path.test.ts "src/: only os/index.ts makes a slot path or opens one ..." fails on 57312ca with "src/cli.ts:1078: import() of slot-file.js or os/index.js: import its functions by name". Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
The merged trees collided with #772's slot-path lint; the fix is a static importAfter the main merge (
Fix ( Why it was dynamic (measured from history): Behaviour unchanged (measured): I built 57312ca and bf0a1bb side by side and ran the CLI in scratch HOME and state dirs.
Checks (measured):
CI on bf0a1bb is running. I'll report it once it finishes. |
Evaluator verdict: PASSFinal check of head How I ran things: scratch worktrees at 1. The delta since f485333 is main's plus the cli.ts import (measured)
2. The import changeBehaviour is unchanged (measured). I ran each command on both builds and compared the full output plus the exit code:
The events log was written under the scratch project state dir only. No import cycle and no startup change.
Tests (measured):
3. CHANGELOG (measured, in memory with
|
| Against | Result |
|---|---|
origin/main = 128453a0 |
clean (tree 0c4ce910). Main is already an ancestor of the head. |
#787 at 20524e06 (moved from 8408283d) |
clean (tree 0c8c9203) |
#820 at a3fc766c |
clean (tree 13474c23) |
#780 at a021bd8f |
conflict in CHANGELOG.md only; docs/test-runs.md auto-merges |
About the #780 conflict:
- It has two hunks: the end of v0.5.3's Upgrading list and the end of its After-install list. In each, both sides add bullets at the same place, so keeping both sides resolves it. It is mechanical.
- It is not caused by 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.
merge-tree origin/main a021bd8fgives the same CHANGELOG conflict, because 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's base is still178c44e6, before 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's layout. 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's own lines only fall inside the first hunk.
Other checks (measured)
- DCO: both new commits are signed off.
- Attribution: none in either commit message.
- Rule 10: nothing is removed or weakened. The import change satisfies Slot-dir lint: a slot path passed as a parameter through a mixed module to another module's writer isn't reported #772's lint rather than exempting it.
- Principle 3: nothing reopens, because behaviour is identical.
Required changes
None.
New trade-offs for the owner
None. This round brings in main and makes a behaviour-neutral import change. The PR is protected, so the owner merges.
…nto issue-545-slirp-test-user Conflicts: - src/project-env.ts RESERVED_NAMES: both lists kept (#780's glibc unsafe-environment names and LIBMOUNT_*, LANGUAGE; #787's SHELLOPTS, BASHOPTS, GLOBIGNORE, EXECIGNORE, BASH_*). - CHANGELOG.md, at the coordinator's anchors: - #780's Upgrading bullets go before "The dashboard never takes a Claude credential (#749, #751)". The bash-names bullet now says #713 refuses SHELLOPTS, BASHOPTS and BASH_* at the config check. - #780's test-user.sh install step is folded into #809's egress bullet: the installs with #809's caveats, then test-user.sh check per instance, then the egress check. - #780's "No new sudoers rules" line is dropped, since #809's is fuller. - #780's guardrail bullet goes before "The host egress rule covers the test-run users by default". Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Intent: One rule decides who may approve each kind of decision, and every surface uses it: a GitHub comment, a batched PR review, the dashboard, chat and the CLI. A high-risk merge (
land) is the owners' only. Writers can approve one only when review.yaml on main saysapprovals: { land: writers }and the instance'spolicy.yamlwriters_approve_landis on. That file is set by root or the coordinator user today, by root alone once #769 merges, and never from the dashboard.decideruns only as root or the coordinator user, and records the instance's owner. A batched review never merges while auto-merge is stopped, and the stop is checked again right before the merge call. The money-path list is read from main's tree, never from the coordinator's working tree, and the list file itself counts as a money path.Risk class: protected. Every path under
src/**isengine. This changessrc/review.ts,src/decide.ts,src/dashboard-auth.ts(sign-in) andsrc/coordinator/merge-flow.ts(the merge policy), which are all self-modification parts. It also changestest/review.test.tsanddocs/design.md, which are engine-guards and self-modification.Closes #715
Money-path verification (done first, as the issue asked)
Run on origin/main 0a0aeda's build, in a scratch repo, with the coordinator's clone made and then only fetched (measured:
node scratchpad/work/issue-715/verify-money.mjs):**/*.txtfalls underdocs.All three are fixed here. The fail-without test below moves main after the clone, as the issue asked.
What changed
src/dashboard-auth.tsmayAnswerKind(kind, role, approvals):landis also the writers' only withapprovals.land === 'writers'. Any other value, near-misses included, means owners only (measured, below).src/coordinator/decisions.tscommentAnswer, for land, plan, domain and question);merge-flow.tsownerApproved);answerDecision,canAnswerand decision detail);answerDecision);approveMerge). Discord is called with noapprovals, so it stays owners-only, a narrower surface like voice. Chat replies never answerland(NEVER_FROM_CHAT, unchanged).approvalscomes from.writersApproveLand(), which reads the instance'spolicy.yamlon every approval. If that read is absent or throws, it is treated as off, so the change fails closed.decide.tsapprovalsAt(root, origin/<branch>, policyLetsWriters). It reads main's config with git, caches it per config tree, and returns undefined for anything unreadable or invalid.() => true, so its own review.yaml decides.roleOf. WithoutroleOf,owners.defaultis the owner and the other listed writers are writers. If the lookup fails, the comment counts for nothing. The recorded answer now carriesrole.decide.decide.tscliDecideRefusalruns before anything is read. It allows only root, the instance's coordinator user (wl-<name>, compared with the OS's own user name, never the environment), or, on a local site, the uid that owns the event log.answerDecision, asowners.defaultwith role owner,via: 'cli', and the audit recordsos_user.--instanceis honoured now. Before, it was ignored and the cwd's log was written.!this.autoMergeStopped()is checked beforeownerApproved(merge-flow.ts:574 after merging Merge: idempotent across a crash: merge.attempted before the API call, then reconciled, so the engine never reverts its own good merge (#733) [PROTECTED: owner merges] #793), as the Discord path does. It sits beside Merge: idempotent across a crash: merge.attempted before the API call, then reconciled, so the engine never reverts its own good merge (#733) [PROTECTED: owner merges] #793'striesCapped.trainMergechecksemergencyInForce() || autoMergeStopped()immediately beforemergeOurs(merge-flow.ts:452), after its own GitHub read. Every path into a merge goes through it. The earlier checks stay.loadMoneyPaths(repo, src, rev)readsgit show <rev>:<file>. The list file's own exact path is returned first.base.tsmoneyPaths(), which readsrefs/remotes/<remote>/<branch>. That covers all 15 call sites,landing.ts:94included. The dashboard's decision detail readsorigin/<branch>.live.tsstartConfig): if main's config is invalid or missing at start, the checkout's committed HEAD config runs (with the instance check), not a hand-edited working tree. The working tree is used only when HEAD has no valid config (a first run). The missing case was added in round 2.mergeTestedchecks the emergency stop and the auto-merge stop again immediately beforetrainMerge. That covers the batched review and Discord's batched path.decidetakes the caller's uid fromgeteuid()throughos/index.tscurrentUid(), never fromidon PATH.approvals: { land: owners|writers }(optional). policy.yaml getswriters_approve_land(default false).dashboard.audit.viagetscli.No regression for existing installs (and what a writer who used to approve sees)
/<cli> approvecomment on alanddecision or a held plan no longer answers it. Nothing is recorded and the decision waits for an admin.decideneedssudo -u wl-<name> ... --instance <name>(or root).land, the install sets bothapprovals: { land: writers }in review.yaml on main andwriters_approve_land: truein policy.yaml. With only the review.yaml line, the setting is ignored, not refused. So turning the policy line off takes effect on the next approval and never stops an instance starting. (measured:letting writers approve…test)How it was verified
Fail-without (measured). I ran the new tests against base 0a0aeda in a scratch worktree, with the new test files checked out over the old
src/. All 14 new tests fail and the 12 existing batch tests pass. The key failures:Acceptance lines and the tests that fail without this change:
landdecision when policy says owners onlytest/decision-gates.test.ts"GitHub comment: a writer's / approve doesn't land…"decidefrom an unprivileged user is refuseddecidefrom an unprivileged user… is refused, and nothing is recorded" (spawns the real CLI)mergeDecisionFor)autoMergeStopped()test/pr-batch-coordinator.test.ts"batching: an owner's approving review merges nothing while auto-merge is stopped…"decideby the user whose event log it is…", run with a fakeidprinting 4242 first on PATHpr-batch-coordinator"batching: a stop raised while an approved merge is under way…"Round-2 fail-without (measured on 875419a): I checked the two test files out over 875419a in a scratch worktree, built and ran them. tests 28, pass 25, fail 3, and the 3 are exactly the new cases:
After (measured, scratch HOME, state dirs and TMPDIR):
decision-gates12/12 andpr-batch-coordinator14/14 pass.npm run typecheckis clean, src and web.npm run check:denylist: "1289 files clean".check-dco.mjs origin/main HEAD: "all commits signed off".fix-forward-coordinator's two real-git tests. They fail the same way on base 0a0aeda in this sandbox (measured), so they are not caused by this change.Changed existing test:
test/review.test.ts"money paths load from the project source of truth" now commits the list and reads it atHEAD, because the API reads a ref. It keeps every assertion it had and adds one, that the list file itself comes first. Nothing was weakened.Changed existing fixture (round 2):
test/direct-land.test.tsnow commits its instance'sconfig.yamledit, as a real instance's config is committed. Before, it relied on an uncommitted edit running when main has no config, which the round-2 fallback fix now refuses (CI run 39217174535 shows the four failures). Its assertions are unchanged.Adversarial pass
Each attack, its result and what I changed because of it:
A writer's comment approving
landor a held plan (default policy): refused. Measured, tests above.A listed writer whose repo role is viewer, or whose role lookup fails (GitHub 502): answers nothing, owner comments included when the lookup fails. Measured, new test.
A writer pushes
approvals: { land: writers }straight to main (an unprotected branch):writers_approve_land, read on every approval.I first made writers-without-the-policy a config refusal. Turning the policy off could then stop an instance starting (
loadInstancechecks the working-tree config).policyViolationsreturns nothing for it). Measured in the test.The coordinator built without the switch wired:
runSitewires() => trueonly for a local site (no policy.yaml).Hand-editing the coordinator's checkout (review.yaml
approvals, the money list, config.yaml at start): none of these takes effect. Measured:approvalsAtand the money and config tests.decideas another user:USER,LOGNAMEorSUDO_USER=wl-exampleis still refused, because the user comes from getpwuid. Measured.decideOS calls:fileAccess).userUid(user), which runsid -ufound on PATH, so a fakeidchanged it (the reviewer measured this).currentUid()(process.geteuid()). Measured: with a fakeidprinting 4242 first on PATH, the log's owner is still recorded. On 875419a the same call was refused.Steering the local log path (
<PREFIX>_PROJECT_STATE_DIR,--root) in round 2: the owner check runs on the very file then written. Steering only picks which log is written, and passes only for a log the caller already owns and could write anyway. An instance's log is its coordinator user's, so it is refused (inferred from the code; the instance branch is name-checked and the test measures it).Round 2: main's config dir deleted after the clone, plus a hand edit to the checkout: before the fix, the hand edit ran (
['hand-edited'], measured on 875419a). Fixed: committed HEAD runs. Measured.Round 2: a stop raised while an approved batched merge reads its checks: before the fix it merged (measured on 875419a). Fixed: a re-check right before
trainMerge. Measured.Near-miss policy values (
'writers ','WRITERS') or theautomationkind withland: writers: owners only. Measured, probes. The schema also rejects the near-misses (inferred, zod enum).approvalsAton a non-repo, or on a ref that doesn't exist: undefined, which means owners only. Measured, probes.A money list path outside the repo (
../outside.txt): git refuses it. A list missing on main throws, as a missing file did before. Measured, probes.A batched review while auto-merge is stopped: no merge. Once the stop is cleared it merges. Measured.
A batched review by the issue's owner who is only a writer: no merge. With both settings it merges. Measured.
Turning things off: the policy line off applies at the next approval (measured). The auto-merge stop holds approvals on both approval paths (measured).
Round 3: a stop raised while the combined gate runs on the autonomous, non-batched path.
not merged: the stop came in before the merge callfails).trainMerge's merge call.Round 3: every call into
mergeOurs(measured by grep at f485333). The only caller ofbacklog.mergeProutside the backlogs ismergeOurs(merge-flow.ts:393). The only caller ofmergeOursistrainMerge(merge-flow.ts:455), right after the new check at :452. The callers oftrainMerge:mergeOrWait, :624, aftercombinedGate): through :452. Measured by the new test.mergeTested(:660; batched auto, batched review, and Discord's batched approval throughmergeApproved): through its own :659 check and :452. Measured by the round-2 test.mergeApproved(:725; Discord, not batched): through its own :724 check and :452 (inferred: no new test for this path; the same code sits in front of it).Round 3: the start note now names which config ran: "started with the checkout's committed config (HEAD)" or "…working tree (no valid config committed at HEAD)". Measured: the two config tests assert the HEAD form.
What this can never do
policy.yamlsaying so. (tests above)Compromised component
decide(measured: refused).events.dbdirectly (sqlite, not the CLI), it could append a forgeddecision.answered. The CLI gate doesn't cover that. File modes do: the db is the coordinator user's (inferred, not re-measured here).writers_approve_landtoday (measured:add-project.sh:222creates policy.yaml as the coordinator user). Only root can, once Single machine: policy.yaml is root's, and the engine refuses one that isn't (#726 part 1) [PROTECTED: owner merges] #769 makes policy.yaml root's. Single machine: policy.yaml is root's, and the engine refuses one that isn't (#726 part 1) [PROTECTED: owner merges] #769 changesreadPolicyitself, and both reads here go through it.POLICY_KEYS, measured).approvalsfrom main and the policy's switch on each request. It can't loosen either one:writers_approve_landisn't one of the policy keys the dashboard form may change (measured by readingsrc/policy-edit.tsPOLICY_KEYS).landand plan approval by comment and by batched review. Gains nothing unless both settings are set.approvals, but without the instance's policy line it has no effect (measured). They could change the money list on main, as before. Such a change is now L3 when it comes through the harness.writers_approve_land, today alongside the coordinator user, and alone once Single machine: policy.yaml is root's, and the engine refuses one that isn't (#726 part 1) [PROTECTED: owner merges] #769 merges.Overlaps (measured,
git merge-treeagainst every open PR head)issue-693-gitattributes): clean. Sharessrc/review.ts, but my hunks there are onlyloadMoneyPathsand its two imports. Also sharesdocs/design.md.src/files auto-merge.Site.policyFileand the Coordinator construction line. That keeps this PR off therunSitesignature 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) and therunSitecall line (Single machine: policy.yaml is root's, and the engine refuses one that isn't (#726 part 1) [PROTECTED: owner merges] #769), soservice.tsmerges cleanly with both.scripts/setup/lib.sh,test/engine-update.test.ts,test/update-checks.test.ts) are files this PR doesn't touch.Left open
backlog/github.ts's token or rate-limit wait before the merge request (up to about 10 s), or while that request is in flight, can't hold that one merge. The in-flight part can't be avoided; the wait could be covered by a check passed intomergePr(a backlog-interface change, outside Approvals: the same role gate on every surface (GitHub comment, dashboard, CLI), and money paths read from main #715). Not a regression: on main the open window was the whole combined gate (inferred, round-3 final check).decideon a local site is refused there, because there's no uid to compare (fail closed). CI doesn't test Windows.fix-forward-coordinator's two real-git tests fail in my sandbox on base 0a0aeda too (measured, same "both PRs open" assertion). This is environmental here, not caused by this change. CI is the judge.done_whennames one test file.New dependencies
None.