Bracketed-paste marker leaks in CLI and desktop

NousResearch/hermes-agent · cluster 7316

Data as of

Nothing open on GitHub. Fix merged in #16367. Check it covers your case.

The short answer

0open issues
0open PRs
7items grouped
Known?
Yes. 7 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #16367. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PRs #16367, #64616. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and pull requests address bracketed-paste escape sequences leaking into user input in both the CLI and Electron desktop app. The work involves stripping these markers before they corrupt persisted content or cause unresponsiveness.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #16367 fix(cli): strip leaked bracketed-paste wrappers (salvage #7324)

    merged to main

    files: cli.py, scripts/release.py, tests/cli/test_cli_bracketed_paste_sanitizer.py

  • #64616 fix(desktop): strip bracketed-paste leaks before prompt persistence (#62557)

    merged to main

    files: apps/desktop/src/app/chat/composer/index.tsx, apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts, apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts, apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx, apps/desktop/src/lib/composer-input-sanitize.test.ts, apps/desktop/src/lib/composer-input-sanitize.ts, cli.py, hermes_cli/input_sanitize.py, tests/hermes_cli/test_input_sanitize.py, tests/test_tui_gateway_server.py, tui_gateway/server.py

    Show the diff
    • apps/desktop/src/app/chat/composer/index.tsx
      @@ -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()
    • apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
      @@ -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) {
    • apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts
      @@ -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
    • apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
      @@ -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()
    • apps/desktop/src/lib/composer-input-sanitize.test.ts
      @@ -0,0 +1,51 @@
      +import { describe, expect, it } from 'vitest'
      +
      +import {
      +  collapseRepeatedInputArtifacts,
      +  sanitizeComposerInput,
      +  stripLeakedBracketedPasteWrappers
      +} from './composer-input-sanitize'
      +
      +describe('stripLeakedBracketedPasteWrappers', () => {
      +  it('leaves plain text unchanged', () => {
      +    expect(stripLeakedBracketedPasteWrappers('hello world')).toBe('hello world')
      +  })
      +
      +  it('strips canonical escape wrappers', () => {
      +    expect(stripLeakedBracketedPasteWrappers('\x1b[200~hello\x1b[201~')).toBe('hello')
      +  })
      +
      +  it('keeps embedded literal bracket forms', () => {
      +    const text = 'literal[200~tag and literal[201~tag should stay'
      +    expect(stripLeakedBracketedPasteWrappers(text)).toBe(text)
      +  })
      +})
      +
      +describe('collapseRepeatedInputArtifacts', () => {
      +  it('removes the desktop corruption tail from #62557', () => {
      +    const prefix = '需要时随时叫我。'
      +    const tail = '[e~[[e' + '~[[e'.repeat(20)
      +    expect(collapseRepeatedInputArtifacts(prefix + tail)).toBe(prefix)
      +  })
      +
      +  it('preserves a mid-string marker followed by valid suffix', () => {
      +    const text = 'notes ~[[e more text here'
      +    expect(collapseRepeatedInputArtifacts(text)).toBe(text)
      +  })
      +
      +  it('preserves trailing punctuation that is not the corruption signature', () => {
      +    expect(collapseRepeatedInputArtifacts('wait....')).toBe('wait....')
      +  })
      +
      +  it('does not strip when fewer than minRepeats markers appear at the tail', () => {
      +    const text = 'hello~[[e~[[e'
      +    expect(collapseRepeatedInputArtifacts(text)).toBe(text)
      +  })
      +})
      +
      +describe('sanitizeComposerInput', () => {
      +  it('normalizes wrappers and repeated artifact tails together', () => {
      +    const corrupted = 'hello[' + '~[[e'.repeat(8)
      +    expect(sanitizeComposerInput(corrupted)).toBe('hello')
      +  })
      +})
    • apps/desktop/src/lib/composer-input-sanitize.ts
      @@ -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))
      +}
    • 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:
    • hermes_cli/input_sanitize.py
      @@ -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)
    • tests/hermes_cli/test_input_sanitize.py
      @@ -0,0 +1,51 @@
      +"""Tests for shared user prompt input sanitization."""
      +
      +from hermes_cli.input_sanitize import (
      +    collapse_repeated_input_artifacts,
      +    sanitize_user_prompt_text,
      +    strip_leaked_bracketed_paste_wrappers,
      +)
      +
      +
      +class TestStripLeakedBracketedPasteWrappers:
      +    def test_plain_text_unchanged(self):
      +        assert strip_leaked_bracketed_paste_wrappers("hello world") == "hello world"
      +
      +    def test_strips_canonical_escape_wrappers(self):
      +        assert strip_leaked_bracketed_paste_wrappers("\x1b[200~hello\x1b[201~") == "hello"
      +
      +    def test_strips_visible_caret_escape_wrappers(self):
      +        assert strip_leaked_bracketed_paste_wrappers("^[[200~hello^[[201~") == "hello"
      +
      +    def test_does_not_strip_non_wrapper_bracket_forms_in_normal_text(self):
      +        text = "literal[200~tag and literal[201~tag should stay"
      +        assert strip_leaked_bracketed_paste_wrappers(text) == text
      +
      +
      +class TestCollapseRepeatedInputArtifacts:
      +    def test_issue_62557_corruption_tail(self):
      +        prefix = "需要时随时叫我。"
      +        tail = "[e~[[e" + "~[[e" * 20
      +        assert collapse_repeated_input_artifacts(prefix + tail) == prefix
      +
      +    def test_plain_text_unchanged(self):
      +        text = "build00~tag should stay"
      +        assert collapse_repeated_input_artifacts(text) == text
      +
      +    def test_mid_string_marker_followed_by_suffix_preserved(self):
      +        text = "notes ~[[e more text here"
      +        assert collapse_repeated_input_artifacts(text) == text
      +
      +    def test_trailing_punctuation_preserved(self):
      +        assert collapse_repeated_input_artifacts("wait....") == "wait...."
      +
      +    def test_insufficient_tail_repeats_preserved(self):
      +        text = "hello~[[e~[[e"
      +        assert collapse_repeated_input_artifacts(text) == text
      +
      +
      +class TestSanitizeUserPromptText:
      +    def test_combines_wrapper_strip_and_tail_collapse(self):
      +        prefix = "hello["
      +        corrupted = prefix + "~[[e" * 8
      +        assert sanitize_user_prompt_text(corrupted) == "hello"
    • tests/test_tui_gateway_server.py
      @@ -5093,6 +5093,51 @@ def start(self):
               server._sessions.pop("sid", None)
       
       
      +def test_prompt_submit_sanitizes_bracketed_paste_before_agent(monkeypatch):
      +    """prompt.submit must sanitize corrupted user text before run_conversation."""
      +    captured: dict[str, str] = {}
      +
      +    class _Agent:
      +        def run_conversation(
      +            self, prompt, conversation_history=None, stream_callback=None
      +        ):
      +            captured["prompt"] = prompt
      +            return {
      +                "final_response": "ok",
      +                "messages": [{"role": "assistant", "content": "ok"}],
      +            }
      +
      +    class _ImmediateThread:
      +        def __init__(self, target=None, daemon=None, **kw):
      +            self._target = target
      +
      +        def start(self):
      +            self._target()
      +
      +    corrupted = "hello[" + "~[[e" * 8
      +    server._sessions["sid"] = _session(agent=_Agent())
      +    try:
      +        monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
      +        monkeypatch.setattr(server, "_get_usage", lambda _a: {})
      +        monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
      +        monkeypatch.setattr(server, "_emit", lambda *a: None)
      +        monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
      +        monkeypatch.setattr(server, "_ensure_session_db_row", lambda *a, **k: None)
      +        monkeypatch.setattr(server, "_persist_branch_seed", lambda *a, **k: None)
      +
      +        resp = server.handle_request(
      +            {
      +                "id": "1",
      +                "method": "prompt.submit",
      +                "params": {"session_id": "sid", "text": corrupted},
      +            }
      +        )
      +        assert resp.get("result"), f"got error: {resp.get('error')}"
      +        assert captured["prompt"] == "hello"
      +    finally:
      +        server._sessions.pop("sid", None)
      +
      +
       def test_prompt_submit_history_version_match_persists_normally(monkeypatch):
           """Regression guard: the backstop does not affect the happy path."""
       
    • tui_gateway/server.py
      @@ -8455,7 +8455,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:

3 PRs closed without merging

From the close comments: closed without comment 1 · done elsewhere 1 · merged via another change 1

  • #7324 fix(cli): strip leaked bracketed-paste wrappers · merged via another change → a0fe73b, #16367
  • #11504 fix(cli): strip leaked bracketed-paste markers from user input · done elsewhere
  • #62599 fix(desktop): strip bracketed-paste leaks before prompt persistence (#62557) · closed without comment
Closed issues · 2
  • Closed#7316[Bug]: Hermes CLI can leak bracketed-paste markers (^[[200~) into input and become unresponsive in Ghostty
  • Closed#62557[Bug]: Hermes Desktop (Electron) leaks bracketed-paste markers into user messages, corrupting persisted content (~[[e ×200+ repeats in messages.content)
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.30: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#16367, #64616
Closed issues
2
Attempts
3 PRs closed without merging
Fix link checked
15 Jul 2026

For your agent

The same facts, typed.