0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

止まったhookはゲートにならない — 公式仕様の確認と、5分の検証手順

0
Posted at

本記事は note / Zenn に公開した同題の記事の再掲です。元記事: https://note.com/trimkeep/n/n0e7e9874b46f / Zenn 版: https://zenn.dev/trimkeep/articles/790f25d87c70a0

私の結論は3行だ。

  1. Claude Codeのhookは、タイムアウトしても、スクリプトが起動できなくても、標準出力が壊れていても、ツール呼び出しをブロックしない。実装の癖ではなく、公式ドキュメントに書かれた挙動だ。
  2. だから設定を誤ったhookは、エラーではなく無反応として通過する。「入れたつもり」と「動いている」を区別する手がかりが残らない。
  3. どちらに倒れるかはコマンド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, or mcp_tool hook 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.commandfile_path を正規表現で見るだけ。
  • 見えるのは BashEdit/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 pushterraform 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)の開発チームが執筆しています。

リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?