You have accepted that the host owns the list. Now: what goes on it?
The obvious answer is tool names. The obvious answer is wrong, and it is wrong in a way that forces you into a choice with no acceptable side.
Part 1 ยท A name-only gate forces a bad choice
cookie_jar is not a dangerous tool.
cookie_jar { action: "eat" } is.
Same tool. Same name. Same server. One reads a number; the other destroys something that cannot be un-destroyed.
If your rules key on names, you have exactly two options for cookie_jar:
Gate the name
The agent stops and asks before it can even look in the jar.
You are now clicking Approve on action: "look" several times a minute โ which is how click-fatigue starts, and click-fatigue is worse than no gate.
Don't gate the name
The agent empties the jar without asking.
Which is the exact thing the gate exists to prevent.
Neither is acceptable. And the reason is not that the cookie jar is badly designed.
Part 2 ยท Real tools are shaped exactly the same way
| Tool | Fine | Not fine |
|---|---|---|
sql | SELECT * FROM users LIMIT 10 | DROP TABLE users |
github | reading an issue | force-pushing to main |
stripe | fetching a customer | issuing a refund |
shell | ls | rm -rf / |
k8s | get pods | delete namespace prod |
email | listing the inbox | sending to 4,000 people |
In every one of those, the destructive act and the harmless act share a tool name. The name is a namespace โ a rough category of capability โ not a permission.
The verb lives in the arguments, so the gate has to read them.
Part 3 ยท So a rule is a predicate
{
tool: "cookiejar__cookie_jar",
when: 'action is "eat"',
matches: (args) => args.action === "eat",
reason:
"Eating removes cookies from the jar. Cookies cannot be un-eaten, so a human should see the number first.",
}Four fields, and each is pulling its weight:
| Field | Job |
|---|---|
tool | the namespaced name the model sees โ cookiejar__cookie_jar |
when | plain English, for the docs and the UI. Not used in the decision. |
matches | the actual predicate, over the model's arguments |
reason | shown to the human when it fires |
when earns its place
It exists purely so the rule table can be printed โ in the checkpoint script, and in the browser โ without anybody reading JavaScript to find out what the gate does.
npm run mcp:translate # ends with the rule table and a per-tool verdictPart 4 ยท args is untrusted, and the rule must survive that
This is the part people skip, and it is a genuine availability bug waiting to happen.
args is whatever the model produced. Not whatever your schema says โ
the gate runs before dispatch, and the model can emit anything.
export function classifyCall(toolName: string, args: unknown): GateVerdict {
const safeArgs: Record<string, unknown> =
args && typeof args === "object" && !Array.isArray(args)
? (args as Record<string, unknown>)
: {};
for (const rule of RULES) {
if (rule.tool !== toolName) continue;
let hit = false;
try {
hit = rule.matches(safeArgs);
} catch {
// If we cannot tell whether this call is dangerous, that is exactly
// when to ask a human.
return {
decision: "ask",
reason: `Could not evaluate the safety rule for ${toolName}, so it is being treated as dangerous.`,
};
}
if (hit) return { decision: "ask", reason: rule.reason };
}
return { decision: DEFAULT_DECISION };
}Three defensive decisions in there:
args: unknown, deliberately. Typing it as your expected shape would be a
lie, and the compiler would then let you write args.action.toLowerCase()
against something that might be null.
Non-objects become {}. A rule that does args.action === "eat" against
null throws. Normalising first means most rules can be written naively.
A crashing rule fails closed.
Part 5 ยท The three rules, and why they are three different shapes
Project #3's whole list is three entries, deliberately not all the same kind of predicate:
| Rule | Fires when | Why |
|---|---|---|
smash_jar | always | Permanently deletes every cookie and erases the jar's history. No undo, no backup. |
cookie_jar | action is "eat" | Cookies cannot be un-eaten. A human should see the number first. |
cookie_jar | action is "refill" | Resets the jar to exactly 12. If the jar holds more than 12, this silently destroys the difference. |
Row one is the name-only case, and it still exists โ sometimes the whole tool really is dangerous. Reading arguments does not mean you must.
Row two is the argument case.
Row three is the interesting entry, and the one that would not occur to you.
And what is deliberately not on the list
Just as instructive:
cookiejar__jar_history reading is free
legacy__roll_dice nothing it does is hard to undo
legacy__say_hello ditto
cookiejar__secret_code ditto
toolbox__secret_code dittoEvery rule you add spends some of a fixed budget of interruptions. Stopping for something reversible does not buy safety; it buys noise, and noise is what makes people stop reading the cards that matter.
Part 6 ยท Where this goes next
Reading arguments turns out to be necessary but not sufficient.
Project #5 hits a tool where even reading the arguments does not tell you whether to stop, because the thing being controlled is cost โ and cost has to be computed from the arguments rather than matched against them.
The same summarise_week call is 0.22ยข or 6.0ยข depending on one string.
What you now know
- Name-only gates force a choice between click-fatigue and no protection.
- Real tools share a name across safe and destructive uses โ
sql,github,stripe,shell. The verb is in the arguments. - A rule is
tool+matches+reason, plus a plain-Englishwhenso the list can be printed and audited. argsis untrusted: type itunknown, normalise non-objects, and fail closed when a rule throws.- Friendly-sounding overwrite operations belong on the list.
- Every rule spends part of a fixed interruption budget.