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
526fn scrub_string(s: &str) -> Option<String> {
527 if contains_private_key_block(s) {
530 return Some(REDACTED.to_string());
531 }
532 for prefix in SECRET_VALUE_PREFIXES {
534 if s.starts_with(prefix) {
535 return Some(REDACTED.to_string());
536 }
537 }
538 if looks_like_jwt(s) {
539 return Some(REDACTED.to_string());
540 }
541 let (mut current, mut changed) = match scrub_url_query(s) {
543 Some(scrubbed) => (scrubbed, true),
544 None => (s.to_string(), false),
545 };
546 let mut out = String::new();
549 let mut prev_bearer = false;
550 let mut word_changed = false;
551 for word in current.split_inclusive(char::is_whitespace) {
552 let trimmed = word.trim_end();
553 let ws = &word[trimmed.len()..];
554 let is_secret = !trimmed.is_empty()
555 && (prev_bearer
556 || looks_like_jwt(trimmed)
557 || SECRET_WORD_PREFIXES
558 .iter()
559 .any(|p| trimmed.starts_with(p) && trimmed.len() >= p.len() + 6));
560 if is_secret {
561 out.push_str(REDACTED);
562 word_changed = true;
563 } else {
564 out.push_str(trimmed);
565 }
566 if !trimmed.is_empty() {
567 prev_bearer = trimmed.eq_ignore_ascii_case("bearer");
568 }
569 out.push_str(ws);
570 }
571 if word_changed {
572 current = out;
573 changed = true;
574 }
575 changed.then_some(current)
576}
577
578pub fn redact_value(value: &mut Value) -> u64 {
582 match value {
583 Value::Object(map) => {
584 let mut count = 0;
585 for (key, entry) in map.iter_mut() {
586 if key_is_secret(key) {
587 *entry = Value::String(REDACTED.to_string());
588 count += 1;
589 } else {
590 count += redact_value(entry);
591 }
592 }
593 count
594 }
595 Value::Array(items) => items.iter_mut().map(redact_value).sum(),
596 Value::String(s) => {
597 if let Some(scrubbed) = scrub_string(s) {
598 *s = scrubbed;
599 1
600 } else {
601 0
602 }
603 }
604 _ => 0,
605 }
606}
607
608#[derive(Debug, Serialize, Deserialize)]
612pub struct ContentExport {
613 pub schema: String,
615 pub request_id: TraceId,
616 pub captured_unix_ms: u64,
617 pub exported_unix_ms: u64,
618 pub redacted: bool,
621 pub redactions_applied: u64,
622 pub body: Value,
623}
624
625pub fn export_content(
629 capture_dir: &Path,
630 id: &str,
631 output: &Path,
632 allow_content_export: bool,
633) -> Result<(), ExportError> {
634 if !allow_content_export {
635 return Err(ExportError::ContentOptInMissing);
636 }
637 let id = TraceId::new(id).map_err(ExportError::InvalidId)?;
638 let path = capture_path(capture_dir, &id);
639 let _ = sweep_expired_captures_except(capture_dir, Some(&path));
644 let raw = match read_bounded_regular_file(&path, CAPTURE_BUNDLE_READ_CAP) {
647 Ok(raw) => raw,
648 Err(BoundedReadError::NotFound) => return Err(ExportError::CaptureMissing(path)),
649 Err(BoundedReadError::NotRegular(why)) => {
650 return Err(ExportError::CaptureInvalid(path, why.to_string()));
651 }
652 Err(BoundedReadError::Oversized) => {
653 return Err(ExportError::CaptureInvalid(
654 path,
655 format!("exceeds the {CAPTURE_BUNDLE_READ_CAP}-byte bundle read cap"),
656 ));
657 }
658 Err(BoundedReadError::Io(e)) => return Err(ExportError::Io(e)),
659 };
660 let bundle: ContentCaptureBundle = serde_json::from_slice(&raw)
661 .map_err(|e| ExportError::CaptureInvalid(path.clone(), classify_json_error(&e)))?;
662 if bundle.schema != CONTENT_CAPTURE_SCHEMA {
663 return Err(ExportError::CaptureInvalid(
664 path,
665 format!("unexpected schema tag {}", bundle.schema),
666 ));
667 }
668 if bundle.request_id != id {
673 return Err(ExportError::CaptureInvalid(
674 path,
675 format!(
676 "bundle request_id {} does not match requested id {}",
677 bundle.request_id.as_str(),
678 id.as_str()
679 ),
680 ));
681 }
682 let now = unix_ms_now();
683 if now >= bundle.expires_unix_ms
686 || bundle
687 .expires_unix_ms
688 .saturating_sub(bundle.created_unix_ms)
689 > 2 * CAPTURE_TTL.as_millis() as u64
690 {
691 let _ = std::fs::remove_file(&path);
692 return Err(ExportError::CaptureExpired(path));
693 }
694 if !bundle.redacted {
695 let _ = std::fs::remove_file(&path);
696 return Err(ExportError::CaptureInvalid(
697 path,
698 "bundle not marked redacted".to_string(),
699 ));
700 }
701 let Some(mut body) = bundle.body else {
702 let _ = std::fs::remove_file(&path);
703 return Err(ExportError::CaptureOverBudget);
704 };
705 let redactions = redact_value(&mut body);
707
708 let export = ContentExport {
709 schema: CONTENT_EXPORT_SCHEMA.to_string(),
710 request_id: bundle.request_id,
711 captured_unix_ms: bundle.created_unix_ms,
712 exported_unix_ms: now,
713 redacted: true,
714 redactions_applied: redactions,
715 body,
716 };
717 let mut file = create_private_new(output)?;
718 let data = serde_json::to_vec_pretty(&export)
719 .map_err(|e| ExportError::Io(std::io::Error::other(e)))?;
720 file.write_all(&data)?;
721 file.write_all(b"\n")?;
722 file.flush()?;
723
724 let _ = std::fs::remove_file(&path);
726 Ok(())
727}
728
729pub fn sweep_expired_captures(dir: &Path) -> u64 {
744 sweep_expired_captures_except(dir, None)
745}
746
747fn sweep_expired_captures_except(dir: &Path, keep: Option<&Path>) -> u64 {
750 let Ok(entries) = std::fs::read_dir(dir) else {
751 return 0;
752 };
753 let now = unix_ms_now();
754 let mut removed = 0;
755 for entry in entries.flatten().take(MAX_SWEEP_ENTRIES) {
756 let path = entry.path();
757 if keep.is_some_and(|k| k == path) {
758 continue;
759 }
760 let expired = match read_bounded_regular_file(&path, CAPTURE_BUNDLE_READ_CAP) {
761 Ok(raw) => serde_json::from_slice::<ContentCaptureBundle>(&raw)
762 .map(|b| now >= b.expires_unix_ms)
763 .unwrap_or(true),
766 Err(BoundedReadError::NotFound) => continue,
768 Err(_) => true,
772 };
773 if expired && std::fs::remove_file(&path).is_ok() {
776 removed += 1;
777 }
778 }
779 removed
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use crate::runtime::trace::{
786 CollectingTraceSink, EndpointMeta, RequestAnatomy, TraceSchemaVersion, TransportKind,
787 TransportOutcome,
788 };
789 use agent_core::TurnOutcome;
790
791 fn sample_record(turn: &str, request: &str) -> RequestTrace {
792 RequestTrace {
793 schema: TraceSchemaVersion,
794 session_id: TraceId::new("session-1").unwrap(),
795 turn_id: TraceId::new(turn).unwrap(),
796 request_id: TraceId::new(request).unwrap(),
797 execution_events: Vec::new(),
798 attempt: 1,
799 model: agent_core::prompt::QualifiedModelId::parse("anthropic/claude-test").unwrap(),
800 transport: TransportKind::AnthropicMessages,
801 endpoint: EndpointMeta::new("api.anthropic.com", "/v1/messages").unwrap(),
802 anatomy: RequestAnatomy::default(),
803 wire: None,
804 system_segments: Vec::new(),
805 messages: Vec::new(),
806 tools: Vec::new(),
807 cache: Default::default(),
808 translation_losses: Vec::new(),
809 outcome: TransportOutcome::unobserved(TurnOutcome::Completed),
810 }
811 }
812
813 fn write_log(dir: &Path, records: &[RequestTrace]) -> PathBuf {
814 let path = dir.join("request-trace.jsonl");
815 let lines: Vec<String> = records
816 .iter()
817 .map(|r| serde_json::to_string(r).unwrap())
818 .collect();
819 std::fs::write(&path, lines.join("\n") + "\n").unwrap();
820 path
821 }
822
823 #[test]
824 fn metadata_export_selects_exact_id_and_writes_private_file() {
825 let _ = CollectingTraceSink::new(); let dir = tempfile::tempdir().unwrap();
827 let log = write_log(
828 dir.path(),
829 &[
830 sample_record("turn-a", "req-a"),
831 sample_record("turn-b", "req-b"),
832 sample_record("turn-b", "req-c"),
833 ],
834 );
835 let out = dir.path().join("out/export.jsonl");
836 let stats = export_metadata(&log, "turn-b", &out).expect("export");
837 assert_eq!(stats.scanned, 3);
838 assert_eq!(stats.exported, 2);
839
840 let data = std::fs::read_to_string(&out).unwrap();
841 for line in data.lines() {
842 let record: RequestTrace = serde_json::from_str(line).expect("schema-valid line");
843 assert_eq!(record.turn_id.as_str(), "turn-b");
844 }
845 #[cfg(unix)]
846 {
847 use std::os::unix::fs::PermissionsExt as _;
848 let mode = std::fs::metadata(&out).unwrap().permissions().mode() & 0o777;
849 assert_eq!(mode, 0o600, "export must be 0600");
850 let parent_mode = std::fs::metadata(out.parent().unwrap())
851 .unwrap()
852 .permissions()
853 .mode()
854 & 0o777;
855 assert_eq!(parent_mode, 0o700, "created parent must be 0700");
856 }
857 }
858
859 #[test]
860 fn metadata_export_matches_request_id_too() {
861 let dir = tempfile::tempdir().unwrap();
862 let log = write_log(dir.path(), &[sample_record("turn-a", "req-a")]);
863 let out = dir.path().join("byreq.jsonl");
864 let stats = export_metadata(&log, "req-a", &out).expect("export by request id");
865 assert_eq!(stats.exported, 1);
866 }
867
868 #[test]
869 fn metadata_export_refuses_invalid_line_missing_id_and_unsafe_targets() {
870 let dir = tempfile::tempdir().unwrap();
871 let log = write_log(dir.path(), &[sample_record("turn-a", "req-a")]);
872
873 assert!(matches!(
875 export_metadata(&log, "turn-zzz", &dir.path().join("x.jsonl")),
876 Err(ExportError::NotFound(_))
877 ));
878 assert!(matches!(
880 export_metadata(&log, "turn a\n", &dir.path().join("x.jsonl")),
881 Err(ExportError::InvalidId(_))
882 ));
883 let existing = dir.path().join("existing.jsonl");
885 std::fs::write(&existing, b"old").unwrap();
886 assert!(matches!(
887 export_metadata(&log, "turn-a", &existing),
888 Err(ExportError::UnsafeTarget(..))
889 ));
890 #[cfg(unix)]
892 {
893 let link = dir.path().join("link.jsonl");
894 std::os::unix::fs::symlink(dir.path().join("elsewhere"), &link).unwrap();
895 assert!(matches!(
896 export_metadata(&log, "turn-a", &link),
897 Err(ExportError::UnsafeTarget(..))
898 ));
899 }
900 let bad = dir.path().join("bad.jsonl");
902 std::fs::write(&bad, "{\"schema\":\"other/1\"}\n").unwrap();
903 assert!(matches!(
904 export_metadata(&bad, "turn-a", &dir.path().join("y.jsonl")),
905 Err(ExportError::InvalidRecord { line: 1, .. })
906 ));
907 }
908
909 #[test]
910 fn redaction_is_recursive_over_nested_arrays_and_objects() {
911 let mut value = serde_json::json!({
912 "messages": [
913 {"role": "user", "content": [
914 {"type": "text", "text": "use Bearer abcdef123456 please"},
915 {"type": "text", "text": "harmless"}
916 ]},
917 {"nested": {"deep": {"api_key": "SENTINEL-KEY-1",
918 "list": ["sk-SENTINELVALUE123456"]}}}
919 ],
920 "Authorization": "Bearer SENTINEL-TOKEN-2",
921 "metadata": {"password": "SENTINEL-PASS-3"}
922 });
923 let count = redact_value(&mut value);
924 let flat = value.to_string();
925 assert!(count >= 4, "expected multiple redactions, got {count}");
926 for sentinel in [
927 "SENTINEL-KEY-1",
928 "sk-SENTINELVALUE123456",
929 "SENTINEL-TOKEN-2",
930 "SENTINEL-PASS-3",
931 "Bearer abcdef123456",
932 ] {
933 assert!(!flat.contains(sentinel), "sentinel survived: {sentinel}");
934 }
935 assert!(flat.contains("harmless"), "non-secret content preserved");
936 }
937
938 #[test]
939 fn redaction_covers_jwts_urls_and_nested_structures() {
940 let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
941 let mut value = serde_json::json!({
942 "messages": [
943 {"role": "user", "content": format!("my session token is {jwt} thanks")},
944 {"role": "user", "content":
945 "fetch https://api.example.com/v1/data?api_key=SENTINEL-QP-1&page=2&access_token=SENTINEL-QP-2#frag"},
946 {"nested": [[{"deep": {"note": format!("anthropic key sk-ant-SENTINEL456789 here")}}]]},
947 ],
948 "auth_url": "https://x.test/cb?client_secret=SENTINEL-QP-3&state=ok",
949 "pem": "-----BEGIN RSA PRIVATE KEY-----\nSENTINEL-PEM-4\n-----END RSA PRIVATE KEY-----",
950 "raw_jwt": jwt,
951 });
952 let count = redact_value(&mut value);
953 assert!(count >= 6, "expected >=6 redactions, got {count}");
954 let flat = value.to_string();
955 for sentinel in [
956 jwt,
957 "SENTINEL-QP-1",
958 "SENTINEL-QP-2",
959 "SENTINEL-QP-3",
960 "sk-ant-SENTINEL456789",
961 "SENTINEL-PEM-4",
962 ] {
963 assert!(!flat.contains(sentinel), "sentinel survived: {sentinel}");
964 }
965 assert!(flat.contains("api.example.com/v1/data"), "url base kept");
967 assert!(flat.contains("page=2"), "safe query param kept");
968 assert!(flat.contains("state=ok"), "safe query param kept");
969 assert!(flat.contains("thanks"), "surrounding text kept");
970 }
971
972 #[test]
973 fn content_export_refuses_bundle_claiming_a_different_request_id() {
974 let dir = tempfile::tempdir().unwrap();
975 agent_core::core::private_fs::ensure_private_dir(dir.path()).unwrap();
976 let claimed = TraceId::new("req-other").unwrap();
977 let bundle = ContentCaptureBundle {
978 schema: CONTENT_CAPTURE_SCHEMA.to_string(),
979 request_id: claimed,
980 created_unix_ms: unix_ms_now(),
981 expires_unix_ms: unix_ms_now() + 60_000,
982 redacted: true,
983 over_budget: false,
984 body: Some(serde_json::json!({"x": 1})),
985 };
986 let requested = TraceId::new("req-mine").unwrap();
988 std::fs::write(
989 capture_path(dir.path(), &requested),
990 serde_json::to_vec(&bundle).unwrap(),
991 )
992 .unwrap();
993 let out = dir.path().join("out.json");
994 assert!(matches!(
995 export_content(dir.path(), "req-mine", &out, true),
996 Err(ExportError::CaptureInvalid(_, reason)) if reason.contains("does not match")
997 ));
998 assert!(!out.exists());
999 }
1000
1001 #[test]
1002 fn content_export_requires_opt_in_and_consumes_capture() {
1003 let dir = tempfile::tempdir().unwrap();
1004 let capture_dir = dir.path().join("cap");
1005 let id = TraceId::new("req-42").unwrap();
1006 let capture = crate::runtime::trace::controls::ContentCapture::new(capture_dir.clone());
1007 let body = serde_json::json!({
1008 "messages": [{"role": "user", "content": "hello sk-SENTINELSECRET9876543"}]
1009 });
1010 capture.capture(&id, serde_json::to_vec(&body).unwrap().as_slice());
1011
1012 let out = dir.path().join("content.json");
1013 assert!(matches!(
1015 export_content(&capture_dir, "req-42", &out, false),
1016 Err(ExportError::ContentOptInMissing)
1017 ));
1018 assert!(!out.exists());
1019
1020 export_content(&capture_dir, "req-42", &out, true).expect("content export");
1021 let data = std::fs::read_to_string(&out).unwrap();
1022 assert!(!data.contains("sk-SENTINELSECRET9876543"));
1023 let export: ContentExport = serde_json::from_str(&data).unwrap();
1024 assert_eq!(export.schema, CONTENT_EXPORT_SCHEMA);
1025 assert!(export.redacted);
1026 assert!(serde_json::from_str::<RequestTrace>(&data).is_err());
1028 assert!(matches!(
1030 export_content(&capture_dir, "req-42", &dir.path().join("again.json"), true),
1031 Err(ExportError::CaptureMissing(_))
1032 ));
1033 #[cfg(unix)]
1034 {
1035 use std::os::unix::fs::PermissionsExt as _;
1036 let mode = std::fs::metadata(&out).unwrap().permissions().mode() & 0o777;
1037 assert_eq!(mode, 0o600);
1038 }
1039 }
1040
1041 #[test]
1042 fn expired_capture_is_refused_and_deleted() {
1043 let dir = tempfile::tempdir().unwrap();
1044 let id = TraceId::new("req-old").unwrap();
1045 let bundle = ContentCaptureBundle {
1046 schema: CONTENT_CAPTURE_SCHEMA.to_string(),
1047 request_id: id.clone(),
1048 created_unix_ms: 1000,
1049 expires_unix_ms: 2000, redacted: true,
1051 over_budget: false,
1052 body: Some(serde_json::json!({"x": 1})),
1053 };
1054 agent_core::core::private_fs::ensure_private_dir(dir.path()).unwrap();
1055 let path = capture_path(dir.path(), &id);
1056 std::fs::write(&path, serde_json::to_vec(&bundle).unwrap()).unwrap();
1057
1058 let out = dir.path().join("out.json");
1059 assert!(matches!(
1060 export_content(dir.path(), "req-old", &out, true),
1061 Err(ExportError::CaptureExpired(_))
1062 ));
1063 assert!(!path.exists(), "expired bundle must be deleted");
1064 assert!(!out.exists());
1065 }
1066
1067 #[test]
1068 fn sweep_removes_expired_and_foreign_files() {
1069 let dir = tempfile::tempdir().unwrap();
1070 let id = TraceId::new("req-x").unwrap();
1071 let live = ContentCaptureBundle {
1072 schema: CONTENT_CAPTURE_SCHEMA.to_string(),
1073 request_id: id.clone(),
1074 created_unix_ms: unix_ms_now(),
1075 expires_unix_ms: unix_ms_now() + 60_000,
1076 redacted: true,
1077 over_budget: false,
1078 body: Some(serde_json::json!({})),
1079 };
1080 std::fs::write(
1081 capture_path(dir.path(), &id),
1082 serde_json::to_vec(&live).unwrap(),
1083 )
1084 .unwrap();
1085 std::fs::write(dir.path().join("garbage.json"), b"not a bundle").unwrap();
1086 let removed = sweep_expired_captures(dir.path());
1087 assert_eq!(removed, 1);
1088 assert!(capture_path(dir.path(), &id).exists());
1089 }
1090
1091 fn live_bundle(id: &TraceId) -> ContentCaptureBundle {
1092 ContentCaptureBundle {
1093 schema: CONTENT_CAPTURE_SCHEMA.to_string(),
1094 request_id: id.clone(),
1095 created_unix_ms: unix_ms_now(),
1096 expires_unix_ms: unix_ms_now() + 60_000,
1097 redacted: true,
1098 over_budget: false,
1099 body: Some(serde_json::json!({})),
1100 }
1101 }
1102
1103 #[cfg(unix)]
1107 #[test]
1108 fn sweep_removes_symlink_without_following_it() {
1109 let dir = tempfile::tempdir().unwrap();
1110 let cap_dir = dir.path().join("cap");
1111 std::fs::create_dir_all(&cap_dir).unwrap();
1112 let id = TraceId::new("req-linked").unwrap();
1116 let target = dir.path().join("outside.json");
1117 std::fs::write(&target, serde_json::to_vec(&live_bundle(&id)).unwrap()).unwrap();
1118 let link = cap_dir.join("capture-req-linked.json");
1119 std::os::unix::fs::symlink(&target, &link).unwrap();
1120
1121 let removed = sweep_expired_captures(&cap_dir);
1122 assert_eq!(removed, 1, "planted symlink must be swept");
1123 assert!(!link.exists(), "symlink must be removed");
1124 assert!(target.exists(), "symlink target must never be touched");
1125 }
1126
1127 #[cfg(unix)]
1131 #[test]
1132 fn sweep_does_not_block_on_planted_fifo_and_removes_it() {
1133 let dir = tempfile::tempdir().unwrap();
1134 let cap_dir = dir.path().join("cap");
1135 std::fs::create_dir_all(&cap_dir).unwrap();
1136 let fifo = cap_dir.join("capture-fifo.json");
1137 let c_path = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap();
1138 assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }, 0);
1139 let id = TraceId::new("req-live").unwrap();
1141 std::fs::write(
1142 capture_path(&cap_dir, &id),
1143 serde_json::to_vec(&live_bundle(&id)).unwrap(),
1144 )
1145 .unwrap();
1146
1147 let (tx, rx) = std::sync::mpsc::channel();
1148 let sweep_dir = cap_dir.clone();
1149 std::thread::spawn(move || {
1150 let _ = tx.send(sweep_expired_captures(&sweep_dir));
1151 });
1152 let removed = match rx.recv_timeout(std::time::Duration::from_secs(5)) {
1153 Ok(removed) => removed,
1154 Err(_) => {
1155 let _ = std::fs::OpenOptions::new().write(true).open(&fifo);
1157 panic!("sweep blocked on a planted FIFO");
1158 }
1159 };
1160 assert_eq!(removed, 1, "FIFO must be swept without blocking");
1161 assert!(!fifo.exists());
1162 assert!(capture_path(&cap_dir, &id).exists(), "live bundle survives");
1163 }
1164
1165 #[test]
1169 fn sweep_removes_oversized_file_without_unbounded_read() {
1170 let dir = tempfile::tempdir().unwrap();
1171 let cap_dir = dir.path().join("cap");
1172 std::fs::create_dir_all(&cap_dir).unwrap();
1173 let big = cap_dir.join("capture-big.json");
1175 let f = std::fs::File::create(&big).unwrap();
1176 f.set_len(CAPTURE_BUNDLE_READ_CAP + 1024 * 1024).unwrap();
1177 drop(f);
1178 std::fs::create_dir(cap_dir.join("capture-subdir")).unwrap();
1181 let id = TraceId::new("req-live2").unwrap();
1182 std::fs::write(
1183 capture_path(&cap_dir, &id),
1184 serde_json::to_vec(&live_bundle(&id)).unwrap(),
1185 )
1186 .unwrap();
1187
1188 let removed = sweep_expired_captures(&cap_dir);
1189 assert_eq!(removed, 1, "oversized file must be swept");
1190 assert!(!big.exists());
1191 assert!(capture_path(&cap_dir, &id).exists(), "live bundle survives");
1192 }
1193
1194 #[cfg(unix)]
1198 #[test]
1199 fn export_content_refuses_symlinked_capture_without_following() {
1200 let dir = tempfile::tempdir().unwrap();
1201 agent_core::core::private_fs::ensure_private_dir(dir.path()).unwrap();
1202 let id = TraceId::new("req-sym").unwrap();
1203 let target = dir.path().join("target-outside");
1204 std::fs::write(&target, serde_json::to_vec(&live_bundle(&id)).unwrap()).unwrap();
1205 std::os::unix::fs::symlink(&target, capture_path(dir.path(), &id)).unwrap();
1206 let out = dir.path().join("out.json");
1207 assert!(matches!(
1208 export_content(dir.path(), "req-sym", &out, true),
1209 Err(ExportError::CaptureInvalid(..))
1210 ));
1211 assert!(!out.exists());
1212 assert!(target.exists(), "symlink target must never be consumed");
1213 }
1214
1215 #[test]
1219 fn redaction_covers_url_fragments_recursively() {
1220 let mut value = serde_json::json!({
1221 "messages": [
1222 {"content": [{"text":
1223 "see https://x.test/cb#access_token=SENTINEL-FRAG-1&state=keepme ok"}]},
1224 {"nested": {"deep": ["https://x.test/p?q=1#id_token=SENTINEL-FRAG-2"]}},
1225 ],
1226 "doc": "https://x.test/doc#section-2",
1227 "app": "https://x.test/app#page=intro&lang=en",
1228 "jwt_frag":
1229 "https://x.test/app#sess=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl",
1230 "cred_frag": "https://x.test/app#v=AKIASENTINELFRAG34567",
1231 });
1232 let count = redact_value(&mut value);
1233 assert!(count >= 4, "expected >=4 fragment redactions, got {count}");
1234 let flat = value.to_string();
1235 for sentinel in [
1236 "SENTINEL-FRAG-1",
1237 "SENTINEL-FRAG-2",
1238 "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl",
1239 "AKIASENTINELFRAG34567",
1240 ] {
1241 assert!(!flat.contains(sentinel), "sentinel survived: {sentinel}");
1242 }
1243 assert!(flat.contains("state=keepme"), "benign fragment param kept");
1244 assert!(flat.contains("#section-2"), "benign fragment kept");
1245 assert!(flat.contains("page=intro"), "benign fragment param kept");
1246 assert!(flat.contains("lang=en"), "benign fragment param kept");
1247 assert!(
1248 flat.contains("https://x.test/cb#access_token="),
1249 "url shape kept"
1250 );
1251 }
1252
1253 #[test]
1256 fn metadata_export_errors_never_echo_hostile_log_content() {
1257 let dir = tempfile::tempdir().unwrap();
1258 let log = dir.path().join("trace.jsonl");
1259 std::fs::write(&log, b"\"HOSTILE-SENTINEL-999\"\n").unwrap();
1262 let err = export_metadata(&log, "turn-a", &dir.path().join("o.jsonl"))
1263 .expect_err("invalid record must fail");
1264 let shown = format!("{err} / {err:?}");
1265 assert!(
1266 !shown.contains("HOSTILE-SENTINEL-999"),
1267 "hostile log content echoed in error: {shown}"
1268 );
1269 assert!(matches!(err, ExportError::InvalidRecord { line: 1, .. }));
1270 }
1271
1272 #[test]
1275 fn metadata_export_enforces_line_and_total_bounds() {
1276 let dir = tempfile::tempdir().unwrap();
1277 let record = sample_record("turn-a", "req-a");
1278 let line = serde_json::to_string(&record).unwrap();
1279
1280 let log = dir.path().join("wide.jsonl");
1282 std::fs::write(&log, format!("{line}\n")).unwrap();
1283 let err = export_metadata_bounded(
1284 &log,
1285 "turn-a",
1286 &dir.path().join("o1.jsonl"),
1287 line.len() - 1,
1288 1024 * 1024,
1289 )
1290 .expect_err("over-cap line must fail");
1291 assert!(matches!(err, ExportError::BoundExceeded { .. }), "{err:?}");
1292
1293 let log2 = dir.path().join("long.jsonl");
1295 std::fs::write(&log2, format!("{line}\n{line}\n")).unwrap();
1296 let err = export_metadata_bounded(
1297 &log2,
1298 "turn-a",
1299 &dir.path().join("o2.jsonl"),
1300 1024 * 1024,
1301 line.len() as u64 + 1,
1302 )
1303 .expect_err("over-cap log must fail");
1304 assert!(matches!(err, ExportError::BoundExceeded { .. }), "{err:?}");
1305
1306 let stats = export_metadata_bounded(
1308 &log,
1309 "turn-a",
1310 &dir.path().join("o3.jsonl"),
1311 1024 * 1024,
1312 1024 * 1024,
1313 )
1314 .expect("in-bounds export");
1315 assert_eq!(stats.exported, 1);
1316 }
1317}