深夜、長めのリファクタリングを Claude Code に任せて別の作業をしていたときのことです。ふと実行ログを遡って、手が止まりました。ビルド成果物を消すための rm コマンドが、変数の展開結果しだいではプロジェクト直下を対象にし得る形で書かれていたのです。実害はありませんでした。それでも、「実行される前に機械的に検査する仕組み」を持っていないことが、急に心許なくなりました。
個人開発には、コマンドをレビューしてくれる同僚がいません。私自身、エージェントに任せる時間は今後も増えていく見込みだったので、このタイミングで PreToolUse フックによる事前検査を組み込みました。導入から数週間、実際に何度か「止まってくれた」場面があり、構成としても運用としても手応えを感じています。その過程で分かったことを、設定ファイルと検査スクリプトの実物とあわせて残しておきます。
許可ダイアログがあるのに、なぜ別の検査が要るのか
Claude Code には標準で許可確認の仕組みがあります。それでも検査の層をもう一枚足したのは、理由が2つあります。
1つ目は、自動承認と組み合わせる場面が現実に多いことです。長時間のタスクでは Bash の実行を許可リストに載せたり、確認を省略する設定で走らせたりします。便利さと引き換えに、「人間が読んでから実行する」という前提はそこで崩れます。
2つ目は、確認疲れです。何十回も続けて承認していると、ダイアログの中身を読まずに通すようになります。少なくとも私はそうなりました。人間の注意力は、残念ながら安全装置として当てになりません。読まなくなった人間の代わりに、毎回同じ集中力で読んでくれる検査役を置く。それが PreToolUse フックの位置づけです。
PreToolUse フックが介入できるタイミング
PreToolUse は、Claude Code がツールを実行する直前に呼ばれるフックです。フックに登録したコマンドは標準入力から JSON を受け取ります。Bash ツールの場合、tool_name と、実行されようとしているコマンド文字列を含む tool_input が入っています。
押さえておきたいのは終了コードの意味です。
exit 0— 実行を許可しますexit 2— 実行をブロックし、標準エラー出力の内容を Claude にフィードバックします- それ以外の非ゼロ — エラーとして扱われますが、ブロックにはなりません
この「2 だけが特別」という仕様は見落としやすく、私は最初 exit 1 で書いてしまい、何もブロックしない検査スクリプトをしばらく動かしていました。判定に引っかかったら必ず 2 を返す。ここを先に押さえておくと、無駄なデバッグが減ります。
settings.json への最小構成
プロジェクト単位で効かせる場合は .claude/settings.json に書きます。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/check_bash.py"
}
]
}
]
}
}matcher はツール名へのマッチです。ここを空にすると全ツールの実行前に呼ばれてしまうため、Bash に絞っています。ファイル編集系まで検査対象を広げると誤検知の調整が一気に難しくなるので、まずコマンド実行だけを見るのが現実的です。
設定をリポジトリにコミットしておけば、同じリポジトリを触る別マシンでも同じ検査が効きます。私はこの点を重視していて、まず1つのリポジトリで2週間ほど運用してから、~/.claude/settings.json に昇格させて全プロジェクト共通にしました。
検査スクリプトの実装
検査の本体は Python で書きました。「消えたら取り返しがつかない操作」だけを止める、控えめな拒否リストです。
#!/usr/bin/env python3
# .claude/hooks/check_bash.py
import json
import re
import sys
BLOCK_PATTERNS = [
(r"rm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+(/|~|\$HOME)(\s|$)",
"ルートやホームディレクトリへの再帰削除は許可していません"),
(r"git\s+push\s+(?!.*--force-with-lease).*--force",
"force push は --force-with-lease を使ってください"),
(r"chmod\s+(-R\s+)?777",
"chmod 777 は許可していません。必要な権限を個別に指定してください"),
(r"curl\s+[^|;]*\|\s*(ba|z)?sh",
"ダウンロードしたスクリプトの直接実行は許可していません"),
(r">\s*/dev/sd[a-z]",
"ブロックデバイスへの直接書き込みは許可していません"),
]
def main() -> int:
data = json.load(sys.stdin)
if data.get("tool_name") != "Bash":
return 0
command = data.get("tool_input", {}).get("command", "")
for pattern, reason in BLOCK_PATTERNS:
if re.search(pattern, command):
print(f"blocked by check_bash.py: {reason}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())許可リスト方式(許可したコマンド以外を全部止める)も検討しましたが、最初の1週間で捨てました。エージェントが使うコマンドの幅は想像より広く、許可リストが育つまでの間、ほとんど何も実行できなくなるからです。拒否リストで「致命傷だけ防ぐ」線から始めて、ヒヤリとした実例が出るたびにパターンを足す。個人開発の規模では、この育て方が一番長く続きます。
ブロックされた後、Claude はどう動くか
導入して最初に面白かったのは、ブロックされたときの Claude の振る舞いです。exit 2 で返した標準エラーの文面は、そのまま Claude へのフィードバックになります。
実際にあった例では、git push --force を止めたとき、Claude は文面にある「--force-with-lease を使ってください」を読み取り、自分でコマンドを書き換えて再実行してきました。つまり、うまく書いたエラーメッセージは、禁止ではなく誘導として機能します。
この挙動が分かってからは、メッセージを「何がだめか」ではなく「代わりにどうするか」が伝わる形に直しました。blocked とだけ返すと、Claude は原因を推測して別の回避策を探し始めることがあり、かえって挙動が読みにくくなります。
数週間の運用で調整した点
実際に回してみて、手を入れたのは次の3箇所でした。
- 一時ディレクトリの誤検知: テストが使う
$TMPDIR配下の再帰削除まで止めていました。パターンを見直し、対象を/・~・$HOMEの直指定に絞って解消しています。 - ブロックログの記録: 何を止めたかを
~/.claude/hooks.logに追記するようにしました。週に一度見返すと、パターンを足す判断にも削る判断にも、このログがそのまま根拠になります。 - 検査の軽さの維持: このスクリプトはツール実行のたびに走るので、起動の重い処理を入れると体感がはっきり悪くなります。外部コマンドの呼び出しやネットワークアクセスは入れない、が私の結論です。
permissions の deny ルールとの住み分け
Claude Code には設定ベースの deny ルールもあり、最近のアップデートで glob によるパターン指定にも対応しました。「設定で書けるなら、スクリプトは不要では」と考えて両方試しましたが、いまは役割分担に落ち着いています。
- 静的に決まる禁止事項(特定ディレクトリへの書き込み禁止、特定コマンドの使用禁止)→ permissions の deny ルール
- 文脈を見て判断したい事項(引数の組み合わせ、リダイレクト先、代替案をメッセージで誘導したいもの)→ PreToolUse フック
deny ルールは宣言的で高速、フックは表現力で勝ります。どちらかに寄せるより、単純な禁止を deny 側へ逃がしてフックの検査パターンを最小限に保つ構成が、メンテナンスの負担としては一番軽くなりました。
最初の一歩は1ファイルから
この仕組みは、check_bash.py を1ファイル置いて settings.json に数行足すだけで動き始めます。まずは拒否パターンを2つか3つに絞って1週間運用し、ブロックログを眺めてから育てる順番がおすすめです。誤検知に悩まされる前に、「止まってくれた」という最初の体験が得られるはずです。