自動運用の設定を見直していて、手が止まった箇所があります。
「ワークフローのサブエージェントは、セッションの権限モードに関わらずファイル編集を自動承認する」。この一行を読んだとき、自分の環境で何が起きているのかを説明できませんでした。個人開発でブログの生成パイプラインを回している以上、書き込み先の境界は自分で引いているつもりでいたのですが、その境界が権限モードの側にしか置かれていなかったのです。
シェルコマンドや許可リスト外の MCP 呼び出しは実行中でも確認を挟みます。ファイル書き込みだけが挟まない。auto / bypass / claude -p では、起動時のプロンプト自体が省かれます。
では何が残るのか。公式ドキュメントを追うと、PreToolUse フックの入力にはサブエージェント内でのみ agent_id と agent_type が届くと書かれています。つまり「誰の書き込みか」をフック側で見分けられる。ここが最後の境界になります。
そう理解して、ありがちな形でゲートを書き、150通りのペイロードを流してみました。結果は想定と逆でした。
素通りは54件でした — まず母集合を作って測る
判定の質を語る前に、判定を通す入力の幅を決める必要があります。PreToolUse の共通入力フィールドから、実際に分岐に効く軸だけを取りました。
permission_mode — default / plan / acceptEdits / auto / dontAsk / bypassPermissions の6値(UI で Manual と表示されるモードは default として届きます)
- 呼び出し元 — メインスレッド、名前付きサブエージェント2種、プラグイン配布の
my-plugin:reviewer、--agent 起動(agent_type はあるが agent_id はない)の5通り
- ツールと書き込み先 — 許可ルート内、許可ルート外、テスト配下、CI 設定への書き込み、そして
file_path を欠いた入力の5通り
6 × 5 × 5 で150通り。この母集合を組む Python が以下です。
# payloads.py — PreToolUse ペイロードの母集合
import copy
import itertools
BASE = {
"session_id": "sess_gate_probe",
"prompt_id": "550e8400-e29b-41d4-a716-446655440000",
"transcript_path": "/home/u/.claude/projects/x/transcript.jsonl",
"cwd": "/repo",
"hook_event_name": "PreToolUse",
"tool_use_id": "toolu_probe",
}
MODES = ["default", "plan", "acceptEdits", "auto", "dontAsk", "bypassPermissions"]
ORIGINS = [
{}, # メインスレッド
{"agent_id": "ag_01", "agent_type": "doc-writer"},
{"agent_id": "ag_02", "agent_type": "test-runner"},
{"agent_id": "ag_03", "agent_type": "my-plugin:reviewer"},
{"agent_type": "doc-writer"}, # --agent 起動(agent_id なし)
]
TOOLS = [
("Write", {"file_path": "/repo/content/a.mdx", "content": "x"}),
("Write", {"file_path": "/repo/src/config/pricing.ts", "content": "x"}),
("Edit", {"file_path": "/repo/tests/t.spec.ts", "old_string": "a", "new_string": "b"}),
("Edit", {"file_path": "/repo/.github/workflows/deploy.yml",
"old_string": "a", "new_string": "b"}),
("Write", {"content": "x"}), # file_path 欠落
]
def build():
out = []
for mode, origin, (tool, tin) in itertools.product(MODES, ORIGINS, TOOLS):
p = copy.deepcopy(BASE)
p["permission_mode"] = mode
p.update(origin)
p["tool_name"] = tool
p["tool_input"] = tin
out.append(p)
return out
流す相手は、私が最初に書いたゲートです。素直に書くとこうなります。
# naive_gate.py — サブエージェントの書き込みだけを制限したい、という最初の実装
import json
import sys
ALLOWED_ROOTS = {
"doc-writer": ["/repo/content"],
"test-runner": ["/repo/tests"],
}
data = json.load(sys.stdin)
agent_type = data["agent_type"]
path = data["tool_input"]["file_path"]
roots = ALLOWED_ROOTS.get(agent_type, [])
if not any(path.startswith(r) for r in roots):
print(f"{agent_type} は {path} へ書き込めません", file=sys.stderr)
sys.exit(2)
sys.exit(0)
150通りを流した結果です。
| 判定 | 件数 |
| BLOCK(exit 2 でツール呼び出しを中止) | 78 |
| PASS(無言で許可) | 18 |
| PASS(フック異常のまま続行) | 54 |
150件中54件、率にして36%が「フックが落ちたのに書き込みは進む」状態でした。ブロックしたつもりの箇所ではありません。フックが例外で終わった箇所です。
exit 1 は失敗ではなく「意見なし」として扱われる
原因は Claude Code の終了コードの規約にあります。
ほとんどのフックイベントで、動作を止めるのは exit 2 だけです。exit 1 は non-blocking error として扱われ、Unix の慣習では失敗を意味する終了コードであるにもかかわらず、動作はそのまま進みます。例外は WorktreeCreate で、ここだけは非ゼロなら中止されます。
Python スクリプトが未捕捉の例外で終われば、終了コードは 1 です。つまり KeyError ひとつで、ゲートは「止める」から「意見を言わない」へ静かに切り替わります。
素通り54件の内訳を取ると、性質が二つに分かれました。
| 呼び出し元 | 原因 | 件数 |
| メインスレッド | agent_type 欠落 | 24 |
| メインスレッド | file_path 欠落 | 6 |
| サブエージェント | file_path 欠落 | 24 |
メインスレッド由来の30件は、結果として通ってよいものです。agent_id はサブエージェント内でのみ届く任意フィールドなので、存在しないのは正常です。ただし通したのは設計ではなく、例外が偶然そう振る舞っただけでした。
問題は下段の24件です。サブエージェントからの書き込みでありながら file_path が取れず、ゲートは何の判断も返さないまま、書き込みは進みました。ここを見つけた瞬間、境界を引いたつもりでいた自分の設定が、実は「例外が出なかった経路にだけ効く境界」だったと分かりました。
事前の予想は逆でした。厳しすぎて開発が止まる方を心配していたのに、実際は最も情報の足りない入力ほど素通りしていたのです。ゲートは、判定できないときにこそ止まらなければ意味がありません。
判定できないときに止まる実装へ書き直す
方針を三つ決めました。
- 判定は exit 0 + stdout の JSON で返す。 Claude Code は exit 0 のときだけ JSON を解釈します。exit 2 を出すと JSON は無視されるため、どちらか一方に統一する必要があります。
- 想定外の例外は exit 2 で落とす。 exit 1 では素通りします。判定できないなら止める、をコードで表明します。
- 判定材料が欠けている入力は
deny ではなく ask に上げる。 経路不明の書き込みを一律に拒否すると、正当な作業まで止まります。人間に上げるのが妥当な落としどころでした。
書き直したものが以下です。そのまま .claude/hooks/agent_write_gate.py に置ける形にしてあります。
#!/usr/bin/env python3
"""PreToolUse: サブエージェントの書き込みを agent_type ごとの許可ルートに限定する。
Claude Code の規約に合わせた設計:
- 判定は exit 0 + stdout の JSON で返す(exit 2 との併用は不可)
- 想定外の例外は exit 2 で落とす(exit 1 は non-blocking error 扱いで素通りする)
- agent_id / agent_type はサブエージェント内でのみ届く任意フィールド
"""
import json
import os
import sys
WRITE_TOOLS = {"Write", "Edit", "NotebookEdit"}
# agent_type -> 書き込みを許すルート(絶対パス)
ALLOWED_ROOTS = {
"doc-writer": ["/repo/content"],
"test-runner": ["/repo/tests", "/repo/fixtures"],
"my-plugin:reviewer": [], # 読むだけ。書き込みは一切許さない
}
DEFAULT_ROOTS = [] # 未知の agent_type は書けない
def decide(payload):
"""(permissionDecision, reason) を返す。None は「意見なし」。"""
tool = payload.get("tool_name", "")
if tool not in WRITE_TOOLS:
return None, None
agent_type = payload.get("agent_type")
if agent_type is None:
# メインスレッドの書き込み。通常のパーミッション評価に委ねる
return None, None
tool_input = payload.get("tool_input") or {}
path = tool_input.get("file_path")
if not path:
# 経路不明の書き込み。素通しにせず人間に上げる
return "ask", f"{agent_type} の {tool} に file_path がありません"
path = os.path.normpath(path)
roots = ALLOWED_ROOTS.get(agent_type, DEFAULT_ROOTS)
for r in roots:
r = os.path.normpath(r)
if path == r or path.startswith(r + os.sep):
return "allow", f"{agent_type} の許可ルート {r} 配下です"
return "deny", (
f"{agent_type} に許可された書き込み先は {roots or '(なし)'} です。"
f"{path} は範囲外のため中止しました"
)
def main():
try:
payload = json.load(sys.stdin)
decision, reason = decide(payload)
except Exception as e:
# フェイルクローズ。exit 1 だと non-blocking error として書き込みが通る
print(f"agent-write-gate が判定できませんでした: {e!r}", file=sys.stderr)
sys.exit(2)
if decision is None:
sys.exit(0) # JSON を出さない=通常評価へ委ねる
json.dump({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision,
"permissionDecisionReason": reason,
}
}, sys.stdout)
sys.exit(0)
if __name__ == "__main__":
main()
hookEventName を hookSpecificOutput の中に入れる点は忘れやすい箇所です。トップレベルに置くと出力が解釈されません。permissionDecisionReason はモデルにも渡るため、再試行を誘発しない言い回しにしておくと無駄な往復が減ります。
同じ150通りを流し直した結果です。
| 判定 | naive_gate | agent_write_gate |
| BLOCK(deny) | 78 | 78 |
| ASK(人間へ確認) | 0 | 24 |
| PASS(明示 allow) | 0 | 18 |
| PASS(無言=通常評価へ委譲) | 18 | 30 |
| PASS(フック異常のまま続行) | 54 | 0 |
素通り54件がゼロになりました。うち24件は ask として人間に上がり、30件はメインスレッド由来として通常のパーミッション評価へ明示的に委ねられます。同じ「通る」でも、意図した委譲と例外の副作用では意味がまるで違います。
壊れた入力も直接流して確かめました。
| stdin | naive_gate | agent_write_gate |
| 空文字 | exit 1(素通り) | exit 2(中止) |
not json at all | exit 1(素通り) | exit 2(中止) |
{"tool_name":"Write"} | exit 1(素通り) | exit 0・出力なし(通常評価へ) |
tool_input が null | exit 1(素通り) | exit 0・ask を返す |
接頭辞一致が許可ルートを広げていた
もうひとつ、測るまで気づかなかった穴があります。startswith による許可ルートの判定です。
doc-writer に /repo/content を許可したつもりで、実際には何が通るのかを並べました。
| 書き込み先 | naive_gate | agent_write_gate |
/repo/content/a.mdx | 通過 | allow |
/repo/content-archive/a.mdx | 通過 | deny |
/repo/contentious.txt | 通過 | deny |
/repo/content/../src/pricing.ts | 通過 | deny |
/repo/content で始まる文字列は /repo/content-archive/ も /repo/contentious.txt も含みます。ディレクトリを一段増やしただけで、意図していない領域が許可範囲に入る。修正は区切り文字を明示するだけで、path == r or path.startswith(r + os.sep) とします。
.. を含むパスについては、公式ドキュメントに「Claude Code は ~ と相対パスをフックの実行前に展開する」とあるため、実運用では正規化済みの絶対パスが届きます。os.path.normpath は二重の備えという位置づけですが、ローカルでこのフックを単体テストする場面では効きます。
Windows で運用する場合はもう一段あります。ドキュメントによれば file_path はバックスラッシュ区切りで届くため、Git Bash 上で $PWD が /c/project に見えていても、/src/ のようなスラッシュ表記の比較は一致しません。パスの比較はプラットフォームを意識して書く必要があります。
実行コストを測ってから配線する
PreToolUse は Claude がツールを呼ぶたびに走ります。書き込みの多いパイプラインでは、フック自体の実行時間が積み上がります。同一ペイロードで1,000回実行した実測が以下です。
| 指標 | 値 |
| p50 | 21.9 ms |
| p95 | 23.3 ms |
| p99 | 23.7 ms |
| 1,000回の合計 | 22.1 秒 |
内訳を取ると、python3 -c pass だけで p50 が 16.8 ms でした。判定ロジックそのものは5 ms 程度で、大半はインタプリタの起動コストです。書き込み1,000回で22秒。許容できる範囲だと判断しましたが、ここを削るなら判定を単一バイナリや常駐プロセスへ寄せる余地があります。数値を持たずに「フックは重そうだから避ける」と決めてしまうのが、いちばん惜しい判断でした。
配線は .claude/settings.json に書きます。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/agent_write_gate.py\"",
"timeout": 10
}
]
}
]
}
}
timeout は既定で command 型が600秒です。ポリシーゲートとしては長すぎるので、実測 p99 の 23.7 ms に対して十分な余裕を見た10秒へ落としました。
配線で押さえておくべき挙動が二つあります。ひとつは、条件に一致するフックはすべて並列に走り、deny が defer より、defer が ask より、ask が allow より優先されること。どれか一つでも deny を返せば、他が何を返してもツール呼び出しは止まります。ゲートを分割して足していける設計になっています。
もうひとつは、PreToolUse は Claude がツールを呼んだときにだけ走るという点です。プロンプト内で @ を使って参照したファイルはツール呼び出しを経ずに読み込まれるため、Read にマッチするフックも発火しません。読み取りを止めたい場合は、フックではなく Read の deny ルールを使う必要があります。
本番運用で私が踏んだ注意点
配線した直後、ゲートが何も言わないように見える時間帯がありました。原因は単純で、exit 0 で終えたフックの stderr は デバッグログにしか出ず、トランスクリプトにも Claude にも渡らないという仕様です。print(..., file=sys.stderr) でデバッグ出力を仕込んでも、通常の画面には現れません。私はこの挙動に気づくまで、ゲートが発火していないのかログが出ていないだけなのかを切り分けられませんでした。回避策としては、判定内容を自前のファイルへ追記するか、デバッグログを有効化して確認します。
もうひとつ、フックのプロセスは親の環境変数を引き継ぎますが、OTEL_* のエクスポータ変数だけは Claude Code が生成する全サブプロセスから取り除かれます。判定結果をテレメトリへ流そうと考えている場合、この一点で計装が無言のまま届かなくなります。相関を取りたいなら、共通入力に含まれる prompt_id を自前のログへ記録しておくのが確実です。この場合は OpenTelemetry 側のイベントと後から突き合わせられます。
残っている不確かさ
正直に書いておきたい点があります。
ここで測ったのは、私が書いたゲートスクリプトが PreToolUse の入力仕様に対してどう振る舞うか、です。ターン単位のサブエージェント(Task)からの書き込みについては、agent_id / agent_type が届くことがドキュメントに明記されており、このゲートが効くと考えて差し支えありません。
一方、隔離されたワークフローランタイムで同じフックが発火するかどうかは、私が確認した範囲ではドキュメントに明示がありませんでした。ワークフローを自律実行の中核に据えるなら、ここは自分の環境で確かめてから頼るべき箇所です。確かめ方はそれほど難しくありません。agent_write_gate.py の main() の冒頭に、受け取ったペイロードをそのまま追記するだけの数行を差し込み、ワークフローを一度走らせて、ログに agent_id を持つ PreToolUse が現れるかを見ます。現れなければ、そのランタイムに対してこのゲートは存在しないのと同じです。
次にやること
自動運用を組んでいる方に、まず一つだけ確かめてほしいことがあります。
いま設定している PreToolUse フックに、わざと壊れた JSON を流してみてください。終了コードが 2 以外なら、そのフックはポリシーゲートとして機能していません。判定を間違える以前に、判定しないまま通しています。
私自身、境界を引いた気になっていた期間がそれなりに長くありました。設定ファイルに書いた文字列と、実際に走る挙動は別物です。150通り流すのに要したのはスクリプト二本と数分でした。同じ時間で自分の設定を測り直せるなら、やっておく価値はあると思います。
お読みいただきありがとうございました。
参考