DEV Community

Dakota Liu
Dakota Liu

Posted on

Case Study: An Allowlist Gate That Rejects an Agent Patch Before You Apply It

You should reject an agent-written patch before it touches your tree, unless a written allowlist and a named test command both pass. Free generation makes extra files cheap to propose, while a wrong file remains expensive to undo. This case study walks you through a tiny Python gate you can run on any disposable workspace. The gate is a review tool, not a claim that any model or host is safe.

What you are protecting

You are protecting a three-file health service, not a production platform and not a secret-bearing repository. The service exposes /health, and you want a second route named /ready with one focused test. An agent can draft that change quickly, yet it can also edit deploy files, lockfiles, or a hidden env sample. Your job is to keep the applied diff inside a card you wrote before the session started.

Background

Picture a small folder that already contains app.py, test_app.py, and README.md, with no database and no cloud credentials. You ask an assistant to add readiness behavior, and you expect a unified diff plus a short report. The report must name the exact test command you will run after the patch applies cleanly. If either artifact drifts outside the card, you stop and you do not apply the patch.

An agent draft is still an untrusted patch, even when the prose around the diff sounds finished and careful. You need a local rule that does not depend on how confident the report appears in the chat window. This exercise does not rank models, and it does not borrow a scoreboard from a public thread. You are freezing one small contract that names allowed paths, forbidden paths, and a test command.

Goal

The goal is a fail-closed checker that exits nonzero unless three conditions hold at the same time. The diff may touch only paths on the allowlist, and it must not touch any forbidden path. The agent report must contain the exact test command that you already wrote on the card. You treat missing fields as failure, because a silent default is how an unreviewed file lands in the tree.

The session card

You write the card before you prompt, and you store it beside the checker rather than inside the agent's scratch space. You keep the card in the repository you control, so a later edit to the prompt cannot quietly widen the allowlist. You treat the byte cap as your own courtesy limit, not as a limit advertised by any host. You can change the goal line, but you should not add deploy files just because a draft looks tidy.

# session_card.yaml - proposal, not a production policy
goal: Add GET /ready that returns 200 and a ready status
allowed_paths:
  - app.py
  - test_app.py
forbidden_paths:
  - .env
  - .env.example
  - credentials.json
  - deploy/
  - .git/
required_test_command: python -m unittest test_app.py
max_diff_bytes: 12000
Enter fullscreen mode Exit fullscreen mode

Implementation

The checker below is a labeled, unexecuted example that you should read carefully before you run it. It uses only the Python standard library, so you do not add a dependency just to review a patch. It reads the card, the diff, and the report, then prints one JSON line for a completed check. You should treat a parser error or a bad argument count as a failed review before any patch is applied.

#!/usr/bin/env python3
"""session_gate.py - unexecuted example. Fail closed on an agent diff."""

import json
import sys
from pathlib import Path


def load_card(path: Path) -> dict:
    card = {"allowed_paths": [], "forbidden_paths": []}
    key = None
    for raw in path.read_text(encoding="utf-8").splitlines():
        line = raw.split("#", 1)[0].rstrip()
        if not line.strip():
            continue
        if line.startswith("  - "):
            if key is None:
                raise SystemExit("list item before a key")
            card.setdefault(key, []).append(line.strip()[2:].strip())
            continue
        if ":" not in line:
            raise SystemExit("card line missing colon")
        key, value = line.split(":", 1)
        key, value = key.strip(), value.strip()
        if value:
            card[key] = int(value) if value.isdigit() else value
        else:
            card.setdefault(key, [])
    required = ("goal", "required_test_command", "max_diff_bytes")
    missing = [name for name in required if name not in card]
    if missing or not card["allowed_paths"]:
        raise SystemExit(f"incomplete card: {missing or 'allowed_paths'}")
    return card


def changed_paths(diff_text: str) -> list[str]:
    paths = []
    for line in diff_text.splitlines():
        if line.startswith("+++ b/"):
            path = line[6:].strip()
            if path and path != "/dev/null":
                paths.append(path)
    return paths


def main() -> int:
    if len(sys.argv) != 4:
        print("usage: session_gate.py CARD DIFF REPORT", file=sys.stderr)
        return 2
    card = load_card(Path(sys.argv[1]))
    diff_text = Path(sys.argv[2]).read_text(encoding="utf-8")
    report = Path(sys.argv[3]).read_text(encoding="utf-8")
    reasons = []
    if len(diff_text.encode("utf-8")) > int(card["max_diff_bytes"]):
        reasons.append("diff exceeds max_diff_bytes")
    allowed = set(card["allowed_paths"])
    forbidden = tuple(card["forbidden_paths"])
    for path in changed_paths(diff_text):
        if path in forbidden or any(path.startswith(prefix) for prefix in forbidden):
            reasons.append(f"forbidden path: {path}")
        elif path not in allowed:
            reasons.append(f"path outside allowlist: {path}")
    command = str(card["required_test_command"])
    report_lines = [line.strip() for line in report.splitlines()]
    if command not in report_lines:
        reasons.append("report missing required_test_command")
    if "+++ " not in diff_text:
        reasons.append("diff has no file headers")
    result = {"ok": not reasons, "reasons": reasons, "command": command}
    print(json.dumps(result, ensure_ascii=True))
    return 0 if result["ok"] else 1


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Commands you run

You create two fixtures, one clean and one dirty, then you run the gate against each fixture. These commands are the reproducible artifact, and they do not call a network or a model. You keep the fixtures in a local fixtures directory, and you do not fetch them from an untrusted gist. You run each command in a clean shell so a previous exit code cannot confuse the result you record.

Local fixtures

Save this as fixtures/ready_ok.diff. It is a proposal hunk, not a patch this article applied to a live service.

--- a/app.py
+++ b/app.py
@@ -1,2 +1,5 @@
 def health():
     return {"status": "ok"}
+
+def ready():
+    return {"status": "ready"}
--- a/test_app.py
+++ b/test_app.py
@@ -1,2 +1,8 @@
 import unittest
+from app import ready
+
+
+class ReadyTest(unittest.TestCase):
+    def test_ready(self):
+        self.assertEqual(ready()["status"], "ready")
Enter fullscreen mode Exit fullscreen mode

Save fixtures/ready_ok.md as one exact line, with no extra words on that line.

python -m unittest test_app.py
Enter fullscreen mode Exit fullscreen mode

Save fixtures/ready_bad.diff as a forbidden-path example. The token below is a placeholder, not a credential you should reuse anywhere.

--- a/.env
+++ b/.env
@@ -0,0 +1 @@
+API_TOKEN=example-not-real
Enter fullscreen mode Exit fullscreen mode

A clean diff should mention only app.py and test_app.py, and the matching report should repeat python -m unittest test_app.py exactly. A dirty diff should add .env or deploy/app.yaml, or the matching report should omit the required command. You apply the patch only after the clean run prints "ok": true and you have read the diff yourself. You discard the dirty fixture even if the prose around it sounds confident, because the gate exists to ignore tone.

python3 session_gate.py session_card.yaml fixtures/ready_ok.diff fixtures/ready_ok.md
python3 session_gate.py session_card.yaml fixtures/ready_bad.diff fixtures/ready_bad.md
Enter fullscreen mode Exit fullscreen mode

Apply only after a clean gate

patch -p1 --dry-run < fixtures/ready_ok.diff
patch -p1 < fixtures/ready_ok.diff
python -m unittest test_app.py
Enter fullscreen mode Exit fullscreen mode

You use --dry-run first so a malformed hunk cannot rewrite a file before you have inspected the reject list. You run the unittest only after a real apply in that same workspace, not in a different checkout with different code. You stop immediately if the dry run, the apply, or the unittest fails, and you do not let the agent edit in place. You save the JSON line next to the diff so a later reviewer can see why you applied or refused the change.

Expected results

Specified exits

This section describes the specification of the example, not a measured production result and not a benchmark. A clean fixture should exit 0 and print a JSON object whose ok field is true. A dirty fixture should exit 1 and list either a forbidden path or a missing command. An incomplete card should exit through SystemExit before any diff is judged, because a missing allowlist must not become allow everything.

Decision table

Fixture What changed Report Expected
clean app.py, test_app.py exact command line exit 0
forbidden adds .env exact command line exit 1
silent report allowed files only command omitted exit 1
broken card any diff any report abort before judgment

You can treat the table as the test plan, and you should not claim a pass until your machine prints the same exits. A row that aborts is still a failure, even when the diff itself would have been small and boring. You record the JSON beside the fixture name, so a later run can be compared without trusting memory. You should rerun the table after any parser edit, because a green memory of yesterday is not evidence about today's script.

Extra cases worth trying

You should also try an empty diff, which this checker rejects because it finds no file headers at all. A delete hunk that uses a null header can slip past this scan, so you should treat that pass as unfinished. If that null-header case passes, you revise the path parser before any agent draft is allowed near the tree. No latency number, token count, or pass rate is claimed here, because this example was not executed against a live service for this article.

Where free model access fits

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

MonkeyCode is relevant only as the place you might draft the patch and run the gate, if you choose that tool. The operator-supplied notes for this draft say that free model access and a free server option exist. This article does not name models, quotas, hardware, duration, or permanence, because those details were not verified against a primary source dated 2026-10-12. You should read the current product docs before you rely on any limit, and you should assume a free workspace can disappear or reset.

A split you can actually run

A practical split keeps the product mention from doing the review work that you still owe the tree. You may use free model access to propose app.py and test_app.py only, with the card pasted into the prompt as a constraint. You may use a free server as a disposable workspace to run session_gate.py and python -m unittest, away from your laptop secrets. You still copy the diff back by hand, and you still refuse the patch when the JSON says ok is false.

Confirm the docs first

If you want to try that split, start from the current MonkeyCode docs and confirm what the free options include today. Run the two fixture commands before you send a real repository, and stop if the docs disagree with this draft. That confirmation step is the only invitation this case study makes, and it is not a promise about price or capacity. You can ignore the product entirely and still keep the card, the checker, and the fail-closed rule.

Limitations

You should read these limits before you reuse the script, because a teaching checker hides several sharp edges. None of these limits is a reason to skip the gate, but each one is a reason not to overclaim what it proves. You can replace the parser later, and you should do that before anyone treats the script as team policy.

  • The tiny card parser is not a YAML library, and nested values or quoted colons will break it.
  • Path checks use the +++ b/ header only, so a clever or malformed diff can hide a rename.
  • A matching test command in the report does not prove the test passed or even exists on disk.
  • The byte cap is a local courtesy limit you chose, not a quota published by any vendor or host.
  • The gate cannot see side effects that an agent already performed on a server before you downloaded the diff.
  • Forbidden prefixes are literal strings, so a prefix such as deploy/ does not cover every obfuscated path.
  • The patch command is a local Unix assumption, so a Windows shell may need an equivalent apply tool you already trust.

Who should not use this approach

You should not use this gate as a security boundary for a hostile model, a malware sample, or a secret-filled repository. You should not point a free server at production credentials, private customer data, or a remote you cannot revoke. You should not skip the dry-run patch and the unittest just because the JSON line looks calm and short. Teams that need signed reviews, policy-as-code, or a real diff parser should use those systems instead of this teaching script.

Lessons

  1. Write the allowlist before the prompt, because an agent cannot honestly invent your risk boundary after the fact.
  2. Require the test command as an exact line, so a vague sentence about tests cannot pass the gate alone.
  3. Fail closed on a missing card field, since a default of allow-all is the bug you are trying to remove.
  4. Keep vendor claims out of the result line, and record only the exit code and JSON your own command printed.
  5. Treat a free workspace as disposable, and never store long-lived keys there even for a short teaching demo.

You leave this exercise with a card, a checker, two fixture commands, and a rule you can explain to a reviewer in one minute. The rule is simple enough to repeat, and it says that no allowlist match and no named test means no applied patch. That habit stays useful even if you later replace the script with a stricter diff tool on your team. You can rerun the same fixtures next week, and any JSON change then belongs to your checker rather than a headline.

Top comments (0)