Compare commits

..

14 Commits

Author SHA1 Message Date
molecule-ai[bot] a1e40fe0d9 Merge pull request #2612 from Molecule-AI/staging
staging → main: auto-promote a8708ca
2026-05-03 11:18:27 -07:00
Hongming Wang a8708caf73 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)
2026-05-03 18:02:10 +00:00
Hongming Wang 02ae2fd6fb feat(security): trust-boundary gate non-peer_id meta fields in _build_channel_notification (#2488)
Defense-in-depth follow-up to #2481 (peer_id trust-boundary gate).
Same XML-attribute injection vector applies to the four other meta
fields rendered as agent-context attrs in the <channel> tag:

  <channel kind="..." method="..." activity_id="..." ts="..." source="molecule">

Each field is now passed through a closed-set / shape-validate gate:

- kind     → frozenset {canvas_user, peer_agent} via _safe_meta_field
- method   → frozenset {message/send, tasks/send, tasks/get, notify, ""}
- activity_id → UUID-shape regex via _safe_activity_id
- ts       → ISO-8601 RFC3339 regex via _safe_ts

Any value outside the allowed shape is replaced with empty string.
Today the values come from a platform-DB column so they're trusted,
but "trust the source" was the same assumption that got peer_id into
trouble (#2481). Closed-enum allowlists make this row-content-blind.

5 new tests mirroring test_envelope_enrichment_strips_path_traversal_peer_id:
- test_envelope_strips_unknown_kind         — kind injection stripped
- test_envelope_strips_unknown_method       — method injection stripped
- test_envelope_strips_malformed_activity_id — non-UUID stripped
- test_envelope_strips_malformed_ts         — non-ISO8601 stripped
- test_envelope_keeps_valid_meta_fields_unchanged — happy-path negative case

Mutation-tested: temporarily making _safe_meta_field permissive kills
both kind/method strip tests with the injection payload reflecting
into the meta dict, confirming the gate is what blocks them.

Two existing tests updated to use UUID-shaped activity_ids ("act-7",
"act-bridge-test" → real UUIDs) since the gate strips synthetic ids.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:58:52 -07:00
Hongming Wang f21d79c4ad Merge pull request #2610 from Molecule-AI/auto-sync/main-120bb1f0
chore: sync main → staging (auto, ff to 120bb1f0)
2026-05-03 17:53:10 +00:00
molecule-ai[bot] 120bb1f0a2 Merge pull request #2609 from Molecule-AI/staging
staging → main: auto-promote 257079c
2026-05-03 10:48:41 -07:00
Hongming Wang cfd5ec8d82 Merge pull request #2608 from Molecule-AI/ui/canvas-workspace-card-contrast
fix(canvas): WorkspaceNode + tier-config contrast in light theme
2026-05-03 17:32:22 +00:00
Hongming Wang a4a32cded5 fix(canvas): WorkspaceNode + tier-config contrast in light theme
Cards on the canvas had multiple low-contrast surfaces in light mode:

WorkspaceNode.tsx (parent + TeamMemberChip) — same fixes both copies:
- "N sub" badge: hardcoded text-violet-300 + bg-violet-900/40 → semantic
  text-accent + bg-accent/15 + border-accent/40 (themes correctly).
- "REMOTE" pill: hardcoded violet/40 alpha → solid bg-violet-600 text-white
  (works on either surface with WCAG AA contrast).
- Runtime pill: drop /60 + /30 alpha modifiers, use solid surface-card +
  border-line tokens.
- Skill chips (online): text-good/80 + bg-emerald-950/30 (washed-out on
  warm-paper) → text-good + bg-good/15 + border-good/40 semantic.
- Skill chips (offline): text-ink-mid + bg-surface-card without alpha.
- Restart-to-apply banner: bg-sky-950/30 + text-sky-300/80 → bg-accent/10 +
  text-accent (sky-950 was nearly invisible on cream).
- Provisioning status text: text-sky-400 (poor on cream) → text-accent.
- "+N more" badges: text-ink-soft (3.5:1) → text-ink-mid (7:1).
- Active-tasks dot: bg-amber-400 + text-warm/80 → semantic bg-warm + text-warm.
- Degraded error preview: bg-amber-950/20 + text-warm/60 → bg-warm/10 +
  text-warm + border-warm/40.
- Eject icon hover: hover:text-sky-400 → hover:text-accent.
- Role text: text-ink-soft → text-ink-mid.

design-tokens.ts:
- TIER_CONFIG was dark-only: T2 (text-sky-400 + bg-sky-950/50), T3
  (text-violet-400 + bg-violet-950/50), T4 (text-warm + bg-amber-950/50).
  Migrated to solid bg + white text patterns: T2=accent, T3=violet-600,
  T4=warm. T1 stays neutral (surface-card + ink-mid). All four pass WCAG
  AA on either theme.

No globals.css changes; uses existing semantic tokens.
2026-05-03 10:28:49 -07:00
Hongming Wang 257079c7a2 Merge pull request #2605 from Molecule-AI/fix/2485-chat-history-followups
fix(chat-history): correct docstring inversion + pin empty-history JSON shape (#2485)
2026-05-03 17:24:42 +00:00
Hongming Wang 0567502316 Merge pull request #2607 from Molecule-AI/auto-sync/main-7cba0477
chore: sync main → staging (auto, ff to 7cba0477)
2026-05-03 17:23:35 +00:00
molecule-ai[bot] 7cba0477cc Merge pull request #2606 from Molecule-AI/staging
staging → main: auto-promote 4e72f1d
2026-05-03 10:18:56 -07:00
Hongming Wang ff3dcd37f6 fix(chat-history): correct docstring inversion + pin empty-history JSON shape (#2485)
Two follow-ups from the multi-axis review of #2474:

1. **Docstring inversion** in tool_chat_history. The doc said
   '(source_id=peer)' meant 'this workspace is the sender' — actually
   it means the *peer* is the sender (source_id is where the activity
   came FROM). Reframed to 'where the peer is either the sender or
   the recipient' to match the underlying SQL semantics.

2. **Empty-history test**. TestChatHistory had 10 tests but no
   200+[] happy-path pin. Added test_empty_history_returns_empty_json_list
   asserting result == '[]' on exact-equality (per assert-exact
   memory — substring '[]' would match envelope shapes too).

Both changes are pure docs+tests — no behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:09:15 -07:00
Hongming Wang 4e72f1d1db Merge pull request #2604 from Molecule-AI/ui/canvas-chat-contrast
fix(canvas): chat bubble + sub-tab contrast in light theme
2026-05-03 17:00:54 +00:00
Hongming Wang e22f7969f8 Merge pull request #2603 from Molecule-AI/auto-sync/main-46c8c1de
chore: sync main → staging (auto, ff to 46c8c1de)
2026-05-03 17:00:37 +00:00
Hongming Wang 3d145da99d fix(canvas): chat bubble + sub-tab contrast in light theme
Chat bubble fixes (canvas/src/components/tabs/ChatTab.tsx):
- User bubble: bg-accent-strong/30 + text-blue-100 → bg-accent + text-white
  (translucent dark-blue overlay on warm-paper surface read as pale lavender
  with near-invisible light-blue text — a real WCAG AA failure on the
  highest-traffic surface in canvas).
- System/error bubble: bg-red-900/30 + text-red-200 → bg-bad/10 + text-bad,
  using semantic tokens so dark-mode adapts automatically.
- Agent bubble: drop /80 + /30 opacity modifiers; solid bg-surface-card +
  text-ink + border-line gives consistent contrast in both themes.
- prose-invert was unconditional, so markdown text on agent/system bubbles
  rendered as light text on a light surface in light mode. Make it apply
  only on the user bubble (the only dark surface in this component).
- Timestamp: text-ink-soft is too pale on warm-paper; use text-ink-mid for
  agent/system, white/70 for user (visible on the now-solid blue bg).

Sub-tab bar fixes (canvas/src/components/SidePanel.tsx):
- Right-edge fade was hardcoded `from-zinc-950` — that paints a dark vertical
  strip on the right edge of the tab bar in light mode. Switch to
  `from-surface` so the gradient blends into whichever theme is active.
- Inactive tab text: text-ink-soft (~3.5:1 on warm-paper) → text-ink-mid
  (~7:1). Active tab background: drop the /40 opacity so the selection is
  unambiguous on either surface.

No semantic-token additions; all changes use existing warm-paper tokens
that already work in both themes.
2026-05-03 09:58:18 -07:00
8 changed files with 177 additions and 46 deletions
+3 -3
View File
@@ -202,7 +202,7 @@ export function SidePanel() {
{/* Tabs — relative wrapper lets the fade gradient position against the scroll container */}
<div className="relative border-b border-line/40">
{/* Right-edge fade: signals more tabs are hidden off-screen when the bar overflows */}
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-zinc-950 to-transparent z-10" aria-hidden="true" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-surface to-transparent z-10" aria-hidden="true" />
<div
role="tablist"
aria-label="Workspace panel tabs"
@@ -232,8 +232,8 @@ export function SidePanel() {
onClick={() => setPanelTab(tab.id)}
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 ${
panelTab === tab.id
? "text-ink bg-surface-card/40 border-b-2 border-accent"
: "text-ink-soft hover:text-ink hover:bg-surface-card/40"
? "text-ink bg-surface-card border-b-2 border-accent"
: "text-ink-mid hover:text-ink hover:bg-surface-card/60"
}`}
>
<span className="mr-1 opacity-50" aria-hidden="true">{tab.icon}</span>
+23 -23
View File
@@ -36,7 +36,7 @@ function EjectIcon(props: React.SVGProps<SVGSVGElement>) {
export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>) {
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline;
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-ink-soft bg-surface-card" };
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-ink-mid bg-surface-card border border-line" };
// Org-deploy context — four derived flags off one store subscription.
// Drives the shimmer while provisioning, the dimmed/non-draggable
// treatment on locked descendants, and the Cancel pill on the root.
@@ -179,7 +179,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
</div>
<div className="flex items-center gap-1.5 shrink-0">
{hasChildren && (
<span className="text-[10px] font-mono text-violet-300 bg-violet-900/40 border border-violet-700/30 px-1.5 py-0.5 rounded-md">
<span className="text-[10px] font-mono text-accent bg-accent/15 border border-accent/40 px-1.5 py-0.5 rounded-md">
{descendantCount} sub
</span>
)}
@@ -207,13 +207,13 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
<div className="mb-1 flex items-center gap-1">
{runtime === "external" ? (
<span
className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-violet-200 bg-violet-900/50 border border-violet-500/40"
className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-white bg-violet-600 border border-violet-700"
title="Phase 30 remote agent — runs outside this platform's Docker network. Lifecycle managed via heartbeat-based polling, not Docker exec."
>
REMOTE
</span>
) : (
<span className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-ink-mid bg-surface-card/60 border border-line/30">
<span className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-ink-mid bg-surface-card border border-line">
{runtime}
</span>
)}
@@ -237,15 +237,15 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
key={skill}
className={`text-[10px] px-1.5 py-0.5 rounded-md border ${
isOnline
? "text-good/80 bg-emerald-950/30 border-emerald-800/30"
: "text-ink-mid bg-surface-card/60 border-line/40"
? "text-good bg-good/15 border-good/40"
: "text-ink-mid bg-surface-card border-line"
}`}
>
{skill}
</span>
))}
{skills.length > 4 && (
<span className="text-[10px] text-ink-soft self-center">
<span className="text-[10px] text-ink-mid self-center">
+{skills.length - 4}
</span>
)}
@@ -274,10 +274,10 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
e.stopPropagation();
useCanvasStore.getState().restartWorkspace(id).catch(() => showToast("Restart failed", "error"));
}}
className="flex items-center gap-1.5 mt-1 w-full bg-sky-950/30 px-2 py-1 rounded-md border border-sky-800/30 hover:bg-sky-900/40 transition-colors text-left focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:outline-none"
className="flex items-center gap-1.5 mt-1 w-full bg-accent/10 px-2 py-1 rounded-md border border-accent/40 hover:bg-accent/20 transition-colors text-left focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:outline-none"
>
<span className="text-[10px]"></span>
<span className="text-[10px] text-sky-300/80">Restart to apply changes</span>
<span className="text-[10px] text-accent"></span>
<span className="text-[10px] text-accent">Restart to apply changes</span>
</button>
)}
@@ -287,8 +287,8 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
<div className={`text-[10px] uppercase tracking-widest font-medium ${
data.status === "failed" ? "text-bad" :
data.status === "degraded" ? "text-warm" :
data.status === "provisioning" ? "text-sky-400" :
"text-ink-soft"
data.status === "provisioning" ? "text-accent" :
"text-ink-mid"
}`}>
{statusCfg.label}
</div>
@@ -296,8 +296,8 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
{data.activeTasks > 0 && (
<div className="flex items-center gap-1">
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse" />
<span className="text-[10px] text-warm/80 tabular-nums">
<div className="w-1 h-1 rounded-full bg-warm motion-safe:animate-pulse" />
<span className="text-[10px] text-warm tabular-nums">
{data.activeTasks} task{data.activeTasks > 1 ? "s" : ""}
</span>
</div>
@@ -307,7 +307,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
{/* Degraded error preview */}
{data.status === "degraded" && data.lastSampleError && (
<div
className="text-[10px] text-warm/60 truncate mt-1 bg-amber-950/20 px-1.5 py-0.5 rounded border border-amber-800/20"
className="text-[10px] text-warm truncate mt-1 bg-warm/10 px-1.5 py-0.5 rounded border border-warm/40"
title={data.lastSampleError}
>
{data.lastSampleError}
@@ -357,7 +357,7 @@ function TeamMemberChip({
}) {
const { data } = node;
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline;
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-ink-soft bg-surface-card" };
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-ink-mid bg-surface-card border border-line" };
const isOnline = data.status === "online";
const skills = getSkillNames(data.agentCard);
@@ -408,7 +408,7 @@ function TeamMemberChip({
</div>
<div className="flex items-center gap-1 shrink-0">
{hasSubChildren && (
<span className="text-[7px] font-mono text-violet-300 bg-violet-900/40 border border-violet-700/30 px-1 py-0.5 rounded">
<span className="text-[7px] font-mono text-accent bg-accent/15 border border-accent/40 px-1 py-0.5 rounded">
{descendantCount}
</span>
)}
@@ -423,7 +423,7 @@ function TeamMemberChip({
e.stopPropagation();
onExtract(node.id);
}}
className="opacity-0 group-hover/child:opacity-100 text-ink-soft hover:text-sky-400 transition-all focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:outline-none rounded"
className="opacity-0 group-hover/child:opacity-100 text-ink-mid hover:text-accent transition-all focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:outline-none rounded"
>
<EjectIcon aria-hidden="true" />
</button>
@@ -432,7 +432,7 @@ function TeamMemberChip({
{/* Role */}
{data.role && (
<div className="text-[10px] text-ink-soft mb-1 leading-tight truncate">{data.role}</div>
<div className="text-[10px] text-ink-mid mb-1 leading-tight truncate">{data.role}</div>
)}
{/* Skills */}
@@ -443,8 +443,8 @@ function TeamMemberChip({
key={skill}
className={`text-[10px] px-1 py-0.5 rounded border ${
isOnline
? "text-good/70 bg-emerald-950/20 border-emerald-800/20"
: "text-ink-soft bg-surface-card/40 border-line/30"
? "text-good bg-good/15 border-good/40"
: "text-ink-mid bg-surface-card border-line"
}`}
>
{skill}
@@ -462,8 +462,8 @@ function TeamMemberChip({
<span className={`text-[10px] uppercase tracking-widest font-medium ${
data.status === "failed" ? "text-bad" :
data.status === "degraded" ? "text-warm" :
data.status === "provisioning" ? "text-sky-400" :
"text-ink-soft"
data.status === "provisioning" ? "text-accent" :
"text-ink-mid"
}`}>
{statusCfg.label}
</span>
+5 -5
View File
@@ -773,14 +773,14 @@ function MyChatPanel({ workspaceId, data }: Props) {
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
msg.role === "user"
? "bg-accent-strong/30 text-blue-100 border border-accent/20"
? "bg-accent text-white border border-accent-strong"
: msg.role === "system"
? "bg-red-900/30 text-red-200 border border-red-800/30"
: "bg-surface-card/80 text-ink border border-line/30"
? "bg-bad/10 text-bad border border-bad/40"
: "bg-surface-card text-ink border border-line"
}`}
>
{msg.content && (
<div className="prose prose-sm prose-invert max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0">
<div className={`prose prose-sm max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 ${msg.role === "user" ? "prose-invert" : ""}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
</div>
)}
@@ -796,7 +796,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
))}
</div>
)}
<div className="text-[9px] text-ink-soft mt-1">
<div className={`text-[9px] mt-1 ${msg.role === "user" ? "text-white/70" : "text-ink-mid"}`}>
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
+4 -4
View File
@@ -12,10 +12,10 @@ export function statusDotClass(status: string): string {
}
export const TIER_CONFIG: Record<number, { label: string; color: string; border: string }> = {
1: { label: "T1", color: "text-ink-soft bg-surface-card/80", border: "text-ink-mid border-line/60" },
2: { label: "T2", color: "text-sky-400 bg-sky-950/50", border: "text-sky-400 border-sky-500/30" },
3: { label: "T3", color: "text-violet-400 bg-violet-950/50", border: "text-violet-400 border-violet-500/30" },
4: { label: "T4", color: "text-warm bg-amber-950/50", border: "text-warm border-amber-500/30" },
1: { label: "T1", color: "text-ink-mid bg-surface-card border border-line", border: "text-ink-mid border-line" },
2: { label: "T2", color: "text-white bg-accent border border-accent-strong", border: "text-accent border-accent" },
3: { label: "T3", color: "text-white bg-violet-600 border border-violet-700", border: "text-violet-600 border-violet-500" },
4: { label: "T4", color: "text-white bg-warm border border-warm", border: "text-warm border-warm" },
};
export const COMM_TYPE_LABELS: Record<string, string> = {
+29 -4
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 ""
+4 -3
View File
@@ -559,9 +559,10 @@ async def tool_chat_history(peer_id: str, limit: int = 20, before_ts: str = "")
Hits ``/workspaces/<self>/activity?peer_id=<peer>&limit=<N>``
against the workspace-server, which returns activity rows where
this workspace is either the sender (``source_id=peer``) or the
recipient (``target_id=peer``) of an A2A turn — both sides of the
conversation in chronological order.
the peer is either the sender (``source_id=peer`` — they sent us
the message) or the recipient (``target_id=peer`` — we sent to
them) of an A2A turn — both sides of the conversation in
chronological order.
Args:
peer_id: The other workspace's UUID. Same value the agent
+88 -4
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()
+21
View File
@@ -1050,6 +1050,27 @@ class TestChatHistory:
assert mc.get.call_args.kwargs["params"]["before_ts"] == "2026-05-01T00:00:00Z"
async def test_empty_history_returns_empty_json_list(self):
"""Pin the happy-path-with-no-rows shape: server returns 200
with an empty list, the wheel returns the JSON literal ``"[]"``.
Without this pin the surrounding tests all pre-populate rows;
none verify what an agent sees when there's literally no chat
history with this peer yet (a fresh A2A peering, or a peer
whose history was rotated out). #2485.
"""
import a2a_tools
mc = _make_http_mock(get_resp=_resp(200, []))
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
result = await a2a_tools.tool_chat_history(peer_id=_PEER)
# Exact-equality on the JSON literal (per assert-exact memory) —
# substring "[]" would also match `{"items": []}` or any number
# of envelope shapes, only `result == "[]"` discriminates the
# bare-list contract callers depend on.
assert result == "[]"
async def test_reverses_desc_response_to_chronological(self):
"""Server returns DESC (newest first); the wheel reverses to
chronological so the agent reads the chat top-down — same