Files
plyngent/tests/test_cli/test_limits.py
T

299 lines
11 KiB
Python
Raw Normal View History

from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING
import pytest
from plyngent.cli.display import close_open_pretty_line
from plyngent.cli.limits import (
auto_continue_enabled,
format_tool_confirm_box,
install_cli_limit_hooks,
prompt_confirm_tool,
prompt_continue_limit,
reset_auto_continue,
reset_auto_continue_turn,
set_auto_continue_default,
)
from plyngent.prompting import (
NonInteractiveBackend,
get_prompt_backend,
get_prompt_output_hook,
get_prompt_wait_hook,
temporary_backend,
)
from plyngent.tools.process.pty_session import PtyManager
from tests.test_prompting import ScriptedBackend
if TYPE_CHECKING:
from collections.abc import Generator, Iterator
@pytest.fixture
def clean_auto_continue() -> Iterator[None]:
reset_auto_continue()
yield
reset_auto_continue()
def test_prompt_continue_limit_yes() -> None:
backend = ScriptedBackend(["y"])
with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is True
def test_prompt_continue_limit_default_yes() -> None:
backend = ScriptedBackend([])
with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is True
def test_prompt_continue_limit_no() -> None:
backend = ScriptedBackend(["n"])
with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is False
def test_prompt_continue_limit_yyy_sets_turn_skip(clean_auto_continue: None) -> None:
del clean_auto_continue
backend = ScriptedBackend(["yyy"])
with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is True
assert auto_continue_enabled() is True
# No further prompt: the script is empty and must not be consumed.
assert prompt_continue_limit("hit a wall again") is True
def test_reset_auto_continue_turn_clears_yyy(clean_auto_continue: None) -> None:
del clean_auto_continue
with temporary_backend(ScriptedBackend(["yyy"])):
assert prompt_continue_limit("limit") is True
reset_auto_continue_turn()
assert auto_continue_enabled() is False
with temporary_backend(ScriptedBackend(["n"])):
assert prompt_continue_limit("limit") is False
def test_auto_continue_default_short_circuits_prompt(clean_auto_continue: None) -> None:
del clean_auto_continue
set_auto_continue_default(enabled=True)
assert auto_continue_enabled() is True
with temporary_backend(NonInteractiveBackend()):
assert prompt_continue_limit("limit") is True
def test_prompt_continue_limit_noninteractive_denies() -> None:
with temporary_backend(NonInteractiveBackend()):
assert prompt_continue_limit("limit") is False
def test_setup_hooks_installs_pty_hook_for_auto_continue(clean_auto_continue: None) -> None:
del clean_auto_continue
from plyngent.cli.app import _setup_hooks
from plyngent.config.models import NotifyConfig
_setup_hooks(interactive=False, auto_continue=True, notify=NotifyConfig())
assert PtyManager._limit_continue is not None
_setup_hooks(interactive=False, auto_continue=False, notify=NotifyConfig())
assert PtyManager._limit_continue is None
def test_pty_offer_raise_uses_auto_continue(clean_auto_continue: None) -> None:
del clean_auto_continue
PtyManager.set_limit_continue_hook(prompt_continue_limit)
set_auto_continue_default(enabled=True)
try:
with temporary_backend(NonInteractiveBackend()):
assert PtyManager._offer_raise("PTY session limit reached") is True
finally:
PtyManager.set_limit_continue_hook(None)
def test_prompt_confirm_tool_default_deny() -> None:
backend = ScriptedBackend([], confirms=[False])
with temporary_backend(backend):
assert prompt_confirm_tool("delete_path", {"path": "x"}, "delete path 'x'") is False
def test_format_tool_confirm_box_multiline() -> None:
reason = "run_argv: python3 -c (review code before allow)\nargv:\n python3 -c print(1)\n-c code:\n print(1)"
box = format_tool_confirm_box("run_argv", reason)
assert "┌" in box and "└" in box
assert "confirm · tool 'run_argv'" in box
assert "python3 -c" in box
assert "print(1)" in box
# Distinct lines, not one jammed row
assert box.count("\n") >= 4
def test_install_cli_limit_hooks() -> None:
install_cli_limit_hooks()
assert callable(getattr(PtyManager, "_limit_continue", None))
# A prompt raised mid-call must end the open pretty tool line first.
assert get_prompt_output_hook() is close_open_pretty_line
PtyManager.set_limit_continue_hook(None)
# Backend remains usable after install.
assert get_prompt_backend().is_interactive() or True
def test_policy_confirm_noninteractive_denies() -> None:
from plyngent.cli.limits import prompt_policy_command_confirm
from plyngent.prompting import NonInteractiveBackend
with temporary_backend(NonInteractiveBackend()):
assert prompt_policy_command_confirm("sudo", ["sudo", "id"], 1.0) is False
def test_directory_access_prompt_noninteractive_denies() -> None:
from pathlib import Path
from plyngent.cli.limits import prompt_directory_access_confirm
from plyngent.prompting import NonInteractiveBackend
from plyngent.tools.workspace import AccessMode
with temporary_backend(NonInteractiveBackend()):
assert prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 1.0) is None
def test_directory_access_prompt_yes_and_level(monkeypatch: pytest.MonkeyPatch) -> None:
from pathlib import Path
from plyngent.cli import limits
from plyngent.tools.access import AccessDecision
from plyngent.tools.workspace import AccessMode
target = Path("/tmp")
with temporary_backend(ScriptedBackend([])):
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "y\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
AccessMode.READ
)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "exec\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
AccessMode.EXEC
)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "n\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: None)
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
class _AnswerSource:
"""A host surface offering answers to a waiting prompt."""
supports_actions = True
def __init__(self, *answers: str) -> None:
self._answers = list(answers)
def take(self) -> str | None:
return self._answers.pop(0) if self._answers else None
@contextlib.contextmanager
def _answered(*answers: str) -> Generator[_AnswerSource]:
"""Install a wait hook whose prompt can be answered by the notification."""
from plyngent.prompting import set_prompt_wait_hook
source = _AnswerSource(*answers)
previous = get_prompt_wait_hook()
set_prompt_wait_hook(lambda _wait: contextlib.nullcontext(source))
try:
yield source
finally:
set_prompt_wait_hook(previous)
def test_directory_access_takes_a_notification_answer() -> None:
"""``allow`` from the notification approves the requested level."""
from pathlib import Path
from plyngent.cli import limits
from plyngent.tools.access import AccessDecision
from plyngent.tools.workspace import AccessMode
with temporary_backend(ScriptedBackend([])), _answered("allow"):
assert limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) == AccessDecision(
AccessMode.READ
)
def test_directory_access_denied_by_the_notification() -> None:
from pathlib import Path
from plyngent.cli import limits
from plyngent.tools.workspace import AccessMode
with temporary_backend(ScriptedBackend([])), _answered("deny"):
assert limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) is None
def test_tool_confirm_takes_a_notification_answer() -> None:
with temporary_backend(ScriptedBackend([])), _answered("allow"):
assert prompt_confirm_tool("run_argv", {}, "runs a command") is True
def test_tool_confirm_denied_by_the_notification() -> None:
with temporary_backend(ScriptedBackend([""])), _answered("deny"):
assert prompt_confirm_tool("run_argv", {}, "runs a command") is False
def test_batch_confirm_takes_a_notification_answer() -> None:
from plyngent.agent import ToolConfirmRequest
from plyngent.cli.limits import _prompt_confirm_batch_sync
requests = [
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
]
with temporary_backend(ScriptedBackend([])), _answered("allow"):
assert _prompt_confirm_batch_sync(requests).allowed is True
def test_batch_confirm_denied_by_the_notification() -> None:
from plyngent.agent import ToolConfirmRequest
from plyngent.cli.limits import _prompt_confirm_batch_sync
requests = [
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
]
with temporary_backend(ScriptedBackend([""])), _answered("deny"):
assert _prompt_confirm_batch_sync(requests).allowed is False
def test_every_confirm_announces_itself() -> None:
"""Each blocking confirm reports its kind, so a host can pick a surface."""
from pathlib import Path
from plyngent.agent import ToolConfirmRequest
from plyngent.cli import limits
from plyngent.tools.workspace import AccessMode
kinds: list[str] = []
@contextlib.contextmanager
def hook(wait: object) -> Generator[None]:
kinds.append(wait.kind) # type: ignore[attr-defined]
yield None
from plyngent.prompting import set_prompt_wait_hook
previous = get_prompt_wait_hook()
set_prompt_wait_hook(hook)
try:
with temporary_backend(ScriptedBackend(["y", "y", "y"], confirms=[True])):
_ = prompt_confirm_tool("run_argv", {}, "reason")
_ = limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0)
_ = limits._prompt_confirm_batch_sync(
[
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
]
)
_ = prompt_continue_limit("limit")
finally:
set_prompt_wait_hook(previous)
assert kinds == ["confirm", "access", "batch", "limit"]