本記事は note / Zenn に公開した同題の記事の再掲です。元記事: https://note.com/trimkeep/n/n0e7e9874b46f / Zenn 版: https://zenn.dev/trimkeep/articles/790f25d87c70a0
私の結論は3行だ。
- Claude Codeのhookは、タイムアウトしても、スクリプトが起動できなくても、標準出力が壊れていても、ツール呼び出しをブロックしない。実装の癖ではなく、公式ドキュメントに書かれた挙動だ。
- だから設定を誤ったhookは、エラーではなく無反応として通過する。「入れたつもり」と「動いている」を区別する手がかりが残らない。
- どちらに倒れるかはコマンド3本・5分で確かめられる。手順と、想定外を拒否側に寄せる書き方を書く。
相手はClaude Codeに実リポジトリを触らせている個人開発者と小さなチーム。私はHandrailという6本のhookを書いていて、実装を材料にする。
公式ドキュメントは「止まったhookを門番にするな」と書いている
引用はhooksリファレンス(https://code.claude.com/docs/en/hooks、2026-09-09取得)から原文のまま。
判定は終了コードで返す。"Exit 2 means a blocking error." で、PreToolUseでの効果は "Blocks the tool call"。それ以外は "Any other exit code doesn't block on its own for most hook events."
問題は、exit 2を返せなかった場合だ。ドキュメントは3つ挙げている。
- タイムアウト: "A timed-out
command,http, ormcp_toolhook doesn't block the tool call. The call continues through the normal permission flow, so don't count on a stalled hook to act as a gate." - 起動できないスクリプト: "A hook that can't start lands in the same non-blocking bucket."
- 壊れた出力: "... with empty stdout, it's a non-blocking error for most hook events: the action proceeds"
判定を返せなかったhookは、ゲートの数に入らない。
timeoutの既定は "Defaults: 600 for command, http, and mcp_tool"。Handrailは6本すべてに "timeout": 10 を書いているが、これは待ち時間の上限で、止まったhookを拒否側に倒すものではない。
このhookがやらないこと
- 判定はテキストマッチだ。
tool_input.commandとfile_pathを正規表現で見るだけ。 - 見えるのは
BashとEdit/Write/MultiEditの入力だけ。docker execの内側やSSH先のシェルは現れない。 -
.claude/settings.jsonやhookスクリプト自身の書き換えを止めるルールは、6本のどこにも無い。 - サンドボックスではない。プロセスもネットワークも制限しない。
- 誤検知する。再現できる例を後ろに置いた。
5分で自分のhookを確かめる
一つ目。登録されたコマンドが実在して実行できるか。「起動できないhookは通る」に一番よく当たるのがパスの取り残しだ。
$ jq -r '.hooks.PreToolUse[].hooks[].command' .claude/settings.json | sed "s|\"\$CLAUDE_PROJECT_DIR\"|.|" \
| sort -u | while read -r c; do test -x "$c" || echo "MISSING $c"; done
MISSING ./.claude/hooks/handrail/old/git-force-push.sh
インストール直後の設定で、パスを1本だけ古いディレクトリに書き換えて実行した出力だ。この状態でもツール呼び出しは止まらない。
二つ目。壊れた入力でどちらに倒れるか。jqをPATHから外して流す。
$ printf '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' \
| PATH=/var/empty /bin/bash .claude/hooks/handrail/destructive-shell.sh; echo "exit=$?"
handrail destructive-shell: jq is required
exit=2
exit=2で理由が1行出ればfail-closedだ。exit=0で無言なら、その入力にhookは何もしていない。空stdinや壊れたJSONでも同じ形が出る。PATH はjqの無いディレクトリなら何でもいい。
三つ目。止めたい入力を1件、実物として流す。確認を返すルールはJSONで見分けられる。
$ printf '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}' \
| .claude/hooks/handrail/git-force-push.sh | jq -c .hookSpecificOutput
{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"git push targets main/master; confirm before pushing"}
main/masterへの素のpushは拒否ではなく確認だ。
想定外をすべてdenyに寄せる骨格
6本は先頭の4行が同じだ。jqが無い・stdinが読めない・空・壊れたJSON——どれも deny() に集約され、exit 2で終わる。
Code excerpts from trimkeep/handrail-kit, MIT License, © 2026 Handrail contributors, source: https://github.com/trimkeep/handrail-kit
set -euo pipefail
deny() { echo "handrail destructive-shell: $1" >&2; exit 2; }
command -v jq >/dev/null 2>&1 || deny "jq is required"
input=$(cat) || deny "could not read stdin"
[ -n "$input" ] || deny "empty stdin"
jq -e . >/dev/null 2>&1 <<<"$input" || deny "stdin is not valid JSON"
allow() という関数は6本のどこにも無い。書ける関数が無ければ書き忘れることもできない。ask() を持つのは4本(git-force-push / prod-guard / publish-guard / remote-exec)、残る2本は deny() だけだ。
この性質はテストで固定してある。assertOnlyTightens() は標準出力に "permissionDecision":"allow" が現れたテストをその場で落とす。fail-closed側は6本×4条件(空stdin・非JSON・tool_input 無し・jq無しPATH)が個別のテストだ。npm test は2026-09-09に128件全部通った。
誤検知は「安全側」ではない、という指摘について
2026-09-06公開のZenn記事「Claude Code の hook の誤検知は安全側の失敗ではない」(https://zenn.dev/erenoa6622/articles/overblocking-is-not-safe-side)は、読み取り専用ロールのhookが正当な作業を4種類ブロックした実例から、「過検知は『安全側の失敗』ではありません。検証役が測れる範囲が確実に狭まるぶん、未検証の領域が増えます」と書く。`subprocess.` というトークンの有無だけで判定していたのが原因だとしている。
同意する。そのうえで軸は2本ある。
- 判定を返せたうえで広く止めすぎる → 検証できる範囲が狭くなる。Zenn記事の話。
- 判定を返せずに通す → ゲートが最初から無かったのと同じになる。ここまでの話。
片方の答えはもう片方の答えにならない。fail-closedは「壊れたときどうするか」の設計で、「何を止めるか」の広さとは別に決める話だ。
Handrailの拒否リストが短いのは前者を踏まないためだ。拒否fixtureは publish-guard 1件、prod-guard 2件しかない。docker push も terraform apply も確認だ。
誤検知は出る。自分の分を1つ。
$ printf '{"tool_name":"Bash","tool_input":{"command":"cat docs/secrets/README.md"}}' \
| .claude/hooks/handrail/secret-paths.sh; echo "exit=$?"
handrail secret-paths: command references a secret-shaped path
exit=2
docs/secrets/ の下のREADMEを読むだけのコマンドだ。secret-paths.sh のパス正規表現に (^|/)secrets(/|$) が入っているので、中身に関係なく止まる。回避はディレクトリ名を変えるか、.claude/settings.json からその1行を外すかだ。
止まる場所は使えば分かる。止まらない場所は使っても分からない。だから実例は隠さない。
手元で試す
6本の担当は destructive-shell(rm -rf 系)、secret-paths(.env・**/secrets/**)、git-force-push(force系push)、prod-guard(terraform destroy)、publish-guard(npm publish --access public)、remote-exec(curl | bash 系)。test/fixtures/*.json の拒否/確認件数は順に 10/0・9/0・8/1・2/6・1/9・6/2(2026-09-09時点)。
無料のMITリポジトリ: https://github.com/trimkeep/handrail-kit
# プラグインとして
/plugin marketplace add trimkeep/claude-plugins
/plugin install handrail@trimkeep
# スクリプトをコピー
git clone https://github.com/trimkeep/handrail-kit
cd handrail-kit && ./install.sh /path/to/your/project
プラグイン版は ${CLAUDE_PLUGIN_ROOT} を参照し、対象リポジトリに何も書き込まない。スクリプト版は既存の .claude/settings.json を退避してからマージし、2回目の実行では何も変えない。6本とテストは無料で、これからも無料のままだ。
注記
以下の2文は原文のまま掲載する必須文。訳は参考(非公式)。
-
(compatibility, 原文): "Handrail works with Claude Code and other agent CLIs in plain text only; it is not affiliated with, endorsed by, or a product of Anthropic."
参考訳(非公式): Handrailは Claude Code や他のエージェントCLIとプレーンテキストのみで連携するもので、Anthropicの提携先・推奨・製品ではありません。 -
(defence-in-depth, 原文): "Handrail is a defence-in-depth layer — it reduces risk but does not eliminate it, is not a security audit or certification, and does not replace backups, code review, or your own judgment."
参考訳(非公式): Handrailは多層防御の一層であり、リスクを減らしはしても無くしはしません。セキュリティ監査や認証ではなく、バックアップ・コードレビュー・自分自身の判断の代わりにもなりません。
補足(免責と執筆者)
- Handrail は Claude Code など各種エージェント CLI と平文で互換性があるのみです。Anthropic の製品ではなく、Anthropic による承認・提携もありません。
- Handrail は多層防御(defence-in-depth)の一層であり、リスクを低減しますが排除はしません。セキュリティ監査や認証ではなく、バックアップ・コードレビュー・ご自身の判断の代わりにはなりません。
- 本記事は Handrail(Trimkeep)の開発チームが執筆しています。
リンク
- 無料の MIT リポジトリ(6本のフック、テスト、インストーラ): https://github.com/trimkeep/handrail-kit
- 元記事 (note): https://note.com/trimkeep/n/n0e7e9874b46f
- 参照した反対側の議論 (Zenn, 2026-09-06): https://zenn.dev/erenoa6622/articles/overblocking-is-not-safe-side
- 本文に書かなかった実装の詳細をまとめた日本語ガイド (Markdown / HTML / PDF。fixture の組み方、
allowを書かせないテストの作り方、バージョン差分で hook 契約のどこがずれるか、黙って止まるパターンの一覧。提供条件と価格は購入ページに表示): https://buy.polar.sh/polar_cl_qyMuDCz4oYfqvqy8BZ995lcrTGuOLGNw9ADFe3uDvwz