自動運用の設定を見直していて、手が止まった箇所があります。
「ワークフローのサブエージェントは、セッションの権限モードに関わらずファイル編集を自動承認する」。この一行を読んだとき、自分の環境で何が起きているのかを説明できませんでした。個人開発でブログの生成パイプラインを回している以上、書き込み先の境界は自分で引いているつもりでいたのですが、その境界が権限モードの側にしか置かれていなかったのです。
シェルコマンドや許可リスト外の 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通りを流した結果です。
150件中54件、率にして36%が「フックが落ちたのに書き込みは進む」状態でした。ブロックしたつもりの箇所ではありません。フックが例外で終わった箇所です。
exit 1 は失敗ではなく「意見なし」として扱われる
原因は Claude Code の終了コードの規約にあります。
ほとんどのフックイベントで、動作を止めるのは exit 2 だけです。exit 1 は non-blocking error として扱われ、Unix の慣習では失敗を意味する終了コードであるにもかかわらず、動作はそのまま進みます。例外は WorktreeCreate で、ここだけは非ゼロなら中止されます。
Python スクリプトが未捕捉の例外で終われば、終了コードは 1 です。つまり KeyError ひとつで、ゲートは「止める」から「意見を言わない」へ静かに切り替わります。
素通り54件の内訳を取ると、性質が二つに分かれました。
メインスレッド由来の30件は、結果として通ってよいものです。agent_id はサブエージェント内でのみ届く任意フィールドなので、存在しないのは正常です。ただし通したのは設計ではなく、例外が偶然そう振る舞っただけでした。
問題は下段の24件です。サブエージェントからの書き込みでありながら file_path が取れず、ゲートは何の判断も返さないまま、書き込みは進みました。ここを見つけた瞬間、境界を引いたつもりでいた自分の設定が、実は「例外が出なかった経路にだけ効く境界」だったと分かりました。
事前の予想は逆でした。厳しすぎて開発が止まる方を心配していたのに、実際は最も情報の足りない入力ほど素通りしていたのです。ゲートは、判定できないときにこそ止まらなければ意味がありません。
判定できないときに止まる実装へ書き直す
方針を三つ決めました。
- 判定は exit 0 + stdout の JSON で返します。 exit 2 は JSON を読んだうえで、ブロックを覆せない扱いにします。「止める」と「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 の JSON はブロックを覆せない)
- 想定外の例外は 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通りを流し直した結果です。
素通り54件がゼロになりました。うち24件は ask として人間に上がり、30件はメインスレッド由来として通常のパーミッション評価へ明示的に委ねられます。同じ「通る」でも、意図した委譲と例外の副作用では意味がまるで違います。
壊れた入力も直接流して確かめました。
接頭辞一致が許可ルートを広げていた
もうひとつ、測るまで気づかなかった穴があります。startswith による許可ルートの判定です。
doc-writer に /repo/content を許可したつもりで、実際には何が通るのかを並べました。
/repo/content で始まる文字列は /repo/content-archive/ も /repo/contentious.txt も含みます。ディレクトリを一段増やしただけで、意図していない領域が許可範囲に入ります。修正は区切り文字を明示するだけで、path == r or path.startswith(r + os.sep) とします。
.. を含むパスについては、フックに届く file_path が正規化済みかどうかを、公式ドキュメントの範囲では確認できませんでした。そのため os.path.normpath は二重の備えではなく必須の処理として残しています。上の表の /repo/content/../src/pricing.ts が deny になるのは、この一行のおかげです。
Windows で運用する場合は、区切り文字の違いにも注意が必要です。私は Windows 上では測っていないため、file_path がどの区切りで届くかは自分の環境で一度ログに出して確かめてください。スラッシュ前提の比較は、バックスラッシュ区切りの入力には一致しません。
実行コストを測ってから配線する
PreToolUse は Claude がツールを呼ぶたびに走ります。書き込みの多いパイプラインでは、フック自体の実行時間が積み上がります。同一ペイロードで1,000回実行した実測が以下です。
内訳を取ると、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 / ask / allow / defer の優先順位を公式ドキュメントで見つけられませんでした。ゲートを複数重ねる場合は、「片方が deny、もう片方が allow を返したらどうなるか」を最初に一度流して確かめてください。重ねるほど、確かめた事実が効いてきます。
もうひとつは、PreToolUse は Claude がツールを呼んだときにだけ走るという点です。プロンプト内で @ を使って参照したファイルはツール呼び出しを経ずに読み込まれるため、Read にマッチするフックも発火しません。読み取りを止めたい場合は、フックではなく Read の deny ルールを使う必要があります。
本番運用で私が踏んだ注意点
配線した直後、ゲートが何も言わないように見える時間帯がありました。原因は単純で、exit 0 で終えたフックの stderr は デバッグログにしか出ず、トランスクリプトにも Claude にも渡らないという仕様です。print(..., file=sys.stderr) でデバッグ出力を仕込んでも、通常の画面には現れません。私はこの挙動に気づくまで、ゲートが発火していないのかログが出ていないだけなのかを切り分けられませんでした。回避策としては、判定内容を自前のファイルへ追記するか、デバッグログを有効化して確認します。
もうひとつ、フックのプロセスは親の環境変数を引き継ぎますが、OTEL_* のエクスポータ変数だけは Claude Code が生成する全サブプロセスから取り除かれます。判定結果をテレメトリへ流そうと考えている場合、この一点で計装が無言のまま届かなくなります。相関を取りたいなら、共通入力に含まれる prompt_id を自前のログへ記録しておくのが確実です。この場合は OpenTelemetry 側のイベントと後から突き合わせられます。
壊れた入力で毎回確かめる — 自己テストを置く
フェイルクローズは、一度書いて終わりではありません。誰かが decide() を直した拍子に try の範囲がずれ、気づかないうちに exit 1 へ戻る可能性があります。そこで、壊れた入力を流して終了コードを確認するだけの小さなテストを、フックの隣に置くことにしました。書いてみて気づいたのは、テストの価値は「通ること」より「落ち方が変わったら気づけること」にある、という点です。
# gate_selftest.py — 壊れた入力でもフックが exit 2 で落ちるかを確認する
import subprocess
import sys
GATE = sys.argv[1] if len(sys.argv) > 1 else ".claude/hooks/agent_write_gate.py"
# (名前, stdin, 期待する終了コード)
CASES = [
("空入力", "", 2),
("JSON ではない", "not json at all", 2),
("配列が来た", "[]", 2),
("tool_input が null",
'{"tool_name":"Write","agent_type":"doc-writer","tool_input":null}', 0),
("範囲外の書き込み",
'{"tool_name":"Write","agent_type":"doc-writer",'
'"tool_input":{"file_path":"/repo/content-archive/a.mdx"}}', 0),
]
bad = 0
for name, stdin, want in CASES:
r = subprocess.run([sys.executable, GATE], input=stdin,
capture_output=True, text=True)
ok = r.returncode == want
bad += not ok
print(f"{'OK ' if ok else 'NG '} {name}: exit {r.returncode} (期待 {want})")
sys.exit(1 if bad else 0)
私の環境では次の結果になりました。
最後の二行が exit 0 なのは、判定を JSON で返しているためです。「exit 0 だから通った」と読み違えないよう、期待値の列に意味を持たせています。CI やコミット前フックにこのテストを足しておけば、ゲートが素通りへ戻る事故は、変更した時点で検出できます。
残っている不確かさ
正直に書いておきたい点があります。
ここで測ったのは、私が書いたゲートスクリプトが PreToolUse の入力仕様に対してどう振る舞うか、です。ターン単位のサブエージェント(Task)からの書き込みについては、agent_id / agent_type が届くことがドキュメントに明記されており、このゲートが効くと考えて差し支えありません。
一方、隔離されたワークフローランタイムで同じフックが発火するかどうかは、私が確認した範囲ではドキュメントに明示がありませんでした。ワークフローを自律実行の中核に据えるなら、ここは自分の環境で確かめてから頼るべき箇所です。確かめ方はそれほど難しくありません。agent_write_gate.py の main() の冒頭に、受け取ったペイロードをそのまま追記するだけの数行を差し込み、ワークフローを一度走らせて、ログに agent_id を持つ PreToolUse が現れるかを見ます。現れなければ、そのランタイムに対してこのゲートは存在しないのと同じです。
次にやること
自動運用を組んでいる方に、まず一つだけ確かめてほしいことがあります。
いま設定している PreToolUse フックに、わざと壊れた JSON を流してみてください。終了コードが 2 以外なら、そのフックはポリシーゲートとして機能していません。判定を間違える以前に、判定しないまま通しています。
私自身、境界を引いた気になっていた期間がそれなりに長くありました。設定ファイルに書いた文字列と、実際に走る挙動は別物です。上の自己テストは数十行で、数秒で終わります。まず自分のフックに対して流し、2 が返ってこないケースが一つでもあれば、そこが次に直す場所です。
お読みいただきありがとうございました。
参考