Skip to content

Commit ceb6b5f

Browse files
claude[bot]claude
andauthored
fix(settings,pm): deny the MCP enqueue class — enable/disable_pr_auto_merge join lock 1 and the roster gate (#18317)
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 ```text A — mcp__github__enable_pr_auto_merge and mcp__github__disable_pr_auto_merge join permissions.deny in .claude/settings.json (lock 1, 15 → 17 entries); guard-governed-enqueue.sh stays as defence in depth; references/rest-channel.md :53 drops the 「auto-merge 备用 MCP 未拒」 clause. The REST route (PUT …/pulls/N/ccr/auto_merge, rest-channel :50) already serves every real enqueue, so nothing a seat does today is lost. Rules layer: the change lands as a four-piece governed draft on an authorized APPROVED, ⛔ never armed or merged by a seat; the objectui mirror of lock 1 (PR objectui#9448) gets the same two entries in its own card once this lands. ``` Class-1 self-adjudication, batch #135 追认, the maintainer's 「135 同意」. ## What changes — three files, nothing else 1. **`.claude/settings.json`** — `mcp__github__enable_pr_auto_merge` and `mcp__github__disable_pr_auto_merge` appended to `permissions.deny` after `mcp__github__fork_repository`, in the file's own write-class order (issue → PR → comments → review → files → branch → sub-issue → merge → repo → enqueue). 15 → 17 `mcp__github__` entries; every other byte of the file unchanged. The `guard-governed-enqueue.sh` matcher on line 82 still names `enable_pr_auto_merge` — that is the hook's registration, not a permission, and it stays. 2. **`scripts/pm/check-settings-deny-roster.mjs`** (the gate PR #18285 landed) — `CONTENT_WRITE_TOOLS` gains 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 battery `the 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_FLOOR` 7 → 8; self-test 32 → 36 cases. 3. **`.claude/skills/pm-dispatch/references/rest-channel.md` line 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, no `ruledRaises` owed. Untouched by design: `.claude/hooks/guard-governed-enqueue.sh` and its self-test (defence in depth, per the ruling), every other row of `rest-channel.md`, `platform-readings.md`, objectui. ### Why the header paragraph is re-keyed rather than deleted — the one design choice, on the four axes - **实际业务需求** — the next editor of `CONTENT_WRITE_TOOLS` reads 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. - **项目长远合理性** — AGENTS.md makes a gate script's own header the authority on its detail; one short paragraph with the card number is the file's existing convention (it already carries #18281, #18218, #4690) and carries no narrative. - **防 AI 写代码犯错** — an AI "tidying" the roster later would otherwise drop the pair as not-content-writes; the gate would go red on roster drift (the safety net held both ways in the ablation below), but the header should not invite that edit in the first place. - **创业阶段不扩散需求** — re-keying replaces the forecast paragraph one-for-one (net +4 header lines); no new mechanism, no staged window. ## Verification (all readings on this branch; final union on `ad7a3da5`) **Reverse verification — the count is the control.** - Base `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`. - Base roster vs a 17-entry fixture (the two new deny entries added to main's file): 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`. This is the direction the change removes. - Head: `✓ 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`. - Head roster vs a fixture missing `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 -- PATH` for the mutation, `git checkout HEAD -- PATH` for the restore, a trap on EXIT/INT/TERM with absolute paths, every blob hash compared and non-empty): - Leg A — `.claude/settings.json` at the base blob `0acd039e` (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 blob `428a0300`. - Leg B — gate at the base blob `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 blob `5736aad3`. - Restored tree: 17 = 17 green; `git diff HEAD` empty; `git status --porcelain` empty. **The hook is untouched and its self-test reads the same before and after.** `git diff --stat 6ef3312 ad7a3da -- .claude/hooks/` is empty. `bash .claude/hooks/guard-governed-enqueue.selftest.sh` reads `54 passed, 1 failed` on the untouched base and byte-identically on the head (`diff` of the two logs empty). The one red is pre-existing and environmental, not this PR's: the case `an exception-row path in a repo this container cannot resolve` expects allow on the premise that no sibling `cloud` checkout 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).` - 38 commands exit 0, including `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 #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). - NOT MEASURED: `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/formula` means building `@objectstack/spec` first, so this gate is left to CI, where it runs on a built tree. Declared narrowing, not a skip. - ESLint on the gate file: exit 0 before and after. Control-character self-scan over the three files: no hits. **`skip-changeset`** — every path is in the never-published lane (`.claude/**`, `scripts/pm/**`); no package `files[]` moves. ## Acceptance notes - Out of scope, for the seat to card (as #18310 did for the `update_pull_request` rows): `references/platform-readings.md` still describes `enable_pr_auto_merge` / `disable_pr_auto_merge` BEHAVIOUR as if the MCP tools were callable — after PR #18307's renumbering these are lines 41 (转 draft 与 `disable_pr_auto_merge` 都做), 49 (`enable_pr_auto_merge` 恒显式传 `mergeMethod`), 58 (照样成功 on `mergeable_state: clean`), 60 (配额枯竭时回成功). Once lock 1 denies the pair those rows describe a channel no seat can reach; the REST `ccr/auto_merge` readings on rest-channel 50–52 are the live ones. Not touched here — this card is three files. - Finding, for the seat (`.claude/hooks/**` is outside this card): `guard-governed-enqueue.selftest.sh` is not hermetic on a box with a sibling `cloud` checkout — the case above reads red there and green in CI. Repro: with `/home/user/cloud` present, `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. - The objectui mirror of lock 1 (PR objectui#9448) gets the same two entries in its own card once this lands — per the ruling, not this PR. ## 维护者速读(草稿) **改了什么** — 把 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](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0cc5ce8 commit ceb6b5f

3 files changed

Lines changed: 44 additions & 17 deletions

File tree

.claude/settings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@
6464
"mcp__github__sub_issue_write",
6565
"mcp__github__merge_pull_request",
6666
"mcp__github__create_repository",
67-
"mcp__github__fork_repository"
67+
"mcp__github__fork_repository",
68+
"mcp__github__enable_pr_auto_merge",
69+
"mcp__github__disable_pr_auto_merge"
6870
]
6971
},
7072
"hooks": {

.claude/skills/pm-dispatch/references/rest-channel.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
- ✓ auto-merge 挂载 `PUT .../pulls/{n}/ccr/auto_merge``{"merge_method":"SQUASH"}`,`DELETE` 卸载。
5151
-`PUT .../ccr/auto_merge` 在 draft 上 422 零存储;`DELETE` 无挂载回 422 = 本就没挂,非失败。
5252
- 入队读 timeline `added_to_merge_queue`,落地读 `git rev-list --parents`;⛔ `auto_merge` 与回显都不作数。
53-
- ⛔ 永不 MCP `update_pull_request`(锁 1 已拒);ready/draft 翻转只走 ccr 路;auto-merge 备用 MCP 未拒
53+
- ⛔ 永不 MCP `update_pull_request`(锁 1 已拒);ready/draft 翻转只走 ccr 路;auto-merge MCP 锁 1 同拒
5454
- 直合仓 `PUT .../pulls/{n}/merge`;actor 记通道令牌:REST 按会话为 `claude[bot]` 或用户,MCP 恒用户。
5555

5656
## 不可迁移 —— 只有这三件,围着它们排计划;红窗守候规则住 `platform-readings.md` 配额段

scripts/pm/check-settings-deny-roster.mjs

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,23 @@
5454
* tells a seat which tools are closed POINTS at it; a second enumeration is the
5555
* hand reconciliation this gate exists to end.
5656
*
57-
* ## What is deliberately NOT asserted
57+
* ## The ENQUEUE class is in the roster
58+
*
59+
* `enable_pr_auto_merge` and `disable_pr_auto_merge` are declared closed
60+
* (#18282, ruled A). The REST route `PUT .../pulls/{n}/ccr/auto_merge` in
61+
* `.claude/skills/pm-dispatch/references/rest-channel.md` already serves every
62+
* real enqueue, so denying the MCP pair costs a seat nothing, and a tool the
63+
* runtime refuses cannot be mis-called by a seat that mis-reads the scope of
64+
* the PreToolUse hook `.claude/hooks/guard-governed-enqueue.sh`. That hook
65+
* stays as defence in depth: it judges a governed PR's pinned approval, this
66+
* gate judges membership, and neither reads the other.
5867
*
59-
* The ENQUEUE class -- `enable_pr_auto_merge` and `disable_pr_auto_merge` -- is
60-
* not in the roster. Both are a live fallback channel in
61-
* `.claude/skills/pm-dispatch/references/rest-channel.md`, so whether they are
62-
* closed is the maintainer's call and is carded separately (#18282). If they
63-
* are ruled closed, this gate needs exactly one edit -- two more names in the
64-
* constant -- and that is the point of a single constant.
68+
* ## What is deliberately NOT asserted
6569
*
66-
* Also not asserted: that `allow` and `deny` agree, that the tools exist on the
67-
* MCP server, or that a session honours either list. This gate holds the one
68-
* property neither prose surface can hold about itself -- that the enforced
69-
* list still equals the declared one.
70+
* That `allow` and `deny` agree, that the tools exist on the MCP server, or
71+
* that a session honours either list. This gate holds the one property neither
72+
* prose surface can hold about itself -- that the enforced list still equals
73+
* the declared one.
7074
*
7175
* ## Exit contract
7276
*
@@ -99,9 +103,9 @@ import { isEntrypoint } from '../invoked-as.mjs';
99103
*
100104
* Membership rule, so the next editor does not have to infer it: a tool is here
101105
* when invoking it PUBLISHES or MUTATES repository content -- an issue, a
102-
* comment, a review body, a pull request, a branch, a file, a repository. The
103-
* enqueue pair is deliberately absent (see the header). Read-only tools are not
104-
* in this namespace question at all.
106+
* comment, a review body, a pull request, a branch, a file, a repository -- or
107+
* arms or disarms a pull request's landing, which is the enqueue pair (see the
108+
* header). Read-only tools are not in this namespace question at all.
105109
*/
106110
export const CONTENT_WRITE_TOOLS = Object.freeze([
107111
'mcp__github__issue_write',
@@ -119,6 +123,8 @@ export const CONTENT_WRITE_TOOLS = Object.freeze([
119123
'mcp__github__merge_pull_request',
120124
'mcp__github__create_repository',
121125
'mcp__github__fork_repository',
126+
'mcp__github__enable_pr_auto_merge',
127+
'mcp__github__disable_pr_auto_merge',
122128
]);
123129

124130
/** The namespace this gate judges. Entries outside it are another subsystem's. */
@@ -337,12 +343,13 @@ const SELF_TEST_BATTERIES = Object.freeze({
337343
'settingsPathFromArgv': 3,
338344
'the exit contract': 4,
339345
'the shipped .claude/settings.json': 4,
346+
'the enqueue class — declared closed': 4,
340347
'the dispatch-gates declaration': 3,
341348
});
342349

343350
// DELETING an entry silences that battery's floor exactly as effectively as
344351
// zeroing it, so the roster's own size is pinned too.
345-
const SELF_TEST_BATTERY_FLOOR = 7;
352+
const SELF_TEST_BATTERY_FLOOR = 8;
346353

347354
// The key an assertion is filed under when no battery is open. It is not a
348355
// declared battery, so it reds by the same set difference rather than silently
@@ -436,6 +443,24 @@ function selfTest() {
436443
assert('the shipped deny list declares no tool this roster lacks', verdict(shipped.deny, CONTENT_WRITE_TOOLS).drift, []);
437444
assert('every shipped deny entry is a string', shipped.deny.every((entry) => typeof entry === 'string'), true);
438445

446+
// --- the enqueue class -------------------------------------------------
447+
//
448+
// Ruled closed (see the header). Pinned on FIXTURES, not on the shipped
449+
// file: the shipped-file battery above deliberately leaves containment to
450+
// the live run, and this battery keeps that line. What it holds is that the
451+
// roster names both tools and that the gate's verdict moves with them.
452+
battery('the enqueue class — declared closed');
453+
assert('the roster declares the auto-merge arming tool', CONTENT_WRITE_TOOLS.includes('mcp__github__enable_pr_auto_merge'), true);
454+
assert('the roster declares the auto-merge disarming tool', CONTENT_WRITE_TOOLS.includes('mcp__github__disable_pr_auto_merge'), true);
455+
// A settings document denying exactly the roster -- every entry, the pair
456+
// included -- reads declared = enforced in both directions.
457+
const exactlyTheRoster = denyList(settingsWith([...CONTENT_WRITE_TOOLS]));
458+
assert('a document denying exactly the roster reads declared = enforced', verdict(exactlyTheRoster.deny, CONTENT_WRITE_TOOLS), { missing: [], drift: [] });
459+
// The pre-ruling shape, reproduced: the pair absent from the file is the
460+
// containment finding, and it names the tool.
461+
const withoutDisarm = CONTENT_WRITE_TOOLS.filter((tool) => tool !== 'mcp__github__disable_pr_auto_merge');
462+
assert('a document missing one of the pair names it as left open', verdict(withoutDisarm, CONTENT_WRITE_TOOLS).missing, ['mcp__github__disable_pr_auto_merge']);
463+
439464
// --- the dispatch-gates declaration ------------------------------------
440465
//
441466
// Enforcement cannot hold any of these from here: the declaration is read by

0 commit comments

Comments
 (0)