mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-13 19:52:20 +02:00
105 lines
4.3 KiB
Python
105 lines
4.3 KiB
Python
"""Plan mode gating regression tests.
|
|
|
|
Plan mode restricts the agent to read-only/inspection tools so it can investigate
|
|
and propose a plan without mutating anything. These pin the security-relevant
|
|
contract:
|
|
|
|
- The read-only allowlist contains only inspection tools (no writes/sends/manage_*).
|
|
- `plan_mode_disabled_tools()` blocks every mutating tool and never blocks an
|
|
allowlisted one.
|
|
- It fails CLOSED: if the tool-schema list can't be loaded, it still blocks a
|
|
known-mutating set rather than returning nothing (which would allow mutations).
|
|
|
|
Pure-function tests — no FastAPI app boot, no DB.
|
|
"""
|
|
|
|
from src.tool_security import (
|
|
PLAN_MODE_READONLY_TOOLS,
|
|
_PLAN_MODE_KNOWN_MUTATORS,
|
|
plan_mode_disabled_tools,
|
|
)
|
|
|
|
|
|
def test_allowlist_has_no_obvious_mutating_tools():
|
|
# Sanity: the read-only allowlist must not contain mutating/external tools.
|
|
mutating_markers = ("write_", "send_", "manage_", "create_", "edit_", "delete_")
|
|
for name in PLAN_MODE_READONLY_TOOLS:
|
|
assert not name.startswith(mutating_markers), f"{name} should not be read-only"
|
|
|
|
|
|
def test_plan_mode_blocks_mutating_tools():
|
|
disabled = plan_mode_disabled_tools()
|
|
# A representative spread of mutating/external tools must be blocked.
|
|
for name in (
|
|
"write_file", "send_email", "reply_to_email", "manage_memory",
|
|
"manage_settings", "create_document", "edit_document", "download_model",
|
|
"generate_image", "trigger_research",
|
|
):
|
|
assert name in disabled, f"{name} must be blocked in plan mode"
|
|
|
|
|
|
def test_plan_mode_allows_readonly_tools():
|
|
disabled = plan_mode_disabled_tools()
|
|
# Read-only investigation tools stay enabled, including the discovery tools
|
|
# (grep/glob/ls) that replace freestyle shell.
|
|
for name in ("read_file", "grep", "glob", "ls", "web_search", "web_fetch", "search_chats"):
|
|
assert name not in disabled, f"{name} should be usable in plan mode"
|
|
|
|
|
|
def test_plan_mode_blocks_shell():
|
|
# bash/python can mutate and can't be constrained read-only, so plan mode
|
|
# must block them (the whole point of dropping shell from plan mode).
|
|
disabled = plan_mode_disabled_tools()
|
|
for name in ("bash", "python"):
|
|
assert name in disabled, f"{name} must be blocked in plan mode"
|
|
|
|
|
|
def test_disabled_never_intersects_allowlist():
|
|
assert plan_mode_disabled_tools() & PLAN_MODE_READONLY_TOOLS == set()
|
|
|
|
|
|
def test_mcp_readonly_classification():
|
|
from src.mcp_manager import mcp_tool_is_readonly as ro
|
|
# Server-provided hints win over the name heuristic.
|
|
assert ro({"name": "zap", "annotations": {"readOnlyHint": True}}) is True
|
|
assert ro({"name": "list_things", "annotations": {"readOnlyHint": False}}) is False
|
|
assert ro({"name": "get_x", "annotations": {"destructiveHint": True}}) is False
|
|
# No hint → leading-verb heuristic, fail closed for ambiguous names.
|
|
assert ro({"name": "list_files"}) is True
|
|
assert ro({"name": "search_docs"}) is True
|
|
assert ro({"name": "send_message"}) is False
|
|
assert ro({"name": "frobnicate"}) is False
|
|
|
|
|
|
def test_fail_closed_fallback_blocks_mutations(monkeypatch):
|
|
# If the schema list can't load, we must still block (fail closed), not
|
|
# return an empty set that would silently allow every mutating tool.
|
|
import src.tool_security as ts
|
|
|
|
def _boom():
|
|
raise ImportError("simulated circular import failure")
|
|
|
|
# Force the dynamic path to fail by making the lazy import explode.
|
|
monkeypatch.setitem(
|
|
__import__("sys").modules, "src.agent_tools", None
|
|
)
|
|
disabled = ts.plan_mode_disabled_tools()
|
|
assert disabled, "plan mode must never fail open (empty disabled set)"
|
|
assert "write_file" in disabled
|
|
assert "send_email" in disabled
|
|
assert disabled == set(_PLAN_MODE_KNOWN_MUTATORS)
|
|
|
|
|
|
def test_active_plan_note_pins_checklist():
|
|
"""The approved-plan note re-grounds execution so a long plan survives
|
|
history truncation (the agent can always re-read it)."""
|
|
from src.agent_loop import build_active_plan_note
|
|
plan = "- [ ] step one\n- [ ] step two"
|
|
note = build_active_plan_note(plan)
|
|
assert "ACTIVE PLAN" in note
|
|
assert plan in note # the actual checklist is embedded
|
|
assert "IN ORDER" in note # execution guidance present
|
|
# Empty input → no note (so we never inject a blank pin).
|
|
assert build_active_plan_note("") == ""
|
|
assert build_active_plan_note(" ") == ""
|