diff --git a/molecule_agent/README.md b/molecule_agent/README.md index bff6f93..535125b 100644 --- a/molecule_agent/README.md +++ b/molecule_agent/README.md @@ -72,7 +72,7 @@ A runnable demo with full setup walkthrough lives at | `poll_state()` | 30.4 | Lightweight `{status, paused, deleted}` poll | | `heartbeat(...)` | 30.1 | Single bearer-authed heartbeat | | `get_peers()` / `discover_peer()` | 30.6 | Sibling URL discovery with TTL cache | -| `call_peer(target, message)` | 30.6 | Direct A2A with proxy fallback | +| `call_peer(target, message)` | 30.6 | Direct A2A with proxy fallback; response may be wrapped in OFFSEC-003 boundary markers — use ``strip_a2a_boundary()`` to remove them | | `fetch_inbound(since_id=…)` | 30.8c | One-shot poll of `/workspaces/:id/activity` for inbound A2A | | `reply(msg, text)` | 30.8c | Smart-routes reply to `/notify` (canvas user) or `/a2a` (peer) | | `run_heartbeat_loop()` | combo | Drives heartbeat + state-poll on a timer; exits on pause/delete | @@ -165,6 +165,27 @@ silent acks. On non-2xx the underlying `requests.HTTPError` propagates so the handler can decide whether to retry, surface to its observability, or fail loudly. +### OFFSEC-003 — A2A peer response trust boundary + +As of the OFFSEC-003 platform rollout, peer A2A responses are wrapped in +trust-boundary markers before being returned to callers:: + + [A2A_RESULT_FROM_PEER][/A2A_RESULT_FROM_PEER] + +The markers signal that the enclosed content is untrusted third-party output. +Use ``strip_a2a_boundary()`` to remove them before passing the response to +your agent context:: + + from molecule_agent import RemoteAgentClient, strip_a2a_boundary + + result = client.call_peer(target_id, "do the thing") + raw_text = result.get("result", {}).get("text", "") + trusted_text = strip_a2a_boundary(raw_text) + +The function returns the input unchanged if the markers are absent (platform +versions older than the OFFSEC-003 rollout), so it is safe to call on any +response. + ## CLI: `molecule_agent connect` One command bootstraps the full poll-mode loop. No code beyond your handler: diff --git a/molecule_agent/__init__.py b/molecule_agent/__init__.py index 3c92ddd..a9f4f83 100644 --- a/molecule_agent/__init__.py +++ b/molecule_agent/__init__.py @@ -39,6 +39,7 @@ from .client import ( PeerInfo, RemoteAgentClient, WorkspaceState, + strip_a2a_boundary, verify_plugin_sha256, ) from .inbound import ( @@ -71,6 +72,7 @@ __all__ = [ "DEFAULT_POLL_INTERVAL", "compute_plugin_sha256", "verify_plugin_sha256", + "strip_a2a_boundary", "__version__", ] __version__ = "0.1.0" diff --git a/molecule_agent/client.py b/molecule_agent/client.py index c51b8ff..2b84ea9 100644 --- a/molecule_agent/client.py +++ b/molecule_agent/client.py @@ -90,6 +90,43 @@ def make_idempotency_key(task_text: str) -> str: return hashlib.sha256(payload.encode("utf-8")).hexdigest() +# ── A2A boundary marker stripping (OFFSEC-003) ─────────────────────────────── + +_A2A_BOUNDARY_START = "[A2A_RESULT_FROM_PEER]" +_A2A_BOUNDARY_END = "[/A2A_RESULT_FROM_PEER]" + + +def strip_a2a_boundary(text: str) -> str: + """Strip OFFSEC-003 trust-boundary markers from a peer A2A response. + + The platform wraps peer A2A responses in:: + + [A2A_RESULT_FROM_PEER][/A2A_RESULT_FROM_PEER] + + to mark them as untrusted third-party content. Call this helper to + remove the wrapper before passing the content to your agent context: + + Usage:: + + result = client.call_peer(target_id, "do the thing") + text = result.get("result", {}).get("text", "") + content = strip_a2a_boundary(text) + + Returns the interior content (everything between the two markers). + Returns the input unchanged if the boundary markers are absent (the caller + may be talking to a platform version older than the OFFSEC-003 rollout). + Returns ``""`` for ``None`` or empty input. + """ + if not text: + return "" + start = text.find(_A2A_BOUNDARY_START) + end = text.find(_A2A_BOUNDARY_END) + if start != -1 and end != -1 and end > start: + return text[start + len(_A2A_BOUNDARY_START):end].strip() + return text + + + def _safe_extract_tar(tf: tarfile.TarFile, dest: Path) -> None: """Extract a tarfile, refusing entries that would escape `dest` and logging skipped symlinks/hardlinks. diff --git a/tests/test_remote_agent.py b/tests/test_remote_agent.py index 3c52aee..edca0d5 100644 --- a/tests/test_remote_agent.py +++ b/tests/test_remote_agent.py @@ -709,7 +709,7 @@ def test_install_plugin_404_raises_with_useful_url(client: RemoteAgentClient): import hashlib -from molecule_agent.client import make_idempotency_key +from molecule_agent.client import make_idempotency_key, strip_a2a_boundary def test_delegate_posts_task_and_idempotency_key(client: RemoteAgentClient): @@ -883,6 +883,55 @@ def test_make_idempotency_key_deterministic(): assert a == b +# --------------------------------------------------------------------------- +# strip_a2a_boundary — OFFSEC-003 trust-boundary marker stripping +# --------------------------------------------------------------------------- + + +def test_strip_a2a_boundary_basic(): + """Interior text between the two markers is returned.""" + wrapped = "[A2A_RESULT_FROM_PEER]hello world[/A2A_RESULT_FROM_PEER]" + assert strip_a2a_boundary(wrapped) == "hello world" + + +def test_strip_a2a_boundary_strips_whitespace_edges(): + """Trailing/leading whitespace inside the boundary is stripped.""" + wrapped = "[A2A_RESULT_FROM_PEER] peer reply [/A2A_RESULT_FROM_PEER]" + assert strip_a2a_boundary(wrapped) == "peer reply" + + +def test_strip_a2a_boundary_no_markers_returns_unchanged(): + """Without both markers present the input passes through unchanged.""" + assert strip_a2a_boundary("plain text with no markers") == "plain text with no markers" + + +def test_strip_a2a_boundary_only_start_returns_unchanged(): + """Only a start marker — no-op to stay safe during mid-rollout.""" + assert strip_a2a_boundary("[A2A_RESULT_FROM_PEER]unclosed") == "[A2A_RESULT_FROM_PEER]unclosed" + + +def test_strip_a2a_boundary_only_end_returns_unchanged(): + """Only an end marker — no-op.""" + assert strip_a2a_boundary("[/A2A_RESULT_FROM_PEER]no start") == "[/A2A_RESULT_FROM_PEER]no start" + + +def test_strip_a2a_boundary_empty_returns_empty(): + assert strip_a2a_boundary("") == "" + assert strip_a2a_boundary(None) == "" # type: ignore[arg-type] + + +def test_strip_a2a_boundary_end_before_start_returns_unchanged(): + """If end marker appears before start, treat as no-op.""" + text = "[/A2A_RESULT_FROM_PEER]X[A2A_RESULT_FROM_PEER]" + assert strip_a2a_boundary(text) == text + + +def test_strip_a2a_boundary_multiline_content(): + """Multiline interior content is preserved (stripped at edges only).""" + wrapped = "[A2A_RESULT_FROM_PEER]\n step one\n step two\n[/A2A_RESULT_FROM_PEER]" + assert strip_a2a_boundary(wrapped) == "step one\n step two" + + # --------------------------------------------------------------------------- # _safe_extract_tar # ---------------------------------------------------------------------------