openbox-sdk-python 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openbox_core/__init__.py +59 -0
- openbox_core/adapters/__init__.py +3 -0
- openbox_core/adapters/base.py +123 -0
- openbox_core/approvals.py +106 -0
- openbox_core/client.py +298 -0
- openbox_core/config.py +260 -0
- openbox_core/conformance/__init__.py +3 -0
- openbox_core/conformance/fake_core.py +169 -0
- openbox_core/conformance/hook_preflight.py +87 -0
- openbox_core/conformance/instrumentation.py +91 -0
- openbox_core/context.py +205 -0
- openbox_core/contracts/__init__.py +3 -0
- openbox_core/contracts/context.py +79 -0
- openbox_core/contracts/events.py +401 -0
- openbox_core/contracts/otel_spans.py +325 -0
- openbox_core/contracts/results.py +287 -0
- openbox_core/errors.py +287 -0
- openbox_core/gate.py +185 -0
- openbox_core/hooks/__init__.py +3 -0
- openbox_core/hooks/events.py +64 -0
- openbox_core/hooks/preflight.py +292 -0
- openbox_core/hooks/wrappers.py +105 -0
- openbox_core/identity.py +231 -0
- openbox_core/instrumentation/__init__.py +3 -0
- openbox_core/instrumentation/db.py +689 -0
- openbox_core/instrumentation/file.py +239 -0
- openbox_core/instrumentation/function.py +121 -0
- openbox_core/instrumentation/http.py +840 -0
- openbox_core/instrumentation/llm.py +3 -0
- openbox_core/instrumentation/manager.py +135 -0
- openbox_core/instrumentation/shared.py +27 -0
- openbox_core/otel/__init__.py +3 -0
- openbox_core/otel/propagation.py +45 -0
- openbox_core/otel/provider.py +35 -0
- openbox_core/otel/setup.py +36 -0
- openbox_core/otel/span_processor.py +62 -0
- openbox_core/otel/trace_context.py +71 -0
- openbox_core/py.typed +0 -0
- openbox_core/runtime.py +138 -0
- openbox_core/sdk_version.py +79 -0
- openbox_core/serialization.py +129 -0
- openbox_core/validation/__init__.py +3 -0
- openbox_core/validation/diagnostics.py +60 -0
- openbox_core/validation/event_rules.py +164 -0
- openbox_core/validation/registry.py +31 -0
- openbox_core/validation/span_normalization.py +107 -0
- openbox_core/wire/__init__.py +3 -0
- openbox_core/wire/core_span.py +130 -0
- openbox_core/wire/evaluate_payload.py +56 -0
- openbox_sdk_python-0.2.0.dist-info/METADATA +94 -0
- openbox_sdk_python-0.2.0.dist-info/RECORD +52 -0
- openbox_sdk_python-0.2.0.dist-info/WHEEL +4 -0
openbox_core/config.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""OpenBoxConfig and nested config groups with layered env resolution.
|
|
2
|
+
|
|
3
|
+
Resolution order (highest wins):
|
|
4
|
+
|
|
5
|
+
1. explicit arguments
|
|
6
|
+
2. SDK-specific environment variables via ``env_prefix`` (e.g.
|
|
7
|
+
``OPENBOX_FRAMEWORK_API_KEY`` for ``env_prefix="OPENBOX_FRAMEWORK"``)
|
|
8
|
+
3. global ``OPENBOX_*`` environment variables
|
|
9
|
+
4. defaults
|
|
10
|
+
5. validation and normalization
|
|
11
|
+
|
|
12
|
+
Common framework configuration fields map onto the nested groups here:
|
|
13
|
+
|
|
14
|
+
skip_workflow_types / skip_activity_types / skip_signals /
|
|
15
|
+
enforce_task_queues / send_start_event / send_activity_start_event -> gate
|
|
16
|
+
hitl_enabled / skip_hitl_activity_types / hitl_poll_interval_ms -> hitl
|
|
17
|
+
max_body_size -> privacy
|
|
18
|
+
on_api_error / api_timeout -> top level
|
|
19
|
+
|
|
20
|
+
No heavy imports; safe outside sandbox paths (env access happens only inside
|
|
21
|
+
``resolve()``, never at import time).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from .errors import OpenBoxAuthError, OpenBoxConfigError, OpenBoxInsecureURLError
|
|
32
|
+
from .sdk_version import DEFAULT_SDK_ENGINE, DEFAULT_SDK_LANGUAGE
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"GLOBAL_ENV_PREFIX",
|
|
36
|
+
"HitlConfig",
|
|
37
|
+
"TelemetryConfig",
|
|
38
|
+
"InstrumentationConfig",
|
|
39
|
+
"GateConfig",
|
|
40
|
+
"PrivacyConfig",
|
|
41
|
+
"OpenBoxConfig",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# API key format pattern (obx_live_... or obx_test_...)
|
|
45
|
+
API_KEY_PATTERN = re.compile(r"^obx_(live|test)_\w+$")
|
|
46
|
+
|
|
47
|
+
GLOBAL_ENV_PREFIX = "OPENBOX"
|
|
48
|
+
|
|
49
|
+
# Config fields resolvable from the environment (suffix -> coercion).
|
|
50
|
+
_ENV_FIELDS: dict[str, str] = {
|
|
51
|
+
"api_url": "API_URL",
|
|
52
|
+
"api_key": "API_KEY",
|
|
53
|
+
"timeout_seconds": "TIMEOUT_SECONDS",
|
|
54
|
+
"on_api_error": "ON_API_ERROR",
|
|
55
|
+
"agent_name": "AGENT_NAME",
|
|
56
|
+
"agent_did": "AGENT_DID",
|
|
57
|
+
"agent_private_key": "AGENT_PRIVATE_KEY",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class HitlConfig:
|
|
63
|
+
"""Human-in-the-loop approval polling configuration."""
|
|
64
|
+
|
|
65
|
+
enabled: bool = True
|
|
66
|
+
poll_interval_ms: int = 5000
|
|
67
|
+
max_wait_ms: int | None = None # None = poll indefinitely (framework decides)
|
|
68
|
+
# Activity types to skip approval checks for (avoids infinite loops).
|
|
69
|
+
skip_activity_types: set[str] = field(default_factory=lambda: {"send_governance_event"})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class TelemetryConfig:
|
|
74
|
+
"""Telemetry emission toggles."""
|
|
75
|
+
|
|
76
|
+
enabled: bool = True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class InstrumentationConfig:
|
|
81
|
+
"""Generic instrumentation install toggles."""
|
|
82
|
+
|
|
83
|
+
enabled: bool = True
|
|
84
|
+
http_enabled: bool = True
|
|
85
|
+
db_enabled: bool = True
|
|
86
|
+
# Safe to default-on: interpreter-owned paths bypass governance and a
|
|
87
|
+
# re-entrancy guard passes through evaluation-time opens.
|
|
88
|
+
file_enabled: bool = True
|
|
89
|
+
function_enabled: bool = True
|
|
90
|
+
llm_enabled: bool = False # Reserved; disabled until provider hooks are implemented.
|
|
91
|
+
install_opentelemetry: bool = True
|
|
92
|
+
preflight_enabled: bool = True
|
|
93
|
+
completed_telemetry_enabled: bool = True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class GateConfig:
|
|
98
|
+
"""Event-level gate toggles (which lifecycle events are evaluated).
|
|
99
|
+
|
|
100
|
+
Gate mode is not configurable: event/runtime contracts are always strict.
|
|
101
|
+
These fields control only which events are emitted.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
skip_workflow_types: set[str] = field(default_factory=set)
|
|
105
|
+
skip_signals: set[str] = field(default_factory=set)
|
|
106
|
+
# By default skip the governance event activity itself to avoid loops.
|
|
107
|
+
skip_activity_types: set[str] = field(default_factory=lambda: {"send_governance_event"})
|
|
108
|
+
enforce_task_queues: set[str] | None = None # None = all
|
|
109
|
+
send_start_event: bool = True
|
|
110
|
+
send_activity_start_event: bool = True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class PrivacyConfig:
|
|
115
|
+
"""Redaction/truncation applied BEFORE signing."""
|
|
116
|
+
|
|
117
|
+
redact_keys: set[str] = field(default_factory=set)
|
|
118
|
+
max_body_size: int = 65536 # chars
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class OpenBoxConfig:
|
|
123
|
+
"""Resolved base-SDK configuration.
|
|
124
|
+
|
|
125
|
+
Build via :meth:`resolve` for layered env resolution + validation, or
|
|
126
|
+
construct directly in tests (no validation on direct construction).
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
api_url: str = ""
|
|
130
|
+
api_key: str = ""
|
|
131
|
+
timeout_seconds: float = 30.0
|
|
132
|
+
on_api_error: str = "fail_open" # "fail_open" | "fail_closed"
|
|
133
|
+
on_fallback: Any = None # reserved passthrough for fallback callbacks
|
|
134
|
+
agent_name: str | None = None
|
|
135
|
+
agent_did: str | None = None
|
|
136
|
+
agent_private_key: str | None = field(default=None, repr=False) # never in repr
|
|
137
|
+
sdk_version: str | None = None
|
|
138
|
+
sdk_engine: str = DEFAULT_SDK_ENGINE
|
|
139
|
+
sdk_language: str = DEFAULT_SDK_LANGUAGE
|
|
140
|
+
env_prefix: str | None = None
|
|
141
|
+
hitl: HitlConfig = field(default_factory=HitlConfig)
|
|
142
|
+
telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
|
|
143
|
+
instrumentation: InstrumentationConfig = field(default_factory=InstrumentationConfig)
|
|
144
|
+
gate: GateConfig = field(default_factory=GateConfig)
|
|
145
|
+
privacy: PrivacyConfig = field(default_factory=PrivacyConfig)
|
|
146
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
147
|
+
|
|
148
|
+
# ── Resolution ───────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def resolve(
|
|
152
|
+
cls,
|
|
153
|
+
*,
|
|
154
|
+
env_prefix: str | None = None,
|
|
155
|
+
environ: Mapping[str, str] | None = None,
|
|
156
|
+
validate: bool = True,
|
|
157
|
+
**explicit: Any,
|
|
158
|
+
) -> OpenBoxConfig:
|
|
159
|
+
"""Layered resolution: explicit > env_prefix > OPENBOX_* > defaults.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
env_prefix: SDK-specific env namespace (e.g. ``OPENBOX_FRAMEWORK``).
|
|
163
|
+
environ: Environment mapping (defaults to ``os.environ``; injectable
|
|
164
|
+
for tests).
|
|
165
|
+
validate: Run validation/normalization (step 5). Disable only in
|
|
166
|
+
tests that need partial configs.
|
|
167
|
+
**explicit: Explicit values for any OpenBoxConfig field. ``None``
|
|
168
|
+
means "not provided" and falls through to the next layer.
|
|
169
|
+
"""
|
|
170
|
+
if environ is None:
|
|
171
|
+
import os
|
|
172
|
+
|
|
173
|
+
environ = os.environ
|
|
174
|
+
|
|
175
|
+
unknown = set(explicit) - {f.name for f in cls.__dataclass_fields__.values()} # type: ignore[attr-defined]
|
|
176
|
+
if unknown:
|
|
177
|
+
raise OpenBoxConfigError(f"Unknown config fields: {sorted(unknown)}")
|
|
178
|
+
|
|
179
|
+
resolved: dict[str, Any] = {}
|
|
180
|
+
for field_name, suffix in _ENV_FIELDS.items():
|
|
181
|
+
value: Any = explicit.get(field_name)
|
|
182
|
+
if value is None and env_prefix:
|
|
183
|
+
value = environ.get(f"{env_prefix}_{suffix}")
|
|
184
|
+
if value is None:
|
|
185
|
+
value = environ.get(f"{GLOBAL_ENV_PREFIX}_{suffix}")
|
|
186
|
+
if value is not None:
|
|
187
|
+
resolved[field_name] = value
|
|
188
|
+
|
|
189
|
+
# Non-env fields pass through explicitly only.
|
|
190
|
+
for field_name, value in explicit.items():
|
|
191
|
+
if field_name not in _ENV_FIELDS and value is not None:
|
|
192
|
+
resolved[field_name] = value
|
|
193
|
+
|
|
194
|
+
config = cls(env_prefix=env_prefix, **resolved)
|
|
195
|
+
return config.normalized() if validate else config
|
|
196
|
+
|
|
197
|
+
def normalized(self) -> OpenBoxConfig:
|
|
198
|
+
"""Validate + normalize in place (step 5). Returns self for chaining."""
|
|
199
|
+
if not self.api_url:
|
|
200
|
+
raise OpenBoxConfigError("api_url is required")
|
|
201
|
+
if not self.api_key:
|
|
202
|
+
raise OpenBoxConfigError("api_key is required")
|
|
203
|
+
|
|
204
|
+
self.api_url = str(self.api_url).rstrip("/")
|
|
205
|
+
_validate_url_security(self.api_url)
|
|
206
|
+
|
|
207
|
+
if not API_KEY_PATTERN.match(self.api_key):
|
|
208
|
+
raise OpenBoxAuthError(
|
|
209
|
+
f"Invalid API key format. Expected 'obx_live_*' or 'obx_test_*', "
|
|
210
|
+
f"got: '{self.api_key[:15]}...' (showing first 15 chars)"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
self.timeout_seconds = float(self.timeout_seconds)
|
|
215
|
+
except (TypeError, ValueError):
|
|
216
|
+
raise OpenBoxConfigError(
|
|
217
|
+
f"timeout_seconds must be numeric, got {self.timeout_seconds!r}"
|
|
218
|
+
) from None
|
|
219
|
+
|
|
220
|
+
if self.on_api_error not in ("fail_open", "fail_closed"):
|
|
221
|
+
raise OpenBoxConfigError(
|
|
222
|
+
f"on_api_error must be 'fail_open' or 'fail_closed', got {self.on_api_error!r}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# DID + private key: both-or-neither; format-validate the DID eagerly.
|
|
226
|
+
if bool(self.agent_did) != bool(self.agent_private_key):
|
|
227
|
+
raise OpenBoxConfigError(
|
|
228
|
+
"agent_did and agent_private_key must be provided together "
|
|
229
|
+
"(got only one). Provide both to enable signed requests, or neither."
|
|
230
|
+
)
|
|
231
|
+
if self.agent_did:
|
|
232
|
+
from .identity import validate_agent_did
|
|
233
|
+
|
|
234
|
+
validate_agent_did(self.agent_did)
|
|
235
|
+
return self
|
|
236
|
+
|
|
237
|
+
def load_identity(self) -> Any:
|
|
238
|
+
"""Load an :class:`~openbox_core.identity.AgentIdentity` (or None).
|
|
239
|
+
|
|
240
|
+
Decodes + loads the Ed25519 seed exactly once; callers keep the
|
|
241
|
+
returned identity and never re-touch the raw key string.
|
|
242
|
+
"""
|
|
243
|
+
if not (self.agent_did and self.agent_private_key):
|
|
244
|
+
return None
|
|
245
|
+
from .identity import AgentIdentity
|
|
246
|
+
|
|
247
|
+
return AgentIdentity.from_private_key(self.agent_did, self.agent_private_key)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _validate_url_security(api_url: str) -> None:
|
|
251
|
+
"""HTTPS required for non-localhost URLs (protects API keys in transit)."""
|
|
252
|
+
from urllib.parse import urlparse
|
|
253
|
+
|
|
254
|
+
parsed = urlparse(api_url)
|
|
255
|
+
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
|
256
|
+
if parsed.scheme == "http" and not is_localhost:
|
|
257
|
+
raise OpenBoxInsecureURLError(
|
|
258
|
+
f"Insecure HTTP URL detected: {api_url}. "
|
|
259
|
+
"Use HTTPS for non-localhost URLs to protect API keys in transit."
|
|
260
|
+
)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Programmable fake OpenBox Core — verdict/approval queues + payload capture.
|
|
2
|
+
|
|
3
|
+
No live network: requests terminate in an ``httpx.MockTransport``. Serves both
|
|
4
|
+
sync and async clients. Importable from any framework SDK repo.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..client import EvaluationClient
|
|
14
|
+
|
|
15
|
+
__all__ = ["FakeCore", "fake_client", "assert_hook_wire_shape"]
|
|
16
|
+
|
|
17
|
+
_SPAN_ID_RE = re.compile(r"^[0-9a-f]{16}$")
|
|
18
|
+
_TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
|
19
|
+
|
|
20
|
+
# Common root fields every flat hook span must carry (present even when null).
|
|
21
|
+
_COMMON_ROOT_FIELDS = (
|
|
22
|
+
"span_id",
|
|
23
|
+
"trace_id",
|
|
24
|
+
"parent_span_id",
|
|
25
|
+
"name",
|
|
26
|
+
"kind",
|
|
27
|
+
"stage",
|
|
28
|
+
"start_time",
|
|
29
|
+
"end_time",
|
|
30
|
+
"duration_ns",
|
|
31
|
+
"attributes",
|
|
32
|
+
"status",
|
|
33
|
+
"events",
|
|
34
|
+
"hook_type",
|
|
35
|
+
"error",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Family-specific root fields that must exist (present even when null) per type.
|
|
39
|
+
_FAMILY_ROOT_FIELDS = {
|
|
40
|
+
"http_request": (
|
|
41
|
+
"http_method",
|
|
42
|
+
"http_url",
|
|
43
|
+
"http_status_code",
|
|
44
|
+
"request_headers",
|
|
45
|
+
"response_headers",
|
|
46
|
+
"request_body",
|
|
47
|
+
"response_body",
|
|
48
|
+
),
|
|
49
|
+
"db_query": (
|
|
50
|
+
"db_system",
|
|
51
|
+
"db_name",
|
|
52
|
+
"db_operation",
|
|
53
|
+
"db_statement",
|
|
54
|
+
"server_address",
|
|
55
|
+
"server_port",
|
|
56
|
+
"rowcount",
|
|
57
|
+
),
|
|
58
|
+
"file_operation": (
|
|
59
|
+
"file_path",
|
|
60
|
+
"file_mode",
|
|
61
|
+
"file_operation",
|
|
62
|
+
"bytes_read",
|
|
63
|
+
"bytes_written",
|
|
64
|
+
),
|
|
65
|
+
"function_call": ("function", "module", "args", "result"),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FakeCore:
|
|
70
|
+
"""Queue verdict/approval responses; capture every outgoing payload.
|
|
71
|
+
|
|
72
|
+
Responses pop FIFO from ``queue``; an empty queue answers ALLOW. Approval
|
|
73
|
+
polls share the same queue (enqueue in call order).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, *responses: dict[str, Any]):
|
|
77
|
+
self.queue: list[dict[str, Any]] = list(responses)
|
|
78
|
+
self.payloads: list[dict[str, Any]] = []
|
|
79
|
+
self.approval_requests: list[dict[str, Any]] = []
|
|
80
|
+
|
|
81
|
+
# ── transport ─────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def handler(self, request: Any) -> Any:
|
|
84
|
+
import httpx
|
|
85
|
+
|
|
86
|
+
if request.url.path.endswith("/governance/evaluate"):
|
|
87
|
+
self.payloads.append(json.loads(request.content))
|
|
88
|
+
return httpx.Response(200, json=self._next({"verdict": "allow"}))
|
|
89
|
+
if request.url.path.endswith("/governance/approval"):
|
|
90
|
+
self.approval_requests.append(json.loads(request.content))
|
|
91
|
+
return httpx.Response(200, json=self._next({"action": "allow"}))
|
|
92
|
+
return httpx.Response(200, json={})
|
|
93
|
+
|
|
94
|
+
def _next(self, default: dict[str, Any]) -> dict[str, Any]:
|
|
95
|
+
return self.queue.pop(0) if self.queue else default
|
|
96
|
+
|
|
97
|
+
# ── capture views ─────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def started_payloads(self) -> list[dict[str, Any]]:
|
|
101
|
+
return [
|
|
102
|
+
p for p in self.payloads
|
|
103
|
+
if p.get("spans") and p["spans"][0].get("stage") == "started"
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def completed_payloads(self) -> list[dict[str, Any]]:
|
|
108
|
+
return [
|
|
109
|
+
p for p in self.payloads
|
|
110
|
+
if p.get("spans") and p["spans"][0].get("stage") == "completed"
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def lifecycle_payloads(self) -> list[dict[str, Any]]:
|
|
115
|
+
return [p for p in self.payloads if not p.get("hook_trigger")]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def fake_client(fake_core: FakeCore, **kwargs: Any) -> EvaluationClient:
|
|
119
|
+
"""EvaluationClient wired to the fake Core (sync + async transports)."""
|
|
120
|
+
import httpx
|
|
121
|
+
|
|
122
|
+
transport = httpx.MockTransport(fake_core.handler)
|
|
123
|
+
return EvaluationClient(
|
|
124
|
+
kwargs.pop("api_url", "https://core.test"),
|
|
125
|
+
kwargs.pop("api_key", "obx_test_conformance"),
|
|
126
|
+
transport=transport,
|
|
127
|
+
async_transport=transport,
|
|
128
|
+
**kwargs,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def assert_hook_wire_shape(payload: dict[str, Any]) -> None:
|
|
133
|
+
"""Assert one captured hook payload matches the flat Core wire contract:
|
|
134
|
+
|
|
135
|
+
- ``event_type=ActivityStarted`` + ``hook_trigger=true`` + non-empty spans
|
|
136
|
+
- hex-string ids (regex, not truthiness)
|
|
137
|
+
- flat ``SpanData`` dicts — the nested ``otel``/``openbox`` envelope and any
|
|
138
|
+
opt-in ``data`` blob must never reach the wire
|
|
139
|
+
- every common root field present (``stage``/``hook_type``/``error``/…)
|
|
140
|
+
- every family-specific root field present for the span's ``hook_type``
|
|
141
|
+
- ``semantic_type`` never set by the SDK (Core computes it)
|
|
142
|
+
"""
|
|
143
|
+
assert payload.get("event_type") == "ActivityStarted", payload.get("event_type")
|
|
144
|
+
assert payload.get("hook_trigger") is True
|
|
145
|
+
spans = payload.get("spans")
|
|
146
|
+
assert spans, "hook payload must carry non-empty spans"
|
|
147
|
+
assert payload.get("span_count") == len(spans)
|
|
148
|
+
for span in spans:
|
|
149
|
+
assert "otel" not in span and "openbox" not in span, (
|
|
150
|
+
"nested span envelope leaked to the wire"
|
|
151
|
+
)
|
|
152
|
+
assert "data" not in span, "flat hook spans must not carry a data blob"
|
|
153
|
+
assert "semantic_type" not in span, "semantic_type is computed by Core, not the SDK"
|
|
154
|
+
for field_name in _COMMON_ROOT_FIELDS:
|
|
155
|
+
assert field_name in span, f"missing common root field: {field_name}"
|
|
156
|
+
assert _SPAN_ID_RE.fullmatch(span.get("span_id", "")), span.get("span_id")
|
|
157
|
+
assert _TRACE_ID_RE.fullmatch(span.get("trace_id", "")), span.get("trace_id")
|
|
158
|
+
parent = span.get("parent_span_id")
|
|
159
|
+
if parent is not None:
|
|
160
|
+
assert _SPAN_ID_RE.fullmatch(parent), parent
|
|
161
|
+
assert span.get("stage") in ("started", "completed")
|
|
162
|
+
assert span.get("hook_type"), "hook spans must carry hook_type at the root"
|
|
163
|
+
for field_name in _FAMILY_ROOT_FIELDS.get(span.get("hook_type", ""), ()):
|
|
164
|
+
assert field_name in span, (
|
|
165
|
+
f"missing {span.get('hook_type')} root field: {field_name}"
|
|
166
|
+
)
|
|
167
|
+
if span.get("stage") == "started":
|
|
168
|
+
assert span["end_time"] is None
|
|
169
|
+
assert span["duration_ns"] is None
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Adapter/context-binding conformance pieces: recording adapter + env builder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..config import InstrumentationConfig, OpenBoxConfig
|
|
8
|
+
from ..context import ContextStore
|
|
9
|
+
from ..contracts.context import ActivityContext
|
|
10
|
+
from ..contracts.results import EvaluationResult, Verdict
|
|
11
|
+
from ..errors import ApprovalRejectedError, GovernanceBlockedError, GovernanceHaltError
|
|
12
|
+
from ..runtime import OpenBoxRuntime
|
|
13
|
+
from .fake_core import FakeCore, fake_client
|
|
14
|
+
|
|
15
|
+
__all__ = ["CONFORMANCE_CONTEXT", "RecordingHookAdapter", "build_conformance_runtime"]
|
|
16
|
+
|
|
17
|
+
# The reference activity context every conformance case binds.
|
|
18
|
+
CONFORMANCE_CONTEXT = ActivityContext(
|
|
19
|
+
workflow_id="wf-conformance",
|
|
20
|
+
run_id="run-conformance",
|
|
21
|
+
workflow_type="ConformanceWorkflow",
|
|
22
|
+
task_queue="conformance-queue",
|
|
23
|
+
activity_id="act-conformance",
|
|
24
|
+
activity_type="conformance_activity",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RecordingHookAdapter:
|
|
29
|
+
"""Reference FrameworkAdapter: records every delegation, raises core errors.
|
|
30
|
+
|
|
31
|
+
``approve_next`` drives the async approval outcome (True ⇒ approved).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
name = "conformance"
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self.hook_blocked: list[EvaluationResult] = []
|
|
38
|
+
self.lifecycle_blocked: list[EvaluationResult] = []
|
|
39
|
+
self.completed_results: list[EvaluationResult] = []
|
|
40
|
+
self.completed_contexts: list[ActivityContext | None] = []
|
|
41
|
+
self.approvals: list[EvaluationResult] = []
|
|
42
|
+
self.approve_next = True
|
|
43
|
+
|
|
44
|
+
async def handle_approval(self, result: EvaluationResult) -> None:
|
|
45
|
+
self.approvals.append(result)
|
|
46
|
+
if not self.approve_next:
|
|
47
|
+
raise ApprovalRejectedError("rejected by conformance adapter")
|
|
48
|
+
|
|
49
|
+
def raise_lifecycle_blocked(self, result: EvaluationResult) -> None:
|
|
50
|
+
self.lifecycle_blocked.append(result)
|
|
51
|
+
self._raise(result)
|
|
52
|
+
|
|
53
|
+
def raise_hook_blocked(self, result: EvaluationResult) -> None:
|
|
54
|
+
self.hook_blocked.append(result)
|
|
55
|
+
self._raise(result)
|
|
56
|
+
|
|
57
|
+
def on_completed_hook_result(
|
|
58
|
+
self, result: EvaluationResult, context: ActivityContext | None = None
|
|
59
|
+
) -> None:
|
|
60
|
+
self.completed_results.append(result)
|
|
61
|
+
self.completed_contexts.append(context)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _raise(result: EvaluationResult) -> None:
|
|
65
|
+
if result.verdict is Verdict.HALT:
|
|
66
|
+
raise GovernanceHaltError(result.reason or "halted")
|
|
67
|
+
raise GovernanceBlockedError(result.verdict, result.reason or "blocked")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_conformance_runtime(
|
|
71
|
+
fake_core: FakeCore,
|
|
72
|
+
adapter: Any | None = None,
|
|
73
|
+
store: ContextStore | None = None,
|
|
74
|
+
**instrumentation_overrides: Any,
|
|
75
|
+
) -> OpenBoxRuntime:
|
|
76
|
+
"""OpenBoxRuntime wired to the fake Core with an isolated ContextStore."""
|
|
77
|
+
config = OpenBoxConfig(
|
|
78
|
+
api_url="https://core.test",
|
|
79
|
+
api_key="obx_test_conformance",
|
|
80
|
+
instrumentation=InstrumentationConfig(**instrumentation_overrides),
|
|
81
|
+
)
|
|
82
|
+
return OpenBoxRuntime(
|
|
83
|
+
config,
|
|
84
|
+
adapter if adapter is not None else RecordingHookAdapter(),
|
|
85
|
+
client=fake_client(fake_core),
|
|
86
|
+
context_store=store if store is not None else ContextStore(),
|
|
87
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Instrumentation conformance environment — real wrappers, fake Core.
|
|
2
|
+
|
|
3
|
+
Framework SDKs import this to prove their integration keeps every behavioral
|
|
4
|
+
guarantee: the checks assert the REAL operation ran / did not run, not merely
|
|
5
|
+
payload shape. No live network (governance terminates in the fake Core; HTTP
|
|
6
|
+
cases hit a local counting server).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import http.server
|
|
13
|
+
import threading
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..context import ContextStore, activity_scope
|
|
18
|
+
from ..instrumentation.manager import InstrumentationManager
|
|
19
|
+
from ..runtime import OpenBoxRuntime
|
|
20
|
+
from .fake_core import FakeCore
|
|
21
|
+
from .hook_preflight import (
|
|
22
|
+
CONFORMANCE_CONTEXT,
|
|
23
|
+
RecordingHookAdapter,
|
|
24
|
+
build_conformance_runtime,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"LocalCountingServer",
|
|
29
|
+
"installed_conformance_runtime",
|
|
30
|
+
"bound_conformance_activity",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LocalCountingServer:
|
|
35
|
+
"""Loopback HTTP server counting hits — proves a request was/wasn't sent."""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self.hits = 0
|
|
39
|
+
outer = self
|
|
40
|
+
|
|
41
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
42
|
+
def _respond(self) -> None:
|
|
43
|
+
outer.hits += 1
|
|
44
|
+
body = b'{"ok": true}'
|
|
45
|
+
self.send_response(200)
|
|
46
|
+
self.send_header("Content-Type", "application/json")
|
|
47
|
+
self.send_header("Content-Length", str(len(body)))
|
|
48
|
+
self.end_headers()
|
|
49
|
+
self.wfile.write(body)
|
|
50
|
+
|
|
51
|
+
do_GET = _respond
|
|
52
|
+
do_POST = _respond
|
|
53
|
+
|
|
54
|
+
def log_message(self, *args: Any) -> None: # keep test output clean
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
self._server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
58
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
59
|
+
self._thread.start()
|
|
60
|
+
self.url = f"http://127.0.0.1:{self._server.server_port}/echo"
|
|
61
|
+
|
|
62
|
+
def stop(self) -> None:
|
|
63
|
+
self._server.shutdown()
|
|
64
|
+
self._server.server_close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextlib.contextmanager
|
|
68
|
+
def installed_conformance_runtime(
|
|
69
|
+
fake_core: FakeCore,
|
|
70
|
+
adapter: Any | None = None,
|
|
71
|
+
store: ContextStore | None = None,
|
|
72
|
+
**instrumentation_overrides: Any,
|
|
73
|
+
) -> Iterator[OpenBoxRuntime]:
|
|
74
|
+
"""Runtime with REAL instrumentation installed; guaranteed uninstall."""
|
|
75
|
+
adapter = adapter if adapter is not None else RecordingHookAdapter()
|
|
76
|
+
store = store if store is not None else ContextStore()
|
|
77
|
+
runtime = build_conformance_runtime(fake_core, adapter, store, **instrumentation_overrides)
|
|
78
|
+
manager = InstrumentationManager(runtime)
|
|
79
|
+
runtime._instrumentation_manager = manager
|
|
80
|
+
manager.install()
|
|
81
|
+
try:
|
|
82
|
+
yield runtime
|
|
83
|
+
finally:
|
|
84
|
+
manager.uninstall()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@contextlib.contextmanager
|
|
88
|
+
def bound_conformance_activity(store: ContextStore) -> Iterator[None]:
|
|
89
|
+
"""Bind the reference ActivityContext with guaranteed reset."""
|
|
90
|
+
with activity_scope(CONFORMANCE_CONTEXT, store=store):
|
|
91
|
+
yield
|