はじめに / 対象と前提
Claude Code で複数のプロジェクトを自動運転させていると、rm -rf や git push --force、あるいは知らないホストへの curl をエージェントがうっかり実行してしまわないか不安になる。設定画面で毎回許可を求めれば安全だが、非対話(headless)実行ではそもそも人間が確認プロンプトに答えられない。
この記事は以下を前提にする。
- Claude Code v2.x 系(hooks の
PreToolUseイベントが存在するバージョン) - Node.js 22.x / macOS or Linux
- 「Bash ツールを呼ぶ直前に、コマンド文字列を検査して危険なら止めたい」というニーズがある人向け
settings.json の permissions.allow/deny によるワイルドカード制御はすでに知っている前提で、それだけでは足りないケース(動的な判定ロジックが要る場合)にフォーカスする。
TL;DR
-
PreToolUsehook は Bash ツール呼び出しの直前に割り込んで、コマンド文字列を任意のロジックで検査できる - ブロック方法は2種類ある: exit code 2(簡易)と JSON の
permissionDecision(細かい制御)。混在させると片方が無視される -
matcherはツール名(Bashなど)にしかマッチしない。コマンド文字列自体のパターンマッチは hook スクリプト側で自分で書く必要がある - headless(
-pオプション)実行ではaskは自動的にdeny扱いになる
手順 / 動かし方
1. hook スクリプトを用意する
~/.claude/hooks/block-dangerous-bash.py として以下を置く。stdin から JSON が渡ってくるので、tool_input.command を取り出して正規表現でチェックする。
#!/usr/bin/env python3
import json
import re
import sys
DANGEROUS_PATTERNS = [
r"\brm\s+-rf\s+/",
r"\bgit\s+push\s+.*--force\b",
r"\bgit\s+reset\s+--hard\b",
r"\bcurl\s+.*\|\s*sh\b",
]
def main():
payload = json.load(sys.stdin)
tool_name = payload.get("tool_name", "")
command = payload.get("tool_input", {}).get("command", "")
if tool_name != "Bash":
# matcher で絞っていても、念のため二重チェック
print(json.dumps({}))
return
for pattern in DANGEROUS_PATTERNS:
if re.search(pattern, command):
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"危険パターンに一致: {pattern}",
}
}
print(json.dumps(output))
return
print(json.dumps({}))
if __name__ == "__main__":
main()
実行権限を付けておく。
chmod +x ~/.claude/hooks/block-dangerous-bash.py
2. settings.json に登録する
~/.claude/settings.json(またはプロジェクトの .claude/settings.json)に以下を追加する。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/block-dangerous-bash.py"
}
]
}
]
}
}
matcher に "Bash" を指定すると、Bash ツールが呼ばれるたびにこの hook が起動する。Write/Edit など他のツールでは発火しない。
3. 動作確認
Claude Code のセッション内で意図的に危険なコマンドを投げてみる。
git push origin main --force
hook が deny を返すと、Claude 側には「このコマンドは実行できませんでした」という結果が返り、git push は実際には走らない。ログで確認するなら stderr にデバッグ出力を足しておくと分かりやすい。
sys.stderr.write(f"[hook] checked command: {command}\n")
claude --debug で起動すると、どの hook がどう判定したかがコンソールに出るので、初回セットアップ時はこれで確認するのが早い。
ハマりどころ
1. matcher はコマンド文字列にはマッチしない
matcher は ツール名(Bash, Write, Edit など)にしかマッチしない。「rm を含む Bash コマンドだけに絞りたい」といった正規表現を matcher に書いても効かない。コマンド文字列そのものの判定は、必ず hook スクリプト内で tool_input.command をパースして行う必要がある。最初にこれを誤解して「matcher に rm.* と書けば絞れるはず」とハマった。
2. exit code 方式と JSON 出力方式を混在させると片方が無視される
ブロックには2つの方法がある。
- exit code 2: stderr に書いたメッセージがそのままブロック理由として使われる、シンプルな方式
-
JSON 出力: stdout に
hookSpecificOutput.permissionDecisionを含む JSON を返す、allow/deny/askを細かく制御できる方式
厄介なのは、この2つを同じスクリプト内で中途半端に混在させたときの挙動だ。exit code 2 で終了しつつ stdout にも JSON を書いていたケースでは、JSON 側の permissionDecision は評価されず exit code 側の判定が優先された。どちらか一方の方式に統一するのが安全で、細かい理由文言を返したいなら JSON 方式、単純に止めたいだけなら exit code 2 で十分。
3. headless 実行では ask が自動的に deny になる
対話セッションでは permissionDecision: "ask" を返すと人間に確認ダイアログが出る。しかし claude -p "..." の headless 実行では確認相手がいないため、ask は自動的に deny として扱われる。「グレーゾーンのコマンドは ask にして人間の判断を仰ぐ」という設計にしていると、無人実行時に想定外に全部ブロックされてハマる。無人ジョブで動かす hook は ask を使わず、allow か deny の二値で判定を書き切る設計にしておいたほうが事故がない。
背景・補足
PreToolUse は「ツール実行前」、PostToolUse は「ツール実行後」に発火する。lint や型チェックを自動で走らせたいなら PostToolUse(Write/Edit の後に fire)、実行そのものを止めたいなら PreToolUse という住み分けになる。同じ hooks の仕組みでもイベントが違えば用途もまったく別なので、「hook を設定した」だけで安心せず、どのイベントに登録したかを都度確認する癖をつけておくとよい。
まとめ
-
PreToolUsehook で Bash コマンドを実行前に検査し、危険なパターンを自動ブロックできる - ブロック方式は exit code 2 と JSON 出力の2種類、混在させず統一する
-
matcherはツール名のみが対象、コマンド文字列の判定は自前で書く - headless 実行では
askが自動denyになるため、無人ジョブではallow/denyの二値設計にする - 導入後は
claude --debugで実際に判定ログを確認してから運用に載せるのが安全