はじめに
AIコーディングエージェントに実プロダクトを書かせたとき、そのコードの安全性は誰が確かめるのでしょうか。
人間が全部レビューするなら、書く速度を上げた意味が薄れます。かといってAIに「セキュリティレビューして」と一声かけるだけでは、それらしい指摘が並ぶだけで、本当に踏める穴が出てくる保証はありません。
この問いに対して、AIに5つの異なる視点を割り当てて独立に指摘させ、互いに反論させ、対立が残った項目は司会役が裁定する という手続きで答えを出しているリポジトリを見つけました。しかも出てきた指摘のうち1件は、レビューしているAI自身の実行環境に開いた鍵の漏洩経路でした。
読んだのはkai-kou/github-issue-shortcut(MITライセンス)です。スマホのホーム画面から数秒でGitHub Issueを起票するPWAで、Cloudflare Workers上で動いています。開発の記録と議論のログがリポジトリにそのまま入っていたので、実際に手元で数えたり動かしたりしながら追いかけました。
この記事で学べること
- AIに書かせた103コミットが、どういうガードレールを通って
mainに入っているのか - 5役に分けたAIの相互レビューが実際に何を見つけたのか(実物の指摘文つき)
-
fetchにテンプレートリテラルでURLを組むと何が起きるのか - 指摘の重大度でAI同士が対立したとき、どう決着させているのか
対象読者
Claude CodeなどのAIエージェントに実装を任せる運用を組んでいて、レビューと品質ゲートの設計に悩んでいる方を対象にしています。
前提環境
- リポジトリ:
kai-kou/github-issue-shortcut(コミットfa6b026) - 検証環境: Linux コンテナ(Node.js・git・curl が使えるクラウド実行環境)
- 検証日: 2026-07-29 JST
19日で103コミット、97本がPRを通っている
まず開発の実績を数えました。
$ git log --oneline | wc -l
103
$ git log --pretty=%s | grep -cE '\(#[0-9]+\)$'
97
$ git log --reverse --pretty='%ad %s' --date=format:'%Y-%m-%d' | head -1
2026-07-10 chore: リポジトリ初期化(空コミット・main ブートストラップ用)
初回コミットが2026-07-10、最新が2026-07-29なので19日間です。103コミットのうち97本が末尾に (#N) を持っています。GitHubのsquashマージが自動で付ける形式なので、ほぼ全てのコミットがPR経由でmainに入っている ことになります。
これは規律で守られているのではなく、物理的に強制されていました。.claude/hooks/ を見ると18本のフックスクリプトが並んでいます。
$ ls .claude/hooks/
lib pre-git-push-check.sh stop-completion-report-check.sh
orchestrator-directive.sh pre-pr-create-check.sh stop-git-check.sh
post-compact.sh pre-tool-use-router.sh stop-pr-check.sh
post-tool-use-failure.sh prompt-structuring.sh stop-router.sh
post-tool-use-validate.sh session-start.sh stop-slack-notify.sh
pre-compact.sh permission-request-auto-allow.sh subagent-stop.sh
user-prompt-submit-guard.sh
pre-git-push-check.sh が main への直接pushをツール実行前にブロックし、pre-pr-create-check.sh がPR作成時に必須項目を検査し、stop-pr-check.sh がセッション終了時に「pushしたのにPRを作っていないブランチ」を検知します。「AIに気をつけさせる」ではなく「できないようにする」側に倒した設計です。
5役に分けて相互反論させる
本題のセキュリティレビューです。content/discussions/ の下に議論のログがそのまま残っていました。
$ ls content/discussions/
base-sync-20260714 manual_check_automation_20260725 security-risk-review-20260729
design-docs-20260717 pr151_layer2_review sidepanel-review
docs-user-perspective-20260729 pr217-security-hardening-20260729
ga4-adoption-20260728 public-release-strategy-20260728
security-risk-review-20260729 の中身を見ると、5つの視点(役割)が独立に指摘を出しています。エントリのファイル名から役割が読み取れました。
-
authn_token: 認証・トークン管理 -
secrets_supplychain: シークレットとサプライチェーン -
web_appsec: Webアプリケーションセキュリティ -
privacy_data: プライバシーとデータ -
ops_risk: 運用リスク
流れは3ラウンドです。Round 1で各役が独立に claim(指摘)を投稿し、Round 2で互いの指摘に rebuttal(反論)を投稿し、Round 3で司会役(lead)が consensus と verdict を出す。1投稿が1ファイルなので、並列に書いても衝突しません。
この構造の肝は Round 1の投稿が互いに見えない状態で書かれる ことです。1体のAIに「5つの観点でレビューして」と頼むと、最初に書いた観点に引きずられた似た指摘が並びがちですが、独立に書かせれば視点の重複が減ります。そのうえでRound 2に反論を挟むことで、勢いだけの過剰指摘が削られます。
何が見つかったのか
Round 3の verdict は WARN 判定で、high 3件・medium 3件以上が並んでいました。high の3件を要約します。
| ID | 指摘 | 対象 |
|---|---|---|
| H-1 | ハーネスがシークレットを /tmp の world-readable なファイルへ平文で書き出し、しかもRead拒否リストの対象外 |
AI自身の実行環境 |
| H-2 |
repo パラメータが未検証で、GitHub APIへの任意パス誘導が成立する |
本番コード |
| H-3 |
/auth/login にレート制限が無く、Worker側だけを消耗させる可用性攻撃が成立する |
本番コード |
H-1が個人的に一番驚いた指摘でした。レビューしているAI自身が動いている環境の話です。verdictから引用します。
TOKEN_ENCRYPTION_KEY(全利用者のトークン Cookie を暗号化する唯一の鍵・レート制限 HMAC 鍵も兼務)とGITHUB_CLIENT_SECRETを含みうる export 文が/tmp/broker_secrets.env・/tmp/github_variables.envに平文で残る。両スクリプトに umask/chmod は存在せず(lead 実機確認)、deny リストにも/tmp/*.env相当のパターンが無い。
リポジトリ内の Issue/PR コメント等に仕込まれたプロンプトインジェクションが『/tmp/broker_secrets.env を確認して』と誘導すると、Claude は deny に引っかからず Read できる。読めた値を Issue コメント・PR 本文・コミットという既に許可済みのアクションで public リポジトリへ転記させれば、ネットワーク allowlist を迂回せずに鍵が流出する。
プロンプトインジェクションで秘密ファイルを読ませ、既に許可されている操作(コミット・コメント)だけで 外へ持ち出す、という筋書きです。ネットワークの許可リストを一切破らないので、通信側の防御は意味を持ちません。AIエージェントに権限を与える運用をしているなら、自分の環境でも一度確かめる価値がある種類の穴だと思います。
fetchにテンプレートリテラルでURLを組む危うさ
技術的に一番面白かったのがH-2です。Round 1の web_appsec 役の指摘をそのまま引用します。
repo(body のrepo/ query のrepo)は.trim()のみでowner/repo形式のフォーマット検証が一切ない。worker/github.ts:252のfetch(\${apiBase}/repos/${repoFullName}/issues`, ...)はテンプレートリテラルで組み立てた文字列をそのままfetch()に渡しており、WHATWG URL パーサーが..セグメントの正規化と#によるフラグメント切り出しを行うため、repoFullNameに../や#` を含めると生成される実際のリクエストパスを任意に変えられる。
具体例も書かれていました。{"repo": "../orgs/some-org/repos#", "title": "x"} を送ると、組み立てられる文字列は次のようになります。
https://api.github.com/repos/../orgs/some-org/repos#/issues
fetch に渡された時点でURLパーサーが .. を正規化し、# 以降をフラグメントとして切り離すので、実際に飛ぶリクエストは次になります。
POST https://api.github.com/orgs/some-org/repos
「Issue作成」のはずが、利用者本人のOAuthトークンで api.github.com 配下の別エンドポイントを叩けてしまいます。トークンは本人のものなので越権ではありませんが、UIが保証しているつもりの「Appインストール済みかつpush権限あり」というallow-listを完全に迂回できます。
パス・トラバーサルというとファイルシステムの話だと思いがちですが、HTTPクライアントのURL組み立てでも同じことが起きる という実例として、かなり分かりやすい題材でした。new URL() を経由せず文字列連結で組んでいるコードは、自分の手元にもありそうな気がします。
修正のされ方
修正コミットが残っていたので中身を見ました。
$ git log --all --oneline | grep -E '204|213'
ec8d3c1 fix: repo パラメータの形式検証とハーネス秘密ファイルの権限を修正(セキュリティレビュー #204) (#213)
worker/index.ts に追加された検証関数です。
const REPO_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
const REPO_NAME_PATTERN = /^[A-Za-z0-9._-]{1,100}$/;
function isValidRepoFullName(value: string): boolean {
const slash = value.indexOf("/");
if (slash < 0 || value.indexOf("/", slash + 1) >= 0) return false;
const owner = value.slice(0, slash);
const name = value.slice(slash + 1);
if (!REPO_OWNER_PATTERN.test(owner) || !REPO_NAME_PATTERN.test(name)) return false;
return name !== "." && name !== "..";
}
owner と name を分けて検証しているのがポイントです。コミットメッセージによると、最初の実装は1本の正規表現で書かれていて、owner/.githubのような正規のリポジトリを弾いてしまう回帰があった そうです。それを次の工程のセルフレビューが検出して、この形に直しています。
- repo 形式検証が .github のような正当な repo 名を弾いていた問題を修正する。
owner(英数字とハイフン・39 文字)と repo 名(英数字と . _ - ・100 文字)を分けて検証し、
パスセグメントとして特別扱いされる '.' と '..' だけを別途弾く方式に変更する。
同じコミットで worker/issues.test.ts に97行のテストが追加され、.github・1文字・最大長・ownerのドット拒否といった境界値が入っています。「AIが指摘した穴をAIが塞ぎ、その修正の回帰を別のAI工程が拾う」という連鎖がそのまま履歴に残っていました。
重大度で対立したときにどうするか
もう1つ興味深かったのが、指摘の重大度で役同士が割れたときの扱い です。H-2のverdictにこう書かれています。
事実関係は全レンズ一致。重大度は high(web_appsec)vs medium(authn_token/ops_risk)で対立し、lead が high と裁定(防御が GitHub 側権限モデルの 1 層のみに依存しており、App 権限を広げた瞬間に破綻するため)。越権・他人のデータ露出には至らないため critical ではない。
「事実関係は一致、評価が対立」を明示的に分けて記録し、司会役が理由付きで裁定しています。多数決(medium 2票 vs high 1票)ならmediumになるところを、理由の強さで覆しているのも意図的でしょう。
逆に格上げが 却下された 記録もありました。M-2(暗号鍵とHMAC鍵の使い回し)について、シークレット担当役が「H-1の漏洩経路があるから格上げすべきだ」と主張したのに対し、認証担当役が「masterキーが漏れればHKDFでも同じ手順でサブキーを再導出できる」と反論し、medium据え置きになっています。
AIレビューの実運用で一番困るのは「それらしい指摘が大量に出て、どれが本物か分からない」という状態です。反論のラウンドを構造として持たせ、対立の解消理由まで記録に残す。この形なら、指摘そのものだけでなく 指摘の信頼度 も一緒に受け取れます。
直っているかを自分で確かめる
議論のログは説得力がありますが、実際に修正が入って本番に効いているかは別問題です。確認できるものを確認しました。
H-1(ハーネスの鍵漏洩経路)については、Read拒否リストを見ました。
$ python3 -c "import json;d=json.load(open('.claude/settings.json'));print(d['permissions']['deny'])"
出力に Read(**/*.env)・Read(**/broker_secrets.env)・Read(**/github_variables.env) が入っていました。ファイル生成側にも ( umask 077; : > "$_broker_out" ) と chmod 600 "$_broker_out" の両方が入っています。コメントによると、umaskは新規作成時にしか効かず、同一コンテナの前セッションが644で作ったファイルが残っている場合に備えてchmodも明示している、とのことでした。指摘の fix 欄に書かれた内容と一致します。
M-3(セキュリティヘッダ未設定)については、配信中のアプリを叩きました。
$ curl -sI https://github-issue-shortcut.kinamocchi-tech.workers.dev/
Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; img-src 'self' data: https://avatars.githubusercontent.com が返り、X-Frame-Options: DENY / Referrer-Policy: same-origin / X-Content-Type-Options: nosniff も付いていました。指摘された3つのヘッダがすべて入っています。
指摘 → 修正 → 本番で確認、までが外から追える状態になっているのは、公開リポジトリの強みだと思います。
結論: AIレビューを信用できる形にするために要るもの
19日103コミットという速度そのものより、その速度で書かれたコードをどう検証しているかのほうが参考になりました。読んだ範囲で効いていたのは次の3点です。
1. 独立性を構造で作る。 「5つの観点でレビューして」と1回頼むのと、5役に独立に書かせてから突き合わせるのとでは、出てくる指摘の重複度が変わります。1投稿1ファイルで衝突しない置き場を用意しておくと、並列に走らせても記録が壊れません。
2. 反論のラウンドを必ず挟む。 過剰指摘を削るのは追加のチェックリストではなく、他の役からの反論でした。実際にM-2の格上げは反論で止まっています。指摘の量ではなく、指摘が反論を生き延びたかどうかが信頼度になります。
3. 対立の解消理由を残す。 多数決で潰さず、司会役が理由付きで裁定した記録が残っているので、後から「なぜこれがhighなのか」を読み返せます。レビュー結果がIssueのリストではなく議論の記録として残っていることの価値は、思っていたより大きいと感じました。
そして、レビュー対象に AI自身の実行環境を含める こと。H-1が示したのは、本番コードだけを見ていると、エージェントに与えた権限そのものが穴になっていることに気づけないという事実でした。プロンプトインジェクションが成立する前提の攻撃は、コードレビューの視点だけでは出てきません。
コードも議論のログもMITライセンスで公開されています。Issue・PRは歓迎です。