DEV Community

Charlie Hu
Charlie Hu

Posted on

Seal the Tools: A Weekend Allowlist for Agent Side Projects

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 ALLOW or DENY and 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": ""
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Expected stdout, in order:

{"allow": true, "code": "OK", "detail": "write_file"}
{"allow": false, "code": "UNKNOWN_TOOL", "detail": "run_shell"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.

  1. No shell tool. Command strings are a second language. An allowlist of argv templates is a different article.
  2. No content scanners. The byte cap stops accidental dumps. It does not detect secrets already in content.
  3. No idempotency keys. A retried write_file can clobber a good file after a legal allow.
  4. No audit chain. breaker.json is a counter, not a signed log.
  5. 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")
Enter fullscreen mode Exit fullscreen mode

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.json before 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)