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 .notices import truncated_turn_notice as truncated_turn_notice
from .tools import BatchConfirmDecision as BatchConfirmDecision from .tools import BatchConfirmDecision as BatchConfirmDecision
from .tools import DangerClassifier as DangerClassifier from .tools import DangerClassifier as DangerClassifier
from .tools import DangerReason as DangerReason
from .tools import ToolBatchConfirmHook as ToolBatchConfirmHook from .tools import ToolBatchConfirmHook as ToolBatchConfirmHook
from .tools import ToolConfirmHook as ToolConfirmHook from .tools import ToolConfirmHook as ToolConfirmHook
from .tools import ToolConfirmRequest as ToolConfirmRequest 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.lmproto.openai_compatible.model import ChatImagePart, ToolFunction, ToolFunctionItem
from plyngent.typedef import JSONSchema # noqa: TC001 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 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. # Returns True to allow, False to deny, or a non-empty str as denial reason for the model.
type ToolConfirmResult = bool | str type ToolConfirmResult = bool | str
type ToolConfirmHook = Callable[ 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})" 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: def _write_path_key(value: str) -> str:
"""Comparable key for one path argument of a writing tool. """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 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 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) definition = self._tools.get(name)
if self._danger is None or definition is None: if self._danger is None or definition is None:
@@ -567,8 +590,10 @@ class ToolRegistry:
args = _decode_tool_args(arguments_json) args = _decode_tool_args(arguments_json)
if isinstance(args, str) or _missing_required_error(definition, args) is not None: if isinstance(args, str) or _missing_required_error(definition, args) is not None:
return None return None
reason = self._danger(name, args) reason = _as_danger_reason(self._danger(name, args))
return None if reason is None else ToolConfirmRequest(name=name, args=args, reason=reason) 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( def _batch_requests(
self, self,
@@ -633,29 +658,32 @@ class ToolRegistry:
tags: ToolTag, tags: ToolTag,
decision: BatchConfirmDecision | None = None, decision: BatchConfirmDecision | None = None,
) -> str | 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):: Pipeline (soft gate only; hard denylists stay in tool handlers)::
no soft reason → run no soft reason → run
YOLO mode, (tags & YOLO) and a reason it may drop → allow
batch decision for this call → apply it batch decision for this call → apply it
(tags & TRUSTABLE) and grant exists → allow (tags & TRUSTABLE) and grant exists → allow
else → on_confirm (decorator YOLO tag does not override else → on_confirm (a tool without the YOLO tag, or a reason YOLO
dangerous invocations — internal danger classification may not drop — a privilege escalation — still prompts
always takes precedence) under YOLO mode)
""" """
if self._danger is None: if self._danger is None:
return None return None
reason = self._danger(name, args) reason = _as_danger_reason(self._danger(name, args))
if reason is None: if reason is None:
return None return None
if self._yolo and (tags & ToolTag.YOLO) and reason.yolo_skip:
return None
async with self._confirm_lock: async with self._confirm_lock:
if decision is not None: 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): if self._grant_allows(name, tags=tags):
return None 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( async def execute(
self, self,
+39 -1
View File
@@ -16,7 +16,16 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Sequence 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. # Wrappers that run the program named on their own command line.
_ENV_WRAPPERS: frozenset[str] = frozenset( _ENV_WRAPPERS: frozenset[str] = frozenset(
@@ -99,6 +108,10 @@ _INTERPRETERS: frozenset[str] = frozenset(
# Trailing version of an interpreter: ``python3.12`` / ``node20`` / ``php8.2``. # Trailing version of an interpreter: ``python3.12`` / ``node20`` / ``php8.2``.
_VERSION_SUFFIX = re.compile(r"[0-9][0-9.]*$") _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). # ``FOO=bar`` in front of a program (env/sudo style).
_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=") _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 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: def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
"""Resolve *argv* to the program behind any wrappers and ``run`` launchers. """Resolve *argv* to the program behind any wrappers and ``run`` launchers.
+31 -24
View File
@@ -4,7 +4,8 @@ import json
import shlex import shlex
from typing import TYPE_CHECKING, cast 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 from plyngent.tools.workspace import AccessMode, WorkspaceError, resolve_path
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -23,19 +24,6 @@ def _as_argv(args: Mapping[str, object], *, key: str = "command") -> list[str] |
return out 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: def _indent_block(text: str, *, prefix: str = " ") -> str:
"""Indent every line of *text* (for multi-line -c code in confirm prompts).""" """Indent every line of *text* (for multi-line -c code in confirm prompts)."""
if not text: if not text:
@@ -51,7 +39,7 @@ def _format_argv_for_confirm(argv: Sequence[str], *, code_index: int | None) ->
return shlex.join(parts) 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. """Confirm a command that runs code or detaches, whatever wraps it.
Multi-line reason (shown inside the CLI confirm box). ``via`` is a short 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 (``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>:`` too, with the text it carries printed under its own ``<wrapper> <option>:``
label. 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) scan = unwrap_command(argv)
interpreter = is_interpreter(scan.base) interpreter = is_interpreter(scan.base)
@@ -75,7 +68,10 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
findings = list(dict.fromkeys(findings)) findings = list(dict.fromkeys(findings))
if not findings: if not findings:
return None 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 code_index = scan.offset + code[0] if code is not None else None
display = _format_argv_for_confirm(argv, code_index=code_index) display = _format_argv_for_confirm(argv, code_index=code_index)
reason = f"{via}: {' + '.join(findings)} — review before allow\n argv: {display}" 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])}" reason += f"\n command:\n{_indent_block(code[1])}"
for span in scan.command_strings: for span in scan.command_strings:
reason += f"\n {span.label}:\n{_indent_block(span.text)}" 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: 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}" 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") argv = _as_argv(args, key="argv")
if argv is None: if argv is None:
return None return None
@@ -161,8 +157,12 @@ def _batch_step_argv(item: object) -> list[str] | None:
return parts or None return parts or None
def _run_argv_batch_reason(args: Mapping[str, object]) -> str | None: def _run_argv_batch_reason(args: Mapping[str, object]) -> DangerReason | None:
"""One confirm for the whole batch if any step runs code or detaches.""" """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") raw = args.get("steps")
if isinstance(raw, str): if isinstance(raw, str):
try: try:
@@ -181,10 +181,13 @@ def _run_argv_batch_reason(args: Mapping[str, object]) -> str | None:
if not risky: if not risky:
return None return None
header = f"run_argv_batch: {len(risky)} risky step(s) (review before allow)" 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) argv = _as_argv(args)
if argv is None: if argv is None:
return None return None
@@ -204,14 +207,18 @@ def _fetch_reason(args: Mapping[str, object]) -> str | None:
return fetch_soft_reason(method, url, body) return fetch_soft_reason(method, url, body)
def classify_danger(name: str, args: Mapping[str, object]) -> str | None: # noqa: PLR0911 def classify_danger(name: str, args: Mapping[str, object]) -> str | DangerReason | None: # noqa: PLR0911
"""Return a short reason if ``name``/``args`` need user confirm, else ``None``. """Return why ``name``/``args`` need a user confirm, else ``None``.
Hard denylists (paths/commands) still raise independently. This only covers Hard denylists (paths/commands) still raise independently. This only covers
soft confirms for mutating tools and risky command launches — interpreters soft confirms for mutating tools and risky command launches — interpreters
(however invoked) and detached runners such as ``nohup``, resolved past (however invoked) and detached runners such as ``nohup``, resolved past
environment wrappers and ``<launcher> run``. environment wrappers and ``<launcher> run``.
Private/loopback fetch_local targets use a separate policy grant (not YOLO). 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": if name == "delete_path":
return _delete_path_reason(args) return _delete_path_reason(args)
@@ -93,6 +93,46 @@ async def test_round_asks_once_for_many_mutating_calls() -> None:
assert single_prompts == [] assert single_prompts == []
async def test_yolo_calls_are_left_out_of_the_prompt() -> None:
"""Under YOLO mode a YOLO-tagged call runs unprompted, batch prompt included."""
batch_prompts: list[list[str]] = []
single_prompts: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def yolo_ok(path: str) -> str:
return f"stamped {path}"
@tool(tags=ToolTag.LOCAL, register=False)
async def always_ask(path: str) -> str:
return f"stamped {path}"
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
batch_prompts.append([request.name for request in requests])
return BatchConfirmDecision(allowed=True)
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
single_prompts.append(name)
return True
reg = ToolRegistry(
[yolo_ok, always_ask],
danger=_danger,
on_confirm=on_single,
on_confirm_batch=on_batch,
yolo=True,
)
calls = [("yolo_ok", '{"path": "a"}'), ("always_ask", '{"path": "b"}'), ("always_ask", '{"path": "c"}')]
decisions = await reg.prepare_batch_confirm(calls)
assert decisions[0] is None
assert batch_prompts == [["always_ask", "always_ask"]]
results = [
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
]
assert results == ["stamped a", "stamped b", "stamped c"]
assert single_prompts == []
async def test_single_request_keeps_the_one_call_prompt() -> None: async def test_single_request_keeps_the_one_call_prompt() -> None:
"""One mutating call stays the plain per-call confirm.""" """One mutating call stays the plain per-call confirm."""
batch_prompts: list[int] = [] batch_prompts: list[int] = []
+2 -2
View File
@@ -486,8 +486,8 @@ async def test_yolo_rebuilds_tool_registry(tmp_path: Path) -> None:
) )
try: try:
assert st.agent.tools is not None assert st.agent.tools is not None
# Soft-confirm hooks stay installed; YOLO mode does not bypass # Soft-confirm hooks stay installed; YOLO mode only auto-approves the
# dangerous invocations — internal danger classification takes precedence. # confirms of YOLO-tagged tools, so the others still prompt.
assert st.agent.tools.soft_confirm is True assert st.agent.tools.soft_confirm is True
assert st.agent.tools.yolo is False assert st.agent.tools.yolo is False
st.set_yolo("on") st.set_yolo("on")
+10 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from plyngent.tools.command_scan import basename, is_interpreter, unwrap_command from plyngent.tools.command_scan import basename, escalators_in_chain, is_interpreter, unwrap_command
def test_basename() -> None: def test_basename() -> None:
@@ -9,6 +9,15 @@ def test_basename() -> None:
assert basename("pdm") == "pdm" assert basename("pdm") == "pdm"
def test_escalators_in_chain() -> None:
assert escalators_in_chain(["sudo", "-u", "root", "python", "-c", "print(1)"]) == ("sudo",)
assert escalators_in_chain(["env", "FOO=1", "doas", "id"]) == ("doas",)
assert escalators_in_chain(["pdm", "run", "sudo", "id"]) == ("sudo",)
# Only the command line counts: the code an interpreter carries is not read.
assert escalators_in_chain(["bash", "-c", "sudo id"]) == ()
assert escalators_in_chain(["grep", "-c", "sudo", "notes.txt"]) == ()
def test_unwrap_plain_command() -> None: def test_unwrap_plain_command() -> None:
scan = unwrap_command(["grep", "-c", "needle", "f.txt"]) scan = unwrap_command(["grep", "-c", "needle", "f.txt"])
assert scan.wrappers == () assert scan.wrappers == ()
+48 -8
View File
@@ -2,18 +2,18 @@
from __future__ import annotations from __future__ import annotations
from plyngent.agent import ToolRegistry, ToolTag, tool from plyngent.agent import DangerReason, ToolRegistry, ToolTag, tool
from plyngent.tools.context import SessionState, bind_tool_context from plyngent.tools.context import SessionState, bind_tool_context
from plyngent.tools.grants import clear_grants, has_grant, hydrate_grants, persist_grants from plyngent.tools.grants import clear_grants, has_grant, hydrate_grants, persist_grants
from plyngent.tools.view import MemoryViewStore, session_data_view from plyngent.tools.view import MemoryViewStore, session_data_view
async def test_danger_overrides_yolo_tag() -> None: async def test_yolo_only_skips_yolo_tagged_tools() -> None:
"""Decorator YOLO tag does not bypass confirm when danger flags the call.""" """YOLO mode auto-approves the soft confirms of YOLO-tagged tools only."""
calls: list[str] = [] calls: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False) @tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def yolo_tool() -> str: async def yolo_ok() -> str:
return "y" return "y"
@tool(tags=ToolTag.LOCAL, register=False) @tool(tags=ToolTag.LOCAL, register=False)
@@ -28,15 +28,55 @@ async def test_danger_overrides_yolo_tag() -> None:
return True return True
reg = ToolRegistry( reg = ToolRegistry(
[yolo_tool, no_yolo], [yolo_ok, no_yolo],
danger=danger, danger=danger,
on_confirm=confirm, on_confirm=confirm,
yolo=True, yolo=True,
) )
assert await reg.execute("yolo_tool", "{}") == "y" assert await reg.execute("yolo_ok", "{}") == "y"
assert calls == ["yolo_tool"] assert calls == []
assert await reg.execute("no_yolo", "{}") == "n" assert await reg.execute("no_yolo", "{}") == "n"
assert calls == ["yolo_tool", "no_yolo"] assert calls == ["no_yolo"]
async def test_yolo_off_keeps_the_yolo_tagged_prompt() -> None:
"""The YOLO tag is a policy bit: with YOLO off the call still asks."""
calls: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def yolo_ok() -> str:
return "y"
def danger(_name: str, _args: object) -> str | None:
return "soft reason"
async def confirm(name: str, _args: object, _reason: str) -> bool:
calls.append(name)
return True
reg = ToolRegistry([yolo_ok], danger=danger, on_confirm=confirm, yolo=False)
assert await reg.execute("yolo_ok", "{}") == "y"
assert calls == ["yolo_ok"]
async def test_yolo_keeps_a_reason_that_refuses_the_skip() -> None:
"""`DangerReason(yolo_skip=False)` — a privilege escalation — still prompts."""
seen: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def escalated() -> str:
return "x"
def danger(_name: str, _args: object) -> DangerReason:
return DangerReason("interpreter 'python' + privilege escalation sudo", yolo_skip=False)
async def confirm(_name: str, _args: object, reason: str) -> bool:
seen.append(reason)
return True
reg = ToolRegistry([escalated], danger=danger, on_confirm=confirm, yolo=True)
assert await reg.execute("escalated", "{}") == "x"
assert seen == ["interpreter 'python' + privilege escalation sudo"]
async def test_trustable_grant_once() -> None: async def test_trustable_grant_once() -> None:
+89 -45
View File
@@ -2,19 +2,35 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from plyngent.agent import DangerReason
from plyngent.tools.danger import classify_danger from plyngent.tools.danger import classify_danger
def _reason(name: str, args: dict[str, object]) -> str | None:
"""Confirm text of a classifier answer (``None`` when the call needs none)."""
found = classify_danger(name, args)
if found is None:
return None
return found if isinstance(found, str) else found.text
def _danger(name: str, args: dict[str, object]) -> DangerReason:
"""Classifier answer as a typed reason (the call must need a confirm)."""
found = classify_danger(name, args)
assert isinstance(found, DangerReason), found
return found
def test_classify_delete_and_move() -> None: def test_classify_delete_and_move() -> None:
assert classify_danger("delete_path", {"path": "a.txt"}) == "delete path 'a.txt'" assert _reason("delete_path", {"path": "a.txt"}) == "delete path 'a.txt'"
assert "recursively" in (classify_danger("delete_path", {"path": "d", "recursive": True}) or "") assert "recursively" in (_reason("delete_path", {"path": "d", "recursive": True}) or "")
assert "move" in (classify_danger("move_path", {"src": "a", "dst": "b"}) or "") assert "move" in (_reason("move_path", {"src": "a", "dst": "b"}) or "")
def test_classify_copy() -> None: def test_classify_copy() -> None:
# Without overwrite flag, copy is not soft-confirmed. # Without overwrite flag, copy is not soft-confirmed.
assert classify_danger("copy_path", {"src": "a", "dst": "b"}) is None assert _reason("copy_path", {"src": "a", "dst": "b"}) is None
assert classify_danger("copy_path", {"src": "a", "dst": "b", "overwrite": False}) is None assert _reason("copy_path", {"src": "a", "dst": "b", "overwrite": False}) is None
def test_classify_write_overwrite_only(tmp_path: Path) -> None: def test_classify_write_overwrite_only(tmp_path: Path) -> None:
@@ -25,35 +41,35 @@ def test_classify_write_overwrite_only(tmp_path: Path) -> None:
with bind_instance(instance): with bind_instance(instance):
_ = set_workspace_root(tmp_path) _ = set_workspace_root(tmp_path)
# New file: no soft-confirm, with or without the overwrite flag # New file: no soft-confirm, with or without the overwrite flag
assert classify_danger("write_file", {"path": "new.txt"}) is None assert _reason("write_file", {"path": "new.txt"}) is None
assert classify_danger("write_file", {"path": "new.txt", "overwrite": True}) is None assert _reason("write_file", {"path": "new.txt", "overwrite": True}) is None
# Partial edits: never soft-confirm # Partial edits: never soft-confirm
assert classify_danger("edit_replace", {"path": "x.txt"}) is None assert _reason("edit_replace", {"path": "x.txt"}) is None
assert classify_danger("edit_lineno", {"path": "x.txt", "start_line": 1, "end_line": 2}) is None assert _reason("edit_lineno", {"path": "x.txt", "start_line": 1, "end_line": 2}) is None
# Existing file: only an explicit overwrite asks; an unset/false flag is # Existing file: only an explicit overwrite asks; an unset/false flag is
# refused inside the tool, so no prompt may fire for it. # refused inside the tool, so no prompt may fire for it.
(tmp_path / "x.txt").write_text("old", encoding="utf-8") (tmp_path / "x.txt").write_text("old", encoding="utf-8")
assert classify_danger("write_file", {"path": "x.txt"}) is None assert _reason("write_file", {"path": "x.txt"}) is None
assert classify_danger("write_file", {"path": "x.txt", "overwrite": False}) is None assert _reason("write_file", {"path": "x.txt", "overwrite": False}) is None
reason = classify_danger("write_file", {"path": "x.txt", "overwrite": True}) reason = _reason("write_file", {"path": "x.txt", "overwrite": True})
assert reason is not None and "overwrite" in reason assert reason is not None and "overwrite" in reason
(tmp_path / "dst.txt").write_text("d", encoding="utf-8") (tmp_path / "dst.txt").write_text("d", encoding="utf-8")
(tmp_path / "src.txt").write_text("s", encoding="utf-8") (tmp_path / "src.txt").write_text("s", encoding="utf-8")
assert classify_danger("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": False}) is None assert _reason("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": False}) is None
creason = classify_danger("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": True}) creason = _reason("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": True})
assert creason is not None and "overwrite" in creason assert creason is not None and "overwrite" in creason
def test_classify_safe_tools() -> None: def test_classify_safe_tools() -> None:
assert classify_danger("read_file", {"path": "a"}) is None assert _reason("read_file", {"path": "a"}) is None
assert classify_danger("listdir", {"path": "."}) is None assert _reason("listdir", {"path": "."}) is None
assert classify_danger("run_argv", {"argv": ["echo", "hi"]}) is None assert _reason("run_argv", {"argv": ["echo", "hi"]}) is None
assert classify_danger("open_pty", {"command": ["true"]}) is None assert _reason("open_pty", {"command": ["true"]}) is None
assert classify_danger("run_argv", {"argv": ["ls", "-la"]}) is None assert _reason("run_argv", {"argv": ["ls", "-la"]}) is None
def test_classify_run_argv_batch_risky() -> None: def test_classify_run_argv_batch_risky() -> None:
reason = classify_danger( reason = _reason(
"run_argv_batch", "run_argv_batch",
{ {
"steps": [ "steps": [
@@ -66,7 +82,7 @@ def test_classify_run_argv_batch_risky() -> None:
assert "run_argv_batch" in reason assert "run_argv_batch" in reason
assert "risky" in reason or "bash" in reason assert "risky" in reason or "bash" in reason
assert ( assert (
classify_danger( _reason(
"run_argv_batch", "run_argv_batch",
{"steps": [{"argv": ["echo", "ok"]}]}, {"steps": [{"argv": ["echo", "ok"]}]},
) )
@@ -75,42 +91,42 @@ def test_classify_run_argv_batch_risky() -> None:
def test_classify_shell_and_dash_c() -> None: def test_classify_shell_and_dash_c() -> None:
r = classify_danger("run_argv", {"argv": ["bash", "-c", "rm -rf /"]}) r = _reason("run_argv", {"argv": ["bash", "-c", "rm -rf /"]})
assert r is not None assert r is not None
assert "bash -c" in r assert "bash -c" in r
assert "rm -rf" in r assert "rm -rf" in r
r2 = classify_danger("run_argv", {"argv": ["python3", "-c", "print(1)"]}) r2 = _reason("run_argv", {"argv": ["python3", "-c", "print(1)"]})
assert r2 is not None assert r2 is not None
assert "python" in r2 assert "python" in r2
assert "-c" in r2 assert "-c" in r2
assert "print(1)" in r2 assert "print(1)" in r2
r_py = classify_danger("run_argv", {"argv": ["python", "-c", "import os"]}) r_py = _reason("run_argv", {"argv": ["python", "-c", "import os"]})
assert r_py is not None and "python -c" in r_py assert r_py is not None and "python -c" in r_py
r3 = classify_danger("open_pty", {"command": ["bash"]}) r3 = _reason("open_pty", {"command": ["bash"]})
assert r3 is not None and "bash" in r3 assert r3 is not None and "bash" in r3
assert "interactive" in r3 or "review" in r3 assert "interactive" in r3 or "review" in r3
r4 = classify_danger("open_pty", {"command": ["python3"]}) r4 = _reason("open_pty", {"command": ["python3"]})
assert r4 is not None and "python" in r4 assert r4 is not None and "python" in r4
r5 = classify_danger("open_pty", {"command": ["python3", "-c", "x=1"]}) r5 = _reason("open_pty", {"command": ["python3", "-c", "x=1"]})
assert r5 is not None and "-c" in r5 assert r5 is not None and "-c" in r5
def test_dash_c_means_code_only_for_interpreters() -> None: def test_dash_c_means_code_only_for_interpreters() -> None:
# ``-c`` is a plain flag elsewhere: no code to review, no confirm. # ``-c`` is a plain flag elsewhere: no code to review, no confirm.
assert classify_danger("run_argv", {"argv": ["grep", "-c", "needle", "f.txt"]}) is None assert _reason("run_argv", {"argv": ["grep", "-c", "needle", "f.txt"]}) is None
assert classify_danger("run_argv", {"argv": ["od", "-c", "f.bin"]}) is None assert _reason("run_argv", {"argv": ["od", "-c", "f.bin"]}) is None
assert classify_danger("run_argv", {"argv": ["sort", "-c", "f.txt"]}) is None assert _reason("run_argv", {"argv": ["sort", "-c", "f.txt"]}) is None
def test_interpreter_review_does_not_split_on_dash_c() -> None: def test_interpreter_review_does_not_split_on_dash_c() -> None:
script = classify_danger("run_argv", {"argv": ["python", "script.py"]}) script = _reason("run_argv", {"argv": ["python", "script.py"]})
one_liner = classify_danger("run_argv", {"argv": ["python", "-c", "print(1)"]}) one_liner = _reason("run_argv", {"argv": ["python", "-c", "print(1)"]})
bare_shell = classify_danger("open_pty", {"command": ["bash"]}) bare_shell = _reason("open_pty", {"command": ["bash"]})
assert script is not None and "interpreter 'python'" in script assert script is not None and "interpreter 'python'" in script
assert one_liner is not None and "interpreter 'python'" in one_liner assert one_liner is not None and "interpreter 'python'" in one_liner
assert bare_shell is not None and "interpreter 'bash'" in bare_shell assert bare_shell is not None and "interpreter 'bash'" in bare_shell
@@ -121,7 +137,7 @@ def test_interpreter_review_does_not_split_on_dash_c() -> None:
def test_versioned_interpreter_is_still_reviewed() -> None: def test_versioned_interpreter_is_still_reviewed() -> None:
reason = classify_danger("run_argv", {"argv": ["python3.12", "-c", "import os"]}) reason = _reason("run_argv", {"argv": ["python3.12", "-c", "import os"]})
assert reason is not None and "interpreter 'python3.12'" in reason assert reason is not None and "interpreter 'python3.12'" in reason
assert "import os" in reason assert "import os" in reason
@@ -134,7 +150,7 @@ def test_wrappers_reach_the_interpreter() -> None:
["pdm", "run", "python", "-c", "print(1)"], ["pdm", "run", "python", "-c", "print(1)"],
["uv", "run", "--with", "rich", "python", "-c", "print(1)"], ["uv", "run", "--with", "rich", "python", "-c", "print(1)"],
): ):
reason = classify_danger("run_argv", {"argv": argv}) reason = _reason("run_argv", {"argv": argv})
assert reason is not None, argv assert reason is not None, argv
assert "interpreter 'python'" in reason or "interpreter 'bash'" in reason assert "interpreter 'python'" in reason or "interpreter 'bash'" in reason
assert "$(command)" in reason assert "$(command)" in reason
@@ -142,7 +158,7 @@ def test_wrappers_reach_the_interpreter() -> None:
def test_value_free_flags_do_not_hide_the_interpreter() -> None: def test_value_free_flags_do_not_hide_the_interpreter() -> None:
"""``exec -c`` takes no value, so the interpreter behind it is still reviewed.""" """``exec -c`` takes no value, so the interpreter behind it is still reviewed."""
reason = classify_danger("run_argv", {"argv": ["exec", "-c", "python", "-c", "print(1)"]}) reason = _reason("run_argv", {"argv": ["exec", "-c", "python", "-c", "print(1)"]})
assert reason is not None assert reason is not None
assert "interpreter 'python'" in reason assert "interpreter 'python'" in reason
assert "print(1)" in reason assert "print(1)" in reason
@@ -150,26 +166,26 @@ def test_value_free_flags_do_not_hide_the_interpreter() -> None:
def test_wrapper_command_line_option_is_reviewed() -> None: def test_wrapper_command_line_option_is_reviewed() -> None:
"""`env -S` / `su -c` carry a command line, so they are confirmed like `-c`.""" """`env -S` / `su -c` carry a command line, so they are confirmed like `-c`."""
reason = classify_danger("run_argv", {"argv": ["env", "-S", "rm -rf /"]}) reason = _reason("run_argv", {"argv": ["env", "-S", "rm -rf /"]})
assert reason is not None assert reason is not None
assert "env -S runs a command line" in reason assert "env -S runs a command line" in reason
assert " env -S:\n rm -rf /" in reason assert " env -S:\n rm -rf /" in reason
# Plain wrapper options are not a review trigger. # Plain wrapper options are not a review trigger.
assert classify_danger("run_argv", {"argv": ["env", "-u", "FOO=1", "git", "status"]}) is None assert _reason("run_argv", {"argv": ["env", "-u", "FOO=1", "git", "status"]}) is None
assert classify_danger("run_argv", {"argv": ["env", "FOO=1", "git", "-c", "x=y", "log"]}) is None assert _reason("run_argv", {"argv": ["env", "FOO=1", "git", "-c", "x=y", "log"]}) is None
def test_wrapper_command_line_value_needs_no_interpreter() -> None: def test_wrapper_command_line_value_needs_no_interpreter() -> None:
"""The wrapper's value is reviewed even when no interpreter is resolved.""" """The wrapper's value is reviewed even when no interpreter is resolved."""
reason = classify_danger("run_argv", {"argv": ["env", "-S", "python -O script.py"]}) reason = _reason("run_argv", {"argv": ["env", "-S", "python -O script.py"]})
assert reason is not None assert reason is not None
assert "env -S runs a command line" in reason assert "env -S runs a command line" in reason
assert "python -O script.py" in reason assert "python -O script.py" in reason
def test_only_the_interpreter_dash_c_becomes_a_placeholder() -> None: def test_only_the_interpreter_dash_c_becomes_a_placeholder() -> None:
reason = classify_danger("run_argv", {"argv": ["ionice", "-c", "2", "python", "-c", "print(1)"]}) reason = _reason("run_argv", {"argv": ["ionice", "-c", "2", "python", "-c", "print(1)"]})
assert reason is not None assert reason is not None
argv_line = next(line for line in reason.splitlines() if line.startswith(" argv:")) argv_line = next(line for line in reason.splitlines() if line.startswith(" argv:"))
assert "ionice -c 2" in argv_line assert "ionice -c 2" in argv_line
@@ -178,15 +194,43 @@ def test_only_the_interpreter_dash_c_becomes_a_placeholder() -> None:
def test_detached_runner_is_always_reviewed() -> None: def test_detached_runner_is_always_reviewed() -> None:
assert classify_danger("run_argv", {"argv": ["sleep", "5"]}) is None assert _reason("run_argv", {"argv": ["sleep", "5"]}) is None
reason = classify_danger("run_argv", {"argv": ["nohup", "sleep", "5"]}) reason = _reason("run_argv", {"argv": ["nohup", "sleep", "5"]})
assert reason is not None and "nohup (detached run)" in reason assert reason is not None and "nohup (detached run)" in reason
nested = classify_danger("run_argv", {"argv": ["nohup", "python", "-c", "print(1)"]}) nested = _reason("run_argv", {"argv": ["nohup", "python", "-c", "print(1)"]})
assert nested is not None assert nested is not None
assert "nohup (detached run)" in nested and "interpreter 'python'" in nested assert "nohup (detached run)" in nested and "interpreter 'python'" in nested
assert "print(1)" in nested assert "print(1)" in nested
def test_privilege_escalation_refuses_the_yolo_skip() -> None:
"""A reason that runs through `sudo`/`doas` keeps its prompt under YOLO mode."""
reason = _danger("run_argv", {"argv": ["sudo", "python", "-c", "print(1)"]})
assert "interpreter 'python'" in reason.text
assert "privilege escalation sudo" in reason.text
assert reason.yolo_skip is False
assert _danger("run_argv", {"argv": ["env", "FOO=1", "doas", "bash", "-c", "ls"]}).yolo_skip is False
# A plain interpreter run is still YOLO mode's to drop.
assert _danger("run_argv", {"argv": ["bash", "-c", "echo hi"]}).yolo_skip is True
# Escalation adds no reason of its own: the denylist gate asks about it.
assert classify_danger("run_argv", {"argv": ["sudo", "ls"]}) is None
def test_batch_refuses_the_yolo_skip_when_a_step_escalates() -> None:
"""One escalating step keeps the batch prompt; all-plain steps may be dropped."""
mixed = _danger(
"run_argv_batch",
{"steps": [{"argv": ["bash", "-c", "ls"]}, {"argv": ["sudo", "python", "-c", "print(1)"]}]},
)
assert mixed.yolo_skip is False
plain = _danger(
"run_argv_batch",
{"steps": [{"argv": ["bash", "-c", "ls"]}, {"argv": ["python", "-c", "print(1)"]}]},
)
assert plain.yolo_skip is True
async def test_confirm_deny_with_comment() -> None: async def test_confirm_deny_with_comment() -> None:
from plyngent.agent.tools import ToolRegistry, tool from plyngent.agent.tools import ToolRegistry, tool
from plyngent.tools.danger import classify_danger as danger from plyngent.tools.danger import classify_danger as danger
@@ -208,7 +252,7 @@ async def test_confirm_deny_with_comment() -> None:
def test_shell_confirm_formats_command_placeholder() -> None: def test_shell_confirm_formats_command_placeholder() -> None:
script = chr(10).join(["line1", "line2", "line3"]) script = chr(10).join(["line1", "line2", "line3"])
reason = classify_danger( reason = _reason(
"run_argv", "run_argv",
{"argv": ["bash", "-c", script]}, {"argv": ["bash", "-c", script]},
) )
+2 -2
View File
@@ -184,9 +184,9 @@ def test_soft_confirm_reason_matrix() -> None:
def test_classify_danger_fetch() -> None: def test_classify_danger_fetch() -> None:
assert classify_danger("fetch_local", {"url": "https://example.com/", "method": "GET"}) is None assert classify_danger("fetch_local", {"url": "https://example.com/", "method": "GET"}) is None
reason = classify_danger("fetch_local", {"url": "http://example.com/", "method": "GET"}) reason = classify_danger("fetch_local", {"url": "http://example.com/", "method": "GET"})
assert reason is not None and "cleartext" in reason assert isinstance(reason, str) and "cleartext" in reason
reason2 = classify_danger("fetch_local", {"url": "https://example.com/api", "method": "DELETE"}) reason2 = classify_danger("fetch_local", {"url": "https://example.com/api", "method": "DELETE"})
assert reason2 is not None and "DELETE" in reason2 assert isinstance(reason2, str) and "DELETE" in reason2
async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> None: async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> None: