feat(runtime): enforce server request authority

This commit is contained in:
Alexandre Teixeira
2026-10-01 18:35:41 +01:00
parent 05c6cfcfa1
commit 9d0257134f
37 changed files with 1683 additions and 39 deletions
+38 -1
View File
@@ -86,6 +86,7 @@ from src.model_profiles import (
tool_schema_profile,
)
from src.tool_execution import AgentExecutionBridge, bind_execution_bridge
from src.agent_runtime.authority import is_internal_tool_request, request_authority_for_http
from src.turn_contract import (
FAMILY_TOOLS, bind_turn_contract, preserve_bound_editor_selected_tools,
requested_capabilities, resolve_turn_contract,
@@ -95,6 +96,15 @@ from src.turn_contract import (
logger = logging.getLogger(__name__)
def _append_internal_chat_context(ctx, message):
tagged = untrusted_context_message("internal tool request", message)
ctx.messages.append(tagged)
routed = getattr(ctx, "route_messages", None)
if routed is not None and routed is not ctx.messages:
routed.append(tagged)
# Track active streams for partial-save safety net
_active_streams: Dict[str, dict] = {}
@@ -2196,7 +2206,10 @@ def setup_chat_routes(
webhook_manager=webhook_manager,
allow_tool_preprocessing=allow_tool_preprocessing,
defer_context_shaping=foreground_policy.enabled,
persist_user_message=not is_internal_tool_request(request),
)
if is_internal_tool_request(request):
_append_internal_chat_context(ctx, message)
# Research injection
research_blocked_by_policy = (
@@ -2648,6 +2661,8 @@ def setup_chat_routes(
)
owner = effective_user(request)
if tool_approval_id:
from src.agent_runtime.authority import require_user_approval_request
require_user_approval_request(request)
pending_tool_approval = tool_approval_store.peek(tool_approval_id)
normalized_owner = str(owner or "").strip().casefold()
if (
@@ -2907,10 +2922,12 @@ def setup_chat_routes(
and pending_tool_approval.continuation_query
else None
),
persist_user_message=not tool_approval_continuation,
persist_user_message=not tool_approval_continuation and not is_internal_tool_request(request),
interaction_mode=chat_mode,
auto_escalated=auto_escalated,
)
if is_internal_tool_request(request):
_append_internal_chat_context(ctx, message)
_research_flags = {"do": do_research} # Mutable container for generator scope
@@ -3349,6 +3366,24 @@ def setup_chat_routes(
"manage_documents", "create_document", "edit_document", "update_document",
}.issubset(disabled_tools),
}
# Capture permission state before schema selection/reconciliation.
# Only deterministic request intent supplies grants, never inventory.
_request_authority = request_authority_for_http(
request, message, owner=_user, session_id=session, workspace=workspace,
history=_turn_history, policy=tool_policy,
active_document=bool(active_doc),
image_attachment=any(str(a.get('mime') or '').startswith('image/')
for a in (ctx.preprocessed.attachment_meta or [])),
capabilities=({'search_browser'} if (
_explicit_browser_intent or _external_discovery_intent
) else ()),
)
if exact_tool_approval is not None:
from src.agent_runtime.authority import RequestAuthority
_request_authority = (
exact_tool_approval.pending.request_authority
or RequestAuthority.empty(owner=_user, session_id=session, workspace=workspace)
).restrict(tool_policy)
_turn_contract = None
# Image models execute directly, not through the text-agent inventory.
# Keep the permission policy above, but do not apply routing omissions
@@ -3477,6 +3512,7 @@ def setup_chat_routes(
warm_tools=_warm_tools,
message=message, history=getattr(sess, "history", []) or [],
)
_request_authority = _request_authority.restrict(_contract_policy)
# Resolution already applies user, owner, and global policy. An
# admitted tool must not later be rejected by the stale
# pre-contract disabled snapshot during execution.
@@ -4415,6 +4451,7 @@ def setup_chat_routes(
cwd=_agent_turn_cwd(sess, client_runtime_context),
forced_tools=_forced_tools,
turn_contract=_turn_contract,
request_authority=_request_authority,
uploaded_files=ctx.uploaded_files,
defer_context_shaping=_foreground_policy.enabled,
external_untrusted_context_seen=external_untrusted_context_seen,
+16 -1
View File
@@ -534,11 +534,13 @@ async def _run_skill_test_job(
messages=None,
transcript=None,
exact_approval=None,
request_authority=None,
):
"""Background coroutine: run the skill in an agent loop, capture a condensed
log + transcript, then have the judge grade it. Writes into _skill_test_jobs."""
import json as _json
from src.agent_loop import stream_agent_loop
from src.agent_runtime.authority import RequestAuthority
job = _skill_test_jobs.get(key)
if job is None:
@@ -559,6 +561,9 @@ async def _run_skill_test_job(
url, model, messages, headers=headers,
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner,
exact_approval=exact_approval,
request_authority=(request_authority or (
exact_approval.pending.request_authority if exact_approval is not None else None
) or RequestAuthority.empty(owner=owner)),
):
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
continue
@@ -1039,6 +1044,7 @@ async def _run_skill_audit_arm(messages: list[dict], url, model, headers, owner,
"""Run one audit arm in the agent loop; return transcript, stats, approval."""
import json as _json
from src.agent_loop import stream_agent_loop
from src.agent_runtime.authority import RequestAuthority, active_request_authority
transcript = []
approval_required = None
stats = {"turns": 0, "tool_calls": 0}
@@ -1051,6 +1057,7 @@ async def _run_skill_audit_arm(messages: list[dict], url, model, headers, owner,
url, model, messages, headers=headers,
temperature=0.3, max_tokens=4096, max_rounds=8,
owner=owner, workload=workload, suppress_skills=True,
request_authority=(active_request_authority() or RequestAuthority.empty(owner=owner)),
):
# Streams can include an SSE event line before the data line,
# notably `event: error`. Do not silently discard those failures.
@@ -2001,6 +2008,9 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
user = _owner(request)
body = await request.json()
task = (body.get("task") or "").strip()
from src.agent_runtime.authority import RequestAuthority, request_authority_for_http
request_authority = (request_authority_for_http(request, task, owner=user)
if task else RequestAuthority.empty(owner=user))
skills = skills_manager.load(owner=user)
match = next((s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id), None)
@@ -2064,9 +2074,12 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
"model": model,
"headers": headers,
"owner": user,
"request_authority": request_authority,
},
}
_asyncio.create_task(_run_skill_test_job(key, name, md, task, url, model, headers, user, skills_manager))
_asyncio.create_task(_run_skill_test_job(
key, name, md, task, url, model, headers, user, skills_manager,
request_authority=request_authority))
return {"ok": True, "status": "running", "skill": name, "model": model}
@router.post("/{skill_id}/test-approval")
@@ -2074,6 +2087,8 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
"""Resume a manual skill test with one exact server-sealed action."""
import asyncio as _asyncio
from src.tool_approvals import tool_approval_store
from src.agent_runtime.authority import require_user_approval_request
require_user_approval_request(request)
user = _owner(request)
skills = skills_manager.load(owner=user)
+13
View File
@@ -25,6 +25,14 @@ from routes.prefs_routes import _load_for_user, _save_for_user
logger = logging.getLogger(__name__)
def _seal_request_task_authority(request, prompt, task_type, action, owner):
from src.agent_runtime.authority import MISSING_AUTHORITY, is_internal_tool_request, seal_task_authority
# A tool HTTP call starts another ASGI context. Its model-produced body is
# not a fresh user request, even though the internal token authenticates it.
parent = None if is_internal_tool_request(request) else MISSING_AUTHORITY
return seal_task_authority(prompt, task_type, action, owner=owner, parent_authority=parent)
def _maybe_cascade_calendar_event(task) -> None:
"""Delete the linked calendar event when a cookbook_serve task is
removed. Two lookup strategies:
@@ -530,6 +538,8 @@ def setup_task_routes(task_scheduler) -> APIRouter:
owner=user,
name=name,
prompt=req.prompt,
request_authority_json=_seal_request_task_authority(
request, req.prompt, req.task_type, req.action, user),
task_type=req.task_type,
action=req.action,
schedule=req.schedule,
@@ -737,6 +747,9 @@ def setup_task_routes(task_scheduler) -> APIRouter:
task.task_type = req.task_type
if req.action is not None:
task.action = req.action
if any(value is not None for value in (req.prompt, req.task_type, req.action)):
task.request_authority_json = _seal_request_task_authority(
request, task.prompt, task.task_type, task.action, user)
if req.output_target is not None:
task.output_target = req.output_target
if req.model is not None: