1use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::core::{ConditionsRecord, RunRecord, ToolInvocation};
18use crate::pipeline::error::PipelineError;
19use crate::pipeline::io::{now_iso8601, write_json};
20use crate::pipeline::slots::{run_key, run_slots};
21use crate::sandbox::{WRITE_TOOLS, classify_bash, is_under, path_arg};
22use crate::validation::{SchemaName, validate_against_schema};
23
24const SHELL_TOOLS: [&str; 2] = ["Bash", "command_execution"];
26const READ_TOOLS: [&str; 3] = ["Read", "Glob", "Grep"];
28
29fn is_file_write_tool(name: &str) -> bool {
31 WRITE_TOOLS.contains(&name) || name == "file_change"
32}
33
34const LIVE_SOURCE_REASON: &str =
35 "reads the live skill source instead of its staged copy — the arm may be contaminated";
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct StrayFinding {
41 pub tool: String,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub path: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub command: Option<String>,
46 pub ordinal: u32,
47 pub reason: String,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct RunFindings {
53 pub violations: Vec<StrayFinding>,
54 pub warnings: Vec<StrayFinding>,
55}
56
57fn command_of(inv: &ToolInvocation) -> &str {
59 inv.args
60 .as_ref()
61 .and_then(|a| a.get("command"))
62 .and_then(|v| v.as_str())
63 .unwrap_or("")
64}
65
66pub fn detect_stray_writes(
69 invocations: &[ToolInvocation],
70 outputs_dir: &str,
71 repo_root: &Path,
72) -> RunFindings {
73 let mut findings = RunFindings::default();
74
75 for inv in invocations {
76 if is_file_write_tool(&inv.name) {
77 if let Some(p) = inv.args.as_ref().and_then(path_arg)
78 && !is_under(p, outputs_dir, repo_root)
79 {
80 findings.violations.push(StrayFinding {
81 tool: inv.name.clone(),
82 path: Some(p.to_string()),
83 command: None,
84 ordinal: inv.ordinal,
85 reason: "writes outside the run's outputs dir".to_string(),
86 });
87 }
88 continue;
89 }
90
91 if SHELL_TOOLS.contains(&inv.name.as_str()) {
92 let command = command_of(inv);
93 if let Some(reason) =
94 classify_bash(command, std::slice::from_ref(&outputs_dir.to_string()))
95 {
96 findings.warnings.push(StrayFinding {
97 tool: inv.name.clone(),
98 path: None,
99 command: Some(command.to_string()),
100 ordinal: inv.ordinal,
101 reason: reason.to_string(),
102 });
103 }
104 }
105 }
106
107 findings
108}
109
110fn absolutize(p: &Path) -> PathBuf {
112 std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
113}
114
115fn path_relative(from: &Path, to: &Path) -> String {
119 let from_comps: Vec<_> = from.components().collect();
120 let to_comps: Vec<_> = to.components().collect();
121 let mut i = 0;
122 while i < from_comps.len() && i < to_comps.len() && from_comps[i] == to_comps[i] {
123 i += 1;
124 }
125 let mut parts: Vec<String> = vec!["..".to_string(); from_comps.len() - i];
126 for c in &to_comps[i..] {
127 parts.push(c.as_os_str().to_string_lossy().into_owned());
128 }
129 parts.join("/")
130}
131
132fn is_leading_boundary(b: u8) -> bool {
135 b.is_ascii_whitespace() || matches!(b, b'\'' | b'"' | b'=' | b':' | b'(' | b'/')
136}
137
138fn is_trailing_boundary(b: u8) -> bool {
141 b == b'/' || b.is_ascii_whitespace() || matches!(b, b'\'' | b'"' | b')')
142}
143
144fn references_bare_rel(command: &str, rel: &str) -> bool {
149 if rel.is_empty() {
150 return false;
151 }
152 let bytes = command.as_bytes();
153 let mut search_from = 0;
154 while let Some(off) = command[search_from..].find(rel) {
155 let start = search_from + off;
156 let end = start + rel.len();
157
158 let leading_ok = start == 0 || is_leading_boundary(bytes[start - 1]);
159 let lookbehind_ok = start == 0 || {
162 let before = &command[..start - 1];
163 !before.ends_with(".claude") && !before.ends_with(".agents")
164 };
165 let trailing_ok = end == command.len() || is_trailing_boundary(bytes[end]);
166
167 if leading_ok && lookbehind_ok && trailing_ok {
168 return true;
169 }
170 search_from = start + 1;
171 }
172 false
173}
174
175pub fn detect_live_source_reads(
179 invocations: &[ToolInvocation],
180 live_skill_dir: &Path,
181 repo_root: &Path,
182) -> Vec<StrayFinding> {
183 let mut findings = Vec::new();
184 let live_dir = absolutize(live_skill_dir);
185 let live_dir_str = live_dir.to_string_lossy();
186 let rel = path_relative(repo_root, &live_dir);
187 let rel_usable = !rel.starts_with("..");
188
189 for inv in invocations {
190 if READ_TOOLS.contains(&inv.name.as_str()) {
191 if let Some(p) = inv.args.as_ref().and_then(path_arg)
192 && is_under(p, &live_dir_str, repo_root)
193 {
194 findings.push(StrayFinding {
195 tool: inv.name.clone(),
196 path: Some(p.to_string()),
197 command: None,
198 ordinal: inv.ordinal,
199 reason: LIVE_SOURCE_REASON.to_string(),
200 });
201 }
202 continue;
203 }
204
205 if SHELL_TOOLS.contains(&inv.name.as_str()) {
206 let command = command_of(inv);
207 if command.contains(live_dir_str.as_ref())
208 || (rel_usable && references_bare_rel(command, &rel))
209 {
210 findings.push(StrayFinding {
211 tool: inv.name.clone(),
212 path: None,
213 command: Some(command.to_string()),
214 ordinal: inv.ordinal,
215 reason: LIVE_SOURCE_REASON.to_string(),
216 });
217 }
218 }
219 }
220
221 findings
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
228pub struct RunReport {
229 pub eval_id: String,
230 pub condition: String,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub run_index: Option<u32>,
234 pub violations: Vec<StrayFinding>,
235 pub warnings: Vec<StrayFinding>,
236 pub live_source_reads: Vec<StrayFinding>,
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
241pub struct Totals {
242 pub violations: usize,
243 pub warnings: usize,
244 pub live_source_reads: usize,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
249pub struct StrayWritesReport {
250 pub generated: String,
251 pub iteration: u32,
252 pub totals: Totals,
253 pub runs: Vec<RunReport>,
254 #[serde(skip)]
258 pub invocations_inspected: usize,
259}
260
261#[derive(Debug, Deserialize)]
263struct DispatchEnvelope {
264 tasks: Option<Vec<DispatchRef>>,
265}
266
267#[derive(Debug, Deserialize)]
268struct DispatchRef {
269 eval_id: String,
270 condition: String,
271 #[serde(default)]
272 run_index: Option<u32>,
273 #[serde(default)]
274 outputs_dir: Option<String>,
275}
276
277pub fn detect_stray_writes_report(
281 iteration_dir: &Path,
282 iteration: u32,
283 live_skill_dir: &Path,
284 repo_root: &Path,
285) -> Result<StrayWritesReport, PipelineError> {
286 let conditions_path = iteration_dir.join("conditions.json");
287 if !conditions_path.exists() {
288 return Err(PipelineError::Message(format!(
289 "missing: {}",
290 conditions_path.display()
291 )));
292 }
293 let conditions: ConditionsRecord =
294 serde_json::from_str(&std::fs::read_to_string(&conditions_path)?)?;
295 let condition_names: Vec<String> = conditions
296 .conditions
297 .iter()
298 .map(|c| c.name.clone())
299 .collect();
300
301 let outputs_by_key = outputs_dirs_by_key(iteration_dir);
302
303 let mut runs = Vec::new();
304 let mut totals = Totals {
305 violations: 0,
306 warnings: 0,
307 live_source_reads: 0,
308 };
309 let mut invocations_inspected = 0usize;
310
311 let mut eval_dirs: Vec<String> = std::fs::read_dir(iteration_dir)?
312 .flatten()
313 .filter_map(|e| {
314 let name = e.file_name().to_string_lossy().into_owned();
315 name.starts_with("eval-").then_some(name)
316 })
317 .collect();
318 eval_dirs.sort();
319
320 for dir_name in &eval_dirs {
321 let eval_id = dir_name.strip_prefix("eval-").unwrap_or(dir_name);
322 for cond in &condition_names {
323 let cond_dir = iteration_dir.join(dir_name).join(cond);
324 for slot in run_slots(&cond_dir) {
325 let run_path = slot.dir.join("run.json");
326 if !run_path.exists() {
327 continue;
328 }
329 let source = run_path.to_string_lossy();
330 let run: RunRecord = validate_against_schema(
331 SchemaName::RunRecord,
332 &serde_json::from_str(&std::fs::read_to_string(&run_path)?)?,
333 &source,
334 )?;
335
336 let outputs_dir = outputs_by_key
337 .get(&run_key(eval_id, cond, slot.run_index))
338 .cloned()
339 .unwrap_or_else(|| slot.dir.join("outputs").to_string_lossy().into_owned());
340
341 invocations_inspected += run.tool_invocations.len();
342 let findings = detect_stray_writes(&run.tool_invocations, &outputs_dir, repo_root);
343 let live_reads =
344 detect_live_source_reads(&run.tool_invocations, live_skill_dir, repo_root);
345
346 totals.violations += findings.violations.len();
347 totals.warnings += findings.warnings.len();
348 totals.live_source_reads += live_reads.len();
349
350 if !findings.violations.is_empty()
351 || !findings.warnings.is_empty()
352 || !live_reads.is_empty()
353 {
354 runs.push(RunReport {
355 eval_id: eval_id.to_string(),
356 condition: cond.clone(),
357 run_index: slot.run_index,
358 violations: findings.violations,
359 warnings: findings.warnings,
360 live_source_reads: live_reads,
361 });
362 }
363 }
364 }
365 }
366
367 let report = StrayWritesReport {
368 generated: now_iso8601(),
369 iteration,
370 totals,
371 runs,
372 invocations_inspected,
373 };
374
375 let out_path = iteration_dir.join("stray-writes.json");
376 validate_against_schema::<serde_json::Value>(
377 SchemaName::StrayWrites,
378 &serde_json::to_value(&report)?,
379 &out_path.to_string_lossy(),
380 )?;
381 write_json(&out_path, &report)?;
382
383 Ok(report)
384}
385
386fn outputs_dirs_by_key(iteration_dir: &Path) -> std::collections::HashMap<String, String> {
390 let mut out = std::collections::HashMap::new();
391 if let Ok(raw) = std::fs::read_to_string(iteration_dir.join("dispatch.json"))
392 && let Ok(env) = serde_json::from_str::<DispatchEnvelope>(&raw)
393 {
394 for t in env.tasks.unwrap_or_default() {
395 if let Some(dir) = t.outputs_dir {
396 out.insert(run_key(&t.eval_id, &t.condition, t.run_index), dir);
397 }
398 }
399 }
400 out
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use serde_json::json;
407
408 const OUTPUTS: &str = "/work/iteration-1/eval-x/with_skill/outputs";
409 const REPO: &str = "/work/repo";
410 const LIVE_SKILL: &str = "/work/repo/skills/mr-review";
411
412 fn inv(name: &str, args: serde_json::Value, ordinal: u32) -> ToolInvocation {
414 ToolInvocation {
415 name: name.to_string(),
416 args: Some(args),
417 result: None,
418 ordinal,
419 }
420 }
421
422 fn repo() -> &'static Path {
423 Path::new(REPO)
424 }
425
426 fn live() -> &'static Path {
427 Path::new(LIVE_SKILL)
428 }
429
430 #[test]
433 fn a_write_inside_outputs_is_clean() {
434 let f = detect_stray_writes(
435 &[inv(
436 "Write",
437 json!({"file_path": format!("{OUTPUTS}/answer.md")}),
438 0,
439 )],
440 OUTPUTS,
441 repo(),
442 );
443 assert!(f.violations.is_empty());
444 assert!(f.warnings.is_empty());
445 }
446
447 #[test]
448 fn a_write_outside_outputs_is_a_violation() {
449 let f = detect_stray_writes(
450 &[inv(
451 "Write",
452 json!({"file_path": format!("{REPO}/runner/run.ts")}),
453 2,
454 )],
455 OUTPUTS,
456 repo(),
457 );
458 assert_eq!(f.violations.len(), 1);
459 assert_eq!(f.violations[0].tool, "Write");
460 assert_eq!(
461 f.violations[0].path.as_deref(),
462 Some(&*format!("{REPO}/runner/run.ts"))
463 );
464 assert_eq!(f.violations[0].ordinal, 2);
465 }
466
467 #[test]
468 fn edit_multiedit_notebookedit_outside_outputs_is_a_violation() {
469 let f = detect_stray_writes(
470 &[
471 inv("Edit", json!({"file_path": "/etc/hosts"}), 0),
472 inv("NotebookEdit", json!({"notebook_path": "/tmp/x.ipynb"}), 1),
473 ],
474 OUTPUTS,
475 repo(),
476 );
477 let mut tools: Vec<&str> = f.violations.iter().map(|v| v.tool.as_str()).collect();
478 tools.sort();
479 assert_eq!(tools, vec!["Edit", "NotebookEdit"]);
480 }
481
482 #[test]
483 fn an_install_command_is_a_warning() {
484 let f = detect_stray_writes(
485 &[inv("Bash", json!({"command": "npm install left-pad"}), 0)],
486 OUTPUTS,
487 repo(),
488 );
489 assert_eq!(f.warnings.len(), 1);
490 assert_eq!(f.warnings[0].tool, "Bash");
491 assert!(f.warnings[0].reason.to_lowercase().contains("install"));
492 }
493
494 #[test]
495 fn a_codex_command_execution_install_is_a_warning() {
496 let f = detect_stray_writes(
497 &[inv(
498 "command_execution",
499 json!({"command": "npm install left-pad"}),
500 0,
501 )],
502 OUTPUTS,
503 repo(),
504 );
505 assert_eq!(f.warnings.len(), 1);
506 assert_eq!(f.warnings[0].tool, "command_execution");
507 assert!(f.warnings[0].reason.to_lowercase().contains("install"));
508 }
509
510 #[test]
511 fn a_codex_file_change_outside_outputs_is_a_violation() {
512 let f = detect_stray_writes(
513 &[inv(
514 "file_change",
515 json!({"path": format!("{REPO}/src/app.ts")}),
516 4,
517 )],
518 OUTPUTS,
519 repo(),
520 );
521 assert_eq!(f.violations.len(), 1);
522 assert_eq!(f.violations[0].tool, "file_change");
523 assert_eq!(
524 f.violations[0].path.as_deref(),
525 Some(&*format!("{REPO}/src/app.ts"))
526 );
527 assert_eq!(f.violations[0].ordinal, 4);
528 }
529
530 #[test]
531 fn a_mutating_bash_scoped_to_outputs_is_not_flagged() {
532 let f = detect_stray_writes(
533 &[inv(
534 "Bash",
535 json!({"command": format!("echo hi > {OUTPUTS}/log.txt")}),
536 0,
537 )],
538 OUTPUTS,
539 repo(),
540 );
541 assert!(f.warnings.is_empty());
542 }
543
544 #[test]
545 fn git_worktree_add_is_a_warning() {
546 let f = detect_stray_writes(
547 &[inv(
548 "Bash",
549 json!({"command": "git worktree add ../wt -b scratch"}),
550 0,
551 )],
552 OUTPUTS,
553 repo(),
554 );
555 assert_eq!(f.warnings.len(), 1);
556 assert!(f.warnings[0].reason.to_lowercase().contains("worktree"));
557 }
558
559 #[test]
560 fn creating_a_path_under_dot_claude_is_a_warning() {
561 let f = detect_stray_writes(
562 &[inv("Bash", json!({"command": "mkdir -p .claude/foo"}), 0)],
563 OUTPUTS,
564 repo(),
565 );
566 assert_eq!(f.warnings.len(), 1);
567 assert!(f.warnings[0].reason.to_lowercase().contains(".claude"));
568 }
569
570 #[test]
571 fn read_only_tools_are_never_flagged() {
572 let f = detect_stray_writes(
573 &[
574 inv("Read", json!({"file_path": "/anywhere"}), 0),
575 inv("Grep", json!({"pattern": "x"}), 1),
576 inv("Bash", json!({"command": "ls -la /"}), 2),
577 ],
578 OUTPUTS,
579 repo(),
580 );
581 assert!(f.violations.is_empty());
582 assert!(f.warnings.is_empty());
583 }
584
585 #[test]
588 fn a_read_of_the_live_skill_md_is_flagged() {
589 let f = detect_live_source_reads(
590 &[inv(
591 "Read",
592 json!({"file_path": format!("{LIVE_SKILL}/SKILL.md")}),
593 1,
594 )],
595 live(),
596 repo(),
597 );
598 assert_eq!(f.len(), 1);
599 assert_eq!(f[0].tool, "Read");
600 assert_eq!(
601 f[0].path.as_deref(),
602 Some(&*format!("{LIVE_SKILL}/SKILL.md"))
603 );
604 assert_eq!(f[0].ordinal, 1);
605 assert!(f[0].reason.to_lowercase().contains("live skill source"));
606 }
607
608 #[test]
609 fn a_read_of_a_staged_eval_copy_is_not_flagged() {
610 let f = detect_live_source_reads(
611 &[inv(
612 "Read",
613 json!({"file_path": format!("{REPO}/.claude/skills/slow-powers-eval-1-old_skill__mr-review/SKILL.md")}),
614 0,
615 )],
616 live(),
617 repo(),
618 );
619 assert!(f.is_empty());
620 }
621
622 #[test]
623 fn a_relative_read_resolving_under_the_live_dir_is_flagged() {
624 let f = detect_live_source_reads(
625 &[inv(
626 "Read",
627 json!({"file_path": "skills/mr-review/SKILL.md"}),
628 0,
629 )],
630 live(),
631 repo(),
632 );
633 assert_eq!(f.len(), 1);
634 }
635
636 #[test]
637 fn a_grep_scoped_to_the_live_dir_is_flagged() {
638 let f = detect_live_source_reads(
639 &[inv("Grep", json!({"pattern": "x", "path": LIVE_SKILL}), 2)],
640 live(),
641 repo(),
642 );
643 assert_eq!(f.len(), 1);
644 assert_eq!(f[0].tool, "Grep");
645 }
646
647 #[test]
648 fn a_bash_referencing_the_live_dir_relatively_is_flagged() {
649 let f = detect_live_source_reads(
650 &[inv(
651 "Bash",
652 json!({"command": "cat skills/mr-review/SKILL.md"}),
653 3,
654 )],
655 live(),
656 repo(),
657 );
658 assert_eq!(f.len(), 1);
659 assert_eq!(f[0].tool, "Bash");
660 assert_eq!(
661 f[0].command.as_deref(),
662 Some("cat skills/mr-review/SKILL.md")
663 );
664 }
665
666 #[test]
667 fn a_codex_command_referencing_the_live_dir_relatively_is_flagged() {
668 let f = detect_live_source_reads(
669 &[inv(
670 "command_execution",
671 json!({"command": "cat skills/mr-review/SKILL.md"}),
672 3,
673 )],
674 live(),
675 repo(),
676 );
677 assert_eq!(f.len(), 1);
678 assert_eq!(f[0].tool, "command_execution");
679 assert_eq!(
680 f[0].command.as_deref(),
681 Some("cat skills/mr-review/SKILL.md")
682 );
683 }
684
685 #[test]
686 fn a_bash_referencing_the_live_dir_absolutely_is_flagged() {
687 let f = detect_live_source_reads(
688 &[inv(
689 "Bash",
690 json!({"command": format!("grep -r trigger {LIVE_SKILL}/")}),
691 0,
692 )],
693 live(),
694 repo(),
695 );
696 assert_eq!(f.len(), 1);
697 }
698
699 #[test]
700 fn a_bash_referencing_a_staged_copy_under_dot_claude_skills_is_not_flagged() {
701 let f = detect_live_source_reads(
702 &[inv(
703 "Bash",
704 json!({"command": "cat .claude/skills/mr-review/SKILL.md"}),
705 0,
706 )],
707 live(),
708 repo(),
709 );
710 assert!(f.is_empty());
711 }
712
713 #[test]
714 fn a_bash_referencing_a_staged_copy_under_dot_agents_skills_is_not_flagged() {
715 let f = detect_live_source_reads(
716 &[inv(
717 "Bash",
718 json!({"command": "cat .agents/skills/mr-review/SKILL.md"}),
719 0,
720 )],
721 live(),
722 repo(),
723 );
724 assert!(f.is_empty());
725 }
726
727 #[test]
728 fn unrelated_reads_and_commands_are_not_flagged() {
729 let f = detect_live_source_reads(
730 &[
731 inv("Read", json!({"file_path": format!("{OUTPUTS}/x.md")}), 0),
732 inv("Bash", json!({"command": "ls skills-workspace"}), 1),
733 inv(
735 "Write",
736 json!({"file_path": format!("{LIVE_SKILL}/SKILL.md")}),
737 2,
738 ),
739 ],
740 live(),
741 repo(),
742 );
743 assert!(f.is_empty());
744 }
745}