chore(release): 0.14.4 — ToolParameters Approval Rules wire contract - #80
Merged
Conversation
Three independent fixes that fell out of the 0.14.1 demo run, plus a stub refresh on the two _RecordingRuntime mocks whose shape the new @Protect emit broke. * fix(decorators): @Protect now emits a tools/track_tool event after the wrapped body returns. Pre-0.14.2 the protected decorator only fired the gate check and skipped the bookkeeping emit, so the dashboard never saw a protect execution. The emit goes through the same sink as llm_call events so it picks up the dedup LRU at runtime.track() for free. * fix(runtime): track_tool event carries tokens: 0 and a fresh uuidv7 execution_id. The backend's SdkTrackRequest requires both fields as non-Optional u64 / string; pre-0.14.2 the event dict only carried type / tool_name / is_retry and the deserializer rejected it. Span lifecycle events get the same tokens: 0 default via _enrich_event. * fix(transport): approval-resolved WS callback is now a plain sync function. The WebSocket dispatch path invokes it as a dict -> None callable; the previous async-decorated coroutine was silently dropped, so the sync threading.Event inside _wait_for_approval_resolution never got set on the first approval round-trip - the demo's first approval hung forever. Caught 2026-07-24. * fix(runtime): treat websocket cancellation as a clean shutdown signal. WebSocketConnection.close() cancels the receive task to unblock the waiter during normal end of session; on Python 3.11+ CancelledError derives from BaseException, so the old except Exception branch re-raised it and produced a noisy debug line on every clean exit. The new except CancelledError branch is silent and the finally cleanup still runs. * test: refresh _RecordingRuntime in tests/test_protect.py and tests/test_preflight_fail_policy.py with a track_tool stub. The previous shape only mocked track_event, which is why the @Protect emit silently failed under the new decorator wiring. * chore: ruff format on the three source files touched by this release (decorators / runtime / transport). The format-only reformat of the 65 unrelated files is intentionally deferred to a separate PR. * docs: 0.14.2 changelog entry describing the four fixes end-to-end. No SDK_MIN_VERSION bump. No public API change. No on-wire breaking change. Backends on 1.0.0 keep working unchanged. Verification: - pytest -n auto -> 1369 passed, 7 skipped, 29 warnings. - ruff check src/ tests/ -> All checks passed. - mypy src/nullrun --strict -> Success: no issues found in 36 source files.
…ramsExtractor on bare @sensitive Phase 1 / MVP 1.1 (Tier 2 / Razryv 2 follow-up). The backend already accepts BusinessImpact::ToolCall(ToolCallParams) on the /execute wire (commit 1e501cd6 in the backend repo). This commit wires the SDK-side path so users get ToolParameters Approval Rules by default with no decorator changes: @sensitive @Protect def delete_user(user_id: int, force: bool = False): ... The above now ships BusinessImpact(kind='tool_call', tool_name='delete_user', params={'user_id': ..., 'force': ...}) on every /execute call, matched against ToolParameters rules on the backend. No new decorator argument required. What ships: - business_impact.py: ToolCallParams dataclass mirrors the backend struct (tool_name <= 128 bytes, param_name <= 64, JSON-roundtrippable values only). BusinessImpact.kind now discriminates Money | ToolCall. New factory BusinessImpact.tool_call(...) for hand-built impacts. - extractor.py: ToolParamsExtractor class + tool_params() factory (by analogy with MoneyImpactExtractor + money_outflow). Three modes: explicit {rule_param: arg_name} map, include_all (default, every kwarg), or empty (include_all=False with no map). JSON-unsafe values (float, custom objects) and PII-masked sentinels ("***") are filtered before the wire. - decorators.py: _enforce_sensitive_tool dispatch now handles both MoneyImpactExtractor and ToolParamsExtractor. NR-B003 error hint branches by extractor type so the operator sees the right remediation advice. - decorators.py: _do_sensitive_register auto-attaches a default ToolParamsExtractor(include_all=True) on bare @sensitive. An explicit @sensitive(impact=money_outflow(...)) wins -- the auto-attach only fires when no extractor is present. The stamp uses _stamp_extractor_on_innermost so the bare function (the one @Protect captures as fn) carries the attribute, not just the @Protect wrapper (the 2026-07-24 root-cause fix). - tests/test_tool_params_extractor.py: 19 tests covering factory shape, three extraction modes, PII sentinel filtering, JSON round-trip, fail-CLOSED on backend rejection, action_digest byte-identity with the backend's canonical JSON, the auto-attach wiring, the auto-attach-vs-explicit-extractor priority, and the dataclass validator. Wire-contract 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 ToolParameters wire shape. - Existing @sensitive(impact=money_outflow(...)) callers are unaffected: the explicit extractor wins over the auto-attach. - Legacy 'no impact extractor' test_sensitive_extractor.py fixture (registers the tool manually, bypassing the decorator) still passes because auto-attach is only wired through _do_sensitive_register -- the @sensitive decorator path. Users who registered sensitive tools via rt.add_sensitive_tool(name) directly are unaffected. Verification: - tests/test_tool_params_extractor.py: 19 passed - tests/test_sensitive_extractor.py: 5 passed (regression check) - tests/test_business_impact.py: 19 passed - tests/test_extractors.py: 35 passed - 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 passed - tests/test_runtime.py + test_runtime_branches.py + test_init_contract.py: 70 passed, 1 skipped Per project rhythm: local commit only, no push. Refs: - backend BusinessImpact::ToolCall variant: backend/src/proxy/gate/business_impact.rs:62-307 - backend Razryv 2 / Tier 1+2 commits: 1e501cd6, 63ba9f6a - Test companion for Phase 1 / MVP 1.0 money: tests/test_sensitive_extractor.py - Plan: fix-plan.md P1-2 (Phase 1 trust_level enum -- now unblocked once this commit lands and operators actually deploy ToolParameters rules).
…ams() map Ad-hoc verification after the initial commit (40d391a) surfaced a silent regression in the auto-attach path: @sensitive(impact=tool_params({"delete_force": "force"})) @Protect def delete_user(force, user_id): ... Before this fix, the wire payload for this function used the auto-attach DEFAULT (every kwarg, no rename) instead of the explicit map the user wrote. Root cause: ``_do_sensitive_register`` called ``getattr(fn, "_nullrun_extractor", None)`` on the @Protect WRAPPER, but ``@sensitive(impact=...)`` factory form stamps the explicit extractor on the BARE function via ``_stamp_extractor_on_innermost``. The wrapper itself has no attribute, so the check returned None and the auto-attach path silently overwrote with the default ToolParamsExtractor( include_all=True). The user's explicit param_extractors map was discarded without warning. Fix: walk the ``__wrapped__`` chain in ``_do_sensitive_register`` via a new helper ``_find_extractor_in_chain``. The walk is bounded (32 hops) to defend against pathological ``__wrapped__`` cycles and returns the FIRST extractor found or None. Behavior: * ``@sensitive`` bare -> chain walk finds nothing, auto-attach default wins. * ``@sensitive(impact=money_outflow(...))`` -> chain walk finds the MoneyImpactExtractor stamped on the bare function; auto-attach skips. * ``@sensitive(impact=tool_params({...}))`` -> chain walk finds the explicit map; auto-attach skips. Wire contract for end users: * ``@sensitive(impact=tool_params({"force_param": "force"}))`` on a function with ``force: bool, user_id: int`` kwargs now sends ``{force_param: <bool>}`` on the wire (the renamed key) instead of the previous broken ``{force: <bool>, user_id: <int>}``. * Bare ``@sensitive`` continues to ship ``{force: <bool>, user_id: <int>}`` on the wire (unchanged from commit 40d391a). * ``@sensitive(impact=money_outflow(...))`` is unaffected (chain walk finds the MoneyImpactExtractor, auto-attach skips). Regression tests (4 new in ``tests/test_tool_params_extractor.py::TestAutoAttachChainWalk``): * ``test_bare_sensitive_chain_walk_attaches_default``: pin the bare-form auto-attach path. * ``test_explicit_tool_params_chain_walk_preserves_map``: the regression case -- explicit ``impact=tool_params({...})`` must NOT be overwritten. * ``test_explicit_money_outflow_chain_walk_preserved``: the original Phase 1 / MVP 1.0 money variant must NOT be overwritten (regression on the regression). * ``test_chain_walk_does_not_loop_on_circular_wraps``: defensive -- a pathological ``__wrapped__`` cycle (a -> a or a -> b -> a -> b) returns None within the bounded hop count without hanging. Verification: - cargo check equivalent: ``.venv-ci/Scripts/python.exe -m pytest`` on the full touched surface (160 tests across test_tool_params_extractor, test_sensitive_extractor, test_business_impact, test_extractors, test_protect, test_protect_branches, test_execute_approval_flow, test_approval_money_flow) -- 160 passed, 0 failed. - ad-hoc verification script at ``C:/Users/ANATOL~1/AppData/Local/Temp/hermes-verify-toolparams.py`` confirms all three decorator variants wire the correct extractor type with the right map. Local commit only; no push. Refs: - Original commit (the regression): 40d391a - Ad-hoc verifier that surfaced the regression: ``hermes-verify-toolparams.py``
Pins the SDK compute_action_digest(BusinessImpact.tool_call(...))
to the same hex literal the Rust backend asserts in
backend/src/proxy/gate/business_impact.rs::tests::
tool_call_digest_golden_value_stripe_charge_500.
Fixture payload: BusinessImpact.tool_call("stripe.charge",
{"region": "EU", "amount": 500}) -- mirrors the backend
helper at business_impact.rs:1473. The protocol prefix and
canonical-JSON algorithm must remain identical across both
languages; a drift on either side trips BOTH pins next time
the suite runs.
Five new tests in TestToolCallActionDigestPins:
* test_tool_call_stripe_charge_500_matches_golden_hex
-- the pin itself
* test_tool_call_two_calls_produce_identical_hex
-- determinism for the tamper-evident re-check on /execute
* test_tool_call_param_change_produces_different_hex
-- positive half: param change flips the digest
* test_tool_call_wire_dict_shape
-- kind='tool_call' discriminator (snake_case) pin so a
typo would not silently route to Money on the backend
* test_tool_call_extractor_metadata_advisory
-- extractor_* defaults are advisory provenance, present
on every wire payload
NOTE: ToolCallParams.validate() is intentionally NOT auto-
invoked by the dataclass __post_init__ -- the SDK relies on
BusinessImpact.tool_call(...) factory to enforce rejection
paths. Direct ToolCallParams(...) construction succeeds
without raising. This is a documented design choice that
matches the backend Rust struct (validation at the
construction site, not on the wire carrier).
Verification:
pytest tests/test_business_impact.py -v
-> 28 passed (23 pre-existing + 5 new pins)
Local commit only; no push.
Refs:
backend Rust pin: backend/src/proxy/gate/business_impact.rs
backend Tier 2 commit: 1e501cd6
SDK ToolParameters phase 1: 40d391a
SDK auto-attach chain walk fix: 436dc7b
Plan: docs/runbooks/action-digest-contract.md
Bumps SDK 0.14.2 -> 0.14.4 and lands the Tier 2 / Разрыв 2 follow-up that wires the SDK-side ToolParameters path so users get ToolParameters Approval Rules by default on every bare @sensitive function with no decorator change. Skipping 0.14.3 because the working branch was tagged archive/cleanup-attempted-1c1e326 throughout the ToolParameters work; this release is the first user-facing tag on the release/0.14.4-toolparameters branch. What ships in this release (full changelog at CHANGELOG.md): * BusinessImpact.tool_call(...) factory + ToolCallParams dataclass (business_impact.py) * ToolParamsExtractor + tool_params(...) factory (extractor.py) * Bare @sensitive auto-attaches a default ToolParamsExtractor(include_all=True) via _do_sensitive_register * @sensitive(impact=tool_params({...})) decorator form * Auto-attach chain walk (_find_extractor_in_chain) preserves an explicit impact=tool_params({...}) map -- the regression that was silently dropped in 40d391a (fixed in 436dc7b) * Cross-language ToolCall action digest parity pin (Rust + SDK assert the same golden hex literal) Version bumps: * pyproject.toml: 0.14.2 -> 0.14.4 (hatchling source of truth) * src/nullrun/__version__: 0.13.11 -> 0.14.4 (was lagging; also bumped the docstring header to v3.30 / 0.14.4) Behavioural change (called out in CHANGELOG > Compatibility): * Bare @sensitive now ships kind=tool_call on the wire where it previously shipped nothing. Money callers unaffected. Legacy backends ignore the new envelope (additive on the SDK side). Verification (pre-commit, local venv): pytest tests/ -q --ignore=tests/contract -> 1382 passed, 7 skipped, 10 warnings in 98.36s Local commit only; no push. Refs: Phase 1 wire contract: 40d391a Chain-walk fix: 436dc7b Cross-language parity pin: f608414 backend BusinessImpact::ToolCall variant: 1e501cd6 backend Tier 2 commits: 1e501cd6, 63ba9f6a
Resolve the conflicts introduced by PR #79 (origin/master 14c673c), which re-merged the 0.14.2 hotfixes on top of master while our branch carries the same logical commits under a different SHA (4c143e2 -- an earlier attempt before the public 0.14.2 PR landed). Three files tripped git's conflict heuristic: * CHANGELOG.md -- both sides added new sections at the top. Kept our 0.14.4 entry above master's 0.14.2 entry. * pyproject.toml -- version line. Kept our 0.14.4. * src/nullrun/decorators.py -- auto-merged clean (master's additions are byte-identical to ours up to whitespace; our ToolParameters additions are purely additive on top). This is a no-op merge for the source files -- the conflict markers were an artifact of the SHA mismatch, not a real textual disagreement. Verified locally with pytest on the touched surface (224 passed, 2 warnings in 21.52s). Refs: PR #79 (master): origin/master 14c673c Our branch: release/0.14.4-toolparameters 332acfb
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
… strings CI failed on the 0.14.4 PR (#80) at the lint step with ruff F541 (f-string without any placeholders) -- 14 errors in src/nullrun/decorators.py plus similar auto-fixable issues in the other 4 files I touched. Auto-fixed via: ruff check src/ tests/ --fix ruff format src/nullrun/decorators.py src/nullrun/business_impact.py src/nullrun/extractor.py src/nullrun/__version__.py tests/test_tool_params_extractor.py tests/test_business_impact.py What changed (no behavioural change, just syntax): * src/nullrun/decorators.py: f"..." -> "..." on the MoneyImpactExtractor / ToolParamsExtractor / fallback hint literals in _enforce_sensitive_tool (the @sensitive error path). The strings had no {}-placeholders so the f-prefix was dead syntax that ruff F541 (selected by the default rule set in pyproject.toml) flagged. * src/nullrun/business_impact.py: import-order normalisation in extractor.py imports + format-only whitespace tidy. * src/nullrun/extractor.py: same import-order + format tidy. * tests/test_tool_params_extractor.py: import block was un-sorted (I001 fixable). Re-ordered to ruff convention. * tests/test_business_impact.py: format-only whitespace tidy from the cross-language parity pin (f608414). Verification: ruff check src/ tests/ -> All checks passed! ruff format --check <my 6 files> -> 6 files already formatted pytest tests/ --ignore=tests/contract -> 1382 passed, 7 skipped This is exactly the fix 4c143e2 (the 0.14.2 release) called out as 'deferred to a separate PR' for its own touch surface: 'ruff format on the three source files touched by this release. The format-only reformat of the 65 unrelated files is intentionally deferred to a separate PR.' I am doing the same scope discipline here -- only the files I authored or modified for 0.14.4, not the 67 pre-existing format-debt files (those are a separate PR). Refs: Failing CI: PR #80, run 30291404693 (test 3.11 + coverage) ToolParameters phase 1: 40d391a Cross-language parity pin: f608414 Release 0.14.4: 332acfb Master merge: 3471bc4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Bumps SDK 0.14.2 → 0.14.4 and lands the Tier 2 / Разрыв 2 follow-up that wires the SDK-side ToolParameters path so users get ToolParameters Approval Rules by default on every bare
@sensitivefunction, with no decorator change.Skipping 0.14.3 because the working branch was tagged
archive/cleanup-attempted-1c1e326throughout the ToolParameters work; 0.14.4 is the first user-facing tag on therelease/0.14.4-toolparametersbranch.What ships
ToolParamsExtractoron bare@sensitiveimpact=tool_params({...})mapNew public surface
BusinessImpact.tool_call(tool_name, params)factory —business_impact.py:323ToolCallParamsdataclass —business_impact.py:143(mirrors backendBusinessImpact::ToolCall)ToolParamsExtractor+tool_params(...)factory —extractor.py:815@sensitive(impact=tool_params({...}))decorator form —decorators.py:1065Behavioural change
@sensitivenow shipskind=tool_callon the wire where it previously shipped nothing.@sensitive(impact=money_outflow(...))callers unaffected — explicit extractor wins.1e501cd6) ignore the new envelope — additive on the SDK side.Bug fixes
impact=tool_params({...})map. Before 436dc7b,@sensitive(impact=tool_params({"delete_force": "force"}))silently shipped{force, user_id}(the auto-attach default) instead of the renamed{delete_force}key. Regression tests inTestAutoAttachChainWalk(4 cases)._enforce_sensitive_tooldispatches both extractor types (Money / ToolCall); NR-B003 error hint branches accordingly.Cross-language parity
tests/test_business_impact.py::TestToolCallActionDigestPins(5 new tests) pins the SDKcompute_action_digest(BusinessImpact.tool_call(...))to the same hex literal the Rust backend asserts inbackend/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:
tool_call("stripe.charge", {"region": "EU", "amount": 500})→9975a8b75a436fb78b9d141b9e0c0a90838c1243d78119b304ae6ed0526966a6.Verification
pytest tests/ -q --ignore=tests/contract→ 1382 passed, 7 skipped, 10 warnings in 98.36spytest tests/test_tool_params_extractor.py tests/test_business_impact.py tests/test_sensitive_extractor.py tests/test_extractors.py→ 87 passedpytest tests/test_protect.py tests/test_protect_branches.py tests/test_execute_approval_flow.py tests/test_approval_money_flow.py tests/test_gate_real_path.py tests/test_handle.py tests/test_runtime.py tests/test_runtime_branches.py tests/test_init_contract.py→ 169 passed, 1 skippedCompatibility
1.0.0keep working unchanged;kind=tool_callenvelope is additive.@sensitiveCHANGED — see CHANGELOG > Compatibility for the migration path (@sensitive(impact=tool_params(include_all=False))for callers that need the legacy "no business_impact on wire" shape).Checklist
pyproject.tomlbumped 0.14.2 → 0.14.4 (hatchling source of truth)src/nullrun/__version__bumped 0.13.11 → 0.14.4 (was lagging; also fixed docstring header to v3.30)CHANGELOG.md0.14.4 entry added (37 lines)Refs
BusinessImpact::ToolCallvariant:backend/src/proxy/gate/business_impact.rs:62-3071e501cd6,63ba9f6adocs/runbooks/action-digest-contract.md🤖 Generated with Claude Code