@gotgenes/pi-permission-system 2.0.0 → 3.0.1

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +92 -35
  3. package/config/config.example.json +6 -0
  4. package/package.json +1 -1
  5. package/schemas/permissions.schema.json +114 -16
  6. package/src/active-agent.ts +58 -0
  7. package/src/config-loader.ts +398 -0
  8. package/src/config-paths.ts +34 -0
  9. package/src/config-reporter.ts +16 -8
  10. package/src/external-directory.ts +113 -0
  11. package/src/forwarded-permissions/io.ts +328 -0
  12. package/src/forwarded-permissions/polling.ts +334 -0
  13. package/src/index.ts +153 -1095
  14. package/src/permission-manager.ts +25 -111
  15. package/src/permission-prompts.ts +131 -0
  16. package/src/subagent-context.ts +52 -0
  17. package/src/tool-input-preview.ts +206 -0
  18. package/tests/active-agent.test.ts +160 -0
  19. package/tests/bash-filter.test.ts +137 -0
  20. package/tests/common.test.ts +189 -0
  21. package/tests/config-loader.test.ts +364 -0
  22. package/tests/config-paths.test.ts +78 -0
  23. package/tests/config-reporter.test.ts +42 -33
  24. package/tests/extension-config.test.ts +51 -0
  25. package/tests/external-directory.test.ts +250 -0
  26. package/tests/permission-prompts.test.ts +301 -0
  27. package/tests/permission-system.test.ts +9 -26
  28. package/tests/session-start.test.ts +8 -33
  29. package/tests/skill-prompt-sanitizer.test.ts +244 -0
  30. package/tests/subagent-context.test.ts +124 -0
  31. package/tests/system-prompt-sanitizer.test.ts +186 -0
  32. package/tests/tool-input-preview.test.ts +452 -0
  33. package/tests/tool-registry.test.ts +155 -0
  34. package/tests/wildcard-matcher.test.ts +180 -0
  35. package/tests/yolo-mode.test.ts +110 -0
package/src/index.ts CHANGED
@@ -1,15 +1,11 @@
1
1
  import {
2
2
  existsSync,
3
3
  mkdirSync,
4
- readdirSync,
5
- readFileSync,
6
4
  renameSync,
7
- rmdirSync,
8
5
  unlinkSync,
9
6
  writeFileSync,
10
7
  } from "node:fs";
11
- import { homedir } from "node:os";
12
- import { join, normalize, resolve, sep } from "node:path";
8
+ import { dirname, join, normalize } from "node:path";
13
9
  import {
14
10
  type ExtensionAPI,
15
11
  type ExtensionCommandContext,
@@ -17,41 +13,68 @@ import {
17
13
  getAgentDir,
18
14
  isToolCallEventType,
19
15
  } from "@mariozechner/pi-coding-agent";
16
+ import {
17
+ getActiveAgentName,
18
+ getActiveAgentNameFromSystemPrompt,
19
+ } from "./active-agent.js";
20
20
  import {
21
21
  createActiveToolsCacheKey,
22
22
  createBeforeAgentStartPromptStateKey,
23
23
  shouldApplyCachedAgentStartState,
24
24
  } from "./before-agent-start-cache.js";
25
- import { getNonEmptyString, toRecord } from "./common.js";
25
+ import { 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
- import { createPermissionSystemLogger, safeJsonStringify } from "./logging.js";
38
46
  import {
39
- isPermissionDecisionState,
47
+ formatExternalDirectoryAskPrompt,
48
+ formatExternalDirectoryDenyReason,
49
+ formatExternalDirectoryUserDeniedReason,
50
+ getPathBearingToolPath,
51
+ isPathOutsideWorkingDirectory,
52
+ normalizePathForComparison,
53
+ PATH_BEARING_TOOLS,
54
+ } from "./external-directory.js";
55
+ import { setForwardedPermissionLogger } from "./forwarded-permissions/io.js";
56
+ import {
57
+ confirmPermission,
58
+ type PermissionForwardingDeps,
59
+ processForwardedPermissionRequests,
60
+ } from "./forwarded-permissions/polling.js";
61
+ import { createPermissionSystemLogger } from "./logging.js";
62
+ import {
40
63
  type PermissionPromptDecision,
41
64
  requestPermissionDecisionFromUi,
42
65
  } from "./permission-dialog.js";
43
- import {
44
- createPermissionForwardingLocation,
45
- type ForwardedPermissionRequest,
46
- type ForwardedPermissionResponse,
47
- isForwardedPermissionRequestForSession,
48
- PERMISSION_FORWARDING_POLL_INTERVAL_MS,
49
- PERMISSION_FORWARDING_TIMEOUT_MS,
50
- type PermissionForwardingLocation,
51
- resolvePermissionForwardingTargetSessionId,
52
- SUBAGENT_ENV_HINT_KEYS,
53
- } from "./permission-forwarding.js";
66
+ import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding.js";
54
67
  import { PermissionManager } from "./permission-manager.js";
68
+ import {
69
+ formatAskPrompt,
70
+ formatDenyReason,
71
+ formatMissingToolNameReason,
72
+ formatSkillAskPrompt,
73
+ formatSkillPathAskPrompt,
74
+ formatSkillPathDenyReason,
75
+ formatUnknownToolReason,
76
+ formatUserDeniedReason,
77
+ } from "./permission-prompts.js";
55
78
  import {
56
79
  findSkillPathMatch,
57
80
  resolveSkillPromptEntries,
@@ -61,12 +84,13 @@ import {
61
84
  PERMISSION_SYSTEM_STATUS_KEY,
62
85
  syncPermissionSystemStatus,
63
86
  } from "./status.js";
87
+ import { isSubagentExecutionContext } from "./subagent-context.js";
64
88
  import { sanitizeAvailableToolsSection } from "./system-prompt-sanitizer.js";
89
+ import { getPermissionLogContext } from "./tool-input-preview.js";
65
90
  import {
66
91
  checkRequestedToolRegistration,
67
92
  getToolNameFromValue,
68
93
  } from "./tool-registry.js";
69
- import type { PermissionCheckResult } from "./types.js";
70
94
  import {
71
95
  canResolveAskPermissionRequest,
72
96
  shouldAutoApprovePermissionState,
@@ -77,23 +101,18 @@ const SESSIONS_DIR = join(PI_AGENT_DIR, "sessions");
77
101
  const SUBAGENT_SESSIONS_DIR = join(PI_AGENT_DIR, "subagent-sessions");
78
102
  const PERMISSION_FORWARDING_DIR = join(SESSIONS_DIR, "permission-forwarding");
79
103
 
80
- const ACTIVE_AGENT_TAG_REGEX = /<active_agent\s+name=["']([^"']+)["'][^>]*>/i;
81
-
82
104
  type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
83
- const PATH_BEARING_TOOLS = new Set([
84
- "read",
85
- "write",
86
- "edit",
87
- "find",
88
- "grep",
89
- "ls",
90
- ]);
91
105
 
92
106
  let extensionConfig: PermissionSystemExtensionConfig = {
93
107
  ...DEFAULT_EXTENSION_CONFIG,
94
108
  };
109
+ const GLOBAL_LOGS_DIR = getGlobalLogsDir(PI_AGENT_DIR);
95
110
  const extensionLogger = createPermissionSystemLogger({
96
111
  getConfig: () => extensionConfig,
112
+ debugLogPath: join(GLOBAL_LOGS_DIR, DEBUG_LOG_FILENAME),
113
+ reviewLogPath: join(GLOBAL_LOGS_DIR, REVIEW_LOG_FILENAME),
114
+ ensureLogsDirectory: () =>
115
+ ensurePermissionSystemLogsDirectory(GLOBAL_LOGS_DIR),
97
116
  });
98
117
  const reportedLoggingWarnings = new Set<string>();
99
118
  let loggingWarningReporter: ((message: string) => void) | null = null;
@@ -137,67 +156,6 @@ function writeReviewLog(
137
156
  }
138
157
  }
139
158
 
140
- function normalizePathForComparison(pathValue: string, cwd: string): string {
141
- const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
142
- if (!trimmed) {
143
- return "";
144
- }
145
-
146
- let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
147
-
148
- if (normalizedPath === "~") {
149
- normalizedPath = homedir();
150
- } else if (
151
- normalizedPath.startsWith("~/") ||
152
- normalizedPath.startsWith("~\\")
153
- ) {
154
- normalizedPath = join(homedir(), normalizedPath.slice(2));
155
- }
156
-
157
- const absolutePath = resolve(cwd, normalizedPath);
158
- const normalizedAbsolutePath = normalize(absolutePath);
159
- return process.platform === "win32"
160
- ? normalizedAbsolutePath.toLowerCase()
161
- : normalizedAbsolutePath;
162
- }
163
-
164
- function isPathWithinDirectory(pathValue: string, directory: string): boolean {
165
- if (!pathValue || !directory) {
166
- return false;
167
- }
168
-
169
- if (pathValue === directory) {
170
- return true;
171
- }
172
-
173
- const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
174
- return pathValue.startsWith(prefix);
175
- }
176
-
177
- function getPathBearingToolPath(
178
- toolName: string,
179
- input: unknown,
180
- ): string | null {
181
- if (!PATH_BEARING_TOOLS.has(toolName)) {
182
- return null;
183
- }
184
-
185
- return getNonEmptyString(toRecord(input).path);
186
- }
187
-
188
- function isPathOutsideWorkingDirectory(
189
- pathValue: string,
190
- cwd: string,
191
- ): boolean {
192
- const normalizedCwd = normalizePathForComparison(cwd, cwd);
193
- const normalizedPath = normalizePathForComparison(pathValue, cwd);
194
- return Boolean(
195
- normalizedCwd &&
196
- normalizedPath &&
197
- !isPathWithinDirectory(normalizedPath, normalizedCwd),
198
- );
199
- }
200
-
201
159
  function extractSkillNameFromInput(text: string): string | null {
202
160
  const trimmed = text.trim();
203
161
  if (!trimmed.startsWith("/skill:")) {
@@ -234,981 +192,14 @@ function getEventInput(event: unknown): unknown {
234
192
  return {};
235
193
  }
236
194
 
237
- function normalizeAgentName(value: unknown): string | null {
238
- if (typeof value !== "string") {
239
- return null;
240
- }
241
-
242
- const trimmed = value.trim();
243
- return trimmed ? trimmed : null;
244
- }
245
-
246
- function getActiveAgentName(ctx: ExtensionContext): string | null {
247
- const entries = ctx.sessionManager.getEntries();
248
- for (let i = entries.length - 1; i >= 0; i--) {
249
- const entry = entries[i] as {
250
- type: string;
251
- customType?: string;
252
- data?: unknown;
253
- };
254
- if (entry.type !== "custom" || entry.customType !== "active_agent") {
255
- continue;
256
- }
257
-
258
- const data = entry.data as { name?: unknown } | undefined;
259
- const normalizedName = normalizeAgentName(data?.name);
260
- if (normalizedName) {
261
- return normalizedName;
262
- }
263
-
264
- if (data?.name === null) {
265
- return null;
266
- }
267
- }
268
-
269
- return null;
270
- }
271
-
272
- function getActiveAgentNameFromSystemPrompt(
273
- systemPrompt: string | undefined,
274
- ): string | null {
275
- if (!systemPrompt) {
276
- return null;
277
- }
278
-
279
- const match = systemPrompt.match(ACTIVE_AGENT_TAG_REGEX);
280
- if (!match?.[1]) {
281
- return null;
282
- }
283
-
284
- return normalizeAgentName(match[1]);
285
- }
286
-
287
- function getContextSystemPrompt(ctx: ExtensionContext): string | undefined {
288
- const getSystemPrompt = toRecord(ctx).getSystemPrompt;
289
- if (typeof getSystemPrompt !== "function") {
290
- return undefined;
291
- }
292
-
293
- try {
294
- const systemPrompt = getSystemPrompt.call(ctx);
295
- return typeof systemPrompt === "string" ? systemPrompt : undefined;
296
- } catch (error) {
297
- logPermissionForwardingWarning(
298
- "Failed to read context system prompt for forwarded permission metadata",
299
- error,
300
- );
301
- return undefined;
302
- }
303
- }
304
-
305
- function formatMissingToolNameReason(): string {
306
- return "Tool call was blocked because no tool name was provided. Use a registered tool name from pi.getAllTools().";
307
- }
308
-
309
- function formatUnknownToolReason(
310
- toolName: string,
311
- availableToolNames: readonly string[],
312
- ): string {
313
- const preview = availableToolNames.slice(0, 10);
314
- const suffix = availableToolNames.length > preview.length ? ", ..." : "";
315
- const availableList =
316
- preview.length > 0 ? `${preview.join(", ")}${suffix}` : "none";
317
-
318
- const mcpHint =
319
- toolName === "mcp"
320
- ? ""
321
- : ' If this was intended as an MCP server tool, call the registered \'mcp\' tool when available (for example: {"tool":"server:tool"}).';
322
-
323
- return `Tool '${toolName}' is not registered in this runtime and was blocked before permission checks.${mcpHint} Registered tools: ${availableList}.`;
324
- }
325
-
326
- function formatPermissionHardStopHint(result: PermissionCheckResult): string {
327
- if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
328
- return "Hard stop: this MCP permission denial is policy-enforced. Do not retry this target, do not run discovery/investigation to bypass it, and report the block to the user.";
329
- }
330
-
331
- return "Hard stop: this permission denial is policy-enforced. Do not retry or investigate bypasses; report the block to the user.";
332
- }
333
-
334
- function formatDenyReason(
335
- result: PermissionCheckResult,
336
- agentName?: string,
337
- ): string {
338
- const parts: string[] = [];
339
-
340
- if (agentName) {
341
- parts.push(`Agent '${agentName}'`);
342
- }
343
-
344
- if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
345
- parts.push(`is not permitted to run MCP target '${result.target}'`);
346
- } else {
347
- parts.push(`is not permitted to run '${result.toolName}'`);
348
- }
349
-
350
- if (result.command) {
351
- parts.push(`command '${result.command}'`);
352
- }
353
-
354
- if (result.matchedPattern) {
355
- parts.push(`(matched '${result.matchedPattern}')`);
356
- }
357
-
358
- return `${parts.join(" ")}. ${formatPermissionHardStopHint(result)}`;
359
- }
360
-
361
- function formatUserDeniedReason(
362
- result: PermissionCheckResult,
363
- denialReason?: string,
364
- ): string {
365
- const base =
366
- (result.source === "mcp" || result.toolName === "mcp") && result.target
367
- ? `User denied MCP target '${result.target}'.`
368
- : result.toolName === "bash" && result.command
369
- ? `User denied bash command '${result.command}'.`
370
- : `User denied tool '${result.toolName}'.`;
371
- const reasonSuffix = denialReason ? ` Reason: ${denialReason}.` : "";
372
-
373
- return `${base}${reasonSuffix} ${formatPermissionHardStopHint(result)}`;
374
- }
375
-
376
- const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
377
- const TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH = 1000;
378
- const TOOL_TEXT_SUMMARY_MAX_LENGTH = 80;
379
-
380
- function truncateInlineText(value: string, maxLength: number): string {
381
- return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
382
- }
383
-
384
- function sanitizeInlineText(
385
- value: string,
386
- maxLength = TOOL_TEXT_SUMMARY_MAX_LENGTH,
387
- ): string {
388
- const normalized = value.replace(/\s+/g, " ").trim();
389
- return normalized ? truncateInlineText(normalized, maxLength) : "empty text";
390
- }
391
-
392
- function countTextLines(value: string): number {
393
- if (!value) {
394
- return 0;
395
- }
396
-
397
- return value.split(/\r\n|\r|\n/).length;
398
- }
399
-
400
- function formatCount(value: number, singular: string, plural: string): string {
401
- return `${value} ${value === 1 ? singular : plural}`;
402
- }
403
-
404
- function getPromptPath(input: Record<string, unknown>): string | null {
405
- return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
406
- }
407
-
408
- function formatEditInputForPrompt(input: Record<string, unknown>): string {
409
- const path = getPromptPath(input);
410
- const rawEdits = Array.isArray(input.edits)
411
- ? input.edits
412
- : typeof input.oldText === "string" && typeof input.newText === "string"
413
- ? [{ oldText: input.oldText, newText: input.newText }]
414
- : [];
415
-
416
- const edits = rawEdits
417
- .map((edit) => toRecord(edit))
418
- .filter(
419
- (edit) =>
420
- typeof edit.oldText === "string" && typeof edit.newText === "string",
421
- );
422
-
423
- const pathPart = path ? `for '${path}'` : "";
424
- if (edits.length === 0) {
425
- return pathPart ? `${pathPart} with edit input` : "with edit input";
426
- }
427
-
428
- const firstEdit = edits[0];
429
- const oldText = String(firstEdit.oldText);
430
- const newText = String(firstEdit.newText);
431
- const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
432
- const extraEdits =
433
- edits.length > 1
434
- ? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}`
435
- : "";
436
- const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
437
- return pathPart ? `${pathPart} ${summary}` : summary;
438
- }
439
-
440
- function formatWriteInputForPrompt(input: Record<string, unknown>): string {
441
- const path = getPromptPath(input);
442
- const content = typeof input.content === "string" ? input.content : "";
443
- const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
444
- return path ? `for '${path}' ${summary}` : summary;
445
- }
446
-
447
- function formatReadInputForPrompt(input: Record<string, unknown>): string {
448
- const path = getPromptPath(input);
449
- const parts = path ? [`path '${path}'`] : [];
450
- if (typeof input.offset === "number") {
451
- parts.push(`offset ${input.offset}`);
452
- }
453
- if (typeof input.limit === "number") {
454
- parts.push(`limit ${input.limit}`);
455
- }
456
- return parts.length > 0 ? `for ${parts.join(", ")}` : "";
457
- }
458
-
459
- function formatSearchInputForPrompt(
460
- toolName: string,
461
- input: Record<string, unknown>,
462
- ): string {
463
- const parts: string[] = [];
464
- const path = getPromptPath(input);
465
- const pattern = getNonEmptyString(input.pattern);
466
- const glob = getNonEmptyString(input.glob);
467
-
468
- if (pattern) {
469
- parts.push(`pattern '${sanitizeInlineText(pattern)}'`);
470
- }
471
- if (glob) {
472
- parts.push(`glob '${sanitizeInlineText(glob)}'`);
473
- }
474
- if (path) {
475
- parts.push(`path '${path}'`);
476
- } else if (toolName === "find" || toolName === "grep" || toolName === "ls") {
477
- parts.push("current working directory");
478
- }
479
-
480
- return parts.length > 0 ? `for ${parts.join(", ")}` : "";
481
- }
482
-
483
- function serializeToolInputPreview(input: unknown): string {
484
- const serialized = safeJsonStringify(input);
485
- if (!serialized || serialized === "{}" || serialized === "null") {
486
- return "";
487
- }
488
-
489
- return serialized.replace(/\s+/g, " ").trim();
490
- }
491
-
492
- function formatJsonInputForPrompt(input: unknown): string {
493
- const inline = serializeToolInputPreview(input);
494
- return inline
495
- ? `with input ${truncateInlineText(inline, TOOL_INPUT_PREVIEW_MAX_LENGTH)}`
496
- : "";
497
- }
498
-
499
- function formatToolInputForPrompt(toolName: string, input: unknown): string {
500
- const inputRecord = toRecord(input);
501
-
502
- switch (toolName) {
503
- case "edit":
504
- return formatEditInputForPrompt(inputRecord);
505
- case "write":
506
- return formatWriteInputForPrompt(inputRecord);
507
- case "read":
508
- return formatReadInputForPrompt(inputRecord);
509
- case "find":
510
- case "grep":
511
- case "ls":
512
- return formatSearchInputForPrompt(toolName, inputRecord);
513
- default:
514
- return formatJsonInputForPrompt(input);
515
- }
516
- }
517
-
518
- function formatAskPrompt(
519
- result: PermissionCheckResult,
520
- agentName?: string,
521
- input?: unknown,
522
- ): string {
523
- const subject = agentName ? `Agent '${agentName}'` : "Current agent";
524
-
525
- if (result.toolName === "bash") {
526
- const patternInfo = result.matchedPattern
527
- ? ` (matched '${result.matchedPattern}')`
528
- : "";
529
- return `${subject} requested bash command '${result.command || ""}'${patternInfo}. Allow this command?`;
530
- }
531
-
532
- if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
533
- const patternInfo = result.matchedPattern
534
- ? ` (matched '${result.matchedPattern}')`
535
- : "";
536
- return `${subject} requested MCP target '${result.target}'${patternInfo}. Allow this call?`;
537
- }
538
-
539
- const patternInfo = result.matchedPattern
540
- ? ` (matched '${result.matchedPattern}')`
541
- : "";
542
- const inputPreview = formatToolInputForPrompt(result.toolName, input);
543
- const inputSuffix = inputPreview ? ` ${inputPreview}` : "";
544
- return `${subject} requested tool '${result.toolName}'${patternInfo}${inputSuffix}. Allow this call?`;
545
- }
546
-
547
- function formatSkillAskPrompt(skillName: string, agentName?: string): string {
548
- const subject = agentName ? `Agent '${agentName}'` : "Current agent";
549
- return `${subject} requested skill '${skillName}'. Allow loading this skill?`;
550
- }
551
-
552
- function formatSkillPathAskPrompt(
553
- skill: SkillPromptEntry,
554
- readPath: string,
555
- agentName?: string,
556
- ): string {
557
- const subject = agentName ? `Agent '${agentName}'` : "Current agent";
558
- return `${subject} requested access to skill '${skill.name}' via '${readPath}'. Allow this read?`;
559
- }
560
-
561
- function formatSkillPathDenyReason(
562
- skill: SkillPromptEntry,
563
- readPath: string,
564
- agentName?: string,
565
- ): string {
566
- const subject = agentName ? `Agent '${agentName}'` : "Current agent";
567
- return `${subject} is not permitted to access skill '${skill.name}' via '${readPath}'.`;
568
- }
569
-
570
- function formatExternalDirectoryHardStopHint(): string {
571
- return "Hard stop: this external directory permission denial is policy-enforced. Do not retry this path, do not attempt a filesystem bypass, and report the block to the user.";
572
- }
573
-
574
- function formatExternalDirectoryAskPrompt(
575
- toolName: string,
576
- pathValue: string,
577
- cwd: string,
578
- agentName?: string,
579
- ): string {
580
- const subject = agentName ? `Agent '${agentName}'` : "Current agent";
581
- return `${subject} requested tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. Allow this external directory access?`;
582
- }
583
-
584
- function formatExternalDirectoryDenyReason(
585
- toolName: string,
586
- pathValue: string,
587
- cwd: string,
588
- agentName?: string,
589
- ): string {
590
- const subject = agentName ? `Agent '${agentName}'` : "Current agent";
591
- return `${subject} is not permitted to run tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. ${formatExternalDirectoryHardStopHint()}`;
592
- }
593
-
594
- function formatExternalDirectoryUserDeniedReason(
595
- toolName: string,
596
- pathValue: string,
597
- denialReason?: string,
598
- ): string {
599
- const reasonSuffix = denialReason ? ` Reason: ${denialReason}.` : "";
600
- return `User denied external directory access for tool '${toolName}' path '${pathValue}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
601
- }
602
-
603
- function formatGenericToolInputForLog(input: unknown): string | undefined {
604
- const inline = serializeToolInputPreview(input);
605
- return inline
606
- ? `input ${truncateInlineText(inline, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)}`
607
- : undefined;
608
- }
609
-
610
- function getToolInputPreviewForLog(
611
- result: PermissionCheckResult,
612
- input: unknown,
613
- ): string | undefined {
614
- if (
615
- result.toolName === "bash" ||
616
- result.toolName === "mcp" ||
617
- result.source === "mcp"
618
- ) {
619
- return undefined;
620
- }
621
-
622
- if (PATH_BEARING_TOOLS.has(result.toolName)) {
623
- const inputPreview = formatToolInputForPrompt(result.toolName, input);
624
- return inputPreview
625
- ? truncateInlineText(inputPreview, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)
626
- : undefined;
627
- }
628
-
629
- return formatGenericToolInputForLog(input);
630
- }
631
-
632
- function getPermissionLogContext(
633
- result: PermissionCheckResult,
634
- input: unknown,
635
- ): { command?: string; target?: string; toolInputPreview?: string } {
636
- return {
637
- command: result.command,
638
- target: result.target,
639
- toolInputPreview: getToolInputPreviewForLog(result, input),
640
- };
641
- }
642
-
643
- function sleep(ms: number): Promise<void> {
644
- return new Promise((resolve) => {
645
- setTimeout(resolve, ms);
646
- });
647
- }
648
-
649
- function normalizeFilesystemPath(pathValue: string): string {
650
- const normalizedPath = normalize(pathValue);
651
- return process.platform === "win32"
652
- ? normalizedPath.toLowerCase()
653
- : normalizedPath;
654
- }
655
-
656
- function getSessionId(ctx: ExtensionContext): string {
657
- try {
658
- const sessionId = ctx.sessionManager.getSessionId();
659
- if (typeof sessionId === "string" && sessionId.trim()) {
660
- return sessionId.trim();
661
- }
662
- } catch {}
663
-
664
- return "unknown";
665
- }
666
-
667
- function isSubagentExecutionContext(ctx: ExtensionContext): boolean {
668
- for (const key of SUBAGENT_ENV_HINT_KEYS) {
669
- const value = process.env[key];
670
- if (typeof value === "string" && value.trim()) {
671
- return true;
672
- }
673
- }
674
-
675
- const sessionDir = ctx.sessionManager.getSessionDir();
676
- if (!sessionDir) {
677
- return false;
678
- }
679
-
680
- const normalizedSessionDir = normalizeFilesystemPath(sessionDir);
681
- const normalizedSubagentRoot = normalizeFilesystemPath(SUBAGENT_SESSIONS_DIR);
682
- return isPathWithinDirectory(normalizedSessionDir, normalizedSubagentRoot);
683
- }
684
-
685
195
  function canRequestPermissionConfirmation(ctx: ExtensionContext): boolean {
686
196
  return canResolveAskPermissionRequest({
687
197
  config: extensionConfig,
688
198
  hasUI: ctx.hasUI,
689
- isSubagent: isSubagentExecutionContext(ctx),
199
+ isSubagent: isSubagentExecutionContext(ctx, SUBAGENT_SESSIONS_DIR),
690
200
  });
691
201
  }
692
202
 
693
- function formatUnknownErrorMessage(error: unknown): string {
694
- if (error instanceof Error && error.message) {
695
- return error.message;
696
- }
697
- return String(error);
698
- }
699
-
700
- function isErrnoCode(error: unknown, code: string): boolean {
701
- return Boolean(
702
- error &&
703
- typeof error === "object" &&
704
- "code" in error &&
705
- (error as { code?: string }).code === code,
706
- );
707
- }
708
-
709
- function logPermissionForwardingWarning(
710
- message: string,
711
- error?: unknown,
712
- ): void {
713
- const details =
714
- typeof error === "undefined"
715
- ? { message }
716
- : { message, error: formatUnknownErrorMessage(error) };
717
-
718
- writeReviewLog("permission_forwarding.warning", details);
719
- writeDebugLog("permission_forwarding.warning", details);
720
- }
721
-
722
- function logPermissionForwardingError(message: string, error?: unknown): void {
723
- const details =
724
- typeof error === "undefined"
725
- ? { message }
726
- : { message, error: formatUnknownErrorMessage(error) };
727
-
728
- writeReviewLog("permission_forwarding.error", details);
729
- writeDebugLog("permission_forwarding.error", details);
730
- }
731
-
732
- function ensureDirectoryExists(path: string, description: string): boolean {
733
- try {
734
- mkdirSync(path, { recursive: true });
735
- return true;
736
- } catch (error) {
737
- logPermissionForwardingError(
738
- `Failed to create ${description} directory '${path}'`,
739
- error,
740
- );
741
- return false;
742
- }
743
- }
744
-
745
- function getPermissionForwardingLocationForSession(
746
- sessionId: string,
747
- ): PermissionForwardingLocation {
748
- return createPermissionForwardingLocation(
749
- PERMISSION_FORWARDING_DIR,
750
- sessionId,
751
- );
752
- }
753
-
754
- function ensurePermissionForwardingLocation(
755
- sessionId: string,
756
- ): PermissionForwardingLocation | null {
757
- let location: PermissionForwardingLocation;
758
- try {
759
- location = getPermissionForwardingLocationForSession(sessionId);
760
- } catch (error) {
761
- logPermissionForwardingError(
762
- "Failed to resolve permission forwarding location",
763
- error,
764
- );
765
- return null;
766
- }
767
-
768
- const sessionRootReady = ensureDirectoryExists(
769
- location.sessionRootDir,
770
- "permission forwarding session root",
771
- );
772
- const requestsReady = ensureDirectoryExists(
773
- location.requestsDir,
774
- "permission forwarding requests",
775
- );
776
- const responsesReady = ensureDirectoryExists(
777
- location.responsesDir,
778
- "permission forwarding responses",
779
- );
780
-
781
- return sessionRootReady && requestsReady && responsesReady ? location : null;
782
- }
783
-
784
- function getExistingPermissionForwardingLocation(
785
- sessionId: string,
786
- ): PermissionForwardingLocation | null {
787
- let location: PermissionForwardingLocation;
788
- try {
789
- location = getPermissionForwardingLocationForSession(sessionId);
790
- } catch {
791
- return null;
792
- }
793
-
794
- return existsSync(location.requestsDir) ? location : null;
795
- }
796
-
797
- function tryRemoveDirectoryIfEmpty(path: string, description: string): void {
798
- if (!existsSync(path)) {
799
- return;
800
- }
801
-
802
- let entries: string[];
803
- try {
804
- entries = readdirSync(path);
805
- } catch (error) {
806
- logPermissionForwardingWarning(
807
- `Failed to inspect ${description} directory '${path}'`,
808
- error,
809
- );
810
- return;
811
- }
812
-
813
- if (entries.length > 0) {
814
- return;
815
- }
816
-
817
- try {
818
- rmdirSync(path);
819
- } catch (error) {
820
- if (isErrnoCode(error, "ENOENT") || isErrnoCode(error, "ENOTEMPTY")) {
821
- return;
822
- }
823
-
824
- logPermissionForwardingWarning(
825
- `Failed to remove empty ${description} directory '${path}'`,
826
- error,
827
- );
828
- }
829
- }
830
-
831
- function cleanupPermissionForwardingLocationIfEmpty(
832
- location: PermissionForwardingLocation,
833
- ): void {
834
- tryRemoveDirectoryIfEmpty(
835
- location.requestsDir,
836
- `${location.label} permission forwarding requests`,
837
- );
838
- tryRemoveDirectoryIfEmpty(
839
- location.responsesDir,
840
- `${location.label} permission forwarding responses`,
841
- );
842
- tryRemoveDirectoryIfEmpty(
843
- location.sessionRootDir,
844
- `${location.label} permission forwarding session root`,
845
- );
846
- }
847
-
848
- function safeDeleteFile(filePath: string, description: string): void {
849
- try {
850
- unlinkSync(filePath);
851
- } catch (error) {
852
- if (isErrnoCode(error, "ENOENT")) {
853
- return;
854
- }
855
-
856
- logPermissionForwardingWarning(
857
- `Failed to delete ${description} file '${filePath}'`,
858
- error,
859
- );
860
- }
861
- }
862
-
863
- function writeJsonFileAtomic(filePath: string, value: unknown): void {
864
- const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
865
-
866
- try {
867
- writeFileSync(tempPath, JSON.stringify(value), "utf-8");
868
- renameSync(tempPath, filePath);
869
- } catch (error) {
870
- safeDeleteFile(tempPath, "temporary permission-forwarding");
871
- throw error;
872
- }
873
- }
874
-
875
- function readForwardedPermissionRequest(
876
- filePath: string,
877
- ): ForwardedPermissionRequest | null {
878
- try {
879
- const raw = readFileSync(filePath, "utf-8");
880
- const parsed = JSON.parse(raw) as Partial<ForwardedPermissionRequest>;
881
- if (
882
- !parsed ||
883
- typeof parsed.id !== "string" ||
884
- typeof parsed.createdAt !== "number" ||
885
- typeof parsed.requesterSessionId !== "string" ||
886
- typeof parsed.targetSessionId !== "string" ||
887
- typeof parsed.requesterAgentName !== "string" ||
888
- typeof parsed.message !== "string"
889
- ) {
890
- logPermissionForwardingWarning(
891
- `Ignoring invalid forwarded permission request format in '${filePath}'`,
892
- );
893
- return null;
894
- }
895
-
896
- return {
897
- id: parsed.id,
898
- createdAt: parsed.createdAt,
899
- requesterSessionId: parsed.requesterSessionId,
900
- targetSessionId: parsed.targetSessionId,
901
- requesterAgentName: parsed.requesterAgentName,
902
- message: parsed.message,
903
- };
904
- } catch (error) {
905
- logPermissionForwardingWarning(
906
- `Failed to read forwarded permission request '${filePath}'`,
907
- error,
908
- );
909
- return null;
910
- }
911
- }
912
-
913
- function readForwardedPermissionResponse(
914
- filePath: string,
915
- ): ForwardedPermissionResponse | null {
916
- try {
917
- const raw = readFileSync(filePath, "utf-8");
918
- const parsed = JSON.parse(raw) as Partial<ForwardedPermissionResponse>;
919
- if (
920
- !parsed ||
921
- typeof parsed.approved !== "boolean" ||
922
- !isPermissionDecisionState(parsed.state) ||
923
- typeof parsed.responderSessionId !== "string"
924
- ) {
925
- logPermissionForwardingWarning(
926
- `Ignoring invalid forwarded permission response format in '${filePath}'`,
927
- );
928
- return null;
929
- }
930
-
931
- return {
932
- approved: parsed.approved,
933
- state: parsed.state,
934
- denialReason:
935
- typeof parsed.denialReason === "string"
936
- ? parsed.denialReason
937
- : undefined,
938
- responderSessionId: parsed.responderSessionId,
939
- respondedAt:
940
- typeof parsed.respondedAt === "number"
941
- ? parsed.respondedAt
942
- : Date.now(),
943
- };
944
- } catch (error) {
945
- logPermissionForwardingWarning(
946
- `Failed to read forwarded permission response '${filePath}'`,
947
- error,
948
- );
949
- return null;
950
- }
951
- }
952
-
953
- function formatForwardedPermissionPrompt(
954
- request: ForwardedPermissionRequest,
955
- ): string {
956
- const agentName = request.requesterAgentName || "unknown";
957
- const sessionId = request.requesterSessionId || "unknown";
958
- return [
959
- `Subagent '${agentName}' requested permission.`,
960
- `Session ID: ${sessionId}`,
961
- "",
962
- request.message,
963
- ].join("\n");
964
- }
965
-
966
- async function waitForForwardedPermissionApproval(
967
- ctx: ExtensionContext,
968
- message: string,
969
- ): Promise<PermissionPromptDecision> {
970
- const requesterSessionId = getSessionId(ctx);
971
- const targetSessionId = resolvePermissionForwardingTargetSessionId({
972
- hasUI: ctx.hasUI,
973
- isSubagent: isSubagentExecutionContext(ctx),
974
- currentSessionId: requesterSessionId,
975
- env: process.env,
976
- });
977
-
978
- if (!targetSessionId) {
979
- logPermissionForwardingError(
980
- "Permission forwarding target session could not be resolved from subagent runtime metadata (expected PI_AGENT_ROUTER_PARENT_SESSION_ID)",
981
- );
982
- return { approved: false, state: "denied" };
983
- }
984
-
985
- const location = ensurePermissionForwardingLocation(targetSessionId);
986
- if (!location) {
987
- logPermissionForwardingError(
988
- `Permission forwarding is unavailable because session-scoped directories could not be prepared for '${targetSessionId}'`,
989
- );
990
- return { approved: false, state: "denied" };
991
- }
992
-
993
- const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
994
- const requesterAgentName =
995
- getActiveAgentName(ctx) ||
996
- getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ||
997
- "unknown";
998
- const request: ForwardedPermissionRequest = {
999
- id: requestId,
1000
- createdAt: Date.now(),
1001
- requesterSessionId,
1002
- targetSessionId,
1003
- requesterAgentName,
1004
- message,
1005
- };
1006
-
1007
- const requestPath = join(location.requestsDir, `${requestId}.json`);
1008
- const responsePath = join(location.responsesDir, `${requestId}.json`);
1009
-
1010
- writeReviewLog("forwarded_permission.request_created", {
1011
- requestId,
1012
- requesterAgentName,
1013
- requesterSessionId: request.requesterSessionId,
1014
- targetSessionId,
1015
- requestPath,
1016
- responsePath,
1017
- });
1018
-
1019
- try {
1020
- writeJsonFileAtomic(requestPath, request);
1021
- } catch (error) {
1022
- logPermissionForwardingError(
1023
- `Failed to write forwarded permission request '${requestPath}'`,
1024
- error,
1025
- );
1026
- return { approved: false, state: "denied" };
1027
- }
1028
-
1029
- const deadline = Date.now() + PERMISSION_FORWARDING_TIMEOUT_MS;
1030
- while (Date.now() < deadline) {
1031
- if (existsSync(responsePath)) {
1032
- const response = readForwardedPermissionResponse(responsePath);
1033
- writeReviewLog("forwarded_permission.response_received", {
1034
- requestId,
1035
- approved: response?.approved ?? null,
1036
- state: response?.state ?? null,
1037
- denialReason: response?.denialReason ?? null,
1038
- responderSessionId: response?.responderSessionId ?? null,
1039
- targetSessionId,
1040
- responsePath,
1041
- });
1042
- safeDeleteFile(responsePath, "forwarded permission response");
1043
- safeDeleteFile(requestPath, "forwarded permission request");
1044
- cleanupPermissionForwardingLocationIfEmpty(location);
1045
- return response ?? { approved: false, state: "denied" };
1046
- }
1047
-
1048
- await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
1049
- }
1050
-
1051
- logPermissionForwardingWarning(
1052
- `Timed out waiting for forwarded permission response '${responsePath}'`,
1053
- );
1054
- writeReviewLog("forwarded_permission.response_timed_out", {
1055
- requestId,
1056
- requesterAgentName,
1057
- targetSessionId,
1058
- responsePath,
1059
- });
1060
- safeDeleteFile(requestPath, "forwarded permission request");
1061
- cleanupPermissionForwardingLocationIfEmpty(location);
1062
- return { approved: false, state: "denied" };
1063
- }
1064
-
1065
- async function processForwardedPermissionRequests(
1066
- ctx: ExtensionContext,
1067
- ): Promise<void> {
1068
- if (!ctx.hasUI) {
1069
- return;
1070
- }
1071
-
1072
- const currentSessionId = getSessionId(ctx);
1073
- const location = getExistingPermissionForwardingLocation(currentSessionId);
1074
- if (!location) {
1075
- return;
1076
- }
1077
-
1078
- let requestFiles: string[] = [];
1079
- try {
1080
- requestFiles = readdirSync(location.requestsDir)
1081
- .filter((name) => name.endsWith(".json"))
1082
- .sort();
1083
- } catch (error) {
1084
- logPermissionForwardingWarning(
1085
- `Failed to read ${location.label} permission forwarding requests from '${location.requestsDir}'`,
1086
- error,
1087
- );
1088
- return;
1089
- }
1090
-
1091
- for (const fileName of requestFiles) {
1092
- const requestPath = join(location.requestsDir, fileName);
1093
- const request = readForwardedPermissionRequest(requestPath);
1094
- if (!request) {
1095
- safeDeleteFile(
1096
- requestPath,
1097
- `${location.label} forwarded permission request`,
1098
- );
1099
- continue;
1100
- }
1101
-
1102
- if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
1103
- logPermissionForwardingWarning(
1104
- `Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
1105
- );
1106
- safeDeleteFile(
1107
- requestPath,
1108
- `${location.label} forwarded permission request`,
1109
- );
1110
- continue;
1111
- }
1112
-
1113
- const forwardedPermissionLogDetails = {
1114
- requestId: request.id,
1115
- source: location.label,
1116
- requesterAgentName: request.requesterAgentName,
1117
- requesterSessionId: request.requesterSessionId,
1118
- targetSessionId: request.targetSessionId,
1119
- requestPath,
1120
- };
1121
-
1122
- let decision: PermissionPromptDecision = {
1123
- approved: false,
1124
- state: "denied",
1125
- };
1126
- if (shouldAutoApprovePermissionState("ask", extensionConfig)) {
1127
- writeReviewLog(
1128
- "forwarded_permission.auto_approved",
1129
- forwardedPermissionLogDetails,
1130
- );
1131
- decision = { approved: true, state: "approved" };
1132
- } else {
1133
- writeReviewLog(
1134
- "forwarded_permission.prompted",
1135
- forwardedPermissionLogDetails,
1136
- );
1137
- try {
1138
- decision = await requestPermissionDecisionFromUi(
1139
- ctx.ui,
1140
- "Permission Required (Subagent)",
1141
- formatForwardedPermissionPrompt(request),
1142
- );
1143
- } catch (error) {
1144
- logPermissionForwardingError(
1145
- "Failed to show forwarded permission confirmation dialog",
1146
- error,
1147
- );
1148
- decision = { approved: false, state: "denied" };
1149
- }
1150
- }
1151
-
1152
- const responsePath = join(location.responsesDir, `${request.id}.json`);
1153
- writeReviewLog(
1154
- decision.approved
1155
- ? "forwarded_permission.approved"
1156
- : "forwarded_permission.denied",
1157
- {
1158
- requestId: request.id,
1159
- source: location.label,
1160
- requesterAgentName: request.requesterAgentName,
1161
- requesterSessionId: request.requesterSessionId,
1162
- targetSessionId: request.targetSessionId,
1163
- responsePath,
1164
- resolution: decision.state,
1165
- denialReason: decision.denialReason ?? null,
1166
- },
1167
- );
1168
- try {
1169
- writeJsonFileAtomic(responsePath, {
1170
- approved: decision.approved,
1171
- state: decision.state,
1172
- denialReason: decision.denialReason,
1173
- responderSessionId: currentSessionId,
1174
- respondedAt: Date.now(),
1175
- } satisfies ForwardedPermissionResponse);
1176
- } catch (error) {
1177
- logPermissionForwardingError(
1178
- `Failed to write ${location.label} forwarded permission response '${responsePath}'`,
1179
- error,
1180
- );
1181
- continue;
1182
- }
1183
-
1184
- safeDeleteFile(
1185
- requestPath,
1186
- `${location.label} forwarded permission request`,
1187
- );
1188
- }
1189
-
1190
- cleanupPermissionForwardingLocationIfEmpty(location);
1191
- }
1192
-
1193
- async function confirmPermission(
1194
- ctx: ExtensionContext,
1195
- message: string,
1196
- ): Promise<PermissionPromptDecision> {
1197
- if (ctx.hasUI) {
1198
- return requestPermissionDecisionFromUi(
1199
- ctx.ui,
1200
- "Permission Required",
1201
- message,
1202
- );
1203
- }
1204
-
1205
- if (!isSubagentExecutionContext(ctx)) {
1206
- return { approved: false, state: "denied" };
1207
- }
1208
-
1209
- return waitForForwardedPermissionApproval(ctx, message);
1210
- }
1211
-
1212
203
  function derivePiProjectPaths(cwd: string | undefined | null): {
1213
204
  projectGlobalConfigPath: string;
1214
205
  projectAgentsDir: string;
@@ -1217,24 +208,22 @@ function derivePiProjectPaths(cwd: string | undefined | null): {
1217
208
  return null;
1218
209
  }
1219
210
 
1220
- const projectAgentRoot = join(cwd, ".pi", "agent");
1221
211
  return {
1222
- projectGlobalConfigPath: join(projectAgentRoot, "pi-permissions.jsonc"),
1223
- projectAgentsDir: join(projectAgentRoot, "agents"),
212
+ projectGlobalConfigPath: getProjectConfigPath(cwd),
213
+ projectAgentsDir: join(cwd, ".pi", "agent", "agents"),
1224
214
  };
1225
215
  }
1226
216
 
1227
217
  function createPermissionManagerForCwd(
1228
218
  cwd: string | undefined | null,
1229
219
  ): PermissionManager {
220
+ const agentDir = getAgentDir();
1230
221
  const projectPaths = derivePiProjectPaths(cwd);
1231
- if (!projectPaths) {
1232
- return new PermissionManager();
1233
- }
1234
222
 
1235
223
  return new PermissionManager({
1236
- projectGlobalConfigPath: projectPaths.projectGlobalConfigPath,
1237
- projectAgentsDir: projectPaths.projectAgentsDir,
224
+ globalConfigPath: getGlobalConfigPath(agentDir),
225
+ projectGlobalConfigPath: projectPaths?.projectGlobalConfigPath,
226
+ projectAgentsDir: projectPaths?.projectAgentsDir,
1238
227
  });
1239
228
  }
1240
229
 
@@ -1269,26 +258,34 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1269
258
  runtimeContext = ctx;
1270
259
  }
1271
260
 
1272
- const result = loadPermissionSystemConfig();
1273
- setExtensionConfig(result.config);
261
+ const cwd = runtimeContext?.cwd ?? null;
262
+ const agentDir = getAgentDir();
263
+ const mergeResult = loadAndMergeConfigs(
264
+ agentDir,
265
+ cwd ?? "",
266
+ EXTENSION_ROOT,
267
+ );
268
+ const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
269
+ setExtensionConfig(runtimeConfig);
1274
270
 
1275
271
  if (runtimeContext?.hasUI) {
1276
- syncPermissionSystemStatus(runtimeContext, result.config);
272
+ syncPermissionSystemStatus(runtimeContext, runtimeConfig);
1277
273
  }
1278
274
 
1279
- if (result.warning && result.warning !== lastConfigWarning) {
1280
- lastConfigWarning = result.warning;
1281
- notifyWarning(result.warning);
1282
- } else if (!result.warning) {
275
+ const warning =
276
+ mergeResult.issues.length > 0 ? mergeResult.issues.join("\n") : undefined;
277
+ if (warning && warning !== lastConfigWarning) {
278
+ lastConfigWarning = warning;
279
+ notifyWarning(warning);
280
+ } else if (!warning) {
1283
281
  lastConfigWarning = null;
1284
282
  }
1285
283
 
1286
284
  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,
285
+ warning: warning ?? null,
286
+ debugLog: runtimeConfig.debugLog,
287
+ permissionReviewLog: runtimeConfig.permissionReviewLog,
288
+ yoloMode: runtimeConfig.yoloMode,
1292
289
  });
1293
290
  };
1294
291
 
@@ -1297,11 +294,35 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1297
294
  ctx: ExtensionCommandContext,
1298
295
  ): void => {
1299
296
  const normalized = normalizePermissionSystemConfig(next);
1300
- const saved = savePermissionSystemConfig(normalized);
1301
- if (!saved.success) {
1302
- if (saved.error) {
1303
- ctx.ui.notify(saved.error, "error");
297
+ const globalPath = getGlobalConfigPath(getAgentDir());
298
+
299
+ // Load existing global config and merge runtime knobs into it
300
+ const existing = loadUnifiedConfig(globalPath);
301
+ const merged = {
302
+ ...existing.config,
303
+ debugLog: normalized.debugLog,
304
+ permissionReviewLog: normalized.permissionReviewLog,
305
+ yoloMode: normalized.yoloMode,
306
+ };
307
+
308
+ const tmpPath = `${globalPath}.tmp`;
309
+ try {
310
+ mkdirSync(dirname(globalPath), { recursive: true });
311
+ writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
312
+ renameSync(tmpPath, globalPath);
313
+ } catch (error) {
314
+ try {
315
+ if (existsSync(tmpPath)) {
316
+ unlinkSync(tmpPath);
317
+ }
318
+ } catch {
319
+ // Ignore cleanup failures.
1304
320
  }
321
+ const message = error instanceof Error ? error.message : String(error);
322
+ ctx.ui.notify(
323
+ `Failed to save permission-system config at '${globalPath}': ${message}`,
324
+ "error",
325
+ );
1305
326
  return;
1306
327
  }
1307
328
 
@@ -1317,11 +338,22 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1317
338
  };
1318
339
 
1319
340
  setLoggingWarningReporter(notifyWarning);
341
+ setForwardedPermissionLogger({ writeReviewLog, writeDebugLog });
342
+
343
+ const forwardingDeps: PermissionForwardingDeps = {
344
+ forwardingDir: PERMISSION_FORWARDING_DIR,
345
+ subagentSessionsDir: SUBAGENT_SESSIONS_DIR,
346
+ writeReviewLog,
347
+ requestPermissionDecisionFromUi,
348
+ shouldAutoApprove: () =>
349
+ shouldAutoApprovePermissionState("ask", extensionConfig),
350
+ };
351
+
1320
352
  refreshExtensionConfig();
1321
353
  registerPermissionSystemCommand(pi, {
1322
354
  getConfig: () => extensionConfig,
1323
355
  setConfig: saveExtensionConfig,
1324
- getConfigPath: getPermissionSystemConfigPath,
356
+ getConfigPath: () => getGlobalConfigPath(getAgentDir()),
1325
357
  });
1326
358
 
1327
359
  const createPermissionRequestId = (prefix: string): string => {
@@ -1386,7 +418,11 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1386
418
 
1387
419
  reviewPermissionDecision("permission_request.waiting", details);
1388
420
 
1389
- const decision = await confirmPermission(ctx, details.message);
421
+ const decision = await confirmPermission(
422
+ ctx,
423
+ details.message,
424
+ forwardingDeps,
425
+ );
1390
426
  reviewPermissionDecision(
1391
427
  decision.approved
1392
428
  ? "permission_request.approved"
@@ -1411,7 +447,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1411
447
  };
1412
448
 
1413
449
  const startForwardedPermissionPolling = (ctx: ExtensionContext): void => {
1414
- if (!ctx.hasUI || isSubagentExecutionContext(ctx)) {
450
+ if (!ctx.hasUI || isSubagentExecutionContext(ctx, SUBAGENT_SESSIONS_DIR)) {
1415
451
  stopForwardedPermissionPolling();
1416
452
  return;
1417
453
  }
@@ -1429,6 +465,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1429
465
  isProcessingForwardedRequests = true;
1430
466
  void processForwardedPermissionRequests(
1431
467
  permissionForwardingContext,
468
+ forwardingDeps,
1432
469
  ).finally(() => {
1433
470
  isProcessingForwardedRequests = false;
1434
471
  });
@@ -1470,11 +507,28 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1470
507
 
1471
508
  const logResolvedConfigPaths = (): void => {
1472
509
  const policyPaths = permissionManager.getResolvedPolicyPaths();
1473
- const entry = buildResolvedConfigLogEntry(
1474
- CONFIG_PATH,
1475
- existsSync(CONFIG_PATH),
1476
- policyPaths,
510
+ const cwd = runtimeContext?.cwd ?? null;
511
+
512
+ // Detect legacy files for the log entry
513
+ const agentDir = getAgentDir();
514
+ const legacyGlobalPolicyDetected = existsSync(
515
+ getLegacyGlobalPolicyPath(agentDir),
1477
516
  );
517
+ const legacyProjectPolicyDetected = cwd
518
+ ? existsSync(getLegacyProjectPolicyPath(cwd))
519
+ : false;
520
+ const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
521
+ const newGlobalPath = getGlobalConfigPath(agentDir);
522
+ const legacyExtensionConfigDetected =
523
+ normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
524
+ existsSync(legacyExtConfigPath);
525
+
526
+ const entry = buildResolvedConfigLogEntry({
527
+ policyPaths,
528
+ legacyGlobalPolicyDetected,
529
+ legacyProjectPolicyDetected,
530
+ legacyExtensionConfigDetected,
531
+ });
1478
532
  writeReviewLog(
1479
533
  "config.resolved",
1480
534
  entry as unknown as Record<string, unknown>,
@@ -1848,7 +902,11 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1848
902
  input,
1849
903
  agentName ?? undefined,
1850
904
  );
1851
- const permissionLogContext = getPermissionLogContext(check, input);
905
+ const permissionLogContext = getPermissionLogContext(
906
+ check,
907
+ input,
908
+ PATH_BEARING_TOOLS,
909
+ );
1852
910
 
1853
911
  if (check.state === "deny") {
1854
912
  writeReviewLog("permission_request.blocked", {