← ブログに戻る

MCP承認ゲートを3層に分けた: allow/ask/denyの運用ログ

この記事を含む総合ガイド Claude Code 実戦運用ガイド

MCPの人間承認ゲートを、私は長いあいだ「1本の分厚いhook」で運用していました。あらゆるツール呼び出しを1つの PreToolUse シェルスクリプトに集めて、そこで正規表現を並べて許可・拒否・確認を切り分ける。書いたときは「シンプルで完結している」と満足していたのですが、3か月経つ頃には正規表現が40行を超え、私自身が「これ、いま何を承認したっけ」となる状態になっていました。

先週、たまりかねて全部書き直しました。1本のhookを、承認の粒度で allow / ask / deny の3層 に割り、Claude Codeの PreToolUse hookが受け取れる permissionDecision の3値と1対1で対応させる、という構造です。この記事は、その運用設計と、書き直し前後で見えたことの記録です。

前提: 巨大な1本hookは何がまずかったのか

MCPを本番運用に載せるとき、私が真っ先に手を伸ばしたのは「危ないツール呼び出しはPreToolUseで人間に確認させる」というhookでした。ざっくり書くとこんな形です。

#!/bin/bash
# ~/.claude/hooks/pre-tool.sh
# 悪い例: あらゆる判断を1本に詰めた古い実装
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if echo "$CMD" | grep -qE 'rm -rf|sudo|:\(\)\{'; then
  echo "危険パターン検出" >&2
  exit 2
fi
if [ "$TOOL_NAME" = "Write" ] || [ "$TOOL_NAME" = "Edit" ]; then
  # ここで何を返すか毎回悩む
  exit 2  # 全部止めていた
fi
exit 0

読み返してみると、まずかったところが次々に見えてきました。

一番大きい勘違いが exit 2 の意味です。公式ドキュメントを斜め読みした私は、これを「人間の承認ダイアログを出す合図」だと思い込んでいました。実物はそうではなく、モデルに理由をフィードバックしてツール実行を強制的に止めるだけ。ユーザー側にダイアログは出ません。「承認ダイアログを出したい」なら別のAPIを使う必要があります (後述)。

判断ロジックが1本にまとまっていたのも痛かったです。書き込み系ツール全部を exit 2 で止めていたので、Claudeは「なぜ止まったか」を毎回同じ理由文字列で受け取り、リトライの品質が悪化しました。

もうひとつ、.claude/settings.jsonallowedTools と役割が重複していました。ツール数の絞り込みと個別コマンドの承認ゲートは別の層でやるべきなのに、両方が同じhookに混ざっていたのです。

3層に割った設計

書き直したあとの構造は、こうです。

Layer 1: allow (自動許可)     → 参照・読み取り系 / 副作用なし
Layer 2: ask   (人間に確認)   → 作成・更新系 / 冪等でない副作用あり
Layer 3: deny  (即ブロック)   → 削除・秘密情報アクセス / 復旧困難

Claude Codeの PreToolUse hookは、標準出力にJSONを返すことで判定を細かく指示できます。使えるのは hookSpecificOutput.permissionDecision の3値で、これがそのまま3層と対応しました。

  • "allow" — 通常の承認プロンプトをスキップして即実行
  • "ask" — ユーザーに承認ダイアログを出して人間に判断を仰ぐ
  • "deny" — ツール呼び出しをキャンセルし、モデルに理由をフィードバック (ユーザーにダイアログは出ない)

exit 2 は「無条件ブロック + モデルへのfeedback」で、ユーザー確認は出ません。人間に聞きたいときは必ず "ask" を返す、というのを最初に叩き込みました。

割り振りテーブル

MCPサーバーが公開しているツールを、副作用と復旧コストで3層に振り分けます。手元のfreee MCPだと、こう分類しました。

操作カテゴリ具体ツール例Layer
参照・読み取りlist_deals, get_deal, get_trial_balance, list_partners, list_walletablesallow
作成・更新create_deal, update_dealask
削除・鍵操作delete_deal, batch_delete, APIキー変更deny

分類の基準は「失敗したときに私が手で戻せるか」です。取引の参照は誤爆しても実害ゼロ、取引の作成は間違えても更新で修正できる範囲、削除は監査ログを追いながら手で復旧しないといけない領域。「復旧に自分の午後が飛ぶかどうか」を目安にすると、境界はかなり素直に決まりました。

Layer 2 (ask) の実装

一番大事なのがLayer 2です。「ここで人間に聞く」の実装が甘いと、3層設計そのものが機能しなくなります。

#!/bin/bash
# ~/.claude/hooks/mcp-ask-layer.sh
# Layer 2: 副作用のあるMCP呼び出しを人間確認に回す
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')

# MCPツールは "mcp__<server>__<tool>" という名前で来る
case "$TOOL_NAME" in
  mcp__freee__create_deal|mcp__freee__update_deal)
    jq -n --arg tool "$TOOL_NAME" '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "ask",
        permissionDecisionReason: ("Layer 2 (作成・更新): " + $tool + " の実行を確認してください")
      }
    }'
    exit 0
    ;;
esac
exit 0  # 該当しなければ通常フローへ

効かせるコツは3行で言えます。

  • 標準出力にJSONを出す、exit 0 で終わる。exit 2 は使わない
  • hookSpecificOutput.hookEventName"PreToolUse" を明示する
  • permissionDecisionReason に理由を入れる。これがそのままユーザーの承認プロンプトに表示される

私は最初、permissionDecision: "ask" を返しているのに何も起きなくて30分溶かしました。犯人はhookをJSONではなく単なるテキストで書いていたことでした。標準出力に返すのはあくまで valid JSON で、パース失敗時は普通のフローに戻ります。エラーではなく黙って通常フローに戻るので、動いていないことに気づきにくい罠でした。

Layer 3 (deny) の実装

Layer 3は「モデルに任せずに、こちら側で止める」ゾーンです。ここは "deny" かexit 2のどちらかを使います。私は、モデルにフィードバックを返して次の行動を促したいので "deny" を選びました。

#!/bin/bash
# ~/.claude/hooks/mcp-deny-layer.sh
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')

case "$TOOL_NAME" in
  mcp__freee__delete_deal|mcp__freee__batch_delete)
    jq -n --arg tool "$TOOL_NAME" '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: ("Layer 3 (削除): " + $tool + " は自動化対象外。手動での実行を依頼してください")
      }
    }'
    exit 0
    ;;
esac
exit 0

"deny" は「ユーザーには聞かない、モデルに理由を返してその場でツール呼び出しを取り消す」動きです。ダイアログは出ません。この点が "ask" との一番大きな違いです。

削除系を "ask" で人間に聞く運用も選べます。ただし私の場合、削除は月次締めのタイミングで別のバッチスクリプトから流すルールなので、対話セッションの中で承認する経路自体を塞ぎました。「聞かれたら押してしまう」問題を、そもそも聞かないことで回避する、という設計上の判断です。

allowedTools との役割分担

Book『MCP実践セキュリティ』の第7章に、freeeの270ツールを10ツールに絞る事例が載っています。差分は .claude/settings.jsonallowedTools で吸収する話で、これは今回の3層設計の外側にあたる別レイヤーです。

{
  "mcpServers": {
    "freee": {
      "allowedTools": [
        "create_deal", "list_deals", "get_deal", "update_deal",
        "get_trial_balance", "list_account_items",
        "list_partners", "list_taxes", "list_walletables", "get_company"
      ]
    }
  }
}

270→10で権限面積を 96%削減 し、残った10ツールを3層 (allow/ask/deny) に配る。この2層構造にしたことで、権限縮小と挙動制御が別のファイルで独立して管理できるようになりました。片方だけ変更したときに、もう片方を見ずに済むのが地味に効いています。

1週間の運用シミュレーション

書き直しが済んで、私はこの3層をローカルで1週間 (2026-08-11〜17) 動かして、どこに何回落ちるかをログに取りました。ここは私の使い方の分布に強く依存する話なので、数字を絶対値で信じないでください。目安として置いておきます。

同じ freee MCPの構成を持って、確定申告直前の帳簿確認セッションを回した想定です。呼び出し総数は約200件。分布はこうなりました。

  • Layer 1 (allow): 参照系が多数 → 約7割
  • Layer 2 (ask): 作成・更新が数十件 → 約2〜3割
  • Layer 3 (deny): 削除系はセッション中に発生せず → 0件

Layer 3が0件で終わったのは、削除操作を対話セッションから外してあるからです。「発生していたら困る」という性質を考えると、denyの存在価値はゼロ件ヒットのときにこそ一番高い、と感じています。

Layer 2の承認ダイアログは、私の体感で1回あたり2〜4秒の待ち (見て確認して押す時間) が乗ります。数十回積み上がるとセッション後半でリズムが崩れて、私は途中でコーヒーを取りに立ちました。ここは自動化の敵ではなくて、自動化の限界線をUIで見せてくれる場所だと捉えるようにしています。押すのが疲れる操作カテゴリがあるなら、それは「そもそも自動でやろうとしていた設計が乱暴だった」というシグナルです。

詰まったところ

書き直しの途中で、いくつか勘違いをしました。備忘です。

1. exit 2 が承認ダイアログを出すと思っていた

冒頭に書いたやつです。exit 2 はブロック + モデルフィードバックで、UI側に確認は出ません。ドキュメントの Exit codes 節と PreToolUse decision control 節の両方を読み合わせないと気づけない構造でした。同じ罠を先日書いた記事 Claude Code auto modeで Bash だけが止まる の hook 設計案でも踏みかけて、そのときは前段で "ask" を返すことで踏み外しをかろうじて避けていました。今回はそこを主軸に据えた形です。

2. hookはJSONを返しても失敗すると黙って無視される

permissionDecision を返しているつもりで、jqの引用符ミスでJSON全体が壊れていて、hookが機能していないのに気づけない時間帯がありました。デバッグの基本は jq -n '...' | jq empty でパース確認しつつ、hookの実行ログを ~/.claude/logs/ あたりに残すことです (公式ではlogは自動保存されないので、hook側で >> ~/.claude/hook.log に書き足すのが早い)。

3. matcherはtool名の正規表現だがMCPツールも同じ枠

mcp__<server>__<tool> という長い名前がそのままmatcherにかかります。settings.json 側で "matcher": "mcp__freee__(create|update|delete).*" のようにhookイベントごとの絞り込みもできるので、上のスクリプトの case を全部消してsettings側で絞る書き方もありました。私はテスト時のログを取りやすくしたかったのでスクリプト側にcase文を残しています。

「巨大1本hook」との差分

書き直し前と後で、変わったのはこれだけです。

  • 前: 40行の正規表現1本、exit 2 で全部止める、モデルへの理由が毎回同じ
  • 後: 3ファイル (allow/ask/denyそれぞれ) に分割、permissionDecision で層ごとに正しい挙動、モデルへの理由が層ごとに違う

コード行数はほぼ変わっていません。分けたことで見通しがよくなった、それだけの話です。3層の境界を先に決めれば、あとはその境界に沿ってツールを振り分けるだけの機械的な作業になり、判断のたびに「これはどの層だっけ」と迷わなくて済むようになりました。

関連記事

参考書籍

権限最小化とワークアラウンドの元ネタは、この Book の第7章です。今回の3層設計は、そこにある「270→10ツールの絞り込み」と「人間承認ゲート」の表を、permissionDecision の3値と接続する形で運用に落とし込んだものです。

MCP実践セキュリティ

ハーネス・エンジニアリング 関連書籍 ハーネス・エンジニアリング ハーネスエンジニアリング 入門 | AGENTS.md 設計・hooks 実装・AIエージェント運用の体系書 書籍ページを見る →