diff --git a/README.md b/README.md index f15a222..1b16004 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ What this does: ## Public API -- `init_assembly(gateway_url, api_key, agent_id=None, mode="auto", *, control_plane_url=None) -> AssemblyContext` +- `init_assembly(gateway_url, api_key, agent_id=None, mode="auto", *, control_plane_url=None, allow_insecure=False) -> AssemblyContext` - Exceptions: `AssemblyError`, `AgentError`, `PolicyError`, `GatewayError`, `ConfigurationError` - Data models: `AgentConfig`, `AgentState`, `PolicyEvaluation` @@ -206,6 +206,25 @@ Resolution order is **explicit kwarg > env-var > unset**: | `gateway_url` | `AA_GATEWAY_URL` | | `control_plane_url` | `AA_CONTROL_PLANE_URL` | +### Insecure transport opt-in + +Agent registration dials the gateway's gRPC port over the native `register` +call. By default a **plaintext `http://` register channel to a non-loopback +host is refused** — the `Register` call carries the agent identity, so it must +not travel unencrypted to a remote host. Loopback (local dev) and `https://` +targets always pass. + +Pass `allow_insecure=True` to opt into plaintext to a non-loopback host, on a +**trusted network only** (e.g. a private link where TLS terminates elsewhere). +This mirrors the operator-control `connect(allow_insecure=...)` opt-in: + +```python +init_assembly( + gateway_url="http://gateway.internal:7391", + allow_insecure=True, # trusted-network only; leave False in production +) +``` + ## Error Handling ```python diff --git a/agent_assembly/core/assembly.py b/agent_assembly/core/assembly.py index f69fdda..b0d71e6 100644 --- a/agent_assembly/core/assembly.py +++ b/agent_assembly/core/assembly.py @@ -166,6 +166,7 @@ def init_assembly( spawned_by_tool: str | None = None, depth: int | None = None, enforcement_mode: EnforcementMode | None = None, + allow_insecure: bool = False, ) -> AssemblyContext: """Initialize the Agent Assembly SDK runtime for this process. @@ -204,6 +205,15 @@ def init_assembly( the agent in dry-run / sandbox mode: every action proceeds (local checks fail open) and the gateway records would-be violations as shadow audit events. + :param allow_insecure: Opt into a plaintext (non-TLS) native ``register`` + channel when the resolved gateway host is non-loopback (AAASM-4664). + Defaults to ``False`` — secure-by-default (AAASM-4655): a derived + plaintext ``http://`` register endpoint to a non-loopback host is + refused, since the ``Register`` call carries the agent identity and must + not travel unencrypted to a remote host. Set to ``True`` only on a + trusted network (loopback dev / private link), mirroring op-control's + ``connect(allow_insecure=...)`` semantics. Loopback and ``https://`` + targets always pass regardless of this flag. """ gateway_url = resolve_gateway_url(gateway_url) api_key = resolve_api_key(api_key) @@ -267,7 +277,7 @@ def init_assembly( runtime_client=runtime_client, agent_id=resolved_agent_id, enforcement_mode=enforcement_mode, - gateway_endpoint=resolve_gateway_grpc_endpoint(gateway_url), + gateway_endpoint=resolve_gateway_grpc_endpoint(gateway_url, allow_insecure=allow_insecure), native_available=native_available, team_id=team_id, parent_agent_id=parent_agent_id, diff --git a/test/unit/core/test_init_registration.py b/test/unit/core/test_init_registration.py index 7525f4a..b33325f 100644 --- a/test/unit/core/test_init_registration.py +++ b/test/unit/core/test_init_registration.py @@ -513,6 +513,61 @@ def test_enforce_mode_propagates_register_failure(monkeypatch: pytest.MonkeyPatc ) +# A non-loopback gateway host reached over plaintext http:// — the register +# endpoint TLS guard (AAASM-4655) fail-closes this by default, and the +# allow_insecure opt-in (AAASM-4664) is what lets it through. +_INSECURE_GW_URL = "http://gateway.test" +_INSECURE_GRPC_ENDPOINT = "http://gateway.test:50051" + + +def test_init_refuses_plaintext_nonloopback_register_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Omitting ``allow_insecure`` keeps init fail-closed: a plaintext http:// + register channel to a non-loopback host is refused (AAASM-4664 preserves the + secure-by-default guard from AAASM-4655).""" + runtime_client = FakeRuntimeClient(decision="allow") + install_fake_core(monkeypatch, runtime_client) + _no_network(monkeypatch) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + + with pytest.raises(ConfigurationError, match="Failed to initialize assembly runtime"): + init_assembly( + gateway_url=_INSECURE_GW_URL, + api_key=_API_KEY, + agent_id="agent-insecure-default", + mode="sdk-only", + ) + # The guard fires before register is dialed, so no registration happened. + assert runtime_client.register_calls == [] + + +def test_init_allow_insecure_permits_plaintext_nonloopback_register( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``allow_insecure=True`` threads through to + ``resolve_gateway_grpc_endpoint`` and opts into a plaintext http:// register + channel to a non-loopback host (trusted-network only, AAASM-4664).""" + runtime_client = FakeRuntimeClient(decision="allow") + install_fake_core(monkeypatch, runtime_client) + _no_network(monkeypatch) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + + context = init_assembly( + gateway_url=_INSECURE_GW_URL, + api_key=_API_KEY, + agent_id="agent-insecure-optin", + mode="sdk-only", + allow_insecure=True, + ) + try: + assert runtime_client.register_calls == [ + ("agent-insecure-optin", "agent-insecure-optin", "python", _INSECURE_GRPC_ENDPOINT, None, None) + ] + finally: + context.shutdown() + + def _patched_register_adapters(adapter: _CapturingAdapter) -> object: """Build a stand-in for ``_register_adapters`` that drives the real interceptor builder and registers the capturing adapter with it."""