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

公開: 2026-09-24
AIエージェント権限分離launchdOpenClawMac mini

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作成は、ホスト側のスクリプトが行う。成り立つのは、共有ディレクトリが非対称だからだ。

コンテナ(GitHubの鍵なし)ホスト(GitHubの鍵はここだけ)実装ローカルLLMcodexがレビュー合図のファイルを置くlaunchdが書き込みを検知固定スクリプトが検査push・PR・squashマージ共有ディレクトリ.pr-queue×コンテナからホスト側を動かす手段は無い合図から約10秒でmainに入る(検知までは約4秒)
コンテナが書けるのは合図のファイルまで。何をどこへpushするかは、ホストの固定スクリプトが決める。

分けたのは、実装とpushという2つの仕事だ。実装はコンテナの中のLLM、pushはホストの固定スクリプトが受け持つ。ホスト側の実行役をLLMにしないのが要点になる。ここをcodexに任せると、差分やIssue本文に「mainへ直接入れて」と書かれていたとき、従う余地が残る。スクリプトは説得されない。

これで、残っていた2つの問題が同時に消えた。

ホスト側のスクリプトが弾くのは次の5つ。

  1. mainとmaster、detached HEADへのpush
  2. 許可した7種類の接頭辞(feature/ fix/ chore/ docs/ content/ refactor/ test/)以外のブランチ名
  3. 秘密情報らしいファイル名(.env、鍵、認証情報のファイルなど)の混入
  4. 空、または200文字を超えるコミットメッセージ
  5. 英数字と. _ -以外の文字、または..を含む作業名(パスの外へ出さないため)

最初はcloneもコンテナ内で行う形で書いていた。だが非公開リポジトリのcloneには認証が要る。それでは鍵を渡すことになり、方針と矛盾する。cloneもホスト側へ移した。

共有ディレクトリには1つ懸念があった。ホストとコンテナで実行ユーザーのIDが違うと、コンテナから書けないのではないか。実機で確かめると、問題なかった。 仮想化層のマウントが所有者を読み替えるので、コンテナが作ったファイルは、ホスト側ではホストのユーザーのものになる。設定の追加は要らなかった。

合図から10秒

検知は、合図のファイルで行う。エージェントが.pr-queue/作業名というファイルを1つ置く。1行目がコミットメッセージだ。これをホストのlaunchdが拾う。

launchdのQueueDirectoriesは、指定したディレクトリが空でない間、ジョブを動かし続ける。 書き方は分かっても挙動が想像しづらいので、手元(macOS 26.5.1)で一時ジョブを作って確かめた。

ここから、設計で決めたことが2つある。

取りこぼしの保険に、300秒ごとの定期起動も併用している。launchdは、非対話のSSHと同じくログインシェルのPATHを持たない。この落とし穴は文書化した直後に踏んだので、plistにもスクリプトの冒頭にもPATHを書いた。

経路全体の実測はこうだった。

一時ジョブでは、ホスト上で直接ファイルを置くと0.2〜0.6秒で起動した(2回)。約4秒との差は共有ディレクトリ越しの伝わり方にあるとみられるが、切り分けてはいない。

壁の投函口に紙片が落ちる瞬間。合図のファイルが置かれた途端に、ホストが反応する

機械が止めることと、指示書が頼んでいること

この経路には、機械的に止まるものと、そうでないものが混ざっている。

レビューを通してから合図を置く順序は、エージェントへの指示書に書いた手順で、スクリプトは確かめていない。合図が置かれれば、スクリプトは検査を通ったものを自動で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秒だけだった。

今日の教訓

手順と挙動は、WIKIにまとめた。

前の2日はMac miniを常時稼働のAIサーバーにする前夜とMac mini実測。この経路が実装を任せる先は、ちょうぼっちやValscopeなどを含むこのリポジトリのコードだ。