1use 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#[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
70pub 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
248fn 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 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 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
375pub 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 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 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 let long = "a".repeat(200);
497 assert_eq!(sanitize_for_shell_literal(&long).len(), 96);
498 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 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 assert!(script.contains("cap=OS_COMMAND_whoami_"), "cap not sanitised: {script}");
526 assert!(script.contains("lang=py_exit"), "lang not sanitised: {script}");
527 }
528}