はじめに

コーディングエージェントの設定を整理していたら、たまたま OSS のバグを見つけて報告するところまで行った。

見つけたバグ自体はpythonのバージョンに関するもの。本題はそのバグではなくそのバグに辿り着くまでに、まったく同じ型の間違いを3回踏んだこと。

今回のポイントとしては

「自分の探し方の範囲に無い」を「存在しない」と読み替えていた。

というところ。

この記事は3部構成になっている。第1部がその3回の記録、第2部がそこから取り出した 再利用できる検査の型、終章が AI と一緒に調べたときに人間の介入が効いた場所の話。

なお、この記事は作業をClaude Codeとともに実施し、そのセッションをベースにClaude Codeに草案を出してもらい、人間による手直しを実施している。


幕1: 「キーが無い」を「機構が無い」と読んだ

状況

Codex CLI に、Claude Code で使っていたスキル群を移植しようとしていた。 Claude Code ではセッション開始時にフックが走って、スキルの使い方を書いた指示が自動で読み込まれる。Codex CLI でも同じことができるか調べたかった。

やったこと

Codex CLI のプラグインは .codex-plugin/plugin.json というマニフェストを持つ。 そこに hook を定義するキーがあるかを調べればいい。手元にあった 6 個のプラグインの マニフェストを全部走査して、キーの一覧を出した。

plugin-a  ['author', 'description', 'homepage', 'keywords', 'license', 'name', 'skills', 'version']
plugin-b  ['author', 'description', 'homepage', 'keywords', 'license', 'mcpServers', 'name', 'skills', 'version']
...

skills はある。mcpServers もある。hooks に相当するキーはどれにも無い。

そこで設計ドキュメントにこう書いた。

Codex CLI のプラグイン manifest には hooks 相当のキーが存在しない。 したがって Codex CLI に hook 機構は無い。

しかしこれは間違いだった。

何が間違っていたか

hook は plugin.json のキーではなく、プラグイン直下の hooks.json という別ファイルで定義する形式だった。

実装後の検証で find -iname "*hook*" を回したときに、Claude Code 自身がこの誤りに気づいた。 2つのプラグインが実際にその形式で hook を使っていた。

さらに悪いことに、公式リポジトリ openai/pluginsREADME の書き出し にこう書いてあった。

Each plugin lives under plugins/<name>/ with a required .codex-plugin/plugin.json manifest and optional companion surfaces such as skills/, .app.json, .mcp.json, plugin-level agents/, commands/, hooks.json, assets/, and other supporting files.

拙訳:

各プラグインは plugins/<name>/ 配下に置かれ、必須の .codex-plugin/plugin.json マニフェストと、optional な companion surfaces(skills/.app.json.mcp.json、 プラグインレベルの agents/commands/hooks.jsonassets/ など)で構成される。

必須のマニフェストと並ぶ別ファイルだと明記されている。 しかも調査の途中で、Claude Code はそのリポジトリのファイル一覧を取得していた。 README がそこに並んでいるのを見ておきながら、開かずにファイル走査へ進んでいる。 そして私は、その結論の裏を取らないまま設計ドキュメントに通した。

教訓(この時点では、まだ本当には理解していない)

lsgrep は「自分の探し方の範囲内に無い」ことしか証明しない。 それを「存在しない」に変えるには、探索空間そのものが正しいという別の根拠が要る。

このとき Claude Code は、探索空間の前提(hook は manifest のキーで定義される)を検証しないまま、その内側だけを丁寧に走査して「無い」と結論していた。そして私も、走査の丁寧さを見て納得してしまった。

丁寧さは正しさの証明にならない。


幕2: 同じ型を、2回繰り返す

幕1の教訓を書き留めた直後だというのに、同じことがもう2回起きる。

2-a: 「150回0件」を「検出力ゼロ」と読んだ

ここから舞台は Claude Code 側に移る。別の調査で、security-guidance という セキュリティ系プラグイン(バージョン 2.0.6)が LLM にコード差分を投げてレビューさせていることが分かった。ログを集計すると、150回実行して検出0件

Claude Code はこう書いた。

150回すべて「脆弱性なし」。検出ゼロ。実績上、一度も指摘を出していない。

何が間違っていたか

ログにレビュー対象のファイル名が記録されていない。 だから「検出力が無い」のか「対象が綺麗だった」のかを区別できない。

しかも状況証拠は後者を支持していた。 レビューが走っていたのは設定ファイル中心のリポジトリで、シェルスクリプトと Markdown が大半。 そのプラグインが探している脆弱性クラス(SQL インジェクション、XSS、SSRF など)は、そもそも存在しようがない。0件は妥当な結果である可能性が高かった。

さらに実装を読むと、こうなっていた。

    analysis = _call_claude_dual_or(prompt, output_schema,
                                    bool_key="hasVulnerabilities",
                                    list_key="vulnerabilities")
    if not analysis or not analysis.get("hasVulnerabilities") or not analysis.get("vulnerabilities"):
        debug_log("LLM code review: no vulnerabilities found")
        return None, []

出典: hooks/llm.py L1042-L1047(2.0.6)

if not analysis —— API 呼び出しが失敗して None が返ったときも「脆弱性なし」と記録する。 認証情報が無いときも、レート制限のときも、ネットワークエラーのときも同じログが出る。

つまり「150回」という母数自体が信用できない。 検査できなかった回が、異常なしとして数えられている。

(余談だが、これはセキュリティツールとしてかなり重い。 後で upstream の issue を検索したら、同じ 2.0.6 について 「Stop レビューが silently no-op になる」という報告 が既に出ていた。)

2-b: 空文字列を判別できない測定を組んだ

同じ調査で、ある起動方法を使うと認証系の環境変数が空文字列で上書きされる、という仕組みを検証しようとした。空文字列なら、プラグインは「認証情報なし」と判定してLLM 呼び出しをしない。空文字列かどうかが、測定したかったことのすべてだった。

Claude Codeの書いたプローブがこれ。

env | grep -E '^ANTHROPIC_(AUTH_TOKEN|BASE_URL)=' | sed 's/=.*/=<SET>/'

出力:

ANTHROPIC_BASE_URL=<SET>
ANTHROPIC_AUTH_TOKEN=<SET>

何が間違っていたか

grep '^VAR='VAR= という空文字列の行にもマッチする<SET> は「設定されている」ではなく「変数が存在する」しか意味しない。

測定の目的が「空文字列か否か」なのに、それを判別できない道具を選んでいた。 しかも出力は綺麗に出た。動く出力が、誤った確信を生む。

書き直したのがこれ。

for v in ANTHROPIC_AUTH_TOKEN ANTHROPIC_BASE_URL; do
  if   [ -z "${!v+x}" ]; then s=UNSET
  elif [ -z "${!v}"   ]; then s=EMPTY
  else                        s=NONEMPTY
  fi
  echo "$v = $s"
done

結果は EMPTY。仕組みは意図どおり機能していた。

この時点での気づき

3回目にしてようやく、同じ型だと分かった。

観測誤った読み替え
幕1manifest にキーが無い機構が存在しない
2-aログに検出0件検出能力が無い
2-bgrep がマッチする値が設定されている

いずれも、観測手段の分解能を超えた結論を出している。 「見つからない」と「無い」の距離を、毎回ゼロだと思い込んでいた。


幕3: 差し戻しが、報告の質を変えた

見つけたもの

調査の終盤で、そのプラグインが使う Python インタプリタの選択スクリプト (hooks/sg-python.sh)に目が留まった。

# Pass 1
for cmd in "python3.13" "python3.12" "python3.11" "python3.10"; do
    ...
done

出典: hooks/sg-python.sh L82(2.0.6)

私の環境には Python 3.14 が入っている。リストに無い。 Claude Code は「3.14 が候補から漏れている。これはバグだ」という線で報告文をまとめにかかった。

差し戻し

ここで、ふと違和感を感じてClaude Codeにこのように投げた。

本当にこれはバグなのだろうか。もう少し検証してみたい。

そもそも Python ってもっとバージョンたくさんあるはずだし、 一部のバージョンしか指定していないのってなんか違和感がある。

前者で Claude Code の足が止まり、後者が核心を突いていた。

再調査で分かったこと

スクリプトには2つのバージョン機構があった。

判定(このバージョンは使えるか)は、パターンで書かれていた。

case "$1" in
    3.1[0-9]|3.[2-9][0-9]|[4-9].*|[1-9][0-9].*) return 0 ;;

3.1[0-9] は “3.14” にマッチする。3.20 も 4.0 も通る。将来対応済みだ。

探索(どこにあるか)だけが、ハードコードされた列挙だった。

つまり報告しようとしていた「3.14 が非対応と判定されている」は誤りで、 正しくは「3.14 が発見されない」だった。切り分けがまるで違う。

機構実装新しい Python リリースを越えられるか
互換性判定パターン
探索列挙×

判定は原理で書かれ、探索は列挙で書かれている。 列挙は Python がリリースされるたびに陳腐化する構造だ。

コメントが契約を宣言していた

さらに Pass 1 のコメントを精読して、決定的なものが出てきた。

# Pass 1 — try minor-versioned binaries in descending order. These are only
# present if the user explicitly installed them (Homebrew / python.org / pyenv),
# so picking one here always upgrades over the system `python3`. Highest
# available wins; the user doesn't have to PATH-prefer it.

出典: hooks/sg-python.sh L78-L81(2.0.6)

最後の一文——「最も新しいものが勝つ。ユーザーは PATH を優先設定しなくてよい」。

これはこのコードが自ら宣言した契約だ。 そして 3.14 が漏れていると、この契約は2通りに破れる。

再現手順を2本作った

以下はすべて security-guidance 2.0.6 に対する 2026-08-05 時点の観測である。 報告済みなので、新しいバージョンでは再現しない可能性がある。

破れ方その1: 「最も新しいものが勝つ」が守られない

スタブのインタプリタを2つ置くだけで再現する。実際の Python は要らない。

d=$(mktemp -d)
for v in 3.12 3.14; do
  printf '#!/bin/bash\nif [ "$1" = "-c" ]; then echo "%s"; else echo "SELECTED %s"; fi\n' "$v" "$v" > "$d/python$v"
  chmod +x "$d/python$v"
done
PATH="$d:$PATH" bash path/to/security-guidance/hooks/sg-python.sh /dev/null

観測: SELECTED 3.12古い方が選ばれる。

破れ方その2: PATH 優先設定が実質必要になる

python3 が古いバージョン(macOS 標準の 3.9)を指し、3.10〜3.13 が不在の環境では、 インストール済みの 3.14 が発見されない。結果、非対応の 3.9 が採用される。

その帰結が、エラーメッセージにはっきり出た。

ModuleNotFoundError: No module named 'claude_agent_sdk'   # 素の import
SyntaxError: invalid syntax (session.py, line 578)        # venv 注入後

「モジュールが無い」ではなく「構文が読めない」。 3.9 では解釈できない新しい構文が入っている、つまりバージョン不一致だという動かぬ証拠だ。

そしてログには SDK unavailable としか出ない。 ユーザーから見ると、対応するインタプリタを入れているのに機能が黙って消える。

既報を探す

報告の前に、同じ内容が既に出ていないか検索した。ここでも収穫があった。

hooks/ 配下のコメントには 17件の issue 番号が埋まっていて、活発に報告・修正されている OSS だと分かる。 そして探索リストのすぐ上に、こう書いてあった。

See anthropics/claude-plugins-official#2071.

その issue の タイトルがこれ。

LLM review silently disabled on default macOS Python 3.9 (and unreachable even after installing 3.12)

1リリース前の、同じ障害だった。 そのときの修正が、まさにこの列挙リストを追加したものだったのだ。

つまりこのバグは「直し方が再発を内包していた」ケースになる。 だから報告する修正案は「3.14 を足す」ではなく「列挙をやめて動的に探索する」にした。


何が残ったか

報告した issue が anthropics/claude-plugins-official#4907 になる。事実と解釈を分けて書いた。

  • 事実: コメントが宣言する契約の引用、マシン非依存の再現手順、対照実験
  • 解釈: 「判定は原理・探索は列挙」という非対称の指摘、再発構造の説明

万一解釈が的外れでも、再現手順は生き残る。issue として最低限成立する構造にした。

そして今、記事としてまとめてみて思うのは、残すべきだったのは失敗の記録だったということだ。

これは謙虚さの話でも、読み物としての面白さの話でもない。

この記事の結論は「判定は原理・探索は列挙という非対称がバグの根」という抽象化だ。 抽象化された主張は、読者からするとどこまで検証されたのか分からない。 「たまたま思いついた綺麗な説明」と「証拠に追い詰められて到達した説明」は、 文章としては見分けがつかない。

でも今回は、その抽象化に到達するまでに何を棄却したかの記録がある。 「リストに 3.14 が無い=バグ」で報告しかけて差し戻され、 判定式が将来対応済みだと分かって切り分けが変わった——この経緯が書いてあれば、 読者は結論が消去法で残ったものだと確認できる。

失敗の記録は、反証可能性の代わりになる。

だから削れないのは「失敗があったこと」ではなく、何を棄却したかの具体だ。 「回り道した」とだけ書くなら、書かないのと同じだと思う。


第2部: 調査と報告の勘所

第1部は「何を間違えたか」の記録だった。ここでは、そこから取り出せる再利用可能な形をまとめる。 どれも AI を使うかどうかとは無関係に、コードレビューや障害調査にそのまま効くものだ。

不在の主張は、二重の主張である

「X は無い」と言うとき、実は2つ主張している。

  1. 私が探した範囲に X は無かった
  2. 私が探した範囲は、X が在りうる範囲を覆っている

3回とも②を検証しなかった。①は丁寧にやっていた。だから丁寧さが誤りを隠した

書く前に一問だけ通せばいい。

この「無い」が間違いだとしたら、どこを見ていなかったことになるか?

これは他人のレポートを読むときの武器にもなる。「該当なし」「再現しませんでした」「ログに出ていません」——調べ方とセットでない不在報告は、情報量がほぼゼロだ。

測定は、結果より先に「判別可能性」を設計する

空文字列を判別できない道具で空文字列を測ろうとした失敗(第1部 2-b)から取れる形。

仮説が偽だったとき、この測定はどんな出力を出すか?

その出力が真のときと同じなら、それは測定ではない。書く前に止まれる。

関連して、自分のテストにも選択バイアスは入る。同じツールを2通りのテストで測ったら、検出率が 94%0% に割れた。前者はルール実装を読んでからケースを書いたもので、後者は仕様が謳う脆弱性クラスから逆算したものだ。

実装を見てからテストを書くと、実装を追認するテストになる。 仕様や外部ドキュメントから逆算したケースを最低1本混ぜると、構造的に避けられる。

検査不能を、正常と同じ状態にしない

第1部で触れた if not analysis: log("no vulnerabilities found") は、エラーハンドリングの不備というより状態モデルの設計ミスだ。

必要な状態は3つある。

① 検査した → 問題なし
② 検査した → 問題あり
③ 検査できなかった   ← これが欠けている

try/except を足しても直らない。そして畳み込む方向には非対称性がある。 ③を②側に倒せばノイズは出るが危険ではない。①側に倒すと危険だ。 畳まざるを得ないときはうるさく失敗する側へ倒す

さらに一段。silent failure の発見しにくさは、その仕組みへの依存度に比例する。 「異常なし」と言うチェックは、まさに誰も見に行かないチェックだからだ。

だから silent failure が集中するのは、成功時の出力が「何もしていない」と見分けがつかない仕組み——検査系、バックアップ、アラート、レート制限。原則は「エラーを握り潰すな」より一段強く言える。

成功が沈黙で表現される仕組みには、動作していることの信号を先に設計する。

列挙で書かれた箇所は、次のリリースで壊れる

見つけたバグの根は、同一ファイル内の抽象度の非対称だった。

機構実装新リリースを越えられるか
互換性の判定パターン
対象の探索列挙×

しかも1つ前の同種バグの修正が、まさにこの列挙を追加したものだった。 修正が再発を内包していた。

レビューで使う問いはこれ。

この修正は、問題と同じ抽象度にあるか?

「リストに1件足す」形の修正は疑ってかかる。日常にいくらでもある。 サポート対象バージョンのハードコード、拡張子の許可リスト、エラーコードの if チェーン、環境名の分岐。どれも1件追加で直せてしまうから、構造の問題として認識されないまま蓄積する

コメントは、テスト可能な仕様である

報告の強度を決めたのは、対象コードのコメントが宣言していた契約だった。 「私の期待と違う」ではなく「このコードは自分のコメントに書いた契約を満たしていない」という形にできると、反論の余地がまるで違う。

裏返すと、自分がコメントを書くときは満たせない契約を書かないこと。 「常に」「必ず」と書いた瞬間、それは検証対象になる。

成果物は、事実と解釈を分けて作る

報告した issue はこう構成した。

部分性質外れたときの影響
コメントの引用・再現手順・対照実験事実外れない
「判定は原理・探索は列挙」の分析解釈外れても報告は生き残る

解釈が間違っていても価値がゼロにならない構造にしておく。 postmortem・RFC・障害報告すべてに効く。

そしてこの構造は、委譲の不安も解く。英語の報告文を自分で完全に検証できなくても、主張の柱を「誰でも30秒で再現できる観測」に置けば、内容を完全に理解していなくても**「書いてあることと動きが違う」ことは自分の目で確認できる**。

信頼してもらうのではなく、信頼を不要にする。他人のコード、外部ベンダの報告、AI の出力——自分で検証しきれない成果物を受け取ったときに使える形だと思う。

先行事例の確認は、最も安い保険

ローカル解析だけで完結させようとして、issue 検索を後回しにしていた。 実際に検索したら、同型の既報と、同バージョンの未解決 issue が両方出てきた。

コードは「何が起きうるか」しか教えない。「実際に何が起きているか」はコミュニティが持っている。

数分の検索で、報告の質と正当性が両方上がる。重複報告も避けられる。


終章: AI と一緒に調べたとき、人間はどこで効いたか

この調査は Claude Code と一緒に進めた。幕1で調べたのは Codex CLI のプラグイン機構、 幕2・幕3で調べたのは Claude Code のプラグインだが、調べる側はどちらも Claude Code だった。 ここまで見たとおり、3回の誤りはいずれも Claude Code が出した結論で、私はそのどれも止められずに通している。

では人間側は何をしたのか。振り返ると、効いた場所が驚くほどはっきりしている。

効いた介入は4つ、すべて「進む」ではなく「止める」だった

1. 結論を差し戻した

本当にこれはバグなのだろうか。もう少し検証してみたい。

エージェントは報告文の作成に進もうとしていた。ここで止まったことで、判定式と探索リストの切り分けに到達した。止めなければ、誤った内容で報告していた。

2. 違和感を言語化した

一部のバージョンしか指定していないのってなんか違和感がある。

これが最も価値が高かった。エージェントは「リストに 3.14 が無い」という症状を見ていて、 「なぜ列挙なのか」という設計の質を見ていなかった。 この一言が、記事の結論そのものを掘り当てている。

技術的に詳しい指摘ではない。むしろ「なんか変」という素朴な違和感だ。 エージェントが局所最適に入っているとき、外から見た違和感のほうが正確なことがある。

3. 外部の情報源を思い出させた

OSS なら誰か報告してるんでねの。

エージェントはローカルのコード解析だけで完結させようとしていた。 この指摘で issue 検索に向かい、同型の既報(#2071)同バージョンの未解決 issue が見つかった。

ローカル解析は「何が起きうるか」しか出せない。 「実際に何が起きているか」はコミュニティが持っている。

4. 存在しない成果物を指摘した

下書きなくね?

エージェントは「下書きは scratchpad にある」と述べたが、 中身を一度も提示していなかった。ファイルは実在したので嘘ではないが、相手から見れば成果物は存在しないのと同じだ。

人間が効かなかった場所

公平のために書いておくと、以下はエージェント側で完結していた。

  • ログの集計、実装の読解、対照実験の設計と実行
  • 再現手順のマシン非依存化
  • 英語の issue 本文の作成
  • 事実と解釈の分離

手を動かす部分と、英語で書く部分は任せて問題なかった。

そこから言えること

介入が効いた4箇所は、性質が揃っている。

介入
本当にバグなのか結論の停止
一部しか指定していないのは違和感前提への疑い
誰か報告してるのでは探索空間の拡張
下書きなくね成果物の実在確認

どれも作業を進める介入ではなく、進行を止めて枠組みを問い直す介入だ。

そして皮肉なことに、この4つは全部、 エージェントが3回繰り返した誤り——「自分の探し方の範囲を、事実の範囲と取り違える」——に対する外部からの補正になっている。

同じ枠組みの中で丁寧に探しても、枠組み自体の誤りは見つからない。 そこを壊せるのが、今のところ人間の側にある役割なんだと思う。