diff --git a/.lint_baselines/falsey_clobber.json b/.lint_baselines/falsey_clobber.json index b734209..e08f9f7 100644 --- a/.lint_baselines/falsey_clobber.json +++ b/.lint_baselines/falsey_clobber.json @@ -22,24 +22,24 @@ "axonflow/adapters/tool_wrapper.py:190:20", "axonflow/adapters/tool_wrapper.py:208:20", "axonflow/adapters/tool_wrapper.py:220:20", - "axonflow/client.py:1059:16", - "axonflow/client.py:1136:16", - "axonflow/client.py:1608:37", - "axonflow/client.py:1649:18", - "axonflow/client.py:1707:37", - "axonflow/client.py:2225:24", - "axonflow/client.py:2246:33", - "axonflow/client.py:2247:31", - "axonflow/client.py:2259:25", - "axonflow/client.py:2320:28", - "axonflow/client.py:2361:69", + "axonflow/client.py:1065:16", + "axonflow/client.py:1142:16", + "axonflow/client.py:1614:37", + "axonflow/client.py:1655:18", + "axonflow/client.py:1713:37", + "axonflow/client.py:2231:24", + "axonflow/client.py:2252:33", + "axonflow/client.py:2253:31", + "axonflow/client.py:2265:25", + "axonflow/client.py:2326:28", + "axonflow/client.py:2367:69", "axonflow/client.py:286:14", "axonflow/client.py:291:24", "axonflow/client.py:292:20", "axonflow/client.py:488:44", - "axonflow/client.py:6068:25", - "axonflow/client.py:793:20", - "axonflow/client.py:879:20", + "axonflow/client.py:6074:25", + "axonflow/client.py:799:20", + "axonflow/client.py:885:20", "axonflow/execution.py:205:19", "axonflow/interceptors/anthropic.py:134:43", "axonflow/interceptors/anthropic.py:161:43", diff --git a/axonflow/client.py b/axonflow/client.py index cb79050..a14e380 100644 --- a/axonflow/client.py +++ b/axonflow/client.py @@ -509,10 +509,16 @@ def __init__( # Configure SSL verification verify_ssl: bool = not insecure_skip_verify - # Build headers + # Build headers. + # ADR-050 §4: every governed request to the agent carries + # X-Axonflow-Client so the agent can derive request scope (sdk) and + # validate it against the token's aud.scope via HasScope(). Sourced + # from the bundled _SDK_VERSION constant; no env override (the + # consumer doesn't get to spoof its own client identity to the agent). headers: dict[str, str] = { "Content-Type": "application/json", "User-Agent": f"axonflow-sdk-python/{_SDK_VERSION}", + "X-Axonflow-Client": f"sdk-python/{_SDK_VERSION}", } # Always send Basic auth — server derives tenant from clientId. # Uses effective client_id ("community" default when not configured). diff --git a/tests/test_client_header.py b/tests/test_client_header.py new file mode 100644 index 0000000..3ed27db --- /dev/null +++ b/tests/test_client_header.py @@ -0,0 +1,56 @@ +"""X-Axonflow-Client header injection — ADR-050 §4. + +Asserts every governed HTTP request forwards +``X-Axonflow-Client: sdk-python/<__version__>`` so the agent can derive +request scope (sdk) and validate against the token's aud.scope via +HasScope(). + +Header is sourced from the bundled __version__ constant; the consumer +cannot spoof its own client identity through config (intentional — +honest-99% header injection per ADR-050 §4). +""" + +import pytest + +from axonflow import AxonFlow +from axonflow._version import __version__ + +EXPECTED_CLIENT = f"sdk-python/{__version__}" + + +@pytest.mark.asyncio +async def test_client_header_present_on_proxy_call(httpx_mock): + """Every governed request includes X-Axonflow-Client.""" + httpx_mock.add_response( + url="http://localhost:8080/api/request", + json={"success": True, "data": {"answer": "ok"}, "blocked": False}, + ) + + client = AxonFlow( + endpoint="http://localhost:8080", + client_id="test-client", + client_secret="test-secret", + ) + async with client: + await client.proxy_llm_call(user_token="", query="ping", request_type="chat") + + requests = httpx_mock.get_requests() + assert len(requests) == 1 + headers = dict(requests[0].headers) + # httpx normalizes header keys to lowercase + assert headers.get("x-axonflow-client") == EXPECTED_CLIENT + + +@pytest.mark.asyncio +async def test_client_header_format_is_id_slash_semver(): + """Sanity: the value matches sdk-python/. + + The agent's deriveScopeFromClientHeader splits on '/' and maps + "sdk-*" prefixes to scope=sdk. If we ever ship a different shape + this fails loudly so we don't regress agent-side parsing. + """ + import re + + assert EXPECTED_CLIENT.startswith("sdk-python/") + assert re.match(r"^sdk-python/\d+\.\d+\.\d+", EXPECTED_CLIENT) + assert EXPECTED_CLIENT.count("/") == 1