The fix was in five agents. The sixth never called it.

Follows My guard read the tool's name, never its arguments, which introduced the fix this post tests.

The last post ended with a fix I was pleased with. My guard used to decide about Ghidra MCP tools by their name alone; now ghidraCheck(rules, tool, input, cwd) runs the guard's read rules over every path-like argument first, so an agent that asks Ghidra to import ~/.ssh/id_ed25519 into its project gets refused. Both guard implementations, TypeScript and Python, had cases for it. One agent, Claude Code, had been probed against a fake MCP server. The post said the pi and omp extensions, the OpenCode plugin and the Hermes and Claude Code hook "all go through it".

That sentence covers six agents with five names, because "the OpenCode plugin" is two plugins, one per OpenCode generation. It was written by someone who had watched one of the six go through it, and was sure about the rest.

Five of the six did.

One case per agent, judged by the server#

My templates configure six terminal coding agents. Builds for everything below: OpenCode V1 1.19.4 and V2 2.1.4, pi 1.4.0, omp 18.12.6, Hermes Agent from source at commit afe3cbd (after v0.22.5), Claude Code 2.1.309. Until now, the end-to-end suite had never made an MCP call: it drove the agents' shell and file tools. The new cases drive Ghidra through each agent's real binary, with a scripted fake model and a fake MCP server registered under the name ghidra.

Same rule as in The model that only says RUN: the fake server's call log decides, because agents wrap MCP results in banners, truncate them or swallow them, and "did the tool run?" is a bad question to ask the agent's output. A case counts as executed only if its tool name is in that log.

The first case runs on the shipped config; then Ghidra is switched on the documented way and the other six run:

CaseMust
list_functions with the shipped confignot run: Ghidra ships off
list_functions, a rename, import_binary ./fw.binrun
import_binary ~/.ssh/id_ed25519not run
delete_project_binarynot run
run_scriptrecord what the agent does

"The documented way" differs per agent, and the suite uses each one: a launcher for OpenCode, a /ghidra on command in pi, a config key for omp, hermes config set for Hermes, an extra --mcp-config for Claude Code.

46 of 47#

AgentResult
OpenCode V145/45
OpenCode V246/47
pi43/43
omp51/51
Hermes51/51
Claude Code51/51

OpenCode V2 ran the key import.

Its guard plugin hooks V2's permission system, and it checked three kinds of request: shell commands, edits and reads. An MCP tool call goes through the same permission hook, but as a fourth kind. The plugin looked at it, found no branch with its name on it, and waved it through, the way a bouncer with a list of three troublemakers waves in everyone else. ghidraCheck existed, and V2's plugin never called it.

The delete still failed, but not because of my guard: V2's own policy in the same template, a hand-written deny for ghidra_delete_*, refuses it. So the layers had quietly divided the work. For the delete, the policy held. For the import, only the plugin could have known the path was a key, and it wasn't looking.

Reading the shape of the request out of a binary#

V2 2.1.4 ships as a compiled binary without type definitions for its plugin API, so I couldn't look up what an MCP permission request looks like. The binary still contains the bundled JavaScript as text, and searching it for the MCP tool wrapper gave the answer: the request carries the action <server>_<tool>, the resource "*", and the tool's arguments under metadata.input.

That is everything ghidraCheck needs. The plugin now calls it before its other checks:

await ctx.permission.hook("evaluate", (event) => {
  const input = (event.metadata as { input?: Record<string, unknown> } | undefined)?.input
  const ghidra = ghidraCheck(rules, event.action.toLowerCase(), input, cwd)
  if (ghidra?.decision === "deny") {
    event.effect = "deny"
    event.message = denialMessage(ghidra)
    return
  }
  // ...shell, edit and read checks as before
})

The claim in the last post was true of the guard library and false of one of its callers. Unit tests can't tell those apart. They call ghidraCheck directly, so they prove the function works, not that every agent's glue code reaches it. The one-agent probe couldn't tell them apart either, because the agent it probed was one of the five. Only a case per agent, through each agent's own hook, could catch the sixth.

Proving the cases can fail#

A blocked case that passes proves nothing until it has been seen to fail. So, before the fix, the suite also ran on copies of the tree with every Ghidra decision removed: both guards' Ghidra verdicts gone, and every native Ghidra deny and ask taken out of the templates. My first copy missed one: OpenCode V1's reverse-engineer agent file carries its own Ghidra rules, and it went on refusing the delete on a tree that was supposed to have no opinions left. With that file stripped too, the key import and the delete ran in all six agents.

pi, for a different reason, needed one more piece removed before its import ran: pi's extension has a generic check for path arguments of any MCP tool, and it refuses the key import on its own. For the key import that is two independent layers in pi; every other agent has the guard alone, and V2, until this fix, had nothing.

The "off" case got the same treatment. On a copy of the templates that ship Ghidra switched on, list_functions ran in four agents. pi and Claude Code have no config entry to flip: Ghidra exists there only when you start it, so that case can't fail on them. It still counts as a pass in their totals, and the testing docs say why.

Two agents, three surprises, before a single call went through#

Getting one MCP call through each agent non-interactively turned up three behaviours I hadn't known about.

OpenCode V2 offers MCP tools only from its second step. opencode run builds its first model request before the MCP servers have connected, so in the first step the Ghidra tools don't exist yet and every call failed as an unknown tool, even with a bare config. The fake model gained a THEN: instruction (make this call after the first tool result), and V2's prompt starts with a sleep 3. In real use it means a one-shot opencode run on V2 can't use an MCP tool in its first step.

Hermes' gateway keeps its old server list. A background gateway started by an earlier hermes chat -q doesn't pick up hermes config set mcp_servers.ghidra.enabled true; five seconds later it still offered no Ghidra tools. hermes gateway stop fixed it.

Hermes couldn't connect to any MCP server at all in my test install: the MCP SDK is an optional dependency group, and my setup had installed Hermes without it. Ghidra ships off, so nothing had ever tried. The setup's install line with that group fixed it.

An ask with nobody there#

The run_script row was the one I had expected least from. My Ghidra policy lets everything run except running a script, which asks, and deleting a binary, which is denied. The suite runs every agent unattended: --auto for OpenCode, --yolo for Hermes, and for pi a setting of my own guard, HARNESS_GUARD_NO_UI=allow, that approves asks when there is no UI. Under those, an ask is approved: run_script ran in OpenCode V1 and V2, pi and Hermes. It stopped only where nobody could answer: omp's guard extension refuses an ask without a UI, and Claude Code's -p denies one even in bypass mode.

A Ghidra script is ordinary Java or Python, and my guard sees only the tool's arguments, never what the script does. So in an unattended session that approves asks (OpenCode --auto, Hermes --yolo, pi with HARNESS_GUARD_NO_UI=allow), "asks" means "runs, and can read any file my account can read". That is not new: python3 script.py is an ask too, with the same result. I left the rule as it is and wrote the consequence into the docs, next to the advice that already covers scripts: for anything untrusted, run the agent inside harness-sandbox (bubblewrap), which starts the MCP server inside the sandbox too. I have worked that out from the wrapper; I haven't run Ghidra in it yet.

The one line that changed#

The fix itself is seven lines in one hook. The number that matters is smaller: the V2 suite went from 46/47 to 47/47, and the case that moved is the one that reached the plugin the way a real session does, through V2's own front door, judged by a log the agent can't wrap.

Filed under: ai-agents, security, testing, ghidra

Sources#

  • OpenCode 2.1.4 (npm package @opencode/cli): the compiled binary, searched as text for the MCP tool wrapper and its permission request.
  • Hermes Agent at commit afe3cbd: the MCP client and the gateway behaviour described above.