An AI Agent Exposed 31 Secrets. Here's What I Changed.
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 coding agent session ran cat .env. The file held 31 credentials I used to work on CertWatch, my pre-launch product for tracking TLS certificate expiration and alerting people before an expired certificate causes an outage. All 31 landed in the session transcript. The command output became part of subsequent model API requests and was saved in a log file on my laptop.
CertWatch had no external customers or customer data at risk. It did have a production environment, where I was testing deployment, monitoring, email delivery, and backups before bringing in customers. The credentials were real, including production infrastructure access.
I found no evidence of unauthorized use and treated every credential as compromised.
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 uses credentials to deploy to staging, read logs, call the Stripe sandbox, and run browser tests against a dedicated account.
My .env wasn’t a normal file. It was a named pipe backed by 1Password, so reading it resolved the secrets on demand without storing the resolved plaintext in a regular .env file. 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.
No malicious prompt or tool was needed. An ordinary debugging command exposed credentials because I had made them available to the session. Keeping plaintext out of a file had not kept it out of the agent’s output.
Rule one: deletion doesn’t un-share
Once a secret reaches a destination outside its intended handling path, I treat it as compromised. Deleting the transcript would not restore trust in those credentials, so rotation came first. 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 infrastructure 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.
- Changing a value in the vault is only part of the job. 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, updating the app secrets restarted my long-running API machines. My scheduled checker machines were separate and retained the old value until I updated each one.
- My database rotation required a coordinated cutover. I changed the role password and immediately updated the application connection string. New connections could fail between those steps, so I kept the interval short and verified connectivity afterward.
- Secret stores provide limited verification. Fly exposes names and digests; GitHub exposes names and update timestamps. Those helped confirm updates, but I also checked the consumers: a health check, a scheduled run, a manual backup dispatch, and 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. Running agent-secrets env would have exposed the token that gave the wrapper its vault access. I had relied on op run’s output masking without accounting for the wrapper’s own authentication credential.
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 credentials scoped to the tasks the agent needs: staging operations, selected read-only access, sandbox services, and a dedicated browser-test account. These still carry risk, but they have a smaller blast radius. A separate vault holds production infrastructure, billing, backup, and CI credentials. The agent’s service account cannot read it.
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, reducing the risk of inherited variables changing which command runs. - It only runs commands from an allowlist, rejecting direct requests for a shell,
env,printenv,echo, or an arbitrary executable path. - It resolves the command from a short list of trusted directories before loading credentials.
- It gives each command only the variables it needs and removes the service account token before starting the child process.
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. That limits production access while allowing broader testing in staging.
Rules in the repo. AGENTS.md now says: treat credential variables as write-only, never echo or print one, avoid verbose or debug output while credentials are loaded, and don’t read credential files on the machine. Debug output can include credentials or authorization headers. 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 reduces accidental exposure and problems caused by inherited environments. It does not isolate credentials from code running with the same user’s access.
An agent could bypass the wrapper, use a program to print an injected credential, or read credential files already on disk. An approved command can also expose secrets through its own output.
Written rules and tool permission deny-rules add guardrails. I configured deny-rules for direct op calls and credential files, and documented them next to the wrapper. I do not treat those rules as a security boundary against arbitrary code execution.
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. Reducing accidental exposure and containing arbitrary code execution require different designs.
- Give the agent access only to the credentials its tasks require. Keep privileged infrastructure credentials outside that access.
- Keep an inventory of every credential, where it lives, what it can reach, and what else changes when you rotate it. I wrote mine after the incident, and I wish I’d had it before.
- Assume a debugging command will eventually print something it shouldn’t. Make the blast radius small ahead of time.
- Don’t print a secret-bearing file just to check that credentials are configured. Check names and presence without exposing values, then verify through the operation that needs them.
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 applied less rigor to this solo project, and the incident exposed that gap. A credential setup that looked clever had solved plaintext storage without solving access or output handling. The lasting lesson was to decide what the agent should be able to reach before making that access convenient.