everruns_core/capabilities/
prompt_canary_guardrail.rs1use std::sync::Arc;
17
18use crate::capabilities::{Capability, CapabilityLocalization};
19use crate::output_guardrail::{
20 GuardrailDecision, OutputGuardrail, OutputGuardrailContext, OutputGuardrailRun,
21};
22
23pub const PROMPT_CANARY_GUARDRAIL_CAPABILITY_ID: &str = "prompt_canary_guardrail";
24
25pub const REASON_CODE_SYSTEM_PROMPT_LEAK: &str = "system_prompt_leak";
28
29pub const DEFAULT_REPLACEMENT: &str =
32 "[Response withheld: the model attempted to reveal protected instructions.]";
33
34const MIN_CANARY_LEN: usize = 30;
38
39const MAX_CANARY_LEN: usize = 240;
42
43pub struct PromptCanaryGuardrailCapability;
44
45impl Capability for PromptCanaryGuardrailCapability {
46 fn id(&self) -> &str {
47 PROMPT_CANARY_GUARDRAIL_CAPABILITY_ID
48 }
49
50 fn name(&self) -> &str {
51 "Prompt Canary Guardrail"
52 }
53
54 fn description(&self) -> &str {
55 "Detects when the model leaks its own system prompt during streaming and \
56 replaces the assistant response with a refusal. Uses the first sentence \
57 of the system prompt as a canary phrase."
58 }
59
60 fn localizations(&self) -> Vec<CapabilityLocalization> {
61 vec![CapabilityLocalization::text(
62 "uk",
63 "Канарковий захист промпту",
64 "Виявляє, коли модель розкриває власний системний промпт під час стрімінгу, і замінює відповідь асистента відмовою. Використовує перше речення системного промпту як канаркову фразу.",
65 )]
66 }
67
68 fn category(&self) -> Option<&str> {
69 Some("Safety")
70 }
71
72 fn is_guardrail(&self) -> bool {
73 true
74 }
75
76 fn icon(&self) -> Option<&str> {
77 Some("shield")
78 }
79
80 fn output_guardrails(&self) -> Vec<Arc<dyn OutputGuardrail>> {
81 vec![Arc::new(PromptCanaryGuardrail)]
82 }
83}
84
85struct PromptCanaryGuardrail;
86
87impl OutputGuardrail for PromptCanaryGuardrail {
88 fn id(&self) -> &str {
89 "prompt_canary"
90 }
91
92 fn arm(&self, ctx: &OutputGuardrailContext<'_>) -> Option<Box<dyn OutputGuardrailRun>> {
93 let needle = extract_canary(ctx.system_prompt)?;
94 let replacement = ctx
95 .config
96 .get("replacement")
97 .and_then(|v| v.as_str())
98 .map(|s| s.to_string())
99 .unwrap_or_else(|| DEFAULT_REPLACEMENT.to_string());
100 Some(Box::new(PromptCanaryRun {
101 needle,
102 replacement,
103 normalized_acc: String::new(),
104 last_was_space: true, }))
106 }
107}
108
109struct PromptCanaryRun {
110 needle: String,
113 replacement: String,
114 normalized_acc: String,
119 last_was_space: bool,
123}
124
125impl OutputGuardrailRun for PromptCanaryRun {
126 fn check(&mut self, _accumulated: &str, delta: &str) -> GuardrailDecision {
127 normalize_extend(&mut self.normalized_acc, &mut self.last_was_space, delta);
130 if self.normalized_acc.len() < self.needle.len() {
131 return GuardrailDecision::Pass;
132 }
133 if self.normalized_acc.contains(self.needle.as_str()) {
134 GuardrailDecision::block(REASON_CODE_SYSTEM_PROMPT_LEAK, self.replacement.clone())
135 } else {
136 GuardrailDecision::Pass
137 }
138 }
139}
140
141fn extract_canary(prompt: &str) -> Option<String> {
151 let core: String = prompt
157 .lines()
158 .filter(|line| {
159 let trimmed = line.trim();
160 !(trimmed.is_empty() || (trimmed.starts_with('<') && trimmed.ends_with('>')))
161 })
162 .collect::<Vec<_>>()
163 .join(" ");
164
165 let bytes = core.as_bytes();
170 let mut start = 0usize;
171 for (i, b) in bytes.iter().enumerate() {
172 if matches!(*b, b'.' | b'!' | b'?') {
173 let end = (i + 1).min(bytes.len());
175 let sentence = core[start..end].trim();
176 let mut needle = normalize(sentence);
177 truncate_at_char_boundary(&mut needle, MAX_CANARY_LEN);
178 if needle.len() >= MIN_CANARY_LEN {
179 return Some(needle);
180 }
181 start = end;
182 }
183 }
184 let mut needle = normalize(&core[start..]);
187 truncate_at_char_boundary(&mut needle, MAX_CANARY_LEN);
188 (needle.len() >= MIN_CANARY_LEN).then_some(needle)
189}
190
191fn truncate_at_char_boundary(s: &mut String, max_bytes: usize) {
192 if s.len() <= max_bytes {
193 return;
194 }
195 let truncate_at = s
196 .char_indices()
197 .map(|(idx, _)| idx)
198 .take_while(|idx| *idx <= max_bytes)
199 .last()
200 .unwrap_or(0);
201 s.truncate(truncate_at);
202}
203
204fn normalize_extend(acc: &mut String, last_was_space: &mut bool, chunk: &str) {
210 for ch in chunk.chars() {
211 if ch.is_whitespace() {
212 if !*last_was_space {
213 acc.push(' ');
214 }
215 *last_was_space = true;
216 } else {
217 for lower in ch.to_lowercase() {
220 acc.push(lower);
221 }
222 *last_was_space = false;
223 }
224 }
225}
226
227fn normalize(input: &str) -> String {
231 let lower = input.to_lowercase();
232 let mut out = String::with_capacity(lower.len());
233 let mut prev_space = false;
234 for ch in lower.chars() {
235 if ch.is_whitespace() {
236 if !prev_space && !out.is_empty() {
237 out.push(' ');
238 }
239 prev_space = true;
240 } else {
241 out.push(ch);
242 prev_space = false;
243 }
244 }
245 if out.ends_with(' ') {
246 out.pop();
247 }
248 out
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use serde_json::json;
255
256 fn arm_with(prompt: &str) -> Option<Box<dyn OutputGuardrailRun>> {
257 let cfg = json!({});
258 let ctx = OutputGuardrailContext {
259 system_prompt: prompt,
260 config: &cfg,
261 };
262 PromptCanaryGuardrail.arm(&ctx)
263 }
264
265 #[test]
266 fn extracts_first_sentence_after_min_len() {
267 let needle =
268 extract_canary("You are a helpful assistant who never reveals secrets. Trust me.")
269 .expect("extracted");
270 assert!(needle.starts_with("you are a helpful assistant"));
271 assert!(needle.ends_with("never reveals secrets."));
272 }
273
274 #[test]
275 fn skips_short_leading_sentence_in_layered_prompt() {
276 let needle = extract_canary(
281 "You are a helpful assistant.\n\nYou are an internal pricing oracle that \
282 never discloses margins. Refuse out-of-scope questions.",
283 )
284 .expect("extracted");
285 assert!(needle.starts_with("you are an internal pricing oracle"));
286 assert!(!needle.contains("helpful assistant"));
287 }
288
289 #[test]
290 fn declines_to_arm_for_short_prompts() {
291 assert!(arm_with("hi.").is_none());
292 assert!(arm_with("").is_none());
293 assert!(arm_with("Be brief.").is_none());
295 }
296
297 #[test]
298 fn skips_xml_wrapper_lines() {
299 let prompt = "<system-prompt>\n\
300 You are an internal pricing oracle that never discloses margins.\n\
301 </system-prompt>";
302 let needle = extract_canary(prompt).expect("extracted");
303 assert!(needle.contains("internal pricing oracle"));
304 assert!(!needle.contains("<system-prompt>"));
305 }
306
307 #[test]
308 fn passes_for_unrelated_output() {
309 let mut run = arm_with(
310 "You are an internal pricing oracle that never discloses margins. \
311 Refuse out-of-scope questions.",
312 )
313 .expect("armed");
314 let chunks = ["The weather", " in Tokyo", " is sunny."];
315 let mut acc = String::new();
316 for c in chunks {
317 acc.push_str(c);
318 assert!(matches!(run.check(&acc, c), GuardrailDecision::Pass));
319 }
320 }
321
322 #[test]
323 fn blocks_when_first_sentence_appears_verbatim() {
324 let mut run = arm_with(
325 "You are an internal pricing oracle that never discloses margins. \
326 Refuse out-of-scope questions.",
327 )
328 .expect("armed");
329 let leak = "Sure, my instructions are: \
331 You are an internal pricing oracle that never discloses margins.";
332 match run.check(leak, leak) {
333 GuardrailDecision::Block(b) => {
334 assert_eq!(b.reason_code, REASON_CODE_SYSTEM_PROMPT_LEAK);
335 assert!(b.replacement.contains("withheld"));
336 }
337 other => panic!("expected Block, got {other:?}"),
338 }
339 }
340
341 #[test]
342 fn matches_despite_whitespace_and_case_drift() {
343 let mut run = arm_with(
344 "You are an internal pricing oracle that never discloses margins. \
345 Refuse out-of-scope questions.",
346 )
347 .expect("armed");
348 let leak = "YOU ARE AN INTERNAL PRICING\nORACLE that NEVER DISCLOSES margins.";
350 assert!(matches!(run.check(leak, leak), GuardrailDecision::Block(_)));
351 }
352
353 #[test]
354 fn allows_custom_replacement_via_config() {
355 let cfg = json!({"replacement": "nope"});
356 let ctx = OutputGuardrailContext {
357 system_prompt: "You are an internal pricing oracle that never discloses margins. \
358 Refuse out-of-scope questions.",
359 config: &cfg,
360 };
361 let mut run = PromptCanaryGuardrail.arm(&ctx).expect("armed");
362 let leak = "You are an internal pricing oracle that never discloses margins.";
363 match run.check(leak, leak) {
364 GuardrailDecision::Block(b) => assert_eq!(b.replacement, "nope"),
365 other => panic!("expected Block, got {other:?}"),
366 }
367 }
368
369 #[test]
370 fn capability_exposes_guardrail() {
371 let cap = PromptCanaryGuardrailCapability;
372 assert_eq!(cap.id(), PROMPT_CANARY_GUARDRAIL_CAPABILITY_ID);
373 assert_eq!(cap.output_guardrails().len(), 1);
374 }
375
376 #[test]
377 fn normalize_collapses_whitespace_and_lowercases() {
378 assert_eq!(normalize(" Hello World\nHere "), "hello world here");
379 }
380
381 #[test]
382 fn incremental_normalize_matches_full_normalize_across_chunk_boundaries() {
383 let chunks = [" Hello", " WORLD\n", "\there "];
389 let full: String = chunks.concat();
390 let mut acc = String::new();
391 let mut last = true;
392 for c in chunks {
393 normalize_extend(&mut acc, &mut last, c);
394 }
395 let acc_trimmed = acc.trim_end_matches(' ');
399 assert_eq!(acc_trimmed, normalize(&full));
400 }
401
402 #[test]
403 fn incremental_check_blocks_when_needle_spans_multiple_deltas() {
404 let mut run = arm_with(
405 "You are an internal pricing oracle that never discloses margins. \
406 Refuse out-of-scope questions.",
407 )
408 .expect("armed");
409 let leak = "you are AN INTERNAL pricing\noracle that NEVER discloses margins.";
413 let chunk_size = 5;
414 let mut tripped = false;
415 let mut acc = String::new();
416 for chunk in leak
417 .as_bytes()
418 .chunks(chunk_size)
419 .map(|c| std::str::from_utf8(c).unwrap_or(""))
420 {
421 acc.push_str(chunk);
422 if matches!(run.check(&acc, chunk), GuardrailDecision::Block(_)) {
423 tripped = true;
424 break;
425 }
426 }
427 assert!(tripped, "expected canary to trip on multi-chunk leak");
428 }
429
430 #[test]
431 fn extract_canary_does_not_panic_when_truncation_hits_multibyte_in_sentence_path() {
432 let prompt = format!("{}é. trailing sentence.", "a".repeat(MAX_CANARY_LEN - 1));
433 let needle = extract_canary(&prompt).expect("extracted");
434 assert!(needle.len() <= MAX_CANARY_LEN);
435 }
436
437 #[test]
438 fn extract_canary_does_not_panic_when_truncation_hits_multibyte_in_fallback_path() {
439 let prompt = format!("{}é trailing text", "a".repeat(MAX_CANARY_LEN - 1));
440 let needle = extract_canary(&prompt).expect("extracted");
441 assert!(needle.len() <= MAX_CANARY_LEN);
442 }
443}