diff --git a/src/plyngent/agent/tools.py b/src/plyngent/agent/tools.py index db2afe6..b82e6be 100644 --- a/src/plyngent/agent/tools.py +++ b/src/plyngent/agent/tools.py @@ -42,6 +42,7 @@ class ToolTag(Flag): YOLO = auto() # soft-confirm: eligible for YOLO auto-approve INSTANCE_STATE = auto() # needs instance-scoped state SESSION_STATE = auto() # needs session-scoped state + READ_ONLY = auto() # never mutates host state; safe for read-only contexts class ToolDefinition: @@ -266,14 +267,22 @@ class ToolRegistry: session_state: object | None = None, yolo: bool | None = None, auto_bind_state: bool | None = None, + read_only_only: bool = False, ) -> ToolRegistry: """New registry with the same tools/hooks and rebound host state. *instance_state* / *session_state* are applied as given (use the parent registry's handles only when the caller passes them through). + + With *read_only_only*, only :attr:`ToolTag.READ_ONLY` tools are kept + (e.g. for safe side turns / planning); hooks and policy flags are + preserved. """ + definitions = self.definitions() + if read_only_only: + definitions = [d for d in definitions if d.tags & ToolTag.READ_ONLY] return ToolRegistry( - self.definitions(), + definitions, danger=self._danger, on_confirm=self._on_confirm, yolo=self._yolo if yolo is None else yolo, diff --git a/src/plyngent/tools/chat/ask.py b/src/plyngent/tools/chat/ask.py index 70b5e24..aff2ff7 100644 --- a/src/plyngent/tools/chat/ask.py +++ b/src/plyngent/tools/chat/ask.py @@ -4,7 +4,7 @@ from plyngent.agent import ToolTag, tool from plyngent.prompting import NonInteractiveError, ask_async -@tool(name="ask_user_line", tags=ToolTag.LOCAL) +@tool(name="ask_user_line", tags=ToolTag.LOCAL | ToolTag.READ_ONLY) async def ask_user(question: str, default: str = "") -> str: """Ask the human a free-form one-line question and return their answer. diff --git a/src/plyngent/tools/chat/choose.py b/src/plyngent/tools/chat/choose.py index dd98a1f..2fa71cd 100644 --- a/src/plyngent/tools/chat/choose.py +++ b/src/plyngent/tools/chat/choose.py @@ -36,7 +36,7 @@ def parse_options(options: list[object]) -> list[ChoiceOption]: return out -@tool(name="ask_user_choice", tags=ToolTag.LOCAL) +@tool(name="ask_user_choice", tags=ToolTag.LOCAL | ToolTag.READ_ONLY) async def choose_user( question: str, options: list[str] | list[dict[str, object]], diff --git a/src/plyngent/tools/chat/form.py b/src/plyngent/tools/chat/form.py index 08895ef..79c53c9 100644 --- a/src/plyngent/tools/chat/form.py +++ b/src/plyngent/tools/chat/form.py @@ -46,7 +46,7 @@ def parse_fields(fields: list[dict[str, object]]) -> list[FormField]: return out -@tool(name="ask_user_form", tags=ToolTag.LOCAL) +@tool(name="ask_user_form", tags=ToolTag.LOCAL | ToolTag.READ_ONLY) async def form_user( title: str, fields: list[dict[str, object]], diff --git a/src/plyngent/tools/chat/wait.py b/src/plyngent/tools/chat/wait.py index 3d17c2a..f0fc0ea 100644 --- a/src/plyngent/tools/chat/wait.py +++ b/src/plyngent/tools/chat/wait.py @@ -8,7 +8,7 @@ from plyngent.prompting import get_prompt_backend, read_line_with_timeout, run_p WAIT_PROMPT = "Waiting for {duration}s. Press Enter to disturb. Feel free to ship any reason before pressing Enter. " -@tool(name="wait", tags=ToolTag.LOCAL) +@tool(name="wait", tags=ToolTag.LOCAL | ToolTag.READ_ONLY) async def wait(duration: int, *, reason: str | None = None) -> str: """Wait ``duration`` seconds before continuing. diff --git a/src/plyngent/tools/context.py b/src/plyngent/tools/context.py index 571bd38..037ff9f 100644 --- a/src/plyngent/tools/context.py +++ b/src/plyngent/tools/context.py @@ -137,6 +137,29 @@ def bind_session(state: SessionState | None) -> Generator[SessionState | None]: _session.reset(token) +_read_only: ContextVar[bool] = ContextVar("plyngent_read_only_context", default=False) + + +@contextmanager +def read_only_context() -> Generator[None]: + """Mark the current context as read-only for tool execution. + + Tools that may mutate depending on arguments (e.g. ``fetch`` with a + non-GET method) check :func:`is_read_only_context` and refuse to mutate + while it is set. + """ + token = _read_only.set(True) + try: + yield + finally: + _read_only.reset(token) + + +def is_read_only_context() -> bool: + """Whether tools currently run inside a read-only context.""" + return _read_only.get() + + @contextmanager def bind_tool_context( *, diff --git a/src/plyngent/tools/file/get_truncated.py b/src/plyngent/tools/file/get_truncated.py index 37bb59d..aab9203 100644 --- a/src/plyngent/tools/file/get_truncated.py +++ b/src/plyngent/tools/file/get_truncated.py @@ -15,7 +15,7 @@ from plyngent.tools.truncate_token import ( ) -@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) +@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY) async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS) -> str: """Fetch the next chunk of a truncated result using its ``truncate_token``. diff --git a/src/plyngent/tools/file/glob_paths.py b/src/plyngent/tools/file/glob_paths.py index f3172eb..2e7eeff 100644 --- a/src/plyngent/tools/file/glob_paths.py +++ b/src/plyngent/tools/file/glob_paths.py @@ -65,7 +65,7 @@ def _resolve_glob_base(path: str) -> tuple[Path, Path] | str: return root, base -@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) +@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY) async def glob_paths( pattern: str, path: str = ".", diff --git a/src/plyngent/tools/file/grep_files.py b/src/plyngent/tools/file/grep_files.py index f408e97..2842aa6 100644 --- a/src/plyngent/tools/file/grep_files.py +++ b/src/plyngent/tools/file/grep_files.py @@ -127,7 +127,7 @@ def _compile_pattern(pattern: str, *, case_insensitive: bool) -> re.Pattern[str] return f"error: invalid regex: {exc}" -@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) +@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY) async def grep_files( pattern: str, path: str = ".", diff --git a/src/plyngent/tools/file/listdir.py b/src/plyngent/tools/file/listdir.py index 5df3604..6e0054e 100644 --- a/src/plyngent/tools/file/listdir.py +++ b/src/plyngent/tools/file/listdir.py @@ -4,7 +4,7 @@ from plyngent.agent import ToolTag, tool from plyngent.tools.workspace import resolve_path -@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) +@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY) async def listdir(path: str = ".") -> str: """List entries in a directory under the workspace (name and type).""" target = resolve_path(path) diff --git a/src/plyngent/tools/file/read.py b/src/plyngent/tools/file/read.py index a8b75bf..f9e0c2c 100644 --- a/src/plyngent/tools/file/read.py +++ b/src/plyngent/tools/file/read.py @@ -49,7 +49,7 @@ def read_raw_text(path: str) -> tuple[str | None, str]: return target.read_text(encoding="utf-8", errors="replace"), "" -@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) +@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY) async def read_file( path: str, *, diff --git a/src/plyngent/tools/file/tree.py b/src/plyngent/tools/file/tree.py index 9eb4c67..6f9cba8 100644 --- a/src/plyngent/tools/file/tree.py +++ b/src/plyngent/tools/file/tree.py @@ -151,7 +151,7 @@ def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]: return frozenset(name for name in skip_dirs if name) -@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) +@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY) async def tree( path: str = ".", *, diff --git a/src/plyngent/tools/net/fetch.py b/src/plyngent/tools/net/fetch.py index 09fe65f..959cbbb 100644 --- a/src/plyngent/tools/net/fetch.py +++ b/src/plyngent/tools/net/fetch.py @@ -3,6 +3,7 @@ from __future__ import annotations from plyngent.agent import ToolTag, tool +from plyngent.tools.context import is_read_only_context from plyngent.tools.net.client import http_fetch from plyngent.tools.net.policy import ( DEFAULT_MAX_BODY_CHARS_IN, @@ -66,7 +67,7 @@ def format_fetch_result( return "\n".join(parts) -@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE) +@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE | ToolTag.READ_ONLY) async def fetch( url: str, *, @@ -92,9 +93,13 @@ async def fetch( Private/loopback/link-local hosts require an explicit human policy grant (not skipped by YOLO). HTTPS→HTTP redirects are blocked unless ``allow_http_downgrade`` is true. + + Read-only contexts (e.g. ``/btw --tools=read``) accept only ``GET``. """ try: verb = normalize_method(method) + if is_read_only_context() and verb != "GET": + return "error: fetch method must be GET in a read-only context (POST/PUT/DELETE mutate)" parsed = parse_fetch_url(url) hdrs = normalize_request_headers(headers, user_agent=user_agent) body_bytes: bytes | None = None diff --git a/src/plyngent/tools/todo.py b/src/plyngent/tools/todo.py index af416c5..a9a8c54 100644 --- a/src/plyngent/tools/todo.py +++ b/src/plyngent/tools/todo.py @@ -48,7 +48,7 @@ async def _with_todo_stack(mutator: Callable[[TodoStack], str]) -> str: return result -@tool(name="todo_list", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE) +@tool(name="todo_list", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE | ToolTag.READ_ONLY) async def todo_list() -> str: """Show the LIFO stack of **task groups** (TOP group = current breakdown level). diff --git a/src/plyngent/tools/vcs/tools.py b/src/plyngent/tools/vcs/tools.py index cdac5ed..c7f00ae 100644 --- a/src/plyngent/tools/vcs/tools.py +++ b/src/plyngent/tools/vcs/tools.py @@ -22,7 +22,7 @@ def _backend_or_error() -> VcsBackend | str: return backend -@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) +@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY) async def vcs_kind() -> str: """Return the detected VCS kind under the workspace (e.g. ``git``), or an error.""" backend = _backend_or_error() @@ -31,7 +31,7 @@ async def vcs_kind() -> str: return backend.kind -@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) +@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY) async def vcs_status() -> str: """Show working-tree status for the detected VCS (read-only).""" backend = _backend_or_error() @@ -40,7 +40,7 @@ async def vcs_status() -> str: return backend.status() -@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) +@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY) async def vcs_diff(path: str = "", *, staged: bool = False) -> str: """Show a unified diff for the detected VCS (read-only). @@ -54,7 +54,7 @@ async def vcs_diff(path: str = "", *, staged: bool = False) -> str: return backend.diff(staged=staged, path=rel) -@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) +@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY) async def vcs_log(limit: int = 10) -> str: """Show recent commits for the detected VCS (read-only).""" if limit < 1: @@ -65,7 +65,7 @@ async def vcs_log(limit: int = 10) -> str: return backend.log(limit=limit) -@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) +@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY) async def vcs_branch() -> str: """Show the current branch / named head for the detected VCS (read-only).""" backend = _backend_or_error() diff --git a/tests/test_tools/test_catalog.py b/tests/test_tools/test_catalog.py index 8f7bda0..939327b 100644 --- a/tests/test_tools/test_catalog.py +++ b/tests/test_tools/test_catalog.py @@ -4,7 +4,7 @@ from __future__ import annotations import inspect -from plyngent.agent import ToolTag, tool +from plyngent.agent import ToolRegistry, ToolTag, tool from plyngent.tools import default_tool_definitions, register_builtin_tools from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source from plyngent.tools.chat import CHAT_TOOLS @@ -42,6 +42,45 @@ def test_public_surface_is_subset() -> None: assert "read_file" not in public +def test_read_only_classification() -> None: + """READ_ONLY marks exactly the non-mutating builtins.""" + register_builtin_tools() + read_only = {t.name for t in default_tool_definitions(surface="local") if t.tags & ToolTag.READ_ONLY} + assert read_only == { + "read_file", + "get_truncated", + "glob_paths", + "grep_files", + "listdir", + "tree", + "todo_list", + "wait", + "ask_user_line", + "ask_user_choice", + "ask_user_form", + "vcs_kind", + "vcs_status", + "vcs_diff", + "vcs_log", + "vcs_branch", + "fetch", + } + + +def test_registry_clone_read_only_only() -> None: + """clone(read_only_only=True) keeps only READ_ONLY tools, nothing else.""" + register_builtin_tools() + full = ToolRegistry(default_tool_definitions(surface="local")) + ro = full.clone(read_only_only=True) + assert 0 < len(ro) < len(full) + assert {t.name for t in ro.definitions()} == {t.name for t in full.definitions() if t.tags & ToolTag.READ_ONLY} + assert all(t.tags & ToolTag.READ_ONLY for t in ro.definitions()) + assert "run_argv" not in ro + assert "edit_replace" not in ro + # Plain clone keeps every tool. + assert len(full.clone()) == len(full) + + def test_catalog_scope_empty_isolates_registration() -> None: with catalog_scope(empty=True) as catalog: diff --git a/tests/test_tools/test_fetch.py b/tests/test_tools/test_fetch.py index dbedc08..f4e88aa 100644 --- a/tests/test_tools/test_fetch.py +++ b/tests/test_tools/test_fetch.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, override import pytest +from plyngent.tools.context import read_only_context from plyngent.tools.danger import classify_danger from plyngent.tools.net import fetch, grant_private_host from plyngent.tools.net.policy import ( @@ -199,6 +200,23 @@ async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> N assert "status=204" in out_del +async def test_fetch_read_only_context_blocks_non_get(workspace: Path, http_server: str) -> None: + """In a read-only context fetch only allows GET; outside it POST works.""" + del workspace + port = int(http_server.rsplit(":", 1)[1]) + grant_private_host("127.0.0.1", port) + + with read_only_context(): + out = await call_async(fetch, f"{http_server}/hello") + assert "status=200" in out # GET is fine in a read-only context + blocked = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi") + assert blocked.startswith("error:") + assert "GET" in blocked + + out_post = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi") + assert "POST:hi" in out_post + + async def test_fetch_user_agent_passthrough(workspace: Path, http_server: str) -> None: del workspace port = int(http_server.rsplit(":", 1)[1])