core/agent: let YOLO mode skip YOLO-tagged soft confirms

`--yes` / `/yolo on|off|once` clears the prompt of a `YOLO`-tagged tool: interpreter runs (`run_argv`, `run_argv_batch`, `open_pty`), the file mutations (overwrite / copy / move / delete) and `fetch_local`'s cleartext and mutating requests. A tool without the tag still asks, and hard path/command denylists stay untouched.

`classify_danger` now answers with a `DangerReason` — the reason text plus `yolo_skip` — so one call can refuse that skip: a command that runs through `sudo` / `su` / `doas` / `pkexec` (as the program or a wrapper) keeps its confirm whatever YOLO mode says. It never adds a reason of its own, so the program the denylist gate already asks about is not asked twice.

`prepare_batch_confirm` leaves the covered calls out, so a skipped call neither prompts on its own nor takes a slot in the round's aggregated prompt, and one escalating step keeps the whole batch in front of the human.
This commit is contained in:
2026-10-21 19:10:46 +08:00
parent 029ac47350
commit 8ec63aa6a2
10 changed files with 301 additions and 94 deletions
+1
View File
@@ -30,6 +30,7 @@ from .notices import skill_tools_notice as skill_tools_notice
from .notices import truncated_turn_notice as truncated_turn_notice
from .tools import BatchConfirmDecision as BatchConfirmDecision
from .tools import DangerClassifier as DangerClassifier
from .tools import DangerReason as DangerReason
from .tools import ToolBatchConfirmHook as ToolBatchConfirmHook
from .tools import ToolConfirmHook as ToolConfirmHook
from .tools import ToolConfirmRequest as ToolConfirmRequest
+39 -11
View File
@@ -16,8 +16,22 @@ from msgspec import Struct
from plyngent.lmproto.openai_compatible.model import ChatImagePart, ToolFunction, ToolFunctionItem
from plyngent.typedef import JSONSchema # noqa: TC001
@dataclass(frozen=True, slots=True)
class DangerReason:
"""Why a call needs a soft confirm, and whether YOLO mode may drop that prompt.
``yolo_skip`` is false for a call that reaches rights the session does not
have (``sudo`` / ``doas`` / ``pkexec`` and friends): the human answers it
however YOLO mode is set.
"""
text: str
yolo_skip: bool = True
type ToolHandler = Callable[..., Any | Awaitable[Any]]
type DangerClassifier = Callable[[str, Mapping[str, object]], str | None]
type DangerClassifier = Callable[[str, Mapping[str, object]], str | DangerReason | None]
# Returns True to allow, False to deny, or a non-empty str as denial reason for the model.
type ToolConfirmResult = bool | str
type ToolConfirmHook = Callable[
@@ -313,6 +327,13 @@ def _denied_error(name: str, reason: str, comment: str | None) -> str:
return f"error: tool {name!r} denied by user confirm ({reason})"
def _as_danger_reason(raw: str | DangerReason | None) -> DangerReason | None:
"""Normalize a classifier answer; a bare string is a reason YOLO mode may drop."""
if raw is None:
return None
return raw if isinstance(raw, DangerReason) else DangerReason(text=raw)
def _write_path_key(value: str) -> str:
"""Comparable key for one path argument of a writing tool.
@@ -559,7 +580,9 @@ class ToolRegistry:
Mirrors the front half of :meth:`execute`: a call whose arguments fail to
decode or miss a required argument is answered with an error there and
must not be put in front of the user now.
must not be put in front of the user now, and a call whose reason YOLO
mode covers runs unprompted, so it must not take a slot in the round's
aggregated prompt either.
"""
definition = self._tools.get(name)
if self._danger is None or definition is None:
@@ -567,8 +590,10 @@ class ToolRegistry:
args = _decode_tool_args(arguments_json)
if isinstance(args, str) or _missing_required_error(definition, args) is not None:
return None
reason = self._danger(name, args)
return None if reason is None else ToolConfirmRequest(name=name, args=args, reason=reason)
reason = _as_danger_reason(self._danger(name, args))
if reason is None or (self._yolo and (definition.tags & ToolTag.YOLO) and reason.yolo_skip):
return None
return ToolConfirmRequest(name=name, args=args, reason=reason.text)
def _batch_requests(
self,
@@ -633,29 +658,32 @@ class ToolRegistry:
tags: ToolTag,
decision: BatchConfirmDecision | None = None,
) -> str | None:
"""Soft-confirm gate driven by danger reason + tags + grants.
"""Soft-confirm gate driven by danger reason + tags + grants + YOLO.
Pipeline (soft gate only; hard denylists stay in tool handlers)::
no soft reason → run
YOLO mode, (tags & YOLO) and a reason it may drop → allow
batch decision for this call → apply it
(tags & TRUSTABLE) and grant exists → allow
else → on_confirm (decorator YOLO tag does not override
dangerous invocations — internal danger classification
always takes precedence)
else → on_confirm (a tool without the YOLO tag, or a reason YOLO
may not drop — a privilege escalation — still prompts
under YOLO mode)
"""
if self._danger is None:
return None
reason = self._danger(name, args)
reason = _as_danger_reason(self._danger(name, args))
if reason is None:
return None
if self._yolo and (tags & ToolTag.YOLO) and reason.yolo_skip:
return None
async with self._confirm_lock:
if decision is not None:
return await self._apply_batch_decision(name, reason, decision, tags=tags)
return await self._apply_batch_decision(name, reason.text, decision, tags=tags)
if self._grant_allows(name, tags=tags):
return None
return await self._prompt_soft_confirm(name, args, reason, tags=tags)
return await self._prompt_soft_confirm(name, args, reason.text, tags=tags)
async def execute(
self,
+39 -1
View File
@@ -16,7 +16,16 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
__all__ = ["CommandString", "UnwrappedCommand", "basename", "is_interpreter", "unwrap_command"]
__all__ = [
"ESCALATION_PROGRAMS",
"CommandString",
"UnwrappedCommand",
"basename",
"dash_c_code",
"escalators_in_chain",
"is_interpreter",
"unwrap_command",
]
# Wrappers that run the program named on their own command line.
_ENV_WRAPPERS: frozenset[str] = frozenset(
@@ -99,6 +108,10 @@ _INTERPRETERS: frozenset[str] = frozenset(
# Trailing version of an interpreter: ``python3.12`` / ``node20`` / ``php8.2``.
_VERSION_SUFFIX = re.compile(r"[0-9][0-9.]*$")
# Privilege escalation: these run with rights the session itself does not have,
# so a call that reaches one is never approved on the caller's behalf
# (``danger._command_reason`` keeps the confirm whatever YOLO mode says).
ESCALATION_PROGRAMS: frozenset[str] = frozenset({"doas", "pkexec", "run0", "su", "sudo", "systemd-run"})
# ``FOO=bar`` in front of a program (env/sudo style).
_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=")
@@ -245,6 +258,31 @@ def is_interpreter(base: str) -> bool:
return base in _INTERPRETERS or _VERSION_SUFFIX.sub("", base) in _INTERPRETERS
def dash_c_code(command: Sequence[str]) -> tuple[int, str] | None:
"""Index and text of the ``-c`` code in an interpreter argv, if present.
Only interpreters hand ``-c`` a block of code; for anything else it is a
plain flag (``grep -c``, ``od -c``) with nothing to review.
"""
for index, part in enumerate(command[1:], start=1):
if part == "-c":
return index, command[index + 1] if index + 1 < len(command) else ""
# Combined short forms are uncommon; only exact -c is supported.
return None
def escalators_in_chain(argv: Sequence[str]) -> tuple[str, ...]:
"""Escalation programs *argv* runs: the program itself or a wrapper around it.
``sudo -u root python …`` and ``doas bash`` both count, whatever program they
finally run. Code an interpreter only carries as text is out of scope here —
this reads the command line, not what the text would do.
"""
scan = unwrap_command(argv)
names = (scan.base, *scan.wrappers)
return tuple(dict.fromkeys(name for name in names if name in ESCALATION_PROGRAMS))
def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
"""Resolve *argv* to the program behind any wrappers and ``run`` launchers.
+31 -24
View File
@@ -4,7 +4,8 @@ import json
import shlex
from typing import TYPE_CHECKING, cast
from plyngent.tools.command_scan import is_interpreter, unwrap_command
from plyngent.agent.tools import DangerReason
from plyngent.tools.command_scan import dash_c_code, escalators_in_chain, is_interpreter, unwrap_command
from plyngent.tools.workspace import AccessMode, WorkspaceError, resolve_path
if TYPE_CHECKING:
@@ -23,19 +24,6 @@ def _as_argv(args: Mapping[str, object], *, key: str = "command") -> list[str] |
return out
def _dash_c_code(command: Sequence[str]) -> tuple[int, str] | None:
"""Index and text of the ``-c`` code in an interpreter argv, if present.
Only interpreters hand ``-c`` a block of code; for anything else it is a
plain flag (``grep -c``, ``od -c``) with nothing to review.
"""
for index, part in enumerate(command[1:], start=1):
if part == "-c":
return index, command[index + 1] if index + 1 < len(command) else ""
# Combined short forms are uncommon; only exact -c is supported.
return None
def _indent_block(text: str, *, prefix: str = " ") -> str:
"""Indent every line of *text* (for multi-line -c code in confirm prompts)."""
if not text:
@@ -51,7 +39,7 @@ def _format_argv_for_confirm(argv: Sequence[str], *, code_index: int | None) ->
return shlex.join(parts)
def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
def _command_reason(argv: Sequence[str], *, via: str) -> DangerReason | None:
"""Confirm a command that runs code or detaches, whatever wraps it.
Multi-line reason (shown inside the CLI confirm box). ``via`` is a short
@@ -65,6 +53,11 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
(``env -S "…"``, ``su -c "…"``) hides code the same way, so it is confirmed
too, with the text it carries printed under its own ``<wrapper> <option>:``
label.
A command that escalates privileges (``sudo``/``doas``/``pkexec`` as the
program or a wrapper) is confirmed with ``yolo_skip=False``. It never adds a
reason of its own: the denylist gate in the handler always asks about the
escalation, so a second prompt here would only repeat it.
"""
scan = unwrap_command(argv)
interpreter = is_interpreter(scan.base)
@@ -75,7 +68,10 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
findings = list(dict.fromkeys(findings))
if not findings:
return None
code = _dash_c_code(scan.command) if interpreter else None
escalators = escalators_in_chain(argv)
if escalators:
findings.append(f"privilege escalation {', '.join(escalators)}")
code = dash_c_code(scan.command) if interpreter else None
code_index = scan.offset + code[0] if code is not None else None
display = _format_argv_for_confirm(argv, code_index=code_index)
reason = f"{via}: {' + '.join(findings)} — review before allow\n argv: {display}"
@@ -83,7 +79,7 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
reason += f"\n command:\n{_indent_block(code[1])}"
for span in scan.command_strings:
reason += f"\n {span.label}:\n{_indent_block(span.text)}"
return reason
return DangerReason(text=reason, yolo_skip=not escalators)
def _write_file_reason(args: Mapping[str, object]) -> str | None:
@@ -139,7 +135,7 @@ def _delete_path_reason(args: Mapping[str, object]) -> str | None:
return f"delete path {path!r}{extra}"
def _run_argv_reason(args: Mapping[str, object]) -> str | None:
def _run_argv_reason(args: Mapping[str, object]) -> DangerReason | None:
argv = _as_argv(args, key="argv")
if argv is None:
return None
@@ -161,8 +157,12 @@ def _batch_step_argv(item: object) -> list[str] | None:
return parts or None
def _run_argv_batch_reason(args: Mapping[str, object]) -> str | None:
"""One confirm for the whole batch if any step runs code or detaches."""
def _run_argv_batch_reason(args: Mapping[str, object]) -> DangerReason | None:
"""One confirm for the whole batch if any step runs code or detaches.
YOLO mode may drop it only when every step's reason allows that, so one
escalating step keeps the whole batch in front of the human.
"""
raw = args.get("steps")
if isinstance(raw, str):
try:
@@ -181,10 +181,13 @@ def _run_argv_batch_reason(args: Mapping[str, object]) -> str | None:
if not risky:
return None
header = f"run_argv_batch: {len(risky)} risky step(s) (review before allow)"
return header + "\n" + "\n".join(risky)
return DangerReason(
text=header + "\n" + "\n".join(reason.text for reason in risky),
yolo_skip=all(reason.yolo_skip for reason in risky),
)
def _open_pty_reason(args: Mapping[str, object]) -> str | None:
def _open_pty_reason(args: Mapping[str, object]) -> DangerReason | None:
argv = _as_argv(args)
if argv is None:
return None
@@ -204,14 +207,18 @@ def _fetch_reason(args: Mapping[str, object]) -> str | None:
return fetch_soft_reason(method, url, body)
def classify_danger(name: str, args: Mapping[str, object]) -> str | None: # noqa: PLR0911
"""Return a short reason if ``name``/``args`` need user confirm, else ``None``.
def classify_danger(name: str, args: Mapping[str, object]) -> str | DangerReason | None: # noqa: PLR0911
"""Return why ``name``/``args`` need a user confirm, else ``None``.
Hard denylists (paths/commands) still raise independently. This only covers
soft confirms for mutating tools and risky command launches — interpreters
(however invoked) and detached runners such as ``nohup``, resolved past
environment wrappers and ``<launcher> run``.
Private/loopback fetch_local targets use a separate policy grant (not YOLO).
A reason also says whether YOLO mode may drop its prompt
(:attr:`DangerReason.yolo_skip`); a command that escalates privileges
refuses.
"""
if name == "delete_path":
return _delete_path_reason(args)