Files
coder/codersdk/promptsanitize.go
T
Ben Potter 8a7e8d9d5b refactor: move chat prompt sanitization into codersdk (#28283)
Exports `SanitizePromptText` (and its helpers) from `codersdk` instead
of `coderd/x/chatd`, so API consumers can sanitize prompt text exactly
the way the server stores it.

## Why

coder/terraform-provider-coderd#412 adds a `coderd_chat_system_prompt`
resource whose `system_prompt` attribute compares values by their
sanitized forms (otherwise the trailing newline from
`file("system-prompt.md")` is perpetual drift, since the server stores
the sanitized value). That currently requires a mirrored copy of the
sanitizer in the provider, which can silently rot. Per review there
([discussion](https://github.com/coder/terraform-provider-coderd/pull/412#discussion_r3801110795)),
the sanitizer should be exported from `codersdk` and imported instead.

## What

- `coderd/x/chatd/sanitize.go` → `codersdk/promptsanitize.go` (pure
move; package + doc-comment note about why it lives in codersdk)
- `coderd/x/chatd/sanitize_test.go` → `codersdk/promptsanitize_test.go`
- Callsites updated (`exp_chats.go`, `chatd.go`, `subagent.go`,
`context_prompt.go`); no wrapper left behind, single source of truth
- No behavior change

> Generated by Coder Agents on behalf of @bpmct
2026-08-19 08:51:25 -05:00

166 lines
4.8 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package codersdk
import (
"strings"
"unicode"
)
// SanitizePromptText strips invisible Unicode characters that could
// hide prompt-injection content from human reviewers, normalizes line
// endings, collapses excessive blank lines, and trims surrounding
// whitespace.
//
// It lives in codersdk so API consumers, such as terraform-provider-coderd,
// can compare prompt values the same way the server stores them.
//
// The stripped codepoints are truly invisible and have no legitimate
// use in prompt text. An explicit codepoint list is used rather than
// blanket unicode.Cf stripping to avoid breaking subdivision flag
// emoji (🏴󠁧󠁢󠁥󠁮󠁧󠁿) and other legitimate format characters.
//
// Note: U+200D (ZWJ) is stripped even though it joins compound emoji
// (e.g. 👨‍👩‍👦 → 👨👩👦). This is an acceptable trade-off because
// system prompts are not emoji art, and ZWJ is actively exploited in
// zero-width steganography schemes as a delimiter character.
func SanitizePromptText(s string) string {
// 1. Normalize line endings: \r\n → \n, lone \r → \n.
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
// 2. Strip invisible characters rune-by-rune.
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if !isVisible(r) {
continue
}
_, _ = b.WriteRune(r)
}
s = b.String()
// 3. Collapse 3+ consecutive newlines down to 2 (one blank
// line between paragraphs). This runs after invisible-char
// stripping so that lines containing only stripped chars
// become empty and get collapsed.
s = collapseNewlines(s)
// 4. Final trim.
return strings.TrimSpace(s)
}
// isVisible reports whether r is a visible Unicode character that
// should be preserved in prompt text. Each invisible range is
// documented with its Unicode name and rationale.
func isVisible(r rune) bool {
switch {
// Soft hyphen — invisible in most renderers, used to hide
// content boundaries.
case r == 0x00AD:
return false
// Combining grapheme joiner — invisible, no legitimate
// prompt use.
case r == 0x034F:
return false
// Arabic letter mark — bidi control, invisible.
case r == 0x061C:
return false
// Mongolian vowel separator — invisible spacing character.
case r == 0x180E:
return false
// Zero-width space (U+200B).
case r == 0x200B:
return false
// U+200C (ZWNJ) is deliberately NOT stripped. It is
// required for correct rendering of Persian, Urdu, and
// Kurdish scripts where it controls cursive joining.
// Stripping ZWS (U+200B) and ZWJ (U+200D) already breaks
// zero-width steganography encodings regardless of whether
// ZWNJ survives.
// Zero-width joiner (U+200D) — also used in compound emoji,
// but actively exploited in steganography. See
// SanitizePromptText doc comment.
case r == 0x200D:
return false
// Left-to-right mark (U+200E).
case r == 0x200E:
return false
// Right-to-left mark (U+200F).
case r == 0x200F:
return false
// Bidi embedding and override controls (U+202A–U+202E):
// LRE, RLE, PDF, LRO, RLO.
case r >= 0x202A && r <= 0x202E:
return false
// Word joiner and invisible operators (U+2060–U+2064):
// word joiner, function application, invisible times,
// invisible separator, invisible plus.
case r >= 0x2060 && r <= 0x2064:
return false
// Bidi isolate controls (U+2066–U+2069):
// LRI, RLI, FSI, PDI.
case r >= 0x2066 && r <= 0x2069:
return false
// Deprecated format characters (U+206A–U+206F): inhibit
// symmetric swapping through nominal digit shapes.
case r >= 0x206A && r <= 0x206F:
return false
// Byte order mark / zero-width no-break space (U+FEFF).
// Common at start of Windows-edited files.
case r == 0xFEFF:
return false
// Interlinear annotation anchor, separator, and
// terminator (U+FFF9–U+FFFB).
case r >= 0xFFF9 && r <= 0xFFFB:
return false
default:
return true
}
}
// collapseNewlines replaces runs of 3 or more consecutive newlines
// with exactly 2, preserving single blank lines (paragraph breaks)
// while eliminating scroll-padding attacks. Trailing whitespace on
// each line is stripped first so that whitespace-only lines become
// empty and collapse naturally.
func collapseNewlines(s string) string {
// Step 1: Trim trailing whitespace from each line, preserving
// leading whitespace for indentation.
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = strings.TrimRightFunc(line, unicode.IsSpace)
}
s = strings.Join(lines, "\n")
// Step 2: Collapse runs of 3+ consecutive newlines down to 2.
var b strings.Builder
b.Grow(len(s))
consecutiveNewlines := 0
for _, r := range s {
if r == '\n' {
consecutiveNewlines++
if consecutiveNewlines <= 2 {
_, _ = b.WriteRune(r)
}
continue
}
consecutiveNewlines = 0
_, _ = b.WriteRune(r)
}
return b.String()
}