Claude Codeは実在OSSのバグを、正解を見ずに修正できるのか — Click・urllib3の診断3件を検証する
前回の記事「Claude Codeの「テストPASS」報告は何を保証するのか」では、AIが書いたコードを別のセッションがレビューすると、新しい問題が見つかることを紹介した。今回は逆向きの問いを扱う。AIは、実在するOSSの過去のバグを、上流の修正を見ずに診断し、直せるのか。
Claude Codeのクラウド環境で、Pallets Clickとurllib3の実際のバグ修正を題材にした実験を3回行った。いずれも、上流(OSS本家)が後に出した修正を、AIが先に見ていない状態で原因を調べ、自分で修正し、最後に上流の修正と比べた。結論を先に挙げると、3件とも上流と同じ根本原因に到達した。ただし「完全なブラインドで成功した」とは言えない。どこまで隠せていたのかが、この実験の最も重要な論点になった。
実験の設計と、ブラインド度の見方
手順は共通している。修正前のコードで症状を再現し、仮説を立てて測定で絞り込み、根本原因を文書にしてコミットし、修正を書く。その後で初めて上流の修正を読み、差を比べる。人間の介入は0回と報告されている(Exp38だけ、実行環境からの催促が2回あった)。使ったモデルは、この3つの実験の報告書には明記されていない。数字は特に断らない限り、各セッションの報告書にある自己申告である。
「ブラインド」と言っても、何が隠せたのかには段階がある。この記事では次の5点を区別する。
- A:上流の修正diffを見なかった。
- B:コミット件名など、diff以外から修正のヒントが漏れた。
- C:正解の情報に技術的にはアクセスできる状態だった。
- D:正解と診断環境を実際に隔離できた。
- E:独立したモデルや人間による評価だった。
結果から言うと、3件ともAは守られたが、BやCは完全には防げず、Dはほとんど成立していない。以下、順に見ていく。
事例1:Clickのusage出力(Exp38)
対象はClickの HelpFormatter.write_usage。題材の選定には、コミット件名とファイルの変更量(--stat)だけを使った。件名は「Fix HelpFormatter.write_usage producing spurious characters」で、クラスとメソッドの名前がそのまま書いてある。
症状は、引数が空のときに HelpFormatter(width=40).write_usage("prog", "") を呼ぶと、フォーマッタへ書き込まれる出力が改行だけ('\n')になり、Usage: prog の行が消えることだった。AIは仮説を6つ立て、3つを測定で棄却した。根本原因は次の2点だった。
usage_prefixの末尾に区切りの空白を埋め込み、折り返し処理(wrap_text)にprefixの出力を任せている。argsが空だとwrap_text("")は何も出力しないため、prefixごと消える。
最初の修正案(v0)は、末尾の空白をすべての分岐から取り除くものだった。しかしこれは既存のテスト(test_wrapping_long_command_name)が、次の行にargsを置くレイアウトで末尾の空白を期待していたため、失敗した。つまり、AIの仮説(末尾の空白がバグ)を反証したのは、上流ではなくリポジトリの既存テストだった。最終的な修正は、args を strip し、空なら usage_prefix.rstrip() だけを書く、+6/-1行の小さなものだった。
テストは、修正前に13件が失敗し、修正後は既存テストを含めて1621件が通った(自作テストは20件)。上流の修正を読むと、根本原因はほぼ同じで、修正も似ていた。差は、空白だけの引数の扱いにある。上流は空白だけの引数を処理せず、出力が改行だけになり、狭い幅ではハングする。AIの修正は処理する。上流の修正は、AIが書いた20件中5件に失敗した(いずれも空白だけの引数のケース)。一方でAIの修正は、上流のテストファイル全体(35件)を通っている。ただし、空白だけの引数は上流が扱っていない範囲を、AIが独自に広げたものだ。
ブラインド度は、部分的(PARTIAL)である。件名にクラス名とメソッド名が出ており、--stat は修正対象のファイルまで示していた。場所の特定はヒントをもらったに等しい。また、修正はわずか数行で、一般的な診断能力の証拠にはなりにくい。
事例2:urllib3のリダイレクト処理(Exp39)
対象はurllib3 1.25.8の PoolManager が行うリダイレクト。preload_content=False でリクエストすると、リダイレクト先ごとに新しいTCP接続が増え、block=True の接続プールでは EmptyPoolError になる。MaxRetryError で終わる経路では、接続が解放されずに漏れる。3回のリダイレクトで、TCP接続が4本開いた。
仮説は5つ立て、2つが実験で確認され、2つが棄却され、残る1つは確認された仮説に統合された。根本原因は、リダイレクトを処理するループが、リダイレクト応答の本体を読み捨てず、接続を解放していないことだった。AIは HTTPResponse.drain_conn() を追加し、PoolManager.urlopen の2か所から呼ぶ修正(+19行)を書いた。
テストは、自作16件のうち12件が修正前に失敗した(残り4件は、最終応答を読み捨てないことを守るガードで、修正前後とも通る)。既存テストは836件が通り、84件が失敗した。この84件は環境依存で、修正前後で同じ内容だった。そのため、回帰を検出する力は限られる。
上流との比較では、根本原因もAPI名も、呼び出し箇所もほぼ同じだった。差は2つある。
- 上流の
drain_conn()は本体をデコードしながら読む。AIの版はdecode_content=Falseで、デコードしない。gzipで200MBに膨らむリダイレクト本体を読み捨てる計測では、AIの版が0.66秒・22MBのメモリ、上流が2.62秒・422MBだった。悪意あるサーバーによる圧縮爆弾への耐性という面では、AIの版が有利に見える。ただし、この比較の裏付けはこの1回の計測だけである。 - 上流は、既存の接続プール側の同種の私的ヘルパーを
drain_conn()に統合して重複をなくした。AIの版は2つの実装が残る。
ブラインド度は部分的である。件名が「Drain connection after PoolManager redirect」で、方針(drain)そのものが書いてあった。AIが選んだ drain_conn という名前が上流と一致したのも、報告書自身が件名か事前知識の影響を疑っている。HTTPSとプロキシの経路は、実際には試していない。
事例3:圧縮された分割転送の巨大な応答(Exp40)
この実験では、ブラインドを成立させる試みを初めて本格的に設計した。作業を3つのセッションに分けた。
準備担当:題材と隔離の設計
準備担当のセッションは、urllib3の過去の修正から題材を選んだ。症状は「分割転送(chunked)で圧縮された応答を stream(4096) で読むと、4096バイトを要求しても約2MBの塊が返る」というもの。データ自体は正しいが、メモリが膨らむ。
診断用には、gitの履歴・変更履歴・上流のコミット情報を取り除いた修正前ソースの配布パッケージと、公開症状テスト12件を作った。正解と隠しテストは、age で暗号化して同じリポジトリに置いた。ここで準備担当自身が、設計の限界を明記している。
- 暗号化は機密性を与えるが、アクセス制御ではない。暗号文は同じリポジトリから読める。
- ライブラリが特定できるため、上流の修正やPyPIの新しいリリースには、技術的には到達できる。診断側への禁止はプロンプトに書いてあるだけだった。
- 準備担当のセッション記録が、同じアカウントから見える可能性がある。
つまり、準備担当は「強いブラインド(STRONG BLIND)は達成していない」と自分で書いた。復号用の秘密鍵は一時的な実行環境に保存されたが、人間への受け渡しは完了しなかった。評価担当は鍵を使えず、事前に固定した非公開の隠しテストは実行できなかった。鍵が失われたと断定できる記録はなく、受け渡しが完了していないという事実だけを書いておく。
診断担当:上流を見ずに根本原因へ
診断担当は、配布パッケージだけを新しい環境に展開し、症状を再現した(12件中12件が不具合)。最小の再現は b"a"*100 を1つのチャンクで送り、amt=10 で読むと、[10, 10, 80] のように、80バイトの大きな塊が最後に出る、というものだった。
根本原因は2段になっている。read_chunked がチャンクごとにデコーダへ max_length=amt を渡すが、デコーダ内部に残った出力を吐き出さない。さらに、本体の終端で _flush_decoder() が上限なしに残りを全部出す。診断担当は、デコーダの残りを吐き出す補助関数を追加し、終端の出力を amt 以下に切り分ける修正を書いた。
補足すると、max_length は「1回のデコードで取り出す最大バイト数」の上限である。圧縮データは小さな入力が大きく展開されることがあり、呼び出し側が1回に受け取りたい量(amt)と、デコーダが内部に抱えている展開待ちのデータ量は一致しない。上限を付けて展開しても、上限で打ち切られた分はデコーダの中に残る。その残りを取り出す処理を回さないまま本体の終端に達すると、終端の処理が残りを上限なしにまとめて出力し、指定したチャンクサイズを超える塊になる。今回の症状はこの形だった。
この修正の過程で、もう1つの欠陥が見つかった。複数メンバーのgzip(gzipを連結したデータ)で、GzipDecoder が終わった展開オブジェクトを、max_length で止まる前に入れ替えない。このため、次の呼び出しで出力が重複したり終わらなくなったりする。診断担当は、これも直した。
テストは、既存の1F/1064P/7S(1件失敗、1064件成功、7件スキップ)が修正前後で同じだった。公開症状テストは12件中0件から12件へ、自作テストは367件が通過(54件は設計上スキップ)した。乱数入力のファジングは、300ケース中約110件の失敗から、1800ケースで0件になった。
評価担当:上流3バージョンとの比較
別セッションが、元の2.6.1、上流の修正版2.6.2、検証対象とした2.8.0、診断担当の修正の4つを、同じ条件で比べた。2,742ケースの評価マトリクス(圧縮方式・転送方式・読み方・amt・データ形状・サイズの組合せ)の失敗数は次のとおりである。
| 対象 | 失敗 | 内訳 |
|---|---|---|
| 元の2.6.1 | 238 | サイズ超過222、ハング11、暴走5 |
| 上流2.6.2 | 16 | ハング11、暴走5 |
| 上流2.8.0 | 16 | ハング9、暴走7 |
| 診断担当の修正 | 0 |
元の2.6.1の222件のサイズ超過は、上流2.6.2で解消している。つまり、主症状については上流と診断担当の修正は同等である。差が出たのは、上流の3バージョンに共通して残る16件で、すべて「gzipを3メンバー連結して read1(1) で1バイトずつ読む」ケースだった。これは元のコードにも、非チャンク転送にもあるため、診断担当の修正が作った問題ではなく、既存の不具合である。
デコーダ単体では、メンバー境界の126条件で、上流3バージョンがそれぞれ61件(出力の誤り52、ハング5、暴走4)失敗し、診断担当の修正は0件だった。評価担当は、デコーダ単体では問題が max_length=1 に限らず、HTTPレベルに現れるのは amt=1 の経路だけだったと、範囲も整理している。
加えて、診断担当のマトリクスを乱数の種と順序を変えて2回再実行し、どちらも0件失敗だった(同じ2,742ケースの再実行なので、新しい独立ケースではない)。メモリは、gzipで圧縮した0を256MB流す検証で、元の2.6.1がRSS増分258.9MB(最大の塊268MB)、上流2.6.2が1.5MB、診断担当の修正が1.4MBだった。1GBの検証は、上流と診断担当の修正でのみ実行し、どちらも約1.5MB・約4.5秒だった(元のコードは1GBを保持するため実行していない)。
評価担当の総合判定は、元の問題に対しては技術的にA(根本原因が一致し、検証範囲では上流と同等かそれ以上)である。ただし、評価の限界も残る。
- 隠しテストが使えなかった。マトリクスは評価担当が後から書いたもので、事前登録のテストではない。
- 診断担当の修正の一部(終端の出力を切り分ける処理)が必要だったかどうかは、変異テストでも示せていない。
- 検証は1つのPythonバージョン、ループバックの通信のみ。ブロトリ(brotli)や不正データ・切り詰めデータは対象外。
ブラインド度の評価
3つの実験を、冒頭の5点で整理する。
| 観点 | Exp38(click) | Exp39(urllib3) | Exp40(urllib3) |
|---|---|---|---|
| A 修正diffを見なかった | 守られた | 守られた | 守られた |
| B 件名などからヒントが漏れた | 漏れた(クラス名・メソッド名・変更ファイル) | 漏れた(件名が方針そのもの) | 症状文が検索範囲を絞るヒントになった |
| C 正解に技術的に到達できた | できた | できた | できた(暗号文は同じリポジトリ、PyPIに新版) |
| D 正解と環境を隔離できた | していない | していない | 一部(履歴なしのパッケージ)。アクセス制御は未実現 |
| E 独立した評価だったか | 同じセッションが比較 | 同じセッションが比較 | 別セッションが比較。ただし同じモデルで、隠しテストは実行不能 |
Exp40の診断担当は、上流の修正を検索せず、配布パッケージ以外のブランチの中身も見なかったと報告している。しかし、これは自己申告であり、技術的に強制されたものではない。その意味で、3件すべて「修正diffを見ずに、自分で原因を突き止めた」ことは言えても、「完全ブラインドで成功した」とは言えない。
数字の読み方
この記事の数字は、種類の違うテストを含んでいる。公開症状テストの12件、診断担当の自作テスト367件、評価担当の2,742ケース、デコーダ単体の126条件は、母集団が違う。足し合わせても意味のある数にならない。また、再実行は新しいケースではない。
実務での使い方
ここからは、実験で直接確認したことと、私の推奨を分ける。
実験で確認できたのは次の点である。
- 修正前のコードと症状だけから、再現、仮説の棄却、根本原因の特定まで進められた(3件)。
- 既存テストが、AIの誤った仮説を反証した(Exp38)。
- 別セッションの評価が、上流にも残る不具合を示した(Exp40)。
ここから導いた推奨は次のとおり。いずれも実験で効果を検証したものではない。
- バグ報告の一次調査として、再現手順と原因仮説の下書きを作らせる使い方は有望に見える。
- 修正は、既存テストに加えて、境界条件の追加テストと別セッションの評価を通す。
- 上流と同じ修正が出たことを「正しさの証明」とは扱わない。上流に比べて余計な変更がないかも見る。
限界
- 3件とも、題材が小さいか、場所が絞られていた。数行の修正(Exp38)と約19行の修正(Exp39)で、一般的な診断能力は示せない。
- ブラインドは部分的で、モデルがOSSを事前知識として知っている影響を排除できない。
- 1件ずつの試行であり、再現性や成功率は評価していない。
- 隠しテストの結果がない。HTTPS、プロキシ、他のPythonバージョンも未検証。
- 評価担当も同じモデルである。
まとめ
実在OSSのバグを、上流の修正を見ずに診断し、上流と同じ根本原因に到達した例は確かにあった。Exp40では、上流の3バージョンに残る不具合まで見つけている。ただし、場所のヒントが漏れていたり、正解に技術的には到達できる状態だったりと、ブラインドの強さは弱い。成功の主張は、その限界の範囲で読む必要がある。
次回は、同じ実験群から、kill -9 に耐えるツールをAIに作らせ、障害注入で検証した結果を紹介する予定だ。