Back to notes

Note

Looking Is Not Taking

I installed a guard so coding agents could not read my env files. It blocked a command that only lists filenames, and then it blocked a commit message describing the problem. Neither could leak anything. A guard that cannot tell discovery from disclosure does not protect your secrets, it teaches agents to work around you.

#security#ai-agents#secrets#building

I have a guard that stops AI coding agents from touching my env files. It runs before every tool call, in every project I own, and for months I assumed it was quietly doing its job.

Then it blocked this:

find . -maxdepth 3 -name ".env*" -type f

Look at what that command actually does. It prints paths. It opens no file, reads no bytes, and cannot emit a single character of a single value. The question it answers is "which env files exist here," which is a fact about the shape of my projects, not about my credentials.

Denied. Globally. With this:

Bash commands referencing .env files are blocked globally. .env.example is allowed; cat/source/grep on other .env files is not.

Later, it blocked a commit message.

An agent was writing up the problem, in a commit, in prose. The message named those files, because you cannot describe a problem about them without writing their name. Denied. Same rule, same wall.

A commit message touches nothing. It is text going into a git object. There is no file handle anywhere in the operation.

That one left an artifact, and the artifact is still sitting in my repository. The first commit of the project I eventually built out of this contains the line "why agents currently flail against a blocked env file." Every other document in that repo writes the actual filename, dozens of times over. That one line says "env file" in longhand because it is the rewrite. The commit that would have described the problem is the commit that got blocked, and nobody went back afterwards to say so.

I find that funnier than I should. It is also the cleanest possible statement of what was wrong.

A gate blocks a figure from reading a directory signboard while the sealed vault behind it stays completely untouched.

The guard did not fail at security. It failed at vocabulary.

It had one word where it needed two.

There are two entirely different operations an agent can perform against a file full of secrets, and they are not neighbours.

Discovery asks what exists. Which files are here. Which variables are defined. How many. How long a value is, what character class it uses, whether it looks like a URL or a token. Every one of those answers is structural. You could hand all of it to a stranger and lose nothing.

Disclosure asks what the value is. That is the whole game, and it is one narrow thing.

My guard matched on the filename. The filename appears in both operations, which means it separates neither. And because discovery is by far the more common request, the guard spent most of its working life blocking the safe half of the problem while feeling like security.

This is not a niche bug. It is the default shape of any control written in a hurry: find the string that appears in the bad thing, block the string. The string appears in the good thing too.

The part that actually costs you

Here is where it stops being funny.

An agent that gets denied does not stop. It has a task, it has been told no, and the denial named no alternative. So it reasons its way around the wall. It writes a script that does the thing indirectly. It reaches for the value through an interpreter, a subshell, a pipe, some path the string match did not anticipate.

Read that behaviour again, because it is precisely the thing you were afraid of. You installed the guard because you did not want an agent handling your secrets. The guard, by refusing without direction, taught the agent to get inventive about handling your secrets.

A denial that names no next step is not a wall. It is a puzzle, handed to something very good at puzzles.

The agents working on this did not route around it, and I want to be exact about why that matters. Working around a control the owner installed on purpose is not something a tool should do quietly, even when the control is wrong. The correct response to a bad guard is to say the guard is bad. So the inventory this whole project needed simply did not happen for a while, and the task file said so out loud instead of quietly circumventing it.

That is the good outcome. It was still expensive. Every one of those denials was a round trip that produced nothing.

The line is the equals sign

The fix, once it was obvious, took about ten minutes and it is one sentence long.

The line moved from the filename to the = sign.

Everything left of the equals sign is inventory. Which files exist, which names are defined in them, how many there are, how long a value is. All of that is legitimately the agent's business, because an agent that knows what exists stops guessing, and an agent that stops guessing stops writing workaround scripts.

Everything right of it is the secret. That stays exactly as locked as it ever was.

The guard was rebuilt around that line and then tested in both directions, which is the half people skip. Ninety-five assertions covering the block cases and the pass cases. Eighteen adversarial attempts to reach a value through a side door, all denied, including echo $(cat .env), backticks, find -exec cat, xargs cat, and CAT=cat; $CAT .env for the shell-variable trick. Plus a new capability that prints variable names and never values, in three modes: names, names with value shape, and a plain count.

The pass case is the one that matters, and it is the one a security change almost never verifies. A guard is not finished when the bad thing is blocked. It is finished when the good thing is proven to still work. The exact find command that started all this now runs, through the real hook, and returns seventy files.

Every denial now names the command to run instead, with the path already filled in. That single change is worth more than the rest of it combined, because an agent told "no" invents, and an agent told "no, run this instead" complies.

A long column of name and value pairs with a bright line falling exactly on the equals signs, names lit on the left and values sealed in darkness on the right.

So what was it guarding?

Once discovery was allowed, I could finally count what was behind the wall. This is the part I did not expect.

Across twenty-eight projects on this machine there are seventy-two env files holding one thousand nine hundred and two variable definitions. Nine hundred and fifty-one distinct names, four hundred and twenty-nine of them in real files rather than examples.

Of those four hundred and twenty-nine:

  • One hundred are actual secrets. Credentials. The real thing.
  • One hundred and forty-three are decided constants. A value someone chose once, identical in every environment that exists, sitting in a dashboard row because that is where env vars go.
  • Fifty-three cannot be secret by construction. They carry a NEXT_PUBLIC_, VITE_ or NUXT_PUBLIC_ prefix, which means a build step compiles them into a browser bundle and serves them to every visitor. A name like NEXT_PUBLIC_SOMETHING_SECRET is a naming mistake, not a credential.
  • One hundred and thirty-three need a human to look at them, and most of those are probably constants too.

So the guard was standing in front of four hundred and twenty-nine things, of which roughly a hundred were the thing it was there for. It could not tell those apart either, for exactly the same reason it could not tell looking from taking. It was matching on where a value lives instead of on what the value is.

The redundancy showed up in the same count. DATABASE_URL appears in fifteen separate projects. OPENAI_API_KEY and OPENROUTER_API_KEY in seven each. Rotating one of those today means editing it in that many places and hoping I did not miss one.

I already had a rule about this, written down months ago after a deployment took four failed builds to surface four separate configuration faults. A value is not an environment variable until it has to be. It qualifies on two grounds only: it is a secret, or it genuinely differs between deployments that exist today. Everything else is a decision, and a decision belongs in source where it can be reviewed, not in a dashboard row a human typed once and can never verify.

That project went from forty-six required variables down to thirty-two, of which twenty-four were real secrets. The same collapse just happened at ten times the scale, and it is the strongest thing the count told me. Most of what you are protecting was never a secret. It is configuration in a costume, and every extra row is one more chance for a typo to fail a build at the worst moment.

A large heap of variable tokens sorted into four bins of very different sizes, with only the smallest bin sealed and marked as genuine credentials.

Guards are measured, not installed

I want to end on the thing that stops this being a story about how I fixed something.

While writing this piece, I ran a probe against the guard. Not to break it, and not to reach a value. I wanted to compare the old behaviour against the new, so I piped a small JSON payload into an archived copy of the previous script. The command read no file, opened no file, and named no file as a target. The payload happened to contain the filename as a quoted string, because describing the payload is what the payload was for.

Denied. By the fixed guard.

The denial even misidentified which command was doing the reading, quoting back a fragment it had scraped out of my JSON.

So the line did move from the filename to the equals sign, and that was the right move, and it removed the daily friction completely. But a command whose only offence is mentioning one of those files still trips the wire. The guard got much better at telling a read from a list. It still cannot tell a description from an action, which is the same category error one level up.

I am not going to pretend that is a serious hole. Nothing leaked, and a false denial is the safe direction to fail in. It is the original bug surviving its own fix, at smaller scale, and I only found it because I went and looked.

That is the lesson that outlives this particular hook.

A guard is not something you install. It is something you measure, in both directions, repeatedly. Test what it blocks, or you are trusting a regular expression with your credentials. Test what it allows, or you will not notice the day it quietly starts refusing the work.

And write the denial message as though the thing reading it is capable, determined, and has somewhere else to be. Because it is.

Looking is not taking. If your guard cannot tell the difference, it is not protecting your secrets. It is only making them expensive.


This came out of building LoopsVault, a local vault that lets agents use credentials without ever seeing the values. The mechanism is its own note.


Sources

Audio transcript

You are listening to "Looking Is Not Taking," a note by Jain Yagi.

I installed a guard to keep AI coding agents away from my secrets, and for months I assumed it was working. Then it blocked a command that lists filenames.

That is all the command did. It printed which config files existed in a folder. It opened nothing, read nothing, and could not have produced a single character of a single value even if something had gone wrong. It answered a question about the shape of my projects, not about my credentials. Denied anyway.

Later the same guard blocked a commit message. An agent was writing a description of the problem, in prose, into the commit. The description named the files, because you cannot describe a problem about those files without writing their name. A commit message touches nothing. It is text going into a version control object. There is no file being opened anywhere in that operation. Denied.

The evidence is still sitting in my repository. The very first commit of the project I eventually built out of this frustration contains a strangely worded line. Every other document in that repo writes the actual filename over and over, but that one line says it in longhand, awkwardly, because it is the rewrite. The commit that would have explained the problem is the commit that got blocked, and nobody went back afterwards to point that out.

So the guard did not fail at security. It failed at vocabulary. It had one word where it needed two.

There are two completely different operations against a file full of secrets. Discovery asks what exists. Which files, which variable names, how many, how long a value is. All of that is structural. You could tell a stranger every bit of it and lose nothing. Disclosure asks what the value is. That is the whole game, and it is one narrow thing.

My guard matched on the filename, and the filename appears in both. So it separated neither. Because discovery is by far the more common request, the guard spent most of its life blocking the harmless half of the problem while feeling like protection.

Here is the part that actually costs you. An agent that gets denied does not stop. It has a task, and the refusal named no alternative, so it reasons its way around the wall. It writes a script that does the thing indirectly. It reaches through a subshell or an interpreter, some path the pattern did not anticipate.

Sit with that, because it is exactly what you were afraid of in the first place. You installed the guard because you did not want an agent handling your secrets. The guard, by refusing without direction, taught the agent to get inventive about handling your secrets. A denial that names no next step is not a wall. It is a puzzle, handed to something very good at puzzles.

The fix took about ten minutes and it is one sentence. The line moved from the filename to the equals sign. Everything to the left of the equals sign is inventory, and agents can have it. Everything to the right is the secret, and that stays exactly as locked as it was.

Then, with discovery finally allowed, I counted what had been behind the wall the whole time. Across twenty eight projects there were seventy two config files holding over nineteen hundred definitions. Four hundred and twenty nine distinct names in real files. Of those, about one hundred were actual credentials. A hundred and forty three were decided constants, values chosen once that never differ anywhere. Fifty three could not be secret at all, because a build step compiles them into a browser bundle and serves them to every visitor.

Most of what I was protecting was never a secret. It was configuration in a costume.

One last thing, and it is why I do not get to call this fixed. While writing the piece I ran a probe against the new guard. The command read no file and named no file. It merely contained the filename inside a quoted string. Denied. The original mistake survived its own fix, one level up, and I only found it because I went and looked.

So a guard is not something you install. It is something you measure, in both directions, repeatedly. Test what it blocks, or you are trusting a pattern with your credentials. Test what it allows, or you will not notice the day it starts quietly refusing the work.

Looking is not taking. If your guard cannot tell the difference, it is not protecting your secrets. It is only making them expensive.