โ† All guides
๐Ÿ›กsecurity

Agent Loop Safety: Hooks, Budgets, and Evaluators

Agent Loop Safety: Hooks, Budgets, and Evaluators

Every agent loop runs on the same bargain: you give up per-action review in exchange for autonomy. The loop only pays off if you replace that human review with something mechanical. This guide covers the three layers that do it โ€” hooks (block bad actions in the moment), budgets (bound how much a bad run can cost), and evaluators (judge the result before accepting it) โ€” plus the isolation layer under all three.

Safe loops, not runaway agents. Here's the actual engineering.

The threat model

An agent in a loop with --dangerously-skip-permissions optimizes for its exit condition. Most failures aren't malice โ€” they're the agent finding a shortcut you didn't forbid:

  • Reward hacking. Goal: "tests pass." Shortcut: delete the failing test, or slap .skip on it. Both satisfy npm test.
  • Scope creep. Goal: fix the auth bug. Twelve iterations later it's "refactoring" the payment module because a type error led it there.
  • Destructive commands. git checkout . to "clean up," rm -rf on the wrong path, a force-push over a colleague's commits.
  • Data exfiltration. A loop that reads .env and pastes contents into a commit message, log line, or PR description. Usually careless, occasionally caused by prompt injection from content the loop ingested.
  • Runaway spend. A loop that can't satisfy its exit condition and doesn't know it, retrying the same failed approach for six hours.

Each layer below kills a different subset of these.

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
  • Worktree โ€” bad run costs a git worktree remove. Right for most loops on [/type/goal](/type/goal) and [/type/ralph](/type/ralph).
  • Container / throwaway VM โ€” required when the loop runs untrusted input (scraped content, third-party PRs) or has network access.
  • No production credentials, ever. A loop that needs prod data gets a snapshot. A loop that deploys goes through CI like everyone else โ€” see how [ship-pr-until-green](/loops/ship-pr-until-green) drives everything through the PR pipeline rather than touching anything directly.

Layer 1: Hooks โ€” block bad actions in real time

Claude Code hooks run *your* code on the agent's lifecycle events. A PreToolUse hook fires before every tool call and can veto it โ€” deterministic enforcement, not a polite note in the prompt.

.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 ideas apply when *publishing* loop content, not just running 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 worth having in every harness:

  • PostToolUse on Edit/Write โ€” run the formatter and a fast lint on any file the agent touches, so drift gets corrected the moment it happens.
  • Stop hook โ€” when the session ends, verify the agent wrote its handoff notes (see [ralph-guardrails-learning](/loops/ralph-guardrails-learning) for the pattern of appending learnings every iteration).

Layer 2: Budgets โ€” bound the cost of being wrong

The harness enforces budgets. Never the model โ€” it's 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 โ€” one hung command shouldn't eat the whole budget), and spend, via your API console's limits. A stuck loop should degrade into a report, not a bill.

One addition worth its weight: 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's the moment to send an email, and giving agents a clean way to do that is exactly 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: did the loop achieve the goal *legitimately*?

The cardinal rule: evaluators are default-fail. Can't verify โ†’ reject. An evaluator that shrugs "probably fine" is decoration.

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: an agent grading its own homework gives itself an A. This is exactly the check that makes [kill-flaky-tests](/loops/kill-flaky-tests) demand ten consecutive green runs instead of one, and makes [reach-coverage-target](/loops/reach-coverage-target) confine the agent to tests/.

The stack, assembled

isolation   โ†’ worktree/container; no prod credentials
hooks       โ†’ PreToolUse denylist; PostToolUse format+lint; Stop requires handoff notes
budgets     โ†’ iterations + wall clock + per-command timeout + spend cap
evaluator   โ†’ default-fail: goal met, no test attrition, diff in scope, no suppressions
on failure  โ†’ stop, write progress.md, notify a human

None of these layers is clever. All of them are cheap. Together they turn "agent with root and a dream" into something you can run overnight and review over coffee.

The [loop builder](/builder) generates this whole stack โ€” hooks file, budgeted harness, default-fail evaluator โ€” from your goal and scope. Describe the loop; it'll build the cage.

Ready to run one? Browse the loop directory โ†’