「ChatでAIエージェントに作業を投げたい。でも、そいつにホストの~/.sshを触らせるのは怖い」
この二つの欲求は、わりと正面からぶつかります。エージェントに自由を与えるほど便利になり、与えるほど事故ったときの被害範囲が広がる。妥協点として「コンテナの中だけで動かす」という選択肢は前からあったわけですが、いざ AI コーディングエージェントを箱に閉じ込めようとすると、設定の引き継ぎとクレデンシャルの受け渡しで地味にハマります。
今回は、Slack を入口にしたチャット常駐 daemon から、Docker コンテナ内の Claude Code を起動する構成を作ってみた実験記録です。動かすまでに踏んだ落とし穴を、設計判断ごとに残しておきます。
何を作ろうとしたのか
やりたかったことはシンプルです。
- Slack にメッセージを送る
- 常駐している gateway がそれを受ける
- Docker コンテナを backend にして、その中で AI エージェントにタスク(プロジェクト固有の skill 実行など)をやらせる
- 結果が Slack に返ってくる
ホスト(Mac)はあくまで gateway とコンテナ起動の管理だけ。実際にコードを触る作業は、隔離されたコンテナの中で完結させる。OS レベルで隔離しておけば、エージェントが多少暴れても被害はコンテナの中に収まる、という発想です。
土台になる Docker イメージそのものは、Nix flake から生成しています。dockerTools.buildLayeredImage でホストと同じ CLI ツール一式(ripgrep や gh、jq など)を単一ソースから供給し、ホストとコンテナで道具が食い違わないようにする。この基盤づくり自体は以前から進めていて、今回はその上に「AI エージェントを乗せる」フェーズです。
落とし穴1: コンテナに claude コマンドが存在しない
最初の壁は、当たり前すぎて逆に見落としていたところでした。ベースイメージには Node.js も AI エージェント本体も入っていないので、コンテナに入っても claude というコマンドが、そもそも、ない(出鼻をくじかれる)。
ここで素朴に思いつくのは「イメージ build 時に npm install -g で入れればいいのでは」です。が、これは採用を見送りました。npm install -g 系の手順は postinstall でネイティブバイナリを取りに行く挙動に依存しがちで、再現性が落ちるからです。
この再現性の弱さは、過去にもバックエンド選定で同じ轍を踏んでいます。npm 経由のインストールはビルド済みバイナリより優先順位が低く、明示すると壊れやすかった。最終的に registry 短縮名へ寄せてビルド済みバイナリを直接取る方式に統一した経緯があり、同じ失敗をもう一度やる気はありませんでした。
そこで採った方針が、イメージ build 時に Node.js v24 と AI エージェントを Nix パッケージとして同梱する案です。
ポイントは、これをコンテナ mode のときだけ効かせたことです。ホスト側は別のツールで Node を管理しているので、ホストに Node を入れると PATH が衝突してしまう。そこでパッケージ定義に「コンテナ専用」の枠を一つ設け、コンテナ向けにイメージを組むときだけ Node.js v24 を足して返す分岐を入れました。これで docker run --rm <image> claude --version がコンテナの中で通るようになりました。
ただし、ここで一つ Nix 特有の罠を踏みます。AI エージェントのパッケージはライセンス上 unfree 扱いなので、そのままだとイメージ build が「評価を拒否する」というエラーで止まる。
かといって全体の unfree 許可を開けると、他のものまで通ってしまう。なので許可の述語を使い、対象のパッケージだけ名前で絞って通すようにしました。build が通ったら、念のため箱の中で claude --version を叩いて 2.1.148 が返ることを確認し、その結果をメモに残しておく。「実際にコンテナの中で version が出た」という一次記録は、後から効きます。
落とし穴2: ホストの設定をそのまま渡すと壊れる
コマンドが入って起動したとして、次は「エージェントの振る舞いをホストと揃えたい」という話になります。permissions(何を許可し何を拒否するか)や hooks(イベントごとの処理)は、ホストで使い慣れた設定をそのまま持ち込みたい。
最初に考えるのは「ホストの設定ディレクトリを丸ごと bind mount すればいいじゃん」です。これは、ダメでした。
理由は設計判断としてはっきりしています。ホストの設定には macOS 依存のものが混ざっているからです。通知系の hook は macOS の osascript を呼ぶし、メモリ監視の hook は Python に依存していてコンテナには入っていない。ホスト固有のディレクトリパスを参照する設定もあるし、クレデンシャルファイルを共有すると rotation(更新)が競合する。丸ごと mount は、地雷の詰め合わせだったわけです。「全部持っていけば楽だろう」という発想がいちばん楽じゃなかった、という当たり前のオチでした。
採った解決策が、コンテナ専用の設定ファイルを分離して部分 mount する方式です。ホストの設定から派生させたコンテナ専用 settings を新設し、挙動をホストと揃えたうえで、コンタミした部分だけを引き算しました。
- 通知 hook(
osascript)・メモリ監視 hook(Python 未同梱)を削除 - ホストの skill ディレクトリに依存する hook を削除
- 追加参照ディレクトリの指定を、コンテナ内のパスに書き換え
- 通知系のフラグは
falseに - ただし permissions の allow / deny はホストとフル一致させる
ポイントは最後の一行です。「環境依存で動かないものは引く、でもセキュリティの肝である許可・拒否リストはホストと完全一致させる」。コンテナだから緩める、ではなく、コンテナだからこそ守りは揃える。設定ファイルそのものは読み取り専用(:ro)で部分 mount し、プロジェクトやメモリやクレデンシャルといった rotation 競合・ホスト衝突の火種になるものは、あえて mount しない方針にしています。
落とし穴3: トークンが daemon に届いていなかった
ここが今回いちばん「うわ、そこか」となった部分です。
AI エージェントをコンテナで動かすにあたって、課金まわりの設計判断を先に決めていました。バックグラウンドで何時間も走るエージェントを API 従量課金に乗せたら、月末の請求は実際どうなの? ——読めない請求書は、夜中にふと不安になるやつです。だから方針はシンプルにしました。API 課金ではなく、サブスクリプション枠(Pro $20/月、Max $100〜200/月。公式 pricing 参照)で動かすという方針です。バックグラウンド実行と agent 管理の経路はサブスク枠の中で使えるので、長く回るエージェントを API 従量課金に乗せずに済む。コスト構造がフラットになるのは、長時間タスクを前提にするなら大きい。
そのために、サブスク枠に紐づく長寿命の OAuth トークンを発行し、ホストからコンテナへ環境変数として forward する設計にしました。コンテナ専用のトークンを別途発行すれば、ホストのセッションとも分離できる。設計上はきれいです。
ところが、いざ Slack から叩いても、トークンがコンテナに渡らない。
原因は、daemon の起動経路にありました。ログイン時に gateway を自動起動する仕組み(launchd agent)が、daemon のバイナリを直接 exec していたため、.env ファイルが読み込まれていなかったのです。.env の中に書いたトークンが、そもそも環境変数として export される前に daemon が起動してしまっていた。
ここで「ホスト側の shell で source すればいい」という案も出たのですが、launchd 経由の起動では効きません。かといって launchd の設定に環境変数を直書きすると、Slack や他サービスのトークンまで全プロセスに撒かれて露出する。どちらも筋が悪い。
最終的には、.env を set -a で export してから本物のバイナリを exec する、薄い wrapper shell を一枚かませる解決にしました。launchd は wrapper を起動し、wrapper が .env を読んでから daemon を起動する。これでようやく、サブスク枠のトークンがコンテナまで透過するようになりました。env をグローバルに撒かず、対象を当該 daemon の .env 一個に限定できるのも安全側の判断です。
落とし穴4: 設定を変えたのに、反映されない
最後はおまけのような、でも実害のあったハマりです。
コンテナへの mount 設定(docker_volumes)を足して、イメージを再ロードしたのに、コンテナの中から設定ディレクトリが見えない。「あれ、さっき追加したよね?」となる。
犯人は daemon のライフサイクルでした。この gateway daemon は config を起動時にしか読まない設計で、セッションごとに reload しない。なので config を書き換えても、走りっぱなしの daemon は古い設定を握ったまま。launchctl kickstart で明示的に再起動して、ようやく反映される。
これは「config.yaml 変更時は daemon restart が必要」だと README に書き残しました。検証手段として、起動中コンテナの mount 一覧を docker inspect で確認するコマンドも併記しています。地味ですが、こういう「読み込みタイミングのズレ」は再発しやすいので、ハマり所はコード本体ではなくドキュメントに固定するのが一番効きます。
やってみて見えたこと
四つの落とし穴を順に踏みながら、いくつか方針として残ったものがあります。
隔離はやるなら設定もセットで考える。 コンテナで隔離する以上、ホストの設定はそのまま使えません。でも「使えないから捨てる」のではなく、「環境依存だけ引いて、セキュリティの核は揃える」という引き算の設計にすると、ホストとコンテナで挙動が揃ったまま隔離だけ得られます。設定ファイルの分離は手間ですが、丸ごと mount の地雷を踏むよりずっと安い。
長く回すエージェントは、課金構造から決める。 バックグラウンドで走り続けるエージェントを API 従量課金に乗せると、コストが読めなくなります。サブスク枠で動かせる経路を選び、そのためにトークンの受け渡し経路を整える。動かす前に「どの財布から出るか」を決めておくと、設計のあちこちがそれに沿って素直になります。
「反映されない」系は、ファイルでなく"読み手"を疑う。 設定を変えたのに効かないとき、ファイルの中身を何度見直しても無駄なことがあります。原因が「ファイルじゃなくて、それを読むプロセスのほう」だと、探す場所がそもそもズレているからです。今回の daemon は起動時しか config を読まないので restart するまで古いまま。ただこれは daemon に限らず、ファイルを watch して即 reload する派、SIGHUP で読み直す派、起動時に一発だけ読む派と、ツールごとにバラバラです。自分が毎日触っているそれがどの派なのか、即答できるものは案外少ない。そのライフサイクルを README に書いておくのが、未来の自分への一番の親切でした。
Slack から箱の中のエージェントを叩く、という構成自体はまだ実験段階です。ただ、ここで踏んだ「設定分離」「トークン forward」「読み込みタイミング」の三つは、AI エージェントをどこかに常駐させて運用するなら、たいてい誰でも踏む場所だと思います。同じところでつまずいた人の、ショートカットになれば。



