Skip to content

fix(claude-code-review): request checks: read so the reviewer can read check runs (closes #829) - #830

Merged
d-morrison merged 4 commits into
mainfrom
fix/829-checks-read-permission
Sep 6, 2026
Merged

d-morrison merged 4 commits into
mainfrom
fix/829-checks-read-permission

Conversation

@d-morrison

Copy link
Copy Markdown
Collaborator

Closes #829.

What

claude-code-review.yml's claude-review and post-review jobs now request
checks: read; the file header and the README's caller-permissions list name
it, and the README explains why callers must grant it as well.

Why

actions: read covers workflow runs, not check runs:
GET /repos/{owner}/{repo}/commits/{ref}/check-runs needs checks: read.
On ucdavis/bcs#964 (2026-09-05) the reviewer found no defects, then every
check-status read -- including check-pr-fully-clean.py, which it ran --
failed with Resource not accessible by integration (HTTP 403), and the run
declared the diff blocked on human review for a reason unrelated to the
diff. Re-running cannot help while the token is the same.

Consumer side: ucdavis/bcs#965 (the caller adds checks: read once this is
released).

This is a workflow-file edit, so the repo's own AI review skips it; an agy
read-only self-review is running (verdict to follow in a comment). Pushed
with ALLOW_UNREVIEWED_PUSH=1 (Morrison-Lab/ai-config#3209).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rojsq7df4i6yQ2yE9wjb2g

…d check runs (closes #829)

actions: read covers workflow runs, not check runs: GET
/repos/{owner}/{repo}/commits/{ref}/check-runs needs checks: read. Without
it the reviewer, and check-pr-fully-clean.py which it runs, got "Resource
not accessible by integration (HTTP 403)" on Morrison-Lab/bcs#964 and reported
a clean diff as blocked on human review. Both the claude-review and
post-review jobs request it now, the file header and README list it, and
the README says callers must grant it too, since a nested job cannot
request more than its caller allows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rojsq7df4i6yQ2yE9wjb2g
@d-morrison
d-morrison requested a lite review from Copilot September 6, 2026 00:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@d-morrison

Copy link
Copy Markdown
Collaborator Author

Self-review (fallback): this PR edits a workflow file, so the repo's own AI review skips it. An agy read-only adversarial review of 65e0715 against the standards below returned no findings:

  • YAML intact; checks: read added to exactly the claude-review and post-review jobs.
  • File header comment and README caller-permission list now match the permission set the jobs request.
  • GET /repos/{owner}/{repo}/commits/{ref}/check-runs requires the checks: read permission for a GitHub App / GITHUB_TOKEN, per the REST reference for check runs; the 403 on ucdavis/bcs#964 is the observed consequence of omitting it.
  • The changelog.d fragment matches its siblings (dash bullet, fixed category, issue references).
  • ASCII-only, plain prose.

Verdict

Clean.

Reviewed commit: 65e0715

Posted by Claude Code (AI agent) --- not written by a human.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

…py, and only where it is read

Review findings on #830. The example caller stub, website/permissions.qmd,
and website/reference/claude-code-review.qmd (prose and example) restate
the caller grant list and had not been swept; a consumer copying the stub
would have kept the 403 this PR fixes. post-review reads no check runs, so
its checks: read is dropped. run-review-job-split-tests.py now asserts
checks: read parity across the example stub, README, website, and the
model job itself, so the next drift fails offline. Two lines packing more
than one sentence are split for the semantic-break gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rojsq7df4i6yQ2yE9wjb2g
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

…e the parity test fail loudly

Round-2 self-review findings on #830: website/workflows.qmd and this
repo's own dogfood caller .github/workflows/claude-review.yml restated the
grant list without checks: read; the reference-example regex could match
a grant anywhere after `permissions:`, so it now admits only the block's
own `key: value` lines; and a missing doc target used to skip its
assertions silently, so it now records a failure. The parity suite also
covers workflows.qmd's scope list and the dogfood caller.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rojsq7df4i6yQ2yE9wjb2g
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished review — View run


The merge commit (5db8472) introduces no changes beyond 89cb6f6 — it's a clean fast-forward-equivalent merge with an empty diff. The full change set since the last review round is exactly these three cosmetic line-wraps.

I checked all four findings from the round-1 review (65e0715) and confirmed they were resolved in round 2 (0ca7406), which was verdicted "Ready for merge" at that comment (posted 2026-09-06T00:41:34Z, run 34001818332). This round's diff (082bb7a..5db8472) only rewraps three prose lines in website/permissions.qmd, website/reference/claude-code-review.qmd, and website/workflows.qmd to satisfy the 80-column qmd lint — verified above that each wrapped line is now ≤80 characters, the wrap points are semantically neutral, and no wording, links, or code spans changed. No new documentation-drift sites, no functional changes, and no regressions were introduced by this range.

The prior round's "Ready for merge" verdict stands; this round adds nothing new to review.

Verdict

Ready for merge

Structured Review Data (JSON)
{
  "schema_version": "1.1",
  "reviewer": "claude",
  "commit_sha": "5db84725327775adacbdfa47e2273eee7a21ee50",
  "verdict": "CLEAN",
  "findings": [],
  "detailed_assessment": "The only changes since the prior CLEAN verdict are three cosmetic line rewraps in website/permissions.qmd, website/reference/claude-code-review.qmd, and website/workflows.qmd to satisfy the 80-column qmd lint, with no wording or semantic content altered.",
  "holistic_assessment": "All four blocking findings from round one were already resolved and confirmed clean in round two; this round introduces no functional, documentation-drift, or scope regressions requiring further validation."
}

Reviewed commit: 89cb6f6

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

💰 Cost: $0.7220 (review) — run

@github-actions

github-actions Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.

2026-09-06 00:57 UTC

@d-morrison
d-morrison merged commit c07f7d4 into main Sep 6, 2026
68 checks passed
@d-morrison
d-morrison deleted the fix/829-checks-read-permission branch September 6, 2026 00:56
@d-morrison

Copy link
Copy Markdown
Collaborator Author

Post-merge incident: sliding v2 to this merge (c07f7d45) made the reusable workflow's claude-review job request checks: read before any caller granted it, so consumer review dispatches now end in startup_failure (ucdavis/bcs#966: run at 00:32Z succeeded, run at 01:03Z failed; slide at 00:58Z). Same class as gha#685. Remedy: move v2 back to 898a3e2e until callers grant the permission (ucdavis/bcs#966 is the bcs caller change), then slide again. This session could not perform the tag rollback (force-push denied by its permission classifier); the maintainer has been asked.

Posted by Claude Code (AI agent) --- not written by a human.

d-morrison added a commit to Morrison-Lab/ai-config that referenced this pull request Sep 7, 2026
…3313)

Every claude-review run in this repo has ended in startup_failure since
2026-09-06T06:50Z, across four different branches. The last success was
2026-09-05T18:09Z.

Cause, already documented as ai-config#3303: Morrison-Lab/gha#830 added
'checks: read' to the reusable claude-code-review.yml's review job, and v2 was
slid to it. A called job may not request more than its caller grants, so every
consumer whose caller lacked that grant broke at parse time. gha@v2 line 550
declares it; this caller granted contents, pull-requests, issues, id-token and
actions, but not checks.

The failure is silent in the way that matters: a workflow that never starts
contributes no failing check run, so a PR shows green checks and no red one
while nothing has reviewed it. check-pr-fully-clean.py correctly reports no
review found, which reads as 'not yet' rather than 'broken'.

This unblocks every open PR in the repo, not only the one that surfaced it.

Refs #3303, #3309.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
d-morrison added a commit to Morrison-Lab/ai-config that referenced this pull request Sep 7, 2026
…ected claims (#3323)

* ums: a missing review, an unchained mutation, and two agy/classifier gotchas

Four learnings from the 2026-09-06/07 session, each extending an existing
memory entry rather than adding a new file:

- gha-reusable-workflow-permissions.md: the checks: read startup_failure
  (ai-config#3303, Morrison-Lab/gha#830) as a fourth occurrence of this
  file's permission-mismatch class, plus the new insight that
  workflow_dispatch runs the default branch's workflow file, so a
  caller-permission fix on main heals every open PR via redispatch with
  no branch sync.
- git-worktrees.md: extends the existing refspec-push entry with the
  detached-checkout form for creating new commits on a branch checked out
  elsewhere, and records flag-unchained-branch-switch.py firing correctly
  on a live incident (three of four checkouts failed, the unchained merge
  ran anyway and reported false success).
- delegation.md: extends the agy headless-permission section with the
  command-vs-write_file asymmetry and the agy -p "" empty-prompt fast-fail,
  distinguishing it from a genuine stall.
- mistake-patterns.md Pattern 43: records the new symptom that the
  auto-mode classifier's escalation spreads to unrelated innocuous
  commands, and that a same-session identical retry (no restart) recovers
  the goal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* ums: correct a wrong redispatch claim, plus five new learnings

Corrects gha-reusable-workflow-permissions.md's prior "redispatch heals
every open PR" entry: workflow_dispatch does run the default branch's
fixed caller and does post a review comment, so check-pr-fully-clean.py
correctly goes green, but the run attaches its check to main's SHA, not
the PR head, so the required review/require-review check never appears
and the PR stays unmergeable despite the clean instrument verdict
(measured on ai-config#3305). Also corrects a second, untested claim in
the same entry: a pull_request event's merge-ref semantics do not
substitute for a push -- #3305's reopen (head SHA unchanged) hit
startup_failure while #3312's push succeeded, with neither branch
carrying the checks: read fix, so branch content was not the variable.

New learnings, each extending an existing file:

- review-verdict-pitfalls.md: no-push-without-self-review.py's
  VERDICT_LINE regex accepts only "Ready for merge"/"Needs (more) work"
  and reads a report's "CLEAN" (the vocabulary check-pr-fully-clean.py
  and fully-clean.md use) as no verdict at all (ai-config#3018).
- mistake-patterns.md Pattern 52: a variable cd target on a guarded push
  now resolves to a wrong commit and reports it as fact rather than
  admitting it could not be resolved, worse than the case #2680 fixed
  (ai-config#3319).
- dont-reinvent-wheel.md: "reuse the shared module" is a correctness
  claim about that module and should be measured -- scripts/lib/shellcmd.py
  has a false-positive wrapper-lookahead bug and a false-negative escaped-
  heredoc bug on top of its already-fixed heredoc-scanner history
  (ai-config#3321).
- algorithmatize-checks.md: a warn-only guard's false positive and false
  negative are not equally expensive (over-warn by default), while a
  blocking guard's two directions both cost real work and want precision
  instead (ai-config#3302, ai-config#3304).
- derive-dont-enumerate.md: enumerating review findings for a subagent
  hands over a summary instead of the primary source; briefing with the
  deriving command let a subagent rebut a finding with measured evidence
  instead of complying with an unchecked summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ums: cite the report that actually reproduces the verdict-vocabulary bug

Review finding: the entry's 'observed live' example named the report that
carried BOTH vocabularies -- prose 'Ready for merge' beside JSON
'"verdict": "CLEAN"'. That report satisfies VERDICT_LINE on its prose half,
so it is not an instance of this bug, and the refusal recorded against it on
ai-config#3018's opening post has a separate, still-unverified cause.

The reproduction is the 2026-09-06 comment on the same issue, whose prose
verdict read solely '### Verdict: CLEAN'. Cites that instead, and adds the
discriminator so the wrong report is not picked up again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
d-morrison added a commit to Morrison-Lab/rme that referenced this pull request Sep 7, 2026
Morrison-Lab/gha's v2 tag moved on 2026-09-06T00:56:53Z to c07f7d45,
which added `checks: read` to the reusable review job so it can call
/repos/{owner}/{repo}/commits/{ref}/check-runs (Morrison-Lab/gha#830).

A called reusable workflow cannot request a permission its caller
withheld, so from the next review trigger onward every Claude Code Review
run ended in `startup_failure` -- on PR branches and on main alike, with
no job created and therefore no check run to go red. Reviews stopped
happening and nothing said so.

Swept all twelve Morrison-Lab/gha callers across .github/workflows/,
comparing each callee's declared permission keys at @v2 against what the
caller grants. This is the only one with a gap; with it closed, all
twelve report ok.

Note the sweep had to glob both `*.yml` and `*.yaml`: an earlier pass
globbed only `*.yml`, examined eleven callers, and reported that count as
if it were the population. It missed check-spelling.yaml entirely. That
caller turns out to be fine, so the fix was never wrong -- but the
completeness claim was, which is the more dangerous half.

Closes #1142

Co-authored-by: d-morrison <2474437+d-morrison@users.noreply.github.com>
d-morrison added a commit that referenced this pull request Sep 7, 2026
…fails callers

A reusable workflow's job cannot request a permission its caller lacks: the
run ends in startup_failure before any job starts. #830 added checks: read to
the claude-review job, and the v2 slide onto c07f7d4 killed review dispatch
in every consumer that had not granted it.

The parity test now pins the model job's permission SET rather than one key,
so the next addition of any name fails offline instead of at a consumer's
next PR.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d-morrison added a commit that referenced this pull request Sep 7, 2026
R5-F4 is the substantive one. README already carries 'Widening permissions
is a breaking change', with the identical prior incident measured (#685,
when #638 added issues: read to ai-code-review.yml and the v2 slide
delivered it). My CLAUDE.md addition re-derived that rule without citing
it, and prescribed a WEAKER remedy: grep the diff, where README says sweep
REVDEPS.md and PR the caller grants BEFORE sliding. An operator following
CLAUDE.md alone would have shipped #830 exactly as it shipped.

I found this by grepping for 'checks: read' rather than for the concept --
the corpus-gap-from-a-grep trap. CLAUDE.md now defers to README as the
authority, adds only the check that makes it fire, and records the actual
lesson: the rule was written down with a worked precedent and was not
consulted, so the check belongs at the readiness bar that made it feel safe.

R5-F1 Four sites still said checks: read is 'not yet used by @v2'. v2 points
      at the commit that DOES request it, so the grant is currently the only
      thing keeping a caller alive -- and one of the four is gha's own
      caller, whose grant is why this repo is the 1-of-18 still working. A
      maintainer trimming an 'unused' permission would have broken it.
R5-F2 The workflow comment still named #831 as the live tracker of a moving
      count, and this PR closes #831. Retargeted to #833; #831's frozen
      figure is labelled as frozen and dated.
R5-F3 '@v2' meant the tag as it resolves today in one sentence and the tag
      after the slide six lines later, unmarked. Both now say which.
R5-F5 'currently points at' and 'required by the currently-tagged @v2' were
      undated volatile claims that go false at the slide. Dated.
R5-F6 The same point was made three times and #833 linked twice in one
      paragraph. Duplicate sentence dropped.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d-morrison added a commit that referenced this pull request Sep 7, 2026
…fails every caller without it (closes #831) (#832)

* chore: open PR for #831 (v2 checks: read outage)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(claude-code-review): drop the callee's checks: read that startup-fails callers

A reusable workflow's job cannot request a permission its caller lacks: the
run ends in startup_failure before any job starts. #830 added checks: read to
the claude-review job, and the v2 slide onto c07f7d4 killed review dispatch
in every consumer that had not granted it.

The parity test now pins the model job's permission SET rather than one key,
so the next addition of any name fails offline instead of at a consumer's
next PR.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: state that checks: read is caller-recommended and callee-unrequested at v2

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address adversarial review round 1 on #832

F1 The comment claimed the outage ended 'until the tag moved back'. v2 is
   still c07f7d4; the rollback never happened. Says so, and says plainly
   that merging fixes nothing until v2 is slid onto the merge commit.
F2 '18 consumers' was the count of repos pinning @v2, not the count that
   broke. Derived: 16 of 18. gha and ucdavis/bcs already had the grant.
F3 CLAUDE.md's tag-slide readiness bar still said green-and-none-pending,
   which c07f7d4 satisfied -- the policy that permitted the incident was
   the one site not updated. Adds the permission-addition gate, with a
   command verified to flag the real incident (898a3e2..c07f7d4).
F4 The example stub and dogfood caller comments, and a test message, still
   said the model job reads check runs. It no longer can.
F5 The workflows.qmd needle could not show the list 'ends' -- appending the
   grant left it green. Anchored on the closing paren; mutation-confirmed.
   Also adds the missing self-test case for an ADDED scope, which nothing
   in the suite demonstrated; confirmed non-vacuous by weakening the
   assertion.
F6 'the v3 that will request it' now cites #833 at all five sites.
F7 Rewrapped the header sentence at a clause boundary.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address adversarial review round 2 on #832

R2-F1 The '18 consumer repositories' the round-1 sweep missed, in the test
      file's own comment -- invisible to a line-based grep because it spans
      a line break. Swept by joining comment continuations instead.
R2-F2 CLAUDE.md said consumers 'lost review dispatch until gha#832'. That PR
      is unmerged and merging it is not what ends the outage; the same diff
      says so twelve lines away. Claim removed.
R2-F3 '16 broke' was the count STILL broken. bcs#966 merged 19:14Z, hours
      after the slide, so bcs broke and was then repaired: 17 broke, 16
      remain. Corrected at all six sites.
R2-F4 The gate command resolved the tag from a LOCAL ref, which this same
      file documents as stale after a slide. Now resolves via ls-remote and
      diffs against FETCH_HEAD.
R2-F5 The gate flagged this very PR: a caller's comment-only change on an
      unchanged key. Now scoped to workflow_call callees, and labelled a
      prompt rather than a gate, with its residual imprecision stated and
      #836 filed for the parsed instrument.
R2-F6 'the one breaking change CI cannot see' was a false universal; it is
      a family, and the siblings are named.
R2-F7 The round-1 rewrap produced a 128-char header line. Rewrapped.
R2-F8 'recommended but not yet used: ... so the reads fail' read as though
      granting stops the 403. Says plainly that at @v2 the reads fail either
      way, because the model job does not request the scope.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address adversarial review round 3 on #832

R3-F1 The residual count decayed while the PR was being written --
      ai-config granted checks: read 15 minutes before the review, making
      '16 still broken, only bcs has added it' false on both halves. The
      consumer docs and changelog now carry only the fixed historical fact
      (17 of 18 broke) and point at #831 for the number that moves; the
      workflow comment dates its figure. #833's own '18' corrected too.
R3-F2 website/workflows.qmd was the one doc that never got round 2's F8
      correction, so it still implied a caller's grant fixes the 403.
R3-F3 The gate globbed *.yml only -- the exact drift workflow_discovery.py
      exists for, and untestable here since the tree has no *.yaml
      workflow -- and enumerated callees from the working tree while
      diffing FETCH_HEAD. Both fixed or disclosed, and the limitation list
      now says it is partial.
R3-F4 'changing a required input's default' cannot break a caller: the
      default is unreachable. Replaced with the two that do break --
      changing an OPTIONAL input's default, and making an optional input
      required.
R3-F5 checks: read sat in a must-grant list while the prose below called it
      inert; marked '(recommended, see below)'. The reference page's
      example stub was the third copy and lacked the comment the other two
      gained.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address adversarial review round 4 on #832

R4-F1 The docs asserted present-tense that the 403 happens whether or not
      the caller grants the scope. v2 still points at the commit that DOES
      request it, so until the slide a grant is exactly what makes a caller
      work -- bcs has been green since 19:15Z on the strength of it. As
      written a repaired consumer could read the page and remove the grant.
      Now states what holds before the slide, after it, and at the v3.
R4-F2 Three docs named #831 as the live tracker of the remaining count,
      and this PR closes #831. Retargeted to #833, which stays open and is
      where consumer adoption is the v3 precondition.
R4-F3 '18 repositories pin @v2' names a strictly larger population than the
      one measured -- ucdavis/hac.sap pins quarto-publish@v2 and was never
      affected. Says 'this workflow at @v2' at all six sites.
R4-F4 The closing-paren anchor guarded only workflows.qmd; the identical
      parenthetical in three other docs was unguarded. Now looped over all
      four, mutation-confirmed red in each.
R4-F5 perm_block tolerated a trailing comment on the target line but not on
      the intermediate ones, so a pure reorder of two correct grants turned
      the suite red. Fixed; reorder now passes.
R4-F6 'gha#836 carries the full list' was backwards -- CLAUDE.md's list is
      a superset. Reworded to 'carries the reasoning'.
R4-F7 The CI-invisible family omitted renaming or removing a JOB, which is
      the one this repo's own reference pages expose consumers to by
      telling them to gate branch protection on job names.
R4-F8 '(recommended, see below)' was a forward reference. Replaced with a
      self-contained qualifier.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address adversarial review round 5 on #832

R5-F4 is the substantive one. README already carries 'Widening permissions
is a breaking change', with the identical prior incident measured (#685,
when #638 added issues: read to ai-code-review.yml and the v2 slide
delivered it). My CLAUDE.md addition re-derived that rule without citing
it, and prescribed a WEAKER remedy: grep the diff, where README says sweep
REVDEPS.md and PR the caller grants BEFORE sliding. An operator following
CLAUDE.md alone would have shipped #830 exactly as it shipped.

I found this by grepping for 'checks: read' rather than for the concept --
the corpus-gap-from-a-grep trap. CLAUDE.md now defers to README as the
authority, adds only the check that makes it fire, and records the actual
lesson: the rule was written down with a worked precedent and was not
consulted, so the check belongs at the readiness bar that made it feel safe.

R5-F1 Four sites still said checks: read is 'not yet used by @v2'. v2 points
      at the commit that DOES request it, so the grant is currently the only
      thing keeping a caller alive -- and one of the four is gha's own
      caller, whose grant is why this repo is the 1-of-18 still working. A
      maintainer trimming an 'unused' permission would have broken it.
R5-F2 The workflow comment still named #831 as the live tracker of a moving
      count, and this PR closes #831. Retargeted to #833; #831's frozen
      figure is labelled as frozen and dated.
R5-F3 '@v2' meant the tag as it resolves today in one sentence and the tag
      after the slide six lines later, unmarked. Both now say which.
R5-F5 'currently points at' and 'required by the currently-tagged @v2' were
      undated volatile claims that go false at the slide. Dated.
R5-F6 The same point was made three times and #833 linked twice in one
      paragraph. Duplicate sentence dropped.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address adversarial review round 6 on #832

R6-F1 The four-doc parity needle was a whitespace-intolerant substring, so
      a semantic-line-break reflow -- the edit this repo's own review item 7
      asks for -- turned it red claiming the model job holds checks: read.
      Regex now tolerates the reflow; still red on an appended grant.
R6-F2 The gate flagged every permission line of a callee ADDED since the
      tag, which has no callers to break (measured on
      check-code-similarity.yml, new in #728). --diff-filter=M drops those;
      the rename consequence is disclosed.
R6-F3 'the model job stops requesting it' was stated twice, six lines apart,
      in three files. Merged, with the rationale attached to the first.
R6-F4 workflows.qmd was the one prose site left undated, and the three
      consumer-copied stubs justified the grant by a fact that goes false at
      the slide -- and then lives forever in every repo that copied them.
      Reason is now durable: the v3 reinstates it on the callee.
R6-F5 The exact-set assertion fires on a REMOVAL too but reported it as an
      addition needing a v3, contradicting CLAUDE.md.
R6-F6 Breaks fell mid-noun-phrase ('a called' / 'workflow').
R6-F7 The PR body still said 16 and called bcs unaffected -- written before
      17 was derived. bcs broke for ~18h. Body rewritten.
R6-F8 'gha#831 ... freezes at 15' made the issue the subject; and the
      population count was undated while the figure it counts was dated.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address adversarial review round 7 on #832

R7-F1 is the substantive one. My bullet's stop action was 'go do the REVDEPS
sweep', and REVDEPS.md lists 4 repos of which 1 names this workflow, while
its owner-scoped fallback omits Morrison-Lab and Lacaedemon -- the org this
repo now lives in. A sweep run during the outage would have found 1 of 18
consumers and reported the rest safe, so the remedy I pointed at would have
reproduced the incident. REVDEPS.md now leads with the unscoped per-workflow
search that actually worked, records why the scoped list is a fallback, and
adds the two missing owners; CLAUDE.md carries the derivation inline rather
than delegating it.

R7-F2 'only additions break callers' missed value escalation: contents: read
      to contents: write adds no key and startup-fails every read-only
      caller. The grep surfaces it and the prose said to dismiss it.
R7-F4 The workflow comment attributed the figure 15 to #831, whose body says
      18 and whose comment said 16; #833 and #829 still said 18. Derivation
      posted to all three.
R7-F5 perm_block tolerated trailing comments and reflows but not a full-line
      comment inside the block -- which the sibling stub this PR touches
      already uses. Mutation-confirmed both ways.
R7-F6 workflows.qmd kept the duplication fixed in the other three files, and
      an 'It' whose nearest antecedent was the wrong noun.
R7-F7 'all three phases:' enumerated two; the middle one is now named.
R7-F8 The stub comment gave only the post-slide reason, so it omitted that
      the tagged @v2 makes the grant mandatory today -- the mirror of the
      round-6 error.

R7-F3 (branch unpushed) resolves with this push; the pre-push guard held it
until a clean verdict.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address adversarial review round 8 on #832

R8-F1 and R8-F2 are both on my own REVDEPS edit, and F1 is the same class
this PR keeps fixing: I asserted the old owner-scoped sweep would have found
'1 of the 18' without deriving it. Measured, it returns 10 of 18 -- the
figure 1 belonged to the consumer table, a different list. Both figures are
now derived and dated.

R8-F2 The fallback queries could never work: GitHub code search reads a
      leading 'uses:' as a qualifier and drops the term, so both returned 0
      hits under every owner list -- indistinguishable from no consumers.
      Measured 0 with the prefix, 30 without. Prefix dropped.
R8-F3 The tag-slide section's closing Do/Don't still presented green CI as
      the whole gate, which is what the new bullet exists to deny.
R8-F4 The consumer-search snippet hard-coded @v2 while the same section says
      to derive the major -- and #833 cuts a v3 that would be missed.
R8-F5 CLAUDE.md's enumeration of what run-review-job-split-tests.py pins
      omitted this change's central guard, describing the suite as weaker
      than it is.
R8-F6 README and website/versioning.qmd -- the sections the new bullet calls
      the authority -- still prescribed the REVDEPS sweep this PR documents
      as having missed most affected repos.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address adversarial review round 9 on #832

Every finding this round was on a numeric claim I wrote into documentation
without deriving it -- the third round running where that is the dominant
class, and the same failure the PR itself is about.

R9-F1 'the 6 under Morrison-Lab and Lacaedemon plus 2 more' invented a
      residual category. Derived: the old five-owner list misses exactly 8,
      all under those two owners (Morrison-Lab 7, Lacaedemon 1), and the
      corrected list returns all 18.
R9-F2 README and versioning.qmd said 'its owner-scoped fallback returned 10',
      dropping REVDEPS.md's own 'pre-2026-09-06' qualifier, so they
      discredited the fallback this PR had just fixed. Both now attribute
      the 10 to the retired list and say the current one returns 18.
R9-F3 CLAUDE.md still summarized README's remedy as 'sweep REVDEPS.md',
      which this PR removed from README -- and contradicted its own text 35
      lines later.
R9-F4 The search snippet interpolated $major before the block that assigns
      it. Run verbatim it does not error: it drops to an unpinned query,
      measured 34 hits against 29, silently widening a blast-radius check.
      The assignment moved into the same block.
R9-F6 The --diff-filter=M rationale cited a false positive that cannot be
      observed: check-code-similarity.yml predates the current tag, and the
      v2..main range adds no workflow at all (11 M, 0 A). The comment now
      says the filter drops nothing today and names a range that shows the
      shape.
R9-F5 The owner-scoped fallbacks omitted --limit, so gh's default of 30
      truncates silently -- the exact failure the surrounding note warns
      about.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address adversarial review round 10 on #832

R10-F1 'The list below ... returns all 18' pointed at the BROAD fallback,
       not the pinned query the 18 was derived from. Measured 2026-09-07:
       that fallback returns 100 hits across 4 repositories at --limit 100,
       and 304 across 28 at --limit 1000 -- so it was truncating exactly as
       silently as the default 30, in the block whose own comment claimed
       --limit fixed that. Cap raised, the measurement recorded, the
       sentence scoped to the pinned query, and a re-raise instruction added
       for when the hit count equals the cap.
R10-F2 The pre-slide grep matched only the values read and write, so a block
       collapsing to permissions: write-all -- a widening that startup-fails
       every narrower caller, and a spelling the suite's own fixture uses --
       was invisible. Pattern and prose both widened; verified against a
       synthetic write-all line, and confirmed still clean on the live range
       and still red on the incident range.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address adversarial review round 11 on #832

R11-F1 The 'permissions: (read|write)-all' alternative I added last round was
       dead code: the value pattern is unanchored, so 'write' already matches
       the prefix of 'write-all'. Verified -- the full pattern and the first
       alternative alone match an identical set. Removed, and the caveat list
       now records what the pattern really does: an INDENTED write-all is
       caught, a workflow-level one at column 0 is not, and that gap is
       unreachable today because every callee job declares its own block.
R11-F2 'the pre-2026-09-06 owner list' asserted a supersession that never
       happened -- git says the OWNERS line last changed 2026-07-28 and the
       names 2026-06-18, and the list is replaced by THIS commit. Three
       sites now say 'the list this change replaces' and date it from
       history. README and versioning.qmd additionally narrated this diff's
       own edit in the past tense, which is false on main.
R11-F3 The pre-slide caller search carried --limit 100 with no truncation
       caveat, on the one surface where truncation costs an outage. REVDEPS
       spells the hazard out; CLAUDE.md now does too, with the measured
       busiest count (claude.yml, 29) so the cap is checkable.
R11-F4 REVDEPS's note still framed itself around moving @v1 and cutting @v2.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address adversarial review round 12 on #832

R12-F1 is the one that matters, and it inverts my own recommendation. The
unscoped per-workflow search I had been telling operators to prefer returns
17 of the 18 consumers: reproduced across three runs, it omits
d-morrison/rme, a public non-archived repo whose caller does pin this
workflow at @v2. The owner-scoped form finds it. Neither form is complete,
neither reports dropping anything, and the --limit guard cannot see it
because 28 never equals the cap.

Following my own instructions before the next slide would therefore have
PRed 17 callers, slid the tag, and startup-failed the eighteenth -- the
gha#831 outage reproduced by the procedure written to prevent it. Every site
now says to run both forms and union them, with the measurement recorded.

R12-F2 'the busiest workflow was claude.yml at 29 hits' was wrong on both
       identity and count -- 29 is claude.yml's UNPINNED figure, while
       claude-code-review.yml measures 28 pinned and 34 unpinned. The
       adjacent 34-against-29 pair mixed a scoped and an unscoped count. The
       specific ceiling is dropped in favour of the mechanical check, since
       a remembered ceiling is the same defect one level down.

Also refreshes the dated consumer figure from the union query: 14 of 18 lack
the grant as of 2026-09-07 02:24 PDT (rme added it since the last reading).

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address adversarial review round 13 on #832

Round 12's headline finding was my own, and it was wrong. I claimed the
unscoped per-workflow search structurally omits a consumer, on three
repeated runs. Re-measured: d-morrison/rme's caller was pushed at 01:55 PDT
(rme#1143), my readings were at ~02:24, and by 02:53 the same query returns
29 hits across 18 with rme present, stable over four runs. The omission was
code-search INDEX LAG, and my three repetitions were three samples of one
stale index inside a ~30-minute window -- repetition is not independent
sampling when the thing sampled is a cache.

All four sites now carry the true lesson instead: the index lags a recent
push, so union the two query forms, treat any count as a floor, and re-run
after a delay when a caller may have changed recently. The owner-list half
of the evidence was solid and is kept.

R13-F2 'Measured 2026-09-06: 0 with the prefix, 30 without' cited 30, which
       is gh's default cap -- the exact truncation the next code block in
       the same file warns about. Re-measured at --limit 1000: 0 and 304
       across 28 repositories. The artifact is now named as one.
       The adjacent '28 pinned' figure was stale at 29; the comment no
       longer depends on either number.

Also corrects a prose overclaim the reviewer raised as non-blocking:
CLAUDE.md said the model job requests those four keys 'at read and nothing
more', but the exact-set assertion is over KEYS, with values pinned
separately against write. Mutation-confirmed: pull-requests: none passes.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address adversarial review round 14 on #832

R14-F1 The union instruction referenced ${OWNERS[@]} three lines before the
array was assigned, under a header introducing a different sweep. Followed
in order, bash expands an unset array to zero words, so the 'owner-scoped'
per-workflow query is byte-identical to the unscoped one already run and the
union silently collapses to a single form -- the exact failure the
surrounding note exists to prevent, reproduced inside the instrument written
to prevent it.

The block is reordered so OWNERS is assigned first (both sweeps use it), the
two per-workflow forms are spelled out as real adjacent commands rather than
one command and a comment, and the reason for the ordering is stated so a
later edit does not undo it. Run verbatim in order, both forms now execute:
29 hits / 18 repos each.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: correct a misleading REVDEPS clause (review round 15)

'returns all 18 -- under the PINNED per-workflow query, not under the broad
fallback beside it' reads as saying the broad sweep would miss consumers the
pinned query finds. Measured twice on 2026-09-07: the broad sweep returns
304 hits across 28 repositories, and that set is a strict superset of the 18
(pin - broad is empty).

The true distinction is not coverage but specificity: the broad form cannot
say WHICH callers pin the workflow being changed. Only the truncated
--limit 100 run omits any of the 18, and the truncation note beside it
already covers that. Reworded to say what actually separates the two forms.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: make the pre-slide snippet work on a shallow clone (@claude review)

The @claude review's one finding, non-blocking but real and reproduced: the
snippet resolved $tagsha with git ls-remote, which reads the remote without
fetching anything, then diffed against that sha after fetching only main. On
a shallow clone -- what actions/checkout gives by default, and what the
review job itself runs in -- every loop iteration dies with 'fatal: bad
object'.

Reproduced on a --depth 1 clone and fixed by fetching the tag OBJECT
(git fetch --tags --force) and diffing against the tag ref rather than a
bare sha. Verified on both a shallow and a full clone: the loop completes,
stays clean on v2..main, and still fires on the incident range.

--force because a slide moves the tag, which this file already documents
elsewhere.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: make the stub comment's reason durable (post-merge review)

'required by the tagged @v2 now' dates itself with a word rather than a
date, and becomes false the moment v2 is slid onto this merge -- which this
PR's own body says must happen immediately. These three files are the ones
consumers copy verbatim and then keep indefinitely, so a reason that expires
is the worst place to put one.

The reviewer's failure scenario is the outage this PR exists to prevent: a
maintainer auditing unused permissions weeks later checks the stated reason,
finds it false, drops the grant, and gets startup-failed by the v3.

Reworded to a reason that holds through all three phases.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: correct the code-search index mechanism (post-merge review round 2)

I attributed the rme observation to 'a caller edited minutes ago may not be
indexed yet'. The measurement rules that out: rme#1143 added only a comment
and a checks: read line, leaving the uses: line the query matches on
byte-identical since 2026-07-28 (verified from the commit's own patch --
the uses: line appears in neither the + nor the - side). So the document had
matched for six weeks and was dropped anyway.

That is a reindex DROP of an already-indexed file, not a not-yet-indexed new
one, and the difference changes what a reader concludes. Under the old
wording, a count that FELL between two readings reads as a consumer having
removed its pin, because the stated hazard only covers new callers. All four
sites now say a push to any caller's file can drop it until reindexed, and
name the falling-count reading explicitly.

The remedy was already right -- union the forms, treat counts as a floor,
re-run after a delay -- so only the cause and its scope change.

Refs #831

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
d-morrison added a commit to Morrison-Lab/ai-config that referenced this pull request Sep 9, 2026
…kflow job permission (closes #3303) (#3304)

* feat(hooks): deny a slide-major-tag dispatch that adds a reusable-workflow job permission (closes #3303)

2026-09-05 incident: Morrison-Lab/gha#830 added `checks: read` to reusable
claude-code-review.yml's claude-review job and dispatched slide-major-tag,
breaking un-updated consumers with startup_failure (Morrison-Lab/bcs#966; same
class as gha#685). A called workflow job may not request permissions
its caller does not grant; sliding a major tag is a release to all
consumers rather than a post-merge chore. Clearable with ALLOW_BREAKING_SLIDE=1.

* chore: regenerate the plugin hooks manifest for the slide guard

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rojsq7df4i6yQ2yE9wjb2g

* fix(hooks): structurally parse reusable-workflow permissions in guard-slide-major-tag (refs #3303, #3304)

- Switch from line-oriented diff regex heuristic to structural YAML parsing with block-context tracking fallback
- Isolate permissions mappings at both workflow root and job levels, avoiding false-positive denials on step inputs (e.g. access: write in with:)
- Detect permission additions at workflow root as well as under jobs
- Add regression fixtures for both false-positive and false-negative reproductions
- Update README hook catalog description to cover both job-level and workflow-level permissions

* fix(hooks): fail closed when YAML cannot be parsed in guard-slide-major-tag (refs #3303, #3304)

- Eliminate ad-hoc _extract_permissions_fallback parser that failed open on valid quoted keys and quoted values
- Fail closed by issuing an actionable denial when PyYAML is unavailable or workflow YAML is unparseable
- Ensure native PyYAML parsing handles quoted keys, quoted values, and flow mappings
- Add tests in test-guard-slide-major-tag.py covering quoted keys/values, PyYAML absence, and malformed YAML syntax

* fix(hooks): pre-filter workflow files for workflow_call before parse-or-deny in guard-slide-major-tag (refs #3303, #3304)

* fix(hooks): reorder workflow parsing before raw scan in guard-slide-major-tag (refs #3303, #3304)

* fix(hooks): compare permission escalation over PERM_RANK in guard-slide-major-tag (refs #3303, #3304)

* fix(hooks): drop the unreachable PERM_RANK raise that failed open (refs #3304)

The raise KeyError added alongside PERM_RANK was specified by my own brief as
a way to 'fail loudly rather than silently comparing as equal'. It did the
opposite. main() wraps evaluate() in a blanket 'except Exception: return 0',
and for a PreToolUse hook return 0 with empty stdout is ALLOW, so an internal
logic error became permission to proceed in a guard whose purpose is failing
closed. A reviewer reproduced that end-to-end.

Removed rather than converted to a deny, because RX_PERM_VAL already filters
every value through ^(read|write|none)$ before it can reach the comparison,
so the raise guarded an unreachable state. It was a second, weaker check
standing where the authoritative one already suffices -- the shape that has
recurred in every round of this PR.

The test asserting it 'fails loudly' asserted something false and is replaced
by one covering the invariant that does hold: an out-of-rank value is
filtered upstream, so it neither reaches the comparison nor produces a
spurious deny.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(guard-slide-major-tag): assert on stderr so 11d can fail

Review finding: test 11d asserted only verdict == allow and discarded stderr,
but allow is reachable two ways -- the invariant holding and the value being
filtered upstream, or the invariant breaking so PERM_RANK raises KeyError and
main()'s blanket handler converts it to exit 0 with empty stdout, which is
also allow. The test could not tell those apart, so it covered nothing.

Adds an assertion that stderr carries no 'could not evaluate command'.

Mutation-tested, which is the point of the change. With RX_PERM_VAL widened
to .* the new assertion fails with exactly:
  guard-slide-major-tag: could not evaluate command ('admin')
while the old verdict-only assertion stayed green under the same mutant.
Restored: 43/43.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: rank the shorthand permission forms so a downgrade is not a deny

_find_added_permissions compared the shorthand form (read-all/write-all)
with a plain inequality while the dict form used PERM_RANK, so a strict
downgrade read as an added permission and denied a safe slide.

    _find_added_permissions({'workflow': 'write-all'},
                            {'workflow': 'read-all'})
    -> [('workflow', 'read-all')]

The same failure class sat in the dict branch, which the review did not
name: a shorthand baseline was coerced to {} there, so every key of an
explicit dict looked newly added.

    write-all -> {contents: read}   # a strict downgrade
    -> [('workflow', 'contents: read')]

Both now rank on one scale. SHORTHAND_RANK maps read-all/write-all onto
PERM_RANK's read/write, and _shorthand_floor turns a shorthand baseline
into a floor the keys it covers are compared against.

The floor does NOT extend to every key, and getting that wrong opened a
worse hole than the one being fixed. `id-token` accepts only `write` or
`none` and never `read` (GitHub's workflow-syntax reference), so read-all
provably cannot grant it and write-all's coverage of it is documented
nowhere. A floor equal to PERM_RANK's maximum made every dict key
unreachable after a write-all baseline, so

    write-all -> {id-token: write}

stopped being reported -- a real escalation, silently allowed, that the
pre-fix code denied correctly. SHORTHAND_UNCOVERED_KEYS holds id-token
out of the floor so it keeps a zero baseline and stays reportable. For a
guard, a scope it cannot rank must be compared against zero.

Other asymmetries kept deliberately:

- dict -> shorthand still reports. read-all grants read on scopes the
  dict never listed, so it widens even where one named key drops.
- An unrecognized shorthand reports rather than ranking to 0.

Fourteen cases added (43 -> 57). Five fail against the pre-fix hook, and
one -- write-all -> {id-token: write} -- fails against the intermediate
version that had the floor covering every key, so both directions are
pinned rather than only the one the finding named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: list the scopes a shorthand covers, not the ones it does not

Round 2 review of 26b1544 found the carve-out pointed the wrong way, and
it is right. SHORTHAND_UNCOVERED_KEYS was an exclusion list, so a scope
nobody had added to it inherited a full floor and its explicit grant
stopped being reported -- a silent allow, the direction this guard must
never fail in. GitHub added `vulnerability-alerts` on 2026-09-03, five
days before that commit, and it already exercised the gap.

Inverted to SHORTHAND_COVERED_KEYS: the fourteen scopes GitHub documents
as `read|write|none`, taken from github/docs
data/reusables/actions/github-token-available-permissions.md. Anything
absent -- `id-token` (write|none), `vulnerability-alerts` (read|none),
and any scope added after this list was written -- gets a zero floor and
stays reportable.

The cost of a new scope is now a spurious deny that names itself, rather
than an escalation that says nothing. That is the trade this guard should
make, and it is the same reasoning the previous round applied to
id-token, applied to the shape of the check instead of to one key.

Also fixes the no-op the same review found: the unrecognized-shorthand
branch reported a value identical to the baseline, so

    _find_added_permissions({'j': 'admin-all'}, {'j': 'admin-all'})
    -> [('j', 'admin-all')]

contradicting the docstring's claim that a no-op is never reported. An
equality test now short-circuits ahead of the recognized/unrecognized
split.

Three cases added, 57 -> 60. All three fail against 26b1544:
vulnerability-alerts and an unknown future scope both read as covered
there, and the no-op reported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

claude-code-review: the review job needs checks: read; without it check-runs return 403 and a clean review calls itself blocked

2 participants