Claude Codeに「古いビルドキャッシュだけ消しておいて」と頼んだ深夜、auto-acceptのまま危険な削除コマンドが走りかけていました。承認を全部手動へ戻すと今度は翌朝まで承認待ちで止まる。permissionsのallow/denyだけでは、「このパターンだけ機械的に止めて、それ以外は全部自動化」という線引きが書けませんでした。hooksでこの境界を設計し直した結果、危ない操作だけをブロックしたまま、夜間バッチを壊さずに自動化できています。
この記事では、hooksに任せる/任さないの選定基準を表にまとめ、PreToolUseガードのコピペ完全形(settings.jsonとPython 1ファイル・標準ライブラリのみ)と、設計を間違えたときに壊れる3つの失敗パターンまで書きます。
先に結論: hooksで自動化するかの判断軸
| 判断軸 | hooks向き(自動化する) | hooks外(permissions・人間の判断) |
|---|---|---|
| 判断の性質 | 文字列パターンで機械的に決まる | 文脈を読んで決める |
| 実行時間 | 1回あたり2秒以内 | 検査に1分以上かかる |
| 繰り返し実行 | 何回走っても冪等で安全 | 実行回数自体が状態を変える |
| 失敗時にすること | exit 2で止めて後に人間が見る | 止める理由をモデルに説明する |
左列だけをhooksに置きます。右列をhooksに押し込むと、遅延・無限ループ・承認地獄のどれかが必ず起きます(後述の失敗1〜3と対応します)。
permissionsとhooksの棲み分け
- permissions: 常時の許可・拒否。判断は人間が一度だけ行う(allow/denyの一覧)。
- hooks: イベントが起きた瞬間に外部コマンドが走る。判断はスクリプトが毎回行う。
- 境界: 「絶対に通さない一覧」はpermissions.denyに置く。 オプションやパスの組合せ次第で変わる「条件付きの阻止」はhooksに置く。
この棲み分けをせずhooksだけで全部やろうとすると、拒否一覧が正規表現の中に埋もれ、後から読めない設定になります。
hooksの最小知識(60秒版)
- 設定ファイル:
.claude/settings.json(リポジトリで共有)と.claude/settings.local.json(個人用)。 - 構造: イベント名 → matcher(正規表現でツール名を絞る) → hooks配列(type: command)。
- hookコマンドは stdinにJSON を受け取る(tool_name・tool_input・cwdなど)。
- 終了コード: 0 = 続行 / 2 = ブロック。2のときstderrがそのままモデルへ返され、Claudeは止まった理由と代替案を自力で出す。
- timeout(秒)を指定できる。未指定の長い処理は夜間バッチで刺さる原因になるので必ず明示する。
- カレントディレクトリは実行のされ方で変わるため、ファイル参照は
$CLAUDE_PROJECT_DIRで解決する。
自動化すべき3つ(判断軸の左列どおり)
| # | 対象 | イベントとmatcher | 理由 |
|---|---|---|---|
| 1 | 危険なBashコマンドの阻止 | PreToolUse × Bash | パターン一致で機械的に決まる。削除の実行後に検知しても遅い |
| 2 | 保存直後の整形 | PostToolUse × Edit・Write | 実行が冪等・軽い・モデルへ判断を任せる必要がない |
| 3 | 終了・待機の通知 | Notification・Stop | 止めずに人へ伝えるだけ。モデルの流れを変えない |
やめておくべき3つ(私がすべて経験済み)
| # | やめたこと | 起きること |
|---|---|---|
| 1 | UserPromptSubmitで重い前処理 | プロンプト送信のたびに待たされ、体感が常に遅くなる |
| 2 | Stopで品質チェック全実行 | 夜間ジョブが終わらない。タイムアウトと再実行の悪循環になる |
| 3 | hooksでモデルの挙動を変換する | ブロックを返し続けた結果、モデルが別の抜け道を探し始める |
3番が厄介です。ガードは「止める」までにして、「動かす」をhooksに書かない。モデルの動く方向を変えたい場合は、プロンプトとテンプレート側で設計します。
コピペ完全形: PreToolUseガード
まず .claude/settings.json(新規ならこのまま。既存ならhooksキーを統合する):
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Edit|Write",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard.py",
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format_after_write.sh",
"timeout": 30
}
]
}
]
}
}
次に .claude/hooks/guard.py(標準ライブラリのみ・1ファイル完全形):
#!/usr/bin/env python3
# PreToolUseガード: 危ないパターンはexit 2、それ以外はexit 0
import json, re, sys
def main():
data = json.load(sys.stdin)
tool = data.get("tool_name", "")
arg = data.get("tool_input", {})
if tool == "Bash":
cmd = arg.get("command", "")
patterns = [
r"\brm\s+-rf?\s+/(?:Users|home)/",
r"\brm\s+-rf?\s+\.",
r"\bgit\s+reset\s+--hard\b",
r"\bgit\s+push[^|]*--force\b",
r"\bgit\s+push[^|]*\bmain\b",
r":\(\)\s*\{.*\};\s*:",
]
for p in patterns:
if re.search(p, cmd):
sys.stderr.write(
"BLOCK: dangerous Bash pattern: "
+ cmd[:200]
+ "\n"
+ "next step: 削除対象を/tmp配下に限定し、対象一覧を出してから人間承認で消す\n"
)
sys.exit(2)
if tool in ("Write", "Edit"):
path = arg.get("file_path", "")
if re.search(r"\.env\b|prod/|production/|\.github/workflows/", path):
sys.stderr.write(
"BLOCK: protected path: " + path[:200]
+ "\n"
+ "next step: 一時作業は/tmp配下へ。本番への反映はPR経由のみ\n"
)
sys.exit(2)
sys.exit(0)
if __name__ == "__main__":
main()
.claude/hooks/format_after_write.sh(PostToolUse側。整形は軽いので許可する):
#!/bin/sh
# 編集後の整形。整形に失敗しても止めない(exit 0)
cd "$CLAUDE_PROJECT_DIR" 2>/dev/null || exit 0
command -v ruff >/dev/null 2>&1 && ruff format --quiet . && ruff check --quiet --fix .
exit 0
動作確認の手順(この順に実行する)
chmod +x .claude/hooks/guard.py .claude/hooks/format_after_write.sh- Claude Codeのセッションで
/hooksを開き、PreToolUseとPostToolUseが一覧に載ることを確認する - Bashで
git reset --hardを打ってみる。「BLOCK: dangerous Bash pattern」がモデルへ返り、代替方針を出すことを確認 -
rm /tmp/ok_test.txtのような対象外パターンも通して、拒否しすぎていないか確認する
失敗パターン: 私は設計を3回間違えた
| # | 間違えた設計 | 起きたこと | 直し方 |
|---|---|---|---|
| 1 | ガードをPostToolUseに置いた | 削除が実行された後に検知する。対象が無くなっていて復旧できなかった | 削除・強制書換は必ずPreToolUseで止める |
| 2 | guard.pyのデバッグ出力をstdoutへ書いた | exit 0でもイベントによってはstdoutが文脈に入り、毎回余計な文字がモデルへ渡った | 情報はstderrへ。stdoutは何も出さないを運用則にした |
| 3 | StopからClaudeを再起動した | Stopがまた発火して二重起動し、朝までCPUが回っていた | Stopは通知だけ。再起動は人間が見て判断する |
1が最も取り返しが付きません。検知の瞬間が実行後では遅い対象(削除・強制書換・push)は、必ずPreToolUseで止めてください。
ストック資産: hooks導入チェックリスト
導入前にこの6行を確認する。すべて「はい」ならhooks向きです:
| # | 確認項目 |
|---|---|
| 1 | 判断を文字列パターンとして書けるか(モデルに説明が要らないか) |
| 2 | 1回の実行が2秒以内で終わるか |
| 3 | 繰り返し実行しても安全か(冪等か) |
| 4 | 危ない側の検知はPreToolUseになっているか |
| 5 | stderr以外に余計な出力がないか |
| 6 | timeoutを明示したか |
まとめ: 今日やる3つの次アクション
- 本記事の完全形をそのままコピーし、guard.py 1本だけ入れる(settings.json統合・chmod・
/hooks確認・git reset --hardでの挙動確認まで10分) - 1週間回して誤検知ゼロなら、PostToolUseの整形を追加する。品質チェックはhooksに書かずCIへ出す
- 棲み分け表へ戻って確認する: 絶対拒否はpermissions.denyに残し、条件付きの阻止だけをhooksの正規表現に足して終わり
順序が重要です。deny一覧 → PreToolUseガード → PostToolUse整形の順で足す。逆にやると「止められていない削除」と「整形の無限修正」が同時に起きて、原因が分からなくなります。
読者特典(無料)
本記事のような実装パターンを横断的にまとめたチートシートと、Obsidian・Notionのテンプレート集を無料配布しています。8冊の内容から「何をどのツールでやるか」の判断チャートも含みます。
関連記事(同テーマ)
📗 MCP実践入門 — AIエージェントを拡張するModel Context Protocol(Kindle・Kindle Unlimited読み放題対象)