fix(settings,pm): deny the MCP enqueue class — enable/disable_pr_auto_merge join lock 1 and the roster gate - #18317
claude[bot] wants to merge 2 commits into
Conversation
…_merge join lock 1 and the roster gate
`permissions.deny` in `.claude/settings.json` is the enforced half of the
dispatch charter's rule that GitHub writes go through the REST proxy and
never through an MCP write tool. The enqueue pair —
`mcp__github__enable_pr_auto_merge` and `mcp__github__disable_pr_auto_merge`
— was the one MCP write class left outside it: hook-guarded only, so a
non-governed enqueue could still reach GitHub through MCP under the bound
user account, and the prose had to name the exception.
The director seat ruled A: the pair joins lock 1. The REST route
`PUT .../pulls/{n}/ccr/auto_merge` already serves every real enqueue, so
nothing a seat does today is lost, and a tool the runtime refuses cannot be
mis-called by a seat that mis-reads the hook's scope.
## What lands
- `.claude/settings.json` — the two names appended to `permissions.deny`
beside the fifteen `mcp__github__` entries (15 → 17), nothing else moved.
- `scripts/pm/check-settings-deny-roster.mjs` — `CONTENT_WRITE_TOOLS` gains
the two names in the file's order (the one declaration); the header
paragraph that said the enqueue class is NOT in the roster now says it IS
and why; the membership rule above the constant covers arming/disarming a
landing; a new self-test battery of four fixture cases pins both names,
a document denying exactly the roster reading declared = enforced, and a
document missing one of the pair naming it; battery floor 7 → 8,
self-test 32 → 36 cases.
- `.claude/skills/pm-dispatch/references/rest-channel.md` line 53 — the
「auto-merge 备用 MCP 未拒」 clause is dropped in place; the row now says the
auto-merge MCP pair is denied by lock 1 too (118 → 117 bytes, one line
stays one line, the file stays at its 82-line ceiling).
`guard-governed-enqueue.sh` and its self-test are untouched: the hook stays
as defence in depth. No changeset — nothing published moves.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr
Contract reviewServed-tier: Reviewing seat: ① Derived judgments
② Semver levelNot applicable — nothing published moves; ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
维护者速读(终稿)— PR #18317 / #18282改了什么:按总监席裁决 A(你「135 同意」): 验证:主干 15 = 15 → head 17 = 17;两向反证(缺一项 → 报「未 deny」;多一项 → 报「roster drift」)各自命中;40 条派生门禁 39 绿 1 NOT MEASURED(需先构建 formula 包,CI 会跑);电池 1730 例两次通过。 要你做的:审 PR #18317,同意即 APPROVE;之后由本席按裁定 C 落地。落地后 objectui 的锁 1 镜像(objectui PR #9448)另立一张卡加同样两项,按裁决文本。 顺带发现(已立卡,不阻塞):hook 自检有一例在本容器上红——它假定不存在 Generated by Claude Code |
Fixes #18282
Draft, rules layer — stays a draft until an authorized APPROVED review. The diff touches
.claude/settings.json(a governed surface under Prime Directive 14), so no seat readies, queues, or arms auto-merge on it. Landing follows ruling C on card #17971 (maintainer direct ruling, 2026-09-13, comment 5652306063: 「C. approve 后不管后续改动都由席位落地:」) — after the authorized approval the owning seat lands it; no reviewer request comes from the dev.The ruling this implements — director seat, comment 5682626575 on #18282, verbatim and untranslated
Class-1 self-adjudication, batch #135 追认, the maintainer's 「135 同意」.
What changes — three files, nothing else
.claude/settings.json—mcp__github__enable_pr_auto_mergeandmcp__github__disable_pr_auto_mergeappended topermissions.denyaftermcp__github__fork_repository, in the file's own write-class order (issue → PR → comments → review → files → branch → sub-issue → merge → repo → enqueue). 15 → 17mcp__github__entries; every other byte of the file unchanged. Theguard-governed-enqueue.shmatcher on line 82 still namesenable_pr_auto_merge— that is the hook's registration, not a permission, and it stays.scripts/pm/check-settings-deny-roster.mjs(the gate PR feat(pm): a gate pins permissions.deny to the charter's content-write roster #18285 landed) —CONTENT_WRITE_TOOLSgains the same two names in the same order (15 → 17; still the ONE declaration). The header paragraph that said the enqueue class is NOT in the roster is re-keyed to say it IS, ruled closed, and why; the membership rule above the constant now covers "arms or disarms a pull request's landing" so the criterion and the constant agree. A new self-test batterythe enqueue class — declared closed(4 fixture cases: both names in the roster; a document denying exactly the roster reads declared = enforced; a document missing one of the pair names it);SELF_TEST_BATTERY_FLOOR7 → 8; self-test 32 → 36 cases..claude/skills/pm-dispatch/references/rest-channel.mdline 53 — the 「auto-merge 备用 MCP 未拒」 clause is dropped in place; the row now ends 「auto-merge MCP 锁 1 同拒。」. 118 → 117 bytes, one line stays one line, the file stays 82/82 on the ratchet, noruledRaisesowed.Untouched by design:
.claude/hooks/guard-governed-enqueue.shand its self-test (defence in depth, per the ruling), every other row ofrest-channel.md,platform-readings.md, objectui.Why the header paragraph is re-keyed rather than deleted — the one design choice, on the four axes
CONTENT_WRITE_TOOLSreads that header to learn the membership rule. Arming auto-merge is not obviously "publishing content", so without a sentence saying the pair is in and why, the pair reads as drift to someone applying the old rule. A reader today: the seat that mirrors lock 1 into objectui.permissions.denyto the MCP write-tool roster the charter's ACCEPT refusal names — declared vs enforced is reconciled by hand #18281, finding(settings): permissions.deny omits mcp__github__update_pull_request — an MCP content write (title/body/draft) the charter now declares closed, and the seat's ready-flip fallback names the same tool #18218, check:react-declaration-parity 是唯一没接进任何 workflow 的源码审计门禁,且无 MANIFEST 时静默 skip 退出 0 —— 它现在永远不可能红 #4690) and carries no narrative.Verification (all readings on this branch; final union on
ad7a3da5)Reverse verification — the count is the control.
6ef33128(origin/main at cut):pnpm check:pm-settings-deny-roster→✓ … 15 content-write tool(s) declared = enforced in .claude/settings.json (15 mcp__github__ deny entr(ies) …);--self-test→32 cases pass.denies mcp__github__enable_pr_auto_merge, mcp__github__disable_pr_auto_merge, which this gate's roster does not declare — roster drift. This is the direction the change removes.✓ check-settings-deny-roster: 17 content-write tool(s) declared = enforced in .claude/settings.json (17 mcp__github__ deny entr(ies), 0 outside this gate's population and ignored).;--self-test→36 cases pass.disable_pr_auto_merge: exit 1 —does not deny mcp__github__disable_pr_auto_merge — the charter declares it closed while the enforced list leaves it open.Ablation from the committed state, hash proofs per leg (
git restore --source=BASE -- PATHfor the mutation,git checkout HEAD -- PATHfor the restore, a trap on EXIT/INT/TERM with absolute paths, every blob hash compared and non-empty):.claude/settings.jsonat the base blob0acd039e(15 entries), gate at HEAD: exit 1,does not deny mcp__github__enable_pr_auto_merge, mcp__github__disable_pr_auto_merge … leaves them open. Restored to HEAD blob428a0300.391f20ce(15-name roster), settings at HEAD: exit 1,denies mcp__github__enable_pr_auto_merge, mcp__github__disable_pr_auto_merge, which this gate's roster does not declare — roster drift. Restored to HEAD blob5736aad3.git diff HEADempty;git status --porcelainempty.The hook is untouched and its self-test reads the same before and after.
git diff --stat 6ef33128 ad7a3da5 -- .claude/hooks/is empty.bash .claude/hooks/guard-governed-enqueue.selftest.shreads54 passed, 1 failedon the untouched base and byte-identically on the head (diffof the two logs empty). The one red is pre-existing and environmental, not this PR's: the casean exception-row path in a repo this container cannot resolveexpects allow on the premise that no siblingcloudcheckout exists, and this container has/home/user/cloud, which the hook resolves at its lines 453–454 and recomputes against, so it blocks. See Acceptance notes.Derived gate union on the merge commit
ad7a3da5(node scripts/pm/dispatch-gates.mjs --commands, 40 commands, identical list before and after merging origin/main; each exit captured by redirect, then--ran):✓ dispatch-gates --ran: 40 derived famil(ies) accounted for — 39 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).pnpm check:pm-settings-deny-roster,pnpm check:pm-skill-ratchet(rest-channel.md is 82 lines (ceiling 82; headroom 0)),pnpm check:pm-skill-id-lint,pnpm check:pm-half-states(on the feat(pm): the half-state patrol reads the tier a triage round artefact names #18311 copy,4196 cases pass),pnpm check:nul-bytes,pnpm check:watch-hint-literal,pnpm check:skill-frame-sync,pnpm check:pm-governed-merges,node scripts/check-self-test-wired.mjs(+--self-test).pnpm check:pm-dispatch-gates(the battery) run detached: exit 0,dispatch-gates self-test: 1730 cases pass, 454 s on this box (462 s on the pre-merge run).pnpm --filter @objectstack/lint run check:doc-formula-expressions— exit 3,PREREQUISITE NOT MET — the workspace package @objectstack/formula is not built. The diff touches no package; building@objectstack/formulameans building@objectstack/specfirst, so this gate is left to CI, where it runs on a built tree. Declared narrowing, not a skip.skip-changeset— every path is in the never-published lane (.claude/**,scripts/pm/**); no packagefiles[]moves.Acceptance notes
update_pull_requestwrapper as a live edit channel (:347 · :348 · :354 on the PR #18307 head) #18310 did for theupdate_pull_requestrows):references/platform-readings.mdstill describesenable_pr_auto_merge/disable_pr_auto_mergeBEHAVIOUR as if the MCP tools were callable — after PR docs(pm): platform-readings retires the rows that still describe MCP update_pull_request as a live channel #18307's renumbering these are lines 41 (转 draft 与disable_pr_auto_merge都做), 49 (enable_pr_auto_merge恒显式传mergeMethod), 58 (照样成功 onmergeable_state: clean), 60 (配额枯竭时回成功). Once lock 1 denies the pair those rows describe a channel no seat can reach; the RESTccr/auto_mergereadings on rest-channel 50–52 are the live ones. Not touched here — this card is three files..claude/hooks/**is outside this card):guard-governed-enqueue.selftest.shis not hermetic on a box with a siblingcloudcheckout — the case above reads red there and green in CI. Repro: with/home/user/cloudpresent,bash .claude/hooks/guard-governed-enqueue.selftest.sh→54 passed, 1 failed,FAIL want=allow got=block an exception-row path in a repo this container cannot resolve. Dedupe words: guard-governed-enqueue selftest sibling cloud checkout hermetic.维护者速读(草稿)
改了什么 — 把 MCP 的
enable_pr_auto_merge/disable_pr_auto_merge两个工具加进.claude/settings.json的permissions.deny(锁 1,15 → 17 条);门禁check:pm-settings-deny-roster的名单同步加这两条、它自己的说明改成「入队类已裁定关闭」、自测 32 → 36 例;rest-channel.md第 53 行删掉「auto-merge 备用 MCP 未拒」,改为「auto-merge MCP 锁 1 同拒」。钩子guard-governed-enqueue.sh一字未动,继续作纵深防御。为什么改 — 董事席裁决 A(#135 批追认,你的「135 同意」):真实入队全部走 REST 的
PUT …/pulls/N/ccr/auto_merge,MCP 那条备用路没人用却留着口子,靠钩子挡只覆盖受管 PR;运行时直接拒掉,席位就不可能误用,声明 = 强制的原则延伸到入队类。风险与代价(含回滚) — 席位现有流程零损失(所有真实入队都走 REST)。风险只有一种:某个会话若真去调这两个 MCP 工具会被运行时拒绝——这正是目的。回滚 = revert 本 PR 的一个 commit;门禁把 deny 与名单钉成相等,任何半回滚都会立刻变红,不可能静默存在。
席位意见 — (留空,席位定稿)
你要做的 — 审阅并 APPROVE(
.claude/settings.json属 rules 层,需要授权账号的 APPROVED);之后由席位落地,不需要你合并。objectui 镜像(PR objectui#9448 的锁 1)另开一张卡跟进,不在本 PR。Generated by Claude Code