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 task environment:
5//!
6//! - **violations**: file-write tools (per the adapters' cross-harness
7//!   vocabulary union) whose target path resolves outside the task's eval root.
8//! - **warnings**: shell commands matching a mutating pattern that don't
9//!   reference the eval root (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 task environment. See the
64/// module docs for what counts as a violation vs. a warning.
65pub 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
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 (task-environment boundary).
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    eval_root: 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 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                // `dispatch.json` is the authoritative source of the private task
339                // environment boundary. Without it we skip out-of-bounds write
340                // classification rather than guess. Live-source-read detection is
341                // independent of this boundary and still runs.
342                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
399/// Map `"<eval_id>:<condition>[:r<k>]"` → the task's `eval_root` from
400/// `dispatch.json`. Empty when the file is absent or malformed.
401fn 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    /// Build a minimal invocation from name/args/ordinal (result is unused here).
425    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    // --- detectStrayWrites ---
443
444    #[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    // --- detectLiveSourceReads ---
623
624    #[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                // Write tools are detect_stray_writes' jurisdiction — reads only here.
789                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}