2026-07-14 23:30:02 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-10-21 19:10:46 +08:00
|
|
|
from plyngent.agent import DangerReason
|
2026-07-14 23:30:02 +08:00
|
|
|
from plyngent.tools.danger import classify_danger
|
2026-10-21 20:23:54 +08:00
|
|
|
from plyngent.tools.workspace import set_trash_enabled
|
2026-07-14 23:30:02 +08:00
|
|
|
|
|
|
|
|
|
2026-10-21 19:10:46 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 23:30:02 +08:00
|
|
|
def test_classify_delete_and_move() -> None:
|
2026-10-21 20:23:54 +08:00
|
|
|
# No bound workspace here, so the confirm falls back to the default wording.
|
|
|
|
|
assert _reason("delete_path", {"path": "a.txt"}) == "move path 'a.txt' to the system trash"
|
2026-10-21 19:10:46 +08:00
|
|
|
assert "recursively" in (_reason("delete_path", {"path": "d", "recursive": True}) or "")
|
|
|
|
|
assert "move" in (_reason("move_path", {"src": "a", "dst": "b"}) or "")
|
2026-07-14 23:30:02 +08:00
|
|
|
|
|
|
|
|
|
2026-10-21 20:23:54 +08:00
|
|
|
def test_classify_permanent_delete() -> None:
|
|
|
|
|
assert _reason("delete_path", {"path": "a.txt", "permanent": True}) == "delete path 'a.txt' permanently"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_scratch_path_is_announced_as_permanent(workspace: object) -> None:
|
|
|
|
|
"""The confirm says what will happen: the temp workspace is never trashed."""
|
|
|
|
|
del workspace
|
|
|
|
|
assert _reason("delete_path", {"path": "a.txt"}) == "delete path 'a.txt' permanently"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_host_switch_is_announced_as_permanent(workspace: object) -> None:
|
|
|
|
|
del workspace
|
|
|
|
|
set_trash_enabled(enabled=False)
|
|
|
|
|
assert _reason("delete_path", {"path": "a.txt"}) == "delete path 'a.txt' permanently"
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 17:41:44 +08:00
|
|
|
def test_classify_copy() -> None:
|
2026-07-19 03:05:01 +08:00
|
|
|
# Without overwrite flag, copy is not soft-confirmed.
|
2026-10-21 19:10:46 +08:00
|
|
|
assert _reason("copy_path", {"src": "a", "dst": "b"}) is None
|
|
|
|
|
assert _reason("copy_path", {"src": "a", "dst": "b", "overwrite": False}) is None
|
2026-07-14 23:30:02 +08:00
|
|
|
|
|
|
|
|
|
2026-07-19 03:05:01 +08:00
|
|
|
def test_classify_write_overwrite_only(tmp_path: Path) -> None:
|
2026-07-24 16:32:33 +08:00
|
|
|
from plyngent.tools.context import InstanceState, bind_instance
|
|
|
|
|
from plyngent.tools.workspace import set_workspace_root
|
2026-07-18 17:41:44 +08:00
|
|
|
|
2026-07-24 16:32:33 +08:00
|
|
|
instance = InstanceState(workspace_root=tmp_path.resolve())
|
|
|
|
|
with bind_instance(instance):
|
|
|
|
|
_ = set_workspace_root(tmp_path)
|
2026-10-16 18:44:14 +08:00
|
|
|
# New file: no soft-confirm, with or without the overwrite flag
|
2026-10-21 19:10:46 +08:00
|
|
|
assert _reason("write_file", {"path": "new.txt"}) is None
|
|
|
|
|
assert _reason("write_file", {"path": "new.txt", "overwrite": True}) is None
|
2026-07-19 03:05:01 +08:00
|
|
|
# Partial edits: never soft-confirm
|
2026-10-21 19:10:46 +08:00
|
|
|
assert _reason("edit_replace", {"path": "x.txt"}) is None
|
|
|
|
|
assert _reason("edit_lineno", {"path": "x.txt", "start_line": 1, "end_line": 2}) is None
|
2026-10-16 18:44:14 +08:00
|
|
|
# Existing file: only an explicit overwrite asks; an unset/false flag is
|
|
|
|
|
# refused inside the tool, so no prompt may fire for it.
|
2026-07-19 03:05:01 +08:00
|
|
|
(tmp_path / "x.txt").write_text("old", encoding="utf-8")
|
2026-10-21 19:10:46 +08:00
|
|
|
assert _reason("write_file", {"path": "x.txt"}) is None
|
|
|
|
|
assert _reason("write_file", {"path": "x.txt", "overwrite": False}) is None
|
|
|
|
|
reason = _reason("write_file", {"path": "x.txt", "overwrite": True})
|
2026-07-19 03:05:01 +08:00
|
|
|
assert reason is not None and "overwrite" in reason
|
|
|
|
|
(tmp_path / "dst.txt").write_text("d", encoding="utf-8")
|
|
|
|
|
(tmp_path / "src.txt").write_text("s", encoding="utf-8")
|
2026-10-21 19:10:46 +08:00
|
|
|
assert _reason("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": False}) is None
|
|
|
|
|
creason = _reason("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": True})
|
2026-07-19 03:05:01 +08:00
|
|
|
assert creason is not None and "overwrite" in creason
|
2026-07-14 23:30:02 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_classify_safe_tools() -> None:
|
2026-10-21 19:10:46 +08:00
|
|
|
assert _reason("read_file", {"path": "a"}) is None
|
|
|
|
|
assert _reason("listdir", {"path": "."}) is None
|
|
|
|
|
assert _reason("run_argv", {"argv": ["echo", "hi"]}) is None
|
|
|
|
|
assert _reason("open_pty", {"command": ["true"]}) is None
|
|
|
|
|
assert _reason("run_argv", {"argv": ["ls", "-la"]}) is None
|
2026-07-18 17:41:44 +08:00
|
|
|
|
|
|
|
|
|
2026-08-04 21:40:25 +08:00
|
|
|
def test_classify_run_argv_batch_risky() -> None:
|
2026-10-21 19:10:46 +08:00
|
|
|
reason = _reason(
|
2026-08-04 21:40:25 +08:00
|
|
|
"run_argv_batch",
|
2026-07-19 02:46:21 +08:00
|
|
|
{
|
2026-08-04 21:40:25 +08:00
|
|
|
"steps": [
|
|
|
|
|
{"argv": ["echo", "ok"]},
|
|
|
|
|
{"argv": ["bash", "-c", "echo risky"]},
|
2026-07-19 02:46:21 +08:00
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert reason is not None
|
2026-08-04 21:40:25 +08:00
|
|
|
assert "run_argv_batch" in reason
|
2026-07-19 02:46:21 +08:00
|
|
|
assert "risky" in reason or "bash" in reason
|
|
|
|
|
assert (
|
2026-10-21 19:10:46 +08:00
|
|
|
_reason(
|
2026-08-04 21:40:25 +08:00
|
|
|
"run_argv_batch",
|
|
|
|
|
{"steps": [{"argv": ["echo", "ok"]}]},
|
2026-07-19 02:46:21 +08:00
|
|
|
)
|
|
|
|
|
is None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 17:41:44 +08:00
|
|
|
def test_classify_shell_and_dash_c() -> None:
|
2026-10-21 19:10:46 +08:00
|
|
|
r = _reason("run_argv", {"argv": ["bash", "-c", "rm -rf /"]})
|
2026-07-18 17:41:44 +08:00
|
|
|
assert r is not None
|
|
|
|
|
assert "bash -c" in r
|
|
|
|
|
assert "rm -rf" in r
|
|
|
|
|
|
2026-10-21 19:10:46 +08:00
|
|
|
r2 = _reason("run_argv", {"argv": ["python3", "-c", "print(1)"]})
|
2026-07-18 17:41:44 +08:00
|
|
|
assert r2 is not None
|
|
|
|
|
assert "python" in r2
|
|
|
|
|
assert "-c" in r2
|
|
|
|
|
assert "print(1)" in r2
|
|
|
|
|
|
2026-10-21 19:10:46 +08:00
|
|
|
r_py = _reason("run_argv", {"argv": ["python", "-c", "import os"]})
|
2026-07-18 17:41:44 +08:00
|
|
|
assert r_py is not None and "python -c" in r_py
|
|
|
|
|
|
2026-10-21 19:10:46 +08:00
|
|
|
r3 = _reason("open_pty", {"command": ["bash"]})
|
2026-07-18 17:41:44 +08:00
|
|
|
assert r3 is not None and "bash" in r3
|
|
|
|
|
assert "interactive" in r3 or "review" in r3
|
|
|
|
|
|
2026-10-21 19:10:46 +08:00
|
|
|
r4 = _reason("open_pty", {"command": ["python3"]})
|
2026-07-18 17:41:44 +08:00
|
|
|
assert r4 is not None and "python" in r4
|
|
|
|
|
|
2026-10-21 19:10:46 +08:00
|
|
|
r5 = _reason("open_pty", {"command": ["python3", "-c", "x=1"]})
|
2026-07-18 17:41:44 +08:00
|
|
|
assert r5 is not None and "-c" in r5
|
|
|
|
|
|
|
|
|
|
|
2026-10-06 19:44:29 +08:00
|
|
|
def test_dash_c_means_code_only_for_interpreters() -> None:
|
|
|
|
|
# ``-c`` is a plain flag elsewhere: no code to review, no confirm.
|
2026-10-21 19:10:46 +08:00
|
|
|
assert _reason("run_argv", {"argv": ["grep", "-c", "needle", "f.txt"]}) is None
|
|
|
|
|
assert _reason("run_argv", {"argv": ["od", "-c", "f.bin"]}) is None
|
|
|
|
|
assert _reason("run_argv", {"argv": ["sort", "-c", "f.txt"]}) is None
|
2026-10-06 19:44:29 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_interpreter_review_does_not_split_on_dash_c() -> None:
|
2026-10-21 19:10:46 +08:00
|
|
|
script = _reason("run_argv", {"argv": ["python", "script.py"]})
|
|
|
|
|
one_liner = _reason("run_argv", {"argv": ["python", "-c", "print(1)"]})
|
|
|
|
|
bare_shell = _reason("open_pty", {"command": ["bash"]})
|
2026-10-06 19:44:29 +08:00
|
|
|
assert script is not None and "interpreter 'python'" in script
|
|
|
|
|
assert one_liner is not None and "interpreter 'python'" in one_liner
|
|
|
|
|
assert bare_shell is not None and "interpreter 'bash'" in bare_shell
|
|
|
|
|
# Only the ``-c`` form has code to print below the argv line.
|
|
|
|
|
assert "command:" not in script
|
|
|
|
|
assert "command:" in one_liner
|
|
|
|
|
assert "print(1)" in one_liner
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_versioned_interpreter_is_still_reviewed() -> None:
|
2026-10-21 19:10:46 +08:00
|
|
|
reason = _reason("run_argv", {"argv": ["python3.12", "-c", "import os"]})
|
2026-10-06 19:44:29 +08:00
|
|
|
assert reason is not None and "interpreter 'python3.12'" in reason
|
|
|
|
|
assert "import os" in reason
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wrappers_reach_the_interpreter() -> None:
|
|
|
|
|
for argv in (
|
|
|
|
|
["env", "FOO=1", "python", "-c", "print(1)"],
|
|
|
|
|
["sudo", "-u", "root", "python", "-c", "print(1)"],
|
|
|
|
|
["timeout", "5", "bash", "-c", "rm -rf /"],
|
|
|
|
|
["pdm", "run", "python", "-c", "print(1)"],
|
|
|
|
|
["uv", "run", "--with", "rich", "python", "-c", "print(1)"],
|
|
|
|
|
):
|
2026-10-21 19:10:46 +08:00
|
|
|
reason = _reason("run_argv", {"argv": argv})
|
2026-10-06 19:44:29 +08:00
|
|
|
assert reason is not None, argv
|
|
|
|
|
assert "interpreter 'python'" in reason or "interpreter 'bash'" in reason
|
|
|
|
|
assert "$(command)" in reason
|
|
|
|
|
|
|
|
|
|
|
2026-10-06 21:50:21 +08:00
|
|
|
def test_value_free_flags_do_not_hide_the_interpreter() -> None:
|
|
|
|
|
"""``exec -c`` takes no value, so the interpreter behind it is still reviewed."""
|
2026-10-21 19:10:46 +08:00
|
|
|
reason = _reason("run_argv", {"argv": ["exec", "-c", "python", "-c", "print(1)"]})
|
2026-10-06 21:50:21 +08:00
|
|
|
assert reason is not None
|
|
|
|
|
assert "interpreter 'python'" in reason
|
|
|
|
|
assert "print(1)" in reason
|
|
|
|
|
|
|
|
|
|
|
2026-10-06 21:54:18 +08:00
|
|
|
def test_wrapper_command_line_option_is_reviewed() -> None:
|
|
|
|
|
"""`env -S` / `su -c` carry a command line, so they are confirmed like `-c`."""
|
2026-10-21 19:10:46 +08:00
|
|
|
reason = _reason("run_argv", {"argv": ["env", "-S", "rm -rf /"]})
|
2026-10-06 21:54:18 +08:00
|
|
|
assert reason is not None
|
|
|
|
|
assert "env -S runs a command line" in reason
|
|
|
|
|
assert " env -S:\n rm -rf /" in reason
|
|
|
|
|
|
|
|
|
|
# Plain wrapper options are not a review trigger.
|
2026-10-21 19:10:46 +08:00
|
|
|
assert _reason("run_argv", {"argv": ["env", "-u", "FOO=1", "git", "status"]}) is None
|
|
|
|
|
assert _reason("run_argv", {"argv": ["env", "FOO=1", "git", "-c", "x=y", "log"]}) is None
|
2026-10-06 21:54:18 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wrapper_command_line_value_needs_no_interpreter() -> None:
|
|
|
|
|
"""The wrapper's value is reviewed even when no interpreter is resolved."""
|
2026-10-21 19:10:46 +08:00
|
|
|
reason = _reason("run_argv", {"argv": ["env", "-S", "python -O script.py"]})
|
2026-10-06 21:54:18 +08:00
|
|
|
assert reason is not None
|
|
|
|
|
assert "env -S runs a command line" in reason
|
|
|
|
|
assert "python -O script.py" in reason
|
|
|
|
|
|
|
|
|
|
|
2026-10-06 19:44:29 +08:00
|
|
|
def test_only_the_interpreter_dash_c_becomes_a_placeholder() -> None:
|
2026-10-21 19:10:46 +08:00
|
|
|
reason = _reason("run_argv", {"argv": ["ionice", "-c", "2", "python", "-c", "print(1)"]})
|
2026-10-06 19:44:29 +08:00
|
|
|
assert reason is not None
|
|
|
|
|
argv_line = next(line for line in reason.splitlines() if line.startswith(" argv:"))
|
|
|
|
|
assert "ionice -c 2" in argv_line
|
|
|
|
|
assert argv_line.count("$(command)") == 1
|
|
|
|
|
assert "print(1)" in reason
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_detached_runner_is_always_reviewed() -> None:
|
2026-10-21 19:10:46 +08:00
|
|
|
assert _reason("run_argv", {"argv": ["sleep", "5"]}) is None
|
|
|
|
|
reason = _reason("run_argv", {"argv": ["nohup", "sleep", "5"]})
|
2026-10-06 19:44:29 +08:00
|
|
|
assert reason is not None and "nohup (detached run)" in reason
|
2026-10-21 19:10:46 +08:00
|
|
|
nested = _reason("run_argv", {"argv": ["nohup", "python", "-c", "print(1)"]})
|
2026-10-06 19:44:29 +08:00
|
|
|
assert nested is not None
|
|
|
|
|
assert "nohup (detached run)" in nested and "interpreter 'python'" in nested
|
|
|
|
|
assert "print(1)" in nested
|
|
|
|
|
|
|
|
|
|
|
2026-10-21 19:10:46 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 17:41:44 +08:00
|
|
|
async def test_confirm_deny_with_comment() -> None:
|
|
|
|
|
from plyngent.agent.tools import ToolRegistry, tool
|
|
|
|
|
from plyngent.tools.danger import classify_danger as danger
|
|
|
|
|
|
2026-07-24 14:57:03 +08:00
|
|
|
@tool(register=False)
|
2026-07-18 17:41:44 +08:00
|
|
|
def delete_path(path: str) -> str:
|
|
|
|
|
return f"deleted {path}"
|
|
|
|
|
|
|
|
|
|
async def deny_comment(name: str, args: object, reason: str) -> str:
|
|
|
|
|
del name, args, reason
|
|
|
|
|
return "too destructive for this session"
|
|
|
|
|
|
|
|
|
|
reg = ToolRegistry([delete_path], danger=danger, on_confirm=deny_comment)
|
|
|
|
|
out = await reg.execute("delete_path", '{"path": "x"}')
|
|
|
|
|
assert "denied" in out
|
|
|
|
|
assert "user comment:" in out
|
|
|
|
|
assert "too destructive" in out
|
2026-07-19 13:01:06 +08:00
|
|
|
|
2026-07-20 04:35:26 +08:00
|
|
|
|
2026-07-19 13:01:06 +08:00
|
|
|
def test_shell_confirm_formats_command_placeholder() -> None:
|
2026-07-19 13:02:24 +08:00
|
|
|
script = chr(10).join(["line1", "line2", "line3"])
|
2026-10-21 19:10:46 +08:00
|
|
|
reason = _reason(
|
2026-08-04 21:38:20 +08:00
|
|
|
"run_argv",
|
|
|
|
|
{"argv": ["bash", "-c", script]},
|
2026-07-19 13:01:06 +08:00
|
|
|
)
|
|
|
|
|
assert reason is not None
|
|
|
|
|
assert "$(command)" in reason
|
|
|
|
|
assert "line1" in reason and "line2" in reason and "line3" in reason
|
2026-10-06 19:44:29 +08:00
|
|
|
argv_line = next(ln for ln in reason.splitlines() if ln.startswith(" argv:"))
|
2026-07-19 13:01:06 +08:00
|
|
|
assert "line1" not in argv_line
|
|
|
|
|
lines = reason.splitlines()
|
|
|
|
|
idx = lines.index(" command:")
|
|
|
|
|
body = lines[idx + 1 :]
|
|
|
|
|
assert body
|
|
|
|
|
assert all(ln.startswith(" ") for ln in body)
|