Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
010da48
fix(core): reject malformed governance responses
salamisandwich77 Jul 22, 2026
f952171
feat(telemetry): add privacy-safe sandbox hook contract
salamisandwich77 Jul 25, 2026
738cede
fix(telemetry): align sandbox direct hook attributes
salamisandwich77 Jul 25, 2026
005d27e
feat: merge sandbox SDK into base SDK
salamisandwich77 Jul 27, 2026
06b1df5
sync: bring the sandbox modules to the current monorepo state
salamisandwich77 Aug 8, 2026
396de80
test: adapt retry_plan parsing test to the patch vocabulary
salamisandwich77 Aug 10, 2026
1b5dacd
lint: resolve ruff violations (UP035, UP017, UP037, B905, B009/B018, …
salamisandwich77 Aug 10, 2026
a7c6afc
fix: propagate the transport error detail through the sandbox create …
salamisandwich77 Aug 11, 2026
fad8a7a
fix: rename detail kwarg to error_detail at the SANDBOX_CREATE call s…
salamisandwich77 Aug 11, 2026
2c14382
fix: propagate error_detail through _terminal as well
salamisandwich77 Aug 11, 2026
a7f3a55
feat: dispatcher parses typed result into ActivityCompleted output
salamisandwich77 Aug 12, 2026
9eb10fb
fix: organize imports in dispatcher (ruff)
salamisandwich77 Aug 12, 2026
1a3424d
feat: ship bounded stdout/stderr content in ActivityCompleted output
salamisandwich77 Aug 12, 2026
f2c5063
fix: dispatch_with_decision reports completed sandbox evidence to Core
salamisandwich77 Aug 13, 2026
53c2a0f
fix: dispatcher completed event carries execution evidence without ty…
salamisandwich77 Aug 13, 2026
281579f
chore: replace machine path with repo-relative path in fixture generator
salamisandwich77 Aug 13, 2026
1f61990
chore: fixture generator resolves the temporal SDK via env or sibling…
salamisandwich77 Aug 13, 2026
38bbe6c
fix: dispatcher governance transport uses httpx with an SDK User-Agent
salamisandwich77 Aug 13, 2026
cb110cd
chore: drop the dead urllib opener from the dispatcher governance client
salamisandwich77 Aug 13, 2026
f48f828
fix(lint): drop the unused ssl import that fails CI
salamisandwich77 Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 5 additions & 8 deletions openbox_core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions openbox_core/contracts/otel_spans.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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",
}

Expand Down Expand Up @@ -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": (),
}


Expand Down
110 changes: 104 additions & 6 deletions openbox_core/contracts/results.py
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 4 additions & 5 deletions openbox_core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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``).
Expand Down
Loading
Loading