Overreach

Asked for one fix, it changed unrelated files

只让修一处,它改了一堆无关文件

You asked for one change; the diff also "cleaned up" other functions, files and configs nobody mentioned.

Applies to: Claude Code · Cursor · Codex · Copilot agent

Symptom

A five-line fix arrives as a twelve-file diff. The real change is buried; review takes ten times longer; rollback drags unrelated edits with it. After a few rounds you no longer know what it changed.

Why it happens

From inside the task, tidying a neighbor feels like doing a good job, and a sentence like "only change what's necessary" is advisory context, not enforcement. Kaplira, Night Forge and the dev.to diff-budget post all reach the same split. Rules state intent; permissions and hooks block the tool; a scope-vs-diff check catches what slipped. agent-failure-modes calls it AFM-2 Scope Vandalism.

Example

From the dev.to diff-budget post.

text
Task: "fix the off-by-one in the pagination helper"
Agent also: renamed two variables "for clarity", converted a callback to async/await,
            "aligned" a similar pattern in a neighboring module, edited a linter config.

Copy-paste fixes

Pick the files for the agents you use. Snippets are in English so they work in any repo.

Rules

Claude Code: add to CLAUDE.md

CLAUDE.md
## Scope rules
- Only modify files the request needs. Reading other files is fine; editing them is not.
- Before the first edit, list the files you expect to change. If you then need another file,
  stop and explain why before editing it.
- No opportunistic refactors, renames, reformatting, comment rewrites or config changes.
- Put anything you noticed but didn't fix into FOLLOWUPS.md (file, issue, suggested task).
- Finish with `git diff --stat` and justify every changed file against the request.
Rules

Codex, Copilot coding agent and others: add to AGENTS.md

AGENTS.md
## Scope rules
- Only modify files the request needs. Reading other files is fine; editing them is not.
- Before the first edit, list the files you expect to change. If you then need another file,
  stop and explain why before editing it.
- No opportunistic refactors, renames, reformatting, comment rewrites or config changes.
- Put anything you noticed but didn't fix into FOLLOWUPS.md (file, issue, suggested task).
- Finish with `git diff --stat` and justify every changed file against the request.
Cursor rule

Cursor: project rule

.cursor/rules/unrelated-files-changed.mdc
---
description: "AgentFails: Asked for one fix, it changed unrelated files"
alwaysApply: true
---

## Scope rules
- Only modify files the request needs. Reading other files is fine; editing them is not.
- Before the first edit, list the files you expect to change. If you then need another file,
  stop and explain why before editing it.
- No opportunistic refactors, renames, reformatting, comment rewrites or config changes.
- Put anything you noticed but didn't fix into FOLLOWUPS.md (file, issue, suggested task).
- Finish with `git diff --stat` and justify every changed file against the request.
Hook

Claude Code PreToolUse hook: block edits outside the declared scope

Adapted from the diff-budget design on dev.to (yureki_lab). Put allowed globs, one per line, in .agent-scope at the repo root. Exit code 2 blocks the tool call and feeds stderr back to the agent. It only covers file-edit tools; shell writes still need the diff check.

.claude/hooks/scope_guard.py
#!/usr/bin/env python3
"""PreToolUse hook: block Edit/Write/MultiEdit outside the globs in .agent-scope."""
import fnmatch, json, os, sys

call = json.load(sys.stdin)
if call.get("tool_name") not in ("Edit", "Write", "MultiEdit"):
    sys.exit(0)

root = os.environ.get("CLAUDE_PROJECT_DIR") or call.get("cwd") or os.getcwd()
scope_file = os.path.join(root, ".agent-scope")
if not os.path.exists(scope_file):
    sys.exit(0)  # no scope declared for this task -> allow

allowed = [l.strip() for l in open(scope_file) if l.strip() and not l.startswith("#")]
target = os.path.relpath(call["tool_input"]["file_path"], root)

if any(fnmatch.fnmatch(target, pat) for pat in allowed):
    sys.exit(0)

print(
    f"BLOCKED: {target} is outside this task's scope ({', '.join(allowed)}).\n"
    "If this edit is required, stop and ask the user to widen .agent-scope, with a one-line reason.\n"
    "If it is just an improvement you noticed, add it to FOLLOWUPS.md and keep going.",
    file=sys.stderr,
)
sys.exit(2)
Settings

Register the scope guard in .claude/settings.json

.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          { "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/scope_guard.py" }
        ]
      }
    ]
  }
}
CI / script

Diff budget check (run before commit or in CI)

Adapted from the diff-budget post on dev.to (yureki_lab). Reads the real git diff, so it also catches edits made through the shell. Usage: python3 scripts/diff_budget.py origin/main 6 150

scripts/diff_budget.py
#!/usr/bin/env python3
"""Fail if the diff vs BASE touches files outside .agent-scope or exceeds the budget."""
import fnmatch, os, subprocess, sys

base = sys.argv[1] if len(sys.argv) > 1 else "HEAD"
max_files = int(sys.argv[2]) if len(sys.argv) > 2 else 6
max_lines = int(sys.argv[3]) if len(sys.argv) > 3 else 150

allowed = []
if os.path.exists(".agent-scope"):
    allowed = [l.strip() for l in open(".agent-scope") if l.strip() and not l.startswith("#")]

out = subprocess.run(["git", "diff", "--numstat", base], capture_output=True, text=True, check=True).stdout
untracked = subprocess.run(["git", "ls-files", "--others", "--exclude-standard"],
                           capture_output=True, text=True, check=True).stdout.split()

files, lines, outside = 0, 0, []
rows = [r.split("\t") for r in out.strip().splitlines()] + [["0", "0", p] for p in untracked]
for added, deleted, path in rows:
    files += 1
    if added != "-":
        lines += int(added) + int(deleted)
    if allowed and not any(fnmatch.fnmatch(path, p) for p in allowed):
        outside.append(path)

problems = []
if outside:
    problems.append("out-of-scope files: " + ", ".join(outside))
if files > max_files:
    problems.append(f"{files} files changed (budget {max_files})")
if lines > max_lines:
    problems.append(f"{lines} lines changed (budget {max_lines})")

if problems:
    print("OVER BUDGET: " + "; ".join(problems))
    sys.exit(1)
print(f"within budget: {files} files, {lines} lines")
Skill

Shared /verify skill for Claude Code

Commit it to the repo so the whole team (and every agent session) runs the same checks. Replace the commands with your project's real ones.

.claude/skills/verify/SKILL.md
---
name: verify
description: Verify the current change before calling it done. Use before every commit, PR, or "done" message.
---

# Verify

Run every step and paste the real output. Never summarize a step you did not run.

1. **Scope**: run `git diff --stat` and `git status --short`. For every changed file, say which part
   of the request needs it. Any file you cannot justify gets reverted, or listed under
   "Unrequested changes" for the user to decide.
2. **Build and types**: `npm run build` (or the project's build command).
3. **Tests**: `npm test`. Report pass/fail counts. If a test was edited, skipped or deleted,
   say so explicitly with the reason.
4. **Behavior**: exercise the changed behavior once for real (a curl, a CLI run, a screenshot)
   and show what happened.
5. **Report** in this format:
   - Requested: <one line>
   - Changed files: <list, each with its reason>
   - Checks run: <command -> result>
   - Not done / unsure: <list, or "none">

Sources and credit

Related patterns

翻车 of the week

One real agent failure and its fix, every week. Free, short, no spam.