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
100 changes: 70 additions & 30 deletions agent_assembly/core/runtime_interceptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down Expand Up @@ -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).
Expand All @@ -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,
)

Expand Down Expand Up @@ -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,
Expand All @@ -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``
Expand All @@ -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)
38 changes: 38 additions & 0 deletions test/unit/core/test_init_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
51 changes: 40 additions & 11 deletions test/unit/core/test_runtime_interceptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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."""

Expand Down