レビューで潰したはずの穴が、実測では違う場所にあった話

obsidian-import(外部の動画や記事をObsidianノートに変換する自作ツール)は、動画の文字起こしをYouTube限定にしていた。TikTokやInstagramの動画を渡すたびに「記事として処理されてしまう」摩擦が積み重なって、ようやく直すことにした。

調べてみると、YouTube限定は「Whisperが重いのを避けたい」という以前の判断の代理変数だった。字幕取得も概要欄取得も、実装はサイト非依存のyt-dlp呼び出し1つ。YouTube判定は入口の正規表現1箇所だけで、そこを本体の条件(長さ上限)に置き換えれば安全に拡張できる構造になっていた。

ここまでは原因を突き止めて直すだけの話だ。今回書きたいのはその先、直し方の方になる。仕様書の執筆はClaude Fable 5に任せ、実装はClaude Sonnet 5に切り替えた。ただ「2つのモデルを使い分けた」という話ではなく、なぜその境界線で切ったかが今回の芯になる。

仕様書を書く仕事と、実装する仕事は別物

以前書いた記事で、「AIが書いたコードはレビューが律速する」と書いた。今回起きたのは、それと同じことがコードの手前、仕様書のレベルでも起きるという話だ。

仕様書を書く作業には、判断が大量に詰まっている。「YouTube限定を外したら何が壊れるか」「テキストだけの投稿を動画扱いしてしまったらどうするか」「非YouTubeのプレイリストエントリのURLは信用していいのか」。どれも実装より先に決めておかないと、実装した後で手戻りになる種類の判断だ。ここは時間をかけて考えさせたかったので、Fable 5に書かせた。

仕様が固まった後の実装は、性質が違う。決まった要求を、決まったテスト方針に沿ってコードに落とす作業が中心になる。ここはSonnet 5に渡した。

判断の分量が多い工程と、量をこなす工程を分けて別のモデルに渡す。これ自体はよくある話だと思う。今回面白かったのは、その両方に同じ穴が開いていたことだった。

Fableの仕様が、最初のレビューで割れた

Fable 5が書き上げた仕様書を、すぐにはそのまま実装に回さなかった。書いた直後にSubagentへ敵対的レビューをかけた。結果、Highが3件出てきた。

1つ目は、entryURLのSSRFガード欠落。非YouTubeのプレイリストを列挙するとき、yt-dlpが返すJSON出力の中のURLをそのまま次の処理に渡す設計になっていた。これは外部データだ。YouTube限定だった頃は「検証済みの動画IDからURLを組み立て直す」形だったので構造的に安全だったが、非YouTubeに広げた瞬間、外部由来の文字列がそのまま使われる経路に変わっていた。

2つ目は、ホスト判定の緩さ。youtube.comかどうかの判定がhost in "youtube.com"のような部分一致になっていた。この書き方だとyoutube.com.evil.exampleのような偽装ホストも一致してしまい、本来は通すべきでない入力がガード省略の高速パスに乗ってしまう。

3つ目は、「動画サイトだが動画がない」ケースの機能退行。X・Instagram・TikTokのようなextractorはドメイン単位でURLを引き受ける。テキストだけの投稿や、写真だけの投稿、プロフィールページを渡しても「これは動画サイトだ」と判定されてしまう。ここで単純に失敗させると、今まで記事として処理できていたURLが軒並みエラーになる。

3つとも、仕様書を実際にコードに落とす前の段階で見つかっている。SSRFガードの欠落は、実装してからレビューで見つけても直せる。ただし仕様の骨組みごと考え直す必要が出てくる。仕様の段階で潰したぶん、実装後の手戻りは1つも要らなかった。

指摘を受けて、仕様書に3本とも反映した。entryのURLは使う前に必ず安全性チェックを通す。ホスト判定は部分一致でなく完全一致か、ドット区切りの末尾一致に直す。動画サイト判定が動画なしで失敗した場合は専用のエラーコードを返し、呼び出し側はそれを見て記事処理にフォールバックする。

実装後、もう一度レビューに通す

仕様が固まったところでSonnet 5に実装を渡した。要求は5本、テストは189件、全部グリーンで上がってきた。

ここでも、実装ができたからといってそのままpushはしなかった。差分全体にもう一度Subagentのセキュリティレビューをかけた。SSRF・コマンドインジェクション・パストラバーサル・プロンプトインジェクション・リソース枯渇・ガード迂回可能性、いずれもCritical/High/Mediumの指摘はなし。ただしLowが1件出た。yt-dlpのサブプロセス呼び出しにタイムアウトが設定されていない箇所があった。応答しないホストを掴んだときに処理がハングする経路だ。pushする前に直した。

仕様書のレビューでHigh3件、実装のレビューでLow1件。段階を分けたことで、どちらのレビューも「その段階でしか見えない穴」を拾っている。仕様の穴は実装を書く前に、実装の穴は実装を書いた後にしか見えない。

レビューが読んだ場所と、実際に壊れた場所

ここまでは、仕様も実装もレビューを通してから次に進むという、外部の記事や動画を取り込む自作ツールを、敵対的レビューで固めた話と同じ運用の踏襲だ。今回はもう一段、レビューだけでは分からないことが残っていた。

仕様レビューでHigh3件、実装レビューでLow1件を検出。テキストのみのX投稿URLでは、設計した専用exitコードの経路でなく、yt-dlp自身が先に失敗する経路で同じ結果(記事処理へのフォールバック)に着地していた

「動画サイトだが動画がない」ケースの対策として、仕様書には動画なし専用のフォールバック経路を用意していた。X投稿のURLを渡す→そのextractorは動画サイトだと判定する→でも実際に動画を列挙しようとすると失敗する→専用の合図を返す→呼び出し側がそれを見て記事処理に切り替える、という経路だ。机上ではここが「テキストだけのX投稿を正しく処理する」ための本命の仕組みになっていた。

実装が終わった後、実URLで動作を確認した。TikTok動画・Vimeo動画は仕様通りに動画判定された。記事URLは仕様通りに動画扱いされなかった。ここまでは想定通りだ。

テキストのみのX投稿(x.com/jack/status/20、最初期の投稿の一つ)を渡したときの挙動が、想定と違った。用意しておいたフォールバック経路まで辿り着く前に、yt-dlp自身が「動画が見つからない」と失敗して、そのまま記事処理にフォールバックしていた。仕様が時間をかけて設計した安全弁は、この特定のURLに関しては一度も出番がなかったことになる。問題はレビューが想定したよりも手前の段階で、すでに解決していた。

もう一つ、非YouTubeのプレイリスト(TikTokのユーザーページ)を実際に列挙してみると、返ってくるJSONの中でextractorフィールドが空でie_keyフィールドの方に"TikTok"が入っているケースがあった。これは仕様書の時点で「フィールドが欠けることがあるかもしれないので両方を見るフォールバック書式を使う」と一応書いてはあったが、本当にそうなるかどうかは実測するまで確証がなかった。動かしてみて初めて、その書き方が必須だったと分かった。

机上のレビューは「起こりうる失敗をどう扱うか」を精度良く詰められる。ただし、その失敗が実際にどの層で起きるかまでは、動かしてみないと分からない。今回、時間をかけて設計した安全弁が実は出番のない場所だったという事実は、仕様書のレビューだけでは絶対に出てこなかった。

整理

工程担当見つかった/確かめたこと
仕様執筆Fable 5YouTube限定を本体条件(長さ上限)に置き換える設計
仕様レビューSubagentHigh3件(SSRFガード欠落・ホスト判定の緩さ・機能退行)
実装Sonnet 5要求5本、テスト189件
実装レビューSubagentLow1件(サブプロセスのタイムアウト未設定)
実URL検証本人想定した安全弁より手前でyt-dlp自身が問題を解決していた

結び

外部の記事や動画を取り込む自作ツールを、敵対的レビューで固めた話で書いた「AIコードはレビューが律速する」は、コードの手前、仕様書の段階でもそのまま成り立っていた。

ただ、机上でどれだけ緻密にレビューしても、実際に動かして初めて分かることは残る。安全弁を用意した場所と、実際に問題が解決した場所がずれていた。これは仕様の不備ではなく、仕様と実装のレビューだけでは埋まらない最後の一段だった。だから実URLでの確認を、レビューの後工程として省かずに残している。