diff --git a/.lint_baselines/falsey_clobber.json b/.lint_baselines/falsey_clobber.json index 54b0cc1..6d42bcc 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:2292:24", - "axonflow/client.py:2313:33", - "axonflow/client.py:2314:31", - "axonflow/client.py:2326:25", - "axonflow/client.py:2387:28", - "axonflow/client.py:2428:69", + "axonflow/client.py:1686:37", + "axonflow/client.py:1727:18", + "axonflow/client.py:1785:37", + "axonflow/client.py:2309:24", + "axonflow/client.py:2330:33", + "axonflow/client.py:2331:31", + "axonflow/client.py:2343:25", + "axonflow/client.py:2404:28", + "axonflow/client.py:2445:69", "axonflow/client.py:300:14", "axonflow/client.py:305:24", "axonflow/client.py:306:20", "axonflow/client.py:529:44", - "axonflow/client.py:6469:25", + "axonflow/client.py:6486: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 87871e6..0a2570f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,18 +11,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [9.0.0] - 2026-07-18 + +### Changed (BREAKING) + +- **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 **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/axonflow/adapters/computer_use.py b/axonflow/adapters/computer_use.py index a7d55e8..7c17e4d 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,30 @@ async def check_tool_use(self, tool_use_block: dict[str, Any]) -> CheckResult: policies_evaluated=0, ) - # Derive connector_type from tool name + action - 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, - ) - - # 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=connector_type, + connector_type="computer_use", + tool=name, statement=statement, operation="execute", ) @@ -204,10 +203,16 @@ async def check_result( Returns: CheckResult with allowed status and optional redacted_result. """ - connector_type = _derive_connector_type(tool_name) - + # 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=connector_type, + connector_type="computer_use", + tool=tool_name, message=result, ) diff --git a/axonflow/client.py b/axonflow/client.py index d4095fd..b72c65e 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 @@ -1518,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, @@ -1530,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, @@ -1545,6 +1554,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 +1571,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 +1597,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 @@ -1618,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, @@ -1630,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, @@ -7555,6 +7572,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, @@ -7569,6 +7587,7 @@ def mcp_check_input( statement, operation, parameters, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, @@ -7618,6 +7637,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, @@ -7631,6 +7651,7 @@ def mcp_check_output( message, metadata, row_count, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, @@ -7645,6 +7666,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, @@ -7658,6 +7680,7 @@ def check_tool_input( statement, operation, parameters, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, @@ -7674,6 +7697,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, @@ -7687,6 +7711,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 dce7b84..f4b9f35 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -407,6 +407,13 @@ 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 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, 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) operation: str = Field(default="execute") @@ -462,6 +469,13 @@ 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). 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) metadata: dict[str, Any] | None = Field(default=None) 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"} 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..99e5a75 --- /dev/null +++ b/runtime-e2e/computer_use_tool_name_split/test.py @@ -0,0 +1,209 @@ +"""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 +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 +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_use"`` AND ``tool="computer"`` as two + distinct wire fields — not the old folded ``"computer_use.left_click"`` + 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`` 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: + + 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: 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-domain-marker", + body.get("connector_type") == "computer_use", + f"connector_type={body.get('connector_type')!r}", + ) + check( + "actioned-tool-field-is-tool-name", + body.get("tool") == "computer", + 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-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: connector_type is still the domain marker and + # tool carries the tool name. + _captured.clear() + 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") == "computer_use", + f"connector_type={body2.get('connector_type')!r}", + ) + check( + "non-actioned-tool-field-is-tool-name", + body2.get("tool") == "bash", + f"tool={body2.get('tool')!r}", + ) + check( + "non-actioned-agent-allowed", + result2.allowed, + f"allowed={result2.allowed}", + ) + + # 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 {} + 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-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-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(): 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-domain-marker", + body4.get("connector_type") == "computer_use", + f"connector_type={body4.get('connector_type')!r}", + ) + check( + "check-result-tool-field-is-tool-name", + body4.get("tool") == "computer", + f"tool={body4.get('tool')!r}", + ) + check( + "check-result-agent-allowed", + result4.allowed, + f"allowed={result4.allowed}", + ) + + if failures: + print(f"RESULT: FAIL ({len(failures)}): {failures}") + sys.exit(1) + print(f"RESULT: PASS ({14 - len(failures)}/14)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/fixtures/wire_shape_baseline.json b/tests/fixtures/wire_shape_baseline.json index 1f1dab4..527aba5 100644 --- a/tests/fixtures/wire_shape_baseline.json +++ b/tests/fixtures/wire_shape_baseline.json @@ -304,9 +304,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) \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" + "content_type", + "tool" ], "spec_only": [] }, @@ -319,6 +320,13 @@ ], "spec_only": [] }, + "MCPCheckOutputRequest": { + "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" + ], + "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..442c212 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,91 @@ 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, and the (server, tool) identity + is coherent with the LangGraph adapters. - def test_tool_without_action(self) -> None: - assert _derive_connector_type("bash") == "computer_use.bash" + 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``. + """ - def test_text_editor(self) -> None: - assert _derive_connector_type("text_editor") == "computer_use.text_editor" + @pytest.mark.asyncio + async def test_tool_name_is_tool_field_not_action(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 + # 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"] == "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"] == "computer_use" + assert call_kwargs["tool"] == "text_editor" + + @pytest.mark.asyncio + 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( + {"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_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" # --------------------------------------------------------------------------- @@ -193,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_uses_action(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( { @@ -202,7 +270,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_use" + assert call_kwargs["tool"] == "computer" @pytest.mark.asyncio async def test_connector_type_for_bash(self) -> None: @@ -214,7 +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"] == "computer_use.bash" + assert call_kwargs["connector_type"] == "computer_use" + assert call_kwargs["tool"] == "bash" @pytest.mark.asyncio async def test_custom_blocked_patterns(self) -> None: @@ -245,7 +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"] == "computer_use.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: @@ -287,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"] == "computer_use.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: