feat(pm): a gate pins permissions.deny to the charter's content-write roster - #18285
Conversation
… roster The dispatch charter's ACCEPT refusal names the MCP GitHub write tools by pointing at `permissions.deny` in `.claude/settings.json` rather than enumerating them, and nothing under `scripts/` read that list's membership: the key occurred there only inside comments and one handoff string, and the hook self-tests grep the settings file for hook registration and two enqueue matchers, never for a tool name. A tool the charter declares closed could therefore be absent from the enforced list with every gate green. `scripts/pm/check-settings-deny-roster.mjs` declares the content-write roster once and holds the two sets equal over the `mcp__github__` namespace: a roster tool missing from `deny` is red, and a denied tool the roster does not declare is red as roster drift. Entries outside that namespace are another subsystem's and are ignored. The enqueue pair is deliberately not in the roster. Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewing seat: ① Derived judgments
② Semver levelNot applicable — nothing published moves; ③ Boundary flags
Implemented-by: VERDICT: PASS (serial — lands after PR #18276) Generated by Claude Code |
|
Landing provenance — skills seat, session Generated by Claude Code |
Fixes #18281
permissions.denyin.claude/settings.jsonis the enforced half of the dispatch charter's rule that content writes go through the REST proxy and never through an MCP write tool. The charter's ACCEPT refusal names that rule by POINTING at the deny list rather than enumerating it, and nothing underscripts/read the list's membership: the key occurred there only inside comments and one handoff string, and the hook self-tests grep the settings file for hook registration and for two enqueue matchers, never for a tool name. So a tool the charter declares shut could be absent from the enforced list with every gate green — measured, not hypothetical, on the card that preceded this one.What lands
scripts/pm/check-settings-deny-roster.mjs(new) — the gate, with a--self-testthat runs on fixtures and no network.package.json— one script line,check:pm-settings-deny-roster, in the--self-test && gateform its neighbours use..github/workflows/lint.yml— one step inLint & Repo Gates, beside the othercheck:pm-*steps, quoted name, noif:and no paths filter.No
.claude/**edit: the prose lines that should point at the constant are a later rules-layer card. No changeset — nothing published moves (skip-changeset).The gate
The roster is declared ONCE, as
CONTENT_WRITE_TOOLS: the fifteenmcp__github__tools the charter classes as content writes. The population judged is themcp__github__namespace and only it — a deny entry outside that namespace belongs to another subsystem and is ignored — and inside it the two sets must be EQUAL:deny. This is the declared-shut / enforced-open direction.mcp__github__entry indenyis one the roster declares, worded as roster drift and pointing the author at the constant.The enqueue pair (
enable_pr_auto_merge/disable_pr_auto_merge) is deliberately NOT in the roster: both are a live fallback channel inreferences/rest-channel.md, and #18282 carries the maintainer's decision on them. Should they be ruled shut, this gate needs exactly one edit — two more names in the constant — which is the point of a single constant.Exit contract: 0 declared = enforced · 1 a finding (a roster tool missing, or a denied tool the roster does not declare; a settings document with no
permissions.denyat all is this code too, since that file parses and what it says is that nothing is denied) · 2 this gate's own roster is unusable, refused rather than passed vacuously · 3 PREREQUISITE NOT MET, the settings file unreadable, unparsable, or carrying adenythat is not a list of strings, with the path named.ROOT_DIR_WATCH_HINTSis declared as a literal array naming the one file this gate opens, so the dispatch derivation places it on a settings card. Proven rather than asserted: on this branchnode scripts/pm/dispatch-gates.mjs --commands .claude/settings.jsonderives 13 commands, 6 matched by path,pnpm check:pm-settings-deny-rosteramong them; on the merge base the same invocation derives 12, 5 matched by path.Serial: lands after PR #18276
PR #18276 is the fifteenth entry — it adds
mcp__github__update_pull_requestto the deny list — and it has not landed. On this tree the gate therefore reads RED by exactly one name, which is the measured gap the gate exists to catch and not a defect in this change. Both readings were taken:The second reading is
.claude/settings.jsontaken verbatim fromorigin/claude/issue-18218-deny-update-pull-requestat79ee821e(only that file, into a scratch path outside the tree) and judged through the gate's--settingsflag. The roster was NOT shrunk to make this tree green, and the settings entry was NOT added here: that one-line change belongs to PR #18276, which is governed.So this PR is serial behind it. CI's Lint job here shows
Settings deny-roster pinred until PR #18276 is onmain; the owning seat mergesmainfirst and lands this afterwards.Gates run
node scripts/pm/dispatch-gates.mjs --commandsderived 63 families for the three changed paths; all 63 were run and reconciled with--ran:The five NOT MEASURED are the build-prerequisite gates (
check:dts-closure,check:dual-build-cjs-loads,check:lean-entry-closure,check:sourcemap-no-sources-content,check:type-check-debt), each of which exits 3 in a fresh worktree because no package has been built; this diff touches no package source and nodist/, and CI builds before running them. Of the 58 run, 57 are green and the 58th is this gate's own live run, red by the one name above. Beyond the derived union:pnpm check:pm-dispatch-gatesgreen (447s, detached); ESLint on the new file green (0 errors, 0 warnings, 1 file linted — the repo-wide sweep is CI's, and this config enables no type-aware linting, so this diff cannot move the verdict on a file it does not touch);check:workflow-step-name-quoting,check:workflow-status-functions,check:required-contextsandnode scripts/check-self-test-wired.mjsall green.scripts/check-self-test-wired.mjsneeded no ledger row: the new script is wired through thepackage.jsonalias the workflow step names, which is exactly what that gate asks for. Its run is in the union above.Acceptance notes
mcp__github__enable_pr_auto_mergeappended: the gate reds with the roster-drift wording and exit 1. No tree file was mutated for it.references/rest-channel.mdstill names MCPupdate_pull_requestandenable_pr_auto_mergeas a fallback channel while the charter's ACCEPT refusal treats the first as shut. PR fix(settings,pm-dispatch): deny mcp__github__update_pull_request and route the ready flip through the CCR routes #18276 already edits that line, so this is that PR's to settle, not a new card.Generated by Claude Code