セキュリティチェックが「異常なし」と報告してきた。ただしそれは、ちゃんと検査した上での「異常なし」ではなく、検査そのものが動かなかったことに誰も気づかないまま出た「異常なしっぽい沈黙」だった——という話をすると、大抵の人はまず苦笑いします(次に真顔になります)。
自動でコードを書かせ、テストを回し、PRまで作らせる。ここまではもう珍しくありません(むしろ「うちもやってます」という顔で聞かれる時代です)。厄介なのはその先で、「危険な変更が紛れ込んでいないか」を確認するチェックそのものが壊れたとき、システムは安全側に倒れるのか、それとも気づかず通してしまうのか。この記事は、決定論的なセキュリティチェックを組み込んだ自動開発パイプラインの監査で実際に見つかった「チェック機構が壊れた時にfail-openになっていた」バグと、その修正が生んだ副作用、さらにその副作用を塞いだ設計の記録です。
この記事で学べること
- 「危険を検出しなかった」と「チェックが実行に失敗した」を混同すると何が起きるか
- fail-open / fail-closed はチェックの性質ごとに使い分けるべきで、一律にどちらか片方が正解ではないこと
- fail-closedに倒しただけでは終わらない、二次的な設計課題(判定ループがそのまま空回りする)
- 「セキュリティは緩めない、でも無限ループは避ける」を両立させる設計パターン
- 一度塞いだ抜け道を、別の経路からもこっそり塞ぐdefense-in-depthの考え方
決定論チェックは「blocking」のはずだった
このパイプラインには、コード差分に危険なパターン——権限昇格やデータ削除、認証情報の取り扱いなど——が含まれていないかを機械的にスキャンするステップがあります。LLMの気分で判定させるのではなく、決定論的なスクリプトで検出する設計です。位置づけも明確で、どんな運用ポリシーであってもこのチェックが引っかかれば自動マージは止まる、という強い前提(blocking)が置かれていました。
ところが実際の実行チェーンを追うと、この「絶対に止まるはず」の足場が思ったより頼りないことが分かりました(「絶対」という言葉、大体こういう場面で裏切られます)。スクリプト自体はワークフロー実行環境の制約でサブエージェント経由で走らせ、その出力をそのまま転写してもらう構成です。つまりスクリプトの実行失敗、転写過程でのちょっとした写し間違い、あるいは単なる空出力——これらが起きる余地があります。
そして本題です。このパイプラインは、危険パターンの「検出ヒットなし」と「チェックがそもそも動かなかった」を区別できていませんでした。 監査で見つかった実際の記述はこうです。
スクリプト実行失敗・LLMの転写ミス・空出力がすべて「clean」と区別不能で、security floorが無音で全通過する
自動的な解決ロジックが、ヒットが無いケースをすべて「セキュリティ項目は確認済み(clean)」に倒していたわけです。チェックが正常に走って何も見つからなかった場合と、チェックがコケて何も見つけようがなかった場合が、同じ「hitなし」という結果に押し込まれる(そして人間には見分けがつかない)。これは典型的なfail-open——防御機構が壊れた時に、安全側ではなく通す側に倒れる設計です。
表で整理する: チェックの性質ごとの失敗ポリシー
このバグの是正と合わせて、パイプライン内の決定論チェック群それぞれについて「実行に失敗した時、どちらに倒すか」を棚卸しして一覧化する作業も行われました。実際に採用されている使い分けを一般化するとこうなります。
| チェックの性質 | 失敗時のポリシー | 理由 |
|---|---|---|
| 危険パターン検出(セキュリティスキャン) | fail-closed(全項目を未確認に戻す) | 見逃しは致命的。cleanと失敗を同一視しない |
| 差分の実体把握 | fail-safe(大きい変更区分に倒す) | 差分規模が不明なら安全側の重い扱いにする |
| テスト結果の把握 | fail-safe(検査済み扱いにしない) | テストが通ったか不明なら未検査のまま |
| 情報の陳腐化チェック | fail-open(警告のみ) | 補助シグナル止まり。失敗しても他の決定論ゲートは緩めない |
| UI動作確認 | fail-open(スキップ+ログ) | advisoryな確認。失敗が本体のゲートに波及しない設計 |
| CIチェック結果の参照 | fail-open(対象項目を保留、警告ログのみ) | 判定は別の決定論ロジックが担う。失敗してもゲートの合否自体は動かない |
一列に並べると分かりやすいのですが、大事なのは「全部fail-closedにしろ」でも「全部fail-open安全側にしとけ」でもないことです(実は「とりあえず全部厳しくしとけばいい」も思考停止という意味では同じ穴に落ちます)。セキュリティのような一発アウト系はfail-closed一択で良い一方、補助的な鮮度チェックまでfail-closedにすると、ちょっとしたスクリプトの不調のたびに全部の自動化が止まってしまい、今度は別の意味で運用が崩壊します。チェックの性質に応じて個別に決める、というのがこの棚卸しの結論でした。
fail-closedに直したら、今度はループが空回りした
セキュリティスキャンの失敗時挙動をfail-closedに直すこと自体は、方針としては素直です。チェックが失敗したら、該当するセキュリティ項目を全部「未確認」のまま据え置く。ここまでは正しい防御です。
ところが、直した直後の運用でまったく別の問題が表面化しました。この「未確認のまま据え置く」判定を、実装が完了したかどうかを判定するループの収束条件にもそのまま使っていたのです。何が起きるかというと——
セキュリティスキャンがずっとfail-closed(=ずっとエラーで動かない)状態だと、そのセキュリティ項目は永遠に「未確認」のままです。実装ループの中には、この項目を「確認済み」に変えられるアクターが誰もいません。実際に危険を検出したわけではないので、評価する側も「安全です、根拠はこれです」と言いようがない。だからループの収束条件を満たすことは構造的に不可能になります。
結果、評価する側は「合格・追加の指摘なし」を毎回返し続け、実装をやり直す側は直すべき指摘がないので何も変えられないまま、ループの上限回数まで空転する。監査で見つかった実際の記述にはこうあります。
空 feedback の implementer fix# が 9 回無駄に走る
中身のない「もう一度お願いします」を9往復(律儀に9回)繰り返したようなものです。最終的にマージ判定自体は正しく「保留」に落ちるので、危険なコードが通ってしまうわけではありません。ただし、そこに至るまでの往復がまるまる無駄です。fail-closedに直したことで一つのバグは塞がったのに、別の場所で新しい非効率を生んでしまった——というのが二次被害の正体です。
収束判定とゲート判定を分離する
この二次被害への対処が、この記事でいちばん面白いところだと思っています。安易な解決策は「セキュリティチェックが失敗したら、ループの収束条件からその項目を丸ごと外してしまう」ですが、それをやるとマージの最終判定からも同じ項目が抜け落ちかねず、せっかく直したfail-openバグが別の形で復活します。
採用されたのは、「ループを先に進めるための収束判定」と「マージしていいかどうかの最終ゲート判定」を分離する設計でした。具体的には、未確認になった理由が「チェック失敗によるfail-closed」なのか「実際に危険を検出したから」なのかを見分けるフラグを新設し、それぞれの判定ロジックで使い分けます。
// チェック失敗由来と実検出由来を、語彙として明確に分ける
function resolveSecurityChecklist(ledger, scanResult) {
if (!scanResult || scanResult.ok !== true) {
// 実行失敗・転写ミス・空出力 → fail-closed
// evidence の文言も「見つからなかった」ではなく「動かなかった」と分かる形にする
return markAllUnchecked(ledger, {
failClosed: true,
evidence: `security-scan unavailable (fail-closed): ${scanResult?.error ?? 'unknown'}`,
});
}
// 通常分岐は従来通り: 実際にhitしたクラスだけ要対応にする
return resolveByHits(ledger, scanResult.hits);
}
// ループの収束判定専用: fail-closed分だけは対象から除外する
function isLoopConverged(ledger) {
return ledger.securityItems.every((it) => it.checked || it.failClosed);
}
// マージ判定は従来通り。fail-closed分も「未確認」として扱い続ける
function isMergeReady(ledger) {
return ledger.securityItems.every((it) => it.checked);
}
isLoopConverged はfail-closed項目を「ここはループの中では解消しようがない」として見逃してあげることで、他のやることが片付いていればループを正常に抜けられるようにします。一方 isMergeReady はセキュリティの下限を一切緩めず、fail-closed項目が残っている限りマージは保留のままです。監査記録のコメントにも、この使い分けの意図がはっきり書かれていました。
実際の danger 検出(risk.ok:true + hits)とは語彙を分け
同じ「未確認」という状態でも、原因が違えば扱いも違う。ここを一つのブール値にまとめず、意図的に2種類の判定関数へ分けたのがこの修正の核心です。
抜け道をもう一つ、別の経路から塞ぐ
さらにもう一段、細かい防御が入っています。このパイプラインには、実装が一通り終わったあと最終確認のタイミングで、「評価の時点では安全と確認できていたのに、その後の再チェックで未確認に転じた項目」だけを対象に、一回限りの確認を挟む仕組み(one-shot clearance)があります。
問題は、fail-closed由来の未確認項目までこの一回限りの確認対象に含めてしまうと、チェック機構が壊れているだけなのに「一回だけ人間や評価役に聞いて、通ったら以後はスルー」という運用が成立してしまいかねないことです。これではfail-closedにした意味が薄れます。そこで、この一回限りの確認対象を決めるロジックには、明示的な除外条件が入っています。
after 側で fail_closed:true の item は defense-in-depth として除外する
「fail-closedで未確認になった項目は、そもそもこの緩和ルートの対象にしない」という一文です。一つの入口を塞いだだけで満足せず、別の入口からも同じ抜け道が通れないかを確認して、通れるなら明示的に塞ぐ。これがdefense-in-depth(多重防御)という考え方の実践例です。実際、この修正には「fail-closedと実検出の evidence の語彙が混同できないこと」を確かめる専用のテストケースも追加されていて、監査記録にはこう残っています。
ツール欠落(fail-closed) と danger 実検出の evidence 語彙が区別できること
fail-open/fail-closedは「決め」の問題
ここまでの流れをまとめると、教訓は意外とシンプルです。チェックが失敗した時にどちらへ倒すかは、性能の問題でも実装の巧拙の問題でもなく、「このチェックが見逃した時の被害と、誤って止めた時のコストのどちらが大きいか」を最初に決めておくかどうかの問題です。決めていなければ、実装の都合で自然とfail-openに寄ってしまいます(「hitが空なら通す」の方が、コードとしては素直に書けてしまうので)。
これはAIエージェントに実装からテスト、PR作成まで任せるパイプライン設計に限った話ではありません。人間が書くCIパイプラインでも同じ問いは常について回ります。デプロイ前の脆弱性スキャンがタイムアウトしたら、デプロイを止めますか、それとも通しますか。答えは「チェックの性質による」であるべきで、その答えを事前に一覧表にしておかないと、壊れ方に応じて挙動がバラバラになり、しかも誰も気づかないまま数か月運用され続けたりします。
なお、この記事の論点は「チェックが壊れた/実行できなかった時にどちらへ倒すか」という設計判断です。「危険を検出したあと誰が承認するか」というマージのガバナンス設計は、また別の切り口として危険な変更だけ人間を挟む門番の設計にまとめています。似ているようで、扱っている失敗モードが違う話です。
このあたりの「チェック機構自体の設計」から実装まで丸ごと相談したい場合は、AI開発支援サービスでも、fail-open/fail-closedの棚卸しを含めた自動化パイプラインの設計支援を行っています。
まとめ
決定論的なセキュリティチェックほど、「検出ヒットなし」と「チェックが実行できなかった」を同じ結果に押し込めていないか疑う価値があります。fail-closedに倒すのは正しい第一歩ですが、それをループの収束判定やその他の緩和ロジックにそのまま流用すると、今度は無駄な空転や別経路からの抜け道という二次被害を生みます。判定ロジックを「何のための判定か」で分離し、失敗理由の語彙を意図的に区別しておく——地味ですが、これがセキュリティの下限を緩めずに運用の無駄も削る、一番効く設計判断でした。



