AIに鍵を渡さず、合図1枚でmainへ 実測10秒と、検査に空いていた穴

Mac miniに常駐させたエージェントに、直した結果をmainまで持っていかせたい。ただしGitHubの鍵は渡したくない。今日はこの2つを両立させた。
結果から書く。エージェントが合図のファイルを1枚置いてから、mainへ入るまで約10秒。 鍵はコンテナの中に存在しない。
もう1つ書いておく。検査を書いた当日のうちに、その検査の抜けが2種類、止める手段の不具合が1つ見つかった。うまくいった話より、こちらのほうが持ち帰れるものが多い。
「必須」と書いた前提が、使えなかった
エージェントの成果を、どうやってmainへ入れるか。設計書には「mainの保護はGitHubのブランチ保護に頼る。これは必須」と書いてあった。書いただけで確かめていなかったので、APIで確かめた。
使えなかった。 手元の無料プランの非公開リポジトリでは、ブランチ保護もルールセットも403で断られた。
しかも、このリポジトリでは定期実行のバッチがmainへ直接pushしている。仮に保護を有効にできても、除外設定なしではこのバッチが毎晩止まる。「必須」と書いた手順をそのまま実行していたら、別の自動化を壊していた。
代わりの案を3つ並べた。
| 案 | mainへ入らない保証 | 費用 | 弱点 |
|---|---|---|---|
| 有料プランへ上げる | サーバー側で強制 | 有料 | 定期バッチの除外設定が要る |
| フォーク限定のトークンを渡す | 権限の形で不可能 | なし | トークンがコンテナに残る。フォークの管理が増える |
| pre-pushフックだけ入れる | 回避できる | なし | エージェントはシェルを使える。--no-verifyでもフックの削除でも外せる |
3案目は先に入れたが、単独では保証にならないとスクリプトにも設計書にも書いた。エージェント自身が外せるガードは、ガードではない。
鍵を渡さない、に切り替えた
3案とも「鍵を渡したうえで、どう制限するか」の話だった。そこで発想を変えた。鍵をコンテナへ渡さなければいい。
GitHubのトークンはホストにだけ置く。commitとpushとPR作成は、ホスト側のスクリプトが行う。成り立つのは、共有ディレクトリが非対称だからだ。
/workspaceはホストとコンテナで同じ実体。コンテナが書いたものは、ホストから読める- コンテナからホストへは、何も実行できない。Dockerのソケットも鍵も渡していない
分けたのは、実装とpushという2つの仕事だ。実装はコンテナの中のLLM、pushはホストの固定スクリプトが受け持つ。ホスト側の実行役をLLMにしないのが要点になる。ここをcodexに任せると、差分やIssue本文に「mainへ直接入れて」と書かれていたとき、従う余地が残る。スクリプトは説得されない。
これで、残っていた2つの問題が同時に消えた。
- ブランチ保護が使えない → 使わなくても、コンテナにはmainへ書く手段が無い
- 実行主体がトークンを読める → トークンがコンテナに無い
ホスト側のスクリプトが弾くのは次の5つ。
- mainとmaster、detached HEADへのpush
- 許可した7種類の接頭辞(
feature/fix/chore/docs/content/refactor/test/)以外のブランチ名 - 秘密情報らしいファイル名(
.env、鍵、認証情報のファイルなど)の混入 - 空、または200文字を超えるコミットメッセージ
- 英数字と
._-以外の文字、または..を含む作業名(パスの外へ出さないため)
最初はcloneもコンテナ内で行う形で書いていた。だが非公開リポジトリのcloneには認証が要る。それでは鍵を渡すことになり、方針と矛盾する。cloneもホスト側へ移した。
共有ディレクトリには1つ懸念があった。ホストとコンテナで実行ユーザーのIDが違うと、コンテナから書けないのではないか。実機で確かめると、問題なかった。 仮想化層のマウントが所有者を読み替えるので、コンテナが作ったファイルは、ホスト側ではホストのユーザーのものになる。設定の追加は要らなかった。
合図から10秒
検知は、合図のファイルで行う。エージェントが.pr-queue/作業名というファイルを1つ置く。1行目がコミットメッセージだ。これをホストのlaunchdが拾う。
launchdのQueueDirectoriesは、指定したディレクトリが空でない間、ジョブを動かし続ける。 書き方は分かっても挙動が想像しづらいので、手元(macOS 26.5.1)で一時ジョブを作って確かめた。
- 合図を消さずに失敗終了させると、約10秒おきに起動し続けた(32秒で4回)。手で消すと止まった
- 空のサブディレクトリが1つあるだけでも「空でない」扱いになり、ファイルを1つも置かなくても約10秒おきに起動した
ここから、設計で決めたことが2つある。
- 合図は先に消す。 処理が途中で失敗しても、再試行が続かない。先に消しても、空になったあとに空振りの起動が1回だけ残った(3回の実験すべて)。スクリプトは合図が無くても安全に終わる作りにしてある
- 合図専用の、平らなディレクトリにする。 worktreeなどを置いたディレクトリを指定すると、常に「空でない」になって起動し続ける
取りこぼしの保険に、300秒ごとの定期起動も併用している。launchdは、非対話のSSHと同じくログインシェルのPATHを持たない。この落とし穴は文書化した直後に踏んだので、plistにもスクリプトの冒頭にもPATHを書いた。
経路全体の実測はこうだった。
- コンテナから合図を置いて、ホストが検知するまで約4秒
- 合図から、mainへのsquashマージまで約10秒(push、PR作成、マージ、作業ディレクトリの片付けを含む)
一時ジョブでは、ホスト上で直接ファイルを置くと0.2〜0.6秒で起動した(2回)。約4秒との差は共有ディレクトリ越しの伝わり方にあるとみられるが、切り分けてはいない。

機械が止めることと、指示書が頼んでいること
この経路には、機械的に止まるものと、そうでないものが混ざっている。
- 機械的に止まる: 上の5つと、コンテナからの直接push(認証情報が無いので失敗する)
- 指示書が頼んでいるだけ: 「codexのレビューを済ませてから合図を置く」「デプロイはしない」
レビューを通してから合図を置く順序は、エージェントへの指示書に書いた手順で、スクリプトは確かめていない。合図が置かれれば、スクリプトは検査を通ったものを自動でsquashマージする。当初は「マージは人」の設計だった。夕方にこれを外した。人の関門が1つ減った。
止める手段として、環境変数AUTO_MERGE=0でPR作成までに戻す作りにしてあった。ところが、この記事のために確かめると、導入時に指定しても、launchdのジョブには届いていなかった。 plistの環境変数に書いていたのはPATHとHOMEだけで、ジョブは常に既定値で動く。止めたつもりで、常にマージまで進む状態だった。導入時の値をplistへ書くよう直した。生成されるplistを新旧で比べて確かめたところまでで、miniでの実測はこれからだ。
未明に見つけた抜け grepの終了コード
ここから、検査の話になる。
未明、Discordの設定スクリプトのレビューで、監査コマンドの結果をgrepへパイプしている箇所が指摘された。形はこうだった(コマンド名は置き換えている)。
audit 2>&1 | grep -i critical || echo "指摘なし"
これが判定しているのは、grepの終了コードだ。監査コマンドが異常終了しても、出力にcriticalが無ければ「指摘なし」と出る。しかも、監査より先にGatewayを再起動していた。
出力と終了コードを分け、監査を反映の前に移して、失敗したら止まるようにした。同じ系統の書き方(|| trueで失敗を捨てる)を全スクリプトで探すと、あと2か所あった。
直したあと、sandbox explainを「失敗したら止める」形にしたところ、その場で実在の問題を捕まえた。複数エージェント構成では、agentIdの指定が必須だった。以前の|| trueのままなら、気づかずに「確認済み」として通していた。
夕方に見つけた抜け git statusは新しいディレクトリを畳む
この記事を書くために、pushのスクリプトを読み直した。秘密情報らしいファイル名を調べる検査が、git status --porcelainの出力を見ている。
このコマンドは、未追跡のディレクトリをディレクトリ名1行に畳む。 一時リポジトリに.env、top.pem、newdir/.env、newdir/id_rsaを置いて確かめた。
$ git status --porcelain
?? .env
?? newdir/
?? top.pem
リポジトリ直下の.envとtop.pemは検出できた。newdir/の中の.envとid_rsaは、名前が出てこないので検出できなかった。直後のgit add -Aは中身をすべてステージするので、そのままcommitとpushまで進み得た。

直し方は、先にステージして、commitされるファイル名の一覧(git diff --cached --name-only -z)を調べる形にした。検出したらステージを戻して止める。実ファイルから切り出した検査ブロックを一時リポジトリで7ケース通して確認し、別のPRで反映した。
未確認なのは、mini上での実行だ。 2つの修正はどちらも、mini側でgit pullしてから確かめる。記録上、拒否側の経路(main宛て、接頭辞違反、メッセージ長、秘密情報の混入)を合図から通した実測もまだ無い。今日確かめたのは、通る側の10秒だけだった。
今日の教訓
- 必須と書く前に、使えるかを実測する。 ブランチ保護は使えるものと思い込んでいた
- エージェント自身が外せるガードは、ガードではない。 実行役を別の主体に分け、鍵ごとエージェントの手の届かない側に置く
- 検査を書いたら、通すべきでないものを実際に通してみる。止める手段も同じ。 「動いている」と「止めるべきものを止める」は別で、今日の3つはどれも前者しか見ていなかった
手順と挙動は、WIKIにまとめた。
- 鍵を渡さないpush設計 — 型、検査の順番、launchdの挙動、再現手順
- ガードが素通りする書き方 — 今日の3つの再現と、ガードを壊して確かめる手順
前の2日はMac miniを常時稼働のAIサーバーにする前夜とMac mini実測。この経路が実装を任せる先は、ちょうぼっちやValscopeなどを含むこのリポジトリのコードだ。