fix(input): strip bracketed-paste leaks before prompt persistence (#62557)

Extract shared hermes_cli/input_sanitize.py (bracketed-paste wrapper stripping
and terminal ~[[e artifact suffix collapse) and wire it into prompt.submit
and the Desktop composer so corrupted user text is cleaned before
messages.content is persisted.
This commit is contained in:
HexLab98 2026-07-12 06:54:55 +07:00 committed by Teknium
parent 7c954969b7
commit 1011cd24e2
8 changed files with 159 additions and 30 deletions

View file

@ -6,6 +6,7 @@ import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-d
import { Button } from '@/components/ui/button'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@ -267,7 +268,7 @@ export function ChatBar({
normalizeComposerEditorDom(editor)
const nextDraft = composerPlainText(editor)
const nextDraft = sanitizeComposerInput(composerPlainText(editor))
if (nextDraft !== draftRef.current) {
draftRef.current = nextDraft
@ -332,7 +333,7 @@ export function ChatBar({
// blank lines (common when selecting from terminals, code blocks, web pages)
// doesn't dump multiline padding into the composer. Internal newlines are
// preserved — only the edges are cleaned up.
const pastedText = event.clipboardData.getData('text').trim()
const pastedText = sanitizeComposerInput(event.clipboardData.getData('text').trim())
if (!pastedText) {
event.preventDefault()

View file

@ -6,6 +6,7 @@ import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS, transcribeAudio } from '@/hermes'
import { useI18n } from '@/i18n'
import { stripAnsi } from '@/lib/ansi'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
@ -471,7 +472,7 @@ export function usePromptActions({
const submitText = useCallback(
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const visibleText = sanitizeComposerInput(rawText).trim()
const attachments = options?.attachments ?? $composerAttachments.get()
if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
@ -596,7 +597,7 @@ export function usePromptActions({
// window) so the caller can fall back to queueing the words for the next turn.
const steerPrompt = useCallback(
async (rawText: string): Promise<boolean> => {
const text = rawText.trim()
const text = sanitizeComposerInput(rawText).trim()
const sessionId = activeSessionId || activeSessionIdRef.current
if (!text || !sessionId) {

View file

@ -3,6 +3,7 @@ import { type MutableRefObject, useCallback } from 'react'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import type { Translations } from '@/i18n'
import { type ChatMessage, textPart } from '@/lib/chat-messages'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { optimisticAttachmentRef } from '@/lib/chat-runtime'
import { setMutableRef } from '@/lib/mutable-ref'
import {
@ -67,7 +68,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
return useCallback(
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const visibleText = sanitizeComposerInput(rawText).trim()
const usingComposerAttachments = !options?.attachments
// Drop undefined/null holes a session switch or draft restore can leave in

View file

@ -57,6 +57,7 @@ import { Codicon } from '@/components/ui/codicon'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { Loader2Icon } from '@/lib/icons'
@ -188,7 +189,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
const syncDraftFromEditor = useCallback(
(editor: HTMLDivElement) => {
const nextDraft = composerPlainText(editor)
const nextDraft = sanitizeComposerInput(composerPlainText(editor))
if (nextDraft !== draftRef.current) {
draftRef.current = nextDraft
@ -477,7 +478,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
}
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const pastedText = event.clipboardData.getData('text')
const pastedText = sanitizeComposerInput(event.clipboardData.getData('text'))
if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) {
event.preventDefault()

View file

@ -0,0 +1,71 @@
/**
* Strip terminal bracketed-paste leaks and repeated artifact tails from composer
* text before it is shown in the UI or sent to the gateway.
*
* Mirrors hermes_cli/input_sanitize.py (CLI/TUI gateway defensive path).
*/
const BRACKETED_PASTE_BOUNDARY_START = /(^|[\s\n>:\]\)])\[200~/g
const BRACKETED_PASTE_BOUNDARY_END = /\[201~(?=$|[\s\n<[():;.,!?])/g
const BRACKETED_PASTE_DEGRADED_START = /(^|[\s\n>:\]\)])00~/g
const BRACKETED_PASTE_DEGRADED_END = /01~(?=$|[\s\n<[():;.,!?])/g
const DESKTOP_PASTE_ARTIFACT = '~[[e'
/** Strip leaked bracketed-paste wrapper markers from user-visible text. */
export function stripLeakedBracketedPasteWrappers(text: string): string {
if (!text) {
return text
}
let cleaned = text
.replace(/\x1b\[200~/g, '')
.replace(/\x1b\[201~/g, '')
.replace(/\^\[\[200~/g, '')
.replace(/\^\[\[201~/g, '')
cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_START, '$1')
cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_END, '')
cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_START, '$1')
cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_END, '')
return cleaned
}
/** Drop a trailing run of the desktop ~[[e corruption signature (#62557). */
export function collapseRepeatedInputArtifacts(text: string, minRepeats = 4): string {
if (!text) {
return text
}
const marker = DESKTOP_PASTE_ARTIFACT
let index = text.length
let repeatCount = 0
while (index >= marker.length && text.slice(index - marker.length, index) === marker) {
repeatCount += 1
index -= marker.length
}
if (repeatCount < minRepeats) {
return text
}
let start = index
if (start >= 2 && text.slice(start - 2, start) === '[e') {
start -= 2
} else if (start >= 1 && text[start - 1] === '[') {
start -= 1
}
return text.slice(0, start)
}
/** Normalize composer text before submit or draft persistence. */
export function sanitizeComposerInput(text: string): string {
if (!text) {
return text
}
return collapseRepeatedInputArtifacts(stripLeakedBracketedPasteWrappers(text))
}

24
cli.py
View file

@ -2995,29 +2995,9 @@ def _should_auto_attach_clipboard_image_on_paste(pasted_text: str) -> bool:
def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
"""Strip leaked bracketed-paste wrapper markers from user-visible text.
from hermes_cli.input_sanitize import strip_leaked_bracketed_paste_wrappers
Defensive normalization for cases where terminal/prompt_toolkit parsing
fails and bracketed-paste markers end up in the buffer as literal text.
We strip canonical wrappers unconditionally and also handle degraded
visible forms like ``[200~`` / ``[201~`` and ``00~`` / ``01~`` when they
look like wrapper boundaries, not arbitrary user content.
"""
if not text:
return text
text = (
text.replace("\x1b[200~", "")
.replace("\x1b[201~", "")
.replace("^[[200~", "")
.replace("^[[201~", "")
)
text = re.sub(r"(^|[\s\n>:\]\)])\[200~", r"\1", text)
text = re.sub(r"\[201~(?=$|[\s\n<\[\(\):;.,!?])", "", text)
text = re.sub(r"(^|[\s\n>:\]\)])00~", r"\1", text)
text = re.sub(r"01~(?=$|[\s\n<\[\(\):;.,!?])", "", text)
return text
return strip_leaked_bracketed_paste_wrappers(text)
def _apply_bracketed_paste_timeout_patch() -> None:

View file

@ -0,0 +1,70 @@
"""Sanitize user prompt text leaked from terminal / paste control sequences."""
from __future__ import annotations
import re
_BRACKETED_PASTE_BOUNDARY_START = re.compile(r"(^|[\s\n>:\]\)])\[200~")
_BRACKETED_PASTE_BOUNDARY_END = re.compile(r"\[201~(?=$|[\s\n<\[\(\):;.,!?])")
_BRACKETED_PASTE_DEGRADED_START = re.compile(r"(^|[\s\n>:\]\)])00~")
_BRACKETED_PASTE_DEGRADED_END = re.compile(r"01~(?=$|[\s\n<\[\(\):;.,!?])")
# Corruption signature from desktop bracketed-paste leaks (#62557).
_DESKTOP_PASTE_ARTIFACT = "~[[e"
def strip_leaked_bracketed_paste_wrappers(text: str) -> str:
"""Strip leaked bracketed-paste wrapper markers from user-visible text.
Defensive normalization for cases where terminal/prompt_toolkit parsing
fails and bracketed-paste markers end up in the buffer as literal text.
Canonical wrappers are stripped unconditionally. Degraded visible forms like
``[200~`` / ``[201~`` and ``00~`` / ``01~`` are removed only at boundaries
so embedded literals such as ``literal[200~tag`` stay intact.
"""
if not text:
return text
text = (
text.replace("\x1b[200~", "")
.replace("\x1b[201~", "")
.replace("^[[200~", "")
.replace("^[[201~", "")
)
text = _BRACKETED_PASTE_BOUNDARY_START.sub(r"\1", text)
text = _BRACKETED_PASTE_BOUNDARY_END.sub("", text)
text = _BRACKETED_PASTE_DEGRADED_START.sub(r"\1", text)
text = _BRACKETED_PASTE_DEGRADED_END.sub("", text)
return text
def collapse_repeated_input_artifacts(text: str, min_repeats: int = 4) -> str:
"""Drop a trailing run of the desktop ~[[e corruption signature (#62557)."""
if not text:
return text
marker = _DESKTOP_PASTE_ARTIFACT
index = len(text)
repeat_count = 0
while index >= len(marker) and text[index - len(marker) : index] == marker:
repeat_count += 1
index -= len(marker)
if repeat_count < min_repeats:
return text
start = index
if start >= 2 and text[start - 2 : start] == "[e":
start -= 2
elif start >= 1 and text[start - 1] == "[":
start -= 1
return text[:start]
def sanitize_user_prompt_text(text: str) -> str:
"""Normalize user-authored prompt text before persistence or model input."""
if not isinstance(text, str) or not text:
return text
cleaned = strip_leaked_bracketed_paste_wrappers(text)
return collapse_repeated_input_artifacts(cleaned)

View file

@ -8463,7 +8463,11 @@ def _(rid, params: dict) -> dict:
@method("prompt.submit")
def _(rid, params: dict) -> dict:
sid, text = params.get("session_id", ""), params.get("text", "")
from hermes_cli.input_sanitize import sanitize_user_prompt_text
sid = params.get("session_id", "")
raw_text = params.get("text", "")
text = sanitize_user_prompt_text(raw_text) if isinstance(raw_text, str) else raw_text
truncate_user_ordinal = params.get("truncate_before_user_ordinal")
session, err = _sess_nowait(params, rid)
if err: