AI

ルールを増やす前に、ガードの迂回経路を塞いだ

  • Claude Code
  • Security

ghコマンド向けのルールを積む前に、claude-bash-guard自体のコマンド解釈層に5つの迂回経路が見つかったため、優先度を上げて先に塞ぎました。この記事では、2軸の判定基準に沿って対応した3件と見送った2件、実装の途中で見つかった既存バグ2件を書きます。実装は前3回と同じくcorrupt952/dotfilesmodules/claude/claude-bash-guard.pyにあります。

ブログ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向けのルールより先に、解釈層を疑った

前回の記事では、gh CLIの全サブコマンドをdeny・ask・allowの3層に分類しました。その作業に取りかかる前に、もう一段手前で立ち止まっています。ルールを何本積んでも、コマンドの文字列をルールに渡す前の層が正しく分解できていなければ意味がありません。

security dump-keychainをブロックできても、対象リストに名前が無いインタープリターを使われれば、その1本のルールは最初から検査対象に入りません。

実データを当て直してみると、5つの迂回経路が見つかりました。gh向けのルールを積み上げる前に、土台にあたるこちらを先に塞いでいます。

2軸で5つの経路を仕分ける

1本目の記事で立てた判定基準は、ガードのdocstringにある一文がもとになっています。

Fails open: a malformed payload lets the call through to the normal permission
flow. Real containment belongs to the sandbox settings.

封じ込め(containment)はsandboxの仕事で、ガードの仕事は誘導です。新しいルールを足すかどうかは「モデルの素直な迂回を防げるか」と「敵の難読化まで見抜く必要があるか」の2軸で決めます。見つかった5つの経路に、この基準をそのまま当てました。

5つの迂回経路を2軸の判定基準で仕分ける図。1つ目の問い、モデルの素直な迂回を防げるか、は5件とも満たしており、2つ目の問い、敵の難読化まで見抜く必要があるか、だけで分かれる。必要はないと答えたshell -cの1段包み、nixなどのラッパー、インタープリター名の不足の3件はガードが対応し、必要があると答えたリダイレクトの書き込みと環境変数プレフィックスの2件はsandboxの領分として見送る。

対応したのは3件、見送ったのは2件です。

対応した3件

shell -cの中身を1段だけ開く

bash -c '...'のように渡されたコマンドは、シェルへの1個の引数としてそのままトークナイザーを通り抜けます。中身がどんなコマンドであっても、ルール側はbashという名前しか見ておらず、-cの引数は1個の文字列としてしか扱われません。修正前のコードで実際に試すと、こうなっていました。

bash -c 'rm -rf /tmp/x'   -> not blocked

rm -rfという明確に危険な操作でも、bash -cで1枚包むだけで検査から完全に外れています。ここにnested_command()を足しました。

def nested_command(command: Command) -> str | None:
    """The program a shell was handed with -c, which no rule can otherwise see.

    The letter may sit anywhere in a short-flag cluster, and the program is the
    token after that cluster rather than the first positional, since a flag's
    own value can come in between.
    """
    if command.name not in SHELLS:
        return None

    for index, arg in enumerate(command.args):
        if not arg.startswith("-") or arg.startswith("--"):
            continue
        if "c" in arg[1:]:
            # An end-of-options separator may sit in between, and is not it.
            for candidate in command.args[index + 1 :]:
                if candidate != "--":
                    return candidate
            return None

    return None

呼び出し側のfind_violationは、これを深さ1回だけ呼びます。

# One level, deliberately. Deeper nesting stops being the plain detour
# this hook exists to redirect, and containment is the sandbox's job.
if depth == 0:
    payload = nested_command(command)
    ...

修正後、1段包んだ形は開いて検査するようになりました。2段目には踏み込みません。

bash -c 'rm -rf /tmp/x'                    -> BLOCKED by rm-forced
bash -c 'bash -c "rm -rf /tmp/x"'          -> not blocked

shell -cの再帰を1段までで止める図。深さ0は元のコマンド行そのもので、通常通り全ルールに掛ける。深さ1はbash -cの引数を1回だけ取り出し、同じルールに掛け直す。深さ2、つまりbash -cの中にさらにbash -cが入れ子になった形は取り出さず、sandboxの領分として扱う。

1段包んだ形は「モデルの素直な迂回」に対する備えです。2段以上を追いかけようとすると、包み方の組み合わせは際限なく増え、これは「敵の難読化」の側に踏み出す作業になるので、そこから先はsandboxの領分として扱っています。

nixなどのラッパーを、WRAPPERSの外で剥がす

envsudoのようなラッパーは、WRAPPERSという定数に名前を登録しておくだけで、normalize()が先頭から1個ずつ剥がしてくれます。

WRAPPERS = frozenset(
    {
        "builtin", "command", "doas", "env", "exec", "nice",
        "nohup", "setsid", "stdbuf", "sudo", "time", "timeout", "xargs",
    }
)

nix shell ... --command Xは、この単純な剥がし方には収まりません。nixの後ろに--commandが来るまでの間に、パッケージ名やフラグがいくつも挟まるからです。修正前に試すと、次のようになっていました。

nix shell nixpkgs#python3 --command python3 -c 'print(1)'   -> not blocked

インタープリターを対象にしたルールがあっても、nixという1語がすべてを覆い隠していました。normalize()に、nix専用の分岐を1つ足しています。

# `nix ... --command X` runs X; everything before it only picks an
# environment. Dropping the prefix puts X in command position.
if tokens and tokens[0] == "nix" and "--command" in tokens:
    del tokens[: tokens.index("--command") + 1]
    changed = True

normalize()全体は、剥がせるものが無くなるまで同じループを回り続ける作りです。

changed = True
while changed and tokens:
    changed = False
    while tokens and (tokens[0] in KEYWORDS or ASSIGNMENT.match(tokens[0])):
        tokens.pop(0)
        changed = True
    if tokens and tokens[0] == "nix" and "--command" in tokens:
        del tokens[: tokens.index("--command") + 1]
        changed = True
    if tokens and tokens[0] in WRAPPERS:
        ...
        changed = True

normalize()が先頭のトークンを剥がし続けるループの図。キーワードや変数代入を剥がす、nixのプレフィックスを剥がす、WRAPPERSの名前を剥がす、という3種類の処理を順に試し、どれか1つでも剥がせたら先頭に戻ってもう一度同じ3種類を試す。3種類とも剥がせなかった時点でループを抜け、残ったトークン列の先頭が実際のコマンドになる。

nixの分岐を足したことで、さきほどの例は正しく検査対象に入るようになりました。

nix shell nixpkgs#python3 --command python3 -c 'print(1)'   -> BLOCKED by interpreter-inline-code

インタープリターの名前をdictにする

コード実行系のルールは、もともと5個の名前を直接書いていました。

names=frozenset({"python", "python3", "node", "ruby", "perl"}),
predicate=lambda c: c.has_trailing_short_flag(
    "c" if c.name.startswith("python") else "e"
),

インラインコードを渡すフラグの文字は、インタープリターごとに違います。pythonは-c、node・ruby・perlは-e、phpは-rです。もとの実装は「pythonならc、それ以外はe」という2択の三項演算子で済ませていたので、名前を足すだけでは足りません。

swift・bun・osascriptはたまたま-eなので三項演算子のままでも動きますが、-rを要求するphpを足そうとした瞬間、この2択には収まらなくなります。

インタープリターのフラグ判定をbeforeとafterで対比する図。beforeはpythonなら文字c、それ以外なら文字eという2択の三項演算子で、名前をswift・bun・osascriptに広げても2択のままなのでたまたま動く。phpを足すとphpにも文字eが割り当てられてしまい、phpが要求する文字rと食い違って動かなくなる。afterはインタープリター名から対応するフラグ文字を引くdictになっており、phpにはrがそのまま対応する。

フラグをdictで持たせ、名前ごとに引くようにしました。

INLINE_CODE_FLAG: dict[str, str] = {
    "python": "c",
    "node": "e",
    "ruby": "e",
    "perl": "e",
    "swift": "e",
    "bun": "e",
    "osascript": "e",
    "php": "r",
}

INTERPRETERS = frozenset(INLINE_CODE_FLAG)

ルール側は、この辞書を引くだけになりました。

Rule(
    id="interpreter-inline-code",
    names=INTERPRETERS,
    predicate=lambda c: c.has_trailing_short_flag(INLINE_CODE_FLAG[c.program]),
    ...
),

修正前は通っていた3つが、検査対象に入りました。

swift -e 'print(1)'                          -> BLOCKED by interpreter-inline-code
bun -e 'console.log(1)'                      -> BLOCKED by interpreter-inline-code
osascript -e 'do shell script "whoami"'      -> BLOCKED by interpreter-inline-code

見送った2件

リダイレクトの書き込み

echo payload > ~/.ssh/authorized_keysのような書き込みは、ペイロードの文字列だけからは判定できません。書き込み先が実際に何であるかを知るには、ファイルシステムを見るstat()のような呼び出しが要ります。ガードはコマンド行だけを見て判定を返す決定的な関数であることを前提にしているので、ここに手を出すと前提そのものが崩れます。

環境変数プレフィックス

FOO=bar commandという形のFOO=部分は、トークナイザーが先頭から読み飛ばす仕様になっています。この仕様自体は、sudo -u nobody FOO=bar rm -rf /tmp/xのような正当な代入まで誤検知しないために要ります。

一方で、プログラム側が環境変数の中身をシェルに渡して実行する経路まで塞ごうとすると、無数にある環境変数のうちどれが実行に化けるかを1つずつ把握する必要が出てきます。個別のプログラムの仕様に踏み込む対応になるため、ここも見送りました。

どちらも、2軸のうち「敵の難読化まで見抜く必要があるか」の側に傾きます。docstringの言葉を借りれば、ここから先はsandboxの領分になります。

実装の途中で見つかった、既存のバグ2件

語中の#がコメントとして扱われ、複数行スクリプトの残りが消える

コメントの除去は、もとはshlexの標準機能に任せていました。

prepared = command_line.replace("\n", " ; ").replace("\r", " ; ").replace("`", " ; ")
lexer = shlex.shlex(prepared, posix=True, punctuation_chars=True)
lexer.whitespace_split = True

改行は先に;へ置き換えてから渡しています。改行のままだとshlexはただの空白として扱ってしまい、複数行のスクリプトが1個のコマンドに繋がってしまうためです。

ところがこの2つの処理は、組み合わさると別の問題を起こします。shlexは既定で#をコメントの開始として扱い、コメントは行の終わりまでを丸ごと捨てます。改行を先に;へ変換してしまっているので、shlexから見るとその行に終わりがありません。#が1つ出てきた時点で、残りの文字列すべてが消えます。

実際に確認すると、次のようになっていました。

"echo hi\n# tidy up\nrm -rf /tmp/x"  -> ['echo', 'hi', ';']

# tidy upという何気ない1行のコメントのために、後ろにあったrm -rf /tmp/xがトークン列から消えています。ルールは分解された結果しか見ないので、このコマンドはecho hi ;が実行されただけとして扱われ、rm-forcedルールには一度も渡りません。

しかも#は語の先頭かどうかを見ずにコメントとして扱われるので、nixpkgs#python3のようなnixのflake参照や、URLのフラグメントでも同じことが起こります。

"a#b"                                          -> ['a']
"nixpkgs#python3"                              -> ['nixpkgs']
"curl https://example.com/page#section"        -> ['curl', 'https://example.com/page']

コメント除去のbeforeとafterを、文字列上の範囲で対比する図。beforeは echo hi 改行 # tidy up 改行 rm -rf /tmp/x という入力に対し、最初の#から文字列の終わりまでを丸ごとコメントとして消してしまい、rm -rf /tmp/xが結果に残らない。afterは#の直後にある改行で止まるため、# tidy upの部分だけが消え、次の行のrm -rf /tmp/xは残る。

strip_comments()という専用の関数を新設し、引用符の状態を自前で追いながら、語の先頭にある#だけをコメントの開始として扱うようにしました。

def strip_comments(command_line: str) -> str:
    """Drop `#` comments, leaving quoted text alone.

    Has to run before tokenizing: shlex removes the quotes, after which a
    quoted `#` cannot be told from a comment marker. A `#` opens a comment only
    at the start of a word, which is what keeps flake refs and URL fragments.
    """

shlexに渡す前にこの関数でコメントを取り除くので、lexer.commentersは空にして無効化しています。

lexer.commenters = ""

同じ入力を試すと、消えていたrm -rf /tmp/xが残るようになりました。

"echo hi\n# tidy up\nrm -rf /tmp/x"  -> ['echo', 'hi', ';', ';', 'rm', '-rf', '/tmp/x']

nixpkgs#python3page#sectionも、語の途中にある#として扱われ、そのまま残ります。

ポートを緩めた分だけ、localhost判定にuserinfo経由の隙間ができかけた

curlwgetの宛先がlocalhostだけかどうかは、LOCAL_TARGETという正規表現1本で判定しています。ポート部分は、直すまでは数字しか受け付けていませんでした。

r"(?::[0-9]+)?(?:[/?#]|$)"

これだとcurl http://localhost:${PORT}/healthのように、シェル展開される前のポート番号を含むコマンドが弾かれます。${PORT}は数字ではないので一致せず、targets_only_localhost()Falseを返し、localhost宛てのヘルスチェックがnetwork-fetchルールでブロックされていました。

ポート部分を区切り文字以外なら何でも通す形に緩めれば、この誤検知は直ります。ただし単純に緩めるだけでは、別の隙間ができます。

URLのuser:password@hostという書式では、@より前がユーザー情報で、実際の宛先ホストは@の後ろにあります。ポート部分を無条件に緩めると、ユーザー情報の部分が「localhostへのポート付きアクセス」として一致してしまい、@の後ろにある本当の宛先を隠したまま「localhost宛てだから安全」という判定を通してしまいます。

localhost判定の正規表現が、userinfoを挟んだURLのどこまでを一致させるかを示す図。http://localhost:99@203.0.113.10/というURLは、スキームの次がユーザー情報のlocalhost:99、@の後ろが実際の宛先ホスト203.0.113.10になる。ポート部分を無条件に緩めた場合、正規表現はuserinfo全体まで飲み込んでlocalhostへの一致だと誤認し、@の後ろにある本当の宛先を検査から隠してしまう。

実装したのは、ポート部分の文字種から@だけを除いた形です。

r"(?::[^/?#@]*)?(?:[/?#]|$)"

@の手前で一致そのものが止まるので、userinfoを使った書式は「localhostへの一致」にならず、network-fetchルールの対象に留まります。${PORT}のような変数付きのポートは、これまで通り通します。

検証

300件を超えるテストと、ソースの1行を意図的に壊す30件近い変異のすべてで、変更した箇所を守るテストが機能していることを確認しました。

実際に使ったBash呼び出し履歴4万件近くに新しいコードを当て直した結果、失われたブロックはありませんでした。新たにブロックされるようになったコマンドは60件近くあり、1件ずつ手で確認して本来ブロックされるべきものであることを確かめています。

ブロックが外れたのは1件だけで、数字以外の形のポートを含むlocalhost宛てのポートスキャンでした。そもそもブロックする理由がなかったコマンドです。

dictの外にあるインタープリターの起動経路

INLINE_CODE_FLAGのdict化で対応したのは、-e-cのような1個のフラグにコードをそのまま渡す形だけです。sqlite3の.readのようなドットコマンドや、対話モード経由でコードを渡す起動のしかたは、コードの渡し先がフラグではないため、この仕組みの対象に入っていません。同じ「モデルの素直な迂回を防げるか」という軸では対応が要る側に見えますが、判定の形そのものが違うので、今回のdictを広げるだけでは届きません。同じ種類の起動経路が実データに次に出てきた時点で、あらためて仕組みを作る必要があります。