From 92fb7cfa6c41108d19214d938464a76879d2443e Mon Sep 17 00:00:00 2001 From: Gregory Zak Date: Thu, 16 Jul 2026 15:33:05 -0700 Subject: [PATCH 1/4] fix(computer-use): stop dropping tool name when action is present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _derive_connector_type(tool_name, action) discarded tool_name whenever action was present, returning only "computer_use.{action}" — so every actioned Computer Use tool call collapsed to the same connector_type shape regardless of which tool (computer vs. any other actioned tool) triggered it. MCPCheckInputRequest/MCPCheckOutputRequest now carry a genuine two-field (server, tool) identity contract (epic #2905, platform sub-issue #2904). Client.mcp_check_input/mcp_check_output gain a `tool` parameter sent as its own wire field. _derive_connector_type is removed; ComputerUseGovernor now passes connector_type=tool_name and tool=action directly to mcp_check_input, so neither value is discarded. tool_name is chosen as the connector_type-equivalent (rather than a fixed "computer_use" literal) because it matches the rest of the SDK's convention of bare category names for connector_type (e.g. "postgres", "snowflake") with no namespace prefix, and because action already varies meaningfully within a single tool (e.g. "left_click" vs "screenshot" for "computer") while tool_name is the more stable partition key. check_result() has no action available at its call site, so tool is left unset there, matching its pre-existing behavior of not being able to distinguish sub-actions on output checks. Signed-off-by: Gregory Zak --- CHANGELOG.md | 17 ++ axonflow/adapters/computer_use.py | 36 ++-- axonflow/client.py | 17 ++ axonflow/types.py | 9 + .../computer_use_tool_name_split/test.py | 186 ++++++++++++++++++ tests/fixtures/wire_shape_baseline.json | 12 +- tests/test_computer_use.py | 83 ++++++-- 7 files changed, 320 insertions(+), 40 deletions(-) create mode 100644 runtime-e2e/computer_use_tool_name_split/test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d930cae..79f9c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and tag v{X.Y.Z}. The release workflow's preflight checks the section header matches the tag. --> +## [Unreleased] + +### Fixed + +- **`ComputerUseGovernor.check_tool_use` no longer drops the tool name when + an action is present.** `_derive_connector_type(tool_name, action)` folded + both into a single `connector_type` string (`"computer_use.{action}"`), + silently discarding `tool_name` whenever `action` was present — every + actioned Computer Use tool call (e.g. `computer` with + `action="left_click"`) collapsed to the same `connector_type` shape + regardless of which tool actually triggered it. `_derive_connector_type` + is removed; `check_tool_use` now passes `connector_type=name` and + `tool=action` as two separate wire fields, matching the platform's + two-field (server, tool) identity contract (epic #2905 / #2904). + `mcp_check_input`/`mcp_check_output` gain a matching optional `tool` + parameter. + ## [8.5.1] - 2026-07-09 — Interceptor sync bridge + async-client detection + example fixes Hostile-testing sweep ahead of the BukuWarung integration diff --git a/axonflow/adapters/computer_use.py b/axonflow/adapters/computer_use.py index a7d55e8..cb7bdc1 100644 --- a/axonflow/adapters/computer_use.py +++ b/axonflow/adapters/computer_use.py @@ -83,19 +83,6 @@ class CheckResult: """Number of policies the server evaluated.""" -def _derive_connector_type(tool_name: str, action: str | None = None) -> str: - """Derive AxonFlow connector_type from Computer Use tool name and action. - - Examples: - _derive_connector_type("computer", "left_click") -> "computer_use.left_click" - _derive_connector_type("bash") -> "computer_use.bash" - _derive_connector_type("text_editor") -> "computer_use.text_editor" - """ - if action: - return f"computer_use.{action}" - return f"computer_use.{tool_name}" - - class ComputerUseGovernor: """Governance middleware for Anthropic Computer Use tool_use blocks. @@ -162,18 +149,22 @@ async def check_tool_use(self, tool_use_block: dict[str, Any]) -> CheckResult: policies_evaluated=0, ) - # Derive connector_type from tool name + action + # Two-field (server, tool) identity contract (epic #2905 / #2904): + # `connector_type` carries the Computer Use tool name (e.g. "computer", + # "bash", "text_editor") and `action` — when present — is sent + # separately as `tool` (e.g. "left_click", "screenshot"). Previously + # both were folded into a single derived connector_type string + # ("computer_use.{action}"), which silently discarded `name` whenever + # an action was present. Neither value is dropped now. action = tool_input.get("action") if isinstance(tool_input, dict) else None - connector_type = _derive_connector_type( - name, - action if isinstance(action, str) else None, - ) + tool = action if isinstance(action, str) else None # Serialize input for policy evaluation statement = json.dumps(tool_input, default=str) check = await self._client.mcp_check_input( - connector_type=connector_type, + connector_type=name, + tool=tool, statement=statement, operation="execute", ) @@ -204,10 +195,11 @@ async def check_result( Returns: CheckResult with allowed status and optional redacted_result. """ - connector_type = _derive_connector_type(tool_name) - + # No `action` is available at this call site, so `tool` is left + # unset here — see check_tool_use() for the (connector_type, tool) + # split when an action is known. check = await self._client.mcp_check_output( - connector_type=connector_type, + connector_type=tool_name, message=result, ) diff --git a/axonflow/client.py b/axonflow/client.py index c138b01..7ffdd4c 100644 --- a/axonflow/client.py +++ b/axonflow/client.py @@ -1438,6 +1438,7 @@ async def mcp_check_input( operation: str = "execute", parameters: dict[str, Any] | None = None, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -1455,6 +1456,10 @@ async def mcp_check_input( statement: The SQL query or command to validate. operation: Operation type - "query" or "execute" (default). parameters: Optional query parameters. + tool: Optional tool name, distinct from ``connector_type``. Lets a + PEP report the (server, tool) identity as two separate values + instead of concatenating them into ``connector_type`` + (epic #2905 / #2904). client_id: Client identifier (overrides client config when set). tenant_id: Tenant identifier for multi-tenant scoping. user_id: End-user identifier for per-user policies. @@ -1481,6 +1486,8 @@ async def mcp_check_input( } if parameters: body["parameters"] = parameters + if tool: + body["tool"] = tool # Wire-canonical scoping fields surfaced in the v6 sweep. if client_id is not None: body["client_id"] = client_id @@ -1545,6 +1552,7 @@ async def mcp_check_output( metadata: dict[str, Any] | None = None, row_count: int = 0, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -1561,6 +1569,9 @@ async def mcp_check_output( message: Execute-style response message (e.g., "5 rows affected"). metadata: Connector metadata for SQLi scanning. row_count: Total number of rows returned. + tool: Optional tool name, distinct from ``connector_type``. Mirrors + the ``tool`` parameter on :meth:`mcp_check_input` (epic #2905 / + #2904). client_id: Client identifier (overrides client config when set). tenant_id: Tenant identifier for multi-tenant scoping. user_id: End-user identifier for per-user policies. @@ -1584,6 +1595,8 @@ async def mcp_check_output( body["metadata"] = metadata if row_count > 0: body["row_count"] = row_count + if tool: + body["tool"] = tool # Wire-canonical scoping fields surfaced in the v6 sweep. if client_id is not None: body["client_id"] = client_id @@ -7549,6 +7562,7 @@ def mcp_check_input( operation: str = "execute", parameters: dict[str, Any] | None = None, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -7563,6 +7577,7 @@ def mcp_check_input( statement, operation, parameters, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, @@ -7612,6 +7627,7 @@ def mcp_check_output( metadata: dict[str, Any] | None = None, row_count: int = 0, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -7625,6 +7641,7 @@ def mcp_check_output( message, metadata, row_count, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, diff --git a/axonflow/types.py b/axonflow/types.py index 8e1d249..a79a2be 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -407,6 +407,12 @@ class MCPCheckInputRequest(BaseModel): """Request to validate input against MCP policies.""" connector_type: str + # Two-field (server, tool) identity contract (epic #2905 / #2904). `tool` + # carries the tool name so a PEP no longer has to concatenate it into + # `connector_type` (e.g. "server.tool") to preserve tool identity. + # Source of truth: platform/agent MCPCheckInputRequest (#2904, R3-approved, + # not yet merged to axonflow-enterprise at the time this field was added). + tool: str | None = Field(default=None) statement: str parameters: dict[str, Any] | None = Field(default=None) operation: str = Field(default="execute") @@ -462,6 +468,9 @@ class MCPCheckOutputRequest(BaseModel): """Request to validate output against MCP policies.""" connector_type: str + # Two-field (server, tool) identity contract, mirrored from + # MCPCheckInputRequest.tool (epic #2905 / #2904). + tool: str | None = Field(default=None) response_data: list[dict[str, Any]] | None = Field(default=None) message: str | None = Field(default=None) metadata: dict[str, Any] | None = Field(default=None) diff --git a/runtime-e2e/computer_use_tool_name_split/test.py b/runtime-e2e/computer_use_tool_name_split/test.py new file mode 100644 index 0000000..60a9af0 --- /dev/null +++ b/runtime-e2e/computer_use_tool_name_split/test.py @@ -0,0 +1,186 @@ +"""Real-stack assertion: ComputerUseGovernor preserves both tool_name and +action instead of dropping tool_name whenever action is present (#2910, +epic #2905/#2904). + +Per CLAUDE.md HARD RULE #0 this test MUST hit a real running AxonFlow agent — +no mocks. ``httpx.AsyncClient.request`` is wrapped only to OBSERVE the +outbound wire body (the real POST to the real agent still happens and its +response is asserted on); this mirrors the capture pattern already used in +``runtime-e2e/x-client-id/test.py``. + +Regression background: ``_derive_connector_type(tool_name, action)`` folded +both into a single ``connector_type`` string (``"computer_use.{action}"``), +silently discarding ``tool_name`` whenever ``action`` was present — every +actioned Computer Use tool call (e.g. ``computer`` with +``action="left_click"``) collapsed to the same ``connector_type`` shape +regardless of which tool actually triggered it. ``_derive_connector_type`` +is removed; ``ComputerUseGovernor.check_tool_use`` now sends +``connector_type=name`` and ``tool=action`` as two separate wire fields. + +Assertions against a live agent: + + 1. An actioned tool (``"computer"`` + ``action="left_click"``) sends + ``connector_type="computer"`` AND ``tool="left_click"`` as two + distinct wire fields — not the old folded ``"computer_use.left_click"`` + string, and not dropping the tool name — and the live agent allows it. + 2. A non-actioned tool (``"text_editor"``, no ``action`` key) sends + ``connector_type="text_editor"`` and omits ``tool`` entirely, and the + live agent allows it. + 3. Two calls to the SAME tool with DIFFERENT actions produce the SAME + ``connector_type`` but DIFFERENT ``tool`` values — exactly what the + old bug broke (both actioned calls used to collapse to + indistinguishable ``connector_type`` strings). + 4. ``check_result()`` — which has no ``action`` available at its call + site — sends ``connector_type=tool_name`` and never sends ``tool``, + matching its documented pre-existing behavior (no regression there). + +Run locally: + + AXONFLOW_AGENT_URL=http://localhost:8080 \ + python3 runtime-e2e/computer_use_tool_name_split/test.py +""" + +from __future__ import annotations + +import asyncio +import os +import sys +from typing import Any + +import httpx + +from axonflow import AxonFlow +from axonflow.adapters.computer_use import ComputerUseGovernor + +AGENT_URL = os.environ.get("AXONFLOW_AGENT_URL", "http://localhost:8080") +CLIENT_ID = os.environ.get("AXONFLOW_CLIENT_ID", "py-sdk-2910-runtime-e2e") + +_orig_request = httpx.AsyncClient.request +_captured: list[dict[str, Any]] = [] + + +async def _patched(self: httpx.AsyncClient, method: str, url: Any, **kw: Any) -> httpx.Response: + resp = await _orig_request(self, method, url, **kw) + if "/mcp/check-" in str(url): + _captured.append({"url": str(url), "json": kw.get("json") or {}}) + return resp + + +httpx.AsyncClient.request = _patched # type: ignore[method-assign] + +failures: list[str] = [] + + +def check(name: str, ok: bool, detail: str = "") -> None: + print(f"{'PASS' if ok else 'FAIL'} [{name}] {detail}") + if not ok: + failures.append(name) + + +async def main() -> None: + client = AxonFlow(endpoint=AGENT_URL, client_id=CLIENT_ID) + governor = ComputerUseGovernor(client) + + async with client: + # 1. Actioned tool: tool_name AND action must both survive as two + # separate wire fields (the exact bug: action present used to + # discard tool_name). + _captured.clear() + result = await governor.check_tool_use( + {"name": "computer", "input": {"action": "left_click", "coordinate": [100, 200]}} + ) + body = _captured[-1]["json"] if _captured else {} + check( + "actioned-connector-type-is-bare-tool-name", + body.get("connector_type") == "computer", + f"connector_type={body.get('connector_type')!r}", + ) + check( + "actioned-tool-field-carries-action", + body.get("tool") == "left_click", + f"tool={body.get('tool')!r}", + ) + check( + "actioned-not-folded-into-single-string", + body.get("connector_type") != "computer_use.left_click", + f"connector_type={body.get('connector_type')!r} (must not be the old " + f"concatenated shape)", + ) + check( + "actioned-agent-allowed", + result.allowed and result.policies_evaluated > 0, + f"allowed={result.allowed} policies_evaluated={result.policies_evaluated}", + ) + + # 2. Non-actioned tool: `tool` must be entirely absent on the wire + # (client.py only adds it when truthy). + _captured.clear() + result2 = await governor.check_tool_use({"name": "text_editor", "input": {}}) + body2 = _captured[-1]["json"] if _captured else {} + check( + "non-actioned-connector-type", + body2.get("connector_type") == "text_editor", + f"connector_type={body2.get('connector_type')!r}", + ) + check( + "non-actioned-tool-field-absent", + "tool" not in body2, + f"body={body2}", + ) + check( + "non-actioned-agent-allowed", + result2.allowed, + f"allowed={result2.allowed}", + ) + + # 3. Same tool, different actions: connector_type must match while + # tool differs — exactly what the old bug could not do (it + # dropped tool_name, and different-action calls on unrelated + # tools could even collide on the folded string). + _captured.clear() + await governor.check_tool_use({"name": "computer", "input": {"action": "screenshot"}}) + first = _captured[-1]["json"] if _captured else {} + await governor.check_tool_use( + {"name": "computer", "input": {"action": "left_click", "coordinate": [1, 1]}} + ) + second = _captured[-1]["json"] if _captured else {} + check( + "same-tool-different-actions-share-connector-type", + first.get("connector_type") == second.get("connector_type") == "computer", + f"first={first.get('connector_type')!r} second={second.get('connector_type')!r}", + ) + check( + "same-tool-different-actions-distinguishable-by-tool", + first.get("tool") == "screenshot" and second.get("tool") == "left_click", + f"first tool={first.get('tool')!r} second tool={second.get('tool')!r}", + ) + + # 4. check_result(): no action available at its call site, so + # connector_type=tool_name and `tool` is never sent. + _captured.clear() + result4 = await governor.check_result("computer", "some tool output text") + body4 = _captured[-1]["json"] if _captured else {} + check( + "check-result-connector-type-is-tool-name", + body4.get("connector_type") == "computer", + f"connector_type={body4.get('connector_type')!r}", + ) + check( + "check-result-tool-field-absent", + "tool" not in body4, + f"body={body4}", + ) + check( + "check-result-agent-allowed", + result4.allowed, + f"allowed={result4.allowed}", + ) + + if failures: + print(f"RESULT: FAIL ({len(failures)}): {failures}") + sys.exit(1) + print("RESULT: PASS (12/12)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/fixtures/wire_shape_baseline.json b/tests/fixtures/wire_shape_baseline.json index 7d592a2..23965a1 100644 --- a/tests/fixtures/wire_shape_baseline.json +++ b/tests/fixtures/wire_shape_baseline.json @@ -297,9 +297,10 @@ "spec_only": [] }, "MCPCheckInputRequest": { - "note": "acknowledged-sdk-superset: tracked in #2563/#2571 \u2014 SDK declares `content_type` (request-redaction detector selector, ADR-056); the agent consumes it on POST /api/v1/mcp/check-input but agent-api.yaml doesn't yet declare it.", + "note": "acknowledged-sdk-superset: tracked in #2563/#2571 \u2014 SDK declares `content_type` (request-redaction detector selector, ADR-056); the agent consumes it on POST /api/v1/mcp/check-input but agent-api.yaml doesn't yet declare it. Also declares `tool` (epic #2905, platform sub-issue #2904, R3-approved/CI-green but not yet merged to axonflow-enterprise) \u2014 the two-field (server, tool) identity contract; agent-api.yaml will gain it once #2904 lands.", "sdk_only": [ - "content_type" + "content_type", + "tool" ], "spec_only": [] }, @@ -312,6 +313,13 @@ ], "spec_only": [] }, + "MCPCheckOutputRequest": { + "note": "acknowledged-sdk-superset: tracked in epic #2905, platform sub-issue #2904 (R3-approved/CI-green but not yet merged to axonflow-enterprise) \u2014 SDK declares `tool`, mirroring MCPCheckInputRequest.tool for the two-field (server, tool) identity contract; agent-api.yaml will gain it once #2904 lands.", + "sdk_only": [ + "tool" + ], + "spec_only": [] + }, "MCPCheckOutputResponse": { "note": "acknowledged-sdk-superset: tracked in #2563/#2571 \u2014 SDK declares `redaction_evaluated` for response-phase obligation fail-closed parity with check-input (ADR-056); platform predates the field on check-output, so it defaults False.", "sdk_only": [ diff --git a/tests/test_computer_use.py b/tests/test_computer_use.py index 2a0a3c5..5598b05 100644 --- a/tests/test_computer_use.py +++ b/tests/test_computer_use.py @@ -11,7 +11,6 @@ DEFAULT_BLOCKED_BASH_PATTERNS, CheckResult, ComputerUseGovernor, - _derive_connector_type, ) from axonflow.types import MCPCheckInputResponse, MCPCheckOutputResponse @@ -60,22 +59,71 @@ def _make_governor( # --------------------------------------------------------------------------- -# Connector Type Derivation +# connector_type / tool identity split (epic #2905 / #2904) # --------------------------------------------------------------------------- -class TestDeriveConnectorType: - def test_tool_with_action(self) -> None: - assert _derive_connector_type("computer", "left_click") == "computer_use.left_click" +class TestConnectorTypeToolSplit: + """The tool name must never be discarded when an action is present. - def test_tool_without_action(self) -> None: - assert _derive_connector_type("bash") == "computer_use.bash" + Previously ``_derive_connector_type`` folded both into a single + ``connector_type`` string ("computer_use.{action}"), silently dropping + the tool name whenever an action was present. Now `connector_type` + always carries the tool name and `tool` carries the action (when any), + as two separate wire fields — nothing is discarded. + """ - def test_text_editor(self) -> None: - assert _derive_connector_type("text_editor") == "computer_use.text_editor" + @pytest.mark.asyncio + async def test_tool_with_action_preserves_both(self) -> None: + governor, client = _make_governor() + await governor.check_tool_use( + { + "name": "computer", + "input": {"action": "left_click", "coordinate": [100, 200]}, + } + ) + call_kwargs = client.mcp_check_input.call_args.kwargs + assert call_kwargs["connector_type"] == "computer" + assert call_kwargs["tool"] == "left_click" + + @pytest.mark.asyncio + async def test_tool_without_action(self) -> None: + governor, client = _make_governor() + await governor.check_tool_use({"name": "bash", "input": {"command": "echo hi"}}) + call_kwargs = client.mcp_check_input.call_args.kwargs + assert call_kwargs["connector_type"] == "bash" + assert call_kwargs["tool"] is None + + @pytest.mark.asyncio + async def test_text_editor_no_action(self) -> None: + governor, client = _make_governor() + await governor.check_tool_use({"name": "text_editor", "input": {}}) + call_kwargs = client.mcp_check_input.call_args.kwargs + assert call_kwargs["connector_type"] == "text_editor" + assert call_kwargs["tool"] is None + + @pytest.mark.asyncio + async def test_different_actions_distinguishable_for_same_tool(self) -> None: + """Two calls to the same tool with different actions must not + collide on connector_type — this is exactly what the old + `computer_use.{action}`-only derivation broke, since it dropped + `name` and could conflate distinct tools that happen to share an + action name.""" + governor, client = _make_governor() + + await governor.check_tool_use( + {"name": "computer", "input": {"action": "screenshot"}}, + ) + first = client.mcp_check_input.call_args.kwargs + + await governor.check_tool_use( + {"name": "computer", "input": {"action": "left_click", "coordinate": [1, 1]}}, + ) + second = client.mcp_check_input.call_args.kwargs - def test_action_none(self) -> None: - assert _derive_connector_type("computer", None) == "computer_use.computer" + assert first["connector_type"] == second["connector_type"] == "computer" + assert first["tool"] == "screenshot" + assert second["tool"] == "left_click" # --------------------------------------------------------------------------- @@ -193,7 +241,7 @@ async def test_server_blocks_pii_in_input(self) -> None: assert result.block_reason == "PII detected in tool arguments" @pytest.mark.asyncio - async def test_connector_type_uses_action(self) -> None: + async def test_connector_type_is_tool_name_action_sent_as_tool(self) -> None: governor, client = _make_governor() await governor.check_tool_use( { @@ -202,7 +250,8 @@ async def test_connector_type_uses_action(self) -> None: } ) call_kwargs = client.mcp_check_input.call_args.kwargs - assert call_kwargs["connector_type"] == "computer_use.left_click" + assert call_kwargs["connector_type"] == "computer" + assert call_kwargs["tool"] == "left_click" @pytest.mark.asyncio async def test_connector_type_for_bash(self) -> None: @@ -214,7 +263,8 @@ async def test_connector_type_for_bash(self) -> None: } ) call_kwargs = client.mcp_check_input.call_args.kwargs - assert call_kwargs["connector_type"] == "computer_use.bash" + assert call_kwargs["connector_type"] == "bash" + assert call_kwargs["tool"] is None @pytest.mark.asyncio async def test_custom_blocked_patterns(self) -> None: @@ -245,7 +295,8 @@ async def test_missing_name_defaults_to_unknown(self) -> None: governor, client = _make_governor() await governor.check_tool_use({"input": {"action": "click"}}) call_kwargs = client.mcp_check_input.call_args.kwargs - assert call_kwargs["connector_type"] == "computer_use.click" + assert call_kwargs["connector_type"] == "unknown" + assert call_kwargs["tool"] == "click" @pytest.mark.asyncio async def test_missing_input_defaults_to_empty(self) -> None: @@ -291,7 +342,7 @@ async def test_connector_type_correct(self) -> None: governor, client = _make_governor() await governor.check_result("text_editor", "file contents") call_kwargs = client.mcp_check_output.call_args.kwargs - assert call_kwargs["connector_type"] == "computer_use.text_editor" + assert call_kwargs["connector_type"] == "text_editor" @pytest.mark.asyncio async def test_dict_redacted_data_json_serialized(self) -> None: From 01d26275ab9aa6dcac2463921af31cb8072adee6 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 17 Jul 2026 22:51:27 +0200 Subject: [PATCH 2/4] fix(computer-use)!: report (server, tool) identity, tool=tool name (RULING 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply epic #2905 RULING 1 to ComputerUseGovernor so the `tool_name` audit column means the tool name in every adapter: - connector_type = the constant "computer_use" connector/domain marker - tool = the tool NAME ("computer"/"bash"/"text_editor"), never the action - the action stays in the serialized `statement` (unchanged) — it is NOT placed in `tool`, nor in `operation` (agent-api constrains operation to the enum {query, execute}) check_result() now sends the same identity on the response plane so request- and response-plane rows correlate. Removes _derive_connector_type. BREAKING: the connector_type wire value changes shape; policies scoped to the old concatenated value (e.g. "computer_use.left_click", a bare tool name, or a computer_use.* prefix) stop matching. Re-scope to connector_type=="computer_use" plus the tool field. No connector_type_fn/tool_fn escape hatch on this adapter. Requires platform v9.10.0+ to consume `tool` on check-input; check-output does not consume it on any released version yet (#2955). Reconciles the shared client.py/types.py/wire_shape_baseline.json hunks with PR #216 to byte-identical, corrected text (#2916/c8df2006b merged, first released in v9.10.0); regenerates .lint_baselines/falsey_clobber.json (CI). Refs #2910, epic #2905. Signed-off-by: Saurabh Jain --- .lint_baselines/falsey_clobber.json | 24 ++-- CHANGELOG.md | 61 +++++++-- axonflow/adapters/computer_use.py | 47 ++++--- axonflow/client.py | 8 ++ axonflow/types.py | 13 +- .../computer_use_tool_name_split/test.py | 127 +++++++++++------- tests/fixtures/wire_shape_baseline.json | 4 +- tests/test_computer_use.py | 93 ++++++++----- 8 files changed, 244 insertions(+), 133 deletions(-) diff --git a/.lint_baselines/falsey_clobber.json b/.lint_baselines/falsey_clobber.json index c710deb..3b640a7 100644 --- a/.lint_baselines/falsey_clobber.json +++ b/.lint_baselines/falsey_clobber.json @@ -1,8 +1,8 @@ { "_comment": "Pre-existing falsey-clobber findings. Generated by scripts/lint_no_falsey_clobber.py --write-baseline. CI fails on any finding NOT listed here. Burn this list down via targeted PRs that fix each pattern (or add # noqa: falsey-clobber if the `or` is intentional).", "findings": [ - "axonflow/adapters/computer_use.py:184:29", - "axonflow/adapters/computer_use.py:217:29", + "axonflow/adapters/computer_use.py:183:29", + "axonflow/adapters/computer_use.py:222:29", "axonflow/adapters/langchain.py:117:30", "axonflow/adapters/langchain.py:117:58", "axonflow/adapters/langchain.py:118:34", @@ -24,20 +24,20 @@ "axonflow/adapters/tool_wrapper.py:220:20", "axonflow/client.py:1111:16", "axonflow/client.py:1188:16", - "axonflow/client.py:1669:37", - "axonflow/client.py:1710:18", - "axonflow/client.py:1768:37", - "axonflow/client.py:2286:24", - "axonflow/client.py:2307:33", - "axonflow/client.py:2308:31", - "axonflow/client.py:2320:25", - "axonflow/client.py:2381:28", - "axonflow/client.py:2422:69", + "axonflow/client.py:1686:37", + "axonflow/client.py:1727:18", + "axonflow/client.py:1785:37", + "axonflow/client.py:2303:24", + "axonflow/client.py:2324:33", + "axonflow/client.py:2325:31", + "axonflow/client.py:2337:25", + "axonflow/client.py:2398:28", + "axonflow/client.py:2439:69", "axonflow/client.py:300:14", "axonflow/client.py:305:24", "axonflow/client.py:306:20", "axonflow/client.py:529:44", - "axonflow/client.py:6463:25", + "axonflow/client.py:6480:25", "axonflow/client.py:845:20", "axonflow/client.py:931:20", "axonflow/execution.py:205:19", diff --git a/CHANGELOG.md b/CHANGELOG.md index 79f9c58..34cece9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,20 +11,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - -- **`ComputerUseGovernor.check_tool_use` no longer drops the tool name when - an action is present.** `_derive_connector_type(tool_name, action)` folded - both into a single `connector_type` string (`"computer_use.{action}"`), - silently discarding `tool_name` whenever `action` was present — every - actioned Computer Use tool call (e.g. `computer` with - `action="left_click"`) collapsed to the same `connector_type` shape - regardless of which tool actually triggered it. `_derive_connector_type` - is removed; `check_tool_use` now passes `connector_type=name` and - `tool=action` as two separate wire fields, matching the platform's - two-field (server, tool) identity contract (epic #2905 / #2904). - `mcp_check_input`/`mcp_check_output` gain a matching optional `tool` - parameter. +> **This release contains a breaking change and MUST be published as a major +> version bump.** The `connector_type` wire value emitted by +> `ComputerUseGovernor` changes shape; policies matching the old value stop +> matching until re-scoped (see the migration note below). + +### Changed (BREAKING) + +- **`ComputerUseGovernor` now reports the (server, tool) identity as two + separate wire fields instead of concatenating them into `connector_type`.** + Previously `_derive_connector_type(tool_name, action)` folded the tool name + and the action into a single `connector_type` string + (`"computer_use.{action}"`), silently discarding the tool name whenever an + action was present. Now: + - `connector_type` is the constant connector/domain marker `"computer_use"`; + - `tool` carries the tool **name** (e.g. `"computer"`, `"bash"`, + `"text_editor"`) — the same `(server, tool)` mapping the LangGraph adapter + uses, so the `tool_name` audit column means the tool name regardless of + which adapter emitted it (epic #2905, RULING 1); + - the action (e.g. `"left_click"`) is preserved inside the serialized + `statement`, where the policy engine already evaluates it — it is **not** + placed in `tool`, nor in `operation` (the agent-api spec constrains + `operation` to the enum `{query, execute}`). + + `_derive_connector_type` is removed. `check_result()` sends the same + identity on the response plane (`connector_type="computer_use"`, + `tool=`) so request- and response-plane rows correlate. + `mcp_check_input`/`mcp_check_output` (and their `check_tool_input`/ + `check_tool_output` aliases) gain a matching optional `tool` parameter. + + **Migration.** Policies or per-connector settings scoped to the old + concatenated value — e.g. `connector_type == "computer_use.left_click"`, a + bare tool name like `"bash"`, or a `computer_use.*` prefix match — stop + matching after upgrade. Re-scope them to match `connector_type == + "computer_use"` together with the `tool` field (e.g. `tool == "bash"`). + **This adapter has no `connector_type_fn`/`tool_fn` escape hatch**, so there + is no way to restore the old concatenated value from the SDK side — + re-scoping the policies is the only migration path. + + **Minimum platform.** The `tool` field is consumed on `POST + /api/v1/mcp/check-input` by platform **v9.10.0+** (enterprise `c8df2006b`, + epic #2905 / #2904). On platforms below v9.10.0 the `tool` field is silently + ignored and identity degrades to the bare `"computer_use"` connector, which + is coarser than the old concatenated value — **upgrade the platform to + v9.10.0+ before adopting this SDK major.** The response plane + (`check-output`) does **not** consume `tool` on any released platform + version yet (tracked by #2955, targeted for v9.11.0); the SDK sends it + forward-compatibly and current platforms ignore it. ## [8.5.1] - 2026-07-09 — Interceptor sync bridge + async-client detection + example fixes diff --git a/axonflow/adapters/computer_use.py b/axonflow/adapters/computer_use.py index cb7bdc1..7c17e4d 100644 --- a/axonflow/adapters/computer_use.py +++ b/axonflow/adapters/computer_use.py @@ -149,22 +149,30 @@ async def check_tool_use(self, tool_use_block: dict[str, Any]) -> CheckResult: policies_evaluated=0, ) - # Two-field (server, tool) identity contract (epic #2905 / #2904): - # `connector_type` carries the Computer Use tool name (e.g. "computer", - # "bash", "text_editor") and `action` — when present — is sent - # separately as `tool` (e.g. "left_click", "screenshot"). Previously - # both were folded into a single derived connector_type string - # ("computer_use.{action}"), which silently discarded `name` whenever - # an action was present. Neither value is dropped now. - action = tool_input.get("action") if isinstance(tool_input, dict) else None - tool = action if isinstance(action, str) else None - - # Serialize input for policy evaluation + # Two-field (server, tool) identity contract (epic #2905 / #2904). + # `connector_type` is the Computer Use connector/domain marker + # ("computer_use") and `tool` is the tool NAME (e.g. "computer", + # "bash", "text_editor") — the same (server, tool) mapping the + # LangGraph adapters use, so the `tool_name` audit column means the + # tool name regardless of which adapter emitted it. + # + # The `action` (e.g. "left_click", "screenshot") is NOT a tool + # identity, so it must not go in `tool`. It stays inside `statement` + # below — `json.dumps(tool_input)` already includes the "action" + # key, and the policy engine evaluates the statement. It is + # deliberately NOT placed in the `operation` field, which the + # agent-api spec constrains to the enum {query, execute}. + # + # Previously both were folded into a single derived connector_type + # string ("computer_use.{action}"), which silently discarded the tool + # name whenever an action was present. Neither value is dropped now. + + # Serialize input for policy evaluation (retains the action). statement = json.dumps(tool_input, default=str) check = await self._client.mcp_check_input( - connector_type=name, - tool=tool, + connector_type="computer_use", + tool=name, statement=statement, operation="execute", ) @@ -195,11 +203,16 @@ async def check_result( Returns: CheckResult with allowed status and optional redacted_result. """ - # No `action` is available at this call site, so `tool` is left - # unset here — see check_tool_use() for the (connector_type, tool) - # split when an action is known. + # Same (server, tool) identity as check_tool_use(): the connector is + # the "computer_use" domain marker and the tool NAME is `tool_name` + # (e.g. "computer", "bash"), so the request- and response-plane rows + # share one connector_type and one tool identity. NB: the platform's + # check-output schema does not yet consume `tool` (#2955, targeted for + # v9.11.0); it is sent now for forward compatibility and is ignored by + # every current platform version (omitted on the wire when empty). check = await self._client.mcp_check_output( - connector_type=tool_name, + connector_type="computer_use", + tool=tool_name, message=result, ) diff --git a/axonflow/client.py b/axonflow/client.py index 7ffdd4c..6824cee 100644 --- a/axonflow/client.py +++ b/axonflow/client.py @@ -1525,6 +1525,7 @@ async def check_tool_input( operation: str = "execute", parameters: dict[str, Any] | None = None, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -1537,6 +1538,7 @@ async def check_tool_input( statement, operation, parameters, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, @@ -1631,6 +1633,7 @@ async def check_tool_output( metadata: dict[str, Any] | None = None, row_count: int = 0, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -1643,6 +1646,7 @@ async def check_tool_output( message, metadata, row_count, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, @@ -7656,6 +7660,7 @@ def check_tool_input( operation: str = "execute", parameters: dict[str, Any] | None = None, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -7669,6 +7674,7 @@ def check_tool_input( statement, operation, parameters, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, @@ -7685,6 +7691,7 @@ def check_tool_output( metadata: dict[str, Any] | None = None, row_count: int = 0, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -7698,6 +7705,7 @@ def check_tool_output( message, metadata, row_count, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, diff --git a/axonflow/types.py b/axonflow/types.py index a79a2be..d33053b 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -408,10 +408,11 @@ class MCPCheckInputRequest(BaseModel): connector_type: str # Two-field (server, tool) identity contract (epic #2905 / #2904). `tool` - # carries the tool name so a PEP no longer has to concatenate it into + # carries the MCP tool name so a PEP no longer has to concatenate it into # `connector_type` (e.g. "server.tool") to preserve tool identity. - # Source of truth: platform/agent MCPCheckInputRequest (#2904, R3-approved, - # not yet merged to axonflow-enterprise at the time this field was added). + # Source of truth: platform/agent MCPCheckInputRequest (#2904, merged to + # axonflow-enterprise as c8df2006b and first released in platform v9.10.0); + # consumed on the request-input plane. Platforms below v9.10.0 ignore it. tool: str | None = Field(default=None) statement: str parameters: dict[str, Any] | None = Field(default=None) @@ -469,7 +470,11 @@ class MCPCheckOutputRequest(BaseModel): connector_type: str # Two-field (server, tool) identity contract, mirrored from - # MCPCheckInputRequest.tool (epic #2905 / #2904). + # MCPCheckInputRequest.tool (epic #2905 / #2904). Unlike the input-phase + # field, the platform's MCPCheckOutputRequest has no matching `tool` field + # on ANY released version yet (tracked by #2955); sending it is + # forward-compatible and harmless — the agent's JSON decoder ignores + # unrecognized keys — but it is not yet consumed server-side. tool: str | None = Field(default=None) response_data: list[dict[str, Any]] | None = Field(default=None) message: str | None = Field(default=None) diff --git a/runtime-e2e/computer_use_tool_name_split/test.py b/runtime-e2e/computer_use_tool_name_split/test.py index 60a9af0..99e5a75 100644 --- a/runtime-e2e/computer_use_tool_name_split/test.py +++ b/runtime-e2e/computer_use_tool_name_split/test.py @@ -1,6 +1,6 @@ -"""Real-stack assertion: ComputerUseGovernor preserves both tool_name and -action instead of dropping tool_name whenever action is present (#2910, -epic #2905/#2904). +"""Real-stack assertion: ComputerUseGovernor reports the (server, tool) +identity as two separate wire fields instead of concatenating them, and the +tool NAME is never dropped (#2910, epic #2905/#2904, RULING 1). Per CLAUDE.md HARD RULE #0 this test MUST hit a real running AxonFlow agent — no mocks. ``httpx.AsyncClient.request`` is wrapped only to OBSERVE the @@ -9,30 +9,36 @@ ``runtime-e2e/x-client-id/test.py``. Regression background: ``_derive_connector_type(tool_name, action)`` folded -both into a single ``connector_type`` string (``"computer_use.{action}"``), -silently discarding ``tool_name`` whenever ``action`` was present — every -actioned Computer Use tool call (e.g. ``computer`` with -``action="left_click"``) collapsed to the same ``connector_type`` shape -regardless of which tool actually triggered it. ``_derive_connector_type`` -is removed; ``ComputerUseGovernor.check_tool_use`` now sends -``connector_type=name`` and ``tool=action`` as two separate wire fields. +the tool name and the action into a single ``connector_type`` string +(``"computer_use.{action}"``), silently discarding ``tool_name`` whenever +``action`` was present — every actioned Computer Use tool call (e.g. +``computer`` with ``action="left_click"``) collapsed to the same +``connector_type`` shape regardless of which tool actually triggered it. +``_derive_connector_type`` is removed; ``ComputerUseGovernor`` now sends +``connector_type="computer_use"`` (the constant connector/domain marker) and +``tool=`` — the same ``(server, tool)`` mapping the LangGraph +adapter uses. The action is preserved inside ``statement`` (the serialized +tool input), NOT in ``tool`` and NOT in ``operation`` (the agent-api spec +constrains ``operation`` to ``{query, execute}``). Assertions against a live agent: 1. An actioned tool (``"computer"`` + ``action="left_click"``) sends - ``connector_type="computer"`` AND ``tool="left_click"`` as two + ``connector_type="computer_use"`` AND ``tool="computer"`` as two distinct wire fields — not the old folded ``"computer_use.left_click"`` - string, and not dropping the tool name — and the live agent allows it. - 2. A non-actioned tool (``"text_editor"``, no ``action`` key) sends - ``connector_type="text_editor"`` and omits ``tool`` entirely, and the + string, not the action in ``tool`` — with the action still present in + the serialized ``statement``, ``operation`` still ``"execute"``, and the live agent allows it. + 2. A non-actioned tool (``"bash"``) sends ``connector_type="computer_use"`` + and ``tool="bash"``, and the live agent allows it. 3. Two calls to the SAME tool with DIFFERENT actions produce the SAME - ``connector_type`` but DIFFERENT ``tool`` values — exactly what the - old bug broke (both actioned calls used to collapse to - indistinguishable ``connector_type`` strings). - 4. ``check_result()`` — which has no ``action`` available at its call - site — sends ``connector_type=tool_name`` and never sends ``tool``, - matching its documented pre-existing behavior (no regression there). + ``connector_type`` AND the SAME ``tool`` (the tool identity is stable) — + the differing action lives in ``statement``. This is exactly what the + old bug broke (it dropped the tool name entirely). + 4. ``check_result()`` sends the same identity on the response plane + (``connector_type="computer_use"``, ``tool=``) so request- + and response-plane rows correlate. The platform does not yet consume + ``tool`` on check-output (#2955); it is sent forward-compatibly. Run locally: @@ -82,22 +88,22 @@ async def main() -> None: governor = ComputerUseGovernor(client) async with client: - # 1. Actioned tool: tool_name AND action must both survive as two - # separate wire fields (the exact bug: action present used to - # discard tool_name). + # 1. Actioned tool: connector_type is the constant domain marker, + # tool is the tool NAME (never the action), the action survives in + # the serialized statement, and operation stays spec-compliant. _captured.clear() result = await governor.check_tool_use( {"name": "computer", "input": {"action": "left_click", "coordinate": [100, 200]}} ) body = _captured[-1]["json"] if _captured else {} check( - "actioned-connector-type-is-bare-tool-name", - body.get("connector_type") == "computer", + "actioned-connector-type-is-domain-marker", + body.get("connector_type") == "computer_use", f"connector_type={body.get('connector_type')!r}", ) check( - "actioned-tool-field-carries-action", - body.get("tool") == "left_click", + "actioned-tool-field-is-tool-name", + body.get("tool") == "computer", f"tool={body.get('tool')!r}", ) check( @@ -106,26 +112,37 @@ async def main() -> None: f"connector_type={body.get('connector_type')!r} (must not be the old " f"concatenated shape)", ) + check( + "actioned-action-preserved-in-statement", + "left_click" in (body.get("statement") or ""), + f"statement={body.get('statement')!r}", + ) + check( + "actioned-operation-spec-compliant", + body.get("operation") == "execute", + f"operation={body.get('operation')!r} (must stay in the " + f"agent-api enum {{query, execute}})", + ) check( "actioned-agent-allowed", result.allowed and result.policies_evaluated > 0, f"allowed={result.allowed} policies_evaluated={result.policies_evaluated}", ) - # 2. Non-actioned tool: `tool` must be entirely absent on the wire - # (client.py only adds it when truthy). + # 2. Non-actioned tool: connector_type is still the domain marker and + # tool carries the tool name. _captured.clear() - result2 = await governor.check_tool_use({"name": "text_editor", "input": {}}) + result2 = await governor.check_tool_use({"name": "bash", "input": {"command": "ls"}}) body2 = _captured[-1]["json"] if _captured else {} check( "non-actioned-connector-type", - body2.get("connector_type") == "text_editor", + body2.get("connector_type") == "computer_use", f"connector_type={body2.get('connector_type')!r}", ) check( - "non-actioned-tool-field-absent", - "tool" not in body2, - f"body={body2}", + "non-actioned-tool-field-is-tool-name", + body2.get("tool") == "bash", + f"tool={body2.get('tool')!r}", ) check( "non-actioned-agent-allowed", @@ -133,10 +150,10 @@ async def main() -> None: f"allowed={result2.allowed}", ) - # 3. Same tool, different actions: connector_type must match while - # tool differs — exactly what the old bug could not do (it - # dropped tool_name, and different-action calls on unrelated - # tools could even collide on the folded string). + # 3. Same tool, different actions: the (connector_type, tool) identity + # is stable across both calls — the differing action lives in the + # statement, not the tool identity. The old bug dropped the tool + # name entirely, so identity could not survive at all. _captured.clear() await governor.check_tool_use({"name": "computer", "input": {"action": "screenshot"}}) first = _captured[-1]["json"] if _captured else {} @@ -145,30 +162,36 @@ async def main() -> None: ) second = _captured[-1]["json"] if _captured else {} check( - "same-tool-different-actions-share-connector-type", - first.get("connector_type") == second.get("connector_type") == "computer", - f"first={first.get('connector_type')!r} second={second.get('connector_type')!r}", + "same-tool-different-actions-share-identity", + first.get("connector_type") == second.get("connector_type") == "computer_use" + and first.get("tool") == second.get("tool") == "computer", + f"first=({first.get('connector_type')!r},{first.get('tool')!r}) " + f"second=({second.get('connector_type')!r},{second.get('tool')!r})", ) check( - "same-tool-different-actions-distinguishable-by-tool", - first.get("tool") == "screenshot" and second.get("tool") == "left_click", - f"first tool={first.get('tool')!r} second tool={second.get('tool')!r}", + "same-tool-different-actions-distinguishable-in-statement", + "screenshot" in (first.get("statement") or "") + and "left_click" in (second.get("statement") or ""), + f"first statement={first.get('statement')!r} " + f"second statement={second.get('statement')!r}", ) - # 4. check_result(): no action available at its call site, so - # connector_type=tool_name and `tool` is never sent. + # 4. check_result(): same identity on the response plane so request- + # and response-plane rows correlate. The platform does not yet + # consume `tool` on check-output (#2955); it is sent + # forward-compatibly. _captured.clear() result4 = await governor.check_result("computer", "some tool output text") body4 = _captured[-1]["json"] if _captured else {} check( - "check-result-connector-type-is-tool-name", - body4.get("connector_type") == "computer", + "check-result-connector-type-is-domain-marker", + body4.get("connector_type") == "computer_use", f"connector_type={body4.get('connector_type')!r}", ) check( - "check-result-tool-field-absent", - "tool" not in body4, - f"body={body4}", + "check-result-tool-field-is-tool-name", + body4.get("tool") == "computer", + f"tool={body4.get('tool')!r}", ) check( "check-result-agent-allowed", @@ -179,7 +202,7 @@ async def main() -> None: if failures: print(f"RESULT: FAIL ({len(failures)}): {failures}") sys.exit(1) - print("RESULT: PASS (12/12)") + print(f"RESULT: PASS ({14 - len(failures)}/14)") if __name__ == "__main__": diff --git a/tests/fixtures/wire_shape_baseline.json b/tests/fixtures/wire_shape_baseline.json index 23965a1..675550e 100644 --- a/tests/fixtures/wire_shape_baseline.json +++ b/tests/fixtures/wire_shape_baseline.json @@ -297,7 +297,7 @@ "spec_only": [] }, "MCPCheckInputRequest": { - "note": "acknowledged-sdk-superset: tracked in #2563/#2571 \u2014 SDK declares `content_type` (request-redaction detector selector, ADR-056); the agent consumes it on POST /api/v1/mcp/check-input but agent-api.yaml doesn't yet declare it. Also declares `tool` (epic #2905, platform sub-issue #2904, R3-approved/CI-green but not yet merged to axonflow-enterprise) \u2014 the two-field (server, tool) identity contract; agent-api.yaml will gain it once #2904 lands.", + "note": "acknowledged-sdk-superset: tracked in #2563/#2571 \u2014 SDK declares `content_type` (request-redaction detector selector, ADR-056); the agent consumes it on POST /api/v1/mcp/check-input but agent-api.yaml doesn't yet declare it. Also declares `tool` (epic #2905, platform sub-issue #2904) \u2014 the two-field (server, tool) identity contract; #2904 merged to axonflow-enterprise (c8df2006b) and first released in platform v9.10.0, so this drift just tracks the pinned OpenAPI spec (agent-api.yaml) catching up.", "sdk_only": [ "content_type", "tool" @@ -314,7 +314,7 @@ "spec_only": [] }, "MCPCheckOutputRequest": { - "note": "acknowledged-sdk-superset: tracked in epic #2905, platform sub-issue #2904 (R3-approved/CI-green but not yet merged to axonflow-enterprise) \u2014 SDK declares `tool`, mirroring MCPCheckInputRequest.tool for the two-field (server, tool) identity contract; agent-api.yaml will gain it once #2904 lands.", + "note": "acknowledged-sdk-superset: tracked in epic #2905, platform sub-issue #2904 \u2014 SDK declares `tool`, mirroring MCPCheckInputRequest.tool for the two-field (server, tool) identity contract. Unlike the input-phase field, the platform's MCPCheckOutputRequest has no matching `tool` field on any released version yet (tracked by #2955) \u2014 sending it is forward-compatible and harmless but not yet consumed server-side.", "sdk_only": [ "tool" ], diff --git a/tests/test_computer_use.py b/tests/test_computer_use.py index 5598b05..442c212 100644 --- a/tests/test_computer_use.py +++ b/tests/test_computer_use.py @@ -64,17 +64,21 @@ def _make_governor( class TestConnectorTypeToolSplit: - """The tool name must never be discarded when an action is present. - - Previously ``_derive_connector_type`` folded both into a single - ``connector_type`` string ("computer_use.{action}"), silently dropping - the tool name whenever an action was present. Now `connector_type` - always carries the tool name and `tool` carries the action (when any), - as two separate wire fields — nothing is discarded. + """The tool name must never be discarded, and the (server, tool) identity + is coherent with the LangGraph adapters. + + Previously ``_derive_connector_type`` folded the tool name and the action + into a single ``connector_type`` string ("computer_use.{action}"), + silently dropping the tool name whenever an action was present. Now + ``connector_type`` is the constant "computer_use" connector/domain marker + and ``tool`` carries the tool NAME (e.g. "computer", "bash") — the same + (server, tool) mapping the LangGraph adapters use (RULING 1, epic #2905). + The action is preserved inside ``statement`` (the serialized tool input), + never in ``tool``. """ @pytest.mark.asyncio - async def test_tool_with_action_preserves_both(self) -> None: + async def test_tool_name_is_tool_field_not_action(self) -> None: governor, client = _make_governor() await governor.check_tool_use( { @@ -83,32 +87,35 @@ async def test_tool_with_action_preserves_both(self) -> None: } ) call_kwargs = client.mcp_check_input.call_args.kwargs - assert call_kwargs["connector_type"] == "computer" - assert call_kwargs["tool"] == "left_click" + # connector_type is the constant domain marker; tool is the tool NAME + # (never the action). + assert call_kwargs["connector_type"] == "computer_use" + assert call_kwargs["tool"] == "computer" + # The action is preserved in the serialized statement, not in `tool`. + assert "left_click" in call_kwargs["statement"] @pytest.mark.asyncio async def test_tool_without_action(self) -> None: governor, client = _make_governor() await governor.check_tool_use({"name": "bash", "input": {"command": "echo hi"}}) call_kwargs = client.mcp_check_input.call_args.kwargs - assert call_kwargs["connector_type"] == "bash" - assert call_kwargs["tool"] is None + assert call_kwargs["connector_type"] == "computer_use" + assert call_kwargs["tool"] == "bash" @pytest.mark.asyncio async def test_text_editor_no_action(self) -> None: governor, client = _make_governor() await governor.check_tool_use({"name": "text_editor", "input": {}}) call_kwargs = client.mcp_check_input.call_args.kwargs - assert call_kwargs["connector_type"] == "text_editor" - assert call_kwargs["tool"] is None + assert call_kwargs["connector_type"] == "computer_use" + assert call_kwargs["tool"] == "text_editor" @pytest.mark.asyncio - async def test_different_actions_distinguishable_for_same_tool(self) -> None: - """Two calls to the same tool with different actions must not - collide on connector_type — this is exactly what the old - `computer_use.{action}`-only derivation broke, since it dropped - `name` and could conflate distinct tools that happen to share an - action name.""" + async def test_tool_name_survives_regardless_of_action(self) -> None: + """Two calls to the same tool with different actions carry the same + tool identity — the tool name is never conflated with the action. + This is exactly what the old ``computer_use.{action}``-only + derivation broke, since it dropped the tool name entirely.""" governor, client = _make_governor() await governor.check_tool_use( @@ -121,9 +128,22 @@ async def test_different_actions_distinguishable_for_same_tool(self) -> None: ) second = client.mcp_check_input.call_args.kwargs - assert first["connector_type"] == second["connector_type"] == "computer" - assert first["tool"] == "screenshot" - assert second["tool"] == "left_click" + assert first["connector_type"] == second["connector_type"] == "computer_use" + assert first["tool"] == second["tool"] == "computer" + # The differing action lives in the statement, not the tool identity. + assert "screenshot" in first["statement"] + assert "left_click" in second["statement"] + + @pytest.mark.asyncio + async def test_operation_stays_spec_compliant(self) -> None: + """The action must NOT be smuggled into `operation` — the agent-api + spec constrains it to the enum {query, execute}.""" + governor, client = _make_governor() + await governor.check_tool_use( + {"name": "computer", "input": {"action": "left_click"}}, + ) + call_kwargs = client.mcp_check_input.call_args.kwargs + assert call_kwargs["operation"] == "execute" # --------------------------------------------------------------------------- @@ -241,7 +261,7 @@ async def test_server_blocks_pii_in_input(self) -> None: assert result.block_reason == "PII detected in tool arguments" @pytest.mark.asyncio - async def test_connector_type_is_tool_name_action_sent_as_tool(self) -> None: + async def test_connector_type_is_domain_marker_tool_is_tool_name(self) -> None: governor, client = _make_governor() await governor.check_tool_use( { @@ -250,8 +270,8 @@ async def test_connector_type_is_tool_name_action_sent_as_tool(self) -> None: } ) call_kwargs = client.mcp_check_input.call_args.kwargs - assert call_kwargs["connector_type"] == "computer" - assert call_kwargs["tool"] == "left_click" + assert call_kwargs["connector_type"] == "computer_use" + assert call_kwargs["tool"] == "computer" @pytest.mark.asyncio async def test_connector_type_for_bash(self) -> None: @@ -263,8 +283,8 @@ async def test_connector_type_for_bash(self) -> None: } ) call_kwargs = client.mcp_check_input.call_args.kwargs - assert call_kwargs["connector_type"] == "bash" - assert call_kwargs["tool"] is None + assert call_kwargs["connector_type"] == "computer_use" + assert call_kwargs["tool"] == "bash" @pytest.mark.asyncio async def test_custom_blocked_patterns(self) -> None: @@ -295,8 +315,12 @@ async def test_missing_name_defaults_to_unknown(self) -> None: governor, client = _make_governor() await governor.check_tool_use({"input": {"action": "click"}}) call_kwargs = client.mcp_check_input.call_args.kwargs - assert call_kwargs["connector_type"] == "unknown" - assert call_kwargs["tool"] == "click" + # connector_type stays the constant domain marker; the missing tool + # name falls back to "unknown" in the `tool` field. The action lives + # in the statement, never in `tool`. + assert call_kwargs["connector_type"] == "computer_use" + assert call_kwargs["tool"] == "unknown" + assert "click" in call_kwargs["statement"] @pytest.mark.asyncio async def test_missing_input_defaults_to_empty(self) -> None: @@ -338,11 +362,16 @@ async def test_result_blocked(self) -> None: assert result.block_reason == "Exfiltration detected" @pytest.mark.asyncio - async def test_connector_type_correct(self) -> None: + async def test_connector_type_and_tool_coherent_with_input_plane(self) -> None: governor, client = _make_governor() await governor.check_result("text_editor", "file contents") call_kwargs = client.mcp_check_output.call_args.kwargs - assert call_kwargs["connector_type"] == "text_editor" + # Response plane shares the same (server, tool) identity as the + # request plane: connector_type is the domain marker, tool is the + # tool NAME (RULING 1). Platform doesn't yet consume `tool` on + # check-output (#2955) but the SDK sends it forward-compatibly. + assert call_kwargs["connector_type"] == "computer_use" + assert call_kwargs["tool"] == "text_editor" @pytest.mark.asyncio async def test_dict_redacted_data_json_serialized(self) -> None: From c4e893c6885a1d2bcddcdf51bac84b5552466973 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 17 Jul 2026 23:35:56 +0200 Subject: [PATCH 3/4] docs(changelog): add blank line before ### Added (MD022) Signed-off-by: Saurabh Jain --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10596c3..ad0e558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`check-output`) does **not** consume `tool` on any released platform version yet (tracked by #2955, targeted for v9.11.0); the SDK sends it forward-compatibly and current platforms ignore it. + ### Added - **`AuditToolCallRequest.caller_name`** (getaxonflow/axonflow-enterprise#2912, From f1c45da93865c88f7a0ebf465469d3d0119aa01f Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 18 Jul 2026 00:21:21 +0200 Subject: [PATCH 4/4] =?UTF-8?q?release:=20finalize=20v9.0.0=20=E2=80=94=20?= =?UTF-8?q?version=20bump=20+=20clean=20[9.0.0]=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump pyproject.toml + axonflow/_version.py to 9.0.0 and finalize the tool-identity content under ## [9.0.0] - 2026-07-18 (banner dropped). The LangGraph and Computer Use de-concatenation is described in a single combined entry (byte-identical across #216/#217 — merge #216 first). Changelog scrubbed of internal issue/PR numbers and internal review terminology; minimum platform stated as versions (check-input tool = platform v9.10.0+, response-plane tool = platform v9.11.0+). Signed-off-by: Saurabh Jain --- CHANGELOG.md | 101 +++++++++++++++++++++---------------------- axonflow/_version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 51 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad0e558..0a2570f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,66 +11,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -> **This release contains a breaking change and MUST be published as a major -> version bump.** The `connector_type` wire value emitted by -> `ComputerUseGovernor` changes shape; policies matching the old value stop -> matching until re-scoped (see the migration note below). +## [9.0.0] - 2026-07-18 ### Changed (BREAKING) -- **`ComputerUseGovernor` now reports the (server, tool) identity as two - separate wire fields instead of concatenating them into `connector_type`.** - Previously `_derive_connector_type(tool_name, action)` folded the tool name - and the action into a single `connector_type` string - (`"computer_use.{action}"`), silently discarding the tool name whenever an - action was present. Now: - - `connector_type` is the constant connector/domain marker `"computer_use"`; - - `tool` carries the tool **name** (e.g. `"computer"`, `"bash"`, - `"text_editor"`) — the same `(server, tool)` mapping the LangGraph adapter - uses, so the `tool_name` audit column means the tool name regardless of - which adapter emitted it (epic #2905, RULING 1); - - the action (e.g. `"left_click"`) is preserved inside the serialized - `statement`, where the policy engine already evaluates it — it is **not** - placed in `tool`, nor in `operation` (the agent-api spec constrains - `operation` to the enum `{query, execute}`). - - `_derive_connector_type` is removed. `check_result()` sends the same - identity on the response plane (`connector_type="computer_use"`, - `tool=`) so request- and response-plane rows correlate. - `mcp_check_input`/`mcp_check_output` (and their `check_tool_input`/ - `check_tool_output` aliases) gain a matching optional `tool` parameter. - - **Migration.** Policies or per-connector settings scoped to the old - concatenated value — e.g. `connector_type == "computer_use.left_click"`, a - bare tool name like `"bash"`, or a `computer_use.*` prefix match — stop - matching after upgrade. Re-scope them to match `connector_type == - "computer_use"` together with the `tool` field (e.g. `tool == "bash"`). - **This adapter has no `connector_type_fn`/`tool_fn` escape hatch**, so there - is no way to restore the old concatenated value from the SDK side — - re-scoping the policies is the only migration path. +- **The LangGraph and Computer Use adapters now report the (server, tool) + identity as two separate wire fields instead of concatenating them into + `connector_type`.** `mcp_check_input`/`mcp_check_output` (and their + `check_tool_input`/`check_tool_output` aliases) gain an optional `tool` + parameter, sent alongside `connector_type` on the wire — the platform's + two-field (server, tool) identity contract. The tool name is never folded + back into `connector_type`. + + - **LangGraph** (`mcp_tool_interceptor`) now sends + `connector_type = request.server_name` and `tool = request.name` instead + of `f"{server_name}.{name}"`; the default `connector_type_fn` returns the + bare `server_name`. `connector_type_fn` is the compatibility lever — a + caller can restore any prior `connector_type` value (including the old + concatenated form, `lambda req: f"{req.server_name}.{req.name}"`) without + losing the separate `tool` field. The human-readable `statement` is now + `"{connector_type}.{tool}(args)"` built from the resolved connector type; + with a custom `connector_type_fn` its shape shifts from `"{custom}(args)"` + to `"{custom}.{tool}(args)"`. With the default resolver, a tool whose + `server_name` is empty sends `connector_type=""`, which the platform + rejects with HTTP 400 — the call raises `ConnectorError` and is blocked + (fail-closed); supply a `connector_type_fn` for server-less MCP tools. + + - **Computer Use** (`ComputerUseGovernor`) now sends the constant + `connector_type = "computer_use"` and `tool =` the tool name (`computer`, + `bash`, `text_editor`). The action (e.g. `left_click`) is preserved inside + the serialized `statement`, not in `tool` (and not in `operation`, which + is constrained to `{query, execute}`). This adapter has **no** + `connector_type_fn` escape hatch, so re-scoping policies is the only + migration path. + + **Migration.** Policies or per-connector settings matching the old + concatenated value — e.g. `connector_type == "filesystem.read_file"` or + `"computer_use.left_click"` — stop matching after upgrade. Re-scope them to + match `connector_type` (the bare server name, or `"computer_use"`) together + with the `tool` field (e.g. `tool == "read_file"`). **Minimum platform.** The `tool` field is consumed on `POST - /api/v1/mcp/check-input` by platform **v9.10.0+** (enterprise `c8df2006b`, - epic #2905 / #2904). On platforms below v9.10.0 the `tool` field is silently - ignored and identity degrades to the bare `"computer_use"` connector, which - is coarser than the old concatenated value — **upgrade the platform to - v9.10.0+ before adopting this SDK major.** The response plane - (`check-output`) does **not** consume `tool` on any released platform - version yet (tracked by #2955, targeted for v9.11.0); the SDK sends it - forward-compatibly and current platforms ignore it. + /api/v1/mcp/check-input` by **AxonFlow platform v9.10.0+**. On older + platforms it is silently dropped and identity degrades to the bare + `connector_type` — upgrade the platform to v9.10.0+ before adopting this SDK + major. Response-plane (`check-output`) `tool` scoping requires **AxonFlow + platform v9.11.0+**; until then the SDK sends it forward-compatibly and older + platforms ignore it. ### Added -- **`AuditToolCallRequest.caller_name`** (getaxonflow/axonflow-enterprise#2912, - sub-issue of epic #2905) — identifies which client made a non-LLM tool - call (e.g. `claude_code`, `codex`, `cursor`, `openclaw`). Replaces the - misleadingly-named `tool_type` field, which every real caller actually - used to identify the calling client rather than any property of the - tool. `tool_type` is kept as a deprecated input fallback (not removed): - the server resolves `caller_name` if supplied, else the legacy - `tool_type`, else a default. See - `runtime-e2e/caller_name_audit/` for the real-stack proof that - `caller_name` reaches `policy_details.caller_name` on the audit row. +- **`AuditToolCallRequest.caller_name`** — identifies which client made a + non-LLM tool call (e.g. `claude_code`, `codex`, `cursor`, `openclaw`). + Replaces the misleadingly-named `tool_type` field, which every real caller + actually used to identify the calling client rather than any property of the + tool. `tool_type` is kept as a deprecated input fallback (not removed): the + server resolves `caller_name` if supplied, else the legacy `tool_type`, else + a default. ## [8.5.1] - 2026-07-09 — Interceptor sync bridge + async-client detection + example fixes diff --git a/axonflow/_version.py b/axonflow/_version.py index 4ea87e2..7c29405 100644 --- a/axonflow/_version.py +++ b/axonflow/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the AxonFlow SDK version.""" -__version__ = "8.5.1" +__version__ = "9.0.0" diff --git a/pyproject.toml b/pyproject.toml index bc076e7..cace8e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "axonflow" -version = "8.5.1" +version = "9.0.0" description = "AxonFlow Python SDK - Enterprise AI Governance in 3 Lines of Code" readme = "README.md" license = {text = "MIT"}