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