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 (per the adapters' cross-harness
7//!   vocabulary union) 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::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
25/// A read-only tool carrying a target path argument, in any harness's
26/// vocabulary.
27fn 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/// One flagged tool invocation. `path` is set for write/read findings, `command`
35/// for shell findings; the unused one is omitted (the schema forbids extras).
36#[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/// The stray-write classification for one run.
48#[derive(Debug, Clone, PartialEq, Eq, Default)]
49pub struct RunFindings {
50    pub violations: Vec<StrayFinding>,
51    pub warnings: Vec<StrayFinding>,
52}
53
54/// The `command` arg of a shell invocation, or `""` when absent.
55fn 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
63/// Classify a run's tool invocations against its allowed outputs dir. See the
64/// module docs for what counts as a violation vs. a warning.
65pub 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
107/// Lexically absolutize a path (no disk access). Mirrors node's `resolve()`.
108fn absolutize(p: &Path) -> PathBuf {
109    std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
110}
111
112/// Node-style lexical `path.relative(from, to)` over absolute, normalized paths.
113/// Returns forward-slash-joined components; starts with `..` when `to` is not
114/// under `from`.
115fn 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
129/// Leading boundary before a bare `rel` reference: start-of-string or one of
130/// `\s'"=:(/`.
131fn is_leading_boundary(b: u8) -> bool {
132    b.is_ascii_whitespace() || matches!(b, b'\'' | b'"' | b'=' | b':' | b'(' | b'/')
133}
134
135/// Trailing boundary after a bare `rel` reference: end-of-string or one of
136/// `/\s'")`.
137fn is_trailing_boundary(b: u8) -> bool {
138    b == b'/' || b.is_ascii_whitespace() || matches!(b, b'\'' | b'"' | b')')
139}
140
141/// True if `command` references `rel` as a bare path token — bounded as a path
142/// segment and **not** prefixed by any harness config dir (`config_dirs`, the
143/// caller-supplied `adapters::all_config_dir_names()` list). The `regex` crate
144/// has no lookbehind, so each occurrence is scanned directly for the boundary +
145/// preceding-segment conditions.
146fn 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        // The lookbehind sits before the boundary char: the text up to (but not
158        // including) that char must not end with a staging-dir prefix.
159        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
173/// Flag tool invocations that read the **live** skill-under-test directory
174/// instead of the staged copy. Reads are detected, not blocked, so this surfaces
175/// post-hoc as a validity warning. See `detect-stray-writes.ts` for the rationale.
176pub 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// --- CLI report ---
224
225/// Per-(eval, condition, run) findings, emitted only for runs with ≥1 finding.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
227pub struct RunReport {
228    pub eval_id: String,
229    pub condition: String,
230    /// 1-based run index within a multi-run cell; absent for single-run cells.
231    #[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/// Aggregate counts across all runs.
239#[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/// The full `stray-writes.json` report.
247#[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    /// How many transcript tool-calls were actually examined across every run.
254    /// Zero means nothing was inspected — a clean `totals` is then *unverifiable*,
255    /// not a pass. In-memory only; never serialized into `stray-writes.json`.
256    #[serde(skip)]
257    pub invocations_inspected: usize,
258}
259
260/// `dispatch.json` fields the report builder reads (outputs-dir override).
261#[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
276/// Build, validate, and write `<iteration_dir>/stray-writes.json` for every
277/// `run.json` in the iteration. `repo_root` is the runner's cwd (relative paths
278/// resolve against it); `live_skill_dir` is the skill-under-test source.
279pub 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                // `dispatch.json` is the authoritative source of the outputs
339                // boundary: an absolute path into the isolated env
340                // (`env/.eval-magic-outputs/...`). Without it we cannot honor the
341                // outputs-only contract, so we skip out-of-bounds *write*
342                // classification for that run rather than guess a boundary — the old
343                // `<slot>/outputs` convention no longer matches where agents write and
344                // would mis-flag every legitimate write. Live-source-read detection is
345                // independent of the boundary and still runs.
346                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
403/// Map `"<eval_id>:<condition>[:r<k>]"` → the task's `outputs_dir` from
404/// `dispatch.json`. Empty when the file is absent or malformed (callers fall
405/// back to convention).
406fn 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    /// Build a minimal invocation from name/args/ordinal (result is unused here).
430    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    // --- detectStrayWrites ---
448
449    #[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    // --- detectLiveSourceReads ---
618
619    #[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                // Write tools are detect_stray_writes' jurisdiction — reads only here.
780                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}