Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
836b9eb
fix(gate): a /gate body with no verdict must never read as "allowed"
maltsev-dev Sep 30, 2026
cdf94c3
fix(gate): refuse NULLRUN_SENSITIVE_FAIL_OPEN against production
maltsev-dev Sep 30, 2026
700fea7
fix(gate): make the non-prod budget bypass visible in logs and metrics
maltsev-dev Sep 30, 2026
3c45138
test(gate): pin that a 503 is an allow and a 403 is a stop (ADR-063 1…
maltsev-dev Sep 30, 2026
838a905
test: correct the ADR-063 fixtures to the real wire nesting
maltsev-dev Sep 30, 2026
83960b4
fix(breaker): classify every gate refusal, and raise when it cannot be
maltsev-dev Sep 30, 2026
4cb009c
feat(breaker): add on_denied, a message path for `denied` and nothing…
maltsev-dev Sep 30, 2026
408668a
test(adr063): pin §4.7 option 2 — a 503 refusal blocks, an outage fai…
maltsev-dev Sep 30, 2026
d191a7f
fix(breaker): carry the category and the agent message onto /execute …
maltsev-dev Oct 1, 2026
939fd7d
docs(readme): state the three enforcement limitations, verified
maltsev-dev Oct 1, 2026
a33f863
fix(docs): name ADR-064's discriminator, not a marker that is not on …
maltsev-dev Oct 1, 2026
c459379
fix(gate): a /gate body must state WHO decided before it is a verdict
maltsev-dev Oct 1, 2026
c2e52d5
docs(readme): state the provenance boundary of server-authoritative e…
maltsev-dev Oct 1, 2026
a3ea0ce
style: clear the three lint errors this branch's own test files carry
maltsev-dev Oct 1, 2026
660954a
docs(readme): name the trust boundary precisely, and the second versi…
maltsev-dev Oct 1, 2026
6d0285b
test(protect): prove refusals and provenance at the decorator level
maltsev-dev Oct 1, 2026
467cb1a
docs(changelog): record the second half of DEF-MP-TS12-ENF-01
maltsev-dev Oct 1, 2026
642ebef
docs(changelog): migration note for the refusal-category SDK
maltsev-dev Oct 1, 2026
b38fcf5
fix(protect): make @tool/@protect ordering irrelevant
maltsev-dev Oct 1, 2026
f8bfbdc
release(sdk): 0.20.0 — migration note first, ordering documented
maltsev-dev Oct 1, 2026
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
196 changes: 196 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,199 @@
## [0.20.0] - 2026-10-01

The remaining half of `DEF-MP-TS12-ENF-01` (QA cycle RUN_ID
20260929T1338). 0.19.0 closed the paths it found by reading; these
close the ones that only showed up when the properties were asserted
end-to-end.

Minor, not patch: an unclassifiable refusal now **raises** where 0.19.0
let the call proceed. That is a working loop becoming a throwing one,
which is a behavioural break and not a bug fix — read
[Migration](#migration) before upgrading.

### Migration

Six things differ from 0.19.0. Only the first three can surprise you
at runtime, and the third is the one worth reading twice.

1. **A `/gate` test double must carry `decision_source`.** Real
backend answers always do — it is a non-`Option` `String` on
`GateResponse`. A hand-written fixture that omits it now raises
`NullRunMalformedGateResponseError`. Fix: add
`"decision_source": "gateway"` next to `"decision": "allow"`.
2. **`NULLRUN_SENSITIVE_FAIL_OPEN` against production now needs a
second variable.** `NULLRUN_ALLOW_SENSITIVE_FAIL_OPEN=1` must be
set too, otherwise the opt-out is refused, enforcement falls back
to its own fail-CLOSED default, and the attempt is logged at ERROR
with a metric. Non-production behaviour is unchanged.
3. **An unclassifiable refusal now raises where 0.19.0 let the call
proceed.** This is the intended fix, and it is the one that can
turn a working loop into a throwing one.
`NullRunUnclassifiedRefusalError` is importable from
`nullrun.breaker.categories` (it is *not* re-exported at the top
level) and carries `error_code="NR-P003"` and `retryable=True`.
It is a **`NullRunInfrastructureError` and a sibling of
`NullRunTransportError`** — deliberately *not* a subclass of it.
So an existing `except NullRunTransportError:` arm, which in
0.19.0 caught everything the gate could not classify and failed
open, **will not catch this one**. That is the point: it is what
stops a failed-open arm from swallowing a refusal. If you have
such an arm, you have three options and they are not equivalent:

```python
from nullrun.breaker.categories import NullRunUnclassifiedRefusalError
from nullrun.breaker.exceptions import NullRunError, NullRunTransportError

try:
...
except NullRunUnclassifiedRefusalError:
raise # recommended: the SDK was right
except NullRunTransportError:
... # still fails open, as before
```

Catching it alongside `NullRunTransportError` restores 0.19.0's
behaviour exactly — which means it restores the bypass. Do not
widen the arm to `NullRunError`: that also swallows real policy
refusals, which is a larger hole than the one this closes.
4. **`on_denied` is new.** Default `"raise"`, which is 0.19.0's
behaviour. Set `"message"` to get a `NullRunDeniedError` carrying
the server-authored `agent_message` for `category="denied"` only.
Any other value raises `ValueError` at construction rather than
at first refusal.
5. **`NULLRUN_SKIP_BUDGET_CHECK` outside production now logs and
increments a metric** when it skips the check. Enforcement
behaviour is unchanged.
6. **`/execute` refusal bodies carry `category` and
`agent_message`.** Additive on the wire. If you parse that body
yourself and reject unknown keys, relax that.

Carried over from 0.19.0 and still true on 0.20.0, because it is the
same class of break and the same shape of fix:

- **`NullRunRuntime.execute(..., mode="inline")` is gone** and has no
replacement — every call goes through `/execute`. Drop the argument;
`mode="auto"` (the default) already always contacts the gateway.
- **`nullrun.runtime.register_strict_mode_forced` /
`is_strict_mode_forced`** are gone, along with `@guarded`,
`nullrun.handle` (renamed `nullrun.guard` in 0.18.5),
`nullrun.status()`, and `nullrun.auto_instrument`.

### Security

- **A `/gate` body must state who decided before it counts as a
verdict.** `_require_gate_decision` rejects a body whose
`decision_source` is absent or unrecognised, raising
`NullRunMalformedGateResponseError`. The runtime's rule was
`decision_source != fallback → honour the wire decision`, which
read a *missing* `decision_source` as more trustworthy than
`fallback`. So `{"decision": "allow"}` — a captive portal's login
JSON, an intercepting proxy's stub, anything on-path — was enough to
authorise a call no policy engine evaluated. The field is a
non-`Option` `String` on the backend's `GateResponse`
(`gate/internal.rs:638`) and every producer sets `"gateway"`, so a
real answer always carries one; there is no legitimate body this
rejects. **This is a behaviour change on the `/gate` path**: a
hand-rolled `/gate` response in an existing test double must now
include `decision_source`.
- **`NULLRUN_SENSITIVE_FAIL_OPEN` is refused against production.** It
was read straight into the enforcement path, letting a sensitive
tool's body run with no policy evaluation at all. Its sibling
`NULLRUN_SKIP_BUDGET_CHECK` has been production-guarded since it
was caught doing the same; the asymmetry was an oversight, and this
half is the more dangerous one — that one skips a pre-flight, this
one skips the gate. The guard *refuses* the bypass rather than
raising, so enforcement falls back to its own fail-CLOSED default
and the attempt is logged at ERROR with a metric. Requires both
`NULLRUN_SENSITIVE_FAIL_OPEN=1` and `NULLRUN_ALLOW_SENSITIVE_FAIL_OPEN=1`;
the documented dev/test use is unchanged.
- **The non-prod budget bypass is visible.** When
`NULLRUN_SKIP_BUDGET_CHECK=1` skips the check, it now logs and
increments a metric instead of being indistinguishable from a normal
call.

### Fixed

- **`@protect` no longer loses a LangChain tool when it is the outer
decorator.** Applied to a `@tool` result, `@protect` wrapped the
object with `functools.wraps` and returned a plain function — so
`.invoke`, `.name`, `.args_schema` and `.description` were gone, an
agent loop could not bind the tool, and a tool the loop cannot see
raises no refusal. **Enforcement was silently absent in that
ordering.** The reported symptom was not even about NullRun:
`convert_to_openai_tool` on the result raises
`NameError: name 'Annotated' is not defined`, which reads as a
LangChain type bug rather than "your tool is not a tool any more".

```python
@nullrun.protect # now fine
@tool
def charge(amount: int) -> str: ...

@tool # was already fine
@nullrun.protect
def charge(amount: int) -> str: ...
```

`protect` wraps the tool's `func`/`coroutine` in place and returns the
same object, so both orderings gate identically. Duck-typed on
`.invoke` + `.name` rather than `isinstance(BaseTool)`, because
`langchain_core` is an optional dependency. `handle_tool_error=True`
remains safe: it catches only `ToolException`, so an enforcement
refusal still aborts rather than becoming model-visible text.

### Changed

- **Every gate refusal is classified, and an unclassifiable one
raises.** `NullRunUnclassifiedRefusalError` (an
`NullRunInfrastructureError`) is raised when a refusal carries no
`category` or one the SDK does not know. ADR-062 §2.2: absent or
unrecognised means *ask*, never *guess*. The exception hierarchy is
what makes this safe — `NullRunUnclassifiedRefusalError` and
`NullRunTransportError` are **siblings**, not parent and child, so
the fail-OPEN `except NullRunTransportError` arms cannot swallow it.
- **`on_denied` selects the shape of a `denied` refusal and nothing
else.** `on_denied="message"` turns a `category="denied"` refusal
into a `NullRunDeniedError` carrying the server-authored
`agent_message` — the only text the SDK guarantees is safe to relay
to a model. `budget` and `halt` keep their own exceptions even with
the flag set: an agent told "that tool is not allowed" has an
obvious next move, and that move walks into a budget wall or an
operator's stop. `infra` is not reached by this flag at all — a
503 is converted by the 5xx band and the STRICT fallback, which is
the correct outcome (an outage is not a denial).
- **`/execute` refusals carry `category` and `agent_message`.** The
MCP path (`MCPAdapter.call_tool`) is a different enforcement path
from `/gate`, so anything the category work added to the `/gate`
block site was absent there by default. Both properties are now
proven on the real adapter rather than inferred from
`runtime.execute`'s lack of a fail-OPEN `except`.

### Documentation

- README states the decorator ordering for LangChain tools and shows it
in both directions, with the reason (an unbound tool cannot refuse).
`on_denied="message"` is documented as the operator-facing "explain,
don't crash" mode, including that `handle_tool_error=True` does not
provide the same thing and would not be safe if it did.
- README "Known limitations" states the trust boundary precisely:
the SDK trusts the channel and says so. Certificate verification
cannot be switched off by configuration, plain `http` is refused,
and the residual risk is named — an on-path responder that can
present a certificate the OS already trusts for `api.nullrun.io`,
which is not an exotic thing to find on a managed network. NullRun's
responses are not signed, so there is no after-the-fact detection.
- README names the **second** version skew beside the first: an SDK
older than the `decision_source` check accepts a forged allow. Both
skews point the same way — on an older SDK a real refusal *and* a
fabricated permission are both read permissively, and neither is
visible in the SDK's output. Pin the version if either matters.
- The `transport.py` comment that claimed `is_fail_closed` marks a
fail-closed response on the wire is corrected. It does not:
`GateErrorCode::is_fail_closed` is an in-process Rust method that is
never serialised, and a client written against it could not have
found it. ADR-064 owns the actual discriminator (ADR-063 §4.7
option 2, which pointed at the same non-existent marker).

## [0.19.0] - 2026-09-30

Closes the SDK-side bypasses found auditing `DEF-MP-TS12-ENF-01`
Expand Down
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,52 @@ exits with code 1 instead of raising. `nullrun.shutdown()` is
auto-registered via `atexit` inside `init()`, so a clean WS close on
process exit happens without any explicit call.

### Decorator order with LangChain tools

Both orders gate. `@protect` recognises a LangChain tool, wraps the
tool's `func`/`coroutine` in place, and returns the same object, so this
is not a rule you have to remember:

```python
from langchain_core.tools import tool
from nullrun import protect

@tool # fine
@protect
def charge(amount: int) -> str: ...

@protect # also fine
@tool
def charge(amount: int) -> str: ...
```

Before 0.20.0 the second form silently produced a plain function. The
agent loop could not bind it, and a tool the loop cannot bind cannot
refuse — so the gate was not running. If you saw
`NameError: name 'Annotated' is not defined` from
`convert_to_openai_tool`, that was this.

### Handing an agent a reason instead of a crash

By default a refusal raises, which is right for most code: the caller
decides what happens next. `on_denied="message"` is the operator-facing
alternative for a **policy** denial — the agent gets the
server-authored explanation and the run continues:

```python
rt = nullrun.init(on_denied="message")
```

The text is authored by the backend, never assembled by the SDK, and it
applies to `category="denied"` only. Budget and halt refusals keep their
own exceptions under the same flag: an agent told "that tool is not
allowed" when the truth is "you are out of money" will go looking for
another way to spend.

LangChain's own `handle_tool_error=True` is **not** an equivalent. It
catches `ToolException` and stringifies it, and it does not know which
exceptions are refusals — use `on_denied="message"`.

---

## How NullRun compares
Expand Down Expand Up @@ -354,6 +400,34 @@ require tests for new public API, and run `ruff` + `mypy` in CI.

---

## Known limitations

Four things this SDK does not do. All four are enforcement-relevant, so they are stated here rather than left to be discovered during an incident. Each was verified against the code before being written down.

**1. A failed security check arrives as a 503, and only this version of the SDK stops on it.** When the backend cannot evaluate the security check itself, it refuses with a 503 carrying a `category` field. This SDK reads that field and fails **closed** — the call is refused. An SDK older than the category work has nothing to read: the 503 is turned into a synthetic `FALLBACK` decision, and `check_workflow_budget` fails **open** on a `FALLBACK` source — the call proceeds. So during a partial backend outage, enforcement differs by SDK version. A genuine outage (not a failed check) still fails open on every version, which is deliberate: a dead backend must not freeze your agent loop.

If you need this guarantee today, pin the SDK version. Do not assume a refused call implies the backend rejected the call.

**Older SDKs fail open on a forged allow, too — and this is the second version-skewed behaviour, so pin for it too.** The `decision_source` check described in limitation 4 arrived with the same release. An SDK older than it accepts `{"decision": "allow"}` from any responder, because it reads a missing `decision_source` as "not `fallback`" and therefore as authoritative. The two skews point the same way and are worth knowing together: on an older SDK, both a real backend refusal *and* a fabricated permission are read the permissive way, and neither is visible in the SDK's own output — the call simply proceeds. Pin the version if either matters to you.

**2. The gate circuit-breaker's trip mode is a server-side setting, and `LogOnly` does not block.** When the gate's circuit breaker trips, what happens is decided by `NULLRUN_GATE_CB_TRIP_ENFORCEMENT_MODE` on the server, not by anything in this SDK. In `LogOnly` the trip is recorded and alerted on, but tripped workflows still pass `/check`. The production boot check refuses to start unless the variable is explicitly set to `Enforce` or `LogOnly`, so a deploy cannot inherit the dev default (`detect_mode()` still falls back to `LogOnly` when unset outside production) — but an operator who chooses `LogOnly` is choosing non-enforcement, knowingly. If your compliance story depends on breaker trips being enforced, confirm that value with whoever operates the deployment.

**3. Pause and kill both reach the agent as a 403.** There is no separate status to branch on. `WORKFLOW_PAUSED` and `WORKFLOW_INACTIVE` are served as the same 403 from the same key; the only thing distinguishing them is the operator-facing text, which the backend deliberately keeps distinct because they mean opposite things about whether the run will resume. If you write support tooling, key off the error code, not the status. Separately, the SDK can observe pause/kill ahead of the next gate call via `check_control_plane` (WebSocket push, or a `/status` poll), which raises `WorkflowPausedException` / `NullRunWorkflowKilledError` locally.

**4. The SDK trusts the channel, and says so rather than pretending otherwise.** Everything above rests on one premise: that the JSON arriving on the SDK's HTTPS connection was written by NullRun. The SDK checks for it — a `/gate` body with no usable `decision_source` is rejected as `NullRunMalformedGateResponseError` rather than acted on — but that is a *field in the body*, not a signature, and a field is only as trustworthy as the channel carrying it.

What the channel does give you, verified in `transport.py`: certificate verification is **on and cannot be switched off by configuration** — `verify_cert` is `True` (`:572`) and there is no env var that sets it to `False`; the only override, `NULLRUN_TLS_CA_CERT` (`:568`), replaces the trust anchor with one you chose explicitly and is still verification. Plain `http://` is refused outright (`InsecureTransportError`, `:526`). So a passive network observer cannot pose as NullRun, and the ordinary captive portal — which returns a login page, or JSON without a `decision` — is rejected.

What remains is specific, and it is not "use https". It is: **an on-path responder that can present a certificate the operating system already trusts for `api.nullrun.io`.** That is what corporate TLS interception installs, and it is not exotic — it is a normal thing to find on a managed network. Such a responder can return a perfectly well-formed body *including* `decision_source: "gateway"`; it needs neither your API key nor an HMAC bypass, only to answer before the real backend. NullRun's responses are **not signed**, so there is no after-the-fact detection either — the audit trail would faithfully record an allow that the gate never gave.

So the honest boundary is: server-authoritative enforcement is authoritative against a *client* and against a *network observer*, not against an attacker who terminates TLS inside your trust store. If you operate on such a network, exclude `api.nullrun.io` from interception and check that it stays excluded — that is an operational control, not something the SDK can do for you.

### What does fail open

Fail-open here is narrow and deliberate, and the authoritative table lives in `runtime.py` (ADR-008). In short: a **transport** failure on the check path is open, so an unreachable backend cannot freeze your agent; a **wire response that names an enforcement failure** is closed, because the backend made a decision and the SDK will not overrule it; a **body that is not a verdict at all** — no `decision`, no `decision_source`, or an unrecognised value in either — is closed, because something answered and what it said was not a decision, and reading that as permission would let a non-NullRun responder authorise a call no policy engine evaluated; a **401** is closed, because no retry fixes a revoked key; and the `/execute` path is closed by default (`FallbackMode.STRICT`).

---

## Security

NullRun does **not** store or proxy your LLM provider keys — it sits beside your existing clients and observes the calls. The gate is **server-authoritative** for cost: even a malicious SDK cannot inflate spend by sending a fake `cost_cents` to `/track`.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "hatchling.build"
name = "nullrun"
# Full release history lives in CHANGELOG.md; only the current version
# is pinned here.
version = "0.19.0"
version = "0.20.0"
# Kept under the 200-char preview threshold so the full line is visible
# without an "expand" click. The headline is the canonical §1 statement
# from positioning.md — "runtime decision layer for tool-using AI agents"
Expand Down
Loading
Loading