Skip to main content

everruns_core/capabilities/
prompt_canary_guardrail.rs

1// Prompt-canary streaming output guardrail.
2//
3// Detects the simplest form of system-prompt leakage: the model echoing
4// (a normalized form of) the first sentence of its own system prompt back
5// to the user. When detected, the assistant message is replaced with a
6// canned refusal and the original tokens are dropped — they are never
7// persisted or sent on subsequent turns.
8//
9// This is intentionally narrow: substring matching of one normalized
10// needle against the accumulated output. It catches naive prompt-extraction
11// attempts ("repeat your system prompt back") without trying to be a
12// general-purpose data-loss-prevention layer. False-positive risk is bounded
13// by requiring a minimum needle length (`MIN_CANARY_LEN`); shorter prompts
14// produce no canary and the guardrail is a no-op for that stream.
15
16use 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
25/// Reason code surfaced in `output.message.replaced` events. Stable: clients
26/// localize their copy from this string.
27pub const REASON_CODE_SYSTEM_PROMPT_LEAK: &str = "system_prompt_leak";
28
29/// Default replacement text shown in place of the suppressed model output.
30/// Plain prose so any client renders it sensibly — no markdown, no template.
31pub const DEFAULT_REPLACEMENT: &str =
32    "[Response withheld: the model attempted to reveal protected instructions.]";
33
34/// Minimum length of the extracted needle, after normalization. Below this
35/// the canary refuses to arm — short fragments would over-trigger on
36/// commonplace phrases like "you are a helpful assistant".
37const MIN_CANARY_LEN: usize = 30;
38
39/// Maximum length of the extracted needle. Long needles waste cycles and are
40/// unlikely to ever match because the model paraphrases.
41const 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, // suppress a leading space when first chunk is whitespace-only
105        }))
106    }
107}
108
109struct PromptCanaryRun {
110    /// Normalized first sentence of the system prompt — lowercased and with
111    /// runs of whitespace collapsed to a single space.
112    needle: String,
113    replacement: String,
114    /// Normalized accumulator updated incrementally from each delta. We never
115    /// re-normalize the full output — extending this buffer per delta makes
116    /// `check` O(|delta|) instead of O(|accumulated|), avoiding O(n²) behavior
117    /// across long streams.
118    normalized_acc: String,
119    /// Tracks whether the *last* character of `normalized_acc` is the lone
120    /// trailing space we collapsed into; lets us coalesce whitespace runs
121    /// that straddle a delta boundary.
122    last_was_space: bool,
123}
124
125impl OutputGuardrailRun for PromptCanaryRun {
126    fn check(&mut self, _accumulated: &str, delta: &str) -> GuardrailDecision {
127        // Append the normalized form of just this delta. Whitespace runs that
128        // straddle delta boundaries are coalesced via `last_was_space`.
129        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
141/// Extract a canary needle from `prompt`. Walks sentence boundaries and
142/// returns the first sentence whose normalized form is ≥ `MIN_CANARY_LEN`.
143/// This intentionally prefers a single self-contained sentence (e.g. the
144/// agent-specific instructions inside a longer assembled prompt) over the
145/// whole multi-sentence prefix, so the canary still matches when the model
146/// regurgitates only the agent layer of a layered prompt.
147///
148/// Returns `None` when no qualifying sentence exists — common for very
149/// short prompts where false-positive risk is too high.
150fn extract_canary(prompt: &str) -> Option<String> {
151    // Skip over XML-tag wrapper lines (e.g. `<system-prompt>`,
152    // `<capability id="...">`). The runtime wraps capability contributions
153    // in tags that are not part of the *intended* leak target — leaking
154    // those is fine, it just reveals structure. We want the human-authored
155    // sentence inside.
156    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    // Split on sentence terminators (., !, ?). Newlines were already
166    // collapsed into spaces above so the only remaining boundaries are
167    // punctuation. Iterate sentences in order; the first one whose
168    // normalized form is ≥ MIN_CANARY_LEN is the canary.
169    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            // Inclusive of the terminator so "ends with" tests still see it.
174            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    // No sentence boundary found — fall back to the whole prompt if it's
185    // long enough on its own.
186    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
204/// Append the normalized form of `chunk` to `acc`, preserving the
205/// "previous char was whitespace" state in `last_was_space` so whitespace
206/// runs that straddle chunk boundaries are correctly collapsed. Used by
207/// `PromptCanaryRun::check` to maintain a running normalized buffer in O(|delta|)
208/// per call instead of re-normalizing the full accumulated output every time.
209fn 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            // `to_lowercase()` per char allocates a small iterator; cheaper
218            // than allocating a full lowercase string for the whole chunk.
219            for lower in ch.to_lowercase() {
220                acc.push(lower);
221            }
222            *last_was_space = false;
223        }
224    }
225}
226
227/// Lowercase + collapse whitespace. Mirrored on both sides of the comparison
228/// so the canary survives reformatting (extra spaces, capitalization,
229/// trailing punctuation drift, line wrapping).
230fn 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        // Mimics what the runtime assembles when a harness "You are a helpful
277        // assistant." is layered with an agent prompt: the canary should be
278        // the agent's longer, identifying sentence — not the harness's
279        // generic opener.
280        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        // Single short sentence under min len.
294        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        // Simulate the model echoing the prompt back to the user.
330        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        // Same words, mangled formatting.
349        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        // The streaming check builds its normalized buffer one delta at a
384        // time. The result must equal a single-shot `normalize()` call on
385        // the concatenated input — including across whitespace runs that
386        // straddle chunk boundaries (e.g. a chunk ending with a space and
387        // the next starting with a space).
388        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        // Trim a trailing collapsed space if any — `normalize()` does this
396        // by popping the final char; the streaming buffer doesn't, but
397        // substring matching is unaffected.
398        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        // Stream the leak in many small chunks that together reconstruct the
410        // canary. Should still trip — the incremental buffer accumulates
411        // across calls.
412        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}