Skip to content
Security & Privacy

Claude Code safety_event: What It Logs, Tested

Claude Code 2.1.307 added a safety_event OpenTelemetry event. I ran 2.1.306, 2.1.307 and 2.1.310 to see which refusals it logs and which it skips in -p runs.

Nerd Level TechPublished 14 min readالعربية
Cover image for Claude Code safety_event: What It Logs, Tested

claude_code.safety_event is an OpenTelemetry event that Claude Code 2.1.307 added to log tool calls that a permission rule, auto mode, a safety check, a hook or the sandbox stopped, prompted or sent back. In my claude -p runs on 2.1.310 it fired for deny rules, ask rules, dontAsk mode, hooks and two built-in safety checks, and it did not fire when a -p run refused a Bash command that nothing granted.

I kept the logs of 108 headless sessions run with the console log exporter, to see what the event contains and what it leaves out. I found a gap worth knowing about before you build an alert on it.

TL;DR

  • claude_code.safety_event first appeared in 2.1.307. On 2.1.306, the three refusal cases I ran on every build produced only a tool_decision event. On 2.1.307 and 2.1.310 they produced both.
  • It covered deny rules from five sources (project, local, a --settings file, the --disallowedTools flag, managed settings), an ask rule in project settings, dontAsk mode, --permission-prompts none, a PreToolUse hook deny, a PostToolUse block, a Read outside the working directory with no allow rule, and a Write to .claude/settings.json.
  • It did not fire when a -p run refused a Bash command that nothing granted. That refusal appeared only as a tool_decision event with decision set to reject. The docs describe this case as a prompt with no category. A denied tool that the model never called, WebFetch, produced no event for that tool.
  • The event's own attributes held no Bash command text, including in the runs with OTEL_LOG_TOOL_DETAILS=1. The command appeared on the matching tool_decision event. All 42 safety events in my 69 main runs on 2.1.310 shared a tool_use_id with a tool_decision event.
  • I tested claude -p only, with the haiku alias. Auto mode, the sandbox categories, interactive prompts and OTLP export are untested.

What You'll Learn

  • What claude_code.safety_event is and which version added it
  • How to print it with the console exporter using two environment variables
  • Which refusals produce an event, with the category and detail attribute for each
  • Which refusals do not, and how to find them
  • What the event leaves out, and how to join it to the command

What the event is

claude_code.safety_event is a structured log event that Claude Code emits when something stops, prompts for, or sends back a tool call. The changelog lists it under 2.1.307, dated October 21, 2026: "Added the claude_code.safety_event OpenTelemetry event for each tool call a permission rule, auto mode, a safety check, a hook or the sandbox stopped, prompted or sent back".1

The monitoring documentation says the event "exports with the other events and has no separate setting."2 If you already ship Claude Code telemetry, it arrives with the rest. The docs define 14 values for the category attribute: permission_rule, managed_policy, permission_mode, hook, auto_mode_block, auto_mode_no_verdict, safety_check, static_analysis, sandbox_override, user_rejected, sandbox_network, sandbox_other, hook_review and other.2

The decision attribute is accept, reject, or sent_back when a hook blocked a result or the end of a turn.2

How I tested it

The setup is two required environment variables and a shell. I also shortened the export interval to one second. I did not test whether the default would have flushed before a short run exited.

bash
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_LOGS_EXPORTER=console
export OTEL_LOGS_EXPORT_INTERVAL=1000   # docs default is 5000

claude -p "Use the Bash tool to run exactly this command and nothing else: touch made-by-lab.txt
If the tool is refused, reply with the single word REFUSED and stop." \
  --model haiku --max-turns 4 --disallowedTools "Bash(touch:*)" < /dev/null > run.txt

grep -A 30 'body: "claude_code.safety_event"' run.txt

The console exporter writes each event to stdout as a JavaScript style object, not JSON, so the model's reply and the events share one stream. I used grep and a small parser to pull out the attributes. The < /dev/null in the command only skips a stdin warning, and my scripted runs did not use it.

Every run used its own fresh directory, the haiku alias and Linux x86_64, on October 26, 2026. The logs show the model as claude-haiku-5-5 in every reported run, on all three builds. 2.1.310 was the latest tag on npm when I checked.3 I used touch made-by-lab.txt as the command. Three runs with echo in dontAsk mode were allowed with no rule at all, so echo could not show a refusal, and I dropped it.

I ran 23 configurations three times each on 2.1.310, which is 69 runs. For the version comparison I installed 2.1.306 and 2.1.307 from npm in their own folders and ran three cases three times each on both, plus the same three cases again on 2.1.310. That is 27 runs. Four follow-up checks of three runs each, the echo check above and three Read and Write checks described below, bring the total to 108 sessions with saved logs.

I had run the 2.1.306 and 2.1.307 cases once earlier, three runs per case, without keeping the logs. The results matched, and the table uses the rerun.

Two of the 23 configurations are not reported as results. One was a PostToolUse test whose allow rule sat in project settings, which a quirk described below confounded, so I redid it with the allow rule on the command line. The other was a plan mode write test where the model declined before calling the tool in two of three runs, and whose logs show a second model, claude-sonnet-5-5, so I left it out. In the results table and the version table, each case gave the same event types and the same attribute values in all three runs. I left event ids and sequence numbers out of that comparison.

Results on 2.1.310

Table of the safety_event category and detail attribute reported for each refusal case on Claude Code 2.1.310, with two rows where no safety_event appeared. One of them is a refused Bash command that nothing granted.

Rule denials report which settings layer supplied the rule. The rule_source value matched where I put the rule each time: projectSettings, localSettings, flagSettings for a --settings file, cliArg for --disallowedTools, and policySettings for a managed settings file I placed at /etc/claude-code/managed-settings.json. The managed case also changes the category to managed_policy, so a managed rule is easy to separate from a developer's own deny rule.

An ask rule in a -p run was refused in my test, which fits a session where nobody can answer a prompt. It was logged as permission_rule with rule_source set to projectSettings. A deny rule in project settings also beat an allow for the same command on the command line (--allowedTools), three runs out of three, and was logged the same way.

Hooks split in two. A PreToolUse hook that returned a deny decision gave category hook, decision reject, and reason_code hook:PreToolUse. A PostToolUse hook that returned a block gave category hook_review and decision sent_back. In that second case the matching tool_decision event said accept, because the allow rule had approved the call before it ran. The hook blocked the result afterward.

I tested two built-in checks, and both landed under safety_check. A Write to .claude/settings.json in acceptEdits mode carried circuit_breaker set to claudeSettingsFile. It stayed refused, with the same event, when I added --allowedTools Write, three runs out of three. A Read of /etc/hostname, which is outside the working directory, carried reason_code set to workingDir and no circuit_breaker attribute. That Read had no allow rule. With --allowedTools Read the same Read was accepted and logged no safety event, three runs out of three. So in my test an allow rule granted the outside read, while the settings file write stayed refused even with an allow. I tried one path and one rule form for each. My first Read test used an allow rule in project settings, which a quirk described below ignored. I reran it with no allow rule at all and got the same event. The docs list circuit_breaker as the detail attribute for this category, with outsideReadsBlocked as one example value.2 I did not see that value on my plain Read. Treat the exact attribute for an outside read as something to confirm on your own build.

It first appeared in 2.1.307

I ran a deny rule in project settings, dontAsk mode, and a PreToolUse hook deny on 2.1.306, 2.1.307 and 2.1.310, three runs each, with the same prompt and settings on every build.

Case2.1.3062.1.3072.1.310
Deny rule in project settingstool_decision onlytool_decision + safety_eventtool_decision + safety_event
dontAsk modetool_decision onlytool_decision + safety_eventtool_decision + safety_event
PreToolUse hook denytool_decision onlytool_decision + safety_eventtool_decision + safety_event

npm lists 2.1.306 as published on October 21, 2026 at 02:28 UTC and 2.1.307 at 20:47 UTC the same day, which matches the changelog.3 2.1.306 is the only older build I ran, but the changelog lists the event under 2.1.307, so a dashboard built on it should show nothing for machines on older builds.

The gap: refusals with no event

The docs say some refusals produce no safety event. One is a -p run where a prompt had no category and nobody could answer it, "with neither --permission-prompt-tool nor --permission-prompts none".2

I saw the same behavior. With no rule granting touch, the -p run refused the call, the tool_decision event said reject with source config, and no safety event followed, three runs out of three. Adding --permission-prompts none to the same command produced a safety event with category permission_mode, reason_type asyncAgent and reason_code unknown.

Our read: an alert built only on safety_event will miss one headless failure that unattended jobs can run into, which is an agent asking for something nobody pre-approved. The docs' own suggestion is to look for tool_decision events that report reject with source config and no safety event sharing their tool_use_id.2 If you run unattended jobs, build that second query as well, or pass --permission-prompts none so those refusals get a category.

A denied tool that the model never called is not a refusal, and it left no event either. With WebFetch denied, the model said in all three runs that the tool was not available, and I saw no safety_event in any of them. In every run the model first searched for the tool with ToolSearch. In two of the three runs its replies say it then tried curl through Bash, and those refusals had no safety event either, which is the gap above. I did not run a control without the deny rule. The WebFetch result is consistent with my MCP permission rules test, where deny rules took a tool out of the session's tool list in the runs I inspected, although the model still tried to call it in 8 of the 11 runs where a tool was missing. I did not inspect the tool list here, so I am inferring it from what the model said.

What the event holds, and how to join it

The docs say the event's own attributes hold no command text, file paths, host names, or MCP server and tool names.2 The attribute lists in my Bash, Read and Write runs held none of those. No MCP tool was called in any run, so I did not check MCP server or tool names. With OTEL_LOG_TOOL_DETAILS=1 set on one deny case, three runs, the tool_decision event carried the full command in tool_parameters, and the safety_event for the same call did not.

The two events join on tool_use_id. Across the 69 runs on 2.1.310, all 42 safety events matched a tool_decision event with the same tool_use_id. In 39 of them the safety event came one event.sequence number after the decision. In the other three, the PostToolUse cases, it came five later, after the tool had run.

The record also carries the standard attributes, which the docs say can include workspace.host_paths and the repository attributes.2 In my console output they also included user.email and account identifiers. If you forward these events to a shared collector, check what the standard attributes expose before you do.

One thing I saw and did not explain

An allow rule written to .claude/settings.json in my scratch directory did not take effect in -p runs. The touch call was refused with no safety event, three runs out of three, and again in a second directory with a stub .git folder. The same allow rule in settings.local.json, in a --settings file or in --allowedTools worked. A deny rule in .claude/settings.json applied normally. I did not chase the cause, and I would not generalize beyond my setup, but it explains why an early version of my test showed refusals I had expected to be allowed.

What I did not test

  • Interactive sessions, where a person answers prompts. The docs list user_rejected for a declined or dismissed prompt, which I did not trigger.
  • Auto mode: auto_mode_block, auto_mode_no_verdict and the classifier_category attribute.
  • The sandbox categories sandbox_network, sandbox_other and sandbox_override, and static_analysis.
  • OTLP export. I used the console exporter only.
  • MCP tools, subagents, skills and plugins.
  • Plan mode, and PermissionRequest and Stop hooks. The docs mention them under permission_mode, hook and hook_review, and my one plan mode test was inconclusive.
  • User level settings as a rule source, other models, other operating systems and Bedrock, Vertex or Foundry.
  • Builds other than 2.1.306, 2.1.307 and 2.1.310.

Bottom Line

In my claude -p runs, claude_code.safety_event gave a usable audit trail for rule denials, hooks and two built-in checks, with the settings layer named for each rule. It stayed silent when a headless run refused a Bash command that nothing granted, so pair it with a query on tool_decision rejects that have no matching safety event.

Start with the console exporter on one machine, trigger a deny rule, and read the record. Then decide whether the standard attributes are safe to ship before you point the exporter at a shared collector.

References

Frequently Asked Questions

What is claude_code.safety_event?

It is an OpenTelemetry log event added in Claude Code 2.1.307 that records tool calls that a permission rule, auto mode, a safety check, a hook or the sandbox stopped, prompted or sent back. Its category attribute says which check acted, and decision says whether the call was accepted, rejected or sent back.

How do I see safety events locally?

Set CLAUDE_CODE_ENABLE_TELEMETRY=1 and OTEL_LOGS_EXPORTER=console, then run claude -p and read stdout. The event has no separate setting. In my -p runs the events printed to standard output, mixed with the model's reply.

Does a safety event include the command that was blocked?

No. The docs say the event's own attributes hold no command text, file paths or server names, and the attribute lists in my Bash, Read and Write runs held none of those. In my three runs with OTEL_LOG_TOOL_DETAILS=1, the command appeared only on the tool_decision event, which you join by tool_use_id.

Why did my blocked command have no safety event?

In a -p run, a prompt with no category that nobody can answer is refused without a safety event, and the docs list this case. I saw the same behavior with a Bash command that no rule granted, in 3 of 3 runs. The refusal still shows up as a tool_decision event with decision reject and source config. Passing --permission-prompts none gave the same call a permission_mode safety event.

Which Claude Code version added safety_event?

Version 2.1.307, published October 21, 2026. I confirmed it by running three builds: 2.1.306 emitted only tool_decision for three refusal cases, and 2.1.307 and 2.1.310 emitted both events.

Footnotes

  1. Claude Code changelog, Anthropic, version 2.1.307, October 21, 2026, read October 26, 2026. Source for the release of claude_code.safety_event and its quoted description. ↩ ↩2 ↩3

  2. Monitoring, Claude Code documentation, Anthropic, read October 26, 2026. Source for the event description, the 14 category values, the attribute list, the export behavior, the OTEL_LOG_TOOL_DETAILS flag and the cases without a safety event. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12

  3. @anthropic-ai/claude-code on npm, publish times and dist-tags queried with npm view, October 26, 2026. Source for the 2.1.306 and 2.1.307 publish times and for 2.1.310 as the latest tag. ↩ ↩2 ↩3

Stay on the Nerd Track

One email a week: a Thursday issue with one system teardown, three signals, one workflow, one job and one Arabic takeaway.

No spam. Unsubscribe anytime.