AI

gh apiが全ルールの抜け道だったので、ホスト判定に割り切る

  • Claude Code
  • gh CLI
  • Security

Claude CodeのBash実行ガードに、ghコマンド全体を対象にしたルールを追加しました。gh CLIの全サブコマンドをdeny・ask・allowの3層に分類する過程で、gh apiがそれまでに足したすべてのdenyルールの抜け道になることに気づき、判定ロジックを丸ごと作り直しています。

ブログBash実行ガードにask判定を追加するClaude CodeのBash実行ガードはdenyしか返せず、破壊的だが正当な理由のある操作の置き場がありませんでした。Ruleにdecisionフィールドを足してask判定を追加し、メッセージの長さをdenyはモデルが読む詳しい説明に、askはユーザーが読む一文に分けています。実装の過程で見つかったテストの穴も書きます。

前回の記事では、Ruledecisionフィールドを足し、denyに加えてaskを返せるようにしました。この記事では、そのdecisionを実際にghコマンドへ使ってみた結果を書きます。実装は前回と同じくcorrupt952/dotfilesmodules/claude/claude-bash-guard.pyにあります。

ghは無警告で実行されていた

ghはsandbox内で動き、github.comは許可ドメインに入っています。sandbox.autoAllowBashIfSandboxedがそのまま効き、ghの呼び出しは確認なしに実行されます。認証情報を常に保持するコマンドなのに、チェックポイントがどこにもありませんでした。

作業を始めた当初は、permissions.allowBash(gh *)が無いのでghはすべて確認プロンプトが出る、と思い込んでいました。実際はその逆で、この無警告実行こそが対応の動機です。

この勘違いに気づいたことで、ask層の位置づけも変わりました。将来ありうるallow設定への保険ではなく、gh呼び出しに対する唯一のチェックポイントです。

全サブコマンドを--helpに当てて分類する

実機のgh 2.96.0で、全サブコマンドに--helpを当てて挙動を確認しました。分類の軸は3つです。

  • 自己権限拡大、任意コード実行の永続化、復旧不能な削除など、承認しても得るものがない操作 → deny
  • 外向きの書き込みではあるものの、日常的に必要で取り消せる操作 → ask
  • 読み取り専用の操作 → allow(ガードは何もしない)

ghコマンドをdeny・ask・allowの3層に振り分ける。ghコマンドが入力されると、静的ルールがdeny・ask・allowのいずれかに振り分ける。denyにはgh auth token、gh repo delete、gh copilotなどの6本のルールがあり、askにはgh secret set、gh release delete、gh workflow runなどの4本のルールがある。どちらにもマッチしない残り全部がallowで、そのまま実行される。

分類の土台になっているのがgh_is()です。

def gh_is(command: Command, *paths: tuple[str, ...]) -> bool:
    # Asking gh how a subcommand works prints text and does nothing else, and
    # it is how the right invocation gets found in the first place.
    if "help" in set(command.long_flags()) or "-h" in command.args:
        return False

    words = gh_words(command)
    return any(words[: len(path)] == path for path in paths)

("auth", "token")のようなタプルをサブコマンドの並びの先頭と突き合わせるだけの、単純な前方一致です。

globalフラグでサブコマンドの並びがずれる

この前方一致には、実データに当てるまで気づかなかった穴がありました。ghのglobalフラグには、-R owner/repoのように次の引数を値として取るものがあります。これを無視すると、gh -R owner/repo auth tokenのサブコマンドの並びは["owner/repo", "auth", "token"]になり、("auth", "token")との前方一致が外れてしまいます。

# gh's global flags that take a separate value. Their value would otherwise
# read as the first subcommand word.
GH_VALUE_FLAGS = frozenset({"-R", "--repo", "--hostname", "-q", "--jq", "-t", "--template"})

def gh_words(command: Command) -> tuple[str, ...]:
    words: list[str] = []
    skip = False
    for arg in command.args:
        if skip:
            skip = False
        elif arg in GH_VALUE_FLAGS:
            skip = True
        elif not arg.startswith("-"):
            words.append(arg)
    return tuple(words)

値を取るフラグとその次の1語をあらかじめ取り除いてから並びを組み立てるので、gh -R owner/repo auth tokengh auth tokenと同じ判定になります。gh --repo owner/repo repo deploy-key addのように、フラグの値とサブコマンド名がどちらもrepoを含む場合でも、値だけを正しく読み飛ばします。

gh apiが全ルールの抜け道になる

gh CLIには、サブコマンドを介さずGitHubのREST・GraphQL APIへ直接アクセスするgh apiがあります。ここが今回の分類でいちばん厄介な発見でした。

gh repo delete owner/repo --yes
gh api -X DELETE /repos/owner/repo

この2つは、たどり着く先が同じです。サブコマンド名の並びだけを見てgh-irreversible-deleteのようなルールを書いても、gh api経由で同じ効果を再現されたら意味がありません。認証の書き換えも、公開範囲の変更も、事情は同じです。deny対象にしたサブコマンドの数だけ、gh apiという抜け道が並行して開いていることになります。

gh repo delete owner/repo --yes と gh api -X DELETE /repos/owner/repo は行き着く先が同じ。左のコマンドはサブコマンド名を見るルールが直接ブロックできるのに対し、右のコマンドはサブコマンド名のルールを素通りする。ghの内部では両方とも同じHTTPリクエスト、DELETE /repos/{owner}/{repo}に変換され、結果としてリポジトリが消え、取り消す手段はない。

メソッド推論を捨て、ホスト判定に単純化する

最初に考えたのは、gh apiが投げるHTTPメソッドを推論して判定する案でした。公式ドキュメントの説明はこうです。

The default HTTP request method is GET normally and POST if any parameters were added. Override the method with --method.

gh api

これに沿って、--methodがあればそれを採用し、無ければ-f-F--inputのいずれかが付いているかどうかで暗黙のPOSTかGETかを判定し、GraphQLはquery文字列に含まれるmutationという語で見分け、書き込みと判定できた場合はuser/keysrepos/*/hooksのような無条件denyのエンドポイント一覧と照合する、という設計をかなり具体的に詰めました。

この案は実装せずに捨てました。理由は2つあります。

1つ目は、denyのメッセージを読んだうえで生のAPIに回るのは、モデルが素直に迂回したのではなく指示に従わなかっただけだという判断です。前回の記事で立てた2軸のうち、ここで問われているのは「モデルの素直な迂回を防げるか」ではなく、モデルがそもそも指示に背くかどうかで、静的ルールが引き受ける種類の問題ではありません。

2つ目は、argvのパースで対抗しようとすること自体が際限のない対応だという判断です。1つのエンドポイントを例外にすれば、PATCH /repos/{owner}/{repo}PUT /repos/{owner}/{repo}/keysも同じ理屈で一覧に足すことになり、結局捨てたはずのエンドポイント一覧に戻ります。

メソッド推論案を、ホスト判定だけに単純化した。Beforeの案は、--methodの有無、-f・-F・--inputの有無、GraphQLのmutation文字列という3段の判定を経て、書き込みと分かったコマンドをDENY_ENDPOINTSの一覧と照合しており、対象を1つ足すたびに一覧が伸び続ける。Afterの実装は、gh apiがgithub.com以外のホストを向いているかだけを見ており、向いていればdeny、github.com宛てはすべて素通しする。受け皿はトークンのスコープで、delete_repoが元々無く、それを得る唯一の経路であるgh auth refreshもdenyの対象になっている。

実装したのは、ホストの判定だけです。

GITHUB_HOST = re.compile(r"(?:^|\.)github\.com$", re.IGNORECASE)

# An argument that is itself a URL. Anchored, so a `://` inside a search query
# or a field value is left alone.
URL_ARGUMENT = re.compile(r"^[a-z][a-z0-9+.\-]*://", re.IGNORECASE)

def gh_leaves_github(command: Command) -> bool:
    """True when `gh api` was pointed at a host that is not github.com."""
    if not gh_is(command, ("api",)):
        return False

    hosts = []
    hostname = flag_value(command, "--hostname")
    if hostname:
        hosts.append(hostname)
    for arg in command.args:
        if URL_ARGUMENT.match(arg):
            hosts.append(arg.split("://", 1)[1].split("/", 1)[0].split("@")[-1])

    return any(not GITHUB_HOST.search(host.split(":")[0]) for host in hosts)

github.com宛てのリクエストは、書き込みであってもそのまま通します。受け皿はトークンのスコープです。手元のトークンには元々delete_repoが無く、それを得る唯一の素直な経路であるgh auth refreshgh-authルールでdenyにしています。

環境変数プレフィックスは追わない

実機でもう一つ確かめたのが、環境変数プレフィックスによる迂回です。

GH_BROWSER='sh -c "echo pwned"' gh pr list --web

gh--webが付いたコマンドを開く際、GH_BROWSERの値をそのまま実行します。ガードのトークナイザーはコマンド行の先頭にあるVAR=代入を読み飛ばす仕様なので、ルールに届くのはgh pr list --webという文字列だけで、GH_BROWSERの中身は一度も検査されません。

これも前回の2軸に当てはめると、「モデルの素直な迂回を防げるか」ではなく「敵の難読化まで見抜く必要があるか」の側に倒れます。GH_PAGERGH_CONFIG_DIRなど同じ形の変数は他にもあり、すべてのVAR=をコマンドとして解釈しようとすると誤検知が際限なく増えます。ここはsandboxの領分として、ガードでは追いませんでした。

最終形、deny6本とask4本

gh apiとglobalフラグの土台を直したうえで、残りのサブコマンドをdenyとaskに振り分けました。

deny:

  • gh-auth — 認証トークンの表示、ログイン・ログアウト・スコープ更新、SSH・GPGキーの追加
  • gh-config-persist — alias・config・extension・skillへの書き込み。セッションをまたいで残る
  • gh-agent-spawngh copilot。別のエージェントをシェル権限付きで起動する
  • gh-api-remotegh apiがgithub.com以外のホストを向いた場合
  • gh-irreversible-delete — リポジトリ・プロジェクトの削除
  • gh-repo-publish — 公開範囲をpublicに変更する操作
Rule(
    id="gh-repo-publish",
    names=frozenset({"gh"}),
    predicate=lambda c: gh_is(c, ("repo", "edit"))
    and (flag_value(c, "--visibility") or "").lower() == "public",
    message=(
        "Making a repository public is blocked by a static rule in "
        "settings.json. Nobody blocked this interactively. Disclosure cannot "
        "be taken back: once the code has been fetched, setting it private "
        "again changes nothing. Tell the user what you would publish and let "
        "them decide."
    ),
),

ask:

  • gh-ci-config — secret・variableの設定と削除
  • gh-remote-delete — release・issue・gist・runの削除
  • gh-remote-run — workflowの実行、agent-taskの作成
  • gh-repo-sync-force — 強制的なリポジトリ同期

askのメッセージは、前回の記事で決めた通り一文です。

Rule(
    id="gh-remote-run",
    names=frozenset({"gh"}),
    decision="ask",
    predicate=lambda c: gh_is(c, ("workflow", "run"), ("agent-task", "create")),
    message="This starts work on GitHub's machines, and it bills.",
),

gh workflow run --refはdenyに分けていません。ワークフローの実行はpushに対する権限昇格ではなく、on: pushのワークフローがあれば、dispatchが無くても同じコードがそのまま走ります。

実際の履歴にあったgh workflow runの呼び出しは、すべて--ref付きでした。ここをdenyにすると、実際に使われている形だけをちょうど塞ぐことになります。

検証は、実際に使ったBash呼び出し履歴に新しいルールを当て直す形で行いました。4万件近い履歴のうち、gh呼び出しは5,000件を超えます。deny側で新たに止まったのはgh auth tokenが数件とgh repo deleteが1件だけで、ask側で新たに確認を挟むようになったのはgh workflow runの呼び出しだけでした。

実データに当てて初めて出た誤検知

テストでは見つからず、実際のBash呼び出し履歴に当てて初めて出てきた誤検知が3件ありました。

1つ目は--helpです。gh_is()は当初、サブコマンドの並びだけを見ていたので、gh alias set --helpのような呼び出しもgh-config-persistにマッチしていました。ヘルプを読むことは、そのコマンドの正しい使い方を確かめる手段そのものです。上で見たgh_is()の冒頭にあるhelpチェックは、このために足しました。

2つ目は検索クエリの誤認です。gh api -X GET search/code -f q="docker://"のような呼び出しで、クエリ文字列に含まれるdocker://が「URLへのアクセス」と誤認されていました。URL_ARGUMENTの正規表現を引数の先頭からのスキーム表記に絞り、値の途中に出てくる://は対象から外しています。

3つ目は--visibilityの大文字表記です。gh repo edit --visibility PUBLICは、比較が完全一致だったため素通りしていました。gh自身もこの値を正規化しないことを実機で確認したうえで、上のgh-repo-publishにある.lower()で大文字小文字を無視する比較に直しています。

ガードで追わない経路

ghには、ローカルのファイルをそのまま外向きのリクエストに乗せる書き方があります。gh api -F x=@.envgh gist create .envgh secret set -f .envはどれも、ローカルのファイルの中身をGitHub側に送ります。

これらは今回のガードでは対象にしていません。ガードはコマンド行だけを見る決定的な関数で、ファイル名が.envかどうかまでは判定できても、中身が別の経路で読み込まれた場合に同じ判定を保てる保証がありません。ここはsandboxの読み取り制限の領分として、別の対応に残しています。

bash-guardにも書いていますが、そもそもの権限を最低限に絞ったり、環境自体を見える譲歩を制限するといったことにした方がいいでしょう。