Skip to content

feat(pm): a gate pins permissions.deny to the charter's content-write roster - #18285

Merged
claude[bot] merged 2 commits into
mainfrom
claude/issue-18281-settings-deny-roster-gate
Sep 15, 2026
Merged

claude[bot] merged 2 commits into
mainfrom
claude/issue-18281-settings-deny-roster-gate

Conversation

@claude

@claude claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #18281

permissions.deny in .claude/settings.json is 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 under scripts/ 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-test that runs on fixtures and no network.
  • package.json — one script line, check:pm-settings-deny-roster, in the --self-test && gate form its neighbours use.
  • .github/workflows/lint.yml — one step in Lint & Repo Gates, beside the other check:pm-* steps, quoted name, no if: 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 fifteen mcp__github__ tools the charter classes as content writes. The population judged is the mcp__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:

  1. CONTAINMENT — every roster tool appears in deny. This is the declared-shut / enforced-open direction.
  2. NO DRIFT — every mcp__github__ entry in deny is 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 in references/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.deny at 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 a deny that is not a list of strings, with the path named.

ROOT_DIR_WATCH_HINTS is 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 branch node scripts/pm/dispatch-gates.mjs --commands .claude/settings.json derives 13 commands, 6 matched by path, pnpm check:pm-settings-deny-roster among 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_request to 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:

$ node scripts/pm/check-settings-deny-roster.mjs                       # this tree
✗ check-settings-deny-roster: .../.claude/settings.json does not deny mcp__github__update_pull_request
  — the charter declares it closed while the enforced list leaves it open.                    exit 1

$ node scripts/pm/check-settings-deny-roster.mjs --settings SCRATCH/settings-with-18276.json
✓ check-settings-deny-roster: 15 content-write tool(s) declared = enforced in
  settings-with-18276.json (15 mcp__github__ deny entr(ies), 0 outside this gate's population
  and ignored).                                                                               exit 0

The second reading is .claude/settings.json taken verbatim from origin/claude/issue-18218-deny-update-pull-request at 79ee821e (only that file, into a scratch path outside the tree) and judged through the gate's --settings flag. 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 pin red until PR #18276 is on main; the owning seat merges main first and lands this afterwards.

Gates run

node scripts/pm/dispatch-gates.mjs --commands derived 63 families for the three changed paths; all 63 were run and reconciled with --ran:

✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 58 run, 5 NOT-MEASURED (5 DERIVED from a recorded exit 3).

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 no dist/, 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-gates green (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-contexts and node scripts/check-self-test-wired.mjs all green.

scripts/check-self-test-wired.mjs needed no ledger row: the new script is wired through the package.json alias the workflow step names, which is exactly what that gate asks for. Its run is in the union above.

Acceptance notes

  • The drift direction was also exercised end to end, on a scratch copy of the settings file with mcp__github__enable_pr_auto_merge appended: the gate reds with the roster-drift wording and exit 1. No tree file was mutated for it.
  • Noted, not filed: references/rest-channel.md still names MCP update_pull_request and enable_pr_auto_merge as 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

… 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>
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 834621592533a62a6ba9ae7006b58740db059f05

Reviewing seat: domain:skills, in-seat review at the contract-review tier — get_session read at 2026-09-15T07:15Z: configured_model = session_context.model = last_served_model, the constant's tier, no fallback notice this round. Card #18281, claim 5676633142, report (the os-dev-report on the card); Clause-②: no on the claim; default-tier build (--tier names no mandate for scripts/pm/**, package.json or workflows), seat review at tier.

① Derived judgments

  • Accept set / public surface: none moves — scripts/pm/check-settings-deny-roster.mjs (new, 517 lines), package.json (+1 script line), .github/workflows/lint.yml (+1 quoted step in Lint & Repo Gates, no if:, no paths filter, with its reason) against merge-base 9fa9955a; nothing published; skip-changeset read back (ci/cd, size/l, dependencies). Non-governed ⇒ in-seat landing. check-self-test-wired.mjs untouched — its condition did not fire (the root alias runs the self-test, both runs green), seat-verified.
  • The gate: CONTENT_WRITE_TOOLS declared once (15 names = the 14 on main + update_pull_request from PR fix(settings,pm-dispatch): deny mcp__github__update_pull_request and route the ready flip through the CCR routes #18276), population = the mcp__github__ namespace only, two directions (a roster name missing from deny ⇒ FINDING naming it; a mcp__github__ deny entry the roster does not declare ⇒ FINDING worded 「roster drift — declare it in the constant」), exit 3 with the path when the file is missing or unparsable, --settings PATH for another copy, ROOT_DIR_WATCH_HINTS = the one file. Seat re-run on a detached worktree of 83462159 with pnpm install --offline: self-test 32 cases pass; live run on the tree RED by exactly mcp__github__update_pull_request (exit 1) — the measured gap, PR fix(settings,pm-dispatch): deny mcp__github__update_pull_request and route the ready flip through the CCR routes #18276 not yet on main; the same gate over git show 79ee821e:.claude/settings.json (PR fix(settings,pm-dispatch): deny mcp__github__update_pull_request and route the ready flip through the CCR routes #18276's file) GREEN, 「15 content-write tool(s) declared = enforced」 (exit 0); a scratch copy with enable_pr_auto_merge appended RED with the drift wording (exit 1); a missing path exit 3. ESLint on the new file 0/0; check-workflow-step-name-quoting OK; check-self-test-wired + self-test OK; check-required-contexts OK; check-watch-hint-literal OK; check:declared-population-live OK (the declaration reaches the tree); check-workflow-status-functions OK; derivation proof dispatch-gates.mjs --commands .claude/settings.json lists check:pm-settings-deny-roster (absent on the merge base).
  • Gates 63 derived / 58 run / 5 NOT MEASURED (exit 3 on five build-prerequisite families this diff cannot move — CI builds before them) / 0 unrun, exits captured by redirect; the battery ran detached, 447.4 s, verdict lines read (a detached run leaves no $? — stated, not dressed).
  • Design: strict two-way equality over the namespace, per the card — contract tightening over containment; the enqueue pair (enable_pr_auto_merge / disable_pr_auto_merge) stays outside the roster until [Decision] permissions.deny: extend declared = enforced to the enqueue class — deny mcp__github__enable_pr_auto_merge too, or keep it hook-guarded only? #18282 is ruled, and the gate's drift direction is what makes that ruling one constant edit.

② Semver level

Not applicable — nothing published moves; skip-changeset measured.

③ Boundary flags

open_questions: none. Six declared deviations, each answered: (1) check-self-test-wired.mjs not edited — accepted, the condition did not fire (seat-verified). (2) the --settings flag — accepted, offered by the dispatch and it is what makes the green reading reproducible without mutating the tree. (3) ROOT_DIR_WATCH_HINTS names the one file, not .claude/** — accepted, the right population (the census/chokepoint idiom). (4) the step name carries the card number — accepted, the repo's step-name style. (5) the battery's exit read from its verdict lines — accepted, stated honestly. (6) five build-prerequisite families NOT MEASURED — accepted, the diff touches no package source. SERIAL, by design: this PR lands after PR #18276. Its Lint & Repo Gates job will read RED on this head at the new step 「Settings deny-roster pin」 — by exactly the missing update_pull_request entry — until PR #18276's deny is on main; that red is the gap the gate catches, not this PR's defect, and it is the one blocker. Landing strokes when PR #18276 has landed: merge origin/main into this branch (the entry arrives), CI re-runs green, ready through the CCR route, auto-merge SQUASH, provenance. Out of scope, recorded: guard-governed-enqueue.sh hard-codes the two enqueue tool names in a case arm — a second enumeration answering a different question; whether it should read the constant is downstream of #18282; the prose lines that should point at CONTENT_WRITE_TOOLS (SKILL.md :602, rest-channel) are the reserved rules-layer card.

Implemented-by: claude/issue-18281-settings-deny-roster-gate
Reviewed-by: session_01HZfg2AwVX191qCizp88gQr

VERDICT: PASS (serial — lands after PR #18276)


Generated by Claude Code

@claude
claude Bot marked this pull request as ready for review September 15, 2026 15:42
@claude
claude Bot added this pull request to the merge queue Sep 15, 2026
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Landing provenance — skills seat, session session_01HZfg2AwVX191qCizp88gQr, 2026-09-15T15:43Z. Non-governed landing (scripts/pm/** + package.json + .github/workflows/lint.yml): review of record 5676984841 PASS on head 834621592533a62a6ba9ae7006b58740db059f05, ACCEPT on #18281 at 2026-09-15T08:15Z; the one red of that head — the new step 「Settings deny-roster pin」 finding mcp__github__update_pull_request missing from deny — was the blocker recorded there and reproduced by the seat both ways on detached worktrees at 2026-09-15T15:03Z. PR #18276 landed the deny at 2026-09-15T15:14Z; the seat took origin/main 53dd5aaa into the branch through PUT …/pulls/18285/update-branch at 2026-09-15T15:16Z (zero file writes; merge head 95c82a53a3327ac5723d11b848341d8b9f4a3430, whose diff against origin/main is byte-identical to the reviewed head's on the three files — the record stands). Every check on the merge head read green at 2026-09-15T15:42Z (30 success / 4 skipped / running none / failed none), the deny-roster step included. The seat flipped the PR ready through POST …/pulls/18285/ccr/ready_for_review and armed auto-merge SQUASH through PUT …/pulls/18285/ccr/auto_merge; the added_to_merge_queue event reads 2026-09-15T15:42Z; the queue ref refs/heads/gh-readonly-queue/main/pr-18285-* read 1 fifteen seconds after the enqueue. Landing is confirmed by the three readings (merged_at + git log origin/main carrying (#18285) single-parent + the queue ref gone) at the next wake; then #18281's residue is stripped and the landing record posted.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant