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