From 010da48403701d546899e657c4d93a62af7fa7aa Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Wed, 22 Jul 2026 22:07:30 +0700 Subject: [PATCH 01/20] fix(core): reject malformed governance responses --- openbox_core/client.py | 13 ++- openbox_core/contracts/results.py | 110 +++++++++++++++++++++++-- openbox_core/errors.py | 9 +- tests/client/test_fail_modes.py | 25 +++++- tests/contracts/test_result_parsing.py | 79 ++++++++++++++++++ 5 files changed, 214 insertions(+), 22 deletions(-) diff --git a/openbox_core/client.py b/openbox_core/client.py index 41ec0db..104d19b 100644 --- a/openbox_core/client.py +++ b/openbox_core/client.py @@ -9,8 +9,9 @@ - Signed requests send ``content=body_bytes`` — NEVER ``json=`` (client-side re-serialization breaks Core's body-hash verification). - ``httpx`` is imported lazily so this module never taints pure import paths. -- Fail modes apply to NETWORK errors only — contract violations raise before - any send (see gate.py) and are never converted to fail-open ALLOWs: +- Fail modes apply to NETWORK errors only — SDK input contract violations raise + before send and malformed successful Core responses raise after receive; neither + is converted to a fail-open ALLOW: * fail_open (default): return allow-shaped ``EvaluationResult`` with ``fallback_used=True`` (callers can tell it apart from a policy ALLOW). * fail_closed: raise ``GovernanceAPIError`` (adapters map to native @@ -200,13 +201,9 @@ async def aevaluate(self, payload: dict) -> EvaluationResult: return self._parse_evaluate_response(response) def _parse_evaluate_response(self, response: Any) -> EvaluationResult: - if response.status_code >= 400: + if not 200 <= response.status_code < 300: return self._network_failure(f"Governance API error: HTTP {response.status_code}") - try: - data = response.json() - except Exception as e: - return self._network_failure(f"Governance API returned unparseable body: {e}") - result = EvaluationResult.from_dict(data) + result = EvaluationResult.from_wire(response.content) if result.verdict.should_stop(): logger.info(f"Governance blocked: {result.reason} (policy: {result.policy_id})") return result diff --git a/openbox_core/contracts/results.py b/openbox_core/contracts/results.py index 59fe953..50ac05f 100644 --- a/openbox_core/contracts/results.py +++ b/openbox_core/contracts/results.py @@ -1,20 +1,21 @@ """Result contracts — Verdict, GuardrailsResult, EvaluationResult, ApprovalResult. Pure, import-safe module: no network, crypto, OTel, logging, wall-clock, or -random. Strict dataclass constructors AND loose ``from_dict()`` parsers are -both public so callers can work with typed values or raw backend dicts. - -Parsing preserves ``raw`` so nothing the backend sent is ever lost, and stays -tolerant of unknown keys (field-shape drift from Core must not crash SDKs). +random. Successful Core responses use strict ``from_wire()`` parsing; +``from_dict()`` remains a loose compatibility parser for explicit callers. +Both preserve unknown keys in ``raw``. """ from __future__ import annotations +import json import math from dataclasses import dataclass, field from enum import Enum from typing import Any +from ..errors import ContractError + __all__ = [ "Verdict", "GuardrailsResult", @@ -187,6 +188,77 @@ def _parse_patch(container: dict[str, Any]) -> Patch | None: return Patch(new_input=new_input) +_WIRE_VERDICTS = {value.value: value for value in Verdict} +_WIRE_ACTIONS = { + **_WIRE_VERDICTS, + "continue": Verdict.ALLOW, + "stop": Verdict.HALT, + "require-approval": Verdict.REQUIRE_APPROVAL, + "request_approval": Verdict.REQUIRE_APPROVAL, + "request-approval": Verdict.REQUIRE_APPROVAL, +} + + +def _wire_error() -> ContractError: + return ContractError("Malformed governance response", code="RESPONSE_INVALID") + + +def _strict_wire_object(body: bytes) -> dict[str, Any]: + if not isinstance(body, bytes): + raise _wire_error() + + def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError + result[key] = value + return result + + def finite_float(value: str) -> float: + result = float(value) + if not math.isfinite(result): + raise ValueError + return result + + def reject_constant(_: str) -> None: + raise ValueError + + try: + value = json.loads( + body.decode("utf-8", errors="strict"), + object_pairs_hook=unique_object, + parse_constant=reject_constant, + parse_float=finite_float, + ) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError): + raise _wire_error() from None + if not isinstance(value, dict): + raise _wire_error() + return value + + +def _wire_decision( + data: dict[str, Any], field_name: str, vocabulary: dict[str, Verdict] +) -> Verdict | None: + if field_name not in data: + return None + value = data[field_name] + if not isinstance(value, str) or value not in vocabulary: + raise _wire_error() + return vocabulary[value] + + +def _valid_wire_constraints(value: Any) -> bool: + if isinstance(value, dict): + return True + if not isinstance(value, list): + return False + return all(isinstance(item, str) for item in value) or all( + isinstance(item, dict) for item in value + ) + + @dataclass class EvaluationResult: """Response from a governance evaluation. @@ -211,7 +283,7 @@ class EvaluationResult: trust_tier: str | None = None alignment_score: float | None = None behavioral_violations: list[str] | None = None - constraints: list[dict[str, Any]] | None = None + constraints: list[dict[str, Any]] | list[str] | dict[str, Any] | None = None fallback_used: bool = False # True when fail-open produced this result diagnostics: list[Any] = field(default_factory=list) raw: dict[str, Any] = field(default_factory=dict) @@ -269,6 +341,32 @@ def from_dict(cls, data: dict[str, Any]) -> EvaluationResult: patch=_parse_patch(data), ) + @classmethod + def from_wire(cls, body: bytes) -> EvaluationResult: + """Strictly parse one successful Core evaluation response.""" + data = _strict_wire_object(body) + verdict = _wire_decision(data, "verdict", _WIRE_VERDICTS) + action = _wire_decision(data, "action", _WIRE_ACTIONS) + if verdict is None and action is None: + raise _wire_error() + if verdict is not None and action is not None and verdict is not action: + raise _wire_error() + + if "fallback_used" in data and type(data["fallback_used"]) is not bool: + raise _wire_error() + constraints = data.get("constraints") + if constraints is not None and not _valid_wire_constraints(constraints): + raise _wire_error() + for field_name in ("guardrails_result", "guardrails"): + if data.get(field_name) is not None and not isinstance(data[field_name], dict): + raise _wire_error() + + result = cls.from_dict(data) + decision = verdict if verdict is not None else action + assert decision is not None + result.verdict = decision + return result + @classmethod def fallback_allow(cls, reason: str) -> EvaluationResult: """Allow-shaped result for fail-open network-error paths. diff --git a/openbox_core/errors.py b/openbox_core/errors.py index 256146b..fa31d39 100644 --- a/openbox_core/errors.py +++ b/openbox_core/errors.py @@ -5,7 +5,7 @@ Hierarchy: OpenBoxError (base) - ├── ContractError # strict-gate event/runtime contract violation + ├── ContractError # SDK input or Core response contract violation ├── OpenBoxConfigError │ ├── OpenBoxAuthError │ │ └── OpenBoxSigningError # Core rejected a signed (AIP DID) request @@ -62,11 +62,10 @@ class OpenBoxError(Exception): class ContractError(OpenBoxError): - """Raised by the always-strict gate on a malformed event/runtime contract. + """Raised when SDK input or a successful Core response violates its contract. - Contract violations raise *before* any network send, regardless of the - ``on_api_error`` fail-open/fail-closed setting — fail-open applies only to - network errors, never to contract violations. + Input violations raise before network send; response violations raise after + a successful exchange. Neither is converted to a fail-open ALLOW result. Attributes: code: Machine-readable violation code (e.g. ``HOOK_TRIGGER_FALSE``). diff --git a/tests/client/test_fail_modes.py b/tests/client/test_fail_modes.py index 57a76f8..1080c92 100644 --- a/tests/client/test_fail_modes.py +++ b/tests/client/test_fail_modes.py @@ -14,6 +14,7 @@ ) from openbox_core.contracts.results import Verdict from openbox_core.errors import ( + ContractError, GovernanceAPIError, OpenBoxAuthError, OpenBoxNetworkError, @@ -94,13 +95,31 @@ def handler(request): result = make_client(handler).evaluate({"x": 1}) assert result.fallback_used is True - def test_unparseable_body_returns_fallback_allow(self): + def test_redirect_is_not_parsed_as_success(self): def handler(request): - return httpx.Response(200, content=b"not json") + return httpx.Response(302, json={"verdict": "allow"}) result = make_client(handler).evaluate({"x": 1}) assert result.fallback_used is True + def test_unparseable_success_is_contract_failure(self): + def handler(request): + return httpx.Response(200, content=b"not json") + + with pytest.raises(ContractError, match="Malformed governance response"): + make_client(handler).evaluate({"x": 1}) + + def test_client_creation_failure_still_follows_fail_open(self, monkeypatch): + client = make_client(lambda _: httpx.Response(200, json={})) + + def fail(): + raise RuntimeError("client setup failed") + + monkeypatch.setattr(client, "_sync", fail) + result = client.evaluate({"x": 1}) + assert result.verdict is Verdict.ALLOW + assert result.fallback_used is True + async def test_async_network_error_fallback(self): def handler(request): raise httpx.ConnectError("boom") @@ -177,7 +196,7 @@ def handler(request): class TestClose: def test_close_idempotent(self): - client = make_client(lambda r: httpx.Response(200, json={})) + client = make_client(lambda r: httpx.Response(200, json={"verdict": "allow"})) client.evaluate({"x": 1}) client.close() client.close() diff --git a/tests/contracts/test_result_parsing.py b/tests/contracts/test_result_parsing.py index c3d34fe..7556e3e 100644 --- a/tests/contracts/test_result_parsing.py +++ b/tests/contracts/test_result_parsing.py @@ -1,10 +1,15 @@ """EvaluationResult / GuardrailsResult / Verdict parsing tests.""" +import json + +import pytest + from openbox_core.contracts.results import ( EvaluationResult, GuardrailsResult, Verdict, ) +from openbox_core.errors import ContractError class TestVerdict: @@ -116,6 +121,80 @@ def test_fallback_allow_shape(self): assert result.fallback_used is True assert result.reason == "network unreachable" + def test_from_dict_remains_tolerant_and_action_compatible(self): + result = EvaluationResult.from_dict( + {"verdict": "unknown", "action": "allow", "fallback_used": 1} + ) + assert result.verdict is Verdict.ALLOW + assert result.action == "continue" + assert result.fallback_used is True + + +class TestStrictWireParsing: + @pytest.mark.parametrize( + "body", + [ + b"\xff", + b"not-json", + b"[]", + b"{}", + b'{"verdict":"unknown"}', + b'{"action":"unknown"}', + b'{"verdict":"allow","action":"stop"}', + b'{"verdict":"allow","fallback_used":0}', + b'{"verdict":"allow","fallback_used":null}', + b'{"verdict":"constrain","constraints":1}', + b'{"verdict":"constrain","constraints":["sandbox",{}]}', + b'{"verdict":"allow","guardrails_result":[]}', + b'{"verdict":"allow","verdict":"block"}', + b'{"verdict":"allow","risk_score":NaN}', + b'{"verdict":"allow","risk_score":Infinity}', + b'{"verdict":"allow","risk_score":1e9999}', + ], + ) + def test_rejects_malformed_success_body(self, body): + with pytest.raises(ContractError, match="Malformed governance response"): + EvaluationResult.from_wire(body) + + @pytest.mark.parametrize( + ("field", "value", "expected"), + [ + ("verdict", "allow", Verdict.ALLOW), + ("verdict", "constrain", Verdict.CONSTRAIN), + ("verdict", "require_approval", Verdict.REQUIRE_APPROVAL), + ("action", "continue", Verdict.ALLOW), + ("action", "stop", Verdict.HALT), + ("action", "require-approval", Verdict.REQUIRE_APPROVAL), + ("action", "request_approval", Verdict.REQUIRE_APPROVAL), + ("action", "block", Verdict.BLOCK), + ], + ) + def test_accepts_current_and_legacy_decisions(self, field, value, expected): + result = EvaluationResult.from_wire(f'{{"{field}":"{value}"}}'.encode()) + assert result.verdict is expected + + @pytest.mark.parametrize( + "constraints", + [{"max_rows": 3}, [{"kind": "limit"}], ["run_in_sandbox"], []], + ) + def test_accepts_current_constraint_shapes(self, constraints): + body = json.dumps({"verdict": "constrain", "constraints": constraints}).encode() + assert EvaluationResult.from_wire(body).constraints == constraints + + def test_preserves_unknown_fields_without_changing_action_compatibility(self): + result = EvaluationResult.from_wire( + b'{"verdict":"allow","action":"allow","future":{"x":1}}' + ) + assert result.action == "continue" + assert result.raw["future"] == {"x": 1} + + def test_preserves_retry_plan_parsing(self): + result = EvaluationResult.from_wire( + b'{"verdict":"block","retry_plan":{"new_input":{"attempt":2}}}' + ) + assert result.retry_plan is not None + assert result.retry_plan.new_input == {"attempt": 2} + class TestGuardrailsResult: def test_from_dict_defaults(self): From f9521718c5ffb0170ef7e74dd3a132a5b60893f2 Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Sat, 25 Jul 2026 11:44:28 +0700 Subject: [PATCH 02/20] feat(telemetry): add privacy-safe sandbox hook contract --- openbox_core/contracts/otel_spans.py | 5 + openbox_core/validation/event_rules.py | 157 ++++++++++++++++++++++++- tests/wire/test_flat_hook_contract.py | 68 ++++++++++- 3 files changed, 223 insertions(+), 7 deletions(-) diff --git a/openbox_core/contracts/otel_spans.py b/openbox_core/contracts/otel_spans.py index 3aff9f4..d5d6b9a 100644 --- a/openbox_core/contracts/otel_spans.py +++ b/openbox_core/contracts/otel_spans.py @@ -40,6 +40,7 @@ class HookType(str, Enum): DB_QUERY = "db_query" FILE_OPERATION = "file_operation" FUNCTION_CALL = "function_call" + SANDBOX_EXECUTION = "sandbox_execution" LLM_CALL = "llm_call" # reserved; disabled until provider hooks are implemented @@ -96,6 +97,7 @@ def _attributes_of(obj: Any) -> dict[str, Any]: "db_query": "CLIENT", "file_operation": "INTERNAL", "function_call": "INTERNAL", + "sandbox_execution": "INTERNAL", "llm_call": "CLIENT", } @@ -128,6 +130,9 @@ def _attributes_of(obj: Any) -> dict[str, Any]: "bytes_written", ), "function_call": ("function", "module", "args", "result"), + # Sandbox evidence is deliberately attributes-only. It must never gain body, + # environment, command, or credential-bearing root fields. + "sandbox_execution": (), } diff --git a/openbox_core/validation/event_rules.py b/openbox_core/validation/event_rules.py index 3a2e69c..a4d2c27 100644 --- a/openbox_core/validation/event_rules.py +++ b/openbox_core/validation/event_rules.py @@ -16,6 +16,7 @@ from __future__ import annotations +import re from typing import Any from ..contracts.events import EventEnvelope, EventKind, EventType, classify_event @@ -31,6 +32,54 @@ # Payload fields every workflow-scoped lifecycle event must carry. _REQUIRED_WORKFLOW_FIELDS = ("workflow_id", "run_id", "workflow_type") _REQUIRED_HANDOFF_FIELDS = ("from_agent_did", "multi_agent_session_id") +_SANDBOX_HOOK_TYPE = "sandbox_execution" +_SANDBOX_SAFE_ATTRIBUTES = frozenset( + { + "sandbox.provider", + "openbox.sandbox.profile_id", + "openbox.sandbox.runtime_contract_version", + "openbox.sandbox.adapter_build_sha256", + "openbox.sandbox.image_digest", + "openbox.sandbox.policy_id", + "openbox.sandbox.policy_version", + "openbox.sandbox.policy_sha256", + "openbox.sandbox.profile_bundle_version", + "openbox.sandbox.id", + "openbox.sandbox.outcome", + "openbox.sandbox.disposition", + "openbox.sandbox.timeout_status", + "openbox.sandbox.cleanup_status", + "openbox.sandbox.directive", + "openbox.sandbox.error_code", + "openbox.sandbox.exit_code", + "openbox.sandbox.stdout_bytes", + "openbox.sandbox.stderr_bytes", + "openbox.sandbox.stdout_sha256", + "openbox.sandbox.stderr_sha256", + } +) +_SANDBOX_SAFE_ROOT_FIELDS = frozenset( + { + "span_id", + "trace_id", + "parent_span_id", + "name", + "kind", + "stage", + "start_time", + "end_time", + "duration_ns", + "attributes", + "status", + "events", + "hook_type", + "error", + } +) +_SHA256 = re.compile(r"[0-9a-f]{64}\Z") +_IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") +_SPAN_ID = re.compile(r"[0-9a-f]{16}\Z") +_TRACE_ID = re.compile(r"[0-9a-f]{32}\Z") def span_stage(span: Any) -> str | None: @@ -102,6 +151,103 @@ def check_lifecycle_envelope(event: EventEnvelope) -> None: ) +def _check_sandbox_span(span: dict[str, Any], index: int) -> None: + unknown_root = sorted(set(span) - _SANDBOX_SAFE_ROOT_FIELDS) + attributes = span.get("attributes") + if unknown_root or not isinstance(attributes, dict): + raise ContractError( + "Sandbox execution spans may contain only bounded evidence fields", + code="SANDBOX_SPAN_UNSAFE_FIELDS", + detail={"index": index, "unknown_root": unknown_root}, + ) + unknown_attributes = sorted(set(attributes) - _SANDBOX_SAFE_ATTRIBUTES) + if unknown_attributes: + raise ContractError( + "Sandbox execution attributes are not privacy allowlisted", + code="SANDBOX_SPAN_UNSAFE_FIELDS", + detail={"index": index, "unknown_attributes": unknown_attributes}, + ) + status = span.get("status") + parent_span_id = span.get("parent_span_id") + start_time = span.get("start_time") + end_time = span.get("end_time") + duration_ns = span.get("duration_ns") + span_id = span.get("span_id") + trace_id = span.get("trace_id") + if ( + not isinstance(span_id, str) + or _SPAN_ID.fullmatch(span_id) is None + or not isinstance(trace_id, str) + or _TRACE_ID.fullmatch(trace_id) is None + or ( + parent_span_id is not None + and (not isinstance(parent_span_id, str) or _SPAN_ID.fullmatch(parent_span_id) is None) + ) + or span.get("name") != "openbox.sandbox_execution" + or span.get("kind") != "INTERNAL" + or span.get("stage") not in {"started", "completed"} + or isinstance(start_time, bool) + or not isinstance(start_time, int) + or start_time < 0 + or not isinstance(status, dict) + or set(status) != {"code", "description"} + or status.get("code") not in {"UNSET", "ERROR"} + or status.get("description") is not None + ): + raise ContractError( + "Sandbox execution common fields are malformed or unbounded", + code="SANDBOX_SPAN_UNSAFE_FIELDS", + detail={"index": index}, + ) + if span.get("stage") == "started": + valid_timing = end_time is None and duration_ns is None + else: + valid_timing = ( + isinstance(end_time, int) + and not isinstance(end_time, bool) + and isinstance(duration_ns, int) + and not isinstance(duration_ns, bool) + and end_time >= start_time + and duration_ns == end_time - start_time + ) + if not valid_timing: + raise ContractError( + "Sandbox execution timing fields are malformed", + code="SANDBOX_SPAN_UNSAFE_FIELDS", + detail={"index": index}, + ) + for key, value in attributes.items(): + if isinstance(value, bool): + continue + if isinstance(value, int) and -(2**63) <= value < 2**63: + continue + if isinstance(value, str) and len(value.encode("utf-8")) <= 512 and value.isprintable(): + if key.endswith("_sha256") and _SHA256.fullmatch(value) is None: + raise ContractError( + "Sandbox execution hash attribute is malformed", + code="SANDBOX_SPAN_UNSAFE_FIELDS", + detail={"index": index, "attribute": key}, + ) + if key.endswith("image_digest") and _IMAGE_DIGEST.fullmatch(value) is None: + raise ContractError( + "Sandbox image digest attribute is malformed", + code="SANDBOX_SPAN_UNSAFE_FIELDS", + detail={"index": index, "attribute": key}, + ) + continue + raise ContractError( + "Sandbox execution attribute value is unbounded", + code="SANDBOX_SPAN_UNSAFE_FIELDS", + detail={"index": index, "attribute": key}, + ) + if span.get("events") not in (None, []) or span.get("error") is not None: + raise ContractError( + "Sandbox execution spans cannot carry raw events or error bodies", + code="SANDBOX_SPAN_UNSAFE_FIELDS", + detail={"index": index}, + ) + + def check_hook_envelope(event: EventEnvelope) -> None: """Strict checks for hook (span-bearing) envelopes.""" if not event.hook_trigger: @@ -111,15 +257,13 @@ def check_hook_envelope(event: EventEnvelope) -> None: ) if event.event_type is not EventType.ACTIVITY_STARTED: raise ContractError( - f"Hook events must use wire event type ActivityStarted, got " - f"{event.event_type.value}", + f"Hook events must use wire event type ActivityStarted, got {event.event_type.value}", code="HOOK_WRONG_WIRE_TYPE", detail={"event_type": event.event_type.value}, ) if not event.spans: raise ContractError( - "Hook event carries no spans — instrumentation produced an impossible " - "hook event", + "Hook event carries no spans — instrumentation produced an impossible hook event", code="HOOK_EMPTY_SPANS", ) if not event.activity_id or not event.activity_type: @@ -142,6 +286,8 @@ def check_hook_envelope(event: EventEnvelope) -> None: code="HOOK_SPAN_NOT_FLAT", detail={"index": index, "forbidden": forbidden}, ) + if span.get("hook_type") == _SANDBOX_HOOK_TYPE: + _check_sandbox_span(span, index) def check_stage(event: EventEnvelope, expected_stage: str) -> None: @@ -150,8 +296,7 @@ def check_stage(event: EventEnvelope, expected_stage: str) -> None: stage = span_stage(span) if stage is None: raise ContractError( - f"Hook span[{index}] has no stage — instrumentation produced a " - "malformed hook span", + f"Hook span[{index}] has no stage — instrumentation produced a malformed hook span", code="HOOK_SPAN_NO_STAGE", detail={"index": index}, ) diff --git a/tests/wire/test_flat_hook_contract.py b/tests/wire/test_flat_hook_contract.py index 6bcb9e9..39d7062 100644 --- a/tests/wire/test_flat_hook_contract.py +++ b/tests/wire/test_flat_hook_contract.py @@ -19,6 +19,8 @@ from openbox_core.conformance.fake_core import assert_hook_wire_shape from openbox_core.contracts.events import hook from openbox_core.contracts.otel_spans import HookType, Stage, from_otel_span +from openbox_core.errors import ContractError +from openbox_core.validation.event_rules import check_hook_envelope from openbox_core.wire.evaluate_payload import build_evaluate_payload _ACTIVITY_CONTEXT = { @@ -72,6 +74,7 @@ "bytes_written", ), HookType.FUNCTION_CALL: ("function", "module", "args", "result"), + HookType.SANDBOX_EXECUTION: (), } _ALL_HOOK_TYPES = list(_FAMILY_ROOT_FIELDS) @@ -132,6 +135,64 @@ def test_started_stage_emits_explicit_nulls(hook_type): assert span["duration_ns"] is None +class TestSandboxExecutionPrivacy: + SAFE_ATTRIBUTES = { + "sandbox.provider": "openshell", + "openbox.sandbox.profile_id": "accounts-payable", + "openbox.sandbox.image_digest": "sha256:" + "a" * 64, + "openbox.sandbox.policy_sha256": "b" * 64, + "openbox.sandbox.stdout_bytes": 12, + "openbox.sandbox.stdout_sha256": "c" * 64, + "openbox.sandbox.cleanup_status": "deleted", + } + + def _event(self, *, attributes=None, fields=None): + span = FakeSpan(attributes=attributes or self.SAFE_ATTRIBUTES) + span_data = from_otel_span( + span, + stage=Stage.COMPLETED, + hook_type=HookType.SANDBOX_EXECUTION, + fields=fields, + ) + span_data["name"] = "openbox.sandbox_execution" + span_data["kind"] = "INTERNAL" + return hook( + activity_context=_ACTIVITY_CONTEXT, + activity_id="act-sandbox", + activity_type="openbox_governed_command", + spans=[span_data], + ) + + def test_allowlisted_bounded_evidence_passes_strict_hook_validation(self): + event = self._event() + check_hook_envelope(event) + payload, _ = build_evaluate_payload(event) + span = payload["spans"][0] + assert span["hook_type"] == "sandbox_execution" + assert span["attributes"] == self.SAFE_ATTRIBUTES + assert "request_body" not in span + assert "response_body" not in span + + @pytest.mark.parametrize( + ("attributes", "fields"), + [ + ({"authorization": "Bearer secret"}, None), + ({"openbox.sandbox.stdout_sha256": "not-a-hash"}, None), + ({"sandbox.provider": ["openshell"]}, None), + ({"sandbox.provider": "openshell"}, {"request_body": "secret"}), + ({"sandbox.provider": "openshell"}, {"error": "raw stderr"}), + ({"sandbox.provider": "openshell"}, {"events": [{"name": "raw"}]}), + ( + {"sandbox.provider": "openshell"}, + {"status": {"code": "ERROR", "description": "raw stderr"}}, + ), + ], + ) + def test_unallowlisted_or_unbounded_evidence_fails_before_send(self, attributes, fields): + with pytest.raises(ContractError, match="Sandbox execution"): + check_hook_envelope(self._event(attributes=attributes, fields=fields)) + + class TestHttpBodyAndHeaders: def test_started_retains_request_body_and_redacted_headers(self): # Headers arrive already sanitized from the instrumentation layer; the @@ -243,7 +304,12 @@ def test_captured_args_and_result(self): _, span = emit( HookType.FUNCTION_CALL, Stage.COMPLETED, - fields={"function": "charge", "module": "billing", "args": {"args": [5]}, "result": "ok"}, + fields={ + "function": "charge", + "module": "billing", + "args": {"args": [5]}, + "result": "ok", + }, ) assert span["function"] == "charge" assert span["module"] == "billing" From 738cede2d391f9776974e93cdc16f5024740271b Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Sat, 25 Jul 2026 11:55:47 +0700 Subject: [PATCH 03/20] fix(telemetry): align sandbox direct hook attributes --- README.md | 12 ++++++++++++ openbox_core/validation/event_rules.py | 2 ++ tests/wire/test_flat_hook_contract.py | 5 +++++ 3 files changed, 19 insertions(+) diff --git a/README.md b/README.md index 1f91a71..46dc8d0 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,18 @@ uv sync --extra http # + HTTP instrumentation targets uv sync --extra db # + DB instrumentation targets ``` +## Sandbox execution hook contract + +`sandbox_execution` hook spans carry privacy-allowlisted, bounded evidence in +`attributes` only. They must not include raw command output, environment +variables, credentials, or body-like root fields. Direct hooks may identify the +sandbox template with `openbox.sandbox.compatibility_id` and the lowercase, +64-character SHA-256 attribute `openbox.sandbox.template_sha256`, alongside the +other allowlisted sandbox evidence attributes. + +Sandbox hook governance uses the SDK's existing direct evaluate path and does +not require an OTLP exporter. + ## Import safety `openbox_core.__init__` and all `openbox_core.contracts.*` modules import diff --git a/openbox_core/validation/event_rules.py b/openbox_core/validation/event_rules.py index a4d2c27..53e93b8 100644 --- a/openbox_core/validation/event_rules.py +++ b/openbox_core/validation/event_rules.py @@ -37,6 +37,8 @@ { "sandbox.provider", "openbox.sandbox.profile_id", + "openbox.sandbox.compatibility_id", + "openbox.sandbox.template_sha256", "openbox.sandbox.runtime_contract_version", "openbox.sandbox.adapter_build_sha256", "openbox.sandbox.image_digest", diff --git a/tests/wire/test_flat_hook_contract.py b/tests/wire/test_flat_hook_contract.py index 39d7062..5c10cb9 100644 --- a/tests/wire/test_flat_hook_contract.py +++ b/tests/wire/test_flat_hook_contract.py @@ -139,6 +139,8 @@ class TestSandboxExecutionPrivacy: SAFE_ATTRIBUTES = { "sandbox.provider": "openshell", "openbox.sandbox.profile_id": "accounts-payable", + "openbox.sandbox.compatibility_id": "openbox-direct-hook-v1", + "openbox.sandbox.template_sha256": "d" * 64, "openbox.sandbox.image_digest": "sha256:" + "a" * 64, "openbox.sandbox.policy_sha256": "b" * 64, "openbox.sandbox.stdout_bytes": 12, @@ -178,6 +180,9 @@ def test_allowlisted_bounded_evidence_passes_strict_hook_validation(self): [ ({"authorization": "Bearer secret"}, None), ({"openbox.sandbox.stdout_sha256": "not-a-hash"}, None), + ({"openbox.sandbox.template_sha256": "not-a-hash"}, None), + ({"openbox.sandbox.template_sha256": "D" * 64}, None), + ({"openbox.sandbox.compatibility_id": "x" * 513}, None), ({"sandbox.provider": ["openshell"]}, None), ({"sandbox.provider": "openshell"}, {"request_body": "secret"}), ({"sandbox.provider": "openshell"}, {"error": "raw stderr"}), From 005d27e0455cedb36fe04513576b85db8975b5ef Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Mon, 27 Jul 2026 16:08:07 +0700 Subject: [PATCH 04/20] feat: merge sandbox SDK into base SDK - Absorb openbox_sandbox package tree from openbox-sandbox-sdk-python - Add openbox-sandbox-agent console script entry point - Add openbox_sandbox to hatch/mypy/ruff config - Bump to v1.2.0 The sandbox SDK is now part of the base SDK. Consumers no longer need to install openbox-sandbox-sdk-python separately. --- openbox_sandbox/__init__.py | 102 +++ openbox_sandbox/__init__.pyi | 83 +++ openbox_sandbox/_trusted_files.py | 135 ++++ openbox_sandbox/authorization.py | 63 ++ openbox_sandbox/command.py | 95 +++ openbox_sandbox/command_profiles.py | 597 +++++++++++++++++ openbox_sandbox/contracts.py | 239 +++++++ openbox_sandbox/deployment.py | 538 +++++++++++++++ openbox_sandbox/engine.py | 781 ++++++++++++++++++++++ openbox_sandbox/errors.py | 38 ++ openbox_sandbox/profiles.py | 384 +++++++++++ openbox_sandbox/py.typed | 0 openbox_sandbox/receipts.py | 541 +++++++++++++++ openbox_sandbox/registry.py | 478 +++++++++++++ openbox_sandbox/release.py | 274 ++++++++ openbox_sandbox/result.py | 98 +++ openbox_sandbox/runtime/__init__.py | 52 ++ openbox_sandbox/runtime/agent_client.py | 496 ++++++++++++++ openbox_sandbox/runtime/agent_server.py | 560 ++++++++++++++++ openbox_sandbox/runtime/client.py | 312 +++++++++ openbox_sandbox/runtime/errors.py | 31 + openbox_sandbox/runtime/types.py | 277 ++++++++ openbox_sandbox/telemetry.py | 234 +++++++ pyproject.toml | 13 +- tests/sandbox/__init__.py | 0 tests/sandbox/deployment_helpers.py | 108 +++ tests/sandbox/helpers.py | 200 ++++++ tests/sandbox/sandbox_helpers.py | 77 +++ tests/sandbox/test_agent_configuration.py | 67 ++ tests/sandbox/test_agent_protocol.py | 181 +++++ tests/sandbox/test_deployment.py | 382 +++++++++++ tests/sandbox/test_engine.py | 190 ++++++ tests/sandbox/test_governed_receipts.py | 477 +++++++++++++ tests/sandbox/test_import_safety.py | 50 ++ tests/sandbox/test_packaging.py | 58 ++ tests/sandbox/test_protocol.py | 157 +++++ tests/sandbox/test_receipt_issuance.py | 204 ++++++ tests/sandbox/test_registry.py | 163 +++++ tests/sandbox/test_release.py | 138 ++++ tests/sandbox/test_sandbox_profiles.py | 303 +++++++++ 40 files changed, 9173 insertions(+), 3 deletions(-) create mode 100644 openbox_sandbox/__init__.py create mode 100644 openbox_sandbox/__init__.pyi create mode 100644 openbox_sandbox/_trusted_files.py create mode 100644 openbox_sandbox/authorization.py create mode 100644 openbox_sandbox/command.py create mode 100644 openbox_sandbox/command_profiles.py create mode 100644 openbox_sandbox/contracts.py create mode 100644 openbox_sandbox/deployment.py create mode 100644 openbox_sandbox/engine.py create mode 100644 openbox_sandbox/errors.py create mode 100644 openbox_sandbox/profiles.py create mode 100644 openbox_sandbox/py.typed create mode 100644 openbox_sandbox/receipts.py create mode 100644 openbox_sandbox/registry.py create mode 100644 openbox_sandbox/release.py create mode 100644 openbox_sandbox/result.py create mode 100644 openbox_sandbox/runtime/__init__.py create mode 100644 openbox_sandbox/runtime/agent_client.py create mode 100644 openbox_sandbox/runtime/agent_server.py create mode 100644 openbox_sandbox/runtime/client.py create mode 100644 openbox_sandbox/runtime/errors.py create mode 100644 openbox_sandbox/runtime/types.py create mode 100644 openbox_sandbox/telemetry.py create mode 100644 tests/sandbox/__init__.py create mode 100644 tests/sandbox/deployment_helpers.py create mode 100644 tests/sandbox/helpers.py create mode 100644 tests/sandbox/sandbox_helpers.py create mode 100644 tests/sandbox/test_agent_configuration.py create mode 100644 tests/sandbox/test_agent_protocol.py create mode 100644 tests/sandbox/test_deployment.py create mode 100644 tests/sandbox/test_engine.py create mode 100644 tests/sandbox/test_governed_receipts.py create mode 100644 tests/sandbox/test_import_safety.py create mode 100644 tests/sandbox/test_packaging.py create mode 100644 tests/sandbox/test_protocol.py create mode 100644 tests/sandbox/test_receipt_issuance.py create mode 100644 tests/sandbox/test_registry.py create mode 100644 tests/sandbox/test_release.py create mode 100644 tests/sandbox/test_sandbox_profiles.py diff --git a/openbox_sandbox/__init__.py b/openbox_sandbox/__init__.py new file mode 100644 index 0000000..1bbf13a --- /dev/null +++ b/openbox_sandbox/__init__.py @@ -0,0 +1,102 @@ +"""Framework-neutral execution of already-authorized sandbox constraints. + +Exports are loaded on first access so deterministic framework code can import +``openbox_sandbox.contracts`` without importing runtime, TLS, signing, telemetry, +or filesystem modules. +""" + +from __future__ import annotations + +from typing import Any + +_EXPORTS: dict[str, tuple[str, str]] = { + "ApprovedSandboxRelease": ("release", "ApprovedSandboxRelease"), + "AuthorizationSource": ("authorization", "AuthorizationSource"), + "AuthorizedConstrain": ("receipts", "AuthorizedConstrain"), + "CleanupBacklog": ("telemetry", "CleanupBacklog"), + "CleanupReconciliationResult": ("result", "CleanupReconciliationResult"), + "CleanupStatus": ("result", "CleanupStatus"), + "CommandProfileBundle": ("profiles", "CommandProfileBundle"), + "CommandProfileBundleError": ("command_profiles", "CommandProfileBundleError"), + "CommandResultValidationError": ( + "command_profiles", + "CommandResultValidationError", + ), + "DecimalArgument": ("registry", "DecimalArgument"), + "Disposition": ("result", "Disposition"), + "EnumArgument": ("registry", "EnumArgument"), + "ExecutionMetadata": ("result", "ExecutionMetadata"), + "GOVERNED_COMMAND_ACTIVITY_TYPE": ("contracts", "GOVERNED_COMMAND_ACTIVITY_TYPE"), + "GovernedCommandDeploymentError": ("errors", "GovernedCommandDeploymentError"), + "IdentifierArgument": ("registry", "IdentifierArgument"), + "IdentifierResultField": ("registry", "IdentifierResultField"), + "InMemoryTelemetrySink": ("telemetry", "InMemoryTelemetrySink"), + "IntegerResultField": ("registry", "IntegerResultField"), + "LiteralArgument": ("registry", "LiteralArgument"), + "NormalizedSandboxError": ("errors", "NormalizedSandboxError"), + "ProfileValidationError": ("errors", "ProfileValidationError"), + "ReceiptSigner": ("receipts", "ReceiptSigner"), + "SandboxActivityResult": ("contracts", "GovernedCommandActivityResult"), + "SandboxAuthorization": ("authorization", "SandboxAuthorization"), + "SandboxCommand": ("command", "SandboxCommand"), + "SandboxCommandArgument": ("contracts", "StructuredCommandArgument"), + "SandboxCommandDefinition": ("registry", "GovernedCommandDefinition"), + "SandboxCommandRegistry": ("registry", "GovernedCommandRegistry"), + "SandboxCommandRegistryError": ("registry", "GovernedCommandRegistryError"), + "SandboxCommandRequest": ("contracts", "GovernedCommandRequest"), + "SandboxDeployment": ("deployment", "SandboxDeployment"), + "SandboxDeploymentConfig": ("deployment", "SandboxDeploymentConfig"), + "SandboxEngineConfig": ("engine", "SandboxEngineConfig"), + "SandboxErrorCode": ("errors", "SandboxErrorCode"), + "SandboxExecutionConfig": ("engine", "SandboxExecutionConfig"), + "SandboxExecutionEngine": ("engine", "SandboxExecutionEngine"), + "SandboxExecutionResult": ("result", "SandboxExecutionResult"), + "SandboxHealth": ("deployment", "SandboxHealth"), + "SandboxInputError": ("contracts", "GovernedCommandInputError"), + "SandboxReceipt": ("contracts", "GovernedCommandReceipt"), + "SandboxReceiptError": ("receipts", "GovernedCommandReceiptError"), + "SandboxReceiptVerifier": ("receipts", "GovernedCommandReceiptVerifier"), + "SandboxReleaseMaterial": ("release", "SandboxReleaseMaterial"), + "SandboxResultValue": ("contracts", "GovernedCommandResultValue"), + "SandboxTypedResult": ("contracts", "GovernedCommandTypedResult"), + "SandboxValidationError": ("errors", "SandboxValidationError"), + "StructuredCommandProfileBundle": ( + "command_profiles", + "StructuredCommandProfileBundle", + ), + "TelemetryEvent": ("telemetry", "TelemetryEvent"), + "TelemetrySink": ("telemetry", "TelemetrySink"), + "TimeoutStatus": ("result", "TimeoutStatus"), + "TypedJsonResultSchema": ("registry", "TypedJsonResultSchema"), + "UnixAgentExecutionConfig": ("engine", "UnixAgentExecutionConfig"), + "approved_sandbox_release": ("release", "approved_sandbox_release"), + "asset_bundle_sha256": ("receipts", "asset_bundle_sha256"), + "command_sha256": ("receipts", "command_sha256"), + "issue_sandbox_receipt": ("receipts", "issue_sandbox_receipt"), + "load_approved_sandbox_release": ("release", "load_approved_sandbox_release"), + "load_sandbox_deployment": ("deployment", "load_sandbox_deployment"), + "materialize_approved_sandbox_release": ( + "release", + "materialize_approved_sandbox_release", + ), + "receipt_binding": ("receipts", "receipt_binding"), + "request_arguments_sha256": ("receipts", "request_arguments_sha256"), + "sandbox_command_registry": ("registry", "governed_command_registry"), +} + +__all__ = sorted(_EXPORTS) + + +def __getattr__(name: str) -> Any: + try: + module_name, attribute_name = _EXPORTS[name] + except KeyError: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None + module = __import__(f"{__name__}.{module_name}", fromlist=[attribute_name]) + value = getattr(module, attribute_name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted((*globals(), *__all__)) diff --git a/openbox_sandbox/__init__.pyi b/openbox_sandbox/__init__.pyi new file mode 100644 index 0000000..daa6607 --- /dev/null +++ b/openbox_sandbox/__init__.pyi @@ -0,0 +1,83 @@ +__all__: list[str] + +from .authorization import AuthorizationSource as AuthorizationSource +from .authorization import SandboxAuthorization as SandboxAuthorization +from .command import SandboxCommand as SandboxCommand +from .command_profiles import CommandProfileBundleError as CommandProfileBundleError +from .command_profiles import CommandResultValidationError as CommandResultValidationError +from .command_profiles import StructuredCommandProfileBundle as StructuredCommandProfileBundle +from .contracts import GOVERNED_COMMAND_ACTIVITY_TYPE as GOVERNED_COMMAND_ACTIVITY_TYPE +from .contracts import ( + GovernedCommandActivityResult, + GovernedCommandInputError, + GovernedCommandReceipt, + GovernedCommandRequest, + GovernedCommandResultValue, + GovernedCommandTypedResult, + StructuredCommandArgument, +) +from .deployment import SandboxDeployment as SandboxDeployment +from .deployment import SandboxDeploymentConfig as SandboxDeploymentConfig +from .deployment import SandboxHealth as SandboxHealth +from .deployment import load_sandbox_deployment as load_sandbox_deployment +from .engine import SandboxEngineConfig as SandboxEngineConfig +from .engine import SandboxExecutionConfig as SandboxExecutionConfig +from .engine import SandboxExecutionEngine as SandboxExecutionEngine +from .engine import UnixAgentExecutionConfig as UnixAgentExecutionConfig +from .errors import GovernedCommandDeploymentError as GovernedCommandDeploymentError +from .errors import NormalizedSandboxError as NormalizedSandboxError +from .errors import ProfileValidationError as ProfileValidationError +from .errors import SandboxErrorCode as SandboxErrorCode +from .errors import SandboxValidationError as SandboxValidationError +from .profiles import CommandProfileBundle as CommandProfileBundle +from .receipts import AuthorizedConstrain as AuthorizedConstrain +from .receipts import GovernedCommandReceiptError, GovernedCommandReceiptVerifier +from .receipts import ReceiptSigner as ReceiptSigner +from .receipts import asset_bundle_sha256 as asset_bundle_sha256 +from .receipts import command_sha256 as command_sha256 +from .receipts import issue_sandbox_receipt as issue_sandbox_receipt +from .receipts import receipt_binding as receipt_binding +from .receipts import request_arguments_sha256 as request_arguments_sha256 +from .registry import DecimalArgument as DecimalArgument +from .registry import EnumArgument as EnumArgument +from .registry import ( + GovernedCommandDefinition, + GovernedCommandRegistry, + GovernedCommandRegistryError, +) +from .registry import IdentifierArgument as IdentifierArgument +from .registry import IdentifierResultField as IdentifierResultField +from .registry import IntegerResultField as IntegerResultField +from .registry import LiteralArgument as LiteralArgument +from .registry import TypedJsonResultSchema as TypedJsonResultSchema +from .registry import governed_command_registry as sandbox_command_registry +from .release import ApprovedSandboxRelease as ApprovedSandboxRelease +from .release import SandboxReleaseMaterial as SandboxReleaseMaterial +from .release import approved_sandbox_release as approved_sandbox_release +from .release import load_approved_sandbox_release as load_approved_sandbox_release +from .release import ( + materialize_approved_sandbox_release as materialize_approved_sandbox_release, +) +from .result import CleanupReconciliationResult as CleanupReconciliationResult +from .result import CleanupStatus as CleanupStatus +from .result import Disposition as Disposition +from .result import ExecutionMetadata as ExecutionMetadata +from .result import SandboxExecutionResult as SandboxExecutionResult +from .result import TimeoutStatus as TimeoutStatus +from .telemetry import CleanupBacklog as CleanupBacklog +from .telemetry import InMemoryTelemetrySink as InMemoryTelemetrySink +from .telemetry import TelemetryEvent as TelemetryEvent +from .telemetry import TelemetrySink as TelemetrySink + +SandboxActivityResult = GovernedCommandActivityResult +SandboxCommandArgument = StructuredCommandArgument +SandboxCommandDefinition = GovernedCommandDefinition +SandboxCommandRegistry = GovernedCommandRegistry +SandboxCommandRegistryError = GovernedCommandRegistryError +SandboxCommandRequest = GovernedCommandRequest +SandboxInputError = GovernedCommandInputError +SandboxReceipt = GovernedCommandReceipt +SandboxReceiptError = GovernedCommandReceiptError +SandboxReceiptVerifier = GovernedCommandReceiptVerifier +SandboxResultValue = GovernedCommandResultValue +SandboxTypedResult = GovernedCommandTypedResult diff --git a/openbox_sandbox/_trusted_files.py b/openbox_sandbox/_trusted_files.py new file mode 100644 index 0000000..0a35d74 --- /dev/null +++ b/openbox_sandbox/_trusted_files.py @@ -0,0 +1,135 @@ +"""Private secure-file and strict-JSON helpers for deployment configuration.""" + +from __future__ import annotations + +import json +import os +import stat +from pathlib import Path +from typing import Any + +from .errors import GovernedCommandDeploymentError + +MAX_CONFIG_BYTES = 1024 * 1024 + + +def strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise GovernedCommandDeploymentError() + result[key] = value + return result + + +def reject_constant(_: str) -> None: + raise GovernedCommandDeploymentError() + + +def _reject_symlink_components(path: Path) -> None: + if not path.is_absolute(): + raise GovernedCommandDeploymentError() + current = Path(path.anchor) + for part in path.parts[1:]: + current /= part + try: + if stat.S_ISLNK(os.lstat(current).st_mode): + raise GovernedCommandDeploymentError() + except FileNotFoundError: + if current == path: + return + raise GovernedCommandDeploymentError() from None + except OSError: + raise GovernedCommandDeploymentError() from None + + +def read_trusted_file( + path: Path, + *, + maximum: int = MAX_CONFIG_BYTES, + private: bool = False, + read_data: bool = True, +) -> bytes: + """Read one owner-controlled regular file through a verified descriptor.""" + if not isinstance(path, Path) or not path.is_absolute() or type(maximum) is not int: + raise GovernedCommandDeploymentError() + if not 1 <= maximum <= MAX_CONFIG_BYTES: + raise GovernedCommandDeploymentError() + _reject_symlink_components(path) + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor: int | None = None + try: + descriptor = os.open(path, flags) + metadata = os.fstat(descriptor) + mode = stat.S_IMODE(metadata.st_mode) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.getuid() + or metadata.st_size <= 0 + or metadata.st_size > maximum + or (private and mode != 0o600) + or (not private and mode & 0o022) + ): + raise GovernedCommandDeploymentError() + if not read_data: + return b"" + chunks: list[bytes] = [] + remaining = maximum + 1 + while remaining: + chunk = os.read(descriptor, min(remaining, 64 * 1024)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + body = b"".join(chunks) + if not body or len(body) != metadata.st_size or len(body) > maximum: + raise GovernedCommandDeploymentError() + return body + except GovernedCommandDeploymentError: + raise + except (OSError, ValueError): + raise GovernedCommandDeploymentError() from None + finally: + if descriptor is not None: + os.close(descriptor) + + +def parse_strict_json(body: bytes) -> dict[str, Any]: + try: + text = body.decode("utf-8", errors="strict") + value = json.loads( + text, + object_pairs_hook=strict_object, + parse_constant=reject_constant, + ) + except ( + UnicodeDecodeError, + json.JSONDecodeError, + GovernedCommandDeploymentError, + RecursionError, + ): + raise GovernedCommandDeploymentError() from None + if not isinstance(value, dict): + raise GovernedCommandDeploymentError() + return value + + +def load_strict_json(path: Path) -> dict[str, Any]: + return parse_strict_json(read_trusted_file(path)) + + +def validate_trusted_file(path: Path, *, private: bool = False) -> None: + read_trusted_file(path, private=private, read_data=False) + + +def validate_secure_directory(path: Path) -> None: + if not isinstance(path, Path) or not path.is_absolute(): + raise GovernedCommandDeploymentError() + _reject_symlink_components(path) + try: + metadata = os.lstat(path) + except OSError: + raise GovernedCommandDeploymentError() from None + mode = stat.S_IMODE(metadata.st_mode) + if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.getuid() or mode != 0o700: + raise GovernedCommandDeploymentError() diff --git a/openbox_sandbox/authorization.py b/openbox_sandbox/authorization.py new file mode 100644 index 0000000..2c84168 --- /dev/null +++ b/openbox_sandbox/authorization.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import re +import uuid +from dataclasses import dataclass +from enum import Enum +from typing import Any + +_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,511}\Z") + + +class AuthorizationSource(str, Enum): + TRUSTED_APPLICATION = "trusted_application" + VERIFIED_RECEIPT = "verified_receipt" + + +@dataclass(frozen=True, slots=True, repr=False) +class SandboxAuthorization: + """Bounded proof identity for one already-authorized ``CONSTRAIN``. + + Authorization is established by the framework wrapper before this value is + constructed. The sandbox engine accepts no other verdict and never calls + OpenBox Core. Arbitrary caller metadata is intentionally not retained. + """ + + authorization_id: str + source: AuthorizationSource + + def __post_init__(self) -> None: + if ( + not isinstance(self.authorization_id, str) + or _IDENTIFIER.fullmatch(self.authorization_id) is None + or not isinstance(self.source, AuthorizationSource) + ): + raise ValueError("sandbox authorization rejected") + + @classmethod + def trusted_application(cls, authorization_id: str) -> SandboxAuthorization: + return cls(authorization_id, AuthorizationSource.TRUSTED_APPLICATION) + + @classmethod + def verified_receipt(cls, authorization_id: str) -> SandboxAuthorization: + return cls(authorization_id, AuthorizationSource.VERIFIED_RECEIPT) + + @property + def governance_event_id(self) -> str: + return str(uuid.uuid5(uuid.NAMESPACE_URL, self.authorization_id)) + + @property + def raw(self) -> dict[str, Any]: + return { + "governance_event_id": self.governance_event_id, + "verdict": "constrain", + "risk_score": 0.0, + "action": "constrain", + "fallback_used": False, + "constraints": ["run_in_sandbox"], + "authorization_id": self.authorization_id, + "authorization_source": self.source.value, + } + + def __repr__(self) -> str: + return f"SandboxAuthorization(source={self.source.value!r}, authorization_id=)" diff --git a/openbox_sandbox/command.py b/openbox_sandbox/command.py new file mode 100644 index 0000000..329565b --- /dev/null +++ b/openbox_sandbox/command.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping, Sequence + +from openbox_core.contracts.context import ActivityContext + +from .errors import SandboxValidationError + + +def _identifier(value: object) -> str: + if not isinstance(value, str) or not value or len(value.encode("utf-8")) > 512: + raise SandboxValidationError() + return value + + +@dataclass(frozen=True, slots=True, repr=False, init=False) +class SandboxCommand: + """One profile-derived command bound to the canonical ActivityContext.""" + + context: ActivityContext + argv: tuple[str, ...] + profile_id: str + timeout_seconds: int + + def __init__( + self, + *, + context: ActivityContext, + argv: Sequence[str], + profile_id: str, + timeout_seconds: int = 30, + ) -> None: + if not isinstance(context, ActivityContext): + raise SandboxValidationError() + for value in (context.workflow_id, context.run_id, context.activity_id): + _identifier(value) + if isinstance(argv, (str, bytes, bytearray, Mapping)): + raise SandboxValidationError() + try: + snapshot = tuple(argv) + except TypeError as error: + raise SandboxValidationError() from error + attempt = context.metadata.get("attempt", 1) + if ( + not snapshot + or not all(isinstance(value, str) and "\x00" not in value for value in snapshot) + or sum(len(value.encode("utf-8")) for value in snapshot) > 1024 * 1024 + or type(attempt) is not int + or attempt != 1 + or isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, int) + or not 1 <= timeout_seconds <= 300 + ): + raise SandboxValidationError() + object.__setattr__(self, "context", context) + object.__setattr__(self, "argv", snapshot) + object.__setattr__(self, "profile_id", _identifier(profile_id)) + object.__setattr__(self, "timeout_seconds", timeout_seconds) + + @property + def workflow_id(self) -> str: + assert self.context.workflow_id is not None + return self.context.workflow_id + + @property + def run_id(self) -> str: + assert self.context.run_id is not None + return self.context.run_id + + @property + def activity_id(self) -> str: + assert self.context.activity_id is not None + return self.context.activity_id + + @property + def workflow_type(self) -> str: + return self.context.workflow_type or "generic" + + @property + def task_queue(self) -> str: + return self.context.task_queue or "generic" + + @property + def attempt(self) -> int: + return 1 + + def __repr__(self) -> str: + return ( + "SandboxCommand(" + f"workflow_id={self.workflow_id!r}, run_id={self.run_id!r}, " + f"activity_id={self.activity_id!r}, profile_id={self.profile_id!r}, " + f"argv=, argv_count={len(self.argv)}, " + f"timeout_seconds={self.timeout_seconds})" + ) diff --git a/openbox_sandbox/command_profiles.py b/openbox_sandbox/command_profiles.py new file mode 100644 index 0000000..9c841d3 --- /dev/null +++ b/openbox_sandbox/command_profiles.py @@ -0,0 +1,597 @@ +"""Strict, callable-free structured command profile mapping.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from types import MappingProxyType +from typing import Any, Mapping, Sequence + +from .contracts import ( + GovernedCommandInputError, + GovernedCommandRequest, + GovernedCommandResultValue, + GovernedCommandTypedResult, +) + +_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z") +_FIELD = re.compile(r"[A-Za-z][A-Za-z0-9_]{0,63}\Z") +_HEX = re.compile(r"[0-9a-f]{64}\Z") +_MAX_DOCUMENT = 1024 * 1024 +_MAX_RESULT_BODY = 16 * 1024 + + +class CommandProfileBundleError(ValueError): + def __init__(self) -> None: + super().__init__("structured command profile bundle rejected") + + +class CommandResultValidationError(ValueError): + def __init__(self) -> None: + super().__init__("governed command typed result rejected") + + +def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise CommandProfileBundleError() + result[key] = value + return result + + +def _reject_constant(_: str) -> None: + raise CommandProfileBundleError() + + +def _strict_result_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise CommandResultValidationError() + result[key] = value + return result + + +def _reject_result_constant(_: str) -> None: + raise CommandResultValidationError() + + +def _object(value: object, fields: set[str]) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + raise CommandProfileBundleError() + return value + + +def _canonical(value: object) -> bytes: + try: + return json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode() + except (TypeError, ValueError): + raise CommandProfileBundleError() from None + + +def _timestamp(value: object) -> datetime: + if not isinstance(value, str) or not value.endswith("Z"): + raise CommandProfileBundleError() + try: + parsed = datetime.fromisoformat(value[:-1] + "+00:00") + except ValueError: + raise CommandProfileBundleError() from None + return parsed.astimezone(timezone.utc) + + +@dataclass(frozen=True) +class _ArgumentMapping: + kind: str + field: str | None = None + literal: str | None = None + values: tuple[str, ...] = () + minimum: int | None = None + maximum: int | None = None + max_bytes: int | None = None + + @classmethod + def parse(cls, value: object) -> "_ArgumentMapping": + if not isinstance(value, dict) or not isinstance(value.get("kind"), str): + raise CommandProfileBundleError() + kind = value["kind"] + if kind == "literal": + item = _object(value, {"kind", "value"})["value"] + if not isinstance(item, str) or "\x00" in item or len(item.encode()) > 4096: + raise CommandProfileBundleError() + return cls(kind=kind, literal=item) + if kind == "field_identifier": + item = _object(value, {"kind", "field", "max_bytes"}) + _field(item["field"]) + maximum = item["max_bytes"] + if ( + isinstance(maximum, bool) + or not isinstance(maximum, int) + or not 1 <= maximum <= 4096 + ): + raise CommandProfileBundleError() + return cls(kind=kind, field=item["field"], max_bytes=maximum) + if kind == "field_enum": + item = _object(value, {"kind", "field", "values"}) + _field(item["field"]) + values = item["values"] + if ( + not isinstance(values, list) + or not values + or not all( + isinstance(choice, str) + and "\x00" not in choice + and len(choice.encode("utf-8")) <= 4096 + for choice in values + ) + or len(values) != len(set(values)) + or len(values) > 128 + ): + raise CommandProfileBundleError() + return cls(kind=kind, field=item["field"], values=tuple(values)) + if kind == "field_decimal": + item = _object(value, {"kind", "field", "minimum", "maximum"}) + _field(item["field"]) + minimum, maximum = item["minimum"], item["maximum"] + if ( + isinstance(minimum, bool) + or isinstance(maximum, bool) + or not isinstance(minimum, int) + or not isinstance(maximum, int) + or minimum > maximum + ): + raise CommandProfileBundleError() + return cls( + kind=kind, + field=item["field"], + minimum=minimum, + maximum=maximum, + ) + raise CommandProfileBundleError() + + def map(self, values: Mapping[str, str | int]) -> str: + if self.kind == "literal": + assert self.literal is not None + return self.literal + assert self.field is not None + if self.field not in values: + raise GovernedCommandInputError("governed command input rejected") + value = values[self.field] + if self.kind == "field_identifier": + if ( + not isinstance(value, str) + or _IDENTIFIER.fullmatch(value) is None + or len(value.encode()) > self.max_bytes # type: ignore[operator] + ): + raise GovernedCommandInputError("governed command input rejected") + return value + if self.kind == "field_enum": + if not isinstance(value, str) or value not in self.values: + raise GovernedCommandInputError("governed command input rejected") + return value + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value < self.minimum # type: ignore[operator] + or value > self.maximum # type: ignore[operator] + ): + raise GovernedCommandInputError("governed command input rejected") + return str(value) + + +def _field(value: object) -> None: + if not isinstance(value, str) or _FIELD.fullmatch(value) is None: + raise CommandProfileBundleError() + lowered = value.lower() + if any( + part in lowered + for part in ( + "argv", + "command", + "cmd", + "code", + "secret", + "token", + "password", + "credential", + "private_key", + ) + ): + raise CommandProfileBundleError() + + +@dataclass(frozen=True) +class _ResultField: + name: str + kind: str + minimum: int | None = None + maximum: int | None = None + max_bytes: int | None = None + + @classmethod + def parse(cls, value: object) -> "_ResultField": + if not isinstance(value, dict) or not isinstance(value.get("kind"), str): + raise CommandProfileBundleError() + kind = value["kind"] + if kind == "identifier": + item = _object(value, {"name", "kind", "max_bytes"}) + _field(item["name"]) + maximum = item["max_bytes"] + if type(maximum) is not int or not 1 <= maximum <= 4096: + raise CommandProfileBundleError() + return cls(item["name"], kind, max_bytes=maximum) + if kind == "integer": + item = _object(value, {"name", "kind", "minimum", "maximum"}) + _field(item["name"]) + minimum, maximum = item["minimum"], item["maximum"] + if type(minimum) is not int or type(maximum) is not int or minimum > maximum: + raise CommandProfileBundleError() + return cls(item["name"], kind, minimum=minimum, maximum=maximum) + raise CommandProfileBundleError() + + def validate(self, value: object) -> GovernedCommandResultValue: + if self.kind == "identifier": + if ( + not isinstance(value, str) + or _IDENTIFIER.fullmatch(value) is None + or len(value.encode("utf-8")) > self.max_bytes # type: ignore[operator] + ): + raise CommandResultValidationError() + elif ( + type(value) is not int + or value < self.minimum # type: ignore[operator] + or value > self.maximum # type: ignore[operator] + ): + raise CommandResultValidationError() + try: + return GovernedCommandResultValue(self.name, value) + except GovernedCommandInputError: + raise CommandResultValidationError() from None + + +@dataclass(frozen=True) +class _ResultSchema: + name: str + max_bytes: int + fields: tuple[_ResultField, ...] + + @classmethod + def parse(cls, value: object) -> "_ResultSchema": + item = _object(value, {"name", "max_bytes", "fields"}) + name, maximum, raw_fields = item["name"], item["max_bytes"], item["fields"] + if ( + not isinstance(name, str) + or _IDENTIFIER.fullmatch(name) is None + or len(name.encode("utf-8")) > 128 + or type(maximum) is not int + or not 1 <= maximum <= _MAX_RESULT_BODY + or not isinstance(raw_fields, list) + or not raw_fields + or len(raw_fields) > 64 + ): + raise CommandProfileBundleError() + fields = tuple(_ResultField.parse(field) for field in raw_fields) + names = [field.name for field in fields] + if len(names) != len(set(names)): + raise CommandProfileBundleError() + return cls(name, maximum, fields) + + def parse_output(self, output: bytes) -> GovernedCommandTypedResult: + if type(output) is not bytes or not output or len(output) > self.max_bytes: + raise CommandResultValidationError() + try: + text = output.decode("utf-8") + value = json.loads( + text, + object_pairs_hook=_strict_result_object, + parse_constant=_reject_result_constant, + ) + except ValueError: + raise CommandResultValidationError() from None + if not isinstance(value, dict) or set(value) != {field.name for field in self.fields}: + raise CommandResultValidationError() + try: + canonical = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + except (TypeError, ValueError): + raise CommandResultValidationError() from None + if output != canonical: + raise CommandResultValidationError() + return GovernedCommandTypedResult( + self.name, + tuple(field.validate(value[field.name]) for field in self.fields), + ) + + +@dataclass(frozen=True) +class _Profile: + profile_id: str + executable: str + arguments: tuple[_ArgumentMapping, ...] + fingerprint: str + result_schema: _ResultSchema | None = None + + def derive(self, request: GovernedCommandRequest) -> tuple[str, ...]: + values = {item.name: item.value for item in request.arguments} + expected = {item.field for item in self.arguments if item.field is not None} + if set(values) != expected: + raise GovernedCommandInputError("governed command input rejected") + return (self.executable, *(item.map(values) for item in self.arguments)) + + def parse_result(self, output: bytes) -> GovernedCommandTypedResult | None: + if self.result_schema is None: + return None + return self.result_schema.parse_output(output) + + +def _parse_profile_values(value: object) -> dict[str, _Profile]: + if not isinstance(value, list) or not value or len(value) > 1024: + raise CommandProfileBundleError() + profiles: dict[str, _Profile] = {} + base_profile_fields = { + "id", + "executable", + "arguments", + "sensitive", + "free_form", + "result_mode", + } + for raw in value: + if not isinstance(raw, dict): + raise CommandProfileBundleError() + if set(raw) == base_profile_fields and raw.get("result_mode") == "metadata_only": + item = raw + result_schema = None + elif ( + set(raw) == base_profile_fields | {"result_schema"} + and raw.get("result_mode") == "typed_json_v1" + ): + item = raw + result_schema = _ResultSchema.parse(raw["result_schema"]) + else: + raise CommandProfileBundleError() + profile_id, executable, arguments = ( + item["id"], + item["executable"], + item["arguments"], + ) + if ( + not isinstance(profile_id, str) + or _IDENTIFIER.fullmatch(profile_id) is None + or len(profile_id.encode()) > 128 + or profile_id in profiles + or not isinstance(executable, str) + or not executable.startswith("/") + or "\x00" in executable + or len(executable.encode()) > 4096 + or not isinstance(arguments, list) + or len(arguments) > 128 + or item["sensitive"] is not False + or item["free_form"] is not False + ): + raise CommandProfileBundleError() + mappings = tuple(_ArgumentMapping.parse(item) for item in arguments) + fields = [item.field for item in mappings if item.field is not None] + if len(fields) != len(set(fields)): + raise CommandProfileBundleError() + profiles[profile_id] = _Profile( + profile_id, + executable, + mappings, + hashlib.sha256(_canonical(item)).hexdigest(), + result_schema, + ) + return profiles + + +@dataclass(frozen=True, init=False) +class StructuredCommandProfileBundle: + schema_version: int + bundle_version: str + key_id: str + issued_at: datetime + expires_at: datetime + fingerprint: str + _profiles: Mapping[str, _Profile] + + def __init__(self) -> None: + raise TypeError("use load() or from_trusted() to construct structured profiles") + + @classmethod + def from_trusted( + cls, + *, + bundle_version: str, + issued_at: datetime, + expires_at: datetime, + profiles: Sequence[Mapping[str, Any]], + now: datetime, + ) -> "StructuredCommandProfileBundle": + """Build immutable mappings from profiles owned by this process.""" + return _trusted_bundle( + cls, + bundle_version=bundle_version, + issued_at=issued_at, + expires_at=expires_at, + profiles=profiles, + now=now, + ) + + @classmethod + def load( + cls, + document: bytes | str, + *, + secret: bytes, + expected_key_id: str, + now: datetime | None = None, + ) -> "StructuredCommandProfileBundle": + if not isinstance(secret, bytes) or len(secret) < 32 or not expected_key_id: + raise CommandProfileBundleError() + body = document.encode() if isinstance(document, str) else document + if not isinstance(body, bytes) or not body or len(body) > _MAX_DOCUMENT: + raise CommandProfileBundleError() + try: + root = json.loads( + body, + object_pairs_hook=_strict_object, + parse_constant=_reject_constant, + ) + except ( + json.JSONDecodeError, + UnicodeDecodeError, + CommandProfileBundleError, + ): + raise CommandProfileBundleError() from None + root = _object(root, {"payload", "signature"}) + payload = _object( + root["payload"], + { + "schema_version", + "bundle_version", + "key_id", + "issued_at", + "expires_at", + "profiles", + }, + ) + signature = _object(root["signature"], {"algorithm", "key_id", "value"}) + if ( + signature["algorithm"] != "hmac-sha256" + or signature["key_id"] != expected_key_id + or payload["key_id"] != expected_key_id + or not isinstance(signature["value"], str) + or _HEX.fullmatch(signature["value"]) is None + or not hmac.compare_digest( + signature["value"], + hmac.new(secret, _canonical(payload), hashlib.sha256).hexdigest(), + ) + ): + raise CommandProfileBundleError() + if ( + type(payload["schema_version"]) is not int + or payload["schema_version"] != 1 + or not isinstance(payload["bundle_version"], str) + or not payload["bundle_version"] + ): + raise CommandProfileBundleError() + issued, expires = ( + _timestamp(payload["issued_at"]), + _timestamp(payload["expires_at"]), + ) + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + if issued > current or expires <= current or issued >= expires: + raise CommandProfileBundleError() + profile_values = payload["profiles"] + profiles = _parse_profile_values(profile_values) + instance = object.__new__(cls) + object.__setattr__(instance, "schema_version", 1) + object.__setattr__(instance, "bundle_version", payload["bundle_version"]) + object.__setattr__(instance, "key_id", expected_key_id) + object.__setattr__(instance, "issued_at", issued) + object.__setattr__(instance, "expires_at", expires) + object.__setattr__(instance, "fingerprint", hashlib.sha256(_canonical(payload)).hexdigest()) + object.__setattr__(instance, "_profiles", MappingProxyType(profiles)) + return instance + + @property + def profile_ids(self) -> tuple[str, ...]: + """Return the validated profile identifiers in stable order.""" + return tuple(sorted(self._profiles)) + + def derive( + self, request: GovernedCommandRequest, *, now: datetime | None = None + ) -> tuple[str, ...]: + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + profile = self._profiles.get(request.profile_id) + if profile is None or not self.issued_at <= current < self.expires_at: + raise GovernedCommandInputError("governed command input rejected") + return profile.derive(request) + + def profile_fingerprint(self, profile_id: str, *, now: datetime | None = None) -> str: + """Return a stable identity for one validated profile definition.""" + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + profile = self._profiles.get(profile_id) + if profile is None or not self.issued_at <= current < self.expires_at: + raise GovernedCommandInputError("governed command input rejected") + return profile.fingerprint + + def parse_result( + self, + profile_id: str, + output: bytes, + *, + now: datetime | None = None, + ) -> GovernedCommandTypedResult | None: + """Return only profile-admitted values, never the raw output body.""" + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + profile = self._profiles.get(profile_id) + if profile is None or not self.issued_at <= current < self.expires_at: + raise CommandResultValidationError() + return profile.parse_result(output) + + +def _trusted_bundle( + bundle_type: type[StructuredCommandProfileBundle], + *, + bundle_version: str, + issued_at: datetime, + expires_at: datetime, + profiles: Sequence[Mapping[str, Any]], + now: datetime, +) -> StructuredCommandProfileBundle: + if ( + not isinstance(bundle_version, str) + or not bundle_version + or not isinstance(issued_at, datetime) + or issued_at.tzinfo is None + or not isinstance(expires_at, datetime) + or expires_at.tzinfo is None + or not isinstance(now, datetime) + or now.tzinfo is None + or isinstance(profiles, (str, bytes)) + or not isinstance(profiles, Sequence) + or not profiles + or len(profiles) > 1024 + ): + raise CommandProfileBundleError() + issued = issued_at.astimezone(timezone.utc) + expires = expires_at.astimezone(timezone.utc) + current = now.astimezone(timezone.utc) + if issued > current or expires <= current or issued >= expires: + raise CommandProfileBundleError() + + profile_values = list(profiles) + parsed = _parse_profile_values(profile_values) + + identity = { + "schema_version": 1, + "bundle_version": bundle_version, + "issued_at": issued.isoformat(), + "expires_at": expires.isoformat(), + "profiles": profile_values, + } + instance = object.__new__(bundle_type) + object.__setattr__(instance, "schema_version", 1) + object.__setattr__(instance, "bundle_version", bundle_version) + object.__setattr__(instance, "key_id", "") + object.__setattr__(instance, "issued_at", issued) + object.__setattr__(instance, "expires_at", expires) + object.__setattr__(instance, "fingerprint", hashlib.sha256(_canonical(identity)).hexdigest()) + object.__setattr__(instance, "_profiles", MappingProxyType(parsed)) + return instance diff --git a/openbox_sandbox/contracts.py b/openbox_sandbox/contracts.py new file mode 100644 index 0000000..e073da1 --- /dev/null +++ b/openbox_sandbox/contracts.py @@ -0,0 +1,239 @@ +"""Sandbox-command Activity types shared with deterministic Workflow code.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Mapping + +GOVERNED_COMMAND_ACTIVITY_TYPE = "openbox_governed_command" +_MAX_ARGUMENTS = 64 +_MAX_RESULT_FIELDS = 64 +_MAX_VALUE_BYTES = 4096 +_FIELD_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_]{0,63}\Z") +_PROFILE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") +_FORBIDDEN_NAMES = { + "argv", + "command", + "cmd", + "code", + "password", + "secret", + "token", + "credential", + "private_key", +} + + +class GovernedCommandInputError(ValueError): + """Raised before scheduling when structured command input is unsafe.""" + + +@dataclass(frozen=True) +class GovernedCommandReceipt: + """Authorization envelope carried durably through framework history.""" + + schema_version: int + receipt_id: str + nonce: str + workflow_id: str + verdict: str + profile_id: str + arguments_sha256: str + command_sha256: str + asset_bundle_sha256: str + profile_fingerprint: str + issued_at: str + expires_at: str + key_id: str + signature: str + + @classmethod + def from_value(cls, value: Any) -> "GovernedCommandReceipt": + if isinstance(value, cls): + return value + if not isinstance(value, dict) or set(value) != { + "schema_version", + "receipt_id", + "nonce", + "workflow_id", + "verdict", + "profile_id", + "arguments_sha256", + "command_sha256", + "asset_bundle_sha256", + "profile_fingerprint", + "issued_at", + "expires_at", + "key_id", + "signature", + }: + raise GovernedCommandInputError("governed command receipt rejected") + try: + return cls(**value) + except TypeError as error: + raise GovernedCommandInputError("governed command receipt rejected") from error + + +@dataclass(frozen=True) +class StructuredCommandArgument: + name: str + value: str | int + + def __post_init__(self) -> None: + if ( + not isinstance(self.name, str) + or _FIELD_NAME.fullmatch(self.name) is None + or any(part in self.name.lower() for part in _FORBIDDEN_NAMES) + or isinstance(self.value, bool) + or not isinstance(self.value, (str, int)) + or len(str(self.value).encode("utf-8")) > _MAX_VALUE_BYTES + ): + raise GovernedCommandInputError("governed command input rejected") + + +@dataclass(frozen=True, init=False) +class GovernedCommandRequest: + profile_id: str + arguments: tuple[StructuredCommandArgument, ...] + receipt: GovernedCommandReceipt | None = None + + def __init__( + self, + profile_id: str, + arguments: ( + Mapping[str, str | int] + | tuple[StructuredCommandArgument, ...] + | list[StructuredCommandArgument] + ), + receipt: GovernedCommandReceipt | None = None, + ) -> None: + if not isinstance(profile_id, str) or _PROFILE_ID.fullmatch(profile_id) is None: + raise GovernedCommandInputError("governed command input rejected") + if isinstance(arguments, Mapping): + snapshot = tuple( + StructuredCommandArgument(name, value) for name, value in arguments.items() + ) + elif isinstance(arguments, (tuple, list)): + snapshot = tuple(arguments) + if not all(isinstance(item, StructuredCommandArgument) for item in snapshot): + raise GovernedCommandInputError("governed command input rejected") + else: + raise GovernedCommandInputError("governed command input rejected") + names = [item.name for item in snapshot] + if len(snapshot) > _MAX_ARGUMENTS or len(set(names)) != len(names): + raise GovernedCommandInputError("governed command input rejected") + if receipt is not None and not isinstance(receipt, GovernedCommandReceipt): + raise GovernedCommandInputError("governed command receipt rejected") + object.__setattr__(self, "profile_id", profile_id) + object.__setattr__(self, "arguments", snapshot) + object.__setattr__(self, "receipt", receipt) + + def to_history_value(self) -> dict[str, Any]: + """Return a bounded wire value, omitting unused authority metadata.""" + value: dict[str, Any] = { + "profile_id": self.profile_id, + "arguments": [{"name": item.name, "value": item.value} for item in self.arguments], + } + if self.receipt is not None: + value["receipt"] = self.receipt + return value + + @classmethod + def from_value(cls, value: Any) -> "GovernedCommandRequest": + if isinstance(value, cls): + return value + if not isinstance(value, dict) or set(value) not in ( + {"profile_id", "arguments"}, + {"profile_id", "arguments", "receipt"}, + ): + raise GovernedCommandInputError("governed command input rejected") + arguments = value["arguments"] + receipt_value = value.get("receipt") + receipt = ( + None if receipt_value is None else GovernedCommandReceipt.from_value(receipt_value) + ) + if isinstance(arguments, dict): + return cls(value["profile_id"], arguments, receipt) + if isinstance(arguments, list): + converted: list[StructuredCommandArgument] = [] + for item in arguments: + if not isinstance(item, dict) or set(item) != {"name", "value"}: + raise GovernedCommandInputError("governed command input rejected") + converted.append(StructuredCommandArgument(item["name"], item["value"])) + return cls(value["profile_id"], converted, receipt) + raise GovernedCommandInputError("governed command input rejected") + + +@dataclass(frozen=True) +class GovernedCommandResultValue: + """One schema-validated value; never a raw command-output body.""" + + name: str + value: str | int + + def __post_init__(self) -> None: + if ( + not isinstance(self.name, str) + or _FIELD_NAME.fullmatch(self.name) is None + or any(part in self.name.lower() for part in _FORBIDDEN_NAMES) + or isinstance(self.value, bool) + or not isinstance(self.value, (str, int)) + or (isinstance(self.value, str) and len(self.value.encode("utf-8")) > _MAX_VALUE_BYTES) + ): + raise GovernedCommandInputError("governed command result rejected") + + +@dataclass(frozen=True) +class GovernedCommandTypedResult: + """Named, ordered result values admitted by an authenticated profile schema.""" + + schema_name: str + values: tuple[GovernedCommandResultValue, ...] + + def __post_init__(self) -> None: + if ( + not isinstance(self.schema_name, str) + or _PROFILE_ID.fullmatch(self.schema_name) is None + or not isinstance(self.values, tuple) + or not self.values + or len(self.values) > _MAX_RESULT_FIELDS + or not all(isinstance(item, GovernedCommandResultValue) for item in self.values) + ): + raise GovernedCommandInputError("governed command result rejected") + names = [item.name for item in self.values] + if len(names) != len(set(names)): + raise GovernedCommandInputError("governed command result rejected") + + +@dataclass(frozen=True) +class GovernedCommandActivityResult: + profile_id: str + disposition: str + exit_code: int + timeout_status: str + cleanup_status: str + stdout_bytes: int + stderr_bytes: int + typed_result: GovernedCommandTypedResult | None = None + + def __post_init__(self) -> None: + if ( + not isinstance(self.profile_id, str) + or _PROFILE_ID.fullmatch(self.profile_id) is None + or self.disposition != "executed_in_sandbox" + or type(self.exit_code) is not int + or not 0 <= self.exit_code <= 2**31 - 1 + or self.timeout_status not in {"not_observed", "confirmed_timeout", "possible_timeout"} + or self.cleanup_status not in {"deleted", "failed"} + or type(self.stdout_bytes) is not int + or not 0 <= self.stdout_bytes <= 1024 * 1024 + or type(self.stderr_bytes) is not int + or not 0 <= self.stderr_bytes <= 1024 * 1024 + or self.stdout_bytes + self.stderr_bytes > 2 * 1024 * 1024 + or ( + self.typed_result is not None + and not isinstance(self.typed_result, GovernedCommandTypedResult) + ) + ): + raise GovernedCommandInputError("governed command result rejected") diff --git a/openbox_sandbox/deployment.py b/openbox_sandbox/deployment.py new file mode 100644 index 0000000..54680e3 --- /dev/null +++ b/openbox_sandbox/deployment.py @@ -0,0 +1,538 @@ +"""Provider-neutral, owner-controlled sandbox deployment materialization. + +This module configures an existing client-side sandbox runtime. It does not call +OpenBox Core, import a workflow framework, spawn processes, build artifacts, +select governance verdicts, execute on the host, or retry commands. +""" + +from __future__ import annotations + +import asyncio +import ipaddress +import re +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from ._trusted_files import ( + load_strict_json, + validate_secure_directory, + validate_trusted_file, +) +from .command_profiles import StructuredCommandProfileBundle +from .engine import ( + SandboxEngineConfig, + SandboxExecutionConfig, + SandboxExecutionEngine, + UnixAgentExecutionConfig, +) +from .errors import GovernedCommandDeploymentError +from .profiles import CommandProfileBundle +from .registry import GovernedCommandRegistry +from .release import ( + ApprovedSandboxRelease, + _declaration_matches, + _parse_release_declaration, + approved_sandbox_release, + materialize_approved_sandbox_release, +) +from .result import CleanupReconciliationResult +from .runtime import ( + AssetBundleIdentity, + OutputLimits, + PolicyDocument, + SandboxRuntimeClient, + SandboxRuntimeClientConfig, + ServiceResponse, + UnixAgentRuntimeClient, + UnixAgentRuntimeClientConfig, +) +from .telemetry import CleanupBacklog, TelemetrySink + +_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") +_SHA256 = re.compile(r"[0-9a-f]{64}\Z") +_DNS_LABEL = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\Z") + + +def _exact(value: object, fields: set[str]) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + raise GovernedCommandDeploymentError() + return value + + +def _string(value: object, *, maximum: int = 4096) -> str: + if ( + not isinstance(value, str) + or not value + or "\x00" in value + or any(ord(character) < 32 for character in value) + or len(value.encode("utf-8")) > maximum + ): + raise GovernedCommandDeploymentError() + return value + + +def _integer(value: object, minimum: int, maximum: int) -> int: + if type(value) is not int or not minimum <= value <= maximum: + raise GovernedCommandDeploymentError() + return value + + +def _boolean(value: object) -> bool: + if type(value) is not bool: + raise GovernedCommandDeploymentError() + return value + + +def _absolute_path(value: object) -> Path: + path = Path(_string(value)) + if not path.is_absolute(): + raise GovernedCommandDeploymentError() + return path + + +def _server_name(value: object) -> str: + name = _string(value, maximum=253) + if name.endswith("."): + name = name[:-1] + labels = name.split(".") + if not labels or any(_DNS_LABEL.fullmatch(label) is None for label in labels): + raise GovernedCommandDeploymentError() + return name + + +@dataclass(frozen=True, slots=True, repr=False) +class SandboxDeploymentConfig: + """Validated immutable deployment configuration.""" + + deployment_id: str + manifest_path: Path + transport_kind: str + sandbox: SandboxExecutionConfig | UnixAgentExecutionConfig + registry_fingerprint: str + profile_bundle_version: str + cleanup_backlog_directory: Path + + def __repr__(self) -> str: + return ( + "SandboxDeploymentConfig(" + f"deployment_id={self.deployment_id!r}, " + f"transport_kind={self.transport_kind!r}, " + f"registry_fingerprint={self.registry_fingerprint!r}, " + f"profile_bundle_version={self.profile_bundle_version!r}, " + f"sandbox={self.sandbox!r}, manifest_path=, " + "cleanup_backlog_directory=)" + ) + + +@dataclass(frozen=True, slots=True) +class SandboxHealth: + """Strictly validated sandbox-service health state.""" + + ready: bool + draining: bool + startup_reconciled: bool + active_operations: int + pending_cleanup_records: int + + +@dataclass(frozen=True, slots=True, repr=False, init=False) +class SandboxDeployment: + """Materialized client-side sandbox deployment and lifecycle owner.""" + + config: SandboxDeploymentConfig + release: ApprovedSandboxRelease + asset_bundle: AssetBundleIdentity + policy_document: PolicyDocument = field(repr=False) + registry: GovernedCommandRegistry + profiles: CommandProfileBundle + structured_profiles: StructuredCommandProfileBundle + cleanup_backlog: CleanupBacklog + engine: SandboxExecutionEngine + _runtime: SandboxRuntimeClient | UnixAgentRuntimeClient = field(repr=False, compare=False) + + def __init__(self) -> None: + raise TypeError("use SandboxDeployment.load()") + + @classmethod + def load( + cls, + manifest_path: Path, + *, + registry: GovernedCommandRegistry, + telemetry: TelemetrySink | None = None, + ) -> SandboxDeployment: + """Load one explicit manifest and materialize its validated runtime.""" + return load_sandbox_deployment( + manifest_path, + registry=registry, + telemetry=telemetry, + ) + + async def preflight(self, *, deadline_ms: int = 5_000) -> SandboxHealth: + """Validate readiness through the exact approved request identity. + + Both transports place ``asset_bundle`` in the authenticated request + boundary. A mismatched service or agent fails before a health response + can be accepted; the response itself is then checked with an exact + schema. + """ + if type(deadline_ms) is not int or not 1 <= deadline_ms <= 120_000: + raise GovernedCommandDeploymentError() + if ( + self.engine.asset_bundle != self.asset_bundle + or self.config.sandbox.asset_bundle != self.asset_bundle + or self.release != approved_sandbox_release() + ): + raise GovernedCommandDeploymentError() + try: + response = await self._runtime.health(deadline_ms) + return _parse_health(response) + except asyncio.CancelledError: + raise + except GovernedCommandDeploymentError: + raise GovernedCommandDeploymentError() from None + except Exception: + raise GovernedCommandDeploymentError() from None + + async def reconcile_cleanup(self) -> CleanupReconciliationResult: + """Retry engine-owned terminal-absence confirmation from the backlog.""" + try: + result = await self.engine.reconcile_cleanup() + except asyncio.CancelledError: + raise + except Exception: + raise GovernedCommandDeploymentError() from None + if not isinstance(result, CleanupReconciliationResult): + raise GovernedCommandDeploymentError() + return result + + def __repr__(self) -> str: + return ( + "SandboxDeployment(" + f"config={self.config!r}, release={self.release!r}, " + f"registry={self.registry!r}, profiles={self.profiles!r}, " + "policy_document=, runtime=)" + ) + + +def _parse_health(response: ServiceResponse) -> SandboxHealth: + if ( + not isinstance(response, ServiceResponse) + or response.response != "health" + or set(response.fields) != {"status"} + ): + raise GovernedCommandDeploymentError() + status = response.fields["status"] + fields = { + "ready", + "draining", + "startup_reconciled", + "active_operations", + "pending_cleanup_records", + } + if not isinstance(status, dict) or set(status) != fields: + raise GovernedCommandDeploymentError() + active = status["active_operations"] + pending = status["pending_cleanup_records"] + if ( + status["ready"] is not True + or status["draining"] is not False + or status["startup_reconciled"] is not True + or type(active) is not int + or type(pending) is not int + or active < 0 + or pending < 0 + ): + raise GovernedCommandDeploymentError() + return SandboxHealth(True, False, True, active, pending) + + +def _parse_profiles(value: object, registry: GovernedCommandRegistry) -> None: + profiles = _exact( + value, + {"registry_fingerprint", "bundle_version", "command_ids"}, + ) + fingerprint = _string(profiles["registry_fingerprint"], maximum=64) + command_ids = profiles["command_ids"] + if ( + _SHA256.fullmatch(fingerprint) is None + or fingerprint != registry.fingerprint + or profiles["bundle_version"] != registry.bundle_version + or not isinstance(command_ids, list) + or command_ids != list(registry.command_ids) + ): + raise GovernedCommandDeploymentError() + + +def _parse_output_limits(value: object) -> OutputLimits: + limits = _exact( + value, + {"stdout_bytes", "stderr_bytes", "combined_bytes", "chunk_bytes"}, + ) + stdout = _integer(limits["stdout_bytes"], 1, 1024 * 1024) + stderr = _integer(limits["stderr_bytes"], 1, 1024 * 1024) + combined = _integer(limits["combined_bytes"], 1, 2 * 1024 * 1024) + chunk = _integer(limits["chunk_bytes"], 1, 4 * 1024 * 1024) + if combined < max(stdout, stderr): + raise GovernedCommandDeploymentError() + try: + return OutputLimits(stdout, stderr, combined, chunk) + except (TypeError, ValueError): + raise GovernedCommandDeploymentError() from None + + +def _parse_deadlines(value: object) -> dict[str, int]: + deadlines = _exact( + value, + { + "create_deadline_ms", + "readiness_deadline_ms", + "exec_deadline_ms", + "delete_deadline_ms", + "wait_deleted_deadline_ms", + }, + ) + return { + "create_deadline_ms": _integer(deadlines["create_deadline_ms"], 1, 60_000), + "readiness_deadline_ms": _integer(deadlines["readiness_deadline_ms"], 1, 120_000), + "exec_deadline_ms": _integer(deadlines["exec_deadline_ms"], 1, 45_000), + "delete_deadline_ms": _integer(deadlines["delete_deadline_ms"], 1, 60_000), + "wait_deleted_deadline_ms": _integer(deadlines["wait_deleted_deadline_ms"], 1, 60_000), + } + + +def _direct_transport( + value: dict[str, Any], + *, + asset_bundle: AssetBundleIdentity, + policy_document: PolicyDocument, + output_limits: OutputLimits, + deadlines: dict[str, int], + enabled: bool, +) -> tuple[SandboxExecutionConfig, SandboxRuntimeClient]: + transport = _exact( + value, + { + "kind", + "host", + "port", + "server_name", + "ca_path", + "certificate_path", + "private_key_path", + }, + ) + if transport["kind"] != "direct_tls": + raise GovernedCommandDeploymentError() + host = _string(transport["host"], maximum=64) + try: + address = ipaddress.ip_address(host) + except ValueError: + raise GovernedCommandDeploymentError() from None + if not address.is_loopback: + raise GovernedCommandDeploymentError() + ca_path = _absolute_path(transport["ca_path"]) + certificate_path = _absolute_path(transport["certificate_path"]) + private_key_path = _absolute_path(transport["private_key_path"]) + validate_trusted_file(ca_path) + validate_trusted_file(certificate_path, private=True) + validate_trusted_file(private_key_path, private=True) + server_name = _server_name(transport["server_name"]) + port = _integer(transport["port"], 1, 65_535) + sandbox = SandboxExecutionConfig( + host=str(address), + port=port, + server_name=server_name, + ca_path=ca_path, + certificate_path=certificate_path, + private_key_path=private_key_path, + asset_bundle=asset_bundle, + policy_document=policy_document, + output_limits=output_limits, + enabled=enabled, + **deadlines, + ) + runtime = SandboxRuntimeClient( + SandboxRuntimeClientConfig( + host=sandbox.host, + port=sandbox.port, + server_name=sandbox.server_name, + ca_path=sandbox.ca_path, + certificate_path=sandbox.certificate_path, + private_key_path=sandbox.private_key_path, + asset_bundle=asset_bundle, + ) + ) + return sandbox, runtime + + +def _uds_transport( + value: dict[str, Any], + *, + registry: GovernedCommandRegistry, + asset_bundle: AssetBundleIdentity, + policy_document: PolicyDocument, + output_limits: OutputLimits, + deadlines: dict[str, int], + enabled: bool, +) -> tuple[UnixAgentExecutionConfig, UnixAgentRuntimeClient]: + transport = _exact(value, {"kind", "socket_path"}) + if transport["kind"] != "uds_agent": + raise GovernedCommandDeploymentError() + socket_path = _absolute_path(transport["socket_path"]) + sandbox = UnixAgentExecutionConfig( + socket_path=socket_path, + registry_fingerprint=registry.fingerprint, + asset_bundle=asset_bundle, + policy_document=policy_document, + output_limits=output_limits, + enabled=enabled, + **deadlines, + ) + runtime = UnixAgentRuntimeClient( + UnixAgentRuntimeClientConfig( + socket_path=socket_path, + asset_bundle=asset_bundle, + registry_fingerprint=registry.fingerprint, + ) + ) + return sandbox, runtime + + +def _load_sandbox_deployment( + manifest_path: Path, + *, + registry: GovernedCommandRegistry, + telemetry: TelemetrySink | None, +) -> SandboxDeployment: + if not isinstance(manifest_path, Path) or not manifest_path.is_absolute(): + raise GovernedCommandDeploymentError() + if not isinstance(registry, GovernedCommandRegistry): + raise GovernedCommandDeploymentError() + root = _exact( + load_strict_json(manifest_path), + { + "schema_version", + "deployment_id", + "transport", + "release", + "profiles", + "cleanup_backlog_directory", + "output_limits", + "deadlines", + "enabled", + }, + ) + if type(root["schema_version"]) is not int or root["schema_version"] != 1: + raise GovernedCommandDeploymentError() + deployment_id = _string(root["deployment_id"], maximum=128) + if _IDENTIFIER.fullmatch(deployment_id) is None: + raise GovernedCommandDeploymentError() + + release = approved_sandbox_release() + declaration = _parse_release_declaration(root["release"]) + if not _declaration_matches(declaration, release): + raise GovernedCommandDeploymentError() + material = materialize_approved_sandbox_release() + + _parse_profiles(root["profiles"], registry) + profiles = registry.admission_profile_bundle() + structured_profiles = registry.structured_profile_bundle() + if ( + profiles.fingerprint != registry.fingerprint + or structured_profiles.fingerprint != registry.fingerprint + or profiles.bundle_version != structured_profiles.bundle_version + ): + raise GovernedCommandDeploymentError() + + cleanup_directory = _absolute_path(root["cleanup_backlog_directory"]) + validate_secure_directory(cleanup_directory) + cleanup_backlog = CleanupBacklog(cleanup_directory, release.compatibility_id) + output_limits = _parse_output_limits(root["output_limits"]) + deadlines = _parse_deadlines(root["deadlines"]) + enabled = _boolean(root["enabled"]) + + transport = root["transport"] + if not isinstance(transport, dict): + raise GovernedCommandDeploymentError() + sandbox: SandboxExecutionConfig | UnixAgentExecutionConfig + runtime: SandboxRuntimeClient | UnixAgentRuntimeClient + if transport.get("kind") == "direct_tls": + sandbox, runtime = _direct_transport( + transport, + asset_bundle=material.asset_bundle, + policy_document=material.policy_document, + output_limits=output_limits, + deadlines=deadlines, + enabled=enabled, + ) + transport_kind = "direct_tls" + elif transport.get("kind") == "uds_agent": + sandbox, runtime = _uds_transport( + transport, + registry=registry, + asset_bundle=material.asset_bundle, + policy_document=material.policy_document, + output_limits=output_limits, + deadlines=deadlines, + enabled=enabled, + ) + transport_kind = "uds_agent" + else: + raise GovernedCommandDeploymentError() + + engine_config = SandboxEngineConfig( + profiles=profiles, + sandbox=sandbox, + telemetry=telemetry, + cleanup_backlog=cleanup_backlog, + ) + engine = SandboxExecutionEngine._from_components( + engine_config, + sandbox=runtime, + clock=lambda: datetime.now(timezone.utc), + sandbox_id=lambda: f"sbx-{uuid.uuid4()}", + ) + config = SandboxDeploymentConfig( + deployment_id=deployment_id, + manifest_path=manifest_path, + transport_kind=transport_kind, + sandbox=sandbox, + registry_fingerprint=registry.fingerprint, + profile_bundle_version=registry.bundle_version, + cleanup_backlog_directory=cleanup_directory, + ) + result = object.__new__(SandboxDeployment) + object.__setattr__(result, "config", config) + object.__setattr__(result, "release", release) + object.__setattr__(result, "asset_bundle", material.asset_bundle) + object.__setattr__(result, "policy_document", material.policy_document) + object.__setattr__(result, "registry", registry) + object.__setattr__(result, "profiles", profiles) + object.__setattr__(result, "structured_profiles", structured_profiles) + object.__setattr__(result, "cleanup_backlog", cleanup_backlog) + object.__setattr__(result, "engine", engine) + object.__setattr__(result, "_runtime", runtime) + return result + + +def load_sandbox_deployment( + manifest_path: Path, + *, + registry: GovernedCommandRegistry, + telemetry: TelemetrySink | None = None, +) -> SandboxDeployment: + """Load one deployment while exposing only a constant public failure.""" + try: + return _load_sandbox_deployment( + manifest_path, + registry=registry, + telemetry=telemetry, + ) + except GovernedCommandDeploymentError: + raise GovernedCommandDeploymentError() from None + except Exception: + raise GovernedCommandDeploymentError() from None diff --git a/openbox_sandbox/engine.py b/openbox_sandbox/engine.py new file mode 100644 index 0000000..70fc8d3 --- /dev/null +++ b/openbox_sandbox/engine.py @@ -0,0 +1,781 @@ +from __future__ import annotations + +import asyncio +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +from .authorization import SandboxAuthorization +from .command import SandboxCommand +from .errors import NormalizedSandboxError, SandboxErrorCode +from .profiles import CommandProfileBundle +from .result import ( + CleanupReconciliationResult, + CleanupStatus, + Disposition, + ExecutionMetadata, + SandboxExecutionResult, + TimeoutStatus, +) +from .runtime import ( + AssetBundleIdentity, + CreateRequest, + ExecCompleted, + ExecRequest, + OutputLimits, + PolicyDocument, + ProtocolValidationError, + SandboxRuntimeClient, + SandboxRuntimeClientConfig, + SandboxServiceTransportError, + SubmissionState, + UnixAgentRuntimeClient, + UnixAgentRuntimeClientConfig, +) +from .telemetry import CleanupBacklog, NullTelemetrySink, TelemetryEvent, TelemetrySink + + +@dataclass(frozen=True, slots=True, repr=False) +class SandboxExecutionConfig: + host: str + port: int + server_name: str + ca_path: Path + certificate_path: Path + private_key_path: Path + asset_bundle: AssetBundleIdentity + policy_document: PolicyDocument + output_limits: OutputLimits = OutputLimits( + stdout_bytes=1024 * 1024, + stderr_bytes=1024 * 1024, + combined_bytes=2 * 1024 * 1024, + chunk_bytes=4 * 1024 * 1024, + ) + create_deadline_ms: int = 60_000 + readiness_deadline_ms: int = 120_000 + exec_deadline_ms: int = 45_000 + delete_deadline_ms: int = 60_000 + wait_deleted_deadline_ms: int = 60_000 + enabled: bool = True + + def __post_init__(self) -> None: + for value, maximum in ( + (self.create_deadline_ms, 60_000), + (self.readiness_deadline_ms, 120_000), + (self.exec_deadline_ms, 45_000), + (self.delete_deadline_ms, 60_000), + (self.wait_deleted_deadline_ms, 60_000), + ): + if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum: + raise ValueError("sandbox deadline rejected") + if type(self.enabled) is not bool: + raise ValueError("sandbox kill switch rejected") + + def __repr__(self) -> str: + return ( + f"SandboxExecutionConfig(host={self.host!r}, port={self.port}, " + f"server_name={self.server_name!r}, credentials=, " + f"asset_bundle={self.asset_bundle!r}, policy_document=, " + f"output_limits={self.output_limits!r}, enabled={self.enabled})" + ) + + +@dataclass(frozen=True, slots=True, repr=False) +class UnixAgentExecutionConfig: + socket_path: Path + registry_fingerprint: str + asset_bundle: AssetBundleIdentity + policy_document: PolicyDocument + output_limits: OutputLimits = OutputLimits( + stdout_bytes=1024 * 1024, + stderr_bytes=1024 * 1024, + combined_bytes=2 * 1024 * 1024, + chunk_bytes=4 * 1024 * 1024, + ) + create_deadline_ms: int = 60_000 + readiness_deadline_ms: int = 120_000 + exec_deadline_ms: int = 45_000 + delete_deadline_ms: int = 60_000 + wait_deleted_deadline_ms: int = 60_000 + enabled: bool = True + + def __post_init__(self) -> None: + for value, maximum in ( + (self.create_deadline_ms, 60_000), + (self.readiness_deadline_ms, 120_000), + (self.exec_deadline_ms, 45_000), + (self.delete_deadline_ms, 60_000), + (self.wait_deleted_deadline_ms, 60_000), + ): + if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum: + raise ValueError("sandbox deadline rejected") + if type(self.enabled) is not bool or not self.socket_path.is_absolute(): + raise ValueError("sandbox execution configuration rejected") + + def __repr__(self) -> str: + return ( + "UnixAgentExecutionConfig(socket_path=, " + f"asset_bundle={self.asset_bundle!r}, policy_document=, " + f"output_limits={self.output_limits!r}, enabled={self.enabled})" + ) + + +@dataclass(frozen=True, slots=True, repr=False) +class SandboxEngineConfig: + profiles: CommandProfileBundle + sandbox: SandboxExecutionConfig | UnixAgentExecutionConfig + telemetry: TelemetrySink | None = None + cleanup_backlog: CleanupBacklog | None = None + + def __post_init__(self) -> None: + if ( + not isinstance(self.profiles, CommandProfileBundle) + or not isinstance( + self.sandbox, + (SandboxExecutionConfig, UnixAgentExecutionConfig), + ) + or (self.telemetry is not None and not callable(getattr(self.telemetry, "emit", None))) + or ( + self.cleanup_backlog is not None + and not isinstance(self.cleanup_backlog, CleanupBacklog) + ) + ): + raise ValueError("sandbox engine configuration rejected") + + def __repr__(self) -> str: + return ( + f"SandboxEngineConfig(profiles={self.profiles!r}, sandbox={self.sandbox!r}, " + f"telemetry={'configured' if self.telemetry else 'disabled'}, " + f"cleanup_backlog={'configured' if self.cleanup_backlog else 'disabled'})" + ) + + +class SandboxExecutionEngine: + """Execute only caller-authorized ``CONSTRAIN`` commands in a sandbox.""" + + def __init__(self, config: SandboxEngineConfig) -> None: + if not isinstance(config, SandboxEngineConfig): + raise TypeError("SandboxEngineConfig required") + runtime: SandboxRuntimeClient | UnixAgentRuntimeClient + if isinstance(config.sandbox, UnixAgentExecutionConfig): + runtime = UnixAgentRuntimeClient( + UnixAgentRuntimeClientConfig( + socket_path=config.sandbox.socket_path, + asset_bundle=config.sandbox.asset_bundle, + registry_fingerprint=config.sandbox.registry_fingerprint, + ) + ) + else: + runtime = SandboxRuntimeClient( + SandboxRuntimeClientConfig( + host=config.sandbox.host, + port=config.sandbox.port, + server_name=config.sandbox.server_name, + ca_path=config.sandbox.ca_path, + certificate_path=config.sandbox.certificate_path, + private_key_path=config.sandbox.private_key_path, + asset_bundle=config.sandbox.asset_bundle, + ) + ) + self._configure( + config, + runtime, + lambda: datetime.now(timezone.utc), + lambda: f"sbx-{uuid.uuid4()}", + ) + + @classmethod + def _from_components( + cls, + config: SandboxEngineConfig, + *, + sandbox: Any, + clock: Callable[[], datetime], + sandbox_id: Callable[[], str] = lambda: f"sbx-{uuid.uuid4()}", + ) -> "SandboxExecutionEngine": + instance = cls.__new__(cls) + instance._configure(config, sandbox, clock, sandbox_id) + return instance + + def _configure( + self, + config: SandboxEngineConfig, + sandbox: Any, + clock: Callable[[], datetime], + sandbox_id: Callable[[], str], + ) -> None: + self._config = config + self._sandbox = sandbox + self._clock = clock + self._sandbox_id = sandbox_id + self._telemetry = config.telemetry or NullTelemetrySink() + + @property + def profiles(self) -> CommandProfileBundle: + """Return the immutable profile admission bundle owned by this engine.""" + return self._config.profiles + + @property + def asset_bundle(self) -> AssetBundleIdentity: + """Return the runtime/image/policy identity bound to this engine.""" + return self._config.sandbox.asset_bundle + + @property + def telemetry_sink(self) -> TelemetrySink: + """Return the sink receiving lifecycle events from this engine.""" + return self._telemetry + + async def execute( + self, command: SandboxCommand, authorization: SandboxAuthorization + ) -> SandboxExecutionResult: + if not isinstance(command, SandboxCommand) or not isinstance( + authorization, SandboxAuthorization + ): + raise TypeError("authorized sandbox execution rejected") + now = self._clock().astimezone(timezone.utc) + if not self._config.profiles.admits(command.profile_id, command.argv, now=now): + return await self._terminal( + command, + authorization, + Disposition.NOT_EXECUTED, + None, + SandboxErrorCode.PROFILE_REJECTED, + ) + await self._emit(command, authorization, "authorization_accepted") + if not self._config.sandbox.enabled: + return await self._terminal( + command, + authorization, + Disposition.NOT_EXECUTED, + None, + SandboxErrorCode.SANDBOX_DISABLED, + ) + return await self._dispatch_sandbox(command, authorization) + + async def reconcile_cleanup(self) -> CleanupReconciliationResult: + backlog = self._config.cleanup_backlog + if backlog is None: + return CleanupReconciliationResult(attempted=0, deleted=0, remaining=0) + async with backlog.reconciliation_lock(): + request_ids = await backlog.request_ids() + deleted = 0 + for request_id in request_ids: + try: + await self._sandbox.delete(request_id, self._config.sandbox.delete_deadline_ms) + absent = await self._sandbox.wait_deleted( + request_id, self._config.sandbox.wait_deleted_deadline_ms + ) + if absent.response != "terminally_absent": + continue + await backlog.remove(request_id) + deleted += 1 + except ( + SandboxServiceTransportError, + ProtocolValidationError, + ValueError, + TypeError, + OSError, + ): + continue + remaining = len(await backlog.request_ids()) + return CleanupReconciliationResult( + attempted=len(request_ids), deleted=deleted, remaining=remaining + ) + + async def _dispatch_sandbox( + self, command: SandboxCommand, authorization: SandboxAuthorization + ) -> SandboxExecutionResult: + sandbox_id = self._sandbox_id() + ownership = [False] + try: + return await self._dispatch_sandbox_lifecycle( + command, authorization, sandbox_id, ownership + ) + except asyncio.CancelledError: + if ownership[0]: + cleanup_task = asyncio.create_task( + self._cleanup(command, authorization, sandbox_id) + ) + try: + await asyncio.shield(cleanup_task) + except asyncio.CancelledError: + await cleanup_task + raise + + async def _dispatch_sandbox_lifecycle( + self, + command: SandboxCommand, + authorization: SandboxAuthorization, + sandbox_id: str, + ownership: list[bool], + ) -> SandboxExecutionResult: + cleanup_required = False + lifecycle_token: str | None = None + await self._emit( + command, + authorization, + "sandbox_create_started", + sandbox_id=sandbox_id, + lifecycle_phase="create", + ) + try: + response = await self._sandbox.create( + CreateRequest( + request_id=sandbox_id, + template=self._config.sandbox.asset_bundle.template, + policy_document=self._config.sandbox.policy_document, + expected_policy=self._config.sandbox.asset_bundle.policy, + ), + self._config.sandbox.create_deadline_ms, + ) + except SandboxServiceTransportError as error: + cleanup_required = error.submission_state is SubmissionState.POSSIBLY_SUBMITTED + ownership[0] = cleanup_required + return await self._sandbox_terminal( + command, + authorization, + sandbox_id, + cleanup_required, + Disposition.NOT_EXECUTED, + SandboxErrorCode.SANDBOX_CREATE, + None, + ) + except (ProtocolValidationError, ValueError, TypeError): + return await self._sandbox_terminal( + command, + authorization, + sandbox_id, + False, + Disposition.NOT_EXECUTED, + SandboxErrorCode.SANDBOX_PROTOCOL, + None, + ) + if response.response == "created": + cleanup_required = True + ownership[0] = True + if response.fields.get("request_id") != sandbox_id or not isinstance( + response.fields.get("lifecycle_token"), str + ): + return await self._sandbox_terminal( + command, + authorization, + sandbox_id, + True, + Disposition.NOT_EXECUTED, + SandboxErrorCode.SANDBOX_PROTOCOL, + None, + ) + lifecycle_token = response.fields["lifecycle_token"] + elif response.response == "create_failed": + failure = response.fields.get("failure") + state = failure.get("state") if isinstance(failure, dict) else None + cleanup_required = state == "possibly_created" + if state not in {"not_created", "possibly_created", "conflict"}: + cleanup_required = True + ownership[0] = cleanup_required + return await self._sandbox_terminal( + command, + authorization, + sandbox_id, + cleanup_required, + Disposition.NOT_EXECUTED, + SandboxErrorCode.SANDBOX_CREATE, + None, + ) + elif response.response == "boundary_failed": + failure = response.fields.get("failure") + cleanup_required = ( + isinstance(failure, dict) and failure.get("cleanup_target") is not None + ) + ownership[0] = cleanup_required + return await self._sandbox_terminal( + command, + authorization, + sandbox_id, + cleanup_required, + Disposition.NOT_EXECUTED, + SandboxErrorCode.SANDBOX_CREATE, + None, + ) + else: + ownership[0] = True + return await self._sandbox_terminal( + command, + authorization, + sandbox_id, + True, + Disposition.NOT_EXECUTED, + SandboxErrorCode.SANDBOX_PROTOCOL, + None, + ) + await self._emit( + command, + authorization, + "sandbox_create_finished", + sandbox_id=sandbox_id, + lifecycle_phase="create", + ) + try: + ready = await self._sandbox.wait_ready( + sandbox_id, + lifecycle_token, + self._config.sandbox.asset_bundle.policy, + self._config.sandbox.readiness_deadline_ms, + ) + except ( + SandboxServiceTransportError, + ProtocolValidationError, + ValueError, + TypeError, + ): + return await self._sandbox_terminal( + command, + authorization, + sandbox_id, + cleanup_required, + Disposition.NOT_EXECUTED, + SandboxErrorCode.SANDBOX_READINESS, + None, + ) + ready_token = ready.fields.get("lifecycle_token") + if ( + ready.response != "ready" + or ready.fields.get("request_id") != sandbox_id + or not isinstance(ready_token, str) + or ready.fields.get("active_policy") + != self._config.sandbox.asset_bundle.policy.to_wire() + ): + return await self._sandbox_terminal( + command, + authorization, + sandbox_id, + cleanup_required, + Disposition.NOT_EXECUTED, + SandboxErrorCode.SANDBOX_READINESS, + None, + ) + lifecycle_token = ready_token + await self._emit( + command, + authorization, + "sandbox_ready", + sandbox_id=sandbox_id, + lifecycle_phase="ready", + ) + await self._emit( + command, + authorization, + "sandbox_exec_started", + sandbox_id=sandbox_id, + lifecycle_phase="exec", + ) + try: + executed = await self._sandbox.exec( + sandbox_id, + lifecycle_token, + ExecRequest( + command.argv, + command.timeout_seconds, + self._config.sandbox.output_limits, + ), + self._config.sandbox.exec_deadline_ms, + ) + except SandboxServiceTransportError as error: + disposition = ( + Disposition.NOT_EXECUTED + if error.submission_state is SubmissionState.NOT_SUBMITTED + else Disposition.EXECUTION_INDETERMINATE + ) + code = ( + SandboxErrorCode.SANDBOX_EXEC_NOT_DISPATCHED + if disposition is Disposition.NOT_EXECUTED + else SandboxErrorCode.SANDBOX_EXEC_INDETERMINATE + ) + return await self._sandbox_terminal( + command, + authorization, + sandbox_id, + cleanup_required, + disposition, + code, + None, + ) + except (ProtocolValidationError, ValueError, TypeError): + return await self._sandbox_terminal( + command, + authorization, + sandbox_id, + cleanup_required, + Disposition.EXECUTION_INDETERMINATE, + SandboxErrorCode.SANDBOX_PROTOCOL, + None, + ) + if executed.response == "executed": + try: + completed = ExecCompleted.from_wire(executed.fields.get("result")) + limits = self._config.sandbox.output_limits + if ( + len(completed.stdout) > limits.stdout_bytes + or len(completed.stderr) > limits.stderr_bytes + or len(completed.stdout) + len(completed.stderr) > limits.combined_bytes + ): + raise ProtocolValidationError() + except (ProtocolValidationError, TypeError): + return await self._sandbox_terminal( + command, + authorization, + sandbox_id, + cleanup_required, + Disposition.EXECUTION_INDETERMINATE, + SandboxErrorCode.SANDBOX_PROTOCOL, + None, + ) + execution = ExecutionMetadata( + sandbox_id=sandbox_id, + exit_code=completed.exit_code, + stdout=completed.stdout, + stderr=completed.stderr, + timeout_status=_timeout_status(completed.timeout), + cleanup_status=CleanupStatus.FAILED, + ) + await self._emit( + command, + authorization, + "sandbox_exec_finished", + sandbox_id=sandbox_id, + lifecycle_phase="exec", + disposition=Disposition.EXECUTED_IN_SANDBOX.value, + execution=execution, + ) + return await self._sandbox_terminal( + command, + authorization, + sandbox_id, + cleanup_required, + Disposition.EXECUTED_IN_SANDBOX, + None, + execution, + ) + if executed.response in {"exec_failed", "boundary_failed"}: + failure = executed.fields.get("failure") + dispatch_state = failure.get("dispatch_state") if isinstance(failure, dict) else None + disposition = ( + Disposition.NOT_EXECUTED + if dispatch_state == "not_dispatched" + else Disposition.EXECUTION_INDETERMINATE + ) + code = ( + SandboxErrorCode.SANDBOX_EXEC_NOT_DISPATCHED + if disposition is Disposition.NOT_EXECUTED + else SandboxErrorCode.SANDBOX_EXEC_INDETERMINATE + ) + return await self._sandbox_terminal( + command, + authorization, + sandbox_id, + cleanup_required, + disposition, + code, + None, + ) + return await self._sandbox_terminal( + command, + authorization, + sandbox_id, + cleanup_required, + Disposition.EXECUTION_INDETERMINATE, + SandboxErrorCode.SANDBOX_PROTOCOL, + None, + ) + + async def _sandbox_terminal( + self, + command: SandboxCommand, + authorization: SandboxAuthorization, + sandbox_id: str, + cleanup_required: bool, + disposition: Disposition, + error_code: SandboxErrorCode | None, + execution: ExecutionMetadata | None, + ) -> SandboxExecutionResult: + cleanup = ( + await self._cleanup(command, authorization, sandbox_id) + if cleanup_required + else CleanupStatus.NOT_NEEDED + ) + if execution is None and disposition is Disposition.EXECUTION_INDETERMINATE: + execution = ExecutionMetadata( + sandbox_id=sandbox_id, + exit_code=None, + stdout=b"", + stderr=b"", + timeout_status=TimeoutStatus.UNKNOWN, + cleanup_status=cleanup, + ) + elif execution is not None: + execution = ExecutionMetadata( + sandbox_id=execution.sandbox_id, + exit_code=execution.exit_code, + stdout=execution.stdout, + stderr=execution.stderr, + timeout_status=execution.timeout_status, + cleanup_status=cleanup, + ) + return await self._terminal( + command, + authorization, + disposition, + execution, + error_code, + ) + + async def _cleanup( + self, command: SandboxCommand, authorization: SandboxAuthorization, sandbox_id: str + ) -> CleanupStatus: + await self._emit( + command, + authorization, + "sandbox_delete_started", + sandbox_id=sandbox_id, + lifecycle_phase="delete", + ) + status = CleanupStatus.FAILED + try: + await self._sandbox.delete(sandbox_id, self._config.sandbox.delete_deadline_ms) + absent = await self._sandbox.wait_deleted( + sandbox_id, self._config.sandbox.wait_deleted_deadline_ms + ) + if absent.response == "terminally_absent": + status = CleanupStatus.DELETED + except ( + SandboxServiceTransportError, + ProtocolValidationError, + ValueError, + TypeError, + ): + status = CleanupStatus.FAILED + if status is CleanupStatus.DELETED: + if self._config.cleanup_backlog is not None: + try: + await self._config.cleanup_backlog.remove(sandbox_id) + except OSError: + pass + await self._emit( + command, + authorization, + "sandbox_deleted", + sandbox_id=sandbox_id, + lifecycle_phase="delete", + cleanup_status=status.value, + ) + else: + if self._config.cleanup_backlog is not None: + try: + await self._config.cleanup_backlog.record( + sandbox_id, + "unconfirmed_absence", + _iso8601(self._clock()), + ) + except OSError: + pass + await self._emit( + command, + authorization, + "sandbox_execution_failed", + sandbox_id=sandbox_id, + lifecycle_phase="delete", + cleanup_status=status.value, + ) + return status + + async def _terminal( + self, + command: SandboxCommand, + authorization: SandboxAuthorization, + disposition: Disposition, + execution: ExecutionMetadata | None, + error_code: SandboxErrorCode | None, + ) -> SandboxExecutionResult: + result = SandboxExecutionResult( + disposition=disposition, + execution=execution, + error=None if error_code is None else NormalizedSandboxError(error_code), + _authorization=authorization.raw, + ) + await self._emit( + command, + authorization, + "dispatch_terminal", + disposition=disposition.value, + execution=execution, + error_code=None if error_code is None else error_code.value, + ) + return result + + async def _emit( + self, + command: SandboxCommand, + authorization: SandboxAuthorization, + event: str, + *, + disposition: str | None = None, + directive: str | None = None, + sandbox_id: str | None = None, + lifecycle_phase: str | None = None, + execution: ExecutionMetadata | None = None, + duration_ms: int | None = None, + error_code: str | None = None, + cleanup_status: str | None = None, + ) -> None: + raw = authorization.raw + bundle = self._config.sandbox.asset_bundle + value = TelemetryEvent( + event=event, + workflow_id=command.workflow_id, + run_id=command.run_id, + activity_id=command.activity_id, + attempt=command.attempt, + governance_event_id=raw["governance_event_id"], + verdict="constrain", + action="constrain", + disposition=disposition, + sandbox_id=sandbox_id, + lifecycle_phase=lifecycle_phase, + timeout_seconds=command.timeout_seconds, + timeout_status=None if execution is None else execution.timeout_status.value, + exit_code=None if execution is None else execution.exit_code, + stdout_bytes=None if execution is None else len(execution.stdout), + stderr_bytes=None if execution is None else len(execution.stderr), + duration_ms=duration_ms, + error_code=error_code, + cleanup_status=( + cleanup_status + if cleanup_status is not None + else None + if execution is None + else execution.cleanup_status.value + ), + runtime_contract_version=bundle.runtime_contract_version, + policy_id=bundle.policy.id, + policy_version=bundle.policy.version, + template_digest=bundle.template, + profile_bundle_version=self._config.profiles.bundle_version, + ) + try: + await self._telemetry.emit(value) + except Exception: + pass + + +def _iso8601(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def _timeout_status(value: str) -> TimeoutStatus: + return { + "not_observed": TimeoutStatus.NOT_OBSERVED, + "confirmed": TimeoutStatus.CONFIRMED_TIMEOUT, + "possible": TimeoutStatus.POSSIBLE_TIMEOUT, + }[value] diff --git a/openbox_sandbox/errors.py b/openbox_sandbox/errors.py new file mode 100644 index 0000000..97754b8 --- /dev/null +++ b/openbox_sandbox/errors.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class SandboxErrorCode(str, Enum): + PROFILE_REJECTED = "profile_rejected" + SANDBOX_DISABLED = "sandbox_disabled" + SANDBOX_CREATE = "sandbox_create_failed" + SANDBOX_READINESS = "sandbox_readiness_failed" + SANDBOX_EXEC_NOT_DISPATCHED = "sandbox_exec_not_dispatched" + SANDBOX_EXEC_INDETERMINATE = "sandbox_exec_indeterminate" + SANDBOX_PROTOCOL = "sandbox_protocol_failed" + CANCELLED = "cancelled" + + +@dataclass(frozen=True, slots=True) +class NormalizedSandboxError: + code: SandboxErrorCode + + def to_wire(self) -> dict[str, str]: + return {"code": self.code.value} + + +class SandboxValidationError(ValueError): + def __init__(self) -> None: + super().__init__("sandbox command rejected") + + +class ProfileValidationError(ValueError): + def __init__(self) -> None: + super().__init__("command profile bundle rejected") + + +class GovernedCommandDeploymentError(ValueError): + def __init__(self) -> None: + super().__init__("governed-command deployment rejected") diff --git a/openbox_sandbox/profiles.py b/openbox_sandbox/profiles.py new file mode 100644 index 0000000..26a720f --- /dev/null +++ b/openbox_sandbox/profiles.py @@ -0,0 +1,384 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from types import MappingProxyType +from typing import Any, Mapping, Sequence + +from .errors import ProfileValidationError + +_MAX_DOCUMENT_BYTES = 1024 * 1024 +_HEX_SHA256 = re.compile(r"[0-9a-f]{64}\Z") +_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z") + + +def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise ProfileValidationError() + value[key] = item + return value + + +def _reject_constant(_: str) -> None: + raise ProfileValidationError() + + +def _timestamp(value: object) -> datetime: + if not isinstance(value, str) or not value.endswith("Z"): + raise ProfileValidationError() + try: + parsed = datetime.fromisoformat(value[:-1] + "+00:00") + except ValueError: + raise ProfileValidationError() from None + if parsed.tzinfo is None: + raise ProfileValidationError() + return parsed.astimezone(timezone.utc) + + +def _canonical(value: object) -> bytes: + try: + return json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + except (TypeError, ValueError): + raise ProfileValidationError() from None + + +def _plain_object(value: object, keys: set[str]) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != keys: + raise ProfileValidationError() + return value + + +@dataclass(frozen=True, slots=True) +class ArgumentRule: + kind: str + literal: str | None = None + choices: tuple[str, ...] = () + minimum: int | None = None + maximum: int | None = None + max_bytes: int | None = None + + @classmethod + def from_wire(cls, value: object) -> ArgumentRule: + if not isinstance(value, dict) or not isinstance(value.get("kind"), str): + raise ProfileValidationError() + kind = value["kind"] + if kind == "literal": + item = _plain_object(value, {"kind", "value"})["value"] + if not isinstance(item, str) or "\x00" in item or len(item.encode("utf-8")) > 4096: + raise ProfileValidationError() + return cls(kind=kind, literal=item) + if kind == "enum": + items = _plain_object(value, {"kind", "values"})["values"] + if ( + not isinstance(items, list) + or not items + or len(items) > 128 + or not all(isinstance(item, str) for item in items) + or len(set(items)) != len(items) + or any("\x00" in item or len(item.encode("utf-8")) > 4096 for item in items) + ): + raise ProfileValidationError() + return cls(kind=kind, choices=tuple(items)) + if kind == "decimal": + item = _plain_object(value, {"kind", "minimum", "maximum"}) + minimum, maximum = item["minimum"], item["maximum"] + if ( + isinstance(minimum, bool) + or isinstance(maximum, bool) + or not isinstance(minimum, int) + or not isinstance(maximum, int) + or minimum > maximum + ): + raise ProfileValidationError() + return cls(kind=kind, minimum=minimum, maximum=maximum) + if kind == "identifier": + item = _plain_object(value, {"kind", "max_bytes"})["max_bytes"] + if isinstance(item, bool) or not isinstance(item, int) or not 1 <= item <= 4096: + raise ProfileValidationError() + return cls(kind=kind, max_bytes=item) + raise ProfileValidationError() + + def accepts(self, value: str) -> bool: + if self.kind == "literal": + return value == self.literal + if self.kind == "enum": + return value in self.choices + if self.kind == "decimal": + try: + parsed = int(value, 10) + except ValueError: + return False + return str(parsed) == value and self.minimum <= parsed <= self.maximum # type: ignore[operator] + if self.kind == "identifier": + return ( + len(value.encode("utf-8")) <= self.max_bytes # type: ignore[operator] + and _IDENTIFIER.fullmatch(value) is not None + ) + return False + + +@dataclass(frozen=True, slots=True, repr=False) +class CommandProfile: + profile_id: str + executable: str + arguments: tuple[ArgumentRule, ...] + sensitive: bool + free_form: bool + + def __repr__(self) -> str: + return ( + f"CommandProfile(profile_id={self.profile_id!r}, " + f"executable={self.executable!r}, arguments={len(self.arguments)}, " + f"sensitive={self.sensitive}, free_form={self.free_form})" + ) + + def admits(self, argv: Sequence[str]) -> bool: + return ( + not self.sensitive + and not self.free_form + and len(argv) == len(self.arguments) + 1 + and argv[0] == self.executable + and all(rule.accepts(value) for rule, value in zip(self.arguments, argv[1:])) + ) + + +@dataclass(frozen=True, slots=True, repr=False, init=False) +class CommandProfileBundle: + schema_version: int + bundle_version: str + key_id: str + issued_at: datetime + expires_at: datetime + fingerprint: str + _profiles: Mapping[str, CommandProfile] + + def __init__(self) -> None: + raise TypeError("use load() or from_trusted() to construct command profiles") + + @classmethod + def from_trusted( + cls, + *, + bundle_version: str, + issued_at: datetime, + expires_at: datetime, + profiles: Sequence[Mapping[str, Any]], + now: datetime, + ) -> CommandProfileBundle: + """Build an immutable bundle from profiles owned by this process.""" + return _trusted_bundle( + cls, + bundle_version=bundle_version, + issued_at=issued_at, + expires_at=expires_at, + profiles=profiles, + now=now, + ) + + @classmethod + def load( + cls, + document: bytes | str, + *, + secret: bytes, + expected_key_id: str, + now: datetime | None = None, + ) -> CommandProfileBundle: + if not isinstance(secret, bytes) or len(secret) < 32 or not expected_key_id: + raise ProfileValidationError() + encoded = document.encode("utf-8") if isinstance(document, str) else document + if not isinstance(encoded, bytes) or not encoded or len(encoded) > _MAX_DOCUMENT_BYTES: + raise ProfileValidationError() + try: + root = json.loads( + encoded, + object_pairs_hook=_strict_object, + parse_constant=_reject_constant, + ) + except (json.JSONDecodeError, UnicodeDecodeError, ProfileValidationError): + raise ProfileValidationError() from None + root = _plain_object(root, {"payload", "signature"}) + payload = _plain_object( + root["payload"], + { + "schema_version", + "bundle_version", + "key_id", + "issued_at", + "expires_at", + "profiles", + }, + ) + signature = _plain_object(root["signature"], {"algorithm", "key_id", "value"}) + if ( + signature["algorithm"] != "hmac-sha256" + or signature["key_id"] != expected_key_id + or payload["key_id"] != expected_key_id + or not isinstance(signature["value"], str) + or _HEX_SHA256.fullmatch(signature["value"]) is None + ): + raise ProfileValidationError() + canonical = _canonical(payload) + expected = hmac.new(secret, canonical, hashlib.sha256).hexdigest() + if not hmac.compare_digest(signature["value"], expected): + raise ProfileValidationError() + if ( + type(payload["schema_version"]) is not int + or payload["schema_version"] != 1 + or not isinstance(payload["bundle_version"], str) + or not payload["bundle_version"] + ): + raise ProfileValidationError() + issued_at = _timestamp(payload["issued_at"]) + expires_at = _timestamp(payload["expires_at"]) + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + if issued_at > current or expires_at <= current or issued_at >= expires_at: + raise ProfileValidationError() + profile_values = payload["profiles"] + if not isinstance(profile_values, list) or not profile_values or len(profile_values) > 1024: + raise ProfileValidationError() + profiles: dict[str, CommandProfile] = {} + for raw_profile in profile_values: + profile = _parse_profile(raw_profile) + if profile.profile_id in profiles: + raise ProfileValidationError() + profiles[profile.profile_id] = profile + instance = object.__new__(cls) + object.__setattr__(instance, "schema_version", 1) + object.__setattr__(instance, "bundle_version", payload["bundle_version"]) + object.__setattr__(instance, "key_id", expected_key_id) + object.__setattr__(instance, "issued_at", issued_at) + object.__setattr__(instance, "expires_at", expires_at) + object.__setattr__(instance, "fingerprint", hashlib.sha256(canonical).hexdigest()) + object.__setattr__(instance, "_profiles", MappingProxyType(profiles)) + return instance + + def __repr__(self) -> str: + return ( + f"CommandProfileBundle(schema_version={self.schema_version}, " + f"bundle_version={self.bundle_version!r}, key_id={self.key_id!r}, " + f"profiles={len(self._profiles)}, fingerprint={self.fingerprint!r})" + ) + + @property + def profile_ids(self) -> tuple[str, ...]: + """Return the validated profile identifiers in stable order.""" + return tuple(sorted(self._profiles)) + + def admits(self, profile_id: str, argv: Sequence[str], *, now: datetime) -> bool: + current = now.astimezone(timezone.utc) + profile = self._profiles.get(profile_id) + return ( + self.issued_at <= current < self.expires_at + and profile is not None + and profile.admits(argv) + ) + + +def _parse_profile(value: object) -> CommandProfile: + profile = _plain_object( + value, + {"id", "executable", "arguments", "sensitive", "free_form"}, + ) + profile_id = profile["id"] + executable = profile["executable"] + arguments = profile["arguments"] + if ( + not isinstance(profile_id, str) + or _IDENTIFIER.fullmatch(profile_id) is None + or not isinstance(executable, str) + or not executable.startswith("/") + or "\x00" in executable + or len(executable.encode("utf-8")) > 4096 + or not isinstance(arguments, list) + or len(arguments) > 128 + or type(profile["sensitive"]) is not bool + or type(profile["free_form"]) is not bool + ): + raise ProfileValidationError() + return CommandProfile( + profile_id=profile_id, + executable=executable, + arguments=tuple(ArgumentRule.from_wire(item) for item in arguments), + sensitive=profile["sensitive"], + free_form=profile["free_form"], + ) + + +def _trusted_bundle( + bundle_type: type[CommandProfileBundle], + *, + bundle_version: str, + issued_at: datetime, + expires_at: datetime, + profiles: Sequence[Mapping[str, Any]], + now: datetime, +) -> CommandProfileBundle: + if ( + not isinstance(bundle_version, str) + or not bundle_version + or not isinstance(issued_at, datetime) + or issued_at.tzinfo is None + or not isinstance(expires_at, datetime) + or expires_at.tzinfo is None + or not isinstance(now, datetime) + or now.tzinfo is None + or isinstance(profiles, (str, bytes)) + or not isinstance(profiles, Sequence) + or not profiles + or len(profiles) > 1024 + ): + raise ProfileValidationError() + issued = issued_at.astimezone(timezone.utc) + expires = expires_at.astimezone(timezone.utc) + current = now.astimezone(timezone.utc) + if issued > current or expires <= current or issued >= expires: + raise ProfileValidationError() + parsed: dict[str, CommandProfile] = {} + profile_values = list(profiles) + for raw_profile in profile_values: + profile = _parse_profile(raw_profile) + if profile.profile_id in parsed or profile.sensitive or profile.free_form: + raise ProfileValidationError() + parsed[profile.profile_id] = profile + identity = { + "schema_version": 1, + "bundle_version": bundle_version, + "issued_at": issued.isoformat(), + "expires_at": expires.isoformat(), + "profiles": profile_values, + } + instance = object.__new__(bundle_type) + object.__setattr__(instance, "schema_version", 1) + object.__setattr__(instance, "bundle_version", bundle_version) + object.__setattr__(instance, "key_id", "") + object.__setattr__(instance, "issued_at", issued) + object.__setattr__(instance, "expires_at", expires) + object.__setattr__(instance, "fingerprint", hashlib.sha256(_canonical(identity)).hexdigest()) + object.__setattr__(instance, "_profiles", MappingProxyType(parsed)) + return instance + + +def _sign_for_test(payload: Mapping[str, Any], secret: bytes, key_id: str) -> bytes: + canonical = _canonical(payload) + root = { + "payload": payload, + "signature": { + "algorithm": "hmac-sha256", + "key_id": key_id, + "value": hmac.new(secret, canonical, hashlib.sha256).hexdigest(), + }, + } + return _canonical(root) diff --git a/openbox_sandbox/py.typed b/openbox_sandbox/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/openbox_sandbox/receipts.py b/openbox_sandbox/receipts.py new file mode 100644 index 0000000..47eccc7 --- /dev/null +++ b/openbox_sandbox/receipts.py @@ -0,0 +1,541 @@ +"""Governed-command receipt verification. + +The signed verifier remains fail closed. The separately named local verifier is +explicitly insecure and exists only for local/offline testing. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import secrets +import threading +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Any, Callable, Mapping, Protocol, Sequence + +if TYPE_CHECKING: + from .registry import GovernedCommandRegistry + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from .contracts import GovernedCommandReceipt, GovernedCommandRequest + +_SHA256 = re.compile(r"[0-9a-f]{64}\Z") +_ED25519_SIGNATURE = re.compile(r"[0-9a-f]{128}\Z") +_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\Z") +_WORKFLOW_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,255}\Z") +_MAX_RECEIPT_LIFETIME = timedelta(minutes=10) + + +class GovernedCommandReceiptError(ValueError): + """Raised when a command is not authorized by its receipt.""" + + +def _canonical(value: object) -> bytes: + try: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + except (TypeError, ValueError) as error: + raise GovernedCommandReceiptError("governed command receipt rejected") from error + + +def _timestamp(value: object) -> datetime: + if not isinstance(value, str) or not value.endswith("Z"): + raise GovernedCommandReceiptError("governed command receipt rejected") + try: + parsed = datetime.fromisoformat(value[:-1] + "+00:00") + except ValueError as error: + raise GovernedCommandReceiptError("governed command receipt rejected") from error + if parsed.tzinfo is None: + raise GovernedCommandReceiptError("governed command receipt rejected") + return parsed.astimezone(timezone.utc) + + +def request_arguments_sha256(request: GovernedCommandRequest) -> str: + """Hash the exact typed operation and ordered safe input snapshot.""" + if not isinstance(request, GovernedCommandRequest): + raise GovernedCommandReceiptError("governed command receipt rejected") + return hashlib.sha256( + _canonical( + { + "profile_id": request.profile_id, + "arguments": [ + {"name": item.name, "value": item.value} for item in request.arguments + ], + } + ) + ).hexdigest() + + +def command_sha256(argv: Sequence[str]) -> str: + """Hash derived argv without placing argv itself in a receipt or history.""" + if ( + isinstance(argv, (str, bytes)) + or not isinstance(argv, Sequence) + or not argv + or not all(isinstance(value, str) for value in argv) + ): + raise GovernedCommandReceiptError("governed command receipt rejected") + return hashlib.sha256(_canonical(list(argv))).hexdigest() + + +def asset_bundle_sha256(asset_bundle: object) -> str: + """Hash the trusted runtime asset identity without exposing policy bodies.""" + to_wire = getattr(asset_bundle, "to_wire", None) + value = to_wire() if callable(to_wire) else asset_bundle + if not isinstance(value, Mapping): + raise GovernedCommandReceiptError("governed command receipt rejected") + return hashlib.sha256(_canonical(dict(value))).hexdigest() + + +def receipt_binding( + request: GovernedCommandRequest, + *, + command_argv: Sequence[str], + asset_bundle: object, + profile_fingerprint: str, +) -> dict[str, str]: + """Derive the safe binding fields independently used by agent and Worker.""" + if not isinstance(profile_fingerprint, str) or _SHA256.fullmatch(profile_fingerprint) is None: + raise GovernedCommandReceiptError("governed command receipt rejected") + return { + "arguments_sha256": request_arguments_sha256(request), + "command_sha256": command_sha256(command_argv), + "asset_bundle_sha256": asset_bundle_sha256(asset_bundle), + "profile_fingerprint": profile_fingerprint, + } + + +def receipt_payload(receipt: GovernedCommandReceipt) -> dict[str, Any]: + """Return the canonical signed fields, excluding the signature itself.""" + return { + "schema_version": receipt.schema_version, + "receipt_id": receipt.receipt_id, + "nonce": receipt.nonce, + "workflow_id": receipt.workflow_id, + "verdict": receipt.verdict, + "profile_id": receipt.profile_id, + "arguments_sha256": receipt.arguments_sha256, + "command_sha256": receipt.command_sha256, + "asset_bundle_sha256": receipt.asset_bundle_sha256, + "profile_fingerprint": receipt.profile_fingerprint, + "issued_at": receipt.issued_at, + "expires_at": receipt.expires_at, + "key_id": receipt.key_id, + } + + +@dataclass(frozen=True, slots=True) +class _VerificationErrors: + required: str + rejected: str + verifier_rejected: str + consumed: str + + +_SIGNED_ERRORS = _VerificationErrors( + required="governed command receipt required", + rejected="governed command receipt rejected", + verifier_rejected="receipt verifier rejected", + consumed="governed command receipt already consumed", +) +_INSECURE_LOCAL_ERRORS = _VerificationErrors( + required="INSECURE LOCAL unsigned governed command receipt required", + rejected="INSECURE LOCAL unsigned governed command receipt rejected", + verifier_rejected="INSECURE LOCAL receipt verifier rejected", + consumed="INSECURE LOCAL unsigned governed command receipt already consumed", +) + + +def _validate_clock(clock: Callable[[], datetime], errors: _VerificationErrors) -> None: + if not callable(clock): + raise GovernedCommandReceiptError(errors.verifier_rejected) + + +def _validate_common_receipt( + request: GovernedCommandRequest, + *, + expected_workflow_id: str, + command_argv: Sequence[str], + asset_bundle: object, + profile_fingerprint: str, + errors: _VerificationErrors, +) -> GovernedCommandReceipt: + """Validate every receipt property unrelated to key authentication.""" + if not isinstance(request, GovernedCommandRequest): + raise GovernedCommandReceiptError(errors.rejected) + receipt = request.receipt + if receipt is None: + raise GovernedCommandReceiptError(errors.required) + if not isinstance(receipt, GovernedCommandReceipt): + raise GovernedCommandReceiptError(errors.rejected) + try: + binding = receipt_binding( + request, + command_argv=command_argv, + asset_bundle=asset_bundle, + profile_fingerprint=profile_fingerprint, + ) + except GovernedCommandReceiptError as error: + if str(error) == errors.rejected: + raise + raise GovernedCommandReceiptError(errors.rejected) from error + + common_strings = ( + receipt.receipt_id, + receipt.nonce, + receipt.workflow_id, + receipt.profile_id, + receipt.arguments_sha256, + receipt.command_sha256, + receipt.asset_bundle_sha256, + receipt.profile_fingerprint, + receipt.issued_at, + receipt.expires_at, + ) + if ( + type(receipt.schema_version) is not int + or receipt.schema_version != 1 + or receipt.verdict != "constrain" + or not all(isinstance(value, str) and value for value in common_strings) + or _IDENTIFIER.fullmatch(receipt.receipt_id) is None + or _IDENTIFIER.fullmatch(receipt.nonce) is None + or _WORKFLOW_ID.fullmatch(receipt.workflow_id) is None + or not isinstance(expected_workflow_id, str) + or _WORKFLOW_ID.fullmatch(expected_workflow_id) is None + or receipt.workflow_id != expected_workflow_id + or _IDENTIFIER.fullmatch(receipt.profile_id) is None + or receipt.profile_id != request.profile_id + or _SHA256.fullmatch(receipt.arguments_sha256) is None + or _SHA256.fullmatch(receipt.command_sha256) is None + or _SHA256.fullmatch(receipt.asset_bundle_sha256) is None + or _SHA256.fullmatch(receipt.profile_fingerprint) is None + or receipt.arguments_sha256 != binding["arguments_sha256"] + or receipt.command_sha256 != binding["command_sha256"] + or receipt.asset_bundle_sha256 != binding["asset_bundle_sha256"] + or receipt.profile_fingerprint != binding["profile_fingerprint"] + ): + raise GovernedCommandReceiptError(errors.rejected) + + return receipt + + +def _validate_common_time_window( + receipt: GovernedCommandReceipt, + *, + clock: Callable[[], datetime], + errors: _VerificationErrors, +) -> None: + """Validate the shared issued/expiry window against a verifier clock.""" + try: + issued_at = _timestamp(receipt.issued_at) + expires_at = _timestamp(receipt.expires_at) + except GovernedCommandReceiptError as error: + if str(error) == errors.rejected: + raise + raise GovernedCommandReceiptError(errors.rejected) from error + now = clock() + if not isinstance(now, datetime) or now.tzinfo is None: + raise GovernedCommandReceiptError(errors.verifier_rejected) + now = now.astimezone(timezone.utc) + lifetime = expires_at - issued_at + if ( + issued_at > now + or expires_at <= now + or issued_at >= expires_at + or lifetime > _MAX_RECEIPT_LIFETIME + ): + raise GovernedCommandReceiptError(errors.rejected) + + +def _consume_receipt( + receipt: GovernedCommandReceipt, + *, + consumed_receipt_ids: set[str], + consumed_nonces: set[str], + lock: threading.Lock, + error_message: str, +) -> str: + """Atomically consume one validated receipt ID and nonce in this process.""" + with lock: + if receipt.receipt_id in consumed_receipt_ids or receipt.nonce in consumed_nonces: + raise GovernedCommandReceiptError(error_message) + consumed_receipt_ids.add(receipt.receipt_id) + consumed_nonces.add(receipt.nonce) + return receipt.receipt_id + + +@dataclass(repr=False) +class GovernedCommandReceiptVerifier: + """Ed25519 verifier with atomic in-process one-time receipt consumption.""" + + key_id: str + public_key: bytes + clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc) + _consumed_receipt_ids: set[str] = field(default_factory=set, init=False, repr=False) + _consumed_nonces: set[str] = field(default_factory=set, init=False, repr=False) + _consumption_lock: threading.Lock = field( + default_factory=threading.Lock, init=False, repr=False + ) + + def __post_init__(self) -> None: + if ( + not isinstance(self.key_id, str) + or _IDENTIFIER.fullmatch(self.key_id) is None + or not isinstance(self.public_key, bytes) + or len(self.public_key) != 32 + ): + raise GovernedCommandReceiptError(_SIGNED_ERRORS.verifier_rejected) + _validate_clock(self.clock, _SIGNED_ERRORS) + try: + Ed25519PublicKey.from_public_bytes(self.public_key) + except ValueError as error: + raise GovernedCommandReceiptError(_SIGNED_ERRORS.verifier_rejected) from error + + def verify( + self, + request: GovernedCommandRequest, + *, + expected_workflow_id: str, + command_argv: Sequence[str], + asset_bundle: object, + profile_fingerprint: str, + ) -> str: + receipt = _validate_common_receipt( + request, + expected_workflow_id=expected_workflow_id, + command_argv=command_argv, + asset_bundle=asset_bundle, + profile_fingerprint=profile_fingerprint, + errors=_SIGNED_ERRORS, + ) + if ( + not isinstance(receipt.key_id, str) + or not receipt.key_id + or receipt.key_id != self.key_id + or not isinstance(receipt.signature, str) + or not receipt.signature + or _ED25519_SIGNATURE.fullmatch(receipt.signature) is None + ): + raise GovernedCommandReceiptError(_SIGNED_ERRORS.rejected) + _validate_common_time_window(receipt, clock=self.clock, errors=_SIGNED_ERRORS) + try: + Ed25519PublicKey.from_public_bytes(self.public_key).verify( + bytes.fromhex(receipt.signature), _canonical(receipt_payload(receipt)) + ) + except (InvalidSignature, ValueError) as error: + raise GovernedCommandReceiptError(_SIGNED_ERRORS.rejected) from error + + # Consumption happens only after every structural, binding, temporal, and + # cryptographic check passes. The lock makes concurrent duplicate use + # fail closed before any caller can begin sandbox creation. + return _consume_receipt( + receipt, + consumed_receipt_ids=self._consumed_receipt_ids, + consumed_nonces=self._consumed_nonces, + lock=self._consumption_lock, + error_message=_SIGNED_ERRORS.consumed, + ) + + def __repr__(self) -> str: + return ( + f"SandboxReceiptVerifier(key_id={self.key_id!r}, " + "public_key=, replay_protection=in_process)" + ) + + +@dataclass(repr=False) +class InsecureLocalReceiptVerifier: + """INSECURE local/testing-only verifier that ignores receipt signatures. + + This verifier is never selected automatically and must be constructed + explicitly. It performs the same schema, binding, time-window, lifetime, + and atomic in-process replay checks as the signed verifier, but deliberately + does not require a Core key. ``key_id`` is syntactically required but its + value is not authenticated, and any string ``signature`` (including an + empty unsigned value) is ignored. Never use this verifier in production. + """ + + clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc) + _consumed_receipt_ids: set[str] = field(default_factory=set, init=False, repr=False) + _consumed_nonces: set[str] = field(default_factory=set, init=False, repr=False) + _consumption_lock: threading.Lock = field( + default_factory=threading.Lock, init=False, repr=False + ) + + def __post_init__(self) -> None: + _validate_clock(self.clock, _INSECURE_LOCAL_ERRORS) + + def verify( + self, + request: GovernedCommandRequest, + *, + expected_workflow_id: str, + command_argv: Sequence[str], + asset_bundle: object, + profile_fingerprint: str, + ) -> str: + """Validate and consume one unsigned local/testing receipt.""" + receipt = _validate_common_receipt( + request, + expected_workflow_id=expected_workflow_id, + command_argv=command_argv, + asset_bundle=asset_bundle, + profile_fingerprint=profile_fingerprint, + errors=_INSECURE_LOCAL_ERRORS, + ) + if ( + not isinstance(receipt.key_id, str) + or not receipt.key_id + or _IDENTIFIER.fullmatch(receipt.key_id) is None + or not isinstance(receipt.signature, str) + ): + raise GovernedCommandReceiptError(_INSECURE_LOCAL_ERRORS.rejected) + + _validate_common_time_window(receipt, clock=self.clock, errors=_INSECURE_LOCAL_ERRORS) + + # The caller invokes verification before dispatcher sandbox creation. + # Consume only after all retained checks pass, and atomically reject + # concurrent duplicate receipt IDs or nonces in this process. + return _consume_receipt( + receipt, + consumed_receipt_ids=self._consumed_receipt_ids, + consumed_nonces=self._consumed_nonces, + lock=self._consumption_lock, + error_message=_INSECURE_LOCAL_ERRORS.consumed, + ) + + def __repr__(self) -> str: + return ( + "InsecureLocalReceiptVerifier(" + "mode=INSECURE_LOCAL_UNSIGNED_TESTING_ONLY, " + "signature_verification=disabled, " + "replay_protection=in_process)" + ) + + +@dataclass(frozen=True, slots=True) +class AuthorizedConstrain: + """Explicit caller attestation that Core already returned ``CONSTRAIN``.""" + + verdict: str + authorization_id: str + + def __post_init__(self) -> None: + if ( + self.verdict != "constrain" + or not isinstance(self.authorization_id, str) + or _IDENTIFIER.fullmatch(self.authorization_id) is None + ): + raise GovernedCommandReceiptError("authorized CONSTRAIN attestation required") + + +class ReceiptSigner(Protocol): + """External signer boundary; implementations retain their private key.""" + + def sign(self, payload: bytes) -> bytes: ... + + +def _rfc3339(value: datetime) -> str: + return value.isoformat(timespec="seconds").replace("+00:00", "Z") + + +def issue_sandbox_receipt( + request: GovernedCommandRequest, + *, + authorization: AuthorizedConstrain, + registry: GovernedCommandRegistry, + workflow_id: str, + key_id: str, + signer: ReceiptSigner, + ttl: timedelta = timedelta(minutes=5), + now: datetime | None = None, +) -> GovernedCommandRequest: + """Bind one already-authorized request to the installed approved release. + + The issuer does not call Core and cannot mint a receipt without the explicit + ``AuthorizedConstrain`` marker. The signer receives only canonical public + receipt bytes; raw private-key material never enters this API. + """ + from .registry import GovernedCommandRegistry + from .release import materialize_approved_sandbox_release + + if ( + not isinstance(request, GovernedCommandRequest) + or request.receipt is not None + or not isinstance(authorization, AuthorizedConstrain) + or not isinstance(registry, GovernedCommandRegistry) + or not isinstance(workflow_id, str) + or _WORKFLOW_ID.fullmatch(workflow_id) is None + or not isinstance(key_id, str) + or _IDENTIFIER.fullmatch(key_id) is None + or not callable(getattr(signer, "sign", None)) + or not isinstance(ttl, timedelta) + ): + raise GovernedCommandReceiptError("governed command receipt issuance rejected") + seconds = ttl.total_seconds() + if not seconds.is_integer() or not 1 <= seconds <= _MAX_RECEIPT_LIFETIME.total_seconds(): + raise GovernedCommandReceiptError("governed command receipt issuance rejected") + current = now or datetime.now(timezone.utc) + if not isinstance(current, datetime) or current.tzinfo is None: + raise GovernedCommandReceiptError("governed command receipt issuance rejected") + issued_at = current.astimezone(timezone.utc).replace(microsecond=0) + expires_at = issued_at + ttl + + profiles = registry.structured_profile_bundle() + try: + argv = profiles.derive(request, now=issued_at) + profile_fingerprint = profiles.profile_fingerprint( + request.profile_id, + now=issued_at, + ) + asset_bundle = materialize_approved_sandbox_release().asset_bundle + binding = receipt_binding( + request, + command_argv=argv, + asset_bundle=asset_bundle, + profile_fingerprint=profile_fingerprint, + ) + unsigned = GovernedCommandReceipt( + schema_version=1, + receipt_id=authorization.authorization_id, + nonce=secrets.token_hex(16), + workflow_id=workflow_id, + verdict="constrain", + profile_id=request.profile_id, + arguments_sha256=binding["arguments_sha256"], + command_sha256=binding["command_sha256"], + asset_bundle_sha256=binding["asset_bundle_sha256"], + profile_fingerprint=binding["profile_fingerprint"], + issued_at=_rfc3339(issued_at), + expires_at=_rfc3339(expires_at), + key_id=key_id, + signature="", + ) + signature = signer.sign(_canonical(receipt_payload(unsigned))) + except GovernedCommandReceiptError: + raise + except Exception: + raise GovernedCommandReceiptError("governed command receipt issuance rejected") from None + if not isinstance(signature, bytes) or len(signature) != 64: + raise GovernedCommandReceiptError("governed command receipt issuance rejected") + receipt = GovernedCommandReceipt( + **{ + **receipt_payload(unsigned), + "signature": signature.hex(), + } + ) + return GovernedCommandRequest(request.profile_id, request.arguments, receipt) + + +# Public framework-neutral names. +SandboxReceiptError = GovernedCommandReceiptError +SandboxReceiptVerifier = GovernedCommandReceiptVerifier diff --git a/openbox_sandbox/registry.py b/openbox_sandbox/registry.py new file mode 100644 index 0000000..4a2bbd1 --- /dev/null +++ b/openbox_sandbox/registry.py @@ -0,0 +1,478 @@ +"""Immutable typed application command registry for governed commands. + +The registry is the only public way applications define governed commands. +It is constructed from plain typed values in code — never from manifest +files, environment selectors, JSON documents, or signed bundles — and it +produces both the structured argv-derivation mapping and the dispatcher +admission bundle from the same in-memory definitions, so the two can never +diverge. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, Mapping + +from .command_profiles import ( + StructuredCommandProfileBundle, + _ArgumentMapping, + _Profile, + _ResultField, + _ResultSchema, +) + +if TYPE_CHECKING: + from .profiles import CommandProfileBundle + +_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z") +_FIELD = re.compile(r"[A-Za-z][A-Za-z0-9_]{0,63}\Z") +_FORBIDDEN_FIELD_PARTS = ( + "argv", + "command", + "cmd", + "code", + "secret", + "token", + "password", + "credential", + "private_key", +) +# The registry is process-lifetime configuration. Both derived bundles use one +# fixed validity window so their canonical fingerprints stay deterministic. +_REGISTRY_ISSUED_AT = datetime(2000, 1, 1, tzinfo=timezone.utc) +_REGISTRY_EXPIRES_AT = datetime(9999, 1, 1, tzinfo=timezone.utc) +_REGISTRY_KEY_ID = "typed-registry" + + +class GovernedCommandRegistryError(ValueError): + """Raised when a typed command definition cannot be accepted.""" + + def __init__(self) -> None: + super().__init__("governed command registry rejected") + + +def _require_field_name(name: object) -> str: + if not isinstance(name, str) or _FIELD.fullmatch(name) is None: + raise GovernedCommandRegistryError() + lowered = name.lower() + if any(part in lowered for part in _FORBIDDEN_FIELD_PARTS): + raise GovernedCommandRegistryError() + return name + + +@dataclass(frozen=True, slots=True) +class LiteralArgument: + """A fixed argv token that never varies with Workflow input.""" + + value: str + + def __post_init__(self) -> None: + if ( + not isinstance(self.value, str) + or len(self.value.encode()) > 4096 + or "\x00" in self.value + ): + raise GovernedCommandRegistryError() + + +@dataclass(frozen=True, slots=True) +class IdentifierArgument: + """A caller-supplied bounded identifier token.""" + + field: str + max_bytes: int = 256 + + def __post_init__(self) -> None: + _require_field_name(self.field) + if ( + isinstance(self.max_bytes, bool) + or not isinstance(self.max_bytes, int) + or not 1 <= self.max_bytes <= 4096 + ): + raise GovernedCommandRegistryError() + + +@dataclass(frozen=True, slots=True) +class EnumArgument: + """A caller-supplied token restricted to a finite fixed choice set.""" + + field: str + values: tuple[str, ...] + + def __post_init__(self) -> None: + _require_field_name(self.field) + if ( + not isinstance(self.values, tuple) + or not self.values + or len(self.values) > 128 + or len(set(self.values)) != len(self.values) + or not all( + isinstance(item, str) and "\x00" not in item and 0 < len(item.encode()) <= 4096 + for item in self.values + ) + ): + raise GovernedCommandRegistryError() + + +@dataclass(frozen=True, slots=True) +class DecimalArgument: + """A caller-supplied bounded base-10 integer token.""" + + field: str + minimum: int + maximum: int + + def __post_init__(self) -> None: + _require_field_name(self.field) + if ( + isinstance(self.minimum, bool) + or isinstance(self.maximum, bool) + or not isinstance(self.minimum, int) + or not isinstance(self.maximum, int) + or self.minimum > self.maximum + ): + raise GovernedCommandRegistryError() + + +CommandArgument = LiteralArgument | IdentifierArgument | EnumArgument | DecimalArgument + + +@dataclass(frozen=True, slots=True) +class IdentifierResultField: + """A bounded identifier admitted from canonical JSON output.""" + + name: str + max_bytes: int = 256 + + def __post_init__(self) -> None: + _require_field_name(self.name) + if type(self.max_bytes) is not int or not 1 <= self.max_bytes <= 4096: + raise GovernedCommandRegistryError() + + def _profile_field(self) -> _ResultField: + return _ResultField(self.name, "identifier", max_bytes=self.max_bytes) + + def _canonical(self) -> dict[str, Any]: + return {"name": self.name, "kind": "identifier", "max_bytes": self.max_bytes} + + +@dataclass(frozen=True, slots=True) +class IntegerResultField: + """A bounded integer admitted from canonical JSON output.""" + + name: str + minimum: int + maximum: int + + def __post_init__(self) -> None: + _require_field_name(self.name) + if ( + type(self.minimum) is not int + or type(self.maximum) is not int + or self.minimum > self.maximum + ): + raise GovernedCommandRegistryError() + + def _profile_field(self) -> _ResultField: + return _ResultField( + self.name, + "integer", + minimum=self.minimum, + maximum=self.maximum, + ) + + def _canonical(self) -> dict[str, Any]: + return { + "name": self.name, + "kind": "integer", + "minimum": self.minimum, + "maximum": self.maximum, + } + + +ResultField = IdentifierResultField | IntegerResultField + + +@dataclass(frozen=True, slots=True) +class TypedJsonResultSchema: + """Canonical bounded JSON output admitted into durable framework results.""" + + name: str + fields: tuple[ResultField, ...] + max_bytes: int = 16 * 1024 + + def __post_init__(self) -> None: + if ( + not isinstance(self.name, str) + or _IDENTIFIER.fullmatch(self.name) is None + or len(self.name.encode("utf-8")) > 128 + or not isinstance(self.fields, tuple) + or not self.fields + or len(self.fields) > 64 + or not all( + isinstance(field, (IdentifierResultField, IntegerResultField)) + for field in self.fields + ) + or len({field.name for field in self.fields}) != len(self.fields) + or type(self.max_bytes) is not int + or not 1 <= self.max_bytes <= 16 * 1024 + ): + raise GovernedCommandRegistryError() + + def _profile_schema(self) -> _ResultSchema: + return _ResultSchema( + self.name, + self.max_bytes, + tuple(field._profile_field() for field in self.fields), + ) + + def _canonical(self) -> dict[str, Any]: + return { + "name": self.name, + "max_bytes": self.max_bytes, + "fields": [field._canonical() for field in self.fields], + } + + +@dataclass(frozen=True, slots=True) +class GovernedCommandDefinition: + """One typed governed command: absolute executable plus bounded arguments.""" + + command_id: str + executable: str + arguments: tuple[CommandArgument, ...] = () + result_schema: TypedJsonResultSchema | None = None + + def __post_init__(self) -> None: + if ( + not isinstance(self.command_id, str) + or _IDENTIFIER.fullmatch(self.command_id) is None + or len(self.command_id.encode()) > 128 + or not isinstance(self.executable, str) + or not self.executable.startswith("/") + or "\x00" in self.executable + or len(self.executable.encode()) > 4096 + or not isinstance(self.arguments, tuple) + or len(self.arguments) > 128 + or ( + self.result_schema is not None + and not isinstance(self.result_schema, TypedJsonResultSchema) + ) + ): + raise GovernedCommandRegistryError() + fields: list[str] = [] + for argument in self.arguments: + if not isinstance( + argument, + (LiteralArgument, IdentifierArgument, EnumArgument, DecimalArgument), + ): + raise GovernedCommandRegistryError() + if not isinstance(argument, LiteralArgument): + fields.append(argument.field) + if len(fields) != len(set(fields)): + raise GovernedCommandRegistryError() + + def _canonical(self) -> dict[str, Any]: + arguments: list[dict[str, Any]] = [] + for argument in self.arguments: + if isinstance(argument, LiteralArgument): + arguments.append({"kind": "literal", "value": argument.value}) + elif isinstance(argument, IdentifierArgument): + arguments.append( + { + "kind": "identifier", + "field": argument.field, + "max_bytes": argument.max_bytes, + } + ) + elif isinstance(argument, EnumArgument): + arguments.append( + { + "kind": "enum", + "field": argument.field, + "values": list(argument.values), + } + ) + else: + arguments.append( + { + "kind": "decimal", + "field": argument.field, + "minimum": argument.minimum, + "maximum": argument.maximum, + } + ) + return { + "command_id": self.command_id, + "executable": self.executable, + "arguments": arguments, + "result_schema": ( + None if self.result_schema is None else self.result_schema._canonical() + ), + } + + +@dataclass(frozen=True, init=False, repr=False) +class GovernedCommandRegistry: + """Immutable, canonically fingerprinted set of typed command definitions.""" + + fingerprint: str + _definitions: Mapping[str, GovernedCommandDefinition] = field(compare=False) + + def __init__(self, commands: tuple[GovernedCommandDefinition, ...]) -> None: + if ( + not isinstance(commands, tuple) + or not commands + or len(commands) > 1024 + or not all(isinstance(item, GovernedCommandDefinition) for item in commands) + ): + raise GovernedCommandRegistryError() + definitions: dict[str, GovernedCommandDefinition] = {} + for command in commands: + if command.command_id in definitions: + raise GovernedCommandRegistryError() + definitions[command.command_id] = command + canonical = json.dumps( + { + "schema": "openbox-governed-command-registry/v1", + "commands": [definitions[key]._canonical() for key in sorted(definitions)], + }, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode() + object.__setattr__(self, "fingerprint", hashlib.sha256(canonical).hexdigest()) + object.__setattr__(self, "_definitions", MappingProxyType(definitions)) + + def __repr__(self) -> str: + return ( + f"GovernedCommandRegistry(commands={len(self._definitions)}, " + f"fingerprint={self.fingerprint!r})" + ) + + @property + def command_ids(self) -> tuple[str, ...]: + return tuple(sorted(self._definitions)) + + @property + def bundle_version(self) -> str: + return f"typed-registry-{self.fingerprint[:16]}" + + def structured_profile_bundle(self) -> StructuredCommandProfileBundle: + """Build the framework-neutral structured-input mapping.""" + profiles: dict[str, _Profile] = {} + for command in self._definitions.values(): + mappings: list[_ArgumentMapping] = [] + for argument in command.arguments: + if isinstance(argument, LiteralArgument): + mappings.append(_ArgumentMapping(kind="literal", literal=argument.value)) + elif isinstance(argument, IdentifierArgument): + mappings.append( + _ArgumentMapping( + kind="field_identifier", + field=argument.field, + max_bytes=argument.max_bytes, + ) + ) + elif isinstance(argument, EnumArgument): + mappings.append( + _ArgumentMapping( + kind="field_enum", + field=argument.field, + values=argument.values, + ) + ) + else: + mappings.append( + _ArgumentMapping( + kind="field_decimal", + field=argument.field, + minimum=argument.minimum, + maximum=argument.maximum, + ) + ) + profile_fingerprint = hashlib.sha256( + json.dumps( + command._canonical(), + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest() + profiles[command.command_id] = _Profile( + command.command_id, + command.executable, + tuple(mappings), + profile_fingerprint, + ( + None + if command.result_schema is None + else command.result_schema._profile_schema() + ), + ) + bundle = object.__new__(StructuredCommandProfileBundle) + object.__setattr__(bundle, "schema_version", 1) + object.__setattr__(bundle, "bundle_version", self.bundle_version) + object.__setattr__(bundle, "key_id", _REGISTRY_KEY_ID) + object.__setattr__(bundle, "issued_at", _REGISTRY_ISSUED_AT) + object.__setattr__(bundle, "expires_at", _REGISTRY_EXPIRES_AT) + object.__setattr__(bundle, "fingerprint", self.fingerprint) + object.__setattr__(bundle, "_profiles", MappingProxyType(profiles)) + return bundle + + def admission_profile_bundle(self) -> "CommandProfileBundle": + """Build the independent engine admission bundle.""" + from .profiles import ( + ArgumentRule, + CommandProfile, + CommandProfileBundle, + ) + + profiles: dict[str, CommandProfile] = {} + for command in self._definitions.values(): + rules: list[ArgumentRule] = [] + for argument in command.arguments: + if isinstance(argument, LiteralArgument): + rules.append(ArgumentRule(kind="literal", literal=argument.value)) + elif isinstance(argument, IdentifierArgument): + rules.append(ArgumentRule(kind="identifier", max_bytes=argument.max_bytes)) + elif isinstance(argument, EnumArgument): + rules.append(ArgumentRule(kind="enum", choices=argument.values)) + else: + rules.append( + ArgumentRule( + kind="decimal", + minimum=argument.minimum, + maximum=argument.maximum, + ) + ) + profiles[command.command_id] = CommandProfile( + profile_id=command.command_id, + executable=command.executable, + arguments=tuple(rules), + sensitive=False, + free_form=False, + ) + bundle = object.__new__(CommandProfileBundle) + object.__setattr__(bundle, "schema_version", 1) + object.__setattr__(bundle, "bundle_version", self.bundle_version) + object.__setattr__(bundle, "key_id", _REGISTRY_KEY_ID) + object.__setattr__(bundle, "issued_at", _REGISTRY_ISSUED_AT) + object.__setattr__(bundle, "expires_at", _REGISTRY_EXPIRES_AT) + object.__setattr__(bundle, "fingerprint", self.fingerprint) + object.__setattr__(bundle, "_profiles", MappingProxyType(profiles)) + return bundle + + +def governed_command_registry( + *commands: GovernedCommandDefinition, +) -> GovernedCommandRegistry: + """Build an immutable registry from typed command definitions.""" + return GovernedCommandRegistry(tuple(commands)) diff --git a/openbox_sandbox/release.py b/openbox_sandbox/release.py new file mode 100644 index 0000000..72dcada --- /dev/null +++ b/openbox_sandbox/release.py @@ -0,0 +1,274 @@ +"""Explicit, process-wide sandbox release approval. + +No release identity is embedded in this distribution. The application owner +must load one exact release declaration from an owner-controlled absolute path +before a deployment can be materialized. The declaration and policy are read +through verified descriptors and can be installed only once per process. +""" + +from __future__ import annotations + +import hashlib +import re +import threading +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ._trusted_files import load_strict_json, read_trusted_file +from .errors import GovernedCommandDeploymentError +from .runtime import AssetBundleIdentity, PolicyDocument, PolicyIdentity + +_HEX_SHA256 = re.compile(r"[0-9a-f]{64}\Z") +_IMAGE = re.compile(r"[^\s]+@sha256:[0-9a-f]{64}\Z") +_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") +_MAX_POLICY_BYTES = 1024 * 1024 + + +def _exact(value: object, fields: set[str]) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + raise GovernedCommandDeploymentError() + return value + + +def _integer(value: object, minimum: int, maximum: int) -> int: + if type(value) is not int or not minimum <= value <= maximum: + raise GovernedCommandDeploymentError() + return value + + +def _string(value: object, *, maximum: int = 4096) -> str: + if ( + not isinstance(value, str) + or not value + or "\x00" in value + or any(ord(character) < 32 for character in value) + or len(value.encode("utf-8")) > maximum + ): + raise GovernedCommandDeploymentError() + return value + + +def _absolute_path(value: object) -> Path: + path = Path(_string(value)) + if not path.is_absolute(): + raise GovernedCommandDeploymentError() + return path + + +@dataclass(frozen=True, slots=True, repr=False) +class ApprovedSandboxRelease: + """One exact, immutable sandbox runtime and policy identity.""" + + runtime_contract_version: int + adapter_build_sha256: str + template: str + policy_id: str + policy_version: int + policy_media_type: str + policy_body: bytes + compatibility_id: str + + def __post_init__(self) -> None: + if ( + type(self.runtime_contract_version) is not int + or not 1 <= self.runtime_contract_version <= 2**32 - 1 + or not isinstance(self.adapter_build_sha256, str) + or _HEX_SHA256.fullmatch(self.adapter_build_sha256) is None + or not isinstance(self.template, str) + or _IMAGE.fullmatch(self.template) is None + or not isinstance(self.policy_id, str) + or _IDENTIFIER.fullmatch(self.policy_id) is None + or type(self.policy_version) is not int + or not 1 <= self.policy_version <= 2**32 - 1 + or not isinstance(self.policy_media_type, str) + or not 0 < len(self.policy_media_type.encode("utf-8")) <= 128 + or not isinstance(self.policy_body, bytes) + or not 0 < len(self.policy_body) <= _MAX_POLICY_BYTES + or not isinstance(self.compatibility_id, str) + or _IDENTIFIER.fullmatch(self.compatibility_id) is None + ): + raise GovernedCommandDeploymentError() + + @property + def policy_sha256(self) -> str: + return hashlib.sha256(self.policy_body).hexdigest() + + def __repr__(self) -> str: + return ( + "ApprovedSandboxRelease(" + f"runtime_contract_version={self.runtime_contract_version}, " + f"template=, policy_id={self.policy_id!r}, " + f"policy_version={self.policy_version}, " + f"compatibility_id={self.compatibility_id!r}, policy_body=)" + ) + + +@dataclass(frozen=True, slots=True, repr=False) +class SandboxReleaseMaterial: + """Runtime values derived exclusively from the installed release.""" + + release: ApprovedSandboxRelease + asset_bundle: AssetBundleIdentity + policy_document: PolicyDocument + + def __repr__(self) -> str: + return ( + "SandboxReleaseMaterial(" + f"release={self.release!r}, asset_bundle={self.asset_bundle!r}, " + "policy_document=)" + ) + + +@dataclass(frozen=True, slots=True) +class _ReleaseDeclaration: + runtime_contract_version: int + adapter_build_sha256: str + template: str + policy_id: str + policy_version: int + policy_sha256: str + policy_media_type: str + policy_path: Path + compatibility_id: str + + +def _parse_release_declaration(value: object) -> _ReleaseDeclaration: + root = _exact( + value, + { + "runtime_contract_version", + "adapter_build_sha256", + "template", + "policy", + "compatibility_id", + }, + ) + policy = _exact(root["policy"], {"id", "version", "sha256", "media_type", "path"}) + adapter_hash = _string(root["adapter_build_sha256"], maximum=64) + template = _string(root["template"]) + policy_hash = _string(policy["sha256"], maximum=64) + if ( + _HEX_SHA256.fullmatch(adapter_hash) is None + or _IMAGE.fullmatch(template) is None + or _HEX_SHA256.fullmatch(policy_hash) is None + ): + raise GovernedCommandDeploymentError() + return _ReleaseDeclaration( + runtime_contract_version=_integer(root["runtime_contract_version"], 1, 2**32 - 1), + adapter_build_sha256=adapter_hash, + template=template, + policy_id=_string(policy["id"], maximum=128), + policy_version=_integer(policy["version"], 1, 2**32 - 1), + policy_sha256=policy_hash, + policy_media_type=_string(policy["media_type"], maximum=128), + policy_path=_absolute_path(policy["path"]), + compatibility_id=_string(root["compatibility_id"], maximum=128), + ) + + +def _release_from_declaration(declaration: _ReleaseDeclaration) -> ApprovedSandboxRelease: + policy_body = read_trusted_file(declaration.policy_path, maximum=_MAX_POLICY_BYTES) + if hashlib.sha256(policy_body).hexdigest() != declaration.policy_sha256: + raise GovernedCommandDeploymentError() + release = ApprovedSandboxRelease( + runtime_contract_version=declaration.runtime_contract_version, + adapter_build_sha256=declaration.adapter_build_sha256, + template=declaration.template, + policy_id=declaration.policy_id, + policy_version=declaration.policy_version, + policy_media_type=declaration.policy_media_type, + policy_body=policy_body, + compatibility_id=declaration.compatibility_id, + ) + _material_from_release(release) + return release + + +def _declaration_matches( + declaration: _ReleaseDeclaration, + release: ApprovedSandboxRelease, +) -> bool: + try: + policy_body = read_trusted_file(declaration.policy_path, maximum=_MAX_POLICY_BYTES) + except GovernedCommandDeploymentError: + return False + return ( + declaration.runtime_contract_version == release.runtime_contract_version + and declaration.adapter_build_sha256 == release.adapter_build_sha256 + and declaration.template == release.template + and declaration.policy_id == release.policy_id + and declaration.policy_version == release.policy_version + and declaration.policy_sha256 == release.policy_sha256 + and declaration.policy_media_type == release.policy_media_type + and policy_body == release.policy_body + and declaration.compatibility_id == release.compatibility_id + ) + + +def _material_from_release(release: ApprovedSandboxRelease) -> SandboxReleaseMaterial: + try: + policy = PolicyIdentity( + release.policy_id, + release.policy_version, + release.policy_sha256, + ) + asset_bundle = AssetBundleIdentity( + runtime_contract_version=release.runtime_contract_version, + adapter_build_sha256=release.adapter_build_sha256, + template=release.template, + policy=policy, + compatibility_id=release.compatibility_id, + ) + document = PolicyDocument(release.policy_media_type, release.policy_body) + except (TypeError, ValueError): + raise GovernedCommandDeploymentError() from None + return SandboxReleaseMaterial(release, asset_bundle, document) + + +_lock = threading.Lock() +_installed: ApprovedSandboxRelease | None = None + + +def _install_approved_sandbox_release(release: ApprovedSandboxRelease) -> None: + global _installed + with _lock: + if _installed is not None and _installed != release: + raise GovernedCommandDeploymentError() + _installed = release + + +def load_approved_sandbox_release(path: Path) -> ApprovedSandboxRelease: + """Load and atomically install one explicit owner-approved release file.""" + try: + if not isinstance(path, Path) or not path.is_absolute(): + raise GovernedCommandDeploymentError() + root = _exact(load_strict_json(path), {"schema_version", "release"}) + if type(root["schema_version"]) is not int or root["schema_version"] != 1: + raise GovernedCommandDeploymentError() + release = _release_from_declaration(_parse_release_declaration(root["release"])) + _install_approved_sandbox_release(release) + return release + except GovernedCommandDeploymentError: + raise GovernedCommandDeploymentError() from None + except Exception: + raise GovernedCommandDeploymentError() from None + + +def approved_sandbox_release() -> ApprovedSandboxRelease: + """Return the installed approved release, failing closed when absent.""" + with _lock: + if _installed is None: + raise GovernedCommandDeploymentError() + return _installed + + +def materialize_approved_sandbox_release() -> SandboxReleaseMaterial: + """Derive immutable runtime values from the installed release only.""" + return _material_from_release(approved_sandbox_release()) + + +def _clear_approved_sandbox_release_for_testing() -> None: + global _installed + with _lock: + _installed = None diff --git a/openbox_sandbox/result.py b/openbox_sandbox/result.py new file mode 100644 index 0000000..d6df5ba --- /dev/null +++ b/openbox_sandbox/result.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import base64 +import copy +from dataclasses import dataclass +from enum import Enum +from typing import Any, Mapping + +from .errors import NormalizedSandboxError + + +class Disposition(str, Enum): + EXECUTED_IN_SANDBOX = "executed_in_sandbox" + NOT_EXECUTED = "not_executed" + EXECUTION_INDETERMINATE = "execution_indeterminate" + + +class TimeoutStatus(str, Enum): + NOT_OBSERVED = "not_observed" + CONFIRMED_TIMEOUT = "confirmed_timeout" + POSSIBLE_TIMEOUT = "possible_timeout" + UNKNOWN = "unknown" + + +class CleanupStatus(str, Enum): + NOT_NEEDED = "not_needed" + DELETED = "deleted" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class CleanupReconciliationResult: + attempted: int + deleted: int + remaining: int + + +@dataclass(frozen=True, slots=True, repr=False) +class ExecutionMetadata: + sandbox_id: str + exit_code: int | None + stdout: bytes + stderr: bytes + timeout_status: TimeoutStatus + cleanup_status: CleanupStatus + + def __post_init__(self) -> None: + if not isinstance(self.sandbox_id, str) or not self.sandbox_id: + raise TypeError("sandbox identifier required") + if not isinstance(self.stdout, bytes) or not isinstance(self.stderr, bytes): + raise TypeError("execution output must be bytes") + + def __repr__(self) -> str: + return ( + "ExecutionMetadata(" + f"sandbox_id={self.sandbox_id!r}, exit_code={self.exit_code!r}, " + f"stdout_bytes={len(self.stdout)}, stderr_bytes={len(self.stderr)}, " + f"timeout_status={self.timeout_status.value!r}, " + f"cleanup_status={self.cleanup_status.value!r}, output=)" + ) + + def to_wire(self) -> dict[str, Any]: + return { + "sandbox_id": self.sandbox_id, + "sandbox_name": self.sandbox_id, + "exit_code": self.exit_code, + "stdout_base64": base64.b64encode(self.stdout).decode("ascii"), + "stderr_base64": base64.b64encode(self.stderr).decode("ascii"), + "timeout_status": self.timeout_status.value, + "cleanup_status": self.cleanup_status.value, + } + + +@dataclass(frozen=True, slots=True, repr=False) +class SandboxExecutionResult: + disposition: Disposition + execution: ExecutionMetadata | None + error: NormalizedSandboxError | None + _authorization: Mapping[str, Any] + + @property + def authorization(self) -> dict[str, Any]: + return copy.deepcopy(dict(self._authorization)) + + def __repr__(self) -> str: + return ( + f"SandboxExecutionResult(disposition={self.disposition.value!r}, " + f"execution={self.execution!r}, error={self.error!r}, " + "authorization=)" + ) + + def to_wire(self) -> dict[str, Any]: + return { + "authorization": self.authorization, + "disposition": self.disposition.value, + "execution": None if self.execution is None else self.execution.to_wire(), + "error": None if self.error is None else self.error.to_wire(), + } diff --git a/openbox_sandbox/runtime/__init__.py b/openbox_sandbox/runtime/__init__.py new file mode 100644 index 0000000..41aedfc --- /dev/null +++ b/openbox_sandbox/runtime/__init__.py @@ -0,0 +1,52 @@ +from .agent_client import ( + AgentProtocolError, + UnixAgentRuntimeClient, + UnixAgentRuntimeClientConfig, + agent_socket_present, + default_agent_socket_path, +) +from .client import SandboxRuntimeClient, SandboxRuntimeClientConfig +from .errors import ( + ProtocolValidationError, + SandboxServiceTransportError, + SubmissionState, + TransportFailureCode, +) +from .types import ( + AssetBundleIdentity, + CreateRequest, + ExecCompleted, + ExecRequest, + OutputLimits, + PolicyDocument, + PolicyIdentity, + ServiceResponse, + capability_token, + operation_id, + request_owned_id, +) + +__all__ = [ + "AgentProtocolError", + "AssetBundleIdentity", + "CreateRequest", + "ExecCompleted", + "ExecRequest", + "OutputLimits", + "PolicyDocument", + "PolicyIdentity", + "ProtocolValidationError", + "SandboxRuntimeClient", + "SandboxRuntimeClientConfig", + "SandboxServiceTransportError", + "ServiceResponse", + "SubmissionState", + "TransportFailureCode", + "UnixAgentRuntimeClient", + "UnixAgentRuntimeClientConfig", + "agent_socket_present", + "capability_token", + "default_agent_socket_path", + "operation_id", + "request_owned_id", +] diff --git a/openbox_sandbox/runtime/agent_client.py b/openbox_sandbox/runtime/agent_client.py new file mode 100644 index 0000000..6922d51 --- /dev/null +++ b/openbox_sandbox/runtime/agent_client.py @@ -0,0 +1,496 @@ +"""Typed Unix-domain-socket client for the local OpenBox sandbox agent. + +The local agent is an authenticated executor adapter only. This client speaks +a strict typed handshake (Hello/HelloAck) and then exactly one existing typed +service request per connection; the agent reconstructs the unchanged TCP mTLS +``RequestEnvelope`` and forwards it to sandbox-service. The agent never calls +Core, never derives argv, never chooses a policy or profile, and never retries +execution. + +Discovery is internal and deterministic (standard per-user OS runtime +directories); there is no OpenBox environment selector. The trust boundary is +the operating-system user: socket ownership, file modes, and peer credentials +are all validated against the current UID, and a hostile same-UID process is +inside that boundary by design. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import secrets +import socket +import stat +import struct +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, NoReturn + +from .client import MAX_REQUEST_BYTES, MAX_RESPONSE_BYTES, PROTOCOL_VERSION +from .errors import ( + ProtocolValidationError, + SandboxServiceTransportError, + SubmissionState, + TransportFailureCode, +) +from .types import ( + AssetBundleIdentity, + CreateRequest, + ExecRequest, + PolicyIdentity, + ServiceResponse, + capability_token, + operation_id, + request_owned_id, +) + +AGENT_PROTOCOL_VERSION = 1 +MAX_HELLO_BYTES = 256 * 1024 +_HELLO_DEADLINE_SECONDS = 5.0 +_REQUIRED_CAPABILITY = "cancel_on_disconnect" + + +class AgentProtocolError(ValueError): + """Constant public error for any invalid local-agent interaction.""" + + def __init__(self) -> None: + super().__init__("sandbox agent endpoint rejected") + + +def default_agent_socket_path() -> Path: + """Return the deterministic per-user agent socket path for this platform.""" + uid = os.getuid() + if sys.platform == "linux": + runtime_root = os.environ.get("XDG_RUNTIME_DIR") + if runtime_root and Path(runtime_root).is_absolute(): + return Path(runtime_root) / "openbox" / "agent.sock" + return Path(f"/run/user/{uid}") / "openbox" / "agent.sock" + temporary_root = os.environ.get("TMPDIR") + if temporary_root and Path(temporary_root).is_absolute(): + return Path(os.path.realpath(temporary_root)) / f"openbox-{uid}" / "agent.sock" + return Path(f"/tmp/openbox-{uid}") / "agent.sock" + + +def _reject_symlink_components(path: Path) -> None: + if not path.is_absolute(): + raise AgentProtocolError() + current = Path(path.anchor) + for part in path.parts[1:]: + current = current / part + try: + if stat.S_ISLNK(os.lstat(current).st_mode): + raise AgentProtocolError() + except FileNotFoundError: + raise + except OSError: + raise AgentProtocolError() from None + + +def agent_socket_present(path: Path) -> bool: + """Return whether a socket exists at the path; validate when present.""" + try: + _reject_symlink_components(path) + metadata = os.lstat(path) + except FileNotFoundError: + return False + if not stat.S_ISSOCK(metadata.st_mode): + raise AgentProtocolError() + _validate_socket_metadata(path, metadata) + return True + + +def _validate_socket_metadata(path: Path, metadata: os.stat_result) -> None: + parent = os.lstat(path.parent) + if ( + not stat.S_ISSOCK(metadata.st_mode) + or metadata.st_uid != os.getuid() + or stat.S_IMODE(metadata.st_mode) != 0o600 + or not stat.S_ISDIR(parent.st_mode) + or parent.st_uid != os.getuid() + or stat.S_IMODE(parent.st_mode) & 0o077 + ): + raise AgentProtocolError() + + +def _peer_uid(raw_socket: socket.socket) -> int: + if sys.platform == "linux": + credentials = raw_socket.getsockopt( + socket.SOL_SOCKET, + socket.SO_PEERCRED, # type: ignore[attr-defined] + struct.calcsize("3i"), + ) + _, uid, _ = struct.unpack("3i", credentials) + return int(uid) + if sys.platform == "darwin": + # struct xucred { u_int cr_version; uid_t cr_uid; short cr_ngroups; + # gid_t cr_groups[16]; } + raw = raw_socket.getsockopt(0, socket.LOCAL_PEERCRED, 4 + 4 + 4 + 16 * 4) + version, uid = struct.unpack_from("Ii", raw, 0) + if version != 0: + raise AgentProtocolError() + return int(uid) + raise AgentProtocolError() + + +@dataclass(frozen=True, slots=True, repr=False) +class UnixAgentRuntimeClientConfig: + socket_path: Path + asset_bundle: AssetBundleIdentity + registry_fingerprint: str + + def __post_init__(self) -> None: + if ( + not isinstance(self.socket_path, Path) + or not self.socket_path.is_absolute() + or not isinstance(self.registry_fingerprint, str) + or len(self.registry_fingerprint) != 64 + or any(character not in "0123456789abcdef" for character in self.registry_fingerprint) + ): + raise ProtocolValidationError() + + def __repr__(self) -> str: + return ( + "UnixAgentRuntimeClientConfig(socket_path=, " + f"asset_bundle={self.asset_bundle!r}, " + f"registry_fingerprint={self.registry_fingerprint!r})" + ) + + +class UnixAgentRuntimeClient: + """Drop-in runtime surface matching :class:`SandboxRuntimeClient`.""" + + def __init__(self, config: UnixAgentRuntimeClientConfig) -> None: + self._config = config + + async def _connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + path = self._config.socket_path + try: + _reject_symlink_components(path) + metadata = os.lstat(path) + _validate_socket_metadata(path, metadata) + except FileNotFoundError: + raise AgentProtocolError() from None + reader, writer = await asyncio.open_unix_connection(str(path)) + try: + raw_socket = writer.get_extra_info("socket") + if raw_socket is None or _peer_uid(raw_socket) != os.getuid(): + raise AgentProtocolError() + except AgentProtocolError: + writer.close() + raise + except OSError: + writer.close() + raise AgentProtocolError() from None + return reader, writer + + async def _write_frame( + self, + writer: asyncio.StreamWriter, + value: object, + maximum: int, + ) -> None: + body = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + if not body or len(body) > maximum: + raise ProtocolValidationError() + writer.write(struct.pack(">I", len(body)) + body) + await writer.drain() + + async def _read_frame(self, reader: asyncio.StreamReader, maximum: int) -> dict[str, Any]: + size = struct.unpack(">I", await reader.readexactly(4))[0] + if not 1 <= size <= maximum: + raise ProtocolValidationError() + body = await reader.readexactly(size) + value = json.loads( + body, + object_pairs_hook=_strict_object, + parse_constant=_reject_constant, + ) + if not isinstance(value, dict): + raise ProtocolValidationError() + return value + + async def _handshake(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> str: + nonce = secrets.token_urlsafe(32) + await self._write_frame( + writer, + { + "agent_protocol_version": AGENT_PROTOCOL_VERSION, + "client_nonce": nonce, + "asset_bundle": self._config.asset_bundle.to_wire(), + "registry_fingerprint": self._config.registry_fingerprint, + "max_request_bytes": MAX_REQUEST_BYTES, + "max_response_bytes": MAX_RESPONSE_BYTES, + }, + MAX_HELLO_BYTES, + ) + acknowledged = await self._read_frame(reader, MAX_HELLO_BYTES) + if set(acknowledged) != { + "agent_protocol_version", + "client_nonce", + "operation_capability", + "capabilities", + "max_request_bytes", + "max_response_bytes", + }: + raise ProtocolValidationError() + capabilities = acknowledged["capabilities"] + if ( + acknowledged["agent_protocol_version"] != AGENT_PROTOCOL_VERSION + or acknowledged["client_nonce"] != nonce + or not isinstance(capabilities, list) + or len(capabilities) > 16 + or not all(isinstance(item, str) and 0 < len(item) <= 64 for item in capabilities) + or _REQUIRED_CAPABILITY not in capabilities + or acknowledged["max_request_bytes"] != MAX_REQUEST_BYTES + or acknowledged["max_response_bytes"] != MAX_RESPONSE_BYTES + ): + raise ProtocolValidationError() + capability = acknowledged["operation_capability"] + if not isinstance(capability, str): + raise ProtocolValidationError() + return capability_token(capability) + + async def call( + self, + operation: str, + fields: Mapping[str, Any], + deadline_ms: int, + *, + request_operation_id: str | None = None, + ) -> ServiceResponse: + if not 1 <= deadline_ms <= 120_000 or not operation: + raise ProtocolValidationError() + request_id = request_operation_id or operation_id() + capability_token(request_id) + writer: asyncio.StreamWriter | None = None + submission = SubmissionState.NOT_SUBMITTED + try: + async with asyncio.timeout(_HELLO_DEADLINE_SECONDS + deadline_ms / 1000): + reader, writer = await self._connect() + capability = await self._handshake(reader, writer) + envelope = { + "agent_protocol_version": AGENT_PROTOCOL_VERSION, + "operation_capability": capability, + "envelope": { + "protocol_version": PROTOCOL_VERSION, + "operation_id": request_id, + "asset_bundle": self._config.asset_bundle.to_wire(), + "request": {"operation": operation, **dict(fields)}, + }, + } + submission = SubmissionState.POSSIBLY_SUBMITTED + await self._write_frame(writer, envelope, MAX_REQUEST_BYTES) + response = await self._read_frame(reader, MAX_RESPONSE_BYTES) + except asyncio.CancelledError as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.CANCELLED, + ) from error + except TimeoutError as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.DEADLINE, + ) from error + except AgentProtocolError as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.AUTHENTICATION, + ) from error + except (ConnectionError, OSError, asyncio.IncompleteReadError) as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.TRANSPORT, + ) from error + except ( + ProtocolValidationError, + json.JSONDecodeError, + UnicodeDecodeError, + ) as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.PROTOCOL, + ) from error + finally: + if writer is not None: + writer.close() + try: + await writer.wait_closed() + except (ConnectionError, OSError): + pass + return _decode_agent_response(response, request_id) + + async def health(self, deadline_ms: int = 5_000) -> ServiceResponse: + return await self.call("health", {}, deadline_ms) + + async def create( + self, + request: CreateRequest, + deadline_ms: int = 60_000, + ) -> ServiceResponse: + if ( + request.template != self._config.asset_bundle.template + or request.expected_policy != self._config.asset_bundle.policy + ): + raise ProtocolValidationError() + return await self.call( + "create", + {"request": request.to_wire(), "deadline_ms": deadline_ms}, + deadline_ms, + ) + + async def wait_ready( + self, + sandbox_id: str, + lifecycle_token: str, + expected_policy: PolicyIdentity, + deadline_ms: int = 120_000, + ) -> ServiceResponse: + return await self.call( + "wait_ready", + { + "request_id": request_owned_id(sandbox_id), + "lifecycle_token": capability_token(lifecycle_token), + "expected_policy": expected_policy.to_wire(), + "deadline_ms": deadline_ms, + }, + deadline_ms, + ) + + async def exec( + self, + sandbox_id: str, + lifecycle_token: str, + request: ExecRequest, + deadline_ms: int = 45_000, + ) -> ServiceResponse: + started = asyncio.get_running_loop().time() + + def remaining() -> int: + elapsed = int((asyncio.get_running_loop().time() - started) * 1000) + value = deadline_ms - elapsed + if value <= 0: + raise SandboxServiceTransportError( + SubmissionState.NOT_SUBMITTED, + TransportFailureCode.DEADLINE, + ) + return value + + prepare_deadline = remaining() + prepared = await self.call( + "prepare_exec", + { + "request_id": request_owned_id(sandbox_id), + "lifecycle_token": capability_token(lifecycle_token), + "request": request.to_wire(), + "deadline_ms": prepare_deadline, + }, + prepare_deadline, + ) + if prepared.response != "exec_prepared": + return prepared + token = prepared.fields.get("prepare_token") + if not isinstance(token, str): + raise ProtocolValidationError() + commit_deadline = remaining() + return await self.call( + "commit_exec", + { + "request_id": sandbox_id, + "prepare_token": capability_token(token), + "deadline_ms": commit_deadline, + }, + commit_deadline, + ) + + async def delete( + self, + sandbox_id: str, + deadline_ms: int = 60_000, + ) -> ServiceResponse: + return await self.call( + "delete", + { + "target": {"request_id": request_owned_id(sandbox_id)}, + "deadline_ms": deadline_ms, + }, + deadline_ms, + ) + + async def wait_deleted( + self, + sandbox_id: str, + deadline_ms: int = 60_000, + ) -> ServiceResponse: + return await self.call( + "wait_deleted", + { + "target": {"request_id": request_owned_id(sandbox_id)}, + "deadline_ms": deadline_ms, + }, + deadline_ms, + ) + + async def cancel( + self, + target_operation_id: str, + deadline_ms: int = 5_000, + ) -> ServiceResponse: + return await self.call( + "cancel", + {"target_operation_id": capability_token(target_operation_id)}, + deadline_ms, + ) + + +def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ProtocolValidationError() + result[key] = value + return result + + +def _reject_constant(_: str) -> NoReturn: + raise ProtocolValidationError() + + +def _decode_agent_response(value: dict[str, Any], expected_operation_id: str) -> ServiceResponse: + try: + if set(value) != {"agent_protocol_version", "envelope"}: + raise ProtocolValidationError() + if value["agent_protocol_version"] != AGENT_PROTOCOL_VERSION: + raise ProtocolValidationError() + envelope = value["envelope"] + if not isinstance(envelope, dict) or set(envelope) != { + "protocol_version", + "operation_id", + "response", + }: + raise ProtocolValidationError() + if ( + envelope["protocol_version"] != PROTOCOL_VERSION + or envelope["operation_id"] != expected_operation_id + or not isinstance(envelope["response"], dict) + ): + raise ProtocolValidationError() + response = envelope["response"] + response_type = response.get("response") + if not isinstance(response_type, str): + raise ProtocolValidationError() + return ServiceResponse( + response=response_type, + fields={key: item for key, item in response.items() if key != "response"}, + ) + except ProtocolValidationError as error: + raise SandboxServiceTransportError( + SubmissionState.POSSIBLY_SUBMITTED, + TransportFailureCode.PROTOCOL, + ) from error diff --git a/openbox_sandbox/runtime/agent_server.py b/openbox_sandbox/runtime/agent_server.py new file mode 100644 index 0000000..764d9b0 --- /dev/null +++ b/openbox_sandbox/runtime/agent_server.py @@ -0,0 +1,560 @@ +"""Minimal authenticated Unix-socket adapter for the sandbox service. + +This process is deliberately an executor transport only. It authenticates a +same-UID SDK client, negotiates one fixed asset/registry identity, accepts one +existing typed sandbox-service operation per connection, and forwards that +operation over the existing TLS 1.3/mTLS client. Governance, command-profile +selection, lifecycle ordering, retries, and cleanup decisions remain in the +SDK dispatcher. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import signal +import stat +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, NoReturn + +from .._trusted_files import load_strict_json, validate_trusted_file +from ..errors import GovernedCommandDeploymentError +from .agent_client import ( + AGENT_PROTOCOL_VERSION, + MAX_HELLO_BYTES, + _peer_uid, + _strict_object, +) +from .client import ( + MAX_REQUEST_BYTES, + MAX_RESPONSE_BYTES, + PROTOCOL_VERSION, + SandboxRuntimeClient, + SandboxRuntimeClientConfig, +) +from .errors import ProtocolValidationError +from .types import ( + AssetBundleIdentity, + PolicyIdentity, + capability_token, +) + +_REQUIRED_CAPABILITY = "cancel_on_disconnect" +_ALLOWED_REQUEST_FIELDS: Mapping[str, frozenset[str]] = { + "health": frozenset({"operation"}), + "create": frozenset({"operation", "request", "deadline_ms"}), + "wait_ready": frozenset( + { + "operation", + "request_id", + "lifecycle_token", + "expected_policy", + "deadline_ms", + } + ), + "prepare_exec": frozenset( + { + "operation", + "request_id", + "lifecycle_token", + "request", + "deadline_ms", + } + ), + "commit_exec": frozenset({"operation", "request_id", "prepare_token", "deadline_ms"}), + "delete": frozenset({"operation", "target", "deadline_ms"}), + "wait_deleted": frozenset({"operation", "target", "deadline_ms"}), + "cancel": frozenset({"operation", "target_operation_id"}), +} + + +def _reject_constant(_: str) -> NoReturn: + raise ProtocolValidationError() + + +def _sha256(value: object) -> str: + if ( + not isinstance(value, str) + or len(value) != 64 + or any(character not in "0123456789abcdef" for character in value) + ): + raise ProtocolValidationError() + return value + + +def _asset_bundle(value: object) -> AssetBundleIdentity: + if not isinstance(value, dict) or set(value) != { + "runtime_contract_version", + "adapter_build_sha256", + "template", + "policy", + "compatibility_id", + }: + raise ProtocolValidationError() + policy = value["policy"] + if not isinstance(policy, dict) or set(policy) != {"id", "version", "sha256"}: + raise ProtocolValidationError() + policy_id = policy["id"] + policy_version = policy["version"] + template = value["template"] + compatibility_id = value["compatibility_id"] + contract_version = value["runtime_contract_version"] + if ( + not isinstance(policy_id, str) + or isinstance(policy_version, bool) + or not isinstance(policy_version, int) + or not isinstance(template, str) + or not isinstance(compatibility_id, str) + or isinstance(contract_version, bool) + or not isinstance(contract_version, int) + ): + raise ProtocolValidationError() + return AssetBundleIdentity( + runtime_contract_version=contract_version, + adapter_build_sha256=_sha256(value["adapter_build_sha256"]), + template=template, + policy=PolicyIdentity( + id=policy_id, + version=policy_version, + sha256=_sha256(policy["sha256"]), + ), + compatibility_id=compatibility_id, + ) + + +def load_service_client_config( + service_config: Path, + *, + ca_path: Path, + certificate_path: Path, + private_key_path: Path, +) -> SandboxRuntimeClientConfig: + """Load only the upstream address and immutable asset identity.""" + try: + raw = load_strict_json(service_config) + if not isinstance(raw, dict): + raise ProtocolValidationError() + bind_address = raw["bind_address"] + if not isinstance(bind_address, str): + raise ProtocolValidationError() + host, port_text = bind_address.rsplit(":", 1) + port = int(port_text) + asset = _asset_bundle(raw["asset_bundle"]) + except ( + KeyError, + OSError, + UnicodeDecodeError, + ValueError, + GovernedCommandDeploymentError, + ) as error: + raise ProtocolValidationError() from error + return SandboxRuntimeClientConfig( + host=host, + port=port, + server_name="localhost", + ca_path=ca_path, + certificate_path=certificate_path, + private_key_path=private_key_path, + asset_bundle=asset, + ) + + +@dataclass(frozen=True, slots=True, repr=False) +class UnixAgentServerConfig: + socket_path: Path + registry_fingerprint: str + upstream: SandboxRuntimeClientConfig + + def __post_init__(self) -> None: + if not self.socket_path.is_absolute(): + raise ProtocolValidationError() + _sha256(self.registry_fingerprint) + + def __repr__(self) -> str: + return ( + "UnixAgentServerConfig(socket_path=, " + f"registry_fingerprint={self.registry_fingerprint!r}, " + "upstream=)" + ) + + +class UnixAgentServer: + """One-operation-per-connection typed local adapter.""" + + def __init__( + self, + config: UnixAgentServerConfig, + *, + upstream: Any | None = None, + ) -> None: + self._config = config + self._upstream = upstream or SandboxRuntimeClient(config.upstream) + self._server: asyncio.AbstractServer | None = None + self._socket_identity: tuple[int, int] | None = None + + async def start(self) -> None: + if self._server is not None: + raise RuntimeError("sandbox agent already started") + path = self._config.socket_path + parent = path.parent + self._prepare_parent(parent) + try: + os.lstat(path) + except FileNotFoundError: + pass + else: + raise ProtocolValidationError() + old_umask = os.umask(0o077) + try: + server = await asyncio.start_unix_server(self._handle, path=str(path)) + finally: + os.umask(old_umask) + try: + os.chmod(path, 0o600, follow_symlinks=False) + metadata = os.lstat(path) + if ( + not stat.S_ISSOCK(metadata.st_mode) + or metadata.st_uid != os.getuid() + or stat.S_IMODE(metadata.st_mode) != 0o600 + ): + raise ProtocolValidationError() + except BaseException: + server.close() + await server.wait_closed() + try: + path.unlink() + except OSError: + pass + raise + self._server = server + self._socket_identity = (metadata.st_dev, metadata.st_ino) + + def _prepare_parent(self, parent: Path) -> None: + ancestor = parent.parent + try: + ancestor_metadata = os.lstat(ancestor) + if not stat.S_ISDIR(ancestor_metadata.st_mode): + raise ProtocolValidationError() + parent.mkdir(mode=0o700) + except FileExistsError: + pass + except OSError as error: + raise ProtocolValidationError() from error + metadata = os.lstat(parent) + if ( + stat.S_ISLNK(metadata.st_mode) + or not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.getuid() + or stat.S_IMODE(metadata.st_mode) & 0o077 + ): + raise ProtocolValidationError() + + async def close(self) -> None: + server = self._server + self._server = None + if server is not None: + server.close() + await server.wait_closed() + path = self._config.socket_path + identity = self._socket_identity + self._socket_identity = None + if identity is None: + return + try: + metadata = os.lstat(path) + if ( + not stat.S_ISSOCK(metadata.st_mode) + or (metadata.st_dev, metadata.st_ino) != identity + ): + raise ProtocolValidationError() + path.unlink() + except FileNotFoundError: + return + + async def serve_forever(self) -> None: + if self._server is None: + raise RuntimeError("sandbox agent not started") + await self._server.serve_forever() + + async def _read_frame( + self, + reader: asyncio.StreamReader, + maximum: int, + ) -> dict[str, Any]: + size = struct.unpack(">I", await reader.readexactly(4))[0] + if not 1 <= size <= maximum: + raise ProtocolValidationError() + body = await reader.readexactly(size) + value = json.loads( + body, + object_pairs_hook=_strict_object, + parse_constant=_reject_constant, + ) + if not isinstance(value, dict): + raise ProtocolValidationError() + return value + + async def _write_frame( + self, + writer: asyncio.StreamWriter, + value: object, + maximum: int, + ) -> None: + body = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + if not body or len(body) > maximum: + raise ProtocolValidationError() + writer.write(struct.pack(">I", len(body)) + body) + await writer.drain() + + async def _handle( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + try: + raw_socket = writer.get_extra_info("socket") + if raw_socket is None or _peer_uid(raw_socket) != os.getuid(): + raise ProtocolValidationError() + async with asyncio.timeout(5): + hello = await self._read_frame(reader, MAX_HELLO_BYTES) + nonce = self._validate_hello(hello) + operation_capability = capability_token_from_random() + await self._write_frame( + writer, + { + "agent_protocol_version": AGENT_PROTOCOL_VERSION, + "client_nonce": nonce, + "operation_capability": operation_capability, + "capabilities": [_REQUIRED_CAPABILITY], + "max_request_bytes": MAX_REQUEST_BYTES, + "max_response_bytes": MAX_RESPONSE_BYTES, + }, + MAX_HELLO_BYTES, + ) + request = await self._read_frame(reader, MAX_REQUEST_BYTES) + operation_id, operation, fields, deadline_ms = self._validate_request( + request, + operation_capability, + ) + upstream_task = asyncio.create_task( + self._upstream.call( + operation, + fields, + deadline_ms, + request_operation_id=operation_id, + ) + ) + disconnect_task = asyncio.create_task(reader.read(1)) + done, _ = await asyncio.wait( + {upstream_task, disconnect_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if disconnect_task in done: + upstream_task.cancel() + await asyncio.gather(upstream_task, return_exceptions=True) + return + disconnect_task.cancel() + await asyncio.gather(disconnect_task, return_exceptions=True) + response = await upstream_task + await self._write_frame( + writer, + { + "agent_protocol_version": AGENT_PROTOCOL_VERSION, + "envelope": { + "protocol_version": PROTOCOL_VERSION, + "operation_id": operation_id, + "response": { + "response": response.response, + **dict(response.fields), + }, + }, + }, + MAX_RESPONSE_BYTES, + ) + except ( + asyncio.IncompleteReadError, + ConnectionError, + OSError, + ProtocolValidationError, + TimeoutError, + UnicodeDecodeError, + ValueError, + ): + return + finally: + writer.close() + try: + await writer.wait_closed() + except (ConnectionError, OSError): + pass + + def _validate_hello(self, value: dict[str, Any]) -> str: + if set(value) != { + "agent_protocol_version", + "client_nonce", + "asset_bundle", + "registry_fingerprint", + "max_request_bytes", + "max_response_bytes", + }: + raise ProtocolValidationError() + nonce = value["client_nonce"] + if ( + value["agent_protocol_version"] != AGENT_PROTOCOL_VERSION + or not isinstance(nonce, str) + or not 32 <= len(nonce) <= 128 + or _asset_bundle(value["asset_bundle"]) != self._config.upstream.asset_bundle + or value["registry_fingerprint"] != self._config.registry_fingerprint + or value["max_request_bytes"] != MAX_REQUEST_BYTES + or value["max_response_bytes"] != MAX_RESPONSE_BYTES + ): + raise ProtocolValidationError() + return nonce + + def _validate_request( + self, + value: dict[str, Any], + expected_capability: str, + ) -> tuple[str, str, dict[str, Any], int]: + if set(value) != { + "agent_protocol_version", + "operation_capability", + "envelope", + }: + raise ProtocolValidationError() + if ( + value["agent_protocol_version"] != AGENT_PROTOCOL_VERSION + or value["operation_capability"] != expected_capability + ): + raise ProtocolValidationError() + envelope = value["envelope"] + if not isinstance(envelope, dict) or set(envelope) != { + "protocol_version", + "operation_id", + "asset_bundle", + "request", + }: + raise ProtocolValidationError() + operation_id = envelope["operation_id"] + capability_token(operation_id) + if ( + envelope["protocol_version"] != PROTOCOL_VERSION + or _asset_bundle(envelope["asset_bundle"]) != self._config.upstream.asset_bundle + ): + raise ProtocolValidationError() + request = envelope["request"] + if not isinstance(request, dict): + raise ProtocolValidationError() + operation = request.get("operation") + if not isinstance(operation, str): + raise ProtocolValidationError() + expected_fields = _ALLOWED_REQUEST_FIELDS.get(operation) + if expected_fields is None or set(request) != expected_fields: + raise ProtocolValidationError() + raw_deadline = request.get("deadline_ms", 5_000) + if ( + isinstance(raw_deadline, bool) + or not isinstance(raw_deadline, int) + or not 1 <= raw_deadline <= 120_000 + ): + raise ProtocolValidationError() + fields = {key: item for key, item in request.items() if key != "operation"} + return operation_id, operation, fields, raw_deadline + + +def capability_token_from_random() -> str: + from .types import operation_id + + return operation_id() + + +async def _serve(args: argparse.Namespace) -> None: + try: + validate_trusted_file(args.ca) + validate_trusted_file(args.certificate, private=True) + validate_trusted_file(args.private_key, private=True) + except GovernedCommandDeploymentError as error: + raise ProtocolValidationError() from error + upstream = load_service_client_config( + args.service_config, + ca_path=args.ca, + certificate_path=args.certificate, + private_key_path=args.private_key, + ) + server = UnixAgentServer( + UnixAgentServerConfig( + socket_path=args.socket, + registry_fingerprint=args.registry_fingerprint, + upstream=upstream, + ) + ) + await server.start() + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for current_signal in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(current_signal, stop.set) + except NotImplementedError: + pass + try: + await stop.wait() + finally: + await server.close() + + +async def _health(args: argparse.Namespace) -> None: + from .agent_client import UnixAgentRuntimeClient, UnixAgentRuntimeClientConfig + + upstream = load_service_client_config( + args.service_config, + ca_path=Path("/not-used"), + certificate_path=Path("/not-used"), + private_key_path=Path("/not-used"), + ) + client = UnixAgentRuntimeClient( + UnixAgentRuntimeClientConfig( + socket_path=args.socket, + asset_bundle=upstream.asset_bundle, + registry_fingerprint=args.registry_fingerprint, + ) + ) + response = await client.health() + status = response.fields.get("status") + if ( + response.response != "health" + or not isinstance(status, dict) + or status.get("ready") is not True + ): + raise RuntimeError("sandbox agent health rejected") + + +def main() -> None: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + serve = subparsers.add_parser("serve") + health = subparsers.add_parser("health") + for current in (serve, health): + current.add_argument("--service-config", type=Path, required=True) + current.add_argument("--socket", type=Path, required=True) + current.add_argument("--registry-fingerprint", required=True) + serve.add_argument("--ca", type=Path, required=True) + serve.add_argument("--certificate", type=Path, required=True) + serve.add_argument("--private-key", type=Path, required=True) + args = parser.parse_args() + if args.command == "serve": + asyncio.run(_serve(args)) + else: + asyncio.run(_health(args)) + + +if __name__ == "__main__": + main() diff --git a/openbox_sandbox/runtime/client.py b/openbox_sandbox/runtime/client.py new file mode 100644 index 0000000..56c79e5 --- /dev/null +++ b/openbox_sandbox/runtime/client.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +import asyncio +import ipaddress +import json +import ssl +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + +from .errors import ( + ProtocolValidationError, + SandboxServiceTransportError, + SubmissionState, + TransportFailureCode, +) +from .types import ( + AssetBundleIdentity, + CreateRequest, + ExecRequest, + PolicyIdentity, + ServiceResponse, + capability_token, + operation_id, + request_owned_id, +) + +PROTOCOL_VERSION = 1 +MAX_REQUEST_BYTES = 2 * 1024 * 1024 +MAX_RESPONSE_BYTES = 4 * 1024 * 1024 + + +@dataclass(frozen=True, slots=True, repr=False) +class SandboxRuntimeClientConfig: + host: str + port: int + server_name: str + ca_path: Path + certificate_path: Path + private_key_path: Path + asset_bundle: AssetBundleIdentity + + def __post_init__(self) -> None: + try: + address = ipaddress.ip_address(self.host) + except ValueError as error: + raise ProtocolValidationError() from error + if not address.is_loopback or not 1 <= self.port <= 65535 or not self.server_name: + raise ProtocolValidationError() + + def __repr__(self) -> str: + return ( + f"SandboxRuntimeClientConfig(host={self.host!r}, port={self.port}, " + f"server_name={self.server_name!r}, credentials=, " + f"asset_bundle={self.asset_bundle!r})" + ) + + +class SandboxRuntimeClient: + def __init__(self, config: SandboxRuntimeClientConfig) -> None: + self._config = config + context = ssl.create_default_context( + ssl.Purpose.SERVER_AUTH, + cafile=str(config.ca_path), + ) + context.minimum_version = ssl.TLSVersion.TLSv1_3 + context.maximum_version = ssl.TLSVersion.TLSv1_3 + context.load_cert_chain( + certfile=str(config.certificate_path), + keyfile=str(config.private_key_path), + ) + context.check_hostname = True + self._ssl = context + + async def call( + self, + operation: str, + fields: Mapping[str, Any], + deadline_ms: int, + *, + request_operation_id: str | None = None, + ) -> ServiceResponse: + if not 1 <= deadline_ms <= 120_000 or not operation: + raise ProtocolValidationError() + request_id = request_operation_id or operation_id() + capability_token(request_id) + request = { + "protocol_version": PROTOCOL_VERSION, + "operation_id": request_id, + "asset_bundle": self._config.asset_bundle.to_wire(), + "request": {"operation": operation, **dict(fields)}, + } + body = json.dumps( + request, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + if not body or len(body) > MAX_REQUEST_BYTES: + raise SandboxServiceTransportError( + SubmissionState.NOT_SUBMITTED, + TransportFailureCode.PROTOCOL, + ) + writer: asyncio.StreamWriter | None = None + submission = SubmissionState.NOT_SUBMITTED + try: + async with asyncio.timeout(deadline_ms / 1000): + reader, writer = await asyncio.open_connection( + self._config.host, + self._config.port, + ssl=self._ssl, + server_hostname=self._config.server_name, + ) + submission = SubmissionState.POSSIBLY_SUBMITTED + writer.write(struct.pack(">I", len(body)) + body) + await writer.drain() + size = struct.unpack(">I", await reader.readexactly(4))[0] + if not 1 <= size <= MAX_RESPONSE_BYTES: + raise ProtocolValidationError() + response_body = await reader.readexactly(size) + except asyncio.CancelledError as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.CANCELLED, + ) from error + except TimeoutError as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.DEADLINE, + ) from error + except (ssl.SSLError, ConnectionError, OSError, asyncio.IncompleteReadError) as error: + code = ( + TransportFailureCode.AUTHENTICATION + if submission is SubmissionState.NOT_SUBMITTED and isinstance(error, ssl.SSLError) + else TransportFailureCode.TRANSPORT + ) + raise SandboxServiceTransportError(submission, code) from error + except ProtocolValidationError as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.PROTOCOL, + ) from error + finally: + if writer is not None: + writer.close() + try: + await writer.wait_closed() + except (ssl.SSLError, ConnectionError, OSError): + pass + return _decode_response(response_body, request_id) + + async def health(self, deadline_ms: int = 5_000) -> ServiceResponse: + return await self.call("health", {}, deadline_ms) + + async def create( + self, + request: CreateRequest, + deadline_ms: int = 60_000, + ) -> ServiceResponse: + if ( + request.template != self._config.asset_bundle.template + or request.expected_policy != self._config.asset_bundle.policy + ): + raise ProtocolValidationError() + return await self.call( + "create", + {"request": request.to_wire(), "deadline_ms": deadline_ms}, + deadline_ms, + ) + + async def wait_ready( + self, + sandbox_id: str, + lifecycle_token: str, + expected_policy: PolicyIdentity, + deadline_ms: int = 120_000, + ) -> ServiceResponse: + return await self.call( + "wait_ready", + { + "request_id": request_owned_id(sandbox_id), + "lifecycle_token": capability_token(lifecycle_token), + "expected_policy": expected_policy.to_wire(), + "deadline_ms": deadline_ms, + }, + deadline_ms, + ) + + async def exec( + self, + sandbox_id: str, + lifecycle_token: str, + request: ExecRequest, + deadline_ms: int = 45_000, + ) -> ServiceResponse: + started = asyncio.get_running_loop().time() + + def remaining() -> int: + elapsed = int((asyncio.get_running_loop().time() - started) * 1000) + value = deadline_ms - elapsed + if value <= 0: + raise SandboxServiceTransportError( + SubmissionState.NOT_SUBMITTED, + TransportFailureCode.DEADLINE, + ) + return value + + prepare_deadline = remaining() + prepared = await self.call( + "prepare_exec", + { + "request_id": request_owned_id(sandbox_id), + "lifecycle_token": capability_token(lifecycle_token), + "request": request.to_wire(), + "deadline_ms": prepare_deadline, + }, + prepare_deadline, + ) + if prepared.response != "exec_prepared": + return prepared + token = prepared.fields.get("prepare_token") + if not isinstance(token, str): + raise ProtocolValidationError() + commit_deadline = remaining() + return await self.call( + "commit_exec", + { + "request_id": sandbox_id, + "prepare_token": capability_token(token), + "deadline_ms": commit_deadline, + }, + commit_deadline, + ) + + async def delete( + self, + sandbox_id: str, + deadline_ms: int = 60_000, + ) -> ServiceResponse: + return await self.call( + "delete", + { + "target": {"request_id": request_owned_id(sandbox_id)}, + "deadline_ms": deadline_ms, + }, + deadline_ms, + ) + + async def wait_deleted( + self, + sandbox_id: str, + deadline_ms: int = 60_000, + ) -> ServiceResponse: + return await self.call( + "wait_deleted", + { + "target": {"request_id": request_owned_id(sandbox_id)}, + "deadline_ms": deadline_ms, + }, + deadline_ms, + ) + + async def cancel( + self, + target_operation_id: str, + deadline_ms: int = 5_000, + ) -> ServiceResponse: + return await self.call( + "cancel", + {"target_operation_id": capability_token(target_operation_id)}, + deadline_ms, + ) + + +def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ProtocolValidationError() + result[key] = value + return result + + +def _decode_response(body: bytes, expected_operation_id: str) -> ServiceResponse: + try: + value = json.loads(body, object_pairs_hook=_strict_object) + if not isinstance(value, dict) or set(value) != { + "protocol_version", + "operation_id", + "response", + }: + raise ProtocolValidationError() + if ( + value["protocol_version"] != PROTOCOL_VERSION + or value["operation_id"] != expected_operation_id + or not isinstance(value["response"], dict) + ): + raise ProtocolValidationError() + response = value["response"] + response_type = response.get("response") + if not isinstance(response_type, str): + raise ProtocolValidationError() + return ServiceResponse( + response=response_type, + fields={key: item for key, item in response.items() if key != "response"}, + ) + except (json.JSONDecodeError, UnicodeDecodeError, ProtocolValidationError) as error: + raise SandboxServiceTransportError( + SubmissionState.POSSIBLY_SUBMITTED, + TransportFailureCode.PROTOCOL, + ) from error diff --git a/openbox_sandbox/runtime/errors.py b/openbox_sandbox/runtime/errors.py new file mode 100644 index 0000000..6e5a14a --- /dev/null +++ b/openbox_sandbox/runtime/errors.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class SubmissionState(str, Enum): + NOT_SUBMITTED = "not_submitted" + POSSIBLY_SUBMITTED = "possibly_submitted" + + +class TransportFailureCode(str, Enum): + AUTHENTICATION = "authentication" + CANCELLED = "cancelled" + DEADLINE = "deadline" + PROTOCOL = "protocol" + TRANSPORT = "transport" + + +@dataclass(frozen=True, slots=True) +class SandboxServiceTransportError(Exception): + submission_state: SubmissionState + code: TransportFailureCode + + def __str__(self) -> str: + return "sandbox service transport failed" + + +class ProtocolValidationError(ValueError): + def __init__(self) -> None: + super().__init__("sandbox service protocol value rejected") diff --git a/openbox_sandbox/runtime/types.py b/openbox_sandbox/runtime/types.py new file mode 100644 index 0000000..8e16bae --- /dev/null +++ b/openbox_sandbox/runtime/types.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import base64 +import re +import uuid +from dataclasses import dataclass, field +from typing import Any, Mapping, Sequence + +from .errors import ProtocolValidationError + +_SHA256 = re.compile(r"[0-9a-f]{64}\Z") +_COMPATIBILITY = re.compile(r"[A-Za-z0-9._-]{1,128}\Z") +_MEDIA_TYPE = re.compile( + r"[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,63}/" + r"[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,63}\Z" +) +_MAX_POLICY_DOCUMENT_BYTES = 1024 * 1024 +_MAX_STDOUT_BYTES = 1024 * 1024 +_MAX_STDERR_BYTES = 1024 * 1024 +_MAX_COMBINED_BYTES = 2 * 1024 * 1024 +_MAX_CHUNK_BYTES = 4 * 1024 * 1024 +_MAX_ARGV_BYTES = 1024 * 1024 + + +def _sha256(value: object) -> str: + if not isinstance(value, str) or not _SHA256.fullmatch(value): + raise ProtocolValidationError() + return value + + +def _uuid4(value: object) -> str: + if not isinstance(value, str): + raise ProtocolValidationError() + try: + parsed = uuid.UUID(value) + except (ValueError, AttributeError) as error: + raise ProtocolValidationError() from error + if parsed.version != 4 or parsed.variant != uuid.RFC_4122 or str(parsed) != value: + raise ProtocolValidationError() + return value + + +def request_owned_id(value: str) -> str: + if not isinstance(value, str) or not value.startswith("sbx-") or len(value) != 40: + raise ProtocolValidationError() + _uuid4(value[4:]) + return value + + +def operation_id() -> str: + return str(uuid.uuid4()) + + +def capability_token(value: str) -> str: + return _uuid4(value) + + +@dataclass(frozen=True, slots=True) +class PolicyIdentity: + id: str + version: int + sha256: str + + def __post_init__(self) -> None: + if ( + not isinstance(self.id, str) + or _COMPATIBILITY.fullmatch(self.id) is None + or type(self.version) is not int + or not 1 <= self.version <= 2**32 - 1 + ): + raise ProtocolValidationError() + _sha256(self.sha256) + + def to_wire(self) -> dict[str, Any]: + return {"id": self.id, "version": self.version, "sha256": self.sha256} + + +@dataclass(frozen=True, slots=True) +class AssetBundleIdentity: + runtime_contract_version: int + adapter_build_sha256: str + template: str + policy: PolicyIdentity + compatibility_id: str + + def __post_init__(self) -> None: + if ( + type(self.runtime_contract_version) is not int + or not 1 <= self.runtime_contract_version <= 2**32 - 1 + or not isinstance(self.template, str) + or not self.template + or len(self.template.encode("utf-8")) > 4096 + or "\x00" in self.template + or not isinstance(self.policy, PolicyIdentity) + ): + raise ProtocolValidationError() + _sha256(self.adapter_build_sha256) + if not isinstance(self.compatibility_id, str) or not _COMPATIBILITY.fullmatch( + self.compatibility_id + ): + raise ProtocolValidationError() + + def to_wire(self) -> dict[str, Any]: + return { + "runtime_contract_version": self.runtime_contract_version, + "adapter_build_sha256": self.adapter_build_sha256, + "template": self.template, + "policy": self.policy.to_wire(), + "compatibility_id": self.compatibility_id, + } + + +@dataclass(frozen=True, slots=True) +class PolicyDocument: + media_type: str + document: bytes = field(repr=False) + + def __post_init__(self) -> None: + if ( + not isinstance(self.media_type, str) + or _MEDIA_TYPE.fullmatch(self.media_type) is None + or type(self.document) is not bytes + or not self.document + or len(self.document) > _MAX_POLICY_DOCUMENT_BYTES + ): + raise ProtocolValidationError() + + def to_wire(self) -> dict[str, Any]: + return { + "media_type": self.media_type, + "document_base64": base64.b64encode(self.document).decode("ascii"), + } + + +@dataclass(frozen=True, slots=True) +class CreateRequest: + request_id: str + template: str + policy_document: PolicyDocument + expected_policy: PolicyIdentity + + def __post_init__(self) -> None: + request_owned_id(self.request_id) + if ( + not isinstance(self.template, str) + or not self.template + or len(self.template.encode("utf-8")) > 4096 + or "\x00" in self.template + or not isinstance(self.policy_document, PolicyDocument) + or not isinstance(self.expected_policy, PolicyIdentity) + ): + raise ProtocolValidationError() + + def to_wire(self) -> dict[str, Any]: + return { + "request_id": self.request_id, + "template": self.template, + "policy_document": self.policy_document.to_wire(), + "expected_policy": self.expected_policy.to_wire(), + } + + +@dataclass(frozen=True, slots=True) +class OutputLimits: + stdout_bytes: int + stderr_bytes: int + combined_bytes: int + chunk_bytes: int + + def __post_init__(self) -> None: + values = ( + self.stdout_bytes, + self.stderr_bytes, + self.combined_bytes, + self.chunk_bytes, + ) + maxima = ( + _MAX_STDOUT_BYTES, + _MAX_STDERR_BYTES, + _MAX_COMBINED_BYTES, + _MAX_CHUNK_BYTES, + ) + if any(type(value) is not int for value in values) or any( + not 1 <= value <= maximum for value, maximum in zip(values, maxima) + ): + raise ProtocolValidationError() + + def to_wire(self) -> dict[str, int]: + return { + "stdout_bytes": self.stdout_bytes, + "stderr_bytes": self.stderr_bytes, + "combined_bytes": self.combined_bytes, + "chunk_bytes": self.chunk_bytes, + } + + +@dataclass(frozen=True, slots=True, repr=False) +class ExecRequest: + argv: tuple[str, ...] + timeout: int + output_limits: OutputLimits + + def __init__( + self, + argv: Sequence[str], + timeout: int, + output_limits: OutputLimits, + ) -> None: + values = tuple(argv) + if ( + not values + or type(timeout) is not int + or not 1 <= timeout <= 300 + or not isinstance(output_limits, OutputLimits) + or not all(isinstance(value, str) and "\x00" not in value for value in values) + or sum(len(value.encode("utf-8")) for value in values) > _MAX_ARGV_BYTES + ): + raise ProtocolValidationError() + object.__setattr__(self, "argv", values) + object.__setattr__(self, "timeout", timeout) + object.__setattr__(self, "output_limits", output_limits) + + def __repr__(self) -> str: + return ( + "ExecRequest(argv=, " + f"argv_count={len(self.argv)}, timeout={self.timeout}, " + f"output_limits={self.output_limits!r})" + ) + + def to_wire(self) -> dict[str, Any]: + return { + "argv": list(self.argv), + "timeout": self.timeout, + "output_limits": self.output_limits.to_wire(), + } + + +@dataclass(frozen=True, slots=True, repr=False) +class ExecCompleted: + exit_code: int + stdout: bytes + stderr: bytes + timeout: str + + def __repr__(self) -> str: + return ( + f"ExecCompleted(exit_code={self.exit_code}, " + f"stdout_bytes={len(self.stdout)}, stderr_bytes={len(self.stderr)}, " + f"timeout={self.timeout!r})" + ) + + @classmethod + def from_wire(cls, value: Mapping[str, Any]) -> ExecCompleted: + if set(value) != {"exit_code", "stdout_base64", "stderr_base64", "timeout"}: + raise ProtocolValidationError() + exit_code = value["exit_code"] + timeout = value["timeout"] + if type(exit_code) is not int or not 0 <= exit_code <= 2**31 - 1: + raise ProtocolValidationError() + if timeout not in {"not_observed", "confirmed", "possible"}: + raise ProtocolValidationError() + try: + stdout = base64.b64decode(value["stdout_base64"], validate=True) + stderr = base64.b64decode(value["stderr_base64"], validate=True) + except (ValueError, TypeError) as error: + raise ProtocolValidationError() from error + return cls(exit_code=exit_code, stdout=stdout, stderr=stderr, timeout=timeout) + + +@dataclass(frozen=True, slots=True) +class ServiceResponse: + response: str + fields: Mapping[str, Any] + + def __post_init__(self) -> None: + if not isinstance(self.response, str) or not isinstance(self.fields, Mapping): + raise ProtocolValidationError() diff --git a/openbox_sandbox/telemetry.py b/openbox_sandbox/telemetry.py new file mode 100644 index 0000000..d29bab9 --- /dev/null +++ b/openbox_sandbox/telemetry.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import asyncio +import json +import os +import stat +import tempfile +import uuid +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, AsyncIterator, Protocol + +try: + import fcntl +except ImportError: # pragma: no cover - fail-closed portability boundary + fcntl = None # type: ignore[assignment] + + +@dataclass(frozen=True, slots=True) +class TelemetryEvent: + event: str + workflow_id: str + run_id: str + activity_id: str + attempt: int = 1 + governance_event_id: str | None = None + verdict: str | None = None + action: str | None = None + disposition: str | None = None + directive: str | None = None + sandbox_id: str | None = None + lifecycle_phase: str | None = None + timeout_seconds: int | None = None + timeout_status: str | None = None + exit_code: int | None = None + stdout_bytes: int | None = None + stderr_bytes: int | None = None + duration_ms: int | None = None + error_code: str | None = None + cleanup_status: str | None = None + runtime_contract_version: int | None = None + policy_id: str | None = None + policy_version: int | None = None + template_digest: str | None = None + profile_bundle_version: str | None = None + + def to_wire(self) -> dict[str, Any]: + return { + key: value + for key, value in { + field.name: getattr(self, field.name) + for field in self.__dataclass_fields__.values() + }.items() + if value is not None + } + + +class TelemetrySink(Protocol): + async def emit(self, event: TelemetryEvent) -> None: ... + + +class NullTelemetrySink: + async def emit(self, event: TelemetryEvent) -> None: + del event + + +@dataclass(slots=True) +class InMemoryTelemetrySink: + events: list[TelemetryEvent] = field(default_factory=list) + + async def emit(self, event: TelemetryEvent) -> None: + self.events.append(event) + + +@dataclass(frozen=True, slots=True, repr=False) +class CleanupBacklog: + directory: Path + compatibility_id: str + + def __repr__(self) -> str: + return f"CleanupBacklog(directory=, compatibility_id={self.compatibility_id!r})" + + async def record(self, request_id: str, state: str, recorded_at: str) -> None: + await asyncio.to_thread(self._record, request_id, state, recorded_at) + + async def remove(self, request_id: str) -> None: + await asyncio.to_thread(self._remove, request_id) + + async def request_ids(self) -> tuple[str, ...]: + return await asyncio.to_thread(self._request_ids) + + @asynccontextmanager + async def reconciliation_lock(self) -> AsyncIterator[None]: + """Serialize the complete cleanup transaction across local replicas.""" + if fcntl is None: + raise OSError("cleanup reconciliation locking unsupported") + descriptor = self._open_lock() + acquired = False + try: + while True: + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + acquired = True + break + except BlockingIOError: + await asyncio.sleep(0.05) + yield + finally: + if acquired: + try: + fcntl.flock(descriptor, fcntl.LOCK_UN) + except OSError: + pass + os.close(descriptor) + + def _open_lock(self) -> int: + self._secure_directory() + flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) + nofollow = getattr(os, "O_NOFOLLOW", 0) + if nofollow: + flags |= nofollow + path = self.directory / ".reconcile.lock" + try: + if not nofollow: + try: + if stat.S_ISLNK(os.lstat(path).st_mode): + raise OSError("cleanup reconciliation lock rejected") + except FileNotFoundError: + pass + descriptor = os.open(path, flags, 0o600) + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.getuid() + or metadata.st_mode & 0o077 + ): + raise OSError("cleanup reconciliation lock rejected") + os.fchmod(descriptor, 0o600) + return descriptor + except BaseException: + if "descriptor" in locals(): + os.close(descriptor) + raise + + def _secure_directory(self) -> None: + self.directory.mkdir(mode=0o700, parents=True, exist_ok=True) + info = os.lstat(self.directory) + if ( + stat.S_ISLNK(info.st_mode) + or not stat.S_ISDIR(info.st_mode) + or info.st_uid != os.getuid() + ): + raise OSError("cleanup backlog path rejected") + os.chmod(self.directory, 0o700) + + def _path(self, request_id: str) -> Path: + if not request_id.startswith("sbx-") or len(request_id) != 40: + raise OSError("cleanup identifier rejected") + try: + parsed = uuid.UUID(request_id[4:]) + except ValueError as error: + raise OSError("cleanup identifier rejected") from error + if parsed.version != 4 or str(parsed) != request_id[4:]: + raise OSError("cleanup identifier rejected") + return self.directory / f"{request_id}.json" + + def _record(self, request_id: str, state: str, recorded_at: str) -> None: + self._secure_directory() + path = self._path(request_id) + if path.exists() and stat.S_ISLNK(os.lstat(path).st_mode): + raise OSError("cleanup record path rejected") + payload = json.dumps( + { + "schema_version": 1, + "request_id": request_id, + "state": state, + "recorded_at": recorded_at, + "compatibility_id": self.compatibility_id, + }, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + descriptor, temporary = tempfile.mkstemp(prefix=".cleanup-", dir=self.directory) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb") as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + os.chmod(path, 0o600) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + def _remove(self, request_id: str) -> None: + self._secure_directory() + path = self._path(request_id) + try: + if stat.S_ISLNK(os.lstat(path).st_mode): + raise OSError("cleanup record path rejected") + except FileNotFoundError: + return + path.unlink() + + def _request_ids(self) -> tuple[str, ...]: + self._secure_directory() + result: list[str] = [] + for path in self.directory.glob("sbx-*.json"): + info = os.lstat(path) + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + raise OSError("cleanup record path rejected") + value = json.loads(path.read_bytes()) + if ( + not isinstance(value, dict) + or set(value) + != { + "schema_version", + "request_id", + "state", + "recorded_at", + "compatibility_id", + } + or value["schema_version"] != 1 + or value["compatibility_id"] != self.compatibility_id + or value["request_id"] != path.stem + ): + raise OSError("cleanup record rejected") + result.append(value["request_id"]) + return tuple(sorted(result)) diff --git a/pyproject.toml b/pyproject.toml index ceb3177..f880a8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "openbox-sdk-python" version = "1.2.0" -description = "OpenBox base SDK - governance contracts, strict gate, identity/signing, evaluate client, context runtime, OTel span wire serialization, and generic instrumentation shared by every OpenBox framework SDK" +description = "OpenBox SDK - governance contracts, strict gate, identity/signing, evaluate client, sandbox execution, context runtime, OTel span wire serialization, and generic instrumentation for OpenBox framework SDKs" authors = [ { name = "OpenBox Team", email = "tino@openbox.ai" }, ] @@ -9,7 +9,7 @@ requires-python = ">=3.11" readme = "README.md" license = "MIT" license-files = ["LICENSE"] -keywords = ["governance", "observability", "opentelemetry", "sdk", "openbox"] +keywords = ["governance", "sandbox", "observability", "opentelemetry", "sdk", "openbox"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", @@ -29,6 +29,9 @@ dependencies = [ "cryptography>=48.0.1,<50", ] +[project.scripts] +openbox-sandbox-agent = "openbox_sandbox.runtime.agent_server:main" + [project.optional-dependencies] # HTTP instrumentation targets (installed only when the host app opts in). http = [ @@ -64,7 +67,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["openbox_core"] +packages = ["openbox_core", "openbox_sandbox"] [tool.pytest.ini_options] asyncio_mode = "auto" @@ -73,6 +76,7 @@ asyncio_mode = "auto" python_version = "3.11" ignore_missing_imports = true check_untyped_defs = true +packages = ["openbox_core", "openbox_sandbox"] [tool.ruff] target-version = "py311" @@ -83,3 +87,6 @@ select = ["E", "F", "W", "I", "UP", "B"] # E501: line length is advisory. UP042: (str, Enum) is deliberate — StrEnum # changes str(member) semantics, and wire enums rely on plain string behavior. ignore = ["E501", "UP042"] + +[tool.ruff.lint.per-file-ignores] +"openbox_sandbox/__init__.pyi" = ["F401"] diff --git a/tests/sandbox/__init__.py b/tests/sandbox/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sandbox/deployment_helpers.py b/tests/sandbox/deployment_helpers.py new file mode 100644 index 0000000..9ea80d5 --- /dev/null +++ b/tests/sandbox/deployment_helpers.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +from openbox_sandbox import SandboxCommandDefinition, sandbox_command_registry + +POLICY_BODY = b"version: 1\ndefault: deny\n" + + +def registry(): + return sandbox_command_registry(SandboxCommandDefinition("proof", "/usr/local/bin/proof")) + + +def write_file(path: Path, body: bytes, mode: int = 0o600) -> Path: + path.write_bytes(body) + path.chmod(mode) + return path + + +def release_value(policy_path: Path) -> dict[str, Any]: + return { + "runtime_contract_version": 1, + "adapter_build_sha256": "a" * 64, + "template": "registry.invalid/openbox@sha256:" + "c" * 64, + "policy": { + "id": "deny-network", + "version": 1, + "sha256": hashlib.sha256(POLICY_BODY).hexdigest(), + "media_type": "application/yaml", + "path": str(policy_path), + }, + "compatibility_id": "linux-client-v1", + } + + +def prepare_files(tmp_path: Path) -> dict[str, Path]: + policy = write_file(tmp_path / "policy.yaml", POLICY_BODY, 0o644) + release_path = write_file( + tmp_path / "approved-release.json", + json.dumps( + {"schema_version": 1, "release": release_value(policy)}, + separators=(",", ":"), + ).encode(), + 0o644, + ) + cleanup = tmp_path / "cleanup" + cleanup.mkdir(mode=0o700) + return { + "policy": policy, + "release": release_path, + "cleanup": cleanup, + "ca": write_file(tmp_path / "ca.pem", b"test-ca", 0o644), + "certificate": write_file(tmp_path / "client.pem", b"test-cert", 0o600), + "private_key": write_file(tmp_path / "client.key", b"test-key", 0o600), + } + + +def deployment_value(tmp_path: Path, files: dict[str, Path], *, uds: bool = False): + commands = registry() + transport: dict[str, Any] + if uds: + transport = { + "kind": "uds_agent", + "socket_path": str(tmp_path / "agent.sock"), + } + else: + transport = { + "kind": "direct_tls", + "host": "127.0.0.1", + "port": 7443, + "server_name": "sandbox-service.internal", + "ca_path": str(files["ca"]), + "certificate_path": str(files["certificate"]), + "private_key_path": str(files["private_key"]), + } + return commands, { + "schema_version": 1, + "deployment_id": "client-sandbox-v1", + "transport": transport, + "release": release_value(files["policy"]), + "profiles": { + "registry_fingerprint": commands.fingerprint, + "bundle_version": commands.bundle_version, + "command_ids": list(commands.command_ids), + }, + "cleanup_backlog_directory": str(files["cleanup"]), + "output_limits": { + "stdout_bytes": 1024 * 1024, + "stderr_bytes": 1024 * 1024, + "combined_bytes": 2 * 1024 * 1024, + "chunk_bytes": 4 * 1024 * 1024, + }, + "deadlines": { + "create_deadline_ms": 60_000, + "readiness_deadline_ms": 120_000, + "exec_deadline_ms": 45_000, + "delete_deadline_ms": 60_000, + "wait_deleted_deadline_ms": 60_000, + }, + "enabled": True, + } + + +def write_manifest(path: Path, value: dict[str, Any]) -> Path: + return write_file(path, json.dumps(value, separators=(",", ":")).encode(), 0o644) diff --git a/tests/sandbox/helpers.py b/tests/sandbox/helpers.py new file mode 100644 index 0000000..552a394 --- /dev/null +++ b/tests/sandbox/helpers.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import base64 +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from openbox_core.contracts.context import ActivityContext + +from openbox_sandbox import ( + CommandProfileBundle, + InMemoryTelemetrySink, + SandboxAuthorization, + SandboxCommand, + SandboxEngineConfig, + SandboxExecutionConfig, + SandboxExecutionEngine, +) +from openbox_sandbox.profiles import _sign_for_test +from openbox_sandbox.runtime import ( + AssetBundleIdentity, + OutputLimits, + PolicyDocument, + PolicyIdentity, + ServiceResponse, +) +from openbox_sandbox.telemetry import CleanupBacklog, TelemetrySink + +NOW = datetime(2026, 7, 17, tzinfo=timezone.utc) +SECRET = b"0123456789abcdef0123456789abcdef" +KEY_ID = "profiles-2026-01" +SANDBOX_ID = "sbx-550e8400-e29b-41d4-a716-446655440000" +LIFECYCLE_TOKEN = "550e8400-e29b-41d4-a716-446655440001" +READY_TOKEN = "550e8400-e29b-41d4-a716-446655440003" + + +def payload(profiles: list[dict[str, Any]] | None = None) -> dict[str, Any]: + return { + "schema_version": 1, + "bundle_version": "2026-07-17.1", + "key_id": KEY_ID, + "issued_at": "2026-07-16T00:00:00Z", + "expires_at": "2027-07-17T00:00:00Z", + "profiles": profiles + or [ + { + "id": "echo-fixed", + "executable": "/bin/echo", + "arguments": [], + "sensitive": False, + "free_form": False, + } + ], + } + + +def bundle(profiles: list[dict[str, Any]] | None = None) -> CommandProfileBundle: + return CommandProfileBundle.load( + _sign_for_test(payload(profiles), SECRET, KEY_ID), + secret=SECRET, + expected_key_id=KEY_ID, + now=NOW, + ) + + +def asset_bundle() -> AssetBundleIdentity: + return AssetBundleIdentity( + runtime_contract_version=1, + adapter_build_sha256="a" * 64, + template="registry.invalid/openbox@sha256:" + "c" * 64, + policy=PolicyIdentity("deny-network", 1, "b" * 64), + compatibility_id="linux-arm64-v1", + ) + + +def config( + *, + profiles: CommandProfileBundle | None = None, + telemetry: TelemetrySink | None = None, + enabled: bool = True, + cleanup_backlog: CleanupBacklog | None = None, +) -> SandboxEngineConfig: + return SandboxEngineConfig( + profiles=profiles or bundle(), + sandbox=SandboxExecutionConfig( + host="127.0.0.1", + port=7443, + server_name="sandbox.service.invalid", + ca_path=Path("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/credentials/ca.pem"), + certificate_path=Path("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/credentials/client.pem"), + private_key_path=Path("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/credentials/client.key"), + asset_bundle=asset_bundle(), + policy_document=PolicyDocument("application/yaml", b"version: 1\n"), + output_limits=OutputLimits(1024, 1024, 1536, 4096), + enabled=enabled, + ), + telemetry=telemetry, + cleanup_backlog=cleanup_backlog, + ) + + +class FakeSandbox: + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[Any, ...]]] = [] + self.values: dict[str, Any] = { + "create": ServiceResponse( + "created", {"request_id": SANDBOX_ID, "lifecycle_token": LIFECYCLE_TOKEN} + ), + "wait_ready": ServiceResponse( + "ready", + { + "request_id": SANDBOX_ID, + "lifecycle_token": READY_TOKEN, + "active_policy": asset_bundle().policy.to_wire(), + }, + ), + "exec": ServiceResponse( + "executed", + { + "result": { + "exit_code": 7, + "stdout_base64": base64.b64encode(b"sandbox-out\x00").decode(), + "stderr_base64": base64.b64encode(b"sandbox-err\xff").decode(), + "timeout": "not_observed", + } + }, + ), + "delete": ServiceResponse("deleted", {"outcome": "deleted"}), + "wait_deleted": ServiceResponse("terminally_absent", {}), + } + + async def _call(self, name: str, *args: Any) -> Any: + self.calls.append((name, args)) + value = self.values[name] + if isinstance(value, BaseException): + raise value + return value + + async def create(self, *args: Any) -> Any: + return await self._call("create", *args) + + async def wait_ready(self, *args: Any) -> Any: + return await self._call("wait_ready", *args) + + async def exec(self, *args: Any) -> Any: + return await self._call("exec", *args) + + async def delete(self, *args: Any) -> Any: + return await self._call("delete", *args) + + async def wait_deleted(self, *args: Any) -> Any: + return await self._call("wait_deleted", *args) + + +def command(**overrides: Any) -> SandboxCommand: + values: dict[str, Any] = { + "context": ActivityContext( + workflow_id="wf-123", + run_id="run-456", + activity_id="act-789", + workflow_type="ProofWorkflow", + task_queue="governed", + metadata={"attempt": 1}, + ), + "argv": ["/bin/echo"], + "profile_id": "echo-fixed", + } + values.update(overrides) + return SandboxCommand(**values) + + +def authorization() -> SandboxAuthorization: + return SandboxAuthorization.trusted_application("trusted:wf-123:run-456:act-789") + + +def engine( + *, + configuration: SandboxEngineConfig | None = None, + sandbox: FakeSandbox | None = None, +) -> tuple[SandboxExecutionEngine, FakeSandbox]: + fake_sandbox = sandbox or FakeSandbox() + value = SandboxExecutionEngine._from_components( + configuration or config(), + sandbox=fake_sandbox, + clock=lambda: NOW, + sandbox_id=lambda: SANDBOX_ID, + ) + return value, fake_sandbox + + +__all__ = [ + "InMemoryTelemetrySink", + "NOW", + "SANDBOX_ID", + "authorization", + "bundle", + "command", + "config", + "engine", +] diff --git a/tests/sandbox/sandbox_helpers.py b/tests/sandbox/sandbox_helpers.py new file mode 100644 index 0000000..6e5fa39 --- /dev/null +++ b/tests/sandbox/sandbox_helpers.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +from typing import Any + +from openbox_sandbox import StructuredCommandProfileBundle + +from .helpers import KEY_ID, NOW, SECRET + + +def sign(payload: dict[str, Any]) -> bytes: + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False).encode() + return json.dumps( + { + "payload": payload, + "signature": { + "algorithm": "hmac-sha256", + "key_id": KEY_ID, + "value": hmac.new(SECRET, canonical, hashlib.sha256).hexdigest(), + }, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + + +def typed_result_schema() -> dict[str, Any]: + return { + "name": "openbox.proof.v1", + "max_bytes": 256, + "fields": [ + {"name": "job", "kind": "identifier", "max_bytes": 32}, + {"name": "count", "kind": "integer", "minimum": 1, "maximum": 5}, + ], + } + + +def structured_payload( + *, + sensitive: bool = False, + free_form: bool = False, + typed_result: bool = False, +) -> dict[str, Any]: + profile: dict[str, Any] = { + "id": "proof", + "executable": "/usr/bin/proof", + "arguments": [ + {"kind": "literal", "value": "--mode"}, + {"kind": "field_enum", "field": "mode", "values": ["safe", "audit"]}, + {"kind": "field_decimal", "field": "count", "minimum": 1, "maximum": 5}, + {"kind": "field_identifier", "field": "job", "max_bytes": 32}, + ], + "sensitive": sensitive, + "free_form": free_form, + "result_mode": "typed_json_v1" if typed_result else "metadata_only", + } + if typed_result: + profile["result_schema"] = typed_result_schema() + return { + "schema_version": 1, + "bundle_version": "structured-test-v1", + "key_id": KEY_ID, + "issued_at": "2026-07-16T00:00:00Z", + "expires_at": "2027-07-17T00:00:00Z", + "profiles": [profile], + } + + +def structured_profiles(*, typed_result: bool = False) -> StructuredCommandProfileBundle: + return StructuredCommandProfileBundle.load( + sign(structured_payload(typed_result=typed_result)), + secret=SECRET, + expected_key_id=KEY_ID, + now=NOW, + ) diff --git a/tests/sandbox/test_agent_configuration.py b/tests/sandbox/test_agent_configuration.py new file mode 100644 index 0000000..33448e1 --- /dev/null +++ b/tests/sandbox/test_agent_configuration.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from openbox_sandbox.runtime.agent_server import load_service_client_config +from openbox_sandbox.runtime.errors import ProtocolValidationError + + +def _service_config(path: Path) -> Path: + value = { + "bind_address": "127.0.0.1:17443", + "asset_bundle": { + "runtime_contract_version": 1, + "adapter_build_sha256": "a" * 64, + "template": "registry.invalid/openbox@sha256:" + "b" * 64, + "policy": { + "id": "deny-network", + "version": 1, + "sha256": "c" * 64, + }, + "compatibility_id": "openshell-v1", + }, + } + path.write_text(json.dumps(value)) + path.chmod(0o644) + return path + + +def _load(path: Path): + return load_service_client_config( + path, + ca_path=Path("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/credentials/ca.pem"), + certificate_path=Path("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/credentials/client.pem"), + private_key_path=Path("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/credentials/client.key"), + ) + + +def test_agent_service_config_requires_owner_controlled_regular_file(tmp_path: Path) -> None: + config = _service_config(tmp_path / "service.json") + assert _load(config).asset_bundle.compatibility_id == "openshell-v1" + + config.chmod(0o666) + with pytest.raises(ProtocolValidationError): + _load(config) + config.chmod(0o644) + + linked = tmp_path / "linked.json" + linked.symlink_to(config) + with pytest.raises(ProtocolValidationError): + _load(linked) + + +def test_agent_service_config_rejects_duplicate_or_oversized_input(tmp_path: Path) -> None: + duplicate = tmp_path / "duplicate.json" + duplicate.write_bytes(b'{"bind_address":"127.0.0.1:1","bind_address":"127.0.0.1:2"}') + duplicate.chmod(0o644) + with pytest.raises(ProtocolValidationError): + _load(duplicate) + + oversized = tmp_path / "oversized.json" + oversized.write_bytes(b"{" + b" " * (1024 * 1024)) + oversized.chmod(0o644) + with pytest.raises(ProtocolValidationError): + _load(oversized) diff --git a/tests/sandbox/test_agent_protocol.py b/tests/sandbox/test_agent_protocol.py new file mode 100644 index 0000000..c9a94da --- /dev/null +++ b/tests/sandbox/test_agent_protocol.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import asyncio +import os +import stat +import tempfile +import unittest +from pathlib import Path +from typing import Any, Mapping + +from openbox_sandbox.runtime import ( + AssetBundleIdentity, + SandboxServiceTransportError, + ServiceResponse, + TransportFailureCode, + UnixAgentRuntimeClient, + UnixAgentRuntimeClientConfig, + agent_socket_present, +) +from openbox_sandbox.runtime.agent_server import ( + UnixAgentServer, + UnixAgentServerConfig, +) +from openbox_sandbox.runtime.client import SandboxRuntimeClientConfig +from openbox_sandbox.runtime.types import PolicyIdentity + + +class _Upstream: + def __init__(self) -> None: + self.calls: list[tuple[str, Mapping[str, Any], int, str | None]] = [] + + async def call( + self, + operation: str, + fields: Mapping[str, Any], + deadline_ms: int, + *, + request_operation_id: str | None = None, + ) -> ServiceResponse: + self.calls.append((operation, fields, deadline_ms, request_operation_id)) + return ServiceResponse( + response="health", + fields={ + "status": { + "ready": True, + "draining": False, + "startup_reconciled": True, + "active_operations": 0, + "pending_cleanup_records": 0, + } + }, + ) + + +class AgentProtocolTests(unittest.IsolatedAsyncioTestCase): + def bundle(self) -> AssetBundleIdentity: + return AssetBundleIdentity( + runtime_contract_version=1, + adapter_build_sha256="a" * 64, + template="registry.invalid/sandbox@sha256:" + "b" * 64, + policy=PolicyIdentity("deny-network", 1, "c" * 64), + compatibility_id="poc-local-v1", + ) + + def upstream_config(self) -> SandboxRuntimeClientConfig: + return SandboxRuntimeClientConfig( + host="127.0.0.1", + port=7443, + server_name="localhost", + ca_path=Path("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/not-opened/ca"), + certificate_path=Path("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/not-opened/cert"), + private_key_path=Path("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/not-opened/key"), + asset_bundle=self.bundle(), + ) + + async def asyncSetUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.socket_path = Path(os.path.realpath(self.temporary.name)) / "agent" / "agent.sock" + self.fingerprint = "d" * 64 + self.upstream = _Upstream() + self.server = UnixAgentServer( + UnixAgentServerConfig( + socket_path=self.socket_path, + registry_fingerprint=self.fingerprint, + upstream=self.upstream_config(), + ), + upstream=self.upstream, + ) + await self.server.start() + + async def asyncTearDown(self) -> None: + await self.server.close() + self.temporary.cleanup() + + def client(self, fingerprint: str | None = None) -> UnixAgentRuntimeClient: + return UnixAgentRuntimeClient( + UnixAgentRuntimeClientConfig( + socket_path=self.socket_path, + asset_bundle=self.bundle(), + registry_fingerprint=fingerprint or self.fingerprint, + ) + ) + + async def test_same_uid_handshake_forwards_one_typed_health_operation(self) -> None: + response = await self.client().health() + + self.assertEqual(response.response, "health") + self.assertTrue(response.fields["status"]["ready"]) + self.assertEqual(len(self.upstream.calls), 1) + operation, fields, deadline, operation_id = self.upstream.calls[0] + self.assertEqual(operation, "health") + self.assertEqual(fields, {}) + self.assertEqual(deadline, 5_000) + self.assertIsNotNone(operation_id) + + async def test_socket_is_owner_only_and_removed_on_close(self) -> None: + metadata = os.lstat(self.socket_path) + self.assertTrue(stat.S_ISSOCK(metadata.st_mode)) + self.assertEqual(stat.S_IMODE(metadata.st_mode), 0o600) + self.assertEqual(metadata.st_uid, os.getuid()) + self.assertTrue(agent_socket_present(self.socket_path)) + + await self.server.close() + self.assertFalse(self.socket_path.exists()) + + async def test_registry_mismatch_fails_closed_without_upstream_call(self) -> None: + with self.assertRaises(SandboxServiceTransportError) as captured: + await self.client("e" * 64).health() + + self.assertIn( + captured.exception.code, + { + TransportFailureCode.AUTHENTICATION, + TransportFailureCode.TRANSPORT, + TransportFailureCode.PROTOCOL, + }, + ) + self.assertEqual(self.upstream.calls, []) + + async def test_disconnect_cancels_inflight_upstream_operation(self) -> None: + started = asyncio.Event() + cancelled = asyncio.Event() + + class BlockingUpstream(_Upstream): + async def call( + inner_self, + operation: str, + fields: Mapping[str, Any], + deadline_ms: int, + *, + request_operation_id: str | None = None, + ) -> ServiceResponse: + started.set() + try: + await asyncio.Event().wait() + raise AssertionError("blocking upstream unexpectedly resumed") + except asyncio.CancelledError: + cancelled.set() + raise + + await self.server.close() + self.server = UnixAgentServer( + UnixAgentServerConfig( + socket_path=self.socket_path, + registry_fingerprint=self.fingerprint, + upstream=self.upstream_config(), + ), + upstream=BlockingUpstream(), + ) + await self.server.start() + + task = asyncio.create_task(self.client().health()) + await asyncio.wait_for(started.wait(), timeout=2) + task.cancel() + with self.assertRaises(SandboxServiceTransportError): + await task + await asyncio.wait_for(cancelled.wait(), timeout=2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sandbox/test_deployment.py b/tests/sandbox/test_deployment.py new file mode 100644 index 0000000..88ed385 --- /dev/null +++ b/tests/sandbox/test_deployment.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + +import openbox_sandbox.deployment as deployment_module +from openbox_sandbox import ( + GovernedCommandDeploymentError, + SandboxDeployment, + SandboxExecutionConfig, + UnixAgentExecutionConfig, + load_approved_sandbox_release, + load_sandbox_deployment, +) +from openbox_sandbox.release import _clear_approved_sandbox_release_for_testing +from openbox_sandbox.runtime import ServiceResponse + +from .deployment_helpers import ( + POLICY_BODY, + deployment_value, + prepare_files, + release_value, + write_file, + write_manifest, +) + + +class FakeRuntime: + def __init__(self, response: ServiceResponse | None = None) -> None: + self.response = response or healthy_response() + self.config: object | None = None + self.health_deadlines: list[int] = [] + self.calls: list[str] = [] + + async def health(self, deadline_ms: int = 5_000) -> ServiceResponse: + self.health_deadlines.append(deadline_ms) + return self.response + + async def delete(self, *args: Any) -> ServiceResponse: + self.calls.append("delete") + return ServiceResponse("deleted", {"outcome": "deleted"}) + + async def wait_deleted(self, *args: Any) -> ServiceResponse: + self.calls.append("wait_deleted") + return ServiceResponse("terminally_absent", {}) + + +def healthy_response() -> ServiceResponse: + return ServiceResponse( + "health", + { + "status": { + "ready": True, + "draining": False, + "startup_reconciled": True, + "active_operations": 0, + "pending_cleanup_records": 0, + } + }, + ) + + +@pytest.fixture(autouse=True) +def clear_release() -> None: + _clear_approved_sandbox_release_for_testing() + + +def prepared_manifest( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + uds: bool = False, + runtime: FakeRuntime | None = None, +): + files = prepare_files(tmp_path) + load_approved_sandbox_release(files["release"]) + commands, value = deployment_value(tmp_path, files, uds=uds) + selected_runtime = runtime or FakeRuntime() + target = "UnixAgentRuntimeClient" if uds else "SandboxRuntimeClient" + + def runtime_factory(config: object) -> FakeRuntime: + selected_runtime.config = config + return selected_runtime + + monkeypatch.setattr(deployment_module, target, runtime_factory) + manifest = write_manifest(tmp_path / "deployment.json", value) + return files, commands, value, manifest, selected_runtime + + +def test_deployment_requires_validated_factory() -> None: + with pytest.raises(TypeError, match="SandboxDeployment.load"): + SandboxDeployment() + + +@pytest.mark.asyncio +async def test_direct_tls_deployment_preflight_and_cleanup_wiring( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + files, registry, _, manifest, runtime = prepared_manifest(tmp_path, monkeypatch) + deployment = load_sandbox_deployment(manifest, registry=registry) + + assert isinstance(deployment, SandboxDeployment) + assert isinstance(deployment.config.sandbox, SandboxExecutionConfig) + assert deployment.config.transport_kind == "direct_tls" + assert deployment.config.registry_fingerprint == registry.fingerprint + assert deployment.profiles.fingerprint == registry.fingerprint + assert deployment.structured_profiles.fingerprint == registry.fingerprint + assert deployment.cleanup_backlog.directory == files["cleanup"] + assert deployment.engine.asset_bundle == deployment.asset_bundle + assert runtime.config is not None + assert runtime.config.asset_bundle == deployment.asset_bundle # type: ignore[union-attr] + + health = await deployment.preflight(deadline_ms=1_234) + assert health.ready is True + assert runtime.health_deadlines == [1_234] + await deployment.cleanup_backlog.record( + "sbx-550e8400-e29b-41d4-a716-446655440000", + "delete_unconfirmed", + "2026-07-23T03:00:00Z", + ) + cleanup = await deployment.reconcile_cleanup() + assert (cleanup.attempted, cleanup.deleted, cleanup.remaining) == (1, 1, 0) + assert runtime.calls == ["delete", "wait_deleted"] + + +@pytest.mark.asyncio +async def test_uds_deployment_selects_exactly_one_transport( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _, registry, _, manifest, _ = prepared_manifest(tmp_path, monkeypatch, uds=True) + deployment = SandboxDeployment.load(manifest, registry=registry) + assert deployment.config.transport_kind == "uds_agent" + assert isinstance(deployment.config.sandbox, UnixAgentExecutionConfig) + assert deployment.config.sandbox.registry_fingerprint == registry.fingerprint + assert (await deployment.preflight()).ready is True + + +def test_manifest_kill_switch_is_preserved(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _, registry, value, _, _ = prepared_manifest(tmp_path, monkeypatch) + value["enabled"] = False + manifest = write_manifest(tmp_path / "disabled.json", value) + deployment = SandboxDeployment.load(manifest, registry=registry) + assert deployment.config.sandbox.enabled is False + + +@pytest.mark.parametrize( + "mutate", + [ + lambda value: value.update(extra=True), + lambda value: value.update(schema_version=2), + lambda value: value.update(enabled=1), + lambda value: value["transport"].update(socket_path="/tmp/also.sock"), + lambda value: value["transport"].update(host="192.0.2.1"), + lambda value: value["transport"].update(port=True), + lambda value: value["output_limits"].update(stdout_bytes=1024 * 1024 + 1), + lambda value: value["output_limits"].update(stderr_bytes=1024 * 1024 + 1), + lambda value: value["output_limits"].update(combined_bytes=2 * 1024 * 1024 + 1), + lambda value: value["output_limits"].update(chunk_bytes=4 * 1024 * 1024 + 1), + lambda value: value["output_limits"].update(combined_bytes=1), + lambda value: value["deadlines"].update(exec_deadline_ms=45_001), + lambda value: value["profiles"].update(registry_fingerprint="d" * 64), + lambda value: value["profiles"].update(command_ids=[]), + ], +) +def test_manifest_rejects_unknown_noncanonical_and_oversized_values( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mutate, +) -> None: + _, registry, value, _, _ = prepared_manifest(tmp_path, monkeypatch) + mutate(value) + manifest = write_manifest(tmp_path / "invalid-deployment.json", value) + with pytest.raises(GovernedCommandDeploymentError): + load_sandbox_deployment(manifest, registry=registry) + + +@pytest.mark.parametrize( + "body", + [ + b'{"schema_version":1,"schema_version":1}', + b'{"schema_version":NaN}', + b"\xff", + b"[]", + ], +) +def test_manifest_rejects_duplicate_keys_constants_utf8_and_nonobject( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + body: bytes, +) -> None: + _, registry, _, _, _ = prepared_manifest(tmp_path, monkeypatch) + manifest = write_file(tmp_path / "malformed.json", body, 0o644) + with pytest.raises(GovernedCommandDeploymentError): + load_sandbox_deployment(manifest, registry=registry) + + +def test_manifest_rejects_more_than_one_mib( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _, registry, _, _, _ = prepared_manifest(tmp_path, monkeypatch) + manifest = write_file(tmp_path / "oversized.json", b"{" + b" " * (1024 * 1024), 0o644) + with pytest.raises(GovernedCommandDeploymentError): + load_sandbox_deployment(manifest, registry=registry) + + +def test_manifest_requires_absolute_explicit_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _, registry, _, _, _ = prepared_manifest(tmp_path, monkeypatch) + with pytest.raises(GovernedCommandDeploymentError): + load_sandbox_deployment(Path("deployment.json"), registry=registry) + + +def test_secure_file_modes_symlinks_and_owner_are_enforced( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + files, registry, value, manifest, _ = prepared_manifest(tmp_path, monkeypatch) + + manifest.chmod(0o666) + with pytest.raises(GovernedCommandDeploymentError): + load_sandbox_deployment(manifest, registry=registry) + manifest.chmod(0o644) + + for credential in ("certificate", "private_key"): + files[credential].chmod(0o640) + with pytest.raises(GovernedCommandDeploymentError): + load_sandbox_deployment(manifest, registry=registry) + files[credential].chmod(0o600) + + symlink = tmp_path / "linked-policy.yaml" + symlink.symlink_to(files["policy"]) + value["release"] = release_value(symlink) + linked_manifest = write_manifest(tmp_path / "linked.json", value) + with pytest.raises(GovernedCommandDeploymentError): + load_sandbox_deployment(linked_manifest, registry=registry) + + value["release"] = release_value(files["policy"]) + owner_manifest = write_manifest(tmp_path / "owner.json", value) + current_uid = os.getuid() + monkeypatch.setattr("openbox_sandbox._trusted_files.os.getuid", lambda: current_uid + 1) + with pytest.raises(GovernedCommandDeploymentError): + load_sandbox_deployment(owner_manifest, registry=registry) + + +def test_cleanup_backlog_directory_must_be_existing_private_owner_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + files, registry, value, manifest, _ = prepared_manifest(tmp_path, monkeypatch) + files["cleanup"].chmod(0o755) + with pytest.raises(GovernedCommandDeploymentError): + load_sandbox_deployment(manifest, registry=registry) + files["cleanup"].chmod(0o700) + + linked = tmp_path / "linked-cleanup" + linked.symlink_to(files["cleanup"], target_is_directory=True) + value["cleanup_backlog_directory"] = str(linked) + linked_manifest = write_manifest(tmp_path / "linked-cleanup.json", value) + with pytest.raises(GovernedCommandDeploymentError): + load_sandbox_deployment(linked_manifest, registry=registry) + + +@pytest.mark.parametrize( + ("path", "value"), + [ + (("runtime_contract_version",), 2), + (("adapter_build_sha256",), "d" * 64), + (("template",), "registry.invalid/other@sha256:" + "d" * 64), + (("compatibility_id",), "other-client-v1"), + (("policy", "id"), "other-policy"), + (("policy", "version"), 2), + (("policy", "sha256"), "d" * 64), + (("policy", "media_type"), "application/json"), + ], +) +def test_manifest_release_mismatch_fails_closed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + path: tuple[str, ...], + value: object, +) -> None: + _, registry, manifest_value, _, _ = prepared_manifest(tmp_path, monkeypatch) + target = manifest_value["release"] + for part in path[:-1]: + target = target[part] + target[path[-1]] = value + manifest = write_manifest(tmp_path / "mismatched.json", manifest_value) + with pytest.raises(GovernedCommandDeploymentError): + load_sandbox_deployment(manifest, registry=registry) + + +def test_manifest_policy_body_mismatch_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _, registry, value, _, _ = prepared_manifest(tmp_path, monkeypatch) + other = write_file(tmp_path / "other-policy.yaml", b"version: 2\n", 0o644) + value["release"]["policy"]["path"] = str(other) + manifest = write_manifest(tmp_path / "body-mismatch.json", value) + with pytest.raises(GovernedCommandDeploymentError): + load_sandbox_deployment(manifest, registry=registry) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response", + [ + ServiceResponse("boundary_failed", {"failure": {"code": "asset_bundle_mismatch"}}), + ServiceResponse("health", {}), + ServiceResponse("health", {"status": {"ready": True}}), + ServiceResponse( + "health", + { + "status": { + "ready": False, + "draining": False, + "startup_reconciled": True, + "active_operations": 0, + "pending_cleanup_records": 0, + } + }, + ), + ], +) +async def test_preflight_rejects_service_identity_or_shape_mismatch( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + response: ServiceResponse, +) -> None: + runtime = FakeRuntime(response) + _, registry, _, manifest, _ = prepared_manifest( + tmp_path, + monkeypatch, + runtime=runtime, + ) + deployment = SandboxDeployment.load(manifest, registry=registry) + with pytest.raises(GovernedCommandDeploymentError): + await deployment.preflight() + + +def test_deployment_repr_redacts_paths_credentials_and_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + files, registry, _, manifest, _ = prepared_manifest(tmp_path, monkeypatch) + value = SandboxDeployment.load(manifest, registry=registry) + rendered = repr(value) + for forbidden in ( + str(manifest), + str(files["private_key"]), + str(files["cleanup"]), + POLICY_BODY.decode(), + ): + assert forbidden not in rendered + assert "" in rendered + + +def test_deployment_source_and_imports_have_no_framework_core_or_process_control() -> None: + source = Path(deployment_module.__file__).read_text() + for forbidden in ("openbox_core", "temporalio", "subprocess", "Popen", "docker", "cargo"): + assert forbidden not in source.lower() + + snippet = """ +import json, sys +before = set(sys.modules) +import openbox_sandbox.deployment +forbidden = ('temporalio', 'openbox_core.client') +print(json.dumps(sorted(name for name in set(sys.modules) - before if any( + name == prefix or name.startswith(prefix + '.') for prefix in forbidden +)))) +""" + result = subprocess.run( + [sys.executable, "-c", snippet], + capture_output=True, + text=True, + check=True, + timeout=30, + ) + assert json.loads(result.stdout) == [] diff --git a/tests/sandbox/test_engine.py b/tests/sandbox/test_engine.py new file mode 100644 index 0000000..433a8bb --- /dev/null +++ b/tests/sandbox/test_engine.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import asyncio +import base64 +import tempfile +import unittest +from pathlib import Path + +from openbox_core.contracts.context import ActivityContext + +from openbox_sandbox import ( + CleanupBacklog, + CleanupStatus, + Disposition, + InMemoryTelemetrySink, + SandboxAuthorization, + SandboxEngineConfig, + SandboxErrorCode, + SandboxValidationError, +) +from openbox_sandbox.runtime import ( + SandboxServiceTransportError, + ServiceResponse, + SubmissionState, + TransportFailureCode, +) + +from .helpers import SANDBOX_ID, authorization, command, config, engine + + +class SandboxExecutionEngineTests(unittest.IsolatedAsyncioTestCase): + async def test_authorized_constrain_owns_complete_sandbox_lifecycle(self) -> None: + telemetry = InMemoryTelemetrySink() + value, sandbox = engine(configuration=config(telemetry=telemetry)) + + result = await value.execute(command(), authorization()) + + self.assertEqual(result.disposition, Disposition.EXECUTED_IN_SANDBOX) + self.assertEqual( + [name for name, _ in sandbox.calls], + ["create", "wait_ready", "exec", "delete", "wait_deleted"], + ) + assert result.execution is not None + self.assertEqual(result.execution.exit_code, 7) + self.assertEqual(result.execution.stdout, b"sandbox-out\x00") + self.assertEqual(result.execution.stderr, b"sandbox-err\xff") + self.assertEqual(result.execution.cleanup_status, CleanupStatus.DELETED) + self.assertEqual(result.authorization["verdict"], "constrain") + self.assertEqual( + [event.event for event in telemetry.events], + [ + "authorization_accepted", + "sandbox_create_started", + "sandbox_create_finished", + "sandbox_ready", + "sandbox_exec_started", + "sandbox_exec_finished", + "sandbox_delete_started", + "sandbox_deleted", + "dispatch_terminal", + ], + ) + + async def test_profile_rejection_and_kill_switch_never_create(self) -> None: + value, sandbox = engine() + result = await value.execute(command(profile_id="unknown"), authorization()) + assert result.error is not None + self.assertEqual(result.error.code, SandboxErrorCode.PROFILE_REJECTED) + self.assertEqual(sandbox.calls, []) + + value, sandbox = engine(configuration=config(enabled=False)) + result = await value.execute(command(), authorization()) + assert result.error is not None + self.assertEqual(result.error.code, SandboxErrorCode.SANDBOX_DISABLED) + self.assertEqual(sandbox.calls, []) + + async def test_oversized_success_response_is_protocol_indeterminate(self) -> None: + value, sandbox = engine() + sandbox.values["exec"] = ServiceResponse( + "executed", + { + "result": { + "exit_code": 0, + "stdout_base64": base64.b64encode(b"x" * 1025).decode(), + "stderr_base64": "", + "timeout": "not_observed", + } + }, + ) + + result = await value.execute(command(), authorization()) + + self.assertEqual(result.disposition, Disposition.EXECUTION_INDETERMINATE) + assert result.error is not None + self.assertEqual(result.error.code, SandboxErrorCode.SANDBOX_PROTOCOL) + assert result.execution is not None + self.assertEqual(result.execution.stdout, b"") + self.assertEqual(result.execution.cleanup_status, CleanupStatus.DELETED) + + async def test_uncertain_exec_is_indeterminate_and_cleanup_owned(self) -> None: + value, sandbox = engine() + sandbox.values["exec"] = SandboxServiceTransportError( + SubmissionState.POSSIBLY_SUBMITTED, + TransportFailureCode.DEADLINE, + ) + + result = await value.execute(command(), authorization()) + + self.assertEqual(result.disposition, Disposition.EXECUTION_INDETERMINATE) + assert result.error is not None + self.assertEqual(result.error.code, SandboxErrorCode.SANDBOX_EXEC_INDETERMINATE) + self.assertEqual( + [name for name, _ in sandbox.calls], + ["create", "wait_ready", "exec", "delete", "wait_deleted"], + ) + + async def test_cancellation_waits_for_owned_cleanup(self) -> None: + value, sandbox = engine() + started = asyncio.Event() + release = asyncio.Event() + + async def wait_ready(*args): + sandbox.calls.append(("wait_ready", args)) + started.set() + await release.wait() + + sandbox.wait_ready = wait_ready + task = asyncio.create_task(value.execute(command(), authorization())) + await started.wait() + task.cancel() + release.set() + with self.assertRaises(asyncio.CancelledError): + await task + self.assertEqual([name for name, _ in sandbox.calls][-2:], ["delete", "wait_deleted"]) + + async def test_failed_cleanup_is_reconciled_from_backlog(self) -> None: + with tempfile.TemporaryDirectory() as directory: + backlog = CleanupBacklog(Path(directory), "linux-arm64-v1") + value, sandbox = engine(configuration=config(cleanup_backlog=backlog)) + sandbox.values["wait_deleted"] = SandboxServiceTransportError( + SubmissionState.POSSIBLY_SUBMITTED, + TransportFailureCode.TRANSPORT, + ) + result = await value.execute(command(), authorization()) + assert result.execution is not None + self.assertEqual(result.execution.cleanup_status, CleanupStatus.FAILED) + self.assertEqual(await backlog.request_ids(), (SANDBOX_ID,)) + + from openbox_sandbox.runtime import ServiceResponse + + sandbox.values["wait_deleted"] = ServiceResponse("terminally_absent", {}) + reconciled = await value.reconcile_cleanup() + self.assertEqual(reconciled.attempted, 1) + self.assertEqual(reconciled.deleted, 1) + self.assertEqual(reconciled.remaining, 0) + + async def test_configuration_and_command_attempt_fail_closed(self) -> None: + configuration = config() + with self.assertRaises(ValueError): + SandboxEngineConfig( + profiles=object(), # type: ignore[arg-type] + sandbox=configuration.sandbox, + ) + with self.assertRaises(SandboxValidationError): + command( + context=ActivityContext( + workflow_id="wf-123", + run_id="run-456", + activity_id="act-789", + metadata={"attempt": True}, + ) + ) + with self.assertRaises(TypeError): + SandboxAuthorization.verified_receipt( + "receipt-1", + metadata={"unbounded": "value"}, # type: ignore[call-arg] + ) + + async def test_engine_exposes_only_immutable_wrapper_bindings(self) -> None: + telemetry = InMemoryTelemetrySink() + configuration = config(telemetry=telemetry) + value, _ = engine(configuration=configuration) + self.assertIs(value.profiles, configuration.profiles) + self.assertIs(value.asset_bundle, configuration.sandbox.asset_bundle) + self.assertIs(value.telemetry_sink, configuration.telemetry) + + async def test_engine_has_no_core_or_host_execution_surface(self) -> None: + value, _ = engine() + for name in ("dispatch", "dispatch_trusted_constrain", "_dispatch_host"): + self.assertFalse(hasattr(value, name)) diff --git a/tests/sandbox/test_governed_receipts.py b/tests/sandbox/test_governed_receipts.py new file mode 100644 index 0000000..59e09d1 --- /dev/null +++ b/tests/sandbox/test_governed_receipts.py @@ -0,0 +1,477 @@ +from __future__ import annotations + +import inspect +import json +from concurrent.futures import ThreadPoolExecutor +from dataclasses import asdict +from datetime import datetime, timedelta, timezone +from typing import Any + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from openbox_sandbox import ( + SandboxCommandRequest, + SandboxInputError, + SandboxReceipt, +) +from openbox_sandbox.receipts import ( + InsecureLocalReceiptVerifier, + SandboxReceiptError, + SandboxReceiptVerifier, + receipt_binding, + receipt_payload, +) + +NOW = datetime(2026, 7, 21, tzinfo=timezone.utc) +WORKFLOW_ID = "workflow-reconcile-1" +COMMAND_ARGV = ("/usr/bin/safe", "--job", "job-1", "--count", "7") +ASSET_BUNDLE = { + "runtime_contract_version": 1, + "template": "registry.example/sandbox@sha256:" + "b" * 64, + "policy": {"id": "deny-network", "version": 1, "sha256": "c" * 64}, +} +PROFILE_FINGERPRINT = "d" * 64 + + +def _signed_request( + *, + expires_delta: timedelta = timedelta(minutes=5), + receipt_overrides: dict[str, Any] | None = None, +) -> tuple[ + SandboxCommandRequest, + SandboxReceiptVerifier, + dict[str, Any], +]: + private_key = Ed25519PrivateKey.generate() + public_key = private_key.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + bare = SandboxCommandRequest("safe-profile", {"job_id": "job-1", "count": 7}) + binding = receipt_binding( + bare, + command_argv=COMMAND_ARGV, + asset_bundle=ASSET_BUNDLE, + profile_fingerprint=PROFILE_FINGERPRINT, + ) + unsigned_values: dict[str, Any] = { + "schema_version": 1, + "receipt_id": "rcpt-test-1", + "nonce": "nonce-test-1", + "workflow_id": WORKFLOW_ID, + "verdict": "constrain", + "profile_id": bare.profile_id, + **binding, + "issued_at": NOW.isoformat().replace("+00:00", "Z"), + "expires_at": (NOW + expires_delta).isoformat().replace("+00:00", "Z"), + "key_id": "core-key-1", + "signature": "", + } + if receipt_overrides: + unsigned_values.update(receipt_overrides) + unsigned = SandboxReceipt(**unsigned_values) + canonical = json.dumps( + receipt_payload(unsigned), sort_keys=True, separators=(",", ":") + ).encode() + receipt = SandboxReceipt( + **{ + **receipt_payload(unsigned), + "signature": private_key.sign(canonical).hex(), + } + ) + request = SandboxCommandRequest(bare.profile_id, bare.arguments, receipt) + verifier = SandboxReceiptVerifier("core-key-1", public_key, lambda: NOW) + verification = { + "expected_workflow_id": WORKFLOW_ID, + "command_argv": COMMAND_ARGV, + "asset_bundle": ASSET_BUNDLE, + "profile_fingerprint": PROFILE_FINGERPRINT, + } + return request, verifier, verification + + +def _insecure_local_request( + *, + expires_delta: timedelta = timedelta(minutes=5), + receipt_overrides: dict[str, Any] | None = None, +) -> tuple[ + SandboxCommandRequest, + InsecureLocalReceiptVerifier, + SandboxReceiptVerifier, + dict[str, Any], +]: + request, signed_verifier, verification = _signed_request(expires_delta=expires_delta) + assert request.receipt is not None + values = { + **asdict(request.receipt), + "key_id": "insecure-local-testing", + "signature": "", + } + if receipt_overrides: + values.update(receipt_overrides) + receipt = SandboxReceipt(**values) + local_request = SandboxCommandRequest(request.profile_id, request.arguments, receipt) + signed_verifier = SandboxReceiptVerifier( + "insecure-local-testing", signed_verifier.public_key, lambda: NOW + ) + return ( + local_request, + InsecureLocalReceiptVerifier(lambda: NOW), + signed_verifier, + verification, + ) + + +def test_receipt_verifier_authenticates_all_authorization_bindings() -> None: + request, verifier, verification = _signed_request() + + assert verifier.verify(request, **verification) == "rcpt-test-1" + + +@pytest.mark.parametrize( + ("replacement", "value"), + [ + ("expected_workflow_id", "workflow-other"), + ("command_argv", ("/usr/bin/safe", "--job", "job-2")), + ("asset_bundle", {"runtime_contract_version": 2}), + ("profile_fingerprint", "e" * 64), + ], +) +def test_receipt_verifier_rejects_workflow_command_asset_or_profile_mismatch( + replacement: str, value: object +) -> None: + request, verifier, verification = _signed_request() + verification[replacement] = value + + with pytest.raises(SandboxReceiptError): + verifier.verify(request, **verification) + + +def test_receipt_verifier_rejects_profile_or_typed_request_mismatch() -> None: + request, verifier, verification = _signed_request() + tampered = SandboxCommandRequest( + request.profile_id, {"job_id": "job-1", "count": 8}, request.receipt + ) + with pytest.raises(SandboxReceiptError): + verifier.verify(tampered, **verification) + + request, verifier, verification = _signed_request() + wrong_profile = SandboxCommandRequest("other-profile", request.arguments, request.receipt) + with pytest.raises(SandboxReceiptError): + verifier.verify(wrong_profile, **verification) + + +def test_receipt_verifier_fails_closed_for_missing_expired_or_bad_signature() -> None: + request, verifier, verification = _signed_request(expires_delta=timedelta(seconds=-1)) + with pytest.raises(SandboxReceiptError): + verifier.verify(request, **verification) + + _, verifier, verification = _signed_request() + with pytest.raises(SandboxReceiptError): + verifier.verify(SandboxCommandRequest("safe-profile", {}), **verification) + + valid, verifier, verification = _signed_request() + assert valid.receipt is not None + bad = SandboxReceipt(**{**asdict(valid.receipt), "signature": "00" * 64}) + with pytest.raises(SandboxReceiptError): + verifier.verify( + SandboxCommandRequest(valid.profile_id, valid.arguments, bad), + **verification, + ) + + +def test_receipt_verifier_rejects_lifetime_over_ten_minutes() -> None: + request, verifier, verification = _signed_request( + expires_delta=timedelta(minutes=10, microseconds=1) + ) + + with pytest.raises(SandboxReceiptError): + verifier.verify(request, **verification) + + +def test_failed_check_does_not_consume_but_success_consumes_exactly_once() -> None: + request, verifier, verification = _signed_request() + + with pytest.raises(SandboxReceiptError): + verifier.verify( + request, + **{**verification, "expected_workflow_id": "workflow-wrong"}, + ) + assert verifier.verify(request, **verification) == "rcpt-test-1" + with pytest.raises(SandboxReceiptError, match="already consumed"): + verifier.verify(request, **verification) + + +def test_receipt_consumption_is_atomic_under_concurrent_reuse() -> None: + request, verifier, verification = _signed_request() + + def consume() -> str: + return verifier.verify(request, **verification) + + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = [executor.submit(consume) for _ in range(2)] + successes = 0 + failures = 0 + for outcome in outcomes: + try: + assert outcome.result() == "rcpt-test-1" + successes += 1 + except SandboxReceiptError: + failures += 1 + assert (successes, failures) == (1, 1) + + +def test_receipt_contains_digests_but_no_argv_or_output_bodies() -> None: + request, _, _ = _signed_request() + assert request.receipt is not None + encoded = json.dumps(asdict(request.receipt), sort_keys=True) + + assert "command_sha256" in encoded + assert "asset_bundle_sha256" in encoded + assert "profile_fingerprint" in encoded + assert "/usr/bin/safe" not in encoded + assert '"argv"' not in encoded + assert '"stdout"' not in encoded + assert '"stderr"' not in encoded + + +def test_receipt_value_rejects_unknown_or_missing_fields() -> None: + request, _, _ = _signed_request() + assert request.receipt is not None + value = asdict(request.receipt) + + with pytest.raises(SandboxInputError): + SandboxReceipt.from_value({**value, "unknown": "value"}) + value.pop("nonce") + with pytest.raises(SandboxInputError): + SandboxReceipt.from_value(value) + + +@pytest.mark.parametrize( + "receipt_overrides", + [ + {"schema_version": 2}, + {"verdict": "allow"}, + {"receipt_id": "not canonical!"}, + {"nonce": "not canonical!"}, + {"workflow_id": "workflow-other"}, + {"profile_id": "other-profile"}, + {"arguments_sha256": "0" * 64}, + {"command_sha256": "0" * 64}, + {"asset_bundle_sha256": "0" * 64}, + {"profile_fingerprint": "0" * 64}, + {"issued_at": "not-a-timestamp"}, + {"expires_at": "not-a-timestamp"}, + ], +) +def test_signed_and_insecure_modes_share_common_validation( + receipt_overrides: dict[str, Any], +) -> None: + signed_request, signed_verifier, signed_verification = _signed_request( + receipt_overrides=receipt_overrides + ) + local_request, local_verifier, _, local_verification = _insecure_local_request( + receipt_overrides=receipt_overrides + ) + + with pytest.raises(SandboxReceiptError) as signed_error: + signed_verifier.verify(signed_request, **signed_verification) + with pytest.raises(SandboxReceiptError) as local_error: + local_verifier.verify(local_request, **local_verification) + + assert str(signed_error.value) == "governed command receipt rejected" + assert str(local_error.value) == "INSECURE LOCAL unsigned governed command receipt rejected" + + +def test_signed_and_insecure_modes_share_request_presence_and_clock_checks() -> None: + signed_request, signed_verifier, verification = _signed_request() + local_request, local_verifier, _, _ = _insecure_local_request() + missing = SandboxCommandRequest("safe-profile", {}) + + with pytest.raises(SandboxReceiptError) as signed_missing: + signed_verifier.verify(missing, **verification) + with pytest.raises(SandboxReceiptError) as local_missing: + local_verifier.verify(missing, **verification) + assert str(signed_missing.value) == "governed command receipt required" + assert str(local_missing.value) == "INSECURE LOCAL unsigned governed command receipt required" + + with pytest.raises(SandboxReceiptError) as signed_type: + signed_verifier.verify(object(), **verification) # type: ignore[arg-type] + with pytest.raises(SandboxReceiptError) as local_type: + local_verifier.verify(object(), **verification) # type: ignore[arg-type] + assert str(signed_type.value) == "governed command receipt rejected" + assert str(local_type.value) == "INSECURE LOCAL unsigned governed command receipt rejected" + + signed_verifier.clock = lambda: datetime(2026, 7, 21) + local_verifier.clock = lambda: datetime(2026, 7, 21) + with pytest.raises(SandboxReceiptError) as signed_clock: + signed_verifier.verify(signed_request, **verification) + with pytest.raises(SandboxReceiptError) as local_clock: + local_verifier.verify(local_request, **verification) + assert str(signed_clock.value) == "receipt verifier rejected" + assert str(local_clock.value) == "INSECURE LOCAL receipt verifier rejected" + + +def test_authentication_field_shape_precedes_clock_error_as_before() -> None: + signed_request, signed_verifier, verification = _signed_request() + assert signed_request.receipt is not None + signed_receipt = SandboxReceipt(**{**asdict(signed_request.receipt), "signature": ""}) + signed_request = SandboxCommandRequest( + signed_request.profile_id, signed_request.arguments, signed_receipt + ) + local_request, local_verifier, _, _ = _insecure_local_request( + receipt_overrides={"signature": object()} + ) + signed_verifier.clock = lambda: datetime(2026, 7, 21) + local_verifier.clock = lambda: datetime(2026, 7, 21) + + with pytest.raises(SandboxReceiptError) as signed_error: + signed_verifier.verify(signed_request, **verification) + with pytest.raises(SandboxReceiptError) as local_error: + local_verifier.verify(local_request, **verification) + + assert str(signed_error.value) == "governed command receipt rejected" + assert str(local_error.value) == "INSECURE LOCAL unsigned governed command receipt rejected" + + +def test_receipt_verifier_public_contract_and_repr_regression() -> None: + _, signed_verifier, _ = _signed_request() + local_verifier = InsecureLocalReceiptVerifier(lambda: NOW) + + assert tuple(inspect.signature(SandboxReceiptVerifier).parameters) == ( + "key_id", + "public_key", + "clock", + ) + assert tuple(inspect.signature(InsecureLocalReceiptVerifier).parameters) == ("clock",) + assert repr(signed_verifier) == ( + "SandboxReceiptVerifier(key_id='core-key-1', " + "public_key=, replay_protection=in_process)" + ) + assert repr(local_verifier) == ( + "InsecureLocalReceiptVerifier(" + "mode=INSECURE_LOCAL_UNSIGNED_TESTING_ONLY, " + "signature_verification=disabled, replay_protection=in_process)" + ) + + +def test_insecure_local_verifier_accepts_explicitly_unsigned_receipt() -> None: + request, verifier, _, verification = _insecure_local_request() + + assert request.receipt is not None + assert request.receipt.signature == "" + assert verifier.verify(request, **verification) == "rcpt-test-1" + assert "INSECURE_LOCAL_UNSIGNED_TESTING_ONLY" in repr(verifier) + assert "signature_verification=disabled" in repr(verifier) + + +@pytest.mark.parametrize( + ("replacement", "value"), + [ + ("expected_workflow_id", "workflow-other"), + ("command_argv", ("/usr/bin/safe", "--job", "tampered")), + ("asset_bundle", {"runtime_contract_version": 999}), + ("profile_fingerprint", "e" * 64), + ], +) +def test_insecure_local_verifier_retains_every_external_binding( + replacement: str, value: object +) -> None: + request, verifier, _, verification = _insecure_local_request() + verification[replacement] = value + + with pytest.raises(SandboxReceiptError, match="INSECURE LOCAL"): + verifier.verify(request, **verification) + + +def test_insecure_local_verifier_rejects_tampered_request_and_profile() -> None: + request, verifier, _, verification = _insecure_local_request() + tampered_arguments = SandboxCommandRequest( + request.profile_id, {"job_id": "job-1", "count": 8}, request.receipt + ) + with pytest.raises(SandboxReceiptError, match="INSECURE LOCAL"): + verifier.verify(tampered_arguments, **verification) + + request, verifier, _, verification = _insecure_local_request() + tampered_profile = SandboxCommandRequest("other-profile", request.arguments, request.receipt) + with pytest.raises(SandboxReceiptError, match="INSECURE LOCAL"): + verifier.verify(tampered_profile, **verification) + + +@pytest.mark.parametrize( + "receipt_overrides", + [ + {"schema_version": 2}, + {"schema_version": True}, + {"verdict": "allow"}, + {"receipt_id": "not canonical!"}, + {"key_id": "not canonical!"}, + {"signature": object()}, + ], +) +def test_insecure_local_verifier_rejects_malformed_receipts( + receipt_overrides: dict[str, Any], +) -> None: + request, verifier, _, verification = _insecure_local_request( + receipt_overrides=receipt_overrides + ) + + with pytest.raises(SandboxReceiptError, match="INSECURE LOCAL"): + verifier.verify(request, **verification) + + +def test_insecure_local_verifier_retains_expiry_and_lifetime_checks() -> None: + expired, expired_verifier, _, verification = _insecure_local_request( + expires_delta=timedelta(seconds=-1) + ) + with pytest.raises(SandboxReceiptError, match="INSECURE LOCAL"): + expired_verifier.verify(expired, **verification) + + overlong, overlong_verifier, _, verification = _insecure_local_request( + expires_delta=timedelta(minutes=10, microseconds=1) + ) + with pytest.raises(SandboxReceiptError, match="INSECURE LOCAL"): + overlong_verifier.verify(overlong, **verification) + + +def test_insecure_local_verifier_ignores_forgery_but_signed_verifier_does_not() -> None: + forged = "00" * 64 + request, insecure_verifier, signed_verifier, verification = _insecure_local_request( + receipt_overrides={"signature": forged} + ) + + assert insecure_verifier.verify(request, **verification) == "rcpt-test-1" + with pytest.raises(SandboxReceiptError): + signed_verifier.verify(request, **verification) + + +def test_insecure_local_verifier_consumes_receipt_exactly_once() -> None: + request, verifier, _, verification = _insecure_local_request() + + assert verifier.verify(request, **verification) == "rcpt-test-1" + with pytest.raises(SandboxReceiptError, match="already consumed"): + verifier.verify(request, **verification) + + +def test_insecure_local_consumption_is_atomic_under_concurrent_reuse() -> None: + request, verifier, _, verification = _insecure_local_request() + + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = [executor.submit(verifier.verify, request, **verification) for _ in range(2)] + successes = 0 + failures = 0 + for outcome in outcomes: + try: + assert outcome.result() == "rcpt-test-1" + successes += 1 + except SandboxReceiptError: + failures += 1 + assert (successes, failures) == (1, 1) + + +def test_insecure_local_verifier_is_direct_path_only_and_never_top_level() -> None: + import openbox_sandbox + + assert "InsecureLocalReceiptVerifier" not in openbox_sandbox.__all__ + assert not hasattr(openbox_sandbox, "InsecureLocalReceiptVerifier") diff --git a/tests/sandbox/test_import_safety.py b/tests/sandbox/test_import_safety.py new file mode 100644 index 0000000..1ddcfa6 --- /dev/null +++ b/tests/sandbox/test_import_safety.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +_FORBIDDEN_PREFIXES = ( + "cryptography", + "httpx", + "openbox_core.client", + "openbox_core.identity", + "openbox_sandbox.engine", + "openbox_sandbox.receipts", + "openbox_sandbox.runtime", + "ssl", +) + + +@pytest.mark.parametrize( + "statement", + [ + "import openbox_sandbox.contracts", + "from openbox_sandbox import SandboxCommandRequest", + ], +) +def test_history_contract_import_does_not_load_runtime_or_signing(statement: str) -> None: + snippet = f""" +import json +import sys +{statement} +forbidden = {repr(_FORBIDDEN_PREFIXES)} +print(json.dumps(sorted( + name for name in sys.modules + if any(name == prefix or name.startswith(prefix + '.') for prefix in forbidden) +))) +""" + package_root = str(Path(__file__).resolve().parents[1]) + result = subprocess.run( + [sys.executable, "-c", snippet], + capture_output=True, + text=True, + timeout=30, + check=True, + env={**os.environ, "PYTHONPATH": package_root}, + ) + assert json.loads(result.stdout) == [] diff --git a/tests/sandbox/test_packaging.py b/tests/sandbox/test_packaging.py new file mode 100644 index 0000000..51f4eaa --- /dev/null +++ b/tests/sandbox/test_packaging.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import ast +import tomllib +from pathlib import Path + +import openbox_sandbox + +ROOT = Path(__file__).resolve().parents[2] + + +def test_console_script_exposes_existing_executor_only_agent() -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text()) + assert project["project"]["scripts"] == { + "openbox-sandbox-agent": "openbox_sandbox.runtime.agent_server:main" + } + + +def test_distribution_embeds_no_approved_release_or_policy() -> None: + package_files = { + path.relative_to(ROOT / "openbox_sandbox").as_posix() + for path in (ROOT / "openbox_sandbox").rglob("*") + if path.is_file() + } + assert not any( + "approved" in name or name.endswith((".yaml", ".json")) for name in package_files + ) + + +def test_deployment_module_has_no_core_temporal_or_process_imports() -> None: + source = (ROOT / "openbox_sandbox" / "deployment.py").read_text() + tree = ast.parse(source) + imports = {node.module or "" for node in ast.walk(tree) if isinstance(node, ast.ImportFrom)} | { + alias.name + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + } + for forbidden in ("openbox_core", "temporalio", "subprocess", "multiprocessing"): + assert not any(name == forbidden or name.startswith(forbidden + ".") for name in imports) + + +def test_lazy_top_level_exports_match_type_stub() -> None: + stub = (ROOT / "openbox_sandbox" / "__init__.pyi").read_text() + for name in ( + "SandboxDeployment", + "SandboxDeploymentConfig", + "SandboxHealth", + "SandboxReleaseMaterial", + "AuthorizedConstrain", + "ReceiptSigner", + "issue_sandbox_receipt", + "load_approved_sandbox_release", + "load_sandbox_deployment", + "materialize_approved_sandbox_release", + ): + assert name in openbox_sandbox.__all__ + assert name in stub diff --git a/tests/sandbox/test_protocol.py b/tests/sandbox/test_protocol.py new file mode 100644 index 0000000..4df1038 --- /dev/null +++ b/tests/sandbox/test_protocol.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import base64 +import json +import unittest +from pathlib import Path + +from openbox_sandbox.runtime import ( + AssetBundleIdentity, + ExecCompleted, + ExecRequest, + OutputLimits, + PolicyDocument, + PolicyIdentity, + ProtocolValidationError, + SandboxRuntimeClientConfig, + SandboxServiceTransportError, + TransportFailureCode, + capability_token, + request_owned_id, +) +from openbox_sandbox.runtime.client import _decode_response + + +class ProtocolTests(unittest.TestCase): + def policy(self) -> PolicyIdentity: + return PolicyIdentity("deny-network", 1, "b" * 64) + + def bundle(self) -> AssetBundleIdentity: + return AssetBundleIdentity( + runtime_contract_version=1, + adapter_build_sha256="a" * 64, + template="registry.invalid/sandbox@sha256:" + "c" * 64, + policy=self.policy(), + compatibility_id="linux-arm64-v1", + ) + + def test_exact_argv_and_binary_fields_round_trip_without_repr_leak(self) -> None: + request = ExecRequest( + ["/bin/proof", "", "space value", "$HOME", "雪"], + 30, + OutputLimits(64, 64, 96, 128), + ) + self.assertEqual( + request.to_wire()["argv"], + ["/bin/proof", "", "space value", "$HOME", "雪"], + ) + self.assertNotIn("/bin/proof", repr(request)) + + result = ExecCompleted.from_wire( + { + "exit_code": 7, + "stdout_base64": base64.b64encode(b"\x00\xff").decode("ascii"), + "stderr_base64": base64.b64encode(b"\xfe\x00").decode("ascii"), + "timeout": "not_observed", + } + ) + self.assertEqual(result.stdout, b"\x00\xff") + self.assertEqual(result.stderr, b"\xfe\x00") + self.assertNotIn("xff", repr(result)) + + def test_invalid_identifiers_base64_and_unknown_result_fields_fail_closed(self) -> None: + with self.assertRaises(ProtocolValidationError): + request_owned_id("sbx-not-a-uuid") + with self.assertRaises(ProtocolValidationError): + capability_token("00000000-0000-1000-8000-000000000000") + with self.assertRaises(ProtocolValidationError): + ExecCompleted.from_wire( + { + "exit_code": -1, + "stdout_base64": "***", + "stderr_base64": "", + "timeout": "unknown", + } + ) + with self.assertRaises(ProtocolValidationError): + ExecCompleted.from_wire( + { + "exit_code": 0, + "stdout_base64": "", + "stderr_base64": "", + "timeout": "not_observed", + "unexpected": True, + } + ) + with self.assertRaises(ProtocolValidationError): + ExecCompleted.from_wire( + { + "exit_code": True, + "stdout_base64": "", + "stderr_base64": "", + "timeout": "not_observed", + } + ) + with self.assertRaises(ProtocolValidationError): + OutputLimits(1024 * 1024 + 1, 1, 1, 1) + with self.assertRaises(ProtocolValidationError): + OutputLimits(True, 1, 1, 1) + with self.assertRaises(ProtocolValidationError): + ExecRequest(["/bin/proof\x00hidden"], 30, OutputLimits(1, 1, 1, 1)) + with self.assertRaises(ProtocolValidationError): + ExecRequest(["/bin/proof"], True, OutputLimits(1, 1, 1, 1)) + + def test_response_version_operation_and_duplicate_fields_are_strict(self) -> None: + operation = "550e8400-e29b-41d4-a716-446655440000" + body = json.dumps( + { + "protocol_version": 1, + "operation_id": operation, + "response": {"response": "terminally_absent"}, + } + ).encode() + response = _decode_response(body, operation) + self.assertEqual(response.response, "terminally_absent") + + for invalid in [ + body.replace(b'"protocol_version": 1', b'"protocol_version": 2'), + body.replace(operation.encode(), b"550e8400-e29b-41d4-a716-446655440001"), + b'{"protocol_version":1,"protocol_version":1,"operation_id":"' + + operation.encode() + + b'","response":{"response":"health"}}', + ]: + with self.assertRaises(SandboxServiceTransportError) as captured: + _decode_response(invalid, operation) + self.assertEqual(captured.exception.code, TransportFailureCode.PROTOCOL) + + def test_bundle_policy_document_and_config_validation(self) -> None: + document = PolicyDocument("application/yaml", b"version: 1\n") + self.assertNotIn("version: 1", repr(document)) + self.assertEqual( + document.to_wire()["document_base64"], + base64.b64encode(b"version: 1\n").decode("ascii"), + ) + config = SandboxRuntimeClientConfig( + host="127.0.0.1", + port=7443, + server_name="sandbox.service.local", + ca_path=Path("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/sensitive/ca"), + certificate_path=Path("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/sensitive/cert"), + private_key_path=Path("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/sensitive/key"), + asset_bundle=self.bundle(), + ) + self.assertNotIn("/sensitive", repr(config)) + with self.assertRaises(ProtocolValidationError): + SandboxRuntimeClientConfig( + host="192.0.2.1", + port=7443, + server_name="sandbox.service.local", + ca_path=Path("ca"), + certificate_path=Path("cert"), + private_key_path=Path("key"), + asset_bundle=self.bundle(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sandbox/test_receipt_issuance.py b/tests/sandbox/test_receipt_issuance.py new file mode 100644 index 0000000..29ac8cb --- /dev/null +++ b/tests/sandbox/test_receipt_issuance.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import inspect +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from openbox_sandbox import ( + AuthorizedConstrain, + SandboxCommandRequest, + SandboxReceiptError, + SandboxReceiptVerifier, + issue_sandbox_receipt, + load_approved_sandbox_release, + materialize_approved_sandbox_release, +) +from openbox_sandbox.release import _clear_approved_sandbox_release_for_testing + +from .deployment_helpers import prepare_files, registry + +NOW = datetime(2026, 7, 23, 3, 0, tzinfo=timezone.utc) + + +class ExternalSigner: + def __init__(self, key: Ed25519PrivateKey) -> None: + self._key = key + self.payloads: list[bytes] = [] + + def sign(self, payload: bytes) -> bytes: + self.payloads.append(payload) + return self._key.sign(payload) + + +@pytest.fixture(autouse=True) +def clear_release() -> None: + _clear_approved_sandbox_release_for_testing() + + +def test_issues_binding_aware_receipt_from_explicit_constrain( + tmp_path: Path, +) -> None: + files = prepare_files(tmp_path) + load_approved_sandbox_release(files["release"]) + commands = registry() + key = Ed25519PrivateKey.generate() + signer = ExternalSigner(key) + request = SandboxCommandRequest("proof", {}) + + issued = issue_sandbox_receipt( + request, + authorization=AuthorizedConstrain("constrain", "authorization-1"), + registry=commands, + workflow_id="workflow-1", + key_id="receipt-key-1", + signer=signer, + ttl=timedelta(minutes=5), + now=NOW, + ) + + assert request.receipt is None + assert issued.receipt is not None + assert issued.receipt.receipt_id == "authorization-1" + assert issued.receipt.verdict == "constrain" + assert issued.receipt.expires_at == "2026-07-23T03:05:00Z" + assert len(signer.payloads) == 1 + assert b"/usr/local/bin/proof" not in signer.payloads[0] + + public_key = key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + verifier = SandboxReceiptVerifier( + "receipt-key-1", + public_key, + clock=lambda: NOW + timedelta(minutes=1), + ) + profiles = commands.structured_profile_bundle() + assert ( + verifier.verify( + issued, + expected_workflow_id="workflow-1", + command_argv=profiles.derive(request, now=NOW), + asset_bundle=materialize_approved_sandbox_release().asset_bundle, + profile_fingerprint=profiles.profile_fingerprint("proof", now=NOW), + ) + == "authorization-1" + ) + + +@pytest.mark.parametrize( + "authorization", + [ + None, + object(), + ], +) +def test_issuance_requires_explicit_typed_authorization( + tmp_path: Path, authorization: object +) -> None: + files = prepare_files(tmp_path) + load_approved_sandbox_release(files["release"]) + signer = ExternalSigner(Ed25519PrivateKey.generate()) + with pytest.raises(SandboxReceiptError): + issue_sandbox_receipt( + SandboxCommandRequest("proof", {}), + authorization=authorization, # type: ignore[arg-type] + registry=registry(), + workflow_id="workflow-1", + key_id="receipt-key-1", + signer=signer, + now=NOW, + ) + assert signer.payloads == [] + + +def test_authorization_marker_rejects_non_constrain() -> None: + with pytest.raises(SandboxReceiptError): + AuthorizedConstrain("allow", "authorization-1") + + +@pytest.mark.parametrize( + "ttl", + [timedelta(0), timedelta(milliseconds=1), timedelta(minutes=10, seconds=1)], +) +def test_issuance_rejects_invalid_ttl(tmp_path: Path, ttl: timedelta) -> None: + files = prepare_files(tmp_path) + load_approved_sandbox_release(files["release"]) + with pytest.raises(SandboxReceiptError): + issue_sandbox_receipt( + SandboxCommandRequest("proof", {}), + authorization=AuthorizedConstrain("constrain", "authorization-1"), + registry=registry(), + workflow_id="workflow-1", + key_id="receipt-key-1", + signer=ExternalSigner(Ed25519PrivateKey.generate()), + ttl=ttl, + now=NOW, + ) + + +def test_issuance_fails_without_installed_release(tmp_path: Path) -> None: + del tmp_path + with pytest.raises(SandboxReceiptError): + issue_sandbox_receipt( + SandboxCommandRequest("proof", {}), + authorization=AuthorizedConstrain("constrain", "authorization-1"), + registry=registry(), + workflow_id="workflow-1", + key_id="receipt-key-1", + signer=ExternalSigner(Ed25519PrivateKey.generate()), + now=NOW, + ) + + +def test_issuance_rejects_signer_output_and_existing_receipt(tmp_path: Path) -> None: + files = prepare_files(tmp_path) + load_approved_sandbox_release(files["release"]) + + class InvalidSigner: + def sign(self, payload: bytes) -> bytes: + del payload + return b"short" + + commands = registry() + request = SandboxCommandRequest("proof", {}) + with pytest.raises(SandboxReceiptError): + issue_sandbox_receipt( + request, + authorization=AuthorizedConstrain("constrain", "authorization-1"), + registry=commands, + workflow_id="workflow-1", + key_id="receipt-key-1", + signer=InvalidSigner(), + now=NOW, + ) + + issued = issue_sandbox_receipt( + request, + authorization=AuthorizedConstrain("constrain", "authorization-1"), + registry=commands, + workflow_id="workflow-1", + key_id="receipt-key-1", + signer=ExternalSigner(Ed25519PrivateKey.generate()), + now=NOW, + ) + with pytest.raises(SandboxReceiptError): + issue_sandbox_receipt( + issued, + authorization=AuthorizedConstrain("constrain", "authorization-2"), + registry=commands, + workflow_id="workflow-1", + key_id="receipt-key-1", + signer=ExternalSigner(Ed25519PrivateKey.generate()), + now=NOW, + ) + + +def test_issuance_api_has_no_private_key_parameter() -> None: + parameters = inspect.signature(issue_sandbox_receipt).parameters + assert "private_key" not in parameters + assert "secret" not in parameters diff --git a/tests/sandbox/test_registry.py b/tests/sandbox/test_registry.py new file mode 100644 index 0000000..b7a351e --- /dev/null +++ b/tests/sandbox/test_registry.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone + +import pytest + +from openbox_sandbox import ( + DecimalArgument, + EnumArgument, + IdentifierArgument, + IdentifierResultField, + IntegerResultField, + LiteralArgument, + SandboxActivityResult, + SandboxCommandDefinition, + SandboxCommandRegistryError, + SandboxCommandRequest, + SandboxInputError, + TypedJsonResultSchema, + sandbox_command_registry, +) +from openbox_sandbox.command_profiles import CommandResultValidationError + + +def registry(): + return sandbox_command_registry( + SandboxCommandDefinition( + "reconcile", + "/usr/local/bin/reconcile", + ( + LiteralArgument("--batch"), + IdentifierArgument("batch_id", max_bytes=64), + LiteralArgument("--mode"), + EnumArgument("mode", ("strict", "review")), + LiteralArgument("--threshold"), + DecimalArgument("threshold", 0, 100), + ), + TypedJsonResultSchema( + "reconciliation-v1", + ( + IdentifierResultField("batch_id", max_bytes=64), + IntegerResultField("approved", 0, 1_000_000), + ), + ), + ) + ) + + +def test_registry_derives_bound_input_and_admission_from_one_identity() -> None: + value = registry() + structured = value.structured_profile_bundle() + admission = value.admission_profile_bundle() + request = SandboxCommandRequest( + "reconcile", + {"batch_id": "batch-1", "mode": "strict", "threshold": 70}, + ) + + argv = structured.derive(request) + + assert argv == ( + "/usr/local/bin/reconcile", + "--batch", + "batch-1", + "--mode", + "strict", + "--threshold", + "70", + ) + assert admission.admits("reconcile", argv, now=datetime.now(timezone.utc)) + assert structured.fingerprint == admission.fingerprint == value.fingerprint + assert structured.bundle_version == admission.bundle_version == value.bundle_version + + +def test_registry_typed_result_returns_only_schema_admitted_values() -> None: + structured = registry().structured_profile_bundle() + output = json.dumps( + {"approved": 42, "batch_id": "batch-1"}, + sort_keys=True, + separators=(",", ":"), + ).encode() + + result = structured.parse_result("reconcile", output) + + assert result is not None + assert result.schema_name == "reconciliation-v1" + assert [(item.name, item.value) for item in result.values] == [ + ("batch_id", "batch-1"), + ("approved", 42), + ] + + +@pytest.mark.parametrize( + "output", + [ + b'{"batch_id":"batch-1","approved":42}', # non-canonical key order + b'{"approved":1000001,"batch_id":"batch-1"}', + b'{"approved":42,"batch_id":"batch-1","raw":"secret"}', + b'{"approved":NaN,"batch_id":"batch-1"}', + ], +) +def test_registry_typed_result_rejects_noncanonical_or_unbounded_output( + output: bytes, +) -> None: + with pytest.raises(CommandResultValidationError): + registry().structured_profile_bundle().parse_result("reconcile", output) + + +@pytest.mark.parametrize( + "definition", + [ + lambda: SandboxCommandDefinition("bad", "relative"), + lambda: SandboxCommandDefinition("bad", "/bin/echo", (IdentifierArgument("secret_value"),)), + lambda: SandboxCommandDefinition( + "bad", + "/bin/echo", + result_schema=TypedJsonResultSchema( + "result", + ( + IdentifierResultField("same"), + IntegerResultField("same", 0, 1), + ), + ), + ), + ], +) +def test_registry_rejects_unsafe_or_ambiguous_definitions(definition) -> None: + with pytest.raises(SandboxCommandRegistryError): + definition() + + +def test_activity_result_contract_is_bounded_and_terminal() -> None: + valid = SandboxActivityResult( + "reconcile", + "executed_in_sandbox", + 0, + "not_observed", + "deleted", + 1024, + 1024, + ) + assert valid.typed_result is None + + invalid_values = [ + {"disposition": "not_executed"}, + {"exit_code": True}, + {"timeout_status": "unknown"}, + {"cleanup_status": "not_needed"}, + {"stdout_bytes": 1024 * 1024 + 1}, + {"stderr_bytes": -1}, + ] + base = { + "profile_id": "reconcile", + "disposition": "executed_in_sandbox", + "exit_code": 0, + "timeout_status": "not_observed", + "cleanup_status": "deleted", + "stdout_bytes": 0, + "stderr_bytes": 0, + } + for override in invalid_values: + with pytest.raises(SandboxInputError): + SandboxActivityResult(**{**base, **override}) diff --git a/tests/sandbox/test_release.py b/tests/sandbox/test_release.py new file mode 100644 index 0000000..17fdae3 --- /dev/null +++ b/tests/sandbox/test_release.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +import openbox_sandbox +from openbox_sandbox import ( + GovernedCommandDeploymentError, + approved_sandbox_release, + load_approved_sandbox_release, + materialize_approved_sandbox_release, +) +from openbox_sandbox.release import _clear_approved_sandbox_release_for_testing + +from .deployment_helpers import POLICY_BODY, prepare_files, release_value, write_file + + +@pytest.fixture(autouse=True) +def clear_release() -> None: + _clear_approved_sandbox_release_for_testing() + + +def test_explicit_release_load_materializes_exact_identity(tmp_path: Path) -> None: + files = prepare_files(tmp_path) + release = load_approved_sandbox_release(files["release"]) + material = materialize_approved_sandbox_release() + + assert approved_sandbox_release() is release + assert material.release is release + assert material.asset_bundle.runtime_contract_version == 1 + assert material.asset_bundle.adapter_build_sha256 == "a" * 64 + assert material.asset_bundle.template.endswith("@sha256:" + "c" * 64) + assert material.asset_bundle.policy.sha256 == release.policy_sha256 + assert material.policy_document.document == POLICY_BODY + assert "version: 1" not in repr(material) + assert "policy_body=" in repr(release) + + +def test_release_fails_closed_until_explicitly_loaded() -> None: + with pytest.raises(GovernedCommandDeploymentError): + approved_sandbox_release() + with pytest.raises(GovernedCommandDeploymentError): + materialize_approved_sandbox_release() + + +def test_public_mutable_release_installer_is_not_exported() -> None: + assert "install_approved_sandbox_release" not in openbox_sandbox.__all__ + with pytest.raises(AttributeError): + getattr(openbox_sandbox, "install_approved_sandbox_release") + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("runtime_contract_version", 0), + ("adapter_build_sha256", "x" * 64), + ("template", "registry.invalid/openbox:latest"), + ("compatibility_id", "bad value"), + ], +) +def test_release_rejects_invalid_identity(tmp_path: Path, field: str, value: object) -> None: + files = prepare_files(tmp_path) + release = release_value(files["policy"]) + release[field] = value + path = write_file( + tmp_path / "invalid-release.json", + json.dumps({"schema_version": 1, "release": release}).encode(), + 0o644, + ) + with pytest.raises(GovernedCommandDeploymentError): + load_approved_sandbox_release(path) + + +def test_release_rejects_policy_hash_media_type_and_body_mismatch(tmp_path: Path) -> None: + files = prepare_files(tmp_path) + for name, value in ( + ("sha256", "d" * 64), + ("media_type", "not-a-media-type"), + ): + release = release_value(files["policy"]) + release["policy"][name] = value + path = write_file( + tmp_path / f"invalid-{name}.json", + json.dumps({"schema_version": 1, "release": release}).encode(), + 0o644, + ) + with pytest.raises(GovernedCommandDeploymentError): + load_approved_sandbox_release(path) + + +@pytest.mark.parametrize( + "body", + [ + b'{"schema_version":1,"schema_version":1}', + b'{"schema_version":NaN}', + b'{"schema_version":true,"release":{}}', + b"\xff", + ], +) +def test_release_manifest_rejects_noncanonical_json(tmp_path: Path, body: bytes) -> None: + path = write_file(tmp_path / "malformed-release.json", body, 0o644) + with pytest.raises(GovernedCommandDeploymentError): + load_approved_sandbox_release(path) + + +def test_release_manifest_and_policy_require_secure_files(tmp_path: Path) -> None: + files = prepare_files(tmp_path) + files["release"].chmod(0o666) + with pytest.raises(GovernedCommandDeploymentError): + load_approved_sandbox_release(files["release"]) + files["release"].chmod(0o644) + + linked = tmp_path / "linked-release.json" + linked.symlink_to(files["release"]) + with pytest.raises(GovernedCommandDeploymentError): + load_approved_sandbox_release(linked) + + files["policy"].chmod(0o666) + with pytest.raises(GovernedCommandDeploymentError): + load_approved_sandbox_release(files["release"]) + + +def test_release_cannot_be_changed_after_installation(tmp_path: Path) -> None: + files = prepare_files(tmp_path) + load_approved_sandbox_release(files["release"]) + other_policy = write_file(tmp_path / "other-policy.yaml", b"other: policy\n", 0o644) + other = release_value(other_policy) + other["policy"]["sha256"] = hashlib.sha256(other_policy.read_bytes()).hexdigest() + path = write_file( + tmp_path / "other-release.json", + json.dumps({"schema_version": 1, "release": other}).encode(), + 0o644, + ) + with pytest.raises(GovernedCommandDeploymentError): + load_approved_sandbox_release(path) diff --git a/tests/sandbox/test_sandbox_profiles.py b/tests/sandbox/test_sandbox_profiles.py new file mode 100644 index 0000000..7a9d937 --- /dev/null +++ b/tests/sandbox/test_sandbox_profiles.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from typing import Any + +import pytest + +from openbox_sandbox import ( + CommandProfileBundleError, + SandboxCommandArgument, + SandboxCommandRequest, + SandboxInputError, + StructuredCommandProfileBundle, +) +from openbox_sandbox.command_profiles import CommandResultValidationError + +from .sandbox_helpers import ( + KEY_ID, + NOW, + SECRET, + sign, + structured_payload, + structured_profiles, +) + + +def _trusted_temporal_profile(*, typed_result: bool = False) -> dict[str, Any]: + return deepcopy(structured_payload(typed_result=typed_result)["profiles"][0]) + + +def _trusted_temporal_bundle( + profiles: Any, + *, + issued_at: datetime = NOW - timedelta(minutes=1), + expires_at: datetime = NOW + timedelta(minutes=30), + now: datetime = NOW, +) -> StructuredCommandProfileBundle: + return StructuredCommandProfileBundle.from_trusted( + bundle_version="trusted-temporal-test-v1", + issued_at=issued_at, + expires_at=expires_at, + profiles=profiles, + now=now, + ) + + +def test_signed_structural_profile_derives_exact_argv_without_callable() -> None: + profiles = structured_profiles() + request = SandboxCommandRequest("proof", {"mode": "safe", "count": 3, "job": "job-1"}) + assert profiles.derive(request, now=NOW) == ( + "/usr/bin/proof", + "--mode", + "safe", + "3", + "job-1", + ) + assert not callable(profiles) + assert profiles.profile_ids == ("proof",) + + +@pytest.mark.parametrize( + "arguments", + [ + {"mode": "unsafe", "count": 3, "job": "job-1"}, + {"mode": "safe", "count": 0, "job": "job-1"}, + {"mode": "safe", "count": 3, "job": "../escape"}, + {"mode": "safe", "count": 3, "job": "job-1", "extra": "x"}, + ], +) +def test_profile_specific_input_rejection(arguments: dict[str, str | int]) -> None: + with pytest.raises(SandboxInputError): + structured_profiles().derive(SandboxCommandRequest("proof", arguments), now=NOW) + + +def test_generic_input_rejects_raw_action_and_sensitive_field_names() -> None: + for profile_id in ("", "../proof", "p" * 129): + with pytest.raises(SandboxInputError): + SandboxCommandRequest(profile_id, {}) + for name in ("argv", "command", "api_token", "private_key"): + with pytest.raises(SandboxInputError): + SandboxCommandRequest("proof", {name: "value"}) + with pytest.raises(SandboxInputError): + SandboxCommandRequest( + "proof", + [ + SandboxCommandArgument("mode", "safe"), + SandboxCommandArgument("mode", "safe"), + ], + ) + + +@pytest.mark.parametrize( + "document,secret,key,now", + [ + ( + sign(structured_payload()).replace(b"/usr/bin/proof", b"/usr/bin/pro0f"), + SECRET, + KEY_ID, + NOW, + ), + (sign(structured_payload()), b"x" * 32, KEY_ID, NOW), + (sign(structured_payload()), SECRET, "wrong-key", NOW), + ( + sign(structured_payload()), + SECRET, + KEY_ID, + datetime(2025, 1, 1, tzinfo=timezone.utc), + ), + ( + sign(structured_payload()), + SECRET, + KEY_ID, + datetime(2028, 1, 1, tzinfo=timezone.utc), + ), + (sign(structured_payload(sensitive=True)), SECRET, KEY_ID, NOW), + (sign(structured_payload(free_form=True)), SECRET, KEY_ID, NOW), + ], +) +def test_bundle_tamper_key_time_and_unsafe_capabilities_fail_startup( + document: bytes, secret: bytes, key: str, now: datetime +) -> None: + with pytest.raises(CommandProfileBundleError): + StructuredCommandProfileBundle.load(document, secret=secret, expected_key_id=key, now=now) + + +def test_boolean_schema_version_is_rejected() -> None: + with pytest.raises(CommandProfileBundleError): + StructuredCommandProfileBundle.load( + sign(structured_payload() | {"schema_version": True}), + secret=SECRET, + expected_key_id=KEY_ID, + now=NOW, + ) + + +def test_bundle_constructor_requires_an_explicit_validating_constructor() -> None: + with pytest.raises(TypeError, match=r"load\(\) or from_trusted\(\)"): + StructuredCommandProfileBundle() + + +def test_trusted_profiles_snapshot_mappings_schema_and_identity() -> None: + profile = _trusted_temporal_profile(typed_result=True) + profiles = _trusted_temporal_bundle([profile]) + request = SandboxCommandRequest("proof", {"mode": "safe", "count": 3, "job": "job-1"}) + argv = profiles.derive(request, now=NOW) + fingerprint = profiles.profile_fingerprint("proof", now=NOW) + typed = profiles.parse_result("proof", b'{"count":3,"job":"job-1"}', now=NOW) + + profile["executable"] = "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/host/mutated" + profile["arguments"][1]["values"].append("mutated") + profile["arguments"][2]["minimum"] = 99 + profile["result_schema"]["fields"][1]["maximum"] = 1 + + assert profiles.derive(request, now=NOW) == argv + assert profiles.profile_fingerprint("proof", now=NOW) == fingerprint + assert profiles.parse_result("proof", b'{"count":3,"job":"job-1"}', now=NOW) == typed + + +def test_trusted_profiles_reject_shapes_duplicates_and_unsafe_capabilities() -> None: + valid = _trusted_temporal_profile() + unknown = deepcopy(valid) + unknown["unexpected"] = True + missing = deepcopy(valid) + del missing["arguments"] + sensitive = deepcopy(valid) + sensitive["sensitive"] = True + free_form = deepcopy(valid) + free_form["free_form"] = True + invalid_values: list[Any] = [ + {}, + [], + ["not-an-object"], + [unknown], + [missing], + [deepcopy(valid), deepcopy(valid)], + [sensitive], + [free_form], + ] + for profiles in invalid_values: + with pytest.raises(CommandProfileBundleError): + _trusted_temporal_bundle(profiles) + + +def test_trusted_profiles_reject_naive_invalid_or_expired_windows() -> None: + aware = NOW + naive = NOW.replace(tzinfo=None) + invalid = ( + (naive, aware + timedelta(minutes=1), aware), + (aware - timedelta(minutes=1), naive, aware), + (aware - timedelta(minutes=1), aware + timedelta(minutes=1), naive), + (aware + timedelta(seconds=1), aware + timedelta(minutes=1), aware), + (aware - timedelta(minutes=2), aware, aware), + (aware, aware, aware), + ) + for issued_at, expires_at, now in invalid: + with pytest.raises(CommandProfileBundleError): + _trusted_temporal_bundle( + [_trusted_temporal_profile()], + issued_at=issued_at, + expires_at=expires_at, + now=now, + ) + + +def test_trusted_profiles_reject_duplicate_dynamic_fields_and_argument_ranges() -> None: + duplicate = _trusted_temporal_profile() + duplicate["arguments"].append(deepcopy(duplicate["arguments"][1])) + boolean_range = _trusted_temporal_profile() + boolean_range["arguments"][2]["minimum"] = True + reversed_range = _trusted_temporal_profile() + reversed_range["arguments"][2].update(minimum=6, maximum=5) + boolean_size = _trusted_temporal_profile() + boolean_size["arguments"][3]["max_bytes"] = True + for profile in (duplicate, boolean_range, reversed_range, boolean_size): + with pytest.raises(CommandProfileBundleError): + _trusted_temporal_bundle([profile]) + + +def test_trusted_profiles_reject_malformed_typed_result_schemas() -> None: + missing = _trusted_temporal_profile(typed_result=True) + del missing["result_schema"]["fields"] + duplicate = _trusted_temporal_profile(typed_result=True) + duplicate["result_schema"]["fields"].append(deepcopy(duplicate["result_schema"]["fields"][0])) + boolean_range = _trusted_temporal_profile(typed_result=True) + boolean_range["result_schema"]["fields"][1]["minimum"] = True + reversed_range = _trusted_temporal_profile(typed_result=True) + reversed_range["result_schema"]["fields"][1].update(minimum=6, maximum=5) + boolean_size = _trusted_temporal_profile(typed_result=True) + boolean_size["result_schema"]["max_bytes"] = True + unknown = _trusted_temporal_profile(typed_result=True) + unknown["result_schema"]["unexpected"] = True + for profile in ( + missing, + duplicate, + boolean_range, + reversed_range, + boolean_size, + unknown, + ): + with pytest.raises(CommandProfileBundleError): + _trusted_temporal_bundle([profile]) + + +def test_profile_declared_typed_result_is_strict_and_ordered() -> None: + profiles = structured_profiles(typed_result=True) + + result = profiles.parse_result("proof", b'{"count":3,"job":"job-1"}', now=NOW) + + assert result is not None + assert result.schema_name == "openbox.proof.v1" + assert tuple((item.name, item.value) for item in result.values) == ( + ("job", "job-1"), + ("count", 3), + ) + assert profiles.profile_fingerprint( + "proof", now=NOW + ) != structured_profiles().profile_fingerprint("proof", now=NOW) + + +@pytest.mark.parametrize( + "output", + [ + b"{", + b'{"count":3,"count":3,"job":"job-1"}', + b'{"count":3,"extra":1,"job":"job-1"}', + b'{"count":NaN,"job":"job-1"}', + b"x" * 257, + b'{"count":"3","job":"job-1"}', + b'{"count":true,"job":"job-1"}', + b'{"count":6,"job":"job-1"}', + b'{"count":3,"job":"job-1"}{}', + b'{"count":3,"job":"job-1"}\n', + b'{"job":"job-1"}', + b"\xff", + ], +) +def test_profile_declared_typed_result_rejects_untrusted_output(output: bytes) -> None: + with pytest.raises(CommandResultValidationError): + structured_profiles(typed_result=True).parse_result("proof", output, now=NOW) + + +def test_metadata_only_profile_ignores_output_body() -> None: + assert structured_profiles().parse_result("proof", b"untrusted output", now=NOW) is None + + +@pytest.mark.parametrize( + "mutate", + [ + lambda profile: profile.update(result_mode="unknown"), + lambda profile: profile.update( + result_mode="typed_json_v1", result_schema={"name": "unsupported"} + ), + lambda profile: profile.update(result_schema={"unexpected": True}), + ], +) +def test_unsupported_result_schema_or_mode_fails_bundle_load(mutate) -> None: + payload = structured_payload() + mutate(payload["profiles"][0]) + with pytest.raises(CommandProfileBundleError): + StructuredCommandProfileBundle.load( + sign(payload), secret=SECRET, expected_key_id=KEY_ID, now=NOW + ) From 06b1df5ddc29e68227e13277762dfb0964d4d144 Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Sat, 8 Aug 2026 12:24:02 +0700 Subject: [PATCH 05/20] sync: bring the sandbox modules to the current monorepo state Dispatcher, runtime client, engine, deployment, command profiles, policy templates, and the contract/receipt/registry/release surfaces updated to the latest working state (operation-in-event evaluation, single-client convergence, trace join, workflow-governance events). --- openbox_sandbox/deployment.py | 2 +- openbox_sandbox/dispatcher/__init__.py | 62 + openbox_sandbox/dispatcher/_host.py | 177 +++ openbox_sandbox/dispatcher/command.py | 77 + openbox_sandbox/dispatcher/dispatcher.py | 1411 +++++++++++++++++ openbox_sandbox/dispatcher/errors.py | 54 + openbox_sandbox/dispatcher/governance.py | 506 ++++++ openbox_sandbox/dispatcher/profiles.py | 384 +++++ openbox_sandbox/dispatcher/py.typed | 0 openbox_sandbox/dispatcher/result.py | 106 ++ openbox_sandbox/dispatcher/telemetry.py | 233 +++ openbox_sandbox/policy_templates.py | 106 ++ openbox_sandbox/runtime/env.py | 172 ++ openbox_sandbox/runtime/types.py | 23 +- openbox_sandbox/runtime_client/__init__.py | 54 + .../runtime_client/agent_client.py | 496 ++++++ .../runtime_client/agent_server.py | 550 +++++++ openbox_sandbox/runtime_client/client.py | 312 ++++ openbox_sandbox/runtime_client/errors.py | 31 + openbox_sandbox/runtime_client/py.typed | 0 openbox_sandbox/runtime_client/types.py | 233 +++ 21 files changed, 4985 insertions(+), 4 deletions(-) create mode 100644 openbox_sandbox/dispatcher/__init__.py create mode 100644 openbox_sandbox/dispatcher/_host.py create mode 100644 openbox_sandbox/dispatcher/command.py create mode 100644 openbox_sandbox/dispatcher/dispatcher.py create mode 100644 openbox_sandbox/dispatcher/errors.py create mode 100644 openbox_sandbox/dispatcher/governance.py create mode 100644 openbox_sandbox/dispatcher/profiles.py create mode 100644 openbox_sandbox/dispatcher/py.typed create mode 100644 openbox_sandbox/dispatcher/result.py create mode 100644 openbox_sandbox/dispatcher/telemetry.py create mode 100644 openbox_sandbox/policy_templates.py create mode 100644 openbox_sandbox/runtime/env.py create mode 100644 openbox_sandbox/runtime_client/__init__.py create mode 100644 openbox_sandbox/runtime_client/agent_client.py create mode 100644 openbox_sandbox/runtime_client/agent_server.py create mode 100644 openbox_sandbox/runtime_client/client.py create mode 100644 openbox_sandbox/runtime_client/errors.py create mode 100644 openbox_sandbox/runtime_client/py.typed create mode 100644 openbox_sandbox/runtime_client/types.py diff --git a/openbox_sandbox/deployment.py b/openbox_sandbox/deployment.py index 54680e3..b7539d4 100644 --- a/openbox_sandbox/deployment.py +++ b/openbox_sandbox/deployment.py @@ -494,7 +494,7 @@ def _load_sandbox_deployment( engine_config, sandbox=runtime, clock=lambda: datetime.now(timezone.utc), - sandbox_id=lambda: f"sbx-{uuid.uuid4()}", + sandbox_id=lambda: f"sbx-{uuid.uuid4().hex[:15]}", ) config = SandboxDeploymentConfig( deployment_id=deployment_id, diff --git a/openbox_sandbox/dispatcher/__init__.py b/openbox_sandbox/dispatcher/__init__.py new file mode 100644 index 0000000..67d65d3 --- /dev/null +++ b/openbox_sandbox/dispatcher/__init__.py @@ -0,0 +1,62 @@ +from .command import GovernedCommand +from .dispatcher import ( + DispatcherConfig, + GovernedDispatcher, + SandboxExecutionConfig, + UnixAgentExecutionConfig, +) +from .errors import ( + DispatchErrorCode, + DispatcherValidationError, + GovernanceProtocolError, + GovernanceTransportError, + NormalizedDispatchError, + ProfileValidationError, +) +from .governance import ( + GovernanceClient, + GovernanceClientConfig, + GovernanceDecision, + GovernanceRequestSigner, +) +from .profiles import CommandProfileBundle +from .result import ( + CleanupReconciliationResult, + CleanupStatus, + Directive, + DispatchResult, + Disposition, + ExecutionMetadata, + TimeoutStatus, +) +from .telemetry import CleanupBacklog, InMemoryTelemetrySink, TelemetryEvent, TelemetrySink + +__all__ = [ + "CleanupBacklog", + "CleanupReconciliationResult", + "CleanupStatus", + "CommandProfileBundle", + "Directive", + "DispatchErrorCode", + "DispatchResult", + "DispatcherConfig", + "DispatcherValidationError", + "Disposition", + "ExecutionMetadata", + "GovernanceClient", + "GovernanceClientConfig", + "GovernanceDecision", + "GovernanceProtocolError", + "GovernanceRequestSigner", + "GovernanceTransportError", + "GovernedCommand", + "GovernedDispatcher", + "InMemoryTelemetrySink", + "NormalizedDispatchError", + "ProfileValidationError", + "SandboxExecutionConfig", + "UnixAgentExecutionConfig", + "TelemetryEvent", + "TelemetrySink", + "TimeoutStatus", +] diff --git a/openbox_sandbox/dispatcher/_host.py b/openbox_sandbox/dispatcher/_host.py new file mode 100644 index 0000000..4ac1eda --- /dev/null +++ b/openbox_sandbox/dispatcher/_host.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import asyncio +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Mapping + +from .errors import DispatchErrorCode +from .result import TimeoutStatus + + +@dataclass(frozen=True, slots=True, repr=False) +class _HostConfig: + workdir: Path + stdout_bytes: int = 1024 * 1024 + stderr_bytes: int = 1024 * 1024 + combined_bytes: int = 2 * 1024 * 1024 + environment: Mapping[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + if ( + not self.workdir.is_absolute() + or not isinstance(self.environment, Mapping) + or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in self.environment.items() + ) + or min(self.stdout_bytes, self.stderr_bytes, self.combined_bytes) <= 0 + or self.stdout_bytes > self.combined_bytes + or self.stderr_bytes > self.combined_bytes + or self.combined_bytes > 16 * 1024 * 1024 + ): + raise ValueError("host execution configuration rejected") + + def __repr__(self) -> str: + return ( + "_HostConfig(workdir=, " + f"stdout_bytes={self.stdout_bytes}, stderr_bytes={self.stderr_bytes}, " + f"combined_bytes={self.combined_bytes})" + ) + + +@dataclass(frozen=True, slots=True, repr=False) +class _HostOutcome: + exit_code: int + stdout: bytes + stderr: bytes + timeout_status: TimeoutStatus + + +class _HostFailure(Exception): + def __init__(self, code: DispatchErrorCode) -> None: + super().__init__("host execution failed") + self.code = code + + +class _OutputOverflow(Exception): + pass + + +class _HostExecutor: + def __init__(self, config: _HostConfig) -> None: + self._config = config + + async def execute(self, argv: tuple[str, ...], timeout_seconds: int) -> _HostOutcome: + try: + process = await asyncio.create_subprocess_exec( + *argv, + cwd=self._config.workdir, + env=dict(self._config.environment), + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=True, + ) + except (OSError, ValueError) as error: + raise _HostFailure(DispatchErrorCode.HOST_EXEC_INDETERMINATE) from error + assert process.stdout is not None and process.stderr is not None + total = [0] + + async def read_stream(reader: asyncio.StreamReader, limit: int) -> bytes: + result = bytearray() + while True: + chunk = await reader.read(64 * 1024) + if not chunk: + return bytes(result) + total[0] += len(chunk) + if len(result) + len(chunk) > limit or total[0] > self._config.combined_bytes: + raise _OutputOverflow() + result.extend(chunk) + + stdout_task = asyncio.create_task(read_stream(process.stdout, self._config.stdout_bytes)) + stderr_task = asyncio.create_task(read_stream(process.stderr, self._config.stderr_bytes)) + wait_task = asyncio.create_task(process.wait()) + group = asyncio.gather(wait_task, stdout_task, stderr_task) + try: + async with asyncio.timeout(timeout_seconds): + exit_code, stdout, stderr = await asyncio.shield(group) + return _HostOutcome(exit_code, stdout, stderr, TimeoutStatus.NOT_OBSERVED) + except TimeoutError: + await _terminate(process) + try: + async with asyncio.timeout(1): + _, stdout, stderr = await group + except _OutputOverflow as error: + await _cancel_tasks(stdout_task, stderr_task, wait_task) + _close_transport(process) + raise _HostFailure(DispatchErrorCode.HOST_OUTPUT_LIMIT) from error + except TimeoutError as error: + await _cancel_tasks(stdout_task, stderr_task, wait_task) + _close_transport(process) + raise _HostFailure(DispatchErrorCode.HOST_EXEC_INDETERMINATE) from error + _close_transport(process) + # Let asyncio deliver the final pipe/transport close callbacks before + # the process wrapper becomes unreachable. + await asyncio.sleep(0) + await asyncio.sleep(0) + return _HostOutcome( + process.returncode if process.returncode is not None else -9, + stdout, + stderr, + TimeoutStatus.CONFIRMED_TIMEOUT, + ) + except _OutputOverflow as error: + await _terminate(process) + await _cancel_tasks(stdout_task, stderr_task, wait_task) + _close_transport(process) + raise _HostFailure(DispatchErrorCode.HOST_OUTPUT_LIMIT) from error + except asyncio.CancelledError: + await _terminate(process) + await _cancel_tasks(stdout_task, stderr_task, wait_task) + _close_transport(process) + raise + except (OSError, RuntimeError) as error: + await _terminate(process) + await _cancel_tasks(stdout_task, stderr_task, wait_task) + _close_transport(process) + raise _HostFailure(DispatchErrorCode.HOST_EXEC_INDETERMINATE) from error + + +async def _terminate(process: asyncio.subprocess.Process) -> None: + if process.returncode is not None: + return + try: + os.killpg(process.pid, 15) + except (ProcessLookupError, PermissionError): + try: + process.terminate() + except ProcessLookupError: + pass + try: + async with asyncio.timeout(1): + await process.wait() + except TimeoutError: + try: + os.killpg(process.pid, 9) + except (ProcessLookupError, PermissionError): + try: + process.kill() + except ProcessLookupError: + pass + await process.wait() + + +def _close_transport(process: asyncio.subprocess.Process) -> None: + # asyncio exposes no public subprocess close method. Closing its transport is + # necessary after forced termination so pipe descriptors cannot survive until + # garbage collection (CPython issue 103847). + process._transport.close() # type: ignore[attr-defined] + + +async def _cancel_tasks(*tasks: asyncio.Task[object]) -> None: + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) diff --git a/openbox_sandbox/dispatcher/command.py b/openbox_sandbox/dispatcher/command.py new file mode 100644 index 0000000..bb64550 --- /dev/null +++ b/openbox_sandbox/dispatcher/command.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping, Sequence + +from .errors import DispatcherValidationError + + +def _identifier(value: object) -> str: + if not isinstance(value, str) or not value or len(value.encode("utf-8")) > 512: + raise DispatcherValidationError() + return value + + +@dataclass(frozen=True, slots=True, repr=False, init=False) +class GovernedCommand: + workflow_id: str + run_id: str + activity_id: str + argv: tuple[str, ...] + profile_id: str + timeout_seconds: int + workflow_type: str + task_queue: str + attempt: int + arguments: Mapping[str, Any] + + def __init__( + self, + *, + workflow_id: str, + run_id: str, + activity_id: str, + argv: Sequence[str], + profile_id: str, + timeout_seconds: int = 30, + workflow_type: str = "generic", + task_queue: str = "generic", + attempt: int = 1, + arguments: Mapping[str, Any] | None = None, + ) -> None: + if isinstance(argv, (str, bytes, bytearray, Mapping)): + raise DispatcherValidationError() + try: + snapshot = tuple(argv) + except TypeError as error: + raise DispatcherValidationError() from error + if ( + not snapshot + or not all(isinstance(value, str) for value in snapshot) + or sum(len(value.encode("utf-8")) for value in snapshot) > 1024 * 1024 + or isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, int) + or not 1 <= timeout_seconds <= 300 + or type(attempt) is not int + or attempt != 1 + ): + raise DispatcherValidationError() + object.__setattr__(self, "workflow_id", _identifier(workflow_id)) + object.__setattr__(self, "run_id", _identifier(run_id)) + object.__setattr__(self, "activity_id", _identifier(activity_id)) + object.__setattr__(self, "argv", snapshot) + object.__setattr__(self, "profile_id", _identifier(profile_id)) + object.__setattr__(self, "timeout_seconds", timeout_seconds) + object.__setattr__(self, "workflow_type", _identifier(workflow_type)) + object.__setattr__(self, "task_queue", _identifier(task_queue)) + object.__setattr__(self, "attempt", 1) + object.__setattr__(self, "arguments", dict(arguments or {})) + + def __repr__(self) -> str: + return ( + "GovernedCommand(" + f"workflow_id={self.workflow_id!r}, run_id={self.run_id!r}, " + f"activity_id={self.activity_id!r}, profile_id={self.profile_id!r}, " + f"argv=, argv_count={len(self.argv)}, " + f"timeout_seconds={self.timeout_seconds})" + ) diff --git a/openbox_sandbox/dispatcher/dispatcher.py b/openbox_sandbox/dispatcher/dispatcher.py new file mode 100644 index 0000000..f2c0ceb --- /dev/null +++ b/openbox_sandbox/dispatcher/dispatcher.py @@ -0,0 +1,1411 @@ +from __future__ import annotations + +import asyncio +import hashlib +import re +import time +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Mapping + +from openbox_sandbox.runtime_client import ( + AssetBundleIdentity, + CreateRequest, + ExecCompleted, + ExecRequest, + OutputLimits, + PolicyDocument, + ProtocolValidationError, + SandboxRuntimeClient, + SandboxRuntimeClientConfig, + SandboxServiceTransportError, + SubmissionState, + UnixAgentRuntimeClient, + UnixAgentRuntimeClientConfig, + generate_request_owned_id, +) + +from ._host import _HostConfig, _HostExecutor, _HostFailure +from .command import GovernedCommand +from .errors import ( + DispatchErrorCode, + GovernanceProtocolError, + GovernanceTransportError, + NormalizedDispatchError, +) +from .governance import GovernanceClient, GovernanceClientConfig, GovernanceDecision +from .profiles import CommandProfileBundle +from .result import ( + CleanupReconciliationResult, + CleanupStatus, + Directive, + DispatchResult, + Disposition, + ExecutionMetadata, + TimeoutStatus, +) +from .telemetry import CleanupBacklog, NullTelemetrySink, TelemetryEvent, TelemetrySink + +_SAFE_EVIDENCE_IDENTITY = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/@-]{0,511}\Z") + + +@dataclass(frozen=True, slots=True, repr=False) +class SandboxExecutionConfig: + host: str + port: int + server_name: str + ca_path: Path + certificate_path: Path + private_key_path: Path + asset_bundle: AssetBundleIdentity + policy_document: PolicyDocument + output_limits: OutputLimits = OutputLimits( + stdout_bytes=1024 * 1024, + stderr_bytes=1024 * 1024, + combined_bytes=2 * 1024 * 1024, + chunk_bytes=4 * 1024 * 1024, + ) + create_deadline_ms: int = 60_000 + readiness_deadline_ms: int = 120_000 + exec_deadline_ms: int = 45_000 + delete_deadline_ms: int = 60_000 + wait_deleted_deadline_ms: int = 60_000 + enabled: bool = True + policy_resolver: Callable[[str], PolicyDocument] | None = None + + def __post_init__(self) -> None: + for value, maximum in ( + (self.create_deadline_ms, 60_000), + (self.readiness_deadline_ms, 120_000), + (self.exec_deadline_ms, 45_000), + (self.delete_deadline_ms, 60_000), + (self.wait_deleted_deadline_ms, 60_000), + ): + if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum: + raise ValueError("sandbox deadline rejected") + if type(self.enabled) is not bool: + raise ValueError("sandbox kill switch rejected") + + def __repr__(self) -> str: + return ( + f"SandboxExecutionConfig(host={self.host!r}, port={self.port}, " + f"server_name={self.server_name!r}, credentials=, " + f"asset_bundle={self.asset_bundle!r}, policy_document=, " + f"output_limits={self.output_limits!r}, enabled={self.enabled})" + ) + + +@dataclass(frozen=True, slots=True, repr=False) +class UnixAgentExecutionConfig: + """Local typed-agent transport; identical lifecycle semantics to TCP mTLS.""" + + socket_path: Path + registry_fingerprint: str + asset_bundle: AssetBundleIdentity + policy_document: PolicyDocument + output_limits: OutputLimits = OutputLimits( + stdout_bytes=1024 * 1024, + stderr_bytes=1024 * 1024, + combined_bytes=2 * 1024 * 1024, + chunk_bytes=4 * 1024 * 1024, + ) + create_deadline_ms: int = 60_000 + readiness_deadline_ms: int = 120_000 + exec_deadline_ms: int = 45_000 + delete_deadline_ms: int = 60_000 + wait_deleted_deadline_ms: int = 60_000 + enabled: bool = True + policy_resolver: Callable[[str], PolicyDocument] | None = None + + def __post_init__(self) -> None: + for value, maximum in ( + (self.create_deadline_ms, 60_000), + (self.readiness_deadline_ms, 120_000), + (self.exec_deadline_ms, 45_000), + (self.delete_deadline_ms, 60_000), + (self.wait_deleted_deadline_ms, 60_000), + ): + if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum: + raise ValueError("sandbox deadline rejected") + if type(self.enabled) is not bool: + raise ValueError("sandbox kill switch rejected") + if not self.socket_path.is_absolute(): + raise ValueError("sandbox agent socket path rejected") + + def __repr__(self) -> str: + return ( + "UnixAgentExecutionConfig(socket_path=, " + f"asset_bundle={self.asset_bundle!r}, policy_document=, " + f"output_limits={self.output_limits!r}, enabled={self.enabled})" + ) + + +@dataclass(frozen=True, slots=True, repr=False) +class DispatcherConfig: + governance: GovernanceClientConfig | None + profiles: CommandProfileBundle + sandbox: SandboxExecutionConfig | UnixAgentExecutionConfig + host_workdir: Path + telemetry: TelemetrySink | None = None + cleanup_backlog: CleanupBacklog | None = None + host_environment: Mapping[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.host_workdir.is_absolute(): + raise ValueError("host workdir rejected") + if not isinstance(self.host_environment, Mapping) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in self.host_environment.items() + ): + raise ValueError("host environment rejected") + + def __repr__(self) -> str: + return ( + f"DispatcherConfig(governance={self.governance!r}, profiles={self.profiles!r}, " + f"sandbox={self.sandbox!r}, host_workdir=, " + f"telemetry={'configured' if self.telemetry else 'disabled'}, " + f"cleanup_backlog={'configured' if self.cleanup_backlog else 'disabled'})" + ) + + +class GovernedDispatcher: + def __init__(self, config: DispatcherConfig) -> None: + runtime: SandboxRuntimeClient | UnixAgentRuntimeClient + if isinstance(config.sandbox, UnixAgentExecutionConfig): + runtime = UnixAgentRuntimeClient( + UnixAgentRuntimeClientConfig( + socket_path=config.sandbox.socket_path, + asset_bundle=config.sandbox.asset_bundle, + registry_fingerprint=config.sandbox.registry_fingerprint, + ) + ) + else: + runtime = SandboxRuntimeClient( + SandboxRuntimeClientConfig( + host=config.sandbox.host, + port=config.sandbox.port, + server_name=config.sandbox.server_name, + ca_path=config.sandbox.ca_path, + certificate_path=config.sandbox.certificate_path, + private_key_path=config.sandbox.private_key_path, + asset_bundle=config.sandbox.asset_bundle, + ) + ) + self._configure( + config, + None if config.governance is None else GovernanceClient(config.governance), + runtime, + _HostExecutor( + _HostConfig(config.host_workdir, environment=config.host_environment) + ), + lambda: datetime.now(timezone.utc), + time.monotonic, + generate_request_owned_id, + ) + + @classmethod + def _from_components( + cls, + config: DispatcherConfig, + *, + governance: Any, + sandbox: Any, + host: Any, + clock: Callable[[], datetime], + monotonic: Callable[[], float] = time.monotonic, + sandbox_id: Callable[[], str] = generate_request_owned_id, + ) -> GovernedDispatcher: + instance = cls.__new__(cls) + instance._configure(config, governance, sandbox, host, clock, monotonic, sandbox_id) + return instance + + def _configure( + self, + config: DispatcherConfig, + governance: Any, + sandbox: Any, + host: Any, + clock: Callable[[], datetime], + monotonic: Callable[[], float], + sandbox_id: Callable[[], str], + ) -> None: + self._config = config + self._governance = governance + self._sandbox = sandbox + self._host = host + self._clock = clock + self._monotonic = monotonic + self._sandbox_id = sandbox_id + self._telemetry = config.telemetry or NullTelemetrySink() + + @property + def governance_signer_did(self) -> str | None: + governance = self._config.governance + signer = None if governance is None else governance.request_signer + return None if signer is None else signer.agent_did + + async def dispatch(self, command: GovernedCommand) -> DispatchResult: + """Evaluate an unadmitted command with Core, then dispatch its verdict.""" + profile_failure = await self._admit(command) + if profile_failure is not None: + return profile_failure + now = self._clock().astimezone(timezone.utc) + event = _activity_started(command, now) + try: + if self._governance is None: + raise GovernanceProtocolError() + decision = await self._governance.evaluate(event) + if not isinstance(decision, GovernanceDecision): + decision = GovernanceDecision.parse(decision) + except GovernanceTransportError: + return await self._terminal( + command, + None, + Disposition.NOT_EXECUTED, + Directive.CONTINUE, + None, + DispatchErrorCode.GOVERNANCE_TRANSPORT, + ) + except (GovernanceProtocolError, TypeError, ValueError): + return await self._terminal( + command, + None, + Disposition.NOT_EXECUTED, + Directive.CONTINUE, + None, + DispatchErrorCode.GOVERNANCE_PROTOCOL, + ) + await self._emit(command, decision, "governance_decision_received") + return await self._dispatch_decision(command, decision, report_core=True) + + async def dispatch_with_decision( + self, command: GovernedCommand, decision: GovernanceDecision + ) -> DispatchResult: + """Dispatch an already-evaluated operation under its governance decision. + + The caller (an application agent that evaluated the operation with a + governance Core) owns the evaluation; this entry admits the profile, + validates the decision shape, and executes the verdict: CONSTRAIN runs + the command in a sandbox under the policy the decision names (resolved + through the sandbox policy resolver), ALLOW runs it on the host, and + any other verdict terminates without execution. No Core client is + constructed or called. + """ + profile_failure = await self._admit(command) + if profile_failure is not None: + return profile_failure + if not isinstance(decision, GovernanceDecision): + raise TypeError("dispatch_with_decision accepts GovernanceDecision only") + if decision.fallback_used: + return await self._terminal( + command, + decision, + Disposition.NOT_EXECUTED, + Directive.CONTINUE, + None, + DispatchErrorCode.GOVERNANCE_FALLBACK, + ) + await self._emit(command, decision, "governance_decision_received") + return await self._dispatch_decision(command, decision, report_core=False) + + async def dispatch_trusted_constrain(self, command: GovernedCommand) -> DispatchResult: + """Dispatch CONSTRAIN input from an owned application agent. + + This is an explicit same-trust-domain handoff, not an authorization + mechanism. It performs independent profile admission, can only select + sandbox execution, and never constructs or calls a Core client. + """ + profile_failure = await self._admit(command) + if profile_failure is not None: + return profile_failure + reference = "trusted-application:" + ":".join( + (command.workflow_id, command.run_id, command.activity_id) + ) + decision = GovernanceDecision.parse( + { + "governance_event_id": str(uuid.uuid5(uuid.NAMESPACE_URL, reference)), + "verdict": "constrain", + "risk_score": 0.0, + "action": "constrain", + "fallback_used": False, + "constraints": ["run_in_sandbox"], + } + ) + await self._emit(command, decision, "trusted_application_input_accepted") + return await self._dispatch_decision(command, decision, report_core=False) + + async def dispatch_authorized_constrain( + self, command: GovernedCommand, *, authorization_id: str + ) -> DispatchResult: + """Dispatch a caller-authenticated CONSTRAIN without another Core call. + + The caller owns cryptographic authorization validation. This narrow entry + point admits only a non-fallback sandbox constraint and never permits host + execution. The authorization identifier is metadata only. + """ + profile_failure = await self._admit(command) + if profile_failure is not None: + return profile_failure + if not isinstance(authorization_id, str) or not authorization_id: + raise TypeError("authorization id rejected") + decision = GovernanceDecision.parse( + { + "governance_event_id": str(uuid.uuid5(uuid.NAMESPACE_URL, authorization_id)), + "verdict": "constrain", + "risk_score": 0.0, + "action": "constrain", + "fallback_used": False, + "constraints": ["run_in_sandbox"], + } + ) + await self._emit(command, decision, "authorization_receipt_accepted") + return await self._dispatch_decision(command, decision, report_core=False) + + async def _admit(self, command: GovernedCommand) -> DispatchResult | None: + if not isinstance(command, GovernedCommand): + raise TypeError("dispatch accepts GovernedCommand only") + now = self._clock().astimezone(timezone.utc) + if self._config.profiles.admits(command.profile_id, command.argv, now=now): + return None + return await self._terminal( + command, + None, + Disposition.NOT_EXECUTED, + Directive.CONTINUE, + None, + DispatchErrorCode.PROFILE_REJECTED, + ) + + async def _dispatch_decision( + self, + command: GovernedCommand, + decision: GovernanceDecision, + *, + report_core: bool, + ) -> DispatchResult: + if decision.fallback_used: + return await self._terminal( + command, + decision, + Disposition.NOT_EXECUTED, + Directive.CONTINUE, + None, + DispatchErrorCode.GOVERNANCE_FALLBACK, + ) + if decision.verdict == "allow": + return await self._dispatch_host(command, decision) + if decision.verdict == "constrain": + constraints = decision.constraints + if constraints != ("run_in_sandbox",): + return await self._terminal( + command, + decision, + Disposition.NOT_EXECUTED, + Directive.CONTINUE, + None, + DispatchErrorCode.UNSUPPORTED_CONSTRAINT, + ) + if decision.has_guardrails_result: + return await self._terminal( + command, + decision, + Disposition.NOT_EXECUTED, + Directive.CONTINUE, + None, + DispatchErrorCode.REMEDIATION_UNSUPPORTED, + ) + if not self._config.sandbox.enabled: + return await self._terminal( + command, + decision, + Disposition.NOT_EXECUTED, + Directive.CONTINUE, + None, + DispatchErrorCode.SANDBOX_DISABLED, + ) + return await self._dispatch_sandbox(command, decision, report_core=report_core) + if decision.verdict == "require_approval": + return await self._terminal( + command, + decision, + Disposition.NOT_EXECUTED, + Directive.CONTINUE, + None, + DispatchErrorCode.APPROVAL_REQUIRED, + ) + if decision.verdict == "block": + return await self._terminal( + command, + decision, + Disposition.NOT_EXECUTED, + Directive.CONTINUE, + None, + DispatchErrorCode.BLOCKED, + ) + return await self._terminal( + command, + decision, + Disposition.NOT_EXECUTED, + Directive.HALT, + None, + DispatchErrorCode.HALTED, + ) + + async def reconcile_cleanup(self) -> CleanupReconciliationResult: + backlog = self._config.cleanup_backlog + if backlog is None: + return CleanupReconciliationResult(attempted=0, deleted=0, remaining=0) + async with backlog.reconciliation_lock(): + request_ids = await backlog.request_ids() + deleted = 0 + for request_id in request_ids: + try: + await self._sandbox.delete(request_id, self._config.sandbox.delete_deadline_ms) + absent = await self._sandbox.wait_deleted( + request_id, self._config.sandbox.wait_deleted_deadline_ms + ) + if absent.response != "terminally_absent": + continue + await backlog.remove(request_id) + deleted += 1 + except ( + SandboxServiceTransportError, + ProtocolValidationError, + ValueError, + TypeError, + OSError, + ): + continue + remaining = len(await backlog.request_ids()) + return CleanupReconciliationResult( + attempted=len(request_ids), deleted=deleted, remaining=remaining + ) + + async def _dispatch_host( + self, command: GovernedCommand, decision: GovernanceDecision + ) -> DispatchResult: + started = self._monotonic() + try: + outcome = await self._host.execute(command.argv, command.timeout_seconds) + except _HostFailure as failure: + return await self._terminal( + command, + decision, + Disposition.EXECUTION_INDETERMINATE, + Directive.CONTINUE, + ExecutionMetadata( + sandbox_id=None, + exit_code=None, + stdout=b"", + stderr=b"", + timeout_status=TimeoutStatus.UNKNOWN, + cleanup_status=CleanupStatus.NOT_NEEDED, + ), + failure.code, + ) + execution = ExecutionMetadata( + sandbox_id=None, + exit_code=outcome.exit_code, + stdout=outcome.stdout, + stderr=outcome.stderr, + timeout_status=outcome.timeout_status, + cleanup_status=CleanupStatus.NOT_NEEDED, + ) + await self._emit( + command, + decision, + "host_exec_finished", + disposition=Disposition.EXECUTED_ON_HOST.value, + execution=execution, + duration_ms=_duration_ms(started, self._monotonic()), + ) + return await self._terminal( + command, + decision, + Disposition.EXECUTED_ON_HOST, + Directive.CONTINUE, + execution, + None, + ) + + async def _policy_for(self, decision: GovernanceDecision) -> PolicyDocument: + """Resolve the policy the decision names; pinned fallback without a resolver.""" + resolver = self._config.sandbox.policy_resolver + named = decision.raw.get("policy_id") + if resolver is not None: + if not isinstance(named, str) or not named: + raise GovernanceProtocolError("decision omitted a policy id") + resolved = resolver(named) + if not isinstance(resolved, PolicyDocument): + raise GovernanceProtocolError("policy resolution failed") + return resolved + return self._config.sandbox.policy_document + + async def _dispatch_sandbox( + self, + command: GovernedCommand, + decision: GovernanceDecision, + *, + report_core: bool, + ) -> DispatchResult: + sandbox_id = self._sandbox_id() + ownership = [False] + started_ns = _epoch_ns(self._clock()) + try: + result = await self._dispatch_sandbox_lifecycle( + command, decision, sandbox_id, ownership + ) + if report_core: + return await self._report_sandbox_result( + command, decision, result, started_ns=started_ns + ) + return result + except asyncio.CancelledError: + cleanup = CleanupStatus.NOT_NEEDED + if ownership[0]: + cleanup_task = asyncio.create_task(self._cleanup(command, decision, sandbox_id)) + try: + cleanup = await asyncio.shield(cleanup_task) + except asyncio.CancelledError: + cleanup = await cleanup_task + if report_core: + cancelled_result = DispatchResult( + disposition=Disposition.EXECUTION_INDETERMINATE, + directive=Directive.CONTINUE, + execution=ExecutionMetadata( + sandbox_id=sandbox_id, + exit_code=None, + stdout=b"", + stderr=b"", + timeout_status=TimeoutStatus.UNKNOWN, + cleanup_status=cleanup, + ), + error=NormalizedDispatchError(DispatchErrorCode.CANCELLED), + _governance=decision.raw, + ) + await self._report_sandbox_result( + command, decision, cancelled_result, started_ns=started_ns + ) + raise + + async def _dispatch_sandbox_lifecycle( + self, + command: GovernedCommand, + decision: GovernanceDecision, + sandbox_id: str, + ownership: list[bool], + ) -> DispatchResult: + cleanup_required = False + lifecycle_token: str | None = None + await self._emit( + command, + decision, + "sandbox_create_started", + sandbox_id=sandbox_id, + lifecycle_phase="create", + ) + try: + response = await self._sandbox.create( + CreateRequest( + request_id=sandbox_id, + template=self._config.sandbox.asset_bundle.template, + policy_document=await self._policy_for(decision), + expected_policy=self._config.sandbox.asset_bundle.policy, + ), + self._config.sandbox.create_deadline_ms, + ) + except SandboxServiceTransportError as error: + cleanup_required = error.submission_state is SubmissionState.POSSIBLY_SUBMITTED + ownership[0] = cleanup_required + return await self._sandbox_terminal( + command, + decision, + sandbox_id, + cleanup_required, + Disposition.NOT_EXECUTED, + DispatchErrorCode.SANDBOX_CREATE, + None, + ) + except (ProtocolValidationError, ValueError, TypeError): + return await self._sandbox_terminal( + command, + decision, + sandbox_id, + False, + Disposition.NOT_EXECUTED, + DispatchErrorCode.SANDBOX_PROTOCOL, + None, + ) + if response.response == "created": + cleanup_required = True + ownership[0] = True + if response.fields.get("request_id") != sandbox_id or not isinstance( + response.fields.get("lifecycle_token"), str + ): + return await self._sandbox_terminal( + command, + decision, + sandbox_id, + True, + Disposition.NOT_EXECUTED, + DispatchErrorCode.SANDBOX_PROTOCOL, + None, + ) + lifecycle_token = response.fields["lifecycle_token"] + elif response.response == "create_failed": + failure = response.fields.get("failure") + state = failure.get("state") if isinstance(failure, dict) else None + cleanup_required = state == "possibly_created" + if state not in {"not_created", "possibly_created", "conflict"}: + cleanup_required = True + ownership[0] = cleanup_required + return await self._sandbox_terminal( + command, + decision, + sandbox_id, + cleanup_required, + Disposition.NOT_EXECUTED, + DispatchErrorCode.SANDBOX_CREATE, + None, + ) + elif response.response == "boundary_failed": + failure = response.fields.get("failure") + cleanup_required = ( + isinstance(failure, dict) and failure.get("cleanup_target") is not None + ) + ownership[0] = cleanup_required + return await self._sandbox_terminal( + command, + decision, + sandbox_id, + cleanup_required, + Disposition.NOT_EXECUTED, + DispatchErrorCode.SANDBOX_CREATE, + None, + ) + else: + ownership[0] = True + return await self._sandbox_terminal( + command, + decision, + sandbox_id, + True, + Disposition.NOT_EXECUTED, + DispatchErrorCode.SANDBOX_PROTOCOL, + None, + ) + await self._emit( + command, + decision, + "sandbox_create_finished", + sandbox_id=sandbox_id, + lifecycle_phase="create", + ) + try: + ready = await self._sandbox.wait_ready( + sandbox_id, + lifecycle_token, + self._config.sandbox.asset_bundle.policy, + self._config.sandbox.readiness_deadline_ms, + ) + except ( + SandboxServiceTransportError, + ProtocolValidationError, + ValueError, + TypeError, + ): + return await self._sandbox_terminal( + command, + decision, + sandbox_id, + cleanup_required, + Disposition.NOT_EXECUTED, + DispatchErrorCode.SANDBOX_READINESS, + None, + ) + ready_token = ready.fields.get("lifecycle_token") + if ( + ready.response != "ready" + or ready.fields.get("request_id") != sandbox_id + or not isinstance(ready_token, str) + or ready.fields.get("active_policy") + != self._config.sandbox.asset_bundle.policy.to_wire() + ): + return await self._sandbox_terminal( + command, + decision, + sandbox_id, + cleanup_required, + Disposition.NOT_EXECUTED, + DispatchErrorCode.SANDBOX_READINESS, + None, + ) + lifecycle_token = ready_token + await self._emit( + command, + decision, + "sandbox_ready", + sandbox_id=sandbox_id, + lifecycle_phase="ready", + ) + await self._emit( + command, + decision, + "sandbox_exec_started", + sandbox_id=sandbox_id, + lifecycle_phase="exec", + ) + try: + executed = await self._sandbox.exec( + sandbox_id, + lifecycle_token, + ExecRequest( + command.argv, + command.timeout_seconds, + self._config.sandbox.output_limits, + ), + self._config.sandbox.exec_deadline_ms, + ) + except SandboxServiceTransportError as error: + disposition = ( + Disposition.NOT_EXECUTED + if error.submission_state is SubmissionState.NOT_SUBMITTED + else Disposition.EXECUTION_INDETERMINATE + ) + code = ( + DispatchErrorCode.SANDBOX_EXEC_NOT_DISPATCHED + if disposition is Disposition.NOT_EXECUTED + else DispatchErrorCode.SANDBOX_EXEC_INDETERMINATE + ) + return await self._sandbox_terminal( + command, + decision, + sandbox_id, + cleanup_required, + disposition, + code, + None, + ) + except (ProtocolValidationError, ValueError, TypeError): + return await self._sandbox_terminal( + command, + decision, + sandbox_id, + cleanup_required, + Disposition.EXECUTION_INDETERMINATE, + DispatchErrorCode.SANDBOX_PROTOCOL, + None, + ) + if executed.response == "executed": + try: + completed = ExecCompleted.from_wire(executed.fields.get("result")) + except (ProtocolValidationError, TypeError): + return await self._sandbox_terminal( + command, + decision, + sandbox_id, + cleanup_required, + Disposition.EXECUTION_INDETERMINATE, + DispatchErrorCode.SANDBOX_PROTOCOL, + None, + ) + execution = ExecutionMetadata( + sandbox_id=sandbox_id, + exit_code=completed.exit_code, + stdout=completed.stdout, + stderr=completed.stderr, + timeout_status=_timeout_status(completed.timeout), + cleanup_status=CleanupStatus.FAILED, + ) + await self._emit( + command, + decision, + "sandbox_exec_finished", + sandbox_id=sandbox_id, + lifecycle_phase="exec", + disposition=Disposition.EXECUTED_IN_SANDBOX.value, + execution=execution, + ) + return await self._sandbox_terminal( + command, + decision, + sandbox_id, + cleanup_required, + Disposition.EXECUTED_IN_SANDBOX, + None, + execution, + ) + if executed.response in {"exec_failed", "boundary_failed"}: + failure = executed.fields.get("failure") + dispatch_state = failure.get("dispatch_state") if isinstance(failure, dict) else None + disposition = ( + Disposition.NOT_EXECUTED + if dispatch_state == "not_dispatched" + else Disposition.EXECUTION_INDETERMINATE + ) + code = ( + DispatchErrorCode.SANDBOX_EXEC_NOT_DISPATCHED + if disposition is Disposition.NOT_EXECUTED + else DispatchErrorCode.SANDBOX_EXEC_INDETERMINATE + ) + return await self._sandbox_terminal( + command, + decision, + sandbox_id, + cleanup_required, + disposition, + code, + None, + ) + return await self._sandbox_terminal( + command, + decision, + sandbox_id, + cleanup_required, + Disposition.EXECUTION_INDETERMINATE, + DispatchErrorCode.SANDBOX_PROTOCOL, + None, + ) + + async def _sandbox_terminal( + self, + command: GovernedCommand, + decision: GovernanceDecision, + sandbox_id: str, + cleanup_required: bool, + disposition: Disposition, + error_code: DispatchErrorCode | None, + execution: ExecutionMetadata | None, + ) -> DispatchResult: + cleanup = ( + await self._cleanup(command, decision, sandbox_id) + if cleanup_required + else CleanupStatus.NOT_NEEDED + ) + if execution is None and disposition is Disposition.EXECUTION_INDETERMINATE: + execution = ExecutionMetadata( + sandbox_id=sandbox_id, + exit_code=None, + stdout=b"", + stderr=b"", + timeout_status=TimeoutStatus.UNKNOWN, + cleanup_status=cleanup, + ) + elif execution is not None: + execution = ExecutionMetadata( + sandbox_id=execution.sandbox_id, + exit_code=execution.exit_code, + stdout=execution.stdout, + stderr=execution.stderr, + timeout_status=execution.timeout_status, + cleanup_status=cleanup, + ) + return await self._terminal( + command, + decision, + disposition, + Directive.CONTINUE, + execution, + error_code, + ) + + async def _report_sandbox_result( + self, + command: GovernedCommand, + initial_decision: GovernanceDecision, + result: DispatchResult, + *, + started_ns: int, + ) -> DispatchResult: + """Attach bounded sandbox evidence, then govern normal completion. + + The completed hook is best-effort and can never undo execution. A + BLOCK/HALT response is nevertheless surfaced as terminal/future control, + matching completed-hook semantics in the shared SDK. ActivityCompleted + is a separate, span-free lifecycle evaluation and remains fail-closed. + """ + if self._governance is None: + return result + completed_ns = max(started_ns + 1, _epoch_ns(self._clock())) + hook_decision: GovernanceDecision | None = None + try: + raw_hook_decision = await self._governance.evaluate( + _sandbox_completed_hook( + command, + result, + self._config, + started_ns=started_ns, + completed_ns=completed_ns, + ) + ) + hook_decision = ( + raw_hook_decision + if isinstance(raw_hook_decision, GovernanceDecision) + else GovernanceDecision.parse(raw_hook_decision) + ) + except ( + GovernanceTransportError, + GovernanceProtocolError, + TypeError, + ValueError, + ): + # Completed telemetry is explicitly best-effort. Execution and + # cleanup have already happened and are never reclassified here. + hook_decision = None + + if hook_decision is not None and hook_decision.verdict in {"block", "halt"}: + return _completed_stop_result(result, initial_decision, hook_decision) + + if not _is_normal_sandbox_completion(result): + return result + + try: + raw_completed_decision = await self._governance.evaluate( + _activity_completed( + command, + result, + self._clock(), + duration_ns=completed_ns - started_ns, + ) + ) + completed_decision = ( + raw_completed_decision + if isinstance(raw_completed_decision, GovernanceDecision) + else GovernanceDecision.parse(raw_completed_decision) + ) + except GovernanceTransportError: + return _completion_failure_result( + result, initial_decision, DispatchErrorCode.GOVERNANCE_TRANSPORT + ) + except (GovernanceProtocolError, TypeError, ValueError): + return _completion_failure_result( + result, initial_decision, DispatchErrorCode.GOVERNANCE_PROTOCOL + ) + + if completed_decision.fallback_used: + return _completion_failure_result( + result, initial_decision, DispatchErrorCode.GOVERNANCE_FALLBACK + ) + if completed_decision.verdict == "halt": + return _completion_failure_result( + result, + initial_decision, + DispatchErrorCode.HALTED, + directive=Directive.HALT, + ) + if completed_decision.verdict == "block": + return _completion_failure_result(result, initial_decision, DispatchErrorCode.BLOCKED) + if completed_decision.verdict == "require_approval": + return _completion_failure_result( + result, initial_decision, DispatchErrorCode.APPROVAL_REQUIRED + ) + if completed_decision.has_guardrails_result: + return _completion_failure_result( + result, initial_decision, DispatchErrorCode.REMEDIATION_UNSUPPORTED + ) + return result + + async def _cleanup( + self, command: GovernedCommand, decision: GovernanceDecision, sandbox_id: str + ) -> CleanupStatus: + await self._emit( + command, + decision, + "sandbox_delete_started", + sandbox_id=sandbox_id, + lifecycle_phase="delete", + ) + status = CleanupStatus.FAILED + try: + await self._sandbox.delete(sandbox_id, self._config.sandbox.delete_deadline_ms) + absent = await self._sandbox.wait_deleted( + sandbox_id, self._config.sandbox.wait_deleted_deadline_ms + ) + if absent.response == "terminally_absent": + status = CleanupStatus.DELETED + except ( + SandboxServiceTransportError, + ProtocolValidationError, + ValueError, + TypeError, + ): + status = CleanupStatus.FAILED + if status is CleanupStatus.DELETED: + if self._config.cleanup_backlog is not None: + try: + await self._config.cleanup_backlog.remove(sandbox_id) + except OSError: + pass + await self._emit( + command, + decision, + "sandbox_deleted", + sandbox_id=sandbox_id, + lifecycle_phase="delete", + cleanup_status=status.value, + ) + else: + if self._config.cleanup_backlog is not None: + try: + await self._config.cleanup_backlog.record( + sandbox_id, + "unconfirmed_absence", + _iso8601(self._clock()), + ) + except OSError: + pass + await self._emit( + command, + decision, + "sandbox_execution_failed", + sandbox_id=sandbox_id, + lifecycle_phase="delete", + cleanup_status=status.value, + ) + return status + + async def _terminal( + self, + command: GovernedCommand, + decision: GovernanceDecision | None, + disposition: Disposition, + directive: Directive, + execution: ExecutionMetadata | None, + error_code: DispatchErrorCode | None, + ) -> DispatchResult: + result = DispatchResult( + disposition=disposition, + directive=directive, + execution=execution, + error=None if error_code is None else NormalizedDispatchError(error_code), + _governance=None if decision is None else decision.raw, + ) + await self._emit( + command, + decision, + "dispatch_terminal", + disposition=disposition.value, + directive=directive.value, + execution=execution, + error_code=None if error_code is None else error_code.value, + ) + return result + + async def _emit( + self, + command: GovernedCommand, + decision: GovernanceDecision | None, + event: str, + *, + disposition: str | None = None, + directive: str | None = None, + sandbox_id: str | None = None, + lifecycle_phase: str | None = None, + execution: ExecutionMetadata | None = None, + duration_ms: int | None = None, + error_code: str | None = None, + cleanup_status: str | None = None, + ) -> None: + raw = None if decision is None else decision.raw + bundle = self._config.sandbox.asset_bundle + value = TelemetryEvent( + event=event, + workflow_id=command.workflow_id, + run_id=command.run_id, + activity_id=command.activity_id, + attempt=command.attempt, + governance_event_id=None if raw is None else raw["governance_event_id"], + governance_policy_id=( + None + if raw is None or not isinstance(raw.get("policy_id"), str) + else raw["policy_id"] + ), + verdict=None if decision is None else decision.verdict, + action=None if decision is None else decision.action, + fallback_used=None if decision is None else decision.fallback_used, + constraints=None if decision is None else decision.constraints, + disposition=disposition, + directive=directive, + sandbox_id=sandbox_id, + lifecycle_phase=lifecycle_phase, + timeout_seconds=command.timeout_seconds, + timeout_status=None if execution is None else execution.timeout_status.value, + exit_code=None if execution is None else execution.exit_code, + stdout_bytes=None if execution is None else len(execution.stdout), + stderr_bytes=None if execution is None else len(execution.stderr), + duration_ms=duration_ms, + error_code=error_code, + cleanup_status=( + cleanup_status + if cleanup_status is not None + else None + if execution is None + else execution.cleanup_status.value + ), + runtime_contract_version=bundle.runtime_contract_version, + policy_id=bundle.policy.id, + policy_version=bundle.policy.version, + template_digest=bundle.template, + profile_bundle_version=self._config.profiles.bundle_version, + ) + try: + await self._telemetry.emit(value) + except Exception: + pass + + +def _activity_started(command: GovernedCommand, now: datetime) -> dict[str, Any]: + """Build the real pre-execution governed-Activity event evaluated by Core.""" + return { + "source": "governed-dispatcher", + "event_type": "ActivityStarted", + "workflow_id": command.workflow_id, + "run_id": command.run_id, + "workflow_type": command.workflow_type, + "task_queue": command.task_queue, + "timestamp": _iso8601(now), + "activity_id": command.activity_id, + "activity_type": "openbox_governed_command", + "attempt": command.attempt, + "profile_id": command.profile_id, + "activity_input": [{"argv": list(command.argv)}], + "operation": { + "profile_id": command.profile_id, + "arguments": dict(command.arguments), + }, + } + + +def _sandbox_completed_hook( + command: GovernedCommand, + result: DispatchResult, + config: DispatcherConfig, + *, + started_ns: int, + completed_ns: int, +) -> dict[str, Any]: + execution = result.execution + stdout = b"" if execution is None else execution.stdout + stderr = b"" if execution is None else execution.stderr + error_code = "none" if result.error is None else result.error.code.value + disposition = result.disposition.value + timeout_status = "unknown" if execution is None else execution.timeout_status.value + cleanup_status = "not_needed" if execution is None else execution.cleanup_status.value + exit_code = None if execution is None else execution.exit_code + if timeout_status in { + TimeoutStatus.CONFIRMED_TIMEOUT.value, + TimeoutStatus.POSSIBLE_TIMEOUT.value, + }: + outcome = "timeout" + elif disposition == Disposition.EXECUTED_IN_SANDBOX.value: + outcome = "success" if exit_code == 0 else "nonzero" + elif disposition == Disposition.EXECUTION_INDETERMINATE.value: + outcome = "indeterminate" + else: + outcome = "not_executed" + + bundle = config.sandbox.asset_bundle + span_id, trace_id = _sandbox_span_ids( + workflow_id=command.workflow_id, + run_id=command.run_id, + activity_id=command.activity_id, + attempt=command.attempt, + ) + attributes: dict[str, str | int | bool] = { + "sandbox.provider": "openshell", + "openbox.sandbox.profile_id": _safe_evidence_identity(command.profile_id), + "openbox.sandbox.runtime_contract_version": bundle.runtime_contract_version, + "openbox.sandbox.adapter_build_sha256": bundle.adapter_build_sha256, + "openbox.sandbox.compatibility_id": _safe_evidence_identity(bundle.compatibility_id), + "openbox.sandbox.image_digest": _image_digest(bundle.template), + "openbox.sandbox.template_sha256": hashlib.sha256( + bundle.template.encode("utf-8") + ).hexdigest(), + "openbox.sandbox.policy_id": _safe_evidence_identity(bundle.policy.id), + "openbox.sandbox.policy_version": bundle.policy.version, + "openbox.sandbox.policy_sha256": bundle.policy.sha256, + "openbox.sandbox.profile_bundle_version": _safe_evidence_identity( + config.profiles.bundle_version + ), + "openbox.sandbox.outcome": outcome, + "openbox.sandbox.disposition": disposition, + "openbox.sandbox.timeout_status": timeout_status, + "openbox.sandbox.cleanup_status": cleanup_status, + "openbox.sandbox.directive": result.directive.value, + "openbox.sandbox.error_code": error_code, + "openbox.sandbox.stdout_bytes": len(stdout), + "openbox.sandbox.stderr_bytes": len(stderr), + "openbox.sandbox.stdout_sha256": hashlib.sha256(stdout).hexdigest(), + "openbox.sandbox.stderr_sha256": hashlib.sha256(stderr).hexdigest(), + } + if execution is not None and execution.sandbox_id is not None: + attributes["openbox.sandbox.id"] = _safe_evidence_identity(execution.sandbox_id) + if exit_code is not None: + attributes["openbox.sandbox.exit_code"] = exit_code + + span: dict[str, Any] = { + "span_id": span_id, + "trace_id": trace_id, + "parent_span_id": None, + "name": "openbox.sandbox_execution", + "kind": "INTERNAL", + "stage": "completed", + "start_time": started_ns, + "end_time": completed_ns, + "duration_ns": completed_ns - started_ns, + "attributes": attributes, + "status": { + "code": "UNSET" if outcome == "success" else "ERROR", + "description": None, + }, + "events": [], + "hook_type": "sandbox_execution", + "error": None, + } + return { + "source": "workflow-telemetry", + "event_type": "ActivityStarted", + "workflow_id": command.workflow_id, + "run_id": command.run_id, + "workflow_type": command.workflow_type, + "task_queue": command.task_queue, + "timestamp": _iso8601_ns(completed_ns), + "activity_id": command.activity_id, + "activity_type": "openbox_governed_command", + "attempt": command.attempt, + "profile_id": command.profile_id, + "hook_trigger": True, + "span_count": 1, + "spans": [span], + } + + +def _activity_completed( + command: GovernedCommand, + result: DispatchResult, + now: datetime, + *, + duration_ns: int, +) -> dict[str, Any]: + return { + "source": "workflow-telemetry", + "event_type": "ActivityCompleted", + "workflow_id": command.workflow_id, + "run_id": command.run_id, + "workflow_type": command.workflow_type, + "task_queue": command.task_queue, + "timestamp": _iso8601(now), + "activity_id": command.activity_id, + "activity_type": "openbox_governed_command", + "attempt": command.attempt, + "profile_id": command.profile_id, + "status": "completed", + "duration_ms": duration_ns / 1_000_000, + "activity_output": { + "disposition": result.disposition.value, + "cleanup_status": result.execution.cleanup_status.value + if result.execution is not None + else CleanupStatus.NOT_NEEDED.value, + }, + } + + +def _is_normal_sandbox_completion(result: DispatchResult) -> bool: + return ( + result.disposition is Disposition.EXECUTED_IN_SANDBOX + and result.error is None + and result.execution is not None + and result.execution.cleanup_status is CleanupStatus.DELETED + ) + + +def _completion_failure_result( + result: DispatchResult, + initial_decision: GovernanceDecision, + error_code: DispatchErrorCode, + *, + directive: Directive = Directive.CONTINUE, +) -> DispatchResult: + return DispatchResult( + disposition=result.disposition, + directive=directive, + execution=result.execution, + error=NormalizedDispatchError(error_code), + _governance=initial_decision.raw, + ) + + +def _completed_stop_result( + result: DispatchResult, + initial_decision: GovernanceDecision, + completed_decision: GovernanceDecision, +) -> DispatchResult: + if completed_decision.verdict == "halt": + return _completion_failure_result( + result, + initial_decision, + DispatchErrorCode.HALTED, + directive=Directive.HALT, + ) + return _completion_failure_result(result, initial_decision, DispatchErrorCode.BLOCKED) + + +def _sandbox_span_ids( + *, + workflow_id: str, + run_id: str, + activity_id: str, + attempt: int, +) -> tuple[str, str]: + """Derive resend-stable sandbox span identity without relaxing dispatch admission.""" + identity = "|".join( + (workflow_id, run_id, activity_id, str(attempt), "sandbox_execution") + ).encode("utf-8") + return ( + hashlib.sha256(identity).hexdigest()[:16], + hashlib.sha256(b"trace|" + identity).hexdigest()[:32], + ) + + +def _safe_evidence_identity(value: str) -> str: + """Keep identity metadata bounded and incapable of carrying arbitrary bodies.""" + return value if _SAFE_EVIDENCE_IDENTITY.fullmatch(value) is not None else "unknown" + + +def _image_digest(template: str) -> str: + marker = "@sha256:" + before, separator, digest = template.rpartition(marker) + if ( + not before + or separator != marker + or len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + ): + raise GovernanceProtocolError() + return "sha256:" + digest + + +def _epoch_ns(value: datetime) -> int: + return int(value.astimezone(timezone.utc).timestamp() * 1_000_000_000) + + +def _iso8601_ns(value: int) -> str: + return _iso8601(datetime.fromtimestamp(value / 1_000_000_000, timezone.utc)) + + +def _iso8601(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def _duration_ms(started: float, finished: float) -> int: + return max(0, int((finished - started) * 1000)) + + +def _timeout_status(value: str) -> TimeoutStatus: + return { + "not_observed": TimeoutStatus.NOT_OBSERVED, + "confirmed": TimeoutStatus.CONFIRMED_TIMEOUT, + "possible": TimeoutStatus.POSSIBLE_TIMEOUT, + }[value] diff --git a/openbox_sandbox/dispatcher/errors.py b/openbox_sandbox/dispatcher/errors.py new file mode 100644 index 0000000..0af025c --- /dev/null +++ b/openbox_sandbox/dispatcher/errors.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class DispatchErrorCode(str, Enum): + INVALID_COMMAND = "invalid_command" + PROFILE_REJECTED = "profile_rejected" + GOVERNANCE_TRANSPORT = "governance_transport" + GOVERNANCE_PROTOCOL = "governance_protocol" + GOVERNANCE_FALLBACK = "governance_fallback" + UNSUPPORTED_CONSTRAINT = "unsupported_constraint" + REMEDIATION_UNSUPPORTED = "remediation_unsupported" + APPROVAL_REQUIRED = "approval_required" + BLOCKED = "blocked" + HALTED = "halted" + SANDBOX_DISABLED = "sandbox_disabled" + SANDBOX_CREATE = "sandbox_create_failed" + SANDBOX_READINESS = "sandbox_readiness_failed" + SANDBOX_EXEC_NOT_DISPATCHED = "sandbox_exec_not_dispatched" + SANDBOX_EXEC_INDETERMINATE = "sandbox_exec_indeterminate" + SANDBOX_PROTOCOL = "sandbox_protocol_failed" + HOST_EXEC_INDETERMINATE = "host_exec_indeterminate" + HOST_OUTPUT_LIMIT = "host_output_limit" + CANCELLED = "cancelled" + + +@dataclass(frozen=True, slots=True) +class NormalizedDispatchError: + code: DispatchErrorCode + + def to_wire(self) -> dict[str, str]: + return {"code": self.code.value} + + +class DispatcherValidationError(ValueError): + def __init__(self) -> None: + super().__init__("governed command rejected") + + +class ProfileValidationError(ValueError): + def __init__(self) -> None: + super().__init__("command profile bundle rejected") + + +class GovernanceProtocolError(ValueError): + def __init__(self) -> None: + super().__init__("governance protocol rejected") + + +class GovernanceTransportError(RuntimeError): + def __init__(self) -> None: + super().__init__("governance transport failed") diff --git a/openbox_sandbox/dispatcher/governance.py b/openbox_sandbox/dispatcher/governance.py new file mode 100644 index 0000000..1aa391c --- /dev/null +++ b/openbox_sandbox/dispatcher/governance.py @@ -0,0 +1,506 @@ +from __future__ import annotations + +import asyncio +import base64 +import copy +import hashlib +import json +import math +import re +import ssl +import urllib.error +import urllib.parse +import urllib.request +import uuid +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Mapping, Protocol + +from .errors import GovernanceProtocolError, GovernanceTransportError + +_MAX_REQUEST_BYTES = 2 * 1024 * 1024 +_MAX_RESPONSE_BYTES = 2 * 1024 * 1024 +_ENDPOINT = "/api/v1/governance/evaluate" +_AIP_HEADERS = { + "x-openbox-agent-did": "X-OpenBox-Agent-DID", + "x-openbox-agent-timestamp": "X-OpenBox-Agent-Timestamp", + "x-openbox-agent-nonce": "X-OpenBox-Agent-Nonce", + "x-openbox-agent-signature": "X-OpenBox-Agent-Signature", + "x-openbox-body-sha256": "X-OpenBox-Body-SHA256", +} +_NONCE = re.compile(r"[A-Za-z0-9_-]{16,128}\Z") +_VERDICT_ACTION = { + "allow": "allow", + "constrain": "constrain", + "require_approval": "require_approval", + "block": "block", + "halt": "halt", +} +_ALLOWED_RESPONSE_FIELDS = { + "governance_event_id", + "verdict", + "risk_score", + "action", + "fallback_used", + "trust_tier", + "behavioral_violations", + "approval_id", + "constraints", + "approval_expiration_time", + "reason", + "policy_id", + "metadata", + "guardrails_result", + "guardrail_findings", + "age_result", +} +_REQUIRED_RESPONSE_FIELDS = { + "governance_event_id", + "verdict", + "risk_score", + "action", + "fallback_used", +} + + +class GovernanceRequestSigner(Protocol): + """Dependency-free signer seam for optional AIP-authenticated Core calls.""" + + @property + def agent_did(self) -> str: ... + + def sign_headers(self, method: str, path: str, body: bytes) -> Mapping[str, str]: ... + + +def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise GovernanceProtocolError() + result[key] = value + return result + + +def _reject_constant(_: str) -> None: + raise GovernanceProtocolError() + + +def _strict_loads(body: bytes) -> dict[str, Any]: + try: + value = json.loads( + body, + object_pairs_hook=_strict_object, + parse_constant=_reject_constant, + ) + except (json.JSONDecodeError, UnicodeDecodeError, GovernanceProtocolError) as error: + raise GovernanceProtocolError() from error + if not isinstance(value, dict): + raise GovernanceProtocolError() + return value + + +def _json_bytes(value: object) -> bytes: + try: + body = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + except (TypeError, ValueError) as error: + raise GovernanceProtocolError() from error + if not body or len(body) > _MAX_REQUEST_BYTES: + raise GovernanceProtocolError() + return body + + +@dataclass(frozen=True, slots=True, repr=False) +class GovernanceClientConfig: + base_url: str + bearer_token: str + sdk_version: str + ca_path: Path | None = None + timeout_seconds: float = 10.0 + request_signer: GovernanceRequestSigner | None = None + + def __post_init__(self) -> None: + parsed = urllib.parse.urlsplit(self.base_url) + local_http = parsed.scheme == "http" and parsed.hostname in { + "127.0.0.1", + "localhost", + } + if ( + (parsed.scheme != "https" and not local_http) + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path not in {"", "/"} + or (local_http and self.ca_path is not None) + or not self.bearer_token + or "\r" in self.bearer_token + or "\n" in self.bearer_token + or not self.sdk_version + or "\r" in self.sdk_version + or "\n" in self.sdk_version + or isinstance(self.timeout_seconds, bool) + or not isinstance(self.timeout_seconds, (int, float)) + or not 0 < self.timeout_seconds <= 30 + or ( + self.request_signer is not None + and ( + not isinstance(getattr(self.request_signer, "agent_did", None), str) + or not getattr(self.request_signer, "agent_did", "") + or not callable(getattr(self.request_signer, "sign_headers", None)) + ) + ) + ): + raise GovernanceProtocolError() + + @property + def endpoint(self) -> str: + return self.base_url.rstrip("/") + _ENDPOINT + + def __repr__(self) -> str: + return ( + f"GovernanceClientConfig(base_url={self.base_url!r}, " + "bearer_token=, ca_path=, " + f"sdk_version={self.sdk_version!r}, timeout_seconds={self.timeout_seconds}, " + f"request_signer={'configured' if self.request_signer else 'disabled'})" + ) + + +@dataclass(frozen=True, slots=True, repr=False) +class GovernanceDecision: + verdict: str + risk_score: float + action: str + fallback_used: bool + constraints: tuple[str, ...] | None + has_guardrails_result: bool + _raw: Mapping[str, Any] + + @property + def raw(self) -> dict[str, Any]: + return copy.deepcopy(dict(self._raw)) + + def __repr__(self) -> str: + return ( + f"GovernanceDecision(verdict={self.verdict!r}, " + f"risk_score={self.risk_score}, action={self.action!r}, " + f"fallback_used={self.fallback_used}, response=)" + ) + + @classmethod + def parse(cls, value: Mapping[str, Any] | bytes) -> GovernanceDecision: + if isinstance(value, bytes): + raw = _strict_loads(value) + else: + raw = _strict_loads(_json_bytes(value)) + fields = set(raw) + if not _REQUIRED_RESPONSE_FIELDS <= fields or not fields <= _ALLOWED_RESPONSE_FIELDS: + raise GovernanceProtocolError() + event_id = raw["governance_event_id"] + verdict = raw["verdict"] + risk = raw["risk_score"] + action = raw["action"] + fallback = raw["fallback_used"] + try: + parsed_uuid = uuid.UUID(event_id) if isinstance(event_id, str) else None + except ValueError as error: + raise GovernanceProtocolError() from error + if ( + parsed_uuid is None + or str(parsed_uuid) != event_id + or not isinstance(verdict, str) + or verdict not in _VERDICT_ACTION + or not isinstance(action, str) + or action != _VERDICT_ACTION[verdict] + or isinstance(risk, bool) + or not isinstance(risk, (int, float)) + or not math.isfinite(risk) + or not 0 <= risk <= 1 + or type(fallback) is not bool + ): + raise GovernanceProtocolError() + _validate_optional(raw) + constraints = raw.get("constraints") + return cls( + verdict=verdict, + risk_score=float(risk), + action=action, + fallback_used=fallback, + constraints=None if constraints is None else tuple(constraints), + has_guardrails_result="guardrails_result" in raw, + _raw=copy.deepcopy(raw), + ) + + +def _validate_optional(raw: Mapping[str, Any]) -> None: + if "trust_tier" in raw: + value = raw["trust_tier"] + if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 4: + raise GovernanceProtocolError() + if "behavioral_violations" in raw and ( + not isinstance(raw["behavioral_violations"], list) + or not all(isinstance(item, str) for item in raw["behavioral_violations"]) + ): + raise GovernanceProtocolError() + for name in ("approval_id", "approval_expiration_time", "reason", "policy_id"): + if name in raw and not isinstance(raw[name], str): + raise GovernanceProtocolError() + if "constraints" in raw and ( + not isinstance(raw["constraints"], list) + or not all(isinstance(item, str) for item in raw["constraints"]) + ): + raise GovernanceProtocolError() + if "metadata" in raw and not isinstance(raw["metadata"], dict): + raise GovernanceProtocolError() + if "guardrails_result" in raw: + _validate_guardrails_result(raw["guardrails_result"]) + if "age_result" in raw: + _validate_age_result(raw["age_result"]) + + +def _exact_object(value: object, fields: set[str]) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + raise GovernanceProtocolError() + return value + + +def _string(value: object) -> None: + if not isinstance(value, str): + raise GovernanceProtocolError() + + +def _integer(value: object) -> None: + if isinstance(value, bool) or not isinstance(value, int): + raise GovernanceProtocolError() + + +def _finite_number(value: object) -> None: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise GovernanceProtocolError() + + +def _validate_guardrails_result(value: object) -> None: + result = _exact_object( + value, + {"input_type", "redacted_input", "raw_logs", "validation_passed", "reasons", "results"}, + ) + _string(result["input_type"]) + if not isinstance(result["raw_logs"], dict) or type(result["validation_passed"]) is not bool: + raise GovernanceProtocolError() + reasons = result["reasons"] + if not isinstance(reasons, list): + raise GovernanceProtocolError() + for raw_reason in reasons: + reason = _exact_object(raw_reason, {"type", "field", "reason"}) + for item in reason.values(): + _string(item) + results = result["results"] + if not isinstance(results, list): + raise GovernanceProtocolError() + for raw_result in results: + guardrail = _exact_object(raw_result, {"guardrail_type", "results"}) + _string(guardrail["guardrail_type"]) + if not isinstance(guardrail["results"], list): + raise GovernanceProtocolError() + for raw_field in guardrail["results"]: + field = _exact_object(raw_field, {"field", "order", "status", "reason"}) + _string(field["field"]) + _integer(field["order"]) + _string(field["status"]) + if field["reason"] is not None: + _string(field["reason"]) + + +def _validate_age_result(value: object) -> None: + result = _exact_object( + value, + { + "allowed", + "verdict", + "goal_alignment_checked", + "goal_drifted", + "fallback_used", + "final_trust_score", + "span_results", + "total_spans", + "violations_count", + "response_time_ms", + } + | ({"reason"} if isinstance(value, dict) and "reason" in value else set()), + ) + if ( + type(result["allowed"]) is not bool + or result["verdict"] not in _VERDICT_ACTION + or type(result["goal_alignment_checked"]) is not bool + or type(result["goal_drifted"]) is not bool + or type(result["fallback_used"]) is not bool + ): + raise GovernanceProtocolError() + if "reason" in result: + _string(result["reason"]) + if result["final_trust_score"] is not None: + _validate_trust_score(result["final_trust_score"]) + spans = result["span_results"] + if spans is not None and not isinstance(spans, list): + raise GovernanceProtocolError() + for raw_span in spans or []: + span = _exact_object( + raw_span, + { + "span_id", + "semantic_type", + "behavioral_result", + "alignment_result", + "trust_score_after", + "timestamp", + }, + ) + for name in ("span_id", "semantic_type", "timestamp"): + _string(span[name]) + if span["alignment_result"] is not None: + alignment = _exact_object(span["alignment_result"], {"is_aligned", "score"}) + if type(alignment["is_aligned"]) is not bool: + raise GovernanceProtocolError() + _finite_number(alignment["score"]) + if span["trust_score_after"] is not None: + _validate_trust_score(span["trust_score_after"]) + for name in ("total_spans", "violations_count", "response_time_ms"): + _integer(result[name]) + + +def _validate_trust_score(value: object) -> None: + score = _exact_object( + value, + { + "trust_score", + "trust_tier", + "behavioral_compliance", + "alignment_consistency", + "aivss_baseline", + }, + ) + for name in ( + "trust_score", + "behavioral_compliance", + "alignment_consistency", + "aivss_baseline", + ): + _finite_number(score[name]) + _integer(score["trust_tier"]) + + +def _validated_signer_headers(signer: GovernanceRequestSigner, body: bytes) -> dict[str, str]: + try: + supplied = signer.sign_headers("POST", _ENDPOINT, body) + values = dict(supplied) + except Exception as error: + raise GovernanceProtocolError() from error + if any(not isinstance(name, str) for name in values) or any( + not isinstance(value, str) or not value for value in values.values() + ): + raise GovernanceProtocolError() + normalized = {name.lower(): value for name, value in values.items()} + if len(normalized) != len(values) or set(normalized) != set(_AIP_HEADERS): + raise GovernanceProtocolError() + for value in normalized.values(): + try: + encoded = value.encode("ascii") + except UnicodeEncodeError as error: + raise GovernanceProtocolError() from error + if len(encoded) > 8192 or "\r" in value or "\n" in value: + raise GovernanceProtocolError() + did = normalized["x-openbox-agent-did"] + if did != signer.agent_did or not did.startswith("did:aip:"): + raise GovernanceProtocolError() + try: + parsed_did = uuid.UUID(did[len("did:aip:") :]) + except ValueError as error: + raise GovernanceProtocolError() from error + if str(parsed_did) != did[len("did:aip:") :]: + raise GovernanceProtocolError() + timestamp = normalized["x-openbox-agent-timestamp"] + try: + parsed_timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + except ValueError as error: + raise GovernanceProtocolError() from error + if parsed_timestamp.tzinfo is None: + raise GovernanceProtocolError() + if _NONCE.fullmatch(normalized["x-openbox-agent-nonce"]) is None: + raise GovernanceProtocolError() + expected_hash = hashlib.sha256(body).hexdigest() + if normalized["x-openbox-body-sha256"] != expected_hash: + raise GovernanceProtocolError() + try: + signature = base64.b64decode(normalized["x-openbox-agent-signature"], validate=True) + except ValueError as error: + raise GovernanceProtocolError() from error + if len(signature) != 64: + raise GovernanceProtocolError() + return {canonical: normalized[lowered] for lowered, canonical in _AIP_HEADERS.items()} + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request( + self, request: Any, file: Any, code: int, message: str, headers: Any, new_url: str + ) -> None: + return None + + +class GovernanceClient: + def __init__(self, config: GovernanceClientConfig) -> None: + self._config = config + self._ssl = ssl.create_default_context( + cafile=str(config.ca_path) if config.ca_path else None + ) + self._ssl.minimum_version = ssl.TLSVersion.TLSv1_2 + self._opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + urllib.request.HTTPSHandler(context=self._ssl), + _NoRedirect(), + ) + + async def evaluate(self, event: Mapping[str, Any]) -> GovernanceDecision: + body = _json_bytes(event) + response = await asyncio.to_thread(self._post, body) + return GovernanceDecision.parse(response) + + def _post(self, body: bytes) -> bytes: + headers = { + "Authorization": "Bearer " + self._config.bearer_token, + "Content-Type": "application/json", + "X-OpenBox-SDK-Version": self._config.sdk_version, + } + if self._config.request_signer is not None: + headers.update(_validated_signer_headers(self._config.request_signer, body)) + request = urllib.request.Request( + self._config.endpoint, + data=body, + method="POST", + headers=headers, + ) + try: + with self._opener.open(request, timeout=self._config.timeout_seconds) as response: + if response.status != 200: + raise GovernanceTransportError() + content_length = response.headers.get("Content-Length") + if content_length is not None: + try: + length = int(content_length) + except ValueError as error: + raise GovernanceProtocolError() from error + if not 1 <= length <= _MAX_RESPONSE_BYTES: + raise GovernanceProtocolError() + body = response.read(_MAX_RESPONSE_BYTES + 1) + except GovernanceProtocolError: + raise + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError) as error: + raise GovernanceTransportError() from error + if not body or len(body) > _MAX_RESPONSE_BYTES: + raise GovernanceProtocolError() + return body diff --git a/openbox_sandbox/dispatcher/profiles.py b/openbox_sandbox/dispatcher/profiles.py new file mode 100644 index 0000000..afaae62 --- /dev/null +++ b/openbox_sandbox/dispatcher/profiles.py @@ -0,0 +1,384 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from types import MappingProxyType +from typing import Any, Mapping, Sequence + +from .errors import ProfileValidationError + +_MAX_DOCUMENT_BYTES = 1024 * 1024 +_HEX_SHA256 = re.compile(r"[0-9a-f]{64}\Z") +_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z") + + +def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise ProfileValidationError() + value[key] = item + return value + + +def _reject_constant(_: str) -> None: + raise ProfileValidationError() + + +def _timestamp(value: object) -> datetime: + if not isinstance(value, str) or not value.endswith("Z"): + raise ProfileValidationError() + try: + parsed = datetime.fromisoformat(value[:-1] + "+00:00") + except ValueError: + raise ProfileValidationError() from None + if parsed.tzinfo is None: + raise ProfileValidationError() + return parsed.astimezone(timezone.utc) + + +def _canonical(value: object) -> bytes: + try: + return json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + except (TypeError, ValueError): + raise ProfileValidationError() from None + + +def _plain_object(value: object, keys: set[str]) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != keys: + raise ProfileValidationError() + return value + + +@dataclass(frozen=True, slots=True) +class ArgumentRule: + kind: str + literal: str | None = None + choices: tuple[str, ...] = () + minimum: int | None = None + maximum: int | None = None + max_bytes: int | None = None + + @classmethod + def from_wire(cls, value: object) -> ArgumentRule: + if not isinstance(value, dict) or not isinstance(value.get("kind"), str): + raise ProfileValidationError() + kind = value["kind"] + if kind == "literal": + item = _plain_object(value, {"kind", "value"})["value"] + if not isinstance(item, str) or len(item.encode("utf-8")) > 4096: + raise ProfileValidationError() + return cls(kind=kind, literal=item) + if kind == "enum": + items = _plain_object(value, {"kind", "values"})["values"] + if ( + not isinstance(items, list) + or not items + or len(items) > 128 + or not all(isinstance(item, str) for item in items) + or len(set(items)) != len(items) + or any(len(item.encode("utf-8")) > 4096 for item in items) + ): + raise ProfileValidationError() + return cls(kind=kind, choices=tuple(items)) + if kind == "decimal": + item = _plain_object(value, {"kind", "minimum", "maximum"}) + minimum, maximum = item["minimum"], item["maximum"] + if ( + isinstance(minimum, bool) + or isinstance(maximum, bool) + or not isinstance(minimum, int) + or not isinstance(maximum, int) + or minimum > maximum + ): + raise ProfileValidationError() + return cls(kind=kind, minimum=minimum, maximum=maximum) + if kind == "identifier": + item = _plain_object(value, {"kind", "max_bytes"})["max_bytes"] + if isinstance(item, bool) or not isinstance(item, int) or not 1 <= item <= 4096: + raise ProfileValidationError() + return cls(kind=kind, max_bytes=item) + raise ProfileValidationError() + + def accepts(self, value: str) -> bool: + if self.kind == "literal": + return value == self.literal + if self.kind == "enum": + return value in self.choices + if self.kind == "decimal": + try: + parsed = int(value, 10) + except ValueError: + return False + return str(parsed) == value and self.minimum <= parsed <= self.maximum # type: ignore[operator] + if self.kind == "identifier": + return ( + len(value.encode("utf-8")) <= self.max_bytes # type: ignore[operator] + and _IDENTIFIER.fullmatch(value) is not None + ) + return False + + +@dataclass(frozen=True, slots=True, repr=False) +class CommandProfile: + profile_id: str + executable: str + arguments: tuple[ArgumentRule, ...] + sensitive: bool + free_form: bool + + def __repr__(self) -> str: + return ( + f"CommandProfile(profile_id={self.profile_id!r}, " + f"executable={self.executable!r}, arguments={len(self.arguments)}, " + f"sensitive={self.sensitive}, free_form={self.free_form})" + ) + + def admits(self, argv: Sequence[str]) -> bool: + return ( + not self.sensitive + and not self.free_form + and len(argv) == len(self.arguments) + 1 + and argv[0] == self.executable + and all(rule.accepts(value) for rule, value in zip(self.arguments, argv[1:])) + ) + + +@dataclass(frozen=True, slots=True, repr=False, init=False) +class CommandProfileBundle: + schema_version: int + bundle_version: str + key_id: str + issued_at: datetime + expires_at: datetime + fingerprint: str + _profiles: Mapping[str, CommandProfile] + + def __init__(self) -> None: + raise TypeError("use load() or from_trusted() to construct command profiles") + + @classmethod + def from_trusted( + cls, + *, + bundle_version: str, + issued_at: datetime, + expires_at: datetime, + profiles: Sequence[Mapping[str, Any]], + now: datetime, + ) -> CommandProfileBundle: + """Build an immutable bundle from profiles owned by this process.""" + return _trusted_bundle( + cls, + bundle_version=bundle_version, + issued_at=issued_at, + expires_at=expires_at, + profiles=profiles, + now=now, + ) + + @classmethod + def load( + cls, + document: bytes | str, + *, + secret: bytes, + expected_key_id: str, + now: datetime | None = None, + ) -> CommandProfileBundle: + if not isinstance(secret, bytes) or len(secret) < 32 or not expected_key_id: + raise ProfileValidationError() + encoded = document.encode("utf-8") if isinstance(document, str) else document + if not isinstance(encoded, bytes) or not encoded or len(encoded) > _MAX_DOCUMENT_BYTES: + raise ProfileValidationError() + try: + root = json.loads( + encoded, + object_pairs_hook=_strict_object, + parse_constant=_reject_constant, + ) + except (json.JSONDecodeError, UnicodeDecodeError, ProfileValidationError): + raise ProfileValidationError() from None + root = _plain_object(root, {"payload", "signature"}) + payload = _plain_object( + root["payload"], + { + "schema_version", + "bundle_version", + "key_id", + "issued_at", + "expires_at", + "profiles", + }, + ) + signature = _plain_object(root["signature"], {"algorithm", "key_id", "value"}) + if ( + signature["algorithm"] != "hmac-sha256" + or signature["key_id"] != expected_key_id + or payload["key_id"] != expected_key_id + or not isinstance(signature["value"], str) + or _HEX_SHA256.fullmatch(signature["value"]) is None + ): + raise ProfileValidationError() + canonical = _canonical(payload) + expected = hmac.new(secret, canonical, hashlib.sha256).hexdigest() + if not hmac.compare_digest(signature["value"], expected): + raise ProfileValidationError() + if ( + type(payload["schema_version"]) is not int + or payload["schema_version"] != 1 + or not isinstance(payload["bundle_version"], str) + or not payload["bundle_version"] + ): + raise ProfileValidationError() + issued_at = _timestamp(payload["issued_at"]) + expires_at = _timestamp(payload["expires_at"]) + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + if issued_at > current or expires_at <= current or issued_at >= expires_at: + raise ProfileValidationError() + profile_values = payload["profiles"] + if not isinstance(profile_values, list) or not profile_values or len(profile_values) > 1024: + raise ProfileValidationError() + profiles: dict[str, CommandProfile] = {} + for raw_profile in profile_values: + profile = _parse_profile(raw_profile) + if profile.profile_id in profiles: + raise ProfileValidationError() + profiles[profile.profile_id] = profile + instance = object.__new__(cls) + object.__setattr__(instance, "schema_version", 1) + object.__setattr__(instance, "bundle_version", payload["bundle_version"]) + object.__setattr__(instance, "key_id", expected_key_id) + object.__setattr__(instance, "issued_at", issued_at) + object.__setattr__(instance, "expires_at", expires_at) + object.__setattr__(instance, "fingerprint", hashlib.sha256(canonical).hexdigest()) + object.__setattr__(instance, "_profiles", MappingProxyType(profiles)) + return instance + + def __repr__(self) -> str: + return ( + f"CommandProfileBundle(schema_version={self.schema_version}, " + f"bundle_version={self.bundle_version!r}, key_id={self.key_id!r}, " + f"profiles={len(self._profiles)}, fingerprint={self.fingerprint!r})" + ) + + @property + def profile_ids(self) -> tuple[str, ...]: + """Return the validated profile identifiers in stable order.""" + return tuple(sorted(self._profiles)) + + def admits(self, profile_id: str, argv: Sequence[str], *, now: datetime) -> bool: + current = now.astimezone(timezone.utc) + profile = self._profiles.get(profile_id) + return ( + self.issued_at <= current < self.expires_at + and profile is not None + and profile.admits(argv) + ) + + +def _parse_profile(value: object) -> CommandProfile: + profile = _plain_object( + value, + {"id", "executable", "arguments", "sensitive", "free_form"}, + ) + profile_id = profile["id"] + executable = profile["executable"] + arguments = profile["arguments"] + if ( + not isinstance(profile_id, str) + or _IDENTIFIER.fullmatch(profile_id) is None + or not isinstance(executable, str) + or not executable.startswith("/") + or "\x00" in executable + or len(executable.encode("utf-8")) > 4096 + or not isinstance(arguments, list) + or len(arguments) > 128 + or type(profile["sensitive"]) is not bool + or type(profile["free_form"]) is not bool + ): + raise ProfileValidationError() + return CommandProfile( + profile_id=profile_id, + executable=executable, + arguments=tuple(ArgumentRule.from_wire(item) for item in arguments), + sensitive=profile["sensitive"], + free_form=profile["free_form"], + ) + + +def _trusted_bundle( + bundle_type: type[CommandProfileBundle], + *, + bundle_version: str, + issued_at: datetime, + expires_at: datetime, + profiles: Sequence[Mapping[str, Any]], + now: datetime, +) -> CommandProfileBundle: + if ( + not isinstance(bundle_version, str) + or not bundle_version + or not isinstance(issued_at, datetime) + or issued_at.tzinfo is None + or not isinstance(expires_at, datetime) + or expires_at.tzinfo is None + or not isinstance(now, datetime) + or now.tzinfo is None + or isinstance(profiles, (str, bytes)) + or not isinstance(profiles, Sequence) + or not profiles + or len(profiles) > 1024 + ): + raise ProfileValidationError() + issued = issued_at.astimezone(timezone.utc) + expires = expires_at.astimezone(timezone.utc) + current = now.astimezone(timezone.utc) + if issued > current or expires <= current or issued >= expires: + raise ProfileValidationError() + parsed: dict[str, CommandProfile] = {} + profile_values = list(profiles) + for raw_profile in profile_values: + profile = _parse_profile(raw_profile) + if profile.profile_id in parsed or profile.sensitive or profile.free_form: + raise ProfileValidationError() + parsed[profile.profile_id] = profile + identity = { + "schema_version": 1, + "bundle_version": bundle_version, + "issued_at": issued.isoformat(), + "expires_at": expires.isoformat(), + "profiles": profile_values, + } + instance = object.__new__(bundle_type) + object.__setattr__(instance, "schema_version", 1) + object.__setattr__(instance, "bundle_version", bundle_version) + object.__setattr__(instance, "key_id", "") + object.__setattr__(instance, "issued_at", issued) + object.__setattr__(instance, "expires_at", expires) + object.__setattr__(instance, "fingerprint", hashlib.sha256(_canonical(identity)).hexdigest()) + object.__setattr__(instance, "_profiles", MappingProxyType(parsed)) + return instance + + +def _sign_for_test(payload: Mapping[str, Any], secret: bytes, key_id: str) -> bytes: + canonical = _canonical(payload) + root = { + "payload": payload, + "signature": { + "algorithm": "hmac-sha256", + "key_id": key_id, + "value": hmac.new(secret, canonical, hashlib.sha256).hexdigest(), + }, + } + return _canonical(root) diff --git a/openbox_sandbox/dispatcher/py.typed b/openbox_sandbox/dispatcher/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/openbox_sandbox/dispatcher/result.py b/openbox_sandbox/dispatcher/result.py new file mode 100644 index 0000000..e40448c --- /dev/null +++ b/openbox_sandbox/dispatcher/result.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import base64 +import copy +from dataclasses import dataclass +from enum import Enum +from typing import Any, Mapping + +from .errors import NormalizedDispatchError + + +class Disposition(str, Enum): + EXECUTED_ON_HOST = "executed_on_host" + EXECUTED_IN_SANDBOX = "executed_in_sandbox" + NOT_EXECUTED = "not_executed" + EXECUTION_INDETERMINATE = "execution_indeterminate" + + +class Directive(str, Enum): + CONTINUE = "continue" + HALT = "halt" + + +class TimeoutStatus(str, Enum): + NOT_OBSERVED = "not_observed" + CONFIRMED_TIMEOUT = "confirmed_timeout" + POSSIBLE_TIMEOUT = "possible_timeout" + UNKNOWN = "unknown" + + +class CleanupStatus(str, Enum): + NOT_NEEDED = "not_needed" + DELETED = "deleted" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class CleanupReconciliationResult: + attempted: int + deleted: int + remaining: int + + +@dataclass(frozen=True, slots=True, repr=False) +class ExecutionMetadata: + sandbox_id: str | None + exit_code: int | None + stdout: bytes + stderr: bytes + timeout_status: TimeoutStatus + cleanup_status: CleanupStatus + + def __post_init__(self) -> None: + if not isinstance(self.stdout, bytes) or not isinstance(self.stderr, bytes): + raise TypeError("execution output must be bytes") + + def __repr__(self) -> str: + return ( + "ExecutionMetadata(" + f"sandbox_id={self.sandbox_id!r}, exit_code={self.exit_code!r}, " + f"stdout_bytes={len(self.stdout)}, stderr_bytes={len(self.stderr)}, " + f"timeout_status={self.timeout_status.value!r}, " + f"cleanup_status={self.cleanup_status.value!r}, output=)" + ) + + def to_wire(self) -> dict[str, Any]: + value: dict[str, Any] = { + "exit_code": self.exit_code, + "stdout_base64": base64.b64encode(self.stdout).decode("ascii"), + "stderr_base64": base64.b64encode(self.stderr).decode("ascii"), + "timeout_status": self.timeout_status.value, + "cleanup_status": self.cleanup_status.value, + } + if self.sandbox_id is not None: + value["sandbox_id"] = self.sandbox_id + value["sandbox_name"] = self.sandbox_id + return value + + +@dataclass(frozen=True, slots=True, repr=False) +class DispatchResult: + disposition: Disposition + directive: Directive + execution: ExecutionMetadata | None + error: NormalizedDispatchError | None + _governance: Mapping[str, Any] | None + + @property + def governance(self) -> dict[str, Any] | None: + return None if self._governance is None else copy.deepcopy(dict(self._governance)) + + def __repr__(self) -> str: + return ( + f"DispatchResult(disposition={self.disposition.value!r}, " + f"directive={self.directive.value!r}, execution={self.execution!r}, " + f"error={self.error!r}, governance=)" + ) + + def to_wire(self) -> dict[str, Any]: + return { + "governance": self.governance, + "disposition": self.disposition.value, + "directive": self.directive.value, + "execution": None if self.execution is None else self.execution.to_wire(), + "error": None if self.error is None else self.error.to_wire(), + } diff --git a/openbox_sandbox/dispatcher/telemetry.py b/openbox_sandbox/dispatcher/telemetry.py new file mode 100644 index 0000000..0e98961 --- /dev/null +++ b/openbox_sandbox/dispatcher/telemetry.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import asyncio +import json +import os +import stat +import tempfile +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, AsyncIterator, Protocol + +try: + import fcntl +except ImportError: # pragma: no cover - fail-closed portability boundary + fcntl = None # type: ignore[assignment] + + +@dataclass(frozen=True, slots=True) +class TelemetryEvent: + event: str + workflow_id: str + run_id: str + activity_id: str + attempt: int = 1 + governance_event_id: str | None = None + governance_policy_id: str | None = None + verdict: str | None = None + action: str | None = None + fallback_used: bool | None = None + constraints: tuple[str, ...] | None = None + disposition: str | None = None + directive: str | None = None + sandbox_id: str | None = None + lifecycle_phase: str | None = None + timeout_seconds: int | None = None + timeout_status: str | None = None + exit_code: int | None = None + stdout_bytes: int | None = None + stderr_bytes: int | None = None + duration_ms: int | None = None + error_code: str | None = None + cleanup_status: str | None = None + runtime_contract_version: int | None = None + policy_id: str | None = None + policy_version: int | None = None + template_digest: str | None = None + profile_bundle_version: str | None = None + + def to_wire(self) -> dict[str, Any]: + return { + key: value + for key, value in { + field.name: getattr(self, field.name) + for field in self.__dataclass_fields__.values() + }.items() + if value is not None + } + + +class TelemetrySink(Protocol): + async def emit(self, event: TelemetryEvent) -> None: ... + + +class NullTelemetrySink: + async def emit(self, event: TelemetryEvent) -> None: + del event + + +@dataclass(slots=True) +class InMemoryTelemetrySink: + events: list[TelemetryEvent] = field(default_factory=list) + + async def emit(self, event: TelemetryEvent) -> None: + self.events.append(event) + + +@dataclass(frozen=True, slots=True, repr=False) +class CleanupBacklog: + directory: Path + compatibility_id: str + + def __repr__(self) -> str: + return f"CleanupBacklog(directory=, compatibility_id={self.compatibility_id!r})" + + async def record(self, request_id: str, state: str, recorded_at: str) -> None: + await asyncio.to_thread(self._record, request_id, state, recorded_at) + + async def remove(self, request_id: str) -> None: + await asyncio.to_thread(self._remove, request_id) + + async def request_ids(self) -> tuple[str, ...]: + return await asyncio.to_thread(self._request_ids) + + @asynccontextmanager + async def reconciliation_lock(self) -> AsyncIterator[None]: + """Serialize the complete cleanup transaction across local replicas.""" + if fcntl is None: + raise OSError("cleanup reconciliation locking unsupported") + descriptor = self._open_lock() + acquired = False + try: + while True: + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + acquired = True + break + except BlockingIOError: + await asyncio.sleep(0.05) + yield + finally: + if acquired: + try: + fcntl.flock(descriptor, fcntl.LOCK_UN) + except OSError: + pass + os.close(descriptor) + + def _open_lock(self) -> int: + self._secure_directory() + flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) + nofollow = getattr(os, "O_NOFOLLOW", 0) + if nofollow: + flags |= nofollow + path = self.directory / ".reconcile.lock" + try: + if not nofollow: + try: + if stat.S_ISLNK(os.lstat(path).st_mode): + raise OSError("cleanup reconciliation lock rejected") + except FileNotFoundError: + pass + descriptor = os.open(path, flags, 0o600) + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.getuid() + or metadata.st_mode & 0o077 + ): + raise OSError("cleanup reconciliation lock rejected") + os.fchmod(descriptor, 0o600) + return descriptor + except BaseException: + if "descriptor" in locals(): + os.close(descriptor) + raise + + def _secure_directory(self) -> None: + self.directory.mkdir(mode=0o700, parents=True, exist_ok=True) + info = os.lstat(self.directory) + if ( + stat.S_ISLNK(info.st_mode) + or not stat.S_ISDIR(info.st_mode) + or info.st_uid != os.getuid() + ): + raise OSError("cleanup backlog path rejected") + os.chmod(self.directory, 0o700) + + def _path(self, request_id: str) -> Path: + if not request_id.startswith("sbx-") or len(request_id) != 19: + raise ValueError("request_id rejected") + suffix = request_id[4:] + if len(suffix) != 15 or any(c not in "0123456789abcdef" for c in suffix): + raise ValueError("request_id rejected") + return self.directory / f"{request_id}.json" + + def _record(self, request_id: str, state: str, recorded_at: str) -> None: + self._secure_directory() + path = self._path(request_id) + if path.exists() and stat.S_ISLNK(os.lstat(path).st_mode): + raise OSError("cleanup record path rejected") + payload = json.dumps( + { + "schema_version": 1, + "request_id": request_id, + "state": state, + "recorded_at": recorded_at, + "compatibility_id": self.compatibility_id, + }, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + descriptor, temporary = tempfile.mkstemp(prefix=".cleanup-", dir=self.directory) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb") as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + os.chmod(path, 0o600) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + def _remove(self, request_id: str) -> None: + self._secure_directory() + path = self._path(request_id) + try: + if stat.S_ISLNK(os.lstat(path).st_mode): + raise OSError("cleanup record path rejected") + except FileNotFoundError: + return + path.unlink() + + def _request_ids(self) -> tuple[str, ...]: + self._secure_directory() + result: list[str] = [] + for path in self.directory.glob("sbx-*.json"): + info = os.lstat(path) + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + raise OSError("cleanup record path rejected") + value = json.loads(path.read_bytes()) + if ( + not isinstance(value, dict) + or set(value) + != { + "schema_version", + "request_id", + "state", + "recorded_at", + "compatibility_id", + } + or value["schema_version"] != 1 + or value["compatibility_id"] != self.compatibility_id + or value["request_id"] != path.stem + ): + raise OSError("cleanup record rejected") + result.append(value["request_id"]) + return tuple(sorted(result)) diff --git a/openbox_sandbox/policy_templates.py b/openbox_sandbox/policy_templates.py new file mode 100644 index 0000000..dfe91fb --- /dev/null +++ b/openbox_sandbox/policy_templates.py @@ -0,0 +1,106 @@ +"""Policy template registry: which policy the SDK deploys to the sandbox. + +The sandbox service pins ONE policy per deployment (the asset-bundle policy +identity in the provisioned service config) and verifies the active sandbox +policy against it at runtime (fail closed). Policy documents are +operator-supplied material — the SDK never embeds policy bytes. This module +owns the *selection contract*: + +- canonical template ids mapping to the release asset filenames shipped by + ``OpenBox-AI/openbox-sandbox`` ``deploy/policies/`` (the release carries all + of them; ``OPENBOX_SANDBOX_POLICY_FILE`` in agent.env names the deployed + one); +- ``load_policy(template_id, ...)`` materializes the PolicyDocument from the + operator's file (explicit path, or the provisioned agent.env path) and — + when an expected sha256 is supplied — verifies the document against it + before use. + +Callers select the template id per deployment; the engine still verifies the +active sandbox policy against the expected asset-bundle identity at runtime. +""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path + +from .runtime import PolicyDocument, ProtocolValidationError + +_MEDIA_TYPE = "application/yaml" +_ENV_POLICY_FILE = "OPENBOX_SANDBOX_POLICY_FILE" +_ENV_POLICY_SHA256 = "OPENBOX_SANDBOX_POLICY_SHA256" + +# Canonical template ids -> release asset filename (deploy/policies/). +DENY_NETWORK = "openbox-deny-network" +DENY_NETWORK_DEV = "openbox-deny-network-dev" +TEMPORAL_ACTIVITY_WORKER = "openbox-temporal-activity-worker" +TEMPORAL_ACTIVITY_WORKER_DEV = "openbox-temporal-activity-worker-dev" + +_TEMPLATES: dict[str, str] = { + DENY_NETWORK: "policy-deny-network.yaml", + DENY_NETWORK_DEV: "policy-deny-network-dev.yaml", + TEMPORAL_ACTIVITY_WORKER: "policy-temporal-activity-worker.yaml", + TEMPORAL_ACTIVITY_WORKER_DEV: "policy-temporal-activity-worker-dev.yaml", +} + + +def available_templates() -> tuple[str, ...]: + """Return the canonical policy template ids, sorted.""" + return tuple(sorted(_TEMPLATES)) + + +def template_asset(template_id: str) -> str: + """Return the release asset filename for a canonical template id. + + Raises ``KeyError`` for unknown ids. + """ + try: + return _TEMPLATES[template_id] + except KeyError: + raise KeyError( + f"unknown policy template {template_id!r}; " + f"available: {available_templates()}" + ) from None + + +def _sha256_hex(document: bytes) -> str: + return hashlib.sha256(document).hexdigest() + + +def load_policy( + template_id: str, + policy_path: str | os.PathLike[str] | None = None, + expected_sha256: str | None = None, +) -> PolicyDocument: + """Materialize a PolicyDocument for a canonical template id. + + The policy bytes come from the operator's file: ``policy_path`` if given, + otherwise ``OPENBOX_SANDBOX_POLICY_FILE`` (agent.env). When + ``expected_sha256`` is given (or ``OPENBOX_SANDBOX_POLICY_SHA256`` is set), + the document is verified against it before use — matching the service's + pinned asset-bundle policy identity. + + Raises ``KeyError`` for unknown ids, ``FileNotFoundError`` when no policy + file is configured, and ``ProtocolValidationError`` on sha mismatch. + """ + template_asset(template_id) # raises KeyError for unknown ids + if policy_path is None: + configured = os.environ.get(_ENV_POLICY_FILE) + if not configured: + raise FileNotFoundError( + f"no policy file for template {template_id!r}: pass policy_path " + f"or set {_ENV_POLICY_FILE}" + ) + policy_path = Path(configured) + document = Path(policy_path).read_bytes() + if expected_sha256 is None: + expected_sha256 = os.environ.get(_ENV_POLICY_SHA256) or None + if expected_sha256 is not None: + actual = _sha256_hex(document) + if actual != expected_sha256: + raise ProtocolValidationError( + f"policy sha256 mismatch for template {template_id!r}: " + f"expected {expected_sha256}, found {actual}" + ) + return PolicyDocument(_MEDIA_TYPE, document) diff --git a/openbox_sandbox/runtime/env.py b/openbox_sandbox/runtime/env.py new file mode 100644 index 0000000..f5b02cd --- /dev/null +++ b/openbox_sandbox/runtime/env.py @@ -0,0 +1,172 @@ +"""Load a local sandbox runtime from the ``OPENBOX_SANDBOX_*`` environment. + +The provisioning wizard ``packaging/launcher/scripts/provision-local-sandbox.sh`` +emits an ``agent.env`` file containing all the credentials and parameters an +OpenBox SDK agent needs to drive a locally-running ``openbox-sandbox`` service +over mutual TLS. This module ingests that env contract and produces the typed +objects the runtime client requires: + +* :class:`SandboxRuntimeClientConfig` — mTLS connection parameters. +* :class:`AssetBundleIdentity` — the pinned adapter / template / policy bundle. +* :class:`PolicyDocument` — the raw policy YAML the service attests. +* :class:`PolicyIdentity` — the policy's expected identity. + +A framework agent typically does this once at startup:: + + from openbox_sandbox.runtime.env import load_local_sandbox_env + + env = load_local_sandbox_env() + client = SandboxRuntimeClient(env.client_config) + # client.create(CreateRequest(...)) / client.exec(...) ... + +All configured values are required by the service (mTLS, asset-bundle +attestation, policy sha256 match). Missing env vars raise +:class:`EnvLoadError` with a message indicating which one is missing so the +agent fail-closes loudly instead of carrying wrong defaults. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from .client import SandboxRuntimeClientConfig +from .types import AssetBundleIdentity, PolicyDocument, PolicyIdentity + + +def _require(name: str) -> str: + value = os.environ.get(name) + if not value: + raise EnvLoadError(f"missing required env var: {name}") + return value + + +def _require_int(name: str) -> int: + raw = _require(name) + try: + return int(raw) + except ValueError as error: # pragma: no cover - explicit fail-closed + raise EnvLoadError(f"{name} must be an integer, got {raw!r}") from error + + +def _require_path(name: str) -> Path: + return Path(_require(name)).resolve() + + +def _require_file(name: str) -> Path: + path = _require_path(name) + if not path.is_file(): + raise EnvLoadError(f"{name} points to a missing file: {path}") + return path + + +class EnvLoadError(RuntimeError): + """Raised when the ``OPENBOX_SANDBOX_*`` env contract is incomplete.""" + + +@dataclass(frozen=True, slots=True) +class LocalSandboxEnv: + """Resolved local sandbox environment (env contract -> typed objects).""" + + client_config: SandboxRuntimeClientConfig + asset_bundle: AssetBundleIdentity + expected_policy: PolicyIdentity + policy_document: PolicyDocument + template: str + adapter_sha: str + gateway_endpoint: str + + def policy_yaml_sha256(self) -> str: + return self.expected_policy.sha256 + + +def load_local_sandbox_env() -> LocalSandboxEnv: + """Parse the ``OPENBOX_SANDBOX_*`` env contract into typed objects. + + Read by both the Python SDK demo agent and any user agent that wants the + same one-shot local-dev path. The endpoint must be loopback (the service + refuses non-loopback binds) and the policy file SHA256 must match the + expected identity. + """ + endpoint = _require("OPENBOX_SANDBOX_ENDPOINT") + if ":" not in endpoint: + raise EnvLoadError( + "OPENBOX_SANDBOX_ENDPOINT must be host:port, got " + repr(endpoint), + ) + host, _, port_str = endpoint.rpartition(":") + try: + port = int(port_str) + except ValueError as error: + raise EnvLoadError( + f"OPENBOX_SANDBOX_ENDPOINT port must be int, got {port_str!r}", + ) from error + + server_name = os.environ.get("OPENBOX_SANDBOX_SERVER_NAME", "localhost") + template = _require("OPENBOX_SANDBOX_TEMPLATE") + adapter_sha = _require("OPENBOX_SANDBOX_ADAPTER_SHA") + policy_id = _require("OPENBOX_SANDBOX_POLICY_ID") + policy_version = _require_int("OPENBOX_SANDBOX_POLICY_VERSION") + compat_id = os.environ.get("OPENBOX_SANDBOX_COMPAT_ID", "darwin-dev-1") + policy_file = _require_file("OPENBOX_SANDBOX_POLICY_FILE") + ca_path = _require_file("OPENBOX_SANDBOX_CA") + cert_path = _require_file("OPENBOX_SANDBOX_CERT") + key_path = _require_file("OPENBOX_SANDBOX_KEY") + + policy_bytes = policy_file.read_bytes() + import hashlib + + actual_policy_sha = hashlib.sha256(policy_bytes).hexdigest() + expected_policy_sha = os.environ.get( + "OPENBOX_SANDBOX_POLICY_SHA256", + actual_policy_sha, + ) + if expected_policy_sha != actual_policy_sha: + raise EnvLoadError( + f"OPENBOX_SANDBOX_POLICY_SHA256 {expected_policy_sha!r} does not " + f"match the file digest {actual_policy_sha!r}" + ) + policy_identity = PolicyIdentity( + id=policy_id, + version=policy_version, + sha256=actual_policy_sha, + ) + asset_bundle = AssetBundleIdentity( + runtime_contract_version=1, + adapter_build_sha256=adapter_sha, + template=template, + policy=policy_identity, + compatibility_id=compat_id, + ) + policy_document = PolicyDocument( + media_type="application/yaml", + document=policy_bytes, + ) + client_config = SandboxRuntimeClientConfig( + host=host, + port=port, + server_name=server_name, + ca_path=ca_path, + certificate_path=cert_path, + private_key_path=key_path, + asset_bundle=asset_bundle, + ) + return LocalSandboxEnv( + client_config=client_config, + asset_bundle=asset_bundle, + expected_policy=policy_identity, + policy_document=policy_document, + template=template, + adapter_sha=adapter_sha, + gateway_endpoint=os.environ.get( + "OPENBOX_GATEWAY_ENDPOINT", + "https://127.0.0.1:17670", + ), + ) + + +__all__ = [ + "EnvLoadError", + "LocalSandboxEnv", + "load_local_sandbox_env", +] \ No newline at end of file diff --git a/openbox_sandbox/runtime/types.py b/openbox_sandbox/runtime/types.py index 8e16bae..ef4f0be 100644 --- a/openbox_sandbox/runtime/types.py +++ b/openbox_sandbox/runtime/types.py @@ -40,11 +40,28 @@ def _uuid4(value: object) -> str: return value +_HEX = set("0123456789abcdef") + + def request_owned_id(value: str) -> str: - if not isinstance(value, str) or not value.startswith("sbx-") or len(value) != 40: + if not isinstance(value, str) or not value.startswith("sbx-"): raise ProtocolValidationError() - _uuid4(value[4:]) - return value + suffix = value[4:] + if len(suffix) == 15: + # Wire form: `sbx-<15-lowercase-hex>` (19 chars total). This matches the + # openbox-sandbox service contract and fits the OpenShell gateway's + # MAX_ROUTABLE_NAME_LEN (19). The Rust service rejects anything longer. + if not all(byte in _HEX for byte in suffix): + raise ProtocolValidationError() + return value + if len(suffix) == 36: + # Legacy form: `sbx-` (40 chars). Retained for back-compat + # with older manifest-driven deployments. The wire will refuse it + # when run against a real openbox-sandbox service; prefer the 19-char + # form above. + _uuid4(suffix) + return value + raise ProtocolValidationError() def operation_id() -> str: diff --git a/openbox_sandbox/runtime_client/__init__.py b/openbox_sandbox/runtime_client/__init__.py new file mode 100644 index 0000000..aefe93f --- /dev/null +++ b/openbox_sandbox/runtime_client/__init__.py @@ -0,0 +1,54 @@ +from .agent_client import ( + AgentProtocolError, + UnixAgentRuntimeClient, + UnixAgentRuntimeClientConfig, + agent_socket_present, + default_agent_socket_path, +) +from .client import SandboxRuntimeClient, SandboxRuntimeClientConfig +from .errors import ( + ProtocolValidationError, + SandboxServiceTransportError, + SubmissionState, + TransportFailureCode, +) +from .types import ( + AssetBundleIdentity, + CreateRequest, + ExecCompleted, + ExecRequest, + OutputLimits, + PolicyDocument, + PolicyIdentity, + ServiceResponse, + capability_token, + generate_request_owned_id, + operation_id, + request_owned_id, +) + +__all__ = [ + "AgentProtocolError", + "AssetBundleIdentity", + "CreateRequest", + "ExecCompleted", + "ExecRequest", + "OutputLimits", + "PolicyDocument", + "PolicyIdentity", + "ProtocolValidationError", + "SandboxRuntimeClient", + "SandboxRuntimeClientConfig", + "SandboxServiceTransportError", + "ServiceResponse", + "SubmissionState", + "TransportFailureCode", + "UnixAgentRuntimeClient", + "UnixAgentRuntimeClientConfig", + "agent_socket_present", + "capability_token", + "default_agent_socket_path", + "generate_request_owned_id", + "operation_id", + "request_owned_id", +] diff --git a/openbox_sandbox/runtime_client/agent_client.py b/openbox_sandbox/runtime_client/agent_client.py new file mode 100644 index 0000000..6922d51 --- /dev/null +++ b/openbox_sandbox/runtime_client/agent_client.py @@ -0,0 +1,496 @@ +"""Typed Unix-domain-socket client for the local OpenBox sandbox agent. + +The local agent is an authenticated executor adapter only. This client speaks +a strict typed handshake (Hello/HelloAck) and then exactly one existing typed +service request per connection; the agent reconstructs the unchanged TCP mTLS +``RequestEnvelope`` and forwards it to sandbox-service. The agent never calls +Core, never derives argv, never chooses a policy or profile, and never retries +execution. + +Discovery is internal and deterministic (standard per-user OS runtime +directories); there is no OpenBox environment selector. The trust boundary is +the operating-system user: socket ownership, file modes, and peer credentials +are all validated against the current UID, and a hostile same-UID process is +inside that boundary by design. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import secrets +import socket +import stat +import struct +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, NoReturn + +from .client import MAX_REQUEST_BYTES, MAX_RESPONSE_BYTES, PROTOCOL_VERSION +from .errors import ( + ProtocolValidationError, + SandboxServiceTransportError, + SubmissionState, + TransportFailureCode, +) +from .types import ( + AssetBundleIdentity, + CreateRequest, + ExecRequest, + PolicyIdentity, + ServiceResponse, + capability_token, + operation_id, + request_owned_id, +) + +AGENT_PROTOCOL_VERSION = 1 +MAX_HELLO_BYTES = 256 * 1024 +_HELLO_DEADLINE_SECONDS = 5.0 +_REQUIRED_CAPABILITY = "cancel_on_disconnect" + + +class AgentProtocolError(ValueError): + """Constant public error for any invalid local-agent interaction.""" + + def __init__(self) -> None: + super().__init__("sandbox agent endpoint rejected") + + +def default_agent_socket_path() -> Path: + """Return the deterministic per-user agent socket path for this platform.""" + uid = os.getuid() + if sys.platform == "linux": + runtime_root = os.environ.get("XDG_RUNTIME_DIR") + if runtime_root and Path(runtime_root).is_absolute(): + return Path(runtime_root) / "openbox" / "agent.sock" + return Path(f"/run/user/{uid}") / "openbox" / "agent.sock" + temporary_root = os.environ.get("TMPDIR") + if temporary_root and Path(temporary_root).is_absolute(): + return Path(os.path.realpath(temporary_root)) / f"openbox-{uid}" / "agent.sock" + return Path(f"/tmp/openbox-{uid}") / "agent.sock" + + +def _reject_symlink_components(path: Path) -> None: + if not path.is_absolute(): + raise AgentProtocolError() + current = Path(path.anchor) + for part in path.parts[1:]: + current = current / part + try: + if stat.S_ISLNK(os.lstat(current).st_mode): + raise AgentProtocolError() + except FileNotFoundError: + raise + except OSError: + raise AgentProtocolError() from None + + +def agent_socket_present(path: Path) -> bool: + """Return whether a socket exists at the path; validate when present.""" + try: + _reject_symlink_components(path) + metadata = os.lstat(path) + except FileNotFoundError: + return False + if not stat.S_ISSOCK(metadata.st_mode): + raise AgentProtocolError() + _validate_socket_metadata(path, metadata) + return True + + +def _validate_socket_metadata(path: Path, metadata: os.stat_result) -> None: + parent = os.lstat(path.parent) + if ( + not stat.S_ISSOCK(metadata.st_mode) + or metadata.st_uid != os.getuid() + or stat.S_IMODE(metadata.st_mode) != 0o600 + or not stat.S_ISDIR(parent.st_mode) + or parent.st_uid != os.getuid() + or stat.S_IMODE(parent.st_mode) & 0o077 + ): + raise AgentProtocolError() + + +def _peer_uid(raw_socket: socket.socket) -> int: + if sys.platform == "linux": + credentials = raw_socket.getsockopt( + socket.SOL_SOCKET, + socket.SO_PEERCRED, # type: ignore[attr-defined] + struct.calcsize("3i"), + ) + _, uid, _ = struct.unpack("3i", credentials) + return int(uid) + if sys.platform == "darwin": + # struct xucred { u_int cr_version; uid_t cr_uid; short cr_ngroups; + # gid_t cr_groups[16]; } + raw = raw_socket.getsockopt(0, socket.LOCAL_PEERCRED, 4 + 4 + 4 + 16 * 4) + version, uid = struct.unpack_from("Ii", raw, 0) + if version != 0: + raise AgentProtocolError() + return int(uid) + raise AgentProtocolError() + + +@dataclass(frozen=True, slots=True, repr=False) +class UnixAgentRuntimeClientConfig: + socket_path: Path + asset_bundle: AssetBundleIdentity + registry_fingerprint: str + + def __post_init__(self) -> None: + if ( + not isinstance(self.socket_path, Path) + or not self.socket_path.is_absolute() + or not isinstance(self.registry_fingerprint, str) + or len(self.registry_fingerprint) != 64 + or any(character not in "0123456789abcdef" for character in self.registry_fingerprint) + ): + raise ProtocolValidationError() + + def __repr__(self) -> str: + return ( + "UnixAgentRuntimeClientConfig(socket_path=, " + f"asset_bundle={self.asset_bundle!r}, " + f"registry_fingerprint={self.registry_fingerprint!r})" + ) + + +class UnixAgentRuntimeClient: + """Drop-in runtime surface matching :class:`SandboxRuntimeClient`.""" + + def __init__(self, config: UnixAgentRuntimeClientConfig) -> None: + self._config = config + + async def _connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + path = self._config.socket_path + try: + _reject_symlink_components(path) + metadata = os.lstat(path) + _validate_socket_metadata(path, metadata) + except FileNotFoundError: + raise AgentProtocolError() from None + reader, writer = await asyncio.open_unix_connection(str(path)) + try: + raw_socket = writer.get_extra_info("socket") + if raw_socket is None or _peer_uid(raw_socket) != os.getuid(): + raise AgentProtocolError() + except AgentProtocolError: + writer.close() + raise + except OSError: + writer.close() + raise AgentProtocolError() from None + return reader, writer + + async def _write_frame( + self, + writer: asyncio.StreamWriter, + value: object, + maximum: int, + ) -> None: + body = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + if not body or len(body) > maximum: + raise ProtocolValidationError() + writer.write(struct.pack(">I", len(body)) + body) + await writer.drain() + + async def _read_frame(self, reader: asyncio.StreamReader, maximum: int) -> dict[str, Any]: + size = struct.unpack(">I", await reader.readexactly(4))[0] + if not 1 <= size <= maximum: + raise ProtocolValidationError() + body = await reader.readexactly(size) + value = json.loads( + body, + object_pairs_hook=_strict_object, + parse_constant=_reject_constant, + ) + if not isinstance(value, dict): + raise ProtocolValidationError() + return value + + async def _handshake(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> str: + nonce = secrets.token_urlsafe(32) + await self._write_frame( + writer, + { + "agent_protocol_version": AGENT_PROTOCOL_VERSION, + "client_nonce": nonce, + "asset_bundle": self._config.asset_bundle.to_wire(), + "registry_fingerprint": self._config.registry_fingerprint, + "max_request_bytes": MAX_REQUEST_BYTES, + "max_response_bytes": MAX_RESPONSE_BYTES, + }, + MAX_HELLO_BYTES, + ) + acknowledged = await self._read_frame(reader, MAX_HELLO_BYTES) + if set(acknowledged) != { + "agent_protocol_version", + "client_nonce", + "operation_capability", + "capabilities", + "max_request_bytes", + "max_response_bytes", + }: + raise ProtocolValidationError() + capabilities = acknowledged["capabilities"] + if ( + acknowledged["agent_protocol_version"] != AGENT_PROTOCOL_VERSION + or acknowledged["client_nonce"] != nonce + or not isinstance(capabilities, list) + or len(capabilities) > 16 + or not all(isinstance(item, str) and 0 < len(item) <= 64 for item in capabilities) + or _REQUIRED_CAPABILITY not in capabilities + or acknowledged["max_request_bytes"] != MAX_REQUEST_BYTES + or acknowledged["max_response_bytes"] != MAX_RESPONSE_BYTES + ): + raise ProtocolValidationError() + capability = acknowledged["operation_capability"] + if not isinstance(capability, str): + raise ProtocolValidationError() + return capability_token(capability) + + async def call( + self, + operation: str, + fields: Mapping[str, Any], + deadline_ms: int, + *, + request_operation_id: str | None = None, + ) -> ServiceResponse: + if not 1 <= deadline_ms <= 120_000 or not operation: + raise ProtocolValidationError() + request_id = request_operation_id or operation_id() + capability_token(request_id) + writer: asyncio.StreamWriter | None = None + submission = SubmissionState.NOT_SUBMITTED + try: + async with asyncio.timeout(_HELLO_DEADLINE_SECONDS + deadline_ms / 1000): + reader, writer = await self._connect() + capability = await self._handshake(reader, writer) + envelope = { + "agent_protocol_version": AGENT_PROTOCOL_VERSION, + "operation_capability": capability, + "envelope": { + "protocol_version": PROTOCOL_VERSION, + "operation_id": request_id, + "asset_bundle": self._config.asset_bundle.to_wire(), + "request": {"operation": operation, **dict(fields)}, + }, + } + submission = SubmissionState.POSSIBLY_SUBMITTED + await self._write_frame(writer, envelope, MAX_REQUEST_BYTES) + response = await self._read_frame(reader, MAX_RESPONSE_BYTES) + except asyncio.CancelledError as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.CANCELLED, + ) from error + except TimeoutError as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.DEADLINE, + ) from error + except AgentProtocolError as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.AUTHENTICATION, + ) from error + except (ConnectionError, OSError, asyncio.IncompleteReadError) as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.TRANSPORT, + ) from error + except ( + ProtocolValidationError, + json.JSONDecodeError, + UnicodeDecodeError, + ) as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.PROTOCOL, + ) from error + finally: + if writer is not None: + writer.close() + try: + await writer.wait_closed() + except (ConnectionError, OSError): + pass + return _decode_agent_response(response, request_id) + + async def health(self, deadline_ms: int = 5_000) -> ServiceResponse: + return await self.call("health", {}, deadline_ms) + + async def create( + self, + request: CreateRequest, + deadline_ms: int = 60_000, + ) -> ServiceResponse: + if ( + request.template != self._config.asset_bundle.template + or request.expected_policy != self._config.asset_bundle.policy + ): + raise ProtocolValidationError() + return await self.call( + "create", + {"request": request.to_wire(), "deadline_ms": deadline_ms}, + deadline_ms, + ) + + async def wait_ready( + self, + sandbox_id: str, + lifecycle_token: str, + expected_policy: PolicyIdentity, + deadline_ms: int = 120_000, + ) -> ServiceResponse: + return await self.call( + "wait_ready", + { + "request_id": request_owned_id(sandbox_id), + "lifecycle_token": capability_token(lifecycle_token), + "expected_policy": expected_policy.to_wire(), + "deadline_ms": deadline_ms, + }, + deadline_ms, + ) + + async def exec( + self, + sandbox_id: str, + lifecycle_token: str, + request: ExecRequest, + deadline_ms: int = 45_000, + ) -> ServiceResponse: + started = asyncio.get_running_loop().time() + + def remaining() -> int: + elapsed = int((asyncio.get_running_loop().time() - started) * 1000) + value = deadline_ms - elapsed + if value <= 0: + raise SandboxServiceTransportError( + SubmissionState.NOT_SUBMITTED, + TransportFailureCode.DEADLINE, + ) + return value + + prepare_deadline = remaining() + prepared = await self.call( + "prepare_exec", + { + "request_id": request_owned_id(sandbox_id), + "lifecycle_token": capability_token(lifecycle_token), + "request": request.to_wire(), + "deadline_ms": prepare_deadline, + }, + prepare_deadline, + ) + if prepared.response != "exec_prepared": + return prepared + token = prepared.fields.get("prepare_token") + if not isinstance(token, str): + raise ProtocolValidationError() + commit_deadline = remaining() + return await self.call( + "commit_exec", + { + "request_id": sandbox_id, + "prepare_token": capability_token(token), + "deadline_ms": commit_deadline, + }, + commit_deadline, + ) + + async def delete( + self, + sandbox_id: str, + deadline_ms: int = 60_000, + ) -> ServiceResponse: + return await self.call( + "delete", + { + "target": {"request_id": request_owned_id(sandbox_id)}, + "deadline_ms": deadline_ms, + }, + deadline_ms, + ) + + async def wait_deleted( + self, + sandbox_id: str, + deadline_ms: int = 60_000, + ) -> ServiceResponse: + return await self.call( + "wait_deleted", + { + "target": {"request_id": request_owned_id(sandbox_id)}, + "deadline_ms": deadline_ms, + }, + deadline_ms, + ) + + async def cancel( + self, + target_operation_id: str, + deadline_ms: int = 5_000, + ) -> ServiceResponse: + return await self.call( + "cancel", + {"target_operation_id": capability_token(target_operation_id)}, + deadline_ms, + ) + + +def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ProtocolValidationError() + result[key] = value + return result + + +def _reject_constant(_: str) -> NoReturn: + raise ProtocolValidationError() + + +def _decode_agent_response(value: dict[str, Any], expected_operation_id: str) -> ServiceResponse: + try: + if set(value) != {"agent_protocol_version", "envelope"}: + raise ProtocolValidationError() + if value["agent_protocol_version"] != AGENT_PROTOCOL_VERSION: + raise ProtocolValidationError() + envelope = value["envelope"] + if not isinstance(envelope, dict) or set(envelope) != { + "protocol_version", + "operation_id", + "response", + }: + raise ProtocolValidationError() + if ( + envelope["protocol_version"] != PROTOCOL_VERSION + or envelope["operation_id"] != expected_operation_id + or not isinstance(envelope["response"], dict) + ): + raise ProtocolValidationError() + response = envelope["response"] + response_type = response.get("response") + if not isinstance(response_type, str): + raise ProtocolValidationError() + return ServiceResponse( + response=response_type, + fields={key: item for key, item in response.items() if key != "response"}, + ) + except ProtocolValidationError as error: + raise SandboxServiceTransportError( + SubmissionState.POSSIBLY_SUBMITTED, + TransportFailureCode.PROTOCOL, + ) from error diff --git a/openbox_sandbox/runtime_client/agent_server.py b/openbox_sandbox/runtime_client/agent_server.py new file mode 100644 index 0000000..cd4d52e --- /dev/null +++ b/openbox_sandbox/runtime_client/agent_server.py @@ -0,0 +1,550 @@ +"""Minimal authenticated Unix-socket adapter for the sandbox service. + +This process is deliberately an executor transport only. It authenticates a +same-UID SDK client, negotiates one fixed asset/registry identity, accepts one +existing typed sandbox-service operation per connection, and forwards that +operation over the existing TLS 1.3/mTLS client. Governance, command-profile +selection, lifecycle ordering, retries, and cleanup decisions remain in the +SDK dispatcher. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import signal +import stat +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, NoReturn + +from .agent_client import ( + AGENT_PROTOCOL_VERSION, + MAX_HELLO_BYTES, + _peer_uid, + _strict_object, +) +from .client import ( + MAX_REQUEST_BYTES, + MAX_RESPONSE_BYTES, + PROTOCOL_VERSION, + SandboxRuntimeClient, + SandboxRuntimeClientConfig, +) +from .errors import ProtocolValidationError +from .types import ( + AssetBundleIdentity, + PolicyIdentity, + capability_token, +) + +_REQUIRED_CAPABILITY = "cancel_on_disconnect" +_ALLOWED_REQUEST_FIELDS: Mapping[str, frozenset[str]] = { + "health": frozenset({"operation"}), + "create": frozenset({"operation", "request", "deadline_ms"}), + "wait_ready": frozenset( + { + "operation", + "request_id", + "lifecycle_token", + "expected_policy", + "deadline_ms", + } + ), + "prepare_exec": frozenset( + { + "operation", + "request_id", + "lifecycle_token", + "request", + "deadline_ms", + } + ), + "commit_exec": frozenset({"operation", "request_id", "prepare_token", "deadline_ms"}), + "delete": frozenset({"operation", "target", "deadline_ms"}), + "wait_deleted": frozenset({"operation", "target", "deadline_ms"}), + "cancel": frozenset({"operation", "target_operation_id"}), +} + + +def _reject_constant(_: str) -> NoReturn: + raise ProtocolValidationError() + + +def _sha256(value: object) -> str: + if ( + not isinstance(value, str) + or len(value) != 64 + or any(character not in "0123456789abcdef" for character in value) + ): + raise ProtocolValidationError() + return value + + +def _asset_bundle(value: object) -> AssetBundleIdentity: + if not isinstance(value, dict) or set(value) != { + "runtime_contract_version", + "adapter_build_sha256", + "template", + "policy", + "compatibility_id", + }: + raise ProtocolValidationError() + policy = value["policy"] + if not isinstance(policy, dict) or set(policy) != {"id", "version", "sha256"}: + raise ProtocolValidationError() + policy_id = policy["id"] + policy_version = policy["version"] + template = value["template"] + compatibility_id = value["compatibility_id"] + contract_version = value["runtime_contract_version"] + if ( + not isinstance(policy_id, str) + or isinstance(policy_version, bool) + or not isinstance(policy_version, int) + or not isinstance(template, str) + or not isinstance(compatibility_id, str) + or isinstance(contract_version, bool) + or not isinstance(contract_version, int) + ): + raise ProtocolValidationError() + return AssetBundleIdentity( + runtime_contract_version=contract_version, + adapter_build_sha256=_sha256(value["adapter_build_sha256"]), + template=template, + policy=PolicyIdentity( + id=policy_id, + version=policy_version, + sha256=_sha256(policy["sha256"]), + ), + compatibility_id=compatibility_id, + ) + + +def load_service_client_config( + service_config: Path, + *, + ca_path: Path, + certificate_path: Path, + private_key_path: Path, +) -> SandboxRuntimeClientConfig: + """Load only the upstream address and immutable asset identity.""" + try: + raw = json.loads( + service_config.read_bytes(), + object_pairs_hook=_strict_object, + parse_constant=_reject_constant, + ) + if not isinstance(raw, dict): + raise ProtocolValidationError() + bind_address = raw["bind_address"] + if not isinstance(bind_address, str): + raise ProtocolValidationError() + host, port_text = bind_address.rsplit(":", 1) + port = int(port_text) + asset = _asset_bundle(raw["asset_bundle"]) + except (KeyError, OSError, UnicodeDecodeError, ValueError) as error: + raise ProtocolValidationError() from error + return SandboxRuntimeClientConfig( + host=host, + port=port, + server_name="localhost", + ca_path=ca_path, + certificate_path=certificate_path, + private_key_path=private_key_path, + asset_bundle=asset, + ) + + +@dataclass(frozen=True, slots=True, repr=False) +class UnixAgentServerConfig: + socket_path: Path + registry_fingerprint: str + upstream: SandboxRuntimeClientConfig + + def __post_init__(self) -> None: + if not self.socket_path.is_absolute(): + raise ProtocolValidationError() + _sha256(self.registry_fingerprint) + + def __repr__(self) -> str: + return ( + "UnixAgentServerConfig(socket_path=, " + f"registry_fingerprint={self.registry_fingerprint!r}, " + "upstream=)" + ) + + +class UnixAgentServer: + """One-operation-per-connection typed local adapter.""" + + def __init__( + self, + config: UnixAgentServerConfig, + *, + upstream: Any | None = None, + ) -> None: + self._config = config + self._upstream = upstream or SandboxRuntimeClient(config.upstream) + self._server: asyncio.AbstractServer | None = None + self._socket_identity: tuple[int, int] | None = None + + async def start(self) -> None: + if self._server is not None: + raise RuntimeError("sandbox agent already started") + path = self._config.socket_path + parent = path.parent + self._prepare_parent(parent) + try: + os.lstat(path) + except FileNotFoundError: + pass + else: + raise ProtocolValidationError() + old_umask = os.umask(0o077) + try: + server = await asyncio.start_unix_server(self._handle, path=str(path)) + finally: + os.umask(old_umask) + try: + os.chmod(path, 0o600, follow_symlinks=False) + metadata = os.lstat(path) + if ( + not stat.S_ISSOCK(metadata.st_mode) + or metadata.st_uid != os.getuid() + or stat.S_IMODE(metadata.st_mode) != 0o600 + ): + raise ProtocolValidationError() + except BaseException: + server.close() + await server.wait_closed() + try: + path.unlink() + except OSError: + pass + raise + self._server = server + self._socket_identity = (metadata.st_dev, metadata.st_ino) + + def _prepare_parent(self, parent: Path) -> None: + ancestor = parent.parent + try: + ancestor_metadata = os.lstat(ancestor) + if not stat.S_ISDIR(ancestor_metadata.st_mode): + raise ProtocolValidationError() + parent.mkdir(mode=0o700) + except FileExistsError: + pass + except OSError as error: + raise ProtocolValidationError() from error + metadata = os.lstat(parent) + if ( + stat.S_ISLNK(metadata.st_mode) + or not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.getuid() + or stat.S_IMODE(metadata.st_mode) & 0o077 + ): + raise ProtocolValidationError() + + async def close(self) -> None: + server = self._server + self._server = None + if server is not None: + server.close() + await server.wait_closed() + path = self._config.socket_path + identity = self._socket_identity + self._socket_identity = None + if identity is None: + return + try: + metadata = os.lstat(path) + if ( + not stat.S_ISSOCK(metadata.st_mode) + or (metadata.st_dev, metadata.st_ino) != identity + ): + raise ProtocolValidationError() + path.unlink() + except FileNotFoundError: + return + + async def serve_forever(self) -> None: + if self._server is None: + raise RuntimeError("sandbox agent not started") + await self._server.serve_forever() + + async def _read_frame( + self, + reader: asyncio.StreamReader, + maximum: int, + ) -> dict[str, Any]: + size = struct.unpack(">I", await reader.readexactly(4))[0] + if not 1 <= size <= maximum: + raise ProtocolValidationError() + body = await reader.readexactly(size) + value = json.loads( + body, + object_pairs_hook=_strict_object, + parse_constant=_reject_constant, + ) + if not isinstance(value, dict): + raise ProtocolValidationError() + return value + + async def _write_frame( + self, + writer: asyncio.StreamWriter, + value: object, + maximum: int, + ) -> None: + body = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + if not body or len(body) > maximum: + raise ProtocolValidationError() + writer.write(struct.pack(">I", len(body)) + body) + await writer.drain() + + async def _handle( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + try: + raw_socket = writer.get_extra_info("socket") + if raw_socket is None or _peer_uid(raw_socket) != os.getuid(): + raise ProtocolValidationError() + async with asyncio.timeout(5): + hello = await self._read_frame(reader, MAX_HELLO_BYTES) + nonce = self._validate_hello(hello) + operation_capability = capability_token_from_random() + await self._write_frame( + writer, + { + "agent_protocol_version": AGENT_PROTOCOL_VERSION, + "client_nonce": nonce, + "operation_capability": operation_capability, + "capabilities": [_REQUIRED_CAPABILITY], + "max_request_bytes": MAX_REQUEST_BYTES, + "max_response_bytes": MAX_RESPONSE_BYTES, + }, + MAX_HELLO_BYTES, + ) + request = await self._read_frame(reader, MAX_REQUEST_BYTES) + operation_id, operation, fields, deadline_ms = self._validate_request( + request, + operation_capability, + ) + upstream_task = asyncio.create_task( + self._upstream.call( + operation, + fields, + deadline_ms, + request_operation_id=operation_id, + ) + ) + disconnect_task = asyncio.create_task(reader.read(1)) + done, _ = await asyncio.wait( + {upstream_task, disconnect_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if disconnect_task in done: + upstream_task.cancel() + await asyncio.gather(upstream_task, return_exceptions=True) + return + disconnect_task.cancel() + await asyncio.gather(disconnect_task, return_exceptions=True) + response = await upstream_task + await self._write_frame( + writer, + { + "agent_protocol_version": AGENT_PROTOCOL_VERSION, + "envelope": { + "protocol_version": PROTOCOL_VERSION, + "operation_id": operation_id, + "response": { + "response": response.response, + **dict(response.fields), + }, + }, + }, + MAX_RESPONSE_BYTES, + ) + except ( + asyncio.IncompleteReadError, + ConnectionError, + OSError, + ProtocolValidationError, + TimeoutError, + UnicodeDecodeError, + ValueError, + ): + return + finally: + writer.close() + try: + await writer.wait_closed() + except (ConnectionError, OSError): + pass + + def _validate_hello(self, value: dict[str, Any]) -> str: + if set(value) != { + "agent_protocol_version", + "client_nonce", + "asset_bundle", + "registry_fingerprint", + "max_request_bytes", + "max_response_bytes", + }: + raise ProtocolValidationError() + nonce = value["client_nonce"] + if ( + value["agent_protocol_version"] != AGENT_PROTOCOL_VERSION + or not isinstance(nonce, str) + or not 32 <= len(nonce) <= 128 + or _asset_bundle(value["asset_bundle"]) != self._config.upstream.asset_bundle + or value["registry_fingerprint"] != self._config.registry_fingerprint + or value["max_request_bytes"] != MAX_REQUEST_BYTES + or value["max_response_bytes"] != MAX_RESPONSE_BYTES + ): + raise ProtocolValidationError() + return nonce + + def _validate_request( + self, + value: dict[str, Any], + expected_capability: str, + ) -> tuple[str, str, dict[str, Any], int]: + if set(value) != { + "agent_protocol_version", + "operation_capability", + "envelope", + }: + raise ProtocolValidationError() + if ( + value["agent_protocol_version"] != AGENT_PROTOCOL_VERSION + or value["operation_capability"] != expected_capability + ): + raise ProtocolValidationError() + envelope = value["envelope"] + if not isinstance(envelope, dict) or set(envelope) != { + "protocol_version", + "operation_id", + "asset_bundle", + "request", + }: + raise ProtocolValidationError() + operation_id = envelope["operation_id"] + capability_token(operation_id) + if ( + envelope["protocol_version"] != PROTOCOL_VERSION + or _asset_bundle(envelope["asset_bundle"]) != self._config.upstream.asset_bundle + ): + raise ProtocolValidationError() + request = envelope["request"] + if not isinstance(request, dict): + raise ProtocolValidationError() + operation = request.get("operation") + if not isinstance(operation, str): + raise ProtocolValidationError() + expected_fields = _ALLOWED_REQUEST_FIELDS.get(operation) + if expected_fields is None or set(request) != expected_fields: + raise ProtocolValidationError() + raw_deadline = request.get("deadline_ms", 5_000) + if ( + isinstance(raw_deadline, bool) + or not isinstance(raw_deadline, int) + or not 1 <= raw_deadline <= 120_000 + ): + raise ProtocolValidationError() + fields = {key: item for key, item in request.items() if key != "operation"} + return operation_id, operation, fields, raw_deadline + + +def capability_token_from_random() -> str: + from .types import operation_id + + return operation_id() + + +async def _serve(args: argparse.Namespace) -> None: + upstream = load_service_client_config( + args.service_config, + ca_path=args.ca, + certificate_path=args.certificate, + private_key_path=args.private_key, + ) + server = UnixAgentServer( + UnixAgentServerConfig( + socket_path=args.socket, + registry_fingerprint=args.registry_fingerprint, + upstream=upstream, + ) + ) + await server.start() + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for current_signal in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(current_signal, stop.set) + except NotImplementedError: + pass + try: + await stop.wait() + finally: + await server.close() + + +async def _health(args: argparse.Namespace) -> None: + from .agent_client import UnixAgentRuntimeClient, UnixAgentRuntimeClientConfig + + upstream = load_service_client_config( + args.service_config, + ca_path=Path("/not-used"), + certificate_path=Path("/not-used"), + private_key_path=Path("/not-used"), + ) + client = UnixAgentRuntimeClient( + UnixAgentRuntimeClientConfig( + socket_path=args.socket, + asset_bundle=upstream.asset_bundle, + registry_fingerprint=args.registry_fingerprint, + ) + ) + response = await client.health() + status = response.fields.get("status") + if ( + response.response != "health" + or not isinstance(status, dict) + or status.get("ready") is not True + ): + raise RuntimeError("sandbox agent health rejected") + + +def main() -> None: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + serve = subparsers.add_parser("serve") + health = subparsers.add_parser("health") + for current in (serve, health): + current.add_argument("--service-config", type=Path, required=True) + current.add_argument("--socket", type=Path, required=True) + current.add_argument("--registry-fingerprint", required=True) + serve.add_argument("--ca", type=Path, required=True) + serve.add_argument("--certificate", type=Path, required=True) + serve.add_argument("--private-key", type=Path, required=True) + args = parser.parse_args() + if args.command == "serve": + asyncio.run(_serve(args)) + else: + asyncio.run(_health(args)) + + +if __name__ == "__main__": + main() diff --git a/openbox_sandbox/runtime_client/client.py b/openbox_sandbox/runtime_client/client.py new file mode 100644 index 0000000..56c79e5 --- /dev/null +++ b/openbox_sandbox/runtime_client/client.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +import asyncio +import ipaddress +import json +import ssl +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + +from .errors import ( + ProtocolValidationError, + SandboxServiceTransportError, + SubmissionState, + TransportFailureCode, +) +from .types import ( + AssetBundleIdentity, + CreateRequest, + ExecRequest, + PolicyIdentity, + ServiceResponse, + capability_token, + operation_id, + request_owned_id, +) + +PROTOCOL_VERSION = 1 +MAX_REQUEST_BYTES = 2 * 1024 * 1024 +MAX_RESPONSE_BYTES = 4 * 1024 * 1024 + + +@dataclass(frozen=True, slots=True, repr=False) +class SandboxRuntimeClientConfig: + host: str + port: int + server_name: str + ca_path: Path + certificate_path: Path + private_key_path: Path + asset_bundle: AssetBundleIdentity + + def __post_init__(self) -> None: + try: + address = ipaddress.ip_address(self.host) + except ValueError as error: + raise ProtocolValidationError() from error + if not address.is_loopback or not 1 <= self.port <= 65535 or not self.server_name: + raise ProtocolValidationError() + + def __repr__(self) -> str: + return ( + f"SandboxRuntimeClientConfig(host={self.host!r}, port={self.port}, " + f"server_name={self.server_name!r}, credentials=, " + f"asset_bundle={self.asset_bundle!r})" + ) + + +class SandboxRuntimeClient: + def __init__(self, config: SandboxRuntimeClientConfig) -> None: + self._config = config + context = ssl.create_default_context( + ssl.Purpose.SERVER_AUTH, + cafile=str(config.ca_path), + ) + context.minimum_version = ssl.TLSVersion.TLSv1_3 + context.maximum_version = ssl.TLSVersion.TLSv1_3 + context.load_cert_chain( + certfile=str(config.certificate_path), + keyfile=str(config.private_key_path), + ) + context.check_hostname = True + self._ssl = context + + async def call( + self, + operation: str, + fields: Mapping[str, Any], + deadline_ms: int, + *, + request_operation_id: str | None = None, + ) -> ServiceResponse: + if not 1 <= deadline_ms <= 120_000 or not operation: + raise ProtocolValidationError() + request_id = request_operation_id or operation_id() + capability_token(request_id) + request = { + "protocol_version": PROTOCOL_VERSION, + "operation_id": request_id, + "asset_bundle": self._config.asset_bundle.to_wire(), + "request": {"operation": operation, **dict(fields)}, + } + body = json.dumps( + request, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + if not body or len(body) > MAX_REQUEST_BYTES: + raise SandboxServiceTransportError( + SubmissionState.NOT_SUBMITTED, + TransportFailureCode.PROTOCOL, + ) + writer: asyncio.StreamWriter | None = None + submission = SubmissionState.NOT_SUBMITTED + try: + async with asyncio.timeout(deadline_ms / 1000): + reader, writer = await asyncio.open_connection( + self._config.host, + self._config.port, + ssl=self._ssl, + server_hostname=self._config.server_name, + ) + submission = SubmissionState.POSSIBLY_SUBMITTED + writer.write(struct.pack(">I", len(body)) + body) + await writer.drain() + size = struct.unpack(">I", await reader.readexactly(4))[0] + if not 1 <= size <= MAX_RESPONSE_BYTES: + raise ProtocolValidationError() + response_body = await reader.readexactly(size) + except asyncio.CancelledError as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.CANCELLED, + ) from error + except TimeoutError as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.DEADLINE, + ) from error + except (ssl.SSLError, ConnectionError, OSError, asyncio.IncompleteReadError) as error: + code = ( + TransportFailureCode.AUTHENTICATION + if submission is SubmissionState.NOT_SUBMITTED and isinstance(error, ssl.SSLError) + else TransportFailureCode.TRANSPORT + ) + raise SandboxServiceTransportError(submission, code) from error + except ProtocolValidationError as error: + raise SandboxServiceTransportError( + submission, + TransportFailureCode.PROTOCOL, + ) from error + finally: + if writer is not None: + writer.close() + try: + await writer.wait_closed() + except (ssl.SSLError, ConnectionError, OSError): + pass + return _decode_response(response_body, request_id) + + async def health(self, deadline_ms: int = 5_000) -> ServiceResponse: + return await self.call("health", {}, deadline_ms) + + async def create( + self, + request: CreateRequest, + deadline_ms: int = 60_000, + ) -> ServiceResponse: + if ( + request.template != self._config.asset_bundle.template + or request.expected_policy != self._config.asset_bundle.policy + ): + raise ProtocolValidationError() + return await self.call( + "create", + {"request": request.to_wire(), "deadline_ms": deadline_ms}, + deadline_ms, + ) + + async def wait_ready( + self, + sandbox_id: str, + lifecycle_token: str, + expected_policy: PolicyIdentity, + deadline_ms: int = 120_000, + ) -> ServiceResponse: + return await self.call( + "wait_ready", + { + "request_id": request_owned_id(sandbox_id), + "lifecycle_token": capability_token(lifecycle_token), + "expected_policy": expected_policy.to_wire(), + "deadline_ms": deadline_ms, + }, + deadline_ms, + ) + + async def exec( + self, + sandbox_id: str, + lifecycle_token: str, + request: ExecRequest, + deadline_ms: int = 45_000, + ) -> ServiceResponse: + started = asyncio.get_running_loop().time() + + def remaining() -> int: + elapsed = int((asyncio.get_running_loop().time() - started) * 1000) + value = deadline_ms - elapsed + if value <= 0: + raise SandboxServiceTransportError( + SubmissionState.NOT_SUBMITTED, + TransportFailureCode.DEADLINE, + ) + return value + + prepare_deadline = remaining() + prepared = await self.call( + "prepare_exec", + { + "request_id": request_owned_id(sandbox_id), + "lifecycle_token": capability_token(lifecycle_token), + "request": request.to_wire(), + "deadline_ms": prepare_deadline, + }, + prepare_deadline, + ) + if prepared.response != "exec_prepared": + return prepared + token = prepared.fields.get("prepare_token") + if not isinstance(token, str): + raise ProtocolValidationError() + commit_deadline = remaining() + return await self.call( + "commit_exec", + { + "request_id": sandbox_id, + "prepare_token": capability_token(token), + "deadline_ms": commit_deadline, + }, + commit_deadline, + ) + + async def delete( + self, + sandbox_id: str, + deadline_ms: int = 60_000, + ) -> ServiceResponse: + return await self.call( + "delete", + { + "target": {"request_id": request_owned_id(sandbox_id)}, + "deadline_ms": deadline_ms, + }, + deadline_ms, + ) + + async def wait_deleted( + self, + sandbox_id: str, + deadline_ms: int = 60_000, + ) -> ServiceResponse: + return await self.call( + "wait_deleted", + { + "target": {"request_id": request_owned_id(sandbox_id)}, + "deadline_ms": deadline_ms, + }, + deadline_ms, + ) + + async def cancel( + self, + target_operation_id: str, + deadline_ms: int = 5_000, + ) -> ServiceResponse: + return await self.call( + "cancel", + {"target_operation_id": capability_token(target_operation_id)}, + deadline_ms, + ) + + +def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ProtocolValidationError() + result[key] = value + return result + + +def _decode_response(body: bytes, expected_operation_id: str) -> ServiceResponse: + try: + value = json.loads(body, object_pairs_hook=_strict_object) + if not isinstance(value, dict) or set(value) != { + "protocol_version", + "operation_id", + "response", + }: + raise ProtocolValidationError() + if ( + value["protocol_version"] != PROTOCOL_VERSION + or value["operation_id"] != expected_operation_id + or not isinstance(value["response"], dict) + ): + raise ProtocolValidationError() + response = value["response"] + response_type = response.get("response") + if not isinstance(response_type, str): + raise ProtocolValidationError() + return ServiceResponse( + response=response_type, + fields={key: item for key, item in response.items() if key != "response"}, + ) + except (json.JSONDecodeError, UnicodeDecodeError, ProtocolValidationError) as error: + raise SandboxServiceTransportError( + SubmissionState.POSSIBLY_SUBMITTED, + TransportFailureCode.PROTOCOL, + ) from error diff --git a/openbox_sandbox/runtime_client/errors.py b/openbox_sandbox/runtime_client/errors.py new file mode 100644 index 0000000..6e5a14a --- /dev/null +++ b/openbox_sandbox/runtime_client/errors.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class SubmissionState(str, Enum): + NOT_SUBMITTED = "not_submitted" + POSSIBLY_SUBMITTED = "possibly_submitted" + + +class TransportFailureCode(str, Enum): + AUTHENTICATION = "authentication" + CANCELLED = "cancelled" + DEADLINE = "deadline" + PROTOCOL = "protocol" + TRANSPORT = "transport" + + +@dataclass(frozen=True, slots=True) +class SandboxServiceTransportError(Exception): + submission_state: SubmissionState + code: TransportFailureCode + + def __str__(self) -> str: + return "sandbox service transport failed" + + +class ProtocolValidationError(ValueError): + def __init__(self) -> None: + super().__init__("sandbox service protocol value rejected") diff --git a/openbox_sandbox/runtime_client/py.typed b/openbox_sandbox/runtime_client/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/openbox_sandbox/runtime_client/types.py b/openbox_sandbox/runtime_client/types.py new file mode 100644 index 0000000..3e47a22 --- /dev/null +++ b/openbox_sandbox/runtime_client/types.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import base64 +import re +import uuid +from dataclasses import dataclass, field +from typing import Any, Mapping, Sequence + +from .errors import ProtocolValidationError + +_SHA256 = re.compile(r"[0-9a-f]{64}\Z") +_COMPATIBILITY = re.compile(r"[A-Za-z0-9._-]{1,128}\Z") + + +def _sha256(value: str) -> str: + if not _SHA256.fullmatch(value): + raise ProtocolValidationError() + return value + + +def _uuid4(value: str) -> str: + try: + parsed = uuid.UUID(value) + except (ValueError, AttributeError) as error: + raise ProtocolValidationError() from error + if parsed.version != 4 or parsed.variant != uuid.RFC_4122 or str(parsed) != value: + raise ProtocolValidationError() + return value + + +def generate_request_owned_id() -> str: + """Return `sbx-<15-lowercase-hex>` (19 chars) for OpenShell name limits.""" + return f"sbx-{uuid.uuid4().hex[:15]}" + + +def request_owned_id(value: str) -> str: + # OpenShell server MAX_ROUTABLE_NAME_LEN = 19. Match the Rust broker shape: + # sbx- + 15 lowercase hex. + if not isinstance(value, str) or not value.startswith("sbx-") or len(value) != 19: + raise ProtocolValidationError() + suffix = value[4:] + if len(suffix) != 15 or any(c not in "0123456789abcdef" for c in suffix): + raise ProtocolValidationError() + return value + + +def operation_id() -> str: + return str(uuid.uuid4()) + + +def capability_token(value: str) -> str: + return _uuid4(value) + + +@dataclass(frozen=True, slots=True) +class PolicyIdentity: + id: str + version: int + sha256: str + + def __post_init__(self) -> None: + if not self.id or self.version <= 0: + raise ProtocolValidationError() + _sha256(self.sha256) + + def to_wire(self) -> dict[str, Any]: + return {"id": self.id, "version": self.version, "sha256": self.sha256} + + +@dataclass(frozen=True, slots=True) +class AssetBundleIdentity: + runtime_contract_version: int + adapter_build_sha256: str + template: str + policy: PolicyIdentity + compatibility_id: str + + def __post_init__(self) -> None: + if self.runtime_contract_version <= 0 or not self.template: + raise ProtocolValidationError() + _sha256(self.adapter_build_sha256) + if not _COMPATIBILITY.fullmatch(self.compatibility_id): + raise ProtocolValidationError() + + def to_wire(self) -> dict[str, Any]: + return { + "runtime_contract_version": self.runtime_contract_version, + "adapter_build_sha256": self.adapter_build_sha256, + "template": self.template, + "policy": self.policy.to_wire(), + "compatibility_id": self.compatibility_id, + } + + +@dataclass(frozen=True, slots=True) +class PolicyDocument: + media_type: str + document: bytes = field(repr=False) + + def __post_init__(self) -> None: + if not self.media_type or not self.document: + raise ProtocolValidationError() + + def to_wire(self) -> dict[str, Any]: + return { + "media_type": self.media_type, + "document_base64": base64.b64encode(self.document).decode("ascii"), + } + + +@dataclass(frozen=True, slots=True) +class CreateRequest: + request_id: str + template: str + policy_document: PolicyDocument + expected_policy: PolicyIdentity + + def __post_init__(self) -> None: + request_owned_id(self.request_id) + if not self.template: + raise ProtocolValidationError() + + def to_wire(self) -> dict[str, Any]: + return { + "request_id": self.request_id, + "template": self.template, + "policy_document": self.policy_document.to_wire(), + "expected_policy": self.expected_policy.to_wire(), + } + + +@dataclass(frozen=True, slots=True) +class OutputLimits: + stdout_bytes: int + stderr_bytes: int + combined_bytes: int + chunk_bytes: int + + def __post_init__(self) -> None: + if ( + min( + self.stdout_bytes, + self.stderr_bytes, + self.combined_bytes, + self.chunk_bytes, + ) + <= 0 + ): + raise ProtocolValidationError() + + def to_wire(self) -> dict[str, int]: + return { + "stdout_bytes": self.stdout_bytes, + "stderr_bytes": self.stderr_bytes, + "combined_bytes": self.combined_bytes, + "chunk_bytes": self.chunk_bytes, + } + + +@dataclass(frozen=True, slots=True, repr=False) +class ExecRequest: + argv: tuple[str, ...] + timeout: int + output_limits: OutputLimits + + def __init__( + self, + argv: Sequence[str], + timeout: int, + output_limits: OutputLimits, + ) -> None: + values = tuple(argv) + if ( + not values + or not 1 <= timeout <= 300 + or not all(isinstance(value, str) for value in values) + ): + raise ProtocolValidationError() + object.__setattr__(self, "argv", values) + object.__setattr__(self, "timeout", timeout) + object.__setattr__(self, "output_limits", output_limits) + + def __repr__(self) -> str: + return ( + "ExecRequest(argv=, " + f"argv_count={len(self.argv)}, timeout={self.timeout}, " + f"output_limits={self.output_limits!r})" + ) + + def to_wire(self) -> dict[str, Any]: + return { + "argv": list(self.argv), + "timeout": self.timeout, + "output_limits": self.output_limits.to_wire(), + } + + +@dataclass(frozen=True, slots=True, repr=False) +class ExecCompleted: + exit_code: int + stdout: bytes + stderr: bytes + timeout: str + + def __repr__(self) -> str: + return ( + f"ExecCompleted(exit_code={self.exit_code}, " + f"stdout_bytes={len(self.stdout)}, stderr_bytes={len(self.stderr)}, " + f"timeout={self.timeout!r})" + ) + + @classmethod + def from_wire(cls, value: Mapping[str, Any]) -> ExecCompleted: + if set(value) != {"exit_code", "stdout_base64", "stderr_base64", "timeout"}: + raise ProtocolValidationError() + exit_code = value["exit_code"] + timeout = value["timeout"] + if not isinstance(exit_code, int) or exit_code < 0: + raise ProtocolValidationError() + if timeout not in {"not_observed", "confirmed", "possible"}: + raise ProtocolValidationError() + try: + stdout = base64.b64decode(value["stdout_base64"], validate=True) + stderr = base64.b64decode(value["stderr_base64"], validate=True) + except (ValueError, TypeError) as error: + raise ProtocolValidationError() from error + return cls(exit_code=exit_code, stdout=stdout, stderr=stderr, timeout=timeout) + + +@dataclass(frozen=True, slots=True) +class ServiceResponse: + response: str + fields: Mapping[str, Any] From 396de80b74760c6cec435fd38da7950ff4df93cf Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Mon, 10 Aug 2026 12:50:58 +0700 Subject: [PATCH 06/20] test: adapt retry_plan parsing test to the patch vocabulary --- tests/contracts/test_result_parsing.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/contracts/test_result_parsing.py b/tests/contracts/test_result_parsing.py index 7556e3e..b00b62e 100644 --- a/tests/contracts/test_result_parsing.py +++ b/tests/contracts/test_result_parsing.py @@ -188,12 +188,12 @@ def test_preserves_unknown_fields_without_changing_action_compatibility(self): assert result.action == "continue" assert result.raw["future"] == {"x": 1} - def test_preserves_retry_plan_parsing(self): + def test_preserves_patch_parsing(self): result = EvaluationResult.from_wire( - b'{"verdict":"block","retry_plan":{"new_input":{"attempt":2}}}' + b'{"verdict":"block","patch":{"new_input":{"attempt":2}}}' ) - assert result.retry_plan is not None - assert result.retry_plan.new_input == {"attempt": 2} + assert result.patch is not None + assert result.patch.new_input == {"attempt": 2} class TestGuardrailsResult: From 1b5dacd2ba67f6df41d536d4353f58797ca205f8 Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Mon, 10 Aug 2026 14:23:34 +0700 Subject: [PATCH 07/20] lint: resolve ruff violations (UP035, UP017, UP037, B905, B009/B018, W292, I001) --- openbox_sandbox/command.py | 2 +- openbox_sandbox/command_profiles.py | 31 ++++++++++--------- openbox_sandbox/contracts.py | 7 +++-- openbox_sandbox/deployment.py | 4 +-- openbox_sandbox/dispatcher/_host.py | 2 +- openbox_sandbox/dispatcher/command.py | 3 +- openbox_sandbox/dispatcher/dispatcher.py | 17 +++++----- openbox_sandbox/dispatcher/governance.py | 3 +- openbox_sandbox/dispatcher/profiles.py | 22 +++++++------ openbox_sandbox/dispatcher/result.py | 3 +- openbox_sandbox/dispatcher/telemetry.py | 3 +- openbox_sandbox/engine.py | 13 ++++---- openbox_sandbox/profiles.py | 22 +++++++------ openbox_sandbox/receipts.py | 17 +++++----- openbox_sandbox/registry.py | 11 ++++--- openbox_sandbox/result.py | 3 +- openbox_sandbox/runtime/agent_client.py | 3 +- openbox_sandbox/runtime/agent_server.py | 3 +- openbox_sandbox/runtime/client.py | 3 +- openbox_sandbox/runtime/env.py | 2 +- openbox_sandbox/runtime/types.py | 6 ++-- .../runtime_client/agent_client.py | 3 +- .../runtime_client/agent_server.py | 3 +- openbox_sandbox/runtime_client/client.py | 3 +- openbox_sandbox/runtime_client/types.py | 3 +- openbox_sandbox/telemetry.py | 3 +- tests/sandbox/helpers.py | 5 ++- tests/sandbox/test_agent_protocol.py | 3 +- tests/sandbox/test_engine.py | 1 - tests/sandbox/test_governed_receipts.py | 4 +-- tests/sandbox/test_receipt_issuance.py | 4 +-- tests/sandbox/test_registry.py | 4 +-- tests/sandbox/test_release.py | 2 +- tests/sandbox/test_sandbox_profiles.py | 6 ++-- 34 files changed, 126 insertions(+), 98 deletions(-) diff --git a/openbox_sandbox/command.py b/openbox_sandbox/command.py index 329565b..7f0438d 100644 --- a/openbox_sandbox/command.py +++ b/openbox_sandbox/command.py @@ -1,7 +1,7 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import Mapping, Sequence from openbox_core.contracts.context import ActivityContext diff --git a/openbox_sandbox/command_profiles.py b/openbox_sandbox/command_profiles.py index 9c841d3..c8576d4 100644 --- a/openbox_sandbox/command_profiles.py +++ b/openbox_sandbox/command_profiles.py @@ -6,10 +6,11 @@ import hmac import json import re +from collections.abc import Mapping, Sequence from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from types import MappingProxyType -from typing import Any, Mapping, Sequence +from typing import Any from .contracts import ( GovernedCommandInputError, @@ -87,7 +88,7 @@ def _timestamp(value: object) -> datetime: parsed = datetime.fromisoformat(value[:-1] + "+00:00") except ValueError: raise CommandProfileBundleError() from None - return parsed.astimezone(timezone.utc) + return parsed.astimezone(UTC) @dataclass(frozen=True) @@ -101,7 +102,7 @@ class _ArgumentMapping: max_bytes: int | None = None @classmethod - def parse(cls, value: object) -> "_ArgumentMapping": + def parse(cls, value: object) -> _ArgumentMapping: if not isinstance(value, dict) or not isinstance(value.get("kind"), str): raise CommandProfileBundleError() kind = value["kind"] @@ -219,7 +220,7 @@ class _ResultField: max_bytes: int | None = None @classmethod - def parse(cls, value: object) -> "_ResultField": + def parse(cls, value: object) -> _ResultField: if not isinstance(value, dict) or not isinstance(value.get("kind"), str): raise CommandProfileBundleError() kind = value["kind"] @@ -266,7 +267,7 @@ class _ResultSchema: fields: tuple[_ResultField, ...] @classmethod - def parse(cls, value: object) -> "_ResultSchema": + def parse(cls, value: object) -> _ResultSchema: item = _object(value, {"name", "max_bytes", "fields"}) name, maximum, raw_fields = item["name"], item["max_bytes"], item["fields"] if ( @@ -421,7 +422,7 @@ def from_trusted( expires_at: datetime, profiles: Sequence[Mapping[str, Any]], now: datetime, - ) -> "StructuredCommandProfileBundle": + ) -> StructuredCommandProfileBundle: """Build immutable mappings from profiles owned by this process.""" return _trusted_bundle( cls, @@ -440,7 +441,7 @@ def load( secret: bytes, expected_key_id: str, now: datetime | None = None, - ) -> "StructuredCommandProfileBundle": + ) -> StructuredCommandProfileBundle: if not isinstance(secret, bytes) or len(secret) < 32 or not expected_key_id: raise CommandProfileBundleError() body = document.encode() if isinstance(document, str) else document @@ -494,7 +495,7 @@ def load( _timestamp(payload["issued_at"]), _timestamp(payload["expires_at"]), ) - current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + current = (now or datetime.now(UTC)).astimezone(UTC) if issued > current or expires <= current or issued >= expires: raise CommandProfileBundleError() profile_values = payload["profiles"] @@ -517,7 +518,7 @@ def profile_ids(self) -> tuple[str, ...]: def derive( self, request: GovernedCommandRequest, *, now: datetime | None = None ) -> tuple[str, ...]: - current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + current = (now or datetime.now(UTC)).astimezone(UTC) profile = self._profiles.get(request.profile_id) if profile is None or not self.issued_at <= current < self.expires_at: raise GovernedCommandInputError("governed command input rejected") @@ -525,7 +526,7 @@ def derive( def profile_fingerprint(self, profile_id: str, *, now: datetime | None = None) -> str: """Return a stable identity for one validated profile definition.""" - current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + current = (now or datetime.now(UTC)).astimezone(UTC) profile = self._profiles.get(profile_id) if profile is None or not self.issued_at <= current < self.expires_at: raise GovernedCommandInputError("governed command input rejected") @@ -539,7 +540,7 @@ def parse_result( now: datetime | None = None, ) -> GovernedCommandTypedResult | None: """Return only profile-admitted values, never the raw output body.""" - current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + current = (now or datetime.now(UTC)).astimezone(UTC) profile = self._profiles.get(profile_id) if profile is None or not self.issued_at <= current < self.expires_at: raise CommandResultValidationError() @@ -570,9 +571,9 @@ def _trusted_bundle( or len(profiles) > 1024 ): raise CommandProfileBundleError() - issued = issued_at.astimezone(timezone.utc) - expires = expires_at.astimezone(timezone.utc) - current = now.astimezone(timezone.utc) + issued = issued_at.astimezone(UTC) + expires = expires_at.astimezone(UTC) + current = now.astimezone(UTC) if issued > current or expires <= current or issued >= expires: raise CommandProfileBundleError() diff --git a/openbox_sandbox/contracts.py b/openbox_sandbox/contracts.py index e073da1..8a0ad8d 100644 --- a/openbox_sandbox/contracts.py +++ b/openbox_sandbox/contracts.py @@ -3,8 +3,9 @@ from __future__ import annotations import re +from collections.abc import Mapping from dataclasses import dataclass -from typing import Any, Mapping +from typing import Any GOVERNED_COMMAND_ACTIVITY_TYPE = "openbox_governed_command" _MAX_ARGUMENTS = 64 @@ -49,7 +50,7 @@ class GovernedCommandReceipt: signature: str @classmethod - def from_value(cls, value: Any) -> "GovernedCommandReceipt": + def from_value(cls, value: Any) -> GovernedCommandReceipt: if isinstance(value, cls): return value if not isinstance(value, dict) or set(value) != { @@ -140,7 +141,7 @@ def to_history_value(self) -> dict[str, Any]: return value @classmethod - def from_value(cls, value: Any) -> "GovernedCommandRequest": + def from_value(cls, value: Any) -> GovernedCommandRequest: if isinstance(value, cls): return value if not isinstance(value, dict) or set(value) not in ( diff --git a/openbox_sandbox/deployment.py b/openbox_sandbox/deployment.py index b7539d4..2b4e14a 100644 --- a/openbox_sandbox/deployment.py +++ b/openbox_sandbox/deployment.py @@ -12,7 +12,7 @@ import re import uuid from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -493,7 +493,7 @@ def _load_sandbox_deployment( engine = SandboxExecutionEngine._from_components( engine_config, sandbox=runtime, - clock=lambda: datetime.now(timezone.utc), + clock=lambda: datetime.now(UTC), sandbox_id=lambda: f"sbx-{uuid.uuid4().hex[:15]}", ) config = SandboxDeploymentConfig( diff --git a/openbox_sandbox/dispatcher/_host.py b/openbox_sandbox/dispatcher/_host.py index 4ac1eda..70650cc 100644 --- a/openbox_sandbox/dispatcher/_host.py +++ b/openbox_sandbox/dispatcher/_host.py @@ -2,9 +2,9 @@ import asyncio import os +from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path -from typing import Mapping from .errors import DispatchErrorCode from .result import TimeoutStatus diff --git a/openbox_sandbox/dispatcher/command.py b/openbox_sandbox/dispatcher/command.py index bb64550..0a436ab 100644 --- a/openbox_sandbox/dispatcher/command.py +++ b/openbox_sandbox/dispatcher/command.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import Any, Mapping, Sequence +from typing import Any from .errors import DispatcherValidationError diff --git a/openbox_sandbox/dispatcher/dispatcher.py b/openbox_sandbox/dispatcher/dispatcher.py index f2c0ceb..4b01e87 100644 --- a/openbox_sandbox/dispatcher/dispatcher.py +++ b/openbox_sandbox/dispatcher/dispatcher.py @@ -5,10 +5,11 @@ import re import time import uuid +from collections.abc import Callable, Mapping from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Any, Callable, Mapping +from typing import Any from openbox_sandbox.runtime_client import ( AssetBundleIdentity, @@ -200,7 +201,7 @@ def __init__(self, config: DispatcherConfig) -> None: _HostExecutor( _HostConfig(config.host_workdir, environment=config.host_environment) ), - lambda: datetime.now(timezone.utc), + lambda: datetime.now(UTC), time.monotonic, generate_request_owned_id, ) @@ -251,7 +252,7 @@ async def dispatch(self, command: GovernedCommand) -> DispatchResult: profile_failure = await self._admit(command) if profile_failure is not None: return profile_failure - now = self._clock().astimezone(timezone.utc) + now = self._clock().astimezone(UTC) event = _activity_started(command, now) try: if self._governance is None: @@ -366,7 +367,7 @@ async def dispatch_authorized_constrain( async def _admit(self, command: GovernedCommand) -> DispatchResult | None: if not isinstance(command, GovernedCommand): raise TypeError("dispatch accepts GovernedCommand only") - now = self._clock().astimezone(timezone.utc) + now = self._clock().astimezone(UTC) if self._config.profiles.admits(command.profile_id, command.argv, now=now): return None return await self._terminal( @@ -1388,15 +1389,15 @@ def _image_digest(template: str) -> str: def _epoch_ns(value: datetime) -> int: - return int(value.astimezone(timezone.utc).timestamp() * 1_000_000_000) + return int(value.astimezone(UTC).timestamp() * 1_000_000_000) def _iso8601_ns(value: int) -> str: - return _iso8601(datetime.fromtimestamp(value / 1_000_000_000, timezone.utc)) + return _iso8601(datetime.fromtimestamp(value / 1_000_000_000, UTC)) def _iso8601(value: datetime) -> str: - return value.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + return value.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") def _duration_ms(started: float, finished: float) -> int: diff --git a/openbox_sandbox/dispatcher/governance.py b/openbox_sandbox/dispatcher/governance.py index 1aa391c..45b5da1 100644 --- a/openbox_sandbox/dispatcher/governance.py +++ b/openbox_sandbox/dispatcher/governance.py @@ -12,10 +12,11 @@ import urllib.parse import urllib.request import uuid +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any, Mapping, Protocol +from typing import Any, Protocol from .errors import GovernanceProtocolError, GovernanceTransportError diff --git a/openbox_sandbox/dispatcher/profiles.py b/openbox_sandbox/dispatcher/profiles.py index afaae62..33e40ab 100644 --- a/openbox_sandbox/dispatcher/profiles.py +++ b/openbox_sandbox/dispatcher/profiles.py @@ -4,10 +4,11 @@ import hmac import json import re +from collections.abc import Mapping, Sequence from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from types import MappingProxyType -from typing import Any, Mapping, Sequence +from typing import Any from .errors import ProfileValidationError @@ -38,7 +39,7 @@ def _timestamp(value: object) -> datetime: raise ProfileValidationError() from None if parsed.tzinfo is None: raise ProfileValidationError() - return parsed.astimezone(timezone.utc) + return parsed.astimezone(UTC) def _canonical(value: object) -> bytes: @@ -150,7 +151,10 @@ def admits(self, argv: Sequence[str]) -> bool: and not self.free_form and len(argv) == len(self.arguments) + 1 and argv[0] == self.executable - and all(rule.accepts(value) for rule, value in zip(self.arguments, argv[1:])) + and all( + rule.accepts(value) + for rule, value in zip(self.arguments, argv[1:], strict=True) + ) ) @@ -243,7 +247,7 @@ def load( raise ProfileValidationError() issued_at = _timestamp(payload["issued_at"]) expires_at = _timestamp(payload["expires_at"]) - current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + current = (now or datetime.now(UTC)).astimezone(UTC) if issued_at > current or expires_at <= current or issued_at >= expires_at: raise ProfileValidationError() profile_values = payload["profiles"] @@ -278,7 +282,7 @@ def profile_ids(self) -> tuple[str, ...]: return tuple(sorted(self._profiles)) def admits(self, profile_id: str, argv: Sequence[str], *, now: datetime) -> bool: - current = now.astimezone(timezone.utc) + current = now.astimezone(UTC) profile = self._profiles.get(profile_id) return ( self.issued_at <= current < self.expires_at @@ -341,9 +345,9 @@ def _trusted_bundle( or len(profiles) > 1024 ): raise ProfileValidationError() - issued = issued_at.astimezone(timezone.utc) - expires = expires_at.astimezone(timezone.utc) - current = now.astimezone(timezone.utc) + issued = issued_at.astimezone(UTC) + expires = expires_at.astimezone(UTC) + current = now.astimezone(UTC) if issued > current or expires <= current or issued >= expires: raise ProfileValidationError() parsed: dict[str, CommandProfile] = {} diff --git a/openbox_sandbox/dispatcher/result.py b/openbox_sandbox/dispatcher/result.py index e40448c..afda147 100644 --- a/openbox_sandbox/dispatcher/result.py +++ b/openbox_sandbox/dispatcher/result.py @@ -2,9 +2,10 @@ import base64 import copy +from collections.abc import Mapping from dataclasses import dataclass from enum import Enum -from typing import Any, Mapping +from typing import Any from .errors import NormalizedDispatchError diff --git a/openbox_sandbox/dispatcher/telemetry.py b/openbox_sandbox/dispatcher/telemetry.py index 0e98961..70b4344 100644 --- a/openbox_sandbox/dispatcher/telemetry.py +++ b/openbox_sandbox/dispatcher/telemetry.py @@ -5,10 +5,11 @@ import os import stat import tempfile +from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import Any, AsyncIterator, Protocol +from typing import Any, Protocol try: import fcntl diff --git a/openbox_sandbox/engine.py b/openbox_sandbox/engine.py index 70fc8d3..e6238a7 100644 --- a/openbox_sandbox/engine.py +++ b/openbox_sandbox/engine.py @@ -2,10 +2,11 @@ import asyncio import uuid +from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Any, Callable +from typing import Any from .authorization import SandboxAuthorization from .command import SandboxCommand @@ -182,7 +183,7 @@ def __init__(self, config: SandboxEngineConfig) -> None: self._configure( config, runtime, - lambda: datetime.now(timezone.utc), + lambda: datetime.now(UTC), lambda: f"sbx-{uuid.uuid4()}", ) @@ -194,7 +195,7 @@ def _from_components( sandbox: Any, clock: Callable[[], datetime], sandbox_id: Callable[[], str] = lambda: f"sbx-{uuid.uuid4()}", - ) -> "SandboxExecutionEngine": + ) -> SandboxExecutionEngine: instance = cls.__new__(cls) instance._configure(config, sandbox, clock, sandbox_id) return instance @@ -234,7 +235,7 @@ async def execute( authorization, SandboxAuthorization ): raise TypeError("authorized sandbox execution rejected") - now = self._clock().astimezone(timezone.utc) + now = self._clock().astimezone(UTC) if not self._config.profiles.admits(command.profile_id, command.argv, now=now): return await self._terminal( command, @@ -770,7 +771,7 @@ async def _emit( def _iso8601(value: datetime) -> str: - return value.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + return value.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") def _timeout_status(value: str) -> TimeoutStatus: diff --git a/openbox_sandbox/profiles.py b/openbox_sandbox/profiles.py index 26a720f..9c38eb0 100644 --- a/openbox_sandbox/profiles.py +++ b/openbox_sandbox/profiles.py @@ -4,10 +4,11 @@ import hmac import json import re +from collections.abc import Mapping, Sequence from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from types import MappingProxyType -from typing import Any, Mapping, Sequence +from typing import Any from .errors import ProfileValidationError @@ -38,7 +39,7 @@ def _timestamp(value: object) -> datetime: raise ProfileValidationError() from None if parsed.tzinfo is None: raise ProfileValidationError() - return parsed.astimezone(timezone.utc) + return parsed.astimezone(UTC) def _canonical(value: object) -> bytes: @@ -150,7 +151,10 @@ def admits(self, argv: Sequence[str]) -> bool: and not self.free_form and len(argv) == len(self.arguments) + 1 and argv[0] == self.executable - and all(rule.accepts(value) for rule, value in zip(self.arguments, argv[1:])) + and all( + rule.accepts(value) + for rule, value in zip(self.arguments, argv[1:], strict=True) + ) ) @@ -243,7 +247,7 @@ def load( raise ProfileValidationError() issued_at = _timestamp(payload["issued_at"]) expires_at = _timestamp(payload["expires_at"]) - current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + current = (now or datetime.now(UTC)).astimezone(UTC) if issued_at > current or expires_at <= current or issued_at >= expires_at: raise ProfileValidationError() profile_values = payload["profiles"] @@ -278,7 +282,7 @@ def profile_ids(self) -> tuple[str, ...]: return tuple(sorted(self._profiles)) def admits(self, profile_id: str, argv: Sequence[str], *, now: datetime) -> bool: - current = now.astimezone(timezone.utc) + current = now.astimezone(UTC) profile = self._profiles.get(profile_id) return ( self.issued_at <= current < self.expires_at @@ -341,9 +345,9 @@ def _trusted_bundle( or len(profiles) > 1024 ): raise ProfileValidationError() - issued = issued_at.astimezone(timezone.utc) - expires = expires_at.astimezone(timezone.utc) - current = now.astimezone(timezone.utc) + issued = issued_at.astimezone(UTC) + expires = expires_at.astimezone(UTC) + current = now.astimezone(UTC) if issued > current or expires <= current or issued >= expires: raise ProfileValidationError() parsed: dict[str, CommandProfile] = {} diff --git a/openbox_sandbox/receipts.py b/openbox_sandbox/receipts.py index 47eccc7..cc01e3e 100644 --- a/openbox_sandbox/receipts.py +++ b/openbox_sandbox/receipts.py @@ -11,9 +11,10 @@ import re import secrets import threading +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Callable, Mapping, Protocol, Sequence +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, Any, Protocol if TYPE_CHECKING: from .registry import GovernedCommandRegistry @@ -56,7 +57,7 @@ def _timestamp(value: object) -> datetime: raise GovernedCommandReceiptError("governed command receipt rejected") from error if parsed.tzinfo is None: raise GovernedCommandReceiptError("governed command receipt rejected") - return parsed.astimezone(timezone.utc) + return parsed.astimezone(UTC) def request_arguments_sha256(request: GovernedCommandRequest) -> str: @@ -245,7 +246,7 @@ def _validate_common_time_window( now = clock() if not isinstance(now, datetime) or now.tzinfo is None: raise GovernedCommandReceiptError(errors.verifier_rejected) - now = now.astimezone(timezone.utc) + now = now.astimezone(UTC) lifetime = expires_at - issued_at if ( issued_at > now @@ -279,7 +280,7 @@ class GovernedCommandReceiptVerifier: key_id: str public_key: bytes - clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc) + clock: Callable[[], datetime] = lambda: datetime.now(UTC) _consumed_receipt_ids: set[str] = field(default_factory=set, init=False, repr=False) _consumed_nonces: set[str] = field(default_factory=set, init=False, repr=False) _consumption_lock: threading.Lock = field( @@ -364,7 +365,7 @@ class InsecureLocalReceiptVerifier: empty unsigned value) is ignored. Never use this verifier in production. """ - clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc) + clock: Callable[[], datetime] = lambda: datetime.now(UTC) _consumed_receipt_ids: set[str] = field(default_factory=set, init=False, repr=False) _consumed_nonces: set[str] = field(default_factory=set, init=False, repr=False) _consumption_lock: threading.Lock = field( @@ -484,10 +485,10 @@ def issue_sandbox_receipt( seconds = ttl.total_seconds() if not seconds.is_integer() or not 1 <= seconds <= _MAX_RECEIPT_LIFETIME.total_seconds(): raise GovernedCommandReceiptError("governed command receipt issuance rejected") - current = now or datetime.now(timezone.utc) + current = now or datetime.now(UTC) if not isinstance(current, datetime) or current.tzinfo is None: raise GovernedCommandReceiptError("governed command receipt issuance rejected") - issued_at = current.astimezone(timezone.utc).replace(microsecond=0) + issued_at = current.astimezone(UTC).replace(microsecond=0) expires_at = issued_at + ttl profiles = registry.structured_profile_bundle() diff --git a/openbox_sandbox/registry.py b/openbox_sandbox/registry.py index 4a2bbd1..e68c040 100644 --- a/openbox_sandbox/registry.py +++ b/openbox_sandbox/registry.py @@ -13,10 +13,11 @@ import hashlib import json import re +from collections.abc import Mapping from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Mapping +from typing import TYPE_CHECKING, Any from .command_profiles import ( StructuredCommandProfileBundle, @@ -44,8 +45,8 @@ ) # The registry is process-lifetime configuration. Both derived bundles use one # fixed validity window so their canonical fingerprints stay deterministic. -_REGISTRY_ISSUED_AT = datetime(2000, 1, 1, tzinfo=timezone.utc) -_REGISTRY_EXPIRES_AT = datetime(9999, 1, 1, tzinfo=timezone.utc) +_REGISTRY_ISSUED_AT = datetime(2000, 1, 1, tzinfo=UTC) +_REGISTRY_EXPIRES_AT = datetime(9999, 1, 1, tzinfo=UTC) _REGISTRY_KEY_ID = "typed-registry" @@ -427,7 +428,7 @@ def structured_profile_bundle(self) -> StructuredCommandProfileBundle: object.__setattr__(bundle, "_profiles", MappingProxyType(profiles)) return bundle - def admission_profile_bundle(self) -> "CommandProfileBundle": + def admission_profile_bundle(self) -> CommandProfileBundle: """Build the independent engine admission bundle.""" from .profiles import ( ArgumentRule, diff --git a/openbox_sandbox/result.py b/openbox_sandbox/result.py index d6df5ba..e573ad5 100644 --- a/openbox_sandbox/result.py +++ b/openbox_sandbox/result.py @@ -2,9 +2,10 @@ import base64 import copy +from collections.abc import Mapping from dataclasses import dataclass from enum import Enum -from typing import Any, Mapping +from typing import Any from .errors import NormalizedSandboxError diff --git a/openbox_sandbox/runtime/agent_client.py b/openbox_sandbox/runtime/agent_client.py index 6922d51..4080c2b 100644 --- a/openbox_sandbox/runtime/agent_client.py +++ b/openbox_sandbox/runtime/agent_client.py @@ -24,9 +24,10 @@ import stat import struct import sys +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Any, Mapping, NoReturn +from typing import Any, NoReturn from .client import MAX_REQUEST_BYTES, MAX_RESPONSE_BYTES, PROTOCOL_VERSION from .errors import ( diff --git a/openbox_sandbox/runtime/agent_server.py b/openbox_sandbox/runtime/agent_server.py index 764d9b0..671d59b 100644 --- a/openbox_sandbox/runtime/agent_server.py +++ b/openbox_sandbox/runtime/agent_server.py @@ -17,9 +17,10 @@ import signal import stat import struct +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Any, Mapping, NoReturn +from typing import Any, NoReturn from .._trusted_files import load_strict_json, validate_trusted_file from ..errors import GovernedCommandDeploymentError diff --git a/openbox_sandbox/runtime/client.py b/openbox_sandbox/runtime/client.py index 56c79e5..8100e56 100644 --- a/openbox_sandbox/runtime/client.py +++ b/openbox_sandbox/runtime/client.py @@ -5,9 +5,10 @@ import json import ssl import struct +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Any, Mapping +from typing import Any from .errors import ( ProtocolValidationError, diff --git a/openbox_sandbox/runtime/env.py b/openbox_sandbox/runtime/env.py index f5b02cd..786bdf0 100644 --- a/openbox_sandbox/runtime/env.py +++ b/openbox_sandbox/runtime/env.py @@ -169,4 +169,4 @@ def load_local_sandbox_env() -> LocalSandboxEnv: "EnvLoadError", "LocalSandboxEnv", "load_local_sandbox_env", -] \ No newline at end of file +] diff --git a/openbox_sandbox/runtime/types.py b/openbox_sandbox/runtime/types.py index ef4f0be..0c53bbc 100644 --- a/openbox_sandbox/runtime/types.py +++ b/openbox_sandbox/runtime/types.py @@ -3,8 +3,9 @@ import base64 import re import uuid +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field -from typing import Any, Mapping, Sequence +from typing import Any from .errors import ProtocolValidationError @@ -198,7 +199,8 @@ def __post_init__(self) -> None: _MAX_CHUNK_BYTES, ) if any(type(value) is not int for value in values) or any( - not 1 <= value <= maximum for value, maximum in zip(values, maxima) + not 1 <= value <= maximum + for value, maximum in zip(values, maxima, strict=True) ): raise ProtocolValidationError() diff --git a/openbox_sandbox/runtime_client/agent_client.py b/openbox_sandbox/runtime_client/agent_client.py index 6922d51..4080c2b 100644 --- a/openbox_sandbox/runtime_client/agent_client.py +++ b/openbox_sandbox/runtime_client/agent_client.py @@ -24,9 +24,10 @@ import stat import struct import sys +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Any, Mapping, NoReturn +from typing import Any, NoReturn from .client import MAX_REQUEST_BYTES, MAX_RESPONSE_BYTES, PROTOCOL_VERSION from .errors import ( diff --git a/openbox_sandbox/runtime_client/agent_server.py b/openbox_sandbox/runtime_client/agent_server.py index cd4d52e..94fe910 100644 --- a/openbox_sandbox/runtime_client/agent_server.py +++ b/openbox_sandbox/runtime_client/agent_server.py @@ -17,9 +17,10 @@ import signal import stat import struct +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Any, Mapping, NoReturn +from typing import Any, NoReturn from .agent_client import ( AGENT_PROTOCOL_VERSION, diff --git a/openbox_sandbox/runtime_client/client.py b/openbox_sandbox/runtime_client/client.py index 56c79e5..8100e56 100644 --- a/openbox_sandbox/runtime_client/client.py +++ b/openbox_sandbox/runtime_client/client.py @@ -5,9 +5,10 @@ import json import ssl import struct +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Any, Mapping +from typing import Any from .errors import ( ProtocolValidationError, diff --git a/openbox_sandbox/runtime_client/types.py b/openbox_sandbox/runtime_client/types.py index 3e47a22..79f44de 100644 --- a/openbox_sandbox/runtime_client/types.py +++ b/openbox_sandbox/runtime_client/types.py @@ -3,8 +3,9 @@ import base64 import re import uuid +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field -from typing import Any, Mapping, Sequence +from typing import Any from .errors import ProtocolValidationError diff --git a/openbox_sandbox/telemetry.py b/openbox_sandbox/telemetry.py index d29bab9..03e05eb 100644 --- a/openbox_sandbox/telemetry.py +++ b/openbox_sandbox/telemetry.py @@ -6,10 +6,11 @@ import stat import tempfile import uuid +from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import Any, AsyncIterator, Protocol +from typing import Any, Protocol try: import fcntl diff --git a/tests/sandbox/helpers.py b/tests/sandbox/helpers.py index 552a394..a929cc2 100644 --- a/tests/sandbox/helpers.py +++ b/tests/sandbox/helpers.py @@ -1,12 +1,11 @@ from __future__ import annotations import base64 -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any from openbox_core.contracts.context import ActivityContext - from openbox_sandbox import ( CommandProfileBundle, InMemoryTelemetrySink, @@ -26,7 +25,7 @@ ) from openbox_sandbox.telemetry import CleanupBacklog, TelemetrySink -NOW = datetime(2026, 7, 17, tzinfo=timezone.utc) +NOW = datetime(2026, 7, 17, tzinfo=UTC) SECRET = b"0123456789abcdef0123456789abcdef" KEY_ID = "profiles-2026-01" SANDBOX_ID = "sbx-550e8400-e29b-41d4-a716-446655440000" diff --git a/tests/sandbox/test_agent_protocol.py b/tests/sandbox/test_agent_protocol.py index c9a94da..9d5a77f 100644 --- a/tests/sandbox/test_agent_protocol.py +++ b/tests/sandbox/test_agent_protocol.py @@ -5,8 +5,9 @@ import stat import tempfile import unittest +from collections.abc import Mapping from pathlib import Path -from typing import Any, Mapping +from typing import Any from openbox_sandbox.runtime import ( AssetBundleIdentity, diff --git a/tests/sandbox/test_engine.py b/tests/sandbox/test_engine.py index 433a8bb..377d569 100644 --- a/tests/sandbox/test_engine.py +++ b/tests/sandbox/test_engine.py @@ -7,7 +7,6 @@ from pathlib import Path from openbox_core.contracts.context import ActivityContext - from openbox_sandbox import ( CleanupBacklog, CleanupStatus, diff --git a/tests/sandbox/test_governed_receipts.py b/tests/sandbox/test_governed_receipts.py index 59e09d1..7e7e0fb 100644 --- a/tests/sandbox/test_governed_receipts.py +++ b/tests/sandbox/test_governed_receipts.py @@ -4,7 +4,7 @@ import json from concurrent.futures import ThreadPoolExecutor from dataclasses import asdict -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Any import pytest @@ -24,7 +24,7 @@ receipt_payload, ) -NOW = datetime(2026, 7, 21, tzinfo=timezone.utc) +NOW = datetime(2026, 7, 21, tzinfo=UTC) WORKFLOW_ID = "workflow-reconcile-1" COMMAND_ARGV = ("/usr/bin/safe", "--job", "job-1", "--count", "7") ASSET_BUNDLE = { diff --git a/tests/sandbox/test_receipt_issuance.py b/tests/sandbox/test_receipt_issuance.py index 29ac8cb..52db18f 100644 --- a/tests/sandbox/test_receipt_issuance.py +++ b/tests/sandbox/test_receipt_issuance.py @@ -1,7 +1,7 @@ from __future__ import annotations import inspect -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path import pytest @@ -21,7 +21,7 @@ from .deployment_helpers import prepare_files, registry -NOW = datetime(2026, 7, 23, 3, 0, tzinfo=timezone.utc) +NOW = datetime(2026, 7, 23, 3, 0, tzinfo=UTC) class ExternalSigner: diff --git a/tests/sandbox/test_registry.py b/tests/sandbox/test_registry.py index b7a351e..9d9b5ec 100644 --- a/tests/sandbox/test_registry.py +++ b/tests/sandbox/test_registry.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest @@ -67,7 +67,7 @@ def test_registry_derives_bound_input_and_admission_from_one_identity() -> None: "--threshold", "70", ) - assert admission.admits("reconcile", argv, now=datetime.now(timezone.utc)) + assert admission.admits("reconcile", argv, now=datetime.now(UTC)) assert structured.fingerprint == admission.fingerprint == value.fingerprint assert structured.bundle_version == admission.bundle_version == value.bundle_version diff --git a/tests/sandbox/test_release.py b/tests/sandbox/test_release.py index 17fdae3..74105a8 100644 --- a/tests/sandbox/test_release.py +++ b/tests/sandbox/test_release.py @@ -49,7 +49,7 @@ def test_release_fails_closed_until_explicitly_loaded() -> None: def test_public_mutable_release_installer_is_not_exported() -> None: assert "install_approved_sandbox_release" not in openbox_sandbox.__all__ with pytest.raises(AttributeError): - getattr(openbox_sandbox, "install_approved_sandbox_release") + _ = openbox_sandbox.install_approved_sandbox_release @pytest.mark.parametrize( diff --git a/tests/sandbox/test_sandbox_profiles.py b/tests/sandbox/test_sandbox_profiles.py index 7a9d937..6ab6558 100644 --- a/tests/sandbox/test_sandbox_profiles.py +++ b/tests/sandbox/test_sandbox_profiles.py @@ -1,7 +1,7 @@ from __future__ import annotations from copy import deepcopy -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Any import pytest @@ -105,13 +105,13 @@ def test_generic_input_rejects_raw_action_and_sensitive_field_names() -> None: sign(structured_payload()), SECRET, KEY_ID, - datetime(2025, 1, 1, tzinfo=timezone.utc), + datetime(2025, 1, 1, tzinfo=UTC), ), ( sign(structured_payload()), SECRET, KEY_ID, - datetime(2028, 1, 1, tzinfo=timezone.utc), + datetime(2028, 1, 1, tzinfo=UTC), ), (sign(structured_payload(sensitive=True)), SECRET, KEY_ID, NOW), (sign(structured_payload(free_form=True)), SECRET, KEY_ID, NOW), From a7c6afc17b31ee1774f1178c963d6bcd96f6db12 Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Tue, 11 Aug 2026 23:17:46 +0700 Subject: [PATCH 08/20] fix: propagate the transport error detail through the sandbox create failure The dispatcher swallowed the underlying error and returned a generic sandbox_create_failed. Now the NormalizedDispatchError carries an optional detail field populated from the transport error message, the create_failed response detail, or the boundary_failed response, so the caller sees the actual cause. --- openbox_sandbox/dispatcher/dispatcher.py | 9 ++++++++- openbox_sandbox/dispatcher/errors.py | 6 +++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/openbox_sandbox/dispatcher/dispatcher.py b/openbox_sandbox/dispatcher/dispatcher.py index 4b01e87..11cebef 100644 --- a/openbox_sandbox/dispatcher/dispatcher.py +++ b/openbox_sandbox/dispatcher/dispatcher.py @@ -620,6 +620,7 @@ async def _dispatch_sandbox_lifecycle( except SandboxServiceTransportError as error: cleanup_required = error.submission_state is SubmissionState.POSSIBLY_SUBMITTED ownership[0] = cleanup_required + detail = f"{error.code}: {error.message}" if getattr(error, 'message', None) else str(error.code) return await self._sandbox_terminal( command, decision, @@ -628,6 +629,7 @@ async def _dispatch_sandbox_lifecycle( Disposition.NOT_EXECUTED, DispatchErrorCode.SANDBOX_CREATE, None, + detail=detail, ) except (ProtocolValidationError, ValueError, TypeError): return await self._sandbox_terminal( @@ -662,6 +664,7 @@ async def _dispatch_sandbox_lifecycle( if state not in {"not_created", "possibly_created", "conflict"}: cleanup_required = True ownership[0] = cleanup_required + detail = failure.get("detail") if isinstance(failure, dict) else None return await self._sandbox_terminal( command, decision, @@ -670,6 +673,7 @@ async def _dispatch_sandbox_lifecycle( Disposition.NOT_EXECUTED, DispatchErrorCode.SANDBOX_CREATE, None, + detail=detail, ) elif response.response == "boundary_failed": failure = response.fields.get("failure") @@ -677,6 +681,7 @@ async def _dispatch_sandbox_lifecycle( isinstance(failure, dict) and failure.get("cleanup_target") is not None ) ownership[0] = cleanup_required + detail = failure.get("detail") if isinstance(failure, dict) else None return await self._sandbox_terminal( command, decision, @@ -685,6 +690,7 @@ async def _dispatch_sandbox_lifecycle( Disposition.NOT_EXECUTED, DispatchErrorCode.SANDBOX_CREATE, None, + detail=detail, ) else: ownership[0] = True @@ -879,6 +885,7 @@ async def _sandbox_terminal( disposition: Disposition, error_code: DispatchErrorCode | None, execution: ExecutionMetadata | None, + error_detail: str | None = None, ) -> DispatchResult: cleanup = ( await self._cleanup(command, decision, sandbox_id) @@ -1080,7 +1087,7 @@ async def _terminal( disposition=disposition, directive=directive, execution=execution, - error=None if error_code is None else NormalizedDispatchError(error_code), + error=None if error_code is None else NormalizedDispatchError(error_code, detail=error_detail), _governance=None if decision is None else decision.raw, ) await self._emit( diff --git a/openbox_sandbox/dispatcher/errors.py b/openbox_sandbox/dispatcher/errors.py index 0af025c..b82ae9c 100644 --- a/openbox_sandbox/dispatcher/errors.py +++ b/openbox_sandbox/dispatcher/errors.py @@ -29,9 +29,13 @@ class DispatchErrorCode(str, Enum): @dataclass(frozen=True, slots=True) class NormalizedDispatchError: code: DispatchErrorCode + detail: str | None = None def to_wire(self) -> dict[str, str]: - return {"code": self.code.value} + wire: dict[str, str] = {"code": self.code.value} + if self.detail: + wire["detail"] = self.detail + return wire class DispatcherValidationError(ValueError): From fad8a7a48f14302230844af41c65a7573631a3eb Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Tue, 11 Aug 2026 23:21:55 +0700 Subject: [PATCH 09/20] fix: rename detail kwarg to error_detail at the SANDBOX_CREATE call sites --- openbox_sandbox/dispatcher/dispatcher.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openbox_sandbox/dispatcher/dispatcher.py b/openbox_sandbox/dispatcher/dispatcher.py index 11cebef..f65f983 100644 --- a/openbox_sandbox/dispatcher/dispatcher.py +++ b/openbox_sandbox/dispatcher/dispatcher.py @@ -629,7 +629,7 @@ async def _dispatch_sandbox_lifecycle( Disposition.NOT_EXECUTED, DispatchErrorCode.SANDBOX_CREATE, None, - detail=detail, + error_detail=detail, ) except (ProtocolValidationError, ValueError, TypeError): return await self._sandbox_terminal( @@ -673,7 +673,7 @@ async def _dispatch_sandbox_lifecycle( Disposition.NOT_EXECUTED, DispatchErrorCode.SANDBOX_CREATE, None, - detail=detail, + error_detail=detail, ) elif response.response == "boundary_failed": failure = response.fields.get("failure") @@ -690,7 +690,7 @@ async def _dispatch_sandbox_lifecycle( Disposition.NOT_EXECUTED, DispatchErrorCode.SANDBOX_CREATE, None, - detail=detail, + error_detail=detail, ) else: ownership[0] = True From 2c143823823103d7b406aed2b84a9e7d479ad6da Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Tue, 11 Aug 2026 23:24:32 +0700 Subject: [PATCH 10/20] fix: propagate error_detail through _terminal as well --- openbox_sandbox/dispatcher/dispatcher.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openbox_sandbox/dispatcher/dispatcher.py b/openbox_sandbox/dispatcher/dispatcher.py index f65f983..c6ef5a9 100644 --- a/openbox_sandbox/dispatcher/dispatcher.py +++ b/openbox_sandbox/dispatcher/dispatcher.py @@ -917,6 +917,7 @@ async def _sandbox_terminal( Directive.CONTINUE, execution, error_code, + error_detail=error_detail, ) async def _report_sandbox_result( @@ -1082,6 +1083,7 @@ async def _terminal( directive: Directive, execution: ExecutionMetadata | None, error_code: DispatchErrorCode | None, + error_detail: str | None = None, ) -> DispatchResult: result = DispatchResult( disposition=disposition, From a7f3a55afadaa80cbe948bf7d8bf638420c47882 Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Thu, 13 Aug 2026 00:50:32 +0700 Subject: [PATCH 11/20] feat: dispatcher parses typed result into ActivityCompleted output The natural sandbox path posts ActivityCompleted from the dispatcher, which previously carried only disposition and cleanup. Now it parses the profile-admitted typed result and includes the full execution evidence (sandbox_id, exit_code, timeout, stdout/stderr bytes) plus the typed values in the governance event output so the Core can surface them. --- openbox_sandbox/dispatcher/dispatcher.py | 38 ++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/openbox_sandbox/dispatcher/dispatcher.py b/openbox_sandbox/dispatcher/dispatcher.py index c6ef5a9..92d6f83 100644 --- a/openbox_sandbox/dispatcher/dispatcher.py +++ b/openbox_sandbox/dispatcher/dispatcher.py @@ -38,6 +38,7 @@ ) from .governance import GovernanceClient, GovernanceClientConfig, GovernanceDecision from .profiles import CommandProfileBundle +from ..command_profiles import CommandResultValidationError from .result import ( CleanupReconciliationResult, CleanupStatus, @@ -970,6 +971,14 @@ async def _report_sandbox_result( if not _is_normal_sandbox_completion(result): return result + typed_result = None + if result.execution is not None: + try: + typed_result = self._config.profiles.parse_result( + command.profile_id, result.execution.stdout + ) + except CommandResultValidationError: + typed_result = None try: raw_completed_decision = await self._governance.evaluate( _activity_completed( @@ -977,6 +986,7 @@ async def _report_sandbox_result( result, self._clock(), duration_ns=completed_ns - started_ns, + typed_result=typed_result, ) ) completed_decision = ( @@ -1298,7 +1308,18 @@ def _activity_completed( now: datetime, *, duration_ns: int, + typed_result: object | None = None, ) -> dict[str, Any]: + execution = result.execution + typed_wire = None + if typed_result is not None: + typed_wire = { + "schema_name": typed_result.schema_name, + "values": [ + {"name": item.name, "value": item.value} + for item in typed_result.values + ], + } return { "source": "workflow-telemetry", "event_type": "ActivityCompleted", @@ -1315,9 +1336,20 @@ def _activity_completed( "duration_ms": duration_ns / 1_000_000, "activity_output": { "disposition": result.disposition.value, - "cleanup_status": result.execution.cleanup_status.value - if result.execution is not None - else CleanupStatus.NOT_NEEDED.value, + "directive": result.directive.value, + "sandbox_id": None if execution is None else execution.sandbox_id, + "exit_code": None if execution is None else execution.exit_code, + "timeout_status": ( + None if execution is None else execution.timeout_status.value + ), + "cleanup_status": ( + CleanupStatus.NOT_NEEDED.value + if execution is None + else execution.cleanup_status.value + ), + "stdout_bytes": 0 if execution is None else len(execution.stdout), + "stderr_bytes": 0 if execution is None else len(execution.stderr), + "typed_result": typed_wire, }, } From 9eb10fb0ec34acb07eb378d29c04e24b8b73cdc8 Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Thu, 13 Aug 2026 01:02:13 +0700 Subject: [PATCH 12/20] fix: organize imports in dispatcher (ruff) --- openbox_sandbox/dispatcher/dispatcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openbox_sandbox/dispatcher/dispatcher.py b/openbox_sandbox/dispatcher/dispatcher.py index 92d6f83..2e241f9 100644 --- a/openbox_sandbox/dispatcher/dispatcher.py +++ b/openbox_sandbox/dispatcher/dispatcher.py @@ -28,6 +28,7 @@ generate_request_owned_id, ) +from ..command_profiles import CommandResultValidationError from ._host import _HostConfig, _HostExecutor, _HostFailure from .command import GovernedCommand from .errors import ( @@ -38,7 +39,6 @@ ) from .governance import GovernanceClient, GovernanceClientConfig, GovernanceDecision from .profiles import CommandProfileBundle -from ..command_profiles import CommandResultValidationError from .result import ( CleanupReconciliationResult, CleanupStatus, From 1a3424d008380e4be3b920c2a8d5eb2607eef7cf Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Thu, 13 Aug 2026 01:05:19 +0700 Subject: [PATCH 13/20] feat: ship bounded stdout/stderr content in ActivityCompleted output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event output now carries the sandbox's printed output (UTF-8, truncated at 64 KiB) alongside the byte counts so the console can render what the sandbox produced. Raw bodies stay bounded — the profile-admitted typed result remains the durable business data. --- openbox_sandbox/dispatcher/dispatcher.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/openbox_sandbox/dispatcher/dispatcher.py b/openbox_sandbox/dispatcher/dispatcher.py index 2e241f9..9f97a2b 100644 --- a/openbox_sandbox/dispatcher/dispatcher.py +++ b/openbox_sandbox/dispatcher/dispatcher.py @@ -1302,6 +1302,25 @@ def _sandbox_completed_hook( } +_CONTENT_MAX_BYTES = 64 * 1024 + + +def _bounded_text(raw: bytes) -> str: + """Decode stdout/stderr for durable telemetry, bounded for storage. + + Raw output bodies are never shipped unbounded — the profile-admitted + typed result is the durable business data. This text is truncated to + 64 KiB and sanitized so the console can show what the sandbox printed + without admitting arbitrary-length payloads into the event store. + """ + if not raw: + return "" + text = raw.decode("utf-8", errors="replace") + if len(raw) > _CONTENT_MAX_BYTES: + text = text[:_CONTENT_MAX_BYTES] + "…(truncated)" + return text + + def _activity_completed( command: GovernedCommand, result: DispatchResult, @@ -1349,6 +1368,8 @@ def _activity_completed( ), "stdout_bytes": 0 if execution is None else len(execution.stdout), "stderr_bytes": 0 if execution is None else len(execution.stderr), + "stdout": _bounded_text(execution.stdout) if execution is not None else None, + "stderr": _bounded_text(execution.stderr) if execution is not None else None, "typed_result": typed_wire, }, } From f2c5063d887afa32202aff9f3810edfbc760949a Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Thu, 13 Aug 2026 12:56:17 +0700 Subject: [PATCH 14/20] fix: dispatch_with_decision reports completed sandbox evidence to Core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interceptor evaluates the ActivityStarted verdict through Core, then calls dispatch_with_decision — which previously passed report_core=False, so the completed hook and ActivityCompleted never reached Core. The sandbox span synthesis therefore produced empty attributes. The caller still owns the pre-evaluation; only the COMPLETED evidence now flows. _report_sandbox_result no-ops without a governance client, so the no-Core callers are unaffected. --- openbox_sandbox/dispatcher/dispatcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openbox_sandbox/dispatcher/dispatcher.py b/openbox_sandbox/dispatcher/dispatcher.py index 9f97a2b..3d4b90d 100644 --- a/openbox_sandbox/dispatcher/dispatcher.py +++ b/openbox_sandbox/dispatcher/dispatcher.py @@ -310,7 +310,7 @@ async def dispatch_with_decision( DispatchErrorCode.GOVERNANCE_FALLBACK, ) await self._emit(command, decision, "governance_decision_received") - return await self._dispatch_decision(command, decision, report_core=False) + return await self._dispatch_decision(command, decision, report_core=True) async def dispatch_trusted_constrain(self, command: GovernedCommand) -> DispatchResult: """Dispatch CONSTRAIN input from an owned application agent. From 53c2a0f0ab28efcef973042e894461f5a94ef230 Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Thu, 13 Aug 2026 13:00:15 +0700 Subject: [PATCH 15/20] fix: dispatcher completed event carries execution evidence without typed parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatch_with_decision now reports the completed sandbox evidence to Core (report_core=True). The typed-result parsing stays in the wrapper — the dispatcher profile bundle has no result schemas. --- openbox_sandbox/dispatcher/dispatcher.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/openbox_sandbox/dispatcher/dispatcher.py b/openbox_sandbox/dispatcher/dispatcher.py index 3d4b90d..a8beb33 100644 --- a/openbox_sandbox/dispatcher/dispatcher.py +++ b/openbox_sandbox/dispatcher/dispatcher.py @@ -28,7 +28,6 @@ generate_request_owned_id, ) -from ..command_profiles import CommandResultValidationError from ._host import _HostConfig, _HostExecutor, _HostFailure from .command import GovernedCommand from .errors import ( @@ -971,14 +970,6 @@ async def _report_sandbox_result( if not _is_normal_sandbox_completion(result): return result - typed_result = None - if result.execution is not None: - try: - typed_result = self._config.profiles.parse_result( - command.profile_id, result.execution.stdout - ) - except CommandResultValidationError: - typed_result = None try: raw_completed_decision = await self._governance.evaluate( _activity_completed( @@ -986,7 +977,6 @@ async def _report_sandbox_result( result, self._clock(), duration_ns=completed_ns - started_ns, - typed_result=typed_result, ) ) completed_decision = ( From 281579fd9db1226683e6cdcfe5a697eedb3c14a7 Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Thu, 13 Aug 2026 15:15:47 +0700 Subject: [PATCH 16/20] chore: replace machine path with repo-relative path in fixture generator --- tests/signing/generate_golden_fixture_from_temporal_signer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/signing/generate_golden_fixture_from_temporal_signer.py b/tests/signing/generate_golden_fixture_from_temporal_signer.py index 71cd396..455c797 100644 --- a/tests/signing/generate_golden_fixture_from_temporal_signer.py +++ b/tests/signing/generate_golden_fixture_from_temporal_signer.py @@ -23,7 +23,7 @@ from unittest import mock TEMPORAL_SIGNER = pathlib.Path( - "/Users/tino/code/openbox-temporal-sdk-python/openbox/request_signing.py" + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "openbox-temporal-sdk-python", "openbox", "request_signing.py") ) OUT = pathlib.Path(__file__).parent / "golden_temporal_signed_request.json" From 1f61990defdf790c05309d472fb7ebe367cc248c Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Thu, 13 Aug 2026 15:16:05 +0700 Subject: [PATCH 17/20] chore: fixture generator resolves the temporal SDK via env or sibling checkout --- .../signing/generate_golden_fixture_from_temporal_signer.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/signing/generate_golden_fixture_from_temporal_signer.py b/tests/signing/generate_golden_fixture_from_temporal_signer.py index 455c797..66680fd 100644 --- a/tests/signing/generate_golden_fixture_from_temporal_signer.py +++ b/tests/signing/generate_golden_fixture_from_temporal_signer.py @@ -17,14 +17,16 @@ import base64 import importlib.util import json +import os import pathlib import sys import types from unittest import mock +_TEMPORAL_SDK = os.environ.get("OPENBOX_TEMPORAL_SDK_PATH") TEMPORAL_SIGNER = pathlib.Path( - os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "openbox-temporal-sdk-python", "openbox", "request_signing.py") -) + _TEMPORAL_SDK or str(pathlib.Path(__file__).resolve().parents[3] / "openbox-temporal-sdk-python") +) / "openbox" / "request_signing.py" OUT = pathlib.Path(__file__).parent / "golden_temporal_signed_request.json" # ── Fixed inputs (deterministic) ──────────────────────────────────────────── From 38bbe6c99151d9713056d59eef34df164c276493 Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Thu, 13 Aug 2026 22:48:39 +0700 Subject: [PATCH 18/20] fix: dispatcher governance transport uses httpx with an SDK User-Agent urllib's default client fingerprint gets 403 code 1010 from Cloudflare-style bot protection; the interceptor's verdict path worked (httpx) but the dispatcher's governance report died with GovernanceTransportError. Same request through httpx with a stable OpenBox-SDK User-Agent passes. --- openbox_sandbox/dispatcher/governance.py | 47 +++++++++++++----------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/openbox_sandbox/dispatcher/governance.py b/openbox_sandbox/dispatcher/governance.py index 45b5da1..1b716c3 100644 --- a/openbox_sandbox/dispatcher/governance.py +++ b/openbox_sandbox/dispatcher/governance.py @@ -472,36 +472,41 @@ async def evaluate(self, event: Mapping[str, Any]) -> GovernanceDecision: return GovernanceDecision.parse(response) def _post(self, body: bytes) -> bytes: + import httpx + headers = { "Authorization": "Bearer " + self._config.bearer_token, "Content-Type": "application/json", "X-OpenBox-SDK-Version": self._config.sdk_version, + # urllib's default client fingerprint is blocked by + # Cloudflare-style bot protection (403 code 1010); identify as + # the SDK so WAF rules and access logs see a stable client. + "User-Agent": f"OpenBox-SDK/{self._config.sdk_version}", } if self._config.request_signer is not None: headers.update(_validated_signer_headers(self._config.request_signer, body)) - request = urllib.request.Request( - self._config.endpoint, - data=body, - method="POST", - headers=headers, - ) try: - with self._opener.open(request, timeout=self._config.timeout_seconds) as response: - if response.status != 200: - raise GovernanceTransportError() - content_length = response.headers.get("Content-Length") - if content_length is not None: - try: - length = int(content_length) - except ValueError as error: - raise GovernanceProtocolError() from error - if not 1 <= length <= _MAX_RESPONSE_BYTES: - raise GovernanceProtocolError() - body = response.read(_MAX_RESPONSE_BYTES + 1) + with httpx.Client( + timeout=self._config.timeout_seconds, + verify=str(self._config.ca_path) if self._config.ca_path else True, + follow_redirects=False, + ) as client: + response = client.post(self._config.endpoint, content=body, headers=headers) + if response.status_code != 200: + raise GovernanceTransportError() + content_length = response.headers.get("Content-Length") + if content_length is not None: + try: + length = int(content_length) + except ValueError as error: + raise GovernanceProtocolError() from error + if not 1 <= length <= _MAX_RESPONSE_BYTES: + raise GovernanceProtocolError() + response_body = response.content except GovernanceProtocolError: raise - except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError) as error: + except (httpx.HTTPError, TimeoutError, OSError) as error: raise GovernanceTransportError() from error - if not body or len(body) > _MAX_RESPONSE_BYTES: + if not response_body or len(response_body) > _MAX_RESPONSE_BYTES: raise GovernanceProtocolError() - return body + return response_body From cb110cd50a8bc7a9b46ac7c5fbf66caea0d5cfaf Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Thu, 13 Aug 2026 22:52:10 +0700 Subject: [PATCH 19/20] chore: drop the dead urllib opener from the dispatcher governance client --- openbox_sandbox/dispatcher/governance.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/openbox_sandbox/dispatcher/governance.py b/openbox_sandbox/dispatcher/governance.py index 1b716c3..ef8b191 100644 --- a/openbox_sandbox/dispatcher/governance.py +++ b/openbox_sandbox/dispatcher/governance.py @@ -10,7 +10,6 @@ import ssl import urllib.error import urllib.parse -import urllib.request import uuid from collections.abc import Mapping from dataclasses import dataclass @@ -446,25 +445,9 @@ def _validated_signer_headers(signer: GovernanceRequestSigner, body: bytes) -> d return {canonical: normalized[lowered] for lowered, canonical in _AIP_HEADERS.items()} -class _NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request( - self, request: Any, file: Any, code: int, message: str, headers: Any, new_url: str - ) -> None: - return None - - class GovernanceClient: def __init__(self, config: GovernanceClientConfig) -> None: self._config = config - self._ssl = ssl.create_default_context( - cafile=str(config.ca_path) if config.ca_path else None - ) - self._ssl.minimum_version = ssl.TLSVersion.TLSv1_2 - self._opener = urllib.request.build_opener( - urllib.request.ProxyHandler({}), - urllib.request.HTTPSHandler(context=self._ssl), - _NoRedirect(), - ) async def evaluate(self, event: Mapping[str, Any]) -> GovernanceDecision: body = _json_bytes(event) From f48f828efb742a94dfb5d790c827c183d37b3f7e Mon Sep 17 00:00:00 2001 From: salamisandwich77 Date: Wed, 26 Aug 2026 15:05:22 +0700 Subject: [PATCH 20/20] fix(lint): drop the unused ssl import that fails CI test (3.11) and test (3.12) have both been failing on this branch since 13 August: ruff reports F401 for `ssl` in the dispatcher governance client. The import was left behind when the urllib opener was removed, and nothing in the file refers to it. ruff passes and 631 tests pass. --- openbox_sandbox/dispatcher/governance.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openbox_sandbox/dispatcher/governance.py b/openbox_sandbox/dispatcher/governance.py index ef8b191..16e7d93 100644 --- a/openbox_sandbox/dispatcher/governance.py +++ b/openbox_sandbox/dispatcher/governance.py @@ -7,7 +7,6 @@ import json import math import re -import ssl import urllib.error import urllib.parse import uuid