LLM開発はルールの修正、追加の道のりです。現状で開発初期に知りたかったことを抽出しました。今後も開発を続けていくと、どんどん変わっていくと思いますが、現状を書き留め、どなたかのためになればと思います。屍を踏んで行ってください。>┼○ バタッ
この記事の対象読者
- LLMコーディングエージェント(AIペアプログラマ)に実装を任せて、中〜大規模のアプリを作っている / 作ろうとしている
- 「設計書をしっかり書けば、AIはその通りに作ってくれるはず」と思っている(→ 作ってくれません。この記事はその理由と対策です)
- AIが書いたコードで「実装漏れ」「認可チェックの欠落」「エラーの握りつぶし」に悩まされている
筆者は数ヶ月間、100超のドメインを持つWebアプリ(Spring Boot + Nuxt)の実装をほぼ全面的にLLMエージェントに任せて開発しています。その過程で認可漏れ(IDOR)を約60件、エラー握りつぶしを66ファイル、その他無数の「静かな実装漏れ」を踏み抜きました。開発終盤で見つかったものも多く戦慄が走りました。テストダイジ( ゚Д゚)
先に結論を言います。
LLMに規約を守らせたいなら、破ったときにコンパイル / 起動 / CI が赤くなるようにする。
機械的に強制されない規約は、LLMにとって存在しないのと同じ。
この記事では、実際に起きた事故のパターン、その根因、そして新しいプロジェクトの初日に流せば同じ轍を踏まないコピペ用プロンプト(記事末尾)を紹介します。最後まで読んでね!
1. 実際に起きた事故カタログ
まず「設計書を作り込んだのに何が起きたか」を、パターン別に並べます。どれも特定のAIの欠陥ではなく、LLMという仕組みの性質に由来するものです。
事故① 静かな実装漏れ
仕様書に10項目書いても、9項目実装して「完了しました」と報告してくる。抜けた1項目はエラーにならず、存在しないだけなので、レビューでも気づきにくい。
事故② 共通の認可ガードを「使わない」
認可チェック用の共通コンポーネントを整備しても、新しいエンドポイントを追加するときに呼び忘れる。呼び忘れてもコンパイルは通るし、正常系のテストも通る。結果、他人のリソースがIDを差し替えるだけで見えるIDOR(Insecure Direct Object Reference)が量産される。全域監査をかけたら約60件出てきました。
事故③ エラーの握りつぶし
// AIが好んで書くコード
const items = await fetchItems().catch(() => []);
API取得の失敗が「0件表示」に化ける。画面は壊れないので誰も気づかない。気づいたときには同じパターンが66ファイルに転移していました。「とりあえず動く」コードを書く圧力がLLMは非常に強く、エラーハンドリングの握りつぶしはその最頻出形です。
事故④ 型の嘘
// 手書きの型断言。実際のレスポンスと違っていても TypeScript は素通し
const res = await api<{ data: Item[] }>('/items');
バックエンドの実レスポンスと食い違っていても型チェックは通り、モックを使ったテストも「その嘘の型どおりのモック」なので通る。実機で叩いて初めて壊れる。
事故⑤ モックテストの偽グリーン
DBの丸め挙動、ORMのバージョン管理の発火タイミング、タイムゾーン境界……モックは「モックを書いた人の想像どおり」に動くので、実物と食い違う部分のバグは原理的に検出できない。モックUTが全部緑のまま、実DB結合テストや実機E2Eで初めて落ちる事故が繰り返し起きました。
事故⑥ 「完了しました」の自己申告
LLMの「done」「テスト通りました」は成果物ではなく発話です。ビルドすら通っていない状態で完了報告が来ることは普通にあります。
2. 根因はたった1つ — 散文の仕様はLLMを拘束しない
6つの事故は別々の問題に見えますが、根因は1つに集約されます。
人間のチームなら「規約ドキュメント + レビュー文化」である程度回ります。人間は一度覚えた規約を忘れないし、違反にうしろめたさを感じるからです。
LLMは違います。毎回ゼロから書くので、規約の遵守は毎回の確率的な事象です。95%守るとしても、100エンドポイント作れば5件漏れます。認可チェックの5件漏れは、そのままセキュリティホール5件です。
つまり対策は「もっと詳しい設計書を書く」でも「もっと強くプロンプトで念押しする」でもなく、
規約を「文書」から「落ちるテスト」に翻訳すること
です。以降の虎の巻は、すべてこの原則の適用例です。
3. 虎の巻 — 5つの防御原則
原則① セキュリティは opt-in ではなく deny-by-default
事故②(共通ガードの呼び忘れ)の構造的な原因は、各エンドポイントが自発的にガードを呼ぶ設計(opt-in)だったことです。opt-in は「呼び忘れる自由」を残します。LLM相手にその自由を残してはいけません。
具体的な実装手段(スタックに合わせて選ぶ):
- アーキテクチャテストで「認可アノテーションのないControllerメソッドが存在したら失敗」を強制する(Javaなら ArchUnit、TypeScriptなら dependency-cruiser + カスタムルール等)
- フレームワークの設定でデフォルト拒否にする(Spring Security の
denyAll()をベースにする等) - 認可を通らないとデータが取れないRepository層の設計にする(テナントIDでの絞り込みを基底クラスで強制する等)
ポイントは「ガードを呼び忘れたコードが存在できないようにする」こと。レビューで見つけるのではなく、機械が拒否する。
原則② 握りつぶしと型の嘘は lint で初日に禁止する
事故③(catch(() => []))と事故④(手書き型断言)は、どちらも初日にlintルールを数本入れておけば1件も発生しなかった類のものです。
| 事故 | lint / 仕組みでの根治 |
|---|---|
| 空catch・エラーを飲むcatch |
no-empty + カスタムルールで機械的に禁止 |
| API型の手書き断言 | OpenAPIスキーマからの型自動生成を最初のAPIから導入し、as 断言をlintで禁止 |
any の使用 |
no-explicit-any |
| エラーの黙殺全般 | 「エラーはユーザーに見える形で表面化させる」を共通エラーハンドラとして最初に1個作り、以後はそれ以外の書き方をレビューで差し戻す |
66ファイルに転移してからESLintルールを入れると、既存違反の棚卸しという利子付きの技術的負債返済になります。0ファイルの時点で入れれば、コストはゼロです。
原則③ 仕様は散文ではなく「落ちるテスト」で渡す(テスト先行)
事故①(静かな実装漏れ)への唯一の根治策です。設計書の受け入れ条件を、実装より前に失敗するテスト(red)として書き切ってから実装を始めます。
これの何が効くかというと、抜けの表現形が変わることです。
- テスト後行: 抜け = 「静かに存在しない機能」→ 誰も気づかない
- テスト先行: 抜け = 「落ちたままのテスト」→ CIが赤いので完了できない
LLMは「緑にすべき赤いテスト」が目の前にあれば、それを確実に潰しに行きます。散文の仕様10項目より、赤いテスト10本のほうが圧倒的に強い拘束力を持ちます。
さらにレビュー時には、コードの綺麗さより先に「受け入れ条件とテストとdiffが1:1で対応しているか」を照合します。ここで対応の取れない受け入れ条件が見つかれば、それが実装漏れです。
原則④ 「緑」の定義に実物を含める(モック偽グリーン対策)
事故⑤(モックの偽グリーン)への対策は、「テストが通った」の定義を変えることです。
経験則として:
- DB・ORM・タイムゾーン・並行制御が絡むロジックは、モックUTの緑を信用しない。実DBを起動する結合テストを標準にする
- 認可は正常系だけでなく、「他人のIDでアクセスしたら拒否されるか」の契約テストをエンドポイント追加の必須セットにする
- E2Eはread-onlyのスモークでは書き込み系のバグを検出できない。作成→参照→更新→削除の一気通貫で書く
原則⑤ 自己申告を信用せず、検証をパイプラインに埋める
事故⑥(「完了しました」の嘘)への対策。LLMの完了報告は必ず機械の出力で裏取りします。
- 「テスト通りました」→ CIの実行結果そのものを見る(LLMの要約ではなく)
- 「実装しました」→ diffと受け入れ条件を照合する(別セッション・別エージェントにレビューさせると、実装した本人の思い込みが混入しない)
- ビルド・テスト・lintをpre-commitフックとCIの両方に置き、「赤いまま先に進む」選択肢自体を消す
4. まとめ図 — 防御は「コードより先」に築く
5原則を1枚にまとめるとこうなります。重要なのは、これら全部を最初の機能コードを書く前に導入することです。
筆者のプロジェクトでは、これらの防御装置はすべて事故が起きてから導入されました。そして導入後は、同種の事故は機械的に止まっています(番人テストが違反コードをCIで拒否するので、そもそもマージできない)。
つまり教訓は「何を作るべきか」ではなく順序です。
防御装置はコードより先に書く。
LLMとの開発は「信頼して任せる」ではなく「疑って検証装置で囲う」。
設計書の充実は、検証装置の代わりにはならない。
5. コピペ用プロンプト — 新規プロジェクトの初日に流す「虎の巻」
以下をそのまま(またはスタック名を書き換えて)新規プロジェクト立ち上げ時のLLMエージェントに渡してください。CLAUDE.md / .cursorrules 等のプロジェクト規約ファイルに転記して常駐させるのがおすすめです。
# LLM開発ガードレール構築指示(プロジェクト初日に実行すること)
あなたはこれからこのプロジェクトの実装を担当する。ただし**機能コードを1行書く前に**、
以下のガードレールをこの順序で構築せよ。構築完了までフィーチャー開発は禁止する。
## 前提となる原則(このプロジェクトの憲法)
1. **機械的に強制されない規約は存在しないものとみなす。**
守らせたいルールは、違反時にコンパイル・起動・CI・lintのいずれかが
赤くなる形で表現すること。ドキュメントに書くだけの規約を新設しない。
2. **セキュリティは deny-by-default。** 認可チェックを「各実装者が呼ぶ」設計は禁止。
「書き忘れたコードがCIを通過できない」設計にする。
3. **エラーの握りつぶし禁止。** 例外を飲む catch、失敗を空配列や null で
偽装するフォールバック、エラーを隠すフラグ分岐はすべて禁止。
壊れているものは壊れていると表面化させる。
4. **仕様は散文ではなくテストで表現する。** 受け入れ条件は実装前に
「失敗するテスト」として書き切る(テスト先行)。
5. **モックの緑を信用しない。** DB・外部API・時刻・並行制御が絡む検証は
実物(コンテナ起動の実DB等)で行う。「完了」の定義に実機確認を含める。
6. **自己申告禁止。** 「テストが通った」はCIの実行結果で、「実装した」は
diffと受け入れ条件の照合で証明する。
## Day 0 で構築するもの(この順で)
### 1. CI パイプライン(空のプロジェクトの時点で敷く)
- ビルド / lint / テストを実行し、1つでも赤ならマージ不可にする
- 同じチェックを pre-commit フックにも設定する
### 2. 静的解析・lint ルール
言語に応じて、最低限以下を機械的に禁止する:
- 空の catch / エラーを握りつぶす catch
- `any` 等の型システム迂回
- APIレスポンスへの手書き型断言(後述の自動生成型を使う)
- 未使用変数・到達不能コード
### 3. deny-by-default の認可基盤
- 認可の共通ガード(認証必須・リソース所有者チェック・テナント分離)を先に作る
- **アーキテクチャテスト**(Java: ArchUnit / 他言語: 相当ツールか自作テスト)で
「認可指定のない公開エンドポイントが存在したらテスト失敗」を強制する
- データアクセス層は、テナント/所有者IDでの絞り込みを基底クラスや
共通クエリビルダで強制し、絞り込み忘れが書けない構造にする
### 4. API型の自動生成パイプライン
- バックエンドのスキーマ(OpenAPI等)からフロントエンドの型を自動生成する
仕組みを最初のAPIから導入する
- 生成物の手動編集を禁止し、スキーマ変更時の再生成をCIで検証する
### 5. テスト基盤
- 実DBを起動する結合テスト基盤(Testcontainers等)をセットアップする
- 認可契約テストの雛形を作る:
「本人は200 / 他人のIDでは403か404 / 未認証は401」の3点セット
- E2Eテスト基盤(モックなし・実サーバー)をセットアップする
## 毎機能の開発フロー(Day 1 以降、必ずこの順)
1. **受け入れ条件の列挙** — 実装内容を箇条書きの受け入れ条件に落とし、承認を得る
2. **テスト先行(red)** — 受け入れ条件を失敗するテストとして書き切る。
認可が絡むなら認可契約テストを必ず含める
3. **実装(green)** — テストを緑にする。テストの改変による緑化は禁止
4. **照合レビュー** — 受け入れ条件↔テスト↔実装diffの1:1対応を照合し、
対応の取れない条件(=実装漏れ)がないことを確認する
5. **実機確認** — モックなしのE2Eで、作成→参照→更新→削除を一気通貫で通す
## 完了の定義
以下がすべて満たされたときのみ「完了」と報告してよい:
- [ ] CIが全て緑(実行結果のリンクまたはログを提示)
- [ ] 受け入れ条件とテストの対応表を提示できる
- [ ] 認可契約テスト(本人200/他人403or404/未認証401)が存在する
- [ ] エラー握りつぶし・型断言・anyがdiffに含まれない(lintが保証)
- [ ] 実機E2Eの通過を確認した
これを満たさない状態での「完了しました」「実装できました」という報告を禁止する。
6. おわりに
LLMコーディングエージェントは、赤いテストを緑にするのは驚くほど得意です。一方で、散文の規約を100%守り続けることは構造的にできません。
なので人間側の仕事は「詳しい設計書を書くこと」から、
- 規約を機械的な検証装置(テスト・lint・番人)に翻訳すること
- その検証装置を、機能コードより先に設置すること
に変わります。この2つさえ守れば、LLMの生産性を享受しながら、実装漏れも認可漏れも握りつぶしも「CIが赤くなるので存在できない」世界に持ち込めます。
同じ轍を踏む人が一人でも減れば幸いです。