ghコマンド向けのルールを積む前に、claude-bash-guard自体のコマンド解釈層に5つの迂回経路が見つかったため、優先度を上げて先に塞ぎました。この記事では、2軸の判定基準に沿って対応した3件と見送った2件、実装の途中で見つかった既存バグ2件を書きます。実装は前3回と同じくcorrupt952/dotfilesのmodules/claude/claude-bash-guard.pyにあります。
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つの経路に、この基準をそのまま当てました。
対応したのは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
1段包んだ形は「モデルの素直な迂回」に対する備えです。2段以上を追いかけようとすると、包み方の組み合わせは際限なく増え、これは「敵の難読化」の側に踏み出す作業になるので、そこから先はsandboxの領分として扱っています。
nixなどのラッパーを、WRAPPERSの外で剥がす
envやsudoのようなラッパーは、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
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択には収まらなくなります。
フラグを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']
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#python3やpage#sectionも、語の途中にある#として扱われ、そのまま残ります。
ポートを緩めた分だけ、localhost判定にuserinfo経由の隙間ができかけた
curlやwgetの宛先が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宛てだから安全」という判定を通してしまいます。
実装したのは、ポート部分の文字種から@だけを除いた形です。
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を広げるだけでは届きません。同じ種類の起動経路が実データに次に出てきた時点で、あらためて仕組みを作る必要があります。
