DEV Community

Cover image for The Guardian denied both commands. It never read either one.
Marco
Marco

Posted on AI-assisted

The Guardian denied both commands. It never read either one.

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":[]}
Enter fullscreen mode Exit fullscreen mode

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)