AI

git restoreがgit reset --hardの抜け道になっていたので、askで塞いだ

  • Claude Code
  • Git
  • Security

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

ブログPreToolUseフックで代替手段まで返すClaude Codeのpermissions.denyはブロックするだけで理由を返せないため、代替手段の探索をモデルに任せることになり、そのぶんトークンを使います。Bash向けのdenyをPreToolUseフックに移し、exit 2でstderrに書いた文を返すことで、ルールごとに止めた理由と代替手段を伝えられるようにしました。プレフィックス一致では見えなかった回避経路と、止める範囲を引き直した基準も書きます。 ブログBash実行ガードにask判定を追加するClaude CodeのBash実行ガードはdenyしか返せず、破壊的だが正当な理由のある操作の置き場がありませんでした。Ruleにdecisionフィールドを足してask判定を追加し、メッセージの長さをdenyはモデルが読む詳しい説明に、askはユーザーが読む一文に分けています。実装の過程で見つかったテストの穴も書きます。 ブログgh apiが全ルールの抜け道だったので、ホスト判定に割り切るClaude CodeのBash実行ガードにghコマンドのルールを追加したところ、gh apiが全ルールの抜け道になることが分かりました。メソッド推論による判定案を検討したものの、最終的にはgithub.com以外のホストだけを見る形に単純化しています。実データに当てて見つかった3件の誤検知も書きます。 ブログBash実行ガードのテストに変異チェッカを足すclaude-bash-guardのテストスイートに、ソース文字列を1つずつ壊した変異体を作りテストが落ちるか確かめる変異チェッカを新設しました。テストが通ることとテストが効いていることは別だという前提のもと、実際に見つかった2つの穴と、あえてCIには組み込まなかった判断を書きます。 ブログルールを増やす前に、ガードの迂回経路を塞いだgh向けのルールを積む前に、claude-bash-guard自体のコマンド解釈層に5つの迂回経路が見つかりました。モデルの素直な迂回を防げるかと、敵の難読化まで見抜く必要があるかという2軸に沿って3件を塞ぎ、2件は見送っています。実装の途中で見つかったコメント除去とlocalhost判定の既存バグ2件も書きます。

このシリーズもこの記事で最後です。前回までにdenyとaskの仕組み、ghコマンドの分類、ガード自体の解釈層にあった迂回穴、テストの変異チェッカを扱ってきました。今回はgitそのものに話を戻します。実装は前回までと同じくcorrupt952/dotfilesmodules/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のaskallowは評価されません。ガードが何のルールにも一致しなかったときだけ、permissions.jsonの判定に進みます。

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

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

コマンドガードの判定permissions.jsonの判定実際の挙動
git clean -fask(git-clean-force)該当ルールなし確認を挟む
git reset --harddeny(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ルールを入れた

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

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-rewritefilter-branchfilter-repoに加え、reflogのエントリを間引くreflog expire、コミットを本当に回収不能にするgc --prune=now/--prune=allをまとめて拾います。素のgit gcgit 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判定を導入した記事で紹介した通りこうなっています。

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の公式ドキュメントによると、--worktree--stagedのどちらも指定しなかった場合、対象は作業ツリーになります。--stagedだけを指定した場合はインデックスだけが対象です。

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

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

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

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に積んだ変更はそこにしか残っていません。

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.",
),

listpushpopshowといった普段使うstashの操作は対象外のままです。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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