mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/cli+config: confirm destructive tools and path denylist
Wire [agent] confirm_destructive / path_denylist into CLI; default confirm prompt denies; document Phase C safety hooks.
This commit is contained in:
@@ -57,17 +57,18 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init, default local user, se
|
||||
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; optional `on_limit`.
|
||||
- **`ChatAgent`**: optional `MemoryStore` (persist on success only); `stream`; system prompt; `pending_retry_text` + `retry()`.
|
||||
- Events: text_delta, assistant_message, tool_call/result, max_rounds, **error**, **cancelled**.
|
||||
- Config ``[agent]``: `system_prompt`, `max_tool_result_chars`, `parallel_tools`.
|
||||
- Config ``[agent]``: `system_prompt`, `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`.
|
||||
|
||||
### Tools (`tools/`)
|
||||
|
||||
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
|
||||
- **`workspace`**: path resolve under root; path substring denylist; command basename denylist.
|
||||
- **`workspace`**: path resolve under root; path substring denylist; command basename denylist; clearer deny messages.
|
||||
- **`file`**: `read_file`, `write_file`, `listdir`, `tree`, `edit_replace`, `edit_lineno` (1-based range), `copy_path` / `move_path` / `delete_path`.
|
||||
- **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); PTY `open_pty` / `read_pty` / `write_pty` / `close_pty` (**Unix only**: `pty`+`fork`).
|
||||
- PTY: structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)`; session limit/idle TTL/output budget; close SIGTERM→SIGKILL.
|
||||
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget.
|
||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`.
|
||||
- **`DEFAULT_TOOLS`**: file + process tool list for a `ToolRegistry`.
|
||||
|
||||
### CLI (`cli/`)
|
||||
|
||||
@@ -67,6 +67,9 @@ async def _run_chat(
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
|
||||
_ = set_workspace_root(workspace)
|
||||
from plyngent.tools import set_path_denylist
|
||||
|
||||
set_path_denylist(store.agent_config.path_denylist or None)
|
||||
install_cli_limit_hooks()
|
||||
memory = await MemoryStore.open(_database_config(store))
|
||||
try:
|
||||
|
||||
@@ -26,6 +26,8 @@ _MINIMAL_CONFIG = """\
|
||||
# system_prompt = "You are a careful coding assistant."
|
||||
# max_tool_result_chars = 32000
|
||||
# parallel_tools = true
|
||||
# confirm_destructive = true
|
||||
# path_denylist = ["/secrets/", ".ssh/"]
|
||||
|
||||
# [providers.example]
|
||||
# preset = "openai-compatible"
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
def prompt_continue_limit(reason: str) -> bool:
|
||||
"""Ask the user whether to raise a limit and continue (TTY)."""
|
||||
@@ -15,6 +20,17 @@ def prompt_continue_limit(reason: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool:
|
||||
"""Ask whether to allow a destructive tool call (TTY). Default is deny."""
|
||||
del args
|
||||
click.echo()
|
||||
click.secho(f"[confirm] tool {name!r}: {reason}", fg="yellow")
|
||||
try:
|
||||
return bool(click.confirm("Allow this tool call?", default=False))
|
||||
except click.Abort:
|
||||
return False
|
||||
|
||||
|
||||
def install_cli_limit_hooks() -> None:
|
||||
"""Register interactive continue hooks for process-global tool limits."""
|
||||
PtyManager.set_limit_continue_hook(prompt_continue_limit)
|
||||
|
||||
@@ -40,6 +40,16 @@ class ReplState:
|
||||
def _tool_registry(self) -> ToolRegistry | None:
|
||||
if not self.tools_enabled:
|
||||
return None
|
||||
from plyngent.cli.limits import prompt_confirm_tool
|
||||
from plyngent.tools.danger import classify_danger
|
||||
|
||||
agent_cfg = self.config.agent_config
|
||||
if agent_cfg.confirm_destructive:
|
||||
return ToolRegistry(
|
||||
list(DEFAULT_TOOLS),
|
||||
danger=classify_danger,
|
||||
on_confirm=prompt_confirm_tool,
|
||||
)
|
||||
return ToolRegistry(list(DEFAULT_TOOLS))
|
||||
|
||||
def _make_agent(self) -> ChatAgent:
|
||||
|
||||
@@ -16,6 +16,8 @@ class AgentConfig(Struct, omit_defaults=True):
|
||||
system_prompt: str = ""
|
||||
max_tool_result_chars: int = 32_000
|
||||
parallel_tools: bool = True
|
||||
confirm_destructive: bool = True
|
||||
path_denylist: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class ModelConfig(Struct, omit_defaults=True):
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.cli.limits import install_cli_limit_hooks, prompt_continue_limit
|
||||
from plyngent.cli.limits import install_cli_limit_hooks, prompt_confirm_tool, prompt_continue_limit
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -25,6 +25,19 @@ def test_prompt_continue_limit_no(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert prompt_continue_limit("hit a wall") is False
|
||||
|
||||
|
||||
def test_prompt_confirm_tool_default_deny(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
def _confirm(message: str, **kwargs: object) -> bool:
|
||||
seen["message"] = message
|
||||
seen["default"] = kwargs.get("default")
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("click.confirm", _confirm)
|
||||
assert prompt_confirm_tool("delete_path", {"path": "x"}, "delete path 'x'") is False
|
||||
assert seen["default"] is False
|
||||
|
||||
|
||||
def test_install_cli_limit_hooks() -> None:
|
||||
install_cli_limit_hooks()
|
||||
# Hook is installed process-wide for the CLI session.
|
||||
|
||||
@@ -15,6 +15,8 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
|
||||
assert store.agent_config.system_prompt == ""
|
||||
assert store.agent_config.max_tool_result_chars == 32_000 # noqa: PLR2004
|
||||
assert store.agent_config.parallel_tools is True
|
||||
assert store.agent_config.confirm_destructive is True
|
||||
assert store.agent_config.path_denylist == []
|
||||
|
||||
|
||||
def test_agent_section_parse(tmp_path: Path) -> None:
|
||||
@@ -25,6 +27,8 @@ def test_agent_section_parse(tmp_path: Path) -> None:
|
||||
system_prompt = "Be brief."
|
||||
max_tool_result_chars = 100
|
||||
parallel_tools = false
|
||||
confirm_destructive = false
|
||||
path_denylist = ["/secrets/", ".ssh/"]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -32,3 +36,5 @@ parallel_tools = false
|
||||
assert store.agent_config.system_prompt == "Be brief."
|
||||
assert store.agent_config.max_tool_result_chars == 100 # noqa: PLR2004
|
||||
assert store.agent_config.parallel_tools is False
|
||||
assert store.agent_config.confirm_destructive is False
|
||||
assert store.agent_config.path_denylist == ["/secrets/", ".ssh/"]
|
||||
|
||||
Reference in New Issue
Block a user