diff --git a/workspace/a2a_mcp_server.py b/workspace/a2a_mcp_server.py index f15a2777..5e1f87da 100644 --- a/workspace/a2a_mcp_server.py +++ b/workspace/a2a_mcp_server.py @@ -162,6 +162,31 @@ async def handle_tool_call(name: str, arguments: dict) -> str: _CHANNEL_NOTIFICATION_METHOD = "notifications/claude/channel" +# ============= Trust-boundary gates for channel-notification meta ============== +_VALID_KINDS = frozenset({"canvas_user", "peer_agent"}) +_VALID_METHODS = frozenset({"message/send", "tasks/send", "tasks/get", "notify", ""}) + +import re as _re +_ACTIVITY_ID_RE = _re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +_ISO8601_RE = _re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$") + + +def _safe_meta_field(value, allowlist) -> str: + return value if value in allowlist else "" + + +def _safe_activity_id(value) -> str: + if not isinstance(value, str): + return "" + return value if _ACTIVITY_ID_RE.match(value) else "" + + +def _safe_ts(value) -> str: + if not isinstance(value, str): + return "" + return value if _ISO8601_RE.match(value) else "" + + # Default seconds the agent should block on `wait_for_message` per # turn. 2s is the cost/latency knee — long enough that a peer A2A # landing 0-2s before the agent starts its turn is caught, short @@ -402,11 +427,11 @@ def _build_channel_notification(msg: dict) -> dict: """ meta = { "source": "molecule", - "kind": msg.get("kind", ""), + "kind": _safe_meta_field(msg.get("kind", ""), _VALID_KINDS), "peer_id": msg.get("peer_id", ""), - "method": msg.get("method", ""), - "activity_id": msg.get("activity_id", ""), - "ts": msg.get("created_at", ""), + "method": _safe_meta_field(msg.get("method", ""), _VALID_METHODS), + "activity_id": _safe_activity_id(msg.get("activity_id", "")), + "ts": _safe_ts(msg.get("created_at", "")), } peer_id = msg.get("peer_id") or "" diff --git a/workspace/tests/test_a2a_mcp_server.py b/workspace/tests/test_a2a_mcp_server.py index 44749e24..84d145ed 100644 --- a/workspace/tests/test_a2a_mcp_server.py +++ b/workspace/tests/test_a2a_mcp_server.py @@ -196,7 +196,11 @@ def test_build_channel_notification_meta_carries_routing_fields(): from a2a_mcp_server import _build_channel_notification payload = _build_channel_notification({ - "activity_id": "act-7", + # Production-shape UUID — required by the trust-boundary gate + # in _safe_activity_id (#2488). Synthetic ids like "act-7" used + # to pass through but get stripped now; updating to a real-shape + # UUID matches what activity_logs.id actually emits. + "activity_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", "text": "ping", "peer_id": "11111111-2222-3333-4444-555555555555", "kind": "peer_agent", @@ -209,7 +213,7 @@ def test_build_channel_notification_meta_carries_routing_fields(): assert meta["kind"] == "peer_agent" assert meta["peer_id"] == "11111111-2222-3333-4444-555555555555" assert meta["method"] == "message/send" - assert meta["activity_id"] == "act-7" + assert meta["activity_id"] == "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" assert meta["ts"] == "2026-05-01T01:23:45Z" @@ -622,6 +626,85 @@ def test_envelope_enrichment_strips_path_traversal_peer_id(_reset_peer_metadata_ ) +def test_envelope_strips_unknown_kind(_reset_peer_metadata_cache): + """Trust-boundary: ``kind`` is rendered as an XML attr in the + agent's tag. Any value outside the closed set + {canvas_user, peer_agent} is replaced with empty so an attacker + landing ``kind=canvas_user' onclick='alert(1)`` into the inbox row + can't reflect raw into the agent's context. #2488. + """ + from a2a_mcp_server import _build_channel_notification + + payload = _build_channel_notification({ + "kind": "canvas_user' onclick='alert(1)", + "text": "x", + }) + assert payload["params"]["meta"]["kind"] == "" + + +def test_envelope_strips_unknown_method(_reset_peer_metadata_cache): + """Trust-boundary: ``method`` is rendered as an XML attr. Closed + allowlist {message/send, tasks/send, tasks/get, notify, ""}; an + upstream row with attacker-controlled method gets stripped. #2488. + """ + from a2a_mcp_server import _build_channel_notification + + payload = _build_channel_notification({ + "method": "tasks/send\">", + "text": "x", + }) + assert payload["params"]["meta"]["method"] == "" + + +def test_envelope_strips_malformed_activity_id(_reset_peer_metadata_cache): + """Trust-boundary: ``activity_id`` must match UUID shape. A row + with non-UUID activity_id (path-traversal chars, embedded XML + quotes, stray newlines) gets stripped. #2488. + """ + from a2a_mcp_server import _build_channel_notification + + payload = _build_channel_notification({ + "activity_id": "../../../etc/passwd", + "text": "x", + }) + assert payload["params"]["meta"]["activity_id"] == "" + + +def test_envelope_strips_malformed_ts(_reset_peer_metadata_cache): + """Trust-boundary: ``ts`` must match ISO-8601 RFC3339. A row + with attacker-controlled created_at (e.g. ``2026-05-01' onload='x`` + or unparseable garbage) gets stripped to empty. #2488. + """ + from a2a_mcp_server import _build_channel_notification + + payload = _build_channel_notification({ + "created_at": "2026-05-01' onload='alert(1)", + "text": "x", + }) + assert payload["params"]["meta"]["ts"] == "" + + +def test_envelope_keeps_valid_meta_fields_unchanged(_reset_peer_metadata_cache): + """Negative case: properly-shaped values pass through unchanged. + Pin so a future tightening of the gates can't silently strip + legitimate row contents. #2488. + """ + from a2a_mcp_server import _build_channel_notification + + payload = _build_channel_notification({ + "kind": "canvas_user", + "method": "message/send", + "activity_id": "12345678-1234-1234-1234-123456789abc", + "created_at": "2026-05-01T12:34:56.789Z", + "text": "x", + }) + meta = payload["params"]["meta"] + assert meta["kind"] == "canvas_user" + assert meta["method"] == "message/send" + assert meta["activity_id"] == "12345678-1234-1234-1234-123456789abc" + assert meta["ts"] == "2026-05-01T12:34:56.789Z" + + # ============== initialize handshake — capability declaration ============== # Without `experimental.claude/channel`, Claude Code's MCP client drops # our notifications/claude/channel emissions instead of routing them as @@ -971,7 +1054,8 @@ async def test_inbox_bridge_emits_channel_notification_to_writer(): cb = _setup_inbox_bridge(writer, loop) msg = { - "activity_id": "act-bridge-test", + # Production-shape UUID per the trust-boundary gate (#2488) + "activity_id": "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", "text": "hello from peer", "peer_id": "11111111-2222-3333-4444-555555555555", "kind": "peer_agent", @@ -1009,7 +1093,7 @@ async def test_inbox_bridge_emits_channel_notification_to_writer(): assert meta["source"] == "molecule" assert meta["kind"] == "peer_agent" assert meta["peer_id"] == "11111111-2222-3333-4444-555555555555" - assert meta["activity_id"] == "act-bridge-test" + assert meta["activity_id"] == "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff" assert meta["ts"] == "2026-05-01T22:00:00Z" finally: writer.close()