mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-08 07:52:20 +02:00
feat(runtime): enforce server request authority
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
"""Server-owned request admission, independent of model tool availability."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import aclosing, contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, replace
|
||||
from functools import wraps
|
||||
from inspect import signature
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from uuid import uuid4
|
||||
|
||||
from src.tool_policy import ToolPolicy, build_effective_tool_policy
|
||||
from src.turn_contract import (
|
||||
FAMILY_TOOLS, canonical_tool, requested_capabilities,
|
||||
RequiredReadOperation, required_read_operation_for_request, selected_tools_for_request,
|
||||
)
|
||||
|
||||
|
||||
def _owner(value):
|
||||
return str(value or "").strip().casefold()
|
||||
|
||||
|
||||
def _pairs(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("Duplicate operation argument")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _invalid_constant(value):
|
||||
raise ValueError("Non-finite operation argument")
|
||||
|
||||
|
||||
def _json(value):
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExactOperation:
|
||||
tool: str
|
||||
input: str
|
||||
action: str | None = None
|
||||
transport_tool: str = ""
|
||||
|
||||
@classmethod
|
||||
def normalize(cls, tool, content):
|
||||
if not isinstance(tool, str) or not tool.strip() or not isinstance(content, str):
|
||||
raise ValueError("Operation requires a tool name and string input")
|
||||
transport_tool = tool.strip()
|
||||
tool = canonical_tool(transport_tool)
|
||||
normalized = content
|
||||
payload = None
|
||||
raw_input = tool in {"bash", "python"} or tool.startswith("scheduled__")
|
||||
if not raw_input and content.lstrip().startswith("{"):
|
||||
payload = json.loads(content, object_pairs_hook=_pairs, parse_constant=_invalid_constant)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Structured tool input must be an object")
|
||||
normalized = _json(payload)
|
||||
# Reuse the runtime's existing multiplexed-action normalization; this
|
||||
# classifies input and never grants permission or changes the input.
|
||||
from src.tool_capabilities import _action_from_content
|
||||
action = _action_from_content(tool, content)
|
||||
if tool == "private_browser" and isinstance(payload, dict):
|
||||
action = payload.get("action")
|
||||
if action is not None and not isinstance(action, str):
|
||||
raise ValueError("Browser action must be a string")
|
||||
action = action.strip().casefold() if action else None
|
||||
return cls(tool, normalized, action, transport_tool)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationGrant:
|
||||
tool: str
|
||||
actions: frozenset[str] | None = None
|
||||
inputs: frozenset[str] | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if not isinstance(self.tool, str) or not self.tool or canonical_tool(self.tool) != self.tool:
|
||||
raise ValueError("Grant requires a canonical tool identity")
|
||||
for values in (self.actions, self.inputs):
|
||||
if values is not None and (not isinstance(values, frozenset)
|
||||
or any(not isinstance(v, str) for v in values)):
|
||||
raise TypeError("Grant limits must be immutable string sets")
|
||||
|
||||
def permits(self, operation):
|
||||
return (self.tool == operation.tool
|
||||
and (self.actions is None or operation.action in self.actions)
|
||||
and (self.inputs is None or operation.input in self.inputs))
|
||||
|
||||
def intersect(self, other):
|
||||
if self.tool != other.tool:
|
||||
raise ValueError("Cannot intersect different operation classes")
|
||||
def limits(left, right):
|
||||
return right if left is None else left if right is None else left & right
|
||||
return OperationGrant(self.tool, limits(self.actions, other.actions),
|
||||
limits(self.inputs, other.inputs))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestAuthority:
|
||||
request_id: str
|
||||
owner: str
|
||||
session_id: str
|
||||
workspace: str
|
||||
grants: tuple[OperationGrant, ...] = ()
|
||||
denied: frozenset[str] = frozenset()
|
||||
block_all: bool = False
|
||||
disable_mcp: bool = False
|
||||
inherited: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
if (not isinstance(self.request_id, str) or not self.request_id
|
||||
or any(not isinstance(v, str) for v in (self.owner, self.session_id, self.workspace))
|
||||
or not isinstance(self.grants, tuple)
|
||||
or any(not isinstance(g, OperationGrant) for g in self.grants)
|
||||
or len({g.tool for g in self.grants}) != len(self.grants)
|
||||
or not isinstance(self.denied, frozenset)
|
||||
or any(not isinstance(n, str) or canonical_tool(n) != n for n in self.denied)
|
||||
or any(type(v) is not bool for v in (self.block_all, self.disable_mcp, self.inherited))):
|
||||
raise ValueError("Malformed request authority")
|
||||
|
||||
@classmethod
|
||||
def empty(cls, *, owner=None, session_id=None, workspace=None):
|
||||
return cls(uuid4().hex, _owner(owner), str(session_id or ""), str(workspace or ""))
|
||||
|
||||
def bound_to(self, *, owner=None, session_id=None, workspace=None):
|
||||
return (self.owner == _owner(owner) and self.session_id == str(session_id or "")
|
||||
and self.workspace == str(workspace or ""))
|
||||
|
||||
def restricted(self, operation):
|
||||
return (self.block_all or operation.tool in self.denied
|
||||
or (self.disable_mcp and (operation.tool.startswith("mcp__")
|
||||
or operation.transport_tool.startswith("mcp__"))))
|
||||
|
||||
def permits(self, operation):
|
||||
return not self.restricted(operation) and any(g.permits(operation) for g in self.grants)
|
||||
|
||||
def restrict(self, policy=None, disabled_tools=()):
|
||||
policy = policy or ToolPolicy()
|
||||
return replace(self, denied=self.denied | frozenset(
|
||||
canonical_tool(n) for n in set(disabled_tools or ()) | policy.all_disabled_names()),
|
||||
block_all=self.block_all or policy.block_all_tool_calls,
|
||||
disable_mcp=self.disable_mcp or policy.disable_mcp)
|
||||
|
||||
def intersect(self, child):
|
||||
if not isinstance(child, RequestAuthority):
|
||||
raise TypeError("Child authority must be server-owned RequestAuthority")
|
||||
grants = []
|
||||
if (self.owner, self.session_id, self.workspace) == (child.owner, child.session_id, child.workspace):
|
||||
theirs = {g.tool: g for g in child.grants}
|
||||
grants = [g.intersect(theirs[g.tool]) for g in self.grants if g.tool in theirs]
|
||||
return replace(self, grants=tuple(grants), denied=self.denied | child.denied,
|
||||
block_all=self.block_all or child.block_all,
|
||||
disable_mcp=self.disable_mcp or child.disable_mcp, inherited=True)
|
||||
|
||||
def continuation(self, *, owner=None, session_id=None):
|
||||
"""A server continuation may rebind a session, never change owner/grants."""
|
||||
if self.owner != _owner(owner):
|
||||
return RequestAuthority.empty(owner=owner, session_id=session_id)
|
||||
return replace(self, session_id=str(session_id or ""), inherited=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {"version": 1, "request_id": self.request_id, "owner": self.owner,
|
||||
"session_id": self.session_id, "workspace": self.workspace,
|
||||
"grants": [{"tool": g.tool,
|
||||
"actions": None if g.actions is None else sorted(g.actions),
|
||||
"inputs": None if g.inputs is None else sorted(g.inputs)} for g in self.grants],
|
||||
"denied": sorted(self.denied), "block_all": self.block_all,
|
||||
"disable_mcp": self.disable_mcp, "inherited": self.inherited}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
if (not isinstance(value, dict) or type(value.get("version")) is not int
|
||||
or value["version"] != 1):
|
||||
raise ValueError("Unsupported authority snapshot")
|
||||
def limits(value):
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, list) or any(not isinstance(v, str) for v in value):
|
||||
raise ValueError("Malformed authority limits")
|
||||
return frozenset(value)
|
||||
return cls(value["request_id"], value["owner"], value["session_id"], value["workspace"],
|
||||
tuple(OperationGrant(g["tool"], limits(g["actions"]), limits(g["inputs"]))
|
||||
for g in value["grants"]), limits(value["denied"]),
|
||||
value["block_all"], value["disable_mcp"], value["inherited"])
|
||||
|
||||
|
||||
_BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find",
|
||||
"screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticIntent:
|
||||
"""Routing facts, with no execution permission or provider inventory."""
|
||||
capabilities: frozenset[str]
|
||||
selected_tools: frozenset[str] | None
|
||||
required_read: RequiredReadOperation | None
|
||||
|
||||
|
||||
def interpret_request(request_text, *, history=(), workspace=None, active_document=False,
|
||||
image_attachment=False):
|
||||
if not isinstance(request_text, str):
|
||||
raise TypeError("Intent requires request text")
|
||||
# Routing may use model/tool history. Admission may only inherit intent
|
||||
# from trusted user requests; a model's proposal or attempted tool call
|
||||
# cannot establish a new authorized operation class.
|
||||
history = tuple(history or ())
|
||||
trusted_history = []
|
||||
for row in history:
|
||||
get = row.get if isinstance(row, dict) else lambda key, default=None: getattr(row, key, default)
|
||||
metadata = get("metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
try:
|
||||
metadata = json.loads(metadata)
|
||||
except ValueError:
|
||||
metadata = {}
|
||||
if (get("role") == "user" and isinstance(metadata, dict)
|
||||
and metadata.get("trusted") is not False and not metadata.get("tool_gate_untrusted")):
|
||||
trusted_history.append({"role": "user", "content": get("content", "")})
|
||||
families = requested_capabilities(request_text, trusted_history,
|
||||
active_document=active_document, workspace=bool(workspace), image_attachment=image_attachment)
|
||||
selected = selected_tools_for_request(request_text)
|
||||
if (families <= {"unknown"} and selected is None
|
||||
and re.search(r"\b(?:lan|local\s+(?:network|ip)|tailscale|arp|ip\s+route|default\s+route|subnet|network\s+interface|neighbor\s+table|wifi|ethernet)\b", request_text, re.I)
|
||||
and re.search(r"\b(?:find|check|inspect|show|list|lookup|locate)\b", request_text, re.I)
|
||||
and not re.search(r"\b(?:web|internet|online)\b", request_text, re.I)):
|
||||
# An explicit local-network lookup is a host operation. The existing
|
||||
# router already chooses host_shell; neither its schema nor bridge
|
||||
# availability grants Bash/Python alongside this request.
|
||||
return SemanticIntent(frozenset({"shell_files"}), frozenset({"host_shell"}), None)
|
||||
return SemanticIntent(families, selected, required_read_operation_for_request(request_text, history))
|
||||
|
||||
|
||||
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
|
||||
history=(), policy=None, active_document=False,
|
||||
image_attachment=False, capabilities=None):
|
||||
"""Deterministic server policy over semantic facts, never schema inventory."""
|
||||
if not isinstance(request_text, str):
|
||||
raise TypeError("Authority requires trusted request text")
|
||||
intent = interpret_request(request_text, history=history, active_document=active_document,
|
||||
workspace=workspace, image_attachment=image_attachment)
|
||||
families = intent.capabilities
|
||||
if capabilities is not None:
|
||||
families |= frozenset(capabilities)
|
||||
tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in families))
|
||||
selected = intent.selected_tools
|
||||
if selected is not None:
|
||||
tools = tools & set(selected) if families else set(selected)
|
||||
if tools & {"web_search", "web_fetch"}:
|
||||
tools.add("private_browser")
|
||||
operation = intent.required_read
|
||||
if operation is not None and canonical_tool(operation.tool) in {canonical_tool(n) for n in tools}:
|
||||
tools = {canonical_tool(operation.tool)}
|
||||
else:
|
||||
operation = None
|
||||
grants = []
|
||||
for name in sorted(tools | {"ask_user", "update_plan"}):
|
||||
name = canonical_tool(name)
|
||||
actions = inputs = None
|
||||
if name == "private_browser":
|
||||
actions = _BROWSER_READ_ACTIONS
|
||||
# Explicit interaction intent admits its operation class. A
|
||||
# browser offered only as static-Web fallback gets no such grant.
|
||||
if re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I):
|
||||
actions |= frozenset(action for action in ("click", "fill", "type", "press", "evaluate", "select")
|
||||
if re.search(r"\b" + action + r"\b", request_text, re.I))
|
||||
if operation is not None and name == canonical_tool(operation.tool):
|
||||
inputs = frozenset({ExactOperation.normalize(name, _json(dict(operation.args))).input})
|
||||
grants.append(OperationGrant(name, actions, inputs))
|
||||
authority = RequestAuthority(uuid4().hex, _owner(owner), str(session_id or ""),
|
||||
str(workspace or ""), tuple(grants))
|
||||
return authority.restrict(policy or build_effective_tool_policy(last_user_message=request_text))
|
||||
|
||||
|
||||
_ACTIVE: ContextVar[RequestAuthority | None] = ContextVar("request_authority", default=None)
|
||||
MISSING_AUTHORITY = object()
|
||||
|
||||
|
||||
def active_request_authority():
|
||||
return _ACTIVE.get()
|
||||
|
||||
|
||||
def is_internal_tool_request(request):
|
||||
"""HTTP authentication/owner attribution does not make a tool payload user intent."""
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
return (request.headers.get(INTERNAL_TOOL_HEADER) == INTERNAL_TOOL_TOKEN
|
||||
or getattr(request.state, "current_user", None) == "internal-tool")
|
||||
|
||||
|
||||
def require_user_approval_request(request):
|
||||
if is_internal_tool_request(request):
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(403, "Tool requests cannot submit user approval decisions.")
|
||||
|
||||
|
||||
def request_authority_for_http(request, request_text, **context):
|
||||
"""Known tool loopback is a continuation, never a fresh user grant source."""
|
||||
if is_internal_tool_request(request):
|
||||
return RequestAuthority.empty(owner=context.get("owner"),
|
||||
session_id=context.get("session_id"), workspace=context.get("workspace")).restrict(context.get("policy"))
|
||||
return create_request_authority(request_text, **context)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_request_authority(authority):
|
||||
if not isinstance(authority, RequestAuthority):
|
||||
raise TypeError("Authority must be server-owned RequestAuthority")
|
||||
parent = _ACTIVE.get()
|
||||
authority = parent.intersect(authority) if parent is not None else authority
|
||||
token = _ACTIVE.set(authority)
|
||||
try:
|
||||
yield authority
|
||||
finally:
|
||||
_ACTIVE.reset(token)
|
||||
|
||||
|
||||
def _request_text(messages):
|
||||
for message in reversed(messages or ()):
|
||||
metadata = message.get("metadata") or {}
|
||||
if (message.get("role") != "user" or metadata.get("trusted") is False
|
||||
or metadata.get("tool_gate_untrusted")):
|
||||
continue
|
||||
content = message.get("content", "")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "\n".join(p.get("text", "") for p in content
|
||||
if isinstance(p, dict) and p.get("type") == "text")
|
||||
return ""
|
||||
|
||||
|
||||
def with_request_authority(func):
|
||||
"""Bind once per invocation; model rounds/fallbacks never recreate grants."""
|
||||
call_signature = signature(func)
|
||||
@wraps(func)
|
||||
async def wrapped(*args, **kwargs):
|
||||
bound = call_signature.bind(*args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
parameters = bound.arguments
|
||||
parent = active_request_authority()
|
||||
authority = parameters.get("request_authority", MISSING_AUTHORITY)
|
||||
if authority is MISSING_AUTHORITY:
|
||||
approval = parameters.get("exact_approval")
|
||||
if parent is not None:
|
||||
authority = parent
|
||||
elif approval is not None:
|
||||
authority = approval.pending.request_authority or RequestAuthority.empty(
|
||||
owner=parameters.get("owner"), session_id=parameters.get("session_id"),
|
||||
workspace=parameters.get("workspace"))
|
||||
elif (parameters.get("_parent_run_id") or parameters.get("_is_teacher_run")
|
||||
or parameters.get("workload") == "background"):
|
||||
authority = RequestAuthority.empty(owner=parameters.get("owner"),
|
||||
session_id=parameters.get("session_id"), workspace=parameters.get("workspace"))
|
||||
else:
|
||||
authority = create_request_authority(_request_text(parameters.get("messages")),
|
||||
owner=parameters.get("owner"), session_id=parameters.get("session_id"),
|
||||
workspace=parameters.get("workspace"),
|
||||
history=getattr(parameters.get("history_session"), "history", ()) or (),
|
||||
active_document=bool(parameters.get("active_document")))
|
||||
if not isinstance(authority, RequestAuthority):
|
||||
raise TypeError("Missing or malformed server request authority")
|
||||
if parent is None and parameters.get("exact_approval") is not None:
|
||||
authority = replace(authority, inherited=False)
|
||||
authority = authority.restrict(parameters.get("tool_policy"), parameters.get("disabled_tools"))
|
||||
with bind_request_authority(authority) as effective:
|
||||
if "request_authority" in parameters:
|
||||
parameters["request_authority"] = effective
|
||||
async with aclosing(func(*bound.args, **bound.kwargs)) as stream:
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
return wrapped
|
||||
|
||||
|
||||
def task_operation(task_type, action, prompt):
|
||||
if task_type == "action":
|
||||
tool = ("bash" if action in {"run_local", "run_script", "ssh_command"}
|
||||
else "serve_model" if action == "cookbook_serve" else "scheduled__" + str(action))
|
||||
return ExactOperation.normalize(tool, str(prompt or ""))
|
||||
if task_type == "research":
|
||||
return ExactOperation.normalize("trigger_research", str(prompt or ""))
|
||||
return None
|
||||
|
||||
|
||||
def seal_task_authority(prompt, task_type, action, *, owner=None, parent_authority=MISSING_AUTHORITY):
|
||||
"""Only direct ingress grants; a model-created task is capped by its parent."""
|
||||
operation = task_operation(task_type, action, prompt)
|
||||
authority = create_request_authority(str(prompt or ""), owner=owner)
|
||||
if operation is not None:
|
||||
authority = replace(authority, grants=(OperationGrant(operation.tool,
|
||||
inputs=frozenset({operation.input})),))
|
||||
parent = active_request_authority() if parent_authority is MISSING_AUTHORITY else parent_authority
|
||||
if parent_authority is None:
|
||||
parent = RequestAuthority.empty(owner=owner)
|
||||
if parent is not None:
|
||||
authority = parent.intersect(replace(authority, session_id=parent.session_id,
|
||||
workspace=parent.workspace))
|
||||
return _json({"task_input": [prompt, task_type, action], "authority": authority.to_dict()})
|
||||
|
||||
|
||||
def restore_task_authority(snapshot, prompt, task_type, action, *, owner=None, session_id=None):
|
||||
try:
|
||||
value = json.loads(snapshot)
|
||||
if value["task_input"] != [prompt, task_type, action]:
|
||||
raise ValueError("Scheduled request changed")
|
||||
return RequestAuthority.from_dict(value["authority"]).continuation(owner=owner, session_id=session_id)
|
||||
except (ValueError, TypeError, KeyError, AttributeError):
|
||||
return RequestAuthority.empty(owner=owner, session_id=session_id)
|
||||
|
||||
|
||||
def _background_path(job_id):
|
||||
if not isinstance(job_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", job_id):
|
||||
raise ValueError("Invalid background authority identity")
|
||||
from src.constants import BG_JOBS_DIR
|
||||
return Path(BG_JOBS_DIR) / (job_id + ".authority.json")
|
||||
|
||||
|
||||
def save_background_authority(job_id, authority):
|
||||
from core.atomic_io import atomic_write_json
|
||||
atomic_write_json(_background_path(job_id), authority.to_dict())
|
||||
|
||||
|
||||
def restore_background_authority(job_id, *, owner=None, session_id=None):
|
||||
try:
|
||||
authority = RequestAuthority.from_dict(json.loads(_background_path(job_id).read_text()))
|
||||
if authority.session_id != str(session_id or ""):
|
||||
raise ValueError("Background session changed")
|
||||
return authority.continuation(owner=owner, session_id=session_id)
|
||||
except (OSError, ValueError, TypeError, KeyError, AttributeError):
|
||||
return RequestAuthority.empty(owner=owner, session_id=session_id)
|
||||
Reference in New Issue
Block a user