Stop Claude from editing unrelated files
Five layers that keep an agent inside the files you meant — the request, the CLAUDE.md rule, deny rules, a PreToolUse hook, and the git check that proves what actually changed.
An explanation. Nothing was measured for this one.

- Time
- 10 min
- Effort
- moderate
- Needs
- Claude Code · a git repository · jq for the hook example
You ask for one component to change. You get the component, plus a renamed util, plus a “while I was in there” refactor of a file you never mentioned.
There are five layers that reduce this, and they are not equally strong. Instructions lower the odds. Permission rules and hooks block specific operations. Only git tells you what actually happened. Use all five, and do not expect any of them to make the agent incapable of touching the wrong file.
The short version
| Layer | What it does | How strong |
|---|---|---|
| Scope the request | Names the files that are in play | Guidance only |
CLAUDE.md rule | Standing instruction, every session | Guidance only |
permissions.deny | Blocks the built-in file tools on a path | Enforced, with gaps |
PreToolUse hook | Inspects each call and can refuse it | Enforced, your logic |
git status / git diff | Shows what was really written | The only proof |
This page is an explanation. Nothing here was measured — there are no timings or success rates, because Benchivo did not run a comparison to produce them.
1. Scope the request
The cheapest fix is usually the prompt. Compare:
Fix the date formatting bug in the invoice component.
with:
Fix the date formatting bug in
src/invoice/InvoiceRow.tsx. Do not modify any other file. If a change is needed elsewhere, tell me instead of making it.
The second version does two things the first does not: it names the file, and it supplies a rule for the case that actually causes the sprawl — the moment the model decides a second file needs changing to finish the job.
That second clause matters more than the first. An agent that finds a genuine dependency will act on it unless told what else to do with that finding.
2. Put the rule in CLAUDE.md
Anything you retype every session belongs in CLAUDE.md. Vague versions do nothing:
Keep changes focused.
That is not a rule, it is a mood. Write something with an observable test and a defined escape hatch:
## Scope
- Change only files I name, or files I explicitly ask you to find.
- Never rename symbols in files outside the requested change.
- If the task cannot be completed without editing another file, stop and say which
file and why. Do not edit it.
- Do not reformat, reorganise imports, or "tidy" code you were not asked to change.
Its limit is the same as the prompt’s: it is an instruction, and instructions are followed most of the time, not always. Which is why the next two layers exist.
3. Deny rules in settings.json
Permission rules are real enforcement rather than persuasion. They live under
permissions in .claude/settings.json:
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"deny": [
"Edit(migrations/**)",
"Edit(infra/**)",
"Edit(**/*.lock)"
]
}
}
Three properties worth knowing:
- Deny beats allow. A broad deny rule blocks every matching call even when a narrower allow rule also matches, so a deny rule cannot carry exceptions.
ReadandEditrules use gitignore pattern syntax.*matches within one path segment,**matches across directories, and a bare filename matches at any depth —Edit(.env)andEdit(**/.env)are equivalent.- Shell redirection counts as a write. The target of
>,>>or2>is checked against yourEditrules, so a permitted Bash command cannot be used to write to a denied path.
Where deny rules stop working
This is the part most write-ups leave out, and Anthropic’s own documentation is
explicit about it: Read and Edit deny rules apply to Claude’s built-in file tools
and to file commands Claude Code recognises in Bash, such as cat, head, tail and
sed. They do not apply to arbitrary subprocesses that open files themselves — a
Python or Node script that writes a file is not covered.
So a deny rule protects a path from the agent’s editing tools. It does not make the path immutable. For enforcement at the OS level, that is what sandboxing is for.
Denying by exception also scales badly. Listing every directory you want protected
means the one you forget is the one that gets edited. If you want a positive boundary
— only src/, nothing else — a hook expresses it far better.
4. A PreToolUse hook as a hard boundary
A PreToolUse hook runs before a tool call and can refuse it. Unlike a deny list, it
lets you write the rule as an allowlist: everything outside this directory is blocked,
including paths you have not thought of.
Register it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/scope-guard.sh",
"timeout": 10
}
]
}
]
}
}
The matcher selects which tools the hook sees. The hook receives the call as JSON on
stdin, and for Edit and Write the target path is at tool_input.file_path.
.claude/hooks/scope-guard.sh:
#!/usr/bin/env bash
# Refuse any Edit or Write whose target is outside src/.
set -euo pipefail
payload=$(cat)
path=$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty')
# No path in this payload — nothing for us to judge.
[ -z "$path" ] && exit 0
case "$path" in
"$CLAUDE_PROJECT_DIR"/src/*) exit 0 ;;
*)
echo "scope-guard: $path is outside src/ and was not approved." >&2
exit 2
;;
esac
Make it executable with chmod +x. The mechanism is the exit code: 2 blocks the
call and sends stderr back to the model as the reason, 0 allows the normal
permission flow to continue, and any other non-zero code is a non-blocking error that
lets the action proceed.
There is also a JSON form, which is worth using when you want the refusal reason to be structured rather than a stderr line:
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Outside the approved scope (src/)."
}
}'
Its limit: the hook only sees the tools its matcher selects. A guard on Edit|Write
does not see a file written by a shell command, so pair it with the deny rules above
rather than treating it as the single line of defence.
5. Verify with git, because git is the only witness
Every layer above is a claim about what should have happened. Git tells you what did.
git status --short
git diff --name-only
If a file you did not name appears in that list, a layer failed, and you now know which one to tighten. Run this before you commit, not after — a scoped change that quietly carries three extra files is only a problem once it is in history.
For a running check during a long session:
git diff --name-only -- . ':!src'
Any output means something was written outside src/.
When it has already happened
Recovering is straightforward as long as you have not committed.
Discard the changes to files that should not have been touched, naming them explicitly:
git restore -- path/to/file-one path/to/file-two
Avoid a repository-wide restore unless you are certain every uncommitted change is disposable — that includes your own work in progress.
Untracked files that the agent created are not covered by git restore. Find them in
git status --short (the ?? entries) and delete only the ones you have confirmed
are unwanted.
If a file contains both your work and an unwanted edit, do not restore the whole file. Use the diff to pick the hunks apart:
git restore -p -- path/to/file
Then confirm you are back where you meant to be:
git status --short
git diff --name-only
A practical default
- Name the files in the request, and say what to do about dependencies.
- Put a scope rule with an escape hatch in
CLAUDE.md. - Deny the directories that must never change — migrations, infra, lockfiles.
- Add a
PreToolUseguard when a task needs a real boundary rather than a blocklist. - Treat
git statusandgit diffas the final authority before committing.
The distinction to keep hold of: instructions lower the chance of an unwanted edit, controls block specific operations, and git proves what actually changed. Use them together, and do not expect any single one to make the agent incapable of touching the wrong file.
FAQ
Do deny rules make a path truly read-only? No. They cover the built-in file tools and the file commands Claude Code recognises in Bash. A subprocess that opens the file itself is not covered — that needs OS-level sandboxing.
Should I use deny rules or a hook? Deny rules for a short list of paths that must never change. A hook when you want the inverse rule — everything outside one directory is refused — because a blocklist can only block what you remembered to list.
Why does the hook use exit code 2?
That is the documented blocking exit code for PreToolUse. It blocks regardless of
what the hook prints on stdout, and the stderr text is returned to the model as the
reason for the refusal.
Was any of this benchmarked?
No. This is a reference page: an explanation of documented mechanisms, with nothing
measured. Anything on Benchivo carrying numbers says where they came from.
Where this sits
An explanation, not a run. What Benchivo has actually measured is in the tests, and the line between the two is set out in the methodology. For measured MCP behaviour, see stdio vs HTTP latency.