fix(agent): close untrusted-context gate bypasses

This commit is contained in:
RaresKeY
2026-08-15 01:58:32 +00:00
parent 329f9d298d
commit 2295504141
16 changed files with 287 additions and 66 deletions
+28 -15
View File
@@ -34,8 +34,10 @@ 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 (
ResultIntegrity,
ToolRunSecurityContext,
blocked_tool_result,
capabilities_for_tool,
messages_contain_external_untrusted_context,
)
from src.tool_utils import _truncate, get_mcp_manager
@@ -1583,16 +1585,16 @@ def _minimal_saved_memory_message(messages: List[Dict]) -> Optional[Dict]:
if not facts:
return None
logger.info("[agent-intent] odysseus doc minimal memory facts=%s", len(facts))
return {
"role": "user",
"content": (
return untrusted_context_message(
"saved memory: minimal context",
(
"Saved user memory facts from Odysseus Brain. These are the same "
"user facts available in the normal prompt path. Use them when "
"the user asks for personalization, identity, background, "
"preferences, or anything about \"me\" or \"my\":\n"
+ "\n".join(f"- {fact}" for fact in facts)
),
}
)
def _resolved_tool_event_name(event: dict[str, Any]) -> str:
@@ -1690,9 +1692,9 @@ def _minimal_recent_notes_tool_context_message(messages: List[Dict]) -> Optional
recent_text = ""
if recent_turns:
recent_text = "Recent chat turns for pronoun/reference resolution:\n" + "\n".join(recent_turns) + "\n\n"
return {
"role": "user",
"content": (
return untrusted_context_message(
"recent tool context",
(
"Recent Odysseus tool context for follow-up references only. "
"Use concrete note ids, calendar event uids, and email UIDs from "
"here when the user says that note/event/reminder/appointment/"
@@ -1700,7 +1702,7 @@ def _minimal_recent_notes_tool_context_message(messages: List[Dict]) -> Optional
+ recent_text
+ "\n\n".join(parts)
),
}
)
def _compact_email_draft_context(raw: str, *, max_own_chars: int = 1200, max_history_chars: int = 1200) -> str:
@@ -1810,17 +1812,18 @@ def _minimal_odysseus_doc_messages(messages: List[Dict], active_document, stream
else:
content_for_prompt = content
content_note = "Content:\n"
out.append({
"role": "user",
"content": (
active_document_message = untrusted_context_message(
"active editor document",
(
"Active document:\n"
f"Title: {active_document.title}\n"
f"Language: {active_document.language or 'text'}\n"
f"{content_note}"
f"{content_for_prompt}"
),
"_agent_injected": "context",
})
)
active_document_message["_agent_injected"] = "context"
out.append(active_document_message)
out.append({"role": "user", "content": latest})
return out
@@ -2983,11 +2986,19 @@ def _append_tool_results(
messages.append(assistant_msg)
for j, tc in enumerate(native_tool_calls):
result_text = tool_result_texts[j] if j < len(tool_result_texts) else ""
messages.append({
result_message = {
"role": "tool",
"tool_call_id": tc.get("id", f"call_{round_num}_{j}"),
"content": result_text,
})
}
capabilities = capabilities_for_tool(tc.get("name", ""))
if capabilities.result_integrity is not ResultIntegrity.SYSTEM:
result_message["metadata"] = {
"trusted": False,
"source": f"tool result: {tc.get('name', '')}",
"tool_gate_untrusted": True,
}
messages.append(result_message)
else:
tool_output_text = "\n\n".join(tool_results)
msg = {"role": "assistant", "content": round_response}
@@ -4260,6 +4271,7 @@ async def stream_agent_loop(
)
prep_timings["context_trim"] = time.time() - _t3
run_security.observe_messages(_initial_route_request_messages)
agent_prompt_tokens = estimate_tokens(_initial_route_request_messages)
logger.info(
"[agent-timing] prep_done model=%s prompt_tokens=%s context_length=%s prep=%s",
@@ -4493,6 +4505,7 @@ async def stream_agent_loop(
context_length,
)
_last_route_context_length = state["context_length"]
run_security.observe_messages(request_messages)
candidate_tools = _tool_schemas_for_route(state)
state["tools"] = candidate_tools
_candidate_request_states[index] = state
+12 -7
View File
@@ -15,6 +15,7 @@ import json
import logging
from src import bg_jobs
from src.prompt_security import untrusted_context_message
logger = logging.getLogger(__name__)
@@ -25,6 +26,16 @@ POLL_INTERVAL_S = 5
_FOLLOWUP_MAX_ROUNDS = 12
def _background_result_message(rec):
inject = (
f"[Background job {rec['id']} finished]\n\n"
f"{bg_jobs.result_text(rec)}\n\n"
"Continue the task using this output. Don't repeat work that's already done. "
"If the task is now complete, give the user the final result."
)
return untrusted_context_message("background job output", inject)
async def _drain_agent(sess, messages):
"""Run the agent loop headless against a session. Returns
(final_prose, tool_events) — tool_events in the same shape the live chat
@@ -101,14 +112,8 @@ async def _run_followup(rec: dict) -> bool:
except Exception:
pass
inject = (
f"[Background job {rec['id']} finished]\n\n"
f"{bg_jobs.result_text(rec)}\n\n"
"Continue the task using this output. Don't repeat work that's already done. "
"If the task is now complete, give the user the final result."
)
context = sess.get_context_messages()
context.append({"role": "user", "content": inject})
context.append(_background_result_message(rec))
full, tool_events = await _drain_agent(sess, context)
+6 -1
View File
@@ -66,6 +66,7 @@ def untrusted_context_message(
content: Any,
*,
provenance_origin: str | None = None,
arm_tool_gate: bool = True,
) -> Dict[str, Any]:
"""Return an LLM message that keeps retrieved/source text out of system role.
@@ -78,7 +79,11 @@ def untrusted_context_message(
safe_label = _sanitize_label(label)
text = "" if content is None else str(content)
text = _escape_guard_markers(text)
metadata: Dict[str, Any] = {"trusted": False, "source": label}
metadata: Dict[str, Any] = {
"trusted": False,
"source": label,
"tool_gate_untrusted": bool(arm_tool_gate),
}
if provenance_origin:
metadata["provenance_origin"] = provenance_origin
return {
+9 -1
View File
@@ -112,6 +112,7 @@ _register(
_register(
{"bash", "manage_bg_jobs", "python"},
ToolEffect.EXECUTE_CODE,
result_integrity=ResultIntegrity.WORKSPACE_UNTRUSTED,
)
_register(
{"apply_patch", "edit_file", "write_file"},
@@ -291,6 +292,8 @@ def messages_contain_external_untrusted_context(messages: Iterable[dict]) -> boo
metadata = message.get("metadata")
if not isinstance(metadata, dict) or metadata.get("trusted") is not False:
continue
if metadata.get("tool_gate_untrusted") is True:
return True
if metadata.get("provenance_origin") == "external":
return True
source = metadata.get("source")
@@ -311,6 +314,11 @@ class ToolRunSecurityContext:
external_untrusted_context_seen: bool = False
external_sources: list[str] = field(default_factory=list)
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) -> ToolGateDecision:
if not self.external_untrusted_context_seen:
return ToolGateDecision(True)
@@ -336,7 +344,7 @@ class ToolRunSecurityContext:
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:
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)
+33 -3
View File
@@ -32,6 +32,18 @@ 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
class _MissingToolSecurityContext:
pass
class _NoToolSecurityContext:
"""Explicit sentinel for non-agent callers that have no run provenance."""
_MISSING_TOOL_SECURITY_CONTEXT = _MissingToolSecurityContext()
NO_TOOL_SECURITY_CONTEXT = _NoToolSecurityContext()
# Persistent working directory for agent subprocesses.
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
# (/app/data) and the local data directory for manual installs.
@@ -576,7 +588,11 @@ 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,
security_context: (
ToolRunSecurityContext
| _NoToolSecurityContext
| _MissingToolSecurityContext
) = _MISSING_TOOL_SECURITY_CONTEXT,
) -> Tuple[str, Dict]:
"""Execute a single tool block. Returns (description, result_dict).
@@ -584,7 +600,21 @@ 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:
if security_context is _MISSING_TOOL_SECURITY_CONTEXT:
raise TypeError(
"execute_tool_block requires security_context; pass a "
"ToolRunSecurityContext or NO_TOOL_SECURITY_CONTEXT explicitly"
)
if (
not isinstance(security_context, ToolRunSecurityContext)
and security_context is not NO_TOOL_SECURITY_CONTEXT
):
raise TypeError(
"security_context must be a ToolRunSecurityContext or "
"NO_TOOL_SECURITY_CONTEXT"
)
if isinstance(security_context, ToolRunSecurityContext):
decision = security_context.decision_for(getattr(block, "tool_type", None))
if not decision.allowed:
logger.warning(
@@ -606,7 +636,7 @@ async def execute_tool_block(
progress_cb=progress_cb,
tool_policy=tool_policy,
)
if security_context is not None:
if isinstance(security_context, ToolRunSecurityContext):
security_context.observe_tool_result(
getattr(block, "tool_type", None),
output[1],