Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ Beyond token scopes, each mode passes a `--allowedTools` allowlist and a `--disa

**Fork pull requests and the `GITHUB_TOKEN` downgrade.** GitHub automatically restricts the ambient `GITHUB_TOKEN` to read-only for any `pull_request`-triggered run originating from a fork, regardless of what `permissions:` a workflow declares — this is a platform-level protection, not something any input here controls. That's why review mode's own example (`examples/direct/claude-review.yml`) uses the direct form and deliberately leaves `github_token` unset: doing so makes the run authenticate as the Claude Code GitHub App (`claude[bot]`) instead, a separately-minted credential via OIDC that isn't subject to that downgrade. Reviewing a fork pull request through the reusable workflow's default `GITHUB_TOKEN` would appear to run successfully but silently fail to post — worth knowing if a repository that accepts fork pull requests seems to review nothing. Separately, GitHub's own "Require approval for first-time contributors" repository setting (on by default for public repositories) gates _any_ workflow run — including this one — from an unknown contributor's fork pull request until a maintainer approves it once in the Actions UI, on top of everything above.

**A pull request cannot reliably self-test a change to its own OIDC-authenticated workflow file.** Any workflow using the App-identity/OIDC path (leaving `github_token` unset, as `claude-review.yml`, `claude-triage.yml`, and `claude-interactive.yml` all do here) is subject to a GitHub platform check that the calling workflow file be byte-identical to the version on the default branch — the same mechanism the "Automated upstream bumps" section below documents for `dependabot.yml`. A pull request that edits one of these three workflow files fails that check on its own first run against itself: upstream detects this and gracefully no-ops (`Skipping action due to workflow validation`, a real warning, not a crash), so the run still reports success, but no API call happens and nothing gets posted — confirmed live, not theorised, when `headroom_enabled: true` was added to `claude-review.yml` in the same pull request that first tried to dogfood it. Passing `github_token: ${{ github.token }}` explicitly sidesteps this entirely by skipping OIDC, but doing that permanently on one of these three files would give up the App identity's own capabilities (`resolve_stale_threads` and friends) for every future run, not just self-modifying ones — not a trade worth making for a narrow, rare edge case. Validate a change to one of these files with a separate, temporary workflow instead (one that does not itself modify the file under test), then remove it once confirmed; the change becomes fully self-testable again the moment it lands on the default branch.

**Untrusted text reaches the model.** Issue bodies, comments, pull request descriptions, and the contents of a pull request's own files are attacker-influenced input on a public repository or one accepting fork pull requests. The shared prompt tells Claude to treat all of it as data rather than instructions and to report injection attempts, but a prompt is mitigation, not a guarantee. The real controls are the per-mode token scopes and tool allowlists above: assume the prompt can be subverted and check that the blast radius is acceptable if it is.

**`headroom_enabled` adds a new third-party dependency that processes all of the above.** Every diff, CI log, issue body, and file read already covered by the paragraph above passes through the [Headroom](https://github.com/headroomlabs-ai/headroom) proxy before it reaches Anthropic — a real supply-chain trust addition, even though Headroom's own design claims compression runs entirely locally with no prompt or file content sent elsewhere. The proxy's data plane is unauthenticated by default; that is acceptable here specifically because it is bound to `127.0.0.1:<headroom_port>` on the runner — directly, for the pip install method (confirmed by the proxy's own startup banner, which reports "loopback-only" for this exact bind, unlike the Docker method's own "non-loopback bind" warning about its internal `0.0.0.0` listen address, necessary there only so Docker's port-publish can reach it); via a Docker port publish that never touches `0.0.0.0` on the host side, for the docker method — so nothing beyond this job's own processes can reach it either way. This action never widens that bind. `headroom_show_savings` (on by default) additionally posts a comment reporting the numbers — needs no token scope beyond what the mode already holds, and its body is built entirely from numeric stats this action's own bash computes, never from anything the model or untrusted PR content produced, so it carries none of the prompt-injection surface the rest of this section is about.
Expand Down