@czottmann/pi-automode 1.12.0 → 1.14.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.
@@ -1,7 +1,14 @@
1
+ import {
2
+ analyzeBash,
3
+ type BashAnalysis,
4
+ type BashCommandAnalysis,
5
+ type BashRedirectAnalysis,
6
+ } from "./bash.ts";
1
7
  import type { ToolPattern } from "./types.ts";
2
8
  import {
3
9
  expandHomePattern,
4
10
  normalizePathForMatch,
11
+ resolveInputPath,
5
12
  resolvePathForPolicy,
6
13
  resolveToolInputPath,
7
14
  } from "./paths.ts";
@@ -9,6 +16,8 @@ import {
9
16
  export const MAX_WILDCARD_PATTERN_LENGTH = 4096;
10
17
  export const MAX_WILDCARD_INPUT_LENGTH = 1024 * 1024;
11
18
 
19
+ const bashPatternAnalyses = new WeakMap<ToolPattern, BashAnalysis>();
20
+
12
21
  /** Preserve the previous non-Unicode RegExp `/i` case-equivalence rules. */
13
22
  function canonicalizeCase(value: string): string {
14
23
  let canonical = "";
@@ -55,11 +64,14 @@ export function parseToolPattern(value: unknown): ToolPattern | undefined {
55
64
 
56
65
  const match = raw.match(/^@?([A-Za-z0-9_-]+)(?:\((.*)\))?$/s);
57
66
  if (!match) return { raw };
58
- return {
59
- raw,
60
- toolName: normalizeToolName(match[1] ?? ""),
61
- argumentPattern: match[2],
62
- };
67
+ const toolName = normalizeToolName(match[1] ?? "");
68
+ const argumentPattern = match[2];
69
+ const bashPatternAnalysis = toolName === "bash" && argumentPattern
70
+ ? analyzeBash(argumentPattern)
71
+ : undefined;
72
+ const pattern: ToolPattern = { raw, toolName, argumentPattern };
73
+ if (bashPatternAnalysis) bashPatternAnalyses.set(pattern, bashPatternAnalysis);
74
+ return pattern;
63
75
  }
64
76
 
65
77
  function literalPrefixTable(value: string): number[] {
@@ -166,36 +178,110 @@ export function matchesWildcardPattern(
166
178
  return true;
167
179
  }
168
180
 
169
- function getPrimaryArgument(
181
+ export function normalizePermissionPathForMatch(
182
+ path: string,
183
+ platform: NodeJS.Platform = process.platform,
184
+ ): string {
185
+ return platform === "win32" ? path.replace(/\\/g, "/") : path;
186
+ }
187
+
188
+ function pathArgumentsForMatch(
189
+ toolName: string,
190
+ cwd: string,
191
+ value: string,
192
+ overflowPolicy: WildcardOverflowPolicy,
193
+ ): string[] {
194
+ const resolved = resolveToolInputPath(toolName, cwd, value) ?? value;
195
+ const canonical = resolvePathForPolicy(resolved);
196
+ const candidates = canonical
197
+ ? [canonical, normalizePathForMatch(canonical, cwd)]
198
+ : [];
199
+ if (overflowPolicy === "match") {
200
+ candidates.push(resolved, normalizePathForMatch(resolved, cwd));
201
+ }
202
+ return [...new Set(
203
+ candidates.map((candidate) => normalizePermissionPathForMatch(candidate)),
204
+ )];
205
+ }
206
+
207
+ function getPrimaryArguments(
170
208
  toolName: string,
171
209
  input: Record<string, unknown>,
172
210
  cwd: string,
173
- ): string {
211
+ overflowPolicy: WildcardOverflowPolicy,
212
+ ): string[] {
174
213
  if (toolName === "bash" && typeof input.command === "string") {
175
- return input.command;
214
+ return [input.command];
176
215
  }
177
216
  if (
178
217
  (toolName === "read" || toolName === "write" || toolName === "edit") &&
179
218
  typeof input.path === "string"
180
219
  ) {
181
- return normalizePathForMatch(
182
- resolveToolInputPath(toolName, cwd, input.path) ?? input.path,
183
- cwd,
184
- );
220
+ return pathArgumentsForMatch(toolName, cwd, input.path, overflowPolicy);
185
221
  }
186
222
  if (toolName === "grep" && typeof input.pattern === "string") {
187
- return input.pattern;
223
+ return [input.pattern];
188
224
  }
189
225
  if (
190
226
  (toolName === "find" || toolName === "ls") &&
191
227
  typeof input.path === "string"
192
228
  ) {
193
- return normalizePathForMatch(
194
- resolveToolInputPath(toolName, cwd, input.path) ?? input.path,
195
- cwd,
196
- );
229
+ return pathArgumentsForMatch(toolName, cwd, input.path, overflowPolicy);
197
230
  }
198
- return JSON.stringify(input);
231
+ return [JSON.stringify(input)];
232
+ }
233
+
234
+ function isPermissionPathTool(toolName: string): boolean {
235
+ return toolName === "read" ||
236
+ toolName === "write" ||
237
+ toolName === "edit" ||
238
+ toolName === "find" ||
239
+ toolName === "ls";
240
+ }
241
+
242
+ export function appendPermissionPathPatternSuffix(
243
+ scope: string,
244
+ suffix: string,
245
+ ): string {
246
+ const normalizedScope = withoutTrailingSlash(
247
+ normalizePermissionPathForMatch(scope),
248
+ );
249
+ return normalizedScope.endsWith("/")
250
+ ? `${normalizedScope}${suffix}`
251
+ : `${normalizedScope}/${suffix}`;
252
+ }
253
+
254
+ function permissionPathPatternVariants(
255
+ pattern: string,
256
+ cwd: string,
257
+ ): string[] {
258
+ const expanded = normalizePermissionPathForMatch(expandHomePattern(pattern));
259
+ const wildcardIndex = expanded.indexOf("*");
260
+ if (wildcardIndex === -1) {
261
+ const resolved = resolveInputPath(cwd, expanded);
262
+ const canonical = resolved ? resolvePathForPolicy(resolved) : undefined;
263
+ return [...new Set(
264
+ [expanded, resolved, canonical]
265
+ .filter((value): value is string => !!value)
266
+ .map((value) => normalizePermissionPathForMatch(value)),
267
+ )];
268
+ }
269
+
270
+ const fixedPrefix = expanded.slice(0, wildcardIndex);
271
+ const lastSlash = fixedPrefix.lastIndexOf("/");
272
+ if (lastSlash < 0) return [expanded];
273
+ const fixedScope = fixedPrefix.slice(0, lastSlash) || "/";
274
+ const resolvedScope = resolveInputPath(cwd, fixedScope);
275
+ if (!resolvedScope) return [expanded];
276
+ const canonicalScope = resolvePathForPolicy(resolvedScope);
277
+ const suffix = expanded.slice(lastSlash).replace(/^\/+/, "");
278
+ return [...new Set([
279
+ expanded,
280
+ appendPermissionPathPatternSuffix(resolvedScope, suffix),
281
+ ...(canonicalScope
282
+ ? [appendPermissionPathPatternSuffix(canonicalScope, suffix)]
283
+ : []),
284
+ ])];
199
285
  }
200
286
 
201
287
  /**
@@ -285,6 +371,37 @@ export function recursiveSearchMayReachDeniedPath(
285
371
  });
286
372
  }
287
373
 
374
+ function normalizedBashArgumentPattern(pattern: ToolPattern): string {
375
+ const patternAnalysis = bashPatternAnalyses.get(pattern);
376
+ if (
377
+ patternAnalysis &&
378
+ patternAnalysis.errors.length === 0 &&
379
+ patternAnalysis.redirects.length === 0 &&
380
+ isStructurallyPlainSingleCommand(patternAnalysis)
381
+ ) {
382
+ return patternAnalysis.commands[0]?.text ?? pattern.argumentPattern ?? "";
383
+ }
384
+ return pattern.argumentPattern ?? "";
385
+ }
386
+
387
+ function matchesBashArgumentPattern(
388
+ argumentPattern: string,
389
+ candidate: string,
390
+ overflowPolicy: WildcardOverflowPolicy,
391
+ ): boolean {
392
+ if (matchesWildcardPattern(argumentPattern, candidate, overflowPolicy)) {
393
+ return true;
394
+ }
395
+ if (overflowPolicy !== "match" || !argumentPattern.endsWith(" *")) {
396
+ return false;
397
+ }
398
+ return matchesWildcardPattern(
399
+ argumentPattern.slice(0, -2),
400
+ candidate,
401
+ overflowPolicy,
402
+ );
403
+ }
404
+
288
405
  /** Match a scoped permission rule against a concrete tool call. */
289
406
  export function matchesToolPattern(
290
407
  pattern: ToolPattern,
@@ -292,10 +409,215 @@ export function matchesToolPattern(
292
409
  input: Record<string, unknown>,
293
410
  cwd: string,
294
411
  overflowPolicy: WildcardOverflowPolicy = "match",
412
+ bashAnalysis?: BashAnalysis,
295
413
  ): boolean {
296
- if (!pattern.toolName) return false;
414
+ if (!pattern.toolName) return overflowPolicy === "match";
297
415
  if (pattern.toolName !== normalizeToolName(toolName)) return false;
298
- if (!pattern.argumentPattern) return true;
299
- const primary = getPrimaryArgument(toolName, input, cwd);
300
- return matchesWildcardPattern(pattern.argumentPattern, primary, overflowPolicy);
416
+ if (pattern.argumentPattern === undefined) return true;
417
+ if (pattern.argumentPattern.trim() === "") {
418
+ return overflowPolicy === "match";
419
+ }
420
+ if (
421
+ toolName === "bash" &&
422
+ (bashPatternAnalyses.get(pattern)?.errors.length ?? 0) > 0
423
+ ) {
424
+ return overflowPolicy === "match";
425
+ }
426
+ if (toolName === "bash" && bashAnalysis) {
427
+ if (bashAnalysis.errors.length > 0) return overflowPolicy === "match";
428
+ const candidates = overflowPolicy === "match"
429
+ ? [bashAnalysis.source, ...bashAnalysis.commands.map((command) => command.text)]
430
+ : [bashAnalysis.source];
431
+ const argumentPattern = normalizedBashArgumentPattern(pattern);
432
+ return candidates.some((candidate) =>
433
+ matchesBashArgumentPattern(argumentPattern, candidate, overflowPolicy)
434
+ );
435
+ }
436
+ const argumentPatterns = isPermissionPathTool(toolName)
437
+ ? permissionPathPatternVariants(pattern.argumentPattern, cwd)
438
+ : [pattern.argumentPattern];
439
+ const primaryArguments = getPrimaryArguments(
440
+ toolName,
441
+ input,
442
+ cwd,
443
+ overflowPolicy,
444
+ );
445
+ return argumentPatterns.some((argumentPattern) =>
446
+ primaryArguments.some((primary) =>
447
+ matchesWildcardPattern(argumentPattern, primary, overflowPolicy)
448
+ )
449
+ );
450
+ }
451
+
452
+ /** Return the normalized Bash command that matched a scoped permission rule. */
453
+ export function matchingBashCommandText(
454
+ pattern: ToolPattern,
455
+ bashAnalysis: BashAnalysis | undefined,
456
+ overflowPolicy: WildcardOverflowPolicy = "match",
457
+ ): string | undefined {
458
+ if (!bashAnalysis || pattern.toolName !== "bash") return undefined;
459
+ if (bashAnalysis.errors.length > 0) return undefined;
460
+ if (!pattern.argumentPattern) return undefined;
461
+ const argumentPattern = normalizedBashArgumentPattern(pattern);
462
+ const command = bashAnalysis.commands.find((candidate) =>
463
+ matchesBashArgumentPattern(argumentPattern, candidate.text, overflowPolicy)
464
+ );
465
+ if (command) return command.text;
466
+ return matchesBashArgumentPattern(argumentPattern, bashAnalysis.source, overflowPolicy)
467
+ ? bashAnalysis.source
468
+ : undefined;
469
+ }
470
+
471
+ function redirectListsMatch(
472
+ patternRedirects: BashRedirectAnalysis[],
473
+ inputRedirects: BashRedirectAnalysis[],
474
+ ): boolean {
475
+ if (patternRedirects.length !== inputRedirects.length) return false;
476
+ return patternRedirects.every((pattern, index) => {
477
+ const input = inputRedirects[index];
478
+ if (!input || pattern.heredoc || input.heredoc || input.targetDynamic) {
479
+ return false;
480
+ }
481
+ if (
482
+ pattern.operator !== input.operator ||
483
+ pattern.fileDescriptor !== input.fileDescriptor ||
484
+ pattern.variableName !== input.variableName
485
+ ) {
486
+ return false;
487
+ }
488
+ if (pattern.target === undefined) return input.target === undefined;
489
+ if (input.target === undefined) return false;
490
+ return matchesWildcardPattern(pattern.target, input.target, "no-match");
491
+ });
492
+ }
493
+
494
+ function commandMatchesAllowPattern(
495
+ patternCommand: BashCommandAnalysis,
496
+ inputCommand: BashCommandAnalysis,
497
+ ): boolean {
498
+ return matchesWildcardPattern(
499
+ patternCommand.text,
500
+ inputCommand.text,
501
+ "no-match",
502
+ ) && redirectListsMatch(patternCommand.redirects, inputCommand.redirects);
503
+ }
504
+
505
+ function structuresMatch(pattern: BashAnalysis, input: BashAnalysis): boolean {
506
+ return pattern.structure.length === input.structure.length &&
507
+ pattern.structure.every((token, index) => token === input.structure[index]);
508
+ }
509
+
510
+ function allRedirectsAreCommandRedirects(analysis: BashAnalysis): boolean {
511
+ return analysis.redirects.length === analysis.commands.reduce(
512
+ (count, command) => count + command.redirects.length,
513
+ 0,
514
+ );
515
+ }
516
+
517
+ function isStructurallyPlainSingleCommand(analysis: BashAnalysis): boolean {
518
+ if (analysis.commands.length !== 1) return false;
519
+ if (analysis.structure.length !== 3 + analysis.redirects.length) return false;
520
+ if (analysis.structure[0] !== "script:1") return false;
521
+ if (analysis.structure[1] !== "node:Statement:foreground:0") return false;
522
+ if (!/^node:Command:\d+:\d+$/.test(analysis.structure[2] ?? "")) {
523
+ return false;
524
+ }
525
+ return analysis.structure.slice(3).every((token) =>
526
+ token.startsWith("redirect:")
527
+ );
528
+ }
529
+
530
+ function supportsPerCommandAllowPatterns(analysis: BashAnalysis): boolean {
531
+ return analysis.structure.every((token) =>
532
+ token.startsWith("script:") ||
533
+ token.startsWith("node:Statement:foreground:") ||
534
+ token.startsWith("node:Command:") ||
535
+ token.startsWith("node:AndOr:") ||
536
+ token.startsWith("node:Pipeline:plain:plain:") ||
537
+ token.startsWith("redirect:")
538
+ );
539
+ }
540
+
541
+ /** Whether permission allow rules cover the complete tool call. */
542
+ export function matchesAllowedToolPatterns(
543
+ patterns: ToolPattern[],
544
+ toolName: string,
545
+ input: Record<string, unknown>,
546
+ cwd: string,
547
+ bashAnalysis?: BashAnalysis,
548
+ ): boolean {
549
+ if (toolName !== "bash" || !bashAnalysis) {
550
+ return patterns.some((pattern) =>
551
+ matchesToolPattern(pattern, toolName, input, cwd, "no-match")
552
+ );
553
+ }
554
+ if (
555
+ bashAnalysis.errors.length > 0 ||
556
+ bashAnalysis.commands.length === 0 ||
557
+ !bashAnalysis.allowStructureSafe
558
+ ) {
559
+ return false;
560
+ }
561
+ if (
562
+ bashAnalysis.commands.some((command) =>
563
+ command.dynamicName || command.dynamicShellScript
564
+ )
565
+ ) {
566
+ return false;
567
+ }
568
+
569
+ for (const pattern of patterns) {
570
+ if (pattern.toolName !== "bash") continue;
571
+ const patternAnalysis = bashPatternAnalyses.get(pattern);
572
+ if (
573
+ !patternAnalysis ||
574
+ patternAnalysis.errors.length > 0 ||
575
+ !patternAnalysis.allowStructureSafe ||
576
+ isStructurallyPlainSingleCommand(patternAnalysis) ||
577
+ patternAnalysis.commands.length !== bashAnalysis.commands.length ||
578
+ !structuresMatch(patternAnalysis, bashAnalysis) ||
579
+ !redirectListsMatch(patternAnalysis.redirects, bashAnalysis.redirects)
580
+ ) {
581
+ continue;
582
+ }
583
+ if (
584
+ patternAnalysis.commands.every((patternCommand, index) => {
585
+ const inputCommand = bashAnalysis.commands[index];
586
+ return !!inputCommand &&
587
+ commandMatchesAllowPattern(patternCommand, inputCommand);
588
+ })
589
+ ) {
590
+ return true;
591
+ }
592
+ }
593
+
594
+ const hasBareBashPattern = patterns.some((pattern) =>
595
+ pattern.toolName === "bash" && pattern.argumentPattern === undefined
596
+ );
597
+ if (
598
+ !hasBareBashPattern &&
599
+ !supportsPerCommandAllowPatterns(bashAnalysis)
600
+ ) {
601
+ return false;
602
+ }
603
+ if (!allRedirectsAreCommandRedirects(bashAnalysis)) return false;
604
+ return bashAnalysis.commands.every((command) =>
605
+ patterns.some((pattern) => {
606
+ if (pattern.toolName !== "bash") return false;
607
+ if (pattern.argumentPattern === undefined) {
608
+ return command.redirects.length === 0;
609
+ }
610
+ const patternAnalysis = bashPatternAnalyses.get(pattern);
611
+ if (
612
+ !patternAnalysis ||
613
+ patternAnalysis.errors.length > 0 ||
614
+ !isStructurallyPlainSingleCommand(patternAnalysis)
615
+ ) {
616
+ return false;
617
+ }
618
+ const patternCommand = patternAnalysis.commands[0];
619
+ return !!patternCommand &&
620
+ commandMatchesAllowPattern(patternCommand, command);
621
+ })
622
+ );
301
623
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  export * from "./auto-mode/classifier.ts";
10
+ export * from "./auto-mode/bash.ts";
10
11
  export * from "./auto-mode/config.ts";
11
12
  export * from "./auto-mode/constants.ts";
12
13
  export * from "./auto-mode/extension.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@czottmann/pi-automode",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "Claude Code-style auto mode guardrail for pi.",
5
5
  "repository": {
6
6
  "url": "https://github.com/czottmann/pi-automode"
@@ -54,5 +54,8 @@
54
54
  "tsx": "^4.22.4",
55
55
  "typebox": "^1.3.10",
56
56
  "typescript": "^5.8.0"
57
+ },
58
+ "dependencies": {
59
+ "unbash": "4.0.10"
57
60
  }
58
61
  }