AI の PR レビューでは差分の文章も AI の入力になる。トークンと API キーを AI のコンテナに渡さず、宛先をネットワークで塞ぐ GitHub Actions と Docker Compose の構成を実行結果つきで解説する。

本記事はプロモーションを含みます。
GitHub Actions で AI に PR をレビューさせたい。手軽に作ると、AI を動かす step に GITHUB_TOKEN と LLM の API キーを環境変数で渡し、AI 自身に gh pr comment を打たせる形になる。
この形では、AI が読む差分やコードコメントに書かれた文章が、書き込みのできるトークンを持った AI への入力になる。プロンプトに「トークンを外に出すな」と書いても、それは依頼であって制限ではない。
結論から書くと、制限はプロンプトでなく実行環境に置く。
| AI に渡さないもの | 置く場所 | 手段 |
|---|---|---|
| GitHub トークン | 投稿だけをする別の job | job ごとの permissions |
| LLM の API キー | 中継するゲートウェイのコンテナ | AI にはダミーの値を渡す |
| 外への通信 | ゲートウェイだけが外に出られる | Compose の internal: true |
Compose の構成は手元の Docker 29.8.2 で動かして確かめた。ワークフローは actionlint 1.7.12 を通しただけで、実際の PR では動かしていない。LLM の API は応答を返すだけの偽物に置き換えた。
OpenCode+Docker+GitHub Actionsで全自動AIペネトレーションテストはじめました
Zenn

この記事では、このうち PR レビューの部分を、小さく再現できる形に書き下す。
.github/workflows/ai-review.yaml はこの形にする。
name: ai-review
on:
pull_request:
types: [ready_for_review]
permissions: {}
jobs:
review:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: AI にレビューを書かせる
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: |
mkdir out
AGENT_UID=$(id -u)
AGENT_GID=$(id -g)
export AGENT_UID AGENT_GID
docker compose up --exit-code-from agent
- uses: actions/upload-artifact@v7
with:
name: review
path: out/review.md
if-no-files-found: error
post:
needs: review
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/download-artifact@v8
with:
name: review
- name: コメントを投稿する
env:
GH_TOKEN: ${{ github.token }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: gh pr comment "$PR_URL" --body-file review.md
要点は 4 つだ。
permissions: {} で既定を空にし、job ごとに必要な分だけ足す。AI が動く review は contents: read だけになるpersist-credentials: false を付ける。actions/checkout は既定で認証情報を残し、後の step が認証付きの git コマンドを打てるようにする。リポジトリを AI のコンテナにマウントするので、残さないpost は checkout をしない。書き込みのできるトークンがある job で、PR のコードを動かさないためだ--body-file で渡す。run: に式で埋め込むと、AI が書いた文字列がシェルのコードとして解釈されるtypes: [ready_for_review] は Draft から Ready for review に変えたときに起動する。最初から Draft でない PR や、その後の push でも走らせるなら、opened と synchronize を足す。
job の skip は失敗でないので、CI は緑のままだ (「テストもCIも緑なのに壊れた実装を通す穴」に書いた)。レビューのコメントが来ないときは、まず review の job が走ったかを見る。
Actions の権限と job の組み立てを体系的に押さえるなら、次の本がある。
GitHub CI/CD実践ガイド (野村友規 著、技術評論社)
GitHub のドキュメントによると、フォークから pull_request で起動したワークフローには、GITHUB_TOKEN 以外の secret が渡らず、GITHUB_TOKEN も読み取り専用になる。つまりフォークの PR では LLM の API キーが空になる。
ここで pull_request_target に変えると secret は届く。ただし同じドキュメントは、このトリガーで信頼できないコードを動かすと、書き込み権限や secret に意図せず届くと警告している。AI レビューを動かすために変える理由にならない。
ワークフローの if は、フォークの PR を対象から外すためのものだ。公開リポジトリでフォークの PR もレビューしたいなら、この構成のままでは足りない。
次の compose.yaml をリポジトリの直下に置く。your-review-agent は、/work のコードを読んで /out/review.md に書くイメージに置き換える。
services:
agent:
image: your-review-agent
user: "${AGENT_UID:?}:${AGENT_GID:?}"
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
networks: [sandbox]
environment:
LLM_API_KEY: dummy
LLM_BASE_URL: http://gateway:8080/v1
volumes:
- .:/work:ro
- ./out:/out
depends_on:
gateway:
condition: service_healthy
gateway:
image: nginx:alpine
networks: [sandbox, egress]
environment:
- LLM_API_KEY
volumes:
- ./gateway:/etc/nginx/templates:ro
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/health"]
interval: 1s
retries: 20
networks:
sandbox:
internal: true
egress: {}
agent は sandbox だけに属する。internal: true のネットワークは外から切り離されるので、agent が話せるのは同じネットワークの gateway だけになる。
internal の有無で agent の中から試した結果がこれだ。
| 試したこと | internal: false | internal: true |
|---|---|---|
wget http://1.1.1.1/ | 301 Moved Permanently が返る | Network unreachable |
nslookup example.com | IP アドレスが返る | SERVFAIL |
internal: true の構成では、/proc/self/status の CapEff は 0000000000000000 で、touch /x は Read-only file system で失敗した。
名前解決が止まることも見ておきたい。外のホスト名が解決できる環境では、問い合わせる名前そのものにデータを載せて持ち出せる。
Zenn の開発チームは、ネットワークだけを持つコンテナの iptables を既定で拒否にして、宛先とポートの単位で許可している。試験対象のアプリにも届かせる必要があるからだ。PR レビューのように宛先がゲートウェイだけなら、internal: true で足りる。
gateway/default.conf.template を置く。nginx の公式イメージは /etc/nginx/templates/*.template の環境変数を展開して設定にする。
server {
listen 8080;
location = /health {
return 200;
}
location = /v1/chat/completions {
proxy_set_header Authorization "Bearer ${LLM_API_KEY}";
proxy_pass https://llm.example.com;
proxy_ssl_server_name on;
}
location / {
return 403;
}
}
llm.example.com とパスは、使う LLM の API に合わせて変える。HTTPS の先へつなぐ部分は手元では試していない。手元では http://llm:9000 の偽物に向けて、次を確かめた。
agent から試したこと | 結果 |
|---|---|
ダミーの鍵で /v1/chat/completions に POST | 偽物の API に本物の鍵が届いた |
GET /admin | 403 Forbidden |
llm:9000 へ直接 | bad address (名前が解決できない) |
env | grep -E "LLM|GITHUB|TOKEN" | LLM_API_KEY=dummy と LLM_BASE_URL だけ |
最後の行が狙いだ。AI に環境変数を出力させても、見えるのはダミーの値だけになる。
docker compose up の step に渡した LLM_API_KEY は、compose.yaml で - LLM_API_KEY と書いたサービスにだけ入る。agent には dummy を明示しているので上書きされない。docker.sock はどのコンテナにもマウントしない。マウントすると、agent が別のコンテナを起動して、ここまでの制限を外せる。
cap_drop: ALL の root は、runner のディレクトリに書けないここは手元で詰まった。agent に user: を付けずに動かすと、次のエラーになった。
wget: can't open '/out/review.md': Permission denied
コンテナの中は root だが、cap_drop: [ALL] でファイルの権限を無視する capability (CAP_DAC_OVERRIDE) も落ちている。out/ は runner のユーザーが作ったディレクトリなので、権限の上では「他人」になる。
chmod 777 で開ける案もあるが、ワークフローの AGENT_UID と AGENT_GID を user: に渡して、runner と同じユーザーで動かす方が、out/ の外に影響が出ない。${AGENT_UID:?} と書くと、変数が無いときに Compose がエラーで止まる。
この形で 3 回続けて動かし、すべて終了コード 0 で out/review.md ができた。
pull_request は PR のマージコミットのワークフローと compose.yaml で動く。リポジトリに push できる人が PR で internal: true を消すと、その PR の実行では隔離が外れる。この構成が防ぐのは実行中の AI の逸脱で、書き込み権限を持つ人の変更ではない。設定ファイルの変更は人のレビューで止めるエージェントの権限をプロンプトでなく実行環境で絞る話は、脆弱性の調査を AI に頼む場面でも同じだ。「Zero Wireで重大な脆弱性の告知を読む手順」に書いた。
contents: read だけにし、投稿は PR のコードを動かさない別の job でやるpull_request_target へ変えないinternal: true のネットワークに AI を置くと、外への通信と外の名前解決が止まるcap_drop: [ALL] にしたら、マウント先に書くユーザーを user: で合わせる