[FEATURE] Report the deciding permission rule on every decision, not only on a rule-forced ask

Status Open
Reported on v2.1.261
Maintainer reply None cached
Activity 0 comments · opened Sep 22, 2026

Preflight Checklist

  • [x] I have searched existing requests and this feature hasn't been requested yet
  • [x] This is a single feature request (not multiple features)

Problem Statement

A host that delegates its whole permission document to the CLI cannot say what any rule in that document has done.

I build a self-hosted harness on the Agent SDK (pinned 0.3.261, bundled CLI 2.1.261). It owns a permission document, pushes it to the CLI as a settings layer, and — deliberately — keeps no rule matcher of its own: the CLI is the only evaluator, so the host and the CLI can never disagree about what a rule means. That is the design I want. The cost of it is that the host is told whether a call was permitted, and never by which statement.

I measured the three seams that could answer, and none does:

  1. PreToolUse fires before rule evaluation, and carries no decision. PreToolUseHookInput is { hook_event_name, tool_name, tool_input, tool_use_id } and nothing else (sdk.d.ts:2570). This is not the seam to change: a hook whose exit-2 pre-empts rule evaluation runs above the rule layer by design, and a seam that runs before the decision cannot report the decision. I am not asking for this.
  1. canUseTool names a rule on exactly one path, and only for ask. Its options carry matchedAskRule?: { source, toolName, ruleContent? } (sdk.d.ts:264), scoped by the SDK's own comment to "a user-configured ask RULE (permissions.ask)" that "forced this prompt". An allow rule and a deny rule never reach canUseTool at all, so a host sees a rule identity only on the one outcome where a human was already going to be asked.
  1. On an executed allow there is no decision reason anywhere, by construction. The field that would carry one is defined as a non-execution stamp: non_execution_kind is seven values, all denials (user-rejected, permission-rule, automode-blocked, automode-unavailable, automode-parsing-error, interrupted, cancelled), with no allowed counterpart, and its own schema says "absent means the tool ran to completion". The only system frame carrying decision_reason_type is subtype: "permission_denied". Measured over 10 executed allows across 6 command shapes: 0/10 carried tool_result_meta, decision_reason, decision_reason_type, classifier_approvable, or a non-empty permission_denials, on every frame — assistant, user, result, every system subtype. Paired controls (one deny, three asks) came back full, so this is evidence rather than an empty capture.

What does exist is --debug stderr, and it discriminates the decider class — permission engine, auto-mode classifier, or an acceptEdits-simulation fast path — unambiguously on 14/14 rows, with permissionDecisionMs separating engine (3–10 ms) from classifier (1186–1483 ms). So the information is present inside the CLI at decision time. What no channel reports is which statement.

The consequence is concrete: a permissions UI cannot answer "what has this rule permitted since you added it?", an operator cannot tell a rule that is working from a rule that never matches, and there is no way to find the rules a document no longer needs.

Proposed Solution

Report the deciding statement on every permission decision, not only on a rule-forced prompt.

The shape already exists and already carries the right fields — matchedAskRule's { source, toolName, ruleContent }. The ask is that an equivalent be populated on a decision the CLI executed: an allow or a deny that never surfaces to the host as a prompt.

Concretely, either of these would answer it:

  • extend canUseTool's existing field (or add a sibling, e.g. matchedRule with a decision: 'allow' | 'deny' | 'ask') so a rule identity is reported for allow and deny outcomes too; or
  • add an allow-side counterpart on the stream — an automode-allowed/permission-rule-allowed analogue to the existing denial-only non_execution_kind, or a permission_allowed system frame mirroring permission_denied.

Either way the payload is small and already defined: the matched rule's content, the settings source it came from, and the tool it applied to.

Alternative Solutions

The workaround is the thing the design exists to prevent. Attributing a call to a rule without the CLI naming it means re-implementing the CLI's own matching inside the host and running it against the pushed document. That is a second evaluator that will diverge from the first — different glob semantics, different precedence, different handling of the cross-application of Read()/Edit() rules to Bash arguments — and the divergence would be invisible, because the host's number would look plausible while being wrong. The whole reason to delegate evaluation to the CLI is to not have that matcher, so a metric built on one is not an acceptable substitute.

Parsing --debug stderr works only for the decider class, never the rule, and it needs two flags that are not documented together (debug: true and extraArgs: { 'debug-to-stderr': null } — debug: true alone yields zero bytes on stderr). It is a string dependency on an unstable surface, and it still does not answer the question.

Having measured this, my design now assumes the answer stays as it is: the field that would hold the count reads uncountable, and the surface is built for that. This request is filed because the capability is worth having upstream, not because anything of mine is blocked on it.

Priority

Medium - Would be very helpful

Feature Category

Developer tools/SDK

Use Case Example

  1. The operator hits a permission prompt for Bash(gh issue view:*) and clicks Allow always. The host writes an allow rule into its own policy layer and pushes the document to the CLI.
  2. Over the following days, that rule silently permits calls. The CLI evaluates it; the host never learns it fired.
  3. The operator opens the permissions surface and asks the obvious question of every rule there — has this actually done anything? Today there is no answer for any rule, on any outcome, except an ask rule that forced a prompt.
  4. With the deciding statement reported on allows and denies, the host joins each decision against the document version it pushed and answers it: this rule has permitted 340 calls, that one has never matched and can go.

Additional Context

  • Versions measured: Agent SDK 0.3.261, its bundled CLI 2.1.261, model claude-opus-5[1m], Linux.
  • Adjacent but different, and deliberately not folded in here: #87153 asks for the matched rule and its settings file in the denial message shown to a human. This request is for the same fact on the programmatic seam, on every decision including executed allows, for an SDK host rather than a reader of the TUI.
  • Earlier requests in this area were closed as stale or duplicate (#16622, #32904, #37153, #38771, #40634, #41094). They asked variously for an audit log file, a JSONL record, or a new hook event. This one asks for neither a new file nor a new event: it asks that a field the SDK already defines be populated on the decisions it currently skips.

View original on GitHub ↗