Changelog
Rendered from the repositories' CHANGELOG.md files.
attenu-guard
All notable changes to attenu-guard are documented here. The format follows Keep a Changelog; the project adheres to Semantic Versioning.
[Unreleased]
[0.10.0] - 2026-08-31
Fixed
Guard.check()(core,guard.py) — aPRE_HOOK_ONLYallow wedgedcomplete()forever. Codex review round 3, finding 1 (critical):register_pending()ran unconditionally for every v2allow, in both the normal commit path and theCommittedAuditErrorpath, with no regard forcapture. A barecheck()(or any explicitcapture=Capture.PRE_HOOK_ONLY) is an honest promise of NO terminal observation -- nothing is ever going to callrecord_outcome()for it -- yet it was registered pending exactly like aWRAPPER_SYNC/WRAPPER_ASYNC/FRAMEWORK_POST_HOOKallow, socomplete()refused forever for a node with onlyPRE_HOOK_ONLYcalls. This is a defect in the shipped 0.9.0 execution-binding layer itself (unchanged before this fix), surfaced -- not caused -- by round 2/3's adapter mode-splits: the momentadapters.crewai/adapters.google_adkshipped a genuinely PRE_HOOK_ONLY default mode that real callers would use, this was reachable in ordinary use, not just in a synthetic test. The offline verifier already treated a missingPRE_HOOK_ONLYoutcome as merelyunobserved(evidence.py's_execution_binding), so runtime and offline semantics disagreed. Fixed: a call is now registered pending only when its capture is one ofWRAPPER_SYNC/WRAPPER_ASYNC/FRAMEWORK_POST_HOOK-- in both the normal path and theCommittedAuditErrorpath. A bare/PRE_HOOK_ONLYallow never enters the pending set, socomplete()finalizes immediately, and the verifier'sunobservedclassification for it now matches the runtime's own view.adapters.langgraph— the SHIPPED, hand-written-node reference-wiring adapter still had an aliasing snapshot path, and its own regression test tested a different module. Release-gate finding 1 (CRITICAL). Every OTHER Python adapter went through two rounds of adversarial review that closed this exact class of defect;adapters.langgraphwas never touched by either batch (it predates them, as the original reference wiring) and still used rawcopy.deepcopy()in_snapshot_params, falling back to the raw (live) dict on failure. A hostile__deepcopy__reproducedsnapshot["args"][0] is live, and a later mutation of the live argument changed the "snapshot" -- the exactauthorized_params/invoked_paramsintegrity guarantee every other adapter's own_freeze()exists to hold. Separately,tests/integrations/ test_langgraph.py-- despite its filename and its own module docstring's claim to cover "the SHIPPED adapter (attenu_guard.adapters.langgraph)" -- importedattenu_guard.adapters. langchainunder an alias (dg_langgraph) that made everyGuardedDelegation-based test in the file (the large majority of it) read as testing the shippedlanggraphmodule when it was actually, correctly, testingadapters.langchain; only the NAME was wrong, but it meant this regression class had no test coverage anywhere. Fixed:adapters.langgraph's_snapshot_paramsnow routes through the same sharedattenu_guard.adapters._snapshot. freeze()sanitizer every other adapter uses (see the consolidation entry below); the misleading alias intest_langgraph.pyis renamed todg_langchainwith a comment explaining what it actually is; andtests/test_langgraph_adapter.py(the zero-dependency unit suite that genuinely importsattenu_guard.adapters.langgraphitself) gained aTestSnapshotHardeningclass: the never-aliases-a-custom-__deepcopy__regression test every other adapter's own test suite already has, a mutation-does-not-cause-a-params-mismatch test through the realguard_nodewrapper, and a circular-container test pinning the shared sanitizer'sUNSUPPORTEDmarker (see the consolidation entry's own corrections below).adapters.crewai: outcome correlation was keyed by a thread-local slot (one per OS thread, not per dispatch); two async tool calls interleaved on one thread (CrewAI's own async executor can do this) could let a later call'sbeforehook overwrite an earlier call's still- pending outcome. Now keyed byid(ctx.tool_input), the object CrewAI itself threads through the before/after hook contexts for one dispatch, held with a strong reference for that dispatch's whole span. Also: a THIRD-PARTYbefore_tool_callhook (not this bridge's own) that vetoes a call after this bridge already authorized it no longer gets recorded as a fabricatedBodyState.RETURNED-- CrewAI's own literal "blocked by hook" result is recognized and the outcome is left unrecorded instead. Round 2 correction (Codex review, finding 1): three fresh defects. (a)getattr(ctx, "tool_input", None) or {}substituted a BRAND NEW{}literal on every FALSEYtool_input(a zero-argument tool call) -- CrewAI itself reuses the SAME{}object across its own before/ after hooks even then, so theor {}broke correlation entirely for every zero-arg tool (allow, no outcome,complete()==False). Fixed:getattr(ctx, "tool_input", {}), no truthiness check, preserves identity on a present-but-falsey value. (b) Two dispatches CAN legitimately share onetool_inputobject identity (not just equal content -- e.g. CrewAI's own argument-parse caching for identical call text); a single dict slot per key let a second such dispatch overwrite the first's still-pending entry. Fixed:self._pending[key]is now a FIFOcollections.deque, and_before_tool_callpasses the EXACT_Pendingobject it just appended directly to_authorize/_deny(never re-looked-up by key), so there is no ambiguity for the write side;_after_tool_callpops FIFO for the read side, sound because two dispatches sharing one identity are semantically symmetric. (c) The third-party-veto path now recordsBodyState.ABANDONED(this bridge's own observation was cut short by something outside its control) instead of leaving the call unrecorded -- an honest, explicit record beats a silently missing one, thougherror_codeis NOT attached (Guard.record_outcomeonly permits it together withRAISED).
Most importantly, per the execution-binding spec's own governing principle -- an honest
unobserved beats a promised outcome that can be lost -- Capture.FRAMEWORK_POST_HOOK is now
OPT-IN, not automatic: CrewAIGuardBridge(..., strict_single_hook=True) is an explicit
attestation that this bridge's hooks are the ONLY tool-call hooks registered in the process
(required for the third-party-veto scenario above to even be a bounded risk rather than an
open one). The DEFAULT (strict_single_hook=False) never passes capture/authorized_params
to guard.check() at all -- a v2 chain's allow is the Guard's own default
Capture.PRE_HOOK_ONLY, and no outcome is ever recorded by this bridge.
Round 3 correction (Codex review, finding 2): round 2's fix (b) above -- the per-key FIFO
collections.deque -- rested on a false theory: that two dispatches sharing one tool+args
identity are "semantically symmetric", so pairing completions to entries in append order is
as correct as any other pairing. Codex's reverse-completion repro disproved it: nothing
guarantees two same-key dispatches COMPLETE in the order they were AUTHORIZED (e.g. a later-
authorized call blocked near-instantly by an unrelated hook while an earlier one's real tool
body is still running), and CrewAI gives this bridge no per-dispatch token to tell two
completions on one key apart -- a wrong FIFO pairing silently cross-binds outcomes (the
earlier call's record_outcome receiving the LATER call's actual result, and vice versa),
each individually self-consistent and therefore undetectable by the offline verifier. Fixed:
self._pending is a single-slot dict[int, _Pending] again, not a queue -- _before_tool_
call now fails CLOSED on a second, concurrent dispatch that finds its key already occupied
by a still-live entry, denying it outright via HookAborted without ever authorizing it or
giving it a slot to collide with. A collided flag on the occupying entry additionally
closes the one residual this leaves: CrewAI still runs POST_TOOL_CALL for the collision-
denied call too, and if that fires before the first call's own real completion, it would
find the first call's entry still resident -- since a collision-denied call can only ever
itself produce a blocked-looking result, _after_tool_call now trusts a collided entry's
completion when it does NOT look blocked (that shape can only be the first call's own real
result) and leaves it unrecorded, rather than guessing, when it does.
Round 4 correction (Codex review, finding 1, critical): the round-3 fix above still
popped the slot from self._pending UNCONDITIONALLY, before ever classifying whether the
completion was ambiguous. Exact repro Codex found: A authorized; B, sharing A's object,
denied via HookAborted; B's OWN blocked after-hook (CrewAI still runs POST_TOOL_CALL for a
call this bridge itself blocked) fires FIRST and pops A's still-live slot, recording nothing;
A then returns normally, finds no slot, and its promised outcome is silently lost -- one
allow, zero outcomes, complete() wedged, A permanently pending in the core Guard. Fixed:
_after_tool_call now PEEKS the slot and classifies BEFORE ever touching self._pending --
a blocked-looking completion on a collided entry is left exactly where it is (never popped)
for a later, trustworthy (non-blocked) completion to consume; only a non-ambiguous completion
is popped and recorded. The one gap this cannot close: if the surviving call's OWN completion
is genuinely a third-party veto (a legitimate ABANDONED) AND its entry was ever collided,
that specific combination now permanently leaves the call unrecorded rather than ever
recording it wrong -- documented in the module docstring's "CORRELATION" as the least-bad
failure mode available.
- adapters.openai_agents: rebuilt on genuine WRAPPER capture (Capture.WRAPPER_ASYNC, wrapping
the tool's own on_invoke_tool directly) instead of a second, unreliable ToolOutputGuardrail
that a later tool_input_guardrails entry could cause to never run at all (leaving an allow
with no outcome ever recorded, and no way to tell that apart from one merely still in flight).
Execution binding is now OPT-IN via guarded_tool(..., registry=...): previously an output
guardrail (and the schema-2 capture on check()) was attached unconditionally, which changed
a schema_version=1 tool's shape (tool_output_guardrails) even though behavior was
unaffected -- a real regression against every adapter's "schema_version=1 stays byte-and-
type identical" guarantee. Omitting registry= (the default) now never touches the tool at
all, on any chain.
Round 2 correction (Codex review, finding 2): wrapping on_invoke_tool for CAPTURE was not
enough -- authorization still ran in a SEPARATE ToolInputGuardrail, correlated with the
wrapper via a tool_call_id-keyed map. Pinned openai-agents 0.22.0 runs ALL
tool_input_guardrails before invoking anything and returns immediately if a LATER guardrail
(not this adapter's own) rejects, so on_invoke_tool -- and this adapter's wrapper -- was
never called at all for that dispatch, leaking the map entry (an allow with no outcome ever
recorded). guard.check() now runs INSIDE on_invoke_tool itself, immediately before invoking
the original body, in the v2 (registry=) path -- no separate guardrail, no correlation map of
any kind. A denial there raises AuthorityDenied(decision) directly (on_denied="raise") or
returns the denial text as the tool's own output (on_denied="reject", the default) --
on_invoke_tool's own documented return contract, not ToolGuardrailFunctionOutput. If
authorization never runs at all (a third-party guardrail rejects first, or the SDK never
invokes on_invoke_tool for some other reason), there is now no ledger entry whatsoever for
that call, not an allow with a missing outcome. The v1 (no registry=) path is UNCHANGED --
still the original ToolInputGuardrail-based _authorize_v1.
- All six new adapters (below): the "immutable snapshot" helper fell back to a shallow dict(...)
copy whenever copy.deepcopy failed on any nested value, silently keeping shared references to
the live, mutable call arguments -- exactly the false-substitution risk the snapshot exists to
rule out. Replaced with _freeze()/_snapshot_params(): dicts and lists are always rebuilt
fresh, recursively; a leaf that cannot itself be deep-copied is replaced by its repr() (a new,
immutable string) rather than shared as-is. Never shares a mutable container.
Release-gate correction (Codex review): "Never raises" (the claim on the line above, as it
read before this correction) was false -- each of the (then 17) adapter-local _freeze()
copies recursed into a Mapping/(list, tuple, set, frozenset) value with NO cycle guard at
all, so a genuinely circular container (a dict containing itself, directly or through a nested
structure) raised RecursionError. Reproduced directly before fixing. Fixed as part of the
same release-gate pass that consolidated every adapter's own _freeze() into ONE shared
attenu_guard.adapters._snapshot.freeze() (see that module's own doc comment): PATH-ACTIVE
cycle tracking -- the set of container id()s on the CURRENT recursion path, passed as a new
set at each recursive call rather than mutated in place -- reports a genuine cycle as
"<circular>" instead of recursing forever, while correctly NOT flagging a DAG's repeated
reference (the same container appearing twice as sibling values, not an ancestor of itself) as
circular. adapters.langgraph -- the original, hand-written reference-wiring adapter,
untouched by either earlier adversarial-review batch -- was migrated onto this same shared
sanitizer in the same pass; it had been using raw copy.deepcopy() with a live-object
aliasing fallback the whole time (see the separate CRITICAL finding below).
Round 2 correction (Codex review, finding 4): that first fix still tried copy.deepcopy(value)
wholesale before falling back to the rebuild -- but a mutable class can implement __deepcopy__
to hand back self (or another object it still owns), so deepcopy succeeding was never proof
the result was independent of the live object graph. _freeze() now never calls copy.deepcopy,
or any copy protocol, on anything: containers (dict/list/tuple/set/frozenset) are
always rebuilt from scratch as fresh builtins; only already-immutable leaf types
(str/int/float/bool/None/bytes) are kept as-is; everything else becomes its repr().
Re-gate correction (HIGH, symmetry with the TS adapter's own re-gate fix): "everything else
becomes its repr()" (the line above, as it read before this correction) was itself an
attacker-controlled-code-execution defect, not a fix -- repr(value) invokes the value's own
__repr__, unconditionally, BEFORE authorization is ever decided; reproduced directly: a
hostile class's __repr__ override genuinely executed during freeze(). Its own
except Exception: fallback was no better: f"<unrepresentable {type(value).__name__}>" is a
JSON-representable STRING, and a real dict value that happens to equal that exact literal
freezes to itself unchanged (strings pass through verbatim) -- producing the IDENTICAL frozen
shape, and therefore the identical params_c14n_v1 commitment, as the exotic value it was
meant to stand in for. Reproduced directly: freeze({"arg": Explodes()}) (a class whose
__repr__ raises) and freeze({"arg": "<unrepresentable Explodes>"}) (an ordinary string
argument) produced the exact same output. Audited "<circular>" (the genuine-cycle sentinel
above) for the identical collision class rather than leaving it unexamined -- it has the same
problem. Fixed the same way the TS adapter's own freeze() was: anything whose EXACT type is
not None/str/int/float/bool and not a Mapping/list-family container becomes
UNSUPPORTED, a module-private sentinel OBJECT (never a string, so it cannot collide with any
real call argument by construction) -- without calling repr(), str(), or any other
protocol. bytes now routes to UNSUPPORTED too, deliberately: it was already outside the
params_c14n_v1 domain regardless (canonical.dumps has no bytes case, so it already raised
UnsupportedTypeError for it), so this changes nothing about the final commit outcome, only
stops the raw snapshot from retaining a live bytes reference for no purpose -- checked
directly that no shipped adapter inspects the raw snapshot for anything besides handing it to
params.commit(). The whole container walk now runs inside one try/except, degrading any
reflection/iteration failure to UNSUPPORTED too, rather than letting an exception propagate
out of a snapshot taken before authorization. A genuine cycle's own leaf value is UNSUPPORTED
now as well, not "<circular>" -- the same collision class, the same fix. UNSUPPORTED
anywhere in the frozen tree already degrades the whole params_c14n_v1 commitment to
params_hash_reason: "unsupported" via canonical.dumps's own exact-type dispatch (no case
for UNSUPPORTED's type) -- no separate wiring needed; verified directly. Six adapter test
suites (autogen, crewai, google_adk, haystack, openai_agents, pydantic_ai) had a
test asserting the unclonable-leaf fallback was a str -- updated to assert it is
UNSUPPORTED instead, the representation having deliberately changed; every adapter's own
never-aliases-a-custom-__deepcopy__ test (a DIFFERENT invariant, unaffected by this fix)
still passes unchanged.
Final-check correction: the "EXACT type" gate above originally read
isinstance(value, (str, int, float, bool)), which -- despite the CHANGELOG text above already
saying "EXACT type" -- was not actually one: isinstance admits a SUBCLASS of any of those
too, and a subclass instance can carry its own mutable attributes. Reproduced directly:
freeze(boxed) is boxed was True for a str subclass with a mutable list attribute --
the fast path passed it through completely unchanged, aliasing the live object.
params.commit()'s own disposition was never wrong either way (canonical.dumps already
gates on EXACT type there, so a subclass already fell through to UnsupportedTypeError/
"unsupported" downstream regardless), but the raw snapshot itself retained a live, mutable
reference, violating the never-alias invariant every leaf here is supposed to hold. Fixed by
actually matching canonical.dumps's own domain: type(value) in (str, int, float, bool).
Tests added: a str subclass and int/float subclasses each become UNSUPPORTED (asserted
by identity), never the live reference, with zero protocol calls (__str__/__repr__
overrides on the subclass asserted uncalled); a plain-primitives-still-pass-through test
pinning the fix did not become over-strict; and a wrapper-level test through the real
guard_node asserting params_hash_reason: "unsupported" end to end.
- adapters.google_adk: after_tool_callback now checks tool.is_long_running/
tool._defers_response -- the SAME flags ADK's own functions.py checks -- before deciding
RETURNED vs BodyState.DEFERRED; a long-running/deferring tool's placeholder response was
previously always recorded RETURNED. Pending-outcome insertion is now .setdefault-style
(never silently overwrites a colliding, still-unconsumed key). Documents (module docstring,
"HONESTY NOTES") three genuine, structural gaps in ADK's plugin/callback surface that this file
cannot close without going outside documented hooks: a caller's own AGENT-level
before_tool_callback= substituting a response (undetectable from after_tool_callback, and
the one gap that CAN record a wrong RETURNED rather than merely leaving a call unobserved);
asyncio.CancelledError (a BaseException, so neither the error nor after callback ever fires
-- there is no hook to record ABANDONED from); and PluginManager's stop-at-first-non-None
dispatch, where an earlier-registered THIRD-PARTY plugin overriding the result prevents this
plugin's own callback -- and so its outcome close-out -- from ever running. Its module
docstring now also documents duration_ms as an observation window (check() to whichever
callback fires), not a body-execution timer, matching Guard.record_outcome's own contract.
Round 3 correction (Codex review, finding 3): the three documented gaps above are real
on pinned 2.7.1, and documenting them did not make Capture.FRAMEWORK_POST_HOOK an honest
claim for every call -- the plugin still promised it unconditionally. Per the execution-
binding spec's own governing principle -- an honest unobserved beats a promised outcome that
can be lost -- FRAMEWORK_POST_HOOK is now OPT-IN, exactly like the round 2 fix already
applied to adapters.crewai: DelegationGuardPlugin(..., strict_single_hook=True) is an
explicit attestation that this plugin is registered first (or alone) for tool callbacks on
the App/Runner, and that no agent in the tree substitutes a response via a canonical
before_tool_callback=. The DEFAULT (strict_single_hook=False) never passes capture/
authorized_params to guard.check() at all -- a v2 chain's allow is the Guard's own
default Capture.PRE_HOOK_ONLY, and _pending_outcomes is never populated. Also fixed, in
strict mode: the module docstring claimed the pending entry "holds a strong reference to
tool_context for its whole span", but _PendingOutcome never actually stored the object --
only id(tool_context) was kept, as the dict key, which nothing referenced the object back
from. _PendingOutcome now carries a tool_context: ToolContext field so the pinned-alive
claim is genuinely true, not merely asserted in a comment.
- adapters.haystack: a delegation ToolPolicy that declares BOTH a real scope and
delegates_to/grant (unusual, but the dataclass allows it) calls guard.check() for itself
first, exactly like any other guarded tool -- but _ToolGuard.scope() discarded that
already-registered pending outcome when building the delegation's child scope, leaving the
call_id pending forever and wedging complete(). Now carried through unchanged; the existing
test only exercised UNGUARDED (scope=None) delegation, which never hit this branch.
- adapters.pydantic_ai: DelegationGuard.before_tool_execute/wrap_tool_execute correlation
was ordering-dependent when other capabilities are also registered on the same agent --
CombinedCapability composes before_tool_execute sequentially in the capabilities' LISTED
order and wrap_tool_execute as nested middleware in that same order, so a capability
positioned "after" DelegationGuard could raise once this one had already stashed a pending
outcome (leaking it), or wrap the raw tool body such that DelegationGuard's own wrap_tool_
execute was catching an INNER capability's failure and misreporting it as BodyState.RAISED
for a body that never ran. DelegationGuard.get_ordering() now declares position="innermost"
(pydantic-ai's own CapabilityOrdering, topologically sorted regardless of listed order):
every OTHER capability's before_tool_execute now runs first (so if one raises, this one's own
before_tool_execute -- and its pending-outcome stash -- never runs either, and nothing leaks),
and the handler this capability's wrap_tool_execute receives is always the raw tool
invocation, never another capability's own wrapping. The docstring also now explicitly warns
against using both DelegationGuard and GuardedToolset on the same tool (two independent,
complete authorization paths -- two allow/outcome pairs on the ledger for one body).
Round 2 correction (Codex review, finding 5): innermost is a TIER, not a unique position
-- pydantic-ai 2.31.1's sorter places every innermost capability after every non-innermost
one, but preserves LISTED order among MULTIPLE innermost capabilities, so
[DelegationGuard, OtherInnermost] left DelegationGuard authorizing first and wrapping
around OtherInnermost's own middleware -- reproducing both the leaked-pending-entry and the
false-RAISED defects. Fixed structurally, not by ordering alone: authorization and outcome-
recording collapsed into ONE operation, entirely inside wrap_tool_execute -- there is no
before_tool_execute override, and no _pending map, at all any more. If some OTHER
capability's before_tool_execute (or an outer wrap_tool_execute) raises before this one's
own wrap_tool_execute is ever reached, guard.check() simply never ran either -- no allow,
no leak, nothing false. The residual (a SECOND innermost-positioned capability whose own
wrap_tool_execute raises before calling its own handler, which this one's handler would
then be) is documented in the class docstring as a genuine limit of pydantic-ai's own ordering
guarantee -- wraps/wrapped_by reference specific other capability types/instances this
file cannot know in advance for an arbitrary caller-supplied capability. Also: DelegationGuard
+ GuardedToolset dual instrumentation is now REJECTED (not just documented) at AGENT
CONSTRUCTION time via DelegationGuard.for_agent(), which walks agent.toolsets (unwrapping
WrapperToolset chains) for a GuardedToolset instance -- undetectable only for a
GuardedToolset built and used entirely dynamically, never listed in agent.toolsets at all.
Round 3 correction (Codex review, finding 3): the SECOND-innermost-capability residual
documented (not fixed) in round 2 above was real, and documenting it did not stop it: a live
probe against pinned 2.31.1 confirmed a sibling innermost capability's own pre-handler
failure gets misreported here as BodyState.RAISED for a body DelegationGuard never
reached (the raw body's own side-effect sink stayed empty). for_agent() now ALSO rejects
this combination at agent construction time, the same way it already rejects DelegationGuard
+ GuardedToolset: it walks agent.root_capability.capabilities (populated for every sibling
by pydantic-ai's own two-phase bind_capabilities_tier, verified directly against pinned
2.31.1) for any OTHER capability whose get_ordering().position == "innermost" AND that
overrides wrap_tool_execute (the same type(x).method is not Base.method idiom pydantic-ai
uses internally for its own _has_wrap_node_run), and raises UserError naming it -- for
EITHER list order, since pinned 2.31.1's innermost tier has no ordering edges among its own
members, only list order as a tiebreaker. Undetectable only for a capability added entirely
dynamically, per-run (for_run(), never declared in the agent's own capabilities=[...]) --
the same category of limit as the dynamic-GuardedToolset case, documented on for_agent().
Round 4 correction (Codex review, finding 2, high): the round-3 construction-time check
is a fast path, not the guarantee it was treated as. Pinned pydantic-ai 2.31.1 binds the
innermost tier through ONE list comprehension and does not update agent.root_capability
until the WHOLE call returns, so a sibling whose own for_agent() REBINDS to a replacement
that wraps execution (its originally-registered instance did not) is invisible to
for_agent() -- a live probe confirmed construction succeeds in both list orders, and the
adverse order still reaches the misreported-RAISED defect. There is also a public per-run
bypass this hook cannot see at all: agent.run(..., capabilities=[OtherInnermostWrapper()])
adds capabilities AFTER static for_agent() has already run; that repro likewise recorded a
false RAISED with the raw body sink empty. Fixed: wrap_tool_execute now runs the SAME
conflict check again, at the very start of every call, against the ACTUAL resolved
ctx.root_capability (RunContext.root_capability -- pydantic-ai's own documented mechanism
for validating per-run additions, confirmed by a live probe to reflect both adversarial cases
correctly) -- BEFORE _resolve() or guard.check() ever run, so a rejection writes nothing
to the ledger. for_agent()'s construction-time check stays as the friendly, early-failing
fast path for the common case it CAN see; wrap_tool_execute's runtime check is the real
guarantee. One structural asymmetry surfaced while testing this: in the list order where the
conflicting sibling is OUTER of DelegationGuard (not INNER, the shape the original defect
needs), DelegationGuard's own wrap_tool_execute -- and so its new runtime check -- never
even runs, because the outer sibling raises before ever calling its own handler; the raw
exception from the sibling propagates untouched instead of DelegationGuard's UserError,
but the ledger is still untouched either way, since DelegationGuard's own code never ran.
- adapters.autogen: GuardedWorkbench.call_tool_stream recorded no outcome at all when a
consumer closed the stream early (one event consumed, then .aclose()/GC) -- GeneratorExit,
raised inside the generator at its suspended yield, is a BaseException, bypassing the
except Exception/else split entirely, despite the call's allow advertising
Capture.WRAPPER_ASYNC (a promise this adapter CAN keep here, since it does observe the
closure). Added an except GeneratorExit arm recording BodyState.ABANDONED before
re-raising (required by Python's own generator protocol). Also fixes the snapshot fallback
(finding 7), matching the other five adapters.
- Two machine-specific path leaks, release-gate finding 3. tools/render_demo_gif.py's
ffmpeg discovery fell back to a hardcoded absolute path to a Homebrew-installed binary when
shutil.which("ffmpeg") came up empty -- shutil.which already checks every directory on
PATH, including a Homebrew bin dir when it is actually on PATH, so the hardcoded fallback only
ever helped on one specific machine's non-PATH install while leaking that machine's own
layout into the repo; removed, with shutil.which's own result used directly (and its
possible None handled explicitly, which the old code did not do either).
examples/integrations/claude_sdk/live_smoke.py had a code comment naming the user's home
directory's Claude settings path by its shorthand notation; reworded to describe the same
SDK-isolation behavior (setting_sources=[]) without a path-shaped string. Semantic path
fixtures used as test data elsewhere in the tree (/tmp, /etc, /usr/bin, /opt/homebrew)
and file shebangs are unaffected -- they are not machine-specific leakage.
Added
- Execution binding (
record_outcome, 0.9.0) wired into six more adapters, on aschema_version=2chain (unchanged, byte-and-type identical to before, onschema_version=1), each choosing the most honest capture its framework's real hook surface supports: adapters.crewai:Capture.FRAMEWORK_POST_HOOK, OPT-IN viaCrewAIGuardBridge(..., strict_single_hook=True)(see the "Fixed" section's round 2 entry) -- the bridge never calls the tool body itself; in strict mode the outcome is closed out from CrewAI's ownafter_tool_callpost hook, which fires for every dispatch path including a blocked call.BodyState.RAISEDis never reported: CrewAI'sToolUsage.use/ausecatches every tool exception internally and turns it into a formatted string before the post hook ever runs, so a raise and an ordinary return are indistinguishable at the one hook point this adapter has.duration_msis an observation window (before-hook to after-hook), documented as such, not a body-only timer. The default (strict_single_hook=False) isCapture.PRE_HOOK_ONLYwith no outcome ever recorded.adapters.openai_agents:Capture.WRAPPER_ASYNC, OPT-IN viaguarded_tool(..., registry=...)--registry.root_guard.schema_versionis checked once, at build time (a whole-chain property), to decide whether to replace the tool'son_invoke_toolwith a wrapper that calls the original directly and observes completion itself, exactly likeadapters.langgraph's reference wiring.BodyState.RAISED(witherror_code) is reached only when the wrapped tool's ownon_invoke_toolgenuinely lets the exception through (e.g.failure_error_function=None); the SDK's defaultfailure_error_functionstill catches it first and returns an error string, so the honest result there isRETURNED, same as CrewAI.BodyState.ABANDONEDonasyncio.CancelledErroris reliably reached either way (cancellation is aBaseException, not caught by the SDK's ownexcept Exception). A latertool_input_guardrailsentry (not this adapter's own) rejecting the call after this one authorized it meanson_invoke_tool-- ours or the original -- is simply never called, so nothing is fabricated for a body that never ran.guarded_agent_tool()'s delegation-scope check andguarded_handoff()/DelegationGuardHooksmint viaGuard.delegate(), not a tool body, so they stay the library's defaultpre_hook_only.adapters.google_adk:Capture.FRAMEWORK_POST_HOOK, OPT-IN viaDelegationGuardPlugin(..., strict_single_hook=True)(see the "Fixed" section's round 3 entry) -- the plugin never calls the tool body itself; in strict mode the outcome is closed out from ADK's ownafter_tool_callback/on_tool_error_callback, and (unlike CrewAI and the OpenAI Agents SDK) ADK does NOT swallow a tool's exception before its error hook runs, so this is the one adapter of the six that genuinely observes and reportsBodyState.RAISED(witherror_code) for calls whose error hook fires. The two hooks correlate their pending state with_authorize()'scheck()viaid(tool_context), and (strict mode) the pending entry itself holds a strong reference to that sametool_contextobject. Applies uniformly to both the tool check and the delegation-scope check (delegation_scope=...), since both go through_authorize()and both are real ADK tool calls with the same before/after/error lifecycle. The default (strict_single_hook=False) isCapture.PRE_HOOK_ONLYwith no outcome ever recorded. See the "Fixed" section above for three documented, structural gaps in ADK's own callback surface that strict mode's observation still cannot guarantee around.adapters.pydantic_ai:Capture.WRAPPER_ASYNCat BOTH hook points -- unlike the framework-post-hook adapters above, this one calls the tool body itself and awaits it, likeadapters.langgraph's reference wiring.DelegationGuardauthorizes inbefore_tool_execute(unchanged shape onschema_version=1) and closes the outcome out inAbstractCapability.wrap_tool_execute, correlated byid(call)(the sameToolCallPartflows through both for one call).GuardedToolset.call_toolneeds no such correlation -- it already callsself.wrapped.call_tool(...)directly, so authorization and capture live in one method. Both genuinely observe and reportBodyState.RAISED(pydantic-ai does not swallow a tool's exception before either hook runs) andBodyState.ABANDONEDonasyncio.CancelledError.adapters.haystack:Capture.WRAPPER_SYNC/Capture.WRAPPER_ASYNCon hook 2 (guard_tool/guard_tools) --_Guarded.invoke/invoke_asynccallsuper().invoke/invoke_asyncthemselves, exactly likeadapters.langgraph's reference wiring. Haystack'sTool.invoke/invoke_asyncre-raise the body's own exception as aToolInvocationErrorwith the original set as__cause__, which this adapter unwraps for an honesterror_code(_underlying_error_code) rather than reportingToolInvocationErroritself, an artifact of Haystack's own plumbing. A delegation tool mints viaguard.delegate(), never callsguard.check()for itself, so it never binds an outcome. Hook 3 (AttenuationStrategy) is NOT wrapped -- it can veto a pending call but never touches the tool body, which the framework calls afterward entirely outside the hook -- so its checks stay the library's defaultpre_hook_only, unchanged.adapters.autogen:Capture.WRAPPER_ASYNConGuardedWorkbench.call_tool/call_tool_stream, which callsuper().call_tool(...)/super().call_tool_stream(...)themselves, like the langgraph reference wiring. Unlike every other adapter, a delegation-marked tool (policy.delegates_to, the agents-as-tools pattern) STILL gets execution binding here: its body (the nested run) genuinely executes through this same call, unlike aSwarmhandoff (GuardedHandoff), which AutoGen runs entirely outside the workbench and so never callsguard.check()at all.BodyState.RAISEDis never reported: AutoGen's ownStaticWorkbench.call_tool/StaticStreamWorkbench.call_tool_streamalready catch every tool exception and return/yield aToolResult(is_error=True)instead of letting it propagate, so a raise and an ordinary return are the same shape by the time this wrapper resumes -- every completed call isBodyState.RETURNED.asyncio.CancelledErroris NOT caught by AutoGen's ownexcept Exception, so it DOES propagate;BodyState.ABANDONEDis genuinely reachable and is still re-raised.- Execution binding (
record_outcome, 0.9.0) wired intoadapters.langchain(LangGraph 1.x / LangChain 1.xcreate_agent/ deepagents), on aschema_version=2chain (unchanged, byte- and-type identical to before, onschema_version=1):Capture.WRAPPER_SYNC/WRAPPER_ASYNCfromGuardedDelegation.wrap_tool_call/awrap_tool_callrespectively -- both call the tool body (handler(request)) themselves, exactly likeadapters.langgraph's reference wiring.authorized_params/invoked_paramsare one immutable snapshot (_freeze(), never a copy protocol) taken beforehandlerruns.BodyState.RAISED/ABANDONED(onasyncio.CancelledError, re-raised) are both genuinely observed on the async path; no shared, cross-call correlation state exists at all (unlike the hook-based adapters above), since the decision/snapshot travel through the call stack in a local_Gate, not a dict keyed by object identity -- so same-tool-concurrency and later-hook-block classes of defect are structurally inapplicable here. A delegation tool call (task) mints the child viaguard.delegate(), which never callsguard.check()at all -- noDecision/call_idexists to bind an outcome to, so it is unaffected by any of this. Round 2 correction (Codex review, batch 2, finding 1): the original claim thathandler(request)"genuinely observes completion... with no cross-hook honesty caveat" was wrong for thecreate_agent(middleware=[...])entry point. Verified directly against pinnedlangchain.agents.factory._chain_tool_call_wrappers:ToolNode(wrap_tool_call=...)accepts exactly ONE wrapper sohandlerIS genuinely the raw body on that path, butcreate_agentcomposes EVERY registered middleware'swrap_tool_callinto one chain ("first = outermost"), and LangChain ships middleware (tool_retry.py,tool_emulator.py) explicitly designed to call the inner handler zero, one, or several times.Capture.WRAPPER_SYNC/WRAPPER_ASYNCis now OPT-IN viaGuardedDelegation(..., strict_single_hook=True), an attestation that this adapter is the ONLYwrap_tool_call-implementing middleware in use (true by construction on theToolNodepath). The default (strict_single_hook=False) isCapture.PRE_HOOK_ONLYwith no outcome ever recorded, safe regardless of what else is composed. - Execution binding wired into
adapters.llama_index(LlamaIndexAgentWorkflow/FunctionAgent), same terms:Capture.WRAPPER_ASYNCfromguarded_tool()'s_guarded()wrapper, whichawaits the target itself.authorized_params/invoked_paramsare one_freeze()snapshot of the ORIGINAL model-supplied kwargs, taken before the framework's ownctxargument is injected and before the target runs.BodyState.RAISED/ABANDONED(onasyncio.CancelledError, re-raised) are both genuinely observed. The handoff tool (_guarded_handoff) mints the child viaparent.delegate(...), which never callsguard.check()-- unaffected, on any schema version, same as the delegation tool above. - Execution binding wired into
adapters.smolagents, same terms:Capture.WRAPPER_SYNCfromGuardedTool.forward(), which calls the inner tool (self.inner(*args, **kwargs)) itself -- smolagents only ever callsforwardsynchronously, so there is no async variant to wire._freeze()snapshot of{"args": [...], "kwargs": {...}}, taken before the inner tool runs.BodyState.RAISED(witherror_code) is genuinely observed -- smolagents does not swallow a tool's exception beforeforward's own caller sees it.DelegatedAgent.mint()mints the child viaparent_guard.delegate(...), which never callsguard.check()-- unaffected. - Round 2 correction (Codex review, batch 2, finding 5): on a clean return, this adapter
recorded
BodyState.RETURNEDunconditionally, without checking what the inner tool's own return value actually was. Wrong when the inner tool's ownforward()implementation is a generator function (usesyield): pinned smolagents 1.26.0'sTool.__call__returns unknown result types unchanged, so calling a generator function returns a generator OBJECT immediately, with none of its body executed yet -- ordinary Python generator semantics, nothing smolagents-specific, and this wrapper has no way to know whether or when it will ever be iterated. Added the shared_is_deferred_result/_body_state_forpattern every other adapter in this package already uses for its own generator/streaming case (return inspect.isgenerator(result) or inspect.isasyncgen(result), matchingadapters.haystack/pydantic_ai's simpler sync-appropriate form rather than the fuller async-Future-checking one, since smolagents genuinely has no async entry point here).GuardedTool.forward()'s finalrecord_outcome()now reads_body_state_for(result)instead of a hardcodedBodyState.RETURNED. Test added:test_v2_a_tool_returning_a_generator_records_a_deferred_ outcome-- aStreamingCrmQuerytool whoseforward()is a generator function, driven directly throughGuardedTool(the same direct-construction patterntest_metered_passthrough_under_strict_meteringalready uses); asserts the returned value is a live, unconsumed generator, the tool's own side effect has NOT happened yet, and the recordedbody_stateisDEFERRED, notRETURNED. - Round 3 correction (a parallel adversarial review):
_is_deferred_result()checkedisgenerator()/isasyncgen()but not a coroutine -- the same class of gap, just for a tool whose ownforward()implementation isasync defrather than a generator function. Calling it plainly (self.inner(*args, **kwargs), noawaitanywhere in this synchronous wrapper) returns a coroutine object with none of its body run yet;BodyState.RETURNEDwould be exactly as much of a lie as in the generator case. Currently UNREACHABLE through pinned smolagents' own sync-tool contract (nothing in this adapter ever awaits the result, so a caller relying on this adapter alone would never see the gap trigger), but a caller-suppliedToolsubclass is free to define an asyncforwardregardless of what smolagents itself calls for -- closing it costs one line and removes the gap before it can ever surface, rather than leaving it live on an unstated assumption about what callers will or won't hand this adapter. Addedinspect.isawaitable(result)to_is_deferred_result()-- a strict superset ofiscoroutine()that also covers Future-like awaitables and generator-based coroutines with the one check; the "no async entry point" reasoning in the Round 2 bullet above justified skipping this check, which was too narrow -- the WRAPPER has no async entry point, but a tool's own implementation is not constrained by that. Test added:test_v2_a_tool_returning_a_coroutine_records_a_deferred_outcome-- anAsyncCrmQuerytool whoseforward()isasync def, driven the same way as the generator test; asserts the returned value is a live, un-awaited coroutine, the tool's own side effect has NOT happened yet, and the recordedbody_stateisDEFERRED. - Round 4 correction (Codex re-pass on the batch):
isawaitable()(Round 3, above) covers coroutines andasyncio.Future(which implements__await__) but deliberately NOTconcurrent.futures.Future-- a thread-pool future a syncforward()could hand back after merely SUBMITTING work, without waiting for it. Verified directly before writing the fix:inspect.isawaitable(concurrent.futures.Future())isFalse-- confirming the gap was real, not assumed. Added an explicitisinstance(result, (asyncio.Future, concurrent.futures.Future))check alongsideisawaitable()(theasyncio.Futurehalf is redundant withisawaitable()but kept for parity with every other adapter's own "fuller"_is_deferred_result()form)._is_deferred_result()now covers the complete lazy-result family this package checks anywhere: generators, async generators, awaitables (coroutines included), and both Future families. Two tests added:test_v2_a_tool_returning_an_asyncio_future_records_a_deferred_outcomeandtest_v2_a_tool_returning_a_concurrent_futures_future_records_a_deferred_outcome-- the latter assertsnot inspect.isawaitable(result)first, so the test is only meaningful ifisawaitable()genuinely misses the type it is meant to catch. - Execution binding wired into
adapters.strands(AWS Strands Agents), OPT-IN viaDelegationGuard(..., strict_single_hook=True): this adapter never calls the tool body itself, but pinned strands-agents 1.52.x'sAfterToolCallEventis an unusually good hook surface --HookRegistry.invoke_callbacks/_asyncruns every registered callback unconditionally (unlike Google ADK's plugin manager, no other hook can prevent this one's ownafter_tool_callfrom running), andtool_use["toolUseId"]is a genuinely UNIQUE identifier per dispatch (not an object-identity collision risk CrewAI-style).Capture. FRAMEWORK_POST_HOOKcloses out fromAfterToolCallEvent.exception/cancel_message--BodyState.ABANDONED(noerror_code) for a third-party veto after this adapter's own allow (this adapter's own denial never stashes a pending entry, so a latercancel_messagecan only mean that),BodyState.RAISED(witherror_code) otherwise. The default (strict_single_hook=False) isCapture.PRE_HOOK_ONLYwith no outcome ever recorded -- despite the strong hook surface, still opt-in, because pinned 1.52.x's retry mechanism (AfterToolCallEvent.retry) genuinely can discard an already-recorded outcome, and a tool-originated interrupt skipsAfterToolCallEvententirely; both are documented as strict- mode residuals in the module docstring rather than silently promised away. - Round 2 correction (Codex review, batch 2, finding 6): "
HookRegistry.invoke_callbacks/_asyncruns every registered callback unconditionally" was read, correctly, as claimingAfterToolCallEventfires unconditionally, full stop -- verified against pinned 1.52.x source to be FALSE. That claim was only ever true on the axis theAfterToolCallEventdocstring quote actually describes (the tool BODY's own success/failure/cancellation); it says nothing about whether the event is DISPATCHED AT ALL, which depends on the BEFORE-hook phase completing without incident. Three lost-terminal paths exist, verified directly againststrands/tools/executors/_executor.py'sToolExecutor.streamandstrands/hooks/registry.py'sHookRegistry.invoke_callbacks_async, each reproduced with a throwawayHookProvidersibling standalone before being written up: (1) a LATER-registeredbefore_tool_callhook raisesInterruptException--invoke_callbacks_async's per-callback loop catches onlyInterruptException, converting it into the returnedinterruptslist;stream()short-circuits toToolInterruptEvent+returnBEFORE its own innertry:that would call_invoke_after_tool_call_hookis ever entered, so this adapter's already-stashed pending entry is never popped -- reproduced directly (agent(...)returns normally,Guard.complete()reportscompleted=Falsewith that call'scall_idstill pending), and UNLIKE the tool-originated-interrupt case, not reliably self-healing on resume either (self._pendingis keyed bytoolUseId; a fresh allow on retry OVERWRITES the same key, silently orphaning the firstcall_idforever). (2) An ORDINARY (non-Interrupt Exception) exception from abefore_tool_callhook registered to run AFTER this adapter's own --invoke_callbacks_async's loop does not catch a plain exception at all, so it propagates out ofToolExecutor.stream()itself as an unhandled exception (that call is BEFOREstream()'s own innertry/except); reproduced directly:agent(...)raisesEventLoopException, and the pending entry is wedged exactly as in (1). (3) The SAME exception from a hook registered to run BEFORE this adapter's own -- the per-callback loop stops at the first uncaught exception, so this adapter's ownbefore_tool_call/evaluate_tool_callnever runs for that call at all: noguard.check(), no allow/deny logged, no pending entry created; reproduced directly: fail-safe for authorization (the tool body does not run either, since the same exception aborts the whole dispatch before the tool-execution stage), but the attempt leaves NO record in this adapter's ledger at all, not even a denial. A fourth, narrower risk found during the same verification pass but not one of the three named:AfterToolCallEventDOES useshould_reverse_callbacks = True, so an ordinary exception from a siblingafter_tool_callcallback that runs EARLIER than this adapter's own (by virtue of being registered LATER) can wedge this adapter's ownafter_tool_callby the identical uncaught-exception mechanism, one hook-type over -- documented, not given a separate test, since it is structurally identical to path (2). Considered and rejected: whether strict mode should fail closed on a detectable interrupt. It should not -- none of these three paths are an authorization gap (guard.check()'sallow/denyDecisionis already correctly committed before any of this can happen; what is lost is only the later outcome-observation, an audit-completeness concern, not an enforcement bypass), and there is no hook this adapter can install to detect "a sibling before-hook is about to raise" ahead of time to act on.Guard.complete()/the offline verifier already surface a wedgedcall_idhonestly as incomplete, never as a fabricated success -- the same posture already established for the tool-originated-interrupt case. Rewrote the module docstring's "EXECUTION BINDING" intro with this correction and all three paths; no code change was needed (the underlyingstrict_single_hookmechanism andafter_tool_call's own handling were already correct -- this was a documentation-accuracy finding, not a logic bug). Tests added intests/integrations/test_strands.py:test_v2_strict_mode_a_later_before_hook_interrupt_wedges_the_pending_entry,test_v2_strict_mode_a_later_before_hook_ordinary_exception_wedges_the_pending_entry,test_v2_strict_mode_an_earlier_before_hook_exception_means_this_adapter_never_ran-- each reproduces its path with a throwaway siblingHookProvider, verified standalone before being written up as a permanent regression test. - Execution binding wired into
adapters.camel(CAMEL-AI), same terms asadapters.langchain/llama_index/smolagents:Capture.WRAPPER_SYNC/WRAPPER_ASYNCfromGuardedFunctionTool. __call__/async_call, which call the inner tool themselves._freeze()snapshot of{"args": [...], "kwargs": {...}}, taken before the inner tool runs.BodyState.RAISED(witherror_code) is genuinely observed on both paths;asyncio.CancelledErroron the async path isBodyState.ABANDONED, still re-raised.GuardedAgentToolkit.mint()goes throughparent_guard.enforce(...), which never returns aDecision/call_id-- delegation is unaffected by any of this. Also fixed, unrelated to execution binding: thecamelextra'smcp<3pin was stale -- camel-ai 0.2.90 importsmcp.server.FastMCP, renamed toMCPServerin mcp 2.x (mcp's own migration guide recommendsmcp<2), sopip install 'attenu-guard[camel]'was broken on a clean install; corrected tomcp<2. - Execution binding wired into
adapters.agno:Capture.WRAPPER_SYNC/WRAPPER_ASYNCfromguarded_tool_hook/aguarded_tool_hook, which callfunction_call(**arguments)/await function_call(**arguments)themselves._freeze()snapshot of the model-suppliedarguments, taken beforefunction_callruns.BodyState.RAISED(witherror_code) is genuinely observed on both paths when the attestation below holds;asyncio.CancelledErroron the async path isBodyState.ABANDONED, still re-raised.authorize()(shared by both hook flavours) returns(guard, call_id, snapshot)instead ofNone, threadingcapture=through from each hook since they share one authorization function.delegation_tool_hook/adelegation_tool_hookmint viaparent.delegate(...), which never callsguard.check()-- unaffected. Round 2 correction (Codex review, batch 2, finding 1): the original claim that a hook never callingfunction_call"prevents the body from running at all" was true but incomplete -- it did not establish thatfunction_callgenuinely IS the body when it IS called. Verified directly against pinned agno 2.9'sFunctionCall._build_nested_execution_chain:Agent(tool_hooks=[...])is a list, folded into ONE nested chain, sofunction_callthis hook receives can be ANOTHER listed hook's own wrapper, not the real entrypoint; AND, even when this hook is the only/innermost one, Agno's ownexecute_entrypointcan itself return a CACHED result (cache_results=True) without calling the real function at all -- not a sibling hook, baked into the same dispatch.Capture.WRAPPER_SYNC/WRAPPER_ASYNCis now OPT-IN viaguarded_tool_hook(..., strict_single_hook=True)(andaguarded_tool_hook's identical kwarg), an attestation that this hook is the ONLY entry intool_hooks=[...]AND that none of the guarded tools declarecache_results=True. The default (strict_single_hook=False) isCapture.PRE_HOOK_ONLYwith no outcome ever recorded, safe regardless of either. - Execution binding wired into
adapters.ag2(the AutoGen fork):Capture.WRAPPER_ASYNCfrom_Gate.run, which awaitscall_next(event, context)itself._freeze()snapshot ofevent.serialized_arguments, taken at authorization time beforecall_nextruns.BodyState.RAISEDis read from a genuinely honest, TYPED signal here: pinned ag2 1.0.2'sFunctionTool.__call__catches every tool-body exception ITSELF and returns aToolErrorEventcarrying the original.error: Exception-- it never lets the exception propagate as a raised Python exception throughcall_next's own return (unlike CrewAI/ AutoGen, which swallow the distinction into an indistinguishable string), soisinstance(result, ToolErrorEvent)+type(result.error).__name__is read straight off the framework's own typed result, not inferred. Documented honesty note: aToolErrorEventcould, in principle, come from a different middleware ahead of this one in the chain rather than the tool body itself, though not in this module's own prescribed single-DelegationGuard-per- agent usage.asyncio.CancelledErroron the wrapper's ownawaitisBodyState.ABANDONED, still re-raised. AG2 has no separate delegation callback -- every hand-off IS a regular tool call authorized through this SAME path via its ownToolPolicy(scope=...), so a delegation tool call gets exactly the same capture/outcome treatment as any other allowed call; only the internalregistry.delegate(...)mint step (inside the sameauthorize()call, after the scope check passes) adds no second, separate check/outcome of its own. - Round 2 correction (Codex review, batch 2, finding 1): the claim above --
Capture.WRAPPER_ASYNCas an unconditional, always-genuine observation -- was wrong. Pinned ag2 1.0.2'sFunctionTool.register()folds an ORDERED LIST of middleware into ONE composed chain around the tool body, at TWO independent composition points: agent-level (Agent(middleware=[...])) and, separately, tool-level (FunctionTool.with_middleware(...)/Toolkit(middleware=[...]), reversed, so the LAST-listed hook ends up innermost).ag2/middleware/builtin/ships real stackable middleware (llm_retry.py,token_limiter.py,approval.py,logging.py,metrics.py,telemetry.py,history_limiter.py), so a sibling at either point is not hypothetical. Addedstrict_single_hook: bool = Falseto_Gate,DelegationGuard,guard_middleware(),guard_tool_hook()andguarded_tools(). Default:Capture.PRE_HOOK_ONLY, norecord_outcome()ever, safe regardless of what any sibling at either composition point does.strict_single_hook=True: an explicit, scoped attestation that this gate is the sole middleware at ITS composition point, unlockingCapture.WRAPPER_ASYNC-- this package cannot verify the attestation itself (ag2 exposes no construction-time roster the waypydantic-ai'sfor_agent()does for batch 1's equivalent detect-and-refuse pattern). Tests added, verified against ag2's OWN_wrap_middlewarecomposition primitive (not a hand-rolled stand-in): default-mode-honest; both-order short-circuit (guard_outerrecords a falseRETURNED,sibling_outeris never reached); guard-outer with a sibling retrying the real body underneath it (empirically confirmed first, per this project's own "verify, don't assume" discipline: the real body runs twice, this gate records exactly one honestRETURNEDfor the final attempt, silently under-reporting the retry). - Round 3 correction (a parallel adversarial review): the agent-level ordering claim
just above -- "LAST-listed
on_tool_executionends up outermost" -- was backwards. That claim testedFunctionTool.register()'s own_wrap_middlewareloop IN ISOLATION, against a hand-built list, never going throughagent.py's own turn setup at all; that setup REVERSESAgent(middleware=[...])'s user-facing list before it ever reachesregister()(~agent.py:1362-1366:for m in reversed(tuple(chain(self._middleware, additional_middleware))): middleware_instances.append(mw)). The two reversals compose: at the USER-FACINGAgent(middleware=[...])level, the FIRST-listed middleware ends up OUTERMOST, the LAST-listed innermost -- the opposite of the isolated-primitive test's conclusion, now confirmed end-to-end through a realAgent(middleware=[A, B]dispatchesA-enter, B-enter, body, B-exit, A-exit). The tool-level claim (reversed, so the LAST-listed hook ends up innermost, viaFunctionTool.with_middleware(...)/Toolkit(middleware=[...])) was independently re-verified and is correct as written -- only the agent-level ordering direction was wrong. Safety is unaffected: the residual behaviors themselves (a falseRETURNED, an under-reported retry, safety when inner) are properties of WHICH PHYSICAL POSITION in the composed chain a hook occupies, not of how a caller's list order maps to that position, and the existing finding-1 tests construct the composed chain directly via_wrap_middlewarerather than asserting anything aboutAgent(middleware=[...])'s own list-order-to-position mapping -- so no finding-1 test or code-logic change was needed, only the module docstring's and this CHANGELOG entry's prose. - Round 4 (Codex re-pass, low): Codex required the end-to-end ordering claim itself be
committed as a test, not asserted in prose alone against a probe run once and discarded.
Added
test_agent_middleware_first_listed_is_outermost_end_to_endintests/integrations/test_ag2.py: a realAgent, a realTestConfig-scripted tool call, and two realBaseMiddlewaresubclasses (the factory shapeAgent(middleware=[...])actually expects) recording their own entry/exit order -- asserts["A-enter", "B-enter", "body", "B-exit", "A-exit"]formiddleware=[A, B], turning the exact probe that produced the Round 3 correction into a permanent regression test. - Execution binding wired into
adapters.agent_framework(Microsoft Agent Framework):Capture.WRAPPER_ASYNCfromDelegationGuard.process, which awaitscall_next()itself._freeze()snapshot of the tool call's arguments, taken at authorization time beforecall_next()runs.BodyState.RAISEDis genuinely observed via a real raised Python exception -- verified directly against pinned 1.15.x source (_tools.py/_middleware.py) that a tool-body exception propagates all the way throughFunctionMiddlewarePipeline. execute'sfinal_wrapperand every enclosingmiddleware.process's owncall_next(), including this one; the conversion into a tool-error result (cited in the module docstring's "DENIAL SHAPE") happens in anexcept ExceptionABOVE the whole middleware pipeline, not inside it, so this adapter's owntry/exceptaroundawait call_next()sees the raise first, same shape asadapters.langgraph's reference wiring (unlikeadapters.ag2's typed- event signal, or CrewAI/AutoGen's swallowed-into-a-string one).asyncio.CancelledErrorisBodyState.ABANDONED, still re-raised. Same asadapters.ag2: Agent Framework has no separate delegation callback -- every hand-off is a regular tool call authorized through this SAME path via its ownToolPolicy(scope=...), so a delegation tool call gets exactly the same capture/outcome treatment as any other allowed call; only the internalself._registry.delegate(...)mint step adds no second, separate check/outcome of its own. - Round 2 correction (Codex review, batch 2, finding 1): the claim above --
Capture.WRAPPER_ASYNCas an unconditional, always-genuine observation -- was wrong. Pinned 1.15.x'sFunctionMiddlewarePipeline.execute(_middleware.py:1126-1163) is a genuinely composable chain (verified empirically against the framework's own pipeline: index 0 runs first and is outermost), andAgent.middleware(_agents.py:468) is a plain MUTABLE list attribute, not a fixed roster resolved once at construction -- a caller can append or insert into it any time afterAgent(...)returns, and client-level function middleware (_tools.py:3165, already flagged in this module's own "KNOWN GAPS") is a separate, even-less-visible composition point this class cannot see at all. Addedstrict_single_hook: bool = FalsetoDelegationGuardandguarded_agent(). Default:Capture.PRE_HOOK_ONLY, norecord_outcome()ever, safe regardless of what else is on either middleware list, now or later.strict_single_hook=True: an explicit, scoped attestation that this guard is the ONLY function middleware that will ever run on this agent, for its entire lifetime -- unlocksCapture.WRAPPER_ASYNC. This package cannot verify the attestation itself (no construction-time roster to check the waypydantic-ai'sfor_agent()offers for batch 1's equivalent detect-and-refuse pattern). Tests added, verified against the framework's OWNFunctionMiddlewarePipeline(not a hand-rolled stand-in): default-mode-honest; both-order short-circuit (guard_outerrecords a falseRETURNED,sibling_outeris never reached); guard-outer with a sibling retrying the real body underneath it (empirically confirmed first, per this project's own "verify, don't assume" discipline: the real body runs twice, this guard records exactly one honestRETURNEDfor the final attempt, silently under-reporting the retry). - Execution binding wired into
adapters.a2a(the A2A protocol):Capture.WRAPPER_SYNC/Capture.WRAPPER_ASYNCfromguarded_tool()'s sync/async wrapper, which callsfn(*args, **kwargs)/awaits it itself, same shape asadapters.langgraph's reference wiring -- despite being grouped with the other hook-surface adapters up front, pinned-source inspection showed this is a genuine wrapper, not a hook, so no mode split was needed._freeze()snapshot of{"args": ..., "kwargs": ...}, taken at authorization time before the call.BodyState.RAISEDis a real raised Python exception (fnis called directly, nothing in this module's own path catches it first).asyncio.CancelledErroron the async wrapper's ownawaitisBodyState.ABANDONED, still re-raised. The separate delegation/hop machinery (DelegationInterceptor/delegating_guard_forclient-side,GuardedAgentExecutorserver-side) is a cross-process protocol boundary that never callsguard.check()and stays unaffected -- verified directly, not assumed, after the ag2/agent_framework rounds' reminder that "delegation is unaffected" needs checking per framework, not inherited.guarded_tool()'s internal_check()now callsguard.check()directly (raisingAuthorityDenied(decision)itself on a deny) instead of the oldguard.enforce(), which discarded theDecisionand itscall_id; behaviourally identical onschema_version=1. - Execution binding wired into
adapters.claude_sdk(Claude Agent SDK), OPT-IN viaDelegationGuardRegistry(..., strict_single_hook=True). Unlike every other adapter in this batch, the tool body here runs inside the Claude Code CLI -- a separate, closed-source Node.js process on the other side of a JSON control channel -- so this isCapture.FRAMEWORK_POST_HOOKfrom a THIRD hook, not a wrapper:PreToolUse(pre_tool_use) authorizes and stashes a pending outcome keyed bytool_use_id(the SDK's own documented, wire-protocol-guaranteed unique-per-call correlation key -- verified againstToolPermissionContext.tool_use_id's andPreToolUseHookInput/PostToolUseHookInput/PostToolUseFailureHookInput's field docstrings in pinned claude-agent-sdk 0.2.148'stypes.py, no collision machinery needed, same shape asadapters.strands'stoolUseId);PostToolUse/PostToolUseFailure(newly registered byhooks(), strict mode only) close it out.BodyState.RETURNEDfromPostToolUse;BodyState.RAISEDfromPostToolUseFailure, witherror_codeset to the CLI's own free-texterrorstring (single-lined) rather than a Python exception class name -- there usually is no Python exception object, since the tool ran across the process boundary, an explicitly documented deviation from every in-process adapter's convention;PostToolUseFailurewithis_interruptset isBodyState.ABANDONEDinstead (noerror_code, per the contract). The delegation tool call (Agent/Task) gets the same treatment as any other tool: its ownPostToolUsegenuinely fires when the whole subagent run completes, a real body-completion signal.can_use_tool-- the SDK's second, independent permission gate on the SAME callPreToolUsealready gated -- deliberately never participates in execution binding in either mode: only onePostToolUse/PostToolUseFailurecan ever fire pertool_use_id, so binding both call sites would either double-count one call as two ledger entries or leave oneDecisionpermanently orphaned in the pending set; binding only the primary enforcement point avoids both. Honesty note specific to this adapter: the "fires exactly once" guarantee is NOT independently verifiable from this package's Python source the way every other adapter's dispatch loop was -- it rests on the SDK's ownTypedDictfield documentation across a process boundary to a closed-source CLI, not on anything this module can read or exercise offline; documented prominently in the module docstring rather than assumed.duration_msis an observation window (PreToolUsehook seen toPostToolUse/PostToolUseFailurehook seen), not a body-only timer. - Round 2 corrections (Codex review, batch 2, findings 2/3/4): three related defects,
each verified directly against pinned 0.2.139 before being fixed. Finding 3:
authorize()used to computepolicy.context(tool_input)TWICE per call -- once (frozen) for theauthorized_paramscommitment, and again, independently, forguard.check()'s owncontext=argument -- so a non-purepolicy.context(ortool_inputmutated between the two calls by another concurrently-dispatched hook, which this module's own docstring already notes is possible) could commit something different from what was actually enforced; separately,policy.context(tool_input)is usually a narrow, policy-chosen PROJECTION, so any field oftool_inputthe policy did not extract was never committed at all -- contradictingGuard.check's own contract thatauthorized_params"is the exact tool-call JSON object presented at authorization time." Fixed:pre_tool_usenow freezes the COMPLETE, unmodifiedtool_inputexactly ONCE, before this module's own_tool_use_idinjection and beforepolicy.context()ever runs; that frozen snapshot, notpolicy.context(tool_input)'s projection, is what gets committed, andpolicy.context(tool_input)itself is now computed exactly once, purely as the enforcement argument. Finding 2: the recommended strict configuration (hooks=reg.hooks()ANDcan_use_tool=reg.can_use_tool) ranauthorize()TWICE for one physical tool call whenevercan_use_toolfired --PreToolUsewrote oneallow/Capture.FRAMEWORK_POST_HOOKentry,can_use_toolwrote a SECOND, independentallow/Capture.PRE_HOOK_ONLYentry for the SAME call. Fixed:pre_tool_usenow caches its own verdict for EVERY call (not only strict/bound ones), keyed by(agent_id, tool_use_id)-- the only two fieldsToolPermissionContextexposes tocan_use_tool;can_use_toolnow only ever REPLAYS that cached verdict and never callsauthorize()itself again, in any mode; a replay-miss (nohooks=wired alongsidecan_use_tool, a misconfiguration this module's own USAGE section never recommends) fails closed rather than silently allowing or resurrecting an independent decision path.- Round-2-re-pass correction (Codex, medium): that verdict cache
(
_recent_verdicts) grew without bound -- everyPreToolUsewith atool_use_idinserts, onlycan_use_toolremoves, and pinned 0.2.139's owncan_use_tooldocstring says it fires ONLY for the "ask" permission path, so an unclaimed entry is the COMMON case for any tool covered byallowed_tools/an allow rule (Codex repro: 100 non-ask calls -> 100 resident entries). Not an authorization gap -- a call reachingcan_use_toolalready passed its ownPreToolUsecheck, so a collision under memory pressure can only cause a safe FALSE denial, never an unauthorized allow -- but genuine unbounded resource growth over a long session. Fixed:_recent_verdictsis now anOrderedDictbounded bymax_recent_verdicts(constructor parameter, default 2048, mirroringSpoolSink.max_bytes's own bounded-with-a-counted-drop pattern insinks.py) -- the OLDEST entry is evicted once the cache would exceed the cap, counted inself.recent_verdicts_evicted, never silently. Replay-miss fail-closed is UNCHANGED: an evicted entry is indistinguishable from one never cached, socan_use_tooldenies it the same way.post_tool_use/post_tool_use_failurealso pop the entry as a courtesy cleanup when they fire (proof the call already ran, so any lingering verdict for it is provably stale) -- reduces eviction pressure in strict mode specifically (the only mode those hooks are ever registered for), but does NOT replace the bound, since neither post hook firing is guaranteed. Tests added:test_v2_recent_verdicts_cache_stays_bounded_under_sustained_non_ask_traffic(50 calls against a cap of 10 -> exactly 10 resident, 40 evicted, verified with a small explicit cap rather than the production default so the test is fast and deterministic);test_v2_can_use_tool_fails_closed_on_an_evicted_verdict(the evicted entry's own replay is denied, a SURVIVING entry still replays correctly);test_v2_strict_post_tool_use_cleans_up_the_recent_verdicts_entry_too. Finding 4:ToolPermissionContext.tool_use_id's own docstring guarantees uniqueness only "within the assistant message" -- NOT globally, so concurrent messages or concurrently-running subagents CAN collide -- but_pending_outcomeswas keyed by baretool_use_idalone, so a collision would silently overwrite an unclaimed entry, orphaning itscall_idforever. Fixed:_pending_outcomesis now keyed by(session_id, agent_id, tool_use_id)(all three available topre_tool_use,post_tool_useANDpost_tool_use_failurealike), andauthorize()fails closed -- mirroringadapters.crewai's own duplicate-live-key precedent -- on apre_tool_usecall whose key is already occupied by an unclaimed entry, beforeguard.check()ever runs for the new call, leaving the original entry untouched. This fail-closed treatment is deliberately NOT extended to the finding-2 verdict cache (_recent_verdicts, keyed by(agent_id, tool_use_id)only --can_use_toolhas nosession_id): that cache has no reliable release signal (can_use_toolonly fires for calls reaching the CLI's "ask" path, a minority in most configurations, so an unclaimed entry is the COMMON case, not evidence of a collision) -- treating every pre-existing entry there as a collision would misfire on ordinary usage, so it stays last-writer-wins, pop-on-read, documented as a residual. Tests added intests/integrations/test_claude_sdk.py:test_v2_strict_authorized_params_is_the_full_raw_tool_input_evaluated_once(verified against theparamsmodule's own publiccommit()/decode_salt()-- the same path an offline verifier would use, not a privateGuardinternal);test_v2_strict_authorize_ fails_closed_on_a_duplicate_live_correlation_key;test_can_use_tool_fails_closed_when_ no_pretooluse_verdict_was_cached. Six pre-existing tests updated: twocan_use_tooltests rewritten to drivepre_tool_usefirst (the replay-only design requires it), one corrected for a now-stale docstring claim ("cannot lazily mint a Guard" no longer applies oncecan_use_toolnever mints anything itself), thev2_post/v2_post_failuretest helpers given theagent_idtheir own payloads had always been missing (harmless before this round, since the old key was baretool_use_id; load-bearing now).
- Round-2-re-pass correction (Codex, medium): that verdict cache
(
- Execution binding wired into
adapters.semantic_kernel(Microsoft Semantic Kernel):Capture.WRAPPER_ASYNCfrom_dg_tool_gate, whichawaitsnext(context)itself -- there is no sync entry point (KernelFunction.invoke/invoke_streamare bothasync), exactly likeadapters.langgraph's reference wiring. Verified against pinned semantic-kernel 1.44.1:KernelFunction.invoke's owntry/except Exception as e: ...; raise earoundawait stack(function_context)re-raises unchanged, andKernelFunctionFromMethod. _invoke_internaldoes not swallow its own exception either, soBodyState.RAISED(witherror_code) is genuinely observed, not inferred. The SAME registered filter also gatesinvoke_stream(both share oneFilterTypes.FUNCTION_INVOCATIONstack); there,_invoke_internal_streamsetscontext.result.valueto the raw generator/async-generator WITHOUT consuming it -- the actual iteration happens ininvoke_streamitself, AFTERnext(context)has already returned to this filter -- socontext.result.valueis inspected for generator-ness and reportedBodyState.DEFERRED, never fabricated asRETURNED._freeze()snapshot of the function's own rawcontext.arguments, taken immediately beforeawait next(context)runs.asyncio.CancelledErroron the filter's ownawaitisBodyState.ABANDONED, still re-raised. The handoff gate never callsguard.check()at all -- a handoff mints the target's Guard viachain.delegate()->Guard.delegate(), not a scope check -- so it stays outside execution binding entirely, same asadapters.langchain/llama_index/camel, unlikeadapters.ag2/agent_framework(whose delegation IS a priced call). - Round 2 correction (Codex review, batch 2, finding 1): the claim above --
Capture.WRAPPER_ASYNCas an unconditional, always-genuine observation -- was wrong. PinnedKernel.add_filter/construct_call_stack(semantic_kernel/filters/kernel_filters_extension.py:36-51,:108-117) fold EVERYFilterTypes.FUNCTION_INVOCATIONfilter registered on the SAME kernel into ONE composed chain, per kernel, not per filter -- verified by tracingconstruct_call_stack'sstack.insert(0, ...)loop by hand (matchingadd_filter's own docstring: "the first filter added, will be the first to be executed"): the FIRST-added filter ends up OUTERMOST, the LAST-added ends up innermost, closest to the real tool body.attach_guardregisters_dg_tool_gatevia oneadd_filtercall, butkernel.add_filterstays callable on the same kernel for its whole lifetime -- nothing stops a caller from registering anotherFUNCTION_INVOCATIONfilter on it before OR afterattach_guardreturns. Addedstrict_single_hook: bool = Falsetoattach_guard(...). Default:Capture.PRE_HOOK_ONLY, norecord_outcome()ever, safe regardless of what other function-invocation filters are on this kernel, now or later.strict_single_hook=True: an explicit, scoped attestation that_dg_tool_gateis the ONLY such filter for the kernel's entire lifetime -- unlocksCapture.WRAPPER_ASYNC. This package cannot verify the attestation itself (no construction-time roster to check the waypydantic-ai'sfor_agent()offers for batch 1's equivalent detect-and-refuse pattern). Tests added, verified against the framework's OWNKernel.add_filter/construct_call_stack(not a hand-rolled stand-in): default-mode-honest; both-order short-circuit (guard_outerrecords a falseRETURNED,sibling_outeris never reached); guard-outer with a sibling retrying the real body underneath it (empirically confirmed first, per this project's own "verify, don't assume" discipline: the real body runs twice, this guard records exactly one honestRETURNEDfor the final attempt, silently under-reporting the retry). Also fixed, unrelated to execution binding: thesemantic-kernelextra was missingprotobuf--semantic_kernel/agents/runtime/core/serialization.pydoes an unconditionalfrom google.protobuf import any_pb2, reached lazily the momentHandoffOrchestration(or anything else undersemantic_kernel.agents.runtime) is actually accessed -- a bareimport semantic_kernel.agentsalone does not trigger it, PEP 562__getattr__lazy-loads the submodule. Sopip install 'attenu-guard[semantic-kernel]'broke on a clean install the moment this adapter's own shipped demo/tests exercisedHandoffOrchestration; addedprotobufto the extra. - Round 2 correction (Codex review, batch 2, finding 7): the claim above -- "which
semantic-kernel itself does not declare as a dependency" -- was stated as an unqualified,
version-independent fact. It is wrong for
semantic-kernel==1.36.0specifically: Codex checked that wheel's ownMETADATAand it DOES carryRequires-Dist: protobuf(re-verified here directly against the same wheel:pip download semantic-kernel==1.36.0 --no-deps, thenRequires-Dist: protobufis present, unconditioned, in itsMETADATA). What is actually true, checked directly rather than assumed:semantic-kernel==1.44.1-- what>=1.36resolves to today, and the version this test suite actually runs against -- does NOT declareprotobufin itsMETADATA(pip show semantic-kernellists noprotobufunderRequires:), yet still hard-importsgoogle.protobufin the module path above. Reproduced directly in the project's ownsemantic-kernelvenv:pip uninstall -y protobufmakestests/integrations/test_semantic_kernel.pyfail collection withModuleNotFoundError: No module named 'google.protobuf'(traced throughsemantic_kernel/agents/orchestration/handoffs.py->.../agent_actor_base.py->.../orchestration_base.py->.../runtime/core/base_agent.py->.../runtime/core/core_runtime.py->.../runtime/core/serialization.py);pip install protobuf(no version pin needed) fixes it and the suite is 28/28 again. Theprotobufextra addition itself was correct and stays -- only the stated REASON for it was wrong. semantic-kernel's own declared dependency on protobuf is version-dependent (present in 1.36.0's metadata, absent in 1.44.1's), not a fixed framework property; this extra covers the gap for whatever version>=1.36actually resolves to.
[0.9.0] — 2026-08-31
Fixed
- Integers beyond the RFC 8785 safe range (±(2**53-1)) are now rejected — at canonicalization, at
RowLimit/SpendCap/CallLimitconstruction, and bywire.load(asmalformed) — instead of silently colliding with a neighbouring integer once rendered through binary64. A tenth reject vector,reject_unsafe_integer.json, brings the interop suite to 20. evidence.verify_bundleandAuditLog.verify_anchornow check the bundle/anchor schema version and chain identity instead of ignoring them, so a bundle for the wrong version or the wrong chain no longer verifies.
Added
AuditLog.appendnow raisesCommittedAuditError(carrying the committedentry) if persisting an entry fails after it was already committed to the in-memory chain — the file write or a sink raising no longer looks the same as nothing having been recorded. The entry stays committed; callers must not retry the call that produced it on the strength of this error alone.- Execution binding, opt-in per chain via
Guard.issue(..., schema_version=2)(schema version 1 is unchanged and remains the default):check()/record_denial()now allocate acall_id(fail-closed, with meters restored, if the CSPRNG fails) and return it onDecision.call_id;check()gainsauthorized_params/capture/adapterand refuses further calls once the node iscomplete()d (ReasonCode.NODE_FINALIZED).Guard.record_outcome(call_id, body_state, ...)binds what a body-owning wrapper observed afterwards —returned/raised/abandoned/deferred, witherror_coderequired exactly when raised. On aschema_version=2chain,complete()returns a bool-coercibleCompletionResultand refuses while calls are pending; onschema_version=1it still returns a plainbool, byte-and-type identical to every release before 0.9.0.revoke()/revoke_agent()snapshot still-pending call_ids onto thekillentry aspending_at_kill— atomically, under one hold of the chain lock, together with the revocation itself and (incheck()) withcomplete()'s own check-pending-then-append sequence — without clearing them, so a laterecord_outcome()after a kill is still accepted. Every pre-commitcheck()/record_outcome()failure (not only CSPRNG exhaustion) rolls back its meters/bookkeeping. Arguments are committed viaparams_c14n_v1(attenu_guard.params):SHA-256(raw_salt || JCS(params)), never the raw value — closing, for this profile only, the one gap the shared JCS canonicalizer leaves open for out-of-range integral floats, without changing that canonicalizer's own behaviour elsewhere.evidence.verify_bundlegainsexecution_binding: per-call observed/unobserved/unaccounted (an outcome counts as observed only once it is bound correctly — right node, right order), per-node finalized/in_progress/revoked_with_pending, an aggregate clean/incomplete/failed, andparams_coverage(computed over every call, not only those with an outcome) as its own axis —not applicablefor a schema-version-1 bundle.verify_bundlealso rejects a rootless bundle and accepts an optional independently retainedexpected_anchor/expected_head, so a rewritten bundle whose own (self-consistent) anchor cannot be relied on is still caught. The LangGraph adapter (adapters.langgraph) is the reference wiring:guard_node/DelegatedToolNodecallrecord_outcomeon aschema_version=2guard, sync and async, from an immutable pre-invocation argument snapshot (a callable that mutates its own inputs cannot cause a false params mismatch), with generators/futures reporteddeferredandasyncio.CancelledErrorreportedabandoned. Schema and verifier are event- and version-aware and strict: a v2 allow REQUIREScapture/adapter(Guard.check()suppliespre_hook_onlyplus a guard-attributed adapter when the caller passes neither — a barecheck()IS itself pre_hook_only observation, never merely absent),denyFORBIDS every allow-only field, and a v1 entry FORBIDS every v2-only field (includingcall_id— v1 never allocates one);tests/test_execution_binding.pyruns in CI. A language-neutralparams_c14n_v1parity vector file (tests/vectors/params_c14n/params_c14n_v1.json, consumed bytests/test_params_c14n_vectors.py) covers its accepted/rejected numeric boundaries and salt handling; the TypeScript consumer of this same file is being built onattenu-guard-ts(feat/090-execution-binding) — parity between the two is a release gate for 0.9.0, not deferred work.
Changed
- Behaviour change: constructing an
AuditLog(orGuard.issue) with apath/audit_paththat already names a non-empty file now raisesFileExistsErrorinstead of silently truncating it. Passoverwrite=True(Guard.issue(..., audit_overwrite=True)) to keep the old reset-on-open behaviour where that is what you want.
0.8.0 — 2026-08-29
Changed
- Scope values now use one interoperable grammar: lowercase dot-separated
segments, with
*permitted only as the complete final segment after a dot. A terminal wildcard covers any depth below that segment boundary, but not the bare prefix or an adjacent namespace. Constructors and wire verification reject malformed scope syntax.
Added
reject_bare_wildcard.jsonandreject_nonterminal_wildcard.jsonbring the interop suite to 19 vectors and pin malformed wildcard forms to themalformedwire reason.
0.7.1 — 2026-08-29
Changed
c14nis informational; producers still emit it, while verifiers enforce RFC 8785 JCS from canonical bytes and hashes regardless of the label.
0.7.0 — 2026-08-29
Changed — BREAKING
- All signed and hash-linked artifacts now use RFC 8785 JCS exclusively. Delegation
tokens declare
"c14n":"JCS"; their protected header and payload must already be canonical JCS bytes. Audit entries, integrity seals, anchors, and evidence bundles use the same canonicalizer and carry the same marker where the artifact has metadata. Duplicate object members, non-finite numbers, lone surrogates, unmarked tokens, and non-canonical encodings are rejected. The interop suite now contains 17 vectors, including all six known Python/ECMAScript divergence classes. There is no legacy or dual-format reader.
Added
- A ninth interop test vector,
reject_wildcard_boundary.json("expect_reject_reason": "not_narrower"), shipped in both copies and in the installed package. The leaf claimscrmx.readunder a root holding the wildcardcrm.*— a scope that shares the wildcard's letters but not its segment boundary.crm.*coverscrm.followed by anything, socrmx.readis a different namespace and no ancestor grants it. It closes the half the eighth left open:reject_wildcard_widening.jsonpins the DIRECTION of the wildcard rule, and this pins its REACH. An independent verifier that implements the wildcard by stripping the.*and testingstartswith("crm")accepts the neighbouring namespace — the sloppy-prefix bug an attacker uses to step sideways into the namespace next door — and so scored 8/8 while being exploitable; it now fails a vector instead of shipping. The reference implementation already rejected it (it strips only the*and keeps the dot); this is coverage, not a fix.
0.6.1 — 2026-08-29
Added
- An eighth interop test vector,
reject_wildcard_widening.json("expect_reject_reason": "not_narrower"), shipped in both copies and in the installed package. The leaf's scopes are replaced with the wildcardcrm.*while its parent holds only the concretecrm.read, so the leaf claims strictly more than any ancestor ever held. It pins down the direction of the wildcard rule, which the existing seven left implicit:valid_chain.jsonshows a concretecrm.readsitting legitimately under acrm.*parent, and this is that turned round. An independent verifier that tests only whether a parent scope and a child scope are wildcard-compatible, rather than which side is the broader one, accepts both directions and lets a leaf hand itselfcrm.export— it now fails a vector instead of shipping. The reference implementation already rejected it; this is coverage, not a fix.
[0.6.0] — 2026-08-28
Added
- The interop test vectors ship inside the package as
attenu_guard.vectors(VECTOR_NAMES,load_vector,load_vectors,read_vector_bytes, read throughimportlib.resources). The Internet-Draft promises a chain that MUST verify and six that MUST each be rejected for a named reason, so that an implementation written in ANY language from the draft alone can score its own offline verifier; shipping them means doing that needspip install attenu-guardand no clone.tests/vectors/generate.pyis the single writer for both copies — it serialises each vector once and writes those bytes totests/vectors/andsrc/attenu_guard/vectors/— andtests/test_wire.pyasserts the two are byte-identical, so they cannot diverge. A CI step verifies they survive an install, not just a checkout. - A2A adapter (
attenu_guard.adapters.a2a, extraa2a, tested againsta2a-sdk1.1.2): carries the attenuated delegation chain across an Agent2Agent hop, so a remote agent in another process runs with permissions bounded by the calling agent's. Two halves on public seams — client side, aDelegationInterceptor(ClientCallInterceptor.before) mints the child withparent.delegate(...)and puts the signed Delegation Chain (attenu_guard.wire) on the outgoing message as an A2A extension (Message.extensions+Message.metadata[<uri>], spec §4.6.2, with theA2A-Extensionsheader §4.6.1); server side,GuardedAgentExecutorwraps the deployment'sAgentExecutor.execute, verifies the chain offline (wire.load: signatures, parent-hash linkage, depth, child ⊆ parent at every hop, expiry) and mints the servedGuardfrom the verified leaf, narrowed again by what the remote task needs. A missing, forged, spliced, widened or expired chain — or any exception raised while deciding — refuses the request before the remote agent's own logic starts, returning the denial contract in the extension's metadata slot.guarded_tool(fn, scope=…)checks before each tool body;require_guard()refuses a tool reached outside the executor.verify_hop(tokens, signer, client_bundle=…, server_bundle=…)checks the caller's ledger, the remote ledger and the tokens that bind them from those inputs alone, and reports an unsupplied bundle as "not checked" rather than as passing. This answers A2A §7.6.4, which states that the protocol defines no scope, validity or revocation semantics for an in-task authorization decision. Cross-process revocation propagation remains open: an expired chain is refused andrevocation_check=is the seam for a status list, both documented as limits. Example (offline demo plus alive_smoke.pyverified over a real Starlette/uvicorn HTTP hop) and 35 offline tests; seventeenth entry indocs/INTEGRATIONS.md.
[0.5.0] — 2026-08-27
Added
- Haystack adapter (
attenu_guard.adapters.haystack, extrahaystack, tested againsthaystack-ai3.1.0): guards deepset HaystackAgents and pipelines throughTool.invoke/invoke_async(a subclass of each tool's own class, soComponentTool/AgentToolidentity and theinputs_from_state/outputs_to_stringmachinery are untouched), mints the childGuardat theAgentToolcall, and offers Haystack's ownbefore_toolConfirmationHookas an alternative denial path. Denials raise aToolInvocationErrorsubclass, so the Agent's existingraise_on_tool_invocation_failuredecides between "tell the model" and "stop the run". Parent tracking is aContextVar, so parallel delegations in one model turn are siblings, not a chain. Example + 26 offline tests; 13th framework indocs/INTEGRATIONS.md. - Two new framework adapters, both AutoGen successors.
attenu_guard.adapters.agent_frameworkfor Microsoft Agent Framework 1.15 (the AutoGen + Semantic Kernel successor) —DelegationGuard(FunctionMiddleware)gates every tool body through the one seam the framework's function-invocation loop can reach, and the same hook mints the childGuardatAgent.as_tool()andhandoff_to_<target>calls; denials come back as afunction_result, or asMiddlewareFailure(on_deny="failure") for a fail-closed abort.attenu_guard.adapters.ag2for AG2 1.0 (the AutoGen fork, a rewrite around theag2package) —DelegationGuard(BaseMiddleware).on_tool_executiongates the tool body and thetask_<agent>delegation call, plusguarded_tools()/guard_tool_hook()for per-tool middleware, the only hook that reaches a child AG2 constructs itself fromtasks=TaskConfig(...). Install withpip install 'attenu-guard[agent-framework]'/'attenu-guard[ag2]'. Offline demos underexamples/integrations/{agent_framework,ag2}/and 36 tests undertests/integrations/; matrix rows indocs/INTEGRATIONS.md. - Supply chain: every release now carries SLSA build provenance (sigstore attestation via
actions/attest-build-provenance); OpenSSF Scorecard runs weekly and on push; a.pre-commit-hooks.yamlexposesattenu-guard verifyas a pre-commit hook for committed evidence bundles.
Fixed
- Adapter docstrings still referred to the pre-rename paste-in module names (
dg_google_adk,dg_crewai,dg_smolagents,dg_llama_index) and said "paste/copy this file"; they now name the packaged modules (attenu_guard.adapters.<name>) and the matching extras.
[0.4.1] — 2026-08-26
Fixed
- README: the install block still said "pre-publish… once published to PyPI"; the package has been on PyPI since 0.4.0. It now reads
pip install attenu-guard.
Changed
- Packaging: PyPI classifiers,
Documentation/Issues/Changelogproject URLs, and a summary aligned with the project description. No code changes.
[0.4.0] — 2026-08-24
Changed — BREAKING
- Renamed:
delegation-guard→attenu-guard. Distributionattenu-guard, moduleattenu_guard, CLIdg→attenu-guard. Versions before 0.4.0 were published under the old name; nothing else in the API changed in the rename itself. Everything below was unreleased 0.3.0 work and ships here.
Driven by integration PoCs against twelve real agent frameworks
(examples/integrations/, docs/INTEGRATIONS.md): the library integrated
unmodified everywhere; what follows is what those integrations asked for.
Fixed
- Google ADK adapter: parallel delegations were chained, not fanned out. When one
model turn issued several
AgentToolcalls (ADK runs them concurrently), "parent = the last active agent" minted child 2 from child 1. The delegating agent is now recorded at the tool call and used as the parent when the child starts. Safe direction before (authority only shrank), wrong topology. Found by sampling. - Thread-safety under parallel tool calls. Frameworks execute an agent's
parallel tool calls on thread pools; concurrent
check()s could interleave the audit hash-chain append (verify()then rejected the library's own log) and log out of sequence. The audit log, sequence clock and chain mutations are now serialised per chain;ts/seqadvance atomically. strict_metering=Truefailed open on a partial context. Only an entirely empty context was refused; a context that declared some dimensions but omitted a held metered ceiling's field silently skipped that ceiling. Strictness is now per ceiling: a metered call must declare every metered dimension the node holds (ceilings.ctx_field_of,ceilings.is_metered; built-ins carryctx_field).tests/test_langgraph_adapter.pyasserted that langgraph was not installed (a statement about the machine, not the module); it now asserts the actual guarantee — importing the adapter does not import langgraph.
Added
denyledger entries carry adisposition— held is not over-reach.Guard.check(..., disposition=)andGuard.record_denial(..., disposition=)accept aDispositionvalue (held_pending_grant·withheld_tier2·unresolved·out_of_authority); a plainscope_not_granteddeny the caller did not explain recordsout_of_authority(the shim's own truth); unknown values are refused before anything reaches the ledger;allowentries never carry it.Dispositionis exported;evidence.LEDGER_FIELDSandschema/agent-audit.schema.jsongained the field so a strict export still passes. Closes the threat model's "held pending curation must render distinct from denied" item at the ledger, where every UI reads it.evidence.denials(bundle)— deny events grouped by (node, tool, scope, disposition) with counts and first/last seq: the rows a Decisions queue renders, as a pure fold over the ledger;delegation_graphnodes gaindenials_by_disposition.- Disposition contract across all 12 adapters (
ToolPolicy/ToolAuthority/ScopeRequestfields and theguarded_tool/guard_node/GuardedToolkwargs), passed toGuard.check; the ADK denial dict returned to the model carriesdisposition. Undeclared tools now land on the ledger asunresolvedviaGuard.record_denialin every policy-map adapter (previously only in the adapter's memory / an exception).tests/test_adapters_contract.pypins the contract stdlib-only in CI. delegation_guard.identity— a product has an identity before it has a key:.attenu/product.jsondiscovery (ATTENU_PRODUCT_DIRor walk-up), per-processboot_id(), assignednew_chain_id(), andledger_path/spool_pathunder the product dir.AuditLog(sinks=...)+sinks.SpoolSink— local-file sinks fed after the ledger write (never the network); the spool is a separate append-only file (a new AuditLog never truncates it), bounded, flushed per line, fsync'd every N + onflush(), resumable (read_pending/ack), and every line carries the ingest idempotency key (boot_id, chain_id, seq, hash).Guard.issue(audit_sinks=).-
wire.Ed25519Verifier— public-key-only verification for consoles/auditors/ingest (cannot sign);Ed25519Signer.private_bytes_raw()/from_private_bytes()for key files. -
Guard.is_descendant_of(other); the Google ADK adapter treatstransfer_to_agentback to an ancestor as a return, not a delegation: noagent.delegate.<ancestor>check, the returning child is markeddone, control moves up (found live on a 21-agent app where the planner transferred back to root and was denied). evidence.delegation_graphnames a disposition-less deny by its reason (revoked,ceiling_exceeded).
Changed
- Version 0.3.0 (ledger schema gains an optional
dispositionfield on deny; wire and hash chain unchanged). ReasonCode.NO_AUTHORITY— the principal holds no Authority in this chain (adapter-level: undelegated agent, unmapped tool, unparseable args).Guard.record_denial(reason, message, *, scope, tool, context)— put an adapter-level refusal on the audit trail as a schema-conformantdenyevent.Guard.agent_id,Guard.is_revoked,Guard.is_expired(read-only).Guard.revoke_agent(agent_id)— principal-scoped, chain-wide revocation with a grow-only ban (AuthorityErrorreasonagent_bannedon any laterdelegate()), closing the re-delegation bypass found by the Strands/OpenAI-SDK integrations.Guard.would_delegate(agent_id, request)— pure dry-run of the delegation preconditions (Chain.delegation_error), no node, no fanout, no audit write.AuditLog.__iter__/__len__.- Framework adapters shipped in the package as
delegation_guard.adapters.<name>with per-framework extras (pip install 'delegation-guard[crewai]'…):langchain(LangGraphToolNode/ LangChaincreate_agent/ deepagents),openai_agents,google_adk,pydantic_ai,crewai,autogen,claude_sdk,smolagents,strands,llama_index,semantic_kernel,agno— plus the existinglanggraphnode adapter. Each has an offline demo (examples/integrations/) and a test (tests/integrations/, 213 tests) using the framework's own mock model; CI matrix per framework.docs/INTEGRATIONS.mddocuments hooks, versions and what each framework enforces itself. - Scoped call ceilings:
CallLimit(max, applies_to=<scope|pattern>)— its own dimension (max_calls[<scope>], ctx fieldcalls[<scope>]), evaluated only for the matching scope;Authority.permitspasses the requested scope to ceilings as the reserved_scopecontext key. Auto-metering:Guard.check()suppliescalls/calls[<pattern>]per (node, pattern) when the caller does not, incrementing on allow — adapters need no counting logic;would_allow()reads the meter without consuming it. UnscopedCallLimitwire form is unchanged. -
Observe-mode hooks on the LangChain, Google ADK and CrewAI adapters (sampling):
GuardedDelegation(default_policy=, default_subagent_authority=),DelegationGuardPlugin(default_tool_authority=, default_delegation=)andCrewAIGuardBridge(default_policy=, default_delegation_authority=)generate the policy / Authority for an undeclared tool / sub-agent so the call is authorized-and-recorded on the audit log instead of denied. Deny stays the default without the hooks. The ADK plugin now records theAgentToolrequestas the child's task text on the spawn record (was"delegated to <name>"). -
Bundle redaction guarantee (
evidence.redaction_report,export_bundle(strict=, context_allowlist=, redact_task=),EvidenceLeakError). The exported bundle is customer data in transit, so custody is a test not a habit: a top-levelLEDGER_FIELDSallow-list (an unknown field is where a raw argument would hide →strict=Trueraises), an optional callercontext_allowlist(a raw tool-arg value under a non-feature context key is caught), andredact_task=Truereplaces free-text prompts with a length+hash marker before the anchor, so the transport carries no raw prompt yet still verifies. Nothing unvetted leaves the premises. - Offline evidence bundle + verifier (
delegation_guard.evidence).export_bundle(audit_log, signer)produces a self-contained bundle (the hash-chained ledger + a signed anchor);verify_bundle(bundle, signer)checks three invariants from the bundle ALONE, no engine: integrity (hash chain + anchor — a consistent full rewrite fails), monotonicity (every delegation child ⊆ parent), containment (every allowed action was within the acting node's authority).delegation_graph(bundle)renders the chain (nodes, agents, authorities, action counts, edges) for a reviewer or UI. This is the offline-verifiable audit trail: an auditor confirms the guarantees without trusting the engine that produced them. - Ledger anchoring (
AuditLog.anchor/verify_anchor/head, ADR-14). A signed external commitment to the chain head.verify()catches in-chain tampering; a consistent full rewrite (re-hash the whole log) reproduces its own hashes and passesverify()— but notverify_anchor(), because the out-of-band signed head hash is the fixed point it cannot reproduce. Uses the existingwiresigners (Ed25519 in production). StrikePolicy— revoke a node after repeated denials (Guard.issue(strikes=StrikePolicy(n=3, mode="same_scope")), off by default). N denials of the same scope (or N total) cascade-revoke the offending node; onekillevent withreason="strike_policy",scope,strikes,modeso the parent can see why. The policy propagates to every child in the chain. A denied agent that keeps probing the same wall is stopped, not left to keep probing.Guard.complete()/Guard.is_complete— node lifecycle end (doneaudit event, idempotent, informational: authority is unchanged, revocation stays the hard stop). The LangChain, Claude SDK, Google ADK and CrewAI adapters record it when a delegation returns to its caller, so a ledger reader can tell a sub-agent that finished from one that was cut short. Schema enum gainsdone.Ceiling.describe()on all built-ins,ceilings.describe()helper,Authority.describe();ReasonCodeconstants for the structuralAuthorityErrorreasons (CHAIN_REVOKED,AGENT_BANNED,TTL_EXPIRED,MAX_DEPTH,MAX_FANOUT,CHAIN_CEILING).tools/render_demo_gif.pyregeneratesdocs/assets/demo.giffromdg demo.- CI: actions bumped to v6; 6-hourly quickstart canary; per-framework pinned
integrationsjob; weekly unpinnedintegrations-latestdrift canary.
[0.2.0] — 2026-08-17
The hardening release. A black- and white-box red-team pass drove real fixes, the API moved to rich decisions, and the wire format plus offline verification landed as the reference implementation of the Internet-Draft.
Added
- Wire format (
delegation_guard.wire): sign and serialize a delegation chain as JWS Delegation Tokens and verify child ⊆ parent offline, with no authorization server in the path. Ed25519 (via the optionalcryptographyextra) or a stdlib HS256 test signer. Interop test vectors live intests/vectors/— one valid chain and six adversarial rejects. - Typed, extensible ceilings:
RowLimit,SpendCap,CallLimit,EgressRank,Allow,Deny,Prefix, plusregister_ceilingfor your own. Unknown ceiling types fail closed, never silently unbounded. - Rich
Decision:check()returns a bool-coercibleDecisioncarrying machine-readable reason codes andexplain();enforce()raises on denial;would_allow()is a side-effect-free dry run. - Scenario harness: declarative JSON/YAML authorization tests
(
dg scenarios file.json). - LangGraph adapter under
delegation_guard.adapters.langgraph. - CLI:
dg demo | view | verify | scenarios.
Changed
- Public API is now
Guard.issue / delegate / revokeandAuthority.meet / is_narrower_than. The v0.1root / spawn / killnames remain as deprecated aliases and emitDeprecationWarning. - Package moved to a
src/layout. The core is zero-dependency; optional extras arecrypto,yaml, andlanggraph.
Fixed (from the red-team pass)
- TTL was never enforced. Added an injectable clock, per-node
issued_at, and an expiry gate, so expired authority is denied. is_narrower_thanwas unsound for custom ceilings. Any ceiling present on the parent but absent on the child now makes the child not narrower.- Custom ceilings could be inert. Generic quantity and rank constraints are now
enforced at
check()time. - Wildcard scope pruning could false-deny. Only scopes strictly covered by a broader wildcard are pruned.
Security
- A property suite (4,000 random delegation trees per invariant, zero deps) and
a 17-attack red-team harness run in CI. Every genuine finding is fixed and
pinned as a regression. See
docs/RED-TEAM.md.
[0.1.0]
- Initial release:
Authority/Guardcore,meetattenuation, chain depth / fanout / budget ceilings, cascade revocation, and a hash-chained audit log.
attenu-derive
All notable changes to attenu-derive are documented here. The format follows Keep a Changelog; the project adheres to Semantic Versioning.
[Unreleased]
Added
- Supply chain: SLSA build provenance (sigstore attestation) on every release; OpenSSF Scorecard weekly and on push.
[0.2.1] — 2026-08-26
Changed
- Packaging:
[project.urls](homepage, docs, source, issues, changelog) so PyPI shows them; PyPI classifiers; the package summary and README opener now state what the engine does in one sentence. No code changes.
[0.2.0] — 2026-08-25
Changed — BREAKING
- Open engine. Licence is Apache-2.0. The control plane left the package:
attenu link/attenu sync, the installation token, the flywheel export and the Attenu issuer keys now live in the optionalattenu_cloudclient (shipped with the Attenu console).attenu link/sync/uiprint an install hint when it is absent. - Enforcement needs no token.
enforceandshadowrun without any licence check; observe → shadow → enforce is one flag each way, offline. - Config-revision verification trusts the product's own anchor key plus
attenu_derive.config.ISSUER_KEYS(empty by default; the cloud client contributes the Attenu issuer keys when installed).
Added
README.mdfor the public release;AGENTS.mdfor coding agents; this changelog.
attenu-guard-ts
All notable changes to this package are documented here. The format follows Keep a Changelog, and this package follows Semantic Versioning.
[Unreleased]
[0.5.0] - 2026-08-31
Fixed
- Adapter mirror of the Python batch-1/batch-2 adversarial review. The TS package ships
exactly one adapter surface (
src/adapters/langgraph.ts—package.json'sexportsmap declares nothing besides.and./adapters/langgraph; no A2A, no generic wrapper, no separate LangChain.js seam), and execution binding was already fully wired into bothguardNodeandguardToolas of 0.4.0. Each Python defect class was checked against this specific adapter, against pinned@langchain/core@1.2.9/@langchain/langgraph@1.4.13source (installed and grepped directly, not assumed), rather than ported by analogy — see the module doc comment's own "Adversarial review" section for the full per-class evidence. Six of the seven classes came back genuinely inapplicable to this adapter's architecture (no composable middleware chain in either pinned framework package; one wrapper per call, no second gate; no cross-hook correlation map to collide or grow unbounded;isDeferredResultalready covers JavaScript's whole lazy-result landscape; no external multi-phase hook dispatch to lose an event across;src/'s only runtime import is a lazy@langchain/langgraph, correctly undeclared as a hard dependency, matching the README's own "zero runtime dependencies" claim). One real, TS-specific gap was found in the snapshot-commitment family and fixed: snapshotParams/snapshotToolParams's fallback, taken whenstructuredClonecannot clone the value being snapshotted (a function, a class instance it refuses, anything sharing an object graph with one of those), was a bare shallow copy —{args: [...args]}makes a fresh OUTER array, but every element INSIDE it is the same live reference as the real call arguments. Reproduced directly before fixing:snapshot.args[0] === liveArgwastrue, and a mutation ofliveArgafter the call was visible through the "snapshot" — violating the adapter's own documented guarantee ("an IMMUTABLE snapshot... taken BEFORE the wrapped callable runs"). Checked the specifictoJSONvector first:structuredClonedoes NOT consult atoJSONmethod or any other user-overridable protocol the way Python'scopy.deepcopyconsults__deepcopy__(verified empirically — a hostile class's owntoJSONreturning fabricated data is simply ignored, and the clone is never the same OBJECT reference for that specific case); only the FAILURE path aliased for a hostiletoJSON. See the release-gate correction below for why "a successful clone never aliases" was still wrong as a general claim (aSharedArrayBufferclones "successfully" to a DIFFERENT object that shares the SAME underlying memory).- Fixed with a new
freeze()function (exported for direct testing, the same reason every Python adapter's own_freeze()is imported directly by its tests — the audit log never exposes the raw snapshot value, only its hash, so "does this alias" is not otherwise observable): safe JSON-primitive leaves pass through verbatim, plain objects/arrays are rebuilt fresh and recursively, and anything else (a function, a class instance, aMap/Set/Date/RegExp, aSymbol, aBigInt) becomes a safe string representation — never the live reference. This isstructuredClone's support matrix happening to overlap with what the audit log's own JCS canonicalizer (params.ts) can hash, unconditionally, matching the same invariant every Python adapter's_freeze()already holds, rather than scoped narrowly to only the cases proven to reach a hash mismatch. Guards a circular reference with aWeakSet(structuredClonehandles cycles natively; the whole point of this function is the cases it could NOT handle, one of which could still be cyclic). A welcome side effect: becausefreeze()sanitizes an otherwise-unsupported value BEFORE it is ever handed toparams.ts'scommit(), a call that used to commit no hash at all (paramsHashReason: "unsupported") now commits a real, verifiable one. - Tests added in
test/adapter-langgraph.test.ts: two direct unit tests onfreeze()itself (never aliases the unclonable value's own case; never aliases a mutable SIBLING sharing the same object graph as an unclonable value — the mixed case), one guarding the circular reference, and one end-to-end test per wrapper (guardNode,guardTool) driving an unclonable argument through the real call path and asserting a genuine, matchingauthorizedParamsHash/invokedParamsHashpair is committed rather than"unsupported". - Delta review, two more edits to
freeze()itself:- Medium-low, required: the plain-object branch built the rebuilt object with an
out[k] = freeze(v, seen)accumulation loop. A plainJSON.parse('{"__proto__": {...}}')result genuinely has"__proto__"as an OWN, ENUMERABLE data property (Object.keyslists it — JSON has no notion of prototypes, so this is reachable from ordinary untrusted input, not a contrived shape) — but assigning throughout[k] = vfor that specific key name does not create a data property at all; it sets the accumulator's own[[Prototype]]viaObject.prototype's own__proto__accessor instead. The key then vanished from the rebuilt object's own enumerable keys entirely — a params-commitment completeness gap (substitution on that key would be invisible to a params mismatch) thatstructuredClone's own success path, and Python's_freeze(), do not have. Fixed withObject.fromEntries(Object.entries(obj).map(([k, v]) => [k, freeze(v, seen)])), which always defines genuine data properties,__proto__included. Test added: aJSON.parse-created__proto__own key beside an unclonable sibling (forcing the fallback) — the key survives into the snapshot with its value, and the rebuilt object's own prototype is unaffected. - Nit, ride-along: the array branch used
value.map((v) => freeze(v, seen))—Array.prototype.mapSKIPS a hole in a sparse array rather than visiting it, so a hole would survive into the snapshot as a hole too, unlike every other absent valuefreeze()turns into a plainnull. Fixed withArray.from(value, (v) => freeze(v, seen)), which visits every index up to.length, densifying a hole toundefined(thennull, same as any otherundefined). Test added:freeze([1, , 3])equals[1, null, 3], with a real (densified) element at index 1, not a hole.
- Medium-low, required: the plain-object branch built the rebuilt object with an
- Release-gate correction (CRITICAL + HIGH):
freeze()was still only a FALLBACK, run only whenstructuredCloneTHREW — a successful clone bypassed it entirely, and "a successful clone never aliases" (asserted above) was not actually true. Three bypasses, each reproduced directly before fixing: (1) a circular object clones successfully —structuredClonehandles cycles natively — sofreeze()never ran on it at all; the circularity then reachedparams.ts's own hash-commitment walk, which has NO cycle guard, and crashed withRangeError: Maximum call stack size exceededbefore authorization or the tool body ever ran. (2) A sparse array clones successfully too, holes preserved, bypassingfreeze()'s own densification (added above) entirely — it reachedparams.tsasparamsHashReason: "unsupported"instead of a real, densified, hashable snapshot. (3) ASharedArrayBufferclones to a DISTINCT wrapper object that shares the SAME underlying memory, by design — a "successful" clone that is not independent at all. Fixed by makingfreeze()the ONLY snapshot path, unconditionally:structuredCloneis no longer called anywhere in this adapter. Also fixed in the same pass: the cycle guard (seen) was a single, MUTABLEWeakSetshared across the whole call, added to but never removed from — so a DAG's repeated sibling reference (the SAME object appearing twice as two different keys' values, never as its own ancestor) was wrongly reported"<circular>"on its second occurrence, reproduced directly:freeze({a: shared, b: shared})came back{"a": {...}, "b": "<circular>"}. Renamed toactive, a PATH-ACTIVEReadonlySet— a freshSetunioned in at each recursive call, never mutated in place or shared across sibling branches. Separately, HIGH: the array and object branches (Array.from/.map()andObject.entries()) invoke the value's OWN protocols — a hostile[Symbol.iterator]override can yield ANYTHING regardless of an array's real indexed properties (reproduced:[1, , 3]with a hostile iterator froze as[999]), and a getter is INVOKED byObject.entries(), with no guarantee of being invoked only once (reproduced: with an unclonable sibling forcing the old fallback, a getter with a side effect was observed three times across the old clone-attempt/freeze/body sequence, and the committed snapshot was the SECOND of three observations, not the first). Fixed: both branches now walkObject.getOwnPropertyDescriptordirectly (arrays by a.length-bounded index loop, objects byObject.keys) — pure introspection, never invoking user code — and an accessor property (.get/.setpresent) is encoded as the literal string"<accessor>"rather than read at all. A regression caught and fixed before this same commit landed: rewriting the object branch's write-back re-introduced the EXACTout[key] = valuebug the__proto__fix above had already closed (a bracket assignment to the literal key"__proto__"sets the accumulator's prototype instead of a data property) — caught by running the existing__proto__test against the rewrite, not assumed fixed; corrected withObject.definePropertyin the loop instead. Six new tests added, each driving the REAL wrapper (guardNode/guardTool), notfreeze()directly — the earlier circular test guarded the wrong path (it passed even while the actual wrapper crashed): circular input, sparse array, hostile custom iterator, getter,SharedArrayBuffer, and a DAG's repeated reference, all viatest/adapter-langgraph.test.ts. - Release-gate correction (HIGH):
isDeferredResultmissed a plainAsyncIterable. The async branch required the result to have its OWN.nextmethod, matching a self-iterating async generator — but the JavaScript async-iterable protocol only requires a callable[Symbol.asyncIterator](), which can return a SEPARATE object that has.next, without the iterable itself ever having one. Reproduced directly before fixing: a plain object implementing only[Symbol.asyncIterator]()was recordedBodyState.RETURNED, notDEFERRED. Fixed: the async check no longer requires an own.next(the sync check, which DOES require it, was deliberately left alone — dropping it there would misdetect a plainArray/Set/Mapas deferred, since those implementSymbol.iteratortoo without their contents being lazily produced; there is no equivalent JavaScript built-in that implementsSymbol.asyncIteratorover already-computed values, so this asymmetry is not itself a gap). The module doc comment's own "whole lazy-result landscape" claim is narrowed to list exactly whatisDeferredResultchecks, rather than asserting completeness. Two tests added: the async-iterable case now scoresDEFERRED; a plain array result is confirmed to still scoreRETURNED(pinning that the sync branch's requirement was deliberately kept). - Release-gate correction (HIGH): three disagreeing version fields.
package.jsonsaid0.4.0;package-lock.json's root"version"said0.3.1(stale since before the 0.4.0 bump —npm installnever re-synced it);src/version.ts's exportedVERSION— the constantguard.tsand bothadapters/langgraph.tswrappers use to attribute every v2 ledger entry'sadapter.versionfield — said0.3.0. The release workflow (.github/workflows/release.yml) only ever checks the pushed tag againstpackage.json, so it would have published while shipped ledger attribution was still wrong. Every v2 ledger entry produced by the shipped 0.4.0 release has been misreportingadapter.versionas"0.3.0". Fixed: all three aligned to the CURRENT0.4.0(no version bump as part of this fix — that is the operator's call at release time). Addedtest/version-consistency.test.ts, a new CI-run test (not only a release-time check) assertingpackage.json,package-lock.json's root version (both the top-level field and itspackages[""].versioncopy, which have drifted independently before), and the exportedVERSIONall agree, every run, not only at tag time. - Release-gate finding, MEDIUM:
test/wire-vectors.test.tsenumerated and scored 19 of the 20 published interop vectors, silently.reject_unsafe_integer— anticipated in this package's own[0.3.1]CHANGELOG entry below ("will show 20 vectors... once the Python package ships them") and shipped by Python's own[0.9.0]— has had its fixture file present on disk (byte-identical to Python's) since, butVECTOR_NAMESitself was never updated to include it, and the test's own count assertion (19) masked the omission rather than catching it. Fixed: added toVECTOR_NAMES, the count and test name updated to20, and the stale.github/workflows/ci.ymlcomment ("19-vector... >=0.8 ships this") corrected to20-vector/>=0.9(Python's own[0.9.0]CHANGELOG entry is wherereject_unsafe_integer.jsonshipped, matching the pip constraintattenu-guard>=0.9,<0.10already pinned two lines below that comment). - Release-gate finding, LOW: the interop matrix had no early warning for the NEXT Python
minor.
ci.yml'sinteropjob pinsattenu-guard>=0.9,<0.10deliberately — the committed fixtures match that release, and the job's own last step re-generates and diffs them, so pinning to it is correct, not stale. But nothing in this repo would notice a 0.10.0 that breaks wire compatibility until someone widened that pin by hand. Added a second job,interop-next, that runs the same cross-language suite againstattenu-guard>=0.10,<0.11— gated on apip index versionscheck so it reports success without running anything while 0.10.0 is unpublished (confirmed against the live PyPI index: 0.9.0 is current, no 0.10.x yet), and starts actually exercising the suite the moment the operator ships it, with no workflow edit required either way. - D14 —
Guard.check()registered aPRE_HOOK_ONLYallow as pending, wedgingcomplete()forever. Mirrors the fix landing in the Pythonattenu-guardreference implementation (guard.py, same defect, same root cause):registerPendingran unconditionally for everyschemaVersion: 2allow, in both the normal commit path and theCommittedAuditErrorpath, with no regard forcapture. A barecheck()(or any explicitcapture: Capture.PRE_HOOK_ONLY) is an honest promise of NO terminal observation — nothing is ever going to callrecordOutcomefor it — yet it was registered pending exactly like aWRAPPER_SYNC/WRAPPER_ASYNC/FRAMEWORK_POST_HOOKallow, socomplete()refused forever for a node with onlyPRE_HOOK_ONLYcalls. The offline verifier already treated a missingPRE_HOOK_ONLYoutcome as merelyunobserved(evidence.ts's execution-binding report), so runtime and offline semantics disagreed. Fixed: a call is now registered pending only when its capture is one ofWRAPPER_SYNC/WRAPPER_ASYNC/FRAMEWORK_POST_HOOK— in both the normal path and theCommittedAuditErrorpath. A bare/PRE_HOOK_ONLYallow never enters the pending set, socomplete()finalizes immediately, and the verifier'sunobservedclassification for it now matches the runtime's own view. - Re-gate correction (HIGH):
freeze()still executed attacker-controlled code BEFORE authorization, on three separate exotic-value paths, all reproduced directly before this fix. (1) AProxyis not inert under reflection:Object.getPrototypeOf,Object.keys, andObject.getOwnPropertyDescriptorare each real, user-definable traps — walking an ordinary Proxy through the property-descriptor logic fired four of them before authorization was ever decided, andArray.isArrayon a REVOKED Proxy throwsTypeErroroutright rather than degrading cleanly. (2) The bottom fallback still calledString(value)for anything not a plain object/array — a boxed primitive (new Number(...)) with a hostileSymbol.toPrimitive, or aTypedArraywith a hostile owntoString, each ran attacker code exactly once per snapshot. (3) No general safety net: an unanticipated reflection failure would have propagated an exception out of a snapshot taken before authorization, rather than degrading. None of this was an aliasing or authorization-bypass defect — ordinary frozen/accessor-only objects never leaked references, and policy evaluation still fully controlled whether the wrapped body ran — the defect was pre-authorization code execution and exception propagation. Fixed:freeze()now recognizes a Proxy FIRST, viautil.types.isProxy(an internal engine-slot check that invokes nothing, live or revoked — verified directly), beforeArray.isArrayor any other reflection; every value that is not a safe JSON primitive and not a plain object/array (a Proxy, a boxed primitive, a TypedArray/ArrayBuffer/SharedArrayBuffer/DataView, aMap/Set/Date/RegExp, a function, aSymbol, aBigInt, or anything else) becomes the newFREEZE_UNSUPPORTEDsentinel instead of a string — neverString(), never any other protocol; the whole reflective walk runs inside onetry/catch, degrading any other reflection failure the same way.paramsHashReason: "unsupported"for the whole call, never a partial commitment — the same degradation this library already uses for an out-of-domain number, not a new failure mode. - Re-gate correction (MEDIUM):
"<accessor>"was a real commitment collision, and so was"<circular>"by the identical reasoning. Two real wrapper calls — one with an enumerable getter, one with the literal string"<accessor>"as an ordinary value — produced the IDENTICALauthorizedParamsHash, reproduced directly: an evidence-integrity ambiguity in a supposedly cryptographic commitment (two materially different inputs, one commitment), even though the getter itself correctly stayed uninvoked and this was never an authorization bypass. Audited the sibling sentinel"<circular>"for the exact same collision class rather than leaving it unexamined — it has the identical problem (a genuinely circular object and a plain object holding the literal string"<circular>"produce the same commitment); there is no reason a cycle's position makes the collision infeasible, so it gets the same fix. Both an accessor property and a genuine cycle now becomeFREEZE_UNSUPPORTED(above) — the same privateSymbolevery other unrepresentable value degrades to, which cannot equal any real call argument by construction. Regression test added: zero getter calls, no hash,params_hash_reason: "unsupported"; plus a direct test that a real getter-bearing object and the old literal-string sentinel no longer freeze to the same shape. - Re-gate correction (MEDIUM):
interop-next's "is 0.10.x published yet?" check failed OPEN.set -eualone does not catch a failingpip index versionsinside anif PIPE | grep -q ...; thencompound command — a compoundifcondition is exempt fromset -eby design, and withoutpipefaila pipeline's exit status is its LAST command's only. Reproduced directly: a simulated exit 42 from the query flowed straight through topublished=false, the same output as "0.10.x genuinely does not exist yet" — a PyPI outage silently read as a fact about what has been released, with the step still exiting 0. Fixed by extracting the check intotools/check-next-python-published.sh, which captures the query's exit code in its own statement (not inside anif) and exits non-zero — without ever printingfalse— before reaching the match logic. Added a probe step toci.yml'sinterop-nextjob, run on every CI invocation, that points the script at a stubpython3simulating a query failure and asserts the script fails rather than reportingfalse.
Added
- Execution binding, opt-in per chain via
Guard.issue(agentId, authority, {schemaVersion: 2})(schema version 1 is unchanged and remains the default):check/recordDenialnow allocate acallId(fail-closed, with meters restored, ifcrypto.randomBytesthrows) and return it onDecision.callId;checkgainsauthorizedParams/capture/adapteroptions and refuses further calls once the node iscomplete()d (ReasonCode.NODE_FINALIZED).Guard.recordOutcome(callId, bodyState, options)binds what a body-owning wrapper observed afterwards —returned/raised/abandoned/deferred, witherrorCoderequired exactly when raised.complete()returns a plainbooleanon aschemaVersion: 1chain (byte-and-type unchanged from every prior release) and aCompletionResultonschemaVersion: 2only — see its doc comment for the JavaScript limits of its truthiness bridge, since unlike Python's__bool__,if (guard.complete())cannot be made to readfalsethere — and refuses on v2 while calls are pending;revoke/revokeAgentsnapshot still-pending callIds onto thekillentry aspending_at_killwithout clearing them, so a laterecordOutcomeafter a kill is still accepted.check/recordOutcomeroll back meters and re-throw on ANY pre-commit failure (not only acrypto.randomBytesfailure), andrecordOutcomemarks a call outcomed only after its append actually commits, so a pre-commit failure leaves the call retryable.Guard.issuerefuses{schemaVersion: 2, auditOverwrite: true}— the restart rule has no escape hatch on v2. Arguments are committed viaparams_c14n_v1(params.ts):SHA-256(rawSalt || JCS(params)), never the raw value; validated against the language-neutral vector file also published for Python (test/fixtures/params_c14n_v1.json).verifyBundlegainsexecution_binding: per-call observed/unobserved/unaccounted (an outcome must be bound correctly — right node, right order — to count as observed), per-node finalized/in_progress/revoked/revoked_with_pending, an aggregate clean/incomplete/failed, andparams_coverage(computed over every valid allow) as its own axis —{status: "not applicable"}for a schema-version-1 bundle.verifyBundlealso gainsroot_version_mismatch/mixed_entry_versionschecks (a chain is created at one schema version and never mixes), requires exactly one root event (checks.root), does strict, null-aware, type-checked schema validation on every conditional field (capture/adapterare now REQUIRED, not merely paired, on every v2 allow — a barecheck()with nocapturesupplied gets a truthful guard-attributedpre_hook_onlydefault rather than leaving the ledger silent; any allow-only field on adeny, or any v2-only field on aschemaVersion: 1entry —v2_field_on_v1— is invalid), and accepts an optionalexpectedAnchor/expectedHeadto verify against an independently retained reference point instead of only the bundle's own enclosed anchor.AuditLoggainsCommittedAuditError(a post-commit persistence failure after the entry is already in the in-memory chain) and overwrite protection (constructing over apaththat already names a non-empty ledger now throws unlessoverwrite: true). The LangGraph adapter (adapters/langgraph) is the reference wiring:guardNode/guardToolsnapshot a call's arguments once, immutably, before invocation and callrecordOutcomeon aschemaVersion: 2guard, sync and async, reporting a generator/promise-like result asdeferredand anAbortController-driven cancellation asabandoned; wrapping an async callable on aschemaVersion: 1guard stays byte-and-type unchanged (never itself becomes an async function). Mirrors attenu-guard (Python) 0.9.0's execution-binding layer plus its post-review merge-gate hardening, byte-for-byte on every reason-code string; ported test suite:test/execution-binding.test.ts(82 cases),test/params-c14n-vectors.test.ts(the shared parity vectors), plus adapter and evidence coverage intest/adapter-langgraph.test.ts/test/evidence.test.ts.
[0.3.1] — 2026-08-30
Fixed
- Integers beyond the RFC 8785 safe range (±(2**53-1)) are now rejected — at canonicalization, at
RowLimit/SpendCap/CallLimitconstruction, and byload(asmalformed) — instead of silently colliding with a neighbouring integer once serialized as a double. The interop suite will show 20 vectors, andvalid_jcs_big_integer/valid_jcs_exponent_formwill pin different values, once the Python package ships them; until then the affected interop assertions are a known, expected failure (see the README note onnpm run fixtures). verifyBundleandAuditLog.verifyAnchornow check the bundle/anchor schema version and chain identity instead of ignoring them, so a bundle for the wrong version or the wrong chain no longer verifies.
[0.3.0] — 2026-08-29
Changed
- Scope values now use the same lowercase, dot-separated grammar as Python.
Only a complete terminal
.*wildcard is valid; it covers any depth below the segment boundary. Constructors and wire verification reject malformed scope syntax.
Added
- The two malformed-wildcard vectors from Python bring the shared interop suite to 19 vectors.
[0.2.1] — 2026-08-29
Changed
c14nis informational; producers still emit it, while verifiers enforce RFC 8785 JCS from canonical bytes and hashes regardless of the label.
[0.2.0] — 2026-08-29
Changed
- RFC 8785 JCS is the only canonicalization format. Delegation Tokens must
declare
c14n: JCSand already carry canonical header and payload bytes. Ledger entries, anchors and evidence bundles now carry the same marker and use JCS for every hash and signature. There is no legacy or dual-format reader. - The canonicalizer now uses ECMAScript number serialization, raw Unicode and UTF-16 code-unit member ordering, and rejects non-finite numbers, duplicate members, lone surrogates, unsupported values and cyclic structures.
- Cross-language fixtures now target Python
attenu-guard0.7 and include all 17 published interop vectors. The new reason codes arenon_finite,duplicate_member,canonicalization_requiredandnon_canonical.
Added
- Delegation-chain verification.
load(tokens, signer, {rootKeyIds, now})runs the Internet-Draft's Offline Verification Algorithm over a chain of Delegation Tokens and returns aVerifiedChainwhosepermitsauthorises against the leaf authority. All five in-scope steps are checked in the draft's order — JWS signatures with an alg-confusion guard, thepar_hashbyte commitment compared in constant time,del_depth/del_max_depthbounds, subsumption viaAuthority.isNarrowerThan, and the time claims includingexpmonotonicity along the chain — denying on the first failure with aWireErrorcarrying aWireReasonCode. Holder binding (cnf/DPoP) and status-list revocation, the draft's steps 6 and 7, are out of scope in both implementations.b64urlEncode/b64urlDecodeare exported alongside it. - The 17 language-independent interop vectors from the Python repository are generated
into
test/fixtures/vectors/and asserted here: the valid chain verifies and yields the leaf authority, and each adversarial chain is rejected for exactly the reason it declares.
[0.1.1] — 2026-08-28
Changed
- Releases are published from GitHub Actions with npm trusted publishing (OIDC) and provenance attestations; no tokens are stored anywhere. Node 20 is the minimum.
[0.1.0] — 2026-08-28
First release: the TypeScript implementation of attenu-guard.
Added
- Offline verifier.
verifyBundlechecks integrity, monotonicity (child ⊆ parent) and containment from an evidence bundle alone, with the signed anchor verified when a key is supplied. HS256 and Ed25519 anchors are both read.AuditLog.verifychecks a raw.jsonlledger's hash chain.delegationGraphanddenialsfold a bundle into the reviewer's view. - Core enforcement.
Authority(scopes withfamily.*wildcards, typed ceilings, TTL),Guard.issue,delegate(the meet: scopes intersect, ceilings take the tighter bound, TTL the shorter; a request wider than the parent comes back narrowed),checkreturning aDecision,enforcethrowingAuthorityDenied,wouldAllowandwouldDelegateas pure dry-runs, chain depth and fanout ceilings, cascaderevokeand principal-scopedrevokeAgent, strike policy, and a hash-chained audit log. - Ceilings:
RowLimit,SpendCap,CallLimit(optionally scoped, and self-metering),EgressRank,Allow,Deny,Prefix, plusregisterCeilingfor custom ones. An unrecognised constraint type denies rather than going unenforced. - Evidence custody.
exportBundlewith task redaction and a strict mode that refuses to carry any field outside the published ledger allow-list. - LangGraph.js adapter (
attenu-guard/adapters/langgraph):guardTool,guardTools,guardNode,addGuardedNode,delegateTo,toolArgs. The adapter never imports the framework. - CLI:
attenu-guard verify <file> [--hs256-key HEX | --pubkey HEX] [--kid KID], matching the Python CLI's output lines and exit codes. - Cross-language fixtures.
tools/gen_fixtures.pygeneratestest/fixtures/from the Python package; the suite reproduces every vector, and a further test has the Python CLI verify a ledger and bundle written here.
Notes
- Zero runtime dependencies. ESM and CommonJS builds, types included, Node 18+.
- Canonical JSON is byte-identical to Python's
json.dumps(obj, sort_keys=True, separators=(",", ":")), including ASCII escaping and number formatting. Documents that will be re-hashed are parsed with number literals preserved.