@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.
@@ -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
  }
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
- getPermissionSystemConfigPath,
32
- loadPermissionSystemConfig,
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: join(projectAgentRoot, "pi-permissions.jsonc"),
1223
- projectAgentsDir: join(projectAgentRoot, "agents"),
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
- projectGlobalConfigPath: projectPaths.projectGlobalConfigPath,
1237
- projectAgentsDir: projectPaths.projectAgentsDir,
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 result = loadPermissionSystemConfig();
1273
- setExtensionConfig(result.config);
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, result.config);
1295
+ syncPermissionSystemStatus(runtimeContext, runtimeConfig);
1277
1296
  }
1278
1297
 
1279
- if (result.warning && result.warning !== lastConfigWarning) {
1280
- lastConfigWarning = result.warning;
1281
- notifyWarning(result.warning);
1282
- } else if (!result.warning) {
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
- created: result.created,
1288
- warning: result.warning ?? null,
1289
- debugLog: result.config.debugLog,
1290
- permissionReviewLog: result.config.permissionReviewLog,
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 saved = savePermissionSystemConfig(normalized);
1301
- if (!saved.success) {
1302
- if (saved.error) {
1303
- ctx.ui.notify(saved.error, "error");
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: getPermissionSystemConfigPath,
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 entry = buildResolvedConfigLogEntry(
1474
- CONFIG_PATH,
1475
- existsSync(CONFIG_PATH),
1476
- policyPaths,
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>,