Claude Code safety_eventの読み方—素の-pでは拒否が1件出ない

{
  "event.name": "safety_event",
  "category": "permission_rule",
  "decision": "reject",
  "decision_source": "config",
  "reason_code": "rule:localSettings",
  "reason_type": "subcommandResults",
  "rule_source": "localSettings",
  "permission_mode": "default",
  "tool_class": "bash",
  "tool_name": "Bash",
  "tool_source": "builtin",
  "tool_use_id": "toolu_01U35MApWYGdwgzJV84NTfYQ",
  "sandbox_enabled": "false",
  "sandbox_violation_count": "0"
}

Claude Code 2.1.310 で、Bash(rm:*) の deny ルールに当たった rm -f 1件が OpenTelemetry のログとして届いたレコードです。2.1.307 で追加された claude_code.safety_event は、権限ルール・hook・permission mode・sandbox のどれが呼び出しを止めたかを category に乗せて1件ずつ送ってきます。手元に受け口を立てて -p 実行を9回(1回 $0.001〜$0.005、合計 $0.03 以下)流し、何が届いて何が届かないかを確かめました。

目次

拒否1件につき tool_decision と safety_event が1組で届く

先頭のレコードの直前には、同じ tool_use_id を持つ claude_code.tool_decision が event.sequence 34 で届いていました。safety_event はその次の 35。拒否された呼び出し1件につき、この2レコードが隣り合って出ていました。

{
  "event.name": "tool_decision",
  "event.sequence": 34,
  "decision": "reject",
  "source": "config",
  "permission_mode": "default",
  "tool_name": "Bash",
  "tool_source": "builtin",
  "tool_use_id": "toolu_01U35MApWYGdwgzJV84NTfYQ",
  "tool_parameters": "{\"bash_command\":\"rm\",\"full_command\":\"rm -f /tmp/se-lab/a.txt\",\"description\":\"Force-remove /tmp/se-lab/a.txt if present\"}"
}

tool_decision の source=config だけでは止めた主体が分からない

tool_decision の source は config・hook・user_permanent・user_temporary・user_abort・user_reject のどれかで、config には deny ルール・permission mode・auto mode の classifier・--disallowedTools がまとめて入ります。Monitoring リファレンス “Tool decision event” 節にも “The source value doesn’t indicate which of these sources matched” とあり、どれが止めたかは safety_event 側の category と rule_source を読む設計です。

先頭の例だと category=permission_rule、rule_source=localSettings。deny ルールを /tmp/se-lab/.claude/settings.local.json に置いたので localSettings になりました。~/.claude/settings.json なら userSettings、managed settings なら category 自体が managed_policy に変わり、reason_code は rule:policySettings になります(doc の例はこちら)。deny ルールの書き方と評価順序はpermissions の allow/deny の記事にまとめてあります。

コマンド本文は tool_decision 側にしか乗らない

safety_event の属性にはコマンド文字列もファイルパスも入りません。”Safety event” 節の記述は “The event’s own attributes hold no command text, file paths, host names, or MCP server and tool names”。何を止めたかを見るには OTEL_LOG_TOOL_DETAILS=1 を付け、同じ tool_use_id の tool_decision の tool_parameters を読みます。上の例では full_command に rm -f /tmp/se-lab/a.txt が入っています。フラグ無しだと tool_parameters 自体が付かず、Bash が何か拒否された、までしか追えません。

reason_type は rule ではなく subcommandResults

reason_code が rule:localSettings なのに、reason_type は rule ではなく subcommandResults でした。Bash ルールは permissions リファレンス “Compound commands” 節のとおり、&& や ; で分割した各サブコマンドに独立して当てられます(“Deny and ask rules apply when any subcommand matches them”)。単独の rm -f でも同じ経路で評価され、その結果が reason_type に残る形です。Bash の deny は今回3回とも subcommandResults でした。

permission_mode は default。起動は --permission-mode manual でしたが、permission-modes リファレンスに “Manual mode appears under its config value, default” とあるとおり、telemetry には設定値の名前で記録されます。

category 一覧と今回観測できた範囲

“Safety event categories” 節の値に、今回の実行で踏めたものの実測値を添えました。

category止めたもの詳細属性今回
permission_rulemanaged 以外の deny / ask ルールrule_source観測。localSettings
managed_policymanaged settings の deny / askrule_sourcedoc のみ
permission_modedontAsk / plan、または答え手のいないプロンプトreason_code, reason_type観測。mode:dontAsk と unknown
hookPreToolUse / PermissionRequest hookhook_event, reason_code観測。pre_tool_use
auto_mode_blockauto mode の classifier が blockclassifier_categorydoc のみ
auto_mode_no_verdictclassifier の判定が得られず拒否reason_codedoc のみ
safety_check作業ディレクトリ外の Read / Edit / Write などの組み込みチェックcircuit_breaker観測。reason_code=workingDir
static_analysis解析できない Bash / Monitor コマンドreason_codedoc のみ
sandbox_overridesandbox 外で実行する確認reason_codedoc のみ
user_rejected人が No を選んだ、または permission-prompt-tool が拒否decision_sourcedoc のみ
sandbox_network実行後、sandbox の proxy が接続を拒否sandbox_violation_countdoc のみ
sandbox_othersandbox_network のうちホスト名に読めないものsandbox_violation_countdoc のみ
hook_reviewhook が結果やターン終了を差し戻したhook_eventdoc のみ
other上記以外reason_codedoc のみ

auto mode の行は doc からの引用

auto mode の classifier を意図的に block させる検証はしていません。auto_mode_block の classifier_category は OTEL_LOG_TOOL_DETAILS=1 のときだけ付き、値には data_exfiltration のような組み込みルール名が入ります。tool_decision 側にも classifier_verdict(allow / block / no_verdict / unavailable)が 2.1.301 以降で乗るようになりました。

sandbox 系と hook_review は実行後に届く

sandbox_network・sandbox_other・hook_review は呼び出しが止められた記録ではなく、実行された後に届く記録で、”Calls without a safety event” 節が、承認された呼び出しに付く safety_event はこの3種類だけだと書いています。sandbox の proxy による拒否を category を問わず拾うには、sandbox_violation_count が "0" でないレコードを探すしかありません。値は "0" から "20" までの文字列です。

受け口は Python の http.server で足りる

OTLP http/json を 127.0.0.1:4318 で受ける

Collector を立てる前に、届く JSON をそのまま保存する受け口を書きました。POST された body をファイルに落とすだけの 13行です。

import time, pathlib
from http.server import BaseHTTPRequestHandler, HTTPServer
OUT = pathlib.Path("/tmp/se-lab-otlp")
class H(BaseHTTPRequestHandler):
    def do_POST(self):
        n = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(n)
        name = f"{int(time.time()*1000)}_{self.path.strip('/').replace('/','_')}.json"
        (OUT / name).write_bytes(body)
        self.send_response(200); self.send_header("Content-Type","application/json")
        self.end_headers(); self.wfile.write(b"{}")
    def log_message(self, *a): pass
HTTPServer(("127.0.0.1", 4318), H).serve_forever()

Claude Code 側に要るのは環境変数だけで、Monitoring リファレンスの設定表に “Claude Code has no default protocol” とあるとおり OTEL_EXPORTER_OTLP_PROTOCOL は省けません。

export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_LOGS_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=none
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
export OTEL_LOGS_EXPORT_INTERVAL=1000   # 既定 5000ms。検証中だけ詰める
export OTEL_LOG_TOOL_DETAILS=1          # tool_decision にコマンド本文を乗せる

claude -p "..." --permission-mode manual --model haiku --output-format json

1回の -p 実行で POST は 4〜6回。最初の body が 41KB と大きいのは、起動時の hook_registered 12件・plugin_loaded 8件・mcp_server_connection 8件がまとめて乗るためで、safety_event はその後の 5〜12KB の body に入ってきます。exporter や送信間隔の一般的な設定はOpenTelemetry 設定の記事に書きました。

console exporter は –output-format json では何も出なかった

OTEL_LOGS_EXPORTER=console は受け口なしで標準出力にレコードを出す exporter です。ただし -p と --output-format json の組み合わせでは2回試して2回とも、標準出力には結果の JSON(2,203バイト)だけ、標準エラーは 0バイトでした。--output-format text に変えると同じプロンプトで 43レコード・57,696バイトが標準出力に流れ、safety_event も1件含まれます。json 出力と組むなら exporter は otlp にするしかありません。

素の -p では拒否された Write に safety_event が付かない

deny ルールと hook と「ルールの無い Write」を1回の -p で順に踏ませました。設定は次の1ファイル。

// /tmp/se-lab/.claude/settings.local.json
{
  "permissions": { "deny": ["Bash(rm:*)"] },
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{ "type": "command", "command": "/tmp/se-lab/hooks/block-touch.sh" }]
    }]
  }
}

プロンプトは「(1) rm -f /tmp/se-lab/a.txt (2) touch /tmp/se-lab/b.txt (3) Write ツールで c.txt に hi と書く。拒否されても止まらず次へ」。モデルは haiku、4ターン、12秒で3件とも拒否されて終わります。

素の -p では tool_decision の reject だけが残る

結果 JSON の permission_denials には Bash・Bash・Write の3件が並ぶのに、届いた safety_event は 2件。Write の分だけ無く、残ったのはこの tool_decision だけでした。

{
  "event.name": "tool_decision",
  "decision": "reject",
  "source": "config",
  "permission_mode": "default",
  "tool_name": "Write",
  "tool_source": "builtin",
  "tool_use_id": "toolu_011uoiofjuNf4hfQb5BrVEio"
}

doc はこれを “Calls without a safety event” 節で明記しています。Manual モードで作業ディレクトリ内のファイルを書く確認は “Prompts with no category”、どの category にも当たらないプロンプトで、-p には答える人がいないので拒否だけが起きる。”Such refusals happen in a -p run with neither --permission-prompt-tool nor --permission-prompts none” が該当箇所です。safety_event だけを数える集計には、この拒否が1件も入りません。

拾うなら doc の指示どおり、decision=reject かつ source=config の tool_decision のうち、同じ tool_use_id の safety_event が無いものを数えます。今回の4実行分を集計すると tool_decision の reject が 10件、うち safety_event の無いものが 1件。その1件がこの Write です。

–permission-prompts none を付けると permission_mode で届く

同じプロンプトに --permission-prompts none を足すと、Write の拒否にも safety_event が付きました。

{
  "event.name": "safety_event",
  "category": "permission_mode",
  "decision": "reject",
  "decision_source": "config",
  "reason_code": "unknown",
  "reason_type": "asyncAgent",
  "permission_mode": "default",
  "tool_class": "file",
  "tool_name": "Write",
  "tool_source": "builtin",
  "tool_use_id": "toolu_01HmkUyWR5LkC6Tv8LeR8vgA",
  "sandbox_enabled": "false",
  "sandbox_violation_count": "0"
}

headless リファレンスの “Turn off permission prompts in unattended runs” 節にあるフラグで、claude --help の説明は “nobody: anything that would prompt is denied automatically; the permission mode still decides everything else”。category は permission_mode になり、reason_code は unknown、reason_type は asyncAgent。doc が例に挙げる mode:dontAsk のような値ではなく、2.1.310 ではこの組み合わせで届きました。

dontAsk モードなら reason_code は mode:dontAsk

--permission-mode dontAsk で同じ3件を流すと、Write の safety_event は reason_code=mode:dontAsk、reason_type=mode と、doc の例どおりの値になります。permission_mode 属性も3レコードすべて dontAsk に変わりました。

{
  "event.name": "safety_event",
  "category": "permission_mode",
  "decision": "reject",
  "decision_source": "config",
  "reason_code": "mode:dontAsk",
  "reason_type": "mode",
  "permission_mode": "dontAsk",
  "tool_class": "file",
  "tool_name": "Write",
  "tool_use_id": "toolu_01HYwi1V5MCQneSQ8ggrBhzH"
}

permission-modes リファレンスは dontAsk を “Locked-down CI and scripts” 向けとし、プロンプトになる操作は拒否されるが “a mod you installed can still approve some of those calls” と但し書きを付けています。mod が PreToolUse より前に判断する仕組みはMods の記事に書きました。無人実行で止められた操作を全部 safety_event に残すなら、dontAsk か --permission-prompts none のどちらかを付けて走らせます。

PreToolUse hook の exit 2 は category=hook で届く

#!/bin/bash
# /tmp/se-lab/hooks/block-touch.sh
input=$(cat)
cmd=$(printf '%s' "$input" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("command",""))')
case "$cmd" in
  *touch*) echo "touch is blocked by hooks/block-touch.sh" >&2; exit 2 ;;
esac
exit 0
{
  "event.name": "safety_event",
  "category": "hook",
  "decision": "reject",
  "decision_source": "hook",
  "hook_event": "pre_tool_use",
  "reason_code": "hook:PreToolUse",
  "reason_type": "hook",
  "permission_mode": "default",
  "tool_class": "bash",
  "tool_name": "Bash",
  "tool_use_id": "toolu_01FHsBCf4NVZfPWbtsaZ4PSc"
}

stderr の文言は会話ログに残り、telemetry には残らない

hooks リファレンス “Exit code 2” 節のとおり、exit 2 は JSON を出さなくてもツール呼び出しを止めます。hook が stderr に書いた “touch is blocked by hooks/block-touch.sh” はモデルに渡り、会話ログの tool_result に “PreToolUse:Bash hook error: [/tmp/se-lab/hooks/block-touch.sh]: touch is blocked …” として残りました。safety_event にはこの文言も hook のパスも乗らず、reason_code=hook:PreToolUse と hook_event=pre_tool_use だけ。どの hook が止めたかは telemetry だけでは分からず、tool_decision の tool_parameters にあるコマンドから逆算することになります。

結果を差し戻す hook は hook_review

PostToolUse や Stop の hook が結果やターン終了をブロックした場合は、category が hook_review、decision が sent_back、hook_event が post_tool_use / post_tool_use_failure / stop / subagent_stop になります(doc の記述。今回は未検証)。ターン終了を差し戻した hook_review には tool_use_id が付かない、という例外も “Event correlation attributes” 節に書かれています。

作業ディレクトリ外の Read は safety_check

reason_code は workingDir、circuit_breaker 属性は無し

Manual モードで /etc/hosts を Read させた1件の記録です。safety_event は category=safety_check、reason_code=workingDir で、doc が safety_check の詳細属性としている circuit_breaker(例: outsideReadsBlocked)は付きませんでした。

{
  "event.name": "safety_event",
  "category": "safety_check",
  "decision": "reject",
  "decision_source": "config",
  "reason_code": "workingDir",
  "permission_mode": "default",
  "tool_class": "file",
  "tool_name": "Read",
  "tool_use_id": "toolu_01McqWhTQ4v4cUtXswtAhTnk"
}

tool_decision では deny ルールの拒否と区別できない

tool_decision 側はこの Read も source=config のみで、deny ルールの拒否とは区別が付きません。見分けられるのは safety_event の category だけです。

会話ログの is_error を数える方法との違い

JSONL には拒否の理由が3通りの文言で残る

同じ3件の拒否は、~/.claude/projects/-private-tmp-se-lab/<session>.jsonl の tool_result にも is_error: true で残っています。ディレクトリ名が -tmp-se-lab でないのは、macOS の /tmp が /private/tmp の symlink だから。

Permission to use Bash with command rm -f /tmp/se-lab/a.txt has been denied.
PreToolUse:Bash hook error: [/tmp/se-lab/hooks/block-touch.sh]: touch is blocked by hooks/block-touch.sh
Claude requested permissions to write to /tmp/se-lab/c.txt, but you haven't granted it yet.

auto mode の拒否をこの JSONL から数える方法も使われていて、classifier に拒否された旨の文言を tool_result から拾う形です。止めた主体ごとに文言が違うので、deny・hook・プロンプト拒否を分けるには3パターンのマッチが要ります。

2つの数え方の違い

会話ログの is_errorsafety_event
前提設定不要。過去のセッションも読めるCLAUDE_CODE_ENABLE_TELEMETRY=1 と受け口を先に用意
止めた主体の判別エラー文言のパターンマッチcategory / reason_code / rule_source
コマンド本文同じ tool_use_id の tool_use ブロックにあるOTEL_LOG_TOOL_DETAILS=1 で tool_decision 側に乗る
複数マシン各マシンの ~/.claude を集める1つの collector に届く
素の -p のプロンプト拒否is_error に残るsafety_event 無し。tool_decision だけ

受けた JSON を category で数える

import json, glob, collections

by_cat = collections.Counter()
safety_ids, rejects = set(), []
for f in sorted(glob.glob("/tmp/se-lab-otlp/*.json")):
    for rl in json.load(open(f))["resourceLogs"]:
        for sl in rl["scopeLogs"]:
            for rec in sl["logRecords"]:
                a = {x["key"]: list(x["value"].values())[0] for x in rec["attributes"]}
                if a.get("event.name") == "safety_event":
                    by_cat[a["category"]] += 1
                    safety_ids.add(a["tool_use_id"])
                elif a.get("event.name") == "tool_decision" and a.get("decision") == "reject":
                    rejects.append(a["tool_use_id"])
print(dict(by_cat))
print(len(rejects), sum(1 for t in rejects if t not in safety_ids))
{'permission_rule': 3, 'hook': 3, 'permission_mode': 2, 'safety_check': 1}
10 1

manual・--permission-prompts none・dontAsk・Read の4実行、20 body・323KB を数えた結果です。2行目が「reject 10件のうち safety_event の無いもの 1件」。Grafana や Datadog に OTLP で送る場合も、クエリで数えるのはこの2つ、category 別の件数と safety_event を持たない reject です。

まとめ

  • claude_code.safety_event は 2.1.307 以降、拒否1件ごとに category・reason_code・rule_source を持って届く。コマンド本文は OTEL_LOG_TOOL_DETAILS=1 を付けた tool_decision 側で読む
  • 素の -p で答え手のいないプロンプトは tool_decision の reject(source=config)だけで、safety_event が付かない。--permission-prompts none か dontAsk を付ければ permission_mode として届く
  • 2.1.310 で観測した値: deny は reason_type=subcommandResults、作業ディレクトリ外の Read は reason_code=workingDir で circuit_breaker 無し
  • console exporter は --output-format json と組むと何も出ない。Python の http.server で OTLP http/json を受ければ 13行で済む
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次