Skip to content

Commit 2a6cb64

Browse files
author
Yogthos
committed
Fix: 'allow always' patterns for bash/grep/find_files match path args
Reported scenario: [permission] bash: cd /Users/yogthos/src/work/rigging-workshop (y)/(a)/(n)/(ESC) -> will allow: cd * allowed bash cd * (saved to session) ... next command ... [permission] bash: cd /Users/yogthos/src/work/rigging-workshop (y)/(a)/(n)/(ESC) -> will allow: cd * The 'allow always' pattern wasn't firing on subsequent bash commands. Root cause: glob_to_regex compiled `*` to `[^/]*` for all patterns, using classic filesystem-glob semantics (one segment, no slash crossing). For shell commands like `cd /Users/...` the path argument contains slashes, so the regex never matched. The session allowlist entry was silently inert. Fix: split Pattern into two compile modes. - Pattern::new(s) — path-style: `*` -> `[^/]*`, `?` -> `[^/]` - Pattern::new_command(s) — command-style: `*` -> `.*`, `?` -> `.` Add pattern_for_tool(tool, pat) that picks the right variant based on is_path_tool_name(tool). Wired through PermissionChecker::new (rules loaded from config + default bash rules) and add_session_allowlist / load_session_allowlist. External-directory rules keep path semantics since they are path patterns by definition. Path tools (read/write/edit/list_dir) keep their existing one-segment behavior — `src/*` still doesn't auto-match nested files. Only bash, grep, find_files, write_todo_list, etc. get the relaxed semantics. 11 regression + behavioral tests covering: - cd * matches absolute paths and command pipelines (the exact bug) - cd * anchors at start (doesn't match 'echo cd /foo') - path `src/*` still excludes nested files - path `src/**` spans directories - ? exclusion differs across modes - ~/foo expansion works in both modes - regex metachars escaped not interpreted - full session-allowlist roundtrip via PermissionChecker::check - load_session_allowlist preserves command semantics across reload
1 parent ebcb144 commit 2a6cb64

2 files changed

Lines changed: 208 additions & 8 deletions

File tree

src/permission/checker.rs

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ pub struct PermissionChecker {
2525
mode: SecurityMode,
2626
}
2727

28+
/// Tool names where the input is a filesystem path. For these, `*` keeps
29+
/// classic glob semantics (one segment, doesn't cross `/`). Everything else
30+
/// is treated as shell/text where `*` means "any chars including /".
31+
pub(crate) fn is_path_tool_name(tool: &str) -> bool {
32+
matches!(tool, "read" | "write" | "edit" | "list_dir")
33+
}
34+
35+
/// Build a Pattern with the right `*` semantics for the given tool.
36+
pub(crate) fn pattern_for_tool(tool: &str, pat: &str) -> Pattern {
37+
if is_path_tool_name(tool) {
38+
Pattern::new(pat)
39+
} else {
40+
Pattern::new_command(pat)
41+
}
42+
}
43+
2844
impl PermissionChecker {
2945
pub fn new(
3046
config: &PermissionConfig,
@@ -49,11 +65,11 @@ impl PermissionChecker {
4965
let mut entries = Vec::new();
5066
match tp {
5167
ToolPerm::Simple(action) => {
52-
entries.push((Pattern::new("*"), *action));
68+
entries.push((pattern_for_tool(tool_name, "*"), *action));
5369
}
5470
ToolPerm::Granular(map) => {
5571
for (pat, action) in map {
56-
entries.push((Pattern::new(pat), *action));
72+
entries.push((pattern_for_tool(tool_name, pat), *action));
5773
}
5874
}
5975
}
@@ -63,11 +79,12 @@ impl PermissionChecker {
6379
if !rules.contains_key("bash") {
6480
let mut defaults = Vec::new();
6581
for (pat, action) in crate::permission::default_bash_rules() {
66-
defaults.push((Pattern::new(pat), action));
82+
defaults.push((pattern_for_tool("bash", pat), action));
6783
}
6884
rules.insert("bash".to_string(), defaults);
6985
}
7086

87+
// External-directory rules are always path patterns by definition.
7188
let ext_dir_rules = config
7289
.external_directory
7390
.as_ref()
@@ -239,14 +256,14 @@ impl PermissionChecker {
239256
}
240257

241258
pub fn add_session_allowlist(&mut self, tool: String, pattern_str: &str) {
242-
let pattern = Pattern::new(pattern_str);
259+
let pattern = pattern_for_tool(&tool, pattern_str);
243260
self.session_allowlist.push((tool, pattern));
244261
}
245262

246263
pub fn load_session_allowlist(&mut self, entries: &[(String, String)]) {
247264
for (tool, pat) in entries {
248265
self.session_allowlist
249-
.push((tool.clone(), Pattern::new(pat)));
266+
.push((tool.clone(), pattern_for_tool(tool, pat)));
250267
}
251268
}
252269

@@ -318,3 +335,88 @@ fn resolve_absolute(path: &str, working_dir: &str) -> String {
318335
Path::new(working_dir).join(p).to_string_lossy().to_string()
319336
}
320337
}
338+
339+
#[cfg(test)]
340+
mod tests {
341+
use super::*;
342+
use crate::permission::PermissionConfig;
343+
344+
fn fresh_checker() -> PermissionChecker {
345+
PermissionChecker::new(
346+
&PermissionConfig::default(),
347+
SecurityMode::Standard,
348+
Some(std::path::PathBuf::from("/tmp")),
349+
)
350+
}
351+
352+
// Regression: "allow always" → `cd *` saved to session allowlist must
353+
// satisfy the NEXT bash check for `cd /absolute/path`. Before the fix,
354+
// path-glob semantics on `*` (`[^/]*`) refused to match the absolute
355+
// path, so the user was re-prompted every command.
356+
#[test]
357+
fn regression_session_allowlist_cd_star_matches_path_arg() {
358+
let mut checker = fresh_checker();
359+
checker.add_session_allowlist("bash".to_string(), "cd *");
360+
361+
// The exact scenario from the bug report.
362+
let r1 = checker.check(
363+
"bash",
364+
"cd /Users/yogthos/src/work/rigging-workshop && git diff",
365+
);
366+
assert!(
367+
matches!(r1, CheckResult::Allowed),
368+
"expected Allowed, got {:?}",
369+
r1
370+
);
371+
372+
let r2 = checker.check("bash", "cd /Users/yogthos/src/work/rigging-workshop");
373+
assert!(matches!(r2, CheckResult::Allowed));
374+
}
375+
376+
// Path-tool patterns still get filesystem-glob semantics — adding
377+
// `src/*` doesn't allow nested files. Force default Ask so we can read
378+
// the session-allowlist contribution in isolation from the default.
379+
#[test]
380+
fn path_tool_session_allowlist_keeps_one_segment_semantics() {
381+
let mut cfg = PermissionConfig::default();
382+
cfg.default = Some(Action::Ask);
383+
let mut checker = PermissionChecker::new(
384+
&cfg,
385+
SecurityMode::Standard,
386+
Some(std::path::PathBuf::from("/tmp")),
387+
);
388+
checker.add_session_allowlist("read".to_string(), "src/*");
389+
390+
// One-segment hit from the session allowlist.
391+
assert!(matches!(
392+
checker.check_path("read", "src/main.rs"),
393+
CheckResult::Allowed
394+
));
395+
// Nested path: not in allowlist, falls through to default Ask.
396+
let nested = checker.check_path("read", "src/agent/main.rs");
397+
assert!(
398+
matches!(nested, CheckResult::Ask),
399+
"src/* must not match nested path; got {:?}",
400+
nested
401+
);
402+
}
403+
404+
// load_session_allowlist roundtrip: persisted patterns from a previous
405+
// session should match the way they did when saved.
406+
#[test]
407+
fn regression_load_session_allowlist_preserves_command_semantics() {
408+
let mut checker = fresh_checker();
409+
let saved = vec![("bash".to_string(), "cd *".to_string())];
410+
checker.load_session_allowlist(&saved);
411+
412+
let r = checker.check("bash", "cd /home/me/project");
413+
assert!(matches!(r, CheckResult::Allowed));
414+
}
415+
416+
#[test]
417+
fn pattern_for_tool_distinguishes_path_and_command_tools() {
418+
assert!(pattern_for_tool("bash", "cd *").matches("cd /a/b/c"));
419+
assert!(!pattern_for_tool("read", "cd *").matches("cd /a/b/c"));
420+
assert!(pattern_for_tool("read", "cd *").matches("cd file"));
421+
}
422+
}

src/permission/pattern.rs

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,27 @@ pub struct Pattern {
88
}
99

1010
impl Pattern {
11+
/// Filesystem-style glob: `*` matches one path segment (no `/`); `**`
12+
/// matches any depth. Use for path tools (`read`, `write`, `edit`,
13+
/// `list_dir`).
1114
pub fn new(pattern: &str) -> Self {
15+
Self::compile(pattern, /* path_style */ true)
16+
}
17+
18+
/// Shell-style glob for non-path inputs: `*` matches any chars including
19+
/// `/`. Use for `bash` command patterns, `grep`/`find_files` patterns,
20+
/// and other tools where the input isn't a filesystem path.
21+
///
22+
/// Without this, a user pattern like `cd *` (suggested by the harness
23+
/// for `bash` after the user accepts "allow always") would NOT match
24+
/// `cd /Users/foo/bar` because `[^/]*` stops at the first slash.
25+
pub fn new_command(pattern: &str) -> Self {
26+
Self::compile(pattern, /* path_style */ false)
27+
}
28+
29+
fn compile(pattern: &str, path_style: bool) -> Self {
1230
let expanded = expand_home(pattern);
13-
let regex_str = glob_to_regex(&expanded);
31+
let regex_str = glob_to_regex(&expanded, path_style);
1432
let regex = Regex::new(&regex_str).unwrap_or_else(|_| Regex::new("^$").unwrap());
1533
Pattern {
1634
regex,
@@ -44,7 +62,84 @@ fn expand_home(pattern: &str) -> String {
4462
pattern.to_string()
4563
}
4664

47-
fn glob_to_regex(pattern: &str) -> String {
65+
#[cfg(test)]
66+
mod tests {
67+
use super::*;
68+
69+
// Regression: `cd *` saved via "allow always" must match the user's NEXT
70+
// `cd /absolute/path` command. The original bug was filesystem-glob
71+
// semantics applied to a shell-command pattern: `*` compiled to `[^/]*`,
72+
// refusing to cross slashes. Allowlist entries for bash never fired and
73+
// the agent re-prompted on every command.
74+
#[test]
75+
fn regression_command_pattern_cd_star_matches_path_arg() {
76+
let pat = Pattern::new_command("cd *");
77+
assert!(pat.matches("cd /Users/yogthos/src/work/foo"));
78+
assert!(pat.matches("cd /Users/yogthos/src/work/foo && git diff"));
79+
assert!(pat.matches("cd foo"));
80+
}
81+
82+
#[test]
83+
fn regression_command_pattern_anchors_to_start() {
84+
// Don't over-rotate: `cd *` shouldn't match commands that merely
85+
// contain `cd ` somewhere later.
86+
let pat = Pattern::new_command("cd *");
87+
assert!(!pat.matches("xcd foo"));
88+
assert!(!pat.matches("echo cd foo"));
89+
}
90+
91+
#[test]
92+
fn path_pattern_star_still_excludes_slash() {
93+
let pat = Pattern::new("src/*");
94+
assert!(pat.matches("src/main.rs"));
95+
// Single segment only — `*` doesn't span directory boundaries.
96+
assert!(!pat.matches("src/agent/main.rs"));
97+
}
98+
99+
#[test]
100+
fn path_pattern_double_star_spans_directories() {
101+
let pat = Pattern::new("src/**");
102+
assert!(pat.matches("src/main.rs"));
103+
assert!(pat.matches("src/agent/main.rs"));
104+
assert!(pat.matches("src/agent/tools/foo.rs"));
105+
}
106+
107+
#[test]
108+
fn command_pattern_question_mark_matches_any_char() {
109+
let pat = Pattern::new_command("file.?");
110+
assert!(pat.matches("file.a"));
111+
// For commands, `?` is unrestricted.
112+
assert!(pat.matches("file./"));
113+
}
114+
115+
#[test]
116+
fn path_pattern_question_mark_excludes_slash() {
117+
let pat = Pattern::new("file.?");
118+
assert!(pat.matches("file.a"));
119+
assert!(!pat.matches("file./"));
120+
}
121+
122+
#[test]
123+
fn home_expansion_works_for_both_styles() {
124+
if let Some(home) = dirs::home_dir() {
125+
let expected = format!("{}/foo/bar", home.display());
126+
assert!(Pattern::new("~/foo/*").matches(&expected));
127+
assert!(Pattern::new_command("~/foo/*").matches(&expected));
128+
}
129+
}
130+
131+
// Regex metachars in pattern text must be escaped, not interpreted.
132+
#[test]
133+
fn special_chars_are_escaped() {
134+
let pat = Pattern::new_command("npm test (unit)");
135+
assert!(pat.matches("npm test (unit)"));
136+
// Without escaping, `(unit)` would be a regex group and not require
137+
// the literal parens.
138+
assert!(!pat.matches("npm test unit"));
139+
}
140+
}
141+
142+
fn glob_to_regex(pattern: &str, path_style: bool) -> String {
48143
let mut re = String::with_capacity(pattern.len() * 2);
49144
re.push('^');
50145
let mut chars = pattern.chars().peekable();
@@ -59,10 +154,13 @@ fn glob_to_regex(pattern: &str) -> String {
59154
} else {
60155
re.push_str(".*");
61156
}
62-
} else {
157+
} else if path_style {
63158
re.push_str("[^/]*");
159+
} else {
160+
re.push_str(".*");
64161
}
65162
}
163+
'?' if path_style => re.push_str("[^/]"),
66164
'?' => re.push('.'),
67165
'.' => re.push_str("\\."),
68166
'\\' => re.push_str("\\\\"),

0 commit comments

Comments
 (0)