The check_spend tool breaking under loops isn’t really tool-vs-hook — it’s that anything the agent can call sits inside the boundary it’s supposed to guard, so under a retry loop the guard is just one more thing the loop spins through. A native before_tool_call deny would be a thinner version of the same problem: still per-call, still reasoning about cost after the decision to act.
The limit that actually holds lives in the harness, outside the loop, and gates on action class rather than cost — which operations are irreversible or expensive-by-category — with a hard stop between phases the runner enforces, not the model. Framed that way the question stops being “can I deny this call” and becomes “which classes need a gate, and where’s the phase boundary the agent can’t roll past on its own.”
Happy to share how I structure those phase gates if useful — spent a while getting the STOP-and-report boundary right for destructive jobs