security-guidance: stop-time review echoes raw ~/.claude/.credentials.json OAuth tokens into the transcript

Status Open
Reported on v2.1.247
Maintainer reply None cached
Activity 1 comment · opened Aug 27, 2026

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)

  1. ~/.claude is a git repository for many users (dotfiles management). Mine is.
  2. ~/.claude/.credentials.json is untracked — Claude Code does not ship a .gitignore entry for it, and the file is created by the CLI itself.
  3. hooks/gitutil.py builds the review diff with a temporary index using git add --intent-to-add, deliberately pulling untracked files into the diff (_list_untracked, _temp_index, include_untracked). So the entire content of .credentials.json becomes review input.
  4. The OAuth accessToken has an ~8-hour lifetime and is refreshed automatically. Every refresh rewrites the file, producing a "new" diff.
  5. 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 any sk-ant-* string there). The plugin's own log is clean; the exposure happens only on the injection path.

Repro

  1. cd ~/.claude && git init (or use any setup where ~/.claude is a git repo — dotfiles repos are common)
  2. Ensure .credentials.json is untracked and not in .gitignore (this is the default state)
  3. Let the OAuth access token refresh, or just touch/rewrite the file so it shows up as a new untracked change
  4. Run a Claude Code session in that directory and let it reach Stop
  5. The stop-time review reports [CRITICAL] [Hardcoded Secrets] with the token values inlined

Suggested fixes

In rough order of value-per-change:

  1. 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.
  2. 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.
  3. Consider whether untracked files should be in scope at all by default, or at least whether 0600-mode files should be. The --intent-to-add behaviour 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-official v2.0.7
  • Provider: first-party (Claude Max subscription)
  • OS: macOS (Apple Silicon)
  • ~/.claude/security/log.txt: contains no sk-ant-* strings and no reference to this finding — the relevant lines are only Hook called with args / Processing: hook_event=... entries, so there is nothing from that file worth attaching here.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗