DEV Community

Sam Rivera
Sam Rivera

Posted on

Build a Path-Scope Ledger Before a One-File AI Edit

I want a one-file title fixer to stay inside one file. A model draft can still offer a second path beside it. Would I catch that slip on a tired weeknight?

I doubt it, so the gate has to catch the slip. The gate is a tiny path-scope ledger for one edit. It names every file the job is allowed to touch.

It refuses the rest and writes evidence only after a pass. A friendly diff is how small tools grow sharp edges. Scope creep is the bug I am trying to make loud.

Vibe-coded patches fail the same way, by touching an unnamed file. The model got faster, but the apply step did not get safer. A ledger is the small brake I want before that speed lands.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. I use MonkeyCode for two narrow jobs in this workflow. Free model access drafts the one-file patch as text only.

A free server runs the gate away from my laptop repo. I do not treat either option as a permanent promise. I also do not assume a named model or a fixed quota.

Check the current product docs before you depend on either. Those pages should beat any number you remember from a post.

What this ledger actually guards

This ledger is not a substitute for human review. It answers one blunt question before any apply command. Which exact paths may change during this single job?

A fuzzy answer means the job stops immediately. A clean diff with a wide path is still a failure. Missing evidence is a failure, not a soft warning.

Step 1: Freeze the allowed path

Pick one file and write it down before the model speaks. That file becomes the whole contract for tonight's edit. If you need a second file, start a second job.

mkdir -p evidence fixtures
printf '%s\n' 'src/titles.py' > evidence/allowlist.txt
wc -l evidence/allowlist.txt
Enter fullscreen mode Exit fullscreen mode

Why freeze one file instead of a whole directory? A second file hides scope creep inside a friendly diff. Can you explain both edits in one honest sentence?

If the answer takes a paragraph, the job is already two jobs. Split them before you ask the model for help. A wide prompt invites a wide patch you did not request.

Step 2: Capture the proposal, not the apply

Ask the model for a unified diff and save that file. Do not apply it, commit it, or stage it yet. The saved diff is a proposal, not a permission slip.

test -s proposal.diff
git apply --check --verbose proposal.diff
Enter fullscreen mode Exit fullscreen mode

The check command only validates that the patch can apply. It does not prove the path stays inside your allowlist. That proof belongs to the ledger in the next step.

Keep the model prompt boring on purpose for this job. Name the allowed file in the prompt text. Tell it to refuse every other path in the reply.

Step 3: Run the scope ledger

Save the script below as scope_ledger.py in the repo. It is a complete gate you can copy and run tonight. Treat it as a workflow artifact, not a measured outage story.

#!/usr/bin/env python3
"""Fail closed unless a proposed diff stays on the allowlist."""

import argparse
import json
import re
import sys
from pathlib import Path

SECRET = re.compile(
    r"(AKIA[0-9A-Z]{16}|-----BEGIN |api[_-]?key\s*[:=])",
    re.I,
)
DIFF_PATH = re.compile(r"^\+\+\+ b/(.+)$", re.M)


def load_allow(path: Path) -> set[str]:
    lines = path.read_text(encoding="utf-8").splitlines()
    return {line.strip() for line in lines if line.strip()}


def judge(allow: set[str], diff: str, max_files: int) -> dict:
    paths = DIFF_PATH.findall(diff)
    unique = sorted(set(paths))
    extra = sorted(set(paths) - allow)
    reasons: list[str] = []
    if not diff.strip():
        reasons.append("empty-diff")
    if not unique:
        reasons.append("no-target-path")
    if extra:
        reasons.append("path-outside-allowlist:" + ",".join(extra))
    if len(unique) > max_files:
        reasons.append("file-count-over-budget")
    if SECRET.search(diff):
        reasons.append("secret-pattern")
    return {
        "pass": not reasons,
        "allow": sorted(allow),
        "paths": unique,
        "reasons": reasons,
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="Path-scope ledger")
    parser.add_argument("--allow", required=True)
    parser.add_argument("--diff", required=True)
    parser.add_argument("--out", required=True)
    parser.add_argument("--max-files", type=int, default=1)
    args = parser.parse_args()
    card = judge(
        load_allow(Path(args.allow)),
        Path(args.diff).read_text(encoding="utf-8"),
        args.max_files,
    )
    out = Path(args.out)
    out.parent.mkdir(parents=True, exist_ok=True)
    target = out if card["pass"] else out.with_suffix(".fail.json")
    if not card["pass"] and out.exists():
        out.unlink()
    target.write_text(json.dumps(card, indent=2) + "\n", encoding="utf-8")
    print(json.dumps(card))
    return 0 if card["pass"] else 2


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

The script targets Python 3.9 or newer on purpose. Older interpreters will choke on the builtin type hints. Install nothing else, because the standard library is enough.

Run the ledger against the proposal before you touch git. Exit zero means the pass card was written to disk. Exit two means stop, because the pass card must not remain.

python3 scope_ledger.py \
  --allow evidence/allowlist.txt \
  --diff proposal.diff \
  --out evidence/scope-card.json
echo "exit=$?"
test -f evidence/scope-card.json
Enter fullscreen mode Exit fullscreen mode

Would you let an apply script ignore that exit code? I would not, even when the diff is only a few lines. Wire the next command so a missing card aborts the shell.

Step 4: Keep a failure fixture

A gate without a red test is only a wish. Store a bad diff that touches a second path on purpose. The fixture also plants a fake key string so the sniff fires.

cat > fixtures/two-files.diff << 'EOF'
--- a/src/titles.py
+++ b/src/titles.py
@@ -1 +1 @@
-old title
+new title
--- a/src/notes.py
+++ b/src/notes.py
@@ -1 +1 @@
-note
+api_key=EXAMPLE_DO_NOT_USE
EOF
python3 scope_ledger.py \
  --allow evidence/allowlist.txt \
  --diff fixtures/two-files.diff \
  --out evidence/scope-card.json
status=$?
echo "exit=${status}"
test "${status}" -eq 2
test -f evidence/scope-card.fail.json
test ! -f evidence/scope-card.json
Enter fullscreen mode Exit fullscreen mode

This block is the expected fixture print, not a captured server log. Sort order on paths is alphabetical, so notes come first. If your print differs, fix the script before you trust a pass.

{"pass": false, "allow": ["src/titles.py"], "paths": ["src/notes.py", "src/titles.py"], "reasons": ["path-outside-allowlist:src/notes.py", "file-count-over-budget", "secret-pattern"]}
exit=2
Enter fullscreen mode Exit fullscreen mode

You want exit code two from that fixture run. You also want the fail json file and no pass card. If this fixture passes, the ledger is lying to you.

Do not delete the fixture after a green demo. The red file is the regression lock for this script. Run it again after every edit to the script.

Step 5: Apply only behind the card

Read the pass card before any apply command runs. Reject the job when the pass field is missing or false. Then apply with the index flag and list the cached names.

test -f evidence/scope-card.json
python3 - << 'PY'
import json, sys
card = json.load(open("evidence/scope-card.json", encoding="utf-8"))
sys.exit(0 if card.get("pass") is True else 3)
PY
git apply --index proposal.diff
git diff --cached --name-only > evidence/staged.txt
Enter fullscreen mode Exit fullscreen mode

Stop again if those cached names exceed the allowlist. The ledger already checked the diff text for path scope. This second look catches a sloppy apply that drifted anyway.

python3 - << 'PY'
import sys
allow = {ln.strip() for ln in open("evidence/allowlist.txt") if ln.strip()}
staged = {ln.strip() for ln in open("evidence/staged.txt") if ln.strip()}
sys.exit(0 if staged and staged <= allow else 4)
PY
Enter fullscreen mode Exit fullscreen mode

Fail-closed rules you can copy

Copy these gates into the repo notes beside the script. Each row names the evidence file and the stop condition. A missing row is a hole, not a future improvement.

Gate Evidence Fail closed when
Path freeze evidence/allowlist.txt Missing, empty, or more than one path
Proposal saved proposal.diff Missing, empty, or git apply --check fails
Scope match evidence/scope-card.json Any path outside the allowlist
File budget card field paths More than one file in the diff
Secret sniff card field reasons Pattern hit, even on a placeholder
Apply lock exit code 0 Card missing, pass false, or staged name drifts

Three abandon rules sit next to that small table. Stop if the allowlist grows past a single directory. Stop if the model cannot emit a unified diff tonight.

Also stop if the free server cannot run the script. Waiting one night is cheaper than a surprise commit. Abandon the job rather than widening the path to proceed.

A crash is also a stop, because the shell sees a non-zero exit. Do not catch that error and print a friendly success line. Friendly success on a traceback is how quiet failures ship.

Where the free server fits

I do not run unreviewed model output on my daily laptop. The free server is only a canary host for this gate. Copy the script, the allowlist, and the diff over there.

Run the ledger on that host and copy back the card. Leave the patch file behind if the card says fail. Free model access drafts text and never earns apply rights.

python3 scope_ledger.py \
  --allow evidence/allowlist.txt \
  --diff proposal.diff \
  --out evidence/scope-card.json
Enter fullscreen mode Exit fullscreen mode

If the server is down, the job waits until it returns. I will not quote a token cap, a hardware size, or an uptime promise. Those facts change, so read the current MonkeyCode docs before you plan around them.

If the free path is gone, keep the ledger anyway. Run it locally on a scratch clone with the same fixture. The gate matters more than the host that executes it.

Time box and rollback

Budget thirty minutes to add the script and the fixture. Budget ten more minutes for one dry-run on a scratch branch. If setup passes forty minutes, drop the idea for today.

Rollback here is deletion, not a clever migration plan. Remove the proposal diff and leave the branch unmerged. Delete a pass card if you never applied the patch.

Nothing in this workflow should touch the main branch. If main moved, you already skipped a gate somewhere. Reset the scratch branch and start from the allowlist again.

Who should skip this

Skip this if you need a multi-service release tonight. Skip it if counsel expects a formal control framework. Skip it when the edit renames symbols across many files.

The ledger will scream on a wide rename, and it should. Also skip it if you cannot read a unified diff yet. Learn the apply check before you trust any card output.

Solo builders with one file and a scratch branch are the fit. Small teams that already review every hunk can still use the red fixture. Everyone else should keep a human diff review and stop here.

Limits I am not hiding

The secret check is a blunt regular expression, nothing more. It misses real leaks and can false-alarm on documentation. Treat every hit as a stop, then read the hunk yourself.

The allowlist does not judge behavior inside the allowed file. A one-file diff can still delete a function you need. Read the hunk, because the card only proves path scope.

A stale fail file can sit beside a new pass card. Delete both cards before each run if that mix confuses you. The exit code is the real lock, not the leftover filename.

I have not published timing numbers for this small script. It is a few dozen lines, so time it on your own machine. Do not treat a local sketch as your benchmark result.

MonkeyCode is one way to get a free draft and a free server run. It is not required for the ledger to stay useful. Any local model plus a scratch clone can feed the same gate.

If a free MonkeyCode server is already on your account, start there. Point the red fixture at that host before any green run. A red run is the only demo that should impress you.

What single path would you freeze before the next model edit? Which second path keeps sneaking into your generated diffs?

Top comments (0)