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
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Span contracts — Stage, HookType, and flat OpenBox hook spans.
|
|
2
|
+
|
|
3
|
+
OTel spans are the INTERNAL source of truth (there are no fixed
|
|
4
|
+
HttpSpan/DbSpan/... dataclasses), but ``from_otel_span`` returns the flat Core
|
|
5
|
+
``SpanData`` shape immediately. There is no SDK-visible
|
|
6
|
+
``{"otel": ..., "openbox": ...}`` span envelope.
|
|
7
|
+
|
|
8
|
+
Pure module: span access is DUCK-TYPED (plain getattr) so importing this never
|
|
9
|
+
pulls in opentelemetry — the import-safety harness holds.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ..otel.trace_context import format_span_id, format_trace_id
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Stage",
|
|
22
|
+
"HookType",
|
|
23
|
+
"serialize_readable_span",
|
|
24
|
+
"from_otel_span",
|
|
25
|
+
"OpenBoxSpanAdapter",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Stage(str, Enum):
|
|
30
|
+
"""Hook evaluation stage."""
|
|
31
|
+
|
|
32
|
+
STARTED = "started"
|
|
33
|
+
COMPLETED = "completed"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HookType(str, Enum):
|
|
37
|
+
"""Operation category of a hook span (Core root field ``hook_type``)."""
|
|
38
|
+
|
|
39
|
+
HTTP_REQUEST = "http_request"
|
|
40
|
+
DB_QUERY = "db_query"
|
|
41
|
+
FILE_OPERATION = "file_operation"
|
|
42
|
+
FUNCTION_CALL = "function_call"
|
|
43
|
+
LLM_CALL = "llm_call" # reserved; disabled until provider hooks are implemented
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _span_context_of(span: Any) -> Any:
|
|
47
|
+
if hasattr(span, "get_span_context"):
|
|
48
|
+
try:
|
|
49
|
+
return span.get_span_context()
|
|
50
|
+
except Exception:
|
|
51
|
+
return None
|
|
52
|
+
return getattr(span, "context", None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _enum_name(value: Any) -> str | None:
|
|
56
|
+
"""'SpanKind.CLIENT' -> 'CLIENT'; plain strings pass through."""
|
|
57
|
+
if value is None:
|
|
58
|
+
return None
|
|
59
|
+
name = getattr(value, "name", None)
|
|
60
|
+
if isinstance(name, str):
|
|
61
|
+
return name
|
|
62
|
+
text = str(value)
|
|
63
|
+
return text.split(".")[-1] if text else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _attributes_of(obj: Any) -> dict[str, Any]:
|
|
67
|
+
attributes = getattr(obj, "attributes", None)
|
|
68
|
+
if attributes is None:
|
|
69
|
+
return {}
|
|
70
|
+
try:
|
|
71
|
+
return dict(attributes)
|
|
72
|
+
except Exception:
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Best-effort semantic attribute mapping: wire field -> OTel keys (new, legacy).
|
|
77
|
+
_SEMANTIC_ATTR_MAP: dict[str, tuple[str, ...]] = {
|
|
78
|
+
"http_url": ("url.full", "http.url"),
|
|
79
|
+
"http_method": ("http.request.method", "http.method"),
|
|
80
|
+
"http_status_code": ("http.response.status_code", "http.status_code"),
|
|
81
|
+
"db_system": ("db.system.name", "db.system"),
|
|
82
|
+
"db_name": ("db.namespace", "db.name"),
|
|
83
|
+
"db_operation": ("db.operation.name", "db.operation"),
|
|
84
|
+
"db_statement": ("db.query.text", "db.statement"),
|
|
85
|
+
"server_address": ("server.address", "net.peer.name"),
|
|
86
|
+
"server_port": ("server.port", "net.peer.port"),
|
|
87
|
+
"file_path": ("file.path",),
|
|
88
|
+
"file_mode": ("file.mode",),
|
|
89
|
+
"file_operation": ("file.operation",),
|
|
90
|
+
"function": ("code.function.name", "code.function"),
|
|
91
|
+
"module": ("code.namespace", "code.module"),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_DEFAULT_KIND_BY_HOOK: dict[str, str] = {
|
|
95
|
+
"http_request": "CLIENT",
|
|
96
|
+
"db_query": "CLIENT",
|
|
97
|
+
"file_operation": "INTERNAL",
|
|
98
|
+
"function_call": "INTERNAL",
|
|
99
|
+
"llm_call": "CLIENT",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Family-specific root fields that must exist, null-valued when unavailable, so
|
|
103
|
+
# hook spans have the same flat key contract in memory and on the wire.
|
|
104
|
+
_ROOT_FIELDS_BY_HOOK_TYPE: dict[str, tuple[str, ...]] = {
|
|
105
|
+
"http_request": (
|
|
106
|
+
"http_method",
|
|
107
|
+
"http_url",
|
|
108
|
+
"http_status_code",
|
|
109
|
+
"request_headers",
|
|
110
|
+
"response_headers",
|
|
111
|
+
"request_body",
|
|
112
|
+
"response_body",
|
|
113
|
+
),
|
|
114
|
+
"db_query": (
|
|
115
|
+
"db_system",
|
|
116
|
+
"db_name",
|
|
117
|
+
"db_operation",
|
|
118
|
+
"db_statement",
|
|
119
|
+
"server_address",
|
|
120
|
+
"server_port",
|
|
121
|
+
"rowcount",
|
|
122
|
+
),
|
|
123
|
+
"file_operation": (
|
|
124
|
+
"file_path",
|
|
125
|
+
"file_mode",
|
|
126
|
+
"file_operation",
|
|
127
|
+
"bytes_read",
|
|
128
|
+
"bytes_written",
|
|
129
|
+
),
|
|
130
|
+
"function_call": ("function", "module", "args", "result"),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _attr(attributes: Mapping[str, Any], keys: tuple[str, ...]) -> Any:
|
|
135
|
+
for key in keys:
|
|
136
|
+
value = attributes.get(key)
|
|
137
|
+
if value is not None:
|
|
138
|
+
return value
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def serialize_readable_span(span: Any) -> dict[str, Any]:
|
|
143
|
+
"""Preserve the OTel surface the SDK exposes, via duck-typed access.
|
|
144
|
+
|
|
145
|
+
Ids stay RAW INTEGERS here (internal representation; the wire projection
|
|
146
|
+
formats hex strings). Missing pieces degrade to None/empty — a span is
|
|
147
|
+
never rejected during serialization.
|
|
148
|
+
"""
|
|
149
|
+
span_context = _span_context_of(span)
|
|
150
|
+
context_part = None
|
|
151
|
+
if span_context is not None:
|
|
152
|
+
span_id = getattr(span_context, "span_id", None)
|
|
153
|
+
trace_id = getattr(span_context, "trace_id", None)
|
|
154
|
+
context_part = {
|
|
155
|
+
"span_id": span_id if isinstance(span_id, int) else None,
|
|
156
|
+
"trace_id": trace_id if isinstance(trace_id, int) else None,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
parent = getattr(span, "parent", None)
|
|
160
|
+
parent_part = None
|
|
161
|
+
if parent is not None:
|
|
162
|
+
parent_span_id = getattr(parent, "span_id", None)
|
|
163
|
+
if isinstance(parent_span_id, int):
|
|
164
|
+
parent_part = {"span_id": parent_span_id}
|
|
165
|
+
|
|
166
|
+
status = getattr(span, "status", None)
|
|
167
|
+
status_part = None
|
|
168
|
+
if status is not None:
|
|
169
|
+
status_part = {
|
|
170
|
+
"code": _enum_name(getattr(status, "status_code", None)) or "UNSET",
|
|
171
|
+
"description": getattr(status, "description", None),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
events = []
|
|
175
|
+
for event in getattr(span, "events", None) or ():
|
|
176
|
+
events.append(
|
|
177
|
+
{
|
|
178
|
+
"name": getattr(event, "name", None),
|
|
179
|
+
"timestamp": getattr(event, "timestamp", None),
|
|
180
|
+
"attributes": _attributes_of(event),
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
links = []
|
|
185
|
+
for link in getattr(span, "links", None) or ():
|
|
186
|
+
link_context = getattr(link, "context", None)
|
|
187
|
+
links.append(
|
|
188
|
+
{
|
|
189
|
+
"context": {
|
|
190
|
+
"span_id": getattr(link_context, "span_id", None),
|
|
191
|
+
"trace_id": getattr(link_context, "trace_id", None),
|
|
192
|
+
},
|
|
193
|
+
"attributes": _attributes_of(link),
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
resource = getattr(span, "resource", None)
|
|
198
|
+
resource_part = _attributes_of(resource) if resource is not None else None
|
|
199
|
+
|
|
200
|
+
scope = getattr(span, "instrumentation_scope", None)
|
|
201
|
+
scope_part = None
|
|
202
|
+
if scope is not None:
|
|
203
|
+
scope_part = {
|
|
204
|
+
"name": getattr(scope, "name", None),
|
|
205
|
+
"version": getattr(scope, "version", None),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
start_time = getattr(span, "start_time", None)
|
|
209
|
+
end_time = getattr(span, "end_time", None)
|
|
210
|
+
return {
|
|
211
|
+
"context": context_part,
|
|
212
|
+
"parent": parent_part,
|
|
213
|
+
"name": getattr(span, "name", None),
|
|
214
|
+
"kind": _enum_name(getattr(span, "kind", None)),
|
|
215
|
+
"start_time": start_time if isinstance(start_time, int) else None,
|
|
216
|
+
"end_time": end_time if isinstance(end_time, int) else None,
|
|
217
|
+
"attributes": _attributes_of(span),
|
|
218
|
+
"events": events,
|
|
219
|
+
"links": links,
|
|
220
|
+
"status": status_part,
|
|
221
|
+
"resource": resource_part,
|
|
222
|
+
"instrumentation_scope": scope_part,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def from_otel_span(
|
|
227
|
+
span: Any,
|
|
228
|
+
*,
|
|
229
|
+
stage: Stage | str,
|
|
230
|
+
hook_type: HookType | str | None = None,
|
|
231
|
+
activity_context: Any = None,
|
|
232
|
+
fields: Mapping[str, Any] | None = None,
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
"""Build one flat Core ``SpanData`` dict from an OTel span.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
span: OTel span (Span/ReadableSpan/NonRecordingSpan — duck-typed).
|
|
238
|
+
stage: started/completed.
|
|
239
|
+
hook_type: Operation category (None for non-hook telemetry spans).
|
|
240
|
+
activity_context: Accepted for backward-compatible call sites; context is
|
|
241
|
+
stored on the surrounding event, not inside the span.
|
|
242
|
+
fields: Wrapper-supplied Core root fields (request_body, rowcount,
|
|
243
|
+
args, ...) merged at the span root. These carry data OTel attributes
|
|
244
|
+
don't (bodies, results).
|
|
245
|
+
"""
|
|
246
|
+
_ = activity_context
|
|
247
|
+
stage_value = stage.value if isinstance(stage, Stage) else str(stage)
|
|
248
|
+
hook_value = hook_type.value if isinstance(hook_type, HookType) else hook_type
|
|
249
|
+
otel = serialize_readable_span(span)
|
|
250
|
+
context = otel.get("context") or {}
|
|
251
|
+
parent = otel.get("parent") or {}
|
|
252
|
+
span_id_int = context.get("span_id")
|
|
253
|
+
trace_id_int = context.get("trace_id")
|
|
254
|
+
parent_id_int = parent.get("span_id")
|
|
255
|
+
attributes = dict(otel.get("attributes") or {})
|
|
256
|
+
|
|
257
|
+
start_time = otel.get("start_time")
|
|
258
|
+
end_time = otel.get("end_time")
|
|
259
|
+
if stage_value == Stage.STARTED.value:
|
|
260
|
+
end_time = None
|
|
261
|
+
duration_ns = None
|
|
262
|
+
else:
|
|
263
|
+
duration_ns = (
|
|
264
|
+
end_time - start_time
|
|
265
|
+
if isinstance(end_time, int) and isinstance(start_time, int)
|
|
266
|
+
else None
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
wire: dict[str, Any] = {
|
|
270
|
+
"span_id": format_span_id(span_id_int) if isinstance(span_id_int, int) else "0" * 16,
|
|
271
|
+
"trace_id": format_trace_id(trace_id_int) if isinstance(trace_id_int, int) else "0" * 32,
|
|
272
|
+
"parent_span_id": format_span_id(parent_id_int) if isinstance(parent_id_int, int) else None,
|
|
273
|
+
"name": otel.get("name") or (hook_value or "span"),
|
|
274
|
+
"kind": otel.get("kind") or _DEFAULT_KIND_BY_HOOK.get(hook_value or "", "INTERNAL"),
|
|
275
|
+
"stage": stage_value,
|
|
276
|
+
"start_time": start_time,
|
|
277
|
+
"end_time": end_time,
|
|
278
|
+
"duration_ns": duration_ns,
|
|
279
|
+
"attributes": attributes,
|
|
280
|
+
"status": otel.get("status") or {"code": "UNSET", "description": None},
|
|
281
|
+
"events": otel.get("events") or [],
|
|
282
|
+
"error": None,
|
|
283
|
+
}
|
|
284
|
+
if hook_value:
|
|
285
|
+
wire["hook_type"] = hook_value
|
|
286
|
+
|
|
287
|
+
for wire_field, attr_keys in _SEMANTIC_ATTR_MAP.items():
|
|
288
|
+
value = _attr(attributes, attr_keys)
|
|
289
|
+
if value is not None:
|
|
290
|
+
wire[wire_field] = value
|
|
291
|
+
|
|
292
|
+
for field_name, value in dict(fields or {}).items():
|
|
293
|
+
if value is not None:
|
|
294
|
+
wire[field_name] = value
|
|
295
|
+
|
|
296
|
+
for field_name in _ROOT_FIELDS_BY_HOOK_TYPE.get(hook_value or "", ()):
|
|
297
|
+
wire.setdefault(field_name, None)
|
|
298
|
+
|
|
299
|
+
if stage_value != Stage.STARTED.value and wire.get("end_time") is None:
|
|
300
|
+
measured = wire.get("duration_ns")
|
|
301
|
+
if isinstance(start_time, int) and isinstance(measured, int):
|
|
302
|
+
wire["end_time"] = start_time + measured
|
|
303
|
+
|
|
304
|
+
return wire
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class OpenBoxSpanAdapter:
|
|
308
|
+
"""Adapter object form of :func:`from_otel_span` (protocol-compatible)."""
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def from_otel_span(
|
|
312
|
+
span: Any,
|
|
313
|
+
*,
|
|
314
|
+
stage: Stage | str,
|
|
315
|
+
hook_type: HookType | str | None = None,
|
|
316
|
+
activity_context: Any = None,
|
|
317
|
+
fields: Mapping[str, Any] | None = None,
|
|
318
|
+
) -> dict[str, Any]:
|
|
319
|
+
return from_otel_span(
|
|
320
|
+
span,
|
|
321
|
+
stage=stage,
|
|
322
|
+
hook_type=hook_type,
|
|
323
|
+
activity_context=activity_context,
|
|
324
|
+
fields=fields,
|
|
325
|
+
)
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Result contracts — Verdict, GuardrailsResult, EvaluationResult, ApprovalResult.
|
|
2
|
+
|
|
3
|
+
Pure, import-safe module: no network, crypto, OTel, logging, wall-clock, or
|
|
4
|
+
random. Strict dataclass constructors AND loose ``from_dict()`` parsers are
|
|
5
|
+
both public so callers can work with typed values or raw backend dicts.
|
|
6
|
+
|
|
7
|
+
Parsing preserves ``raw`` so nothing the backend sent is ever lost, and stays
|
|
8
|
+
tolerant of unknown keys (field-shape drift from Core must not crash SDKs).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Verdict",
|
|
19
|
+
"GuardrailsResult",
|
|
20
|
+
"EvaluationResult",
|
|
21
|
+
"ApprovalResult",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Verdict(str, Enum):
|
|
26
|
+
"""5-tier graduated response. Priority: HALT > BLOCK > REQUIRE_APPROVAL > CONSTRAIN > ALLOW."""
|
|
27
|
+
|
|
28
|
+
ALLOW = "allow"
|
|
29
|
+
CONSTRAIN = "constrain"
|
|
30
|
+
REQUIRE_APPROVAL = "require_approval"
|
|
31
|
+
BLOCK = "block"
|
|
32
|
+
HALT = "halt"
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_string(cls, value: str | None) -> Verdict:
|
|
36
|
+
"""Parse with v1.0 compat: 'continue'→ALLOW, 'stop'→HALT, 'require-approval'→REQUIRE_APPROVAL."""
|
|
37
|
+
if value is None:
|
|
38
|
+
return cls.ALLOW
|
|
39
|
+
normalized = value.lower().replace("-", "_")
|
|
40
|
+
if normalized == "continue":
|
|
41
|
+
return cls.ALLOW
|
|
42
|
+
if normalized == "stop":
|
|
43
|
+
return cls.HALT
|
|
44
|
+
if normalized in ("require_approval", "request_approval"):
|
|
45
|
+
return cls.REQUIRE_APPROVAL
|
|
46
|
+
try:
|
|
47
|
+
return cls(normalized)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return cls.ALLOW
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def priority(self) -> int:
|
|
53
|
+
"""Priority for aggregation: HALT=5, BLOCK=4, REQUIRE_APPROVAL=3, CONSTRAIN=2, ALLOW=1."""
|
|
54
|
+
return {
|
|
55
|
+
Verdict.ALLOW: 1,
|
|
56
|
+
Verdict.CONSTRAIN: 2,
|
|
57
|
+
Verdict.REQUIRE_APPROVAL: 3,
|
|
58
|
+
Verdict.BLOCK: 4,
|
|
59
|
+
Verdict.HALT: 5,
|
|
60
|
+
}[self]
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def highest_priority(cls, verdicts: list[Verdict]) -> Verdict:
|
|
64
|
+
"""Get highest priority verdict from list. Returns ALLOW if empty."""
|
|
65
|
+
return max(verdicts, key=lambda v: v.priority) if verdicts else cls.ALLOW
|
|
66
|
+
|
|
67
|
+
def should_stop(self) -> bool:
|
|
68
|
+
"""True if BLOCK or HALT."""
|
|
69
|
+
return self in (Verdict.BLOCK, Verdict.HALT)
|
|
70
|
+
|
|
71
|
+
def requires_approval(self) -> bool:
|
|
72
|
+
"""True if REQUIRE_APPROVAL."""
|
|
73
|
+
return self == Verdict.REQUIRE_APPROVAL
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class GuardrailsResult:
|
|
78
|
+
"""Guardrails check result from the governance API.
|
|
79
|
+
|
|
80
|
+
Contains redacted input/output that should replace the original activity
|
|
81
|
+
data, plus validation results that can block execution.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
redacted_input: Any = None # Redacted activity_input/activity_output (JSON-decoded)
|
|
85
|
+
input_type: str = "" # "activity_input" or "activity_output"
|
|
86
|
+
raw_logs: dict[str, Any] | None = None # Raw logs from guardrails evaluation
|
|
87
|
+
validation_passed: bool = True # If False, execution should be stopped
|
|
88
|
+
reasons: list[dict[str, str]] = field(default_factory=list) # [{type, field, reason}]
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_dict(cls, data: dict[str, Any]) -> GuardrailsResult:
|
|
92
|
+
return cls(
|
|
93
|
+
redacted_input=data.get("redacted_input"),
|
|
94
|
+
input_type=data.get("input_type", ""),
|
|
95
|
+
raw_logs=data.get("raw_logs"),
|
|
96
|
+
validation_passed=data.get("validation_passed", True),
|
|
97
|
+
reasons=data.get("reasons") or [],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def get_reason_strings(self) -> list[str]:
|
|
101
|
+
"""Extract just the 'reason' field from each reason object."""
|
|
102
|
+
return [r.get("reason", "") for r in self.reasons if r.get("reason")]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class EvaluationResult:
|
|
107
|
+
"""Response from a governance evaluation.
|
|
108
|
+
|
|
109
|
+
``guardrails`` and ``guardrails_result`` are the SAME object —
|
|
110
|
+
``guardrails_result`` is a read-only compatibility alias; there is no way
|
|
111
|
+
for the two to diverge.
|
|
112
|
+
|
|
113
|
+
Optional transport/diagnostic fields default to falsy values so strict
|
|
114
|
+
construction stays terse.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
verdict: Verdict
|
|
118
|
+
reason: str | None = None
|
|
119
|
+
policy_id: str | None = None
|
|
120
|
+
risk_score: float = 0.0
|
|
121
|
+
metadata: dict[str, Any] | None = None
|
|
122
|
+
governance_event_id: str | None = None
|
|
123
|
+
guardrails: GuardrailsResult | None = None
|
|
124
|
+
approval_id: str | None = None
|
|
125
|
+
approval_expiration_time: str | None = None
|
|
126
|
+
trust_tier: str | None = None
|
|
127
|
+
alignment_score: float | None = None
|
|
128
|
+
behavioral_violations: list[str] | None = None
|
|
129
|
+
constraints: list[dict[str, Any]] | None = None
|
|
130
|
+
fallback_used: bool = False # True when fail-open produced this result
|
|
131
|
+
diagnostics: list[Any] = field(default_factory=list)
|
|
132
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def guardrails_result(self) -> GuardrailsResult | None:
|
|
136
|
+
"""Alias of ``guardrails`` (same object)."""
|
|
137
|
+
return self.guardrails
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def action(self) -> str:
|
|
141
|
+
"""Backward compat: return the v1.0 action string derived from verdict."""
|
|
142
|
+
if self.verdict == Verdict.ALLOW:
|
|
143
|
+
return "continue"
|
|
144
|
+
if self.verdict == Verdict.HALT:
|
|
145
|
+
return "stop"
|
|
146
|
+
if self.verdict == Verdict.REQUIRE_APPROVAL:
|
|
147
|
+
return "require-approval"
|
|
148
|
+
return self.verdict.value
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_dict(cls, data: dict[str, Any]) -> EvaluationResult:
|
|
152
|
+
"""Parse a governance response dict (v1.0 and v1.1 compatible).
|
|
153
|
+
|
|
154
|
+
Verdict-first with v1.0 ``action`` fallback — the approval
|
|
155
|
+
*action-precedence* change applies to :class:`ApprovalResult` only.
|
|
156
|
+
Unknown keys are preserved in ``raw``, never an error.
|
|
157
|
+
"""
|
|
158
|
+
guardrails = None
|
|
159
|
+
if data.get("guardrails_result"):
|
|
160
|
+
guardrails = GuardrailsResult.from_dict(data["guardrails_result"])
|
|
161
|
+
elif data.get("guardrails"):
|
|
162
|
+
guardrails = GuardrailsResult.from_dict(data["guardrails"])
|
|
163
|
+
|
|
164
|
+
verdict = Verdict.from_string(data.get("verdict") or data.get("action", "continue"))
|
|
165
|
+
|
|
166
|
+
return cls(
|
|
167
|
+
verdict=verdict,
|
|
168
|
+
reason=data.get("reason"),
|
|
169
|
+
policy_id=data.get("policy_id"),
|
|
170
|
+
risk_score=data.get("risk_score", 0.0),
|
|
171
|
+
metadata=data.get("metadata"),
|
|
172
|
+
governance_event_id=data.get("governance_event_id"),
|
|
173
|
+
guardrails=guardrails,
|
|
174
|
+
approval_id=data.get("approval_id"),
|
|
175
|
+
approval_expiration_time=data.get("approval_expiration_time"),
|
|
176
|
+
trust_tier=data.get("trust_tier"),
|
|
177
|
+
alignment_score=data.get("alignment_score"),
|
|
178
|
+
behavioral_violations=data.get("behavioral_violations"),
|
|
179
|
+
constraints=data.get("constraints"),
|
|
180
|
+
fallback_used=bool(data.get("fallback_used", False)),
|
|
181
|
+
diagnostics=data.get("diagnostics") or [],
|
|
182
|
+
raw=dict(data),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def fallback_allow(cls, reason: str) -> EvaluationResult:
|
|
187
|
+
"""Allow-shaped result for fail-open network-error paths.
|
|
188
|
+
|
|
189
|
+
``fallback_used=True`` marks it as a fallback so callers can tell a
|
|
190
|
+
policy ALLOW from an unreachable-Core ALLOW. A network error must never
|
|
191
|
+
silently flip BLOCK→ALLOW without this marker.
|
|
192
|
+
"""
|
|
193
|
+
return cls(verdict=Verdict.ALLOW, reason=reason, fallback_used=True)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass
|
|
197
|
+
class ApprovalResult:
|
|
198
|
+
"""Normalized HITL approval-poll response.
|
|
199
|
+
|
|
200
|
+
Decision-source precedence: **``action`` wins over ``verdict``** when both
|
|
201
|
+
are present.
|
|
202
|
+
|
|
203
|
+
When NEITHER field is present, ``verdict`` is ``None`` (pending-unknown) —
|
|
204
|
+
never auto-ALLOW. Expired approvals block unless the backend explicitly
|
|
205
|
+
returned an allow-shaped verdict/action.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
verdict: Verdict | None = None
|
|
209
|
+
action: str | None = None
|
|
210
|
+
reason: str | None = None
|
|
211
|
+
approval_id: str | None = None
|
|
212
|
+
approval_expiration_time: str | None = None
|
|
213
|
+
expired: bool = False
|
|
214
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
215
|
+
|
|
216
|
+
# Known decision vocabulary for approvals (current values + accepted aliases).
|
|
217
|
+
# Anything OUTSIDE this set parses to None (pending) — the evaluate-path
|
|
218
|
+
# leniency of Verdict.from_string (unknown -> ALLOW) is too loose at the
|
|
219
|
+
# human-approval trust boundary and is not used here.
|
|
220
|
+
_DECISION_VOCABULARY = frozenset(
|
|
221
|
+
{
|
|
222
|
+
"allow",
|
|
223
|
+
"constrain",
|
|
224
|
+
"require_approval",
|
|
225
|
+
"request_approval",
|
|
226
|
+
"block",
|
|
227
|
+
"halt",
|
|
228
|
+
"continue",
|
|
229
|
+
"stop",
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _parse_decision(value: Any) -> Verdict | None:
|
|
235
|
+
"""Strict, fail-safe decision parsing: empty/unknown -> None (pending)."""
|
|
236
|
+
if not isinstance(value, str) or not value.strip():
|
|
237
|
+
return None
|
|
238
|
+
normalized = value.strip().lower().replace("-", "_")
|
|
239
|
+
if normalized not in ApprovalResult._DECISION_VOCABULARY:
|
|
240
|
+
return None
|
|
241
|
+
return Verdict.from_string(normalized)
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def from_dict(cls, data: dict[str, Any]) -> ApprovalResult:
|
|
245
|
+
action = data.get("action")
|
|
246
|
+
# Empty/whitespace action is absent; it must not shadow ``verdict``.
|
|
247
|
+
if not isinstance(action, str) or not action.strip():
|
|
248
|
+
action = None
|
|
249
|
+
verdict_source = action if action is not None else data.get("verdict")
|
|
250
|
+
verdict = cls._parse_decision(verdict_source)
|
|
251
|
+
return cls(
|
|
252
|
+
verdict=verdict,
|
|
253
|
+
action=action,
|
|
254
|
+
reason=data.get("reason"),
|
|
255
|
+
# Normalize ``id`` → ``approval_id``.
|
|
256
|
+
approval_id=data.get("approval_id") or data.get("id"),
|
|
257
|
+
approval_expiration_time=data.get("approval_expiration_time"),
|
|
258
|
+
expired=bool(data.get("expired", False)),
|
|
259
|
+
raw=dict(data),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def allow_shaped(self) -> bool:
|
|
264
|
+
"""True when the backend explicitly returned an allow verdict/action."""
|
|
265
|
+
return self.verdict == Verdict.ALLOW
|
|
266
|
+
|
|
267
|
+
def is_blocking(self) -> bool:
|
|
268
|
+
"""True when this response must stop the operation.
|
|
269
|
+
|
|
270
|
+
Expired approvals are blocking unless explicitly allow-shaped; an
|
|
271
|
+
explicit BLOCK/HALT verdict is blocking regardless of expiry.
|
|
272
|
+
"""
|
|
273
|
+
if self.expired:
|
|
274
|
+
return not self.allow_shaped
|
|
275
|
+
return self.verdict is not None and self.verdict.should_stop()
|
|
276
|
+
|
|
277
|
+
def is_pending(self) -> bool:
|
|
278
|
+
"""True while the approval decision is still outstanding.
|
|
279
|
+
|
|
280
|
+
Absent verdict/action (``None``) is pending — never auto-ALLOW.
|
|
281
|
+
REQUIRE_APPROVAL and CONSTRAIN keep polling.
|
|
282
|
+
"""
|
|
283
|
+
if self.expired:
|
|
284
|
+
return False
|
|
285
|
+
if self.verdict is None:
|
|
286
|
+
return True
|
|
287
|
+
return self.verdict in (Verdict.REQUIRE_APPROVAL, Verdict.CONSTRAIN)
|