Skip to content

Commit 692d3bb

Browse files
authored
Merge pull request #181 from ai-agent-assembly/v0.0.1/AAASM-3685/transport_tls_hardening
[AAASM-3685] 🔒 (transport): Require TLS for non-loopback gateway/op-control
2 parents b507dd2 + d9ea5a6 commit 692d3bb

8 files changed

Lines changed: 408 additions & 3 deletions

File tree

agent_assembly/client/gateway.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import httpx
88

99
from agent_assembly.client.dispatch import DispatchToolResult
10+
from agent_assembly.core.transport_security import require_secure_http_url
1011
from agent_assembly.exceptions import GatewayError
1112

1213

@@ -27,6 +28,7 @@ def __init__(
2728
spawned_by_tool: str | None = None,
2829
depth: int | None = None,
2930
enforcement_mode: str | None = None,
31+
allow_insecure: bool = False,
3032
) -> None:
3133
"""
3234
Initialize the GatewayClient.
@@ -51,6 +53,12 @@ def __init__(
5153
``None`` (the default) lets the gateway apply its server-side
5254
default of live enforcement. Stored for reference; registration
5355
itself now goes through the native gRPC path (ADR 0004).
56+
allow_insecure: Permit sending the ``Authorization: Bearer`` API key
57+
over plaintext ``http://`` to a **non-loopback** host. Off by
58+
default — a remote ``http://`` base URL with an API key set is
59+
refused with :class:`ValueError`, because the Bearer credential
60+
would travel unencrypted (AAASM-3725). Loopback ``http://`` is
61+
always allowed (local dev gateway).
5462
"""
5563
self.gateway_url = gateway_url.rstrip("/")
5664
self.control_plane_url = control_plane_url.rstrip("/") if control_plane_url else None
@@ -63,6 +71,7 @@ def __init__(
6371
self.spawned_by_tool = spawned_by_tool
6472
self.depth = depth
6573
self.enforcement_mode = enforcement_mode
74+
self.allow_insecure = allow_insecure
6675
self._client: httpx.Client | None = None
6776

6877
@property
@@ -73,11 +82,17 @@ def client(self) -> httpx.Client:
7382
it falls back to ``gateway_url`` (single-host OSS dev).
7483
"""
7584
if self._client is None:
85+
base_url = self.control_plane_url or self.gateway_url
86+
require_secure_http_url(
87+
base_url,
88+
has_api_key=bool(self.api_key),
89+
allow_insecure=self.allow_insecure,
90+
)
7691
headers = {}
7792
if self.api_key:
7893
headers["Authorization"] = f"Bearer {self.api_key}"
7994
self._client = httpx.Client(
80-
base_url=self.control_plane_url or self.gateway_url,
95+
base_url=base_url,
8196
headers=headers,
8297
timeout=self.timeout,
8398
)

agent_assembly/core/assembly.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
register_agent,
2222
)
2323
from agent_assembly.core.spawn import _SPAWN_CTX
24+
from agent_assembly.core.transport_security import warn_if_insecure_http_url
2425
from agent_assembly.exceptions import AssemblyError, ConfigurationError
2526

2627
RuntimeMode = Literal["auto", "ebpf", "proxy", "sdk-only"]
@@ -173,6 +174,10 @@ def init_assembly(
173174
"""
174175
gateway_url = resolve_gateway_url(gateway_url)
175176
api_key = resolve_api_key(api_key)
177+
# Warn early when the resolved gateway would carry the Bearer API key over
178+
# plaintext http:// to a non-loopback host (AAASM-3725). The control-plane
179+
# URL is the host the credential is actually sent to when set.
180+
warn_if_insecure_http_url(control_plane_url or gateway_url, has_api_key=bool(api_key))
176181
gateway_url, control_plane_url = _validate_inputs(
177182
gateway_url=gateway_url,
178183
mode=mode,
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Shared transport-security validation for gateway connections (AAASM-3685/3725).
2+
3+
The SDK reaches the governance gateway over two transports that both carry
4+
sensitive material:
5+
6+
* the op-control gRPC stream (``op_control.py``), which carries the agent
7+
identity triple and operator pause / terminate signals;
8+
* the control-plane HTTP API (``client/gateway.py``), which sends the API key
9+
as an ``Authorization: Bearer`` header.
10+
11+
Neither must travel unencrypted to a **non-loopback** host without an explicit
12+
opt-in. A loopback target is the local dev-mode gateway, where plaintext is the
13+
documented default; anything else is presumed remote and must be encrypted.
14+
15+
This module centralises the loopback decision and the two enforcement helpers
16+
so the gRPC and HTTP paths agree on what "secure" means (mirrors node-sdk
17+
AAASM-3123).
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import warnings
23+
from urllib.parse import urlsplit
24+
25+
__all__ = [
26+
"LOOPBACK_HOSTS",
27+
"gateway_host_of",
28+
"is_loopback_target",
29+
"require_secure_grpc_target",
30+
"require_secure_http_url",
31+
"warn_if_insecure_http_url",
32+
]
33+
34+
# Hosts treated as loopback for the secure-by-default transport decision.
35+
# A loopback gateway is the local dev-mode CP, where plaintext is the documented
36+
# default; anything else is presumed remote and must be encrypted.
37+
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
38+
39+
40+
def gateway_host_of(gateway_url: str) -> str:
41+
"""Extract the bare host from a gateway target.
42+
43+
Accepts a gRPC ``host:port`` target, a bare host, or a full URL with a
44+
scheme (``http://host:port/path``). IPv6 bracket form (``[::1]:7391``) is
45+
preserved with its brackets so it can be matched against
46+
:data:`LOOPBACK_HOSTS`. The result is lower-cased for case-insensitive
47+
comparison.
48+
"""
49+
target = gateway_url.strip()
50+
51+
# Full URL with a scheme — let urlsplit pull out the host:port authority.
52+
if "://" in target:
53+
target = urlsplit(target).netloc
54+
55+
# Strip any userinfo (``user:pass@host``) — only the host matters here.
56+
if "@" in target:
57+
target = target.rsplit("@", 1)[1]
58+
59+
# Bracketed IPv6 (``[::1]`` or ``[::1]:7391``): keep the brackets, drop the
60+
# trailing ``:port`` that sits *outside* the closing bracket.
61+
if target.startswith("["):
62+
closing = target.find("]")
63+
if closing != -1:
64+
return target[: closing + 1].lower()
65+
return target.lower()
66+
67+
# Bare IPv6 without brackets (more than one colon) — no port to strip.
68+
if target.count(":") > 1:
69+
return target.lower()
70+
71+
# host or host:port — drop the port.
72+
return target.rsplit(":", 1)[0].lower() if ":" in target else target.lower()
73+
74+
75+
def is_loopback_target(gateway_url: str) -> bool:
76+
"""Return True iff ``gateway_url`` points at a loopback host."""
77+
return gateway_host_of(gateway_url) in LOOPBACK_HOSTS
78+
79+
80+
def require_secure_grpc_target(gateway_url: str, *, allow_insecure: bool) -> None:
81+
"""Validate that a plaintext gRPC channel to ``gateway_url`` is permitted.
82+
83+
A loopback target is always allowed (local dev gateway). A non-loopback
84+
target requires the caller to set ``allow_insecure`` — otherwise raise
85+
:class:`ValueError`, because the op-control stream carries the agent
86+
identity and operator control signals.
87+
"""
88+
if is_loopback_target(gateway_url) or allow_insecure:
89+
return
90+
raise ValueError(
91+
f"Refusing to open an insecure (plaintext) gRPC channel to non-loopback "
92+
f"gateway {gateway_url!r}. Use a TLS channel via channel_factory, or pass "
93+
f"allow_insecure=True to explicitly opt in (loopback dev only)."
94+
)
95+
96+
97+
def require_secure_http_url(gateway_url: str, *, has_api_key: bool, allow_insecure: bool) -> None:
98+
"""Validate that an ``http://`` control-plane URL is permitted.
99+
100+
When an API key is set it is sent as a Bearer header, so a plaintext
101+
``http://`` URL to a non-loopback host would leak the credential. Refuse
102+
unless the target is loopback or ``allow_insecure`` is set. ``https://``
103+
URLs (and non-http schemes) always pass.
104+
"""
105+
scheme = urlsplit(gateway_url.strip()).scheme.lower()
106+
if scheme != "http":
107+
return
108+
if not has_api_key:
109+
return
110+
if is_loopback_target(gateway_url) or allow_insecure:
111+
return
112+
raise ValueError(
113+
f"Refusing to send an Authorization: Bearer credential over a plaintext "
114+
f"(non-TLS) connection to non-loopback gateway {gateway_url!r}. Use an "
115+
f"https:// endpoint, or pass allow_insecure=True to explicitly opt in "
116+
f"(loopback dev only)."
117+
)
118+
119+
120+
def warn_if_insecure_http_url(gateway_url: str, *, has_api_key: bool) -> None:
121+
"""Emit a warning when a non-loopback ``http://`` gateway carries a key.
122+
123+
Resolution-time advisory counterpart to :func:`require_secure_http_url`:
124+
the resolver knows the URL and whether a key is set before any request is
125+
issued, so it warns early. The hard refusal still happens later in
126+
``GatewayClient`` (AAASM-3725). ``https://`` and loopback targets are silent.
127+
"""
128+
scheme = urlsplit(gateway_url.strip()).scheme.lower()
129+
if scheme != "http" or not has_api_key or is_loopback_target(gateway_url):
130+
return
131+
warnings.warn(
132+
f"Gateway {gateway_url!r} uses plaintext http:// while an API key is set; "
133+
f"the Authorization: Bearer credential would travel unencrypted. Use "
134+
f"https:// for non-loopback gateways.",
135+
stacklevel=3,
136+
)

agent_assembly/op_control.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
import grpc
4141

42+
from agent_assembly.core.transport_security import require_secure_grpc_target
4243
from agent_assembly.exceptions import OpTerminatedError
4344
from agent_assembly.proto import common_pb2, policy_pb2, policy_pb2_grpc
4445

@@ -102,13 +103,24 @@ def connect(
102103
team_id: str,
103104
agent_id: str,
104105
channel_factory: grpc.Channel | None = None,
106+
allow_insecure: bool = False,
105107
) -> OpControlSubscriber:
106108
"""Open the gRPC channel + subscription stream and start the reader.
107109
108110
``gateway_url`` is the ``host:port`` of the gateway's gRPC endpoint
109111
(no scheme; gRPC uses its own). When ``channel_factory`` is supplied
110-
(tests), it's used instead of opening a fresh insecure channel.
112+
(tests, or a TLS channel built by the caller), it's used verbatim and
113+
the loopback / ``allow_insecure`` check below is bypassed — the caller
114+
has taken responsibility for the transport.
115+
116+
Otherwise a plaintext ``grpc.insecure_channel`` is opened, but only when
117+
the target is loopback (local dev gateway) or ``allow_insecure`` is set.
118+
The op-control stream carries the agent identity triple and operator
119+
pause / terminate signals, so an unencrypted channel to a non-loopback
120+
host is refused with :class:`ValueError` (AAASM-3685).
111121
"""
122+
if channel_factory is None:
123+
require_secure_grpc_target(gateway_url, allow_insecure=allow_insecure)
112124
channel = channel_factory or grpc.insecure_channel(gateway_url)
113125
stub = policy_pb2_grpc.PolicyServiceStub(channel) # type: ignore[no-untyped-call]
114126
proto_agent_id = common_pb2.AgentId(org_id=org_id, team_id=team_id, agent_id=agent_id)

test/unit/client/test_gateway_endpoints.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ def _patch_post(client: GatewayClient, mock_post: MagicMock) -> Any:
4040

4141

4242
def test_http_client_sets_bearer_auth_header_when_api_key_present() -> None:
43-
client = GatewayClient(gateway_url="http://gw.test", agent_id="a", api_key="sekret")
43+
# allow_insecure opts past the AAASM-3725 plaintext-http refusal so this
44+
# test can assert header behavior on a non-loopback http:// base URL.
45+
client = GatewayClient(
46+
gateway_url="http://gw.test", agent_id="a", api_key="sekret", allow_insecure=True
47+
)
4448
try:
4549
assert client.client.headers["Authorization"] == "Bearer sekret"
4650
finally:
@@ -86,3 +90,38 @@ def test_report_edge_raises_gateway_error_on_http_error() -> None:
8690
mock_post = MagicMock(return_value=_raising(httpx.ConnectError("down")))
8791
with _patch_post(client, mock_post), pytest.raises(GatewayError, match="Failed to report edge"):
8892
client.report_edge("src", "dst", "messages")
93+
94+
95+
class TestHttpTransportSecurity:
96+
"""AAASM-3725: refuse Bearer API key over plaintext http to a remote host."""
97+
98+
def test_bearer_over_http_non_loopback_rejected(self) -> None:
99+
with GatewayClient(gateway_url="http://gw.test", agent_id="a", api_key="k") as client:
100+
with pytest.raises(ValueError, match="Bearer"):
101+
_ = client.client
102+
103+
def test_bearer_over_http_loopback_allowed(self) -> None:
104+
with GatewayClient(
105+
gateway_url="http://localhost:7391", agent_id="a", api_key="k"
106+
) as client:
107+
assert client.client.headers["Authorization"] == "Bearer k"
108+
109+
def test_bearer_over_https_non_loopback_allowed(self) -> None:
110+
with GatewayClient(gateway_url="https://gw.test", agent_id="a", api_key="k") as client:
111+
assert client.client.headers["Authorization"] == "Bearer k"
112+
113+
def test_http_non_loopback_without_key_allowed(self) -> None:
114+
with GatewayClient(gateway_url="http://gw.test", agent_id="a") as client:
115+
assert "Authorization" not in client.client.headers
116+
117+
def test_control_plane_url_is_the_validated_target(self) -> None:
118+
# The Bearer header rides the control-plane base URL when set; a remote
119+
# plaintext control-plane URL must be refused even if gateway_url is safe.
120+
with GatewayClient(
121+
gateway_url="https://gw.test",
122+
agent_id="a",
123+
api_key="k",
124+
control_plane_url="http://cp.remote",
125+
) as client:
126+
with pytest.raises(ValueError, match="Bearer"):
127+
_ = client.client
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Tests for the shared transport-security validator (AAASM-3685/3725)."""
2+
3+
from __future__ import annotations
4+
5+
import warnings
6+
7+
import pytest
8+
9+
from agent_assembly.core import transport_security as ts
10+
11+
12+
class TestGatewayHostOf:
13+
@pytest.mark.parametrize(
14+
("target", "expected"),
15+
[
16+
("localhost:7391", "localhost"),
17+
("127.0.0.1:7391", "127.0.0.1"),
18+
("http://localhost:7391", "localhost"),
19+
("https://gw.prod.example/path", "gw.prod.example"),
20+
("gw.prod.example:443", "gw.prod.example"),
21+
("[::1]:7391", "[::1]"),
22+
("[::1]", "[::1]"),
23+
("user:pass@gw.prod.example:443", "gw.prod.example"),
24+
("LOCALHOST:7391", "localhost"),
25+
("bare-host", "bare-host"),
26+
],
27+
)
28+
def test_extracts_host(self, target: str, expected: str) -> None:
29+
assert ts.gateway_host_of(target) == expected
30+
31+
32+
class TestIsLoopbackTarget:
33+
@pytest.mark.parametrize(
34+
"target",
35+
["localhost:7391", "127.0.0.1:7391", "http://localhost:7391", "[::1]:7391", "::1"],
36+
)
37+
def test_loopback(self, target: str) -> None:
38+
assert ts.is_loopback_target(target) is True
39+
40+
@pytest.mark.parametrize(
41+
"target",
42+
["gw.prod.example:443", "http://gw.test", "10.0.0.5:7391", "example.com"],
43+
)
44+
def test_non_loopback(self, target: str) -> None:
45+
assert ts.is_loopback_target(target) is False
46+
47+
48+
class TestRequireSecureGrpcTarget:
49+
def test_loopback_allowed(self) -> None:
50+
ts.require_secure_grpc_target("localhost:7391", allow_insecure=False)
51+
52+
def test_non_loopback_rejected_without_opt_in(self) -> None:
53+
with pytest.raises(ValueError, match="insecure"):
54+
ts.require_secure_grpc_target("gw.prod.example:443", allow_insecure=False)
55+
56+
def test_non_loopback_allowed_with_opt_in(self) -> None:
57+
ts.require_secure_grpc_target("gw.prod.example:443", allow_insecure=True)
58+
59+
60+
class TestRequireSecureHttpUrl:
61+
def test_https_always_allowed(self) -> None:
62+
ts.require_secure_http_url("https://gw.test", has_api_key=True, allow_insecure=False)
63+
64+
def test_http_no_key_allowed(self) -> None:
65+
ts.require_secure_http_url("http://gw.test", has_api_key=False, allow_insecure=False)
66+
67+
def test_loopback_http_with_key_allowed(self) -> None:
68+
ts.require_secure_http_url("http://localhost:7391", has_api_key=True, allow_insecure=False)
69+
70+
def test_non_loopback_http_with_key_rejected(self) -> None:
71+
with pytest.raises(ValueError, match="Bearer"):
72+
ts.require_secure_http_url("http://gw.test", has_api_key=True, allow_insecure=False)
73+
74+
def test_non_loopback_http_with_key_allowed_when_opted_in(self) -> None:
75+
ts.require_secure_http_url("http://gw.test", has_api_key=True, allow_insecure=True)
76+
77+
78+
class TestWarnIfInsecureHttpUrl:
79+
def test_warns_on_non_loopback_http_with_key(self) -> None:
80+
with pytest.warns(UserWarning, match="unencrypted"):
81+
ts.warn_if_insecure_http_url("http://gw.test", has_api_key=True)
82+
83+
@pytest.mark.parametrize(
84+
("url", "has_key"),
85+
[
86+
("https://gw.test", True),
87+
("http://gw.test", False),
88+
("http://localhost:7391", True),
89+
],
90+
)
91+
def test_silent_otherwise(self, url: str, has_key: bool) -> None:
92+
with warnings.catch_warnings():
93+
warnings.simplefilter("error")
94+
ts.warn_if_insecure_http_url(url, has_api_key=has_key)

0 commit comments

Comments
 (0)