security-guidance: stop-time review echoes raw ~/.claude/.credentials.json OAuth tokens into the transcript
Summary
The security-guidance plugin's stop-time LLM review reads ~/.claude/.credentials.json (Claude Code's own OAuth credentials) and echoes the full accessToken and refreshToken values verbatim into the finding text. That finding is then injected into the conversation as a <system-reminder>, which means the raw credentials land in the session transcript — a place that is persistent, plaintext, and fully searchable.
A tool whose job is to detect leaked credentials created a credential leak.
What I saw
UserPromptSubmit operation blocked by hook:
[bash ~/.claude/hooks/secret-guard.sh]: [SECRET-GUARD] detected a suspected API key / secret.
Original prompt: <task-notification>
<summary>Background security review found: Hardcoded Secrets in .credentials.json</summary>
</task-notification>
<system-reminder>
...
.credentials.json:
1. [CRITICAL] [Hardcoded Secrets] {"claudeAiOauth":{"accessToken":"sk-ant-oat01-<REDACTED — the
real 108-char value was printed in full here>","refreshToken":"sk-ant-ort01-<REDACTED — also
printed in full>",...}}
Suggested fix: Immediately revoke/rotate both tokens ...
</system-reminder>
The finding correctly identified the problem — but stated it by reproducing the secret.
In my case a local UserPromptSubmit guard hook blocked the prompt from being submitted, so it never reached the model. Without that hook, the raw tokens would have gone to the model and into the transcript unimpeded. They were still rendered to my terminal.
Why it triggers (root cause chain)
~/.claudeis a git repository for many users (dotfiles management). Mine is.~/.claude/.credentials.jsonis untracked — Claude Code does not ship a.gitignoreentry for it, and the file is created by the CLI itself.hooks/gitutil.pybuilds the review diff with a temporary index usinggit add --intent-to-add, deliberately pulling untracked files into the diff (_list_untracked,_temp_index,include_untracked). So the entire content of.credentials.jsonbecomes review input.- The OAuth
accessTokenhas an ~8-hour lifetime and is refreshed automatically. Every refresh rewrites the file, producing a "new" diff. - Each refresh therefore re-triggers the review, and the LLM re-emits the secrets.
Step 4 is what makes this more than a one-off: it recurs on a schedule as long as the file stays untracked in a git repo.
Why this is worse than a normal false-positive
- The output path is a
<system-reminder>injected into the conversation, so the value is written to the transcript on disk. Deleting it afterwards is unreliable — transcripts get synced, backed up, and indexed. The only sound remediation left to the user is rotating the credential. - The credential in question is Claude Code's own session token, including the refresh token (~27-day lifetime in my case), which is materially worse than the short-lived access token.
- Notably, the finding did not appear in
~/.claude/security/log.txt(I checked — 0 occurrences of anysk-ant-*string there). The plugin's own log is clean; the exposure happens only on the injection path.
Repro
cd ~/.claude && git init(or use any setup where~/.claudeis a git repo — dotfiles repos are common)- Ensure
.credentials.jsonis untracked and not in.gitignore(this is the default state) - Let the OAuth access token refresh, or just
touch/rewrite the file so it shows up as a new untracked change - Run a Claude Code session in that directory and let it reach
Stop - The stop-time review reports
[CRITICAL] [Hardcoded Secrets]with the token values inlined
Suggested fixes
In rough order of value-per-change:
- Redact matched secret values in finding text. A finding is just as actionable with
sk-ant-oat01-…plus file/line — the literal value adds nothing for the reader and is the entire harm. This is the minimal fix and covers every secret type, not just this one file. - Exclude well-known credential paths from review input by default —
.credentials.json,.aws/credentials,.netrc,.ssh/id_*, etc. Reviewing a file whose sole purpose is to hold secrets has a guaranteed 100% hit rate and near-zero information value. - Consider whether untracked files should be in scope at all by default, or at least whether
0600-mode files should be. The--intent-to-addbehaviour is reasonable for source files but surprising for anything the CLI itself writes into its own config directory.
Additionally, Claude Code could ship a .gitignore (or add .credentials.json to an existing one) in ~/.claude. That is defence-in-depth for a much broader class of accidents than this one — right now, a single git add -A in that directory commits the user's live session token.
User-side workaround, verified: adding .credentials.json to ~/.claude/.gitignore removes it from the untracked set, so it no longer enters the review diff (git status --porcelain now returns 0 matches for it). This stops the recurrence but obviously does not help users who don't know to do it.
Environment
- Claude Code CLI:
2.1.247 - Plugin:
security-guidance@claude-plugins-officialv2.0.7 - Provider: first-party (Claude Max subscription)
- OS: macOS (Apple Silicon)
~/.claude/security/log.txt: contains nosk-ant-*strings and no reference to this finding — the relevant lines are onlyHook called with args/Processing: hook_event=...entries, so there is nothing from that file worth attaching here.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗