1use kernel::B3Hash;
36use serde_json::{Map, Value};
37
38const REDACTED_REALM: &str = "redacted";
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Marker {
52 Plain,
54 Fingerprinted,
57}
58
59impl Marker {
60 #[must_use]
62 pub fn spell(self, found: &[u8]) -> String {
63 match self {
64 Marker::Plain => format!("secret:{REDACTED_REALM}"),
65 Marker::Fingerprinted => {
66 format!("secret:{REDACTED_REALM}/{}", fingerprint(found))
67 }
68 }
69 }
70}
71
72#[must_use]
81pub fn redact(mut payload: Map<String, Value>) -> (Map<String, Value>, u32) {
82 let mut hits = 0;
83 for value in payload.values_mut() {
84 walk(value, &mut hits);
85 }
86 (payload, hits)
87}
88
89fn walk(value: &mut Value, hits: &mut u32) {
90 match value {
91 Value::String(text) => {
92 if let Some((replaced, found)) = replaced(text, Marker::Fingerprinted) {
93 *hits = hits.saturating_add(found);
94 *text = replaced;
95 }
96 }
97 Value::Array(items) => items.iter_mut().for_each(|item| walk(item, hits)),
98 Value::Object(map) => map.values_mut().for_each(|item| walk(item, hits)),
99 Value::Null | Value::Bool(_) | Value::Number(_) => {}
100 }
101}
102
103#[cfg(test)]
106fn copied(payload: &Map<String, Value>) -> (Map<String, Value>, u32) {
107 fn walk(value: &Value, hits: &mut u32) -> Value {
108 match value {
109 Value::String(text) => {
110 let (replaced, found) = redact_text(text, Marker::Fingerprinted);
111 *hits = hits.saturating_add(found);
112 Value::String(replaced)
113 }
114 Value::Array(items) => {
115 Value::Array(items.iter().map(|item| walk(item, hits)).collect())
116 }
117 Value::Object(map) => Value::Object(
118 map.iter()
119 .map(|(key, item)| (key.clone(), walk(item, hits)))
120 .collect(),
121 ),
122 Value::Null | Value::Bool(_) | Value::Number(_) => value.clone(),
123 }
124 }
125 let mut hits = 0;
126 let out = payload
127 .iter()
128 .map(|(key, value)| (key.clone(), walk(value, &mut hits)))
129 .collect();
130 (out, hits)
131}
132
133#[must_use]
139pub fn redact_text(text: &str, marker: Marker) -> (String, u32) {
140 replaced(text, marker).unwrap_or_else(|| (text.to_owned(), 0))
141}
142
143fn replaced(text: &str, marker: Marker) -> Option<(String, u32)> {
147 let bytes = text.as_bytes();
148 let spans = kernel::secret::scan(bytes);
149 if spans.is_empty() {
150 return None;
151 }
152 let mut out = String::with_capacity(text.len());
153 let mut at = 0usize;
154 let mut hits: u32 = 0;
155 for span in spans {
156 let end = span.start.saturating_add(span.len);
157 if span.start < at || end > bytes.len() {
158 continue;
159 }
160 let Some(before) = bytes
161 .get(at..span.start)
162 .and_then(|b| std::str::from_utf8(b).ok())
163 else {
164 continue;
165 };
166 let Some(found) = bytes.get(span.start..end) else {
167 continue;
168 };
169 out.push_str(before);
170 out.push_str(&marker.spell(found));
171 at = end;
172 hits = hits.saturating_add(1);
173 }
174 if let Some(rest) = bytes.get(at..).and_then(|b| std::str::from_utf8(b).ok()) {
178 out.push_str(rest);
179 }
180 (hits > 0).then_some((out, hits))
181}
182
183const FINGERPRINT_HEX: usize = 16;
187
188#[must_use]
197pub fn fingerprint(found: &[u8]) -> String {
198 let digest = B3Hash::digest(found).to_string();
199 digest.get(..FINGERPRINT_HEX).unwrap_or(&digest).to_owned()
200}
201
202#[cfg(test)]
203#[allow(
204 clippy::unwrap_used,
205 clippy::expect_used,
206 clippy::panic,
207 clippy::indexing_slicing,
208 reason = "test code"
209)]
210mod tests {
211 use super::*;
212
213 fn key_shaped() -> String {
216 ["sk", "9fQ2xZ", "7Lm4Rt", "0Bv8Kd", "3Wp6"].join("")
217 }
218
219 #[test]
220 fn a_key_shaped_run_of_bytes_does_not_reach_the_payload() {
221 let key = key_shaped();
222 let mut payload = Map::new();
223 payload.insert(
224 "message".to_owned(),
225 Value::String(format!("the token is {key} and it works")),
226 );
227 let (redacted, hits) = redact(payload);
228 assert_eq!(hits, 1);
229 let text = redacted
230 .get("message")
231 .and_then(Value::as_str)
232 .unwrap()
233 .to_owned();
234 assert!(!text.contains(&key), "the value itself is gone");
235 assert!(text.contains("the token is "), "the sentence survives");
236 assert!(text.contains("secret:redacted/"));
237 }
238
239 #[test]
240 fn the_same_value_twice_marks_the_same_way_and_two_values_do_not() {
241 let key = key_shaped();
242 let other = ["sk", "2Tg7Yh", "4Nn1Qs", "8Cz5Ud", "6Ke0"].join("");
243 let (first, _) = redact_text(&key, Marker::Fingerprinted);
244 let (again, _) = redact_text(&key, Marker::Fingerprinted);
245 let (different, _) = redact_text(&other, Marker::Fingerprinted);
246 assert_eq!(first, again, "one value, one marker");
247 assert_ne!(first, different, "two values are distinguishable");
248 }
249
250 #[test]
251 fn nested_strings_are_reached_and_ordinary_prose_is_untouched() {
252 let key = key_shaped();
253 let mut inner = Map::new();
254 inner.insert("text".to_owned(), Value::String(key.clone()));
255 let mut payload = Map::new();
256 payload.insert(
257 "content".to_owned(),
258 Value::Array(vec![Value::Object(inner)]),
259 );
260 payload.insert(
261 "prose".to_owned(),
262 Value::String("nothing here looks like a key at all".to_owned()),
263 );
264 payload.insert("count".to_owned(), Value::Number(3.into()));
265 let (redacted, hits) = redact(payload);
266 assert_eq!(hits, 1);
267 assert_eq!(
268 redacted.get("prose").and_then(Value::as_str),
269 Some("nothing here looks like a key at all"),
270 "prose is not a shape, and a scanner that trimmed it would be unusable"
271 );
272 assert_eq!(redacted.get("count").and_then(Value::as_u64), Some(3));
273 let text = serde_json::to_string(&redacted).unwrap();
274 assert!(!text.contains(&key));
275 }
276
277 #[test]
283 fn a_marker_is_a_reference_a_reader_can_follow_to_an_honest_nothing() {
284 let key = key_shaped();
285 let short = fingerprint(key.as_bytes());
286 assert_eq!(short.len(), 16);
287 let (marked, hits) = redact_text(&key, Marker::Fingerprinted);
288 assert_eq!(hits, 1);
289 assert_eq!(marked, format!("secret:redacted/{short}"));
290 let reference = kernel::SecretRef::parse(&marked).unwrap();
291 assert_eq!(reference.realm(), REDACTED_REALM);
292 assert_eq!(reference.name(), short);
293 }
294
295 #[test]
298 fn a_plain_marker_says_that_something_was_here_and_nothing_more() {
299 let key = key_shaped();
300 let (line, hits) = redact_text(&format!("token {key} used"), Marker::Plain);
301 assert_eq!(hits, 1);
302 assert_eq!(line, "token secret:redacted used");
303 assert!(!line.contains(&fingerprint(key.as_bytes())));
304 let (kept, _) = redact_text(&format!("token {key} used"), Marker::Fingerprinted);
305 assert!(kept.contains(&fingerprint(key.as_bytes())));
306 }
307
308 #[test]
309 fn a_payload_with_nothing_to_hide_comes_back_unchanged() {
310 let mut payload = Map::new();
311 payload.insert(
312 "message".to_owned(),
313 Value::String("measured in metres".to_owned()),
314 );
315 let (redacted, hits) = redact(payload.clone());
316 assert_eq!(hits, 0);
317 assert_eq!(redacted, payload);
318 }
319
320 fn text_at<'v>(payload: &'v Map<String, Value>, key: &str) -> &'v str {
321 payload.get(key).and_then(Value::as_str).unwrap()
322 }
323
324 #[test]
328 fn a_clean_string_comes_back_in_the_allocation_it_went_in_with() {
329 let key = key_shaped();
330 let mut payload = Map::new();
331 payload.insert(
332 "prose".to_owned(),
333 Value::String("measured in metres".to_owned()),
334 );
335 payload.insert("token".to_owned(), Value::String(format!("use {key}")));
336 let before = text_at(&payload, "prose").as_ptr();
337 let (redacted, hits) = redact(payload);
338 assert_eq!(hits, 1);
339 assert_eq!(
340 text_at(&redacted, "prose").as_ptr(),
341 before,
342 "the clean string was copied"
343 );
344 assert!(!text_at(&redacted, "token").contains(&key));
345 }
346
347 fn payloads() -> impl proptest::strategy::Strategy<Value = Map<String, Value>> {
350 use proptest::prelude::*;
351 let text =
352 ("[a-z =:]{0,12}", any::<bool>(), "[a-z ]{0,6}").prop_map(|(head, keyed, tail)| {
353 let middle = if keyed { key_shaped() } else { String::new() };
354 Value::String(format!("{head}{middle}{tail}"))
355 });
356 let leaf = prop_oneof![
357 Just(Value::Null),
358 any::<bool>().prop_map(Value::Bool),
359 any::<i64>().prop_map(|n| Value::Number(n.into())),
360 text,
361 ];
362 let tree = leaf.prop_recursive(3, 24, 4, |inner| {
363 prop_oneof![
364 proptest::collection::vec(inner.clone(), 0..4).prop_map(Value::Array),
365 proptest::collection::btree_map("[a-c]{1,2}", inner, 0..4)
366 .prop_map(|map| Value::Object(map.into_iter().collect())),
367 ]
368 });
369 proptest::collection::btree_map("[a-d]{1,2}", tree, 0..5)
370 .prop_map(|map| map.into_iter().collect())
371 }
372
373 proptest::proptest! {
374 #[test]
377 fn the_owned_walk_agrees_with_the_copying_one(payload in payloads()) {
378 proptest::prop_assert_eq!(redact(payload.clone()), copied(&payload));
379 }
380 }
381}
382
383#[cfg(test)]
390#[allow(
391 clippy::unwrap_used,
392 clippy::disallowed_methods,
393 reason = "test code: an instrument reads the wall clock"
394)]
395mod phases {
396 use std::path::Path;
397 use std::sync::{Arc, Mutex};
398 use std::time::{Duration, Instant};
399
400 use kernel::{Address, Payload, ReadVerdict, Tool, ToolCall, ToolName, ToolOutcome};
401 use serde_json::{Map, Value, json};
402
403 use crate::backlog::{Backlog, PollBudget};
404 use crate::catalog::Catalog;
405 use crate::sandbox::{EchoSandbox, Fuel};
406 use crate::tools::{
407 EditTool, ExecSetup, ExecTool, ReadBound, ReadTool, SearchTool, version_of,
408 };
409
410 #[test]
411 #[ignore = "an instrument: run it by name with --run-ignored only --no-capture"]
412 fn instrument_read_phases() {
413 let city = tempfile::tempdir().unwrap();
414 std::fs::write(city.path().join("history.txt"), history_text()).unwrap();
415 let catalog = Arc::new(Mutex::new(Catalog::new()));
416 let tool =
417 ReadTool::new(city.path(), catalog, everywhere(), Path::new("no-store")).unwrap();
418 let read = call("read", json!({ "path": "history.txt" }));
419 report("read", (0..32).map(|_| timed(&tool, &read)));
420 }
421
422 #[test]
425 #[ignore = "an instrument: run it by name with --run-ignored only --no-capture"]
426 fn instrument_write_and_edit_phases() {
427 let city = tempfile::tempdir().unwrap();
428 let work = Address::parse("work").unwrap();
429 let domain = kernel::WriteDomain::new(vec![work.clone()]).unwrap();
430 std::fs::create_dir_all(city.path().join("work")).unwrap();
431 let tool = EditTool::new(
432 city.path(),
433 work,
434 domain,
435 crate::PolicyCell::new(kernel::RunPolicy::of(kernel::Mode::Work)).reader(),
436 )
437 .unwrap();
438 let text = history_text();
439 let edit = |path: &str, base: &str, old: &str, new: &str| {
440 call(
441 "edit",
442 json!({ "path": path, "base_version": base, "old": old, "new": new }),
443 )
444 };
445 let writes: Vec<_> = (0..32)
446 .map(|at| timed(&tool, &edit(&format!("work/h{at}.txt"), "new", "", &text)))
447 .collect();
448 report("write", writes);
449 let edited = format!("{text}tail\n");
450 let edits: Vec<_> = (0..32)
451 .map(|at| {
452 let path = format!("work/h{at}.txt");
453 std::fs::write(city.path().join(&path), &edited).unwrap();
454 timed(
455 &tool,
456 &edit(&path, &version_of(edited.as_bytes()), "tail", "TAIL"),
457 )
458 })
459 .collect();
460 report("edit", edits);
461 }
462
463 #[test]
467 #[ignore = "an instrument: run it by name with --run-ignored only --no-capture"]
468 fn instrument_exec_phases() {
469 let chamber = tempfile::tempdir().unwrap();
470 std::fs::write(chamber.path().join("history.txt"), history_text()).unwrap();
471 let setup = ExecSetup {
472 workdir: chamber.path().to_path_buf(),
473 mounts: Vec::new(),
474 python_wasm: None,
475 shell: crate::Shell::Absent,
476 fuel: Fuel(1_000_000),
477 env_passthrough: Vec::new(),
478 domain: Address::parse("work").unwrap(),
479 run: kernel::RunId::from_bytes([1; 16]),
480 policy: crate::PolicyCell::new(kernel::RunPolicy::of(kernel::Mode::Work)).reader(),
481 };
482 let patient = Backlog::with_window(PollBudget::new(6_000, 20));
483 let tool = ExecTool::new(setup, Box::new(EchoSandbox::new()), patient).unwrap();
484 let (path, args) = if cfg!(windows) {
485 ("cmd", ["/C", "type history.txt"])
486 } else {
487 ("sh", ["-c", "cat history.txt"])
488 };
489 let exec = call(
490 "exec",
491 json!({ "arm": { "program": { "path": path, "args": args } } }),
492 );
493 report("exec", (0..8).map(|_| timed(&tool, &exec)));
494 }
495
496 #[test]
497 #[ignore = "an instrument: run it by name with --run-ignored only --no-capture"]
498 fn instrument_search_phases() {
499 let city = tempfile::tempdir().unwrap();
500 std::fs::create_dir_all(city.path().join("lab")).unwrap();
501 std::fs::write(city.path().join("lab").join("history.txt"), history_text()).unwrap();
502 let tool = SearchTool::new(city.path(), everywhere()).unwrap();
503 let search = call("search", json!({ "text": "HMAC_KEY" }));
504 report("search", (0..32).map(|_| timed(&tool, &search)));
505 }
506
507 fn everywhere() -> ReadBound {
508 Arc::new(|_: &Address| ReadVerdict::Open)
509 }
510
511 fn call(name: &str, args: Value) -> ToolCall {
512 let Value::Object(args) = args else {
513 return call(name, Value::Object(Map::new()));
514 };
515 ToolCall {
516 id: "call-1".to_owned(),
517 name: ToolName::parse(name).unwrap(),
518 args: Payload::new(args).unwrap(),
519 }
520 }
521
522 fn timed(tool: &dyn Tool, call: &ToolCall) -> (Duration, ToolOutcome) {
523 let started = Instant::now();
524 let outcome = tool.invoke(call).unwrap();
525 (started.elapsed(), outcome)
526 }
527
528 fn history_text() -> String {
532 let hex = "9f3c1a7e5b20d48c6f1ea7b3905d2ce81f64b07a".repeat(2);
533 let token = ["kJ8vQ2xR9m", "W4nZ7pL3sT", "6yB1cD5fG0", "hN8aE2iU4o"].concat();
534 let line = format!(
535 "prev={hex} oid={} HMAC_KEY={} note: see {token} for the run, and nothing else\n",
536 hex.get(..40).unwrap(),
537 hex.get(3..43).unwrap()
538 );
539 line.repeat(400)
540 }
541
542 fn report(tool: &str, calls: impl IntoIterator<Item = (Duration, ToolOutcome)>) {
545 let (mut rounds, mut found) = (0u32, 0u32);
546 let (mut call, mut scan) = (Duration::ZERO, Duration::ZERO);
547 for (took, outcome) in calls {
548 let started = Instant::now();
549 let (_redacted, hits) = super::redact(outcome.result.into_map());
550 scan = scan.saturating_add(started.elapsed());
551 call = call.saturating_add(took);
552 rounds = rounds.saturating_add(1);
553 found = found.saturating_add(hits);
554 }
555 println!(
556 "instrument_tool_phases tool={tool} rounds={rounds} spans={found} call_us={} secret_scan_us={}",
557 call.as_micros(),
558 scan.as_micros()
559 );
560 }
561}