You should split generation from merge on purpose. The generator is a failure domain, not a teammate. A local review gate keeps that boundary honest.
Most teams collapse those two planes by habit. They paste a prompt, accept a tree, and push. That pattern is a hallway with no doors.
Constraints
You do not control the model's training set. You also do not control a remote generation host. You do control which files may enter git.
Think of the generator as a noisy workshop offsite. The workshop can cut wood for you. It cannot hang the door on your house.
You inspect every piece at the gate. A generated patch is lumber, not a finished wall. Merge authority stays in your repo, always.
A hosted coding box can still help this flow. You send a narrow brief and receive a patch. You never send credentials, topology maps, or merge rights.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access and a free server option. You can park that option in the workshop role.
Swap the host later if you still want. The gate does not care about the brand. Architecture cares about the cut, not the logo.
Data flow
Keep one direction of travel for data. A redacted prompt goes out across the wire. A unified diff comes back and nothing else.
You assemble the prompt from a brief template. You never dump .env into that request. You never attach kubeconfigs, SSH keys, or customer dumps.
The host returns text, not a live checkout. You save that text as incoming.patch on disk. You still do not apply it to the worktree.
A local validator owns the next hop. The validator reads bytes instead of model intent. Intent is not an interface you can trust.
Here is a starting gate you can run first. Treat the script as a proposal, not a certified control.
#!/usr/bin/env python3
"""proposal: local review gate for untrusted unified diffs"""
from pathlib import Path
import re
import sys
ALLOWED_PREFIXES = ("src/", "tests/", "docs/")
DENIED_NAMES = {".env", "id_rsa", "credentials.json", "kubeconfig"}
DENIED_PATTERNS = [
re.compile(r"curl\s+[^\n]*\|\s*(sh|bash)"),
re.compile(r"chmod\s+777"),
re.compile(r"os\.system\("),
]
def iter_paths(patch: str):
for line in patch.splitlines():
if line.startswith("+++ b/"):
yield line[6:]
def allowed(path: str) -> bool:
p = Path(path)
if p.is_absolute() or ".." in p.parts:
return False
text = str(p)
return text.startswith(ALLOWED_PREFIXES)
def main(path: str) -> int:
patch = Path(path).read_text(encoding="utf-8")
if "diff --git" not in patch:
print("reject: not a git unified diff")
return 2
if re.search(r"^GIT binary patch", patch, re.M):
print("reject: binary patch")
return 2
paths = list(iter_paths(patch))
for p in paths:
if Path(p).name in DENIED_NAMES or not allowed(p):
print(f"reject: path {p}")
return 2
for rx in DENIED_PATTERNS:
if rx.search(patch):
print(f"reject: pattern {rx.pattern}")
return 2
print("accept: patch may enter human review")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
You invoke it with a boring command. The numeric exit code is your only contract.
python3 tools/review_gate.py incoming.patch
echo $?
# 0: humans may read the diff
# 2: the worktree never sees the bytes
git apply --check incoming.patch
Notice what the script refuses to do. It does not merge or fetch extra files. It does not call the model again.
Failure domains
Draw four boxes and refuse to merge them. Prompt assembly is the first box you own. It leaks if you paste secrets in a hurry.
The generator host is the second box. It can hallucinate dependencies and invent missing APIs. It can emit a helper that shells out.
The review gate is the third box. It can fail closed or fail open. The gate must fail closed on errors.
Merge and deploy form the fourth box. Only signed reviewed trees belong in that box. Generated text is not a merge signature.
Airport screening is the picture to steal. The workshop is allowed to pack a suitcase. Security still opens every bag before the plane.
The plane does not load unchecked bags. Talent in the workshop does not change that. Your production cluster is the plane here.
If the generation host is remote, this cut matters. You should assume the host can see the prompt. You should assume it cannot see production.
What should the gate actually read from a patch? Paths are the cheap signal you get first. Behavior is the expensive signal you still need.
You want tests in the same patch. A patch with no tests is a sketch. Sketches do not get to rename production modules.
Add a second check that counts test files. Keep that rule small and highly visible.
def test_touch_ok(paths):
code = [p for p in paths if p.endswith((".py", ".ts", ".go"))]
tests = [p for p in code if p.startswith("tests/") or "/tests/" in p]
return bool(code) and bool(tests)
You still need a human after that. A model can write a test that asserts nothing. The ratio is a tripwire, not a proof.
Git will not protect you by default. git apply does not know your allowlist. You wrap apply so fingers cannot skip the gate.
#!/bin/sh
# proposal: never apply a generated patch without the gate
set -eu
patch="$1"
python3 tools/review_gate.py "$patch"
git apply --check "$patch"
git apply "$patch"
git diff --stat
You now have data flow with deliberate friction. That friction is the architecture you wanted. Speed without a gate is just blast radius.
Keep a decision table beside the script. A table is a contract people can argue with. Hidden rules in chat do not survive rotation.
| Artifact in | Leave generator | Enter worktree | Merge |
| prompt with secrets | no | no | no |
| unified diff on src/ and tests/ | yes | after gate | after review |
| lockfile rewrite | yes | human only | human only |
| CI yaml, deploy yaml, IAM | no | no | no |
| new runtime dependency | yes | after pin review | after pin review |
Read each row as a promotion rule. The generator may propose a src change. It may not promote CI, IAM, or deploy yaml.
If a change needs workflow files, you type them. That is a failure-domain cut, not stubbornness. Machines that ship themselves are not being reviewed.
What to change next
The current gate still trusts raw filename prefixes. Prefixes are a weak wall under pressure. A path with .. can fool a naive check.
You must normalize paths before you allow them. You reject absolute paths on first sight. You reject parent traversal without a discussion.
You should hash every accepted patch on disk. Then store the digest beside the ticket. Later CI can refuse trees that do not match.
sha256sum incoming.patch | tee incoming.patch.sha256
# proposal: CI checks bytes, and never calls the model
name: patch-provenance
on: pull_request
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python3 tools/review_gate.py incoming.patch
- run: sha256sum -c incoming.patch.sha256
You should stop pasting prompts from live incidents. Build a brief with fields you allow. You list service name, symptom, and failing test.
You list non-goals and you stop there. Leave hostnames and tokens out of that brief. If the model needs a stack trace, redact it.
Redaction is part of prompt assembly, not polish. Give a future self-hosted runner a fake network. A host you do not control will not do that.
Your gate must assume outbound calls happened. You should deny binary patches in this flow. Generated jars and images skip review too easily.
Text diffs are the only artifact on this path. Store the brief next to the digest. Future you will need both during incident review.
Limitations
This design does not make the model honest. It only makes promotion expensive and visible. Expense is how you buy a smaller failure domain.
The regex list will rot in weeks. New exfil tricks will miss curl | sh. You extend the gate when you learn, in review.
The allowlist will annoy you during large refactors. That pain is a useful design signal. Move the boundary with a reviewed config change.
Do not use this approach inside a production VPC. Do not use it when the patch must touch IAM. Do not use it for hot incident patches alone.
If your team cannot run tests locally, stop. The gate becomes theater without a test run. Theater is worse than an honest manual review.
Try the flow on a throwaway repository first. Write a failing test with a tight name. Send only that test and a short brief.
Save the reply as a patch file. Run the gate before you read for style. Then read the diff aloud, file by file.
You are listening for surprise in the file list. You treat unexpected files as a domain leak. Reject extra files even when tests pass.
If you already have a generator host, keep it in that workshop box. MonkeyCode's free models and free server can live there. Merge stays on your laptop either way.
Top comments (0)