Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
CI / Detect changes (pull_request) Successful in 49s
qa-review / approved (pull_request) Failing after 19s
security-review / approved (pull_request) Failing after 19s
gate-check-v3 / gate-check (pull_request) Failing after 34s
E2E API Smoke Test / detect-changes (pull_request) Successful in 56s
sop-tier-check / tier-check (pull_request) Successful in 17s
publish-runtime-autobump / pr-validate (pull_request) Successful in 47s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m0s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 47s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 50s
CI / Platform (Go) (pull_request) Successful in 12s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 11s
CI / Canvas (Next.js) (pull_request) Successful in 13s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 20s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 23s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 22s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 18s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2m53s
CI / Python Lint & Test (pull_request) Successful in 7m36s
Issue #537: builtin_tools/a2a_tools.py:72 returns peer-sourced text from delegate_task() without OFFSEC-003 sanitization. Sibling regression to #491 / #492 in a different code path (google-adk delegation surface). Fix: import sanitize_a2a_result from _sanitize_a2a and wrap all 4 peer-controlled return sites in delegate_task() — parts[0].text path, empty-parts str(result) path, fallback str(result) path, and the error message path. Closes #537.
118 lines
4.9 KiB
Python
118 lines
4.9 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):
|
|
# OFFSEC-003: wrap peer-controlled text before returning
|
|
# to LLM context. Issue #537.
|
|
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)
|
|
# OFFSEC-003: peer-controlled error message; wrap before return.
|
|
return sanitize_a2a_result(f"Error: {msg}")
|
|
return sanitize_a2a_result(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)
|