I do not let a free model, or a free server, write a working tree until a local fence names every path that may change. If a later stamp shows a forbidden path moved, the apply is dead. That is the whole policy.
Why so strict? A cheerful diff is not a safe diff. Have you ever accepted a "small fix" and then found a lockfile, a sample env file, or a migration you never mentioned? I stopped treating that as a surprise. It is what happens when the blast radius lives in a prompt instead of a file.
Recent developer chatter keeps circling the same bruise. Should you retry a confident model answer, or is confidence just a fluent guess? I am not settling that debate here. I am narrowing a smaller question. Which files were allowed to move, and can I prove the others stayed put?
This is a from-zero tutorial on a scratch repository. You will build a fence, a pre-image stamp, a redacted task packet, a plan checker, and a post-image verify. Each stage has a command and a pass line. Miss the pass line and you stop. The script is a proposal until you run it. I am not publishing timings, and I am not inventing a model name or a quota.
What you are actually proving
You are not proving the model is smart. You are proving the tree stayed inside a list you wrote while you were still careful. Does that sound unglamorous? It should. Glamour is how a secret-shaped file ends up in a patch.
Skip this if the real task is "change every file." An allowlist of everything is not a fence. Skip it if you cannot throw the workspace away. A checker is not a backup, and a green stamp is not a code review.
Step 1. Create a tiny tree and a boring fence
I start with three files on purpose. Two may change. One must never move. Would you merge a patch you cannot name file by file? Then do not start the job.
mkdir -p scratch/src scratch/notes
printf 'def add(a, b):\n return a + b\n' > scratch/src/calc.py
printf 'draft\n' > scratch/notes/todo.md
printf 'TOKEN=local-only\n' > scratch/.env
The fence is JSON, not a sentence buried in a prompt. Prompts get negotiated. Files do not.
cat > fence.json << 'EOF'
{
"root": "scratch",
"allow": ["src/calc.py", "notes/todo.md"],
"deny_globs": [".env", "*.pem", ".git/*"]
}
EOF
python3 -c 'import json; d=json.load(open("fence.json")); assert d["allow"]; print("fence_ok", len(d["allow"]))'
Verification: you must see fence_ok 2. If the assert fires, the allowlist is empty. Stop. An empty allowlist is how "tidy this up" becomes "touch anything."
Step 2. Save the checker and compile it
Drop the following into write_fence.py. It only reads the local tree and writes JSON. It does not open a socket. That is deliberate. Any network step comes later, and it stays optional.
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from fnmatch import fnmatch
from pathlib import Path
def iter_files(root: Path):
for path in sorted(root.rglob("*")):
if path.is_file() and ".git" not in path.parts:
yield path
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def stamp(root: Path) -> dict:
files = {p.relative_to(root).as_posix(): digest(p) for p in iter_files(root)}
return {"root": root.as_posix(), "files": files}
def load_json(path: Path) -> dict:
try:
return json.loads(path.read_text())
except json.JSONDecodeError as exc:
raise SystemExit(f"bad_json:{path}:{exc}") from exc
def is_denied(rel: str, fence: dict) -> bool:
allow = fence.get("allow", [])
if rel in allow:
return False
if any(fnmatch(rel, pattern) for pattern in fence.get("deny_globs", [])):
return True
return rel not in allow
def check_plan(fence: dict, plan: dict) -> list[str]:
writes = plan.get("writes")
if not isinstance(writes, list) or not writes:
return ["plan_has_no_writes"]
errors = []
for item in writes:
rel = item.get("path") if isinstance(item, dict) else None
if not rel or rel.startswith("/") or ".." in Path(rel).parts:
errors.append(f"unsafe_path:{rel}")
continue
if is_denied(rel, fence):
errors.append(f"outside_fence:{rel}")
return errors
def verify(root: Path, fence: dict, before: dict) -> list[str]:
disk = stamp(root)["files"]
errors = []
keys = set(before.get("files", {})) | set(disk)
for rel in sorted(keys):
prior = before.get("files", {}).get(rel)
if prior != disk.get(rel) and is_denied(rel, fence):
errors.append(f"denied_path_moved:{rel}")
return errors
def main() -> int:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="cmd", required=True)
stamp_cmd = sub.add_parser("stamp")
stamp_cmd.add_argument("root")
stamp_cmd.add_argument("out")
plan_cmd = sub.add_parser("check-plan")
plan_cmd.add_argument("fence")
plan_cmd.add_argument("plan")
verify_cmd = sub.add_parser("verify")
verify_cmd.add_argument("root")
verify_cmd.add_argument("fence")
verify_cmd.add_argument("before")
args = parser.parse_args()
if args.cmd == "stamp":
payload = stamp(Path(args.root))
Path(args.out).write_text(json.dumps(payload, indent=2) + "\n")
print(f"stamp_ok files={len(payload['files'])}")
return 0
if args.cmd == "check-plan":
errors = check_plan(load_json(Path(args.fence)), load_json(Path(args.plan)))
if errors:
print("plan_rejected")
print("\n".join(errors))
return 2
print("plan_accepted")
return 0
errors = verify(Path(args.root), load_json(Path(args.fence)), load_json(Path(args.before)))
if errors:
print("verify_failed")
print("\n".join(errors))
return 3
print("verify_ok")
return 0
if __name__ == "__main__":
sys.exit(main())
Compile it before you trust it. A broken gate is worse than no gate, because you will lean on it.
python3 -m py_compile write_fence.py && echo compile_ok
Verification: compile_ok. If this fails, fix the script. Do not invent a plan on top of a checker that will not even import.
Step 3. Stamp the pre-image
A fence without a before-image is a wish. I hash every file under scratch/, including the one the fence denies. Otherwise I cannot prove it stayed still.
python3 write_fence.py stamp scratch pre-stamp.json
python3 -c 'import json; d=json.load(open("pre-stamp.json")); assert ".env" in d["files"]; print("pre_stamp_ok", len(d["files"]))'
Verification: the tool prints stamp_ok files=3, then the one-liner prints pre_stamp_ok 3. Why assert that .env is in the stamp? Because the file I most want to protect needs a before-hash. A fence that never recorded it cannot tell you it changed.
Short pause. If this stamp disappeared, would you still send the task? I would not.
Step 4. Build a task packet that omits denied bodies
The model, if you call one at all, gets a packet. It does not get the tree. The packet names allowed paths, names forbidden paths, and copies hashes only for the allowlist. No file bodies from denied paths.
python3 - << 'PY'
import json
pre = json.load(open("pre-stamp.json"))
fence = json.load(open("fence.json"))
allow = fence["allow"]
packet = {
"task": "Add a one-line docstring to add(). Return JSON with a writes list. Do not apply it.",
"may_write": allow,
"must_not_change": [path for path in pre["files"] if path not in allow],
"pre_hashes": {path: pre["files"][path] for path in allow},
}
json.dump(packet, open("task.json", "w"), indent=2)
print("task_packet_ok")
PY
grep -q TOKEN task.json && echo leaked || echo task_redacted_ok
Verification: task_packet_ok, then task_redacted_ok. If you see leaked, the packet is unsafe. Stop and rebuild it. I do not want a draft call to be the moment a local token leaves the disk.
Step 5. Reject a plan that walks outside the fence
A plan is not an apply. It is a list of intended writes. Here is a bad one. It looks helpful. It is not.
cat > plan-bad.json << 'EOF'
{"writes":[{"path":"src/calc.py","note":"add a docstring"},{"path":".env","note":"switch token"}]}
EOF
python3 write_fence.py check-plan fence.json plan-bad.json; echo "exit:$?"
Verification: output contains plan_rejected and outside_fence:.env, and the exit code is 2. If this plan is accepted, stop and fix is_denied. The rest of the tutorial is meaningless if the bad plan passes.
Now the narrow plan. Same task. No extra path.
cat > plan-ok.json << 'EOF'
{"writes":[{"path":"src/calc.py","note":"document the return"}]}
EOF
python3 write_fence.py check-plan fence.json plan-ok.json; echo "exit:$?"
Verification: plan_accepted and exit 0. Only then may a model draft replace plan-ok.json. Not before. Absolute paths and .. segments must fail too. Try "path": "../scratch/.env" if you want a second negative test. I keep that fixture next to the happy path so a future edit cannot quietly widen the gate.
Step 6. Optional draft, then an optional disposable apply
This is the first point where a hosted assistant belongs in the workflow. I sometimes ask for a JSON plan, and only a JSON plan, after pre_stamp_ok and a rejected bad fixture both exist. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The operator-supplied claim I will repeat is narrow: MonkeyCode has free model access and a free server option. I am not stating a token allotment, a model id, a machine size, or an expiry. Those details move. A tutorial should not freeze a number I did not re-check against a primary page today.
How would I use that, if the current docs still match? I would send task.json, not the repo, and I would demand a writes list back. I would not ask the model to apply the edit itself. If check-plan exits 2, I still learned something. The draft failed closed. I would not boot a server to see what a rejected plan does when it is allowed to touch disk.
A free server, when you actually want one, is a place to apply an already accepted plan in a workspace you can delete. It is not a reason to skip the stamp. Apply src/calc.py only. Then verify against the pre-image. The checker reads the live tree, so the post-stamp is whatever is on disk now.
printf 'def add(a, b):\n """Return the sum."""\n return a + b\n' > scratch/src/calc.py
python3 write_fence.py verify scratch fence.json pre-stamp.json; echo "exit:$?"
Verification: verify_ok and exit 0. The allowed file changed. That is fine. The denied file did not. If you did the apply on a disposable server workspace instead of this laptop, run the same verify there, or copy the tree back and verify locally. Same rule either way.
Step 7. Prove the checker catches a sneaky write
Do not trust a happy path you have not broken on purpose. What exactly did you allow? One function file. So a one-byte edit to .env must fail the job, even if the function looks perfect.
printf 'TOKEN=changed\n' > scratch/.env
python3 write_fence.py verify scratch fence.json pre-stamp.json; echo "exit:$?"
Verification: verify_failed, a line denied_path_moved:.env, and exit 3. Restore the file or delete the scratch tree. Do not keep a failed workspace because the docstring looks nice.
If a free server produced that tree, I would delete the workspace and return to the pre-image. I would not hand-edit the forbidden file back and call the apply clean. If the free server is disposable, why keep a result that already broke the fence?
The decision table I keep beside the script
| Check | Pass line | If it fails |
|---|---|---|
| Fence parses and allowlist is non-empty | fence_ok 2 |
Stop. Do not draft a prompt. |
| Script compiles | compile_ok |
Stop. A broken gate is not a warning. |
| Pre-image contains denied files | pre_stamp_ok 3 |
Stop. You cannot prove a later move. |
| Task packet has no denied body | task_redacted_ok |
Stop. Rebuild the packet. |
| Bad plan fixture | exit 2 and outside_fence:.env
|
Stop. The checker is lying. |
| Accepted plan | plan_accepted |
Do not apply. Ask for a narrower plan. |
| Live tree after apply | verify_ok |
Discard the workspace. Do not merge. |
That table is the artifact. The script is how I execute it. Neither one scores a model. They score whether I stayed inside a list I wrote before the call.
Limits, and who should walk away
Inside an allowed path, this proves nothing about behavior. verify_ok can still ship a wrong add. You want a unit test for that. This tutorial does not replace one.
fnmatch is not gitignore. A sloppy glob is a hole. Symlinks, hard links, and a second process writing the same tree are out of scope. I would not point this at a live checkout that a watcher is also mutating.
I did not run this against a live hosted endpoint while writing. If you try free model access or a free server, confirm the current terms on the project page first. Do not treat a blog post as a pricing sheet. Availability can change, and I would rather you see a 404 than trust a frozen claim.
Who should not use the approach? Anyone whose allowlist is the whole repo. Anyone who needs new top-level files in the same breath — extend allow, re-stamp, then ask. Anyone looking for a secret scanner. Denying .env is a policy choice, not detection. And anyone who will override exit code 3 because a deadline moved. The fence only works if a failed verify is allowed to waste the afternoon.
If a fenced scratch repo is the experiment you wanted anyway, read the current MonkeyCode free-model and free-server notes yourself, then run Steps 1 through 7 before you send anything you would hate to see rewritten.
Top comments (0)