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/errors.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""OpenBox base SDK — unified exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Pure module: no network, crypto, OTel, logging, or wall-clock imports. Safe to
|
|
4
|
+
import from constrained framework paths.
|
|
5
|
+
|
|
6
|
+
Hierarchy:
|
|
7
|
+
OpenBoxError (base)
|
|
8
|
+
├── ContractError # strict-gate event/runtime contract violation
|
|
9
|
+
├── OpenBoxConfigError
|
|
10
|
+
│ ├── OpenBoxAuthError
|
|
11
|
+
│ │ └── OpenBoxSigningError # Core rejected a signed (AIP DID) request
|
|
12
|
+
│ ├── OpenBoxNetworkError
|
|
13
|
+
│ └── OpenBoxInsecureURLError
|
|
14
|
+
├── GovernanceBlockedError # hook/activity verdict BLOCK
|
|
15
|
+
├── GovernanceHaltError # verdict HALT (framework-level termination)
|
|
16
|
+
├── GovernanceAPIError # governance API failure (fail_closed)
|
|
17
|
+
├── GuardrailsValidationError # guardrails validation_passed=False
|
|
18
|
+
├── ApprovalExpiredError # HITL approval window expired
|
|
19
|
+
├── ApprovalRejectedError # HITL approval explicitly rejected
|
|
20
|
+
└── ApprovalTimeoutError # HITL polling exceeded max wait
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from .contracts.results import Verdict
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"OpenBoxError",
|
|
32
|
+
"ContractError",
|
|
33
|
+
"OpenBoxConfigError",
|
|
34
|
+
"OpenBoxAuthError",
|
|
35
|
+
"OpenBoxNetworkError",
|
|
36
|
+
"OpenBoxInsecureURLError",
|
|
37
|
+
"OpenBoxSigningError",
|
|
38
|
+
"map_signing_error",
|
|
39
|
+
"GovernanceBlockedError",
|
|
40
|
+
"GovernanceHaltError",
|
|
41
|
+
"GovernanceAPIError",
|
|
42
|
+
"GuardrailsValidationError",
|
|
43
|
+
"ApprovalExpiredError",
|
|
44
|
+
"ApprovalRejectedError",
|
|
45
|
+
"ApprovalTimeoutError",
|
|
46
|
+
"extract_governance_error",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
51
|
+
# Base
|
|
52
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OpenBoxError(Exception):
|
|
56
|
+
"""Base class for all OpenBox SDK errors."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
60
|
+
# Strict-gate contract violations
|
|
61
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ContractError(OpenBoxError):
|
|
65
|
+
"""Raised by the always-strict gate on a malformed event/runtime contract.
|
|
66
|
+
|
|
67
|
+
Contract violations raise *before* any network send, regardless of the
|
|
68
|
+
``on_api_error`` fail-open/fail-closed setting — fail-open applies only to
|
|
69
|
+
network errors, never to contract violations.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
code: Machine-readable violation code (e.g. ``HOOK_TRIGGER_FALSE``).
|
|
73
|
+
detail: Optional structured context about the violation.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, message: str, code: str = "", detail: dict | None = None):
|
|
77
|
+
self.code = code
|
|
78
|
+
self.detail = detail or {}
|
|
79
|
+
super().__init__(message)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
83
|
+
# Configuration errors
|
|
84
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class OpenBoxConfigError(OpenBoxError):
|
|
88
|
+
"""Raised when OpenBox configuration fails."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class OpenBoxAuthError(OpenBoxConfigError):
|
|
92
|
+
"""Raised when API key validation fails."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class OpenBoxNetworkError(OpenBoxConfigError):
|
|
96
|
+
"""Raised when network connectivity fails."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class OpenBoxInsecureURLError(OpenBoxConfigError):
|
|
100
|
+
"""Raised when HTTP is used for non-localhost URLs."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class OpenBoxSigningError(OpenBoxAuthError):
|
|
104
|
+
"""Raised when Core rejects a signed (AIP DID) request.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
reason_code: Core's machine reason code (e.g. ``signature_invalid``).
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, message: str, reason_code: str | None = None):
|
|
111
|
+
self.reason_code = reason_code
|
|
112
|
+
super().__init__(message)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Core signed-request rejection reason codes → actionable SDK guidance.
|
|
116
|
+
# Forward-compatible: Core today often collapses identity failures into a
|
|
117
|
+
# generic "invalid token" body with no machine code; these richer messages
|
|
118
|
+
# activate once Core emits a machine reason code ("reason_code"/"code"/"reason").
|
|
119
|
+
_SIGNING_REASON_MESSAGES: dict[str, str] = {
|
|
120
|
+
"signature_invalid": (
|
|
121
|
+
"Request signature rejected (signature_invalid). The signed bytes did not "
|
|
122
|
+
"match — usually a body-hash mismatch (send content= bytes, never json=) or "
|
|
123
|
+
"a wrong/rotated private key."
|
|
124
|
+
),
|
|
125
|
+
"nonce_replayed": (
|
|
126
|
+
"Request nonce was already used (nonce_replayed). Each request must carry a "
|
|
127
|
+
"fresh nonce; do not retry a fully-prepared request verbatim."
|
|
128
|
+
),
|
|
129
|
+
"did_agent_mismatch": (
|
|
130
|
+
"DID does not match the authenticated agent (did_agent_mismatch). Check that "
|
|
131
|
+
"agent_did matches the agent the API key/private key were provisioned for."
|
|
132
|
+
),
|
|
133
|
+
"verifier_not_configured": (
|
|
134
|
+
"Core has no verifier for this agent (verifier_not_configured). The agent's "
|
|
135
|
+
"public key may not be imported to KMS yet — re-provision the agent."
|
|
136
|
+
),
|
|
137
|
+
# Core's code is "timestamp_outside_window"; "timestamp_skew" kept as an alias.
|
|
138
|
+
"timestamp_outside_window": (
|
|
139
|
+
"Request timestamp outside the allowed window (timestamp_outside_window). Sync "
|
|
140
|
+
"the host clock (NTP); signatures are valid only within ±300s."
|
|
141
|
+
),
|
|
142
|
+
"timestamp_skew": (
|
|
143
|
+
"Request timestamp outside the allowed window (timestamp_skew). Sync the host "
|
|
144
|
+
"clock (NTP); signatures are valid only within ±300s."
|
|
145
|
+
),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def map_signing_error(reason_code: str | None, fallback: str = "") -> OpenBoxSigningError:
|
|
150
|
+
"""Map a Core signing reason code to an actionable OpenBoxSigningError.
|
|
151
|
+
|
|
152
|
+
Unknown/empty codes fall back to a generic message (optionally augmented with
|
|
153
|
+
``fallback`` context). Never raises — always returns an exception to raise.
|
|
154
|
+
"""
|
|
155
|
+
if reason_code and reason_code in _SIGNING_REASON_MESSAGES:
|
|
156
|
+
return OpenBoxSigningError(_SIGNING_REASON_MESSAGES[reason_code], reason_code)
|
|
157
|
+
msg = fallback or (
|
|
158
|
+
"Signed request rejected by OpenBox Core"
|
|
159
|
+
+ (f" ({reason_code})" if reason_code else "")
|
|
160
|
+
+ "."
|
|
161
|
+
)
|
|
162
|
+
return OpenBoxSigningError(msg, reason_code)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
166
|
+
# Governance verdict errors
|
|
167
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class GovernanceBlockedError(OpenBoxError):
|
|
171
|
+
"""Raised when governance blocks an operation (default adapter behavior).
|
|
172
|
+
|
|
173
|
+
Framework adapters typically translate this into a native error type; the
|
|
174
|
+
base adapter raises it directly.
|
|
175
|
+
|
|
176
|
+
Attributes:
|
|
177
|
+
verdict: The Verdict enum value (normalized from string if needed).
|
|
178
|
+
reason: Human-readable explanation from the policy engine.
|
|
179
|
+
url: The URL or resource identifier that was blocked (optional).
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self, verdict: str | Verdict, reason: str, url: str = ""):
|
|
183
|
+
# Lazy import avoids a hard module-level dependency on contracts.
|
|
184
|
+
if isinstance(verdict, str):
|
|
185
|
+
from .contracts.results import Verdict
|
|
186
|
+
|
|
187
|
+
self.verdict = Verdict.from_string(verdict)
|
|
188
|
+
else:
|
|
189
|
+
self.verdict = verdict
|
|
190
|
+
self.reason = reason
|
|
191
|
+
self.url = url
|
|
192
|
+
super().__init__(f"Governance {self.verdict.value}: {reason}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class GovernanceHaltError(OpenBoxError):
|
|
196
|
+
"""Raised when governance halts execution (HALT verdict).
|
|
197
|
+
|
|
198
|
+
HALT is the nuclear option — the framework adapter decides how to stop
|
|
199
|
+
future work.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, message: str):
|
|
203
|
+
super().__init__(message)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class GovernanceAPIError(OpenBoxError):
|
|
207
|
+
"""Raised when the governance API fails and policy is fail_closed."""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
211
|
+
# Guardrails errors
|
|
212
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class GuardrailsValidationError(OpenBoxError):
|
|
216
|
+
"""Raised when guardrails validation_passed is False.
|
|
217
|
+
|
|
218
|
+
Attributes:
|
|
219
|
+
reasons: List of reason strings from the guardrails evaluation.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def __init__(self, reasons: list[str] | None = None):
|
|
223
|
+
self.reasons = reasons or []
|
|
224
|
+
reason_str = (
|
|
225
|
+
"; ".join(self.reasons) if self.reasons else "Guardrails validation failed"
|
|
226
|
+
)
|
|
227
|
+
super().__init__(reason_str)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
231
|
+
# HITL approval errors
|
|
232
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ApprovalExpiredError(OpenBoxError):
|
|
236
|
+
"""Raised when the HITL approval window expires (server-side deadline)."""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class ApprovalRejectedError(OpenBoxError):
|
|
240
|
+
"""Raised when a HITL approval is explicitly rejected by a human."""
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class ApprovalTimeoutError(OpenBoxError):
|
|
244
|
+
"""Raised when HITL polling exceeds the configured max wait time."""
|
|
245
|
+
|
|
246
|
+
def __init__(self, max_wait_ms: int | None = None):
|
|
247
|
+
self.max_wait_ms = max_wait_ms
|
|
248
|
+
msg = (
|
|
249
|
+
f"Approval polling timed out after {max_wait_ms}ms"
|
|
250
|
+
if max_wait_ms
|
|
251
|
+
else "Approval polling timed out"
|
|
252
|
+
)
|
|
253
|
+
super().__init__(msg)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
257
|
+
# Utility: exception chain walker
|
|
258
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def extract_governance_error(exc: BaseException) -> GovernanceBlockedError | None:
|
|
262
|
+
"""Walk an exception chain to find a wrapped GovernanceBlockedError.
|
|
263
|
+
|
|
264
|
+
Frameworks and client libraries often wrap errors. This utility recovers
|
|
265
|
+
the original GovernanceBlockedError for verdict inspection.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
exc: Any exception, potentially wrapping a GovernanceBlockedError.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
The GovernanceBlockedError if found in the chain, None otherwise.
|
|
272
|
+
"""
|
|
273
|
+
seen: set[int] = set()
|
|
274
|
+
current: BaseException | None = exc
|
|
275
|
+
while current is not None and id(current) not in seen:
|
|
276
|
+
seen.add(id(current))
|
|
277
|
+
if isinstance(current, GovernanceBlockedError):
|
|
278
|
+
return current
|
|
279
|
+
# Walk both explicit (__cause__) and implicit (__context__) chains
|
|
280
|
+
next_exc = getattr(current, "__cause__", None) or getattr(
|
|
281
|
+
current, "__context__", None
|
|
282
|
+
)
|
|
283
|
+
# Also check framework .cause properties.
|
|
284
|
+
if next_exc is None:
|
|
285
|
+
next_exc = getattr(current, "cause", None)
|
|
286
|
+
current = next_exc
|
|
287
|
+
return None
|
openbox_core/gate.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""GovernanceGate — always-strict validation orchestrating evaluate calls.
|
|
2
|
+
|
|
3
|
+
The gate is ALWAYS STRICT for OpenBox event contracts and runtime invariants:
|
|
4
|
+
malformed contracts raise ``ContractError`` BEFORE any network send. There is
|
|
5
|
+
deliberately NO ``mode``/``OBSERVE``/``SANITIZE``/``STRICT`` toggle anywhere in
|
|
6
|
+
this API — fail-open policy applies only to network errors inside the client,
|
|
7
|
+
never to contract violations.
|
|
8
|
+
|
|
9
|
+
Paths:
|
|
10
|
+
- ``evaluate``/``aevaluate`` — lifecycle events (no spans); the gate
|
|
11
|
+
serializes the envelope dict directly.
|
|
12
|
+
- ``preflight``/``apreflight`` — hook STARTED-stage evaluation. Enforced by
|
|
13
|
+
the hook runtime/adapter (BLOCK/HALT/REQUIRE_APPROVAL stop the operation).
|
|
14
|
+
- ``completed``/``acompleted`` — hook COMPLETED-stage telemetry. Influences
|
|
15
|
+
*future* execution only; it never undoes the operation.
|
|
16
|
+
|
|
17
|
+
Hook body assembly is delegated to the injected ``payload_builder`` —
|
|
18
|
+
``wire/evaluate_payload.build_evaluate_payload`` is the single owner of the
|
|
19
|
+
evaluate body shape. The gate never reimplements it.
|
|
20
|
+
|
|
21
|
+
Enforcement priority (for adapters interpreting results):
|
|
22
|
+
HALT > BLOCK > guardrails-fail > REQUIRE_APPROVAL > CONSTRAIN > ALLOW.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from .client import EvaluationClient
|
|
31
|
+
from .config import OpenBoxConfig
|
|
32
|
+
from .contracts.events import EventEnvelope
|
|
33
|
+
from .contracts.results import EvaluationResult, Verdict
|
|
34
|
+
from .errors import (
|
|
35
|
+
GovernanceBlockedError,
|
|
36
|
+
GovernanceHaltError,
|
|
37
|
+
GuardrailsValidationError,
|
|
38
|
+
OpenBoxConfigError,
|
|
39
|
+
)
|
|
40
|
+
from .serialization import apply_redaction, rfc3339_now, to_json_safe
|
|
41
|
+
from .validation.diagnostics import Diagnostic
|
|
42
|
+
from .validation.registry import validate_hook, validate_lifecycle
|
|
43
|
+
from .validation.span_normalization import redaction_diagnostics, strip_compat_noise
|
|
44
|
+
|
|
45
|
+
__all__ = ["GovernanceGate", "STAGE_STARTED", "STAGE_COMPLETED", "raise_for_verdict"]
|
|
46
|
+
|
|
47
|
+
STAGE_STARTED = "started"
|
|
48
|
+
STAGE_COMPLETED = "completed"
|
|
49
|
+
|
|
50
|
+
# payload_builder(event) -> (payload_dict, diagnostics)
|
|
51
|
+
PayloadBuilder = Callable[[EventEnvelope], tuple[dict[str, Any], list[Diagnostic]]]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GovernanceGate:
|
|
55
|
+
"""Validate → serialize → evaluate → parse, strictly.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
client: The EvaluationClient used for network calls.
|
|
59
|
+
config: Resolved OpenBoxConfig (privacy settings are read here).
|
|
60
|
+
payload_builder: Hook evaluate-body assembler. Wired to
|
|
61
|
+
``wire/evaluate_payload.build_evaluate_payload`` by the runtime;
|
|
62
|
+
hook paths raise until one is provided (lifecycle paths work
|
|
63
|
+
without it).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
client: EvaluationClient,
|
|
69
|
+
config: OpenBoxConfig | None = None,
|
|
70
|
+
*,
|
|
71
|
+
payload_builder: PayloadBuilder | None = None,
|
|
72
|
+
):
|
|
73
|
+
self._client = client
|
|
74
|
+
self._config = config or OpenBoxConfig()
|
|
75
|
+
self._payload_builder = payload_builder
|
|
76
|
+
|
|
77
|
+
# ── Lifecycle events ──────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
def evaluate(self, event: EventEnvelope) -> EvaluationResult:
|
|
80
|
+
"""Evaluate a lifecycle/signal/handoff event (strictly validated)."""
|
|
81
|
+
payload, diagnostics = self._prepare_lifecycle(event)
|
|
82
|
+
result = self._client.evaluate(payload)
|
|
83
|
+
result.diagnostics.extend(d.to_dict() for d in diagnostics)
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
async def aevaluate(self, event: EventEnvelope) -> EvaluationResult:
|
|
87
|
+
"""Async :meth:`evaluate`."""
|
|
88
|
+
payload, diagnostics = self._prepare_lifecycle(event)
|
|
89
|
+
result = await self._client.aevaluate(payload)
|
|
90
|
+
result.diagnostics.extend(d.to_dict() for d in diagnostics)
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
def _prepare_lifecycle(self, event: EventEnvelope) -> tuple[dict, list[Diagnostic]]:
|
|
94
|
+
diagnostics = validate_lifecycle(event)
|
|
95
|
+
payload = event.to_payload_dict()
|
|
96
|
+
payload.setdefault("timestamp", rfc3339_now())
|
|
97
|
+
# Empty span compatibility noise never reaches the wire.
|
|
98
|
+
payload, noise_diagnostics = strip_compat_noise(payload)
|
|
99
|
+
diagnostics.extend(noise_diagnostics)
|
|
100
|
+
return self._finalize_payload(payload, diagnostics)
|
|
101
|
+
|
|
102
|
+
# ── Hook events ───────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
def preflight(self, event: EventEnvelope) -> EvaluationResult:
|
|
105
|
+
"""Started-stage hook evaluation (validated; body via payload_builder)."""
|
|
106
|
+
payload, diagnostics = self._prepare_hook(event, STAGE_STARTED)
|
|
107
|
+
result = self._client.evaluate(payload)
|
|
108
|
+
result.diagnostics.extend(d.to_dict() for d in diagnostics)
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
async def apreflight(self, event: EventEnvelope) -> EvaluationResult:
|
|
112
|
+
"""Async :meth:`preflight`."""
|
|
113
|
+
payload, diagnostics = self._prepare_hook(event, STAGE_STARTED)
|
|
114
|
+
result = await self._client.aevaluate(payload)
|
|
115
|
+
result.diagnostics.extend(d.to_dict() for d in diagnostics)
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
def completed(self, event: EventEnvelope) -> EvaluationResult:
|
|
119
|
+
"""Completed-stage hook telemetry. Never undoes the operation — the
|
|
120
|
+
result may only influence FUTURE execution (adapter decides)."""
|
|
121
|
+
payload, diagnostics = self._prepare_hook(event, STAGE_COMPLETED)
|
|
122
|
+
result = self._client.evaluate(payload)
|
|
123
|
+
result.diagnostics.extend(d.to_dict() for d in diagnostics)
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
async def acompleted(self, event: EventEnvelope) -> EvaluationResult:
|
|
127
|
+
"""Async :meth:`completed`."""
|
|
128
|
+
payload, diagnostics = self._prepare_hook(event, STAGE_COMPLETED)
|
|
129
|
+
result = await self._client.aevaluate(payload)
|
|
130
|
+
result.diagnostics.extend(d.to_dict() for d in diagnostics)
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
def _prepare_hook(self, event: EventEnvelope, stage: str) -> tuple[dict, list[Diagnostic]]:
|
|
134
|
+
diagnostics = validate_hook(event, stage)
|
|
135
|
+
if self._payload_builder is None:
|
|
136
|
+
raise OpenBoxConfigError(
|
|
137
|
+
"GovernanceGate has no payload_builder — hook evaluation requires "
|
|
138
|
+
"wire/evaluate_payload.build_evaluate_payload (wired by OpenBoxRuntime)"
|
|
139
|
+
)
|
|
140
|
+
payload, builder_diagnostics = self._payload_builder(event)
|
|
141
|
+
diagnostics.extend(builder_diagnostics)
|
|
142
|
+
payload.setdefault("timestamp", rfc3339_now())
|
|
143
|
+
return self._finalize_payload(payload, diagnostics)
|
|
144
|
+
|
|
145
|
+
# ── Shared payload finishing ──────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
def _finalize_payload(
|
|
148
|
+
self, payload: dict, diagnostics: list[Diagnostic]
|
|
149
|
+
) -> tuple[dict, list[Diagnostic]]:
|
|
150
|
+
"""JSON-safety + privacy redaction, applied BEFORE signing/sending.
|
|
151
|
+
|
|
152
|
+
``exclude_none=False`` is deliberate: started-stage spans carry
|
|
153
|
+
EXPLICIT ``end_time: null`` / ``duration_ns: null`` (Core's non-pointer
|
|
154
|
+
int64 relies on them), so nulls must survive to the wire.
|
|
155
|
+
Omit-when-absent is handled where payloads are BUILT (keys left out),
|
|
156
|
+
not by dropping None here.
|
|
157
|
+
"""
|
|
158
|
+
payload = to_json_safe(payload, exclude_none=False)
|
|
159
|
+
redact_keys = self._config.privacy.redact_keys
|
|
160
|
+
if redact_keys:
|
|
161
|
+
payload, changed = apply_redaction(payload, redact_keys)
|
|
162
|
+
diagnostics.extend(redaction_diagnostics(changed))
|
|
163
|
+
return payload, diagnostics
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def raise_for_verdict(result: EvaluationResult) -> EvaluationResult | None:
|
|
167
|
+
"""Default enforcement helper implementing the verdict priority order.
|
|
168
|
+
|
|
169
|
+
HALT > BLOCK > guardrails-fail > REQUIRE_APPROVAL > CONSTRAIN > ALLOW.
|
|
170
|
+
|
|
171
|
+
Raises the core error types for stop-shaped results; returns the result
|
|
172
|
+
for REQUIRE_APPROVAL (caller drives approval) and for ALLOW/CONSTRAIN
|
|
173
|
+
(caller proceeds). Framework adapters typically translate these into
|
|
174
|
+
native errors instead of calling this helper.
|
|
175
|
+
"""
|
|
176
|
+
verdict = result.verdict
|
|
177
|
+
if verdict == Verdict.HALT:
|
|
178
|
+
raise GovernanceHaltError(result.reason or "Halted by governance policy")
|
|
179
|
+
if verdict == Verdict.BLOCK:
|
|
180
|
+
raise GovernanceBlockedError(verdict, result.reason or "Blocked by governance policy")
|
|
181
|
+
# Guardrails failure outranks approval so it is never swallowed by a HITL flow.
|
|
182
|
+
if result.guardrails and not result.guardrails.validation_passed:
|
|
183
|
+
reasons = result.guardrails.get_reason_strings()
|
|
184
|
+
raise GuardrailsValidationError(reasons or ["Guardrails validation failed"])
|
|
185
|
+
return result
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Hook event assembly from the bound ActivityContext + serialized OTel span."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..context import ContextStore
|
|
10
|
+
from ..contracts.context import ActivityContext
|
|
11
|
+
from ..contracts.events import EventEnvelope, hook
|
|
12
|
+
from ..contracts.otel_spans import HookType, Stage, from_otel_span
|
|
13
|
+
from ..otel.trace_context import raw_trace_id
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
__all__ = ["resolve_context", "build_hook_event"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_context(store: ContextStore, span: Any) -> ActivityContext | None:
|
|
21
|
+
"""Bound context via ContextVar first, trace-map fallback second.
|
|
22
|
+
|
|
23
|
+
The trace-map path serves code running where ContextVars don't propagate
|
|
24
|
+
(executor threads); both sides key by the canonical integer trace id.
|
|
25
|
+
"""
|
|
26
|
+
ctx = store.current_activity_context()
|
|
27
|
+
if ctx is not None:
|
|
28
|
+
return ctx
|
|
29
|
+
trace_id = raw_trace_id(span)
|
|
30
|
+
if trace_id is not None and trace_id != 0:
|
|
31
|
+
return store.context_for_trace(trace_id)
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_hook_event(
|
|
36
|
+
store: ContextStore,
|
|
37
|
+
span: Any,
|
|
38
|
+
*,
|
|
39
|
+
stage: Stage,
|
|
40
|
+
hook_type: HookType,
|
|
41
|
+
fields: Mapping[str, Any] | None = None,
|
|
42
|
+
) -> EventEnvelope | None:
|
|
43
|
+
"""Build a hook EventEnvelope, or None when the hook must be SKIPPED.
|
|
44
|
+
|
|
45
|
+
No bound ActivityContext (or a context without an activity binding) means
|
|
46
|
+
this operation is not inside a governed activity — skipping is the
|
|
47
|
+
CORRECT behavior, not an error.
|
|
48
|
+
"""
|
|
49
|
+
ctx = resolve_context(store, span)
|
|
50
|
+
if ctx is None:
|
|
51
|
+
logger.debug("hook skipped: no bound ActivityContext for span")
|
|
52
|
+
return None
|
|
53
|
+
if not ctx.activity_id or not ctx.activity_type:
|
|
54
|
+
logger.debug("hook skipped: bound context has no activity binding")
|
|
55
|
+
return None
|
|
56
|
+
span_data = from_otel_span(
|
|
57
|
+
span, stage=stage, hook_type=hook_type, activity_context=None, fields=fields
|
|
58
|
+
)
|
|
59
|
+
return hook(
|
|
60
|
+
activity_context=ctx.to_payload_fields(),
|
|
61
|
+
activity_id=ctx.activity_id,
|
|
62
|
+
activity_type=ctx.activity_type,
|
|
63
|
+
spans=[span_data],
|
|
64
|
+
)
|