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
@@ -493,6 +493,7 @@
"approval_outcome": {
"enum": [
"approved",
"cancelled",
"denied",
"not_required",
"timed_out",
@@ -578,6 +579,7 @@
"outcome": {
"enum": [
"approved",
"cancelled",
"denied",
"timed_out",
"unknown"
@@ -61,7 +61,7 @@ TOOL_CATEGORIES = frozenset({
"skill", "terminal", "unknown", "web",
})
TOOL_OUTCOMES = frozenset({"blocked", "cancelled", "failed", "success", "timed_out", "unknown"})
TOOL_APPROVAL_OUTCOMES = frozenset({"approved", "denied", "not_required", "timed_out", "unknown"})
TOOL_APPROVAL_OUTCOMES = frozenset({"approved", "cancelled", "denied", "not_required", "timed_out", "unknown"})
TOOL_APPROVAL_ATTRIBUTIONS = frozenset({"tool_call", "unattributed"})
TOOL_LATENCY_BUCKETS = frozenset({
"100ms_to_250ms", "10s_to_30s", "1s_to_2s", "250ms_to_500ms", "2s_to_5s", "500ms_to_1s",
@@ -548,6 +548,7 @@ _APPROVAL_CHOICES = {
),
**dict.fromkeys(("deny", "denied", "smart_deny"), "denied"),
**dict.fromkeys(("timed_out", "timeout"), "timed_out"),
"cancelled": "cancelled", # prompt withdrawn / undeliverable / unanswered — not a user decision
}