improvement(mothership): tool display titles, html sanitization, and ui fixes (#3631)

* improvement(mothership): tool display titles, html sanitization, and ui fixes

- Use TOOL_UI_METADATA as fallback for tool display titles (fast_edit shows "Editing workflow" instead of "Fast Edit")
- Harden HTML-to-text extraction with replaceUntilStable to prevent nested tag injection
- Decode HTML entities in a single pass to avoid double-unescaping
- Fix Google Drive/Docs query escaping for backslashes in folder IDs
- Replace regex with indexOf for email sender/display name parsing
- Update embedded workflow run tooltip to "Run workflow"

* fix(security): decode entities before tag stripping and cap loop iterations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-03-17 10:31:58 -07:00
committed by GitHub
parent 3e3c160789
commit 1873f2d775
7 changed files with 82 additions and 22 deletions

View File

@@ -267,11 +267,24 @@ async function createRejectedTask(
* Format: "username@domain.com" or "Display Name <username@domain.com>"
*/
function extractSenderEmail(from: string): string {
const match = from.match(/<([^>]+)>/)
return (match?.[1] || from).toLowerCase().trim()
const openBracket = from.indexOf('<')
const closeBracket = from.indexOf('>', openBracket + 1)
if (openBracket !== -1 && closeBracket !== -1) {
return from
.substring(openBracket + 1, closeBracket)
.toLowerCase()
.trim()
}
return from.toLowerCase().trim()
}
function extractDisplayName(from: string): string | null {
const match = from.match(/^(.+?)\s*</)
return match?.[1]?.trim().replace(/^"|"$/g, '') || null
const openBracket = from.indexOf('<')
if (openBracket <= 0) return null
const name = from.substring(0, openBracket).trim()
if (!name) return null
if (name.startsWith('"') && name.endsWith('"')) {
return name.slice(1, -1) || null
}
return name
}

View File

@@ -1,7 +1,7 @@
'use client'
import type { ContentBlock, OptionItem, SubagentName, ToolCallData } from '../../types'
import { SUBAGENT_LABELS } from '../../types'
import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types'
import type { AgentGroupItem } from './components'
import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } from './components'
@@ -47,7 +47,10 @@ function toToolData(tc: NonNullable<ContentBlock['toolCall']>): ToolCallData {
return {
id: tc.id,
toolName: tc.name,
displayTitle: tc.displayTitle || formatToolName(tc.name),
displayTitle:
tc.displayTitle ||
TOOL_UI_METADATA[tc.name as keyof typeof TOOL_UI_METADATA]?.title ||
formatToolName(tc.name),
status: tc.status,
result: tc.result,
}

View File

@@ -197,7 +197,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>{isExecuting ? 'Stop' : 'Run'}</p>
<p>{isExecuting ? 'Stop' : 'Run workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
</>

View File

@@ -162,7 +162,7 @@ function buildQuery(sourceConfig: Record<string, unknown>): string {
const folderId = sourceConfig.folderId as string | undefined
if (folderId?.trim()) {
parts.push(`'${folderId.trim().replace(/'/g, "\\'")}' in parents`)
parts.push(`'${folderId.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' in parents`)
}
return parts.join(' and ')

View File

@@ -112,7 +112,7 @@ function buildQuery(sourceConfig: Record<string, unknown>): string {
const folderId = sourceConfig.folderId as string | undefined
if (folderId?.trim()) {
parts.push(`'${folderId.trim().replace(/'/g, "\\'")}' in parents`)
parts.push(`'${folderId.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' in parents`)
}
const fileType = (sourceConfig.fileType as string) || 'all'

View File

@@ -94,26 +94,67 @@ function isForwardedEmail(subject: string | null, body: string | null): boolean
return false
}
/**
* Repeatedly applies a regex replacement until the string stabilises.
* Prevents incomplete sanitization from nested/overlapping patterns
* like `<scr<script>ipt>`.
*/
export function replaceUntilStable(
input: string,
pattern: RegExp,
replacement: string,
maxIterations = 100
): string {
let prev = input
let next = prev.replace(pattern, replacement)
let iterations = 0
while (next !== prev && iterations++ < maxIterations) {
prev = next
next = prev.replace(pattern, replacement)
}
return next
}
const HTML_ENTITY_MAP: Record<string, string> = {
'&nbsp;': ' ',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
}
/**
* Decodes known HTML entities in a single pass to avoid double-unescaping.
* A two-step decode (e.g. `&amp;` -> `&` then `&lt;` -> `<`) would turn
* `&amp;lt;` into `<`, which is incorrect.
*/
function decodeHtmlEntities(text: string): string {
return text.replace(/&(?:nbsp|amp|lt|gt|quot|#39);/g, (match) => HTML_ENTITY_MAP[match] ?? match)
}
/**
* Basic HTML to text extraction.
*/
function extractTextFromHtml(html: string | null): string | null {
if (!html) return null
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
let text = html
text = decodeHtmlEntities(text)
text = replaceUntilStable(text, /<style[^>]*>[\s\S]*?<\/style\s*>/gi, '')
text = replaceUntilStable(text, /<script[^>]*>[\s\S]*?<\/script\s*>/gi, '')
text = text
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<\/div>/gi, '\n')
.replace(/<\/li>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\n{3,}/g, '\n\n')
.trim()
text = replaceUntilStable(text, /<[^>]+>/g, '')
text = text.replace(/\n{3,}/g, '\n\n').trim()
return text
}

View File

@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { marked } from 'marked'
import { getBaseUrl } from '@/lib/core/utils/urls'
import * as agentmail from '@/lib/mothership/inbox/agentmail-client'
import { replaceUntilStable } from '@/lib/mothership/inbox/format'
import type { InboxTask } from '@/lib/mothership/inbox/types'
const logger = createLogger('InboxResponse')
@@ -82,7 +83,9 @@ const EMAIL_STYLES = `
function stripRawHtml(text: string): string {
return text
.split(/(```[\s\S]*?```)/g)
.map((segment, i) => (i % 2 === 0 ? segment.replace(/<\/?[a-z][^>]*>/gi, '') : segment))
.map((segment, i) =>
i % 2 === 0 ? replaceUntilStable(segment, /<\/?[a-z][^>]*>/gi, '') : segment
)
.join('')
}