From 7c37f3a0788ce3639f80e93eca71bcf0c79234d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 05:24:26 +0000 Subject: [PATCH] fix(policies): gate MultiEdit and NotebookEdit in write policies Built-in ask_on_os_tools, read_only_os, and worktree_guard each maintained their own incomplete Claude Code write-tool name sets. MultiEdit bypassed approval and NotebookEdit bypassed read-only enforcement; worktree_guard also missed notebook_path when resolving targets. Introduce omnigent.policies.native_tools as the single source of truth for CLAUDE_NATIVE_EDIT_TOOLS and shared path extraction (path/file_path/ notebook_path). Wire all three policies to it and re-export from server session common constants. Co-authored-by: jan21deepak --- omnigent/policies/builtins/orchestration.py | 43 +++++++++------------ omnigent/policies/builtins/safety.py | 14 +++---- omnigent/policies/native_tools.py | 26 +++++++++++++ omnigent/server/routes/_sessions/common.py | 6 +-- tests/inner/nessie/test_policies.py | 8 ++++ tests/policies/builtins/test_safety.py | 4 +- 6 files changed, 63 insertions(+), 38 deletions(-) create mode 100644 omnigent/policies/native_tools.py diff --git a/omnigent/policies/builtins/orchestration.py b/omnigent/policies/builtins/orchestration.py index 228ea719aa..6db510fef2 100644 --- a/omnigent/policies/builtins/orchestration.py +++ b/omnigent/policies/builtins/orchestration.py @@ -15,6 +15,8 @@ from collections.abc import Callable, Collection from typing import Any, TypeAlias +from omnigent.policies.native_tools import CLAUDE_NATIVE_EDIT_TOOLS, claude_native_write_path + # Heterogeneous JSON-shaped maps — the V0 policy event + decision payloads. _Json: TypeAlias = dict[str, Any] # type: ignore[explicit-any] @@ -552,12 +554,12 @@ def worktree_guard( :returns: An evaluator ``fn(event, config)`` returning a V0 decision. """ - # Match Omnigent built-in OS write/edit, Claude/Codex native Write/Edit - # (surfaced via the PreToolUse hook), and Pi's native lowercase - # write/edit (surfaced via the pi ``tool_call`` hook). Pi uses the same - # ``path`` argument key as the Omnigent tools, so no Pi-specific arg - # branch is needed below. - _write_tools = {"sys_os_write", "sys_os_edit", "Write", "Edit", "MultiEdit", "write", "edit"} + # Match Omnigent built-in OS write/edit, Claude/Codex native write/edit tools + # (surfaced via the PreToolUse hook), and Pi's native lowercase write/edit + # (surfaced via the pi ``tool_call`` hook). Pi uses the same ``path`` + # argument key as the Omnigent tools, so no Pi-specific arg branch is needed + # below. + _write_tools = {"sys_os_write", "sys_os_edit", "write", "edit"} | CLAUDE_NATIVE_EDIT_TOOLS def _evaluate(event: _Json, config: _Json) -> _Json: # noqa: ARG001 """ @@ -571,9 +573,8 @@ def _evaluate(event: _Json, config: _Json) -> _Json: # noqa: ARG001 args = _tool_call(event, _write_tools) if args is None: return _ALLOW - # Omnigent tools use ``path``; Claude native tools use ``file_path``. - path = args.get("path") or args.get("file_path") - if not isinstance(path, str): + path = claude_native_write_path(args) + if not path: return _ALLOW # Backslashes are not valid in POSIX paths and could confuse # downstream processing into treating them as separators, slipping a @@ -602,7 +603,7 @@ def read_only_os( Factory: deny every file-mutating tool call (report-only agents). DENIES ``sys_os_write`` / ``sys_os_edit`` and the Claude/Codex/Pi native - ``Write`` / ``Edit`` / ``MultiEdit`` aliases. Reads, searches, and shell + ``Write`` / ``Edit`` / ``MultiEdit`` / ``NotebookEdit`` aliases. Reads, searches, and shell commands are left untouched — pair with :func:`blast_radius` to also bound shell blast radius. Use on agents whose contract is to investigate and report, never to change code (e.g. a security reviewer and its read-only @@ -614,18 +615,10 @@ def read_only_os( write/edit tool call, ALLOW otherwise. """ - # Match Omnigent built-in OS write/edit, Claude/Codex native Write/Edit/ - # MultiEdit, and Pi's native lowercase write/edit — the same tool set - # worktree_guard gates, so the two write policies stay in lockstep. - write_tools = { - "sys_os_write", - "sys_os_edit", - "Write", - "Edit", - "MultiEdit", - "write", - "edit", - } + # Match Omnigent built-in OS write/edit, Claude/Codex native write/edit tools, + # and Pi's native lowercase write/edit — the same tool set worktree_guard gates, + # so the two write policies stay in lockstep. + write_tools = {"sys_os_write", "sys_os_edit", "write", "edit"} | CLAUDE_NATIVE_EDIT_TOOLS def _evaluate(event: _Json, config: _Json) -> _Json: # noqa: ARG001 """ @@ -700,7 +693,8 @@ def _evaluate(event: _Json, config: _Json) -> _Json: # noqa: ARG001 "kind": "factory", "name": "Restrict Writes to Git Worktree", "description": "Blocks file writes (sys_os_write/edit, Claude/Codex native " - "Write/Edit, and Pi native write/edit) outside the worker's git worktree to " + "Write/Edit/MultiEdit/NotebookEdit, and Pi native write/edit) " + "outside the worker's git worktree to " "prevent cross-branch contamination", }, { @@ -708,7 +702,8 @@ def _evaluate(event: _Json, config: _Json) -> _Json: # noqa: ARG001 "kind": "factory", "name": "Report-Only (Deny File Writes)", "description": "Denies every file-mutating tool (sys_os_write/edit, Claude/Codex " - "native Write/Edit/MultiEdit, and Pi native write/edit) so a report-only agent " + "native Write/Edit/MultiEdit/NotebookEdit, and Pi native write/edit) " + "so a report-only agent " "can read and run shell but never change code", }, ] diff --git a/omnigent/policies/builtins/safety.py b/omnigent/policies/builtins/safety.py index 9e0f8ae20b..326b91ee38 100644 --- a/omnigent/policies/builtins/safety.py +++ b/omnigent/policies/builtins/safety.py @@ -13,6 +13,7 @@ import re as _re from typing import Literal +from omnigent.policies.native_tools import CLAUDE_NATIVE_EDIT_TOOLS, claude_native_write_path from omnigent.policies.schema import ( PolicyCallable, PolicyEvent, @@ -29,7 +30,7 @@ # PostToolUse hook contract (see ``omnigent.native_policy_hook``). # These bypass Omnigent' ``sys_os_*`` MCP tools and execute directly # inside the CLI subprocess. -_NATIVE_OS_TOOLS = frozenset({"Bash", "Read", "Write", "Edit", "Glob", "Grep"}) +_NATIVE_OS_TOOLS = frozenset({"Bash", "Read", "Glob", "Grep"}) | CLAUDE_NATIVE_EDIT_TOOLS # Cursor SDK native tool names surfaced via the preToolUse hook # (see ``omnigent.inner.cursor_policy_hook``). Cursor uses ``Shell`` @@ -222,8 +223,8 @@ def ask_on_os_tools(event: PolicyEvent) -> PolicyResponse: - **Omnigent built-in OS tools** (``sys_os_read``, ``sys_os_write``, ``sys_os_edit``, ``sys_os_shell``). - **Claude Code native tools** (``Bash``, ``Read``, ``Write``, - ``Edit``, ``Glob``, ``Grep``) — surfaced via the - ``PreToolUse`` hook contract. + ``Edit``, ``MultiEdit``, ``NotebookEdit``, ``Glob``, ``Grep``) + — surfaced via the ``PreToolUse`` hook contract. - **Codex native tools** — uses the same ``PreToolUse`` hook contract with the same tool names (e.g. ``Bash``). - **Cursor SDK native tools** (``Shell``) — surfaced via the @@ -272,10 +273,7 @@ def ask_on_os_tools(event: PolicyEvent) -> PolicyResponse: elif tool == "execute_code": preview = args.get("code", "")[:80] if isinstance(args, dict) else "" else: - # Omnigent tools use ``path``; Claude native tools use ``file_path``. - preview = ( - (args.get("path") or args.get("file_path", "")) if isinstance(args, dict) else "" - ) + preview = claude_native_write_path(args) if isinstance(args, dict) else "" return { "result": "ASK", "reason": f"Agent wants to call {tool}({preview!r}). Approve?", @@ -706,7 +704,7 @@ def _scan_text(text: str) -> PolicyResponse: "name": "Require Approval for File & Shell Operations", "description": "Asks for user approval before any file or shell tool call — " "covers Omnigent sys_os_* tools, Claude Code native tools " - "(Bash, Read, Write, Edit, Glob, Grep), Codex native tools, " + "(Bash, Read, Write, Edit, MultiEdit, NotebookEdit, Glob, Grep), Codex native tools, " "opencode native tools (bash, edit, read, grep, glob), " "and Hermes Agent tools (terminal, execute_code, read_file, write_file, search_files)", "params_schema": None, diff --git a/omnigent/policies/native_tools.py b/omnigent/policies/native_tools.py new file mode 100644 index 0000000000..54cbd5264f --- /dev/null +++ b/omnigent/policies/native_tools.py @@ -0,0 +1,26 @@ +"""Canonical native harness tool names shared by built-in write policies.""" + +from __future__ import annotations + +from typing import Any + +CLAUDE_NATIVE_EDIT_TOOLS: frozenset[str] = frozenset( + {"Edit", "Write", "MultiEdit", "NotebookEdit"} +) + +# Argument keys Claude Code uses for file-targeting native write/edit tools. +_CLAUDE_NATIVE_PATH_KEYS: tuple[str, ...] = ("path", "file_path", "notebook_path") + + +def claude_native_write_path(args: dict[str, Any]) -> str: + """Return the target path from Claude native write/edit tool arguments. + + Omnigent ``sys_os_*`` tools use ``path``; Claude Code native tools use + ``file_path`` (``Write`` / ``Edit`` / ``MultiEdit``) or ``notebook_path`` + (``NotebookEdit``). + """ + for key in _CLAUDE_NATIVE_PATH_KEYS: + value = args.get(key) + if isinstance(value, str): + return value + return "" diff --git a/omnigent/server/routes/_sessions/common.py b/omnigent/server/routes/_sessions/common.py index d61631b988..78cfad69bb 100644 --- a/omnigent/server/routes/_sessions/common.py +++ b/omnigent/server/routes/_sessions/common.py @@ -32,6 +32,7 @@ PI_NATIVE_CODING_AGENT, harness_capabilities, ) +from omnigent.policies.native_tools import CLAUDE_NATIVE_EDIT_TOOLS as _CLAUDE_NATIVE_EDIT_TOOLS from omnigent.runner.routing import RunnerRouter from omnigent.server.host_registry import HostRegistry from omnigent.server.schemas import ( @@ -294,11 +295,6 @@ } -_CLAUDE_NATIVE_EDIT_TOOLS: frozenset[str] = frozenset( - {"Edit", "Write", "MultiEdit", "NotebookEdit"} -) - - _CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS: frozenset[str] = frozenset( {"ExitPlanMode", "AskUserQuestion"} ) diff --git a/tests/inner/nessie/test_policies.py b/tests/inner/nessie/test_policies.py index d5330b7a3e..0f9b9f56ab 100644 --- a/tests/inner/nessie/test_policies.py +++ b/tests/inner/nessie/test_policies.py @@ -439,6 +439,10 @@ def test_worktree_guard_blocks_escapes(path: str, expected: str) -> None: ("MultiEdit", "file_path", "src/app.py", "ALLOW"), ("MultiEdit", "file_path", "/etc/passwd", "DENY"), ("MultiEdit", "file_path", "../escape.py", "DENY"), + # Claude native NotebookEdit uses ``notebook_path``. + ("NotebookEdit", "notebook_path", "notebooks/run.ipynb", "ALLOW"), + ("NotebookEdit", "notebook_path", "/etc/x.ipynb", "DENY"), + ("NotebookEdit", "notebook_path", "../escape.ipynb", "DENY"), # Pi native write/edit (lowercase) use ``path`` (Omnigent convention). ("write", "path", "src/app.py", "ALLOW"), ("write", "path", "/etc/passwd", "DENY"), @@ -453,6 +457,9 @@ def test_worktree_guard_blocks_escapes(path: str, expected: str) -> None: "MultiEdit-in-tree", "MultiEdit-absolute", "MultiEdit-escape", + "NotebookEdit-in-tree", + "NotebookEdit-absolute", + "NotebookEdit-escape", "pi-write-in-tree", "pi-write-absolute", "pi-edit-escape", @@ -502,6 +509,7 @@ def test_worktree_guard_only_guards_writes() -> None: ("Write", {"file_path": "a.py", "content": "x"}), ("Edit", {"file_path": "a.py", "old_string": "x", "new_string": "y"}), ("MultiEdit", {"file_path": "a.py", "edits": []}), + ("NotebookEdit", {"notebook_path": "a.ipynb", "cell_id": "c1", "new_source": "x"}), # Pi native lowercase. ("write", {"path": "a.py", "content": "x"}), ("edit", {"path": "a.py"}), diff --git a/tests/policies/builtins/test_safety.py b/tests/policies/builtins/test_safety.py index 15e550106a..75bf26dd8f 100644 --- a/tests/policies/builtins/test_safety.py +++ b/tests/policies/builtins/test_safety.py @@ -54,10 +54,12 @@ def test_ask_on_os_tools_asks_for_sys_os_tools(tool: str) -> None: ("Read", {"path": "/etc/passwd"}, "/etc/passwd"), ("Write", {"path": "/tmp/out.txt"}, "/tmp/out.txt"), ("Edit", {"path": "main.py"}, "main.py"), + ("MultiEdit", {"file_path": "main.py", "edits": []}, "main.py"), + ("NotebookEdit", {"notebook_path": "analysis.ipynb"}, "analysis.ipynb"), ("Glob", {"pattern": "**/*.py"}, "**/*.py"), ("Grep", {"pattern": "secret"}, "secret"), ], - ids=["Bash", "Read", "Write", "Edit", "Glob", "Grep"], + ids=["Bash", "Read", "Write", "Edit", "MultiEdit", "NotebookEdit", "Glob", "Grep"], ) def test_ask_on_os_tools_asks_for_native_tools( tool: str,