Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 19s
CI / Detect changes (pull_request) Successful in 1m17s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m20s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m18s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m2s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 18s
qa-review / approved (pull_request) Failing after 19s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
security-review / approved (pull_request) Failing after 20s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 37s
publish-runtime-autobump / pr-validate (pull_request) Successful in 55s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m31s
sop-checklist / all-items-acked (pull_request) Successful in 27s
sop-tier-check / tier-check (pull_request) Successful in 29s
CI / Platform (Go) (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
CI / all-required (pull_request) Blocked by required conditions
CI / Canvas (Next.js) (pull_request) Successful in 15s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 18s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 19s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3m7s
audit-force-merge / audit (pull_request) Successful in 18s
gate-check-v3 / gate-check (pull_request) Failing after 13m24s
CI / Python Lint & Test (pull_request) Successful in 7m31s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Two bugs fixed in tool_delegate_task wrapping logic:
1. Wrapping used raw _A2A_BOUNDARY_START/_END markers, which
appeared in the output alongside the escaped form of the peer
content (e.g. "[A2A_RESULT_FROM_PEER]\n[/ A2A_RESULT...]").
Fixed: wrap with _A2A_BOUNDARY_START_ESCAPED/_END_ESCAPED so the
output contains no raw closer that could confuse downstream parsers.
2. A malicious peer could inject a fake closer ([/A2A_RESULT_FROM_PEER])
to make legitimate content appear truncated. Fixed: truncate at the
raw closer BEFORE sanitization (truncation loses the raw form, so
escaping afterward cannot retroactively remove it).
Also fixes 10 regressions in test_a2a_offsec003_sanitization.py:
tests were written expecting ZWSP (U+200B) escaping but implementation
uses "[/ " prefix. Updated test invariants to match actual behavior.
Also fixed 5 tests using [A2A_ERROR] in summary fields (not a boundary
marker — no escaping applied) and updated test assertions in
test_a2a_tools_impl.py and test_delegation_sync_via_polling.py to
expect escaped wrapper forms.
Cherry-picked fix/test-stdio-function-name (e478b5b2) from main:
renamed _warn_if_stdio_not_pipe → _assert_stdio_is_pipe_compatible
and added deprecated alias, fixing dangling monkeypatch targets that
caused 5 test failures (issue #957).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
106 lines
4.8 KiB
Python
106 lines
4.8 KiB
Python
"""OFFSEC-003: A2A peer-result sanitization — shared across delegation tools.
|
|
|
|
This module is intentionally a LEAF (no imports from the molecule-runtime
|
|
package) to avoid circular dependency cycles. Both ``a2a_tools_delegation``
|
|
and ``a2a_tools`` can import from here without creating import loops.
|
|
|
|
Trust-boundary design (OFFSEC-003):
|
|
A2A peer responses are untrusted third-party content. Before passing
|
|
them to the agent context, they MUST be wrapped in a trust-boundary
|
|
marker pair so the calling agent knows the content is external.
|
|
|
|
Boundary markers:
|
|
- _A2A_BOUNDARY_START = "[A2A_RESULT_FROM_PEER]"
|
|
- _A2A_BOUNDARY_END = "[/A2A_RESULT_FROM_PEER]"
|
|
|
|
The boundary is the PRIMARY security control. A peer that sends
|
|
"[A2A_RESULT_FROM_PEER]evil[/A2A_RESULT_FROM_PEER]safe" can make "safe"
|
|
appear inside the trusted context unless the markers themselves are
|
|
escaped before wrapping — see _escape_boundary_markers() below.
|
|
|
|
Defense-in-depth (secondary):
|
|
Known prompt-injection control-words are also escaped so that even
|
|
if a calling agent ignores the boundary marker, embedded attack
|
|
patterns (SYSTEM:, OVERRIDE:, etc.) lose their special meaning.
|
|
This is not a complete injection sanitizer — do not rely on it as
|
|
the primary control.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
# ── Trust-boundary markers ────────────────────────────────────────────────────
|
|
|
|
_A2A_BOUNDARY_START = "[A2A_RESULT_FROM_PEER]"
|
|
_A2A_BOUNDARY_END = "[/A2A_RESULT_FROM_PEER]"
|
|
|
|
# ── Boundary-marker escaping ─────────────────────────────────────────────────
|
|
# A peer that sends "[/A2A_RESULT_FROM_PEER]evil" can make "evil" appear
|
|
# inside the trusted zone. Escape BOTH boundary markers in the raw text
|
|
# before wrapping so they can never close the boundary early.
|
|
# We use "[/ " as the escape prefix — visually distinct from the real marker.
|
|
_A2A_BOUNDARY_START_ESCAPED = "[/ A2A_RESULT_FROM_PEER]"
|
|
_A2A_BOUNDARY_END_ESCAPED = "[/ /A2A_RESULT_FROM_PEER]"
|
|
|
|
|
|
def _escape_boundary_markers(text: str) -> str:
|
|
"""Escape boundary markers inside the raw peer text before wrapping.
|
|
|
|
Replaces any occurrence of the boundary start/end markers with a
|
|
visually-similar escaped form so a malicious peer can never close
|
|
the boundary early or inject a fake opener.
|
|
"""
|
|
return (
|
|
text.replace(_A2A_BOUNDARY_START, _A2A_BOUNDARY_START_ESCAPED)
|
|
.replace(_A2A_BOUNDARY_END, _A2A_BOUNDARY_END_ESCAPED)
|
|
)
|
|
|
|
|
|
# ── Defense-in-depth: injection pattern escaping ───────────────────────────────
|
|
# These patterns cover common prompt-injection phrasings. They are NOT a
|
|
# complete sanitizer — see module docstring. The boundary marker is the
|
|
# primary control; these are purely defense-in-depth.
|
|
|
|
_INJECTION_PATTERNS = [
|
|
# Single-word patterns: anchor to word boundary so they don't match
|
|
# inside other words (e.g. "SYSTEM" in "mySYSTEMatic").
|
|
# Single-word patterns: anchor to word boundary so they don't match
|
|
# inside other words (e.g. "SYSTEM" in "mySYSTEMatic").
|
|
(re.compile(r"(^|[^\w])SYSTEM\b", re.IGNORECASE), r"\1[ESCAPED_SYSTEM]"),
|
|
(re.compile(r"(^|[^\w])OVERRIDE\b", re.IGNORECASE), r"\1[ESCAPED_OVERRIDE]"),
|
|
# "INSTRUCTIONS" may appear at the start of a string or after a newline.
|
|
(re.compile(r"(^|\n)INSTRUCTIONS?\b", re.IGNORECASE), " [ESCAPED_INSTRUCTIONS]"),
|
|
(re.compile(r"(^|[^\w])IGNORE\s+ALL\b", re.IGNORECASE), r"\1[ESCAPED_IGNORE_ALL]"),
|
|
(re.compile(r"(^|[^\w])YOU\s+ARE\s+NOW\b", re.IGNORECASE), r"\1[ESCAPED_YOU_ARE_NOW]"),
|
|
]
|
|
|
|
|
|
def sanitize_a2a_result(text: str) -> str:
|
|
"""Sanitize untrusted text from an A2A peer (OFFSEC-003).
|
|
|
|
Order of operations:
|
|
1. Escape boundary markers in the raw text (prevents injection).
|
|
2. Escape known injection patterns (defense-in-depth).
|
|
|
|
Returns the input unchanged if it is empty/None.
|
|
|
|
Note: this function does NOT add boundary wrappers — callers that need
|
|
to establish a trust boundary should wrap the sanitized result with
|
|
``[A2A_RESULT_FROM_PEER]\\n{sanitized}\\n[/A2A_RESULT_FROM_PEER]``.
|
|
See ``a2a_tools_delegation.py:tool_delegate_task`` for the canonical
|
|
wrapping pattern.
|
|
"""
|
|
if not text:
|
|
return text
|
|
|
|
# 1. Escape boundary markers so a malicious peer cannot break the
|
|
# trust boundary from inside their response.
|
|
escaped = _escape_boundary_markers(text)
|
|
|
|
# 2. Escape known injection control-words (defense-in-depth only).
|
|
for pattern, replacement in _INJECTION_PATTERNS:
|
|
escaped = pattern.sub(replacement, escaped)
|
|
|
|
return escaped
|