{
"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_rule | managed 以外の deny / ask ルール | rule_source | 観測。localSettings |
| managed_policy | managed settings の deny / ask | rule_source | doc のみ |
| permission_mode | dontAsk / plan、または答え手のいないプロンプト | reason_code, reason_type | 観測。mode:dontAsk と unknown |
| hook | PreToolUse / PermissionRequest hook | hook_event, reason_code | 観測。pre_tool_use |
| auto_mode_block | auto mode の classifier が block | classifier_category | doc のみ |
| auto_mode_no_verdict | classifier の判定が得られず拒否 | reason_code | doc のみ |
| safety_check | 作業ディレクトリ外の Read / Edit / Write などの組み込みチェック | circuit_breaker | 観測。reason_code=workingDir |
| static_analysis | 解析できない Bash / Monitor コマンド | reason_code | doc のみ |
| sandbox_override | sandbox 外で実行する確認 | reason_code | doc のみ |
| user_rejected | 人が No を選んだ、または permission-prompt-tool が拒否 | decision_source | doc のみ |
| sandbox_network | 実行後、sandbox の proxy が接続を拒否 | sandbox_violation_count | doc のみ |
| sandbox_other | sandbox_network のうちホスト名に読めないもの | sandbox_violation_count | doc のみ |
| hook_review | hook が結果やターン終了を差し戻した | hook_event | doc のみ |
| other | 上記以外 | reason_code | doc のみ |
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_error | safety_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行で済む