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
.skipon it. Both satisfynpm 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 -rfon the wrong path, a force-push over a colleague's commits. - Data exfiltration. A loop that reads
.envand 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.