[AAASM-5787] 🐛 (adapters): Build an audit record on the deny branch of eight adapters - #320
Conversation
Most adapters return or raise at their `if status != "allow":` branch before reaching a record helper, so a denied tool call built no record for the sink to forward (AAASM-5787). This is what those branches will call. It is a leaf module because `_shared.tool_governance` imports `crewai.patch`, so an adapter importing the flow module for this would cycle. `_shared.positional_args` is the same shape.
The deny branch returned the blocked message straight past `_record_sync_tool_result`, so this adapter built nothing for the sink to forward (AAASM-5787). `test_terminal_pending_blocks_tool_and_does_not_run` asserted `recorded_results == []` to mean "the tool never ran". That held only while the deny branch recorded nothing at all, so it now asserts what was recorded instead: the rejection message, and no tool output.
The deny branch returned the blocked message straight past the record call, so this adapter built nothing for the sink to forward (AAASM-5787).
The deny branch returned the blocked message straight past the record call, so this adapter built nothing for the sink to forward (AAASM-5787).
The sync call and async acall wrappers each returned the denied output straight past their record call (AAASM-5787).
execute and aexecute each returned the denied result straight past their record call (AAASM-5787).
The deny branch raised straight past the record call, so this adapter built nothing for the sink to forward (AAASM-5787).
The deny branch raised straight past the record call, so this adapter built nothing for the sink to forward (AAASM-5787).
on_tool_start raised straight out with nothing recorded, and on_tool_end — the hook AAASM-5750 wired to the sink — fires only after a tool has run, so a denied tool never reached it. All three deny paths now route through _deny, which records first and raises unconditionally (AAASM-5787).
This adapter already recorded on the denied path, but the record it built was indistinguishable from a tool that ran and returned the denial text. The flag is offered only to hooks that can receive it (AAASM-5787).
truncate_result_for_audit and accepts_keyword now live in _shared.audit_record, which the deny branches import; tool_governance re-exports them under their private names so its callers and tests are unchanged.
One control per adapter, reaching that adapter's own deny branch — deleting one adapter's record call reddens that adapter's case and no other. The allowed path is the control that moves with it: both populate the hook, so only the denied flag distinguishes them. The harness gains record capture and run_scenario_capturing_records; the langchain driver now calls on_tool_end after a permitted start, as LangChain does, so its allowed path is comparable (AAASM-5787).
README, the Docs Hub landing note, the architecture page and the adapter-authoring guide all said three of eleven adapters recorded on a deny. Eleven do. The handoff caveat is unchanged: an offer to the hook is still not ADR 0033 §6 Observed while AAASM-5783 is open (AAASM-5787).
Both described the denied path as reached by three adapters. The disposition still says nothing about which branch a record came from — that is what the denial marker is for (AAASM-5787).
tool_governance, haystack and llamaindex each stated their denied calls produce no record (AAASM-5787).
| run_id=run_id, | ||
| ) | ||
| if inspect.isawaitable(returned): | ||
| await returned |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Review found the twelfth site: the bolded lead still read "and denied calls are mostly not covered" while both paragraphs it introduces had been corrected. It is the summary a reader takes away, and it contradicted README, the architecture page and .claude/CLAUDE.md. The population in this PR's body was 11 found / 11 fixed; it is 12 / 12 (AAASM-5787).
Several adapters call the hook with just (tool_name, result) on the allowed path, so a caller-supplied handler written to that contract is entitled to reject the rest. The deny paths passed four keywords unconditionally, raising TypeError into a suppression that swallowed it — the record was lost silently, in exactly the population the deny-record claim is about. Filtered in all three record paths, not only the new one: the shared async flow and openai_agents had the same shape and the same defect. openai_agents also sends `args`, which no other adapter's allowed path carries, so that is filtered too. An unreadable signature is not a refusal: a C-implemented callable keeps receiving agent_id and run_id, and only the newer `denied` flag is withheld (AAASM-5787).
… record One case per adapter with a handler accepting only (tool_name, result) and no **kwargs. It also pins the other half: a hook that cannot receive the denial marker still receives the record (AAASM-5787).
| # so the two copies that would otherwise exist stay one. Re-exported under their | ||
| # private names because this module's callers and tests already use those. | ||
| _truncate_result_for_audit = truncate_result_for_audit | ||
| _accepts_keyword = accepts_keyword |
|
Review round 1 — findings addressedIndependent review found one blocking item and three worth fixing. All addressed; head The population claim in this PR's body was wrong — it is 12, not 11F1 (blocking). Fixed, and the claim in this body corrected: population found 12, fixed 12. Re-swept after the fix — zero residual. Worth stating plainly, because it is the defect class this whole programme keeps finding: I enumerated the class by what a sentence asserts, caught eleven sites including five the ticket did not name, and then missed the headline of a block I had edited twice. F3 — the deny path demanded a wider signature than the allowed path, in three placesNot blocking per the review, but it falsifies the PR's own claim for caller-supplied hooks, so it is fixed. Several adapters call the audit hook with just
One subtlety the fix had to preserve: an unreadable signature is not a refusal. A C-implemented callable exposes nothing to introspect, and dropping every optional keyword there silently narrows a call that has always carried New control: F2 — the sync/async mismatch now says what it costs
F5 — dead constants removed
F4 — not changedNarrowing Suite
|



Description
AAASM-5750 wired the SDK interceptors' audit sink so a governed call's record reaches the runtime's event channel. In
python-sdkthat only helped where a record was constructed — and eight of the eleven governing adapters returned or raised at theirif status != "allow":branch before reaching their record helper. A denied tool call built nothing, so the sink had nothing to carry however it was configured.This adds
agent_assembly/adapters/_shared/audit_record.py— a leaf module (it imports no adapter, so the_shared.tool_governance→crewai.patchcycle does not apply;_shared.positional_argsis the same shape) — and calls it from each of the eight deny branches.Two things it deliberately does not do:
Observedclaim, anywhere. Handing a record to the hook is a handoff. The send is unacknowledged, and AAASM-5783 is open onreport_eventpayloads reaching neither the live stream nor the durable entry. What this PR establishes is narrower and checkable: the deny branch now builds a record and hands it over, which is upstream of the question 5783 asks.PolicyViolationError/ToolExecutionBlockedErrorwould stop recognising the deny. That question was settled for theopenai_agentspath under AAASM-4782 and is followed here rather than re-answered.One extra fix outside the eight
openai_agentswas already one of the three that recorded on a deny, but the record it built carried no marker — indistinguishable from a tool that ran and returned the denial text. It now passesdenied=True, offered only to hooks that can receive it. Without this the allowed/denied control below could not discriminate for that adapter.Type of Change
Breaking Changes
The public API is unchanged. The
deniedkeyword is offered only to audit hooks whose signature accepts it (an explicit parameter or a**kwargscatch-all), so a caller-supplied hook written against the older call keeps working.Related Issues
Testing
test/unit/adapters/test_deny_record_conformance.py— a matrix over the existingfailopen_conformancedriver registry, which already invokes each adapter's real governance wrapper against a fake framework tool. Three cases per adapter:denied=True;The registry's existing completeness check (
test_every_adapter_has_a_driver) already fails when a new adapter package ships without a driver, so a twelfth adapter cannot arrive un-measured here either.Proven able to fail — one mutation per adapter
Each adapter's record construction was severed in turn (rebinding that module's record function to a no-op), the matrix re-run, and the file restored. Exactly one case reddened each time, and it was that adapter's own:
crewaitest_a_policy_deny_builds_a_record_in_that_adapters_own_branch[crewai]haystack[haystack]smolagents[smolagents]llamaindex[llamaindex]agno[agno]microsoft_agent_framework[microsoft_agent_framework]mcp[mcp]langchain[langchain]openai_agents[openai_agents]No cross-talk: severing one adapter never reddened another's case. That is what makes this a per-adapter control rather than one control standing in for eleven.
Documentation claim class — population found 11, population fixed 11
The ticket named six sites. Enumerating the class by what a sentence asserts (rather than by one phrasing) found five more:
README.mddocs/index.md— landing notedocs/index.md— "a handoff is not evidence" calloutdocs/concepts/architecture.md.claude/CLAUDE.mdagent_assembly/core/runtime_interceptor.pyagent_assembly/core/audit_sink.pyagent_assembly/adapters/_shared/tool_governance.pydocs/guides/authoring-adapters.mdagent_assembly/adapters/haystack/patch.py(module docstring)agent_assembly/adapters/llamaindex/adapter.py(class docstring)docs/guides/authoring-adapters.mdis the one worth calling out: it told a new adapter author that most adapters do not record on the denied path. It now points at the shared helper and says to passdenied=True.No claim-binding control in this repo referenced any of these sentences — python-sdk has no claim-vocabulary gate, which is AAASM-5784's subject, so there was nothing to move in lockstep. Stating that explicitly rather than leaving it implied.
Also
♻️commit foldstruncate_result_for_audit/accepts_keywordinto the leaf module, so the two copies that would otherwise exist stay one (the cross-file duplication SonarCloud flagged on PR [AAASM-4734] 🐛 (adapters): Fail closed on unrecognized verdict and missing interceptor #269 / AAASM-4746 is the precedent).langchainconformance driver now callson_tool_endafter a permitted start, as LangChain itself does. Without it the driver modelled a framework that never completes a tool, and the allowed path looked like it recorded nothing.test_terminal_pending_blocks_tool_and_does_not_run(crewai) assertedrecorded_results == []to mean "the tool never ran". That held only while the deny branch recorded nothing at all, so it now asserts what was recorded: the rejection message, and no tool output.Checklist