An MCP tool with one argument, script. I asked the OWASP Agent Control Standard (ACS) reference Guardian to judge two calls: echo ciao and echo rm -rf /. It denied both, for the same reason:
{"decision":"deny","reasoning":"mapping.yaml reads tool \"mcp__lab2__exec\"'s policy target from argument \"command\", but this call sent no such argument (it sent: script)","reason_codes":["evaluation_failed"],"policy_references":[]}
The tool was already in the policy manifest, and the hook already sent its calls to the Guardian. It still needed a third entry before any rule read the command.
Part 1 covers the setup and the method: Claude Code with its own model loop, the reference Guardian, the shipped hook, and every protocol frozen with its sha256 before it runs. Part 2 is about the ! prefix. This part is about MCP tools.
Three places
Part 1 ended with a table of three states for an MCP tool. Read as a checklist, it lists where a tool has to be declared before the Guardian judges what it does:
| Where | What it takes | Without it |
|---|---|---|
| The hook settings |
PreToolUse matcher widened from `^(Bash\ |
WebFetch)$ to include mcp__.*` |
| The policy manifest | An entry for the tool, seven lines in the same shape as Bash
|
Deny, runtime_error:tool_unknown, whatever the command |
mapping.yaml |
A by_tool entry naming the argument the policy reads, unless that argument is called command
|
Deny, evaluation_failed, whatever the command |
None of the three is in the shipped configuration for a new MCP tool. Each one fails in a different way.
The first fails open. The other two fail closed, and their denies look like protection. When I drove the hook by hand, echo ciao got the same deny as echo rm -rf / in both cases, so the deny says nothing about the command.
The third place
In part 1 my test tool took an argument called command, and mapping.yaml hands the policy the argument called command by default (policy_target_argument.default). So the manifest entry was enough, and I listed "any MCP tool whose argument isn't called command" under what I didn't measure.
asw84 pointed me to the lookup in the #92 thread, and I measured it. The second tool was mcp__lab2__exec, whose only argument is script. I drove the hook by hand, three repetitions, against three Guardians: the stock one, a small launcher with the same shipped mapping.yaml as a control, and the launcher with one line added to by_tool, mcp__lab2__exec: script.
| Call | No by_tool entry |
With the entry |
|---|---|---|
script: echo ciao |
deny, evaluation_failed
|
allow |
script: echo rm -rf / |
deny, evaluation_failed
|
deny, destructive_shell_command_blocked, offset 5 |
The stock Guardian and the control gave identical results. With the entry, the tool was judged by the same rule as Bash, at the same offset. That was 90 envelopes in total, and the full write-up is my comment on #92.
A comment that predicted the wrong code
The source has a comment above the guard that throws. It said that leaving the argument undefined "would reach AGT as runtime_error:path_missing". asw84 had first read it as a description of what happens. path_missing appears in none of the 90 envelopes: the guard throws first.
asw84 suggested a one-line edit, so the next reader doesn't make the same inference. I opened it as PR #354. It changes one comment line and no code.
The reason code
asw84's second point was about the code on the wire. A wrong mapping.yaml and a policy that could not evaluate both come back as evaluation_failed. In asw84's words, the two "are indistinguishable by machine: only reasoning separates them, and reasoning is prose."
PR #229, which is open and is part of the accepted conformance issue #184, gives one of these failures its own code. I ran the same kind of calls on its head commit, 9d3efa31, three times each:
| Call | Decision | reason_codes |
|---|---|---|
Unregistered tool, command: "echo ciao"
|
deny | tool_unregistered |
| Registered tool, no arguments | deny | evaluation_failed |
Registered tool, only note (the mapping reads command) |
deny | evaluation_failed |
Registered tool, command is an object |
deny | evaluation_failed |
Registered tool, command: "echo ciao"
|
allow | |
Registered tool, command: "echo rm -rf /"
|
deny | destructive_shell_command_blocked |
A tool missing from the manifest now gets tool_unregistered. A missing or unusable argument still gets evaluation_failed. I posted the table on the PR.
A tool with nothing to read
One more shape: an MCP tool that takes no arguments at all. On the commit I tested, fd6ff383, I gave it a by_tool entry naming command, then one naming note. A call with no arguments was denied with evaluation_failed under all four Guardian configurations I ran, three times out of three. The entry itself worked: with by_tool set to note, a call with {"note": "echo ciao"} was allowed.
The guard checks Object.hasOwn(args, policyTargetArgument). A call with no arguments has an empty args, so the check fails whatever argument name the mapping returns. I don't know whether a tool with nothing to evaluate is meant to be denied, so I didn't propose a fix.
What I didn't measure
The script tool and the no-argument tool were measured with the hook driven by hand, not inside Claude Code. Claude Code runs covered only the command tool. The MCP server in the Claude Code runs was a local test stub, not a real authenticated server. PostToolUse still matches only ^Bash$, so MCP output never reaches the result gate, and I didn't measure redaction on it.
PR #229's description also lists wrapped tools/call and a six-hook minimum. On that branch I measured only the reason codes, not which calls reach the Guardian, so the first row of the checklist is measured on fd6ff383 only.
The question I ask now
Part 1 asked which tools cross the control. Part 2 asked where content enters the model's context. This one adds a question about configuration: for each tool, is it declared everywhere the Guardian looks, and what does the deny say when it isn't?
A deny with a generic reason code still blocks. It doesn't tell you whether the policy said no or the deployment never asked it.
Quotes from the ACS source are © The OWASP GenAI Security Project and the ACS contributors, licensed CC BY-SA 4.0.
Top comments (0)