bash
イベント
該当するコンテンツが見つかりませんでした
マガジン
該当するコンテンツが見つかりませんでした
技術ブログ
はじめに こんにちは!サイオステクノロジーのなーがです。2026年7月上旬、いったん提供停止されていた Claude Fable 5 が再公開されましたね。Fable 5 は並列サブエージェントのディスパッチ・管理が得意とされる一方で高価なモデルなので、メインセッションの Fable にはオーケストレーション(計画・分解・統合)だけをさせて、実作業は安価なモデルのサブエージェントに委譲する運用が定石として広まっています。 私もこの流れに乗って、個人開発の Python プロジェクトで Claude Code のマルチエージェント運用を始めてみました。ところが、いざ動かしてみると「サブエージェントの多段委譲問題」と呼ばれる典型的な落とし穴にきれいにハマり、トークンを溶かすはめになりました。 参考: 「Claude Fable 5」が復活、7月7日まではプランの上限内で試用可能(窓の杜) / サブエージェント活用で Claude Fable 5 をコスパよく運用する(Zenn) 今回は、サブエージェントを設定してからこの問題を踏むまでの流れと、「プロンプトによる指示」と「hooks による機械的な拒否」の多層防御で解決するまでの経緯を、途中でやらかした hook 自身の誤検知も含めて、実際の実装とあわせて紹介します。 サブエージェントを設定する まずは私がやった設定から紹介します。Claude Code では、 .claude/agents/ にカスタムエージェントを定義しておくと、メインセッションが Agent ツールでそれらを呼び出せます。今回は役割を3つに分けました。 investigator (調査、haiku):複数ファイルにまたがる調査・検索。読み取り専用 implementer (実装、sonnet):設計確定後のコード編集・テスト・lint reviewer (レビュー、opus):コミット前の品質・セキュリティレビュー。読み取り専用 参考: Claude Code のサブエージェント(公式ドキュメント) エージェント定義はこんな感じです( investigator.md )。読み取り専用の役割なので、 tools を参照系に絞っています。 --- name: investigator description: 複数ファイルにまたがる調査・コード検索・現状把握を行う読み取り専用エージェント。 model: haiku tools: Read, Glob, Grep, Bash --- あなたは調査専門エージェント。 - 読み取り専用で動く。ファイルの作成・編集・削除はしない。 - 結論を先に、根拠となるファイルパスと行番号(`path:line`)を添えて報告する。 あわせて、メインセッションが迷わないように、CLAUDE.md に「エージェント委譲ルール」を書きました。ポイントは、役割の対応表を 固定パイプラインではない と明言することです。 ## エージェント委譲ルール メインセッションは設計・統括・結果検証に徹し、作業はサブエージェントに委譲する。 下記は役割の対応表であり、全タスクに強制する固定パイプラインではない: - 複数ファイルにまたがる調査・検索 → `investigator` - 設計確定後の実装(コード編集・テスト・lint) → `implementer` - コミット前のコードレビュー → `reviewer` 運用原則: - 同じ作業内容を複数のエージェントに順番にリレーしない。 1つのタスクで各役割のエージェントを使うのは最大1回ずつ。 前段の結果を丸ごと次段の課題として再送するのは禁止。 - 互いに独立したタスクは、1つのメッセージで複数のAgent呼び出しを 同時に発行して並列実行する。 - 不要なフェーズは飛ばす。調査が不要なら `investigator` を起動しない。 - 単一ファイルの軽微な修正はメインセッションが直接行ってよい。 「リレー禁止」「独立タスクは並列実行」「不要フェーズのスキップ」の3点セットまで書いて、これでメインの Fable は統括に専念、実作業は3役に振れる分業体制ができました。……はずでした。 早速つまずいた「サブエージェントの多段委譲問題」 運用を始めてすぐ、ダッシュボードまわりの1タスクで様子がおかしくなりました。呼び出しツリーを見ると、メインセッションから呼ばれた implementer が(本来は禁止のはずの)Agent ツールでさらに implementer を呼び、それがまた次へ……と、気づけば 5段ネスト 。各段が2.6万トークン前後を消費し、最深段は6.5万トークンに達していました。CLAUDE.md にわざわざ「リレー禁止」と書いたのに、です。この呼び出しツリーを見つけたときは、さすがに焦りました。 あとで知ったのですが、これは「 サブエージェントの多段委譲問題 」として知られる典型的な失敗でした。委譲の「深さ」と「回数」を放任すると起きるもので、整理すると原因は「 誰が誰を呼ぶか(深さ) 」と「 何を渡すか(内容) 」という2つの軸に分かれます。 再委譲: サブエージェントがさらにサブエージェントを呼ぶ 1つ目は 構造(深さ)の問題 です。委譲されたサブエージェントが自分でも Agent ツールを使い、さらに別のサブエージェントへ仕事を投げてしまうケースです。 こうなると、実際に手を動かしているのが誰なのかメインセッションから見えなくなります。孫エージェントはユーザーの元の意図を知らないまま作業するのでコンテキストが失われ、結果の品質も制御できません。 トークン消費の面でも、単に段数に比例して増えるだけでは済みません。本来不要な中間層のエージェントが1つずつ起動すること自体がコストで、各中間層はコンテキストの読み込み・状況把握・指示の再構成といった同じような処理を重複して行います。つまり、中抜きすれば丸ごと不要だったはずのコストが、層の数だけ積み上がっていくわけです。 多段リレー: 前段の結果を丸ごと次段に再送する 2つ目は 内容の問題 で、もう少し気づきにくいです。メインセッションが「調査 → 実装 → レビュー」をパイプラインだと思い込み、 前段のエージェントが返した長大な結果をほぼそのまま次段のプロンプトに貼り付けて 順送りしてしまうケースです。 一見それらしく動いているのですが、実態は同じテキストがセッション内を何往復もしているだけです。本来メインセッションがやるべき「結果を咀嚼して、次のフェーズに必要な情報だけを渡す」という仕事が抜け落ちており、次のような問題が起きます。 結果の劣化 : 各エージェントが要点の抽出をサボり、丸投げの連鎖になる トークン消費 : 長文が段数ぶん重複して送られ、コストが跳ね上がる 制御不能 : 不要なフェーズ(調査不要のタスクでの investigator 起動など)まで律儀に実行される 冒頭の5段ネストは、まさにこの2つが同時に噴き出した状態でした。 implementer が implementer を呼んでネストが深くなっている点は再委譲そのもの、渡している内容がほぼ同じ点はリレーそのもの です。「深さ」と「内容」という別々の軸なので、原因が違えば防ぎ方も層で分かれます。これは仕組みで止めるしかありません。次章から、その対策を見ていきます。 解決アプローチ: プロンプトと hooks の多層防御 対策は1つではなく、階層の異なる4つを重ねています。すでに設定時に書いた CLAUDE.md の委譲ルールが1つ目のプロンプト層で、ここにエージェント定義の制約(もう1つのプロンプト層)と、2つの hooks(機械的な強制)を足していきます。先ほどの2軸に対応づけると、 再委譲(構造)は主にエージェント定義のツール制限で根本から止め、多段リレー(内容)は主に hook で止める という役割分担になっています。 関連ファイルの構成は以下のとおりです。 .claude/ ├── agents/ │ ├── investigator.md # 調査担当(読み取り専用) │ ├── implementer.md # 実装担当 │ └── reviewer.md # レビュー担当(読み取り専用) ├── hooks/ │ ├── agent-relay-guard.sh # PreToolUse: リレー検出・拒否 │ └── agent-turn-reset.sh # UserPromptSubmit: 履歴リセット ├── tests/ │ └── test_agent_relay_guard.py # hook の挙動テスト ├── agent-calls/ # Agent呼び出し履歴(セッションごとのJSONL、gitignore対象) └── settings.json # hooks の登録 この一式は、そのまま .claude/ に置いて使える最小構成のサンプルとして GitHub で公開 しています。hook・テスト・エージェント定義がそろっているので、動かしな がら読むとわかりやすいと思います。 エージェント定義に再委譲禁止を明記する 設定時に CLAUDE.md へ書いた委譲ルールだけでは足りませんでした。そこで .claude/agents/ の各エージェント定義にも、再委譲を禁止する制約を追記しました。先ほどの investigator.md に、次の「## 制約」セクションを足した形です。 ## 制約 - 他のサブエージェントを呼び出さない(Agentツール使用禁止)。 タスクが担当範囲を超える場合は、その旨を報告して終了する。 - 報告は結論と根拠のみを簡潔に。調査ログや試行過程を全文貼り付けない。 この「制約」セクションは implementer.md / reviewer.md にも同じ文面で入れています。ポイントは2つです。 Agent ツール使用禁止 を明記し、担当範囲を超えたら「報告して終了」という逃げ道を用意する(禁止だけだと無理に自力で解決しようとするため) 報告を「結論と根拠のみ」に絞る。 前段の報告が短ければ、そもそも丸ごとリレーする材料が生まれにくい なお investigator と reviewer は frontmatter の tools で使えるツール自体を読み取り系に絞っており、そもそも Agent ツールを持たせていません。プロンプトの制約とツール制限の二重がけです。 再委譲(サブエージェントがさらにサブエージェントを呼ぶ構造)を根本から止めているのは、実はこのツール制限です。 Agent ツールを持っていなければ、そもそもネストのしようがありません。この後の hook は、主にもう一方の軸である多段リレー(内容の丸ごと再送)を担当します。 ただし、ここまでは全部「お願い」です。CLAUDE.md もエージェント定義もプロンプトの一部でしかないので、コンテキストが長くなると平気で忘れられるんですよね。実際、明文化した後もリレーは散発しました。そこで hooks の出番です。 agent-relay-guard: PreToolUse hook でリレーを拒否する 本丸が agent-relay-guard.sh です。PreToolUse hook を Agent ツールにマッチさせ、 Agent 呼び出しが実行される前に リレーかどうかを判定して、リレーなら拒否します。 参考: Claude Code の hooks(公式ドキュメント) 実体は bash スクリプトですが、bash 部分は薄いラッパーで、判定ロジック本体はスクリプト内にヒアドキュメントで埋め込んだ Python コード( PYSCRIPT )を python3 -c に渡して実行しています(抽出〜判定〜履歴の記録までを1つの Python プロセスに一本化し、ロジックの二重管理を避けるためです)。以降のコード例は、この Python 部分からの抜粋です。 仕組みはシンプルで、セッションごとの Agent 呼び出し履歴を .claude/agent-calls/<session_id>.jsonl に記録しておき、新しい呼び出しのたびに履歴と突き合わせます。 実は最初に作ったバージョンは、この履歴を 同一セッション内でずっと 持ち続ける実装でした。これがあとで誤検知の原因になるのですが、それは後述するとして、まずは判定ロジックを見ていきます。判定は2つです。 判定1: 同一役割への2回目の呼び出しを拒否する investigator / implementer / reviewer の3役については、履歴に同じ役割の呼び出しが残っていれば一律拒否します。CLAUDE.md の「1つのタスクで各役割は最大1回ずつ」をそのまま機械化したものです(以下、最終版の該当部分の抜粋)。 ROLE_NAMES = {"investigator", "implementer", "reviewer"} if subagent_type in ROLE_NAMES: for entry in history: if entry.get("subagent_type") == subagent_type: # この reason が最終的に deny の JSON として stdout に出力される reason = ( f"同一タスク内で役割 '{subagent_type}' への2回目以降のAgent呼び出しは" f"サブエージェントの多段リレー防止のため拒否します。..." ) break この判定は、冒頭で挙げた 再委譲のバックストップ も兼ねています。 implementer は( investigator / reviewer と違って)ツール制限をかけていないため Agent ツールを持っており、プロンプトの禁止をすり抜けて implementer が implementer を呼ぶ再委譲が起こり得ます。そのネストも「同じ役割の2回目」としてここで引っかかるので、エージェント定義のツール制限を主とし、この hook が二段目の網になります。 判定2: 直前呼び出しとの prompt 類似度で「丸ごと再送」を拒否する 役割が違っても、直前の Agent 呼び出しと prompt がほぼ同じなら、それは前段の結果の丸ごと再送です。prompt を行単位に正規化(前後空白を除去し空行を捨てる)した上で、 行集合の Jaccard 係数 を計算し、0.7 を超えたら拒否します。Jaccard 係数は、2つの集合の共通要素が全体(和集合)に占める割合を表す 0〜1 の類似度指標です(以下、該当部分の抜粋)。 SIMILARITY_THRESHOLD = 0.7 def line_similarity(a_lines: list[str], b_lines: list[str]) -> float: set_a, set_b = set(a_lines), set(b_lines) union = set_a | set_b if not union: return 0.0 return len(set_a &amp; set_b) / len(union) 「前段の結果を丸ごと貼って、末尾に指示を1行足しただけ」のようなプロンプトは、行の大半が共通するので類似度が高く出ます。逆に、フェーズごとに内容を咀嚼して書き直したプロンプトなら共通行はほとんど残らないので通過します。 文字単位ではなく行単位の集合比較にしているのは、この「コピペ再送」の検出に特化するためです。こちらの判定は3役に限らず general-purpose などすべての subagent_type に効きます。 拒否時は、PreToolUse hook の JSON 出力で permissionDecision: "deny" を返します。出力の構造は次のとおりで、理由の全文は後述の「実際の挙動」で紹介します。 { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "(拒否理由のテキスト)" } } 設計上の工夫: ガードは fail-open に倒す このガードで一番気を使ったのが異常系の扱いです。開発を止めないことが最優先なので、 判定に必要な情報が揃わないケースはすべて許可側に倒す(fail-open) 設計にしています。 stdin が空・JSON が不正 → 許可 session_id が取れない(セッション単位で状態を分離できない) → 許可 状態ディレクトリが作れない・履歴ファイルが書けない → 許可 さらに、誤検知したときのために 環境変数によるバイパス を用意しています。 AGENT_RELAY_GUARD_DISABLE=1 これをセットするとチェックも履歴の記録もすべてスキップされます。拒否メッセージ自体にこのバイパス方法を書いてあるのもポイントで、誤検知に遭遇した未来の自分(や Claude)がその場で回避策にたどり着けます。 ここまでが対策の第1弾です。これで一件落着……と思いきや、導入してみると今度は ガード自身が誤検知 を起こしました。 agent-turn-reset: ユーザー発言をタスク境界として履歴をリセットする 誤検知の症状はこうです。初版のガードは履歴をセッション単位で持っていたため、同一セッション内では役割ごとに1回しか Agent を呼べず、 独立した別タスクなのに2回目以降の呼び出しが拒否される 。午前中に implementer を使ったせいで、午後の全く別の修正依頼で implementer が呼べない、という状態です。これは明らかにおかしいですよね。 原因は判定単位のズレでした。CLAUDE.md のリレー禁止ルールは「1つのタスクで各役割は最大1回ずつ」という タスク単位 のルールなのに、初版のガードはこれを セッション単位 で判定していたのです。ルールを機械化するときは、条件だけでなく 適用単位 まで正確に写し取る必要がありました。 そこで第2弾の修正として、判定単位をセッションからタスクへ揃えました。 「ユーザーの新しい発言 = 新しいタスクの開始」 とみなし、UserPromptSubmit hook( agent-turn-reset.sh )でそのセッションの呼び出し履歴を削除します。 # UserPromptSubmit hook: ユーザーの新しい発言 = 新しいタスクの開始とみなし、 # agent-relay-guard.sh が使う「そのタスク内のAgent呼び出し履歴」をリセットする。 # 古い(7日以上前の)セッション状態ファイルを掃除する。 find "$STATE_DIR" -maxdepth 1 -name '*.jsonl' -mtime +7 -delete 2>/dev/null || true safe_session=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9_.-' '_') rm -f "$STATE_DIR/$safe_session.jsonl" 2>/dev/null || true exit 0 あわせて、ガード側の履歴にも TTL(既定30分 = 1800秒、 AGENT_RELAY_GUARD_TTL_SEC で変更可)を導入し、リセットが何らかの理由で動かなかった場合も古い履歴を引きずらないようにしました。拒否メッセージも「同一タスク内」という表現に改め、「独立した別タスクなら、ユーザーの次の指示以降は再び呼び出せます」という再呼び出し可能な条件を明記しています。 これで「同一セッションでも、別タスクなら同じ役割を再び呼び出せる」という自然な挙動になりました。この hook もノンブロッキングで、何が起きても exit 0 します(リセットに失敗しても TTL が最終的に古い履歴を無効化してくれます)。 地味な注意点として、履歴を書く側(agent-relay-guard)と消す側(agent-turn-reset)で状態ディレクトリの既定値を 同じ導出方法で 揃える必要があります。ここがすれ違うと、リセットが効かずに誤検知が復活します。実装では両方とも hook スクリプト自身の位置からリポジトリルートを導出しており、この契約はテストで検証しています。 振り返ると、プロンプトで守らせられないルールを hook で機械化したら、今度は hook 側の「判定単位バグ」と付き合うことになったわけです。機械化は誤検知とセットで考え、fail-open・バイパス・境界でのリセットといった逃げ道を最初から用意しておくことの大切さを痛感しました。 実際の挙動 2つの hook は .claude/settings.json に次のように登録します。 { "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-turn-reset.sh\"", "timeout": 10 } ] } ], "PreToolUse": [ { "matcher": "Agent", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-relay-guard.sh\"", "timeout": 10 } ] } ] } } hook 単体の挙動は、JSON を stdin に流せば手元で確認できます。同じセッション ID で implementer を2回呼んでみます。 echo '{"session_id":"demo","tool_name":"Agent","tool_input":{"subagent_type":"implementer","prompt":"課題Aの実装をお願いします。"}}' \ | .claude/hooks/agent-relay-guard.sh 1回目は何も出力されず終了コード0(許可)です。続けて2回目を実行すると deny の JSON が返ります。 { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "同一タスク内で役割 'implementer' への2回目以降のAgent呼び出しはサブエージェントの多段リレー防止のため拒否します。独立した別タスクなら、ユーザーの次の指示以降は再び呼び出せます。誤検知の場合は環境変数 AGENT_RELAY_GUARD_DISABLE=1 で一時的に無効化できます。" } } 類似度判定に引っかかった場合は、理由に類似度の実測値が入ります。 investigator の報告を丸ごと implementer に再送しようとしたケースでは、こんなメッセージで拒否されました。 直前のAgent呼び出し(役割: investigator)とpromptの類似度が高く(0.83 > 0.7)、 前段の結果の丸ごと再送とみなし拒否します。 誤検知の場合は環境変数 AGENT_RELAY_GUARD_DISABLE=1 で一時的に無効化できます。 実セッションでは、Claude が Agent ツールを呼ぼうとした瞬間にこの deny が割り込み、ツール実行はブロックされます。Claude 側には拒否理由がそのままフィードバックされるので、Claude は「リレーが禁止されている」ことをその場で理解し、前段の結果を自分で咀嚼して直接作業を進めるか、次フェーズ用にプロンプトを書き直す方向に軌道修正します。 つまり、 拒否理由がそのまま Claude への行動指針になる ように文面を書いておくのがコツです。 実際にガードが拒否したときの様子がこちらです。拒否理由が赤字のエラーとして表示され、直後に Claude が「自分は implementer なのだから Agent ツールを呼ぶべきではない、直接実装しよう」と軌道修正しています。 なお、こうした hook はエージェントの動作を止めうるものなので、テストを書いておくと安心です。このリポジトリでは「同一役割の2回目は拒否」「ユーザー発言後は再び許可」「別セッションには影響しない」「TTL切れの履歴は無視」「壊れた JSON は許可(fail-open)」といった分岐を unittest で網羅しています。 追記: リレーガードが並列実行を止めてしまった ここまでで一段落……と思っていたのですが、しばらく運用するうちに、このガード自身にもう1つ穴が見つかりました。今度は誤検知どころか、 推奨していたはずの並列委譲まで巻き添えで止めてしまう という、なかなか根の深い問題でした。この後日談も含めて共有します。 症状はこうです。設定時の CLAUDE.md には「互いに独立したタスクは、1つのメッセージで複数の Agent 呼び出しを同時に発行して並列実行する」と書いていました。ところがいざ独立タスクを並列で投げると、 implementer を3本同時に発行したうちの 2本目以降がガードに拒否される のです。前述の「判定1: 同一役割への2回目の呼び出しを拒否する」が、並列に発射した兄弟呼び出しまで「2回目」と数えてしまっていました。リレーを止めるつもりのガードが、自分で推奨した並列委譲を殺していたわけです。 なぜ並列とリレーを取り違えたのか 原因は、判定が 回数と順序 だけを見ていたことでした。「同一役割の2回目」も「直前の呼び出しとの類似度」も、時間的な前後関係しか見ていません。しかし、そもそも 本物のリレーの定義は「前段が 完了 し、その結果を受け取ってから、その内容を次段へ渡す」こと です。この「完了してから」という条件がすっぽり抜けていました。 1つのメッセージで同時に発射した並列の兄弟呼び出しは、まだ誰も完了していません。それを「同じ役割の2回目」と数えてしまったのが取り違えの正体でした。リレーと並列は、回数で見ると区別がつかないのです。 判定対象を「完了済みの呼び出し」だけに絞る そこで判定の軸を回数から 完了 へ切り替えました。 比較対象を「完了済みの Agent 呼び出し」だけに限定する のがポイントです。 こうすると並列は構造的に必ず許可されます。1メッセージで同時発行した兄弟たちは、お互いまだ完了していないので、判定時点で比較対象がゼロ。比較する相手がいなければ、リレー判定のしようがなく素通りします。役割ごとの回数制限はきれいに撤廃し、「何回呼んだか」ではなく「完了した前段の内容を使い回しているか」という 内容ベース の判定に置き換えました。 「完了」は SubagentStop で捉える(PostToolUse ではない) ここで地味に嵌まったのが、「完了」をどのイベントで捉えるかです。素直に考えると Agent ツールの PostToolUse (実行後)が完了に思えますが、これは 罠 でした。 Claude Code のサブエージェントは既定でバックグラウンド実行されるため、 PostToolUse は 起動が返った瞬間 ( tool_response.status が async_launched )に発火します。つまり実処理の完了ではなく、あくまで「起動できた」の合図です。実測すると、 PostToolUse は起動の約0.4秒後、並列呼び出しどうしの間隔は約0.8秒、実際の完了イベントは約6秒後でした。 PostToolUse を完了とみなすと、並列2本目の PreToolUse より前に「1本目は完了済み」と誤認してしまい、また並列が壊れます。 そこで 「完了 = SubagentStop 」 と定義し直しました。役割を3つの hook に分けます。 PostToolUse (Agent、 agent-call-record.sh )… その呼び出しの prompt と agent_id の紐付けを記録する(起動時点。 完了ではない ) SubagentStop ( agent-call-complete.sh )… サブエージェントの最終報告テキストと 完了 を記録する PreToolUse (Agent、 agent-relay-guard.sh )… 上記2つが書いた記録を読んで判定する 判定側が見るのは、 SubagentStop が書いた「完了済み」の記録だけ。これで初めて、並列の兄弟が互いを完了済みとみなさないことが保証されます。 deny と ask を使い分ける 内容ベースに寄せたことで、判定は2段階になりました。 完了済みエージェントの「出力」を丸ごと貼り付けて再送 している(出力の行が高い割合で prompt に含まれる) → 強い証拠なので deny 完了済みエージェントの「 prompt 」の使い回し(行集合の類似度が高い) → グレーなので ask 以前は類似度が高ければ一律 deny でしたが、ここを ask(確認)に緩めました。似た前置き(リポジトリの説明やテストコマンドなど)を共有する独立タスクを、うっかり殺さないためです。ask なら「これは独立した別作業です」と承認してそのまま続行できます。あわせて、箇条書き記号やコードフェンスのような 短い定型行だけの偶然の一致 で誤検知しないよう、比較する行に最小文字数・最小行数の下限も設けました。 状態設計を作り直し、再委譲の深さは公式の仕組みに任せる 判定単位をタスクに揃えるために前章では agent-turn-reset.sh で履歴を削除していましたが、この作り直しでその hook 自体が不要になりました。状態を 1呼び出し1ファイル ( <session>/<prompt_id>/<id>.{start,call,done}.json )に分解し、 タスク境界を prompt_id で表現 するようにしたためです。ユーザーの新しい発言は新しい prompt_id 、つまり別ディレクトリになるので、履歴は削除しなくても自動的に切り替わります。1ファイル1呼び出しなのでロックなしで並列安全になり、「拒否された呼び出しが次の判定を巻き込む」カスケードも消えました。かつての削除リセット hook は、古いセッションを掃除するだけの agent-calls-gc.sh に縮小しています。 もう1つ、内容ベースに寄せたことで、以前は「判定1」が兼ねていた 再委譲(ネスト)のバックストップ が外れました。これは公式の仕組みに委ねます。サブエージェント内からの呼び出し(入力に agent_id が入る)はガードの判定対象外にし、入れ子の深さ制限は Claude Code 公式の環境変数 CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH に任せることにしました。 .claude/settings.json の env で 1 に設定しています。 { "env": { "CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH": "1" } } hook の登録も、記録用の2つ( PostToolUse / SubagentStop )が増え、 UserPromptSubmit は掃除用の agent-calls-gc.sh に差し替わります。 { "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-calls-gc.sh\"", "timeout": 10 } ] } ], "PreToolUse": [ { "matcher": "Agent", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-relay-guard.sh\"", "timeout": 10 } ] } ], "PostToolUse": [ { "matcher": "Agent", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-call-record.sh\"", "timeout": 10 } ] } ], "SubagentStop": [ { "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-call-complete.sh\"", "timeout": 10 } ] } ] } } 結果として、防御は「 ツール制限(エージェント定義)+ 深さ上限(公式の env)+ 内容判定(hook) 」という、役割がきれいに分かれた3枚構成に落ち着きました。回数で殴るのをやめて「完了」と「内容」で見るようにしただけで、並列委譲もリレー防止も両立できたのは、我ながらスッキリした着地でした。 さいごに Claude Code のマルチエージェント運用で起きた「再委譲」と「多段リレー」を、多層の防御で解決するまでの試行錯誤の話でした。要点をまとめます。 問題は2軸あり、 再委譲 (構造・深さ: サブエージェントがサブエージェントを呼ぶ)と、 多段リレー (内容: 前段の結果を咀嚼せず丸ごと再送する)。似て見えるが原因が違う。 防御は役割で分ける。 再委譲はエージェント定義のツール制限+公式の CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH で深さを止め、 多段リレーは PreToolUse hook(agent-relay-guard) が内容を見て止める リレーと並列は回数では区別できない。判定を「回数」から「 完了 」へ切り替え、比較対象を完了済み(SubagentStop)の呼び出しだけに絞ることで、並列の同時発行を構造的に常に許可しつつリレーだけを止める 内容判定は2段階。完了済みの 出力の丸ごと再送は deny 、prompt の使い回しは ask にして、似た前置きを共有する独立タスクを殺さない 一般化すると、 プロンプト指示だけでは守られないルールは、hook で機械的に強制する というのが今回の教訓です。CLAUDE.md に何を書いても、それはあくまで「お願い」であり、コンテキストが長くなれば忘れられます。破られると困るルールほど、hook のような決定的な仕組みに落とすべきで、その際は fail-open とバイパスをセットで用意しておくと運用が破綻しません。 ちなみに、エージェント定義に「再委譲禁止の記述が存在すること」自体は、自作の Linter である agentlint(ルール AL401)で静的に検証するようにしています。agentlint については 別の記事 で詳しく紹介しているので、あわせてどうぞ。 サブエージェントの委譲制御に悩んでいる方は、いきなり拒否まで作り込まなくても、まずは PreToolUse hook で Agent 呼び出しをログに記録するところから試してみてください!それでは! ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Claude Code でサブエージェントが5段ネスト!?トークンを溶かす多段委譲をハーネスで防ぐ! first appeared on SIOS Tech Lab .
PSSL の佐々木です 移動中に Claude Code を回しておきたいのですが、満員電車でノート PC を開くのは無理です。カバンに入れたまま処理だけ走らせておきたいです。 そうすると解決したい問題が 2 つ出てきます。 フタを閉じるとスリープして処理が止まる テザリングだとスマホのバッテリーが減るうえ、PC とスマホが不意に離れると Wi-Fi が切れる この 2 つを潰す設定をまとめます。Windows 側はすべて PowerShell で完結させます。 最近はPCがスリープしないようにちょっとだけPCを開けたまま手に持ち歩く人もいるみたいですが、PCを落としたり普通に邪魔なのでカバンに入れた状態でもClaudeが動くための環境を作りました。 この設定は普通に会社から怒られるかもしれないので自己責任でお願いします。 1. フタを閉じても止めない 管理者権限の PowerShell で実行します。 powershell # フタを閉じたときの動作を「何もしない」に(バッテリー駆動時) powercfg /setdcvalueindex SCHEME_CURRENT SUB_BUTTONS LIDACTION 0 # 電源接続時も同様 powercfg /setacvalueindex SCHEME_CURRENT SUB_BUTTONS LIDACTION 0 # スリープ移行そのものを無効化(0 = 無効) powercfg /change standby-timeout-dc 0 powercfg /change standby-timeout-ac 0 # ディスプレイは切ってよい(1分) powercfg /change monitor-timeout-dc 1 # 反映 powercfg /setactive SCHEME_CURRENT LIDACTION の値です。 0 ... 何もしない 1 ... スリープ 2 ... 休止状態 3 ... シャットダウン 現在値の確認はこちら。 powershell powercfg /query SCHEME_CURRENT SUB_BUTTONS powercfg /a # モダンスタンバイ対応かの確認 WSL 側も落ちないようにしておきます。 %USERPROFILE%\.wslconfig に追記して wsl --shutdown 。 ini [wsl2] vmIdleTimeout = -1 処理自体は tmux の中で回しておくと、復帰後にそのまま続きが見られます。 bash tmux new -s claude claude --dangerously-skip-permissions # Ctrl+b → d でデタッチ Macの場合はさらに簡単でamphetamineというアプリを入れておくと蓋を閉じてもスリープしなくなります。 https://apps.apple.com/jp/app/amphetamine/id937984704?mt=12 2. テザリングをやめてリチャージWi-Fiにする テザリングをやめる理由は 2 つです。スマホのバッテリーが目に見えて減ること、そして満員電車で体勢が変わって PC とスマホが離れると Wi-Fi が切れることです。これを解決するためにリチャージWi-Fiを買いました。 リチャージWi-FiはTypeCをPCに直刺しするとネットにつながります。(特に設定もなく、さしてしばらくするとネットにつながるようになります。) リチャージWi-Fiは100GB8000円ぐらいから売っていて、100GB使い切ったらポータルサイトでチャージするとまた使えるようになります。 https://www.amazon.co.jp/【リチャージWiFi】バッテリーレス-100GB-リチャージ-一体型【M4-100GB-365日】/dp/B0DXVNKZ8T/ref=asc_df_B0DXVNKZ8T?mcid=c2d7374fee12300a9c74a8ba8ade9134&tag=jpgo-22&linkCode=df0&hvadid=707549940401&hvpos=&hvnetw=g&hvrand=8194794017880763317&hvpone=&hvptwo=&hvqmt=&hvdev=c&hvdvcmdl=&hvlocint=&hvlocphy=1009285&hvtargid=pla-2444400325634&psc=1&hvocijid=8194794017880763317-B0DXVNKZ8T-&hvexpln=0 カバンにPCを入れるとこんな感じです。リチャージWi-Fiが映えますね カバンが汚いのは見なかったことにしてください。 3. 注意:フタを開けたら充電が空になっていることがある ここまでやると、フタを閉じてもディスプレイが消えるだけで中身は全力で動き続けます。当然ながら バッテリーの減りはかなり速い です。 私は移動中に走らせたまま忘れて、目的地でフタを開けたら電源が落ちていた経験があります。 客先での商談がある場合には注意が必要が必要です。 そのため、 カバンにはモバイルバッテリー(PD 対応)を一緒に入れておく 長時間走らせるときは充電しながらにする 移動が終わったら設定を戻す 4. まとめ 満員電車で立ったまま何もできない時間が、そのまま実行時間になります。設定は 10 分で終わるので、通勤が長い方は一度入れておくと効きます。 次は軽くて大容量のバッテリーが欲しいです。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 1人がこの投稿は役に立ったと言っています。 The post 満員電車でも Claude Code を動かし続ける技術 first appeared on SIOS Tech Lab .
こんにちは、サイオステクノロジー武井です。 AI コーディングエージェントを使い始めて、ふと不安になったことはないでしょうか。 「このAI、プロジェクトの中を自由に読めるけど…… .env みたいな機密情報も読めてしまうのでは?」 そうなんです。読めちゃうんです。しかも、ただ読めるだけでは終わりません。読んだ内容がドキュメントに紛れ込んだり、コミットに含まれたりして、 GitHub リポジトリ経由で外部に漏れる ところまで想像すると、なかなか怖い話です((((;゚Д゚))))ガクガクブルブル この記事では、その対策として用意されている Claude Code の権限機能とサンドボックス機能を取り上げます。ただ「こう設定すれば安全」という話で終わらせるのではなく、 なぜその機能が必要なのか、どこまで守れて、どこに穴が残るのか を、OS の仕組みまで降りて整理していきます。途中で Linux カーネルの fork / exec という基本機能まで出てきますが、そこが腑に落ちると、サンドボックスが「なぜそういう形をしているのか」まで見通せるようになります。 なお、以降は Claude Code を題材に話を進めますが、 基本的な仕組みは他のコーディングエージェントでも多分同じ です。設定ファイルの名前や項目名は製品ごとに違いますが、どれも「エージェント本体のプロセスがファイルを読む」「シェルコマンドを子プロセスとして起動する」という同じ形で動いていて、OS が用意している道具立て(macOS の Seatbelt、Linux の namespace など)も共通です。この記事で扱うのは、その 共通部分の構造 です。「アプリが自分で我慢しているのか、OS が強制しているのか」という見分け方を持っておけば、お使いのツールがどちらの層で守ってくれているのかも、同じ物差しで判断できるようになります。 何も気をつけないと、AIは機密情報を読んでしまう まず、問題の全体像を確認します。 多くのプロジェクトでは、API キーやデータベースの接続文字列といった秘密情報を .env ファイルに書いて、プロジェクト直下に置いています。ローカルで開発する分には便利な運用です。 ところが、AI コーディングエージェントはプロジェクト内のファイルを読んで文脈を理解します。つまり、 放っておくと .env も読める ということです。そして厄介なのは、読んだ後に何が起きるか予測しづらい点です。 「README を書いて」と頼んだら、環境変数の設定例として本物のキーが埋め込まれる コード生成の過程で、秘密情報を含むファイルを参照した内容がコメントに残る それらをコミットして git push した瞬間、 公開リポジトリに秘密が載る 「AIが勝手に漏らした」というより、「AIが読めたものが、生成物を経由して、いつの間にか外に出ていく」という流れです。悪意がなくても起きるからこそ、仕組みで防いでおく必要があります。 まず思いつく対策 ―― でも、これには穴がある Claude Code には、こうした事故を防ぐための権限機能があります。 settings.json の permissions.deny に、読ませたくないファイルを書いておく方法です。 { "permissions" : { "deny" : [ "Read(./.env)" , "Read(./.env.*)" ] } } これで Read ツールから .env へのアクセスがブロックされます。 Glob や Edit など、ファイルに触れる他のツールについても同様に指定できます。 一見これで安心に見えますが、 大きな穴があります 。 permissions.deny がブロックしているのは、あくまで Claude Code というプログラム自身が持つ機能(Read ツールなど) です。言い換えると、これは アプリケーションが自分で「このファイルは読まない」と自制しているだけ なのです。 では、その自制を回避する経路があったらどうなるでしょうか。あります。そう、それは、、、、なんと!! Bash ツール です。 cat .env も diff .env other も、Read ツールを使っていません。 別プロセスとして起動されるシェルコマンド です。だから「Read ツールを塞ぐ」というアプリレベルの自制は、ここには効きません。正面玄関に鍵をかけても、裏口が開いている状態です。 この「裏口」を塞ぐために登場するのが、サンドボックスです。 サンドボックスとは何か ―― システムコールそのものを止める アプリの自制ではなく、OSの強制 permissions.deny が「アプリの自制」だったのに対し、サンドボックスは OS レベルの強制 です。 Claude Code が Bash コマンドを実行するとき、そのコマンドを サンドボックスで包んで起動 します。すると、そのコマンドは「特定のファイルにアクセスするシステムコール自体を呼べない」状態になります。 cat だろうが diff だろうが Python スクリプトだろうが、ファイルを読むには必ず OS(カーネル)に open() というシステムコールを発行しなければなりません。サンドボックスはこの カーネルへの入り口 で検問を張るので、どんなプログラムを使っても迂回できません。 どのレイヤーで検問しているのか 「アプリの自制」と「OS の強制」は、ソフトウェアの層で見るとまったく違う場所に立っています。図にすると、こうです。 上から順に見ていきます。 アプリケーション層 : permissions.deny はここにいます。この層の特徴は、 判定ロジックも判定対象も同じプロセスの中にある ことです。Claude Code が「 .env を読もうとしている自分」を、自分のコードで止めている。つまり自主規制です。自主規制の弱点は明確で、 そのルールを持っていないコードに仕事を渡せば、ルールは適用されない こと。 cat .env は Claude Code のコードではないので、 deny の判定ロジックをそもそも通りません。 システムコールの境界 : プロセスは自分の中では何でもできますが、 ファイルを読む、ネットワークに出る、といった「外の世界に触る操作」だけは自分では実行できません 。必ず open() connect() といったシステムコールでカーネルに依頼する必要がある。ここがプロセスの自由が終わる線です。 そして重要なのは、この線が プログラムの種類に依存しない こと。 cat でも diff でも Python でも、自作のバイナリでも、ファイルを読むなら全員この一本の線を越えます。だから、この線の上に検問を置けば、 「別のプログラムを使う」という迂回が原理的に成立しません 。アプリ層の検問が「使うツールを変える」で抜けられたのと、ちょうど対照的です。 OS カーネル層 : 実際の判定はこの層で行われます。 sandbox-exec が登録したポリシーは カーネル側にそのプロセスの属性として記録され 、以降そのプロセスがシステムコールを発行するたびにカーネルが照合します。 ここで立場が逆転しています。アプリ層では「自分が自分を止めていた」のに対し、カーネル層では 止める側が、止められる側から手出しできない場所にいる 。だから「自制」ではなく「強制」と呼べるわけです。実装は OS ごとに違いますが、性質は同じです。 macOS (Seatbelt) : カーネル内のフックでシステムコールごとにポリシーを照合し、許可されないアクセスを拒否する Linux (bubblewrap) : namespace を使って、 そのプロセスから見えるファイルシステムの景色自体を作り替える 。拒否する以前に、 .env が存在しない世界を見せる 前者は「頼んでも断られる」、後者は「頼む対象がない」という違いですが、どちらも プロセスの外側で決着している 点が共通です。 ハードウェア層 : ここが「関与しない」になっているのは、単なる余白ではなく 限界の宣言 です。サンドボックスの強制力は「カーネルが正しく動いていること」に全面的に依存しています。カーネルの脆弱性を突かれて特権を取られた場合、判定者そのものが倒れるので、サンドボックスも一緒に倒れます。 つまりサンドボックスは、 「うっかり cat .env される」を確実に防ぐ道具 であって、「カーネルまで攻略してくる攻撃者」を想定した壁ではない、という位置づけです。 この「境界の上か下か」という視点は、後半で出てくる抜け穴を理解するときにも効いてきます。 どのプロセスに効いているのか では、その強制は Claude Code のどのプロセスに効いているのでしょうか。中の構造はこうなっています。 ポイントは、 Bash ツールは「起動時に明示的にサンドボックスでラップする」から拘束される という点です。裏を返せば、ラップされていないものには効きません。ここが後半の伏線になります。 なぜ Read ツールにはサンドボックスが効かないのか 「システムコールを止めるなら、Read ツールも止められるのでは?」と思うかもしれません。ここが理解の勘所です。 サンドボックスは プロセスに貼り付ける制約 です。プロセスが生成される瞬間に適用され、そのプロセスと、さらにその子プロセスに効きます。 一方、Read ツールは Claude Code 本体プロセスに組み込まれた機能 で、 子プロセスを作りません 。本体が自分で open() を呼んでファイルを読むだけです。つまり、サンドボックスを「貼り付ける対象となる新しいプロセス」がそもそも生まれない。だから、Read ツールを守りたければ本体ごとサンドボックスに入れるしかなく、それをやると本体の正常動作(設定の読み書きや通信)まで壊れてしまいます。 だから役割分担になっているのです。 Read などの本体機能 → permissions.deny (アプリの自制)で守る Bash が起動する子プロセス → サンドボックス(OSの強制)で守る 両方揃えて初めて、正面玄関も裏口も塞がる わけです。 設定はこう書く ―― サンドボックスを有効にする 仕組みの話が続いたので、ここで一度、実際の設定を見ておきましょう。 まずは /sandbox で様子を見る 一番手軽なのは、セッション中にスラッシュコマンドを打つ方法です。 /sandbox サンドボックスの設定パネルが開き、モードの選択(サンドボックス内のコマンドを自動承認するか、通常の権限確認を残すか)や、現在の設定内容を確認できます。ここで選んだ内容は、そのプロジェクトの .claude/settings.local.json に保存されます。 Linux で必要なパッケージが足りていない場合は Dependencies タブが出て、何が足りないかを教えてくれます。まずはこれを開いてみるのが早いです。 settings.json に書く すべてのプロジェクトで有効にしたいなら、ユーザー設定 ~/.claude/settings.json に書きます。 { "sandbox" : { "enabled" : true } } これだけです。以降、Bash ツールが起動するコマンドは、次の節で見る「 fork して、隙間で設定して、 exec 」という流れでラップされて動きます。 なお、動く環境には条件があります。 macOS : OS 内蔵の Seatbelt を使うので、追加インストールは不要 Linux / WSL2 : bubblewrap (ファイルシステム隔離)と socat (ネットワーク中継)が必要 sudo apt-get install bubblewrap socat # Ubuntu / Debian ネイティブの Windows は非対応で、WSL2 の中で動かす必要があります。 重要 ―― 有効にするだけでは .env は読めたままです そして、この記事のテーマにとって一番大事な注意点です。 "enabled": true にしただけでは、 .env の読み取りは止まりません。 デフォルトのポリシーは、読みと書きで非対称になっています。 書き込み : 作業ディレクトリ(と一時ディレクトリ)だけ許可 読み取り : マシン全体が許可 (一部の拒否ディレクトリを除く) つまり、デフォルトのサンドボックスが主に想定しているのは「作業ディレクトリの外を勝手に書き換えられること」と「知らないドメインに通信されること」の防止です。 読み取りについてはかなり緩く、 ~/.aws/credentials や ~/.ssh/ すら読めます 。 ですから、 .env を守りたければ、読み取り拒否を明示的に書く必要があります。 { "sandbox" : { "enabled" : true , "filesystem" : { "denyRead" : [ "./.env" ] } , "credentials" : { "files" : [ { "path" : "~/.aws/credentials" , "mode" : "deny" } , { "path" : "~/.ssh" , "mode" : "deny" } ] , "envVars" : [ { "name" : "GITHUB_TOKEN" , "mode" : "deny" } ] } } } filesystem.denyRead : サンドボックス内のプロセスからの読み取りを、OS レベルで拒否する credentials.files : 同じことを「秘密情報」としてまとめて書ける枠( "mode": "deny" ) credentials.envVars : サンドボックス実行前に、その環境変数を消す 。ファイルを塞いでも、同じ秘密が環境変数に入っていては意味がないので、ここも大事です パスの書き方には癖があります。 sandbox.filesystem.* は一般的な慣習どおりで、 /tmp/build が絶対パス、 ~/ がホーム、 ./ がプロジェクトルートです。ただし ./ がプロジェクトルートを指すのは プロジェクト設定( .claude/settings.json )に書いた場合だけ で、ユーザー設定に同じものを書くと ~/.claude からの相対になってしまいます。上の例のような ./.env は、プロジェクト側に置いてください。 2箇所に書くことになる ここで、2章の permissions.deny と並べてみると、役割分担がはっきりします。 守りたい経路 書く場所 効いている層 Read / Edit / Glob(本体の機能) permissions.deny アプリケーション層(自制) Bash が起動する子プロセス sandbox.filesystem.denyRead / sandbox.credentials OS カーネル層(強制) 同じ .env を守るために、 2箇所に書く ことになります。冗長に見えますが、これは重複ではありません。 効いている層が違うので、片方だけでは片方の経路しか塞げない のです。さきほどの「両方揃えて初めて塞がる」を、設定ファイルの言葉に翻訳するとこうなる、というわけです。 サンドボックスの正体 ―― fork と exec ここで、サンドボックスが具体的にどう「プロセスに制約を貼る」のかを見ておきます。実はこれは、特別な魔法ではなく Linux カーネルの基本的な仕組み をそのまま使っています。 Unix / Linux では、「プロセスを作る」と「プログラムを実行する」が 別々のシステムコール に分かれています。 fork() → 今のプロセスを複製する。新しいプロセスができる exec() → 今のプロセスの「中身」を、別のプログラムに丸ごと入れ替える exec() が独特です。新しいプロセスを作るのではなく、 自分自身の中身を捨てて、別のプログラムに変身する のです。器(プロセス)はそのまま、中の人だけが入れ替わる。プロセス番号(PID)も変わりません。 一番わかりやすい確認方法があります。ターミナルで試してみてください。 echo "PID: $$ " exec bash # 新しい bash に変身する echo "PID: $$ " # ← PIDが変わっていない! 新しいシェルになったはずなのに PID が同じ。「変身」が起きている証拠です。 この仕組みが、サンドボックスの土台になっています。 sandbox-exec (macOS の場合)のようなラッパーは、次の順番で動きます。 1. fork() で子プロセスを作る(この時点では sandbox-exec のコード) 2. カーネルにサンドボックスのポリシーを登録する 3. exec("bash") で自分自身を bash に変身させる ↑ ここで「sandbox-exec プロセス」は消え、同じ器が bash になる 4. 以降、その bash には制約が貼り付いた状態 つまり、 「サンドボックスというプロセスが監視役として残る」わけではありません 。 fork で複製し、 exec で目的のコマンドに変身するときに、 制約という「属性」だけがそのプロセスに刻まれて残る のです。制約はカーネル側でそのプロセスに紐づいて記録されるので、 exec で中身が入れ替わっても消えません。むしろ、消えずに引き継がれるからこそ、この仕組みが成立します。 なぜわざわざ「複製してから変身」という回りくどいことをするのか。理由は、 fork と exec の間に「設定をするための隙間」ができる からです。この隙間で「サンドボックスのポリシーを登録する」「作業ディレクトリを変える」「環境変数を設定する」といった細工を、普通のコードとして自由に挟み込めます。 env (環境変数を設定して実行)や nice (優先度を変えて実行)といったおなじみのコマンドも、実は全部この「隙間で細工してから exec する」という同じパターンで作られています。サンドボックスは、その一族の一員にすぎないのです。 そして、この制約は 子孫プロセスに継承されます 。サンドボックス化された Bash が cat を呼べば、 cat も同じ制約を受け継ぐ。だから「Bash 経由なら何を使っても読めない」が成立するわけです。 疑似コードで見る ―― サンドボックスが適用される瞬間 ここまでの話を、コードの形で一度に見てみましょう。ラッパーがやっていることは、だいたいこういう形です。 if ( fork ( ) == 0 ) { // 子プロセスの側 // ここは「まだ自分のコード」なので、好きなことができる chdir ( "/tmp" ) ; // 作業ディレクトリを変える setuid ( 1000 ) ; // 権限を落とす close ( 0 ) ; open ( "input.txt" , . . . ) ; // 標準入力を差し替える setenv ( "LANG" , "C" , 1 ) ; // 環境変数を設定 sandbox_init ( profile , . . . ) ; // サンドボックスを適用 exec ( "/bin/ls" , . . . ) ; // ここで初めて ls に化ける } fork() の戻り値が 0 になる側が子プロセスです。そして この if の中、 exec() までの数行が、さきほど言った「隙間」 です。この時点でプロセスの中身はまだラッパー自身のコードなので、特別な仕掛けは何もいりません。普通の関数呼び出しとして準備ができます。 注目してほしいのは、並んでいる5行が 全部同じ性質の操作 だということです。 chdir でも setuid でも setenv でも sandbox_init でも、設定している相手は共通で、 「これから ls になる、いまの自分」 です。 つまり、 サンドボックスの適用は「特殊な起動方法」ではありません 。 chdir() や setenv() と同じ列に、同じ資格で並んでいる ただの1行 です。3章の冒頭で「サンドボックスは env や nice の一族にすぎない」と書いたのは、こういう意味です。 そして最後の exec("/bin/ls") 。ここでプロセスの中身が ls に入れ替わりますが、 直前に設定したものは全部残ります 。 カレントディレクトリは /tmp UID は 1000 標準入力は input.txt 環境変数は LANG=C そして、サンドボックスのポリシーも ls のコードは、自分がこれらを設定された覚えなどありません。それでも、 生まれた瞬間からその条件下にいる 。ここがサンドボックスの本質です。 ls はサンドボックスの存在を知らないし、協力することも拒否することもできません。制約は「 ls が守るべきルール」ではなく、 「 ls が置かれている環境」 だからです。 だからこそ、 ls を cat に変えても、自作のバイナリに変えても意味がない。ここが、アプリケーション層の自制( permissions.deny )との決定的な違いです。 もうひとつ、 順番が命 だという点も押さえておきたいところです。 sandbox_init() は必ず exec() より前になければいけません。「 exec した後に設定すればいい」は成立しません。 exec の後には もう自分のコードが存在しない からです(中身は ls になっている)。設定するチャンスは fork と exec の間、この隙間しかないのです。 Claude Code が Bash ツールを起動するときも、やっていることはこれと同じです。逆に言えば ―― この隙間を通らずに起動されたプロセスには、当然、何の設定もされていない 。これが次章の話につながります。 さらに残る抜け穴 「Read は permissions.deny 、Bash はサンドボックス。これで完璧」と言いたいところですが、 まだ抜け穴があります 。 そのひとつが MCP サーバー です。MCP サーバーは、AI に外部ツール(ファイル操作、DB アクセスなど)を持たせるための仕組みで、Claude Code とは別のプロセスとして起動します。 「別プロセスなら、サンドボックスで守られるのでは?」と思うところですが、そうはなりません。もう一度、先ほどの図を見てください。 鍵は、図の一番上に書いてある 「Claude Code 本体はサンドボックス未適用」 という点です。 サンドボックスは「制約を持つプロセスから子へ継承される」仕組みでした。ところが本体自身は制約を持っていません。だから、本体が子プロセスを起動するとき、 その起動処理がわざわざサンドボックスでラップしなければ、子は素のまま生まれます 。 Bash ツールは「ラップする」ようにわざわざ実装されているので拘束されます。しかし MCP サーバーは 別のコードパスで起動される ため、そのパスがラップしていなければ、制約なしで動いてしまう。「子プロセスだから安全」ではなく、「 ラップされた子プロセスだけが安全 」なのです。継承元が無拘束である以上、包む処理を通らない経路はすべて素通りになります。 さきほどのレイヤー図の言葉で言い直すと、こうなります。システムコールの境界は すべてのプロセスに共通して存在している のに、そこで照合されるポリシーは プロセスごとに違う 。MCP サーバーは、境界を越えていないわけではありません。 境界は越えているが、そのプロセスには照らし合わせるルールが登録されていない ので、カーネルはそのまま通してしまうのです。 MCP サーバーは、ファイルにも DB にもネットワークにもアクセスできる強力な存在です。それが制約の外で動くというのは、 .env にとっては立派な裏口になり得ます。 MCP サーバーを包む ―― @anthropic-ai/sandbox-runtime では、この裏口は塞げないのでしょうか。塞げます。 @anthropic-ai/sandbox-runtime という、Anthropic が公開しているサンドボックスツールを使います。 これは Claude Code の内蔵サンドボックスと同じ OS の仕組み(macOS なら Seatbelt、Linux なら bubblewrap)を、 任意のプロセスに対して外から適用できるようにした単体パッケージ です。 srt というコマンドを提供していて、公式のドキュメントでも「ローカル MCP サーバーを包むこと」が主要な想定用途として挙げられています。 npm install -g @anthropic-ai/sandbox-runtime やっていることは、3章で見た sandbox-exec とまったく同じです。 fork して、隙間でポリシーを登録して、 exec で目的のコマンドに変身する。だから、 包まれたプロセスの子孫にも制約が継承されます 。 .mcp.json の command を差し替える やり方はシンプルで、 .mcp.json の command を srt に差し替えて、本来のコマンドを引数に回すだけです。 包む前: { "mcpServers" : { "filesystem" : { "command" : "npx" , "args" : [ "-y" , "@modelcontextprotocol/server-filesystem" ] } } } 包んだ後: { "mcpServers" : { "filesystem" : { "command" : "srt" , "args" : [ "npx" , "-y" , "@modelcontextprotocol/server-filesystem" ] } } } 差分は「 command を srt にして、元の command を args の先頭に押し込む」だけです。これで、この MCP サーバー(と、それが起動する子プロセス)はサンドボックスの中で動くようになります。 MCP サーバー側に手を入れる必要はありません 。サーバーは自分が包まれていることを知りませんが、それでも制約は効く ―― 3章の ls の話とまったく同じ構図です。 制約の内容は ~/.srt-settings.json に書きます。 { "filesystem" : { "denyRead" : [ "./.env" , "~/.ssh" , "~/.aws" ] , "allowWrite" : [ "." ] , "denyWrite" : [ "./.env" ] } , "network" : { "allowedDomains" : [ ] , "deniedDomains" : [ ] } } srt --settings /path/to/srt-settings.json ... のように、サーバーごとに別の設定ファイルを渡すこともできます。 デフォルトの挙動には癖があるので、ここは押さえておいてください。 書き込み : デフォルトで 全部拒否 。 allowWrite に書いたものだけ許可される ネットワーク : デフォルトで 全部拒否 。 allowedDomains に書いたものだけ許可される 読み取り : デフォルトで 全部許可 。 denyRead で塞ぐ 内蔵サンドボックスと同じで、ここでも 読み取りだけは緩い という構図です。 .env を守りたければ denyRead を明示的に書く必要があります。しかも srt では allowRead が denyRead より優先される ので、広い allowRead を書くと denyRead が打ち消されてしまいます(内蔵サンドボックスとは優先順位が逆なので、ここは要注意です)。 逆に、書き込みとネットワークはデフォルトで全部閉まっています。 包んだ MCP サーバーが動かなくなったら、まず allowWrite と allowedDomains の不足を疑う のが正解です。 ただし注意点もあります。 @anthropic-ai/sandbox-runtime は ベータのリサーチプレビュー という位置づけで、設定フォーマットは今後変わる可能性があると明記されています。運用ルールに組み込む場合は、そのつもりで扱ってください。 じゃあ、結局どうすればいいのか ここまで、「経路を1つ塞いでも、別の経路が出てくる」というモグラ叩きを見てきました。Read を塞げば Bash、Bash を塞げば MCP……。 塞ぐアプローチには、常に「列挙し忘れた経路」が残る という本質的な弱さがあります。 そこで発想を変えます。 そもそも .env に本物の秘密を置かなければ、何経路から読まれても平気 です。守るべきものが最初からそこに無ければ、読まれても被害はありません。 理想的なのは、 短命なアクセストークン を使う運用です。永続的な秘密鍵をファイルに置くのではなく、必要なときにだけ発行される、寿命の短いトークンで認証する。イメージとしては、こういう流れです。 1. 開発者が最初に CLI でログインする(人間の認証) $ my-tool login 2. その権限をもとに、短命なアクセストークンをリクエストする → 数十分で失効するトークンが発行される 3. アプリはそのトークンを使って動く → ファイルには本物の秘密鍵が存在しない → 万一トークンが漏れても、すぐ失効するので被害が限定的 実際のコマンドで見るとこうなる my-tool login は架空のコマンドですが、実際のクラウドでは、おなじみのコマンドがそのまま当てはまります。 AWS の場合 # 1. 人間の認証(ブラウザが開いて SSO でログインする) aws sso login --profile dev # 2〜3. あとは CLI / SDK が一時credentialを自動で取得して使う aws s3 ls ~/.aws/credentials に永続的なアクセスキーを置く代わりに、 aws sso login で得た権限をもとに、裏側で数時間で失効する一時credentialが発行されます。トークンは ~/.aws/sso/cache/ にキャッシュされるだけなので、期限が切れればそれ以上使えません。 Azure の場合 # 1. 人間の認証 az login # 2. 必要なリソース向けの短命トークンを取得(有効期限は1時間程度) az account get-access-token --resource https://vault.azure.net アプリ側は DefaultAzureCredential を使えば、この CLI のログイン状態を自動で拾ってくれます。 Google Cloud の場合 # 1. 人間の認証 gcloud auth application-default login # 2. 短命なアクセストークン(有効期限は1時間程度) gcloud auth print-access-token こちらも ADC(Application Default Credentials)という仕組みがあり、SDK が自動でトークンを取得してくれます。 どれも形はまったく同じです。 「人間が一度ログインする」→「そこから短命なトークンが発行される」→「アプリはそれを使う」 。そして共通して、 .env に書くべき永続的な秘密がどこにも登場しません 。 クラウドのマネージド ID(実行環境自身の身元でシークレットストアからトークンを取得する仕組み)や、OIDC ベースの一時credential(GitHub Actions からクラウドに、静的なアクセスキーを Secrets に置かずに認証する構成など)が、この考え方の実装にあたります。 秘密は「ファイルに書いて守る」のではなく、「 そもそも書かず、必要な瞬間に短命なものを取りに行く 」。これが、経路を1つずつ塞ぐ発想から抜け出す、根本的な解決策です。 とはいえ、理想はなかなか大変 ただ、正直に言うと、この理想形はすぐに実現できるものではありません。 トークンを発行する仕組み(認証基盤、シークレットストア)を用意する必要がある 既存のアプリを「ファイルから読む」前提で書いている場合、その書き換えが要る ローカル開発、CI、本番で、それぞれトークンの取得経路を整える必要がある 規模の小さいプロジェクトや、とりあえず今動いているものに、いきなりこれを導入するのは腰が重い、というのが現実です。 なので、当面の現実的な立ち回りは、こうなります。 理想を目指しつつ 、本物の秘密を短命トークンに寄せていく(長期的な方向性) それができるまでは 、経路を1つずつ地道に塞ぐ permissions.deny で Read / Edit / Glob をパス単位でブロック サンドボックスを有効にし、 denyRead / credentials で読み取りも明示的に塞ぐ(有効化だけでは読み取りは止まらない) MCP サーバーは使うものを吟味し、 srt ( @anthropic-ai/sandbox-runtime )で包む .env にはできる限りダミー値だけを置き、本物は最小限に 結局のところ、 「守るべきものを、そこに置かない」が最強の対策 です。しかしそれが難しい間は、 「経路をひとつずつ調べて防ぐ」しか、今のところ道はありません 。モグラ叩きに聞こえるかもしれませんが、どの経路がどのレイヤーで守られるのか(アプリの自制なのか、OS の強制なのか)を理解していれば、叩き漏らしはずっと減らせます。 まとめ コーディングエージェントを実用化させるためにはセキュリティは欠かせません。でもだからといって、コーディングエージェントならではの知識が必要なわけではなく、 OS の基本的な仕組みと、プロセスの性質を理解していれば十分 です。今回の話は、Claude Code に限らず、コーディングエージェント全般に当てはまります。なので、やっぱりいくらAIを使って業務をしても、やっぱり基本が大事だなと思います。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post コーディングエージェントに機密情報を読ませない基本的な仕組み first appeared on SIOS Tech Lab .
動画
該当するコンテンツが見つかりませんでした







