Skip to content

chore(release): 0.14.4 — ToolParameters Approval Rules wire contract - #80

Merged
maltsev-dev merged 11 commits into
masterfrom
release/0.14.4-toolparameters
Jul 27, 2026
Merged

chore(release): 0.14.4 — ToolParameters Approval Rules wire contract#80
maltsev-dev merged 11 commits into
masterfrom
release/0.14.4-toolparameters

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

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 @sensitive function, with no decorator change.

Skipping 0.14.3 because the working branch was tagged archive/cleanup-attempted-1c1e326 throughout the ToolParameters work; 0.14.4 is the first user-facing tag on the release/0.14.4-toolparameters branch.

What ships

Commit Layer Subject
40d391a feat ToolParameters phase 1 wire contract — auto-attach ToolParamsExtractor on bare @sensitive
436dc7b fix Auto-attach chain walk — preserve explicit impact=tool_params({...}) map
f608414 test ToolCall cross-language parity pin against Rust golden hex
332acfb chore Release 0.14.4 (this commit)

New public surface

  • BusinessImpact.tool_call(tool_name, params) factory — business_impact.py:323
  • ToolCallParams dataclass — business_impact.py:143 (mirrors backend BusinessImpact::ToolCall)
  • ToolParamsExtractor + tool_params(...) factory — extractor.py:815
  • @sensitive(impact=tool_params({...})) decorator form — decorators.py:1065

Behavioural change

  • Bare @sensitive now ships kind=tool_call on the wire where it previously shipped nothing.
  • @sensitive(impact=money_outflow(...)) callers unaffected — explicit extractor wins.
  • Legacy backends (pre-1e501cd6) ignore the new envelope — additive on the SDK side.

Bug fixes

  • Auto-attach chain walk preserves an explicit 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 in TestAutoAttachChainWalk (4 cases).
  • _enforce_sensitive_tool dispatches 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 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. 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/contract1382 passed, 7 skipped, 10 warnings in 98.36s
  • pytest tests/test_tool_params_extractor.py tests/test_business_impact.py tests/test_sensitive_extractor.py tests/test_extractors.py87 passed
  • pytest 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.py169 passed, 1 skipped

Compatibility

  • No SDK_MIN_VERSION bump. Backends on 1.0.0 keep working unchanged; kind=tool_call envelope is additive.
  • Default SDK behaviour for bare @sensitive CHANGED — 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.toml bumped 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.md 0.14.4 entry added (37 lines)
  • Full suite green: 1382 passed, 7 skipped
  • Cross-language parity pin in place

Refs

  • Backend BusinessImpact::ToolCall variant: backend/src/proxy/gate/business_impact.rs:62-307
  • Backend Tier 2 commits: 1e501cd6, 63ba9f6a
  • Docs: docs/runbooks/action-digest-contract.md

🤖 Generated with Claude Code

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

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.14286% with 32 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/nullrun/extractor.py 78.57% 7 Missing and 5 partials ⚠️
src/nullrun/decorators.py 63.33% 8 Missing and 3 partials ⚠️
src/nullrun/business_impact.py 83.01% 4 Missing and 5 partials ⚠️

📢 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
@maltsev-dev
maltsev-dev merged commit 12bf012 into master Jul 27, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant