From bcfd1098f8aa326710c9d5ed5efac4c81da4a328 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Tue, 14 Jul 2026 23:30:17 +0800 Subject: [PATCH] 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. --- CLAUDE.md | 5 +++-- src/plyngent/cli/app.py | 3 +++ src/plyngent/cli/editor.py | 2 ++ src/plyngent/cli/limits.py | 16 ++++++++++++++++ src/plyngent/cli/state.py | 10 ++++++++++ src/plyngent/config/models.py | 2 ++ tests/test_cli/test_limits.py | 15 ++++++++++++++- tests/test_config/test_agent_section.py | 6 ++++++ 8 files changed, 56 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4eb4620..8ffd59c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/`) diff --git a/src/plyngent/cli/app.py b/src/plyngent/cli/app.py index f3eeba4..eb1d1e8 100644 --- a/src/plyngent/cli/app.py +++ b/src/plyngent/cli/app.py @@ -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: diff --git a/src/plyngent/cli/editor.py b/src/plyngent/cli/editor.py index 97692aa..5b071be 100644 --- a/src/plyngent/cli/editor.py +++ b/src/plyngent/cli/editor.py @@ -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" diff --git a/src/plyngent/cli/limits.py b/src/plyngent/cli/limits.py index 02e373f..64585e8 100644 --- a/src/plyngent/cli/limits.py +++ b/src/plyngent/cli/limits.py @@ -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) diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 489b843..b0dcfa7 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -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: diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index ba11891..caa34b2 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -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): diff --git a/tests/test_cli/test_limits.py b/tests/test_cli/test_limits.py index 35bff6f..97607fb 100644 --- a/tests/test_cli/test_limits.py +++ b/tests/test_cli/test_limits.py @@ -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. diff --git a/tests/test_config/test_agent_section.py b/tests/test_config/test_agent_section.py index af1baae..06f7707 100644 --- a/tests/test_config/test_agent_section.py +++ b/tests/test_config/test_agent_section.py @@ -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/"]