From 4588cfbcf6c8ce72a30afa9a1bba0a9f8b58ccc5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 10 Sep 2026 10:04:44 +0100 Subject: [PATCH 1/3] feat(headroom): enable context compression by default headroom_enabled now defaults to true instead of false, so every review/triage/interactive/generic run gets Headroom's context compression unless a job explicitly opts out. A job using use_bedrock, use_vertex, use_foundry, or anthropic_federation_rule_id -- or running on a self-hosted runner with neither a usable Python nor Docker -- must now set headroom_enabled: false explicitly, or the run fails at validation (the alternative-provider case) or at the proxy's own startup step (the runner case) instead of silently skipping compression. Also exposes headroom_enabled/headroom_install_method/headroom_version/ headroom_image/headroom_port/headroom_show_savings on all three reusable workflows (review.yml, triage.yml, interactive.yml), matching action.yml's own defaults. Previously these inputs existed only on the composite action and the direct-form examples, so a reusable-workflow caller had no way to opt out at all once the default flipped. --- .github/workflows/interactive.yml | 36 +++++++++++++++++++ .github/workflows/review.yml | 36 +++++++++++++++++++ .github/workflows/triage.yml | 36 +++++++++++++++++++ README.md | 11 +++--- action.yml | 10 ++++-- .../direct/claude-review-with-headroom.yml | 4 ++- 6 files changed, 124 insertions(+), 9 deletions(-) diff --git a/.github/workflows/interactive.yml b/.github/workflows/interactive.yml index 00b5b32..7c61c2e 100644 --- a/.github/workflows/interactive.yml +++ b/.github/workflows/interactive.yml @@ -155,6 +155,36 @@ on: type: string required: false default: "" + headroom_enabled: + description: "Run a local Headroom context-compression proxy in front of the Anthropic API for this job. On by default. Mutually exclusive with use_bedrock/use_vertex/use_foundry/anthropic_federation_rule_id -- set false explicitly alongside any of those, or on a self-hosted runner with neither a usable Python nor Docker. See action.yml and the README's 'Context compression (Headroom)' section." + type: boolean + required: false + default: true + headroom_install_method: + description: "How to run the Headroom proxy: 'pip' (default) or 'docker'. See action.yml and the README's 'Context compression (Headroom)' section." + type: string + required: false + default: "pip" + headroom_version: + description: "pip install method only. Exact headroom-ai version to pin. Ignored when headroom_install_method is docker." + type: string + required: false + default: "0.37.0" + headroom_image: + description: "docker install method only. Docker image for the Headroom proxy, pinned by digest. Ignored when headroom_install_method is pip." + type: string + required: false + default: "ghcr.io/headroomlabs-ai/headroom@sha256:35b799e94eef4644cb15a2e695b4b99698fe7668614623e9338cb10c10ececf9" + headroom_port: + description: "Loopback port the Headroom proxy listens on. Change only to avoid a collision with another localhost service already running in the same job." + type: number + required: false + default: 8787 + headroom_show_savings: + description: "On by default. Post a short comment on the pull request or issue reporting Headroom's compression stats for this run, in addition to the Step Summary. Ignored when headroom_enabled is false." + type: boolean + required: false + default: true display_report: description: "Display the Claude Code report in the GitHub Step Summary. Only for trusted-input repositories — see action.yml's own warning." type: boolean @@ -256,6 +286,12 @@ jobs: anthropic_service_account_id: ${{ inputs.anthropic_service_account_id }} anthropic_workspace_id: ${{ inputs.anthropic_workspace_id }} anthropic_oidc_audience: ${{ inputs.anthropic_oidc_audience }} + headroom_enabled: ${{ inputs.headroom_enabled }} + headroom_install_method: ${{ inputs.headroom_install_method }} + headroom_version: ${{ inputs.headroom_version }} + headroom_image: ${{ inputs.headroom_image }} + headroom_port: ${{ inputs.headroom_port }} + headroom_show_savings: ${{ inputs.headroom_show_savings }} display_report: ${{ inputs.display_report }} show_full_output: ${{ inputs.show_full_output }} anthropic_api_key: ${{ secrets.anthropic_api_key }} diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index e1bc4cb..0d9ec1d 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -187,6 +187,36 @@ on: type: string required: false default: "" + headroom_enabled: + description: "Run a local Headroom context-compression proxy in front of the Anthropic API for this job. On by default. Mutually exclusive with use_bedrock/use_vertex/use_foundry/anthropic_federation_rule_id -- set false explicitly alongside any of those, or on a self-hosted runner with neither a usable Python nor Docker. See action.yml and the README's 'Context compression (Headroom)' section." + type: boolean + required: false + default: true + headroom_install_method: + description: "How to run the Headroom proxy: 'pip' (default) or 'docker'. See action.yml and the README's 'Context compression (Headroom)' section." + type: string + required: false + default: "pip" + headroom_version: + description: "pip install method only. Exact headroom-ai version to pin. Ignored when headroom_install_method is docker." + type: string + required: false + default: "0.37.0" + headroom_image: + description: "docker install method only. Docker image for the Headroom proxy, pinned by digest. Ignored when headroom_install_method is pip." + type: string + required: false + default: "ghcr.io/headroomlabs-ai/headroom@sha256:35b799e94eef4644cb15a2e695b4b99698fe7668614623e9338cb10c10ececf9" + headroom_port: + description: "Loopback port the Headroom proxy listens on. Change only to avoid a collision with another localhost service already running in the same job." + type: number + required: false + default: 8787 + headroom_show_savings: + description: "On by default. Post a short comment on the pull request or issue reporting Headroom's compression stats for this run, in addition to the Step Summary. Ignored when headroom_enabled is false." + type: boolean + required: false + default: true display_report: description: "Display the Claude Code report in the GitHub Step Summary. Only for trusted-input repositories — see action.yml's own warning." type: boolean @@ -279,6 +309,12 @@ jobs: anthropic_service_account_id: ${{ inputs.anthropic_service_account_id }} anthropic_workspace_id: ${{ inputs.anthropic_workspace_id }} anthropic_oidc_audience: ${{ inputs.anthropic_oidc_audience }} + headroom_enabled: ${{ inputs.headroom_enabled }} + headroom_install_method: ${{ inputs.headroom_install_method }} + headroom_version: ${{ inputs.headroom_version }} + headroom_image: ${{ inputs.headroom_image }} + headroom_port: ${{ inputs.headroom_port }} + headroom_show_savings: ${{ inputs.headroom_show_savings }} display_report: ${{ inputs.display_report }} show_full_output: ${{ inputs.show_full_output }} anthropic_api_key: ${{ secrets.anthropic_api_key }} diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index a13bdfc..c0241bc 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -129,6 +129,36 @@ on: type: string required: false default: "" + headroom_enabled: + description: "Run a local Headroom context-compression proxy in front of the Anthropic API for this job. On by default. Mutually exclusive with use_bedrock/use_vertex/use_foundry/anthropic_federation_rule_id -- set false explicitly alongside any of those, or on a self-hosted runner with neither a usable Python nor Docker. See action.yml and the README's 'Context compression (Headroom)' section." + type: boolean + required: false + default: true + headroom_install_method: + description: "How to run the Headroom proxy: 'pip' (default) or 'docker'. See action.yml and the README's 'Context compression (Headroom)' section." + type: string + required: false + default: "pip" + headroom_version: + description: "pip install method only. Exact headroom-ai version to pin. Ignored when headroom_install_method is docker." + type: string + required: false + default: "0.37.0" + headroom_image: + description: "docker install method only. Docker image for the Headroom proxy, pinned by digest. Ignored when headroom_install_method is pip." + type: string + required: false + default: "ghcr.io/headroomlabs-ai/headroom@sha256:35b799e94eef4644cb15a2e695b4b99698fe7668614623e9338cb10c10ececf9" + headroom_port: + description: "Loopback port the Headroom proxy listens on. Change only to avoid a collision with another localhost service already running in the same job." + type: number + required: false + default: 8787 + headroom_show_savings: + description: "On by default. Post a short comment on the pull request or issue reporting Headroom's compression stats for this run, in addition to the Step Summary. Ignored when headroom_enabled is false." + type: boolean + required: false + default: true display_report: description: "Display the Claude Code report in the GitHub Step Summary. Only for trusted-input repositories — see action.yml's own warning." type: boolean @@ -201,6 +231,12 @@ jobs: anthropic_service_account_id: ${{ inputs.anthropic_service_account_id }} anthropic_workspace_id: ${{ inputs.anthropic_workspace_id }} anthropic_oidc_audience: ${{ inputs.anthropic_oidc_audience }} + headroom_enabled: ${{ inputs.headroom_enabled }} + headroom_install_method: ${{ inputs.headroom_install_method }} + headroom_version: ${{ inputs.headroom_version }} + headroom_image: ${{ inputs.headroom_image }} + headroom_port: ${{ inputs.headroom_port }} + headroom_show_savings: ${{ inputs.headroom_show_savings }} display_report: ${{ inputs.display_report }} show_full_output: ${{ inputs.show_full_output }} anthropic_api_key: ${{ secrets.anthropic_api_key }} diff --git a/README.md b/README.md index fed40f9..aaf87e8 100644 --- a/README.md +++ b/README.md @@ -113,10 +113,12 @@ Two things worth knowing before relying on it: ## Context compression (Headroom) -`headroom_enabled` (off by default, all modes) runs [Headroom](https://github.com/headroomlabs-ai/headroom) — a local HTTP proxy that compresses tool outputs, logs, and file content before they reach the model — in front of the Anthropic API for the job, and points every `anthropics/claude-code-action` call in it at that proxy via `ANTHROPIC_BASE_URL`. Review, triage, and interactive runs routinely push large diffs, CI logs, and repo-file reads through Claude Code, which is exactly the repetitive, JSON/log-shaped content Headroom compresses well. +`headroom_enabled` (on by default, all modes) runs [Headroom](https://github.com/headroomlabs-ai/headroom) — a local HTTP proxy that compresses tool outputs, logs, and file content before they reach the model — in front of the Anthropic API for the job, and points every `anthropics/claude-code-action` call in it at that proxy via `ANTHROPIC_BASE_URL`. Review, triage, and interactive runs routinely push large diffs, CI logs, and repo-file reads through Claude Code, which is exactly the repetitive, JSON/log-shaped content Headroom compresses well. + +**Set `headroom_enabled: false` explicitly if you use `use_bedrock`, `use_vertex`, `use_foundry`, or `anthropic_federation_rule_id`, or run on a self-hosted runner with neither a usable Python nor Docker.** The four alternative-provider inputs route Claude Code's requests to their own provider directly, bypassing `ANTHROPIC_BASE_URL` and the proxy silently — setting any of them alongside the default `headroom_enabled: true` fails validation loudly rather than starting a proxy that compresses nothing (see "Mutually exclusive" below). A runner with neither Python nor Docker has nowhere to run either install method, and the proxy startup step fails the job outright rather than silently skipping compression. ```yaml -headroom_enabled: true +headroom_enabled: false # opt out; on by default ``` **How it's wired.** A new `Start Headroom compression proxy` step installs and starts the proxy, waits for it to report ready, then exports `ANTHROPIC_BASE_URL=http://127.0.0.1:` to `$GITHUB_ENV` — the only place this action writes `$GITHUB_ENV` rather than `$GITHUB_OUTPUT`, since there is no `anthropic_base_url` input on the upstream action to pass this through. Because `$GITHUB_ENV` persists for the rest of the job, this one step covers every `anthropics/claude-code-action` call site in a run — all five credential-fallback attempts, the turn-limit wrap-up, the automatic-fix pass, and the structured review summary — with nothing else to configure. @@ -124,7 +126,6 @@ headroom_enabled: true **`headroom_install_method` (default `pip`) picks how that step actually runs the proxy.** `pip` installs `headroom-ai[proxy]` — the minimal extra for running just the proxy, without the AST-aware code-compression or Bedrock backend support the Docker image bundles unconditionally — into a dedicated venv (never the system Python directly: modern `ubuntu-latest` images enforce PEP 668 and refuse a bare `pip install` outright, and a venv avoids colliding with anything else in the job regardless), then runs it as a background process. `ubuntu-latest` ships Python 3 already, so this needs no separate runtime install, and it's the lighter of the two options — no container image to pull, no bundled extras this feature doesn't use. `docker` instead pulls and runs a pinned image (`headroom_image`) as a container: heavier, but the only option on a self-hosted runner that has Docker but not a usable Python (the pip method is, in exactly the mirrored way, the only option on a self-hosted runner with Python but no Docker — pick whichever your runner actually has). ```yaml -headroom_enabled: true headroom_install_method: docker # only if your runner lacks a usable Python; pip is the default ``` @@ -134,7 +135,7 @@ headroom_install_method: docker # only if your runner lacks a usable Python; pip **`headroom_show_savings` (on by default) also posts the same numbers directly on the pull request or issue**, as a short standalone comment — the Step Summary above is only visible to someone who opens the run in the Actions tab, which most people looking at a PR never do. This is deliberately a separate comment, not an addition to the existing progress-comment mechanism above: that comment is deleted outright on a successful run, precisely when there would be real savings to show. It's sticky, not one-per-run: a repeated push to the same pull request deletes this bot's own prior Headroom comment before posting a fresh one, the same delete-then-recreate pattern the progress comment uses for its own stale entries, so a long-lived PR never accumulates a near-duplicate comment per push. The comment is skipped when nothing was proxied (a zero-savings comment on every run would be noise) and in `generic` mode, which posts nothing to GitHub by design regardless of this setting. Set `headroom_show_savings: false` to keep the numbers in the Step Summary only. -**Mutually exclusive with `use_bedrock`, `use_vertex`, `use_foundry`, and workload identity federation.** Each of those makes Claude Code call its own provider directly — confirmed directly in Headroom's own documentation for the Bedrock case: `CLAUDE_CODE_USE_BEDROCK=1` bypasses `ANTHROPIC_BASE_URL` and the proxy entirely, silently. Setting `headroom_enabled: true` alongside any of these fails validation before any install (pip or Docker) happens, rather than starting a proxy that ends up compressing nothing. +**Mutually exclusive with `use_bedrock`, `use_vertex`, `use_foundry`, and workload identity federation.** Each of those makes Claude Code call its own provider directly — confirmed directly in Headroom's own documentation for the Bedrock case: `CLAUDE_CODE_USE_BEDROCK=1` bypasses `ANTHROPIC_BASE_URL` and the proxy entirely, silently. Because `headroom_enabled` defaults to `true`, a job that sets any of these four inputs must now also set `headroom_enabled: false` explicitly — otherwise the run fails validation before any install (pip or Docker) happens, rather than starting a proxy that ends up compressing nothing. **`claude_code_oauth_token` compatibility is confirmed, not assumed.** Headroom's own source only names `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` as recognised Claude auth env vars, not `CLAUDE_CODE_OAUTH_TOKEN`, which is what this repository's OAuth-token credential actually sets — so this looked like a real gap until it was tested against a live run. It works: a real run authenticated purely via `claude_code_oauth_token`, with no API key configured, reached the proxy (`requests.total` incremented) and reached Anthropic's own real API through it — confirmed unambiguously by a genuine subscription-tier `429` ("You've hit your weekly limit") coming back through the proxy, which cannot happen unless the real OAuth credential reached the real API. A `Verify Headroom actually proxied this run` step still checks the proxy's own request count after every run and reports it as the `headroom_proxied` output — `false` with a `::warning::` naming the credential type if zero requests went through — as a standing regression check against a future upstream change, not because the compatibility question itself remains open. @@ -174,7 +175,7 @@ Beyond token scopes, each mode passes a `--allowedTools` allowlist and a `--disa **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:` 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. +**`headroom_enabled` adds a new third-party dependency that processes all of the above, and it's on by default.** 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. A repository that does not want that trust addition sets `headroom_enabled: false`. The proxy's data plane is unauthenticated by default; that is acceptable here specifically because it is bound to `127.0.0.1:` 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. One consequence is worth being explicit about: on a `pull_request` event the checkout includes the pull request's own changes, so a fork pull request can modify `.github/claude/review.md` (see below) or the repository's `CLAUDE.md` and thereby change the instructions used to review it. Review mode holds no write scope to repository _contents_ — it cannot edit files, commit, or push — but that no longer bounds the damage to a misleading review by default, because four on-by-default-or-opt-in inputs widen what a subverted review (or its own follow-up call) can do within the token's `pull-requests` scope. `include_suggestions` lets it emit a fenced `suggestion` block an author might one-click apply (not a permission escalation — the author's Apply is the write, the bot has none — but it removes the friction of reading and re-typing a prose fix, so a malicious "fix" gets less scrutiny). `fix_pr_metadata` grants the whole `mcp__github__update_pull_request` tool, not just a title/body-scoped slice of it — the tool itself also accepts `state` (it can close the pull request), `base`, `draft`, `maintainer_can_modify`, and `reviewers`, so a subverted review can call it with any of those, no `gh api:*` needed; only the prompt restricts its own use of the tool to title and body. `resolve_stale_threads` adds `gh api:*` to the review allowlist, so a subverted review gains the whole REST/GraphQL surface within that `pull-requests` scope (editing the PR's title, body, or labels; posting arbitrary comments; blanking or rewriting any review's body, not only the bot's own, since nothing at the API layer restricts the call to reviews the bot itself submitted — only the prompt's own filter-by-login step does) — a second, broader route to the same title/body write, and to more besides. `structured_review_summary`, though opt-in rather than on by default, grants the same `gh api:*` surface to its own follow-up call for the same reason (reading inline review comments has no narrower `gh pr view --json` field) — only the prompt restricts that call to reading. `include_ci_logs`, never previously listed here, belongs on this list too now that it does more than inform prose: it grants tools that fetch this pull request's own workflow logs, which are attacker-influenced on a fork pull request (a test name, an assertion message, or anything else the change causes CI to print lands in the model's context), and it is the input `fix_ci_failures` (below) reads to decide what to change. On its own it is still read-only and low-risk; paired with `fix_ci_failures` it is the input path into a write. On a repository that accepts untrusted fork pull requests, set `include_suggestions: false`, `fix_pr_metadata: false`, `resolve_stale_threads: false`, `verify_prior_findings: false`, and leave `structured_review_summary`, `fix_ci_failures`, `fix_diff_findings`, and `add_regression_tests` off (their defaults) until a human has reviewed the change, and do not treat a review of an untrusted pull request as a security control. diff --git a/action.yml b/action.yml index 7897ec9..ddda0f1 100644 --- a/action.yml +++ b/action.yml @@ -311,13 +311,17 @@ inputs: headroom_enabled: description: | Run a local Headroom context-compression proxy in front of the Anthropic API for this job, and - point every anthropics/claude-code-action call in it at that proxy via ANTHROPIC_BASE_URL. Off + point every anthropics/claude-code-action call in it at that proxy via ANTHROPIC_BASE_URL. On by default. See the README's "Context compression (Headroom)" section. Mutually exclusive with use_bedrock, use_vertex, use_foundry, and workload identity federation (anthropic_federation_rule_id) -- each of those routes Claude Code's requests to its own provider directly, bypassing ANTHROPIC_BASE_URL and the proxy silently. Setting headroom_enabled alongside - any of them fails validation rather than starting a proxy that compresses nothing. + any of them fails validation rather than starting a proxy that compresses nothing -- since this + now defaults to true, a job that sets one of those four inputs must also set headroom_enabled: + false explicitly, or validation fails before any provider call is attempted. The same applies to + a self-hosted runner with neither a usable Python nor Docker: set headroom_enabled: false there + too, since neither install method has anywhere to run. claude_code_oauth_token compatibility is confirmed: a live run authenticated purely by that credential reached the proxy and reached Anthropic's real API through it, confirmed by a genuine @@ -325,7 +329,7 @@ inputs: own request count afterwards and reports headroom_proxied accordingly, as a standing regression check against a future upstream change, not because the question itself remains open. required: false - default: "false" + default: "true" headroom_install_method: description: | How to run the Headroom proxy: 'pip' (default) or 'docker'. pip installs headroom-ai[proxy] -- diff --git a/examples/direct/claude-review-with-headroom.yml b/examples/direct/claude-review-with-headroom.yml index 54e6a2e..e9aeca7 100644 --- a/examples/direct/claude-review-with-headroom.yml +++ b/examples/direct/claude-review-with-headroom.yml @@ -1,7 +1,9 @@ -# Composite-action-step (direct) form of claude-review.yml with the opt-in Headroom context-compression proxy turned on. Copy this to .github/workflows/claude-review.yml in a repository in your organisation -- INSTEAD OF, not alongside, either of the other two review examples. +# Composite-action-step (direct) form of claude-review.yml with the Headroom context-compression proxy explicitly configured. headroom_enabled is on by default -- every plain claude-review.yml example already runs with it -- so this file exists to show the tuning knobs in one place, not to demonstrate opting in. Copy this to .github/workflows/claude-review.yml in a repository in your organisation -- INSTEAD OF, not alongside, either of the other two review examples. # # headroom_enabled needs nothing beyond what review already requires: it defaults to headroom_install_method: pip, which ubuntu-latest can already satisfy (Python 3 ships preinstalled); set headroom_install_method: docker instead only if your runner lacks a usable Python (ubuntu-latest already has Docker too, since review mode needs it for github/github-mcp-server). Either way the proxy runs loopback-only inside this job, so no new permissions: scope is needed. It is genuinely orthogonal to token scope -- it changes how Claude Code talks to Anthropic, not what it can do on GitHub -- which is why this file's permissions: block is identical to examples/direct/claude-review.yml's. # +# If this job also sets use_bedrock, use_vertex, use_foundry, or anthropic_federation_rule_id, set headroom_enabled: false below instead -- each of those routes Claude Code's requests to its own provider directly, bypassing the proxy silently, and leaving headroom_enabled at its true default alongside one of them fails validation outright. +# # One thing worth knowing before enabling this on a repository that accepts untrusted fork pull requests, beyond what the README's "Context compression (Headroom)" and "Security notes" sections already say: Headroom becomes a new third-party dependency processing every diff, CI log, and file read this action already treats as attacker-influenced input on such a repository -- on its own claims that compression runs entirely locally with no data leaving the machine, but it is still new code in that path. # # claude_code_oauth_token compatibility is confirmed, not just assumed -- see the README's "Context compression (Headroom)" section for how. From c1cf630099d45cde86ca83dc3bed944d5cfdc2ed Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 10 Sep 2026 10:27:23 +0100 Subject: [PATCH 2/3] fix(headroom): stop the proxy after every call site that can still need it Stop Headroom proxy ran immediately after the main review/triage/interactive call, but ANTHROPIC_BASE_URL stays exported to GITHUB_ENV for the rest of the job regardless of where the proxy actually stops. Three later steps -- the turn-limit wrap-up, the automatic-fix pass, and the structured review summary -- also call anthropics/claude-code-action and read that same env var, so any of them running after the proxy was already killed pointed at a dead process instead of a live one. Reproduced directly: a turn-limit wrap-up call failed with is_error:true, num_turns:1, total_cost_usd:0 (no request ever reached the model) after roughly three minutes, reproducible across two separate pull requests. Confirmed the mechanism locally -- a request against an already-killed proxy has nothing to connect to, and the SDK's own retry/backoff before giving up accounts for the multi-minute stall. Moves Stop Headroom proxy to run after the structured review summary, the last of the three call sites, instead of right after the main call. --- action.yml | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/action.yml b/action.yml index ddda0f1..49547bc 100644 --- a/action.yml +++ b/action.yml @@ -1811,25 +1811,6 @@ runs: echo "Giving up on posting the Headroom savings comment after 3 attempts." exit 1 - - name: Stop Headroom proxy - if: ${{ always() && inputs.headroom_enabled == 'true' }} - continue-on-error: true - shell: bash - env: - HEADROOM_INSTALL_METHOD: ${{ inputs.headroom_install_method }} - run: | - if [ "$HEADROOM_INSTALL_METHOD" = "docker" ]; then - docker stop headroom-proxy >/dev/null 2>&1 || true - else - # A plain kill only sends SIGTERM once, with no wait and no SIGKILL fallback -- harmless on an ephemeral GitHub-hosted runner (destroyed regardless), but the pip path exists specifically for a self-hosted runner with Python but no Docker, where the runner persists across jobs and a lingering process would hold the port for the next job's own headroom_enabled run. Escalating to SIGKILL after a short grace period matches what docker stop already does above. - if [ -f /tmp/headroom-proxy.pid ]; then - PID="$(cat /tmp/headroom-proxy.pid)" - kill "$PID" >/dev/null 2>&1 || true - sleep 2 - kill -0 "$PID" >/dev/null 2>&1 && kill -9 "$PID" >/dev/null 2>&1 || true - fi - fi - # --- Wrap-up on turn limit --- When the primary run hits --max-turns, Claude Code exits mid-task with no closing reply. This detects that and fires a short follow-on (3 turns) that resumes the session with a summarise-what-you-found prompt, so the work is captured rather than lost. Modelled on Exie's wrap-up pattern: short follow-on, full prior context via --resume, a nudge forcing a closing answer. - name: Detect turn-limit hit id: detect-turn-limit @@ -2093,6 +2074,26 @@ runs: path_to_claude_code_executable: ${{ inputs.path_to_claude_code_executable }} path_to_bun_executable: ${{ inputs.path_to_bun_executable }} + # Stopped here, after every anthropics/claude-code-action call site in the job (the wrap-up follow-on, the automatic-fix pass, the structured summary), not right after the main review -- ANTHROPIC_BASE_URL stays exported to $GITHUB_ENV for the rest of the job regardless of where this runs, so stopping the proxy any earlier than its last real user leaves that env var pointing at a dead process for whichever of those three calls happens to run afterward. Confirmed live: a turn-limit wrap-up call failed with is_error:true, num_turns:1, total_cost_usd:0 (no request ever reached the model) after roughly three minutes, reproducible across two separate pull requests, until this step was moved to its current position after the last call that can still need the proxy. + - name: Stop Headroom proxy + if: ${{ always() && inputs.headroom_enabled == 'true' }} + continue-on-error: true + shell: bash + env: + HEADROOM_INSTALL_METHOD: ${{ inputs.headroom_install_method }} + run: | + if [ "$HEADROOM_INSTALL_METHOD" = "docker" ]; then + docker stop headroom-proxy >/dev/null 2>&1 || true + else + # A plain kill only sends SIGTERM once, with no wait and no SIGKILL fallback -- harmless on an ephemeral GitHub-hosted runner (destroyed regardless), but the pip path exists specifically for a self-hosted runner with Python but no Docker, where the runner persists across jobs and a lingering process would hold the port for the next job's own headroom_enabled run. Escalating to SIGKILL after a short grace period matches what docker stop already does above. + if [ -f /tmp/headroom-proxy.pid ]; then + PID="$(cat /tmp/headroom-proxy.pid)" + kill "$PID" >/dev/null 2>&1 || true + sleep 2 + kill -0 "$PID" >/dev/null 2>&1 && kill -9 "$PID" >/dev/null 2>&1 || true + fi + fi + - name: Save Claude Code session if: ${{ inputs.resume_sessions == 'true' && steps.claude.outputs.session_id != '' }} uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 From 32f10a6191bc8792fcc3811c0c5f10423fc67e97 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 10 Sep 2026 10:36:26 +0100 Subject: [PATCH 3/3] fix(headroom): use the newest, not oldest, PyPI file upload time for the age gate PyPI allows additional files -- a new platform wheel, a replacement build -- to be uploaded under an already-published version well after its initial release; a version's files are not necessarily all published together. min(upload_times) measured only the oldest file's age, so a compromised maintainer account could add a fresh malicious wheel under an already-old pinned version and still pass the 7-day age gate, since pip is free to select whichever file matches the current platform. max(upload_times) measures the newest file under the version instead, so the whole version stays blocked for the 7-day minimum from whenever it was last touched, not just when it was first published. Confirmed against the real pinned 0.37.0 release (all files published together, same result either way) and a synthetic scenario (an old release with one new file added 2 days ago) -- min() would have let it through, max() correctly blocks it. --- action.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 49547bc..dd50456 100644 --- a/action.yml +++ b/action.yml @@ -1329,8 +1329,10 @@ runs: print(f"::error::PyPI has no published files for headroom-ai=={version}. Check headroom_version is correct.") sys.exit(1) + # max(), not min(): PyPI allows additional files (a new platform wheel, a replacement build) to be uploaded under an already-published version well after its initial release -- a version's files are not necessarily all published together. Using min() would measure only the oldest file's age, so a compromised maintainer account could add a fresh malicious wheel under this already-old version and still pass the age gate, since pip is free to select whichever file matches the current platform. max() measures the newest file under this version instead, so the whole version stays blocked for MIN_AGE_DAYS from whenever it was LAST touched, not just when it was first published -- closing that gap the same way a single simultaneous release already passes either way. + # # datetime.fromisoformat() only accepts a trailing "Z" (as PyPI's upload_time_iso_8601 returns) from Python 3.11 onward -- on an older Python it raises ValueError instead. Replacing it with the equivalent +00:00 offset parses correctly on every Python 3 version, not just the one this was tested against. - uploaded = datetime.fromisoformat(min(upload_times).replace("Z", "+00:00")) + uploaded = datetime.fromisoformat(max(upload_times).replace("Z", "+00:00")) age_days = (datetime.now(timezone.utc) - uploaded).days if age_days < MIN_AGE_DAYS: