May 08, 2026 β€’ Version: unknown (regression suspected)

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

ComponentRoleStatus
src/auto-reply/reply/strip-inbound-meta.tsPrompt-input-side stripperBypassed
control-ui/assets/index-*.jsControl-UI render stripperNot applicable to Discord
ChatMarkdownPreprocessor.swiftmacOS native client stripperNot applicable to Discord
Discord channel rendererDiscord-specific render pathMissing 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:

  1. Prompt Assembly Layer β€” strip-inbound-meta.ts strips markers before message inclusion in LLM context
  2. 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

  1. 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
  2. Trigger Event: Any message posted to #sprites-of-thornfield after ~09:35 PDT

  3. Bypass 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

  4. 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

InvestigatorMessage IDsFinding
Cael 🩸1503077391772418201, 150308318298191052925+ rendering anomalies escalating to envelope-tag leak
Silas 🌫1503079386373689436+Leak visible in assistant-side prompt-input
Elliott 🌻1503083255577051239, 1503084519773704393Leak visible in agent-visible input from elliott-host
Ronan 🌊1503084547460435988, 1503084550614548530Confirmed 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:

  1. A partial revert of #24012 fix
  2. A new Discord-specific code path added without applying the same sanitization
  3. 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 { return { content: message.content, // ⚠️ Raw content with markers // … }; }

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 { // βœ… Strip markers before sending to Discord const sanitizedContent = message.content.replace(STRIP_UNTRUSTED_REGEX, ‘’);

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:

TestInputExpected Output
Basic markersText <<<EXTERNAL_UNTRUSTED_CONTENT id="1">>>content<<<END_EXTERNAL_UNTRUSTED_CONTENT id="1">>>> more textText more text
Multiple markersTwo separate marker blocksBoth stripped
Nested markersMarkers within other syntaxOnly outer markers stripped
Empty markers<<<EXTERNAL_UNTRUSTED_CONTENT id="1">>>><<<END_EXTERNAL_UNTRUSTED_CONTENT id="1">>>>Empty string
Discord-specific pathMessage via Discord channel handlerStripped
Non-Discord pathMessage via other channelAlso 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.ts strips markers for messageType='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

IssueTitleRelationship
#24012Control-UI chat leaking <<<EXTERNAL_UNTRUSTED_CONTENT>>> wrapper markupOriginal fix for this bug class; suspected regression source
#69541RFC: Plugin-injected context XML tags / strip-sanitize-ingest contractSame family β€” establishes the strip-sanitize contract that was violated
#72341Assistant text-between-tools blocks render as cumulative duplicatesDifferent surface, possibly same regression vintage; check commit history overlap

Similar/Related Error Patterns

Error CodeDescriptionDistinction
RENDER_STRIP_BYPASSRender surface not executing strip logicPrimary error β€” this issue
PROMPT_ASSEMBLY_LEAKPrompt assembly not executing strip logicSecondary error β€” this issue
MARKER_INJECTIONUser content mimicking marker syntaxSecurity concern if unsanitized
CONTROL_UI_LEAKMarkers visible in control-UIFixed in #24012
MARKDOWN_PREPROCESS_FAILSwift client preprocessor errorDifferent platform
CHAT_HISTORY_CORRUPTIONHistory storage contains unexpected contentDownstream symptom

Regression Detection Issues

PatternSignificance
New Discord-specific code path addedLikely source of bypass
Recent modification to strip-inbound-meta.tsPossible regression point
Configuration change routing Discord messages differentlyPossible bypass cause
Docker image version mismatchCache/state issue (workaround: restart)
# 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

  1. Add integration test that verifies markers are stripped on ALL channel surfaces
  2. Add linter rule to catch direct message.content usage without strip call
  3. Document the two-strip-point architecture in ARCHITECTURE.md
  4. Create gateway doctor channels diagnostic command per Ronan’s suggestion

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.