When you're sitting in front of an agent, "don't touch anything outside src/" is enforced by you noticing. Unattended, it has to be enforced by something that runs whether or not anyone is watching.
Claude Code gives you two mechanisms for that, and they are not interchangeable. One is declarative and can't express what you probably want. The other can, but is structurally blind to a whole category of writes. Here's what each one actually does, and the code for the second.
Why permissions.deny isn't enough
Permission rules live in settings.json and take the form Tool(specifier):
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Write(./.github/**)",
"Write(//etc/**)"
]
}
}
Paths are gitignore-style. A leading // means absolute, ~ means home, and anything else is relative to the settings file. deny beats ask, which beats allow, and rules merge across scopes rather than override — so a deny in project settings still applies even when your personal ~/.claude/settings.json allows the same thing. That precedence is the useful part: a deny rule is hard to undo by accident.
The problem is shape. What you want for an unattended agent is an allow-list — only these directories, nothing else. What deny gives you is a block-list, and you cannot build the first out of the second. The obvious trick of denying everything and allowing back the exceptions fails on exactly the precedence rule that makes deny valuable: Write(**) in deny outranks every allow you pair it with, so the agent can write nothing at all.
Claude Code does have one allow-list-shaped boundary — the project root, plus whatever you list in additionalDirectories. That stops an agent wandering into /etc. It says nothing about which directories inside your project it may write, which is usually the interesting question. Nobody's real worry is that a scheduled agent edits /etc/hosts. It's that the agent tasked with writing articles decides to fix its own scheduling config.
So for anything finer than "stay in the project", you need code.
A PreToolUse hook that refuses the write
A PreToolUse hook is a command Claude Code runs before a tool call, handing it the pending call as JSON on stdin. The hook answers, and the answer is binding.
Register it against the tools that write files:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PROJECT_DIR}/.claude/hooks/deny-outside-scope.mjs"
}
]
}
]
}
}
The payload arriving on stdin carries tool_name, tool_input, and cwd. For Write and Edit, tool_input.file_path is the file about to be touched. For Bash, there's tool_input.command and no path at all — remember that, it matters later.
You reply by exiting 0 and printing JSON on stdout:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "…"
}
}
permissionDecision is allow, deny, ask, or defer. defer means "no opinion, carry on with the normal permission flow", and it's the right default for a guard: a hook that returns allow is overriding the user's own permission rules, which is not a scope guard's job. The permissionDecisionReason goes to the model, so it's worth writing as an instruction rather than an error code.
There's a second way to block — exit code 2, with the reason on stderr. It works, but you lose the structured field, and on exit 0 stderr goes only to the debug log where neither you nor the model will see it. Prefer the JSON.
The whole guard:
import { isAbsolute, relative, resolve } from "node:path";
const FILE_WRITING_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
function contains(root, target) {
const rel = relative(resolve(root), resolve(target));
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
export function decideWrite(payload, { allow, root }) {
if (!FILE_WRITING_TOOLS.has(payload?.tool_name)) return { decision: "defer" };
const filePath = payload?.tool_input?.file_path;
if (typeof filePath !== "string" || filePath === "") return { decision: "defer" };
const base = root ?? payload?.cwd ?? process.cwd();
const target = isAbsolute(filePath) ? filePath : resolve(base, filePath);
const roots = allow.map((entry) => (isAbsolute(entry) ? entry : resolve(base, entry)));
if (roots.some((allowed) => contains(allowed, target))) return { decision: "defer" };
return {
decision: "deny",
reason:
`${payload.tool_name} to ${filePath} is outside this agent's write scope. ` +
`Allowed: ${allow.join(", ")}. If this file genuinely needs changing, ` +
`say so and stop — do not work around the guard.`,
};
}
Three things in there are load-bearing.
contains uses path.relative, not a string prefix. "/app/src-secret".startsWith("/app/src") is true, and that is how an allow-list quietly stops being one. Resolving both sides and asking whether the relative path escapes with .. is the version that survives sibling directories, ./ noise, and traversal in the incoming path.
Everything unrecognised defers rather than denies. A malformed payload isn't a permission decision. Deferring hands it back to the normal flow, which will fail on its own terms and tell you why.
The reason is addressed to the model. "Denied" invites a retry through a different tool. Naming the allowed roots and saying explicitly not to work around the guard gives it somewhere to go that isn't a workaround.
Wrap it in a script that reads stdin and never throws:
export async function runHook({ allow, stdin = process.stdin, stdout = process.stdout }) {
let payload;
try {
const chunks = [];
for await (const chunk of stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
return 0; // fail open — see below
}
const verdict = decideWrite(payload, { allow });
if (verdict.decision === "deny") stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: verdict.reason,
},
}));
return 0;
}
Failing open is a real decision and you should make it deliberately. A hook that throws on a bad payload blocks every write in the session the moment you typo the allow-list, and "the agent can't work" is a worse default failure than "one check didn't run" — provided something else catches what gets through. That's the next section.
The best thing about this being a plain script is that you can test it without an agent anywhere near it:
echo '{"tool_name":"Write","tool_input":{"file_path":"/etc/hosts"},"cwd":"'"$PWD"'"}' | node .claude/hooks/deny-outside-scope.mjs
Output means denied. Silence means allowed. Do that once before you trust it — a guard nobody has seen refuse anything is a guard you're assuming works.
The writes a hook will never see
A PreToolUse hook fires on tool calls. That's the boundary, and it leaks in more places than it first appears:
-
Bashgets a command string, not a path.sed -i,> file,cp,mv,git checkout,npm run build— a hook matched onWrite|Editnever fires, and one matched onBashwould have to parse arbitrary shell to find the writes. Don't try; you'll lose. - Subagents and scripts the agent starts do their own file I/O outside the parent's tool loop.
- Generated output — build artefacts, lockfiles, formatter passes — lands in the tree without any tool call naming it.
Which is why the second half of this is a check on the result rather than the request. Before anything gets committed, diff the working tree against the same allow-list:
import { execFileSync } from "node:child_process";
const changed = execFileSync("git", ["status", "--porcelain=v1"], { encoding: "utf8" })
.split("\n")
.filter((line) => line.length > 3)
.map((line) => line.slice(3).trim()); // slice BEFORE trim: ` M path` has a leading space
const violations = changed.filter((p) => !ALLOWED.some((prefix) => p.startsWith(prefix)));
It doesn't care what produced the change. Shell redirect, subagent, build step — if it landed in the tree, it's in git status, and this catches it.
The two guards are complements, and each covers the other's failure. The hook stops the write and gives the model a reason it can act on, but only for calls it was matched against. The diff sees everything but only after the fact. Run the hook so the mistake mostly doesn't happen; run the diff so you find out when it did anyway.
What this is not
It isn't a sandbox. Both guards live inside the agent's own harness — the hook is invoked by Claude Code, the diff by your own script. That's a good defence against mistakes and against instructions the agent picked up from a file it read. It is not a boundary against an attacker with shell access on the same machine, and treating it as one is how people end up surprised. If you need a real boundary, that's a container or an OS-level sandbox, and it's a different piece of work.
What you get for these ~60 lines is narrower and still worth having: an unattended agent that can't quietly rewrite its own configuration, and a check at commit time that tells you when something got through anyway.
Originally published at fewparts.co.uk.
I write about running agents unattended, and sell the packaged version of this code — Agent Guardrails Kit, £22.00. Saying so up front because you'd work it out in one click anyway.
Top comments (0)