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.
What makes that example teachable is that the command carries its own consequence. git push --force origin main has 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.
Most of what I worry about doesn't look like that at all.
Two calls, one of them a disaster
Here are two refund calls.
await payments.refund({
paymentId: "pay_9f2c14",
amount: 4000,
currency: "GBP",
reason: "customer_request",
});
await payments.refund({
paymentId: "pay_9f2c14",
amount: 4000000,
currency: "GBP",
reason: "customer_request",
});
One 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.
Now the part that actually matters: neither of them is inherently wrong.
Forty 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.
Blast radius is a property of the target, not the command
The 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.
An 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:
- Has this payment already been refunded, fully or partially?
- Does the merchant's balance cover it, or does this pull them negative?
- How much has this actor already moved today?
- Is the original payment under dispute, where a refund does something quite different to who ends up liable?
- Is
pay_9f2c14even 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.
"Fine, have the hook go and check"
This 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.
Try building it and you find out what you signed up for.
Your 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.
You 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.
Put the check where the state is
The 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.
We'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.
The agent-side hook still has a job. Just a smaller one than people want to give it.
// The hook classifies and asks. It does not decide.
async function preToolUse(call) {
if (!MOVES_MONEY.has(call.tool)) return allow(call);
// Ask the system that owns the state what this would actually cost.
const effect = await rail.preflight(call.args);
return confirmWithHuman({ intent: call.args, effect });
}
rail.preflight is 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.
It'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.
I wrote more about mandates versus API keys in an earlier post if that thread interests you.
The uncomfortable bit
Your agent framework cannot own your safety story for anything that moves money, and it doesn't matter how good its hooks get.
That'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.
They 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.
So 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.
Has 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.
Top comments (0)