cli: report each prompt's own denial

The prompts knew the difference already — one branch printed "timed out",
another fell through to a plain no — and then threw it away by returning
False/None.

Each returns a `Denial` now: the timed policies name their deadline, a cancel
is its own answer, and a non-interactive run says nobody could be asked. The
batch decision and the skill write decision carry it too.
This commit is contained in:
2026-10-29 23:43:20 +08:00
parent 4c67f6bb04
commit 8cbb98602e
2 changed files with 59 additions and 46 deletions
+57 -45
View File
@@ -9,7 +9,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
from plyngent.agent import BatchConfirmDecision, ToolConfirmRequest from plyngent.agent import BatchConfirmDecision, Denial, DenialKind, ToolConfirmRequest
from plyngent.cli.display import close_open_pretty_line from plyngent.cli.display import close_open_pretty_line
from plyngent.cli.interrupt import off_loop_prompt, pause_task_cancel_for_prompt from plyngent.cli.interrupt import off_loop_prompt, pause_task_cancel_for_prompt
from plyngent.prompting import ( from plyngent.prompting import (
@@ -293,8 +293,11 @@ def _tool_facts(args: Mapping[str, object]) -> tuple[str, ...]:
return tuple(facts) return tuple(facts)
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | str: def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | Denial:
"""True allow; False deny; non-empty str = deny with comment for the model.""" """True allow; a :class:`Denial` otherwise (declined, cancelled, no prompt)."""
backend = get_prompt_backend()
if not backend.is_interactive():
return Denial(DenialKind.NO_PROMPT)
try: try:
with prompt_wait( with prompt_wait(
"confirm", "confirm",
@@ -305,7 +308,7 @@ def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str
_echo_tool_confirm(name, reason) _echo_tool_confirm(name, reason)
allowed = _confirm_with(wait, "Allow this tool call?", default=False) allowed = _confirm_with(wait, "Allow this tool call?", default=False)
except NonInteractiveError: except NonInteractiveError:
return False return Denial(DenialKind.CANCELLED)
if allowed: if allowed:
return True return True
try: try:
@@ -314,11 +317,11 @@ def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str
default="", default="",
).strip() ).strip()
except NonInteractiveError: except NonInteractiveError:
return False return Denial(DenialKind.CANCELLED)
return comment or False return Denial(comment=comment or None)
def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool | str: def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool | Denial:
"""Ask whether to allow a dangerous tool call (TTY). Default is deny. """Ask whether to allow a dangerous tool call (TTY). Default is deny.
Prints a multi-line boxed summary, then a short y/N prompt. On deny, Prints a multi-line boxed summary, then a short y/N prompt. On deny,
@@ -399,11 +402,12 @@ def prompt_policy_command_confirm(
basename: str, basename: str,
argv: Sequence[str], argv: Sequence[str],
timeout_seconds: float, timeout_seconds: float,
) -> bool: ) -> bool | Denial:
"""Ask whether to allow a denylisted command for this session (timed). """Ask whether to allow a denylisted command for this session (timed).
Independent of YOLO: always prompts when interactive. Default is **deny**. Independent of YOLO: always prompts when interactive. Default is **deny**,
Timeout, cancel, or non-interactive → False. and the answer says which denial it was: declined, timed out, cancelled, or
not asked at all.
""" """
argv_preview = " ".join(str(part) for part in argv) argv_preview = " ".join(str(part) for part in argv)
reason = ( reason = (
@@ -419,7 +423,7 @@ def prompt_policy_command_confirm(
): ):
backend = get_prompt_backend() backend = get_prompt_backend()
if not backend.is_interactive(): if not backend.is_interactive():
return False return Denial(DenialKind.NO_PROMPT)
backend.echo() backend.echo()
backend.secho(format_tool_confirm_box("policy", reason), fg="yellow") backend.secho(format_tool_confirm_box("policy", reason), fg="yellow")
backend.echo() backend.echo()
@@ -435,10 +439,12 @@ def prompt_policy_command_confirm(
fg="red", fg="red",
err=True, err=True,
) )
return False return Denial(DenialKind.TIMEOUT, seconds=timeout_seconds)
return raw.strip().lower() in {"y", "yes"} if raw.strip().lower() in {"y", "yes"}:
return True
return Denial(DenialKind.DECLINED)
except NonInteractiveError, KeyboardInterrupt, EOFError: except NonInteractiveError, KeyboardInterrupt, EOFError:
return False return Denial(DenialKind.CANCELLED)
def prompt_policy_fetch_confirm( def prompt_policy_fetch_confirm(
@@ -446,12 +452,12 @@ def prompt_policy_fetch_confirm(
port: int, port: int,
url: str, url: str,
timeout_seconds: float, timeout_seconds: float,
) -> bool: ) -> bool | Denial:
"""Ask whether to allow fetch_local to a private/loopback host (timed). """Ask whether to allow fetch_local to a private/loopback host (timed).
Independent of YOLO: always prompts when interactive. Default is **deny**. Independent of YOLO: always prompts when interactive. Default is **deny**,
Timeout, cancel, or non-interactive → False. On approve, the host:port is and the answer says which denial it was (see the command policy prompt).
granted for the rest of this process (see net grants). On approve, the host:port is granted for the rest of this process.
""" """
reason = ( reason = (
f"fetch_local targets private/loopback host {host!r} port {port}\n" f"fetch_local targets private/loopback host {host!r} port {port}\n"
@@ -466,7 +472,7 @@ def prompt_policy_fetch_confirm(
): ):
backend = get_prompt_backend() backend = get_prompt_backend()
if not backend.is_interactive(): if not backend.is_interactive():
return False return Denial(DenialKind.NO_PROMPT)
backend.echo() backend.echo()
backend.secho(format_tool_confirm_box("policy", reason), fg="yellow") backend.secho(format_tool_confirm_box("policy", reason), fg="yellow")
backend.echo() backend.echo()
@@ -483,10 +489,12 @@ def prompt_policy_fetch_confirm(
fg="red", fg="red",
err=True, err=True,
) )
return False return Denial(DenialKind.TIMEOUT, seconds=timeout_seconds)
return raw.strip().lower() in {"y", "yes"} if raw.strip().lower() in {"y", "yes"}:
return True
return Denial(DenialKind.DECLINED)
except NonInteractiveError, KeyboardInterrupt, EOFError: except NonInteractiveError, KeyboardInterrupt, EOFError:
return False return Denial(DenialKind.CANCELLED)
def prompt_skill_write_confirm( def prompt_skill_write_confirm(
@@ -499,8 +507,9 @@ def prompt_skill_write_confirm(
``o``/``y`` allows this write; ``s`` allows this skill directory for the rest ``o``/``y`` allows this write; ``s`` allows this skill directory for the rest
of the chat; anything else (or a timeout, cancel, or non-interactive stdin) of the chat; anything else (or a timeout, cancel, or non-interactive stdin)
denies. Independent of YOLO: a skill directory is not a path grant, so the denies, and the answer says which denial it was. Independent of YOLO: a skill
human is asked even with ``--yes`` unless ``[skills].allow_write`` is set. directory is not a path grant, so the human is asked even with ``--yes``
unless ``[skills].allow_write`` is set.
""" """
lines = [ lines = [
f"action: {action} skill {skill_dir.name!r}", f"action: {action} skill {skill_dir.name!r}",
@@ -522,7 +531,7 @@ def prompt_skill_write_confirm(
): ):
backend = get_prompt_backend() backend = get_prompt_backend()
if not backend.is_interactive(): if not backend.is_interactive():
return SkillWriteDecision(allow=False) return SkillWriteDecision(allow=False, denial=Denial(DenialKind.NO_PROMPT))
backend.echo() backend.echo()
backend.secho(format_tool_confirm_box(f"skills {action}", "\n".join(lines)), fg="yellow") backend.secho(format_tool_confirm_box(f"skills {action}", "\n".join(lines)), fg="yellow")
backend.echo() backend.echo()
@@ -536,15 +545,15 @@ def prompt_skill_write_confirm(
if raw is None: if raw is None:
with contextlib.suppress(OSError, NonInteractiveError): with contextlib.suppress(OSError, NonInteractiveError):
backend.secho(f"[skills] timed out after {timeout_seconds:g}s — denied", fg="red", err=True) backend.secho(f"[skills] timed out after {timeout_seconds:g}s — denied", fg="red", err=True)
return SkillWriteDecision(allow=False) return SkillWriteDecision(allow=False, denial=Denial(DenialKind.TIMEOUT, seconds=timeout_seconds))
token = raw.strip().lower() token = raw.strip().lower()
if token in {"s", "session"}: if token in {"s", "session"}:
return SkillWriteDecision(allow=True, session=True) return SkillWriteDecision(allow=True, session=True)
if token in {"o", "once", "y", "yes"}: if token in {"o", "once", "y", "yes"}:
return SkillWriteDecision(allow=True) return SkillWriteDecision(allow=True)
return SkillWriteDecision(allow=False) return SkillWriteDecision(allow=False, denial=Denial(DenialKind.DECLINED))
except NonInteractiveError, KeyboardInterrupt, EOFError: except NonInteractiveError, KeyboardInterrupt, EOFError:
return SkillWriteDecision(allow=False) return SkillWriteDecision(allow=False, denial=Denial(DenialKind.CANCELLED))
def prompt_directory_access_confirm( def prompt_directory_access_confirm(
@@ -552,12 +561,12 @@ def prompt_directory_access_confirm(
mode: AccessMode, mode: AccessMode,
reason: str, reason: str,
timeout_seconds: float, timeout_seconds: float,
) -> AccessDecision | str | None: ) -> AccessDecision | str | Denial:
"""Ask whether to grant access to *path* at *mode* (timed; default deny). """Ask whether to grant access to *path* at *mode* (timed; default deny).
``y`` approves the requested level; ``read`` / ``write`` / ``exec`` approve ``y`` approves the requested level; ``read`` / ``write`` / ``exec`` approve
that level instead. The host's hook decides YOLO/``--yes`` before calling that level instead. Nothing answers this prompt for the human, and a denial
this. Timeout, cancel, or non-interactive → ``None`` (deny). says which one it was: declined, timed out, cancelled, or not asked at all.
""" """
kind = "directory" if path.is_dir() else "file" kind = "directory" if path.is_dir() else "file"
lines = [ lines = [
@@ -580,7 +589,7 @@ def prompt_directory_access_confirm(
): ):
backend = get_prompt_backend() backend = get_prompt_backend()
if not backend.is_interactive(): if not backend.is_interactive():
return None return Denial(DenialKind.NO_PROMPT)
backend.echo() backend.echo()
backend.secho( backend.secho(
format_tool_confirm_box("request_directory_access", "\n".join(lines)), format_tool_confirm_box("request_directory_access", "\n".join(lines)),
@@ -601,20 +610,23 @@ def prompt_directory_access_confirm(
fg="red", fg="red",
err=True, err=True,
) )
return None return Denial(DenialKind.TIMEOUT, seconds=timeout_seconds)
token = raw.strip().lower() token = raw.strip().lower()
if token in {"y", "yes"}: if token in {"y", "yes"}:
return AccessDecision(mode) return AccessDecision(mode)
level = parse_access_mode(token) level = parse_access_mode(token)
if level is not None: if level is not None:
return AccessDecision(level) return AccessDecision(level)
return None return Denial(DenialKind.DECLINED)
except NonInteractiveError, KeyboardInterrupt, EOFError: except NonInteractiveError, KeyboardInterrupt, EOFError:
return None return Denial(DenialKind.CANCELLED)
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | str: async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | Denial:
"""Async confirm: True allow, False deny, str = deny with user comment.""" """Async confirm: True allows; a :class:`Denial` says why it did not."""
backend = get_prompt_backend()
if not backend.is_interactive():
return Denial(DenialKind.NO_PROMPT)
facts = _tool_facts(args) facts = _tool_facts(args)
try: try:
# Box is printed inside the worker thread via confirm's backend. # Box is printed inside the worker thread via confirm's backend.
@@ -627,7 +639,7 @@ async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reaso
allowed = await run_prompt_async(_run) allowed = await run_prompt_async(_run)
except NonInteractiveError: except NonInteractiveError:
return False return Denial(DenialKind.CANCELLED)
if allowed: if allowed:
return True return True
try: try:
@@ -638,8 +650,8 @@ async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reaso
) )
).strip() ).strip()
except NonInteractiveError: except NonInteractiveError:
return False return Denial(DenialKind.CANCELLED)
return comment or False return Denial(comment=comment or None)
async def prompt_confirm_batch_async(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision: async def prompt_confirm_batch_async(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
@@ -654,14 +666,14 @@ async def prompt_confirm_batch_async(requests: Sequence[ToolConfirmRequest]) ->
return await run_prompt_async(lambda: _prompt_confirm_batch_sync(requests)) return await run_prompt_async(lambda: _prompt_confirm_batch_sync(requests))
except NonInteractiveError: except NonInteractiveError:
return BatchConfirmDecision(allowed=False) return BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.CANCELLED))
def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision: def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
backend = get_prompt_backend() backend = get_prompt_backend()
if not backend.is_interactive(): if not backend.is_interactive():
# Non-interactive: deny, like the default of the single prompt. # Non-interactive: deny, like the default of the single prompt.
return BatchConfirmDecision(allowed=False) return BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.NO_PROMPT))
tools = tuple(dict.fromkeys(request.name for request in requests)) tools = tuple(dict.fromkeys(request.name for request in requests))
with prompt_wait( with prompt_wait(
"batch", "batch",
@@ -690,7 +702,7 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
_ = sys.stderr.flush() _ = sys.stderr.flush()
token = (_read_answer(None, wait) or "n").strip().lower() token = (_read_answer(None, wait) or "n").strip().lower()
except NonInteractiveError: except NonInteractiveError:
return BatchConfirmDecision(allowed=False) return BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.CANCELLED))
if token in _BATCH_MANUAL_TOKENS: if token in _BATCH_MANUAL_TOKENS:
return BatchConfirmDecision(allowed=False, each=True) return BatchConfirmDecision(allowed=False, each=True)
if token in _BATCH_YES_TOKENS: if token in _BATCH_YES_TOKENS:
@@ -701,8 +713,8 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
default="", default="",
).strip() ).strip()
except NonInteractiveError: except NonInteractiveError:
comment = "" return BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.CANCELLED))
return BatchConfirmDecision(allowed=False, comment=comment or None) return BatchConfirmDecision(allowed=False, comment=comment or None, denial=Denial(DenialKind.DECLINED))
def format_batch_confirm_box(requests: Sequence[ToolConfirmRequest]) -> str: def format_batch_confirm_box(requests: Sequence[ToolConfirmRequest]) -> str:
+2 -1
View File
@@ -33,6 +33,7 @@ from plyngent.tools.view import MemoryViewStore, session_data_view
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from plyngent.agent import Denial
from plyngent.config.models import Provider from plyngent.config.models import Provider
from plyngent.config.store import ConfigStore from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore from plyngent.memory import MemoryStore
@@ -296,7 +297,7 @@ class ReplState:
mode: AccessMode, mode: AccessMode,
reason: str, reason: str,
timeout_seconds: float, timeout_seconds: float,
) -> AccessDecision | str | None: ) -> AccessDecision | str | Denial:
"""Directory-access confirm: always ask, whatever YOLO mode says. """Directory-access confirm: always ask, whatever YOLO mode says.
An out-of-workspace path is a grant on the filesystem rather than one An out-of-workspace path is a grant on the filesystem rather than one