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