Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 5 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
34 changes: 22 additions & 12 deletions agent_assembly/adapters/_shared/tool_governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -176,14 +178,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 {}

Expand Down Expand Up @@ -286,8 +296,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
Expand Down
4 changes: 3 additions & 1 deletion agent_assembly/adapters/haystack/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
"""

Expand Down
26 changes: 26 additions & 0 deletions agent_assembly/adapters/langchain/callback_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions agent_assembly/adapters/llamaindex/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
14 changes: 13 additions & 1 deletion agent_assembly/client/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
92 changes: 88 additions & 4 deletions agent_assembly/core/assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -116,6 +123,14 @@
# 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)

Expand Down Expand Up @@ -164,7 +179,7 @@
raise AssemblyError("; ".join(shutdown_errors))


def init_assembly(

Check failure on line 182 in agent_assembly/core/assembly.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ai-agent-assembly_python-sdk&issues=AZ_6EV4P3G0zSmH5JFmc&open=AZ_6EV4P3G0zSmH5JFmc&pullRequest=315
gateway_url: str | None = None,
api_key: str | None = None,
agent_id: str | None = None,
Expand Down Expand Up @@ -281,6 +296,7 @@
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
Expand All @@ -293,7 +309,7 @@
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,
Expand All @@ -306,12 +322,19 @@
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
Expand Down Expand Up @@ -373,6 +396,50 @@
)


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,
Expand Down Expand Up @@ -460,7 +527,7 @@
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
Expand All @@ -472,6 +539,11 @@
``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()
Expand All @@ -484,6 +556,11 @@
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)
Expand All @@ -502,8 +579,15 @@
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:
Expand Down
Loading