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
72 lines
2.3 KiB
Go
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
|
|
}
|