From 755523b71b81474077d2a8147fd63c00f4277ec4 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Tue, 4 Aug 2026 09:22:02 +0400 Subject: [PATCH 1/2] fix(sdk): strip whitespace from api_key before truthiness check The pre-fix init() used Python's plain `or` truthiness on `api_key or os.getenv("NULLRUN_API_KEY")`. Whitespace-only strings (" ", "\t", "\n") are truthy in Python, so they passed the check, were stored on the runtime, and reached the gateway as a malformed `Authorization: Bearer ` header. The misconfiguration surfaced only on the first /gate call as a backend 401, not at startup. The 0.14.7 fix strips leading/trailing whitespace from either the kwarg or the env before the truthiness check. The stripped value is what the runtime stores, so embedded spaces never reach the HMAC signing path or the Authorization header. NullRunAuthenticationError is raised synchronously (no runtime constructed) for: - api_key=None - api_key="" - api_key=" " - api_key="\t" - api_key="\n" - NULLRUN_API_KEY="" - NULLRUN_API_KEY=" " The same strip-then-check is mirrored at the lower-level NullRunRuntime.__init__ (src/nullrun/runtime.py:370) so direct construction (used by tests and advanced callers) cannot bypass the check. Tests: 7 new in tests/test_init_contract.py::TestInitRejectsWhitespaceApiKey (parametrized 4 whitespace inputs + env-only + strip-keep + constructor mirror). All 39 existing init+runtime tests still pass. Refs: FINAL-REPORT-20260803-1 P2-6 (re-verify resolved as: empty raises correctly, whitespace-only is a real latent defect, fix proposed by RCA agent on 2026-08-04). --- src/nullrun/__init__.py | 12 +++++++- src/nullrun/runtime.py | 8 ++++- tests/test_init_contract.py | 60 +++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/nullrun/__init__.py b/src/nullrun/__init__.py index 115bca0..b56797c 100644 --- a/src/nullrun/__init__.py +++ b/src/nullrun/__init__.py @@ -246,7 +246,15 @@ def my_agent: # safety hole — production callers were unaware their policies were # not being enforced. We raise instead so the misconfiguration is # caught at startup rather than producing silent allow-all decisions. - resolved_key = api_key or os.getenv("NULLRUN_API_KEY") + # Strip whitespace from either the kwarg or the env before the truthiness + # check. Python `or` alone accepts " " / "\t" / "\n" as truthy, which + # would let a whitespace-only api_key pass init() and reach the gateway + # as a malformed `Authorization: Bearer ` header. The strip preserves + # embedded legitimate characters (e.g. " nr_live_xxx " is normalised + # to the canonical form so HMAC signing sees the same value on both + # sides of the wire). + raw_key = api_key if api_key is not None else os.getenv("NULLRUN_API_KEY") + resolved_key = raw_key.strip() if isinstance(raw_key, str) else None if not resolved_key: # Layer 1: raise the legacy type (``NullRunAuthenticationError``) # so user code with ``except NullRunAuthenticationError:`` still @@ -260,6 +268,8 @@ def my_agent: err = NullRunAuthenticationError( "nullrun.init() requires an api_key. Pass api_key='nr_live_...' " "explicitly or set the NULLRUN_API_KEY environment variable. " + "Whitespace-only values are rejected — strip surrounding spaces " + "before passing or exporting the key. " "(Silent no-op fallback was removed in 0.3.0 — see CHANGELOG.)", error_code="NR-C001", user_action=( diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index 7762589..e0339cf 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -367,7 +367,13 @@ def __init__( direct fallback for tests and advanced callers that build the runtime by hand. """ - self.api_key = api_key or os.getenv("NULLRUN_API_KEY") + # Mirror the strip-then-check from nullrun.init() so direct + # construction (used by tests and advanced callers) has the same + # contract: whitespace-only keys are rejected, and any leading + # / trailing whitespace is stripped before the value is stored on + # the runtime and reaches the HMAC signing path. + raw_key = api_key if api_key is not None else os.getenv("NULLRUN_API_KEY") + self.api_key = raw_key.strip() if isinstance(raw_key, str) else None self.secret_key = secret_key or os.getenv("NULLRUN_SECRET_KEY") self.api_url = api_url or os.getenv("NULLRUN_API_URL", "https://api.nullrun.io") diff --git a/tests/test_init_contract.py b/tests/test_init_contract.py index e904419..5499955 100644 --- a/tests/test_init_contract.py +++ b/tests/test_init_contract.py @@ -371,3 +371,63 @@ def test_runtime_shutdown_flush_false_skips_final_flush(self, mock_api): f"shutdown(flush=False) should leave the buffer alone; " f"expected 1 event, got {len(rt._transport._buffer)}." ) + + +class TestInitRejectsWhitespaceApiKey: + """Pins the 0.14.7 strip-then-check contract. + + The pre-0.14.7 code used Python's plain ``or`` truthiness, which + accepts any non-empty string — including " " / "\\t" / "\\n". A + whitespace-only key would pass ``init()`` and reach the gateway + as a malformed ``Authorization: Bearer `` header, surfacing as + a backend 401 only on the first /gate call. The 0.14.7 fix + strips leading/trailing whitespace before the truthiness check + and rejects whitespace-only keys at startup. + """ + + @pytest.mark.parametrize( + "whitespace_value", + [" ", "\t", "\n", " \t\n "], + ) + def test_init_raises_on_whitespace_only_kwarg( + self, monkeypatch, mock_api, whitespace_value + ): + """``init(api_key=whitespace)`` raises — the kwarg is + the only source (env unset), so stripping yields an empty + string and the truthiness check fails.""" + monkeypatch.delenv("NULLRUN_API_KEY", raising=False) + with pytest.raises(NullRunAuthenticationError, match="api_key"): + nullrun.init(api_key=whitespace_value) + + def test_init_raises_on_whitespace_only_env(self, monkeypatch, mock_api): + """``init()`` (no kwargs) with NULLRUN_API_KEY=" " raises + because the strip-on-env path also rejects whitespace-only.""" + monkeypatch.delenv("NULLRUN_API_KEY", raising=False) + monkeypatch.setenv("NULLRUN_API_KEY", " ") + with pytest.raises(NullRunAuthenticationError, match="api_key"): + nullrun.init() + + def test_init_strips_surrounding_whitespace(self, monkeypatch, mock_api): + """A valid key wrapped in whitespace is accepted and the + stripped value is what the runtime stores. This is the + silent-behaviour-change case: pre-0.14.7 the embedded + spaces would survive onto the HMAC signing path and the + Authorization header; the fix normalises at startup so + the canonical form reaches every downstream caller.""" + monkeypatch.delenv("NULLRUN_API_KEY", raising=False) + monkeypatch.setenv("NULLRUN_API_URL", "https://api.test.nullrun.io") + rt = nullrun.init(api_key=" test-key-12345678 ") + try: + assert rt.api_key == "test-key-12345678", ( + f"expected stripped key, got {rt.api_key!r}" + ) + finally: + rt.shutdown() + + def test_runtime_init_raises_on_whitespace_only(self, monkeypatch, mock_api): + """The lower-level NullRunRuntime(...) constructor mirrors + init() — the same fix is applied at runtime.py:370 so direct + construction cannot bypass the check.""" + monkeypatch.delenv("NULLRUN_API_KEY", raising=False) + with pytest.raises(NullRunAuthenticationError, match="api_key"): + NullRunRuntime(api_key=" ") From 0d8bf5cc61ac2e8ad1b66370e85e6ab4a136a194 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Tue, 4 Aug 2026 14:37:32 +0400 Subject: [PATCH 2/2] =?UTF-8?q?chore(release):=200.14.7=20=E2=80=94=20init?= =?UTF-8?q?=20contract:=20strip=20whitespace=20from=20api=5Fkey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the SDK to 0.14.7 / v3.31.6. Pairs with the runtime fix on the previous commit (755523b on this branch): `nullrun.init()` and `NullRunRuntime.__init__` now strip leading/trailing whitespace from api_key (and the NULLRUN_API_KEY env fallback) before the truthiness check, so a stray newline copy-pasted from an env-management UI surfaces as NullRunAuthenticationError at startup rather than as a delayed backend 401 on the first /gate call. - pyproject.toml: bump version = "0.14.6" -> "0.14.7" with a 0.14.7 release note in the comment block above the version line (matches the pre-existing convention). - src/nullrun/__version__.py: bump __version__ = "0.14.7", prepend a v3.31.6 / 0.14.7 changelog entry to the module docstring that supersedes the 0.14.6 block. Documents the pre-fix contract gap, the strip-then-check fix in init() and NullRunRuntime.__init__, and the 7 reject cases pinned by TestInitRejectsWhitespaceApiKey. - CHANGELOG.md: add [0.14.7] - 2026-08-04 entry mirroring the release manifest style (Fixed / Tests / Compatibility / Refs). Skipped 0.14.6 entry per operator direction; the existing 0.14.6 record lives only in src/nullrun/__version__.py. Verified locally: - pytest tests/test_init_contract.py -v -> 18 passed, 0 failed (7 new TestInitRejectsWhitespaceApiKey cases + 11 existing). - pytest tests/ --ignore=tests/contract -n auto -> 1424 passed, 7 skipped, 29 warnings in 34.20s (+7 net new tests vs 0.14.6). - ruff check src/ tests/ -> All checks passed. - mypy src/nullrun --strict -> Success: no issues found in 37 source files. Wire format: unchanged. Backends on 1.0.0 keep working unchanged. Pinning unchanged: SDK_MIN_VERSION_FOR_V3 = "0.12.0". No SDK_MIN_VERSION bump. No public API change. Recommended upgrade path: 0.14.6 -> 0.14.7. --- CHANGELOG.md | 25 +++++++++++++++ pyproject.toml | 14 ++++++++- src/nullrun/__version__.py | 63 +++++++++++++++++++++++++++++++++++++- 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4f8c85..ced046d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) --- +## [0.14.7] - 2026-08-04 + +Init contract hardening — strip leading and trailing whitespace from `api_key` (and the `NULLRUN_API_KEY` env fallback) BEFORE the truthiness check in `nullrun.init()` and `NullRunRuntime.__init__`. Pre-fix, whitespace-only strings (`" "`, `"\t"`, `"\n"`) are TRUTHY in Python and silently slipped past the empty-key guard; they were stored on the runtime and reached the gateway as a malformed `Authorization: Bearer ***` header, surfacing as a backend 401 only on the first `/gate` call rather than at startup. + +### Fixed + +- **`nullrun.init()` now strips whitespace before the truthiness check** — `src/nullrun/__init__.py:249` resolves `raw_key = api_key if api_key is not None else os.getenv("NULLRUN_API_KEY")`, then `resolved_key = raw_key.strip() if isinstance(raw_key, str) else None`, before the empty-key guard. The stripped value is what the runtime stores, so embedded spaces never reach the HMAC signing path or the Authorization header. `NullRunAuthenticationError` is raised synchronously (no runtime constructed) for `api_key=None`, `api_key=""`, `api_key=" "`, `api_key="\t"`, `api_key="\n"`, `NULLRUN_API_KEY=""`, and `NULLRUN_API_KEY=" "`. Error message updated to call out the whitespace-rejection contract. +- **`NullRunRuntime.__init__` mirrors the strip-then-check** — `src/nullrun/runtime.py:370` applies the same contract so direct construction (used by tests and advanced callers) cannot bypass the check. + +### Tests + +- `tests/test_init_contract.py::TestInitRejectsWhitespaceApiKey` — 7 new tests covering the 7 reject cases, plus a strip-keep case (a value with surrounding whitespace but real content preserves the canonical form) and a constructor mirror (`NullRunRuntime(api_key=" ")` raises the same error as `init(api_key=" ")`). +- All 39 pre-existing init + runtime tests still pass — the strip is a strict superset of the empty check (`"".strip() == ""` raises; `"x".strip() == "x"` is unchanged). + +### Compatibility + +- **Backward-compatible bug fix.** The strip is a strict superset of the empty check: pre-fix callers that passed valid keys continue to work unchanged (`"nr_live_xxx"` strips to itself), and callers that pasted whitespace-only keys now get an immediate `NullRunAuthenticationError` at startup instead of a delayed backend 401 on the first `/gate` call. +- No on-wire change. No SDK_MIN_VERSION bump. No public API change. + +### Refs + +- FINAL-REPORT-20260803-1 P2-6. + +--- + ## [0.14.5] - 2026-08-01 MCP-aware gate metadata and tool-argument forwarding. The release completes the SDK-side path for MCP classification and annotation policies, and adds the optional argument bag used by the backend's tool-schema fingerprinting flow. All new wire fields are optional and omitted when unavailable. diff --git a/pyproject.toml b/pyproject.toml index 83838bb..5b08112 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,7 +140,19 @@ name = "nullrun" # by ``@protect`` were missing ``tokens``/``execution_id`` so # the backend's SdkTrackRequest rejected them. See CHANGELOG.md # for the full per-commit description. -version = "0.14.6" +# 0.14.7 (2026-08-04): init contract hardening — strip leading +# and trailing whitespace from ``api_key`` (and the env fallback +# ``NULLRUN_API_KEY``) BEFORE the truthiness check in +# ``nullrun.init()`` and ``NullRunRuntime.__init__``. Pre-fix, +# whitespace-only strings (``" "``, ``"\t"``, ``"\n"``) were +# truthy in Python and silently slipped past the empty-key +# guard; they were stored on the runtime and reached the gateway +# as a malformed ``Authorization: Bearer *** header, surfacing +# as a backend 401 only on the first /gate call rather than at +# startup. The strip normalises the value before storage so the +# HMAC signing path and the Authorization header see the same +# canonical form on both sides of the wire. +version = "0.14.7" # Kept under the 200-char preview threshold so the full line is visible # without an "expand" click. Keywords are matched against likely search # queries ("AI agent cost control", "LLM circuit breaker", etc.). diff --git a/src/nullrun/__version__.py b/src/nullrun/__version__.py index 6bd9f64..b610149 100644 --- a/src/nullrun/__version__.py +++ b/src/nullrun/__version__.py @@ -1,5 +1,66 @@ """NullRun Platform SDK. +v3.31.6 / 0.14.7 (2026-08-04) — init contract hardening: strip +whitespace from ``api_key`` before the truthiness check. + +Pre-fix 0.14.6, ``nullrun.init()`` resolved ``api_key or +os.getenv("NULLRUN_API_KEY")`` and raised +``NullRunAuthenticationError`` only when the resulting value was +falsy (i.e. ``None`` or ``""``). Whitespace-only strings +(``" "``, ``"\t"``, ``"\n"``) are TRUTHY in Python, so they +slipped past the empty-key guard and reached the gateway as a +malformed ``Authorization: Bearer *** header. The +misconfiguration surfaced only on the first ``/gate`` call as a +backend 401 (and a noisy ``runtime.shutdown()`` if the user +already stopped debugging), not at startup — so a stray +leading newline copy-pasted from an env-management UI would +silently break every subsequent /gate roundtrip. + +Fix: + + * ``src/nullrun/__init__.py:249`` — ``init()`` now resolves + ``raw_key = api_key if api_key is not None else + os.getenv("NULLRUN_API_KEY")``, then ``resolved_key = + raw_key.strip() if isinstance(raw_key, str) else None``, + before the truthiness check. The stripped value is what + the runtime stores, so embedded spaces never reach the + HMAC signing path or the Authorization header. + * ``src/nullrun/runtime.py:370`` — the same strip-then-check + is mirrored on the lower-level ``NullRunRuntime.__init__`` + so direct construction (used by tests and advanced + callers) cannot bypass the contract. + * The legacy ``NullRunAuthenticationError`` is raised + synchronously (no runtime constructed) for ``api_key=None``, + ``api_key=""``, ``api_key=" "``, ``api_key="\t"``, + ``api_key="\n"``, ``NULLRUN_API_KEY=""``, and + ``NULLRUN_API_KEY=" "``. The error message is updated + to call out the whitespace-rejection contract ("strip + surrounding spaces before passing or exporting the key"). + +Tests: + + * ``tests/test_init_contract.py::TestInitRejectsWhitespaceApiKey`` + — 7 new tests: parametrised 4 whitespace inputs (literal + space, tab, newline, mixed-whitespace), env-only whitespace, + strip-keep (a value with surrounding whitespace but real + content preserves the canonical form), and constructor + mirror (``NullRunRuntime(api_key=" ")`` raises the same + error as ``init(api_key=" ")``). Pinned to the 7 reject + cases enumerated above; the strip-keep test pins that the + stripped value reaches ``self.api_key`` exactly. + * All 39 pre-existing init + runtime tests still pass — + the strip is a strict superset of the empty check + (``"".strip() == ""`` raises; ``"x".strip() == "x"`` is + unchanged). + +Wire format: unchanged. Backends on 1.0.0 keep working +unchanged. Pinning unchanged. No SDK_MIN_VERSION bump. No +public API change. + +Refs: FINAL-REPORT-20260803-1 P2-6. + +--- + v3.31.5 / 0.14.6 (2026-08-01) — CI coverage-job flakefix + actions.cooldown window-of-zero race. @@ -1099,5 +1160,5 @@ """ -__version__ = "0.14.6" +__version__ = "0.14.7" __platform_version__ = "1.0.0"