AIのPRレビューにGitHubトークンを渡さない構成

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

AIのPRレビューにGitHubトークンを渡さない構成

本記事はプロモーションを含みます。

はじめに: AI に PR を読ませると、PR の中身も AI への入力になる

GitHub トークンは投稿の job、LLM の API キーはゲートウェイに置き、外への通信はネットワークで塞ぐ。AI のコンテナができるのはファイルに書くことだけ

GitHub Actions で AI に PR をレビューさせたい。手軽に作ると、AI を動かす step に GITHUB_TOKEN と LLM の API キーを環境変数で渡し、AI 自身に gh pr comment を打たせる形になる。

この形では、AI が読む差分やコードコメントに書かれた文章が、書き込みのできるトークンを持った AI への入力になる。プロンプトに「トークンを外に出すな」と書いても、それは依頼であって制限ではない。

結論から書くと、制限はプロンプトでなく実行環境に置く。

AI に渡さないもの置く場所手段
GitHub トークン投稿だけをする別の jobjob ごとの permissions
LLM の API キー中継するゲートウェイのコンテナAI にはダミーの値を渡す
外への通信ゲートウェイだけが外に出られるCompose の internal: true

Compose の構成は手元の Docker 29.8.2 で動かして確かめた。ワークフローは actionlint 1.7.12 を通しただけで、実際の PR では動かしていない。LLM の API は応答を返すだけの偽物に置き換えた。

何の話か: Zenn 開発チームの AI セキュリティ検査の構成

Zenn の開発チームは PR ごとの AI レビューと毎週の検査を同じ Docker の隔離の中で動かし、AI には書かせるだけで外への操作は決まった処理で行う

OpenCode+Docker+GitHub Actionsで全自動AIペネトレーションテストはじめました

Zenn

  • Zenn の開発チームは、PR が Ready for review になると AI エージェントを起動してレビューし、結果を PR のコメントにしている
  • 実際にリクエストを送らないと分からない認可の不備などは、毎週の AI による検査で確かめている
  • AI はレビュー結果をファイルに書くだけで、コメントの投稿は Actions の step が行う。AI のコンテナに GitHub のトークンは渡さない
  • AI のコンテナの宛先は Docker のネットワークで絞り、LLM の API キーはゲートウェイのコンテナだけが持つ

この記事では、このうち PR レビューの部分を、小さく再現できる形に書き下す。

1. 読む job と投稿する job を分ける

review の job は contents: read だけで AI を動かし、結果を artifact で渡す。post の job は pull-requests: write を持つが 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 のコードを動かさないためだ
  • AI の出力は --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 CI/CD実践ガイド (野村友規 著、技術評論社)

フォークからの PR では走らせない

フォークの PR には secret が渡らない。pull_request_target に変えず、if でフォークの PR ではレビューを走らせない

GitHub のドキュメントによると、フォークから pull_request で起動したワークフローには、GITHUB_TOKEN 以外の secret が渡らず、GITHUB_TOKEN も読み取り専用になる。つまりフォークの PR では LLM の API キーが空になる。

ここで pull_request_target に変えると secret は届く。ただし同じドキュメントは、このトリガーで信頼できないコードを動かすと、書き込み権限や secret に意図せず届くと警告している。AI レビューを動かすために変える理由にならない。

ワークフローの if は、フォークの PR を対象から外すためのものだ。公開リポジトリでフォークの PR もレビューしたいなら、この構成のままでは足りない。

2. 外への通信をネットワークで塞ぐ

internal: false では AI のコンテナから 1.1.1.1 へ届き、internal: true では Network unreachable になる。外のホスト名の解決も SERVFAIL で止まる

次の 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: falseinternal: true
wget http://1.1.1.1/301 Moved Permanently が返るNetwork unreachable
nslookup example.comIP アドレスが返るSERVFAIL

internal: true の構成では、/proc/self/status の CapEff は 0000000000000000 で、touch /x は Read-only file system で失敗した。

名前解決が止まることも見ておきたい。外のホスト名が解決できる環境では、問い合わせる名前そのものにデータを載せて持ち出せる。

Zenn の開発チームは、ネットワークだけを持つコンテナの iptables を既定で拒否にして、宛先とポートの単位で許可している。試験対象のアプリにも届かせる必要があるからだ。PR レビューのように宛先がゲートウェイだけなら、internal: true で足りる。

3. API キーはゲートウェイだけが持つ

agent はダミーの鍵で gateway に投げ、gateway が本物の鍵に差し替えて LLM API へ送る。決めたパス以外は 403 になる

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 /admin403 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 が別のコンテナを起動して、ここまでの制限を外せる。

4. cap_drop: ALL の root は、runner のディレクトリに書けない

cap_drop: ALL の root は out/ に書けず Permission denied になる。user に runner の UID と GID を渡すと書ける

ここは手元で詰まった。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 ができた。

5. この構成でも防げないこと

トークンと API キーの持ち出し、許可していない宛先への通信は塞ぐ。コメントの中身の誤りと、PR で設定ファイルを変える人は塞がない
  • コメントの中身: AI が差分の文章に誘導されて、的外れな指摘や誤った「問題なし」を書くことは止められない。レビュー結果は判断の材料として読み、人の承認の代わりにしない
  • PR での設定の変更: pull_request は PR のマージコミットのワークフローと compose.yaml で動く。リポジトリに push できる人が PR で internal: true を消すと、その PR の実行では隔離が外れる。この構成が防ぐのは実行中の AI の逸脱で、書き込み権限を持つ人の変更ではない。設定ファイルの変更は人のレビューで止める
  • LLM への送信: レビューするコードは LLM の API に送られる。入力の学習利用の条件は、使う API の規約で確かめる。元記事も、導入前に確かめる点として挙げている
  • 隔離の破れ: 元記事は、設定の漏れやミドルウェアの未知の脆弱性を前提に、AI の分類器でコマンドを審査する層も重ねている。攻撃の操作まで AI にさせるなら、この記事の構成だけでは足りない

エージェントの権限をプロンプトでなく実行環境で絞る話は、脆弱性の調査を AI に頼む場面でも同じだ。「Zero Wireで重大な脆弱性の告知を読む手順」に書いた。

まとめ

  • AI に読ませる差分は AI の入力になる。制限はプロンプトでなく実行環境に置く
  • AI が動く job は contents: read だけにし、投稿は PR のコードを動かさない別の job でやる
  • フォークの PR のために pull_request_target へ変えない
  • internal: true のネットワークに AI を置くと、外への通信と外の名前解決が止まる
  • API キーはゲートウェイに置き、AI にはダミーの値を渡す
  • cap_drop: [ALL] にしたら、マウント先に書くユーザーを user: で合わせる

参考