AI

Bash実行ガードのテストに変異チェッカを足す

  • Claude Code
  • Testing
  • Security

Claude CodeのBash実行ガードには、これまで2回にわたってdeny・ask・ghコマンド専用のルールを足してきました。そのたびに確認していたのが「450 pass / 0 fail」のようなテスト結果です。ただし全件passは、実装が意図通りに動いている証拠にはなりません。テストスイート自体に穴があれば、壊れた実装のままでもテストは通ったままだからです。

ブログBash実行ガードにask判定を追加するClaude CodeのBash実行ガードはdenyしか返せず、破壊的だが正当な理由のある操作の置き場がありませんでした。Ruleにdecisionフィールドを足してask判定を追加し、メッセージの長さをdenyはモデルが読む詳しい説明に、askはユーザーが読む一文に分けています。実装の過程で見つかったテストの穴も書きます。 ブログgh apiが全ルールの抜け道だったので、ホスト判定に割り切るClaude CodeのBash実行ガードにghコマンドのルールを追加したところ、gh apiが全ルールの抜け道になることが分かりました。メソッド推論による判定案を検討したものの、最終的にはgithub.com以外のホストだけを見る形に単純化しています。実データに当てて見つかった3件の誤検知も書きます。

この前提を確かめるために、claude-bash-guard.mutation.py という変異チェッカを新設しました。実装は前2回と同じくcorrupt952/dotfilesmodules/claudeに置いています。この記事では、変異チェッカの仕組み、それで実際に見つかった2つの穴、そしてこの仕組みをCIには組み込まなかった判断を書きます。

テストが通ることと、テストが効いていることは別

変異チェッカがやっていることは単純です。ガード本体(claude-bash-guard.py)のソースコードから1箇所の文字列を意図的に壊した「変異体」を作り、既存のテストスイート(claude-bash-guard.test.py)をその変異体に対して実行します。テストが1つでも落ちれば、その行は少なくとも1本のテストに守られていることが分かります。全部通ってしまえば、その行を壊してもテストは気づかない、ということです。

def run_suite(source: str) -> tuple[int, str]:
    with tempfile.TemporaryDirectory() as tmp:
        work = pathlib.Path(tmp)
        (work / GUARD).write_text(source)
        shutil.copy(HERE / TEST, work / TEST)
        proc = subprocess.run(
            [sys.executable, str(work / TEST)],
            capture_output=True,
            text=True,
            timeout=900,
        )
        return proc.returncode, proc.stdout

1つの変異は、ソース中の文字列(needle)を別の文字列(replacement)に置き換えるだけの1行の差分として書かれています。例えば、この記事で扱う2件はこうなっています。

MUTATIONS: dict[str, tuple[str, str]] = {
    "abbrev: head budget halved": (
        "    head = text[: limit - marker_budget]",
        "    head = text[: (limit - marker_budget) // 2]",
    ),
    "ask: git clean dry-run not spared": (
        '        and not (c.has_short_letter("n") or "dry-run" in set(c.long_flags())),',
        "        and True,",
    ),
    ...
}

置き換え元の文字列がソースに見つからなければ、それはSTALE(アンカー切れ)として区別して報告します。壊せなかった行と、壊しても通ってしまった行(SURVIVED)を混同しないためです。現在、変異は40件台まで増えています。

変異チェッカの処理の流れ。元のソースから1行だけ壊した変異体を40件台のパターンで作り、それぞれに既存のテストスイートを実行する。結果はCAUGHT(どこかのテストが落ちる)、SURVIVED(全テストが通ったまま)、STALE(壊す対象の文字列が見つからない)の3つに分かれ、SURVIVEDだけがテストの穴を意味する。

上限でしか縛っていなかった不変条件

1つ目に見つかった穴は、abbreviate()にありました。denyのメッセージには、ブロックしたコマンド全文をabbreviate()で200文字(ECHO_LIMIT)に切り詰めてから載せています。

def abbreviate(text: str, limit: int = ECHO_LIMIT) -> str:
    """Cut an over-long sub-command down to its head.

    The head is what identifies the call among several on one line, the only
    job the echo has. Nothing is kept from the end: the model wrote the
    command, so a trailing path tells it nothing it does not already have.

    Reserving the marker inside the limit makes two invariants exact: the
    result never exceeds `limit`, and so is never longer than what it replaced.
    """
    if len(text) <= limit:
        return text

    marker_budget = len(f" ... (+{len(text)} chars)")
    head = text[: limit - marker_budget]
    return f"{head} ... (+{len(text) - len(head)} chars)"

このテストが確認しているのは、次のようなことです。

  • 0文字・200文字ちょうどはそのまま素通しする(境界)
  • 201文字は削られる
  • 削った先頭(head)は元のプログラム名やフラグを保持する
  • 省略した文字数の表示(+N chars)は、実際に削った文字数と一致する
  • 出力全体はECHO_LIMIT以内に収まる、かつ入力より短い

どれも、出力を「これより長くならない」という方向でしか縛っていません。試しに、headに割り当てる予算を半分にする変異を作ってみます。

"abbrev: head budget halved": (
    "    head = text[: limit - marker_budget]",
    "    head = text[: (limit - marker_budget) // 2]",
),

headが本来より短くなるだけなので、上の5つの確認はすべて素通りします。出力は元の実装よりさらに短くなるので上限には収まり続けますし、+N charsの計算はheadの実際の長さから逆算しているので、headをいくら削っても帳尻は合ったままです。

この変異を捕まえていたのは、下限を確かめる次の1本でした。

record(
    len(out) >= LIMIT - 1,
    f"{length:,} chars in fills the cap",
    f"got {len(out)} chars, leaving budget unspent",
)

headが予算を使い切っているかを直接確認する行です。これがあることで、上の変異は出力が199文字を大きく下回ることになり、テストが落ちるようになっています。

abbreviate()の出力長を上限だけで測っていた図。元の実装はheadと省略マーカーで200文字の上限ぎりぎりまで埋めるが、headの予算を半分にした変異は出力がずっと短くなる。それでも上限以下であること・元より短いこと・省略文字数の帳尻が合うことという既存の確認はどれも満たしてしまい、出力が上限にどれだけ近づいているかという下限の確認だけがこの変異を捕まえる。

分岐の片方にしか踏み込んでいなかったテスト

もう1つの穴は、predicateの分岐に隠れていました。前回までの記事で足したgit-clean-forceルールのpredicateは、-f/--forceが付いていて、かつ-n/--dry-runが付いていないことを条件にしています。

predicate=lambda c: c.subcommand_is("clean")
and (c.has_short_letter("f") or "force" in set(c.long_flags()))
and not (c.has_short_letter("n") or "dry-run" in set(c.long_flags())),

dry-runを除く、という条件をテストしていたのは次の3件でした。

allowed("git clean -n")
allowed("git clean -nd")
allowed("git clean --dry-run -d")

この3件はどれも-f/--forceを持っていません。predicateの1つ目の条件(-f/--forceがあるか)がその時点ですでに偽になるので、Pythonのandは短絡し、2つ目の条件(dry-runを除く)は一度も評価されません。dry-run除外の変異を作ってみます。

"ask: git clean dry-run not spared": (
    '        and not (c.has_short_letter("n") or "dry-run" in set(c.long_flags())),',
    "        and True,",
),

この3件のテストにとっては、2つ目の条件がどう変わろうと結果は変わらないので、全部通ってしまいます。実際にこの条件を通すには、-f-n/--dry-runを両方持つコマンドが要ります。

allowed("git clean -fn")
allowed("git clean -nf")
allowed("git clean -f --dry-run")
allowed("git clean --force --dry-run -d")

この4件を足したことで、dry-run除外の条件が壊れたときに初めて検知できるようになりました。

3本のallowedテストが2つ目の条件に一度も届いていなかった図。git-clean-forceのpredicateは-f/--forceの条件とdry-run除外の条件のANDで、-f/--forceを持たない3件は1つ目の条件で決着し2つ目は評価されない。-fと-nの両方を持つコマンドだけが2つ目の条件を実際に通過し、dry-run除外が壊れたことを検知できる。

CIには入れないという判断

変異チェッカは、開発中に手元で走らせる道具として置いていて、CIには組み込んでいません。理由はモジュールのdocstringに書いています。

Breaks the guard on purpose, to see whether the test suite notices.

A development tool, not a CI gate, and deliberately so: every mutation is
anchored to a literal string in the source, so ordinary edits break anchors,
and a stale anchor looks the same as a real gap. The suite's own red means "a
rule stopped matching", and mixing this in would blur that signal.

この40件台の変異は、どれもソースの中の特定の文字列を名指しして壊す形で書かれています。ガード本体を普通に編集すれば対象のコード自体も変わるので、この名指しは頻繁に外れます。

実際、変異チェッカを触っていたある1日だけで、アンカー切れが複数回起きました。

アンカーが外れてSTALEになった変異と、本当にテストの穴が空いているSURVIVEDは、変異チェッカ自身のレポートでは区別されています。ですが、これをCIに混ぜてしまうと、CIが返すのは赤か緑かの1ビットです。CIのclaude-bash-guardジョブには、こう書いてあります。

# The guard decides which Bash commands Claude Code may run, so a rule
# that silently stops matching is a hole rather than a broken test.

通常のテストスイート(claude-bash-guard.test.py)がCIで落ちたときの意味は1つだけです。ルールが黙ってマッチしなくなった、ということです。

ここに変異チェッカを混ぜると、赤の意味が「本当に穴が空いた」のか「昨日の編集でアンカー文字列がずれただけ」なのか、CIの外からは見分けが付かなくなります。変異チェッカは、その1ビットの意味を濁らせないために、CIの外に置いています。

変異チェッカをCIに混ぜると赤の意味が濁る図。現状のCIはtest.pyだけを実行し、赤の意味はルールが黙ってマッチしなくなったという1つに定まる。仮に変異チェッカも混ぜた場合、赤の原因はアンカーがずれただけのSTALEと本当の穴であるSURVIVEDの2つに分かれ、CIの外からはどちらか見分けが付かない。

変異チェッカが確かめないこと

変異チェッカが確かめるのは、ソースの各行を壊したときに既存のテストが気づけるか、という1点だけです。テストケースの前提そのもの(例えばgit clean -fをaskに分類した判断が妥当かどうか)は、この仕組みの外側にあります。ルールを書いた時点の判断が正しいかどうかまでは、ここでも保証されません。