Operations 20 min read

Claude Code's Permission Audit Blind Spot: Why Rejection Counts Don't Reveal the Blocker

Claude Code's tool_decision events only capture accept/reject with a generic source field merging eight judgment sources, while the newer safety_event adds 14 categories but suffers from join gaps, attribute truncation, and missing events for unattended rejections, requiring joined audit tables and CI validation to identify actual blockers.

Architecture Development Notes
Architecture Development Notes
Architecture Development Notes
Claude Code's Permission Audit Blind Spot: Why Rejection Counts Don't Reveal the Blocker

The article opens with a common dashboard pattern: tracking "rejections in the last 7 days" via Claude Code's OpenTelemetry tool_decision events, which only record accept or reject. This count looks impressive but answers nothing when stakeholders ask which rejections came from managed policies, whether auto mode actually ruled unsafe or simply returned no verdict, or how many rejections occurred with no human present to respond.

One reject carries only one bit

The tool_decision event uses a single source field with six possible values: config, hook, user_permanent, user_temporary, user_abort, user_reject. The documentation lists config as an umbrella for eight distinct automatic-decision sources: project settings allow/deny rules, personal settings allow/deny rules, enterprise managed policies, the --allowedTools / --disallowedTools CLI flags, the active permission mode, the auto-mode classifier, prior in-session grants, and the tool's own safety classification. The docs explicitly state that config does not indicate which of the eight actually matched.

A ninth case also maps to config: when the permission prompt itself fails — canUseTool callback or --permission-prompt-tool returns an illegal result, or the input stream closes while a request is pending. These were logged as user_reject before version 2.1.216.

Consequently, a pie chart grouped by source can only show "automatic decision" as the largest slice, never distinguishing "managed policy denied" from "CLI flag denied" — the former belongs in compliance reports, the latter is a one-off launch parameter.

The second event carries the reason, but it is not a mirror of rejections

Version 2.1.307 (21 Oct UTC) introduced claude_code.safety_event. The release notes say it fires for every tool call stopped by a permission rule, auto mode, safety check, hook, or sandbox, or sent back for confirmation. "Confirmation" and "sent back" reveal it covers more than rejections. The safety_event.decision field has three values: accept, reject, sent_back (used when a hook intercepts a result or a turn ends). Therefore the denominator of rejections differs from the denominator of safety_event records.

The explanatory fields are category, rule_source, reason_code; reason_type distinguishes cause types (e.g., rule, mode, hook, classifier), and decision_source records who made the decision (a source value or other). category is the key. The docs list 14 values. Eight directly address the opening questions: managed_policy, permission_rule, permission_mode, auto_mode_block, auto_mode_no_verdict, safety_check, user_rejected, hook. The remaining six are static_analysis, sandbox_override, sandbox_network, sandbox_other, hook_review, other. rule_source further splits the settings layer: policySettings, userSettings, projectSettings, localSettings, flagSettings, cliArg, session. managed_policy with policySettings = managed policy; permission_rule with projectSettings = project-level deny rule. The release notes name five executor categories; the docs provide 14 category values. Mapping by name: managed_policy and permission_rule → "permission rules"; auto_mode_block and auto_mode_no_verdict → "auto mode"; safety_check → "safety check"; hook and hook_review → "hooks"; sandbox_* → "sandbox". The docs supply no cross-reference table and do not classify permission_mode, static_analysis, user_rejected, other. Audit dimensions must be defined per category.

Classifier returning no verdict is also a rejection

The second question separates inside category: auto_mode_block = classifier emitted block; auto_mode_no_verdict = classifier ran but produced no usable verdict — rejection does not mean the classifier deemed the call unsafe. tool_decision.classifier_verdict is finer: allow, block, no_verdict, unavailable (request failure: rate limit, timeout, connection error; also recorded when server-side classifier review is enabled but the server returns no safety verdict).

A reverse trap exists for incidents: the docs note that a classifier check failing mid-way may still land in block, so a partial outage manifests as more block s, not more unavailable s. Interpreting a rise in block as model behavior degradation may actually be reading an outage.

The third question depends on whether a safety_event exists. A dedicated doc section lists calls without safety_event: (1) calls approved via a prompt that has no category; (2) calls approved by canUseTool or --permission-prompt-tool on a prompt that only a PreToolUse hook required; (3) AskUserQuestion and ExitPlanMode, regardless of rejection. The first two are approvals; the third is ambiguous. All lack a permission-judgment event. Sandbox network rejections and hook reviews emitted after execution are a separate matter.

The rejection-side gap is documented elsewhere: in -p runs without --permission-prompt-tool or --permission-prompts none, repeated blocks with no one to answer cause Claude Code to reject directly, logging reject + source=config with no accompanying safety_event.

Same "prompt has no category" scenario: a human clicking reject yields a safety_event with category=user_rejected; no human present leaves only the reject record. This contrast comes from combining two separate doc passages; the docs do not place them side by side. The difference is not whether a block occurred, but whether a human existed. reject also subsumes user_abort: user closed the prompt without answering, treated as rejection; in SDK and -p sessions it also covers interruption while canUseTool / --permission-prompt-tool request is pending. Thus one reject may be policy block, human said no, human dismissed prompt, or no human at all. These describe event classification, not the wording the model receives.

Attribution fields can be legitimately dropped

The two events align on tool_use_id, but not universally. The docs state this value appears in both tool_result and hook payloads, except safety_event records marking turn end — they carry no tool_use_id. On the other side, rejected calls have no tool_result, only tool_decision. Therefore tool_decision, tool_result, and hook payloads chain only on the accept path; the reject path has just two events to join.

Attributes are not unlimited. The docs specify that lowering the OpenTelemetry SDK's OTEL_LOGRECORD_ATTRIBUTE_COUNT_LIMIT below the tool_decision attribute count causes the SDK to drop the last attributes Claude Code sets, in order: parent_agent_id, agent_id, request_id, permission_mode, classifier_category, classifier_verdict. The first three require 2.1.310; the last three were added in 2.1.301 and are dropped in that order when the limit is too low.

This mechanism has already caused an incident, predating safety_event. Version 2.1.301 added classifier_verdict, permission_mode, classifier_category to tool_decision — the release note says the purpose was to separate auto-mode classifier verdicts from permission rules, i.e., for attribution. The very next release, 2.1.302, fixed the side effect: low limits pushed out tool_use_id, tool_source, tool_parameters. Among the dropped three was the join key, breaking the link between tool_decision and tool_result /hook payloads, with no error. The release note only said "newly added attributes"; by chronological inference they are these three.

By 2.1.307 when safety_event appeared, the documented drop order no longer includes tool_use_id; the leading candidates became permission_mode, classifier_category, classifier_verdict. Today the same limit pressure drops attribution dimensions first, not the join key. This is harder to detect: records exist, joins succeed, but permission_mode is empty, or auto-mode blocks show only block without revealing which built-in rule matched, while classifier_category requires OTEL_LOG_TOOL_DETAILS=1 to appear.

Whether this becomes a cost-team problem depends on how the collector configures the SDK. Self-check is straightforward: since 2.1.301 permission_mode lists no default-absence conditions; if you cannot read it, either the version is wrong or it was truncated. This criterion is derived from doc asymmetry: classifier_verdict documents four missing scenarios; permission_mode documents none.

Version channels must also align. Today npm latest is 2.1.311; stable stops at 2.1.308. The latter has safety_event but tool_decision still lacks request_id, agent_id, parent_agent_id; per the latest docs' collection rules these would read as empty. This is inferred from documented version requirements, not verified on a live backend.

Same action, two run modes yield different source values

The docs' descriptions of user_permanent and user_temporary hide a divergence. In interactive CLI, choosing "Yes, and don't ask again" logs user_permanent; subsequent calls hitting that saved rule log config. In Agent SDK or non-interactive -p sessions, both the initial choice and subsequent hits log user_permanent. user_temporary behaves analogously.

The reject side splits the same way. In interactive CLI, a call hitting a personal-settings deny rule logs config; in SDK and -p, the same deny rule logs user_reject. Both sides separate initial decision from subsequent hits, but accept side lands in user_permanent while reject side lands in user_reject.

Combined: the same deny rule is not a user rejection in interactive terminal, but is a user rejection in SDK. Rejection rates grouped by source are incomparable across environments; the delta between CI -p baseline and production terminal numbers partly reflects run mode, not policy.

Build the audit table on category

The fix is not complex but must extract dimensions from source. The audit table uses tool_use_id as primary key, left-joining the two events; category, rule_source, reason_code become primary dimensions; source is demoted to an auxiliary column.

SELECT d.tool_use_id, d.decision, d.source, s.category, s.rule_source, s.reason_code FROM tool_decision AS d LEFT JOIN safety_event AS s ON s.tool_use_id = d.tool_use_id WHERE d.decision = 'reject';

The left join's point is not SQL syntax but naming the unmatched records. Store "no safety_event " as an explicit state value, not NULL.

Two alerts. First, watch the proportion of reject with no safety_event, requiring three simultaneous conditions: decision=reject, source=config, no safety_event on the same tool_use_id. These three together cover only "prompt has no category and no one to answer"; AskUserQuestion and ExitPlanMode also lack safety_event but their source is not necessarily config — mixing them distorts the reading. This ratio itself means "unattended rejection share"; sudden drop to zero or doubling warrants checking the collection pipeline.

Second, watch critical attribute loss. tool_decision missing tool_use_id or permission_mode, or safety_event missing category, all strip attribution capability; such records should be rejected at ingestion.

In CI, synthesize four rejection types and assert they land in distinct combinations:

Managed settings deny → expected category: managed_policy, expected rule_source: policySettings Project settings deny → expected category: permission_rule, expected rule_source: projectSettings Auto mode no verdict → expected category: auto_mode_no_verdict, expected rule_source: none -p unattended → expected category: no safety_event, expected rule_source: none

One of the four assertions is that an event does not exist — the easiest to miss.

On privacy: the docs state safety_event 's own attributes exclude command text, file paths, hostnames, MCP servers, and tool names; to reconstruct the command you must enable OTEL_LOG_TOOL_DETAILS=1 and read tool_decision.tool_parameters. However standard attributes may still include workspace.host_paths; the "no paths" guarantee applies only to native attributes.

Permission audit must answer not "how many blocked" but "who blocked", "on what basis", "was anyone present". All three questions have fields in the current event model, but they live on different events. Writing the join into the collection pipeline turns a counter into an accountable record. Pre-2.1.307 historical data lacks this linkage; that portion cannot be backfilled and must be marked non-attributable.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

observabilityOpenTelemetryattributionpermission auditClaude CodeCI testingsafety_eventtool_decision
Architecture Development Notes
Written by

Architecture Development Notes

Focused on architecture design, technology trend analysis, and practical development experience sharing.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.