Claude Codeのhookは、危険なコマンドをブロックするセーフティネットだ。しかし、テストされていないhookはセーフティネットに穴が空いているのと同じだ。
「rm -rf / をブロックする」hookを作ったとする。本当にブロックされるか? rm -rf /(スペース複数)でもブロックされるか? sudo rm -rf /home は? rm -rf node_modules(安全な削除)まで巻き込んでいないか?
cc-safe-setupでは605個のhookに対して13,931のテストを書いている。この記事では、そのテスト手法を解説する。
hookの入出力は単純だ。標準入力でJSON(ツール呼び出しの情報)を受け取り、exit codeで結果を返す。
- exit 0: 許可(hookを通過)
- exit 2: ブロック(ツール呼び出しを中止)
テストの基本形:
echo '{"tool_input":{"command":"rm -rf /"}}' | bash hook.sh
echo $? # → 2(ブロック)
echo '{"tool_input":{"command":"ls -la"}}' | bash hook.sh
echo $? # → 0(許可)
これだけだ。hookの入力形式はClaude Codeが送るJSON、出力はexit code。特別なテストフレームワークは不要で、bashだけで完結する。
テストを量産するために、ヘルパー関数を作る。cc-safe-setupの test.sh で実際に使っている形式がこれだ:
PASS=0
FAIL=0
test_hook() {
local name="$1" input="$2" expected_exit="$3" desc="$4"
local actual_exit=0
echo "$input" | bash "/tmp/test-$name.sh" > /dev/null 2>/dev/null || actual_exit=$?
if [ "$actual_exit" -eq "$expected_exit" ]; then
echo " PASS: $desc"
PASS=$((PASS + 1))
else
echo " FAIL: $desc (expected exit $expected_exit, got $actual_exit)"
FAIL=$((FAIL + 1))
fi
}
使い方:
test_hook "destructive-guard" '{"tool_input":{"command":"rm -rf /"}}' 2 "rm -rf / blocked"
test_hook "destructive-guard" '{"tool_input":{"command":"rm -rf node_modules"}}' 0 "rm -rf node_modules allowed"
test_hook "destructive-guard" '{"tool_input":{"command":"git reset --hard"}}' 2 "git reset --hard blocked"
test_hook "destructive-guard" '{"tool_input":{"command":"git reset --soft HEAD~1"}}' 0 "git reset --soft allowed"
当然のテスト。危険なコマンドが正しくexit 2を返すことを確認する。
test_hook "destructive-guard" '{"tool_input":{"command":"rm -rf /"}}' 2 "rm -rf / blocked"
test_hook "destructive-guard" '{"tool_input":{"command":"rm -rf ~/"}}' 2 "rm -rf ~/ blocked"
test_hook "destructive-guard" '{"tool_input":{"command":"rm -rf ../"}}' 2 "rm -rf ../ blocked"
test_hook "destructive-guard" '{"tool_input":{"command":"sudo rm -rf /home"}}' 2 "sudo rm -rf blocked"
hookが厳しすぎると、正常な操作までブロックしてしまう。これが偽陽性だ。
test_hook "destructive-guard" '{"tool_input":{"command":"rm -rf node_modules"}}' 0 "rm -rf node_modules allowed"
test_hook "destructive-guard" '{"tool_input":{"command":"rm -rf dist"}}' 0 "rm -rf dist allowed"
test_hook "destructive-guard" '{"tool_input":{"command":"rm -rf .cache"}}' 0 "rm -rf .cache allowed"
test_hook "destructive-guard" '{"tool_input":{"command":"echo git reset --hard"}}' 0 "echo内のコマンドは通す"
echo git reset --hard が通ることの確認は重要だ。文字列中に危険なパターンが含まれているだけでブロックすると、ログ出力やコメントにも反応してしまう。
想定外の入力でhookがクラッシュしないことを確認する。
test_hook "destructive-guard" '' 0 "empty input passes"
test_hook "destructive-guard" '{"tool_input":{}}' 0 "missing command field passes"
test_hook "destructive-guard" 'not json' 0 "invalid JSON passes"
test_hook "destructive-guard" '{"tool_input":{"command":"rm -rf /"}}' 2 "multi-space rm -rf blocked"
エッジケースのテストは地味だが、実運用で最も効果がある。Claude Codeが送るJSONの形式は常に一定とは限らない。
npx cc-safe-setup --verify で、インストール済みの全hookに対してテストを自動実行できる。
$ npx cc-safe-setup --verify
Running hook verification...
destructive-guard: 33/33 passed
branch-guard: 12/12 passed
secret-guard: 8/8 passed
...
All hooks verified.
GitHub Actionsでhookテストを自動実行する設定:
name: Hook Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bash test.sh
cc-safe-setupの13,931テストは全てこのパイプラインで実行されている。PRごとにテストが通ることを確認してからマージする。
13,931テストという数字は結果であって目標ではない。hookが1つ増えるたびに「ブロックすべきもの」「通すべきもの」「エッジケース」の3観点でテストを追加する。その積み重ねが6,000になった。
テストの目的は、hookが正しく動くことの証明だ。「5,000テストあります」ではなく「destructive-guardは33パターン全てでブロックと許可が正しく動きます」と言えることが大事だ。
🛡 ワンコマンドで安全設定: npx cc-safe-setup — 634個のhook例を収録。13,931テストで検証済み。