Claude Code 用に作ったガードが、自分のコミットメッセージに反応して自爆した — 検査する側と検査される側の境界を、中身の性質まで見て引き直した記録
はじめに
以前、AIコーディングアシスタントに「実行前にやらせない仕組み」を作った記録を書いた。
AIコーディングアシスタントに「やらせない」仕組みを作る — 助言から強制へ、そして関門に空いていた横穴
その延長で、もう一つガードを作ることになった。接続先のポート番号を記憶だけで打ち、シェルの引用符を壊す事故が同じ作業時間の中で繰り返し起きたためだ。実行しようとしているコマンドの中身を、実行に割り込んで機械的に検査するガードだった。作り込みの途中で、このガード自身が自分の検査対象に、自分自身を巻き込むという、地味だが厄介な欠陥を2回踏んだ。この記事はその欠陥と、直すために書いた敵対的テストの記録だ。
何を検査するガードか
作ったのは、コマンドを実行する直前に割り込んで、2種類の値を検査する仕組みだ。
- A. 出典の無いポート番号: コマンドの中に
host:portの形が出てきたら、そのポート番号がワークスペースのソースコードのどこかに実際に書かれているかをgit grepで確認する。1件も見つからなければ、記憶で打った疑いがあるとして実行を止める。ただし検査そのものが失敗した場合(検索不能)は、止めずに通す(fail-open)。検査できないことを違反として扱うと、道具が使えない環境で作業そのものが止まってしまうためだ。 - B. シェルに直接埋め込まれた長いコード:
python -c "..."のような書き方で、200文字を超えるコードをシェルの引用符の中に直接埋めていたら止める。引用符の入れ子や変数展開の事故は、この書き方をやめてファイルに書くだけでほぼ防げる。
きっかけになった実際の事故はこうだ。接続先のポート番号を記憶で書いたところ、全ての接続確認が拒否された。正しい値は1文字違いの別の番号で、しかも別のファイルに定数として書いてあった。開けば1秒で分かる場所にあったのに、出典に当たらず、記憶で書いていた。
ガードが、自分自身に反応した(1) — heredocの中の説明文
このガードの変更をコミットする際、コミットメッセージの本文に「python -cのような書き方に200文字を超えるコードを埋めている場合に止める」という、まさにこのガードの説明文を書いた。コミットメッセージは複数行にわたる長い文章だったため、シェルの中で「ここから先の複数行をまとめてデータとして渡す」構文(heredoc)を使って書いた。
ここでガード自身が誤検知した。heredocの中身は、シェルが実行するコードではなく、コマンドへ渡す単なるデータ(この場合はコミットメッセージの文字列)なのに、ガードはその中身までコード検査の対象に含めていた。結果、自分の変更を説明しているだけの文章が「シェルに埋め込まれた長いコード」と誤認され、実行が止まった。
検査対象の境界(縦=コマンドが検査を通る流れ・横=文字列の性質による分岐・
入れ子=直す前と直した後)
コマンド文字列
│
├─▶ heredocの外側 ── 実際にシェルが解釈するコード
│ ├─ 直す前 ── 検査する ── ✅ 正しい
│ └─ 直した後 ─ 検査する ── ✅ 変えていない ────────────┐
│ │
└─▶ heredocの内側 ── コマンドへ渡すただのデータ │
├─ 直す前 ── 検査する ◀── 誤検知① │
│ └─ たまたま "python -c" という語が │
│ コミットメッセージの説明文に含まれていただけ │
│ └─ 自分の変更を説明する文章で、 │
│ 自分の実行が止まる │
│ │ │
│ └──(戻りエッジ)──▶ 境界の引き方を │
│ 「パス」から「中身の性質」へ改める
│ │ │
└─ 直した後 ── 開始記号〜終了記号を先に除去して検査対象外 │
│ │
└───────────┬───────────────────────────┘
▼
同じ1本のコマンド文字列を、性質で2つに割ってから検査する
直し方は、heredocの開始記号から対応する終了記号までの範囲を、検査の対象から先に取り除くことだった。これは以前、別の場面で確立していた教訓——「抽出対象の語が、引用文字列やメッセージの中の意図しない位置に現れて誤って一致することがある」——と同じ形の問題だった。一度学んだはずの教訓を、別の実装でもう一度踏んでいた。
ガードが、自分自身に反応した(2) — テストコードの中の検証用の値
もう一つの誤検知は、このガードの正しさを確かめるための、敵対的なテストコードを書いている最中に起きた。
「出典の無いポート番号は止める」ことを確かめるテストでは、意図的に「ワークスペースのどこにも存在しないはずのポート番号」を使う必要がある。テストコードにその番号をリテラルで書いたところ、ガードが検索範囲をワークスペース全体(テストコード自身が置かれているファイルも含む)にしていたため、ガードの検索が、他でもないそのテストコードの中の記述をヒットとして拾ってしまった。 「このポートはどこにも存在しない」ことを確かめたいテストなのに、ガード自身の検索によって「存在する(テストコードの中に)」という判定になり、検査したかった性質そのものが壊れていた。
出典を探す範囲(縦=検索が走る流れ・横=どのファイルを出典とみなすか・
入れ子=そこに置かれている値の正体)
ポート番号を1つ拾う
│
└─▶ ワークスペース全体を git grep
│
├─▶ 本番コード ── 実際に接続先として使われる値
│ └─ ヒット ─▶ ✅ 「検証済みの値」として通してよい ──┐
│ │
├─▶ 設定ファイル ── 同じく実際に使われる値 │
│ └─ ヒット ─▶ ✅ 通してよい ───────────────────────┤
│ │
└─▶ テストコード ── 「出典が無いこと」を確かめるために │
│ わざと書いた、本物に見える偽の値 │
├─ 直す前 ── ヒット ─▶ ⛔ 誤検知② ────────────────┤
│ └─ 「どこにも存在しない」を確かめたいテストが、
│ 自分の記述を出典として自己肯定してしまう │
│ └─ そのテストは以後、永久に合格する │
│ (=検査したかった性質が消える)│
│ │ │
│ └──(戻りエッジ)──▶ 検索範囲の│
│ 定義そのものを引き直す │
│ │
└─ 直した後 ── テスト命名規則のファイルを除外 ───────┤
▼
「出典」と呼んでよいのは、実際に使われる側だけ
直し方は、テストファイル(test_*.py等の命名規則で置かれているファイル)を検索対象から明示的に除外することだった。テストコードに書かれた値は、実際に接続先として使われる値の出典にはならない。
2つの誤検知に共通していたこと
どちらも根は同じだった。「検査する側」と「検査される側」の境界を、ファイルのパスやディレクトリの区別だけで済ませていて、中身の性質(実行されるコードなのかただのデータなのか、本番の値なのか検証用に書いた偽の値なのか)まで見ていなかった。 heredocの中身は「コードに見えるがデータ」であり、テストコード中のリテラルは「本物に見えるが偽物」だった。どちらも表面上はコマンド文字列やソースコードの一部に見えるが、実際の役割が違う。
敵対的テストで両方向を確認する
直した後、正常に通すべきものを誤って止めていないか(過剰検知)と、止めるべきものを見逃していないか(検知漏れ)の両方向を確認するテストを24項目書いた。実際に配置した環境で実行した結果は次のとおりだ。
[OK ] 許可: ソースに存在するポート(<実在する番号>) rc=0
[OK ] 拒否: ソースに存在しないポート(<非在の番号>) rc=2 contains=True
[OK ] 許可: well-known ポート(443) rc=0
[OK ] 誤爆しない: 時刻表記 12:34 rc=0
[OK ] 誤爆しない: バージョン番号 1.5.8 rc=0
[OK ] 誤爆しない: Windows パス C:\Users rc=0
[OK ] 拒否: python -c に長いコードを埋めている rc=2 contains=True
[OK ] 誤爆しない: メッセージ内に 'python -c' の語が出るだけ rc=0
[OK ] 誤爆しない: heredoc 本体の説明文に 'python -c' と長文が入っている rc=0
[OK ] 誤爆しない: heredoc 本体にソース非在のポート番号が語として出てくる rc=0
[OK ] 拒否: heredoc の外側にある実行対象のインラインコードは検査される rc=2 contains=True
[OK ] 空 stdin で落ちない rc=0
[OK ] 壊れた JSON で落ちない rc=0
[OK ] command 欠落で落ちない rc=0
failures=0
(実出力を抜粋。実在するポート番号のみ <…> でマスクしている。全24項目の内訳は、許可すべきものの確認・拒否すべきものの確認・誤爆しないことの確認・heredoc境界の確認・壊れた入力への耐性確認に分かれる。)
ここで意識したのは、単に「動くケース」を並べることではなく、過去に一度踏んだ誤検知の形を、そのままテストの項目名にすることだった。「heredoc本体に、たまたまソースに存在しないポート番号が語として出てくる」というテストは、まさに②の欠陥を再現するために書いた。同じ欠陥をもう一度埋め込まないための回帰テストになっている。
この設計の限界
このガードは「検索できなかった場合は止めない」設計(fail-open)を選んでいる。これは作業自体を止めないための判断だが、裏を返すと、検索が失敗した時は警告こそ出すものの、機械的に止める効果は発揮していない。 何回に1回この経路を通っているかは集計しておらず、実態は分からない。
また、今回見つけて直せたのは、実際に作業中に踏んで気づいた2件の自己言及だけだ。同じ形の欠陥がまだ他に残っていないかは、今回のテストの範囲でしか確認できていない。検査する側と検査される側の境界は、見つけるたびに引き直すしかない類のものだった。