DevelopersReference

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.

Benchivo cover for the scope-control guide: the headline KEEP IT IN SCOPE over a teal block held inside a bracket while shapes outside stay untouched.
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

LayerWhat it doesHow strong
Scope the requestNames the files that are in playGuidance only
CLAUDE.md ruleStanding instruction, every sessionGuidance only
permissions.denyBlocks the built-in file tools on a pathEnforced, with gaps
PreToolUse hookInspects each call and can refuse itEnforced, your logic
git status / git diffShows what was really writtenThe 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.
  • Read and Edit rules use gitignore pattern syntax. * matches within one path segment, ** matches across directories, and a bare filename matches at any depth — Edit(.env) and Edit(**/.env) are equivalent.
  • Shell redirection counts as a write. The target of >, >> or 2> is checked against your Edit rules, 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

  1. Name the files in the request, and say what to do about dependencies.
  2. Put a scope rule with an escape hatch in CLAUDE.md.
  3. Deny the directories that must never change — migrations, infra, lockfiles.
  4. Add a PreToolUse guard when a task needs a real boundary rather than a blocklist.
  5. Treat git status and git diff as 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.

← All developers