← All guides

Agent Loop Safety: Hooks, Budgets, and Evaluators

A loop running claude -p with --dangerously-skip-permissions has no human between the agent and your filesystem. Something mechanical has to do the review a human is not there to do. Three controls cover it: hooks veto bad commands before they execute, budgets cap what a bad run can cost, and evaluators reject bad results before the harness accepts them. Isolation sits under all three. Working scripts for each below.

The threat model

An agent optimizing for its exit condition takes any shortcut you did not forbid. Almost none of the failures are malice. Five recur:

FailureWhat it looks like
Reward hackingGoal: "tests pass." The agent deletes the failing test, or adds .skip. Both satisfy npm test.
Scope creepSent to fix an auth bug. Twelve iterations later it is "refactoring" the payment module because a type error led it there.
Destructive commandsgit checkout . to "clean up," rm -rf on the wrong path, a force-push over a colleague's commits.
Data exfiltrationReads .env and pastes the contents into a commit message, log line, or PR description. Usually careless; occasionally prompt injection from content the loop ingested.
Runaway spendThe exit condition is unsatisfiable, the loop does not know it, and it retries the same failed approach for six hours.

Each layer below kills a different subset.

Layer 0: Isolation — decide the blast radius first

Before hooks or budgets, decide what a worst-case iteration can destroy:

# Worktree: cheap, good default for code loops
git worktree add ../loop-run -b loop/attempt-1
cd ../loop-run && claude -p "$(cat prompt.md)" --dangerously-skip-permissions
Isolation levelWhen it is enough
WorktreeMost code loops on /type/goal and /type/ralph. A bad run costs one git worktree remove.
Container / throwaway VMRequired when the loop runs untrusted input (scraped content, third-party PRs) or has network access.
No production credentials, everA loop that needs prod data gets a snapshot. A loop that deploys goes through CI — ship-pr-until-green drives everything through the PR pipeline and never touches infrastructure directly.

Layer 1: Hooks — block bad actions in real time

A PreToolUse HOOK, DECIDING IN REAL TIMEAgent issuesa shell commandHook inspectsmatches rm -rf, curl|sh?DENY — never runsAllow — proceeds
A PreToolUse hook inspects every command before it runs — dangerous patterns like rm -rf or curl|sh are denied outright, everything else is allowed through.

Claude Code hooks run your code on the agent's lifecycle events. A PreToolUse hook fires before every tool call and can veto it. The harness enforces the veto; the agent cannot argue with an exit code.

.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "./hooks/deny-dangerous.sh" }]
      }
    ]
  }
}

hooks/deny-dangerous.sh — exit code 2 blocks the call and tells the agent why:

#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command')
deny='rm -rf|git push --force|git push -f|curl[^|]*\|\s*(ba)?sh|git reset --hard|chmod -R 777|> \.env'
if echo "$cmd" | grep -qE "$deny"; then
  echo "Blocked by policy: $cmd" >&2
  exit 2
fi
exit 0

The same denylist matters when you publish loop content, not just when you run it. Our ingest pipeline default-rejects any submitted loop containing rm -rf, curl | sh, force-pushes, or secret-reading commands, for exactly this reason.

Two more hooks belong in every harness:

  • PostToolUse on Edit/Write — run the formatter and a fast lint on any file the agent touches, so drift is corrected the moment it happens.
  • Stop hook — verify the agent wrote its handoff notes before the session ends. ralph-guardrails-learning builds on this pattern, appending learnings every iteration.

Layer 2: Budgets — bound the cost of being wrong

The harness enforces budgets. Never the model — the model is the thing being budgeted.

MAX_ITER=15
DEADLINE=$(( $(date +%s) + 3600 ))   # 1 hour wall clock

for i in $(seq 1 "$MAX_ITER"); do [ "$(date +%s)" -gt "$DEADLINE" ] && { echo "budget: time"; break; }

timeout 15m claude -p "$(cat prompt.md)" --dangerously-skip-permissions

if npm test && npm run lint; then echo "goal met on iteration $i"; exit 0 fi done echo "budget exhausted — leaving notes in progress.md for human review" exit 1 ```

Budget every axis independently: iterations (the outer loop), wall clock (the deadline check), per-iteration time (timeout 15m, so one hung command cannot eat the whole budget), and spend, via your API console's limits. A stuck loop should end as a report in progress.md, not as a bill.

This is the layer people skip, and we can measure it. Every loop in this directory is scored on six prompt signals:

SignalPoints
Not dangerous (no rm -rf, force-push, pipe-to-shell)+40
Exit condition+20
Turn cap+15
Check command+10
Scoped+10
Human gate+5

Across the published corpus, the two budget signals — a stated exit condition and an iteration cap — are consistently the rarest. They are also decisive by construction: the arithmetic above makes an A impossible without both, and a loop carrying either one cannot land at D.

One addition pays for itself fast: a no-progress detector. If the diff between iterations is empty twice in a row, the loop is spinning — stop early and page a human. That is the moment to send an email, and giving agents a clean way to send one is what ConnectMyEmail is for.

Layer 3: Evaluators — judge the result before accepting it

Hooks catch bad actions; evaluators catch bad outcomes. Before the harness accepts an iteration's work, an independent check answers one question: did the loop achieve the goal legitimately?

The rule: evaluators are default-fail. If a check cannot verify the work, it rejects the work.

evaluate() {
  # 1. Goal actually met
  npm test && npm run lint || return 1

# 2. No test attrition — the classic reward hack before=$(git show origin/main:test-manifest.txt | wc -l) after=$(npm test -- --listTests | wc -l) [ "$after" -ge "$before" ] || { echo "FAIL: test count dropped"; return 1; }

# 3. Diff stayed in scope git diff --name-only origin/main | grep -vE '^(src|tests)/' && \ { echo "FAIL: out-of-scope files touched"; return 1; }

# 4. No suppressions smuggled in git diff origin/main | grep -E '\.skip\(|@ts-ignore|eslint-disable' && \ { echo "FAIL: suppression added"; return 1; }

return 0 } ```

For fuzzier goals, add a second-model review: a separate claude -p call with read-only access that grades the diff against the original intent and returns PASS/FAIL. Keep it separate from the worker — a model grading its own diff passes it. This is the check behind kill-flaky-tests demanding ten consecutive green runs instead of one, and behind reach-coverage-target confining the agent to tests/.

The stack, assembled

DEFENCE IN DEPTH — THE SAFETY STACKLayer 3 · Evaluatorsjudge the result before accepting itLayer 2 · Budgetsbound the cost of being wrongLayer 1 · Hooksblock bad actions in real timeLayer 0 · Isolationdecide the blast radius firsta loop that beats one layer still has to beat the rest
Loop safety is defence in depth: isolation decides the blast radius, hooks block bad actions in real time, budgets bound the cost of being wrong, and evaluators judge the result before it is accepted.
LayerControls
IsolationWorktree or container; no prod credentials
HooksPreToolUse denylist; PostToolUse format + lint; Stop hook requires handoff notes
BudgetsIterations, wall clock, per-command timeout, spend cap
EvaluatorDefault-fail: goal met, no test attrition, diff in scope, no suppressions
On failureStop, write progress.md, notify a human

Every layer here is a few lines of shell. The loop builder generates the whole stack — hooks file, budgeted harness, default-fail evaluator — from your goal and scope, so the bounds exist before the loop runs its first iteration.

Ready to run one? Browse the loop directory →
All SECURITY loops

Receipt & statement archiver

Scan the inbox for receipts, invoices, and statements and file each under a Receipts label — read-only on everything else, deleting nothing.

Open loop →

Auto-unsubscribe agent

Find newsletters you never open and unsubscribe via the List-Unsubscribe header — bulk mailing lists only, never transactional or security senders.

Open loop →

Cadence: weekly. you are my presale-question compiler. read…

Community loop loop for docs, sourced from submission. Verified exit condition, evaluator-gated.

Open loop →

Cadence: weekly. you are my share-of-model tracker. read…

Community loop loop for automation, sourced from submission. Verified exit condition, evaluator-gated.

Open loop →