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
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
548/// HIGH-PRECISION pattern scrub, part of the shared [`redact_value`] used
549/// by trace export, memory capture and the rollover context archive the
550/// model recalls (prA I-R5-1). Every rule needs an unmistakable credential
551/// shape, so source code and prose pass through unchanged
552/// (`max_tokens: 4096`, `password: String,`, `let token = load()?;`,
553/// `authorization: Option<String>`):
554/// - URL userinfo `scheme://user:pass@host` → `scheme://[REDACTED]@host`;
555/// - an `Authorization:` header carrying an auth scheme (Basic, Bearer,
556///   Digest, Token, Negotiate, NTLM, AWS4-HMAC-SHA256);
557/// - a `Cookie:` / `Set-Cookie:` header carrying a `name=value` pair;
558/// - curl `-u user:pass` / `--user user:pass` (anchored to curl, colon
559///   required: `-u alice`, `docker -u 1000:1000` are left alone);
560/// - `mysql`/`mariadb` `-pSECRET` (attached, lower-case `-p`) and
561///   `sshpass -p X`, anchored to those commands (`mkdir -p`,
562///   `ssh -p 2222`, `docker -p 8080:80` untouched).
563fn 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
595/// Free-text scrub through the shared, high-precision rules
596/// ([`scrub_secret_patterns`] plus the word-level known-prefix scrub).
597/// Returns the input unchanged when clean.
598pub 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
607/// AGGRESSIVE scrub for one-off display of a command the user is about to
608/// approve (the Confirm prompt preview and the confirmer's message). It
609/// trades false positives for recall, so it must NOT be used for stored
610/// text the model or user later reads back (context archive, memory,
611/// traces): those use [`redact_value`] / [`redact_text`] only. On top of
612/// [`redact_text`] it adds:
613/// - any `Authorization:` / `Cookie:` / `Set-Cookie:` header value;
614/// - `Bearer <tok>`;
615/// - `name=value` / `name: value` where the name contains api_key,
616///   access_key, token, secret, password/passwd or credential, and for a
617///   compound identifier (`aws_secret_access_key`) a whitespace separator;
618/// - UPPER_SNAKE `*_KEY=value` (case-sensitive);
619/// - `--flag value` / `--flag=value` with the keyword anywhere in the flag;
620/// - `sk-…` / `sk_…` keys and JWTs.
621pub 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    // Precise (shared) rules first, so e.g. URL userinfo is replaced before
655    // a broader rule can consume part of it; then the aggressive set; then
656    // the word-level prefix scrub again for anything newly exposed.
657    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    // PEM private key blocks: redact the whole value — partial scrubbing
664    // of multi-line key material is not worth the risk.
665    if contains_private_key_block(s) {
666        return Some(REDACTED.to_string());
667    }
668    // Whole-value credential prefixes ("Bearer <x>", "sk-…", JWTs, …).
669    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    // Pattern rules (headers, userinfo, key=value, flags, …).
678    let patterned = scrub_secret_patterns(s);
679    let base = patterned.as_deref().unwrap_or(s);
680    // URL query parameters carrying secret-named keys.
681    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    // Embedded credential tokens inside longer text: redact just the token
686    // (including the word after a "Bearer" marker and embedded JWTs).
687    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
717/// Recursively redact credential-bearing keys and obvious secret-shaped
718/// values in place. Returns the number of redactions applied. Applies to
719/// nested objects and arrays at any depth.
720pub 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// --- Content export ---
748
749/// On-disk shape of the content export artifact. NOT a request trace.
750#[derive(Debug, Serialize, Deserialize)]
751pub struct ContentExport {
752    /// Always [`CONTENT_EXPORT_SCHEMA`].
753    pub schema: String,
754    pub request_id: TraceId,
755    pub captured_unix_ms: u64,
756    pub exported_unix_ms: u64,
757    /// Hard marker: the body passed recursive redaction (twice — at
758    /// capture and again at export).
759    pub redacted: bool,
760    pub redactions_applied: u64,
761    pub body: Value,
762}
763
764/// Export the ephemeral capture bundle for `id` into a fresh private file.
765/// Fail-closed: requires `allow_content_export`, an existing unexpired
766/// bundle, and re-redacts before writing. Consumes (deletes) the bundle.
767pub 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    // Opportunistic retention sweep (B2): every export interaction removes
779    // stale bundles left behind by exited sessions. The requested bundle is
780    // excluded here so its own expiry is reported precisely below
781    // (`CaptureExpired`, and deleted there) instead of a generic miss.
782    let _ = sweep_expired_captures_except(capture_dir, Some(&path));
783    // Bounded, symlink-refusing, type-checked read of the opened handle —
784    // no check/open TOCTOU, no blocking on planted FIFOs.
785    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    // The bundle must claim exactly the requested request ID (fix 3): a
808    // bundle renamed or crafted to sit at another ID's path is refused —
809    // the export never attributes content to a request it was not
810    // captured for.
811    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    // Expiry: refuse and delete. Also treat a bundle claiming a lifetime
823    // longer than the policy TTL as expired (clock tampering fail-closed).
824    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    // Defense in depth: redact again at export time.
845    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    // Consume: the ephemeral capture never outlives its export.
864    let _ = std::fs::remove_file(&path);
865    Ok(())
866}
867
868/// Delete any expired capture bundles in `dir` (bounded retention sweep).
869///
870/// Expiry is *logical* (an expired bundle can never be exported — see
871/// [`export_content`]); this sweep is the physical deletion invoked on
872/// every trace interaction (new captures, exports, `/trace status`, CLI
873/// trace subcommands) AND operationally at Runtime/session startup and in
874/// both the sync and async shutdown epilogues — stale bundles do not wait
875/// for the next explicit trace interaction.
876///
877/// Hardening: each entry is examined through the bounded regular-file
878/// primitive — symlinks are never followed, FIFOs never block, directories
879/// and devices are never read, and oversized or malformed files are
880/// removed without unbounded reads. Work is bounded ([`MAX_SWEEP_ENTRIES`])
881/// and confined to direct entries of `dir`; every failure is soft.
882pub fn sweep_expired_captures(dir: &Path) -> u64 {
883    sweep_expired_captures_except(dir, None)
884}
885
886/// Sweep, optionally keeping one path for the caller's own precise
887/// expiry handling.
888fn 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                // Unparseable bundles are removed too: nothing else may
903                // live here.
904                .unwrap_or(true),
905            // Vanished concurrently: nothing to remove.
906            Err(BoundedReadError::NotFound) => continue,
907            // Symlinks (removed as links, target untouched), FIFOs,
908            // directories, devices, oversized or unreadable files: all
909            // removable garbage — never read, never blocked on.
910            Err(_) => true,
911        };
912        // `remove_file` never follows symlinks and fails softly on
913        // directories — the sweep stays confined to `dir`'s own entries.
914        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(); // keep import used
965        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        // Unknown ID.
1013        assert!(matches!(
1014            export_metadata(&log, "turn-zzz", &dir.path().join("x.jsonl")),
1015            Err(ExportError::NotFound(_))
1016        ));
1017        // Hostile ID.
1018        assert!(matches!(
1019            export_metadata(&log, "turn a\n", &dir.path().join("x.jsonl")),
1020            Err(ExportError::InvalidId(_))
1021        ));
1022        // Existing target refused.
1023        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        // Symlink target refused.
1030        #[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        // A log with an invalid line fails closed.
1040        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        // Non-secret URL structure survives: host, path, safe params.
1105        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        // Plant the bundle at the path of a DIFFERENT id.
1126        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        // Fail closed without the explicit opt-in.
1153        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        // The artifact must never parse as a RequestTrace.
1166        assert!(serde_json::from_str::<RequestTrace>(&data).is_err());
1167        // Consumed: the capture bundle is gone; a second export fails.
1168        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, // long past
1189            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    /// Security regression (sweep hardening): the sweep must never follow a
1243    /// planted symlink — the link itself is removable garbage; the target
1244    /// outside the capture dir is untouched.
1245    #[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        // A live-looking bundle OUTSIDE the capture dir, targeted by a
1252        // symlink inside it. Reading through the link would see a live
1253        // bundle and keep the link forever.
1254        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    /// Security regression (sweep hardening): a planted FIFO must not block
1267    /// the sweep (the historical `read_to_string` open blocked forever
1268    /// waiting for a writer) and is removed without reading.
1269    #[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        // Keep a live bundle alongside to prove selectivity.
1279        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                // Unblock the stuck reader thread before failing loudly.
1295                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    /// Security regression (sweep hardening): oversized planted files are
1305    /// removed without an unbounded read/allocation; non-regular entries
1306    /// (a directory) never make the sweep read or panic.
1307    #[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        // Sparse file far beyond the bundle read cap.
1313        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        // A directory entry: not removable via remove_file, but must be
1318        // skipped without reading or panicking.
1319        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    /// Security regression (export hardening): a symlink planted at the
1334    /// capture path is refused via the O_NOFOLLOW open — never followed —
1335    /// and the out-of-dir target survives untouched.
1336    #[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    /// Redaction regression: URL fragments carry OAuth implicit-flow
1355    /// credentials (`#access_token=…`, `#id_token=…`) and must be scrubbed
1356    /// recursively, while benign fragment values survive.
1357    #[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    /// F5 regression: metadata export must never echo hostile persisted
1393    /// bytes back through its error values (Display or Debug).
1394    #[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        // Valid JSON, invalid record: serde's own message would echo the
1399        // string value verbatim.
1400        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    /// F5 regression: metadata export streams with explicit per-line and
1412    /// total byte bounds instead of an unbounded whole-file read.
1413    #[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        // Per-line bound: one line beyond the cap is refused.
1420        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        // Total bound: a log beyond the cap is refused.
1433        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        // Within bounds: exports normally.
1446        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}