My guard read the tool's name, never its arguments
Contents
Builds on My guard blocked bash -c. Models write bash -lc., which explains the guard and what it hard-denies.
Every rename asked. Every comment asked. Every retyped variable stopped the agent and waited for me to click, and the binary it was all about never changed once. That was my guard's Ghidra policy doing exactly what I had written.
My agent-config templates give six terminal coding agents a Ghidra MCP server for reverse engineering, off by default and available on demand: pyghidra-mcp 0.2.7, GhidraMCP 2.0 or ReVa 7.3.1. The same guard that hard-denies destructive shell commands also decides about these tools, and its Ghidra policy was cautious: an allowlist of read verbs (get_, list_, decompile_ and a few more), a deny for deleting a binary, and a question for everything else.
Almost everything should run#
I went through the full tool lists: 25 tools in pyghidra-mcp, 30 in GhidraMCP, about 95 in ReVa. Reverse engineering edits the Ghidra project, never the binary being studied. Renaming a function, retyping a variable, adding a comment, importing files, re-running analysis, changing the processor, editing a structure, diffing, checking in, even writing a script: all of that changes my notes about the target, not the target. So the new policy lets almost all of it run. Running a script keeps asking, because a script runs whatever code it contains. Deleting a binary from the project stays denied.
Two tools needed a closer look: pyghidra-mcp's import_binary and ReVa's import-file. They import whatever file path the agent names. Here is pyghidra-mcp's, from the 0.2.7 wheel:
@mcp_error_handler
def import_binary(binary_path: str, ctx: Context) -> ImportRequestResult:
"""Import a binary into the project from a file path."""
pyghidra_context: MCPContext = ctx.request_context.lifespan_context
return pyghidra_context.import_binary_backgrounded(binary_path)
Any path. Name ~/.ssh/id_ed25519, then call read_bytes on it, and whether the key comes back is up to the server's own loader checks, not to anything of mine. My guard's secret-file rules are there for exactly that key when a shell command reads it. This route never involves a shell, and none of those rules ever sees the path.
So the imports went back on the keep-asking list. Then I asked myself the comfortable question: wouldn't the guard's secret-path rules deny that import anyway?
They would not.
The check only saw the name#
The guard's Ghidra function took one input, the tool name, and returned allow, ask or deny from it. The arguments never reached it. No path rule looked at binary_path, because nothing handed binary_path to a path rule. The only thing in my guard between an agent and an imported SSH key was that the import tools happened to be on the ask list, and the first version of the plan had let imports run too. I had built a lock, tested the lock thoroughly, and left the side door without one.
The function was mostly right about the one thing it was built to look at, the name. Nobody had asked it anything else.
Paths first, names second#
The fix gives the check the whole call: ghidraCheck(rules, tool, input, cwd). Before the name-based verdict, it runs the guard's read rules over every path-like argument, both the named path keys and any string value that looks like a path. A secret path denies the call whatever the tool is called. The pi and omp extensions, the OpenCode plugin and the Hermes and Claude Code hook all go through it, and the case file holds both guard implementations to the same verdicts:
| Call | Verdict |
|---|---|
import of ./firmware.bin | allow |
import of ~/.ssh/id_ed25519 | deny |
import of ~/.aws/credentials | deny |
Then the same three-variant check as in The model that only says RUN, rerun on Claude Code 2.1.309 against the new rules, with the fake MCP server's call log as the verdict: bypass mode, Manual mode (default) under -p, and bypass with Claude Code's own deny for the delete tool removed. The reads ran, and so did a rename and import_binary ./fw.bin. The ~/.ssh import was refused by the guard hook. run_script didn't run: it is an ask, and -p has no one to ask. delete_project_binary was refused, and with Claude Code's own deny removed, the hook refused it alone.
Two smaller finds in the same lists#
Reading every tool turned up two more rules that were wrong in the boring direction. pyghidra-mcp's disassemble is as read-only as tools get, and it had been asking for weeks, because the read-verb pattern expected a verb followed by an underscore, and disassemble has nothing after it. And save asked, although it only writes edits that had already passed the guard, and the server saves everything on exit anyway. Asking about it bought nothing but a click.
Of the three finds, the hole is the one I'd have walked past: the two wrong asks were annoying, so someone would have noticed them. A policy that looks only at names is fine until a name stands for "do this to whatever path I give you". Then the name is the least interesting part of the call.
Update, 2026-10-26: one of the six never called it#
"All go through it", above, was true of five agents. Per-agent end-to-end cases with the fake MCP server found the sixth: OpenCode V2's guard plugin checked only shell, edit and read requests, and an MCP tool call reaches its permission hook as a fourth kind of request, so V2 ran the ~/.ssh/id_ed25519 import (46 of 47 cases). V2's own policy still refused the delete. The plugin now runs ghidraCheck on MCP requests too, and the V2 suite passes 47/47. The whole story is in The fix was in five agents. The sixth never called it.
A second correction: "running a script keeps asking" holds when someone is there to answer. In an unattended session that approves asks (OpenCode --auto, Hermes --yolo, pi with HARNESS_GUARD_NO_UI=allow), run_script runs, and a Ghidra script can read any file the account can. omp's guard and Claude Code's -p still refuse it.
Filed under: ai-agents, security, reverse-engineering, ghidra
Read next#
- The fix was in five agents. The sixth never called it.: the end-to-end cases that tested this fix in every agent, and the one that failed.
- My guard blocked bash -c. Models write bash -lc.: the shell side of the same guard, and the command spellings that got past it.
- The model that only says RUN: the fake model behind these checks, and how Claude Code's own permission rules behave in each mode.
- Tests that could not fail: breaking the guard on purpose to see which tests notice.
Sources#
- pyghidra-mcp 0.2.7 on PyPI: the wheel's
pyghidra_mcp/mcp_tools.pydefines the 25 tools, includingimport_binary,read_bytes,disassemble,saveanddelete_project_binary.