Files
odysseus/src/tool_approvals.py
T

622 lines
25 KiB
Python

"""Opaque exact-action approvals with explicit task and chat scopes.
The server still seals and claims the first displayed action exactly once. The
selected scope then bypasses only the automatic post-external-context approval
gate for the rest of the resumed task or chat session. Browser-visible fields
are display copies, never authority.
"""
from __future__ import annotations
import hashlib
import json
import os
import secrets
import threading
import time
from dataclasses import dataclass, field
from typing import Any, TYPE_CHECKING
from src.tool_approval_scopes import (
CHAT_SESSION_APPROVAL_DECISION,
DENY_APPROVAL_DECISION,
TASK_APPROVAL_DECISION,
ToolApprovalScope,
scope_for_decision,
)
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
from src.agent_runtime.authority import RequestAuthority
if TYPE_CHECKING:
from src.agent_runtime.resource_binding import BoundFilesystemOperation
from src.agent_runtime.remote_resources import BoundBackendOperation
from src.agent_runtime.owned_resources import BoundOwnedOperation
from src.agent_runtime.process_resources import BoundProcessOperation
from src.browser_identity import BoundBrowserOperation
DEFAULT_APPROVAL_TTL_SECONDS = 10 * 60
DEFAULT_MAX_PENDING_APPROVALS = 2048
def _normalized_owner(owner: Any) -> str:
return str(owner or "").strip().casefold()
def _normalized_workspace(workspace: Any) -> str:
if not isinstance(workspace, str) or not workspace.strip():
return ""
return os.path.realpath(os.path.expanduser(workspace))
_MAX_APPROVAL_SELECTED_TOOLS = 512
_MAX_APPROVAL_TOOL_NAME_CHARS = 512
_MAX_APPROVAL_CONTINUATION_QUERY_CHARS = 4000
def _normalized_selected_tools(
selected_tools: Any,
*,
required_tool: Any = None,
) -> tuple[str, ...]:
if isinstance(selected_tools, str):
selected_tools = (selected_tools,)
try:
values = selected_tools or ()
names = {
name.strip()
for name in values
if (
isinstance(name, str)
and name.strip()
and len(name.strip()) <= _MAX_APPROVAL_TOOL_NAME_CHARS
)
}
required_name = str(required_tool or "").strip()
if required_name and len(required_name) <= _MAX_APPROVAL_TOOL_NAME_CHARS:
names.add(required_name)
ordered = sorted(names)
if len(ordered) <= _MAX_APPROVAL_SELECTED_TOOLS:
return tuple(ordered)
kept = ordered[:_MAX_APPROVAL_SELECTED_TOOLS]
if required_name and required_name in names and required_name not in kept:
kept[-1] = required_name
kept.sort()
return tuple(kept)
except TypeError:
return ()
def _normalized_continuation_query(value: Any) -> str:
# The query is server-derived from the interrupted run and already lives in
# session history. Keep the pending copy bounded because approvals are held
# in memory until consumed or expired.
return str(value or "").strip()[:_MAX_APPROVAL_CONTINUATION_QUERY_CHARS]
def _canonical_digest(payload: dict[str, Any]) -> str:
encoded = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def document_content_digest(content: Any) -> str:
"""Return the stable server-side fingerprint used to seal a document."""
return hashlib.sha256(str(content or "").encode("utf-8")).hexdigest()
def _binding_payload(
*,
owner: Any,
session_id: Any,
origin_run_id: Any,
tool_name: Any,
content: Any,
workspace: Any,
document_id: Any,
document_version: Any,
document_digest: Any,
external_untrusted_context_seen: bool,
selected_tools: Any,
continuation_query: Any,
effects: tuple[str, ...],
result_integrity: str,
request_authority: RequestAuthority | None = None,
resource_operation=None,
backend_operation=None,
owned_operation=None,
process_operation=None,
browser_operation=None,
) -> dict[str, Any]:
return {
"owner": _normalized_owner(owner),
"session_id": str(session_id or ""),
"origin_run_id": str(origin_run_id or ""),
"tool_name": str(tool_name or ""),
"content": str(content or ""),
"workspace": _normalized_workspace(workspace),
"document_id": str(document_id or ""),
"document_version": (
int(document_version) if document_version is not None else None
),
"document_digest": str(document_digest or "").strip().lower(),
"external_untrusted_context_seen": bool(external_untrusted_context_seen),
"selected_tools": list(
_normalized_selected_tools(selected_tools, required_tool=tool_name)
),
"continuation_query": _normalized_continuation_query(continuation_query),
"effects": list(effects),
"result_integrity": str(result_integrity),
"request_authority": request_authority.to_dict() if request_authority is not None else None,
"resource_operation": resource_operation.to_dict() if resource_operation is not None else None,
"backend_operation": backend_operation.to_dict() if backend_operation is not None else None,
"owned_operation": owned_operation.to_dict() if owned_operation is not None else None,
"process_operation": process_operation.to_dict() if process_operation is not None else None,
"browser_operation": browser_operation.to_dict() if browser_operation is not None else None,
}
@dataclass(frozen=True)
class PendingToolApproval:
approval_id: str
owner: str
session_id: str
origin_run_id: str
tool_name: str
content: str
workspace: str
document_id: str
document_version: int | None
document_digest: str
external_untrusted_context_seen: bool
effects: tuple[str, ...]
result_integrity: str
digest: str
created_at: float
expires_at: float
# Server-only continuation state. Both fields are digest-bound and never
# exposed in the browser payload.
selected_tools: tuple[str, ...] = ()
continuation_query: str = ""
# The originating user request is internal continuation context only; it
# is never displayed or treated as authorization for the sealed action.
request_text: str = ""
request_authority: RequestAuthority | None = None
# Server-resolved targets at proposal time; never read from the approval UI.
resource_operation: BoundFilesystemOperation | None = None
backend_operation: BoundBackendOperation | None = None
owned_operation: BoundOwnedOperation | None = None
process_operation: BoundProcessOperation | None = None
browser_operation: BoundBrowserOperation | None = None
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
return {
"kind": "tool_approval",
"approval_id": self.approval_id,
# The browser already owns this chat id. Persisting it with the
# resolved card lets history-derived session grants remain bound to
# this exact chat and prevents inheritance by a forked session.
"session_id": self.session_id,
"question": "Allow this task to continue?",
"description": reason or (
"Untrusted context influenced this run, so continuing with "
"otherwise-gated actions needs your explicit approval."
),
"options": [
{
"label": "Allow for this task",
"value": TASK_APPROVAL_DECISION,
"description": (
"Execute the sealed action and allow every otherwise-gated "
"action needed to finish this request. Current tool, account, "
"workspace, and sandbox restrictions still apply."
),
},
{
"label": "Allow for this chat session",
"value": CHAT_SESSION_APPROVAL_DECISION,
"description": (
"Execute the sealed action and stop asking at this gate for "
"later requests in this chat. Current tool, account, workspace, "
"and sandbox restrictions still apply."
),
},
{
"label": "Deny",
"value": DENY_APPROVAL_DECISION,
"description": "Do not execute the proposed action.",
},
],
"action": {
"tool": self.tool_name,
# Show the complete sealed input so approval never hides
# trailing lines. This is not read back as authority.
"content": self.content,
"digest": self.digest[:16],
"effects": list(self.effects),
"workspace": self.workspace or None,
"document_id": self.document_id or None,
"document_version": self.document_version,
},
}
@dataclass
class ExactToolApproval:
"""A consumed exact first action plus an explicit continuation scope."""
pending: PendingToolApproval
scope: ToolApprovalScope = ToolApprovalScope.TASK
# The seam consumed by agent_loop. Both chat-card allow choices cover the
# complete resumed task, because one-action scope there immediately
# re-entered the same gate on the next round. Callers with no resumable
# chat still get SINGLE_ACTION, which leaves the gate armed behind the
# sealed action.
allow_remaining_actions: bool = True
_claimed: bool = field(default=False, init=False, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
@property
def grants_chat_session(self) -> bool:
return self.scope is ToolApprovalScope.CHAT_SESSION
def _matches_unlocked(
self,
*,
owner: Any,
session_id: Any,
tool_name: Any,
content: Any,
workspace: Any,
) -> bool:
if self._claimed:
return False
capabilities = capabilities_for_action(tool_name, content)
effects = tuple(sorted(effect.value for effect in capabilities.effects))
result_integrity = capabilities.result_integrity.value
if (
effects != self.pending.effects
or result_integrity != self.pending.result_integrity
):
return False
expected = _binding_payload(
owner=owner,
session_id=session_id,
origin_run_id=self.pending.origin_run_id,
tool_name=tool_name,
content=content,
workspace=workspace,
document_id=self.pending.document_id,
document_version=self.pending.document_version,
document_digest=self.pending.document_digest,
external_untrusted_context_seen=(
self.pending.external_untrusted_context_seen
),
selected_tools=self.pending.selected_tools,
continuation_query=self.pending.continuation_query,
effects=effects,
result_integrity=result_integrity,
request_authority=self.pending.request_authority,
resource_operation=self.pending.resource_operation,
backend_operation=self.pending.backend_operation,
owned_operation=self.pending.owned_operation,
process_operation=self.pending.process_operation,
browser_operation=self.pending.browser_operation,
)
return _canonical_digest(expected) == self.pending.digest
def matches(
self,
*,
owner: Any,
session_id: Any,
tool_name: Any,
content: Any,
workspace: Any,
) -> bool:
with self._lock:
return self._matches_unlocked(
owner=owner,
session_id=session_id,
tool_name=tool_name,
content=content,
workspace=workspace,
)
def claim(
self,
*,
owner: Any,
session_id: Any,
tool_name: Any,
content: Any,
workspace: Any,
) -> bool:
with self._lock:
if not self._matches_unlocked(
owner=owner,
session_id=session_id,
tool_name=tool_name,
content=content,
workspace=workspace,
):
return False
self._claimed = True
return True
class ToolApprovalStore:
"""Thread-safe pending approval registry with destructive consumption."""
def __init__(
self,
*,
ttl_seconds: int = DEFAULT_APPROVAL_TTL_SECONDS,
max_pending: int = DEFAULT_MAX_PENDING_APPROVALS,
):
self._ttl_seconds = max(1, int(ttl_seconds))
self._max_pending = max(1, int(max_pending))
self._pending: dict[str, PendingToolApproval] = {}
self._lock = threading.Lock()
def _purge_expired_locked(self, now: float) -> None:
expired = [
approval_id
for approval_id, pending in self._pending.items()
if pending.expires_at <= now
]
for approval_id in expired:
self._pending.pop(approval_id, None)
def create(
self,
*,
owner: Any,
session_id: Any,
origin_run_id: Any,
tool_name: Any,
content: Any,
workspace: Any,
document_id: Any = None,
document_version: Any = None,
document_digest: Any = None,
selected_tools: Any = None,
continuation_query: Any = None,
external_untrusted_context_seen: bool,
capabilities: ToolCapabilities,
request_text: Any = "",
request_authority: RequestAuthority | None = None,
client_runtime_context: dict | None = None,
) -> PendingToolApproval:
if request_authority is not None and not isinstance(request_authority, RequestAuthority):
raise TypeError("Approval authority must be server-owned RequestAuthority")
now = time.time()
from src.agent_runtime.authority import ExactOperation
from src.agent_runtime.resource_binding import NATIVE_FILESYSTEM_TOOLS, resolve_filesystem_operation
from src.agent_runtime.resources import FilesystemRoot
resource_operation = None
backend_operation = None
owned_operation = None
process_operation = None
browser_operation = None
from src.agent_runtime.remote_resources import BoundBackendOperation, resolve_backend
from src.agent_runtime.owned_resources import needs_owned_binding, resolve_owned_operation
from src.agent_runtime.resources import NativeBackendResource
try:
operation = ExactOperation.normalize(tool_name, content)
backend = resolve_backend(operation.transport_tool, context=client_runtime_context, content=operation.input, owner=_normalized_owner(owner))
if request_authority is not None and request_authority.inherited:
if not request_authority.permits(operation) or backend not in request_authority.backend_resources:
raise ValueError("Child approval exceeds originating authority")
backend_operation = BoundBackendOperation(backend,
request_authority.request_id if request_authority is not None else "",
_normalized_owner(owner), str(session_id or ""), operation.transport_tool, operation.input)
from src.agent_runtime.process_resources import needs_process_binding, resolve_process_operation
if request_authority is not None and needs_process_binding(operation, backend):
process_operation = resolve_process_operation(request_authority, operation, backend)
from src.browser_identity import native_browser, resolve_browser_operation
if native_browser(operation, backend):
if request_authority is None:
raise ValueError("Browser approval requires originating resource authority")
browser_operation = resolve_browser_operation(request_authority, operation)
if isinstance(backend, NativeBackendResource) and needs_owned_binding(operation):
resolved_owned = resolve_owned_operation(operation, owner=_normalized_owner(owner),
thread_id=str(session_id or ""), request_id=backend_operation.request_id,
document_id=document_id)
if request_authority is not None and request_authority.inherited:
if not all(any(scope.permits(r) for scope in request_authority.owned_scopes) for r in resolved_owned.resources):
raise ValueError("Child approval exceeds originating record scope")
owned_operation = resolved_owned
if owned_operation.document_id:
document_id = owned_operation.document_id
document_version = owned_operation.document_version
document_digest = owned_operation.document_digest
except (ValueError, TypeError, OSError, RuntimeError, AttributeError):
pass # Unresolved proposals are never reconstructed at execution.
if tool_name in NATIVE_FILESYSTEM_TOOLS and backend_operation is not None and isinstance(backend_operation.resource, NativeBackendResource):
try:
roots = request_authority.resource_roots if request_authority is not None else ()
if not roots and workspace and (request_authority is None or not request_authority.inherited):
roots = (FilesystemRoot.seal(workspace, owner=_normalized_owner(owner)),)
resource_operation = resolve_filesystem_operation(
ExactOperation.normalize(tool_name, content), roots=roots, workspace=workspace or "",
request_id=request_authority.request_id if request_authority is not None else "")
except (ValueError, TypeError, OSError, RuntimeError):
# An unresolved proposal may be displayed, but it cannot execute
# after approval by reconstructing its targets at claim time.
pass
effects = tuple(sorted(effect.value for effect in capabilities.effects))
result_integrity = capabilities.result_integrity.value
payload = _binding_payload(
owner=owner,
session_id=session_id,
origin_run_id=origin_run_id,
tool_name=tool_name,
content=content,
workspace=workspace,
document_id=document_id,
document_version=document_version,
document_digest=document_digest,
external_untrusted_context_seen=external_untrusted_context_seen,
selected_tools=selected_tools,
continuation_query=continuation_query,
effects=effects,
result_integrity=result_integrity,
request_authority=request_authority,
resource_operation=resource_operation,
backend_operation=backend_operation,
owned_operation=owned_operation,
process_operation=process_operation,
browser_operation=browser_operation,
)
pending = PendingToolApproval(
approval_id=secrets.token_urlsafe(32),
owner=payload["owner"],
session_id=payload["session_id"],
origin_run_id=payload["origin_run_id"],
tool_name=payload["tool_name"],
content=payload["content"],
workspace=payload["workspace"],
document_id=payload["document_id"],
document_version=payload["document_version"],
document_digest=payload["document_digest"],
external_untrusted_context_seen=payload[
"external_untrusted_context_seen"
],
effects=effects,
result_integrity=result_integrity,
digest=_canonical_digest(payload),
created_at=now,
expires_at=now + self._ttl_seconds,
selected_tools=tuple(payload["selected_tools"]),
continuation_query=payload["continuation_query"],
request_text=str(request_text or ""),
request_authority=request_authority,
resource_operation=resource_operation,
backend_operation=backend_operation,
owned_operation=owned_operation,
process_operation=process_operation,
browser_operation=browser_operation,
)
with self._lock:
self._purge_expired_locked(now)
# The chat UI exposes one pending card per session, so supersede an
# older action there. Headless/manual-test callers use an empty
# session id; keep independent origin runs separate so two skill
# tests owned by the same user cannot invalidate each other.
superseded = [
approval_id
for approval_id, existing in self._pending.items()
if (
existing.owner == pending.owner
and existing.session_id == pending.session_id
and (
bool(pending.session_id)
or existing.origin_run_id == pending.origin_run_id
)
)
]
for approval_id in superseded:
self._pending.pop(approval_id, None)
while len(self._pending) >= self._max_pending:
oldest_id = min(
self._pending,
key=lambda approval_id: self._pending[approval_id].created_at,
)
self._pending.pop(oldest_id, None)
self._pending[pending.approval_id] = pending
return pending
def consume(
self,
approval_id: Any,
*,
decision: Any,
owner: Any,
session_id: Any,
allow_continuation: bool = True,
) -> ExactToolApproval | None:
"""Consume a pending approval.
``allow_continuation`` is the caller's assertion that it owns a
resumable conversation the granted scope can apply to. Callers without
one (the skill tester, unattended audits) pass ``False`` and get the
original one-use grant, so a button labelled "Allow once" cannot widen
into a run-long bypass just because the chat card reuses the same wire
value.
"""
now = time.time()
with self._lock:
self._purge_expired_locked(now)
approval_key = str(approval_id or "")
pending = self._pending.get(approval_key)
if pending is None:
return None
if (
pending.owner != _normalized_owner(owner)
or pending.session_id != str(session_id or "")
):
# Authentication is checked before destructive consumption so
# a leaked/guessed opaque id cannot be used to invalidate
# another owner's pending action.
return None
self._pending.pop(approval_key, None)
normalized_decision = str(decision or "").strip().lower()
scope = scope_for_decision(normalized_decision)
if scope is None:
return None
if not allow_continuation:
return ExactToolApproval(
pending,
scope=ToolApprovalScope.SINGLE_ACTION,
allow_remaining_actions=False,
)
return ExactToolApproval(
pending,
scope=scope,
allow_remaining_actions=True,
)
def peek(self, approval_id: Any) -> PendingToolApproval | None:
now = time.time()
with self._lock:
self._purge_expired_locked(now)
return self._pending.get(str(approval_id or ""))
def retire_for_session(self, *, owner: Any, session_id: Any) -> bool:
"""Discard pending actions superseded by an ordinary user turn.
Returns whether any retired action carried external provenance, so the
caller can preserve that security state without treating the new user
message as an approval continuation.
"""
now = time.time()
normalized_owner = _normalized_owner(owner)
normalized_session = str(session_id or "")
if not normalized_session:
return False
with self._lock:
self._purge_expired_locked(now)
retired_ids = [
approval_id
for approval_id, pending in self._pending.items()
if (
pending.owner == normalized_owner
and pending.session_id == normalized_session
)
]
carried_taint = any(
self._pending[approval_id].external_untrusted_context_seen
for approval_id in retired_ids
)
for approval_id in retired_ids:
self._pending.pop(approval_id, None)
return carried_taint
tool_approval_store = ToolApprovalStore()