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. It wakes up, does something, and commits — and the first time you look is hours later, or the next morning, or when something is already wrong.
Three questions become load-bearing the moment that happens. How many times can it run before someone should look? Did it only touch the files it was supposed to? What did it actually do, in a form you can check rather than take its word for?
None of the answers need a framework. What follows is the shape each one takes, in ordinary Node with no dependencies.
1. Cap runs, not dollars
The obvious guard is a spending cap, and for a metered API key that is the right guard. But a lot of scheduled agents run on a flat subscription — Claude Code on a Max plan, say — where there is no per-call dollar figure to meter at all. Cost is not the runaway risk there. The risk is an agent that wakes up far more often than you intended, because a cron entry was wrong, or a run crashed and something retried it, or you forgot the schedule existed.
So cap the thing you can actually count: runs per calendar month.
The important detail is where the count comes from. A counter you increment and store is wrong in a way you won't notice, because any run that dies before it writes the counter back silently buys itself a free run. Derive the count instead, from timestamps you already have:
export function cyclesInMonth(ledger, yyyyMm = new Date().toISOString().slice(0, 7)) {
return ledger.cycles.filter((c) => String(c.startedAt).slice(0, 7) === yyyyMm).length;
}
export function assertCycleBudget(ledger, maxPerMonth, yyyyMm) {
const used = cyclesInMonth(ledger, yyyyMm);
if (used >= maxPerMonth) {
const err = new Error(`Cycle budget exhausted: ${used}/${maxPerMonth} used this month.`);
err.code = "CYCLE_BUDGET_EXCEEDED";
throw err;
}
return { used, max: maxPerMonth, remaining: maxPerMonth - used };
}
ISO timestamps make the month comparison a string slice, which is the whole reason to store them that way. Call this at the very top of the run, before the agent has done anything at all — a blown budget should stop work before there are side effects to clean up, not halfway through.
One more thing worth doing: when the guard trips, print a single word the agent's own instructions tell it to stop on. Mine prints HALT, and the scheduled prompt says, in as many words, that HALT means stop immediately and end the turn. That way the limit is enforced twice — once in code, once in the instructions — and neither depends on the other holding.
That is the cap in the ordinary case, and the ordinary case is most of them. It leaks in three that aren't: a run that crashes before it writes its record buys itself a free one, a month boundary read in the wrong timezone gives you a day of unmetered runs twice a year, and two runs starting at the same instant both read a count below the cap. I only found the last one by racing it deliberately — it holds about four times in five, which is how it survives a test suite.
2. Check scope against git status, right before you commit
An agent with file-write access can write the wrong files. Usually by accident. Occasionally because it read an instruction somewhere it shouldn't have trusted — a README, an issue comment, a page it fetched — and did what the text said.
You can try to prevent that at write time, and permission systems are worth having. But there is a much cheaper check available at the other end: before anything gets committed, diff the working tree against a list of path prefixes the agent is allowed to touch.
import { execFileSync } from "node:child_process";
const normalize = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
export function parsePorcelain(out) {
return out
.split("\n")
.filter((line) => line.length > 3)
.map((line) => {
const path = line.slice(3).trim();
const arrow = path.indexOf(" -> "); // renames read "old -> new"
return arrow === -1 ? path : path.slice(arrow + 4);
})
.map((p) => normalize(p.replace(/^"|"$/g, "")))
.filter(Boolean);
}
export function checkScope(allowedPrefixes, cwd = process.cwd()) {
const prefixes = allowedPrefixes.map(normalize);
const changed = parsePorcelain(execFileSync("git", ["status", "--porcelain=v1"], { cwd, encoding: "utf8" }));
const violations = changed.filter((p) => !prefixes.some((prefix) => p.startsWith(prefix)));
return { ok: violations.length === 0, changed, violations };
}
That is the whole guard. checkScope(["products/", "site/content/"]) returns the paths that have no business being in the diff, and you decide whether that's a warning or a hard stop.
There is one detail in there that will bite you, and it bit me — I shipped this exact function with the bug in it. git status --porcelain=v1 emits XY path, where X and Y are single status characters and either of them may be a space. A staged addition is A file. An unstaged modification is M file, with a leading space. If you trim the line before slicing off the status columns, that leading space vanishes and you slice one character too many:
raw: " M state/ledger.json"
trimmed: "M state/ledger.json"
sliced: "tate/ledger.json" ← wrong, and it looks almost right
Almost right is the bad kind of wrong. state/ledger.json was inside the allow-list; tate/ledger.json is not, so the guard reported a violation for a file the agent was explicitly allowed to write. And it only misfires on unstaged changes — which is to say, exactly the case a pre-commit scope check exists to look at. Slice first, trim after. If you write your own, feed it a " M path" line in a test.
While you're there, handle renames (R old -> new, where the destination is what changed) and git's habit of wrapping paths containing unusual characters in double quotes.
3. Keep a ledger, and commit it
"The agent said it updated the docs" is not an audit trail. It's a summary written by the thing being audited, from memory, after the fact.
An audit trail is a record written at the moment each action happened. An append-only JSON file is enough: one array of cycles, one array of events, each entry with an ISO timestamp and a type.
{
"cycles": [
{ "id": 2, "startedAt": "2026-08-07T21:02:11.884Z", "endedAt": "2026-08-07T21:29:40.512Z", "summary": "…" }
],
"events": [
{ "at": "2026-08-07T21:04:02.117Z", "type": "decision", "text": "Fix the scope guard before writing anything new" }
]
}
Two properties make this worth more than a log file. It's the same data the run cap counts, so the guard and the history can't disagree. And because it's a small text file in the repo, committing it at the end of every cycle turns git log into the actual history of what an unattended agent did and why — each commit carrying both the changes and the reasoning that produced them, side by side.
Record events as they happen, not in a summary pass at the end. A run that crashes halfway should still leave behind what it did before it died; that's the run you'll most want to read.
Wiring it together
The three guards want to be two commands the agent calls itself, at the start and end of its instructions:
// start
const ledger = loadLedger(LEDGER_PATH);
try {
assertCycleBudget(ledger, MAX_CYCLES_PER_MONTH);
} catch (err) {
if (err.code === "CYCLE_BUDGET_EXCEEDED") { console.log("HALT —", err.message); process.exit(0); }
throw err;
}
startCycle(ledger);
saveLedger(LEDGER_PATH, ledger);
// finish
const scope = checkScope(ALLOWED_PREFIXES, ROOT);
if (!scope.ok) notifyOwner(`Out-of-scope changes: ${scope.violations.join(", ")}`);
finishCycle(ledger, summary);
saveLedger(LEDGER_PATH, ledger);
commitAll(`cycle ${ledger.cycles.length}: ${summary}`);
Note that finish warns rather than throws. A scope violation you discover at commit time is information, and the useful response is usually to commit anyway and flag it loudly — the change already exists on disk, and refusing to record it doesn't undo it, it just makes it harder to see. Throw at the start, warn at the end.
What these don't do
They are not a sandbox. A scope check runs after the writes, so it catches what happened rather than preventing it — pair it with real filesystem permissions if an out-of-scope write would be genuinely destructive rather than merely wrong.
A run cap is not a spend cap. If you're on a metered API key and cost is the thing you're worried about, count tokens, not runs; that's a different problem with a different shape.
None of the three notices a run that never happened. Every one of them is triggered by a run — the cap counts runs, the scope check inspects a run's writes, the ledger records a run — so the day the scheduler doesn't fire, all three stay quiet and the log looks exactly like a quiet day. Catching that is arithmetic against the schedule rather than anything a guard can observe, and it's the failure mode most likely to go unnoticed for a week.
And none of this makes the agent's work good. It makes the work bounded, attributable, and reviewable, which is the precondition for leaving it alone — not a substitute for reading what it did.
Three files, about 150 lines, no dependencies. Worth an afternoon of your own if you like writing this sort of thing. If you'd rather skip the afternoon — including the porcelain bug — the packaged version below is these three plus a PreToolUse write guard that refuses a stray write before it lands, a tracer that tells you whether that hook is being invoked at all, and a test suite that exercises every function. That last part isn't filler: a guard whose failure mode is silence passes any review and fails every run.
Originally published at fewparts.co.uk.
I write about running agents unattended, and sell the packaged version of this code — Agent Guardrails Kit, £22.00. Saying so up front because you'd work it out in one click anyway.
Top comments (0)