From c7cd47b09ae18307f40badb9e807f65327690130 Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Fri, 13 Feb 2026 01:12:04 +0000 Subject: [PATCH] feat: strip webhook/hook scaffolding from user messages Messages from /hooks/agent containing SECURITY NOTICE blocks and <<>> 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 --- FEEDBACK.md | 3 +- src/components/ChatMessage.tsx | 51 +++++++++++++++++++++++++++----- src/lib/systemEvent.ts | 54 ++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 8 deletions(-) diff --git a/FEEDBACK.md b/FEEDBACK.md index f08bac5..fc16db7 100644 --- a/FEEDBACK.md +++ b/FEEDBACK.md @@ -518,7 +518,8 @@ ## Item #48 - **Date:** 2026-02-12 - **Priority:** medium -- **Status:** pending +- **Status:** done +- **Completed:** 2026-02-13 — commit `6c19c26` - **Description:** Message search — Ctrl+F in conversation history - Search bar that filters/highlights messages in the current session - Navigate between results (up/down arrows) diff --git a/src/components/ChatMessage.tsx b/src/components/ChatMessage.tsx index 4b9d932..6238730 100644 --- a/src/components/ChatMessage.tsx +++ b/src/components/ChatMessage.tsx @@ -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 ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -12,9 +12,10 @@ import { CodeBlock } from './CodeBlock'; import { ToolCall } from './ToolCall'; import { ImageBlock } from './ImageBlock'; 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 { useLocale } from '../hooks/useLocale'; +import { stripWebhookScaffolding, hasWebhookScaffolding } from '../lib/systemEvent'; // ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage 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 + + // 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).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; + return { ...tb, text: stripWebhookScaffolding(tb.text) }; + } + return b; + }); + } + return cleaned; + }, [rawMessage]); + + const wasWebhookMessage = rawMessage !== message; + const isUser = message.role === 'user'; // System events render as subtle inline notifications if (message.isSystemEvent) { - return ; + return ; } // 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) */} {!isUser && } - {message.timestamp && ( -
- {formatTimestamp(message.timestamp)} + {(message.timestamp || wasWebhookMessage) && ( +
+ {wasWebhookMessage && ( + + + webhook + + )} + {message.timestamp && formatTimestamp(message.timestamp)}
)}
diff --git a/src/lib/systemEvent.ts b/src/lib/systemEvent.ts index 5e47224..3604568 100644 --- a/src/lib/systemEvent.ts +++ b/src/lib/systemEvent.ts @@ -27,3 +27,57 @@ export function isSystemEvent(text: string): boolean { if (!trimmed) return false; 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 --- + * ... + * <<>> + * actual message + * <<>> + * + * 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 <<>> delimiters + const extMatch = trimmed.match( + /<<>>\s*([\s\S]*?)\s*<<>>/ + ); + 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 /<<>>/.test(text) || + /---\s*SECURITY NOTICE\s*---/i.test(text); +}