@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.
- package/CHANGELOG.md +81 -0
- package/LICENSE.md +22 -0
- package/README.md +262 -0
- package/docs/GLOSSARY.md +41 -0
- 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 +449 -0
- package/docs/configuration.md +226 -0
- package/docs/defaults.md +178 -0
- package/docs/diagnostics.md +90 -0
- package/docs/observability-logging.md +160 -0
- package/examples/automode.local.json +45 -0
- package/extensions/auto-mode/bash.ts +692 -0
- package/extensions/auto-mode/classifier.ts +940 -0
- package/extensions/auto-mode/config.ts +948 -0
- package/extensions/auto-mode/constants.ts +232 -0
- package/extensions/auto-mode/extension.ts +1118 -0
- package/extensions/auto-mode/hard-deny.ts +429 -0
- package/extensions/auto-mode/jev.ts +338 -0
- package/extensions/auto-mode/log.ts +173 -0
- package/extensions/auto-mode/model-selector.ts +113 -0
- package/extensions/auto-mode/model.ts +13 -0
- package/extensions/auto-mode/paths.ts +303 -0
- package/extensions/auto-mode/permissions.ts +667 -0
- package/extensions/auto-mode/state.ts +106 -0
- package/extensions/auto-mode/transcript.ts +236 -0
- package/extensions/auto-mode/types.ts +210 -0
- package/extensions/auto-mode/utils.ts +54 -0
- package/extensions/auto-mode.ts +27 -0
- package/package.json +61 -0
- package/skills/automode-diagnostics/SKILL.md +63 -0
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
linkSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { dirname, resolve } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_ALLOW,
|
|
13
|
+
DEFAULT_ALLOW_INSIDE_WORKING_DIRECTORY,
|
|
14
|
+
DEFAULT_CLASSIFIER_TIMEOUT_MS,
|
|
15
|
+
DEFAULT_CLASSIFY_READ_ONLY_TOOLS,
|
|
16
|
+
DEFAULT_DENIED_PATHS,
|
|
17
|
+
DEFAULT_ENVIRONMENT,
|
|
18
|
+
DEFAULT_FAST_CLASSIFIER_MAX_TOKENS,
|
|
19
|
+
DEFAULT_HARD_DENY,
|
|
20
|
+
DEFAULT_INTERACTIVE_CONFIRM,
|
|
21
|
+
DEFAULT_LOG_CONFIG,
|
|
22
|
+
DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS,
|
|
23
|
+
DEFAULT_MAX_USER_TRANSCRIPT_TOKENS,
|
|
24
|
+
DEFAULT_PROTECTED_PATHS,
|
|
25
|
+
DEFAULT_SOFT_DENY,
|
|
26
|
+
MAX_CLASSIFIER_TIMEOUT_MS,
|
|
27
|
+
PI_GLOBAL_SETTINGS,
|
|
28
|
+
PI_LEGACY_GLOBAL_SETTINGS,
|
|
29
|
+
PI_PROJECT_LOCAL_SETTINGS,
|
|
30
|
+
PI_PROJECT_SHARED_SETTINGS,
|
|
31
|
+
} from "./constants.ts";
|
|
32
|
+
import {
|
|
33
|
+
MAX_WILDCARD_PATTERN_LENGTH,
|
|
34
|
+
parseToolPattern,
|
|
35
|
+
} from "./permissions.ts";
|
|
36
|
+
import type {
|
|
37
|
+
AutoModeSettings,
|
|
38
|
+
ClassifierReasoningLevel,
|
|
39
|
+
ConfigLoadResult,
|
|
40
|
+
EffectiveConfig,
|
|
41
|
+
LoadedSettingsFile,
|
|
42
|
+
LogConfig,
|
|
43
|
+
SettingsFile,
|
|
44
|
+
SettingsSources,
|
|
45
|
+
ToolPattern,
|
|
46
|
+
} from "./types.ts";
|
|
47
|
+
import { hasOwn, stringArray } from "./utils.ts";
|
|
48
|
+
|
|
49
|
+
export type GlobalConfigPreparation = {
|
|
50
|
+
status: "current" | "migrated" | "conflict" | "failed";
|
|
51
|
+
activePath: string;
|
|
52
|
+
diagnostic?: string;
|
|
53
|
+
notification?: string;
|
|
54
|
+
writeBlockedReason?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type PrepareGlobalConfigOptions = {
|
|
58
|
+
currentPath?: string;
|
|
59
|
+
legacyPath?: string;
|
|
60
|
+
moveFile?: (source: string, destination: string) => void;
|
|
61
|
+
unlinkFile?: (path: string) => void;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function sameFileIdentity(firstPath: string, secondPath: string): boolean {
|
|
65
|
+
try {
|
|
66
|
+
const first = lstatSync(firstPath);
|
|
67
|
+
const second = lstatSync(secondPath);
|
|
68
|
+
return first.dev === second.dev && first.ino === second.ino;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function cleanupPublishedDestination(
|
|
75
|
+
destination: string,
|
|
76
|
+
unlinkFile: (path: string) => void,
|
|
77
|
+
originalError: unknown,
|
|
78
|
+
): AggregateError | undefined {
|
|
79
|
+
try {
|
|
80
|
+
unlinkFile(destination);
|
|
81
|
+
return undefined;
|
|
82
|
+
} catch (cleanupError) {
|
|
83
|
+
return new AggregateError(
|
|
84
|
+
[originalError, cleanupError],
|
|
85
|
+
`Could not clean up interrupted Auto Mode config migration at ${destination}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function moveFileWithoutOverwrite(
|
|
91
|
+
source: string,
|
|
92
|
+
destination: string,
|
|
93
|
+
unlinkFile: (path: string) => void,
|
|
94
|
+
): void {
|
|
95
|
+
linkSync(source, destination);
|
|
96
|
+
try {
|
|
97
|
+
unlinkFile(source);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw cleanupPublishedDestination(destination, unlinkFile, error) ?? error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function globalConfigFailure(
|
|
104
|
+
currentPath: string,
|
|
105
|
+
legacyPath: string,
|
|
106
|
+
error: unknown,
|
|
107
|
+
writeBlockedReason?: string,
|
|
108
|
+
): GlobalConfigPreparation {
|
|
109
|
+
const message =
|
|
110
|
+
`Could not move Auto Mode config from ${legacyPath} to ${currentPath}: ${
|
|
111
|
+
error instanceof Error ? error.message : String(error)
|
|
112
|
+
}. Using the legacy config for this session${
|
|
113
|
+
writeBlockedReason ? "; global config writes are disabled" : ""
|
|
114
|
+
}.`;
|
|
115
|
+
return {
|
|
116
|
+
status: "failed",
|
|
117
|
+
activePath: legacyPath,
|
|
118
|
+
diagnostic: message,
|
|
119
|
+
notification: message,
|
|
120
|
+
writeBlockedReason,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function globalConfigConflict(
|
|
125
|
+
currentPath: string,
|
|
126
|
+
legacyPath: string,
|
|
127
|
+
): GlobalConfigPreparation {
|
|
128
|
+
const message =
|
|
129
|
+
`Auto Mode config conflict: using ${currentPath}; legacy config ${legacyPath} is ignored and was not changed.`;
|
|
130
|
+
return {
|
|
131
|
+
status: "conflict",
|
|
132
|
+
activePath: currentPath,
|
|
133
|
+
diagnostic: message,
|
|
134
|
+
notification: message,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Select one global config path for this runtime and migrate a legacy file when possible. */
|
|
139
|
+
export function prepareGlobalConfig(
|
|
140
|
+
options: PrepareGlobalConfigOptions = {},
|
|
141
|
+
): GlobalConfigPreparation {
|
|
142
|
+
const currentPath = options.currentPath ?? PI_GLOBAL_SETTINGS[0];
|
|
143
|
+
const legacyPath = options.legacyPath ?? PI_LEGACY_GLOBAL_SETTINGS;
|
|
144
|
+
const currentExists = existsSync(currentPath);
|
|
145
|
+
const legacyExists = existsSync(legacyPath);
|
|
146
|
+
const unlinkFile = options.unlinkFile ?? unlinkSync;
|
|
147
|
+
|
|
148
|
+
if (currentExists && legacyExists) {
|
|
149
|
+
if (!sameFileIdentity(currentPath, legacyPath)) {
|
|
150
|
+
return globalConfigConflict(currentPath, legacyPath);
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
unlinkFile(legacyPath);
|
|
154
|
+
return {
|
|
155
|
+
status: "migrated",
|
|
156
|
+
activePath: currentPath,
|
|
157
|
+
notification:
|
|
158
|
+
`Completed interrupted Auto Mode config migration from ${legacyPath} to ${currentPath}.`,
|
|
159
|
+
};
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const cleanupError = cleanupPublishedDestination(
|
|
162
|
+
currentPath,
|
|
163
|
+
unlinkFile,
|
|
164
|
+
error,
|
|
165
|
+
);
|
|
166
|
+
return globalConfigFailure(
|
|
167
|
+
currentPath,
|
|
168
|
+
legacyPath,
|
|
169
|
+
cleanupError ?? error,
|
|
170
|
+
cleanupError?.message,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (currentExists || !legacyExists) {
|
|
175
|
+
return { status: "current", activePath: currentPath };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
mkdirSync(dirname(currentPath), { recursive: true });
|
|
180
|
+
const moveFile = options.moveFile ??
|
|
181
|
+
((source: string, destination: string) =>
|
|
182
|
+
moveFileWithoutOverwrite(source, destination, unlinkFile));
|
|
183
|
+
moveFile(legacyPath, currentPath);
|
|
184
|
+
return {
|
|
185
|
+
status: "migrated",
|
|
186
|
+
activePath: currentPath,
|
|
187
|
+
notification: `Moved Auto Mode config from ${legacyPath} to ${currentPath}.`,
|
|
188
|
+
};
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (existsSync(currentPath)) {
|
|
191
|
+
if (!sameFileIdentity(currentPath, legacyPath)) {
|
|
192
|
+
return globalConfigConflict(currentPath, legacyPath);
|
|
193
|
+
}
|
|
194
|
+
const cleanupError = cleanupPublishedDestination(
|
|
195
|
+
currentPath,
|
|
196
|
+
unlinkFile,
|
|
197
|
+
error,
|
|
198
|
+
);
|
|
199
|
+
if (cleanupError) {
|
|
200
|
+
return globalConfigFailure(
|
|
201
|
+
currentPath,
|
|
202
|
+
legacyPath,
|
|
203
|
+
cleanupError,
|
|
204
|
+
cleanupError.message,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return globalConfigFailure(currentPath, legacyPath, error);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function readSettingsFile(path: string): LoadedSettingsFile | undefined {
|
|
213
|
+
if (!existsSync(path)) return undefined;
|
|
214
|
+
try {
|
|
215
|
+
const settings = JSON.parse(readFileSync(path, "utf8")) as SettingsFile;
|
|
216
|
+
return {
|
|
217
|
+
path,
|
|
218
|
+
settings,
|
|
219
|
+
diagnostics: validateSettingsFile(settings, path),
|
|
220
|
+
};
|
|
221
|
+
} catch (error) {
|
|
222
|
+
return {
|
|
223
|
+
path,
|
|
224
|
+
diagnostics: [
|
|
225
|
+
`${path}: invalid JSON (${
|
|
226
|
+
error instanceof Error ? error.message : String(error)
|
|
227
|
+
})`,
|
|
228
|
+
],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function validateStringArraySetting(
|
|
234
|
+
value: unknown,
|
|
235
|
+
source: string,
|
|
236
|
+
key: string,
|
|
237
|
+
diagnostics: string[],
|
|
238
|
+
): void {
|
|
239
|
+
if (value === undefined) return;
|
|
240
|
+
if (!Array.isArray(value)) {
|
|
241
|
+
diagnostics.push(`${source}: ${key} must be an array of strings`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
for (const [index, entry] of value.entries()) {
|
|
245
|
+
if (typeof entry !== "string" || entry.trim() === "") {
|
|
246
|
+
diagnostics.push(
|
|
247
|
+
`${source}: ${key}[${index}] must be a non-empty string`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (value.length > 0 && !value.includes("$defaults")) {
|
|
252
|
+
diagnostics.push(
|
|
253
|
+
`${source}: ${key} omits "$defaults" and replaces the built-in ${key} rules`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Validate config shape and emit human-readable diagnostics for `/automode config`. */
|
|
259
|
+
export function validateSettingsFile(
|
|
260
|
+
settings: SettingsFile,
|
|
261
|
+
source: string,
|
|
262
|
+
): string[] {
|
|
263
|
+
const diagnostics: string[] = [];
|
|
264
|
+
const root = settings as Record<string, unknown>;
|
|
265
|
+
for (const key of Object.keys(root)) {
|
|
266
|
+
if (key !== "autoMode" && key !== "permissions") {
|
|
267
|
+
diagnostics.push(`${source}: unknown top-level key ${key}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (settings.autoMode !== undefined) {
|
|
272
|
+
if (
|
|
273
|
+
!settings.autoMode ||
|
|
274
|
+
typeof settings.autoMode !== "object" ||
|
|
275
|
+
Array.isArray(settings.autoMode)
|
|
276
|
+
) {
|
|
277
|
+
diagnostics.push(`${source}: autoMode must be an object`);
|
|
278
|
+
} else {
|
|
279
|
+
const autoMode = settings.autoMode as Record<string, unknown>;
|
|
280
|
+
const knownAutoMode = new Set([
|
|
281
|
+
"enabled",
|
|
282
|
+
"classifierModel",
|
|
283
|
+
"classifierReasoningLevel",
|
|
284
|
+
"classifierTimeoutMs",
|
|
285
|
+
"classifyReadOnlyTools",
|
|
286
|
+
"fastClassifierMaxTokens",
|
|
287
|
+
"allowInsideWorkingDirectory",
|
|
288
|
+
"interactiveConfirm",
|
|
289
|
+
"deniedPaths",
|
|
290
|
+
"maxUserTranscriptTokens",
|
|
291
|
+
"maxToolTranscriptTokens",
|
|
292
|
+
"environment",
|
|
293
|
+
"allow",
|
|
294
|
+
"protectedPaths",
|
|
295
|
+
"soft_deny",
|
|
296
|
+
"softDeny",
|
|
297
|
+
"hard_deny",
|
|
298
|
+
"hardDeny",
|
|
299
|
+
"log",
|
|
300
|
+
]);
|
|
301
|
+
for (const key of Object.keys(autoMode)) {
|
|
302
|
+
if (!knownAutoMode.has(key)) {
|
|
303
|
+
diagnostics.push(`${source}: unknown autoMode key ${key}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (
|
|
307
|
+
hasOwn(autoMode, "enabled") && typeof autoMode.enabled !== "boolean"
|
|
308
|
+
) {
|
|
309
|
+
diagnostics.push(`${source}: autoMode.enabled must be a boolean`);
|
|
310
|
+
}
|
|
311
|
+
if (
|
|
312
|
+
hasOwn(autoMode, "classifierModel") &&
|
|
313
|
+
typeof autoMode.classifierModel !== "string"
|
|
314
|
+
) {
|
|
315
|
+
diagnostics.push(
|
|
316
|
+
`${source}: autoMode.classifierModel must be a provider/model string`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
if (
|
|
320
|
+
hasOwn(autoMode, "classifierReasoningLevel") &&
|
|
321
|
+
!isClassifierReasoningLevel(autoMode.classifierReasoningLevel)
|
|
322
|
+
) {
|
|
323
|
+
diagnostics.push(
|
|
324
|
+
`${source}: autoMode.classifierReasoningLevel must be one of low, medium, high, xhigh, max`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
if (
|
|
328
|
+
hasOwn(autoMode, "classifierTimeoutMs") &&
|
|
329
|
+
(!Number.isInteger(autoMode.classifierTimeoutMs) ||
|
|
330
|
+
(autoMode.classifierTimeoutMs as number) < 1000 ||
|
|
331
|
+
(autoMode.classifierTimeoutMs as number) > MAX_CLASSIFIER_TIMEOUT_MS)
|
|
332
|
+
) {
|
|
333
|
+
diagnostics.push(
|
|
334
|
+
`${source}: autoMode.classifierTimeoutMs must be an integer from 1000 through ${MAX_CLASSIFIER_TIMEOUT_MS}`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (
|
|
338
|
+
hasOwn(autoMode, "classifyReadOnlyTools") &&
|
|
339
|
+
typeof autoMode.classifyReadOnlyTools !== "boolean"
|
|
340
|
+
) {
|
|
341
|
+
diagnostics.push(
|
|
342
|
+
`${source}: autoMode.classifyReadOnlyTools must be a boolean`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (
|
|
346
|
+
hasOwn(autoMode, "fastClassifierMaxTokens") &&
|
|
347
|
+
(!Number.isInteger(autoMode.fastClassifierMaxTokens) ||
|
|
348
|
+
(autoMode.fastClassifierMaxTokens as number) < 16)
|
|
349
|
+
) {
|
|
350
|
+
diagnostics.push(
|
|
351
|
+
`${source}: autoMode.fastClassifierMaxTokens must be an integer of at least 16`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
if (
|
|
355
|
+
hasOwn(autoMode, "allowInsideWorkingDirectory") &&
|
|
356
|
+
typeof autoMode.allowInsideWorkingDirectory !== "boolean"
|
|
357
|
+
) {
|
|
358
|
+
diagnostics.push(
|
|
359
|
+
`${source}: autoMode.allowInsideWorkingDirectory must be a boolean`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
if (
|
|
363
|
+
hasOwn(autoMode, "interactiveConfirm") &&
|
|
364
|
+
typeof autoMode.interactiveConfirm !== "boolean"
|
|
365
|
+
) {
|
|
366
|
+
diagnostics.push(
|
|
367
|
+
`${source}: autoMode.interactiveConfirm must be a boolean`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
validateDeniedPathsSetting(
|
|
371
|
+
autoMode.deniedPaths,
|
|
372
|
+
source,
|
|
373
|
+
diagnostics,
|
|
374
|
+
);
|
|
375
|
+
for (
|
|
376
|
+
const key of [
|
|
377
|
+
"maxUserTranscriptTokens",
|
|
378
|
+
"maxToolTranscriptTokens",
|
|
379
|
+
] as const
|
|
380
|
+
) {
|
|
381
|
+
if (
|
|
382
|
+
hasOwn(autoMode, key) &&
|
|
383
|
+
(!Number.isInteger(autoMode[key]) || Number(autoMode[key]) < 32)
|
|
384
|
+
) {
|
|
385
|
+
diagnostics.push(
|
|
386
|
+
`${source}: autoMode.${key} must be an integer of at least 32`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
validateStringArraySetting(
|
|
391
|
+
autoMode.environment,
|
|
392
|
+
source,
|
|
393
|
+
"autoMode.environment",
|
|
394
|
+
diagnostics,
|
|
395
|
+
);
|
|
396
|
+
validateStringArraySetting(
|
|
397
|
+
autoMode.allow,
|
|
398
|
+
source,
|
|
399
|
+
"autoMode.allow",
|
|
400
|
+
diagnostics,
|
|
401
|
+
);
|
|
402
|
+
validateStringArraySetting(
|
|
403
|
+
autoMode.protectedPaths,
|
|
404
|
+
source,
|
|
405
|
+
"autoMode.protectedPaths",
|
|
406
|
+
diagnostics,
|
|
407
|
+
);
|
|
408
|
+
validateStringArraySetting(
|
|
409
|
+
autoMode.soft_deny ?? autoMode.softDeny,
|
|
410
|
+
source,
|
|
411
|
+
"autoMode.soft_deny",
|
|
412
|
+
diagnostics,
|
|
413
|
+
);
|
|
414
|
+
validateStringArraySetting(
|
|
415
|
+
autoMode.hard_deny ?? autoMode.hardDeny,
|
|
416
|
+
source,
|
|
417
|
+
"autoMode.hard_deny",
|
|
418
|
+
diagnostics,
|
|
419
|
+
);
|
|
420
|
+
if (hasOwn(autoMode, "log")) {
|
|
421
|
+
validateLogSetting(autoMode.log, source, diagnostics);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (settings.permissions !== undefined) {
|
|
427
|
+
if (
|
|
428
|
+
!settings.permissions ||
|
|
429
|
+
typeof settings.permissions !== "object" ||
|
|
430
|
+
Array.isArray(settings.permissions)
|
|
431
|
+
) {
|
|
432
|
+
diagnostics.push(`${source}: permissions must be an object`);
|
|
433
|
+
} else {
|
|
434
|
+
const permissions = settings.permissions as Record<string, unknown>;
|
|
435
|
+
for (const key of Object.keys(permissions)) {
|
|
436
|
+
if (key !== "deny" && key !== "ask" && key !== "allow") {
|
|
437
|
+
diagnostics.push(`${source}: unknown permissions key ${key}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
for (const key of ["deny", "ask", "allow"] as const) {
|
|
441
|
+
const value = permissions[key];
|
|
442
|
+
if (value === undefined) continue;
|
|
443
|
+
if (!Array.isArray(value)) {
|
|
444
|
+
diagnostics.push(
|
|
445
|
+
`${source}: permissions.${key} must be an array of tool patterns`,
|
|
446
|
+
);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
for (const [index, entry] of value.entries()) {
|
|
450
|
+
if (typeof entry !== "string" || !parseToolPattern(entry)) {
|
|
451
|
+
diagnostics.push(
|
|
452
|
+
`${source}: permissions.${key}[${index}] must be a tool pattern string`,
|
|
453
|
+
);
|
|
454
|
+
} else if (entry.length > MAX_WILDCARD_PATTERN_LENGTH) {
|
|
455
|
+
diagnostics.push(
|
|
456
|
+
`${source}: permissions.${key}[${index}] must be at most ${MAX_WILDCARD_PATTERN_LENGTH} characters`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return diagnostics;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
type RuleAccumulator = {
|
|
468
|
+
defaults: string[];
|
|
469
|
+
includeDefaults: boolean;
|
|
470
|
+
entries: string[];
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
function createRuleAccumulator(defaults: string[]): RuleAccumulator {
|
|
474
|
+
return { defaults, includeDefaults: true, entries: [] };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function applyRuleSetting(
|
|
478
|
+
accumulator: RuleAccumulator,
|
|
479
|
+
value: unknown,
|
|
480
|
+
acceptEntry: (entry: string) => boolean = () => true,
|
|
481
|
+
): void {
|
|
482
|
+
const entries = stringArray(value);
|
|
483
|
+
if (!entries) return;
|
|
484
|
+
// Any entry that stringArray or acceptEntry drops marks the list malformed.
|
|
485
|
+
// Fail conservative: keep defaults rather than replace them with a partial list.
|
|
486
|
+
let malformed = Array.isArray(value) && value.length !== entries.length;
|
|
487
|
+
for (const entry of entries) {
|
|
488
|
+
if (entry === "$defaults") continue;
|
|
489
|
+
if (acceptEntry(entry)) {
|
|
490
|
+
accumulator.entries.push(entry);
|
|
491
|
+
} else {
|
|
492
|
+
malformed = true;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
accumulator.includeDefaults = entries.includes("$defaults") || malformed;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function finalizeRuleSetting(accumulator: RuleAccumulator): string[] {
|
|
499
|
+
const base = accumulator.includeDefaults ? accumulator.defaults : [];
|
|
500
|
+
return [...new Set([...base, ...accumulator.entries])];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function validateLogSetting(
|
|
504
|
+
value: unknown,
|
|
505
|
+
source: string,
|
|
506
|
+
diagnostics: string[],
|
|
507
|
+
): void {
|
|
508
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
509
|
+
diagnostics.push(`${source}: autoMode.log must be an object`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const log = value as Record<string, unknown>;
|
|
513
|
+
if (hasOwn(log, "enabled") && typeof log.enabled !== "boolean") {
|
|
514
|
+
diagnostics.push(`${source}: autoMode.log.enabled must be a boolean`);
|
|
515
|
+
}
|
|
516
|
+
if (
|
|
517
|
+
hasOwn(log, "classifierIo") && typeof log.classifierIo !== "boolean"
|
|
518
|
+
) {
|
|
519
|
+
diagnostics.push(`${source}: autoMode.log.classifierIo must be a boolean`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function mergeLog(
|
|
524
|
+
base: LogConfig,
|
|
525
|
+
patch: Partial<LogConfig> | undefined,
|
|
526
|
+
): LogConfig {
|
|
527
|
+
if (!patch) return base;
|
|
528
|
+
return {
|
|
529
|
+
enabled: typeof patch.enabled === "boolean" ? patch.enabled : base.enabled,
|
|
530
|
+
classifierIo: typeof patch.classifierIo === "boolean" ? patch.classifierIo : base.classifierIo,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Validate `deniedPaths`: an array of non-empty path patterns. Unlike the
|
|
536
|
+
* `$defaults` rule lists there is no built-in default list, so `$defaults` is
|
|
537
|
+
* a no-op (accepted for consistency with the other rule lists) and omitting
|
|
538
|
+
* it is not a diagnostic.
|
|
539
|
+
*/
|
|
540
|
+
function validateDeniedPathsSetting(
|
|
541
|
+
value: unknown,
|
|
542
|
+
source: string,
|
|
543
|
+
diagnostics: string[],
|
|
544
|
+
): void {
|
|
545
|
+
if (value === undefined) return;
|
|
546
|
+
if (!Array.isArray(value)) {
|
|
547
|
+
diagnostics.push(`${source}: deniedPaths must be an array of strings`);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
for (const [index, entry] of value.entries()) {
|
|
551
|
+
if (entry === "$defaults") continue;
|
|
552
|
+
if (typeof entry !== "string" || entry.trim() === "") {
|
|
553
|
+
diagnostics.push(
|
|
554
|
+
`${source}: deniedPaths[${index}] must be a non-empty path pattern`,
|
|
555
|
+
);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (entry.length > MAX_WILDCARD_PATTERN_LENGTH) {
|
|
559
|
+
diagnostics.push(
|
|
560
|
+
`${source}: deniedPaths[${index}] must be at most ${MAX_WILDCARD_PATTERN_LENGTH} characters`,
|
|
561
|
+
);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (!DENIED_PATH_PATTERN_PREFIX.test(entry)) {
|
|
565
|
+
diagnostics.push(
|
|
566
|
+
`${source}: deniedPaths[${index}] "${entry}" can never match a resolved absolute path; start it with *, ~, $HOME, \${HOME}, or / (e.g. "**/${entry}")`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* A pattern can only match a resolved absolute path when it starts with a
|
|
574
|
+
* form that anchors it: a leading `/`, a home expansion (`~`, `$HOME`,
|
|
575
|
+
* `${HOME}`), or a `*` wildcard that absorbs the leading slash. Anything else
|
|
576
|
+
* (e.g. `config.json` or `src/secret.txt`) matches only against the bare
|
|
577
|
+
* relative name, which the matcher never sees.
|
|
578
|
+
*/
|
|
579
|
+
const DENIED_PATH_PATTERN_PREFIX =
|
|
580
|
+
/^(?:\/|~(?:\/|$)|\$HOME(?:\/|$)|\$\{HOME\}(?:\/|$)|\*)/;
|
|
581
|
+
|
|
582
|
+
const CLASSIFIER_REASONING_LEVELS = new Set<ClassifierReasoningLevel>([
|
|
583
|
+
"low",
|
|
584
|
+
"medium",
|
|
585
|
+
"high",
|
|
586
|
+
"xhigh",
|
|
587
|
+
"max",
|
|
588
|
+
]);
|
|
589
|
+
|
|
590
|
+
export function isClassifierReasoningLevel(
|
|
591
|
+
value: unknown,
|
|
592
|
+
): value is ClassifierReasoningLevel {
|
|
593
|
+
return typeof value === "string" &&
|
|
594
|
+
CLASSIFIER_REASONING_LEVELS.has(value as ClassifierReasoningLevel);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function validTranscriptBudget(value: unknown): value is number {
|
|
598
|
+
return Number.isInteger(value) && Number(value) >= 32;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function validFastClassifierBudget(value: unknown): value is number {
|
|
602
|
+
return Number.isInteger(value) && Number(value) >= 16;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function validClassifierTimeout(value: unknown): value is number {
|
|
606
|
+
return Number.isInteger(value) &&
|
|
607
|
+
Number(value) >= 1000 &&
|
|
608
|
+
Number(value) <= MAX_CLASSIFIER_TIMEOUT_MS;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function applyAutoModeScalars(
|
|
612
|
+
base: EffectiveConfig,
|
|
613
|
+
settings: AutoModeSettings | undefined,
|
|
614
|
+
): EffectiveConfig {
|
|
615
|
+
if (!settings) return base;
|
|
616
|
+
return {
|
|
617
|
+
...base,
|
|
618
|
+
enabled: typeof settings.enabled === "boolean" ? settings.enabled : base.enabled,
|
|
619
|
+
classifierModel: settings.classifierModel ?? base.classifierModel,
|
|
620
|
+
classifierReasoningLevel: isClassifierReasoningLevel(
|
|
621
|
+
settings.classifierReasoningLevel,
|
|
622
|
+
)
|
|
623
|
+
? settings.classifierReasoningLevel
|
|
624
|
+
: base.classifierReasoningLevel,
|
|
625
|
+
classifyReadOnlyTools: typeof settings.classifyReadOnlyTools === "boolean"
|
|
626
|
+
? settings.classifyReadOnlyTools
|
|
627
|
+
: base.classifyReadOnlyTools,
|
|
628
|
+
allowInsideWorkingDirectory:
|
|
629
|
+
typeof settings.allowInsideWorkingDirectory === "boolean"
|
|
630
|
+
? settings.allowInsideWorkingDirectory
|
|
631
|
+
: base.allowInsideWorkingDirectory,
|
|
632
|
+
interactiveConfirm: typeof settings.interactiveConfirm === "boolean"
|
|
633
|
+
? settings.interactiveConfirm
|
|
634
|
+
: base.interactiveConfirm,
|
|
635
|
+
fastClassifierMaxTokens: validFastClassifierBudget(
|
|
636
|
+
settings.fastClassifierMaxTokens,
|
|
637
|
+
)
|
|
638
|
+
? settings.fastClassifierMaxTokens
|
|
639
|
+
: base.fastClassifierMaxTokens,
|
|
640
|
+
classifierTimeoutMs: validClassifierTimeout(settings.classifierTimeoutMs)
|
|
641
|
+
? settings.classifierTimeoutMs
|
|
642
|
+
: base.classifierTimeoutMs,
|
|
643
|
+
maxUserTranscriptTokens: validTranscriptBudget(
|
|
644
|
+
settings.maxUserTranscriptTokens,
|
|
645
|
+
)
|
|
646
|
+
? settings.maxUserTranscriptTokens
|
|
647
|
+
: base.maxUserTranscriptTokens,
|
|
648
|
+
maxToolTranscriptTokens: validTranscriptBudget(
|
|
649
|
+
settings.maxToolTranscriptTokens,
|
|
650
|
+
)
|
|
651
|
+
? settings.maxToolTranscriptTokens
|
|
652
|
+
: base.maxToolTranscriptTokens,
|
|
653
|
+
log: mergeLog(base.log, settings.log),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function appendPermissionPatterns(
|
|
658
|
+
target: ToolPattern[],
|
|
659
|
+
settings: SettingsFile | undefined,
|
|
660
|
+
key: "deny" | "ask" | "allow",
|
|
661
|
+
): void {
|
|
662
|
+
const values = stringArray(settings?.permissions?.[key]);
|
|
663
|
+
if (!values) return;
|
|
664
|
+
for (const value of values) {
|
|
665
|
+
if (value.length > MAX_WILDCARD_PATTERN_LENGTH) continue;
|
|
666
|
+
const pattern = parseToolPattern(value);
|
|
667
|
+
if (pattern) target.push(pattern);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Merge settings with Claude Code-style precedence using Pi-owned config files.
|
|
673
|
+
*
|
|
674
|
+
* Important details:
|
|
675
|
+
* - shared project `.pi/automode.json` contributes `permissions.deny` and
|
|
676
|
+
* `permissions.ask` but not `permissions.allow` or `autoMode`, so checked-in
|
|
677
|
+
* config can only add permission barriers;
|
|
678
|
+
* - global, project-local, and inline `autoMode` settings combine additively across scopes;
|
|
679
|
+
* - omitting `$defaults` in any scope for a rule list means "replace built-ins" for that list.
|
|
680
|
+
*/
|
|
681
|
+
export function buildEffectiveConfigFromSources(
|
|
682
|
+
sources: SettingsSources = {},
|
|
683
|
+
): EffectiveConfig {
|
|
684
|
+
let config: EffectiveConfig = {
|
|
685
|
+
enabled: true,
|
|
686
|
+
classifyReadOnlyTools: DEFAULT_CLASSIFY_READ_ONLY_TOOLS,
|
|
687
|
+
allowInsideWorkingDirectory: DEFAULT_ALLOW_INSIDE_WORKING_DIRECTORY,
|
|
688
|
+
interactiveConfirm: DEFAULT_INTERACTIVE_CONFIRM,
|
|
689
|
+
deniedPaths: [...DEFAULT_DENIED_PATHS],
|
|
690
|
+
fastClassifierMaxTokens: DEFAULT_FAST_CLASSIFIER_MAX_TOKENS,
|
|
691
|
+
classifierTimeoutMs: DEFAULT_CLASSIFIER_TIMEOUT_MS,
|
|
692
|
+
maxUserTranscriptTokens: DEFAULT_MAX_USER_TRANSCRIPT_TOKENS,
|
|
693
|
+
maxToolTranscriptTokens: DEFAULT_MAX_TOOL_TRANSCRIPT_TOKENS,
|
|
694
|
+
environment: [...DEFAULT_ENVIRONMENT],
|
|
695
|
+
allow: [...DEFAULT_ALLOW],
|
|
696
|
+
protectedPaths: [...DEFAULT_PROTECTED_PATHS],
|
|
697
|
+
softDeny: [...DEFAULT_SOFT_DENY],
|
|
698
|
+
hardDeny: [...DEFAULT_HARD_DENY],
|
|
699
|
+
permissionDeny: [],
|
|
700
|
+
permissionAsk: [],
|
|
701
|
+
permissionAllow: [],
|
|
702
|
+
log: { ...DEFAULT_LOG_CONFIG },
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
const globalSettings = sources.globalSettings ?? [];
|
|
706
|
+
const projectLocalSettings = sources.projectLocalSettings ?? [];
|
|
707
|
+
const projectSharedSettings = sources.projectSharedSettings ?? [];
|
|
708
|
+
const inlineSettings = sources.inlineSettings ?? [];
|
|
709
|
+
|
|
710
|
+
const configurableSettings = [
|
|
711
|
+
...globalSettings,
|
|
712
|
+
...projectLocalSettings,
|
|
713
|
+
...inlineSettings,
|
|
714
|
+
];
|
|
715
|
+
const environment = createRuleAccumulator(DEFAULT_ENVIRONMENT);
|
|
716
|
+
const allow = createRuleAccumulator(DEFAULT_ALLOW);
|
|
717
|
+
const protectedPaths = createRuleAccumulator(DEFAULT_PROTECTED_PATHS);
|
|
718
|
+
const deniedPaths = createRuleAccumulator(DEFAULT_DENIED_PATHS);
|
|
719
|
+
const softDeny = createRuleAccumulator(DEFAULT_SOFT_DENY);
|
|
720
|
+
const hardDeny = createRuleAccumulator(DEFAULT_HARD_DENY);
|
|
721
|
+
|
|
722
|
+
for (const settings of configurableSettings) {
|
|
723
|
+
config = applyAutoModeScalars(config, settings.autoMode);
|
|
724
|
+
applyRuleSetting(environment, settings.autoMode?.environment);
|
|
725
|
+
applyRuleSetting(allow, settings.autoMode?.allow);
|
|
726
|
+
applyRuleSetting(protectedPaths, settings.autoMode?.protectedPaths);
|
|
727
|
+
applyRuleSetting(
|
|
728
|
+
deniedPaths,
|
|
729
|
+
settings.autoMode?.deniedPaths,
|
|
730
|
+
(entry) => entry.length <= MAX_WILDCARD_PATTERN_LENGTH,
|
|
731
|
+
);
|
|
732
|
+
applyRuleSetting(
|
|
733
|
+
softDeny,
|
|
734
|
+
settings.autoMode?.soft_deny ?? settings.autoMode?.softDeny,
|
|
735
|
+
);
|
|
736
|
+
applyRuleSetting(
|
|
737
|
+
hardDeny,
|
|
738
|
+
settings.autoMode?.hard_deny ?? settings.autoMode?.hardDeny,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
config = {
|
|
743
|
+
...config,
|
|
744
|
+
environment: finalizeRuleSetting(environment),
|
|
745
|
+
allow: finalizeRuleSetting(allow),
|
|
746
|
+
protectedPaths: finalizeRuleSetting(protectedPaths),
|
|
747
|
+
deniedPaths: finalizeRuleSetting(deniedPaths),
|
|
748
|
+
softDeny: finalizeRuleSetting(softDeny),
|
|
749
|
+
hardDeny: finalizeRuleSetting(hardDeny),
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
for (
|
|
753
|
+
const settings of [
|
|
754
|
+
...globalSettings,
|
|
755
|
+
...projectSharedSettings,
|
|
756
|
+
...projectLocalSettings,
|
|
757
|
+
...inlineSettings,
|
|
758
|
+
]
|
|
759
|
+
) {
|
|
760
|
+
appendPermissionPatterns(config.permissionDeny, settings, "deny");
|
|
761
|
+
appendPermissionPatterns(config.permissionAsk, settings, "ask");
|
|
762
|
+
}
|
|
763
|
+
for (
|
|
764
|
+
const settings of [
|
|
765
|
+
...globalSettings,
|
|
766
|
+
...projectLocalSettings,
|
|
767
|
+
...inlineSettings,
|
|
768
|
+
]
|
|
769
|
+
) {
|
|
770
|
+
appendPermissionPatterns(config.permissionAllow, settings, "allow");
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return config;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function loadedSettingsToSettings(
|
|
777
|
+
files: Array<LoadedSettingsFile | undefined>,
|
|
778
|
+
): SettingsFile[] {
|
|
779
|
+
return files.flatMap((file) => (file?.settings ? [file.settings] : []));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function loadedSettingsDiagnostics(
|
|
783
|
+
files: Array<LoadedSettingsFile | undefined>,
|
|
784
|
+
): string[] {
|
|
785
|
+
return files.flatMap((file) => file?.diagnostics ?? []);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function ignoredSharedAllowDiagnostics(
|
|
789
|
+
files: Array<LoadedSettingsFile | undefined>,
|
|
790
|
+
): string[] {
|
|
791
|
+
return files.flatMap((file) => {
|
|
792
|
+
const permissions = file?.settings?.permissions;
|
|
793
|
+
if (!file || !permissions || !hasOwn(permissions, "allow")) return [];
|
|
794
|
+
return [
|
|
795
|
+
`${file.path}: permissions.allow is ignored in shared project config. Use a user-owned config source instead`,
|
|
796
|
+
];
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Load config from disk and environment variables, including diagnostics for `/automode config`. Project files require explicit trust. */
|
|
801
|
+
export function loadEffectiveConfigWithDiagnostics(
|
|
802
|
+
cwd: string,
|
|
803
|
+
projectTrusted = false,
|
|
804
|
+
globalSettingsPath = PI_GLOBAL_SETTINGS[0],
|
|
805
|
+
): ConfigLoadResult {
|
|
806
|
+
const inlineSettings: SettingsFile[] = [];
|
|
807
|
+
const diagnostics: string[] = [];
|
|
808
|
+
if (process.env.PI_AUTOMODE_SETTINGS_JSON) {
|
|
809
|
+
try {
|
|
810
|
+
const parsed = JSON.parse(
|
|
811
|
+
process.env.PI_AUTOMODE_SETTINGS_JSON,
|
|
812
|
+
) as SettingsFile;
|
|
813
|
+
inlineSettings.push(parsed);
|
|
814
|
+
diagnostics.push(
|
|
815
|
+
...validateSettingsFile(parsed, "PI_AUTOMODE_SETTINGS_JSON"),
|
|
816
|
+
);
|
|
817
|
+
} catch (error) {
|
|
818
|
+
diagnostics.push(
|
|
819
|
+
`PI_AUTOMODE_SETTINGS_JSON: invalid JSON (${
|
|
820
|
+
error instanceof Error ? error.message : String(error)
|
|
821
|
+
})`,
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const globalFiles = [readSettingsFile(globalSettingsPath)];
|
|
827
|
+
const projectLocalPaths = PI_PROJECT_LOCAL_SETTINGS.map((file) =>
|
|
828
|
+
resolve(cwd, file)
|
|
829
|
+
);
|
|
830
|
+
const projectSharedPaths = PI_PROJECT_SHARED_SETTINGS.map((file) =>
|
|
831
|
+
resolve(cwd, file)
|
|
832
|
+
);
|
|
833
|
+
const projectLocalFiles = projectTrusted
|
|
834
|
+
? projectLocalPaths.map(readSettingsFile)
|
|
835
|
+
: [];
|
|
836
|
+
const projectSharedFiles = projectTrusted
|
|
837
|
+
? projectSharedPaths.map(readSettingsFile)
|
|
838
|
+
: [];
|
|
839
|
+
if (!projectTrusted) {
|
|
840
|
+
for (
|
|
841
|
+
const path of [...projectLocalPaths, ...projectSharedPaths].filter(
|
|
842
|
+
existsSync,
|
|
843
|
+
)
|
|
844
|
+
) {
|
|
845
|
+
diagnostics.push(`${path}: ignored because project is not trusted`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const fileDiagnostics = loadedSettingsDiagnostics([
|
|
849
|
+
...globalFiles,
|
|
850
|
+
...projectLocalFiles,
|
|
851
|
+
...projectSharedFiles,
|
|
852
|
+
]);
|
|
853
|
+
const sharedAllowDiagnostics = ignoredSharedAllowDiagnostics(
|
|
854
|
+
projectSharedFiles,
|
|
855
|
+
);
|
|
856
|
+
|
|
857
|
+
return {
|
|
858
|
+
config: buildEffectiveConfigFromSources({
|
|
859
|
+
globalSettings: loadedSettingsToSettings(globalFiles),
|
|
860
|
+
projectLocalSettings: loadedSettingsToSettings(projectLocalFiles),
|
|
861
|
+
projectSharedSettings: loadedSettingsToSettings(projectSharedFiles),
|
|
862
|
+
inlineSettings,
|
|
863
|
+
}),
|
|
864
|
+
diagnostics: [
|
|
865
|
+
...fileDiagnostics,
|
|
866
|
+
...sharedAllowDiagnostics,
|
|
867
|
+
...diagnostics,
|
|
868
|
+
],
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Load config from disk and environment variables. Exported for tests and diagnostics. */
|
|
873
|
+
export function loadEffectiveConfig(
|
|
874
|
+
cwd: string,
|
|
875
|
+
projectTrusted = false,
|
|
876
|
+
globalSettingsPath = PI_GLOBAL_SETTINGS[0],
|
|
877
|
+
): EffectiveConfig {
|
|
878
|
+
return loadEffectiveConfigWithDiagnostics(
|
|
879
|
+
cwd,
|
|
880
|
+
projectTrusted,
|
|
881
|
+
globalSettingsPath,
|
|
882
|
+
).config;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function readWritableSettingsFile(path: string): SettingsFile {
|
|
886
|
+
if (!existsSync(path)) return {};
|
|
887
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
888
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
889
|
+
throw new Error(`${path}: root must be a JSON object`);
|
|
890
|
+
}
|
|
891
|
+
const settings = parsed as SettingsFile;
|
|
892
|
+
if (
|
|
893
|
+
settings.autoMode !== undefined &&
|
|
894
|
+
(!settings.autoMode ||
|
|
895
|
+
typeof settings.autoMode !== "object" ||
|
|
896
|
+
Array.isArray(settings.autoMode))
|
|
897
|
+
) {
|
|
898
|
+
throw new Error(`${path}: autoMode must be a JSON object`);
|
|
899
|
+
}
|
|
900
|
+
return settings;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Persist the global default classifier model while preserving other settings. */
|
|
904
|
+
export function writeGlobalClassifierModel(
|
|
905
|
+
classifierModel: string,
|
|
906
|
+
path = PI_GLOBAL_SETTINGS[0],
|
|
907
|
+
): void {
|
|
908
|
+
const settings = readWritableSettingsFile(path);
|
|
909
|
+
const next: SettingsFile = {
|
|
910
|
+
...settings,
|
|
911
|
+
autoMode: {
|
|
912
|
+
...settings.autoMode,
|
|
913
|
+
classifierModel,
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
917
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Persist a permissions.allow rule while preserving other settings. Global
|
|
922
|
+
* scope writes the user config; project scope writes the project-local file
|
|
923
|
+
* that Pi reads once the project is trusted.
|
|
924
|
+
*/
|
|
925
|
+
export function persistAllowRule(
|
|
926
|
+
pattern: string,
|
|
927
|
+
scope: "global" | "project",
|
|
928
|
+
cwd: string,
|
|
929
|
+
globalPath = PI_GLOBAL_SETTINGS[0],
|
|
930
|
+
): { path: string; added: boolean } {
|
|
931
|
+
const path = scope === "global"
|
|
932
|
+
? globalPath
|
|
933
|
+
: resolve(cwd, PI_PROJECT_LOCAL_SETTINGS[0]);
|
|
934
|
+
const settings = readWritableSettingsFile(path);
|
|
935
|
+
const existing = Array.isArray(settings.permissions?.allow)
|
|
936
|
+
? settings.permissions.allow.filter(
|
|
937
|
+
(entry): entry is string => typeof entry === "string",
|
|
938
|
+
)
|
|
939
|
+
: [];
|
|
940
|
+
if (existing.includes(pattern)) return { path, added: false };
|
|
941
|
+
const next: SettingsFile = {
|
|
942
|
+
...settings,
|
|
943
|
+
permissions: { ...settings.permissions, allow: [...existing, pattern] },
|
|
944
|
+
};
|
|
945
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
946
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
947
|
+
return { path, added: true };
|
|
948
|
+
}
|