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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
## [0.15.1] - 2026-08-13

Patch release — v3.53 audit fixes (H6 / L5 / L6 / M8 / audit #4 / #5 / #6) plus static-typing closure. No public API change, no wire-format change. Drop-in replacement for 0.15.0.

### Fixed

- **`Transport.execute` fallback default flipped to STRICT** (audit #4) — pre-v3.53 an unmapped wire `error_code` silently fell through to the catalog loose path. Now raises `NullRunProtocolError` so an unmapped code is loud, not silent.
- **`MCPAdapter.call_tool` routes through the gate when a runtime is wired** (audit #5) — pre-v3.53 the adapter bypassed the gate path entirely for ad-hoc MCP tool calls. Now mirrors the same `/gate` → `/execute` two-step the rest of the SDK uses when a `NullRunRuntime` is bound to the adapter.
- **`NULLRUN_SKIP_BUDGET_CHECK=1` refused in production** (audit #6 / Bug #6, CLAUDE.md §20) — pre-v3.53 the bypass was honored regardless of environment. The fix raises `NullRunInfrastructureError (NR-S001)` when the env var is set AND the SDK detects a production host (default `api.nullrun.io` or `NULLRUN_ENV=production` on a non-dev host). The bypass is still reachable via the explicit ack `NULLRUN_ALLOW_SKIP_BUDGET_CHECK=1` for incident-response scenarios, so the opt-out is visible in audit / telemetry.
- **`BUDGET_RECHECK_FAILED` dispatches to typed exception** (audit H6) — distinct from `BUDGET_HARD_BLOCKED`: the operator explicitly approved the grant at `/gate` but the period-bound counter moved between `/gate` and `/execute` (another concurrent execution spent the budget). Caller should re-`/gate` to refresh the reservation envelope and retry `/execute`. Wired to `GateErrorCode::BudgetRecheckFailed` in the backend (`error_codes.rs`).
- **Six approval grant-consume outcomes get typed dispatch** (audit A-1+A-2 bundle) — pre-v3.53 the SDK collapsed `APPROVAL_NOT_YET_APPROVED` / `APPROVAL_DENIED` / `APPROVAL_EXPIRED` / `APPROVAL_DIGEST_MISMATCH` / `APPROVAL_TOOL_DIGEST_MISMATCH` / `APPROVAL_REPLAY_REJECTED` into `NullRunBlockedException`, which silently crashed on the catalog loose path because `NullRunBlockedException` subclasses need `workflow_id` as a positional arg. Post-v3.53 each maps to its own NR-Axxx subclass (`NR-A010..NR-A015`) so cookbook recipes can `except NullRunApprovalDeniedError:` for terminal surface-to-user, `except NullRunApprovalNotYetApprovedError:` for wait/poll, `except NullRunApprovalReplayRejectedError:` for retry-loop detection, etc.
- **`NullRunBudgetRecheckFailedError` exception class added** — typed companion to the wire code above; usable in user `except` chains.
- **`_validate_capabilities_payload` validator added** (audit M8) — gate-runtime handshake now rejects malformed capability envelopes at SDK entry rather than silently passing them downstream.

### Housekeeping

- **`_V3_ERROR_CODE_MAP` type annotation tightened** from `type[BaseException]` to `type[Exception]` (mypy `return-value` error closure — every map value is an `Exception` subclass).
- **Ruff F811 sweep across test files** (`test_actions.py`, `test_v3_wire_contract.py`, `test_audit_wire.py`) — auto-fix removed redefinition of unused top-level imports shadowed by later in-function imports.

_Tests: 1550 passed, 7 skipped in 154.47s. Full suite green. ruff clean. mypy clean (37 source files)._

_Compatibility:_ **No SDK_MIN_VERSION bump.** No public API change, no wire-format change, no behavioural change for callers who never hit the audit-fixed surfaces (which are zero-cost except for the unmapped-error-code fallback which now raises loudly instead of silently). Drop-in replacement for 0.15.0.

## [0.15.0] - 2026-08-12

ADR-009 governance audit surface (P1) — typed read API for the org's hash-chained `audit_events` table. Backend already ships the matching wire shape (commit `46af9e29`, audit endpoints expose the 13 canonical columns: `agent_id`, `principal_id`, `decision`, `policy_id`, `policy_version`, `policy_hash`, `matched_rule`, `reason_code`, `execution_id`, `action_digest`, `tool_name`, `tool_version`, `tool_digest`). This release lands the SDK consumer side: a `nullrun.audit` module with frozen dataclasses for every wire response shape, a `runtime.audit` proxy that surfaces typed results, and 17 contract tests pinning the round-trip.
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.15.0"
version = "0.15.1"
# 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.15.0"
__version__ = "0.15.1"
__platform_version__ = "1.0.0"
187 changes: 187 additions & 0 deletions src/nullrun/breaker/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,59 @@ class NullRunBudgetError(NullRunBlockedException):
retryable = False


class NullRunBudgetRecheckFailedError(NullRunBudgetError):
"""Budget authorization failed during the post-approval re-check on /execute.

Distinct from :class:`NullRunBudgetError` (which is raised when /gate
itself blocks) — this is raised on the SECOND authorization decision:
the operator approved the grant at /gate, but the period-bound
budget counter moved between /gate reserve and /execute (typically
another concurrent execution spent the budget). Wire code
``BUDGET_RECHECK_FAILED`` from `GateErrorCode::BudgetRecheckFailed`
on the backend (error_codes.rs).

Carries ``current_spend_cents`` and ``budget_cents`` (from the
backend ``details`` envelope) so callers can compute the remaining
cap and decide whether to retry after re-``/gate``.

Subclass of :class:`NullRunBudgetError` so the existing
``except NullRunBudgetError:`` pattern keeps matching. New
``except NullRunBudgetRecheckFailedError:`` branches on the typed
shape (recommended: re-/gate then re-/execute).

Audit: H6 (2026-08-12). Pre-fix SDK 0.14.x collapsed this code
into a generic ``NullRunBudgetError("Budget authorization failed")``
with no introspection on the running counter.
"""

error_code = "NR-B006"
user_action = (
"Post-approval budget re-check failed — another execution "
"spent the budget between /gate and /execute. Call /gate "
"again to refresh the reservation, then retry /execute."
)
retryable = True

def __init__(
self,
message: str,
*,
current_spend_cents: int | None = None,
budget_cents: int | None = None,
status_code: int | None = None,
) -> None:
super().__init__(
workflow_id="<recheck>",
reason=message,
status_code=status_code,
)
# First-class attributes so callers can read the running
# counter without indexing into ``details``.
self.current_spend_cents: int | None = current_spend_cents
self.budget_cents: int | None = budget_cents
self.recheck_retryable: bool = True


class NullRunToolBlockedError(NullRunBlockedException):
"""The tool is in the workflow's block list.

Expand All @@ -807,6 +860,140 @@ class NullRunToolBlockedError(NullRunBlockedException):
retryable = False


# ---------------------------------------------------------------------------
# Approval grant-consume outcomes (v3.53 / 2026-08-13 audit, A-1/A-2)
# ---------------------------------------------------------------------------
# These six typed exceptions wire-up the /execute grant-consume outcomes
# that backend `backend/src/proxy/http/gate/internal.rs:3059-3108, 3115-3138`
# surfaces as distinct §13 wire codes. Pre-v3.53 the SDK collapsed all six
# into a generic ``NullRunBlockedException`` because the codes were missing
# from ``_V3_ERROR_CODE_MAP`` (transport.py:2427-2484) — bilateral wire
# gap. Post-v3.53 each outcome maps to its own typed class so cookbook
# recipes can ``except NullRunApprovalDeniedError:`` / ``except
# NullRunApprovalExpiredError:`` / ``except NullRunDigestMismatchError:``
# instead of string-matching the ``error_message``.
#
# All six subclass :class:`NullRunBlockedException` so the legacy
# ``except NullRunBlockedException:`` pattern keeps matching — back-compat
# invariant preserved.
class NullRunApprovalNotYetApprovedError(NullRunBlockedException):
"""The approval row exists but the operator has not yet decided.

Wire code ``APPROVAL_NOT_YET_APPROVED`` (HTTP 403). SDK cookbook
pattern: poll the approval via the WS push channel or sleep +
retry, NOT surface as terminal error.

Distinct from :class:`NullRunApprovalDeniedError` (operator said
no — terminal) and from :class:`NullRunApprovalExpiredError`
(operator said yes but grant TTL elapsed). All three share the
HTTP 403 envelope; the wire code is the discriminator.
"""

error_code = "NR-A010"
user_action = (
"Approval is pending — the operator has not yet decided. Wait "
"for the approval_resolved WebSocket frame or poll the "
"approval row; do NOT raise this to the user as terminal."
)
retryable = True


class NullRunApprovalDeniedError(NullRunBlockedException):
"""Operator explicitly denied the approval.

Wire code ``APPROVAL_DENIED`` (HTTP 403). Terminal — re-running
with the same approval_id will keep failing. Cookbook pattern:
surface denial to the user and request a fresh approval row
(different parameters / intent).
"""

error_code = "NR-A011"
user_action = (
"Operator denied the approval. Surface the denial to the "
"user, request a fresh approval row with revised parameters. "
"Re-running with the same approval_id will fail again."
)
retryable = False


class NullRunApprovalExpiredError(NullRunBlockedException):
"""Approval grant aged out — operator said yes but ``expires_at`` is past.

Wire code ``APPROVAL_EXPIRED`` (HTTP 403). The original grant was
approved but the operator's approval window elapsed before
``/execute`` consumed it. Cookbook pattern: request a fresh
approval row (do not retry the same one).
"""

error_code = "NR-A012"
user_action = (
"Approval grant has expired — the operator approved, but "
"the grant's expires_at is past. Request a fresh approval "
"row and retry /execute with the new approval_id."
)
retryable = False


class NullRunApprovalDigestMismatchError(NullRunBlockedException):
"""Business-impact digest drifted since operator approval (ADR-006).

Wire code ``APPROVAL_DIGEST_MISMATCH` (HTTP 403). The operator
approved action A; SDK /execute requests action B (different
business impact). Defense against prompt-injection-driven silent
capability drift. Cookbook pattern: request fresh approval with
the actual impact the SDK intends to execute.
"""

error_code = "NR-A013"
user_action = (
"Business-impact digest mismatch — the operator approved a "
"different action than the one currently bound to this "
"execution. Request fresh approval with the intended impact "
"and retry /execute."
)
retryable = False


class NullRunApprovalToolDigestMismatchError(NullRunBlockedException):
"""Tool capability digest drifted since operator approval (T8 / ADR-008).

Wire code ``APPROVAL_TOOL_DIGEST_MISMATCH`` (HTTP 403). The
operator approved the tool at /gate-create; the MCP server's
current capability surface differs at /execute (added destructive
flag, expanded schema, etc.). Cookbook pattern: re-pull the
current MCP ``tools/list`` and re-run /gate-create with the new
capability digest, OR roll back the server.
"""

error_code = "NR-A014"
user_action = (
"Tool capability digest mismatch — the operator approved a "
"different tool capability than the one currently bound. "
"Re-pull MCP tools/list and re-run /gate-create with the "
"current capability surface, or roll back the server."
)
retryable = False


class NullRunApprovalReplayRejectedError(NullRunBlockedException):
"""Approval grant was already consumed by a prior /execute call.

Wire code ``APPROVAL_REPLAY_REJECTED`` (HTTP 403). Each grant
is single-use per ``consume_approved`` atomic check-and-set.
Cookbook pattern: do NOT retry the same approval_id; treat as
idempotency violation (likely a client retry loop).
"""

error_code = "NR-A015"
user_action = (
"Approval grant was already consumed by a prior /execute "
"call — this is a replay/retry-loop signal, NOT a transient "
"failure. Inspect your retry logic; the same approval_id "
"will never succeed twice."
)
retryable = False


# NOTE: the following six exception classes were removed in 0.4.0
# because they had no callers in the SDK or in any test. They were
# zombie public surface — defined but never raised. If a real use
Expand Down
79 changes: 79 additions & 0 deletions src/nullrun/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,71 @@ def _parse_rate_limit_scope(payload: Any) -> RateLimitFailScope:
)


def _validate_capabilities_payload(payload: Any) -> list[str]:
"""Validate the shape of the ``/api/v1/capabilities`` JSON payload.

Returns a list of validation errors. Empty list = valid. Used as
a Zod-style guard around :func:`parse_capabilities` so a malformed
probe response (e.g. non-dict top level, capabilities array instead
of dict) surfaces a typed warning instead of silently falling
through to legacy defaults.

M8 (audit 2026-08-12): pre-fix, a malformed probe payload silently
yielded the conservative defaults via ``payload.get("capabilities")
or {}`` and the SDK continued in compatibility mode without
informing the operator. Post-fix, the operator sees a structured
``NullRunCapabilitiesValidationError`` at ``init()`` and can
diagnose the probe failure before the first /check.

Note: validation is intentionally permissive about MISSING fields
(the backend may add new fields at any time without bumping the
SDK version). It rejects only SHAPE errors — wrong types,
wrong container kinds, etc.
"""
errors: list[str] = []
if not isinstance(payload, dict):
errors.append(
f"top-level payload must be a dict, got {type(payload).__name__}"
)
return errors
caps = payload.get("capabilities")
if caps is not None and not isinstance(caps, dict):
errors.append(
f"'capabilities' must be a dict when present, got {type(caps).__name__}"
)
# Type guards on the numeric top-level fields. Strings are common
# in test fixtures but real backend always emits int.
for field_name in ("min_protocol_version", "max_protocol_version", "protocol_version"):
v = payload.get(field_name)
if v is not None and not isinstance(v, int) and not (
isinstance(v, str) and v.isdigit()
):
errors.append(
f"'{field_name}' must be int (or numeric string), got {type(v).__name__}"
)
# Numeric nested fields
if isinstance(caps, dict):
for field_name in (
"heartbeat_interval_seconds",
"heartbeat_skew_tolerance_seconds",
"chain_idle_ttl_seconds",
):
v = caps.get(field_name)
if v is not None and not isinstance(v, int) and not (
isinstance(v, str) and v.isdigit()
):
errors.append(
f"capabilities.{field_name} must be int, got {type(v).__name__}"
)
rl_scope = caps.get("rate_limit_fail_scope")
if rl_scope is not None and not isinstance(rl_scope, dict):
errors.append(
f"capabilities.rate_limit_fail_scope must be a dict, "
f"got {type(rl_scope).__name__}"
)
return errors


def parse_capabilities(payload: dict[str, Any]) -> ServerCapabilities:
"""Parse the backend's ``/api/v1/capabilities`` JSON.

Expand All @@ -228,7 +293,20 @@ def parse_capabilities(payload: dict[str, Any]) -> ServerCapabilities:

Nested wins when both are present so the test fixtures and the
canonical shape are unambiguous.

M8 (audit 2026-08-12): shape errors surface via
:func:`_validate_capabilities_payload` before parsing. The
caller (``probe_capabilities``) logs them at WARNING so the
operator sees the malformed payload without silent fallback to
legacy mode.
"""
# Shape validation — fail loud on type errors, stay quiet on
# missing keys (permissive forward-compat invariant).
shape_errors = _validate_capabilities_payload(payload)
if shape_errors:
for err in shape_errors:
logger.warning("capabilities probe: %s", err)

caps = payload.get("capabilities") or {}
if not isinstance(caps, dict):
caps = {}
Expand Down Expand Up @@ -337,6 +415,7 @@ def _parse(v: str) -> tuple[int, ...]:
"RateLimitFailScope",
"SDK_MIN_VERSION_FOR_V3",
"ServerCapabilities",
"_validate_capabilities_payload",
"parse_capabilities",
"probe_capabilities",
"validate_sdk_version",
Expand Down
Loading
Loading