EXTERNAL_UNTRUSTED_CONTENT Wrapper Markers Leaking into Discord Messages and Agent Prompts
Internal envelope-marker syntax (<<<EXTERNAL_UNTRUSTED_CONTENT>>>) is bypassing sanitization logic and appearing visibly in Discord chat renders and LLM prompt input on the Discord channel surface.
π Symptoms
Visual Manifestations
Discord Chat Surface Leak:
User: Please help me understand the security model Bot: ««EXTERNAL_UNTRUSTED_CONTENT id=“msg-abc123”»>«<END_EXTERNAL_UNTRUSTED_CONTENT id=“msg-abc123”»»
The raw <<<EXTERNAL_UNTRUSTED_CONTENT id="...">>> and <<<END_EXTERNAL_UNTRUSTED_CONTENT id="...">>> envelope markers appear visibly in Discord-rendered messages, visible to all users in the channel.
Agent Prompt Input Leak: The same markers appear in the LLM’s visible prompt context alongside (not instead of) a sanitized copy of the message, meaning:
- The agent sees double: one version with markers, one clean
- The marker content is treated as potentially authoritative by the model
CLI Diagnostic Output
# Check Discord channel message history for marker leakage
$ gateway debug.messages --channel "sprites-of-thornfield" --limit 50 | grep -E "<<>>><<>>>"
# Check stored history for markers (discriminates storage vs render bypass)
$ gateway debug.history --channel "sprites-of-thornfield" --format raw | grep -c "<<&1 | grep -E "<<>>><<>>>
Affected Components
| Component | Role | Status |
|---|---|---|
src/auto-reply/reply/strip-inbound-meta.ts | Prompt-input-side stripper | Bypassed |
control-ui/assets/index-*.js | Control-UI render stripper | Not applicable to Discord |
ChatMarkdownPreprocessor.swift | macOS native client stripper | Not applicable to Discord |
| Discord channel renderer | Discord-specific render path | Missing strip logic |
Error Classification
- Primary:
RENDER_STRIP_BYPASSβ render surface not executing strip logic - Secondary:
PROMPT_ASSEMBLY_LEAKβ prompt assembly not executing strip logic - Security Shape: Potential prompt injection substrate segmentation risk
π§ Root Cause
Architectural Analysis
The OpenClaw system implements a two-layer sanitization strategy for <<<EXTERNAL_UNTRUSTED_CONTENT>>> envelope markers:
- Prompt Assembly Layer β
strip-inbound-meta.tsstrips markers before message inclusion in LLM context - Render Surface Layer β surface-specific preprocessors strip markers before display
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β Message Ingestion β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β βΌ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β Discord Gateway Receiver β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β β stores raw message (with markers) in history backend β β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β βββββββββββββ΄ββββββββββββ βΌ βΌ βββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββ β Prompt Assembly Path β β Render Path β β (LLM context injection) β β (UI display) β βββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββ β β βΌ βΌ βββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββ β strip-inbound-meta.ts β β Discord-specific β β β οΈ BYPASSED on Discord β β renderer β β β β β οΈ MISSING STRIP LOGIC β βββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββ β β βΌ βΌ βββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββ β LLM receives markers β β Users see markers β β in visible prompt β β in Discord chat β βββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββ
Failure Sequence
Timeline: Regression introduced around 2026-05-10 ~09:35 PDT
Initial State: Discord channel rendering path either:
- Used shared strip logic (now bypassed)
- Never had strip logic (new code path added without safeguards)
- Had strip logic that was removed/modified in a recent change
Trigger Event: Any message posted to
#sprites-of-thornfieldafter ~09:35 PDTBypass Mechanism (Prompt Assembly):
In strip-inbound-meta.ts:
// Existing strip logic (now bypassed) content = content.replace(/«<EXTERNAL_UNTRUSTED_CONTENT[\s\S]*?«<END_EXTERNAL_UNTRUSTED_CONTENT»>/g, ‘’);
// Bypass occurs because: // - Discord messages route through a code path that doesn’t call strip-inbound-meta // - OR the function exists but returns early due to conditional check // - OR a new message type/path was added that skips this function entirely
Bypass Mechanism (Render):
Discord-specific rendering path:
// New code path added for Discord-specific formatting // Does NOT call the shared strip-markers utility // Renders content directly to Discord API payload
const discordMessage = { content: rawMessage.content, // β Raw content, markers intact embeds: […] };
Evidence from Cohort Byte-Walks
| Investigator | Message IDs | Finding |
|---|---|---|
| Cael π©Έ | 1503077391772418201, 1503083182981910529 | 25+ rendering anomalies escalating to envelope-tag leak |
| Silas π« | 1503079386373689436+ | Leak visible in assistant-side prompt-input |
| Elliott π» | 1503083255577051239, 1503084519773704393 | Leak visible in agent-visible input from elliott-host |
| Ronan π | 1503084547460435988, 1503084550614548530 | Confirmed strip-inbound-meta.ts bypass |
Prior Art Correlation
- Issue #24012: Earlier fix for control-UI chat leaking
<<<EXTERNAL_UNTRUSTED_CONTENT>>>markers - Issue #69541: RFC around plugin-injected context XML tags / strip-sanitize-ingest contract
- Issue #72341: Assistant text-between-tools blocks render as cumulative duplicates (possibly same regression vintage)
The current bug suggests either:
- A partial revert of #24012 fix
- A new Discord-specific code path added without applying the same sanitization
- A configuration change that routes Discord messages through a different (unstripped) code path
π οΈ Step-by-Step Fix
Phase 1: Isolate the Bypass Point
Before applying fixes, determine which paths are affected:
# Step 1.1: Check if markers exist in raw history storage
$ gateway debug.storage --channel "sprites-of-thornfield" --msg-id 1503084621145964846 --format raw
Expected: Markers should NOT be in raw storage
If markers ARE in storage: the ingest/save path is the issue
If markers are NOT in storage: the retrieval/render path is the issue
# Step 1.2: Check which code paths Discord messages traverse
$ gateway debug.routes --channel "sprites-of-thornfield" --trace
Output should show:
β discord_gateway.receive
β [?strip-inbound-meta?]
β [?discord_renderer?]
β chat.send
Phase 2: Fix Prompt Assembly Bypass
File: src/auto-reply/reply/strip-inbound-meta.ts
Before (potential bypass scenario): typescript export function stripInboundMeta(content: string, messageType?: string): string { // Strip Conversation info blocks content = content.replace(/Conversation info[\s\S]*$/gm, ‘’);
// β οΈ PROBLEM: Discord messages may bypass this function entirely // or messageType check may exclude Discord
if (messageType !== ‘discord’) { // β Bypass condition content = content.replace(/«<EXTERNAL_UNTRUSTED_CONTENT[\s\S]*?«<END_EXTERNAL_UNTRUSTED_CONTENT»>/g, ‘’); }
return content; }
After (strip all message types): typescript export function stripInboundMeta(content: string, messageType?: string): string { // Strip Conversation info blocks content = content.replace(/Conversation info[\s\S]*$/gm, ‘’);
// β Strip EXTERNAL_UNTRUSTED_CONTENT wrappers for ALL message types // This ensures Discord messages are sanitized before prompt assembly content = content.replace(/«<EXTERNAL_UNTRUSTED_CONTENT[\s\S]*?«<END_EXTERNAL_UNTRUSTED_CONTENT»>/g, ‘’);
// Optional: If you need to preserve some metadata for debugging, // replace with placeholder instead of complete removal // content = content.replace( // /«<EXTERNAL_UNTRUSTED_CONTENT id="([^"]+)"»>([\s\S]*?)«<END_EXTERNAL_UNTRUSTED_CONTENT id="\1"»>/g, // ‘[content stripped: external untrusted content]’ // );
return content; }
Alternative Fix (if bypass is via missing function call): typescript // In the Discord message handler (e.g., src/channels/discord/handler.ts)
import { stripInboundMeta } from ‘../../auto-reply/reply/strip-inbound-meta’;
async function handleDiscordMessage(message: DiscordMessage) { const content = message.content;
// β Ensure strip is called for Discord messages const sanitizedContent = stripInboundMeta(content, ‘discord’);
// Continue with sanitizedContent… }
Phase 3: Fix Discord Render Bypass
File: src/channels/discord/renderer.ts (or wherever Discord message formatting occurs)
Before:
typescript
async function formatDiscordMessage(message: RawMessage): Promise
After: typescript // Import shared strip utility (or reuse from strip-inbound-meta.ts) const STRIP_UNTRUSTED_REGEX = /«<EXTERNAL_UNTRUSTED_CONTENT[\s\S]*?«<END_EXTERNAL_UNTRUSTED_CONTENT»>/g;
async function formatDiscordMessage(message: RawMessage): Promise
return { content: sanitizedContent, // … }; }
Alternative: Create shared strip utility typescript // src/utils/strip-render-markers.ts export const RENDER_STRIP_REGEX = /«<EXTERNAL_UNTRUSTED_CONTENT[\s\S]*?«<END_EXTERNAL_UNTRUSTED_CONTENT»>/g;
export function stripRenderMarkers(content: string): string { return content.replace(RENDER_STRIP_REGEX, ‘’); }
// Then import in both strip-inbound-meta.ts AND discord/renderer.ts
Phase 4: Emergency Workaround (Gateway Restart)
If immediate mitigation is required before code changes can be deployed:
# Restart gateway to clear potential cached state
$ gateway restart --service discord-bridge
# Or if running via Docker
$ docker-compose restart openclaw-discord-bridge
# Verify restart completed
$ gateway status --service discord-bridge
β active (running) since 2026-05-10 10:45:00 PDT
π§ͺ Verification
Test Case 1: Prompt Assembly Verification
# Verify markers are stripped from prompt assembly
$ gateway debug.prompt --channel "sprites-of-thornfield" --msg-id 1503084621145964846 2>&1
# Expected: No <<&1 | grep -E "<< Test Case 2: Discord Render Verification
# Post a test message containing marker syntax
$ gateway debug.send-test --channel "sprites-of-thornfield" --content "Test <<>>><<>>> marker visibility"
# Check Discord message via API or webhook
$ discord-api get message --channel sprites-of-thornfield --msg-id [new-msg-id] | jq '.content'
# Expected: "Test marker visibility"
# Actual (before fix): "Test <<>>><<>>> marker visibility"
Test Case 3: Historical Message Verification
# Verify previously-leaked messages are handled correctly going forward
$ gateway debug.messages --channel "sprites-of-thornfield" --limit 10 --format rendered | grep -E "<<Test Case 4: Regression Suite
Execute the following test scenarios to prevent future regressions:
| Test | Input | Expected Output |
|---|---|---|
| Basic markers | Text <<<EXTERNAL_UNTRUSTED_CONTENT id="1">>>content<<<END_EXTERNAL_UNTRUSTED_CONTENT id="1">>>> more text | Text more text |
| Multiple markers | Two separate marker blocks | Both stripped |
| Nested markers | Markers within other syntax | Only outer markers stripped |
| Empty markers | <<<EXTERNAL_UNTRUSTED_CONTENT id="1">>>><<<END_EXTERNAL_UNTRUSTED_CONTENT id="1">>>> | Empty string |
| Discord-specific path | Message via Discord channel handler | Stripped |
| Non-Discord path | Message via other channel | Also stripped (regression prevention) |
# Run unit tests for strip functions
$ npm test -- --grep "stripInboundMeta\|stripRenderMarkers"
# Run integration test for Discord path
$ npm run test:integration -- --channel discord
# Expected output:
# β stripInboundMeta removes single marker block
# β stripInboundMeta removes multiple marker blocks
# β stripInboundMeta handles empty markers
# β Discord renderer strips markers from outbound payload
# β Discord handler calls strip before prompt assembly
Verification Checklist
-
strip-inbound-meta.tsstrips markers formessageType='discord' - Discord renderer calls strip function before sending to Discord API
- No new messages contain visible markers in Discord
- Agent prompt context does not contain markers
- Unit tests pass
- Integration tests pass
- Code review completed for both fix locations
β οΈ Common Pitfalls
Pitfall 1: Incomplete Strip Pattern
Problem: The regex pattern may not match all marker variations.
Example of fragile pattern: typescript // β Fragile: Won’t match if spacing or newlines differ /content.replace(/«<EXTERNAL_UNTRUSTED_CONTENT.*?END_EXTERNAL_UNTRUSTED_CONTENT»>/g, ‘’/
// β Robust: Handles whitespace variations /content.replace(/«<EXTERNAL_UNTRUSTED_CONTENT[\s\S]*?«<END_EXTERNAL_UNTRUSTED_CONTENT»>/g, ‘’/
Verification: bash
Test with various input formats
echo ‘«<EXTERNAL_UNTRUSTED_CONTENT id=“1”»» content «<END_EXTERNAL_UNTRUSTED_CONTENT id=“1”»» ’ | grep -oP ‘«<EXTERNAL_UNTRUSTED_CONTENT[\s\S]*?«<END_EXTERNAL_UNTRUSTED_CONTENT»>’
Pitfall 2: Case-Sensitivity Issues
Problem: Markers may appear in different cases depending on source.
Mitigation: typescript // β Case-insensitive matching const regex = /«<EXTERNAL_UNTRUSTED_CONTENT[\s\S]*?«<END_EXTERNAL_UNTRUSTED_CONTENT»>/gi; content = content.replace(regex, ‘’);
Pitfall 3: Discord Message Length Limits
Problem: Stripping markers may change message length. Discord has 2000-character limit.
If content approaches limit: typescript function safeStrip(content: string, maxLength: number = 2000): string { let stripped = content.replace(STRIP_REGEX, ‘’); if (stripped.length > maxLength) { // Truncate or split message stripped = stripped.substring(0, maxLength - 3) + ‘…’; } return stripped; }
Pitfall 4: MacOS/iOS Client Regression
Problem: If ChatMarkdownPreprocessor.swift was not updated alongside the fix, Apple clients may still show markers.
Check: swift // ChatMarkdownPreprocessor.swift should have matching strip logic let pattern = “«<EXTERNAL_UNTRUSTED_CONTENT[\s\S]*?«<END_EXTERNAL_UNTRUSTED_CONTENT»>” let regex = try? NSRegularExpression(pattern: pattern, options: []) let range = NSRange(content.startIndex…, in: content) content = regex?.stringByReplacingMatches(in: content, options: [], range: range, withTemplate: “”)
Pitfall 5: Control-UI Regression
Problem: Fixing Discord path but breaking control-UI, or vice versa.
Prevention: Use shared utility function in both paths: typescript // src/utils/strip-all-envelope-markers.ts export const ENVELOPE_STRIP_REGEX = /«<EXTERNAL_UNTRUSTED_CONTENT[\s\S]*?«<END_EXTERNAL_UNTRUSTED_CONTENT»>/gi;
export function stripAllEnvelopeMarkers(content: string): string { return content.replace(ENVELOPE_STRIP_REGEX, ‘’); }
Pitfall 6: Missing Unit Test Coverage
Problem: Without tests, the fix may regress silently.
Required test additions: typescript describe(‘stripAllEnvelopeMarkers’, () => { it(‘should strip markers from Discord-sourced messages’, () => { const input = ‘Hello «<EXTERNAL_UNTRUSTED_CONTENT id=“x”»>world«<END_EXTERNAL_UNTRUSTED_CONTENT id=“x”»»’; expect(stripAllEnvelopeMarkers(input)).toBe(‘Hello world’); });
it(‘should strip multiple marker blocks’, () => { const input = ‘A«<EC…»>B«<EC…»>C’; expect(stripAllEnvelopeMarkers(input)).toBe(‘ABC’); });
it(‘should handle malformed markers gracefully’, () => { const input = ‘Text «<EXTERNAL_UNTRUSTED_CONTENT unclosed’; expect(stripAllEnvelopeMarkers(input)).toBe(input); // No change }); });
Pitfall 7: Environment-Specific Behavior
Problem: Docker container may have different Node.js behavior than local development.
Verification: bash
Test in Docker environment
docker-compose run –rm openclaw-gateway npm test – –grep “envelope”
Test on macOS (if applicable)
docker run –platform linux/amd64 –rm openclaw-gateway npm test
π Related Errors
Directly Related Issues
| Issue | Title | Relationship |
|---|---|---|
| #24012 | Control-UI chat leaking <<<EXTERNAL_UNTRUSTED_CONTENT>>> wrapper markup | Original fix for this bug class; suspected regression source |
| #69541 | RFC: Plugin-injected context XML tags / strip-sanitize-ingest contract | Same family β establishes the strip-sanitize contract that was violated |
| #72341 | Assistant text-between-tools blocks render as cumulative duplicates | Different surface, possibly same regression vintage; check commit history overlap |
Similar/Related Error Patterns
| Error Code | Description | Distinction |
|---|---|---|
RENDER_STRIP_BYPASS | Render surface not executing strip logic | Primary error β this issue |
PROMPT_ASSEMBLY_LEAK | Prompt assembly not executing strip logic | Secondary error β this issue |
MARKER_INJECTION | User content mimicking marker syntax | Security concern if unsanitized |
CONTROL_UI_LEAK | Markers visible in control-UI | Fixed in #24012 |
MARKDOWN_PREPROCESS_FAIL | Swift client preprocessor error | Different platform |
CHAT_HISTORY_CORRUPTION | History storage contains unexpected content | Downstream symptom |
Regression Detection Issues
| Pattern | Significance |
|---|---|
| New Discord-specific code path added | Likely source of bypass |
Recent modification to strip-inbound-meta.ts | Possible regression point |
| Configuration change routing Discord messages differently | Possible bypass cause |
| Docker image version mismatch | Cache/state issue (workaround: restart) |
Investigation Commands for Related Issues
# Check git log for strip-inbound-meta.ts changes
$ git log --oneline -20 -- src/auto-reply/reply/strip-inbound-meta.ts
# Check git log for Discord renderer changes
$ git log --oneline -20 -- src/channels/discord/
# Check for recent commits touching marker-related code
$ git log --oneline -50 --all --grep="EXTERNAL_UNTRUSTED_CONTENT"
# Compare current state with pre-regression commit
$ git diff [pre-regression-sha] -- src/channels/discord/
Preventive Measures
- Add integration test that verifies markers are stripped on ALL channel surfaces
- Add linter rule to catch direct
message.contentusage without strip call - Document the two-strip-point architecture in
ARCHITECTURE.md - Create
gateway doctor channelsdiagnostic command per Ronan’s suggestion