Merge pull request #2612 from Molecule-AI/staging

staging → main: auto-promote a8708ca
This commit is contained in:
molecule-ai[bot] 2026-05-03 11:18:27 -07:00 committed by GitHub
commit a1e40fe0d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 117 additions and 8 deletions

View File

@ -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 ""

View File

@ -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 <channel> 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\"><script>alert(1)</script>",
"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()