Claude Code のガードレールはフックで作る:エージェントに push させない運用
目次
Claude Code のガードレールは、CLAUDE.md に書いた文ではなく PreToolUse のフックで作ります。Convika では、エージェントの push、GitHub で実行を始める操作、手で打つ本番のタグをフックが止めます。push とリリースはリードの 1 か所だけです。きっかけは 10 月 14 日でした。「実装担当は push しない」と文で決めていたのに 3 回 push され、GitHub Actions が 79 分ぶん課金されました。
ガードのフックにはテスト 3 本があり、10 月 17 日の時点で 2,003 のチェックが通っています。もう 1 つの教訓は 10 月 13 日のことです。名前を付けて起動したエージェントには、定義ファイルに書いたフックが付かず、ブラウザを操作する担当のガードが一度も動いていませんでした(その日の Claude Code での挙動)。それからはガードを定義ファイルではなく .claude/settings.json から掛けています。
Convika は AI でつくったランディングページを公開するサービスです。人間 1 人と、役割を分けた Claude Code のエージェントの組織で運営しています。組織の全体は「SaaS の運営を AI エージェントの組織に任せた 17 日の記録」に書きました。この記事はそのうちのガードの部分、コピーして使えるフックとテストまで載せます。手元は macOS で、Claude Code の挙動は 2026 年 10 月時点の公式ドキュメントで確かめました。
文で決めた規則は守られなかった:10 月 14 日のこと
コードの変更は実装担当のエージェントに渡します。実装担当が担うのは、自分の git worktree で、テスト付きでコミットするところまで。push と本番のタグ付けは、リード(指揮役の Claude のメインのセッション)だけが行う。これは文で書いた決まりでした。
10 月 14 日、実装担当の 1 つが PR に 3 回 push しました。push のたびに GitHub Actions が走り、79 分ぶん課金されます。GitHub の実行は、決まった月の予算から払っています。
文の規則には、読み落とされたときや、ほかの指示と食い違ったときに止める仕組みがありません。フックは、エージェントが何を読んだかに関係なく、実行の直前にコマンドを読んで止めます。その日のうちに、エージェントの push を文ではなくフックで止めるようにしました。
フックで止めている 4 つの操作
Convika のフック(guard-git.sh)は Bash の呼び出しを実行の前に読み、次の 4 つを止めます。ほかのコマンドはそのまま通します。
| 止める操作 | 止める相手 | なぜ |
|---|---|---|
git push | エージェント | push のたびに CI が走る。10 月 14 日の 79 分はこれだった |
GitHub で実行を始める gh のコマンド(PR の作成とマージ、workflow の実行と再実行、リリースの作成、読み取りでない gh api) | エージェント | どれも Actions の実行につながる |
--no-verify | リードも含めた全員 | コミットと push の前に走る lint、型チェック、テストを飛ばすと、チェックを通っていないコミットが残る |
| 本番のタグを手で push すること | リードも含めた全員 | 本番のタグは、staging で同じコミットが成功するのを待つスクリプトからだけ打つ |
リードには許し、エージェントだけを止める見分け方
Claude Code はフックに渡す JSON に呼び出し元を書きます。サブエージェントと、同じ端末の中で動くチームメイトの呼び出しには agent_id が付き、メインのセッションの呼び出しには付きません(2026 年 10 月時点のフックのリファレンス)。Convika のフックは agent_id があるときだけ push を止めます。リードは push でき、サブエージェントと同じ端末の中で動くチームメイトはできません。
画面を分けて動くチームメイト(tmux や iTerm2 の split panes)は、別の Claude Code のプロセスとして動きます(サブエージェントのドキュメント)。リファレンスが agent_id を約束しているのは前の 2 つだけです。このフックでは止まらないものとして扱い、下のドライランの 4 で確かめます。
見分けに agent_type を使わない理由は、claude --agent で起動したセッションではメインのセッションにも agent_type が付くためです(同じリファレンス)。
permissions の deny ルールではこの線が引けません。settings.json の deny に Bash(git push *) と書くとリードの push も止まります。エージェントの定義ファイルの disallowedTools に Bash(git push *) と書くと、そのエージェントから Bash がまるごと外れます(2026 年 10 月時点のサブエージェントのドキュメント)。リードに許してエージェントだけを止めるには、呼び出し元を読めるフックが要ります。
コピーして使える最小のフック
Convika のフックは言い回しの違い(bash -c の中、git と push の間のオプション、本番のタグの名前)まで読むため長くなります。ここに載せるのは、同じ考え方で書き直した最小のものです。エージェントの git push と、GitHub で実行を始める gh のコマンドを止めます。--no-verify(git commit では短い形の -n も)は誰でも止めます。複数行のコマンドは 1 行にまとめてから読みます。jq が要ります。
#!/usr/bin/env bash
# .claude/hooks/block-agent-push.sh
# PreToolUse(Bash)のフック。終了コード 2 で呼び出しを止め、標準エラーの文を Claude に返す。
# - エージェント(サブエージェントと、同じ端末の中で動くチームメイト)の git push と、
# GitHub で実行を始める gh を止める
# - --no-verify(git commit では -n も)は、メインのセッションも含めて止める
# コマンドを文字列として読むだけなので、変数やスクリプトに隠した push は通る。
set -uo pipefail
# jq がないと入力を読めず、何も止められない。気づけるように、全部止める
if ! command -v jq >/dev/null 2>&1; then
echo "止めました: フックに jq が要ります。jq を入れてください。" >&2
exit 2
fi
input="$(cat)"
cmd="$(jq -r '.tool_input.command // ""' <<<"$input")"
# agent_id は、サブエージェントと、同じ端末の中で動くチームメイトの呼び出しにだけ付く
agent="$(jq -r '.agent_id // ""' <<<"$input")"
# 行末の \ で続く行と改行を空白にして、複数行のコマンドを 1 行として読む。
# パターンの \ を文字どおりに当てるため、引用符で囲んだ変数で渡す
continued=$'\\\n'
cmd="${cmd//"$continued"/ }"
cmd="${cmd//$'\n'/ }"
before='(^|[^[:alnum:]_.-])'
after='([^[:alnum:]_.-]|$)'
git_cmd="${before}git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+"
if grep -Eq "${before}git[[:space:]][^;&|]*--no-verify" <<<"$cmd" ||
grep -Eq "${git_cmd}commit[[:space:]]([^;&|]*[[:space:]])?-[[:alpha:]]*n[[:alpha:]]*${after}" <<<"$cmd"; then
echo "止めました: --no-verify(-n)は使わず、git のフックが出したエラーを直してください。" >&2
exit 2
fi
# メインのセッションは止めない
[[ -z "$agent" ]] && exit 0
push="${git_cmd}push${after}"
gh_run="${before}gh[[:space:]]+(pr[[:space:]]+(create|merge)|workflow[[:space:]]+run|run[[:space:]]+rerun|release[[:space:]]+create)${after}"
if grep -Eq "$push" <<<"$cmd" || grep -Eq "$gh_run" <<<"$cmd"; then
echo "止めました: エージェントは push せず、GitHub で実行も始めません。コミットまでにして、メインのセッションに報告してください。" >&2
exit 2
fi
exit 0
.claude/settings.json に登録します。フックの if 欄でもコマンドを絞れますが、2026 年 10 月時点のフックのガイドはこの絞り込みをベストエフォートとしています。だから if は使わず、matcher を Bash にしてすべての Bash の呼び出しを受け、どれを止めるかはスクリプトの中で決めます。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-agent-push.sh"
}
]
}
]
}
}
スクリプトに実行の権限を付けます。
chmod +x .claude/hooks/block-agent-push.sh
止めたときは、標準エラーの文が止めた理由としてエージェントに届きます(フックのリファレンス)。エージェントはそれを読んで次の手を決めます。だから止めた理由のあとに、代わりにすること(コミットまでにして、報告する)を書いておきます。
フックにもテストを書く:3 本、2,003 件
Convika のガードのテストは 3 本です。10 月 17 日に実行したところ、push のフックが 532、一部のエージェントの道具を絞るガードとその振り分けが 561 と 910、計 2,003 のチェックが通りました。3 本とも pnpm test と push の前のフックから流れます。
テストが要る理由は、正規表現のまちがいがどちらに転んでも事故になるからです。止めすぎれば git stash push のような関係ないコマンドで作業が止まり、止め漏らせば 10 月 14 日に戻ります。
もう 1 つ理由があります。手元の bash(macOS の 3.2)で試すと、構文エラーのあるスクリプトは終了コード 2 で終わりました。PreToolUse で 2 は「止める」です(フックのリファレンス)。マージの衝突の印が残っただけのフックが、掛かっているすべての呼び出しを止めます。Convika の settings.json は、すべての呼び出しに掛かるフックを bash -n で確かめてから動かしています。
上の最小のフックのテストです。止めるコマンドと通すコマンドを並べ、最後に件数を出します。
#!/usr/bin/env bash
# .claude/hooks/block-agent-push.test.sh
# 実行: bash .claude/hooks/block-agent-push.test.sh
set -u
hook="$(dirname "$0")/block-agent-push.sh"
pass=0
fail=0
# 構文エラーのある bash は終了コード 2 で終わり、PreToolUse ではそれが「止める」になる
if bash -n "$hook"; then pass=$((pass + 1)); else fail=$((fail + 1)); echo "NG: 構文エラー"; fi
# check <期待する終了コード> <agent_id(空ならメインのセッション)> <コマンド>
check() {
local want="$1" agent="$2" cmd="$3" input got
input="$(jq -n --arg cmd "$cmd" --arg agent "$agent" \
'{tool_name: "Bash", tool_input: {command: $cmd}} + (if $agent == "" then {} else {agent_id: $agent} end)')"
bash "$hook" <<<"$input" 2>/dev/null
got=$?
if [[ "$got" == "$want" ]]; then
pass=$((pass + 1))
else
fail=$((fail + 1))
echo "NG: ${agent:-main} で「${cmd}」は ${want} のはずが ${got}"
fi
}
# エージェントの push と、GitHub で実行を始める操作は止める
check 2 sub1 "git push"
check 2 sub1 "git push origin main"
check 2 sub1 "npm test && git push"
check 2 sub1 "git -C ../app push"
check 2 sub1 'bash -c "git push"'
check 2 sub1 "gh pr create --fill"
check 2 sub1 "gh pr merge 12"
check 2 sub1 "gh workflow run deploy.yml"
check 2 sub1 "gh run rerun 123"
# エージェントでも、push でない git と、読むだけの gh は通す
check 0 sub1 "git status"
check 0 sub1 "git commit -m 'fix: form label'"
check 0 sub1 "git stash push -m wip"
check 0 sub1 "git config push.default simple"
check 0 sub1 "gh pr view 12"
check 0 sub1 "gh run list"
# メインのセッションは push できる
check 0 "" "git push origin main"
check 0 "" "gh pr create --fill"
# --no-verify は誰でも止める。git commit の -n と、複数行のコマンドの後ろに付けたものも
check 2 "" "git commit --no-verify -m wip"
check 2 sub1 "git push --no-verify"
check 2 "" "git commit -n -m wip"
read -r -d '' multiline <<'CMD'
git commit -m "$(cat <<'EOF'
fix: form label
EOF
)" --no-verify
CMD
check 2 "" "$multiline"
# jq がないときは、何も通さない
env PATH=/nonexistent "$BASH" "$hook" <<<'{}' 2>/dev/null
if [[ $? == 2 ]]; then pass=$((pass + 1)); else fail=$((fail + 1)); echo "NG: jq がないのに止めなかった"; fi
echo "$pass passed, $fail failed"
[[ "$fail" == 0 ]]
bash .claude/hooks/block-agent-push.test.sh
# 23 passed, 0 failed
git stash push と git config push.default を通すケースがいちばん効きます。git.*push のような素朴なパターンに書き換えると、この 2 つで落ちます。メッセージの変数を ${cmd} のように波かっこで囲んでいるのは、macOS の bash 3.2 で試したとき、変数のすぐ後ろの日本語まで変数名として読まれたためです。
フックが止めないもの
- 隠した push: コマンドを文字列として読むだけです。変数や別のスクリプトに隠した push は通ります。
- Bash 以外の道具:
matcherは道具の名前に当たります(フックのリファレンス)。matcherがBashのフックはほかの道具の呼び出しを見ません。GitHub を操作できる MCP の道具をエージェントに渡しているなら、その道具の名前に合うmatcherで別に止めます。 - コミットの中身: push を止めても、コミットが正しいかは分かりません。中身はレビュー担当のエージェントが読み、マージはリードが決めます。流れは「17 日の記録」の「1 件の変更が本番に出るまで」のとおりです。
- 人間の端末: 人が自分の端末で打つコマンドは Claude Code のフックの外です。
新しいセッションで確かめる(ドライラン)
10 月 13 日、ブラウザを操作するエージェントのガードが一度も動いていなかったと分かりました。ガードはそのエージェントの定義ファイルの hooks: に書いていました。名前付きで起動したエージェントは、エージェントチームのチームメイトとして動いていました。チームメイトには定義ファイルの道具と指示文は付いても、フックは付きませんでした(10 月 13 日時点の Claude Code)。
2026 年 10 月時点のエージェントチームのドキュメントでも、チームメイトに当てはまる定義の部分は道具、モデル、指示文などで、hooks は入っていません。エージェントチームは実験的な機能で、CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS を有効にしたときに使われます。settings.json のフックは、サブエージェントの中でも、同じ端末の中で動くチームメイトの中でも動きます(同じ時点のフックのリファレンス)。
その日に変えたことは 2 つです。止めたい操作は、settings.json からすべての呼び出しに掛かるフックで止めます。ガードを変えたら、新しいセッションで、止まるべき呼び出しが本当に止まるかを確かめます。
新しいセッションにするのは、迷わないためです。2026 年 10 月時点のフックのガイドによると、設定ファイルの変更はふつう自動で読み直されますが、読み直しが漏れたときはセッションを開き直すよう案内されています。開き直せば、漏れたかどうかを考えなくて済みます。
- 新しいセッションで
/hooksを開き、PreToolUse にblock-agent-push.shがあるかを見る - サブエージェントに
git push --dry-runを打たせ、「止めました」が返るかを見る。--dry-runの push はリモートを書き換えない - メインのセッションで、同じ
git push --dry-runが通るかを見る - エージェントチームを使っているなら、名前を付けて起動したエージェントでも 2 を試す。画面を分けて動くチームメイトを使うなら、それでも試す
自社で最初に入れる 3 つ
- push と実行の開始を止めるフック: 上の最小のフックを
.claude/settings.jsonから掛けます。エージェントの定義ファイルのhooks:に置くと、起動のしかたによっては付きません。 - そのテスト: 止めるコマンドと通すコマンドの両方を並べます。
bash -nも入れます。 - ガードを変えるたびのドライラン: 新しいセッションで、サブエージェントに
git push --dry-runを打たせます。
この 3 つを自社のリポジトリに入れ、実装、レビュー、リリースの役割まで組み立てるワークショップを用意しています。詳しくは Claude Codeによる事業立ち上げワークショップ をご覧ください。
よくある質問
Claude Code の hooks が動かないときは?
まずは /hooks で、そのフックが PreToolUse の下に出ているかを見ます。出ていないときは、置き場所が .claude/settings.json か、JSON にコメントや末尾のカンマがないか、フォルダを信頼するかの確認に答えたかを確かめてください(2026 年 10 月時点の公式ドキュメントでは、対話のセッションは答えるまで設定ファイルのフックを動かしません)。出ているのに動かないときは、スクリプトの実行権限(chmod +x)と jq があるかを確認します。なお、エージェントの定義ファイルの hooks: に書いたフックは、10 月 13 日、チームメイトとして起動したエージェントに付いていませんでした。止めたい操作は settings.json から止めます。
permissions の deny とフックは、どちらで止めればいい?
リードもエージェントも止めたいときは deny ルール、リードに許してエージェントだけを止めたいときはフックを使います。deny は && でつないだコマンドや、コマンド置換の中にも当たります。公式のガイドも、確実に許すか止めるかには permissions のルールを勧めています。deny ルールはリードの呼び出しにも当たり、定義ファイルの disallowedTools に Bash(git push *) と書くと Bash がまるごと外れます(2026 年 10 月時点のpermissions のドキュメントと、サブエージェントのドキュメント)。
force push だけ止めれば十分?
十分ではありません。force かどうかに関係なく、push は実行を始めます。10 月 14 日の 79 分が課金されたのは、push のたびに CI が走ったからです。Convika はエージェントの push をすべて止めています。
フックのテストはどう書く?
Claude Code が渡すのと同じ形の JSON を標準入力からフックに渡して、終了コードを見ます。止めるべきコマンドは 2、通すべきコマンドは 0 が返るか確かめてください。エージェントの呼び出しは agent_id を付けた JSON、メインのセッションは付けない JSON で試し、git stash push のような止めてはいけない似たコマンドも並べます。最後に bash -n で構文を確かめます。構文エラーのフックは、掛かっているすべての呼び出しを止めるためです。
Windows でも動く?
Git for Windows の Git Bash があれば動きます。2026 年 10 月時点の公式ドキュメントによると、Windows では Git Bash があればフックは bash で動き、なければ PowerShell で動きます。Git Bash がなければこの記事の bash のスクリプトは動きません。jq も別に入れる必要があります。Convika は macOS で動かしていて、Windows では試していません。
関連記事
- Claude Code のエージェント組織が 17 日で 92 回リリースした運用の仕組みClaude Code の AI エージェント組織が 17 日で 1,403 件のコミット、92 回の本番リリース、82 本のページ公開を回した記録。19 件のまちがいから作った権限・レビュー・デプロイの仕組みを公開します。
- バイブコーディングの危険性と実際の事例|公開前に確かめる 8 つの手順2025〜2026 年のバイブコーディングの危険性と情報漏洩の事例を出典つきで整理。原因の多くは AI のコードではなく公開の設定です。自分の作ったものが当てはまるかの見分け方と、公開前に確かめること、漏れたときの対応を解説します。
- AIO対策のやり方と事例|2週間でクリック20倍・表示11倍の手順AIO対策のやり方をconvika.comの実例で解説。AIの回答での引用を測り、質問に正面から答える記事を出して即インデックスさせる4手順で、2週間で検索クリック20倍・表示11倍になった記録を公開します。