AI Coding Scope Guard: Catch Unexpected File Edits Early
TL;DR: Run a NUL-delimited Git path check against an explicit allowlist and denylist before executing tests or staging an AI coding agent’s changes. Inspecting unstaged diffs, staged diffs, and untracked files together catches agents that silently weaken a test file, rewrite a lockfile, or drop temporary artifacts outside the task scope.
What you’ll be able to do after this guide:
- Query unstaged, staged, and untracked repository paths safely with NUL-delimited Git commands.
- Enforce an explicit per-task allowlist and denylist with a zero-dependency Python 3 script.
- Block test runs when an AI agent modifies protected test suites, CI workflows, or lockfiles without permission.
Honest take: Check which paths changed before you run the test suite; a green test means nothing if the agent edited the test assertion to make its own bug pass.
Why can a passing test suite still hide an out-of-scope agent edit?
An AI coding agent asked to fix a bug in src/pricing.py can make the test suite pass in two ways: by fixing src/pricing.py, or by weakening the assertion in tests/test_pricing.py. If your acceptance loop runs pytest or npm test immediately after the agent finishes without checking which files were touched, both outcomes return exit code 0.
This tutorial builds a deterministic file-scope check at the Git boundary. It requires only Git and Python 3’s standard library. It does not require a paid coding assistant subscription, a cloud sandbox, or a model API call, and it does not prevent an agent from writing to disk during its turn. Instead, it acts as a post-turn gate before test execution, review packet export, or commit staging.
| Inspection command | Repository state covered | What it misses if used alone |
|---|---|---|
git diff --name-only -z | Unstaged edits to tracked files | Staged changes (git add) and new untracked files |
git diff --cached --name-only -z | Staged index changes against HEAD | Unstaged working-tree edits and untracked files |
git ls-files --others --exclude-standard -z | Non-ignored untracked files | Modified or staged tracked files |
| Combined 3-query union | All tracked edits (staged or unstaged) plus untracked files | Ignored files (requires --ignored if auditing build outputs) |
As documented in git-diff and git-ls-files, passing -z separates paths with NUL bytes (\0) and disables path quoting. That avoids parsing failures when an agent creates a filename containing spaces or non-ASCII characters.
What did our local Git scope-guard fixture catch?
On November 6, 2026, we tested a local repository fixture using git version 2.47.3 and Python 3.13.5 on Linux. The repository contained three tracked files: src/pricing.py, tests/test_pricing.py, and .github/workflows/ci.yml. We set the task allowlist to src/*.py and the denylist to tests/**, .github/**, and *.lock.
In our first test case, we modified only src/pricing.py. The three-command Git union returned a single path, and the scope evaluator passed:
{"ok": true, "allowed": ["src/pricing.py"], "violations": []}
In our second test case, we simulated an agent that also weakened tests/test_pricing.py and left an untracked src/debug_dump.json file in the working tree. Running the exact same scope check returned two violations and marked the check as failed:
{
"ok": false,
"allowed": ["src/pricing.py"],
"violations": [
{"path": "src/debug_dump.json", "reason": "outside allowlist"},
{"path": "tests/test_pricing.py", "reason": "matched deny pattern"}
]
}
These results verify path collection and pattern evaluation on a synthetic Git repository. We did not benchmark any commercial model’s violation rate or measure token savings.
How do you list every path an agent touched without shell-quoting bugs?
Run three read-only Git queries from the repository root and inspect both tracked and untracked state before running any build or test command:
git rev-parse --show-toplevel
git status --short --untracked-files=all
git diff --name-only -z
git diff --cached --name-only -z
git ls-files --others --exclude-standard -z
Using git-status with --untracked-files=all expands untracked directories into individual file paths for human inspection instead of collapsing a new directory into a single dir/ entry. For script consumption, the three -z commands are safer than parsing human status output because renamed files and quoted paths in text mode require state-machine parsing.
When an agent runs inside a shared repository, pre-existing dirty files will also appear in those lists. If you run two tasks concurrently, isolate each task in its own worktree first—see our Git worktrees two-task budget guide—so every modified path in the worktree belongs to the current task.
How do you enforce an allowlist and denylist in Python?
Save the following standard-library script as check_agent_scope.py. It collects the union of unstaged, staged, and untracked paths, normalizes each path with pathlib.PurePosixPath, checks denylist patterns before allowlist patterns using fnmatch.fnmatchcase, and exits nonzero on any violation.
import argparse
import fnmatch
import json
from pathlib import PurePosixPath
import subprocess
import sys
def normalize_repo_path(raw_path):
if not raw_path or "\x00" in raw_path or "\\" in raw_path:
raise ValueError(f"invalid path syntax: {raw_path!r}")
posix = PurePosixPath(raw_path)
if posix.is_absolute() or ".." in posix.parts:
raise ValueError(f"path escapes repository root: {raw_path!r}")
return posix.as_posix()
def collect_touched_paths(repo_dir):
commands = [
["git", "-C", repo_dir, "diff", "--name-only", "-z"],
["git", "-C", repo_dir, "diff", "--cached", "--name-only", "-z"],
["git", "-C", repo_dir, "ls-files", "--others", "--exclude-standard", "-z"],
]
touched = set()
for cmd in commands:
raw = subprocess.check_output(cmd)
for entry in raw.split(b"\x00"):
if entry:
text = entry.decode("utf-8", errors="surrogateescape")
touched.add(normalize_repo_path(text))
return sorted(touched)
def evaluate_scope(touched_paths, allow_patterns, deny_patterns):
allowed = []
violations = []
for path in touched_paths:
if any(fnmatch.fnmatchcase(path, pat) for pat in deny_patterns):
violations.append({"path": path, "reason": "matched deny pattern"})
elif any(fnmatch.fnmatchcase(path, pat) for pat in allow_patterns):
allowed.append(path)
else:
violations.append({"path": path, "reason": "outside allowlist"})
return {
"ok": len(violations) == 0 and len(allowed) > 0,
"allowed": allowed,
"violations": violations,
}
parser = argparse.ArgumentParser()
parser.add_argument("--repo", default=".")
parser.add_argument("--allow", action="append", required=True)
parser.add_argument("--deny", action="append", default=[])
args = parser.parse_args()
try:
paths = collect_touched_paths(args.repo)
report = evaluate_scope(paths, args.allow, args.deny)
except (subprocess.CalledProcessError, ValueError) as exc:
raise SystemExit(f"SCOPE_ERROR: {exc}")
print(json.dumps(report, indent=2))
if not report["ok"]:
sys.exit(1)
Run the guard from your terminal by passing --allow and --deny globs for the current task:
python3 check_agent_scope.py \
--repo . \
--allow 'src/*.py' \
--deny 'tests/**' \
--deny '.github/**' \
--deny '*.lock'
Why evaluate --deny before --allow? Suppose a broader task allows src/**, calculation helpers live in src/billing/, and you want to forbid changes to src/billing/rates.json. Checking deny_patterns first guarantees that an explicit exclusion always overrides a broad directory wildcard. Using fnmatchcase from Python’s fnmatch module also ensures case-sensitive matching even on macOS or Windows filesystems where fnmatch.fnmatch folds case by default.
Where does the scope guard fit in an AI coding test and review workflow?
Place check_agent_scope.py between the agent’s edit step and your acceptance test command. A three-stage verification pipeline keeps each failure mode separate:
- Scope gate (
check_agent_scope.py): Confirms that at least one allowed file changed and zero out-of-scope or denied files were touched. If you asked for an implementation fix only, puttests/**on the denylist so the agent cannot pass by deleting assertions. If the task explicitly includes adding a test, move the specific test file onto--allowand still deny.github/**,.env*, and dependency lockfiles. - Bounded test command: Run the project’s pinned test suite only after the scope gate returns exit code
0. Our project instructions and test-command budget guide shows how to reject empty test discovery (pytest exit code5) and cap retry loops. - Staged review packet: Once scope and tests pass, stage the explicit paths and export a fingerprinted patch for code review using our staged diff and untracked files review workflow.
| Failure caught by stage | Why running tests first is risky | What to do when triggered |
|---|---|---|
Agent edits tests/test_pricing.py during an implementation-only fix | Test suite passes because the assertion was weakened | Inspect git diff -- tests/, restore the test file, and re-run against the real test |
Agent modifies package-lock.json or pyproject.toml unprompted | Test execution may invoke newly added scripts or unexpected dependencies | Reject the run before npm test or pytest executes |
Agent writes .github/workflows/ci.yml or git hook configs | CI or local hooks can execute altered commands on commit | Block staging and review the diff manually |
Agent produces zero file changes (allowed: []) | Running the full test suite wastes time on an unchanged tree | Fail fast (ok: false) before launching tests |
If your coding agent uses a local Ollama backend and fails to invoke its file-edit tool at all because of argument serialization errors, see our sister site’s Ollama tool-call string vs object argument fix. If you route local coding requests through a self-hosted proxy, see AIFoss’s LiteLLM and Ollama local proxy guide.
What are the boundaries of a post-turn Git scope check?
Be clear about what a Git path check can and cannot prove:
- It is a post-edit check, not an OS sandbox. A script that runs after the agent finishes cannot stop an agent with shell access from reading or modifying files outside the repository during its run, or from reverting a file before exiting. Use container or VM isolation when running untrusted code.
- Ignored files are hidden by
--exclude-standard. If an agent writes to a path matched by.gitignore(such asdist/or.env.local),git ls-files --others --exclude-standardomits it by design. Keep sensitive files out of the workspace or add a separate--ignoredaudit when needed. fnmatchcasematches whole path strings. In Python’sfnmatch,*matches/characters as well as filename characters. Write explicit path prefixes such assrc/pricing.pywhen you want to restrict edits to a single file rather than a subtree.
Honest take: lock the file list before trusting the test result
Giving an AI coding agent a test command without a file-scope check invites the easiest shortcut in software: changing the test or config until the command exits zero. Defining --allow and --deny takes ten seconds per task, runs in milliseconds via subprocess and git, and ensures that your test runner evaluates the code you actually asked the agent to change.
FAQ
Why check unstaged, staged, and untracked files together?
Some AI coding tools edit files in the working tree without staging them, while others run git add automatically or create new untracked files. Taking the union of git diff --name-only -z, git diff --cached --name-only -z, and git ls-files --others --exclude-standard -z catches all three categories.
Should tests/** be on the allowlist or the denylist?
Put tests/** on the denylist when you want the agent to fix production code against an existing regression test; that prevents the agent from editing the test to pass. When you ask the agent to write or update a test, allow the specific test file path explicitly and review the test diff first.
Why use -z in Git diff and ls-files commands?
By default, Git quotes filenames containing spaces, tabs, or non-ASCII bytes and separates entries with newlines. Passing -z outputs raw paths separated by NUL bytes (\0), which Python can split deterministically without unquoting logic.
Sources
git-diffdocumentation (--name-only,--cached,-z) — Git Referencegit-ls-filesdocumentation (--others,--exclude-standard,-z) — Git Referencegit-statusdocumentation (--short,--untracked-files) — Git Referencepathlib.PurePosixPathpath manipulation — Python 3 Documentationfnmatch.fnmatchcasefilename pattern matching — Python 3 Documentationsubprocess.check_outputprocess execution — Python 3 Documentation
Last updated November 6, 2026. Official Git and Python documentation verified and local repository fixtures executed with Git 2.47.3 and Python 3.13.5 on this date; no paid agent service or model benchmark was run.
Was this article helpful?
Thanks for the feedback — it helps improve future articles.
Need hands-on help?
I offer 1-on-1 technical consulting for local AI setup, GPU selection, and AI coding tool configuration — same topics covered on this site.
Book a session — $49 / hour →Know which coding tool is worth paying for
Hands-on comparisons of AI coding assistants and what each one costs to run — including the local-model path. Sent only when something changes. Unsubscribe anytime.