Few Parts
Interactive

Break an unattended agent's guardrails

An agent running in front of you needs no guardrails, because you are the guardrail. You see the plan, you see the diff, and you stop it when it turns down the wrong road. Put the same agent on a cron and every one of those judgements disappears.

Below is a simulation of what replaces them — the same check logic, running in your browser against invented cycles so you can force the interesting failures instead of waiting for one. Nothing is sent anywhere.

scheduled agent · 09:26 daily IDLE
Cycle budget 0 / 8 this month
Guards
cycleGuard 8 remaining
writeGuard nothing blocked
traceHook no invocations
scopeGuard no changes
ledger 0 entries
state/ledger.json 0 B
{
  "cycles": []
}
runs entirely in your browser

Cap runs, not dollars

On a flat subscription there is no per-call figure to meter, so the runaway risk isn't spend — it's an agent waking up far more often than you meant it to. Derive the count instead of storing one: a run that dies before writing a counter back silently buys itself a free run.

js
export function cyclesInMonth(ledger, yyyyMm = new Date().toISOString().slice(0, 7)) {
  return ledger.cycles.filter((c) => String(c.startedAt).slice(0, 7) === yyyyMm).length;
}

Refuse the write before it happens

A PreToolUse hook gets the pending tool call on stdin and can answer "deny" with a reason the model reads. Unlike a permissions.deny list, it can express an allow-list — deny rules enumerate what's forbidden, and the set of paths an agent shouldn't touch is unbounded. The hook only ever denies or defers; answering "allow" would override your own permission rules, which isn't its job.

js
// .claude/settings.json -> hooks.PreToolUse
const verdict = decideWrite(payload, { allow: ["src/", "docs/"] });
// { decision: "deny", reason: "Write to .env is outside this agent's write scope." }
process.stdout.write(hookResponse(verdict));

Compare where paths go, not what they say

path.resolve collapses .. and never opens anything, so a symlink inside the allow-list — or on Windows a directory junction, which needs no elevation to create — reads as inside the allow-list while the write lands wherever it points. Nothing in the path looks wrong, which is why this survived review here until it was reproduced. The guard resolves both the target and the allowed roots through realpath before comparing; resolving only one side is worse than resolving neither, because a project reached through a link then has every legitimate write denied. Press "Stray write, through a symlink" above: the path is inside state/, and the deny message says where it really goes. The reproduction is here.

Prove the hook ran at all

A hook that ran and allowed the call is indistinguishable from a hook that was never wired up: allowing is silent, and on tool events a hook's stdout goes to the debug log rather than to you. So the guard above is only as good as your belief that it is being invoked — and the usual reasons it isn't are mundane, like a matcher that doesn't match or a session that started before you edited settings.json. Wrap the hook instead of changing it: same bytes on stdin, same exit code out, plus one line in a log saying what happened. Press "Stray write, hook not wired" above — nothing on the panel changes except the trace.

js
// .claude/settings.json -> "command": "node trace-hook.mjs node your-hook.mjs"
diagnose(readTrace(".claude/hook-trace.jsonl"), { file: ".env" });
// { verdict: "not-invoked", summary: "No hook was invoked for .env ..." }

Check scope before committing

The hook only sees tool calls it was matched against. A file written by a shell command, a subagent or a build step goes straight past it — which is what this catches, by diffing the working tree against the same allow-list before you commit. Not an alternative to the hook; the other half of it. Press "Stray write, via shell" above to watch one slip through.

js
const scope = checkScope(["products/", "site/content/", "state/"]);
// { ok: false, changed: [...], violations: [".env"] }
if (!scope.ok) notifyOwner("Out of scope: " + scope.violations.join(", "));

Commit a ledger

"Trust the model" is not an audit trail. If the state lives in the repo, git log becomes the record of what ran and why — something you can check rather than take its word for.

js
startCycle(ledger);
// ... the agent does its work ...
finishCycle(ledger, "drafted an article");
saveLedger(LEDGER_PATH, ledger);
commitAll("cycle " + ledger.cycles.length + ": drafted an article");

The full writeup, with all the code and none of the simulation: Hacker News killed your submission and showed you the normal page.

Five modules, about 650 lines, no dependencies. If you'd rather not write them yourself, the ready-made version is Agent Guardrails Kit — £0.00.