mirror of
https://github.com/coder/coder.git
synced 2026-10-31 19:36:41 +08:00
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
166 lines
4.8 KiB
Go
166 lines
4.8 KiB
Go
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()
|
||
}
|