From 0b6dd8734633170997ca3cf72c2716075e2e749e Mon Sep 17 00:00:00 2001 From: Bryant Date: Sun, 5 Jul 2026 00:02:59 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=92=20(transport):=20Enforce=20sec?= =?UTF-8?q?ure=20control-plane=20URL=20on=20non-loopback=20regardless=20of?= =?UTF-8?q?=20API=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit require_secure_http_url / warn_if_insecure_http_url early-returned when no API key was set, so dispatch_tool (resolved secrets) and report_edge (topology metadata) could transit plaintext http:// to a non-loopback host undetected. Gate the guard on non-loopback regardless of has_api_key; the key now only sharpens the error/warning message. Updates the transport and gateway-client contract tests that used a non-loopback plaintext http:// fixture (opting past the guard with allow_insecure where the test's intent is base-URL/header behaviour, not transport security), and adds coverage for the keyless refusal/warning. Refs AAASM-4136 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01R7vqjjo5nrebYNt8WnCNbz --- agent_assembly/core/transport_security.py | 61 +++++++++++++------ .../unit/client/test_gateway_control_plane.py | 7 ++- test/unit/client/test_gateway_endpoints.py | 28 +++++---- test/unit/core/test_transport_security.py | 17 +++++- 4 files changed, 80 insertions(+), 33 deletions(-) diff --git a/agent_assembly/core/transport_security.py b/agent_assembly/core/transport_security.py index 18808eea..7f2a1e33 100644 --- a/agent_assembly/core/transport_security.py +++ b/agent_assembly/core/transport_security.py @@ -6,7 +6,9 @@ * the op-control gRPC stream (``op_control.py``), which carries the agent identity triple and operator pause / terminate signals; * the control-plane HTTP API (``client/gateway.py``), which sends the API key - as an ``Authorization: Bearer`` header. + as an ``Authorization: Bearer`` header and also carries resolved credential + values (``dispatch_tool``) and topology metadata (``report_edge``) — so the + channel is sensitive even when no API key is set. Neither must travel unencrypted to a **non-loopback** host without an explicit opt-in. A loopback target is the local dev-mode gateway, where plaintext is the @@ -97,40 +99,63 @@ def require_secure_grpc_target(gateway_url: str, *, allow_insecure: bool) -> Non def require_secure_http_url(gateway_url: str, *, has_api_key: bool, allow_insecure: bool) -> None: """Validate that an ``http://`` control-plane URL is permitted. - When an API key is set it is sent as a Bearer header, so a plaintext - ``http://`` URL to a non-loopback host would leak the credential. Refuse - unless the target is loopback or ``allow_insecure`` is set. ``https://`` - URLs (and non-http schemes) always pass. + The control-plane channel carries sensitive material even when no API key + is set: ``dispatch_tool`` returns resolved credential values and + ``report_edge`` posts topology metadata. A Bearer credential (when a key + *is* set) is merely the most obvious secret. A plaintext ``http://`` URL to + a non-loopback host would therefore leak whichever of these flow, so the + guard refuses regardless of ``has_api_key``; ``has_api_key`` only sharpens + the error message. Refuse unless the target is loopback or + ``allow_insecure`` is set. ``https://`` URLs (and non-http schemes) always + pass. """ scheme = urlsplit(gateway_url.strip()).scheme.lower() if scheme != "http": return - if not has_api_key: - return if is_loopback_target(gateway_url) or allow_insecure: return + if has_api_key: + raise ValueError( + f"Refusing to send an Authorization: Bearer credential over a plaintext " + f"(non-TLS) connection to non-loopback gateway {gateway_url!r}. Use an " + f"https:// endpoint, or pass allow_insecure=True to explicitly opt in " + f"(loopback dev only)." + ) raise ValueError( - f"Refusing to send an Authorization: Bearer credential over a plaintext " - f"(non-TLS) connection to non-loopback gateway {gateway_url!r}. Use an " - f"https:// endpoint, or pass allow_insecure=True to explicitly opt in " - f"(loopback dev only)." + f"Refusing to open a plaintext (non-TLS) control-plane connection to " + f"non-loopback gateway {gateway_url!r}. The control-plane channel carries " + f"resolved credentials and topology metadata that would travel unencrypted " + f"even without an API key. Use an https:// endpoint, or pass " + f"allow_insecure=True to explicitly opt in (loopback dev only)." ) def warn_if_insecure_http_url(gateway_url: str, *, has_api_key: bool) -> None: - """Emit a warning when a non-loopback ``http://`` gateway carries a key. + """Emit a warning when a non-loopback ``http://`` gateway is used. Resolution-time advisory counterpart to :func:`require_secure_http_url`: the resolver knows the URL and whether a key is set before any request is - issued, so it warns early. The hard refusal still happens later in - ``GatewayClient`` (AAASM-3725). ``https://`` and loopback targets are silent. + issued, so it warns early. Because the control-plane channel carries + resolved credentials and topology metadata even without an API key, the + warning fires for any non-loopback plaintext ``http://`` target regardless + of ``has_api_key`` — the key only sharpens the message. The hard refusal + still happens later in ``GatewayClient`` (AAASM-3725). ``https://`` and + loopback targets are silent. """ scheme = urlsplit(gateway_url.strip()).scheme.lower() - if scheme != "http" or not has_api_key or is_loopback_target(gateway_url): + if scheme != "http" or is_loopback_target(gateway_url): + return + if has_api_key: + warnings.warn( + f"Gateway {gateway_url!r} uses plaintext http:// while an API key is set; " + f"the Authorization: Bearer credential would travel unencrypted. Use " + f"https:// for non-loopback gateways.", + stacklevel=3, + ) return warnings.warn( - f"Gateway {gateway_url!r} uses plaintext http:// while an API key is set; " - f"the Authorization: Bearer credential would travel unencrypted. Use " - f"https:// for non-loopback gateways.", + f"Gateway {gateway_url!r} uses plaintext http:// to a non-loopback host; " + f"control-plane payloads (resolved credentials, topology metadata) would " + f"travel unencrypted. Use https:// for non-loopback gateways.", stacklevel=3, ) diff --git a/test/unit/client/test_gateway_control_plane.py b/test/unit/client/test_gateway_control_plane.py index 1641b02f..f178fb89 100644 --- a/test/unit/client/test_gateway_control_plane.py +++ b/test/unit/client/test_gateway_control_plane.py @@ -23,15 +23,20 @@ def test_control_plane_url_is_stored_and_trimmed() -> None: def test_http_client_base_url_falls_back_to_gateway_url() -> None: """Without control_plane_url the httpx base URL is the gateway URL.""" - with GatewayClient(gateway_url="http://gw.test", agent_id="a") as client: + # allow_insecure opts past the AAASM-4136 plaintext-http refusal so this + # test can assert base-URL routing on a non-loopback http:// target. + with GatewayClient(gateway_url="http://gw.test", agent_id="a", allow_insecure=True) as client: assert str(client.client.base_url) == "http://gw.test" def test_http_client_base_url_uses_control_plane_url_when_set() -> None: """When control_plane_url is set, HTTP routes target it, not gateway_url.""" + # allow_insecure opts past the AAASM-4136 plaintext-http refusal so this + # test can assert base-URL routing on a non-loopback http:// target. with GatewayClient( gateway_url="http://gw.test", agent_id="a", control_plane_url="http://cp.test", + allow_insecure=True, ) as client: assert str(client.client.base_url) == "http://cp.test" diff --git a/test/unit/client/test_gateway_endpoints.py b/test/unit/client/test_gateway_endpoints.py index 1c86a247..93294f05 100644 --- a/test/unit/client/test_gateway_endpoints.py +++ b/test/unit/client/test_gateway_endpoints.py @@ -42,9 +42,7 @@ def _patch_post(client: GatewayClient, mock_post: MagicMock) -> Any: def test_http_client_sets_bearer_auth_header_when_api_key_present() -> None: # allow_insecure opts past the AAASM-3725 plaintext-http refusal so this # test can assert header behavior on a non-loopback http:// base URL. - client = GatewayClient( - gateway_url="http://gw.test", agent_id="a", api_key="sekret", allow_insecure=True - ) + client = GatewayClient(gateway_url="http://gw.test", agent_id="a", api_key="sekret", allow_insecure=True) try: assert client.client.headers["Authorization"] == "Bearer sekret" finally: @@ -52,7 +50,9 @@ def test_http_client_sets_bearer_auth_header_when_api_key_present() -> None: def test_http_client_omits_auth_header_when_no_api_key() -> None: - client = GatewayClient(gateway_url="http://gw.test", agent_id="a") + # allow_insecure opts past the AAASM-4136 plaintext-http refusal so this + # test can assert header omission on a non-loopback http:// base URL. + client = GatewayClient(gateway_url="http://gw.test", agent_id="a", allow_insecure=True) try: assert "Authorization" not in client.client.headers finally: @@ -93,7 +93,12 @@ def test_report_edge_raises_gateway_error_on_http_error() -> None: class TestHttpTransportSecurity: - """AAASM-3725: refuse Bearer API key over plaintext http to a remote host.""" + """AAASM-3725/4136: refuse plaintext http control-plane to a remote host. + + A Bearer API key over plaintext is the sharpest case, but the control-plane + channel carries resolved credentials and topology metadata even with no + key, so a non-loopback http:// target is refused regardless (AAASM-4136). + """ def test_bearer_over_http_non_loopback_rejected(self) -> None: with GatewayClient(gateway_url="http://gw.test", agent_id="a", api_key="k") as client: @@ -101,18 +106,19 @@ def test_bearer_over_http_non_loopback_rejected(self) -> None: _ = client.client def test_bearer_over_http_loopback_allowed(self) -> None: - with GatewayClient( - gateway_url="http://localhost:7391", agent_id="a", api_key="k" - ) as client: + with GatewayClient(gateway_url="http://localhost:7391", agent_id="a", api_key="k") as client: assert client.client.headers["Authorization"] == "Bearer k" def test_bearer_over_https_non_loopback_allowed(self) -> None: with GatewayClient(gateway_url="https://gw.test", agent_id="a", api_key="k") as client: assert client.client.headers["Authorization"] == "Bearer k" - def test_http_non_loopback_without_key_allowed(self) -> None: - with GatewayClient(gateway_url="http://gw.test", agent_id="a") as client: - assert "Authorization" not in client.client.headers + def test_http_non_loopback_without_key_rejected(self) -> None: + with ( + GatewayClient(gateway_url="http://gw.test", agent_id="a") as client, + pytest.raises(ValueError, match="control-plane"), + ): + _ = client.client def test_control_plane_url_is_the_validated_target(self) -> None: # The Bearer header rides the control-plane base URL when set; a remote diff --git a/test/unit/core/test_transport_security.py b/test/unit/core/test_transport_security.py index a63099ac..932266c6 100644 --- a/test/unit/core/test_transport_security.py +++ b/test/unit/core/test_transport_security.py @@ -61,8 +61,12 @@ class TestRequireSecureHttpUrl: def test_https_always_allowed(self) -> None: ts.require_secure_http_url("https://gw.test", has_api_key=True, allow_insecure=False) - def test_http_no_key_allowed(self) -> None: - ts.require_secure_http_url("http://gw.test", has_api_key=False, allow_insecure=False) + def test_non_loopback_http_no_key_rejected(self) -> None: + # The control-plane channel carries resolved credentials/topology + # metadata even without an API key, so plaintext http:// to a + # non-loopback host is refused regardless of has_api_key (AAASM-4136). + with pytest.raises(ValueError, match="control-plane"): + ts.require_secure_http_url("http://gw.test", has_api_key=False, allow_insecure=False) def test_loopback_http_with_key_allowed(self) -> None: ts.require_secure_http_url("http://localhost:7391", has_api_key=True, allow_insecure=False) @@ -80,12 +84,19 @@ def test_warns_on_non_loopback_http_with_key(self) -> None: with pytest.warns(UserWarning, match="unencrypted"): ts.warn_if_insecure_http_url("http://gw.test", has_api_key=True) + def test_warns_on_non_loopback_http_without_key(self) -> None: + # The control-plane channel carries resolved credentials/topology + # metadata even with no API key, so the advisory still fires + # (AAASM-4136). + with pytest.warns(UserWarning, match="unencrypted"): + ts.warn_if_insecure_http_url("http://gw.test", has_api_key=False) + @pytest.mark.parametrize( ("url", "has_key"), [ ("https://gw.test", True), - ("http://gw.test", False), ("http://localhost:7391", True), + ("http://localhost:7391", False), ], ) def test_silent_otherwise(self, url: str, has_key: bool) -> None: From ff194f2c6126f96f57fd002625f3e13993995923 Mon Sep 17 00:00:00 2001 From: Bryant Date: Sun, 5 Jul 2026 00:03:16 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=91=B7=20(ci):=20SHA256-verify=20bund?= =?UTF-8?q?led=20aasm=20sidecar=20before=20staging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four "Stage aasm sidecar binary" steps downloaded, extracted, and chmod'd the aasm-*.tar.gz with no integrity check, while protoc in the same workflow is SHA256-verified. Download the core release's SHA256SUMS and verify the sidecar tarball against it (fail-closed if the asset is absent or mismatched) before bundling. Uses shasum -a 256 for Linux+macOS runner portability. Refs AAASM-4136 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01R7vqjjo5nrebYNt8WnCNbz --- .github/workflows/release-python.yml | 55 ++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index 0ce774f7..1f1ec1ed 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -234,6 +234,22 @@ jobs: # Hard error on missing binary: the operator points binary_source_tag # at a published agent-assembly release whose aasm-* assets exist. gh release download "$AASM_TAG" --repo "$AASM_REPO" --pattern 'aasm-x86_64-unknown-linux-gnu.tar.gz' --dir agent_assembly/bin/ + # SECURITY: verify the downloaded sidecar tarball against the core + # release's published SHA256SUMS before bundling it, mirroring the + # protoc integrity gate in the build step below. Without this, wheel + # integrity rests solely on GitHub-release TLS (AAASM-4136). `shasum + # -a 256` is present on both the Linux and macOS runners; SHA256SUMS + # lists each asset by basename. + gh release download "$AASM_TAG" --repo "$AASM_REPO" --pattern 'SHA256SUMS' --dir agent_assembly/bin/ + EXPECTED_SHA=$(grep ' aasm-x86_64-unknown-linux-gnu.tar.gz$' agent_assembly/bin/SHA256SUMS | awk '{print $1}' || true) + if [[ -z "$EXPECTED_SHA" ]]; then + echo "::error::aasm-x86_64-unknown-linux-gnu.tar.gz is not listed in the core release SHA256SUMS — refusing to bundle"; exit 1 + fi + ACTUAL_SHA=$(shasum -a 256 agent_assembly/bin/aasm-x86_64-unknown-linux-gnu.tar.gz | awk '{print $1}') + if [[ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]]; then + echo "::error::aasm-x86_64-unknown-linux-gnu.tar.gz SHA256 mismatch (expected $EXPECTED_SHA got $ACTUAL_SHA) — refusing to bundle"; exit 1 + fi + rm -f agent_assembly/bin/SHA256SUMS # Tarball contains a single `aasm` binary at the root; # extract in place, then drop the archive. tar -xzf agent_assembly/bin/aasm-x86_64-unknown-linux-gnu.tar.gz -C agent_assembly/bin/ @@ -313,6 +329,19 @@ jobs: # Hard error on missing binary: the operator points binary_source_tag # at a published agent-assembly release whose aasm-* assets exist. gh release download "$AASM_TAG" --repo "$AASM_REPO" --pattern 'aasm-aarch64-unknown-linux-gnu.tar.gz' --dir agent_assembly/bin/ + # SECURITY: SHA256-verify the sidecar tarball against the core + # release's SHA256SUMS before bundling. See linux-x86_64 above for + # the integrity-gate rationale (AAASM-4136). + gh release download "$AASM_TAG" --repo "$AASM_REPO" --pattern 'SHA256SUMS' --dir agent_assembly/bin/ + EXPECTED_SHA=$(grep ' aasm-aarch64-unknown-linux-gnu.tar.gz$' agent_assembly/bin/SHA256SUMS | awk '{print $1}' || true) + if [[ -z "$EXPECTED_SHA" ]]; then + echo "::error::aasm-aarch64-unknown-linux-gnu.tar.gz is not listed in the core release SHA256SUMS — refusing to bundle"; exit 1 + fi + ACTUAL_SHA=$(shasum -a 256 agent_assembly/bin/aasm-aarch64-unknown-linux-gnu.tar.gz | awk '{print $1}') + if [[ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]]; then + echo "::error::aasm-aarch64-unknown-linux-gnu.tar.gz SHA256 mismatch (expected $EXPECTED_SHA got $ACTUAL_SHA) — refusing to bundle"; exit 1 + fi + rm -f agent_assembly/bin/SHA256SUMS # Tarball contains a single `aasm` binary at the root; # extract in place, then drop the archive. tar -xzf agent_assembly/bin/aasm-aarch64-unknown-linux-gnu.tar.gz -C agent_assembly/bin/ @@ -384,6 +413,19 @@ jobs: # Hard error on missing binary: the operator points binary_source_tag # at a published agent-assembly release whose aasm-* assets exist. gh release download "$AASM_TAG" --repo "$AASM_REPO" --pattern 'aasm-aarch64-apple-darwin.tar.gz' --dir agent_assembly/bin/ + # SECURITY: SHA256-verify the sidecar tarball against the core + # release's SHA256SUMS before bundling. See linux-x86_64 above for + # the integrity-gate rationale (AAASM-4136). + gh release download "$AASM_TAG" --repo "$AASM_REPO" --pattern 'SHA256SUMS' --dir agent_assembly/bin/ + EXPECTED_SHA=$(grep ' aasm-aarch64-apple-darwin.tar.gz$' agent_assembly/bin/SHA256SUMS | awk '{print $1}' || true) + if [[ -z "$EXPECTED_SHA" ]]; then + echo "::error::aasm-aarch64-apple-darwin.tar.gz is not listed in the core release SHA256SUMS — refusing to bundle"; exit 1 + fi + ACTUAL_SHA=$(shasum -a 256 agent_assembly/bin/aasm-aarch64-apple-darwin.tar.gz | awk '{print $1}') + if [[ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]]; then + echo "::error::aasm-aarch64-apple-darwin.tar.gz SHA256 mismatch (expected $EXPECTED_SHA got $ACTUAL_SHA) — refusing to bundle"; exit 1 + fi + rm -f agent_assembly/bin/SHA256SUMS # Tarball contains a single `aasm` binary at the root; # extract in place, then drop the archive. tar -xzf agent_assembly/bin/aasm-aarch64-apple-darwin.tar.gz -C agent_assembly/bin/ @@ -438,6 +480,19 @@ jobs: # Hard error on missing binary: the operator points binary_source_tag # at a published agent-assembly release whose aasm-* assets exist. gh release download "$AASM_TAG" --repo "$AASM_REPO" --pattern 'aasm-x86_64-apple-darwin.tar.gz' --dir agent_assembly/bin/ + # SECURITY: SHA256-verify the sidecar tarball against the core + # release's SHA256SUMS before bundling. See linux-x86_64 above for + # the integrity-gate rationale (AAASM-4136). + gh release download "$AASM_TAG" --repo "$AASM_REPO" --pattern 'SHA256SUMS' --dir agent_assembly/bin/ + EXPECTED_SHA=$(grep ' aasm-x86_64-apple-darwin.tar.gz$' agent_assembly/bin/SHA256SUMS | awk '{print $1}' || true) + if [[ -z "$EXPECTED_SHA" ]]; then + echo "::error::aasm-x86_64-apple-darwin.tar.gz is not listed in the core release SHA256SUMS — refusing to bundle"; exit 1 + fi + ACTUAL_SHA=$(shasum -a 256 agent_assembly/bin/aasm-x86_64-apple-darwin.tar.gz | awk '{print $1}') + if [[ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]]; then + echo "::error::aasm-x86_64-apple-darwin.tar.gz SHA256 mismatch (expected $EXPECTED_SHA got $ACTUAL_SHA) — refusing to bundle"; exit 1 + fi + rm -f agent_assembly/bin/SHA256SUMS # Tarball contains a single `aasm` binary at the root; # extract in place, then drop the archive. tar -xzf agent_assembly/bin/aasm-x86_64-apple-darwin.tar.gz -C agent_assembly/bin/