Autopilot Labs

Three guards to add before you let an agent run unattended

7 August 2026

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.

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.

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 ready-made version is below.

£22.00

Agent Guardrails Kit

Three dependency-free modules that cap runs, enforce file scope, and log every cycle for an unattended agent.

Get it