How could a coding agent read your .env file indirectly, and how did zn-gate 1.12.0 close those routes?

EngineeringJohn Silver13 min read

For zn-gate 1.5.0 to 1.11.0, the answer is yes, in a few specific ways. The agent hooks refused the obvious command that reads a .env file, but they judged the path as it was written in the command, relative to the current folder. A command that reached the same file by another route could run. Version 1.12.0 resolves those forms before it decides, checks every command of a chain, and refuses what it cannot check within a time limit. The issue is GHSA-rccq-h3xc-j9qj, rated medium. The zn team found it during an internal review.

This article describes the classes of routes at the level of the published advisory, how the fix is designed, and what we would tell anyone who writes a guard for coding agents. The only commands it quotes are the examples already published in the 1.12.0 changelog; it adds no new form and no proof of concept. The full proof of concept stays unpublished while users update.

What does the read guard of zn-gate do?

zn-gate installs hooks in Claude Code, Codex CLI, Cursor and OpenCode. Before the agent runs a shell command, the hook receives the command text and answers allow or refuse. One of its jobs is to refuse commands that would read a protected file, so the contents never reach the model. Protected files are .env files that hold plain-text values and credential files such as ~/.aws/credentials.

The guard does not run the command. It reads the text, finds the file names the command mentions and decides. That is a static check on a language that is not static: the shell expands, substitutes and reinterprets text while it runs. The distance between what the guard sees and what the shell does is where every route below comes from.

A .env file whose values were replaced with markers by zn env shadow is not a protected target in the same way. It holds names and markers of the form __ZN_<NAME>__, so reading it by any route shows no value. That is why shadowing matters for what follows.

Which routes did the guard miss?

The advisory describes these forms. Here they are as classes.

Class What went wrong Example from the public changelog
Wildcards A pattern in a path was not expanded before the check, so the guard looked for a file named like the pattern and found nothing cat app*/.env
Changing directory The check resolved paths from the folder where the hook runs, not from the folder the command had moved to cd app && cat .env
Paths built while the command runs A variable or a command substitution supplied part of the path, and its value did not exist at check time a path that starts with a variable or a command substitution
Some shell quoting and redirection The shell has several ways to write the same string and to open a file for reading, and the guard understood only the plainest
The copy stored in git A .env committed once, or kept in the index, can be printed from the repository even if it is gone from disk git show HEAD:.env
Very long chains, checked in part Only the first commands of a long chain were examined, so a read at the end was not checked, for any protected file
A check slower than the hook timeout Each agent waits a limited time for the hook; a command that took longer to check than that ran unchecked

The first five are ways to name a file that the guard did not resolve. The last two are different in kind: they do not hide the file, they make the check not happen. The long-chain and timeout forms applied to every protected file, including credential files outside .env.

Why is a wildcard a problem for a path check?

Because the guard and the shell disagree about what the text means. The shell turns a pattern into the list of matching paths before the command starts. A guard that treats the pattern as a literal name asks whether a file called exactly that exists, and the answer is no. The command is then allowed, and the shell expands it into a real file. In 1.11.0 the plain path was refused and the same file reached through a pattern in a folder name was not.

Why does changing directory matter?

Paths in a command mean something relative to the current folder, and a command can change it halfway. The guard resolved what it read against the folder where the agent started. Once a command moves, that base is wrong. The same applies to a .env that is already shadowed in the starting folder: the guard could be satisfied by the shadowed file while the command reads a different one.

What is wrong with paths built while the command runs?

Their value does not exist when the guard looks. A variable set earlier in the chain, or the output of a command substitution, only becomes text when the shell runs it. The guard sees a placeholder and cannot know what it will become.

What about quoting, redirection and the git copy?

Shells accept many spellings for the same string, and several ways to open a file for reading besides naming it as an argument. A guard that normalises only the common spelling will miss the others. The git route is a different observation: a protected file has more than one place to live. The working tree copy is the one everyone thinks about. The committed copy and the history can be printed with git commands, and they are still there after the file is deleted or ignored.

Why do long chains and slow checks matter more than the others?

Because they do not depend on how the file is named. If the guard examines only the first commands of a long chain, anything placed after that point is outside the check. If the guard needs longer than the agent waits, the agent stops waiting and the command runs. Both turn a rule that is correct for each command into a rule that can be switched off by the shape of the input. An agent that follows a prompt injection can produce such input without anyone writing it by hand.

What is the impact?

An agent that ran one of these commands, for example after reading a prompt injection, could print a protected file into its context. That is the scenario the hook exists to prevent. The CVSS 3.1 score is 5.5, medium, because it needs a local agent and a user action, and the confidentiality impact is high while integrity and availability are not affected.

Two facts limit it. Files already shadowed with zn env shadow were not exposed by the .env routes, because they hold only markers. Other credential files (~/.aws/credentials, ~/.ssh/ and similar) were exposed only through the long-chain and timeout forms. The output scrubber, a separate layer, still redacts known secret formats in tool output, which reduced what reached the model for those formats. It is a second line, not a replacement for the guard: a secret in a format it does not know passes through.

How does 1.12.0 close these routes?

The fix has four parts.

1. Resolve before deciding. The guard now computes what the shell would compute, as far as text allows, and checks the result. Patterns are expanded against the folder the command will actually be in. A change of directory updates the base for the commands that follow. Paths built from variables and substitutions are resolved where they can be, and code handed to an interpreter or to another shell (bash -c "cat app/.env", or a script passed to a language runtime) is read as a command in its own right. Several spellings of the same string and several forms of redirection are normalised to the file they open. A read of a protected file from the git repository or its history is refused like a read from disk, also when the file is no longer on disk. The clean-up commands people need still work: git rm --cached .env, git restore --staged .env, git status and git check-ignore .env.

2. Check every command. Earlier versions looked only at the first 1000 simple commands of a chain, counting lines too, and let the rest run. 1.12.0 checks every command of a chain.

3. Refuse what cannot be checked in time. The check runs within a time and disk budget that is well below the agent's hook timeout. A command with too many paths to resolve inside that budget is refused. The agent gets an answer before it gives up waiting, and the answer is no. Two details support this: a FIFO or another special file with a protected name is refused without being read, and a name repeated many times in a long command is checked once.

4. Shadowed files as defense in depth. Reading a path correctly is a prediction about a shell. zn env shadow removes the dependency on that prediction for the files it covers: the file on disk holds markers such as __ZN_API_KEY__, and zn puts the real values back only in the commands you allow. If a future route slips past the guard, it finds markers.

The release also adds protection for private keys kept outside ~/.ssh, for example in a project: SSH key names, key stores (.p12, .pfx, .jks, .ppk), and .pem, .key or .p8 files that hold a private key. Certificates and public keys stay readable.

Each route has a regression test in 1.12.0, and they pass. We used the exact class names of the advisory and nothing beyond them in this article on purpose. If you find another route, write to security@usezn.com.

What would we tell someone writing an agent guard?

These are the lessons we took, ordered by how much they changed the design.

  1. Decide on what the command will do, not on what it says. Every expansion the shell performs before running is a place where text and meaning differ. List them, and for each one decide whether you resolve it or refuse it.
  2. Refuse what you cannot resolve. An unresolved variable in a path that could point at a protected file is not a reason to allow. It is a reason to ask the agent for a simpler command.
  3. Check all of the input. A guard that examines a prefix has a boundary, and the boundary is a place to put the attack. Put no limit on how many commands you check, or make exceeding the limit a refusal.
  4. Treat time as part of the policy. Find out how long the host agent waits for your hook, set your own budget well under it, and make a timeout mean no. A guard that fails open under load protects only when nobody is pushing.
  5. A file has several homes. Disk, index, history, backups, build caches. If the rule is about the contents, think about each place the contents can be read from.
  6. Add a layer that does not depend on the parser. Parsing the shell is hard and will keep having gaps. Making the secret absent from the file, as shadowing does, or redacting known formats on the way out, keeps working when the parser is wrong.
  7. Write the regression test for the class, not only for the example. The first test is for the command that was reported. The useful one asks what else shares its cause.

What should you do now?

  1. Update the packages:

    npm install -g zn-gate@1.12.0
    pip install -U zn-gate
  2. Reinstall the agent hooks:

    npx -y zn-gate@1.12.0 init

    If you use the Claude Code plugin, update it from the plugin directory once 1.12.0 is listed there.

  3. In every folder that has a .env file with values, shadow it:

    zn env shadow .env
  4. Check which agents have the hooks installed:

    zn-gate check

If you cannot update right now, step 3 covers the .env routes. The long-chain and timeout forms have no workaround in older versions.

What does this not cover?

  • A static check on shell text will always have limits. Some commands cannot be resolved without running them, and for those the choice is to refuse. That can refuse a harmless command, and the agent has to rephrase it.
  • The guard protects reads that go through the agent's shell hooks. It does not control what a process you started yourself does.
  • The output scrubber redacts known secret formats. A secret in a format it does not know can pass through it.
  • This article does not list every spelling of every form. That is deliberate while users update.
  • A guard on shell text is a guardrail, not a sandbox: it covers what the agent's normal tools do, and a determined process can find another way to read a file. An agent sandbox that cuts network access is a useful second layer.
  • Updating does not recall anything. A secret that reached the model's context before you updated stays there for that session, so if a value may have been printed earlier, rotate it.

Frequently asked questions

Which zn-gate versions could let an agent read a protected file indirectly?

zn-gate 1.5.0 to 1.11.0. The issue is GHSA-rccq-h3xc-j9qj, rated medium, and it is fixed in 1.12.0.

What do I need to do?

Update with npm install -g zn-gate@1.12.0 and pip install -U zn-gate, then run npx -y zn-gate@1.12.0 init to reinstall the hooks. In every folder with a .env file that holds values, run zn env shadow .env. Run zn-gate check to see which agents have the hooks installed.

Were files protected with zn env shadow exposed?

Not through the .env routes. A shadowed file holds only markers of the form __ZN_NAME__, so reading it by any route reveals no value. Other credential files, such as ~/.aws/credentials and ~/.ssh/, were exposed only through the long-chain and timeout forms.

Is there a workaround for versions before 1.12.0?

For the .env routes, run zn env shadow .env in every folder with a .env file that holds values. The long-chain and timeout forms have no workaround in older versions, so updating is the only fix for those.

Why does zn-gate refuse a command it cannot check in time instead of letting it run?

A guard that allows what it could not check gives an attacker a way to turn the guard off: make the command slow or long. Refusing costs one retry by the agent. Allowing costs the protection.

Where can I read the advisory?

GHSA-rccq-h3xc-j9qj on GitHub. The full proof of concept is not published while users update.


The fixes are in zn-gate 1.12.0 (MIT, local, no account), and the rest of the release is in what's new in zn-gate 1.12.0. For the basics of keeping a .env away from an agent, see how to stop Claude Code, Codex CLI, Cursor and OpenCode from reading your .env with one command. The code is on GitHub, and Install covers each agent.

Share