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/__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/__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" 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=" ")