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.get(&run_key(eval_id, cond, slot.run_index));
337
338 invocations_inspected += run.tool_invocations.len();
339 let findings = match outputs_dir {
348 Some(dir) => detect_stray_writes(&run.tool_invocations, dir, repo_root),
349 None => {
350 let run_label = slot
351 .run_index
352 .map(|k| format!(" run-{k}"))
353 .unwrap_or_default();
354 eprintln!(
355 "⚠ {eval_id}/{cond}{run_label}: no outputs_dir in dispatch.json — \
356 skipping out-of-bounds write classification (boundary unknown)"
357 );
358 RunFindings::default()
359 }
360 };
361 let live_reads =
362 detect_live_source_reads(&run.tool_invocations, live_skill_dir, repo_root);
363
364 totals.violations += findings.violations.len();
365 totals.warnings += findings.warnings.len();
366 totals.live_source_reads += live_reads.len();
367
368 if !findings.violations.is_empty()
369 || !findings.warnings.is_empty()
370 || !live_reads.is_empty()
371 {
372 runs.push(RunReport {
373 eval_id: eval_id.to_string(),
374 condition: cond.clone(),
375 run_index: slot.run_index,
376 violations: findings.violations,
377 warnings: findings.warnings,
378 live_source_reads: live_reads,
379 });
380 }
381 }
382 }
383 }
384
385 let report = StrayWritesReport {
386 generated: now_iso8601(),
387 iteration,
388 totals,
389 runs,
390 invocations_inspected,
391 };
392
393 let out_path = iteration_dir.join("stray-writes.json");
394 validate_against_schema::<serde_json::Value>(
395 SchemaName::StrayWrites,
396 &serde_json::to_value(&report)?,
397 &out_path.to_string_lossy(),
398 )?;
399 write_json(&out_path, &report)?;
400
401 Ok(report)
402}
403
404fn outputs_dirs_by_key(iteration_dir: &Path) -> std::collections::HashMap<String, String> {
408 let mut out = std::collections::HashMap::new();
409 if let Ok(raw) = std::fs::read_to_string(iteration_dir.join("dispatch.json"))
410 && let Ok(env) = serde_json::from_str::<DispatchEnvelope>(&raw)
411 {
412 for t in env.tasks.unwrap_or_default() {
413 if let Some(dir) = t.outputs_dir {
414 out.insert(run_key(&t.eval_id, &t.condition, t.run_index), dir);
415 }
416 }
417 }
418 out
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use serde_json::json;
425
426 const OUTPUTS: &str = "/work/iteration-1/eval-x/with_skill/outputs";
427 const REPO: &str = "/work/repo";
428 const LIVE_SKILL: &str = "/work/repo/skills/mr-review";
429
430 fn inv(name: &str, args: serde_json::Value, ordinal: u32) -> ToolInvocation {
432 ToolInvocation {
433 name: name.to_string(),
434 args: Some(args),
435 result: None,
436 ordinal,
437 }
438 }
439
440 fn repo() -> &'static Path {
441 Path::new(REPO)
442 }
443
444 fn live() -> &'static Path {
445 Path::new(LIVE_SKILL)
446 }
447
448 #[test]
451 fn a_write_inside_outputs_is_clean() {
452 let f = detect_stray_writes(
453 &[inv(
454 "Write",
455 json!({"file_path": format!("{OUTPUTS}/answer.md")}),
456 0,
457 )],
458 OUTPUTS,
459 repo(),
460 );
461 assert!(f.violations.is_empty());
462 assert!(f.warnings.is_empty());
463 }
464
465 #[test]
466 fn a_write_outside_outputs_is_a_violation() {
467 let f = detect_stray_writes(
468 &[inv(
469 "Write",
470 json!({"file_path": format!("{REPO}/runner/run.ts")}),
471 2,
472 )],
473 OUTPUTS,
474 repo(),
475 );
476 assert_eq!(f.violations.len(), 1);
477 assert_eq!(f.violations[0].tool, "Write");
478 assert_eq!(
479 f.violations[0].path.as_deref(),
480 Some(&*format!("{REPO}/runner/run.ts"))
481 );
482 assert_eq!(f.violations[0].ordinal, 2);
483 }
484
485 #[test]
486 fn edit_multiedit_notebookedit_outside_outputs_is_a_violation() {
487 let f = detect_stray_writes(
488 &[
489 inv("Edit", json!({"file_path": "/etc/hosts"}), 0),
490 inv("NotebookEdit", json!({"notebook_path": "/tmp/x.ipynb"}), 1),
491 ],
492 OUTPUTS,
493 repo(),
494 );
495 let mut tools: Vec<&str> = f.violations.iter().map(|v| v.tool.as_str()).collect();
496 tools.sort();
497 assert_eq!(tools, vec!["Edit", "NotebookEdit"]);
498 }
499
500 #[test]
501 fn an_install_command_is_a_warning() {
502 let f = detect_stray_writes(
503 &[inv("Bash", json!({"command": "npm install left-pad"}), 0)],
504 OUTPUTS,
505 repo(),
506 );
507 assert_eq!(f.warnings.len(), 1);
508 assert_eq!(f.warnings[0].tool, "Bash");
509 assert!(f.warnings[0].reason.to_lowercase().contains("install"));
510 }
511
512 #[test]
513 fn a_codex_command_execution_install_is_a_warning() {
514 let f = detect_stray_writes(
515 &[inv(
516 "command_execution",
517 json!({"command": "npm install left-pad"}),
518 0,
519 )],
520 OUTPUTS,
521 repo(),
522 );
523 assert_eq!(f.warnings.len(), 1);
524 assert_eq!(f.warnings[0].tool, "command_execution");
525 assert!(f.warnings[0].reason.to_lowercase().contains("install"));
526 }
527
528 #[test]
529 fn a_codex_file_change_outside_outputs_is_a_violation() {
530 let f = detect_stray_writes(
531 &[inv(
532 "file_change",
533 json!({"path": format!("{REPO}/src/app.ts")}),
534 4,
535 )],
536 OUTPUTS,
537 repo(),
538 );
539 assert_eq!(f.violations.len(), 1);
540 assert_eq!(f.violations[0].tool, "file_change");
541 assert_eq!(
542 f.violations[0].path.as_deref(),
543 Some(&*format!("{REPO}/src/app.ts"))
544 );
545 assert_eq!(f.violations[0].ordinal, 4);
546 }
547
548 #[test]
549 fn a_mutating_bash_scoped_to_outputs_is_not_flagged() {
550 let f = detect_stray_writes(
551 &[inv(
552 "Bash",
553 json!({"command": format!("echo hi > {OUTPUTS}/log.txt")}),
554 0,
555 )],
556 OUTPUTS,
557 repo(),
558 );
559 assert!(f.warnings.is_empty());
560 }
561
562 #[test]
563 fn git_worktree_add_is_a_warning() {
564 let f = detect_stray_writes(
565 &[inv(
566 "Bash",
567 json!({"command": "git worktree add ../wt -b scratch"}),
568 0,
569 )],
570 OUTPUTS,
571 repo(),
572 );
573 assert_eq!(f.warnings.len(), 1);
574 assert!(f.warnings[0].reason.to_lowercase().contains("worktree"));
575 }
576
577 #[test]
578 fn creating_a_path_under_dot_claude_is_a_warning() {
579 let f = detect_stray_writes(
580 &[inv("Bash", json!({"command": "mkdir -p .claude/foo"}), 0)],
581 OUTPUTS,
582 repo(),
583 );
584 assert_eq!(f.warnings.len(), 1);
585 assert!(f.warnings[0].reason.to_lowercase().contains(".claude"));
586 }
587
588 #[test]
589 fn read_only_tools_are_never_flagged() {
590 let f = detect_stray_writes(
591 &[
592 inv("Read", json!({"file_path": "/anywhere"}), 0),
593 inv("Grep", json!({"pattern": "x"}), 1),
594 inv("Bash", json!({"command": "ls -la /"}), 2),
595 ],
596 OUTPUTS,
597 repo(),
598 );
599 assert!(f.violations.is_empty());
600 assert!(f.warnings.is_empty());
601 }
602
603 #[test]
606 fn a_read_of_the_live_skill_md_is_flagged() {
607 let f = detect_live_source_reads(
608 &[inv(
609 "Read",
610 json!({"file_path": format!("{LIVE_SKILL}/SKILL.md")}),
611 1,
612 )],
613 live(),
614 repo(),
615 );
616 assert_eq!(f.len(), 1);
617 assert_eq!(f[0].tool, "Read");
618 assert_eq!(
619 f[0].path.as_deref(),
620 Some(&*format!("{LIVE_SKILL}/SKILL.md"))
621 );
622 assert_eq!(f[0].ordinal, 1);
623 assert!(f[0].reason.to_lowercase().contains("live skill source"));
624 }
625
626 #[test]
627 fn a_read_of_a_staged_eval_copy_is_not_flagged() {
628 let f = detect_live_source_reads(
629 &[inv(
630 "Read",
631 json!({"file_path": format!("{REPO}/.claude/skills/slow-powers-eval-1-old_skill__mr-review/SKILL.md")}),
632 0,
633 )],
634 live(),
635 repo(),
636 );
637 assert!(f.is_empty());
638 }
639
640 #[test]
641 fn a_relative_read_resolving_under_the_live_dir_is_flagged() {
642 let f = detect_live_source_reads(
643 &[inv(
644 "Read",
645 json!({"file_path": "skills/mr-review/SKILL.md"}),
646 0,
647 )],
648 live(),
649 repo(),
650 );
651 assert_eq!(f.len(), 1);
652 }
653
654 #[test]
655 fn a_grep_scoped_to_the_live_dir_is_flagged() {
656 let f = detect_live_source_reads(
657 &[inv("Grep", json!({"pattern": "x", "path": LIVE_SKILL}), 2)],
658 live(),
659 repo(),
660 );
661 assert_eq!(f.len(), 1);
662 assert_eq!(f[0].tool, "Grep");
663 }
664
665 #[test]
666 fn a_bash_referencing_the_live_dir_relatively_is_flagged() {
667 let f = detect_live_source_reads(
668 &[inv(
669 "Bash",
670 json!({"command": "cat skills/mr-review/SKILL.md"}),
671 3,
672 )],
673 live(),
674 repo(),
675 );
676 assert_eq!(f.len(), 1);
677 assert_eq!(f[0].tool, "Bash");
678 assert_eq!(
679 f[0].command.as_deref(),
680 Some("cat skills/mr-review/SKILL.md")
681 );
682 }
683
684 #[test]
685 fn a_codex_command_referencing_the_live_dir_relatively_is_flagged() {
686 let f = detect_live_source_reads(
687 &[inv(
688 "command_execution",
689 json!({"command": "cat skills/mr-review/SKILL.md"}),
690 3,
691 )],
692 live(),
693 repo(),
694 );
695 assert_eq!(f.len(), 1);
696 assert_eq!(f[0].tool, "command_execution");
697 assert_eq!(
698 f[0].command.as_deref(),
699 Some("cat skills/mr-review/SKILL.md")
700 );
701 }
702
703 #[test]
704 fn a_bash_referencing_the_live_dir_absolutely_is_flagged() {
705 let f = detect_live_source_reads(
706 &[inv(
707 "Bash",
708 json!({"command": format!("grep -r trigger {LIVE_SKILL}/")}),
709 0,
710 )],
711 live(),
712 repo(),
713 );
714 assert_eq!(f.len(), 1);
715 }
716
717 #[test]
718 fn a_bash_referencing_a_staged_copy_under_dot_claude_skills_is_not_flagged() {
719 let f = detect_live_source_reads(
720 &[inv(
721 "Bash",
722 json!({"command": "cat .claude/skills/mr-review/SKILL.md"}),
723 0,
724 )],
725 live(),
726 repo(),
727 );
728 assert!(f.is_empty());
729 }
730
731 #[test]
732 fn a_bash_referencing_a_staged_copy_under_dot_agents_skills_is_not_flagged() {
733 let f = detect_live_source_reads(
734 &[inv(
735 "Bash",
736 json!({"command": "cat .agents/skills/mr-review/SKILL.md"}),
737 0,
738 )],
739 live(),
740 repo(),
741 );
742 assert!(f.is_empty());
743 }
744
745 #[test]
746 fn unrelated_reads_and_commands_are_not_flagged() {
747 let f = detect_live_source_reads(
748 &[
749 inv("Read", json!({"file_path": format!("{OUTPUTS}/x.md")}), 0),
750 inv("Bash", json!({"command": "ls .eval-magic"}), 1),
751 inv(
753 "Write",
754 json!({"file_path": format!("{LIVE_SKILL}/SKILL.md")}),
755 2,
756 ),
757 ],
758 live(),
759 repo(),
760 );
761 assert!(f.is_empty());
762 }
763}