Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.).
Expand Down
12 changes: 11 additions & 1 deletion src/nullrun/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=(
Expand Down
63 changes: 62 additions & 1 deletion src/nullrun/__version__.py
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -1099,5 +1160,5 @@

"""

__version__ = "0.14.6"
__version__ = "0.14.7"
__platform_version__ = "1.0.0"
8 changes: 7 additions & 1 deletion src/nullrun/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
60 changes: 60 additions & 0 deletions tests/test_init_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=" ")
Loading