mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 18:22:20 +02:00
593 lines
18 KiB
Python
593 lines
18 KiB
Python
"""Deterministic capability metadata for agent tools.
|
|
|
|
Model output requests an action; it never supplies the authority for that
|
|
action. This module classifies the effects of each built-in tool and applies
|
|
run-local integrity gates before dispatch.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from types import MappingProxyType
|
|
from typing import Any, Iterable, Mapping
|
|
|
|
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
|
|
|
|
|
class ToolEffect(str, Enum):
|
|
READ_PUBLIC = "read_public"
|
|
READ_WORKSPACE = "read_workspace"
|
|
READ_PRIVATE = "read_private"
|
|
WRITE_WORKSPACE = "write_workspace"
|
|
WRITE_PRIVATE = "write_private"
|
|
EXECUTE_CODE = "execute_code"
|
|
BROKERED_NETWORK_READ = "brokered_network_read"
|
|
NETWORK_EGRESS = "network_egress"
|
|
EXTERNAL_SIDE_EFFECT = "external_side_effect"
|
|
UI_SIDE_EFFECT = "ui_side_effect"
|
|
ADMIN_CHANGE = "admin_change"
|
|
DESTRUCTIVE = "destructive"
|
|
USER_INTERACTION = "user_interaction"
|
|
|
|
|
|
class ResultIntegrity(str, Enum):
|
|
SYSTEM = "system"
|
|
WORKSPACE_UNTRUSTED = "workspace_untrusted"
|
|
EXTERNAL_UNTRUSTED = "external_untrusted"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ToolCapabilities:
|
|
effects: frozenset[ToolEffect]
|
|
result_integrity: ResultIntegrity = ResultIntegrity.SYSTEM
|
|
known: bool = True
|
|
|
|
|
|
def _capabilities(
|
|
*effects: ToolEffect,
|
|
result_integrity: ResultIntegrity = ResultIntegrity.SYSTEM,
|
|
) -> ToolCapabilities:
|
|
return ToolCapabilities(frozenset(effects), result_integrity)
|
|
|
|
|
|
_REGISTRY: dict[str, ToolCapabilities] = {}
|
|
|
|
|
|
def _register(
|
|
names: Iterable[str],
|
|
*effects: ToolEffect,
|
|
result_integrity: ResultIntegrity = ResultIntegrity.SYSTEM,
|
|
) -> None:
|
|
capabilities = _capabilities(*effects, result_integrity=result_integrity)
|
|
for name in names:
|
|
if name in _REGISTRY:
|
|
raise RuntimeError(f"Duplicate tool capability classification: {name}")
|
|
_REGISTRY[name] = capabilities
|
|
|
|
|
|
_register(
|
|
{"ask_user", "update_plan"},
|
|
ToolEffect.USER_INTERACTION,
|
|
)
|
|
_register(
|
|
{
|
|
"list_cached_models",
|
|
"list_cookbook_servers",
|
|
"list_downloads",
|
|
"list_models",
|
|
"list_serve_presets",
|
|
"list_served_models",
|
|
},
|
|
ToolEffect.READ_PRIVATE,
|
|
# These readers return provider-controlled model identifiers or durable
|
|
# user/admin-authored Cookbook and process state. Local brokering does not
|
|
# make the returned text server-authored.
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
_register(
|
|
{"search_hf_models"},
|
|
ToolEffect.BROKERED_NETWORK_READ,
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
_register(
|
|
{"get_workspace", "glob", "grep", "ls", "read_file"},
|
|
ToolEffect.READ_WORKSPACE,
|
|
result_integrity=ResultIntegrity.WORKSPACE_UNTRUSTED,
|
|
)
|
|
_register(
|
|
{"web_search"},
|
|
ToolEffect.BROKERED_NETWORK_READ,
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
_register(
|
|
{"web_fetch"},
|
|
ToolEffect.BROKERED_NETWORK_READ,
|
|
ToolEffect.NETWORK_EGRESS,
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
_register(
|
|
{
|
|
"list_email_accounts",
|
|
"list_emails",
|
|
"read_email",
|
|
"resolve_contact",
|
|
"scan_email_unsubscribes",
|
|
"search_chats",
|
|
"search_emails",
|
|
"list_sessions",
|
|
"tail_serve_output",
|
|
"vault_get",
|
|
"vault_search",
|
|
},
|
|
ToolEffect.READ_PRIVATE,
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
_register(
|
|
{"bash", "manage_bg_jobs", "python"},
|
|
ToolEffect.EXECUTE_CODE,
|
|
result_integrity=ResultIntegrity.WORKSPACE_UNTRUSTED,
|
|
)
|
|
_register(
|
|
{"apply_patch", "edit_file", "write_file"},
|
|
ToolEffect.WRITE_WORKSPACE,
|
|
)
|
|
_register(
|
|
{
|
|
"ai_draft_email_reply",
|
|
"create_document",
|
|
"create_session",
|
|
"draft_email",
|
|
"draft_email_reply",
|
|
"manage_calendar",
|
|
"manage_contact",
|
|
"manage_documents",
|
|
"manage_memory",
|
|
"manage_notes",
|
|
"manage_research",
|
|
"manage_session",
|
|
"manage_skills",
|
|
"manage_tasks",
|
|
"suggest_document",
|
|
"todowrite",
|
|
},
|
|
ToolEffect.WRITE_PRIVATE,
|
|
)
|
|
_register(
|
|
{"edit_document", "update_document"},
|
|
ToolEffect.WRITE_PRIVATE,
|
|
# These tools can echo stored document content that was not present in
|
|
# their arguments. edit_document returns the complete edited document;
|
|
# update_document also preserves stored email headers/thread history.
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
_register(
|
|
{"pipeline"},
|
|
ToolEffect.NETWORK_EGRESS,
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
_register(
|
|
{"send_to_session"},
|
|
ToolEffect.NETWORK_EGRESS,
|
|
ToolEffect.WRITE_PRIVATE,
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
_register(
|
|
{"chat_with_model", "ask_teacher"},
|
|
ToolEffect.NETWORK_EGRESS,
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
_register(
|
|
{"download_attachment"},
|
|
ToolEffect.READ_PRIVATE,
|
|
ToolEffect.WRITE_WORKSPACE,
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
_register(
|
|
{"edit_image", "generate_image", "trigger_research"},
|
|
ToolEffect.NETWORK_EGRESS,
|
|
ToolEffect.WRITE_PRIVATE,
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
_register(
|
|
{
|
|
"archive_email",
|
|
"bulk_email",
|
|
"mark_email_read",
|
|
"reply_to_email",
|
|
"send_email",
|
|
"unsubscribe_email",
|
|
},
|
|
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
|
)
|
|
_register(
|
|
{"delete_email"},
|
|
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
|
ToolEffect.DESTRUCTIVE,
|
|
)
|
|
_register(
|
|
{"ui_control"},
|
|
ToolEffect.UI_SIDE_EFFECT,
|
|
)
|
|
_register(
|
|
{
|
|
"adopt_served_model",
|
|
"cancel_download",
|
|
"download_model",
|
|
"serve_model",
|
|
"serve_preset",
|
|
"stop_served_model",
|
|
"vault_unlock",
|
|
},
|
|
ToolEffect.ADMIN_CHANGE,
|
|
)
|
|
_register(
|
|
{
|
|
"api_call",
|
|
"app_api",
|
|
"manage_endpoints",
|
|
"manage_mcp",
|
|
"manage_settings",
|
|
"manage_tokens",
|
|
"manage_webhooks",
|
|
},
|
|
ToolEffect.ADMIN_CHANGE,
|
|
# api_call/app_api return remote or stored application data, and the
|
|
# admin managers can echo user-controlled configuration. Conservatively
|
|
# retain the action effect while treating every successful result as data.
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
|
|
|
|
TOOL_CAPABILITIES: Mapping[str, ToolCapabilities] = MappingProxyType(dict(_REGISTRY))
|
|
KNOWN_CAPABILITY_TOOLS = frozenset(TOOL_CAPABILITIES)
|
|
|
|
_UNKNOWN_CAPABILITIES = _capabilities(
|
|
ToolEffect.READ_PRIVATE,
|
|
ToolEffect.WRITE_WORKSPACE,
|
|
ToolEffect.WRITE_PRIVATE,
|
|
ToolEffect.EXECUTE_CODE,
|
|
ToolEffect.NETWORK_EGRESS,
|
|
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
|
ToolEffect.ADMIN_CHANGE,
|
|
ToolEffect.DESTRUCTIVE,
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
_UNKNOWN_CAPABILITIES = ToolCapabilities(
|
|
_UNKNOWN_CAPABILITIES.effects,
|
|
_UNKNOWN_CAPABILITIES.result_integrity,
|
|
known=False,
|
|
)
|
|
_BROWSER_MCP_READ_CAPABILITIES = _capabilities(
|
|
ToolEffect.BROKERED_NETWORK_READ,
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
_BROWSER_MCP_READ_TOOLS = frozenset(
|
|
{
|
|
"mcp__builtin_browser__browser_console_messages",
|
|
"mcp__builtin_browser__browser_network_requests",
|
|
"mcp__builtin_browser__browser_snapshot",
|
|
"mcp__builtin_browser__browser_take_screenshot",
|
|
}
|
|
)
|
|
|
|
|
|
def capabilities_for_tool(tool_name: Any) -> ToolCapabilities:
|
|
"""Return deterministic capabilities; malformed and unknown tools fail high."""
|
|
if not isinstance(tool_name, str) or not tool_name:
|
|
return _UNKNOWN_CAPABILITIES
|
|
capabilities = TOOL_CAPABILITIES.get(tool_name)
|
|
if capabilities is not None:
|
|
return capabilities
|
|
if tool_name.startswith("mcp__email__"):
|
|
bare_name = tool_name[len("mcp__email__"):]
|
|
capabilities = TOOL_CAPABILITIES.get(bare_name)
|
|
if bare_name in BUILTIN_EMAIL_TOOLS and capabilities is not None:
|
|
return capabilities
|
|
if tool_name in _BROWSER_MCP_READ_TOOLS:
|
|
return _BROWSER_MCP_READ_CAPABILITIES
|
|
return _UNKNOWN_CAPABILITIES
|
|
|
|
|
|
_PRIVATE_ACTION_READS: Mapping[str, frozenset[str]] = MappingProxyType(
|
|
{
|
|
"manage_calendar": frozenset({"list_calendars", "list_events"}),
|
|
"manage_contact": frozenset({"list"}),
|
|
"manage_documents": frozenset({"list", "read", "view", "open", "get"}),
|
|
"manage_memory": frozenset({"list", "search"}),
|
|
"manage_notes": frozenset({"list", "search", "find", "view"}),
|
|
"manage_research": frozenset({"list", "read", "open", "view", "get"}),
|
|
"manage_session": frozenset({"list", "switch", "open", "select", "view"}),
|
|
"manage_skills": frozenset({"list", "index", "view", "view_ref", "search"}),
|
|
"manage_tasks": frozenset({"list"}),
|
|
}
|
|
)
|
|
|
|
_PRIVATE_ACTION_WRITES: Mapping[str, frozenset[str]] = MappingProxyType(
|
|
{
|
|
"manage_calendar": frozenset(
|
|
{"create_event", "update_event", "delete_event"}
|
|
),
|
|
"manage_contact": frozenset({"add", "update", "edit", "delete"}),
|
|
"manage_documents": frozenset({"delete", "tidy"}),
|
|
"manage_memory": frozenset({"add", "edit", "delete"}),
|
|
"manage_notes": frozenset({"add", "update", "delete", "toggle_item"}),
|
|
"manage_research": frozenset({"delete"}),
|
|
"manage_session": frozenset(
|
|
{
|
|
"rename",
|
|
"archive",
|
|
"unarchive",
|
|
"delete",
|
|
"important",
|
|
"unimportant",
|
|
"truncate",
|
|
"fork",
|
|
}
|
|
),
|
|
"manage_skills": frozenset({"add", "edit", "patch", "publish", "delete"}),
|
|
"manage_tasks": frozenset({"create", "edit", "delete", "pause", "resume", "run"}),
|
|
}
|
|
)
|
|
|
|
_ACTION_DEFAULTS: Mapping[str, str] = MappingProxyType(
|
|
{
|
|
"manage_calendar": "list_events",
|
|
"manage_documents": "list",
|
|
"manage_research": "list",
|
|
"manage_tasks": "list",
|
|
}
|
|
)
|
|
|
|
_ACTION_ALIASES: Mapping[str, Mapping[str, str]] = MappingProxyType(
|
|
{
|
|
"manage_calendar": MappingProxyType(
|
|
{
|
|
"create": "create_event",
|
|
"update": "update_event",
|
|
"delete": "delete_event",
|
|
"list": "list_events",
|
|
}
|
|
),
|
|
"manage_notes": MappingProxyType(
|
|
{
|
|
"create": "add",
|
|
"new": "add",
|
|
"save": "add",
|
|
"remind": "add",
|
|
"reminder": "add",
|
|
"remove": "delete",
|
|
"remove_item": "toggle_item",
|
|
}
|
|
),
|
|
}
|
|
)
|
|
|
|
_LINE_ACTION_TOOLS = frozenset({"manage_memory", "manage_session"})
|
|
|
|
|
|
def _action_from_content(tool_name: str, content: Any) -> str | None:
|
|
"""Extract the action discriminator using the same accepted input shapes."""
|
|
if isinstance(content, Mapping):
|
|
payload: Any = dict(content)
|
|
elif isinstance(content, str):
|
|
raw = content.strip()
|
|
if tool_name in _LINE_ACTION_TOOLS and raw and not raw.startswith("{"):
|
|
return raw.splitlines()[0].strip().replace("-", "_").casefold() or None
|
|
try:
|
|
payload = json.loads(raw) if raw else {}
|
|
except (TypeError, ValueError):
|
|
return None
|
|
else:
|
|
payload = {}
|
|
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
if (
|
|
len(payload) == 1
|
|
and isinstance(payload.get("body"), dict)
|
|
and "action" in payload["body"]
|
|
):
|
|
payload = payload["body"]
|
|
|
|
action = payload.get("action")
|
|
if (
|
|
not action
|
|
and tool_name == "manage_calendar"
|
|
and isinstance(payload.get("events"), list)
|
|
):
|
|
action = "create_event"
|
|
if not action and tool_name == "manage_tasks" and any(
|
|
payload.get(key) is not None
|
|
for key in ("task", "description", "schedule", "time", "day_of_week")
|
|
):
|
|
action = "create"
|
|
if not isinstance(action, str) or not action.strip():
|
|
action = _ACTION_DEFAULTS.get(tool_name)
|
|
if not action:
|
|
return None
|
|
normalized = action.strip().replace("-", "_").casefold()
|
|
return _ACTION_ALIASES.get(tool_name, {}).get(normalized, normalized)
|
|
|
|
|
|
def capabilities_for_action(tool_name: Any, content: Any) -> ToolCapabilities:
|
|
"""Classify a sealed multiplexed action; ambiguous actions fail high."""
|
|
base = capabilities_for_tool(tool_name)
|
|
if not isinstance(tool_name, str) or tool_name not in _PRIVATE_ACTION_READS:
|
|
return base
|
|
|
|
action = _action_from_content(tool_name, content)
|
|
if action in _PRIVATE_ACTION_READS[tool_name]:
|
|
return _capabilities(
|
|
ToolEffect.READ_PRIVATE,
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
if action in _PRIVATE_ACTION_WRITES[tool_name]:
|
|
return ToolCapabilities(
|
|
base.effects,
|
|
ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
known=base.known,
|
|
)
|
|
|
|
return _capabilities(
|
|
ToolEffect.READ_PRIVATE,
|
|
ToolEffect.WRITE_PRIVATE,
|
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
|
)
|
|
|
|
|
|
def tool_result_is_successful(result: Any) -> bool:
|
|
"""Return whether a result actually introduced successful tool output."""
|
|
return bool(
|
|
isinstance(result, dict)
|
|
and not result.get("blocked")
|
|
and not result.get("approval_required")
|
|
and not result.get("error")
|
|
and result.get("exit_code") in (None, 0)
|
|
and result.get("success") is not False
|
|
)
|
|
|
|
|
|
def tool_result_should_arm_gate(
|
|
tool_name: Any,
|
|
result: Any,
|
|
content: Any = None,
|
|
) -> bool:
|
|
"""Return whether a result introduced non-system content to the model.
|
|
|
|
A content-free transport or validation failure does not change authority.
|
|
Producers set ``untrusted_content`` when a failed response still carries a
|
|
remote/private body, so HTTP status alone cannot launder that body.
|
|
"""
|
|
if not isinstance(result, dict):
|
|
return False
|
|
if result.get("blocked") or result.get("approval_required"):
|
|
return False
|
|
capabilities = capabilities_for_action(tool_name, content)
|
|
if capabilities.result_integrity is ResultIntegrity.SYSTEM:
|
|
return False
|
|
return tool_result_is_successful(result) or result.get("untrusted_content") is True
|
|
|
|
|
|
POST_EXTERNAL_BLOCKED_EFFECTS = frozenset(
|
|
{
|
|
ToolEffect.READ_PRIVATE,
|
|
ToolEffect.WRITE_WORKSPACE,
|
|
ToolEffect.WRITE_PRIVATE,
|
|
ToolEffect.EXECUTE_CODE,
|
|
ToolEffect.NETWORK_EGRESS,
|
|
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
|
ToolEffect.UI_SIDE_EFFECT,
|
|
ToolEffect.ADMIN_CHANGE,
|
|
ToolEffect.DESTRUCTIVE,
|
|
}
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ToolGateDecision:
|
|
allowed: bool
|
|
reason: str | None = None
|
|
|
|
|
|
_EXTERNAL_MESSAGE_SOURCES = frozenset(
|
|
{
|
|
"injected research context",
|
|
"prefetched search context",
|
|
"research context",
|
|
"web search results",
|
|
"youtube transcript",
|
|
}
|
|
)
|
|
_EXTERNAL_MESSAGE_SOURCE_PREFIXES = ("web page:",)
|
|
|
|
|
|
def messages_contain_external_untrusted_context(messages: Iterable[dict]) -> bool:
|
|
"""Detect explicitly labelled external context already present in a run."""
|
|
for message in messages or ():
|
|
if not isinstance(message, dict):
|
|
continue
|
|
metadata = message.get("metadata")
|
|
if not isinstance(metadata, dict) or metadata.get("trusted") is not False:
|
|
continue
|
|
gate_marker = metadata.get("tool_gate_untrusted")
|
|
if gate_marker is True:
|
|
return True
|
|
if gate_marker is False:
|
|
# Explicit current-format opt-outs are authoritative. The source
|
|
# label heuristics below exist only for older saved wrappers that
|
|
# predate the marker.
|
|
continue
|
|
if metadata.get("provenance_origin") == "external":
|
|
return True
|
|
source = metadata.get("source")
|
|
if not isinstance(source, str):
|
|
continue
|
|
normalized_source = source.strip().casefold()
|
|
if normalized_source in _EXTERNAL_MESSAGE_SOURCES:
|
|
return True
|
|
if normalized_source.startswith(_EXTERNAL_MESSAGE_SOURCE_PREFIXES):
|
|
return True
|
|
return False
|
|
|
|
|
|
@dataclass
|
|
class ToolRunSecurityContext:
|
|
"""Server-owned integrity state for one agent run."""
|
|
|
|
external_untrusted_context_seen: bool = False
|
|
external_sources: list[str] = field(default_factory=list)
|
|
run_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
|
|
def observe_messages(self, messages: Iterable[dict]) -> None:
|
|
"""Promote any server-labelled untrusted prompt context into the gate."""
|
|
if messages_contain_external_untrusted_context(messages):
|
|
self.external_untrusted_context_seen = True
|
|
|
|
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
|
if not self.external_untrusted_context_seen:
|
|
return ToolGateDecision(True)
|
|
capabilities = capabilities_for_action(tool_name, content)
|
|
blocked_effects = capabilities.effects & POST_EXTERNAL_BLOCKED_EFFECTS
|
|
if capabilities.known and not blocked_effects:
|
|
return ToolGateDecision(True)
|
|
effects = ", ".join(sorted(effect.value for effect in blocked_effects))
|
|
if not capabilities.known:
|
|
effects = "unknown/high-impact"
|
|
return ToolGateDecision(
|
|
False,
|
|
(
|
|
"External untrusted context has already influenced this run. "
|
|
f"Tool '{tool_name}' requires a separate user-authorized action "
|
|
f"because it can cause {effects}."
|
|
),
|
|
)
|
|
|
|
def observe_tool_result(
|
|
self,
|
|
tool_name: Any,
|
|
result: Any,
|
|
content: Any = None,
|
|
) -> None:
|
|
if not tool_result_should_arm_gate(tool_name, result, content):
|
|
return
|
|
capabilities = capabilities_for_action(tool_name, content)
|
|
if capabilities.result_integrity is not ResultIntegrity.SYSTEM:
|
|
self.external_untrusted_context_seen = True
|
|
if isinstance(tool_name, str) and tool_name not in self.external_sources:
|
|
self.external_sources.append(tool_name)
|
|
|
|
|
|
def blocked_tool_result(tool_name: Any, reason: str) -> tuple[str, dict]:
|
|
return (
|
|
f"{tool_name}: BLOCKED",
|
|
{
|
|
"error": reason,
|
|
"exit_code": 1,
|
|
"blocked": True,
|
|
"policy": "external_untrusted_context",
|
|
},
|
|
)
|