diff --git a/agent_assembly/core/assembly.py b/agent_assembly/core/assembly.py index 82f6ba65..ae3637b2 100644 --- a/agent_assembly/core/assembly.py +++ b/agent_assembly/core/assembly.py @@ -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, @@ -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 @@ -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, @@ -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, @@ -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: @@ -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 diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 71e8435c..e3d3d023 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -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 diff --git a/test/unit/core/test_init_registration.py b/test/unit/core/test_init_registration.py index 32fcdc9a..270fef8c 100644 --- a/test/unit/core/test_init_registration.py +++ b/test/unit/core/test_init_registration.py @@ -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],