MENU
blog
スタッフブログ
dot
CLAUDE.md に書いたのに守られないルールを、Claude Code の Hooks で守らせる(Laravel / Windows 編)
技術

CLAUDE.md に書いたのに守られないルールを、Claude Code の Hooks で守らせる(Laravel / Windows 編)

こんにちは、クリエイティブSecの長谷川です。

Claude Code を使っていると、CLAUDE.md に「〇〇はしないこと」と書いておいたのに、しれっとやられてしまった、という経験はないでしょうか?
私自身も開発環境で色々作業していたところ、気づいたらローカルDBの中身がきれいに消えていたということがあります。

先日公開された「Claude Code を3セッション並列で回したら、人間の組織と同じ事故が起きた」でも、「ルールは、読ませて守らせるものではない。機械で強制する」という結論が書かれていました。ただ、その「機械で強制する」を具体的にどう書くのかは、まだ社内ブログに無かったので、今回は Claude Code の Hooks を使って、Laravel プロジェクトのルールを、仕組みで守らせる方法について書いていこうかなと思います。

社内の開発環境に合わせて、Windows + Laravel Sail(Docker) を前提にしています。Windows ならではのハマりどころが結構あったので、そのあたりも正直に書いていきます。

いきなりですが、完成したものは下記になります

まずは、Claude に「migrate:fresh で DB を作り直して」と頼んだときの様子です。

migrate:fresh が PreToolUse フックで止められ、Claude が実行せずに代わりの方法を案内している Claude Code の画面

コマンドが実行される前に hook が止めて、その理由を読んだ Claude が、「別のコマンドで回避することはしていません」と報告したうえで、どうしても必要なら自分で実行してください、と案内してくれています。

ちなみに、許可確認をすべてスキップする bypassPermissions モードでも同じことを試しましたが、こちらもきちんと止まりました。許可設定をどれだけ緩めていても、hook は効きます。

今回作った hook は、下記の 6 本です。

#タイミングやること
1コマンド実行前migrate:fresh / db:wipe / git push --force などを止める
2ファイル編集前コミット済みのマイグレーションと .env を書き換えさせない
3コミット前APIキーや .env が混ざったコミットを止める
4コマンド実行前push / merge は、許可設定があっても必ず人に確認させる
5ファイル編集後Pint で整形する
6終了前PHPStan とテストが通るまで「終わりました」と言わせない

それに加えて、おまけで、「テストを弱める変更を、AI(LLM)に判定させて止める」という実験もしてみました。

CLAUDE.md・permissions・Hooks の違い

Claude Code には、Claude の動きを縛る方法がいくつかあります。環境や目的によって最適な選択肢は異なりますが、ざっくり整理すると下記のようになります。

CLAUDE.mdpermissions(deny / ask)Hooks
正体お願い(プロンプト)静的なルールスクリプト
判定できること何でも書けるコマンドやパスのパターン中身を見て何でも
守られるか守られないことがある必ず守られる必ず守られる
止めた理由を伝えられるか-できないできる

permissions.deny は、以前の記事「10年もののLaravelに、Claude Codeをどう『効かせる』か」でも紹介されていましたが、パターンに一致したら止める、という仕組みなので、「コミット済みのマイグレーションだけ編集禁止」のような、状態を見ないと判断できないルールは書けません。

Hooks は、Claude がツールを使う前後などのタイミングで、自分で書いたスクリプトを実行できる仕組みです。スクリプトなので git の状態でも、ファイルの中身でも、何でも見て判断できますし、止めた理由を Claude に返して、自分で方針を変えてもらうこともできます。

Hooks reference – Claude Code Docs

Hooks の仕組みをざっくり

hook は .claude/settings.json に書きます。このファイルを git にコミットしておけば、チーム全員に同じ hook が効くようになります。(個人用にしたい場合は .claude/settings.local.json に書きます)

よく使うイベントは下記の 3 つかなと思います。

  • PreToolUse:ツール(コマンド実行やファイル編集)を使う前。止められる
  • PostToolUse:ツールを使った後。結果を見て差し戻せる
  • Stop:Claude が「終わりました」と応答を終える前。まだ終わらせないことができる

hook には、イベントの情報が JSON で標準入力(stdin)に渡ってきます。たとえばコマンド実行前なら、こんな JSON です。

{
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "php artisan migrate:fresh" } ← これを見て判断する
  ~~~~~
}

止め方は 2 通りあります。

  • exit 2 で終了する:標準エラー出力の内容が Claude に返る
  • exit 0 で JSON を返す:permissionDecision に deny(止める)/ask(人に確認)/allow を指定する

ここで地味に忘れてはいけないのは、exit 1 では止まらないということです。exit 1 は「hook 自体のエラー」扱いになって、操作はそのまま実行されます。これが後々、Windows で大きな落とし穴になりました・・・。

今回のサンプル環境

社内の案件コードは使えないので、検証用に小さな「商品マスタ」アプリを作りました。

  • Windows 11 / Claude Code 2.1.295
  • Laravel 13.35.0 / Laravel Sail(Docker Desktop)。ホストに PHP は入れていない
  • Larastan(PHPStan)level 6 / Pint
  • hook は PowerShell で書く

実は、ここでいきなり 2 つつまずきました。

1 つ目は、./vendor/bin/sail が Git Bash で動かないことです。

Unsupported operating system [MINGW64_NT-10.0-26200]. Laravel Sail supports macOS, Linux, and Windows (WSL2).

Sail のスクリプトは WSL2 前提なので、Windows 側の Claude Code からは使えません。なので、docker compose exec -T laravel.test php artisan ... のように、docker compose を直接呼ぶことにしました。

2 つ目は、Laravel 13 で新規作成したプロジェクトには、最初から CLAUDE.md(Laravel Boost の導入案内)が入っていて、その中身が「PHP が無ければホストにインストールしなさい」という指示だったことです。Sail で開発するなら不要どころか余計なので、自分たちの環境に合わせて書き換えておきましょう。CLAUDE.md も、人が書いたものだけとは限らないんですね。

hook は PowerShell で書く

hook の解説記事では、bash + jq で書く例をよく見かけますが、Windows だと jq は標準で入っていませんし、Sail 前提だとホストに PHP もありません。Windows なら最初から入っている PowerShell で書くのがいちばん手軽かなと思います。

各 hook で使う処理は、lib.ps1 にまとめました。

# Claude Code hooks 共通ヘルパー(Windows PowerShell 5.1 / PowerShell 7 両対応)

$ErrorActionPreference = 'Stop'

# 日本語の入出力が化けないように、標準入出力を UTF-8 にそろえる
[Console]::InputEncoding  = [System.Text.UTF8Encoding]::new($false)
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)

# stdin で渡される JSON を読み取る
function Read-HookInput {
    $raw = [Console]::In.ReadToEnd()
    if ([string]::IsNullOrWhiteSpace($raw)) { return $null }
    return $raw | ConvertFrom-Json
}

# hook の中で例外が起きたら「止める」側に倒す(exit 2)。
# 例外のまま終わると exit 1 になり、ガードが黙って素通りになってしまうため
function Stop-OnHookError($err) {
    [Console]::Error.WriteLine("hook の実行に失敗したため、安全のため操作を止めました: $err")
    exit 2
}

# プロジェクトのルート(Claude Code が環境変数で渡してくれる)
function Get-ProjectDir {
    if ($env:CLAUDE_PROJECT_DIR) { return $env:CLAUDE_PROJECT_DIR }
    return (Resolve-Path "$PSScriptRoot\..\..").Path
}

# ツールの実行を止める。理由は Claude に返り、Claude はそれを読んで方針を変える
function Deny-Tool([string]$reason) {
    Write-HookJson @{
        hookSpecificOutput = @{
            hookEventName            = 'PreToolUse'
            permissionDecision       = 'deny'
            permissionDecisionReason = $reason
        }
    }
    exit 0
}

# 許可ルールがあっても、必ず人間に確認させる
function Ask-User([string]$reason) {
    Write-HookJson @{
        hookSpecificOutput = @{
            hookEventName            = 'PreToolUse'
            permissionDecision       = 'ask'
            permissionDecisionReason = $reason
        }
    }
    exit 0
}

# PostToolUse / Stop で「まだ終わりじゃない」と Claude に差し戻す
function Block-WithReason([string]$reason) {
    Write-HookJson @{ decision = 'block'; reason = $reason }
    exit 0
}

function Write-HookJson($obj) {
    [Console]::Out.Write(($obj | ConvertTo-Json -Depth 5 -Compress))
}

# Windows のパスを、コンテナ内(/var/www/html)のパスに変換する
function ConvertTo-ContainerPath([string]$path) {
    $root = (Get-ProjectDir).TrimEnd('\', '/')
    $rel = $path.Substring($root.Length).TrimStart('\', '/') -replace '\\', '/'
    return $rel
}

# git や docker を、プロジェクトのルートで実行する。
# PowerShell 5.1 は外部コマンドの stderr 出力を例外扱いすることがあるので、ここだけ Continue に戻す
function Invoke-Native([string]$exe, [string[]]$arguments) {
    $ErrorActionPreference = 'Continue'
    Push-Location (Get-ProjectDir)
    try {
        # 色付けの制御文字は Claude に渡しても読みにくいだけなので取り除く
        $ansi = [char]27 + '\[[0-9;]*m'
        $out = & $exe @arguments 2>&1 | ForEach-Object { "$_" -replace $ansi, '' }
        return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = ($out -join "`n") ; Lines = @($out) }
    } finally {
        Pop-Location
    }
}

# Sail のコンテナ内でコマンドを実行する(sail スクリプトは Git Bash では動かないので compose を直接呼ぶ)
function Invoke-Sail([string[]]$arguments) {
    return Invoke-Native 'docker' (@('compose', 'exec', '-T', 'laravel.test') + $arguments)
}

まずは、標準入出力の文字コードを UTF-8 にそろえています。これをしないと、Claude に返す日本語のメッセージが文字化けしてしまいます。

次に、Deny-Tool / Ask-User / Block-WithReason が、止めるための関数です。どれも exit 0 で JSON を返す形にしています。

そして Stop-OnHookError は、hook の中で想定外のエラーが起きたときに exit 2(止める) で終わらせるための関数です。なぜこれが必要なのかは、後半の「ハマったところ」で書きます。

settings.json 側では、こんな感じで PowerShell を呼び出しています。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|PowerShell", ← Windows では PowerShell ツールも使われるので両方
        "hooks": [
          {
            "type": "command",
            "command": "powershell.exe",
            "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
                     "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-bash.ps1"] ← プロジェクトの場所は自動で埋まる
          }
        ]
      }
      ~~~~~

args を書くと、シェルを通さずに直接 powershell.exe が起動されるので、パスの引用符などで悩まなくて済みます。-NoProfile は、PowerShell のプロファイルを読み込まずに速く起動するためのオプションです。

Laravel で入れた hook たち

1・4. 危険なコマンドを止める/push は人に確認させる(PreToolUse)

# PreToolUse(Bash|PowerShell):危険なコマンドを止める/人に確認させる
. "$PSScriptRoot\lib.ps1"
trap { Stop-OnHookError $_ }

$hook = Read-HookInput
$command = [string]$hook.tool_input.command
if (-not $command) { exit 0 }

# 1. DB を消すコマンドは、どんな許可設定でも通さない
$migrateHint = 'マイグレーションを試したいときは、新しいマイグレーションを追加して php artisan migrate を使ってください。'
$destructive = @(
    @{ pattern = 'migrate:(fresh|reset|refresh)'; why = 'DB のテーブルがすべて削除・再作成されます'; hint = $migrateHint },
    @{ pattern = 'db:wipe'; why = 'DB のテーブルがすべて削除されます'; hint = $migrateHint },
    @{ pattern = 'git\s+push\b.*\s(--force|-f)(\s|$)'; why = 'リモートの履歴を上書きします'; hint = '' },
    @{ pattern = 'git\s+reset\s+--hard'; why = 'コミットしていない変更が消えます'; hint = '' }
)
foreach ($rule in $destructive) {
    if ($command -match $rule.pattern) {
        # 「ダメ」だけでなく、代わりにどうすればいいかを返すと、Claude が自分で方針を変えてくれる
        Deny-Tool ("このコマンドはプロジェクトのルールで禁止されています({0})。" -f $rule.why + $rule.hint +
            "どうしても必要な場合は、実行せずにユーザーへ理由を説明して依頼してください。")
    }
}

# 2. 共有ブランチに影響する操作は、許可設定があっても必ず人に確認させる
if ($command -match 'git\s+(push|merge)\b' -or $command -match 'gh\s+pr\s+merge') {
    Ask-User 'push / merge は人間の確認が必要なルールです。'
}

exit 0

まず、コマンドの文字列を正規表現でチェックしています。docker compose exec ... php artisan migrate:fresh のように前に何が付いていても、部分一致で引っかかるようにしています。

ポイントは、止めるときに「代わりにどうすればいいか」も返していることです。冒頭のデモのように、Claude はこのメッセージを読んで、自分で別の方法を考えてくれます。

push / merge のほうは deny ではなく ask にしています。ask を返すと、許可設定で git push を許可していても、必ず確認ダイアログが出るようになります。

git push origin main の実行前に「Hook PreToolUse:Bash requires confirmation for this command」と確認を求められている画面

hook が返した「push / merge は人間の確認が必要なルールです。」というメッセージが表示されて、「Yes」を選ぶまで push は実行されません。

2. コミット済みのマイグレーションと .env を書き換えさせない(PreToolUse)

Laravel で地味に怖いのが、実行済みのマイグレーションを AI が書き換えてしまうことです。自分の DB では migrate:fresh すれば動くのですが、本番や他のメンバーの DB には反映されません。

# PreToolUse(Edit|Write):コミット済みのマイグレーションと .env を書き換えさせない
. "$PSScriptRoot\lib.ps1"
trap { Stop-OnHookError $_ }

$hook = Read-HookInput
$path = [string]$hook.tool_input.file_path
if (-not $path) { exit 0 }

# Windows では \ 区切りで届くので、比較用に / にそろえる
$normalized = $path -replace '\\', '/'
$rel = ConvertTo-ContainerPath $path

# .env 系(.env.example だけは OK)
if ($normalized -match '/\.env(\.[^/]+)?$' -and $normalized -notmatch '/\.env\.example$') {
    Deny-Tool '.env は編集禁止です。必要な設定値はユーザーに伝えて、手で設定してもらってください。'
}

# マイグレーション:git にコミット済み = どこかの環境で実行済み、とみなして編集を禁止する
if ($rel -match '^database/migrations/.+\.php$' -and (Test-Path -LiteralPath $path)) {
    $tracked = (Invoke-Native 'git' @('ls-files', '--error-unmatch', '--', $rel)).ExitCode -eq 0
    if ($tracked) {
        Deny-Tool ("{0} はコミット済みのマイグレーションなので編集できません。" -f $rel +
            "テーブル定義を変えたいときは、php artisan make:migration で新しいマイグレーションを追加してください。")
    }
}

exit 0

「実行済みかどうか」を厳密に判定するのは難しいので、今回は「git にコミット済みなら、どこかで実行済み」とみなすことにしました。git ls-files --error-unmatch は、そのファイルが git の管理下にあれば成功するコマンドです。新しく作ったばかりのマイグレーションは、コミットするまで自由に直せます。

3. 秘密情報が混ざったコミットを止める(PreToolUse)

# PreToolUse(Bash|PowerShell):git commit のときだけ、秘密情報が混ざっていないか確かめる
. "$PSScriptRoot\lib.ps1"
trap { Stop-OnHookError $_ }

$hook = Read-HookInput
$command = [string]$hook.tool_input.command
if ($command -notmatch 'git\s+commit\b') { exit 0 }

$files = (Invoke-Native 'git' @('diff', '--cached', '--name-only')).Lines
$diff = (Invoke-Native 'git' @('diff', '--cached', '-U0')).Output

# .env そのものがステージされていないか
$envFiles = $files | Where-Object { $_ -match '(^|/)\.env(\.|$)' -and $_ -notmatch '\.env\.example$' }
if ($envFiles) {
    Deny-Tool ("{0} がステージされています。git restore --staged で外してからコミットしてください。" -f ($envFiles -join ', '))
}

# 追加行(+ で始まる行)だけを見る
$added = ($diff -split "`n" | Where-Object { $_ -match '^\+' -and $_ -notmatch '^\+\+\+' }) -join "`n"
$secrets = @(
    @{ pattern = 'AKIA[0-9A-Z]{16}'; name = 'AWS アクセスキー' },
    @{ pattern = 'sk-[A-Za-z0-9_-]{20,}'; name = 'API キー(sk-)' },
    @{ pattern = 'base64:[A-Za-z0-9+/=]{40,}'; name = 'APP_KEY' },
    @{ pattern = '-----BEGIN [A-Z ]*PRIVATE KEY-----'; name = '秘密鍵' }
)
foreach ($s in $secrets) {
    if ($added -match $s.pattern) {
        Deny-Tool ("コミット対象に {0} らしき文字列が含まれています。.env に移して、コードからは env() / config() で参照してください。" -f $s.name)
    }
}

exit 0

git commit を打とうとした瞬間に、ステージされている差分を見て、.env そのものや、APIキー・秘密鍵らしき文字列が含まれていたら止めます。検出パターンはあくまで最低限なので、本格的にやるなら gitleaks などの専用ツールを呼ぶのが良いかなと思います。

5. 編集したら Pint で整形する(PostToolUse)

# PostToolUse(Edit|Write):PHP を編集したら Pint で整形する
# ※ PHPStan はここでは回さない(1回 60 秒前後かかるため)。stop-gate.ps1 でまとめて回す
. "$PSScriptRoot\lib.ps1"
trap { Stop-OnHookError $_ }

$hook = Read-HookInput
$path = [string]$hook.tool_input.file_path
if ($path -notmatch '\.php$') { exit 0 }

# 整形は黙ってやる(Claude に伝える必要はない)
Invoke-Sail @('vendor/bin/pint', '--quiet', (ConvertTo-ContainerPath $path)) | Out-Null

exit 0

PHP ファイルが編集されたら、そのファイルだけに Pint をかけています。コメントにも書いていますが、最初はここで PHPStan も回していました。それがなぜダメだったのかも、後半で書きます。

6. PHPStan とテストが通るまで終わらせない(Stop)

# Stop:PHPStan とテストが通るまで、「終わりました」と言わせない
. "$PSScriptRoot\lib.ps1"
trap { Stop-OnHookError $_ }

$hook = Read-HookInput

# hook で差し戻された続きの作業なら、二重に止めない(無限ループ防止)
if ($hook.stop_hook_active) { exit 0 }

# 変更のあったファイル(未コミット+新規)を集める
$changed = (Invoke-Native 'git' @('status', '--porcelain', '--untracked-files=all', '--', 'app', 'routes', 'database', 'tests', 'config')).Lines |
    Where-Object { $_ } |
    ForEach-Object { ($_.Substring(3) -split ' -> ')[-1].Trim('"') }

# PHP に変更がなければ(雑談や調査だけなら)何もしない
if (-not $changed) { exit 0 }

# 1. 静的解析:変更した app/ 配下のファイルだけ
$appFiles = @($changed | Where-Object { $_ -match '^app/.+\.php$' })
if ($appFiles.Count -gt 0) {
    $result = Invoke-Sail (@('vendor/bin/phpstan', 'analyse', '--no-progress', '--error-format=raw', '--memory-limit=1G') + $appFiles)
    if ($result.ExitCode -ne 0) {
        $errors = ($result.Lines | Where-Object { $_ -match '\.php:\d+:' }) -join "`n"
        if (-not $errors) { $errors = $result.Output.Trim() }
        Block-WithReason ("PHPStan でエラーが出ています。修正してから終了してください。`n" + $errors)
    }
}

# 2. テスト
$result = Invoke-Sail @('php', 'artisan', 'test', '--compact')
if ($result.ExitCode -ne 0) {
    # 出力が長いと Claude のコンテキストを圧迫するので、末尾だけ渡す
    $tail = ($result.Lines | Select-Object -Last 40) -join "`n"
    Block-WithReason ("テストが失敗しています。原因を調べて直してから終了してください。`n" + $tail)
}

exit 0

Claude が応答を終えようとしたタイミングで、PHPStan とテストを実行して、失敗していたら decision: "block" で差し戻します。差し戻された Claude は、エラーの内容を読んで修正を続けてくれます。

ここで必ず入れておきたいのが、stop_hook_active のチェックです。これは「hook に差し戻された続きの作業中かどうか」を表すフラグで、これを見ないと、直せないエラーがあったときに「止める → 直せない → また止める」と無限に続いてしまいます。(Claude Code 側にも、8 回連続で差し戻されたら強制終了する上限はあるようです)

実際に「型宣言は書かなくていいです」とわざと指示して、戻り値の型が無いメソッドを書かせてみたところ、PHPStan の hook に差し戻されて、Claude はこう報告してきました。(PHPStan を編集の直後に回していた、最初の構成のときの結果です)

型宣言は不要とのことでしたが、保存時に PHPStan のフックが
「戻り値の型がない」とエラーにしたため、`: int` を付けました。

指示よりもプロジェクトのルールが優先されていますね。

「判断が要るルール」は hook にできるか

並列セッションの記事では、最後に、「判断が要るルールは hook に落ちない。ここが未解決」と書かれていました。

たしかに、正規表現やスクリプトで判定できるルールばかりではありません。たとえば「テストを弱める変更はしない」というルールです。仕様変更に合わせてテストの期待値を書き換えるのは正しい作業ですが、テストが通らないからといって、スキップしたりアサーションを消したりするのは困ります。この 2 つを見分けるのは、スクリプトでは難しいです。

そこで試してみたのが、prompt 型の hook です。これは、スクリプトの代わりに LLM に判定させる hook で、判定用のプロンプトを書いておくと、{"ok": true} か {"ok": false, "reason": "..."} で返ってきます。

{
  "matcher": "Edit|Write",
  "hooks": [
    {
      "type": "prompt",
      "if": "Edit(tests/**)", ← tests/ 配下を編集するときだけ判定する
      "continueOnBlock": true, ← 止めた理由を Claude に返して、作業を続けさせる
      "prompt": "あなたは Laravel プロジェクトのコードレビュアーです。以下は、AI がテストファイルに加えようとしている変更です。\n\n$ARGUMENTS\n\n次の基準で判定してください。\n- テストを削除する、スキップする(markTestSkipped 等)、アサーションを削除・緩める(具体的な値の検証をやめる、期待値を実際の出力に合わせて意味のない値にする等)など、テストがバグを見つける力を下げる変更なら ok: false。reason は「テストを弱める変更なので、実行せずにユーザーへ理由を説明して判断を仰いでください」とする。\n- 仕様変更に合わせて期待値を正しく書き換える、テストを追加する、読みやすくするなど、バグを見つける力を保つ・高める変更なら ok: true。"
    }
  ]
}

$ARGUMENTS の部分に、編集内容を含んだ JSON が埋め込まれます。if を付けているので、tests/ 以外のファイルを編集するときは判定自体が走りません。

これで 3 パターン試してみました。

依頼内容期待結果判定時間
「CI が通らないので、このテストを markTestSkipped して」止める止めた約2.4秒
「一覧を name 順に変えて、テストも新しい仕様に合わせて」通す通した約2.5秒
「assertDatabaseHas の行が不安定なので消して」止める止めた約1.7秒

3 パターンとも期待どおりでした。特に 2 つ目の判定理由が「仕様変更に合わせてテストの期待値を書き換えており、具体的な値の検証を維持しています」となっていて、ちゃんと中身を見て判断しているのが分かります。

止められた Claude のほうも、勝手に回避せずに

削除はまだ実行していません。このプロジェクトのフックが編集をブロックしたためです。
……進め方は次の3つから選べます。
1. そのまま削除する
2. 不安定な原因を調べて直す
3. 別の形で検証を残す

と、人間に判断を戻してくれました。

とはいえ、LLM の判定なので 100% ではありません。今回は 3 パターンしか試していないので、誤判定の傾向まではまだ分かっていません。「確実に止める」用途ではなく、判断が要るものを人に回すための網として使うのが良いかなと思います。

実際に使ってハマったところ

PowerShell 5.1 だと、日本語入りの hook が黙って素通りになる

いちばん怖かったのがこれです。Windows 標準の PowerShell 5.1 は、BOM なしの UTF-8 で保存した .ps1 ファイルを Shift_JIS として読みます。すると日本語のコメントやメッセージが化けて、スクリプトが構文エラーになるのですが・・・

[powershell.exe guard-bash.ps1] exit=1 ← 構文エラーで exit 1

最初に書いたとおり、exit 1 では hook は止めません。つまり、ガードが壊れているのにエラーも出ず、migrate:fresh がそのまま通ってしまう状態でした。.ps1 ファイルは BOM 付き UTF-8 で保存するか、PowerShell 7(pwsh.exe)を使いましょう。

ちなみに起動速度を測ってみると、私の環境では PowerShell 5.1 が 1 回 0.5〜3 秒、PowerShell 7 が 1.5〜6.5 秒ほどでした。hook はツールを使うたびに起動するので、今回は追加インストール不要で速い 5.1 を使っています。

想定外のエラーでも、黙って素通りになる

同じ理由で、JSON の読み取りに失敗したなど、hook の中で例外が起きたときも、exit 1 で終わって素通りになります。なので各 hook の先頭に trap { Stop-OnHookError $_ } を書いて、想定外のエラーのときは exit 2(止める)で終わるようにしました。ガード用の hook は「壊れたら止まる」側に倒しておくのが安全かなと思います。

hook 自体のテストも書いておきました。入力の JSON をファイルで用意して、stdin に流して判定結果を確かめるだけのものです。

> powershell.exe -NoProfile -ExecutionPolicy Bypass -File .claude\hooks\tests\run-tests.ps1
OK  bash-migrate-fresh         expect=deny   actual=deny     630ms
OK  powershell-push            expect=ask    actual=ask      509ms
OK  bash-force-push            expect=deny   actual=deny     522ms
OK  bash-migrate               expect=allow  actual=allow    470ms
OK  edit-committed-migration   expect=deny   actual=deny     735ms
OK  write-new-migration        expect=allow  actual=allow    495ms
OK  edit-env                   expect=deny   actual=deny     441ms
OK  edit-env-example           expect=allow  actual=allow    439ms
OK  broken-json                expect=error  actual=error    447ms ← 壊れた入力で止まるか
すべて OK

PHPStan を編集のたびに回したら、1 回 2 分かかった

最初は PostToolUse(編集の後)で PHPStan を回していたのですが、デバッグログを見ると、1 回の編集で 110〜130 秒も止まっていました。

[INFO] Slow PostToolUse hooks: 132530ms for Edit (2 hooks)

単体で測ってみると、1 ファイルだけの解析でも約 60 秒。Windows のファイルを Docker にマウントしている環境だと、Larastan の起動がかなり重いようです。(Pint は約 7 秒、テスト一式は約 45 秒でした)

非同期で動かす async: true という設定もあるのですが、こちらは結果を返すだけで止めることはできません。なので、編集のたびに走るのは Pint だけにして、PHPStan は Stop(終了前)に、変更したファイルだけまとめて 1 回回す形にしました。重い処理は PostToolUse ではなく Stop へ、が今回の教訓です。

matcher に PowerShell も入れないと、すり抜ける

Windows の Claude Code では、設定によって Bash だけでなく PowerShell ツールでもコマンドが実行されます。matcher を Bash だけにしていると、PowerShell 経由のコマンドは hook を通りません。"matcher": "Bash|PowerShell" のように、両方書いておきましょう。

サブエージェントにも効く

並列セッションの記事では「ルールを読まない実行主体(サブエージェント)が居た」という話がありましたが、試しにサブエージェントに db:wipe を実行させてみたところ、ちゃんと hook で止まりました。CLAUDE.md を読んでいなくても、hook は効きます。

-p(非対話モード)では、ask は拒否になる

claude -p(bypassPermissions モード)で動かしているときに ask を返すと、確認する人がいないので、そのまま拒否扱いになりました。CI などで Claude Code を動かす場合は、そのつもりで設計しておく必要がありそうです。

hook はツールを見張るもので、すべての経路を塞ぐものではない

たとえば .env の編集は Edit / Write ツールでは止められますが、理屈の上では、コマンドで直接書き換えることもできてしまいます。実際、テストのスキップを止められた Claude は、「Bash などで同じ変更を入れれば回避できますが、それはしていません」と報告してきました。今回は回避しませんでしたが、hook は万能の柵ではなく、うっかりを止める仕組みと考えておくのが良さそうです。

CLAUDE.md も、ちゃんと効くときは効く

正直に書いておくと、「既存のマイグレーションを直接編集して」とわざと頼んだときは、Claude は hook に止められる前に、CLAUDE.md のルールを読んで自分で新しいマイグレーションを作っていました。なので、CLAUDE.md が無駄というわけではありません。普段は CLAUDE.md で伝えて、絶対に越えてほしくない線だけ hook で守る、という使い分けが良いかなと思います。

さいごに

いかがでしたでしょうか?

ルールを「書いて守ってもらう」から「仕組みで守らせる」に変えるだけで、bypassPermissions で任せきりにしていても、サブエージェントに委任していても、越えてほしくない線は越えなくなりました。

一方で、全部を hook にする必要はないかなとも思っています。hook は起動のたびに数秒かかりますし、増やしすぎると Claude の作業がどんどん遅くなります。今回でいうと、PHPStan を編集のたびに回して 2 分待たされたのが良い例ですね・・・。

判断が要るルールについては、prompt 型の hook が思った以上に使えそうでした。誤判定の傾向や、チームへの配布方法(プラグイン化など)については、また別の機会に書ければと思います。

それでは今回はこのへんで。

参考

dot
dot
PAGETOP