Paid coding agents feel complete because they edit files, run tests, and hide the apply step inside a vendor runtime. The leftover that actually breaks cutover is not the rented model, but the missing gate that owns disk writes. Rebuild that gate before you leave, because a cheaper loop will otherwise write faster than your review process.
What the paid runtime was doing for you
Most hosted coding agents do more than stream tokens into a chat pane during ordinary coding work. They parse model output into file operations, keep a workspace lock, and often run a hidden formatter first. You only see a green check after the vendor stack already decided the incoming patch was safe enough. That decision is product logic rather than model intelligence, and it will not travel with exported prompts.
When you move the same loop onto a cheaper runtime, the model still emits unified diffs and tool calls. Nothing in that text knows your allowlist, generated-code folders, secret files, or protected CI configuration. If you paste those diffs into a workspace through a naive apply helper, you copy the vendor speed without the vendor brakes. The first leftover of this migration is therefore an explicit apply gate that your repository can enforce.
The failure that shows up after cutover
Consider a coding agent asked to fix a flaky test that also rewrites workflow files and a secrets loader. On a paid agent that extra damage may be blocked by path policy or a human review modal. On a raw loop those edits land as ordinary writes, while a unit test still passes for the wrong reason. You cannot detect that leftover by comparing invoices, because token spend does not encode filesystem policy.
You detect it by replaying each patch against a dry-run and a path policy, then failing closed on violations. The remainder of this diary is a portable gate you can run before any model is allowed to touch the tree. Treat every vendor screenshot as untrusted until the same patch is frozen as a fixture in git.
Cutover plan
Keep the paid agent available while you harvest real patches, because synthetic diffs hide the ugly headers you must parse. Export traces that include file edits rather than chat answers, and store the raw model text beside the ticket. Classification comes next, and anything you cannot label as create, replace, delete, or command should be dropped.
- Export five to ten traces that include file edits, not just chat answers, and save the raw model text beside each ticket.
- Classify every edit as path create, path replace, path delete, or command run, and drop output you cannot classify cleanly.
- Write an allowlist of directories the agent may touch, plus a denylist covering secrets, vendor trees, and generated blobs.
- Parse unified diffs in a dry-run that never writes bytes, and record which files would change under the policy.
- Require a local test command to pass after a real apply, and roll the tree back when that command fails.
- Only then point the same gate at a free model runtime or a free server workspace that you actually control.
The order matters more than the tooling, because switching runtimes first mixes policy bugs with model drift. If both change on the same day, you will not know which layer failed when a patch lands incorrectly. Finish the gate against paid traces first, then swap the generator and keep the gate bytes identical.
Artifact: a local apply gate you can version
The following example is a worked local script, not a production agent, and you should run the tests before trusting it. It reads a unified diff from standard input, enforces path policy, and applies the patch only after a successful dry-run. Keep the file in version control next to your prompts, because boring gates survive model swaps better than clever ones.
#!/usr/bin/env python3
"""apply_gate.py — dry-run and policy-check a unified diff before it touches disk."""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from dataclasses import dataclass
DIFF_HEADER = re.compile(r"^diff --git a/(.+) b/(.+)$")
FORBIDDEN_PARTS = (
".env",
"id_rsa",
"credentials.json",
"node_modules/",
"vendor/",
".git/",
)
@dataclass(frozen=True)
class PatchFile:
path: str
def parse_diff(text: str) -> list[PatchFile]:
files: list[PatchFile] = []
for raw_line in text.splitlines():
match = DIFF_HEADER.match(raw_line)
if not match:
continue
files.append(PatchFile(path=match.group(2)))
if not files:
raise ValueError("no diff --git headers found; refuse unstructured text")
return files
def violates_policy(path: str, allow_roots: list[str]) -> str | None:
posix = path.replace("\\", "/")
if posix.startswith("/") or posix.startswith("../") or "/../" in posix:
return f"path escapes workspace: {path}"
for part in FORBIDDEN_PARTS:
if part in posix:
return f"path hits denylist fragment {part}: {path}"
if allow_roots:
allowed = any(
posix == root or posix.startswith(root.rstrip("/") + "/")
for root in allow_roots
)
if not allowed:
return f"path outside allowlist: {path}"
return None
def git_apply(diff: str, check: bool) -> subprocess.CompletedProcess[str]:
flags = ["git", "apply", "--whitespace=nowarn"]
if check:
flags.append("--check")
return subprocess.run(flags, input=diff, text=True, capture_output=True)
def main() -> int:
parser = argparse.ArgumentParser(description="Policy gate for agent-generated patches")
parser.add_argument("--allow", action="append", default=[], help="relative root the patch may touch")
parser.add_argument("--apply", action="store_true", help="write the patch after dry-run")
parser.add_argument("--test", default="", help="command to run after a real apply")
args = parser.parse_args()
diff = sys.stdin.read()
files = parse_diff(diff)
errors = [msg for item in files if (msg := violates_policy(item.path, args.allow))]
if errors:
print("APPLY_GATE_FAIL")
print("\n".join(errors))
return 2
check = git_apply(diff, check=True)
if check.returncode != 0:
print("APPLY_GATE_FAIL")
print(check.stderr.strip() or check.stdout.strip())
return 3
if not args.apply:
print("APPLY_GATE_DRY_RUN_OK")
for item in files:
print(f"would_touch {item.path}")
return 0
apply = git_apply(diff, check=False)
if apply.returncode != 0:
print("APPLY_GATE_FAIL")
print(apply.stderr.strip())
return 4
if args.test:
test = subprocess.run(args.test, shell=True)
if test.returncode != 0:
subprocess.run(["git", "checkout", "--", "."], check=False)
print("APPLY_GATE_FAIL")
print("tests failed; tree rolled back with git checkout -- .")
return 5
print("APPLY_GATE_APPLIED")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Save that script at the repository root and treat a failed parse as a refused apply rather than a chat repair loop. Unstructured model text without diff headers must not reach git apply, even when the surrounding commentary looks confident. The gate is intentionally small so you can read every branch before you attach it to an unattended worker.
Commands you can run during the diary
Harvest a patch from the paid agent while you still have access, then freeze that text as a fixture under version control. A dry-run that prints APPLY_GATE_DRY_RUN_OK becomes your baseline, and anything else is a cutover defect you still own. Next, prove the denylist fails closed with a fixture that tries to edit an environment file outside the allowlist.
mkdir -p fixtures/patches
# paste a vendor diff into fixtures/patches/fix-flaky-test.diff
python3 apply_gate.py --allow src --allow tests \
< fixtures/patches/fix-flaky-test.diff
cat > fixtures/patches/forbidden-env.diff <<'EOF'
diff --git a/.env b/.env
--- a/.env
+++ b/.env
@@ -1,2 +1,3 @@
API_TOKEN=keep-me
+DEBUG_TOKEN=leaked
EOF
python3 apply_gate.py --allow src --allow tests \
< fixtures/patches/forbidden-env.diff
echo exit:$?
You should see APPLY_GATE_FAIL and a nonzero exit status, which is now evidence rather than a vendor screenshot. When a later model emits the same class of edit, the gate does not care which runtime produced the text. That independence is the whole point of leaving a paid coding agent without leaving behind its hidden filesystem policy.
For an allowed apply that must survive tests, pass a command you already run in continuous integration. If the test command fails, the script attempts a coarse rollback with git checkout, which is a leftover not a transaction manager. It will not undo untracked files, so keep the agent workspace clean before each apply during the migration window.
python3 apply_gate.py --allow src --allow tests --apply \
--test "python -m pytest -q tests" \
< fixtures/patches/fix-flaky-test.diff
Dirty trees mix agent writes with your own edits, and the diary then cannot explain who changed a given path. Reset or stash first, then run the gate, then inspect git status as part of the same ritual. If untracked files appear mid-run, treat them as a failed cutover even when the exit code is zero.
A tiny test plan for the parser
Label the next block as a local example until you execute it against your own fixtures. It does not need a network, a vendor key, or a running agent. It only checks that policy fails closed before git apply is invited to speak.
# test_apply_gate.py — run with: python3 -m pytest -q test_apply_gate.py
from apply_gate import parse_diff, violates_policy
SAMPLE = """diff --git a/src/app.py b/src/app.py
--- a/src/app.py
+++ b/src/app.py
@@ -1,1 +1,2 @@
print(1)
+print(2)
"""
def test_parse_reads_destination_path():
files = parse_diff(SAMPLE)
assert files[0].path == "src/app.py"
def test_denylist_blocks_env():
assert violates_policy(".env", ["src"]) is not None
def test_allowlist_blocks_github_workflows():
msg = violates_policy(".github/workflows/ci.yml", ["src", "tests"])
assert msg is not None and "allowlist" in msg
python3 -m pytest -q test_apply_gate.py
If those three assertions fail, stop the migration and fix the gate before you argue about model quality. Parser bugs masquerade as agent regressions after you leave the paid runtime. Freeze the tests with the fixtures so a later prompt tweak cannot silently widen the write surface.
A decision table for leftovers
Fill this table with your own paths so the migration document lives in the repository instead of a vendor admin console. The script only enforces what you write down, and empty allowlists will fail closed or allow too much depending on flags. Prefer an explicit allowlist of application code and tests over a denylist that forever lags new secret filenames.
| Leftover from the paid agent | What you freeze before cutover | Fail closed when |
|---|---|---|
| Hidden path allowlist |
--allow roots checked into the repo |
Diff touches infra/, .github/, or secrets |
| Review modal before write | Dry-run exit code in CI |
git apply --check fails |
| Vendor formatter pass | Explicit format command after apply | Format diff is outside allowlist |
| Automatic test button |
--test command you chose |
Tests fail or never ran |
| Workspace isolation | One git checkout per agent job | Untracked files appear mid-run |
Revisit the table after the first week on the new runtime, because free loops explore files the paid UI never showed you. Each new forbidden path should become a fixture, not a chat message, or the leftover will return on the next model bump. If you cannot name the test command in the last row, you are not ready to let any agent apply patches unattended.
Where a free model runtime fits
After the gate exists, the model becomes a patch generator instead of a filesystem owner, which is the cutover you want. If you are already evaluating MonkeyCode, its free model access and free server option can host the loop that emits diffs into this gate.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Keep the product at the edge of the design, because the gate still runs if you pipe patches from any other CLI. It still fails closed if the free server is slower, noisier, or missing a vendor-only formatter you used to ignore. Do not treat free capacity as a reason to skip fixtures, since cheap retries are how you replay those fixtures often.
A useful next step is to mount the same repository on the free server and keep the allowlist identical to your laptop. That single check prevents the leftover where a cheap machine can write anywhere because nobody copied the vendor policy. If the server workspace uses different absolute paths, fix the working directory first rather than widening the allowlist.
Limitations
This gate understands diff git headers and git apply, and it will refuse custom tool JSON or headerless whole-file rewrites. Binary patches, submodule tricks, and relative paths that escape through git apply in nested repos can still surprise you. The rollback is workspace-coarse and will clobber unrelated dirty files if you ignore the clean-tree rule described above.
The script also does not replace code review, because a patch inside src can still remove authentication or weaken assertions. Fail-closed path policy is necessary and not sufficient, so pair it with a human diff for anything that ships. Treat green tests after an agent apply as necessary evidence, not as proof that the change matches the original ticket.
Who should not use this approach
Do not use this approach if your agent must edit infrastructure as code without a second reviewer on the same change. Skip it if you lack git, if the workspace is not recoverable, or if untrusted models need syscall isolation rather than path checks. A path allowlist is not a container, and it will not stop network calls, credential theft, or prompt injection into later tools.
Teams that already have a mature policy engine around git apply should wrap that engine instead of copying this example verbatim. The diary is for people leaving a paid coding agent who never saw the apply step because the vendor UI hid it. If that is not your leftover, freeze a different artifact and keep this gate off the critical path.
What to leave behind
The paid coding agent can go when three artifacts live in your repo: frozen patch fixtures, the apply gate, and a one-line test command. Everything else is leftover interface, including review modals that never made it into a headless script you can run overnight. If a free loop cannot get through the gate, the migration is not ready, no matter how the invoice looks after the switch.
Top comments (0)