Claude Codeでforce pushを制限する — --force-with-leaseだけ許可する権限
Claude Codeにgit push --forceを打たせず、--force-with-leaseだけ通す権限ルールの書き方です。deny優先の評価順、ルールの照合の癖、フックとGitHubの保護ブランチとの分担まで説明します。
Claude Codeに履歴の書き換えを許すなら、git push --forceは禁止し、git push --force-with-leaseだけを通すのが現実的な線引きです。ただしBash(git push *)を拒否するだけでは、この2つを区別できません。denyはallowより先に評価されるため、拒否ルールの中に許可の例外を作れないからです。
必要なのは、--forceと-fの形だけを個別に拒否し、--force-with-leaseの形だけを許可するルールの組み立てです。この記事では、そのsettings.jsonの書き方と、ルールがすり抜ける形、フックによる補強、GitHubの保護ブランチとの分担まで順に説明します。
まず置く権限設定
結論の設定を先に示します。プロジェクトの.claude/settings.jsonに入れる形です。
{
"permissions": {
"allow": [
"Bash(git push --force-with-lease *)",
"Bash(git push --force-with-lease=* *)"
],
"deny": [
"Bash(git push --force *)",
"Bash(git push * --force)",
"Bash(git push * --force *)",
"Bash(git push -f *)",
"Bash(git push * -f)",
"Bash(git push * -f *)"
]
}
}役割は次のとおりです。
denyの6本は、--forceと-fが先頭・中間・末尾のどこに置かれても拾いますallowの2本は、--force-with-leaseをgit pushの直後に置いた形だけを通します--force-with-leaseをリモート名やブランチ名の後ろに置いた形は、どちらにも当たらず通常の確認に回ります
最後の点は欠点ではなく意図した設計です。許可する書式をひとつに絞ると、Claudeが別の並びで書いたときに人が確認できます。CLAUDE.mdに「force pushはgit push --force-with-lease origin <branch>の順で書く」と書いておけば、Claudeは許可される形を選びやすくなります。
なぜ「denyの中に例外」は作れないのか
評価順はdeny、ask、allowで、最初に当たったものが結果を決めます。ルールの具体性は順序を変えません。広いBash(aws *)のdenyは、より狭いBash(aws s3 ls)のallowがあっても全件ブロックします。
このため、次の書き方は意図どおりに動きません。
denyで広く止めてallowで例外を作る書き方は通らない
動かない形
denyにBash(git push *)、allowにBash(git push --force-with-lease *)を置く。denyが先に当たり、--force-with-leaseも止まる。
動く形
denyは--forceと-fを含む形だけに絞る。allowは--force-with-leaseの形だけを書く。両者の文字列が重ならないので、順序に依存しない。
同じ理由で、askにBash(git push *)を置くのも避けます。askはallowより先に評価されるため、--force-with-leaseを許可しても毎回確認が出ます。通常のpushに確認を残したいなら、askは使わず、広いallowを書かない形にします。allowに当たらないpushは、既定の確認に回ります。
ルールの照合で外れやすい3か所
Bashルールは、コマンド文字列に対して*を任意のテキストとして照合します。force pushのルールで、特に外れやすいのは次の3か所です。
ワイルドカードの前の空白もルールの一部
Bash(git push --force *)はgit push --force origin mainに当たります。一方でgit push --force-with-lease origin mainには当たりません。--forceの直後に空白が必要だからです。これが「--forceだけ拒否して--force-with-leaseは残す」ことを成り立たせています。
ルール末尾の*の直前の空白は、ルールの一部として扱われます。Bash(ls *)はlsofに当たらず、Bash(ls*)は当たる、というのと同じ仕組みです。逆に、末尾の*は引数なしのコマンドにも当たるので、git push -f単体もBash(git push -f *)で拾えます。
中間のワイルドカードは「何も無い」に当たらない
Bash(git push * --force)はgit push origin main --forceに当たりますが、git push --forceには当たりません。*の前後の空白が両方必要で、git log * mainがgit log mainに当たらないのと同じ挙動だからです。先頭に置く形を別ルールで拾っているのは、そのためです。
書き方を変えると当たらない
Bashルールはコマンド文字列そのものを照合します。同じプログラムを別の形で呼ぶと、denyルールはすり抜けます。ドキュメントはBash(git push *)について、次の形が止まらない例として挙げています。
| 書き方 | Bash(git push *)で止まるか |
|---|---|
git push origin main | Bash(git push *)で止まるか止まる |
git -C . push origin main | Bash(git push *)で止まるか止まらない |
git -c push.default=current push origin main | Bash(git push *)で止まるか止まらない |
git 'push' origin main | Bash(git push *)で止まるか止まらない |
force pushのdenyも同じ事情を抱えます。さらにgit側には、git push origin +mainのようにrefspecの先頭に+を付けて--forceと同じ効果を出す書き方があります。これも上の6本には当たりません。ルールの本数を増やして追いかけるより、次節のフックで文字列全体を検査する方が漏れにくくなります。
一方、timeout 30 git push --forceのようにラッパーを付けた形は、ラッパーを外してから照合されます。FOO=bar git push --forceのような環境変数の代入付きも、denyは代入を越えて当たります。git add . && git push --forceのように複合コマンドの一部になっていても、サブコマンド単位でdenyが適用されます。
フックで補強する
ルールが苦手な「文字列全体を見て判断する」処理は、PreToolUseフックに任せられます。フックは権限ルールを迂回できず、denyとaskは常に評価されます。フックとルールは足し算で使えます。
次のスクリプトは、gitとpushを含み、かつ--force・-f・+refspecのいずれかが単語として現れるコマンドを拒否します。--force-with-leaseは単語が一致しないので通ります。
#!/bin/bash
# .claude/hooks/block-force-push.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -Eq 'git .*push' \
&& echo "$COMMAND" | grep -Eq '(^| )(-f|--force|\+[A-Za-z0-9_./-]+)( |$)'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "force pushは禁止です。--force-with-lease origin <branch> を使ってください"
}
}'
else
exit 0
fichmod +xで実行権限を付け、settings.jsonに登録します。コマンドは${CLAUDE_PROJECT_DIR}で始めます。相対パスのままだと、Claudeがサブディレクトリへ移動した後にスクリプトが見つからず、フックが働きません。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-force-push.sh" }
]
}
]
}
}スクリプトはjqに依存します。判定は正規表現による近似で、gitとpushの間にコマンドを挟んだ誤検知や、引用符の中の-fの取りこぼしは起こりえます。拒否の理由文にClaudeが取るべき書式を入れておくと、次の試行で許可される形に直しやすくなります。フックの書き方全般はフックの解説にまとめています。
--force-with-leaseにも穴がある
--force-with-leaseは万能の安全弁ではありません。gitのドキュメントは、期待値を明示しない形(--force-with-lease単独、または--force-with-lease=<ref>)は、バックグラウンドでgit fetchを走らせる処理と相性が悪いと説明しています。fetchがリモート追跡ブランチを更新すると、リースは「他人の変更を見たうえで上書きしてよい」状態になり、保護が実質的に消えるためです。
念のため期待値まで固定するなら、期待するコミットまで指定する--force-with-lease=<ref>:<expect>を使います。ドキュメントによれば、この形だけが期待値を明示した形で、ほかの形は実験的な扱いです。--force-if-includesを併用して、取り込んでいないリモートの更新があれば強制更新を拒否する方法もあります。
先の設定のBash(git push --force-with-lease=* *)は、この明示形を通すための行です。許可する範囲を明示形だけに絞りたい場合は、Bash(git push --force-with-lease *)の行を外します。--forceと--force-with-leaseを同時に指定されると、--forceが--force-with-leaseのチェックを無効にするため、denyが必ず勝つ形になっていることも重要です。
保護ブランチとの分担
権限ルールもフックも、Claude Codeを動かすマシン上でコマンドを止める仕組みです。リモートへの強制更新そのものを止めるのは、GitHubなどサーバー側の仕事です。
GitHubの保護ブランチのルールは、既定で対象ブランチへのforce pushとブランチ削除を無効にします。ただし既定では、リポジトリの管理者権限や「保護を迂回する」権限を持つ人にはルールが適用されません。制限を管理者にも及ぼす設定が別にあります。Claude Codeを管理者のトークンで動かしているなら、この設定を確認する価値があります。
| 層 | 止められるもの | 止められないもの |
|---|---|---|
| denyルール | 止められるものClaudeが書く典型的なforce push | 止められないものgit -Cなど別の書き方、人が打つコマンド |
| PreToolUseフック | 止められるもの文字列全体の検査で拾える形 | 止められないもの正規表現が拾えない書き方、Claude以外の経路 |
| 保護ブランチ | 止められるもの保護対象ブランチへの強制更新(管理者を除く既定) | 止められないもの保護されていないブランチ、迂回権限を持つ主体 |
Claudeに自分のfeatureブランチだけforce pushさせたいなら、保護ブランチはmainやreleaseに限定し、feature側はClaude Code側の--force-with-leaseだけ許可する構成になります。mainを守るのはサーバー、feature側の事故を減らすのは権限ルール、という分担です。
シェルのコマンドそのものをOSレベルで制約したい場合は、サンドボックスという手段もあります。ただしサンドボックスは主にファイルとネットワークの制限で、gitの引数の中身を見分ける仕組みではありません。
どの設定ファイルに書くか
denyは、どの設定の層に置いても他の層のallowに勝ちます。ユーザー設定のallowとプロジェクト設定のdenyが食い違えば、denyが優先されます。組織の管理設定に置いたdenyは、コマンドライン引数でも上書きできません。
この性質から、置き場所は目的で分けられます。
- 個人の習慣として止めたいなら、ユーザー設定にdenyを置く。どのリポジトリでも効く
- チームで揃えたいなら、プロジェクトの
.claude/settings.jsonに置いてリポジトリに入れる - 例外なく止めたいなら、管理設定に置く。ユーザー側のallowで抜け道を作れない
--force-with-leaseのallowはプロジェクト側に置く方が安全です。個人の設定に広いallowが紛れ込むと、チームのdenyの意図とずれた確認なしの経路が生まれやすくなります。ただしdenyは常に勝つため、allowの置き場所を間違えても、force pushが通るわけではありません。
手元で動作を確かめる
実際のリモートに触れずに確かめるには、ローカルのbareリポジトリを相手にします。
git init --bare /tmp/remote.git
git clone /tmp/remote.git /tmp/work && cd /tmp/work
git commit --allow-empty -m "first" && git push origin HEAD
git commit --amend -m "rewritten"
mkdir -p /tmp/work/.claude
cat > /tmp/work/.claude/settings.json <<'EOF'
{
"permissions": {
"allow": [
"Bash(git push --force-with-lease *)",
"Bash(git push --force-with-lease=* *)"
],
"deny": [
"Bash(git push --force *)",
"Bash(git push * --force)",
"Bash(git push * --force *)",
"Bash(git push -f *)",
"Bash(git push * -f)",
"Bash(git push * -f *)"
]
}
}
EOF/tmp/workに置いたsettings.jsonは、冒頭の権限設定と同じ内容です。フックも試すなら、スクリプトを/tmp/work/.claude/hooks/に置き、登録の設定も足します。
この状態でClaude Codeを/tmp/workで起動します。初回はフォルダーを信頼するダイアログが出ます。受け入れる前はallowが効かないので、手順3の「確認なしで通る」は信頼を受け入れた後の挙動です。起動したら、「履歴を書き換えたのでpushして」と頼みます。--forceで打とうとすればdenyで止まり、--force-with-lease origin <branch>ならそのまま通ります。止まったときの拒否メッセージは、/permissionsのルール一覧と見比べると、どのルールが当たったか追えます。
試すのは次の3つです。
git push --force origin HEADがdenyで止まるgit push origin HEAD --force-with-leaseが、どちらにも当たらず確認に回るgit push --force-with-lease origin HEADが確認なしで通る
2番目の挙動を確かめておくと、「許可していない書式は確認に落ちるだけで、拒否まではされない」ことが実感できます。
確認とトラブルの切り分け
設定を入れたら、/permissionsでルールの出どころが想定どおりか確認します。ルールは追加した次のツール呼び出しから効きます。
push自体が想定外にブロックされるときは、force pushの設定とは別の問題の可能性があります。auto modeでは、許可したはずのpushが分類器に止められる報告が出ています。詳しくはauto modeでpushがブロックされる件を参照してください。
.claude/settings.jsonに書いたallowルールは、フォルダーを信頼するダイアログを受け入れた後にだけ適用されます。denyとaskは制限するだけなので、信頼の前でも効きます。つまり、チームでリポジトリに設定を置く場合、denyはクローンした直後から効き、--force-with-leaseの許可は各自が信頼を受け入れてから効くことになります。
同じ型の「特定の書き込み系コマンドだけ確認に残す」設計は、DVCのdvc pushやSalesforce CLIの本番deployでも使っています。コマンドの名前を変えれば、考え方はそのまま流用できます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。