Skip to content
Go back
3 min read

Your agent's guard hooks don't cover its MCP tools

A security gap in guarded AI coding agents: command-level hooks don't see MCP tool calls, even when the tool performs the exact same action.

TL;DR

If your agent's safety hooks match on Bash commands, an MCP tool that performs the same action bypasses them entirely. Found this auditing a Claude Code agent's tool surface — command-level guards aren't complete unless commands are the only actuator, so enumerate the capability, not the command string.

If you run a coding agent in a permissive mode with deterministic guard hooks — “block git push to main, block gh pr merge, block rm -rf” — check one thing today: does any MCP server you’ve registered expose the same operation as a tool? If yes, your guard probably doesn’t cover it. The example below is Claude Code, but the failure class applies to any agent harness whose policy layer matches command strings while tools call APIs directly.

The 60-second check

  1. List every MCP server registered for the agent (claude mcp list, plus any config-file blocks).
  2. List every operation your hooks deny.
  3. For each denied operation, ask: which registered tool can perform this same action without going through the guarded path?

The setup

Our agent loop runs with broad permissions plus a PreToolUse hook — a shell script that inspects every proposed Bash command and denies the dangerous ones. One of its rules:

if printf '%s' "$CMD" | grep -qE '(^|[;&|]\s*|\s)gh\s+pr\s+merge\b'; then
  deny "Blocked: gh pr merge. Merging is the owner's gate."
fi

Merges were a human-only decision. The hook enforced it deterministically — no matter what the model decided, gh pr merge could not execute. We trusted this for weeks.

The hole

The same environment had a GitHub MCP server registered — redundantly, since the agents did all their GitHub work through the gh CLI. That server exposes a tool named merge_pull_request.

Hooks match on tool names. Ours was wired to fire on Bash (and Write|Edit):

{ "matcher": "Bash", "hooks": [ { "command": ".claude/hooks/safety-guard.sh" } ] }

An MCP tool call is not a Bash call. merge_pull_request goes straight from the model to the GitHub API — no shell, no command string, nothing for the regex to match. The guarded operation and the ungoverned operation were the same action through two different doors, and the lock was only on one of them.

  model ──► Bash("gh pr merge 42")  ──►  [PreToolUse hook]  ──►  DENIED ✅

        └─► merge_pull_request(42)   ──►  ( no hook fires )  ──►  MERGED ✗
                                          GitHub API direct

Nothing bad happened — we found this while auditing resident tool surface for an unrelated reason. But the shape is general:

A guard that enumerates forbidden commands is only complete if commands are the only way to perform the action. The moment a tool performs it directly, the command-level guard is necessary but not sufficient.

Why it’s easy to miss

The GitHub MCP was redundant — every git and GitHub operation our agents actually ran went through the gh CLI, i.e. through Bash, i.e. through the hook. The MCP server contributed exactly two things: resident context cost, and an unguarded merge path. Redundant surface isn’t only token overhead; it’s attack surface that bypasses your controls precisely because you’re not using it and aren’t watching it.

Mitigations, most robust first

  1. Remove redundant MCP surface. If your agent does GitHub via gh, it doesn’t also need the GitHub MCP. We deleted it; the guarded gh path became the only merge path. Strongest fix — it removes the door rather than adding a second lock.

  2. Guard at the tool layer too. If your harness supports PreToolUse matchers on MCP tools by name, replicate every action-gating rule there. Treat the Bash hook and the tool hook as one policy expressed twice.

    { "matcher": "mcp__github__.*", "hooks": [ { "command": ".claude/hooks/mcp-guard.sh" } ] }

    (Claude Code matches MCP tools by regex over the mcp__<server>__<tool> name; the .* is required — a bare mcp__github is treated as an exact string and silently matches nothing, which is its own footgun of this same family.)

  3. Gate by capability, not command string. “This agent may not merge” is a capability statement. Enforce it wherever a merge can originate — CLI, MCP, API — not by pattern-matching one syntax of one of them.

  4. Audit resident tool surface as a security task, not a cost task. List every tool the agent can reach and ask, for each: does this provide a path around a control I rely on elsewhere? We ran this audit for token reasons and found a security hole. They’re the same audit.

The one-liner

Command-level allow/deny lists assume commands are the only actuator. In an MCP world they aren’t. Enumerate your gated capabilities, then enumerate every tool that can perform each one — including the ones you thought were redundant.


Found while auditing a Claude Code agent loop (broad-permissions mode + a deterministic PreToolUse Bash hook), 2026-07-17. The redundant GitHub MCP was removed; the guarded gh CLI path is now the sole merge path.


reply by email

Previous Post
Starting the log