Skip to content
MCP Five

The GateProject #321 / 51

Rules read arguments, not names

`cookie_jar` isn't dangerous. `cookie_jar { action: "eat" }` is. The verb lives in the arguments.

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

ToolFineNot fine
sqlSELECT * FROM users LIMIT 10DROP TABLE users
githubreading an issueforce-pushing to main
stripefetching a customerissuing a refund
shelllsrm -rf /
k8sget podsdelete namespace prod
emaillisting the inboxsending 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

lib/approval.ts
ts
{
  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:

FieldJob
toolthe namespaced name the model sees โ€” cookiejar__cookie_jar
whenplain English, for the docs and the UI. Not used in the decision.
matchesthe actual predicate, over the model's arguments
reasonshown 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.

bash
npm run mcp:translate   # ends with the rule table and a per-tool verdict

Part 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.

ts
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:

RuleFires whenWhy
smash_jaralwaysPermanently deletes every cookie and erases the jar's history. No undo, no backup.
cookie_jaraction is "eat"Cookies cannot be un-eaten. A human should see the number first.
cookie_jaraction 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        ditto

Every 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-English when so the list can be printed and audited.
  • args is untrusted: type it unknown, 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.