diff --git a/CHANGELOG.md b/CHANGELOG.md index 458b04d..1c63efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ This is a pre-1.0 breaking Runtime cutover. +- Runtime Workers now accept and strictly validate Core-owned standard and + Browser authority envelopes, expose Browser interaction policy and canonical + mutation-origin evidence to handlers, and reject tampered authority fields. - Added Browser interaction policy, policy generation, canonical mutation origins, origin digest, and Browser contract evidence to `RunResponse` and the public Core client contract fixture. diff --git a/src/openlinker/runtime/types.py b/src/openlinker/runtime/types.py index 0d3b15b..7a2a3da 100644 --- a/src/openlinker/runtime/types.py +++ b/src/openlinker/runtime/types.py @@ -120,6 +120,11 @@ class RuntimeAuthority: runtime_session_id: str runtime_session_epoch: int runtime_attachment_id: str + execution_profile: str = "" + browser_interaction_policy: str = "" + browser_interaction_policy_generation: int = 0 + browser_mutation_origins: tuple[str, ...] = () + browser_mutation_origins_sha256: str = "" @dataclass(frozen=True) diff --git a/src/openlinker/runtime/worker.py b/src/openlinker/runtime/worker.py index e274739..65f4154 100644 --- a/src/openlinker/runtime/worker.py +++ b/src/openlinker/runtime/worker.py @@ -2,7 +2,9 @@ import asyncio import hashlib +import ipaddress import inspect +import json import logging import random import time @@ -13,6 +15,7 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Protocol +from urllib.parse import urlsplit from websockets.exceptions import ConnectionClosed @@ -2642,6 +2645,15 @@ def _canonical_uuid(value: str, label: str) -> None: _RUNTIME_AUTHORITY_METADATA_KEY = "_openlinker_runtime_authority" +_RUNTIME_AUTHORITY_KEYS = { + "principal_scope_id", + "source", + "execution_profile", + "browser_interaction_policy", + "browser_interaction_policy_generation", + "browser_mutation_origins", + "browser_mutation_origins_sha256", +} def _runtime_authority_from_metadata( @@ -2658,7 +2670,7 @@ def _runtime_authority_from_metadata( if ( ready is None or not isinstance(raw, dict) - or set(raw) != {"principal_scope_id", "source"} + or not set(raw).issubset(_RUNTIME_AUTHORITY_KEYS) or raw.get("source") != "core" or not isinstance(raw.get("principal_scope_id"), str) or session_epoch < 1 @@ -2671,6 +2683,7 @@ def _runtime_authority_from_metadata( _canonical_runtime_principal_scope(principal_scope_id) _canonical_uuid(runtime_session_id, "runtime_session_id") _canonical_uuid(ready.attachment_id, "runtime_attachment_id") + authority = _validated_runtime_authority_fields(raw) except ValueError as exc: raise _RuntimeAssignmentAuthorityError( "assignment Runtime authority is invalid" @@ -2680,9 +2693,120 @@ def _runtime_authority_from_metadata( runtime_session_id=runtime_session_id, runtime_session_epoch=session_epoch, runtime_attachment_id=ready.attachment_id, + **authority, ) +def _validated_runtime_authority_fields(raw: dict[str, Any]) -> dict[str, Any]: + execution_profile = raw.get("execution_profile", "") + policy = raw.get("browser_interaction_policy", "") + generation = raw.get("browser_interaction_policy_generation", 0) + origins = raw.get("browser_mutation_origins") + digest = raw.get("browser_mutation_origins_sha256", "") + if execution_profile in {"", "standard"}: + if any( + key in raw + for key in ( + "browser_interaction_policy", + "browser_interaction_policy_generation", + "browser_mutation_origins", + "browser_mutation_origins_sha256", + ) + ): + raise ValueError("standard Runtime authority contains Browser fields") + return { + "execution_profile": execution_profile, + "browser_interaction_policy": "", + "browser_interaction_policy_generation": 0, + "browser_mutation_origins": (), + "browser_mutation_origins_sha256": "", + } + if ( + execution_profile != "browser" + or policy not in {"restricted", "full"} + or isinstance(generation, bool) + or not isinstance(generation, int) + or generation < 1 + or not isinstance(origins, list) + or len(origins) > 32 + or any(not isinstance(origin, str) for origin in origins) + or not isinstance(digest, str) + or len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + ): + raise ValueError("Browser Runtime authority is invalid") + canonical = sorted(_canonical_runtime_browser_mutation_origin(origin) for origin in origins) + if ( + canonical != origins + or len(set(canonical)) != len(canonical) + or (policy == "restricted" and canonical) + or (policy == "full" and not canonical) + or hashlib.sha256( + json.dumps(canonical, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + ).hexdigest() + != digest + ): + raise ValueError("Browser Runtime authority is not canonical") + return { + "execution_profile": execution_profile, + "browser_interaction_policy": policy, + "browser_interaction_policy_generation": generation, + "browser_mutation_origins": tuple(canonical), + "browser_mutation_origins_sha256": digest, + } + + +def _canonical_runtime_browser_mutation_origin(raw: str) -> str: + if not raw or raw.strip() != raw or "%" in raw: + raise ValueError("Browser mutation origin is invalid") + try: + parsed = urlsplit(raw) + host = parsed.hostname + port = parsed.port + except ValueError as exc: + raise ValueError("Browser mutation origin is invalid") from exc + if ( + parsed.scheme != "https" + or parsed.username is not None + or parsed.password is not None + or not host + or parsed.path + or parsed.query + or parsed.fragment + or host.endswith(".") + ): + raise ValueError("Browser mutation origin is invalid") + try: + address = ipaddress.ip_address(host) + except ValueError: + try: + canonical_host = host.lower().encode("idna").decode("ascii") + except UnicodeError as exc: + raise ValueError("Browser mutation origin host is invalid") from exc + if len(canonical_host) > 253: + raise ValueError("Browser mutation origin host is invalid") + labels = canonical_host.split(".") + if any( + not label + or len(label) > 63 + or label.startswith("-") + or label.endswith("-") + or any(character not in "abcdefghijklmnopqrstuvwxyz0123456789-" for character in label) + for label in labels + ): + raise ValueError("Browser mutation origin host is invalid") + else: + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None: + raise ValueError("IPv4-mapped IPv6 Browser origins are invalid") + canonical_host = f"[{address.compressed}]" if address.version == 6 else address.compressed + if port == 443: + port = None + canonical = f"https://{canonical_host}{f':{port}' if port else ''}" + if raw != canonical: + raise ValueError("Browser mutation origin must already be canonical") + return canonical + + def _canonical_runtime_principal_scope(value: str) -> None: allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._:-" if ( diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 195a06f..b305c9d 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +import hashlib +import json from collections.abc import Awaitable, Callable from datetime import datetime, timedelta, timezone from pathlib import Path @@ -451,6 +453,7 @@ async def test_assignment_is_durable_and_confirmed_before_handler_runs(): "_openlinker_runtime_authority": { "principal_scope_id": "ps1_" + ("A" * 43), "source": "core", + "execution_profile": "standard", } } ) @@ -464,6 +467,7 @@ async def handler(context: runtime.RuntimeContext) -> dict[str, Any]: runtime_session_id=store.identity.runtime_session_id, runtime_session_epoch=store.identity.session_epoch, runtime_attachment_id=ATTACHMENT_ID, + execution_profile="standard", ) handler_started.set() await context.emit("run.progress", {"step": 1}) @@ -484,6 +488,42 @@ async def handler(context: runtime.RuntimeContext) -> dict[str, Any]: assert transport.session_closed +def test_browser_runtime_authority_is_validated_and_exposed(): + origins = ["https://github.com", "https://openlinker.ai"] + digest = hashlib.sha256( + json.dumps(origins, separators=(",", ":")).encode("utf-8") + ).hexdigest() + metadata, authority = runtime_worker_module._runtime_authority_from_metadata( + { + "keep": "ordinary", + "_openlinker_runtime_authority": { + "principal_scope_id": "ps1_" + ("A" * 43), + "source": "core", + "execution_profile": "browser", + "browser_interaction_policy": "full", + "browser_interaction_policy_generation": 7, + "browser_mutation_origins": origins, + "browser_mutation_origins_sha256": digest, + }, + }, + 3, + "99999999-9999-4999-8999-999999999999", + ready(), + ) + assert metadata == {"keep": "ordinary"} + assert authority == runtime.RuntimeAuthority( + principal_scope_id="ps1_" + ("A" * 43), + runtime_session_id="99999999-9999-4999-8999-999999999999", + runtime_session_epoch=3, + runtime_attachment_id=ATTACHMENT_ID, + execution_profile="browser", + browser_interaction_policy="full", + browser_interaction_policy_generation=7, + browser_mutation_origins=tuple(origins), + browser_mutation_origins_sha256=digest, + ) + + @pytest.mark.asyncio async def test_malformed_runtime_authority_is_rejected_before_handler(): store = runtime.MemoryRuntimeStore() @@ -491,10 +531,15 @@ async def test_malformed_runtime_authority_is_rejected_before_handler(): transport.assignment = assignment(store) transport.assignment.metadata.update( { - "_openlinker_runtime_authority": { - "principal_scope_id": "not/an/opaque-id", + "_openlinker_runtime_authority": { + "principal_scope_id": "ps1_" + ("A" * 43), "source": "core", - } + "execution_profile": "browser", + "browser_interaction_policy": "full", + "browser_interaction_policy_generation": 1, + "browser_mutation_origins": ["https://github.com"], + "browser_mutation_origins_sha256": "0" * 64, + }, } ) handler_calls = 0