TL;DR
- SBOM が「何が入っているか」の台帳なら、AI-BOM は「何がそれを作ったか」の台帳です。依存ライブラリは棚卸しできているのに、コードを書いた AI の側は誰も数えていません。
- 自リポジトリで実際に組み立てました。コミット 1,050 本のうち AI に帰属できたのは 325 本(31.0%)、PR の squash コミットに限れば 180 本中 105 本(58.3%) です(
社内データ、基準origin/main=b858a6e、2026-09-07)。 - 行単位まで落とすと、層によって被覆率が大きく割れます。
scripts/56.7% /.agents/10.1% / 記事本文 69.6%(社内データ)。平均値は意味を持ちません。 - 集計は簡単に壊れます。トレーラのキーが
Co-Authored-By:306 件とCo-authored-by:192 件に割れており、素朴な case-sensitive grep は 361 件中 239 件しか拾いません。git log --format='%(trailers:...)'を使えば大文字小文字は吸収されます。 - そして取れなかったもののほうが重要です。想定した収集源 4 層のうち 2 層がデータ 0 件でした。PR ラベルによる分類は存在せず(全 32 ラベル中
ai:系は 0 件)、エージェント定義 24 本は全部がmodel: inheritで、宣言側から実行モデルを復元できません。 - 宣言と実測の集合差が Shadow AI の候補になります。CI は Anthropic / OpenAI / Gemini の 3 鍵を宣言していますが、履歴に OpenAI は 1 件も現れず、逆に Copilot 16 件・Antigravity 1 件は宣言側に存在しません。ただし差分は「未承認」を意味しません。**Copilot のように CI の鍵を通らない経路で入る正規利用も、同じ場所に出ます。**差分は調べる対象であって、判定結果ではありません。
はじめに:SBOM の隣が空欄になっている
依存関係の棚卸しは、この 5 年でだいぶ常識になりました。npm ls を叩けば何が入っているか出ますし、SBOM を CI で生成している組織も珍しくありません。
ところが、そのコードを誰が書いたかは誰も台帳にしていません。AI コーディングエージェントが日常的にコミットを積むようになった今、リポジトリの中身は「人間が書いた行」と「モデルが書いた行」の混合物です。にもかかわらず、インシデントが起きたときに引ける台帳がない。「あのバグは、どのモデルのどの世代が書いた箇所か」に答えられないまま、AI 利用率だけを KPI にしている状態が普通にあります。
問題意識としては業界側も同じ方向を向いています。OWASP GenAI の LLM Top 10 は 2026 版で LLM04:2026 Supply Chain を立て、"LLM supply chains are susceptible to vulnerabilities that affect the integrity of training data, models, adapters, conversion pipelines, and deployment platforms" と書いています(公式値、確認日 2026-09-07。番号は版によって動き、2025 版では LLM03 でした)。ただしそこで扱われるのも主にモデル側のサプライチェーンで、開発工程の側の棚卸しは手薄なままです。
この記事は、その空欄を AI-BOM(AI Bill of Materials) として埋める試みの実測記録です。一般論の解説ではありません。このリポジトリの AI-BOM を実際に組み立てて、どこまで復元できて、何が欠けたかを数字で出します。計測はすべて 2026-09-07、基準は本ブログのリポジトリの origin/main = b858a6e です。
先に結論を言うと、半分も取れませんでした。それが有用な結果です。AI-BOM を作ろうとしたときに現実に何が起きるかは、成功例より欠損の内訳のほうが情報量があります。
セキュリティ設計そのものの 4 層モデル(権限 / 流出 / 実行時 / 設計判断)は AIコーディングのセキュリティ設計 で扱いました。本記事はその前提となる資産の棚卸しレイヤーにあたります。
1. AI-BOM は SBOM の何を引き継ぎ、何が違うのか
まず位置づけを固定します。SBOM の考え方をそのまま持ち込める部分と、持ち込めない部分があります。
| 観点 | SBOM | AI-BOM |
|---|---|---|
| 台帳の対象 | 成果物に含まれる部品(ライブラリ、コンテナ、ファーム) | 成果物を作った主体(モデル、エージェント、IDE 拡張、CI ボット) |
| 主キー | パッケージ名 + バージョン | モデル識別子 + 世代 |
| 生成タイミング | ビルド時に決定論的に生成できる | 事後にしか復元できない。書いた瞬間に記録しないと失われる |
| 粒度 | 依存グラフのノード | コミット / PR / 行 |
| 欠損の性質 | ロックファイルがあれば欠損しない | 記録していない期間は永久に空白 |
| 主な用途 | 脆弱性の影響範囲特定 | 品質インシデントの影響範囲特定、規制対応、Shadow AI 検知 |
決定的な違いは生成タイミングです。SBOM はビルドすればいつでも再生成できますが、AI-BOM は違います。トレーラを書かずにコミットした期間は、後からどうやっても埋まりません。このリポジトリでコミットの 65.6% が空白なのは、まさにそれが理由です。
なお「AI に関する部品表」という発想自体は新しくありません。CycloneDX は v1.5 で ML-BOM を導入し、component.type に machine-learning-model と data を追加しました(公式値、確認日 2026-09-07)。SPDX 3.0.1 にも AI namespace があり、AIPackage や typeOfModel、limitation といったプロパティが定義されています(公式値)。
どちらもモデル・データセット・依存関係を記述対象に含んでおり、「開発工程で使ったモデル」を書いてはいけないとは言っていません。ただし収集手段は用意されていません。SBOM ツールが package.json を読んで自動生成できるのに対し、「どのコミットをどのモデルが書いたか」を吐くツールは標準側に存在しません。本記事が組むのはその収集の部分です。語彙は借りられますが、集める仕組みは自前になります。
規制側も同じ区別をしています。EU AI Act の Annex IV は高リスク AI システムの技術文書に限った規定ですが、その 2(a) は "recourse to pre-trained systems or tools provided by third parties and how those were used, integrated or modified" の記載を求めていますが(公式値、確認日 2026-09-07)、これは AI システムの開発方法の記述であって、BOM の形式を指定してはいません。「規制が AI-BOM を義務付けている」と読むのは踏み込みすぎです。義務化を待たずに自分で作る意味は、後述する Shadow AI 検知のほうにあります。
2. 収集対象を 4 層で定義する
「AI の棚卸し」は漠然としすぎているので、収集源を層に分けます。実際にこのリポジトリで探した先は次の 4 つです。
| 層 | 収集源 | 答えられる問い |
|---|---|---|
| L1 宣言層 | エージェント定義、.claude/settings.json、CI の secrets | 何を使うことになっているか |
| L2 実行層 | CI ワークフロー、実行ログ | いつ何が動いたか |
| L3 成果物層 | commit trailer、git blame | どのコード行を何が書いたか |
| L4 PR 層 | PR ラベル、レビューボットの発言 | どの変更が AI 由来だと分類されたか |
L1 と L4 は「宣言」、L3 は「実測」です。この 2 系統がずれることこそが検知したい対象なので、両方を独立に集めて突き合わせるのが設計の要点になります。
以下、実際に集めた結果を層ごとに見ます。
3. L3 実測:commit trailer から AI-BOM を組む
いちばん情報が濃いのは commit trailer でした。Co-authored-by: に AI の識別子が入っていれば、コミット単位の帰属になります。
前提を書きます。以下は git 2.52.0 / bash / awk で動きます。第 3-1 節の blame は対象ファイル数に比例して時間がかかるので、最初は小さいディレクトリで試してください。
最初に 識別子の辞書を作ります。これは後で Shadow AI 検知の中核になるので、最初から分離しておきます。人間の共著者も列挙します(human として除外するため)。
# identities.tsv (識別子 <TAB> 分類)
Claude Opus 4.6 anthropic
Claude Opus 4.7 anthropic
Claude Opus 4.8 anthropic
Claude Opus 5 anthropic
Claude Sonnet 4.6 anthropic
Claude Fable 5 anthropic
Copilot github
Copilot Autofix powered by AI github
gemini-code-assist[bot] gemini
Antigravity antigravity
your-github-handle human
最後の行はプレースホルダです。**自分のリポジトリの人間の共著者を全員足してください。**次のコマンドで洗い出せます。
git log --format='%(trailers:key=Co-authored-by,valueonly)' | sed 's/ *<.*//' | sort -u
本リポジトリでは AI 側 10 種に対して人間 4 名を列挙する必要がありました。足りないと次のスクリプトが UNKNOWN IDENTIFIER で落ちます。それが正しい挙動です。
辞書に無い識別子が出たら落とします。黙って無視すると、その識別子がまさに検知したかった相手だったときに何も起きません。人間は human として分類し、「記録が無い」のではなく「書いたのは人間」として別扱いにします。この区別は後で効きます。
#!/usr/bin/env bash
# ai-bom-map.sh — コミット SHA と「そのコミットを書いた主体」の対応表を作る
# 出力は 3 値: <モデル名> / human / unattributed
# 終了コード: 0=正常 / 1=未知の識別子を検出(Shadow AI 候補)/ 2=設定エラー
set -euo pipefail
BASE="${1:-HEAD}"
DICT="${DICT:-identities.tsv}"
# 設定エラー(2)と検知(1)を分ける。辞書が読めないだけで全件 UNKNOWN になると、
# 真の検知と区別がつかず「誤検知が多い」と判断されて無効化される。
[ -r "$DICT" ] && [ -s "$DICT" ] || { echo "辞書を読み込めません: $DICT" >&2; exit 2; }
git log "$BASE" --format='%H%x09%(trailers:key=Co-authored-by,valueonly,unfold,separator=%x1F)' \
| LC_ALL=C awk -F'\t' -v dict="$DICT" '
BEGIN {
FS = "\t"
while ((getline line < dict) > 0) {
sub(/\r$/, "", line) # CRLF の辞書でも壊れないようにする
if (line ~ /^#/ || line == "") continue
split(line, f, "\t"); kind[f[1]] = f[2]
}
}
{
n = split($2, raw, "\037")
delete seen; models = ""; human = 0
for (i = 1; i <= n; i++) {
id = raw[i]
sub(/ *<.*/, "", id); gsub(/^[ \t]+|[ \t]+$/, "", id)
if (id == "") continue
sub(/ \(1M context\)/, "", id) # 同一モデルの別表記を寄せる
if (!(id in kind)) { print "UNKNOWN IDENTIFIER: " id > "/dev/stderr"; bad = 1; continue }
if (kind[id] == "human") { human = 1; continue }
if (id in seen) continue
seen[id] = 1
models = (models == "") ? id : models "+" id
}
if (models != "") print $1 "\t" models
else if (human) print $1 "\thuman" # 記録はある。書いたのは人間
else print $1 "\tunattributed" # 記録が無い
}
END { exit bad }
'
値の区切りに %x1F(ASCII の Unit Separator)を使っているのは、コンマや空白が識別子や表示名に含まれても壊れないようにするためです。
実行結果です(社内データ、基準 b858a6e)。
./ai-bom-map.sh b858a6e > /tmp/aibom-map.tsv
cut -f2 /tmp/aibom-map.tsv | sort | uniq -c | sort -rn
| 帰属先 | コミット数 |
|---|---|
| unattributed(記録が無い) | 689 |
| Claude Opus 4.7 | 113 |
| Claude Sonnet 4.6 | 105 |
| Claude Opus 4.6 | 56 |
| human(人間の共著者のみ) | 36 |
| Claude Opus 5 | 18 |
| Copilot | 14 |
| gemini-code-assist[bot] | 8 |
| Claude Fable 5 | 5 |
| Claude Opus 4.8 | 3 |
| Copilot Autofix powered by AI+gemini-code-assist[bot] | 1 |
| Copilot Autofix powered by AI | 1 |
| Antigravity | 1 |
全 1,050 コミット中、AI に帰属できたのは 325 本(31.0%)。残りのうち 36 本は human、つまり「記録が無い」のではなく「書いたのは人間だと記録されている」コミットです。**この 2 つを混ぜてはいけません。**本当に何も分からないのは 689 本(65.6%)です。
ただしこの分母には作業ブランチ上の細かいコミットも含まれます。マージ単位、つまり subject が (#123) で終わる squash コミットに絞ると 180 本中 105 本(58.3%) になります。運用の実感に近いのは後者です。
ベンダー単位に丸めると、Anthropic 300 / GitHub Copilot 系 16 / gemini-code-assist 9 / Antigravity 1 でした。この「4 ベンダー」という数字は第 5 節で効いてきます。
3-1. 行単位まで落とす
コミット単位では「どのファイルの、どの行を、何が書いたか」までは分かりません。git blame の出力を先ほどの対応表に join すれば、行単位まで落とせます。
#!/usr/bin/env bash
# ai-bom-lines.sh — 指定スコープの各行を、それを書いた主体へ紐づける
# 終了コード: 0=正常 / 2=設定エラー / 128=git の失敗(blame 不能なパス等)
set -euo pipefail
BASE="$1" # 対応表と同じリビジョンを必ず渡す
PREFIX="$2" # 例: scripts / .agents / content/posts
PATTERN="${3:-}" # 例: '^content/posts/[^/]+/.+\.md$'(省略可)
MAP="${MAP:-/tmp/aibom-map.tsv}"
BLAME=$(mktemp); trap 'rm -f "$BLAME"' EXIT
[ -r "$MAP" ] && [ -s "$MAP" ] || { echo "先に ai-bom-map.sh を実行してください: $MAP" >&2; exit 2; }
# ls-tree は既定の -z 出力(<mode> SP <type> SP <object> TAB <path>)を使う。
# --format='%(path)' は -z を付けてもパスを C-quote するため、非 ASCII・タブ・改行を
# 含むパスが壊れる。type が blob 以外(submodule = gitlink)は blame できない。
while IFS=$'\t' read -r -d '' meta path; do
set -- $meta
[ "$2" = blob ] || continue
[ -z "$PATTERN" ] || [[ $path =~ $PATTERN ]] || continue
git blame --line-porcelain "$BASE" -- "$path"
done < <(git ls-tree -r -z "$BASE" -- "$PREFIX") \
| LC_ALL=C awk '
substr($0, 1, 1) == "\t" { next } # ポーセリンの本文行は先頭がタブ
$1 ~ /^[0-9a-f]+$/ && (length($1) == 40 || length($1) == 64) && NF >= 3 { print $1 }
' > "$BLAME"
LC_ALL=C awk -F'\t' 'NR==FNR {m[$1] = $2; next}
{k = ($0 in m) ? m[$0] : "unknown"; c[k]++; n++}
END {for (x in c) printf "%8d %5.1f%% %s\n", c[x], c[x]*100/n, x; printf "total=%d\n", n}' \
"$MAP" "$BLAME" | sort -rn
短いスクリプトですが、外せない点が 5 つあります。どれも「静かに間違う」方向の失敗なので、省略すると気づけません。
- 列挙は
git ls-filesではなくgit ls-treeで行う。ls-filesはインデックスを見るので、BASE以降にgit addした新規ファイルが混ざります(--with-treeはインデックスとツリーの和集合であって、リビジョンに合わせる指定ではありません)。混ざるとfatal: no such path ... in <BASE>で全体が止まります。この記事を書きながら実行して、実際に踏みました。 - type が
blobのものだけ通す。submodule は tree の中でcommitオブジェクト(gitlink)として現れ、blame できません。エラーは上と一字一句同じfatal: no such path ... in <BASE>です。上の対処を済ませた読者ほど原因にたどり着けません。なおPATTERNに合わないパスはBASEに実在するので blame 自体は成功します。止まるのは gitlink だけです。 - **
BASEをgit blameにも渡す。**リビジョンを省くと blame は作業ツリーを見るので、対応表と基準がずれて全行がunknownになります。 --formatを使わず、既定の-z出力を読む。ここが一番の落とし穴でした。git ls-tree --format='%(path)'は-zを付けてもパスを C-quote します。--name-only -zはしません。結果、非 ASCII・タブ・改行を含むパスが"d/\346\227\245..."のようなリテラルになり、PATTERN無しならfatal: no such pathで停止し(またしても同じエラー文言です)、PATTERN有りなら正規表現に一致せず沈黙して過少計測します。異常パス 4 種(ASCII / 日本語 / タブ入り / 改行入り)を含む検証用リポジトリで、期待 7 行が 2 行になることを確認しました(社内データ)。-c core.quotePath=falseでも足りません。既定の-z出力(<mode> SP <type> SP <object> TAB <path>)はクォートしないので、そちらを読みます。- ポーセリンの本文行をヘッダと誤認しない。
git blame --line-porcelainの本文行は先頭がタブです。これを除外しないと、「40 桁の hex で始まる本文行」——ロックファイル、チェックサム表、SHA を引用した文書——がヘッダとして数えられ、分母が静かに膨らみます。 - SHA を桁数の範囲指定で書かない。
{40,64}のような interval 表現は awk の実装によって扱いが違います。length($1)で判定すれば SHA-1 と SHA-256 の両方を、実装差なしに拾えます。 - NUL 区切りの読み取りを awk の
RSに任せない。read -r -d ''を使います。BSD awk(awk version 20200816)はRS='\0'を扱えず、33 ファイルが 1 レコードに潰れました(社内データ)。シェルのread -d ''なら実装差がありません。
バイナリの扱いも書いておきます。content/posts をパターン無しで渡すと、以前は awk がマルチバイト変換で落ちていました(towc: multibyte conversion failure)。LC_ALL=C を付けると落ちなくなりますが、問題は解決しません。PNG のバイト列が「行」として数えられ、同じスコープの合計が 71,413 行から 715,005 行に膨れます(社内データ、本リポジトリでの実測値。他環境では画像の量で変わります)。落ちるのをやめただけで、静かに 10 倍間違えるようになります。LC_ALL=C とパターンによる除外はセットです。
終了コードも分けています。設定エラーは 2、未知の識別子の検出は 1、git 自体の失敗はそのまま 128 です。辞書を置き忘れただけで「全件が未知の識別子」になると、真の検知とバイト単位で同じ出力になり、導入初日に全件赤くなって「誤検知が多い」と判断されます。検知器を殺すのはたいてい誤検知ではなく、誤検知に見える設定ミスです。
分けた終了コードは、呼び出し側で振り分けて初めて意味を持ちます。CI ではこう扱います。
if ./ai-bom-map.sh HEAD > /tmp/aibom-map.tsv; then :
elif [ $? -eq 1 ]; then echo "未知の識別子を検出しました。辞書を更新するか調査してください" >&2; exit 1
else echo "AI-BOM の設定エラーです(検知ではありません)" >&2; exit 0 # 設定不備でビルドを止めない
fi
1 だけを fail にし、2 と 128 は通すのが要点です。設定ミスや git の一時的な失敗で赤くなると、検知そのものが信用されなくなります。
3 つのスコープで走らせた結果です(社内データ)。3 つ目だけ、自動生成インデックス content/posts/README.md を除くために正規表現を渡しています。
./ai-bom-lines.sh b858a6e scripts
./ai-bom-lines.sh b858a6e .agents
./ai-bom-lines.sh b858a6e content/posts '^content/posts/[^/]+/.+\.md$'
| スコープ | 総行数 | AI に帰属できた行 | 帰属率 |
|---|---|---|---|
scripts/ | 8,119 | 4,605 | 56.7% |
.agents/ | 32,283 | 3,257 | 10.1% |
記事本文(content/posts/<slug>/*.md) | 71,413 | 49,734 | 69.6% |
**同じリポジトリの中で 10.1% から 69.6% まで開きます。**平均を出しても意味がありません。
.agents/ が極端に低い理由は追えます。記録の無い 28,492 行のうち 20,399 行(71.6%)がたった 1 本のコミットに由来し、上位 5 本で 23,725 行(83.3%)を占めていました(社内データ)。外部から一括インポートしたディレクトリで、取り込みコミットにトレーラが付いていないためです。
本リポジトリの実測から言えるのは、この 3 スコープの被覆率の差は「AI をどれだけ使ったか」ではなく「どういう経路で入ったか」で説明できたということです。.agents/ が低いのは手書きが多いからではなく、一括取り込みが記録を持たなかったためでした。1 リポジトリの観測なので法則として一般化はできませんが、被覆率が低いスコープを見つけたら「使っていない」と結論する前に、まず取り込み経路を疑う価値はあります。
4. 集計を壊す 3 つの罠
素直に grep すると数字が狂います。実際に踏んだものを 3 つ挙げます。
4-1. 大文字小文字だけで集計が 3 分の 2 になる
このリポジトリのトレーラのキーは、Co-Authored-By: が 306 件、Co-authored-by: が 192 件に割れていました。生成したツールが違うだけで、意味は同じです。
case-sensitive な grep で数えると、こうなります(社内データ)。
| 数え方 | ヒットしたコミット数 |
|---|---|
^Co-Authored-By: のみ | 239 |
^Co-authored-by: のみ | 165 |
| 大文字小文字を無視 | 361 |
**片方の表記だけを見ると 3 分の 2 しか拾えません。**しかもエラーにならず、それらしい数字が出ます。AI 利用率のレポートが静かに間違う典型です。
対処は自分で正規表現を書かないことです。Git のトレーラ機構はこれを仕様として吸収します。git log のドキュメントは trailers:key=<key> について "Matching is done case-insensitively and trailing colon is optional." と明記しています(公式値、git-log、確認日 2026-09-07)。実際、キーを Co-authored-by と指定しても CO-AUTHORED-BY と指定しても同じ 384 行が返りました(社内データ、git 2.52.0)。
git log b858a6e --format='%(trailers:key=Co-authored-by,valueonly)' | grep -c .
# => 384
git log b858a6e --format='%(trailers:key=CO-AUTHORED-BY,valueonly)' | grep -c .
# => 384
**grep 'Co-Authored-By' ではなく %(trailers:key=...) を使ってください。**これだけで罠 1 は消えます。
4-2. squash merge がトレーラを本文へ埋め込む
ところが %(trailers:) にも落とし穴があります。Git がトレーラとして解釈するのはメッセージ末尾の連続ブロックだけです。
GitHub の squash merge は、既定のコミットメッセージをリポジトリ設定で選べます。元の各コミットのメッセージを連結する設定(コミットが複数あるときの既定挙動のひとつ)を使っていると、元コミットのトレーラが本文の途中に埋まります。本リポジトリで実際にあった例です。
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_...
* chore(content): 記事一覧READMEを最新mainへ追従して再生成
(…中略…)
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_...
---------
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
このコミットには 2 つのモデルが関与していますが、%(trailers:) が拾うのは末尾の Claude Sonnet 4.6 だけです。本文全体を正規表現でなめる方式と突き合わせたところ、1,050 本中 3 本で結果が食い違いました(社内データ)。いずれも「複数モデルの共同作業」が「単一モデル」に丸められる方向です。
件数としては小さいですが、方向が一貫しているのが問題です。丸められるのは常に「共同作業 → 単独作業」の向きで、逆は起きません。メッセージを連結する設定で squash merge をしているなら、厳密さが要る場面では %(trailers:) の結果と本文全体スキャンの結果を両方出して差分を確認してください。片方だけでは気づけません。
4-3. 識別子が正規化されていない
生の識別子は 12 種類ありました。
Antigravity / Claude Fable 5 / Claude Opus 4.6 / Claude Opus 4.6 (1M context)
Claude Opus 4.7 (1M context) / Claude Opus 4.8 (1M context) / Claude Opus 5
Claude Opus 5 (1M context) / Claude Sonnet 4.6 / Copilot
Copilot Autofix powered by AI / gemini-code-assist[bot]
Claude Opus 5 と Claude Opus 5 (1M context) は同じモデルのコンテキスト長違いです。集計軸として分けたいかは用途次第ですが、分けるつもりがないのに分かれている状態が最悪です。(1M context) を落として正規化すると 12 種 → 10 種になります。
これはモデル世代交代のたびに増え続けます。世代交代そのものをどう運用するかは Coding Agentのモデル更新と回帰テスト で扱いました。AI-BOM 側では、正規化ルールを 1 箇所に持ち、新しい識別子が現れたら落とさずアラートするのが最低限です。未知の識別子を黙って捨てると、それが Shadow AI そのものになります。
5. 取れなかったもの:4 層のうち 2 層がデータ 0 件
ここが本題です。第 2 節で 4 層を定義しましたが、実際に集めてみると半分は空でした。
| 層 | 期待した収集源 | 実際 |
|---|---|---|
| L1 宣言層 | エージェント定義の model | 実質 0。定義 24 本すべてが model: inherit |
| L2 実行層 | CI で AI を起動するジョブ | 部分的に取得。2 ワークフローが 3 種の API キーを参照 |
| L3 成果物層 | commit trailer | 取得。325 コミット / 3 スコープの行単位 |
| L4 PR 層 | ai:generated 等のラベル | 0 件。ラベルは全 32 種あるが ai: 系は存在しない |
L4 は最初から存在しなかった
着手前は「PR ラベルで AI 生成 / AI 支援 / レビュー修正を分類しているはず」という前提を持っていました。確認したら、そんなラベルは 1 つもありませんでした。
gh label list -R <owner>/<repo> --limit 100 --json name --jq 'length'
# => 32 (GitHub 既定ラベル 9 件 + type: / prio: / state: / needs: / area/ / skip-* 系など。
# ai: 系は 0 件)
これは失敗談ではなく、AI-BOM を作ろうとした人が最初に踏む場所です。「あるはず」と思っていた分類が存在しないことは珍しくありません。だから設計の順序は「まず集めてみて、空だった層を記録する」であるべきで、「4 層すべてを埋める仕組みを先に作る」ではありません。空だと分かった層は、これから記録を始めるしかないからです。
L1 は「宣言しているのに何も言っていない」
エージェント定義には model フィールドがありますが、24 本すべてが model: inherit でした(社内データ)。
grep -h '^model:' .agents/agents/*.md | sort | uniq -c
# 24 model: inherit
inherit は「呼び出し元のモデルを引き継ぐ」という意味なので、定義ファイルを読んでも実際に何が動いたかは分かりません。柔軟性のためには妥当な設計ですが、AI-BOM の観点では宣言層が情報ゼロだということです。
これが第 3 節と対になります。**宣言側からは復元できないが、コミットのトレーラからは復元できる。**AI-BOM の一次情報源は設定ファイルではなく成果物側だ、というのがこのリポジトリの実測から出た結論です。
宣言と実測の集合差= Shadow AI
L2 は取れました。CI の 2 ワークフローが参照している API キーは 3 種類です。
{ grep -rhoE '[A-Z]+_API_KEY' .github/workflows || true; } | sort -u
# => ANTHROPIC_API_KEY
# GEMINI_API_KEY
# OPENAI_API_KEY
ベンダー名を先に決め打ちせず [A-Z]+_API_KEY で拾っているのは意図的です。探すベンダーを列挙した時点で、知らないベンダーは見つからなくなります。
これを L3 の実測と突き合わせます。
| ベンダー | L1/L2 宣言 | L3 実測(コミット数) | 突合結果 |
|---|---|---|---|
| Anthropic | あり | 300 | 一致 |
| Google(gemini-code-assist) | あり | 9 | 一致 |
| OpenAI | あり | 0 | 宣言のみ — 使われていない鍵が残っている |
| GitHub Copilot 系 | なし | 16 | 実測のみ(宣言方法の死角) |
| Antigravity | なし | 1 | 実測のみ(要確認) |
実測 4 ベンダーのうち 2 つが宣言側に現れません。そして宣言側の 1 つは実体がありません。この表を定期的に作って差分行が増えていないかを見ることが運用の中身です。難しい仕組みは要りません。
ここで言葉に注意が要ります。この突合の宣言側は「CI が参照している API キー」しか見ていません。**GitHub Copilot は IDE 統合型で CI の鍵を通らないので、正規にライセンスを購入して全社に配っていても機械的に「宣言なし」に落ちます。**上の表の Copilot 行は、未承認利用を意味しません。手法の死角です。
だから差分行は「Shadow AI だと判定されたもの」ではなく、**「宣言側の見方では説明が付かないので、人間が確認すべきもの」**として扱ってください。ここを混同すると、正規利用のベンダーに毎週アラートを出し続けることになり、運用が先に死にます。
Copilot が宣言なしに現れるのは不思議ではありません。GitHub は Copilot の coding agent について "The cloud agent's commits are authored by Copilot, with the human who started the task marked as the co-author." と明記しています(公式値、GitHub Docs、確認日 2026-09-07)。CI の secrets を通らない経路で入ってくるわけです。だから宣言側だけを見ても検知できません。
方向によって意味が違うことに注意してください。
- 実測のみ:想定外の AI が入り込んでいる。権限とデータの流れを確認する対象です。
- 宣言のみ:使われていない認証情報が残っている。攻撃面としては純粋な負債なので、削除の対象です。
後者は見落とされがちですが、棚卸しの価値の半分はここにあります。予算配賦の観点は プラットフォームエンジニアリングの予算設計 にも通じます。
6. 運用に落とす:週次で差分だけ見る
ここまでのスクリプトを毎回手で流す必要はありません。運用としては差分だけを見る形にします。
ここで一度失敗しています。最初は「リポジトリ全体を grep -i してベンダー名を拾う」方式で書きましたが、.claude/ 配下のドキュメントがあらゆるベンダー名に言及しているため、宣言側の集合がノイズで膨らんで差分がほぼ空になりました。**宣言側の情報源は絞る必要があります。**ここでは CI の API キーだけを宣言とみなします。
モデル識別子とベンダーの対応を自動推測しようとしても破綻します。明示的な辞書ファイルが要ります。
辞書は第 3 節の identities.tsv をそのまま使い回します。辞書を 2 つ持たないのが要点です。分けた瞬間に片方だけ更新されて、検知が静かに壊れます。
#!/usr/bin/env bash
# ai-bom-drift.sh — 実測ベンダー集合と宣言ベンダー集合の差分を出す
# 終了コード: 0=正常 / 1=対応表が古い / 2=設定エラー
set -euo pipefail
DICT="${DICT:-identities.tsv}"
MAP="${MAP:-/tmp/aibom-map.tsv}"
[ -r "$DICT" ] && [ -s "$DICT" ] || { echo "辞書を読み込めません: $DICT" >&2; exit 2; }
[ -r "$MAP" ] && [ -s "$MAP" ] || { echo "先に ai-bom-map.sh を実行してください: $MAP" >&2; exit 2; }
observed=$(mktemp); declared=$(mktemp)
trap 'rm -f "$observed" "$declared"' EXIT
cut -f2 "$MAP" | tr '+' '\n' | { grep -vE '^(unattributed|human)$' || true; } | sort -u \
| LC_ALL=C awk -v dict="$DICT" '
BEGIN {
FS = "\t"
while ((getline line < dict) > 0) {
sub(/\r$/, "", line)
if (line ~ /^#/ || line == "") continue
split(line, f, "\t"); v[f[1]] = f[2]
}
}
{ if (!($0 in v)) { print "STALE MAP: " $0 > "/dev/stderr"; bad = 1; next } print v[$0] }
END { exit bad }
' | sort -u > "$observed"
# 宣言: CI が参照している API キー名をベンダーとみなす。
# マッチ0件(= 宣言側が空)は「実測ベンダー全部が差分」という最も重要なケースなので、
# grep の exit 1 で黙って終わらないようにする。
{ grep -rhoE '[A-Z]+_API_KEY' .github/workflows || true; } | sed 's/_API_KEY//' \
| tr 'A-Z' 'a-z' | sort -u > "$declared"
echo "== 実測のみ(宣言方法の死角。Shadow AI 候補)"; comm -23 "$observed" "$declared"
echo "== 宣言のみ(未使用の認証情報候補)"; comm -13 "$observed" "$declared"
本リポジトリでの出力です(社内データ)。
== 実測のみ(宣言方法の死角。Shadow AI 候補)
antigravity
github
== 宣言のみ(未使用の認証情報候補)
openai
第 5 節の表と一致します。未知識別子の検知は上流の ai-bom-map.sh が担っています。identities.tsv から Antigravity の行を消して ai-bom-map.sh を流し直すと、UNKNOWN IDENTIFIER: Antigravity を stderr に出して exit 1 で落ちます。この失敗こそが検知器の本体なので、握りつぶさないでください。
この差分はそのままでは週次運用に耐えません。理由は 2 つあります。
- **
githubは永久に「実測のみ」に出続けます。**Copilot は API キーを介さない経路で入るので、[A-Z]+_API_KEYを見ている限り宣言側に現れません。 - キー名からのベンダー推定が誤帰属を起こします。
[A-Z]+は_にマッチしないので、AZURE_OPENAI_API_KEYはazure_openaiではなくopenaiに潰れます(社内データ、echo 'AZURE_OPENAI_API_KEY' | grep -ohE '[A-Z]+_API_KEY'→OPENAI_API_KEY)。これは粗い丸めではなく別ベンダーへの誤帰属です。Azure OpenAI しか使っていない組織では、第 5 節の突合表で「OpenAI = 宣言のみ」という結論が逆に出ます。
一度確認したものは既知として抑止する仕組み(確認済みベンダーの許可リスト)と、キー名からの推定をやめて宣言側も辞書で持つことの両方が要ります。false positive が続くと運用が摩耗して見なくなります。見なくなった検知器は無いのと同じです。
運用の最小セットはこの 3 つです。
- 今日から trailer を必ず付ける。 過去は埋まりませんが、今日以降は埋まります。被覆率は「これから」の指標です。
- 週次で第 6 節の差分を出す。 差分行が増えたときだけ人間が見ます。増えていなければ何もしません。
- 未知の識別子でジョブを落とす。
identities.tsvに無い識別子が現れたら CI を赤にします。これが Shadow AI の実質的な検知トリガーです。
インシデント時の使い方も決めておきます。問題のあるコードが見つかったら git blame で行を特定し、対応表を引いて「どの世代が書いたか」を出します。同じ世代が書いた他の箇所を洗い出せれば、影響範囲の推定がモデル単位でできるようになります。これが AI-BOM を持つ実利です。
7. 限界と、やらないほうがいい場合
正直に書きます。この方式には無視できない限界があります。
- **トレーラは自己申告です。**署名でも検証でもありません。エージェントが付け忘れれば空欄になり、人間が手で書けば偽装もできます。改ざん検知の仕組みではないので、監査証跡として過信しないでください。
- **過去は埋まりません。**このリポジトリのコミットの 65.6% は永久に空白です。遡及して推定する誘惑がありますが、推定値を台帳に入れると台帳の信頼性が壊れます。空白は空白のまま記録するのが正しい扱いです。
- **
git blameは「最後に触った」しか分かりません。**人間が後から整形した行は人間に帰属します。逆に AI が整形しただけの行も AI に帰属します。書いたか触ったかを区別できません。 - **粒度に対してコストが高い。**行単位の集計は対象規模に比例して時間がかかります。全リポジトリを毎日回すのは割に合いません。
- **標準化された成果物ではありません。**SBOM 側は CISA が 2026-07-29 に最小要素を更新し、2021 年の NTIA 版 7 フィールドから 11 フィールドへ拡張されています(
公式値、確認日 2026-09-07)。AI-BOM に相当する合意された最小要素はまだありません。本記事の 4 層はこのリポジトリで実際に探せた範囲であって、標準ではありません。
そのうえで、やらないほうがいい場合があります。AI の利用が単一ツール・単一モデルに閉じていて、CI からも 1 経路しか叩いていないなら、AI-BOM を作っても新しい情報は出ません。得られるのは既知の事実の再確認だけです。
着手の目安として、本リポジトリで効いた判断軸を挙げておきます。組織一般の基準ではなく、自分のところで同じ問いを立てるための材料として使ってください。
- コミットに現れる AI ベンダーが 3 つ以上ある(本リポジトリは 4 つでした)
- CI が 複数のベンダーの認証情報を持っている(本リポジトリは 3 種類)
- CI を通らない経路(IDE 統合、レビューボット)で入る AI がある
このリポジトリの場合、実測で 4 ベンダーが出てきた時点で、もう頭の中の把握は追いついていませんでした。逆に、どれも当てはまらないなら急ぐ理由は薄いはずです。
もう 1 つ、**被覆率を KPI にしないでください。**被覆率は上げようと思えばトレーラを機械的に付けるだけで上がります。測るべきは「差分行の数」と「未知識別子の発生回数」です。生成量ではなく検証済みの状態を測るという原則は AIガバナンスのKPIループ と同じです。
FAQ
AI-BOM は SBOM のツールで生成できますか
語彙は借りられますが、収集は自前で組む必要があります。CycloneDX の ML-BOM や SPDX 3.0.1 の AI namespace はモデルやデータセットを記述する語彙を持っていますが、「どのコミットをどのモデルが書いたか」を自動で吐くツールは標準側にありません。本記事のように commit trailer から集める部分は自作になります。
コミットのトレーラを付けていない過去分はどう扱いますか
推定せず、空白のまま記録してください。本リポジトリでは 1,050 コミット中 689 本が「記録が無い」状態ですが、これを埋める妥当な方法はありません。被覆率は過去の成績ではなく、今日以降の記録品質を見る指標として使います。
git blame の結果は「そのモデルが書いた」と言えますか
厳密には言えません。blame が示すのは「最後にその行を変更したコミット」です。AI が書いた行を人間が整形すれば人間に、その逆なら AI に帰属します。書いた主体ではなく最終更新の主体だと理解して使ってください。
PR ラベルによる分類は導入すべきですか
導入する価値はありますが、単独では足りません。ラベルは人間または自動化の申告なので、成果物側の実測と突き合わせて初めて検知になります。本リポジトリではラベルが 0 件だったため、実測のみで組み立てました。
未知のモデル識別子が出たら何をすべきですか
CI を落としてください。黙って無視すると、その識別子がまさに検知したかった Shadow AI である場合に何も起きません。識別子の辞書を明示的なファイルに置き、辞書に無い識別子でジョブを失敗させるのが最小の検知器になります。
まとめ:今日やる 3 つ
AI-BOM は新しい標準を待つ話ではありません。トレーラと git blame だけで、今日から半分は組めます。
ai-bom-map.shを自リポジトリで流す(10 分)。 帰属率が何 % か見てください。おそらく想像より低いはずです。本リポジトリは 31.0%、マージ単位で 58.3% でした。- 宣言側と実測側の集合差を出す(20 分)。 第 6 節の差分スクリプトです。実測にしか無いベンダーが 1 つでも出たら、それが最初に見るべき対象です。
identities.tsvを作り、未知識別子で CI を落とす(30 分)。 被覆率を追うより、この 1 本のほうが検知器として機能します。
AI が書いた PR をどうレビュー可能にするかは AI生成の巨大PRをStacked PRに分解する に、セキュリティ設計の全体像は AIコーディングのセキュリティ設計 に続きます。
References
- CycloneDX — Machine Learning Bill of Materials (ML-BOM)(確認日 2026-09-07。v1.5 で ML-BOM を導入し、
component.typeにmachine-learning-modelとdataを追加) - SPDX Specification v3.0.1 — AI namespace(確認日 2026-09-07。"The AI namespace defines a set of concepts and data elements related to AI system and model artifacts.")
- CISA — 2026 Minimum Elements for a Software Bill of Materials (SBOM)(確認日 2026-09-07。2021 年 NTIA 版の 7 データフィールドを 11 へ更新)
- NTIA — The Minimum Elements For a Software Bill of Materials (SBOM)(確認日 2026-09-07。2021 年版。Supplier Name / Component Name / Version of the Component ほか 7 項目)
- OWASP GenAI — LLM04:2026 Supply Chain(確認日 2026-09-07。2025 版では LLM03、2023 年 v1.1 では LLM05 と番号が変わっている点に注意)
- EU AI Act — Annex IV: Technical Documentation(確認日 2026-09-07。2(a) がサードパーティ提供の事前学習済みシステム・ツールの利用方法の記載を要求。BOM 形式の指定はない)
- git-log —
trailers:key=<key>(確認日 2026-09-07。"Matching is done case-insensitively and trailing colon is optional.") - git-interpret-trailers(確認日 2026-09-07。トレーラは "at the end of the otherwise free-form part of a commit message" と定義される。=末尾の連続ブロックだけが対象)
- GitHub Docs — Creating a commit with multiple authors(確認日 2026-09-07。
Co-authored-by:はコミット説明の後に空行を挟んで末尾へ置く) - GitHub Docs — Application card: GitHub Copilot Agents(確認日 2026-09-07。"The cloud agent's commits are authored by Copilot, with the human who started the task marked as the co-author.")
