openbox-sdk-python 1.0.1__tar.gz → 1.2.0__tar.gz
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_sdk_python-1.2.0/CHANGELOG.md +41 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/PKG-INFO +1 -1
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/__init__.py +1 -1
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/adapters/base.py +71 -13
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/conformance/hook_preflight.py +5 -1
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/contracts/results.py +131 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/hooks/preflight.py +15 -22
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/runtime.py +35 -2
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/pyproject.toml +1 -1
- openbox_sdk_python-1.2.0/tests/contracts/test_patch.py +207 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/runtime/test_runtime_delegation.py +61 -1
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/uv.lock +1 -1
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/.github/instructions/openbox-sdk-python.instructions.md +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/.github/workflows/ci.yml +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/.github/workflows/publish.yml +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/.gitignore +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/.python-version +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/README.md +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/adapters/__init__.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/approvals.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/client.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/config.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/conformance/__init__.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/conformance/fake_core.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/conformance/instrumentation.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/context.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/contracts/__init__.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/contracts/context.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/contracts/events.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/contracts/otel_spans.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/errors.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/gate.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/hooks/__init__.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/hooks/events.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/hooks/wrappers.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/identity.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/__init__.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/db.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/file.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/function.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/http.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/llm.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/manager.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/shared.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/otel/__init__.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/otel/propagation.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/otel/provider.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/otel/setup.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/otel/span_processor.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/otel/trace_context.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/py.typed +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/sdk_version.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/serialization.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/validation/__init__.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/validation/diagnostics.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/validation/event_rules.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/validation/registry.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/validation/span_normalization.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/wire/__init__.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/wire/core_span.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/wire/evaluate_payload.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/client/test_approval_poll.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/client/test_fail_modes.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/config/test_resolution_order.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/conformance/test_required_cases.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/context/test_bind_reset.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/context/test_trace_key.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/contracts/test_approval_parsing.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/contracts/test_event_classify.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/contracts/test_result_parsing.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/gate/test_diagnostics.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/gate/test_strict_failures.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/conftest.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/instrumented_env.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_db_block.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_db_redis_mongo.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_file_function_block.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_hook_failclosed_and_sync_approval.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_hook_runtime.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_http_preflight_block.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_http_urllib.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_manager_and_otel_lifecycle.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/signing/generate_golden_fixture_from_temporal_signer.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/signing/golden_temporal_signed_request.json +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/signing/test_golden_signing.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/test_import_safety.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/test_sdk_version.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/go_spandata_compat/go.mod +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/go_spandata_compat/main.go +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/span_fixtures.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/test_backend_compat.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/test_core_span_projection.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/test_flat_hook_contract.py +0 -0
- {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/test_hex_ids.py +0 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.2.0] - 2026-07-23
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **BREAKING:** Renamed the public BLOCK remediation contract from `retry_plan` to `patch`.
|
|
12
|
+
`RetryPlan` is now `Patch`, `RetryDirective` is now `PatchDirective`, and
|
|
13
|
+
`handle_retryable_block()` is now `handle_patch()`. `EvaluationResult.retry_plan` and
|
|
14
|
+
`ApprovalResult.retry_plan` are now `.patch`. The parser now reads only the canonical outer
|
|
15
|
+
wire key `patch`; the old `retry_plan` key is no longer recognized.
|
|
16
|
+
- The verdict gate is unchanged: a directive is still surfaced only for an exact `BLOCK` verdict
|
|
17
|
+
carrying a valid patch — never for HALT/`should_stop()`, and never for an expired approval
|
|
18
|
+
result.
|
|
19
|
+
|
|
20
|
+
### Notes
|
|
21
|
+
- This is a breaking release: the old public names (`RetryPlan`, `RetryDirective`,
|
|
22
|
+
`handle_retryable_block`, `.retry_plan`) are removed, not aliased. Consumers still pinned to
|
|
23
|
+
`1.1.0` remain fail-safe — they ignore the unknown `patch` field but still enforce the `BLOCK`
|
|
24
|
+
verdict.
|
|
25
|
+
|
|
26
|
+
## [1.1.0] - 2026-07-21
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- `RetryPlan` and `RetryDirective` dataclasses in `openbox_core.contracts.results`.
|
|
30
|
+
- Optional `retry_plan` directive parsing on both `EvaluationResult` and `ApprovalResult`.
|
|
31
|
+
A `_MISSING` sentinel keeps a present `new_input: null` distinct from an absent field; falsy
|
|
32
|
+
values (`null`, `""`, `0`, `[]`, `{}`) are preserved; a boolean `new_input` is rejected; and every
|
|
33
|
+
number (recursively) must be finite and, if integral, a JS-safe integer (`|n| <= 2^53 - 1`).
|
|
34
|
+
- `handle_retryable_block(result)` — an opt-in, pure inspector that returns a `RetryDirective` only
|
|
35
|
+
for a `BLOCK` verdict carrying a valid plan. Returns `None` for a plain BLOCK, every non-BLOCK
|
|
36
|
+
verdict (including HALT), a pending verdict, and an expired `ApprovalResult`.
|
|
37
|
+
|
|
38
|
+
### Notes
|
|
39
|
+
- Default enforcement is unchanged: a `BLOCK` verdict still raises `GovernanceBlockedError`. The new
|
|
40
|
+
helper is opt-in and never triggers an automatic retry; malformed or ineligible plans are treated
|
|
41
|
+
as absent (never fail open).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbox-sdk-python
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: OpenBox base SDK - governance contracts, strict gate, identity/signing, evaluate client, context runtime, OTel span wire serialization, and generic instrumentation shared by every OpenBox framework SDK
|
|
5
5
|
Author-email: OpenBox Team <tino@openbox.ai>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -37,7 +37,7 @@ from .errors import (
|
|
|
37
37
|
# governance; eagerly it can deadlock package init as a circular import, and
|
|
38
38
|
# lazily it can recurse unboundedly when a per-request header builder resolves
|
|
39
39
|
# the version. Keep in sync with pyproject.toml on release.
|
|
40
|
-
__version__ = "1.0
|
|
40
|
+
__version__ = "1.2.0"
|
|
41
41
|
|
|
42
42
|
__all__ = [
|
|
43
43
|
"__version__",
|
|
@@ -8,7 +8,8 @@ core error types directly.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
from
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
12
13
|
|
|
13
14
|
from ..contracts.results import EvaluationResult
|
|
14
15
|
from ..errors import (
|
|
@@ -24,7 +25,7 @@ if TYPE_CHECKING:
|
|
|
24
25
|
from ..approvals import ApprovalPoller
|
|
25
26
|
from ..contracts.context import ActivityContext
|
|
26
27
|
|
|
27
|
-
__all__ = ["FrameworkAdapter", "CoreAdapter"]
|
|
28
|
+
__all__ = ["FrameworkAdapter", "CoreAdapter", "adapter_accepts_context"]
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
@runtime_checkable
|
|
@@ -33,16 +34,27 @@ class FrameworkAdapter(Protocol):
|
|
|
33
34
|
|
|
34
35
|
name: str
|
|
35
36
|
|
|
36
|
-
async def handle_approval(
|
|
37
|
+
async def handle_approval(
|
|
38
|
+
self, result: EvaluationResult, context: ActivityContext | None = None
|
|
39
|
+
) -> None:
|
|
37
40
|
"""Drive the framework's approval flow for REQUIRE_APPROVAL.
|
|
38
41
|
|
|
39
42
|
Return normally when approved; raise the framework's native rejection/
|
|
40
43
|
expiry error otherwise. Called BEFORE the real operation runs.
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
``context`` carries the workflow/run/activity IDs the approval poll
|
|
46
|
+
needs: Core's evaluate response does NOT echo them, so they cannot be
|
|
47
|
+
recovered from ``result.raw``. The runtime passes the originating
|
|
48
|
+
``ActivityContext`` (lifecycle events build one from the event).
|
|
49
|
+
Optional — an adapter defined as ``handle_approval(self, result)`` still
|
|
50
|
+
conforms; the runtime passes ``context`` only when the signature accepts
|
|
51
|
+
it (see :func:`adapter_accepts_context`).
|
|
52
|
+
|
|
53
|
+
Adapters may ALSO define a plain-sync ``handle_approval_sync(result,
|
|
54
|
+
context)`` (not part of the required protocol): when present, sync hook
|
|
55
|
+
paths delegate to it instead of driving the core inline poller.
|
|
56
|
+
Frameworks with retry-based HITL can raise their native pending error
|
|
57
|
+
there.
|
|
46
58
|
"""
|
|
47
59
|
...
|
|
48
60
|
|
|
@@ -82,17 +94,16 @@ class CoreAdapter:
|
|
|
82
94
|
def __init__(self, approval_poller: ApprovalPoller | None = None):
|
|
83
95
|
self._poller = approval_poller
|
|
84
96
|
|
|
85
|
-
async def handle_approval(
|
|
97
|
+
async def handle_approval(
|
|
98
|
+
self, result: EvaluationResult, context: ActivityContext | None = None
|
|
99
|
+
) -> None:
|
|
86
100
|
if self._poller is None or not result.approval_id:
|
|
87
101
|
raise ApprovalRejectedError(
|
|
88
102
|
"REQUIRE_APPROVAL verdict but no approval flow is configured — "
|
|
89
103
|
"failing safe (operation not run)"
|
|
90
104
|
)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
result.raw.get("run_id", ""),
|
|
94
|
-
result.raw.get("activity_id", ""),
|
|
95
|
-
)
|
|
105
|
+
workflow_id, run_id, activity_id = _approval_poll_ids(result, context)
|
|
106
|
+
approval = await self._poller.await_decision(workflow_id, run_id, activity_id)
|
|
96
107
|
if approval.allow_shaped:
|
|
97
108
|
return
|
|
98
109
|
if approval.expired:
|
|
@@ -121,3 +132,50 @@ class CoreAdapter:
|
|
|
121
132
|
raise GovernanceBlockedError(
|
|
122
133
|
result.verdict, result.reason or "Blocked by governance policy"
|
|
123
134
|
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _approval_poll_ids(
|
|
138
|
+
result: EvaluationResult, context: ActivityContext | None
|
|
139
|
+
) -> tuple[str, str, str]:
|
|
140
|
+
"""Resolve the (workflow_id, run_id, activity_id) the approval poll sends.
|
|
141
|
+
|
|
142
|
+
Core's evaluate response does not echo them, so the originating
|
|
143
|
+
``ActivityContext`` is authoritative; ``result.raw`` is only a fallback for
|
|
144
|
+
a caller that predates the ``context`` argument (it is empty in real Core
|
|
145
|
+
traffic).
|
|
146
|
+
"""
|
|
147
|
+
raw = result.raw
|
|
148
|
+
if context is None:
|
|
149
|
+
return (
|
|
150
|
+
raw.get("workflow_id", ""),
|
|
151
|
+
raw.get("run_id", ""),
|
|
152
|
+
raw.get("activity_id", ""),
|
|
153
|
+
)
|
|
154
|
+
return (
|
|
155
|
+
context.workflow_id or raw.get("workflow_id", ""),
|
|
156
|
+
context.run_id or raw.get("run_id", ""),
|
|
157
|
+
context.activity_id or raw.get("activity_id", ""),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def adapter_accepts_context(callback: Callable[..., Any] | None) -> bool:
|
|
162
|
+
"""True when an adapter callback accepts a ``context`` argument (an explicit
|
|
163
|
+
parameter or ``**kwargs``).
|
|
164
|
+
|
|
165
|
+
Checked by signature so a genuine ``TypeError`` raised inside the callback
|
|
166
|
+
body is never mistaken for an arity mismatch and silently dropped. Lets the
|
|
167
|
+
runtime stay backward-compatible with adapters written against the older
|
|
168
|
+
``handle_approval(self, result)`` / ``on_completed_hook_result(self,
|
|
169
|
+
result)`` signatures.
|
|
170
|
+
"""
|
|
171
|
+
import inspect
|
|
172
|
+
|
|
173
|
+
if callback is None:
|
|
174
|
+
return False
|
|
175
|
+
try:
|
|
176
|
+
params = inspect.signature(callback).parameters
|
|
177
|
+
except (TypeError, ValueError):
|
|
178
|
+
return False
|
|
179
|
+
return "context" in params or any(
|
|
180
|
+
p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()
|
|
181
|
+
)
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/conformance/hook_preflight.py
RENAMED
|
@@ -39,10 +39,14 @@ class RecordingHookAdapter:
|
|
|
39
39
|
self.completed_results: list[EvaluationResult] = []
|
|
40
40
|
self.completed_contexts: list[ActivityContext | None] = []
|
|
41
41
|
self.approvals: list[EvaluationResult] = []
|
|
42
|
+
self.approval_contexts: list[ActivityContext | None] = []
|
|
42
43
|
self.approve_next = True
|
|
43
44
|
|
|
44
|
-
async def handle_approval(
|
|
45
|
+
async def handle_approval(
|
|
46
|
+
self, result: EvaluationResult, context: ActivityContext | None = None
|
|
47
|
+
) -> None:
|
|
45
48
|
self.approvals.append(result)
|
|
49
|
+
self.approval_contexts.append(context)
|
|
46
50
|
if not self.approve_next:
|
|
47
51
|
raise ApprovalRejectedError("rejected by conformance adapter")
|
|
48
52
|
|
|
@@ -10,6 +10,7 @@ tolerant of unknown keys (field-shape drift from Core must not crash SDKs).
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import math
|
|
13
14
|
from dataclasses import dataclass, field
|
|
14
15
|
from enum import Enum
|
|
15
16
|
from typing import Any
|
|
@@ -19,6 +20,9 @@ __all__ = [
|
|
|
19
20
|
"GuardrailsResult",
|
|
20
21
|
"EvaluationResult",
|
|
21
22
|
"ApprovalResult",
|
|
23
|
+
"Patch",
|
|
24
|
+
"PatchDirective",
|
|
25
|
+
"handle_patch",
|
|
22
26
|
]
|
|
23
27
|
|
|
24
28
|
|
|
@@ -102,6 +106,87 @@ class GuardrailsResult:
|
|
|
102
106
|
return [r.get("reason", "") for r in self.reasons if r.get("reason")]
|
|
103
107
|
|
|
104
108
|
|
|
109
|
+
# Sentinel distinguishing an absent ``patch`` field from a present JSON ``null``.
|
|
110
|
+
# ``dict.get("patch")`` would collapse both to None; membership via this sentinel does not.
|
|
111
|
+
_MISSING = object()
|
|
112
|
+
|
|
113
|
+
# JS Number.MAX_SAFE_INTEGER. Integral numbers must fit within +/- this bound so ``new_input`` can
|
|
114
|
+
# never silently change across FE, backend, Core, and Python (json.loads yields arbitrary-precision
|
|
115
|
+
# ``int`` and ``float('inf')``, both broader than JS).
|
|
116
|
+
_MAX_SAFE_INTEGER = 2**53 - 1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _numbers_are_safe(value: Any) -> bool:
|
|
120
|
+
"""Every number (recursively) must be finite and, if integral, within the JS-safe range.
|
|
121
|
+
|
|
122
|
+
``bool`` is a subclass of ``int`` but is not a number here; a nested bool is a plain leaf
|
|
123
|
+
(top-level ``new_input`` booleans are rejected separately by :func:`_parse_patch`).
|
|
124
|
+
"""
|
|
125
|
+
if isinstance(value, bool):
|
|
126
|
+
return True
|
|
127
|
+
if isinstance(value, int):
|
|
128
|
+
return abs(value) <= _MAX_SAFE_INTEGER
|
|
129
|
+
if isinstance(value, float):
|
|
130
|
+
if math.isinf(value) or math.isnan(value):
|
|
131
|
+
return False
|
|
132
|
+
if value.is_integer():
|
|
133
|
+
return abs(value) <= _MAX_SAFE_INTEGER
|
|
134
|
+
return True
|
|
135
|
+
if isinstance(value, dict):
|
|
136
|
+
return all(_numbers_are_safe(v) for v in value.values())
|
|
137
|
+
if isinstance(value, (list, tuple)):
|
|
138
|
+
return all(_numbers_are_safe(v) for v in value)
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class Patch:
|
|
144
|
+
"""Optional remediation hint carried on a BLOCK verdict (policy-produced or admin-patch).
|
|
145
|
+
|
|
146
|
+
``new_input`` may be null | str | number | list | dict (boolean rejected). A present
|
|
147
|
+
``new_input`` of ``None`` is a valid patch (reuses the original input) and is distinct
|
|
148
|
+
from an absent ``patch`` field.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
new_input: Any = None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class PatchDirective:
|
|
156
|
+
"""Caller-facing view of a BLOCK with patch, surfaced only via :func:`handle_patch`.
|
|
157
|
+
|
|
158
|
+
It is a remediation hint, not proof that a patch was evaluated or executed.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
new_input: Any = None
|
|
162
|
+
governance_event_id: str | None = None
|
|
163
|
+
reason: str | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_patch(container: dict[str, Any]) -> Patch | None:
|
|
167
|
+
"""Parse an optional ``patch`` from a result dict against the frozen wire contract.
|
|
168
|
+
|
|
169
|
+
Returns a patch only when ``patch`` is present, is a dict whose ONLY key is ``new_input``,
|
|
170
|
+
and ``new_input`` is null/str/number/list/dict (boolean rejected) with every number finite and,
|
|
171
|
+
if integral, within the JS-safe integer range. An absent field, a JSON ``null`` field, or any
|
|
172
|
+
contract violation yields ``None`` (treated as absent — never an error). A present
|
|
173
|
+
``new_input`` of ``None`` is preserved as a valid patch.
|
|
174
|
+
"""
|
|
175
|
+
patch = container.get("patch", _MISSING)
|
|
176
|
+
if patch is _MISSING or not isinstance(patch, dict):
|
|
177
|
+
return None
|
|
178
|
+
# Exactly the single key new_input (also guarantees new_input is present, not merely null).
|
|
179
|
+
if set(patch.keys()) != {"new_input"}:
|
|
180
|
+
return None
|
|
181
|
+
new_input = patch["new_input"]
|
|
182
|
+
# bool is a subclass of int — reject a boolean new_input BEFORE the number check.
|
|
183
|
+
if isinstance(new_input, bool):
|
|
184
|
+
return None
|
|
185
|
+
if not _numbers_are_safe(new_input):
|
|
186
|
+
return None
|
|
187
|
+
return Patch(new_input=new_input)
|
|
188
|
+
|
|
189
|
+
|
|
105
190
|
@dataclass
|
|
106
191
|
class EvaluationResult:
|
|
107
192
|
"""Response from a governance evaluation.
|
|
@@ -130,6 +215,7 @@ class EvaluationResult:
|
|
|
130
215
|
fallback_used: bool = False # True when fail-open produced this result
|
|
131
216
|
diagnostics: list[Any] = field(default_factory=list)
|
|
132
217
|
raw: dict[str, Any] = field(default_factory=dict)
|
|
218
|
+
patch: Patch | None = None # Optional BLOCK remediation hint; None = absent
|
|
133
219
|
|
|
134
220
|
@property
|
|
135
221
|
def guardrails_result(self) -> GuardrailsResult | None:
|
|
@@ -180,6 +266,7 @@ class EvaluationResult:
|
|
|
180
266
|
fallback_used=bool(data.get("fallback_used", False)),
|
|
181
267
|
diagnostics=data.get("diagnostics") or [],
|
|
182
268
|
raw=dict(data),
|
|
269
|
+
patch=_parse_patch(data),
|
|
183
270
|
)
|
|
184
271
|
|
|
185
272
|
@classmethod
|
|
@@ -212,6 +299,7 @@ class ApprovalResult:
|
|
|
212
299
|
approval_expiration_time: str | None = None
|
|
213
300
|
expired: bool = False
|
|
214
301
|
raw: dict[str, Any] = field(default_factory=dict)
|
|
302
|
+
patch: Patch | None = None # Present only on an admin-patch BLOCK
|
|
215
303
|
|
|
216
304
|
# Known decision vocabulary for approvals (current values + accepted aliases).
|
|
217
305
|
# Anything OUTSIDE this set parses to None (pending) — the evaluate-path
|
|
@@ -257,6 +345,7 @@ class ApprovalResult:
|
|
|
257
345
|
approval_expiration_time=data.get("approval_expiration_time"),
|
|
258
346
|
expired=bool(data.get("expired", False)),
|
|
259
347
|
raw=dict(data),
|
|
348
|
+
patch=_parse_patch(data),
|
|
260
349
|
)
|
|
261
350
|
|
|
262
351
|
@property
|
|
@@ -285,3 +374,45 @@ class ApprovalResult:
|
|
|
285
374
|
if self.verdict is None:
|
|
286
375
|
return True
|
|
287
376
|
return self.verdict in (Verdict.REQUIRE_APPROVAL, Verdict.CONSTRAIN)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def handle_patch(
|
|
380
|
+
result: EvaluationResult | ApprovalResult,
|
|
381
|
+
) -> PatchDirective | None:
|
|
382
|
+
"""Opt-in, pure inspector: surface a patch directive from a BLOCK with patch.
|
|
383
|
+
|
|
384
|
+
Returns a :class:`PatchDirective` ONLY for ``verdict == Verdict.BLOCK`` with a present, valid
|
|
385
|
+
``patch``. Returns ``None`` for plain BLOCK, every non-BLOCK verdict (including HALT),
|
|
386
|
+
a pending/``None`` verdict, and an expired :class:`ApprovalResult`.
|
|
387
|
+
|
|
388
|
+
This does NOT change enforcement: importing or exposing it never auto-applies the patch. A
|
|
389
|
+
caller must invoke it explicitly, and it makes no claim that a replacement was evaluated or
|
|
390
|
+
executed.
|
|
391
|
+
|
|
392
|
+
The gate is deliberately ``verdict == Verdict.BLOCK`` — NOT ``is_blocking()`` /
|
|
393
|
+
``verdict.should_stop()``, which also match HALT (and, for approvals, expired results) and would
|
|
394
|
+
violate the wire-contract invariant that HALT always strips the patch.
|
|
395
|
+
"""
|
|
396
|
+
# A stale/expired poll result must not surface a directive, even if verdict == BLOCK.
|
|
397
|
+
if isinstance(result, ApprovalResult) and result.expired:
|
|
398
|
+
return None
|
|
399
|
+
# ApprovalResult.verdict may be None (pending); the != BLOCK comparison handles that. Using
|
|
400
|
+
# getattr keeps this safe if a caller passes an unexpected object.
|
|
401
|
+
if getattr(result, "verdict", None) != Verdict.BLOCK:
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
patch = result.patch
|
|
405
|
+
if patch is None:
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
# governance_event_id is typed on EvaluationResult; ApprovalResult carries it only in raw.
|
|
409
|
+
if isinstance(result, EvaluationResult):
|
|
410
|
+
governance_event_id = result.governance_event_id
|
|
411
|
+
else:
|
|
412
|
+
governance_event_id = result.raw.get("governance_event_id") or result.raw.get("id")
|
|
413
|
+
|
|
414
|
+
return PatchDirective(
|
|
415
|
+
new_input=patch.new_input,
|
|
416
|
+
governance_event_id=governance_event_id,
|
|
417
|
+
reason=result.reason,
|
|
418
|
+
)
|
|
@@ -18,6 +18,7 @@ import logging
|
|
|
18
18
|
from collections.abc import Mapping
|
|
19
19
|
from typing import Any, NoReturn
|
|
20
20
|
|
|
21
|
+
from ..adapters.base import adapter_accepts_context
|
|
21
22
|
from ..approvals import ApprovalPoller
|
|
22
23
|
from ..contracts.events import EventEnvelope
|
|
23
24
|
from ..contracts.otel_spans import HookType, Stage
|
|
@@ -39,10 +40,13 @@ class HookRuntime:
|
|
|
39
40
|
self._store = runtime.context_store
|
|
40
41
|
self._gate = runtime.gate
|
|
41
42
|
self._adapter = runtime.adapter
|
|
42
|
-
# Decide ONCE whether the adapter's
|
|
43
|
-
#
|
|
43
|
+
# Decide ONCE whether the adapter's callbacks take ``context``, by
|
|
44
|
+
# inspecting their signatures — so a genuine TypeError raised inside a
|
|
44
45
|
# callback body is never mistaken for an arity mismatch and swallowed.
|
|
45
|
-
self._completed_accepts_context =
|
|
46
|
+
self._completed_accepts_context = adapter_accepts_context(
|
|
47
|
+
self._adapter.on_completed_hook_result
|
|
48
|
+
)
|
|
49
|
+
self._approval_accepts_context = adapter_accepts_context(self._adapter.handle_approval)
|
|
46
50
|
hitl = runtime.config.hitl
|
|
47
51
|
self._sync_poller: ApprovalPoller | None = None
|
|
48
52
|
if hitl.enabled:
|
|
@@ -163,7 +167,14 @@ class HookRuntime:
|
|
|
163
167
|
)
|
|
164
168
|
if verdict.requires_approval():
|
|
165
169
|
# Adapter drives its native approval flow; returning ⇒ approved.
|
|
166
|
-
|
|
170
|
+
# Core omits the workflow/run/activity IDs from the evaluate
|
|
171
|
+
# response, so hand the span-resolved context for the poll.
|
|
172
|
+
if self._approval_accepts_context:
|
|
173
|
+
await self._adapter.handle_approval(
|
|
174
|
+
result, context=resolve_context(self._store, span)
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
await self._adapter.handle_approval(result)
|
|
167
178
|
return True
|
|
168
179
|
return True
|
|
169
180
|
|
|
@@ -259,24 +270,6 @@ class HookRuntime:
|
|
|
259
270
|
return
|
|
260
271
|
self._after_completed(result, span)
|
|
261
272
|
|
|
262
|
-
def _adapter_accepts_context(self) -> bool:
|
|
263
|
-
"""True when the adapter's ``on_completed_hook_result`` accepts a
|
|
264
|
-
``context`` argument (checked once, by signature — not by catching a
|
|
265
|
-
TypeError from the call, which would mask real errors)."""
|
|
266
|
-
import inspect
|
|
267
|
-
|
|
268
|
-
callback = getattr(self._adapter, "on_completed_hook_result", None)
|
|
269
|
-
if callback is None:
|
|
270
|
-
return False
|
|
271
|
-
try:
|
|
272
|
-
params = inspect.signature(callback).parameters
|
|
273
|
-
except (TypeError, ValueError):
|
|
274
|
-
return False
|
|
275
|
-
# Accepts context via an explicit param or **kwargs.
|
|
276
|
-
return "context" in params or any(
|
|
277
|
-
p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()
|
|
278
|
-
)
|
|
279
|
-
|
|
280
273
|
def _after_completed(self, result: EvaluationResult, span: Any) -> None:
|
|
281
274
|
if result.verdict.should_stop():
|
|
282
275
|
self._mark_stopped(result, span) # future execution only
|
|
@@ -17,10 +17,11 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
from typing import Any
|
|
19
19
|
|
|
20
|
-
from .adapters.base import CoreAdapter, FrameworkAdapter
|
|
20
|
+
from .adapters.base import CoreAdapter, FrameworkAdapter, adapter_accepts_context
|
|
21
21
|
from .client import EvaluationClient
|
|
22
22
|
from .config import OpenBoxConfig
|
|
23
23
|
from .context import ContextStore, default_context_store
|
|
24
|
+
from .contracts.context import ActivityContext
|
|
24
25
|
from .contracts.events import EventEnvelope
|
|
25
26
|
from .contracts.results import EvaluationResult, Verdict
|
|
26
27
|
from .errors import GuardrailsValidationError
|
|
@@ -51,6 +52,9 @@ class OpenBoxRuntime:
|
|
|
51
52
|
):
|
|
52
53
|
self.config = config
|
|
53
54
|
self.adapter: FrameworkAdapter = adapter if adapter is not None else CoreAdapter()
|
|
55
|
+
# Decide ONCE whether the adapter's handle_approval accepts ``context``
|
|
56
|
+
# (older adapters take only ``result``) — see adapter_accepts_context.
|
|
57
|
+
self._approval_accepts_context = adapter_accepts_context(self.adapter.handle_approval)
|
|
54
58
|
self.context_store = context_store if context_store is not None else default_context_store()
|
|
55
59
|
self.client = client if client is not None else EvaluationClient(
|
|
56
60
|
config.api_url,
|
|
@@ -104,7 +108,14 @@ class OpenBoxRuntime:
|
|
|
104
108
|
result = await self.gate.aevaluate(event)
|
|
105
109
|
if result.verdict.requires_approval():
|
|
106
110
|
self._check_guardrails(result)
|
|
107
|
-
|
|
111
|
+
# Core omits workflow/run/activity IDs from the evaluate response, so
|
|
112
|
+
# build the approval context from the originating event for the poll.
|
|
113
|
+
if self._approval_accepts_context:
|
|
114
|
+
await self.adapter.handle_approval(
|
|
115
|
+
result, context=_approval_context_from_event(event)
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
await self.adapter.handle_approval(result)
|
|
108
119
|
return result
|
|
109
120
|
return self._enforce_lifecycle(result, drive_approval=False)
|
|
110
121
|
|
|
@@ -136,3 +147,25 @@ class OpenBoxRuntime:
|
|
|
136
147
|
self.uninstall_instrumentation()
|
|
137
148
|
self.context_store.clear()
|
|
138
149
|
await self.client.aclose()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _approval_context_from_event(event: EventEnvelope) -> ActivityContext:
|
|
153
|
+
"""Build the approval context for a lifecycle event.
|
|
154
|
+
|
|
155
|
+
``workflow_id`` / ``run_id`` live in the flat wire ``payload``;
|
|
156
|
+
``activity_id`` is a first-class envelope field (a workflow-level approval
|
|
157
|
+
legitimately has none). Core's evaluate response omits all three, so the
|
|
158
|
+
poll must be built from the originating event — see
|
|
159
|
+
``CoreAdapter.handle_approval``.
|
|
160
|
+
"""
|
|
161
|
+
payload = event.payload
|
|
162
|
+
|
|
163
|
+
def _s(value: Any) -> str | None:
|
|
164
|
+
return value if isinstance(value, str) else None
|
|
165
|
+
|
|
166
|
+
activity_id = event.activity_id if event.activity_id is not None else _s(payload.get("activity_id"))
|
|
167
|
+
return ActivityContext(
|
|
168
|
+
workflow_id=_s(payload.get("workflow_id")),
|
|
169
|
+
run_id=_s(payload.get("run_id")),
|
|
170
|
+
activity_id=activity_id,
|
|
171
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "openbox-sdk-python"
|
|
3
|
-
version = "1.0
|
|
3
|
+
version = "1.2.0"
|
|
4
4
|
description = "OpenBox base SDK - governance contracts, strict gate, identity/signing, evaluate client, context runtime, OTel span wire serialization, and generic instrumentation shared by every OpenBox framework SDK"
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "OpenBox Team", email = "tino@openbox.ai" },
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""patch parsing + handle_patch tests (frozen wire contract).
|
|
2
|
+
|
|
3
|
+
Covers: present-null vs absent, falsy-value preservation, boolean rejection, the finite/
|
|
4
|
+
safe-integer numeric rule (incl. nested), action="block" approvals, HALT/expired -> None,
|
|
5
|
+
the EvaluationResult path not raising AttributeError, and default BLOCK enforcement unchanged.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from openbox_core.contracts.results import (
|
|
11
|
+
ApprovalResult,
|
|
12
|
+
EvaluationResult,
|
|
13
|
+
Patch,
|
|
14
|
+
PatchDirective,
|
|
15
|
+
Verdict,
|
|
16
|
+
handle_patch,
|
|
17
|
+
)
|
|
18
|
+
from openbox_core.errors import GovernanceBlockedError
|
|
19
|
+
from openbox_core.gate import raise_for_verdict
|
|
20
|
+
|
|
21
|
+
SAFE_MAX = 2**53 - 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _block(**extra):
|
|
25
|
+
return {"verdict": "block", **extra}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestPatchParsing:
|
|
29
|
+
def test_valid_patch_parses_on_evaluation(self):
|
|
30
|
+
r = EvaluationResult.from_dict(_block(patch={"new_input": "x"}))
|
|
31
|
+
assert isinstance(r.patch, Patch)
|
|
32
|
+
assert r.patch.new_input == "x"
|
|
33
|
+
|
|
34
|
+
def test_valid_patch_parses_on_approval_block_action(self):
|
|
35
|
+
r = ApprovalResult.from_dict(
|
|
36
|
+
{"action": "block", "patch": {"new_input": [1, 2]}}
|
|
37
|
+
)
|
|
38
|
+
assert r.verdict is Verdict.BLOCK
|
|
39
|
+
assert isinstance(r.patch, Patch)
|
|
40
|
+
assert r.patch.new_input == [1, 2]
|
|
41
|
+
|
|
42
|
+
def test_present_null_is_distinct_from_absent(self):
|
|
43
|
+
present = EvaluationResult.from_dict(_block(patch={"new_input": None}))
|
|
44
|
+
assert present.patch is not None
|
|
45
|
+
assert present.patch.new_input is None
|
|
46
|
+
|
|
47
|
+
absent = EvaluationResult.from_dict(_block())
|
|
48
|
+
assert absent.patch is None
|
|
49
|
+
|
|
50
|
+
def test_json_null_field_is_treated_as_absent(self):
|
|
51
|
+
assert EvaluationResult.from_dict(_block(patch=None)).patch is None
|
|
52
|
+
|
|
53
|
+
def test_falsy_values_preserved(self):
|
|
54
|
+
for val in (None, "", 0, [], {}):
|
|
55
|
+
r = EvaluationResult.from_dict(_block(patch={"new_input": val}))
|
|
56
|
+
assert r.patch is not None, val
|
|
57
|
+
assert r.patch.new_input == val
|
|
58
|
+
|
|
59
|
+
def test_boolean_new_input_rejected(self):
|
|
60
|
+
for val in (True, False):
|
|
61
|
+
r = EvaluationResult.from_dict(_block(patch={"new_input": val}))
|
|
62
|
+
assert r.patch is None
|
|
63
|
+
|
|
64
|
+
def test_extra_key_rejected(self):
|
|
65
|
+
r = EvaluationResult.from_dict(
|
|
66
|
+
_block(patch={"new_input": None, "x": 1})
|
|
67
|
+
)
|
|
68
|
+
assert r.patch is None
|
|
69
|
+
|
|
70
|
+
def test_missing_new_input_rejected(self):
|
|
71
|
+
r = EvaluationResult.from_dict(_block(patch={"other": 1}))
|
|
72
|
+
assert r.patch is None
|
|
73
|
+
|
|
74
|
+
def test_non_dict_patch_rejected(self):
|
|
75
|
+
for bad in ("str", 5, [1], True):
|
|
76
|
+
r = EvaluationResult.from_dict(_block(patch=bad))
|
|
77
|
+
assert r.patch is None
|
|
78
|
+
|
|
79
|
+
def test_numeric_safe_boundary(self):
|
|
80
|
+
assert (
|
|
81
|
+
EvaluationResult.from_dict(
|
|
82
|
+
_block(patch={"new_input": SAFE_MAX})
|
|
83
|
+
).patch
|
|
84
|
+
is not None
|
|
85
|
+
)
|
|
86
|
+
assert (
|
|
87
|
+
EvaluationResult.from_dict(
|
|
88
|
+
_block(patch={"new_input": SAFE_MAX + 1})
|
|
89
|
+
).patch
|
|
90
|
+
is None
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def test_non_finite_and_nested_unsafe_rejected(self):
|
|
94
|
+
assert (
|
|
95
|
+
EvaluationResult.from_dict(
|
|
96
|
+
_block(patch={"new_input": float("inf")})
|
|
97
|
+
).patch
|
|
98
|
+
is None
|
|
99
|
+
)
|
|
100
|
+
assert (
|
|
101
|
+
EvaluationResult.from_dict(
|
|
102
|
+
_block(patch={"new_input": float("nan")})
|
|
103
|
+
).patch
|
|
104
|
+
is None
|
|
105
|
+
)
|
|
106
|
+
assert (
|
|
107
|
+
EvaluationResult.from_dict(
|
|
108
|
+
_block(patch={"new_input": {"a": [SAFE_MAX + 1]}})
|
|
109
|
+
).patch
|
|
110
|
+
is None
|
|
111
|
+
)
|
|
112
|
+
assert (
|
|
113
|
+
EvaluationResult.from_dict(
|
|
114
|
+
_block(patch={"new_input": {"a": [SAFE_MAX]}})
|
|
115
|
+
).patch
|
|
116
|
+
is not None
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def test_raw_preserved(self):
|
|
120
|
+
data = _block(patch={"new_input": "x"}, extra="keep")
|
|
121
|
+
r = EvaluationResult.from_dict(data)
|
|
122
|
+
assert r.raw["extra"] == "keep"
|
|
123
|
+
assert r.raw["patch"] == {"new_input": "x"}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TestHandlePatch:
|
|
127
|
+
def test_directive_from_evaluation_block(self):
|
|
128
|
+
r = EvaluationResult.from_dict(
|
|
129
|
+
_block(
|
|
130
|
+
patch={"new_input": "x"},
|
|
131
|
+
governance_event_id="ev-1",
|
|
132
|
+
reason="blocked",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
d = handle_patch(r)
|
|
136
|
+
assert isinstance(d, PatchDirective)
|
|
137
|
+
assert d.new_input == "x"
|
|
138
|
+
assert d.governance_event_id == "ev-1"
|
|
139
|
+
assert d.reason == "blocked"
|
|
140
|
+
|
|
141
|
+
def test_directive_from_approval_reads_event_id_from_raw(self):
|
|
142
|
+
r = ApprovalResult.from_dict(
|
|
143
|
+
{
|
|
144
|
+
"action": "block",
|
|
145
|
+
"patch": {"new_input": None},
|
|
146
|
+
"governance_event_id": "ev-2",
|
|
147
|
+
"reason": "patch",
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
d = handle_patch(r)
|
|
151
|
+
assert d is not None
|
|
152
|
+
assert d.new_input is None
|
|
153
|
+
assert d.governance_event_id == "ev-2"
|
|
154
|
+
|
|
155
|
+
def test_approval_event_id_falls_back_to_id(self):
|
|
156
|
+
r = ApprovalResult.from_dict(
|
|
157
|
+
{"action": "block", "patch": {"new_input": 1}, "id": "ev-3"}
|
|
158
|
+
)
|
|
159
|
+
d = handle_patch(r)
|
|
160
|
+
assert d is not None
|
|
161
|
+
assert d.governance_event_id == "ev-3"
|
|
162
|
+
|
|
163
|
+
def test_plain_block_returns_none(self):
|
|
164
|
+
assert handle_patch(EvaluationResult.from_dict(_block())) is None
|
|
165
|
+
|
|
166
|
+
def test_non_block_verdicts_return_none(self):
|
|
167
|
+
for verdict in ("allow", "constrain", "require_approval", "halt"):
|
|
168
|
+
r = EvaluationResult.from_dict(
|
|
169
|
+
{"verdict": verdict, "patch": {"new_input": "x"}}
|
|
170
|
+
)
|
|
171
|
+
assert handle_patch(r) is None, verdict
|
|
172
|
+
|
|
173
|
+
def test_halt_approval_returns_none_even_with_patch(self):
|
|
174
|
+
r = ApprovalResult.from_dict(
|
|
175
|
+
{"action": "halt", "patch": {"new_input": "x"}}
|
|
176
|
+
)
|
|
177
|
+
assert handle_patch(r) is None
|
|
178
|
+
|
|
179
|
+
def test_expired_approval_returns_none_even_if_block(self):
|
|
180
|
+
r = ApprovalResult.from_dict(
|
|
181
|
+
{"action": "block", "patch": {"new_input": "x"}, "expired": True}
|
|
182
|
+
)
|
|
183
|
+
assert r.verdict is Verdict.BLOCK
|
|
184
|
+
assert handle_patch(r) is None
|
|
185
|
+
|
|
186
|
+
def test_pending_approval_returns_none(self):
|
|
187
|
+
r = ApprovalResult.from_dict({"patch": {"new_input": "x"}})
|
|
188
|
+
assert r.verdict is None
|
|
189
|
+
assert handle_patch(r) is None
|
|
190
|
+
|
|
191
|
+
def test_evaluation_result_never_raises_attributeerror(self):
|
|
192
|
+
# EvaluationResult has no is_blocking()/expired; the gate must not touch them.
|
|
193
|
+
r = EvaluationResult.from_dict(_block(patch={"new_input": "x"}))
|
|
194
|
+
assert handle_patch(r) is not None
|
|
195
|
+
|
|
196
|
+
def test_block_without_patch_returns_none(self):
|
|
197
|
+
assert handle_patch(EvaluationResult.from_dict(_block())) is None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TestEnforcementUnchanged:
|
|
201
|
+
def test_block_still_raises_even_with_patch(self):
|
|
202
|
+
# Adding patch support must NOT suppress the default block; the helper is opt-in only.
|
|
203
|
+
r = EvaluationResult.from_dict(
|
|
204
|
+
_block(patch={"new_input": "x"}, reason="nope")
|
|
205
|
+
)
|
|
206
|
+
with pytest.raises(GovernanceBlockedError):
|
|
207
|
+
raise_for_verdict(r)
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/runtime/test_runtime_delegation.py
RENAMED
|
@@ -4,10 +4,12 @@ import httpx
|
|
|
4
4
|
import pytest
|
|
5
5
|
|
|
6
6
|
from openbox_core.adapters.base import CoreAdapter, FrameworkAdapter
|
|
7
|
+
from openbox_core.approvals import ApprovalPoller
|
|
7
8
|
from openbox_core.client import EvaluationClient
|
|
8
9
|
from openbox_core.config import OpenBoxConfig
|
|
10
|
+
from openbox_core.contracts.context import ActivityContext
|
|
9
11
|
from openbox_core.contracts.events import workflow_started
|
|
10
|
-
from openbox_core.contracts.results import EvaluationResult, Verdict
|
|
12
|
+
from openbox_core.contracts.results import ApprovalResult, EvaluationResult, Verdict
|
|
11
13
|
from openbox_core.errors import (
|
|
12
14
|
ApprovalRejectedError,
|
|
13
15
|
GovernanceBlockedError,
|
|
@@ -45,6 +47,30 @@ class RecordingAdapter:
|
|
|
45
47
|
self.completed.append(result)
|
|
46
48
|
|
|
47
49
|
|
|
50
|
+
class _IdRecordingClient:
|
|
51
|
+
"""Fake client recording the (workflow_id, run_id, activity_id) each async
|
|
52
|
+
poll is called with, then returning an allow-shaped decision."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, seen: list[tuple[str, str, str]]):
|
|
55
|
+
self._seen = seen
|
|
56
|
+
|
|
57
|
+
async def apoll_approval(self, workflow_id, run_id, activity_id):
|
|
58
|
+
self._seen.append((workflow_id, run_id, activity_id))
|
|
59
|
+
return ApprovalResult.from_dict({"action": "allow"})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _ContextRecordingAdapter(RecordingAdapter):
|
|
63
|
+
"""RecordingAdapter whose handle_approval ACCEPTS and records ``context``."""
|
|
64
|
+
|
|
65
|
+
def __init__(self):
|
|
66
|
+
super().__init__()
|
|
67
|
+
self.approval_contexts: list[ActivityContext | None] = []
|
|
68
|
+
|
|
69
|
+
async def handle_approval(self, result, context=None):
|
|
70
|
+
self.approvals.append(result)
|
|
71
|
+
self.approval_contexts.append(context)
|
|
72
|
+
|
|
73
|
+
|
|
48
74
|
def make_runtime(response_json, adapter=None):
|
|
49
75
|
transport = httpx.MockTransport(lambda r: httpx.Response(200, json=response_json))
|
|
50
76
|
client = EvaluationClient(
|
|
@@ -91,6 +117,17 @@ class TestLifecycleDelegation:
|
|
|
91
117
|
assert result.verdict is Verdict.REQUIRE_APPROVAL
|
|
92
118
|
assert len(adapter.approvals) == 1
|
|
93
119
|
|
|
120
|
+
async def test_require_approval_threads_event_context(self):
|
|
121
|
+
# A context-accepting adapter receives the event's workflow/run IDs —
|
|
122
|
+
# Core's evaluate response never echoes them, so the runtime must supply
|
|
123
|
+
# them from the originating event.
|
|
124
|
+
adapter = _ContextRecordingAdapter()
|
|
125
|
+
runtime = make_runtime({"verdict": "require_approval", "approval_id": "app-1"}, adapter)
|
|
126
|
+
await runtime.aevaluate_lifecycle(workflow_started(**WF))
|
|
127
|
+
ctx = adapter.approval_contexts[0]
|
|
128
|
+
assert ctx is not None
|
|
129
|
+
assert (ctx.workflow_id, ctx.run_id) == ("wf-1", "r-1")
|
|
130
|
+
|
|
94
131
|
def test_sync_require_approval_returned_to_caller(self):
|
|
95
132
|
adapter = RecordingAdapter()
|
|
96
133
|
runtime = make_runtime({"verdict": "require_approval"}, adapter)
|
|
@@ -117,6 +154,29 @@ class TestCoreAdapterDefaults:
|
|
|
117
154
|
EvaluationResult(verdict=Verdict.REQUIRE_APPROVAL, approval_id="a")
|
|
118
155
|
)
|
|
119
156
|
|
|
157
|
+
async def test_core_adapter_polls_with_context_ids_not_raw(self):
|
|
158
|
+
# raw is EMPTY, exactly as a real Core evaluate response is — the poll
|
|
159
|
+
# IDs must come from the context the runtime threads in.
|
|
160
|
+
seen: list[tuple[str, str, str]] = []
|
|
161
|
+
poller = ApprovalPoller(_IdRecordingClient(seen), poll_interval_seconds=0.001)
|
|
162
|
+
adapter = CoreAdapter(approval_poller=poller)
|
|
163
|
+
result = EvaluationResult(verdict=Verdict.REQUIRE_APPROVAL, approval_id="a")
|
|
164
|
+
ctx = ActivityContext(workflow_id="wf-9", run_id="run-9", activity_id="act-9")
|
|
165
|
+
await adapter.handle_approval(result, context=ctx)
|
|
166
|
+
assert seen == [("wf-9", "run-9", "act-9")]
|
|
167
|
+
|
|
168
|
+
async def test_core_adapter_approval_falls_back_to_raw_without_context(self):
|
|
169
|
+
seen: list[tuple[str, str, str]] = []
|
|
170
|
+
poller = ApprovalPoller(_IdRecordingClient(seen), poll_interval_seconds=0.001)
|
|
171
|
+
adapter = CoreAdapter(approval_poller=poller)
|
|
172
|
+
result = EvaluationResult(
|
|
173
|
+
verdict=Verdict.REQUIRE_APPROVAL,
|
|
174
|
+
approval_id="a",
|
|
175
|
+
raw={"workflow_id": "wf-raw", "run_id": "run-raw", "activity_id": "act-raw"},
|
|
176
|
+
)
|
|
177
|
+
await adapter.handle_approval(result)
|
|
178
|
+
assert seen == [("wf-raw", "run-raw", "act-raw")]
|
|
179
|
+
|
|
120
180
|
def test_protocol_conformance(self):
|
|
121
181
|
assert isinstance(CoreAdapter(), FrameworkAdapter)
|
|
122
182
|
assert isinstance(RecordingAdapter(), FrameworkAdapter)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/conformance/instrumentation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/function.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/manager.py
RENAMED
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/shared.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/validation/diagnostics.py
RENAMED
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/validation/event_rules.py
RENAMED
|
File without changes
|
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/validation/span_normalization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/conformance/test_required_cases.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/contracts/test_approval_parsing.py
RENAMED
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/contracts/test_event_classify.py
RENAMED
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/contracts/test_result_parsing.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/instrumented_env.py
RENAMED
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_db_block.py
RENAMED
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_db_redis_mongo.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_hook_runtime.py
RENAMED
|
File without changes
|
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_http_urllib.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/test_core_span_projection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|