Skip to main content

eval_magic/pipeline/
detect_stray_writes.rs

1//! Stage 3 — `detect-stray-writes`.
2//!
3//! Classifies a run's tool
4//! invocations against its allowed outputs dir:
5//!
6//! - **violations**: file-write tools (Write/Edit/MultiEdit/NotebookEdit/Codex
7//!   `file_change`) whose target path resolves outside the outputs dir.
8//! - **warnings**: shell commands matching a mutating pattern that don't
9//!   reference the outputs dir (via the sandbox `classify_bash` policy).
10//! - **live_source_reads**: read tools / shell commands that touched the live
11//!   skill-under-test directory instead of its staged copy.
12
13use 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
24/// Shell-execution tools across harnesses.
25const SHELL_TOOLS: [&str; 2] = ["Bash", "command_execution"];
26/// Read-only tools that carry a target path argument.
27const READ_TOOLS: [&str; 3] = ["Read", "Glob", "Grep"];
28
29/// A file-write tool: a sandbox write tool, or Codex's `file_change`.
30fn 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/// One flagged tool invocation. `path` is set for write/read findings, `command`
38/// for shell findings; the unused one is omitted (the schema forbids extras).
39#[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/// The stray-write classification for one run.
51#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct RunFindings {
53    pub violations: Vec<StrayFinding>,
54    pub warnings: Vec<StrayFinding>,
55}
56
57/// The `command` arg of a shell invocation, or `""` when absent.
58fn 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
66/// Classify a run's tool invocations against its allowed outputs dir. See the
67/// module docs for what counts as a violation vs. a warning.
68pub 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
110/// Lexically absolutize a path (no disk access). Mirrors node's `resolve()`.
111fn absolutize(p: &Path) -> PathBuf {
112    std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
113}
114
115/// Node-style lexical `path.relative(from, to)` over absolute, normalized paths.
116/// Returns forward-slash-joined components; starts with `..` when `to` is not
117/// under `from`.
118fn 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
132/// Leading boundary before a bare `rel` reference: start-of-string or one of
133/// `\s'"=:(/`.
134fn is_leading_boundary(b: u8) -> bool {
135    b.is_ascii_whitespace() || matches!(b, b'\'' | b'"' | b'=' | b':' | b'(' | b'/')
136}
137
138/// Trailing boundary after a bare `rel` reference: end-of-string or one of
139/// `/\s'")`.
140fn is_trailing_boundary(b: u8) -> bool {
141    b == b'/' || b.is_ascii_whitespace() || matches!(b, b'\'' | b'"' | b')')
142}
143
144/// True if `command` references `rel` as a bare path token — bounded as a path
145/// segment and **not** prefixed by a `.claude`/`.agents` staging dir. The
146/// `regex` crate has no lookbehind, so each occurrence is scanned directly for
147/// the boundary + preceding-segment conditions.
148fn 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        // The lookbehind sits before the boundary char: the text up to (but not
160        // including) that char must not end with a staging-dir prefix.
161        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
175/// Flag tool invocations that read the **live** skill-under-test directory
176/// instead of the staged copy. Reads are detected, not blocked, so this surfaces
177/// post-hoc as a validity warning. See `detect-stray-writes.ts` for the rationale.
178pub 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// --- CLI report ---
225
226/// Per-(eval, condition, run) findings, emitted only for runs with ≥1 finding.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
228pub struct RunReport {
229    pub eval_id: String,
230    pub condition: String,
231    /// 1-based run index within a multi-run cell; absent for single-run cells.
232    #[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/// Aggregate counts across all runs.
240#[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/// The full `stray-writes.json` report.
248#[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    /// How many transcript tool-calls were actually examined across every run.
255    /// Zero means nothing was inspected — a clean `totals` is then *unverifiable*,
256    /// not a pass. In-memory only; never serialized into `stray-writes.json`.
257    #[serde(skip)]
258    pub invocations_inspected: usize,
259}
260
261/// `dispatch.json` fields the report builder reads (outputs-dir override).
262#[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
277/// Build, validate, and write `<iteration_dir>/stray-writes.json` for every
278/// `run.json` in the iteration. `repo_root` is the runner's cwd (relative paths
279/// resolve against it); `live_skill_dir` is the skill-under-test source.
280pub 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                // `dispatch.json` is the authoritative source of the outputs
340                // boundary: an absolute path into the isolated env
341                // (`env/.eval-magic-outputs/...`). Without it we cannot honor the
342                // outputs-only contract, so we skip out-of-bounds *write*
343                // classification for that run rather than guess a boundary — the old
344                // `<slot>/outputs` convention no longer matches where agents write and
345                // would mis-flag every legitimate write. Live-source-read detection is
346                // independent of the boundary and still runs.
347                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
404/// Map `"<eval_id>:<condition>[:r<k>]"` → the task's `outputs_dir` from
405/// `dispatch.json`. Empty when the file is absent or malformed (callers fall
406/// back to convention).
407fn 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    /// Build a minimal invocation from name/args/ordinal (result is unused here).
431    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    // --- detectStrayWrites ---
449
450    #[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    // --- detectLiveSourceReads ---
604
605    #[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                // Write tools are detect_stray_writes' jurisdiction — reads only here.
752                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}