先日、Claude Code の PostToolUse hook に Bash の matcher を足しました。それまでは Edit|Write|MultiEdit にだけ張っていて、エージェントが sed -i やヒアドキュメントで書いたファイルは、型チェックも保護パスの検査も通っていませんでした。 この記事は、前回のルールファイルを 38 行にした話で hooks に移した禁止事項が、実はざるだったという話です。
最初に張った 2 本
hooks は .claude/settings.json に書きます。最初の版は PreToolUse と PostToolUse の 2 本で、どちらも matcher は Edit|Write|MultiEdit でした。
hook は stdin で JSON を受け取ります。tool_input.file_path を見ればどのファイルが編集されるかが分かります。止めたい時は exit 2 で stderr に理由を書くと、その内容がエージェントに返ります。それ以外の終了は無言で通ります。
書き込む前に止める
#!/usr/bin/env bash
# PreToolUse: 触ってはいけない場所への書き込みをブロックする。
# exit 2 = ブロックして stderr をエージェントに返す。それ以外は無言で通す。
set -u
input=$(cat)
file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
[ -z "$file" ] && exit 0
root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
rel="${file#"$root"/}"
deny() { echo "BLOCKED: $rel — $1" >&2; exit 2; }
case "$rel" in
docs/idea/*) deny "原典は読み取り専用。変更点は docs/wiki か docs/adr に書く" ;;
docs/tasks/archive/*) deny "アーカイブ済みタスクは編集しない。必要なら新しいタスクを作る" ;;
cdk.out/*|*/cdk.out/*) deny "cdk.out は生成物。ソースを直す" ;;
.env|.env.*|*/.env|*/.env.*) deny "秘密情報ファイルはエージェントが書かない" ;;
esac
# ADR: Accepted 済みの ADR は書き換えず、新しい ADR で上書き(supersede)する
if [[ "$rel" == docs/adr/[0-9]*.md && -f "$file" ]]; then
if grep -qiE '^\*\*Status\*\*: *Accepted' "$file"; then
deny "Accepted 済み ADR。変更は新しい ADR を作り、この ADR を Superseded にする(skill: adr)"
fi
fi
exit 0
止める理由に「代わりにどうするか」を書いています。エージェントは stderr を読んで、言われた通り新しい ADR を作りに行きます。これは最初から入れておいてよかったと思っている点です。
書き込んだ後に型チェックする
編集されたファイルから最寄りの package.json を探し、そのパッケージで tsc --noEmit を、そのファイルに eslint を回します。monorepo なので全体ではなくパッケージ単位です。手元で測ると tsc は 1 パッケージ 2 秒ちょっとでした。成功は無言、失敗は exit 2 で出力を返します。
if [ "$fail" -ne 0 ]; then
printf '%s\n' "$out" | head -60 >&2
echo "→ 上のエラーを直してから次の作業に進むこと。" >&2
exit 2
fi
最後の 1 行は後から足しました。エラー出力だけ返すと、エージェントが「エラーが出ています」と報告して次の作業に進むことがあったからです。「直してから進め」と書いてからは、そのまま直しに行きます。
Bash ツールは file_path を持っていない
エージェントがファイルを書く方法は Edit と Write だけではありません。
sed -i 's/旧/新/' docs/idea/xxx.md
cat > docs/idea/xxx.md <<'EOF'
...
EOF
python -c で書くこともあります。これらは Bash ツールの呼び出しなので、Edit/Write に張った hook は起動しません。Bash ツールの tool_input に入っているのは command の文字列だけで、どのファイルが変わるかは実行してみないと分かりません。
つまり最初の 1 週間、保護パスの検査も tsc も、エージェントが Bash で書いた分には一度も走っていませんでした。
実行後に git に聞く
PostToolUse に Bash の matcher を足しました。
{
"hooks": {
"PreToolUse": [
{ "matcher": "Edit|Write|MultiEdit",
"hooks": [{ "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-paths.sh" }] }
],
"PostToolUse": [
{ "matcher": "Edit|Write|MultiEdit",
"hooks": [{ "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-check.sh", "timeout": 120 }] },
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-bash-check.sh", "timeout": 180 }] }
]
}
}
Bash 側は stdin からファイル名が取れないので、設計を変えました。変更ファイルは git status --porcelain --untracked-files=all に聞きます。未コミットの変更と追加と未追跡を拾い、検査したい拡張子だけ残します。
問題は Bash ツールが ls や git log でも呼ばれることで、そのたびに tsc を回すのは無駄です。変更ファイルの「パス:mtime」一覧を hash した指紋を保存しておき、前回と同じなら即 exit 0 にしました。指紋を更新するのは検査に通った時だけなので、失敗したままだと直すまで毎回引っかかります。
PreToolUse でのブロックはできません。実行前にはファイルが分からないからです。代わりに実行後に検出して「戻せ」と返します。
#!/usr/bin/env bash
# PostToolUse(Bash): Bash 経由の編集(ヒアドキュメント、sed、Python)にも Edit/Write と同じ網をかける。
# 前回チェック以降に「関係するファイル」が変わった時だけ検査する(指紋 = パス:mtime の一覧の hash)。
# 失敗は exit 2 で stderr をエージェントに返す(自己修正ループ)。
set -u
root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
cd "$root" || exit 0
state="$root/.claude/.hook-state"
mkdir -p "$state"
# 未コミットの変更(変更・追加・未追跡)から、検査対象のパスだけ拾う
changed=$(git status --porcelain --untracked-files=all 2>/dev/null | awk '{print $NF}' | grep -E '\.(ts|tsx|md|json)$' || true)
[ -z "$changed" ] && exit 0
fp=$(for f in $changed; do [ -f "$f" ] && stat -c '%n:%Y' "$f"; done | sha1sum | cut -c1-16)
if [ -f "$state/fingerprint" ] && [ "$(cat "$state/fingerprint")" = "$fp" ]; then exit 0; fi
fail=0; out=""
# 1) 触ってはいけない場所(protect-paths と同じ)
for f in $changed; do
case "$f" in
docs/idea/*) fail=1; out+="BLOCKED: $f — 原典は読み取り専用。変更点は docs/wiki か docs/adr に書く"$'\n' ;;
docs/tasks/archive/*)
# HEAD に既にあるファイル(=過去にアーカイブ済み)が HEAD と差分を持つ時だけ弾く。
# 新規に archive へ移した直後(git mv / mv とも、HEAD にはまだ無い)は対象外。
if git cat-file -e "HEAD:$f" 2>/dev/null && ! git diff HEAD --quiet -- "$f" 2>/dev/null; then
fail=1; out+="BLOCKED: $f — アーカイブ済みタスクは編集しない"$'\n'
fi ;;
esac
done
# Accepted 済み ADR の書き換え(HEAD で Accepted だったものに差分がある)
for f in $(echo "$changed" | grep -E '^docs/adr/[0-9]{4}-.*\.md$' || true); do
if git ls-files --error-unmatch "$f" >/dev/null 2>&1 && ! git diff --quiet -- "$f"; then
if git show "HEAD:$f" 2>/dev/null | grep -qiE '^\*\*Status\*\*: *Accepted'; then
fail=1; out+="BLOCKED: $f — Accepted 済み ADR。変更は新しい ADR を作り、この ADR を Superseded にする(skill: adr)"$'\n'
fi
fi
done
# 2) TypeScript: 変更のあったパッケージごとに tsc、変更ファイルに eslint
pkgs=$(for f in $(echo "$changed" | grep -E '\.(ts|tsx)$' || true); do d=$(dirname "$f"); while [ "$d" != "." ] && [ ! -f "$d/package.json" ]; do d=$(dirname "$d"); done; [ -f "$d/package.json" ] && echo "$d"; done | sort -u)
bin="$root/node_modules/.bin"
for p in $pkgs; do
[ -f "$p/tsconfig.json" ] && [ -x "$bin/tsc" ] || continue
r=$(cd "$p" && "$bin/tsc" --noEmit -p tsconfig.json 2>&1) || { fail=1; out+="[tsc: $p]"$'\n'"$r"$'\n'; }
done
tsfiles=$(echo "$changed" | grep -E '\.(ts|tsx)$' | while read -r f; do [ -f "$f" ] && echo "$f"; done || true)
if [ -n "$tsfiles" ] && [ -x "$bin/eslint" ]; then
r=$("$bin/eslint" --no-warn-ignored $tsfiles 2>&1) || { fail=1; out+="[eslint]"$'\n'"$r"$'\n'; }
fi
# 3) 設計書の生成元・設計書・ADR を触ったら docs:check
if echo "$changed" | grep -qE '^(packages/shared/src/(schema|keys|api-routes|api-contract)\.ts|packages/infra/lib/|docs/adr/|docs/design/|docs/context\.md|docs/tasks/_index\.md)'; then
r=$(npm run docs:check 2>&1) || { fail=1; out+="[docs:check]"$'\n'"$(printf '%s\n' "$r" | grep -vE '^(>|$)' | head -40)"$'\n'"→ 設計書がコードと乖離している。npm run docs:gen を実行し、生成された差分を確認すること。"$'\n'; }
fi
if [ "$fail" -ne 0 ]; then
printf '%s\n' "$out" | head -80 >&2
echo "→ 上を直してから次の作業に進むこと(Bash 経由の編集も Edit/Write と同じ検査を受ける)。" >&2
exit 2
fi
echo "$fp" > "$state/fingerprint"
exit 0
.claude/.hook-state/ は .gitignore に入れています。
3 日後に誤検知した
その 3 日後、タスクを 1 つ閉じて docs/tasks/archive/ にファイルを移した直後に、この hook が止めました。
BLOCKED: docs/tasks/archive/T027-xxx.md — アーカイブ済みタスクは編集しない
Edit/Write 用の hook では「archive 配下への書き込みは全部止める」で困りませんでした。archive への移動は git mv なので Write ツールを通らないからです。ところが Bash 用の hook は git status を見ているので、移したばかりのファイルが archive 配下の未追跡ファイルとして出てきます。当時の判定はこうでした。
git ls-files --error-unmatch "$f" >/dev/null 2>&1 && git diff --quiet -- "$f" || { fail=1; ... }
未追跡のファイルは git ls-files --error-unmatch が失敗するので、移動しただけで「編集」扱いになります。そこで「HEAD に既にあるファイルが HEAD と差分を持つ時だけ止める」に変えました。
if git cat-file -e "HEAD:$f" 2>/dev/null && ! git diff HEAD --quiet -- "$f" 2>/dev/null; then
過去にアーカイブ済みのファイルは HEAD に存在するので、それが書き換えられた時だけ止まります。今回移したファイルは HEAD に無いので通ります。
同じ考え方を Accepted 済み ADR の判定にも使っています。PreToolUse では作業ツリーのファイルを grep して Status を見れば足りましたが、Bash 用では作業ツリーは既に書き換えられた後です。エージェントが Status を Superseded に書き換えてから本文を編集すると、作業ツリーを見る判定は通してしまいます。なので git show "HEAD:$f" でコミット済みの状態を見ています。こちらは誤検知が起きる前に気づいたのではなく、archive の修正をしている時に「同じ穴があるな」と思って一緒に直しました。
Kiro からも同じスクリプトを呼ぶ
Kiro の hooks も stdin で JSON を渡し、exit code で結果を返す形式なので、同じ bash スクリプトを呼べます。違うのは設定ファイルの形と matcher の名前です。
{
"version": "v1",
"hooks": [
{ "name": "harness-protect-paths", "trigger": "PreToolUse", "matcher": "write",
"action": { "type": "command", "command": "bash .harness/hooks/protect-paths.sh" }, "timeout": 15 },
{ "name": "harness-post-edit-check", "trigger": "PostToolUse", "matcher": "write",
"action": { "type": "command", "command": "bash .harness/hooks/post-edit-check.sh" }, "timeout": 120 },
{ "name": "harness-post-shell-check", "trigger": "PostToolUse", "matcher": "shell",
"action": { "type": "command", "command": "bash .harness/hooks/post-bash-check.sh" }, "timeout": 180 }
]
}
ただし、Kiro の write ツールが tool_input のどのキーにファイルパスを入れるかは、まだ確認できていません。file_path、path、filePath の順に見る形にしてありますが、実機で試すまでは動く保証がありません。skills ディレクトリの symlink も同様です。ここは確認でき次第、追記します。
まだ決めかねていること
Bash 用の hook は事後検出なので、原典を sed で書き換えた場合は「書き換わった後で止める」ことしかできません。エージェントは stderr を読んで戻しますが、戻し忘れる可能性はあります。PreToolUse で command 文字列を正規表現で見て docs/idea/ への > や sed -i を弾く案も考えましたが、パスの書き方が無限にあるので、今のところやっていません。git で保護パスの差分を見張る方が確実だと思っていますが、実際に戻し忘れが起きるまでは様子見です。
timeout は Edit/Write 用で 120 秒、Bash 用で 180 秒にしています。tsc が 2〜3 秒なので余裕がありすぎる気もしますが、docs:check が Docker で図を描く経路を持っているので、そちらに合わせています。この値も一度も引っかかっていないので、根拠は薄いです。
前の記事で AGENTS.md を 38 行に収められたのは、禁止事項をこの hooks に預けたからでした。ルールファイルには「なぜ」を 1 行、hooks には「どう止めるか」を書く、という分担になっています。
追記: 日本語名と空白入りのパスが素通りしていた
公開後にコメントで指摘をもらいました。core.quotePath が既定の true のままだと、git status --porcelain は非 ASCII や空白を含むパスを "docs/idea/\344\274\201\347\224\273.md" のように引用符付きで出します。先頭が " なので docs/idea/* に一致せず、末尾も md" なので拡張子の grep にも落ちて、そのファイルは検査されていませんでした。手元でも再現しました。
直し方は git status --porcelain -z --untracked-files=all にして NUL 区切りで読むことです。パスが引用もエスケープもされずにそのまま出るので、空白入りのパスで awk の $NF や for が切れる問題も一緒に消えます。git mv は移動先の後に移動元がもう 1 つ NUL 区切りで続くので、そこだけ読み飛ばします。
changed=(); renamed=()
while IFS= read -r -d '' entry; do
xy=${entry:0:2}; path=${entry:3}
case "$xy" in R*|C*|?R|?C) IFS= read -r -d '' _old || true; renamed+=("$path") ;; esac
case "$path" in *.ts|*.tsx|*.mts|*.cts|*.md|*.json) changed+=("$path") ;; esac
done < <(git status --porcelain -z --untracked-files=all 2>/dev/null || true)
以降は "${changed[@]}" で回し、eslint にも配列で渡します。日本語名と空白入りのパスを保護ディレクトリに置くテストを足して、旧スクリプトでは落ち、新スクリプトでは止まることを確認しました。指摘してくれた方、ありがとうございました。