Skip to content

chore(release): 0.15.1 — v3.53 audit closure (H6/L5/L6/M8 + #4/#5/#6) - #89

Merged
maltsev-dev merged 9 commits into
masterfrom
release/0.15.1
Aug 13, 2026
Merged

chore(release): 0.15.1 — v3.53 audit closure (H6/L5/L6/M8 + #4/#5/#6)#89
maltsev-dev merged 9 commits into
masterfrom
release/0.15.1

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

Summary

Patch release 0.15.1 — bundles the v3.53 NULLRUN audit fixes that landed between 0.15.0 and now. Drop-in replacement for 0.15.0 (no public API change, no wire-format change, no SDK_MIN_VERSION bump).

What's in

Audit fixes on the wire path

  • audit fix(ci): add langchain-core to [dev] so test collection passes #4 (STRICT fallback)Transport.execute raises NullRunProtocolError on unmapped wire error_code (was silently falling through the catalog loose path).
  • audit wip: post-merge sync of 0.3.0->0.4.0 migration working tree #5 (MCP gate routing)MCPAdapter.call_tool now mirrors /gate/execute two-step when a NullRunRuntime is bound (was bypassing the gate).
  • audit fix(ci): add Callable to typing imports in runtime.py #6 (NULLRUN_SKIP_BUDGET_CHECK=1 production guard) — raises NullRunInfrastructureError (NR-S001) per CLAUDE.md §20; explicit ack NULLRUN_ALLOW_SKIP_BUDGET_CHECK=1 remains for incident response.
  • audit H6 (BUDGET_RECHECK_FAILED typed dispatch) — distinct from BUDGET_HARD_BLOCKED — period-bound counter moved between /gate and /execute; caller should re-/gate.
  • audit A-1+A-2 (6 approval grant-consume outcomes get NR-A010..NR-A015) — was collapsing to NullRunBlockedException which silently crashed on loose-path signature mismatch.
  • audit M8 (_validate_capabilities_payload) — rejects malformed capability envelopes at SDK entry.

Static-typing + lint closure

  • _V3_ERROR_CODE_MAP annotation: type[BaseException]type[Exception] (mypy return-value closure).
  • ruff F811 sweep across 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.
  • runtime.py non_prod_hosts tuple: 0.0.0.0 is a host-marker string for substring match, not a bind address — silenced S104 with noqa rationale.

Verification

  • Tests: 1550 passed, 7 skipped in 154.47s
  • ruff: clean
  • mypy: clean (37 source files)

Commits in this PR (vs origin/master)

a727522 chore(release): 0.15.1 — v3.53 audit closure (H6/L5/L6/M8 + #4/#5/#6)
7038fe0 fix(sdk): refuse NULLRUN_SKIP_BUDGET_CHECK=1 in production (v3.53 audit #6)
7ac1542 fix(sdk): MCPAdapter.call_tool routes through gate when runtime wired (audit #5)
f58fa5f fix(sdk): flip Transport.execute fallback default to STRICT (audit #4)
09e673f test(sdk): BUDGET_RECHECK_FAILED dispatch to typed NR-B006 (audit H6 closure)
f887219 extend exceptions
7c4fc11 fix(sdk): H6 BUDGET_RECHECK_FAILED + L5 ACK HMAC + L6 wire audit + M8 capabilities shape
e8eec15 test(sdk): fix 2 over-strict / stale source-pin tests from RUN_ID 20260811-1
9c40ae2 fix(sdk): 5xx + invalid-JSON + compromised-wording + request_timeout (RUN_ID 20260811-1)

Compatibility

No SDK_MIN_VERSION bump. Drop-in replacement for 0.15.0.

…(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).
…60811-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.
… 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.
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).
… (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).
#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.
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.
@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.57143% with 30 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/nullrun/capabilities.py 44.00% 8 Missing and 6 partials ⚠️
src/nullrun/runtime.py 87.50% 6 Missing ⚠️
src/nullrun/transport.py 64.70% 6 Missing ⚠️
src/nullrun/transport_websocket.py 0.00% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

@maltsev-dev
maltsev-dev merged commit e85c691 into master Aug 13, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant