fix(approval): undelivered or unanswered CLI approval prompts are not user denials

When the CLI approval callback raises, when no callback is registered on the
thread while prompt_toolkit owns the terminal, or when the input() read is
interrupted, prompt_dangerous_approval returned "deny" and the command gate
rendered "BLOCKED: User denied this command" — attributing a refusal to a
user who was never asked (#22992). #112308 fixed the gateway half of the
class (withdrawn prompts -> outcome "cancelled" with a cause); this closes
the CLI residual on the same shape.

- tools/approval_prompt.py: those three paths return an Unanswered("cancelled")
  sentinel carrying the cause; MCP elicitation consent maps it to "cancel".
- tools/approval.py: the CLI gate renders "BLOCKED: <noun> was not approved: the
  approval prompt could not be delivered or was not answered (<cause>)" with
  outcome "cancelled" — still fail-closed, "Silence is not consent".
- tools/file_tools_write_guards.py: the protected-instruction write gate
  reports the undelivered prompt instead of "was denied by the user".
- Shared metrics: "cancelled" is a counted approval outcome (contract + v2
  schema) instead of falling into "unknown".
- Docs: hook `choice="cancelled"` now covers the CLI causes.

Fixes #22992
This commit is contained in:
teknium1
2026-09-15 20:05:00 -07:00
committed by Teknium
parent 3c3ab69abb
commit 2dfb795cb7
10 changed files with 72 additions and 17 deletions

View File

@@ -483,7 +483,7 @@ _USER_SUMMARIES = {
"denied": "You denied this {noun} — it did not run.",
"timeout": "No answer within {minutes} — the {noun} did not run.",
"notify_failed": "The approval request could not be delivered — the {noun} did not run.",
"cancelled": "The approval prompt was withdrawn before you answered — the {noun} did not run.",
"cancelled": "The approval prompt was withdrawn or never reached you — the {noun} did not run.",
"blocked": "This {noun} is not allowed in an unattended session — it did not run.",
}
@@ -894,6 +894,13 @@ def _human_decision(spec: _GateSpec, *, command: str, description: str,
approval_context._fire_approval_hook("post_approval_response", **hook_kwargs, choice=choice)
if choice == "timeout":
return deny(spec.cli_timeout, "timeout")
if choice == "cancelled":
# The prompt never reached a human (callback raised, no callback under prompt_toolkit, interrupted
# read): fail closed, but do not attribute a refusal to the user (#22992).
return deny(spec.gateway_refused, "cancelled",
reason="was not approved: the approval prompt could not be delivered or was not answered "
f"({getattr(choice, 'cause', 'no answer')})",
reason_addendum="", timeout_addendum=" Silence is not consent.", deny_reason=None)
if choice == "deny":
# No _record_denial(): the breaker counts consecutive guardian LLM
# DENY verdicts, not deliberate human denials.