mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
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:
+57
-45
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user