worktreeに閉じ込めるか、本体で動かすか — コードではない仕事をcc-bridgeに通すために決めた権限の線引き【3/4】

cc-bridge構築記の3本目です。前回までで、claude.aiからClaude Codeへブリーフを直接渡し、報告を読んでWeb画面からマージするところまで動きました。対象は全部「コードのリポジトリ」でした。今回は、コードではない仕事を通そうとして前提を見直した10月前半の話です。

1. 楽曲制作がcc-bridgeを離れていた

9月末、並行して進めていた楽曲制作(Sunoで作った曲をアルバムに仕上げる作業)は、cc-bridgeを使わずにターミナルで直接Claude Codeを回していました。理由を整理すると4つありました。

  1. ジョブはgit worktree(作業用の別コピー)で動くので、git管理外の大容量ファイル(音声・動画・候補画像)が見えない
  2. 工程ごとにブランチを切ってマージして依存を同期する往復が入る。制作は「置く→計測→聴く→差し替え」の短い対話の繰り返しで、この往復が全部オーバーヘッドになる
  3. レンダリングに1.5時間かかるような処理を、ジョブの中で待たせる設計になっていない
  4. 「音を聴く」「画像を見る」という人間の手番が工程の途中に入る。判断待ち(waiting_decision)は文章で答える判断向けで、ファイルを見て答えるには足りない

チャットに相談したときの前置きが率直で、「数分刻みで回す作業は、往復ごとにClaude Codeの起動とPMの返信が挟まるcc-bridgeでは、ターミナルより遅くなる。これは改善しても消えない。cc-bridgeを使う価値が出るのは、スマホから回せること、経過と判断が記録に残ること、長い待ちに人が張り付かずに済むこと、の3点」。この3点に価値を感じるなら作る意味がある、そうでなければ従来方式のままでいい、と。私は3点とも欲しかったので作ることにしました。

2. 直接モードの設計

4つの要件を1つの仕組みで解くより、「直接モード」「成果物の受け渡し」「長時間処理」の3つに分けた方が既存の動作を壊さない、という整理で進めました。

A. 直接モード。config.toml の [repos.<名前>] に mode = "direct" を持たせ、そのリポジトリだけworktreeを切らず、本体の作業ツリー(チェックアウト中のブランチ)でClaude Codeを動かす。設定の無いリポジトリ(cc-bridge自身を含む)は従来どおり。直接モードではマージが無いので、代わりの安全担保を決める必要がありました。

  • 開始時のHEADを refs/cc-bridge/<job_id>/before として残す。報告に「このジョブで入ったコミット一覧」を必ず載せる
  • 同じリポジトリの直接モードのジョブは同時に1本(ロック)
  • リポジトリ外の許可フォルダは extra_paths で明示する

B. 成果物の確認。新しい状態は作らず、既存の判断待ちを使う。報告に「確認してほしい成果物」の節を足し、1行1ファイルのパスを列挙する書式にする。ジョブのページがそのファイルを配信し、拡張子で音声はプレーヤー、動画は動画、画像は画像として埋め込む。配信できるのは報告に列挙されていて、かつリポジトリか extra_paths の中にあるファイルだけ。Tailscale内なので認証なし、動画は数百MBになるのでRange対応。

C. 長時間処理。バックグラウンドで起動して完了マーカーを見張る案。これは「実際にレンダリングで困ってから」として後回しにしました(結果的に別の形で解決します。4節)。

進め方にも工夫がありました。設計ジョブを「設計案を報告に書いて判断待ちで止まる。承認したら同じセッションのreplyで実装に入る」という形で投げたのです。ジョブは3つの判断を挙げて止まりました。

  1. 直接モードで「戻す」手段。推奨案: 直接モードのジョブだけ git revert を許可し、git reset --hard は引き続き許さず、必要ならPMが手で before に戻す
  2. extra_paths の権限を読み書き両方にするか。推奨案: 書き込みも許す(クリップの置き直しを想定)
  3. ロックの範囲。推奨案: 同リポジトリで直接モードがrunningのときだけ。判断待ちの直接ジョブは次を止めない

3つとも推奨どおりで承認しました。1番の線引きは「履歴が消えないrevertはAIに、消えるresetは人に」で、今も気に入っています。

3. 初回の直接ジョブは失敗した

実装後、楽曲制作のリポジトリを直接モードに設定し、初回のジョブ(CLAUDE.mdを直接モード版にする、.gitignoreを確認する、既存の音声1つと画像1つを成果物として報告する)を投げました。

15秒で「失敗」。報告も書かれていない。

ここで困ったのが、チャットには経過が見えないことでした。チャットが読めるのは報告とstatusだけで、Claude Codeの発言やツール呼び出しはジョブのページにしか流れない。「あなたから見えないんだっけ?」「見えません」。私がページの最後の発言を貼って、原因が分かりました。直接モードのセッションでgitコマンドが全部拒否されていた(lsは通る)。worktreeモードでは通っているので、直接モード用の許可リストの組み立ての問題です。

修正のジョブを投げると、原因は両モード共通の許可ルールで、git branch --show-current のように連結された形や git -C <パス> の形が拾えていなかった。直してから再開すると、本体のツリーで動き、CLAUDE.mdを新規作成し、成果物の音声(26MB)と画像がスマホのジョブページで再生・表示できました。直接モードと成果物の確認は、これで本番で一周です。

ジョブページの成果物欄

細かい気づきも残しておきます。成果物の欄は最初ページの上部にありましたが、私の流れは「報告を読む→聴く→返信欄に書く」なので、返信欄の直前に移しました。マージボタンを返信欄の横に移したのと同じ理由です。それと、ジョブが報告に「要判断事項: なし。成果物はPMが確認済み」と文で書くと、「なし」の判定に引っかからず判断待ちのまま残る。判定を緩くしました。報告を書くのはAIなので、書式を厳密に守らせるより受け側を緩くする方が確実です。

4. 音声処理で、任意コード実行を受け入れた

10月初め、別の用途が来ました。遠隔収録した対談の相手トラックに自分の声が漏れているのを除く作業(別記事)で、音声処理のツールを2つ、cc-bridgeで開発・実行したい。どちらも管理外フォルダの録音を読み、処理結果を書き出して聴き比べるので、直接モード+extra_paths = ["~/audio-work"]で登録しました。

問題は実行の許可です。それまでのcc-bridgeは uv run pytest だけを許し、「uv run * は任意コマンド実行と同じ」として絞っていました。今回は開発作業そのものに実行が要る。登録のジョブに「許可の最小案と、広げた場合のリスクを示して判断待ちで止まれ」と書いて投げました。

報告の前提がはっきりしていました。許可を絞っても広げても、ジョブが自分で書いたコードを動かせる時点で任意のコード実行になる。差は「うっかり1行で外に出られるか」だけ。案は3つ。

  • A. 形を絞る(uv sync / uv add、uv run pytest、uv run <自作CLI名>、uv run python scripts/*、ffmpeg / ffprobe)
  • B. uv * を丸ごと許す
  • C. OSのサンドボックスで境界を作る調査ジョブ

私の判断は「案Aで進める。任意コード実行になる点は受け入れる。案Cは不要」。自分のMac Studioで自分が指示したジョブなので、受け入れてよいリスクだと思いました。代わりに運用で守る部分を決めました。

  • モデルの重みのダウンロードは許可しない(取得済み。足りなければ要判断事項に挙げる)
  • 元の録音は私が chmod a-w で読み取り専用にする。出力は ~/audio-work/out/ 配下だけに書く約束
  • 対象リポジトリに置くCLAUDE.mdの雛形(直接モードの規約入り)をこのジョブに書かせ、各リポジトリの最初のジョブで置かせる

権限の方針としては、「読み取り系は権限不足で止まったら、理由を添えて都度足す」「書き込み(作業ツリーの外)と起動停止(launchctlなど)は許可せず手動のまま」に落ち着きました。

小話を1つ。この日、準備のコマンドをスマホの音声入力でターミナルに貼ったら、末尾に誤認識の「ご視聴ありがとうございました。」がくっついて、その名前のフォルダができていました。チャットが貼り付け結果から気づいて rmdir を出してくれました。

5. 1コマンド10分の上限は「ナンセンス」だった

音声処理では、全長の処理に50分かかるものがあります。cc-bridgeのジョブでは1コマンドが10分で切られるので、当初は私がSSHで nohup 起動して、ジョブには区切って処理させる運用でした。ブリーフに「1コマンド10分以内に分けて」と定型で書いていたほどです。

これを「ナンセンス」と言って調べさせたら、正体はcc-bridgeではなくClaude Code側のBashツールの既定の上限(BASH_MAX_TIMEOUT_MS / BASH_DEFAULT_TIMEOUT_MS)でした。cc-bridgeがClaude Codeを起動するときに環境変数で渡せば消える。公式ドキュメントで変数名を確認し(ジョブが「変数名が有効か確認できない」と止まったので、私が確認して返した)、最大60分・既定10分を config.toml で設定できるようにしました。併せて、起動全体の壁時計の上限もリポジトリごとに上書きできるようにし、音声処理の3リポジトリは180分に。

これで全長処理は区切りもnohupもなしの1コマンドで済み、2節で後回しにした「C. 長時間処理」のバックグラウンド起動は不要になりました。困ってから調べると、想定していた解決策と違う所に答えがあることがある、という例です。

6. コミットせずに終わったジョブが、素通りした

同じ頃、もう一つ事故がありました。あるジョブが「バックグラウンドのテスト完了を待ってからコミットする」と言って終了し、未コミット7件のまま。そしてmergeがそれを素通りした。第2回で書いた「コミットせずに終わる」問題の、より大きい版です。

再発防止は2段で決めました。本命は、未コミットの変更が1件でもあれば merge ツールもマージボタンも断る。補助は、プロンプトと対象側CLAUDE.mdの規約に「テストはフォアグラウンドで流し、コミットしてから終える」を足す。ランナー側で自動コミットする案は不採用にしました。AIが「終わった」と言った状態をそのまま取り込む仕組みより、取り込めない状態を取り込めないままにする方が安全だからです。

判断待ちの要判断事項

関連してもう1つ。Sonnetのジョブで、一部のBashコマンドが拒否されると「Bashは全部拒否された」と一般化して、許可されている git commit まで試さずに終わる、という振る舞いが2回ありました。プロンプトに「拒否はコマンドごと。許可されたコマンドは試すこと」と明記して直しています。モデルが安いぶん、こういう癖は出ます。

7. 認証の期限切れ

最後に、仕組みとは関係ないが運用で踏んだ穴を。ジョブが「OAuth session expired」で即failedになる日がありました。Claude Codeの認証期限切れです。SSHでMac Studioに入って claude → /login しても直らない。launchdから起動しているcc-bridgeのサービスには、その認証が届かない。画面共有でMac StudioのTerminal.appから /login すると直りました。後で見張りを入れる話は第4回で書きます。

8. 運用ルールとして残すこと

  • コードのリポジトリはworktreeモード(マージが人間のゲート)。非コード・対話型・大容量の作業は直接モード(本体のツリー、開始時HEADをrefsに残す、同時1本)
  • 直接モードのジョブは git revert だけ許可。git reset --hard は人がやる
  • ターミナルで直接Claude Codeを回している最中のリポジトリには、cc-bridgeのジョブを投げない(ロックはジョブ同士しか見ていない)
  • extra_paths は読み書き両方。元データは chmod a-w、出力は out/ 配下だけ
  • 実行許可は形で絞る(uv run <CLI名>、uv run python scripts/* など)。任意コード実行になる点は受け入れ、OSサンドボックスは作らない
  • 読み取り系の権限は「止まったら理由を添えて足す」。作業ツリー外の書き込みと起動停止は手動のまま
  • 1コマンドの上限は最大60分・既定10分、起動全体は音声3リポで180分。ブリーフに「10分以内に分けて」は書かない
  • 未コミットがあればマージは断る。ジョブは「テストはフォアグラウンド、コミットしてから終える」
  • 設計の判断が要るジョブは「設計案を報告して判断待ちで止まり、承認後に同じセッションで実装」の形で投げる
  • 報告の書式はAIに厳密に守らせるより、受け側(判定・解析)を緩くする
  • 認証期限切れはSSHの /login では直らない。画面共有のTerminal.appから

次回は最終回。壁打ちの結論をその場でリポジトリに残すツール、マージ後の自動push、見張り役のプロセス、そして「本当のボトルネックはトークンに移った」という現在地です。