{"slug": "your-agent-s-guardrails-can-t-see-the-money", "title": "Your Agent's Guardrails Can't See the Money", "summary": "A developer argues that guardrails for AI agents that inspect tool-call text cannot catch dangerous financial operations, because the risk often lies in the state of the system rather than the command itself. They illustrate with two identical-looking refund calls that differ by a factor of 1000, and explain that the only reliable check is at the point of authorization, where the full context is available. The developer describes their approach of having the agent-side hook classify and ask, while the actual decision is made by a preflight check on the system that owns the ledger.", "body_md": "There's a post going round this week about agent guardrails that opens with a good story. The author's agent wanted to force-push to main. Not because it was confused. The rebase was stuck, force-pushing would unstick it, and every step in that chain of reasoning was sound. Locally correct, non-locally expensive.\n\nWhat makes that example teachable is that the command carries its own consequence. `git push --force origin main`\n\nhas the danger written into it. You can pattern-match it. You can put it on a list. A hook can read the string and stop.\n\nMost of what I worry about doesn't look like that at all.\n\nHere are two refund calls.\n\n```\nawait payments.refund({\n  paymentId: \"pay_9f2c14\",\n  amount: 4000,\n  currency: \"GBP\",\n  reason: \"customer_request\",\n});\n\nawait payments.refund({\n  paymentId: \"pay_9f2c14\",\n  amount: 4000000,\n  currency: \"GBP\",\n  reason: \"customer_request\",\n});\n```\n\nOne is forty pounds. The other is forty thousand. Same endpoint, same argument names, same shape, same reason code, same everything a pre-execution hook can read off the text.\n\nNow the part that actually matters: neither of them is inherently wrong.\n\nForty thousand might be a perfectly good refund against a perfectly good invoice. Forty might be a refund on a payment that was already refunded an hour ago, which in some ways is the worse of the two. You cannot sort these by looking at them, because the thing that makes one of them a mistake isn't in the call.\n\nThe force-push case works because the danger lives in the verb. Force-push is dangerous in nearly every context. The set of situations where you genuinely want it is small enough to enumerate, so a rule can cover it and the rule stays roughly true.\n\nAn amount-bearing API call is dangerous as a property of the state on the other side. Whether that refund is safe depends on things the caller simply doesn't have:\n\n`pay_9f2c14`\n\neven this merchant's payment?A hook sitting in the agent process knows none of this. It can read intent. Cost is not a property of intent.\n\nThis is the obvious next move and it isn't stupid. Let the guard call the API, fetch the payment, look at the balance, then decide.\n\nTry building it and you find out what you signed up for.\n\nYour hook now needs credentials to read payment state, which means the agent host is holding read access to your ledger in order to protect you from the agent host. It needs to understand refund semantics, dispute states and settlement timing well enough to form a judgement, so your refund rules now live in two codebases that have to agree forever. And it has a window between checking and executing, which is exactly where the interesting failures live. The balance was fine when you looked. Something else landed. Yours goes through anyway.\n\nYou haven't built a guardrail. You've built a second, worse copy of your authorisation service, running in the least trusted process you own, with a cache.\n\nThe version that survives contact is boring. The check goes at the rail, at the point of authorisation, because that's the only place that knows what this actor may move, what it has already moved, and what the target looks like right now.\n\nWe're regulated, so we already had to have that spine. Every movement of money goes through an authorisation step with the full picture and an audit record on the other side. Wiring agents into it didn't mean building something new. It mostly meant resisting the urge to build something *in front* of it and call that safety.\n\nThe agent-side hook still has a job. Just a smaller one than people want to give it.\n\n```\n// The hook classifies and asks. It does not decide.\nasync function preToolUse(call) {\n  if (!MOVES_MONEY.has(call.tool)) return allow(call);\n\n  // Ask the system that owns the state what this would actually cost.\n  const effect = await rail.preflight(call.args);\n\n  return confirmWithHuman({ intent: call.args, effect });\n}\n```\n\n`rail.preflight`\n\nis where the work happens, and it lives on the side that has the ledger. Its whole purpose is so a human sees *\"this refunds £40,000 against an invoice already refunded in full on the 3rd\"* rather than *\"the agent would like to call refund.\"* One of those is a decision. The other is a rubber stamp with extra steps.\n\nIt's also explicitly advisory. Between preflight and execute the world moves. Enforcement stays server side, under a mandate scoped to an amount, a payee and a clock. If preflight and the rail ever disagree, the rail wins.\n\nI wrote more about mandates versus API keys [in an earlier post](https://dev.to/mickyarun/your-mcp-eval-checklist-has-an-auth-row-in-payments-its-the-whole-table-1n3e) if that thread interests you.\n\nYour agent framework cannot own your safety story for anything that moves money, and it doesn't matter how good its hooks get.\n\nThat's not a knock on the hooks. Command-level guards are useful and I'd rather have them than not. For destructive verbs, where the danger is in the word, they're the right tool and they'll catch real mistakes that would otherwise cost someone a weekend.\n\nThey just sit at the wrong altitude for a whole class of call that reads as completely unremarkable and is defined entirely by context the caller doesn't hold.\n\nSo here's the test I'd apply to any guard before trusting it. Could this exact call, byte for byte, be both correct and catastrophic depending on something the guard cannot see? If the answer is yes, that guard is a linter. Useful. Not load-bearing. Put the real check where the state lives.\n\nHas anyone landed on a decent convention for a tool declaring *\"you can't infer my blast radius from my arguments, come and ask\"*? I haven't seen it in the MCP spec, and it feels like the piece that's missing.", "url": "https://wpnews.pro/news/your-agent-s-guardrails-can-t-see-the-money", "canonical_source": "https://dev.to/mickyarun/your-agents-guardrails-cant-see-the-money-35f", "published_at": "2026-08-21 10:53:11+00:00", "updated_at": "2026-08-21 11:16:41.879706+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "ai-infrastructure"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/your-agent-s-guardrails-can-t-see-the-money", "markdown": "https://wpnews.pro/news/your-agent-s-guardrails-can-t-see-the-money.md", "text": "https://wpnews.pro/news/your-agent-s-guardrails-can-t-see-the-money.txt", "jsonld": "https://wpnews.pro/news/your-agent-s-guardrails-can-t-see-the-money.jsonld"}}