1use super::controls::{
22 capture_path, unix_ms_now, ContentCaptureBundle, CAPTURE_TTL, CONTENT_CAPTURE_SCHEMA,
23};
24use super::types::{RequestTrace, TraceId};
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27use std::io::Write as _;
28use std::path::{Path, PathBuf};
29
30pub const CONTENT_EXPORT_SCHEMA: &str = "synaps-trace-content-export/1";
33
34#[derive(Debug)]
36pub enum ExportError {
37 InvalidId(String),
39 TraceLogUnreadable(PathBuf, std::io::Error),
41 InvalidRecord {
44 line: usize,
45 reason: String,
46 },
47 NotFound(String),
49 UnsafeTarget(PathBuf, String),
51 ContentOptInMissing,
53 CaptureMissing(PathBuf),
56 CaptureExpired(PathBuf),
58 CaptureInvalid(PathBuf, String),
60 CaptureOverBudget,
62 BoundExceeded {
64 what: &'static str,
65 limit: u64,
66 },
67 Io(std::io::Error),
68}
69
70impl std::fmt::Display for ExportError {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 ExportError::InvalidId(reason) => write!(f, "invalid trace id: {reason}"),
74 ExportError::TraceLogUnreadable(p, e) => {
75 write!(f, "cannot read trace log {}: {e}", p.display())
76 }
77 ExportError::InvalidRecord { line, reason } => {
78 write!(f, "trace log line {line} is not a valid record: {reason}")
79 }
80 ExportError::NotFound(id) => {
81 write!(f, "no trace record matches turn/request id {id}")
82 }
83 ExportError::UnsafeTarget(p, why) => {
84 write!(f, "refusing output target {}: {why}", p.display())
85 }
86 ExportError::ContentOptInMissing => write!(
87 f,
88 "content export requires the explicit --allow-content-export opt-in \
89 in the same invocation"
90 ),
91 ExportError::CaptureMissing(p) => write!(
92 f,
93 "no content capture bundle at {} — run `/trace next content` in a live \
94 session and retry the export before it expires; persisted traces contain \
95 no content and prompts are never reconstructed from sessions",
96 p.display()
97 ),
98 ExportError::CaptureExpired(p) => write!(
99 f,
100 "content capture at {} expired and was deleted",
101 p.display()
102 ),
103 ExportError::CaptureInvalid(p, reason) => {
104 write!(f, "capture bundle {} is invalid: {reason}", p.display())
105 }
106 ExportError::CaptureOverBudget => write!(
107 f,
108 "the captured request exceeded the capture byte budget; no content was retained"
109 ),
110 ExportError::BoundExceeded { what, limit } => {
111 write!(f, "trace export {what} exceeds the {limit}-byte bound")
112 }
113 ExportError::Io(e) => write!(f, "export io error: {e}"),
114 }
115 }
116}
117
118impl std::error::Error for ExportError {}
119
120impl From<std::io::Error> for ExportError {
121 fn from(e: std::io::Error) -> Self {
122 ExportError::Io(e)
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct MetadataExportStats {
129 pub scanned: usize,
131 pub exported: usize,
133}
134
135pub(crate) const CAPTURE_BUNDLE_READ_CAP: u64 = 8 * 1024 * 1024;
139
140const MAX_SWEEP_ENTRIES: usize = 4096;
143
144#[derive(Debug)]
146enum BoundedReadError {
147 NotFound,
149 NotRegular(&'static str),
152 Oversized,
154 Io(std::io::Error),
156}
157
158fn read_bounded_regular_file(path: &Path, cap: u64) -> Result<Vec<u8>, BoundedReadError> {
165 use std::io::Read as _;
166 let mut options = std::fs::OpenOptions::new();
167 options.read(true);
168 #[cfg(unix)]
169 {
170 use std::os::unix::fs::OpenOptionsExt as _;
171 options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
176 }
177 #[cfg(not(unix))]
178 if std::fs::symlink_metadata(path)
179 .map(|m| m.file_type().is_symlink())
180 .unwrap_or(false)
181 {
182 return Err(BoundedReadError::NotRegular("symlink"));
183 }
184 let file = match options.open(path) {
185 Ok(file) => file,
186 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
187 return Err(BoundedReadError::NotFound)
188 }
189 #[cfg(unix)]
190 Err(e) if e.raw_os_error() == Some(libc::ELOOP) => {
191 return Err(BoundedReadError::NotRegular("symlink"));
192 }
193 Err(e) => return Err(BoundedReadError::Io(e)),
194 };
195 let meta = file.metadata().map_err(BoundedReadError::Io)?;
198 if !meta.file_type().is_file() {
199 return Err(BoundedReadError::NotRegular("not a regular file"));
200 }
201 if meta.len() > cap {
202 return Err(BoundedReadError::Oversized);
203 }
204 let mut buf = Vec::with_capacity(meta.len().min(cap) as usize);
205 file.take(cap + 1)
206 .read_to_end(&mut buf)
207 .map_err(BoundedReadError::Io)?;
208 if buf.len() as u64 > cap {
209 return Err(BoundedReadError::Oversized);
210 }
211 Ok(buf)
212}
213
214fn classify_json_error(e: &serde_json::Error) -> String {
217 let class = match e.classify() {
218 serde_json::error::Category::Io => "io",
219 serde_json::error::Category::Syntax => "syntax",
220 serde_json::error::Category::Data => "schema",
221 serde_json::error::Category::Eof => "eof",
222 };
223 format!("{class} error at line {} column {}", e.line(), e.column())
224}
225
226fn create_private_new(path: &Path) -> Result<std::fs::File, ExportError> {
230 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
231 agent_core::core::private_fs::ensure_private_dir(parent)
232 .map_err(|e| ExportError::UnsafeTarget(path.to_path_buf(), e.to_string()))?;
233 }
234 match std::fs::symlink_metadata(path) {
235 Ok(meta) if meta.file_type().is_symlink() => {
236 return Err(ExportError::UnsafeTarget(
237 path.to_path_buf(),
238 "symlink planted at target".to_string(),
239 ));
240 }
241 Ok(_) => {
242 return Err(ExportError::UnsafeTarget(
243 path.to_path_buf(),
244 "target already exists".to_string(),
245 ));
246 }
247 Err(_) => {}
248 }
249 let mut options = std::fs::OpenOptions::new();
250 options.write(true).create_new(true);
251 #[cfg(unix)]
252 {
253 use std::os::unix::fs::OpenOptionsExt as _;
254 options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
255 }
256 options
257 .open(path)
258 .map_err(|e| ExportError::UnsafeTarget(path.to_path_buf(), e.to_string()))
259}
260
261pub fn export_metadata(
269 trace_log: &Path,
270 id: &str,
271 output: &Path,
272) -> Result<MetadataExportStats, ExportError> {
273 export_metadata_bounded(trace_log, id, output, 1024 * 1024, 64 * 1024 * 1024)
274}
275
276fn export_metadata_bounded(
278 trace_log: &Path,
279 id: &str,
280 output: &Path,
281 line_cap: usize,
282 total_cap: u64,
283) -> Result<MetadataExportStats, ExportError> {
284 use std::io::{BufRead as _, Read as _};
285 let id = TraceId::new(id).map_err(ExportError::InvalidId)?;
286 let file = std::fs::File::open(trace_log)
287 .map_err(|e| ExportError::TraceLogUnreadable(trace_log.to_path_buf(), e))?;
288 let mut reader = std::io::BufReader::new(file);
289
290 let mut scanned = 0usize;
291 let mut selected: Vec<String> = Vec::new();
292 let mut total_read: u64 = 0;
293 let mut buf: Vec<u8> = Vec::new();
294 let mut line_no = 0usize;
295 loop {
296 line_no += 1;
297 buf.clear();
298 let read = (&mut reader)
301 .take(line_cap as u64 + 1)
302 .read_until(b'\n', &mut buf)?;
303 if read == 0 {
304 break;
305 }
306 if buf.len() > line_cap {
307 return Err(ExportError::BoundExceeded {
308 what: "log line",
309 limit: line_cap as u64,
310 });
311 }
312 total_read = total_read.saturating_add(read as u64);
313 if total_read > total_cap {
314 return Err(ExportError::BoundExceeded {
315 what: "trace log",
316 limit: total_cap,
317 });
318 }
319 let line = std::str::from_utf8(&buf)
320 .map_err(|_| ExportError::InvalidRecord {
321 line: line_no,
322 reason: "not valid UTF-8".to_string(),
323 })?
324 .trim_end_matches(['\n', '\r']);
325 if line.trim().is_empty() {
326 continue;
327 }
328 let record: RequestTrace =
332 serde_json::from_str(line).map_err(|e| ExportError::InvalidRecord {
333 line: line_no,
334 reason: classify_json_error(&e),
335 })?;
336 scanned += 1;
337 if record.turn_id == id || record.request_id == id {
338 let line = serde_json::to_string(&record).map_err(|e| ExportError::InvalidRecord {
341 line: line_no,
342 reason: classify_json_error(&e),
343 })?;
344 selected.push(line);
345 }
346 }
347 if selected.is_empty() {
348 return Err(ExportError::NotFound(id.as_str().to_string()));
349 }
350
351 let mut file = create_private_new(output)?;
352 for line in &selected {
353 file.write_all(line.as_bytes())?;
354 file.write_all(b"\n")?;
355 }
356 file.flush()?;
357 Ok(MetadataExportStats {
358 scanned,
359 exported: selected.len(),
360 })
361}
362
363const SECRET_KEY_MARKERS: &[&str] = &[
367 "authorization",
368 "api_key",
369 "apikey",
370 "api-key",
371 "token",
372 "secret",
373 "password",
374 "passwd",
375 "credential",
376 "cookie",
377 "bearer",
378 "private_key",
379 "access_key",
380 "session_key",
381 "client_secret",
382 "refresh",
383];
384
385const SECRET_VALUE_PREFIXES: &[&str] = &[
387 "sk-",
388 "sk_",
389 "sk-ant-",
390 "xoxb-",
391 "xoxp-",
392 "ghp_",
393 "gho_",
394 "ghu_",
395 "ghs_",
396 "github_pat_",
397 "glpat-",
398 "npm_",
399 "shpat_",
400 "shpss_",
401 "AKIA",
402 "ASIA",
403 "ya29.",
404 "AIza",
405 "Bearer ",
406 "bearer ",
407];
408
409const REDACTED: &str = "[REDACTED]";
410
411fn key_is_secret(key: &str) -> bool {
412 let lower = key.to_ascii_lowercase();
413 SECRET_KEY_MARKERS.iter().any(|m| lower.contains(m))
414}
415
416const SECRET_WORD_PREFIXES: &[&str] = &[
418 "sk-",
419 "sk_",
420 "sk-ant-",
421 "xoxb-",
422 "xoxp-",
423 "ghp_",
424 "gho_",
425 "ghu_",
426 "ghs_",
427 "github_pat_",
428 "glpat-",
429 "npm_",
430 "shpat_",
431 "shpss_",
432 "AKIA",
433 "ASIA",
434 "ya29.",
435 "AIza",
436];
437
438fn looks_like_jwt(token: &str) -> bool {
441 if !token.starts_with("eyJ") {
442 return false;
443 }
444 let mut segments = 0usize;
445 for segment in token.split('.') {
446 segments += 1;
447 if segment.is_empty()
448 || !segment
449 .chars()
450 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '=')
451 {
452 return false;
453 }
454 }
455 segments == 3
456}
457
458fn contains_private_key_block(s: &str) -> bool {
460 s.contains("-----BEGIN") && s.contains("PRIVATE KEY")
461}
462
463fn value_is_secret_shaped(value: &str) -> bool {
467 !value.is_empty()
468 && (looks_like_jwt(value) || SECRET_VALUE_PREFIXES.iter().any(|p| value.starts_with(p)))
469}
470
471fn scrub_param_pairs(params: &str, changed: &mut bool) -> String {
475 params
476 .split('&')
477 .map(|pair| match pair.split_once('=') {
478 Some((key, value))
479 if !value.is_empty() && (key_is_secret(key) || value_is_secret_shaped(value)) =>
480 {
481 *changed = true;
482 format!("{key}={REDACTED}")
483 }
484 _ => pair.to_string(),
485 })
486 .collect::<Vec<String>>()
487 .join("&")
488}
489
490fn scrub_url_query(s: &str) -> Option<String> {
496 let (head, fragment) = match s.find('#') {
497 Some(idx) => (&s[..idx], Some(&s[idx + 1..])),
498 None => (s, None),
499 };
500 let (base, query) = match head.find('?') {
501 Some(idx) => (&head[..idx + 1], Some(&head[idx + 1..])),
502 None => (head, None),
503 };
504 let mut changed = false;
505 let scrubbed_query = match query {
506 Some(query) if query.contains('=') => Some(scrub_param_pairs(query, &mut changed)),
507 other => other.map(str::to_string),
508 };
509 let scrubbed_fragment = match fragment {
510 Some(fragment) if fragment.contains('=') => Some(scrub_param_pairs(fragment, &mut changed)),
511 other => other.map(str::to_string),
512 };
513 changed.then(|| {
514 let mut out = String::from(base);
515 if let Some(query) = scrubbed_query {
516 out.push_str(&query);
517 }
518 if let Some(fragment) = scrubbed_fragment {
519 out.push('#');
520 out.push_str(&fragment);
521 }
522 out
523 })
524}
525
526type Rules = Vec<(regex::Regex, &'static str)>;
527
528fn compile_rules(rules: Vec<(String, &'static str)>) -> Rules {
529 rules
530 .into_iter()
531 .map(|(re, with)| (regex::Regex::new(&re).expect("static redaction pattern"), with))
532 .collect()
533}
534
535fn apply_rules(s: &str, rules: &Rules) -> Option<String> {
536 let mut out = std::borrow::Cow::Borrowed(s);
537 for (re, with) in rules.iter() {
538 if let std::borrow::Cow::Owned(next) = re.replace_all(&out, *with) {
539 out = std::borrow::Cow::Owned(next);
540 }
541 }
542 match out {
543 std::borrow::Cow::Owned(o) if o != s => Some(o),
544 _ => None,
545 }
546}
547
548fn scrub_secret_patterns(s: &str) -> Option<String> {
564 use std::sync::LazyLock;
565 static PRECISE: LazyLock<Rules> = LazyLock::new(|| {
566 compile_rules(vec![
567 (
568 r"(?i)\b([a-z][a-z0-9+.-]*://)[^/\s:@'\x22]+:[^/\s@'\x22]+@".to_string(),
569 "${1}[REDACTED]@",
570 ),
571 (
572 r"(?i)(authorization\s*:\s*)(?:basic|bearer|digest|token|negotiate|ntlm|aws4-hmac-sha256)\s+[^'\x22\r\n]+"
573 .to_string(),
574 "${1}[REDACTED]",
575 ),
576 (
577 r"(?i)((?:set-)?cookie\s*:\s*)[^'\x22\r\n=]*=[^'\x22\r\n]*".to_string(),
578 "${1}[REDACTED]",
579 ),
580 (
581 r"((?i:\bcurl)[^\n|;&]*?\s(?:-u\s*|--user(?:=|\s+)))[^\s:'\x22]+:[^\s'\x22]+"
582 .to_string(),
583 "${1}[REDACTED]",
584 ),
585 (
586 r"((?i:\bmysql\w*|\bmariadb\w*)[^\n|;&]*?\s-p)[^\s'\x22]+".to_string(),
587 "${1}[REDACTED]",
588 ),
589 (r"(\bsshpass\s+-p\s*)\S+".to_string(), "${1}[REDACTED]"),
590 ])
591 });
592 apply_rules(s, &PRECISE)
593}
594
595pub fn redact_text(s: &str) -> String {
599 let mut v = Value::String(s.to_string());
600 redact_value(&mut v);
601 match v {
602 Value::String(out) => out,
603 other => other.to_string(),
604 }
605}
606
607pub fn redact_command_text(s: &str) -> String {
622 use std::sync::LazyLock;
623 const KW: &str = r"(?:api[_-]?key|access[_-]?key|token|secret|passw(?:or)?d|credential)";
624 const VAL: &str = r#"("[^"]*"|'[^']*'|[^\s&'"]+)"#;
625 static AGGRESSIVE: LazyLock<Rules> = LazyLock::new(|| {
626 compile_rules(vec![
627 (r"(?i)(authorization\s*:\s*)[^'\x22\r\n]+".to_string(), "${1}[REDACTED]"),
628 (r"(?i)((?:set-)?cookie\s*:\s*)[^'\x22\r\n]+".to_string(), "${1}[REDACTED]"),
629 (r"(?i)(\bbearer\s+)[A-Za-z0-9._~+/=-]+".to_string(), "${1}[REDACTED]"),
630 (
631 format!(r"(?i)(\b[a-z0-9_-]*{KW}[a-z0-9_-]*\s*[=:]\s*){VAL}"),
632 "${1}[REDACTED]",
633 ),
634 (
635 format!(
636 r"(?i)(\b[a-z0-9]*[_-][a-z0-9_-]*{KW}[a-z0-9_-]*\s+|\b[a-z0-9_-]*{KW}[a-z0-9_-]*[_-][a-z0-9_-]*\s+){VAL}"
637 ),
638 "${1}[REDACTED]",
639 ),
640 (format!(r"(\b[A-Z][A-Z0-9_]*_KEY\s*=\s*){VAL}"), "${1}[REDACTED]"),
641 (
642 format!(
643 r"(?i)(--[a-z0-9-]*(?:api-?key|access-?key|token|secret|passw(?:or)?d|credential)[a-z0-9-]*(?:=|\s+)){VAL}"
644 ),
645 "${1}[REDACTED]",
646 ),
647 (r"\bsk[-_][A-Za-z0-9_-]{6,}".to_string(), "[REDACTED]"),
648 (
649 r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+".to_string(),
650 "[REDACTED]",
651 ),
652 ])
653 });
654 let shared = redact_text(s);
658 let aggressive = apply_rules(&shared, &AGGRESSIVE).unwrap_or(shared);
659 redact_text(&aggressive)
660}
661
662fn scrub_string(s: &str) -> Option<String> {
663 if contains_private_key_block(s) {
666 return Some(REDACTED.to_string());
667 }
668 for prefix in SECRET_VALUE_PREFIXES {
670 if s.starts_with(prefix) {
671 return Some(REDACTED.to_string());
672 }
673 }
674 if looks_like_jwt(s) {
675 return Some(REDACTED.to_string());
676 }
677 let patterned = scrub_secret_patterns(s);
679 let base = patterned.as_deref().unwrap_or(s);
680 let (mut current, mut changed) = match scrub_url_query(base) {
682 Some(scrubbed) => (scrubbed, true),
683 None => (base.to_string(), patterned.is_some()),
684 };
685 let mut out = String::new();
688 let mut prev_bearer = false;
689 let mut word_changed = false;
690 for word in current.split_inclusive(char::is_whitespace) {
691 let trimmed = word.trim_end();
692 let ws = &word[trimmed.len()..];
693 let is_secret = !trimmed.is_empty()
694 && (prev_bearer
695 || looks_like_jwt(trimmed)
696 || SECRET_WORD_PREFIXES
697 .iter()
698 .any(|p| trimmed.starts_with(p) && trimmed.len() >= p.len() + 6));
699 if is_secret {
700 out.push_str(REDACTED);
701 word_changed = true;
702 } else {
703 out.push_str(trimmed);
704 }
705 if !trimmed.is_empty() {
706 prev_bearer = trimmed.eq_ignore_ascii_case("bearer");
707 }
708 out.push_str(ws);
709 }
710 if word_changed {
711 current = out;
712 changed = true;
713 }
714 changed.then_some(current)
715}
716
717pub fn redact_value(value: &mut Value) -> u64 {
721 match value {
722 Value::Object(map) => {
723 let mut count = 0;
724 for (key, entry) in map.iter_mut() {
725 if key_is_secret(key) {
726 *entry = Value::String(REDACTED.to_string());
727 count += 1;
728 } else {
729 count += redact_value(entry);
730 }
731 }
732 count
733 }
734 Value::Array(items) => items.iter_mut().map(redact_value).sum(),
735 Value::String(s) => {
736 if let Some(scrubbed) = scrub_string(s) {
737 *s = scrubbed;
738 1
739 } else {
740 0
741 }
742 }
743 _ => 0,
744 }
745}
746
747#[derive(Debug, Serialize, Deserialize)]
751pub struct ContentExport {
752 pub schema: String,
754 pub request_id: TraceId,
755 pub captured_unix_ms: u64,
756 pub exported_unix_ms: u64,
757 pub redacted: bool,
760 pub redactions_applied: u64,
761 pub body: Value,
762}
763
764pub fn export_content(
768 capture_dir: &Path,
769 id: &str,
770 output: &Path,
771 allow_content_export: bool,
772) -> Result<(), ExportError> {
773 if !allow_content_export {
774 return Err(ExportError::ContentOptInMissing);
775 }
776 let id = TraceId::new(id).map_err(ExportError::InvalidId)?;
777 let path = capture_path(capture_dir, &id);
778 let _ = sweep_expired_captures_except(capture_dir, Some(&path));
783 let raw = match read_bounded_regular_file(&path, CAPTURE_BUNDLE_READ_CAP) {
786 Ok(raw) => raw,
787 Err(BoundedReadError::NotFound) => return Err(ExportError::CaptureMissing(path)),
788 Err(BoundedReadError::NotRegular(why)) => {
789 return Err(ExportError::CaptureInvalid(path, why.to_string()));
790 }
791 Err(BoundedReadError::Oversized) => {
792 return Err(ExportError::CaptureInvalid(
793 path,
794 format!("exceeds the {CAPTURE_BUNDLE_READ_CAP}-byte bundle read cap"),
795 ));
796 }
797 Err(BoundedReadError::Io(e)) => return Err(ExportError::Io(e)),
798 };
799 let bundle: ContentCaptureBundle = serde_json::from_slice(&raw)
800 .map_err(|e| ExportError::CaptureInvalid(path.clone(), classify_json_error(&e)))?;
801 if bundle.schema != CONTENT_CAPTURE_SCHEMA {
802 return Err(ExportError::CaptureInvalid(
803 path,
804 format!("unexpected schema tag {}", bundle.schema),
805 ));
806 }
807 if bundle.request_id != id {
812 return Err(ExportError::CaptureInvalid(
813 path,
814 format!(
815 "bundle request_id {} does not match requested id {}",
816 bundle.request_id.as_str(),
817 id.as_str()
818 ),
819 ));
820 }
821 let now = unix_ms_now();
822 if now >= bundle.expires_unix_ms
825 || bundle
826 .expires_unix_ms
827 .saturating_sub(bundle.created_unix_ms)
828 > 2 * CAPTURE_TTL.as_millis() as u64
829 {
830 let _ = std::fs::remove_file(&path);
831 return Err(ExportError::CaptureExpired(path));
832 }
833 if !bundle.redacted {
834 let _ = std::fs::remove_file(&path);
835 return Err(ExportError::CaptureInvalid(
836 path,
837 "bundle not marked redacted".to_string(),
838 ));
839 }
840 let Some(mut body) = bundle.body else {
841 let _ = std::fs::remove_file(&path);
842 return Err(ExportError::CaptureOverBudget);
843 };
844 let redactions = redact_value(&mut body);
846
847 let export = ContentExport {
848 schema: CONTENT_EXPORT_SCHEMA.to_string(),
849 request_id: bundle.request_id,
850 captured_unix_ms: bundle.created_unix_ms,
851 exported_unix_ms: now,
852 redacted: true,
853 redactions_applied: redactions,
854 body,
855 };
856 let mut file = create_private_new(output)?;
857 let data = serde_json::to_vec_pretty(&export)
858 .map_err(|e| ExportError::Io(std::io::Error::other(e)))?;
859 file.write_all(&data)?;
860 file.write_all(b"\n")?;
861 file.flush()?;
862
863 let _ = std::fs::remove_file(&path);
865 Ok(())
866}
867
868pub fn sweep_expired_captures(dir: &Path) -> u64 {
883 sweep_expired_captures_except(dir, None)
884}
885
886fn sweep_expired_captures_except(dir: &Path, keep: Option<&Path>) -> u64 {
889 let Ok(entries) = std::fs::read_dir(dir) else {
890 return 0;
891 };
892 let now = unix_ms_now();
893 let mut removed = 0;
894 for entry in entries.flatten().take(MAX_SWEEP_ENTRIES) {
895 let path = entry.path();
896 if keep.is_some_and(|k| k == path) {
897 continue;
898 }
899 let expired = match read_bounded_regular_file(&path, CAPTURE_BUNDLE_READ_CAP) {
900 Ok(raw) => serde_json::from_slice::<ContentCaptureBundle>(&raw)
901 .map(|b| now >= b.expires_unix_ms)
902 .unwrap_or(true),
905 Err(BoundedReadError::NotFound) => continue,
907 Err(_) => true,
911 };
912 if expired && std::fs::remove_file(&path).is_ok() {
915 removed += 1;
916 }
917 }
918 removed
919}
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924 use crate::runtime::trace::{
925 CollectingTraceSink, EndpointMeta, RequestAnatomy, TraceSchemaVersion, TransportKind,
926 TransportOutcome,
927 };
928 use agent_core::TurnOutcome;
929
930 fn sample_record(turn: &str, request: &str) -> RequestTrace {
931 RequestTrace {
932 schema: TraceSchemaVersion,
933 session_id: TraceId::new("session-1").unwrap(),
934 turn_id: TraceId::new(turn).unwrap(),
935 request_id: TraceId::new(request).unwrap(),
936 execution_events: Vec::new(),
937 attempt: 1,
938 model: agent_core::prompt::QualifiedModelId::parse("anthropic/claude-test").unwrap(),
939 transport: TransportKind::AnthropicMessages,
940 endpoint: EndpointMeta::new("api.anthropic.com", "/v1/messages").unwrap(),
941 anatomy: RequestAnatomy::default(),
942 wire: None,
943 system_segments: Vec::new(),
944 messages: Vec::new(),
945 tools: Vec::new(),
946 cache: Default::default(),
947 translation_losses: Vec::new(),
948 outcome: TransportOutcome::unobserved(TurnOutcome::Completed),
949 }
950 }
951
952 fn write_log(dir: &Path, records: &[RequestTrace]) -> PathBuf {
953 let path = dir.join("request-trace.jsonl");
954 let lines: Vec<String> = records
955 .iter()
956 .map(|r| serde_json::to_string(r).unwrap())
957 .collect();
958 std::fs::write(&path, lines.join("\n") + "\n").unwrap();
959 path
960 }
961
962 #[test]
963 fn metadata_export_selects_exact_id_and_writes_private_file() {
964 let _ = CollectingTraceSink::new(); let dir = tempfile::tempdir().unwrap();
966 let log = write_log(
967 dir.path(),
968 &[
969 sample_record("turn-a", "req-a"),
970 sample_record("turn-b", "req-b"),
971 sample_record("turn-b", "req-c"),
972 ],
973 );
974 let out = dir.path().join("out/export.jsonl");
975 let stats = export_metadata(&log, "turn-b", &out).expect("export");
976 assert_eq!(stats.scanned, 3);
977 assert_eq!(stats.exported, 2);
978
979 let data = std::fs::read_to_string(&out).unwrap();
980 for line in data.lines() {
981 let record: RequestTrace = serde_json::from_str(line).expect("schema-valid line");
982 assert_eq!(record.turn_id.as_str(), "turn-b");
983 }
984 #[cfg(unix)]
985 {
986 use std::os::unix::fs::PermissionsExt as _;
987 let mode = std::fs::metadata(&out).unwrap().permissions().mode() & 0o777;
988 assert_eq!(mode, 0o600, "export must be 0600");
989 let parent_mode = std::fs::metadata(out.parent().unwrap())
990 .unwrap()
991 .permissions()
992 .mode()
993 & 0o777;
994 assert_eq!(parent_mode, 0o700, "created parent must be 0700");
995 }
996 }
997
998 #[test]
999 fn metadata_export_matches_request_id_too() {
1000 let dir = tempfile::tempdir().unwrap();
1001 let log = write_log(dir.path(), &[sample_record("turn-a", "req-a")]);
1002 let out = dir.path().join("byreq.jsonl");
1003 let stats = export_metadata(&log, "req-a", &out).expect("export by request id");
1004 assert_eq!(stats.exported, 1);
1005 }
1006
1007 #[test]
1008 fn metadata_export_refuses_invalid_line_missing_id_and_unsafe_targets() {
1009 let dir = tempfile::tempdir().unwrap();
1010 let log = write_log(dir.path(), &[sample_record("turn-a", "req-a")]);
1011
1012 assert!(matches!(
1014 export_metadata(&log, "turn-zzz", &dir.path().join("x.jsonl")),
1015 Err(ExportError::NotFound(_))
1016 ));
1017 assert!(matches!(
1019 export_metadata(&log, "turn a\n", &dir.path().join("x.jsonl")),
1020 Err(ExportError::InvalidId(_))
1021 ));
1022 let existing = dir.path().join("existing.jsonl");
1024 std::fs::write(&existing, b"old").unwrap();
1025 assert!(matches!(
1026 export_metadata(&log, "turn-a", &existing),
1027 Err(ExportError::UnsafeTarget(..))
1028 ));
1029 #[cfg(unix)]
1031 {
1032 let link = dir.path().join("link.jsonl");
1033 std::os::unix::fs::symlink(dir.path().join("elsewhere"), &link).unwrap();
1034 assert!(matches!(
1035 export_metadata(&log, "turn-a", &link),
1036 Err(ExportError::UnsafeTarget(..))
1037 ));
1038 }
1039 let bad = dir.path().join("bad.jsonl");
1041 std::fs::write(&bad, "{\"schema\":\"other/1\"}\n").unwrap();
1042 assert!(matches!(
1043 export_metadata(&bad, "turn-a", &dir.path().join("y.jsonl")),
1044 Err(ExportError::InvalidRecord { line: 1, .. })
1045 ));
1046 }
1047
1048 #[test]
1049 fn redaction_is_recursive_over_nested_arrays_and_objects() {
1050 let mut value = serde_json::json!({
1051 "messages": [
1052 {"role": "user", "content": [
1053 {"type": "text", "text": "use Bearer abcdef123456 please"},
1054 {"type": "text", "text": "harmless"}
1055 ]},
1056 {"nested": {"deep": {"api_key": "SENTINEL-KEY-1",
1057 "list": ["sk-SENTINELVALUE123456"]}}}
1058 ],
1059 "Authorization": "Bearer SENTINEL-TOKEN-2",
1060 "metadata": {"password": "SENTINEL-PASS-3"}
1061 });
1062 let count = redact_value(&mut value);
1063 let flat = value.to_string();
1064 assert!(count >= 4, "expected multiple redactions, got {count}");
1065 for sentinel in [
1066 "SENTINEL-KEY-1",
1067 "sk-SENTINELVALUE123456",
1068 "SENTINEL-TOKEN-2",
1069 "SENTINEL-PASS-3",
1070 "Bearer abcdef123456",
1071 ] {
1072 assert!(!flat.contains(sentinel), "sentinel survived: {sentinel}");
1073 }
1074 assert!(flat.contains("harmless"), "non-secret content preserved");
1075 }
1076
1077 #[test]
1078 fn redaction_covers_jwts_urls_and_nested_structures() {
1079 let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
1080 let mut value = serde_json::json!({
1081 "messages": [
1082 {"role": "user", "content": format!("my session token is {jwt} thanks")},
1083 {"role": "user", "content":
1084 "fetch https://api.example.com/v1/data?api_key=SENTINEL-QP-1&page=2&access_token=SENTINEL-QP-2#frag"},
1085 {"nested": [[{"deep": {"note": format!("anthropic key sk-ant-SENTINEL456789 here")}}]]},
1086 ],
1087 "auth_url": "https://x.test/cb?client_secret=SENTINEL-QP-3&state=ok",
1088 "pem": "-----BEGIN RSA PRIVATE KEY-----\nSENTINEL-PEM-4\n-----END RSA PRIVATE KEY-----",
1089 "raw_jwt": jwt,
1090 });
1091 let count = redact_value(&mut value);
1092 assert!(count >= 6, "expected >=6 redactions, got {count}");
1093 let flat = value.to_string();
1094 for sentinel in [
1095 jwt,
1096 "SENTINEL-QP-1",
1097 "SENTINEL-QP-2",
1098 "SENTINEL-QP-3",
1099 "sk-ant-SENTINEL456789",
1100 "SENTINEL-PEM-4",
1101 ] {
1102 assert!(!flat.contains(sentinel), "sentinel survived: {sentinel}");
1103 }
1104 assert!(flat.contains("api.example.com/v1/data"), "url base kept");
1106 assert!(flat.contains("page=2"), "safe query param kept");
1107 assert!(flat.contains("state=ok"), "safe query param kept");
1108 assert!(flat.contains("thanks"), "surrounding text kept");
1109 }
1110
1111 #[test]
1112 fn content_export_refuses_bundle_claiming_a_different_request_id() {
1113 let dir = tempfile::tempdir().unwrap();
1114 agent_core::core::private_fs::ensure_private_dir(dir.path()).unwrap();
1115 let claimed = TraceId::new("req-other").unwrap();
1116 let bundle = ContentCaptureBundle {
1117 schema: CONTENT_CAPTURE_SCHEMA.to_string(),
1118 request_id: claimed,
1119 created_unix_ms: unix_ms_now(),
1120 expires_unix_ms: unix_ms_now() + 60_000,
1121 redacted: true,
1122 over_budget: false,
1123 body: Some(serde_json::json!({"x": 1})),
1124 };
1125 let requested = TraceId::new("req-mine").unwrap();
1127 std::fs::write(
1128 capture_path(dir.path(), &requested),
1129 serde_json::to_vec(&bundle).unwrap(),
1130 )
1131 .unwrap();
1132 let out = dir.path().join("out.json");
1133 assert!(matches!(
1134 export_content(dir.path(), "req-mine", &out, true),
1135 Err(ExportError::CaptureInvalid(_, reason)) if reason.contains("does not match")
1136 ));
1137 assert!(!out.exists());
1138 }
1139
1140 #[test]
1141 fn content_export_requires_opt_in_and_consumes_capture() {
1142 let dir = tempfile::tempdir().unwrap();
1143 let capture_dir = dir.path().join("cap");
1144 let id = TraceId::new("req-42").unwrap();
1145 let capture = crate::runtime::trace::controls::ContentCapture::new(capture_dir.clone());
1146 let body = serde_json::json!({
1147 "messages": [{"role": "user", "content": "hello sk-SENTINELSECRET9876543"}]
1148 });
1149 capture.capture(&id, serde_json::to_vec(&body).unwrap().as_slice());
1150
1151 let out = dir.path().join("content.json");
1152 assert!(matches!(
1154 export_content(&capture_dir, "req-42", &out, false),
1155 Err(ExportError::ContentOptInMissing)
1156 ));
1157 assert!(!out.exists());
1158
1159 export_content(&capture_dir, "req-42", &out, true).expect("content export");
1160 let data = std::fs::read_to_string(&out).unwrap();
1161 assert!(!data.contains("sk-SENTINELSECRET9876543"));
1162 let export: ContentExport = serde_json::from_str(&data).unwrap();
1163 assert_eq!(export.schema, CONTENT_EXPORT_SCHEMA);
1164 assert!(export.redacted);
1165 assert!(serde_json::from_str::<RequestTrace>(&data).is_err());
1167 assert!(matches!(
1169 export_content(&capture_dir, "req-42", &dir.path().join("again.json"), true),
1170 Err(ExportError::CaptureMissing(_))
1171 ));
1172 #[cfg(unix)]
1173 {
1174 use std::os::unix::fs::PermissionsExt as _;
1175 let mode = std::fs::metadata(&out).unwrap().permissions().mode() & 0o777;
1176 assert_eq!(mode, 0o600);
1177 }
1178 }
1179
1180 #[test]
1181 fn expired_capture_is_refused_and_deleted() {
1182 let dir = tempfile::tempdir().unwrap();
1183 let id = TraceId::new("req-old").unwrap();
1184 let bundle = ContentCaptureBundle {
1185 schema: CONTENT_CAPTURE_SCHEMA.to_string(),
1186 request_id: id.clone(),
1187 created_unix_ms: 1000,
1188 expires_unix_ms: 2000, redacted: true,
1190 over_budget: false,
1191 body: Some(serde_json::json!({"x": 1})),
1192 };
1193 agent_core::core::private_fs::ensure_private_dir(dir.path()).unwrap();
1194 let path = capture_path(dir.path(), &id);
1195 std::fs::write(&path, serde_json::to_vec(&bundle).unwrap()).unwrap();
1196
1197 let out = dir.path().join("out.json");
1198 assert!(matches!(
1199 export_content(dir.path(), "req-old", &out, true),
1200 Err(ExportError::CaptureExpired(_))
1201 ));
1202 assert!(!path.exists(), "expired bundle must be deleted");
1203 assert!(!out.exists());
1204 }
1205
1206 #[test]
1207 fn sweep_removes_expired_and_foreign_files() {
1208 let dir = tempfile::tempdir().unwrap();
1209 let id = TraceId::new("req-x").unwrap();
1210 let live = ContentCaptureBundle {
1211 schema: CONTENT_CAPTURE_SCHEMA.to_string(),
1212 request_id: id.clone(),
1213 created_unix_ms: unix_ms_now(),
1214 expires_unix_ms: unix_ms_now() + 60_000,
1215 redacted: true,
1216 over_budget: false,
1217 body: Some(serde_json::json!({})),
1218 };
1219 std::fs::write(
1220 capture_path(dir.path(), &id),
1221 serde_json::to_vec(&live).unwrap(),
1222 )
1223 .unwrap();
1224 std::fs::write(dir.path().join("garbage.json"), b"not a bundle").unwrap();
1225 let removed = sweep_expired_captures(dir.path());
1226 assert_eq!(removed, 1);
1227 assert!(capture_path(dir.path(), &id).exists());
1228 }
1229
1230 fn live_bundle(id: &TraceId) -> ContentCaptureBundle {
1231 ContentCaptureBundle {
1232 schema: CONTENT_CAPTURE_SCHEMA.to_string(),
1233 request_id: id.clone(),
1234 created_unix_ms: unix_ms_now(),
1235 expires_unix_ms: unix_ms_now() + 60_000,
1236 redacted: true,
1237 over_budget: false,
1238 body: Some(serde_json::json!({})),
1239 }
1240 }
1241
1242 #[cfg(unix)]
1246 #[test]
1247 fn sweep_removes_symlink_without_following_it() {
1248 let dir = tempfile::tempdir().unwrap();
1249 let cap_dir = dir.path().join("cap");
1250 std::fs::create_dir_all(&cap_dir).unwrap();
1251 let id = TraceId::new("req-linked").unwrap();
1255 let target = dir.path().join("outside.json");
1256 std::fs::write(&target, serde_json::to_vec(&live_bundle(&id)).unwrap()).unwrap();
1257 let link = cap_dir.join("capture-req-linked.json");
1258 std::os::unix::fs::symlink(&target, &link).unwrap();
1259
1260 let removed = sweep_expired_captures(&cap_dir);
1261 assert_eq!(removed, 1, "planted symlink must be swept");
1262 assert!(!link.exists(), "symlink must be removed");
1263 assert!(target.exists(), "symlink target must never be touched");
1264 }
1265
1266 #[cfg(unix)]
1270 #[test]
1271 fn sweep_does_not_block_on_planted_fifo_and_removes_it() {
1272 let dir = tempfile::tempdir().unwrap();
1273 let cap_dir = dir.path().join("cap");
1274 std::fs::create_dir_all(&cap_dir).unwrap();
1275 let fifo = cap_dir.join("capture-fifo.json");
1276 let c_path = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap();
1277 assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }, 0);
1278 let id = TraceId::new("req-live").unwrap();
1280 std::fs::write(
1281 capture_path(&cap_dir, &id),
1282 serde_json::to_vec(&live_bundle(&id)).unwrap(),
1283 )
1284 .unwrap();
1285
1286 let (tx, rx) = std::sync::mpsc::channel();
1287 let sweep_dir = cap_dir.clone();
1288 std::thread::spawn(move || {
1289 let _ = tx.send(sweep_expired_captures(&sweep_dir));
1290 });
1291 let removed = match rx.recv_timeout(std::time::Duration::from_secs(5)) {
1292 Ok(removed) => removed,
1293 Err(_) => {
1294 let _ = std::fs::OpenOptions::new().write(true).open(&fifo);
1296 panic!("sweep blocked on a planted FIFO");
1297 }
1298 };
1299 assert_eq!(removed, 1, "FIFO must be swept without blocking");
1300 assert!(!fifo.exists());
1301 assert!(capture_path(&cap_dir, &id).exists(), "live bundle survives");
1302 }
1303
1304 #[test]
1308 fn sweep_removes_oversized_file_without_unbounded_read() {
1309 let dir = tempfile::tempdir().unwrap();
1310 let cap_dir = dir.path().join("cap");
1311 std::fs::create_dir_all(&cap_dir).unwrap();
1312 let big = cap_dir.join("capture-big.json");
1314 let f = std::fs::File::create(&big).unwrap();
1315 f.set_len(CAPTURE_BUNDLE_READ_CAP + 1024 * 1024).unwrap();
1316 drop(f);
1317 std::fs::create_dir(cap_dir.join("capture-subdir")).unwrap();
1320 let id = TraceId::new("req-live2").unwrap();
1321 std::fs::write(
1322 capture_path(&cap_dir, &id),
1323 serde_json::to_vec(&live_bundle(&id)).unwrap(),
1324 )
1325 .unwrap();
1326
1327 let removed = sweep_expired_captures(&cap_dir);
1328 assert_eq!(removed, 1, "oversized file must be swept");
1329 assert!(!big.exists());
1330 assert!(capture_path(&cap_dir, &id).exists(), "live bundle survives");
1331 }
1332
1333 #[cfg(unix)]
1337 #[test]
1338 fn export_content_refuses_symlinked_capture_without_following() {
1339 let dir = tempfile::tempdir().unwrap();
1340 agent_core::core::private_fs::ensure_private_dir(dir.path()).unwrap();
1341 let id = TraceId::new("req-sym").unwrap();
1342 let target = dir.path().join("target-outside");
1343 std::fs::write(&target, serde_json::to_vec(&live_bundle(&id)).unwrap()).unwrap();
1344 std::os::unix::fs::symlink(&target, capture_path(dir.path(), &id)).unwrap();
1345 let out = dir.path().join("out.json");
1346 assert!(matches!(
1347 export_content(dir.path(), "req-sym", &out, true),
1348 Err(ExportError::CaptureInvalid(..))
1349 ));
1350 assert!(!out.exists());
1351 assert!(target.exists(), "symlink target must never be consumed");
1352 }
1353
1354 #[test]
1358 fn redaction_covers_url_fragments_recursively() {
1359 let mut value = serde_json::json!({
1360 "messages": [
1361 {"content": [{"text":
1362 "see https://x.test/cb#access_token=SENTINEL-FRAG-1&state=keepme ok"}]},
1363 {"nested": {"deep": ["https://x.test/p?q=1#id_token=SENTINEL-FRAG-2"]}},
1364 ],
1365 "doc": "https://x.test/doc#section-2",
1366 "app": "https://x.test/app#page=intro&lang=en",
1367 "jwt_frag":
1368 "https://x.test/app#sess=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl",
1369 "cred_frag": "https://x.test/app#v=AKIASENTINELFRAG34567",
1370 });
1371 let count = redact_value(&mut value);
1372 assert!(count >= 4, "expected >=4 fragment redactions, got {count}");
1373 let flat = value.to_string();
1374 for sentinel in [
1375 "SENTINEL-FRAG-1",
1376 "SENTINEL-FRAG-2",
1377 "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl",
1378 "AKIASENTINELFRAG34567",
1379 ] {
1380 assert!(!flat.contains(sentinel), "sentinel survived: {sentinel}");
1381 }
1382 assert!(flat.contains("state=keepme"), "benign fragment param kept");
1383 assert!(flat.contains("#section-2"), "benign fragment kept");
1384 assert!(flat.contains("page=intro"), "benign fragment param kept");
1385 assert!(flat.contains("lang=en"), "benign fragment param kept");
1386 assert!(
1387 flat.contains("https://x.test/cb#access_token="),
1388 "url shape kept"
1389 );
1390 }
1391
1392 #[test]
1395 fn metadata_export_errors_never_echo_hostile_log_content() {
1396 let dir = tempfile::tempdir().unwrap();
1397 let log = dir.path().join("trace.jsonl");
1398 std::fs::write(&log, b"\"HOSTILE-SENTINEL-999\"\n").unwrap();
1401 let err = export_metadata(&log, "turn-a", &dir.path().join("o.jsonl"))
1402 .expect_err("invalid record must fail");
1403 let shown = format!("{err} / {err:?}");
1404 assert!(
1405 !shown.contains("HOSTILE-SENTINEL-999"),
1406 "hostile log content echoed in error: {shown}"
1407 );
1408 assert!(matches!(err, ExportError::InvalidRecord { line: 1, .. }));
1409 }
1410
1411 #[test]
1414 fn metadata_export_enforces_line_and_total_bounds() {
1415 let dir = tempfile::tempdir().unwrap();
1416 let record = sample_record("turn-a", "req-a");
1417 let line = serde_json::to_string(&record).unwrap();
1418
1419 let log = dir.path().join("wide.jsonl");
1421 std::fs::write(&log, format!("{line}\n")).unwrap();
1422 let err = export_metadata_bounded(
1423 &log,
1424 "turn-a",
1425 &dir.path().join("o1.jsonl"),
1426 line.len() - 1,
1427 1024 * 1024,
1428 )
1429 .expect_err("over-cap line must fail");
1430 assert!(matches!(err, ExportError::BoundExceeded { .. }), "{err:?}");
1431
1432 let log2 = dir.path().join("long.jsonl");
1434 std::fs::write(&log2, format!("{line}\n{line}\n")).unwrap();
1435 let err = export_metadata_bounded(
1436 &log2,
1437 "turn-a",
1438 &dir.path().join("o2.jsonl"),
1439 1024 * 1024,
1440 line.len() as u64 + 1,
1441 )
1442 .expect_err("over-cap log must fail");
1443 assert!(matches!(err, ExportError::BoundExceeded { .. }), "{err:?}");
1444
1445 let stats = export_metadata_bounded(
1447 &log,
1448 "turn-a",
1449 &dir.path().join("o3.jsonl"),
1450 1024 * 1024,
1451 1024 * 1024,
1452 )
1453 .expect("in-bounds export");
1454 assert_eq!(stats.exported, 1);
1455 }
1456}