Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e3289be
✨ (adapters): Add a shared deny-branch audit record helper
Chisanan232 Aug 14, 2026
5a767a2
πŸ› (crewai): Build an audit record when the tool call is denied
Chisanan232 Aug 14, 2026
b23ebea
πŸ› (haystack): Build an audit record when the tool call is denied
Chisanan232 Aug 14, 2026
4b5b30d
πŸ› (smolagents): Build an audit record when the tool call is denied
Chisanan232 Aug 14, 2026
e3dfb92
πŸ› (llamaindex): Build an audit record on both denied tool paths
Chisanan232 Aug 14, 2026
1696a8a
πŸ› (agno): Build an audit record on both denied tool paths
Chisanan232 Aug 14, 2026
46139af
πŸ› (microsoft-agent-framework): Build an audit record when denied
Chisanan232 Aug 14, 2026
db1a3bf
πŸ› (mcp): Build an audit record when the tool call is denied
Chisanan232 Aug 14, 2026
08523cc
πŸ› (langchain): Build an audit record on the three deny paths
Chisanan232 Aug 14, 2026
ded98d8
πŸ› (openai_agents): Mark the denied record so it reads as a deny
Chisanan232 Aug 14, 2026
429dafc
♻️ (adapters): Fold the duplicated audit helpers into the leaf module
Chisanan232 Aug 14, 2026
00f6542
βœ… (adapters): Add the per-adapter deny-record conformance matrix
Chisanan232 Aug 14, 2026
952e3fc
πŸ“ (docs): Say that denied calls are recorded, and by how many adapters
Chisanan232 Aug 14, 2026
1bc9889
πŸ“ (claude): Correct the SDK layer's denied-path description
Chisanan232 Aug 14, 2026
d7091f1
πŸ“ (core): Correct the audit-sink and interceptor docstrings
Chisanan232 Aug 14, 2026
3bbb56a
πŸ“ (adapters): Point the module docstrings at the deny-record helper
Chisanan232 Aug 14, 2026
cd69edb
πŸ“ (docs): Drop the stale lead from the audit admonition
Chisanan232 Aug 14, 2026
da39073
πŸ› (adapters): Offer only the audit keywords a hook can receive
Chisanan232 Aug 14, 2026
84c515d
βœ… (adapters): Pin that a narrow allowed-path hook still gets the deny…
Chisanan232 Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ ordered by latency cost (lowest first) and detection authority (highest first):
load-bearing and must not be dropped when describing this: the send is
unacknowledged, so a handoff is **not** evidence and never ADR 0033 Β§6
*Observed* β€” AAASM-5783 is open on the downstream half and must land before
that changes; and only `google_adk`, `pydantic_ai` and `openai_agents` record on the
**denied** path β€” the other eight governed adapters return or raise first. With no
reachable runtime nothing is recorded at all (AAASM-5750). Never describe this
layer as producing an audit trail.
that changes. The eleven governing adapters record on the **denied** path as well
as the allowed one, with a denial marker on the record (AAASM-5787); `langgraph`
instruments nodes for lineage and has no tool-call gate. With no reachable runtime
nothing is recorded at all (AAASM-5750). Do not describe this layer as producing an
audit trail.
2. **Sidecar proxy (`aa-proxy`)** β€” MitM of outbound HTTPS; enforces network-egress
policy with no code changes. (Lives in the monorepo.)
3. **eBPF (`aa-ebpf*`)** β€” kernel uprobes; catches everything, including bypass
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Python SDK for **AI Agent Assembly** β€” a governance-native runtime for AI agen

> **The SDK hands records to the runtime; it does not give you an audit trail.** The framework adapters offer a governed call's outcome to an audit hook on the governance interceptor, and over a connected runtime that hook writes it to the native event channel β€” the same one agent registration uses. That is a handoff, **not** evidence: the send is unacknowledged, so this SDK cannot tell you the record arrived, and does not claim it did. Downstream, [AAASM-5783](https://lightning-dust-mite.atlassian.net/browse/AAASM-5783) is open on `report_event` payloads reaching neither the live stream nor the durable entry β€” until it lands, no SDK can claim ADR 0033 Β§6 *Observed*. Without a reachable runtime there is no channel at all and nothing is emitted.
>
> **Denied calls are mostly not covered.** Only `google_adk`, `pydantic_ai` and `openai_agents` build a record on the denied path. The other eight governed adapters β€” `crewai`, `llamaindex`, `haystack`, `agno`, `smolagents`, `microsoft_agent_framework`, `mcp` and `langchain` β€” return or raise before their record helper, so a deny there produces no record for any sink to carry. Enforcement is unaffected either way: a policy DENY still blocks the tool. `init_assembly()` warns when no record can be sent and reports `audit_sink` on the returned context ([AAASM-5750](https://lightning-dust-mite.atlassian.net/browse/AAASM-5750)).
> **Denied calls are covered too.** The eleven governing adapters build a record on the denied path as well as the allowed one, marked so a reader can tell a blocked call from a tool that ran and returned the denial text ([AAASM-5787](https://lightning-dust-mite.atlassian.net/browse/AAASM-5787)); eight of them used to return or raise before their record helper. `langgraph` instruments nodes for lineage and has no tool-call gate, so it records neither. Enforcement is unaffected either way: a policy DENY still blocks the tool. `init_assembly()` warns when no record can be sent and reports `audit_sink` on the returned context ([AAASM-5750](https://lightning-dust-mite.atlassian.net/browse/AAASM-5750)).

## Why use it

Expand Down
186 changes: 186 additions & 0 deletions agent_assembly/adapters/_shared/audit_record.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""Hand a denied tool call's outcome to the adapter's audit hook.

Most adapters return or raise at their ``if status != "allow":`` branch, so
before AAASM-5787 a denied call built no record at all β€” the interceptor's sink
had nothing to forward, however it was configured. This module is what those
branches call.

It is a leaf: it imports no adapter, so any adapter can import it without the
cycle that ``_shared.tool_governance`` would create (that module imports
``crewai.patch``). ``_shared.positional_args`` is the same shape.

What a call here does and does not establish
--------------------------------------------
Handing the record to the hook is a *handoff*. Over a connected runtime the
SDK's own interceptor resolves ``record_result`` and writes to the native event
channel (AAASM-5750); without one, neither hook resolves and this emits nothing.
Neither case is ADR 0033 Β§6 *Observed* β€” that term asks for a durable event
attributed to the action, and AAASM-5783 is open on ``report_event`` payloads
reaching neither the live stream nor the durable entry. What the deny branch
gets from this module is that the record now exists to be forwarded, which is
upstream of the question AAASM-5783 asks.

Exceptions are suppressed here rather than at each call site. The hook is
duck-typed from caller-supplied code, and these calls are being inserted where
none used to exist: a raising handler would otherwise replace a decided deny
with its own exception, and a caller matching on ``PolicyViolationError`` would
stop recognising the deny. A decided deny is final regardless of audit outcome β€”
settled for the ``openai_agents`` path under AAASM-4782, followed rather than
re-answered here.
"""

from __future__ import annotations

import contextlib
import inspect
from typing import Any

MAX_AUDIT_RESULT_CHARS = 2000

_KEYWORD_PARAMETER_KINDS = (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
)


def truncate_result_for_audit(result: object) -> str:
return str(result)[:MAX_AUDIT_RESULT_CHARS]


def accepts_keyword(method: Any, name: str) -> bool:
"""Whether ``method`` can be called with the ``name`` keyword.

The audit hook is duck-typed β€” adapters and user code supply their own
``record_result`` / ``on_tool_end`` β€” and the implementations that predate
the denial flag were written without it, so passing it unconditionally
would raise ``TypeError`` on them. The flag is therefore offered only to
handlers that can receive it (an explicit parameter or a ``**kwargs``
catch-all); the rest still get the record, just without the flag.
"""
try:
signature = inspect.signature(method)
except (TypeError, ValueError):
# C-implemented callables expose no introspectable signature. Fall back
# to the narrow call so the record is still emitted.
return False
for parameter in signature.parameters.values():
if parameter.kind is inspect.Parameter.VAR_KEYWORD:
return True
if parameter.name == name and parameter.kind in _KEYWORD_PARAMETER_KINDS:
return True
return False


def optional_audit_kwargs(method: Any, **candidates: Any) -> dict[str, Any]:
"""Filter ``candidates`` to the keywords ``method`` can actually receive.

The allowed path in several adapters calls the audit hook with just
``tool_name`` and ``result``, so a caller-supplied handler written to that
contract β€” ``def record_result(self, *, tool_name, result)`` β€” is entitled
to reject the rest. Passing them unconditionally raises ``TypeError``, which
the deny paths' suppression then swallows: the record is lost silently, in
exactly the population the "the governing adapters record on deny" claim is
about.

Shared rather than repeated because the same call shape exists in three
places β€” here, ``_shared.tool_governance`` and ``openai_agents`` β€” and the
first fix reached only this one.

An **unreadable** signature is not the same as a refusal. A C-implemented
callable exposes nothing to introspect, and dropping every optional keyword
there would silently narrow a call that has always carried ``agent_id`` and
``run_id``. Those keep flowing; only ``denied``, which is newer than any
such handler, is withheld β€” the same reasoning
:func:`accepts_keyword` already documents for the flag.
"""
try:
inspect.signature(method)
except (TypeError, ValueError):
return {name: value for name, value in candidates.items() if name != "denied"}
return {name: value for name, value in candidates.items() if accepts_keyword(method, name)}


def _offer(
callback_handler: Any,
*,
tool_name: str,
result: object,
agent_id: str | None,
run_id: str | None,
) -> object | None:
"""Call the first hook the handler exposes; return whatever it returned.

The return value matters to the async entry point, which has to await a
coroutine hook. The sync entry point closes one instead β€” see there for why.
"""
payload = truncate_result_for_audit(result)

def optional(method: Any) -> dict[str, Any]:
return optional_audit_kwargs(method, agent_id=agent_id, run_id=run_id, denied=True)

record_method = getattr(callback_handler, "record_result", None)
if callable(record_method):
returned: object = record_method(tool_name=tool_name, result=payload, **optional(record_method))
return returned

tool_end_method = getattr(callback_handler, "on_tool_end", None)
if callable(tool_end_method):
ended: object = tool_end_method(output=payload, tool_name=tool_name, **optional(tool_end_method))
return ended

return None


def record_denied_tool_result(
callback_handler: Any,
*,
tool_name: str,
result: object,
agent_id: str | None = None,
run_id: str | None = None,
) -> None:
"""Offer a denied call's record from a synchronous deny branch."""
with contextlib.suppress(Exception):
returned = _offer(
callback_handler,
tool_name=tool_name,
result=result,
agent_id=agent_id,
run_id=run_id,
)
if inspect.iscoroutine(returned):
# A sync deny branch has no loop to await on. Close it rather than
# abandon it: an un-awaited coroutine emits a RuntimeWarning at
# collection time, from this module, in a run whose real problem is
# that the handler and the adapter disagree about sync vs async.
#
# Be clear about what this costs, because it is the one case where
# this module does NOT deliver: closing throws GeneratorExit at the
# first suspension point, so an `async def` hook's body never runs
# and the record is dropped. Nothing here can fix that β€” the
# adapter's deny branch is synchronous β€” so an async-only handler
# has to be paired with an adapter whose deny branch is async
# (mcp, microsoft_agent_framework, and the `a`-suffixed paths of
# agno and llamaindex).
returned.close()


async def arecord_denied_tool_result(
callback_handler: Any,
*,
tool_name: str,
result: object,
agent_id: str | None = None,
run_id: str | None = None,
) -> None:
"""Offer a denied call's record from an asynchronous deny branch."""
with contextlib.suppress(Exception):
returned = _offer(
callback_handler,
tool_name=tool_name,
result=result,
agent_id=agent_id,
run_id=run_id,
)
if inspect.isawaitable(returned):
await returned
68 changes: 25 additions & 43 deletions agent_assembly/adapters/_shared/tool_governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
if TYPE_CHECKING:
from agent_assembly.exceptions import PolicyViolationError

from agent_assembly.adapters._shared.audit_record import (
accepts_keyword,
optional_audit_kwargs,
truncate_result_for_audit,
)
from agent_assembly.adapters.crewai.patch import (
_get_pending_tool_approval_timeout_seconds as _resolve_pending_timeout_seconds,
)
Expand All @@ -42,13 +47,6 @@
)
from agent_assembly.core.spawn import _SPAWN_CTX, SpawnContext, spawn_context_scope

_MAX_AUDIT_RESULT_CHARS = 2000

_KEYWORD_PARAMETER_KINDS = (
inspect.Parameter.KEYWORD_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
)


def _current_spawn_depth() -> int:
current = _SPAWN_CTX.get()
Expand Down Expand Up @@ -133,33 +131,11 @@ def _get_pending_tool_approval_timeout_seconds(callback_handler: Any) -> int:
return _resolve_pending_timeout_seconds(callback_handler)


def _truncate_result_for_audit(result: object) -> str:
return str(result)[:_MAX_AUDIT_RESULT_CHARS]


def _accepts_keyword(method: Any, name: str) -> bool:
"""Whether ``method`` can be called with the ``name`` keyword.

The audit hook is duck-typed β€” adapters and user code supply their own
``record_result`` / ``on_tool_end`` β€” and every existing implementation was
written against the four-keyword call, so passing a new keyword
unconditionally would raise ``TypeError`` on all of them. The denial flag is
therefore offered only to handlers that can receive it (an explicit
parameter or a ``**kwargs`` catch-all); the rest still get the record, just
without the flag.
"""
try:
signature = inspect.signature(method)
except (TypeError, ValueError):
# C-implemented callables expose no introspectable signature. Fall back
# to the narrow call so the record is still emitted.
return False
for parameter in signature.parameters.values():
if parameter.kind is inspect.Parameter.VAR_KEYWORD:
return True
if parameter.name == name and parameter.kind in _KEYWORD_PARAMETER_KINDS:
return True
return False
# Both live in the leaf module the adapters' deny branches import (AAASM-5787),
# so the two copies that would otherwise exist stay one. Re-exported under their
# private names because this module's callers and tests already use those.
_truncate_result_for_audit = truncate_result_for_audit
_accepts_keyword = accepts_keyword


async def _record_async_tool_result(
Expand Down Expand Up @@ -189,23 +165,31 @@ async def _record_async_tool_result(

Note the scope of "allowed or denied" here: *this* shared flow calls the hook
on both paths, which is why ``google_adk`` and ``pydantic_ai`` cover denies.
Most adapters do not route through it and return or raise before their own
record helper, so their denied calls produce no record at all.
The adapters that do not route through it reach their deny branch directly and
call :func:`agent_assembly.adapters._shared.audit_record.record_denied_tool_result`
there (AAASM-5787).

Which of the two a run is in is declared in ``audit_sink`` (see
:mod:`agent_assembly.core.audit_sink`) and warned about by ``init_assembly``;
a caller that supplies its own handler gets the record either way.
"""
denial_flag = {"denied": denied} if denied else {}

# Every optional keyword is filtered to what the hook can receive, not just
# the denial flag. A caller-supplied handler written to the allowed-path
# contract β€” `(tool_name, result)` β€” otherwise raised TypeError here
# (AAASM-5787).
def optional(method: Any) -> dict[str, Any]:
candidates: dict[str, Any] = {"agent_id": agent_id, "run_id": run_id}
if denied:
candidates["denied"] = True
return optional_audit_kwargs(method, **candidates)

record_method = getattr(callback_handler, "record_result", None)
if callable(record_method):
recorded = record_method(
tool_name=tool_name,
result=_truncate_result_for_audit(result),
agent_id=agent_id,
run_id=run_id,
**(denial_flag if _accepts_keyword(record_method, "denied") else {}),
**optional(record_method),
)
if inspect.isawaitable(recorded):
await recorded
Expand All @@ -216,9 +200,7 @@ async def _record_async_tool_result(
recorded = tool_end_method(
output=_truncate_result_for_audit(result),
tool_name=tool_name,
agent_id=agent_id,
run_id=run_id,
**(denial_flag if _accepts_keyword(tool_end_method, "denied") else {}),
**optional(tool_end_method),
)
if inspect.isawaitable(recorded):
await recorded
Expand Down
10 changes: 10 additions & 0 deletions agent_assembly/adapters/agno/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
from functools import wraps
from typing import Any

from agent_assembly.adapters._shared.audit_record import (
arecord_denied_tool_result,
record_denied_tool_result,
)
from agent_assembly.adapters.crewai.patch import (
_format_approval_rejected_message as _format_approval_rejected,
)
Expand Down Expand Up @@ -200,6 +204,9 @@ def patched_execute(self: Any, *args: Any, **kwargs: Any) -> Any:
# through and running the tool, matching the LangChain handler.
if status != "allow":
message = _format_approval_rejected(reason) if is_pending_flow else _format_blocked(reason)
# AAASM-5787: the deny used to return straight past the record call
# below, so this adapter built nothing for the sink to forward.
record_denied_tool_result(callback_handler, tool_name=tool_name, result=message)
return _build_denied_result(message)

result = original_execute(self, *args, **kwargs)
Expand Down Expand Up @@ -227,6 +234,9 @@ async def patched_aexecute(self: Any, *args: Any, **kwargs: Any) -> Any:
# from falling through and running the tool, matching LangChain.
if status != "allow":
message = _format_approval_rejected(reason) if is_pending_flow else _format_blocked(reason)
# AAASM-5787: as in ``patched_execute``, this branch returned
# past the record call below and built nothing to forward.
await arecord_denied_tool_result(callback_handler, tool_name=tool_name, result=message)
return _build_denied_result(message)

result = await original_aexecute(self, *args, **kwargs)
Expand Down
14 changes: 11 additions & 3 deletions agent_assembly/adapters/crewai/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from threading import local
from typing import Any, Literal, cast

from agent_assembly.adapters._shared.audit_record import record_denied_tool_result
from agent_assembly.adapters._shared.positional_args import merge_positional_tool_args
from agent_assembly.core.spawn import _SPAWN_CTX, SpawnContext, spawn_context_scope

Expand Down Expand Up @@ -397,9 +398,16 @@ def patched_run(self: Any, *args: Any, **kwargs: Any) -> Any:
# non-decision, not a grant β€” blocking it here stops it from falling
# through and running the tool, matching the LangChain handler.
if status != "allow":
if is_pending_flow:
return _format_approval_rejected_message(reason)
return _format_blocked_message(reason)
message = _format_approval_rejected_message(reason) if is_pending_flow else _format_blocked_message(reason)
# AAASM-5787: the deny used to return straight past the record call
# below, so this adapter built nothing for the sink to forward.
record_denied_tool_result(
callback_handler,
tool_name=str(tool_name),
result=message,
agent_id=agent_id,
)
return message

result = original_run(self, *args, **kwargs)
_record_sync_tool_result(callback_handler, tool_name=str(tool_name), result=result)
Expand Down
Loading