From 47e89138f7e1ebf73eba6ff88825c1ce4e2d823e Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 16:26:07 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20(langchain):=20Fail=20closed?= =?UTF-8?q?=20when=20interceptor=20lacks=20check=5Ftool=5Fstart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit on_tool_start allowed unconditionally when the wired interceptor exposed no check_tool_start, silently skipping pre-execution governance under enforce. Mirrors the other adapters' _missing_interceptor_decision fallback: deny under enforce, fail open under observe/disabled. Refs AAASM-4790 --- agent_assembly/adapters/langchain/callback_handler.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/agent_assembly/adapters/langchain/callback_handler.py b/agent_assembly/adapters/langchain/callback_handler.py index bc4b526e..166e5a59 100644 --- a/agent_assembly/adapters/langchain/callback_handler.py +++ b/agent_assembly/adapters/langchain/callback_handler.py @@ -40,6 +40,7 @@ class AssemblyCallbackHandler(_CallbackHandlerBase): # type: ignore[valid-type, raise_error: bool = True _UNKNOWN_DECISION_REASON = "Unrecognized governance decision; denied under enforce." + _MISSING_CHECK_TOOL_START_REASON = "Governance interceptor exposes no check_tool_start; denied under enforce." def __init__(self, interceptor: Any) -> None: self._interceptor = interceptor @@ -135,6 +136,14 @@ def on_tool_start( ) -> None: method = getattr(self._interceptor, "check_tool_start", None) if not callable(method): + # Mirrors the other adapters' ``_missing_interceptor_decision`` + # fallback (AAASM-4790): a co-installed adapter can hand this + # handler an interceptor that exposes no ``check_tool_start``. + # Silently allowing there skipped pre-execution governance under + # ``enforce``, so fail closed there and only fail open under + # observe / disabled, consistent with ``_unknown_decision``. + if self._enforce: + raise ToolExecutionBlockedError(self._MISSING_CHECK_TOOL_START_REASON) return None decision = method( From 5022ea3adb1d41b7741eb0d02d2b61d2f4e779ae Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 16:26:31 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=85=20(langchain):=20Test=20check=5Ft?= =?UTF-8?q?ool=5Fstart-less=20interceptor=20fails=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers AAASM-4790: on_tool_start blocks under enforce and allows under observe/disabled when the interceptor has no check_tool_start. --- .../langchain/test_callback_handler_sync.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/unit/adapters/langchain/test_callback_handler_sync.py b/test/unit/adapters/langchain/test_callback_handler_sync.py index c484d39e..3920c00f 100644 --- a/test/unit/adapters/langchain/test_callback_handler_sync.py +++ b/test/unit/adapters/langchain/test_callback_handler_sync.py @@ -238,3 +238,35 @@ def test_explicit_callback_methods_are_not_delegated() -> None: handler.on_tool_end(output="done", run_id=uuid4()) assert interceptor.tool_end_calls == 1 + + +# --- AAASM-4790: a check_tool_start-less interceptor must fail closed under enforce --- + + +class _NoCheckToolStartInterceptor: + """An interceptor that does not expose ``check_tool_start`` at all.""" + + def __init__(self, *, enforce: bool) -> None: + self._enforce = enforce + + +def test_on_tool_start_blocks_when_check_tool_start_missing_under_enforce() -> None: + handler = AssemblyCallbackHandler(_NoCheckToolStartInterceptor(enforce=True)) + + with pytest.raises(ToolExecutionBlockedError): + handler.on_tool_start( + serialized={"name": "web_search"}, + input_str="query", + run_id=uuid4(), + ) + + +def test_on_tool_start_allows_when_check_tool_start_missing_under_observe() -> None: + handler = AssemblyCallbackHandler(_NoCheckToolStartInterceptor(enforce=False)) + + # Must not raise: observe/disabled preserves the fail-open dry-run posture. + handler.on_tool_start( + serialized={"name": "web_search"}, + input_str="query", + run_id=uuid4(), + ) From 8879a723ae9352b99adfce898834761fc0bb8007 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 16:26:42 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20(core):=20Warn=20on=20stderr?= =?UTF-8?q?=20when=20an=20adapter=20fails=20to=20register=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _register_adapters swallowed register_hooks exceptions with a bare continue, leaving a co-installed framework running fully ungoverned with no trace. Emits an unconditional stderr warning naming the framework, mirroring _warn_agent_unregistered; init still proceeds. Refs AAASM-4790 --- agent_assembly/core/assembly.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/agent_assembly/core/assembly.py b/agent_assembly/core/assembly.py index dcaee05c..6352c1e0 100644 --- a/agent_assembly/core/assembly.py +++ b/agent_assembly/core/assembly.py @@ -426,6 +426,24 @@ def _register_agent_with_gateway( return True +def _warn_adapter_registration_failed(adapter: FrameworkAdapter, error: Exception) -> None: + """Emit a loud, unconditional stderr warning that a framework adapter failed to attach. + + ``register_hooks`` failing was previously swallowed with a bare ``continue`` + (AAASM-4790), leaving a co-installed framework running fully ungoverned with + no trace. Mirrors :func:`_warn_agent_unregistered`'s unconditional + ``sys.stderr.write`` so ``logging`` configuration cannot silence it. Init + still proceeds — other adapters may register fine — matching the existing + fail-open-and-continue policy in :func:`_register_adapters`. + """ + sys.stderr.write( + "[agent-assembly] WARNING: framework adapter " + f"{adapter.get_framework_name()!r} failed to register governance hooks " + f"({error}); this framework will run UNGOVERNED by the SDK layer. The " + "proxy / eBPF layers remain authoritative.\n" + ) + + def _register_adapters( client: GatewayClient, process_agent_id: str, @@ -462,7 +480,8 @@ def _register_adapters( try: adapter.register_hooks(interceptor) - except Exception: + except Exception as error: + _warn_adapter_registration_failed(adapter, error) continue registered.append(adapter) From 88a562e78bbbba37ba49764b56eb81635a6f4440 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 16:28:09 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=85=20(core):=20Test=20stderr=20warni?= =?UTF-8?q?ng=20on=20adapter=20register=5Fhooks=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers AAASM-4790: _register_adapters warns on stderr naming the broken framework and still registers the remaining healthy adapters. --- test/unit/test_assembly.py | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/unit/test_assembly.py b/test/unit/test_assembly.py index a3776e72..48f4b904 100644 --- a/test/unit/test_assembly.py +++ b/test/unit/test_assembly.py @@ -8,6 +8,7 @@ from agent_assembly import init_assembly 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.exceptions import AssemblyError, ConfigurationError @@ -214,6 +215,48 @@ def unregister_hooks(self) -> None: assert context.is_shutdown is True +def test_register_adapters_warns_on_stderr_when_register_hooks_raises( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """A framework adapter whose ``register_hooks`` raises must not fail + silently (AAASM-4790) — the operator gets a stderr warning naming the + framework, and other adapters still register (the ``continue`` is kept).""" + + class _BrokenAdapter(_FakeAdapter): + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + raise RuntimeError("monkeypatch failed to apply") + + broken = _BrokenAdapter("broken-framework") + healthy = _FakeAdapter("healthy-framework") + + class _FakeRegistry: + def get_available_adapters_by_priority(self) -> list[FrameworkAdapter]: + return [broken, healthy] + + monkeypatch.setattr(core_assembly, "AdapterRegistry", _FakeRegistry) + monkeypatch.setattr( + core_assembly, + "build_governance_interceptor", + lambda *args, **kwargs: object(), + ) + + client = GatewayClient( + gateway_url="http://localhost:8080", + agent_id="test-agent-001", + api_key="test-api-key", + ) + registered = core_assembly._register_adapters( + client=client, + process_agent_id="test-agent-001", + ) + + assert registered == [healthy] + stderr = capsys.readouterr().err + assert "broken-framework" in stderr + assert "UNGOVERNED" in stderr + + def test_init_assembly_rejects_conflicting_reinit( monkeypatch: pytest.MonkeyPatch, ) -> None: