Your agent read .env once, six weeks ago.
It's still sitting on your disk in plaintext.
128 credentials · 1,158 occurrences · 242 files
Found on one working developer machine, accumulated over two months of normal use.
Nobody pasted a key anywhere. The agent ran cat .env to debug something, and
Claude Code wrote the output to disk — where it stays forever.
Methodology.
Claude Code records every session to ~/.claude/projects/**/*.jsonl. Everything a tool prints goes in — including the contents of any credential file the agent opened. .gitignore does not apply; this is not your repository.
Anthropic knows. Two issues report it with real incidents — #58043 (a PINECONE_API_KEY leaked during routine debugging) and #59094 (live brokerage credentials leaked despite an explicit project rule forbidding it).
Both are closed as not planned. Labels: area:security, stale. Remediation is your problem.
/plugin marketplace add akzh4n/buzzcut
/plugin install buzzcut@buzzcut
Just want to look first? No install, no plugin, no dependencies — Python 3 is all you need:
git clone https://github.com/akzh4n/buzzcut && python3 buzzcut/scripts/buzzcut.py scan
/buzzcut:scan |
Reports what's in your transcripts. Read-only. Never prints key values — types, projects and counts only, so you can paste the output into a ticket. Covers every project by default (transcripts are global); narrow with --project |
/buzzcut:check |
Which of those keys still work. Asks each provider over a read-only endpoint. Network access, explicit consent required |
/buzzcut:clean |
Cuts the values out of the files. Mandatory backup, --dry-run first |
| hook | Redacts secrets from tool output before the model sees them — so they never reach the context or the disk |
Every other tool stops at "this string looks like a key."
That's the wrong end of the problem. 1,158 matches is noise you scroll past. "Three keys an attacker could use right now" is an incident you act on. buzzcut check asks the provider.
The report it produces (shape of the output — check makes network calls, so it only runs on explicit consent):
LIVE — rotate now
OpenAI sk-pro…Xe4 flowly
GitHub PAT ghp_4m…kM2 flowly
DEAD — already rotated, just clean them out
YOUR CALL
private key, database URLs, internal service secrets
⏺ Bash(cat backend/.env)
⎿ NODE_ENV=production
DATABASE_URL=postgres://flowly:***REDACTED by buzzcut***@10.0.0.4/prod
OPENAI_API_KEY=***REDACTED by buzzcut: openai***
✂ buzzcut: 2 secrets cut from Bash output
The command still ran. The agent got the file it asked for, minus the values. It does not start debugging a "broken" command — which is what happens when a tool rewrites the command itself with a sed filter instead of editing the result.
Numbers above come from a real machine: 4,308 transcripts, 1.9 GB, roughly two months of daily agent use across seven projects. They exclude buzzcut's own project directory, whose transcripts contain this repository's test fixtures rather than anybody's leaks.
A naive scanner reports 373 unique matches on that data. Two thirds of that is noise: PYTHONPATH=/a/b, HEAD=<sha>, WT=/path/DEV-604 — shell variables holding paths and git refs, structurally identical to a secret. A redaction hook that cuts those breaks your agent on every step.
buzzcut gates ENV-shaped findings on the variable name and excludes prefixes that are public by construction (NEXT_PUBLIC_, VITE_, REACT_APP_, EXPO_PUBLIC_ — bundlers ship those to the browser anyway). That leaves 128 unique keys across 1,158 occurrences, and what survives is POSTGRES_PASSWORD, AWS_SECRET_ACCESS_KEY, JWT_SECRET, R2_SECRET_ACCESS_KEY, TIKTOK_CLIENT_SECRET. Real ones.
False positives cost more than misses here — a hook that cuts the wrong string hands your agent corrupted data and it starts debugging a problem that does not exist. So the test suite has more cases asserting what must not be cut than what must.
In a .jsonl transcript a newline is stored as the two characters \ and n, not as a newline. So a cat .env dump lives on one physical line:
{"stdout":"OPENAI_API_KEY=sk-proj-…\nDB_PASSWORD=…\nAWS_SECRET_ACCESS_KEY=…"}
To a regex, …\nDB_PASSWORD is a single word — the word boundary is gone, and everything after the first variable silently disappears. Grepping these files naively finds the first secret in each dump and misses the rest. On this corpus that was 38 keys, including an AWS secret access key and a Postgres password.
buzzcut normalises those escapes before matching, using a replacement of identical length so match offsets stay valid against the original bytes — clean edits the file in place by offset, and a shifted offset would corrupt the line instead of redacting it. That invariant has its own test.
- Cleaning does not un-leak anything. A key that reached a transcript is compromised. Rotate it.
checktells you which ones still matter. - AWS, Google API keys, database URLs and internal secrets are not probed automatically — that needs request signing or a connection to your own production. Those are listed for you to judge.
- The detector is a heuristic. It does not replace
gitleakson your repo; it covers a surfacegitleaksnever looks at.
python3 -m pytest tests/ -q # 94 tests — the plugin
python3 -m pytest research/ -q # 67 tests — the measurement code behind the numbers above
No dependencies, standard library only.
MIT.