A weekend agent loop is not engineering until tool calls are sealed. The demo below is a local allowlist plus a fail-closed circuit breaker. It is small enough to finish in a sitting, and it still stops a runaway agent from inventing rm, curl, or a second write path.
Side-project agents fail in a boring way. The model proposes a tool name. The host executes it. The filesystem or the network takes the hit. Spend meters and step ledgers cap volume. They do not decide which verbs are legal.
This build log cuts scope to three verbs, ships a working CLI, and lists what was skipped. The code is a proposal that runs locally. It is not a production agent runtime.
Why tool calls leak first
Vibe-driven loops look productive because text appears. Side effects are the real work. A model that can call write_file, run_shell, and http_fetch can also rename those tools, widen a path, or retry a failing call until a quota or a disk fills.
Volume controls do not answer the verb question. A step ledger can say “stop after 40 turns.” An allowlist says “this turn may only write_file under ./out, with these keys, and this size.” The second check is the one that keeps a weekend repo intact.
The failure mode is not clever jailbreaks. It is an unconstrained dispatcher. One Python process that treats the model’s JSON as a command line will eventually execute a name nobody reviewed.
Scope cut for one weekend
The box is a single process, one JSON allowlist, and a breaker that opens after repeated rejects. No model client lives in the repo. A fixture file stands in for model output so the gate can be tested without network calls.
In scope
- Three tools:
write_file,read_file,list_dir - Path prefix checks under
./workspace - Argument key and type checks
- Byte cap on writes
- A fail-closed breaker after N consecutive denials
- A CLI that prints
ALLOWorDENYand a reason code
Out of scope
- Shell, HTTP, or package install tools
- Multi-user auth
- Streaming model APIs
- Distributed locks
- Prompt storage
The cut is deliberate. A weekend that also wraps curl and docker will ship neither a gate nor a demo. Three file tools still cover the usual side-project damage: clobbering source, reading secrets outside the tree, and writing megabyte junk.
Decision table
| Incoming call | Allowlist result | Breaker state | Host action |
|---|---|---|---|
write_file under ./workspace/out, 4 KiB |
allow | closed | execute |
write_file under ../.ssh
|
deny PATH_ESCAPE
|
closed | skip |
run_shell |
deny UNKNOWN_TOOL
|
closed | skip |
| same unknown tool, 5th time | deny UNKNOWN_TOOL
|
open | halt loop |
| any tool after halt | deny BREAKER_OPEN
|
open | exit non-zero |
The table is the product. Code below is only a way to make those rows executable.
Artifact: allowlist file
Keep the policy in JSON so a diff review can see it. YAML is nicer to type and worse to pin in tests. The weekend file lives at tools.allow.json.
{
"workspace_root": "./workspace",
"max_write_bytes": 65536,
"breaker_threshold": 5,
"tools": {
"write_file": {
"keys": ["path", "content"],
"path_key": "path",
"must_stay_under": "out",
"content_key": "content"
},
"read_file": {
"keys": ["path"],
"path_key": "path",
"must_stay_under": ""
},
"list_dir": {
"keys": ["path"],
"path_key": "path",
"must_stay_under": ""
}
}
}
Empty must_stay_under still means “inside workspace_root.” Nested out is extra fence for writes. Reads may look at notes and fixtures. Writes may not.
Artifact: the gate
The gate is one module. It resolves paths with Path.resolve(), compares prefixes, and never calls eval. Consecutive denials increment a counter stored in breaker.json. A success resets it. An open breaker refuses every later call, including legal ones, until a human deletes the file.
# gate.py — weekend allowlist + fail-closed breaker
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Decision:
allow: bool
code: str
detail: str
class ToolGate:
def __init__(self, policy_path: Path, breaker_path: Path) -> None:
self.policy = json.loads(policy_path.read_text(encoding="utf-8"))
self.breaker_path = breaker_path
self.root = Path(self.policy["workspace_root"]).resolve()
self.root.mkdir(parents=True, exist_ok=True)
def _breaker(self) -> dict:
if not self.breaker_path.exists():
return {"denies": 0, "open": False}
return json.loads(self.breaker_path.read_text(encoding="utf-8"))
def _save_breaker(self, state: dict) -> None:
self.breaker_path.write_text(json.dumps(state), encoding="utf-8")
def _trip(self, allowed: bool) -> None:
state = self._breaker()
if allowed:
state = {"denies": 0, "open": False}
else:
state["denies"] = int(state.get("denies", 0)) + 1
if state["denies"] >= int(self.policy["breaker_threshold"]):
state["open"] = True
self._save_breaker(state)
def _inside(self, target: Path, extra: str) -> bool:
base = (self.root / extra).resolve() if extra else self.root
try:
target.relative_to(base)
return True
except ValueError:
return False
def check(self, name: str, args: dict) -> Decision:
state = self._breaker()
if state.get("open"):
return Decision(False, "BREAKER_OPEN", "human reset required")
spec = self.policy["tools"].get(name)
if spec is None:
self._trip(False)
return Decision(False, "UNKNOWN_TOOL", name)
expected = list(spec["keys"])
if set(args) != set(expected):
self._trip(False)
return Decision(False, "BAD_KEYS", f"want {expected} got {sorted(args)}")
rel = args[spec["path_key"]]
if not isinstance(rel, str) or rel.startswith("/") or "\\" in rel:
self._trip(False)
return Decision(False, "BAD_PATH", "relative posix path required")
target = (self.root / rel).resolve()
if not self._inside(target, spec.get("must_stay_under", "")):
self._trip(False)
return Decision(False, "PATH_ESCAPE", str(target))
content_key = spec.get("content_key")
if content_key:
body = args.get(content_key, "")
if not isinstance(body, str):
self._trip(False)
return Decision(False, "BAD_CONTENT", "content must be str")
if len(body.encode("utf-8")) > int(self.policy["max_write_bytes"]):
self._trip(False)
return Decision(False, "TOO_LARGE", "write exceeds cap")
self._trip(True)
return Decision(True, "OK", name)
A thin CLI keeps the gate usable from a shell or from a later agent host.
# check_call.py
import json, sys
from pathlib import Path
from gate import ToolGate
def main() -> int:
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
gate = ToolGate(Path("tools.allow.json"), Path("breaker.json"))
decision = gate.check(payload["tool"], payload.get("args") or {})
print(json.dumps({"allow": decision.allow, "code": decision.code, "detail": decision.detail}))
return 0 if decision.allow else 2
if __name__ == "__main__":
raise SystemExit(main())
Working demo
Create the policy file, then two fixtures. The first is a legal write. The second is a tool the policy never named.
mkdir -p workspace/out fixtures
cat > fixtures/ok_write.json << 'EOF'
{"tool": "write_file", "args": {"path": "out/note.md", "content": "# weekend note\n"}}
EOF
cat > fixtures/bad_shell.json << 'EOF'
{"tool": "run_shell", "args": {"cmd": "echo no"}}
EOF
python check_call.py fixtures/ok_write.json
python check_call.py fixtures/bad_shell.json
Expected stdout, in order:
{"allow": true, "code": "OK", "detail": "write_file"}
{"allow": false, "code": "UNKNOWN_TOOL", "detail": "run_shell"}
Repeat the bad fixture until the threshold trips. The fifth denial should return BREAKER_OPEN on every later call, including the legal write. Reset is a file delete, not a model retry.
for i in 1 2 3 4 5; do python check_call.py fixtures/bad_shell.json; done
python check_call.py fixtures/ok_write.json
rm -f breaker.json
python check_call.py fixtures/ok_write.json
That last pair is the demo worth keeping. The loop cannot talk itself out of an open breaker. A human has to touch disk.
Tiny test plan
The tests are the build’s memory. They pin path escape, extra keys, and breaker latch without a model.
# test_gate.py
import json
from pathlib import Path
from gate import ToolGate
def policy(tmp: Path) -> Path:
p = tmp / "tools.allow.json"
p.write_text(json.dumps({
"workspace_root": str(tmp / "workspace"),
"max_write_bytes": 64,
"breaker_threshold": 3,
"tools": {
"write_file": {
"keys": ["path", "content"],
"path_key": "path",
"must_stay_under": "out",
"content_key": "content",
}
},
}), encoding="utf-8")
return p
def test_blocks_parent_escape(tmp_path):
g = ToolGate(policy(tmp_path), tmp_path / "breaker.json")
d = g.check("write_file", {"path": "../secret.txt", "content": "x"})
assert d.allow is False and d.code == "PATH_ESCAPE"
def test_opens_breaker(tmp_path):
g = ToolGate(policy(tmp_path), tmp_path / "breaker.json")
for _ in range(3):
g.check("run_shell", {"cmd": "x"})
d = g.check("write_file", {"path": "out/a.txt", "content": "ok"})
assert d.code == "BREAKER_OPEN"
Run with pytest -q test_gate.py. If pytest is missing, the CLI loop above is enough for a weekend check. The tests exist so the next sitting does not “simplify” prefix logic and re-open ../.
Where a free hosted loop fits
The gate is local on purpose. A laptop still has the author’s ssh keys, browser profiles, and other repos. Running the same loop on a throwaway machine reduces how much a missed path check can touch.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode’s free model access and free server option are a place to exercise the allowlist against a hosted loop without standing up a private GPU box. The gate does not depend on that host. The CLI still runs on a laptop with Python 3.11+ and the JSON files above.
Keep the policy file in the repo that the server mounts. Do not bake tool names into prompts only. Prompts drift. The JSON file is the review surface.
What this weekend skipped
The skip list is part of the log. Each item is a future sitting, not a hidden feature.
- No shell tool. Command strings are a second language. An allowlist of argv templates is a different article.
-
No content scanners. The byte cap stops accidental dumps. It does not detect secrets already in
content. -
No idempotency keys. A retried
write_filecan clobber a good file after a legal allow. -
No audit chain.
breaker.jsonis a counter, not a signed log. - No model adapter. Wiring a vendor SDK would make the demo look complete and make the tests flaky.
Skipping those kept the artifact honest. A host that prints ALLOW and then shells out to an unlisted binary has no allowlist. The execution path has to call check before any write_text.
A minimal host sketch, labeled as unexecuted glue:
# host_sketch.py — proposal, not a full agent
# decision = gate.check(name, args)
# if not decision.allow:
# return decision
# Path(root, args["path"]).write_text(args["content"], encoding="utf-8")
Wire that after the tests pass. Not before.
Limitations and who should not use this
The prefix check uses resolve() and relative_to(). Symlinks that jump out of workspace before resolve can still surprise a host that creates links. Weekend side projects that mount extra volumes should refuse symlinks or check Path.stat().st_ino against the root device. This demo does neither.
The breaker is a local JSON file. Two processes racing on it can under-count denials. That is acceptable for a single weekend worker. It is not acceptable for a shared CI lane.
Argument checks are exact key sets. Extra harmless keys are denied. Missing keys are denied. That is strict and slightly annoying. Strict is the point. Models pad JSON. Padding should fail closed.
Do not use this approach when any of the following hold:
- The agent must run arbitrary shell as a product requirement
- The workspace contains production credentials
- Several workers share one breaker file
- Compliance needs an append-only audit log
- The operator cannot review
tools.allow.jsonbefore each sitting
Those cases need a real sandbox, a job queue, and a person on call. A 150-line gate is a seatbelt for a side project. It is not a vehicle inspection.
The useful result is social as much as technical. A pull request that adds run_shell has to change tools.allow.json. Reviewers see the verb. That is the whole point of sealing tools on a weekend: make the dangerous change visible, then keep the breaker in front of every call.
Readers who already have a loop can drop gate.py in front of their dispatcher. Those who want the same checks against a hosted worker can run the loop with MonkeyCode’s free model access and free server option, still behind this allowlist.
Top comments (0)