DEV Community

Charlie Hu
Charlie Hu

Posted on

Permit the Writes: A Weekend Outbox for Agent Side Projects

Weekend agent demos break when the model can mutate the working tree while it is still exploring. A small permit file and an on-disk outbox keep exploration cheap and commits explicit. The demo is not a longer chat. It is one flushed write that a test can reject.

This note is a weekend build log, not a platform review. Scope was cut first. The working path is a permit desk. Everything else was skipped on purpose.

The failure that wastes Saturday

Side-project agents do not usually fail on the first prompt. They fail on the third retry, when a “fix the demo” turn rewrites README.md, a config file, and a test in the same breath. The transcript still looks productive. The tree does not.

Read-only turns are cheap. Writes are the scarce resource. A weekend kit should treat them that way.

Recent public debate around “vibe coding” versus engineering is noisy. The practical split on a laptop is smaller. If a tool call can change disk, it needs a permit. If it cannot, it can loop until the clock runs out.

Scope cut for one weekend

The build kept three jobs and deleted the rest.

  1. Load a static allowlist of write paths from the repo.
  2. Catch attempted writes and append them to outbox.jsonl instead of touching the tree.
  3. Flush one approved record through a command that a test can run twice.

Skipped on Saturday: multi-user auth, cloud queues, model routing, token dashboards, and any claim about which model is “best.” Those inflate the demo. They do not prove the permit.

The artifact is one Python module, one permit file, one test, and a short shell path. That is the whole ship.

The permit file

The allowlist is boring on purpose. Boring files survive a weekend.

{
  "version": 1,
  "demo_id": "weekend-outbox-001",
  "write_roots": ["demo_out/"],
  "max_flush": 1,
  "forbidden_globs": [
    ".git/**",
    "**/*secret*",
    "**/.env",
    "permits.json"
  ]
}
Enter fullscreen mode Exit fullscreen mode

write_roots is the only place a flush may land. forbidden_globs is a hard veto even if a model argues. max_flush is the Saturday halt: one committed file, then stop. The agent may propose many writes. The desk keeps one.

The outbox desk

The module below is a complete, runnable sketch. It does not call a network model. It only gates writes. Label it as a local fixture, not a production agent runtime.

#!/usr/bin/env python3
"""permit_desk.py — catch writes, flush one permitted path."""
from __future__ import annotations

import json
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parent
PERMIT_PATH = ROOT / "permits.json"
OUTBOX_PATH = ROOT / "outbox.jsonl"
FLUSHED_PATH = ROOT / "flushed.json"


@dataclass(frozen=True)
class Permit:
    version: int
    demo_id: str
    write_roots: tuple[str, ...]
    max_flush: int
    forbidden_globs: tuple[str, ...]


def load_permit(path: Path = PERMIT_PATH) -> Permit:
    raw = json.loads(path.read_text(encoding="utf-8"))
    return Permit(
        version=int(raw["version"]),
        demo_id=str(raw["demo_id"]),
        write_roots=tuple(raw["write_roots"]),
        max_flush=int(raw["max_flush"]),
        forbidden_globs=tuple(raw["forbidden_globs"]),
    )


def _is_forbidden(rel: str, permit: Permit) -> bool:
    text = rel.replace("\\", "/")
    for glob in permit.forbidden_globs:
        pattern = re.escape(glob).replace("\\*\\*", ".*").replace("\\*", "[^/]*")
        if re.fullmatch(pattern, text):
            return True
    return False


def _under_root(rel: str, permit: Permit) -> bool:
    text = rel.replace("\\", "/")
    return any(text.startswith(root) for root in permit.write_roots)


def propose_write(relpath: str, content: str, permit: Permit | None = None) -> dict[str, Any]:
    permit = permit or load_permit()
    rel = relpath.replace("\\", "/")
    record = {
        "ts": datetime.now(timezone.utc).isoformat(),
        "demo_id": permit.demo_id,
        "relpath": rel,
        "bytes": len(content.encode("utf-8")),
        "content": content,
        "status": "queued",
    }
    if _is_forbidden(rel, permit) or not _under_root(rel, permit):
        record["status"] = "rejected"
        record["reason"] = "path not permitted"
    OUTBOX_PATH.parent.mkdir(parents=True, exist_ok=True)
    with OUTBOX_PATH.open("a", encoding="utf-8") as handle:
        handle.write(json.dumps(record, ensure_ascii=False) + "\n")
    return record


def flush_one(permit: Permit | None = None) -> dict[str, Any]:
    permit = permit or load_permit()
    if FLUSHED_PATH.exists():
        raise SystemExit("demo already flushed; refuse second write")
    if not OUTBOX_PATH.exists():
        raise SystemExit("outbox empty")

    queued = []
    for line in OUTBOX_PATH.read_text(encoding="utf-8").splitlines():
        if not line.strip():
            continue
        item = json.loads(line)
        if item.get("status") == "queued":
            queued.append(item)
    if not queued:
        raise SystemExit("no queued writes")
    if permit.max_flush != 1:
        raise SystemExit("this weekend kit flushes exactly one record")

    chosen = queued[0]
    target = ROOT / chosen["relpath"]
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(chosen["content"], encoding="utf-8")
    chosen["status"] = "flushed"
    FLUSHED_PATH.write_text(json.dumps(chosen, indent=2), encoding="utf-8")
    return chosen
Enter fullscreen mode Exit fullscreen mode

The important behavior is negative. A rejected path still lands in the outbox. It never lands in demo_out/. The Saturday operator reads the log, not the model’s self-report.

A test that fails before the demo

The kit is not real until a test refuses a write the agent would love to make.

# test_permit_desk.py
from pathlib import Path
import json
import permit_desk as desk


def test_rejects_git_and_env(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    (tmp_path / "permits.json").write_text(json.dumps({
        "version": 1,
        "demo_id": "t",
        "write_roots": ["demo_out/"],
        "max_flush": 1,
        "forbidden_globs": [".git/**", "**/.env"],
    }))
    monkeypatch.setattr(desk, "ROOT", tmp_path)
    monkeypatch.setattr(desk, "PERMIT_PATH", tmp_path / "permits.json")
    monkeypatch.setattr(desk, "OUTBOX_PATH", tmp_path / "outbox.jsonl")
    monkeypatch.setattr(desk, "FLUSHED_PATH", tmp_path / "flushed.json")

    bad = desk.propose_write(".env", "TOKEN=nope")
    assert bad["status"] == "rejected"
    assert not Path(".env").exists()

    good = desk.propose_write("demo_out/hello.txt", "ok\n")
    assert good["status"] == "queued"
    flushed = desk.flush_one()
    assert flushed["relpath"] == "demo_out/hello.txt"
    assert (tmp_path / "demo_out/hello.txt").read_text() == "ok\n"
Enter fullscreen mode Exit fullscreen mode

Run it locally:

python3 -m pip install pytest
python3 -m pytest -q test_permit_desk.py
Enter fullscreen mode Exit fullscreen mode

The demo is the failing case as much as the passing one. If .env can be written, the weekend is already over.

Commands that prove one path

A short shell path is the public artifact. Chat logs are not.

python3 - <<'PY'
from permit_desk import propose_write, flush_one
print(propose_write("README.md", "# hijack\n"))
print(propose_write("demo_out/receipt.txt", "demo ok\n"))
print(flush_one())
PY

cat outbox.jsonl
cat flushed.json
cat demo_out/receipt.txt
test ! -f README.md
Enter fullscreen mode Exit fullscreen mode

Expected shape of outbox.jsonl:

{"relpath": "README.md", "status": "rejected", "reason": "path not permitted"}
{"relpath": "demo_out/receipt.txt", "status": "queued"}
Enter fullscreen mode Exit fullscreen mode

After flush, README.md is still absent. demo_out/receipt.txt exists. A second flush_one() exits non-zero. That second failure is part of the demo. It records the skip: no extra writes after the receipt.

Decision table for Saturday

Attempted path Permit result Disk effect
demo_out/receipt.txt queued, then one flush file created
README.md rejected unchanged
.env rejected unchanged
.git/config rejected unchanged
second flush process exit no extra file

The table is the product. Models can narrate. The table cannot.

Where a free remote host fits

The permit file has to travel with the repo. A local laptop and a remote workspace should load the same permits.json. If the host changes and the allowlist does not, the agent has been given a new machine without a new contract.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode’s free model access and free server option can host that same layout so the permit desk is not tied to one laptop. No model names, quotas, or hardware claims are attached here because they are not needed to run the fixture. The desk still refuses .env if the process is far away.

Readers who already have a box can ignore the host and keep the files. The workflow does not depend on a vendor remaining available.

What this weekend skipped

Skipped items were written down so they do not sneak back in as “small refactors.”

  • No retry budget. The step count is a different kit.
  • No spend meter. Token cost is a different kit.
  • No golden stdout log. The receipt is a file on disk, not a captured stream.
  • No merge gate for generated patches. Flush is not a pull request.
  • No attempt to rank models. The allowlist does not care who proposed the write.

Those skips are features of the Saturday. A demo that also measures cost, steps, and model quality is no longer a demo. It is a platform.

Limits

The glob matcher is a short regex stand-in. It is not a full gitignore engine. Symbolic links, case-folded filesystems, and writes through subprocesses that never call propose_write will bypass it. The desk is an honor system plus a test, not a kernel sandbox.

max_flush is a file flag, not a distributed lock. Two shells can race. That is acceptable for a weekend side project. It is not acceptable for a shared production volume.

The outbox stores full file contents in JSONL. Large binaries do not belong there. Text receipts do.

Who should not use this approach

Teams with a real sandbox, mandatory code review, or existing policy engines do not need a JSON allowlist. People shipping secrets, payments, or medical data should not treat an outbox file as isolation. Operators who want a ranked model bake-off will not find one in this log.

The kit is for a single maintainer who already let an agent wander through a repo once and did not like the diff. It restores a boring rule: reads may loop, writes need a permit, and the weekend ends after one flushed path.

Top comments (0)