個人開発の壁紙アプリで、Firebase の導入方法を CocoaPods から Swift Package Manager へ切り替えていた週のことです。Podfile を外し、Package の依存を書き直し、ビルドを通すまでを Claude Code に任せ、CLAUDE.md の先頭には「コミットとプッシュは私が行います。差分は作業ツリーに残してください」と書いておりました。翌朝、ビルドの結果を見る前に何気なく git log を叩くと、覚えのない一行が増えていました。
コミットメッセージはきちんとしていて、変更も壊れていませんでした。けれど、履歴に刻む前に自分の目で差分を読むという手順が、私の手を経ずに終わっていたのです。胃のあたりが少し重くなりました。
同じ経験をされた方は少なくないようで、Claude Code のリポジトリには「明示的に禁止したのにコミットされた」という Issue が複数立ち、重複として閉じられてもまた開かれています。私も最初は CLAUDE.md の文言を太字にし、「絶対に」を足し、禁止の理由まで書き足しました。それでも数日後に同じことが起き、そこで考えを変えました。
最初にお伝えしたいのは、指示は読まれるものであって、効くものではないという線引きです。コミットを止めたいなら、止める機構を置くほうが早く、確実で、そして自分の気持ちも落ち着きます。戻し方、切り分け、deny ルール、hook、直らないときの順に書き残します。
症状の見分け方 — まず履歴を戻し、誰が刻んだかを確かめる
コミットされたこと自体は、変更が消えたわけではありませんので、落ち着いて戻せます。直前の一つなら、次の一行で差分を作業ツリーに戻しつつ履歴だけを取り消せます。
# 直前のコミットを取り消し、変更はステージに残す
git reset --soft HEAD~1
git status --short次に、本当に Claude Code が刻んだのかを見ます。私の環境では Claude Code は私の git 設定で動きますので、author を見ても区別がつきません。手がかりになるのは、コミット日時とセッションの記録の突き合わせ、そして reflog です。
git log -3 --format='%h %ad %an | %s' --date=iso
git reflog -5reflog に commit: の行があり、その時刻にセッションが動いていたなら、それが答えです。rebase や stash の自動操作で履歴が動いたように見えるだけのこともありますので、rebase や stash の行と見間違えないようにしています。
原因の切り分け — どの層が止めるはずだったか
ここで一度、何が何を止めるのかを棚卸ししました。Claude Code には「書いた指示」と「設定で与える権限」と「ツール実行前に挟む hook」の三つの層があり、それぞれ止められるものが違います。
| 層 | 止めるもの | 止められないもの |
|---|---|---|
| CLAUDE.md の指示 | モデルの判断(読んで従う) | 解釈のずれ・長いセッションでの見落とし |
| permissions.deny | ルールに合う形のコマンド文 | 同じ操作の別の書き方 |
| PreToolUse hook | 自分のロジックで検査したコマンド文 | コマンド文に現れない操作 |
| サンドボックス | ファイル・ネットワークへの到達そのもの | —— |
私が頼っていたのは一番上の層だけでした。CLAUDE.md の文言は確実に読まれますが、「ビルドが通ったので区切りとして記録しておく」といった判断が差し込まれると、文言はその判断に負けることがあります。これはモデルの不具合というより、文章で渡した約束の性質なのだと、いまは受け止めております。
一方で、下の二層は判断に関わりません。コマンド文が条件に合えば、理由を問わず止まります。止めたい操作は、読ませるのではなく、通さないようにします。 この一文を CLAUDE.md の代わりに置くことにしました。
直し方① permissions.deny に一行足す
最初に足したのは deny ルールです。プロジェクトの .claude/settings.json(チームと共有しないなら .claude/settings.local.json)に書きます。
{
"permissions": {
"deny": [
"Bash(git commit *)",
"Bash(git push *)"
]
}
}これだけで git commit -m "..." は実行前に拒否され、Claude Code には拒否された旨が返ります。公式ドキュメントによると、deny と ask のルールは複合コマンドの一部にも当たりますので、cd ios && git commit -am wip のように前に cd が付いていても止まります。サブシェルや $() の中に書かれた場合も同様です。
ただ、私の環境ではこれで終わりませんでした。理由は、私自身の癖にありました。
サイトの運用では、環境に git の identity を残さないために git -c user.email=... -c user.name=... commit -m "..." という形でコマンドを書いております。CLAUDE.md にもその形で手順を載せていました。Claude Code はその手順を忠実に真似し、-c を挟んだ形でコミットしていたのです。Bash(git commit *) は「git の直後に commit が来る文」にしか当たりませんので、-c が間に入った文は素通しでした。
公式ドキュメントもこの点を隠していません。Bash(git push *) は git push origin main を止めるが、git -C . push origin main や git -c push.default=current push origin main は止めない、と表で示しています。deny ルールは「Claude がふだん書く形」を止めるものであって、プログラムそのものを囲う境界ではない——そう書いてあります。
なお Bash(command:git commit *) のように引数名で縛る書き方は、複合コマンドで回避できるため無視され、起動時に警告が出ます。私も一度書いて警告を見ました。
直し方② PreToolUse hook でコマンド文全体を読む
別の書き方まで含めて止めるには、コマンド文の全体を自分のロジックで検査する hook を置きます。PreToolUse はツールが実行される直前に動き、標準入力の JSON からコマンド文を取り出せます。
何を解決するかを先に書きますと、「git の後ろにいくつオプションが挟まっても、commit か push という語が来たら拒否する」という一点です。
#!/bin/bash
# .claude/hooks/no-commit.sh
# git <options…> commit / push をコマンド文全体から探して止める
COMMAND=$(jq -r '.tool_input.command // empty')
[ -z "$COMMAND" ] && exit 0
if echo "$COMMAND" | grep -Eq '(^|[^[:alnum:]_./-])git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]-][^[:space:]]*)?)*[[:space:]]+(commit|push)([[:space:]]|$)'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "commit と push は人が行う運用です。差分は作業ツリーに残し、このコマンドは実行しないでください。"
}
}'
fi
exit 0chmod +x .claude/hooks/no-commit.sh を忘れずに。設定側は matcher を Bash にして結びます。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": ".claude/hooks/no-commit.sh" }
]
}
]
}
}なぜこう書くかですが、正規表現の中ほどにある ([[:space:]]+-[^[:space:]]+(...)?)* が「-c key=value や -C path のようなオプションの繰り返し」を飲み込む部分です。これがあるおかげで、git commit も git -c user.email=a@b.c commit も git -C . push も同じ一本で捕まります。permissionDecisionReason の文は Claude Code にそのまま渡りますので、拒否の理由と次の振る舞いを一文で書いておくと、同じ試みを繰り返されにくくなりました。
hook は Claude Code を起こさずに手元で試せます。標準入力に JSON を流せばよいだけです。
for c in 'git commit -m "fix"' \
'git -c user.email=a@b.c -c user.name=x commit -m "x"' \
'cd ios && git commit -am wip' \
'git -C . push origin main' \
'git status && git diff --stat' \
'git log --oneline -3'; do
printf '%-55s -> ' "$c"
jq -n --arg c "$c" '{tool_name:"Bash",tool_input:{command:$c}}' \
| .claude/hooks/no-commit.sh \
| jq -r '.hookSpecificOutput.permissionDecision // "pass"'
done私の手元では、上の四つが deny、下の二つが pass になりました。一つ正直に書き添えますと、echo "git commit は私が" のように文字列の中に書いただけでも deny になります。hook はコマンド文を読んでいるだけで、意味は読んでいないのです。私はこれを欠点ではなく、安全側に倒れる誤りとして受け入れています。
ここまでが二層目と三層目です。bash -c '...' の中に書かれた形も hook は文として読みますので止まりますが、コマンド文に現れない操作——たとえばスクリプトファイルを書いてから実行する——は読めません。そこまで囲いたい場合は、表の一番下のサンドボックスの出番になります。
直らないときの次の一手
deny と hook を置いても止まらないときは、順番に次を見ています。
/hooksで登録が見えているか。 設定ファイルの置き場所が違う、JSON の括弧が一つ多い、というのがいちばん多い原因でした。- 実行権限と jq。
chmod +xが抜けていないか、which jqが通るか。jq が無いと hook は何も出さずに終わり、結果として通してしまいます。 - 新しいセッションでの再確認。 設定を変えた直後は、私は一度セッションを閉じて開き直してから確かめるようにしています。
- 別の形で書かれていないか。
/usr/bin/gitやsh -cの形なら、正規表現の先頭の文字クラスを見直すか、サンドボックスへ進みます。
hook 自体の切り分けに踏み込むときは、Claude Code Hooks の本番デバッグ術 — ログが足りない時、どこから切り分けるか に手順を書いております。
差分は Claude に、履歴は私に
戻って考えますと、私が欲しかったのは「コミットしないでくれる Claude」ではなく、「コミットできない作業環境」でした。前者は毎回の運に、後者は一度の設定に依ります。
差分を作るのは Claude、履歴に刻むのは私。 この線引きを置いてから、朝の git log を開くときの胃の重さが消えました。差分を読んで自分の手でコミットする数分は、任せる範囲を広げるための、むしろ安い代価なのだと感じています。
まずは .claude/settings.json に Bash(git commit *) の一行だけを足し、翌朝の git log を見るところから始めていただければと思います。素通しされる形が見つかったら、そのときに hook を足せばよいのです。hook をどのイベントに置き、何を返させるかの判断は、Claude Code Hooks — 中核8フックの設計判断と、33イベントに増えた今の歩き方 でまとめて書いております。