鍵を渡さないpush設計

更新: 2026-09-24
AIエージェント権限分離launchdGitHubDocker

エージェントにシェルとGitHubの鍵を両方渡すと、差分やIssue本文に「mainへ直接入れて」と書かれていたとき、従うかどうかがエージェントの判断に懸かる。「渡したうえで絞る」設計は、絞りきれているかを確かめ続ける仕事が残る。

ここに置くのは、そもそも鍵を渡さない設計だ。実装はコンテナの中のエージェント、pushはホストの固定スクリプトに分ける。この構成で、合図のファイルを置いてからmainへ入るまで約10秒だった。経緯は開発日記のAIに鍵を渡さず、合図1枚でmainへにある。

型

コンテナエージェント(LLM)ホスト固定スクリプト書ける: /workspace鍵もDockerソケットも無い読める: /workspaceGitHubの鍵はここだけ/workspace同じ実体書く読む×コンテナからホストへの実行はできない
書けるのは共有ディレクトリまで。鍵と実行役は、ホストの側にだけある。

成り立つ条件は4つ。

  1. ディレクトリが、ホストとコンテナで同じ実体になっている
  2. コンテナからホストへ実行する手段が無い(Dockerのソケットも、鍵も渡さない)
  3. ホスト側の実行役は固定スクリプトで、LLMにしない
  4. GitHubの鍵は、ホストにだけある

3が要になる。実行役にもLLMを置くと、「mainへ直接入れて」と書かれた差分に従う余地が残り、分けた意味が消える。

ホスト側の検査

ホスト側は2本のスクリプトに分かれている。launchdから起動して合図の検知・マージ・片付けをするpr-watcher.shと、pushとPR作成をするpush-from-host.shだ。検査は次の順番で通る。

pr-watcher.sh(launchdから起動)push-from-host.sh(pushとPR作成)順検査・処理NGのとき12345678910ロック(多重起動しない)作業名は英数字と . _ - だけ(先頭のドットと .. は拒否)作業用worktreeが実在する合図を消す(再試行を止める)コミットメッセージが1〜200文字ブランチが main・master・HEAD のどれでもないブランチ名の接頭辞が許可リスト内秘密情報らしいファイル名が無い(ステージ後の一覧で検査)commit → push → PR作成(マージはしない)squashマージ(AUTO_MERGE=1のとき)スキップ合図を破棄合図を破棄処理しない中止中止中止・ステージを戻すPRを残す(常に実行)(ここまでで完了)
検査は2本のスクリプトにまたがる。合図を消す4段目は、検査より前に置いてある。

順番に理由がある箇所が3つある。

  • 合図を消すのは、検査より前。 消す前に失敗すると、launchdが同じ合図で起動し続ける(下の実測)
  • 秘密情報の検査は、commitされるファイル名の一覧で行う。 先にgit add -Aでステージし、git diff --cached --name-only -zを調べる。git status --porcelainは新規ディレクトリを1行に畳むので、中の.envを見逃す。詳細はガードが素通りする書き方
  • マージはpushとは別の段。 環境変数AUTO_MERGE=0でPR作成までに止められる。ただしこの値は、導入時にplistへ書かないとジョブへ届かない

コミットの著者はホスト側の設定(つまり人間)にして、コンテナ由来であることはトレーラー(Generated-by: openclaw-agent (container))で残している。責任の所在を曖昧にしないためだ。

launchdで発火させる

合図は、.pr-queue/作業名というファイルとして置く。1行目がコミットメッセージだ。これをQueueDirectoriesで拾う。plistの要点は次のとおり。

<key>ProgramArguments</key>
<array><string>/bin/bash</string><string>/path/to/pr-watcher.sh</string></array>
<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
  <key>HOME</key><string>/Users/…</string>
  <key>AUTO_MERGE</key><string>1</string>
</dict>
<key>QueueDirectories</key>
<array><string>/path/to/workspace/.pr-queue</string></array>
<key>StartInterval</key><integer>300</integer>
<key>RunAtLoad</key><true/>

QueueDirectoriesは、指定したディレクトリが空でない間、ジョブを動かし続ける(man launchd.plist)。この挙動を、一時ジョブで実測した(macOS 26.5.1)。

条件結果
ホスト上で直接ファイルを置く0.2〜0.6秒で起動(2回)
合図を消さずに失敗終了する約10秒おきに起動し続ける(32秒で4回)。手で消すと止まる
先に消してから失敗終了する1回目の約10秒後に、空振りの起動が1回。以降は起動しない(25秒間)
空のサブディレクトリだけがあるファイルが無くても約10秒おきに起動し続ける(27秒で3回)。消すと止まる

約10秒という間隔は、launchdの既定の起動間隔(ThrottleInterval。同じジョブを10秒に1回より多くは起動しない)に一致する。

合図が残っている間の起動空になったあとの空振り合図を消さずに失敗終了する手で消す先に消してから失敗終了する25秒の観測の間、3回目の起動は無かった0秒10秒20秒30秒40秒
起動時刻は実測値。上段の空振りの位置(約40秒)は、10秒間隔から置いた目安で、時刻そのものは記録していない。

読み取れることは3つ。

  1. 合図は先に消す。 消さずに失敗すると、約10秒おきの再起動が止まらない
  2. 空になったあとにも、空振りの起動が1回残る。 3つの実験すべてで見た。スクリプトは、合図が無い状態でも安全に終わる作りにする
  3. 合図専用の、平らなディレクトリにする。 worktreeのようなサブディレクトリを持つディレクトリを指定すると、常に「空でない」になる

StartInterval(300秒)は取りこぼしの保険に併用している。スリープ中に来た間隔は飛ぶ(man launchd.plist)ので、これだけには頼れない。

watcher側の骨格は、要点だけ残すとこうなる(実物は132行)。

#!/usr/bin/env bash
set -euo pipefail
export PATH=/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin

QUEUE="$HOME/project/openclaw/workspace/.pr-queue"
LOCK="$HOME/.pr-watcher.lock"

# 多重起動しない(mkdirは原子的に成功か失敗のどちらかになる)
mkdir "$LOCK" 2>/dev/null || exit 0
trap 'rmdir "$LOCK" 2>/dev/null || true' EXIT

for Q in "$QUEUE"/*; do
  [ -f "$Q" ] || continue                 # 合図が無い回(空振り)はここで終わる
  NAME="$(basename "$Q")"
  case "$NAME" in *[!A-Za-z0-9._-]*|.*|*..*) rm -f "$Q"; continue ;; esac
  MSG="$(head -1 "$Q" | tr -d '\r')"
  rm -f "$Q"                              # 先に消す
  [ -n "$MSG" ] && [ "${#MSG}" -le 200 ] || continue
  bash push-from-host.sh "$NAME" "$MSG" --yes || continue
done

踏んだこと

  • ブランチ保護に頼れなかった。 手元の無料プランの非公開リポジトリでは、ブランチ保護もルールセットもAPIが403を返した。この設計は、それに頼らずに「mainへ入らない」を成り立たせている
  • 実行ユーザーのIDの食い違いは、問題にならなかった。 ホストとコンテナで違うと、コンテナから書けないのではと疑った。実機で書けた。仮想化層(Colima)のマウントが所有者を読み替えるので、コンテナが作ったファイルは、ホスト側ではホストのユーザーのものになる
  • cloneをコンテナ内に書いたのが誤りだった。 非公開リポジトリのcloneには認証が要る。鍵を渡すことになり、方針と矛盾する。cloneもホスト側にした
  • launchdはログインシェルのPATHを持たない。 plistにもスクリプトの冒頭にもPATHを書く
  • 導入時の環境変数は、launchdのジョブへ渡らない。 AUTO_MERGE=0を付けて導入しても、plistへ書かなければ届かず、ジョブは常に既定値で動いていた。導入時の値をplistへ書く形に直した
  • 検査に抜けがあった。 新規ディレクトリの中の秘密ファイルを見逃していた。ガードが素通りする書き方にまとめた

何を機械が保証するか

区分内容
機械的に止まる上の検査(宛先、ブランチ名、秘密情報らしいファイル名、メッセージ長、作業名)。コンテナからの直接push(認証情報が無いので失敗する)
指示書が頼んでいるだけ「レビューを済ませてから合図を置く」「デプロイしない」。スクリプトは確かめない
保証していないファイル名が普通のファイルに貼られた秘密。名前で検査する限り止まらないので、内容の走査は別に要る
未確認拒否側の経路(main宛て、接頭辞違反など)を合図から通した実測。修正した導入スクリプトで自動マージが止まること

他の実装との位置づけ

同じ系統の設計は、すでに大手のエージェントにもある。

  • GitHub Copilotのクラウドエージェントは、pushできるのが1つのブランチ(既存のPRのブランチか、新しいcopilot/ブランチ)だけで、既定ブランチへ直接pushできない(出典: GitHub Docs)
  • Claude Code on the webは、gitの認証情報をサンドボックスの外に置く。中のgitは限定した資格情報でプロキシへ接続し、プロキシがpush先などを検証してから本物のトークンを付けてGitHubへ送る(出典: Anthropic)

宛先を絞ること、鍵をエージェントの外に置くこと。この2点は同じだ。違うのは、間に立つのがサービスではなく、同じ機械の固定スクリプト1本とlaunchdだという点。専用のプロキシもGitHub側の追加設定も要らない。代わりに、検査は自分で書いて、自分で壊して確かめる必要がある。

止め方

# 自動マージだけ止める(PR作成で止まる)。導入し直す
# 導入時の値をplistへ書く版が要る(2026-09-24に修正)
AUTO_MERGE=0 bash install-pr-watcher.sh

# 完全に止める
launchctl unload ~/Library/LaunchAgents/<ラベル>.plist

関連

WIKI一覧へ