dateline.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. // Package anthropicfp provides pure helpers for suppressing client-side
  2. // fingerprints that would otherwise be visible to upstream Anthropic when a
  3. // forwarding gateway sits between the client and api.anthropic.com.
  4. //
  5. // Currently exposes NormalizeDateline: it rewrites the "Today's date is
  6. // YYYY-MM-DD." sentence inside a request body back to a canonical ASCII form,
  7. // erasing three bits of steganographic signal (four apostrophe code points and
  8. // a date-separator variant) that some clients embed in that sentence when
  9. // they detect a non-official base URL.
  10. package anthropicfp
  11. import (
  12. "fmt"
  13. "regexp"
  14. "sort"
  15. "strings"
  16. "github.com/tidwall/gjson"
  17. "github.com/tidwall/sjson"
  18. )
  19. // datelineRegexes matches the fingerprinted sentence with any of the four
  20. // apostrophe code points seen in the wild and either separator. Two regexes
  21. // are used because Go's RE2-based regexp package does not support
  22. // backreferences: matching `-` and `/` in two passes keeps the two separators
  23. // inside YYYY?MM?DD forced to agree, so mixed-separator strings like
  24. // "Today's date is 2026-07/01." never match. This is what filters out
  25. // user-authored prose like "Today is foo." or "His date is 2026-06-30." from
  26. // being touched.
  27. var (
  28. datelineRegexHyphen = regexp.MustCompile(`Today(['’ʼʹ])s date is (\d{4})-(\d{2})-(\d{2})\.`)
  29. datelineRegexSlash = regexp.MustCompile(`Today(['’ʼʹ])s date is (\d{4})/(\d{2})/(\d{2})\.`)
  30. )
  31. // systemReminderRegex matches a <system-reminder> block. The dateline lives in
  32. // this block once the conversation has advanced past the first turn (system
  33. // prompt caching hides the top-level system block for subsequent turns), so
  34. // the messages[].content[] scan is confined to what lives inside these tags.
  35. var systemReminderRegex = regexp.MustCompile(`(?s)<system-reminder>.*?</system-reminder>`)
  36. // DatelineHit records what a single rewrite normalized, for observability.
  37. type DatelineHit struct {
  38. // ApostropheVariant is one of "ascii" (U+0027), "u2019", "u02bc", "u02b9".
  39. ApostropheVariant string
  40. // DateSeparator is either "-" or "/" as seen before normalization.
  41. DateSeparator string
  42. }
  43. // canonicalize returns the canonical form of a matched dateline sentence.
  44. // The output always uses ASCII apostrophe and hyphen separators.
  45. func canonicalize(year, month, day string) string {
  46. return fmt.Sprintf("Today's date is %s-%s-%s.", year, month, day)
  47. }
  48. func apostropheVariant(r rune) string {
  49. switch r {
  50. case '’':
  51. return "u2019"
  52. case 'ʼ':
  53. return "u02bc"
  54. case 'ʹ':
  55. return "u02b9"
  56. default:
  57. return "ascii"
  58. }
  59. }
  60. type datelineMatch struct {
  61. start, end int
  62. apoRune rune
  63. sep string
  64. year, month, day string
  65. }
  66. func collectMatches(text string, re *regexp.Regexp, sep string) []datelineMatch {
  67. locs := re.FindAllStringSubmatchIndex(text, -1)
  68. if len(locs) == 0 {
  69. return nil
  70. }
  71. out := make([]datelineMatch, 0, len(locs))
  72. for _, m := range locs {
  73. var apoRune rune
  74. for _, r := range text[m[2]:m[3]] {
  75. apoRune = r
  76. break
  77. }
  78. out = append(out, datelineMatch{
  79. start: m[0],
  80. end: m[1],
  81. apoRune: apoRune,
  82. sep: sep,
  83. year: text[m[4]:m[5]],
  84. month: text[m[6]:m[7]],
  85. day: text[m[8]:m[9]],
  86. })
  87. }
  88. return out
  89. }
  90. // NormalizeText replaces every fingerprinted dateline sentence in text with
  91. // its canonical form. It returns the possibly-rewritten text and the list of
  92. // hits observed. When no match is found the original string is returned
  93. // verbatim (byte-identical), and the returned hit slice is nil.
  94. func NormalizeText(text string) (string, []DatelineHit) {
  95. if !strings.Contains(text, "date is ") {
  96. return text, nil
  97. }
  98. matches := collectMatches(text, datelineRegexHyphen, "-")
  99. matches = append(matches, collectMatches(text, datelineRegexSlash, "/")...)
  100. if len(matches) == 0 {
  101. return text, nil
  102. }
  103. sort.Slice(matches, func(i, j int) bool { return matches[i].start < matches[j].start })
  104. var b strings.Builder
  105. b.Grow(len(text))
  106. prev := 0
  107. hits := make([]DatelineHit, 0, len(matches))
  108. changed := false
  109. for _, m := range matches {
  110. full := text[m.start:m.end]
  111. canonical := canonicalize(m.year, m.month, m.day)
  112. if canonical == full {
  113. // Already canonical: no rewrite, no hit.
  114. continue
  115. }
  116. _, _ = b.WriteString(text[prev:m.start])
  117. _, _ = b.WriteString(canonical)
  118. prev = m.end
  119. changed = true
  120. hits = append(hits, DatelineHit{
  121. ApostropheVariant: apostropheVariant(m.apoRune),
  122. DateSeparator: m.sep,
  123. })
  124. }
  125. if !changed {
  126. return text, nil
  127. }
  128. _, _ = b.WriteString(text[prev:])
  129. return b.String(), hits
  130. }
  131. // normalizeSystemReminderScopedText scans only the <system-reminder> blocks
  132. // inside text and normalizes datelines inside them. Text outside the blocks is
  133. // preserved byte-for-byte, so user prose, tool_result content, code blocks,
  134. // or shell commands that happen to contain an apostrophe or a slash date are
  135. // never touched.
  136. func normalizeSystemReminderScopedText(text string) (string, []DatelineHit) {
  137. if !strings.Contains(text, "<system-reminder>") {
  138. return text, nil
  139. }
  140. locs := systemReminderRegex.FindAllStringIndex(text, -1)
  141. if len(locs) == 0 {
  142. return text, nil
  143. }
  144. var b strings.Builder
  145. b.Grow(len(text))
  146. prev := 0
  147. var hits []DatelineHit
  148. changed := false
  149. for _, loc := range locs {
  150. _, _ = b.WriteString(text[prev:loc[0]])
  151. block := text[loc[0]:loc[1]]
  152. normalized, blockHits := NormalizeText(block)
  153. if normalized != block {
  154. changed = true
  155. }
  156. _, _ = b.WriteString(normalized)
  157. hits = append(hits, blockHits...)
  158. prev = loc[1]
  159. }
  160. if !changed {
  161. return text, nil
  162. }
  163. _, _ = b.WriteString(text[prev:])
  164. return b.String(), hits
  165. }
  166. // NormalizeDateline scans an Anthropic /v1/messages request body and rewrites
  167. // every fingerprinted dateline sentence back to its canonical ASCII form.
  168. //
  169. // Scope (mirroring where genuine clients place the sentence):
  170. // - `system` string, or `.text` field of each text-typed block in `system`.
  171. // - Text bodies inside `messages[i].content` — but ONLY the substrings that
  172. // appear inside `<system-reminder>...</system-reminder>` tags. Free user
  173. // prose, tool_use.input, tool_result.content, and other block types are
  174. // never scanned, guaranteeing that legitimate text like a code block, a
  175. // shell command, or a chat message that mentions today's date is never
  176. // accidentally rewritten.
  177. //
  178. // The function is a pure transform: it never modifies the input slice, and if
  179. // no rewrite is needed it returns the original slice (identity), a nil hit
  180. // slice, and changed=false.
  181. func NormalizeDateline(body []byte) ([]byte, []DatelineHit, bool) {
  182. if len(body) == 0 {
  183. return body, nil, false
  184. }
  185. out := body
  186. var hits []DatelineHit
  187. changed := false
  188. sys := gjson.GetBytes(out, "system")
  189. if sys.Exists() {
  190. switch {
  191. case sys.Type == gjson.String:
  192. normalized, sysHits := NormalizeText(sys.String())
  193. if normalized != sys.String() {
  194. if next, err := sjson.SetBytes(out, "system", normalized); err == nil {
  195. out = next
  196. changed = true
  197. hits = append(hits, sysHits...)
  198. }
  199. }
  200. case sys.IsArray():
  201. idx := 0
  202. sys.ForEach(func(_, item gjson.Result) bool {
  203. if item.Get("type").String() == "text" {
  204. t := item.Get("text")
  205. if t.Exists() && t.Type == gjson.String {
  206. normalized, textHits := NormalizeText(t.String())
  207. if normalized != t.String() {
  208. path := fmt.Sprintf("system.%d.text", idx)
  209. if next, err := sjson.SetBytes(out, path, normalized); err == nil {
  210. out = next
  211. changed = true
  212. hits = append(hits, textHits...)
  213. }
  214. }
  215. }
  216. }
  217. idx++
  218. return true
  219. })
  220. }
  221. }
  222. messages := gjson.GetBytes(out, "messages")
  223. if messages.IsArray() {
  224. msgIdx := -1
  225. messages.ForEach(func(_, msg gjson.Result) bool {
  226. msgIdx++
  227. content := msg.Get("content")
  228. if !content.Exists() {
  229. return true
  230. }
  231. switch {
  232. case content.Type == gjson.String:
  233. normalized, contentHits := normalizeSystemReminderScopedText(content.String())
  234. if normalized != content.String() {
  235. path := fmt.Sprintf("messages.%d.content", msgIdx)
  236. if next, err := sjson.SetBytes(out, path, normalized); err == nil {
  237. out = next
  238. changed = true
  239. hits = append(hits, contentHits...)
  240. }
  241. }
  242. case content.IsArray():
  243. contentIdx := -1
  244. content.ForEach(func(_, block gjson.Result) bool {
  245. contentIdx++
  246. if block.Get("type").String() != "text" {
  247. return true
  248. }
  249. t := block.Get("text")
  250. if !t.Exists() || t.Type != gjson.String {
  251. return true
  252. }
  253. normalized, textHits := normalizeSystemReminderScopedText(t.String())
  254. if normalized != t.String() {
  255. path := fmt.Sprintf("messages.%d.content.%d.text", msgIdx, contentIdx)
  256. if next, err := sjson.SetBytes(out, path, normalized); err == nil {
  257. out = next
  258. changed = true
  259. hits = append(hits, textHits...)
  260. }
  261. }
  262. return true
  263. })
  264. }
  265. return true
  266. })
  267. }
  268. if !changed {
  269. return body, nil, false
  270. }
  271. return out, hits, true
  272. }