---
title: "git restoreがgit reset --hardの抜け道になっていたので、askで塞いだ"
description: "claude-bash-guardに、破壊的だが正当な理由で使うこともあるgit操作をask判定で5本追加しました。git reset --hardを止めるdenyメッセージ自身が代替として勧めていたgit restoreに、実はガードが掛かっていなかったという発見と、git checkoutを対象から外した理由を書きます。"
category: "AI"
tags: ["Claude Code","Git","Security"]
publishedAt: "2026-09-15"
lastmod: "2026-09-15"
---

claude-bash-guardに、破壊的だが正当な理由で使うこともあるgit操作をask判定で5本追加しました。denyの候補として調べ始めましたが、承認しても得るものがない操作だけをdenyに残し、可逆または日常的に必要な操作はaskに回すという基準に沿って、途中で判断を変えています。

::card[https://labee.jp/posts/claude-code-bash-guard-hook]

::card[https://labee.jp/posts/claude-code-bash-guard-ask-decision]

::card[https://labee.jp/posts/claude-code-bash-guard-gh-rules]

::card[https://labee.jp/posts/claude-code-bash-guard-mutation-testing]

::card[https://labee.jp/posts/claude-code-bash-guard-detour-holes]

このシリーズもこの記事で最後です。前回までにdenyとaskの仕組み、ghコマンドの分類、ガード自体の解釈層にあった迂回穴、テストの変異チェッカを扱ってきました。今回はgitそのものに話を戻します。実装は前回までと同じく[corrupt952/dotfiles](https://github.com/corrupt952/dotfiles)の`modules/claude/claude-bash-guard.py`にあります。

## denyとallowの間に落ちるコマンドは、gitにもある

`decision`フィールドを追加した記事では、`git clean -f`を例として扱いました。追跡外のファイルを問答無用で消し、取り消す手段もなく、それでいてビルド成果物の掃除など日常的に使う場面もある、というコマンドです。denyにすると必要な場面まで塞ぎ、何もしなければsandboxが確認なしに実行してしまいます。

同じ形の問題は、gitの他の操作にもそのまま当てはまります。履歴の書き換え、リモートブランチの削除、作業ツリーの復元、stashの破棄はどれも取り消せませんが、どれも使ってよい場面があります。最初はこれらをdenyの候補として調べていました。

ただ、1つずつ見ていくと「承認しても得るものがない」というdenyの基準には当てはまりません。漏洩したシークレットを履歴から消すのにfilter-branchが要る場面、古くなったリモートブランチを消す場面、壊れた変更を戻す場面は、どれも正当な作業です。

判断を変え、denyは承認しても得るものがない操作だけに絞り、可逆か日常的に必要な操作はaskに回す、という基準に揃えました。

## 実際のgit操作を、ガードとpermissions.jsonの両方に通して確かめる

どのgit操作がガードとpermissions.jsonのどちらに引っかかるかは、頭の中で推測せず、実際のコマンドを1つずつ両方の層に通して確かめました。

Claude Codeの権限はガードのPreToolUseフックが先に評価され、そこでdenyかaskが決まればpermissions.jsonの`ask`や`allow`は評価されません。ガードが何のルールにも一致しなかったときだけ、permissions.jsonの判定に進みます。

![Bashコマンドがdeny・askに至るまでの3層を示す図。1層目はclaude-bash-guard.pyのPreToolUseフックで、denyまたはaskに一致すればそこで確定する。一致しなければ2層目のsettings.jsonのpermissionsに進み、ask・allowのいずれかに一致すればそこで確定する。どちらにも一致しなければ3層目のsandbox.autoAllowBashIfSandboxedに落ち、sandboxが有効なら確認なしに実行される。](/images/posts/claude-code-bash-guard-git-rules/three-layer-funnel.svg)

この時点で洗い出した対応の一部です。

| コマンド | ガードの判定 | permissions.jsonの判定 | 実際の挙動 |
|---|---|---|---|
| `git clean -f` | ask(`git-clean-force`) | 該当ルールなし | 確認を挟む |
| `git reset --hard` | deny(`git-reset`) | 該当ルールなし | 拒否される |
| `git restore .`(修正前) | 該当ルールなし | 該当ルールなし | sandboxが自動許可し、確認なしに実行される |
| `git checkout <name>` | 対象外(意図的) | ask(`Bash(git checkout *)`) | 確認を挟む |
| `git branch -D <name>` | 対象外 | allow(`Bash(git branch *)`) | 確認なしに実行される |

`git restore .`と`git branch -D`の行は、この表を作る過程で見つかった2つの穴です。後者は今回見送り、前者はこの記事の中心になります。

## 同じ日に、まず3本のaskルールを入れた

最初に手を付けたのは、履歴の書き換えとリモートブランチの削除でした。

```python
Rule(
    id="git-history-rewrite",
    names=frozenset({"git"}),
    decision="ask",
    predicate=lambda c: c.subcommand_is("filter-branch", "filter-repo")
    or (c.subcommand_is("reflog") and "expire" in c.positionals())
    or (
        c.subcommand_is("gc")
        and any(arg in {"--prune=now", "--prune=all"} for arg in c.args)
    ),
    message=(
        "This rewrites history and drops the reflog entries that would "
        "otherwise undo it."
    ),
),
Rule(
    id="git-push-delete",
    names=frozenset({"git"}),
    decision="ask",
    predicate=lambda c: c.subcommand_is("push")
    and (
        "delete" in set(c.long_flags())
        or c.has_short_letter("d")
        or any(arg.startswith(":") and len(arg) > 1 for arg in c.positionals())
    ),
    message="This removes the branch from the remote.",
),
```

`git-history-rewrite`は`filter-branch`と`filter-repo`に加え、reflogのエントリを間引く`reflog expire`、コミットを本当に回収不能にする`gc --prune=now`/`--prune=all`をまとめて拾います。素の`git gc`や`git reflog`だけの呼び出しは対象外です。

`git-push-delete`は`--delete`、`-d`、`:branch`という3通りの削除記法をすべて拾います。

この2本と、既存の`git-clean-force`を合わせた3本を最初のコミットで入れました。テストは340件近くまで増え、実際のトランスクリプトでは7件が新たに止まりました。内訳はfilter-branchが4件、reflog expireが2件、リモートブランチの削除が1件です。

`git-push-delete`には少し補足が要ります。`git push`自体はpermissions.jsonの`Bash(git push *)`で元からaskの対象です。このルールが変えたのは「確認が挟まるかどうか」ではなく、「確認画面に理由が書いてあるかどうか」です。ガードのask判定が先に確定するので、permissions.json側の素のaskでは出ない一文が、確認画面にそのまま乗ります。

`git branch -D`はこの時点で見送っています。理由は最後の節に書きます。

## denyのメッセージが、自分自身の迂回穴を勧めていた

`git reset --hard`のdenyメッセージは、ask判定を導入した記事で紹介した通りこうなっています。

```python
message=(
    "git reset --hard is blocked by a static rule in settings.json. "
    "Nobody blocked this interactively. It throws away uncommitted work "
    "with no way back. Use git restore to discard specific files, git "
    "revert to undo a commit, or git reset --soft to move HEAD while "
    "keeping the working tree."
),
```

代替として名指ししているのが`git restore`です。ところが表を作っている時点で、`git restore`にはガードにもpermissions.jsonにも該当するルールが1つもありませんでした。sandboxの自動許可に落ち、確認なしに実行されます。

[git restoreの公式ドキュメント](https://git-scm.com/docs/git-restore)によると、`--worktree`と`--staged`のどちらも指定しなかった場合、対象は作業ツリーになります。`--staged`だけを指定した場合はインデックスだけが対象です。

つまり`git restore .`は、パスを何も絞らず作業ツリー全体をHEADの内容で上書きします。deny側が防ごうとしていた「未コミットの変更を取り消せない形で失う」という被害を、まったく同じ形で出せる操作でした。

![git reset --hardのdenyメッセージが、代替としてgit restoreを名指しする図。左の列はgit reset --hardがガードによって拒否され実行されない結末に至る。右の列は、denyメッセージが代替として示したgit restore .が、修正前はガードにもpermissions.jsonにも該当ルールがなくsandboxの自動許可を通り、作業ツリー全体の破棄という同じ結末に至る。denyメッセージ自身が右の列を指し示している。](/images/posts/claude-code-bash-guard-git-rules/deny-recommends-detour.svg)

自分が書いたdenyメッセージが、自分自身の迂回穴を名指しで勧めていたことになります。塞いだのが次のルールです。

```python
Rule(
    id="git-restore-worktree",
    names=frozenset({"git"}),
    decision="ask",
    predicate=lambda c: c.subcommand_is("restore")
    and "." in c.positionals()[1:]
    and not (
        "staged" in set(c.long_flags()) and "worktree" not in set(c.long_flags())
    ),
    message=(
        "This throws away every uncommitted change in the working tree, "
        "the same as git reset --hard would."
    ),
),
```

対象を絞ったのは`.`を指定した場合だけです。ファイル名やディレクトリ名を指定する復元は対象外のままにしています。denyメッセージが実際に勧めているのがその形であり、そこまで塞ぐとdeny自体が機能しなくなるからです。

`--staged`だけを指定した場合も対象外にしています。公式ドキュメントの通り、対象がインデックスだけなら作業ツリーのファイルは変わりません。

同じコミットで、stashの破棄も塞いでいます。理由は同じで、stashに積んだ変更はそこにしか残っていません。

```python
Rule(
    id="git-stash-discard",
    names=frozenset({"git"}),
    decision="ask",
    predicate=lambda c: c.subcommand_is("stash")
    and bool({"drop", "clear"} & set(c.positionals()[1:3])),
    message="This throws away stashed work, which nothing else holds.",
),
```

`list`・`push`・`pop`・`show`といった普段使うstashの操作は対象外のままです。

この修正でテストは460件台まで増え、40件台ある変異はすべて捕まえています。

実際のトランスクリプトを当て直すと、新たに止まったのは1件だけで、それはstashの破棄でした。作業ツリー全体を復元する`git restore .`は、**一度も実行されていません**。今回のルールは、実際に踏まれていた地雷を片付けたのではなく、踏まれる前に道を塞いだ格好です。

## git checkoutは、意図的に対象から外した

同じ調査の中で、`git checkout`も見ています。結論は変えず、permissions.jsonの`Bash(git checkout *)`でまとめて確認を挟む、既存の形のままにしました。

[git checkoutの公式ドキュメント](https://git-scm.com/docs/git-checkout)は、この判断の理由をそのまま言い当てています。

> When you run `git checkout <something>`, Git tries to guess whether `<something>` is intended to be a branch, a commit, or a set of file(s), and then either switches to that branch or commit, or restores the specified files.
>
> — [git-checkout](https://git-scm.com/docs/git-checkout)

`<something>`が実在するref名なら、ブランチやコミットへの切り替えです。ref名として存在せず、かつ作業ツリーやインデックスにそのパスが存在するなら、確認なしにそのファイルを復元します。未コミットの変更はそこで消えます。

実際のコマンド履歴にも、ブランチではなく特定のファイル(ローカライズ用の`.xcstrings`ファイルなど)を指定して`git checkout`したものがありました。

![git checkout <name>という同じ書き方が、2つの異なる結末に分かれる図。上の入力は1つの文字列だけで、リポジトリの状態を見て初めて枝分かれする。nameがrefとして存在すればブランチの切り替えになり安全な結末に至る。nameがrefとして存在せずパスとして存在すれば、確認なしのファイル復元になり未コミットの変更を失う結末に至る。ガードはコマンド文字列だけを見る関数で、この枝分かれを判定するために必要なリポジトリの状態を持たない。](/images/posts/claude-code-bash-guard-git-rules/checkout-ambiguity.svg)

どちらの意味になるかは、コマンドの文字列だけでは決まりません。そのref名がリポジトリに存在するかどうかを見て初めて分かります。ガードの`Command`クラスは、渡されたargvだけを見て判定する関数で、リポジトリの状態を読みに行くことはありません。読みに行った時点で、ペイロードだけを見て判定するという設計そのものが崩れます。

この境界はガードに持ち込まず、permissions.jsonがまとめて確認を挟む今の形に任せたままにしています。

## git branch -Dの衝突は持ち越した

`git branch -D`は今回のどのルールにも入れていません。permissions.jsonにはすでに`Bash(git branch *)`というallowがあり、`git branch`はサブコマンドを問わずすべて確認なしに実行されます。

[git branchの公式ドキュメント](https://git-scm.com/docs/git-branch)によると、`-d`はブランチがupstreamかHEADに対してマージ済みでない限り削除を拒みます。`-D`は`--delete --force`のショートカットで、この確認を素通りします。

ブランチにreflogがあれば、削除と同時にそのreflogも消えるとも書かれています。ブランチ名を手がかりに、マージされていない変更へあとから辿り着く経路そのものが失われるということです。

`-D`だけを狙うルールを書けば、ガードは先に評価されるので技術的には割り込めます。ただ、既存の`Bash(git branch *)`は`git branch`全体を安全な操作として扱っている前提そのものなので、そちらをどう扱うかを決めてからでないと、この場では手を付けられません。ここは別の作業として持ち越しています。