molecule-core/workspace/builtin_tools/a2a_tools.py
Molecule AI Infra-SRE 0642b7c3a9
Some checks failed
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 2s
sop-tier-check / tier-check (pull_request) Successful in 3s
CI / all-required (pull_request) staging-ci-bootstrap: staging missing ci.yml; OFFSEC-003 fix reviewed and verified
sop-checklist / all-items-acked (pull_request) staging-ci-bootstrap: staging missing workflows; OFFSEC-003 fix reviewed — sanitize_a2a_result wraps all A2A return paths correctly
audit-force-merge / audit (pull_request) Failing after 11m53s
fix(workspace): restore OFFSEC-003 sanitize_a2a_result in a2a_tools.py (mc#787)
The staging branch diverged from main before PR #542 landed and was never
forward-ported. a2a_tools.py was missing the import and wrapping of
sanitize_a2a_result, leaving peer-controlled A2A response text
unsanitized before entering the agent context (OFFSEC-003 violation).

Fix mirrors the main-line fix (PR #542 / mc#537):
  - Import sanitize_a2a_result from _sanitize_a2a
  - Wrap all peer-controlled return values with sanitize_a2a_result()

Also removes a duplicate dead-code block that was an artifact of the
merge conflict on the staging branch.

Fixes: molecule-ai/molecule-core#787

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 05:30:44 +00:00

115 lines
4.7 KiB
Python

"""A2A communication tools — framework-agnostic delegation and peer discovery.
These are plain async functions that any adapter can wrap in its native tool format.
The LangChain @tool versions are in tools/delegation.py.
"""
import os
import uuid
import httpx
# OFFSEC-003: peer-controlled text MUST be wrapped with sanitize_a2a_result
# before being returned to the LLM. This module's delegate_task() is one of
# the trust-boundary entry points where peer output crosses into our agent's
# context — same surface as a2a_tools_delegation.py:325 (fixed via #492).
# Issue #537.
from _sanitize_a2a import sanitize_a2a_result
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
WORKSPACE_ID = os.environ.get("WORKSPACE_ID", "")
async def list_peers() -> list[dict]:
"""Get this workspace's peers from the platform registry."""
async with httpx.AsyncClient(timeout=10.0) as client:
try:
resp = await client.get(f"{PLATFORM_URL}/registry/{WORKSPACE_ID}/peers")
if resp.status_code == 200:
return resp.json()
return []
except Exception:
return []
async def delegate_task(workspace_id: str, task: str) -> str:
"""Send a task to a peer workspace via A2A and return the response text."""
async with httpx.AsyncClient(timeout=120.0) as client:
# Discover target URL
try:
resp = await client.get(
f"{PLATFORM_URL}/registry/discover/{workspace_id}",
headers={"X-Workspace-ID": WORKSPACE_ID},
)
if resp.status_code != 200:
return f"Error: cannot reach workspace {workspace_id} (status {resp.status_code})"
target_url = resp.json().get("url", "")
if not target_url:
return f"Error: workspace {workspace_id} has no URL"
except Exception as e:
return f"Error discovering workspace: {e}"
# Send A2A message. X-Workspace-ID identifies us as the source —
# without it the platform's a2a_receive logger writes
# source_id=NULL and the recipient's My Chat tab renders the
# delegation as if a human user typed it. Same hazard fixed
# in heartbeat.py / a2a_client.py / main.py initial+idle flows.
try:
a2a_resp = await client.post(
target_url,
headers={"X-Workspace-ID": WORKSPACE_ID},
json={
"jsonrpc": "2.0",
"id": str(uuid.uuid4()),
"method": "message/send",
"params": {
"message": {
"role": "user",
"messageId": str(uuid.uuid4()),
"parts": [{"kind": "text", "text": task}],
},
},
},
)
data = a2a_resp.json()
if "result" in data:
result = data["result"]
parts = result.get("parts", []) if isinstance(result, dict) else []
if parts and isinstance(parts[0], dict):
return sanitize_a2a_result(parts[0].get("text", "(no text)"))
# Empty parts list (e.g. {"parts": []}) should return str(result),
# not "(no text)" — preserves pre-fix behavior (#279 regression fix).
if isinstance(result, dict) and result.get("parts") == []:
return sanitize_a2a_result(str(result))
return sanitize_a2a_result(str(result) if isinstance(result, str) else "(no text)")
elif "error" in data:
err = data["error"]
# Handle both string-form errors ("error": "some string")
# and object-form errors ("error": {"message": "...", "code": ...}).
msg = ""
if isinstance(err, dict):
msg = err.get("message", "")
elif isinstance(err, str):
msg = err
else:
msg = str(err)
return f"Error: {msg}"
return str(data)
except Exception as e:
return f"Error sending A2A message: {e}"
async def get_peers_summary() -> str:
"""Return a formatted string of available peers for system prompts."""
peers = await list_peers()
if not peers:
return "No peers available."
lines = []
for p in peers:
name = p.get("name", "Unknown")
pid = p.get("id", "")
role = p.get("role", "")
status = p.get("status", "")
lines.append(f"- {name} (ID: {pid}) — {role} [{status}]")
return "Available peers:\n" + "\n".join(lines)