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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user