mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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:
parent
7c954969b7
commit
1011cd24e2
8 changed files with 159 additions and 30 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
71
apps/desktop/src/lib/composer-input-sanitize.ts
Normal file
71
apps/desktop/src/lib/composer-input-sanitize.ts
Normal 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
24
cli.py
|
|
@ -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:
|
||||
|
|
|
|||
70
hermes_cli/input_sanitize.py
Normal file
70
hermes_cli/input_sanitize.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue