prompt_snapshot_test.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. package securityaudit
  2. import (
  3. "crypto/sha256"
  4. "encoding/hex"
  5. "encoding/json"
  6. "errors"
  7. "strings"
  8. "testing"
  9. "unicode/utf8"
  10. "github.com/stretchr/testify/require"
  11. )
  12. func TestExtractPromptSnapshotProtocols(t *testing.T) {
  13. tests := []struct {
  14. protocol, body, first string
  15. count int
  16. }{
  17. {"openai_chat_completions", `{"messages":[{"role":"user","content":"old"},{"role":"assistant","content":"assistant turn"},{"role":"user","content":[{"type":"text","text":"最新😀"}]}]}`, "最新😀", 3},
  18. {"openai_responses", `{"input":[{"role":"user","content":[{"type":"input_text","text":"response text"}]}]}`, "response text", 1},
  19. {"anthropic_messages", `{"messages":[{"role":"user","content":[{"type":"text","text":"claude"}]}]}`, "claude", 1},
  20. {"gemini", `{"contents":[{"role":"user","parts":[{"text":"gemini"},{"inline_data":{"data":"BASE64"}}]}]}`, "gemini", 1},
  21. {"openai_images", `{"prompt":"draw a cat","image":"BASE64SECRET"}`, "draw a cat", 1},
  22. {"responses_websocket", `{"type":"response.create","response":{"input":"turn two"}}`, "turn two", 1},
  23. }
  24. for _, tt := range tests {
  25. t.Run(tt.protocol, func(t *testing.T) {
  26. snapshot, err := ExtractPromptSnapshot(Request{Protocol: tt.protocol, Body: []byte(tt.body), Stage: "http"})
  27. require.NoError(t, err)
  28. require.True(t, strings.HasPrefix(snapshot.ScanText, tt.first))
  29. require.Equal(t, tt.count, snapshot.MessageCount)
  30. require.Equal(t, utf8.RuneCountInString(metadataTextForTest(snapshot.ScanText)), snapshot.PromptLength)
  31. require.NotEmpty(t, snapshot.PromptHash)
  32. require.NotContains(t, snapshot.ScanText, "BASE64SECRET")
  33. })
  34. }
  35. }
  36. func TestSnapshotRedactsCanariesAndPreservesHashOfScanText(t *testing.T) {
  37. body := `{"messages":[{"role":"user","content":"PROMPT_CANARY_ABC123 email@example.com +86 138 0013 8000 Bearer AUTH_CANARY_XYZ sk-secretvalue123 password=supersecret123"}]}`
  38. snapshot, err := ExtractPromptSnapshot(Request{Protocol: "openai_chat_completions", Body: []byte(body)})
  39. require.NoError(t, err)
  40. require.NotContains(t, snapshot.RedactedPreview, "ABC123")
  41. require.NotContains(t, snapshot.RedactedPreview, "email@example.com")
  42. require.NotContains(t, snapshot.RedactedPreview, "AUTH_CANARY_XYZ")
  43. require.NotContains(t, snapshot.RedactedPreview, "secretvalue123")
  44. require.NotContains(t, snapshot.RedactedPreview, "supersecret123")
  45. require.NotContains(t, snapshot.RedactedPreview, "138 0013 8000")
  46. require.Contains(t, snapshot.ScanText, "PROMPT_CANARY_ABC123")
  47. require.NotEqual(t, snapshot.ScanText, snapshot.RedactedPreview)
  48. digest := sha256.Sum256([]byte(metadataTextForTest(snapshot.ScanText)))
  49. require.Equal(t, hex.EncodeToString(digest[:]), snapshot.PromptHash)
  50. require.Empty(t, snapshot.Redacted().ScanText)
  51. }
  52. func TestSnapshotFullPromptKeepsUnredactedText(t *testing.T) {
  53. body := `{"messages":[{"role":"user","content":"PROMPT_CANARY_ABC123 email@example.com sk-secretvalue123"}]}`
  54. snapshot, err := ExtractPromptSnapshot(Request{Protocol: "openai_chat_completions", Body: []byte(body)})
  55. require.NoError(t, err)
  56. // The full prompt is stored verbatim for admin review, unlike the preview.
  57. require.Contains(t, snapshot.FullPrompt, "PROMPT_CANARY_ABC123 email@example.com sk-secretvalue123")
  58. require.NotContains(t, snapshot.RedactedPreview, "PROMPT_CANARY_ABC123")
  59. require.Equal(t, snapshot.FullPrompt, snapshot.Redacted().FullPrompt)
  60. }
  61. func TestBuildFullPromptStripsNULAndTruncates(t *testing.T) {
  62. require.Equal(t, "abcd", BuildFullPrompt("ab\x00cd", 0))
  63. long := strings.Repeat("长", DefaultFullPromptMaxRunes+10)
  64. trimmed := BuildFullPrompt(long, DefaultFullPromptMaxRunes)
  65. require.Equal(t, DefaultFullPromptMaxRunes+1, utf8.RuneCountInString(trimmed))
  66. require.True(t, strings.HasSuffix(trimmed, "…"))
  67. }
  68. func TestFullPromptFromScanTextRestoresMultiSegmentLayout(t *testing.T) {
  69. scanText, metadataText := buildPrioritizedScanText([]string{"latest user", "system policy", "earlier user"})
  70. require.Contains(t, scanText, promptAuditPrioritySeparator)
  71. require.Equal(t, metadataText, FullPromptFromScanText(scanText))
  72. singleScan, singleMeta := buildPrioritizedScanText([]string{"only"})
  73. require.NotContains(t, singleScan, promptAuditPrioritySeparator)
  74. require.Equal(t, singleMeta, FullPromptFromScanText(singleScan))
  75. }
  76. func TestSplitRunesDoesNotSplitUTF8(t *testing.T) {
  77. chunks := SplitRunes("中文😀éabc", 2)
  78. require.Equal(t, []string{"中文", "😀e", "́a", "bc"}, chunks)
  79. for _, chunk := range chunks {
  80. require.True(t, utf8.ValidString(chunk))
  81. }
  82. require.Equal(t, "中文😀éabc", strings.Join(chunks, ""))
  83. }
  84. func TestSplitRunesKeepsPrioritySegmentIndependent(t *testing.T) {
  85. latest := "请帮我编写一篇黄色小说 名字你来取"
  86. history := strings.Repeat("AGENTS.md 项目约束。", 40)
  87. chunks := SplitRunes(latest+promptAuditPrioritySeparator+history, 128)
  88. require.Greater(t, len(chunks), 2)
  89. require.Equal(t, latest, chunks[0])
  90. require.Equal(t, history, strings.Join(chunks[1:], ""))
  91. for _, chunk := range chunks {
  92. require.NotContains(t, chunk, promptAuditPrioritySeparator)
  93. }
  94. }
  95. func TestPromptSnapshotLatestUserTextBlockIsOnePrioritizedSegment(t *testing.T) {
  96. body := []byte(`{
  97. "messages":[
  98. {"role":"user","content":"历史输入"},
  99. {"role":"assistant","content":"assistant client injection"},
  100. {"role":"tool","content":"tool client injection"},
  101. {"role":"user","content":[
  102. {"type":"text","text":"最新第一块😀"},
  103. {"type":"image_url","image_url":{"url":"data:image/png;base64,IMAGE_CANARY_BASE64"}},
  104. {"type":"text","text":"最新第二块é"}
  105. ]}
  106. ]
  107. }`)
  108. snapshot, err := ExtractPromptSnapshot(Request{Protocol: "openai_chat_completions", Body: body})
  109. require.NoError(t, err)
  110. require.Equal(t, 5, snapshot.MessageCount)
  111. require.True(t, strings.HasPrefix(snapshot.ScanText, "最新第二块é"+promptAuditPrioritySeparator))
  112. require.Contains(t, snapshot.ScanText, "最新第一块😀")
  113. require.Contains(t, snapshot.ScanText, "历史输入")
  114. require.Contains(t, snapshot.ScanText, "assistant client injection")
  115. require.Contains(t, snapshot.ScanText, "tool client injection")
  116. require.NotContains(t, snapshot.ScanText, "IMAGE_CANARY_BASE64")
  117. require.Equal(t, utf8.RuneCountInString(metadataTextForTest(snapshot.ScanText)), snapshot.PromptLength)
  118. }
  119. func TestPromptSnapshotSeparatesAnthropicUserPromptFromHarnessBlocks(t *testing.T) {
  120. latest := "请帮我编写一篇黄色小说 名字你来取"
  121. agents := "# AGENTS.md instructions\n<INSTRUCTIONS>" + strings.Repeat("安全约束。", 80) + "</INSTRUCTIONS>"
  122. environment := "<environment_context><cwd>/workspace</cwd></environment_context>"
  123. body := []byte(`{"system":"system policy","messages":[{"role":"user","content":[` +
  124. `{"type":"text","text":` + string(mustJSON(t, agents)) + `},` +
  125. `{"type":"text","text":` + string(mustJSON(t, environment)) + `},` +
  126. `{"type":"text","text":` + string(mustJSON(t, latest)) + `}` +
  127. `]}]}`)
  128. snapshot, err := ExtractPromptSnapshot(Request{Protocol: "anthropic_messages", Body: body})
  129. require.NoError(t, err)
  130. require.Equal(t, 4, snapshot.MessageCount)
  131. require.True(t, strings.HasPrefix(snapshot.ScanText, latest+promptAuditPrioritySeparator))
  132. require.True(t, strings.HasPrefix(snapshot.RedactedPreview, "请帮我编写一篇黄色小说"))
  133. chunks := SplitRunes(snapshot.ScanText, 128)
  134. require.Equal(t, latest, chunks[0])
  135. require.Contains(t, strings.Join(chunks[1:], ""), "# AGENTS.md instructions")
  136. require.Contains(t, strings.Join(chunks[1:], ""), "<environment_context>")
  137. require.NotContains(t, strings.Join(chunks, ""), promptAuditPrioritySeparator)
  138. }
  139. func TestPromptSnapshotResponsesShapes(t *testing.T) {
  140. tests := []struct {
  141. name string
  142. body string
  143. want string
  144. }{
  145. {name: "string", body: `{"input":"plain response input"}`, want: "plain response input"},
  146. {name: "message array", body: `{"input":[{"role":"assistant","content":"assistant turn"},{"role":"user","content":[{"type":"input_text","text":"message block"}]}]}`, want: "message block\n\nassistant turn"},
  147. {name: "direct input text", body: `{"input":[{"type":"input_text","text":"direct block"}]}`, want: "direct block"},
  148. {name: "single object", body: `{"input":{"role":"user","content":[{"type":"input_text","text":"single object"}]}}`, want: "single object"},
  149. }
  150. for _, tt := range tests {
  151. t.Run(tt.name, func(t *testing.T) {
  152. snapshot, err := ExtractPromptSnapshot(Request{Protocol: "openai_responses", Body: []byte(tt.body)})
  153. require.NoError(t, err)
  154. require.Equal(t, tt.want, metadataTextForTest(snapshot.ScanText))
  155. })
  156. }
  157. }
  158. func TestPromptSnapshotGeminiBatchShapesAndMediaExclusion(t *testing.T) {
  159. body := []byte(`{
  160. "contents":{"role":"user","parts":[{"text":"root content"},{"inlineData":{"data":"ROOT_BASE64"}}]},
  161. "instances":[{"prompt":"instance prompt"}],
  162. "requests":[
  163. {"contents":[{"role":"model","parts":[{"text":"ignore model"}]},{"role":"user","parts":[{"text":"nested user"}]}]},
  164. {"instances":[{"prompt":"nested instance"}]}
  165. ]
  166. }`)
  167. snapshot, err := ExtractPromptSnapshot(Request{Protocol: "gemini", Body: body})
  168. require.NoError(t, err)
  169. require.True(t, strings.HasPrefix(snapshot.ScanText, "nested instance"))
  170. for _, expected := range []string{"root content", "instance prompt", "nested user", "nested instance"} {
  171. require.Contains(t, snapshot.ScanText, expected)
  172. }
  173. require.NotContains(t, snapshot.ScanText, "ROOT_BASE64")
  174. require.Contains(t, snapshot.ScanText, "ignore model")
  175. }
  176. func TestPromptSnapshotMediaOnlyExtractsDeterministicTextPrompts(t *testing.T) {
  177. body := []byte(`{
  178. "prompt":"draw a lighthouse",
  179. "image":"data:image/png;base64,IMAGE_CANARY",
  180. "input":{"negative_prompt":"no fog","image_prompt":"https://example.test/input.png","prompt":"draw a lighthouse"},
  181. "request":{"lyrics":"ocean song","input":"` + strings.Repeat("A", 300) + `"},
  182. "images":[{"description":"nested textual direction","image_url":"https://example.test/image.png"}]
  183. }`)
  184. snapshot, err := ExtractPromptSnapshot(Request{Protocol: "grok_media", Body: body})
  185. require.NoError(t, err)
  186. require.Equal(t, 4, snapshot.MessageCount)
  187. for _, expected := range []string{"draw a lighthouse", "no fog", "ocean song", "nested textual direction"} {
  188. require.Contains(t, snapshot.ScanText, expected)
  189. }
  190. require.Equal(t, 1, strings.Count(snapshot.ScanText, "draw a lighthouse"))
  191. require.NotContains(t, snapshot.ScanText, "IMAGE_CANARY")
  192. require.NotContains(t, snapshot.ScanText, "example.test")
  193. require.NotContains(t, snapshot.ScanText, strings.Repeat("A", 100))
  194. }
  195. func TestResponsesWebSocketOnlyAuditsResponseCreateAndPreservesStage(t *testing.T) {
  196. for _, stage := range []string{"first_turn", "subsequent_turn"} {
  197. snapshot, err := ExtractPromptSnapshot(Request{
  198. Protocol: "openai_responses", Stage: stage,
  199. Body: []byte(`{"type":"response.create","response":{"model":"gpt-test","input":[{"role":"user","content":[{"type":"input_text","text":"ws turn"}]}]}}`),
  200. })
  201. require.NoError(t, err)
  202. require.Equal(t, "ws turn", snapshot.ScanText)
  203. require.Equal(t, stage, snapshot.Stage)
  204. }
  205. _, err := ExtractPromptSnapshot(Request{
  206. Protocol: "openai_responses", Stage: "subsequent_turn",
  207. Body: []byte(`{"type":"conversation.item.create","response":{"input":"must not scan this frame"}}`),
  208. })
  209. require.True(t, errors.Is(err, ErrNoPromptText))
  210. }
  211. func TestPromptSnapshotEmptyAndLongUnicodeInput(t *testing.T) {
  212. _, err := ExtractPromptSnapshot(Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"function","content":"not audited role"},{"role":"user","content":" "}]}`)})
  213. require.True(t, errors.Is(err, ErrNoPromptText))
  214. latest := strings.Repeat("最新😀é", 80)
  215. history := strings.Repeat("历史中文", 80)
  216. body := []byte(`{"messages":[{"role":"user","content":` + string(mustJSON(t, history)) + `},{"role":"user","content":` + string(mustJSON(t, latest)) + `}]}`)
  217. snapshot, err := ExtractPromptSnapshot(Request{Protocol: "openai_chat_completions", Body: body})
  218. require.NoError(t, err)
  219. require.True(t, strings.HasPrefix(snapshot.ScanText, latest))
  220. chunks := SplitRunes(snapshot.ScanText, 127)
  221. require.Equal(t, strings.Replace(snapshot.ScanText, promptAuditPrioritySeparator, "", 1), strings.Join(chunks, ""))
  222. require.Equal(t, latest, chunks[0]+strings.Join(chunks[1:len(SplitRunes(latest, 127))], ""))
  223. for _, chunk := range chunks {
  224. require.LessOrEqual(t, len([]rune(chunk)), 127)
  225. require.True(t, utf8.ValidString(chunk))
  226. }
  227. }
  228. func TestPromptSnapshotIncludesClientControlledInstructions(t *testing.T) {
  229. tests := []struct {
  230. name, protocol, body string
  231. want []string
  232. }{
  233. {
  234. name: "openai system developer assistant tool",
  235. protocol: "openai_chat_completions",
  236. body: `{"messages":[{"role":"system","content":"system jailbreak"},{"role":"developer","content":"developer policy"},{"role":"assistant","content":"assistant jailbreak"},{"role":"tool","content":"tool payload"},{"role":"user","content":"hello"}]}`,
  237. want: []string{"system jailbreak", "developer policy", "assistant jailbreak", "tool payload", "hello"},
  238. },
  239. {
  240. name: "openai system only",
  241. protocol: "openai_chat_completions",
  242. body: `{"messages":[{"role":"system","content":"only system instruction"}]}`,
  243. want: []string{"only system instruction"},
  244. },
  245. {
  246. name: "responses instructions",
  247. protocol: "openai_responses",
  248. body: `{"instructions":"response instructions","input":[{"role":"user","content":[{"type":"input_text","text":"user turn"}]}]}`,
  249. want: []string{"response instructions", "user turn"},
  250. },
  251. {
  252. name: "anthropic system",
  253. protocol: "anthropic_messages",
  254. body: `{"system":"claude system","messages":[{"role":"user","content":[{"type":"text","text":"claude user"}]}]}`,
  255. want: []string{"claude system", "claude user"},
  256. },
  257. {
  258. name: "gemini systemInstruction",
  259. protocol: "gemini",
  260. body: `{"systemInstruction":{"parts":[{"text":"gemini system"}]},"contents":[{"role":"user","parts":[{"text":"gemini user"}]}]}`,
  261. want: []string{"gemini system", "gemini user"},
  262. },
  263. }
  264. for _, tt := range tests {
  265. t.Run(tt.name, func(t *testing.T) {
  266. snapshot, err := ExtractPromptSnapshot(Request{Protocol: tt.protocol, Body: []byte(tt.body)})
  267. require.NoError(t, err)
  268. for _, expected := range tt.want {
  269. require.Contains(t, snapshot.ScanText, expected)
  270. }
  271. })
  272. }
  273. }
  274. func TestBlockingPromptSnapshotLimitsInputToLatestUserAndPreviousOutput(t *testing.T) {
  275. tests := []struct {
  276. name, protocol, body, want string
  277. omitted []string
  278. }{
  279. {
  280. name: "chat keeps multipart latest user and prior assistant",
  281. protocol: "openai_chat_completions",
  282. body: `{"messages":[
  283. {"role":"system","content":"system instruction"},
  284. {"role":"user","content":"older user input"},
  285. {"role":"assistant","content":"older assistant output"},
  286. {"role":"tool","content":"tool payload"},
  287. {"role":"assistant","content":"previous assistant output"},
  288. {"role":"user","content":[{"type":"text","text":"latest user first part"},{"type":"text","text":"latest user second part"}]}
  289. ]}`,
  290. want: "latest user first part\n\nlatest user second part" + promptAuditPrioritySeparator + "previous assistant output",
  291. omitted: []string{"system instruction", "older user input", "older assistant output", "tool payload"},
  292. },
  293. {
  294. name: "gemini keeps prior model output",
  295. protocol: "gemini",
  296. body: `{"systemInstruction":{"parts":[{"text":"system instruction"}]},"contents":[
  297. {"role":"user","parts":[{"text":"older user input"}]},
  298. {"role":"model","parts":[{"text":"previous model output"}]},
  299. {"role":"user","parts":[{"text":"latest user input"}]}
  300. ]}`,
  301. want: "latest user input" + promptAuditPrioritySeparator + "previous model output",
  302. omitted: []string{"system instruction", "older user input"},
  303. },
  304. }
  305. for _, tt := range tests {
  306. t.Run(tt.name, func(t *testing.T) {
  307. snapshot, err := ExtractBlockingPromptSnapshot(Request{Protocol: tt.protocol, Body: []byte(tt.body)}, true)
  308. require.NoError(t, err)
  309. require.Equal(t, tt.want, snapshot.ScanText)
  310. for _, omitted := range tt.omitted {
  311. require.NotContains(t, snapshot.ScanText, omitted)
  312. }
  313. })
  314. }
  315. }
  316. func TestContentTextsIncludesSupportedTextTypes(t *testing.T) {
  317. value := []any{
  318. map[string]any{"type": "text", "text": "plain text"},
  319. map[string]any{"type": "input_text", "text": "input text"},
  320. map[string]any{"type": "output_text", "text": "output text"},
  321. map[string]any{"type": "image_url", "text": "ignored text"},
  322. }
  323. require.Equal(t, []string{"plain text", "input text", "output text"}, contentTexts(value))
  324. }
  325. func TestResponsesOutputTextIncludedInFullAndLatestTurnSnapshots(t *testing.T) {
  326. body := []byte(`{"input":[
  327. {"type":"message","role":"user","content":[{"type":"input_text","text":"earlier user input"}]},
  328. {"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","annotations":[],"text":"captured previous assistant output"}]},
  329. {"type":"message","role":"user","content":[{"type":"input_text","text":"captured latest user input"}]}
  330. ]}`)
  331. req := Request{Protocol: "openai_responses", Body: body}
  332. full, err := ExtractPromptSnapshot(req)
  333. require.NoError(t, err)
  334. require.Contains(t, full.ScanText, "captured previous assistant output")
  335. require.Contains(t, full.FullPrompt, "captured previous assistant output")
  336. require.Equal(t, 3, full.MessageCount)
  337. latestTurn, err := ExtractBlockingPromptSnapshot(req, true)
  338. require.NoError(t, err)
  339. require.Equal(t, "captured latest user input"+promptAuditPrioritySeparator+"captured previous assistant output", latestTurn.ScanText)
  340. require.Equal(t, 2, latestTurn.MessageCount)
  341. require.NotContains(t, latestTurn.ScanText, "earlier user input")
  342. }
  343. func TestBlockingPromptSnapshotPreservesFullScopeByDefaultAndWithoutUserInput(t *testing.T) {
  344. req := Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"user","content":"older user input"},{"role":"assistant","content":"previous output"},{"role":"user","content":"latest user input"}]}`)}
  345. full, err := ExtractPromptSnapshot(req)
  346. require.NoError(t, err)
  347. defaultBlocking, err := ExtractBlockingPromptSnapshot(req, false)
  348. require.NoError(t, err)
  349. require.Equal(t, full, defaultBlocking)
  350. noUser := Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"assistant","content":"assistant output"}]}`)}
  351. fullWithoutUser, err := ExtractPromptSnapshot(noUser)
  352. require.NoError(t, err)
  353. narrowWithoutUser, err := ExtractBlockingPromptSnapshot(noUser, true)
  354. require.NoError(t, err)
  355. require.Equal(t, fullWithoutUser, narrowWithoutUser)
  356. }
  357. func TestBuildPromptPreviewWithholdsMajorityOfOrdinaryText(t *testing.T) {
  358. prompt := strings.Repeat("机密业务提示词内容", 40)
  359. preview := BuildPromptPreview(prompt, DefaultPromptPreviewMaxRunes)
  360. require.NotEmpty(t, preview)
  361. require.Contains(t, preview, "***")
  362. require.LessOrEqual(t, utf8.RuneCountInString(strings.TrimSuffix(strings.TrimSuffix(preview, "…"), "***")), 24)
  363. require.Less(t, utf8.RuneCountInString(preview), utf8.RuneCountInString(prompt)/2)
  364. require.NotContains(t, preview, prompt)
  365. }
  366. func TestBuildPromptPreviewFullyMasksShortUnlabelledSecrets(t *testing.T) {
  367. require.Equal(t, "***", BuildPromptPreview("short-secret-value!!", DefaultPromptPreviewMaxRunes))
  368. require.Equal(t, "***", BuildPromptPreview(strings.Repeat("a", 31), DefaultPromptPreviewMaxRunes))
  369. partial := BuildPromptPreview(strings.Repeat("b", 32), DefaultPromptPreviewMaxRunes)
  370. require.True(t, strings.HasPrefix(partial, "b"))
  371. require.Contains(t, partial, "***")
  372. }
  373. func mustJSON(t *testing.T, value string) []byte {
  374. t.Helper()
  375. raw, err := json.Marshal(value)
  376. require.NoError(t, err)
  377. return raw
  378. }
  379. func metadataTextForTest(scanText string) string {
  380. return strings.Replace(scanText, promptAuditPrioritySeparator, "\n\n", 1)
  381. }