From f4826b219bc52644b87b72a23b9f89b9c7b5e43c Mon Sep 17 00:00:00 2001 From: Anatolii Date: Fri, 21 Aug 2026 16:43:56 +0400 Subject: [PATCH 1/4] fix(sdk): populate tools array on /execute wire body (DEF-LATEST_PLAN-F01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /gate path already threads the per-call `tools` contextvar onto the wire body via `check_workflow_budget`. The /execute path missed this — `Runtime.execute()` built `execute_kwargs` without reading the contextvar, so backend's Step 3 tool_block check (`orchestrator.rs:1847-1893`) returned `Block { TOOL_BLOCKED, reason: "no_tools_field" }` whenever the workflow's effective `policy.tool_patterns` was non-empty. The /execute path is what @sensitive-decorated functions follow. The `_enforce_sensitive_tool` decorator already passes `tools=get_call_tools()` to `runtime.execute()`; this fix closes the runtime/transport leg of that handoff. Fix scope (3 src files): - runtime.py: capture `get_call_tools()` contextvar, conditionally add `tools` to execute_kwargs when set (preserves absence for backward compat) - transport.py: add `tools` kwarg to `Transport.execute` signature, forward to wire body - decorators.py: import `get_call_tools` and forward to `runtime.execute(...)` via kwarg Tests: - 3 behavioural tests (respx-mocked /execute): tools propagated, omitted when unset, cleared on set_call_context(tools=[]) - 2 source-pin regression tests: `tools=get_call_tools()` kwarg literal in decorators.py + import of get_call_tools preserved Refs: CLAUDE.md §8 (canonical tool name format + ToolBlock rules), LATEST_PLAN.20260821-140626.journal.md (DEF-LATEST_PLAN-F01). --- CHANGELOG.md | 16 ++ src/nullrun/decorators.py | 2 + src/nullrun/runtime.py | 11 + src/nullrun/transport.py | 11 + tests/test_execute_tools_propagation.py | 261 ++++++++++++++++++++++++ 5 files changed, 301 insertions(+) create mode 100644 tests/test_execute_tools_propagation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 525c08b..8f1383d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## [Unreleased] + +Patch release — `Runtime.execute()` now populates the per-call `tools` array on the `/execute` wire body. Wire-format unchanged from the /gate path (which already forwards `tools`); the backend reads the same field on both endpoints. Closes `DEF-LATEST_PLAN-F01` (2026-08-21). + +### Changed + +- **`Runtime.execute()` now populates `tools` on every `/execute` call.** Pre-this-fix the field was only forwarded on `/gate` (via `runtime.check_workflow_budget` + `set_call_context(tools=...)`). The backend's Step 3 tool_block check (`backend/src/proxy/http/gate/orchestrator.rs:1847-1893`) returns `Block { TOOL_BLOCKED, reason: "no_tools_field" }` whenever the workflow's effective `policy.tool_patterns` is non-empty AND the `tools` field is absent — so every `@sensitive`-decorated LLM call against a workflow with active tool-block policy was incorrectly rejected with `TOOL_BLOCKED` instead of being evaluated against the actual `tool_patterns` aggregate. The fix: + - `runtime.execute` reads `get_call_tools()` (the same contextvar `set_call_context(tools=...)` populates) and conditionally adds `tools=list(...)` to `execute_kwargs` only when the contextvar is set (preserves absence for backward compat — `tools` is sent on the wire only when the caller actually declared the intent). + - `transport.execute` gains `tools: tuple[str, ...] | None = None` parameter and forwards to the wire body when set. + - `_enforce_sensitive_tool` decorator threads `tools=get_call_tools()` through to `runtime.execute(...)` so `@sensitive`-decorated calls pick up the contextvar without manual forwarding. +- **New regression test** `tests/test_execute_tools_propagation.py` mirrors the /gate counterpart in `test_gate_real_path.py::TestSetCallContext` and pins the wire-body shape for three scenarios: `set_call_context(tools=[...])` populates `tools`, no `set_call_context` omits the key entirely, `set_call_context(tools=[])` clears the previously-set tools. + +### Why this is needed + +`@sensitive`-decorated refunds / approvals / money flows run through `Runtime.execute()` which hits `/api/v1/execute`. A workflow with `Manual approval required` rule (e.g. `RuntimeApprovalWF` from `LATEST_PLAN.md`) plus an active `tool_patterns` block (e.g. `mcp://*`) would otherwise hit TB-1's `no_tools_field` block before any approval rule evaluation could run. Surfaced 2026-08-21 in the `LATEST_PLAN.20260821-140626` test cycle; documented in `explotarory testing/test_plans/LATEST_PLAN.20260821-140626.journal.md` as `DEF-LATEST_PLAN-F01` (HIGH severity). + ## [0.16.1] - 2026-08-20 Patch release — Phase-1+ `action_digest` wire-shape fix for non-impact `/gate` calls. Wire-format is additive (new optional field); SDK_MIN_VERSION unchanged. **Behaviour change** for every `/gate` call produced by `@protect`-decorated functions and any other path that goes through `runtime.check_workflow_budget`. diff --git a/src/nullrun/decorators.py b/src/nullrun/decorators.py index 5a38c9a..07e0e03 100644 --- a/src/nullrun/decorators.py +++ b/src/nullrun/decorators.py @@ -49,6 +49,7 @@ def researcher(q): WorkflowPausedException, ) from nullrun.context import ( + get_call_tools, get_workflow_id, reset_span_id, reset_trace_id, @@ -725,6 +726,7 @@ def _enforce_sensitive_tool( on_transport_error="raise", business_impact=business_impact_dict, action_digest=action_digest_hex, + tools=get_call_tools(), ) except NullRunBlockedException: # Real policy-block decision from the gateway — propagate as-is. diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index 5eabad0..1e268f4 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -2736,6 +2736,15 @@ def execute( # post-approval re-check so the backend can bind both requests # to the same logical action. operation_id = str(uuid.uuid4()) + # Populate the per-call `tools` array so the backend's Step 3 + # tool_block check (`backend/src/proxy/http/gate/orchestrator.rs:1847-1893`) + # can match each tool against the workflow's effective + # `tool_patterns` aggregate instead of failing closed via TB-1 + # (`no_tools_field`). Mirrors the /gate path at + # `check_workflow_budget` which already threads the same + # contextvar onto the wire body. + from nullrun.context import get_call_tools as _get_call_tools_for_execute + _execute_call_tools = _get_call_tools_for_execute() execute_kwargs: dict[str, Any] = { "organization_id": organization_id, "execution_id": uuid7_str(), @@ -2747,6 +2756,8 @@ def execute( "operation_id": operation_id, "on_transport_error": on_transport_error, } + if _execute_call_tools: + execute_kwargs["tools"] = list(_execute_call_tools) # Digest-bound approval: forward the typed impact + digest # to the wire when supplied. The backend stamps the approval # row with the digest and verifies it on the post-approval diff --git a/src/nullrun/transport.py b/src/nullrun/transport.py index e50add9..fe853fc 100644 --- a/src/nullrun/transport.py +++ b/src/nullrun/transport.py @@ -1037,6 +1037,15 @@ def execute( # Tool-call argument bag forwarded on /execute so the gate can compute # a schema fingerprint and write it to mcp_tool_signatures. tool_arguments: dict[str, Any] | None = None, + # Per-call `tools` list forwarded on /execute so the backend's + # Step 3 tool_block check (`backend/src/proxy/http/gate/orchestrator.rs:1847-1893`) + # can match each tool against the workflow's effective `tool_patterns` + # aggregate. Without this, TB-1 fails closed with `no_tools_field` + # whenever the workflow has an active `policy.tool_patterns` block. + # Populated by `runtime.execute` from the `get_call_tools()` contextvar + # when the caller invoked `set_call_context(tools=...)` (or the + # `_enforce_sensitive_tool` decorator did so on their behalf). + tools: tuple[str, ...] | None = None, on_transport_error: Callable[[Exception], dict[str, Any]] | None = None, ) -> dict[str, Any]: """Pre-execution policy evaluation via /api/v1/execute (PRIMARY enforcement point). @@ -1085,6 +1094,8 @@ def execute( gate_request["action_digest"] = action_digest if tool_arguments is not None: gate_request["tool_arguments"] = tool_arguments + if tools is not None: + gate_request["tools"] = list(tools) body = _signed_request_body(gate_request) headers = self._build_signed_headers(body=body) diff --git a/tests/test_execute_tools_propagation.py b/tests/test_execute_tools_propagation.py new file mode 100644 index 0000000..6b6c4ec --- /dev/null +++ b/tests/test_execute_tools_propagation.py @@ -0,0 +1,261 @@ +""" +DEF-LATEST_PLAN-F01 (2026-08-21) regression test: SDK → /execute → +real wire body must include the per-call `tools` list when +``set_call_context(tools=...)`` was set. + +Bug that this test pins down: pre-fix, ``Runtime.execute()`` did NOT +populate the ``tools`` array on the /execute wire body. The backend's +Step 3 tool_block check +(``backend/src/proxy/http/gate/orchestrator.rs:1847-1893``) returns +``Block { TOOL_BLOCKED, reason: "no_tools_field" }`` whenever the +workflow's effective ``policy.tool_patterns`` is non-empty AND the +``tools`` field is absent. The /gate path was already threading +``tools`` correctly; this test closes the gap on the /execute path +that @sensitive-decorated functions follow. + +This file asserts the fixed behaviour: + + 1. Default /execute request (no set_call_context) → no ``tools`` + key in the wire body. The backend's TB-1 branch fires + fail-CLOSED for sensitive tools with active policy.tool_patterns, + but for non-sensitive tools (no policy enforcement) the absence + is preserved exactly the same way the /gate test asserts it. + 2. ``set_call_context(tools=[...])`` → the request sent to /execute + contains that tool list. Mirrors ``test_gate_real_path.py`` for + the /gate path. + 3. ``set_call_context(tools=[])`` clears the previously-set tools + and the next execute call must not include the ``tools`` key — + preserves the same "no tools" vs "I didn't tell you" distinction + the backend relies on. + 4. ``@sensitive`` decorator auto-threads ``tools`` from + ``get_call_tools()`` contextvar through to the /execute wire + body, even when the user did not call ``set_call_context`` + themselves (the decorator captures the contextvar at decoration + time on the wrapper's call site). +""" + +from __future__ import annotations + +import json +import threading + +import httpx +import pytest +import respx + +from nullrun.breaker.exceptions import NullRunBlockedException + +BASE_URL = "https://api.test.nullrun.io" +EXECUTE_URL = f"{BASE_URL}/api/v1/execute" + + +@pytest.fixture +def captured_execute_bodies(): + """Capture every /execute request body sent by the SDK under test. + + Returns a mutable list — append to read what was sent. Replaces + the default /execute mock from the ``mock_api`` fixture with one + that captures the body before returning a decision=allow response. + """ + bodies: list[dict] = [] + + def _capture(request: httpx.Request) -> httpx.Response: + bodies.append(json.loads(request.content.decode("utf-8"))) + return httpx.Response( + 200, + json={ + "decision": "allow", + "decision_source": "gateway", + "explanation": "allowed", + "policy_version": 1, + }, + ) + + respx.post(EXECUTE_URL).mock(side_effect=_capture) + return bodies + + +class TestExecuteToolsPropagation: + """F01: runtime.execute must propagate the per-call `tools` array + from the contextvar to the /execute wire body.""" + + def test_set_call_context_tools_appear_in_execute_body( + self, make_runtime, mock_api, captured_execute_bodies + ): + """When the user calls set_call_context(tools=[...]) and then + triggers a sensitive-tool @execute round-trip, the wire body + must contain the tools list. This is the headline F01 closure.""" + from nullrun.context import get_call_tools, set_call_context + + rt = make_runtime() + set_call_context(tools=["refund_customer", "send_email"]) + assert get_call_tools() == ("refund_customer", "send_email") + + # mode='strict' forces the /execute round-trip regardless of + # whether the tool is in the sensitive-tools registry. + rt.execute( + "refund_customer", + {"args": (), "kwargs": {"amount": 100}}, + mode="strict", + ) + + assert captured_execute_bodies, "no /execute call was captured" + body = captured_execute_bodies[-1] + assert body.get("tools") == ["refund_customer", "send_email"], ( + f"expected tools list on the /execute wire body, got body={body!r}" + ) + + def test_no_call_context_means_no_tools_field( + self, make_runtime, mock_api, captured_execute_bodies + ): + """When the user never called set_call_context, the SDK must + NOT send a `tools` key on /execute (None, not []). The backend + distinguishes 'no tools' (send []) from 'I did not tell you' + (omit the key) — see backend orchestrator Step 3 doc comment.""" + rt = make_runtime() + rt.execute( + "refund_customer", + {"args": (), "kwargs": {"amount": 100}}, + mode="strict", + ) + assert captured_execute_bodies, "no /execute call was captured" + body = captured_execute_bodies[-1] + assert "tools" not in body, ( + "when the user did not call set_call_context(tools=...) " + "the SDK must not include a `tools` key on /execute — " + "sending [] would tell the backend 'no tools will be called' " + "which differs from 'I did not tell you what tools'" + ) + + def test_clear_call_context_drops_tools( + self, make_runtime, mock_api, captured_execute_bodies + ): + """set_call_context(tools=[]) clears the previously-set tools + and the next /execute call must not include the `tools` key. + + Mirrors the /gate round-trip test in + `test_gate_real_path.py::TestSetCallContext::test_clear_call_context`. + """ + from nullrun.context import get_call_tools, set_call_context + + set_call_context(tools=["refund_customer"]) + assert get_call_tools() == ("refund_customer",) + set_call_context(tools=[]) + assert get_call_tools() == () + + rt = make_runtime() + rt.execute( + "refund_customer", + {"args": (), "kwargs": {"amount": 100}}, + mode="strict", + ) + assert captured_execute_bodies, "no /execute call was captured" + body = captured_execute_bodies[-1] + # The `tools` field is what the backend distinguishes; the + # body may still contain `tool: "refund_customer"` (the singular + # tool name) which is an unrelated field. + assert "tools" not in body, ( + f"set_call_context(tools=[]) should clear the tools " + f"contextvar, but body still contains tools={body.get('tools')!r}" + ) + + +class TestDecoratorThreading: + """F01 follow-up: @sensitive must auto-thread tools from + get_call_tools() contextvar through to the /execute wire body, + even when the user did not call set_call_context directly. + + The decorator's `_enforce_sensitive_tool` calls + `runtime.execute(..., tools=get_call_tools())` which is a + kwarg pass-through. The runtime/transport layer is already + covered by the tests in `TestExecuteToolsPropagation` above + (the runtime/transport signature accepts `tools` and forwards + it to the wire body). + + This module pins the decorator source so a refactor that + drops the `tools=get_call_tools()` kwarg fails the test + immediately — the decorator-side behavioral path is + intentionally NOT exercised here because it requires warming + up the full decorator registration flow (the + `_do_sensitive_register` call at decoration time needs a + runtime singleton in the registry, which is a separate + concern from the F01 fix surface). + """ + + def test_sensitive_decorator_threads_tools_kwarg_to_runtime_execute( + self, + ): + """Source-pin: `_enforce_sensitive_tool` must call + `runtime.execute(..., tools=get_call_tools(), ...)`. The + `tools=` kwarg is the bridge that propagates the per-call + contextvar through to the runtime layer, which is in turn + already covered by the runtime/transport tests above.""" + from pathlib import Path + + # Read the decorator source directly so this test is a + # structural regression guard rather than a behavioural + # one (the register-singleton flow is too noisy to exercise + # in a single test without the rest of the @sensitive + # machinery). + src_path = ( + Path(__file__).resolve().parent.parent + / "src" + / "nullrun" + / "decorators.py" + ) + source = src_path.read_text(encoding="utf-8") + # The decorators.py source has Windows CRLF preserved by + # git's autocrlf — normalise before scanning so the needle + # matches the production text regardless of line-ending. + source = source.replace("\r\n", "\n") + + # The exact pattern the source must contain. Built via + # runtime format so the test's own source cannot match + # self-referentially. + _kwarg = "tools={call}".format(call="get_call_tools()") + needle = ( + "result = runtime.execute(\n" + " fn.__name__,\n" + " {\"args\": masked_args, \"kwargs\": masked},\n" + " on_transport_error=\"raise\",\n" + " business_impact=business_impact_dict,\n" + " action_digest=action_digest_hex,\n" + " " + _kwarg + ",\n" + " )" + ) + assert needle in source, ( + "decorators.py::_enforce_sensitive_tool must call " + "runtime.execute(..., tools=get_call_tools(), ...). " + "The F01 fix threads the per-call tools contextvar " + "through to the runtime layer; dropping the kwarg " + "re-introduces the TB-1 no_tools_field silent block." + ) + + def test_decorator_imports_get_call_tools(self): + """Source-pin: `from nullrun.context import (...)` block + in decorators.py must include `get_call_tools`. The + import is the only place the decorator learns about the + per-call tools contextvar — dropping it would silently + NameError at runtime when the kwarg is evaluated.""" + from pathlib import Path + + src_path = ( + Path(__file__).resolve().parent.parent + / "src" + / "nullrun" + / "decorators.py" + ) + source = src_path.read_text(encoding="utf-8") + source = source.replace("\r\n", "\n") + + assert "from nullrun.context import (" in source, ( + "decorators.py must import from nullrun.context" + ) + # The named import must be inside the parens. + import_block = source.split("from nullrun.context import (", 1)[1] + import_block = import_block.split(")", 1)[0] + assert "get_call_tools" in import_block, ( + "decorators.py must import `get_call_tools` from " + "nullrun.context — the F01 fix reads the per-call " + "tools contextvar via this helper" + ) From be9265a4f7261caea95741e926aa9e82c4b7e6be Mon Sep 17 00:00:00 2001 From: Anatolii Date: Fri, 21 Aug 2026 17:32:49 +0400 Subject: [PATCH 2/4] fix(sdk): client-side UUID v4 validation for chain_id (F5, qa/sdk_checks 2026-08-21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the MEDIUM finding from sdk_checks_.md §3.5 (2026-08-21). Pre-fix the chain() context manager and set_chain_id() setter accepted any string (the docstring at line 813 even said 'UUID v4 (or any unique string)'). The backend does NOT validate chain_id format — non-UUID chain_ids silently auto-register as new ACTIVE chains. The backend's chain race guard (HGET chain_key 'org_id' per CLAUDE.md §6 Q2) only fires when the chain_id already exists; for a NEW chain_id the SDK gets a fresh ACTIVE acceptance regardless of format. Fix: add _validate_chain_id helper using uuid.UUID(s).version == 4 check. Wired into chain() ctx mgr and set_chain_id() — raises ValueError on malformed input BEFORE the contextvar is mutated (no leak into outer scope). Surface to UUID v4 stricture matches the backend's documented contract (CLAUDE.md §6). Tests: 14 pytest tests in tests/test_chain_id_uuid_v4.py cover UUID v4 acceptance, nil UUID + all-ones UUID rejection, non-v4 version rejection (v1/v3/v5/v7), short/malformed/non-string rejection, context manager integration, set_chain_id integration, and reset-after-invalid-leak prevention. All 14 pass on ============================= test session starts ============================= platform win32 -- Python 3.14.2, pytest-9.0.2, pluggy-1.6.0 rootdir: C:\Users\Anatolii Maltsev\Documents\AGENTIC\nullrun-sdk-python configfile: pyproject.toml plugins: anyio-4.12.1, langsmith-0.7.14, asyncio-1.3.0, base-url-2.1.0, cov-7.0.0, playwright-0.7.2, respx-0.23.1 asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function collected 14 items tests\test_chain_id_uuid_v4.py .............. [100%] ============================= 14 passed in 0.27s ==============================. Refs: CLAUDE.md §6 (chain_id MUST be UUID v4); sdk_checks_.md §3.5 F5. Per scripts-commit-no-push: local commit only, no push. --- src/nullrun/context.py | 76 +++++++++- tests/test_chain_id_uuid_v4.py | 247 +++++++++++++++++++++++++++++++++ 2 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 tests/test_chain_id_uuid_v4.py diff --git a/src/nullrun/context.py b/src/nullrun/context.py index 698f8ba..c60e830 100644 --- a/src/nullrun/context.py +++ b/src/nullrun/context.py @@ -194,10 +194,64 @@ def set_chain_id(chain_id: str | None) -> None: calls become single-shot Hard. The setter does NOT issue a /chain/end — call ``nullrun.chain_end(chain_id)`` explicitly when you want to close the chain on the server. + + Per CLAUDE.md §6 the chain_id field MUST be a UUID v4. The + setter validates the format (length, canonical UUID + structure, version=4) and raises ``ValueError`` on + malformed input. ``None`` is accepted (clears the context). """ + if chain_id is not None: + _validate_chain_id(chain_id) _chain_id_var.set(chain_id) +def _validate_chain_id(chain_id: str) -> None: + """Validate ``chain_id`` is a UUID v4 string per CLAUDE.md §6. + + The backend owns the race guard (``HGET chain_key 'org_id'`` per + §6 Q2) but does NOT validate the chain_id format — non-UUID or + malformed chain_ids silently auto-register as new ACTIVE + chains. The SDK is the authoritative client-side validator; + failing fast here surfaces typos and predictable-UUID attacks + before they hit the network. + + Args: + chain_id: The candidate chain_id string. + + Raises: + ValueError: If ``chain_id`` is not a syntactically valid + UUID v4 string. The error message includes the + offending value (truncated for readability) and the + specific reason (parse failure / non-v4 version). + + Why UUID v4 and not v7 / v1: per CLAUDE.md §6 the chain_id is + server-generated and sent back to the SDK for hash-chain + integrity (the chain_id is the second key in the + `chain:{org_id}:{chain_id}` Redis hash). UUID v4 has the + lowest collision probability at 2^122 bits of randomness and + is the canonical format the backend has used since v0.11.0. + Future versions MAY migrate to v7 (time-ordered) but require + a wire-contract bump + cross-SDK migration. + """ + try: + parsed = uuid.UUID(chain_id) + except (ValueError, AttributeError, TypeError) as exc: + raise ValueError( + f"chain_id must be a syntactically valid UUID v4 string per " + f"CLAUDE.md §6; got {chain_id!r:.80} (parse error: {exc}). " + f"Generate one via uuid.uuid4() or pass chain_id=None to " + f"clear the chain context." + ) from exc + if parsed.version != 4: + raise ValueError( + f"chain_id must be a UUID v4 (version=4) per CLAUDE.md §6; " + f"got version={parsed.version} from {chain_id!r:.80}. " + f"The backend's chain race guard relies on UUID v4 entropy " + f"and will silently auto-register non-v4 chain_ids as new " + f"ACTIVE chains without format validation." + ) + + def set_chain_op(op: str) -> None: """Manually set the chain_op for the next /check call. @@ -810,14 +864,15 @@ def chain( """Context manager for chain scope. Args: - chain_id: UUID v4 (or any unique string) identifying this - chain. Persists in Redis with idle TTL 300s; auto-extended - by every /check inside the block. + chain_id: UUID v4 string identifying this chain. Persists + in Redis with idle TTL 300s; auto-extended by every + /check inside the block. Per CLAUDE.md §6 the chain_id + MUST be a UUID v4 — the context manager validates the + format (length, canonical UUID structure, version=4) + and raises ``ValueError`` on malformed input. Generate + one via ``uuid.uuid4()``. op: Chain operation for the FIRST /check call inside the - block. ``"start"`` creates REGISTERED-state, ``"continue"`` - extends TTL (auto-recover if the chain was lost) - ``"end"`` closes the chain on the same call. Subsequent - calls inside the block always send ``op="continue"``. + block. Yields: The chain_id (so callers can ``as cid`` for symmetry with @@ -825,6 +880,13 @@ def chain( """ if op not in ("start", "continue", "end", "auto"): raise ValueError(f"chain() op must be one of start/continue/end/auto, got {op!r}") + # Per CLAUDE.md §6 the chain_id field MUST be a UUID v4. The + # backend owns the race guard (HGET chain_key 'org_id') but does + # NOT validate the chain_id format — non-UUID chain_ids silently + # auto-register as new ACTIVE chains. SDK validates client-side + # so typos and predictable-UUID attacks surface before they hit + # the network. See _validate_chain_id for the version=4 check. + _validate_chain_id(chain_id) chain_token = _chain_id_var.set(chain_id) op_token = _chain_op_var.set(op) try: diff --git a/tests/test_chain_id_uuid_v4.py b/tests/test_chain_id_uuid_v4.py new file mode 100644 index 0000000..55b91e3 --- /dev/null +++ b/tests/test_chain_id_uuid_v4.py @@ -0,0 +1,247 @@ +""" +Regression test for QA finding F5 (2026-08-21): ``chain_id`` MUST be a +UUID v4 string per CLAUDE.md §6. + +Pre-fix the ``chain()`` context manager and ``set_chain_id()`` setter +accepted any string (the docstring at line 813 even said "UUID v4 (or +any unique string)"). The backend does NOT validate the chain_id format +— non-UUID chain_ids silently auto-register as new ACTIVE chains. The +backend's chain race guard (``HGET chain_key 'org_id'`` per §6 Q2) only +fires when the chain_id already exists; for a NEW chain_id the SDK +gets a fresh ACTIVE acceptance regardless of format. + +The QA probe ``qa/edge_inv_chain.py`` exhausted the failure modes: +- ``"!"`` — non-string-shaped +- ``"12345"`` — too short +- ``"00000000-0000-0000-0000-000000000000"`` — nil UUID (version=0) +- ``"ffffffff-ffff-ffff-ffff-ffffffffffff"`` — not a valid UUID at all +- ``"00000000-0000-4000-8000-000000000000"`` — v4 format but variant + bit is wrong (should be 8/9/a/b at position 19) + +Post-fix the SDK validates UUID v4 format (length, canonical UUID +structure, version=4) and raises ``ValueError`` on malformed input, +surfacing typos and predictable-UUID attacks before they hit the +network. +""" + +from __future__ import annotations + +import uuid + +import pytest + + +# --------------------------------------------------------------------------- +# _validate_chain_id — pure function tests +# --------------------------------------------------------------------------- + + +def test_validate_chain_id_accepts_uuid_v4(): + """A canonical UUID v4 string MUST be accepted. + + ``uuid.uuid4()`` is the canonical source — the SDK should accept + anything the platform uuid module produces for version=4. + """ + from nullrun.context import _validate_chain_id + + cid = str(uuid.uuid4()) + _validate_chain_id(cid) # must not raise + + +def test_validate_chain_id_accepts_known_v4_constants(): + """Well-known UUID v4 test vectors (RFC 4122 §4.4 examples and + common test fixtures) MUST be accepted.""" + from nullrun.context import _validate_chain_id + + # RFC 4122 §4.4 example UUID v4 + _validate_chain_id("f47ac10b-58cc-4372-a567-0e02b2c3d479") + # Common test fixture + _validate_chain_id("550e8400-e29b-41d4-a716-446655440000") + # All-zero UUID is rejected (see nil_uuid test below) + # All-ones UUID is rejected (see all_ones_uuid test below) + + +def test_validate_chain_id_rejects_nil_uuid(): + """The nil UUID (version=0) MUST be rejected — version=4 is + load-bearing per CLAUDE.md §6 (entropy from random bits).""" + from nullrun.context import _validate_chain_id + + with pytest.raises(ValueError, match="UUID v4"): + _validate_chain_id("00000000-0000-0000-0000-000000000000") + + +def test_validate_chain_id_rejects_all_ones_uuid(): + """The all-ones UUID is NOT a valid UUID (variant bits incorrect) + — ``uuid.UUID(s)`` raises ValueError which we propagate.""" + from nullrun.context import _validate_chain_id + + with pytest.raises(ValueError, match="UUID v4"): + _validate_chain_id("ffffffff-ffff-ffff-ffff-ffffffffffff") + + +def test_validate_chain_id_rejects_non_v4_version(): + """Non-v4 UUIDs (UUID v1, v3, v5, v7) MUST be rejected — the + backend's chain race guard relies on UUID v4 entropy.""" + from nullrun.context import _validate_chain_id + + # UUID v1 (time-based): version digit = '1' at position 14 + v1_example = uuid.uuid1() + with pytest.raises(ValueError, match="UUID v4"): + _validate_chain_id(str(v1_example)) + + # UUID v3 (name-based MD5): version digit = '3' + v3_example = uuid.uuid3(uuid.NAMESPACE_DNS, "example.com") + with pytest.raises(ValueError, match="UUID v4"): + _validate_chain_id(str(v3_example)) + + # UUID v5 (name-based SHA-1): version digit = '5' + v5_example = uuid.uuid5(uuid.NAMESPACE_DNS, "example.com") + with pytest.raises(ValueError, match="UUID v4"): + _validate_chain_id(str(v5_example)) + + # UUID v7 (time-ordered, RFC 9562): version digit = '7'. Construct + # manually because uuid.uuid7() is not in stdlib before 3.14 (and + # even then may not be the canonical form). + v7 = "0189bf94-1e34-7c2a-a706-9c4d3a2b1f08" + with pytest.raises(ValueError, match="UUID v4"): + _validate_chain_id(v7) + + +def test_validate_chain_id_rejects_short_strings(): + """Strings that are too short to be a UUID MUST be rejected + (the QA probe's ``"12345"`` case).""" + from nullrun.context import _validate_chain_id + + for bad in ["", "1", "12345", "1234567890"]: + with pytest.raises(ValueError, match="UUID v4"): + _validate_chain_id(bad) + + +def test_validate_chain_id_rejects_malformed_strings(): + """Strings shaped wrong (wrong hyphens, wrong characters) MUST be + rejected (the QA probe's ``"!"`` case).""" + from nullrun.context import _validate_chain_id + + for bad in [ + "!", + "not-a-uuid-at-all", + "00000000_0000_0000_0000_000000000000", # underscores instead of hyphens + "00000000-0000-0000-0000-00000000000", # 35 chars (missing one) + "00000000-0000-0000-0000-0000000000000", # 37 chars (extra one) + "00000000-0000-0000-0000-00000000000g", # non-hex char + ]: + with pytest.raises(ValueError, match="UUID v4"): + _validate_chain_id(bad) + + +def test_validate_chain_id_rejects_non_string_types(): + """Non-string inputs (int, None, bytes, etc.) MUST be rejected + with a clear ValueError — Python's ``uuid.UUID()`` raises TypeError + for these, which we wrap as ValueError.""" + from nullrun.context import _validate_chain_id + + for bad in [12345, None, b"00000000-0000-4000-8000-000000000000", 1.0]: + with pytest.raises((ValueError, TypeError)): + _validate_chain_id(bad) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# chain() context manager — integration tests +# --------------------------------------------------------------------------- + + +def test_chain_context_manager_rejects_non_uuid_v4(): + """The ``chain()`` context manager MUST validate chain_id is a + UUID v4 BEFORE entering the context — a malformed chain_id must + raise ValueError, not silently auto-register as a new ACTIVE chain.""" + from nullrun.context import chain + + with pytest.raises(ValueError, match="UUID v4"): + with chain("!"): + pass # should never reach here + + with pytest.raises(ValueError, match="UUID v4"): + with chain("12345"): + pass + + with pytest.raises(ValueError, match="UUID v4"): + with chain("00000000-0000-0000-0000-000000000000"): + pass # nil UUID rejected + + with pytest.raises(ValueError, match="UUID v4"): + with chain("ffffffff-ffff-ffff-ffff-ffffffffffff"): + pass # all-ones rejected + + +def test_chain_context_manager_accepts_uuid_v4(): + """A canonical UUID v4 string MUST be accepted by the context + manager; the chain_id is set on the contextvar and yielded back.""" + from nullrun.context import chain, get_chain_id + + cid = str(uuid.uuid4()) + with chain(cid) as yielded: + assert yielded == cid + assert get_chain_id() == cid + + +def test_chain_context_manager_resets_after_invalid_chain_id(): + """If ``chain()`` raises ValueError on an invalid chain_id, the + chain_id contextvar MUST be reset (no leak into outer scope). + The pre-fix code would have raised inside the context manager + after setting the var, leaking the bad value to the next + /check call.""" + from nullrun.context import chain, get_chain_id + + assert get_chain_id() is None # fresh test + with pytest.raises(ValueError): + with chain("!"): + pass + assert get_chain_id() is None # contextvar reset + + +# --------------------------------------------------------------------------- +# set_chain_id() manual setter — integration tests +# --------------------------------------------------------------------------- + + +def test_set_chain_id_rejects_invalid_uuid(): + """``set_chain_id()`` MUST validate the chain_id before writing + to the contextvar (mirrors the context manager).""" + from nullrun.context import set_chain_id, get_chain_id + + original = get_chain_id() + try: + with pytest.raises(ValueError, match="UUID v4"): + set_chain_id("!") + assert get_chain_id() == original # not mutated + + with pytest.raises(ValueError, match="UUID v4"): + set_chain_id("00000000-0000-0000-0000-000000000000") + assert get_chain_id() == original + finally: + set_chain_id(original) + + +def test_set_chain_id_accepts_none_to_clear(): + """``set_chain_id(None)`` MUST be accepted (clears the context) + — the ``None`` value is the documented "no chain" state.""" + from nullrun.context import set_chain_id, get_chain_id + + set_chain_id(str(uuid.uuid4())) + assert get_chain_id() is not None + set_chain_id(None) + assert get_chain_id() is None + + +def test_set_chain_id_accepts_uuid_v4(): + """``set_chain_id()`` MUST accept a UUID v4 string and write + it to the contextvar.""" + from nullrun.context import set_chain_id, get_chain_id + + cid = str(uuid.uuid4()) + original = get_chain_id() + try: + set_chain_id(cid) + assert get_chain_id() == cid + finally: + set_chain_id(original) From 55260e50d8c291bfb7aa84ab48e58d0a579190e9 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Sun, 23 Aug 2026 09:28:46 +0400 Subject: [PATCH 3/4] fix(sdk): populate _call_tools_var in decorator path (DEF-LATEST_PLAN-F03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F01 fix (e70e55d) wired Runtime.execute to forward tools from _call_tools_var, but no internal SDK code ever populated that contextvar — only set_call_context (the public API) wrote to it, and grep -rn set_call_context has zero internal callers. Result: every @protect / @sensitive call hit /gate (and /execute) without tools=[...], so backend Step 3 tool_block returned TOOL_BLOCKED (rule_kind: 'policy_cache_miss' / no_tools_field) BEFORE any approval-rule evaluation could fire. Fix: 1. _protect_body seeds _call_tools_var = (fn.__name__,) token-based before check_control_plane(); resets in finally. Skips when an outer set_call_context(tools=[...]) is already in effect, so explicit user intent wins. 2. Runtime.execute gains explicit kwarg so the F01 source-pin thread-through doesn't TypeError if /execute is reached. 3. TestDecoratorF03BehavioralRegression (4 new tests, all pass): - @protect populates tools=['fn_name'] on /gate body - @protect does not override explicit set_call_context - @protect restores prior contextvar on exit (token reset) - @sensitive @protect populates tools=['fn_name'] on /execute body Surfaced by LATEST_PLAN.20260822-181500-a3f1 (TC-SDK-014..017 all TOOL_BLOCKED; TC-OBS-007 pending_count=0). Memory updated; no push. --- CHANGELOG.md | 26 +++ src/nullrun/decorators.py | 32 ++++ src/nullrun/runtime.py | 30 ++- tests/test_execute_tools_propagation.py | 245 ++++++++++++++++++++++++ 4 files changed, 329 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1383d..ecdf4c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ Patch release — `Runtime.execute()` now populates the per-call `tools` array on the `/execute` wire body. Wire-format unchanged from the /gate path (which already forwards `tools`); the backend reads the same field on both endpoints. Closes `DEF-LATEST_PLAN-F01` (2026-08-21). +**Patch .2 (2026-08-23) — closes the F01 regression (`DEF-LATEST_PLAN-F03`).** The 2026-08-21 fix forwarded `tools=get_call_tools()` from `_enforce_sensitive_tool` to `runtime.execute(...)`, but `_call_tools_var` was never populated on the decorator path — only `set_call_context(tools=...)` (the public API) wrote to it, and `grep -rn set_call_context` returns zero internal callers. Result: `/gate` and `/execute` payloads still omitted `tools` on every `@protect` / `@sensitive` call → backend Step 3 tool_block check returned `TOOL_BLOCKED` (`rule_kind: "policy_cache_miss"` / `no_tools_field`) BEFORE approval-rule evaluation could fire. Surfaced 2026-08-22 by `LATEST_PLAN.20260822-181500-a3f1` (TC-SDK-014/015/016/017 all blocked with `TOOL_BLOCKED`; TC-OBS-007 `pending_count=0`). + +### Changed + +- **`_protect_body` now seeds `_call_tools_var` token-based before `runtime.check_control_plane()`.** When the user has not explicitly called `set_call_context(tools=...)`, the decorator sets the contextvar to `(fn.__name__,)` so the @protect / @sensitive wire bodies carry the right `tools=[...]` payload. The token is reset on function exit (preserves any outer explicit context; restores prior nested-dec state correctly via `Token.reset`). +- **`Runtime.execute()` gains an explicit `tools` kwarg** (`tuple[str, ...] | None = None`). Previously the F01 fix at `_enforce_sensitive_tool` called `runtime.execute(..., tools=get_call_tools())` but `Runtime.execute` had no such parameter — the call would have TypeError-ed if `/execute` had been reached (in practice `/gate` short-circuits first, so the TypeError was masked by the catch-all `except Exception`). Now the kwarg is part of the signature: explicit kwarg wins, otherwise falls back to the contextvar (same precedence as before). +- **New behavioural regression tests** `tests/test_execute_tools_propagation.py::TestDecoratorF03BehavioralRegression` (4 tests, all pass). They assert the wire-body shape end-to-end (decorator → transport → respx capture): + 1. `@protect` populates `tools=["fn_name"]` on `/gate` body when user omits `set_call_context`, + 2. `@protect` does NOT override an explicit `set_call_context(tools=["custom"])` (preserves user intent), + 3. `@protect` restores the prior contextvar value on exit (token-based reset semantics), + 4. `@sensitive @protect refund_customer` populates `tools=["refund_customer"]` on the `/execute` wire body — the headline F03 closure (was failing with `WorkflowKilledInterrupt: TOOL_BLOCKED` at `/gate`). + +### Verification + +- Targeted suite: 9/9 in `tests/test_execute_tools_propagation.py` pass (3 existing TestExecuteToolsPropagation + 2 existing TestDecoratorThreading + 4 new TestDecoratorF03BehavioralRegression). +- Broader regression suite: 1481 passed, 6 skipped (1 unrelated pre-existing failure on `test_set_chain_id_persists` — F5 chain_id UUID validation broke that test, not related to F03). +- Live verification pending: re-run `LATEST_PLAN.20260822-181500-a3f1` probes (TC-SDK-014..017) against this patched SDK to confirm approval rows are now created in `approvals` table (TC-OBS-007 should show `pending_count>0`). + +### Why this is needed + +The F01 fix was a partial closure — it wired the downstream consumer (`Runtime.execute`) to forward `tools` from a contextvar, but never wired the upstream producer (decorator) to populate the contextvar. The orphan boundary left the `/gate` and `/execute` payloads empty for every decorated call, defeating TB-1's fail-CLOSED (correct backend behaviour) but exposing a silent `TOOL_BLOCKED` rejection class that masks approval-rule evaluation. This patch closes the boundary by populating the contextvar in `_protect_body` itself, ensuring the wire body is shaped correctly for both endpoints without requiring the user to call `set_call_context` manually. + +### Compatibility + +Wire-format additive only — `tools` field already documented on `/gate` (F01 fix) and now correctly populated on `/execute` as well. No new wire fields, no protocol bump. Backend reads the same field on both endpoints. SDK users who called `set_call_context(tools=[...])` explicitly will see no behaviour change (explicit contextvar still wins; decorator's auto-population is skipped when contextvar is non-empty). + ### Changed - **`Runtime.execute()` now populates `tools` on every `/execute` call.** Pre-this-fix the field was only forwarded on `/gate` (via `runtime.check_workflow_budget` + `set_call_context(tools=...)`). The backend's Step 3 tool_block check (`backend/src/proxy/http/gate/orchestrator.rs:1847-1893`) returns `Block { TOOL_BLOCKED, reason: "no_tools_field" }` whenever the workflow's effective `policy.tool_patterns` is non-empty AND the `tools` field is absent — so every `@sensitive`-decorated LLM call against a workflow with active tool-block policy was incorrectly rejected with `TOOL_BLOCKED` instead of being evaluated against the actual `tool_patterns` aggregate. The fix: diff --git a/src/nullrun/decorators.py b/src/nullrun/decorators.py index 07e0e03..69a6fab 100644 --- a/src/nullrun/decorators.py +++ b/src/nullrun/decorators.py @@ -40,6 +40,7 @@ def researcher(q): import logging import os from collections.abc import Callable +from contextvars import Token from typing import Any, TypeVar from nullrun._registry import get_active_runtime @@ -49,6 +50,7 @@ def researcher(q): WorkflowPausedException, ) from nullrun.context import ( + _call_tools_var, get_call_tools, get_workflow_id, reset_span_id, @@ -462,6 +464,29 @@ def _protect_body(args: tuple[Any, ...], kwargs: dict[str, Any], unify_block: bo # restores the outer trace/span on reset. trace_legacy_token = set_trace_id(span.trace_id) span_legacy_token = set_span_id(span.span_id) + # F03 (2026-08-22): populate `_call_tools_var` from + # ``fn.__name__`` when the user did NOT explicitly call + # ``set_call_context(tools=...)``. The F01 fix + # (``runtime.execute`` body at runtime.py:2746-2760 and the + # /gate path at runtime.py:1903-1941) conditionally forwards + # the per-call tools contextvar onto the wire body, but the + # upstream contextvar was never populated for the @protect / + # @sensitive decorator path. Without this fix every wire + # round-trip omits the `tools` field, the backend's Step 3 + # tool_block check fails-CLOSED via TB-1 + # (``no_tools_field``), and approval-rule probes (TC-SDK-014 + # /015/016/017) never reach the approval_rule_eval step. + # Token-based so a nested @protect inside an outer @protect + # (or inside ``with workflow``) restores the outer contextvar + # on reset — same shape as the legacy + # ``_trace_id_var`` / ``_span_id_var`` resets above. + _existing_call_tools = get_call_tools() + if not _existing_call_tools: + call_tools_token: Token | None = _call_tools_var.set( + (fn.__name__,), + ) + else: + call_tools_token = None error: BaseException | None = None try: # 1. KILL/PAUSE from the dashboard short-circuits @@ -515,6 +540,13 @@ def _protect_body(args: tuple[Any, ...], kwargs: dict[str, Any], unify_block: bo # of which one runs first. reset_trace_id(trace_legacy_token) reset_span_id(span_legacy_token) + # F03 follow-up: reset the per-call tools contextvar if + # we set it. Outer ``with workflow`` / nested @protect + # callers that previously set the contextvar see their + # prior value restored; bare @protect leaves the + # contextvar empty again (the default). + if call_tools_token is not None: + _call_tools_var.reset(call_tools_token) _emit_span_end( runtime, span, diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index 1e268f4..b7fdf17 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -2650,6 +2650,17 @@ def execute( on_transport_error: Callable[[Exception], dict[str, Any]] | None = None, business_impact: dict[str, Any] | None = None, action_digest: str | None = None, + # F03 (2026-08-22): accept `tools` kwarg from the + # ``@sensitive`` decorator (``_enforce_sensitive_tool``) + # so the bridge from decorators.py:735 stays + # source-pin-compatible with test_execute_tools_propagation.py + # while the runtime also reads ``get_call_tools()`` + # internally. The kwarg and the contextvar are merged + # below — kwarg wins when supplied, otherwise the + # contextvar flows through (which the F03 fix in + # decorators.py populates from ``fn.__name__`` before + # this method is called). + tools: tuple[str, ...] | None = None, ) -> dict[str, Any]: """ Pre-execution policy evaluation via /execute endpoint. @@ -2743,8 +2754,19 @@ def execute( # (`no_tools_field`). Mirrors the /gate path at # `check_workflow_budget` which already threads the same # contextvar onto the wire body. - from nullrun.context import get_call_tools as _get_call_tools_for_execute - _execute_call_tools = _get_call_tools_for_execute() + # + # F03 (2026-08-22) precedence: the `tools` kwarg wins when + # supplied (allows callers like `_enforce_sensitive_tool` to + # forward an explicit list); otherwise fall back to the + # ``_call_tools_var`` contextvar which the F03 fix in + # decorators.py populates from ``fn.__name__`` before this + # method is called. The runtime layer was already reading + # the contextvar — the kwarg simply adds a second entry + # point that didn't exist before (causing TypeError on the + # decorator call site). + if tools is None: + from nullrun.context import get_call_tools as _get_call_tools_for_execute + tools = _get_call_tools_for_execute() execute_kwargs: dict[str, Any] = { "organization_id": organization_id, "execution_id": uuid7_str(), @@ -2756,8 +2778,8 @@ def execute( "operation_id": operation_id, "on_transport_error": on_transport_error, } - if _execute_call_tools: - execute_kwargs["tools"] = list(_execute_call_tools) + if tools: + execute_kwargs["tools"] = list(tools) # Digest-bound approval: forward the typed impact + digest # to the wire when supplied. The backend stamps the approval # row with the digest and verifies it on the post-approval diff --git a/tests/test_execute_tools_propagation.py b/tests/test_execute_tools_propagation.py index 6b6c4ec..f7e813c 100644 --- a/tests/test_execute_tools_propagation.py +++ b/tests/test_execute_tools_propagation.py @@ -37,6 +37,7 @@ from __future__ import annotations import json +import os import threading import httpx @@ -47,6 +48,7 @@ BASE_URL = "https://api.test.nullrun.io" EXECUTE_URL = f"{BASE_URL}/api/v1/execute" +GATE_URL = f"{BASE_URL}/api/v1/gate" @pytest.fixture @@ -259,3 +261,246 @@ def test_decorator_imports_get_call_tools(self): "nullrun.context — the F01 fix reads the per-call " "tools contextvar via this helper" ) + + +class TestDecoratorF03BehavioralRegression: + """F03 (2026-08-22) behavioural regression: ``@protect`` / + ``@sensitive`` must populate the per-call ``_call_tools_var`` + contextvar from ``fn.__name__`` when the user did NOT explicitly + call ``set_call_context(tools=...)``. + + Pre-F03 the contextvar was never written by SDK internal code, so + the /gate round-trip triggered by ``check_workflow_budget()`` + omitted the ``tools`` field. The backend's Step 3 tool_block check + (``backend/src/proxy/http/gate/orchestrator.rs``) fail-CLOSED via + TB-1 (``no_tools_field``) and every approval-rule probe returned + ``decision=block reason='TOOL_BLOCKED'`` without ever reaching the + approval_rule_eval step. This suite exercises the decorator's + full runtime path (not the source-pin-only test in + ``TestDecoratorThreading`` above) so a future refactor that drops + the population step fails the test immediately. + + The transport layer already accepts ``tools`` on the wire body + (covered by ``TestExecuteToolsPropagation`` above). This module + closes the gap on the *decorator* leg of the handoff: from + ``@protect`` invocation through ``_protect_body`` into the + underlying runtime call. + """ + + @pytest.fixture + def captured_gate_and_execute(self): + """Capture every /gate AND /execute request body. Returns + a tuple of two mutable lists ``(gate_bodies, execute_bodies)`` + that the test can index into to assert what was sent.""" + gate_bodies: list[dict] = [] + execute_bodies: list[dict] = [] + + def _gate_capture(request: httpx.Request) -> httpx.Response: + gate_bodies.append(json.loads(request.content.decode("utf-8"))) + return httpx.Response( + 200, + json={ + "decision": "allow", + "decision_source": "gateway", + "explanation": "allowed", + "policy_version": 1, + "explanations": [], + }, + ) + + def _execute_capture(request: httpx.Request) -> httpx.Response: + execute_bodies.append(json.loads(request.content.decode("utf-8"))) + return httpx.Response( + 200, + json={ + "decision": "allow", + "decision_source": "gateway", + "explanation": "allowed", + "policy_version": 1, + }, + ) + + respx.post(GATE_URL).mock(side_effect=_gate_capture) + respx.post(EXECUTE_URL).mock(side_effect=_execute_capture) + return gate_bodies, execute_bodies + + def test_protect_populates_tools_on_gate_body_when_user_omits_set_call_context( + self, make_runtime, mock_api, captured_gate_and_execute + ): + """The decorator's ``_protect_body`` must seed + ``_call_tools_var = (fn.__name__,)`` so ``check_workflow_budget`` + forwards ``tools`` on the /gate wire body — even when the user + never called ``set_call_context``. This is the headline F03 + closure for the /gate leg.""" + gate_bodies, _ = captured_gate_and_execute + from nullrun.context import get_call_tools + + # Defensive: assert the precondition the F03 fix relies on + # (no caller-side set_call_context for this test). + assert get_call_tools() == () + + import nullrun.decorators as dec + + rt = make_runtime() + dec._runtime = rt # belt-and-braces — make_runtime already does this. + + @dec.protect + def my_agent(query: str) -> str: + return f"answer:{query}" + + result = my_agent("hello") + assert result == "answer:hello" + + # /gate must have been called once and must carry + # tools=["my_agent"] — populated by _protect_body from + # fn.__name__ before check_workflow_budget(). + assert gate_bodies, "no /gate call was captured" + gate_body = gate_bodies[-1] + assert gate_body.get("tools") == ["my_agent"], ( + f"F03 not closed: /gate body must carry tools=['my_agent'] " + f"after @protect; got body={gate_body!r}. The fix is in " + f"decorators.py::_protect_body which seeds " + f"_call_tools_var from fn.__name__ before " + f"check_workflow_budget()." + ) + + def test_protect_does_not_override_explicit_set_call_context( + self, make_runtime, mock_api, captured_gate_and_execute + ): + """When the user explicitly called + ``set_call_context(tools=[user_list])``, the decorator MUST + preserve the user's list — not overwrite it with + ``[fn.__name__]``. Precedence: explicit > auto-populated.""" + gate_bodies, _ = captured_gate_and_execute + from nullrun.context import get_call_tools, set_call_context + + # User explicitly declared their tool list. + set_call_context(tools=["user_declared_tool", "another_tool"]) + assert get_call_tools() == ("user_declared_tool", "another_tool") + + import nullrun.decorators as dec + + rt = make_runtime() + dec._runtime = rt + + @dec.protect + def my_agent(query: str) -> str: + return f"answer:{query}" + + try: + my_agent("hello") + assert gate_bodies, "no /gate call was captured" + gate_body = gate_bodies[-1] + # The user's explicit list survives — NOT fn.__name__. + assert gate_body.get("tools") == [ + "user_declared_tool", + "another_tool", + ], ( + f"explicit set_call_context must win over decorator " + f"auto-population; got body={gate_body!r}" + ) + finally: + # Clean up so the test's explicit context doesn't leak. + set_call_context(tools=[]) + assert get_call_tools() == () + + def test_protect_restores_prior_call_tools_context_after_call( + self, make_runtime, mock_api, captured_gate_and_execute + ): + """Token-based reset: a nested @protect inside an outer @protect + (or inside ``with workflow``) restores the prior + ``_call_tools_var`` value on exit. Bare @protect leaves the + contextvar empty again. The same shape as the legacy + ``_trace_id_var`` / ``_span_id_var`` resets in _protect_body.""" + from nullrun.context import _call_tools_var, get_call_tools, set_call_context + + # Outer: user explicitly set tools=["outer"] + set_call_context(tools=["outer"]) + assert get_call_tools() == ("outer",) + + import nullrun.decorators as dec + + rt = make_runtime() + dec._runtime = rt + + @dec.protect + def outer(query: str) -> str: + return f"outer:{query}" + + # Before invocation: outer context is "outer" + assert get_call_tools() == ("outer",) + + # Invoke outer — its _protect_body will see _existing="outer" + # and SKIP auto-population (call_tools_token stays None). + _ = outer("hello") + + # After invocation: outer context STILL "outer" (unchanged). + assert get_call_tools() == ("outer",), ( + f"explicit set_call_context was clobbered by the decorator " + f"after the call exited; got {get_call_tools()!r}" + ) + + # Clean up + set_call_context(tools=[]) + assert get_call_tools() == () + + def test_sensitive_decorator_populates_tools_on_execute_body( + self, make_runtime, mock_api, captured_gate_and_execute + ): + """``@sensitive`` is the decorator combo reported in DEF-LATEST_PLAN-F03 + (probes ``qa/approval_rules/ar_toolname_run.py``, + ``ar_toolname_run_chain.py``, ``ar_params_run.py``, + ``ar_threshold_run.py``). Pre-F03 the ``_enforce_sensitive_tool`` + ``runtime.execute(..., tools=get_call_tools())`` call saw an + empty contextvar, the wire body omitted ``tools``, and the + backend's Step 3 tool_block fail-CLOSED via TB-1 + (``no_tools_field``) BEFORE the approval_rule_eval step could + fire — every approval-rule probe returned + ``decision=block reason='TOOL_BLOCKED'``. + + Post-F03 the decorator populates ``_call_tools_var`` from + ``fn.__name__`` in ``_protect_body`` (before /gate and + /execute are called), and ``runtime.execute`` now accepts the + ``tools=`` kwarg directly so the source-pin pattern at + decorators.py:735 no longer TypeErrors. The /execute wire body + must carry ``tools=["refund_customer"]``. + """ + _gate_bodies, execute_bodies = captured_gate_and_execute + from nullrun.context import get_call_tools + + assert get_call_tools() == () # precondition + + import nullrun.decorators as dec + + rt = make_runtime() + dec._runtime = rt + + # The /sensitive registration flow warms the runtime + # singleton's sensitive-tools set. ``_do_sensitive_register`` + # calls ``add_sensitive_tool(fn.__name__)`` so the + # ``_enforce_sensitive_tool`` short-circuit (line 606 in + # decorators.py) doesn't return early. + @dec.sensitive + @dec.protect + def refund_customer(refund_amount: float) -> str: + return f"refund:{refund_amount}" + + # Register the tool manually (decoration-time registration + # uses the lazy singleton; in this test we pin the runtime + # directly via dec._runtime so the registration lands on the + # pinned instance — same trick make_runtime uses). + rt.add_sensitive_tool("refund_customer") + + result = refund_customer(refund_amount=100.0) + assert result == "refund:100.0" + + # /execute (the sensitive-tool round-trip) must carry + # tools=["refund_customer"] — the F03 headline closure. + assert execute_bodies, "no /execute call was captured" + execute_body = execute_bodies[-1] + assert execute_body.get("tools") == ["refund_customer"], ( + f"F03 not closed on /execute path: body must carry " + f"tools=['refund_customer']; got body={execute_body!r}. " + f"This is the symptom that broke all four approval-rule " + f"probes in LATEST_PLAN run 20260822-181500-a3f1." + ) From a6ed5e8f216c779c960c96ae5180bfe13d7a0709 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Sun, 23 Aug 2026 11:02:55 +0400 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=200.16.2=20=E2=80=94=20bump?= =?UTF-8?q?=20version,=20update=20tests=20for=20UUID=20v4=20+=20F03?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release-prep patch on top of the three F01 / F03 / F5 commits already cherry-picked onto release/0.16.2: - pyproject.toml + src/nullrun/__version__.py: 0.16.1 → 0.16.2. - CHANGELOG.md: lock [Unreleased] to [0.16.2] - 2026-08-23 and extend the blurb to call out F03 + F5 closure alongside F01. - src/nullrun/decorators.py: parameterize Token[tuple[str, ...]] on the _call_tools_var.set() token so mypy --strict is clean (Token is contextvars.Token, generic on the variable's value type). - tests/test_chain_id_uuid_v4.py: ruff --fix I001 reorder (in-function imports sorted alphabetically; blank-line trim). No behavior change. - tests/test_v3_wire_contract.py: - test_set_chain_id_persists: replace "chain-1" literal with str(uuid.uuid4()) (chain_id now validated as UUID v4). - test_chain_contextmanager_rejects_invalid_op: supply a valid UUID v4 + invalid op so the op-rejection assertion is not shadowed by the chain_id-validator. - test_chain_nested_restores_outer_on_exit: two UUID v4 literals for outer / inner scope. - TestGateCacheRuntimeFlow (3 tests): chain("chain-runtime-cache" / "chain-runtime-uuid7" / "chain-no-cache") → chain(str(uuid.uuid4())) so the cache / no-cache / uuid7 wire assertions don't trip the new strict validator. Verified: pytest 1598 passed / 7 skipped; ruff clean; mypy clean on src/nullrun. No wire-format change. The three feature commits (f4826b2 / be9265a / 55260e5) remain the source of truth for F01 / F5 / F03 behavior. --- CHANGELOG.md | 4 ++-- pyproject.toml | 2 +- src/nullrun/__version__.py | 2 +- src/nullrun/decorators.py | 2 +- tests/test_chain_id_uuid_v4.py | 7 +++---- tests/test_v3_wire_contract.py | 35 ++++++++++++++++++++++++---------- 6 files changed, 33 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecdf4c2..689aa96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ -## [Unreleased] +## [0.16.2] - 2026-08-23 -Patch release — `Runtime.execute()` now populates the per-call `tools` array on the `/execute` wire body. Wire-format unchanged from the /gate path (which already forwards `tools`); the backend reads the same field on both endpoints. Closes `DEF-LATEST_PLAN-F01` (2026-08-21). +Patch release — `Runtime.execute()` now populates the per-call `tools` array on the `/execute` wire body. Wire-format unchanged from the /gate path (which already forwards `tools`); the backend reads the same field on both endpoints. Closes `DEF-LATEST_PLAN-F01` (2026-08-21) + regression `DEF-LATEST_PLAN-F03` + `F5` (UUID v4 chain_id validation). Wire-format additive only. **Patch .2 (2026-08-23) — closes the F01 regression (`DEF-LATEST_PLAN-F03`).** The 2026-08-21 fix forwarded `tools=get_call_tools()` from `_enforce_sensitive_tool` to `runtime.execute(...)`, but `_call_tools_var` was never populated on the decorator path — only `set_call_context(tools=...)` (the public API) wrote to it, and `grep -rn set_call_context` returns zero internal callers. Result: `/gate` and `/execute` payloads still omitted `tools` on every `@protect` / `@sensitive` call → backend Step 3 tool_block check returned `TOOL_BLOCKED` (`rule_kind: "policy_cache_miss"` / `no_tools_field`) BEFORE approval-rule evaluation could fire. Surfaced 2026-08-22 by `LATEST_PLAN.20260822-181500-a3f1` (TC-SDK-014/015/016/017 all blocked with `TOOL_BLOCKED`; TC-OBS-007 `pending_count=0`). diff --git a/pyproject.toml b/pyproject.toml index 7abade5..b35af1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "nullrun" # Full release history lives in CHANGELOG.md; only the current version # is pinned here. -version = "0.16.1" +version = "0.16.2" # Kept under the 200-char preview threshold so the full line is visible # without an "expand" click. The headline is the canonical §1 statement # from positioning.md — "runtime decision layer for tool-using AI agents" diff --git a/src/nullrun/__version__.py b/src/nullrun/__version__.py index 197e73c..cb4e6a2 100644 --- a/src/nullrun/__version__.py +++ b/src/nullrun/__version__.py @@ -5,5 +5,5 @@ string and the SDK_MIN_VERSION constant. """ -__version__ = "0.16.1" +__version__ = "0.16.2" __platform_version__ = "1.0.0" diff --git a/src/nullrun/decorators.py b/src/nullrun/decorators.py index 69a6fab..c288a45 100644 --- a/src/nullrun/decorators.py +++ b/src/nullrun/decorators.py @@ -482,7 +482,7 @@ def _protect_body(args: tuple[Any, ...], kwargs: dict[str, Any], unify_block: bo # ``_trace_id_var`` / ``_span_id_var`` resets above. _existing_call_tools = get_call_tools() if not _existing_call_tools: - call_tools_token: Token | None = _call_tools_var.set( + call_tools_token: Token[tuple[str, ...]] | None = _call_tools_var.set( (fn.__name__,), ) else: diff --git a/tests/test_chain_id_uuid_v4.py b/tests/test_chain_id_uuid_v4.py index 55b91e3..2f8fa30 100644 --- a/tests/test_chain_id_uuid_v4.py +++ b/tests/test_chain_id_uuid_v4.py @@ -30,7 +30,6 @@ import pytest - # --------------------------------------------------------------------------- # _validate_chain_id — pure function tests # --------------------------------------------------------------------------- @@ -207,7 +206,7 @@ def test_chain_context_manager_resets_after_invalid_chain_id(): def test_set_chain_id_rejects_invalid_uuid(): """``set_chain_id()`` MUST validate the chain_id before writing to the contextvar (mirrors the context manager).""" - from nullrun.context import set_chain_id, get_chain_id + from nullrun.context import get_chain_id, set_chain_id original = get_chain_id() try: @@ -225,7 +224,7 @@ def test_set_chain_id_rejects_invalid_uuid(): def test_set_chain_id_accepts_none_to_clear(): """``set_chain_id(None)`` MUST be accepted (clears the context) — the ``None`` value is the documented "no chain" state.""" - from nullrun.context import set_chain_id, get_chain_id + from nullrun.context import get_chain_id, set_chain_id set_chain_id(str(uuid.uuid4())) assert get_chain_id() is not None @@ -236,7 +235,7 @@ def test_set_chain_id_accepts_none_to_clear(): def test_set_chain_id_accepts_uuid_v4(): """``set_chain_id()`` MUST accept a UUID v4 string and write it to the contextvar.""" - from nullrun.context import set_chain_id, get_chain_id + from nullrun.context import get_chain_id, set_chain_id cid = str(uuid.uuid4()) original = get_chain_id() diff --git a/tests/test_v3_wire_contract.py b/tests/test_v3_wire_contract.py index e312da0..d88ede7 100644 --- a/tests/test_v3_wire_contract.py +++ b/tests/test_v3_wire_contract.py @@ -623,8 +623,15 @@ def test_get_chain_id_default_none(self): assert get_chain_id() is None def test_set_chain_id_persists(self): - set_chain_id("chain-1") - assert get_chain_id() == "chain-1" + # v0.16.2: set_chain_id now requires a UUID v4 string (see + # src/nullrun/context.py::_validate_chain_id, F5 sdk_checks + # 2026-08-21). The pre-validation `"chain-1"` literal here + # would raise ValueError after ebbe4ef — use a real UUID v4 + # so the persistence contract is still asserted without + # tripping the new strict validator. + cid = str(uuid.uuid4()) + set_chain_id(cid) + assert get_chain_id() == cid def test_chain_contextmanager_sets_and_resets(self): cid = str(uuid.uuid4()) @@ -636,16 +643,24 @@ def test_chain_contextmanager_sets_and_resets(self): assert get_chain_id() is None def test_chain_contextmanager_rejects_invalid_op(self): + # v0.16.2: chain() now validates chain_id BEFORE op. To test + # op-rejection specifically, supply a syntactically valid + # UUID v4 with an invalid op — otherwise the chain_id check + # would fire first and shadow the op-rejection assertion. + valid_cid = str(uuid.uuid4()) with pytest.raises(ValueError, match="chain\\(\\) op must be"): - with chain("cid", op="garbage"): + with chain(valid_cid, op="garbage"): pass def test_chain_nested_restores_outer_on_exit(self): - with chain("outer", op="start"): - with chain("inner", op="continue"): - assert get_chain_id() == "inner" + # v0.16.2: chain() requires UUID v4 per context._validate_chain_id. + outer_cid = str(uuid.uuid4()) + inner_cid = str(uuid.uuid4()) + with chain(outer_cid, op="start"): + with chain(inner_cid, op="continue"): + assert get_chain_id() == inner_cid # Inner exited — outer restored. - assert get_chain_id() == "outer" + assert get_chain_id() == outer_cid # Both exited. assert get_chain_id() is None @@ -1067,7 +1082,7 @@ def test_chain_mode_collapses_three_checks_to_one_gate_call(self): ) try: with workflow("wf-runtime-cache") as _wf_id, chain( - "chain-runtime-cache" + str(uuid.uuid4()) ) as _cid: # Direct calls in chain scope — bypasses @protect but # exercises the same check_workflow_budget codepath. @@ -1120,7 +1135,7 @@ def test_chain_mode_emits_fresh_uuid7_execution_id_per_call(self): polling=False, ) try: - with workflow("wf-runtime-uuid7"), chain("chain-runtime-uuid7"): + with workflow("wf-runtime-uuid7"), chain(str(uuid.uuid4())): rt_inst.check_workflow_budget() rt_inst.check_workflow_budget() gate_calls = [ @@ -1170,7 +1185,7 @@ def test_chain_mode_disabled_via_env_bypasses_cache(self): polling=False, ) try: - with workflow("wf-no-cache"), chain("chain-no-cache"): + with workflow("wf-no-cache"), chain(str(uuid.uuid4())): rt_inst.check_workflow_budget() rt_inst.check_workflow_budget() gate_calls = [