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,129 @@
|
|
|
1
|
+
"""Byte/transform-only JSON serialization.
|
|
2
|
+
|
|
3
|
+
This module knows NOTHING about events or spans — evaluate-body assembly
|
|
4
|
+
(``span_count``, compat-noise removal) is owned by ``wire/evaluate_payload.py``.
|
|
5
|
+
It owns exactly:
|
|
6
|
+
|
|
7
|
+
- ``to_json_safe`` — dataclass/enum/datetime-tolerant JSON coercion
|
|
8
|
+
- ``serialize_body`` — the EXACT bytes that are signed and transmitted
|
|
9
|
+
- ``truncate_string`` / ``apply_redaction`` — pre-signing transforms
|
|
10
|
+
- ``rfc3339_now`` — wall-clock helper (this module is NOT sandbox-pure)
|
|
11
|
+
|
|
12
|
+
Wall-clock lives here (not in contracts) so pure modules stay deterministic.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import dataclasses
|
|
18
|
+
import json
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"to_json_safe",
|
|
25
|
+
"serialize_body",
|
|
26
|
+
"truncate_string",
|
|
27
|
+
"apply_redaction",
|
|
28
|
+
"rfc3339_now",
|
|
29
|
+
"REDACTED_PLACEHOLDER",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
REDACTED_PLACEHOLDER = "[REDACTED]"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def rfc3339_now() -> str:
|
|
36
|
+
"""Current UTC time, RFC3339 with millisecond precision and trailing ``Z``.
|
|
37
|
+
|
|
38
|
+
Event-payload timestamp format — distinct from the request-*signing*
|
|
39
|
+
timestamp which keeps ``+00:00`` (see ``identity.py``).
|
|
40
|
+
"""
|
|
41
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def to_json_safe(obj: Any, exclude_none: bool = True) -> Any:
|
|
45
|
+
"""Recursively coerce ``obj`` into JSON-serializable primitives.
|
|
46
|
+
|
|
47
|
+
Handles dataclasses (as dicts), Enums (``.value``), datetimes (RFC3339),
|
|
48
|
+
sets/tuples (lists), and dict keys via ``str()``. Unknown objects fall back
|
|
49
|
+
to ``str(obj)`` — governance telemetry must never crash the host app over
|
|
50
|
+
an exotic payload type.
|
|
51
|
+
"""
|
|
52
|
+
if obj is None or isinstance(obj, (str, int, float, bool)):
|
|
53
|
+
return obj
|
|
54
|
+
if isinstance(obj, Enum):
|
|
55
|
+
return to_json_safe(obj.value, exclude_none)
|
|
56
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
57
|
+
return to_json_safe(dataclasses.asdict(obj), exclude_none)
|
|
58
|
+
if isinstance(obj, datetime):
|
|
59
|
+
if obj.tzinfo is None:
|
|
60
|
+
obj = obj.replace(tzinfo=UTC)
|
|
61
|
+
return obj.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
62
|
+
if isinstance(obj, dict):
|
|
63
|
+
return {
|
|
64
|
+
str(k): to_json_safe(v, exclude_none)
|
|
65
|
+
for k, v in obj.items()
|
|
66
|
+
if not (exclude_none and v is None)
|
|
67
|
+
}
|
|
68
|
+
if isinstance(obj, (list, tuple, set, frozenset)):
|
|
69
|
+
return [to_json_safe(v, exclude_none) for v in obj]
|
|
70
|
+
if isinstance(obj, bytes):
|
|
71
|
+
return obj.decode("utf-8", errors="replace")
|
|
72
|
+
return str(obj)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def serialize_body(payload: dict | None) -> bytes:
|
|
76
|
+
"""Serialize a payload to the EXACT bytes that will be transmitted.
|
|
77
|
+
|
|
78
|
+
- ``None`` -> ``b""`` (body hash becomes the empty-body SHA-256).
|
|
79
|
+
- Compact separators (no spaces) and a single serialization pass so the
|
|
80
|
+
bytes we hash are identical to the bytes we send. Re-serializing with
|
|
81
|
+
``json=`` elsewhere would break Core's body-hash verification.
|
|
82
|
+
"""
|
|
83
|
+
if payload is None:
|
|
84
|
+
return b""
|
|
85
|
+
return json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def truncate_string(value: str, max_size: int | None) -> tuple[str, bool]:
|
|
89
|
+
"""Truncate ``value`` to ``max_size`` chars. Returns (value, truncated?).
|
|
90
|
+
|
|
91
|
+
``None``/non-positive ``max_size`` disables truncation. Applied BEFORE
|
|
92
|
+
signing — the signed bytes are the truncated bytes.
|
|
93
|
+
"""
|
|
94
|
+
if not max_size or max_size <= 0 or len(value) <= max_size:
|
|
95
|
+
return value, False
|
|
96
|
+
return value[:max_size], True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def apply_redaction(
|
|
100
|
+
obj: Any,
|
|
101
|
+
redact_keys: frozenset[str] | set[str],
|
|
102
|
+
replacement: str = REDACTED_PLACEHOLDER,
|
|
103
|
+
) -> tuple[Any, list[str]]:
|
|
104
|
+
"""Replace values of case-insensitive key matches anywhere in ``obj``.
|
|
105
|
+
|
|
106
|
+
Returns ``(redacted_copy, changed_paths)`` — the paths identify what
|
|
107
|
+
changed so callers can attach diagnostics. Applied BEFORE signing.
|
|
108
|
+
"""
|
|
109
|
+
if not redact_keys:
|
|
110
|
+
return obj, []
|
|
111
|
+
lowered = {k.lower() for k in redact_keys}
|
|
112
|
+
changed: list[str] = []
|
|
113
|
+
|
|
114
|
+
def _walk(node: Any, path: str) -> Any:
|
|
115
|
+
if isinstance(node, dict):
|
|
116
|
+
out = {}
|
|
117
|
+
for k, v in node.items():
|
|
118
|
+
child_path = f"{path}.{k}" if path else str(k)
|
|
119
|
+
if isinstance(k, str) and k.lower() in lowered:
|
|
120
|
+
out[k] = replacement
|
|
121
|
+
changed.append(child_path)
|
|
122
|
+
else:
|
|
123
|
+
out[k] = _walk(v, child_path)
|
|
124
|
+
return out
|
|
125
|
+
if isinstance(node, (list, tuple)):
|
|
126
|
+
return [_walk(v, f"{path}[{i}]") for i, v in enumerate(node)]
|
|
127
|
+
return node
|
|
128
|
+
|
|
129
|
+
return _walk(obj, ""), changed
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Structured diagnostic records attached to evaluation results.
|
|
2
|
+
|
|
3
|
+
Diagnostics are the NON-FAIL half of validation: they record best-effort
|
|
4
|
+
degradations (missing semantic attributes, compat-noise removal, redaction)
|
|
5
|
+
without rejecting anything. Strict failures raise ContractError instead —
|
|
6
|
+
there is no diagnostic for a contract violation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DiagnosticLevel",
|
|
17
|
+
"Diagnostic",
|
|
18
|
+
"SPAN_ATTR_MISSING",
|
|
19
|
+
"COMPAT_NOISE_REMOVED",
|
|
20
|
+
"ATTR_REDACTED",
|
|
21
|
+
"ATTR_TRUNCATED",
|
|
22
|
+
"HOOK_SKIPPED_NO_CONTEXT",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# Diagnostic codes (stable machine identifiers)
|
|
26
|
+
SPAN_ATTR_MISSING = "SPAN_ATTR_MISSING"
|
|
27
|
+
COMPAT_NOISE_REMOVED = "COMPAT_NOISE_REMOVED"
|
|
28
|
+
ATTR_REDACTED = "ATTR_REDACTED"
|
|
29
|
+
ATTR_TRUNCATED = "ATTR_TRUNCATED"
|
|
30
|
+
HOOK_SKIPPED_NO_CONTEXT = "HOOK_SKIPPED_NO_CONTEXT"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DiagnosticLevel(str, Enum):
|
|
34
|
+
INFO = "INFO"
|
|
35
|
+
WARNING = "WARNING"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Diagnostic:
|
|
40
|
+
"""One structured diagnostic record.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
level: Severity (INFO/WARNING) — never an error (errors raise).
|
|
44
|
+
code: Stable machine code (e.g. ``SPAN_ATTR_MISSING``).
|
|
45
|
+
message: Human-readable summary.
|
|
46
|
+
detail: Structured context (what changed, which key, which span).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
level: DiagnosticLevel
|
|
50
|
+
code: str
|
|
51
|
+
message: str
|
|
52
|
+
detail: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"level": self.level.value,
|
|
57
|
+
"code": self.code,
|
|
58
|
+
"message": self.message,
|
|
59
|
+
"detail": dict(self.detail),
|
|
60
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Strict contract failures — raise ContractError BEFORE any network send.
|
|
2
|
+
|
|
3
|
+
The gate is ALWAYS strict for OpenBox event contracts and runtime invariants;
|
|
4
|
+
there is no OBSERVE/SANITIZE/STRICT mode and no way to downgrade these to
|
|
5
|
+
diagnostics. Fail-open applies only to network errors, never here.
|
|
6
|
+
|
|
7
|
+
Strict failure list:
|
|
8
|
+
- malformed lifecycle event envelope
|
|
9
|
+
- hook event has ``hook_trigger=false`` (span-bearing but not marked hook)
|
|
10
|
+
- hook event uses the wrong wire event type
|
|
11
|
+
- ``preflight()`` receives completed-stage spans
|
|
12
|
+
- ``completed()`` receives started-stage spans
|
|
13
|
+
- ``ActivityCompleted`` contains non-empty hook span payloads
|
|
14
|
+
- instrumentation produced an impossible/malformed hook event
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ..contracts.events import EventEnvelope, EventKind, EventType, classify_event
|
|
22
|
+
from ..errors import ContractError
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"span_stage",
|
|
26
|
+
"check_lifecycle_envelope",
|
|
27
|
+
"check_hook_envelope",
|
|
28
|
+
"check_stage",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Payload fields every workflow-scoped lifecycle event must carry.
|
|
32
|
+
_REQUIRED_WORKFLOW_FIELDS = ("workflow_id", "run_id", "workflow_type")
|
|
33
|
+
_REQUIRED_HANDOFF_FIELDS = ("from_agent_did", "multi_agent_session_id")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def span_stage(span: Any) -> str | None:
|
|
37
|
+
"""Extract the stage from a span payload.
|
|
38
|
+
|
|
39
|
+
Spans are flat-only: the stage lives at the top level. Enum values normalize
|
|
40
|
+
via ``.value``.
|
|
41
|
+
"""
|
|
42
|
+
if isinstance(span, dict):
|
|
43
|
+
stage: Any = span.get("stage")
|
|
44
|
+
else:
|
|
45
|
+
stage = getattr(span, "stage", None)
|
|
46
|
+
value = getattr(stage, "value", stage)
|
|
47
|
+
return value if isinstance(value, str) else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _require_fields(event: EventEnvelope, fields: tuple[str, ...], what: str) -> None:
|
|
51
|
+
missing = [f for f in fields if not event.payload.get(f)]
|
|
52
|
+
if missing:
|
|
53
|
+
raise ContractError(
|
|
54
|
+
f"Malformed {what} envelope: missing required fields {missing}",
|
|
55
|
+
code="ENVELOPE_MISSING_FIELDS",
|
|
56
|
+
detail={"missing": missing, "event_type": event.event_type.value},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def check_lifecycle_envelope(event: EventEnvelope) -> None:
|
|
61
|
+
"""Strict checks for non-hook envelopes (lifecycle/signal/handoff)."""
|
|
62
|
+
if not isinstance(event.event_type, EventType):
|
|
63
|
+
raise ContractError(
|
|
64
|
+
f"Malformed envelope: event_type must be an EventType, got {type(event.event_type).__name__}",
|
|
65
|
+
code="ENVELOPE_BAD_EVENT_TYPE",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
kind = classify_event(event)
|
|
69
|
+
if kind is EventKind.HOOK:
|
|
70
|
+
raise ContractError(
|
|
71
|
+
"check_lifecycle_envelope received a hook event — route hook events "
|
|
72
|
+
"through preflight()/completed()",
|
|
73
|
+
code="HOOK_ON_LIFECYCLE_PATH",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# A span-bearing envelope that is not marked as a hook is malformed:
|
|
77
|
+
# either it IS a hook (then hook_trigger must be true) or spans are noise.
|
|
78
|
+
if event.spans:
|
|
79
|
+
if event.event_type is EventType.ACTIVITY_COMPLETED:
|
|
80
|
+
raise ContractError(
|
|
81
|
+
"ActivityCompleted must not carry non-empty hook spans — completed "
|
|
82
|
+
"telemetry routes through completed() with completed-stage spans",
|
|
83
|
+
code="ACTIVITY_COMPLETED_WITH_SPANS",
|
|
84
|
+
detail={"span_count": len(event.spans)},
|
|
85
|
+
)
|
|
86
|
+
raise ContractError(
|
|
87
|
+
f"{event.event_type.value} carries spans but hook_trigger=false — "
|
|
88
|
+
"span-bearing evaluations must be hook events",
|
|
89
|
+
code="HOOK_TRIGGER_FALSE",
|
|
90
|
+
detail={"span_count": len(event.spans)},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if kind is EventKind.HANDOFF:
|
|
94
|
+
_require_fields(event, _REQUIRED_HANDOFF_FIELDS, "handoff")
|
|
95
|
+
return
|
|
96
|
+
_require_fields(event, _REQUIRED_WORKFLOW_FIELDS, "lifecycle")
|
|
97
|
+
if kind is EventKind.SIGNAL and not event.payload.get("signal_name"):
|
|
98
|
+
raise ContractError(
|
|
99
|
+
"Malformed signal envelope: missing signal_name",
|
|
100
|
+
code="ENVELOPE_MISSING_FIELDS",
|
|
101
|
+
detail={"missing": ["signal_name"]},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_hook_envelope(event: EventEnvelope) -> None:
|
|
106
|
+
"""Strict checks for hook (span-bearing) envelopes."""
|
|
107
|
+
if not event.hook_trigger:
|
|
108
|
+
raise ContractError(
|
|
109
|
+
"Hook evaluation requires hook_trigger=true",
|
|
110
|
+
code="HOOK_TRIGGER_FALSE",
|
|
111
|
+
)
|
|
112
|
+
if event.event_type is not EventType.ACTIVITY_STARTED:
|
|
113
|
+
raise ContractError(
|
|
114
|
+
f"Hook events must use wire event type ActivityStarted, got "
|
|
115
|
+
f"{event.event_type.value}",
|
|
116
|
+
code="HOOK_WRONG_WIRE_TYPE",
|
|
117
|
+
detail={"event_type": event.event_type.value},
|
|
118
|
+
)
|
|
119
|
+
if not event.spans:
|
|
120
|
+
raise ContractError(
|
|
121
|
+
"Hook event carries no spans — instrumentation produced an impossible "
|
|
122
|
+
"hook event",
|
|
123
|
+
code="HOOK_EMPTY_SPANS",
|
|
124
|
+
)
|
|
125
|
+
if not event.activity_id or not event.activity_type:
|
|
126
|
+
raise ContractError(
|
|
127
|
+
"Hook event is not attached to a bound activity (activity_id and "
|
|
128
|
+
"activity_type are required)",
|
|
129
|
+
code="HOOK_UNBOUND_ACTIVITY",
|
|
130
|
+
detail={
|
|
131
|
+
"activity_id": event.activity_id,
|
|
132
|
+
"activity_type": event.activity_type,
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
for index, span in enumerate(event.spans):
|
|
136
|
+
if isinstance(span, dict):
|
|
137
|
+
forbidden = sorted({"otel", "openbox", "data"} & set(span))
|
|
138
|
+
if forbidden:
|
|
139
|
+
raise ContractError(
|
|
140
|
+
"Hook spans must be flat Core SpanData dicts — nested/debug "
|
|
141
|
+
f"keys are not allowed at spans[{index}]: {forbidden}",
|
|
142
|
+
code="HOOK_SPAN_NOT_FLAT",
|
|
143
|
+
detail={"index": index, "forbidden": forbidden},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def check_stage(event: EventEnvelope, expected_stage: str) -> None:
|
|
148
|
+
"""Reject stage-mismatched spans (preflight=started, completed=completed)."""
|
|
149
|
+
for index, span in enumerate(event.spans):
|
|
150
|
+
stage = span_stage(span)
|
|
151
|
+
if stage is None:
|
|
152
|
+
raise ContractError(
|
|
153
|
+
f"Hook span[{index}] has no stage — instrumentation produced a "
|
|
154
|
+
"malformed hook span",
|
|
155
|
+
code="HOOK_SPAN_NO_STAGE",
|
|
156
|
+
detail={"index": index},
|
|
157
|
+
)
|
|
158
|
+
if stage != expected_stage:
|
|
159
|
+
raise ContractError(
|
|
160
|
+
f"{expected_stage}-stage evaluation received a {stage}-stage span "
|
|
161
|
+
f"at spans[{index}]",
|
|
162
|
+
code="HOOK_STAGE_MISMATCH",
|
|
163
|
+
detail={"index": index, "expected": expected_stage, "actual": stage},
|
|
164
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Validation dispatch — route an envelope to its rule set, aggregate diagnostics.
|
|
2
|
+
|
|
3
|
+
Strict failures raise ContractError (before any send); the return value is the
|
|
4
|
+
list of non-fail diagnostics collected along the way.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ..contracts.events import EventEnvelope
|
|
10
|
+
from .diagnostics import Diagnostic
|
|
11
|
+
from .event_rules import check_hook_envelope, check_lifecycle_envelope, check_stage
|
|
12
|
+
|
|
13
|
+
__all__ = ["validate_lifecycle", "validate_hook"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_lifecycle(event: EventEnvelope) -> list[Diagnostic]:
|
|
17
|
+
"""Validate a lifecycle/signal/handoff envelope. Raises ContractError on
|
|
18
|
+
violation; returns diagnostics (none today — reserved for future rules)."""
|
|
19
|
+
check_lifecycle_envelope(event)
|
|
20
|
+
return []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_hook(event: EventEnvelope, expected_stage: str) -> list[Diagnostic]:
|
|
24
|
+
"""Validate a hook envelope for the given stage ("started"/"completed").
|
|
25
|
+
|
|
26
|
+
``check_hook_envelope``'s first rule rejects ``hook_trigger=false``, which
|
|
27
|
+
also covers non-HOOK envelopes routed here by mistake.
|
|
28
|
+
"""
|
|
29
|
+
check_hook_envelope(event)
|
|
30
|
+
check_stage(event, expected_stage)
|
|
31
|
+
return []
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Non-fail span diagnostics — best-effort degradations that never reject.
|
|
2
|
+
|
|
3
|
+
Missing semantic attributes, compatibility noise, and privacy transforms are
|
|
4
|
+
DIAGNOSTICS, not failures. Only malformed envelopes/stages/bindings are strict
|
|
5
|
+
(see event_rules.py). Semantic gaps never reject or drop a span.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .diagnostics import (
|
|
13
|
+
ATTR_REDACTED,
|
|
14
|
+
ATTR_TRUNCATED,
|
|
15
|
+
COMPAT_NOISE_REMOVED,
|
|
16
|
+
SPAN_ATTR_MISSING,
|
|
17
|
+
Diagnostic,
|
|
18
|
+
DiagnosticLevel,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"strip_compat_noise",
|
|
23
|
+
"semantic_gap_diagnostics",
|
|
24
|
+
"redaction_diagnostics",
|
|
25
|
+
"truncation_diagnostic",
|
|
26
|
+
"SEMANTIC_FIELDS_BY_HOOK_TYPE",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Best-effort semantic fields per hook type — absence is an INFO diagnostic.
|
|
30
|
+
SEMANTIC_FIELDS_BY_HOOK_TYPE: dict[str, tuple[str, ...]] = {
|
|
31
|
+
"http_request": ("http_method", "http_url"),
|
|
32
|
+
"db_query": ("db_system", "db_statement"),
|
|
33
|
+
"file_operation": ("file_path", "file_operation"),
|
|
34
|
+
"function_call": ("function", "module"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def strip_compat_noise(payload: dict[str, Any]) -> tuple[dict[str, Any], list[Diagnostic]]:
|
|
39
|
+
"""Remove ``spans=[]``/``span_count=0`` from a lifecycle wire payload.
|
|
40
|
+
|
|
41
|
+
Core ignores these empty fields, but they are contract noise. Removal is
|
|
42
|
+
recorded as a diagnostic — it is NOT a configurable mode. Non-empty spans
|
|
43
|
+
are not touched here (they are a strict failure upstream).
|
|
44
|
+
"""
|
|
45
|
+
diagnostics: list[Diagnostic] = []
|
|
46
|
+
removed: list[str] = []
|
|
47
|
+
cleaned = dict(payload)
|
|
48
|
+
if "spans" in cleaned and cleaned["spans"] == []:
|
|
49
|
+
del cleaned["spans"]
|
|
50
|
+
removed.append("spans")
|
|
51
|
+
if "span_count" in cleaned and cleaned["span_count"] == 0:
|
|
52
|
+
del cleaned["span_count"]
|
|
53
|
+
removed.append("span_count")
|
|
54
|
+
if removed:
|
|
55
|
+
diagnostics.append(
|
|
56
|
+
Diagnostic(
|
|
57
|
+
level=DiagnosticLevel.INFO,
|
|
58
|
+
code=COMPAT_NOISE_REMOVED,
|
|
59
|
+
message=f"Removed compatibility noise before send: {removed}",
|
|
60
|
+
detail={"removed": removed},
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
return cleaned, diagnostics
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def semantic_gap_diagnostics(
|
|
67
|
+
span_wire: dict[str, Any], hook_type: str | None
|
|
68
|
+
) -> list[Diagnostic]:
|
|
69
|
+
"""INFO diagnostics for missing best-effort semantic fields.
|
|
70
|
+
|
|
71
|
+
The span is still sent — semantic gaps NEVER reject a span.
|
|
72
|
+
"""
|
|
73
|
+
fields = SEMANTIC_FIELDS_BY_HOOK_TYPE.get(hook_type or "", ())
|
|
74
|
+
return [
|
|
75
|
+
Diagnostic(
|
|
76
|
+
level=DiagnosticLevel.INFO,
|
|
77
|
+
code=SPAN_ATTR_MISSING,
|
|
78
|
+
message=f"Best-effort semantic attribute missing: {field_name}",
|
|
79
|
+
detail={"hook_type": hook_type, "field": field_name},
|
|
80
|
+
)
|
|
81
|
+
for field_name in fields
|
|
82
|
+
if span_wire.get(field_name) is None
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def redaction_diagnostics(changed_paths: list[str]) -> list[Diagnostic]:
|
|
87
|
+
"""Diagnostics identifying exactly what redaction changed."""
|
|
88
|
+
if not changed_paths:
|
|
89
|
+
return []
|
|
90
|
+
return [
|
|
91
|
+
Diagnostic(
|
|
92
|
+
level=DiagnosticLevel.INFO,
|
|
93
|
+
code=ATTR_REDACTED,
|
|
94
|
+
message=f"Redacted {len(changed_paths)} value(s) before send",
|
|
95
|
+
detail={"paths": list(changed_paths)},
|
|
96
|
+
)
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def truncation_diagnostic(field_name: str, original_size: int, max_size: int) -> Diagnostic:
|
|
101
|
+
"""Diagnostic identifying a truncated value."""
|
|
102
|
+
return Diagnostic(
|
|
103
|
+
level=DiagnosticLevel.INFO,
|
|
104
|
+
code=ATTR_TRUNCATED,
|
|
105
|
+
message=f"Truncated {field_name} from {original_size} to {max_size} chars",
|
|
106
|
+
detail={"field": field_name, "original_size": original_size, "max_size": max_size},
|
|
107
|
+
)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""to_core_span_data — normalize flat Core ``SpanData`` wire spans.
|
|
2
|
+
|
|
3
|
+
Wire rules:
|
|
4
|
+
|
|
5
|
+
- Ids are HEX STRINGS: span_id 16, trace_id 32, parent_span_id 16 chars.
|
|
6
|
+
Raw integer OTel ids must never be sent.
|
|
7
|
+
- Timestamps are epoch NANOSECONDS.
|
|
8
|
+
- started-stage spans emit EXPLICIT ``end_time: null`` and
|
|
9
|
+
``duration_ns: null`` (never omitted, never ``end_time == start_time``);
|
|
10
|
+
Core's non-pointer ``EndTime int64`` unmarshals null -> 0.
|
|
11
|
+
- The COMMON root fields (span_id, trace_id, parent_span_id, name, kind,
|
|
12
|
+
stage, start_time, end_time, duration_ns, attributes, status, events,
|
|
13
|
+
hook_type, error) are ALWAYS present — null-valued when absent, never
|
|
14
|
+
omitted — matching the flat hook contract.
|
|
15
|
+
- Each hook family's own root fields (http_*/db_*/file_*/function) are ALSO
|
|
16
|
+
always present for that family (null when the wrapper/attributes did not
|
|
17
|
+
supply them). ``attributes`` carries OTel-native attributes ONLY.
|
|
18
|
+
- Semantic HTTP/DB/file/function fields are best-effort from OTel attributes
|
|
19
|
+
during ``contracts.otel_spans.from_otel_span``; absence is a diagnostic,
|
|
20
|
+
NEVER a rejection.
|
|
21
|
+
- ``semantic_type`` is NEVER set here — Core computes it.
|
|
22
|
+
- Hook spans are FLAT in memory and on the wire: no ``data`` blob and no nested
|
|
23
|
+
``{"otel", "openbox"}`` envelope.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from ..config import PrivacyConfig
|
|
31
|
+
from ..contracts.otel_spans import _ROOT_FIELDS_BY_HOOK_TYPE
|
|
32
|
+
from ..serialization import apply_redaction, truncate_string
|
|
33
|
+
from ..validation.diagnostics import Diagnostic
|
|
34
|
+
from ..validation.span_normalization import (
|
|
35
|
+
redaction_diagnostics,
|
|
36
|
+
semantic_gap_diagnostics,
|
|
37
|
+
truncation_diagnostic,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = ["to_core_span_data"]
|
|
41
|
+
|
|
42
|
+
# Wrapper-supplied fields eligible for body truncation.
|
|
43
|
+
_TRUNCATABLE_FIELDS = ("request_body", "response_body")
|
|
44
|
+
|
|
45
|
+
_COMMON_DEFAULTS: dict[str, Any] = {
|
|
46
|
+
"span_id": "0" * 16,
|
|
47
|
+
"trace_id": "0" * 32,
|
|
48
|
+
"parent_span_id": None,
|
|
49
|
+
"name": "span",
|
|
50
|
+
"kind": "INTERNAL",
|
|
51
|
+
"start_time": None,
|
|
52
|
+
"end_time": None,
|
|
53
|
+
"duration_ns": None,
|
|
54
|
+
"attributes": {},
|
|
55
|
+
"status": {"code": "UNSET", "description": None},
|
|
56
|
+
"events": [],
|
|
57
|
+
"error": None,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def to_core_span_data(
|
|
62
|
+
span: dict[str, Any],
|
|
63
|
+
*,
|
|
64
|
+
privacy: PrivacyConfig | None = None,
|
|
65
|
+
include_otel_data: bool = False,
|
|
66
|
+
) -> tuple[dict[str, Any], list[Diagnostic]]:
|
|
67
|
+
"""Normalize one flat Core ``SpanData`` dict.
|
|
68
|
+
|
|
69
|
+
Returns ``(wire_span, diagnostics)``. Missing semantic attributes are
|
|
70
|
+
diagnostics, never failures; redaction/truncation is recorded.
|
|
71
|
+
|
|
72
|
+
``include_otel_data`` is retained as a compatibility parameter but is now a
|
|
73
|
+
no-op: spans stay flat everywhere.
|
|
74
|
+
"""
|
|
75
|
+
_ = include_otel_data
|
|
76
|
+
diagnostics: list[Diagnostic] = []
|
|
77
|
+
wire = dict(span)
|
|
78
|
+
# Never let nested/debug shapes leak forward.
|
|
79
|
+
wire.pop("otel", None)
|
|
80
|
+
wire.pop("openbox", None)
|
|
81
|
+
wire.pop("data", None)
|
|
82
|
+
wire.pop("metadata", None)
|
|
83
|
+
|
|
84
|
+
hook_type = wire.get("hook_type")
|
|
85
|
+
attributes = dict(wire.get("attributes") or {})
|
|
86
|
+
if privacy and privacy.redact_keys:
|
|
87
|
+
attributes, changed = apply_redaction(attributes, privacy.redact_keys)
|
|
88
|
+
diagnostics.extend(redaction_diagnostics([f"attributes.{p}" for p in changed]))
|
|
89
|
+
wire["attributes"] = attributes
|
|
90
|
+
|
|
91
|
+
for field_name in _TRUNCATABLE_FIELDS:
|
|
92
|
+
if field_name not in wire:
|
|
93
|
+
continue
|
|
94
|
+
value = wire.get(field_name)
|
|
95
|
+
if privacy and field_name in _TRUNCATABLE_FIELDS and isinstance(value, str):
|
|
96
|
+
truncated, was_truncated = truncate_string(value, privacy.max_body_size)
|
|
97
|
+
if was_truncated:
|
|
98
|
+
diagnostics.append(
|
|
99
|
+
truncation_diagnostic(field_name, len(value), privacy.max_body_size)
|
|
100
|
+
)
|
|
101
|
+
value = truncated
|
|
102
|
+
wire[field_name] = value
|
|
103
|
+
|
|
104
|
+
for field_name, value in _COMMON_DEFAULTS.items():
|
|
105
|
+
if field_name in wire:
|
|
106
|
+
continue
|
|
107
|
+
if isinstance(value, dict):
|
|
108
|
+
wire[field_name] = dict(value)
|
|
109
|
+
elif isinstance(value, list):
|
|
110
|
+
wire[field_name] = list(value)
|
|
111
|
+
else:
|
|
112
|
+
wire[field_name] = value
|
|
113
|
+
|
|
114
|
+
# Guarantee every family-specific root key exists (explicit null if neither
|
|
115
|
+
# attributes nor the wrapper supplied it); the flat hook contract emits the
|
|
116
|
+
# full family key set, and Core's ``omitempty`` tolerates the nulls.
|
|
117
|
+
for field_name in _ROOT_FIELDS_BY_HOOK_TYPE.get(hook_type or "", ()):
|
|
118
|
+
wire.setdefault(field_name, None)
|
|
119
|
+
|
|
120
|
+
# OTel-owned HTTP spans are not always ended when the response hook fires,
|
|
121
|
+
# so ``end_time`` may be null even though a duration was measured.
|
|
122
|
+
# Reconstruct it from start_time + duration for completed spans.
|
|
123
|
+
if wire.get("stage") != "started" and wire.get("end_time") is None:
|
|
124
|
+
start_time = wire.get("start_time")
|
|
125
|
+
measured = wire.get("duration_ns")
|
|
126
|
+
if isinstance(start_time, int) and isinstance(measured, int):
|
|
127
|
+
wire["end_time"] = start_time + measured
|
|
128
|
+
|
|
129
|
+
diagnostics.extend(semantic_gap_diagnostics(wire, hook_type))
|
|
130
|
+
return wire, diagnostics
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""build_evaluate_payload — the SINGLE owner of the evaluate request body.
|
|
2
|
+
|
|
3
|
+
Assembles the exact ``/api/v1/governance/evaluate`` body for hook events:
|
|
4
|
+
the ``EventEnvelope`` fields at the top level (``event_type=ActivityStarted``,
|
|
5
|
+
``hook_trigger=true``, ``activity_id``/``activity_type``, ``timestamp``),
|
|
6
|
+
``spans`` as a list of flat Core ``SpanData`` dicts, and ``span_count``.
|
|
7
|
+
|
|
8
|
+
The strict gate injects this as its ``payload_builder`` — the gate never
|
|
9
|
+
reimplements body assembly, and ``serialization.serialize_body`` (byte-only)
|
|
10
|
+
then produces the signed bytes.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..config import PrivacyConfig
|
|
18
|
+
from ..contracts.events import EventEnvelope
|
|
19
|
+
from ..validation.diagnostics import Diagnostic
|
|
20
|
+
from .core_span import to_core_span_data
|
|
21
|
+
|
|
22
|
+
__all__ = ["build_evaluate_payload", "make_payload_builder"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_evaluate_payload(
|
|
26
|
+
event: EventEnvelope,
|
|
27
|
+
*,
|
|
28
|
+
privacy: PrivacyConfig | None = None,
|
|
29
|
+
) -> tuple[dict[str, Any], list[Diagnostic]]:
|
|
30
|
+
"""Assemble the hook evaluate body from a validated hook envelope.
|
|
31
|
+
|
|
32
|
+
Spans are flat Core ``SpanData`` dicts in memory. The normalizer enforces
|
|
33
|
+
the final no-nested/no-data wire shape and applies privacy transforms.
|
|
34
|
+
"""
|
|
35
|
+
diagnostics: list[Diagnostic] = []
|
|
36
|
+
wire_spans: list[dict[str, Any]] = []
|
|
37
|
+
for span in event.spans:
|
|
38
|
+
wire_span, span_diagnostics = to_core_span_data(
|
|
39
|
+
dict(span), privacy=privacy, include_otel_data=False
|
|
40
|
+
)
|
|
41
|
+
diagnostics.extend(span_diagnostics)
|
|
42
|
+
wire_spans.append(wire_span)
|
|
43
|
+
|
|
44
|
+
payload = event.to_payload_dict()
|
|
45
|
+
payload["spans"] = wire_spans
|
|
46
|
+
payload["span_count"] = len(wire_spans)
|
|
47
|
+
return payload, diagnostics
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def make_payload_builder(privacy: PrivacyConfig | None = None):
|
|
51
|
+
"""Bind a privacy config into the gate's single-argument builder seam."""
|
|
52
|
+
|
|
53
|
+
def _builder(event: EventEnvelope) -> tuple[dict[str, Any], list[Diagnostic]]:
|
|
54
|
+
return build_evaluate_payload(event, privacy=privacy)
|
|
55
|
+
|
|
56
|
+
return _builder
|