はじめに / 対象と前提
Claude Code に権限を広めに渡して長時間動かしていると、「これだけは絶対に実行してほしくない」コマンドが出てくる。rm -rf や git push --force を毎回目視で弾くのは現実的でないので、自分は PreToolUse hook でツール実行前に機械的にブロックする仕組みを入れた。この記事はその実装手順とハマりどころのまとめ。
- 想定読者:Claude Code で hooks を使い始めた/これから入れる Web エンジニア
- 前提環境:Claude Code v2.x / Python 3.13 / macOS(Linux でも同様)
- hooks の基本(
settings.jsonに書く・スクリプトの stdin に JSON が流れてくる)は既知とする
TL;DR
- PreToolUse hook を使うと、Claude がツールを実行する直前に割り込んで、拒否(
deny)・自動許可(allow)・ユーザー確認(ask)を返せる - 判定は stdout に
hookSpecificOutput.permissionDecisionを含む JSON を吐いて返す。exit code 2 でも止められるが、3 択の使い分けをするなら JSON 形式が必要 -
matcherはツール名にしか効かない。「rmを含む Bash だけ」のような絞り込みはスクリプト側でtool_inputを見る
手順 / 動かし方
1. settings.json に hook を登録
~/.claude/settings.json(プロジェクト単位なら .claude/settings.json)に追記する。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 ~/.claude/hooks/guard.py" }
]
}
]
}
}
2. ガードスクリプト本体
stdin に飛んでくる JSON の tool_input.command を正規表現で検査し、危険パターンなら deny を返す。
#!/usr/bin/env python3
import json
import re
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
DENY_PATTERNS = [
(r"\brm\s+-[a-zA-Z]*r[a-zA-Z]*f", "再帰強制削除は禁止。個別ファイルを rm するか、ゴミ箱移動を提案して"),
(r"\bgit\s+push\s+.*--force", "force push は禁止。--force-with-lease か通常 push を検討して"),
(r">\s*/dev/sd", "デバイスへの直接書き込みは禁止"),
]
for pattern, reason in DENY_PATTERNS:
if re.search(pattern, command):
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}, ensure_ascii=False))
sys.exit(0)
sys.exit(0) # 何も出力しなければ通常の許可フローに流れる
3. 動作確認
新しいセッションを立てて、Claude に「/tmp/work ディレクトリを rm -rf で消して」と頼む。実行前に hook が発火してブロックされ、permissionDecisionReason の文字列が Claude にフィードバックされる。自分の環境では Claude が「rm -rf は拒否されたので、中身を個別に削除します」と代替案に切り替えたところまで確認できた。
逆に読み取り専用コマンドを自動許可して確認ダイアログを減らすこともできる。permissionDecision を "allow" にするだけで、git status や ls のたびに出る確認をスキップできる。判断に迷うパターンは "ask" にしてユーザーに委ねる。
ハマりどころ
1. matcher はツール名にしか効かない
最初に "matcher": "rm.*" と書いて全く発火せず悩んだ。matcher がマッチする対象は Bash / Edit / Write といったツール名であって、コマンド文字列ではない。rm.* というツール名は存在しないので、単に何も起きない(エラーも出ない)。コマンド内容での絞り込みは必ずスクリプト内で tool_input.command を読んで行う。一方ツール名側は正規表現が使えるので、"Edit|Write" のような複数ツール指定は matcher でやるのが正しい。
2. permissionDecision は hookSpecificOutput の中に入れる
古い記事ではトップレベルに {"decision": "approve"} / {"decision": "block"} を返す形式が紹介されていることがある。これは旧形式で、現行は hookSpecificOutput.permissionDecision に allow / deny / ask を入れる。
厄介なのは、JSON の形式を間違えても黙って無視されて素通りすること。エラーにならないので「入れたつもりのガード」が実は効いていない事故が起きる。ブロックが本当に効くかは必ず実弾(危険コマンドを依頼してみる)で確認すること。
なお exit code 2 でもブロック自体はできて、その場合は stderr の内容が Claude へのフィードバックになる。ただし allow / ask は表現できないので、3 択を使い分けるなら exit 0 + stdout JSON の一択。
3. settings.json の編集は実行中セッションに即反映されない
hooks の設定はセッション開始時に読み込まれるため、稼働中のセッションの途中で settings.json を書き換えても反映されない。「パターンを直したのにまだ突破される」と思ったら、だいたいこれが原因だった。修正後は新しいセッションを立て直すか、/hooks メニューで現在有効な hook を確認する。
背景・補足
デフォルトの permission システムだけでも大半のケースは守れる。PreToolUse ガードが効くのは、自律的に長く動かすために許可リストを広めに取りつつ、特定パターンだけピンポイントで止めたいとき。permissionDecisionReason は Claude 側に渡るので、「なぜダメか+何をすべきか」まで書いておくと、Claude が代替手段へスムーズに切り替えてくれる。禁止だけして理由を書かないと、Claude が別の書き方(例:rm -fr や変数展開)で再試行してくることがあるので、パターン側も表記ゆれを意識して書く。
まとめ
- PreToolUse hook で、ツール実行の直前に
deny/allow/askを返して介入できる -
matcherはツール名専用。コマンド内容の判定はスクリプト内でtool_inputを見る - 返す JSON は
hookSpecificOutput.permissionDecision。形式ミスは黙って素通りするので、実際にブロックされるかの動作確認が必須 -
settings.jsonの変更は既存セッションに反映されない。直したら新セッションで確認