Featured image of post Parity means doing the opposite

Parity means doing the opposite

rust-tool-base needed go-tool-base's control over which commands an AI agent may call. Closing the gap meant doing the opposite of what Go did.

rust-tool-base had a gap to close. Its Go sibling, go-tool-base, had shipped a proper system for deciding which of your CLI’s commands an AI agent is allowed to call, and rtb had a blunt on/off switch and nothing more. A parity gap, flagged in an audit, with an obvious-looking fix: go and do what gtb did. So I read what gtb did, and then I did the opposite. Twice, as it turned out.

First, the thing being gated, because it’s easy to wave past. A modern CLI built on either framework can expose its commands as MCP tools, which is the protocol that lets an AI agent call them. That’s genuinely useful, an agent that can run your tool’s commands on your behalf, but it comes with an obvious knife-edge: some of your commands really should not be a thing an agent can decide to invoke. The ones that delete, that deploy, that spend money, that rotate a key. So you need to gate exposure: a way to say, per command, “this one is fair game for the agent, that one absolutely is not.”

gtb had built the full version of this and rtb hadn’t. gtb’s model is a tri-state per command, Inherit, Exposed or Excluded, with a resolver that walks up the command tree so a subcommand inherits its parent’s setting unless it says otherwise, plus the runtime verbs to flip a command on or off and threading so the scaffolder writes the right setting into new commands. Good, complete, hierarchical. rtb, by contrast, had a single flat method, mcp_exposed() -> bool, no hierarchy, no subtree semantics, no nuance. The gap was real and the structure gtb had was the right structure.

So rtb got it: the same tri-state, Inherit / Exposed / Excluded, the same ancestor-walk resolver, the same runtime enable/disable mcp, the same scaffolder threading. On the shape of the feature, it’s parity, deliberately. That part was copying, and it should be.

The default is where I turned around and walked the other way. gtb’s resolver defaults to exposed: a command with no explicit decision is an MCP tool, and you reach for Excluded to take the dangerous ones back out. rtb’s resolver defaults to hidden: an unresolved command is not an MCP tool, and you reach for Exposed to let the safe ones in. Same three states, opposite resting position. gtb is opt-out; rtb is opt-in.

That’s not an accident of translation, it’s the whole argument. Think about the failure you’re actually guarding against, which is forgetting. On a default-exposed system, the day you add a new command and forget to think about MCP, that command is automatically an agent tool. If it happens to be a destructive one, you’ve handed an agent a loaded command by omission, and nothing told you. On a default-hidden system, the same forgotten command is simply invisible to the agent until you deliberately turn it on. The cost of forgetting is “the agent can’t use a command it could have”, which is mildly annoying, instead of “the agent can use a command it shouldn’t have”, which is a security hole. Opt-in fails safe. For the specific case of handing command execution to a non-deterministic agent, I’ll take the smaller attack surface every time, even though it means diverging from the sibling I’m meant to be matching.

And inverting the default inverts a second thing too, the actual day-to-day work of using it. On gtb you curate by subtraction: everything’s in, you go through and pull the risky ones out, and the mental model is a blacklist. On rtb you curate by addition: nothing’s in, you go through and let the safe ones in, and the mental model is an allowlist. Two systems with an identical tri-state enum, and yet the verb a tool author actually reaches for is the opposite one. Match the feature, flip the default, and the workflow flips underneath you without anyone choosing it.

The reason I find this worth writing down is that “parity” sounds like it means “copy,” and most of the time it does. rtb spends a lot of its life faithfully carrying go-tool-base’s ideas across to Rust, and that’s the job. But parity is about matching capability, not cloning choices, and now and then the most faithful thing you can do is keep the structure and reverse a decision, because your sibling made that decision for its context and yours is different. Here the difference is that I wanted the safer default for the part of the system that hands real tools to an AI. The most faithful way to reach parity was to build the same machine and run it in reverse.

Built with Hugo · Theme Stack designed by Jimmy