Skip to content

Commit 33c707f

Browse files
committed
Switch permission pattern matching from regex to glob, improve pattern inference
- Use glob patterns matching Claude Code's behavior (e.g. `Bash(ls *)` matches `ls -la` but not `lsof`, space before `*` enforces word boundary) - Change inferred pattern syntax from `Bash(cmd:*)` to `Bash(cmd *)` - Add backward compatibility for legacy `:*` patterns - Skip prefix commands (sleep, timeout, env, etc.) when inferring patterns - Use quote-aware splitting so quoted args don't leak into patterns - Pick last meaningful command in `&&` chains for pattern inference
1 parent a14eff2 commit 33c707f

7 files changed

Lines changed: 559 additions & 149 deletions

File tree

packages/vide_core/lib/src/permissions/pattern_inference.dart

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,44 +25,66 @@ class PatternInference {
2525
}
2626

2727
/// Infer pattern for Bash commands
28-
/// Example: "npm run test" → "Bash(npm run test:*)"
29-
/// Example: "git status" → "Bash(git status:*)"
30-
/// Example: "cd /path && dart pub get" → "Bash(dart pub get:*)"
31-
/// Example: "find /path -name *.dart" → "Bash(find:*)"
32-
/// Example: "dart test 2>&1" → "Bash(dart test:*)" (redirects stripped)
28+
/// Example: "npm run test" → "Bash(npm run test *)"
29+
/// Example: "git status" → "Bash(git status *)"
30+
/// Example: "cd /path && dart pub get" → "Bash(dart pub get *)"
31+
/// Example: "find /path -name *.dart" → "Bash(find *)"
32+
/// Example: "dart test 2>&1" → "Bash(dart test *)" (redirects stripped)
3333
static String _inferBashPattern(String command) {
3434
if (command.isEmpty) return 'Bash(*)';
3535

3636
// Parse compound commands
3737
final parsedCommands = BashCommandParser.parse(command);
3838

39-
// Find the "main" command (skip cd commands)
40-
var mainCommand = parsedCommands
41-
.firstWhere(
42-
(cmd) => cmd.type != CommandType.cd,
43-
orElse: () => parsedCommands.isNotEmpty
44-
? parsedCommands.first
45-
: ParsedCommand('', CommandType.simple),
46-
)
47-
.command;
39+
// Find the "main" command:
40+
// - Skip cd commands (just directory changes)
41+
// - Skip prefix commands (sleep, timeout, env, etc.)
42+
// - Skip pipeline filter parts (grep, head, tail after |)
43+
// - Prefer the last meaningful command (prefix cmds appear first in chains)
44+
var mainCommand = '';
45+
for (final cmd in parsedCommands.reversed) {
46+
if (cmd.type == CommandType.cd) continue;
47+
if (cmd.type == CommandType.pipelinePart) continue;
48+
if (_isPrefixCommand(cmd.command)) continue;
49+
mainCommand = cmd.command;
50+
break;
51+
}
52+
// Fallback: if all non-cd commands are pipeline parts, use the first one
53+
// (the producer, not the filter)
54+
if (mainCommand.isEmpty) {
55+
mainCommand = parsedCommands
56+
.firstWhere(
57+
(cmd) => cmd.type != CommandType.cd && !_isPrefixCommand(cmd.command),
58+
orElse: () => parsedCommands.firstWhere(
59+
(cmd) => cmd.type != CommandType.cd,
60+
orElse: () => parsedCommands.isNotEmpty
61+
? parsedCommands.first
62+
: ParsedCommand('', CommandType.simple),
63+
),
64+
)
65+
.command;
66+
}
4867

4968
if (mainCommand.isEmpty) return 'Bash(*)';
5069

5170
// Strip shell redirects from the command before inferring pattern
5271
// These are implementation details that shouldn't be part of the pattern
5372
mainCommand = _stripShellRedirects(mainCommand);
5473

55-
// Split into parts
56-
final parts = mainCommand.trim().split(RegExp(r'\s+'));
74+
// Split into parts (quote-aware)
75+
final parts = _splitRespectingQuotes(mainCommand.trim());
5776
if (parts.isEmpty) return 'Bash(*)';
5877

59-
// Extract base command (command name only, no path arguments)
78+
// Extract base command (command name only, no path/flag/quoted arguments)
6079
final baseParts = <String>[];
6180

6281
for (final part in parts) {
6382
// Stop at flags
6483
if (part.startsWith('-')) break;
6584

85+
// Stop at quoted arguments (these are values, not sub-command names)
86+
if (part.startsWith('"') || part.startsWith("'")) break;
87+
6688
// Stop at path-like arguments (starting with / or ./ or ~/ or ..)
6789
if (part.startsWith('/') ||
6890
part.startsWith('./') ||
@@ -79,7 +101,62 @@ class PatternInference {
79101
}
80102

81103
final baseCommand = baseParts.join(' ');
82-
return baseCommand.isEmpty ? 'Bash(*)' : 'Bash($baseCommand:*)';
104+
return baseCommand.isEmpty ? 'Bash(*)' : 'Bash($baseCommand *)';
105+
}
106+
107+
/// Split a command string into parts, keeping quoted strings as single tokens.
108+
static List<String> _splitRespectingQuotes(String command) {
109+
final parts = <String>[];
110+
final buffer = StringBuffer();
111+
var inSingleQuote = false;
112+
var inDoubleQuote = false;
113+
114+
for (var i = 0; i < command.length; i++) {
115+
final char = command[i];
116+
117+
if (char == "'" && !inDoubleQuote) {
118+
inSingleQuote = !inSingleQuote;
119+
buffer.write(char);
120+
} else if (char == '"' && !inSingleQuote) {
121+
inDoubleQuote = !inDoubleQuote;
122+
buffer.write(char);
123+
} else if (char == ' ' && !inSingleQuote && !inDoubleQuote) {
124+
if (buffer.isNotEmpty) {
125+
parts.add(buffer.toString());
126+
buffer.clear();
127+
}
128+
} else {
129+
buffer.write(char);
130+
}
131+
}
132+
133+
if (buffer.isNotEmpty) {
134+
parts.add(buffer.toString());
135+
}
136+
137+
return parts;
138+
}
139+
140+
/// Commands that are typically used as prefixes before the "real" command.
141+
/// These are delay/wrapper commands that shouldn't be used for pattern inference.
142+
static const _prefixCommands = {
143+
'sleep',
144+
'timeout',
145+
'env',
146+
'nice',
147+
'nohup',
148+
'time',
149+
'watch',
150+
'retry',
151+
'wait',
152+
'true',
153+
'false',
154+
};
155+
156+
/// Check if a command is a prefix/wrapper command like sleep, timeout, etc.
157+
static bool _isPrefixCommand(String command) {
158+
final firstWord = command.trim().split(RegExp(r'\s+')).firstOrNull ?? '';
159+
return _prefixCommands.contains(firstWord);
83160
}
84161

85162
/// Strip shell redirects from a command string.

packages/vide_core/lib/src/permissions/permission_matcher.dart

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,77 @@ class PermissionMatcher {
210210
return true; // All commands are safe
211211
}
212212

213-
/// Match Bash commands with compound command support
213+
/// Convert a bash glob pattern to an anchored regex.
214+
///
215+
/// Claude Code uses glob-style matching for Bash patterns:
216+
/// - `*` matches any characters (like `.*` in regex)
217+
/// - `Bash(ls *)` matches `ls`, `ls -la` but NOT `lsof` (space enforces
218+
/// word boundary: prefix must be followed by space-or-end-of-string)
219+
/// - `Bash(ls*)` matches both `ls -la` AND `lsof`
220+
/// - `Bash(git * main)` matches `git checkout main`
221+
/// - Legacy `:*` suffix is treated as ` *` for backward compatibility
222+
///
223+
/// The pattern is anchored (full-string match), not a substring search.
224+
static RegExp _bashGlobToRegex(String pattern) {
225+
// Legacy support: convert trailing `:*` to ` *`
226+
var normalized = pattern;
227+
if (normalized.endsWith(':*')) {
228+
normalized =
229+
'${normalized.substring(0, normalized.length - 2)} *';
230+
}
231+
232+
// Special case: trailing ` *` enforces word boundary.
233+
// "ls *" matches "ls" (end-of-string) and "ls -la" (space + args)
234+
// but NOT "lsof" (no boundary).
235+
// We handle this by converting trailing ` *` to `( .*)?` and then
236+
// processing the rest normally.
237+
String? trailingSuffix;
238+
if (normalized.endsWith(' *')) {
239+
trailingSuffix = '( .*)?';
240+
normalized = normalized.substring(0, normalized.length - 2);
241+
}
242+
243+
// Escape regex special characters, then convert glob `*` to `.*`
244+
final buffer = StringBuffer();
245+
for (var i = 0; i < normalized.length; i++) {
246+
final char = normalized[i];
247+
if (char == '*') {
248+
buffer.write('.*');
249+
} else if (_regexSpecialChars.contains(char)) {
250+
buffer.write('\\');
251+
buffer.write(char);
252+
} else {
253+
buffer.write(char);
254+
}
255+
}
256+
257+
if (trailingSuffix != null) {
258+
buffer.write(trailingSuffix);
259+
}
260+
261+
return RegExp('^${buffer.toString()}\$');
262+
}
263+
264+
static const _regexSpecialChars = {
265+
'.', '+', '?', '[', ']', '(', ')', '{', '}', '^', r'$', '|', r'\',
266+
};
267+
268+
/// Check if a command matches a bash glob pattern.
269+
static bool _bashGlobMatches(String pattern, String command) {
270+
try {
271+
return _bashGlobToRegex(pattern).hasMatch(command);
272+
} catch (e) {
273+
// If pattern is invalid, fall back to exact match
274+
return pattern == command;
275+
}
276+
}
277+
278+
/// Match Bash commands with compound command support.
279+
///
280+
/// Uses glob-style matching (like Claude Code):
281+
/// - Each sub-command in `&&`/`||`/`;` chains is matched independently
282+
/// - `cd` within the working directory is auto-approved
283+
/// - Pipelines use smart matching with safe filter allowlists
214284
static bool _matchesBashCommand(
215285
String argPattern,
216286
String command,
@@ -247,8 +317,8 @@ class PermissionMatcher {
247317
// Wildcard pattern - use smart matching with safe filters
248318
return _matchesPipeline(parsedCommands, argPattern, cwd);
249319
} else {
250-
// Exact pattern - just check if the whole command matches
251-
return RegExp(argPattern).hasMatch(command);
320+
// Exact pattern - check full command string
321+
return _bashGlobMatches(argPattern, command);
252322
}
253323
}
254324

@@ -264,7 +334,7 @@ class PermissionMatcher {
264334
}
265335

266336
// Check this sub-command against the pattern
267-
if (!RegExp(argPattern).hasMatch(parsed.command)) {
337+
if (!_bashGlobMatches(argPattern, parsed.command)) {
268338
return false; // One sub-command doesn't match
269339
}
270340
}
@@ -295,7 +365,7 @@ class PermissionMatcher {
295365
}
296366

297367
// Check if this command matches the pattern
298-
final matches = RegExp(argPattern).hasMatch(parsed.command);
368+
final matches = _bashGlobMatches(argPattern, parsed.command);
299369
if (matches) {
300370
hasMatchingCommand = true;
301371
continue;

0 commit comments

Comments
 (0)