From 9c8fdfe057fce42c9067d4c032d9c4f9797bd7ea Mon Sep 17 00:00:00 2001 From: Bryant Date: Wed, 15 Jul 2026 20:24:48 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=94=92=20(core):=20Enforce=20non-loop?= =?UTF-8?q?back=20TLS=20on=20register=20endpoint=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_gateway_grpc_endpoint derived a plaintext http:// register target for a non-loopback gateway with no refusal, unlike the op-control path (require_secure_grpc_target) and the go-sdk. The Register call carries the agent identity, so mirror op-control's non-loopback→TLS contract: refuse a derived plaintext endpoint to a remote host unless allow_insecure is set. https:// and loopback still pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HMnPRm9T3fdrS4uNYXkzg6 --- agent_assembly/core/gateway_resolver.py | 28 ++++++++++++++++++++++--- test/unit/core/test_gateway_resolver.py | 9 ++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/agent_assembly/core/gateway_resolver.py b/agent_assembly/core/gateway_resolver.py index bc4cfec..50c10d1 100644 --- a/agent_assembly/core/gateway_resolver.py +++ b/agent_assembly/core/gateway_resolver.py @@ -30,6 +30,7 @@ import httpx +from agent_assembly.core.transport_security import require_secure_grpc_target from agent_assembly.exceptions import ConfigurationError, GatewayError logger = logging.getLogger(__name__) @@ -235,7 +236,7 @@ def resolve_gateway_url(explicit: str | None = None) -> str: return DEFAULT_GATEWAY_URL -def resolve_gateway_grpc_endpoint(gateway_url: str | None = None) -> str: +def resolve_gateway_grpc_endpoint(gateway_url: str | None = None, *, allow_insecure: bool = False) -> str: """Resolve the gRPC endpoint the native ``register`` call should dial. Agent registration goes over the gateway's **gRPC** port (:50051), which is a @@ -248,15 +249,30 @@ def resolve_gateway_grpc_endpoint(gateway_url: str | None = None) -> str: 1. ``AA_GATEWAY_ENDPOINT`` — explicit operator override, honoured verbatim (the same env var the native ``aa-sdk-client`` resolver reads, so a - value set for one path applies to both). + value set for one path applies to both). As an explicit operator + escape hatch — the ``channel_factory`` analogue of the op-control path + (``op_control.py``) — it bypasses the TLS guard below; the operator has + taken responsibility for the transport they named. 2. The resolved REST ``gateway_url``'s host with the gRPC port (:50051) substituted — so a non-loopback gateway host registers against *that* host's gRPC port rather than always ``127.0.0.1``. 3. The loopback default ``http://127.0.0.1:50051``. + A **derived** (branch 2) plaintext ``http://`` endpoint to a non-loopback host + is refused, mirroring the op-control stream's non-loopback→TLS contract + (``require_secure_grpc_target``, AAASM-3685): the ``Register`` call carries the + agent identity, so it must not travel unencrypted to a remote host by default. + ``https://`` (TLS) and loopback targets always pass; pass ``allow_insecure`` to + opt into plaintext to a non-loopback host (loopback dev / trusted-network only), + exactly as op-control's ``connect`` does (AAASM-4655). + :param gateway_url: The already-resolved REST gateway URL, used only for its host. When it carries no usable host the loopback default is returned. + :param allow_insecure: Opt into a plaintext (non-TLS) register channel to a + non-loopback host. Defaults to ``False`` (fail-closed). :returns: A gRPC endpoint URL (e.g. ``http://127.0.0.1:50051``). + :raises ValueError: When the derived endpoint is plaintext ``http://`` to a + non-loopback host and ``allow_insecure`` is not set. """ env_value = os.environ.get(ENV_GATEWAY_ENDPOINT) if env_value: @@ -266,7 +282,13 @@ def resolve_gateway_grpc_endpoint(gateway_url: str | None = None) -> str: parsed = urlparse(gateway_url) if parsed.hostname: scheme = parsed.scheme or "http" - return f"{scheme}://{parsed.hostname}:{DEFAULT_GRPC_PORT}" + endpoint = f"{scheme}://{parsed.hostname}:{DEFAULT_GRPC_PORT}" + # Only plaintext http:// needs the guard; https:// carries its own TLS + # and loopback is the documented dev default (both pass through + # require_secure_grpc_target unconditionally). + if scheme == "http": + require_secure_grpc_target(endpoint, allow_insecure=allow_insecure) + return endpoint return DEFAULT_GRPC_ENDPOINT diff --git a/test/unit/core/test_gateway_resolver.py b/test/unit/core/test_gateway_resolver.py index 2a8c474..e01d954 100644 --- a/test/unit/core/test_gateway_resolver.py +++ b/test/unit/core/test_gateway_resolver.py @@ -315,8 +315,13 @@ def test_env_override_wins_verbatim(self, monkeypatch: pytest.MonkeyPatch) -> No def test_derives_host_and_substitutes_grpc_port(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_ENDPOINT, raising=False) # The REST :7391 URL becomes the same host on the gRPC :50051 port — not - # the REST port, which exposes no AgentLifecycleService. - assert gateway_resolver.resolve_gateway_grpc_endpoint("http://gw.example:7391") == "http://gw.example:50051" + # the REST port, which exposes no AgentLifecycleService. A non-loopback + # plaintext http:// target now requires the allow_insecure opt-in + # (AAASM-4655); the port-substitution behaviour itself is unchanged. + assert ( + gateway_resolver.resolve_gateway_grpc_endpoint("http://gw.example:7391", allow_insecure=True) + == "http://gw.example:50051" + ) def test_preserves_non_loopback_host(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_ENDPOINT, raising=False) From f5213e30eea1802775d8da7d60d3e4c52e5f974b Mon Sep 17 00:00:00 2001 From: Bryant Date: Wed, 15 Jul 2026 20:25:49 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=85=20(core):=20Add=20register-endpoi?= =?UTF-8?q?nt=20TLS=20guard=20regression=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover resolve_gateway_grpc_endpoint's non-loopback TLS guard: loopback plaintext allowed, non-loopback plaintext allowed with allow_insecure, and non-loopback plaintext without opt-in raises the same secure-transport error op_control raises via require_secure_grpc_target. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HMnPRm9T3fdrS4uNYXkzg6 --- test/unit/core/test_gateway_resolver.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/unit/core/test_gateway_resolver.py b/test/unit/core/test_gateway_resolver.py index e01d954..87f15df 100644 --- a/test/unit/core/test_gateway_resolver.py +++ b/test/unit/core/test_gateway_resolver.py @@ -335,3 +335,27 @@ def test_falls_back_to_loopback_default_when_no_host(self, monkeypatch: pytest.M monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_ENDPOINT, raising=False) assert gateway_resolver.resolve_gateway_grpc_endpoint(None) == gateway_resolver.DEFAULT_GRPC_ENDPOINT assert gateway_resolver.resolve_gateway_grpc_endpoint("") == gateway_resolver.DEFAULT_GRPC_ENDPOINT + + +class TestResolveGatewayGrpcEndpointTlsGuard: + """The register endpoint mirrors op-control's non-loopback→TLS contract + (``require_secure_grpc_target``): a derived plaintext http:// target to a + non-loopback host is refused unless ``allow_insecure`` opts in (AAASM-4655).""" + + def test_loopback_plaintext_allowed(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_ENDPOINT, raising=False) + # Loopback is the documented dev default — plaintext stays allowed. + assert gateway_resolver.resolve_gateway_grpc_endpoint("http://localhost:7391") == "http://localhost:50051" + + def test_non_loopback_plaintext_allowed_with_opt_in(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_ENDPOINT, raising=False) + assert ( + gateway_resolver.resolve_gateway_grpc_endpoint("http://gw.prod.example:7391", allow_insecure=True) + == "http://gw.prod.example:50051" + ) + + def test_non_loopback_plaintext_rejected_without_opt_in(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_ENDPOINT, raising=False) + # Same secure-transport refusal op_control raises via require_secure_grpc_target. + with pytest.raises(ValueError, match="insecure"): + gateway_resolver.resolve_gateway_grpc_endpoint("http://gw.prod.example:7391") From 94bf9aeba0d373aec50fbb5539f771776935d257 Mon Sep 17 00:00:00 2001 From: Bryant Date: Wed, 15 Jul 2026 20:59:06 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=85=20(core):=20Reconcile=20init=5Fas?= =?UTF-8?q?sembly=20tests=20with=20register=20TLS=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AAASM-4655 register-endpoint TLS guard fail-closes a plaintext http:// register channel to a non-loopback host. Existing tests that used a non-loopback plaintext gateway as an incidental fixture now hit the guard. Preserve each test's intent: switch remote-host fixtures to https:// (still a non-loopback host, so the host→gRPC-port substitution stays exercised), and assert the plaintext-warning test now fail-closes after its warning fires. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HMnPRm9T3fdrS4uNYXkzg6 --- test/unit/core/test_init_registration.py | 9 +++++++-- test/unit/core/test_spawn_context.py | 6 +++--- test/unit/test_assembly.py | 21 +++++++++++++-------- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/test/unit/core/test_init_registration.py b/test/unit/core/test_init_registration.py index 90b7636..7525f4a 100644 --- a/test/unit/core/test_init_registration.py +++ b/test/unit/core/test_init_registration.py @@ -25,12 +25,17 @@ install_fake_core_with_connect, ) -_GW_URL = "http://gateway.test" +# A non-loopback gateway host over TLS (https) — remote gateways must be +# encrypted, since the register-endpoint TLS guard (AAASM-4655) fail-closes a +# plaintext http:// register channel to a non-loopback host. https keeps this a +# genuine non-loopback host (so the host→gRPC-port substitution below is still +# exercised) while passing the guard as correct remote usage. +_GW_URL = "https://gateway.test" _API_KEY = "test-key" # The gRPC register endpoint derived from _GW_URL's host with the gateway gRPC # port (:50051) substituted for the REST port — see resolve_gateway_grpc_endpoint # (AAASM-4547). register_agent forwards this as the 4th positional argument. -_GRPC_ENDPOINT = "http://gateway.test:50051" +_GRPC_ENDPOINT = "https://gateway.test:50051" class _CapturingAdapter(FrameworkAdapter): diff --git a/test/unit/core/test_spawn_context.py b/test/unit/core/test_spawn_context.py index d576596..9924655 100644 --- a/test/unit/core/test_spawn_context.py +++ b/test/unit/core/test_spawn_context.py @@ -83,7 +83,7 @@ def fake_gateway_client(**kwargs: Any) -> Any: spawn_context_scope(spawn_ctx), ): ctx = assembly.init_assembly( - gateway_url="http://gw", + gateway_url="https://gw", api_key="key", agent_id="child-agent", mode="sdk-only", @@ -120,7 +120,7 @@ def fake_gateway_client(**kwargs: Any) -> Any: spawn_context_scope(spawn_ctx), ): ctx = assembly.init_assembly( - gateway_url="http://gw", + gateway_url="https://gw", api_key="key", agent_id="child", mode="sdk-only", @@ -154,7 +154,7 @@ def fake_gateway_client(**kwargs: Any) -> Any: patch("agent_assembly.core.assembly._start_network_layer", return_value=("sdk-only", lambda: None)), ): ctx = assembly.init_assembly( - gateway_url="http://gw", + gateway_url="https://gw", api_key="key", agent_id="solo-agent", mode="sdk-only", diff --git a/test/unit/test_assembly.py b/test/unit/test_assembly.py index ac7e2d9..a3776e7 100644 --- a/test/unit/test_assembly.py +++ b/test/unit/test_assembly.py @@ -122,12 +122,12 @@ def _fail_auto_start(_url: str = "") -> None: ) context = init_assembly( - gateway_url="http://explicit.gw:9999", + gateway_url="https://explicit.gw:9999", api_key="explicit-key", agent_id="agent-x", ) try: - assert context.client.gateway_url == "http://explicit.gw:9999" + assert context.client.gateway_url == "https://explicit.gw:9999" assert context.client.api_key == "explicit-key" assert context.client.agent_id == "agent-x" finally: @@ -503,7 +503,7 @@ def test_init_assembly_gateway_url_falls_back_to_env_var( monkeypatch.setattr(gateway_resolver, "_probe_healthz", lambda _url: False) monkeypatch.setattr(gateway_resolver, "resolve_gateway_url", lambda explicit=None: explicit or "") monkeypatch.setattr(core_assembly, "resolve_gateway_url", lambda explicit=None: explicit or "") - monkeypatch.setenv(core_assembly.ENV_GATEWAY_URL, "http://env-gateway:7000") + monkeypatch.setenv(core_assembly.ENV_GATEWAY_URL, "https://env-gateway:7000") monkeypatch.delenv(core_assembly.ENV_CONTROL_PLANE_URL, raising=False) monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) monkeypatch.setattr( @@ -515,7 +515,7 @@ def test_init_assembly_gateway_url_falls_back_to_env_var( context = init_assembly(api_key="test-api-key", agent_id="env-gw-agent") try: - assert context.client.gateway_url == "http://env-gateway:7000" + assert context.client.gateway_url == "https://env-gateway:7000" finally: context.shutdown() @@ -660,7 +660,13 @@ def test_init_assembly_enforcement_mode_defaults_to_none_to_preserve_wire_shape( def test_init_assembly_warns_on_plaintext_http_with_api_key( monkeypatch: pytest.MonkeyPatch, ) -> None: - """AAASM-3725: a resolved non-loopback http:// gateway + API key warns.""" + """AAASM-3725: a resolved non-loopback http:// gateway + API key warns. + + The early AAASM-3725 warning still fires for the plaintext non-loopback + gateway, but the AAASM-4655 register-endpoint TLS guard now fail-closes on + that same plaintext non-loopback target, so ``init_assembly`` raises after + the warning is emitted. + """ monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: []) monkeypatch.setattr( core_assembly, @@ -668,13 +674,12 @@ def test_init_assembly_warns_on_plaintext_http_with_api_key( lambda **kwargs: ("sdk-only", core_assembly._noop_shutdown), ) - with pytest.warns(UserWarning, match="unencrypted"): - context = init_assembly( + with pytest.warns(UserWarning, match="unencrypted"), pytest.raises(ConfigurationError): + init_assembly( gateway_url="http://gw.remote:9999", api_key="explicit-key", agent_id="agent-warn", ) - context.shutdown() def test_init_assembly_no_warning_for_loopback_http_with_api_key(