Skip to main content

agent_engine/runtime/trace/
export.rs

1//! Trace export (Task 12): metadata export of persisted
2//! `synaps-request-trace/1` records, explicit redacted content export, and
3//! the recursive secret redaction they share.
4//!
5//! - **Metadata export** (`synaps trace export <id> --metadata-only`):
6//!   reads the private trace JSONL, validates every line as a
7//!   [`RequestTrace`], selects the records whose `turn_id` or `request_id`
8//!   equals the given (validated, bounded) [`TraceId`], and writes them to
9//!   a user-selected private file (`0600`, parent `0700`), refusing
10//!   symlinks and pre-existing targets. No network, no content — the
11//!   records are structurally metadata-only.
12//! - **Content export** (`--include-content --allow-content-export`):
13//!   fail-closed. It requires BOTH flags in the same invocation and an
14//!   existing ephemeral capture bundle produced by `/trace next content`
15//!   (see `trace::controls`). The bundle is re-redacted (defense in depth),
16//!   written under its own clearly-labeled schema
17//!   (`synaps-trace-content-export/1`, never `synaps-request-trace/1`),
18//!   and the capture bundle is deleted (consumed). Expired bundles are
19//!   deleted and refused.
20
21use 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
30/// Schema tag of the content-export artifact. Deliberately distinct from
31/// both the request-trace and capture schemas.
32pub const CONTENT_EXPORT_SCHEMA: &str = "synaps-trace-content-export/1";
33
34/// Typed export failure. Every variant is metadata-only (paths, counts).
35#[derive(Debug)]
36pub enum ExportError {
37    /// The given ID is not a valid bounded `TraceId`.
38    InvalidId(String),
39    /// The trace log does not exist / is unreadable.
40    TraceLogUnreadable(PathBuf, std::io::Error),
41    /// A persisted line failed `RequestTrace` validation (fail closed:
42    /// export refuses a log it cannot fully validate).
43    InvalidRecord {
44        line: usize,
45        reason: String,
46    },
47    /// No record matched the requested turn/request ID.
48    NotFound(String),
49    /// Refusing to write through a symlink or over an existing file.
50    UnsafeTarget(PathBuf, String),
51    /// Content export invoked without the explicit runtime opt-in flag.
52    ContentOptInMissing,
53    /// No capture bundle exists for this ID (content export is fail-closed:
54    /// it never reconstructs prompts from sessions).
55    CaptureMissing(PathBuf),
56    /// The capture bundle expired (it is deleted, not exported).
57    CaptureExpired(PathBuf),
58    /// The capture bundle is malformed or carries the wrong schema tag.
59    CaptureInvalid(PathBuf, String),
60    /// The capture recorded an over-budget body: nothing to export.
61    CaptureOverBudget,
62    /// A streaming read bound was exceeded (per-line or total bytes).
63    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/// Result of a metadata export.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct MetadataExportStats {
129    /// Records scanned in the trace log.
130    pub scanned: usize,
131    /// Records matching the ID and written to the output.
132    pub exported: usize,
133}
134
135/// Conservative cap on bytes read from a single capture bundle. A valid
136/// bundle body is bounded by `CAPTURE_MAX_BYTES` before pretty-printing;
137/// anything beyond this cap is planted garbage, removable without reading.
138pub(crate) const CAPTURE_BUNDLE_READ_CAP: u64 = 8 * 1024 * 1024;
139
140/// Upper bound on directory entries examined by one capture sweep: bounded
141/// work even against a maliciously stuffed directory.
142const MAX_SWEEP_ENTRIES: usize = 4096;
143
144/// Classified failure of the bounded regular-file read primitive.
145#[derive(Debug)]
146enum BoundedReadError {
147    /// No entry at the path.
148    NotFound,
149    /// Symlink, FIFO, directory, device, or other non-regular entry —
150    /// refused before any read (static reason string, never file content).
151    NotRegular(&'static str),
152    /// The (regular) file exceeds the byte cap.
153    Oversized,
154    /// Other I/O failure.
155    Io(std::io::Error),
156}
157
158/// Safe bounded read of a regular file, closing the check/open TOCTOU:
159/// open read-only with `O_NOFOLLOW|O_NONBLOCK` (Unix), `fstat` the opened
160/// handle, require a regular file, enforce `cap` before allocating, and
161/// read at most `cap + 1` bytes to detect concurrent growth. Symlinks,
162/// FIFOs (no blocking open), directories, and devices are refused without
163/// reading a single byte.
164fn 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        // O_NOFOLLOW: a planted symlink fails the open (ELOOP), never
172        // followed. O_NONBLOCK: opening a writer-less FIFO returns
173        // immediately instead of blocking forever; harmless on regular
174        // files, which is the only type accepted below.
175        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    // fstat the opened handle — the type/size decision and the read use
196    // the same file description, so a swap after open cannot bypass it.
197    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
214/// Static classification of a serde_json error: category and position
215/// only — NEVER serde's own message, which can echo hostile input bytes.
216fn 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
226/// Create the output file privately: parent `0700`, file `0600`,
227/// `O_CREAT|O_EXCL|O_NOFOLLOW` — a symlink or pre-existing file at the
228/// target is refused, never followed or truncated.
229fn 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
261/// Export all records whose `turn_id` or `request_id` equals `id` from the
262/// trace JSONL at `trace_log` into a fresh private file at `output`.
263///
264/// F5 hardening: the log is streamed line by line under explicit per-line
265/// (1 MiB) and total (64 MiB) byte bounds — no unbounded whole-file read —
266/// and parse failures are reported by static category and position only,
267/// never by echoing persisted bytes.
268pub 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
276/// [`export_metadata`] with explicit per-line and total byte bounds.
277fn 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        // Bounded line read: never buffer more than the cap (+1 to detect
299        // the overflow) regardless of the log's contents.
300        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        // Every line must validate as a RequestTrace (fail closed). Parse
329        // failures are classified statically — hostile bytes never travel
330        // through the error value.
331        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            // Re-serialize the validated record (canonical field order) —
339            // never copy the raw line into the export.
340            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
363// --- Recursive redaction ---
364
365/// Key substrings (lowercased comparison) whose values are always redacted.
366const 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
385/// Value prefixes that mark an obvious credential wherever it appears.
386const 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
416/// Word-level credential prefixes for embedded-token scanning.
417const 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
438/// A token shaped like a JWT: three dot-separated base64url segments, the
439/// first being a base64url-encoded JSON header (always starts with `eyJ`).
440fn 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
458/// PEM-armored private key material anywhere in a value.
459fn contains_private_key_block(s: &str) -> bool {
460    s.contains("-----BEGIN") && s.contains("PRIVATE KEY")
461}
462
463/// A standalone value shaped like an obvious credential (JWT or known
464/// secret prefix) — used for query/fragment parameter values whose key is
465/// not secret-named.
466fn 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
471/// Scrub one `k=v&k2=v2` parameter list: values under secret-named keys and
472/// credential-shaped values under benign keys are redacted; benign pairs
473/// and non-pair segments are preserved verbatim.
474fn 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
490/// Redact secret-bearing query parameters AND fragment parameters inside a
491/// URL-shaped string: `?api_key=…&token=…` and `#access_token=…&state=…`
492/// (OAuth implicit-flow style) → secret values replaced, keys and safe
493/// params preserved. Applies to any string containing a `?key=value` query
494/// or `#key=value` fragment, not only strict URLs — conservative by design.
495fn 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    // PEM private key blocks: redact the whole value — partial scrubbing
528    // of multi-line key material is not worth the risk.
529    if contains_private_key_block(s) {
530        return Some(REDACTED.to_string());
531    }
532    // Whole-value credential prefixes ("Bearer <x>", "sk-…", JWTs, …).
533    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    // URL query parameters carrying secret-named keys.
542    let (mut current, mut changed) = match scrub_url_query(s) {
543        Some(scrubbed) => (scrubbed, true),
544        None => (s.to_string(), false),
545    };
546    // Embedded credential tokens inside longer text: redact just the token
547    // (including the word after a "Bearer" marker and embedded JWTs).
548    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
578/// Recursively redact credential-bearing keys and obvious secret-shaped
579/// values in place. Returns the number of redactions applied. Applies to
580/// nested objects and arrays at any depth.
581pub 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// --- Content export ---
609
610/// On-disk shape of the content export artifact. NOT a request trace.
611#[derive(Debug, Serialize, Deserialize)]
612pub struct ContentExport {
613    /// Always [`CONTENT_EXPORT_SCHEMA`].
614    pub schema: String,
615    pub request_id: TraceId,
616    pub captured_unix_ms: u64,
617    pub exported_unix_ms: u64,
618    /// Hard marker: the body passed recursive redaction (twice — at
619    /// capture and again at export).
620    pub redacted: bool,
621    pub redactions_applied: u64,
622    pub body: Value,
623}
624
625/// Export the ephemeral capture bundle for `id` into a fresh private file.
626/// Fail-closed: requires `allow_content_export`, an existing unexpired
627/// bundle, and re-redacts before writing. Consumes (deletes) the bundle.
628pub 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    // Opportunistic retention sweep (B2): every export interaction removes
640    // stale bundles left behind by exited sessions. The requested bundle is
641    // excluded here so its own expiry is reported precisely below
642    // (`CaptureExpired`, and deleted there) instead of a generic miss.
643    let _ = sweep_expired_captures_except(capture_dir, Some(&path));
644    // Bounded, symlink-refusing, type-checked read of the opened handle —
645    // no check/open TOCTOU, no blocking on planted FIFOs.
646    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    // The bundle must claim exactly the requested request ID (fix 3): a
669    // bundle renamed or crafted to sit at another ID's path is refused —
670    // the export never attributes content to a request it was not
671    // captured for.
672    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    // Expiry: refuse and delete. Also treat a bundle claiming a lifetime
684    // longer than the policy TTL as expired (clock tampering fail-closed).
685    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    // Defense in depth: redact again at export time.
706    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    // Consume: the ephemeral capture never outlives its export.
725    let _ = std::fs::remove_file(&path);
726    Ok(())
727}
728
729/// Delete any expired capture bundles in `dir` (bounded retention sweep).
730///
731/// Expiry is *logical* (an expired bundle can never be exported — see
732/// [`export_content`]); this sweep is the physical deletion invoked on
733/// every trace interaction (new captures, exports, `/trace status`, CLI
734/// trace subcommands) AND operationally at Runtime/session startup and in
735/// both the sync and async shutdown epilogues — stale bundles do not wait
736/// for the next explicit trace interaction.
737///
738/// Hardening: each entry is examined through the bounded regular-file
739/// primitive — symlinks are never followed, FIFOs never block, directories
740/// and devices are never read, and oversized or malformed files are
741/// removed without unbounded reads. Work is bounded ([`MAX_SWEEP_ENTRIES`])
742/// and confined to direct entries of `dir`; every failure is soft.
743pub fn sweep_expired_captures(dir: &Path) -> u64 {
744    sweep_expired_captures_except(dir, None)
745}
746
747/// Sweep, optionally keeping one path for the caller's own precise
748/// expiry handling.
749fn 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                // Unparseable bundles are removed too: nothing else may
764                // live here.
765                .unwrap_or(true),
766            // Vanished concurrently: nothing to remove.
767            Err(BoundedReadError::NotFound) => continue,
768            // Symlinks (removed as links, target untouched), FIFOs,
769            // directories, devices, oversized or unreadable files: all
770            // removable garbage — never read, never blocked on.
771            Err(_) => true,
772        };
773        // `remove_file` never follows symlinks and fails softly on
774        // directories — the sweep stays confined to `dir`'s own entries.
775        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(); // keep import used
826        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        // Unknown ID.
874        assert!(matches!(
875            export_metadata(&log, "turn-zzz", &dir.path().join("x.jsonl")),
876            Err(ExportError::NotFound(_))
877        ));
878        // Hostile ID.
879        assert!(matches!(
880            export_metadata(&log, "turn a\n", &dir.path().join("x.jsonl")),
881            Err(ExportError::InvalidId(_))
882        ));
883        // Existing target refused.
884        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        // Symlink target refused.
891        #[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        // A log with an invalid line fails closed.
901        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        // Non-secret URL structure survives: host, path, safe params.
966        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        // Plant the bundle at the path of a DIFFERENT id.
987        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        // Fail closed without the explicit opt-in.
1014        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        // The artifact must never parse as a RequestTrace.
1027        assert!(serde_json::from_str::<RequestTrace>(&data).is_err());
1028        // Consumed: the capture bundle is gone; a second export fails.
1029        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, // long past
1050            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    /// Security regression (sweep hardening): the sweep must never follow a
1104    /// planted symlink — the link itself is removable garbage; the target
1105    /// outside the capture dir is untouched.
1106    #[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        // A live-looking bundle OUTSIDE the capture dir, targeted by a
1113        // symlink inside it. Reading through the link would see a live
1114        // bundle and keep the link forever.
1115        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    /// Security regression (sweep hardening): a planted FIFO must not block
1128    /// the sweep (the historical `read_to_string` open blocked forever
1129    /// waiting for a writer) and is removed without reading.
1130    #[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        // Keep a live bundle alongside to prove selectivity.
1140        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                // Unblock the stuck reader thread before failing loudly.
1156                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    /// Security regression (sweep hardening): oversized planted files are
1166    /// removed without an unbounded read/allocation; non-regular entries
1167    /// (a directory) never make the sweep read or panic.
1168    #[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        // Sparse file far beyond the bundle read cap.
1174        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        // A directory entry: not removable via remove_file, but must be
1179        // skipped without reading or panicking.
1180        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    /// Security regression (export hardening): a symlink planted at the
1195    /// capture path is refused via the O_NOFOLLOW open — never followed —
1196    /// and the out-of-dir target survives untouched.
1197    #[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    /// Redaction regression: URL fragments carry OAuth implicit-flow
1216    /// credentials (`#access_token=…`, `#id_token=…`) and must be scrubbed
1217    /// recursively, while benign fragment values survive.
1218    #[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    /// F5 regression: metadata export must never echo hostile persisted
1254    /// bytes back through its error values (Display or Debug).
1255    #[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        // Valid JSON, invalid record: serde's own message would echo the
1260        // string value verbatim.
1261        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    /// F5 regression: metadata export streams with explicit per-line and
1273    /// total byte bounds instead of an unbounded whole-file read.
1274    #[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        // Per-line bound: one line beyond the cap is refused.
1281        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        // Total bound: a log beyond the cap is refused.
1294        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        // Within bounds: exports normally.
1307        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}