鍵を渡さないpush設計
エージェントにシェルとGitHubの鍵を両方渡すと、差分やIssue本文に「mainへ直接入れて」と書かれていたとき、従うかどうかがエージェントの判断に懸かる。「渡したうえで絞る」設計は、絞りきれているかを確かめ続ける仕事が残る。
ここに置くのは、そもそも鍵を渡さない設計だ。実装はコンテナの中のエージェント、pushはホストの固定スクリプトに分ける。この構成で、合図のファイルを置いてからmainへ入るまで約10秒だった。経緯は開発日記のAIに鍵を渡さず、合図1枚でmainへにある。
型
成り立つ条件は4つ。
- ディレクトリが、ホストとコンテナで同じ実体になっている
- コンテナからホストへ実行する手段が無い(Dockerのソケットも、鍵も渡さない)
- ホスト側の実行役は固定スクリプトで、LLMにしない
- GitHubの鍵は、ホストにだけある
3が要になる。実行役にもLLMを置くと、「mainへ直接入れて」と書かれた差分に従う余地が残り、分けた意味が消える。
ホスト側の検査
ホスト側は2本のスクリプトに分かれている。launchdから起動して合図の検知・マージ・片付けをするpr-watcher.shと、pushとPR作成をするpush-from-host.shだ。検査は次の順番で通る。
順番に理由がある箇所が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回より多くは起動しない)に一致する。
読み取れることは3つ。
- 合図は先に消す。 消さずに失敗すると、約10秒おきの再起動が止まらない
- 空になったあとにも、空振りの起動が1回残る。 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
関連
- 開発日記: AIに鍵を渡さず、合図1枚でmainへ — この設計に至る経緯と、同じ日に見つけた抜け
- ガードが素通りする書き方 — 検査と止め方が効いていなかった3つの実例
- 指示書を1つにする — エージェントへの指示が届いていない状態の作り方と直し方