Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

72 lines
2.3 KiB
Go

// ABOUTME: terminal-output hardening for session CLI commands.
// ABOUTME: Strips C0/C1 control bytes before printing so session
// ABOUTME: text cannot spoof terminal state via escape sequences.
package main
import (
"strings"
"unicode/utf8"
)
// sanitizeTerminal strips C0/C1 control bytes (including ESC and
// CR) from s so that session-derived text — message content,
// display names, project names, tool names, etc. — cannot drive
// terminal escape sequences when printed in --format human mode.
// Preserves only \n and \t so line breaks and tabs still work;
// carriage return is dropped because bare \r returns the cursor
// to column 0 and lets "safe\rEVIL" overwrite earlier output
// without any ANSI involved. CRLF input still renders correctly
// because terminals treat lone \n as a newline.
//
// Rationale: even though agentsview is a single-user tool and
// session files are generally trusted, content flows in from
// imported transcripts and remote machines via PG sync. Without
// this filter a malicious session could emit OSC 8 hyperlinks
// (phishing), OSC 52 clipboard writes, title-set sequences, or
// cursor-movement that overwrites prior output. JSON output is
// left untouched because consumers there handle their own escaping.
func sanitizeTerminal(s string) string {
if !hasControlBytes(s) {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); {
r, size := utf8.DecodeRuneInString(s[i:])
// Invalid UTF-8 byte: drop one byte and retry. This
// prevents raw 0x80-0xBF bytes from surfacing as U+FFFD
// and keeps the output valid UTF-8.
if r == utf8.RuneError && size == 1 {
i++
continue
}
switch {
case r == '\n' || r == '\t':
b.WriteRune(r)
case r < 0x20, r == 0x7f, r >= 0x80 && r <= 0x9f:
// C0, DEL, C1 controls: dropped.
default:
b.WriteRune(r)
}
i += size
}
return b.String()
}
// hasControlBytes is a fast-path check that avoids building a new
// string when s is already clean. It only looks at raw bytes — the
// UTF-8 range pass in sanitizeTerminal handles rune boundaries.
// Keep the preserved set here in sync with sanitizeTerminal.
func hasControlBytes(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '\n' || c == '\t':
continue
case c < 0x20, c == 0x7f, c >= 0x80 && c <= 0x9f:
return true
}
}
return false
}