@czottmann/pi-automode 1.11.0 → 1.13.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,10 +1,41 @@
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,
5
11
  resolveInputPath,
12
+ resolvePathForPolicy,
13
+ resolveToolInputPath,
6
14
  } from "./paths.ts";
7
15
 
16
+ export const MAX_WILDCARD_PATTERN_LENGTH = 4096;
17
+ export const MAX_WILDCARD_INPUT_LENGTH = 1024 * 1024;
18
+
19
+ const bashPatternAnalyses = new WeakMap<ToolPattern, BashAnalysis>();
20
+
21
+ /** Preserve the previous non-Unicode RegExp `/i` case-equivalence rules. */
22
+ function canonicalizeCase(value: string): string {
23
+ let canonical = "";
24
+ for (let index = 0; index < value.length; index += 1) {
25
+ const character = value[index] ?? "";
26
+ const uppercase = character.toUpperCase();
27
+ if (
28
+ uppercase.length !== 1 ||
29
+ (character.charCodeAt(0) >= 128 && uppercase.charCodeAt(0) < 128)
30
+ ) {
31
+ canonical += character;
32
+ } else {
33
+ canonical += uppercase;
34
+ }
35
+ }
36
+ return canonical;
37
+ }
38
+
8
39
  function normalizeToolName(name: string): string {
9
40
  const lower = name.trim().replace(/^@/, "").toLowerCase();
10
41
  const aliases: Record<string, string> = {
@@ -33,50 +64,224 @@ export function parseToolPattern(value: unknown): ToolPattern | undefined {
33
64
 
34
65
  const match = raw.match(/^@?([A-Za-z0-9_-]+)(?:\((.*)\))?$/s);
35
66
  if (!match) return { raw };
36
- return {
37
- raw,
38
- toolName: normalizeToolName(match[1] ?? ""),
39
- argumentPattern: match[2],
40
- };
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;
75
+ }
76
+
77
+ function literalPrefixTable(value: string): number[] {
78
+ const table = new Array<number>(value.length).fill(0);
79
+ let prefixLength = 0;
80
+ for (let index = 1; index < value.length; index += 1) {
81
+ while (
82
+ prefixLength > 0 && value[index] !== value[prefixLength]
83
+ ) {
84
+ prefixLength = table[prefixLength - 1] ?? 0;
85
+ }
86
+ if (value[index] === value[prefixLength]) prefixLength += 1;
87
+ table[index] = prefixLength;
88
+ }
89
+ return table;
90
+ }
91
+
92
+ function findLiteral(
93
+ value: string,
94
+ literal: string,
95
+ start: number,
96
+ end: number,
97
+ ): number {
98
+ const prefixTable = literalPrefixTable(literal);
99
+ let matched = 0;
100
+ for (let index = start; index < end; index += 1) {
101
+ while (matched > 0 && value[index] !== literal[matched]) {
102
+ matched = prefixTable[matched - 1] ?? 0;
103
+ }
104
+ if (value[index] === literal[matched]) matched += 1;
105
+ if (matched === literal.length) return index - literal.length + 1;
106
+ }
107
+ return -1;
108
+ }
109
+
110
+ export type WildcardOverflowPolicy = "match" | "no-match";
111
+
112
+ /**
113
+ * Match a case-insensitive `*` wildcard pattern in linear time.
114
+ *
115
+ * `*` matches zero or more characters, including newlines and path separators.
116
+ * Denial callers use `match` for over-limit values so they fail closed. Allow
117
+ * callers use `no-match` so an oversized input cannot broaden an allow rule.
118
+ */
119
+ export function matchesWildcardPattern(
120
+ pattern: string,
121
+ value: string,
122
+ overflowPolicy: WildcardOverflowPolicy = "match",
123
+ ): boolean {
124
+ if (
125
+ pattern.length > MAX_WILDCARD_PATTERN_LENGTH ||
126
+ value.length > MAX_WILDCARD_INPUT_LENGTH
127
+ ) {
128
+ return overflowPolicy === "match";
129
+ }
130
+
131
+ const normalizedPattern = canonicalizeCase(pattern);
132
+ const normalizedValue = canonicalizeCase(value);
133
+ if (!normalizedPattern.includes("*")) {
134
+ return normalizedPattern === normalizedValue;
135
+ }
136
+
137
+ const startsWithWildcard = normalizedPattern.startsWith("*");
138
+ const endsWithWildcard = normalizedPattern.endsWith("*");
139
+ const literals = normalizedPattern.split("*").filter(Boolean);
140
+ if (literals.length === 0) return true;
141
+
142
+ let literalIndex = 0;
143
+ let valueIndex = 0;
144
+ let lastLiteralIndex = literals.length;
145
+
146
+ if (!startsWithWildcard) {
147
+ const prefix = literals[0] ?? "";
148
+ if (!normalizedValue.startsWith(prefix)) return false;
149
+ valueIndex = prefix.length;
150
+ literalIndex = 1;
151
+ }
152
+
153
+ let searchEnd = normalizedValue.length;
154
+ if (!endsWithWildcard) {
155
+ const suffix = literals[literals.length - 1] ?? "";
156
+ searchEnd -= suffix.length;
157
+ if (
158
+ searchEnd < valueIndex ||
159
+ !normalizedValue.endsWith(suffix)
160
+ ) {
161
+ return false;
162
+ }
163
+ lastLiteralIndex -= 1;
164
+ }
165
+
166
+ for (; literalIndex < lastLiteralIndex; literalIndex += 1) {
167
+ const literal = literals[literalIndex] ?? "";
168
+ const found = findLiteral(
169
+ normalizedValue,
170
+ literal,
171
+ valueIndex,
172
+ searchEnd,
173
+ );
174
+ if (found < 0) return false;
175
+ valueIndex = found + literal.length;
176
+ }
177
+
178
+ return true;
179
+ }
180
+
181
+ export function normalizePermissionPathForMatch(
182
+ path: string,
183
+ platform: NodeJS.Platform = process.platform,
184
+ ): string {
185
+ return platform === "win32" ? path.replace(/\\/g, "/") : path;
41
186
  }
42
187
 
43
- function wildcardToRegExp(pattern: string): RegExp {
44
- const escaped = pattern
45
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
46
- .replace(/\*/g, ".*");
47
- return new RegExp(`^${escaped}$`, "i");
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
+ )];
48
205
  }
49
206
 
50
- function getPrimaryArgument(
207
+ function getPrimaryArguments(
51
208
  toolName: string,
52
209
  input: Record<string, unknown>,
53
210
  cwd: string,
54
- ): string {
211
+ overflowPolicy: WildcardOverflowPolicy,
212
+ ): string[] {
55
213
  if (toolName === "bash" && typeof input.command === "string") {
56
- return input.command;
214
+ return [input.command];
57
215
  }
58
216
  if (
59
217
  (toolName === "read" || toolName === "write" || toolName === "edit") &&
60
218
  typeof input.path === "string"
61
219
  ) {
62
- return normalizePathForMatch(
63
- resolveInputPath(cwd, input.path) ?? input.path,
64
- cwd,
65
- );
220
+ return pathArgumentsForMatch(toolName, cwd, input.path, overflowPolicy);
66
221
  }
67
222
  if (toolName === "grep" && typeof input.pattern === "string") {
68
- return input.pattern;
223
+ return [input.pattern];
69
224
  }
70
225
  if (
71
226
  (toolName === "find" || toolName === "ls") &&
72
227
  typeof input.path === "string"
73
228
  ) {
74
- return normalizePathForMatch(
75
- resolveInputPath(cwd, input.path) ?? input.path,
76
- cwd,
77
- );
229
+ return pathArgumentsForMatch(toolName, cwd, input.path, overflowPolicy);
230
+ }
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
+ )];
78
268
  }
79
- return JSON.stringify(input);
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
+ ])];
80
285
  }
81
286
 
82
287
  /**
@@ -89,23 +294,330 @@ export function matchesDeniedPath(
89
294
  resolvedPath: string,
90
295
  deniedPaths: string[],
91
296
  ): boolean {
92
- const normalized = resolvedPath.replace(/\\/g, "/");
297
+ const normalized = resolvedPath.replace(/\\/g, "/").normalize("NFC");
298
+ return deniedPaths.some((pattern) =>
299
+ deniedPatternVariants(pattern).some((variant) =>
300
+ matchesWildcardPattern(variant.normalize("NFC"), normalized)
301
+ )
302
+ );
303
+ }
304
+
305
+ function deniedPatternVariants(pattern: string): string[] {
306
+ const expanded = expandHomePattern(pattern).replace(/\\/g, "/");
307
+ const wildcardIndex = expanded.indexOf("*");
308
+ if (wildcardIndex === -1) {
309
+ const canonical = resolvePathForPolicy(expanded)?.replace(/\\/g, "/");
310
+ return canonical && canonical !== expanded
311
+ ? [expanded, canonical]
312
+ : [expanded];
313
+ }
314
+
315
+ const fixedPrefix = expanded.slice(0, wildcardIndex);
316
+ const lastSlash = fixedPrefix.lastIndexOf("/");
317
+ if (lastSlash < 0) return [expanded];
318
+ const fixedScope = fixedPrefix.slice(0, lastSlash) || "/";
319
+ const canonicalScope = resolvePathForPolicy(fixedScope)?.replace(/\\/g, "/");
320
+ if (!canonicalScope || canonicalScope === fixedScope) return [expanded];
321
+ const suffix = expanded.slice(lastSlash).replace(/^\/+/, "");
322
+ const canonicalPattern = canonicalScope === "/"
323
+ ? `/${suffix}`
324
+ : `${withoutTrailingSlash(canonicalScope)}/${suffix}`;
325
+ return canonicalPattern === expanded
326
+ ? [expanded]
327
+ : [expanded, canonicalPattern];
328
+ }
329
+
330
+ function withoutTrailingSlash(path: string): string {
331
+ if (path === "/" || /^[A-Za-z]:\/$/.test(path)) return path;
332
+ return path.replace(/\/+$/, "");
333
+ }
334
+
335
+ function wildcardCanMatchDescendant(root: string, pattern: string): boolean {
336
+ const normalizedRoot = withoutTrailingSlash(
337
+ canonicalizeCase(root.replace(/\\/g, "/").normalize("NFC")),
338
+ );
339
+ const prefix = normalizedRoot === "/" || /^[A-Za-z]:\/$/.test(normalizedRoot)
340
+ ? normalizedRoot
341
+ : `${normalizedRoot}/`;
342
+ const normalizedPattern = canonicalizeCase(pattern.normalize("NFC"));
343
+ const wildcardIndex = normalizedPattern.indexOf("*");
344
+ if (wildcardIndex < 0) {
345
+ return normalizedPattern.length > prefix.length &&
346
+ normalizedPattern.startsWith(prefix);
347
+ }
348
+
349
+ const fixedPrefix = normalizedPattern.slice(0, wildcardIndex);
350
+ return prefix.startsWith(fixedPrefix) || fixedPrefix.startsWith(prefix);
351
+ }
352
+
353
+ /**
354
+ * Whether a recursive search scope can contain a path matched by `deniedPaths`.
355
+ *
356
+ * The check asks whether the wildcard pattern can match any path beginning
357
+ * with the search-root prefix. It does not scan the search tree.
358
+ */
359
+ export function recursiveSearchMayReachDeniedPath(
360
+ resolvedRoot: string,
361
+ deniedPaths: string[],
362
+ ): boolean {
363
+ if (resolvedRoot.length > MAX_WILDCARD_INPUT_LENGTH) {
364
+ return deniedPaths.length > 0;
365
+ }
93
366
  return deniedPaths.some((pattern) => {
94
- const expanded = expandHomePattern(pattern).replace(/\\/g, "/");
95
- return wildcardToRegExp(expanded).test(normalized);
367
+ if (pattern.length > MAX_WILDCARD_PATTERN_LENGTH) return true;
368
+ return deniedPatternVariants(pattern).some((expanded) =>
369
+ wildcardCanMatchDescendant(resolvedRoot, expanded)
370
+ );
96
371
  });
97
372
  }
98
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
+
99
405
  /** Match a scoped permission rule against a concrete tool call. */
100
406
  export function matchesToolPattern(
101
407
  pattern: ToolPattern,
102
408
  toolName: string,
103
409
  input: Record<string, unknown>,
104
410
  cwd: string,
411
+ overflowPolicy: WildcardOverflowPolicy = "match",
412
+ bashAnalysis?: BashAnalysis,
105
413
  ): boolean {
106
- if (!pattern.toolName) return false;
414
+ if (!pattern.toolName) return overflowPolicy === "match";
107
415
  if (pattern.toolName !== normalizeToolName(toolName)) return false;
108
- if (!pattern.argumentPattern || pattern.argumentPattern === "*") return true;
109
- const primary = getPrimaryArgument(toolName, input, cwd);
110
- return wildcardToRegExp(pattern.argumentPattern).test(primary);
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
+ );
111
623
  }
@@ -37,6 +37,7 @@ export function statusText(
37
37
  `classifier denied: ${state.classifierDenied}`,
38
38
  `permissions.deny rules: ${config.permissionDeny.length}`,
39
39
  `permissions.ask rules: ${config.permissionAsk.length}`,
40
+ `permissions.allow rules: ${config.permissionAllow.length}`,
40
41
  `environment entries: ${config.environment.length}`,
41
42
  `allow entries: ${config.allow.length}`,
42
43
  `soft_deny entries: ${config.softDeny.length}`,
@@ -44,6 +44,8 @@ export type AutoModeSettings = {
44
44
  classifyReadOnlyTools?: boolean;
45
45
  /** Override the fast-stage completion token budget (default 512). */
46
46
  fastClassifierMaxTokens?: number;
47
+ /** Per-request timeout for classifier completions in milliseconds (default 20000). */
48
+ classifierTimeoutMs?: number;
47
49
  /** When true, file tools whose resolved path is inside the working directory are allowed deterministically (no classifier), and outside-CWD file access is classified. */
48
50
  allowInsideWorkingDirectory?: boolean;
49
51
  /** Path glob patterns (file tools) that are always denied before the classifier. Supports `~` and `*` (matches any characters, including `/`). */
@@ -65,6 +67,11 @@ export type SettingsFile = {
65
67
  permissions?: {
66
68
  deny?: unknown;
67
69
  ask?: unknown;
70
+ /**
71
+ * Deterministic allow tier: matching calls skip the classifier only. Read
72
+ * from user-owned config sources, never shared project config.
73
+ */
74
+ allow?: unknown;
68
75
  };
69
76
  };
70
77
 
@@ -86,6 +93,7 @@ export type EffectiveConfig = {
86
93
  classifierReasoningLevel?: ClassifierReasoningLevel;
87
94
  classifyReadOnlyTools: boolean;
88
95
  fastClassifierMaxTokens: number;
96
+ classifierTimeoutMs: number;
89
97
  allowInsideWorkingDirectory: boolean;
90
98
  deniedPaths: string[];
91
99
  maxUserTranscriptTokens: number;
@@ -97,6 +105,7 @@ export type EffectiveConfig = {
97
105
  hardDeny: string[];
98
106
  permissionDeny: ToolPattern[];
99
107
  permissionAsk: ToolPattern[];
108
+ permissionAllow: ToolPattern[];
100
109
  log: LogConfig;
101
110
  };
102
111
 
@@ -128,6 +137,7 @@ export type DenialRecord = {
128
137
  /** Denial kind plus the deterministic allow fast paths, used for decision log entries. */
129
138
  export type DecisionKind =
130
139
  | DenialRecord["kind"]
140
+ | "permissions.allow"
131
141
  | "read-only"
132
142
  | "inside-working-directory";
133
143
 
@@ -161,6 +171,7 @@ export type ClassifierIo = {
161
171
  prompt: {
162
172
  system: string;
163
173
  context: string;
174
+ action: string;
164
175
  fastInstruction: string;
165
176
  detailedInstruction: string;
166
177
  };
@@ -30,7 +30,15 @@ export function safeJson(value: unknown, maxLength = 4000): string {
30
30
  Math.max(200, Math.floor(maxLength / 4)),
31
31
  );
32
32
  }
33
- if (Array.isArray(current)) return current.slice(0, 30);
33
+ if (Array.isArray(current)) {
34
+ if (current.length <= 30) return current;
35
+ return {
36
+ $truncatedArray: true,
37
+ items: current.slice(0, 30),
38
+ omittedEntries: current.length - 30,
39
+ totalEntries: current.length,
40
+ };
41
+ }
34
42
  if (current && typeof current === "object") {
35
43
  if (seen.has(current)) return "[Circular]";
36
44
  seen.add(current);