Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,45 @@
## [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) + 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`).

### 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:
- `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`.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/nullrun/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
string and the SDK_MIN_VERSION constant.
"""

__version__ = "0.16.1"
__version__ = "0.16.2"
__platform_version__ = "1.0.0"
76 changes: 69 additions & 7 deletions src/nullrun/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -810,21 +864,29 @@ 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
``workflow ``).
"""
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:
Expand Down
34 changes: 34 additions & 0 deletions src/nullrun/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,6 +50,8 @@ def researcher(q):
WorkflowPausedException,
)
from nullrun.context import (
_call_tools_var,
get_call_tools,
get_workflow_id,
reset_span_id,
reset_trace_id,
Expand Down Expand Up @@ -461,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[tuple[str, ...]] | 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
Expand Down Expand Up @@ -514,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,
Expand Down Expand Up @@ -725,6 +758,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.
Expand Down
33 changes: 33 additions & 0 deletions src/nullrun/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -2736,6 +2747,26 @@ 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.
#
# 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(),
Expand All @@ -2747,6 +2778,8 @@ def execute(
"operation_id": operation_id,
"on_transport_error": on_transport_error,
}
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
Expand Down
11 changes: 11 additions & 0 deletions src/nullrun/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading