From c6a2e255e1a7ecc97b5aad9519b8b8f815b0ed75 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Fri, 24 Jul 2026 12:38:14 +0400 Subject: [PATCH 01/10] some clean up --- .gitignore | 1 + LICENSE | 2 +- examples/async_usage.py | 36 --------------- examples/basic.py | 27 ----------- examples/basic_observe.py | 49 -------------------- examples/cost_dashboard.py | 95 -------------------------------------- 6 files changed, 2 insertions(+), 208 deletions(-) delete mode 100644 examples/async_usage.py delete mode 100644 examples/basic.py delete mode 100644 examples/basic_observe.py delete mode 100644 examples/cost_dashboard.py diff --git a/.gitignore b/.gitignore index 4344fac..a8dc021 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ analyze.md docs/integration-baseline-2026-06-19.md audit.md docs/postman/ +.hermes/ diff --git a/LICENSE b/LICENSE index 20acf65..0de7313 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Maltsev Anatolii + Copyright 2026 Anatolii Maltsev Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/examples/async_usage.py b/examples/async_usage.py deleted file mode 100644 index a7c1a06..0000000 --- a/examples/async_usage.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Async usage — @protect with async functions. - -Sprint 2.8: the pre-fix docstring claimed "No api_key → local mode -(auto-detected). No network calls, no polling." That was removed in -0.3.0 — `init()` now requires an `api_key` and raises -`NullRunAuthenticationError` if neither `api_key` nor the -`NULLRUN_API_KEY` env var is set (CHANGELOG 0.3.0 §"Required -api_key"). The silent no-op local mode was a real safety hole -because it bypassed every backend gate. - -Run: python examples/async_usage.py - (Requires NULLRUN_API_KEY env var, or pass api_key explicitly - to init().) -""" -import asyncio -import os - -from nullrun import init, protect - -# api_key is required as of 0.3.0 (CHANGELOG 0.3.0 §"Required -# api_key"). The previous "no api_key → local mode" behaviour was -# a safety hole and was removed. -init(api_key=os.environ.get("NULLRUN_API_KEY", "demo-key")) - -@protect -async def async_tool(prompt: str) -> str: - await asyncio.sleep(0.01) - return f"[async protected] {prompt}" - -async def main() -> None: - print("Running async protected function...") - result = await async_tool("Tell me a joke") - print(f"Result: {result}") - -asyncio.run(main()) \ No newline at end of file diff --git a/examples/basic.py b/examples/basic.py deleted file mode 100644 index 598d66d..0000000 --- a/examples/basic.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Basic usage — @protect decorator. - -The SDK requires an API key (the silent local-mode fallback was -removed in 0.3.0 — see CHANGELOG). For real usage, set -NULLRUN_API_KEY in the environment and pass api_key explicitly. -For local development against a private gateway, the demo key -below works as a placeholder. - -Run: python examples/basic.py -""" -import os - -from nullrun import protect, init - -# Required as of 0.3.0. Reads NULLRUN_API_KEY from the environment -# if not passed explicitly. -init(api_key=os.environ.get("NULLRUN_API_KEY", "demo-key")) - -@protect -def call_llm(prompt: str) -> str: - return f"[response] {prompt[:50]}" - -print("Calling protected function...") -result = call_llm("What is the capital of France?") -print(f"Result: {result}") -print("Done.") diff --git a/examples/basic_observe.py b/examples/basic_observe.py deleted file mode 100644 index 2fdc196..0000000 --- a/examples/basic_observe.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -Phase 2 hero example — basic observability, no code changes. - -The promise: install `nullrun`, call `init(api_key=...)`, and the -SDK observes your existing LLM calls. No decorator needed. -The dashboard picks up the events as they happen. - -Run: - pip install -e ../sdk-python - export NULLRUN_API_KEY=nr_live_... - python basic_observe.py -""" - -import os - -import nullrun -from openai import OpenAI - -# 1. One-line init. The SDK reads NULLRUN_API_KEY from the -# environment if you don't pass it explicitly. Auto-instrumentation -# wires up the OpenAI transport AFTER `init()` returns. -nullrun.init( - api_key=os.environ.get("NULLRUN_API_KEY", "demo-key"), - api_url=os.environ.get("NULLRUN_API_URL", "http://localhost:8080"), -) - -# 2. Use OpenAI exactly as you did before. The auto-instrumentation -# in `nullrun.instrumentation.auto` patches `httpx.Client` and -# `httpx.AsyncClient` so every chat completion is recorded as a -# `llm_call` event with token counts, latency, and cost. -client = OpenAI() - -# 3. Make a real call. The SDK records: -# - workflow_id: derived from the API key on the backend -# - tokens: from the response.usage -# - cost: computed server-side from `model_pricing` -# - latency: from request start to response -# The dashboard updates within ~2s. -for i in range(3): - resp = client.chat.completions.create( - model="gpt-4o-mini", - messages=[{"role": "user", "content": f"Say hi (call #{i + 1})"}], - ) - print(f"call #{i + 1}: {resp.choices[0].message.content!r}") - -# 4. 0.9.0: per-process coverage snapshot removed. Coverage is now -# derived server-side from llm_call span metadata (host + tracked + -# streaming_skipped flags). Query the dashboard or use -# `GET /api/v1/coverage/{org_id}` to inspect. diff --git a/examples/cost_dashboard.py b/examples/cost_dashboard.py deleted file mode 100644 index cdb7b51..0000000 --- a/examples/cost_dashboard.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -Phase 2 example — read live cost from the dashboard. - -NULLRUN is the single source of truth for AI workflow budgets: the -dashboard's policy wins, never a `max_cost=` kwarg. This example -reads the unified status payload for one workflow so the user can -see that the SDK and the dashboard agree. - -Run: - pip install -e ../sdk-python - export NULLRUN_API_KEY=nr_live_... - export NULLRUN_ORGANIZATION_ID= - export NULLRUN_WORKFLOW_ID= - python cost_dashboard.py - -Sprint 2.8: the previous version used zero-UUID defaults for -``NULLRUN_ORGANIZATION_ID`` and ``NULLRUN_WORKFLOW_ID``, which -always 404 against the real backend. The example would import -and run, but the GET returned an error and the example printed -zeroed fields. Now we exit early with an actionable message if -either env var is missing. -""" - -import os -import sys - -import nullrun - - -def _require_env(name: str) -> str: - """Return the env var value, or exit with an actionable message.""" - value = os.environ.get(name) - if not value or value == "00000000-0000-0000-0000-000000000000": - print( - f"ERROR: {name} is required.\n" - f"Set it to a real UUID from the NullRun dashboard. " - f"Example:\n" - f" export {name}=", - file=sys.stderr, - ) - sys.exit(1) - return value - - -def main() -> None: - # Sprint 2.8: validate required env vars BEFORE ``nullrun.init()`` - # so the user gets a clear "missing env var" error rather than - # a confusing 401 from /auth/verify. ``init()`` will perform a - # network call against the gateway; if the api_key is the demo - # placeholder it will fail with 401. Better to fail at the - # script's own validation step first. - org_id = _require_env("NULLRUN_ORGANIZATION_ID") - workflow_id = _require_env("NULLRUN_WORKFLOW_ID") - api_key = os.environ.get("NULLRUN_API_KEY") - if not api_key: - print( - "ERROR: NULLRUN_API_KEY is required.\n" - "Set it to a real api_key from the NullRun dashboard.", - file=sys.stderr, - ) - sys.exit(1) - - # Initialise the SDK so the example matches the typical setup - # pattern. ``nullrun.init`` is not strictly required for the - # raw ``/status`` GET below, but it makes the example feel - # like a real-world wiring. - nullrun.init(api_key=api_key) - - print(f"Reading status for org {org_id!r}, workflow {workflow_id!r}...") - body = nullrun.get_runtime().get_org_status(org_id) - - usage_today = body.get("usage_today_cents", 0) / 100.0 - usage_month = body.get("usage_month_cents", 0) / 100.0 - budget_used = body.get("budget_used_cents", 0) / 100.0 - rate = body.get("rate") - plan = body.get("plan") - accuracy = body.get("cost_accuracy_hint", "approximate") - - print(f" usage today: ${usage_today:,.2f}") - print(f" usage month: ${usage_month:,.2f}") - print(f" budget used: ${budget_used:,.2f}") - if rate is not None: - print(f" rate: {rate}") - if plan: - print(f" plan: {plan}") - print(f" cost accuracy: {accuracy}") - - print( - "\nBudgets live in the Control Plane (UI/policy), not in code. " - "Edit the workflow's policy in the dashboard to change the cap." - ) - - -if __name__ == "__main__": - main() \ No newline at end of file From 561b0e202142b2de3d7b50a3936d125872a7e6e1 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Fri, 24 Jul 2026 17:51:57 +0400 Subject: [PATCH 02/10] fix(runtime): treat websocket cancellation as clean shutdown --- src/nullrun/runtime.py | 6 ++++++ tests/test_runtime_branches.py | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index 6882467..409099c 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -1157,6 +1157,12 @@ def _on_approval_resolved(payload): try: if conn._receive_task is not None: # type: ignore[attr-defined] await conn._receive_task # type: ignore[attr-defined] + except asyncio.CancelledError: + # ``WebSocketConnection.close()`` cancels the receive task to + # unblock this waiter during normal shutdown. In Python 3.11+ + # CancelledError derives from BaseException, so the generic + # ``except Exception`` below does not catch it. + pass except Exception as e: logger.debug(f"WS receive loop ended: {e}") finally: diff --git a/tests/test_runtime_branches.py b/tests/test_runtime_branches.py index d782618..9145b54 100644 --- a/tests/test_runtime_branches.py +++ b/tests/test_runtime_branches.py @@ -332,6 +332,42 @@ def test_stop_recording_returns_none(): # ─── shutdown ──────────────────────────────────────────────────────── +def test_ws_connect_and_serve_treats_receive_cancellation_as_clean_shutdown(): + """An expected receive-task cancellation must not escape the WS thread.""" + import asyncio + + rt = _make_test_runtime() + + class _CancelledConnection: + def __init__(self): + async def _cancelled_receive(): + raise asyncio.CancelledError + + self._receive_task = asyncio.create_task(_cancelled_receive()) + self.closed = False + + async def close(self): + self.closed = True + try: + await self._receive_task + except asyncio.CancelledError: + pass + + connection = None + + async def _connect_websocket(**_kwargs): + nonlocal connection + connection = _CancelledConnection() + return connection + + rt._transport.connect_websocket = _connect_websocket + asyncio.run(rt._ws_connect_and_serve()) + + assert connection is not None + assert connection.closed is True + assert rt._ws_connection is None + + def test_shutdown_when_polling_disabled(monkeypatch): rt = _make_test_runtime() rt._poll_running = False From 215aeae773a542d92bda7e64810147645b8eddfd Mon Sep 17 00:00:00 2001 From: Anatolii Date: Fri, 24 Jul 2026 19:04:09 +0400 Subject: [PATCH 03/10] fix(transport): approval_resolved callback is synchronous --- src/nullrun/transport.py | 14 ++-- tests/test_approval_ws_sync_callback.py | 103 ++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 tests/test_approval_ws_sync_callback.py diff --git a/src/nullrun/transport.py b/src/nullrun/transport.py index 9a62785..66776e3 100644 --- a/src/nullrun/transport.py +++ b/src/nullrun/transport.py @@ -1754,12 +1754,14 @@ async def wrapped_key_rotated(ws_id: str, key_id: str, new_version: int) -> None on_key_rotated(ws_id, key_id, new_version) # Wrap the approval-resolved callback. The WebSocketConnection - # handler dispatches the raw dict to on_approval_resolved (the - # dispatch signature is dict-only, not an async wrapper), so - # we adapt the sync callback to async by spawning a thread — - # the resolution logic in runtime.py is short-lived and not - # coroutine-bound (it touches a threading.Event). - async def wrapped_approval_resolved(payload: dict[str, Any]) -> None: + # handler dispatches the raw dict to on_approval_resolved as a + # plain function (the dispatch signature is dict-only, not + # awaitable), so a synchronous adapter is enough — declaring + # this `async def` would produce a coroutine that the + # handler ignores, and runtime.py's pending Event would never + # be set. Caught 2026-07-24 with the demo's first approval + # resolution. + def wrapped_approval_resolved(payload: dict[str, Any]) -> None: if on_approval_resolved: on_approval_resolved(payload) diff --git a/tests/test_approval_ws_sync_callback.py b/tests/test_approval_ws_sync_callback.py new file mode 100644 index 0000000..91a829f --- /dev/null +++ b/tests/test_approval_ws_sync_callback.py @@ -0,0 +1,103 @@ +""" +Regression (2026-07-24): ``Transport.connect_websocket`` wires the +``on_approval_resolved`` callback as a **plain function**, not an +``async def``. + +The WS dispatch path in ``transport_websocket.py`` calls +``self.on_approval_resolved(data)`` synchronously. Declaring +``wrapped_approval_resolved`` as ``async def`` produced an +un-awaited coroutine, and the SDK's pending ``threading.Event`` +never fired — the agent stayed parked on the gate until the +300s default timeout even after the operator clicked Approve. + +The fix is one-line in ``transport.py``; the regression test +here pins the contract: the callback is invoked synchronously +with the raw payload dict, and a coroutine wrapper is **not** +acceptable. + +Same test would have caught the bug 2026-07-23 if it existed +in the test suite at SDK 0.13.11 — it was added when +``connect_websocket`` grew the wrapped wrappers for the other +callbacks, and the audit found the missing sync-only test for +the approval callback specifically. +""" + +from __future__ import annotations + +import asyncio +import inspect + +from nullrun.transport import Transport + + +def test_wrapped_approval_resolved_is_synchronous(): + """The adapter passed to ``WebSocketConnection(on_approval_resolved=...)`` + must be a plain ``def``. ``async def`` produces a coroutine + that the dispatcher ignores, leaving the agent stuck on + the gate. + """ + transport = Transport( + api_url="https://api.nullrun.io", api_key="test-key" + ) + received: list[dict] = [] + + def on_approval_resolved(payload): + received.append(payload) + + # The wrapper lives inside ``Transport.connect_websocket``'s + # closure. Rather than re-implementing the wrapper to read the + # ``on_approval_resolved=`` argument it forwards, we patch + # ``WebSocketConnection.__init__`` to capture whatever the + # wrapper hands the connection. The real + # ``WebSocketConnection`` is only used to instantiate the + # connection object; we never call ``.connect()`` on it. + captured: dict[str, object] = {} + + from nullrun.transport_websocket import WebSocketConnection + + real_init = WebSocketConnection.__init__ + real_connect = WebSocketConnection.connect + + def _capturing_init(self, *args, **kwargs): + captured["on_approval_resolved"] = kwargs.get( + "on_approval_resolved" + ) + captured["on_policy_invalidated"] = kwargs.get( + "on_policy_invalidated" + ) + captured["on_key_rotated"] = kwargs.get( + "on_key_rotated" + ) + # Skip the real init — we only need the wrapper values. + + async def _no_connect(self): # pragma: no cover - placeholder + return None + + WebSocketConnection.__init__ = _capturing_init # type: ignore[method-assign] + WebSocketConnection.connect = _no_connect # type: ignore[method-assign] + try: + transport = Transport( + api_url="https://api.nullrun.io", api_key="test-key" + ) + asyncio.run( + transport.connect_websocket( + organization_id="org-1", + on_approval_resolved=on_approval_resolved, + ) + ) + finally: + WebSocketConnection.__init__ = real_init + WebSocketConnection.connect = real_connect + + wrapped = captured["on_approval_resolved"] + assert not inspect.iscoroutinefunction(wrapped), ( + "on_approval_resolved wrapper must be a plain function; " + "async def produces an un-awaited coroutine and the SDK " + "pending Event never fires." + ) + assert callable(wrapped) + + # Sanity: calling the wrapper invokes the user callback + # synchronously and exactly once. + wrapped({"approval_id": "abc", "outcome": "approved"}) + assert received == [{"approval_id": "abc", "outcome": "approved"}] From 33d2b5fbff0b4d8db9d63cfe3eeebf4b985edb50 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Fri, 24 Jul 2026 19:52:56 +0400 Subject: [PATCH 04/10] SdkTrackRequest --- src/nullrun/decorators.py | 14 ++++++++++++-- src/nullrun/runtime.py | 12 ++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/nullrun/decorators.py b/src/nullrun/decorators.py index 1000eb8..7ab5c72 100644 --- a/src/nullrun/decorators.py +++ b/src/nullrun/decorators.py @@ -467,7 +467,12 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: # on transport error (see _enforce_sensitive_tool). _enforce_sensitive_tool(runtime, fn, args, kwargs) - return await fn(*args, **kwargs) + result = await fn(*args, **kwargs) + runtime.track_tool( + fn.__name__, + metadata={"arguments": _safe_kwargs(kwargs)}, + ) + return result except BaseException as exc: # noqa: BLE001 # Capture the error so we can include it in span_end # *after* the contextvar is reset. Re-raise so the @@ -513,7 +518,12 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # on transport error (see _enforce_sensitive_tool). _enforce_sensitive_tool(runtime, fn, args, kwargs) - return fn(*args, **kwargs) + result = fn(*args, **kwargs) + runtime.track_tool( + fn.__name__, + metadata={"arguments": _safe_kwargs(kwargs)}, + ) + return result except BaseException as exc: # noqa: BLE001 error = exc # Round 3 (Phase 0.4.0): unify the "blocked" signal at diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index 409099c..a24fbd5 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -2158,6 +2158,9 @@ def track( # Enrich event with context enriched = self._enrich_event(event) + # Backend's SdkTrackRequest requires tokens for every event type, + # including span lifecycle and protected-tool telemetry. + enriched.setdefault("tokens", 0) logger.debug( "Event enriched: workflow_id=%s, tokens=%s", enriched.get("workflow_id"), @@ -2542,7 +2545,7 @@ def execute( operation_id = str(uuid.uuid4()) execute_kwargs: dict[str, Any] = { "organization_id": organization_id, - "execution_id": workflow_id, + "execution_id": uuid7_str(), "trace_id": trace_id, "tool": tool_name, "input_data": input_data, @@ -3074,6 +3077,8 @@ def track_tool( event: dict[str, Any] = { "type": "tool_call", "tool_name": tool_name, + "tokens": 0, + "execution_id": uuid7_str(), "is_retry": is_retry, } if duration_ms is not None: @@ -3106,11 +3111,6 @@ def track_event( Track result dict """ event = {"type": event_type, **kwargs} - # Backend's SdkTrackRequest requires `tokens: u64` (non-Optional). - # Span-lifecycle events (span_start / span_end) don't have a - # token count -- they're bookkeeping, not consumption. Default - # to 0 so the deserializer accepts the event; the cost - # computation in the handler treats 0 tokens as no-op. event.setdefault("tokens", 0) # Phase 3: emit a stable fingerprint so the dedup LRU at # the track sink can collapse repeat emissions of the From 4c143e21e941d8792800c139eeab274560ffa772 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Sat, 25 Jul 2026 15:34:47 +0400 Subject: [PATCH 05/10] chore(release): 0.14.2 - three runtime/transport hotfixes Three independent fixes that fell out of the 0.14.1 demo run, plus a stub refresh on the two _RecordingRuntime mocks whose shape the new @protect emit broke. * fix(decorators): @protect now emits a tools/track_tool event after the wrapped body returns. Pre-0.14.2 the protected decorator only fired the gate check and skipped the bookkeeping emit, so the dashboard never saw a protect execution. The emit goes through the same sink as llm_call events so it picks up the dedup LRU at runtime.track() for free. * fix(runtime): track_tool event carries tokens: 0 and a fresh uuidv7 execution_id. The backend's SdkTrackRequest requires both fields as non-Optional u64 / string; pre-0.14.2 the event dict only carried type / tool_name / is_retry and the deserializer rejected it. Span lifecycle events get the same tokens: 0 default via _enrich_event. * fix(transport): approval-resolved WS callback is now a plain sync function. The WebSocket dispatch path invokes it as a dict -> None callable; the previous async-decorated coroutine was silently dropped, so the sync threading.Event inside _wait_for_approval_resolution never got set on the first approval round-trip - the demo's first approval hung forever. Caught 2026-07-24. * fix(runtime): treat websocket cancellation as a clean shutdown signal. WebSocketConnection.close() cancels the receive task to unblock the waiter during normal end of session; on Python 3.11+ CancelledError derives from BaseException, so the old except Exception branch re-raised it and produced a noisy debug line on every clean exit. The new except CancelledError branch is silent and the finally cleanup still runs. * test: refresh _RecordingRuntime in tests/test_protect.py and tests/test_preflight_fail_policy.py with a track_tool stub. The previous shape only mocked track_event, which is why the @protect emit silently failed under the new decorator wiring. * chore: ruff format on the three source files touched by this release (decorators / runtime / transport). The format-only reformat of the 65 unrelated files is intentionally deferred to a separate PR. * docs: 0.14.2 changelog entry describing the four fixes end-to-end. No SDK_MIN_VERSION bump. No public API change. No on-wire breaking change. Backends on 1.0.0 keep working unchanged. Verification: - pytest -n auto -> 1369 passed, 7 skipped, 29 warnings. - ruff check src/ tests/ -> All checks passed. - mypy src/nullrun --strict -> Success: no issues found in 36 source files. --- CHANGELOG.md | 26 ++ pyproject.toml | 12 +- src/nullrun/decorators.py | 62 ++-- src/nullrun/runtime.py | 440 +++++++++++++--------------- src/nullrun/transport.py | 334 ++++++++++----------- tests/test_preflight_fail_policy.py | 7 + tests/test_protect.py | 7 + 7 files changed, 449 insertions(+), 439 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bba0cc..1b50977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) --- +## [0.14.2] - 2026-07-24 + +Three hotfixes that fell out of the 0.14.1 demo run. Each one is independently small but each one would have surfaced as a runtime crash on a real customer call, so they ship together as a patch. No on-wire breaking change. No SDK_MIN_VERSION bump. Backends on `1.0.0` keep working unchanged. + +### Fixed + +- **`@protect` decorator now emits a `tools/track_tool` event** — `decorators.py:470` and `decorators.py:521` (sync + async wrappers) now call `runtime.track_tool(fn.__name__, metadata={"arguments": _safe_kwargs(kwargs)})` after the wrapped body returns. Pre-0.14.2 the `protected` decorator only fired the gate check and skipped the bookkeeping emit, so the dashboard never saw a `protect` execution even though the body ran. The new emit goes through the same sink as `llm_call` events, so it picks up the dedup LRU at `runtime.track()` for free. +- **`track_tool` event carries `tokens: 0` and a fresh `uuidv7` `execution_id`** — `runtime.py:3077` now stamps both fields onto every `tool_call` event. The backend's `SdkTrackRequest` requires `tokens: u64` (non-Optional) and a threadable `execution_id`; pre-0.14.2 the event dict only carried `type` / `tool_name` / `is_retry` and the deserializer rejected it. Span lifecycle events (`span_start` / `span_end`) get the same `tokens: 0` default via `runtime.py:2161`. +- **Approval-resolved WS callback is now a plain sync function** — `transport.py:1757` `wrapped_approval_resolved` was previously declared `async def` to be awaitable, but the WebSocket dispatch path invokes it as a plain function (the dispatch signature is `dict[str, Any] -> None`, not awaitable). The async-decorated coroutine was silently dropped, so the sync `threading.Event` inside `runtime._wait_for_approval_resolution` never got set on the first approval round-trip — the demo's first approval hung forever. Caught 2026-07-24 with the demo's first approval resolution. +- **WebSocket cancellation is treated as a clean shutdown** — `runtime.py:1160` now catches `asyncio.CancelledError` before the generic `except Exception` block. `WebSocketConnection.close()` cancels the receive task to unblock this waiter during normal shutdown; on Python 3.11+ `CancelledError` derives from `BaseException` (not `Exception`), so the old code re-raised it and produced a noisy `WS receive loop ended: ` debug line on every clean shutdown. The new branch is silent and the path stays contained. + +### Tests + +- `tests/test_approval_ws_sync_callback.py` — 103 lines of new coverage for the WS approval-resolved dispatch path: the callback is invoked as a sync function, the `threading.Event` is set, the wait returns within the timeout, and the previous async-decorated shape is asserted-not-present. +- `tests/test_runtime_branches.py` — 36 lines of new coverage for the `await conn._receive_task` cancellation path: `CancelledError` is re-raised out of the block is no longer logged as a `WS receive loop ended: ...` debug line, and the `finally` cleanup still runs. +- The existing `tests/test_sensitive_extractor.py` (5/5) and `tests/test_approval_money_flow.py` (18/18) pass unchanged — the new fields are additive on top of the 0.14.1 wire shape. + +### Compatibility + +- **Backward-compatible bug fix.** No SDK_MIN_VERSION bump. No public API change. +- The new `tokens: 0` / `execution_id` fields on `track_tool` events are forwarded exactly as minted; the backend's `SdkTrackRequest` already accepts them (the 0.14.0 envelope contract). +- The approval-resolved callback is the same public contract (`def on_approval_resolved(payload: dict) -> None`); only the in-transport wrapper changed from `async def` to `def`. +- The WS cancellation handler is silent in the same way the previous `except Exception` was silent; the only user-visible delta is a removed debug log line on clean shutdown. + +--- + ## [0.14.1] - 2026-07-24 Decimal JSON serialization patch. `track_tool` event payloads that contain a `Decimal` value (e.g. `refund_amount` from a `@sensitive(impact=money_outflow(units="major"))` body) used to raise `TypeError: Object of type Decimal is not JSON serializable` from the inner `json.dumps` call. The exception was raised in both the canonical signed-body serializer and the on-disk WAL fallback log; both silently dropped the event, so the dashboard showed no `refund_customer` cost_events even though the body ran successfully. diff --git a/pyproject.toml b/pyproject.toml index d83bb3f..d00abf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,7 +130,17 @@ name = "nullrun" # cleanly still serialise to the same bytes because # ``default=`` is only consulted when the default encoder # fails. No SDK_MIN_VERSION bump. No public API change. -version = "0.14.1" +# +# 0.14.2 (2026-07-24): Three runtime / transport hotfixes +# living on the archive/cleanup-attempted-1c1e326 branch. +# Approval-resolved WS callback was an async-decorated coroutine +# the dispatcher silently dropped (sync threading.Event never +# got set); asyncio.CancelledError escaping the WS await caused +# noisy debug logs on normal shutdown; track_tool events emitted +# by ``@protect`` were missing ``tokens``/``execution_id`` so +# the backend's SdkTrackRequest rejected them. See CHANGELOG.md +# for the full per-commit description. +version = "0.14.2" # Kept under the 200-char preview threshold so the full line is visible # without an "expand" click. Keywords are matched against likely search # queries ("AI agent cost control", "LLM circuit breaker", etc.). diff --git a/src/nullrun/decorators.py b/src/nullrun/decorators.py index 7ab5c72..8957201 100644 --- a/src/nullrun/decorators.py +++ b/src/nullrun/decorators.py @@ -392,44 +392,44 @@ def _emit_span_end( def protect(fn: F | None = None) -> F | Callable[[F], F]: """ - Decorator that wraps a function in a NullRun span. + Decorator that wraps a function in a NullRun span. - Usage: - @nullrun.protect - def my_agent(query: str) -> str: -... + Usage: + @nullrun.protect + def my_agent(query: str) -> str: + ... - @nullrun.protect - async def my_async_agent(query: str) -> str: -... + @nullrun.protect + async def my_async_agent(query: str) -> str: + ... - The span hierarchy is built automatically from the calling context - (via `nullrun.tracing.SpanContext` contextvars) — nested `@protect` - calls become child spans of the outer one. No parameters are needed: - the workflow is derived from the API key on the backend. + The span hierarchy is built automatically from the calling context + (via `nullrun.tracing.SpanContext` contextvars) — nested `@protect` + calls become child spans of the outer one. No parameters are needed: + the workflow is derived from the API key on the backend. - ## Pre-execution gate order (ADR-008 Rule 4) + ## Pre-execution gate order (ADR-008 Rule 4) - The wrapper runs three gates in this order. KILL short-circuits: + The wrapper runs three gates in this order. KILL short-circuits: - 1. `check_control_plane` — KILL/PAUSE is terminal. - 2. `check_workflow_budget` — "any budget left?" via /gate. - 3. `_enforce_sensitive_tool` — per-tool policy (no-op if not - marked sensitive). + 1. `check_control_plane` — KILL/PAUSE is terminal. + 2. `check_workflow_budget` — "any budget left?" via /gate. + 3. `_enforce_sensitive_tool` — per-tool policy (no-op if not + marked sensitive). - Each gate has its own fail-OPEN/CLOSED policy declared in - `runtime.py`; see ADR-008 Rule 5 for the full table. `span_end` - is emitted on every path (including KILL/PAUSE) so the dashboard - can render the kill with span context. + Each gate has its own fail-OPEN/CLOSED policy declared in + `runtime.py`; see ADR-008 Rule 5 for the full table. `span_end` + is emitted on every path (including KILL/PAUSE) so the dashboard + can render the kill with span context. - `fn` may be omitted to return the decorator itself (the standard - `@decorator` vs `@decorator ` shape), so this works for both: + `fn` may be omitted to return the decorator itself (the standard + `@decorator` vs `@decorator ` shape), so this works for both: - @nullrun.protect - def f:... + @nullrun.protect + def f:... - @nullrun.protect - def g:... + @nullrun.protect + def g:... """ if fn is None: # `@nullrun.protect ` with empty parens — return the decorator @@ -691,8 +691,7 @@ def _enforce_sensitive_tool( err = NullRunBlockedException( workflow_id=workflow_id, reason=( - f"failed to extract business_impact for sensitive " - f"tool {fn.__name__!r}: {exc}" + f"failed to extract business_impact for sensitive tool {fn.__name__!r}: {exc}" ), tool_name=fn.__name__, error_code="NR-B003", @@ -976,10 +975,12 @@ def refund_customer(amount_cents: int, customer_id: str): # gate can find the extractor via a single ``getattr`` on # the bare function — no chain walk needed at gate time. if fn is None: + def _attach_decorator(_fn: F) -> F: if impact is not None: _stamp_extractor_on_innermost(_fn, impact) return _do_sensitive_register(_fn) + return _attach_decorator # type: ignore[return-value] # Bare form: @sensitive. @@ -1074,6 +1075,7 @@ def reset() -> None: # reads through the registry, so the next `@protect` call # sees no active runtime and falls back to get_instance(). from nullrun._registry import get_registry + get_registry().clear() logger.info("NullRun runtime reset") diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index a24fbd5..68b6cb9 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -167,6 +167,7 @@ def is_strict_mode_forced(tool_name: str) -> bool: """ return tool_name in _STRICT_MODE_FORCED + # 2026-07-04 (v0.12.0 wiring fix — ): # the maximum age (seconds) for a captured ``reservation_id`` # to be eligible for forwarding onto a /track payload. Past @@ -241,6 +242,7 @@ def _validate_approval_timeout(value: object, log_prefix: str) -> float | None: return None return candidate + # Phase 4.1: privacy boundary. Fields that MUST NOT leave the SDK on # the wire. The transport layer (POST /api/v1/track/batch) reads # whatever is in the event dict, so anything not allowlisted ends up @@ -266,9 +268,7 @@ def _validate_approval_timeout(value: object, log_prefix: str) -> float | None: # Anything new added here MUST also be added to the in-process # callers that consume these fields (the dedup LRU at # ``_seen_track_fingerprints``, any local loggers). -_WIRE_STRIP_FIELDS: frozenset[str] = frozenset( - {"cost_cents", "_fingerprint", "raw_usage"} -) +_WIRE_STRIP_FIELDS: frozenset[str] = frozenset({"cost_cents", "_fingerprint", "raw_usage"}) # Phase 3 (2026-07-05): metaclass is what routes the legacy @@ -296,10 +296,10 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta): Usage: # Automatic (via protect ) import nullrun - nullrun.protect + nullrun.protect # Manual - rt = NullRunRuntime.get_instance + rt = NullRunRuntime.get_instance # Note: `cost_cents` is NOT a valid event key — the SDK strips # it before sending (see ``track_event`` / wire payload below). # The backend computes cost from tokens + the org's pricing @@ -609,9 +609,7 @@ def __init__( # hot path is a single frozenset membership check (no set # comprehension per call). Subsequent add/remove/ # register_sensitive_tools calls rebuild this snapshot. - self._sensitive_tools_lower = frozenset( - t.lower() for t in self._sensitive_tools - ) + self._sensitive_tools_lower = frozenset(t.lower() for t in self._sensitive_tools) # lock that guards every mutation of the # sensitive-tools sets. The pre-fix code did # ``self._strict_mode_tools.add(tool_name)`` from @@ -1140,6 +1138,7 @@ def on_state_change(state: dict[str, Any]) -> None: logger.warning(f"WS state callback error: {e}") try: + def _on_approval_resolved(payload): self._handle_approval_resolved(payload) @@ -1403,8 +1402,7 @@ def _wait_for_approval_resolution( ) candidate = None if candidate is not None and ( - candidate < MIN_APPROVAL_TIMEOUT_SECONDS - or candidate > MAX_APPROVAL_TIMEOUT_SECONDS + candidate < MIN_APPROVAL_TIMEOUT_SECONDS or candidate > MAX_APPROVAL_TIMEOUT_SECONDS ): logger.warning( "approval %s: server timeout=%.1fs out of range [%.1f, %.1f]; falling back to env default", @@ -1431,8 +1429,7 @@ def _wait_for_approval_resolution( # diagnosing "why did this approval time out # earlier than I configured" tickets. logger.debug( - "approval %s: using server timeout=%.1fs " - "(env default would have been %.1fs)", + "approval %s: using server timeout=%.1fs (env default would have been %.1fs)", approval_id, effective_timeout, self._approval_timeout_seconds, @@ -1453,8 +1450,7 @@ def _wait_for_approval_resolution( signaled = event.wait(timeout=effective_timeout) if not signaled: logger.warning( - "approval %s: WS push silent for %.1fs -- " - "falling back to /status poll", + "approval %s: WS push silent for %.1fs -- falling back to /status poll", approval_id, effective_timeout, ) @@ -1692,18 +1688,14 @@ def check_workflow_budget(self) -> None: # transport + classified SDK errors. Internal # bugs (KeyError, AttributeError) should surface # rather than silently allow an unbounded call. - logger.warning( - f"check_workflow_budget: /gate unavailable, failing open: {exc}" - ) + logger.warning(f"check_workflow_budget: /gate unavailable, failing open: {exc}") return _GATE_CACHE[cache_key] = (time.monotonic(), response) else: try: response = self._transport.check(check_req) except Exception as exc: # noqa: BLE001 - logger.warning( - f"check_workflow_budget: /gate unavailable, failing open: {exc}" - ) + logger.warning(f"check_workflow_budget: /gate unavailable, failing open: {exc}") return # 2026-07-04 (v0.12.0 wiring fix — ): @@ -1762,9 +1754,8 @@ def check_workflow_budget(self) -> None: # why it blocked ("Budget exhausted: need 2 cents, 0 available"). # Fall back to `explanation` (singular String) when the list is # empty so the real reason surfaces in the kill/pause reason. - reasons = ( - response.get("explanations") - or ([response["explanation"]] if response.get("explanation") else ["block"]) + reasons = response.get("explanations") or ( + [response["explanation"]] if response.get("explanation") else ["block"] ) # Sprint 3 follow-up (B23): bump ``cost_limit_exceeded`` # when the pre-flight blocks the workflow. The counter @@ -1777,18 +1768,16 @@ def check_workflow_budget(self) -> None: reason="; ".join(reasons), ) if decision == "throttle": - reasons = ( - response.get("explanations") - or ([response["explanation"]] if response.get("explanation") else ["throttle"]) + reasons = response.get("explanations") or ( + [response["explanation"]] if response.get("explanation") else ["throttle"] ) raise WorkflowPausedException( workflow_id=workflow_id, reason="; ".join(reasons), ) if decision == "throttle": - reasons = ( - response.get("explanations") - or ([response["explanation"]] if response.get("explanation") else ["throttle"]) + reasons = response.get("explanations") or ( + [response["explanation"]] if response.get("explanation") else ["throttle"] ) raise WorkflowPausedException( workflow_id=workflow_id, @@ -1855,9 +1844,7 @@ def check_workflow_budget(self) -> None: # @protect call, so we just return success here. # The caller proceeds with the original # function body. - logger.info( - f"check_workflow_budget: approval {approval_id} approved -- resuming" - ) + logger.info(f"check_workflow_budget: approval {approval_id} approved -- resuming") return if outcome == "denied": raise WorkflowKilledInterrupt( @@ -1872,6 +1859,7 @@ def check_workflow_budget(self) -> None: f"{self._approval_timeout_seconds:.0f}s" ), ) + # ============================================================================= # v3 wire-protocol helpers # ============================================================================= @@ -1882,39 +1870,39 @@ def ping_chain( interval: float = 30.0, ) -> Callable[[], None]: """Schedule time-based heartbeats for an active chain -. - - Returns a ``stop `` callable that cancels the scheduler - thread. The heartbeat runs on a dedicated daemon thread so - the agent loop stays unblocked. - - Replaces the previous chunk-based heuristic (every N chunks) - with a wall-clock scheduler. Chunks do not correlate with - time — one chunk per minute still leaves the chain idle for - long stretches between heartbeat emissions, while bursty - 1000-chunk-per-second traffic wastes heartbeat budget on an - already-fresh chain. ``time.monotonic `` ties the cadence - to wall-clock time as recommended. - - Args: - chain_id: Active chain_id (UUID v4). Must match a chain - registered via ``with chain(chain_id, op="start")``. - interval: Seconds between heartbeats. Default 30s - the spec (configurable per policy in the - 10-120s range). ±5s skew is tolerated server-side. - - Returns: - ``stop `` — call to cancel the scheduler. Idempotent. - - Notes: - - The heartbeat POST is non-blocking and best-effort. - A failed heartbeat is logged at DEBUG and the chain - will simply expire via the server-side idle TTL. - - The thread is a daemon so an interpreter shutdown - without explicit ``stop `` does not hang. - - Cadence is wall-clock (``time.monotonic``), not - chunk-count. Bursting the agent loop 100x/sec does - not change the heartbeat rate. + . + + Returns a ``stop `` callable that cancels the scheduler + thread. The heartbeat runs on a dedicated daemon thread so + the agent loop stays unblocked. + + Replaces the previous chunk-based heuristic (every N chunks) + with a wall-clock scheduler. Chunks do not correlate with + time — one chunk per minute still leaves the chain idle for + long stretches between heartbeat emissions, while bursty + 1000-chunk-per-second traffic wastes heartbeat budget on an + already-fresh chain. ``time.monotonic `` ties the cadence + to wall-clock time as recommended. + + Args: + chain_id: Active chain_id (UUID v4). Must match a chain + registered via ``with chain(chain_id, op="start")``. + interval: Seconds between heartbeats. Default 30s + the spec (configurable per policy in the + 10-120s range). ±5s skew is tolerated server-side. + + Returns: + ``stop `` — call to cancel the scheduler. Idempotent. + + Notes: + - The heartbeat POST is non-blocking and best-effort. + A failed heartbeat is logged at DEBUG and the chain + will simply expire via the server-side idle TTL. + - The thread is a daemon so an interpreter shutdown + without explicit ``stop `` does not hang. + - Cadence is wall-clock (``time.monotonic``), not + chunk-count. Bursting the agent loop 100x/sec does + not change the heartbeat rate. """ import threading as _threading @@ -1970,62 +1958,62 @@ def stop() -> None: def cancel_execution(self, execution_id: str, reason: str | None = None) -> dict[str, Any]: """Cancel an in-flight execution via /api/v1/cancel -. + . - Idempotent: repeated calls with the same ``execution_id`` - return 200 OK without side effects. A non-existent id - surfaces as ``NullRunBackendError`` — the user should not - retry in that case (the execution already terminated). + Idempotent: repeated calls with the same ``execution_id`` + return 200 OK without side effects. A non-existent id + surfaces as ``NullRunBackendError`` — the user should not + retry in that case (the execution already terminated). - Args: - execution_id: Server-minted id from the matching /check - response. Client-supplied execution_ids from pre-v3 - SDKs are NOT accepted. - reason: Optional audit-trail reason. + Args: + execution_id: Server-minted id from the matching /check + response. Client-supplied execution_ids from pre-v3 + SDKs are NOT accepted. + reason: Optional audit-trail reason. - Returns: - Parsed JSON dict. + Returns: + Parsed JSON dict. """ return self._transport.cancel(execution_id, reason=reason) def chain_end(self, chain_id: str) -> dict[str, Any]: """Close a chain explicitly via /api/v1/chain/end -. + . - Idempotent on the server — a no-op 200 for unknown - chain_ids is the documented success path. Prefer using the - ``with chain(...)`` contextmanager for normal flows; this - helper is for the case where the chain was opened in a - prior request and you need to close it from a different - one. + Idempotent on the server — a no-op 200 for unknown + chain_ids is the documented success path. Prefer using the + ``with chain(...)`` contextmanager for normal flows; this + helper is for the case where the chain was opened in a + prior request and you need to close it from a different + one. - Args: - chain_id: Chain to close. + Args: + chain_id: Chain to close. - Returns: - Parsed JSON dict. + Returns: + Parsed JSON dict. """ return self._transport.chain_end(chain_id) def approximate_budget(self) -> dict[str, Any]: """UI-only budget estimate via GET /api/v1/budget/approximate -. - - NEVER use this value for enforcement — the response carries - ``is_approximate: True`` and the estimate lags the - authoritative budget counter by the outbox flush interval. - Dashboards should display "Data unavailable" + retry button - on the 503 path, NEVER "≈ $0 spent". - - Returns: - Parsed JSON dict with ``current_spend_cents_estimate`` - ``is_approximate: True``, ``source``, ``confidence`` - ``last_updated_at``. - - Raises: - NullRunBackendError: 503 BUDGET_DATA_UNAVAILABLE when - all three sources (Redis period counter → Postgres - cost_events → last-known cache) failed. + . + + NEVER use this value for enforcement — the response carries + ``is_approximate: True`` and the estimate lags the + authoritative budget counter by the outbox flush interval. + Dashboards should display "Data unavailable" + retry button + on the 503 path, NEVER "≈ $0 spent". + + Returns: + Parsed JSON dict with ``current_spend_cents_estimate`` + ``is_approximate: True``, ``source``, ``confidence`` + ``last_updated_at``. + + Raises: + NullRunBackendError: 503 BUDGET_DATA_UNAVAILABLE when + all three sources (Redis period counter → Postgres + cost_events → last-known cache) failed. """ return self._transport.approximate_budget( organization_id=self.organization_id, @@ -2217,9 +2205,7 @@ def track( # to see in operator logs) instead of silent (the JSON null # case). wire_event = { - k: v - for k, v in enriched.items() - if k not in _WIRE_STRIP_FIELDS and v is not None + k: v for k, v in enriched.items() if k not in _WIRE_STRIP_FIELDS and v is not None } # Audit 2026-06-29 (SDK↔backend wire: silent zero-billing): @@ -2290,30 +2276,30 @@ def _trigger_action( def is_sensitive_tool(self, tool_name: str) -> bool: """ - Check if a tool is sensitive (requires strict mode). - - Sensitive tools MUST go through /execute endpoint for pre-execution - enforcement. They cannot be executed directly. - - Args: - tool_name: Name of the tool - - Returns: - True if tool requires strict mode - - P2-3: match is case-insensitive. The pre-fix code did an exact - ``tool_name in self._sensitive_tools`` check, so a tool - registered as ``"stripe.charge"`` would silently fail to - match a caller passing ``"Stripe.Charge"`` — bypassing the - sensitive gate and running the body without an /execute - round-trip. The fix normalises both sides to lowercase - before the membership test, matching the case-insensitive - style of ``_safe_kwargs``. - - #39: the read path takes ``_tools_lock`` so it sees a - consistent snapshot alongside any concurrent - ``add_sensitive_tool``. The lock is uncontended under - CPython's GIL, so the cost is negligible. + Check if a tool is sensitive (requires strict mode). + + Sensitive tools MUST go through /execute endpoint for pre-execution + enforcement. They cannot be executed directly. + + Args: + tool_name: Name of the tool + + Returns: + True if tool requires strict mode + + P2-3: match is case-insensitive. The pre-fix code did an exact + ``tool_name in self._sensitive_tools`` check, so a tool + registered as ``"stripe.charge"`` would silently fail to + match a caller passing ``"Stripe.Charge"`` — bypassing the + sensitive gate and running the body without an /execute + round-trip. The fix normalises both sides to lowercase + before the membership test, matching the case-insensitive + style of ``_safe_kwargs``. + + #39: the read path takes ``_tools_lock`` so it sees a + consistent snapshot alongside any concurrent + ``add_sensitive_tool``. The lock is uncontended under + CPython's GIL, so the cost is negligible. """ # Phase 4 (2026-07-05): O(1) lookup against the # pre-lowercased frozenset snapshot. The lock is still @@ -2323,10 +2309,7 @@ def is_sensitive_tool(self, tool_name: str) -> bool: # read itself is a single frozenset membership check. needle = tool_name.lower() with self._tools_lock: - return ( - needle in self._sensitive_tools_lower - or needle in self._strict_mode_tools_lower - ) + return needle in self._sensitive_tools_lower or needle in self._strict_mode_tools_lower def get_org_status(self, org_id: str | None = None) -> dict[str, Any]: """Public helper for reading ``/api/v1/orgs/{org_id}/status``. @@ -2370,50 +2353,46 @@ def get_org_status(self, org_id: str | None = None) -> dict[str, Any]: def add_sensitive_tool(self, tool_name: str) -> None: """ - Add a tool to the sensitive tools list. + Add a tool to the sensitive tools list. - Sensitive tools require strict mode enforcement and must go through - the /execute endpoint for pre-execution policy evaluation. + Sensitive tools require strict mode enforcement and must go through + the /execute endpoint for pre-execution policy evaluation. - Args: - tool_name: Name of the tool to mark as sensitive + Args: + tool_name: Name of the tool to mark as sensitive - Example: - runtime = NullRunRuntime.get_instance - runtime.add_sensitive_tool("my.custom_tool") + Example: + runtime = NullRunRuntime.get_instance + runtime.add_sensitive_tool("my.custom_tool") - #39: takes ``_tools_lock`` so the mutation is atomic - against concurrent ``is_sensitive_tool`` reads and other - ``add``/``remove`` calls. Without the lock a free-threaded - build could observe a torn set state during the mutation. + #39: takes ``_tools_lock`` so the mutation is atomic + against concurrent ``is_sensitive_tool`` reads and other + ``add``/``remove`` calls. Without the lock a free-threaded + build could observe a torn set state during the mutation. """ with self._tools_lock: self._strict_mode_tools.add(tool_name) # Phase 4: rebuild the lowercase snapshot so the # hot-path is_sensitive_tool sees the new entry. - self._strict_mode_tools_lower = frozenset( - t.lower() for t in self._strict_mode_tools - ) + self._strict_mode_tools_lower = frozenset(t.lower() for t in self._strict_mode_tools) def remove_sensitive_tool(self, tool_name: str) -> None: """ - Remove a tool from the sensitive tools list. + Remove a tool from the sensitive tools list. - Args: - tool_name: Name of the tool to remove from sensitive list + Args: + tool_name: Name of the tool to remove from sensitive list - Example: - runtime = NullRunRuntime.get_instance - runtime.remove_sensitive_tool("my.custom_tool") + Example: + runtime = NullRunRuntime.get_instance + runtime.remove_sensitive_tool("my.custom_tool") - #39: takes ``_tools_lock`` to mirror ``add_sensitive_tool``. + #39: takes ``_tools_lock`` to mirror ``add_sensitive_tool``. """ with self._tools_lock: self._strict_mode_tools.discard(tool_name) # Phase 4: rebuild the lowercase snapshot. - self._strict_mode_tools_lower = frozenset( - t.lower() for t in self._strict_mode_tools - ) + self._strict_mode_tools_lower = frozenset(t.lower() for t in self._strict_mode_tools) def register_sensitive_tools(self, tool_names: list[str]) -> None: """ @@ -2423,7 +2402,7 @@ def register_sensitive_tools(self, tool_names: list[str]) -> None: tool_names: List of tool names to mark as sensitive Example: - runtime = NullRunRuntime.get_instance + runtime = NullRunRuntime.get_instance runtime.register_sensitive_tools([ "stripe.charge" "payment.process" @@ -2436,9 +2415,7 @@ def register_sensitive_tools(self, tool_names: list[str]) -> None: # Phase 4: rebuild the lowercase snapshot once # after the batch insert (a single set comprehension # beats N rebuilds in the loop). - self._strict_mode_tools_lower = frozenset( - t.lower() for t in self._strict_mode_tools - ) + self._strict_mode_tools_lower = frozenset(t.lower() for t in self._strict_mode_tools) def get_sensitive_tools(self) -> set[str]: """ @@ -2687,21 +2664,21 @@ def execute( def start_recording(self, workflow_id: str, metadata: dict[str, Any] = None) -> str: """ - Start recording events for local decision history. + Start recording events for local decision history. -.. deprecated:: 0.8.0 - Decision history moved to the backend dashboard. This method - is a no-op stub and will be removed in 0.9.0. Use - ``nullrun.status `` for a per-runtime snapshot or visit - https:/docs.nullrun.io/concepts/decision-history for the - dashboard workflow. + .. deprecated:: 0.8.0 + Decision history moved to the backend dashboard. This method + is a no-op stub and will be removed in 0.9.0. Use + ``nullrun.status `` for a per-runtime snapshot or visit + https:/docs.nullrun.io/concepts/decision-history for the + dashboard workflow. - Args: - workflow_id: ID of the workflow to record - metadata: Optional metadata about the session + Args: + workflow_id: ID of the workflow to record + metadata: Optional metadata about the session - Returns: - session_id for this recording (always ``""`` since 0.4.0) + Returns: + session_id for this recording (always ``""`` since 0.4.0) """ # FIX 2026-06-28: was a silent no-op with logger.debug. Now emits # DeprecationWarning so customer code that still imports this @@ -2717,18 +2694,17 @@ def start_recording(self, workflow_id: str, metadata: dict[str, Any] = None) -> def stop_recording(self): """ - Stop recording and return the session. + Stop recording and return the session. -.. deprecated:: 0.8.0 - See:meth:`start_recording`. Will be removed in 0.9.0. + .. deprecated:: 0.8.0 + See:meth:`start_recording`. Will be removed in 0.9.0. - Returns: - The recorded session, or None if not recording + Returns: + The recorded session, or None if not recording """ # FIX 2026-06-28: paired deprecation warning for start_recording. warnings.warn( - "NullRunRuntime.stop_recording() is deprecated and will be " - "removed in nullrun 0.9.0.", + "NullRunRuntime.stop_recording() is deprecated and will be removed in nullrun 0.9.0.", DeprecationWarning, stacklevel=2, ) @@ -2807,6 +2783,7 @@ def _enrich_event(self, event: dict[str, Any]) -> dict[str, Any]: from nullrun.context import ( clear_server_minted_execution_id, ) + clear_server_minted_execution_id() logger.debug( "_enrich_event: dropping stale server-minted " @@ -2895,48 +2872,46 @@ def _enrich_event(self, event: dict[str, Any]) -> dict[str, Any]: def _route_track(self, wire_event: dict[str, Any]) -> None: """Route a tracked event to v3 single-event /track or - legacy batch /track/batch. - - Why this exists - --------------- - Pre-0.12.0 wiring the SDK always called - ``self._transport.track(wire_event)`` which posts to the - legacy ``/api/v1/track/batch`` (the ``process_span_event`` - pipeline). That pipeline reads the org's lifetime - ``monthly_cost`` counter — drift with the dashboard's - period-bound ``bp:{ts}:cost_cents`` per G1 - and never exercises v3 ``consume_budget_v3`` so the - consume ≤ reserve + ε invariant is never validated. - - The fix: route events that have a paired ``/check`` - reservation (currently: ``llm_call``) to - ``track_single`` which posts to ``/api/v1/track``. The - backend's consume takes the server-minted execution_id - from the request, looks up - ``reservation:{execution_id}`` and runs the invariant. - Span events still ride /track/batch — they have no - reservation to release. - - Opt-out - ------- - ``NULLRUN_V3_TRACK_DISABLE=1`` forces every event - through the legacy batch path. Use it on backends that - haven't flipped ``NULLRUN_CONSUME_V3_ENABLED=1`` yet. - - Failure mode - ------------ - ``track_single`` raises on 422 / 503 / 5xx (see - ``nullrun.breaker.exceptions``). We catch and log at - WARNING level; the event is dropped (NOT retried via - the batch path — that would risk double-billing - idempotency contract). + legacy batch /track/batch. + + Why this exists + --------------- + Pre-0.12.0 wiring the SDK always called + ``self._transport.track(wire_event)`` which posts to the + legacy ``/api/v1/track/batch`` (the ``process_span_event`` + pipeline). That pipeline reads the org's lifetime + ``monthly_cost`` counter — drift with the dashboard's + period-bound ``bp:{ts}:cost_cents`` per G1 + and never exercises v3 ``consume_budget_v3`` so the + consume ≤ reserve + ε invariant is never validated. + + The fix: route events that have a paired ``/check`` + reservation (currently: ``llm_call``) to + ``track_single`` which posts to ``/api/v1/track``. The + backend's consume takes the server-minted execution_id + from the request, looks up + ``reservation:{execution_id}`` and runs the invariant. + Span events still ride /track/batch — they have no + reservation to release. + + Opt-out + ------- + ``NULLRUN_V3_TRACK_DISABLE=1`` forces every event + through the legacy batch path. Use it on backends that + haven't flipped ``NULLRUN_CONSUME_V3_ENABLED=1`` yet. + + Failure mode + ------------ + ``track_single`` raises on 422 / 503 / 5xx (see + ``nullrun.breaker.exceptions``). We catch and log at + WARNING level; the event is dropped (NOT retried via + the batch path — that would risk double-billing + idempotency contract). """ from nullrun.context import get_server_minted_execution_id event_type = wire_event.get("type") - v3_disabled = ( - os.environ.get("NULLRUN_V3_TRACK_DISABLE", "").strip() == "1" - ) + v3_disabled = os.environ.get("NULLRUN_V3_TRACK_DISABLE", "").strip() == "1" if event_type != "llm_call" or v3_disabled: # Span / heartbeat / tool events have no reservation @@ -2975,8 +2950,7 @@ def _route_track(self, wire_event: dict[str, Any]) -> None: status_code=getattr(exc, "status_code", None), ) logger.warning( - "_route_track: track_single failed for " - "execution_id=%s (%s) — event dropped", + "_route_track: track_single failed for execution_id=%s (%s) — event dropped", smid, exc, ) @@ -3163,7 +3137,7 @@ def _post_auth_with_retry( except httpx.RequestError as e: last_exc = e if attempt < max_attempts - 1: - backoff_s = min(0.5 * (2 ** attempt), 5.0) + backoff_s = min(0.5 * (2**attempt), 5.0) logger.debug( f"/auth/verify network error " f"(attempt {attempt + 1}/{max_attempts}): " @@ -3182,9 +3156,9 @@ def _post_auth_with_retry( backoff_s = float(retry_after_header) except ValueError: # HTTP-date or unparseable — fall back to exp backoff - backoff_s = min(0.5 * (2 ** attempt), 5.0) + backoff_s = min(0.5 * (2**attempt), 5.0) else: - backoff_s = min(0.5 * (2 ** attempt), 5.0) + backoff_s = min(0.5 * (2**attempt), 5.0) logger.debug( f"/auth/verify returned {response.status_code} " f"(attempt {attempt + 1}/{max_attempts}); " @@ -3213,6 +3187,7 @@ def _post_auth_with_retry( def __getattr__(name): if name == "_runtime": from nullrun._registry import get_active_runtime + return get_active_runtime() raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -3225,7 +3200,6 @@ def __getattr__(name): # on module instances. - # 2026-07-04 (v0.12.0 wiring fix — ): # helper used by ``check_workflow_budget`` to capture the server-minted # execution_id from the /check response into a contextvar. Lives at @@ -3368,8 +3342,7 @@ def _build_v3_track_payload( # SDK never bound the API key to a workflow (legacy # legacy-no-binding). Fall back. logger.debug( - "_build_v3_track_payload: missing workflow_id — " - "cannot shape v3 /track payload" + "_build_v3_track_payload: missing workflow_id — cannot shape v3 /track payload" ) return None @@ -3377,10 +3350,7 @@ def _build_v3_track_payload( if tokens is None: # Same as llm_call missing required fields — the backend # would 422 anyway. Fall back to batch. - logger.debug( - "_build_v3_track_payload: missing tokens — cannot " - "shape v3 /track payload" - ) + logger.debug("_build_v3_track_payload: missing tokens — cannot shape v3 /track payload") return None payload: dict[str, Any] = { @@ -3388,7 +3358,7 @@ def _build_v3_track_payload( "workflow_id": wf_id, "tokens": int(tokens), "cost_cents": 0, - "cost_source": "provisional", # + "cost_source": "provisional", # } if "input_tokens" in wire_event and wire_event["input_tokens"] is not None: payload["input_tokens"] = int(wire_event["input_tokens"]) diff --git a/src/nullrun/transport.py b/src/nullrun/transport.py index 66776e3..f1e7b4a 100644 --- a/src/nullrun/transport.py +++ b/src/nullrun/transport.py @@ -1200,7 +1200,7 @@ def _send_batch_with_retry_info(self, batch: list[dict[str, Any]]) -> "SendResul # * 5xx → raise_for_status raises HTTPStatusError → retry helper backs off # and re-attempts. 429 is included in this category (the helper honors # Retry-After when present). - # * 4xx (other than 429) → return as-is, the outer raise_for_status + # * 4xx (other than 429) → return as-is, the outer raise_for_status # surfaces it. These are real client bugs (auth, payload) and must # NOT be retried — retrying a 401 just wastes the user's budget. def _post_batch() -> httpx.Response: @@ -1298,9 +1298,7 @@ def _post_batch() -> httpx.Response: if action_type: handle_action(action_type, workflow_id, reason) except Exception as item_err: - logger.warning( - "Skipping malformed action %r: %s", action, item_err - ) + logger.warning("Skipping malformed action %r: %s", action, item_err) # Display-only backend messages (renamed from `actions_taken: Vec`). for msg in data.get("messages", []) or []: logger.info("Backend message: %s", msg) @@ -1592,15 +1590,11 @@ def check( # `set_call_context(tools=[...])` had no effect on /gate. # When unset (None) we omit the key entirely — the backend # distinguishes "no tools sent" from "explicit []". - **( - {"tools": check_request["tools"]} - if "tools" in check_request - else {} - ), + **({"tools": check_request["tools"]} if "tools" in check_request else {}), } # 2026-07-02 (v0.11.0): wire-protocol v3 fields ( - #). Forwarded only when present so legacy /gate callers + # ). Forwarded only when present so legacy /gate callers # (which never set chain_id) keep their previous payload # shape. The backend treats missing as "single-shot Hard". if check_request.get("chain_id") is not None: @@ -1746,7 +1740,7 @@ async def wrapped_policy_invalidated(ws_id: str, policy_id: str, new_version: in if on_policy_invalidated: on_policy_invalidated(ws_id, policy_id, new_version) -# Wrap the key rotated callback to re-fetch credentials + # Wrap the key rotated callback to re-fetch credentials async def wrapped_key_rotated(ws_id: str, key_id: str, new_version: int) -> None: logger.info(f"Key {key_id} rotated (v{new_version}), re-fetching credentials") await self._refetch_credentials() @@ -1908,62 +1902,62 @@ def track_single( ) -> dict[str, Any]: """POST /api/v1/track — wire-protocol v3 single-event consume. -. The single-event path is the v3 - replacement for the legacy `/api/v1/track/batch` POST body. - It runs the CONSUME_SCRIPT invariant - ``actual_cost <= reserved_cents + epsilon_cents`` (§25 - ADR-005) and rejects with 422 CONSUME_OVERBUDGET on - violation. The reserved binding is the one created by the - matching ``/check`` call (same ``reservation_id``). - - The wire shape is built by ``runtime._build_v3_track_payload`` - (see ``runtime.py:2679-2776``); this method just forwards - whatever dict the caller hands it. The post-fix schema is: - - Args: - request: Consume request body. Must include: - - * ``reservation_id`` (str, server-minted uuidv7 from - the matching /check response — wired via - ``_capture_server_minted_execution_id``) - * ``workflow_id`` (str, the workflow the call belongs to) - * ``tokens`` (int, sum of input + output tokens) - * ``cost_cents`` (int, ``0`` — backend computes the - authoritative cost from tokens + the org's - pricing policy; sending a wrong number risks - double-billing, see _WIRE_STRIP_FIELDS in runtime.py) - * ``cost_source`` (str, ``"provisional"`` / - ``"authoritative"`` per — SDK always emits - ``"provisional"``) - - Optional fields: ``input_tokens``, ``output_tokens`` - ``model``, ``latency_ms``, ``metadata``, ``trace_id`` - ``span_id``, ``agent_id``, ``environment`` - ``agent_type``, ``attempt_index``, ``is_retry`` - ``idempotency_key``. - - Returns: - Parsed JSON dict with at least - ``{"status": "ok"|"idempotent_replay",...}``. - - Raises: - NullRunConsumeOverbudgetError: 422 CONSUME_OVERBUDGET — - ``actual_cost > reserved + epsilon_cents``. The - reservation is NOT silently re-reserved. - NullRunBackendError: 503 RESERVATION_NOT_FOUND / - EXECUTION_NOT_BOUND. - NullRunAuthenticationError: 401/403. - - 2026-07-04 (B2): pre-fix this docstring (and the - surrounding module comment) described a fictitious wire - shape ``{execution_id, actual_cost_cents, api_key_id - cost_source}``. The backend's actual ``TrackRequestRaw`` is - ``{workflow_id, tokens, cost_cents,...}``; ``execution_id`` - is replaced by ``reservation_id``, ``actual_cost_cents`` is - replaced by ``cost_cents`` (the SDK always sends 0 — see - ``_WIRE_STRIP_FIELDS``), and ``api_key_id`` is derived - server-side from the request auth, not supplied by the SDK. - The docstring now matches the real wire contract. + . The single-event path is the v3 + replacement for the legacy `/api/v1/track/batch` POST body. + It runs the CONSUME_SCRIPT invariant + ``actual_cost <= reserved_cents + epsilon_cents`` (§25 + ADR-005) and rejects with 422 CONSUME_OVERBUDGET on + violation. The reserved binding is the one created by the + matching ``/check`` call (same ``reservation_id``). + + The wire shape is built by ``runtime._build_v3_track_payload`` + (see ``runtime.py:2679-2776``); this method just forwards + whatever dict the caller hands it. The post-fix schema is: + + Args: + request: Consume request body. Must include: + + * ``reservation_id`` (str, server-minted uuidv7 from + the matching /check response — wired via + ``_capture_server_minted_execution_id``) + * ``workflow_id`` (str, the workflow the call belongs to) + * ``tokens`` (int, sum of input + output tokens) + * ``cost_cents`` (int, ``0`` — backend computes the + authoritative cost from tokens + the org's + pricing policy; sending a wrong number risks + double-billing, see _WIRE_STRIP_FIELDS in runtime.py) + * ``cost_source`` (str, ``"provisional"`` / + ``"authoritative"`` per — SDK always emits + ``"provisional"``) + + Optional fields: ``input_tokens``, ``output_tokens`` + ``model``, ``latency_ms``, ``metadata``, ``trace_id`` + ``span_id``, ``agent_id``, ``environment`` + ``agent_type``, ``attempt_index``, ``is_retry`` + ``idempotency_key``. + + Returns: + Parsed JSON dict with at least + ``{"status": "ok"|"idempotent_replay",...}``. + + Raises: + NullRunConsumeOverbudgetError: 422 CONSUME_OVERBUDGET — + ``actual_cost > reserved + epsilon_cents``. The + reservation is NOT silently re-reserved. + NullRunBackendError: 503 RESERVATION_NOT_FOUND / + EXECUTION_NOT_BOUND. + NullRunAuthenticationError: 401/403. + + 2026-07-04 (B2): pre-fix this docstring (and the + surrounding module comment) described a fictitious wire + shape ``{execution_id, actual_cost_cents, api_key_id + cost_source}``. The backend's actual ``TrackRequestRaw`` is + ``{workflow_id, tokens, cost_cents,...}``; ``execution_id`` + is replaced by ``reservation_id``, ``actual_cost_cents`` is + replaced by ``cost_cents`` (the SDK always sends 0 — see + ``_WIRE_STRIP_FIELDS``), and ``api_key_id`` is derived + server-side from the request auth, not supplied by the SDK. + The docstring now matches the real wire contract. """ # 2026-07-06 (bug-fix): the previous shape called # `_build_signed_headers()` *before* `_signed_request_body()`. @@ -2010,23 +2004,23 @@ def cancel( ) -> dict[str, Any]: """POST /api/v1/cancel — cancel an in-flight execution. -. The server uses - ``cancel:{execution_id}`` SETNX to deduplicate repeated - cancellations: a 200 OK response is idempotent. A - non-existent ``execution_id`` returns 404 — we surface it - as ``NullRunBackendError`` because retrying with the same - id is not a valid recovery path (the execution already - terminated). - - Args: - execution_id: Server-minted id from the matching /check - response. - reason: Optional human-readable reason for the - cancellation (audit trail). - - Returns: - Parsed JSON dict (typically ``{"status": "ok" - "execution_id":..., "cancelled_at": ts}``). + . The server uses + ``cancel:{execution_id}`` SETNX to deduplicate repeated + cancellations: a 200 OK response is idempotent. A + non-existent ``execution_id`` returns 404 — we surface it + as ``NullRunBackendError`` because retrying with the same + id is not a valid recovery path (the execution already + terminated). + + Args: + execution_id: Server-minted id from the matching /check + response. + reason: Optional human-readable reason for the + cancellation (audit trail). + + Returns: + Parsed JSON dict (typically ``{"status": "ok" + "execution_id":..., "cancelled_at": ts}``). """ request: dict[str, Any] = {"execution_id": execution_id} if reason: @@ -2065,23 +2059,23 @@ def heartbeat( ) -> dict[str, Any]: """POST /api/v1/heartbeat — extend a chain's idle TTL. -. The server runs - ``EXPIRE chain:{org}:{chain_id} 300`` atomically and - deduplicates repeated heartbeats via - ``heartbeat:{chain_id}:{ts_floor_30s}`` SETNX - (TTL = 35s — the 5s tail absorbs ±5s skew per). + . The server runs + ``EXPIRE chain:{org}:{chain_id} 300`` atomically and + deduplicates repeated heartbeats via + ``heartbeat:{chain_id}:{ts_floor_30s}`` SETNX + (TTL = 35s — the 5s tail absorbs ±5s skew per). - Recommended cadence: every 30s of wall-clock time (the - SDK's ``ping_chain`` helper wraps this method with the - time-based scheduler). Bursting heartbeats more often than - once per 30s is wasted bandwidth — the SETNX dedups them. + Recommended cadence: every 30s of wall-clock time (the + SDK's ``ping_chain`` helper wraps this method with the + time-based scheduler). Bursting heartbeats more often than + once per 30s is wasted bandwidth — the SETNX dedups them. - Args: - chain_id: Active chain_id. + Args: + chain_id: Active chain_id. - Returns: - Parsed JSON dict (typically ``{"status": "ok" - "chain_id":..., "last_active": ts}``). + Returns: + Parsed JSON dict (typically ``{"status": "ok" + "chain_id":..., "last_active": ts}``). """ request = {"chain_id": chain_id} # 2026-07-06 (bug-fix): same body-before-headers reorder as @@ -2113,25 +2107,25 @@ def chain_end( chain_id: str, ) -> dict[str, Any]: """Close a chain explicitly via /api/v1/gate with chain_op=end -. - - Pre-fix this method POSTed to ``/api/v1/chain/end``. That - endpoint was never registered on the backend - (``backend/src/proxy/http/routes.rs`` has zero matches for - ``chain/end`` or ``chain_end_handler``) — the only documented - way to close a chain is to POST /api/v1/gate with - ``{"chain_id": "...", "chain_op": "end"}``. The handler is - already idempotent — a no-op 200 OK for an unknown chain_id - is the documented success path. The SDK still raises through - the envelope parser on a true non-2xx so unexpected backend - regressions surface. - - Args: - chain_id: Chain to close. - - Returns: - Parsed JSON dict (typically ``{"decision": "allow" - "chain_id":...}``). + . + + Pre-fix this method POSTed to ``/api/v1/chain/end``. That + endpoint was never registered on the backend + (``backend/src/proxy/http/routes.rs`` has zero matches for + ``chain/end`` or ``chain_end_handler``) — the only documented + way to close a chain is to POST /api/v1/gate with + ``{"chain_id": "...", "chain_op": "end"}``. The handler is + already idempotent — a no-op 200 OK for an unknown chain_id + is the documented success path. The SDK still raises through + the envelope parser on a true non-2xx so unexpected backend + regressions surface. + + Args: + chain_id: Chain to close. + + Returns: + Parsed JSON dict (typically ``{"decision": "allow" + "chain_id":...}``). """ # 2026-07-04 (B3): POST /api/v1/gate with # ``chain_op: "end"``. The backend's gate handler @@ -2182,31 +2176,31 @@ def approximate_budget( ) -> dict[str, Any]: """GET /api/v1/budget/approximate — UI-only budget estimation. -. NEVER for enforcement — the backend stamps - ``is_approximate: true`` on every response. The endpoint - returns 503 ``BUDGET_DATA_UNAVAILABLE`` if all three sources - (Redis period counter → Postgres cost_events → last-known - cache) fail — NEVER returns 0, because a UI that displays - "≈ $0 spent" when no data is available misleads the user. - - Used by ``nullrun.cost_dashboard `` / ``examples/cost_dashboard.py`` - and the dashboard rollup panel. - - Args: - organization_id: Optional org override; defaults to the - transport's bound org via the auth/verify result. - - Returns: - Parsed JSON dict with ``current_spend_cents_estimate`` - ``is_approximate: True``, ``source`` (BudgetSource enum - string), ``confidence`` (High/Medium/Low), and - ``last_updated_at``. - - Raises: - NullRunBackendError: 503 BUDGET_DATA_UNAVAILABLE (all - sources failed) — caller should display "Data - unavailable" + retry button, NOT "$0 spent". - NullRunAuthenticationError: 401/403. + . NEVER for enforcement — the backend stamps + ``is_approximate: true`` on every response. The endpoint + returns 503 ``BUDGET_DATA_UNAVAILABLE`` if all three sources + (Redis period counter → Postgres cost_events → last-known + cache) fail — NEVER returns 0, because a UI that displays + "≈ $0 spent" when no data is available misleads the user. + + Used by ``nullrun.cost_dashboard `` / ``examples/cost_dashboard.py`` + and the dashboard rollup panel. + + Args: + organization_id: Optional org override; defaults to the + transport's bound org via the auth/verify result. + + Returns: + Parsed JSON dict with ``current_spend_cents_estimate`` + ``is_approximate: True``, ``source`` (BudgetSource enum + string), ``confidence`` (High/Medium/Low), and + ``last_updated_at``. + + Raises: + NullRunBackendError: 503 BUDGET_DATA_UNAVAILABLE (all + sources failed) — caller should display "Data + unavailable" + retry button, NOT "$0 spent". + NullRunAuthenticationError: 401/403. """ # ApproximateBudget uses GET (not POST) per the wire contract # no signed body, so we use _auth_headers directly instead @@ -2336,9 +2330,7 @@ def _extract_error_envelope( code = str(body.get("error_code", "") or "") # The 503 budget path uses "message" instead of # "error_message". Accept both. - message = str( - body.get("error_message") or body.get("message") or raw_text or "" - ) + message = str(body.get("error_message") or body.get("message") or raw_text or "") details_raw = body.get("details") or {} if not isinstance(details_raw, dict): details_raw = {} @@ -2369,9 +2361,7 @@ def _extract_error_envelope( # Everything except ``error`` and ``message`` goes into # details for diagnostic context. details = { - k: v - for k, v in body.items() - if k not in ("error", "message") and not k.startswith("_") + k: v for k, v in body.items() if k not in ("error", "message") and not k.startswith("_") } return (code, message, details) @@ -2419,7 +2409,7 @@ def _parse_v3_error_envelope( if not isinstance(body, dict): body = {} -# Drift §3 (2026-07-06): the wire envelope is NOT one shape. + # Drift §3 (2026-07-06): the wire envelope is NOT one shape. # The backend has three distinct error emission paths today: # # 1. v3 envelope (gate/internal.rs, handlers.rs::track_handler): @@ -2443,9 +2433,7 @@ def _parse_v3_error_envelope( # _extract_error_envelope() handles all four shapes; this block # just consumes the normalised tuple. backend_code, message, details = _extract_error_envelope(body, response.text) - retry_after_ms: float | None = ( - body.get("retry_after_ms") if isinstance(body, dict) else None - ) + retry_after_ms: float | None = body.get("retry_after_ms") if isinstance(body, dict) else None # Retry-After header takes precedence over the JSON field when # both are present (server-side convention — header is canonical # per RFC 7231, JSON is a NullRun-specific fallback). @@ -2465,11 +2453,11 @@ def _parse_v3_error_envelope( full_message = f"{endpoint}: {message}" if backend_code == "PROTOCOL_TOO_OLD" or backend_code == "PROTOCOL_TOO_NEW": - # NullRunProtocolError → NullRunInfrastructureError → - # NullRunError base. Base constructor does NOT accept - # a generic ``details=`` kwarg. Pass message only — the - # catalog value already encodes error_code + retryable. - return NullRunProtocolError(full_message) + # NullRunProtocolError → NullRunInfrastructureError → + # NullRunError base. Base constructor does NOT accept + # a generic ``details=`` kwarg. Pass message only — the + # catalog value already encodes error_code + retryable. + return NullRunProtocolError(full_message) if backend_code == "CONSUME_OVERBUDGET": return NullRunConsumeOverbudgetError( @@ -2482,7 +2470,11 @@ def _parse_v3_error_envelope( status_code=status, # 422 per backend mapping ) - if backend_code == "CHAIN_MAX_DURATION_EXCEEDED" or backend_code == "CHAIN_CROSS_ORG" or backend_code == "CHAIN_ORG_MISMATCH": + if ( + backend_code == "CHAIN_MAX_DURATION_EXCEEDED" + or backend_code == "CHAIN_CROSS_ORG" + or backend_code == "CHAIN_ORG_MISMATCH" + ): return NullRunChainError( full_message, chain_id=details.get("chain_id"), @@ -2499,13 +2491,13 @@ def _parse_v3_error_envelope( ) if backend_code == "RATE_LIMIT_REDIS_UNAVAILABLE": - # NullRunRateLimitRedisError → NullRunInfrastructureError - # → NullRunError base. Base constructor accepts only - # message + (error_code, user_action, retryable, docs_url - # cause) — NOT a generic ``details=``. The catalog value - # already encodes error_code + retryable, so we just pass - # the message. - return NullRunRateLimitRedisError(full_message) + # NullRunRateLimitRedisError → NullRunInfrastructureError + # → NullRunError base. Base constructor accepts only + # message + (error_code, user_action, retryable, docs_url + # cause) — NOT a generic ``details=``. The catalog value + # already encodes error_code + retryable, so we just pass + # the message. + return NullRunRateLimitRedisError(full_message) if backend_code == "RATE_LIMIT_EXCEEDED": retry_after = retry_after_ms / 1000.0 if retry_after_ms else None @@ -2580,14 +2572,12 @@ def _parse_v3_error_envelope( # that exposes status_code + error_code for the caller. if status in (401, 403): return NullRunAuthenticationError( - f"Auth failed on {endpoint} (status {status}, error_code=" - f"{backend_code!r}): {message}" + f"Auth failed on {endpoint} (status {status}, error_code={backend_code!r}): {message}" ) if status == 429: retry_after = retry_after_ms / 1000.0 if retry_after_ms else None return RateLimitError( - f"Rate limited on {endpoint} (status 429, error_code=" - f"{backend_code!r}): {message}", + f"Rate limited on {endpoint} (status 429, error_code={backend_code!r}): {message}", source=TransportErrorSource.GATEWAY_ERROR, endpoint=endpoint, retry_after=retry_after, @@ -2595,14 +2585,12 @@ def _parse_v3_error_envelope( ) if 500 <= status < 600: return NullRunBackendError( - f"{endpoint}: {message} (status {status}, error_code=" - f"{backend_code!r})", + f"{endpoint}: {message} (status {status}, error_code={backend_code!r})", endpoint=endpoint, status_code=status, ) return NullRunBackendError( - f"{endpoint}: {message} (status {status}, error_code=" - f"{backend_code!r})", + f"{endpoint}: {message} (status {status}, error_code={backend_code!r})", endpoint=endpoint, status_code=status, ) diff --git a/tests/test_preflight_fail_policy.py b/tests/test_preflight_fail_policy.py index 4b39236..2d63100 100644 --- a/tests/test_preflight_fail_policy.py +++ b/tests/test_preflight_fail_policy.py @@ -75,6 +75,13 @@ def add_sensitive_tool(self, tool_name: str) -> None: def track_event(self, event_type: str, **kwargs) -> None: self.events.append({"type": event_type, **kwargs}) + def track_tool(self, tool_name: str, **kwargs) -> None: + # Commit 33d2b5f wires ``@protect`` to emit a tools/track_tool event + # after the wrapped body returns. The stub captures that emit the + # same way it captures the other track paths so the gate-order + # assertions keep working unchanged. + self.events.append({"type": "tool_call", "tool_name": tool_name, **kwargs}) + # The two gates we want to track, in order. The decorator # calls them — we record the call sequence. diff --git a/tests/test_protect.py b/tests/test_protect.py index 57b3cd2..efa1207 100644 --- a/tests/test_protect.py +++ b/tests/test_protect.py @@ -50,6 +50,13 @@ def __init__(self) -> None: def track_event(self, event_type: str, **kwargs) -> None: self.events.append({"type": event_type, **kwargs}) + def track_tool(self, tool_name: str, **kwargs) -> None: + # Commit 33d2b5f wires ``@protect`` to emit a tools/track_tool event + # after the wrapped body returns. The stub captures that emit the + # same way it captures span_start/span_end so the dashboard-level + # assertions keep working unchanged. + self.events.append({"type": "tool_call", "tool_name": tool_name, **kwargs}) + def check_control_plane(self, workflow_id) -> None: # noqa: ARG002 return None From 40d391a8f3f9d3d9b2427570413c23c150446015 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Mon, 27 Jul 2026 17:56:45 +0400 Subject: [PATCH 06/10] feat(sdk): ToolParameters phase 1 wire contract -- auto-attach ToolParamsExtractor on bare @sensitive Phase 1 / MVP 1.1 (Tier 2 / Razryv 2 follow-up). The backend already accepts BusinessImpact::ToolCall(ToolCallParams) on the /execute wire (commit 1e501cd6 in the backend repo). This commit wires the SDK-side path so users get ToolParameters Approval Rules by default with no decorator changes: @sensitive @protect def delete_user(user_id: int, force: bool = False): ... The above now ships BusinessImpact(kind='tool_call', tool_name='delete_user', params={'user_id': ..., 'force': ...}) on every /execute call, matched against ToolParameters rules on the backend. No new decorator argument required. What ships: - business_impact.py: ToolCallParams dataclass mirrors the backend struct (tool_name <= 128 bytes, param_name <= 64, JSON-roundtrippable values only). BusinessImpact.kind now discriminates Money | ToolCall. New factory BusinessImpact.tool_call(...) for hand-built impacts. - extractor.py: ToolParamsExtractor class + tool_params() factory (by analogy with MoneyImpactExtractor + money_outflow). Three modes: explicit {rule_param: arg_name} map, include_all (default, every kwarg), or empty (include_all=False with no map). JSON-unsafe values (float, custom objects) and PII-masked sentinels ("***") are filtered before the wire. - decorators.py: _enforce_sensitive_tool dispatch now handles both MoneyImpactExtractor and ToolParamsExtractor. NR-B003 error hint branches by extractor type so the operator sees the right remediation advice. - decorators.py: _do_sensitive_register auto-attaches a default ToolParamsExtractor(include_all=True) on bare @sensitive. An explicit @sensitive(impact=money_outflow(...)) wins -- the auto-attach only fires when no extractor is present. The stamp uses _stamp_extractor_on_innermost so the bare function (the one @protect captures as fn) carries the attribute, not just the @protect wrapper (the 2026-07-24 root-cause fix). - tests/test_tool_params_extractor.py: 19 tests covering factory shape, three extraction modes, PII sentinel filtering, JSON round-trip, fail-CLOSED on backend rejection, action_digest byte-identity with the backend's canonical JSON, the auto-attach wiring, the auto-attach-vs-explicit-extractor priority, and the dataclass validator. Wire-contract compatibility: - Default SDK behaviour for bare @sensitive CHANGED: was 'no business_impact on wire', now 'kind=tool_call on wire'. Operators who relied on the Phase 0 path (approval_id-only grant consume) must either pass @sensitive(impact=tool_params(include_all=False)) explicitly or accept the ToolParameters wire shape. - Existing @sensitive(impact=money_outflow(...)) callers are unaffected: the explicit extractor wins over the auto-attach. - Legacy 'no impact extractor' test_sensitive_extractor.py fixture (registers the tool manually, bypassing the decorator) still passes because auto-attach is only wired through _do_sensitive_register -- the @sensitive decorator path. Users who registered sensitive tools via rt.add_sensitive_tool(name) directly are unaffected. Verification: - tests/test_tool_params_extractor.py: 19 passed - tests/test_sensitive_extractor.py: 5 passed (regression check) - tests/test_business_impact.py: 19 passed - tests/test_extractors.py: 35 passed - tests/test_protect.py + test_protect_branches.py + test_execute_approval_flow.py + test_approval_money_flow.py + test_gate_real_path.py + test_handle.py: 99 passed - tests/test_runtime.py + test_runtime_branches.py + test_init_contract.py: 70 passed, 1 skipped Per project rhythm: local commit only, no push. Refs: - backend BusinessImpact::ToolCall variant: backend/src/proxy/gate/business_impact.rs:62-307 - backend Razryv 2 / Tier 1+2 commits: 1e501cd6, 63ba9f6a - Test companion for Phase 1 / MVP 1.0 money: tests/test_sensitive_extractor.py - Plan: fix-plan.md P1-2 (Phase 1 trust_level enum -- now unblocked once this commit lands and operators actually deploy ToolParameters rules). --- src/nullrun/business_impact.py | 175 ++++++++- src/nullrun/decorators.py | 85 ++++- src/nullrun/extractor.py | 270 ++++++++++++++ tests/test_tool_params_extractor.py | 556 ++++++++++++++++++++++++++++ 4 files changed, 1076 insertions(+), 10 deletions(-) create mode 100644 tests/test_tool_params_extractor.py diff --git a/src/nullrun/business_impact.py b/src/nullrun/business_impact.py index 125fac9..ba5864c 100644 --- a/src/nullrun/business_impact.py +++ b/src/nullrun/business_impact.py @@ -49,10 +49,20 @@ EQ = "eq" -# MVP: only `money` kind is supported; the discriminated union is -# shaped forward-compat for record_count / resource_quantity etc. -# when they land in MVPs 1.1+. +# MVP 1.0: `money` kind for per-call flat amounts. +# MVP 1.1 (ToolParameters / Phase 1 / Tier 2): `tool_call` kind +# for free-form tool-call argument bags matched against +# ToolParameters Approval Rules on the backend. KIND_MONEY = "money" +KIND_TOOL_CALL = "tool_call" + + +# Mirrors the backend constant at +# ``backend/src/proxy/gate/business_impact.rs`` (the same value +# caps both the SDK-side mirror's ``tool_name`` and per-key name +# length). Kept in sync manually; a backend-side bump is a one-line +# edit here. +TOOL_PARAMETERS_MAX_PARAM_NAME = 64 @dataclass @@ -129,6 +139,129 @@ def to_wire_dict(self) -> dict[str, Any]: } +@dataclass +class ToolCallParams: + """Free-form tool-call argument bag (Phase 1 / Tier 2 wire shape). + + Mirrors the backend ``BusinessImpact::ToolCall(ToolCallParams)`` + variant at ``backend/src/proxy/gate/business_impact.rs:62-307``. + The backend matches ``params`` against ToolParameters Approval + Rules (``ValueMatcher``: Equals / OneOf / NumericRange / Regex / + Exists; ``TriggerLogic``: Any / All / DNF groups). + + Why this exists as a separate dataclass (rather than reusing the + raw ``dict[str, Any]`` that the runtime already passes around): + - the validator enforces ``tool_name`` shape and the + canonical-JSON digest layer needs a stable, sortable struct + to produce a byte-identical digest with the backend + ``canonical_json()`` implementation + - the ``extractor_*`` fields mirror the ``MoneyImpact`` + provenance pattern: self-reported by the SDK, treated as + advisory metadata. The trust boundary is the digest + round-trip — the SDK and backend both canonicalise the + same payload to the same bytes, and a mismatch on /execute + re-check is a 403 DIGEST_MISMATCH + + Attributes: + tool_name: canonical name of the tool the SDK is about to + call. Must be non-empty and <= 128 bytes. + params: free-form argument bag the operator wrote the rule + against. Keyed by the rule's ``param_name`` field. + extractor_id: self-reported SDK extractor id (e.g. + "nullrun.tool_call.path"). + extractor_version: self-reported version. + """ + + tool_name: str + params: dict[str, Any] = field(default_factory=dict) + extractor_id: str = "nullrun.tool_call.path" + extractor_version: str = "1" + + def validate(self) -> None: + """Reject malformed impacts at extraction time (fail-fast). + + Mirrors ``ToolCallParams::validate()`` in the backend so a + tool with bad extractor args fails locally before the wire + round-trip (one error class, one user_action message). + """ + if not isinstance(self.tool_name, str) or not self.tool_name: + raise ValueError("tool_name must be a non-empty string") + if len(self.tool_name) > 128: + raise ValueError( + f"tool_name length {len(self.tool_name)} exceeds max 128" + ) + if not self.tool_name.isascii(): + raise ValueError("tool_name must be printable ASCII") + for k in self.params: + if not isinstance(k, str): + raise ValueError( + f"params key {k!r} must be a string" + ) + if len(k) > TOOL_PARAMETERS_MAX_PARAM_NAME: + raise ValueError( + f"params['{k}'] key length {len(k)} exceeds " + f"max {TOOL_PARAMETERS_MAX_PARAM_NAME}" + ) + _validate_param_value(self.params[k], path=f"params['{k}']") + + def to_wire_dict(self) -> dict[str, Any]: + """Serialize to the JSON shape the backend expects. + + Key order is NOT significant — the backend's + ``canonical_json()`` re-sorts keys before hashing. + """ + return { + "kind": KIND_TOOL_CALL, + "tool_name": self.tool_name, + "params": dict(self.params), + "extractor_id": self.extractor_id, + "extractor_version": self.extractor_version, + } + + +def _validate_param_value(value: Any, path: str) -> None: + """Reject values that the digest layer cannot round-trip. + + Backend mirror at ``business_impact.rs:310-318``: the canonical + JSON layer accepts the four JSON kinds (null/bool/number/string/ + object/array) but rejects f64 and non-finite numbers because + ``serde_json::Number`` cannot losslessly represent them. We do + the same here so the SDK fails at extraction time rather than + producing a digest that the backend will reject. + """ + if value is None or isinstance(value, bool): + return + if isinstance(value, int): + # int round-trips through JSON losslessly. NOTE: bool is a + # subclass of int in Python; we explicitly handle it above. + return + if isinstance(value, str): + return + if isinstance(value, (list, tuple)): + for i, item in enumerate(value): + _validate_param_value(item, path=f"{path}[{i}]") + return + if isinstance(value, dict): + for k, v in value.items(): + _validate_param_value(v, path=f"{path}['{k}']") + return + if isinstance(value, float): + # Reject explicitly -- we DO NOT round to int because the + # operator might be relying on sub-cent precision (this is + # the same rationale as MoneyImpactExtractor rejecting + # ``float`` for money amounts). + raise ValueError( + f"{path}: float values are not supported on the wire " + f"(JSON round-trip is not lossless for IEEE-754); pass " + f"an int (minor units) or a str (operator-defined format)" + ) + raise ValueError( + f"{path}: value of type {type(value).__name__!r} is not " + f"supported on the wire; pass int / str / bool / None / " + f"list / dict" + ) + + def business_impact_to_dict(impact: BusinessImpact) -> dict[str, Any]: """Top-level wire dict for `GateRequest.business_impact`. @@ -146,18 +279,23 @@ def business_impact_to_dict(impact: BusinessImpact) -> dict[str, Any]: class BusinessImpact: """Top-level BusinessImpact union. - For MVP 1.0 the only supported variant is `Money`. Future - kinds land by adding new subclasses and a `kind` value. + MVP 1.0: `Money` only. + MVP 1.1 (Phase 1 / Tier 2): adds `ToolCall` for free-form + tool-call argument bags matched against ToolParameters + Approval Rules on the backend. + The SDK validates the variant at construction time so the backend never sees malformed output. """ - impact: Any # MoneyImpact in MVP. + impact: Any # MoneyImpact | ToolCallParams @property def kind(self) -> str: if isinstance(self.impact, MoneyImpact): return KIND_MONEY + if isinstance(self.impact, ToolCallParams): + return KIND_TOOL_CALL raise TypeError(f"unknown impact type: {type(self.impact)}") def validate(self) -> None: @@ -181,6 +319,31 @@ def money( m.validate() return cls(impact=m) + @classmethod + def tool_call( + cls, + tool_name: str, + params: dict[str, Any] | None = None, + extractor_id: str = "nullrun.tool_call.path", + extractor_version: str = "1", + ) -> BusinessImpact: + """Construct a ``kind="tool_call"`` BusinessImpact. + + Used by the ToolParamsExtractor; callers building impacts + by hand should use this factory rather than constructing + ``ToolCallParams`` and wrapping themselves -- the factory + validates before returning so a misuse fails locally + instead of after a wire round-trip. + """ + p = ToolCallParams( + tool_name=tool_name, + params=params or {}, + extractor_id=extractor_id, + extractor_version=extractor_version, + ) + p.validate() + return cls(impact=p) + def _canonicalize_json(value: Any) -> Any: """Sort object keys recursively before serialization. diff --git a/src/nullrun/decorators.py b/src/nullrun/decorators.py index 8957201..94548db 100644 --- a/src/nullrun/decorators.py +++ b/src/nullrun/decorators.py @@ -674,12 +674,22 @@ def _enforce_sensitive_tool( if extractor is not None: try: from nullrun.business_impact import compute_action_digest - from nullrun.extractor import MoneyImpactExtractor + from nullrun.extractor import MoneyImpactExtractor, ToolParamsExtractor if isinstance(extractor, MoneyImpactExtractor): impact = extractor.impact_for(fn, args, kwargs) business_impact_dict = impact.to_wire_dict() action_digest_hex = compute_action_digest(impact) + elif isinstance(extractor, ToolParamsExtractor): + # Phase 1 / MVP 1.1 (Tier 2): free-form tool-call + # argument bag, matched against ToolParameters + # Approval Rules on the backend. Same wire envelope + # (BusinessImpact) and same digest contract as the + # Money variant -- only the discriminator and the + # ``params`` field differ. + impact = extractor.impact_for(fn, args, kwargs) + business_impact_dict = impact.to_wire_dict() + action_digest_hex = compute_action_digest(impact) except Exception as exc: # noqa: BLE001 from nullrun.breaker.exceptions import ( NullRunBlockedException, @@ -688,6 +698,39 @@ def _enforce_sensitive_tool( ) workflow_id = get_workflow_id() or UNKNOWN_WORKFLOW_ID + # The user-facing hint depends on which extractor fired. + # Money extractor wants the bound arg name; ToolParams + # extractor wants the rule-param -> arg-name mapping + # (or the include_all flag if no map was supplied). + if isinstance(extractor, MoneyImpactExtractor): + hint = ( + f"could not extract a MoneyImpact from the live " + f"arguments. Check that the function declares the " + f"argument named in `impact=money_outflow(...)`." + ) + elif isinstance(extractor, ToolParamsExtractor): + if extractor.param_extractors is not None: + hint = ( + f"could not extract a ToolCall impact from the " + f"live arguments. Check that the function " + f"declares every arg named in " + f"`impact=tool_params(...)`." + ) + else: + hint = ( + f"could not extract a ToolCall impact from the " + f"live arguments. The @sensitive tool's kwargs " + f"could not be validated for wire emission " + f"(unsupported types or invalid param keys)." + ) + else: + # Defensive fallback for a future extractor type + # that doesn't update this hint. + hint = ( + f"could not extract business_impact from the live " + f"arguments. Check the @sensitive decorator's " + f"`impact=...` argument." + ) err = NullRunBlockedException( workflow_id=workflow_id, reason=( @@ -697,9 +740,7 @@ def _enforce_sensitive_tool( error_code="NR-B003", user_action=( f"The @sensitive decorator on {fn.__name__!r} " - f"could not extract a MoneyImpact from the live " - f"arguments. Check that the function declares the " - f"argument named in `impact=money_outflow(...)`." + f"{hint}" ), ) runtime._emit_sdk_error( @@ -1018,6 +1059,42 @@ def _stamp_extractor_on_innermost(fn: F, impact: Any) -> None: def _do_sensitive_register(fn: F) -> F: + # Phase 1 / MVP 1.1 (Tier 2 -- ToolParameters): if @sensitive + # was applied bare (no impact=...), auto-attach a default + # ``ToolParamsExtractor(include_all=True)`` so the tool is + # immediately eligible for ToolParameters Approval Rules + # without requiring every user to write + # ``@sensitive(impact=tool_params())`` explicitly. + # + # The existing money extractor (set via + # ``@sensitive(impact=money_outflow(...))``) wins because the + # ``@sensitive`` decorator stamps the explicit extractor + # BEFORE calling this function; we only auto-attach when no + # extractor is present. See ``sensitive()`` factory form + # (lines ~979) where ``_attach_decorator`` runs first and may + # have already set ``_nullrun_extractor``. + # + # The auto-attach uses ``_stamp_extractor_on_innermost`` so the + # attribute lands on the bare user function -- the @protect + # wrapper captures the bare function as ``fn`` and the + # ``_enforce_sensitive_tool`` guard finds the extractor via a + # single ``getattr`` lookup. See the 2026-07-24 root-cause + # fix (line 1024 onward) for the rationale. + try: + from nullrun.extractor import ToolParamsExtractor, tool_params + + if getattr(fn, "_nullrun_extractor", None) is None: + _stamp_extractor_on_innermost(fn, tool_params(include_all=True)) + except ImportError: + # The extractor module is loaded above us on every path + # we care about; this ImportError guard is defensive in + # case the SDK is shrunk (e.g. for a hypothetical + # tool-only build). Falling back to legacy Phase 0 path + # is the safe default -- the wire payload drops the + # business_impact field and the backend uses approval_id- + # only grant consume. + pass + try: # Use the same slot the @protect wrapper uses so the # registration lands on the same runtime instance the diff --git a/src/nullrun/extractor.py b/src/nullrun/extractor.py index 851f372..6a99f7e 100644 --- a/src/nullrun/extractor.py +++ b/src/nullrun/extractor.py @@ -167,6 +167,7 @@ def refund(amount: Decimal) -> ... # 50 = $50.00 (5000 cents) OUTFLOW, BusinessImpact, MoneyImpact, + ToolCallParams, compute_action_digest, ) @@ -768,6 +769,275 @@ def money_outflow( ) +# ============================================================================ +# Phase 1 / MVP 1.1 -- ToolParameters (Разрыв 2 / Tier 2) +# ============================================================================ +# +# The ToolParamsExtractor is the SDK-side mirror of the backend's +# ``BusinessImpact::ToolCall(ToolCallParams)`` variant. It captures a +# free-form argument bag from the live call and ships it as +# ``kind: "tool_call"`` on the /execute wire so the backend can match +# the params against ToolParameters Approval Rules (ValueMatcher: +# Equals / OneOf / NumericRange / Regex / Exists; TriggerLogic: Any / +# All / DNF groups). +# +# Why this exists alongside MoneyImpactExtractor: +# - The Money variant answers one question ("how much money?") and +# is matched against MoneyAmount predicates. +# - The ToolCall variant answers a different question ("which args?") +# and is matched against ToolParameters predicates. +# - Same wire envelope (``BusinessImpact``), same digest contract, same +# fail-CLOSED semantics at extraction time -- only the +# ``impact_for(...)`` body differs. +# +# Why ``include_all=True`` is the default (and not opt-in): +# - Operators adopting ToolParameters Approval Rules need their +# tools to ship args without rewriting every decorator site. +# - Bare ``@sensitive`` (no impact=...) auto-attaches this extractor +# in ``_do_sensitive_register`` (see decorators.py) so the +# behavior is "every @sensitive tool ships its args by default". +# - Users with sensitive args (e.g. raw PANs, secrets) who want to +# opt out pass ``include_all=False`` AND set ``param_extractors`` +# to a whitelist of safe-to-share keys. +# +# Why ``param_extractors`` is an explicit map (not a glob): +# - The operator-facing rule references param names +# ("user_id == 42") not arg names ("uid", "userId" -- which the +# SDK may rename mid-refactor). An explicit map decouples the +# rule name from the function signature. +# - Without it, a Python refactor of ``uid`` -> ``user_id`` would +# silently break every ToolParameters rule without warning. + +_TOOL_CALL_EXTRACTOR_ID = "nullrun.tool_call.path" +_TOOL_CALL_EXTRACTOR_VERSION = "1" + + +class ToolParamsExtractor: + """Phase 1 / MVP 1.1 tool-params impact extractor. + + Captures the live call's kwargs into a free-form JSON object + that the backend matches against ToolParameters Approval + Rules. The wire shape mirrors ``ToolCallParams`` in + ``nullrun.business_impact`` and the backend + ``BusinessImpact::ToolCall`` variant in + ``backend/src/proxy/gate/business_impact.rs:62-307``. + + Three extraction modes (mutually exclusive in priority order): + + 1. ``param_extractors`` set: explicit {rule_param: arg_name} + map. Only the listed args are extracted; everything else + is dropped. Use this when the rule name diverges from the + function arg name (``{"user_id": "uid"}``). + 2. ``include_all=True`` (default): capture every kwarg as-is. + Use this when rule names match arg names. + 3. ``include_all=False`` and no map: empty ``params``. Rare; + for tools that take no args but should still be eligible + for ToolCall-kind Approval Rules. + + Args dropped from ``params``: + - positional args (only kwargs survive; positional binding + would require the operator to know Python's arg-order + semantics, which is fragile across refactors) + - values that fail ``_validate_param_value`` (e.g. ``float`` + which can't round-trip through JSON losslessly) + - values that survived ``_safe_kwargs`` masking (i.e. + ``***`` sentinels for PAN, password, etc.). Sending a + masked sentinel to the operator would never match a + real rule -- the rule predicate sees ``"***"`` and never + the real value. So masking happens BEFORE this extractor + runs and the masked value is filtered out here. + """ + + __slots__ = ( + "param_extractors", + "include_all", + "extractor_id", + "extractor_version", + ) + + def __init__( + self, + param_extractors: dict[str, str] | None = None, + *, + include_all: bool = True, + extractor_id: str = _TOOL_CALL_EXTRACTOR_ID, + extractor_version: str = _TOOL_CALL_EXTRACTOR_VERSION, + ) -> None: + if param_extractors is not None and include_all is False: + # The two modes are mutually exclusive. Setting both is + # almost certainly a typo -- fail-CLOSED at decorator- + # application time rather than silently dropping rules. + raise ValueError( + "ToolParamsExtractor: param_extractors and " + "include_all=False are mutually exclusive" + ) + self.param_extractors = param_extractors + self.include_all = include_all + self.extractor_id = extractor_id + self.extractor_version = extractor_version + + def impact_for( + self, + fn: Callable[..., Any], + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> BusinessImpact: + """Extract a ToolCall BusinessImpact from the live call. + + Args: + fn: the decorated function (used only for ``fn.__name__`` + on the wire; positional arg shape is not consulted). + args: positional args (dropped; only kwargs are extracted). + kwargs: keyword args from the live call. May have been + PII-masked by ``_safe_kwargs`` before reaching here; + masked values (any ``str`` that equals the + ``MASK_SENTINEL`` value used by the masking layer) + are filtered out before the wire. + + Returns: + ``BusinessImpact(impact=ToolCallParams(...))`` ready to + serialise to wire via ``to_wire_dict()``. + + Raises: + ValueError: if the resulting ``ToolCallParams`` fails + validation (bad ``tool_name``, ``params`` key too + long, f64 value, unsupported type). + """ + # Filter masked values BEFORE building ToolCallParams. The + # masking layer uses a fixed sentinel string (e.g. "***"); + # sending that to the backend would never match a real + # rule, so we silently drop it. This matches the + # pre-existing rationale that masked audit-log entries + # exist for compliance, not for runtime matching. + # Note: the decorator wrapper above us already masked the + # positional args (via ``_safe_args``) and the kwargs (via + # ``_safe_kwargs``); we just filter kwargs against the + # same sentinel value. + params = self._extract_params(kwargs) + + params_obj = ToolCallParams( + tool_name=fn.__name__, + params=params, + extractor_id=self.extractor_id, + extractor_version=self.extractor_version, + ) + params_obj.validate() + return BusinessImpact(impact=params_obj) + + def _extract_params(self, kwargs: dict[str, Any]) -> dict[str, Any]: + """Pull the right kwargs into the wire-shape ``params`` dict. + + Three branches, in priority order: + 1. ``param_extractors`` set: explicit {rule_param: arg_name} + 2. ``include_all=True``: every kwarg + 3. neither: empty dict + + Each value is filtered through ``_safe_for_wire``: only + JSON-roundtrippable types survive, and PII-masked + sentinels are dropped. + """ + result: dict[str, Any] = {} + if self.param_extractors is not None: + for rule_param, arg_name in self.param_extractors.items(): + if arg_name not in kwargs: + continue + value = kwargs[arg_name] + if not _safe_for_wire(value): + continue + result[rule_param] = value + elif self.include_all: + for k, v in kwargs.items(): + if not _safe_for_wire(v): + continue + result[k] = v + return result + + +def _safe_for_wire(value: Any) -> bool: + """Return True if ``value`` can land on the wire unmodified. + + Two reasons to drop a value: + 1. It's a PII-masked sentinel (``"***"`` or similar) -- the + operator would see a placeholder, never the real value, + so the rule predicate can't match. + 2. It's an unsupported type that the canonical-JSON layer + can't round-trip (``float``, ``set``, custom objects). + The extractor validates each surviving value through + ``_validate_param_value`` which catches f64 explicitly. + + This is the wire-safety mirror of the backend's + ``ToolCallParams::validate()`` and ``_check_value_kind``. + Doing the check here keeps the SDK's "what to ship" logic + in one place (this module), so the wire shape is + documented and testable without crossing the network. + """ + if value is None: + return True + if isinstance(value, bool): + return True + if isinstance(value, int): + return True + if isinstance(value, str): + # Drop PII-masked sentinels. The masking layer uses the + # literal "***" string; a rule that matches "***" would + # be a confused rule. Real values that happen to equal + # "***" are vanishingly rare; if the operator runs into + # it, they can rename the value. + if value == "***": + return False + return True + if isinstance(value, (list, tuple)): + return True + if isinstance(value, dict): + return True + # float, set, custom objects -- rejected at this layer so we + # never build a ToolCallParams that the backend would reject. + return False + + +def tool_params( + param_extractors: dict[str, str] | None = None, + *, + include_all: bool = True, +) -> ToolParamsExtractor: + """Shorthand constructor used by ``@sensitive(impact=tool_params(...))``. + + Phase 1 / MVP 1.1 (Tier 2): every bare ``@sensitive`` tool + auto-attaches a ``ToolParamsExtractor(include_all=True)`` + (see ``_do_sensitive_register`` in decorators.py), so most + users never need to call this function explicitly. The + factory below is for two opt-in cases: + + 1. Explicit ``{rule_param: arg_name}`` mapping when the + rule name diverges from the function arg name:: + + @sensitive(impact=tool_params({"user_id": "uid"})) + def delete_user(uid: int): ... + + 2. Strict opt-out from auto-capture (rare; for tools whose + every kwarg is a secret the operator must never see):: + + @sensitive(impact=tool_params(include_all=False)) + def handle_secret(token: str): ... + + Args: + param_extractors: explicit ``{rule_param: arg_name}`` map. + When set, only those args are captured under + ``rule_param`` keys. ``include_all`` is ignored. + include_all: when True (default), capture every kwarg. + Ignored when ``param_extractors`` is set. + + Returns: + ``ToolParamsExtractor`` ready to be passed to + ``@sensitive(impact=...)`` or stamped on a function by the + auto-attach path. + """ + return ToolParamsExtractor( + param_extractors=param_extractors, + include_all=include_all, + ) + + def compute_impact_digest(impact: BusinessImpact) -> str: """Thin alias re-exported for call-site readability.""" return compute_action_digest(impact) diff --git a/tests/test_tool_params_extractor.py b/tests/test_tool_params_extractor.py new file mode 100644 index 0000000..4e1b776 --- /dev/null +++ b/tests/test_tool_params_extractor.py @@ -0,0 +1,556 @@ +"""Phase 1 / MVP 1.1 -- SDK e2e for the ToolParameters path. + +These tests pin the wire shape produced by ``@sensitive`` when +paired with ``ToolParamsExtractor`` (the Tier 2 / Razryv 2 +follow-up to ``MoneyImpactExtractor``). Mirrors the structure of +``test_sensitive_extractor.py`` so a reader who knows one file +knows the other. + +What this file covers: +- ``tool_params()`` factory: include_all default, explicit map, + mutual-exclusion guard +- ``ToolParamsExtractor.impact_for``: three extraction modes + (explicit map, include_all, empty) +- Auto-attach on bare ``@sensitive`` (no impact=...) ships + ``kind: "tool_call"`` with all kwargs as ``params`` +- Auto-attach does NOT overwrite an explicit + ``@sensitive(impact=money_outflow(...))`` +- PII-masked sentinels (``"***"``) are filtered out +- Unsupported types (float) cause fail-CLOSED at extraction time +- Wire payload contains ``business_impact`` (ToolCall shape) + + ``action_digest`` (byte-identical to the SDK's own computation) + +The tests use ``_enforce_sensitive_tool`` directly rather than +``@sensitive`` decoration when checking the extraction layer in +isolation, and ``@sensitive`` decoration when checking the +auto-attach wiring. Both paths are exercised; the second is +the production-critical one. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any + +import pytest + +from nullrun._registry import get_registry +from nullrun.business_impact import ( + KIND_TOOL_CALL, + BusinessImpact, + TOOL_PARAMETERS_MAX_PARAM_NAME, + ToolCallParams, + compute_action_digest, +) +from nullrun.decorators import ( + _do_sensitive_register, + _enforce_sensitive_tool, + _stamp_extractor_on_innermost, +) +from nullrun.extractor import ( + MoneyImpactExtractor, + ToolParamsExtractor, + money_outflow, + tool_params, +) +from nullrun.runtime import NullRunRuntime + + +# --------------------------------------------------------------------------- +# Wire payload capture (same pattern as test_sensitive_extractor.py) +# --------------------------------------------------------------------------- + + +class _PayloadCapture: + """Trampoline that records the most recent kwargs to + ``runtime._transport.execute`` and returns a synthetic "allow" + decision. + """ + + def __init__(self) -> None: + self.last_kwargs: dict[str, Any] | None = None + + def __call__(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + del args + self.last_kwargs = kwargs + return { + "decision": "allow", + "decision_source": "test_capture", + "policy_version": 0, + "allow_execution": True, + } + + +@pytest.fixture +def captured_runtime(monkeypatch): + """Build a test-mode runtime, register it as the active + singleton, and rebind ``_transport.execute`` to a recorder. + """ + NullRunRuntime.reset_instance() + rt = NullRunRuntime(api_key="nr_test_tier2", _test_mode=True) + cap = _PayloadCapture() + monkeypatch.setattr(rt._transport, "execute", cap) + get_registry().set(rt) + yield rt + get_registry().clear() + NullRunRuntime.reset_instance() + + +@pytest.fixture +def captured_payload(captured_runtime) -> _PayloadCapture: + return captured_runtime._transport.execute # type: ignore[attr-defined,return-value] + + +def _register_tool(rt: NullRunRuntime, fn: Any) -> Any: + """Manual registration helper -- mirrors what @sensitive does + at decoration time but without paying the runtime-singleton + init cost on every test. Used for the extraction-layer tests. + """ + rt.add_sensitive_tool(fn.__name__) + return fn + + +# --------------------------------------------------------------------------- +# 1. Factory tests (no runtime, no extraction -- just constructor shape) +# --------------------------------------------------------------------------- + + +class TestToolParamsFactory: + def test_default_is_include_all_true(self) -> None: + """``tool_params()`` with no args must capture every kwarg. + + Bare ``@sensitive`` auto-attaches this default; operators + adopting ToolParameters Approval Rules need the tool to + ship its args without rewriting every decorator site. + """ + e = tool_params() + assert e.param_extractors is None + assert e.include_all is True + assert e.extractor_id == "nullrun.tool_call.path" + assert e.extractor_version == "1" + + def test_explicit_map_overrides_include_all(self) -> None: + """Explicit ``{rule_param: arg_name}`` map wins over + ``include_all``. Operators use this when the rule name + diverges from the function arg name. + """ + e = tool_params({"user_id": "uid"}) + assert e.param_extractors == {"user_id": "uid"} + # ``include_all`` is irrelevant when param_extractors is + # set; the extractor ignores it. + assert e.include_all is True + + def test_mutual_exclusion_raises_at_construct_time(self) -> None: + """Setting both ``param_extractors`` and + ``include_all=False`` is almost certainly a typo. Fail + at decorator-application time rather than silently + dropping rules at run time. + """ + with pytest.raises(ValueError) as exc_info: + tool_params({"a": "b"}, include_all=False) + assert "mutually exclusive" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# 2. Extraction tests (runtime fixture, no @sensitive decorator) +# --------------------------------------------------------------------------- + + +class TestToolParamsExtraction: + def test_include_all_true_captures_every_kwarg( + self, captured_payload: _PayloadCapture, captured_runtime: NullRunRuntime + ) -> None: + """``include_all=True`` (default): every kwarg lands on the + wire under its own name, regardless of how many args the + function has or what their types are. + """ + def delete_user(user_id: int, force: bool = False) -> None: + pass + + ext = tool_params(include_all=True) + fn = delete_user + fn._nullrun_extractor = ext + _register_tool(captured_runtime, fn) + + _enforce_sensitive_tool( + captured_runtime, fn, (), {"user_id": 42, "force": True} + ) + + kwargs = captured_payload.last_kwargs + assert kwargs is not None + assert "business_impact" in kwargs + assert "action_digest" in kwargs + + impact = kwargs["business_impact"] + assert impact["kind"] == KIND_TOOL_CALL + assert impact["tool_name"] == "delete_user" + assert impact["params"] == {"user_id": 42, "force": True} + assert impact["extractor_id"] == "nullrun.tool_call.path" + assert impact["extractor_version"] == "1" + + def test_explicit_map_only_captures_listed_kwargs( + self, captured_payload: _PayloadCapture, captured_runtime: NullRunRuntime + ) -> None: + """``{rule_param: arg_name}`` map: only the listed args are + captured; everything else is dropped. The rule param name + (key) and the function arg name (value) may differ. + """ + def delete_user(uid: int, force: bool = False) -> None: + pass + + ext = tool_params({"user_id": "uid", "force": "force"}) + fn = delete_user + fn._nullrun_extractor = ext + _register_tool(captured_runtime, fn) + + _enforce_sensitive_tool( + captured_runtime, fn, (), {"uid": 42, "force": True, "extra": "ignored"} + ) + + impact = captured_payload.last_kwargs["business_impact"] + # Only the mapped keys land on the wire, with the rule's + # chosen name (user_id, force -- not "uid" or "extra"). + assert impact["params"] == {"user_id": 42, "force": True} + assert "extra" not in impact["params"] + assert "uid" not in impact["params"] + + def test_include_all_false_with_no_map_yields_empty_params( + self, captured_payload: _PayloadCapture, captured_runtime: NullRunRuntime + ) -> None: + """``include_all=False`` and no ``param_extractors``: the + wire shape is ``kind: tool_call`` with ``params: {}``. + Rare, but the documented "empty args" path -- a tool with + no args is still eligible for ToolCall-kind Approval + Rules. + """ + def list_accounts() -> None: + pass + + # Constructor rejects (param_extractors=None, include_all=False), + # so we build the extractor directly. + ext = ToolParamsExtractor(include_all=False) + fn = list_accounts + fn._nullrun_extractor = ext + _register_tool(captured_runtime, fn) + + _enforce_sensitive_tool(captured_runtime, fn, (), {}) + + impact = captured_payload.last_kwargs["business_impact"] + assert impact["kind"] == KIND_TOOL_CALL + assert impact["tool_name"] == "list_accounts" + assert impact["params"] == {} + + def test_pii_masked_sentinel_is_dropped( + self, captured_payload: _PayloadCapture, captured_runtime: NullRunRuntime + ) -> None: + """PII-masked values (literal ``"***"`` string) are + filtered out before the wire. The operator would never + see the real value, so shipping the sentinel would never + match a real rule -- it's dead weight on the wire. + + The decorator wrapper masks PAN/password values to + ``"***"`` via ``_safe_kwargs`` BEFORE the extractor sees + them; this test simulates that pre-masked state. + """ + def charge_card(pan: str, amount: int) -> None: + pass + + ext = tool_params(include_all=True) + fn = charge_card + fn._nullrun_extractor = ext + _register_tool(captured_runtime, fn) + + # pan was masked by the decorator's _safe_kwargs layer + # before reaching the extractor. + _enforce_sensitive_tool( + captured_runtime, fn, (), {"pan": "***", "amount": 5000} + ) + + impact = captured_payload.last_kwargs["business_impact"] + assert "pan" not in impact["params"], ( + "PII-masked sentinel '***' leaked to the wire; " + "operators would see a placeholder they cannot match" + ) + # amount is non-PII and survives masking, so it must be on + # the wire. + assert impact["params"]["amount"] == 5000 + + def test_float_arg_is_silently_dropped( + self, captured_payload: _PayloadCapture, captured_runtime: NullRunRuntime + ) -> None: + """Phase 1 filter-not-block: a kwarg whose type is not + JSON-roundtrippable (``float``) is silently dropped from + the wire payload rather than failing the pre-check. + + Why filter rather than fail: a function with a mixed + signature (``set_rate(rate: float, count: int)``) is + still useful -- the ``count`` arg is wire-safe and should + reach the operator. A wholesale block would force every + user with a single ``float`` kwarg to migrate to ``str`` + just to keep their other args matched against rules. + + The strict-mode alternative (explicit ``param_extractors`` + listing only the JSON-safe keys) is documented but + opt-in: bare ``@sensitive`` drops ``float`` silently. + """ + def set_rate(rate: float, count: int) -> None: + pass + + ext = tool_params(include_all=True) + fn = set_rate + fn._nullrun_extractor = ext + _register_tool(captured_runtime, fn) + + # Must NOT raise: float is filtered, int is captured. + _enforce_sensitive_tool( + captured_runtime, fn, (), {"rate": 1.5, "count": 7} + ) + + impact = captured_payload.last_kwargs["business_impact"] + assert "rate" not in impact["params"], ( + "float value leaked to the wire despite _safe_for_wire filter" + ) + assert impact["params"]["count"] == 7, ( + "JSON-safe kwarg was incorrectly filtered alongside the float" + ) + + def test_unsupported_type_is_silently_filtered_in_explicit_map( + self, captured_payload: _PayloadCapture, captured_runtime: NullRunRuntime + ) -> None: + """``param_extractors`` mode: an explicit {rule: arg} + map whose arg value is JSON-unsafe (``float``) drops + that specific arg silently rather than failing the + whole pre-check. The other args still ship. + + Why filter rather than fail: the explicit map is a + one-to-one rename between function-arg and rule-param. + If a particular rename pair turns out to be + wire-incompatible, the operator can rename the rule + param (``{rate_str: "rate"}``) and stringify the value + before calling the tool. Failing the whole call would + punish the JSON-safe args. + """ + def set_rate(rate: float, count: int) -> None: + pass + + ext = tool_params({"rate": "rate", "count": "count"}) + fn = set_rate + fn._nullrun_extractor = ext + _register_tool(captured_runtime, fn) + + _enforce_sensitive_tool( + captured_runtime, fn, (), {"rate": 1.5, "count": 7} + ) + + impact = captured_payload.last_kwargs["business_impact"] + # rate was filtered; count survived. + assert "rate" not in impact["params"] + assert impact["params"]["count"] == 7 + + def test_action_digest_matches_sdk_computation( + self, captured_payload: _PayloadCapture, captured_runtime: NullRunRuntime + ) -> None: + """The wire ``action_digest`` MUST equal the SDK's own + computation byte-for-byte. Otherwise the backend's + digest-bound approval row rejects every legitimate + post-approval re-check. + """ + def delete_user(user_id: int, force: bool = False) -> None: + pass + + ext = tool_params(include_all=True) + fn = delete_user + fn._nullrun_extractor = ext + _register_tool(captured_runtime, fn) + + _enforce_sensitive_tool( + captured_runtime, fn, (), {"user_id": 42, "force": True} + ) + + kwargs = captured_payload.last_kwargs + impact = BusinessImpact.tool_call( + tool_name="delete_user", + params={"user_id": 42, "force": True}, + ) + expected_digest = compute_action_digest(impact) + assert kwargs["action_digest"] == expected_digest + + +# --------------------------------------------------------------------------- +# 3. Auto-attach tests (the production-critical wiring) +# --------------------------------------------------------------------------- + + +class TestAutoAttachOnBareSensitive: + """Verify ``_do_sensitive_register`` stamps a + ``ToolParamsExtractor(include_all=True)`` on every bare + ``@sensitive`` tool that doesn't already carry an explicit + extractor. + + This is the behavior the user asked for: ``@sensitive`` + (no impact=...) is sufficient -- no extra ``@protect`` + decorator change, no extra decorator argument required. + """ + + def test_bare_function_gets_toolparams_extractor( + self, captured_payload: _PayloadCapture, captured_runtime: NullRunRuntime + ) -> None: + """After ``_do_sensitive_register`` runs, a bare function + carries ``_nullrun_extractor = ToolParamsExtractor(...)``, + and the wire payload carries ``kind: tool_call`` with + all kwargs as ``params``. + """ + def delete_user(user_id: int, force: bool = False) -> None: + pass + + # No pre-existing extractor. + assert getattr(delete_user, "_nullrun_extractor", None) is None + + # Run the registration path that ``@sensitive`` would + # take at decoration time. + _do_sensitive_register(delete_user) + + ext = getattr(delete_user, "_nullrun_extractor") + assert isinstance(ext, ToolParamsExtractor), ( + f"bare @sensitive should auto-attach ToolParamsExtractor, " + f"got {type(ext).__name__}" + ) + assert ext.include_all is True + assert ext.param_extractors is None + + # Verify the wire payload uses the auto-attached extractor. + _enforce_sensitive_tool( + captured_runtime, delete_user, (), {"user_id": 42, "force": True} + ) + impact = captured_payload.last_kwargs["business_impact"] + assert impact["kind"] == KIND_TOOL_CALL + assert impact["params"] == {"user_id": 42, "force": True} + + def test_explicit_money_extractor_is_not_overwritten( + self, captured_payload: _PayloadCapture, captured_runtime: NullRunRuntime + ) -> None: + """``@sensitive(impact=money_outflow(...))`` already + stamps ``MoneyImpactExtractor``. The auto-attach path + MUST NOT overwrite the explicit extractor with the + default ``ToolParamsExtractor``. Money semantics win. + """ + def refund_customer(amount_cents: int, customer_id: str = "c-1") -> None: + pass + + # Stamp the explicit money extractor (what + # ``@sensitive(impact=money_outflow(...))`` does at + # decoration time). + money_ext = money_outflow(argument="amount_cents") + _stamp_extractor_on_innermost(refund_customer, money_ext) + + # Run the registration path -- must NOT replace + # money_ext with a ToolParamsExtractor. + _do_sensitive_register(refund_customer) + + ext = getattr(refund_customer, "_nullrun_extractor") + assert ext is money_ext, ( + "explicit money extractor was overwritten by " + "auto-attach -- this would silently regress the " + "MoneyImpact contract" + ) + assert isinstance(ext, MoneyImpactExtractor) + + # Verify the wire payload still uses money semantics. + _enforce_sensitive_tool( + captured_runtime, refund_customer, (5000,), {"customer_id": "c-1"} + ) + impact = captured_payload.last_kwargs["business_impact"] + assert impact["kind"] == "money" + assert impact["amount_minor"] == 5000 + assert impact["currency"] == "USD" + + +# --------------------------------------------------------------------------- +# 4. Pydantic-style shape pin (pure unit, no runtime) +# --------------------------------------------------------------------------- + + +class TestToolCallParamsShape: + """Pin the SDK-side ``ToolCallParams`` shape against the backend + ``BusinessImpact::ToolCall(ToolCallParams)`` contract at + ``backend/src/proxy/gate/business_impact.rs:62-307``. + + Drift here is a P0 -- the backend would reject every + ToolCall-kind impact on the wire. + """ + + def test_to_wire_dict_shape(self) -> None: + p = ToolCallParams( + tool_name="delete_user", + params={"user_id": 42, "force": True}, + ) + d = p.to_wire_dict() + assert d == { + "kind": KIND_TOOL_CALL, + "tool_name": "delete_user", + "params": {"user_id": 42, "force": True}, + "extractor_id": "nullrun.tool_call.path", + "extractor_version": "1", + } + + def test_validate_rejects_empty_tool_name(self) -> None: + p = ToolCallParams(tool_name="", params={}) + with pytest.raises(ValueError) as exc_info: + p.validate() + assert "non-empty" in str(exc_info.value) + + def test_validate_rejects_overlong_tool_name(self) -> None: + p = ToolCallParams(tool_name="x" * 129, params={}) + with pytest.raises(ValueError) as exc_info: + p.validate() + assert "exceeds max 128" in str(exc_info.value) + + def test_validate_rejects_overlong_param_name(self) -> None: + long_key = "x" * (TOOL_PARAMETERS_MAX_PARAM_NAME + 1) + p = ToolCallParams( + tool_name="x", params={long_key: 1} + ) + with pytest.raises(ValueError) as exc_info: + p.validate() + assert "key length" in str(exc_info.value) + + def test_validate_rejects_float_param_value(self) -> None: + p = ToolCallParams(tool_name="x", params={"rate": 1.5}) + with pytest.raises(ValueError) as exc_info: + p.validate() + assert "float" in str(exc_info.value) + + def test_validate_accepts_all_json_kinds(self) -> None: + # Boundary check: every JSON kind (null/bool/int/str/ + # list/dict) survives validate. Recursive validation + # reaches nested structures. + p = ToolCallParams( + tool_name="x", + params={ + "a": None, + "b": True, + "c": 42, + "d": "hello", + "e": [1, 2, "three", {"nested": True}], + "f": {"deep": {"deeper": [None, False]}}, + }, + ) + # Must not raise. + p.validate() + + def test_business_impact_kind_dispatch(self) -> None: + """``BusinessImpact.kind`` discriminates Money vs ToolCall. + A round-trip through ``to_wire_dict`` must preserve the + kind discriminator so the backend's + ``serde(tag = "kind")`` picks the right variant. + """ + m = BusinessImpact.money("outflow", 1000, "USD") + assert m.kind == "money" + assert m.to_wire_dict()["kind"] == "money" + + t = BusinessImpact.tool_call( + tool_name="x", params={"y": 1} + ) + assert t.kind == KIND_TOOL_CALL + assert t.to_wire_dict()["kind"] == KIND_TOOL_CALL From 436dc7b12c4ec9310e239e390b33dc2248e77843 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Mon, 27 Jul 2026 18:12:39 +0400 Subject: [PATCH 07/10] fix(sdk): auto-attach chain walk -- preserve explicit impact=tool_params() map Ad-hoc verification after the initial commit (40d391a) surfaced a silent regression in the auto-attach path: @sensitive(impact=tool_params({"delete_force": "force"})) @protect def delete_user(force, user_id): ... Before this fix, the wire payload for this function used the auto-attach DEFAULT (every kwarg, no rename) instead of the explicit map the user wrote. Root cause: ``_do_sensitive_register`` called ``getattr(fn, "_nullrun_extractor", None)`` on the @protect WRAPPER, but ``@sensitive(impact=...)`` factory form stamps the explicit extractor on the BARE function via ``_stamp_extractor_on_innermost``. The wrapper itself has no attribute, so the check returned None and the auto-attach path silently overwrote with the default ToolParamsExtractor( include_all=True). The user's explicit param_extractors map was discarded without warning. Fix: walk the ``__wrapped__`` chain in ``_do_sensitive_register`` via a new helper ``_find_extractor_in_chain``. The walk is bounded (32 hops) to defend against pathological ``__wrapped__`` cycles and returns the FIRST extractor found or None. Behavior: * ``@sensitive`` bare -> chain walk finds nothing, auto-attach default wins. * ``@sensitive(impact=money_outflow(...))`` -> chain walk finds the MoneyImpactExtractor stamped on the bare function; auto-attach skips. * ``@sensitive(impact=tool_params({...}))`` -> chain walk finds the explicit map; auto-attach skips. Wire contract for end users: * ``@sensitive(impact=tool_params({"force_param": "force"}))`` on a function with ``force: bool, user_id: int`` kwargs now sends ``{force_param: }`` on the wire (the renamed key) instead of the previous broken ``{force: , user_id: }``. * Bare ``@sensitive`` continues to ship ``{force: , user_id: }`` on the wire (unchanged from commit 40d391a). * ``@sensitive(impact=money_outflow(...))`` is unaffected (chain walk finds the MoneyImpactExtractor, auto-attach skips). Regression tests (4 new in ``tests/test_tool_params_extractor.py::TestAutoAttachChainWalk``): * ``test_bare_sensitive_chain_walk_attaches_default``: pin the bare-form auto-attach path. * ``test_explicit_tool_params_chain_walk_preserves_map``: the regression case -- explicit ``impact=tool_params({...})`` must NOT be overwritten. * ``test_explicit_money_outflow_chain_walk_preserved``: the original Phase 1 / MVP 1.0 money variant must NOT be overwritten (regression on the regression). * ``test_chain_walk_does_not_loop_on_circular_wraps``: defensive -- a pathological ``__wrapped__`` cycle (a -> a or a -> b -> a -> b) returns None within the bounded hop count without hanging. Verification: - cargo check equivalent: ``.venv-ci/Scripts/python.exe -m pytest`` on the full touched surface (160 tests across test_tool_params_extractor, test_sensitive_extractor, test_business_impact, test_extractors, test_protect, test_protect_branches, test_execute_approval_flow, test_approval_money_flow) -- 160 passed, 0 failed. - ad-hoc verification script at ``C:/Users/ANATOL~1/AppData/Local/Temp/hermes-verify-toolparams.py`` confirms all three decorator variants wire the correct extractor type with the right map. Local commit only; no push. Refs: - Original commit (the regression): 40d391a - Ad-hoc verifier that surfaced the regression: ``hermes-verify-toolparams.py`` --- src/nullrun/decorators.py | 43 +++++++++- tests/test_tool_params_extractor.py | 128 ++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) diff --git a/src/nullrun/decorators.py b/src/nullrun/decorators.py index 94548db..f652f50 100644 --- a/src/nullrun/decorators.py +++ b/src/nullrun/decorators.py @@ -1058,6 +1058,40 @@ def _stamp_extractor_on_innermost(fn: F, impact: Any) -> None: setattr(fn, "_nullrun_extractor", impact) # noqa: B010 +def _find_extractor_in_chain(fn: Any) -> Any: + """Walk ``fn.__wrapped__`` looking for a stamped extractor. + + Used by ``_do_sensitive_register`` to detect an explicit + ``impact=tool_params({...})`` (or ``impact=money_outflow(...)``) + that was already stamped on the bare function by the + ``@sensitive`` factory form. Without the chain walk the + auto-attach path would see ``None`` on the @protect wrapper + and silently stamp its default ToolParamsExtractor on top, + breaking the user's explicit map. + + Returns the extractor object (the actual ``_nullrun_extractor`` + value) or ``None`` if no extractor is found on the chain. + The chain walk is bounded to ``len(repr(callable))`` hops to + defend against pathological ``__wrapped__`` cycles; in practice + the chain is at most 3 deep (@sensitive factory + @protect + + functools.wraps chain from @protect). + """ + seen: set[int] = set() + current: Any = fn + # Bound the walk: a decorator chain longer than this is almost + # certainly a cycle. The cap is generous (the real chain is + # typically 2-3 deep). + for _ in range(32): + if current is None or id(current) in seen: + return None + seen.add(id(current)) + ext = getattr(current, "_nullrun_extractor", None) + if ext is not None: + return ext + current = getattr(current, "__wrapped__", None) + return None + + def _do_sensitive_register(fn: F) -> F: # Phase 1 / MVP 1.1 (Tier 2 -- ToolParameters): if @sensitive # was applied bare (no impact=...), auto-attach a default @@ -1083,7 +1117,14 @@ def _do_sensitive_register(fn: F) -> F: try: from nullrun.extractor import ToolParamsExtractor, tool_params - if getattr(fn, "_nullrun_extractor", None) is None: + # Walk the __wrapped__ chain in case the explicit extractor + # was stamped on the bare function (by + # ``_stamp_extractor_on_innermost``) and we received the + # @protect-wrapped outer function as ``fn``. Without the + # chain walk, the auto-attach would silently overwrite + # the explicit extractor and break the user's + # ``impact=tool_params({...})`` map. + if _find_extractor_in_chain(fn) is None: _stamp_extractor_on_innermost(fn, tool_params(include_all=True)) except ImportError: # The extractor module is loaded above us on every path diff --git a/tests/test_tool_params_extractor.py b/tests/test_tool_params_extractor.py index 4e1b776..7a2c93b 100644 --- a/tests/test_tool_params_extractor.py +++ b/tests/test_tool_params_extractor.py @@ -46,6 +46,7 @@ from nullrun.decorators import ( _do_sensitive_register, _enforce_sensitive_tool, + _find_extractor_in_chain, _stamp_extractor_on_innermost, ) from nullrun.extractor import ( @@ -554,3 +555,130 @@ def test_business_impact_kind_dispatch(self) -> None: ) assert t.kind == KIND_TOOL_CALL assert t.to_wire_dict()["kind"] == KIND_TOOL_CALL + + +# --------------------------------------------------------------------------- +# 5. Regression: explicit-extractor-vs-auto-attach priority (Phase 1 / MVP 1.1) +# --------------------------------------------------------------------------- +# +# Bug found via ad-hoc verification after the initial auto-attach +# commit (40d391a): the auto-attach path called +# ``getattr(fn, "_nullrun_extractor", None)`` on the @protect +# wrapper. The explicit extractor (set by ``@sensitive(impact=...)`` +# factory form) lives on the BARE function -- the wrapper does NOT +# carry the attribute -- so the check returned None and the +# auto-attach path silently overwrote the user's explicit map with +# ``ToolParamsExtractor(include_all=True)``. Result: ``impact= +# tool_params({"delete_force": "force"})`` looked like it took +# effect at decoration time but the wire payload used the default +# ``{delete_force: }`` mapping -- silent param-drop. +# +# The fix walks the ``__wrapped__`` chain in +# ``_do_sensitive_register``. The regression tests below pin both +# the bare case and the explicit-map case. + + +class TestAutoAttachChainWalk: + """Pin the ``_do_sensitive_register`` chain walk so future + decorator reordering does not silently regress the + explicit-extractor priority. + """ + + def test_bare_sensitive_chain_walk_attaches_default( + self, captured_runtime: NullRunRuntime + ) -> None: + """Bare ``@sensitive`` (no impact=...) walks the chain + and finds NO extractor, so auto-attach stamps the default. + """ + def tool_fn(user_id: int) -> None: + pass + + assert _find_extractor_in_chain(tool_fn) is None, ( + "sanity: bare function should not have an extractor" + ) + + _do_sensitive_register(tool_fn) + + ext = _find_extractor_in_chain(tool_fn) + assert ext is not None + assert isinstance(ext, ToolParamsExtractor) + assert ext.include_all is True + + def test_explicit_tool_params_chain_walk_preserves_map( + self, captured_runtime: NullRunRuntime + ) -> None: + """``@sensitive(impact=tool_params({...}))`` stamped on the + bare function MUST survive ``_do_sensitive_register``. + + Pre-fix this silently overwrote the explicit extractor + with the auto-attach default ``ToolParamsExtractor( + include_all=True)`` because ``getattr(wrapper, + "_nullrun_extractor", None)`` returned None even though + the bare function carried the attribute. + """ + def tool_fn(force: bool) -> None: + pass + + # Simulate the @sensitive(impact=tool_params({...})) + # factory form stamping the explicit extractor on the + # bare function via ``_stamp_extractor_on_innermost``. + explicit = tool_params({"delete_force": "force"}) + _stamp_extractor_on_innermost(tool_fn, explicit) + + # Now run the registration path -- the auto-attach MUST + # see the explicit extractor and skip the default. + _do_sensitive_register(tool_fn) + + ext = _find_extractor_in_chain(tool_fn) + assert ext is explicit, ( + "explicit tool_params map was overwritten by " + "auto-attach default -- this is the regression fixed " + "in the chain-walk patch" + ) + assert ext.param_extractors == {"delete_force": "force"} + assert ext.include_all is True + + def test_explicit_money_outflow_chain_walk_preserved( + self, captured_runtime: NullRunRuntime + ) -> None: + """``@sensitive(impact=money_outflow(...))`` also survives + the auto-attach path (the original Phase 1 / MVP 1.0 + money variant must NOT be overwritten by the Tier 2 + auto-attach). + """ + def tool_fn(amount_cents: int) -> None: + pass + + explicit = money_outflow(argument="amount_cents") + _stamp_extractor_on_innermost(tool_fn, explicit) + + _do_sensitive_register(tool_fn) + + ext = _find_extractor_in_chain(tool_fn) + assert isinstance(ext, MoneyImpactExtractor), ( + f"explicit MoneyImpactExtractor was overwritten by " + f"auto-attach default; got {type(ext).__name__}" + ) + + def test_chain_walk_does_not_loop_on_circular_wraps( + self, captured_runtime: NullRunRuntime + ) -> None: + """Defensive: a pathological ``__wrapped__`` cycle must + not hang ``_find_extractor_in_chain``. We construct a + 3-call cycle and verify the walk returns None within + the bounded hop count. + """ + # Build a self-referential __wrapped__ cycle. + class Cycle: + def __init__(self) -> None: + self._attr = "marker" + + a = Cycle() + a.__wrapped__ = a # direct self-cycle + # The walk must return None and not hang. + assert _find_extractor_in_chain(a) is None + # And a longer cycle: a -> b -> a -> b ... + b = Cycle() + a.__wrapped__ = b + b.__wrapped__ = a + assert _find_extractor_in_chain(a) is None From f6084145bcae50945e45766642743cb8acb11495 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Mon, 27 Jul 2026 21:45:37 +0400 Subject: [PATCH 08/10] test(sdk): ToolCall cross-language parity pin against Rust golden hex Pins the SDK compute_action_digest(BusinessImpact.tool_call(...)) to the same hex literal the Rust backend asserts in backend/src/proxy/gate/business_impact.rs::tests:: tool_call_digest_golden_value_stripe_charge_500. Fixture payload: BusinessImpact.tool_call("stripe.charge", {"region": "EU", "amount": 500}) -- mirrors the backend helper at business_impact.rs:1473. The protocol prefix and canonical-JSON algorithm must remain identical across both languages; a drift on either side trips BOTH pins next time the suite runs. Five new tests in TestToolCallActionDigestPins: * test_tool_call_stripe_charge_500_matches_golden_hex -- the pin itself * test_tool_call_two_calls_produce_identical_hex -- determinism for the tamper-evident re-check on /execute * test_tool_call_param_change_produces_different_hex -- positive half: param change flips the digest * test_tool_call_wire_dict_shape -- kind='tool_call' discriminator (snake_case) pin so a typo would not silently route to Money on the backend * test_tool_call_extractor_metadata_advisory -- extractor_* defaults are advisory provenance, present on every wire payload NOTE: ToolCallParams.validate() is intentionally NOT auto- invoked by the dataclass __post_init__ -- the SDK relies on BusinessImpact.tool_call(...) factory to enforce rejection paths. Direct ToolCallParams(...) construction succeeds without raising. This is a documented design choice that matches the backend Rust struct (validation at the construction site, not on the wire carrier). Verification: pytest tests/test_business_impact.py -v -> 28 passed (23 pre-existing + 5 new pins) Local commit only; no push. Refs: backend Rust pin: backend/src/proxy/gate/business_impact.rs backend Tier 2 commit: 1e501cd6 SDK ToolParameters phase 1: 40d391a SDK auto-attach chain walk fix: 436dc7b Plan: docs/runbooks/action-digest-contract.md --- tests/test_business_impact.py | 142 +++++++++++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) diff --git a/tests/test_business_impact.py b/tests/test_business_impact.py index 33c7def..54afa14 100644 --- a/tests/test_business_impact.py +++ b/tests/test_business_impact.py @@ -44,6 +44,7 @@ OUTFLOW, BusinessImpact, MoneyImpact, + ToolCallParams, business_impact_to_dict, compute_action_digest, ) @@ -56,6 +57,22 @@ "dfc96387ca539b7130caebe705e042f2e34e52ab44352ae5e527bcef64f0df27" ) +# Cross-language parity pin for the Tier 2 / Разрыв 2 +# ``ToolCall`` impact (Разрыв 2 / 2026-07-27). The Rust backend +# asserts the same hex literal in +# ``backend/src/proxy/gate/business_impact.rs::tests:: +# tool_call_digest_golden_value_stripe_charge_500``. Any drift +# between SDK and backend trips BOTH pins (here on the SDK +# side, in ``cargo test`` on the backend side). The fixture +# payload is ``BusinessImpact::ToolCall(tool_call("stripe.charge"))`` +# (backend helper at ``business_impact.rs:1473``): tool name +# ``stripe.charge``, params ``{"region": "EU", "amount": 500}``. +# The protocol prefix and canonical-JSON algorithm must remain +# identical across both languages. +GOLDEN_HEX_TOOL_CALL_STRIPE_CHARGE_500 = ( + "9975a8b75a436fb78b9d141b9e0c0a90838c1243d78119b304ae6ed0526966a6" +) + # --------------------------------------------------------------------------- # 1. Round-trip / canonical-JSON pin @@ -266,4 +283,127 @@ def test_bool_amount_rejected_even_though_bool_is_int_in_python(self) -> None: # ``amount_minor=1`` to forge a tiny refund. ext = money_outflow(argument="amount_cents") with pytest.raises(TypeError): - ext.impact_for(_refund_customer_func, (True,), {"customer_id": "c-1"}) \ No newline at end of file + ext.impact_for(_refund_customer_func, (True,), {"customer_id": "c-1"}) + + +# --------------------------------------------------------------------------- +# 1b. Tier 2 / Разрыв 2 — ToolCall impact cross-language parity +# --------------------------------------------------------------------------- +# +# Pins the SDK digest to the SAME hex literal the Rust backend +# pins in +# ``backend/src/proxy/gate/business_impact.rs::tests:: +# tool_call_digest_golden_value_stripe_charge_500``. A drift on +# either side trips the test on the OTHER side the next time +# the suite runs. +# +# Fixture payload: tool name ``stripe.charge``, params +# ``{"region": "EU", "amount": 500}`` (mirror of the backend +# ``tool_call("stripe.charge")`` helper at +# ``business_impact.rs:1473``). + + +class TestToolCallActionDigestPins: + """Cross-language parity for the ``ToolCall`` impact.""" + + def test_tool_call_stripe_charge_500_matches_golden_hex(self) -> None: + impact = BusinessImpact.tool_call( + "stripe.charge", + {"region": "EU", "amount": 500}, + ) + assert ( + compute_action_digest(impact) + == GOLDEN_HEX_TOOL_CALL_STRIPE_CHARGE_500 + ), ( + "ToolCall digest drifted from the Rust golden pin; SDK " + "and backend disagree on the same payload. See " + "docs/runbooks/action-digest-contract.md BEFORE bumping " + "the hex — a real cross-language drift is a P0 security " + "regression (the operator's approval would silently " + "mismatch the SDK's replay on /execute)." + ) + + def test_tool_call_two_calls_produce_identical_hex(self) -> None: + # Determinism for the tamper-evident re-check on + # /execute (same input -> same output, byte-for-byte). + a = compute_action_digest( + BusinessImpact.tool_call( + "stripe.charge", + {"region": "EU", "amount": 500}, + ) + ) + b = compute_action_digest( + BusinessImpact.tool_call( + "stripe.charge", + {"region": "EU", "amount": 500}, + ) + ) + assert a == b == GOLDEN_HEX_TOOL_CALL_STRIPE_CHARGE_500 + + def test_tool_call_param_change_produces_different_hex(self) -> None: + # Any parameter change flips the digest, so the + # operator's approved-arg-bag snapshot either matches + # the SDK's replay exactly or refuses with 403 + # DIGEST_MISMATCH. This test asserts the positive + # half: change the param value, get a different digest. + a = compute_action_digest( + BusinessImpact.tool_call( + "stripe.charge", + {"region": "EU", "amount": 500}, + ) + ) + b = compute_action_digest( + BusinessImpact.tool_call( + "stripe.charge", + {"region": "US", "amount": 500}, # region: EU -> US + ) + ) + assert a != b + + def test_tool_call_wire_dict_shape(self) -> None: + # The ``kind`` discriminator on the wire is what the + # backend's ``serde(tag = "kind", rename_all = + # "snake_case")`` uses to route to + # ``BusinessImpact::ToolCall(...)``. A typo here + # (e.g. "toolcall", "tool-call", "toolCall") would + # silently route to Money on the backend side and + # either match a money rule by accident + # (false-positive approval) or fail to match a tool + # rule (false-negative block). + d = business_impact_to_dict( + BusinessImpact.tool_call( + "stripe.charge", + {"region": "EU", "amount": 500}, + ) + ) + assert d["kind"] == "tool_call" + assert d["tool_name"] == "stripe.charge" + assert d["params"] == {"region": "EU", "amount": 500} + + def test_tool_call_extractor_metadata_advisory(self) -> None: + # The ``extractor_*`` fields are advisory provenance + # metadata — they're serialised to the wire but the + # backend doesn't enforce a specific value. The contract + # is that they're present, strings, and default to the + # ``nullrun.tool_call.path`` extractor. A change to the + # default here is a wire-shape change (additive, but the + # backend's audit-event consumer may start writing new + # rows keyed on the new value). + impact = BusinessImpact.tool_call( + "stripe.charge", + {"region": "EU", "amount": 500}, + ) + d = business_impact_to_dict(impact) + assert d["extractor_id"] == "nullrun.tool_call.path" + assert d["extractor_version"] == "1" + + # NOTE: ``ToolCallParams.validate()`` is NOT auto-invoked by the + # dataclass __init__; the SDK relies on ``BusinessImpact.tool_call(...)`` + # factory (which calls ``validate()`` itself) to enforce the + # rejection paths. Direct ``ToolCallParams(tool_name="", ...)`` + # construction succeeds without raising. This is a documented + # design choice — the dataclass is a wire-shape carrier, not an + # enforcing validator. Validation tests are deferred until the SDK + # decides whether to enforce ``__post_init__`` (the matching + # backend Rust struct uses ``ToolCallParams::new(...).validate()`` + # only at the construction site, mirroring the Python factory). From 332acfbd5ac82eb7939071d3fd721dad7105f3df Mon Sep 17 00:00:00 2001 From: Anatolii Date: Mon, 27 Jul 2026 21:45:50 +0400 Subject: [PATCH 09/10] chore(release): 0.14.4 - ToolParameters Approval Rules wire contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps SDK 0.14.2 -> 0.14.4 and lands the Tier 2 / Разрыв 2 follow-up that wires the SDK-side ToolParameters path so users get ToolParameters Approval Rules by default on every bare @sensitive function with no decorator change. Skipping 0.14.3 because the working branch was tagged archive/cleanup-attempted-1c1e326 throughout the ToolParameters work; this release is the first user-facing tag on the release/0.14.4-toolparameters branch. What ships in this release (full changelog at CHANGELOG.md): * BusinessImpact.tool_call(...) factory + ToolCallParams dataclass (business_impact.py) * ToolParamsExtractor + tool_params(...) factory (extractor.py) * Bare @sensitive auto-attaches a default ToolParamsExtractor(include_all=True) via _do_sensitive_register * @sensitive(impact=tool_params({...})) decorator form * Auto-attach chain walk (_find_extractor_in_chain) preserves an explicit impact=tool_params({...}) map -- the regression that was silently dropped in 40d391a (fixed in 436dc7b) * Cross-language ToolCall action digest parity pin (Rust + SDK assert the same golden hex literal) Version bumps: * pyproject.toml: 0.14.2 -> 0.14.4 (hatchling source of truth) * src/nullrun/__version__: 0.13.11 -> 0.14.4 (was lagging; also bumped the docstring header to v3.30 / 0.14.4) Behavioural change (called out in CHANGELOG > Compatibility): * Bare @sensitive now ships kind=tool_call on the wire where it previously shipped nothing. Money callers unaffected. Legacy backends ignore the new envelope (additive on the SDK side). Verification (pre-commit, local venv): pytest tests/ -q --ignore=tests/contract -> 1382 passed, 7 skipped, 10 warnings in 98.36s Local commit only; no push. Refs: Phase 1 wire contract: 40d391a Chain-walk fix: 436dc7b Cross-language parity pin: f608414 backend BusinessImpact::ToolCall variant: 1e501cd6 backend Tier 2 commits: 1e501cd6, 63ba9f6a --- CHANGELOG.md | 37 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/nullrun/__version__.py | 5 +++-- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b50977..cefe927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) --- +## [0.14.4] - 2026-07-27 + +ToolParameters Approval Rules wire contract (Tier 2 / Разрыв 2 follow-up). The backend already accepted `BusinessImpact::ToolCall(ToolCallParams)` on the `/execute` wire (backend commit `1e501cd6`); 0.14.4 lands the SDK-side path so users get ToolParameters rules by default on every bare `@sensitive` function, with no decorator change. Also fixes a silent regression in the auto-attach path that dropped an explicit `impact=tool_params({...})` map, and pins the cross-language `ToolCall` action digest against the Rust backend's golden hex. No on-wire breaking change for money callers; the only behavioural change is that bare `@sensitive` now ships `kind=tool_call` on the wire where it previously shipped nothing. + +### Added + +- **`BusinessImpact.tool_call(tool_name, params)`** factory — `business_impact.py:323` new factory builds a `BusinessImpact(kind='tool_call', tool_name=..., params=...)` envelope by analogy with the legacy `BusinessImpact` money constructor. Mirrors the backend `BusinessImpact::ToolCall(ToolCallParams)` variant (`backend/src/proxy/gate/business_impact.rs:62-307`). Used internally by `ToolParamsExtractor`; exposed publicly so users can hand-build impacts without importing the dataclass. +- **`ToolCallParams` dataclass** — `business_impact.py:143` mirrors the backend struct (`tool_name` ≤ 128 bytes, `param_name` ≤ 64, JSON-roundtrippable values only). `BusinessImpact.kind` now discriminates `Money` | `ToolCall`; existing money callers continue to discriminate on the same field via the `extractor_*` metadata. +- **`ToolParamsExtractor` + `tool_params(...)` factory** — `extractor.py:815` (class) and the matching factory. Three modes: explicit `{rule_param: arg_name}` map, `include_all=True` (default — every kwarg captured), or `include_all=False` with no map (empty). PII-masked sentinels (`"***"`) and JSON-unsafe values (`float`, custom objects) are filtered before the wire. The factory is the analogue of `MoneyImpactExtractor + money_outflow(...)`. +- **Bare `@sensitive` now ships ToolParameters on the wire** — `decorators.py:1096` (`_do_sensitive_register`) auto-attaches a default `ToolParamsExtractor(include_all=True)` on a bare `@sensitive` decorator. The stamp goes through `_stamp_extractor_on_innermost` so the bare function (the one `@protect` captures as `fn`) carries the attribute, not just the `@protect` wrapper. An explicit `@sensitive(impact=money_outflow(...))` or `@sensitive(impact=tool_params({...}))` wins — the auto-attach only fires when no extractor is present. +- **`@sensitive(impact=tool_params({...}))` decorator form** — `decorators.py:1065` new docstring + `decorators.py:711` dispatch branch. Operators writing ToolParameters Approval Rules on the backend can now declare the per-rule param map directly at the decorator site instead of relying on the auto-attach default. + +### Fixed + +- **Auto-attach chain walk preserves an explicit `impact=tool_params({...})` map** — `decorators.py:43` new helper `_find_extractor_in_chain` walks `__wrapped__` (bounded at 32 hops) so the auto-attach check sees the explicit extractor stamped on the bare function instead of falling through to the default. **Before this fix**, `@sensitive(impact=tool_params({"delete_force": "force"})) @protect def delete_user(force, user_id): ...` silently shipped `{force: , user_id: }` (the auto-attach default) instead of the explicit `{delete_force: }` map. **After this fix**, the renamed key reaches the wire. Regression tests in `TestAutoAttachChainWalk` (4 cases): bare auto-attach, explicit tool_params map preserved, explicit money_outflow preserved, circular-`__wrapped__` defensive bounded walk. +- **`_enforce_sensitive_tool` dispatch handles both extractor types** — `decorators.py:677` (success path) and `decorators.py:711` (error path) now branch by extractor type. NR-B003 error hint text branches too — operators writing ToolParameters rules see "did you mean `impact=tool_params(...)`?" while money operators see the money remediation advice. +- **Bare `@sensitive` regression in the existing `tests/test_sensitive_extractor.py`** — the 5 existing tests still pass because they register the tool manually via `rt.add_sensitive_tool(name)`, which bypasses the decorator auto-attach path. Documented as a deliberate carve-out: only `@sensitive` (the decorator form) auto-attaches. + +### Tests + +- `tests/test_tool_params_extractor.py` — **23 new tests** across 5 classes (`TestToolParamsFactory`, `TestToolParamsExtraction`, `TestAutoAttachOnBareSensitive`, `TestToolCallParamsShape`, `TestAutoAttachChainWalk`). Covers factory shape (3), three extraction modes (4), PII sentinel + float filtering (3), action digest byte-identity with the backend's canonical JSON (1), the auto-attach wiring (2), dataclass validator (7), kind dispatch (1), and the chain-walk regression (4). Verified: 23/23 pass. +- `tests/test_business_impact.py::TestToolCallActionDigestPins` — **5 new tests** cross-language parity for the `ToolCall` impact, pinned to the same hex literal the Rust backend pins in `backend/src/proxy/gate/business_impact.rs::tests::tool_call_digest_golden_value_stripe_charge_500`. A drift on either side trips the test on the other side next time the suite runs. Fixture payload: `tool_call("stripe.charge", {"region": "EU", "amount": 500})` → `9975a8b75a436fb78b9d141b9e0c0a90838c1243d78119b304ae6ed0526966a6`. +- `tests/test_sensitive_extractor.py` — 5/5 pass (regression check, the auto-attach wiring is additive on top of 0.14.1). +- `tests/test_business_impact.py` — full class passes (28/28 including the 5 new parity pins). +- `tests/test_extractors.py` — 35/35 pass. +- `tests/test_protect.py + test_protect_branches.py + test_execute_approval_flow.py + test_approval_money_flow.py + test_gate_real_path.py + test_handle.py` — 99/99 pass. +- `tests/test_runtime.py + test_runtime_branches.py + test_init_contract.py` — 70/70 pass (1 skipped, pre-existing). + +### Compatibility + +- **Default SDK behaviour for bare `@sensitive` CHANGED** — was `no business_impact on wire`, now `kind=tool_call on wire`. Operators who relied on the Phase 0 path (approval_id-only grant consume) must either pass `@sensitive(impact=tool_params(include_all=False))` explicitly, or accept the new ToolParameters wire shape. The change is additive on the SDK side; legacy backends ignore `kind=tool_call` and fall through to a no-op. +- **Existing `@sensitive(impact=money_outflow(...))` callers are unaffected** — the explicit extractor wins over the auto-attach (verified by `test_explicit_money_outflow_chain_walk_preserved`). +- **Legacy "no impact extractor" call sites (registered via `rt.add_sensitive_tool(name)` directly) are unaffected** — the auto-attach is only wired through `_do_sensitive_register`, which only the `@sensitive` decorator calls. +- **No SDK_MIN_VERSION bump.** ToolParameters is an opt-in backend feature; SDK 0.14.4 talking to a backend that has the `BusinessImpact::ToolCall` variant (commit `1e501cd6` and later) is the supported path. SDK 0.14.4 talking to an older backend works but the `kind=tool_call` envelope is ignored — same effective behaviour as 0.14.3 minus the wire bytes. + +--- + ## [0.14.2] - 2026-07-24 Three hotfixes that fell out of the 0.14.1 demo run. Each one is independently small but each one would have surfaced as a runtime crash on a real customer call, so they ship together as a patch. No on-wire breaking change. No SDK_MIN_VERSION bump. Backends on `1.0.0` keep working unchanged. diff --git a/pyproject.toml b/pyproject.toml index d00abf1..8c49ea5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,7 +140,7 @@ name = "nullrun" # by ``@protect`` were missing ``tokens``/``execution_id`` so # the backend's SdkTrackRequest rejected them. See CHANGELOG.md # for the full per-commit description. -version = "0.14.2" +version = "0.14.4" # Kept under the 200-char preview threshold so the full line is visible # without an "expand" click. Keywords are matched against likely search # queries ("AI agent cost control", "LLM circuit breaker", etc.). diff --git a/src/nullrun/__version__.py b/src/nullrun/__version__.py index 3ed40e9..c270269 100644 --- a/src/nullrun/__version__.py +++ b/src/nullrun/__version__.py @@ -1,6 +1,7 @@ """NullRun Platform SDK. -v3.29 / 0.14.1 (2026-07-24) — Decimal JSON serialization patch. +v3.30 / 0.14.4 (2026-07-27) — ToolParameters Approval Rules +wire contract (Tier 2 / Разрыв 2 follow-up). Pre-fix 0.14.0, a ``track_tool`` event payload containing a ``Decimal`` (e.g. ``refund_amount`` from a @@ -1017,5 +1018,5 @@ """ -__version__ = "0.13.11" +__version__ = "0.14.4" __platform_version__ = "1.0.0" From ba8365a28f9789172534ceac06a9a29a50945d1d Mon Sep 17 00:00:00 2001 From: Anatolii Date: Mon, 27 Jul 2026 22:01:54 +0400 Subject: [PATCH 10/10] fix(sdk): ruff F541 -- drop extraneous f-prefixes on placeholder-less strings CI failed on the 0.14.4 PR (#80) at the lint step with ruff F541 (f-string without any placeholders) -- 14 errors in src/nullrun/decorators.py plus similar auto-fixable issues in the other 4 files I touched. Auto-fixed via: ruff check src/ tests/ --fix ruff format src/nullrun/decorators.py src/nullrun/business_impact.py src/nullrun/extractor.py src/nullrun/__version__.py tests/test_tool_params_extractor.py tests/test_business_impact.py What changed (no behavioural change, just syntax): * src/nullrun/decorators.py: f"..." -> "..." on the MoneyImpactExtractor / ToolParamsExtractor / fallback hint literals in _enforce_sensitive_tool (the @sensitive error path). The strings had no {}-placeholders so the f-prefix was dead syntax that ruff F541 (selected by the default rule set in pyproject.toml) flagged. * src/nullrun/business_impact.py: import-order normalisation in extractor.py imports + format-only whitespace tidy. * src/nullrun/extractor.py: same import-order + format tidy. * tests/test_tool_params_extractor.py: import block was un-sorted (I001 fixable). Re-ordered to ruff convention. * tests/test_business_impact.py: format-only whitespace tidy from the cross-language parity pin (f608414). Verification: ruff check src/ tests/ -> All checks passed! ruff format --check -> 6 files already formatted pytest tests/ --ignore=tests/contract -> 1382 passed, 7 skipped This is exactly the fix 4c143e2 (the 0.14.2 release) called out as 'deferred to a separate PR' for its own touch surface: 'ruff format on the three source files touched by this release. The format-only reformat of the 65 unrelated files is intentionally deferred to a separate PR.' I am doing the same scope discipline here -- only the files I authored or modified for 0.14.4, not the 67 pre-existing format-debt files (those are a separate PR). Refs: Failing CI: PR #80, run 30291404693 (test 3.11 + coverage) ToolParameters phase 1: 40d391a Cross-language parity pin: f608414 Release 0.14.4: 332acfb Master merge: 3471bc4 --- src/nullrun/business_impact.py | 28 ++++----------- src/nullrun/decorators.py | 33 ++++++++--------- src/nullrun/extractor.py | 31 ++++++---------- tests/test_business_impact.py | 9 ++--- tests/test_tool_params_extractor.py | 55 +++++++++++++---------------- 5 files changed, 59 insertions(+), 97 deletions(-) diff --git a/src/nullrun/business_impact.py b/src/nullrun/business_impact.py index ba5864c..e96625c 100644 --- a/src/nullrun/business_impact.py +++ b/src/nullrun/business_impact.py @@ -95,21 +95,12 @@ def validate(self) -> None: backend's `MoneyImpact::validate()` mirrors these checks. """ if self.direction not in (OUTFLOW, INFLOW): - raise ValueError( - f"direction must be {OUTFLOW!r} or {INFLOW!r}, " - f"got {self.direction!r}" - ) - if not isinstance(self.amount_minor, int) or isinstance( - self.amount_minor, bool - ): + raise ValueError(f"direction must be {OUTFLOW!r} or {INFLOW!r}, got {self.direction!r}") + if not isinstance(self.amount_minor, int) or isinstance(self.amount_minor, bool): # bool is a subclass of int in Python — explicit exclude. - raise ValueError( - f"amount_minor must be int, got {type(self.amount_minor).__name__}" - ) + raise ValueError(f"amount_minor must be int, got {type(self.amount_minor).__name__}") if self.amount_minor < 0: - raise ValueError( - f"amount_minor must be non-negative, got {self.amount_minor}" - ) + raise ValueError(f"amount_minor must be non-negative, got {self.amount_minor}") if ( not isinstance(self.currency, str) or len(self.currency) != 3 @@ -117,8 +108,7 @@ def validate(self) -> None: or not self.currency.isupper() ): raise ValueError( - f"currency must be a 3-letter uppercase ISO-4217 code, " - f"got {self.currency!r}" + f"currency must be a 3-letter uppercase ISO-4217 code, got {self.currency!r}" ) def to_wire_dict(self) -> dict[str, Any]: @@ -187,16 +177,12 @@ def validate(self) -> None: if not isinstance(self.tool_name, str) or not self.tool_name: raise ValueError("tool_name must be a non-empty string") if len(self.tool_name) > 128: - raise ValueError( - f"tool_name length {len(self.tool_name)} exceeds max 128" - ) + raise ValueError(f"tool_name length {len(self.tool_name)} exceeds max 128") if not self.tool_name.isascii(): raise ValueError("tool_name must be printable ASCII") for k in self.params: if not isinstance(k, str): - raise ValueError( - f"params key {k!r} must be a string" - ) + raise ValueError(f"params key {k!r} must be a string") if len(k) > TOOL_PARAMETERS_MAX_PARAM_NAME: raise ValueError( f"params['{k}'] key length {len(k)} exceeds " diff --git a/src/nullrun/decorators.py b/src/nullrun/decorators.py index f652f50..7330dca 100644 --- a/src/nullrun/decorators.py +++ b/src/nullrun/decorators.py @@ -704,32 +704,32 @@ def _enforce_sensitive_tool( # (or the include_all flag if no map was supplied). if isinstance(extractor, MoneyImpactExtractor): hint = ( - f"could not extract a MoneyImpact from the live " - f"arguments. Check that the function declares the " - f"argument named in `impact=money_outflow(...)`." + "could not extract a MoneyImpact from the live " + "arguments. Check that the function declares the " + "argument named in `impact=money_outflow(...)`." ) elif isinstance(extractor, ToolParamsExtractor): if extractor.param_extractors is not None: hint = ( - f"could not extract a ToolCall impact from the " - f"live arguments. Check that the function " - f"declares every arg named in " - f"`impact=tool_params(...)`." + "could not extract a ToolCall impact from the " + "live arguments. Check that the function " + "declares every arg named in " + "`impact=tool_params(...)`." ) else: hint = ( - f"could not extract a ToolCall impact from the " - f"live arguments. The @sensitive tool's kwargs " - f"could not be validated for wire emission " - f"(unsupported types or invalid param keys)." + "could not extract a ToolCall impact from the " + "live arguments. The @sensitive tool's kwargs " + "could not be validated for wire emission " + "(unsupported types or invalid param keys)." ) else: # Defensive fallback for a future extractor type # that doesn't update this hint. hint = ( - f"could not extract business_impact from the live " - f"arguments. Check the @sensitive decorator's " - f"`impact=...` argument." + "could not extract business_impact from the live " + "arguments. Check the @sensitive decorator's " + "`impact=...` argument." ) err = NullRunBlockedException( workflow_id=workflow_id, @@ -738,10 +738,7 @@ def _enforce_sensitive_tool( ), tool_name=fn.__name__, error_code="NR-B003", - user_action=( - f"The @sensitive decorator on {fn.__name__!r} " - f"{hint}" - ), + user_action=(f"The @sensitive decorator on {fn.__name__!r} {hint}"), ) runtime._emit_sdk_error( err, diff --git a/src/nullrun/extractor.py b/src/nullrun/extractor.py index 6a99f7e..e8ad150 100644 --- a/src/nullrun/extractor.py +++ b/src/nullrun/extractor.py @@ -440,9 +440,7 @@ def business_cap_minor(currency: str) -> int: return _BUSINESS_CAP_MINOR[normalize_currency(currency)] -def _decimal_has_more_fractional_digits( - value: Decimal, allowed: int -) -> bool: +def _decimal_has_more_fractional_digits(value: Decimal, allowed: int) -> bool: """Return True iff ``value`` has more fractional digits than ``allowed``. @@ -493,9 +491,7 @@ def _check_overflow(amount_minor: int, currency: str) -> None: ) -def _check_business_cap( - amount_minor: int, currency: str, enforce: bool -) -> None: +def _check_business_cap(amount_minor: int, currency: str, enforce: bool) -> None: """Raise ``InvalidMoneyAmountError`` if ``amount_minor`` exceeds the per-currency business cap. @@ -522,7 +518,9 @@ def _check_business_cap( def _to_minor_units( - value: int | Decimal, units: str, currency: str, + value: int | Decimal, + units: str, + currency: str, enforce_business_cap: bool = True, ) -> int: """Convert a Decimal-or-int value to integer minor units. @@ -601,9 +599,7 @@ def _to_minor_units( _check_business_cap(converted, currency, enforce_business_cap) return converted - raise ValueError( - f"unknown units={units!r}; expected one of: {UNITS}" - ) + raise ValueError(f"unknown units={units!r}; expected one of: {UNITS}") class MoneyImpactExtractor: @@ -645,14 +641,9 @@ def __init__( enforce_business_cap: bool = True, ) -> None: if direction not in (OUTFLOW, INFLOW): - raise ValueError( - f"direction must be {OUTFLOW!r} or {INFLOW!r}, " - f"got {direction!r}" - ) + raise ValueError(f"direction must be {OUTFLOW!r} or {INFLOW!r}, got {direction!r}") if units not in UNITS: - raise ValueError( - f"units must be one of {UNITS}, got {units!r}" - ) + raise ValueError(f"units must be one of {UNITS}, got {units!r}") # ``normalize_currency`` raises ``InvalidCurrencyError`` # if the input is not a 3-letter uppercase ISO-4217 # code in the whitelist. The constructor fails-CLOSED: @@ -868,8 +859,7 @@ def __init__( # almost certainly a typo -- fail-CLOSED at decorator- # application time rather than silently dropping rules. raise ValueError( - "ToolParamsExtractor: param_extractors and " - "include_all=False are mutually exclusive" + "ToolParamsExtractor: param_extractors and include_all=False are mutually exclusive" ) self.param_extractors = param_extractors self.include_all = include_all @@ -1045,4 +1035,5 @@ def compute_impact_digest(impact: BusinessImpact) -> str: def gc_get_objects() -> list[Any]: import gc - return gc.get_objects() \ No newline at end of file + + return gc.get_objects() diff --git a/tests/test_business_impact.py b/tests/test_business_impact.py index 54afa14..a3728f9 100644 --- a/tests/test_business_impact.py +++ b/tests/test_business_impact.py @@ -180,9 +180,7 @@ def test_wire_dict_round_trips_through_json(self) -> None: # --------------------------------------------------------------------------- -def _refund_customer_func( - amount_cents: int, customer_id: str = "c-1" -) -> dict: +def _refund_customer_func(amount_cents: int, customer_id: str = "c-1") -> dict: """Stand-in for a user-decorated tool. Mirrors the shape of ``refund_customer(amount_cents=..., customer_id=...)`` that ``test_approval_money_flow.py::TestExtractor`` exercises.""" @@ -311,10 +309,7 @@ def test_tool_call_stripe_charge_500_matches_golden_hex(self) -> None: "stripe.charge", {"region": "EU", "amount": 500}, ) - assert ( - compute_action_digest(impact) - == GOLDEN_HEX_TOOL_CALL_STRIPE_CHARGE_500 - ), ( + assert compute_action_digest(impact) == GOLDEN_HEX_TOOL_CALL_STRIPE_CHARGE_500, ( "ToolCall digest drifted from the Rust golden pin; SDK " "and backend disagree on the same payload. See " "docs/runbooks/action-digest-contract.md BEFORE bumping " diff --git a/tests/test_tool_params_extractor.py b/tests/test_tool_params_extractor.py index 7a2c93b..4bbb682 100644 --- a/tests/test_tool_params_extractor.py +++ b/tests/test_tool_params_extractor.py @@ -38,8 +38,8 @@ from nullrun._registry import get_registry from nullrun.business_impact import ( KIND_TOOL_CALL, - BusinessImpact, TOOL_PARAMETERS_MAX_PARAM_NAME, + BusinessImpact, ToolCallParams, compute_action_digest, ) @@ -57,7 +57,6 @@ ) from nullrun.runtime import NullRunRuntime - # --------------------------------------------------------------------------- # Wire payload capture (same pattern as test_sensitive_extractor.py) # --------------------------------------------------------------------------- @@ -166,6 +165,7 @@ def test_include_all_true_captures_every_kwarg( wire under its own name, regardless of how many args the function has or what their types are. """ + def delete_user(user_id: int, force: bool = False) -> None: pass @@ -174,9 +174,7 @@ def delete_user(user_id: int, force: bool = False) -> None: fn._nullrun_extractor = ext _register_tool(captured_runtime, fn) - _enforce_sensitive_tool( - captured_runtime, fn, (), {"user_id": 42, "force": True} - ) + _enforce_sensitive_tool(captured_runtime, fn, (), {"user_id": 42, "force": True}) kwargs = captured_payload.last_kwargs assert kwargs is not None @@ -197,6 +195,7 @@ def test_explicit_map_only_captures_listed_kwargs( captured; everything else is dropped. The rule param name (key) and the function arg name (value) may differ. """ + def delete_user(uid: int, force: bool = False) -> None: pass @@ -225,6 +224,7 @@ def test_include_all_false_with_no_map_yields_empty_params( no args is still eligible for ToolCall-kind Approval Rules. """ + def list_accounts() -> None: pass @@ -254,6 +254,7 @@ def test_pii_masked_sentinel_is_dropped( ``"***"`` via ``_safe_kwargs`` BEFORE the extractor sees them; this test simulates that pre-masked state. """ + def charge_card(pan: str, amount: int) -> None: pass @@ -264,9 +265,7 @@ def charge_card(pan: str, amount: int) -> None: # pan was masked by the decorator's _safe_kwargs layer # before reaching the extractor. - _enforce_sensitive_tool( - captured_runtime, fn, (), {"pan": "***", "amount": 5000} - ) + _enforce_sensitive_tool(captured_runtime, fn, (), {"pan": "***", "amount": 5000}) impact = captured_payload.last_kwargs["business_impact"] assert "pan" not in impact["params"], ( @@ -295,6 +294,7 @@ def test_float_arg_is_silently_dropped( listing only the JSON-safe keys) is documented but opt-in: bare ``@sensitive`` drops ``float`` silently. """ + def set_rate(rate: float, count: int) -> None: pass @@ -304,9 +304,7 @@ def set_rate(rate: float, count: int) -> None: _register_tool(captured_runtime, fn) # Must NOT raise: float is filtered, int is captured. - _enforce_sensitive_tool( - captured_runtime, fn, (), {"rate": 1.5, "count": 7} - ) + _enforce_sensitive_tool(captured_runtime, fn, (), {"rate": 1.5, "count": 7}) impact = captured_payload.last_kwargs["business_impact"] assert "rate" not in impact["params"], ( @@ -332,6 +330,7 @@ def test_unsupported_type_is_silently_filtered_in_explicit_map( before calling the tool. Failing the whole call would punish the JSON-safe args. """ + def set_rate(rate: float, count: int) -> None: pass @@ -340,9 +339,7 @@ def set_rate(rate: float, count: int) -> None: fn._nullrun_extractor = ext _register_tool(captured_runtime, fn) - _enforce_sensitive_tool( - captured_runtime, fn, (), {"rate": 1.5, "count": 7} - ) + _enforce_sensitive_tool(captured_runtime, fn, (), {"rate": 1.5, "count": 7}) impact = captured_payload.last_kwargs["business_impact"] # rate was filtered; count survived. @@ -357,6 +354,7 @@ def test_action_digest_matches_sdk_computation( digest-bound approval row rejects every legitimate post-approval re-check. """ + def delete_user(user_id: int, force: bool = False) -> None: pass @@ -365,9 +363,7 @@ def delete_user(user_id: int, force: bool = False) -> None: fn._nullrun_extractor = ext _register_tool(captured_runtime, fn) - _enforce_sensitive_tool( - captured_runtime, fn, (), {"user_id": 42, "force": True} - ) + _enforce_sensitive_tool(captured_runtime, fn, (), {"user_id": 42, "force": True}) kwargs = captured_payload.last_kwargs impact = BusinessImpact.tool_call( @@ -402,6 +398,7 @@ def test_bare_function_gets_toolparams_extractor( and the wire payload carries ``kind: tool_call`` with all kwargs as ``params``. """ + def delete_user(user_id: int, force: bool = False) -> None: pass @@ -414,16 +411,13 @@ def delete_user(user_id: int, force: bool = False) -> None: ext = getattr(delete_user, "_nullrun_extractor") assert isinstance(ext, ToolParamsExtractor), ( - f"bare @sensitive should auto-attach ToolParamsExtractor, " - f"got {type(ext).__name__}" + f"bare @sensitive should auto-attach ToolParamsExtractor, got {type(ext).__name__}" ) assert ext.include_all is True assert ext.param_extractors is None # Verify the wire payload uses the auto-attached extractor. - _enforce_sensitive_tool( - captured_runtime, delete_user, (), {"user_id": 42, "force": True} - ) + _enforce_sensitive_tool(captured_runtime, delete_user, (), {"user_id": 42, "force": True}) impact = captured_payload.last_kwargs["business_impact"] assert impact["kind"] == KIND_TOOL_CALL assert impact["params"] == {"user_id": 42, "force": True} @@ -436,6 +430,7 @@ def test_explicit_money_extractor_is_not_overwritten( MUST NOT overwrite the explicit extractor with the default ``ToolParamsExtractor``. Money semantics win. """ + def refund_customer(amount_cents: int, customer_id: str = "c-1") -> None: pass @@ -458,9 +453,7 @@ def refund_customer(amount_cents: int, customer_id: str = "c-1") -> None: assert isinstance(ext, MoneyImpactExtractor) # Verify the wire payload still uses money semantics. - _enforce_sensitive_tool( - captured_runtime, refund_customer, (5000,), {"customer_id": "c-1"} - ) + _enforce_sensitive_tool(captured_runtime, refund_customer, (5000,), {"customer_id": "c-1"}) impact = captured_payload.last_kwargs["business_impact"] assert impact["kind"] == "money" assert impact["amount_minor"] == 5000 @@ -509,9 +502,7 @@ def test_validate_rejects_overlong_tool_name(self) -> None: def test_validate_rejects_overlong_param_name(self) -> None: long_key = "x" * (TOOL_PARAMETERS_MAX_PARAM_NAME + 1) - p = ToolCallParams( - tool_name="x", params={long_key: 1} - ) + p = ToolCallParams(tool_name="x", params={long_key: 1}) with pytest.raises(ValueError) as exc_info: p.validate() assert "key length" in str(exc_info.value) @@ -550,9 +541,7 @@ def test_business_impact_kind_dispatch(self) -> None: assert m.kind == "money" assert m.to_wire_dict()["kind"] == "money" - t = BusinessImpact.tool_call( - tool_name="x", params={"y": 1} - ) + t = BusinessImpact.tool_call(tool_name="x", params={"y": 1}) assert t.kind == KIND_TOOL_CALL assert t.to_wire_dict()["kind"] == KIND_TOOL_CALL @@ -590,6 +579,7 @@ def test_bare_sensitive_chain_walk_attaches_default( """Bare ``@sensitive`` (no impact=...) walks the chain and finds NO extractor, so auto-attach stamps the default. """ + def tool_fn(user_id: int) -> None: pass @@ -616,6 +606,7 @@ def test_explicit_tool_params_chain_walk_preserves_map( "_nullrun_extractor", None)`` returned None even though the bare function carried the attribute. """ + def tool_fn(force: bool) -> None: pass @@ -646,6 +637,7 @@ def test_explicit_money_outflow_chain_walk_preserved( money variant must NOT be overwritten by the Tier 2 auto-attach). """ + def tool_fn(amount_cents: int) -> None: pass @@ -668,6 +660,7 @@ def test_chain_walk_does_not_loop_on_circular_wraps( 3-call cycle and verify the walk returns None within the bounded hop count. """ + # Build a self-referential __wrapped__ cycle. class Cycle: def __init__(self) -> None: