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
- List every MCP server registered for the agent (
claude mcp list, plus any config-file blocks). - List every operation your hooks deny.
- 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
-
Remove redundant MCP surface. If your agent does GitHub via
gh, it doesn’t also need the GitHub MCP. We deleted it; the guardedghpath became the only merge path. Strongest fix — it removes the door rather than adding a second lock. -
Guard at the tool layer too. If your harness supports
PreToolUsematchers 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 baremcp__githubis treated as an exact string and silently matches nothing, which is its own footgun of this same family.) -
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.
-
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.