anthropics / anthropics/claude-plugins-official
security-guidance: uses ambient ANTHROPIC_API_KEY even when Claude Code has recorded the user rejecting it, silently moving reviews off subscription billing
- Dominant language
- Python
- Stars
- 36.3k
- Forks
- 4.1k
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 539
Description
## Summary
The plugin reads `ANTHROPIC_API_KEY` straight from the environment and prefers it over the OAuth token Claude Code injects, even when the host application has already recorded that the user **declined** to use that exact key.
Claude Code has a consent mechanism for ambient API keys: it detects a key in the environment, prompts, and persists the answer in `customApiKeyResponses` (`approved` / `rejected`) in `~/.claude.json`. In my case that file contained the key's identifier in `rejected` with an empty `approved` list — I had declined it three times, and the CLI's own main loop correctly honored that and ran on my subscription.
The plugin bypasses that record entirely. `llm.py:102` reads `os.environ`; `llm.py:483` prefers the key over the injected OAuth token. Result: every Stop-hook and commit review billed a Console org per-token, while the host application, on the same machine and in the same session, was deliberately not using that credential.
Repo state: `b12baea`, security-guidance `2.0.6`.
## How this surfaced
I noticed unexplained per-token API charges while believing I only used Claude via a Team subscription. Tracing it: the plugin's own debug log at `~/.claude/security/log.txt` correlated exactly with the billed days — the four heaviest activity days in the log were the only four days with usage on that key, and the heaviest by 6× matched the largest usage row by an order of magnitude (~25M tokens of cache traffic in a single day, from the agentic commit reviewer).
The model ids made it conclusive: `claude-opus-4-7` is this plugin's `SECURITY_REVIEW_MODEL` default (`llm.py:131`), and `claude-haiku-4-5` its cheap path (`llm.py:289`).
Worth noting how invisible this is from the user side. Nothing in the CLI indicates a plugin is billing a different credential than the session is using, and the only local evidence is a debug log you have to know exists.
## Relevant code
- `llm.py:102` — `ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")`, no consultation of the host's consent record
- `llm.py:483` — `use_token = _auth_prefer_token or not ANTHROPIC_API_KEY`; the key wins whenever present
- `llm.py:1127-1134` — `_agentic_env` blanks `ANTHROPIC_AUTH_TOKEN` so the inner CLI cannot fall back to the subscription
- `llm.py:255`, `:542` — `_auth_prefer_token` is only ever set by the runtime 401 handler; **there is no configuration that selects the auth path**
The rationale is documented at `llm.py:1309-1315`:
> Plugin-hook subprocesses get ANTHROPIC_AUTH_TOKEN (the user's OAuth token) injected by Claude Code. The SDK builds the child env as `{**os.environ, **opts.env}`, so the inner claude inherits it and prefers it over ANTHROPIC_API_KEY — but some model endpoints reject OAuth bearers (401 → exit 1 → silent fallback). Override with empty so API-key auth wins.
That reliability concern is legitimate. But note the fallback in `_call_claude` is one-directional: `llm.py:539` falls back key→token on 401, and there is no token→key mirror. Adding the mirror addresses the reliability problem without making the key the default — you get correct billing _and_ the 401 safety net.
## Not documented
README:23 lists "A working API path (subscription, API key, or 3P provider config)" as a prerequisite, presenting the three as interchangeable. Nothing states that the API key takes precedence when both are available, and the README has no billing or cost section. Discovering this behavior currently requires reading `llm.py:483`.
## Why this is worth changing
This is the same failure mode as several reports against the CLI itself — an ambient `ANTHROPIC_API_KEY` silently winning over subscription auth (anthropics/claude-code[#36350](https://github.com/anthropics/claude-code/issues/36350), [#37686](https://github.com/anthropics/claude-code/issues/37686), [#53638](https://github.com/anthropics/claude-code/issues/53638)). An `ANTHROPIC_API_KEY` exported into a long-lived shell, tmux server, or CI environment is extremely common, and it is usually there for some unrelated tool.
There is also precedent in this repo. #853 reported that `skill-creator`'s `improve_description.py` called the Anthropic SDK directly and so billed per-token instead of routing through the user's subscription. That was resolved by switching the script to shell out to `claude -p`, and the file now carries the comment "uses the session's Claude Code auth, no separate `ANTHROPIC_API_KEY` needed." The principle already applied there — a plugin should use the session's auth rather than reaching for a separate credential — is the same one at issue here.
To be clear about the tradeoff: "always prefer the subscription" is not unambiguously correct either. Subscription usage consumes rate limits that gate interactive work, and some users will deliberately want plugin spend routed to a Console key to protect those limits. The problem is not which credential is chosen — it is that the choice is silent, undocumented, unconfigurable, and contradicts a decision the user already made in the host application.
## Requested changes
1. **Honor the host's consent record.** If Claude Code has the ambient key in `customApiKeyResponses.rejected`, do not use it. This is the core ask — it is an explicit user decision the plugin currently overrides.
2. **Make the auth path configurable**, e.g. `SECURITY_GUIDANCE_AUTH=subscription|apikey|auto`, so both preferences are expressible.
3. **Default to the subscription** when the host is OAuth-authenticated, as the least-surprise behavior: billing then matches what the user sees the rest of their session doing.
4. **Mirror the 401 fallback** (token→key, as key→token already works at `llm.py:539`) so a subscription-first default stays robust against endpoints that reject OAuth bearers — which removes the need for the `_agentic_env` override.
5. **Document the precedence and its billing consequence** in the README, whatever the default ends up being.
If the default is left as-is, then at minimum a one-time per-session notice that reviews are billing an API key rather than the subscription would have saved me the entire investigation.
Contributor guide
No contributing guide indexed for this repository
Research direction
Read llm.py around lines 102, 483, 539, 1127-1134, and 1309-1315, then inspect Claude Code's customApiKeyResponses record in ~/.claude.json. Trace the existing key-to-token fallback and the README prerequisite at line 23 before deciding how the requested auth modes interact. Done means the host's rejection is honored, precedence is explicit and configurable, fallback behavior is covered, and billing consequences are documented.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- authentication, cli, documentation, security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 42/100