| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288 |
- // Package anthropicfp provides pure helpers for suppressing client-side
- // fingerprints that would otherwise be visible to upstream Anthropic when a
- // forwarding gateway sits between the client and api.anthropic.com.
- //
- // Currently exposes NormalizeDateline: it rewrites the "Today's date is
- // YYYY-MM-DD." sentence inside a request body back to a canonical ASCII form,
- // erasing three bits of steganographic signal (four apostrophe code points and
- // a date-separator variant) that some clients embed in that sentence when
- // they detect a non-official base URL.
- package anthropicfp
- import (
- "fmt"
- "regexp"
- "sort"
- "strings"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
- )
- // datelineRegexes matches the fingerprinted sentence with any of the four
- // apostrophe code points seen in the wild and either separator. Two regexes
- // are used because Go's RE2-based regexp package does not support
- // backreferences: matching `-` and `/` in two passes keeps the two separators
- // inside YYYY?MM?DD forced to agree, so mixed-separator strings like
- // "Today's date is 2026-07/01." never match. This is what filters out
- // user-authored prose like "Today is foo." or "His date is 2026-06-30." from
- // being touched.
- var (
- datelineRegexHyphen = regexp.MustCompile(`Today(['’ʼʹ])s date is (\d{4})-(\d{2})-(\d{2})\.`)
- datelineRegexSlash = regexp.MustCompile(`Today(['’ʼʹ])s date is (\d{4})/(\d{2})/(\d{2})\.`)
- )
- // systemReminderRegex matches a <system-reminder> block. The dateline lives in
- // this block once the conversation has advanced past the first turn (system
- // prompt caching hides the top-level system block for subsequent turns), so
- // the messages[].content[] scan is confined to what lives inside these tags.
- var systemReminderRegex = regexp.MustCompile(`(?s)<system-reminder>.*?</system-reminder>`)
- // DatelineHit records what a single rewrite normalized, for observability.
- type DatelineHit struct {
- // ApostropheVariant is one of "ascii" (U+0027), "u2019", "u02bc", "u02b9".
- ApostropheVariant string
- // DateSeparator is either "-" or "/" as seen before normalization.
- DateSeparator string
- }
- // canonicalize returns the canonical form of a matched dateline sentence.
- // The output always uses ASCII apostrophe and hyphen separators.
- func canonicalize(year, month, day string) string {
- return fmt.Sprintf("Today's date is %s-%s-%s.", year, month, day)
- }
- func apostropheVariant(r rune) string {
- switch r {
- case '’':
- return "u2019"
- case 'ʼ':
- return "u02bc"
- case 'ʹ':
- return "u02b9"
- default:
- return "ascii"
- }
- }
- type datelineMatch struct {
- start, end int
- apoRune rune
- sep string
- year, month, day string
- }
- func collectMatches(text string, re *regexp.Regexp, sep string) []datelineMatch {
- locs := re.FindAllStringSubmatchIndex(text, -1)
- if len(locs) == 0 {
- return nil
- }
- out := make([]datelineMatch, 0, len(locs))
- for _, m := range locs {
- var apoRune rune
- for _, r := range text[m[2]:m[3]] {
- apoRune = r
- break
- }
- out = append(out, datelineMatch{
- start: m[0],
- end: m[1],
- apoRune: apoRune,
- sep: sep,
- year: text[m[4]:m[5]],
- month: text[m[6]:m[7]],
- day: text[m[8]:m[9]],
- })
- }
- return out
- }
- // NormalizeText replaces every fingerprinted dateline sentence in text with
- // its canonical form. It returns the possibly-rewritten text and the list of
- // hits observed. When no match is found the original string is returned
- // verbatim (byte-identical), and the returned hit slice is nil.
- func NormalizeText(text string) (string, []DatelineHit) {
- if !strings.Contains(text, "date is ") {
- return text, nil
- }
- matches := collectMatches(text, datelineRegexHyphen, "-")
- matches = append(matches, collectMatches(text, datelineRegexSlash, "/")...)
- if len(matches) == 0 {
- return text, nil
- }
- sort.Slice(matches, func(i, j int) bool { return matches[i].start < matches[j].start })
- var b strings.Builder
- b.Grow(len(text))
- prev := 0
- hits := make([]DatelineHit, 0, len(matches))
- changed := false
- for _, m := range matches {
- full := text[m.start:m.end]
- canonical := canonicalize(m.year, m.month, m.day)
- if canonical == full {
- // Already canonical: no rewrite, no hit.
- continue
- }
- _, _ = b.WriteString(text[prev:m.start])
- _, _ = b.WriteString(canonical)
- prev = m.end
- changed = true
- hits = append(hits, DatelineHit{
- ApostropheVariant: apostropheVariant(m.apoRune),
- DateSeparator: m.sep,
- })
- }
- if !changed {
- return text, nil
- }
- _, _ = b.WriteString(text[prev:])
- return b.String(), hits
- }
- // normalizeSystemReminderScopedText scans only the <system-reminder> blocks
- // inside text and normalizes datelines inside them. Text outside the blocks is
- // preserved byte-for-byte, so user prose, tool_result content, code blocks,
- // or shell commands that happen to contain an apostrophe or a slash date are
- // never touched.
- func normalizeSystemReminderScopedText(text string) (string, []DatelineHit) {
- if !strings.Contains(text, "<system-reminder>") {
- return text, nil
- }
- locs := systemReminderRegex.FindAllStringIndex(text, -1)
- if len(locs) == 0 {
- return text, nil
- }
- var b strings.Builder
- b.Grow(len(text))
- prev := 0
- var hits []DatelineHit
- changed := false
- for _, loc := range locs {
- _, _ = b.WriteString(text[prev:loc[0]])
- block := text[loc[0]:loc[1]]
- normalized, blockHits := NormalizeText(block)
- if normalized != block {
- changed = true
- }
- _, _ = b.WriteString(normalized)
- hits = append(hits, blockHits...)
- prev = loc[1]
- }
- if !changed {
- return text, nil
- }
- _, _ = b.WriteString(text[prev:])
- return b.String(), hits
- }
- // NormalizeDateline scans an Anthropic /v1/messages request body and rewrites
- // every fingerprinted dateline sentence back to its canonical ASCII form.
- //
- // Scope (mirroring where genuine clients place the sentence):
- // - `system` string, or `.text` field of each text-typed block in `system`.
- // - Text bodies inside `messages[i].content` — but ONLY the substrings that
- // appear inside `<system-reminder>...</system-reminder>` tags. Free user
- // prose, tool_use.input, tool_result.content, and other block types are
- // never scanned, guaranteeing that legitimate text like a code block, a
- // shell command, or a chat message that mentions today's date is never
- // accidentally rewritten.
- //
- // The function is a pure transform: it never modifies the input slice, and if
- // no rewrite is needed it returns the original slice (identity), a nil hit
- // slice, and changed=false.
- func NormalizeDateline(body []byte) ([]byte, []DatelineHit, bool) {
- if len(body) == 0 {
- return body, nil, false
- }
- out := body
- var hits []DatelineHit
- changed := false
- sys := gjson.GetBytes(out, "system")
- if sys.Exists() {
- switch {
- case sys.Type == gjson.String:
- normalized, sysHits := NormalizeText(sys.String())
- if normalized != sys.String() {
- if next, err := sjson.SetBytes(out, "system", normalized); err == nil {
- out = next
- changed = true
- hits = append(hits, sysHits...)
- }
- }
- case sys.IsArray():
- idx := 0
- sys.ForEach(func(_, item gjson.Result) bool {
- if item.Get("type").String() == "text" {
- t := item.Get("text")
- if t.Exists() && t.Type == gjson.String {
- normalized, textHits := NormalizeText(t.String())
- if normalized != t.String() {
- path := fmt.Sprintf("system.%d.text", idx)
- if next, err := sjson.SetBytes(out, path, normalized); err == nil {
- out = next
- changed = true
- hits = append(hits, textHits...)
- }
- }
- }
- }
- idx++
- return true
- })
- }
- }
- messages := gjson.GetBytes(out, "messages")
- if messages.IsArray() {
- msgIdx := -1
- messages.ForEach(func(_, msg gjson.Result) bool {
- msgIdx++
- content := msg.Get("content")
- if !content.Exists() {
- return true
- }
- switch {
- case content.Type == gjson.String:
- normalized, contentHits := normalizeSystemReminderScopedText(content.String())
- if normalized != content.String() {
- path := fmt.Sprintf("messages.%d.content", msgIdx)
- if next, err := sjson.SetBytes(out, path, normalized); err == nil {
- out = next
- changed = true
- hits = append(hits, contentHits...)
- }
- }
- case content.IsArray():
- contentIdx := -1
- content.ForEach(func(_, block gjson.Result) bool {
- contentIdx++
- if block.Get("type").String() != "text" {
- return true
- }
- t := block.Get("text")
- if !t.Exists() || t.Type != gjson.String {
- return true
- }
- normalized, textHits := normalizeSystemReminderScopedText(t.String())
- if normalized != t.String() {
- path := fmt.Sprintf("messages.%d.content.%d.text", msgIdx, contentIdx)
- if next, err := sjson.SetBytes(out, path, normalized); err == nil {
- out = next
- changed = true
- hits = append(hits, textHits...)
- }
- }
- return true
- })
- }
- return true
- })
- }
- if !changed {
- return body, nil, false
- }
- return out, hits, true
- }
|