Skip to main content

lean_ctx/core/
sanitize.rs

1const MAX_METADATA_LEN: usize = 200;
2
3pub fn neutralize_metadata(input: &str) -> String {
4    // Foreign content in the X5 wrapper keeps its markers: only the body is
5    // neutralised and cut, so the end marker is never lost.
6    if crate::core::untrusted::is_wrapped(input) {
7        let lines: Vec<&str> = input.lines().collect();
8        if let [header, preamble, body @ .., close] = lines.as_slice()
9            && !body.is_empty()
10        {
11            return format!(
12                "{header}\n{preamble}\n{}\n{close}",
13                neutralize_plain(&body.join("\n"))
14            );
15        }
16    }
17    neutralize_plain(input)
18}
19
20fn neutralize_plain(input: &str) -> String {
21    let mut out = String::with_capacity(input.len().min(MAX_METADATA_LEN));
22    let mut count = 0usize;
23    for ch in input.chars() {
24        if count >= MAX_METADATA_LEN {
25            out.push('…');
26            break;
27        }
28        if (ch as u32) < 0x20 && ch != '\n' && ch != '\t' && ch != '\r' {
29            continue;
30        }
31        match ch {
32            '<' => out.push('‹'),
33            '>' => out.push('›'),
34            '`' => out.push('\''),
35            _ => out.push(ch),
36        }
37        count += 1;
38    }
39    out
40}
41
42pub fn neutralize_shell_content(input: &str) -> String {
43    let mut out = String::with_capacity(input.len());
44    let mut i = 0;
45    let chars: Vec<char> = input.chars().collect();
46    while i < chars.len() {
47        let ch = chars[i];
48        if (ch as u32) < 0x20 && ch != '\n' && ch != '\t' && ch != '\r' {
49            i += 1;
50            continue;
51        }
52        out.push(ch);
53        i += 1;
54    }
55    out
56}
57
58fn to_hex(bytes: &[u8]) -> String {
59    const HEX: &[u8; 16] = b"0123456789abcdef";
60    let mut out = String::with_capacity(bytes.len() * 2);
61    for &b in bytes {
62        out.push(HEX[(b >> 4) as usize] as char);
63        out.push(HEX[(b & 0x0f) as usize] as char);
64    }
65    out
66}
67
68fn safe_label(label: &str) -> String {
69    let mut out = String::new();
70    for ch in label.chars() {
71        if ch.is_ascii_alphanumeric() {
72            out.push(ch.to_ascii_uppercase());
73        } else if ch == '_' || ch == '-' {
74            out.push('_');
75        }
76    }
77    if out.is_empty() {
78        "BLOCK".to_string()
79    } else {
80        out
81    }
82}
83
84/// Wrap `content` in an injection fence: the same marker line before and
85/// after it, `‹‹‹LCTX_{LABEL}_{32 hex}›››`.
86///
87/// The token is the first 16 bytes of a keyed BLAKE3 digest over the label
88/// and the content, keyed with a secret of this installation. So the fence is
89/// a function of its input — identical input gives identical output, call
90/// after call and process after process (#498: provider prompt caching needs
91/// byte-stable text) — and it is still a fence: the author of the content
92/// cannot produce the marker, because the token depends on a key they do not
93/// have and on the content itself. A text that contained its own token would
94/// be a different content, with a different token.
95///
96/// Refusal form: when no secure key exists — the key file can be neither read
97/// nor created and the system's randomness is unavailable — the result is
98/// [`FENCE_REFUSAL`] and nothing of the content. Untrusted content is never
99/// emitted unfenced, and never behind a marker its author could compute: a
100/// key derived from anything guessable would be exactly that.
101pub fn fence_content(label: &str, content: &str) -> String {
102    let key_file = crate::core::paths::data_dir()
103        .ok()
104        .map(|dir| dir.join(FENCE_KEY_FILE));
105    fence_with(fence_key(key_file).as_ref(), label, content)
106}
107
108/// What [`fence_content`] returns instead of a fenced block when it has no
109/// secure key: one constant, with no part of the label or the content.
110pub const FENCE_REFUSAL: &str = "[lean-ctx: content withheld — no secure fence key available]";
111
112fn fence_with(key: Option<&[u8; 32]>, label: &str, content: &str) -> String {
113    let Some(key) = key else {
114        static WARNED: std::sync::Once = std::sync::Once::new();
115        WARNED.call_once(|| {
116            tracing::warn!("no secure fence key available: fenced content is withheld");
117        });
118        return FENCE_REFUSAL.to_owned();
119    };
120    let label = safe_label(label);
121    let token = fence_token(key, &label, content);
122    let marker = format!("LCTX_{label}_{token}");
123    format!("‹‹‹{marker}›››\n{content}\n‹‹‹{marker}›››")
124}
125
126fn fence_token(key: &[u8; 32], label: &str, content: &str) -> String {
127    let mut hasher = blake3::Hasher::new_keyed(key);
128    // Length-prefixed, so no label/content pair shares its input with another.
129    hasher.update(&(label.len() as u64).to_le_bytes());
130    hasher.update(label.as_bytes());
131    hasher.update(content.as_bytes());
132    to_hex(&hasher.finalize().as_bytes()[..16])
133}
134
135const FENCE_KEY_FILE: &str = "fence_key";
136
137/// The key last read, with the file it was read from.
138static FENCE_KEY: std::sync::Mutex<Option<(std::path::PathBuf, [u8; 32])>> =
139    std::sync::Mutex::new(None);
140
141/// The installation's fence key: 32 random bytes, created once, stored as 64
142/// hex characters in an owner-only file under the data directory (the form of
143/// the gateway's PII salt, `proxy::pii`). Cached per key file. When the key
144/// file can be neither read nor created (`key_file` is `None` without a data
145/// directory) the key is a random key of this process, and `None` when the
146/// system has no randomness to make one from.
147fn fence_key(key_file: Option<std::path::PathBuf>) -> Option<[u8; 32]> {
148    let Some(path) = key_file else {
149        return process_fence_key();
150    };
151    let Ok(mut cached) = FENCE_KEY.lock() else {
152        return process_fence_key();
153    };
154    if let Some((cached_path, key)) = cached.as_ref()
155        && *cached_path == path
156    {
157        return Some(*key);
158    }
159    let Some(key) = read_fence_key(&path).or_else(|| create_fence_key(&path)) else {
160        return process_fence_key();
161    };
162    *cached = Some((path, key));
163    Some(key)
164}
165
166/// A key for this process only, from the system's secure randomness and from
167/// nothing else. The first answer stands for the process: a failure is not
168/// retried into a different key later (the fence of one text must not change
169/// within a process), so without randomness at that moment every fence of the
170/// process without a key file is the refusal form.
171fn process_fence_key() -> Option<[u8; 32]> {
172    static KEY: std::sync::OnceLock<Option<[u8; 32]>> = std::sync::OnceLock::new();
173    // A simulated failure is this thread's alone: it is never cached.
174    #[cfg(test)]
175    if randomness_seam::fails() {
176        return None;
177    }
178    *KEY.get_or_init(random_key)
179}
180
181/// Fill `buffer` from the system's secure randomness; `false` when there is
182/// none. The only source of key material in this module.
183fn fill_random(buffer: &mut [u8]) -> bool {
184    #[cfg(test)]
185    if randomness_seam::fails() {
186        return false;
187    }
188    getrandom::fill(buffer).is_ok()
189}
190
191/// Test-only: make the randomness source fail on the current thread. Not
192/// compiled into any other build.
193#[cfg(test)]
194mod randomness_seam {
195    thread_local! {
196        static FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
197    }
198
199    pub(super) fn fails() -> bool {
200        FAILS.with(std::cell::Cell::get)
201    }
202
203    /// Randomness fails on this thread until the guard is dropped.
204    pub(super) struct Failing;
205
206    pub(super) fn fail() -> Failing {
207        FAILS.with(|fails| fails.set(true));
208        Failing
209    }
210
211    impl Drop for Failing {
212        fn drop(&mut self) {
213            FAILS.with(|fails| fails.set(false));
214        }
215    }
216}
217
218fn random_key() -> Option<[u8; 32]> {
219    let mut key = [0u8; 32];
220    fill_random(&mut key).then_some(key)
221}
222
223fn read_fence_key(path: &std::path::Path) -> Option<[u8; 32]> {
224    let raw = std::fs::read_to_string(path).ok()?;
225    let raw = raw.trim();
226    if raw.len() != 64 {
227        return None;
228    }
229    let mut key = [0u8; 32];
230    for (byte, pair) in key.iter_mut().zip(raw.as_bytes().chunks(2)) {
231        *byte = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?;
232    }
233    Some(key)
234}
235
236/// Create the key file atomically and owner-only: the key is written to a
237/// private temporary file beside it and linked into place, which fails when
238/// the file already exists — a reader never sees a partial key, and of two
239/// processes that create it at once both end up with the one that was linked.
240fn create_fence_key(path: &std::path::Path) -> Option<[u8; 32]> {
241    use std::io::Write as _;
242    let key = random_key()?;
243    let parent = path.parent()?;
244    std::fs::create_dir_all(parent).ok()?;
245    let mut nonce = [0u8; 8];
246    if !fill_random(&mut nonce) {
247        return None;
248    }
249    let temporary = parent.join(format!(
250        ".{FENCE_KEY_FILE}.{}.{}.tmp",
251        std::process::id(),
252        to_hex(&nonce)
253    ));
254    let mut options = std::fs::OpenOptions::new();
255    options.write(true).create_new(true);
256    #[cfg(unix)]
257    {
258        use std::os::unix::fs::OpenOptionsExt;
259        options.mode(0o600);
260    }
261    let written = options
262        .open(&temporary)
263        .and_then(|mut file| file.write_all(to_hex(&key).as_bytes()));
264    let linked = written.and_then(|()| std::fs::hard_link(&temporary, path));
265    let _ = std::fs::remove_file(&temporary);
266    match linked {
267        Ok(()) => Some(key),
268        // Created by another process in the meantime, or not creatable here.
269        Err(_) => read_fence_key(path),
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn neutralize_replaces_angle_and_backticks() {
279        let s = "<tag>`code`</tag>";
280        let out = neutralize_metadata(s);
281        assert!(out.contains('‹'));
282        assert!(out.contains('›'));
283        assert!(!out.contains('`'));
284    }
285
286    #[test]
287    fn fence_wraps_symmetrically() {
288        let out = fence_content("knowledge", "hello");
289        let lines: Vec<&str> = out.lines().collect();
290        assert!(lines.len() >= 3);
291        assert_eq!(lines[0], lines[lines.len() - 1]);
292    }
293
294    fn token_of(fenced: &str, label: &str) -> String {
295        let open = fenced.lines().next().expect("marker line");
296        open.strip_prefix(&format!("‹‹‹LCTX_{label}_"))
297            .and_then(|rest| rest.strip_suffix("›››"))
298            .unwrap_or_else(|| panic!("not a fence marker: {open}"))
299            .to_owned()
300    }
301
302    /// #498: the fence is a function of (installation key, label, content).
303    /// It drew 16 random bytes per call, so no fenced output was ever equal
304    /// to the previous one.
305    #[test]
306    fn a_fence_is_the_same_for_the_same_input_and_differs_with_label_or_content() {
307        let _data = crate::core::data_dir::isolated_data_dir();
308        let first = fence_content("knowledge", "hello");
309        assert_eq!(fence_content("knowledge", "hello"), first);
310        let token = token_of(&first, "KNOWLEDGE");
311        assert!(
312            token.len() == 32
313                && token
314                    .bytes()
315                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
316            "{first}"
317        );
318        assert_eq!(
319            first,
320            format!("‹‹‹LCTX_KNOWLEDGE_{token}›››\nhello\n‹‹‹LCTX_KNOWLEDGE_{token}›››")
321        );
322
323        assert_ne!(
324            token_of(&fence_content("knowledge", "hello!"), "KNOWLEDGE"),
325            token
326        );
327        assert_ne!(
328            token_of(&fence_content("gotchas", "hello"), "GOTCHAS"),
329            token
330        );
331        // The content cannot carry its own marker: quoting it is new content.
332        assert_ne!(
333            token_of(&fence_content("knowledge", &first), "KNOWLEDGE"),
334            token
335        );
336        // Label and content are separate inputs of the digest.
337        assert_ne!(
338            fence_token(&[7; 32], "AB", "C"),
339            fence_token(&[7; 32], "A", "BC")
340        );
341    }
342
343    /// The key is stored once, owner-only, and read again by a process that
344    /// has not cached it: the fence survives a restart. Another installation
345    /// (another data directory) has another key.
346    #[test]
347    fn the_fence_key_is_created_once_and_read_again() {
348        let first = {
349            let _data = crate::core::data_dir::isolated_data_dir();
350            let path = crate::core::paths::data_dir()
351                .expect("data dir")
352                .join(FENCE_KEY_FILE);
353            assert!(!path.exists());
354            let first = fence_content("knowledge", "hello");
355            let stored = std::fs::read_to_string(&path).expect("key file");
356            assert_eq!(stored.len(), 64);
357            #[cfg(unix)]
358            {
359                use std::os::unix::fs::PermissionsExt;
360                let mode = std::fs::metadata(&path)
361                    .expect("metadata")
362                    .permissions()
363                    .mode();
364                assert_eq!(mode & 0o777, 0o600);
365            }
366            // A second creation attempt keeps the stored key.
367            assert_eq!(create_fence_key(&path), read_fence_key(&path));
368            assert_eq!(std::fs::read_to_string(&path).expect("key file"), stored);
369            // Forget the cached key, as a new process has: the file is read.
370            *FENCE_KEY.lock().expect("key cache") = None;
371            assert_eq!(fence_content("knowledge", "hello"), first);
372            assert_eq!(std::fs::read_to_string(&path).expect("key file"), stored);
373            first
374        };
375        let _data = crate::core::data_dir::isolated_data_dir();
376        assert_ne!(
377            fence_content("knowledge", "hello"),
378            first,
379            "another installation"
380        );
381    }
382
383    /// A key file that can be neither read nor created: its parent is a
384    /// regular file.
385    fn unusable_key_file(directory: &std::path::Path) -> std::path::PathBuf {
386        let blocker = directory.join("not-a-directory");
387        std::fs::write(&blocker, "x").expect("blocker");
388        blocker.join(FENCE_KEY_FILE)
389    }
390
391    /// No key file and no randomness: nothing is fenced and nothing is
392    /// delivered. The answer is the one constant — no label, no content.
393    #[test]
394    fn without_a_secure_key_the_content_is_withheld() {
395        let directory = tempfile::tempdir().expect("directory");
396        let key_file = unusable_key_file(directory.path());
397        let _no_randomness = randomness_seam::fail();
398        assert_eq!(fence_key(Some(key_file.clone())), None);
399        assert_eq!(fence_key(None), None);
400        let refused = fence_with(
401            fence_key(Some(key_file)).as_ref(),
402            "project_knowledge",
403            "rotate the deploy token on Fridays",
404        );
405        assert_eq!(refused, FENCE_REFUSAL);
406        assert_eq!(
407            refused,
408            "[lean-ctx: content withheld — no secure fence key available]"
409        );
410        for part in ["rotate", "Fridays", "KNOWLEDGE", "knowledge", "LCTX_", "‹"] {
411            assert!(!refused.contains(part), "{part}: {refused}");
412        }
413        // And no key is ever written without randomness.
414        let fresh = directory.path().join(FENCE_KEY_FILE);
415        assert_eq!(create_fence_key(&fresh), None);
416        assert!(!fresh.exists());
417    }
418
419    /// A stored key needs no randomness: the fence is the normal one, the
420    /// same as with randomness.
421    #[test]
422    fn a_stored_key_fences_even_when_randomness_fails() {
423        let directory = tempfile::tempdir().expect("directory");
424        let key_file = directory.path().join(FENCE_KEY_FILE);
425        let key = create_fence_key(&key_file).expect("created with randomness");
426        let expected = fence_with(Some(&key), "knowledge", "hello");
427        let _no_randomness = randomness_seam::fail();
428        // Read from the file: this path was never cached.
429        assert_eq!(fence_key(Some(key_file.clone())), Some(key));
430        let fenced = fence_with(fence_key(Some(key_file)).as_ref(), "knowledge", "hello");
431        assert_eq!(fenced, expected);
432        assert_eq!(
433            token_of(&fenced, "KNOWLEDGE"),
434            fence_token(&key, "KNOWLEDGE", "hello")
435        );
436    }
437
438    /// Randomness, but no usable key file (or no data directory at all): a
439    /// random key of this process, the same for every call.
440    #[test]
441    fn without_a_key_file_the_key_is_random_per_process_and_stable() {
442        let directory = tempfile::tempdir().expect("directory");
443        let key_file = unusable_key_file(directory.path());
444        let key = fence_key(Some(key_file.clone())).expect("randomness is available");
445        assert_ne!(key, [0u8; 32]);
446        assert_eq!(fence_key(Some(key_file.clone())), Some(key));
447        assert_eq!(fence_key(None), Some(key));
448        assert_eq!(process_fence_key(), Some(key));
449        assert_eq!(
450            fence_with(fence_key(Some(key_file)).as_ref(), "knowledge", "hello"),
451            fence_with(Some(&key), "knowledge", "hello")
452        );
453    }
454}