@gotgenes/pi-permission-system 2.0.0 → 3.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 +24 -0
- package/README.md +92 -35
- package/config/config.example.json +6 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +18 -4
- package/src/config-loader.ts +398 -0
- package/src/config-paths.ts +34 -0
- package/src/config-reporter.ts +16 -8
- package/src/index.ts +95 -34
- package/src/permission-manager.ts +25 -111
- package/tests/config-loader.test.ts +364 -0
- package/tests/config-paths.test.ts +78 -0
- package/tests/config-reporter.test.ts +42 -33
- package/tests/extension-config.test.ts +51 -0
- package/tests/permission-system.test.ts +9 -26
- package/tests/session-start.test.ts +8 -33
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { normalize } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { isPermissionState, toRecord } from "./common.js";
|
|
5
|
+
import {
|
|
6
|
+
getGlobalConfigPath,
|
|
7
|
+
getLegacyExtensionConfigPath,
|
|
8
|
+
getLegacyGlobalPolicyPath,
|
|
9
|
+
getLegacyProjectPolicyPath,
|
|
10
|
+
getProjectConfigPath,
|
|
11
|
+
} from "./config-paths.js";
|
|
12
|
+
import type { PermissionDefaultPolicy, PermissionState } from "./types.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Unified config shape combining runtime knobs and policy in one object.
|
|
16
|
+
* All fields are optional so partial configs (project-only, global-only) work.
|
|
17
|
+
*/
|
|
18
|
+
export interface UnifiedPermissionConfig {
|
|
19
|
+
// Runtime knobs
|
|
20
|
+
debugLog?: boolean;
|
|
21
|
+
permissionReviewLog?: boolean;
|
|
22
|
+
yoloMode?: boolean;
|
|
23
|
+
|
|
24
|
+
// Policy
|
|
25
|
+
defaultPolicy?: Partial<PermissionDefaultPolicy>;
|
|
26
|
+
tools?: Record<string, PermissionState>;
|
|
27
|
+
bash?: Record<string, PermissionState>;
|
|
28
|
+
mcp?: Record<string, PermissionState>;
|
|
29
|
+
skills?: Record<string, PermissionState>;
|
|
30
|
+
special?: Record<string, PermissionState>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UnifiedConfigLoadResult {
|
|
34
|
+
config: UnifiedPermissionConfig;
|
|
35
|
+
issues: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEPRECATED_SPECIAL_KEYS: ReadonlySet<string> = new Set([
|
|
39
|
+
"tool_call_limit",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
|
|
43
|
+
"bash",
|
|
44
|
+
"read",
|
|
45
|
+
"write",
|
|
46
|
+
"edit",
|
|
47
|
+
"grep",
|
|
48
|
+
"find",
|
|
49
|
+
"ls",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const SPECIAL_PERMISSION_KEYS = new Set(["doom_loop", "external_directory"]);
|
|
53
|
+
|
|
54
|
+
export function stripJsonComments(input: string): string {
|
|
55
|
+
let output = "";
|
|
56
|
+
let inString = false;
|
|
57
|
+
let stringQuote: '"' | "'" | "" = "";
|
|
58
|
+
let escaping = false;
|
|
59
|
+
let inLineComment = false;
|
|
60
|
+
let inBlockComment = false;
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < input.length; i++) {
|
|
63
|
+
const char = input[i];
|
|
64
|
+
const next = input[i + 1] || "";
|
|
65
|
+
|
|
66
|
+
if (inLineComment) {
|
|
67
|
+
if (char === "\n") {
|
|
68
|
+
inLineComment = false;
|
|
69
|
+
output += char;
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (inBlockComment) {
|
|
75
|
+
if (char === "*" && next === "/") {
|
|
76
|
+
inBlockComment = false;
|
|
77
|
+
i++;
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!inString && char === "/" && next === "/") {
|
|
83
|
+
inLineComment = true;
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!inString && char === "/" && next === "*") {
|
|
89
|
+
inBlockComment = true;
|
|
90
|
+
i++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
output += char;
|
|
95
|
+
|
|
96
|
+
if (!inString && (char === '"' || char === "'")) {
|
|
97
|
+
inString = true;
|
|
98
|
+
stringQuote = char;
|
|
99
|
+
escaping = false;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!inString) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (escaping) {
|
|
108
|
+
escaping = false;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (char === "\\") {
|
|
113
|
+
escaping = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (char === stringQuote) {
|
|
118
|
+
inString = false;
|
|
119
|
+
stringQuote = "";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return output;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizePartialPolicy(
|
|
127
|
+
value: unknown,
|
|
128
|
+
): Partial<PermissionDefaultPolicy> | undefined {
|
|
129
|
+
const record = toRecord(value);
|
|
130
|
+
const normalized: Partial<PermissionDefaultPolicy> = {};
|
|
131
|
+
let hasAny = false;
|
|
132
|
+
|
|
133
|
+
for (const key of ["tools", "bash", "mcp", "skills", "special"] as const) {
|
|
134
|
+
if (isPermissionState(record[key])) {
|
|
135
|
+
normalized[key] = record[key] as PermissionState;
|
|
136
|
+
hasAny = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return hasAny ? normalized : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function normalizePermissionRecord(
|
|
144
|
+
value: unknown,
|
|
145
|
+
): Record<string, PermissionState> | undefined {
|
|
146
|
+
const record = toRecord(value);
|
|
147
|
+
const normalized: Record<string, PermissionState> = {};
|
|
148
|
+
let hasAny = false;
|
|
149
|
+
|
|
150
|
+
for (const [key, state] of Object.entries(record)) {
|
|
151
|
+
if (isPermissionState(state)) {
|
|
152
|
+
normalized[key] = state;
|
|
153
|
+
hasAny = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return hasAny ? normalized : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function normalizeOptionalBoolean(value: unknown): boolean | undefined {
|
|
161
|
+
if (typeof value === "boolean") {
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Normalize raw parsed JSON into the unified config shape.
|
|
169
|
+
* Handles top-level shorthand keys (e.g. `bash: "allow"` at root)
|
|
170
|
+
* and deprecated special keys, collecting issues along the way.
|
|
171
|
+
*/
|
|
172
|
+
export function normalizeUnifiedConfig(raw: unknown): {
|
|
173
|
+
config: UnifiedPermissionConfig;
|
|
174
|
+
issues: string[];
|
|
175
|
+
} {
|
|
176
|
+
const record = toRecord(raw);
|
|
177
|
+
const issues: string[] = [];
|
|
178
|
+
|
|
179
|
+
const config: UnifiedPermissionConfig = {};
|
|
180
|
+
|
|
181
|
+
// Runtime knobs
|
|
182
|
+
const debugLog = normalizeOptionalBoolean(record.debugLog);
|
|
183
|
+
if (debugLog !== undefined) config.debugLog = debugLog;
|
|
184
|
+
|
|
185
|
+
const permissionReviewLog = normalizeOptionalBoolean(
|
|
186
|
+
record.permissionReviewLog,
|
|
187
|
+
);
|
|
188
|
+
if (permissionReviewLog !== undefined)
|
|
189
|
+
config.permissionReviewLog = permissionReviewLog;
|
|
190
|
+
|
|
191
|
+
const yoloMode = normalizeOptionalBoolean(record.yoloMode);
|
|
192
|
+
if (yoloMode !== undefined) config.yoloMode = yoloMode;
|
|
193
|
+
|
|
194
|
+
// Policy
|
|
195
|
+
const defaultPolicy = normalizePartialPolicy(record.defaultPolicy);
|
|
196
|
+
if (defaultPolicy) config.defaultPolicy = defaultPolicy;
|
|
197
|
+
|
|
198
|
+
const tools = normalizePermissionRecord(record.tools);
|
|
199
|
+
if (tools) config.tools = tools;
|
|
200
|
+
|
|
201
|
+
const bash = normalizePermissionRecord(record.bash);
|
|
202
|
+
if (bash) config.bash = bash;
|
|
203
|
+
|
|
204
|
+
const mcp = normalizePermissionRecord(record.mcp);
|
|
205
|
+
if (mcp) config.mcp = mcp;
|
|
206
|
+
|
|
207
|
+
const skills = normalizePermissionRecord(record.skills);
|
|
208
|
+
if (skills) config.skills = skills;
|
|
209
|
+
|
|
210
|
+
const special = normalizePermissionRecord(record.special);
|
|
211
|
+
if (special) config.special = special;
|
|
212
|
+
|
|
213
|
+
// Detect deprecated special keys
|
|
214
|
+
const rawSpecial = toRecord(record.special);
|
|
215
|
+
for (const key of DEPRECATED_SPECIAL_KEYS) {
|
|
216
|
+
if (key in rawSpecial) {
|
|
217
|
+
issues.push(
|
|
218
|
+
`special.${key} is deprecated and ignored — remove it from your config file.`,
|
|
219
|
+
);
|
|
220
|
+
if (config.special) {
|
|
221
|
+
delete config.special[key];
|
|
222
|
+
if (Object.keys(config.special).length === 0) {
|
|
223
|
+
delete config.special;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Handle top-level shorthand keys (e.g. `bash: "allow"` at root level)
|
|
230
|
+
for (const [key, value] of Object.entries(record)) {
|
|
231
|
+
if (!isPermissionState(value)) continue;
|
|
232
|
+
|
|
233
|
+
if (BUILT_IN_TOOL_PERMISSION_NAMES.has(key)) {
|
|
234
|
+
config.tools = { ...(config.tools || {}), [key]: value };
|
|
235
|
+
} else if (SPECIAL_PERMISSION_KEYS.has(key)) {
|
|
236
|
+
config.special = { ...(config.special || {}), [key]: value };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { config, issues };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Merge two unified configs. Object-shaped fields (defaultPolicy, tools, bash,
|
|
245
|
+
* mcp, skills, special) are shallow-merged (override wins per-key). Scalar
|
|
246
|
+
* fields (debugLog, permissionReviewLog, yoloMode) are replaced when present
|
|
247
|
+
* in the override.
|
|
248
|
+
*/
|
|
249
|
+
export function mergeUnifiedConfigs(
|
|
250
|
+
base: UnifiedPermissionConfig,
|
|
251
|
+
override: UnifiedPermissionConfig,
|
|
252
|
+
): UnifiedPermissionConfig {
|
|
253
|
+
const merged: UnifiedPermissionConfig = {};
|
|
254
|
+
|
|
255
|
+
// Scalars: override replaces base when defined
|
|
256
|
+
for (const key of ["debugLog", "permissionReviewLog", "yoloMode"] as const) {
|
|
257
|
+
const value = override[key] ?? base[key];
|
|
258
|
+
if (value !== undefined) {
|
|
259
|
+
merged[key] = value;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Object fields: shallow spread merge
|
|
264
|
+
for (const key of [
|
|
265
|
+
"defaultPolicy",
|
|
266
|
+
"tools",
|
|
267
|
+
"bash",
|
|
268
|
+
"mcp",
|
|
269
|
+
"skills",
|
|
270
|
+
"special",
|
|
271
|
+
] as const) {
|
|
272
|
+
const baseVal = base[key];
|
|
273
|
+
const overrideVal = override[key];
|
|
274
|
+
if (baseVal || overrideVal) {
|
|
275
|
+
merged[key] = { ...(baseVal || {}), ...(overrideVal || {}) } as never;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return merged;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface MergedConfigResult {
|
|
283
|
+
global: UnifiedPermissionConfig;
|
|
284
|
+
project: UnifiedPermissionConfig;
|
|
285
|
+
merged: UnifiedPermissionConfig;
|
|
286
|
+
issues: string[];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Load global and project configs from the new layout, detect legacy files,
|
|
291
|
+
* merge everything, and collect issues.
|
|
292
|
+
*
|
|
293
|
+
* Merge order:
|
|
294
|
+
* 1. Legacy global policy (if present) — lowest precedence
|
|
295
|
+
* 2. Legacy extension runtime config (if present and path differs from new global)
|
|
296
|
+
* 3. New global config
|
|
297
|
+
* 4. Legacy project policy (if present)
|
|
298
|
+
* 5. New project config — highest precedence
|
|
299
|
+
*/
|
|
300
|
+
export function loadAndMergeConfigs(
|
|
301
|
+
agentDir: string,
|
|
302
|
+
cwd: string,
|
|
303
|
+
extensionRoot: string,
|
|
304
|
+
): MergedConfigResult {
|
|
305
|
+
const allIssues: string[] = [];
|
|
306
|
+
|
|
307
|
+
const newGlobalPath = getGlobalConfigPath(agentDir);
|
|
308
|
+
const newProjectPath = getProjectConfigPath(cwd);
|
|
309
|
+
const legacyGlobalPolicyPath = getLegacyGlobalPolicyPath(agentDir);
|
|
310
|
+
const legacyProjectPolicyPath = getLegacyProjectPolicyPath(cwd);
|
|
311
|
+
const legacyExtConfigPath = getLegacyExtensionConfigPath(extensionRoot);
|
|
312
|
+
|
|
313
|
+
// Start with empty
|
|
314
|
+
let merged: UnifiedPermissionConfig = {};
|
|
315
|
+
|
|
316
|
+
// 1. Legacy global policy
|
|
317
|
+
if (existsSync(legacyGlobalPolicyPath)) {
|
|
318
|
+
const legacy = loadUnifiedConfig(legacyGlobalPolicyPath);
|
|
319
|
+
allIssues.push(
|
|
320
|
+
`Legacy global policy found at '${legacyGlobalPolicyPath}'. ` +
|
|
321
|
+
`Move it to '${newGlobalPath}':\n` +
|
|
322
|
+
` mv '${legacyGlobalPolicyPath}' '${newGlobalPath}'`,
|
|
323
|
+
);
|
|
324
|
+
allIssues.push(...legacy.issues);
|
|
325
|
+
merged = mergeUnifiedConfigs(merged, legacy.config);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 2. Legacy extension runtime config (only if different from new global path)
|
|
329
|
+
const normalizedLegacyExt = normalize(legacyExtConfigPath);
|
|
330
|
+
const normalizedNewGlobal = normalize(newGlobalPath);
|
|
331
|
+
if (
|
|
332
|
+
normalizedLegacyExt !== normalizedNewGlobal &&
|
|
333
|
+
existsSync(legacyExtConfigPath)
|
|
334
|
+
) {
|
|
335
|
+
const legacy = loadUnifiedConfig(legacyExtConfigPath);
|
|
336
|
+
allIssues.push(
|
|
337
|
+
`Legacy extension config found at '${legacyExtConfigPath}'. ` +
|
|
338
|
+
`Move runtime settings to '${newGlobalPath}':\n` +
|
|
339
|
+
` mv '${legacyExtConfigPath}' '${newGlobalPath}'`,
|
|
340
|
+
);
|
|
341
|
+
allIssues.push(...legacy.issues);
|
|
342
|
+
merged = mergeUnifiedConfigs(merged, legacy.config);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 3. New global config
|
|
346
|
+
const globalResult = loadUnifiedConfig(newGlobalPath);
|
|
347
|
+
allIssues.push(...globalResult.issues);
|
|
348
|
+
const globalConfig = globalResult.config;
|
|
349
|
+
merged = mergeUnifiedConfigs(merged, globalConfig);
|
|
350
|
+
|
|
351
|
+
// 4. Legacy project policy
|
|
352
|
+
if (existsSync(legacyProjectPolicyPath)) {
|
|
353
|
+
const legacy = loadUnifiedConfig(legacyProjectPolicyPath);
|
|
354
|
+
allIssues.push(
|
|
355
|
+
`Legacy project policy found at '${legacyProjectPolicyPath}'. ` +
|
|
356
|
+
`Move it to '${newProjectPath}':\n` +
|
|
357
|
+
` mv '${legacyProjectPolicyPath}' '${newProjectPath}'`,
|
|
358
|
+
);
|
|
359
|
+
allIssues.push(...legacy.issues);
|
|
360
|
+
merged = mergeUnifiedConfigs(merged, legacy.config);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 5. New project config
|
|
364
|
+
const projectResult = loadUnifiedConfig(newProjectPath);
|
|
365
|
+
allIssues.push(...projectResult.issues);
|
|
366
|
+
const projectConfig = projectResult.config;
|
|
367
|
+
merged = mergeUnifiedConfigs(merged, projectConfig);
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
global: globalConfig,
|
|
371
|
+
project: projectConfig,
|
|
372
|
+
merged,
|
|
373
|
+
issues: allIssues,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Load and normalize a unified config file.
|
|
379
|
+
* Returns an empty config with no issues if the file does not exist.
|
|
380
|
+
* Returns an empty config with an issue if the file cannot be parsed.
|
|
381
|
+
*/
|
|
382
|
+
export function loadUnifiedConfig(path: string): UnifiedConfigLoadResult {
|
|
383
|
+
if (!existsSync(path)) {
|
|
384
|
+
return { config: {}, issues: [] };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const raw = readFileSync(path, "utf-8");
|
|
389
|
+
const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
|
|
390
|
+
return normalizeUnifiedConfig(parsed);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
393
|
+
return {
|
|
394
|
+
config: {},
|
|
395
|
+
issues: [`Failed to read config at '${path}': ${message}`],
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
const EXTENSION_ID = "pi-permission-system";
|
|
4
|
+
|
|
5
|
+
export const DEBUG_LOG_FILENAME = `${EXTENSION_ID}-debug.jsonl`;
|
|
6
|
+
export const REVIEW_LOG_FILENAME = `${EXTENSION_ID}-permission-review.jsonl`;
|
|
7
|
+
|
|
8
|
+
export function getGlobalConfigDir(agentDir: string): string {
|
|
9
|
+
return join(agentDir, "extensions", EXTENSION_ID);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getGlobalConfigPath(agentDir: string): string {
|
|
13
|
+
return join(getGlobalConfigDir(agentDir), "config.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getGlobalLogsDir(agentDir: string): string {
|
|
17
|
+
return join(getGlobalConfigDir(agentDir), "logs");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getProjectConfigPath(cwd: string): string {
|
|
21
|
+
return join(cwd, ".pi", "extensions", EXTENSION_ID, "config.json");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getLegacyGlobalPolicyPath(agentDir: string): string {
|
|
25
|
+
return join(agentDir, "pi-permissions.jsonc");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getLegacyProjectPolicyPath(cwd: string): string {
|
|
29
|
+
return join(cwd, ".pi", "agent", "pi-permissions.jsonc");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getLegacyExtensionConfigPath(extensionRoot: string): string {
|
|
33
|
+
return join(extensionRoot, "config.json");
|
|
34
|
+
}
|
package/src/config-reporter.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { ResolvedPolicyPaths } from "./permission-manager.js";
|
|
2
2
|
|
|
3
3
|
export interface ResolvedConfigLogEntry {
|
|
4
|
-
extensionConfigPath: string;
|
|
5
|
-
extensionConfigExists: boolean;
|
|
6
4
|
globalConfigPath: string;
|
|
7
5
|
globalConfigExists: boolean;
|
|
8
6
|
projectConfigPath: string | null;
|
|
@@ -11,16 +9,26 @@ export interface ResolvedConfigLogEntry {
|
|
|
11
9
|
agentsDirExists: boolean;
|
|
12
10
|
projectAgentsDir: string | null;
|
|
13
11
|
projectAgentsDirExists: boolean;
|
|
12
|
+
legacyGlobalPolicyDetected: boolean;
|
|
13
|
+
legacyProjectPolicyDetected: boolean;
|
|
14
|
+
legacyExtensionConfigDetected: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BuildResolvedConfigLogEntryOptions {
|
|
18
|
+
policyPaths: ResolvedPolicyPaths;
|
|
19
|
+
legacyGlobalPolicyDetected?: boolean;
|
|
20
|
+
legacyProjectPolicyDetected?: boolean;
|
|
21
|
+
legacyExtensionConfigDetected?: boolean;
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
export function buildResolvedConfigLogEntry(
|
|
17
|
-
|
|
18
|
-
extensionConfigExists: boolean,
|
|
19
|
-
policyPaths: ResolvedPolicyPaths,
|
|
25
|
+
options: BuildResolvedConfigLogEntryOptions,
|
|
20
26
|
): ResolvedConfigLogEntry {
|
|
21
27
|
return {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
...options.policyPaths,
|
|
29
|
+
legacyGlobalPolicyDetected: options.legacyGlobalPolicyDetected ?? false,
|
|
30
|
+
legacyProjectPolicyDetected: options.legacyProjectPolicyDetected ?? false,
|
|
31
|
+
legacyExtensionConfigDetected:
|
|
32
|
+
options.legacyExtensionConfigDetected ?? false,
|
|
25
33
|
};
|
|
26
34
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
writeFileSync,
|
|
10
10
|
} from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
|
-
import { join, normalize, resolve, sep } from "node:path";
|
|
12
|
+
import { dirname, join, normalize, resolve, sep } from "node:path";
|
|
13
13
|
import {
|
|
14
14
|
type ExtensionAPI,
|
|
15
15
|
type ExtensionCommandContext,
|
|
@@ -23,16 +23,25 @@ import {
|
|
|
23
23
|
shouldApplyCachedAgentStartState,
|
|
24
24
|
} from "./before-agent-start-cache.js";
|
|
25
25
|
import { getNonEmptyString, toRecord } from "./common.js";
|
|
26
|
+
import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader.js";
|
|
26
27
|
import { registerPermissionSystemCommand } from "./config-modal.js";
|
|
28
|
+
import {
|
|
29
|
+
DEBUG_LOG_FILENAME,
|
|
30
|
+
getGlobalConfigPath,
|
|
31
|
+
getGlobalLogsDir,
|
|
32
|
+
getLegacyExtensionConfigPath,
|
|
33
|
+
getLegacyGlobalPolicyPath,
|
|
34
|
+
getLegacyProjectPolicyPath,
|
|
35
|
+
getProjectConfigPath,
|
|
36
|
+
REVIEW_LOG_FILENAME,
|
|
37
|
+
} from "./config-paths.js";
|
|
27
38
|
import { buildResolvedConfigLogEntry } from "./config-reporter.js";
|
|
28
39
|
import {
|
|
29
|
-
CONFIG_PATH,
|
|
30
40
|
DEFAULT_EXTENSION_CONFIG,
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
EXTENSION_ROOT,
|
|
42
|
+
ensurePermissionSystemLogsDirectory,
|
|
33
43
|
normalizePermissionSystemConfig,
|
|
34
44
|
type PermissionSystemExtensionConfig,
|
|
35
|
-
savePermissionSystemConfig,
|
|
36
45
|
} from "./extension-config.js";
|
|
37
46
|
import { createPermissionSystemLogger, safeJsonStringify } from "./logging.js";
|
|
38
47
|
import {
|
|
@@ -92,8 +101,13 @@ const PATH_BEARING_TOOLS = new Set([
|
|
|
92
101
|
let extensionConfig: PermissionSystemExtensionConfig = {
|
|
93
102
|
...DEFAULT_EXTENSION_CONFIG,
|
|
94
103
|
};
|
|
104
|
+
const GLOBAL_LOGS_DIR = getGlobalLogsDir(PI_AGENT_DIR);
|
|
95
105
|
const extensionLogger = createPermissionSystemLogger({
|
|
96
106
|
getConfig: () => extensionConfig,
|
|
107
|
+
debugLogPath: join(GLOBAL_LOGS_DIR, DEBUG_LOG_FILENAME),
|
|
108
|
+
reviewLogPath: join(GLOBAL_LOGS_DIR, REVIEW_LOG_FILENAME),
|
|
109
|
+
ensureLogsDirectory: () =>
|
|
110
|
+
ensurePermissionSystemLogsDirectory(GLOBAL_LOGS_DIR),
|
|
97
111
|
});
|
|
98
112
|
const reportedLoggingWarnings = new Set<string>();
|
|
99
113
|
let loggingWarningReporter: ((message: string) => void) | null = null;
|
|
@@ -1217,24 +1231,22 @@ function derivePiProjectPaths(cwd: string | undefined | null): {
|
|
|
1217
1231
|
return null;
|
|
1218
1232
|
}
|
|
1219
1233
|
|
|
1220
|
-
const projectAgentRoot = join(cwd, ".pi", "agent");
|
|
1221
1234
|
return {
|
|
1222
|
-
projectGlobalConfigPath:
|
|
1223
|
-
projectAgentsDir: join(
|
|
1235
|
+
projectGlobalConfigPath: getProjectConfigPath(cwd),
|
|
1236
|
+
projectAgentsDir: join(cwd, ".pi", "agent", "agents"),
|
|
1224
1237
|
};
|
|
1225
1238
|
}
|
|
1226
1239
|
|
|
1227
1240
|
function createPermissionManagerForCwd(
|
|
1228
1241
|
cwd: string | undefined | null,
|
|
1229
1242
|
): PermissionManager {
|
|
1243
|
+
const agentDir = getAgentDir();
|
|
1230
1244
|
const projectPaths = derivePiProjectPaths(cwd);
|
|
1231
|
-
if (!projectPaths) {
|
|
1232
|
-
return new PermissionManager();
|
|
1233
|
-
}
|
|
1234
1245
|
|
|
1235
1246
|
return new PermissionManager({
|
|
1236
|
-
|
|
1237
|
-
|
|
1247
|
+
globalConfigPath: getGlobalConfigPath(agentDir),
|
|
1248
|
+
projectGlobalConfigPath: projectPaths?.projectGlobalConfigPath,
|
|
1249
|
+
projectAgentsDir: projectPaths?.projectAgentsDir,
|
|
1238
1250
|
});
|
|
1239
1251
|
}
|
|
1240
1252
|
|
|
@@ -1269,26 +1281,34 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1269
1281
|
runtimeContext = ctx;
|
|
1270
1282
|
}
|
|
1271
1283
|
|
|
1272
|
-
const
|
|
1273
|
-
|
|
1284
|
+
const cwd = runtimeContext?.cwd ?? null;
|
|
1285
|
+
const agentDir = getAgentDir();
|
|
1286
|
+
const mergeResult = loadAndMergeConfigs(
|
|
1287
|
+
agentDir,
|
|
1288
|
+
cwd ?? "",
|
|
1289
|
+
EXTENSION_ROOT,
|
|
1290
|
+
);
|
|
1291
|
+
const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
|
|
1292
|
+
setExtensionConfig(runtimeConfig);
|
|
1274
1293
|
|
|
1275
1294
|
if (runtimeContext?.hasUI) {
|
|
1276
|
-
syncPermissionSystemStatus(runtimeContext,
|
|
1295
|
+
syncPermissionSystemStatus(runtimeContext, runtimeConfig);
|
|
1277
1296
|
}
|
|
1278
1297
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1298
|
+
const warning =
|
|
1299
|
+
mergeResult.issues.length > 0 ? mergeResult.issues.join("\n") : undefined;
|
|
1300
|
+
if (warning && warning !== lastConfigWarning) {
|
|
1301
|
+
lastConfigWarning = warning;
|
|
1302
|
+
notifyWarning(warning);
|
|
1303
|
+
} else if (!warning) {
|
|
1283
1304
|
lastConfigWarning = null;
|
|
1284
1305
|
}
|
|
1285
1306
|
|
|
1286
1307
|
writeDebugLog("config.loaded", {
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
yoloMode: result.config.yoloMode,
|
|
1308
|
+
warning: warning ?? null,
|
|
1309
|
+
debugLog: runtimeConfig.debugLog,
|
|
1310
|
+
permissionReviewLog: runtimeConfig.permissionReviewLog,
|
|
1311
|
+
yoloMode: runtimeConfig.yoloMode,
|
|
1292
1312
|
});
|
|
1293
1313
|
};
|
|
1294
1314
|
|
|
@@ -1297,11 +1317,35 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1297
1317
|
ctx: ExtensionCommandContext,
|
|
1298
1318
|
): void => {
|
|
1299
1319
|
const normalized = normalizePermissionSystemConfig(next);
|
|
1300
|
-
const
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1320
|
+
const globalPath = getGlobalConfigPath(getAgentDir());
|
|
1321
|
+
|
|
1322
|
+
// Load existing global config and merge runtime knobs into it
|
|
1323
|
+
const existing = loadUnifiedConfig(globalPath);
|
|
1324
|
+
const merged = {
|
|
1325
|
+
...existing.config,
|
|
1326
|
+
debugLog: normalized.debugLog,
|
|
1327
|
+
permissionReviewLog: normalized.permissionReviewLog,
|
|
1328
|
+
yoloMode: normalized.yoloMode,
|
|
1329
|
+
};
|
|
1330
|
+
|
|
1331
|
+
const tmpPath = `${globalPath}.tmp`;
|
|
1332
|
+
try {
|
|
1333
|
+
mkdirSync(dirname(globalPath), { recursive: true });
|
|
1334
|
+
writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
|
|
1335
|
+
renameSync(tmpPath, globalPath);
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
try {
|
|
1338
|
+
if (existsSync(tmpPath)) {
|
|
1339
|
+
unlinkSync(tmpPath);
|
|
1340
|
+
}
|
|
1341
|
+
} catch {
|
|
1342
|
+
// Ignore cleanup failures.
|
|
1304
1343
|
}
|
|
1344
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1345
|
+
ctx.ui.notify(
|
|
1346
|
+
`Failed to save permission-system config at '${globalPath}': ${message}`,
|
|
1347
|
+
"error",
|
|
1348
|
+
);
|
|
1305
1349
|
return;
|
|
1306
1350
|
}
|
|
1307
1351
|
|
|
@@ -1321,7 +1365,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1321
1365
|
registerPermissionSystemCommand(pi, {
|
|
1322
1366
|
getConfig: () => extensionConfig,
|
|
1323
1367
|
setConfig: saveExtensionConfig,
|
|
1324
|
-
getConfigPath:
|
|
1368
|
+
getConfigPath: () => getGlobalConfigPath(getAgentDir()),
|
|
1325
1369
|
});
|
|
1326
1370
|
|
|
1327
1371
|
const createPermissionRequestId = (prefix: string): string => {
|
|
@@ -1470,11 +1514,28 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1470
1514
|
|
|
1471
1515
|
const logResolvedConfigPaths = (): void => {
|
|
1472
1516
|
const policyPaths = permissionManager.getResolvedPolicyPaths();
|
|
1473
|
-
const
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1517
|
+
const cwd = runtimeContext?.cwd ?? null;
|
|
1518
|
+
|
|
1519
|
+
// Detect legacy files for the log entry
|
|
1520
|
+
const agentDir = getAgentDir();
|
|
1521
|
+
const legacyGlobalPolicyDetected = existsSync(
|
|
1522
|
+
getLegacyGlobalPolicyPath(agentDir),
|
|
1477
1523
|
);
|
|
1524
|
+
const legacyProjectPolicyDetected = cwd
|
|
1525
|
+
? existsSync(getLegacyProjectPolicyPath(cwd))
|
|
1526
|
+
: false;
|
|
1527
|
+
const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
|
|
1528
|
+
const newGlobalPath = getGlobalConfigPath(agentDir);
|
|
1529
|
+
const legacyExtensionConfigDetected =
|
|
1530
|
+
normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
|
|
1531
|
+
existsSync(legacyExtConfigPath);
|
|
1532
|
+
|
|
1533
|
+
const entry = buildResolvedConfigLogEntry({
|
|
1534
|
+
policyPaths,
|
|
1535
|
+
legacyGlobalPolicyDetected,
|
|
1536
|
+
legacyProjectPolicyDetected,
|
|
1537
|
+
legacyExtensionConfigDetected,
|
|
1538
|
+
});
|
|
1478
1539
|
writeReviewLog(
|
|
1479
1540
|
"config.resolved",
|
|
1480
1541
|
entry as unknown as Record<string, unknown>,
|