DEV Community

Taylor Lin
Taylor Lin

Posted on

Classify the Path Before You Keep the Diff

A billing service had a one-line tax-rounding ticket. The agent returned a green summary, a confident commit message, and fourteen changed files.

Three of those files were the actual fix. The rest were a lockfile bump, a rewritten workflow, and a .env.example that now contained a real token shape. The summary still said “done.”

That is the failure mode this article treats as the unit of work. Not model quality. Not prompt tone. Path class. If you cannot classify every path in an agent diff, you do not have a review. You have a story about a review.

The workflow below is a glossary, a four-branch tree, and a small classifier you can run on any git range. It is written for teams that already let coding agents propose diffs and now need a stop rule that does not depend on the agent’s last paragraph.

The Friday afternoon diff

Consider this scenario, labeled as a worked incident rather than a production claim.

Ticket: “Round VAT to two decimal places in billing/tax.py.” The agent ran for twelve minutes, printed a checklist, and opened a branch. git diff --stat showed billing/tax.py, billing/tax_test.py, poetry.lock, .github/workflows/ci.yml, README.md, and .env.example.

The tests for tax rounding passed. That fact hid the other classes. A passing oracle on one path does not license writes on five others. Treat the extra paths as a separate decision, or they will ride in on the same merge.

Glossary, used as filters

Use these terms as filters on git diff --name-only. Do not use them as vibes.

  1. Touch class. A label for one path in the diff: secret, generated, named, or drive_by. Every path gets exactly one class. If two classes compete, pick the stricter one.
  2. Freeze path. A path the agent may read but must not write. Lockfiles, generated clients, vendored trees, and secret analogs start frozen unless the ticket names them as the task.
  3. Secret path. Credentials, tokens, private keys, .env*, cloud credential files, and CI variable blocks that can hold live values. Classification is by location and shape, not by whether the agent “meant” to redact.
  4. Generated path. Lockfiles, *.pb.go, OpenAPI clients, dist/, and any file a build tool owns. Humans edit these only through the tool that owns them.
  5. Named path. A path the ticket, issue, or failing test already pointed at. Named is earned by the task record, not by the agent repeating the filename in its plan.
  6. Drive-by path. Any other write. README polish, CI “cleanup,” formatter storms, and extra helpers live here until you record an exception.
  7. Scratch host. A machine that is not the reviewer’s laptop. A free remote server is a scratch host. So is a CI runner. The reviewer cannot rely on editor muscle memory to notice a stray write.
  8. Scope receipt. The list of (path, touch_class, keep_or_revert) rows produced before anyone reads the patch hunks. Hunk review starts after the receipt, not before.

If a term is not in this list, do not invent a softer one mid-review. Softer names are how lockfiles sneak through.

Where a free remote box fits

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

A scratch host is useful when you want the agent off your laptop: no local model process, no leftover temp files in your working tree, and a clean clone you can throw away. MonkeyCode’s free model access and free server option are one way to obtain that scratch host. They do not classify paths for you. The receipt still has to run against the resulting git range, on your side of the clone.

Do not treat “it ran remotely” as isolation. A remote write to .env is still a secret-path event. Remote only changes who was looking at the editor, not the keep/revert rule.

Decision tree

Run this tree once per path. Do not batch-justify the whole diff with a single “looks related.”

  1. Does the path match a secret analog (.env, id_rsa, *.pem, credential JSON, CI secret blocks)? Yes → Leaf A. Revert. Rotate if the scratch host saw a live value.
  2. Is the path generated, vendored, or a lockfile? Yes → Leaf B. Keep only if the ticket is a dependency change and the lockfile is the point of the task.
  3. Did the originating task record name this path (issue body, failing test, or an explicit allow file)? Yes → Leaf C. Keep if the existing tests can observe the behavior.
  4. Otherwise the path is drive-by. Leaf D. Revert, unless you write a one-line exception into the scope receipt before merge.

Stop at the first yes. Secret beats generated. Generated beats named. Named beats drive-by. That order is the whole policy.

Leaf A — secret path

Worked example. Ticket still says tax rounding. Diff includes .env.example with STRIPE_SECRET_KEY=sk_live_... and a GitHub Actions snippet that inlines AWS_SECRET_ACCESS_KEY.

git diff --name-only main...HEAD
# .env.example
# .github/workflows/ci.yml
# billing/tax.py
Enter fullscreen mode Exit fullscreen mode

Keep rule: revert both secret-class files even if billing/tax.py is correct. Then check whether the scratch host log retained the value. If it did, rotate. Do not argue about “example” in the filename. The class is about leak shape.

Command-level revert:

git checkout main -- .env.example .github/workflows/ci.yml
Enter fullscreen mode Exit fullscreen mode

Who trips this leaf: any agent that “completes” setup by inventing env files. The tax tests will not catch it.

Leaf B — generated or lockfile

Worked example. Same ticket. poetry.lock moves 40 entries. No issue text asked for a dependency bump. pyproject.toml is untouched, which is a useful contradiction: a lockfile rewrite without a manifest change is almost never the task.

git diff --stat main...HEAD -- poetry.lock pyproject.toml
Enter fullscreen mode Exit fullscreen mode

Keep rule: revert poetry.lock. The exception is narrow. If the ticket is “upgrade pydantic to v2” and pyproject.toml plus the lockfile both change, Leaf B may keep them. Tax rounding is not that ticket.

git checkout main -- poetry.lock
Enter fullscreen mode Exit fullscreen mode

If you need to confirm the lockfile was noise, restore it and re-run the named tests. They should still pass. If they fail, the agent coupled behavior to an undeclared dependency edit, which is a second defect, not a reason to keep the lockfile.

Leaf C — named path

Worked example. The issue names billing/tax.py. A failing test already exists: billing/tax_test.py::test_vat_rounds_half_up. The agent edits both files. The scope receipt marks them named.

Keep rule: keep if the named tests pass on the reduced diff, after Leaves A, B, and D have stripped the rest.

git diff main...HEAD -- billing/tax.py billing/tax_test.py
pytest billing/tax_test.py::test_vat_rounds_half_up -q
Enter fullscreen mode Exit fullscreen mode

Named is not “the agent mentioned the file.” Named is “the task record mentioned the file before the run.” If you let the plan document create names, the agent can name the entire repository.

Leaf D — drive-by path

Worked example. README.md gains a “VAT rounding” section. A new helper billing/utils_round.py appears. Neither was in the issue. Both are easy to rationalize after the fact. That is why the tree asks the question before hunk review.

Keep rule: revert by default. If a reviewer wants the helper, they add one receipt line: billing/utils_round.py drive_by keep: extracts rounding used by tax.py. No line, no keep.

git checkout main -- README.md
# helper: either checkout or record the exception, not both
Enter fullscreen mode Exit fullscreen mode

Drive-by documentation is not free. It ages against a behavior the next agent will treat as spec.

Artifact: classify the range before you read hunks

The script below is a worked example. It reads git diff --name-only, applies the four classes, and prints a scope receipt. It is not a sandbox. It does not block network calls. It only makes silent path classes loud.

Save as classify_touch.py:

#!/usr/bin/env python3
"""Classify paths in an agent diff. Worked example, not a sandbox."""

from __future__ import annotations

import argparse
import fnmatch
import subprocess
import sys
from pathlib import Path

SECRET_GLOBS = [
    ".env",
    ".env.*",
    "**/.env",
    "**/.env.*",
    "**/id_rsa",
    "**/*.pem",
    "**/*credentials*.json",
    "**/*secret*",
]
GENERATED_GLOBS = [
    "**/poetry.lock",
    "**/package-lock.json",
    "**/yarn.lock",
    "**/pnpm-lock.yaml",
    "**/go.sum",
    "**/Cargo.lock",
    "**/dist/**",
    "**/*.pb.go",
    "**/generated/**",
    "**/vendor/**",
]
NAMED_FILE = Path(".agent-named-paths")


def git_names(base: str) -> list[str]:
    out = subprocess.check_output(
        ["git", "diff", "--name-only", f"{base}...HEAD"],
        text=True,
    )
    return [line.strip() for line in out.splitlines() if line.strip()]


def load_named() -> set[str]:
    if not NAMED_FILE.exists():
        return set()
    return {
        line.strip()
        for line in NAMED_FILE.read_text().splitlines()
        if line.strip() and not line.startswith("#")
    }


def matches(path: str, globs: list[str]) -> bool:
    return any(fnmatch.fnmatch(path, pat) for pat in globs)


def classify(path: str, named: set[str]) -> str:
    if matches(path, SECRET_GLOBS):
        return "secret"
    if matches(path, GENERATED_GLOBS):
        return "generated"
    if path in named:
        return "named"
    return "drive_by"


KEEP_DEFAULT = {"named": "keep", "secret": "revert",
                "generated": "revert", "drive_by": "revert"}


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--base", default="main")
    args = parser.parse_args()
    named = load_named()
    rows = []
    for path in git_names(args.base):
        klass = classify(path, named)
        rows.append((path, klass, KEEP_DEFAULT[klass]))

    if not rows:
        print("no paths in range")
        return 0

    print(f"{'path':<40} {'class':<10} action")
    print("-" * 62)
    for path, klass, action in rows:
        print(f"{path:<40} {klass:<10} {action}")

    bad = [r for r in rows if r[2] == "revert"]
    return 2 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

Seed the named list from the ticket, not from the agent plan:

printf '%s\n' billing/tax.py billing/tax_test.py > .agent-named-paths
python3 classify_touch.py --base main
echo $?  # 2 means at least one revert class is present
Enter fullscreen mode Exit fullscreen mode

Numbered use in review:

  1. Write .agent-named-paths from the issue before the agent starts.
  2. Let the agent produce a branch on a laptop or a scratch host.
  3. Run classify_touch.py on the range. Read the receipt. Do not open hunks yet.
  4. git checkout every revert row, re-run the named tests, then review what remains.

Exit code 2 is the useful one. It means the classifier disagrees with “all green.” Wire it as a required check if you already merge from agent branches. If you do not want a check yet, run it locally and paste the receipt into the PR body so drive-by files cannot hide under a summary.

What this does not do

The classifier does not replace a sandbox. A process that can run curl can still exfiltrate a secret before git diff exists. It also does not parse patch hunks, so a named file can still grow an unrelated function. Hunk review remains mandatory on every keep.

Glob lists lie in both directions. *secret* will freeze a documentation file named secret_santa.md. A credentials file named prod-cfg.json will slip through unless you add it. Maintain the lists next to the repo, not in a chat log.

Free model access on a scratch host does not make generated files safer. It only makes it cheaper to obtain a diff you still have to classify. If your policy is “agents may not write at all,” skip this tree. You need an allowlist at the tool layer, not a receipt after the fact.

Who should not use this

Do not use path classification as your only control if you handle regulated data, production credentials, or customer imports. Those runs need an isolated VM, no secret mounts, and a network policy. This article is for application-code tickets where the realistic failure is extra files, not a determined attacker.

Do not use it to justify larger agent autonomy. The tree shrinks what you keep. If a team is already drowning in drive-by README churn, the receipt will look harsh. That is the point. Harsh receipts are cheaper than lockfile incidents that surface in the next deploy.

The tax ticket at the top becomes three files, then two, then the two the issue named. The summary can still say “done.” The receipt is allowed to disagree.

Top comments (0)