Claude Codeはgit pushやファイル削除も人に代わって実行します。危険な一手を実行前に止める入り口が、PreToolUseフックです。本記事は仕組みとPythonコード、テストの書き方、誤検知を減らす調整の考え方を、公式ドキュメントと自社実装の実例で示します。

01Claude CodeのPreToolUseフックで危険コマンドを遮断する|できるようになること

判定結果は allow(許可)・deny(拒否)・ask(人に確認)・defer(通常の権限設定に任せる)の4種類で返します。

PreToolUseフックの判定は、どの4方向に分かれ、どこが戻せないか PreToolUseフックの判定フロー図。左端でClaudeがツール呼び出しを試み、PreToolUseフックが発火し、スクリプトがtool_inputを判定する。結果はallow・ask・deny・deferの4方向に分岐し、allowは即実行、askは人に確認、deferは既存の権限設定に従う。denyは実行されず理由が返るが、exit code 0のJSON出力とexit code 2のstderrという2つの経路がここへ合流する。実行後は人が気づいても、操作そのものは戻らない。 FLOW PreToolUseは3ステップを経て、4つの行き先に分かれる 時間の流れ → Claudeがツール呼び出しを試みる PreToolUseフックが発火 スクリプトがtool_inputを判定 allow 即実行される ask 人に確認が出る deny 実行されず理由が返る defer 既存の権限設定に従う exit 0:JSON出力 exit 2:stderr 実行後は人が気づいても、操作そのものは戻らない
判定は3ステップの先で4方向に分岐。deny行きにはexit code 0(JSON)とexit code 2(stderr)の2経路があり、実行後は戻せない

02前提:PreToolUseを動かすための環境とアクセス権限

今回はBashコマンドが対象です。.claude/settings.json(プロジェクト共有)か~/.claude/settings.json(個人設定)への書き込み権限が要ります。

スクリプトを動かすpython3など、commandの実行体が使える環境も必要です。設定ファイルは変更検知で自動再読込されるため、Claude Codeの再起動は不要です(出典: Claude Code公式)。

スコープ置き場所優先度
Managed組織の管理ポリシー最優先(上書き不可)
コマンドライン引数セッション起動時の指定2番目
Local.claude/settings.local.json3番目
Project.claude/settings.json4番目
User~/.claude/settings.json最下位

検証環境はclaude-opus-5・Claude Code v2.1.x系・macOS 15、検証日は2026-07-28です。

03危険コマンドの条件をPreToolUseスクリプトに書く

matcherが絞るのはツール名だけで、コマンドの中身はスクリプト側で判定します。次のdanger-guard.pyは標準入力のJSONからtool_input.commandを読み、危険なコマンドだけdeny・askで返す最小構成です。

#!/usr/bin/env python3
"""danger-guard.py — Bash用PreToolUseフック。
実行前のコマンド文字列を判定し、危険なものだけdeny/askで返す。
該当なしのときは何も出力せず終了する(=通常の権限設定に任せる)。
"""
import json
import re
import sys

# 即座に止める操作。動詞と対象の組み合わせで見て、誤検知を抑える。
DENY_RULES = [
    (re.compile(r"\brm\s+-rf\s+/(?:\s|$)"), "ルート直下へのrm -rfは遮断します"),
    (re.compile(r"\bgit\s+push\b[^\n]*--force\b"), "force pushは人間の承認が必要です"),
    (re.compile(r"\bgit\s+reset\s+--hard\b"), "reset --hardは未コミットの変更を消します"),
]

# 誤検知が起きやすい操作。denyせず、人への確認に留める。
ASK_RULES = [
    (re.compile(r"\bfind\b[^\n]*-delete\b"), "find -deleteは対象範囲を確認してから実行してください"),
]


def decide(command: str):
    for pattern, reason in DENY_RULES:
        if pattern.search(command):
            return "deny", reason
    for pattern, reason in ASK_RULES:
        if pattern.search(command):
            return "ask", reason
    return None, None


def main() -> None:
    payload = json.load(sys.stdin)
    command = payload.get("tool_input", {}).get("command", "")
    if not isinstance(command, str) or not command:
        return
    decision, reason = decide(command)
    if decision is None:
        return
    print(json.dumps({
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": decision,
            "permissionDecisionReason": reason,
        }
    }))


if __name__ == "__main__":
    main()

該当ルールが無ければ何も出力せず終了コード0のまま戻り、Claude Codeは通常どおり実行します。deny・askも標準出力にJSONを書くだけで終了コードは0のままです。公式サンプルスクリプトも同じ方式です(出典: Claude Code公式)。

コマンド文字列は、DENY→ASKのどちらにも合わないとき何が起こるか danger-guard.pyのdecide()の分岐図。コマンド文字列をDENY_RULES(rm -rf /・push --force・reset --hard)と照合し、一致すればdenyで終了する。一致しなければASK_RULES(find -delete)と照合し、一致すればaskになる。どちらにも一致しなければNoneが返り通常実行される。安全なコマンドが誤ってdenyに一致すれば作業が止まるだけだが、危険なコマンドがどれにも一致しなければ保護されないまま実行される。 BRANCH DENY→ASKの順で照合し、どちらにも外れると素通りする コマンド文字列(入力) DENY_RULESと照合 はい(一致) deny+reasonで終了(実行されない) いいえ ①rm -rf / ②push --force ③reset --hard ASK_RULESと照合 はい(一致) ask+reasonを返す(許可すれば実行) いいえ find -delete None → Claude Codeが通常実行 動詞と対象の組合せのみ一致(誤検知回避) 誤判定のとき、何が起きるか 誤検知:安全なコマンドがdenyに一致 → 正当な作業が止まる(安全側のロス) 見逃し:危険なコマンドがどれにも不一致 → 保護されないまま実行される ルールが拾わない限り、危険なコマンドも黙って実行される
danger-guard.pyのdecide()関数が判定する分岐図

04settings.jsonへ実行前フックを登録する手順

スクリプトを置いたら、.claude/settings.jsonhooks.PreToolUseに登録します。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/danger-guard.py"
          }
        ]
      }
    ]
  }
}

matcherは完全一致・パイプ区切りのリスト・正規表現の3通りです(出典: Claude Code公式)。Bashは完全一致、Edit|Writeはリスト、mcp__.*は正規表現です。

05PreToolUseフックの判定をテストコードで確認する

スクリプトをsettings.jsonへ登録する前に、単体でテストします。危険なコマンドを1件、安全なコマンドを1件、最低でもこの2種類は用意します。

import json
import subprocess


def run_hook(command: str) -> subprocess.CompletedProcess:
    payload = {"tool_input": {"command": command}}
    return subprocess.run(
        ["python3", "danger-guard.py"],
        input=json.dumps(payload),
        text=True,
        capture_output=True,
    )


def test_blocks_force_push():
    result = run_hook("git push origin main --force")
    assert result.returncode == 0
    output = json.loads(result.stdout)
    assert output["hookSpecificOutput"]["permissionDecision"] == "deny"


def test_allows_normal_push():
    result = run_hook("git push origin main")
    assert result.returncode == 0
    assert result.stdout.strip() == ""

2つ目のテストは標準出力が空であることを確認します。空でなければ、通常のコマンドまで誤ってdenyしている証拠です。ダミーの危険コマンドだけでなく、日常的に打つ安全なコマンドも同じ数だけテストに残します。

06誤検知を減らすときに、PreToolUseの調整で見るべき観点

標準出力とexit codeの扱いを間違える

スクリプトの標準出力にはJSON以外を書けません。シェルの起動プロファイルの出力が混ざると解析に失敗します。ブロックできるのはexit code 2だけです。exit code 1は非ブロッキングのまま呼び出しが続くため、例外で落ちて意図せずexit 1になっていないかをテストで確かめます(出典: Claude Code公式)。

権限ルールとPreToolUseフックを混同する

観点権限ルール(permissionsPreToolUseフック
書き方Bash(rm -rf*)のようなパターン文字列Pythonなど任意言語のスクリプト
判定できる範囲コマンドのプレフィックス一致正規表現・外部ファイル参照など任意のロジック
設定場所settings.jsonpermissionssettings.jsonhooks.PreToolUse
向く用途既知の危険パターンを素早く止める状況に応じた判定、deny・askの使い分け

WEBMARKSの本番設定は両方を併用しています。permissions.denyにはBash(rm -rf*)Bash(git push --force*)のような既知パターンを置いています。PreToolUseフックには、機密ファイルへのアクセス遮断や難読化対策などプレフィックス一致では書けない判定をまとめています(2026-07-28にリポジトリを直接確認)。

全部denyにすると、誤検知で仕事が止まる

境界線上のコマンドまでdenyにすると正当な作業までブロックされます。確信度が低い判定はaskに落とし、ユーザーが許可すれば実行が続く余地を残します(出典: Claude Code公式)。

WEBMARKSの本番フックも、動詞と対象の組み合わせで判定する方式です。この方式は誤検知を減らせる反面、組み合わせの網羅は設計者の想定に依存し、想定外の組み合わせは通ってしまいます。この方式だけを最終防衛線にせず、権限ルールと重ねて運用しています。2026-07-28時点で1本のスクリプトに20種類超の拒否条件が積み重なり、対応するテストファイルで検証しています。

07動作確認の方法|PreToolUseが危険コマンドを遮断したかの判定基準

動作確認は/hooksとデバッグ起動で行います。/hooksと入力すると設定済みフックを読み取り専用で一覧でき(出典: Claude Code公式)、個別の実行内容は--debug起動時のログに残ります。

本記事の執筆中にも、保護対象のパスを含むコマンドを打った瞬間にPreToolUseフックが発火し、その場でdenyされました(2026-07-28実測)。返ってきた拒否理由はそのままエラーとして表示され、コマンドは実行されていません。

判定基準は次の4つです。

  • deny用・allow用のテストコードが両方グリーンである
  • /hooksにスクリプトのパスとmatcherが表示されている
  • 実際に危険なコマンドを打ち、permissionDecisionReasonの内容がそのまま画面に出る
  • 安全なコマンドを打ち、標準出力・stderrともに何も出ない

08FAQ

denyにしたコマンドを、Claudeは別の書き方で試せますか

denyは最終判定で、Claudeは同じ呼び出しを再試行できません(出典: Claude Code公式)。別の書き方で渡し直せば、その文字列を改めて判定します。

個人設定と共有設定、どちらにフックを書くべきですか

チーム全員に効かせたい危険コマンド遮断はProject(.claude/settings.json)でGit共有します。個人の一時的な上書きだけLocal(.claude/settings.local.json)に置きます。優先順位は前提の章の表のとおりです(出典: Claude Code公式)。