I Printed 31 Secrets Into an AI Transcript
On September 30th a Claude Code agent session ran cat .env. The file held every credential I use to work on CertWatch, my certificate monitoring product, and all 31 of them landed in the session transcript. That transcript was sent to the model provider’s API and written to a log file on my laptop.
On September 30th a Claude Code agent session ran cat .env. The file held every credential I use to work on CertWatch, my certificate monitoring product, and all 31 of them landed in the session transcript. That transcript was sent to the model provider’s API and written to a log file on my laptop.
Nothing bad happened as far as I can tell. This post is about what I did next, because the cleanup taught me more than the mistake did.
How it happened
CertWatch is a solo project, and I write most of it with a coding agent. The agent needs credentials to be useful: it deploys to staging, reads logs, calls the Stripe sandbox, and runs browser tests against a real account.
My .env wasn’t a normal file. It was a named pipe backed by 1Password, so reading it resolved the secrets on demand and nothing sat on disk. 1Password documents this as a feature of its Environments product, and I felt good about it. Then an agent read the whole thing, and everything in it went into the conversation.
The secrets weren’t leaked by clever prompt injection or a malicious tool. The agent made a minor mistake, in a place where output becomes context. Most exposure with these tools will probably look like this: ordinary and fast, with no attacker involved.
Rule one: deletion doesn’t un-share
Once a value reaches a transcript, a log, a screenshot, or a chat message, it is compromised. Cleaning up the transcript doesn’t change that, so I didn’t try. I treated all 31 as burned, including the ones that looked low-risk.
The exposed set included the production database password, the backup bucket keys, the deploy tokens, and the email provider’s account token. I filed one issue as the checklist and ordered it by blast radius: critical, high, medium, low. Production data and backups first, sandbox keys last.
Rotating is more work than it sounds
I assumed rotation would be a matter of generating new values. Most of the time went to what else each value touches.
- Rotating in the vault alone breaks your deploy. Every secret had a consumer somewhere: a Fly app secret, a GitHub Actions secret, or a Terraform Cloud variable. I made the rule “change every paired copy in the same sitting,” and wrote a table of each credential and its consumers into the repo.
- Setting an app secret doesn’t update everything that uses it. On Fly,
fly secrets setrolling-restarts the long-running API machines. My scheduled checker machines are separate and kept running with the old secret until I updated each one by hand. - Database passwords need two steps back to back. I changed the role password, then immediately set the new connection string. The app can’t reach its database in between, so I did both without stopping to check anything.
- You can’t read a secret back. Fly shows names and digests, and GitHub does the same. A changed digest was the only proof that a value had changed, so I verified against the live system each time: a health check, a scheduled run, a manual backup dispatch, a Terraform plan.
- Names told me less than I assumed. Some names were ambiguous, and the docs and config disagreed about a few. One credential was a Grafana account password in one doc and an app test account’s password in the Terraform variables. I traced what actually consumed each before touching it, then picked a naming convention and wrote an inventory of every credential, what else changes when it rotates, and what it can reach.
The rotation took about a day. Most of that was verification, and I would do it the same way again.
The fix I tried first was wrong
My first replacement was a one-line wrapper around 1Password’s op run. It resolved references from a committed template and handed the secrets to whatever command I ran. It looked tidy.
It had a hole. The wrapper authenticated with a service account token and passed it into the command’s environment, so agent-secrets env would have printed the very token that unlocks the vault. op run masks secret values it injects, but it doesn’t mask its own credentials.
I had patched one variable at a time without writing down what I was defending against. A threat model would have saved me several rounds of review.
What it looks like now
Two vaults. An “agent” vault holds only credentials where exposure is a nuisance: staging, read-only, and sandbox values. A “master” vault holds production, billing, backup, and CI, and the agent’s service account can’t read it at all. If the agent leaks something, I rotate a low-risk credential instead of the production database.
One wrapper. Agents run commands through a single script. It does four things:
- It re-executes itself in a clean environment with a fixed
PATH, so a hostile or sloppy caller environment can’t shadow a binary. - It only runs commands from an allowlist, and never a shell,
env,printenv,echo, or a path. - It resolves the command from a short list of trusted directories before any credential exists, so a look-alike
flynever gets a token. - It gives each command only the variables it needs and strips the service account token before the command starts.
A dedicated test account. Browser tests run against an agent@ account with the lowest privileges that still cover the flow. It’s a free-plan user in production and an admin on staging, so anything destructive happens somewhere I can afford to lose.
Rules in the repo. AGENTS.md now says: treat any credential variable as write-only, never echo or print one, never run tools in verbose mode while one is loaded (debug flags log authorization headers), and don’t read credential files on the machine. If a secret shows up where it shouldn’t, stop and tell the owner right away so they can rotate it.
What it doesn’t stop
The wrapper covers accidents and messy environments. It can’t stop an agent that is deliberately looking for secrets: one that skips the clean-environment step, prints the test password from a node one-liner, or reads credential files already on disk.
For that, I rely on written rules and on permission deny-rules I set myself, for direct op calls and for the credential files. I documented this in the repo next to the wrapper. A security measure that doesn’t say what it can’t do gives you false confidence, and I’d rather read an honest limitation than discover it.
What I’d tell someone setting this up
- Decide what you’re defending against before you write the wrapper: accidental exposure, or a determined agent. They need different designs.
- Give the agent a separate vault of low-risk credentials. Don’t give it access to your real one and hope it’s careful.
- Keep an inventory of every credential, where it lives, and what else changes when you rotate it. I wrote mine after the incident, and I wish I’d had it before.
- Assume you’ll make this mistake, because the commands you type to debug are the ones that print things. Make the blast radius small ahead of time.
- Never read a secret-bearing file just to check it. Check that it exists, or check a name or a length.
The part that surprised me most was how ordinary the fix was. It was least privilege and an inventory of what I owned, the kind of thing I’d have insisted on at work. I skipped it here because the tools let me build something that worked before I’d thought about how it should work, and nothing slowed me down enough to ask. A credential setup that looked clever was the first place that cost me. Speed changes the order you do things in, and the thinking I’d normally do up front only showed up after the incident.