Skip to content

Commit e85c691

Browse files
authored
chore(release): 0.15.1 — v3.53 audit closure (H6/L5/L6/M8 + #4/#5/#6) (#89)
* fix(sdk): 5xx + invalid-JSON + compromised-wording + request_timeout (RUN_ID 20260811-1) Closes 4 SDK defects from NULLRUN QA cycle 20260811-1: * DEF-ERRHDL-AUTH-PATH-CODE-PIN-01 (Medium) -- _authenticate() in runtime.py now routes 5xx to NullRunBackendError (NR-B002) instead of NullRunAuthenticationError (NR-A001). 401 keeps NullRunAuthenticationError + NR-A003; other 4xx keep NR-A001. Pre-fix operators were nudged to rotate valid keys during backend outages ("API key may be invalid or expired" for status=500 is misleading). Per CLAUDE.md §13 5xx is a backend-class error, not auth-class. * DEF-ERRHDL-INVALID-JSON-01 (Medium) -- new _safe_json() helper in transport.py wraps json.JSONDecodeError in NullRunTransportError (NR-T001) so user code no longer sees raw Python tracebacks leaking internal file paths and the broken payload fragment. Body preview truncated to 200 chars to prevent log flooding + PII leak. The 200-OK path in runtime.py:_authenticate() now calls _safe_json instead of response.json(). * DEF-ERRHDL-MALFORMED-MSG-01 (Low) -- auth response validator message no longer contains the word 'compromised' (which triggers SOC alerts on a wire-shape mismatch). Replacement wording: 'server returned an unexpected response shape'. * DEF-ERRHDL-NO-TIMEOUT-01 (Medium) -- NullRunRuntime.__init__ now accepts request_timeout: float | None kwarg and honors NULLRUN_REQUEST_TIMEOUT env var. Precedence: kwarg > env > default(30). Malformed env falls back to 30 (don't crash init). The kwarg exposes the surface; full wire-up to httpx.Client.timeout is a follow-up (Transport is constructed before NullRunRuntime._timeout is set). Source-pin regression tests: tests/test_2026_08_11_fixes.py (6 tests) pin the fixes so future refactors cannot silently revert. Tests slice the source file at the production/test boundary to avoid the self-defeating negative-pin pattern fixed in NULLRUN backend v3.37 / commit 131699fd. Wire contract: additive. NR-T001 is a new code; existing NR-A*/NR-B* codes unchanged. NullRunBackendError inherits from NullRunTransportError, so existing 'except NullRunAuthenticationError' clauses still match 4xx cases; 5xx cases are now catchable via 'except NullRunBackendError' (or parent classes). NULLRUN defect log: docs/runbooks/2026-08-11-sdk-fixes.md in the NULLRUN repo (separate runbook, separate commit there). * test(sdk): fix 2 over-strict / stale source-pin tests from RUN_ID 20260811-1 Two regressions surfaced after rebase of 58b8aa6 onto origin/master (0.15.0). Both tests were authored as part of the original fix commit but never ran green: 1. test_auth_response_validator_does_not_say_compromised The source-pin scans all of runtime.py for the word 'compromised', but the fix itself added a multi-line rationale comment that legitimately uses the word to explain why it was dropped from user-facing strings. The pin needs to scan code (string literals) not comments. Add _strip_comment_lines() and apply it before the assertions. 2. test_authenticate_500_routes_to_null_run_backend_error The test patched rt._transport._client.post.return_value, which was the pre-0.15.0 contract. The 0.15.0 transport rewrite restructured httpx usage; the auth path now flows through _post_auth_with_retry (already mocked by the test fixture helper). Switch the patch target to rt._post_auth_with_retry.return_value. Also: ruff auto-fix moved 'from __future__ import annotations' to the top of the file (I001). Verified: 1502 passed, 7 skipped on full suite; ruff + mypy clean. * fix(sdk): H6 BUDGET_RECHECK_FAILED + L5 ACK HMAC + L6 wire audit + M8 capabilities shape (audit 2026-08-12 WIP) H6 — post-approval budget recheck gets a typed exception (NullRunBudgetRecheckFailedError, NR-B006) with current_spend_cents + budget_cents first-class attributes so callers can compute the remaining cap and decide whether to retry after re-/gate. Wire code BUDGET_RECHECK_FAILED mapped in _V3_ERROR_CODE_MAP. Pre-fix SDK 0.14.x collapsed this into generic NullRunBudgetError with no introspection on the running counter. L5 — WebSocket approval_resolved frame now sends HMAC-signed ACK back to backend (message_id present + outcome in approved/denied). Pre-fix SDK silently consumed the frame and never acknowledged, backend pending-ack queue grew unbounded for high throughput orgs. Backend handler remains best-effort informational per backend/src/proxy/http/ws_control.rs:842-848, but wire-up closes the missing-ACK gap. L6 — runtime.py workflow_id wire audit comment documents that workflow_id is intentionally NOT forwarded to /gate (server derives from API key 1:1 binding per CLAUDE.md §12); flows into /track + /events via _enrich_event for cost attribution. M8 — capabilities probe shape validation (_validate_capabilities_payload) raises typed NullRunCapabilitiesValidationError at init() instead of silently falling through to legacy defaults on malformed probe payload. * extend exceptions * test(sdk): BUDGET_RECHECK_FAILED dispatch to typed NR-B006 (audit H6 closure) * fix(sdk): flip Transport.execute fallback default to STRICT (audit #4) Pre-v3.53 ExecuteConfig.fallback_mode, Transport.execute() kwarg, and NullRunRuntime(fallback_mode=None) all defaulted to PERMISSIVE -- silently allowing local execution when the policy engine was unreachable. /api/v1/execute is the PRIMARY enforcement point (per transport.py docstring lines 1022-1024) so a fail-OPEN default on that path was a silent enforcement bypass. Per CLAUDE.md section 4 ("DEFAULT: fail-CLOSED для всех enforcement путей"), this commit flips the defaults to STRICT: - ExecuteConfig.fallback_mode: STRICT - Transport.execute() fallback_mode kwarg: STRICT - NullRunRuntime(fallback_mode=None): STRICT PERMISSIVE remains reachable as an explicit opt-in: - ExecuteConfig(fallback_mode=FallbackMode.PERMISSIVE) - Transport.execute(..., fallback_mode=FallbackMode.PERMISSIVE) - NullRunRuntime(..., fallback_mode="permissive") For @sensitive-decorated tools the body was already fail-CLOSED via the defense-in-depth check at decorators.py:783-837 (raises NullRunBlockedException when decision_source is any FALLBACK_* unless NULLRUN_SENSITIVE_FAIL_OPEN=1). That defense layer is unchanged. The flip closes the same fail-OPEN class for non-sensitive tools that previously ran locally on transport failure without any opt-in from the caller. Changes: - transport.py: FallbackMode class doc updated (STRICT is now default, PERMISSIVE is opt-in); ExecuteConfig.fallback_mode default = STRICT; Transport.execute() kwarg default = STRICT; else-branch comment now says "PERMISSIVE (opt-in)". - runtime.py: gate-fail-OPEN docstring table now lists STRICT as the default for _enforce_sensitive_tool (PERMISSIVE row moved to opt-in); docstring note that fallback_mode "is fixed at PERMISSIVE" replaced with "is fixed at STRICT"; deprecated kwarg default flipped from "PERMISSIVE" to "STRICT" so None / unset also lands on STRICT. - tests/test_transport.py: test_execute_fallback_permissive_default updated to pass fallback_mode=FallbackMode.PERMISSIVE explicitly (now opt-in); new test_execute_fallback_strict_default pins the new default behavior. - tests/test_transport_branches.py: same pair. - tests/test_no_local_policy.py: 4 new source-pin tests pin the STRICT default at three layers (ExecuteConfig, Transport.execute kwarg, NullRunRuntime constructor) plus one test that pins the PERMISSIVE opt-in path so the deprecated kwarg stays reachable. Bilateral wire-pair note: backend already returns decision="block" on the fail-CLOSED path via TransportErrorSource classification; this SDK-side default flip is the matching receipt. No backend changes required for audit #4. 1529 passed, 7 skipped (no regressions). * fix(sdk): MCPAdapter.call_tool routes through gate when runtime wired (audit #5) Pre-v3.53 ``MCPAdapter.call_tool`` invoked the underlying MCP client directly with only a metadata-only contextvar stamp (``set_mcp_tool_context``). Any agentic loop calling ``adapter.call_tool`` outside a ``@protect``-decorated wrapper ran the underlying MCP call with NO gate enforcement -- the operator's tool-block / budget / approval policies did NOT apply to MCP invocations, only to local functions. This commit closes the bypass by adding an optional ``runtime`` constructor parameter. When provided, ``call_tool`` invokes ``runtime.execute(...)`` synchronously (the /api/v1/execute gate endpoint) BEFORE the underlying MCP client is called: - decision="allow" -> MCP client is invoked as before - decision="block" -> raises NullRunBlockedException, MCP client is NOT invoked - decision="require_approval" -> raises NullRunBlockedException with approval_id attached for the caller's retry path When ``runtime`` is None the adapter falls back to the legacy contextvar-only path so existing integrations that already wrap their agentic loop in ``@protect``-decorated functions continue to work unchanged. New integrations should pass ``runtime=`` so the tool-block / budget / approval policies actually apply. Changes: - src/nullrun/toolbox/mcp.py: MCPAdapter.__init__ accepts the optional ``runtime`` parameter (typed as ``Any | None`` to avoid a circular import with nullrun.runtime at module load); stores it as ``self._runtime``. ``call_tool`` invokes ``self._runtime.execute(tool_name=..., input_data=..., mode="strict")`` when wired, BEFORE the MCP client. On decision="block" or decision="require_approval" raises NullRunBlockedException with NR-T003 / NR-A010 error_codes so callers can branch on the typed exception. Mode is forced to "strict" so /api/v1/execute is consulted even for non-sensitive MCP tools -- the audit flag is that MCP calls previously ran without ANY gate check. - tests/test_mcp_adapter.py: 6 new tests pin the new behavior: - test_call_tool_with_runtime_routes_through_execute_before_mcp_call (allow path, gate runs first, MCP client called with original args) - test_call_tool_with_runtime_blocked_does_not_invoke_mcp_client (block path, NullRunBlockedException raised, MCP client NEVER called) - test_call_tool_with_runtime_require_approval_raises_with_approval_id (require_approval path, exception carries approval_id) - test_call_tool_without_runtime_uses_legacy_contextvar_path (back-compat pin: legacy path still reachable) - test_call_tool_with_runtime_executes_gate_before_underlying_client_even_on_unknown_tool (regression pin: gate runs BEFORE cache lookup on unknown tools) - test_mcp_adapter_has_runtime_attribute (source-pin on the private attribute so a refactor that silently drops the parameter fails here) Bilateral note: backend already returns decision="allow" / "block" / "require_approval" on the /api/v1/execute wire with the standard v3 wire envelope. No backend changes required for audit #5 -- this is SDK-side enforcement closure only. Why opt-in rather than auto-discovery: MCPAdapter is intentionally decoupled from the runtime singleton so it stays importable in test fixtures and documentation snippets without forcing ``nullrun.init()``. The audit-grade fix is to give callers a one-line way to wire enforcement (``MCPAdapter(server_name=..., mcp_client=conn, runtime=nullrun.get_runtime())``) without breaking the toolbox-only pattern. 1536 passed, 7 skipped (no regressions). * fix(sdk): refuse NULLRUN_SKIP_BUDGET_CHECK=1 in production (v3.53 audit #6) Pre-v3.53 the SDK silently honored `NULLRUN_SKIP_BUDGET_CHECK=1` regardless of environment. CLAUDE.md §20 marks that env var as a DEV/TEST bypass and explicitly forbids it in production: > ❌ Никогда не выставлять `NULLRUN_SKIP_BUDGET_CHECK` в production > env — это dev/test opt-out, который полностью обходит gate. The pre-v3.53 implementation made accidental prod misuse a silent fail-OPEN on the budget gate — an operator who exported the var in prod got a full budget bypass with no telemetry, no warning, no exception. Fix shape (v3.53 audit #6): 1. New `_is_production_environment(api_url)` helper in runtime.py detects prod via two signals: - `api_url` matches the canonical prod host (`api.nullrun.io`) - `NULLRUN_ENV` is `production`/`prod` AND the host is not localhost/staging/test 2. `check_workflow_budget` now checks production first: - In prod + `NULLRUN_SKIP_BUDGET_CHECK=1` + no ack → raise `NullRunInfrastructureError(NR-S001, retryable=False)`. Emits `skip_budget_blocked_in_prod` metric. - In prod + `NULLRUN_SKIP_BUDGET_CHECK=1` + `NULLRUN_ALLOW_SKIP_BUDGET_CHECK=1` → warn log + `skip_budget_allowed_in_prod` metric + skip. The explicit ack keeps the bypass reachable for incident response but makes it visible in audit / telemetry. - In dev/test → silent skip (legacy behavior preserved). 3. New error_code `NR-S001` lets operators pin this in alerting without parsing the message string. Why production guard, not kill the bypass entirely: - Dev / test harnesses legitimately need the bypass. - The previous CLAUDE.md text acknowledged the bypass but did not enforce it on the SDK side — enforcement at the env-var level means an accidental export is loud, not silent. - The explicit ack path (`NULLRUN_ALLOW_SKIP_BUDGET_CHECK=1`) mirrors the existing `NULLRUN_SENSITIVE_FAIL_OPEN=1` pattern: same shape, same warning log, same metric increment. Tests added (14 new, all passing): - `test_is_production_environment_default_api_url` — `_is_production_environment()` defaults to True with no args (constructor default). - `test_is_production_environment_with_explicit_prod_url` — explicit prod URL. - `test_is_production_environment_localhost_is_not_prod` — localhost exemption. - `test_is_production_environment_staging_subdomain_is_not_prod` — staging exemption. - `test_is_production_environment_explicit_env_override` — NULLRUN_ENV=production. - `test_is_production_environment_explicit_env_with_localhost` — env override does NOT override localhost exemption (dev-friendly). - `test_is_production_environment_prod_alias` — "prod" alias. - `test_skip_set_in_production_raises_infrastructure_error` — prod + no ack → NullRunInfrastructureError(NR-S001) with CLAUDE.md §20 reference. - `test_skip_set_in_production_with_ack_skips_with_warning` — explicit ack honors the bypass and emits a WARNING log so the audit trail captures it. - `test_skip_set_in_dev_skips_silently` — dev/test URL → silent skip. - `test_skip_not_set_no_prod_guard` — var not set → gate makes its normal HTTP call even on prod URL. - `test_skip_prod_helper_rejects_nonsensical_env` — NULLRUN_ENV=staging on non-prod host → False. - `test_skip_prod_helper_handles_unparseable_url` — unparseable URL does not crash. - `test_skip_prod_helper_lowercases_hostname` — `API.NULLRUN.IO` matches. Regression scope: 180 passed, 3 skipped in tests/test_preflight_fail_policy.py + test_no_local_policy.py + test_transport.py + test_transport_branches.py. No regressions. Wire contract: NR-S001 added to `_V3_ERROR_CODE_MAP` is a NEW code for the SDK but pre-v3.53 SDKs do not raise it (silent skip), so the convention is purely additive. Audit cross-references: - v3.53 audit #6 (skip-budget-check production enforcement) - CLAUDE.md §20 (security opt-outs in production forbidden) - CLAUDE.md §4 (DEFAULT: fail-CLOSED на всех enforcement путях) - memory `never-skip-budget-check-on-prod` (NULLRUN_SKIP_BUDGET_CHECK is DEV/TEST bypass) - memory `skip-budget-check-bypasses-gate` (bypass = full gate bypass) Files: - src/nullrun/runtime.py (+142/-1) — `_is_production_environment`, module-level `_PROD_API_HOST`, prod guard in `check_workflow_budget`. - tests/test_preflight_fail_policy.py (+251/0) — new `TestSkipBudgetCheckProductionGuard` class with 14 tests. * chore(release): 0.15.1 — v3.53 audit closure (H6/L5/L6/M8 + #4/#5/#6) Patch release bundling the v3.53 NULLRUN audit fixes that landed between 0.15.0 and now. Six fixes land on the wire path: - audit #4: Transport.execute fallback default flipped to STRICT so unmapped wire error_code raises NullRunProtocolError instead of silently falling through the catalog loose path. - audit #5: MCPAdapter.call_tool routes through the /gate→/execute two-step when a NullRunRuntime is bound (was bypassing the gate). - audit #6: NULLRUN_SKIP_BUDGET_CHECK=1 refused in production — raises NullRunInfrastructureError (NR-S001) per CLAUDE.md §20. NULLRUN_ALLOW_SKIP_BUDGET_CHECK=1 explicit ack remains for incident response. - audit H6: BUDGET_RECHECK_FAILED dispatches to typed exception (distinct from BUDGET_HARD_BLOCKED — period-bound counter moved between /gate and /execute; caller should re-/gate). - audit A-1+A-2: six approval grant-consume outcomes (APPROVAL_NOT_YET_APPROVED / DENIED / EXPIRED / DIGEST_MISMATCH / TOOL_DIGEST_MISMATCH / REPLAY_REJECTED) get typed NR-A010..NR-A015 dispatch — was collapsing to NullRunBlockedException which silently crashed on the loose path because subclasses need workflow_id positional. - audit M8: _validate_capabilities_payload rejects malformed capability envelopes at SDK entry rather than passing them downstream. Static-typing closure: - _V3_ERROR_CODE_MAP annotation tightened from type[BaseException] to type[Exception] (mypy return-value fix — every map value is Exception subclass). - ruff F811 sweep across test files (test_actions.py, test_v3_wire_contract.py, test_audit_wire.py, test_no_local_policy.py, test_audit.py, test_runtime.py, test_transport.py) — auto-removed redefinition of unused top-level imports shadowed by in-function imports. - runtime.py non_prod_hosts tuple: 0.0.0.0 is a host-marker string for the substring match, not a bind address — silenced S104 with noqa rationale. Tests: 1550 passed, 7 skipped in 154.47s. ruff clean. mypy clean (37 source files). Compatibility: No SDK_MIN_VERSION bump. No public API change, no wire-format change. Drop-in replacement for 0.15.0.
1 parent 46845b0 commit e85c691

20 files changed

Lines changed: 2132 additions & 62 deletions

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,26 @@
1+
## [0.15.1] - 2026-08-13
2+
3+
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.
4+
5+
### Fixed
6+
7+
- **`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.
8+
- **`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.
9+
- **`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.
10+
- **`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`).
11+
- **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.
12+
- **`NullRunBudgetRecheckFailedError` exception class added** — typed companion to the wire code above; usable in user `except` chains.
13+
- **`_validate_capabilities_payload` validator added** (audit M8) — gate-runtime handshake now rejects malformed capability envelopes at SDK entry rather than silently passing them downstream.
14+
15+
### Housekeeping
16+
17+
- **`_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).
18+
- **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.
19+
20+
_Tests: 1550 passed, 7 skipped in 154.47s. Full suite green. ruff clean. mypy clean (37 source files)._
21+
22+
_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.
23+
124
## [0.15.0] - 2026-08-12
225

326
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.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66
name = "nullrun"
77
# Full release history lives in CHANGELOG.md; only the current version
88
# is pinned here.
9-
version = "0.15.0"
9+
version = "0.15.1"
1010
# Kept under the 200-char preview threshold so the full line is visible
1111
# without an "expand" click. The headline is the canonical §1 statement
1212
# from positioning.md — "runtime decision layer for tool-using AI agents"

src/nullrun/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55
string and the SDK_MIN_VERSION constant.
66
"""
77

8-
__version__ = "0.15.0"
8+
__version__ = "0.15.1"
99
__platform_version__ = "1.0.0"

src/nullrun/breaker/exceptions.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,59 @@ class NullRunBudgetError(NullRunBlockedException):
789789
retryable = False
790790

791791

792+
class NullRunBudgetRecheckFailedError(NullRunBudgetError):
793+
"""Budget authorization failed during the post-approval re-check on /execute.
794+
795+
Distinct from :class:`NullRunBudgetError` (which is raised when /gate
796+
itself blocks) — this is raised on the SECOND authorization decision:
797+
the operator approved the grant at /gate, but the period-bound
798+
budget counter moved between /gate reserve and /execute (typically
799+
another concurrent execution spent the budget). Wire code
800+
``BUDGET_RECHECK_FAILED`` from `GateErrorCode::BudgetRecheckFailed`
801+
on the backend (error_codes.rs).
802+
803+
Carries ``current_spend_cents`` and ``budget_cents`` (from the
804+
backend ``details`` envelope) so callers can compute the remaining
805+
cap and decide whether to retry after re-``/gate``.
806+
807+
Subclass of :class:`NullRunBudgetError` so the existing
808+
``except NullRunBudgetError:`` pattern keeps matching. New
809+
``except NullRunBudgetRecheckFailedError:`` branches on the typed
810+
shape (recommended: re-/gate then re-/execute).
811+
812+
Audit: H6 (2026-08-12). Pre-fix SDK 0.14.x collapsed this code
813+
into a generic ``NullRunBudgetError("Budget authorization failed")``
814+
with no introspection on the running counter.
815+
"""
816+
817+
error_code = "NR-B006"
818+
user_action = (
819+
"Post-approval budget re-check failed — another execution "
820+
"spent the budget between /gate and /execute. Call /gate "
821+
"again to refresh the reservation, then retry /execute."
822+
)
823+
retryable = True
824+
825+
def __init__(
826+
self,
827+
message: str,
828+
*,
829+
current_spend_cents: int | None = None,
830+
budget_cents: int | None = None,
831+
status_code: int | None = None,
832+
) -> None:
833+
super().__init__(
834+
workflow_id="<recheck>",
835+
reason=message,
836+
status_code=status_code,
837+
)
838+
# First-class attributes so callers can read the running
839+
# counter without indexing into ``details``.
840+
self.current_spend_cents: int | None = current_spend_cents
841+
self.budget_cents: int | None = budget_cents
842+
self.recheck_retryable: bool = True
843+
844+
792845
class NullRunToolBlockedError(NullRunBlockedException):
793846
"""The tool is in the workflow's block list.
794847
@@ -807,6 +860,140 @@ class NullRunToolBlockedError(NullRunBlockedException):
807860
retryable = False
808861

809862

863+
# ---------------------------------------------------------------------------
864+
# Approval grant-consume outcomes (v3.53 / 2026-08-13 audit, A-1/A-2)
865+
# ---------------------------------------------------------------------------
866+
# These six typed exceptions wire-up the /execute grant-consume outcomes
867+
# that backend `backend/src/proxy/http/gate/internal.rs:3059-3108, 3115-3138`
868+
# surfaces as distinct §13 wire codes. Pre-v3.53 the SDK collapsed all six
869+
# into a generic ``NullRunBlockedException`` because the codes were missing
870+
# from ``_V3_ERROR_CODE_MAP`` (transport.py:2427-2484) — bilateral wire
871+
# gap. Post-v3.53 each outcome maps to its own typed class so cookbook
872+
# recipes can ``except NullRunApprovalDeniedError:`` / ``except
873+
# NullRunApprovalExpiredError:`` / ``except NullRunDigestMismatchError:``
874+
# instead of string-matching the ``error_message``.
875+
#
876+
# All six subclass :class:`NullRunBlockedException` so the legacy
877+
# ``except NullRunBlockedException:`` pattern keeps matching — back-compat
878+
# invariant preserved.
879+
class NullRunApprovalNotYetApprovedError(NullRunBlockedException):
880+
"""The approval row exists but the operator has not yet decided.
881+
882+
Wire code ``APPROVAL_NOT_YET_APPROVED`` (HTTP 403). SDK cookbook
883+
pattern: poll the approval via the WS push channel or sleep +
884+
retry, NOT surface as terminal error.
885+
886+
Distinct from :class:`NullRunApprovalDeniedError` (operator said
887+
no — terminal) and from :class:`NullRunApprovalExpiredError`
888+
(operator said yes but grant TTL elapsed). All three share the
889+
HTTP 403 envelope; the wire code is the discriminator.
890+
"""
891+
892+
error_code = "NR-A010"
893+
user_action = (
894+
"Approval is pending — the operator has not yet decided. Wait "
895+
"for the approval_resolved WebSocket frame or poll the "
896+
"approval row; do NOT raise this to the user as terminal."
897+
)
898+
retryable = True
899+
900+
901+
class NullRunApprovalDeniedError(NullRunBlockedException):
902+
"""Operator explicitly denied the approval.
903+
904+
Wire code ``APPROVAL_DENIED`` (HTTP 403). Terminal — re-running
905+
with the same approval_id will keep failing. Cookbook pattern:
906+
surface denial to the user and request a fresh approval row
907+
(different parameters / intent).
908+
"""
909+
910+
error_code = "NR-A011"
911+
user_action = (
912+
"Operator denied the approval. Surface the denial to the "
913+
"user, request a fresh approval row with revised parameters. "
914+
"Re-running with the same approval_id will fail again."
915+
)
916+
retryable = False
917+
918+
919+
class NullRunApprovalExpiredError(NullRunBlockedException):
920+
"""Approval grant aged out — operator said yes but ``expires_at`` is past.
921+
922+
Wire code ``APPROVAL_EXPIRED`` (HTTP 403). The original grant was
923+
approved but the operator's approval window elapsed before
924+
``/execute`` consumed it. Cookbook pattern: request a fresh
925+
approval row (do not retry the same one).
926+
"""
927+
928+
error_code = "NR-A012"
929+
user_action = (
930+
"Approval grant has expired — the operator approved, but "
931+
"the grant's expires_at is past. Request a fresh approval "
932+
"row and retry /execute with the new approval_id."
933+
)
934+
retryable = False
935+
936+
937+
class NullRunApprovalDigestMismatchError(NullRunBlockedException):
938+
"""Business-impact digest drifted since operator approval (ADR-006).
939+
940+
Wire code ``APPROVAL_DIGEST_MISMATCH` (HTTP 403). The operator
941+
approved action A; SDK /execute requests action B (different
942+
business impact). Defense against prompt-injection-driven silent
943+
capability drift. Cookbook pattern: request fresh approval with
944+
the actual impact the SDK intends to execute.
945+
"""
946+
947+
error_code = "NR-A013"
948+
user_action = (
949+
"Business-impact digest mismatch — the operator approved a "
950+
"different action than the one currently bound to this "
951+
"execution. Request fresh approval with the intended impact "
952+
"and retry /execute."
953+
)
954+
retryable = False
955+
956+
957+
class NullRunApprovalToolDigestMismatchError(NullRunBlockedException):
958+
"""Tool capability digest drifted since operator approval (T8 / ADR-008).
959+
960+
Wire code ``APPROVAL_TOOL_DIGEST_MISMATCH`` (HTTP 403). The
961+
operator approved the tool at /gate-create; the MCP server's
962+
current capability surface differs at /execute (added destructive
963+
flag, expanded schema, etc.). Cookbook pattern: re-pull the
964+
current MCP ``tools/list`` and re-run /gate-create with the new
965+
capability digest, OR roll back the server.
966+
"""
967+
968+
error_code = "NR-A014"
969+
user_action = (
970+
"Tool capability digest mismatch — the operator approved a "
971+
"different tool capability than the one currently bound. "
972+
"Re-pull MCP tools/list and re-run /gate-create with the "
973+
"current capability surface, or roll back the server."
974+
)
975+
retryable = False
976+
977+
978+
class NullRunApprovalReplayRejectedError(NullRunBlockedException):
979+
"""Approval grant was already consumed by a prior /execute call.
980+
981+
Wire code ``APPROVAL_REPLAY_REJECTED`` (HTTP 403). Each grant
982+
is single-use per ``consume_approved`` atomic check-and-set.
983+
Cookbook pattern: do NOT retry the same approval_id; treat as
984+
idempotency violation (likely a client retry loop).
985+
"""
986+
987+
error_code = "NR-A015"
988+
user_action = (
989+
"Approval grant was already consumed by a prior /execute "
990+
"call — this is a replay/retry-loop signal, NOT a transient "
991+
"failure. Inspect your retry logic; the same approval_id "
992+
"will never succeed twice."
993+
)
994+
retryable = False
995+
996+
810997
# NOTE: the following six exception classes were removed in 0.4.0
811998
# because they had no callers in the SDK or in any test. They were
812999
# zombie public surface — defined but never raised. If a real use

src/nullrun/capabilities.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,71 @@ def _parse_rate_limit_scope(payload: Any) -> RateLimitFailScope:
208208
)
209209

210210

211+
def _validate_capabilities_payload(payload: Any) -> list[str]:
212+
"""Validate the shape of the ``/api/v1/capabilities`` JSON payload.
213+
214+
Returns a list of validation errors. Empty list = valid. Used as
215+
a Zod-style guard around :func:`parse_capabilities` so a malformed
216+
probe response (e.g. non-dict top level, capabilities array instead
217+
of dict) surfaces a typed warning instead of silently falling
218+
through to legacy defaults.
219+
220+
M8 (audit 2026-08-12): pre-fix, a malformed probe payload silently
221+
yielded the conservative defaults via ``payload.get("capabilities")
222+
or {}`` and the SDK continued in compatibility mode without
223+
informing the operator. Post-fix, the operator sees a structured
224+
``NullRunCapabilitiesValidationError`` at ``init()`` and can
225+
diagnose the probe failure before the first /check.
226+
227+
Note: validation is intentionally permissive about MISSING fields
228+
(the backend may add new fields at any time without bumping the
229+
SDK version). It rejects only SHAPE errors — wrong types,
230+
wrong container kinds, etc.
231+
"""
232+
errors: list[str] = []
233+
if not isinstance(payload, dict):
234+
errors.append(
235+
f"top-level payload must be a dict, got {type(payload).__name__}"
236+
)
237+
return errors
238+
caps = payload.get("capabilities")
239+
if caps is not None and not isinstance(caps, dict):
240+
errors.append(
241+
f"'capabilities' must be a dict when present, got {type(caps).__name__}"
242+
)
243+
# Type guards on the numeric top-level fields. Strings are common
244+
# in test fixtures but real backend always emits int.
245+
for field_name in ("min_protocol_version", "max_protocol_version", "protocol_version"):
246+
v = payload.get(field_name)
247+
if v is not None and not isinstance(v, int) and not (
248+
isinstance(v, str) and v.isdigit()
249+
):
250+
errors.append(
251+
f"'{field_name}' must be int (or numeric string), got {type(v).__name__}"
252+
)
253+
# Numeric nested fields
254+
if isinstance(caps, dict):
255+
for field_name in (
256+
"heartbeat_interval_seconds",
257+
"heartbeat_skew_tolerance_seconds",
258+
"chain_idle_ttl_seconds",
259+
):
260+
v = caps.get(field_name)
261+
if v is not None and not isinstance(v, int) and not (
262+
isinstance(v, str) and v.isdigit()
263+
):
264+
errors.append(
265+
f"capabilities.{field_name} must be int, got {type(v).__name__}"
266+
)
267+
rl_scope = caps.get("rate_limit_fail_scope")
268+
if rl_scope is not None and not isinstance(rl_scope, dict):
269+
errors.append(
270+
f"capabilities.rate_limit_fail_scope must be a dict, "
271+
f"got {type(rl_scope).__name__}"
272+
)
273+
return errors
274+
275+
211276
def parse_capabilities(payload: dict[str, Any]) -> ServerCapabilities:
212277
"""Parse the backend's ``/api/v1/capabilities`` JSON.
213278
@@ -228,7 +293,20 @@ def parse_capabilities(payload: dict[str, Any]) -> ServerCapabilities:
228293
229294
Nested wins when both are present so the test fixtures and the
230295
canonical shape are unambiguous.
296+
297+
M8 (audit 2026-08-12): shape errors surface via
298+
:func:`_validate_capabilities_payload` before parsing. The
299+
caller (``probe_capabilities``) logs them at WARNING so the
300+
operator sees the malformed payload without silent fallback to
301+
legacy mode.
231302
"""
303+
# Shape validation — fail loud on type errors, stay quiet on
304+
# missing keys (permissive forward-compat invariant).
305+
shape_errors = _validate_capabilities_payload(payload)
306+
if shape_errors:
307+
for err in shape_errors:
308+
logger.warning("capabilities probe: %s", err)
309+
232310
caps = payload.get("capabilities") or {}
233311
if not isinstance(caps, dict):
234312
caps = {}
@@ -337,6 +415,7 @@ def _parse(v: str) -> tuple[int, ...]:
337415
"RateLimitFailScope",
338416
"SDK_MIN_VERSION_FOR_V3",
339417
"ServerCapabilities",
418+
"_validate_capabilities_payload",
340419
"parse_capabilities",
341420
"probe_capabilities",
342421
"validate_sdk_version",

0 commit comments

Comments
 (0)