forked from molecule-ai/molecule-core
Merge pull request #2611 from Molecule-AI/fix/2488-trust-boundary-meta-fields
feat(security): trust-boundary gate non-peer_id meta fields in _build_channel_notification (#2488)
This commit is contained in:
commit
a8708caf73
@ -162,6 +162,31 @@ async def handle_tool_call(name: str, arguments: dict) -> str:
|
|||||||
_CHANNEL_NOTIFICATION_METHOD = "notifications/claude/channel"
|
_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
|
# Default seconds the agent should block on `wait_for_message` per
|
||||||
# turn. 2s is the cost/latency knee — long enough that a peer A2A
|
# 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
|
# landing 0-2s before the agent starts its turn is caught, short
|
||||||
@ -402,11 +427,11 @@ def _build_channel_notification(msg: dict) -> dict:
|
|||||||
"""
|
"""
|
||||||
meta = {
|
meta = {
|
||||||
"source": "molecule",
|
"source": "molecule",
|
||||||
"kind": msg.get("kind", ""),
|
"kind": _safe_meta_field(msg.get("kind", ""), _VALID_KINDS),
|
||||||
"peer_id": msg.get("peer_id", ""),
|
"peer_id": msg.get("peer_id", ""),
|
||||||
"method": msg.get("method", ""),
|
"method": _safe_meta_field(msg.get("method", ""), _VALID_METHODS),
|
||||||
"activity_id": msg.get("activity_id", ""),
|
"activity_id": _safe_activity_id(msg.get("activity_id", "")),
|
||||||
"ts": msg.get("created_at", ""),
|
"ts": _safe_ts(msg.get("created_at", "")),
|
||||||
}
|
}
|
||||||
|
|
||||||
peer_id = msg.get("peer_id") or ""
|
peer_id = msg.get("peer_id") or ""
|
||||||
|
|||||||
@ -196,7 +196,11 @@ def test_build_channel_notification_meta_carries_routing_fields():
|
|||||||
from a2a_mcp_server import _build_channel_notification
|
from a2a_mcp_server import _build_channel_notification
|
||||||
|
|
||||||
payload = _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",
|
"text": "ping",
|
||||||
"peer_id": "11111111-2222-3333-4444-555555555555",
|
"peer_id": "11111111-2222-3333-4444-555555555555",
|
||||||
"kind": "peer_agent",
|
"kind": "peer_agent",
|
||||||
@ -209,7 +213,7 @@ def test_build_channel_notification_meta_carries_routing_fields():
|
|||||||
assert meta["kind"] == "peer_agent"
|
assert meta["kind"] == "peer_agent"
|
||||||
assert meta["peer_id"] == "11111111-2222-3333-4444-555555555555"
|
assert meta["peer_id"] == "11111111-2222-3333-4444-555555555555"
|
||||||
assert meta["method"] == "message/send"
|
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"
|
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 ==============
|
# ============== initialize handshake — capability declaration ==============
|
||||||
# Without `experimental.claude/channel`, Claude Code's MCP client drops
|
# Without `experimental.claude/channel`, Claude Code's MCP client drops
|
||||||
# our notifications/claude/channel emissions instead of routing them as
|
# 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)
|
cb = _setup_inbox_bridge(writer, loop)
|
||||||
|
|
||||||
msg = {
|
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",
|
"text": "hello from peer",
|
||||||
"peer_id": "11111111-2222-3333-4444-555555555555",
|
"peer_id": "11111111-2222-3333-4444-555555555555",
|
||||||
"kind": "peer_agent",
|
"kind": "peer_agent",
|
||||||
@ -1009,7 +1093,7 @@ async def test_inbox_bridge_emits_channel_notification_to_writer():
|
|||||||
assert meta["source"] == "molecule"
|
assert meta["source"] == "molecule"
|
||||||
assert meta["kind"] == "peer_agent"
|
assert meta["kind"] == "peer_agent"
|
||||||
assert meta["peer_id"] == "11111111-2222-3333-4444-555555555555"
|
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"
|
assert meta["ts"] == "2026-05-01T22:00:00Z"
|
||||||
finally:
|
finally:
|
||||||
writer.close()
|
writer.close()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user