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