# 'Ignore .env' is not a defense

> A worker once obeyed every rule about secrets and still pasted a live token into a notes file, twice, and committed it. The rule it had was a sentence in a prompt. What I run now is a stack of controls that hold even when the model doesn't — identity, scope, transport, proof, and a hook — none of them foolproof, all of them more than a wish.

- Canonical: https://jedarden.com/notes/ignore-env-is-not-a-defense/
- Published: 2026-08-29
- Updated: 2026-08-29
- Tags: agents, safety, secrets, collaboration

---


The worker had a rule about secrets, and it followed the rule. It was verifying a GitHub token — did the scopes cover what the task needed — and it did that correctly: fetched the token the sanctioned way, checked it, got the right answer. Then it wrote up what it had done in a markdown notes file, and in the write-up, where it meant to record *how to obtain the token*, it recorded the token. Twice. Then it committed the file.

A pre-receive hook on the git server caught it before the file reached the public mirror. That hook is the only reason this note is about a rotated credential instead of a leaked one.

What matters is that the worker was not careless. It never touched a `.env` file. It never printed a secret to the terminal on purpose. It did something more ordinary and much harder to forbid: it conflated "document the token" with "document how to get the token," and the two sentences are one clipboard apart. Every rule I had at the time was a paragraph telling the model to be careful. The model was careful. The token went into the file anyway.

## The rule was a wish

The standard defense — the one in nearly every agent setup I've seen, and the one I had — is a line in the system prompt: *never read `.env`; never print secrets.* Some harnesses go further and make the file unreadable by the agent entirely. That's fine as far as it goes, and it goes about a foot.

It protects one location. Secrets don't live in one location. They come back from `gh auth token`, from a Kubernetes Secret you were told to describe, from a custom resource's `.status`, from the environment of a process you were debugging. The agent reaches all of those legitimately, in the course of doing the job you gave it, and the instruction about `.env` has nothing to say about any of them. The rule guards a door in a room with no walls.

The deeper problem is where the damage actually happens. When an agent leaks a secret, the secret doesn't leak *from* the store. It leaks *into* the transcript — the command line it typed (captured in the session log, in shell history, in `ps`), the tool output it read back, the file it wrote, the commit message, the ticket, the chat turn. Every one of those is logged, cached, and unrecallable. Once a value has been text the agent produced, it's gone, and no amount of deleting the file gets it back.

> A secret isn't safe from an agent because the agent can't *read* it. It's safe because the agent can't *say* it.

That reframe changes what you build. You stop trying to hide values from the model and start making sure that a value, on its way from wherever it lives to wherever it's needed, never passes through anything the model can see. The store isn't the thing you're protecting. The transcript is.

## What I run instead

None of what follows is exotic. It's the ordinary discipline any secrets store expects of a service account, applied without exception to a very talkative kind of service account. Each layer catches a different way the wish failed.

**The agent has its own identity, never a borrowed one.** The old setup had a root token for the vault sitting in a dotfile on the agent's workstation, because that's what was there when the agent first needed access. Now the workstation agent logs in with its own AppRole and gets a token that lives an hour. Agents running inside the cluster authenticate by their Kubernetes ServiceAccount and get ten- to twenty-minute tokens. Humans come in on a completely separate path, through SSO. Compromising the human path can't mint an agent token; compromising an agent can't log in as a human. Nothing eternal exists for anyone to steal.

**Scope is a prefix, not a role.** The agent's policy names exactly the path prefix it's allowed to write — its own cluster's secrets — and nothing outside it. Each automated writer in the cluster gets exactly the prefix it produces, and none of them can read what another one writes. Even the deliberately powerful superuser policy carries two explicit denials: it cannot disable the audit device, and it cannot seal the store. An identity that can switch off its own ledger voids the ledger, so that capability is carved out before anything else is granted.

**Values travel by pipe, `@file`, or stdin — never as an argument.** This is the rule that does the most work, and it's the one that's easiest to state: a credential never appears in `argv`. A new password is generated and stored in one line — `openssl rand -base64 32 | bao kv put secret/app/db password=-` — and the plaintext never exists as a literal anywhere. A value that has to move between two stores goes through a single-use *wrapping token* and a pipe: unwrap on one side, write on the other, nothing in between. When a value has to land on disk for a tool to read, it goes into a mode-600 file the agent redirects into and never `cat`s. The login wrapper itself passes its own credentials as `@file` so that even authenticating doesn't put a value on a command line.

**Every proof is by property, never by value.** This is the layer that took me longest to see, because it's a restriction on the *check*, not on the action. "Confirm the write worked" naturally becomes "read it back and look," and reading it back is the leak. So the sanctioned proofs are all things that demonstrate the value without showing it: the secret's version counter incremented; the consumer that syncs from the store reports `Ready`; the content hash on the delivered object is unchanged from before; a token was revoked by its *accessor* — the handle, not the value. If a check would need to print the thing to succeed, the check is wrong.

**The rule is enforced by a hook, not a paragraph.** A pre-tool hook inspects every file write, every edit, and every shell command before it runs, and refuses any that carry a high-signal credential shape — a GitHub token, a Slack token, a PEM block — in *any* file type, because the real leak went into a `.md`. It doesn't matter whether the model thought the value was a placeholder. It matters whether it matches. The same shapes are refused on the way into git by the server-side hook that caught the original leak, so there are two independent detectors between an agent's slip and the public internet.

**Writes can't silently clobber each other.** Every write to the store carries a check-and-set version. When two agents — or an agent and a scheduled job — race on the same path, the loser gets a 400 and retries next cycle, instead of overwriting a value nobody knew had changed. This one isn't about leaks at all; it's about the other way agents make secrets unsafe, which is by quietly making them wrong.

**There's a ledger, and the agent can't turn it off.** Every operation against the store is audited. When something looks wrong later, the question "which identity did what, when" has an answer that doesn't depend on the agent's own account of events.

## What it bought

Once these held, I did something I would not have done a month earlier: I granted the agents read *and write* access to the secrets store, as a standing policy, not a break-glass exception.

Nine days later, one of them migrated the entire store. Every secret the cluster depended on moved from an old vault to a hardened one — hundreds of paths, all verified by comparing hashes on both sides. Forty-odd consumers were re-pointed at the new store in waves, each wave proven by an unchanged content hash on the delivered secrets and zero restarted pods. The old vault's root tokens were found and revoked by accessor. Peer credentials were delivered by wrapping token and pipe. The old instance was frozen, exported, and decommissioned.

At no point in that entire operation did a secret value appear in the transcript. Not because the agent was told to be careful — because there was no path through which a value *could* have appeared, and every proof of success was designed to work without one. The agent did the most sensitive job on the estate under exactly the conditions where I'd have least wanted a human doing it by hand: hundreds of repetitive, high-stakes copy operations at the end of a long day.

## What it doesn't do

I want to be exact about the word *safer*, because it's the word, not *safe*.

**The hook fails open.** If it can't parse its input, it allows the call. A rule that fails closed would block the agent on every malformed edge case, and a blocked agent gets worked around. So the hook catches the common slip, and the rule still binds the agent regardless of whether the hook fires.

**The hook can't see output.** It inspects what the agent writes and what it runs — not what comes back. An agent that greps a process's environment and reads the result has put a value in the transcript, and nothing mechanical stopped it. The defense there is the reflex: presence-check, never print. `wc -c`, `md5sum`, "does this key exist" — never the value.

**Truncation is not redaction.** I once watched an agent avoid dumping a token by piping it through `head -c 100`. The base64 token was 110 characters long. Nearly all of it printed. The rule needs an operator who understands *why* it exists, because a model applying it as a ritual will find a way to satisfy the letter and lose the point.

**The audit ledger lives in the same trust domain as the store.** An attacker who owns the cluster owns the ledger. The append-only, off-site copy that would fix that is designed and not built; I looked at the cost of a locked bucket in a cloud project I don't otherwise use and decided the risk of a runaway bill outweighed the risk I was protecting against, for now. That's a real gap, and I'd rather name it than let the rest of this note imply it's closed.

So: not foolproof. But every one of those gaps is a *known* gap with a known shape, which is a different situation from a sentence in a prompt and a hope.

## Two scales, one rule

By hand, in an interactive session, the rule is a reflex: you type the pipe instead of the value, you ask "would this check have to print the thing to pass," you catch the agent reaching for `head -c` and explain why that isn't enough. It's the same posture as [refusing to let the agent grade its own homework](/notes/dont-let-the-agent-grade-its-own-homework/), pointed at the one domain where even a *correct* check is forbidden if it shows its work.

At fleet scale, where no human is reading the transcript as it happens, the reflex has to become structure: the identity is scoped by the store, the value is refused by the hook, the proof-by-property is written into the task's acceptance criteria before the task is dispatched. Nothing about the rule changes. What changes is who — or what — is holding it.

## The question I now ask

Before I hand an agent anything that touches a credential, I ask:

> What would the agent have to *see* to prove this worked?

If the answer is "the value," the check is wrong — and usually the task description is too. If the answer is a version number, a `Ready` condition, a hash, an accessor, the agent can proceed, and I can stop watching. Not because I trust it to be careful. Because there's nothing on the path for it to be careless with.

— Jed

---

*Background: [Trust is a property of the system, not the agent](/notes/trust-is-a-property-of-the-system/) — the general case: an agent doesn't need to be trusted, an action needs to be cheap to be wrong about. A leaked secret is the action that isn't, which is why it gets its own stack. And [Don't let the agent grade its own homework](/notes/dont-let-the-agent-grade-its-own-homework/) — independent verification, here with the extra constraint that the verifier is forbidden from looking.*

*Code: [utilities/agent-secrets](https://github.com/jedarden/utilities/tree/agent-secrets/v0.1.0/agent-secrets) — the credential-guard hook, the `bao-as` wrapper, and the prefix-scoped policies from this note, with tests and an installer.*
