Parked-permission "retirement" always stamps toolDenialKind:"user-rejected", even when the actual cause is an internal session reset (e.g. after /mcp reconnect), not a real user action

Status Open
Maintainer reply None cached
Activity 1 comment · opened Sep 11, 2026

Summary

A tool call can be falsely reported to the model as user-rejected — tool_result text "The user doesn't want to proceed with this tool use. The tool use was rejected..." plus toolDenialKind/non_execution_kind: "user-rejected" — even when:

  • the tool is already pre-approved in settings.json's permissions.allow list,
  • no permission prompt ever appeared (confirmed directly with the user in the session where this was found — not a missed/off-screen dialog),
  • the user did not press Esc, Ctrl+C, or otherwise interrupt anything.

This is distinct from #86001 (interrupting a tool call that is already executing reuses the wrong denial text) and from #51674 (closed as not_planned by the stale bot, root cause left as "unknown" — same symptom, same workaround, never actually diagnosed). Both of those start from "the user did interrupt something, but the label is wrong." This report is about a case where nothing the user did caused an interruption at all, and the false label is generated by internal retirement logic for a different reason.

Root cause (found by reading the shipped bundle directly)

Extracted the bundled application code from the actual installed claude.exe (v2.1.268, native/Bun-compiled distribution — current npm packages for this platform now ship only this compiled binary, no separate JS/source-map files) and traced the mechanism:

  1. There is an internal "parked permission" state machine (in the bundle's print.ts-derived chunk) that tracks a tool call awaiting a control_response decision.
  2. A session-reset path (a function I'll call md() from its minified name) runs on some internal reset/session-refresh event. It aborts the current abortController and, unless explicitly told retireParkedPermission:false, calls a resume/retirement handler (Lo()) with a default reason of "interrupt" — a generic internal label, not necessarily tied to an actual keypress:

``js
md=(p={})=>{if(on.abortController?.abort(),on.abortController=null,on.lastEmitted=null,on.pendingSuggestion=null,p.retireParkedPermission!==!1)Lo(p.parkedPermissionReason??"interrupt")}
``

  1. Inside Lo(), when the parked entry has no persisted control_response and the reason is "timeout" (with CLAUDE_CODE_RETIRE_UNANSWERED_PARKED_PERMISSION set) or "denied", it calls a retirement function (Uu()). Critically, the "interrupt" reason can also reach Uu() through a nearby branch gated on internal flags (CLAUDE_CODE_PARKED_STOP_RETIRES / an "adopted/hold" state q):

``js
if(p==="interrupt"||p==="new_input"||p==="rewind"||ze||(q||oe.kind==="adopted"?ur():F.hasUserIntentCommandsInQueue())){
...
let ce=p==="interrupt"&&a.CLAUDE_CODE_PARKED_STOP_RETIRES;
if(!ze&&!qs()&&!ao()){
if((q||ce)&&p==="interrupt")Uu(xu(oe),p,Date.now()-qr);
else if((q||Jt)&&!ui())nd()
}
Ri();return
}
``

  1. Uu() is the actual retirement function. It computes a real, distinct internal outcome ("retired_unanswered" vs "retire_superseded" vs "retire_write_failed") — the information to report this accurately already exists — but for the "retired_unanswered" case it unconditionally emits, for every affected tool_use_id, the same hardcoded denial regardless of why the entry was parked:

``js
let Me=(async()=>{
let Ce="retired_unanswered",xe=0;
try{
let Et=[];
for(let d of p){let Ke=await DTt(d,v.storageV5);if(Ke)Et.push({toolUseId:d,assistantMessage:Ke})}
if(q())Ce="retire_superseded";
else if(Et.length>0){
let d=Et.reduce((Ke,Je)=>Je.assistantMessage.timestamp>Ke.assistantMessage.timestamp?Je:Ke).assistantMessage;
await EE([d,...Et.map(({toolUseId:Ke,assistantMessage:Je})=>Te({
content:[{type:"tool_result",content:wE,is_error:!0,tool_use_id:Ke}],
toolUseResult:wE,
toolDenialKind:"user-rejected",
sourceToolAssistantUUID:Je.uuid
})),KO({toolUse:!0})],void 0,void 0,void 0,v.storageV5);
xe=Et.length
}
}catch(Et){Ce="retire_write_failed", ...}
...
return{outcome:Ce,retiredCount:xe}
})();
``

where wE is the literal string:
> "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."

So: a tool call that never got a chance to even show a permission prompt (because it was already allow-listed) can still enter this "parked" bookkeeping, then get swept up by a session-reset event using the internal "interrupt" label, then get "retired" through the exact same code path as a genuine denial — with the exact same wording — even though no prompt was ever shown and no user action occurred.

Suspected trigger in practice

In the session where this was found, the only unusual event immediately preceding both false rejections was running /mcp to reconnect an unrelated MCP server (github, for an auth token fix) earlier in the same session. This matches:

  • The user's own prior, repeated observation (across multiple past sessions, not just this one) that this exact false-rejection shape recurs specifically after an /mcp reconnect, followed by calls to a different, unrelated MCP server.
  • #51674's reproduction note: "/mcp reconnect restores the tool set; after reconnect the same tool calls succeed" — i.e., reconnect is the workaround, which is consistent with reconnect-adjacent session-reset logic being the actual trigger, not a coincidence.

I was not able to fully confirm from the outside which specific internal event calls md() with the default "interrupt" reason in this scenario (that requires source not visible from the bundle alone, e.g. exact call sites into md), but the retirement path is unambiguous once a parked entry is swept up this way.

Why this matters

Same core problem as #86001, but for a strictly wider set of triggers: any code path that funnels through this "parked permission retirement" logic with reason "interrupt" produces an unconditional false "the user rejected this" signal, including in cases (like this one) where:

  • the tool was already allow-listed,
  • no prompt was ever shown,
  • no user keypress occurred at all.

A model that trusts this text will conclude a tool genuinely failed due to explicit user intervention and may stop, backtrack, or ask the user for clarification about a rejection that never happened — actively confusing the user, who (understandably) has no idea what "interrupted" or "rejected" tool call is even being referred to.

Suggested fix direction

  • In Uu(), thread the real p/reason ("timeout", "denied", "interrupt", etc.) and the already-computed outcome (retired_unanswered / retire_superseded / retire_write_failed) into the emitted tool_result/toolDenialKind, instead of hardcoding "user-rejected" + the fixed wE string for every case.
  • At minimum, reserve toolDenialKind:"user-rejected" for cases where a permission prompt genuinely was shown and genuinely was answered "no" — every other path (timeout, session reset, reconnect-driven retirement, write failure) should use a distinct, honest non_execution_kind ("interrupted"/"cancelled", or a new value if none fits) and non-accusatory wording, consistent with the intent already described in this session's own internal tool documentation string for non_execution_kind ("interrupted / cancelled for aborts ... those are NOT denials").

Related

  • #86001 — mid-execution interrupt reuses the denial wording; confirmed by maintainers, but scoped to genuine in-flight Esc/interrupt, not this parked-permission-retirement path.
  • #51674 — same visible symptom and workaround (/mcp reconnect), closed not_planned by the stale bot with root cause explicitly left "unknown." This report is the root-cause follow-up to that one.
  • #47282 — earlier (2026-04-13) report of the wording problem generally, also stale-closed and locked.

Environment

  • claude.exe v2.1.268, native Windows x64 build (single compiled binary; current @anthropic-ai/claude-code npm package is just a thin installer that fetches this binary from @anthropic-ai/claude-code-win32-x64 — no separate JS bundle ships anymore for this platform/version)
  • Reproduced via mcp__sublime-mcp__batch tool calls (a local stdio/SSE MCP server) failing with this exact shape twice in a row in the same session, both times with zero visible permission prompt, both times shortly after an unrelated /mcp reconnect for a different server

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗