From da569a9a12141be584df125a2aed4a60de31ac6a Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 13 Aug 2026 15:23:40 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20AuditSinkDisp?= =?UTF-8?q?osition=20and=20resolve=5Faudit=5Fsink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapters' audit hook is duck-typed and returns None, so a handler that retains the record, one that drops it, and one on which the hook never resolves are indistinguishable at the call site. Give a handler a way to say which it is. The vocabulary separates "absent" from "discarded" because they are different failures with different remedies: a discarded record was built and handed over, whereas an absent hook means nothing is attempted at all — which is why this SDK's gap covers the allowed path and not only the denied one. It carries no "recorded" value: the SDK can only speak for handlers it built, so the honest answer for anything else is the absence of a claim, and an unrecognised value degrades to that rather than being trusted through. Refs AAASM-5731 --- agent_assembly/core/audit_sink.py | 96 +++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 agent_assembly/core/audit_sink.py diff --git a/agent_assembly/core/audit_sink.py b/agent_assembly/core/audit_sink.py new file mode 100644 index 00000000..3a2a304b --- /dev/null +++ b/agent_assembly/core/audit_sink.py @@ -0,0 +1,96 @@ +"""What the SDK's own governance handlers do with hook-layer audit records. + +The framework adapters offer the outcome of every governed tool call — allowed +or denied — to an audit hook on the interceptor they were handed +(``record_result``, falling back to ``on_tool_end``). Both hooks are duck-typed +and both return ``None``, so a handler that retains the record and one that does +nothing with it are indistinguishable at the call site. + +On every interceptor this SDK ships, neither hook **resolves at all**: +``RuntimeQueryInterceptor`` defines only ``check_tool_start`` and delegates the +rest to :class:`~agent_assembly.client.gateway.GatewayClient`, whose surface has +no ``record_result`` and no ``on_tool_end``. The adapters' ``getattr`` guard +therefore finds nothing and returns without emitting — for **allowed** calls as +much as denied ones. Nothing in ``agent_assembly`` calls the native +``RuntimeClient.send_event`` either, so no tool-call event reaches the runtime by +any other route. + +This module is how that stops being invisible. Every handler the SDK ships +declares its disposition; :func:`resolve_audit_sink` reads it; ``init_assembly`` +warns about it and reports it on the returned context. + +Under ADR 0033 §6 this makes SDK-side recording **Planned** (AAASM-5731), not +*Observed* — *Observed* requires a durable event attributed to the action, and +there is none. It is deliberately not *Unmeasured*: §6 reserves that for an +action no control inspected, where nothing is known, and here exactly where the +record stops has been measured against the native boundary. +""" + +from __future__ import annotations + +from typing import Any, Literal, get_args + +type AuditSinkDisposition = Literal["absent", "discarded", "caller-supplied"] +"""What a governance handler does with the hook-layer audit record. + +The vocabulary separates *how* a record fails to survive, not merely that it +does, because the two failures have different blast radii and different remedies +— and the three SDKs sit on both sides of the split, so collapsing them would +misdescribe at least one of them. +""" + +AUDIT_SINK_ABSENT: AuditSinkDisposition = "absent" +"""No audit hook resolves on this handler, so no record is even attempted. + +This is what every interceptor this SDK ships does. It is strictly worse than +``"discarded"``: nothing constructs the event, so supplying a sink downstream is +not sufficient on its own — the call site finds no hook to call. It is also why +the gap covers the **allowed** path and not only the denied one. +""" + +AUDIT_SINK_DISCARDED: AuditSinkDisposition = "discarded" +"""An audit hook resolves, accepts the record, and drops it. + +The call site is correct and the sink is not. This is what the LangChain +:class:`~agent_assembly.adapters.langchain.callback_handler.AssemblyCallbackHandler` +does when the interceptor it wraps has no ``on_tool_end`` to forward to, and it +is what the Go and Node SDKs' shipped clients do (AAASM-5731 / AAASM-5681). +""" + +AUDIT_SINK_CALLER_SUPPLIED: AuditSinkDisposition = "caller-supplied" +"""The handler did not come from this SDK, so this SDK claims nothing about it. + +The **absence of a claim, not an assurance** that the record is retained. An +*Observed* claim for the hook layer is available only on this branch, and only +if the caller's own handler actually keeps what it is given. +""" + +AUDIT_SINK_ATTRIBUTE = "audit_sink" +"""Attribute name a handler declares its disposition under. + +Duck-typed for the same reason the audit hook itself is: the adapters accept any +object as a ``callback_handler``, so requiring a base class or a Protocol +registration would break every caller-supplied handler that works today. +""" + +_VALID_DISPOSITIONS = frozenset(get_args(AuditSinkDisposition.__value__)) + + +def resolve_audit_sink(handler: Any) -> AuditSinkDisposition: + """Report what ``handler`` does with the hook-layer audit record. + + A handler that declares nothing — or declares something outside the + vocabulary — is reported as :data:`AUDIT_SINK_CALLER_SUPPLIED`. An + unrecognised value is deliberately *not* trusted through: a typo must degrade + to "this SDK makes no claim", never to a claim this SDK cannot stand behind. + + ``None`` is :data:`AUDIT_SINK_ABSENT` rather than caller-supplied: no handler + means no hook to call, which is the same thing the shipped interceptors do. + """ + if handler is None: + return AUDIT_SINK_ABSENT + declared = getattr(handler, AUDIT_SINK_ATTRIBUTE, None) + if isinstance(declared, str) and declared in _VALID_DISPOSITIONS: + # Cast is safe: membership in _VALID_DISPOSITIONS is exactly the Literal. + return declared # type: ignore[return-value] + return AUDIT_SINK_CALLER_SUPPLIED From fc5dc5bf7880684991215935f44b3c6efa717dc0 Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 13 Aug 2026 15:23:55 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E2=9C=A8=20(core):=20Declare=20that=20th?= =?UTF-8?q?e=20shipped=20interceptors=20resolve=20no=20audit=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against the native and HTTP boundaries: on RuntimeQueryInterceptor, getattr for record_result and on_tool_end both return None — __getattr__ delegates to a GatewayClient that has neither — so the adapters' guard finds nothing to call and no record is emitted for a governed call, allowed or denied. The fail-closed interceptor delegates the same way, so a call denied there produces nothing either. Positive controls on the same objects resolve normally (check_tool_start, report_edge), so this is specific to the audit hooks rather than broken delegation. Refs AAASM-5731 --- agent_assembly/core/runtime_interceptor.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/agent_assembly/core/runtime_interceptor.py b/agent_assembly/core/runtime_interceptor.py index 6595f4e0..701e0efb 100644 --- a/agent_assembly/core/runtime_interceptor.py +++ b/agent_assembly/core/runtime_interceptor.py @@ -43,6 +43,7 @@ from importlib import metadata from typing import Any +from agent_assembly.core.audit_sink import AUDIT_SINK_ABSENT, AuditSinkDisposition from agent_assembly.exceptions import OpTerminatedError ENV_RUNTIME_SOCKET = "AA_RUNTIME_SOCKET" @@ -227,8 +228,21 @@ class RuntimeQueryInterceptor: an authoritative allow — a raising ``query_policy`` or an error-sentinel ``decision`` — maps to ``deny`` (fail closed). When ``False`` those paths proceed (fail open), preserving the observe / disabled behavior. + + The "delegates everything else" clause is doing more work than it looks: + ``record_result`` and ``on_tool_end`` — the adapters' audit hook — delegate to + a ``GatewayClient`` that has neither, so neither resolves and the adapters + emit **no** audit record for a governed call, allowed or denied. That is + declared in :attr:`audit_sink` rather than left to be discovered by reading + this class (AAASM-5731). """ + # AAASM-5731 — an audit-hook lookup on this object returns None, so the + # adapters' getattr guard finds nothing to call and the record is never even + # attempted. Declared so init_assembly can surface it and a test can catch a + # shipped handler that emits nothing without saying so. + audit_sink: AuditSinkDisposition = AUDIT_SINK_ABSENT + def __init__( self, client: Any, @@ -362,9 +376,13 @@ class _FailClosedInterceptor: control) but the runtime socket could not be connected, meaning no authoritative verdict can be obtained. Under ``enforce`` this must block every tool rather than silently allow it (AAASM-3106). Non-check attributes - delegate to the wrapped ``GatewayClient`` so event reporting still works. + delegate to the wrapped ``GatewayClient``, whose surface carries no audit hook + — so a call denied here produces no audit record either (see + :attr:`audit_sink`, AAASM-5731). """ + audit_sink: AuditSinkDisposition = AUDIT_SINK_ABSENT + def __init__(self, client: Any, reason: str) -> None: self._client = client self._reason = reason From eade32876868395140430c85b14f348dbfd3eb8a Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 13 Aug 2026 15:23:56 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=E2=9C=A8=20(client):=20Declare=20the=20g?= =?UTF-8?q?ateway=20client's=20audit=20disposition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under observe / disabled the bare GatewayClient is handed to the adapters as the governance interceptor unchanged, so its surface is also the audit surface — and it exposes neither record_result nor on_tool_end. report_edge is topology metadata, not a tool-call record, and does not close the gap. Refs AAASM-5731 --- agent_assembly/client/gateway.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/agent_assembly/client/gateway.py b/agent_assembly/client/gateway.py index 51d000b5..38559739 100644 --- a/agent_assembly/client/gateway.py +++ b/agent_assembly/client/gateway.py @@ -7,12 +7,24 @@ import httpx from agent_assembly.client.dispatch import DispatchToolResult +from agent_assembly.core.audit_sink import AUDIT_SINK_ABSENT, AuditSinkDisposition from agent_assembly.core.transport_security import require_secure_http_url from agent_assembly.exceptions import GatewayError class GatewayClient: - """Client for communicating with the Agent Assembly governance gateway.""" + """Client for communicating with the Agent Assembly governance gateway. + + Under the ``observe`` / ``disabled`` postures this object is handed to the + framework adapters as the governance interceptor unchanged, so its surface is + also the audit surface — and it has no ``record_result`` and no + ``on_tool_end``, so the adapters' audit hook does not resolve and no record is + emitted for a governed tool call. Declared in :attr:`audit_sink` + (AAASM-5731). ``report_edge`` is topology metadata, not a tool-call audit + record, and does not close this gap. + """ + + audit_sink: AuditSinkDisposition = AUDIT_SINK_ABSENT def __init__( self, From 0b5bd13915614f30d850a514b5738748fa75a559 Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 13 Aug 2026 15:23:58 +0800 Subject: [PATCH 04/12] =?UTF-8?q?=E2=9C=A8=20(langchain):=20Declare=20that?= =?UTF-8?q?=20the=20callback=20handler=20accepts=20the=20record=20and=20dr?= =?UTF-8?q?ops=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This handler sits on the other side of the split from the interceptors it wraps: on_tool_end is defined here, so the adapters' audit-hook lookup does resolve and the record IS handed over. It is then forwarded to the interceptor's own on_tool_end, which does not exist on anything this SDK ships — so the record stops here. Accepted and dropped is "discarded", not "absent". Computed rather than declared, so a caller-supplied interceptor that really records is reported as such: this SDK claims nothing about a handler it did not build, in either direction. Refs AAASM-5731 --- .../adapters/langchain/callback_handler.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/agent_assembly/adapters/langchain/callback_handler.py b/agent_assembly/adapters/langchain/callback_handler.py index 166e5a59..2f2cbf18 100644 --- a/agent_assembly/adapters/langchain/callback_handler.py +++ b/agent_assembly/adapters/langchain/callback_handler.py @@ -7,6 +7,12 @@ from typing import Any, Literal, cast from uuid import UUID +from agent_assembly.core.audit_sink import ( + AUDIT_SINK_ABSENT, + AUDIT_SINK_DISCARDED, + AuditSinkDisposition, + resolve_audit_sink, +) from agent_assembly.exceptions import ToolExecutionBlockedError _KNOWN_STATUSES: frozenset[str] = frozenset({"allow", "deny", "pending"}) @@ -45,6 +51,26 @@ class AssemblyCallbackHandler(_CallbackHandlerBase): # type: ignore[valid-type, def __init__(self, interceptor: Any) -> None: self._interceptor = interceptor + @property + def audit_sink(self) -> AuditSinkDisposition: + """What this handler does with the hook-layer audit record (AAASM-5731). + + Computed rather than declared, because it genuinely depends on what is + wrapped, and this handler sits on the *other* side of the split from the + interceptors it wraps. ``on_tool_end`` is defined here, so the adapters' + audit-hook lookup **does** resolve on this object — the record is built + and handed over. It is then forwarded to the interceptor's own + ``on_tool_end``, and on every interceptor this SDK ships there is none, + so the record stops here: accepted and dropped, which is ``discarded``, + not ``absent``. + + A caller-supplied interceptor that really records is reported as such: + this SDK does not claim anything about a handler it did not build, in + either direction. + """ + wrapped = resolve_audit_sink(self._interceptor) + return AUDIT_SINK_DISCARDED if wrapped == AUDIT_SINK_ABSENT else wrapped + def __getattr__(self, name: str) -> Any: """Delegate any attribute this handler does not define to the interceptor. From a24b0028df4e1b5e35b65d4dd92ddd2afa5bb254 Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 13 Aug 2026 15:24:11 +0800 Subject: [PATCH 05/12] =?UTF-8?q?=E2=9C=A8=20(core):=20Warn=20at=20init=5F?= =?UTF-8?q?assembly=20and=20report=20audit=5Fsink=20on=20the=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this there was no signal at all that governed tool calls produce no audit evidence, so a caller had to read the interceptor to find out. Warn once per init_assembly on stderr — where logging configuration cannot silence it, as with the unregistered-agent warning — and expose the value on the returned context as the programmatic counterpart. _register_adapters now returns the disposition alongside the adapters rather than the caller re-deriving it, because building a second interceptor to ask would re-emit the one-time native-missing warning. Its stubs across five test modules are updated for the new return shape in the same commit, so no revision in between is left broken. The warning branches on the disposition: "absent" and "discarded" fail differently and a single sentence would be wrong in one direction. It does not fail init — the proxy / eBPF layers are unaffected, so refusing to start over an evidence gap would trade a truthfulness fix for an availability regression. Refs AAASM-5731 --- agent_assembly/core/assembly.py | 92 ++++++++++++++++++- .../test_spawn_lineage_integration.py | 3 +- test/unit/adapters/langchain/test_runtime.py | 9 +- test/unit/core/test_init_registration.py | 39 ++++---- test/unit/core/test_spawn_context.py | 7 +- test/unit/test_assembly.py | 49 +++++----- 6 files changed, 144 insertions(+), 55 deletions(-) diff --git a/agent_assembly/core/assembly.py b/agent_assembly/core/assembly.py index 8c483853..2a3e1c55 100644 --- a/agent_assembly/core/assembly.py +++ b/agent_assembly/core/assembly.py @@ -15,6 +15,13 @@ from agent_assembly.adapters.langchain.runtime import get_active_callback_handler from agent_assembly.adapters.registry import AdapterRegistry from agent_assembly.client.gateway import GatewayClient +from agent_assembly.core.audit_sink import ( + AUDIT_SINK_ABSENT, + AUDIT_SINK_CALLER_SUPPLIED, + AUDIT_SINK_DISCARDED, + AuditSinkDisposition, + resolve_audit_sink, +) from agent_assembly.core.gateway_resolver import ( resolve_api_key, resolve_gateway_grpc_endpoint, @@ -116,6 +123,14 @@ class AssemblyContext: # programmatically rather than relying on the stderr warning alone # (AAASM-4547, mirroring the Node SDK's ``ctx.registered``). registered: bool = True + # What the governance interceptor the adapters were handed does with the + # hook-layer audit record for a governed tool call (AAASM-5731). Anything + # other than ``"caller-supplied"`` means governed actions produce NO audit + # evidence from this SDK, so no claim of attributability or after-the-fact + # review holds on the SDK path. The programmatic counterpart of the stderr + # warning ``_warn_audit_not_recorded`` emits, so the gap is detectable in + # code and not only by reading stderr. + audit_sink: AuditSinkDisposition = AUDIT_SINK_ABSENT _lock: Lock = field(default_factory=Lock, init=False, repr=False) _is_shutdown: bool = field(default=False, init=False, repr=False) @@ -281,6 +296,7 @@ def init_assembly( network_mode: NetworkMode = "sdk-only" network_shutdown: Callable[[], None] = _noop_shutdown registered = False + audit_sink: AuditSinkDisposition = AUDIT_SINK_ABSENT try: native_available = _native_core_available() runtime_client = connect_runtime_client(resolved_agent_id) if native_available else None @@ -293,7 +309,7 @@ def init_assembly( team_id=team_id, parent_agent_id=parent_agent_id, ) - registered_adapters = _register_adapters( + registered_adapters, audit_sink = _register_adapters( client=client, process_agent_id=resolved_agent_id, enforcement_mode=enforcement_mode, @@ -306,12 +322,19 @@ def init_assembly( client.close() raise ConfigurationError(f"Failed to initialize assembly runtime: {error}") from error + # AAASM-5731 — surface an audit path that retains nothing, on the + # default path with nothing opted into. Emitted after registration so it + # reflects the interceptor the adapters were actually handed. + if audit_sink != AUDIT_SINK_CALLER_SUPPLIED: + _warn_audit_not_recorded(audit_sink) + context = AssemblyContext( client=client, adapters=registered_adapters, network_mode=network_mode, _network_shutdown=network_shutdown, registered=registered, + audit_sink=audit_sink, ) _ACTIVE_CONTEXT = context return context @@ -373,6 +396,50 @@ def _warn_agent_unregistered(detail: str) -> None: ) +def _warn_audit_not_recorded(disposition: AuditSinkDisposition) -> None: + """Emit a loud, unconditional stderr warning that no audit record is kept. + + The framework adapters offer the outcome of every governed tool call to an + audit hook on the interceptor they were handed. On every interceptor this SDK + ships that hook does not resolve, so nothing is emitted — for **allowed** + calls as much as denied ones — and the caller had no way to learn that short + of reading the interceptor. Enforcement is genuinely unaffected, which is + exactly why the gap is easy to miss: denies still deny, and the governed call + returns normally. + + Written straight to ``sys.stderr`` for the same reason as + :func:`_warn_agent_unregistered`: ``logging`` configuration cannot silence it. + Once per ``init_assembly`` rather than per governed call, so it cannot become + steady-state noise. It does not fail init — a caller may not need SDK-side + audit at all, and the proxy / eBPF layers are unaffected, so refusing to start + over an evidence gap would trade a truthfulness fix for an availability + regression. + + :param disposition: The resolved sink disposition. The mechanism clause MUST + branch on it: ``"absent"`` and ``"discarded"`` fail differently and need + different remedies, and a single unconditional sentence would be wrong in + one direction or the other. + """ + mechanism = ( + "the LangChain callback handler accepts the record and drops it, because " + "the interceptor it forwards to exposes no on_tool_end" + if disposition == AUDIT_SINK_DISCARDED + else "no audit hook (record_result / on_tool_end) resolves on the governance " + "interceptor this SDK builds, so no record is even attempted" + ) + sys.stderr.write( + "[agent-assembly] WARNING: hook-layer audit records are NOT retained " + f"(audit sink '{disposition}'): {mechanism}. Governed tool calls — ALLOWED " + "ones as well as denied ones — therefore produce NO audit evidence from " + "this SDK, and nothing on this path can be attributed or reviewed after " + "the fact. Enforcement is unaffected: a policy DENY still blocks a tool " + "call, and the proxy / eBPF layers remain authoritative. Supply your own " + "handler with a record_result or on_tool_end to retain the record, and " + "inspect the 'audit_sink' attribute on the returned assembly context to " + "detect this programmatically (AAASM-5731).\n" + ) + + def _register_agent_with_gateway( *, runtime_client: Any | None, @@ -460,7 +527,7 @@ def _register_adapters( enforcement_mode: EnforcementMode | None = None, runtime_client: Any | None = None, native_available: bool = False, -) -> list[FrameworkAdapter]: +) -> tuple[list[FrameworkAdapter], AuditSinkDisposition]: """Detect available frameworks via AdapterRegistry and register hooks. Adapters are returned in priority order. LangChain is registered first @@ -472,6 +539,11 @@ def _register_adapters( ``check_tool_start``. ``enforcement_mode`` decides the failure posture: under ``enforce`` an unreachable runtime or a failed query blocks (fail closed, AAASM-3106); under ``observe`` / ``disabled`` it proceeds (fail open). + + Returns the registered adapters and what the interceptor they were handed + does with hook-layer audit records (AAASM-5731). The disposition is returned + rather than re-derived by the caller because building a second interceptor to + ask it would re-emit the one-time native-missing warning. """ registry = AdapterRegistry() adapters = registry.get_available_adapters_by_priority() @@ -484,6 +556,11 @@ def _register_adapters( runtime_client=runtime_client, native_available=native_available, ) + # AAASM-5731 — read off the interceptor the SDK itself builds, before the + # LangChain hand-over below. That one is the object this SDK can speak for; + # the handler that may replace it declares its own disposition, and the + # worst of the two is what the caller is told. + audit_sink = resolve_audit_sink(interceptor) for adapter in adapters: adapter.set_process_agent_id(process_agent_id) @@ -502,8 +579,15 @@ def _register_adapters( callback_handler = get_active_callback_handler() if callback_handler is not None: interceptor = callback_handler - - return registered + handler_sink = resolve_audit_sink(callback_handler) + # Only ever narrow away from "no claim": the handler accepts the + # record and drops it where the wrapped interceptor never sees + # one at all, and reporting the milder of the two would + # understate what the later adapters actually get. + if handler_sink != AUDIT_SINK_CALLER_SUPPLIED: + audit_sink = handler_sink + + return registered, audit_sink def _unregister_adapters(adapters: list[FrameworkAdapter]) -> None: diff --git a/test/integration/test_spawn_lineage_integration.py b/test/integration/test_spawn_lineage_integration.py index 76ae38c8..26c3f93c 100644 --- a/test/integration/test_spawn_lineage_integration.py +++ b/test/integration/test_spawn_lineage_integration.py @@ -18,6 +18,7 @@ from agent_assembly.core import assembly as core_assembly from agent_assembly.core.assembly import init_assembly +from agent_assembly.core.audit_sink import AUDIT_SINK_ABSENT from agent_assembly.core.spawn import _SPAWN_CTX, SpawnContext, spawn_context_scope @@ -57,7 +58,7 @@ def _call_init_assembly(**kwargs: object) -> MagicMock: # Also patch out adapter registration and network layer — we only care # about what GatewayClient receives. with ( - patch("agent_assembly.core.assembly._register_adapters", return_value=[]), + patch("agent_assembly.core.assembly._register_adapters", return_value=([], AUDIT_SINK_ABSENT)), patch( "agent_assembly.core.assembly._start_network_layer", return_value=("sdk-only", lambda: None), diff --git a/test/unit/adapters/langchain/test_runtime.py b/test/unit/adapters/langchain/test_runtime.py index a19056fa..169b38b9 100644 --- a/test/unit/adapters/langchain/test_runtime.py +++ b/test/unit/adapters/langchain/test_runtime.py @@ -9,6 +9,7 @@ get_active_callback_handler, ) from agent_assembly.core import assembly as core_assembly +from agent_assembly.core.audit_sink import AUDIT_SINK_ABSENT from agent_assembly.exceptions import ConfigurationError @@ -63,9 +64,9 @@ def test_init_assembly_auto_injects_callback_handler(monkeypatch: pytest.MonkeyP _reset_runtime_state_for_tests() _reset_assembly_state() - def fake_register_adapters(**kwargs: object) -> list[object]: + def fake_register_adapters(**kwargs: object) -> tuple[list[object], str]: auto_inject_callback_handler(kwargs["client"]) - return [] + return [], AUDIT_SINK_ABSENT monkeypatch.setattr(core_assembly, "_register_adapters", fake_register_adapters) monkeypatch.setattr( @@ -89,9 +90,9 @@ def test_init_assembly_reuses_existing_callback_handler(monkeypatch: pytest.Monk _reset_runtime_state_for_tests() _reset_assembly_state() - def fake_register_adapters(**kwargs: object) -> list[object]: + def fake_register_adapters(**kwargs: object) -> tuple[list[object], str]: auto_inject_callback_handler(kwargs["client"]) - return [] + return [], AUDIT_SINK_ABSENT monkeypatch.setattr(core_assembly, "_register_adapters", fake_register_adapters) monkeypatch.setattr( diff --git a/test/unit/core/test_init_registration.py b/test/unit/core/test_init_registration.py index 68792202..32fcdc9a 100644 --- a/test/unit/core/test_init_registration.py +++ b/test/unit/core/test_init_registration.py @@ -14,6 +14,7 @@ from agent_assembly import init_assembly from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor from agent_assembly.core import assembly as core_assembly +from agent_assembly.core.audit_sink import AUDIT_SINK_ABSENT, resolve_audit_sink from agent_assembly.core.runtime_interceptor import build_governance_interceptor from agent_assembly.core.spawn import SpawnContext, spawn_context_scope from agent_assembly.exceptions import ConfigurationError @@ -78,7 +79,7 @@ def test_init_assembly_registers_agent_on_init(monkeypatch: pytest.MonkeyPatch) runtime_client = FakeRuntimeClient(decision="allow") install_fake_core(monkeypatch, runtime_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) context = init_assembly(gateway_url=_GW_URL, api_key=_API_KEY, agent_id="agent-7", mode="sdk-only") try: @@ -94,7 +95,7 @@ def test_init_assembly_forwards_team_and_parent_on_register( runtime_client = FakeRuntimeClient(decision="allow") install_fake_core(monkeypatch, runtime_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) context = init_assembly( gateway_url=_GW_URL, @@ -119,7 +120,7 @@ def test_init_assembly_forwards_only_team_when_parent_absent( runtime_client = FakeRuntimeClient(decision="allow") install_fake_core(monkeypatch, runtime_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) context = init_assembly( gateway_url=_GW_URL, @@ -143,7 +144,7 @@ def test_init_assembly_forwards_only_parent_when_team_absent( runtime_client = FakeRuntimeClient(decision="allow") install_fake_core(monkeypatch, runtime_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) context = init_assembly( gateway_url=_GW_URL, @@ -165,7 +166,7 @@ def test_init_assembly_no_lineage_when_neither_set(monkeypatch: pytest.MonkeyPat runtime_client = FakeRuntimeClient(decision="allow") install_fake_core(monkeypatch, runtime_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) context = init_assembly( gateway_url=_GW_URL, @@ -191,7 +192,7 @@ def test_init_assembly_forwards_ambient_spawn_parent_on_register( runtime_client = FakeRuntimeClient(decision="allow") install_fake_core(monkeypatch, runtime_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) ctx = SpawnContext(parent_agent_id="ambient-parent", depth=1, spawned_by_tool="delegate") with spawn_context_scope(ctx): @@ -216,7 +217,7 @@ def test_explicit_parent_overrides_ambient_spawn_parent( runtime_client = FakeRuntimeClient(decision="allow") install_fake_core(monkeypatch, runtime_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) ctx = SpawnContext(parent_agent_id="ambient-parent", depth=2, spawned_by_tool="delegate") with spawn_context_scope(ctx): @@ -243,7 +244,7 @@ def test_register_falls_back_on_older_native_build_without_lineage_kwargs( legacy_client = LegacyRuntimeClient() install_fake_core(monkeypatch, legacy_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) context = init_assembly( gateway_url=_GW_URL, @@ -268,7 +269,7 @@ def test_init_assembly_lineage_values_round_trip_verbatim( runtime_client = FakeRuntimeClient(decision="allow") install_fake_core(monkeypatch, runtime_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) team = "équipe-paiements-🌐" parent = "parent-" + ("a" * 200) @@ -483,7 +484,7 @@ def test_observe_mode_swallows_register_failure(monkeypatch: pytest.MonkeyPatch) runtime_client.register_should_raise = RuntimeError("gateway down") install_fake_core(monkeypatch, runtime_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) context = init_assembly( gateway_url=_GW_URL, @@ -501,7 +502,7 @@ def test_enforce_mode_propagates_register_failure(monkeypatch: pytest.MonkeyPatc runtime_client.register_should_raise = RuntimeError("gateway rejected") install_fake_core(monkeypatch, runtime_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) with pytest.raises(ConfigurationError, match="Failed to initialize assembly runtime"): init_assembly( @@ -529,7 +530,7 @@ def test_init_refuses_plaintext_nonloopback_register_by_default( runtime_client = FakeRuntimeClient(decision="allow") install_fake_core(monkeypatch, runtime_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) with pytest.raises(ConfigurationError, match="Failed to initialize assembly runtime"): init_assembly( @@ -551,7 +552,7 @@ def test_init_allow_insecure_permits_plaintext_nonloopback_register( runtime_client = FakeRuntimeClient(decision="allow") install_fake_core(monkeypatch, runtime_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) context = init_assembly( gateway_url=_INSECURE_GW_URL, @@ -579,7 +580,7 @@ def _impl( enforcement_mode: str | None = None, runtime_client: object | None = None, native_available: bool = False, - ) -> list[FrameworkAdapter]: + ) -> tuple[list[FrameworkAdapter], str]: interceptor = build_governance_interceptor( client, process_agent_id, @@ -588,7 +589,7 @@ def _impl( native_available=native_available, ) adapter.register_hooks(interceptor) - return [adapter] + return [adapter], resolve_audit_sink(interceptor) return _impl @@ -598,7 +599,7 @@ def test_successful_register_marks_context_registered(monkeypatch: pytest.Monkey runtime_client = FakeRuntimeClient(decision="allow") install_fake_core(monkeypatch, runtime_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) context = init_assembly(gateway_url=_GW_URL, api_key=_API_KEY, agent_id="reg-ok", mode="sdk-only") try: @@ -615,7 +616,7 @@ def test_native_absent_warns_loudly_and_marks_unregistered( it warns loudly and reports ``ctx.registered`` False (AAASM-4547).""" monkeypatch.setattr(core_assembly, "_native_core_available", lambda: False) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) context = init_assembly(gateway_url=_GW_URL, api_key=_API_KEY, agent_id="no-native", mode="sdk-only") try: @@ -675,7 +676,7 @@ def test_native_present_but_no_runtime_client_warns_and_marks_unregistered( monkeypatch.setattr(core_assembly, "_native_core_available", lambda: True) monkeypatch.setattr(core_assembly, "connect_runtime_client", lambda _agent_id: None) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) context = init_assembly(gateway_url=_GW_URL, api_key=_API_KEY, agent_id="no-client", mode="sdk-only") try: @@ -695,7 +696,7 @@ def test_register_failure_under_observe_warns_and_marks_unregistered( runtime_client.register_should_raise = RuntimeError("gateway gRPC endpoint is unreachable") install_fake_core(monkeypatch, runtime_client) _no_network(monkeypatch) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT)) context = init_assembly( gateway_url=_GW_URL, diff --git a/test/unit/core/test_spawn_context.py b/test/unit/core/test_spawn_context.py index 203296f5..3f197514 100644 --- a/test/unit/core/test_spawn_context.py +++ b/test/unit/core/test_spawn_context.py @@ -5,6 +5,7 @@ import pytest from agent_assembly.core import assembly as core_assembly +from agent_assembly.core.audit_sink import AUDIT_SINK_ABSENT from agent_assembly.core.spawn import _SPAWN_CTX, SpawnContext, spawn_context_scope @@ -93,7 +94,7 @@ def fake_gateway_client(**kwargs: Any) -> Any: with ( patch("agent_assembly.core.assembly.GatewayClient", side_effect=fake_gateway_client), - patch("agent_assembly.core.assembly._register_adapters", return_value=[]), + patch("agent_assembly.core.assembly._register_adapters", return_value=([], AUDIT_SINK_ABSENT)), patch("agent_assembly.core.assembly._start_network_layer", return_value=("sdk-only", lambda: None)), spawn_context_scope(spawn_ctx), ): @@ -130,7 +131,7 @@ def fake_gateway_client(**kwargs: Any) -> Any: with ( patch("agent_assembly.core.assembly.GatewayClient", side_effect=fake_gateway_client), - patch("agent_assembly.core.assembly._register_adapters", return_value=[]), + patch("agent_assembly.core.assembly._register_adapters", return_value=([], AUDIT_SINK_ABSENT)), patch("agent_assembly.core.assembly._start_network_layer", return_value=("sdk-only", lambda: None)), spawn_context_scope(spawn_ctx), ): @@ -165,7 +166,7 @@ def fake_gateway_client(**kwargs: Any) -> Any: with ( patch("agent_assembly.core.assembly.GatewayClient", side_effect=fake_gateway_client), - patch("agent_assembly.core.assembly._register_adapters", return_value=[]), + patch("agent_assembly.core.assembly._register_adapters", return_value=([], AUDIT_SINK_ABSENT)), patch("agent_assembly.core.assembly._start_network_layer", return_value=("sdk-only", lambda: None)), ): ctx = assembly.init_assembly( diff --git a/test/unit/test_assembly.py b/test/unit/test_assembly.py index 637df845..9791bf31 100644 --- a/test/unit/test_assembly.py +++ b/test/unit/test_assembly.py @@ -10,6 +10,7 @@ from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor from agent_assembly.client.gateway import GatewayClient from agent_assembly.core import assembly as core_assembly +from agent_assembly.core.audit_sink import AUDIT_SINK_ABSENT from agent_assembly.exceptions import AssemblyError, ConfigurationError @@ -59,7 +60,7 @@ def cleanup_active_context() -> None: def test_init_assembly_with_valid_config_returns_context( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -101,7 +102,7 @@ def test_init_assembly_zero_arg_resolves_local_default( monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_URL, raising=False) monkeypatch.delenv(gateway_resolver.ENV_API_KEY, raising=False) monkeypatch.setattr(gateway_resolver, "_load_config_file", lambda: {}) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -130,7 +131,7 @@ def _fail_auto_start(_url: str = "") -> None: monkeypatch.setattr(gateway_resolver, "_probe_healthz", _fail_probe) monkeypatch.setattr(gateway_resolver, "_auto_start_gateway", _fail_auto_start) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -212,7 +213,7 @@ def unregister_hooks(self) -> None: monkeypatch.setattr( core_assembly, "_register_adapters", - lambda **kwargs: [_TrackingAdapter("a"), _TrackingAdapter("b")], + lambda **kwargs: ([_TrackingAdapter("a"), _TrackingAdapter("b")], AUDIT_SINK_ABSENT), ) monkeypatch.setattr( core_assembly, @@ -261,7 +262,7 @@ def get_available_adapters_by_priority(self) -> list[FrameworkAdapter]: agent_id="test-agent-001", api_key="test-api-key", ) - registered = core_assembly._register_adapters( + registered, _audit_sink = core_assembly._register_adapters( client=client, process_agent_id="test-agent-001", ) @@ -275,7 +276,7 @@ def get_available_adapters_by_priority(self) -> list[FrameworkAdapter]: def test_init_assembly_rejects_conflicting_reinit( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -301,7 +302,7 @@ def test_init_assembly_rejects_conflicting_reinit( def test_init_assembly_rejects_conflicting_gateway_and_api_key( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -373,12 +374,12 @@ def test_init_assembly_is_thread_safe_and_idempotent( release = Event() register_call_count = 0 - def fake_register_adapters(**kwargs: Any) -> list[Any]: + def fake_register_adapters(**kwargs: Any) -> tuple[list[Any], str]: nonlocal register_call_count register_call_count += 1 started.set() release.wait(timeout=2) - return [] + return [], AUDIT_SINK_ABSENT monkeypatch.setattr(core_assembly, "_register_adapters", fake_register_adapters) monkeypatch.setattr( @@ -423,7 +424,7 @@ def test_init_assembly_topology_params_forwarded_to_client( delegation_reason: str | None, spawned_by_tool: str | None, ) -> None: - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -452,7 +453,7 @@ def test_init_assembly_topology_params_forwarded_to_client( def test_init_assembly_without_topology_params_is_backward_compatible( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -476,7 +477,7 @@ def test_init_assembly_without_topology_params_is_backward_compatible( def test_init_assembly_delegation_reason_too_long_raises( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -498,7 +499,7 @@ def test_init_assembly_control_plane_url_forwarded_to_client( monkeypatch: pytest.MonkeyPatch, ) -> None: """An explicit control_plane_url lands on the GatewayClient.""" - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -524,7 +525,7 @@ def test_init_assembly_control_plane_url_defaults_to_gateway_url( ) -> None: """Without control_plane_url, HTTP routes fall back to gateway_url.""" monkeypatch.delenv(core_assembly.ENV_CONTROL_PLANE_URL, raising=False) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -563,7 +564,7 @@ def test_init_assembly_gateway_url_falls_back_to_env_var( monkeypatch.setattr(core_assembly, "resolve_gateway_url", lambda explicit=None: explicit or "") monkeypatch.setenv(core_assembly.ENV_GATEWAY_URL, "https://env-gateway:7000") monkeypatch.delenv(core_assembly.ENV_CONTROL_PLANE_URL, raising=False) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -583,7 +584,7 @@ def test_init_assembly_control_plane_url_falls_back_to_env_var( ) -> None: """control_plane_url resolves from AA_CONTROL_PLANE_URL when no kwarg is given.""" monkeypatch.setenv(core_assembly.ENV_CONTROL_PLANE_URL, "http://env-control-plane:9100") - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -607,7 +608,7 @@ def test_init_assembly_explicit_control_plane_url_overrides_env_var( ) -> None: """Explicit kwarg wins over AA_CONTROL_PLANE_URL (kwarg > env-var).""" monkeypatch.setenv(core_assembly.ENV_CONTROL_PLANE_URL, "http://env-control-plane:9100") - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -641,7 +642,7 @@ def test_init_assembly_enforcement_mode_forwarded_to_client( # This keeps the test focused on enforcement-mode forwarding regardless of # whether the native `_core` extension is built (AAASM-3435). monkeypatch.setattr(core_assembly, "_native_core_available", lambda: False) - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -671,7 +672,7 @@ def test_init_assembly_enforcement_mode_invalid_raises_configuration_error( """ from agent_assembly.exceptions import ConfigurationError - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -696,7 +697,7 @@ def test_init_assembly_enforcement_mode_defaults_to_none_to_preserve_wire_shape( The gateway then applies its server-side default of live enforcement, so semantic behaviour is identical to before. """ - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -725,7 +726,7 @@ def test_init_assembly_warns_on_plaintext_http_with_api_key( that same plaintext non-loopback target, so ``init_assembly`` raises after the warning is emitted. """ - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -746,7 +747,7 @@ def test_init_assembly_no_warning_for_loopback_http_with_api_key( """AAASM-3725: loopback http:// + API key must not warn (local dev).""" import warnings - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -815,7 +816,7 @@ def test_init_assembly_rejects_malformed_agent_id( when the SDK tries to connect. The validator raises ValueError up-front so the caller gets a clear, actionable error. """ - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", @@ -838,7 +839,7 @@ def test_init_assembly_accepts_default_agent_id( Guards against a future change that alters _DEFAULT_AGENT_ID to a value the regex rejects, which would break every caller of init_assembly() (no args). """ - monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: ([], AUDIT_SINK_ABSENT)) monkeypatch.setattr( core_assembly, "_start_network_layer", From a8379e3bd183a83c30e20625d45ddf62b8ce288b Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 13 Aug 2026 15:24:33 +0800 Subject: [PATCH 06/12] =?UTF-8?q?=E2=9C=85=20(test):=20Pin=20the=20audit-s?= =?UTF-8?q?ink=20declaration=20against=20measured=20behaviour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things are pinned separately, because any one alone passes while the defect is present: every handler the factory can return declares a disposition; the declaration matches behaviour in both directions; and init_assembly surfaces it on the default path. Handlers are enumerated by sweeping build_governance_interceptor's own branches rather than by naming classes, because a name list is not a gate — a fourth branch returning a fourth undeclared handler would pass by omission. The LangChain handler is included because _register_adapters substitutes it for the interceptor, making it equally a shipped audit surface. Both directions are pinned: "absent" asserts the hook does not resolve (which is why the gap covers the allowed path), "discarded" asserts it does resolve and still reaches nothing, and a handler that genuinely records must be reported as caller-supplied. Every absence assertion is paired with a positive control on the same boundary and with a forwarding control, since otherwise it is indistinguishable from a probe that never ran or one that cannot see a record. Refs AAASM-5731 --- test/unit/core/test_audit_sink_disposition.py | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 test/unit/core/test_audit_sink_disposition.py diff --git a/test/unit/core/test_audit_sink_disposition.py b/test/unit/core/test_audit_sink_disposition.py new file mode 100644 index 00000000..aa4b5c1c --- /dev/null +++ b/test/unit/core/test_audit_sink_disposition.py @@ -0,0 +1,345 @@ +"""AAASM-5731 — a shipped governance handler must not swallow the audit record silently. + +The adapters' audit hook is duck-typed and returns ``None``, so a handler that +retains the record, one that drops it, and one that never resolves the hook at +all are indistinguishable at the call site. On every interceptor this SDK ships +the hook does not resolve, so **nothing is emitted for an allowed call either** — +not just for a denied one — and before this suite there was no signal of that at +all. + +Three things are pinned separately, because any one of them alone passes while +the defect is present: + +1. every handler ``build_governance_interceptor`` can return, plus the LangChain + handler that replaces it, *declares* a disposition; +2. the declaration matches behaviour in **both** directions — a handler + declaring ``absent`` must resolve no hook and reach nothing, a handler + declaring ``discarded`` must resolve a hook and still reach nothing, and a + handler that genuinely records must be reported as caller-supplied; +3. ``init_assembly`` surfaces it on the DEFAULT path, with nothing opted into. + +The stubs here sit at the **downstream boundaries** — the native +``RuntimeClient`` and the ``GatewayClient``'s HTTP transport — not in place of +the code under test. The point is to prove nothing crosses them. Every +"reached nothing" assertion is paired with a positive control on the same +boundary, because otherwise it is indistinguishable from a probe that never ran, +and with a forwarding control, because otherwise it is indistinguishable from a +probe that cannot see a record at all. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import uuid +from typing import Any + +import httpx +import pytest + +from agent_assembly import init_assembly +from agent_assembly.adapters._shared.tool_governance import run_governed_async_tool +from agent_assembly.adapters.langchain.callback_handler import AssemblyCallbackHandler +from agent_assembly.client.gateway import GatewayClient +from agent_assembly.core import assembly as core_assembly +from agent_assembly.core.audit_sink import ( + AUDIT_SINK_ABSENT, + AUDIT_SINK_CALLER_SUPPLIED, + AUDIT_SINK_DISCARDED, + resolve_audit_sink, +) +from agent_assembly.core.runtime_interceptor import build_governance_interceptor + +from ._fake_core import FakeRuntimeClient, install_fake_core + +_GW_URL = "https://gateway.test" +_API_KEY = "test-key" +_AGENT_ID = "audit-sink-agent" + +# Distinctive enough that finding it anywhere downstream is unambiguous. The +# RESULT suffix is the discriminator: only the record path carries it, whereas +# the args reach the boundary on the policy check, which is the positive control. +_PROBE = "AUDIT-PROBE-AAASM-5731" +_PROBE_RESULT = f"{_PROBE}-RESULT" + +# The two hook names the adapters look up, in the order they look them up. +_AUDIT_HOOKS = ("record_result", "on_tool_end") + + +class _RecordingRuntimeClient(FakeRuntimeClient): + """The native boundary, recording every crossing rather than only queries.""" + + def __init__(self, decision: str = "allow", reason: str = "") -> None: + super().__init__(decision=decision, reason=reason) + self.crossings: list[str] = [] + + def query_policy(self, *args: Any, **kwargs: Any) -> dict[str, str]: + self.crossings.append(f"query_policy:{args!r}:{kwargs!r}") + return super().query_policy(*args, **kwargs) + + def register(self, *args: Any, **kwargs: Any) -> str: + self.crossings.append(f"register:{args!r}") + return super().register(*args, **kwargs) + + def send_event(self, *args: Any, **kwargs: Any) -> None: + # Exposed by the native shim and never called from ``agent_assembly``. + # Recorded so a future wiring change shows up here rather than silently. + self.crossings.append(f"send_event:{args!r}") + + def __getattr__(self, name: str) -> Any: + # Any attribute the SDK reaches for that is not defined above is still an + # attempt to cross; record it rather than raising, so the sweep below + # cannot miss a channel it did not anticipate. + def _recorder(*args: Any, **kwargs: Any) -> None: + self.crossings.append(f"{name}:{args!r}:{kwargs!r}") + + self.crossings.append(f"getattr:{name}") + return _recorder + + +class _RecordingTransport(httpx.BaseTransport): + """The HTTP boundary the GatewayClient would use.""" + + def __init__(self, crossings: list[str]) -> None: + self._crossings = crossings + + def handle_request(self, request: httpx.Request) -> httpx.Response: + self._crossings.append(f"http:{request.method} {request.url.path} {request.content!r}") + return httpx.Response(200, json={"edge_id": "e1"}) + + +def _gateway_client(http_crossings: list[str]) -> GatewayClient: + client = GatewayClient(_GW_URL, _AGENT_ID, api_key=_API_KEY) + client._client = httpx.Client(base_url=client.gateway_url, transport=_RecordingTransport(http_crossings)) + return client + + +def _shipped_interceptor( + native: Any, + http_crossings: list[str], + *, + enforcement_mode: str | None = None, + native_available: bool = True, +) -> Any: + return build_governance_interceptor( + _gateway_client(http_crossings), + _AGENT_ID, + enforcement_mode, + runtime_client=native, + native_available=native_available, + ) + + +def _run_governed(handler: Any) -> tuple[str, Any]: + """Drive the SDK's own governed-tool chain and settle the outcome.""" + + async def _go() -> Any: + return await run_governed_async_tool( + handler, + enforce=True, + tool_name="web_search", + tool_args={"q": _PROBE}, + agent_id=_AGENT_ID, + run_id="run-1", + invoke_original=lambda: _PROBE_RESULT, + ) + + try: + return "returned", asyncio.run(_go()) + except Exception as error: # noqa: BLE001 - the deny path raises by design + return "raised", error + + +def _shipped_handler_matrix(http_crossings: list[str]) -> dict[str, Any]: + """Every handler the SDK can hand an adapter, discovered from the factory. + + Enumerated by sweeping ``build_governance_interceptor``'s own branches rather + than by naming classes, because a name list is not a gate: a fourth branch + returning a fourth undeclared handler would pass by omission. The LangChain + handler is added because ``_register_adapters`` substitutes it for the + interceptor once LangChain registers, so it is equally a shipped audit + surface. + """ + native = _RecordingRuntimeClient() + handlers: dict[str, Any] = {} + for label, kwargs in ( + ("runtime reachable, enforce", {"enforcement_mode": None}), + ("runtime reachable, observe", {"enforcement_mode": "observe"}), + ("runtime unreachable, enforce", {"enforcement_mode": None}), + ("runtime unreachable, observe", {"enforcement_mode": "observe"}), + ("native missing, enforce", {"enforcement_mode": None, "native_available": False}), + ("native missing, observe", {"enforcement_mode": "observe", "native_available": False}), + ): + reachable = label.startswith("runtime reachable") + # The native-missing enforce branch emits its own one-time warning; let + # it through rather than letting -W error turn the sweep into a failure. + expect_warning = "native missing, enforce" in label + with pytest.warns(UserWarning) if expect_warning else contextlib.nullcontext(): + handler = _shipped_interceptor( + native if reachable else None, + http_crossings, + **kwargs, + ) + handlers[label] = handler + handlers["langchain callback handler"] = AssemblyCallbackHandler(handlers["runtime reachable, enforce"]) + return handlers + + +def test_every_shipped_governance_handler_declares_its_audit_sink() -> None: + handlers = _shipped_handler_matrix([]) + + # Positive control on the sweep itself: it must find more than one distinct + # handler type, or an all-pass result would only mean the sweep collapsed. + distinct_types = {type(handler).__name__ for handler in handlers.values()} + assert len(distinct_types) >= 3, ( + f"the factory sweep produced only {distinct_types}; it is not exercising " + "the branches it is supposed to, so its verdict proves nothing" + ) + + undeclared = [ + label + for label, handler in handlers.items() + if getattr(handler, "audit_sink", None) not in {AUDIT_SINK_ABSENT, AUDIT_SINK_DISCARDED} + ] + assert not undeclared, ( + f"handler(s) {undeclared} are shipped without declaring what they do with the " + "hook-layer audit record; the hook returns None either way, so a handler that " + "records and one that emits nothing are indistinguishable (AAASM-5731)" + ) + + +@pytest.mark.parametrize("label", ["runtime reachable, enforce", "runtime unreachable, enforce"]) +def test_a_handler_declaring_absent_resolves_no_audit_hook(label: str) -> None: + """``absent`` means the hook does not resolve — not merely that it records nothing. + + The distinction is load-bearing: it is why the gap covers the ALLOWED path. + The controls are on the same objects, so a blanket ``getattr`` failure cannot + masquerade as the finding. + """ + handlers = _shipped_handler_matrix([]) + handler = handlers[label] + assert handler.audit_sink == AUDIT_SINK_ABSENT + + for hook in _AUDIT_HOOKS: + assert getattr(handler, hook, None) is None, ( + f"{label} declares {AUDIT_SINK_ABSENT!r} but {hook!r} resolves on it; " + "the declaration and the behaviour disagree" + ) + + # Positive controls on the same objects: attribute resolution works, and + # delegation to the wrapped GatewayClient works. Without these, the four + # `is None` assertions above are consistent with a broken probe. + assert callable(handler.check_tool_start) + assert callable(handler.report_edge) + + +@pytest.mark.parametrize("decision", ["allow", "deny"]) +def test_the_shipped_path_reaches_no_boundary_with_the_record(decision: str) -> None: + native = _RecordingRuntimeClient(decision=decision, reason="policy forbids this") + http_crossings: list[str] = [] + handler = _shipped_interceptor(native, http_crossings) + + outcome, _value = _run_governed(handler) + assert outcome == ("raised" if decision == "deny" else "returned") + + # Positive control: the check crossed the native boundary carrying the probe. + assert any(_PROBE in crossing for crossing in native.crossings), ( + f"nothing carrying the probe crossed the native boundary (crossings: " + f"{native.crossings}); the probe never ran, so the absence below proves nothing" + ) + + all_crossings = native.crossings + http_crossings + leaked = [crossing for crossing in all_crossings if _PROBE_RESULT in crossing] + assert not leaked, ( + f"the tool outcome reached a boundary on the shipped path: {leaked}; the " + f"handler declares {handler.audit_sink!r}, so the declaration is wrong" + ) + + +def test_a_handler_that_records_does_reach_the_probe() -> None: + """The forwarding control, in the direction the tests above cannot establish. + + Without it, "the record reached nothing" is consistent with a probe that + cannot observe a record at all, and every absence assertion here is + unfalsifiable. + """ + received: list[dict[str, Any]] = [] + + class _RecordingHandler: + def check_tool_start(self, **_kwargs: Any) -> dict[str, str]: + return {"status": "allow"} + + def record_result(self, **kwargs: Any) -> None: + received.append(kwargs) + + handler = _RecordingHandler() + outcome, _value = _run_governed(handler) + + assert outcome == "returned" + assert any(_PROBE_RESULT in json.dumps(record, default=str) for record in received), ( + f"a handler whose record path genuinely resolves received nothing ({received}); " + "the probe cannot observe a record, so every absence assertion in this file " + "is unfalsifiable" + ) + # This SDK must claim nothing about a handler it did not build, in either + # direction — including one that plainly records. + assert resolve_audit_sink(handler) == AUDIT_SINK_CALLER_SUPPLIED + + +def test_the_langchain_handler_resolves_the_hook_and_still_drops_the_record() -> None: + """``discarded`` is a different failure from ``absent``, and both are shipped. + + The handler defines ``on_tool_end``, so the adapters' lookup DOES resolve and + the record is handed over. It is then forwarded to the interceptor's own + ``on_tool_end``, which does not exist — so the record stops here. + """ + native = _RecordingRuntimeClient() + http_crossings: list[str] = [] + handler = AssemblyCallbackHandler(_shipped_interceptor(native, http_crossings)) + + assert handler.audit_sink == AUDIT_SINK_DISCARDED + assert callable(handler.on_tool_end), ( + "the LangChain handler declares 'discarded', which asserts the hook RESOLVES " + "and the record is dropped after being accepted; if no hook resolves the " + "honest declaration is 'absent'" + ) + + baseline = len(native.crossings) + len(http_crossings) + handler.on_tool_end(_PROBE_RESULT, run_id=uuid.uuid4()) + assert len(native.crossings) + len(http_crossings) == baseline, ( + f"on_tool_end crossed a boundary: native={native.crossings} http={http_crossings}" + ) + + # Positive control on the same handler and the same boundary. + handler.on_tool_start({"name": "web_search"}, _PROBE, run_id=uuid.uuid4()) + assert any(_PROBE in crossing for crossing in native.crossings), ( + "the positive control did not cross either; the probe never ran" + ) + + +def test_init_assembly_warns_and_reports_the_audit_sink_on_the_default_path( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """The signal must arrive with nothing opted into. + + A caller who has to already suspect the problem in order to discover it has + not been told. + """ + install_fake_core(monkeypatch, FakeRuntimeClient(decision="allow")) + monkeypatch.setattr( + core_assembly, + "_start_network_layer", + lambda **_kwargs: ("sdk-only", core_assembly._noop_shutdown), + ) + core_assembly._ACTIVE_CONTEXT = None + + context = init_assembly(gateway_url=_GW_URL, api_key=_API_KEY, agent_id=_AGENT_ID, mode="sdk-only") + try: + stderr = capsys.readouterr().err + assert context.audit_sink != AUDIT_SINK_CALLER_SUPPLIED + for expected in ("audit", "NOT retained", context.audit_sink, "ALLOWED", "AAASM-5731"): + assert expected in stderr, f"{expected!r} missing from init stderr: {stderr!r}" + finally: + context.shutdown() + core_assembly._ACTIVE_CONTEXT = None From ac15c116bd14b84a7876835b65ea1b6f1ec720b3 Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 13 Aug 2026 15:24:34 +0800 Subject: [PATCH 07/12] =?UTF-8?q?=F0=9F=93=9D=20(adapters):=20Correct=20th?= =?UTF-8?q?e=20claim=20term=20for=20an=20unrecorded=20tool=20outcome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docstring called the shipped path Unmeasured, but ADR 0033 section 6 reserves that for an action no control inspected, where nothing is known. Here exactly where the record stops has been measured against the native and HTTP boundaries, so what is missing is a decided-but-unbuilt sink — which is Planned, with AAASM-5731 as the reference. It is certainly not Observed. Refs AAASM-5731 --- .../adapters/_shared/tool_governance.py | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/agent_assembly/adapters/_shared/tool_governance.py b/agent_assembly/adapters/_shared/tool_governance.py index 2f2b59b5..7f1b4079 100644 --- a/agent_assembly/adapters/_shared/tool_governance.py +++ b/agent_assembly/adapters/_shared/tool_governance.py @@ -176,14 +176,22 @@ async def _record_async_tool_result( apart from a tool that ran and returned that same text. Whether anything is recorded depends entirely on the ``callback_handler``. - Both hooks are duck-typed, and on the interceptor the SDK builds today - *neither resolves*: ``RuntimeQueryInterceptor`` defines only - ``check_tool_start`` and delegates the rest to ``GatewayClient``, whose - surface has no ``record_result`` and no ``on_tool_end``. So on the shipped - path this function finds no hook and emits nothing — for allowed calls as - much as denied ones — leaving tool outcomes Unmeasured in audit evidence - (ADR 0033 §6). A caller that supplies its own handler does get the record; - wiring a sink into the SDK's own interceptor is a separate capability. + Both hooks are duck-typed, and on every interceptor the SDK ships *neither + resolves*: ``RuntimeQueryInterceptor`` defines only ``check_tool_start`` and + delegates the rest to ``GatewayClient``, whose surface has no + ``record_result`` and no ``on_tool_end``. Measured against the native and + HTTP boundaries, this function therefore finds no hook and emits nothing on + the shipped path — for allowed calls as much as denied ones. + + Under ADR 0033 §6 that makes SDK-side recording **Planned** (AAASM-5731), + not *Unmeasured*: §6 reserves ``Unmeasured`` for an action no control + inspected, where nothing is known, and here exactly where the record stops + has been measured. It is certainly not *Observed*, which needs a durable + event attributed to the action. Every handler the SDK ships declares this in + ``audit_sink`` (see :mod:`agent_assembly.core.audit_sink`), ``init_assembly`` + warns about it, and a caller that supplies its own handler does get the + record. Wiring a sink into the SDK's own interceptor is a separate + capability. """ denial_flag = {"denied": denied} if denied else {} @@ -286,8 +294,8 @@ async def run_governed_async_tool( # Previously this raised straight past the record call below, so a # denied call could not reach an audit sink even when the caller had # supplied one. See _record_async_tool_result on why the SDK's own - # interceptor still resolves no hook, leaving the shipped path - # Unmeasured. + # interceptor still resolves no hook, so the shipped path emits nothing + # here either (AAASM-5731). # # Best-effort, and the guard is load-bearing: the hook is duck-typed # from caller-supplied code, and inserting a call here where none used From ddb3f229d58f464b4f4eec387d63dfa78b067735 Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 13 Aug 2026 15:24:35 +0800 Subject: [PATCH 08/12] =?UTF-8?q?=F0=9F=93=9D=20(docs):=20Stop=20claiming?= =?UTF-8?q?=20the=20SDK=20layer=20emits=20audit=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README, the docs home and the site description all asserted that "every tool call, prompt, and policy decision is emitted to the gateway". The policy check is; the record is not, on any path. The architecture page also described step 5 as "the side-channel that streams audit events to the gateway" and step 8 as flushing them. Read against the code, all three branches of _start_network_layer return a no-op shutdown and start nothing, so there is no such side-channel and nothing to flush. The two example pages narrate audit output that the demos' own handlers produce; they now say so, since a caller-supplied handler is exactly the branch on which a record does survive. Refs AAASM-5731 --- README.md | 6 ++++-- docs/concepts/architecture.md | 4 ++-- docs/examples/crewai-research-crew.md | 2 +- docs/examples/langchain-research-agent.md | 2 +- docs/index.md | 24 ++++++++++++++++++++--- mkdocs.yml | 4 ++-- 6 files changed, 31 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 63cd6c2b..0bdaf6d1 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,15 @@ [![Code style: ruff](https://img.shields.io/badge/style-ruff-261230?logo=ruff&logoColor=white)](https://github.com/astral-sh/ruff) [![Type-checked: mypy](https://img.shields.io/badge/types-mypy-2a6db2?logo=python&logoColor=white)](https://mypy-lang.org/) -Python SDK for **AI Agent Assembly** — a governance-native runtime for AI agents. One `init_assembly()` call wires your agent into the policy gateway, applies pre-execution allow/deny on tool calls, and emits audit events without changing how the agent itself is written. +Python SDK for **AI Agent Assembly** — a governance-native runtime for AI agents. One `init_assembly()` call wires your agent into the policy gateway and applies pre-execution allow/deny on tool calls, without changing how the agent itself is written. + +> **The SDK layer produces no audit evidence of its own.** The framework adapters offer the outcome of every governed call to an audit hook on the governance interceptor — but on every interceptor this SDK ships, that hook **does not resolve**, so nothing is emitted. This covers **allowed** calls as much as denied ones. Enforcement is unaffected: a policy DENY still blocks the tool. `init_assembly()` warns about it and reports `audit_sink` on the returned context; supply your own handler with a `record_result` or `on_tool_end` to retain the record ([AAASM-5731](https://lightning-dust-mite.atlassian.net/browse/AAASM-5731)). ## Why use it - **Framework adapters** for LangChain, LangGraph, CrewAI, OpenAI Agents, Pydantic AI, Google ADK, Haystack, Smolagents, Agno, LlamaIndex, Microsoft Agent Framework, and MCP servers — drop in, no SDK rewrites required. - **Pre-execution policy enforcement** via the `FrameworkAdapter` ABC — block disallowed tool calls before they hit the LLM. -- **Audit trail** — every tool call, prompt, and policy decision is emitted to the gateway with full agent lineage (parent / root / team). +- **Agent lineage** — parent / root / team identity is registered with the gateway and carried on every policy check. (An audit *trail* is not part of what this SDK layer delivers — see the note above.) - **Native PyO3 fast path** (optional) — drop into a Rust runtime client when you need sub-millisecond policy checks. - **Typed throughout** — Pydantic models for every gateway payload, mypy strict on adapter base and registry. diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 8fad38fb..f506c651 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -102,7 +102,7 @@ For most contributors, this is unnecessary — the pure-Python SDK is the defaul 2. **Create the gateway client** — pure-Python `GatewayClient` by default. If `mode != "sdk-only"` and the native extension is available, the assembly may switch to the Rust `RuntimeClient` (transparent to the caller). 3. **Discover adapters** via `AdapterRegistry.get_available_adapters_by_priority()`. Adapters whose underlying framework is not importable are silently skipped — no warning noise. 4. **Install hooks** by calling `adapter.register_hooks(interceptor)` for each available adapter, in priority order. Each adapter records the patches it owns so they can be reverted in step 9. -5. **Start the network layer** (the side-channel that streams audit events to the gateway). For `mode="ebpf"` and `mode="proxy"`, this is where the network sidecar handshake happens. +5. **Start the network layer.** This is the seam reserved for the sidecar handshake under `mode="ebpf"` / `mode="proxy"`; in this SDK all three branches currently return a no-op shutdown and start nothing, so no side-channel streams audit events from here (AAASM-5731). 6. **Register the active context** in a process-global slot under a lock — `init_assembly()` is idempotent within a process: a second call returns the active context unchanged rather than double-installing hooks. 7. Return the [`AssemblyContext`](../api-reference/index.md) to the caller. @@ -110,7 +110,7 @@ For most contributors, this is unnecessary — the pure-Python SDK is the defaul The returned `AssemblyContext` doubles as a context manager (`__enter__` / `__exit__`). On `shutdown()`: -8. **Stop the network layer** — flush in-flight audit events. +8. **Stop the network layer** — a no-op today, matching step 5; there are no in-flight audit events to flush. 9. **`unregister_hooks()` on every adapter, in reverse install order** — guarantees that nested patches (e.g. LangGraph wrapping LangChain) come off in the order opposite to install. 10. **Close the gateway client** — drain the HTTP keep-alive pool. 11. **Clear the process-global active-context slot** — the next `init_assembly()` call starts clean. diff --git a/docs/examples/crewai-research-crew.md b/docs/examples/crewai-research-crew.md index 46ab2be9..0d028a93 100644 --- a/docs/examples/crewai-research-crew.md +++ b/docs/examples/crewai-research-crew.md @@ -177,7 +177,7 @@ Running crew delegation trajectory: → write_file({"path": "report.md"}) ❌ BLOCKED — Approval for 'write_file' by 'critic' was rejected — the crew may not persist files without sign-off. -Delegation-aware audit events recorded this run: +Delegation-aware audit events recorded this run (by the demo's own handler — the SDK layer produces none, AAASM-5731): ---------------------------------------------- ✅ allow web_search chain: researcher → web_search ✅ allow web_search chain: researcher → web_search diff --git a/docs/examples/langchain-research-agent.md b/docs/examples/langchain-research-agent.md index 52f623a6..216d619d 100644 --- a/docs/examples/langchain-research-agent.md +++ b/docs/examples/langchain-research-agent.md @@ -8,7 +8,7 @@ This example initializes Agent Assembly with `init_assembly()` in `sdk-only` mod - **Network allowlist** — outbound egress is only allowed to `*.openai.com`. - **Daily budget** — tool calls are metered against a `$1.00 / day` cap. -- **Tool-call logging** — every governed call is recorded as an audit event. +- **Tool-call logging** — the demo appends every governed call to its own in-process `audit_log`. That log is the demo's, not the SDK's: the SDK layer produces no audit evidence (AAASM-5731). - **Credential-leak block** — any tool input carrying a secret is denied. It also includes a credential-leak demo that uses a **SAFE, FAKE** key (`sk-FAKE...`) — never a real secret — to show the leak rule firing. Finally, `--mock` mode runs the whole demo offline with no API keys, so CI can run it. diff --git a/docs/index.md b/docs/index.md index ed82ca32..c70b6c99 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,15 +43,33 @@ flowchart LR - **Developers** who want to add governance to an existing Python agent without re-architecting it. - **Platform teams** standing up a policy gateway who need their agents to report to it. -- **Operators** who need an audit trail of every tool call, prompt, and policy decision. +- **Operators** who need agents to run under a policy gate they control, with identity and + lineage registered against the gateway. + +Note that an **audit trail of governed tool calls is not something this SDK layer +produces** — see the warning under "Why use it" below. ## Why use it - **Framework adapters** for LangChain, LangGraph, CrewAI, OpenAI Agents, Pydantic AI, Google ADK, and MCP servers — drop in, no agent rewrites required. - **Pre-execution policy enforcement** — block disallowed tool calls *before* they run. -- **Audit trail** — every tool call, prompt, and policy decision is emitted to the gateway - with full agent lineage (parent / root / team). +- **Agent lineage** — parent / root / team identity is registered with the gateway and + carried on every policy check. + +!!! warning "The SDK layer keeps no audit trail of its own" + + The framework adapters offer the outcome of every governed call to an audit hook + on the governance interceptor. On every interceptor this SDK ships that hook + **does not resolve**, so nothing is emitted — for **allowed** calls as much as + denied ones — and no claim of attributability or after-the-fact review holds on + the SDK path. + + Enforcement is unaffected: a policy DENY still blocks the tool, and the proxy / + eBPF layers remain authoritative. `init_assembly()` warns at startup and reports + `audit_sink` on the returned context; supply your own handler exposing + `record_result` or `on_tool_end` to retain the record + ([AAASM-5731](https://lightning-dust-mite.atlassian.net/browse/AAASM-5731)). - **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 diff --git a/mkdocs.yml b/mkdocs.yml index a12bd5ea..62594895 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,8 +4,8 @@ site_url: https://docs.agent-assembly.com/python-sdk/ site_author: Agent Assembly Team site_description: >- Python SDK for AI Agent Assembly — a governance-native runtime for AI agents. - Wire framework adapters, enforce policy on tool calls, and emit audit events - with one init_assembly() call. + Wire framework adapters and enforce policy on tool calls with one + init_assembly() call. # Repository repo_name: ai-agent-assembly/python-sdk From 5011fc0616b46676e85884528830929f9431ecaf Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 13 Aug 2026 15:31:11 +0800 Subject: [PATCH 09/12] =?UTF-8?q?=F0=9F=93=9D=20(docs):=20Propagate=20the?= =?UTF-8?q?=20accurate=20audit=20wording=20to=20the=2012=20remaining=20sur?= =?UTF-8?q?faces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciled against an exhaustive inventory of the repo rather than the targeted search the first docs commit was based on: 19 claims that the SDK layer produces audit evidence, of which that commit reached 9. The remaining twelve, in blast-radius order: the .claude/CLAUDE.md three-layer model, which re-seeds the claim into every future agent task on this repo; the module docstring of tool_governance.py, which asserted "either way the outcome is recorded" 170 lines above the docstring that states neither hook resolves — a reader who stops at the module docstring gets exactly the wrong model; a sample comment in framework-support.md that a user copies into their own code; the architecture diagram edge labelling the SDK→gateway hop "allow/deny + audit"; the native-shim section, where send_event exists in the shim and nothing in agent_assembly calls it; the adapter-author contract table, which documented the audit hook as a live route; three adapter/example docstrings; and the docs-home lede presenting "recorded" as a third outcome alongside allowed and denied. Nothing new is invented. The repo already contained the correct framing in _record_async_tool_result's docstring, in one example page's "a real gateway would persist server-side", and throughout the observe-mode family's "the gateway records" — this propagates that wording outward to the surfaces users read, which is the only place it had failed to reach. Refs AAASM-5731 --- .claude/CLAUDE.md | 7 +++++-- agent_assembly/adapters/_shared/tool_governance.py | 6 ++++-- agent_assembly/adapters/haystack/patch.py | 4 +++- agent_assembly/adapters/llamaindex/adapter.py | 6 ++++-- docs/concepts/architecture.md | 8 +++++--- docs/examples/crewai-research-crew.md | 4 ++-- docs/examples/framework-support.md | 4 +++- docs/guides/authoring-adapters.md | 2 +- docs/index.md | 5 +++-- examples/basic_usage.py | 4 +++- 10 files changed, 33 insertions(+), 17 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 5c5d7873..2a3a73e2 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -32,8 +32,11 @@ The product enforces governance through three independently-deployable layers, ordered by latency cost (lowest first) and detection authority (highest first): 1. **SDK layer (in-process)** — *this repo*. The SDK applies pre-execution allow/deny - on tool calls and emits audit events to the gateway, via the native shim over - `aa-sdk-client`. Fastest path; requires SDK adoption. + on tool calls via the native shim over `aa-sdk-client`. Fastest path; requires SDK + adoption. It does **not** emit audit events: the adapters offer every governed + outcome to an audit hook, but on every interceptor this SDK ships that hook does not + resolve, so nothing is recorded — for allowed calls as much as denied ones + (AAASM-5731). 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 diff --git a/agent_assembly/adapters/_shared/tool_governance.py b/agent_assembly/adapters/_shared/tool_governance.py index 7f1b4079..66d91412 100644 --- a/agent_assembly/adapters/_shared/tool_governance.py +++ b/agent_assembly/adapters/_shared/tool_governance.py @@ -5,8 +5,10 @@ call is intercepted is identical: serialize the args, ask the interceptor for a verdict, honour a ``pending`` approval round-trip, deny by raising when the verdict is ``deny``, otherwise run the original inside a spawn-context scope. -Either way the outcome is recorded through the audit hook before the flow ends -(AAASM-5665). That shared body — previously duplicated verbatim in both +Either way the outcome is *offered* to the audit hook before the flow ends +(AAASM-5665) — offered, not recorded: on every interceptor this SDK ships the hook +does not resolve, so nothing is retained on either path. See +:func:`_record_async_tool_result` for the measurement. That shared body — previously duplicated verbatim in both adapters (the cross-file duplication SonarCloud flagged on PR #269, AAASM-4746) — lives here so each adapter keeps only its framework-specific glue. diff --git a/agent_assembly/adapters/haystack/patch.py b/agent_assembly/adapters/haystack/patch.py index ce9a8108..40605361 100644 --- a/agent_assembly/adapters/haystack/patch.py +++ b/agent_assembly/adapters/haystack/patch.py @@ -13,7 +13,9 @@ The interceptor contract mirrors the other tool-call adapters (CrewAI, Pydantic AI): a ``check_tool_start`` pre-execution gate that returns ``allow`` / ``deny`` / ``pending``, an optional ``wait_for_tool_approval`` for the pending flow, and a -post-execution ``record_result`` / ``on_tool_end`` audit hook. Under the fail-closed +post-execution ``record_result`` / ``on_tool_end`` audit hook — which no interceptor +this SDK ships resolves, so the outcome is offered and not recorded (AAASM-5731). +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 50921b1a..dc39f8f4 100644 --- a/agent_assembly/adapters/llamaindex/adapter.py +++ b/agent_assembly/adapters/llamaindex/adapter.py @@ -9,8 +9,10 @@ class LlamaIndexAdapter(FrameworkAdapter): """Adapter for LlamaIndex framework governance hook installation. - Wires the SDK-layer pre-execution allow/deny + audit hook onto the - LlamaIndex tool-execution path (``FunctionTool.call`` / ``acall``). The + Wires the SDK-layer pre-execution allow/deny onto the LlamaIndex + tool-execution path (``FunctionTool.call`` / ``acall``), and offers each + outcome to the audit hook — which no interceptor this SDK ships resolves, so + nothing is recorded from here (AAASM-5731). 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). diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index f506c651..c9f942a4 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -64,11 +64,13 @@ flowchart LR Adapter -->|register_hooks(interceptor)| Patch Patch -->|monkey-patch| Framework Framework -.->|every tool call| Interceptor - Interceptor -.->|allow/deny + audit| Gateway + Interceptor -.->|allow/deny| Gateway ``` 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. +There is deliberately no audit edge on that hop. The adapters offer every governed outcome to an audit hook on the interceptor, but on every interceptor this SDK ships the hook does not resolve, so no record leaves the SDK — for allowed calls as much as denied ones ([AAASM-5731](https://lightning-dust-mite.atlassian.net/browse/AAASM-5731)). + ## PyO3 FFI layer The pure-Python adapters described above are sufficient for governing most agent frameworks. For deployments where every microsecond of policy-check latency matters — typically gateways under heavy multi-tenant load — the SDK ships an **optional** native runtime client written in Rust and exposed to Python via [PyO3](https://pyo3.rs/). @@ -77,8 +79,8 @@ The pure-Python adapters described above are sufficient for governing most agent The native crate lives at `native/aa-ffi-python/` in the repository and is built with [`maturin`](https://www.maturin.rs/). When installed, it exposes a private `agent_assembly._core` module with two symbols: -- `RuntimeClient` — a Rust-backed runtime client (a thin shim over the shared `aa-sdk-client` crate) that ships governance events to `aa-runtime` over the local socket. Sub-millisecond, fire-and-forget event reporting under load. -- `GovernanceEvent` — Rust-side dataclass for events emitted on the audit channel. +- `RuntimeClient` — a Rust-backed runtime client (a thin shim over the shared `aa-sdk-client` crate). `agent_assembly` uses it for `register` and `query_policy` only. It also exposes `send_event`, which **nothing in `agent_assembly` calls** — the capability exists in the shim and the SDK never reaches it, so no governance event is shipped from here (AAASM-5731). +- `GovernanceEvent` — Rust-side dataclass for the events that channel would carry. Exported from `agent_assembly`, and never constructed by it. `agent_assembly/__init__.py` imports these symbols inside a `try / except ImportError` block. **If the native extension was never built, the SDK still works** — pure-Python `GatewayClient` is the fallback, and the `RuntimeClient` symbol simply is not present in `agent_assembly.__all__`. diff --git a/docs/examples/crewai-research-crew.md b/docs/examples/crewai-research-crew.md index 0d028a93..5b61dcf1 100644 --- a/docs/examples/crewai-research-crew.md +++ b/docs/examples/crewai-research-crew.md @@ -1,11 +1,11 @@ # CrewAI — multi-agent research crew -A three-agent CrewAI-style research crew (researcher → writer → critic) governed by Agent Assembly, where every governed tool call is attributed to the acting agent with the full delegation chain captured on each audit event. +A three-agent CrewAI-style research crew (researcher → writer → critic) governed by Agent Assembly, where every governed tool call is attributed to the acting agent with the full delegation chain captured on each audit event by the demo's own `CrewPolicyEngine`. The SDK layer records nothing itself (AAASM-5731). ## What this example demonstrates - A three-agent crew: **researcher → writer → critic**, each with a distinct role. -- **Agent-delegation tracking** — every governed call records an `AuditEvent` whose `call_stack` is the delegation chain (`parent → agent → tool`), built from the SDK's real `agent_assembly.types.AuditEvent` and `CallStackNode`. +- **Agent-delegation tracking** — the demo's `CrewPolicyEngine` records an `AuditEvent` per governed call whose `call_stack` is the delegation chain (`parent → agent → tool`), built from the SDK's real `agent_assembly.types.AuditEvent` and `CallStackNode`. The types are the SDK's; the recording is the demo's. - **Multi-agent governance** under one policy: - **File-write approval** — any agent that attempts `write_file` is gated; the decision is `pending` until an approver signs off (rejected in this demo). - **Shared daily budget** — tool calls across all three agents are metered against a single `$2.00 / day` cap. diff --git a/docs/examples/framework-support.md b/docs/examples/framework-support.md index a1d59409..6f055edb 100644 --- a/docs/examples/framework-support.md +++ b/docs/examples/framework-support.md @@ -24,7 +24,9 @@ with init_assembly( mode="sdk-only", ): # Build and run your agent exactly as you normally would. - # Every tool call now passes through the policy gate and is audited. + # Every tool call now passes through the policy gate. It is NOT audited by the + # SDK layer: the outcome is offered to an audit hook that does not resolve on + # any interceptor this SDK ships, so nothing is recorded (AAASM-5731). ... ``` diff --git a/docs/guides/authoring-adapters.md b/docs/guides/authoring-adapters.md index c9dc42e4..f8e42dae 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 | Report a completed tool call's output for audit. | +| `record_result` / `on_tool_end` | adapter → interceptor, no return | Offer a governed tool call's outcome for audit — allowed or denied. **No interceptor this SDK ships resolves either name**, so on the shipped path the `getattr` guard finds nothing and the outcome is not recorded; only a caller-supplied handler retains it (AAASM-5731). | | `record` | adapter → interceptor, no return | Generic structured event (e.g. `action="task_start"`). | !!! note "These are conventions, not a typed contract" diff --git a/docs/index.md b/docs/index.md index c70b6c99..88586eeb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,8 +2,9 @@ **In plain terms:** this SDK is how a Python agent asks for permission before it acts. You wrap your existing agent in one `init_assembly()` call, and from that point on every -tool call your agent makes is checked against a governance policy — allowed, denied, or -recorded — without you rewriting a single line of the agent itself. +tool call your agent makes is checked against a governance policy — allowed or denied — +without you rewriting a single line of the agent itself. Recording is *not* a third +outcome: the SDK layer produces no audit evidence of its own (see below). It is two things in one package: diff --git a/examples/basic_usage.py b/examples/basic_usage.py index 30fd063f..5bab9f4c 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -31,7 +31,9 @@ # For example: # - Register the agent with the gateway # - Check policy compliance before executing actions -# - Log audit events +# +# Note: the SDK layer does NOT log audit events. Governed outcomes are offered to an +# audit hook that no interceptor this SDK ships resolves (AAASM-5731). # Don't forget to shutdown the runtime when done assembly.shutdown() From 4d1d9e966a8bfdd514b7e7ead5d0e31570d9fc24 Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 13 Aug 2026 16:08:54 +0800 Subject: [PATCH 10/12] =?UTF-8?q?=F0=9F=90=9B=20(core):=20Compute=20the=20?= =?UTF-8?q?interceptors'=20disposition=20instead=20of=20fixing=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuntimeQueryInterceptor owns no audit hook — __getattr__ hands both names straight to the wrapped client — so its disposition is the client's, not a constant. As a fixed class attribute it produced a false 'absent': review of #315 measured a caller-supplied client whose record_result resolves still reporting 'absent', contradicting the very hook the adapters would have called, with the LangChain handler on top compounding it to 'discarded'. Both interceptors now compute it, as AssemblyCallbackHandler already did. The two branches are the honest ones: no hook resolves on the delegate, so nothing can be attempted through the interceptor either (absent); a hook resolves, so it came from the caller and this SDK makes no claim (caller-supplied). The old error under-claimed rather than over-claimed, which is why this is a correctness fix and not a severity one — it never reported retention where there was none, and the new resolver cannot either. AUDIT_HOOK_NAMES moves into audit_sink.py so the interceptors, the adapters and the tests cannot drift on what counts as the audit hook — a drift that would make every 'absent' declaration unfalsifiable. Refs AAASM-5731 --- agent_assembly/core/audit_sink.py | 35 ++++++++++++++++++++++ agent_assembly/core/runtime_interceptor.py | 26 +++++++++++----- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/agent_assembly/core/audit_sink.py b/agent_assembly/core/audit_sink.py index 3a2a304b..c25ce8d5 100644 --- a/agent_assembly/core/audit_sink.py +++ b/agent_assembly/core/audit_sink.py @@ -65,6 +65,14 @@ if the caller's own handler actually keeps what it is given. """ +AUDIT_HOOK_NAMES = ("record_result", "on_tool_end") +"""The audit hooks the adapters look up, in the order they look them up. + +Defined here so the interceptors, the tests and the adapters cannot drift apart +on what counts as "the audit hook" — a drift that would make every +:data:`AUDIT_SINK_ABSENT` declaration below unfalsifiable. +""" + AUDIT_SINK_ATTRIBUTE = "audit_sink" """Attribute name a handler declares its disposition under. @@ -76,6 +84,33 @@ _VALID_DISPOSITIONS = frozenset(get_args(AuditSinkDisposition.__value__)) +def resolve_delegated_audit_sink(delegate: Any) -> AuditSinkDisposition: + """Disposition of an interceptor that delegates its audit hooks to ``delegate``. + + Computed rather than fixed, because it genuinely depends on what is wrapped. + Review of AAASM-5731's PR measured the cost of hard-coding it: with a + caller-supplied client whose ``record_result`` resolves, + ``RuntimeQueryInterceptor`` still reported :data:`AUDIT_SINK_ABSENT` — a false + ``absent``, contradicting the very hook the adapters would have called. + + Two branches, both honest: + + * no hook resolves on ``delegate`` — nothing can be attempted through this + interceptor either, so :data:`AUDIT_SINK_ABSENT`; + * a hook resolves — this SDK ships no client that has one, so the hook came + from the caller and this SDK makes no claim about it: + :data:`AUDIT_SINK_CALLER_SUPPLIED`. + + Note the failure direction if this is ever wrong: it under-claims. Reporting + ``caller-supplied`` where a record is in fact dropped withholds a claim; the + reverse — reporting retention where there is none — is the defect this whole + type exists to prevent, and this function cannot produce it. + """ + if any(callable(getattr(delegate, hook, None)) for hook in AUDIT_HOOK_NAMES): + return AUDIT_SINK_CALLER_SUPPLIED + return AUDIT_SINK_ABSENT + + def resolve_audit_sink(handler: Any) -> AuditSinkDisposition: """Report what ``handler`` does with the hook-layer audit record. diff --git a/agent_assembly/core/runtime_interceptor.py b/agent_assembly/core/runtime_interceptor.py index 701e0efb..c2fe5a09 100644 --- a/agent_assembly/core/runtime_interceptor.py +++ b/agent_assembly/core/runtime_interceptor.py @@ -43,7 +43,7 @@ from importlib import metadata from typing import Any -from agent_assembly.core.audit_sink import AUDIT_SINK_ABSENT, AuditSinkDisposition +from agent_assembly.core.audit_sink import AuditSinkDisposition, resolve_delegated_audit_sink from agent_assembly.exceptions import OpTerminatedError ENV_RUNTIME_SOCKET = "AA_RUNTIME_SOCKET" @@ -237,11 +237,20 @@ class RuntimeQueryInterceptor: this class (AAASM-5731). """ - # AAASM-5731 — an audit-hook lookup on this object returns None, so the - # adapters' getattr guard finds nothing to call and the record is never even - # attempted. Declared so init_assembly can surface it and a test can catch a - # shipped handler that emits nothing without saying so. - audit_sink: AuditSinkDisposition = AUDIT_SINK_ABSENT + @property + def audit_sink(self) -> AuditSinkDisposition: + """What this interceptor does with the audit record (AAASM-5731). + + Computed from the wrapped client rather than fixed, because this class + owns no audit hook of its own — ``__getattr__`` hands both names + straight to the client, so the client's surface *is* the answer. With + the ``GatewayClient`` this SDK builds, neither resolves and the record is + never attempted; with a caller-supplied client that has one, this SDK + makes no claim. Declared on this class rather than inherited through + ``__getattr__`` so a test can require the interceptor to speak for + itself. + """ + return resolve_delegated_audit_sink(self._client) def __init__( self, @@ -381,7 +390,10 @@ class _FailClosedInterceptor: :attr:`audit_sink`, AAASM-5731). """ - audit_sink: AuditSinkDisposition = AUDIT_SINK_ABSENT + @property + def audit_sink(self) -> AuditSinkDisposition: + """See :attr:`RuntimeQueryInterceptor.audit_sink` — same delegation, same answer.""" + return resolve_delegated_audit_sink(self._client) def __init__(self, client: Any, reason: str) -> None: self._client = client From 9eb7eac34b37500bd2630d754e22290f6e22d573 Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 13 Aug 2026 16:09:07 +0800 Subject: [PATCH 11/12] =?UTF-8?q?=E2=9C=85=20(test):=20Require=20each=20ha?= =?UTF-8?q?ndler=20to=20declare=20its=20disposition,=20not=20inherit=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate read audit_sink with getattr, which every interceptor answers through __getattr__. Review of #315 measured the hole: deleting the declaration from RuntimeQueryInterceptor left GatewayClient answering for it and all eight tests passed, so the interceptor was never required to speak for itself — and a fourth interceptor that delegates would inherit 'absent' silently even when its own hook resolves. The gate now walks the handler's own MRO, which instance __getattr__ cannot satisfy. Re-measured with the reviewer's mutation: it fails and names the two affected branches. The probe carries its own control, because the gate's verdict now rests on it: a delegating object that answers the same value must be rejected while a declaring one is accepted. Without it, a probe stuck at True would make the gate pass for everything. Adds the regression test for the false 'absent', with a control that the shipped client still reads 'absent' — so the fix cannot pass by simply ceasing to report the real gap. Refs AAASM-5731 --- test/unit/core/test_audit_sink_disposition.py | 93 ++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/test/unit/core/test_audit_sink_disposition.py b/test/unit/core/test_audit_sink_disposition.py index aa4b5c1c..51852aeb 100644 --- a/test/unit/core/test_audit_sink_disposition.py +++ b/test/unit/core/test_audit_sink_disposition.py @@ -186,6 +186,43 @@ def _shipped_handler_matrix(http_crossings: list[str]) -> dict[str, Any]: return handlers +def _declares_on_its_own_class(handler: object) -> bool: + """Whether ``audit_sink`` is declared in the handler's own MRO. + + ``getattr`` cannot answer this: every interceptor here defines ``__getattr__`` + and would happily forward the lookup to the wrapped client, which is exactly + the hole this closes. A class-dictionary walk is consulted instead, because + instance ``__getattr__`` is not invoked for attributes found on the class. + """ + return any("audit_sink" in klass.__dict__ for klass in type(handler).__mro__ if klass is not object) + + +def test_the_declaration_probe_can_tell_delegation_from_declaration() -> None: + """Control for _declares_on_its_own_class, which the gate's verdict rests on. + + Without it, a probe that returned True unconditionally would make the gate + above pass for every handler, including one that only delegates. + """ + + class _Delegating: + def __init__(self, inner: object) -> None: + self._inner = inner + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) + + class _Declaring: + audit_sink = AUDIT_SINK_ABSENT + + declaring = _Declaring() + delegating = _Delegating(declaring) + + # The delegating object answers the same value, and must still be rejected. + assert getattr(delegating, "audit_sink", None) == AUDIT_SINK_ABSENT + assert _declares_on_its_own_class(declaring) is True + assert _declares_on_its_own_class(delegating) is False + + def test_every_shipped_governance_handler_declares_its_audit_sink() -> None: handlers = _shipped_handler_matrix([]) @@ -197,10 +234,24 @@ def test_every_shipped_governance_handler_declares_its_audit_sink() -> None: "the branches it is supposed to, so its verdict proves nothing" ) + # Every handler must declare on ITS OWN class, not inherit an answer through + # __getattr__. Review of #315 measured why: deleting audit_sink from + # RuntimeQueryInterceptor left __getattr__ answering from GatewayClient and + # all eight tests passed, so the interceptor was never required to speak for + # itself — and a fourth interceptor that delegates would inherit "absent" + # silently even if its own hook resolved. + silent = [label for label, handler in handlers.items() if not _declares_on_its_own_class(handler)] + assert not silent, ( + f"handler(s) {silent} do not declare 'audit_sink' anywhere in their own MRO; " + "they answer only by delegation, so the gate would pass a handler that never " + "says what it does with the record (AAASM-5731)" + ) + undeclared = [ label for label, handler in handlers.items() - if getattr(handler, "audit_sink", None) not in {AUDIT_SINK_ABSENT, AUDIT_SINK_DISCARDED} + if getattr(handler, "audit_sink", None) + not in {AUDIT_SINK_ABSENT, AUDIT_SINK_DISCARDED, AUDIT_SINK_CALLER_SUPPLIED} ] assert not undeclared, ( f"handler(s) {undeclared} are shipped without declaring what they do with the " @@ -287,6 +338,46 @@ def record_result(self, **kwargs: Any) -> None: assert resolve_audit_sink(handler) == AUDIT_SINK_CALLER_SUPPLIED +def test_a_caller_supplied_recording_client_is_not_reported_as_absent() -> None: + """A false ``absent`` is a claim about the caller's code that this SDK cannot make. + + ``RuntimeQueryInterceptor`` owns no audit hook — ``__getattr__`` hands both + names to the wrapped client — so its disposition is the client's. When it was + a fixed class attribute, a caller-supplied client whose ``record_result`` + resolves still reported ``absent``, contradicting the very hook the adapters + would have called, and the LangChain handler on top compounded it to + ``discarded``. + + The direction of the old error matters and is why this is a correctness fix + rather than a severity one: it under-claimed. It never reported retention + where there was none. + """ + http_crossings: list[str] = [] + + class _RecordingClient(GatewayClient): + def record_result(self, **_kwargs: Any) -> None: + return None + + client = _RecordingClient(_GW_URL, _AGENT_ID, api_key=_API_KEY) + client._client = httpx.Client(base_url=client.gateway_url, transport=_RecordingTransport(http_crossings)) + interceptor = build_governance_interceptor( + client, _AGENT_ID, None, runtime_client=_RecordingRuntimeClient(), native_available=True + ) + + # Precondition: the hook really does resolve through the delegation, or this + # test is asserting about a situation that cannot arise. + assert callable(getattr(interceptor, "record_result", None)) + + assert resolve_audit_sink(interceptor) == AUDIT_SINK_CALLER_SUPPLIED + assert resolve_audit_sink(AssemblyCallbackHandler(interceptor)) == AUDIT_SINK_CALLER_SUPPLIED + + # Control on the same shapes: the client this SDK actually ships still reads + # 'absent', so the fix did not simply stop reporting the real gap. + shipped = _shipped_interceptor(_RecordingRuntimeClient(), http_crossings) + assert resolve_audit_sink(shipped) == AUDIT_SINK_ABSENT + assert resolve_audit_sink(AssemblyCallbackHandler(shipped)) == AUDIT_SINK_DISCARDED + + def test_the_langchain_handler_resolves_the_hook_and_still_drops_the_record() -> None: """``discarded`` is a different failure from ``absent``, and both are shipped. From 0b03e7c730147f85c59cf44d6551c9b258f21304 Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 13 Aug 2026 16:09:08 +0800 Subject: [PATCH 12/12] =?UTF-8?q?=F0=9F=93=9D=20(docs):=20Correct=20the=20?= =?UTF-8?q?`record`=20contract=20row=20and=20the=20residual=20Unmeasured?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inventory item 16. The adapter-author contract table was corrected on the record_result / on_tool_end row but not the `record` row one line below, so it still documented a live audit route. Measured: `record` resolves to None on all three shipped interceptors, and crewai/patch.py:429,445 looks it up for task start and complete — so those events are not recorded either. The row was missed because the fix was applied to the item rather than to the list it belongs to; the whole table is now consistent. Also the last residual Unmeasured. Every remaining occurrence in the repo is now part of an explicit "Planned, not Unmeasured, because where the record stops has been measured" statement. Refs AAASM-5731 --- docs/guides/authoring-adapters.md | 2 +- test/unit/test_quickstart_negative_control.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/guides/authoring-adapters.md b/docs/guides/authoring-adapters.md index f8e42dae..b7a43e5b 100644 --- a/docs/guides/authoring-adapters.md +++ b/docs/guides/authoring-adapters.md @@ -135,7 +135,7 @@ status string `"allow" | "deny" | "pending"`, or a mapping `{"status": ..., "rea | `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 — allowed or denied. **No interceptor this SDK ships resolves either name**, so on the shipped path the `getattr` guard finds nothing and the outcome is not recorded; only a caller-supplied handler retains it (AAASM-5731). | -| `record` | adapter → interceptor, no return | Generic structured event (e.g. `action="task_start"`). | +| `record` | adapter → interceptor, no return | Generic structured event (e.g. `action="task_start"`). **No interceptor this SDK ships resolves this name either** — the CrewAI patch looks it up for task start/complete (`crewai/patch.py:429,445`) and finds nothing, so those events are not recorded on the shipped path (AAASM-5731). | !!! note "These are conventions, not a typed contract" The names above are what today's built-in adapters happen to call (see the CrewAI patch diff --git a/test/unit/test_quickstart_negative_control.py b/test/unit/test_quickstart_negative_control.py index 5f412be7..0a3cafe2 100644 --- a/test/unit/test_quickstart_negative_control.py +++ b/test/unit/test_quickstart_negative_control.py @@ -298,9 +298,11 @@ def test_a_denied_call_emits_an_audit_record_carrying_the_agent_and_tool( # Scope of the evidence: the record is captured by this fixture's # handler. The interceptor the SDK builds resolves no audit hook at all # (RuntimeQueryInterceptor + GatewayClient expose neither - # record_result nor on_tool_end), so tool outcomes are Unmeasured in - # audit evidence on the shipped path. What this pins is the governance - # flow's call — the part fixable without wiring a sink. + # record_result nor on_tool_end), so tool outcomes produce no audit + # evidence on the shipped path — Planned under ADR 0033 §6 + # (AAASM-5731), not Unmeasured, since where the record stops has been + # measured. What this pins is the governance flow's call — the part + # fixable without wiring a sink. assert len(quickstart.interceptor.records) == 1 record = quickstart.interceptor.records[0] assert record.tool_name == "write_to_disk"