Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 19 additions & 24 deletions omnigent/policies/builtins/orchestration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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
"""
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
"""
Expand Down Expand Up @@ -700,15 +693,17 @@ 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",
},
{
"handler": "omnigent.policies.builtins.orchestration.read_only_os",
"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",
},
]
14 changes: 6 additions & 8 deletions omnigent/policies/builtins/safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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``
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?",
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions omnigent/policies/native_tools.py
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty path blocks alternate keys

Medium Severity

claude_native_write_path returns the first string value among path, file_path, and notebook_path, including empty strings. That differs from the prior path or file_path logic. When path is "" but file_path or notebook_path holds the real target, worktree_guard treats the call as having no path and ALLOWs instead of validating an escape.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7c37f3a. Configure here.

return ""
6 changes: 1 addition & 5 deletions omnigent/server/routes/_sessions/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"}
)
Expand Down
8 changes: 8 additions & 0 deletions tests/inner/nessie/test_policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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",
Expand Down Expand Up @@ -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"}),
Expand Down
4 changes: 3 additions & 1 deletion tests/policies/builtins/test_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down