From 0a8307aa0a2035968e8eafebc195133c910d1822 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 14:02:54 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20(core):=20fail=20closed=20wh?= =?UTF-8?q?en=20native=20core=20is=20absent=20under=20enforce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under an enforce posture (the None default or explicit "enforce") a pure-Python / containerized install with no native agent_assembly._core extension previously returned the bare GatewayClient from build_governance_interceptor. The adapters got no check_tool_start, so every governed tool call proceeded UN-governed while the session looked governed — a silent fail-open (AAASM-4760). Now the native-missing enforce path returns a deny-all _FailClosedInterceptor with an explicit, logged reason and keeps the loud one-time warning: no tool call is authoritatively allowed when there is no native authority to consult. The explicit observe / disabled dry-run postures remain the opt-in advisory path (fail open), unchanged. Updates the two interceptor tests that encoded the old fail-open contract and adds a builder-level regression asserting the deny reason is explicit. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019NafDDcehs2ez4Ax9kuWfi --- agent_assembly/core/runtime_interceptor.py | 100 ++++++++++++++------- test/unit/core/test_runtime_interceptor.py | 51 ++++++++--- 2 files changed, 110 insertions(+), 41 deletions(-) diff --git a/agent_assembly/core/runtime_interceptor.py b/agent_assembly/core/runtime_interceptor.py index 12e370f7..6595f4e0 100644 --- a/agent_assembly/core/runtime_interceptor.py +++ b/agent_assembly/core/runtime_interceptor.py @@ -16,14 +16,15 @@ * Under an **enforce** posture — explicit ``enforce`` *or* the ``None`` default, which is advertised as the gateway's live ``enforce`` (AAASM-4130) — the SDK is - a security control and **fails closed**. When the native extension is missing the - bare client is returned (no native authority exists to consult — see - :func:`build_governance_interceptor`) but a loud one-time warning is emitted so the - skipped in-process deny is not silent; once the native extension is present every - other failure denies: an unreachable runtime socket yields a deny-all interceptor, - a raising ``query_policy`` maps to ``deny``, and a native ``decision`` that is - itself an error sentinel (``query_failed`` / ``channel_closed``) maps to ``deny`` - rather than allow. + a security control and **fails closed**. When the native extension is missing a + deny-all interceptor is returned (no native authority exists to consult, so no + tool call may be authoritatively allowed — AAASM-4760) alongside a loud one-time + warning; previously this case returned the bare client and ran every tool call + ungoverned while the session looked governed. Once the native extension is present + every other failure likewise denies: an unreachable runtime socket yields a + deny-all interceptor, a raising ``query_policy`` maps to ``deny``, and a native + ``decision`` that is itself an error sentinel (``query_failed`` / + ``channel_closed``) maps to ``deny`` rather than allow. * Under the explicit dry-run postures **observe** / **disabled**, the SDK is a dry-run / hermetic-test layer and **fails open**: an unreachable runtime returns the bare client unchanged, a raising or error ``query_policy`` @@ -61,6 +62,21 @@ # Node SDK; the runtime remains the authority on redaction, so ``redact`` proceeds. _ALLOW_DECISIONS = frozenset({"allow", "redact"}) +# Deny reason surfaced when the native ``agent_assembly._core`` extension is absent +# under an enforce posture (AAASM-4760). Previously this case returned the bare +# client (fail open): a pure-Python / containerized install with no native wheel ran +# every tool call UN-governed while the session looked governed. The SDK is the +# in-process security control; with no native authority to consult it must fail +# CLOSED — deny every governed tool call — rather than silently allow. The explicit +# ``observe`` / ``disabled`` postures remain the opt-in advisory path (fail open). +_NATIVE_MISSING_REASON = ( + "governance unavailable: the native agent_assembly._core extension is not " + "installed, so the SDK cannot make an in-process policy decision — refusing to " + "run ungoverned under enforce. Install the native extension to enable the " + "in-process allow/deny fast path, or set enforcement_mode='observe' to run " + "advisory-only." +) + def _local_posture_is_enforce(enforcement_mode: str | None) -> bool: """Whether the SDK's *local* failure posture must fail closed (AAASM-4130). @@ -81,17 +97,22 @@ def _warn_sdk_enforcement_unavailable() -> None: Under an enforce posture (the ``None`` default or explicit ``enforce``) a policy ``deny`` is advertised to block the tool before it runs, but that in-process fast path needs the native ``agent_assembly._core`` extension. On a pure-Python install - it is absent, so no local deny is possible. Rather than silently fail open (Python - diverging from go's fail-closed and node's ``ConfigurationError``), emit a warning - so the gap is visible; the gateway / proxy / eBPF layers remain authoritative, so - the SDK stays advisory and does not hard-fail on a missing runtime. + it is absent, so no local allow/deny decision is possible. + + Rather than silently fail open (Python diverging from go's fail-closed and node's + ``ConfigurationError``), the SDK now fails **closed** under enforce: governed tool + calls are denied (see :func:`build_governance_interceptor` / + :data:`_NATIVE_MISSING_REASON`, AAASM-4760) instead of running ungoverned while the + session looks governed. This warning makes the missing extension visible so the + developer knows *why* their tools are being denied and how to restore the fast path. """ warnings.warn( "Agent Assembly enforcement is active but the native runtime extension " - "(agent_assembly._core) is not installed, so the SDK cannot apply a local " - "pre-execution deny; tool calls proceed in-process and rely on the gateway / " - "proxy / eBPF layers for enforcement. Install the native extension to enable " - "the in-process deny fast path.", + "(agent_assembly._core) is not installed, so the SDK cannot make an " + "in-process policy decision. Under enforce the SDK now fails CLOSED: governed " + "tool calls are DENIED rather than proceeding ungoverned. Install the native " + "extension to enable the in-process allow/deny fast path, or set " + "enforcement_mode='observe' to run advisory-only.", stacklevel=2, ) @@ -497,6 +518,28 @@ def _native_core_available() -> bool: return True +def _governance_unavailable(client: Any, enforce: bool, reason: str, *, warn: bool) -> Any: + """Resolve the interceptor when no authoritative runtime decision is possible. + + Shared by every ``build_governance_interceptor`` path that ends up without a + connected runtime client — the native extension missing, or present but the + socket unreachable/distrusted. Under an enforce posture the SDK is the security + control and must fail **closed**: return a deny-all :class:`_FailClosedInterceptor` + so no governed tool call slips through ungoverned (AAASM-4760 / AAASM-3106). Under + the explicit ``observe`` / ``disabled`` dry-run postures the SDK is advisory and + fails open: return the bare ``client`` unchanged. + + ``warn`` gates the one-time loud warning to the native-missing case (a + pure-Python install), matching the historical AAASM-4130 behavior; the + unreachable-socket case denies without an extra warning. + """ + if not enforce: + return client + if warn: + _warn_sdk_enforcement_unavailable() + return _FailClosedInterceptor(client, reason) + + def build_governance_interceptor( client: Any, agent_id: str, @@ -515,10 +558,11 @@ def build_governance_interceptor( the same *local* fail-closed posture as an explicit ``enforce`` (AAASM-4130); only ``observe`` / ``disabled`` fail open locally: - * The native extension is **missing**: return ``client`` unchanged in every - mode — there is no native authority to fail closed *to*. Under an enforce - posture this would silently skip the in-process deny, so a loud one-time - warning is emitted first (AAASM-4130) rather than failing open silently. + * The native extension is **missing**: under an enforce posture return a + deny-all :class:`_FailClosedInterceptor` (AAASM-4760) and emit a loud one-time + warning (AAASM-4130) — with no native authority there is no authoritative allow, + so governed tool calls must be denied rather than run ungoverned. Under + ``observe`` / ``disabled`` return ``client`` unchanged (advisory / fail open). * The native extension is **present** but the runtime socket is unreachable: under an enforce posture (``None`` default or explicit ``enforce``) return a deny-all :class:`_FailClosedInterceptor`; under ``observe`` / ``disabled`` @@ -538,22 +582,18 @@ def build_governance_interceptor( if runtime_client is None and native_available is None: # No caller-supplied client or hint: detect + connect ourselves. if not _native_core_available(): - if enforce: - _warn_sdk_enforcement_unavailable() - return client + return _governance_unavailable(client, enforce, _NATIVE_MISSING_REASON, warn=True) runtime_client = connect_runtime_client(agent_id) native_available = True if runtime_client is None: - # Native missing → no authority to fail closed to, return bare client. + # Native missing → no in-process authority. Under enforce this is a + # fail-closed deny-all (never a silent allow, AAASM-4760); observe / + # disabled stay advisory (fail open). if not native_available: - if enforce: - _warn_sdk_enforcement_unavailable() - return client + return _governance_unavailable(client, enforce, _NATIVE_MISSING_REASON, warn=True) # Native present but runtime unreachable: deny everything under enforce # (fail closed); proceed under observe / disabled (fail open). - if enforce: - return _FailClosedInterceptor(client, "runtime unreachable") - return client + return _governance_unavailable(client, enforce, "runtime unreachable", warn=False) return RuntimeQueryInterceptor(client, runtime_client, agent_id, enforce=enforce, op_control=op_control) diff --git a/test/unit/core/test_runtime_interceptor.py b/test/unit/core/test_runtime_interceptor.py index d8411479..4fa4bf58 100644 --- a/test/unit/core/test_runtime_interceptor.py +++ b/test/unit/core/test_runtime_interceptor.py @@ -143,11 +143,12 @@ def test_callback_handler_allows_on_runtime_allow() -> None: ) -def test_build_interceptor_without_native_core_returns_bare_client( +def test_build_interceptor_without_native_core_fails_closed_under_enforce( monkeypatch: pytest.MonkeyPatch, ) -> None: - """No native extension: the bare GatewayClient is returned unchanged so the - adapters have no check_tool_start and proceed (fail-open / no-core path).""" + """AAASM-4760: no native extension under the default (enforce) posture must fail + CLOSED — a deny-all interceptor, not the old bare-client fail-open that ran tool + calls ungoverned. The warning fires because no in-process decision is possible.""" monkeypatch.delitem(sys.modules, "agent_assembly._core", raising=False) # Force the import inside build_governance_interceptor to fail. @@ -163,10 +164,11 @@ def _no_core_import(name: str, *args: Any, **kwargs: Any) -> Any: monkeypatch.setattr(builtins, "__import__", _no_core_import) client = _FakeGatewayClient() - result = build_governance_interceptor(client, "agent-001") + with pytest.warns(UserWarning, match="native runtime extension"): + result = build_governance_interceptor(client, "agent-001") - assert result is client - assert not hasattr(result, "check_tool_start") + assert isinstance(result, _FailClosedInterceptor) + assert result.check_tool_start(serialized={"name": "t"}, input_str="i")["status"] == "deny" def test_build_interceptor_returns_bare_client_when_connect_fails( @@ -240,9 +242,9 @@ def connect(_socket_path: str) -> Any: def test_default_mode_warns_when_native_core_missing(monkeypatch: pytest.MonkeyPatch) -> None: - """AAASM-4130: pure-Python install (native extension absent) under the default - ``enforce`` posture cannot run a local deny, so it must warn loudly rather than - fail open silently. The bare client is still returned (init stays graceful).""" + """AAASM-4130 / AAASM-4760: pure-Python install (native extension absent) under the + default ``enforce`` posture cannot run a local decision, so it warns loudly *and* + fails closed (deny-all) rather than proceeding ungoverned.""" monkeypatch.delitem(sys.modules, "agent_assembly._core", raising=False) import builtins @@ -260,8 +262,8 @@ def _no_core_import(name: str, *args: Any, **kwargs: Any) -> Any: with pytest.warns(UserWarning, match="native runtime extension"): result = build_governance_interceptor(client, "agent-001") # no mode -> default - assert result is client - assert not hasattr(result, "check_tool_start") + assert isinstance(result, _FailClosedInterceptor) + assert result.check_tool_start(serialized={"name": "t"}, input_str="i")["status"] == "deny" def test_observe_mode_does_not_warn_when_native_core_missing(monkeypatch: pytest.MonkeyPatch) -> None: @@ -289,6 +291,33 @@ def _no_core_import(name: str, *args: Any, **kwargs: Any) -> Any: assert result is client +def test_native_missing_deny_reason_is_explicit(monkeypatch: pytest.MonkeyPatch) -> None: + """AAASM-4760 regression: the native-missing fail-closed deny carries a clear, + actionable reason so the developer knows *governance is unavailable* (install the + native extension / opt into observe) rather than mistaking it for a policy deny — + the loud, logged reason the ticket requires.""" + monkeypatch.delitem(sys.modules, "agent_assembly._core", raising=False) + + import builtins + + real_import = builtins.__import__ + + def _no_core_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "agent_assembly._core": + raise ImportError("native extension unavailable") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _no_core_import) + + with pytest.warns(UserWarning, match="native runtime extension"): + result = build_governance_interceptor(_FakeGatewayClient(), "agent-001", "enforce") + + verdict = result.check_tool_start(serialized={"name": "t"}, input_str="i") + assert verdict["status"] == "deny" + assert "governance unavailable" in verdict["reason"] + assert "ungoverned" in verdict["reason"] + + def test_enforce_query_raising_fails_closed() -> None: """AAASM-3106: under enforce a raising query_policy denies, not allows.""" From 380fbecbbd3933f44e36c770a9674358816931fd Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 14:02:57 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=85=20(core):=20regression=20test=20f?= =?UTF-8?q?or=20native-absent=20fail-closed=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end assertion for AAASM-4760: with the native extension absent under the default enforce posture, init_assembly must not run ungoverned-but-looks-governed. The test asserts both guarantees at once — ctx.registered is False (not a governed/registered session) and the wired interceptor's check_tool_start returns deny, never a silent allow. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019NafDDcehs2ez4Ax9kuWfi --- test/unit/core/test_init_registration.py | 38 ++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/unit/core/test_init_registration.py b/test/unit/core/test_init_registration.py index b33325f7..68792202 100644 --- a/test/unit/core/test_init_registration.py +++ b/test/unit/core/test_init_registration.py @@ -628,6 +628,44 @@ def test_native_absent_warns_loudly_and_marks_unregistered( context.shutdown() +def test_native_absent_under_enforce_wires_fail_closed_interceptor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """AAASM-4760 regression: with the native extension absent under the default + (enforce) posture the SDK must never run ungoverned-but-looks-governed. Two + guarantees at once: the session is NOT reported registered/governed, and the + interceptor the adapters receive fails CLOSED — a governed tool call is denied, + not silently allowed.""" + monkeypatch.setattr(core_assembly, "_native_core_available", lambda: False) + _no_network(monkeypatch) + adapter = _CapturingAdapter() + monkeypatch.setattr(core_assembly, "_register_adapters", _patched_register_adapters(adapter)) + + with pytest.warns(UserWarning, match="native runtime extension"): + context = init_assembly( + gateway_url=_GW_URL, + api_key=_API_KEY, + agent_id="no-native-enforce", + mode="sdk-only", + ) + try: + # Not a governed/registered session — the ``registered`` flag stays False. + assert context.registered is False + # And a governed tool call fails closed rather than proceeding ungoverned. + assert adapter.interceptor is not None + interceptor: Any = adapter.interceptor + verdict = interceptor.check_tool_start( + serialized={"name": "web_search"}, + input_str="q", + tool_name="web_search", + args={"q": "x"}, + ) + assert verdict["status"] == "deny" + assert verdict["status"] != "allow" + finally: + context.shutdown() + + def test_native_present_but_no_runtime_client_warns_and_marks_unregistered( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],