Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/release-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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/
Expand Down
61 changes: 43 additions & 18 deletions agent_assembly/core/transport_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
7 changes: 6 additions & 1 deletion test/unit/client/test_gateway_control_plane.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
28 changes: 17 additions & 11 deletions test/unit/client/test_gateway_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,17 @@ 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:
client.close()


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:
Expand Down Expand Up @@ -93,26 +93,32 @@ 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:
with pytest.raises(ValueError, match="Bearer"):
_ = 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
Expand Down
17 changes: 14 additions & 3 deletions test/unit/core/test_transport_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down