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
54 changes: 51 additions & 3 deletions agent_assembly/core/assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
resolve_gateway_url,
)
from agent_assembly.core.runtime_interceptor import (
ENFORCE_MODE,
_local_posture_is_enforce,
_native_core_available,
build_governance_interceptor,
Expand Down Expand Up @@ -215,6 +216,14 @@ def init_assembly(
a ``deny`` blocks the tool before it runs. The SDK never calls a core HTTP
endpoint directly for registration or policy checks.

:param mode: Interception layer to activate (see :data:`RuntimeMode`).
``"sdk-only"`` is the in-process-only layer that starts no network
sidecar, so it is also the one mode where a gateway that cannot be
registered with only **warns** instead of failing init (AAASM-6155) β€”
unless ``enforcement_mode="enforce"`` is passed explicitly, which asks
for the fail-closed posture by name and aborts in every mode. Governed
tool calls are unaffected either way: the interceptor still denies under
enforce when no authoritative decision is available.
:param control_plane_url: Optional URL of the control-plane HTTP API. When
supplied, the SDK issues its remaining HTTP routes (topology edges,
secret dispatch) against it instead of ``gateway_url``. When omitted it
Expand Down Expand Up @@ -308,6 +317,7 @@ def init_assembly(
runtime_client=runtime_client,
agent_id=resolved_agent_id,
enforcement_mode=enforcement_mode,
mode=mode,
gateway_endpoint=resolve_gateway_grpc_endpoint(gateway_url, allow_insecure=allow_insecure),
native_available=native_available,
team_id=team_id,
Expand Down Expand Up @@ -456,11 +466,48 @@ def _warn_audit_not_recorded(disposition: AuditSinkDisposition) -> None:
)


def _register_failure_is_fatal(*, mode: RuntimeMode, enforcement_mode: EnforcementMode | None) -> bool:
"""Whether a failed ``register`` must abort init rather than warn (AAASM-6155).

Two rules, in order:

* An **explicit** ``enforce`` always aborts, in every ``mode``. The caller
asked for the fail-closed posture by name, so a gateway it cannot register
with is a misconfiguration and init must not come up.
* Otherwise ``mode="sdk-only"`` warns and continues. That mode is documented
as the in-process-only layer that starts no network sidecar and is "the best
choice for deterministic, offline examples and tests"; the quick-start
states the offline path *warns* that the agent is unregistered. Requiring a
reachable gateway there contradicts the one mode whose purpose is to run
without one.

Every other ``mode`` keeps the :func:`_local_posture_is_enforce` posture, so an
unset ``enforcement_mode`` of ``None`` still aborts (AAASM-4130): ``None``
registers under the gateway's server-side default of live ``enforce``, and
``auto`` / ``proxy`` / ``ebpf`` all do bring up an interception layer that the
gateway is expected to back.

Relaxing init here does not make an ``sdk-only`` session run ungoverned. The
registration warning is unconditional (see :func:`_warn_agent_unregistered`),
``AssemblyContext.registered`` reports ``False``, and the interceptor the
adapters are handed keeps its own fail-closed posture under enforce β€” an
unreachable runtime or an unauthoritative ``query_policy`` still **denies** the
tool call (AAASM-3106, AAASM-4760). What changes is only whether ``init_assembly``
raises instead of warning.
"""
if enforcement_mode == ENFORCE_MODE:
return True
if mode == "sdk-only":
return False
return _local_posture_is_enforce(enforcement_mode)


def _register_agent_with_gateway(
*,
runtime_client: Any | None,
agent_id: str,
enforcement_mode: EnforcementMode | None,
mode: RuntimeMode,
gateway_endpoint: str,
native_available: bool,
team_id: str | None = None,
Expand Down Expand Up @@ -488,8 +535,9 @@ def _register_agent_with_gateway(
``register`` raises, the failure is no longer silent: a loud
:func:`_warn_agent_unregistered` fires and ``False`` is returned (AAASM-4547).
Init still proceeds so the proxy / eBPF layers stay authoritative β€” except
under an enforce posture (the ``None`` default or explicit ``enforce``), where a
``register`` failure propagates so a misconfigured gateway fails init closed.
where :func:`_register_failure_is_fatal` says the failure must abort, which is
an explicit ``enforce`` in any mode, or the ``None`` default in any mode other
than ``sdk-only`` (AAASM-6155).
"""
if runtime_client is None:
if native_available:
Expand All @@ -512,7 +560,7 @@ def _register_agent_with_gateway(
parent_agent_id=parent_agent_id,
)
except Exception as error:
if _local_posture_is_enforce(enforcement_mode):
if _register_failure_is_fatal(mode=mode, enforcement_mode=enforcement_mode):
raise
_warn_agent_unregistered(f"registration failed: {error}")
return False
Expand Down
8 changes: 8 additions & 0 deletions docs/concepts/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ Two independent knobs control governance. It's worth keeping them straight:
| `proxy` | Routes outbound traffic through the `aasm` sidecar proxy β€” network-egress policy without modifying the agent's source. |
| `ebpf` | Kernel-level interception via eBPF. **Linux only** β€” raises `ConfigurationError` elsewhere. |

The two knobs are independent with one exception: what a **failed agent registration** does to
`init_assembly()`. Under `sdk-only` β€” the mode that starts no sidecar and is documented as
needing no gateway β€” an unreachable gateway **warns** and init continues. Every other mode, and
any mode where you pass `enforcement_mode="enforce"` explicitly, treats it as a misconfiguration
and fails init closed (AAASM-6155). Only whether `init_assembly()` raises differs: in both cases
`ctx.registered` reports `False`, the warning is unconditional, and a governed tool call with no
authoritative decision behind it is still **denied** under enforce.

### Enforcement modes

`enforcement_mode` is the governance posture sent to the gateway at registration. Leaving it
Expand Down
132 changes: 132 additions & 0 deletions test/unit/core/test_init_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,138 @@ def test_native_present_but_no_runtime_client_warns_and_marks_unregistered(
context.shutdown()


def test_sdk_only_default_posture_warns_on_register_failure(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""AAASM-6155 regression: ``mode="sdk-only"`` with no ``enforcement_mode`` must
warn on a register failure, not abort init.

``sdk-only`` is documented as the in-process-only layer that starts no network
sidecar and needs no gateway, so requiring a reachable one there contradicted
the mode's purpose. The AAASM-4130 fail-closed posture for an unset
``enforcement_mode`` still applies to every other mode β€” see the ``auto`` /
``proxy`` / ``ebpf`` cases below, which are what keeps this from being a blanket
relaxation.
"""
runtime_client = FakeRuntimeClient(decision="allow")
runtime_client.register_should_raise = RuntimeError("gateway gRPC endpoint is unreachable for registration")
install_fake_core(monkeypatch, runtime_client)
_no_network(monkeypatch)
monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT))

context = init_assembly(gateway_url=_GW_URL, api_key=_API_KEY, agent_id="offline-demo", mode="sdk-only")
try:
# Init came up, and it is honest about what it could not do.
assert context.registered is False
err = capsys.readouterr().err
assert "NOT registered" in err
assert "registration failed" in err
finally:
context.shutdown()


@pytest.mark.parametrize("mode", ["auto", "proxy", "ebpf"])
def test_non_sdk_only_default_posture_still_propagates_register_failure(
monkeypatch: pytest.MonkeyPatch,
mode: str,
) -> None:
"""The AAASM-6155 relaxation is scoped to ``sdk-only`` and nothing else.

Every mode that does bring up an interception layer keeps the AAASM-4130
posture: an unset ``enforcement_mode`` registers under the gateway's
server-side default of live ``enforce``, so a gateway it cannot register with
fails init closed. Without these cases the ``sdk-only`` test above would also
pass if the guard had been removed outright.
"""
runtime_client = FakeRuntimeClient(decision="allow")
runtime_client.register_should_raise = RuntimeError("gateway gRPC endpoint is unreachable for registration")
install_fake_core(monkeypatch, runtime_client)
# Patched so the assertion is about registration, not about which network
# layer this host happens to support (``ebpf`` is Linux-only).
_no_network(monkeypatch)
monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: ([], AUDIT_SINK_ABSENT))

with pytest.raises(ConfigurationError, match="Failed to initialize assembly runtime"):
init_assembly(gateway_url=_GW_URL, api_key=_API_KEY, agent_id=f"agent-{mode}", mode=mode) # type: ignore[arg-type]


def test_sdk_only_register_failure_still_denies_governed_tool_calls(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The AAASM-6155 relaxation must not let an ``sdk-only`` session run ungoverned.

Only whether ``init_assembly`` *raises* changes. With registration failed and
the runtime unable to return an authoritative verdict, the interceptor the
adapters are handed keeps its enforce-posture fail-closed behaviour: the
governed tool call is denied (AAASM-3106). A relaxation that had also loosened
the interceptor would show ``allow`` here.
"""
# ``query_failed`` is what an unreachable runtime yields β€” not an
# authoritative allow, so under enforce it must deny.
runtime_client = FakeRuntimeClient(decision="query_failed")
runtime_client.register_should_raise = RuntimeError("gateway gRPC endpoint is unreachable for registration")
install_fake_core(monkeypatch, runtime_client)
_no_network(monkeypatch)
adapter = _CapturingAdapter()
monkeypatch.setattr(core_assembly, "_register_adapters", _patched_register_adapters(adapter))

context = init_assembly(gateway_url=_GW_URL, api_key=_API_KEY, agent_id="offline-governed", mode="sdk-only")
try:
assert context.registered is False
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()


@pytest.mark.parametrize(
("mode", "enforcement_mode", "fatal"),
[
# sdk-only: the None default warns; an explicit enforce still aborts.
("sdk-only", None, False),
("sdk-only", "enforce", True),
("sdk-only", "observe", False),
("sdk-only", "disabled", False),
# Every other mode keeps the AAASM-4130 posture for the None default.
("auto", None, True),
("auto", "enforce", True),
("auto", "observe", False),
("auto", "disabled", False),
("proxy", None, True),
("proxy", "enforce", True),
("proxy", "observe", False),
("proxy", "disabled", False),
("ebpf", None, True),
("ebpf", "enforce", True),
("ebpf", "observe", False),
("ebpf", "disabled", False),
],
)
def test_register_failure_fatality_matrix(mode: str, enforcement_mode: str | None, fatal: bool) -> None:
"""The full ``mode`` Γ— ``enforcement_mode`` contract for a failed register.

Spelled out exhaustively because the defect (AAASM-6155) was a single cell of
this matrix β€” ``sdk-only`` Γ— ``None`` β€” flipping when the guard moved from an
equality test to :func:`_local_posture_is_enforce`.
"""
assert (
core_assembly._register_failure_is_fatal(
mode=mode, # type: ignore[arg-type]
enforcement_mode=enforcement_mode, # type: ignore[arg-type]
)
is fatal
)


def test_register_failure_under_observe_warns_and_marks_unregistered(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
Expand Down
Loading