From 1d98b64696147c61c9d9414afbbb4dfdb8177363 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 6 Nov 2026 09:15:53 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=90=9B=20(langchain):=20Govern=20tool?= =?UTF-8?q?=20calls=20from=20init=5Fassembly=20via=20configure=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit init_assembly() stored an AssemblyCallbackHandler but never gave it to LangChain, so a denied tool still ran. Register a process-wide proxy handler through langchain_core's public register_configure_hook (default-valued ContextVar so plain threads are covered; handle_class dedupes an explicitly passed handler). raise_error stays True so any handler failure blocks the tool. Refs AAASM-6445 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TLpw696KjUDUg4QjH83En4 --- .../adapters/langchain/configure_hook.py | 80 +++++++++++++++++++ agent_assembly/adapters/langchain/patch.py | 2 + 2 files changed, 82 insertions(+) create mode 100644 agent_assembly/adapters/langchain/configure_hook.py diff --git a/agent_assembly/adapters/langchain/configure_hook.py b/agent_assembly/adapters/langchain/configure_hook.py new file mode 100644 index 00000000..6fbb2816 --- /dev/null +++ b/agent_assembly/adapters/langchain/configure_hook.py @@ -0,0 +1,80 @@ +"""Process-wide LangChain callback registration through a public extension point. + +``langchain_core`` lets a library contribute a callback handler to every run's +configuration with ``register_configure_hook(ContextVar, inheritable=True)``. This is +how LangChain's own tracers are attached, and it is documented API, not patching. + +Two properties of the registration matter for governance: + +* The ``ContextVar`` carries a **default** (a process-wide proxy handler) instead of + being ``set()`` by ``init_assembly``. A value ``set()`` in one context is invisible to + a plain ``threading.Thread`` (threads start with an empty context), so a tool run + there would silently skip the gate. A default is seen from every thread and task. +* ``handle_class=AssemblyCallbackHandler`` makes LangChain skip the proxy when the app + already passed an ``AssemblyCallbackHandler`` itself, so a tool is not checked twice. + +The hook cannot be unregistered, so the proxy resolves the active handler on every +call and does nothing once ``init_assembly``'s context is shut down. +""" + +from __future__ import annotations + +import importlib +from contextvars import ContextVar +from threading import Lock +from typing import Any + +from agent_assembly.adapters.langchain.callback_handler import AssemblyCallbackHandler +from agent_assembly.adapters.langchain.runtime import get_active_callback_handler + + +class ProcessGovernanceHandler(AssemblyCallbackHandler): + """Forwards LangChain tool callbacks to the currently active governance handler. + + ``raise_error`` stays True (inherited): LangChain swallows a callback's exception + otherwise, so any failure here, including a runtime/gateway error raised by the + interceptor, aborts the tool call instead of letting it run ungoverned. + """ + + def __init__(self) -> None: + super().__init__(interceptor=None) + + def on_tool_start(self, serialized: dict[str, Any], input_str: str, **kwargs: Any) -> None: + active = get_active_callback_handler() + if active is not None: + active.on_tool_start(serialized, input_str, **kwargs) + + def on_tool_end(self, output: Any, **kwargs: Any) -> None: + active = get_active_callback_handler() + if active is not None: + active.on_tool_end(output, **kwargs) + + +_REGISTER_LOCK = Lock() +_registered_var: ContextVar[Any] | None = None + + +def ensure_configure_hook_registered() -> bool: + """Register the process-wide governance handler with LangChain once. + + Returns ``True`` when the hook is in place and ``False`` when ``langchain_core`` + is not installed (nothing to govern). Safe to call on every ``init_assembly``. + """ + global _registered_var + + with _REGISTER_LOCK: + if _registered_var is not None: + return True + try: + context = importlib.import_module("langchain_core.tracers.context") + except ImportError: + return False + # B039: the default is deliberately a shared mutable handler; a per-context + # ``set()`` would not reach plain threads (see module docstring). + var: ContextVar[Any] = ContextVar( + "agent_assembly_langchain_governance", + default=ProcessGovernanceHandler(), # noqa: B039 + ) + context.register_configure_hook(var, True, AssemblyCallbackHandler) + _registered_var = var + return True diff --git a/agent_assembly/adapters/langchain/patch.py b/agent_assembly/adapters/langchain/patch.py index 3111baea..d16f6668 100644 --- a/agent_assembly/adapters/langchain/patch.py +++ b/agent_assembly/adapters/langchain/patch.py @@ -6,6 +6,7 @@ from typing import Any from agent_assembly.adapters.langchain.callback_handler import AssemblyCallbackHandler +from agent_assembly.adapters.langchain.configure_hook import ensure_configure_hook_registered from agent_assembly.adapters.langchain.runtime import ( _reset_runtime_state_for_tests, auto_inject_callback_handler, @@ -25,6 +26,7 @@ def apply(self) -> bool: self.interceptor, process_agent_id=self.process_agent_id, ) + ensure_configure_hook_registered() return True def revert(self) -> None: From e03e6e5f35d592761716a8ddaf3fc6ae52cc5843 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 6 Nov 2026 09:16:36 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=85=20(langchain):=20Cover=20init-onl?= =?UTF-8?q?y=20governance,=20fail-closed=20and=20thread=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs AAASM-6445 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TLpw696KjUDUg4QjH83En4 --- ...st_quickstart_langchain_governs_on_init.py | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 test/unit/test_quickstart_langchain_governs_on_init.py diff --git a/test/unit/test_quickstart_langchain_governs_on_init.py b/test/unit/test_quickstart_langchain_governs_on_init.py new file mode 100644 index 00000000..605f2c57 --- /dev/null +++ b/test/unit/test_quickstart_langchain_governs_on_init.py @@ -0,0 +1,203 @@ +"""``init_assembly()`` alone governs LangChain tool calls (AAASM-6445). + +The README Quick Start promises that one ``init_assembly()`` call sends each +LangChain tool call through the pre-execution policy gate. Until AAASM-6445 the +adapter only stored an ``AssemblyCallbackHandler`` singleton and never handed it to +LangChain, so a denied tool still ran unless the app passed the handler itself. + +The adapter now registers a process-wide handler through LangChain's public +``register_configure_hook`` extension point. These tests drive the real ``init_assembly`` +(fake native runtime as the decision source) and bare ``langchain_core`` tools through +LangChain's own callback manager; the tool body writes a file, so a deny is asserted +as the absence of that file and each deny test has an allow positive control. + +Needs the real ``langchain_core`` (``uv sync --group langchain-test``); skipped otherwise. +""" + +from __future__ import annotations + +import asyncio +import sys +import threading +import types +from collections.abc import Callable, Iterator +from pathlib import Path +from typing import Any + +import pytest + +pytest.importorskip("langchain_core") +from langchain_core.tools import StructuredTool # noqa: E402 + +from agent_assembly import init_assembly # noqa: E402 +from agent_assembly.adapters.langchain import LangChainAdapter # noqa: E402 +from agent_assembly.core import assembly as core_assembly # noqa: E402 +from agent_assembly.exceptions import ToolExecutionBlockedError # noqa: E402 + +from .core._fake_core import FakeRuntimeClient, install_fake_core # noqa: E402 +from .negative_control import FileSideEffect # noqa: E402 + +_DENY_REASON = "policy forbids disk writes" + + +@pytest.fixture(autouse=True) +def _cleanup_active_context() -> None: + active = core_assembly._ACTIVE_CONTEXT + if active is not None and not active.is_shutdown: + active.shutdown() + core_assembly._ACTIVE_CONTEXT = None + + +@pytest.fixture(autouse=True) +def _langchain_importable(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "langchain", types.ModuleType("langchain")) + + +@pytest.fixture +def file_effect(tmp_path: Path) -> FileSideEffect: + return FileSideEffect(path=tmp_path / "langchain-tool-write.txt") + + +@pytest.fixture +def init(monkeypatch: pytest.MonkeyPatch) -> Iterator[Callable[..., tuple[Any, FakeRuntimeClient]]]: + contexts: list[Any] = [] + + def _init(*, decision: str = "allow", enforcement_mode: str = "enforce") -> tuple[Any, FakeRuntimeClient]: + runtime = FakeRuntimeClient(decision=decision, reason=_DENY_REASON) + install_fake_core(monkeypatch, runtime) + monkeypatch.setattr( + core_assembly, + "_start_network_layer", + lambda **_kwargs: ("sdk-only", core_assembly._noop_shutdown), + ) + context = init_assembly( + gateway_url="https://gateway.test", + api_key="test-key", + agent_id="quickstart-langchain-govern-agent", + mode="sdk-only", + enforcement_mode=enforcement_mode, # type: ignore[arg-type] + ) + contexts.append(context) + return context, runtime + + yield _init + for context in contexts: + context.shutdown() + + +def _write_tool(effect: FileSideEffect) -> StructuredTool: + def write_to_disk(content: str) -> str: + """Write ``content`` to disk.""" + return effect.write(content) + + return StructuredTool.from_function(write_to_disk, name="write_to_disk", description="Write to disk.") + + +def test_allowed_tool_executes_with_no_explicit_callbacks(init: Any, file_effect: FileSideEffect) -> None: + _, runtime = init(decision="allow") + + result = _write_tool(file_effect).invoke({"content": "allowed"}) + + assert result == str(file_effect.path) + assert file_effect.content() == "allowed" + assert [call[2] for call in runtime.query_calls] == ["write_to_disk"] + + +def test_denied_tool_never_executes_with_no_explicit_callbacks(init: Any, file_effect: FileSideEffect) -> None: + _, runtime = init(decision="deny") + + with pytest.raises(ToolExecutionBlockedError, match=_DENY_REASON): + _write_tool(file_effect).invoke({"content": "denied"}) + + assert file_effect.occurred() is False + assert [call[2] for call in runtime.query_calls] == ["write_to_disk"] + + +def test_denied_tool_never_executes_in_a_plain_thread(init: Any, file_effect: FileSideEffect) -> None: + """A plain ``threading.Thread`` does not inherit contextvars: the gate must still hold.""" + init(decision="deny") + errors: list[BaseException] = [] + + def _run() -> None: + try: + _write_tool(file_effect).invoke({"content": "denied"}) + except BaseException as exc: # noqa: BLE001 - captured for the assertion below + errors.append(exc) + + thread = threading.Thread(target=_run) + thread.start() + thread.join() + + assert [type(e) for e in errors] == [ToolExecutionBlockedError] + assert file_effect.occurred() is False + + +def test_denied_tool_never_executes_on_the_async_path(init: Any, file_effect: FileSideEffect) -> None: + init(decision="deny") + + with pytest.raises(ToolExecutionBlockedError): + asyncio.run(_write_tool(file_effect).ainvoke({"content": "denied"})) + + assert file_effect.occurred() is False + + +def test_explicit_handler_is_not_checked_twice(init: Any, file_effect: FileSideEffect) -> None: + context, runtime = init(decision="allow") + adapter = next(a for a in context.adapters if isinstance(a, LangChainAdapter)) + + _write_tool(file_effect).invoke({"content": "x"}, config={"callbacks": [adapter.get_callback_handler()]}) + + assert len(runtime.query_calls) == 1 + + +def test_runtime_query_failure_under_enforce_blocks_the_tool( + init: Any, file_effect: FileSideEffect, monkeypatch: pytest.MonkeyPatch +) -> None: + _, runtime = init(decision="allow") + + def _unreachable(*_args: Any, **_kwargs: Any) -> dict[str, str]: + raise ConnectionError("runtime unreachable") + + monkeypatch.setattr(runtime, "query_policy", _unreachable) + + with pytest.raises(Exception): # noqa: B017, PT011 - any raise is a block; the tool must not run + _write_tool(file_effect).invoke({"content": "must not run"}) + + assert file_effect.occurred() is False + + +def test_unexpected_handler_exception_blocks_the_tool( + init: Any, file_effect: FileSideEffect, monkeypatch: pytest.MonkeyPatch +) -> None: + context, _ = init(decision="allow") + adapter = next(a for a in context.adapters if isinstance(a, LangChainAdapter)) + handler = adapter.get_callback_handler() + + def _boom(**_kwargs: Any) -> None: + raise RuntimeError("handler bug") + + monkeypatch.setattr(handler._interceptor, "check_tool_start", _boom) + + with pytest.raises(RuntimeError, match="handler bug"): + _write_tool(file_effect).invoke({"content": "must not run"}) + + assert file_effect.occurred() is False + + +def test_observe_mode_still_checks_but_the_runtime_lets_the_tool_run(init: Any, file_effect: FileSideEffect) -> None: + """Under ``observe`` the runtime answers allow (dry-run); the SDK adds no extra blocking.""" + _, runtime = init(decision="allow", enforcement_mode="observe") + + _write_tool(file_effect).invoke({"content": "observed"}) + + assert file_effect.content() == "observed" + assert [call[2] for call in runtime.query_calls] == ["write_to_disk"] + + +def test_shutdown_stops_governing_tools(init: Any, file_effect: FileSideEffect) -> None: + context, _ = init(decision="deny") + context.shutdown() + + _write_tool(file_effect).invoke({"content": "after shutdown"}) + + assert file_effect.content() == "after shutdown" From 55a1a128a13455c1db0e3c5d5f3f199ae86f689e Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 6 Nov 2026 09:22:40 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=93=9D=20(python-sdk):=20Describe=20h?= =?UTF-8?q?ow=20init=5Fassembly()=20governs=20LangChain=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs AAASM-6445 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TLpw696KjUDUg4QjH83En4 --- README.md | 2 +- docs/index.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e0328fa6..93c2c702 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ with init_assembly( What this does: -1. `init_assembly()` registers the agent with the gateway and auto-loads the LangChain adapter — each tool call the wrapped agent makes from now on goes through the policy gate. +1. `init_assembly()` registers the agent with the gateway and auto-loads the LangChain adapter. The adapter registers one process-wide callback handler through `langchain_core`'s public `register_configure_hook`, so each tool call any LangChain run makes from now on — in any thread or async task — goes through the policy gate. Under `enforce` a deny raises `ToolExecutionBlockedError` before the tool body runs, and a runtime or handler failure blocks the tool too. You do not pass `callbacks` yourself; a handler you do pass is not checked twice. The gate needs `langchain-core` importable, and it stops once the `with` block (or `shutdown()`) ends. 2. The `FakeListLLM` replays canned responses so the example runs **offline** with no real LLM. 3. The `with` block tears down the gateway connection and unwinds adapter hooks on exit. diff --git a/docs/index.md b/docs/index.md index 2e667b5f..6151ebfa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,8 +24,9 @@ You keep writing agents the way you always have. The SDK is the seatbelt you cli The SDK does not replace your agent framework — it **intercepts** it. When you call `init_assembly()`, the SDK detects which supported framework is importable in your process -and monkey-patches that framework's tool-call entry points so each call passes through a -policy gate first. Today it ships adapters for **LangChain, LangGraph, CrewAI, OpenAI Agents, +and hooks that framework's tool-call entry points so each call passes through a +policy gate first (LangChain through its public `register_configure_hook` callback extension point, +the other adapters by patching the framework). Today it ships adapters for **LangChain, LangGraph, CrewAI, OpenAI Agents, Pydantic AI, Google ADK, and MCP servers**. ```mermaid From 935299cd12cda2620a959758b3d4c17c47117697 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 6 Nov 2026 09:31:01 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=90=9B=20(langchain):=20Skip=20the=20?= =?UTF-8?q?proxy=20only=20for=20a=20run=20the=20active=20interceptor=20alr?= =?UTF-8?q?eady=20allowed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle_class made LangChain drop the process-wide proxy whenever any AssemblyCallbackHandler was in callbacks, so a handler built around a weaker interceptor silently suppressed the active governance. Track allowed runs by interceptor instead. Refs AAASM-6445 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TLpw696KjUDUg4QjH83En4 --- .../adapters/langchain/callback_handler.py | 22 +++++++++++++++++++ .../adapters/langchain/configure_hook.py | 19 +++++++++++----- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/agent_assembly/adapters/langchain/callback_handler.py b/agent_assembly/adapters/langchain/callback_handler.py index 27b34b8e..1a0e47aa 100644 --- a/agent_assembly/adapters/langchain/callback_handler.py +++ b/agent_assembly/adapters/langchain/callback_handler.py @@ -3,7 +3,9 @@ from __future__ import annotations import importlib +from collections import OrderedDict from collections.abc import Mapping +from threading import Lock from typing import Any, Literal, NoReturn, cast from uuid import UUID @@ -18,6 +20,22 @@ _KNOWN_STATUSES: frozenset[str] = frozenset({"allow", "deny", "pending"}) +# run_id -> interceptor that already allowed that tool run, so the process-wide +# configure-hook proxy (configure_hook.py) can skip a run an explicitly passed handler +# already checked *with the active interceptor*, instead of checking it twice. Keyed by +# interceptor so a handler built around a weaker or different one never suppresses the +# active governance. Bounded: entries are consumed by the proxy and the oldest are +# dropped if no proxy ever runs. +_CHECKED_RUNS: OrderedDict[UUID, object] = OrderedDict() +_CHECKED_RUNS_LOCK = Lock() +_CHECKED_RUNS_MAX = 4096 + + +def consume_checked_run(run_id: UUID) -> object | None: + """Pop and return the interceptor that already allowed ``run_id``, if any.""" + with _CHECKED_RUNS_LOCK: + return _CHECKED_RUNS.pop(run_id, None) + class _FallbackBaseCallbackHandler: """Fallback base type when langchain-core is not installed.""" @@ -202,6 +220,10 @@ def on_tool_start( approval_reason or reason or "Tool execution was not approved by governance.", ) + with _CHECKED_RUNS_LOCK: + _CHECKED_RUNS[run_id] = self._interceptor + while len(_CHECKED_RUNS) > _CHECKED_RUNS_MAX: + _CHECKED_RUNS.popitem(last=False) return None @staticmethod diff --git a/agent_assembly/adapters/langchain/configure_hook.py b/agent_assembly/adapters/langchain/configure_hook.py index 6fbb2816..4b085778 100644 --- a/agent_assembly/adapters/langchain/configure_hook.py +++ b/agent_assembly/adapters/langchain/configure_hook.py @@ -10,8 +10,11 @@ being ``set()`` by ``init_assembly``. A value ``set()`` in one context is invisible to a plain ``threading.Thread`` (threads start with an empty context), so a tool run there would silently skip the gate. A default is seen from every thread and task. -* ``handle_class=AssemblyCallbackHandler`` makes LangChain skip the proxy when the app - already passed an ``AssemblyCallbackHandler`` itself, so a tool is not checked twice. +* The proxy skips a run only when an explicitly passed ``AssemblyCallbackHandler`` already + allowed it with the *active* interceptor, so a tool is not checked twice. A handler built + around a weaker or different interceptor never suppresses the active governance + (registering with ``handle_class`` would have: LangChain then skips the proxy for any + ``AssemblyCallbackHandler`` instance). The hook cannot be unregistered, so the proxy resolves the active handler on every call and does nothing once ``init_assembly``'s context is shut down. @@ -24,7 +27,7 @@ from threading import Lock from typing import Any -from agent_assembly.adapters.langchain.callback_handler import AssemblyCallbackHandler +from agent_assembly.adapters.langchain.callback_handler import AssemblyCallbackHandler, consume_checked_run from agent_assembly.adapters.langchain.runtime import get_active_callback_handler @@ -41,8 +44,12 @@ def __init__(self) -> None: def on_tool_start(self, serialized: dict[str, Any], input_str: str, **kwargs: Any) -> None: active = get_active_callback_handler() - if active is not None: - active.on_tool_start(serialized, input_str, **kwargs) + if active is None: + return + run_id = kwargs.get("run_id") + if run_id is not None and consume_checked_run(run_id) is active._interceptor: + return + active.on_tool_start(serialized, input_str, **kwargs) def on_tool_end(self, output: Any, **kwargs: Any) -> None: active = get_active_callback_handler() @@ -75,6 +82,6 @@ def ensure_configure_hook_registered() -> bool: "agent_assembly_langchain_governance", default=ProcessGovernanceHandler(), # noqa: B039 ) - context.register_configure_hook(var, True, AssemblyCallbackHandler) + context.register_configure_hook(var, True) _registered_var = var return True From 19b5d0ef6629bdd7f5dc847389c056c962497d4e Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 6 Nov 2026 09:31:03 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E2=9C=85=20(langchain):=20Cover=20weaker?= =?UTF-8?q?=20handler,=20deny=20audit=20record=20and=20ToolNode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also narrow the runtime-failure assertion to ToolExecutionBlockedError. Refs AAASM-6445 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TLpw696KjUDUg4QjH83En4 --- ...st_quickstart_langchain_governs_on_init.py | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/test/unit/test_quickstart_langchain_governs_on_init.py b/test/unit/test_quickstart_langchain_governs_on_init.py index 605f2c57..e9ca5172 100644 --- a/test/unit/test_quickstart_langchain_governs_on_init.py +++ b/test/unit/test_quickstart_langchain_governs_on_init.py @@ -30,7 +30,7 @@ from langchain_core.tools import StructuredTool # noqa: E402 from agent_assembly import init_assembly # noqa: E402 -from agent_assembly.adapters.langchain import LangChainAdapter # noqa: E402 +from agent_assembly.adapters.langchain import AssemblyCallbackHandler, LangChainAdapter # noqa: E402 from agent_assembly.core import assembly as core_assembly # noqa: E402 from agent_assembly.exceptions import ToolExecutionBlockedError # noqa: E402 @@ -150,6 +150,66 @@ def test_explicit_handler_is_not_checked_twice(init: Any, file_effect: FileSideE assert len(runtime.query_calls) == 1 +class _AllowAllInterceptor: + """A weaker interceptor an app might build its own handler around.""" + + def check_tool_start(self, **_kwargs: object) -> dict[str, str]: + return {"status": "allow"} + + +def test_explicit_handler_with_a_weaker_interceptor_does_not_suppress_governance( + init: Any, file_effect: FileSideEffect +) -> None: + init(decision="deny") + weak = AssemblyCallbackHandler(_AllowAllInterceptor()) + + with pytest.raises(ToolExecutionBlockedError, match=_DENY_REASON): + _write_tool(file_effect).invoke({"content": "x"}, config={"callbacks": [weak]}) + + assert file_effect.occurred() is False + + +def test_denied_call_offers_its_record_to_the_audit_hook( + init: Any, file_effect: FileSideEffect, monkeypatch: pytest.MonkeyPatch +) -> None: + context, _ = init(decision="deny") + adapter = next(a for a in context.adapters if isinstance(a, LangChainAdapter)) + interceptor = adapter.get_callback_handler()._interceptor + records: list[dict[str, Any]] = [] + + def _record(*, tool_name: str, result: object, **extra: Any) -> None: + records.append({"tool_name": tool_name, "result": result, **extra}) + + monkeypatch.setattr(interceptor, "record_result", _record, raising=False) + + with pytest.raises(ToolExecutionBlockedError): + _write_tool(file_effect).invoke({"content": "x"}) + + assert len(records) == 1 + assert records[0]["tool_name"] == "write_to_disk" + assert records[0]["result"] == _DENY_REASON + assert records[0].get("denied") is True + assert isinstance(records[0].get("run_id"), str) and records[0]["run_id"] + + +def test_langgraph_tool_node_is_governed(init: Any, file_effect: FileSideEffect) -> None: + pytest.importorskip("langgraph.prebuilt") + from langchain_core.messages import AIMessage + from langgraph.graph import START, MessagesState, StateGraph + from langgraph.prebuilt import ToolNode + + init(decision="deny") + call = AIMessage(content="", tool_calls=[{"name": "write_to_disk", "args": {"content": "x"}, "id": "c1"}]) + + with pytest.raises(ToolExecutionBlockedError): + graph = StateGraph(MessagesState) + graph.add_node("tools", ToolNode([_write_tool(file_effect)])) + graph.add_edge(START, "tools") + graph.compile().invoke({"messages": [call]}) + + assert file_effect.occurred() is False + + def test_runtime_query_failure_under_enforce_blocks_the_tool( init: Any, file_effect: FileSideEffect, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -160,7 +220,7 @@ def _unreachable(*_args: Any, **_kwargs: Any) -> dict[str, str]: monkeypatch.setattr(runtime, "query_policy", _unreachable) - with pytest.raises(Exception): # noqa: B017, PT011 - any raise is a block; the tool must not run + with pytest.raises(ToolExecutionBlockedError, match="runtime query failed"): _write_tool(file_effect).invoke({"content": "must not run"}) assert file_effect.occurred() is False From cc172c69d6c0c08370217a2cf80dc7b9acc40278 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 6 Nov 2026 09:31:05 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=93=9D=20(python-sdk):=20State=20the?= =?UTF-8?q?=20bounds=20of=20the=20LangChain=20governance=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs AAASM-6445 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TLpw696KjUDUg4QjH83En4 --- README.md | 10 +++++++++- docs/index.md | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 93c2c702..e2cbb250 100644 --- a/README.md +++ b/README.md @@ -177,10 +177,18 @@ with init_assembly( What this does: -1. `init_assembly()` registers the agent with the gateway and auto-loads the LangChain adapter. The adapter registers one process-wide callback handler through `langchain_core`'s public `register_configure_hook`, so each tool call any LangChain run makes from now on — in any thread or async task — goes through the policy gate. Under `enforce` a deny raises `ToolExecutionBlockedError` before the tool body runs, and a runtime or handler failure blocks the tool too. You do not pass `callbacks` yourself; a handler you do pass is not checked twice. The gate needs `langchain-core` importable, and it stops once the `with` block (or `shutdown()`) ends. +1. `init_assembly()` registers the agent with the gateway and auto-loads the LangChain adapter. The adapter registers one process-wide callback handler through `langchain_core`'s public `register_configure_hook`, so each tool call a LangChain run makes from now on — in any thread or async task — goes through the policy gate. Under `enforce` a deny raises `ToolExecutionBlockedError` before the tool body runs, and a runtime or handler failure blocks the tool too. You do not pass `callbacks` yourself. 2. The `FakeListLLM` replays canned responses so the example runs **offline** with no real LLM. 3. The `with` block tears down the gateway connection and unwinds adapter hooks on exit. +> **Bounds of the LangChain gate.** It is a LangChain *callback*, so it is exactly as strong as that path: +> +> - A tool body called directly (`tool.func(...)`, `tool._run(...)`) never goes through LangChain's callback manager and is **not** checked. +> - It covers `langchain_core` runs only. Other frameworks are governed by their own adapters, not by this hook. +> - It governs tool calls only. LLM-side hooks (`on_llm_start` / `on_llm_end`, prompt scanning) are **not** enabled by `init_assembly()`; they run only for an `AssemblyCallbackHandler` you pass in `callbacks` yourself. +> - It stops governing after `shutdown()` / the end of the `with` block, and it is **never installed** if the LangChain adapter fails to register or `langchain-core` is not importable. A failed adapter registration only writes a `framework adapter 'langchain' failed to register governance hooks` warning to stderr and `init_assembly()` still returns, even under `enforce`; the tool then runs ungoverned. `ctx.registered` reports the *gateway* registration, not this. Check that the warning is absent and that `any(a.get_framework_name() == "langchain" for a in ctx.adapters)` is true. +> - A handler you pass yourself is not checked twice when it wraps the active interceptor. A handler built around a different interceptor is checked in addition, never instead. + ## Public API - `init_assembly(gateway_url, api_key, agent_id=None, mode="auto", *, control_plane_url=None, allow_insecure=False) -> AssemblyContext` diff --git a/docs/index.md b/docs/index.md index 6151ebfa..0c0e05d5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,7 +26,7 @@ The SDK does not replace your agent framework — it **intercepts** it. When you `init_assembly()`, the SDK detects which supported framework is importable in your process and hooks that framework's tool-call entry points so each call passes through a policy gate first (LangChain through its public `register_configure_hook` callback extension point, -the other adapters by patching the framework). Today it ships adapters for **LangChain, LangGraph, CrewAI, OpenAI Agents, +the other adapters by patching the framework). The LangChain gate is a callback, so a tool body called directly (`tool.func(...)`) is not checked, LLM-side hooks are not enabled by `init_assembly()`, and if the adapter fails to register (a stderr warning, init still succeeds) LangChain runs ungoverned — see the README Quick Start for the full list of bounds. Today it ships adapters for **LangChain, LangGraph, CrewAI, OpenAI Agents, Pydantic AI, Google ADK, and MCP servers**. ```mermaid From a280b6492fa77709f1871d0bb9fb633b26dd574f Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 6 Nov 2026 09:34:50 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=90=9B=20(langchain):=20Skip=20the=20?= =?UTF-8?q?proxy's=20on=5Ftool=5Fend=20for=20runs=20it=20skipped=20at=20st?= =?UTF-8?q?art?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without it an explicit handler on the active interceptor ended each run twice (duplicate audit/budget side effects). Mark skipped runs, clear on end/error, and use a unique miss sentinel so a registry miss never equals an interceptor. Refs AAASM-6445 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TLpw696KjUDUg4QjH83En4 --- .../adapters/langchain/callback_handler.py | 34 +++++++++++++++---- .../adapters/langchain/configure_hook.py | 17 +++++++++- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/agent_assembly/adapters/langchain/callback_handler.py b/agent_assembly/adapters/langchain/callback_handler.py index 1a0e47aa..05a3632e 100644 --- a/agent_assembly/adapters/langchain/callback_handler.py +++ b/agent_assembly/adapters/langchain/callback_handler.py @@ -27,14 +27,38 @@ # active governance. Bounded: entries are consumed by the proxy and the oldest are # dropped if no proxy ever runs. _CHECKED_RUNS: OrderedDict[UUID, object] = OrderedDict() +# Runs the proxy skipped at start; it must skip their on_tool_end too, or the wrapped +# interceptor would see two ends (duplicate audit / budget side effects) for one run. +_END_SKIPPED_RUNS: OrderedDict[UUID, None] = OrderedDict() _CHECKED_RUNS_LOCK = Lock() _CHECKED_RUNS_MAX = 4096 +# Returned on a registry miss. A unique object, so a miss can never compare equal to an +# interceptor (``None`` would, for a handler built around ``interceptor=None``). +CHECKED_RUN_MISS: object = object() -def consume_checked_run(run_id: UUID) -> object | None: - """Pop and return the interceptor that already allowed ``run_id``, if any.""" +def _bounded_put(registry: OrderedDict[UUID, Any], run_id: UUID, value: Any) -> None: + registry[run_id] = value + while len(registry) > _CHECKED_RUNS_MAX: + registry.popitem(last=False) + + +def consume_checked_run(run_id: UUID) -> object: + """Pop the interceptor that already allowed ``run_id``, or ``CHECKED_RUN_MISS``.""" + with _CHECKED_RUNS_LOCK: + return _CHECKED_RUNS.pop(run_id, CHECKED_RUN_MISS) + + +def mark_end_skipped(run_id: UUID) -> None: + """Record that the proxy skipped ``run_id``'s start, so it skips the matching end.""" + with _CHECKED_RUNS_LOCK: + _bounded_put(_END_SKIPPED_RUNS, run_id, None) + + +def consume_end_skipped(run_id: UUID) -> bool: + """Whether ``run_id``'s start was skipped; clears the mark.""" with _CHECKED_RUNS_LOCK: - return _CHECKED_RUNS.pop(run_id, None) + return _END_SKIPPED_RUNS.pop(run_id, CHECKED_RUN_MISS) is not CHECKED_RUN_MISS class _FallbackBaseCallbackHandler: @@ -221,9 +245,7 @@ def on_tool_start( ) with _CHECKED_RUNS_LOCK: - _CHECKED_RUNS[run_id] = self._interceptor - while len(_CHECKED_RUNS) > _CHECKED_RUNS_MAX: - _CHECKED_RUNS.popitem(last=False) + _bounded_put(_CHECKED_RUNS, run_id, self._interceptor) return None @staticmethod diff --git a/agent_assembly/adapters/langchain/configure_hook.py b/agent_assembly/adapters/langchain/configure_hook.py index 4b085778..0c408434 100644 --- a/agent_assembly/adapters/langchain/configure_hook.py +++ b/agent_assembly/adapters/langchain/configure_hook.py @@ -27,7 +27,12 @@ from threading import Lock from typing import Any -from agent_assembly.adapters.langchain.callback_handler import AssemblyCallbackHandler, consume_checked_run +from agent_assembly.adapters.langchain.callback_handler import ( + AssemblyCallbackHandler, + consume_checked_run, + consume_end_skipped, + mark_end_skipped, +) from agent_assembly.adapters.langchain.runtime import get_active_callback_handler @@ -48,14 +53,24 @@ def on_tool_start(self, serialized: dict[str, Any], input_str: str, **kwargs: An return run_id = kwargs.get("run_id") if run_id is not None and consume_checked_run(run_id) is active._interceptor: + mark_end_skipped(run_id) return active.on_tool_start(serialized, input_str, **kwargs) def on_tool_end(self, output: Any, **kwargs: Any) -> None: + run_id = kwargs.get("run_id") + if run_id is not None and consume_end_skipped(run_id): + return active = get_active_callback_handler() if active is not None: active.on_tool_end(output, **kwargs) + def on_tool_error(self, _error: BaseException, **kwargs: Any) -> None: + # Only clears the per-run mark so a failed tool does not leak it. + run_id = kwargs.get("run_id") + if run_id is not None: + consume_end_skipped(run_id) + _REGISTER_LOCK = Lock() _registered_var: ContextVar[Any] | None = None From 87e0f831a4c49b9cb5b8809494ff61cd91cf950d Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 6 Nov 2026 09:34:52 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E2=9C=85=20(langchain):=20Pin=20single=20e?= =?UTF-8?q?nd,=20swallowing=20subclass,=20bound=20and=20sentinel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs AAASM-6445 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TLpw696KjUDUg4QjH83En4 --- ...st_quickstart_langchain_governs_on_init.py | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/test/unit/test_quickstart_langchain_governs_on_init.py b/test/unit/test_quickstart_langchain_governs_on_init.py index e9ca5172..0203de25 100644 --- a/test/unit/test_quickstart_langchain_governs_on_init.py +++ b/test/unit/test_quickstart_langchain_governs_on_init.py @@ -261,3 +261,114 @@ def test_shutdown_stops_governing_tools(init: Any, file_effect: FileSideEffect) _write_tool(file_effect).invoke({"content": "after shutdown"}) assert file_effect.content() == "after shutdown" + + +def _count_interceptor_calls(interceptor: Any, monkeypatch: pytest.MonkeyPatch) -> dict[str, int]: + """Wrap the active interceptor's start check and end hook with call counters.""" + counts = {"starts": 0, "ends": 0} + real_start = interceptor.check_tool_start + real_end = getattr(interceptor, "on_tool_end", None) + + def _start(**kwargs: Any) -> Any: + counts["starts"] += 1 + return real_start(**kwargs) + + def _end(**kwargs: Any) -> Any: + counts["ends"] += 1 + return real_end(**kwargs) if callable(real_end) else None + + monkeypatch.setattr(interceptor, "check_tool_start", _start) + monkeypatch.setattr(interceptor, "on_tool_end", _end, raising=False) + return counts + + +def test_explicit_handler_on_the_active_interceptor_is_checked_and_ended_once( + init: Any, file_effect: FileSideEffect, monkeypatch: pytest.MonkeyPatch +) -> None: + context, _ = init(decision="allow") + adapter = next(a for a in context.adapters if isinstance(a, LangChainAdapter)) + handler = adapter.get_callback_handler() + counts = _count_interceptor_calls(handler._interceptor, monkeypatch) + + _write_tool(file_effect).invoke({"content": "x"}, config={"callbacks": [handler]}) + + assert counts == {"starts": 1, "ends": 1} + + +def test_proxy_only_path_is_checked_and_ended_once( + init: Any, file_effect: FileSideEffect, monkeypatch: pytest.MonkeyPatch +) -> None: + context, _ = init(decision="allow") + adapter = next(a for a in context.adapters if isinstance(a, LangChainAdapter)) + counts = _count_interceptor_calls(adapter.get_callback_handler()._interceptor, monkeypatch) + + _write_tool(file_effect).invoke({"content": "x"}) + + assert counts == {"starts": 1, "ends": 1} + + +def test_handler_subclass_that_swallows_its_own_deny_is_rechecked_by_the_proxy( + init: Any, file_effect: FileSideEffect +) -> None: + """A deny must not be recorded as "checked": a raise_error=False subclass swallows the block.""" + + class _Swallowing(AssemblyCallbackHandler): + raise_error = False + + context, _ = init(decision="deny") + adapter = next(a for a in context.adapters if isinstance(a, LangChainAdapter)) + swallowing = _Swallowing(adapter.get_callback_handler()._interceptor) + + with pytest.raises(ToolExecutionBlockedError, match=_DENY_REASON): + _write_tool(file_effect).invoke({"content": "x"}, config={"callbacks": [swallowing]}) + + assert file_effect.occurred() is False + + +def test_checked_run_registry_is_bounded_and_an_evicted_run_is_still_checked( + init: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + from uuid import uuid4 + + from agent_assembly.adapters.langchain import callback_handler as ch + from agent_assembly.adapters.langchain.configure_hook import ProcessGovernanceHandler + + context, _ = init(decision="allow") + adapter = next(a for a in context.adapters if isinstance(a, LangChainAdapter)) + handler = adapter.get_callback_handler() + counts = _count_interceptor_calls(handler._interceptor, monkeypatch) + ch._CHECKED_RUNS.clear() + monkeypatch.setattr(ch, "_CHECKED_RUNS_MAX", 3) + run_ids = [uuid4() for _ in range(5)] + + for run_id in run_ids: + handler.on_tool_start({"name": "t"}, "", run_id=run_id) + + assert len(ch._CHECKED_RUNS) <= 3 + assert counts["starts"] == 5 + ProcessGovernanceHandler().on_tool_start({"name": "t"}, "", run_id=run_ids[0]) # evicted: re-checked + assert counts["starts"] == 6 + ProcessGovernanceHandler().on_tool_start({"name": "t"}, "", run_id=run_ids[-1]) # still held: skipped + assert counts["starts"] == 6 + + +def test_registry_miss_never_equals_an_interceptor_even_none(init: Any, monkeypatch: pytest.MonkeyPatch) -> None: + from uuid import uuid4 + + from agent_assembly.adapters.langchain import runtime as lc_runtime + from agent_assembly.adapters.langchain.callback_handler import CHECKED_RUN_MISS, consume_checked_run + from agent_assembly.adapters.langchain.configure_hook import ProcessGovernanceHandler + + init(decision="allow") + assert consume_checked_run(uuid4()) is CHECKED_RUN_MISS + assert CHECKED_RUN_MISS is not None + + bare = AssemblyCallbackHandler(None) + seen: list[object] = [] + monkeypatch.setattr(bare, "on_tool_start", lambda *a, **k: seen.append(k["run_id"])) + monkeypatch.setattr(lc_runtime, "_ACTIVE_CALLBACK_HANDLER", bare) + run_id = uuid4() + + ProcessGovernanceHandler().on_tool_start({"name": "t"}, "", run_id=run_id) + + assert seen == [run_id] From eb4e95ee2a4d582bcd7cdaf3b4a58b74eda315e7 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 6 Nov 2026 09:34:53 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=93=9D=20(python-sdk):=20Say=20a=20ha?= =?UTF-8?q?ndler=20is=20neither=20checked=20nor=20ended=20twice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs AAASM-6445 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TLpw696KjUDUg4QjH83En4 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e2cbb250..ab2f8ad4 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ What this does: > - It covers `langchain_core` runs only. Other frameworks are governed by their own adapters, not by this hook. > - It governs tool calls only. LLM-side hooks (`on_llm_start` / `on_llm_end`, prompt scanning) are **not** enabled by `init_assembly()`; they run only for an `AssemblyCallbackHandler` you pass in `callbacks` yourself. > - It stops governing after `shutdown()` / the end of the `with` block, and it is **never installed** if the LangChain adapter fails to register or `langchain-core` is not importable. A failed adapter registration only writes a `framework adapter 'langchain' failed to register governance hooks` warning to stderr and `init_assembly()` still returns, even under `enforce`; the tool then runs ungoverned. `ctx.registered` reports the *gateway* registration, not this. Check that the warning is absent and that `any(a.get_framework_name() == "langchain" for a in ctx.adapters)` is true. -> - A handler you pass yourself is not checked twice when it wraps the active interceptor. A handler built around a different interceptor is checked in addition, never instead. +> - A handler you pass yourself is neither checked nor ended twice (one policy check and one `on_tool_end` per tool run) when it wraps the active interceptor, and only after it allowed the run. A handler built around a different interceptor is checked in addition, never instead. ## Public API