fix(agent): gate tools after external context

Classify built-in tool effects in a server-owned registry and carry run-local external-context integrity state through the agent loop and dispatcher. Block high-impact and unknown actions after successful external results, including same-batch calls, without relying on model compliance.
This commit is contained in:
RaresKeY
2026-08-15 01:57:08 +00:00
parent f9235ebbf1
commit fef0e6f3c0
4 changed files with 688 additions and 3 deletions
+40 -3
View File
@@ -33,6 +33,11 @@ from src.settings import get_setting
from src.prompt_security import untrusted_context_message
from src.tool_security import blocked_tools_for_owner, plan_mode_disabled_tools
from src.tool_policy import GUIDE_ONLY_DIRECTIVE, WEB_TOOL_NAMES, ToolPolicy
from src.tool_capabilities import (
ToolRunSecurityContext,
blocked_tool_result,
messages_contain_external_untrusted_context,
)
from src.tool_utils import _truncate, get_mcp_manager
from src.agent_tools import (
parse_tool_blocks,
@@ -3327,6 +3332,7 @@ async def stream_agent_loop(
forced_tools: Optional[Set[str]] = None,
uploaded_files: Optional[List[Dict]] = None,
workload: str = "foreground",
external_untrusted_context_seen: bool = False,
_is_teacher_run: bool = False,
history_session=None,
defer_context_shaping: bool = False,
@@ -3342,6 +3348,12 @@ async def stream_agent_loop(
- data: [DONE] (end)
"""
run_security = ToolRunSecurityContext(
external_untrusted_context_seen=(
bool(external_untrusted_context_seen)
or messages_contain_external_untrusted_context(messages)
)
)
mcp_mgr = get_mcp_manager()
prep_timings: Dict[str, float] = {}
disabled_tools = set(disabled_tools or [])
@@ -4340,6 +4352,17 @@ async def stream_agent_loop(
# so the user can resume instead of the turn silently stalling.
_exhausted_rounds = False
def _filter_route_tool_schemas(schemas):
if not run_security.external_untrusted_context_seen or not schemas:
return schemas
return [
schema
for schema in schemas
if run_security.decision_for(
(schema.get("function") or {}).get("name") or schema.get("name")
).allowed
]
def _tool_schemas_for_route(route_state):
route_mcp_schemas = route_state["mcp_schemas"]
route_relevant_tools = route_state["relevant_tools"]
@@ -4373,10 +4396,11 @@ async def stream_agent_loop(
if schema.get("function", {}).get("name") not in disabled_tools
and schema.get("name") not in disabled_tools
]
return schemas
return _filter_route_tool_schemas(schemas)
wants_mcp = any(keyword in _last_user.lower() for keyword in _MCP_KEYWORDS)
return route_mcp_schemas if wants_mcp and route_mcp_schemas else []
schemas = route_mcp_schemas if wants_mcp and route_mcp_schemas else []
return _filter_route_tool_schemas(schemas)
for round_num in range(1, max_rounds + 1):
round_response = ""
@@ -5389,11 +5413,21 @@ async def stream_agent_loop(
else:
cmd_display = full_command
security_decision = run_security.decision_for(block.tool_type)
_ody_clamped_tool_allowed = (
_ody_notes_finetune_mode
and block.tool_type in {"manage_notes", "manage_calendar", "manage_tasks"}
)
if tool_policy and tool_policy.blocks(block.tool_type) and not _ody_clamped_tool_allowed:
if not security_decision.allowed:
desc, result = blocked_tool_result(
block.tool_type,
security_decision.reason or "Tool blocked by external-context policy.",
)
logger.info(
"Tool blocked before start by external-context policy: %s",
block.tool_type,
)
elif tool_policy and tool_policy.blocks(block.tool_type) and not _ody_clamped_tool_allowed:
desc = f"{block.tool_type}: BLOCKED"
result = {
"error": tool_policy.reason_for(block.tool_type),
@@ -5425,6 +5459,7 @@ async def stream_agent_loop(
owner=owner,
progress_cb=_push_progress,
workspace=workspace,
security_context=run_security,
)
finally:
# Sentinel so the drainer knows to stop.
@@ -5457,6 +5492,8 @@ async def stream_agent_loop(
except (asyncio.CancelledError, Exception):
pass
run_security.observe_tool_result(block.tool_type, result)
# A skill the model just loaded can prescribe tools that weren't
# RAG-selected this turn (declared via requires_toolsets in its
# frontmatter). Union them into the selection so the NEXT round's
+348
View File
@@ -0,0 +1,348 @@
"""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
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",
"search_hf_models",
},
ToolEffect.READ_PUBLIC,
)
_register(
{"get_workspace", "glob", "grep", "ls", "read_file"},
ToolEffect.READ_WORKSPACE,
result_integrity=ResultIntegrity.WORKSPACE_UNTRUSTED,
)
_register(
{"web_fetch", "web_search"},
ToolEffect.BROKERED_NETWORK_READ,
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,
)
_register(
{"apply_patch", "edit_file", "write_file"},
ToolEffect.WRITE_WORKSPACE,
)
_register(
{
"ai_draft_email_reply",
"create_document",
"create_session",
"draft_email",
"draft_email_reply",
"edit_document",
"manage_calendar",
"manage_contact",
"manage_documents",
"manage_memory",
"manage_notes",
"manage_research",
"manage_session",
"manage_skills",
"manage_tasks",
"pipeline",
"send_to_session",
"suggest_document",
"todowrite",
"update_document",
},
ToolEffect.WRITE_PRIVATE,
)
_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",
"api_call",
"app_api",
"cancel_download",
"download_model",
"manage_endpoints",
"manage_mcp",
"manage_settings",
"manage_tokens",
"manage_webhooks",
"serve_model",
"serve_preset",
"stop_served_model",
"vault_unlock",
},
ToolEffect.ADMIN_CHANGE,
)
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
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",
}
)
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
if metadata.get("provenance_origin") == "external":
return True
source = metadata.get("source")
if isinstance(source, str) and source.strip().casefold() in _EXTERNAL_MESSAGE_SOURCES:
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)
def decision_for(self, tool_name: Any) -> ToolGateDecision:
if not self.external_untrusted_context_seen:
return ToolGateDecision(True)
capabilities = capabilities_for_tool(tool_name)
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) -> None:
if not isinstance(result, dict):
return
if result.get("blocked") or result.get("error") or result.get("exit_code") not in (None, 0):
return
capabilities = capabilities_for_tool(tool_name)
if capabilities.result_integrity is ResultIntegrity.EXTERNAL_UNTRUSTED:
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",
},
)
+19
View File
@@ -27,6 +27,7 @@ from src.tool_security import (
is_public_blocked_tool,
owner_is_admin_or_single_user,
)
from src.tool_capabilities import ToolRunSecurityContext, blocked_tool_result
from src.tool_policy import ToolPolicy
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES, DATA_DIR
from src.tool_utils import _truncate, get_mcp_manager
@@ -575,6 +576,7 @@ async def execute_tool_block(
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
workspace: Optional[str] = None,
tool_policy: Optional[Any] = None,
security_context: Optional[ToolRunSecurityContext] = None,
) -> Tuple[str, Dict]:
"""Execute a single tool block. Returns (description, result_dict).
@@ -582,6 +584,18 @@ async def execute_tool_block(
cwd confine to it) for the duration of this call, then delegate. Reset on the
way out so the binding never leaks to the next tool call.
"""
if security_context is not None:
decision = security_context.decision_for(getattr(block, "tool_type", None))
if not decision.allowed:
logger.warning(
"External-context policy blocked tool=%r",
getattr(block, "tool_type", None),
)
return blocked_tool_result(
getattr(block, "tool_type", None),
decision.reason or "Tool blocked by external-context policy.",
)
token = _active_workspace.set(workspace or None)
try:
output = await _execute_tool_block_impl(
@@ -592,6 +606,11 @@ async def execute_tool_block(
progress_cb=progress_cb,
tool_policy=tool_policy,
)
if security_context is not None:
security_context.observe_tool_result(
getattr(block, "tool_type", None),
output[1],
)
return output
finally:
_active_workspace.reset(token)