You must split planner and runner before any generate step. An agent is two hops with unequal privilege, not one brain. Hide that split and the host becomes the chat window.
Most generation loops still collapse those hops. A prompt enters, then a remote shell quietly follows. The model reaches your machine like a trusted coworker.
That shape fails in small and boring ways. The planner invents a flag your service never shipped. The runner applies it, and you debug the wrong box.
The constraint you actually own
You do not control the model's next token stream. You do control which sockets the planner may touch. Treat that limit as architecture, not as a later patch.
Variable models make this constraint tighter, not looser. Latency wobbles and the output format drifts without warning. A free call still cannot hold an operational contract.
So freeze three facts on your own laptop first. Name the allowed files, commands, and rollback path. Everything else stays outside the trust boundary you drew.
Think of a drawbridge rather than an open lobby door. The castle keeps the keys on the inside always. The visitor never walks the halls without a card.
Data flow that cannot lie to itself
Name three processes and refuse to merge them later. Call them planner, gate, and runner without clever branding. Give each process one mailbox and no extra sockets.
The planner sees the contract and the user prompt. It never sees SSH keys, cloud creds, or live disks. It emits a patch object, not a hopeful paragraph.
The gate sees that patch and the frozen contract. It never calls a model and never opens a shell. It accepts or rejects with a short machine reason.
The runner sees only an approved job file. It never sees the prompt and never retries the model. It applies the files, verifies health, then exits.
If one process can perform two of those jobs, stop. You do not have a loop in that case. You have one blob wearing a friendly agent name.
Check a contract into git before the planner speaks. The file below is a proposal you can copy. Keep the job_id boring and dated on purpose.
{
"job_id": "resize-health-timeout-2026-09-09",
"allowed_paths": [
"deploy/healthcheck.sh",
"deploy/systemd/app.service"
],
"forbidden_prefixes": ["/etc", "/root", "/.ssh", "deploy/.."],
"allowed_commands": [
["systemctl", "reload", "app"],
["curl", "-fsS", "http://127.0.0.1:8080/health"]
],
"max_diff_bytes": 4096,
"max_runtime_sec": 30,
"rollback": ["git", "-C", "/opt/app", "checkout", "--", "deploy/"]
}
The planner may only fill files on that list. A story about why the patch is wise is noise. Noise does not cross the gate.
A gate you can run on a laptop
Label this script as a local example, not a product. It reads a patch from stdin and writes one job. It has no vendor SDK and no hidden retry.
#!/usr/bin/env python3
"""Proposal: privilege split for a generation loop."""
import hashlib, json, pathlib, sys
CONTRACT = json.loads(pathlib.Path("contract.json").read_text())
def fail(msg: str) -> None:
print(json.dumps({"ok": False, "error": msg}))
raise SystemExit(1)
def normalize(path: str) -> str:
p = pathlib.PurePosixPath(path)
if p.is_absolute() or ".." in p.parts or p.as_posix() != path:
fail(f"unsafe path: {path}")
return p.as_posix()
def main() -> None:
patch = json.loads(sys.stdin.read())
if patch.get("job_id") != CONTRACT["job_id"]:
fail("job_id mismatch")
files = patch.get("files") or []
if not files:
fail("empty patch")
allowed = set(CONTRACT["allowed_paths"])
total = 0
clean = []
for item in files:
path = normalize(item.get("path", ""))
body = item.get("content", "")
if path not in allowed:
fail(f"path not allowed: {path}")
total += len(body.encode("utf-8"))
clean.append({"path": path, "content": body})
if total > CONTRACT["max_diff_bytes"]:
fail("diff too large")
digest = hashlib.sha256(
json.dumps({"job_id": patch["job_id"], "files": clean}, sort_keys=True).encode()
).hexdigest()
job = {
"ok": True,
"digest": digest,
"commands": CONTRACT["allowed_commands"],
"rollback": CONTRACT["rollback"],
"files": clean,
"max_runtime_sec": CONTRACT["max_runtime_sec"],
}
pathlib.Path("approved.job.json").write_text(json.dumps(job, indent=2))
print(json.dumps({"ok": True, "digest": digest}))
if __name__ == "__main__":
main()
Run it like a filter, not like a chatbot sidecar. Keep the model on the left of the pipe. Keep the host on the right of the pipe.
python3 plan_stub.py > patch.json
python3 gate.py < patch.json
test -f approved.job.json || exit 1
python3 runner.py approved.job.json
The runner should look dull in code review. Dull means it cannot phone the planner. Dull means a failed health check never widens the contract.
#!/usr/bin/env python3
"""Proposal: apply an approved job, never a prompt."""
import json, pathlib, subprocess, sys
def main(job_path: str) -> None:
job = json.loads(pathlib.Path(job_path).read_text())
if not job.get("ok"):
raise SystemExit("refusing unapproved job")
root = pathlib.Path("worktree")
timeout = int(job["max_runtime_sec"])
for item in job["files"]:
target = (root / item["path"]).resolve()
if root.resolve() not in target.parents and target != root.resolve():
raise SystemExit(f"escaped worktree: {target}")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(item["content"])
for argv in job["commands"]:
subprocess.run(argv, check=True, timeout=timeout)
print("applied", job["digest"])
if __name__ == "__main__":
main(sys.argv[1])
Notice what this runner refuses to import. There is no HTTP client for a model. There is no prompt variable hiding in a comment. Failure goes to rollback argv, not back into chat.
If apply fails, you run the rollback from the same job file. You do not paste the stack trace into the planner. That courtesy is how a loop eats a host.
Failure domains, drawn as three boxes
Draw three boxes on a napkin before you open SSH. Label them prompt-side, gate-side, and host-side in ink. Then stop adding arrows that feel convenient.
Prompt-side fails by fluent invention. It writes a plausible systemd flag your unit file never had. That failure belongs in a file you can delete.
Gate-side fails by being too kind with strings. A prefix check on deploy/ still loves deploy/../etc. Normalize the path, reject dots, and reject absolute paths.
Host-side fails by ambient authority you forgot was there. If the runner user can rewrite unit files anywhere, a bad job still wins. Give that user a worktree mount and no extra groups.
The analogy is a hotel key card, not a master key. The guest opens one numbered door for one night. The front desk never lends the whole board.
A free remote server is still a host-side box. Zero invoice does not move the box into the planner. Cost is not a trust boundary, and it never was.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option, which is enough to rehearse this split. Rehearsal still starts with the gate on your laptop.
You write the contract where the model cannot call home. You approve the job in a process the runner cannot prompt. The free host only sees approved.job.json and a short argv list.
Prove the gate before you prove the model
Chat logs do not replay. Job files with digests do. Treat the gate like a parser test, not like a demo day script.
# job_id mismatch must die
echo '{"job_id":"wrong","files":[{"path":"deploy/healthcheck.sh","content":"x"}]}' \
| python3 gate.py ; echo exit:$?
# path escape must die
python3 - <<'PY' | python3 gate.py ; echo exit:$?
import json
print(json.dumps({
"job_id": "resize-health-timeout-2026-09-09",
"files": [{"path": "deploy/../etc/passwd", "content": "nope"}]
}))
PY
# oversized diff must die
python3 - <<'PY' | python3 gate.py ; echo exit:$?
import json
print(json.dumps({
"job_id": "resize-health-timeout-2026-09-09",
"files": [{"path": "deploy/healthcheck.sh", "content": "x" * 5000}]
}))
PY
You want three non-zero exits and no approved.job.json leftover. If a case writes a job file, the gate is theater. Theater is how privilege split dies in review.
A passing happy path should print a digest and nothing else. Save that digest next to the contract in git. Tomorrow you can ask whether production still matches that hash.
Who should not run this loop
Do not sell this split as a security product. It is an architecture review pattern for generation. It will not save you from a stolen runner key.
Do not point the runner at a shared production bastion. Rehearse on a throwaway host with a snapshot. If you cannot snapshot, you cannot afford the experiment.
Skip the split when you only generate comments or docs. The blast radius is already tiny in that case. Extra processes would only slow a harmless edit.
Skip it when nobody can freeze allowed_paths in writing. A loop with an open path list is still chat beside SSH. The drawbridge is stuck open, whatever the model costs.
What to change next
Change subprocess so every command is argv, never a shell string. The proposal above already leans that way on purpose. Keep leaning until shell=True cannot sneak back.
Change health checks from curl exit codes to a pinned digest. An HTTP 200 can still serve yesterday's wrong page. Read the body and hash it against a known fixture.
Change the runner so it dies after one job. A long-lived runner becomes a daemon while you look away. Daemons collect extra permissions the napkin never drew.
Keep the control plane boring on purpose after those changes. Boring is the failure domain you can actually name. Boring is how the model stays off the SSH socket.
Top comments (0)