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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,43 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

---

## [0.14.4] - 2026-07-27

ToolParameters Approval Rules wire contract (Tier 2 / Разрыв 2 follow-up). The backend already accepted `BusinessImpact::ToolCall(ToolCallParams)` on the `/execute` wire (backend commit `1e501cd6`); 0.14.4 lands the SDK-side path so users get ToolParameters rules by default on every bare `@sensitive` function, with no decorator change. Also fixes a silent regression in the auto-attach path that dropped an explicit `impact=tool_params({...})` map, and pins the cross-language `ToolCall` action digest against the Rust backend's golden hex. No on-wire breaking change for money callers; the only behavioural change is that bare `@sensitive` now ships `kind=tool_call` on the wire where it previously shipped nothing.

### Added

- **`BusinessImpact.tool_call(tool_name, params)`** factory — `business_impact.py:323` new factory builds a `BusinessImpact(kind='tool_call', tool_name=..., params=...)` envelope by analogy with the legacy `BusinessImpact` money constructor. Mirrors the backend `BusinessImpact::ToolCall(ToolCallParams)` variant (`backend/src/proxy/gate/business_impact.rs:62-307`). Used internally by `ToolParamsExtractor`; exposed publicly so users can hand-build impacts without importing the dataclass.
- **`ToolCallParams` dataclass** — `business_impact.py:143` mirrors the backend struct (`tool_name` ≤ 128 bytes, `param_name` ≤ 64, JSON-roundtrippable values only). `BusinessImpact.kind` now discriminates `Money` | `ToolCall`; existing money callers continue to discriminate on the same field via the `extractor_*` metadata.
- **`ToolParamsExtractor` + `tool_params(...)` factory** — `extractor.py:815` (class) and the matching factory. Three modes: explicit `{rule_param: arg_name}` map, `include_all=True` (default — every kwarg captured), or `include_all=False` with no map (empty). PII-masked sentinels (`"***"`) and JSON-unsafe values (`float`, custom objects) are filtered before the wire. The factory is the analogue of `MoneyImpactExtractor + money_outflow(...)`.
- **Bare `@sensitive` now ships ToolParameters on the wire** — `decorators.py:1096` (`_do_sensitive_register`) auto-attaches a default `ToolParamsExtractor(include_all=True)` on a bare `@sensitive` decorator. The stamp goes through `_stamp_extractor_on_innermost` so the bare function (the one `@protect` captures as `fn`) carries the attribute, not just the `@protect` wrapper. An explicit `@sensitive(impact=money_outflow(...))` or `@sensitive(impact=tool_params({...}))` wins — the auto-attach only fires when no extractor is present.
- **`@sensitive(impact=tool_params({...}))` decorator form** — `decorators.py:1065` new docstring + `decorators.py:711` dispatch branch. Operators writing ToolParameters Approval Rules on the backend can now declare the per-rule param map directly at the decorator site instead of relying on the auto-attach default.

### Fixed

- **Auto-attach chain walk preserves an explicit `impact=tool_params({...})` map** — `decorators.py:43` new helper `_find_extractor_in_chain` walks `__wrapped__` (bounded at 32 hops) so the auto-attach check sees the explicit extractor stamped on the bare function instead of falling through to the default. **Before this fix**, `@sensitive(impact=tool_params({"delete_force": "force"})) @protect def delete_user(force, user_id): ...` silently shipped `{force: <bool>, user_id: <int>}` (the auto-attach default) instead of the explicit `{delete_force: <bool>}` map. **After this fix**, the renamed key reaches the wire. Regression tests in `TestAutoAttachChainWalk` (4 cases): bare auto-attach, explicit tool_params map preserved, explicit money_outflow preserved, circular-`__wrapped__` defensive bounded walk.
- **`_enforce_sensitive_tool` dispatch handles both extractor types** — `decorators.py:677` (success path) and `decorators.py:711` (error path) now branch by extractor type. NR-B003 error hint text branches too — operators writing ToolParameters rules see "did you mean `impact=tool_params(...)`?" while money operators see the money remediation advice.
- **Bare `@sensitive` regression in the existing `tests/test_sensitive_extractor.py`** — the 5 existing tests still pass because they register the tool manually via `rt.add_sensitive_tool(name)`, which bypasses the decorator auto-attach path. Documented as a deliberate carve-out: only `@sensitive` (the decorator form) auto-attaches.

### Tests

- `tests/test_tool_params_extractor.py` — **23 new tests** across 5 classes (`TestToolParamsFactory`, `TestToolParamsExtraction`, `TestAutoAttachOnBareSensitive`, `TestToolCallParamsShape`, `TestAutoAttachChainWalk`). Covers factory shape (3), three extraction modes (4), PII sentinel + float filtering (3), action digest byte-identity with the backend's canonical JSON (1), the auto-attach wiring (2), dataclass validator (7), kind dispatch (1), and the chain-walk regression (4). Verified: 23/23 pass.
- `tests/test_business_impact.py::TestToolCallActionDigestPins` — **5 new tests** cross-language parity for the `ToolCall` impact, pinned to the same hex literal the Rust backend pins in `backend/src/proxy/gate/business_impact.rs::tests::tool_call_digest_golden_value_stripe_charge_500`. A drift on either side trips the test on the other side next time the suite runs. Fixture payload: `tool_call("stripe.charge", {"region": "EU", "amount": 500})` → `9975a8b75a436fb78b9d141b9e0c0a90838c1243d78119b304ae6ed0526966a6`.
- `tests/test_sensitive_extractor.py` — 5/5 pass (regression check, the auto-attach wiring is additive on top of 0.14.1).
- `tests/test_business_impact.py` — full class passes (28/28 including the 5 new parity pins).
- `tests/test_extractors.py` — 35/35 pass.
- `tests/test_protect.py + test_protect_branches.py + test_execute_approval_flow.py + test_approval_money_flow.py + test_gate_real_path.py + test_handle.py` — 99/99 pass.
- `tests/test_runtime.py + test_runtime_branches.py + test_init_contract.py` — 70/70 pass (1 skipped, pre-existing).

### Compatibility

- **Default SDK behaviour for bare `@sensitive` CHANGED** — was `no business_impact on wire`, now `kind=tool_call on wire`. Operators who relied on the Phase 0 path (approval_id-only grant consume) must either pass `@sensitive(impact=tool_params(include_all=False))` explicitly, or accept the new ToolParameters wire shape. The change is additive on the SDK side; legacy backends ignore `kind=tool_call` and fall through to a no-op.
- **Existing `@sensitive(impact=money_outflow(...))` callers are unaffected** — the explicit extractor wins over the auto-attach (verified by `test_explicit_money_outflow_chain_walk_preserved`).
- **Legacy "no impact extractor" call sites (registered via `rt.add_sensitive_tool(name)` directly) are unaffected** — the auto-attach is only wired through `_do_sensitive_register`, which only the `@sensitive` decorator calls.
- **No SDK_MIN_VERSION bump.** ToolParameters is an opt-in backend feature; SDK 0.14.4 talking to a backend that has the `BusinessImpact::ToolCall` variant (commit `1e501cd6` and later) is the supported path. SDK 0.14.4 talking to an older backend works but the `kind=tool_call` envelope is ignored — same effective behaviour as 0.14.3 minus the wire bytes.

---

## [0.14.2] - 2026-07-24

Three hotfixes that fell out of the 0.14.1 demo run. Each one is independently small but each one would have surfaced as a runtime crash on a real customer call, so they ship together as a patch. No on-wire breaking change. No SDK_MIN_VERSION bump. Backends on `1.0.0` keep working unchanged.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ 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.2"
version = "0.14.4"
# 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
5 changes: 3 additions & 2 deletions src/nullrun/__version__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""NullRun Platform SDK.

v3.29 / 0.14.1 (2026-07-24) — Decimal JSON serialization patch.
v3.30 / 0.14.4 (2026-07-27) — ToolParameters Approval Rules
wire contract (Tier 2 / Разрыв 2 follow-up).

Pre-fix 0.14.0, a ``track_tool`` event payload containing a
``Decimal`` (e.g. ``refund_amount`` from a
Expand Down Expand Up @@ -1017,5 +1018,5 @@

"""

__version__ = "0.13.11"
__version__ = "0.14.4"
__platform_version__ = "1.0.0"
191 changes: 170 additions & 21 deletions src/nullrun/business_impact.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,20 @@
EQ = "eq"


# MVP: only `money` kind is supported; the discriminated union is
# shaped forward-compat for record_count / resource_quantity etc.
# when they land in MVPs 1.1+.
# MVP 1.0: `money` kind for per-call flat amounts.
# MVP 1.1 (ToolParameters / Phase 1 / Tier 2): `tool_call` kind
# for free-form tool-call argument bags matched against
# ToolParameters Approval Rules on the backend.
KIND_MONEY = "money"
KIND_TOOL_CALL = "tool_call"


# Mirrors the backend constant at
# ``backend/src/proxy/gate/business_impact.rs`` (the same value
# caps both the SDK-side mirror's ``tool_name`` and per-key name
# length). Kept in sync manually; a backend-side bump is a one-line
# edit here.
TOOL_PARAMETERS_MAX_PARAM_NAME = 64


@dataclass
Expand Down Expand Up @@ -85,30 +95,20 @@ def validate(self) -> None:
backend's `MoneyImpact::validate()` mirrors these checks.
"""
if self.direction not in (OUTFLOW, INFLOW):
raise ValueError(
f"direction must be {OUTFLOW!r} or {INFLOW!r}, "
f"got {self.direction!r}"
)
if not isinstance(self.amount_minor, int) or isinstance(
self.amount_minor, bool
):
raise ValueError(f"direction must be {OUTFLOW!r} or {INFLOW!r}, got {self.direction!r}")
if not isinstance(self.amount_minor, int) or isinstance(self.amount_minor, bool):
# bool is a subclass of int in Python — explicit exclude.
raise ValueError(
f"amount_minor must be int, got {type(self.amount_minor).__name__}"
)
raise ValueError(f"amount_minor must be int, got {type(self.amount_minor).__name__}")
if self.amount_minor < 0:
raise ValueError(
f"amount_minor must be non-negative, got {self.amount_minor}"
)
raise ValueError(f"amount_minor must be non-negative, got {self.amount_minor}")
if (
not isinstance(self.currency, str)
or len(self.currency) != 3
or not self.currency.isascii()
or not self.currency.isupper()
):
raise ValueError(
f"currency must be a 3-letter uppercase ISO-4217 code, "
f"got {self.currency!r}"
f"currency must be a 3-letter uppercase ISO-4217 code, got {self.currency!r}"
)

def to_wire_dict(self) -> dict[str, Any]:
Expand All @@ -129,6 +129,125 @@ def to_wire_dict(self) -> dict[str, Any]:
}


@dataclass
class ToolCallParams:
"""Free-form tool-call argument bag (Phase 1 / Tier 2 wire shape).

Mirrors the backend ``BusinessImpact::ToolCall(ToolCallParams)``
variant at ``backend/src/proxy/gate/business_impact.rs:62-307``.
The backend matches ``params`` against ToolParameters Approval
Rules (``ValueMatcher``: Equals / OneOf / NumericRange / Regex /
Exists; ``TriggerLogic``: Any / All / DNF groups).

Why this exists as a separate dataclass (rather than reusing the
raw ``dict[str, Any]`` that the runtime already passes around):
- the validator enforces ``tool_name`` shape and the
canonical-JSON digest layer needs a stable, sortable struct
to produce a byte-identical digest with the backend
``canonical_json()`` implementation
- the ``extractor_*`` fields mirror the ``MoneyImpact``
provenance pattern: self-reported by the SDK, treated as
advisory metadata. The trust boundary is the digest
round-trip — the SDK and backend both canonicalise the
same payload to the same bytes, and a mismatch on /execute
re-check is a 403 DIGEST_MISMATCH

Attributes:
tool_name: canonical name of the tool the SDK is about to
call. Must be non-empty and <= 128 bytes.
params: free-form argument bag the operator wrote the rule
against. Keyed by the rule's ``param_name`` field.
extractor_id: self-reported SDK extractor id (e.g.
"nullrun.tool_call.path").
extractor_version: self-reported version.
"""

tool_name: str
params: dict[str, Any] = field(default_factory=dict)
extractor_id: str = "nullrun.tool_call.path"
extractor_version: str = "1"

def validate(self) -> None:
"""Reject malformed impacts at extraction time (fail-fast).

Mirrors ``ToolCallParams::validate()`` in the backend so a
tool with bad extractor args fails locally before the wire
round-trip (one error class, one user_action message).
"""
if not isinstance(self.tool_name, str) or not self.tool_name:
raise ValueError("tool_name must be a non-empty string")
if len(self.tool_name) > 128:
raise ValueError(f"tool_name length {len(self.tool_name)} exceeds max 128")
if not self.tool_name.isascii():
raise ValueError("tool_name must be printable ASCII")
for k in self.params:
if not isinstance(k, str):
raise ValueError(f"params key {k!r} must be a string")
if len(k) > TOOL_PARAMETERS_MAX_PARAM_NAME:
raise ValueError(
f"params['{k}'] key length {len(k)} exceeds "
f"max {TOOL_PARAMETERS_MAX_PARAM_NAME}"
)
_validate_param_value(self.params[k], path=f"params['{k}']")

def to_wire_dict(self) -> dict[str, Any]:
"""Serialize to the JSON shape the backend expects.

Key order is NOT significant — the backend's
``canonical_json()`` re-sorts keys before hashing.
"""
return {
"kind": KIND_TOOL_CALL,
"tool_name": self.tool_name,
"params": dict(self.params),
"extractor_id": self.extractor_id,
"extractor_version": self.extractor_version,
}


def _validate_param_value(value: Any, path: str) -> None:
"""Reject values that the digest layer cannot round-trip.

Backend mirror at ``business_impact.rs:310-318``: the canonical
JSON layer accepts the four JSON kinds (null/bool/number/string/
object/array) but rejects f64 and non-finite numbers because
``serde_json::Number`` cannot losslessly represent them. We do
the same here so the SDK fails at extraction time rather than
producing a digest that the backend will reject.
"""
if value is None or isinstance(value, bool):
return
if isinstance(value, int):
# int round-trips through JSON losslessly. NOTE: bool is a
# subclass of int in Python; we explicitly handle it above.
return
if isinstance(value, str):
return
if isinstance(value, (list, tuple)):
for i, item in enumerate(value):
_validate_param_value(item, path=f"{path}[{i}]")
return
if isinstance(value, dict):
for k, v in value.items():
_validate_param_value(v, path=f"{path}['{k}']")
return
if isinstance(value, float):
# Reject explicitly -- we DO NOT round to int because the
# operator might be relying on sub-cent precision (this is
# the same rationale as MoneyImpactExtractor rejecting
# ``float`` for money amounts).
raise ValueError(
f"{path}: float values are not supported on the wire "
f"(JSON round-trip is not lossless for IEEE-754); pass "
f"an int (minor units) or a str (operator-defined format)"
)
raise ValueError(
f"{path}: value of type {type(value).__name__!r} is not "
f"supported on the wire; pass int / str / bool / None / "
f"list / dict"
)


def business_impact_to_dict(impact: BusinessImpact) -> dict[str, Any]:
"""Top-level wire dict for `GateRequest.business_impact`.

Expand All @@ -146,18 +265,23 @@ def business_impact_to_dict(impact: BusinessImpact) -> dict[str, Any]:
class BusinessImpact:
"""Top-level BusinessImpact union.

For MVP 1.0 the only supported variant is `Money`. Future
kinds land by adding new subclasses and a `kind` value.
MVP 1.0: `Money` only.
MVP 1.1 (Phase 1 / Tier 2): adds `ToolCall` for free-form
tool-call argument bags matched against ToolParameters
Approval Rules on the backend.

The SDK validates the variant at construction time so the
backend never sees malformed output.
"""

impact: Any # MoneyImpact in MVP.
impact: Any # MoneyImpact | ToolCallParams

@property
def kind(self) -> str:
if isinstance(self.impact, MoneyImpact):
return KIND_MONEY
if isinstance(self.impact, ToolCallParams):
return KIND_TOOL_CALL
raise TypeError(f"unknown impact type: {type(self.impact)}")

def validate(self) -> None:
Expand All @@ -181,6 +305,31 @@ def money(
m.validate()
return cls(impact=m)

@classmethod
def tool_call(
cls,
tool_name: str,
params: dict[str, Any] | None = None,
extractor_id: str = "nullrun.tool_call.path",
extractor_version: str = "1",
) -> BusinessImpact:
"""Construct a ``kind="tool_call"`` BusinessImpact.

Used by the ToolParamsExtractor; callers building impacts
by hand should use this factory rather than constructing
``ToolCallParams`` and wrapping themselves -- the factory
validates before returning so a misuse fails locally
instead of after a wire round-trip.
"""
p = ToolCallParams(
tool_name=tool_name,
params=params or {},
extractor_id=extractor_id,
extractor_version=extractor_version,
)
p.validate()
return cls(impact=p)


def _canonicalize_json(value: Any) -> Any:
"""Sort object keys recursively before serialization.
Expand Down
Loading
Loading