From e3289be1c12e6b047ef97dccb301b8a566485b04 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:29:42 +0800 Subject: [PATCH 01/19] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Add=20a=20share?= =?UTF-8?q?d=20deny-branch=20audit=20record=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../adapters/_shared/audit_record.py | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 agent_assembly/adapters/_shared/audit_record.py diff --git a/agent_assembly/adapters/_shared/audit_record.py b/agent_assembly/adapters/_shared/audit_record.py new file mode 100644 index 00000000..4eb2b78f --- /dev/null +++ b/agent_assembly/adapters/_shared/audit_record.py @@ -0,0 +1,158 @@ +"""Hand a denied tool call's outcome to the adapter's audit hook. + +Most adapters return or raise at their ``if status != "allow":`` branch, so +before AAASM-5787 a denied call built no record at all — the interceptor's sink +had nothing to forward, however it was configured. This module is what those +branches call. + +It is a leaf: it imports no adapter, so any adapter can import it without the +cycle that ``_shared.tool_governance`` would create (that module imports +``crewai.patch``). ``_shared.positional_args`` is the same shape. + +What a call here does and does not establish +-------------------------------------------- +Handing the record to the hook is a *handoff*. Over a connected runtime the +SDK's own interceptor resolves ``record_result`` and writes to the native event +channel (AAASM-5750); without one, neither hook resolves and this emits nothing. +Neither case is ADR 0033 §6 *Observed* — that term asks for a durable event +attributed to the action, and AAASM-5783 is open on ``report_event`` payloads +reaching neither the live stream nor the durable entry. What the deny branch +gets from this module is that the record now exists to be forwarded, which is +upstream of the question AAASM-5783 asks. + +Exceptions are suppressed here rather than at each call site. The hook is +duck-typed from caller-supplied code, and these calls are being inserted where +none used to exist: a raising handler would otherwise replace a decided deny +with its own exception, and a caller matching on ``PolicyViolationError`` would +stop recognising the deny. A decided deny is final regardless of audit outcome — +settled for the ``openai_agents`` path under AAASM-4782, followed rather than +re-answered here. +""" + +from __future__ import annotations + +import contextlib +import inspect +from typing import Any + +MAX_AUDIT_RESULT_CHARS = 2000 + +_KEYWORD_PARAMETER_KINDS = ( + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, +) + + +def truncate_result_for_audit(result: object) -> str: + return str(result)[:MAX_AUDIT_RESULT_CHARS] + + +def accepts_keyword(method: Any, name: str) -> bool: + """Whether ``method`` can be called with the ``name`` keyword. + + The audit hook is duck-typed — adapters and user code supply their own + ``record_result`` / ``on_tool_end`` — and the implementations that predate + the denial flag were written without it, so passing it unconditionally + would raise ``TypeError`` on them. The flag is therefore offered only to + handlers that can receive it (an explicit parameter or a ``**kwargs`` + catch-all); the rest still get the record, just without the flag. + """ + try: + signature = inspect.signature(method) + except (TypeError, ValueError): + # C-implemented callables expose no introspectable signature. Fall back + # to the narrow call so the record is still emitted. + return False + for parameter in signature.parameters.values(): + if parameter.kind is inspect.Parameter.VAR_KEYWORD: + return True + if parameter.name == name and parameter.kind in _KEYWORD_PARAMETER_KINDS: + return True + return False + + +def _offer( + callback_handler: Any, + *, + tool_name: str, + result: object, + agent_id: str | None, + run_id: str | None, +) -> object | None: + """Call the first hook the handler exposes; return whatever it returned. + + The return value matters to the async entry point, which has to await a + coroutine hook. The sync entry point closes one instead — see there for why. + """ + denial_flag = {"denied": True} + payload = truncate_result_for_audit(result) + + record_method = getattr(callback_handler, "record_result", None) + if callable(record_method): + returned: object = record_method( + tool_name=tool_name, + result=payload, + agent_id=agent_id, + run_id=run_id, + **(denial_flag if accepts_keyword(record_method, "denied") else {}), + ) + return returned + + tool_end_method = getattr(callback_handler, "on_tool_end", None) + if callable(tool_end_method): + ended: object = tool_end_method( + output=payload, + tool_name=tool_name, + agent_id=agent_id, + run_id=run_id, + **(denial_flag if accepts_keyword(tool_end_method, "denied") else {}), + ) + return ended + + return None + + +def record_denied_tool_result( + callback_handler: Any, + *, + tool_name: str, + result: object, + agent_id: str | None = None, + run_id: str | None = None, +) -> None: + """Offer a denied call's record from a synchronous deny branch.""" + with contextlib.suppress(Exception): + returned = _offer( + callback_handler, + tool_name=tool_name, + result=result, + agent_id=agent_id, + run_id=run_id, + ) + if inspect.iscoroutine(returned): + # A sync deny branch has no loop to await on. Close it rather than + # abandon it: an un-awaited coroutine emits a RuntimeWarning at + # collection time, from this module, in a run whose real problem is + # that the handler and the adapter disagree about sync vs async. + returned.close() + + +async def arecord_denied_tool_result( + callback_handler: Any, + *, + tool_name: str, + result: object, + agent_id: str | None = None, + run_id: str | None = None, +) -> None: + """Offer a denied call's record from an asynchronous deny branch.""" + with contextlib.suppress(Exception): + returned = _offer( + callback_handler, + tool_name=tool_name, + result=result, + agent_id=agent_id, + run_id=run_id, + ) + if inspect.isawaitable(returned): + await returned From 5a767a2a92af245de3b6ab648f284721b453b8f5 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:31:26 +0800 Subject: [PATCH 02/19] =?UTF-8?q?=F0=9F=90=9B=20(crewai):=20Build=20an=20a?= =?UTF-8?q?udit=20record=20when=20the=20tool=20call=20is=20denied?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent_assembly/adapters/crewai/patch.py | 14 +++++++++++--- test/unit/adapters/crewai/test_patch.py | 8 ++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index dfb2d351..46544917 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -9,6 +9,7 @@ from threading import local from typing import Any, Literal, cast +from agent_assembly.adapters._shared.audit_record import record_denied_tool_result from agent_assembly.adapters._shared.positional_args import merge_positional_tool_args from agent_assembly.core.spawn import _SPAWN_CTX, SpawnContext, spawn_context_scope @@ -397,9 +398,16 @@ def patched_run(self: Any, *args: Any, **kwargs: Any) -> Any: # non-decision, not a grant — blocking it here stops it from falling # through and running the tool, matching the LangChain handler. if status != "allow": - if is_pending_flow: - return _format_approval_rejected_message(reason) - return _format_blocked_message(reason) + message = _format_approval_rejected_message(reason) if is_pending_flow else _format_blocked_message(reason) + # AAASM-5787: the deny used to return straight past the record call + # below, so this adapter built nothing for the sink to forward. + record_denied_tool_result( + callback_handler, + tool_name=str(tool_name), + result=message, + agent_id=agent_id, + ) + return message result = original_run(self, *args, **kwargs) _record_sync_tool_result(callback_handler, tool_name=str(tool_name), result=result) diff --git a/test/unit/adapters/crewai/test_patch.py b/test/unit/adapters/crewai/test_patch.py index e103d98f..6034b569 100644 --- a/test/unit/adapters/crewai/test_patch.py +++ b/test/unit/adapters/crewai/test_patch.py @@ -420,8 +420,12 @@ def record_result(self, **kwargs: object) -> None: assert isinstance(result, str) assert result.startswith("[APPROVAL REJECTED]") - # The original tool never executed, so its result was never recorded. - assert recorded_results == [] + # The original tool never executed, so its output was never recorded. This + # used to read `recorded_results == []`, which said the same thing only for + # as long as the deny branch recorded nothing at all; since AAASM-5787 it + # records the rejection, so the assertion is now about *what* was recorded. + assert recorded_results == ["[APPROVAL REJECTED] Action was reviewed and denied: still pending"] + assert not any(isinstance(entry, dict) for entry in recorded_results) def test_task_start_and_complete_events_are_recorded( From b23ebeabbda7a25c7389654708a1712068c70bec Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:45:29 +0800 Subject: [PATCH 03/19] =?UTF-8?q?=F0=9F=90=9B=20(haystack):=20Build=20an?= =?UTF-8?q?=20audit=20record=20when=20the=20tool=20call=20is=20denied?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deny branch returned the blocked message straight past the record call, so this adapter built nothing for the sink to forward (AAASM-5787). --- agent_assembly/adapters/haystack/patch.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/agent_assembly/adapters/haystack/patch.py b/agent_assembly/adapters/haystack/patch.py index 2b011441..5addb5f1 100644 --- a/agent_assembly/adapters/haystack/patch.py +++ b/agent_assembly/adapters/haystack/patch.py @@ -29,6 +29,7 @@ from functools import wraps from typing import Any, Literal, cast +from agent_assembly.adapters._shared.audit_record import record_denied_tool_result from agent_assembly.adapters._shared.positional_args import merge_positional_tool_args _TOOL_PATCHED_FLAG = "_agent_assembly_haystack_tool_patched" @@ -268,9 +269,11 @@ def patched_invoke(self: Any, *args: Any, **kwargs: Any) -> Any: # non-decision, not a grant — blocking it here stops it from falling # through and running the tool, matching the LangChain handler. if status != "allow": - if is_pending_flow: - return _format_approval_rejected_message(reason) - return _format_blocked_message(reason) + message = _format_approval_rejected_message(reason) if is_pending_flow else _format_blocked_message(reason) + # AAASM-5787: the deny used to return straight past the record call + # below, so this adapter built nothing for the sink to forward. + record_denied_tool_result(callback_handler, tool_name=tool_name, result=message) + return message result = original_invoke(self, *args, **kwargs) _record_tool_result(callback_handler, tool_name=tool_name, result=result) From 4b5b30d9ade2225c539ea1e6479a41921450c897 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:45:32 +0800 Subject: [PATCH 04/19] =?UTF-8?q?=F0=9F=90=9B=20(smolagents):=20Build=20an?= =?UTF-8?q?=20audit=20record=20when=20the=20tool=20call=20is=20denied?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deny branch returned the blocked message straight past the record call, so this adapter built nothing for the sink to forward (AAASM-5787). --- agent_assembly/adapters/smolagents/patch.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/agent_assembly/adapters/smolagents/patch.py b/agent_assembly/adapters/smolagents/patch.py index 12fc0d4f..18e4064e 100644 --- a/agent_assembly/adapters/smolagents/patch.py +++ b/agent_assembly/adapters/smolagents/patch.py @@ -24,6 +24,7 @@ # Reuse the governance-decision plumbing proven by the CrewAI adapter so every # framework normalizes verdicts and fail-closed posture identically. +from agent_assembly.adapters._shared.audit_record import record_denied_tool_result from agent_assembly.adapters.crewai.patch import ( _format_approval_rejected_message, _format_blocked_message, @@ -158,9 +159,11 @@ def patched_call(self: Any, *args: Any, **kwargs: Any) -> Any: # non-decision, not a grant — blocking it here stops it from falling # through and running the tool, matching the LangChain handler. if status != "allow": - if is_pending_flow: - return _format_approval_rejected_message(reason) - return _format_blocked_message(reason) + message = _format_approval_rejected_message(reason) if is_pending_flow else _format_blocked_message(reason) + # AAASM-5787: the deny used to return straight past the record call + # below, so this adapter built nothing for the sink to forward. + record_denied_tool_result(callback_handler, tool_name=tool_name, result=message, agent_id=agent_id) + return message result = original_call(self, *args, **kwargs) _record_sync_tool_result(callback_handler, tool_name=tool_name, result=result) From e3dfb92ecdc00ca23a2ce7bfdfabce33df252a3d Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:45:35 +0800 Subject: [PATCH 05/19] =?UTF-8?q?=F0=9F=90=9B=20(llamaindex):=20Build=20an?= =?UTF-8?q?=20audit=20record=20on=20both=20denied=20tool=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync call and async acall wrappers each returned the denied output straight past their record call (AAASM-5787). --- agent_assembly/adapters/llamaindex/patch.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/agent_assembly/adapters/llamaindex/patch.py b/agent_assembly/adapters/llamaindex/patch.py index 2943210e..7d3e4e03 100644 --- a/agent_assembly/adapters/llamaindex/patch.py +++ b/agent_assembly/adapters/llamaindex/patch.py @@ -27,6 +27,10 @@ from threading import local from typing import Any +from agent_assembly.adapters._shared.audit_record import ( + arecord_denied_tool_result, + record_denied_tool_result, +) from agent_assembly.adapters._shared.positional_args import merge_positional_tool_args from agent_assembly.adapters.crewai.patch import ( _get_pending_tool_approval_timeout_seconds as _resolve_pending_timeout_seconds, @@ -291,6 +295,9 @@ def patched_call(self: Any, *args: Any, **kwargs: Any) -> Any: # through and running the tool, matching the LangChain handler. if status != "allow": message = _format_approval_rejected_message(reason) if is_pending_flow else _format_blocked_message(reason) + # AAASM-5787: the deny used to return straight past the record call + # below, so this adapter built nothing for the sink to forward. + record_denied_tool_result(callback_handler, tool_name=tool_name, result=message, agent_id=agent_id) return _denied_tool_output(self, tool_name=tool_name, message=message) # Invoke the original via the descriptor protocol so the instance binds @@ -337,6 +344,9 @@ async def patched_acall(self: Any, *args: Any, **kwargs: Any) -> Any: # through and running the tool, matching the LangChain handler. if status != "allow": message = _format_approval_rejected_message(reason) if is_pending_flow else _format_blocked_message(reason) + # AAASM-5787: as in ``patched_call``, this branch returned past the + # record call below and built nothing for the sink to forward. + await arecord_denied_tool_result(callback_handler, tool_name=tool_name, result=message, agent_id=agent_id) return _denied_tool_output(self, tool_name=tool_name, message=message) # See ``patched_call`` for why the original is invoked via the From 1696a8aa1c85ae0ae8ec303f52bc927df523bd78 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:45:39 +0800 Subject: [PATCH 06/19] =?UTF-8?q?=F0=9F=90=9B=20(agno):=20Build=20an=20aud?= =?UTF-8?q?it=20record=20on=20both=20denied=20tool=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute and aexecute each returned the denied result straight past their record call (AAASM-5787). --- agent_assembly/adapters/agno/patch.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/agent_assembly/adapters/agno/patch.py b/agent_assembly/adapters/agno/patch.py index 451088d3..22c32679 100644 --- a/agent_assembly/adapters/agno/patch.py +++ b/agent_assembly/adapters/agno/patch.py @@ -24,6 +24,10 @@ from functools import wraps from typing import Any +from agent_assembly.adapters._shared.audit_record import ( + arecord_denied_tool_result, + record_denied_tool_result, +) from agent_assembly.adapters.crewai.patch import ( _format_approval_rejected_message as _format_approval_rejected, ) @@ -200,6 +204,9 @@ def patched_execute(self: Any, *args: Any, **kwargs: Any) -> Any: # through and running the tool, matching the LangChain handler. if status != "allow": message = _format_approval_rejected(reason) if is_pending_flow else _format_blocked(reason) + # AAASM-5787: the deny used to return straight past the record call + # below, so this adapter built nothing for the sink to forward. + record_denied_tool_result(callback_handler, tool_name=tool_name, result=message) return _build_denied_result(message) result = original_execute(self, *args, **kwargs) @@ -227,6 +234,9 @@ async def patched_aexecute(self: Any, *args: Any, **kwargs: Any) -> Any: # from falling through and running the tool, matching LangChain. if status != "allow": message = _format_approval_rejected(reason) if is_pending_flow else _format_blocked(reason) + # AAASM-5787: as in ``patched_execute``, this branch returned + # past the record call below and built nothing to forward. + await arecord_denied_tool_result(callback_handler, tool_name=tool_name, result=message) return _build_denied_result(message) result = await original_aexecute(self, *args, **kwargs) From 46139affde64a64c763d5035fd74ace6e3eadbd8 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:45:44 +0800 Subject: [PATCH 07/19] =?UTF-8?q?=F0=9F=90=9B=20(microsoft-agent-framework?= =?UTF-8?q?):=20Build=20an=20audit=20record=20when=20denied?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deny branch raised straight past the record call, so this adapter built nothing for the sink to forward (AAASM-5787). --- .../adapters/microsoft_agent_framework/patch.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/agent_assembly/adapters/microsoft_agent_framework/patch.py b/agent_assembly/adapters/microsoft_agent_framework/patch.py index ebfbe485..93e5d957 100644 --- a/agent_assembly/adapters/microsoft_agent_framework/patch.py +++ b/agent_assembly/adapters/microsoft_agent_framework/patch.py @@ -27,6 +27,7 @@ from functools import wraps from typing import TYPE_CHECKING, Any, Literal +from agent_assembly.adapters._shared.audit_record import arecord_denied_tool_result from agent_assembly.adapters.crewai.patch import ( _get_pending_tool_approval_timeout_seconds as _resolve_pending_timeout_seconds, ) @@ -334,9 +335,17 @@ async def patched_invoke(self: Any, *args: Any, **kwargs: Any) -> Any: # non-decision, not a grant — blocking it here stops it from falling # through and running the tool, matching the LangChain handler. if status != "allow": - if is_pending_flow: - raise _build_pending_rejected_error(tool_name, reason) - raise _build_denied_error(tool_name, reason) + error = ( + _build_pending_rejected_error(tool_name, reason) + if is_pending_flow + else _build_denied_error(tool_name, reason) + ) + # AAASM-5787: the deny used to raise straight past the record call + # below, so this adapter built nothing for the sink to forward. + await arecord_denied_tool_result( + callback_handler, tool_name=tool_name, result=str(error), agent_id=agent_id + ) + raise error spawn_ctx = SpawnContext( parent_agent_id=agent_id or "", From db1a3bf0c01d8d727e3f57cb8b6ac888a91af0a9 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:45:47 +0800 Subject: [PATCH 08/19] =?UTF-8?q?=F0=9F=90=9B=20(mcp):=20Build=20an=20audi?= =?UTF-8?q?t=20record=20when=20the=20tool=20call=20is=20denied?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deny branch raised straight past the record call, so this adapter built nothing for the sink to forward (AAASM-5787). --- agent_assembly/adapters/mcp/patch.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/agent_assembly/adapters/mcp/patch.py b/agent_assembly/adapters/mcp/patch.py index c8dcdda3..c492f7c0 100644 --- a/agent_assembly/adapters/mcp/patch.py +++ b/agent_assembly/adapters/mcp/patch.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from agent_assembly.exceptions import MCPToolBlockedError +from agent_assembly.adapters._shared.audit_record import arecord_denied_tool_result from agent_assembly.adapters.crewai.patch import ( _get_pending_tool_approval_timeout_seconds as _resolve_pending_timeout_seconds, ) @@ -289,12 +290,18 @@ async def patched_call_tool(self: Any, *args: Any, **kwargs: Any) -> Any: # non-decision, not a grant — blocking it here stops it from falling # through and running the tool, matching the LangChain handler. if status != "allow": - raise _build_blocked_error( + error = _build_blocked_error( tool_name=tool_name, server_identifier=server_identifier, reason=reason, is_pending_rejection=is_pending_flow, ) + # AAASM-5787: the deny used to raise straight past the record call + # below, so this adapter built nothing for the sink to forward. + await arecord_denied_tool_result( + callback_handler, tool_name=tool_name, result=str(error), agent_id=agent_id + ) + raise error result = original_call_tool(self, *args, **kwargs) if inspect.isawaitable(result): From 08523ccbc6c4d0bed6e78c1418b845188d6d3ca5 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:46:01 +0800 Subject: [PATCH 09/19] =?UTF-8?q?=F0=9F=90=9B=20(langchain):=20Build=20an?= =?UTF-8?q?=20audit=20record=20on=20the=20three=20deny=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../adapters/langchain/callback_handler.py | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/agent_assembly/adapters/langchain/callback_handler.py b/agent_assembly/adapters/langchain/callback_handler.py index 94cbb2e2..27b34b8e 100644 --- a/agent_assembly/adapters/langchain/callback_handler.py +++ b/agent_assembly/adapters/langchain/callback_handler.py @@ -4,9 +4,10 @@ import importlib from collections.abc import Mapping -from typing import Any, Literal, cast +from typing import Any, Literal, NoReturn, cast from uuid import UUID +from agent_assembly.adapters._shared.audit_record import record_denied_tool_result from agent_assembly.core.audit_sink import ( AUDIT_SINK_ABSENT, AUDIT_SINK_DISCARDED, @@ -164,6 +165,7 @@ def on_tool_start( run_id: UUID, **kwargs: Any, ) -> None: + tool_name = self._tool_name(serialized) method = getattr(self._interceptor, "check_tool_start", None) if not callable(method): # Mirrors the other adapters' ``_missing_interceptor_decision`` @@ -173,7 +175,7 @@ def on_tool_start( # ``enforce``, so fail closed there and only fail open under # observe / disabled, consistent with ``_unknown_decision``. if self._enforce: - raise ToolExecutionBlockedError(self._MISSING_CHECK_TOOL_START_REASON) + self._deny(tool_name, run_id, self._MISSING_CHECK_TOOL_START_REASON) return None decision = method( @@ -184,7 +186,7 @@ def on_tool_start( ) status, reason = self._normalize_decision(decision) if status == "deny": - raise ToolExecutionBlockedError(reason or "Tool execution blocked by governance.") + self._deny(tool_name, run_id, reason or "Tool execution blocked by governance.") if status == "pending": approval = self._resolve_pending_approval( serialized=serialized, @@ -194,12 +196,46 @@ def on_tool_start( ) approval_status, approval_reason = self._normalize_decision(approval) if approval_status != "allow": - raise ToolExecutionBlockedError( - approval_reason or reason or "Tool execution was not approved by governance." + self._deny( + tool_name, + run_id, + approval_reason or reason or "Tool execution was not approved by governance.", ) return None + @staticmethod + def _tool_name(serialized: dict[str, Any]) -> str: + """The tool's name as LangChain reports it on the start callback. + + LangChain puts it in ``serialized["name"]``. It is absent for a tool + constructed without one, and the record is worth more with an empty + name than not at all, so this does not raise. + """ + name = serialized.get("name") + return name if isinstance(name, str) else "" + + def _deny(self, tool_name: str, run_id: UUID, reason: str) -> NoReturn: + """Record the denial, then block the call by raising. + + 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. This adapter + therefore built no record on any of its three deny paths. + + The record is offered first and the raise is unconditional: the helper + suppresses a raising audit hook so it cannot replace a decided deny with + its own exception, and a caller matching on ``ToolExecutionBlockedError`` + keeps recognising the deny. + """ + record_denied_tool_result( + self._interceptor, + tool_name=tool_name, + result=reason, + run_id=str(run_id), + ) + raise ToolExecutionBlockedError(reason) + def _resolve_pending_approval( self, *, From ded98d82aa9ad6fcf70d7fd475fb92dfc1079a8d Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:46:04 +0800 Subject: [PATCH 10/19] =?UTF-8?q?=F0=9F=90=9B=20(openai=5Fagents):=20Mark?= =?UTF-8?q?=20the=20denied=20record=20so=20it=20reads=20as=20a=20deny?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- agent_assembly/adapters/openai_agents/patch.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index 0e6c260e..042f62ca 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -31,6 +31,7 @@ from functools import wraps from typing import Any, Literal +from agent_assembly.adapters._shared.audit_record import accepts_keyword from agent_assembly.adapters.crewai.patch import ( _get_pending_tool_approval_timeout_seconds as _resolve_pending_timeout_seconds, ) @@ -416,7 +417,13 @@ async def _record_async_tool_result( result: object, agent_id: str | None, ctx: Any, + denied: bool = False, ) -> None: + # AAASM-5787: this adapter was already one of the three that 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, for the reason ``accepts_keyword`` documents. + denial_flag = {"denied": denied} if denied else {} target = _resolve_governance_target(callback_handler) record_method = getattr(target, "record_result", None) @@ -427,6 +434,7 @@ async def _record_async_tool_result( result=_truncate_result_for_audit(result), agent_id=agent_id, run_context=ctx, + **(denial_flag if accepts_keyword(record_method, "denied") else {}), ) if inspect.isawaitable(recorded): await recorded @@ -439,6 +447,7 @@ async def _record_async_tool_result( tool_name=tool_name, agent_id=agent_id, run_context=ctx, + **(denial_flag if accepts_keyword(tool_end_method, "denied") else {}), ) if inspect.isawaitable(recorded): await recorded @@ -465,6 +474,7 @@ async def _record_denied_tool_result( try: await _record_async_tool_result( callback_handler, + denied=True, tool_name=tool_name, tool_input=tool_input, result=result, From 429dafc804141fcb2966c150bdc9dfc5e7b92cbe Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:46:06 +0800 Subject: [PATCH 11/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(adapters):=20Fold?= =?UTF-8?q?=20the=20duplicated=20audit=20helpers=20into=20the=20leaf=20mod?= =?UTF-8?q?ule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../adapters/_shared/tool_governance.py | 33 ++++--------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/agent_assembly/adapters/_shared/tool_governance.py b/agent_assembly/adapters/_shared/tool_governance.py index affba47b..4af5214e 100644 --- a/agent_assembly/adapters/_shared/tool_governance.py +++ b/agent_assembly/adapters/_shared/tool_governance.py @@ -31,6 +31,7 @@ if TYPE_CHECKING: from agent_assembly.exceptions import PolicyViolationError +from agent_assembly.adapters._shared.audit_record import accepts_keyword, truncate_result_for_audit from agent_assembly.adapters.crewai.patch import ( _get_pending_tool_approval_timeout_seconds as _resolve_pending_timeout_seconds, ) @@ -133,33 +134,11 @@ def _get_pending_tool_approval_timeout_seconds(callback_handler: Any) -> int: return _resolve_pending_timeout_seconds(callback_handler) -def _truncate_result_for_audit(result: object) -> str: - return str(result)[:_MAX_AUDIT_RESULT_CHARS] - - -def _accepts_keyword(method: Any, name: str) -> bool: - """Whether ``method`` can be called with the ``name`` keyword. - - The audit hook is duck-typed — adapters and user code supply their own - ``record_result`` / ``on_tool_end`` — and every existing implementation was - written against the four-keyword call, so passing a new keyword - unconditionally would raise ``TypeError`` on all of them. The denial flag is - therefore offered only to handlers that can receive it (an explicit - parameter or a ``**kwargs`` catch-all); the rest still get the record, just - without the flag. - """ - try: - signature = inspect.signature(method) - except (TypeError, ValueError): - # C-implemented callables expose no introspectable signature. Fall back - # to the narrow call so the record is still emitted. - return False - for parameter in signature.parameters.values(): - if parameter.kind is inspect.Parameter.VAR_KEYWORD: - return True - if parameter.name == name and parameter.kind in _KEYWORD_PARAMETER_KINDS: - return True - return False +# Both live in the leaf module the adapters' deny branches import (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 async def _record_async_tool_result( From 00f6542aaaed5c29810fa2fe8714b06eac648842 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:46:18 +0800 Subject: [PATCH 12/19] =?UTF-8?q?=E2=9C=85=20(adapters):=20Add=20the=20per?= =?UTF-8?q?-adapter=20deny-record=20conformance=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- test/unit/adapters/failopen_conformance.py | 53 +++++++-- .../adapters/test_deny_record_conformance.py | 102 ++++++++++++++++++ 2 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 test/unit/adapters/test_deny_record_conformance.py diff --git a/test/unit/adapters/failopen_conformance.py b/test/unit/adapters/failopen_conformance.py index 10147584..729df3c7 100644 --- a/test/unit/adapters/failopen_conformance.py +++ b/test/unit/adapters/failopen_conformance.py @@ -118,6 +118,11 @@ class _FaultInterceptor: def __init__(self, scenario: str, *, enforce: bool) -> None: self._scenario = scenario self._enforce = enforce + # Every offer made to either audit hook, in order. AAASM-5787 asserts over + # this: a deny that hands nothing over leaves it empty, and the allowed + # path populates it too, so the two are compared rather than one being + # read on its own. + self.records: list[dict[str, Any]] = [] def check_tool_start(self, **_kwargs: Any) -> object: scenario = self._scenario @@ -140,10 +145,14 @@ def wait_for_tool_approval(self, **_kwargs: Any) -> object: # adapter must fail closed under enforce and open under observe. return None - def record_result(self, **_kwargs: Any) -> None: + def record_result(self, **kwargs: Any) -> None: + # Captured before the audit fault fires: an offer that reached the hook + # and then met a raising sink is still an offer the adapter made. + self.records.append(dict(kwargs)) self._maybe_fail_audit() - def on_tool_end(self, **_kwargs: Any) -> None: + def on_tool_end(self, **kwargs: Any) -> None: + self.records.append(dict(kwargs)) self._maybe_fail_audit() def _maybe_fail_audit(self) -> None: @@ -163,12 +172,13 @@ class _MissingCheckInterceptor: def __init__(self, *, enforce: bool) -> None: self._enforce = enforce + self.records: list[dict[str, Any]] = [] - def record_result(self, **_kwargs: Any) -> None: - return None + def record_result(self, **kwargs: Any) -> None: + self.records.append(dict(kwargs)) - def on_tool_end(self, **_kwargs: Any) -> None: - return None + def on_tool_end(self, **kwargs: Any) -> None: + self.records.append(dict(kwargs)) def make_fake_interceptor(scenario: str, *, enforce: bool) -> object: @@ -279,8 +289,14 @@ async def _drive_langchain(interceptor: object, ran: list[bool]) -> None: # LangChain governs via a pre-execution callback: on_tool_start raises to abort the # tool. The tool "runs" iff the pre-hook returns without blocking. handler = AssemblyCallbackHandler(interceptor) - handler.on_tool_start({"name": "conformance_tool"}, "input", run_id=uuid4()) + run_id = uuid4() + handler.on_tool_start({"name": "conformance_tool"}, "input", run_id=run_id) ran[0] = True + # LangChain calls this after the tool body; without it the driver models a + # framework that never completes a tool, and the allowed path would look + # like it records nothing (AAASM-5787). Unreachable on a blocked start, + # which is what makes the two paths comparable. + handler.on_tool_end("conformance output", run_id=run_id) async def _drive_llamaindex(interceptor: object, ran: list[bool]) -> None: @@ -483,3 +499,26 @@ async def run_scenario(adapter_name: str, scenario: str, mode: str | None) -> bo with contextlib.suppress(AssemblyError): await DRIVERS[adapter_name](interceptor, ran) return ran[0] + + +async def run_scenario_capturing_records( + adapter_name: str, + scenario: str, + mode: str | None, +) -> tuple[bool, list[dict[str, Any]]]: + """Like :func:`run_scenario`, but also return what reached the audit hook. + + Separate from ``run_scenario`` because that function's contract is "the + ``ran`` flag is the sole signal", and AAASM-5787 needs the other one. The + interceptor is the same fake, so a record here is one the adapter's own deny + branch built — not one synthesised by the harness. + """ + from agent_assembly.exceptions import AssemblyError + + enforce = _local_posture_is_enforce(mode) + interceptor = make_fake_interceptor(scenario, enforce=enforce) + ran = [False] + with contextlib.suppress(AssemblyError): + await DRIVERS[adapter_name](interceptor, ran) + records = getattr(interceptor, "records", []) + return ran[0], list(records) diff --git a/test/unit/adapters/test_deny_record_conformance.py b/test/unit/adapters/test_deny_record_conformance.py new file mode 100644 index 00000000..c0948009 --- /dev/null +++ b/test/unit/adapters/test_deny_record_conformance.py @@ -0,0 +1,102 @@ +"""Every governing adapter builds an audit record on its own deny branch (AAASM-5787). + +Before this, eight of the eleven governing adapters returned or raised at +``if status != "allow":`` and never reached their record helper, so a denied tool +call constructed nothing — whatever sink the interceptor had was irrelevant, +because there was no record to carry. AAASM-5750 wired the sink; this is the +half upstream of it. + +Why this file rather than eight per-adapter tests +------------------------------------------------- +It reuses :mod:`test.unit.adapters.failopen_conformance`, whose drivers already +invoke each adapter's *real* governance wrapper against a fake framework tool. +Two consequences matter: + +* Each parametrised case reaches **that adapter's own** deny branch. Deleting one + adapter's record call reddens that adapter's case and no other, which is what + makes the matrix a per-adapter control rather than one control standing in for + eleven. +* The registry's 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. + +What is asserted, and against what control +------------------------------------------ +An offer to the audit hook is not evidence: over a connected runtime the SDK's +interceptor writes it to the native event channel unacknowledged, and +AAASM-5783 is open on ``report_event`` payloads reaching neither the live stream +nor the durable entry. So no ADR 0033 §6 *Observed* claim is made here or +anywhere downstream of it. What is asserted is narrower and checkable: the deny +branch **built** a record and handed it over. + +The denied and allowed paths are compared rather than the denied one being read +alone. Both populate the hook, so "a record exists" cannot distinguish them; the +``denied`` flag can, and the allowed case is asserted to carry no such flag. A +single-sided assertion here would pass against an adapter that recorded the +allowed path twice. +""" + +from __future__ import annotations + +from test.unit.adapters import failopen_conformance as conf + +import pytest + + +def _denied_records(records: list[dict[str, object]]) -> list[dict[str, object]]: + return [record for record in records if record.get("denied") is True] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("adapter_name", sorted(conf.DRIVERS)) +async def test_a_policy_deny_builds_a_record_in_that_adapters_own_branch(adapter_name: str) -> None: + """An authoritative deny hands a record to the audit hook, marked as a deny.""" + ran, records = await conf.run_scenario_capturing_records(adapter_name, conf.EXPLICIT_DENY, "enforce") + + assert ran is False, f"{adapter_name}: the tool ran under an authoritative deny" + assert records, ( + f"{adapter_name}: the deny branch handed nothing to the audit hook, so no record " + f"existed for any sink to carry (AAASM-5787)" + ) + denied = _denied_records(records) + assert denied, ( + f"{adapter_name}: {len(records)} record(s) reached the hook on the denied path but none " + f"carried denied=True, so a reader cannot tell them from a tool that ran and returned " + f"the denial text" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("adapter_name", sorted(conf.DRIVERS)) +async def test_an_allowed_call_records_without_the_denial_flag(adapter_name: str) -> None: + """The control that moves with the variable under test. + + Both paths reach the hook, so the presence of a record decides nothing on its + own. This pins the other side: an allowed call records, and its record is not + marked as a deny. Without it, an adapter that recorded the allowed outcome on + both branches would satisfy the deny test above. + """ + ran, records = await conf.run_scenario_capturing_records(adapter_name, conf.EXPLICIT_ALLOW, "enforce") + + assert ran is True, f"{adapter_name}: the tool did not run under an authoritative allow" + assert records, f"{adapter_name}: the allowed path handed nothing to the audit hook" + assert not _denied_records(records), ( + f"{adapter_name}: an allowed call produced a record marked denied=True, so the flag " + f"does not distinguish the two paths" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("adapter_name", sorted(conf.DRIVERS)) +async def test_a_deny_whose_audit_hook_raises_still_records_and_still_blocks(adapter_name: str) -> None: + """AAASM-4782's invariant, extended to the branches AAASM-5787 added. + + The record calls are new on eight deny branches, and the hook is duck-typed + from caller-supplied code. A raising hook must not re-enter the run path and + downgrade a decided deny — and the offer must still have been made, which is + why the fake captures before it raises. + """ + ran, records = await conf.run_scenario_capturing_records(adapter_name, conf.DENY_AUDIT_RAISES, "enforce") + + assert ran is False, f"{adapter_name}: a raising audit hook let the denied tool run" + assert _denied_records(records), f"{adapter_name}: the deny record was not offered before the hook raised" From 952e3fc6aa52932cc8239edd173478c451c0b107 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:47:52 +0800 Subject: [PATCH 13/19] =?UTF-8?q?=F0=9F=93=9D=20(docs):=20Say=20that=20den?= =?UTF-8?q?ied=20calls=20are=20recorded,=20and=20by=20how=20many=20adapter?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- README.md | 2 +- docs/concepts/architecture.md | 2 +- docs/guides/authoring-adapters.md | 2 +- docs/index.md | 16 +++++++++------- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 206bc972..ce84455a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Python SDK for **AI Agent Assembly** — a governance-native runtime for AI agen > **The SDK hands records to the runtime; it does not give you an audit trail.** The framework adapters offer a governed call's outcome to an audit hook on the governance interceptor, and over a connected runtime that hook writes it to the native event channel — the same one agent registration uses. That is a handoff, **not** evidence: the send is unacknowledged, so this SDK cannot tell you the record arrived, and does not claim it did. Downstream, [AAASM-5783](https://lightning-dust-mite.atlassian.net/browse/AAASM-5783) is open on `report_event` payloads reaching neither the live stream nor the durable entry — until it lands, no SDK can claim ADR 0033 §6 *Observed*. Without a reachable runtime there is no channel at all and nothing is emitted. > -> **Denied calls are mostly not covered.** Only `google_adk`, `pydantic_ai` and `openai_agents` build a record on the denied path. The other eight governed adapters — `crewai`, `llamaindex`, `haystack`, `agno`, `smolagents`, `microsoft_agent_framework`, `mcp` and `langchain` — return or raise before their record helper, so a deny there produces no record for any sink to carry. Enforcement is unaffected either way: a policy DENY still blocks the tool. `init_assembly()` warns when no record can be sent and reports `audit_sink` on the returned context ([AAASM-5750](https://lightning-dust-mite.atlassian.net/browse/AAASM-5750)). +> **Denied calls are covered too.** The eleven governing adapters build a record on the denied path as well as the allowed one, marked so a reader can tell a blocked call from a tool that ran and returned the denial text ([AAASM-5787](https://lightning-dust-mite.atlassian.net/browse/AAASM-5787)); eight of them used to return or raise before their record helper. `langgraph` instruments nodes for lineage and has no tool-call gate, so it records neither. Enforcement is unaffected either way: a policy DENY still blocks the tool. `init_assembly()` warns when no record can be sent and reports `audit_sink` on the returned context ([AAASM-5750](https://lightning-dust-mite.atlassian.net/browse/AAASM-5750)). ## Why use it diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index c65c2ca5..fa5fb2ac 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -69,7 +69,7 @@ flowchart LR Solid arrows are install-time; dashed arrows fire on every framework call after hooks are installed. The interceptor → gateway hop is the only network boundary in the data path. -The audit edge on that hop runs to the runtime, not to the gateway. The adapters offer a governed outcome to an audit hook on the interceptor; over a connected runtime that hook resolves and writes the record to the native event channel. It is a handoff — unacknowledged, so the SDK cannot report arrival — and it covers every governed adapter on the allowed path but only `google_adk`, `pydantic_ai` and `openai_agents` on the denied one; the other eight return or raise before their record helper. Without a reachable runtime the hook does not resolve and no record leaves the SDK ([AAASM-5750](https://lightning-dust-mite.atlassian.net/browse/AAASM-5750)). Downstream of the handoff, [AAASM-5783](https://lightning-dust-mite.atlassian.net/browse/AAASM-5783) is open on `report_event` payloads reaching neither the live stream nor the durable entry — until it lands, no SDK can claim ADR 0033 §6 *Observed*. +The audit edge on that hop runs to the runtime, not to the gateway. The adapters offer a governed outcome to an audit hook on the interceptor; over a connected runtime that hook resolves and writes the record to the native event channel. It is a handoff — unacknowledged, so the SDK cannot report arrival — and it covers the eleven governing adapters on the allowed path and on the denied one, where the record carries a denial marker ([AAASM-5787](https://lightning-dust-mite.atlassian.net/browse/AAASM-5787)); eight of them used to return or raise before their record helper. Without a reachable runtime the hook does not resolve and no record leaves the SDK ([AAASM-5750](https://lightning-dust-mite.atlassian.net/browse/AAASM-5750)). Downstream of the handoff, [AAASM-5783](https://lightning-dust-mite.atlassian.net/browse/AAASM-5783) is open on `report_event` payloads reaching neither the live stream nor the durable entry — until it lands, no SDK can claim ADR 0033 §6 *Observed*. ## PyO3 FFI layer diff --git a/docs/guides/authoring-adapters.md b/docs/guides/authoring-adapters.md index b6e8e0a6..b45a78bd 100644 --- a/docs/guides/authoring-adapters.md +++ b/docs/guides/authoring-adapters.md @@ -134,7 +134,7 @@ status string `"allow" | "deny" | "pending"`, or a mapping `{"status": ..., "rea | `check_tool_start` / `check_tool_call` | adapter → interceptor, returns decision | Pre-execution gate for a tool call; `deny` blocks it. | | `wait_for_tool_approval` | adapter → interceptor, returns decision | Block until a `pending` tool call is approved or rejected (human-in-the-loop). | | `get_pending_tool_approval_timeout_seconds` | adapter → interceptor | Configurable timeout for the approval wait. | -| `record_result` / `on_tool_end` | adapter → interceptor, no return | Offer a governed tool call's outcome for audit. Over a connected runtime the SDK's interceptor resolves both names and writes the record to the runtime's event channel — a handoff, not a retention guarantee; without one neither resolves and the `getattr` guard finds nothing. **Your adapter must call this on the denied path too** — most do not: only `google_adk`, `pydantic_ai` and `openai_agents` do, and the other eight return or raise first (AAASM-5750). | +| `record_result` / `on_tool_end` | adapter → interceptor, no return | Offer a governed tool call's outcome for audit. Over a connected runtime the SDK's interceptor resolves both names and writes the record to the runtime's event channel — a handoff, not a retention guarantee; without one neither resolves and the `getattr` guard finds nothing. **Your adapter must call this on the denied path too**, passing `denied=True` so the record is distinguishable from a tool that ran and returned the denial text. `_shared.audit_record.record_denied_tool_result` (and its `a`-prefixed async sibling) is what the eleven governing adapters call there; it suppresses a raising hook, because a decided deny is final regardless of audit outcome (AAASM-5787). | | `record` | adapter → interceptor, no return | Generic structured event (e.g. `action="task_start"`). **No interceptor this SDK ships resolves this name** — unlike the row above, `record` was not wired by AAASM-5750. The CrewAI patch looks it up for task start/complete (`crewai/patch.py:429,445`) and finds nothing, so those events are recorded only by a caller-supplied handler. | !!! note "These are conventions, not a typed contract" diff --git a/docs/index.md b/docs/index.md index 76fb38ad..7bf4450a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -63,8 +63,8 @@ see the note under "Why use it" below. The framework adapters offer the outcome of every governed call to an audit hook on the governance interceptor. Over a connected runtime that hook resolves and writes the record to the native event channel — the same channel agent - registration uses. On the **allowed** path that covers every governed adapter; - on the denied path it covers three of eleven (see below). + registration uses. That covers the eleven governing adapters on the **allowed** + path and, since AAASM-5787, on the denied one as well (see below). Without a reachable runtime there is no channel to send on, the hook does not resolve, and nothing is emitted; no claim of attributability or after-the-fact @@ -80,11 +80,13 @@ see the note under "Why use it" below. claim it did — and downstream, [AAASM-5783](https://lightning-dust-mite.atlassian.net/browse/AAASM-5783) is open on `report_event` payloads reaching neither the live stream nor the durable - entry, so no SDK can claim ADR 0033 §6 *Observed* until it lands. And only `google_adk`, `pydantic_ai` and `openai_agents` build a - record on the **denied** path — the other eight governed adapters (`crewai`, - `llamaindex`, `haystack`, `agno`, `smolagents`, `microsoft_agent_framework`, - `mcp`, `langchain`) return or raise before their record helper, so a deny there - produces no record for any sink to carry. + entry, so no SDK can claim ADR 0033 §6 *Observed* until it lands. That limit + applies to the denied path as much as the allowed one: the eleven governing + adapters build a record on both + ([AAASM-5787](https://lightning-dust-mite.atlassian.net/browse/AAASM-5787)), + and the record carries a denial marker so a blocked call is distinguishable + from a tool that ran and returned the denial text — but handing it over is + still a handoff, not evidence. - **Native PyO3 fast path** (optional) — drop into a Rust runtime client when you need sub-millisecond policy checks. - **Typed throughout** — typed models for every gateway payload; the package ships a From 1bc98898486d8907391e86c45e410c257acc9821 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:47:54 +0800 Subject: [PATCH 14/19] =?UTF-8?q?=F0=9F=93=9D=20(claude):=20Correct=20the?= =?UTF-8?q?=20SDK=20layer's=20denied-path=20description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AAASM-5787. --- .claude/CLAUDE.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 37c13529..9f5bd73f 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -39,10 +39,11 @@ ordered by latency cost (lowest first) and detection authority (highest first): load-bearing and must not be dropped when describing this: the send is unacknowledged, so a handoff is **not** evidence and never ADR 0033 §6 *Observed* — AAASM-5783 is open on the downstream half and must land before - that changes; and only `google_adk`, `pydantic_ai` and `openai_agents` record on the - **denied** path — the other eight governed adapters return or raise first. With no - reachable runtime nothing is recorded at all (AAASM-5750). Never describe this - layer as producing an audit trail. + that changes. The eleven governing adapters record on the **denied** path as well + as the allowed one, with a denial marker on the record (AAASM-5787); `langgraph` + instruments nodes for lineage and has no tool-call gate. With no reachable runtime + nothing is recorded at all (AAASM-5750). Do not describe this layer as producing an + audit trail. 2. **Sidecar proxy (`aa-proxy`)** — MitM of outbound HTTPS; enforces network-egress policy with no code changes. (Lives in the monorepo.) 3. **eBPF (`aa-ebpf*`)** — kernel uprobes; catches everything, including bypass From d7091f1eadad1aabaf61ff77ebe24b50c98f495c Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:47:55 +0800 Subject: [PATCH 15/19] =?UTF-8?q?=F0=9F=93=9D=20(core):=20Correct=20the=20?= =?UTF-8?q?audit-sink=20and=20interceptor=20docstrings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- agent_assembly/core/audit_sink.py | 8 ++++---- agent_assembly/core/runtime_interceptor.py | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/agent_assembly/core/audit_sink.py b/agent_assembly/core/audit_sink.py index 4ac0bdfa..381bcc23 100644 --- a/agent_assembly/core/audit_sink.py +++ b/agent_assembly/core/audit_sink.py @@ -64,10 +64,10 @@ is open: today ``report_event`` payloads reach neither the live stream nor the durable entry. -Its coverage is also uneven across adapters, which the disposition cannot express -because it is a property of the client, not of the call site: every governed -adapter reaches the hook on the **allowed** path, but only ``google_adk``, -``pydantic_ai`` and ``openai_agents`` build a record on the **denied** one. +The disposition is a property of the client, not of the call site, so it says +nothing about which branch a record came from. The eleven governing adapters +reach the hook on the **allowed** path and on the **denied** one, and a denied +record carries ``denied=True`` for hooks that accept it (AAASM-5787). """ AUDIT_SINK_ABSENT: AuditSinkDisposition = "absent" diff --git a/agent_assembly/core/runtime_interceptor.py b/agent_assembly/core/runtime_interceptor.py index 3bca0095..7873aedc 100644 --- a/agent_assembly/core/runtime_interceptor.py +++ b/agent_assembly/core/runtime_interceptor.py @@ -273,8 +273,9 @@ class RuntimeQueryInterceptor: rather than delegates. ``record_result`` hands a governed call's outcome to the runtime over the native event channel — a write with no acknowledgement, so it is not evidence the record survived (AAASM-5750). Whether the *denied* - path reaches it is the adapter's business, not this class's: most adapters - raise first. Before that it delegated like everything else, to a + path reaches it is the adapter's business, not this class's; since AAASM-5787 + the eleven governing adapters call it there, passing ``denied=True``. Before + that it delegated like everything else, to a ``GatewayClient`` that has neither ``record_result`` nor ``on_tool_end``, so the adapters' ``getattr`` lookup found nothing and no record was emitted on either path (AAASM-5731). From 3bbb56a72f4e2b43a1f596acac8af688d2feceb8 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 15:47:57 +0800 Subject: [PATCH 16/19] =?UTF-8?q?=F0=9F=93=9D=20(adapters):=20Point=20the?= =?UTF-8?q?=20module=20docstrings=20at=20the=20deny-record=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool_governance, haystack and llamaindex each stated their denied calls produce no record (AAASM-5787). --- agent_assembly/adapters/_shared/tool_governance.py | 5 +++-- agent_assembly/adapters/haystack/patch.py | 5 +++-- agent_assembly/adapters/llamaindex/adapter.py | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/agent_assembly/adapters/_shared/tool_governance.py b/agent_assembly/adapters/_shared/tool_governance.py index 4af5214e..dcc71778 100644 --- a/agent_assembly/adapters/_shared/tool_governance.py +++ b/agent_assembly/adapters/_shared/tool_governance.py @@ -168,8 +168,9 @@ async def _record_async_tool_result( Note the scope of "allowed or denied" here: *this* shared flow calls the hook on both paths, which is why ``google_adk`` and ``pydantic_ai`` cover denies. - Most adapters do not route through it and return or raise before their own - record helper, so their denied calls produce no record at all. + The adapters that do not route through it reach their deny branch directly and + call :func:`agent_assembly.adapters._shared.audit_record.record_denied_tool_result` + there (AAASM-5787). Which of the two a run is in is declared in ``audit_sink`` (see :mod:`agent_assembly.core.audit_sink`) and warned about by ``init_assembly``; diff --git a/agent_assembly/adapters/haystack/patch.py b/agent_assembly/adapters/haystack/patch.py index 5addb5f1..a58ffbcb 100644 --- a/agent_assembly/adapters/haystack/patch.py +++ b/agent_assembly/adapters/haystack/patch.py @@ -15,8 +15,9 @@ ``pending``, an optional ``wait_for_tool_approval`` for the pending flow, and a post-execution ``record_result`` / ``on_tool_end`` audit hook — which the SDK's own interceptor resolves over a connected runtime, handing the outcome to the -runtime's event channel, and does not resolve without one. Note this adapter does -not reach that hook on the denied path: it raises first (AAASM-5750). +runtime's event channel, and does not resolve without one. The deny branch reaches +the hook too, via ``_shared.audit_record`` (AAASM-5787); it used to return the +blocked message without recording. Under the fail-closed ``enforce`` posture an unknown or malformed verdict denies (AAASM-3107). """ diff --git a/agent_assembly/adapters/llamaindex/adapter.py b/agent_assembly/adapters/llamaindex/adapter.py index d2cd4020..ec62450d 100644 --- a/agent_assembly/adapters/llamaindex/adapter.py +++ b/agent_assembly/adapters/llamaindex/adapter.py @@ -13,8 +13,9 @@ class LlamaIndexAdapter(FrameworkAdapter): tool-execution path (``FunctionTool.call`` / ``acall``), and offers each outcome to the audit hook — which the SDK's own interceptor resolves over a connected runtime, handing the record to the runtime's event channel, and - does not resolve without one. Only the *allowed* path reaches it here: a deny - raises before the record helper, so it produces no record (AAASM-5750). The + does not resolve without one. Both paths reach it: since AAASM-5787 the deny + branch records through ``_shared.audit_record`` before returning its denied + output, where it used to return past the record helper entirely. The framework package is imported as ``llama_index.core``; the patch targets the concrete tool methods the agent loop actually invokes (the base methods are abstract). From cd69edbbe76d055303bd3fe849989d096ebcd7b7 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 16:40:59 +0800 Subject: [PATCH 17/19] =?UTF-8?q?=F0=9F=93=9D=20(docs):=20Drop=20the=20sta?= =?UTF-8?q?le=20lead=20from=20the=20audit=20admonition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 7bf4450a..72b07d43 100644 --- a/docs/index.md +++ b/docs/index.md @@ -75,7 +75,7 @@ see the note under "Why use it" below. rather than to a failed tool call ([AAASM-5750](https://lightning-dust-mite.atlassian.net/browse/AAASM-5750)). - **A handoff is not evidence, and denied calls are mostly not covered.** The send + **A handoff is not evidence.** The send is unacknowledged, so this SDK cannot report that a record arrived and does not claim it did — and downstream, [AAASM-5783](https://lightning-dust-mite.atlassian.net/browse/AAASM-5783) is open From da3907333528fee93365d97834bb1ab9ceaac126 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 16:41:00 +0800 Subject: [PATCH 18/19] =?UTF-8?q?=F0=9F=90=9B=20(adapters):=20Offer=20only?= =?UTF-8?q?=20the=20audit=20keywords=20a=20hook=20can=20receive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../adapters/_shared/audit_record.py | 58 ++++++++++++++----- .../adapters/_shared/tool_governance.py | 32 +++++----- .../adapters/openai_agents/patch.py | 25 +++++--- 3 files changed, 76 insertions(+), 39 deletions(-) diff --git a/agent_assembly/adapters/_shared/audit_record.py b/agent_assembly/adapters/_shared/audit_record.py index 4eb2b78f..a363d8fd 100644 --- a/agent_assembly/adapters/_shared/audit_record.py +++ b/agent_assembly/adapters/_shared/audit_record.py @@ -71,6 +71,35 @@ def accepts_keyword(method: Any, name: str) -> bool: return False +def optional_audit_kwargs(method: Any, **candidates: Any) -> dict[str, Any]: + """Filter ``candidates`` to the keywords ``method`` can actually receive. + + The allowed path in several adapters calls the audit hook with just + ``tool_name`` and ``result``, so a caller-supplied handler written to that + contract — ``def record_result(self, *, tool_name, result)`` — is entitled + to reject the rest. Passing them unconditionally raises ``TypeError``, which + the deny paths' suppression then swallows: the record is lost silently, in + exactly the population the "the governing adapters record on deny" claim is + about. + + Shared rather than repeated because the same call shape exists in three + places — here, ``_shared.tool_governance`` and ``openai_agents`` — and the + first fix reached only this one. + + An **unreadable** signature is not the same as a refusal. A C-implemented + callable exposes nothing to introspect, and dropping every optional keyword + there would silently narrow a call that has always carried ``agent_id`` and + ``run_id``. Those keep flowing; only ``denied``, which is newer than any + such handler, is withheld — the same reasoning + :func:`accepts_keyword` already documents for the flag. + """ + try: + inspect.signature(method) + except (TypeError, ValueError): + return {name: value for name, value in candidates.items() if name != "denied"} + return {name: value for name, value in candidates.items() if accepts_keyword(method, name)} + + def _offer( callback_handler: Any, *, @@ -84,29 +113,19 @@ def _offer( The return value matters to the async entry point, which has to await a coroutine hook. The sync entry point closes one instead — see there for why. """ - denial_flag = {"denied": True} payload = truncate_result_for_audit(result) + def optional(method: Any) -> dict[str, Any]: + return optional_audit_kwargs(method, agent_id=agent_id, run_id=run_id, denied=True) + record_method = getattr(callback_handler, "record_result", None) if callable(record_method): - returned: object = record_method( - tool_name=tool_name, - result=payload, - agent_id=agent_id, - run_id=run_id, - **(denial_flag if accepts_keyword(record_method, "denied") else {}), - ) + returned: object = record_method(tool_name=tool_name, result=payload, **optional(record_method)) return returned tool_end_method = getattr(callback_handler, "on_tool_end", None) if callable(tool_end_method): - ended: object = tool_end_method( - output=payload, - tool_name=tool_name, - agent_id=agent_id, - run_id=run_id, - **(denial_flag if accepts_keyword(tool_end_method, "denied") else {}), - ) + ended: object = tool_end_method(output=payload, tool_name=tool_name, **optional(tool_end_method)) return ended return None @@ -134,6 +153,15 @@ def record_denied_tool_result( # abandon it: an un-awaited coroutine emits a RuntimeWarning at # collection time, from this module, in a run whose real problem is # that the handler and the adapter disagree about sync vs async. + # + # Be clear about what this costs, because it is the one case where + # this module does NOT deliver: closing throws GeneratorExit at the + # first suspension point, so an `async def` hook's body never runs + # and the record is dropped. Nothing here can fix that — the + # adapter's deny branch is synchronous — so an async-only handler + # has to be paired with an adapter whose deny branch is async + # (mcp, microsoft_agent_framework, and the `a`-suffixed paths of + # agno and llamaindex). returned.close() diff --git a/agent_assembly/adapters/_shared/tool_governance.py b/agent_assembly/adapters/_shared/tool_governance.py index dcc71778..2120c303 100644 --- a/agent_assembly/adapters/_shared/tool_governance.py +++ b/agent_assembly/adapters/_shared/tool_governance.py @@ -31,7 +31,11 @@ if TYPE_CHECKING: from agent_assembly.exceptions import PolicyViolationError -from agent_assembly.adapters._shared.audit_record import accepts_keyword, truncate_result_for_audit +from agent_assembly.adapters._shared.audit_record import ( + accepts_keyword, + optional_audit_kwargs, + truncate_result_for_audit, +) from agent_assembly.adapters.crewai.patch import ( _get_pending_tool_approval_timeout_seconds as _resolve_pending_timeout_seconds, ) @@ -43,13 +47,6 @@ ) from agent_assembly.core.spawn import _SPAWN_CTX, SpawnContext, spawn_context_scope -_MAX_AUDIT_RESULT_CHARS = 2000 - -_KEYWORD_PARAMETER_KINDS = ( - inspect.Parameter.KEYWORD_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, -) - def _current_spawn_depth() -> int: current = _SPAWN_CTX.get() @@ -176,16 +173,23 @@ async def _record_async_tool_result( :mod:`agent_assembly.core.audit_sink`) and warned about by ``init_assembly``; a caller that supplies its own handler gets the record either way. """ - denial_flag = {"denied": denied} if denied else {} + + # Every optional keyword is filtered to what the hook can receive, not just + # the denial flag. A caller-supplied handler written to the allowed-path + # contract — `(tool_name, result)` — otherwise raised TypeError here + # (AAASM-5787). + def optional(method: Any) -> dict[str, Any]: + candidates: dict[str, Any] = {"agent_id": agent_id, "run_id": run_id} + if denied: + candidates["denied"] = True + return optional_audit_kwargs(method, **candidates) record_method = getattr(callback_handler, "record_result", None) if callable(record_method): recorded = record_method( tool_name=tool_name, result=_truncate_result_for_audit(result), - agent_id=agent_id, - run_id=run_id, - **(denial_flag if _accepts_keyword(record_method, "denied") else {}), + **optional(record_method), ) if inspect.isawaitable(recorded): await recorded @@ -196,9 +200,7 @@ async def _record_async_tool_result( recorded = tool_end_method( output=_truncate_result_for_audit(result), tool_name=tool_name, - agent_id=agent_id, - run_id=run_id, - **(denial_flag if _accepts_keyword(tool_end_method, "denied") else {}), + **optional(tool_end_method), ) if inspect.isawaitable(recorded): await recorded diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index 042f62ca..a5c06294 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -31,7 +31,7 @@ from functools import wraps from typing import Any, Literal -from agent_assembly.adapters._shared.audit_record import accepts_keyword +from agent_assembly.adapters._shared.audit_record import optional_audit_kwargs from agent_assembly.adapters.crewai.patch import ( _get_pending_tool_approval_timeout_seconds as _resolve_pending_timeout_seconds, ) @@ -423,18 +423,27 @@ async def _record_async_tool_result( # 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, for the reason ``accepts_keyword`` documents. - denial_flag = {"denied": denied} if denied else {} target = _resolve_governance_target(callback_handler) + # As in the shared flow: filter every optional keyword to what the hook can + # receive, so a handler written to the narrow allowed-path contract still + # gets the record instead of raising into a suppressed TypeError + # (AAASM-5787). + def optional(method: Any) -> dict[str, Any]: + # `args` is filtered too: this adapter is the only one that sends it, so + # a handler written against any other adapter's allowed path does not + # have it either. + candidates: dict[str, Any] = {"args": tool_input, "agent_id": agent_id, "run_context": ctx} + if denied: + candidates["denied"] = True + return optional_audit_kwargs(method, **candidates) + record_method = getattr(target, "record_result", None) if callable(record_method): recorded = record_method( tool_name=tool_name, - args=tool_input, result=_truncate_result_for_audit(result), - agent_id=agent_id, - run_context=ctx, - **(denial_flag if accepts_keyword(record_method, "denied") else {}), + **optional(record_method), ) if inspect.isawaitable(recorded): await recorded @@ -445,9 +454,7 @@ async def _record_async_tool_result( recorded = tool_end_method( output=_truncate_result_for_audit(result), tool_name=tool_name, - agent_id=agent_id, - run_context=ctx, - **(denial_flag if accepts_keyword(tool_end_method, "denied") else {}), + **optional(tool_end_method), ) if inspect.isawaitable(recorded): await recorded From 84c515d52d63cb1dceda9c3afd038bac61b68145 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 14 Aug 2026 16:42:44 +0800 Subject: [PATCH 19/19] =?UTF-8?q?=E2=9C=85=20(adapters):=20Pin=20that=20a?= =?UTF-8?q?=20narrow=20allowed-path=20hook=20still=20gets=20the=20deny=20r?= =?UTF-8?q?ecord?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../adapters/test_deny_record_conformance.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/test/unit/adapters/test_deny_record_conformance.py b/test/unit/adapters/test_deny_record_conformance.py index c0948009..c33899e3 100644 --- a/test/unit/adapters/test_deny_record_conformance.py +++ b/test/unit/adapters/test_deny_record_conformance.py @@ -38,6 +38,7 @@ from __future__ import annotations +import contextlib from test.unit.adapters import failopen_conformance as conf import pytest @@ -100,3 +101,49 @@ async def test_a_deny_whose_audit_hook_raises_still_records_and_still_blocks(ada assert ran is False, f"{adapter_name}: a raising audit hook let the denied tool run" assert _denied_records(records), f"{adapter_name}: the deny record was not offered before the hook raised" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("adapter_name", sorted(conf.DRIVERS)) +async def test_a_hook_written_to_the_allowed_path_contract_still_gets_the_deny_record( + adapter_name: str, +) -> None: + """The deny record must not require a wider signature than the allowed one. + + Several adapters call the audit hook with just ``tool_name`` and ``result`` + on the allowed path, so a caller-supplied handler is entitled to accept only + those. Offering the extra keywords unconditionally raised ``TypeError``, + which :mod:`agent_assembly.adapters._shared.audit_record` then suppressed — + the deny recorded nothing, silently, in exactly the population the + "eleven adapters record on deny" claim is about. + + The handler here is deliberately narrow: no ``**kwargs``, no ``denied``. It + therefore also pins the other half — that a hook which cannot receive the + denial marker still receives the record. + """ + records: list[tuple[str, str]] = [] + + class NarrowHandler: + _enforce = True + + def check_tool_start(self, **_kwargs: object) -> dict[str, str]: + return {"status": "deny", "reason": "denied by policy"} + + def wait_for_tool_approval(self, **_kwargs: object) -> dict[str, str]: + return {"status": "deny", "reason": "denied by policy"} + + def record_result(self, *, tool_name: str, result: str) -> None: + records.append((tool_name, result)) + + from agent_assembly.exceptions import AssemblyError + + ran = [False] + with contextlib.suppress(AssemblyError): + await conf.DRIVERS[adapter_name](NarrowHandler(), ran) + + assert ran[0] is False, f"{adapter_name}: the tool ran under an authoritative deny" + assert records, ( + f"{adapter_name}: a handler accepting only (tool_name, result) — the allowed-path " + f"contract — received no deny record; the wider call raised TypeError and it was " + f"suppressed (AAASM-5787)" + )