I clicked Approve on delete_customer. My agent refused anyway.
That is the behavior I want, and it comes from five lines of code where the only trick is the order.
The problem with "a human approved it"
Most agent safety ends at one question: did someone click Approve? That turns your most destructive action into one tired click. Approve the refund, approve the lookup, approve the delete. Oops.
Three permissions, not two
In Module 1 of Ship a Safe Agent, every tool is registered with a permission:
-
allow:
lookup_order,track_package -
ask:
issue_refund -
deny:
delete_customer
The check, and why order matters
export function checkPermission(tool: Tool, approved: Set<string>): Verdict {
if (tool.permission === "deny") return { ok: false, reason: "denied" };
if (tool.permission === "ask" && !approved.has(tool.name)) return { ok: false, reason: "needs approval" };
return { ok: true };
}
Deny is checked before approvals are even read. Here is the step's expected output:
lookup_order no approval: ok approved: ok
issue_refund no approval: needs approval approved: ok
delete_customer no approval: denied approved: denied
track_package no approval: ok approved: ok
Approval unlocks "ask". It can never unlock "deny". Swap the first two lines and one click deletes a customer. No prompt fixes that. Code order does.
Run it yourself
git clone https://github.com/bobbyhalljr/ship-a-safe-agent-module-1
cd ship-a-safe-agent-module-1
npm install
npm run check
No API key. The model is scripted, so every run is deterministic, and each step has an expected.txt to diff against.
The rest of Module 1
After permissions you add skills that keep tools invisible until loaded, a scripted model, runAgent with a step budget, and a replayable event log. Then 3 graded exercises; npm test fails until you finish them.
Top comments (1)
The order argument is right and it's the same rule a card scheme runs: you check whether the transaction class is permitted before you look at whether the customer authenticated. Authentication proves who asked. It was never what made the payment allowed. Your
denybranch above the approval read is that, in five lines.Where I think it bites is one level down.
denyis a property of the tool, not of the call.delete_customeris denied, butissue_refundisask, and no permission attached to a tool name can say £5 and not £5,000. In payments the deny decision is almost always about the arguments — amount, currency, counterparty — which is why it has to sit after argument binding rather than at registration.The bit I'd change is
approved: Set<string>. Keyed ontool.name, one approval ofissue_refundis an approval of every refund for the rest of the session. That is a reusable consent, and reusable consent is the thing regulation spent a decade stamping out — a mandate carries a per-payment cap, a per-period cap and a period, because two of those three don't constrain what people actually worry about. Key the set on the tool plus a hash of the bound arguments and an approval becomes an approval of one call.Doesn't touch your point. Swapping those two lines is still the bug that deletes a customer. It just means the next
asktool that matters will be one where the answer depends on what it was called with, and the Set won't be able to express it.