Someone on your team pastes a stack trace into an assistant, gets a plausible diff back, and hits "apply." Twenty minutes later the preview environment is broken and nobody can say which automated run changed what. When I trace incidents like this, the weak point is almost never the model's reasoning. It is the seam where a text suggestion quietly inherits shell, network, and write permissions it never had to justify.
I recently reworked one of these flows into a conveyor of small, separately-permissioned stages. The principle: an assistant starts with observation rights only, and every escalation toward mutation passes through a machine-checkable gate. Inexpensive model capacity is great for the high-volume early stages — drafting summaries, proposing commands — but the gate itself must hold even when the model is down, hallucinating, or swapped for another vendor.
Start from a real breakage, not a prompt
My motivating incident was a dependency bump, not a migration. An assistant with a generic "run commands" tool tried to upgrade a package, hit a peer-dependency conflict, retried with --force, and left the lockfile in a state that passed local tests but failed the deploy build with a 422 from the registry webhook. Three separate automated runs had touched the lockfile and none of them recorded which actor asked for which command.
That produced a five-stage shape with one hard rule per stage:
- Scan — the assistant may read source, test output, and error bodies. Nothing else exists yet.
- Draft — it returns a structured change proposal: intended edits plus the exact commands it wishes it could run. Wishing is not running.
- Rehearse — those commands execute inside a throwaway sandbox whose permissions are deny-by-default.
- Commit — only CI or a named human can land the change, and the actor identity is stamped onto the artifact.
- Trace — every artifact carries the proposal hash, the command list, and the permission grant that allowed it, so orphans are impossible.
For the cheap, repetitive Scan and Draft stages I run them in a low-commitment assistant environment rather than burning production-adjacent infrastructure. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The operator's availability notes say MonkeyCode offers free model access and a free server option; I treat both as changeable platform conditions — I confirm they still hold before relying on them, and I never route secrets, customer payloads, or any irreversible privilege through them, regardless of cost tier.
The real artifact: a policy the sandbox evaluates
Prompts did not fix my incident; an enforceable policy object did. Here is the shape in Python this time — the same idea ports to any stack:
# policy.py
from dataclasses import dataclass, field
import time
@dataclass
class Grant:
run_id: str
role: str # 'observer', 'rehearsal', 'committer'
readable_paths: list[str]
writable_paths: list[str] = field(default_factory=list)
allowed_bins: list[str] = field(default_factory=list)
network_egress: list[str] = field(default_factory=list)
ttl_seconds: int = 120
issued_at: float = field(default_factory=time.time)
class PolicyViolation(Exception):
def __init__(self, code: str, detail: str):
super().__init__(detail)
self.code = code
def authorize(grant: Grant, binary: str, workdir: str) -> None:
if time.time() > grant.issued_at + grant.ttl_seconds:
raise PolicyViolation("GRANT_STALE", "grant lifetime exceeded")
if grant.role == "observer" and (grant.writable_paths or "git push" in grant.allowed_bins):
raise PolicyViolation("OBSERVER_WITH_TEETH", "observer role must hold zero mutation rights")
if not any(binary.startswith(b) for b in grant.allowed_bins):
raise PolicyViolation("BINARY_BLOCKED", f"{binary} not on allowlist")
if not any(workdir.startswith(p) for p in grant.readable_paths):
raise PolicyViolation("PATH_BLOCKED", f"{workdir} outside granted scope")
Two design choices carry the weight here. First, observer grants are structurally incapable of holding write scope — the check runs at authorization time, so a copy-pasted helper cannot smuggle mutation rights into the reading stage. Second, lifetimes are measured in minutes; a leaked grant expires before it is useful. This is the antidote to the classic vibe-coding accident where draft_change() and apply_change() end up sharing one over-privileged client object because both "needed repo access."
The sandbox wrapper is deliberately thin — all policy lives in authorize, so there is one file to audit:
# sandbox.py
import subprocess
from policy import Grant, authorize
def rehearse(grant: Grant, binary: str, argv: list[str], workdir: str) -> dict:
authorize(grant, binary, workdir)
proc = subprocess.run(
[binary, *argv], cwd=workdir, capture_output=True, text=True, timeout=90,
)
return {"run_id": grant.run_id, "cmd": [binary, *argv], "rc": proc.returncode,
"out": proc.stdout[-4000:], "err": proc.stderr[-4000:]}
Tests that make the boundary fail loudly at build time
These are cheap to run and worth more than a week of prompt tuning:
# test_policy.py
import pytest
from policy import Grant, authorize, PolicyViolation
def observer_grant(**kw):
base = dict(run_id="run-9", role="observer",
readable_paths=["/workspace/repo"],
allowed_bins=["pytest", "grep"], ttl_seconds=60)
return Grant(**(base | kw))
def test_observer_may_rehearse_tests():
authorize(observer_grant(), "pytest", "/workspace/repo/pkg")
def test_observer_cannot_hold_write_scope():
with pytest.raises(PolicyViolation) as e:
authorize(observer_grant(writable_paths=["/workspace/repo"]), "pytest", "/workspace/repo")
assert e.value.code == "OBSERVER_WITH_TEETH"
def test_unlisted_binary_refused():
with pytest.raises(PolicyViolation) as e:
authorize(observer_grant(), "curl", "/workspace/repo")
assert e.value.code == "BINARY_BLOCKED"
def test_expired_grant_refused():
g = observer_grant(issued_at=0)
with pytest.raises(PolicyViolation) as e:
authorize(g, "pytest", "/workspace/repo")
assert e.value.code == "GRANT_STALE"
If you host the Rehearse stage on a free server option, design it as combustible: zero production credentials, no mounted volume you would miss, no shared message bus with anything that can commit, and a teardown cron that assumes compromise. What you are buying is isolation and a clean retry loop — never trust.
Who may do what, and which code tells you the gate worked
| Stage | Assistant output | Sandbox permission | Human/CI duty | Code that proves the gate held |
|---|---|---|---|---|
| Scan | log summary, diff reading | scoped read paths only | flag any leaked secret | PATH_BLOCKED |
| Draft | proposed edits + desired commands | none | sign the proposal hash | PROPOSAL_MUTATED |
| Rehearse | interpret failing output | allowlisted binaries, 90s timeout | promote artifact to review | BINARY_BLOCKED |
| Commit | never present | never present | merge with stamped actor | ROLE_FORGED |
| Trace | first-pass incident note | attach captured logs | keep immutable ledger | LEDGER_ORPHAN |
The rightmost column matters more than it looks: when something goes wrong, you want a specific denial code pointing at the exact stage, not a shrug and a chat transcript.
Where I would not run this pattern
Skip free-tier model access and free server capacity for anything touching regulated records, unreleased customer source, credential handling, direct database mutation, or workloads with a retention guarantee. Skip the whole staged design if your team cannot yet say which layer owns a change — UI, API, worker, or schema — because the gate will surface that ambiguity as friction without resolving it. And if you cannot write the binary allowlist down in one sitting, the assistant is still negotiating its privileges in prose, which is exactly the failure this pattern exists to remove.
The durable idea is modest: let inexpensive capacity absorb the repetitive reading, summarizing, and rehearsal loops, and concentrate your engineering effort on the single seam where generated text becomes a committed change. If you build a lane like this, the most valuable thing you can report back is the handoff that wobbled first — and the concrete denial code or HTTP status that caught it. Which stage boundary is least stable in your setup?
Top comments (0)