@gotgenes/pi-permission-system 1.2.1 → 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.
@@ -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
+ }
@@ -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
- extensionConfigPath: string,
18
- extensionConfigExists: boolean,
19
- policyPaths: ResolvedPolicyPaths,
25
+ options: BuildResolvedConfigLogEntryOptions,
20
26
  ): ResolvedConfigLogEntry {
21
27
  return {
22
- extensionConfigPath,
23
- extensionConfigExists,
24
- ...policyPaths,
28
+ ...options.policyPaths,
29
+ legacyGlobalPolicyDetected: options.legacyGlobalPolicyDetected ?? false,
30
+ legacyProjectPolicyDetected: options.legacyProjectPolicyDetected ?? false,
31
+ legacyExtensionConfigDetected:
32
+ options.legacyExtensionConfigDetected ?? false,
25
33
  };
26
34
  }