feat: strip webhook/hook scaffolding from user messages
Messages from /hooks/agent containing SECURITY NOTICE blocks and <<<EXTERNAL_UNTRUSTED_CONTENT>>> delimiters are now cleaned up. Only the actual user content is displayed, with a small webhook badge indicator showing the message originated from a webhook. Closes feedback #54
This commit is contained in:
@@ -518,7 +518,8 @@
|
|||||||
## Item #48
|
## Item #48
|
||||||
- **Date:** 2026-02-12
|
- **Date:** 2026-02-12
|
||||||
- **Priority:** medium
|
- **Priority:** medium
|
||||||
- **Status:** pending
|
- **Status:** done
|
||||||
|
- **Completed:** 2026-02-13 — commit `6c19c26`
|
||||||
- **Description:** Message search — Ctrl+F in conversation history
|
- **Description:** Message search — Ctrl+F in conversation history
|
||||||
- Search bar that filters/highlights messages in the current session
|
- Search bar that filters/highlights messages in the current session
|
||||||
- Navigate between results (up/down arrows)
|
- Navigate between results (up/down arrows)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
@@ -12,9 +12,10 @@ import { CodeBlock } from './CodeBlock';
|
|||||||
import { ToolCall } from './ToolCall';
|
import { ToolCall } from './ToolCall';
|
||||||
import { ImageBlock } from './ImageBlock';
|
import { ImageBlock } from './ImageBlock';
|
||||||
import { buildImageSrc } from '../lib/image';
|
import { buildImageSrc } from '../lib/image';
|
||||||
import { Bot, User, Wrench, Copy, Check, RefreshCw, Zap, Info } from 'lucide-react';
|
import { Bot, User, Wrench, Copy, Check, RefreshCw, Zap, Info, Webhook } from 'lucide-react';
|
||||||
import { t, getLocale } from '../lib/i18n';
|
import { t, getLocale } from '../lib/i18n';
|
||||||
import { useLocale } from '../hooks/useLocale';
|
import { useLocale } from '../hooks/useLocale';
|
||||||
|
import { stripWebhookScaffolding, hasWebhookScaffolding } from '../lib/systemEvent';
|
||||||
// ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage
|
// ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage
|
||||||
|
|
||||||
function getBcp47(): string {
|
function getBcp47(): string {
|
||||||
@@ -321,13 +322,43 @@ function SystemEventMessage({ message }: { message: ChatMessageType }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessageComponent({ message, onRetry, agentAvatarUrl }: { message: ChatMessageType; onRetry?: (text: string) => void; agentAvatarUrl?: string }) {
|
export function ChatMessageComponent({ message: rawMessage, onRetry, agentAvatarUrl }: { message: ChatMessageType; onRetry?: (text: string) => void; agentAvatarUrl?: string }) {
|
||||||
useLocale(); // re-render on locale change
|
useLocale(); // re-render on locale change
|
||||||
|
|
||||||
|
// Strip webhook/hook scaffolding from user messages before rendering
|
||||||
|
const message = useMemo(() => {
|
||||||
|
if (rawMessage.role !== 'user') return rawMessage;
|
||||||
|
const content = rawMessage.content || '';
|
||||||
|
const textBlocks = getTextBlocks(rawMessage.blocks);
|
||||||
|
const contentHasScaffolding = hasWebhookScaffolding(content);
|
||||||
|
const anyBlockHasScaffolding = textBlocks.some(b =>
|
||||||
|
hasWebhookScaffolding((b as Extract<MessageBlock, { type: 'text' }>).text)
|
||||||
|
);
|
||||||
|
if (!contentHasScaffolding && !anyBlockHasScaffolding) return rawMessage;
|
||||||
|
// Clean the content and blocks
|
||||||
|
const cleaned: ChatMessageType = { ...rawMessage };
|
||||||
|
if (cleaned.content) {
|
||||||
|
cleaned.content = stripWebhookScaffolding(cleaned.content);
|
||||||
|
}
|
||||||
|
if (cleaned.blocks.length > 0) {
|
||||||
|
cleaned.blocks = cleaned.blocks.map(b => {
|
||||||
|
if (b.type === 'text') {
|
||||||
|
const tb = b as Extract<MessageBlock, { type: 'text' }>;
|
||||||
|
return { ...tb, text: stripWebhookScaffolding(tb.text) };
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}, [rawMessage]);
|
||||||
|
|
||||||
|
const wasWebhookMessage = rawMessage !== message;
|
||||||
|
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
|
|
||||||
// System events render as subtle inline notifications
|
// System events render as subtle inline notifications
|
||||||
if (message.isSystemEvent) {
|
if (message.isSystemEvent) {
|
||||||
return <SystemEventMessage message={message} />;
|
return <SystemEventMessage message={rawMessage} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assistant message with no text content — only tool calls / thinking
|
// Assistant message with no text content — only tool calls / thinking
|
||||||
@@ -407,9 +438,15 @@ export function ChatMessageComponent({ message, onRetry, agentAvatarUrl }: { mes
|
|||||||
{/* Tool calls & thinking (inline) */}
|
{/* Tool calls & thinking (inline) */}
|
||||||
{!isUser && <InternalsSummary blocks={message.blocks} />}
|
{!isUser && <InternalsSummary blocks={message.blocks} />}
|
||||||
</div>
|
</div>
|
||||||
{message.timestamp && (
|
{(message.timestamp || wasWebhookMessage) && (
|
||||||
<div className={`mt-1 text-[11px] text-pc-text-muted ${isUser ? 'text-right pr-2' : 'pl-2'}`}>
|
<div className={`mt-1 flex items-center gap-1.5 text-[11px] text-pc-text-muted ${isUser ? 'justify-end pr-2' : 'pl-2'}`}>
|
||||||
{formatTimestamp(message.timestamp)}
|
{wasWebhookMessage && (
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-[10px] text-pc-text-faint" title="Webhook message (scaffolding stripped)">
|
||||||
|
<Webhook size={10} className="opacity-60" />
|
||||||
|
<span>webhook</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{message.timestamp && formatTimestamp(message.timestamp)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,3 +27,57 @@ export function isSystemEvent(text: string): boolean {
|
|||||||
if (!trimmed) return false;
|
if (!trimmed) return false;
|
||||||
return SYSTEM_PATTERNS.some(pat => pat.test(trimmed));
|
return SYSTEM_PATTERNS.some(pat => pat.test(trimmed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip webhook/hook scaffolding from message content.
|
||||||
|
* OpenClaw wraps inbound webhook payloads in security envelopes like:
|
||||||
|
* [hook:agent task_id=xxx job_id=xxx]
|
||||||
|
* --- SECURITY NOTICE ---
|
||||||
|
* ...
|
||||||
|
* <<<EXTERNAL_UNTRUSTED_CONTENT>>>
|
||||||
|
* actual message
|
||||||
|
* <<<END_EXTERNAL_UNTRUSTED_CONTENT>>>
|
||||||
|
*
|
||||||
|
* This extracts the actual user content and returns it clean.
|
||||||
|
* Also strips leading [hook:...] / [cron:...] / [sms-inbound ...] tags
|
||||||
|
* and SECURITY NOTICE blocks when no EXTERNAL_UNTRUSTED_CONTENT delimiters exist.
|
||||||
|
*/
|
||||||
|
export function stripWebhookScaffolding(text: string): string {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
|
||||||
|
// Extract content between <<<EXTERNAL_UNTRUSTED_CONTENT>>> delimiters
|
||||||
|
const extMatch = trimmed.match(
|
||||||
|
/<<<EXTERNAL_UNTRUSTED_CONTENT>>>\s*([\s\S]*?)\s*<<<END_EXTERNAL_UNTRUSTED_CONTENT>>>/
|
||||||
|
);
|
||||||
|
if (extMatch) {
|
||||||
|
return extMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip leading bracket tags: [hook:...], [cron:...], [sms-inbound ...], etc.
|
||||||
|
let cleaned = trimmed.replace(/^\[(?:hook|cron|webhook|sms-inbound)[^\]]*\]\s*/i, '');
|
||||||
|
|
||||||
|
// Strip SECURITY NOTICE blocks (--- SECURITY NOTICE --- ... --- END ---)
|
||||||
|
cleaned = cleaned.replace(
|
||||||
|
/---\s*SECURITY NOTICE\s*---[\s\S]*?---\s*END\s*---\s*/i,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
// Strip standalone security notice lines without END marker
|
||||||
|
cleaned = cleaned.replace(
|
||||||
|
/---\s*SECURITY NOTICE\s*---[^\n]*\n(?:.*\n)*?(?=\S)/i,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
// Strip task/job ID lines
|
||||||
|
cleaned = cleaned.replace(/^(?:task_id|job_id|Task|Job)\s*[:=]\s*\S+\s*\n?/gim, '');
|
||||||
|
|
||||||
|
return cleaned.trim() || trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a message contains webhook scaffolding that should be cleaned.
|
||||||
|
*/
|
||||||
|
export function hasWebhookScaffolding(text: string): boolean {
|
||||||
|
return /<<<EXTERNAL_UNTRUSTED_CONTENT>>>/.test(text) ||
|
||||||
|
/---\s*SECURITY NOTICE\s*---/i.test(text);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user