@ibartel74/pi-automode-ext 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,667 @@
1
+ import {
2
+ analyzeBash,
3
+ type BashAnalysis,
4
+ type BashCommandAnalysis,
5
+ type BashRedirectAnalysis,
6
+ } from "./bash.ts";
7
+ import { PATH_BEARING_TOOLS } from "./constants.ts";
8
+ import type { ToolPattern } from "./types.ts";
9
+ import {
10
+ extractInputPath,
11
+ expandHomePattern,
12
+ normalizePathForMatch,
13
+ resolveInputPath,
14
+ resolvePathForPolicy,
15
+ resolveToolInputPath,
16
+ } from "./paths.ts";
17
+
18
+ export const MAX_WILDCARD_PATTERN_LENGTH = 4096;
19
+ export const MAX_WILDCARD_INPUT_LENGTH = 1024 * 1024;
20
+
21
+ const bashPatternAnalyses = new WeakMap<ToolPattern, BashAnalysis>();
22
+
23
+ /** Preserve the previous non-Unicode RegExp `/i` case-equivalence rules. */
24
+ function canonicalizeCase(value: string): string {
25
+ let canonical = "";
26
+ for (let index = 0; index < value.length; index += 1) {
27
+ const character = value[index] ?? "";
28
+ const uppercase = character.toUpperCase();
29
+ if (
30
+ uppercase.length !== 1 ||
31
+ (character.charCodeAt(0) >= 128 && uppercase.charCodeAt(0) < 128)
32
+ ) {
33
+ canonical += character;
34
+ } else {
35
+ canonical += uppercase;
36
+ }
37
+ }
38
+ return canonical;
39
+ }
40
+
41
+ function normalizeToolName(name: string): string {
42
+ const lower = name.trim().replace(/^@/, "").toLowerCase();
43
+ const aliases: Record<string, string> = {
44
+ bash: "bash",
45
+ read: "read",
46
+ edit: "edit",
47
+ write: "write",
48
+ grep: "grep",
49
+ find: "find",
50
+ ls: "ls",
51
+ };
52
+ return aliases[lower] ?? lower;
53
+ }
54
+
55
+ /**
56
+ * Parse Pi permission entries such as `bash(git push *)`.
57
+ *
58
+ * Capitalized names such as `Bash(...)` are accepted as a convenience, but Pi's
59
+ * actual tool names are lowercase. Scoped entries stay scoped: we do not flatten
60
+ * `bash(git status *)` into a blanket `bash` permission.
61
+ */
62
+ export function parseToolPattern(value: unknown): ToolPattern | undefined {
63
+ if (typeof value !== "string") return undefined;
64
+ const raw = value.trim();
65
+ if (!raw) return undefined;
66
+
67
+ const match = raw.match(/^@?([A-Za-z0-9_-]+)(?:\((.*)\))?$/s);
68
+ if (!match) return { raw };
69
+ const toolName = normalizeToolName(match[1] ?? "");
70
+ const argumentPattern = match[2];
71
+ const bashPatternAnalysis = toolName === "bash" && argumentPattern
72
+ ? analyzeBash(argumentPattern)
73
+ : undefined;
74
+ const pattern: ToolPattern = { raw, toolName, argumentPattern };
75
+ if (bashPatternAnalysis) bashPatternAnalyses.set(pattern, bashPatternAnalysis);
76
+ return pattern;
77
+ }
78
+
79
+ /**
80
+ * Generate an exact-match `permissions.allow` rule for the current action, used
81
+ * by the interactive-confirmation "always allow" choices. Returns undefined
82
+ * when no safe exact rule exists: pattern metacharacters in the target would
83
+ * widen the match into a wildcard, implicit search roots (grep/find/ls without
84
+ * a path) are not persistable, and tools without a known argument cannot be
85
+ * scoped.
86
+ */
87
+ export function allowRuleForAction(
88
+ toolName: string,
89
+ input: Record<string, unknown>,
90
+ ): string | undefined {
91
+ const target = toolName === "bash"
92
+ ? (typeof input.command === "string" ? input.command.trim() : "")
93
+ : extractInputPath(toolName, input) ?? "";
94
+ if (!target || target === ".") return undefined;
95
+ if (target.length > MAX_WILDCARD_PATTERN_LENGTH) return undefined;
96
+ if (/[*()\n\r]/.test(target)) return undefined;
97
+ return `${toolName}(${target})`;
98
+ }
99
+
100
+ /**
101
+ * Validate a user-edited allow rule from the interactive-confirmation dialog.
102
+ * Unlike `allowRuleForAction`, wildcards are permitted — the user explicitly
103
+ * chose the scope — but the rule must parse as a scoped tool pattern for the
104
+ * same tool as the blocked action. Returns the normalized rule, or undefined
105
+ * when the input is unusable.
106
+ */
107
+ export function normalizeEditedAllowRule(
108
+ toolName: string,
109
+ value: unknown,
110
+ ): string | undefined {
111
+ if (typeof value !== "string") return undefined;
112
+ const trimmed = value.trim();
113
+ if (!trimmed || trimmed.length > MAX_WILDCARD_PATTERN_LENGTH) return undefined;
114
+ if (/[\n\r]/.test(trimmed)) return undefined;
115
+ const pattern = parseToolPattern(trimmed);
116
+ if (!pattern?.argumentPattern) return undefined;
117
+ if (pattern.toolName !== normalizeToolName(toolName)) return undefined;
118
+ return pattern.raw;
119
+ }
120
+
121
+ function literalPrefixTable(value: string): number[] {
122
+ const table = new Array<number>(value.length).fill(0);
123
+ let prefixLength = 0;
124
+ for (let index = 1; index < value.length; index += 1) {
125
+ while (
126
+ prefixLength > 0 && value[index] !== value[prefixLength]
127
+ ) {
128
+ prefixLength = table[prefixLength - 1] ?? 0;
129
+ }
130
+ if (value[index] === value[prefixLength]) prefixLength += 1;
131
+ table[index] = prefixLength;
132
+ }
133
+ return table;
134
+ }
135
+
136
+ function findLiteral(
137
+ value: string,
138
+ literal: string,
139
+ start: number,
140
+ end: number,
141
+ ): number {
142
+ const prefixTable = literalPrefixTable(literal);
143
+ let matched = 0;
144
+ for (let index = start; index < end; index += 1) {
145
+ while (matched > 0 && value[index] !== literal[matched]) {
146
+ matched = prefixTable[matched - 1] ?? 0;
147
+ }
148
+ if (value[index] === literal[matched]) matched += 1;
149
+ if (matched === literal.length) return index - literal.length + 1;
150
+ }
151
+ return -1;
152
+ }
153
+
154
+ export type WildcardOverflowPolicy = "match" | "no-match";
155
+
156
+ /**
157
+ * Match a case-insensitive `*` wildcard pattern in linear time.
158
+ *
159
+ * `*` matches zero or more characters, including newlines and path separators.
160
+ * Denial callers use `match` for over-limit values so they fail closed. Allow
161
+ * callers use `no-match` so an oversized input cannot broaden an allow rule.
162
+ */
163
+ export function matchesWildcardPattern(
164
+ pattern: string,
165
+ value: string,
166
+ overflowPolicy: WildcardOverflowPolicy = "match",
167
+ ): boolean {
168
+ if (
169
+ pattern.length > MAX_WILDCARD_PATTERN_LENGTH ||
170
+ value.length > MAX_WILDCARD_INPUT_LENGTH
171
+ ) {
172
+ return overflowPolicy === "match";
173
+ }
174
+
175
+ const normalizedPattern = canonicalizeCase(pattern);
176
+ const normalizedValue = canonicalizeCase(value);
177
+ if (!normalizedPattern.includes("*")) {
178
+ return normalizedPattern === normalizedValue;
179
+ }
180
+
181
+ const startsWithWildcard = normalizedPattern.startsWith("*");
182
+ const endsWithWildcard = normalizedPattern.endsWith("*");
183
+ const literals = normalizedPattern.split("*").filter(Boolean);
184
+ if (literals.length === 0) return true;
185
+
186
+ let literalIndex = 0;
187
+ let valueIndex = 0;
188
+ let lastLiteralIndex = literals.length;
189
+
190
+ if (!startsWithWildcard) {
191
+ const prefix = literals[0] ?? "";
192
+ if (!normalizedValue.startsWith(prefix)) return false;
193
+ valueIndex = prefix.length;
194
+ literalIndex = 1;
195
+ }
196
+
197
+ let searchEnd = normalizedValue.length;
198
+ if (!endsWithWildcard) {
199
+ const suffix = literals[literals.length - 1] ?? "";
200
+ searchEnd -= suffix.length;
201
+ if (
202
+ searchEnd < valueIndex ||
203
+ !normalizedValue.endsWith(suffix)
204
+ ) {
205
+ return false;
206
+ }
207
+ lastLiteralIndex -= 1;
208
+ }
209
+
210
+ for (; literalIndex < lastLiteralIndex; literalIndex += 1) {
211
+ const literal = literals[literalIndex] ?? "";
212
+ const found = findLiteral(
213
+ normalizedValue,
214
+ literal,
215
+ valueIndex,
216
+ searchEnd,
217
+ );
218
+ if (found < 0) return false;
219
+ valueIndex = found + literal.length;
220
+ }
221
+
222
+ return true;
223
+ }
224
+
225
+ export function normalizePermissionPathForMatch(
226
+ path: string,
227
+ platform: NodeJS.Platform = process.platform,
228
+ ): string {
229
+ return platform === "win32" ? path.replace(/\\/g, "/") : path;
230
+ }
231
+
232
+ function pathArgumentsForMatch(
233
+ toolName: string,
234
+ cwd: string,
235
+ value: string,
236
+ overflowPolicy: WildcardOverflowPolicy,
237
+ ): string[] {
238
+ const resolved = resolveToolInputPath(toolName, cwd, value) ?? value;
239
+ const canonical = resolvePathForPolicy(resolved);
240
+ const candidates = canonical
241
+ ? [canonical, normalizePathForMatch(canonical, cwd)]
242
+ : [];
243
+ if (overflowPolicy === "match") {
244
+ candidates.push(resolved, normalizePathForMatch(resolved, cwd));
245
+ }
246
+ return [...new Set(
247
+ candidates.map((candidate) => normalizePermissionPathForMatch(candidate)),
248
+ )];
249
+ }
250
+
251
+ function getPrimaryArguments(
252
+ toolName: string,
253
+ input: Record<string, unknown>,
254
+ cwd: string,
255
+ overflowPolicy: WildcardOverflowPolicy,
256
+ ): string[] {
257
+ if (toolName === "bash" && typeof input.command === "string") {
258
+ return [input.command];
259
+ }
260
+ if (
261
+ (toolName === "read" || toolName === "write" || toolName === "edit") &&
262
+ typeof input.path === "string"
263
+ ) {
264
+ return pathArgumentsForMatch(toolName, cwd, input.path, overflowPolicy);
265
+ }
266
+ if (toolName === "grep" && typeof input.pattern === "string") {
267
+ return [input.pattern];
268
+ }
269
+ if (
270
+ (toolName === "find" || toolName === "ls") &&
271
+ typeof input.path === "string"
272
+ ) {
273
+ return pathArgumentsForMatch(toolName, cwd, input.path, overflowPolicy);
274
+ }
275
+ return [JSON.stringify(input)];
276
+ }
277
+
278
+ function isPermissionPathTool(toolName: string): boolean {
279
+ return toolName === "read" ||
280
+ toolName === "write" ||
281
+ toolName === "edit" ||
282
+ toolName === "find" ||
283
+ toolName === "ls";
284
+ }
285
+
286
+ export function appendPermissionPathPatternSuffix(
287
+ scope: string,
288
+ suffix: string,
289
+ ): string {
290
+ const normalizedScope = withoutTrailingSlash(
291
+ normalizePermissionPathForMatch(scope),
292
+ );
293
+ return normalizedScope.endsWith("/")
294
+ ? `${normalizedScope}${suffix}`
295
+ : `${normalizedScope}/${suffix}`;
296
+ }
297
+
298
+ function permissionPathPatternVariants(
299
+ pattern: string,
300
+ cwd: string,
301
+ ): string[] {
302
+ const expanded = normalizePermissionPathForMatch(expandHomePattern(pattern));
303
+ const wildcardIndex = expanded.indexOf("*");
304
+ if (wildcardIndex === -1) {
305
+ const resolved = resolveInputPath(cwd, expanded);
306
+ const canonical = resolved ? resolvePathForPolicy(resolved) : undefined;
307
+ return [...new Set(
308
+ [expanded, resolved, canonical]
309
+ .filter((value): value is string => !!value)
310
+ .map((value) => normalizePermissionPathForMatch(value)),
311
+ )];
312
+ }
313
+
314
+ const fixedPrefix = expanded.slice(0, wildcardIndex);
315
+ const lastSlash = fixedPrefix.lastIndexOf("/");
316
+ if (lastSlash < 0) return [expanded];
317
+ const fixedScope = fixedPrefix.slice(0, lastSlash) || "/";
318
+ const resolvedScope = resolveInputPath(cwd, fixedScope);
319
+ if (!resolvedScope) return [expanded];
320
+ const canonicalScope = resolvePathForPolicy(resolvedScope);
321
+ const suffix = expanded.slice(lastSlash).replace(/^\/+/, "");
322
+ return [...new Set([
323
+ expanded,
324
+ appendPermissionPathPatternSuffix(resolvedScope, suffix),
325
+ ...(canonicalScope
326
+ ? [appendPermissionPathPatternSuffix(canonicalScope, suffix)]
327
+ : []),
328
+ ])];
329
+ }
330
+
331
+ /**
332
+ * Whether a resolved absolute path matches a configured path-denial pattern.
333
+ * Patterns support `~`/`$HOME` expansion and `*` globs, where `*` matches any
334
+ * characters, including `/`. Matching is case-insensitive and
335
+ * conservative-safe: over-matching only blocks more.
336
+ */
337
+ export function matchesDeniedPath(
338
+ resolvedPath: string,
339
+ deniedPaths: string[],
340
+ ): boolean {
341
+ const normalized = resolvedPath.replace(/\\/g, "/").normalize("NFC");
342
+ return deniedPaths.some((pattern) =>
343
+ deniedPatternVariants(pattern).some((variant) =>
344
+ matchesWildcardPattern(variant.normalize("NFC"), normalized)
345
+ )
346
+ );
347
+ }
348
+
349
+ function deniedPatternVariants(pattern: string): string[] {
350
+ const expanded = expandHomePattern(pattern).replace(/\\/g, "/");
351
+ const wildcardIndex = expanded.indexOf("*");
352
+ if (wildcardIndex === -1) {
353
+ const canonical = resolvePathForPolicy(expanded)?.replace(/\\/g, "/");
354
+ return canonical && canonical !== expanded
355
+ ? [expanded, canonical]
356
+ : [expanded];
357
+ }
358
+
359
+ const fixedPrefix = expanded.slice(0, wildcardIndex);
360
+ const lastSlash = fixedPrefix.lastIndexOf("/");
361
+ if (lastSlash < 0) return [expanded];
362
+ const fixedScope = fixedPrefix.slice(0, lastSlash) || "/";
363
+ const canonicalScope = resolvePathForPolicy(fixedScope)?.replace(/\\/g, "/");
364
+ if (!canonicalScope || canonicalScope === fixedScope) return [expanded];
365
+ const suffix = expanded.slice(lastSlash).replace(/^\/+/, "");
366
+ const canonicalPattern = canonicalScope === "/"
367
+ ? `/${suffix}`
368
+ : `${withoutTrailingSlash(canonicalScope)}/${suffix}`;
369
+ return canonicalPattern === expanded
370
+ ? [expanded]
371
+ : [expanded, canonicalPattern];
372
+ }
373
+
374
+ function withoutTrailingSlash(path: string): string {
375
+ if (path === "/" || /^[A-Za-z]:\/$/.test(path)) return path;
376
+ return path.replace(/\/+$/, "");
377
+ }
378
+
379
+ function wildcardCanMatchDescendant(root: string, pattern: string): boolean {
380
+ const normalizedRoot = withoutTrailingSlash(
381
+ canonicalizeCase(root.replace(/\\/g, "/").normalize("NFC")),
382
+ );
383
+ const prefix = normalizedRoot === "/" || /^[A-Za-z]:\/$/.test(normalizedRoot)
384
+ ? normalizedRoot
385
+ : `${normalizedRoot}/`;
386
+ const normalizedPattern = canonicalizeCase(pattern.normalize("NFC"));
387
+ const wildcardIndex = normalizedPattern.indexOf("*");
388
+ if (wildcardIndex < 0) {
389
+ return normalizedPattern.length > prefix.length &&
390
+ normalizedPattern.startsWith(prefix);
391
+ }
392
+
393
+ const fixedPrefix = normalizedPattern.slice(0, wildcardIndex);
394
+ return prefix.startsWith(fixedPrefix) || fixedPrefix.startsWith(prefix);
395
+ }
396
+
397
+ /**
398
+ * Whether a recursive search scope can contain a path matched by `deniedPaths`.
399
+ *
400
+ * The check asks whether the wildcard pattern can match any path beginning
401
+ * with the search-root prefix. It does not scan the search tree.
402
+ */
403
+ export function recursiveSearchMayReachDeniedPath(
404
+ resolvedRoot: string,
405
+ deniedPaths: string[],
406
+ ): boolean {
407
+ if (resolvedRoot.length > MAX_WILDCARD_INPUT_LENGTH) {
408
+ return deniedPaths.length > 0;
409
+ }
410
+ return deniedPaths.some((pattern) => {
411
+ if (pattern.length > MAX_WILDCARD_PATTERN_LENGTH) return true;
412
+ return deniedPatternVariants(pattern).some((expanded) =>
413
+ wildcardCanMatchDescendant(resolvedRoot, expanded)
414
+ );
415
+ });
416
+ }
417
+
418
+ function normalizedBashArgumentPattern(pattern: ToolPattern): string {
419
+ const patternAnalysis = bashPatternAnalyses.get(pattern);
420
+ if (
421
+ patternAnalysis &&
422
+ patternAnalysis.errors.length === 0 &&
423
+ patternAnalysis.redirects.length === 0 &&
424
+ isStructurallyPlainSingleCommand(patternAnalysis)
425
+ ) {
426
+ return patternAnalysis.commands[0]?.text ?? pattern.argumentPattern ?? "";
427
+ }
428
+ return pattern.argumentPattern ?? "";
429
+ }
430
+
431
+ function matchesBashArgumentPattern(
432
+ argumentPattern: string,
433
+ candidate: string,
434
+ overflowPolicy: WildcardOverflowPolicy,
435
+ ): boolean {
436
+ if (matchesWildcardPattern(argumentPattern, candidate, overflowPolicy)) {
437
+ return true;
438
+ }
439
+ if (overflowPolicy !== "match" || !argumentPattern.endsWith(" *")) {
440
+ return false;
441
+ }
442
+ return matchesWildcardPattern(
443
+ argumentPattern.slice(0, -2),
444
+ candidate,
445
+ overflowPolicy,
446
+ );
447
+ }
448
+
449
+ /** Match a scoped permission rule against a concrete tool call. */
450
+ export function matchesToolPattern(
451
+ pattern: ToolPattern,
452
+ toolName: string,
453
+ input: Record<string, unknown>,
454
+ cwd: string,
455
+ overflowPolicy: WildcardOverflowPolicy = "match",
456
+ bashAnalysis?: BashAnalysis,
457
+ ): boolean {
458
+ if (!pattern.toolName) return overflowPolicy === "match";
459
+ if (pattern.toolName !== normalizeToolName(toolName)) return false;
460
+ if (pattern.argumentPattern === undefined) return true;
461
+ if (pattern.argumentPattern.trim() === "") {
462
+ return overflowPolicy === "match";
463
+ }
464
+ if (
465
+ toolName === "bash" &&
466
+ (bashPatternAnalyses.get(pattern)?.errors.length ?? 0) > 0
467
+ ) {
468
+ return overflowPolicy === "match";
469
+ }
470
+ if (toolName === "bash" && bashAnalysis) {
471
+ if (bashAnalysis.errors.length > 0) return overflowPolicy === "match";
472
+ const candidates = overflowPolicy === "match"
473
+ ? [bashAnalysis.source, ...bashAnalysis.commands.map((command) => command.text)]
474
+ : [bashAnalysis.source];
475
+ const argumentPattern = normalizedBashArgumentPattern(pattern);
476
+ return candidates.some((candidate) =>
477
+ matchesBashArgumentPattern(argumentPattern, candidate, overflowPolicy)
478
+ );
479
+ }
480
+ const argumentPatterns = isPermissionPathTool(toolName)
481
+ ? permissionPathPatternVariants(pattern.argumentPattern, cwd)
482
+ : [pattern.argumentPattern];
483
+ const primaryArguments = getPrimaryArguments(
484
+ toolName,
485
+ input,
486
+ cwd,
487
+ overflowPolicy,
488
+ );
489
+ return argumentPatterns.some((argumentPattern) =>
490
+ primaryArguments.some((primary) =>
491
+ matchesWildcardPattern(argumentPattern, primary, overflowPolicy)
492
+ )
493
+ );
494
+ }
495
+
496
+ /** Return the normalized Bash command that matched a scoped permission rule. */
497
+ export function matchingBashCommandText(
498
+ pattern: ToolPattern,
499
+ bashAnalysis: BashAnalysis | undefined,
500
+ overflowPolicy: WildcardOverflowPolicy = "match",
501
+ ): string | undefined {
502
+ if (!bashAnalysis || pattern.toolName !== "bash") return undefined;
503
+ if (bashAnalysis.errors.length > 0) return undefined;
504
+ if (!pattern.argumentPattern) return undefined;
505
+ const argumentPattern = normalizedBashArgumentPattern(pattern);
506
+ const command = bashAnalysis.commands.find((candidate) =>
507
+ matchesBashArgumentPattern(argumentPattern, candidate.text, overflowPolicy)
508
+ );
509
+ if (command) return command.text;
510
+ return matchesBashArgumentPattern(argumentPattern, bashAnalysis.source, overflowPolicy)
511
+ ? bashAnalysis.source
512
+ : undefined;
513
+ }
514
+
515
+ function redirectListsMatch(
516
+ patternRedirects: BashRedirectAnalysis[],
517
+ inputRedirects: BashRedirectAnalysis[],
518
+ ): boolean {
519
+ if (patternRedirects.length !== inputRedirects.length) return false;
520
+ return patternRedirects.every((pattern, index) => {
521
+ const input = inputRedirects[index];
522
+ if (!input || pattern.heredoc || input.heredoc || input.targetDynamic) {
523
+ return false;
524
+ }
525
+ if (
526
+ pattern.operator !== input.operator ||
527
+ pattern.fileDescriptor !== input.fileDescriptor ||
528
+ pattern.variableName !== input.variableName
529
+ ) {
530
+ return false;
531
+ }
532
+ if (pattern.target === undefined) return input.target === undefined;
533
+ if (input.target === undefined) return false;
534
+ return matchesWildcardPattern(pattern.target, input.target, "no-match");
535
+ });
536
+ }
537
+
538
+ function commandMatchesAllowPattern(
539
+ patternCommand: BashCommandAnalysis,
540
+ inputCommand: BashCommandAnalysis,
541
+ ): boolean {
542
+ return matchesWildcardPattern(
543
+ patternCommand.text,
544
+ inputCommand.text,
545
+ "no-match",
546
+ ) && redirectListsMatch(patternCommand.redirects, inputCommand.redirects);
547
+ }
548
+
549
+ function structuresMatch(pattern: BashAnalysis, input: BashAnalysis): boolean {
550
+ return pattern.structure.length === input.structure.length &&
551
+ pattern.structure.every((token, index) => token === input.structure[index]);
552
+ }
553
+
554
+ function allRedirectsAreCommandRedirects(analysis: BashAnalysis): boolean {
555
+ return analysis.redirects.length === analysis.commands.reduce(
556
+ (count, command) => count + command.redirects.length,
557
+ 0,
558
+ );
559
+ }
560
+
561
+ function isStructurallyPlainSingleCommand(analysis: BashAnalysis): boolean {
562
+ if (analysis.commands.length !== 1) return false;
563
+ if (analysis.structure.length !== 3 + analysis.redirects.length) return false;
564
+ if (analysis.structure[0] !== "script:1") return false;
565
+ if (analysis.structure[1] !== "node:Statement:foreground:0") return false;
566
+ if (!/^node:Command:\d+:\d+$/.test(analysis.structure[2] ?? "")) {
567
+ return false;
568
+ }
569
+ return analysis.structure.slice(3).every((token) =>
570
+ token.startsWith("redirect:")
571
+ );
572
+ }
573
+
574
+ function supportsPerCommandAllowPatterns(analysis: BashAnalysis): boolean {
575
+ return analysis.structure.every((token) =>
576
+ token.startsWith("script:") ||
577
+ token.startsWith("node:Statement:foreground:") ||
578
+ token.startsWith("node:Command:") ||
579
+ token.startsWith("node:AndOr:") ||
580
+ token.startsWith("node:Pipeline:plain:plain:") ||
581
+ token.startsWith("redirect:")
582
+ );
583
+ }
584
+
585
+ /** Whether permission allow rules cover the complete tool call. */
586
+ export function matchesAllowedToolPatterns(
587
+ patterns: ToolPattern[],
588
+ toolName: string,
589
+ input: Record<string, unknown>,
590
+ cwd: string,
591
+ bashAnalysis?: BashAnalysis,
592
+ ): boolean {
593
+ if (toolName !== "bash" || !bashAnalysis) {
594
+ return patterns.some((pattern) =>
595
+ matchesToolPattern(pattern, toolName, input, cwd, "no-match")
596
+ );
597
+ }
598
+ if (
599
+ bashAnalysis.errors.length > 0 ||
600
+ bashAnalysis.commands.length === 0 ||
601
+ !bashAnalysis.allowStructureSafe
602
+ ) {
603
+ return false;
604
+ }
605
+ if (
606
+ bashAnalysis.commands.some((command) =>
607
+ command.dynamicName || command.dynamicShellScript
608
+ )
609
+ ) {
610
+ return false;
611
+ }
612
+
613
+ for (const pattern of patterns) {
614
+ if (pattern.toolName !== "bash") continue;
615
+ const patternAnalysis = bashPatternAnalyses.get(pattern);
616
+ if (
617
+ !patternAnalysis ||
618
+ patternAnalysis.errors.length > 0 ||
619
+ !patternAnalysis.allowStructureSafe ||
620
+ isStructurallyPlainSingleCommand(patternAnalysis) ||
621
+ patternAnalysis.commands.length !== bashAnalysis.commands.length ||
622
+ !structuresMatch(patternAnalysis, bashAnalysis) ||
623
+ !redirectListsMatch(patternAnalysis.redirects, bashAnalysis.redirects)
624
+ ) {
625
+ continue;
626
+ }
627
+ if (
628
+ patternAnalysis.commands.every((patternCommand, index) => {
629
+ const inputCommand = bashAnalysis.commands[index];
630
+ return !!inputCommand &&
631
+ commandMatchesAllowPattern(patternCommand, inputCommand);
632
+ })
633
+ ) {
634
+ return true;
635
+ }
636
+ }
637
+
638
+ const hasBareBashPattern = patterns.some((pattern) =>
639
+ pattern.toolName === "bash" && pattern.argumentPattern === undefined
640
+ );
641
+ if (
642
+ !hasBareBashPattern &&
643
+ !supportsPerCommandAllowPatterns(bashAnalysis)
644
+ ) {
645
+ return false;
646
+ }
647
+ if (!allRedirectsAreCommandRedirects(bashAnalysis)) return false;
648
+ return bashAnalysis.commands.every((command) =>
649
+ patterns.some((pattern) => {
650
+ if (pattern.toolName !== "bash") return false;
651
+ if (pattern.argumentPattern === undefined) {
652
+ return command.redirects.length === 0;
653
+ }
654
+ const patternAnalysis = bashPatternAnalyses.get(pattern);
655
+ if (
656
+ !patternAnalysis ||
657
+ patternAnalysis.errors.length > 0 ||
658
+ !isStructurallyPlainSingleCommand(patternAnalysis)
659
+ ) {
660
+ return false;
661
+ }
662
+ const patternCommand = patternAnalysis.commands[0];
663
+ return !!patternCommand &&
664
+ commandMatchesAllowPattern(patternCommand, command);
665
+ })
666
+ );
667
+ }