AI Coding Scope Guard: Catch Unexpected File Edits Early

AI Coding AgentsGitDeveloper WorkflowPythonTutorial

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 commandRepository state coveredWhat it misses if used alone
git diff --name-only -zUnstaged edits to tracked filesStaged changes (git add) and new untracked files
git diff --cached --name-only -zStaged index changes against HEADUnstaged working-tree edits and untracked files
git ls-files --others --exclude-standard -zNon-ignored untracked filesModified or staged tracked files
Combined 3-query unionAll tracked edits (staged or unstaged) plus untracked filesIgnored 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:

  1. 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, put tests/** 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 --allow and still deny .github/**, .env*, and dependency lockfiles.
  2. 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 code 5) and cap retry loops.
  3. 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 stageWhy running tests first is riskyWhat to do when triggered
Agent edits tests/test_pricing.py during an implementation-only fixTest suite passes because the assertion was weakenedInspect git diff -- tests/, restore the test file, and re-run against the real test
Agent modifies package-lock.json or pyproject.toml unpromptedTest execution may invoke newly added scripts or unexpected dependenciesReject the run before npm test or pytest executes
Agent writes .github/workflows/ci.yml or git hook configsCI or local hooks can execute altered commands on commitBlock staging and review the diff manually
Agent produces zero file changes (allowed: [])Running the full test suite wastes time on an unchanged treeFail 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 as dist/ or .env.local), git ls-files --others --exclude-standard omits it by design. Keep sensitive files out of the workspace or add a separate --ignored audit when needed.
  • fnmatchcase matches whole path strings. In Python’s fnmatch, * matches / characters as well as filename characters. Write explicit path prefixes such as src/pricing.py when 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

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?

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.