@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.
- package/CHANGELOG.md +39 -0
- package/README.md +82 -114
- package/docs/GLOSSARY.md +18 -14
- package/docs/adr/ADR-001-permission-precedence-and-trust-boundaries.md +46 -0
- package/docs/adr/ADR-002-global-config-in-extension-data-directory.md +60 -0
- package/docs/adr/INDEX.md +6 -0
- package/docs/automode-classifier-flow.md +201 -107
- package/docs/configuration.md +171 -0
- package/docs/defaults.md +55 -14
- package/docs/diagnostics.md +90 -0
- package/docs/observability-logging.md +61 -26
- package/examples/automode.local.json +5 -0
- package/extensions/auto-mode/bash.ts +692 -0
- package/extensions/auto-mode/classifier.ts +172 -18
- package/extensions/auto-mode/config.ts +307 -32
- package/extensions/auto-mode/constants.ts +7 -1
- package/extensions/auto-mode/extension.ts +393 -44
- package/extensions/auto-mode/hard-deny.ts +103 -148
- package/extensions/auto-mode/log.ts +60 -5
- package/extensions/auto-mode/paths.ts +124 -14
- package/extensions/auto-mode/permissions.ts +542 -30
- package/extensions/auto-mode/state.ts +1 -0
- package/extensions/auto-mode/types.ts +11 -0
- package/extensions/auto-mode/utils.ts +9 -1
- package/extensions/auto-mode.ts +1 -0
- package/package.json +12 -2
- package/skills/automode-diagnostics/SKILL.md +63 -0
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
argumentPattern
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
207
|
+
function getPrimaryArguments(
|
|
51
208
|
toolName: string,
|
|
52
209
|
input: Record<string, unknown>,
|
|
53
210
|
cwd: string,
|
|
54
|
-
|
|
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
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
return
|
|
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
|
|
414
|
+
if (!pattern.toolName) return overflowPolicy === "match";
|
|
107
415
|
if (pattern.toolName !== normalizeToolName(toolName)) return false;
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
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))
|
|
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);
|