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:
2026-07-14 23:30:17 +08:00
parent 35e89c4dcc
commit bcfd1098f8
8 changed files with 56 additions and 3 deletions
+3 -2
View File
@@ -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/`)
+3
View File
@@ -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:
+2
View File
@@ -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"
+16
View File
@@ -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)
+10
View File
@@ -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:
+2
View File
@@ -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):
+14 -1
View File
@@ -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.
+6
View File
@@ -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/"]