Claude CodeのPreToolUse HookをPython標準ライブラリだけで実装し、許可時の沈黙、拒否JSON、不正入力、パストラバーサル、symlink脱出を8ケースの自動テストと実CLIで検証します。
Claude Code Hooksをテストする方法|止まらない事故を8ケースで防ぐ
Claude CodeのHookは、設定を保存しただけでは完成ではありません。保護対象を拒否できることに加え、通常ファイルを邪魔しないこと、壊れた入力で意図せず許可しないこと、相対パスやsymlinkで迂回できないことまで、自動テストで確かめる必要があります。
この記事では、.envと.gitへの書き込み、およびプロジェクト外への脱出を拒否するPreToolUse Hookを作ります。完成状態は、Python標準ライブラリだけで動くHook本体、設定ファイル、8ケースのunittestです。最後に実際のClaude Code CLIからWriteを1回発火させ、.envが生成されないところまで確認します。
検証日は2026年7月21日、環境はmacOS Darwin 25.5.0 arm64、Claude Code 2.1.209、Python 3.9.6です。仕様は更新されるため、導入時にはAnthropicのHooks referenceを再確認してください。
AnthropicのHooksガイドは、HooksをClaude Codeのライフサイクル上で自動実行されるユーザー定義コマンドとして説明しています。PreToolUseはツール実行前に動くため、危険な入力を検査して処理を拒否できます。しかし、Hookも通常のプログラムです。入力JSON、パス解決、標準出力、終了コードのどれかを誤ると、「設定は存在するが期待どおり止まらない」状態になります。
今回の完成条件は次のとおりです。
src/app.pyへの書き込みは終了コード0で許可し、stdoutとstderrは完全に空にする。
.envと.git/configは、構造化された拒否JSONをstdoutへ返す。
.env.exampleは誤検知せず許可する。
../outside.txtと、プロジェクト内symlinkを経由した外部パスを拒否する。
壊れたJSONとfile_path欠損はfail-closed、つまり安全側に倒して拒否する。
実CLIで拒否後、対象ファイルが生成されていないことを確認する。
これは秘密ファイルを完全防御する仕組みではありません。Hook自体はユーザー権限で動きます。Claude Codeのpermissions公式仕様にあるdeny、ask、allowやsandbox、Gitの保護、CIと重ねる補助線として使います。
検証用の空ディレクトリで、次の構成を作ります。本番リポジトリへ入れる前に隔離環境で試すと、設定ミスによる影響を限定できます。
hook-lab/
├── .claude/
│ ├── hooks/
│ │ └── protect_files.py
│ └── settings.json
└── test_protect_files.pyHookの入力はstdinのJSONです。今回使う主なフィールドはtool_input.file_pathとcwdで、プロジェクトルートは環境変数CLAUDE_PROJECT_DIRを優先します。パスを単なる文字列の前方一致で判定してはいけません。OWASPのPath Traversal解説が示すように、../などの入力は意図したディレクトリの外へ到達できます。さらに、文字列上は内部でもsymlinkの解決後に外部へ出る場合があります。
次を.claude/hooks/protect_files.pyとして保存します。許可時には何も出力しない点が重要です。拒否時だけ、hookSpecificOutput内でイベント名とpermissionDecision: denyを返します。
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
def deny(reason: str) -> int:
result = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
return 0
def main() -> int:
try:
event = json.load(sys.stdin)
except (json.JSONDecodeError, UnicodeDecodeError):
return deny("Hook input is not valid JSON")
tool_input = event.get("tool_input")
raw_path = tool_input.get("file_path") if isinstance(tool_input, dict) else None
if not isinstance(raw_path, str) or not raw_path.strip():
return deny("file_path is missing")
root_text = os.environ.get("CLAUDE_PROJECT_DIR") or event.get("cwd")
if not isinstance(root_text, str) or not root_text:
return deny("project directory is missing")
try:
root = Path(root_text).resolve(strict=True)
candidate = Path(raw_path).expanduser()
if not candidate.is_absolute():
candidate = root / candidate
resolved = candidate.resolve(strict=False)
relative = resolved.relative_to(root)
except (OSError, RuntimeError, ValueError):
return deny("write outside the project is blocked")
if relative.name == ".env" or ".git" in relative.parts:
return deny(f"protected path is blocked: {relative}")
return 0
if __name__ == "__main__":
raise SystemExit(main())Path.resolve(strict=False)は、対象ファイルがまだ存在しない書き込み前でもパスを正規化し、既存の親symlinkを解決します。その結果をrootに対してrelative_toできなければ、プロジェクト外です。".." not in pathのような文字列判定より、絶対パス、親ディレクトリ移動、symlinkの三つを同じ境界で扱えます。
.envはファイル名の完全一致で判定します。部分一致にすると、共有してよい雛形.env.exampleまで止めてしまいます。.gitはパス要素として判定するため、.git/configなど配下全体を対象にできます。
.claude/settings.jsonへ次を保存します。WriteとEditの直前だけ実行し、無関係なツールには介入しません。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect_files.py"
}
]
}
]
}
}プロジェクト設定はチーム共有できますが、Hookは任意コードを実行できるため、変更を通常のソースコードと同じようにレビューしてください。特に外部通信、秘密値の読み取り、入力文字列をshellへ再展開する処理を安易に追加しないことが重要です。
テストはClaude Codeを毎回起動せず、Hookのプロセス境界を直接検査します。これにより、正常時の完全無出力、拒否JSONの妥当性、終了コードを高速かつ決定的に確認できます。次をtest_protect_files.pyとして保存します。
import json, os, subprocess, sys, tempfile, unittest
from pathlib import Path
HOOK = Path(__file__).parent / ".claude/hooks/protect_files.py"
class HookTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name) / "repo"
self.root.mkdir()
(self.root / ".git").mkdir()
self.outside = Path(self.tmp.name) / "outside"
self.outside.mkdir()
def tearDown(self):
self.tmp.cleanup()
def event(self, path):
return {"hook_event_name": "PreToolUse", "tool_name": "Write",
"cwd": str(self.root),
"tool_input": {"file_path": path}}
def run_hook(self, payload, raw=False):
data = payload if raw else json.dumps(payload)
env = os.environ.copy()
env["CLAUDE_PROJECT_DIR"] = str(self.root)
return subprocess.run([sys.executable, str(HOOK)], input=data,
text=True, capture_output=True, env=env, check=False)
def denied(self, result):
self.assertEqual((result.returncode, result.stderr), (0, ""))
output = json.loads(result.stdout)["hookSpecificOutput"]
self.assertEqual(output["hookEventName"], "PreToolUse")
self.assertEqual(output["permissionDecision"], "deny")
def test_normal_file_is_silent(self):
r = self.run_hook(self.event("src/app.py"))
self.assertEqual((r.returncode, r.stdout, r.stderr), (0, "", ""))
def test_dotenv_is_denied(self):
self.denied(self.run_hook(self.event(".env")))
def test_dotenv_example_is_allowed(self):
r = self.run_hook(self.event(".env.example"))
self.assertEqual((r.returncode, r.stdout, r.stderr), (0, "", ""))
def test_git_config_is_denied(self):
self.denied(self.run_hook(self.event(".git/config")))
def test_parent_traversal_is_denied(self):
self.denied(self.run_hook(self.event("../outside.txt")))
def test_symlink_escape_is_denied(self):
(self.root / "linked").symlink_to(self.outside, target_is_directory=True)
self.denied(self.run_hook(self.event("linked/escaped.txt")))
def test_broken_json_is_denied(self):
self.denied(self.run_hook("{broken", raw=True))
def test_missing_path_is_denied(self):
self.denied(self.run_hook({"tool_input": {}, "cwd": str(self.root)}))
if __name__ == "__main__":
unittest.main(verbosity=2)プロジェクトルートでpython3 test_protect_files.pyを実行します。今回の実測結果はRan 8 tests in 0.158s、OKでした。通常ファイルでは終了コード0、stdout 0 byte、stderr 0 byte、.env拒否では終了コード0、stdout 143 byte、stderr 0 byteでした。byte数は理由文やJSON整形で変わるため、テストでは固定値ではなく「許可時は空」「拒否時は有効JSON」を契約にします。
直接テストが通ったら、隔離ディレクトリで実CLIのイベント境界を1回だけ確認します。次の依頼は、拒否された場合に別パスへ再試行しないよう明示しています。
rm -f .env
claude -p --dangerously-skip-permissions --output-format json \
'Write a file named .env in the current project containing exactly TEST_ONLY=1. Use the Write tool once. If a hook blocks it, do not try another path.'
test ! -e .env--dangerously-skip-permissionsはHookの挙動を分離して確認するため、使い捨ての検証ディレクトリだけで使用しました。通常開発へそのまま持ち込むための推奨設定ではありません。実測ではClaude Codeは終了コード0、stdout 1797 byte、stderr 0 byteで、結果にHookが書き込みを拒否した旨が含まれ、.envは存在しませんでした。
検証分かること向いている頻度Hook直接実行入力、出力、終了コード、境界値を決定的に検査コミットごと、CI実CLI統合設定の読み込み、matcher、実イベントとの接続を検査導入時、CLI更新時
単体テストだけでは設定ファイルの読み込み漏れを発見できず、実CLIだけでは異常入力やsymlinkを安全かつ網羅的に再現しにくいため、両方が必要です。
HookのstdoutはClaude Codeとのプロトコルに使われます。print("allowed")のようなログを混ぜず、許可時は完全に沈黙させます。調査ログが必要ならstderrまたは専用ファイルへ分離し、本番設定へ残す前に秘密情報が含まれないことを確認します。
公式Hooks referenceでは、終了コード2のstderrはブロッキングエラーとして扱われます。一方、今回のPreToolUseは構造化JSONでpermissionDecisionと理由を明示し、終了コード0で正常に判断を返します。予期したポリシー拒否には構造化JSON、Hook自体の実行失敗を明示したい場合には終了コード2とstderr、というように目的を分けます。終了コード1は一般的な非ブロッキングエラーとして扱われるため、「1なら止まる」と仮定しないでください。
startswith(root)だけでは、名前が似た隣接ディレクトリや正規化前の入力を誤判定します。復旧方法は、ルートと候補を絶対・正規化済みPathへ変換し、relative_toで包含関係を判定することです。symlinkケースを回帰テストへ残せば、将来の単純化で防御が退行したときに検出できます。
JSONデコード例外やキー欠損をそのまま落とすと、利用者には「Hookが壊れた」ことしか伝わらず、拒否契約も曖昧になります。保護目的のHookでは、認識できない入力を理由付きで拒否し、仕様更新で新しい入力形が来たときにテストを更新します。ただし、すべての例外を握りつぶすのではなく、想定した入力エラーだけを処理します。
仕組み主な責任今回のHookとの関係permissionsツールやコマンドのallow、ask、deny既知の操作を先に制限するPreToolUse Hookツール入力の文脈的・動的な検査ファイルパスを正規化して拒否するsandboxファイルシステム・ネットワーク境界Hookを迂回した場合の影響を限定するGit・CI差分レビュー、秘密検知、品質ゲート最終成果物を別境界で検査する
判断の順番は、静的に表せる禁止をpermissionsへ置き、入力内容に応じる規則をHookへ置き、実行環境の境界をsandboxで絞り、成果物をGitとCIで再検査する、です。Hookだけをセキュリティ境界と呼ばないことが重要です。また、Claude Code・Codex・Cursorを併用するチームでは、このHookはClaude Codeにだけ効きます。共通ルールはpre-commitやCIにも実装し、各エージェント固有のHookは早いフィードバックに使います。
隔離ディレクトリへHook、settings、8ケースのテストをコピーする。
通常ファイルのstdoutとstderrが0 byteであることを確認する。
拒否結果をJSONとして解析し、permissionDecisionがdenyであることを確認する。
実CLIで1回だけ発火させ、拒否対象が生成されていないことをファイルシステムで確認する。
Hookと設定をコードレビューし、CIで直接テストを継続実行する。
Claude Code更新時に公式仕様を再確認し、実CLI統合テストを再実行する。
安全Hookの価値は、設定例の見た目ではなく、許可・拒否・異常系を繰り返し証明できることにあります。まずは上の3ファイルを一時ディレクトリで動かし、自分のチームで保護したいパスをテストケースとして追加してください。
Claude Code・Codex・Cursorなどを並列実行する前に、Git worktreeが分離する範囲を5つの統合テストで確認します。.env欠落、同一行競合、dirty削除拒否を再現し、fail-closedの検査スクリプトを導入できます。
Claude Codeの/loopは、指定した時間ごとにAIへ同じお願いを自動で繰り返す機能です。デプロイ完了待ちやPR確認の「見張り」を任せられます。前提知識から使い方、注意点まで、初心者向けに整理します。
OpenAI CodexやGoogleの開発者向けAI発表をもとに、AIコーディングエージェントがシステム開発の現場に与える変化を、企業の実務目線で整理します。