mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-02 21:12:21 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""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(" ") == ""
|
||||
Reference in New Issue
Block a user