Skip to content

fix: allow gh issue/pr commands so bot workflows don't get silently denied - #306

Merged
johanzander merged 1 commit into
mainfrom
fix/triage-bot-gh-permissions
Jul 14, 2026
Merged

fix: allow gh issue/pr commands so bot workflows don't get silently denied#306
johanzander merged 1 commit into
mainfrom
fix/triage-bot-gh-permissions

Conversation

@johanzander

Copy link
Copy Markdown
Owner

Summary

  • Every issue-triage.yml run since 2026-07-11 has reported success but applied zero labels/comments (permission_denials_count went from 0 to 2-3 that day, with no repo-side change to explain it).
  • Traced to anthropics/claude-code-action@v1 (floating tag) picking up a Claude Code CLI bump from 2.1.206 to 2.1.207 in that window — --permission-mode bypassPermissions appears to no longer force-allow Bash commands (like gh) that aren't already on the project's explicit allow list.
  • Adds the gh issue/gh pr subcommands actually used across the bot pipeline (triage/analyze/fix/review) to .claude/settings.json's allow list, so those bots don't depend on bypass-mode behavior at all.

Test plan

…enied

The Stage 1-4 bot workflows (triage, analyze, fix, review) run with
--permission-mode bypassPermissions, but every triage run since 2026-07-11
has shown permission_denials_count > 0 and applied zero labels/comments
despite the job reporting success. This lines up with claude-code-action@v1
picking up a Claude Code CLI bump (2.1.206 -> 2.1.207) around that date,
after which bypassPermissions stopped force-allowing gh commands that
weren't already on the project's explicit Bash allow list.

Add gh issue/pr subcommands actually used by the workflows to the allow
list directly, so the bots don't depend on bypass-mode behavior at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@johanzander
johanzander merged commit fec8b3b into main Jul 14, 2026
9 checks passed
@johanzander
johanzander deleted the fix/triage-bot-gh-permissions branch July 14, 2026 20:59
johanzander added a commit that referenced this pull request Aug 13, 2026
The guard blocked a live beta release. Three commands in a row prompted,
all issued from the changelog worktree and all operating inside it:

  cd <that worktree>; .venv/bin/pytest -m "not slow" -q
  cd <that worktree>; python3 - <<'EOF' ... rewrite config.yaml ... EOF
  cd <that worktree>; git add -A; git commit -m "merge: ..."

Root cause: the token scan separated on quotes, parens, commas and `=` but
not on shell separators, so `cd /path/to/wt; cmd` produced the token
"/path/to/wt;" -- semicolon attached. That matches neither "$session_root"
nor "$session_root"/*, so a worktree was judged foreign to ITSELF and every
`cd <own worktree>; ...` prompted. Separators are now separators.

Two further changes, both from the same root problem -- the check was
trying to parse shell with regex, and each fix spawned the next false
positive. Four distinct shapes reached the user, every one introduced by a
FIX to this check rather than by the original code:

  cmd 2>/dev/null with any $var       (fixed earlier)
  cd into a SIBLING worktree
  read-only grep whose literal pattern held `$(` and `;;`
  inspecting files under $HOME

So the trigger is narrowed from "any absolute path, any .., any ~" to "can
this command WRITE": the write verbs, a repo-redirect flag, or a redirect
that creates a file (2>/dev/null and 2>&1 stripped first). A command that
cannot write cannot commit the accident being guarded. Interpreters are
included wholesale, since `python3 -c "shutil.rmtree(...)"` was the
motivating miss for the old verb list.

And a sibling worktree now counts as contained. A linked worktree is
disposable by exactly the argument that justifies auto-allowing commands
inside this one, and agents legitimately reach across them -- cutting a
release from a changelog worktree is what triggered this. The checkout the
guard exists for is the MAIN one, which is shared, long-lived, and the only
place a stale path does lasting damage; it stays excluded explicitly, even
though .claude/worktrees/* sits inside it.

Known and accepted: an arbitrary-write shape not on the verb list is now
auto-allowed inside a worktree. That is the price of the false-positive
rate, and the header comment says so rather than implying coverage the
check does not have.

Verification: all four false-positive shapes and the three release commands
are pinned, alongside six main-checkout attacks that must still ask
(rm -rf $MAIN/core, sed -i into $MAIN, shutil.rmtree($MAIN), redirect into
$MAIN, $HOME/GitHub/bess-manager, ../../../bess-manager) -- all still ask.
The fixture gains a second linked worktree so sibling-vs-main is tested
rather than asserted. Suite 118 -> 133 cases, all pass; quality-check.sh
0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W29UVDwQPwVRRqBn2sMgHa

Also in this commit -- the five PR #561 review findings, all verified
against the merged hook before fixing:

- A missing hook test file was a WARNING, so quality-check.sh still exited
  0 with the permission matrix unguarded. #562 converted every other
  unavailable tool in that script to an ERROR on exactly this principle;
  now an ERROR.
- `gh pr review` was absent from the safe list while settings.json
  explicitly allows `Bash(gh pr review *)` (added in #306 because the
  Stage-4 review bot was being denied). A hook decision supersedes a
  settings rule, so this silently revoked a granted permission.
- Only the FIRST `git push` in a compound was scanned, so
  `git push origin feat/x && git push origin main` was allowed. Every push
  is now examined.
- `mutates_shared_state` is a blocklist, so ref-moving shapes it did not
  name were allowed: `git branch -f main HEAD`, `git update-ref`,
  `git symbolic-ref`. Added, and the header no longer claims every check
  fails closed -- it names this function as the weakest link instead.
- The shared-ref regex missed `beta-release-*` branches, which are shared
  release refs. Refspecs are now compared as TOKENS (src:dst split, `+`
  and refs/heads/ stripped), so `beta-release-1.2` matches while
  `feat/main-ish` still does not.
- Corrected a comment claiming settings.json has a blanket `Bash(gh *)`
  entry; it has eight narrow patterns, and the safe list must stay a
  superset of them.
johanzander added a commit that referenced this pull request Aug 13, 2026
)

The guard blocked a live beta release. Three commands in a row prompted,
all issued from the changelog worktree and all operating inside it:

  cd <that worktree>; .venv/bin/pytest -m "not slow" -q
  cd <that worktree>; python3 - <<'EOF' ... rewrite config.yaml ... EOF
  cd <that worktree>; git add -A; git commit -m "merge: ..."

Root cause: the token scan separated on quotes, parens, commas and `=` but
not on shell separators, so `cd /path/to/wt; cmd` produced the token
"/path/to/wt;" -- semicolon attached. That matches neither "$session_root"
nor "$session_root"/*, so a worktree was judged foreign to ITSELF and every
`cd <own worktree>; ...` prompted. Separators are now separators.

Two further changes, both from the same root problem -- the check was
trying to parse shell with regex, and each fix spawned the next false
positive. Four distinct shapes reached the user, every one introduced by a
FIX to this check rather than by the original code:

  cmd 2>/dev/null with any $var       (fixed earlier)
  cd into a SIBLING worktree
  read-only grep whose literal pattern held `$(` and `;;`
  inspecting files under $HOME

So the trigger is narrowed from "any absolute path, any .., any ~" to "can
this command WRITE": the write verbs, a repo-redirect flag, or a redirect
that creates a file (2>/dev/null and 2>&1 stripped first). A command that
cannot write cannot commit the accident being guarded. Interpreters are
included wholesale, since `python3 -c "shutil.rmtree(...)"` was the
motivating miss for the old verb list.

And a sibling worktree now counts as contained. A linked worktree is
disposable by exactly the argument that justifies auto-allowing commands
inside this one, and agents legitimately reach across them -- cutting a
release from a changelog worktree is what triggered this. The checkout the
guard exists for is the MAIN one, which is shared, long-lived, and the only
place a stale path does lasting damage; it stays excluded explicitly, even
though .claude/worktrees/* sits inside it.

Known and accepted: an arbitrary-write shape not on the verb list is now
auto-allowed inside a worktree. That is the price of the false-positive
rate, and the header comment says so rather than implying coverage the
check does not have.

Verification: all four false-positive shapes and the three release commands
are pinned, alongside six main-checkout attacks that must still ask
(rm -rf $MAIN/core, sed -i into $MAIN, shutil.rmtree($MAIN), redirect into
$MAIN, $HOME/GitHub/bess-manager, ../../../bess-manager) -- all still ask.
The fixture gains a second linked worktree so sibling-vs-main is tested
rather than asserted. Suite 118 -> 133 cases, all pass; quality-check.sh
0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W29UVDwQPwVRRqBn2sMgHa

Also in this commit -- the five PR #561 review findings, all verified
against the merged hook before fixing:

- A missing hook test file was a WARNING, so quality-check.sh still exited
  0 with the permission matrix unguarded. #562 converted every other
  unavailable tool in that script to an ERROR on exactly this principle;
  now an ERROR.
- `gh pr review` was absent from the safe list while settings.json
  explicitly allows `Bash(gh pr review *)` (added in #306 because the
  Stage-4 review bot was being denied). A hook decision supersedes a
  settings rule, so this silently revoked a granted permission.
- Only the FIRST `git push` in a compound was scanned, so
  `git push origin feat/x && git push origin main` was allowed. Every push
  is now examined.
- `mutates_shared_state` is a blocklist, so ref-moving shapes it did not
  name were allowed: `git branch -f main HEAD`, `git update-ref`,
  `git symbolic-ref`. Added, and the header no longer claims every check
  fails closed -- it names this function as the weakest link instead.
- The shared-ref regex missed `beta-release-*` branches, which are shared
  release refs. Refspecs are now compared as TOKENS (src:dst split, `+`
  and refs/heads/ stripped), so `beta-release-1.2` matches while
  `feat/main-ish` still does not.
- Corrected a comment claiming settings.json has a blanket `Bash(gh *)`
  entry; it has eight narrow patterns, and the safe list must stay a
  superset of them.
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.

1 participant