fix(ci): add Callable to typing imports in runtime.py - #6
Merged
Conversation
Runtime.py uses Callable[[Exception], dict[str, Any]] in the NullRunRuntime.execute signature (line 1392) but the typing import only had Any, Optional. Under Python 3.11 (CI matrix) the class body evaluates annotations eagerly, so the missing import raises NameError at *collection* time and every test errors with 'ERROR collecting tests/test_*.py - NameError: name Callable is not defined' before pytest can even run a single test. Python 3.14 happens to defer annotation evaluation so the same code passes locally; that masked the bug during development and during the previous local pytest run (443/443 passed). The bug is purely a missing import - adding Callable to the existing 'from typing import Any, Optional' line fixes all 19 collection errors and lets the test matrix reach the actual test cases. This is a pre-existing bug, not caused by the byte-mismatch or S-2 fixes; it survived the initial import commit (316a694) and the wip/working-tree migration (1244901) because no one ran the 3.11 matrix on a workstation with the right tooling. The fix is mechanical: one identifier added to one import line.
test_track_span_context.py uses 'from tests.conftest import BASE_URL' which requires the tests/ directory to be importable as a top-level package. On Python 3.10/3.11 (CI matrix) pytest's rootdir discovery lands on the repo root rather than the tests/ directory, so 'tests' is not on sys.path and the import raises ModuleNotFoundError at collection time, failing one test: FAILED tests/test_track_span_context.py::test_module_level_track_llm_output_tokens_optional ModuleNotFoundError: No module named 'tests' On Python 3.14 the same code passes because pytest-asyncio / hatchling pyproject discovery adds the repo root to sys.path. 3.10/3.11 don't get that for free. Add 'pythonpath = ["."]' to [tool.pytest.ini_options] so all Python versions in the supported matrix resolve 'tests' as a top-level module.
8 tasks
maltsev-dev
added a commit
that referenced
this pull request
Jun 19, 2026
Closes the P0/P1/P2/P3 issues from the security review (plan §10/§11.4).
Security / PCI-DSS / GDPR
- P0-1: Mask positional PII in `_enforce_sensitive_tool` by introspecting
the wrapped function's signature and applying `SENSITIVE_ARG_KEYS` to
positional params. Pre-fix, `charge("4111-…-1111", 50)` forwarded the
PAN into `/execute` and the audit log.
- P0-6 / P3-3: `_safe_repr` now redacts BEFORE truncating. The pre-fix
order truncated first, so `details={…}` past position 50 leaked
verbatim. `_safe_repr` is now the single source of truth for the
redact-then-truncate flow.
Cost-audit / reliability
- P0-3: Bounded chunked reads on the sync + async httpx transports
(`MAX_RESPONSE_BYTES`, default 16 MiB, `NULLRUN_MAX_RESPONSE_BYTES`
env override). Above the cap, tracking is skipped and
`_coverage_streaming_skipped` is incremented. Replaces the
`response.read()` / `await response.aread()` unbounded buffer that
held entire LLM streaming bodies in memory.
- P0-4: `_do_flush_locked` re-queue on CB OPEN now drops the NEWEST
non-critical events instead of the oldest. The oldest events
(incident start, billing-period start) are exactly what a billing
investigator needs; losing them silently broke monthly rollups.
Control-plane events (`state_change`, `kill_received`,
`policy_invalidated`, `key_rotated`) are preserved unconditionally
so the dashboard KILL switch lands even under sustained backend
outage.
Identity
- S-8 / P2-4: `agent()` now emits `str(uuid.uuid4())` (with dashes).
Pre-fix the format was `f"agent-{uuid.uuid4().hex}"` — 32 hex chars,
no dashes — and backend UUID-typed columns dropped these to NULL
on insert. User-supplied names are still preserved verbatim.
- §7.2 #16: `workflow()` context manager now resets `span_id` (not
only `workflow_id` / `trace_id`) so nested `with span()` blocks
don't leave the inner span_id visible inside the workflow scope.
Resource leaks
- S-9: `_active_runs` on `NullRunCallback` is now an `OrderedDict`
capped at 4096 with FIFO eviction. Pre-fix the dict grew
unbounded when `on_chain_end` did not fire (some LangChain
versions short-circuit the end hook on chain-body errors).
- S-10: WebSocket reconnect loop is now capped at 10 consecutive
failures, then falls back to HTTP-poll. Pre-fix the loop ran
forever when the backend was permanently down, leaking the
WS thread.
Transport
- §7.2 #6: Separate `hmac_verify_expired_total` counter so SRE can
distinguish clock-skew (NTP drift) from forged packets. Mirrored
in both the HTTP and WebSocket verify paths.
- §7.2 #35: `CircuitBreaker.call` now dispatches the OPEN→HALF_OPEN
jitter through `_maybe_apply_open_jitter_sync` /
`_maybe_apply_open_jitter_async`. Pre-fix the jitter used
`time.sleep` before dispatching to async, which blocked the
caller's event loop on every transition.
- P2-1: `_coverage_seen` now bumps in the httpx path (sync + async).
Pre-fix the counter was only bumped by the `requests` transport,
so the dashboard's coverage view was empty for the dominant
OpenAI / Anthropic / Gemini / Mistral / Cohere traffic.
- P2-3: `is_sensitive_tool` match is case-insensitive. Pre-fix
`"stripe.charge"` did not match `"Stripe.Charge"`, bypassing the
sensitive gate.
Concurrency
- §7.2 #39: New `_tools_lock` guards every mutation of
`_strict_mode_tools` / `_sensitive_tools`. Same lock guards the
coverage-counter bump+prune sequence (§7.2 #33) so two threads
can't both observe the dict at length 4095 and both grow it to
4097 before either prune lands.
- §7.2 #47: New `_langchain_lock` / `_langgraph_lock` guard the
patch sequences end-to-end. Pre-fix two threads racing through
`auto_instrument` could both pass the early `_x_patched` check
and double-wrap `BaseCallbackManager` / `Pregel`.
- §7.2 #33: `_COVERAGE_CAP` (4096) bounds the per-host coverage
dicts.
Webhook delivery
- P3-2: Exponential backoff (0.5s, 1s, 2s, 4s, 8s, 16s, 30s cap)
replaces the previous linear schedule. Linear didn't back off
fast enough under sustained outage — each KILL/PAUSE spawned
its own delivery thread, producing 1000+ spinning threads
hammering the dead endpoint.
WAL crash-recovery
- P1-5b: Atomic WAL writes (tmp + `fsync` + `os.replace`), 64 MiB
rotation with `os.replace(wal, wal.1)`, replay drains both
`wal.1` and `wal`. New `NULLRUN_WAL_PATH` / `NULLRUN_WAL_MAX_BYTES`
env overrides for containers with `readOnlyRootFilesystem: true`.
Tests
8 new regression test files (57 tests total):
test_agent_id_uuid.py, test_args_pii_masked.py,
test_streaming_oom_cap.py, test_lru_active_runs.py,
test_reconnect_cap.py, test_coverage_seen_httpx.py,
test_webhook_backoff.py, test_redact.py
`test_buffer_invariants.py` extended with drop-newest +
critical-event preservation cases. `test_release_polish.py`
updated to pin the 5s cap on both the sync and async jitter
helpers (post §7.2 #35 split).
Full incident write-ups in CHANGELOG.md under the same P0/S/P tags.
maltsev-dev
added a commit
that referenced
this pull request
Jun 19, 2026
* fix: P0 security/stability hardening bundle
Closes the P0/P1/P2/P3 issues from the security review (plan §10/§11.4).
Security / PCI-DSS / GDPR
- P0-1: Mask positional PII in `_enforce_sensitive_tool` by introspecting
the wrapped function's signature and applying `SENSITIVE_ARG_KEYS` to
positional params. Pre-fix, `charge("4111-…-1111", 50)` forwarded the
PAN into `/execute` and the audit log.
- P0-6 / P3-3: `_safe_repr` now redacts BEFORE truncating. The pre-fix
order truncated first, so `details={…}` past position 50 leaked
verbatim. `_safe_repr` is now the single source of truth for the
redact-then-truncate flow.
Cost-audit / reliability
- P0-3: Bounded chunked reads on the sync + async httpx transports
(`MAX_RESPONSE_BYTES`, default 16 MiB, `NULLRUN_MAX_RESPONSE_BYTES`
env override). Above the cap, tracking is skipped and
`_coverage_streaming_skipped` is incremented. Replaces the
`response.read()` / `await response.aread()` unbounded buffer that
held entire LLM streaming bodies in memory.
- P0-4: `_do_flush_locked` re-queue on CB OPEN now drops the NEWEST
non-critical events instead of the oldest. The oldest events
(incident start, billing-period start) are exactly what a billing
investigator needs; losing them silently broke monthly rollups.
Control-plane events (`state_change`, `kill_received`,
`policy_invalidated`, `key_rotated`) are preserved unconditionally
so the dashboard KILL switch lands even under sustained backend
outage.
Identity
- S-8 / P2-4: `agent()` now emits `str(uuid.uuid4())` (with dashes).
Pre-fix the format was `f"agent-{uuid.uuid4().hex}"` — 32 hex chars,
no dashes — and backend UUID-typed columns dropped these to NULL
on insert. User-supplied names are still preserved verbatim.
- §7.2 #16: `workflow()` context manager now resets `span_id` (not
only `workflow_id` / `trace_id`) so nested `with span()` blocks
don't leave the inner span_id visible inside the workflow scope.
Resource leaks
- S-9: `_active_runs` on `NullRunCallback` is now an `OrderedDict`
capped at 4096 with FIFO eviction. Pre-fix the dict grew
unbounded when `on_chain_end` did not fire (some LangChain
versions short-circuit the end hook on chain-body errors).
- S-10: WebSocket reconnect loop is now capped at 10 consecutive
failures, then falls back to HTTP-poll. Pre-fix the loop ran
forever when the backend was permanently down, leaking the
WS thread.
Transport
- §7.2 #6: Separate `hmac_verify_expired_total` counter so SRE can
distinguish clock-skew (NTP drift) from forged packets. Mirrored
in both the HTTP and WebSocket verify paths.
- §7.2 #35: `CircuitBreaker.call` now dispatches the OPEN→HALF_OPEN
jitter through `_maybe_apply_open_jitter_sync` /
`_maybe_apply_open_jitter_async`. Pre-fix the jitter used
`time.sleep` before dispatching to async, which blocked the
caller's event loop on every transition.
- P2-1: `_coverage_seen` now bumps in the httpx path (sync + async).
Pre-fix the counter was only bumped by the `requests` transport,
so the dashboard's coverage view was empty for the dominant
OpenAI / Anthropic / Gemini / Mistral / Cohere traffic.
- P2-3: `is_sensitive_tool` match is case-insensitive. Pre-fix
`"stripe.charge"` did not match `"Stripe.Charge"`, bypassing the
sensitive gate.
Concurrency
- §7.2 #39: New `_tools_lock` guards every mutation of
`_strict_mode_tools` / `_sensitive_tools`. Same lock guards the
coverage-counter bump+prune sequence (§7.2 #33) so two threads
can't both observe the dict at length 4095 and both grow it to
4097 before either prune lands.
- §7.2 #47: New `_langchain_lock` / `_langgraph_lock` guard the
patch sequences end-to-end. Pre-fix two threads racing through
`auto_instrument` could both pass the early `_x_patched` check
and double-wrap `BaseCallbackManager` / `Pregel`.
- §7.2 #33: `_COVERAGE_CAP` (4096) bounds the per-host coverage
dicts.
Webhook delivery
- P3-2: Exponential backoff (0.5s, 1s, 2s, 4s, 8s, 16s, 30s cap)
replaces the previous linear schedule. Linear didn't back off
fast enough under sustained outage — each KILL/PAUSE spawned
its own delivery thread, producing 1000+ spinning threads
hammering the dead endpoint.
WAL crash-recovery
- P1-5b: Atomic WAL writes (tmp + `fsync` + `os.replace`), 64 MiB
rotation with `os.replace(wal, wal.1)`, replay drains both
`wal.1` and `wal`. New `NULLRUN_WAL_PATH` / `NULLRUN_WAL_MAX_BYTES`
env overrides for containers with `readOnlyRootFilesystem: true`.
Tests
8 new regression test files (57 tests total):
test_agent_id_uuid.py, test_args_pii_masked.py,
test_streaming_oom_cap.py, test_lru_active_runs.py,
test_reconnect_cap.py, test_coverage_seen_httpx.py,
test_webhook_backoff.py, test_redact.py
`test_buffer_invariants.py` extended with drop-newest +
critical-event preservation cases. `test_release_polish.py`
updated to pin the 5s cap on both the sync and async jitter
helpers (post §7.2 #35 split).
Full incident write-ups in CHANGELOG.md under the same P0/S/P tags.
* fix: address ruff lint findings from CI
Three CI lint failures on `ruff check src/` — fixes only, no
behavioural changes:
- **B905** (`src/nullrun/decorators.py:162`): `zip(bound_params,
args)` now passes `strict=False` explicitly. Pre-fix the two
iterables can be different lengths — `bound_params` is sliced to
`[: len(args)]` but the function may have fewer positional
parameters than args provided (e.g. *args-style callables), in
which case the trailing loop below handles the excess. `strict=`
was implicit and triggered B905. Now explicit so the intent is
documented in code.
- **I001** (`src/nullrun/instrumentation/auto.py:1146`): the late
`import os as _os` was moved to the top-of-file import block as
`import os` (alphabetical order: hashlib, json, logging, os,
threading). The `_os` alias was only there to avoid shadowing —
there is no top-level `os` in scope, so the plain name is fine.
Call site updated to use `os.environ.get(...)`.
- **S108** (`src/nullrun/transport.py:632`): replaced the
hardcoded `/tmp/nullrun.wal` with
`os.path.join(tempfile.gettempdir(), "nullrun.wal")`. The
hardcoded `/tmp` flagged S108 (insecure / non-portable temp
path) and would have broken the SDK on Windows out of the box.
`gettempdir()` returns the OS-appropriate temp dir
(`/tmp` on Linux, `/var/folders/...` on macOS, `%TEMP%` on
Windows). `NULLRUN_WAL_PATH` env override still wins, so
containers with `readOnlyRootFilesystem: true` are unaffected.
Added `import tempfile` to the top-of-file imports.
Verified:
- `ruff check src/` → All checks passed!
- `mypy src/` → Success: no issues found in 23 source files
- `pytest` → 493 passed, 13 skipped (CI default, no `-W error`)
maltsev-dev
added a commit
that referenced
this pull request
Jun 19, 2026
* fix: P0 security/stability hardening bundle
Closes the P0/P1/P2/P3 issues from the security review (plan §10/§11.4).
Security / PCI-DSS / GDPR
- P0-1: Mask positional PII in `_enforce_sensitive_tool` by introspecting
the wrapped function's signature and applying `SENSITIVE_ARG_KEYS` to
positional params. Pre-fix, `charge("4111-…-1111", 50)` forwarded the
PAN into `/execute` and the audit log.
- P0-6 / P3-3: `_safe_repr` now redacts BEFORE truncating. The pre-fix
order truncated first, so `details={…}` past position 50 leaked
verbatim. `_safe_repr` is now the single source of truth for the
redact-then-truncate flow.
Cost-audit / reliability
- P0-3: Bounded chunked reads on the sync + async httpx transports
(`MAX_RESPONSE_BYTES`, default 16 MiB, `NULLRUN_MAX_RESPONSE_BYTES`
env override). Above the cap, tracking is skipped and
`_coverage_streaming_skipped` is incremented. Replaces the
`response.read()` / `await response.aread()` unbounded buffer that
held entire LLM streaming bodies in memory.
- P0-4: `_do_flush_locked` re-queue on CB OPEN now drops the NEWEST
non-critical events instead of the oldest. The oldest events
(incident start, billing-period start) are exactly what a billing
investigator needs; losing them silently broke monthly rollups.
Control-plane events (`state_change`, `kill_received`,
`policy_invalidated`, `key_rotated`) are preserved unconditionally
so the dashboard KILL switch lands even under sustained backend
outage.
Identity
- S-8 / P2-4: `agent()` now emits `str(uuid.uuid4())` (with dashes).
Pre-fix the format was `f"agent-{uuid.uuid4().hex}"` — 32 hex chars,
no dashes — and backend UUID-typed columns dropped these to NULL
on insert. User-supplied names are still preserved verbatim.
- §7.2 #16: `workflow()` context manager now resets `span_id` (not
only `workflow_id` / `trace_id`) so nested `with span()` blocks
don't leave the inner span_id visible inside the workflow scope.
Resource leaks
- S-9: `_active_runs` on `NullRunCallback` is now an `OrderedDict`
capped at 4096 with FIFO eviction. Pre-fix the dict grew
unbounded when `on_chain_end` did not fire (some LangChain
versions short-circuit the end hook on chain-body errors).
- S-10: WebSocket reconnect loop is now capped at 10 consecutive
failures, then falls back to HTTP-poll. Pre-fix the loop ran
forever when the backend was permanently down, leaking the
WS thread.
Transport
- §7.2 #6: Separate `hmac_verify_expired_total` counter so SRE can
distinguish clock-skew (NTP drift) from forged packets. Mirrored
in both the HTTP and WebSocket verify paths.
- §7.2 #35: `CircuitBreaker.call` now dispatches the OPEN→HALF_OPEN
jitter through `_maybe_apply_open_jitter_sync` /
`_maybe_apply_open_jitter_async`. Pre-fix the jitter used
`time.sleep` before dispatching to async, which blocked the
caller's event loop on every transition.
- P2-1: `_coverage_seen` now bumps in the httpx path (sync + async).
Pre-fix the counter was only bumped by the `requests` transport,
so the dashboard's coverage view was empty for the dominant
OpenAI / Anthropic / Gemini / Mistral / Cohere traffic.
- P2-3: `is_sensitive_tool` match is case-insensitive. Pre-fix
`"stripe.charge"` did not match `"Stripe.Charge"`, bypassing the
sensitive gate.
Concurrency
- §7.2 #39: New `_tools_lock` guards every mutation of
`_strict_mode_tools` / `_sensitive_tools`. Same lock guards the
coverage-counter bump+prune sequence (§7.2 #33) so two threads
can't both observe the dict at length 4095 and both grow it to
4097 before either prune lands.
- §7.2 #47: New `_langchain_lock` / `_langgraph_lock` guard the
patch sequences end-to-end. Pre-fix two threads racing through
`auto_instrument` could both pass the early `_x_patched` check
and double-wrap `BaseCallbackManager` / `Pregel`.
- §7.2 #33: `_COVERAGE_CAP` (4096) bounds the per-host coverage
dicts.
Webhook delivery
- P3-2: Exponential backoff (0.5s, 1s, 2s, 4s, 8s, 16s, 30s cap)
replaces the previous linear schedule. Linear didn't back off
fast enough under sustained outage — each KILL/PAUSE spawned
its own delivery thread, producing 1000+ spinning threads
hammering the dead endpoint.
WAL crash-recovery
- P1-5b: Atomic WAL writes (tmp + `fsync` + `os.replace`), 64 MiB
rotation with `os.replace(wal, wal.1)`, replay drains both
`wal.1` and `wal`. New `NULLRUN_WAL_PATH` / `NULLRUN_WAL_MAX_BYTES`
env overrides for containers with `readOnlyRootFilesystem: true`.
Tests
8 new regression test files (57 tests total):
test_agent_id_uuid.py, test_args_pii_masked.py,
test_streaming_oom_cap.py, test_lru_active_runs.py,
test_reconnect_cap.py, test_coverage_seen_httpx.py,
test_webhook_backoff.py, test_redact.py
`test_buffer_invariants.py` extended with drop-newest +
critical-event preservation cases. `test_release_polish.py`
updated to pin the 5s cap on both the sync and async jitter
helpers (post §7.2 #35 split).
Full incident write-ups in CHANGELOG.md under the same P0/S/P tags.
* fix: address ruff lint findings from CI
Three CI lint failures on `ruff check src/` — fixes only, no
behavioural changes:
- **B905** (`src/nullrun/decorators.py:162`): `zip(bound_params,
args)` now passes `strict=False` explicitly. Pre-fix the two
iterables can be different lengths — `bound_params` is sliced to
`[: len(args)]` but the function may have fewer positional
parameters than args provided (e.g. *args-style callables), in
which case the trailing loop below handles the excess. `strict=`
was implicit and triggered B905. Now explicit so the intent is
documented in code.
- **I001** (`src/nullrun/instrumentation/auto.py:1146`): the late
`import os as _os` was moved to the top-of-file import block as
`import os` (alphabetical order: hashlib, json, logging, os,
threading). The `_os` alias was only there to avoid shadowing —
there is no top-level `os` in scope, so the plain name is fine.
Call site updated to use `os.environ.get(...)`.
- **S108** (`src/nullrun/transport.py:632`): replaced the
hardcoded `/tmp/nullrun.wal` with
`os.path.join(tempfile.gettempdir(), "nullrun.wal")`. The
hardcoded `/tmp` flagged S108 (insecure / non-portable temp
path) and would have broken the SDK on Windows out of the box.
`gettempdir()` returns the OS-appropriate temp dir
(`/tmp` on Linux, `/var/folders/...` on macOS, `%TEMP%` on
Windows). `NULLRUN_WAL_PATH` env override still wins, so
containers with `readOnlyRootFilesystem: true` are unaffected.
Added `import tempfile` to the top-of-file imports.
Verified:
- `ruff check src/` → All checks passed!
- `mypy src/` → Success: no issues found in 23 source files
- `pytest` → 493 passed, 13 skipped (CI default, no `-W error`)
* chore(release): bump to 0.5.2
- Promote [Unreleased] to [0.5.2] — 2026-06-19; merge the two
[Unreleased] sections that had drifted during Sprint 2.5 +
Phase 0 development so release tooling scanning for the
[Unreleased] anchor picks up the complete change set exactly
once.
- Add PEP 561 marker (py.typed) — the package ships inline type
annotations; the marker tells mypy / pyright / pylance to honour
them.
- runtime.py (S-4): case-insensitive state compare in
check_control_plane. Defensive against any backend casing drift
beyond the current PascalCase (handlers.rs:9258). Pinned by
tests/test_state_compare_case_insensitive.py (10 cases covering
PascalCase / UPPERCASE / lowercase / mixed-case).
Working-notes file docs/integration-baseline-2026-06-19.md is
deliberately left untracked, matching the analyze.md pattern from
d74712e.
maltsev-dev
added a commit
that referenced
this pull request
Jun 19, 2026
* fix: P0 security/stability hardening bundle
Closes the P0/P1/P2/P3 issues from the security review (plan §10/§11.4).
Security / PCI-DSS / GDPR
- P0-1: Mask positional PII in `_enforce_sensitive_tool` by introspecting
the wrapped function's signature and applying `SENSITIVE_ARG_KEYS` to
positional params. Pre-fix, `charge("4111-…-1111", 50)` forwarded the
PAN into `/execute` and the audit log.
- P0-6 / P3-3: `_safe_repr` now redacts BEFORE truncating. The pre-fix
order truncated first, so `details={…}` past position 50 leaked
verbatim. `_safe_repr` is now the single source of truth for the
redact-then-truncate flow.
Cost-audit / reliability
- P0-3: Bounded chunked reads on the sync + async httpx transports
(`MAX_RESPONSE_BYTES`, default 16 MiB, `NULLRUN_MAX_RESPONSE_BYTES`
env override). Above the cap, tracking is skipped and
`_coverage_streaming_skipped` is incremented. Replaces the
`response.read()` / `await response.aread()` unbounded buffer that
held entire LLM streaming bodies in memory.
- P0-4: `_do_flush_locked` re-queue on CB OPEN now drops the NEWEST
non-critical events instead of the oldest. The oldest events
(incident start, billing-period start) are exactly what a billing
investigator needs; losing them silently broke monthly rollups.
Control-plane events (`state_change`, `kill_received`,
`policy_invalidated`, `key_rotated`) are preserved unconditionally
so the dashboard KILL switch lands even under sustained backend
outage.
Identity
- S-8 / P2-4: `agent()` now emits `str(uuid.uuid4())` (with dashes).
Pre-fix the format was `f"agent-{uuid.uuid4().hex}"` — 32 hex chars,
no dashes — and backend UUID-typed columns dropped these to NULL
on insert. User-supplied names are still preserved verbatim.
- §7.2 #16: `workflow()` context manager now resets `span_id` (not
only `workflow_id` / `trace_id`) so nested `with span()` blocks
don't leave the inner span_id visible inside the workflow scope.
Resource leaks
- S-9: `_active_runs` on `NullRunCallback` is now an `OrderedDict`
capped at 4096 with FIFO eviction. Pre-fix the dict grew
unbounded when `on_chain_end` did not fire (some LangChain
versions short-circuit the end hook on chain-body errors).
- S-10: WebSocket reconnect loop is now capped at 10 consecutive
failures, then falls back to HTTP-poll. Pre-fix the loop ran
forever when the backend was permanently down, leaking the
WS thread.
Transport
- §7.2 #6: Separate `hmac_verify_expired_total` counter so SRE can
distinguish clock-skew (NTP drift) from forged packets. Mirrored
in both the HTTP and WebSocket verify paths.
- §7.2 #35: `CircuitBreaker.call` now dispatches the OPEN→HALF_OPEN
jitter through `_maybe_apply_open_jitter_sync` /
`_maybe_apply_open_jitter_async`. Pre-fix the jitter used
`time.sleep` before dispatching to async, which blocked the
caller's event loop on every transition.
- P2-1: `_coverage_seen` now bumps in the httpx path (sync + async).
Pre-fix the counter was only bumped by the `requests` transport,
so the dashboard's coverage view was empty for the dominant
OpenAI / Anthropic / Gemini / Mistral / Cohere traffic.
- P2-3: `is_sensitive_tool` match is case-insensitive. Pre-fix
`"stripe.charge"` did not match `"Stripe.Charge"`, bypassing the
sensitive gate.
Concurrency
- §7.2 #39: New `_tools_lock` guards every mutation of
`_strict_mode_tools` / `_sensitive_tools`. Same lock guards the
coverage-counter bump+prune sequence (§7.2 #33) so two threads
can't both observe the dict at length 4095 and both grow it to
4097 before either prune lands.
- §7.2 #47: New `_langchain_lock` / `_langgraph_lock` guard the
patch sequences end-to-end. Pre-fix two threads racing through
`auto_instrument` could both pass the early `_x_patched` check
and double-wrap `BaseCallbackManager` / `Pregel`.
- §7.2 #33: `_COVERAGE_CAP` (4096) bounds the per-host coverage
dicts.
Webhook delivery
- P3-2: Exponential backoff (0.5s, 1s, 2s, 4s, 8s, 16s, 30s cap)
replaces the previous linear schedule. Linear didn't back off
fast enough under sustained outage — each KILL/PAUSE spawned
its own delivery thread, producing 1000+ spinning threads
hammering the dead endpoint.
WAL crash-recovery
- P1-5b: Atomic WAL writes (tmp + `fsync` + `os.replace`), 64 MiB
rotation with `os.replace(wal, wal.1)`, replay drains both
`wal.1` and `wal`. New `NULLRUN_WAL_PATH` / `NULLRUN_WAL_MAX_BYTES`
env overrides for containers with `readOnlyRootFilesystem: true`.
Tests
8 new regression test files (57 tests total):
test_agent_id_uuid.py, test_args_pii_masked.py,
test_streaming_oom_cap.py, test_lru_active_runs.py,
test_reconnect_cap.py, test_coverage_seen_httpx.py,
test_webhook_backoff.py, test_redact.py
`test_buffer_invariants.py` extended with drop-newest +
critical-event preservation cases. `test_release_polish.py`
updated to pin the 5s cap on both the sync and async jitter
helpers (post §7.2 #35 split).
Full incident write-ups in CHANGELOG.md under the same P0/S/P tags.
* fix: address ruff lint findings from CI
Three CI lint failures on `ruff check src/` — fixes only, no
behavioural changes:
- **B905** (`src/nullrun/decorators.py:162`): `zip(bound_params,
args)` now passes `strict=False` explicitly. Pre-fix the two
iterables can be different lengths — `bound_params` is sliced to
`[: len(args)]` but the function may have fewer positional
parameters than args provided (e.g. *args-style callables), in
which case the trailing loop below handles the excess. `strict=`
was implicit and triggered B905. Now explicit so the intent is
documented in code.
- **I001** (`src/nullrun/instrumentation/auto.py:1146`): the late
`import os as _os` was moved to the top-of-file import block as
`import os` (alphabetical order: hashlib, json, logging, os,
threading). The `_os` alias was only there to avoid shadowing —
there is no top-level `os` in scope, so the plain name is fine.
Call site updated to use `os.environ.get(...)`.
- **S108** (`src/nullrun/transport.py:632`): replaced the
hardcoded `/tmp/nullrun.wal` with
`os.path.join(tempfile.gettempdir(), "nullrun.wal")`. The
hardcoded `/tmp` flagged S108 (insecure / non-portable temp
path) and would have broken the SDK on Windows out of the box.
`gettempdir()` returns the OS-appropriate temp dir
(`/tmp` on Linux, `/var/folders/...` on macOS, `%TEMP%` on
Windows). `NULLRUN_WAL_PATH` env override still wins, so
containers with `readOnlyRootFilesystem: true` are unaffected.
Added `import tempfile` to the top-of-file imports.
Verified:
- `ruff check src/` → All checks passed!
- `mypy src/` → Success: no issues found in 23 source files
- `pytest` → 493 passed, 13 skipped (CI default, no `-W error`)
* chore(release): bump to 0.5.2
- Promote [Unreleased] to [0.5.2] — 2026-06-19; merge the two
[Unreleased] sections that had drifted during Sprint 2.5 +
Phase 0 development so release tooling scanning for the
[Unreleased] anchor picks up the complete change set exactly
once.
- Add PEP 561 marker (py.typed) — the package ships inline type
annotations; the marker tells mypy / pyright / pylance to honour
them.
- runtime.py (S-4): case-insensitive state compare in
check_control_plane. Defensive against any backend casing drift
beyond the current PascalCase (handlers.rs:9258). Pinned by
tests/test_state_compare_case_insensitive.py (10 cases covering
PascalCase / UPPERCASE / lowercase / mixed-case).
Working-notes file docs/integration-baseline-2026-06-19.md is
deliberately left untracked, matching the analyze.md pattern from
d74712e.
* test: bump coverage 70.92% → 84.52% with branch coverage
Lifts the SDK's Codecov score from 70.92 % to 84.52 % (+13.6 pp) by
adding 347 new tests across 10 files that exercise previously-untested
branches in the auto-instrumentation patches, runtime gates, transport
fallback modes, circuit breaker Redis path, and the @Protect decorator
fail-CLOSED contract.
pyproject.toml
- Enable branch coverage so error / fallback paths count.
- Raise fail_under from 70 → 82 (enforced in CI via `coverage run -m
pytest && coverage report`).
- Add precision=2 and skip_empty=true to keep the report readable.
New tests (all 817 pass locally, all 4 CI jobs green):
tests/test_autogen_patch.py — 13 tests
tests/test_crewai_patch.py — 15 tests
tests/test_llama_index_patch.py — 13 tests
tests/test_langgraph_callback.py — 38 tests
tests/test_auto_requests.py — 24 tests
tests/test_runtime_branches.py — 43 tests
tests/test_transport_branches.py — 44 tests
tests/test_circuit_breaker_branches.py — 31 tests
tests/test_protect_branches.py — 43 tests
tests/test_actions_context_init.py — 50 tests
Per-file coverage deltas:
instrumentation/autogen.py 21.33 → 93.41 %
instrumentation/crewai.py 22.97 → 90.82 %
instrumentation/llama_index.py 28.30 → 100.00 %
instrumentation/langgraph.py 23.75 → 93.69 %
instrumentation/auto_requests.py 33.72 → 99.09 %
breaker/circuit_breaker.py 59.76 → 90.21 %
transport.py 82.57 → 84.79 %
transport_websocket.py 68.70 → 64.10 % (msg-type branches
still need live ws
round-trip tests)
decorators.py 83.33 → 95.49 %
runtime.py 80.14 → 83.24 %
context.py 82.76 → 100.00 %
actions.py 92.12 → 96.89 %
breaker/exceptions.py 98.51 → 97.26 %
All 4 CI jobs pass locally (pytest, ruff check, mypy, coverage).
Working-notes file docs/integration-baseline-2026-06-19.md is
deliberately left untracked, matching the analyze.md pattern from
d74712e.
maltsev-dev
added a commit
that referenced
this pull request
Jun 20, 2026
…erage reporter (#26) * fix: P0 security/stability hardening bundle Closes the P0/P1/P2/P3 issues from the security review (plan §10/§11.4). Security / PCI-DSS / GDPR - P0-1: Mask positional PII in `_enforce_sensitive_tool` by introspecting the wrapped function's signature and applying `SENSITIVE_ARG_KEYS` to positional params. Pre-fix, `charge("4111-…-1111", 50)` forwarded the PAN into `/execute` and the audit log. - P0-6 / P3-3: `_safe_repr` now redacts BEFORE truncating. The pre-fix order truncated first, so `details={…}` past position 50 leaked verbatim. `_safe_repr` is now the single source of truth for the redact-then-truncate flow. Cost-audit / reliability - P0-3: Bounded chunked reads on the sync + async httpx transports (`MAX_RESPONSE_BYTES`, default 16 MiB, `NULLRUN_MAX_RESPONSE_BYTES` env override). Above the cap, tracking is skipped and `_coverage_streaming_skipped` is incremented. Replaces the `response.read()` / `await response.aread()` unbounded buffer that held entire LLM streaming bodies in memory. - P0-4: `_do_flush_locked` re-queue on CB OPEN now drops the NEWEST non-critical events instead of the oldest. The oldest events (incident start, billing-period start) are exactly what a billing investigator needs; losing them silently broke monthly rollups. Control-plane events (`state_change`, `kill_received`, `policy_invalidated`, `key_rotated`) are preserved unconditionally so the dashboard KILL switch lands even under sustained backend outage. Identity - S-8 / P2-4: `agent()` now emits `str(uuid.uuid4())` (with dashes). Pre-fix the format was `f"agent-{uuid.uuid4().hex}"` — 32 hex chars, no dashes — and backend UUID-typed columns dropped these to NULL on insert. User-supplied names are still preserved verbatim. - §7.2 #16: `workflow()` context manager now resets `span_id` (not only `workflow_id` / `trace_id`) so nested `with span()` blocks don't leave the inner span_id visible inside the workflow scope. Resource leaks - S-9: `_active_runs` on `NullRunCallback` is now an `OrderedDict` capped at 4096 with FIFO eviction. Pre-fix the dict grew unbounded when `on_chain_end` did not fire (some LangChain versions short-circuit the end hook on chain-body errors). - S-10: WebSocket reconnect loop is now capped at 10 consecutive failures, then falls back to HTTP-poll. Pre-fix the loop ran forever when the backend was permanently down, leaking the WS thread. Transport - §7.2 #6: Separate `hmac_verify_expired_total` counter so SRE can distinguish clock-skew (NTP drift) from forged packets. Mirrored in both the HTTP and WebSocket verify paths. - §7.2 #35: `CircuitBreaker.call` now dispatches the OPEN→HALF_OPEN jitter through `_maybe_apply_open_jitter_sync` / `_maybe_apply_open_jitter_async`. Pre-fix the jitter used `time.sleep` before dispatching to async, which blocked the caller's event loop on every transition. - P2-1: `_coverage_seen` now bumps in the httpx path (sync + async). Pre-fix the counter was only bumped by the `requests` transport, so the dashboard's coverage view was empty for the dominant OpenAI / Anthropic / Gemini / Mistral / Cohere traffic. - P2-3: `is_sensitive_tool` match is case-insensitive. Pre-fix `"stripe.charge"` did not match `"Stripe.Charge"`, bypassing the sensitive gate. Concurrency - §7.2 #39: New `_tools_lock` guards every mutation of `_strict_mode_tools` / `_sensitive_tools`. Same lock guards the coverage-counter bump+prune sequence (§7.2 #33) so two threads can't both observe the dict at length 4095 and both grow it to 4097 before either prune lands. - §7.2 #47: New `_langchain_lock` / `_langgraph_lock` guard the patch sequences end-to-end. Pre-fix two threads racing through `auto_instrument` could both pass the early `_x_patched` check and double-wrap `BaseCallbackManager` / `Pregel`. - §7.2 #33: `_COVERAGE_CAP` (4096) bounds the per-host coverage dicts. Webhook delivery - P3-2: Exponential backoff (0.5s, 1s, 2s, 4s, 8s, 16s, 30s cap) replaces the previous linear schedule. Linear didn't back off fast enough under sustained outage — each KILL/PAUSE spawned its own delivery thread, producing 1000+ spinning threads hammering the dead endpoint. WAL crash-recovery - P1-5b: Atomic WAL writes (tmp + `fsync` + `os.replace`), 64 MiB rotation with `os.replace(wal, wal.1)`, replay drains both `wal.1` and `wal`. New `NULLRUN_WAL_PATH` / `NULLRUN_WAL_MAX_BYTES` env overrides for containers with `readOnlyRootFilesystem: true`. Tests 8 new regression test files (57 tests total): test_agent_id_uuid.py, test_args_pii_masked.py, test_streaming_oom_cap.py, test_lru_active_runs.py, test_reconnect_cap.py, test_coverage_seen_httpx.py, test_webhook_backoff.py, test_redact.py `test_buffer_invariants.py` extended with drop-newest + critical-event preservation cases. `test_release_polish.py` updated to pin the 5s cap on both the sync and async jitter helpers (post §7.2 #35 split). Full incident write-ups in CHANGELOG.md under the same P0/S/P tags. * fix: address ruff lint findings from CI Three CI lint failures on `ruff check src/` — fixes only, no behavioural changes: - **B905** (`src/nullrun/decorators.py:162`): `zip(bound_params, args)` now passes `strict=False` explicitly. Pre-fix the two iterables can be different lengths — `bound_params` is sliced to `[: len(args)]` but the function may have fewer positional parameters than args provided (e.g. *args-style callables), in which case the trailing loop below handles the excess. `strict=` was implicit and triggered B905. Now explicit so the intent is documented in code. - **I001** (`src/nullrun/instrumentation/auto.py:1146`): the late `import os as _os` was moved to the top-of-file import block as `import os` (alphabetical order: hashlib, json, logging, os, threading). The `_os` alias was only there to avoid shadowing — there is no top-level `os` in scope, so the plain name is fine. Call site updated to use `os.environ.get(...)`. - **S108** (`src/nullrun/transport.py:632`): replaced the hardcoded `/tmp/nullrun.wal` with `os.path.join(tempfile.gettempdir(), "nullrun.wal")`. The hardcoded `/tmp` flagged S108 (insecure / non-portable temp path) and would have broken the SDK on Windows out of the box. `gettempdir()` returns the OS-appropriate temp dir (`/tmp` on Linux, `/var/folders/...` on macOS, `%TEMP%` on Windows). `NULLRUN_WAL_PATH` env override still wins, so containers with `readOnlyRootFilesystem: true` are unaffected. Added `import tempfile` to the top-of-file imports. Verified: - `ruff check src/` → All checks passed! - `mypy src/` → Success: no issues found in 23 source files - `pytest` → 493 passed, 13 skipped (CI default, no `-W error`) * chore(release): bump to 0.5.2 - Promote [Unreleased] to [0.5.2] — 2026-06-19; merge the two [Unreleased] sections that had drifted during Sprint 2.5 + Phase 0 development so release tooling scanning for the [Unreleased] anchor picks up the complete change set exactly once. - Add PEP 561 marker (py.typed) — the package ships inline type annotations; the marker tells mypy / pyright / pylance to honour them. - runtime.py (S-4): case-insensitive state compare in check_control_plane. Defensive against any backend casing drift beyond the current PascalCase (handlers.rs:9258). Pinned by tests/test_state_compare_case_insensitive.py (10 cases covering PascalCase / UPPERCASE / lowercase / mixed-case). Working-notes file docs/integration-baseline-2026-06-19.md is deliberately left untracked, matching the analyze.md pattern from d74712e. * test: bump coverage 70.92% → 84.52% with branch coverage Lifts the SDK's Codecov score from 70.92 % to 84.52 % (+13.6 pp) by adding 347 new tests across 10 files that exercise previously-untested branches in the auto-instrumentation patches, runtime gates, transport fallback modes, circuit breaker Redis path, and the @Protect decorator fail-CLOSED contract. pyproject.toml - Enable branch coverage so error / fallback paths count. - Raise fail_under from 70 → 82 (enforced in CI via `coverage run -m pytest && coverage report`). - Add precision=2 and skip_empty=true to keep the report readable. New tests (all 817 pass locally, all 4 CI jobs green): tests/test_autogen_patch.py — 13 tests tests/test_crewai_patch.py — 15 tests tests/test_llama_index_patch.py — 13 tests tests/test_langgraph_callback.py — 38 tests tests/test_auto_requests.py — 24 tests tests/test_runtime_branches.py — 43 tests tests/test_transport_branches.py — 44 tests tests/test_circuit_breaker_branches.py — 31 tests tests/test_protect_branches.py — 43 tests tests/test_actions_context_init.py — 50 tests Per-file coverage deltas: instrumentation/autogen.py 21.33 → 93.41 % instrumentation/crewai.py 22.97 → 90.82 % instrumentation/llama_index.py 28.30 → 100.00 % instrumentation/langgraph.py 23.75 → 93.69 % instrumentation/auto_requests.py 33.72 → 99.09 % breaker/circuit_breaker.py 59.76 → 90.21 % transport.py 82.57 → 84.79 % transport_websocket.py 68.70 → 64.10 % (msg-type branches still need live ws round-trip tests) decorators.py 83.33 → 95.49 % runtime.py 80.14 → 83.24 % context.py 82.76 → 100.00 % actions.py 92.12 → 96.89 % breaker/exceptions.py 98.51 → 97.26 % All 4 CI jobs pass locally (pytest, ruff check, mypy, coverage). Working-notes file docs/integration-baseline-2026-06-19.md is deliberately left untracked, matching the analyze.md pattern from d74712e. * feat(security): make @sensitive registration fail-CLOSED (ADR-008) Sensitive-tool registration is part of the security boundary. The old behaviour caught any exception from _get_or_create_runtime(), logged it at DEBUG, and returned the original function unchanged — which meant the wrapped body would later execute without ever being added to the runtime's sensitive-tool set, completely bypassing the pre-execution gate under partial initialization (e.g. transient NullRunAuthenticationError on import). Replace the silent logger.debug(...) with raise RuntimeError(..., chained from the original exception. The decorator is the registration point, not the call site, so raising at decoration time is the correct signal: the import / module-load fails loudly, the body never gets a chance to run untracked, and the caller can still inspect the root cause via __cause__. The two pre-existing tests pinned the old (silent / wrong-type) contract; update them to assert the new RuntimeError wrapping: - test_sensitive_raises_on_missing_api_key now expects RuntimeError whose __cause__ is the original NullRunAuthenticationError. - test_sensitive_runtime_init_failure_is_silent is renamed to ..._raises and asserts the same __cause__ chaining when a _get_or_create_runtime mock raises. * fix(transport): retry /track/batch on 5xx and align auth-verify path (P0 #2, P0 #5) P0 #2 — _send_batch_with_retry_info used to do a single self._client.post(...) + raise_for_status(). A transient backend 5xx raised out of the flush path; the in-memory buffer was cleared at the call site and every event in the batch was permanently lost. Wrap the post() in _retry_with_backoff (max 3 attempts, exponential backoff + jitter, capped at 10s) so a single 500 no longer drops the whole batch. 429 is retried (helper honors Retry-After when present); other 4xx errors are returned as-is — those are real client bugs and must not be retried (e.g. a 401 just wastes the user's budget). P0 #5 — contract drift: this file's auth-verify call site used /auth/verify, while the corresponding call in runtime.py:599 already used /api/v1/auth/verify. Align the rotation call site to /api/v1/auth/verify so the contract-drift-guard CI catches any future divergence. Update tests/test_transport.py::test_retry_on_500 to assert the new contract (third attempt succeeds → call_count == 3, event id in accepted_event_ids) instead of expecting an immediate exception. Add tests/test_track_batch_retry.py with full regression coverage: single 5xx → success, three consecutive 5xx → BreakerTransportError, 429 with Retry-After → honored before next attempt. * feat(runtime): emit background coverage_report every 60s The SDK has tracked per-host seen / tracked / streaming_skipped counters since 0.4.x (bump_coverage_counter, get_coverage_stats), but there was no path to ship them to the backend — the counters only ever existed in process memory. This commit adds a daemon thread that emits a coverage_report track event every 60 seconds so the backend can build the per-host coverage dashboard. * NullRunRuntime.track_coverage() — returns a track-result dict when there is something to report, or None on cold start (no counters bumped yet) so the backend doesn't get an empty row per minute. * start_coverage_reporter() / stop_coverage_reporter() — idempotent lifecycle, daemon thread, sleeps in 0.5s slices for responsive shutdown, emits once on entry so short-lived processes (CI, batch jobs) still leave a row. * nullrun.init() wires start_coverage_reporter() in; the reporter is a no-op while the process is still cold, so re-init is safe. New tests/test_coverage_report.py pins the contract: cold start → None, post-traffic → track-result dict with type=coverage_report and the three counter dicts, start is idempotent, stop joins cleanly. * chore(breaker): add __main__ shim so 'python -m nullrun.breaker' exits cleanly Historically the SDK shipped a 'python -m nullrun.breaker' entry point for in-container health probes and ad-hoc debugging. The nullrun.breaker subpackage is the circuit-breaker + policy-exceptions surface — it is not a runnable command. Without this shim, containerized deployments that scripted 'python -m nullrun.breaker' as a no-op smoke check would fail with 'No module named nullrun.breaker.__main__'. This module makes that invocation exit cleanly (return 0) and print a short pointer to nullrun-doctor (nullrun.toolbox.diagnostics) for real runtime checks. * chore: gitignore audit.md (project-local working notes, sibling of analyze.md) * test: re-align @sensitive test with fail-CLOSED contract after master merge The auto-merge of master into this branch (commit 7875210) resolved tests/test_protect_branches.py by taking master's side of the conflict, leaving the old test_sensitive_runtime_init_failure_is_silent in place. That test asserts @sensitive does NOT raise — but the production change in commit 58263a1 (this branch) makes @sensitive raise RuntimeError (fail-CLOSED, ADR-008). Result: CI ran the old assertion against the new production code and failed. Restore the renamed and re-asserted version of the test from commit 58263a1 — test_sensitive_runtime_init_failure_raises — so the test asserts the new contract: RuntimeError is raised and __cause__ chains the original exception. runtime.py was resolved correctly by the auto-merge (both sides kept: the new track_coverage / start_coverage_reporter / stop_coverage_reporter / _coverage_reporter_loop methods AND the existing bump_coverage_counter are all present), so no changes there.
maltsev-dev
added a commit
that referenced
this pull request
Jun 21, 2026
* fix: P0 security/stability hardening bundle
Closes the P0/P1/P2/P3 issues from the security review (plan §10/§11.4).
Security / PCI-DSS / GDPR
- P0-1: Mask positional PII in `_enforce_sensitive_tool` by introspecting
the wrapped function's signature and applying `SENSITIVE_ARG_KEYS` to
positional params. Pre-fix, `charge("4111-…-1111", 50)` forwarded the
PAN into `/execute` and the audit log.
- P0-6 / P3-3: `_safe_repr` now redacts BEFORE truncating. The pre-fix
order truncated first, so `details={…}` past position 50 leaked
verbatim. `_safe_repr` is now the single source of truth for the
redact-then-truncate flow.
Cost-audit / reliability
- P0-3: Bounded chunked reads on the sync + async httpx transports
(`MAX_RESPONSE_BYTES`, default 16 MiB, `NULLRUN_MAX_RESPONSE_BYTES`
env override). Above the cap, tracking is skipped and
`_coverage_streaming_skipped` is incremented. Replaces the
`response.read()` / `await response.aread()` unbounded buffer that
held entire LLM streaming bodies in memory.
- P0-4: `_do_flush_locked` re-queue on CB OPEN now drops the NEWEST
non-critical events instead of the oldest. The oldest events
(incident start, billing-period start) are exactly what a billing
investigator needs; losing them silently broke monthly rollups.
Control-plane events (`state_change`, `kill_received`,
`policy_invalidated`, `key_rotated`) are preserved unconditionally
so the dashboard KILL switch lands even under sustained backend
outage.
Identity
- S-8 / P2-4: `agent()` now emits `str(uuid.uuid4())` (with dashes).
Pre-fix the format was `f"agent-{uuid.uuid4().hex}"` — 32 hex chars,
no dashes — and backend UUID-typed columns dropped these to NULL
on insert. User-supplied names are still preserved verbatim.
- §7.2 #16: `workflow()` context manager now resets `span_id` (not
only `workflow_id` / `trace_id`) so nested `with span()` blocks
don't leave the inner span_id visible inside the workflow scope.
Resource leaks
- S-9: `_active_runs` on `NullRunCallback` is now an `OrderedDict`
capped at 4096 with FIFO eviction. Pre-fix the dict grew
unbounded when `on_chain_end` did not fire (some LangChain
versions short-circuit the end hook on chain-body errors).
- S-10: WebSocket reconnect loop is now capped at 10 consecutive
failures, then falls back to HTTP-poll. Pre-fix the loop ran
forever when the backend was permanently down, leaking the
WS thread.
Transport
- §7.2 #6: Separate `hmac_verify_expired_total` counter so SRE can
distinguish clock-skew (NTP drift) from forged packets. Mirrored
in both the HTTP and WebSocket verify paths.
- §7.2 #35: `CircuitBreaker.call` now dispatches the OPEN→HALF_OPEN
jitter through `_maybe_apply_open_jitter_sync` /
`_maybe_apply_open_jitter_async`. Pre-fix the jitter used
`time.sleep` before dispatching to async, which blocked the
caller's event loop on every transition.
- P2-1: `_coverage_seen` now bumps in the httpx path (sync + async).
Pre-fix the counter was only bumped by the `requests` transport,
so the dashboard's coverage view was empty for the dominant
OpenAI / Anthropic / Gemini / Mistral / Cohere traffic.
- P2-3: `is_sensitive_tool` match is case-insensitive. Pre-fix
`"stripe.charge"` did not match `"Stripe.Charge"`, bypassing the
sensitive gate.
Concurrency
- §7.2 #39: New `_tools_lock` guards every mutation of
`_strict_mode_tools` / `_sensitive_tools`. Same lock guards the
coverage-counter bump+prune sequence (§7.2 #33) so two threads
can't both observe the dict at length 4095 and both grow it to
4097 before either prune lands.
- §7.2 #47: New `_langchain_lock` / `_langgraph_lock` guard the
patch sequences end-to-end. Pre-fix two threads racing through
`auto_instrument` could both pass the early `_x_patched` check
and double-wrap `BaseCallbackManager` / `Pregel`.
- §7.2 #33: `_COVERAGE_CAP` (4096) bounds the per-host coverage
dicts.
Webhook delivery
- P3-2: Exponential backoff (0.5s, 1s, 2s, 4s, 8s, 16s, 30s cap)
replaces the previous linear schedule. Linear didn't back off
fast enough under sustained outage — each KILL/PAUSE spawned
its own delivery thread, producing 1000+ spinning threads
hammering the dead endpoint.
WAL crash-recovery
- P1-5b: Atomic WAL writes (tmp + `fsync` + `os.replace`), 64 MiB
rotation with `os.replace(wal, wal.1)`, replay drains both
`wal.1` and `wal`. New `NULLRUN_WAL_PATH` / `NULLRUN_WAL_MAX_BYTES`
env overrides for containers with `readOnlyRootFilesystem: true`.
Tests
8 new regression test files (57 tests total):
test_agent_id_uuid.py, test_args_pii_masked.py,
test_streaming_oom_cap.py, test_lru_active_runs.py,
test_reconnect_cap.py, test_coverage_seen_httpx.py,
test_webhook_backoff.py, test_redact.py
`test_buffer_invariants.py` extended with drop-newest +
critical-event preservation cases. `test_release_polish.py`
updated to pin the 5s cap on both the sync and async jitter
helpers (post §7.2 #35 split).
Full incident write-ups in CHANGELOG.md under the same P0/S/P tags.
* fix: address ruff lint findings from CI
Three CI lint failures on `ruff check src/` — fixes only, no
behavioural changes:
- **B905** (`src/nullrun/decorators.py:162`): `zip(bound_params,
args)` now passes `strict=False` explicitly. Pre-fix the two
iterables can be different lengths — `bound_params` is sliced to
`[: len(args)]` but the function may have fewer positional
parameters than args provided (e.g. *args-style callables), in
which case the trailing loop below handles the excess. `strict=`
was implicit and triggered B905. Now explicit so the intent is
documented in code.
- **I001** (`src/nullrun/instrumentation/auto.py:1146`): the late
`import os as _os` was moved to the top-of-file import block as
`import os` (alphabetical order: hashlib, json, logging, os,
threading). The `_os` alias was only there to avoid shadowing —
there is no top-level `os` in scope, so the plain name is fine.
Call site updated to use `os.environ.get(...)`.
- **S108** (`src/nullrun/transport.py:632`): replaced the
hardcoded `/tmp/nullrun.wal` with
`os.path.join(tempfile.gettempdir(), "nullrun.wal")`. The
hardcoded `/tmp` flagged S108 (insecure / non-portable temp
path) and would have broken the SDK on Windows out of the box.
`gettempdir()` returns the OS-appropriate temp dir
(`/tmp` on Linux, `/var/folders/...` on macOS, `%TEMP%` on
Windows). `NULLRUN_WAL_PATH` env override still wins, so
containers with `readOnlyRootFilesystem: true` are unaffected.
Added `import tempfile` to the top-of-file imports.
Verified:
- `ruff check src/` → All checks passed!
- `mypy src/` → Success: no issues found in 23 source files
- `pytest` → 493 passed, 13 skipped (CI default, no `-W error`)
* chore(release): bump to 0.5.2
- Promote [Unreleased] to [0.5.2] — 2026-06-19; merge the two
[Unreleased] sections that had drifted during Sprint 2.5 +
Phase 0 development so release tooling scanning for the
[Unreleased] anchor picks up the complete change set exactly
once.
- Add PEP 561 marker (py.typed) — the package ships inline type
annotations; the marker tells mypy / pyright / pylance to honour
them.
- runtime.py (S-4): case-insensitive state compare in
check_control_plane. Defensive against any backend casing drift
beyond the current PascalCase (handlers.rs:9258). Pinned by
tests/test_state_compare_case_insensitive.py (10 cases covering
PascalCase / UPPERCASE / lowercase / mixed-case).
Working-notes file docs/integration-baseline-2026-06-19.md is
deliberately left untracked, matching the analyze.md pattern from
d74712e.
* test: bump coverage 70.92% → 84.52% with branch coverage
Lifts the SDK's Codecov score from 70.92 % to 84.52 % (+13.6 pp) by
adding 347 new tests across 10 files that exercise previously-untested
branches in the auto-instrumentation patches, runtime gates, transport
fallback modes, circuit breaker Redis path, and the @Protect decorator
fail-CLOSED contract.
pyproject.toml
- Enable branch coverage so error / fallback paths count.
- Raise fail_under from 70 → 82 (enforced in CI via `coverage run -m
pytest && coverage report`).
- Add precision=2 and skip_empty=true to keep the report readable.
New tests (all 817 pass locally, all 4 CI jobs green):
tests/test_autogen_patch.py — 13 tests
tests/test_crewai_patch.py — 15 tests
tests/test_llama_index_patch.py — 13 tests
tests/test_langgraph_callback.py — 38 tests
tests/test_auto_requests.py — 24 tests
tests/test_runtime_branches.py — 43 tests
tests/test_transport_branches.py — 44 tests
tests/test_circuit_breaker_branches.py — 31 tests
tests/test_protect_branches.py — 43 tests
tests/test_actions_context_init.py — 50 tests
Per-file coverage deltas:
instrumentation/autogen.py 21.33 → 93.41 %
instrumentation/crewai.py 22.97 → 90.82 %
instrumentation/llama_index.py 28.30 → 100.00 %
instrumentation/langgraph.py 23.75 → 93.69 %
instrumentation/auto_requests.py 33.72 → 99.09 %
breaker/circuit_breaker.py 59.76 → 90.21 %
transport.py 82.57 → 84.79 %
transport_websocket.py 68.70 → 64.10 % (msg-type branches
still need live ws
round-trip tests)
decorators.py 83.33 → 95.49 %
runtime.py 80.14 → 83.24 %
context.py 82.76 → 100.00 %
actions.py 92.12 → 96.89 %
breaker/exceptions.py 98.51 → 97.26 %
All 4 CI jobs pass locally (pytest, ruff check, mypy, coverage).
Working-notes file docs/integration-baseline-2026-06-19.md is
deliberately left untracked, matching the analyze.md pattern from
d74712e.
* feat(security): make @sensitive registration fail-CLOSED (ADR-008)
Sensitive-tool registration is part of the security boundary. The
old behaviour caught any exception from _get_or_create_runtime(),
logged it at DEBUG, and returned the original function unchanged —
which meant the wrapped body would later execute without ever being
added to the runtime's sensitive-tool set, completely bypassing the
pre-execution gate under partial initialization (e.g. transient
NullRunAuthenticationError on import).
Replace the silent logger.debug(...) with raise RuntimeError(...,
chained from the original exception. The decorator is the registration
point, not the call site, so raising at decoration time is the correct
signal: the import / module-load fails loudly, the body never gets a
chance to run untracked, and the caller can still inspect the root
cause via __cause__.
The two pre-existing tests pinned the old (silent / wrong-type) contract;
update them to assert the new RuntimeError wrapping:
- test_sensitive_raises_on_missing_api_key now expects RuntimeError
whose __cause__ is the original NullRunAuthenticationError.
- test_sensitive_runtime_init_failure_is_silent is renamed to
..._raises and asserts the same __cause__ chaining when a
_get_or_create_runtime mock raises.
* fix(transport): retry /track/batch on 5xx and align auth-verify path (P0 #2, P0 #5)
P0 #2 — _send_batch_with_retry_info used to do a single
self._client.post(...) + raise_for_status(). A transient backend 5xx
raised out of the flush path; the in-memory buffer was cleared at the
call site and every event in the batch was permanently lost. Wrap the
post() in _retry_with_backoff (max 3 attempts, exponential backoff +
jitter, capped at 10s) so a single 500 no longer drops the whole batch.
429 is retried (helper honors Retry-After when present); other 4xx
errors are returned as-is — those are real client bugs and must not
be retried (e.g. a 401 just wastes the user's budget).
P0 #5 — contract drift: this file's auth-verify call site used
/auth/verify, while the corresponding call in runtime.py:599 already
used /api/v1/auth/verify. Align the rotation call site to /api/v1/auth/verify
so the contract-drift-guard CI catches any future divergence.
Update tests/test_transport.py::test_retry_on_500 to assert the new
contract (third attempt succeeds → call_count == 3, event id in
accepted_event_ids) instead of expecting an immediate exception.
Add tests/test_track_batch_retry.py with full regression coverage:
single 5xx → success, three consecutive 5xx → BreakerTransportError,
429 with Retry-After → honored before next attempt.
* feat(runtime): emit background coverage_report every 60s
The SDK has tracked per-host seen / tracked / streaming_skipped counters
since 0.4.x (bump_coverage_counter, get_coverage_stats), but there was
no path to ship them to the backend — the counters only ever existed
in process memory. This commit adds a daemon thread that emits a
coverage_report track event every 60 seconds so the backend can build
the per-host coverage dashboard.
* NullRunRuntime.track_coverage() — returns a track-result dict when
there is something to report, or None on cold start (no counters
bumped yet) so the backend doesn't get an empty row per minute.
* start_coverage_reporter() / stop_coverage_reporter() — idempotent
lifecycle, daemon thread, sleeps in 0.5s slices for responsive
shutdown, emits once on entry so short-lived processes (CI, batch
jobs) still leave a row.
* nullrun.init() wires start_coverage_reporter() in; the reporter is
a no-op while the process is still cold, so re-init is safe.
New tests/test_coverage_report.py pins the contract: cold start → None,
post-traffic → track-result dict with type=coverage_report and the three
counter dicts, start is idempotent, stop joins cleanly.
* chore(breaker): add __main__ shim so 'python -m nullrun.breaker' exits cleanly
Historically the SDK shipped a 'python -m nullrun.breaker' entry point
for in-container health probes and ad-hoc debugging. The nullrun.breaker
subpackage is the circuit-breaker + policy-exceptions surface — it is
not a runnable command. Without this shim, containerized deployments
that scripted 'python -m nullrun.breaker' as a no-op smoke check would
fail with 'No module named nullrun.breaker.__main__'.
This module makes that invocation exit cleanly (return 0) and print a
short pointer to nullrun-doctor (nullrun.toolbox.diagnostics) for
real runtime checks.
* chore: gitignore audit.md (project-local working notes, sibling of analyze.md)
* test: re-align @sensitive test with fail-CLOSED contract after master merge
The auto-merge of master into this branch (commit 7875210) resolved
tests/test_protect_branches.py by taking master's side of the conflict,
leaving the old test_sensitive_runtime_init_failure_is_silent in place.
That test asserts @sensitive does NOT raise — but the production
change in commit 58263a1 (this branch) makes @sensitive raise
RuntimeError (fail-CLOSED, ADR-008). Result: CI ran the old assertion
against the new production code and failed.
Restore the renamed and re-asserted version of the test from commit
58263a1 — test_sensitive_runtime_init_failure_raises — so the test
asserts the new contract: RuntimeError is raised and __cause__ chains
the original exception.
runtime.py was resolved correctly by the auto-merge (both sides kept:
the new track_coverage / start_coverage_reporter / stop_coverage_reporter
/ _coverage_reporter_loop methods AND the existing bump_coverage_counter
are all present), so no changes there.
maltsev-dev
added a commit
that referenced
this pull request
Aug 7, 2026
* fix(ci): add Callable to typing imports in runtime.py Runtime.py uses Callable[[Exception], dict[str, Any]] in the NullRunRuntime.execute signature (line 1392) but the typing import only had Any, Optional. Under Python 3.11 (CI matrix) the class body evaluates annotations eagerly, so the missing import raises NameError at *collection* time and every test errors with 'ERROR collecting tests/test_*.py - NameError: name Callable is not defined' before pytest can even run a single test. Python 3.14 happens to defer annotation evaluation so the same code passes locally; that masked the bug during development and during the previous local pytest run (443/443 passed). The bug is purely a missing import - adding Callable to the existing 'from typing import Any, Optional' line fixes all 19 collection errors and lets the test matrix reach the actual test cases. This is a pre-existing bug, not caused by the byte-mismatch or S-2 fixes; it survived the initial import commit (316a694) and the wip/working-tree migration (1244901) because no one ran the 3.11 matrix on a workstation with the right tooling. The fix is mechanical: one identifier added to one import line. * fix(ci): add pythonpath=["."] to pytest config test_track_span_context.py uses 'from tests.conftest import BASE_URL' which requires the tests/ directory to be importable as a top-level package. On Python 3.10/3.11 (CI matrix) pytest's rootdir discovery lands on the repo root rather than the tests/ directory, so 'tests' is not on sys.path and the import raises ModuleNotFoundError at collection time, failing one test: FAILED tests/test_track_span_context.py::test_module_level_track_llm_output_tokens_optional ModuleNotFoundError: No module named 'tests' On Python 3.14 the same code passes because pytest-asyncio / hatchling pyproject discovery adds the repo root to sys.path. 3.10/3.11 don't get that for free. Add 'pythonpath = ["."]' to [tool.pytest.ini_options] so all Python versions in the supported matrix resolve 'tests' as a top-level module.
maltsev-dev
added a commit
that referenced
this pull request
Aug 7, 2026
Closes the P0/P1/P2/P3 issues from the security review (plan §10/§11.4).
Security / PCI-DSS / GDPR
- P0-1: Mask positional PII in `_enforce_sensitive_tool` by introspecting
the wrapped function's signature and applying `SENSITIVE_ARG_KEYS` to
positional params. Pre-fix, `charge("4111-…-1111", 50)` forwarded the
PAN into `/execute` and the audit log.
- P0-6 / P3-3: `_safe_repr` now redacts BEFORE truncating. The pre-fix
order truncated first, so `details={…}` past position 50 leaked
verbatim. `_safe_repr` is now the single source of truth for the
redact-then-truncate flow.
Cost-audit / reliability
- P0-3: Bounded chunked reads on the sync + async httpx transports
(`MAX_RESPONSE_BYTES`, default 16 MiB, `NULLRUN_MAX_RESPONSE_BYTES`
env override). Above the cap, tracking is skipped and
`_coverage_streaming_skipped` is incremented. Replaces the
`response.read()` / `await response.aread()` unbounded buffer that
held entire LLM streaming bodies in memory.
- P0-4: `_do_flush_locked` re-queue on CB OPEN now drops the NEWEST
non-critical events instead of the oldest. The oldest events
(incident start, billing-period start) are exactly what a billing
investigator needs; losing them silently broke monthly rollups.
Control-plane events (`state_change`, `kill_received`,
`policy_invalidated`, `key_rotated`) are preserved unconditionally
so the dashboard KILL switch lands even under sustained backend
outage.
Identity
- S-8 / P2-4: `agent()` now emits `str(uuid.uuid4())` (with dashes).
Pre-fix the format was `f"agent-{uuid.uuid4().hex}"` — 32 hex chars,
no dashes — and backend UUID-typed columns dropped these to NULL
on insert. User-supplied names are still preserved verbatim.
- §7.2 #16: `workflow()` context manager now resets `span_id` (not
only `workflow_id` / `trace_id`) so nested `with span()` blocks
don't leave the inner span_id visible inside the workflow scope.
Resource leaks
- S-9: `_active_runs` on `NullRunCallback` is now an `OrderedDict`
capped at 4096 with FIFO eviction. Pre-fix the dict grew
unbounded when `on_chain_end` did not fire (some LangChain
versions short-circuit the end hook on chain-body errors).
- S-10: WebSocket reconnect loop is now capped at 10 consecutive
failures, then falls back to HTTP-poll. Pre-fix the loop ran
forever when the backend was permanently down, leaking the
WS thread.
Transport
- §7.2 #6: Separate `hmac_verify_expired_total` counter so SRE can
distinguish clock-skew (NTP drift) from forged packets. Mirrored
in both the HTTP and WebSocket verify paths.
- §7.2 #35: `CircuitBreaker.call` now dispatches the OPEN→HALF_OPEN
jitter through `_maybe_apply_open_jitter_sync` /
`_maybe_apply_open_jitter_async`. Pre-fix the jitter used
`time.sleep` before dispatching to async, which blocked the
caller's event loop on every transition.
- P2-1: `_coverage_seen` now bumps in the httpx path (sync + async).
Pre-fix the counter was only bumped by the `requests` transport,
so the dashboard's coverage view was empty for the dominant
OpenAI / Anthropic / Gemini / Mistral / Cohere traffic.
- P2-3: `is_sensitive_tool` match is case-insensitive. Pre-fix
`"stripe.charge"` did not match `"Stripe.Charge"`, bypassing the
sensitive gate.
Concurrency
- §7.2 #39: New `_tools_lock` guards every mutation of
`_strict_mode_tools` / `_sensitive_tools`. Same lock guards the
coverage-counter bump+prune sequence (§7.2 #33) so two threads
can't both observe the dict at length 4095 and both grow it to
4097 before either prune lands.
- §7.2 #47: New `_langchain_lock` / `_langgraph_lock` guard the
patch sequences end-to-end. Pre-fix two threads racing through
`auto_instrument` could both pass the early `_x_patched` check
and double-wrap `BaseCallbackManager` / `Pregel`.
- §7.2 #33: `_COVERAGE_CAP` (4096) bounds the per-host coverage
dicts.
Webhook delivery
- P3-2: Exponential backoff (0.5s, 1s, 2s, 4s, 8s, 16s, 30s cap)
replaces the previous linear schedule. Linear didn't back off
fast enough under sustained outage — each KILL/PAUSE spawned
its own delivery thread, producing 1000+ spinning threads
hammering the dead endpoint.
WAL crash-recovery
- P1-5b: Atomic WAL writes (tmp + `fsync` + `os.replace`), 64 MiB
rotation with `os.replace(wal, wal.1)`, replay drains both
`wal.1` and `wal`. New `NULLRUN_WAL_PATH` / `NULLRUN_WAL_MAX_BYTES`
env overrides for containers with `readOnlyRootFilesystem: true`.
Tests
8 new regression test files (57 tests total):
test_agent_id_uuid.py, test_args_pii_masked.py,
test_streaming_oom_cap.py, test_lru_active_runs.py,
test_reconnect_cap.py, test_coverage_seen_httpx.py,
test_webhook_backoff.py, test_redact.py
`test_buffer_invariants.py` extended with drop-newest +
critical-event preservation cases. `test_release_polish.py`
updated to pin the 5s cap on both the sync and async jitter
helpers (post §7.2 #35 split).
Full incident write-ups in CHANGELOG.md under the same P0/S/P tags.
maltsev-dev
added a commit
that referenced
this pull request
Aug 7, 2026
* fix: P0 security/stability hardening bundle
Closes the P0/P1/P2/P3 issues from the security review (plan §10/§11.4).
Security / PCI-DSS / GDPR
- P0-1: Mask positional PII in `_enforce_sensitive_tool` by introspecting
the wrapped function's signature and applying `SENSITIVE_ARG_KEYS` to
positional params. Pre-fix, `charge("4111-…-1111", 50)` forwarded the
PAN into `/execute` and the audit log.
- P0-6 / P3-3: `_safe_repr` now redacts BEFORE truncating. The pre-fix
order truncated first, so `details={…}` past position 50 leaked
verbatim. `_safe_repr` is now the single source of truth for the
redact-then-truncate flow.
Cost-audit / reliability
- P0-3: Bounded chunked reads on the sync + async httpx transports
(`MAX_RESPONSE_BYTES`, default 16 MiB, `NULLRUN_MAX_RESPONSE_BYTES`
env override). Above the cap, tracking is skipped and
`_coverage_streaming_skipped` is incremented. Replaces the
`response.read()` / `await response.aread()` unbounded buffer that
held entire LLM streaming bodies in memory.
- P0-4: `_do_flush_locked` re-queue on CB OPEN now drops the NEWEST
non-critical events instead of the oldest. The oldest events
(incident start, billing-period start) are exactly what a billing
investigator needs; losing them silently broke monthly rollups.
Control-plane events (`state_change`, `kill_received`,
`policy_invalidated`, `key_rotated`) are preserved unconditionally
so the dashboard KILL switch lands even under sustained backend
outage.
Identity
- S-8 / P2-4: `agent()` now emits `str(uuid.uuid4())` (with dashes).
Pre-fix the format was `f"agent-{uuid.uuid4().hex}"` — 32 hex chars,
no dashes — and backend UUID-typed columns dropped these to NULL
on insert. User-supplied names are still preserved verbatim.
- §7.2 #16: `workflow()` context manager now resets `span_id` (not
only `workflow_id` / `trace_id`) so nested `with span()` blocks
don't leave the inner span_id visible inside the workflow scope.
Resource leaks
- S-9: `_active_runs` on `NullRunCallback` is now an `OrderedDict`
capped at 4096 with FIFO eviction. Pre-fix the dict grew
unbounded when `on_chain_end` did not fire (some LangChain
versions short-circuit the end hook on chain-body errors).
- S-10: WebSocket reconnect loop is now capped at 10 consecutive
failures, then falls back to HTTP-poll. Pre-fix the loop ran
forever when the backend was permanently down, leaking the
WS thread.
Transport
- §7.2 #6: Separate `hmac_verify_expired_total` counter so SRE can
distinguish clock-skew (NTP drift) from forged packets. Mirrored
in both the HTTP and WebSocket verify paths.
- §7.2 #35: `CircuitBreaker.call` now dispatches the OPEN→HALF_OPEN
jitter through `_maybe_apply_open_jitter_sync` /
`_maybe_apply_open_jitter_async`. Pre-fix the jitter used
`time.sleep` before dispatching to async, which blocked the
caller's event loop on every transition.
- P2-1: `_coverage_seen` now bumps in the httpx path (sync + async).
Pre-fix the counter was only bumped by the `requests` transport,
so the dashboard's coverage view was empty for the dominant
OpenAI / Anthropic / Gemini / Mistral / Cohere traffic.
- P2-3: `is_sensitive_tool` match is case-insensitive. Pre-fix
`"stripe.charge"` did not match `"Stripe.Charge"`, bypassing the
sensitive gate.
Concurrency
- §7.2 #39: New `_tools_lock` guards every mutation of
`_strict_mode_tools` / `_sensitive_tools`. Same lock guards the
coverage-counter bump+prune sequence (§7.2 #33) so two threads
can't both observe the dict at length 4095 and both grow it to
4097 before either prune lands.
- §7.2 #47: New `_langchain_lock` / `_langgraph_lock` guard the
patch sequences end-to-end. Pre-fix two threads racing through
`auto_instrument` could both pass the early `_x_patched` check
and double-wrap `BaseCallbackManager` / `Pregel`.
- §7.2 #33: `_COVERAGE_CAP` (4096) bounds the per-host coverage
dicts.
Webhook delivery
- P3-2: Exponential backoff (0.5s, 1s, 2s, 4s, 8s, 16s, 30s cap)
replaces the previous linear schedule. Linear didn't back off
fast enough under sustained outage — each KILL/PAUSE spawned
its own delivery thread, producing 1000+ spinning threads
hammering the dead endpoint.
WAL crash-recovery
- P1-5b: Atomic WAL writes (tmp + `fsync` + `os.replace`), 64 MiB
rotation with `os.replace(wal, wal.1)`, replay drains both
`wal.1` and `wal`. New `NULLRUN_WAL_PATH` / `NULLRUN_WAL_MAX_BYTES`
env overrides for containers with `readOnlyRootFilesystem: true`.
Tests
8 new regression test files (57 tests total):
test_agent_id_uuid.py, test_args_pii_masked.py,
test_streaming_oom_cap.py, test_lru_active_runs.py,
test_reconnect_cap.py, test_coverage_seen_httpx.py,
test_webhook_backoff.py, test_redact.py
`test_buffer_invariants.py` extended with drop-newest +
critical-event preservation cases. `test_release_polish.py`
updated to pin the 5s cap on both the sync and async jitter
helpers (post §7.2 #35 split).
Full incident write-ups in CHANGELOG.md under the same P0/S/P tags.
* fix: address ruff lint findings from CI
Three CI lint failures on `ruff check src/` — fixes only, no
behavioural changes:
- **B905** (`src/nullrun/decorators.py:162`): `zip(bound_params,
args)` now passes `strict=False` explicitly. Pre-fix the two
iterables can be different lengths — `bound_params` is sliced to
`[: len(args)]` but the function may have fewer positional
parameters than args provided (e.g. *args-style callables), in
which case the trailing loop below handles the excess. `strict=`
was implicit and triggered B905. Now explicit so the intent is
documented in code.
- **I001** (`src/nullrun/instrumentation/auto.py:1146`): the late
`import os as _os` was moved to the top-of-file import block as
`import os` (alphabetical order: hashlib, json, logging, os,
threading). The `_os` alias was only there to avoid shadowing —
there is no top-level `os` in scope, so the plain name is fine.
Call site updated to use `os.environ.get(...)`.
- **S108** (`src/nullrun/transport.py:632`): replaced the
hardcoded `/tmp/nullrun.wal` with
`os.path.join(tempfile.gettempdir(), "nullrun.wal")`. The
hardcoded `/tmp` flagged S108 (insecure / non-portable temp
path) and would have broken the SDK on Windows out of the box.
`gettempdir()` returns the OS-appropriate temp dir
(`/tmp` on Linux, `/var/folders/...` on macOS, `%TEMP%` on
Windows). `NULLRUN_WAL_PATH` env override still wins, so
containers with `readOnlyRootFilesystem: true` are unaffected.
Added `import tempfile` to the top-of-file imports.
Verified:
- `ruff check src/` → All checks passed!
- `mypy src/` → Success: no issues found in 23 source files
- `pytest` → 493 passed, 13 skipped (CI default, no `-W error`)
maltsev-dev
added a commit
that referenced
this pull request
Aug 7, 2026
* fix: P0 security/stability hardening bundle
Closes the P0/P1/P2/P3 issues from the security review (plan §10/§11.4).
Security / PCI-DSS / GDPR
- P0-1: Mask positional PII in `_enforce_sensitive_tool` by introspecting
the wrapped function's signature and applying `SENSITIVE_ARG_KEYS` to
positional params. Pre-fix, `charge("4111-…-1111", 50)` forwarded the
PAN into `/execute` and the audit log.
- P0-6 / P3-3: `_safe_repr` now redacts BEFORE truncating. The pre-fix
order truncated first, so `details={…}` past position 50 leaked
verbatim. `_safe_repr` is now the single source of truth for the
redact-then-truncate flow.
Cost-audit / reliability
- P0-3: Bounded chunked reads on the sync + async httpx transports
(`MAX_RESPONSE_BYTES`, default 16 MiB, `NULLRUN_MAX_RESPONSE_BYTES`
env override). Above the cap, tracking is skipped and
`_coverage_streaming_skipped` is incremented. Replaces the
`response.read()` / `await response.aread()` unbounded buffer that
held entire LLM streaming bodies in memory.
- P0-4: `_do_flush_locked` re-queue on CB OPEN now drops the NEWEST
non-critical events instead of the oldest. The oldest events
(incident start, billing-period start) are exactly what a billing
investigator needs; losing them silently broke monthly rollups.
Control-plane events (`state_change`, `kill_received`,
`policy_invalidated`, `key_rotated`) are preserved unconditionally
so the dashboard KILL switch lands even under sustained backend
outage.
Identity
- S-8 / P2-4: `agent()` now emits `str(uuid.uuid4())` (with dashes).
Pre-fix the format was `f"agent-{uuid.uuid4().hex}"` — 32 hex chars,
no dashes — and backend UUID-typed columns dropped these to NULL
on insert. User-supplied names are still preserved verbatim.
- §7.2 #16: `workflow()` context manager now resets `span_id` (not
only `workflow_id` / `trace_id`) so nested `with span()` blocks
don't leave the inner span_id visible inside the workflow scope.
Resource leaks
- S-9: `_active_runs` on `NullRunCallback` is now an `OrderedDict`
capped at 4096 with FIFO eviction. Pre-fix the dict grew
unbounded when `on_chain_end` did not fire (some LangChain
versions short-circuit the end hook on chain-body errors).
- S-10: WebSocket reconnect loop is now capped at 10 consecutive
failures, then falls back to HTTP-poll. Pre-fix the loop ran
forever when the backend was permanently down, leaking the
WS thread.
Transport
- §7.2 #6: Separate `hmac_verify_expired_total` counter so SRE can
distinguish clock-skew (NTP drift) from forged packets. Mirrored
in both the HTTP and WebSocket verify paths.
- §7.2 #35: `CircuitBreaker.call` now dispatches the OPEN→HALF_OPEN
jitter through `_maybe_apply_open_jitter_sync` /
`_maybe_apply_open_jitter_async`. Pre-fix the jitter used
`time.sleep` before dispatching to async, which blocked the
caller's event loop on every transition.
- P2-1: `_coverage_seen` now bumps in the httpx path (sync + async).
Pre-fix the counter was only bumped by the `requests` transport,
so the dashboard's coverage view was empty for the dominant
OpenAI / Anthropic / Gemini / Mistral / Cohere traffic.
- P2-3: `is_sensitive_tool` match is case-insensitive. Pre-fix
`"stripe.charge"` did not match `"Stripe.Charge"`, bypassing the
sensitive gate.
Concurrency
- §7.2 #39: New `_tools_lock` guards every mutation of
`_strict_mode_tools` / `_sensitive_tools`. Same lock guards the
coverage-counter bump+prune sequence (§7.2 #33) so two threads
can't both observe the dict at length 4095 and both grow it to
4097 before either prune lands.
- §7.2 #47: New `_langchain_lock` / `_langgraph_lock` guard the
patch sequences end-to-end. Pre-fix two threads racing through
`auto_instrument` could both pass the early `_x_patched` check
and double-wrap `BaseCallbackManager` / `Pregel`.
- §7.2 #33: `_COVERAGE_CAP` (4096) bounds the per-host coverage
dicts.
Webhook delivery
- P3-2: Exponential backoff (0.5s, 1s, 2s, 4s, 8s, 16s, 30s cap)
replaces the previous linear schedule. Linear didn't back off
fast enough under sustained outage — each KILL/PAUSE spawned
its own delivery thread, producing 1000+ spinning threads
hammering the dead endpoint.
WAL crash-recovery
- P1-5b: Atomic WAL writes (tmp + `fsync` + `os.replace`), 64 MiB
rotation with `os.replace(wal, wal.1)`, replay drains both
`wal.1` and `wal`. New `NULLRUN_WAL_PATH` / `NULLRUN_WAL_MAX_BYTES`
env overrides for containers with `readOnlyRootFilesystem: true`.
Tests
8 new regression test files (57 tests total):
test_agent_id_uuid.py, test_args_pii_masked.py,
test_streaming_oom_cap.py, test_lru_active_runs.py,
test_reconnect_cap.py, test_coverage_seen_httpx.py,
test_webhook_backoff.py, test_redact.py
`test_buffer_invariants.py` extended with drop-newest +
critical-event preservation cases. `test_release_polish.py`
updated to pin the 5s cap on both the sync and async jitter
helpers (post §7.2 #35 split).
Full incident write-ups in CHANGELOG.md under the same P0/S/P tags.
* fix: address ruff lint findings from CI
Three CI lint failures on `ruff check src/` — fixes only, no
behavioural changes:
- **B905** (`src/nullrun/decorators.py:162`): `zip(bound_params,
args)` now passes `strict=False` explicitly. Pre-fix the two
iterables can be different lengths — `bound_params` is sliced to
`[: len(args)]` but the function may have fewer positional
parameters than args provided (e.g. *args-style callables), in
which case the trailing loop below handles the excess. `strict=`
was implicit and triggered B905. Now explicit so the intent is
documented in code.
- **I001** (`src/nullrun/instrumentation/auto.py:1146`): the late
`import os as _os` was moved to the top-of-file import block as
`import os` (alphabetical order: hashlib, json, logging, os,
threading). The `_os` alias was only there to avoid shadowing —
there is no top-level `os` in scope, so the plain name is fine.
Call site updated to use `os.environ.get(...)`.
- **S108** (`src/nullrun/transport.py:632`): replaced the
hardcoded `/tmp/nullrun.wal` with
`os.path.join(tempfile.gettempdir(), "nullrun.wal")`. The
hardcoded `/tmp` flagged S108 (insecure / non-portable temp
path) and would have broken the SDK on Windows out of the box.
`gettempdir()` returns the OS-appropriate temp dir
(`/tmp` on Linux, `/var/folders/...` on macOS, `%TEMP%` on
Windows). `NULLRUN_WAL_PATH` env override still wins, so
containers with `readOnlyRootFilesystem: true` are unaffected.
Added `import tempfile` to the top-of-file imports.
Verified:
- `ruff check src/` → All checks passed!
- `mypy src/` → Success: no issues found in 23 source files
- `pytest` → 493 passed, 13 skipped (CI default, no `-W error`)
* chore(release): bump to 0.5.2
- Promote [Unreleased] to [0.5.2] — 2026-06-19; merge the two
[Unreleased] sections that had drifted during Sprint 2.5 +
Phase 0 development so release tooling scanning for the
[Unreleased] anchor picks up the complete change set exactly
once.
- Add PEP 561 marker (py.typed) — the package ships inline type
annotations; the marker tells mypy / pyright / pylance to honour
them.
- runtime.py (S-4): case-insensitive state compare in
check_control_plane. Defensive against any backend casing drift
beyond the current PascalCase (handlers.rs:9258). Pinned by
tests/test_state_compare_case_insensitive.py (10 cases covering
PascalCase / UPPERCASE / lowercase / mixed-case).
Working-notes file docs/integration-baseline-2026-06-19.md is
deliberately left untracked, matching the analyze.md pattern from
d74712e.
maltsev-dev
added a commit
that referenced
this pull request
Aug 7, 2026
* fix: P0 security/stability hardening bundle
Closes the P0/P1/P2/P3 issues from the security review (plan §10/§11.4).
Security / PCI-DSS / GDPR
- P0-1: Mask positional PII in `_enforce_sensitive_tool` by introspecting
the wrapped function's signature and applying `SENSITIVE_ARG_KEYS` to
positional params. Pre-fix, `charge("4111-…-1111", 50)` forwarded the
PAN into `/execute` and the audit log.
- P0-6 / P3-3: `_safe_repr` now redacts BEFORE truncating. The pre-fix
order truncated first, so `details={…}` past position 50 leaked
verbatim. `_safe_repr` is now the single source of truth for the
redact-then-truncate flow.
Cost-audit / reliability
- P0-3: Bounded chunked reads on the sync + async httpx transports
(`MAX_RESPONSE_BYTES`, default 16 MiB, `NULLRUN_MAX_RESPONSE_BYTES`
env override). Above the cap, tracking is skipped and
`_coverage_streaming_skipped` is incremented. Replaces the
`response.read()` / `await response.aread()` unbounded buffer that
held entire LLM streaming bodies in memory.
- P0-4: `_do_flush_locked` re-queue on CB OPEN now drops the NEWEST
non-critical events instead of the oldest. The oldest events
(incident start, billing-period start) are exactly what a billing
investigator needs; losing them silently broke monthly rollups.
Control-plane events (`state_change`, `kill_received`,
`policy_invalidated`, `key_rotated`) are preserved unconditionally
so the dashboard KILL switch lands even under sustained backend
outage.
Identity
- S-8 / P2-4: `agent()` now emits `str(uuid.uuid4())` (with dashes).
Pre-fix the format was `f"agent-{uuid.uuid4().hex}"` — 32 hex chars,
no dashes — and backend UUID-typed columns dropped these to NULL
on insert. User-supplied names are still preserved verbatim.
- §7.2 #16: `workflow()` context manager now resets `span_id` (not
only `workflow_id` / `trace_id`) so nested `with span()` blocks
don't leave the inner span_id visible inside the workflow scope.
Resource leaks
- S-9: `_active_runs` on `NullRunCallback` is now an `OrderedDict`
capped at 4096 with FIFO eviction. Pre-fix the dict grew
unbounded when `on_chain_end` did not fire (some LangChain
versions short-circuit the end hook on chain-body errors).
- S-10: WebSocket reconnect loop is now capped at 10 consecutive
failures, then falls back to HTTP-poll. Pre-fix the loop ran
forever when the backend was permanently down, leaking the
WS thread.
Transport
- §7.2 #6: Separate `hmac_verify_expired_total` counter so SRE can
distinguish clock-skew (NTP drift) from forged packets. Mirrored
in both the HTTP and WebSocket verify paths.
- §7.2 #35: `CircuitBreaker.call` now dispatches the OPEN→HALF_OPEN
jitter through `_maybe_apply_open_jitter_sync` /
`_maybe_apply_open_jitter_async`. Pre-fix the jitter used
`time.sleep` before dispatching to async, which blocked the
caller's event loop on every transition.
- P2-1: `_coverage_seen` now bumps in the httpx path (sync + async).
Pre-fix the counter was only bumped by the `requests` transport,
so the dashboard's coverage view was empty for the dominant
OpenAI / Anthropic / Gemini / Mistral / Cohere traffic.
- P2-3: `is_sensitive_tool` match is case-insensitive. Pre-fix
`"stripe.charge"` did not match `"Stripe.Charge"`, bypassing the
sensitive gate.
Concurrency
- §7.2 #39: New `_tools_lock` guards every mutation of
`_strict_mode_tools` / `_sensitive_tools`. Same lock guards the
coverage-counter bump+prune sequence (§7.2 #33) so two threads
can't both observe the dict at length 4095 and both grow it to
4097 before either prune lands.
- §7.2 #47: New `_langchain_lock` / `_langgraph_lock` guard the
patch sequences end-to-end. Pre-fix two threads racing through
`auto_instrument` could both pass the early `_x_patched` check
and double-wrap `BaseCallbackManager` / `Pregel`.
- §7.2 #33: `_COVERAGE_CAP` (4096) bounds the per-host coverage
dicts.
Webhook delivery
- P3-2: Exponential backoff (0.5s, 1s, 2s, 4s, 8s, 16s, 30s cap)
replaces the previous linear schedule. Linear didn't back off
fast enough under sustained outage — each KILL/PAUSE spawned
its own delivery thread, producing 1000+ spinning threads
hammering the dead endpoint.
WAL crash-recovery
- P1-5b: Atomic WAL writes (tmp + `fsync` + `os.replace`), 64 MiB
rotation with `os.replace(wal, wal.1)`, replay drains both
`wal.1` and `wal`. New `NULLRUN_WAL_PATH` / `NULLRUN_WAL_MAX_BYTES`
env overrides for containers with `readOnlyRootFilesystem: true`.
Tests
8 new regression test files (57 tests total):
test_agent_id_uuid.py, test_args_pii_masked.py,
test_streaming_oom_cap.py, test_lru_active_runs.py,
test_reconnect_cap.py, test_coverage_seen_httpx.py,
test_webhook_backoff.py, test_redact.py
`test_buffer_invariants.py` extended with drop-newest +
critical-event preservation cases. `test_release_polish.py`
updated to pin the 5s cap on both the sync and async jitter
helpers (post §7.2 #35 split).
Full incident write-ups in CHANGELOG.md under the same P0/S/P tags.
* fix: address ruff lint findings from CI
Three CI lint failures on `ruff check src/` — fixes only, no
behavioural changes:
- **B905** (`src/nullrun/decorators.py:162`): `zip(bound_params,
args)` now passes `strict=False` explicitly. Pre-fix the two
iterables can be different lengths — `bound_params` is sliced to
`[: len(args)]` but the function may have fewer positional
parameters than args provided (e.g. *args-style callables), in
which case the trailing loop below handles the excess. `strict=`
was implicit and triggered B905. Now explicit so the intent is
documented in code.
- **I001** (`src/nullrun/instrumentation/auto.py:1146`): the late
`import os as _os` was moved to the top-of-file import block as
`import os` (alphabetical order: hashlib, json, logging, os,
threading). The `_os` alias was only there to avoid shadowing —
there is no top-level `os` in scope, so the plain name is fine.
Call site updated to use `os.environ.get(...)`.
- **S108** (`src/nullrun/transport.py:632`): replaced the
hardcoded `/tmp/nullrun.wal` with
`os.path.join(tempfile.gettempdir(), "nullrun.wal")`. The
hardcoded `/tmp` flagged S108 (insecure / non-portable temp
path) and would have broken the SDK on Windows out of the box.
`gettempdir()` returns the OS-appropriate temp dir
(`/tmp` on Linux, `/var/folders/...` on macOS, `%TEMP%` on
Windows). `NULLRUN_WAL_PATH` env override still wins, so
containers with `readOnlyRootFilesystem: true` are unaffected.
Added `import tempfile` to the top-of-file imports.
Verified:
- `ruff check src/` → All checks passed!
- `mypy src/` → Success: no issues found in 23 source files
- `pytest` → 493 passed, 13 skipped (CI default, no `-W error`)
* chore(release): bump to 0.5.2
- Promote [Unreleased] to [0.5.2] — 2026-06-19; merge the two
[Unreleased] sections that had drifted during Sprint 2.5 +
Phase 0 development so release tooling scanning for the
[Unreleased] anchor picks up the complete change set exactly
once.
- Add PEP 561 marker (py.typed) — the package ships inline type
annotations; the marker tells mypy / pyright / pylance to honour
them.
- runtime.py (S-4): case-insensitive state compare in
check_control_plane. Defensive against any backend casing drift
beyond the current PascalCase (handlers.rs:9258). Pinned by
tests/test_state_compare_case_insensitive.py (10 cases covering
PascalCase / UPPERCASE / lowercase / mixed-case).
Working-notes file docs/integration-baseline-2026-06-19.md is
deliberately left untracked, matching the analyze.md pattern from
d74712e.
* test: bump coverage 70.92% → 84.52% with branch coverage
Lifts the SDK's Codecov score from 70.92 % to 84.52 % (+13.6 pp) by
adding 347 new tests across 10 files that exercise previously-untested
branches in the auto-instrumentation patches, runtime gates, transport
fallback modes, circuit breaker Redis path, and the @Protect decorator
fail-CLOSED contract.
pyproject.toml
- Enable branch coverage so error / fallback paths count.
- Raise fail_under from 70 → 82 (enforced in CI via `coverage run -m
pytest && coverage report`).
- Add precision=2 and skip_empty=true to keep the report readable.
New tests (all 817 pass locally, all 4 CI jobs green):
tests/test_autogen_patch.py — 13 tests
tests/test_crewai_patch.py — 15 tests
tests/test_llama_index_patch.py — 13 tests
tests/test_langgraph_callback.py — 38 tests
tests/test_auto_requests.py — 24 tests
tests/test_runtime_branches.py — 43 tests
tests/test_transport_branches.py — 44 tests
tests/test_circuit_breaker_branches.py — 31 tests
tests/test_protect_branches.py — 43 tests
tests/test_actions_context_init.py — 50 tests
Per-file coverage deltas:
instrumentation/autogen.py 21.33 → 93.41 %
instrumentation/crewai.py 22.97 → 90.82 %
instrumentation/llama_index.py 28.30 → 100.00 %
instrumentation/langgraph.py 23.75 → 93.69 %
instrumentation/auto_requests.py 33.72 → 99.09 %
breaker/circuit_breaker.py 59.76 → 90.21 %
transport.py 82.57 → 84.79 %
transport_websocket.py 68.70 → 64.10 % (msg-type branches
still need live ws
round-trip tests)
decorators.py 83.33 → 95.49 %
runtime.py 80.14 → 83.24 %
context.py 82.76 → 100.00 %
actions.py 92.12 → 96.89 %
breaker/exceptions.py 98.51 → 97.26 %
All 4 CI jobs pass locally (pytest, ruff check, mypy, coverage).
Working-notes file docs/integration-baseline-2026-06-19.md is
deliberately left untracked, matching the analyze.md pattern from
d74712e.
maltsev-dev
added a commit
that referenced
this pull request
Aug 7, 2026
…erage reporter (#26) * fix: P0 security/stability hardening bundle Closes the P0/P1/P2/P3 issues from the security review (plan §10/§11.4). Security / PCI-DSS / GDPR - P0-1: Mask positional PII in `_enforce_sensitive_tool` by introspecting the wrapped function's signature and applying `SENSITIVE_ARG_KEYS` to positional params. Pre-fix, `charge("4111-…-1111", 50)` forwarded the PAN into `/execute` and the audit log. - P0-6 / P3-3: `_safe_repr` now redacts BEFORE truncating. The pre-fix order truncated first, so `details={…}` past position 50 leaked verbatim. `_safe_repr` is now the single source of truth for the redact-then-truncate flow. Cost-audit / reliability - P0-3: Bounded chunked reads on the sync + async httpx transports (`MAX_RESPONSE_BYTES`, default 16 MiB, `NULLRUN_MAX_RESPONSE_BYTES` env override). Above the cap, tracking is skipped and `_coverage_streaming_skipped` is incremented. Replaces the `response.read()` / `await response.aread()` unbounded buffer that held entire LLM streaming bodies in memory. - P0-4: `_do_flush_locked` re-queue on CB OPEN now drops the NEWEST non-critical events instead of the oldest. The oldest events (incident start, billing-period start) are exactly what a billing investigator needs; losing them silently broke monthly rollups. Control-plane events (`state_change`, `kill_received`, `policy_invalidated`, `key_rotated`) are preserved unconditionally so the dashboard KILL switch lands even under sustained backend outage. Identity - S-8 / P2-4: `agent()` now emits `str(uuid.uuid4())` (with dashes). Pre-fix the format was `f"agent-{uuid.uuid4().hex}"` — 32 hex chars, no dashes — and backend UUID-typed columns dropped these to NULL on insert. User-supplied names are still preserved verbatim. - §7.2 #16: `workflow()` context manager now resets `span_id` (not only `workflow_id` / `trace_id`) so nested `with span()` blocks don't leave the inner span_id visible inside the workflow scope. Resource leaks - S-9: `_active_runs` on `NullRunCallback` is now an `OrderedDict` capped at 4096 with FIFO eviction. Pre-fix the dict grew unbounded when `on_chain_end` did not fire (some LangChain versions short-circuit the end hook on chain-body errors). - S-10: WebSocket reconnect loop is now capped at 10 consecutive failures, then falls back to HTTP-poll. Pre-fix the loop ran forever when the backend was permanently down, leaking the WS thread. Transport - §7.2 #6: Separate `hmac_verify_expired_total` counter so SRE can distinguish clock-skew (NTP drift) from forged packets. Mirrored in both the HTTP and WebSocket verify paths. - §7.2 #35: `CircuitBreaker.call` now dispatches the OPEN→HALF_OPEN jitter through `_maybe_apply_open_jitter_sync` / `_maybe_apply_open_jitter_async`. Pre-fix the jitter used `time.sleep` before dispatching to async, which blocked the caller's event loop on every transition. - P2-1: `_coverage_seen` now bumps in the httpx path (sync + async). Pre-fix the counter was only bumped by the `requests` transport, so the dashboard's coverage view was empty for the dominant OpenAI / Anthropic / Gemini / Mistral / Cohere traffic. - P2-3: `is_sensitive_tool` match is case-insensitive. Pre-fix `"stripe.charge"` did not match `"Stripe.Charge"`, bypassing the sensitive gate. Concurrency - §7.2 #39: New `_tools_lock` guards every mutation of `_strict_mode_tools` / `_sensitive_tools`. Same lock guards the coverage-counter bump+prune sequence (§7.2 #33) so two threads can't both observe the dict at length 4095 and both grow it to 4097 before either prune lands. - §7.2 #47: New `_langchain_lock` / `_langgraph_lock` guard the patch sequences end-to-end. Pre-fix two threads racing through `auto_instrument` could both pass the early `_x_patched` check and double-wrap `BaseCallbackManager` / `Pregel`. - §7.2 #33: `_COVERAGE_CAP` (4096) bounds the per-host coverage dicts. Webhook delivery - P3-2: Exponential backoff (0.5s, 1s, 2s, 4s, 8s, 16s, 30s cap) replaces the previous linear schedule. Linear didn't back off fast enough under sustained outage — each KILL/PAUSE spawned its own delivery thread, producing 1000+ spinning threads hammering the dead endpoint. WAL crash-recovery - P1-5b: Atomic WAL writes (tmp + `fsync` + `os.replace`), 64 MiB rotation with `os.replace(wal, wal.1)`, replay drains both `wal.1` and `wal`. New `NULLRUN_WAL_PATH` / `NULLRUN_WAL_MAX_BYTES` env overrides for containers with `readOnlyRootFilesystem: true`. Tests 8 new regression test files (57 tests total): test_agent_id_uuid.py, test_args_pii_masked.py, test_streaming_oom_cap.py, test_lru_active_runs.py, test_reconnect_cap.py, test_coverage_seen_httpx.py, test_webhook_backoff.py, test_redact.py `test_buffer_invariants.py` extended with drop-newest + critical-event preservation cases. `test_release_polish.py` updated to pin the 5s cap on both the sync and async jitter helpers (post §7.2 #35 split). Full incident write-ups in CHANGELOG.md under the same P0/S/P tags. * fix: address ruff lint findings from CI Three CI lint failures on `ruff check src/` — fixes only, no behavioural changes: - **B905** (`src/nullrun/decorators.py:162`): `zip(bound_params, args)` now passes `strict=False` explicitly. Pre-fix the two iterables can be different lengths — `bound_params` is sliced to `[: len(args)]` but the function may have fewer positional parameters than args provided (e.g. *args-style callables), in which case the trailing loop below handles the excess. `strict=` was implicit and triggered B905. Now explicit so the intent is documented in code. - **I001** (`src/nullrun/instrumentation/auto.py:1146`): the late `import os as _os` was moved to the top-of-file import block as `import os` (alphabetical order: hashlib, json, logging, os, threading). The `_os` alias was only there to avoid shadowing — there is no top-level `os` in scope, so the plain name is fine. Call site updated to use `os.environ.get(...)`. - **S108** (`src/nullrun/transport.py:632`): replaced the hardcoded `/tmp/nullrun.wal` with `os.path.join(tempfile.gettempdir(), "nullrun.wal")`. The hardcoded `/tmp` flagged S108 (insecure / non-portable temp path) and would have broken the SDK on Windows out of the box. `gettempdir()` returns the OS-appropriate temp dir (`/tmp` on Linux, `/var/folders/...` on macOS, `%TEMP%` on Windows). `NULLRUN_WAL_PATH` env override still wins, so containers with `readOnlyRootFilesystem: true` are unaffected. Added `import tempfile` to the top-of-file imports. Verified: - `ruff check src/` → All checks passed! - `mypy src/` → Success: no issues found in 23 source files - `pytest` → 493 passed, 13 skipped (CI default, no `-W error`) * chore(release): bump to 0.5.2 - Promote [Unreleased] to [0.5.2] — 2026-06-19; merge the two [Unreleased] sections that had drifted during Sprint 2.5 + Phase 0 development so release tooling scanning for the [Unreleased] anchor picks up the complete change set exactly once. - Add PEP 561 marker (py.typed) — the package ships inline type annotations; the marker tells mypy / pyright / pylance to honour them. - runtime.py (S-4): case-insensitive state compare in check_control_plane. Defensive against any backend casing drift beyond the current PascalCase (handlers.rs:9258). Pinned by tests/test_state_compare_case_insensitive.py (10 cases covering PascalCase / UPPERCASE / lowercase / mixed-case). Working-notes file docs/integration-baseline-2026-06-19.md is deliberately left untracked, matching the analyze.md pattern from d74712e. * test: bump coverage 70.92% → 84.52% with branch coverage Lifts the SDK's Codecov score from 70.92 % to 84.52 % (+13.6 pp) by adding 347 new tests across 10 files that exercise previously-untested branches in the auto-instrumentation patches, runtime gates, transport fallback modes, circuit breaker Redis path, and the @Protect decorator fail-CLOSED contract. pyproject.toml - Enable branch coverage so error / fallback paths count. - Raise fail_under from 70 → 82 (enforced in CI via `coverage run -m pytest && coverage report`). - Add precision=2 and skip_empty=true to keep the report readable. New tests (all 817 pass locally, all 4 CI jobs green): tests/test_autogen_patch.py — 13 tests tests/test_crewai_patch.py — 15 tests tests/test_llama_index_patch.py — 13 tests tests/test_langgraph_callback.py — 38 tests tests/test_auto_requests.py — 24 tests tests/test_runtime_branches.py — 43 tests tests/test_transport_branches.py — 44 tests tests/test_circuit_breaker_branches.py — 31 tests tests/test_protect_branches.py — 43 tests tests/test_actions_context_init.py — 50 tests Per-file coverage deltas: instrumentation/autogen.py 21.33 → 93.41 % instrumentation/crewai.py 22.97 → 90.82 % instrumentation/llama_index.py 28.30 → 100.00 % instrumentation/langgraph.py 23.75 → 93.69 % instrumentation/auto_requests.py 33.72 → 99.09 % breaker/circuit_breaker.py 59.76 → 90.21 % transport.py 82.57 → 84.79 % transport_websocket.py 68.70 → 64.10 % (msg-type branches still need live ws round-trip tests) decorators.py 83.33 → 95.49 % runtime.py 80.14 → 83.24 % context.py 82.76 → 100.00 % actions.py 92.12 → 96.89 % breaker/exceptions.py 98.51 → 97.26 % All 4 CI jobs pass locally (pytest, ruff check, mypy, coverage). Working-notes file docs/integration-baseline-2026-06-19.md is deliberately left untracked, matching the analyze.md pattern from d74712e. * feat(security): make @sensitive registration fail-CLOSED (ADR-008) Sensitive-tool registration is part of the security boundary. The old behaviour caught any exception from _get_or_create_runtime(), logged it at DEBUG, and returned the original function unchanged — which meant the wrapped body would later execute without ever being added to the runtime's sensitive-tool set, completely bypassing the pre-execution gate under partial initialization (e.g. transient NullRunAuthenticationError on import). Replace the silent logger.debug(...) with raise RuntimeError(..., chained from the original exception. The decorator is the registration point, not the call site, so raising at decoration time is the correct signal: the import / module-load fails loudly, the body never gets a chance to run untracked, and the caller can still inspect the root cause via __cause__. The two pre-existing tests pinned the old (silent / wrong-type) contract; update them to assert the new RuntimeError wrapping: - test_sensitive_raises_on_missing_api_key now expects RuntimeError whose __cause__ is the original NullRunAuthenticationError. - test_sensitive_runtime_init_failure_is_silent is renamed to ..._raises and asserts the same __cause__ chaining when a _get_or_create_runtime mock raises. * fix(transport): retry /track/batch on 5xx and align auth-verify path (P0 #2, P0 #5) P0 #2 — _send_batch_with_retry_info used to do a single self._client.post(...) + raise_for_status(). A transient backend 5xx raised out of the flush path; the in-memory buffer was cleared at the call site and every event in the batch was permanently lost. Wrap the post() in _retry_with_backoff (max 3 attempts, exponential backoff + jitter, capped at 10s) so a single 500 no longer drops the whole batch. 429 is retried (helper honors Retry-After when present); other 4xx errors are returned as-is — those are real client bugs and must not be retried (e.g. a 401 just wastes the user's budget). P0 #5 — contract drift: this file's auth-verify call site used /auth/verify, while the corresponding call in runtime.py:599 already used /api/v1/auth/verify. Align the rotation call site to /api/v1/auth/verify so the contract-drift-guard CI catches any future divergence. Update tests/test_transport.py::test_retry_on_500 to assert the new contract (third attempt succeeds → call_count == 3, event id in accepted_event_ids) instead of expecting an immediate exception. Add tests/test_track_batch_retry.py with full regression coverage: single 5xx → success, three consecutive 5xx → BreakerTransportError, 429 with Retry-After → honored before next attempt. * feat(runtime): emit background coverage_report every 60s The SDK has tracked per-host seen / tracked / streaming_skipped counters since 0.4.x (bump_coverage_counter, get_coverage_stats), but there was no path to ship them to the backend — the counters only ever existed in process memory. This commit adds a daemon thread that emits a coverage_report track event every 60 seconds so the backend can build the per-host coverage dashboard. * NullRunRuntime.track_coverage() — returns a track-result dict when there is something to report, or None on cold start (no counters bumped yet) so the backend doesn't get an empty row per minute. * start_coverage_reporter() / stop_coverage_reporter() — idempotent lifecycle, daemon thread, sleeps in 0.5s slices for responsive shutdown, emits once on entry so short-lived processes (CI, batch jobs) still leave a row. * nullrun.init() wires start_coverage_reporter() in; the reporter is a no-op while the process is still cold, so re-init is safe. New tests/test_coverage_report.py pins the contract: cold start → None, post-traffic → track-result dict with type=coverage_report and the three counter dicts, start is idempotent, stop joins cleanly. * chore(breaker): add __main__ shim so 'python -m nullrun.breaker' exits cleanly Historically the SDK shipped a 'python -m nullrun.breaker' entry point for in-container health probes and ad-hoc debugging. The nullrun.breaker subpackage is the circuit-breaker + policy-exceptions surface — it is not a runnable command. Without this shim, containerized deployments that scripted 'python -m nullrun.breaker' as a no-op smoke check would fail with 'No module named nullrun.breaker.__main__'. This module makes that invocation exit cleanly (return 0) and print a short pointer to nullrun-doctor (nullrun.toolbox.diagnostics) for real runtime checks. * chore: gitignore audit.md (project-local working notes, sibling of analyze.md) * test: re-align @sensitive test with fail-CLOSED contract after master merge The auto-merge of master into this branch (commit 7875210) resolved tests/test_protect_branches.py by taking master's side of the conflict, leaving the old test_sensitive_runtime_init_failure_is_silent in place. That test asserts @sensitive does NOT raise — but the production change in commit 58263a1 (this branch) makes @sensitive raise RuntimeError (fail-CLOSED, ADR-008). Result: CI ran the old assertion against the new production code and failed. Restore the renamed and re-asserted version of the test from commit 58263a1 — test_sensitive_runtime_init_failure_raises — so the test asserts the new contract: RuntimeError is raised and __cause__ chains the original exception. runtime.py was resolved correctly by the auto-merge (both sides kept: the new track_coverage / start_coverage_reporter / stop_coverage_reporter / _coverage_reporter_loop methods AND the existing bump_coverage_counter are all present), so no changes there.
maltsev-dev
added a commit
that referenced
this pull request
Aug 7, 2026
* fix: P0 security/stability hardening bundle
Closes the P0/P1/P2/P3 issues from the security review (plan §10/§11.4).
Security / PCI-DSS / GDPR
- P0-1: Mask positional PII in `_enforce_sensitive_tool` by introspecting
the wrapped function's signature and applying `SENSITIVE_ARG_KEYS` to
positional params. Pre-fix, `charge("4111-…-1111", 50)` forwarded the
PAN into `/execute` and the audit log.
- P0-6 / P3-3: `_safe_repr` now redacts BEFORE truncating. The pre-fix
order truncated first, so `details={…}` past position 50 leaked
verbatim. `_safe_repr` is now the single source of truth for the
redact-then-truncate flow.
Cost-audit / reliability
- P0-3: Bounded chunked reads on the sync + async httpx transports
(`MAX_RESPONSE_BYTES`, default 16 MiB, `NULLRUN_MAX_RESPONSE_BYTES`
env override). Above the cap, tracking is skipped and
`_coverage_streaming_skipped` is incremented. Replaces the
`response.read()` / `await response.aread()` unbounded buffer that
held entire LLM streaming bodies in memory.
- P0-4: `_do_flush_locked` re-queue on CB OPEN now drops the NEWEST
non-critical events instead of the oldest. The oldest events
(incident start, billing-period start) are exactly what a billing
investigator needs; losing them silently broke monthly rollups.
Control-plane events (`state_change`, `kill_received`,
`policy_invalidated`, `key_rotated`) are preserved unconditionally
so the dashboard KILL switch lands even under sustained backend
outage.
Identity
- S-8 / P2-4: `agent()` now emits `str(uuid.uuid4())` (with dashes).
Pre-fix the format was `f"agent-{uuid.uuid4().hex}"` — 32 hex chars,
no dashes — and backend UUID-typed columns dropped these to NULL
on insert. User-supplied names are still preserved verbatim.
- §7.2 #16: `workflow()` context manager now resets `span_id` (not
only `workflow_id` / `trace_id`) so nested `with span()` blocks
don't leave the inner span_id visible inside the workflow scope.
Resource leaks
- S-9: `_active_runs` on `NullRunCallback` is now an `OrderedDict`
capped at 4096 with FIFO eviction. Pre-fix the dict grew
unbounded when `on_chain_end` did not fire (some LangChain
versions short-circuit the end hook on chain-body errors).
- S-10: WebSocket reconnect loop is now capped at 10 consecutive
failures, then falls back to HTTP-poll. Pre-fix the loop ran
forever when the backend was permanently down, leaking the
WS thread.
Transport
- §7.2 #6: Separate `hmac_verify_expired_total` counter so SRE can
distinguish clock-skew (NTP drift) from forged packets. Mirrored
in both the HTTP and WebSocket verify paths.
- §7.2 #35: `CircuitBreaker.call` now dispatches the OPEN→HALF_OPEN
jitter through `_maybe_apply_open_jitter_sync` /
`_maybe_apply_open_jitter_async`. Pre-fix the jitter used
`time.sleep` before dispatching to async, which blocked the
caller's event loop on every transition.
- P2-1: `_coverage_seen` now bumps in the httpx path (sync + async).
Pre-fix the counter was only bumped by the `requests` transport,
so the dashboard's coverage view was empty for the dominant
OpenAI / Anthropic / Gemini / Mistral / Cohere traffic.
- P2-3: `is_sensitive_tool` match is case-insensitive. Pre-fix
`"stripe.charge"` did not match `"Stripe.Charge"`, bypassing the
sensitive gate.
Concurrency
- §7.2 #39: New `_tools_lock` guards every mutation of
`_strict_mode_tools` / `_sensitive_tools`. Same lock guards the
coverage-counter bump+prune sequence (§7.2 #33) so two threads
can't both observe the dict at length 4095 and both grow it to
4097 before either prune lands.
- §7.2 #47: New `_langchain_lock` / `_langgraph_lock` guard the
patch sequences end-to-end. Pre-fix two threads racing through
`auto_instrument` could both pass the early `_x_patched` check
and double-wrap `BaseCallbackManager` / `Pregel`.
- §7.2 #33: `_COVERAGE_CAP` (4096) bounds the per-host coverage
dicts.
Webhook delivery
- P3-2: Exponential backoff (0.5s, 1s, 2s, 4s, 8s, 16s, 30s cap)
replaces the previous linear schedule. Linear didn't back off
fast enough under sustained outage — each KILL/PAUSE spawned
its own delivery thread, producing 1000+ spinning threads
hammering the dead endpoint.
WAL crash-recovery
- P1-5b: Atomic WAL writes (tmp + `fsync` + `os.replace`), 64 MiB
rotation with `os.replace(wal, wal.1)`, replay drains both
`wal.1` and `wal`. New `NULLRUN_WAL_PATH` / `NULLRUN_WAL_MAX_BYTES`
env overrides for containers with `readOnlyRootFilesystem: true`.
Tests
8 new regression test files (57 tests total):
test_agent_id_uuid.py, test_args_pii_masked.py,
test_streaming_oom_cap.py, test_lru_active_runs.py,
test_reconnect_cap.py, test_coverage_seen_httpx.py,
test_webhook_backoff.py, test_redact.py
`test_buffer_invariants.py` extended with drop-newest +
critical-event preservation cases. `test_release_polish.py`
updated to pin the 5s cap on both the sync and async jitter
helpers (post §7.2 #35 split).
Full incident write-ups in CHANGELOG.md under the same P0/S/P tags.
* fix: address ruff lint findings from CI
Three CI lint failures on `ruff check src/` — fixes only, no
behavioural changes:
- **B905** (`src/nullrun/decorators.py:162`): `zip(bound_params,
args)` now passes `strict=False` explicitly. Pre-fix the two
iterables can be different lengths — `bound_params` is sliced to
`[: len(args)]` but the function may have fewer positional
parameters than args provided (e.g. *args-style callables), in
which case the trailing loop below handles the excess. `strict=`
was implicit and triggered B905. Now explicit so the intent is
documented in code.
- **I001** (`src/nullrun/instrumentation/auto.py:1146`): the late
`import os as _os` was moved to the top-of-file import block as
`import os` (alphabetical order: hashlib, json, logging, os,
threading). The `_os` alias was only there to avoid shadowing —
there is no top-level `os` in scope, so the plain name is fine.
Call site updated to use `os.environ.get(...)`.
- **S108** (`src/nullrun/transport.py:632`): replaced the
hardcoded `/tmp/nullrun.wal` with
`os.path.join(tempfile.gettempdir(), "nullrun.wal")`. The
hardcoded `/tmp` flagged S108 (insecure / non-portable temp
path) and would have broken the SDK on Windows out of the box.
`gettempdir()` returns the OS-appropriate temp dir
(`/tmp` on Linux, `/var/folders/...` on macOS, `%TEMP%` on
Windows). `NULLRUN_WAL_PATH` env override still wins, so
containers with `readOnlyRootFilesystem: true` are unaffected.
Added `import tempfile` to the top-of-file imports.
Verified:
- `ruff check src/` → All checks passed!
- `mypy src/` → Success: no issues found in 23 source files
- `pytest` → 493 passed, 13 skipped (CI default, no `-W error`)
* chore(release): bump to 0.5.2
- Promote [Unreleased] to [0.5.2] — 2026-06-19; merge the two
[Unreleased] sections that had drifted during Sprint 2.5 +
Phase 0 development so release tooling scanning for the
[Unreleased] anchor picks up the complete change set exactly
once.
- Add PEP 561 marker (py.typed) — the package ships inline type
annotations; the marker tells mypy / pyright / pylance to honour
them.
- runtime.py (S-4): case-insensitive state compare in
check_control_plane. Defensive against any backend casing drift
beyond the current PascalCase (handlers.rs:9258). Pinned by
tests/test_state_compare_case_insensitive.py (10 cases covering
PascalCase / UPPERCASE / lowercase / mixed-case).
Working-notes file docs/integration-baseline-2026-06-19.md is
deliberately left untracked, matching the analyze.md pattern from
d74712e.
* test: bump coverage 70.92% → 84.52% with branch coverage
Lifts the SDK's Codecov score from 70.92 % to 84.52 % (+13.6 pp) by
adding 347 new tests across 10 files that exercise previously-untested
branches in the auto-instrumentation patches, runtime gates, transport
fallback modes, circuit breaker Redis path, and the @Protect decorator
fail-CLOSED contract.
pyproject.toml
- Enable branch coverage so error / fallback paths count.
- Raise fail_under from 70 → 82 (enforced in CI via `coverage run -m
pytest && coverage report`).
- Add precision=2 and skip_empty=true to keep the report readable.
New tests (all 817 pass locally, all 4 CI jobs green):
tests/test_autogen_patch.py — 13 tests
tests/test_crewai_patch.py — 15 tests
tests/test_llama_index_patch.py — 13 tests
tests/test_langgraph_callback.py — 38 tests
tests/test_auto_requests.py — 24 tests
tests/test_runtime_branches.py — 43 tests
tests/test_transport_branches.py — 44 tests
tests/test_circuit_breaker_branches.py — 31 tests
tests/test_protect_branches.py — 43 tests
tests/test_actions_context_init.py — 50 tests
Per-file coverage deltas:
instrumentation/autogen.py 21.33 → 93.41 %
instrumentation/crewai.py 22.97 → 90.82 %
instrumentation/llama_index.py 28.30 → 100.00 %
instrumentation/langgraph.py 23.75 → 93.69 %
instrumentation/auto_requests.py 33.72 → 99.09 %
breaker/circuit_breaker.py 59.76 → 90.21 %
transport.py 82.57 → 84.79 %
transport_websocket.py 68.70 → 64.10 % (msg-type branches
still need live ws
round-trip tests)
decorators.py 83.33 → 95.49 %
runtime.py 80.14 → 83.24 %
context.py 82.76 → 100.00 %
actions.py 92.12 → 96.89 %
breaker/exceptions.py 98.51 → 97.26 %
All 4 CI jobs pass locally (pytest, ruff check, mypy, coverage).
Working-notes file docs/integration-baseline-2026-06-19.md is
deliberately left untracked, matching the analyze.md pattern from
d74712e.
* feat(security): make @sensitive registration fail-CLOSED (ADR-008)
Sensitive-tool registration is part of the security boundary. The
old behaviour caught any exception from _get_or_create_runtime(),
logged it at DEBUG, and returned the original function unchanged —
which meant the wrapped body would later execute without ever being
added to the runtime's sensitive-tool set, completely bypassing the
pre-execution gate under partial initialization (e.g. transient
NullRunAuthenticationError on import).
Replace the silent logger.debug(...) with raise RuntimeError(...,
chained from the original exception. The decorator is the registration
point, not the call site, so raising at decoration time is the correct
signal: the import / module-load fails loudly, the body never gets a
chance to run untracked, and the caller can still inspect the root
cause via __cause__.
The two pre-existing tests pinned the old (silent / wrong-type) contract;
update them to assert the new RuntimeError wrapping:
- test_sensitive_raises_on_missing_api_key now expects RuntimeError
whose __cause__ is the original NullRunAuthenticationError.
- test_sensitive_runtime_init_failure_is_silent is renamed to
..._raises and asserts the same __cause__ chaining when a
_get_or_create_runtime mock raises.
* fix(transport): retry /track/batch on 5xx and align auth-verify path (P0 #2, P0 #5)
P0 #2 — _send_batch_with_retry_info used to do a single
self._client.post(...) + raise_for_status(). A transient backend 5xx
raised out of the flush path; the in-memory buffer was cleared at the
call site and every event in the batch was permanently lost. Wrap the
post() in _retry_with_backoff (max 3 attempts, exponential backoff +
jitter, capped at 10s) so a single 500 no longer drops the whole batch.
429 is retried (helper honors Retry-After when present); other 4xx
errors are returned as-is — those are real client bugs and must not
be retried (e.g. a 401 just wastes the user's budget).
P0 #5 — contract drift: this file's auth-verify call site used
/auth/verify, while the corresponding call in runtime.py:599 already
used /api/v1/auth/verify. Align the rotation call site to /api/v1/auth/verify
so the contract-drift-guard CI catches any future divergence.
Update tests/test_transport.py::test_retry_on_500 to assert the new
contract (third attempt succeeds → call_count == 3, event id in
accepted_event_ids) instead of expecting an immediate exception.
Add tests/test_track_batch_retry.py with full regression coverage:
single 5xx → success, three consecutive 5xx → BreakerTransportError,
429 with Retry-After → honored before next attempt.
* feat(runtime): emit background coverage_report every 60s
The SDK has tracked per-host seen / tracked / streaming_skipped counters
since 0.4.x (bump_coverage_counter, get_coverage_stats), but there was
no path to ship them to the backend — the counters only ever existed
in process memory. This commit adds a daemon thread that emits a
coverage_report track event every 60 seconds so the backend can build
the per-host coverage dashboard.
* NullRunRuntime.track_coverage() — returns a track-result dict when
there is something to report, or None on cold start (no counters
bumped yet) so the backend doesn't get an empty row per minute.
* start_coverage_reporter() / stop_coverage_reporter() — idempotent
lifecycle, daemon thread, sleeps in 0.5s slices for responsive
shutdown, emits once on entry so short-lived processes (CI, batch
jobs) still leave a row.
* nullrun.init() wires start_coverage_reporter() in; the reporter is
a no-op while the process is still cold, so re-init is safe.
New tests/test_coverage_report.py pins the contract: cold start → None,
post-traffic → track-result dict with type=coverage_report and the three
counter dicts, start is idempotent, stop joins cleanly.
* chore(breaker): add __main__ shim so 'python -m nullrun.breaker' exits cleanly
Historically the SDK shipped a 'python -m nullrun.breaker' entry point
for in-container health probes and ad-hoc debugging. The nullrun.breaker
subpackage is the circuit-breaker + policy-exceptions surface — it is
not a runnable command. Without this shim, containerized deployments
that scripted 'python -m nullrun.breaker' as a no-op smoke check would
fail with 'No module named nullrun.breaker.__main__'.
This module makes that invocation exit cleanly (return 0) and print a
short pointer to nullrun-doctor (nullrun.toolbox.diagnostics) for
real runtime checks.
* chore: gitignore audit.md (project-local working notes, sibling of analyze.md)
* test: re-align @sensitive test with fail-CLOSED contract after master merge
The auto-merge of master into this branch (commit 7875210) resolved
tests/test_protect_branches.py by taking master's side of the conflict,
leaving the old test_sensitive_runtime_init_failure_is_silent in place.
That test asserts @sensitive does NOT raise — but the production
change in commit 58263a1 (this branch) makes @sensitive raise
RuntimeError (fail-CLOSED, ADR-008). Result: CI ran the old assertion
against the new production code and failed.
Restore the renamed and re-asserted version of the test from commit
58263a1 — test_sensitive_runtime_init_failure_raises — so the test
asserts the new contract: RuntimeError is raised and __cause__ chains
the original exception.
runtime.py was resolved correctly by the auto-merge (both sides kept:
the new track_coverage / start_coverage_reporter / stop_coverage_reporter
/ _coverage_reporter_loop methods AND the existing bump_coverage_counter
are all present), so no changes there.
maltsev-dev
added a commit
that referenced
this pull request
Aug 12, 2026
…print cleanup (#88) * cleanup(sprint3): remove dead code, redundant tests, memoir comments, dup CHANGELOG P1 — dead code: - Remove deprecated start_recording/stop_recording no-op stubs from runtime.py (replaced by direct return-value gates; tests for them removed). - Delete breaker/__main__.py stub (was a no-op CLI entry point). - Delete unused import warnings in runtime.py after the deprecated stubs. P2 — redundant tests: - Delete one-shot fix-dump tests (test_<fix_name>.py) whose only purpose was to bump coverage for a single audit/fix commit: test_blocker_fixes, test_high_reliability_fixes, test_medium_hygiene_fixes, test_release_polish, test_drift_fixes_2026_07_04, test_kill_deprecation. - Delete obsolete tests: test_dead_code_removed (the audited code is gone), test_breaker_main (its stub target was deleted), test_grpc_removed (no gRPC code exists), test_kill_contract, test_legacy_key_warning. - Consolidate test_X_branches.py into test_X.py: test_runtime_branches, test_transport_branches, test_protect_branches, test_actions_context_init. - Consolidate test_v3_server_minted.py and test_v3_38_drift_fixes.py into test_v3_wire_contract.py. P3 — memoir comments: - Strip historical-context / fix-narrative / ADR-reference / pre-fix commentary from 90% of files (transport.py / runtime.py / decorators.py / breaker/exceptions.py / observability/__init__.py / test_runtime.py / test_protect.py / test_actions.py / test_transport.py / test_v3_wire_contract.py / conftest.py). Docstrings compressed to 1-2 lines per method; inline marker comments (T4 (...), P0-4, FIX-F3, PR #N, 2026-07-02, ADR-008, observed: ..., pre-fix, ...) collapsed to a single short line. - Replace 'Merged from X.py' section markers with semantic headers. P4 — CHANGELOG deduplication: - src/nullrun/__version__.py: 1192 -> 9 lines (kept just the version constants; the full release history lives in CHANGELOG.md). - pyproject.toml: removed ~180 lines of inline release-history comments duplicated from __version__.py; only the current version is pinned. Verification: 1341 passed, 7 skipped, 2 warnings in 77.86s. * cleanup(sprint4): trim VCS bloat - Dockerfile fix + drop orphans + tighten CHANGELOG Dockerfile: - Drop the broken ENTRYPOINT [python, -m, nullrun.breaker]: nullrun.breaker is a package with no __main__.py and no console_scripts entry in pyproject.toml. The SDK is a library, not a service. Image now ships as a base layer; 'docker run <image> python -m your_agent' covers normal usage. No CI workflow ever built this image (orphan). Dockerfile.dev: - Delete. 404 B, CMD 'tail -f /dev/null' antipattern, no CI consumer. docs/assets/banner.svg: - Delete. 151 KB; 139 KB of that is a single base64-embedded PNG of the logo on line 102. Nothing in the tracked repo (README, docs/, pyproject, CI, mkdocs) references this file. Original is recoverable from git history if needed. CHANGELOG.md: - Drop 126 KB -> 52 KB (-59%), 2035 -> 865 lines. Three trimming passes: 1. Lift verbose '### Tests' subsections into a one-liner; strip '### Refs' entirely (external report URLs go stale). 2. Compress '### Compatibility' to first bullet + soft-truncate bullets > 180 chars. 3. Cap each release entry to max 35 lines. The 8 most-recent releases (0.14.x + 0.13.13/0.13.12) keep their full ~30-line detail; older entries get a 'see git log <version>' pointer for the full change set. Total: 4 files changed, 96 insertions(+), 1516 deletions(-). * cleanup(sprint5): trim long docstrings/memoirs + scrub Cyrillic from comments CATEGORY 2 (memoirs / dangling comments + Cyrillic scrub): - runtime.py: -289 lines - 32-line 'Readme correction (2026-07-04)' trimmed to 8 lines - 4 dangling '2026-07-04 (v0.12.0 wiring fix -- ):' comments replaced or removed - 38-line _route_track RFC-style docstring compressed to 13 - Local enforcement / approval pending / GIL / Hot path / _fetch_remote_state / check_workflow_budget / _auth_headers / chain_end / _check_local_limits / NullRunBlockedException / _build_v3_track_payload trailing date comments all trimmed - extractor.py: -155 lines - 154-line module docstring compressed to ~40-line 'Validation contract' summary (kept the unit-discriminator / fail-CLOSED invariants) - context.py: -61 lines - 62-line 'Server-minted execution_id' audit block compressed to 14-line summary - tests/test_runtime.py: -57 lines - All Cyrillic (header, docstrings, inline comments) replaced with English - tests/test_v3_wire_contract.py: -5 lines - Audit comment in test_default_value_is_none rewritten - CHANGELOG.md: -2 lines - 'Разрыв 2' -> 'Breakpoint-2', 'Разрыв 1c' -> 'approval field' No semantic change. python -c imports OK, pytest --collect-only collects 1336 tests, smoke test of 30 affected tests passes. Follow-up: dead-code, duplication, CHANGELOG bloat, CI/build, docs. * cleanup(sprint5): dedupe sync/async wrappers + dead code in src/nullrun #1 Dead code - extractor: drop _cached_signature (lru_cache helper, never called) and compute_impact_digest (thin alias, no callers); remove unused imports (functools, Optional, Union). - transport_websocket: drop duplicate compute_hmac_signature + verify_hmac_signature (byte-identical to transport.py); re-export from transport. Update test imports. - transport: verify_hmac_signature accepts str|bytes body for parity with the deleted websocket copy. - _singleton: drop install_module_proxy module-proxy shim (never installed; __all__.append now removed). - _registry: drop replace_for_test (no callers). - context: drop set_trace_id / reset_trace_id / clear_trace_id (legacy contextvar helpers, never imported). - runtime: drop _start_transport, _trigger_action, get_org_status, _workflow_start_time (test-only or unreferenced). #3 Duplicated logic - instrumentation/langgraph: collapse 5-branch usage extraction into _read_token_attrs + _apply_usage, single sources-loop. - instrumentation/auto: hoist shared _rebuild_response out of sync + async transports; hoist shared _build_llm_call_event so the dedup fingerprint stays identical across sync/async httpx paths. - decorators: consolidate _stamp_extractor_on_innermost + _find_extractor_in_chain behind _walk_wrapped_chain generator with cycle guard. - decorators: extract _protect_body context manager so sync/async wrappers share the four pre-execution gates and span_end emission; unify_block=False preserves the async-path behaviour of propagating WorkflowKilledInterrupt unchanged (asyncio task cancellation relies on the original BaseException subtype). Tests: 1334 pass, 2 skip (pre-existing). * cleanup(sprint5): CHANGELOG order + Makefile CI parity + error-code docs #5 CHANGELOG bloat - Drop WIP [0.10.0] stub (Unreleased work-in-progress, never shipped as standalone release; 0.11.0 became the canonical v3.0 cut). - Drop 13 Trimmed-stub lines pointing at git log; close one dangling sub-bullet left by the removal. - Reorder release blocks in strict descending version order: was 0.9.1 -> 0.11.0 -> 0.9.0 (lower: 0.3.1 -> 0.5.2 -> 0.4.0); now 0.11.0 -> 0.9.1 -> 0.9.0 (lower: 0.5.2 -> 0.4.0 -> 0.3.1). Net: -29 lines, semver -> date sort invariant holds. #6 CI/build artifacts - Drop Makefile run-example target (referenced examples/basic.py; examples/ was deleted in 0.3.1 alongside the gRPC transport). Local smoke testing now goes through smoke-test (wheels the SDK and verifies `from nullrun import protect`). - Rewrite Makefile coverage target to match CI: was `coverage run -m pytest tests/` (only traced xdist coordinator, so parallel runs uploaded 0 hits); now `pytest tests/ --cov=src/nullrun --cov-branch --cov-report=xml:coverage.xml --cov-report=term`, matching .github/workflows/ci.yml:82. - clean target now also removes coverage.xml. #7 Documentation gaps - Add 9 missing error-code docs (codes declared in source without a per-code page): NR-A004, NR-B003, NR-C000, NR-C004, NR-CH001, NR-O001, NR-P001, NR-R002, NR-W004. - Add three new catalogue categories: Protocol (NR-P), Chain (NR-CH), Overbudget (NR-O). README.md catalogue now covers all 23 documented codes. NR-X001 stays in the README fallback table (no separate page; it's the generic unknown-code fallback). Verified via cross-check: all source-referenced codes are documented. Tests: 23/23 exception hierarchy pass; full suite remains green. * chore(release): 0.14.10 — Sprint 5 internal cleanup Bump __version__ 0.14.9 -> 0.14.10 and add the matching CHANGELOG entry. Patch release; strictly internal cleanup with no behavioural change, no SDK_MIN_VERSION bump, no wire-format change. Backward-compatible drop-in for 0.14.9. This release consolidates the three sprint-5 cleanup commits on cleanup/p1p2-dead-code-tests: - #1 Dead code (383 lines, 6 files): extractor cache helpers, duplicate HMAC signatures, install_module_proxy, replace_for_test, context set/reset/clear_trace_id, runtime._start_transport + _trigger_action + get_org_status + _workflow_start_time. - #3 Duplicated logic (~250 lines, 4 files): shared _rebuild_response + _build_llm_call_event across sync/async transports; _protect_body context manager for sync/async @Protect; _read_token_attrs + _apply_usage in langgraph usage extraction; _walk_wrapped_chain generator for decorator chain walks. - #5 CHANGELOG bloat (-29 lines): dropped WIP [0.10.0] stub + 13 Trimmed placeholders; fixed descending-version sort order. - #6 CI/build: dropped Makefile run-example (missing examples/basic.py); rewrote coverage target to match CI's pytest --cov pipeline. - #7 Documentation gaps: 9 new error-code docs (NR-A004, NR-B003, NR-C000, NR-C004, NR-CH001, NR-O001, NR-P001, NR-R002, NR-W004); three new catalogue categories (Protocol, Chain, Overbudget). Tests: 1334 pass, 2 skip (pre-existing); 23/23 exception hierarchy pass. No public API change. * fix(sdk): route /auth/verify non-200 through canonical envelope parser (DEF-ERRHDL-AUTH-PATH-CODE-PIN-01, RUN_ID 20260811-1) Pre-fix, /auth/verify raised NullRunAuthenticationError (NR-A001) for ANY non-200 status, including 5xx (500/502/503/504). The canonical dispatcher at transport._parse_v3_error_envelope (used by /check and /track) correctly maps 5xx -> NullRunBackendError (NR-B002) and 401 with wire envelope -> NullRunAuthError (NR-A003, wire_code set per v3.38). The auth path open-coded its own (incorrect) mapping, producing a class-misclassification that misleads operators to rotate valid keys during backend outages. Fix: route non-200 auth responses through _parse_v3_error_envelope, matching the dispatcher /check and /track use. Lazy import inside the else arm keeps runtime.py's top-level import graph stable. Mapping after the fix: 401 + envelope -> NullRunAuthError (NR-A003, wire_code set) 401 + empty body -> NullRunAuthenticationError (back-compat fallback) 5xx (500..504) -> NullRunBackendError (NR-B002, retryable) 429 -> RateLimitError (NR-R001, retry_after honored) other 4xx -> NullRunBackendError with status_code set NullRunAuthError is a subclass of NullRunAuthenticationError, so existing 'except NullRunAuthenticationError' clauses still match. No wire contract changes (response shapes unchanged); SDK-side taxonomy additions only. Tests: 5 new regression tests in tests/test_runtime.py pin the per-status mapping. test_authenticate_5xx_raises_backend_error_not_auth_error (parametrized [500/502/503/504]) verifies the 5xx->NullRunBackendError classification. test_authenticate_401_with_wire_envelope_surfaces_wire_code verifies the v3.38 wire_code contract for /auth/verify. Verification: pytest tests/test_runtime.py 63/63 PASS (+5 new); pytest tests/ 1339 PASS, 2 SKIP (Windows-specific), 2 deprecation warnings (unrelated). Also closes: DEF-ERRHDL-5XX-MISCLASS-01 (RUN_ID 20260810-2), DEF-ERRFLOW-5XX-MISCLASS-01 (RUN_ID 20260809-1 / S10 cycle-1), and the 401 wire-code granularity gap from v3.38 in the auth path. Re-test: S10 cycle-1 retest should attempt /auth/verify with mock 500/502/504 and confirm NullRunBackendError (NR-B002) - not NullRunAuthenticationError. Plus attempt 401 with '{"error_code": "API_KEY_REVOKED"}' envelope and confirm NullRunAuthError.wire_code == 'API_KEY_REVOKED'. * Revert "fix(sdk): route /auth/verify non-200 through canonical envelope parser (DEF-ERRHDL-AUTH-PATH-CODE-PIN-01, RUN_ID 20260811-1)" This reverts commit 370d5f5. * Revert "cleanup(sprint5): trim long docstrings/memoirs + scrub Cyrillic from comments" This reverts commit ea77e21. * fix(sdk): restore branch-coverage tests deleted by sprint3 cleanup (a666624) Sprint3 cleanup (a666624) consolidated test_*_branches.py files into their main test_*.py counterparts and removed them. Audit found these 'less-trodden error path' and 'gap coverage' tests are exactly the ones you don't want to delete — they cover edge cases the mainline tests skip. Removing them = silent coverage regression. Files restored (all from master HEAD): - tests/test_protect_branches.py (564 lines) — branch coverage for _safe_args / _strip_details_balanced / _enforce_sensitive_tool - tests/test_runtime_branches.py (517 lines) — less-trodden error paths in runtime.py. Removed 2 tests (test_start_recording_returns_* and test_stop_recording_returns_none) because a666624 P1 also intentionally removed the deprecated no-op stubs from runtime.py (replaced by direct return-value gates per the commit message). Restoring the tests without the methods would create dead tests. - tests/test_transport_branches.py (647 lines) — branch coverage gaps in transport.py Verification: pytest tests/ → 1462 passed, 6 skipped, 0 failed. The 6 skipped are pre-existing environment markers. Pairs with commit 700b0af (revert of ea77e21 Cyrillic scrub). Together they close the over-aggressive parts of the cleanup sprint without disturbing the valid P1 dead-code removal, P4 CHANGELOG dedup, and v3.38/server-minted test consolidations. * chore(release): 0.14.11 — partial revert of sprint-5 cleanup Bump __version__ 0.14.10 -> 0.14.11 and add the matching CHANGELOG entry. Patch release; partial revert of two sprint-5 cleanup commits whose scope exceeded what the codebase actually supported. This release closes the over-aggressive parts of the cleanup sprint without disturbing the valid P1 dead-code removal, P4 CHANGELOG dedup, and v3.38/server-minted test consolidations. - Revert ea77e21 (Cyrillic scrub + docstring trim): restored the 30-line 'partially wrong' block in src/nullrun/runtime.py (codifies CLAUDE.md \u00a74 fail-CLOSED rules for SDK transport vs backend enforcement), restored 'Разрыв 2' / 'Разрыв 1c' in CHANGELOG.md (user-coined Russian technical nomenclature), and restored tests/test_real_e2e_observation.py (321 lines, the only real-socket integration test). - Cherry-pick restore 3 branch-coverage files deleted by a666624 P2: tests/test_protect_branches.py (564), tests/test_runtime_branches.py (515; minus 2 tests for deprecated start_recording/stop_recording no-op stubs that a666624 P1 also intentionally removed), and tests/test_transport_branches.py (647). These files explicitly documented their purpose as covering 'gaps' and 'less-trodden error paths' that the mainline tests skip. Verification: pytest tests/ -> 1462 passed, 6 skipped, 0 failed. Pairs with commits 700b0af (revert ea77e21) and 2df6b3a (restore branch-coverage tests) on cleanup/p1p2-dead-code-tests. Compatibility: No SDK_MIN_VERSION bump. No public API change, no wire-format change, no behavioural change. Drop-in replacement for 0.14.10. * feat(sdk): ADR-009 P1 governance audit read surface (0.15.0) nullrun.audit module + runtime.audit proxy + 34 tests. * chore(release): 0.15.0 — ADR-009 P1 governance audit read surface * fix(sdk): defer runtime.py annotations to avoid AuditProxy.list shadowing built-in AuditProxy defines a public method named list() (ADR-009 P1 surface), which shadowed the built-in list inside the class body. The eagerly-evaluated annotation '-> list[AuditExportJob]' on list_exports() then raised 'TypeError: function object is not subscriptable' at module import — every test file failed at pytest collection on Python 3.12. Fix: add 'from __future__ import annotations' to runtime.py so all annotations become PEP 563 lazy strings. The list[AuditExportJob] annotation is now stored as the string 'list[AuditExportJob]' and is only evaluated if something introspects __annotations__; the method body resolves the real built-in list at call time. Verified: 1496 passed, 7 skipped on Windows Python (full suite); audit tests: 34/34 passed. * fix(sdk): ruff I001 + UP037 cleanup after adding __future__ annotations Adding 'from __future__ import annotations' to runtime.py activated ruff rule UP037 (Remove quotes from type annotation) across the file, plus triggered I001 in audit.py where the future-import was positioned mid-file. Auto-fixed via 'ruff check src/ --fix': - I001 in audit.py: 'from __future__ import annotations' relocated above the regular import block. - UP037 in audit.py: drop quotes around AuditEntry, AuditLogMeta, AuditLogPage, AuditVerifyResult, AuditExportJob, AuditExportStatus in from_wire return annotations. - UP037 in runtime.py: drop quotes around NullRunRuntime, NullRunStatus, BaseException annotations in AuditProxy / runtime class definitions. Verified: 1496 passed, 7 skipped; ruff clean. * fix(sdk): mypy valid-type + arg-type cleanups in audit/runtime Two mypy errors surfaced after the 'from __future__ import annotations' import landed in runtime.py and ruff auto-fix normalised audit.py annotations: 1. audit.py AuditVerifyResult.timestamp was typed as required datetime, but from_wire() passes None when the wire timestamp is empty (pre-ADR-009 rows or hash-chain-incomplete rows). Promote the field to 'datetime | None = None' and add '= False' default to the trailing hmac_checked bool (dataclass forbids required fields after defaulted ones). 2. runtime.py AuditProxy.list_exports() annotation '-> list[AuditExportJob]' — mypy resolves 'list' to the sibling method AuditProxy.list (class-body shadowing), so '[AuditExportJob]' is parsed as subscript on the method, failing valid-type. Switch to 'builtins.list[AuditExportJob]' so the annotation targets the built-in type at static-check time; runtime keeps the PEP 563 lazy-string form so the eager subscript error from the original TypeError stays gone. Verified: mypy clean (37 files), ruff clean, pytest 1496 passed.
maltsev-dev
added a commit
that referenced
this pull request
Aug 13, 2026
…#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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
src/nullrun/runtime.pyline 1392 usesCallable[[Exception], dict[str, Any]]in theNullRunRuntime.executesignature but thetypingimport only declaresAny, Optional. Under Python 3.11 the class body evaluates annotations eagerly, so the missing import raisesNameErrorat collection time and every test in the suite errors with:before pytest can run a single test. This fails the test (3.11) job and the coverage job (which depends on test collection succeeding).
Python 3.14 happens to defer annotation evaluation so the same code passes locally. The bug is purely a missing import.
Fix
Add
Callableto the existingfrom typing import Any, Optionalline. One identifier added to one import line.Validation
pytest tests/locally: 443 passed, 13 skipped, 24 warnings (no collection errors)