1const MAX_METADATA_LEN: usize = 200;
2
3pub fn neutralize_metadata(input: &str) -> String {
4 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
84pub 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
108pub 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 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
137static FENCE_KEY: std::sync::Mutex<Option<(std::path::PathBuf, [u8; 32])>> =
139 std::sync::Mutex::new(None);
140
141fn 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
166fn process_fence_key() -> Option<[u8; 32]> {
172 static KEY: std::sync::OnceLock<Option<[u8; 32]>> = std::sync::OnceLock::new();
173 #[cfg(test)]
175 if randomness_seam::fails() {
176 return None;
177 }
178 *KEY.get_or_init(random_key)
179}
180
181fn 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#[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 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
236fn 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 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 #[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 assert_ne!(
333 token_of(&fence_content("knowledge", &first), "KNOWLEDGE"),
334 token
335 );
336 assert_ne!(
338 fence_token(&[7; 32], "AB", "C"),
339 fence_token(&[7; 32], "A", "BC")
340 );
341 }
342
343 #[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 assert_eq!(create_fence_key(&path), read_fence_key(&path));
368 assert_eq!(std::fs::read_to_string(&path).expect("key file"), stored);
369 *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 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 #[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 let fresh = directory.path().join(FENCE_KEY_FILE);
415 assert_eq!(create_fence_key(&fresh), None);
416 assert!(!fresh.exists());
417 }
418
419 #[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 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 #[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}