1. The file at the root
There is a file at the root of the repo called .agentignore, and it is the entire policy. It has two kinds of lines: paths the agent should not touch, and patterns that should never come back in a tool result. The hook reads it on every call.
This started as an experiment. It is not a security control, and it is not something to rely on to keep a secret safe. I wanted to see whether a small policy file and a small hook could make an agent's boundaries legible, and what that would cost. The four limits below are part of the answer.
The path rules are gitignore-style, because anyone who works in a repo already knows the shape. private/ is a directory. secrets/api-key.txt is a file. .env is a name that can turn up at any depth. The second kind is a scrub: line, a regular expression matched against the text a tool returns. One file, two rule kinds, and a human can read the policy without opening the hook.
secrets/api-key.txt
private/
.env
scrub: \bsk_(live|test)_[A-Za-z0-9]{16,}\b
A coding agent will read whatever its tools can reach. Most of the time that is the point, but then it reads the .env file, or a scratch directory, or a key you pasted into a note, and you realize the repo has no line between what the agent should see and what it can see. You can put a line in the prompt, but the prompt is per-conversation. The repo is the thing that persists.
The reason I built it this way was to avoid adding a hook every time I found something else the agent should avoid, which was getting old fast. I wanted one write-once hook and a convention developers already knew, so a new rule would not mean changing the hook. That is why the policy is a file. It lives where the work lives, and it survives the conversation.
2. Before and after
A hook is a program the harness runs around a tool call. It sees the call, and it returns a decision. The harness runs one before the tool and one after.
Before the tool runs, the hook can deny the call. After it returns, it can replace the result. A path check only needs the first, but the second is where secrets leak, because a file can be read by a tool that was not supposed to read it or a command can print something it should not. The result is the last place to catch it before the model sees it.
Claude Code can replace a tool result before the model sees it with updatedToolOutput, but the replacement has to match the tool's output shape, and that is the constraint that decides the design. A scrub: match replaces the whole result with a placeholder. It does not try to redact one span inside an arbitrary output, because the hook would need to know every tool's shape, and a replacement that does not match is ignored. The whole-result swap is coarse, but it is the version that works for every tool. The cost is real. A broad pattern can throw away a useful answer, which is why the patterns should be narrow. The alternative is to let the secret through, which is worse.
3. The policy protects itself
The policy file, the hook, and the harness settings are always denied by name. That stops the obvious edit. The agent cannot walk up to the leash and take it off.
That sounds like a small detail until you watch an agent hit a wall and look for the wall's source. If the policy file is editable, the guardrail is advisory, and if the hook is editable, the next tool call can remove the check. The self-protection line is what makes the rest of the policy mean anything - in theory.
The policy is not hidden - a listing can show that .agentignore exists, and one of the limits is that the agent can learn a protected directory exists without entering it. The contents are a different matter. The hook denies reads and writes to the policy surface, so the agent can know the leash is there but cannot take it off by name.
4. What the hook does
For a file tool, the hook takes the path argument. For a search tool, it takes the pattern and the path. For everything else, it stringifies the input and pulls out path-shaped strings. Each candidate is resolved against the repo root and checked against the rules, and if any candidate lands inside a protected path, the call is denied.
Bash is the exception - the hook does not parse shell. It checks whether the command string contains a literal protected path, and denies the call if it does. Otherwise it allows it, which is the whole rule.
The limit is deliberate. A partial shell parser is a trap. It looks like it understands cat, xargs, pipes and command substitution, and then it is wrong in a way you cannot see. A literal check is small enough to explain, and its hole is visible. With secrets/api-key.txt in the policy, cat secrets/* gets through because the command never names the file. I would rather have that hole in the open than a parser that is confident and wrong.
I want to be plain about what this is - an experiment in shape, not a security product. It will not stop an agent that is trying, and it will not catch every path. If you need protection, use a sandbox, a separate checkout, or a host-level control. The value here is the policy format and the two moments.
After the tool returns, the hook joins the result text and tests each scrub: pattern case-insensitively. On the first match it returns updatedToolOutput, a copy of the result with every content string replaced by a placeholder, keeping the shape so the host accepts it. No partial redaction - the hook does not know the shape of every tool's output, and a replacement that does not match is ignored.
The smoke test is three lines.
mkdir private
echo hello > private/notes.txt
# ask the agent to read private/notes.txt
The call is denied. That is the whole demonstration, and it fits in three lines.
5. What it gives up
The hook is a few hundred lines and one policy file. That is what happens when the claim is small enough to state. The claim is narrow. This is for an honestly acting agent that stumbles into the wrong file, and it stops a known secret shape from coming back in a tool result. A sandbox, or an attacker, is a different problem.
The four limits follow from that claim.
Shell globs and runtime paths get through. The hook checks the command string, not the shell's expansion. cat secrets/* passes when the rule is a file, and a path assembled at runtime passes for the same reason. The hook is not a shell parser, and it will not become one.
Indirection gets through. Base64, a variable, a command substitution - the hook sees the string, not the path it becomes.
Hardlinks and symlinks get through. The hook matches names, not file identities. An inode map would catch a hardlink under an innocent name, and resolving the real path would catch a symlink. Both are adversarial defenses, and the map would mean a filesystem walk on every tool call. Neither is here, so a link that already exists is just another path.
[§5]Listings reveal existence. ls can show that private/ exists, but the hook denies entering the path, not knowing it. Hiding the name would mean scanning every result for policy text, which is a different tool.
All four are consistent with the threat model. A curious agent stumbles into a path, while an attacker goes looking for the seam. The line between them is where the code stops.
The test suite treats the four limits as expected failures. It asserts the secure behavior, catches the failure, prints the miss, and keeps going, so the suite exits zero while the holes are still there. If one starts passing, it exits non-zero and tells me to update the README. The suite shows the hole, so you do not have to take my word for it.
6. The seam
The right place for this is the host. If the harness knew which paths were off-limits, it could enforce that without a hook per repo. Until then, a hook is the seam we have. The policy file is the part worth keeping, and the hook is the thing that enforces it in this harness this month. If the host grows a real ignore mechanism, the hook goes away and the policy comes with you.
The test of a guardrail is whether I can say exactly what it catches, and exactly what it misses. The four limits are not the fine print - they are the shape of the leash.