Skip to main content

nyx_agent_core/report/
repro_bundle.rs

1//! Per-finding repro bundle writer.
2//!
3//! Builds a self-contained tarball that another operator can untar
4//! and replay via `bash repro.sh`. The bundle's layout mirrors the
5//! per-finding bundles `nyx` itself ships so the same replay tooling
6//! reads both:
7//!
8//! ```text
9//! <finding-id>/
10//!   README.md
11//!   repro.sh
12//!   payload.bin
13//!   expected/
14//!     verdict.json
15//!     trace.jsonl
16//! ```
17//!
18//! Built via the `tar` crate so PAX extension headers (long names),
19//! symlinks, and the rest of the standard typeflags work for free.
20//! Standard `tar xf <bundle>.tar` reads the output.
21//!
22//! Compression is deliberately omitted - the inputs are small
23//! (kilobytes per finding) and a deterministic uncompressed archive
24//! makes the SHA-256 the API stamps on `repro_bundles.sha256`
25//! reproducible without depending on the zstd/zlib version
26//! installed on the writer host.
27
28use std::collections::BTreeMap;
29use std::path::{Path, PathBuf};
30
31use thiserror::Error;
32
33pub use nyx_agent_types::api::BundleManifest;
34
35use crate::store::{
36    AgentTraceRecord, FindingRecord, PayloadRecord, ReproBundleRecord, ReproBundleStore, Store,
37    StoreError,
38};
39
40pub const REPRO_SCRIPT_FILENAME: &str = "repro.sh";
41pub const PAYLOAD_FILENAME: &str = "payload.bin";
42pub const EXPECTED_VERDICT_FILENAME: &str = "expected/verdict.json";
43pub const EXPECTED_TRACE_FILENAME: &str = "expected/trace.jsonl";
44pub const README_FILENAME: &str = "README.md";
45
46/// One file in the bundle.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct BundleArtifact {
49    pub path: String,
50    pub mode: u32,
51    pub contents: Vec<u8>,
52}
53
54#[derive(Debug, Error)]
55pub enum BundleError {
56    #[error("finding `{0}` not found")]
57    FindingNotFound(String),
58    #[error("tar write error: {0}")]
59    Tar(#[source] std::io::Error),
60    #[error(transparent)]
61    Store(#[from] StoreError),
62    #[error("io error at {path}: {source}")]
63    Io {
64        path: PathBuf,
65        #[source]
66        source: std::io::Error,
67    },
68}
69
70/// Read every persisted artifact for `finding_id`, render the
71/// per-bundle files, write a USTAR tarball at `out_dir`, and stamp a
72/// new row on `repro_bundles`. Returns the manifest the caller hands
73/// to the API.
74pub async fn build_bundle(
75    store: &Store,
76    finding_id: &str,
77    out_dir: &Path,
78    now_ms: i64,
79) -> Result<BundleManifest, BundleError> {
80    let finding = store
81        .findings()
82        .get(finding_id)
83        .await?
84        .ok_or_else(|| BundleError::FindingNotFound(finding_id.to_string()))?;
85    let payloads = store.payloads().list_for_finding(finding_id).await?;
86    let traces = store.agent_traces().list_for_finding(finding_id).await?;
87
88    let mut artifacts: Vec<BundleArtifact> = Vec::new();
89    artifacts.push(BundleArtifact {
90        path: README_FILENAME.to_string(),
91        mode: 0o644,
92        contents: render_readme(&finding, &payloads).into_bytes(),
93    });
94    let payload_bytes = payloads.first().map(|p| p.vuln_bytes.clone()).unwrap_or_default();
95    artifacts.push(BundleArtifact {
96        path: PAYLOAD_FILENAME.to_string(),
97        mode: 0o644,
98        contents: payload_bytes,
99    });
100    artifacts.push(BundleArtifact {
101        path: REPRO_SCRIPT_FILENAME.to_string(),
102        mode: 0o755,
103        contents: render_repro_script(&finding, payloads.first()).into_bytes(),
104    });
105    artifacts.push(BundleArtifact {
106        path: EXPECTED_VERDICT_FILENAME.to_string(),
107        mode: 0o644,
108        contents: render_expected_verdict(&finding).into_bytes(),
109    });
110    artifacts.push(BundleArtifact {
111        path: EXPECTED_TRACE_FILENAME.to_string(),
112        mode: 0o644,
113        contents: render_expected_trace(&traces).into_bytes(),
114    });
115
116    let tar_bytes = build_ustar(&finding.id, &artifacts, now_ms)?;
117    std::fs::create_dir_all(out_dir)
118        .map_err(|source| BundleError::Io { path: out_dir.to_path_buf(), source })?;
119    let bundle_path = out_dir.join(format!("{}.tar", finding.id));
120    std::fs::write(&bundle_path, &tar_bytes)
121        .map_err(|source| BundleError::Io { path: bundle_path.clone(), source })?;
122    let sha256 = blake3_hex(&tar_bytes);
123
124    let record = ReproBundleRecord {
125        id: bundle_id(&finding.id, now_ms),
126        finding_id: finding.id.clone(),
127        path: bundle_path.display().to_string(),
128        sha256: sha256.clone(),
129        created_at: now_ms,
130        last_replay_at: None,
131        last_replay_status: None,
132    };
133    insert_bundle_row(&store.repro_bundles(), &record).await?;
134
135    Ok(BundleManifest {
136        finding_id: finding.id,
137        bundle_path,
138        sha256,
139        byte_size: tar_bytes.len() as u64,
140        artifacts: artifacts.into_iter().map(|a| a.path).collect(),
141    })
142}
143
144async fn insert_bundle_row(
145    store: &ReproBundleStore<'_>,
146    record: &ReproBundleRecord,
147) -> Result<(), BundleError> {
148    store.insert(record).await?;
149    Ok(())
150}
151
152fn bundle_id(finding_id: &str, now_ms: i64) -> String {
153    format!("bundle-{finding_id}-{now_ms:x}")
154}
155
156fn render_readme(finding: &FindingRecord, payloads: &[PayloadRecord]) -> String {
157    let payload_block = payloads
158        .first()
159        .map(|p| {
160            format!(
161                "## Payload\n- cap: {}\n- lang: {}\n- vuln_bytes: {} bytes\n- benign_bytes: {}\n- prompt_version: {}\n",
162                p.cap,
163                p.lang,
164                p.vuln_bytes.len(),
165                p.benign_bytes.as_ref().map(|b| b.len().to_string()).unwrap_or_else(|| "-".to_string()),
166                p.prompt_version.as_deref().unwrap_or("-")
167            )
168        })
169        .unwrap_or_else(|| "## Payload\n_No AI-synthesised payload on this finding._\n".to_string());
170    format!(
171        "# Repro bundle: {id}\n\n\
172         - run_id: {run}\n\
173         - repo: {repo}\n\
174         - path: {path}{line}\n\
175         - rule: {rule}\n\
176         - cap: {cap}\n\
177         - severity: {sev}\n\
178         - status: {status}\n\
179         - origin: {origin}\n\
180         - first_seen: {first_seen}\n\
181         - last_seen: {last_seen}\n\n\
182         {payload}\n\
183         ## Replay\n\
184         Run `bash {repro_sh}` on a Linux or macOS host that has the same nyx\n\
185         binary as the daemon. The script materialises `{payload_bin}` next\n\
186         to itself and re-executes the verifier; compare its exit code\n\
187         + stdout against `{expected_verdict}` and the per-turn AI trace\n\
188         against `{expected_trace}`.\n",
189        id = finding.id,
190        run = finding.run_id,
191        repo = finding.repo,
192        path = finding.path,
193        line = finding.line.map(|n| format!(":{n}")).unwrap_or_default(),
194        rule = finding.rule,
195        cap = finding.cap,
196        sev = finding.severity,
197        status = finding.status,
198        origin = finding.finding_origin,
199        first_seen = finding.first_seen,
200        last_seen = finding.last_seen,
201        payload = payload_block,
202        repro_sh = REPRO_SCRIPT_FILENAME,
203        payload_bin = PAYLOAD_FILENAME,
204        expected_verdict = EXPECTED_VERDICT_FILENAME,
205        expected_trace = EXPECTED_TRACE_FILENAME,
206    )
207}
208
209fn render_repro_script(finding: &FindingRecord, payload: Option<&PayloadRecord>) -> String {
210    let cap_raw = payload.map(|p| p.cap.as_str()).unwrap_or(finding.cap.as_str());
211    let lang_raw = payload.map(|p| p.lang.as_str()).unwrap_or("unknown");
212    let id = sanitize_for_shell_literal(&finding.id);
213    let cap = sanitize_for_shell_literal(cap_raw);
214    let lang = sanitize_for_shell_literal(lang_raw);
215    let rule = sanitize_for_shell_literal(&finding.rule);
216    let bundle_dir = "$(cd \"$(dirname \"$0\")\" && pwd)";
217    format!(
218        "#!/usr/bin/env bash\n\
219         # Auto-generated repro for finding {id}.\n\
220         # cap={cap} lang={lang} rule={rule}\n\
221         set -euo pipefail\n\
222         BUNDLE_DIR={bundle_dir}\n\
223         PAYLOAD=\"$BUNDLE_DIR/{payload_bin}\"\n\
224         EXPECTED_VERDICT=\"$BUNDLE_DIR/{expected_verdict}\"\n\
225         EXPECTED_TRACE=\"$BUNDLE_DIR/{expected_trace}\"\n\
226         echo \"[repro] finding={id} cap={cap}\"\n\
227         echo \"[repro] payload bytes:\"\n\
228         wc -c \"$PAYLOAD\" || true\n\
229         echo \"[repro] expected verdict:\"\n\
230         cat \"$EXPECTED_VERDICT\"\n\
231         echo\n\
232         echo \"[repro] AI trace tail:\"\n\
233         tail -n 20 \"$EXPECTED_TRACE\" || true\n\
234         echo \"[repro] replay surface placeholder - wire the sandbox verifier here.\"\n\
235         echo \"[repro] exit 0 means the script ran to completion; verifier wiring lands with the chain-lane runner.\"\n\
236         exit 0\n",
237        id = id,
238        cap = cap,
239        lang = lang,
240        rule = rule,
241        bundle_dir = bundle_dir,
242        payload_bin = PAYLOAD_FILENAME,
243        expected_verdict = EXPECTED_VERDICT_FILENAME,
244        expected_trace = EXPECTED_TRACE_FILENAME,
245    )
246}
247
248/// Whitelist-escape an AI-controlled or scanner-controlled field before
249/// inlining it into the repro shell script. Any byte outside the
250/// conservative `[A-Za-z0-9._:/-]` set collapses to `_`, and the result
251/// is truncated to `MAX_INTERP_LEN` bytes. The fields this guards
252/// (`finding.id`, `cap`, `lang`, `rule`) all live inside a `# comment`
253/// line and a double-quoted `echo`, so a newline / backtick / `$()`
254/// would otherwise break out and run arbitrary shell. The replacement
255/// is lossy on purpose: any legitimate identifier already fits the
256/// whitelist, and a sanitiser that preserved exotic bytes via escapes
257/// would re-introduce the parser ambiguity it set out to remove.
258fn sanitize_for_shell_literal(s: &str) -> String {
259    const MAX_INTERP_LEN: usize = 96;
260    let mut out = String::with_capacity(s.len().min(MAX_INTERP_LEN));
261    for b in s.bytes().take(MAX_INTERP_LEN) {
262        let ok = b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'/' | b'-');
263        out.push(if ok { b as char } else { '_' });
264    }
265    if out.is_empty() {
266        out.push('_');
267    }
268    out
269}
270
271fn render_expected_verdict(finding: &FindingRecord) -> String {
272    let blob = finding
273        .verdict_blob
274        .as_deref()
275        .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
276        .unwrap_or(serde_json::Value::Null);
277    serde_json::to_string_pretty(&serde_json::json!({
278        "finding_id": finding.id,
279        "status": finding.status,
280        "attack_provenance": finding.attack_provenance,
281        "verdict_blob": blob,
282    }))
283    .expect("serialize expected verdict")
284}
285
286fn render_expected_trace(traces: &[AgentTraceRecord]) -> String {
287    let mut out = String::new();
288    for t in traces {
289        let v = serde_json::json!({
290            "id": t.id,
291            "task_kind": t.task_kind,
292            "runtime_name": t.runtime_name,
293            "model": t.model,
294            "prompt_version": t.prompt_version,
295            "tokens_in": t.tokens_in,
296            "tokens_out": t.tokens_out,
297            "cost_usd_micros": t.cost_usd_micros,
298            "duration_ms": t.duration_ms,
299            "started_at": t.started_at,
300            "finished_at": t.finished_at,
301        });
302        out.push_str(&v.to_string());
303        out.push('\n');
304    }
305    out
306}
307
308fn build_ustar(
309    finding_id: &str,
310    artifacts: &[BundleArtifact],
311    now_ms: i64,
312) -> Result<Vec<u8>, BundleError> {
313    let mtime = (now_ms / 1_000).max(0) as u64;
314    let mut builder = tar::Builder::new(Vec::with_capacity(4096));
315    // Reproducible: omit owner/group strings, deterministic uid/gid 0,
316    // and write entries in a fixed order so the sha256 the API stamps
317    // on repro_bundles.sha256 is stable across writer hosts.
318    for dir in collect_directories(finding_id, artifacts) {
319        let mut header = tar::Header::new_ustar();
320        header.set_path(&dir).map_err(BundleError::Tar)?;
321        header.set_mode(0o755);
322        header.set_uid(0);
323        header.set_gid(0);
324        header.set_size(0);
325        header.set_mtime(mtime);
326        header.set_entry_type(tar::EntryType::Directory);
327        header.set_cksum();
328        builder.append(&header, std::io::empty()).map_err(BundleError::Tar)?;
329    }
330    for a in artifacts {
331        let entry_path = format!("{finding_id}/{}", a.path);
332        let mut header = tar::Header::new_ustar();
333        // set_path falls back to PAX extension for paths longer than
334        // the 100-byte USTAR name field; the extractor crate handles
335        // PAX records transparently.
336        header.set_path(&entry_path).map_err(BundleError::Tar)?;
337        header.set_mode(a.mode);
338        header.set_uid(0);
339        header.set_gid(0);
340        header.set_size(a.contents.len() as u64);
341        header.set_mtime(mtime);
342        header.set_entry_type(tar::EntryType::Regular);
343        header.set_cksum();
344        builder.append(&header, a.contents.as_slice()).map_err(BundleError::Tar)?;
345    }
346    builder.finish().map_err(BundleError::Tar)?;
347    builder.into_inner().map_err(BundleError::Tar)
348}
349
350fn collect_directories(finding_id: &str, artifacts: &[BundleArtifact]) -> Vec<String> {
351    let mut dirs: BTreeMap<String, ()> = BTreeMap::new();
352    dirs.insert(format!("{finding_id}/"), ());
353    for a in artifacts {
354        let mut acc = finding_id.to_string();
355        for component in a.path.split('/').collect::<Vec<_>>().iter().rev().skip(1).rev() {
356            acc.push('/');
357            acc.push_str(component);
358            let mut dir = acc.clone();
359            dir.push('/');
360            dirs.insert(dir, ());
361        }
362    }
363    dirs.into_keys().collect()
364}
365
366fn blake3_hex(bytes: &[u8]) -> String {
367    let hash = blake3::hash(bytes);
368    let mut s = String::with_capacity(64);
369    for b in hash.as_bytes() {
370        s.push_str(&format!("{b:02x}"));
371    }
372    s
373}
374
375/// Verify that `bytes` hashes (BLAKE3) to the `expected` hex digest
376/// stored on the matching `repro_bundles` row. The replay path calls
377/// this before extracting the tarball so a substituted bundle on disk
378/// does not silently exec under the daemon's identity.
379pub fn verify_sha256(bytes: &[u8], expected: &str) -> bool {
380    blake3_hex(bytes) == expected
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use crate::store::testutil::{
387        fresh_store, sample_finding, sample_payload, sample_repo, sample_run,
388    };
389    use crate::store::AgentTraceRecord;
390
391    async fn seed_finding(store: &Store) -> String {
392        store.repos().upsert(&sample_repo("repo")).await.expect("repo");
393        store.runs().insert(&sample_run("run-1")).await.expect("run");
394        let mut f = sample_finding("run-1", "repo", "src/a.py", "rule-1");
395        f.status = "Verified".to_string();
396        f.verdict_blob = Some(r#"{"kind":"VerifyResult","replay_stable":true}"#.to_string());
397        f.attack_provenance = Some("LlmSynthesised".to_string());
398        let fid = f.id.clone();
399        store.findings().upsert(&f).await.expect("finding");
400        let payload = sample_payload("p-1", &fid);
401        store.payloads().insert(&payload).await.expect("payload");
402        store
403            .agent_traces()
404            .insert(&AgentTraceRecord {
405                id: "trace-1".to_string(),
406                finding_id: Some(fid.clone()),
407                task_kind: "PayloadSynthesis".to_string(),
408                runtime_name: "anthropic".to_string(),
409                model: "claude-opus-4-7".to_string(),
410                prompt_version: Some("v1".to_string()),
411                conversation_jsonl_path: None,
412                tokens_in: 100,
413                tokens_out: 50,
414                cost_usd_micros: 5_000,
415                cache_hits: 0,
416                cache_misses: 1,
417                duration_ms: Some(500),
418                started_at: 5_000,
419                finished_at: Some(5_500),
420                verifier_blob: None,
421            })
422            .await
423            .expect("trace");
424        fid
425    }
426
427    #[tokio::test]
428    async fn build_bundle_writes_tar_and_persists_row() {
429        let (_tmp, store) = fresh_store().await;
430        let fid = seed_finding(&store).await;
431        let bundle_dir = tempfile::tempdir().expect("bundle dir");
432        let manifest = build_bundle(&store, &fid, bundle_dir.path(), 6_000).await.expect("bundle");
433
434        assert_eq!(manifest.finding_id, fid);
435        assert!(manifest.bundle_path.exists(), "tarball must exist on disk");
436        assert_eq!(manifest.byte_size, std::fs::metadata(&manifest.bundle_path).unwrap().len());
437        assert!(manifest.artifacts.contains(&REPRO_SCRIPT_FILENAME.to_string()));
438        assert!(manifest.artifacts.contains(&PAYLOAD_FILENAME.to_string()));
439        assert!(manifest.artifacts.contains(&EXPECTED_VERDICT_FILENAME.to_string()));
440        assert!(manifest.artifacts.contains(&EXPECTED_TRACE_FILENAME.to_string()));
441        assert!(manifest.artifacts.contains(&README_FILENAME.to_string()));
442
443        // Persisted row matches the on-disk bundle.
444        let rows = store.repro_bundles().list_for_finding(&fid).await.expect("rows");
445        assert_eq!(rows.len(), 1);
446        assert_eq!(rows[0].sha256, manifest.sha256);
447        assert_eq!(rows[0].path, manifest.bundle_path.display().to_string());
448    }
449
450    #[tokio::test]
451    async fn build_bundle_tarball_contains_finding_paths() {
452        let (_tmp, store) = fresh_store().await;
453        let fid = seed_finding(&store).await;
454        let bundle_dir = tempfile::tempdir().expect("bundle dir");
455        let manifest = build_bundle(&store, &fid, bundle_dir.path(), 8_000).await.expect("bundle");
456        let bytes = std::fs::read(&manifest.bundle_path).expect("read tar");
457        let mut archive = tar::Archive::new(std::io::Cursor::new(&bytes));
458        let mut names: Vec<String> = Vec::new();
459        for entry in archive.entries().expect("entries") {
460            let entry = entry.expect("entry");
461            let path = entry.path().expect("path").display().to_string();
462            names.push(path);
463        }
464        assert!(
465            names.iter().any(|n| n.ends_with("/repro.sh")),
466            "expected repro.sh entry: {names:?}"
467        );
468        assert!(
469            names.iter().any(|n| n.ends_with("/payload.bin")),
470            "expected payload.bin entry: {names:?}"
471        );
472        assert!(
473            names.iter().any(|n| n.ends_with("/expected/verdict.json")),
474            "expected verdict entry: {names:?}"
475        );
476    }
477
478    #[tokio::test]
479    async fn build_bundle_missing_finding_errors() {
480        let (_tmp, store) = fresh_store().await;
481        let bundle_dir = tempfile::tempdir().expect("bundle dir");
482        let err = build_bundle(&store, "ghost", bundle_dir.path(), 0).await.expect_err("missing");
483        assert!(matches!(err, BundleError::FindingNotFound(_)));
484    }
485
486    #[test]
487    fn sanitize_for_shell_literal_collapses_dangerous_bytes() {
488        // Newline would break out of `# comment` and `echo "..."` lines.
489        assert_eq!(sanitize_for_shell_literal("safe-id_123"), "safe-id_123");
490        assert_eq!(sanitize_for_shell_literal("with\nnewline"), "with_newline");
491        assert_eq!(sanitize_for_shell_literal("`backtick`"), "_backtick_");
492        assert_eq!(sanitize_for_shell_literal("$(rm -rf ~)"), "__rm_-rf___");
493        assert_eq!(sanitize_for_shell_literal("\"quoted\""), "_quoted_");
494        assert_eq!(sanitize_for_shell_literal("a;b|c&d"), "a_b_c_d");
495        // Length is capped to keep the comment line readable.
496        let long = "a".repeat(200);
497        assert_eq!(sanitize_for_shell_literal(&long).len(), 96);
498        // Empty / all-illegal inputs collapse to a single underscore so
499        // the resulting `cap=` literal is never empty.
500        assert_eq!(sanitize_for_shell_literal(""), "_");
501        assert_eq!(sanitize_for_shell_literal("!!!"), "___");
502    }
503
504    #[test]
505    fn render_repro_script_escapes_injection_attempts() {
506        let mut finding = sample_finding("run-1", "repo", "src/a.py", "rule-1");
507        // The id field is set by the constructor (hash); replace with
508        // an attacker-controlled value to lock the defense-in-depth
509        // sanitisation of finding.id specifically.
510        finding.id = "fid\ninjected-comment\nrm -rf /".to_string();
511        finding.cap = "$(id)".to_string();
512        finding.rule = "rule\"; rm -rf /; #".to_string();
513        let mut payload = sample_payload("pid", &finding.id);
514        payload.cap = "OS_COMMAND`whoami`".to_string();
515        payload.lang = "py;exit".to_string();
516
517        let script = render_repro_script(&finding, Some(&payload));
518        for substr in ["rm -rf /", "$(id)", "`whoami`", "; #", "\"; ", "py;exit"] {
519            assert!(
520                !script.contains(substr),
521                "raw injection {substr:?} leaked into script: {script}"
522            );
523        }
524        // The sanitised forms (underscores) appear instead.
525        assert!(script.contains("cap=OS_COMMAND_whoami_"), "cap not sanitised: {script}");
526        assert!(script.contains("lang=py_exit"), "lang not sanitised: {script}");
527    }
528}