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`.
|
- **`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()`.
|
- **`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**.
|
- 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/`)
|
### Tools (`tools/`)
|
||||||
|
|
||||||
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
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`.
|
- **`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`).
|
- **`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.
|
- 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.
|
- 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`.
|
- **`DEFAULT_TOOLS`**: file + process tool list for a `ToolRegistry`.
|
||||||
|
|
||||||
### CLI (`cli/`)
|
### CLI (`cli/`)
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ async def _run_chat(
|
|||||||
raise click.ClickException(str(exc)) from exc
|
raise click.ClickException(str(exc)) from exc
|
||||||
|
|
||||||
_ = set_workspace_root(workspace)
|
_ = 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()
|
install_cli_limit_hooks()
|
||||||
memory = await MemoryStore.open(_database_config(store))
|
memory = await MemoryStore.open(_database_config(store))
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ _MINIMAL_CONFIG = """\
|
|||||||
# system_prompt = "You are a careful coding assistant."
|
# system_prompt = "You are a careful coding assistant."
|
||||||
# max_tool_result_chars = 32000
|
# max_tool_result_chars = 32000
|
||||||
# parallel_tools = true
|
# parallel_tools = true
|
||||||
|
# confirm_destructive = true
|
||||||
|
# path_denylist = ["/secrets/", ".ssh/"]
|
||||||
|
|
||||||
# [providers.example]
|
# [providers.example]
|
||||||
# preset = "openai-compatible"
|
# preset = "openai-compatible"
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from plyngent.tools.process.pty_session import PtyManager
|
from plyngent.tools.process.pty_session import PtyManager
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
|
||||||
def prompt_continue_limit(reason: str) -> bool:
|
def prompt_continue_limit(reason: str) -> bool:
|
||||||
"""Ask the user whether to raise a limit and continue (TTY)."""
|
"""Ask the user whether to raise a limit and continue (TTY)."""
|
||||||
@@ -15,6 +20,17 @@ def prompt_continue_limit(reason: str) -> bool:
|
|||||||
return False
|
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:
|
def install_cli_limit_hooks() -> None:
|
||||||
"""Register interactive continue hooks for process-global tool limits."""
|
"""Register interactive continue hooks for process-global tool limits."""
|
||||||
PtyManager.set_limit_continue_hook(prompt_continue_limit)
|
PtyManager.set_limit_continue_hook(prompt_continue_limit)
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ class ReplState:
|
|||||||
def _tool_registry(self) -> ToolRegistry | None:
|
def _tool_registry(self) -> ToolRegistry | None:
|
||||||
if not self.tools_enabled:
|
if not self.tools_enabled:
|
||||||
return None
|
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))
|
return ToolRegistry(list(DEFAULT_TOOLS))
|
||||||
|
|
||||||
def _make_agent(self) -> ChatAgent:
|
def _make_agent(self) -> ChatAgent:
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class AgentConfig(Struct, omit_defaults=True):
|
|||||||
system_prompt: str = ""
|
system_prompt: str = ""
|
||||||
max_tool_result_chars: int = 32_000
|
max_tool_result_chars: int = 32_000
|
||||||
parallel_tools: bool = True
|
parallel_tools: bool = True
|
||||||
|
confirm_destructive: bool = True
|
||||||
|
path_denylist: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ModelConfig(Struct, omit_defaults=True):
|
class ModelConfig(Struct, omit_defaults=True):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
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
|
from plyngent.tools.process.pty_session import PtyManager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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
|
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:
|
def test_install_cli_limit_hooks() -> None:
|
||||||
install_cli_limit_hooks()
|
install_cli_limit_hooks()
|
||||||
# Hook is installed process-wide for the CLI session.
|
# 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.system_prompt == ""
|
||||||
assert store.agent_config.max_tool_result_chars == 32_000 # noqa: PLR2004
|
assert store.agent_config.max_tool_result_chars == 32_000 # noqa: PLR2004
|
||||||
assert store.agent_config.parallel_tools is True
|
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:
|
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."
|
system_prompt = "Be brief."
|
||||||
max_tool_result_chars = 100
|
max_tool_result_chars = 100
|
||||||
parallel_tools = false
|
parallel_tools = false
|
||||||
|
confirm_destructive = false
|
||||||
|
path_denylist = ["/secrets/", ".ssh/"]
|
||||||
""",
|
""",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
@@ -32,3 +36,5 @@ parallel_tools = false
|
|||||||
assert store.agent_config.system_prompt == "Be brief."
|
assert store.agent_config.system_prompt == "Be brief."
|
||||||
assert store.agent_config.max_tool_result_chars == 100 # noqa: PLR2004
|
assert store.agent_config.max_tool_result_chars == 100 # noqa: PLR2004
|
||||||
assert store.agent_config.parallel_tools is False
|
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