@gotgenes/pi-permission-system 0.7.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/src/index.ts ADDED
@@ -0,0 +1,1983 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ rmdirSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join, normalize, resolve, sep } from "node:path";
13
+ import {
14
+ type ExtensionAPI,
15
+ type ExtensionCommandContext,
16
+ type ExtensionContext,
17
+ getAgentDir,
18
+ isToolCallEventType,
19
+ } from "@mariozechner/pi-coding-agent";
20
+ import {
21
+ createActiveToolsCacheKey,
22
+ createBeforeAgentStartPromptStateKey,
23
+ shouldApplyCachedAgentStartState,
24
+ } from "./before-agent-start-cache.js";
25
+ import { getNonEmptyString, toRecord } from "./common.js";
26
+ import { registerPermissionSystemCommand } from "./config-modal.js";
27
+ import { buildResolvedConfigLogEntry } from "./config-reporter.js";
28
+ import {
29
+ CONFIG_PATH,
30
+ DEFAULT_EXTENSION_CONFIG,
31
+ getPermissionSystemConfigPath,
32
+ loadPermissionSystemConfig,
33
+ normalizePermissionSystemConfig,
34
+ type PermissionSystemExtensionConfig,
35
+ savePermissionSystemConfig,
36
+ } from "./extension-config.js";
37
+ import { createPermissionSystemLogger, safeJsonStringify } from "./logging.js";
38
+ import { registerModelOptionCompatibilityGuard } from "./model-option-compatibility.js";
39
+ import {
40
+ isPermissionDecisionState,
41
+ type PermissionPromptDecision,
42
+ requestPermissionDecisionFromUi,
43
+ } from "./permission-dialog.js";
44
+ import {
45
+ createPermissionForwardingLocation,
46
+ type ForwardedPermissionRequest,
47
+ type ForwardedPermissionResponse,
48
+ isForwardedPermissionRequestForSession,
49
+ PERMISSION_FORWARDING_POLL_INTERVAL_MS,
50
+ PERMISSION_FORWARDING_TIMEOUT_MS,
51
+ type PermissionForwardingLocation,
52
+ resolvePermissionForwardingTargetSessionId,
53
+ SUBAGENT_ENV_HINT_KEYS,
54
+ } from "./permission-forwarding.js";
55
+ import { PermissionManager } from "./permission-manager.js";
56
+ import {
57
+ findSkillPathMatch,
58
+ resolveSkillPromptEntries,
59
+ type SkillPromptEntry,
60
+ } from "./skill-prompt-sanitizer.js";
61
+ import {
62
+ PERMISSION_SYSTEM_STATUS_KEY,
63
+ syncPermissionSystemStatus,
64
+ } from "./status.js";
65
+ import { sanitizeAvailableToolsSection } from "./system-prompt-sanitizer.js";
66
+ import {
67
+ checkRequestedToolRegistration,
68
+ getToolNameFromValue,
69
+ } from "./tool-registry.js";
70
+ import type { PermissionCheckResult } from "./types.js";
71
+ import {
72
+ canResolveAskPermissionRequest,
73
+ shouldAutoApprovePermissionState,
74
+ } from "./yolo-mode.js";
75
+
76
+ const PI_AGENT_DIR = getAgentDir();
77
+ const SESSIONS_DIR = join(PI_AGENT_DIR, "sessions");
78
+ const SUBAGENT_SESSIONS_DIR = join(PI_AGENT_DIR, "subagent-sessions");
79
+ const PERMISSION_FORWARDING_DIR = join(SESSIONS_DIR, "permission-forwarding");
80
+
81
+ const ACTIVE_AGENT_TAG_REGEX = /<active_agent\s+name=["']([^"']+)["'][^>]*>/i;
82
+
83
+ type PermissionRequestSource = "tool_call" | "skill_input" | "skill_read";
84
+ type PermissionRequestState = "waiting" | "approved" | "denied";
85
+
86
+ type PermissionRequestEvent = {
87
+ requestId: string;
88
+ source: PermissionRequestSource;
89
+ state: PermissionRequestState;
90
+ message: string;
91
+ toolCallId?: string;
92
+ toolName?: string;
93
+ skillName?: string;
94
+ path?: string;
95
+ command?: string;
96
+ target?: string;
97
+ toolInputPreview?: string;
98
+ agentName?: string | null;
99
+ };
100
+
101
+ const PERMISSION_REQUEST_EVENT_CHANNEL =
102
+ "pi-permission-system:permission-request";
103
+ const PATH_BEARING_TOOLS = new Set([
104
+ "read",
105
+ "write",
106
+ "edit",
107
+ "find",
108
+ "grep",
109
+ "ls",
110
+ ]);
111
+
112
+ let extensionConfig: PermissionSystemExtensionConfig = {
113
+ ...DEFAULT_EXTENSION_CONFIG,
114
+ };
115
+ const extensionLogger = createPermissionSystemLogger({
116
+ getConfig: () => extensionConfig,
117
+ });
118
+ const reportedLoggingWarnings = new Set<string>();
119
+ let loggingWarningReporter: ((message: string) => void) | null = null;
120
+
121
+ function setExtensionConfig(config: PermissionSystemExtensionConfig): void {
122
+ extensionConfig = normalizePermissionSystemConfig(config);
123
+ }
124
+
125
+ function setLoggingWarningReporter(
126
+ reporter: ((message: string) => void) | null,
127
+ ): void {
128
+ loggingWarningReporter = reporter;
129
+ }
130
+
131
+ function reportLoggingWarning(message: string): void {
132
+ if (!loggingWarningReporter || reportedLoggingWarnings.has(message)) {
133
+ return;
134
+ }
135
+
136
+ reportedLoggingWarnings.add(message);
137
+ loggingWarningReporter(message);
138
+ }
139
+
140
+ function writeDebugLog(
141
+ event: string,
142
+ details: Record<string, unknown> = {},
143
+ ): void {
144
+ const warning = extensionLogger.debug(event, details);
145
+ if (warning) {
146
+ reportLoggingWarning(warning);
147
+ }
148
+ }
149
+
150
+ function writeReviewLog(
151
+ event: string,
152
+ details: Record<string, unknown> = {},
153
+ ): void {
154
+ const warning = extensionLogger.review(event, details);
155
+ if (warning) {
156
+ reportLoggingWarning(warning);
157
+ }
158
+ }
159
+
160
+ function normalizePathForComparison(pathValue: string, cwd: string): string {
161
+ const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
162
+ if (!trimmed) {
163
+ return "";
164
+ }
165
+
166
+ let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
167
+
168
+ if (normalizedPath === "~") {
169
+ normalizedPath = homedir();
170
+ } else if (
171
+ normalizedPath.startsWith("~/") ||
172
+ normalizedPath.startsWith("~\\")
173
+ ) {
174
+ normalizedPath = join(homedir(), normalizedPath.slice(2));
175
+ }
176
+
177
+ const absolutePath = resolve(cwd, normalizedPath);
178
+ const normalizedAbsolutePath = normalize(absolutePath);
179
+ return process.platform === "win32"
180
+ ? normalizedAbsolutePath.toLowerCase()
181
+ : normalizedAbsolutePath;
182
+ }
183
+
184
+ function isPathWithinDirectory(pathValue: string, directory: string): boolean {
185
+ if (!pathValue || !directory) {
186
+ return false;
187
+ }
188
+
189
+ if (pathValue === directory) {
190
+ return true;
191
+ }
192
+
193
+ const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
194
+ return pathValue.startsWith(prefix);
195
+ }
196
+
197
+ function getPathBearingToolPath(
198
+ toolName: string,
199
+ input: unknown,
200
+ ): string | null {
201
+ if (!PATH_BEARING_TOOLS.has(toolName)) {
202
+ return null;
203
+ }
204
+
205
+ return getNonEmptyString(toRecord(input).path);
206
+ }
207
+
208
+ function isPathOutsideWorkingDirectory(
209
+ pathValue: string,
210
+ cwd: string,
211
+ ): boolean {
212
+ const normalizedCwd = normalizePathForComparison(cwd, cwd);
213
+ const normalizedPath = normalizePathForComparison(pathValue, cwd);
214
+ return Boolean(
215
+ normalizedCwd &&
216
+ normalizedPath &&
217
+ !isPathWithinDirectory(normalizedPath, normalizedCwd),
218
+ );
219
+ }
220
+
221
+ function extractSkillNameFromInput(text: string): string | null {
222
+ const trimmed = text.trim();
223
+ if (!trimmed.startsWith("/skill:")) {
224
+ return null;
225
+ }
226
+
227
+ const afterPrefix = trimmed.slice("/skill:".length);
228
+ if (!afterPrefix) {
229
+ return null;
230
+ }
231
+
232
+ const firstWhitespace = afterPrefix.search(/\s/);
233
+ const skillName = (
234
+ firstWhitespace === -1 ? afterPrefix : afterPrefix.slice(0, firstWhitespace)
235
+ ).trim();
236
+ return skillName || null;
237
+ }
238
+
239
+ function getEventToolName(event: unknown): string | null {
240
+ return getToolNameFromValue(event);
241
+ }
242
+
243
+ function getEventInput(event: unknown): unknown {
244
+ const record = toRecord(event);
245
+
246
+ if (record.input !== undefined) {
247
+ return record.input;
248
+ }
249
+
250
+ if (record.arguments !== undefined) {
251
+ return record.arguments;
252
+ }
253
+
254
+ return {};
255
+ }
256
+
257
+ function normalizeAgentName(value: unknown): string | null {
258
+ if (typeof value !== "string") {
259
+ return null;
260
+ }
261
+
262
+ const trimmed = value.trim();
263
+ return trimmed ? trimmed : null;
264
+ }
265
+
266
+ function getActiveAgentName(ctx: ExtensionContext): string | null {
267
+ const entries = ctx.sessionManager.getEntries();
268
+ for (let i = entries.length - 1; i >= 0; i--) {
269
+ const entry = entries[i] as {
270
+ type: string;
271
+ customType?: string;
272
+ data?: unknown;
273
+ };
274
+ if (entry.type !== "custom" || entry.customType !== "active_agent") {
275
+ continue;
276
+ }
277
+
278
+ const data = entry.data as { name?: unknown } | undefined;
279
+ const normalizedName = normalizeAgentName(data?.name);
280
+ if (normalizedName) {
281
+ return normalizedName;
282
+ }
283
+
284
+ if (data?.name === null) {
285
+ return null;
286
+ }
287
+ }
288
+
289
+ return null;
290
+ }
291
+
292
+ function getActiveAgentNameFromSystemPrompt(
293
+ systemPrompt: string | undefined,
294
+ ): string | null {
295
+ if (!systemPrompt) {
296
+ return null;
297
+ }
298
+
299
+ const match = systemPrompt.match(ACTIVE_AGENT_TAG_REGEX);
300
+ if (!match || !match[1]) {
301
+ return null;
302
+ }
303
+
304
+ return normalizeAgentName(match[1]);
305
+ }
306
+
307
+ function getContextSystemPrompt(ctx: ExtensionContext): string | undefined {
308
+ const getSystemPrompt = toRecord(ctx).getSystemPrompt;
309
+ if (typeof getSystemPrompt !== "function") {
310
+ return undefined;
311
+ }
312
+
313
+ try {
314
+ const systemPrompt = getSystemPrompt.call(ctx);
315
+ return typeof systemPrompt === "string" ? systemPrompt : undefined;
316
+ } catch (error) {
317
+ logPermissionForwardingWarning(
318
+ "Failed to read context system prompt for forwarded permission metadata",
319
+ error,
320
+ );
321
+ return undefined;
322
+ }
323
+ }
324
+
325
+ function formatMissingToolNameReason(): string {
326
+ return "Tool call was blocked because no tool name was provided. Use a registered tool name from pi.getAllTools().";
327
+ }
328
+
329
+ function formatUnknownToolReason(
330
+ toolName: string,
331
+ availableToolNames: readonly string[],
332
+ ): string {
333
+ const preview = availableToolNames.slice(0, 10);
334
+ const suffix = availableToolNames.length > preview.length ? ", ..." : "";
335
+ const availableList =
336
+ preview.length > 0 ? `${preview.join(", ")}${suffix}` : "none";
337
+
338
+ const mcpHint =
339
+ toolName === "mcp"
340
+ ? ""
341
+ : ' If this was intended as an MCP server tool, call the registered \'mcp\' tool when available (for example: {"tool":"server:tool"}).';
342
+
343
+ return `Tool '${toolName}' is not registered in this runtime and was blocked before permission checks.${mcpHint} Registered tools: ${availableList}.`;
344
+ }
345
+
346
+ function formatPermissionHardStopHint(result: PermissionCheckResult): string {
347
+ if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
348
+ 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.";
349
+ }
350
+
351
+ return "Hard stop: this permission denial is policy-enforced. Do not retry or investigate bypasses; report the block to the user.";
352
+ }
353
+
354
+ function formatDenyReason(
355
+ result: PermissionCheckResult,
356
+ agentName?: string,
357
+ ): string {
358
+ const parts: string[] = [];
359
+
360
+ if (agentName) {
361
+ parts.push(`Agent '${agentName}'`);
362
+ }
363
+
364
+ if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
365
+ parts.push(`is not permitted to run MCP target '${result.target}'`);
366
+ } else {
367
+ parts.push(`is not permitted to run '${result.toolName}'`);
368
+ }
369
+
370
+ if (result.command) {
371
+ parts.push(`command '${result.command}'`);
372
+ }
373
+
374
+ if (result.matchedPattern) {
375
+ parts.push(`(matched '${result.matchedPattern}')`);
376
+ }
377
+
378
+ return `${parts.join(" ")}. ${formatPermissionHardStopHint(result)}`;
379
+ }
380
+
381
+ function formatUserDeniedReason(
382
+ result: PermissionCheckResult,
383
+ denialReason?: string,
384
+ ): string {
385
+ const base =
386
+ (result.source === "mcp" || result.toolName === "mcp") && result.target
387
+ ? `User denied MCP target '${result.target}'.`
388
+ : result.toolName === "bash" && result.command
389
+ ? `User denied bash command '${result.command}'.`
390
+ : `User denied tool '${result.toolName}'.`;
391
+ const reasonSuffix = denialReason ? ` Reason: ${denialReason}.` : "";
392
+
393
+ return `${base}${reasonSuffix} ${formatPermissionHardStopHint(result)}`;
394
+ }
395
+
396
+ const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
397
+ const TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH = 1000;
398
+ const TOOL_TEXT_SUMMARY_MAX_LENGTH = 80;
399
+
400
+ function truncateInlineText(value: string, maxLength: number): string {
401
+ return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
402
+ }
403
+
404
+ function sanitizeInlineText(
405
+ value: string,
406
+ maxLength = TOOL_TEXT_SUMMARY_MAX_LENGTH,
407
+ ): string {
408
+ const normalized = value.replace(/\s+/g, " ").trim();
409
+ return normalized ? truncateInlineText(normalized, maxLength) : "empty text";
410
+ }
411
+
412
+ function countTextLines(value: string): number {
413
+ if (!value) {
414
+ return 0;
415
+ }
416
+
417
+ return value.split(/\r\n|\r|\n/).length;
418
+ }
419
+
420
+ function formatCount(value: number, singular: string, plural: string): string {
421
+ return `${value} ${value === 1 ? singular : plural}`;
422
+ }
423
+
424
+ function getPromptPath(input: Record<string, unknown>): string | null {
425
+ return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
426
+ }
427
+
428
+ function formatEditInputForPrompt(input: Record<string, unknown>): string {
429
+ const path = getPromptPath(input);
430
+ const rawEdits = Array.isArray(input.edits)
431
+ ? input.edits
432
+ : typeof input.oldText === "string" && typeof input.newText === "string"
433
+ ? [{ oldText: input.oldText, newText: input.newText }]
434
+ : [];
435
+
436
+ const edits = rawEdits
437
+ .map((edit) => toRecord(edit))
438
+ .filter(
439
+ (edit) =>
440
+ typeof edit.oldText === "string" && typeof edit.newText === "string",
441
+ );
442
+
443
+ const pathPart = path ? `for '${path}'` : "";
444
+ if (edits.length === 0) {
445
+ return pathPart ? `${pathPart} with edit input` : "with edit input";
446
+ }
447
+
448
+ const firstEdit = edits[0];
449
+ const oldText = String(firstEdit.oldText);
450
+ const newText = String(firstEdit.newText);
451
+ const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
452
+ const extraEdits =
453
+ edits.length > 1
454
+ ? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}`
455
+ : "";
456
+ const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
457
+ return pathPart ? `${pathPart} ${summary}` : summary;
458
+ }
459
+
460
+ function formatWriteInputForPrompt(input: Record<string, unknown>): string {
461
+ const path = getPromptPath(input);
462
+ const content = typeof input.content === "string" ? input.content : "";
463
+ const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
464
+ return path ? `for '${path}' ${summary}` : summary;
465
+ }
466
+
467
+ function formatReadInputForPrompt(input: Record<string, unknown>): string {
468
+ const path = getPromptPath(input);
469
+ const parts = path ? [`path '${path}'`] : [];
470
+ if (typeof input.offset === "number") {
471
+ parts.push(`offset ${input.offset}`);
472
+ }
473
+ if (typeof input.limit === "number") {
474
+ parts.push(`limit ${input.limit}`);
475
+ }
476
+ return parts.length > 0 ? `for ${parts.join(", ")}` : "";
477
+ }
478
+
479
+ function formatSearchInputForPrompt(
480
+ toolName: string,
481
+ input: Record<string, unknown>,
482
+ ): string {
483
+ const parts: string[] = [];
484
+ const path = getPromptPath(input);
485
+ const pattern = getNonEmptyString(input.pattern);
486
+ const glob = getNonEmptyString(input.glob);
487
+
488
+ if (pattern) {
489
+ parts.push(`pattern '${sanitizeInlineText(pattern)}'`);
490
+ }
491
+ if (glob) {
492
+ parts.push(`glob '${sanitizeInlineText(glob)}'`);
493
+ }
494
+ if (path) {
495
+ parts.push(`path '${path}'`);
496
+ } else if (toolName === "find" || toolName === "grep" || toolName === "ls") {
497
+ parts.push("current working directory");
498
+ }
499
+
500
+ return parts.length > 0 ? `for ${parts.join(", ")}` : "";
501
+ }
502
+
503
+ function serializeToolInputPreview(input: unknown): string {
504
+ const serialized = safeJsonStringify(input);
505
+ if (!serialized || serialized === "{}" || serialized === "null") {
506
+ return "";
507
+ }
508
+
509
+ return serialized.replace(/\s+/g, " ").trim();
510
+ }
511
+
512
+ function formatJsonInputForPrompt(input: unknown): string {
513
+ const inline = serializeToolInputPreview(input);
514
+ return inline
515
+ ? `with input ${truncateInlineText(inline, TOOL_INPUT_PREVIEW_MAX_LENGTH)}`
516
+ : "";
517
+ }
518
+
519
+ function formatToolInputForPrompt(toolName: string, input: unknown): string {
520
+ const inputRecord = toRecord(input);
521
+
522
+ switch (toolName) {
523
+ case "edit":
524
+ return formatEditInputForPrompt(inputRecord);
525
+ case "write":
526
+ return formatWriteInputForPrompt(inputRecord);
527
+ case "read":
528
+ return formatReadInputForPrompt(inputRecord);
529
+ case "find":
530
+ case "grep":
531
+ case "ls":
532
+ return formatSearchInputForPrompt(toolName, inputRecord);
533
+ default:
534
+ return formatJsonInputForPrompt(input);
535
+ }
536
+ }
537
+
538
+ function formatAskPrompt(
539
+ result: PermissionCheckResult,
540
+ agentName?: string,
541
+ input?: unknown,
542
+ ): string {
543
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
544
+
545
+ if (result.toolName === "bash") {
546
+ const patternInfo = result.matchedPattern
547
+ ? ` (matched '${result.matchedPattern}')`
548
+ : "";
549
+ return `${subject} requested bash command '${result.command || ""}'${patternInfo}. Allow this command?`;
550
+ }
551
+
552
+ if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
553
+ const patternInfo = result.matchedPattern
554
+ ? ` (matched '${result.matchedPattern}')`
555
+ : "";
556
+ return `${subject} requested MCP target '${result.target}'${patternInfo}. Allow this call?`;
557
+ }
558
+
559
+ const patternInfo = result.matchedPattern
560
+ ? ` (matched '${result.matchedPattern}')`
561
+ : "";
562
+ const inputPreview = formatToolInputForPrompt(result.toolName, input);
563
+ const inputSuffix = inputPreview ? ` ${inputPreview}` : "";
564
+ return `${subject} requested tool '${result.toolName}'${patternInfo}${inputSuffix}. Allow this call?`;
565
+ }
566
+
567
+ function formatSkillAskPrompt(skillName: string, agentName?: string): string {
568
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
569
+ return `${subject} requested skill '${skillName}'. Allow loading this skill?`;
570
+ }
571
+
572
+ function formatSkillPathAskPrompt(
573
+ skill: SkillPromptEntry,
574
+ readPath: string,
575
+ agentName?: string,
576
+ ): string {
577
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
578
+ return `${subject} requested access to skill '${skill.name}' via '${readPath}'. Allow this read?`;
579
+ }
580
+
581
+ function formatSkillPathDenyReason(
582
+ skill: SkillPromptEntry,
583
+ readPath: string,
584
+ agentName?: string,
585
+ ): string {
586
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
587
+ return `${subject} is not permitted to access skill '${skill.name}' via '${readPath}'.`;
588
+ }
589
+
590
+ function formatExternalDirectoryHardStopHint(): string {
591
+ 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.";
592
+ }
593
+
594
+ function formatExternalDirectoryAskPrompt(
595
+ toolName: string,
596
+ pathValue: string,
597
+ cwd: string,
598
+ agentName?: string,
599
+ ): string {
600
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
601
+ return `${subject} requested tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. Allow this external directory access?`;
602
+ }
603
+
604
+ function formatExternalDirectoryDenyReason(
605
+ toolName: string,
606
+ pathValue: string,
607
+ cwd: string,
608
+ agentName?: string,
609
+ ): string {
610
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
611
+ return `${subject} is not permitted to run tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. ${formatExternalDirectoryHardStopHint()}`;
612
+ }
613
+
614
+ function formatExternalDirectoryUserDeniedReason(
615
+ toolName: string,
616
+ pathValue: string,
617
+ denialReason?: string,
618
+ ): string {
619
+ const reasonSuffix = denialReason ? ` Reason: ${denialReason}.` : "";
620
+ return `User denied external directory access for tool '${toolName}' path '${pathValue}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
621
+ }
622
+
623
+ function formatGenericToolInputForLog(input: unknown): string | undefined {
624
+ const inline = serializeToolInputPreview(input);
625
+ return inline
626
+ ? `input ${truncateInlineText(inline, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)}`
627
+ : undefined;
628
+ }
629
+
630
+ function getToolInputPreviewForLog(
631
+ result: PermissionCheckResult,
632
+ input: unknown,
633
+ ): string | undefined {
634
+ if (
635
+ result.toolName === "bash" ||
636
+ result.toolName === "mcp" ||
637
+ result.source === "mcp"
638
+ ) {
639
+ return undefined;
640
+ }
641
+
642
+ if (PATH_BEARING_TOOLS.has(result.toolName)) {
643
+ const inputPreview = formatToolInputForPrompt(result.toolName, input);
644
+ return inputPreview
645
+ ? truncateInlineText(inputPreview, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)
646
+ : undefined;
647
+ }
648
+
649
+ return formatGenericToolInputForLog(input);
650
+ }
651
+
652
+ function getPermissionLogContext(
653
+ result: PermissionCheckResult,
654
+ input: unknown,
655
+ ): { command?: string; target?: string; toolInputPreview?: string } {
656
+ return {
657
+ command: result.command,
658
+ target: result.target,
659
+ toolInputPreview: getToolInputPreviewForLog(result, input),
660
+ };
661
+ }
662
+
663
+ function sleep(ms: number): Promise<void> {
664
+ return new Promise((resolve) => {
665
+ setTimeout(resolve, ms);
666
+ });
667
+ }
668
+
669
+ function normalizeFilesystemPath(pathValue: string): string {
670
+ const normalizedPath = normalize(pathValue);
671
+ return process.platform === "win32"
672
+ ? normalizedPath.toLowerCase()
673
+ : normalizedPath;
674
+ }
675
+
676
+ function getSessionId(ctx: ExtensionContext): string {
677
+ try {
678
+ const sessionId = ctx.sessionManager.getSessionId();
679
+ if (typeof sessionId === "string" && sessionId.trim()) {
680
+ return sessionId.trim();
681
+ }
682
+ } catch {}
683
+
684
+ return "unknown";
685
+ }
686
+
687
+ function isSubagentExecutionContext(ctx: ExtensionContext): boolean {
688
+ for (const key of SUBAGENT_ENV_HINT_KEYS) {
689
+ const value = process.env[key];
690
+ if (typeof value === "string" && value.trim()) {
691
+ return true;
692
+ }
693
+ }
694
+
695
+ const sessionDir = ctx.sessionManager.getSessionDir();
696
+ if (!sessionDir) {
697
+ return false;
698
+ }
699
+
700
+ const normalizedSessionDir = normalizeFilesystemPath(sessionDir);
701
+ const normalizedSubagentRoot = normalizeFilesystemPath(SUBAGENT_SESSIONS_DIR);
702
+ return isPathWithinDirectory(normalizedSessionDir, normalizedSubagentRoot);
703
+ }
704
+
705
+ function canRequestPermissionConfirmation(ctx: ExtensionContext): boolean {
706
+ return canResolveAskPermissionRequest({
707
+ config: extensionConfig,
708
+ hasUI: ctx.hasUI,
709
+ isSubagent: isSubagentExecutionContext(ctx),
710
+ });
711
+ }
712
+
713
+ function formatUnknownErrorMessage(error: unknown): string {
714
+ if (error instanceof Error && error.message) {
715
+ return error.message;
716
+ }
717
+ return String(error);
718
+ }
719
+
720
+ function isErrnoCode(error: unknown, code: string): boolean {
721
+ return Boolean(
722
+ error &&
723
+ typeof error === "object" &&
724
+ "code" in error &&
725
+ (error as { code?: string }).code === code,
726
+ );
727
+ }
728
+
729
+ function logPermissionForwardingWarning(
730
+ message: string,
731
+ error?: unknown,
732
+ ): void {
733
+ const details =
734
+ typeof error === "undefined"
735
+ ? { message }
736
+ : { message, error: formatUnknownErrorMessage(error) };
737
+
738
+ writeReviewLog("permission_forwarding.warning", details);
739
+ writeDebugLog("permission_forwarding.warning", details);
740
+ }
741
+
742
+ function logPermissionForwardingError(message: string, error?: unknown): void {
743
+ const details =
744
+ typeof error === "undefined"
745
+ ? { message }
746
+ : { message, error: formatUnknownErrorMessage(error) };
747
+
748
+ writeReviewLog("permission_forwarding.error", details);
749
+ writeDebugLog("permission_forwarding.error", details);
750
+ }
751
+
752
+ function ensureDirectoryExists(path: string, description: string): boolean {
753
+ try {
754
+ mkdirSync(path, { recursive: true });
755
+ return true;
756
+ } catch (error) {
757
+ logPermissionForwardingError(
758
+ `Failed to create ${description} directory '${path}'`,
759
+ error,
760
+ );
761
+ return false;
762
+ }
763
+ }
764
+
765
+ function getPermissionForwardingLocationForSession(
766
+ sessionId: string,
767
+ ): PermissionForwardingLocation {
768
+ return createPermissionForwardingLocation(
769
+ PERMISSION_FORWARDING_DIR,
770
+ sessionId,
771
+ );
772
+ }
773
+
774
+ function ensurePermissionForwardingLocation(
775
+ sessionId: string,
776
+ ): PermissionForwardingLocation | null {
777
+ let location: PermissionForwardingLocation;
778
+ try {
779
+ location = getPermissionForwardingLocationForSession(sessionId);
780
+ } catch (error) {
781
+ logPermissionForwardingError(
782
+ "Failed to resolve permission forwarding location",
783
+ error,
784
+ );
785
+ return null;
786
+ }
787
+
788
+ const sessionRootReady = ensureDirectoryExists(
789
+ location.sessionRootDir,
790
+ "permission forwarding session root",
791
+ );
792
+ const requestsReady = ensureDirectoryExists(
793
+ location.requestsDir,
794
+ "permission forwarding requests",
795
+ );
796
+ const responsesReady = ensureDirectoryExists(
797
+ location.responsesDir,
798
+ "permission forwarding responses",
799
+ );
800
+
801
+ return sessionRootReady && requestsReady && responsesReady ? location : null;
802
+ }
803
+
804
+ function getExistingPermissionForwardingLocation(
805
+ sessionId: string,
806
+ ): PermissionForwardingLocation | null {
807
+ let location: PermissionForwardingLocation;
808
+ try {
809
+ location = getPermissionForwardingLocationForSession(sessionId);
810
+ } catch {
811
+ return null;
812
+ }
813
+
814
+ return existsSync(location.requestsDir) ? location : null;
815
+ }
816
+
817
+ function tryRemoveDirectoryIfEmpty(path: string, description: string): void {
818
+ if (!existsSync(path)) {
819
+ return;
820
+ }
821
+
822
+ let entries: string[];
823
+ try {
824
+ entries = readdirSync(path);
825
+ } catch (error) {
826
+ logPermissionForwardingWarning(
827
+ `Failed to inspect ${description} directory '${path}'`,
828
+ error,
829
+ );
830
+ return;
831
+ }
832
+
833
+ if (entries.length > 0) {
834
+ return;
835
+ }
836
+
837
+ try {
838
+ rmdirSync(path);
839
+ } catch (error) {
840
+ if (isErrnoCode(error, "ENOENT") || isErrnoCode(error, "ENOTEMPTY")) {
841
+ return;
842
+ }
843
+
844
+ logPermissionForwardingWarning(
845
+ `Failed to remove empty ${description} directory '${path}'`,
846
+ error,
847
+ );
848
+ }
849
+ }
850
+
851
+ function cleanupPermissionForwardingLocationIfEmpty(
852
+ location: PermissionForwardingLocation,
853
+ ): void {
854
+ tryRemoveDirectoryIfEmpty(
855
+ location.requestsDir,
856
+ `${location.label} permission forwarding requests`,
857
+ );
858
+ tryRemoveDirectoryIfEmpty(
859
+ location.responsesDir,
860
+ `${location.label} permission forwarding responses`,
861
+ );
862
+ tryRemoveDirectoryIfEmpty(
863
+ location.sessionRootDir,
864
+ `${location.label} permission forwarding session root`,
865
+ );
866
+ }
867
+
868
+ function safeDeleteFile(filePath: string, description: string): void {
869
+ try {
870
+ unlinkSync(filePath);
871
+ } catch (error) {
872
+ if (isErrnoCode(error, "ENOENT")) {
873
+ return;
874
+ }
875
+
876
+ logPermissionForwardingWarning(
877
+ `Failed to delete ${description} file '${filePath}'`,
878
+ error,
879
+ );
880
+ }
881
+ }
882
+
883
+ function writeJsonFileAtomic(filePath: string, value: unknown): void {
884
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
885
+
886
+ try {
887
+ writeFileSync(tempPath, JSON.stringify(value), "utf-8");
888
+ renameSync(tempPath, filePath);
889
+ } catch (error) {
890
+ safeDeleteFile(tempPath, "temporary permission-forwarding");
891
+ throw error;
892
+ }
893
+ }
894
+
895
+ function readForwardedPermissionRequest(
896
+ filePath: string,
897
+ ): ForwardedPermissionRequest | null {
898
+ try {
899
+ const raw = readFileSync(filePath, "utf-8");
900
+ const parsed = JSON.parse(raw) as Partial<ForwardedPermissionRequest>;
901
+ if (
902
+ !parsed ||
903
+ typeof parsed.id !== "string" ||
904
+ typeof parsed.createdAt !== "number" ||
905
+ typeof parsed.requesterSessionId !== "string" ||
906
+ typeof parsed.targetSessionId !== "string" ||
907
+ typeof parsed.requesterAgentName !== "string" ||
908
+ typeof parsed.message !== "string"
909
+ ) {
910
+ logPermissionForwardingWarning(
911
+ `Ignoring invalid forwarded permission request format in '${filePath}'`,
912
+ );
913
+ return null;
914
+ }
915
+
916
+ return {
917
+ id: parsed.id,
918
+ createdAt: parsed.createdAt,
919
+ requesterSessionId: parsed.requesterSessionId,
920
+ targetSessionId: parsed.targetSessionId,
921
+ requesterAgentName: parsed.requesterAgentName,
922
+ message: parsed.message,
923
+ };
924
+ } catch (error) {
925
+ logPermissionForwardingWarning(
926
+ `Failed to read forwarded permission request '${filePath}'`,
927
+ error,
928
+ );
929
+ return null;
930
+ }
931
+ }
932
+
933
+ function readForwardedPermissionResponse(
934
+ filePath: string,
935
+ ): ForwardedPermissionResponse | null {
936
+ try {
937
+ const raw = readFileSync(filePath, "utf-8");
938
+ const parsed = JSON.parse(raw) as Partial<ForwardedPermissionResponse>;
939
+ if (
940
+ !parsed ||
941
+ typeof parsed.approved !== "boolean" ||
942
+ !isPermissionDecisionState(parsed.state) ||
943
+ typeof parsed.responderSessionId !== "string"
944
+ ) {
945
+ logPermissionForwardingWarning(
946
+ `Ignoring invalid forwarded permission response format in '${filePath}'`,
947
+ );
948
+ return null;
949
+ }
950
+
951
+ return {
952
+ approved: parsed.approved,
953
+ state: parsed.state,
954
+ denialReason:
955
+ typeof parsed.denialReason === "string"
956
+ ? parsed.denialReason
957
+ : undefined,
958
+ responderSessionId: parsed.responderSessionId,
959
+ respondedAt:
960
+ typeof parsed.respondedAt === "number"
961
+ ? parsed.respondedAt
962
+ : Date.now(),
963
+ };
964
+ } catch (error) {
965
+ logPermissionForwardingWarning(
966
+ `Failed to read forwarded permission response '${filePath}'`,
967
+ error,
968
+ );
969
+ return null;
970
+ }
971
+ }
972
+
973
+ function formatForwardedPermissionPrompt(
974
+ request: ForwardedPermissionRequest,
975
+ ): string {
976
+ const agentName = request.requesterAgentName || "unknown";
977
+ const sessionId = request.requesterSessionId || "unknown";
978
+ return [
979
+ `Subagent '${agentName}' requested permission.`,
980
+ `Session ID: ${sessionId}`,
981
+ "",
982
+ request.message,
983
+ ].join("\n");
984
+ }
985
+
986
+ async function waitForForwardedPermissionApproval(
987
+ ctx: ExtensionContext,
988
+ message: string,
989
+ ): Promise<PermissionPromptDecision> {
990
+ const requesterSessionId = getSessionId(ctx);
991
+ const targetSessionId = resolvePermissionForwardingTargetSessionId({
992
+ hasUI: ctx.hasUI,
993
+ isSubagent: isSubagentExecutionContext(ctx),
994
+ currentSessionId: requesterSessionId,
995
+ env: process.env,
996
+ });
997
+
998
+ if (!targetSessionId) {
999
+ logPermissionForwardingError(
1000
+ "Permission forwarding target session could not be resolved from subagent runtime metadata (expected PI_AGENT_ROUTER_PARENT_SESSION_ID)",
1001
+ );
1002
+ return { approved: false, state: "denied" };
1003
+ }
1004
+
1005
+ const location = ensurePermissionForwardingLocation(targetSessionId);
1006
+ if (!location) {
1007
+ logPermissionForwardingError(
1008
+ `Permission forwarding is unavailable because session-scoped directories could not be prepared for '${targetSessionId}'`,
1009
+ );
1010
+ return { approved: false, state: "denied" };
1011
+ }
1012
+
1013
+ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
1014
+ const requesterAgentName =
1015
+ getActiveAgentName(ctx) ||
1016
+ getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ||
1017
+ "unknown";
1018
+ const request: ForwardedPermissionRequest = {
1019
+ id: requestId,
1020
+ createdAt: Date.now(),
1021
+ requesterSessionId,
1022
+ targetSessionId,
1023
+ requesterAgentName,
1024
+ message,
1025
+ };
1026
+
1027
+ const requestPath = join(location.requestsDir, `${requestId}.json`);
1028
+ const responsePath = join(location.responsesDir, `${requestId}.json`);
1029
+
1030
+ writeReviewLog("forwarded_permission.request_created", {
1031
+ requestId,
1032
+ requesterAgentName,
1033
+ requesterSessionId: request.requesterSessionId,
1034
+ targetSessionId,
1035
+ requestPath,
1036
+ responsePath,
1037
+ });
1038
+
1039
+ try {
1040
+ writeJsonFileAtomic(requestPath, request);
1041
+ } catch (error) {
1042
+ logPermissionForwardingError(
1043
+ `Failed to write forwarded permission request '${requestPath}'`,
1044
+ error,
1045
+ );
1046
+ return { approved: false, state: "denied" };
1047
+ }
1048
+
1049
+ const deadline = Date.now() + PERMISSION_FORWARDING_TIMEOUT_MS;
1050
+ while (Date.now() < deadline) {
1051
+ if (existsSync(responsePath)) {
1052
+ const response = readForwardedPermissionResponse(responsePath);
1053
+ writeReviewLog("forwarded_permission.response_received", {
1054
+ requestId,
1055
+ approved: response?.approved ?? null,
1056
+ state: response?.state ?? null,
1057
+ denialReason: response?.denialReason ?? null,
1058
+ responderSessionId: response?.responderSessionId ?? null,
1059
+ targetSessionId,
1060
+ responsePath,
1061
+ });
1062
+ safeDeleteFile(responsePath, "forwarded permission response");
1063
+ safeDeleteFile(requestPath, "forwarded permission request");
1064
+ cleanupPermissionForwardingLocationIfEmpty(location);
1065
+ return response ?? { approved: false, state: "denied" };
1066
+ }
1067
+
1068
+ await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
1069
+ }
1070
+
1071
+ logPermissionForwardingWarning(
1072
+ `Timed out waiting for forwarded permission response '${responsePath}'`,
1073
+ );
1074
+ writeReviewLog("forwarded_permission.response_timed_out", {
1075
+ requestId,
1076
+ requesterAgentName,
1077
+ targetSessionId,
1078
+ responsePath,
1079
+ });
1080
+ safeDeleteFile(requestPath, "forwarded permission request");
1081
+ cleanupPermissionForwardingLocationIfEmpty(location);
1082
+ return { approved: false, state: "denied" };
1083
+ }
1084
+
1085
+ async function processForwardedPermissionRequests(
1086
+ ctx: ExtensionContext,
1087
+ ): Promise<void> {
1088
+ if (!ctx.hasUI) {
1089
+ return;
1090
+ }
1091
+
1092
+ const currentSessionId = getSessionId(ctx);
1093
+ const location = getExistingPermissionForwardingLocation(currentSessionId);
1094
+ if (!location) {
1095
+ return;
1096
+ }
1097
+
1098
+ let requestFiles: string[] = [];
1099
+ try {
1100
+ requestFiles = readdirSync(location.requestsDir)
1101
+ .filter((name) => name.endsWith(".json"))
1102
+ .sort();
1103
+ } catch (error) {
1104
+ logPermissionForwardingWarning(
1105
+ `Failed to read ${location.label} permission forwarding requests from '${location.requestsDir}'`,
1106
+ error,
1107
+ );
1108
+ return;
1109
+ }
1110
+
1111
+ for (const fileName of requestFiles) {
1112
+ const requestPath = join(location.requestsDir, fileName);
1113
+ const request = readForwardedPermissionRequest(requestPath);
1114
+ if (!request) {
1115
+ safeDeleteFile(
1116
+ requestPath,
1117
+ `${location.label} forwarded permission request`,
1118
+ );
1119
+ continue;
1120
+ }
1121
+
1122
+ if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
1123
+ logPermissionForwardingWarning(
1124
+ `Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
1125
+ );
1126
+ safeDeleteFile(
1127
+ requestPath,
1128
+ `${location.label} forwarded permission request`,
1129
+ );
1130
+ continue;
1131
+ }
1132
+
1133
+ const forwardedPermissionLogDetails = {
1134
+ requestId: request.id,
1135
+ source: location.label,
1136
+ requesterAgentName: request.requesterAgentName,
1137
+ requesterSessionId: request.requesterSessionId,
1138
+ targetSessionId: request.targetSessionId,
1139
+ requestPath,
1140
+ };
1141
+
1142
+ let decision: PermissionPromptDecision = {
1143
+ approved: false,
1144
+ state: "denied",
1145
+ };
1146
+ if (shouldAutoApprovePermissionState("ask", extensionConfig)) {
1147
+ writeReviewLog(
1148
+ "forwarded_permission.auto_approved",
1149
+ forwardedPermissionLogDetails,
1150
+ );
1151
+ decision = { approved: true, state: "approved" };
1152
+ } else {
1153
+ writeReviewLog(
1154
+ "forwarded_permission.prompted",
1155
+ forwardedPermissionLogDetails,
1156
+ );
1157
+ try {
1158
+ decision = await requestPermissionDecisionFromUi(
1159
+ ctx.ui,
1160
+ "Permission Required (Subagent)",
1161
+ formatForwardedPermissionPrompt(request),
1162
+ );
1163
+ } catch (error) {
1164
+ logPermissionForwardingError(
1165
+ "Failed to show forwarded permission confirmation dialog",
1166
+ error,
1167
+ );
1168
+ decision = { approved: false, state: "denied" };
1169
+ }
1170
+ }
1171
+
1172
+ const responsePath = join(location.responsesDir, `${request.id}.json`);
1173
+ writeReviewLog(
1174
+ decision.approved
1175
+ ? "forwarded_permission.approved"
1176
+ : "forwarded_permission.denied",
1177
+ {
1178
+ requestId: request.id,
1179
+ source: location.label,
1180
+ requesterAgentName: request.requesterAgentName,
1181
+ requesterSessionId: request.requesterSessionId,
1182
+ targetSessionId: request.targetSessionId,
1183
+ responsePath,
1184
+ resolution: decision.state,
1185
+ denialReason: decision.denialReason ?? null,
1186
+ },
1187
+ );
1188
+ try {
1189
+ writeJsonFileAtomic(responsePath, {
1190
+ approved: decision.approved,
1191
+ state: decision.state,
1192
+ denialReason: decision.denialReason,
1193
+ responderSessionId: currentSessionId,
1194
+ respondedAt: Date.now(),
1195
+ } satisfies ForwardedPermissionResponse);
1196
+ } catch (error) {
1197
+ logPermissionForwardingError(
1198
+ `Failed to write ${location.label} forwarded permission response '${responsePath}'`,
1199
+ error,
1200
+ );
1201
+ continue;
1202
+ }
1203
+
1204
+ safeDeleteFile(
1205
+ requestPath,
1206
+ `${location.label} forwarded permission request`,
1207
+ );
1208
+ }
1209
+
1210
+ cleanupPermissionForwardingLocationIfEmpty(location);
1211
+ }
1212
+
1213
+ async function confirmPermission(
1214
+ ctx: ExtensionContext,
1215
+ message: string,
1216
+ ): Promise<PermissionPromptDecision> {
1217
+ if (ctx.hasUI) {
1218
+ return requestPermissionDecisionFromUi(
1219
+ ctx.ui,
1220
+ "Permission Required",
1221
+ message,
1222
+ );
1223
+ }
1224
+
1225
+ if (!isSubagentExecutionContext(ctx)) {
1226
+ return { approved: false, state: "denied" };
1227
+ }
1228
+
1229
+ return waitForForwardedPermissionApproval(ctx, message);
1230
+ }
1231
+
1232
+ function derivePiProjectPaths(cwd: string | undefined | null): {
1233
+ projectGlobalConfigPath: string;
1234
+ projectAgentsDir: string;
1235
+ } | null {
1236
+ if (!cwd) {
1237
+ return null;
1238
+ }
1239
+
1240
+ const projectAgentRoot = join(cwd, ".pi", "agent");
1241
+ return {
1242
+ projectGlobalConfigPath: join(projectAgentRoot, "pi-permissions.jsonc"),
1243
+ projectAgentsDir: join(projectAgentRoot, "agents"),
1244
+ };
1245
+ }
1246
+
1247
+ function createPermissionManagerForCwd(
1248
+ cwd: string | undefined | null,
1249
+ ): PermissionManager {
1250
+ const projectPaths = derivePiProjectPaths(cwd);
1251
+ if (!projectPaths) {
1252
+ return new PermissionManager();
1253
+ }
1254
+
1255
+ return new PermissionManager({
1256
+ projectGlobalConfigPath: projectPaths.projectGlobalConfigPath,
1257
+ projectAgentsDir: projectPaths.projectAgentsDir,
1258
+ });
1259
+ }
1260
+
1261
+ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1262
+ let permissionManager = new PermissionManager();
1263
+ let activeSkillEntries: SkillPromptEntry[] = [];
1264
+ let lastKnownActiveAgentName: string | null = null;
1265
+ let lastActiveToolsCacheKey: string | null = null;
1266
+ let lastPromptStateCacheKey: string | null = null;
1267
+ let permissionForwardingContext: ExtensionContext | null = null;
1268
+ let permissionForwardingTimer: NodeJS.Timeout | null = null;
1269
+ let isProcessingForwardedRequests = false;
1270
+ let runtimeContext: ExtensionContext | null = null;
1271
+ let lastConfigWarning: string | null = null;
1272
+
1273
+ const invalidateAgentStartCache = (): void => {
1274
+ activeSkillEntries = [];
1275
+ lastActiveToolsCacheKey = null;
1276
+ lastPromptStateCacheKey = null;
1277
+ };
1278
+
1279
+ const notifyWarning = (message: string): void => {
1280
+ if (!runtimeContext?.hasUI) {
1281
+ return;
1282
+ }
1283
+
1284
+ runtimeContext.ui.notify(message, "warning");
1285
+ };
1286
+
1287
+ const refreshExtensionConfig = (ctx?: ExtensionContext): void => {
1288
+ if (ctx) {
1289
+ runtimeContext = ctx;
1290
+ }
1291
+
1292
+ const result = loadPermissionSystemConfig();
1293
+ setExtensionConfig(result.config);
1294
+
1295
+ if (runtimeContext?.hasUI) {
1296
+ syncPermissionSystemStatus(runtimeContext, result.config);
1297
+ }
1298
+
1299
+ if (result.warning && result.warning !== lastConfigWarning) {
1300
+ lastConfigWarning = result.warning;
1301
+ notifyWarning(result.warning);
1302
+ } else if (!result.warning) {
1303
+ lastConfigWarning = null;
1304
+ }
1305
+
1306
+ writeDebugLog("config.loaded", {
1307
+ created: result.created,
1308
+ warning: result.warning ?? null,
1309
+ debugLog: result.config.debugLog,
1310
+ permissionReviewLog: result.config.permissionReviewLog,
1311
+ yoloMode: result.config.yoloMode,
1312
+ });
1313
+ };
1314
+
1315
+ const saveExtensionConfig = (
1316
+ next: PermissionSystemExtensionConfig,
1317
+ ctx: ExtensionCommandContext,
1318
+ ): void => {
1319
+ const normalized = normalizePermissionSystemConfig(next);
1320
+ const saved = savePermissionSystemConfig(normalized);
1321
+ if (!saved.success) {
1322
+ if (saved.error) {
1323
+ ctx.ui.notify(saved.error, "error");
1324
+ }
1325
+ return;
1326
+ }
1327
+
1328
+ setExtensionConfig(normalized);
1329
+ syncPermissionSystemStatus(ctx, normalized);
1330
+ lastConfigWarning = null;
1331
+
1332
+ writeDebugLog("config.saved", {
1333
+ debugLog: normalized.debugLog,
1334
+ permissionReviewLog: normalized.permissionReviewLog,
1335
+ yoloMode: normalized.yoloMode,
1336
+ });
1337
+ };
1338
+
1339
+ setLoggingWarningReporter(notifyWarning);
1340
+ refreshExtensionConfig();
1341
+ registerModelOptionCompatibilityGuard(pi);
1342
+
1343
+ registerPermissionSystemCommand(pi, {
1344
+ getConfig: () => extensionConfig,
1345
+ setConfig: saveExtensionConfig,
1346
+ getConfigPath: getPermissionSystemConfigPath,
1347
+ });
1348
+
1349
+ const createPermissionRequestId = (prefix: string): string => {
1350
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
1351
+ };
1352
+
1353
+ const emitPermissionRequestEvent = (event: PermissionRequestEvent): void => {
1354
+ try {
1355
+ pi.events.emit(PERMISSION_REQUEST_EVENT_CHANNEL, event);
1356
+ } catch (error) {
1357
+ writeDebugLog("permission_request.event_emit_failed", {
1358
+ requestId: event.requestId,
1359
+ source: event.source,
1360
+ state: event.state,
1361
+ error: formatUnknownErrorMessage(error),
1362
+ });
1363
+ }
1364
+ };
1365
+
1366
+ const reviewPermissionDecision = (
1367
+ event: string,
1368
+ details: {
1369
+ requestId: string;
1370
+ source: PermissionRequestSource;
1371
+ agentName: string | null;
1372
+ message: string;
1373
+ toolCallId?: string;
1374
+ toolName?: string;
1375
+ skillName?: string;
1376
+ path?: string;
1377
+ command?: string;
1378
+ target?: string;
1379
+ toolInputPreview?: string;
1380
+ resolution?: string;
1381
+ denialReason?: string;
1382
+ },
1383
+ ): void => {
1384
+ writeReviewLog(event, {
1385
+ requestId: details.requestId,
1386
+ source: details.source,
1387
+ agentName: details.agentName,
1388
+ message: details.message,
1389
+ toolCallId: details.toolCallId ?? null,
1390
+ toolName: details.toolName ?? null,
1391
+ skillName: details.skillName ?? null,
1392
+ path: details.path ?? null,
1393
+ command: details.command ?? null,
1394
+ target: details.target ?? null,
1395
+ toolInputPreview: details.toolInputPreview ?? null,
1396
+ resolution: details.resolution ?? null,
1397
+ denialReason: details.denialReason ?? null,
1398
+ });
1399
+ };
1400
+
1401
+ const promptPermission = async (
1402
+ ctx: ExtensionContext,
1403
+ details: {
1404
+ requestId: string;
1405
+ source: PermissionRequestSource;
1406
+ agentName: string | null;
1407
+ message: string;
1408
+ toolCallId?: string;
1409
+ toolName?: string;
1410
+ skillName?: string;
1411
+ path?: string;
1412
+ command?: string;
1413
+ target?: string;
1414
+ toolInputPreview?: string;
1415
+ },
1416
+ ): Promise<PermissionPromptDecision> => {
1417
+ if (shouldAutoApprovePermissionState("ask", extensionConfig)) {
1418
+ reviewPermissionDecision("permission_request.auto_approved", details);
1419
+ emitPermissionRequestEvent({
1420
+ requestId: details.requestId,
1421
+ source: details.source,
1422
+ state: "approved",
1423
+ message: details.message,
1424
+ toolCallId: details.toolCallId,
1425
+ toolName: details.toolName,
1426
+ skillName: details.skillName,
1427
+ path: details.path,
1428
+ command: details.command,
1429
+ target: details.target,
1430
+ toolInputPreview: details.toolInputPreview,
1431
+ agentName: details.agentName,
1432
+ });
1433
+ return { approved: true, state: "approved" };
1434
+ }
1435
+
1436
+ reviewPermissionDecision("permission_request.waiting", details);
1437
+ emitPermissionRequestEvent({
1438
+ requestId: details.requestId,
1439
+ source: details.source,
1440
+ state: "waiting",
1441
+ message: details.message,
1442
+ toolCallId: details.toolCallId,
1443
+ toolName: details.toolName,
1444
+ skillName: details.skillName,
1445
+ path: details.path,
1446
+ command: details.command,
1447
+ target: details.target,
1448
+ toolInputPreview: details.toolInputPreview,
1449
+ agentName: details.agentName,
1450
+ });
1451
+
1452
+ const decision = await confirmPermission(ctx, details.message);
1453
+ reviewPermissionDecision(
1454
+ decision.approved
1455
+ ? "permission_request.approved"
1456
+ : "permission_request.denied",
1457
+ {
1458
+ ...details,
1459
+ resolution: decision.state,
1460
+ denialReason: decision.denialReason,
1461
+ },
1462
+ );
1463
+ emitPermissionRequestEvent({
1464
+ requestId: details.requestId,
1465
+ source: details.source,
1466
+ state: decision.approved ? "approved" : "denied",
1467
+ message: details.message,
1468
+ toolCallId: details.toolCallId,
1469
+ toolName: details.toolName,
1470
+ skillName: details.skillName,
1471
+ path: details.path,
1472
+ command: details.command,
1473
+ target: details.target,
1474
+ toolInputPreview: details.toolInputPreview,
1475
+ agentName: details.agentName,
1476
+ });
1477
+
1478
+ return decision;
1479
+ };
1480
+
1481
+ const stopForwardedPermissionPolling = (): void => {
1482
+ if (permissionForwardingTimer) {
1483
+ clearInterval(permissionForwardingTimer);
1484
+ permissionForwardingTimer = null;
1485
+ }
1486
+
1487
+ permissionForwardingContext = null;
1488
+ isProcessingForwardedRequests = false;
1489
+ };
1490
+
1491
+ const startForwardedPermissionPolling = (ctx: ExtensionContext): void => {
1492
+ if (!ctx.hasUI || isSubagentExecutionContext(ctx)) {
1493
+ stopForwardedPermissionPolling();
1494
+ return;
1495
+ }
1496
+
1497
+ permissionForwardingContext = ctx;
1498
+ if (permissionForwardingTimer) {
1499
+ return;
1500
+ }
1501
+
1502
+ permissionForwardingTimer = setInterval(() => {
1503
+ if (!permissionForwardingContext || isProcessingForwardedRequests) {
1504
+ return;
1505
+ }
1506
+
1507
+ isProcessingForwardedRequests = true;
1508
+ void processForwardedPermissionRequests(
1509
+ permissionForwardingContext,
1510
+ ).finally(() => {
1511
+ isProcessingForwardedRequests = false;
1512
+ });
1513
+ }, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
1514
+ };
1515
+
1516
+ const resolveAgentName = (
1517
+ ctx: ExtensionContext,
1518
+ systemPrompt?: string,
1519
+ ): string | null => {
1520
+ const fromSession = getActiveAgentName(ctx);
1521
+ if (fromSession) {
1522
+ lastKnownActiveAgentName = fromSession;
1523
+ return fromSession;
1524
+ }
1525
+
1526
+ const fromSystemPrompt = getActiveAgentNameFromSystemPrompt(systemPrompt);
1527
+ if (fromSystemPrompt) {
1528
+ lastKnownActiveAgentName = fromSystemPrompt;
1529
+ return fromSystemPrompt;
1530
+ }
1531
+
1532
+ return lastKnownActiveAgentName;
1533
+ };
1534
+
1535
+ const shouldExposeTool = (
1536
+ toolName: string,
1537
+ agentName: string | null,
1538
+ ): boolean => {
1539
+ // Use tool-level permission check for tool injection decisions
1540
+ // This ensures that agent-specific tool deny rules (e.g., bash: deny) are respected
1541
+ // before any command-level permissions are considered
1542
+ const toolPermission = permissionManager.getToolPermission(
1543
+ toolName,
1544
+ agentName ?? undefined,
1545
+ );
1546
+ return toolPermission !== "deny";
1547
+ };
1548
+
1549
+ const logResolvedConfigPaths = (): void => {
1550
+ const policyPaths = permissionManager.getResolvedPolicyPaths();
1551
+ const entry = buildResolvedConfigLogEntry(
1552
+ CONFIG_PATH,
1553
+ existsSync(CONFIG_PATH),
1554
+ policyPaths,
1555
+ );
1556
+ writeReviewLog(
1557
+ "config.resolved",
1558
+ entry as unknown as Record<string, unknown>,
1559
+ );
1560
+ writeDebugLog(
1561
+ "config.resolved",
1562
+ entry as unknown as Record<string, unknown>,
1563
+ );
1564
+ };
1565
+
1566
+ pi.on("session_start", async (event, ctx) => {
1567
+ runtimeContext = ctx;
1568
+ refreshExtensionConfig(ctx);
1569
+ permissionManager = createPermissionManagerForCwd(ctx.cwd);
1570
+ invalidateAgentStartCache();
1571
+ lastKnownActiveAgentName = getActiveAgentName(ctx);
1572
+ startForwardedPermissionPolling(ctx);
1573
+ logResolvedConfigPaths();
1574
+
1575
+ if (event.reason === "reload") {
1576
+ writeDebugLog("lifecycle.reload", {
1577
+ triggeredBy: "session_start",
1578
+ reason: event.reason,
1579
+ cwd: ctx.cwd,
1580
+ });
1581
+ }
1582
+ });
1583
+
1584
+ pi.on("resources_discover", async (event, _ctx) => {
1585
+ if (event.reason === "reload") {
1586
+ permissionManager = runtimeContext
1587
+ ? createPermissionManagerForCwd(runtimeContext.cwd)
1588
+ : new PermissionManager();
1589
+ invalidateAgentStartCache();
1590
+ writeDebugLog("lifecycle.reload", {
1591
+ triggeredBy: "resources_discover",
1592
+ reason: event.reason,
1593
+ cwd: runtimeContext?.cwd ?? null,
1594
+ });
1595
+ }
1596
+ });
1597
+
1598
+ pi.on("session_shutdown", async () => {
1599
+ runtimeContext?.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
1600
+ runtimeContext = null;
1601
+ invalidateAgentStartCache();
1602
+ stopForwardedPermissionPolling();
1603
+ });
1604
+
1605
+ pi.on("before_agent_start", async (event, ctx) => {
1606
+ runtimeContext = ctx;
1607
+ refreshExtensionConfig(ctx);
1608
+ startForwardedPermissionPolling(ctx);
1609
+ const agentName = resolveAgentName(ctx, event.systemPrompt);
1610
+ const allTools = pi.getAllTools();
1611
+ const allowedTools: string[] = [];
1612
+
1613
+ for (const tool of allTools) {
1614
+ const toolName = getEventToolName(tool);
1615
+ if (!toolName) {
1616
+ continue;
1617
+ }
1618
+
1619
+ if (shouldExposeTool(toolName, agentName)) {
1620
+ allowedTools.push(toolName);
1621
+ }
1622
+ }
1623
+
1624
+ const activeToolsCacheKey = createActiveToolsCacheKey(allowedTools);
1625
+ if (
1626
+ shouldApplyCachedAgentStartState(
1627
+ lastActiveToolsCacheKey,
1628
+ activeToolsCacheKey,
1629
+ )
1630
+ ) {
1631
+ pi.setActiveTools(allowedTools);
1632
+ lastActiveToolsCacheKey = activeToolsCacheKey;
1633
+ }
1634
+
1635
+ const promptStateCacheKey = createBeforeAgentStartPromptStateKey({
1636
+ agentName,
1637
+ cwd: ctx.cwd,
1638
+ permissionStamp: permissionManager.getPolicyCacheStamp(
1639
+ agentName ?? undefined,
1640
+ ),
1641
+ systemPrompt: event.systemPrompt,
1642
+ allowedToolNames: allowedTools,
1643
+ });
1644
+
1645
+ if (
1646
+ !shouldApplyCachedAgentStartState(
1647
+ lastPromptStateCacheKey,
1648
+ promptStateCacheKey,
1649
+ )
1650
+ ) {
1651
+ return {};
1652
+ }
1653
+
1654
+ lastPromptStateCacheKey = promptStateCacheKey;
1655
+ const toolPromptResult = sanitizeAvailableToolsSection(
1656
+ event.systemPrompt,
1657
+ allowedTools,
1658
+ );
1659
+ const skillPromptResult = resolveSkillPromptEntries(
1660
+ toolPromptResult.prompt,
1661
+ permissionManager,
1662
+ agentName,
1663
+ ctx.cwd,
1664
+ );
1665
+ activeSkillEntries = skillPromptResult.entries;
1666
+
1667
+ if (skillPromptResult.prompt !== event.systemPrompt) {
1668
+ return { systemPrompt: skillPromptResult.prompt };
1669
+ }
1670
+
1671
+ return {};
1672
+ });
1673
+
1674
+ pi.on("input", async (event, ctx) => {
1675
+ runtimeContext = ctx;
1676
+ startForwardedPermissionPolling(ctx);
1677
+ const skillName = extractSkillNameFromInput(event.text);
1678
+ if (!skillName) {
1679
+ return { action: "continue" };
1680
+ }
1681
+
1682
+ const agentName = resolveAgentName(ctx);
1683
+ const check = permissionManager.checkPermission(
1684
+ "skill",
1685
+ { name: skillName },
1686
+ agentName ?? undefined,
1687
+ );
1688
+
1689
+ if (check.state === "deny") {
1690
+ if (ctx.hasUI) {
1691
+ const message = agentName
1692
+ ? `Skill '${skillName}' is not permitted for agent '${agentName}'.`
1693
+ : `Skill '${skillName}' is not permitted by the current skill policy.`;
1694
+ ctx.ui.notify(message, "warning");
1695
+ }
1696
+ writeReviewLog("permission_request.blocked", {
1697
+ source: "skill_input",
1698
+ skillName,
1699
+ agentName,
1700
+ resolution: "policy_denied",
1701
+ });
1702
+ return { action: "handled" };
1703
+ }
1704
+
1705
+ if (check.state === "ask") {
1706
+ const message = formatSkillAskPrompt(skillName, agentName ?? undefined);
1707
+ if (!canRequestPermissionConfirmation(ctx)) {
1708
+ writeReviewLog("permission_request.blocked", {
1709
+ source: "skill_input",
1710
+ skillName,
1711
+ agentName,
1712
+ message,
1713
+ resolution: "confirmation_unavailable",
1714
+ });
1715
+ return { action: "handled" };
1716
+ }
1717
+
1718
+ const decision = await promptPermission(ctx, {
1719
+ requestId: createPermissionRequestId("skill-input"),
1720
+ source: "skill_input",
1721
+ agentName,
1722
+ message,
1723
+ skillName,
1724
+ });
1725
+ if (!decision.approved) {
1726
+ return { action: "handled" };
1727
+ }
1728
+ }
1729
+
1730
+ return { action: "continue" };
1731
+ });
1732
+
1733
+ pi.on("tool_call", async (event, ctx) => {
1734
+ runtimeContext = ctx;
1735
+ startForwardedPermissionPolling(ctx);
1736
+ const agentName = resolveAgentName(ctx);
1737
+ const toolName = getEventToolName(event);
1738
+
1739
+ if (!toolName) {
1740
+ return { block: true, reason: formatMissingToolNameReason() };
1741
+ }
1742
+
1743
+ const registrationCheck = checkRequestedToolRegistration(
1744
+ toolName,
1745
+ pi.getAllTools(),
1746
+ );
1747
+ if (registrationCheck.status === "missing-tool-name") {
1748
+ return { block: true, reason: formatMissingToolNameReason() };
1749
+ }
1750
+
1751
+ if (registrationCheck.status === "unregistered") {
1752
+ return {
1753
+ block: true,
1754
+ reason: formatUnknownToolReason(
1755
+ registrationCheck.requestedToolName,
1756
+ registrationCheck.availableToolNames,
1757
+ ),
1758
+ };
1759
+ }
1760
+
1761
+ if (isToolCallEventType("read", event) && activeSkillEntries.length > 0) {
1762
+ const normalizedReadPath = normalizePathForComparison(
1763
+ event.input.path,
1764
+ ctx.cwd,
1765
+ );
1766
+ const matchedSkill = findSkillPathMatch(
1767
+ normalizedReadPath,
1768
+ activeSkillEntries,
1769
+ );
1770
+
1771
+ if (matchedSkill) {
1772
+ if (matchedSkill.state === "deny") {
1773
+ writeReviewLog("permission_request.blocked", {
1774
+ source: "skill_read",
1775
+ skillName: matchedSkill.name,
1776
+ agentName,
1777
+ path: event.input.path,
1778
+ resolution: "policy_denied",
1779
+ });
1780
+ return {
1781
+ block: true,
1782
+ reason: formatSkillPathDenyReason(
1783
+ matchedSkill,
1784
+ event.input.path,
1785
+ agentName ?? undefined,
1786
+ ),
1787
+ };
1788
+ }
1789
+
1790
+ if (matchedSkill.state === "ask") {
1791
+ const message = formatSkillPathAskPrompt(
1792
+ matchedSkill,
1793
+ event.input.path,
1794
+ agentName ?? undefined,
1795
+ );
1796
+ if (!canRequestPermissionConfirmation(ctx)) {
1797
+ writeReviewLog("permission_request.blocked", {
1798
+ source: "skill_read",
1799
+ skillName: matchedSkill.name,
1800
+ agentName,
1801
+ path: event.input.path,
1802
+ message,
1803
+ resolution: "confirmation_unavailable",
1804
+ });
1805
+ return {
1806
+ block: true,
1807
+ reason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
1808
+ };
1809
+ }
1810
+
1811
+ const decision = await promptPermission(ctx, {
1812
+ requestId: event.toolCallId,
1813
+ source: "skill_read",
1814
+ agentName,
1815
+ message,
1816
+ toolCallId: event.toolCallId,
1817
+ toolName: toolName,
1818
+ skillName: matchedSkill.name,
1819
+ path: event.input.path,
1820
+ });
1821
+ if (!decision.approved) {
1822
+ const denialReason = decision.denialReason
1823
+ ? ` Reason: ${decision.denialReason}.`
1824
+ : "";
1825
+ return {
1826
+ block: true,
1827
+ reason: `User denied access to skill '${matchedSkill.name}'.${denialReason}`,
1828
+ };
1829
+ }
1830
+ }
1831
+ }
1832
+ }
1833
+
1834
+ const input = getEventInput(event);
1835
+ const externalDirectoryPath = ctx.cwd
1836
+ ? getPathBearingToolPath(toolName, input)
1837
+ : null;
1838
+
1839
+ if (
1840
+ ctx.cwd &&
1841
+ externalDirectoryPath &&
1842
+ isPathOutsideWorkingDirectory(externalDirectoryPath, ctx.cwd)
1843
+ ) {
1844
+ const extCheck = permissionManager.checkPermission(
1845
+ "external_directory",
1846
+ {},
1847
+ agentName ?? undefined,
1848
+ );
1849
+
1850
+ if (extCheck.state === "deny") {
1851
+ writeReviewLog("permission_request.blocked", {
1852
+ source: "tool_call",
1853
+ toolCallId: event.toolCallId,
1854
+ toolName,
1855
+ agentName,
1856
+ path: externalDirectoryPath,
1857
+ resolution: "policy_denied",
1858
+ });
1859
+ return {
1860
+ block: true,
1861
+ reason: formatExternalDirectoryDenyReason(
1862
+ toolName,
1863
+ externalDirectoryPath,
1864
+ ctx.cwd,
1865
+ agentName ?? undefined,
1866
+ ),
1867
+ };
1868
+ }
1869
+
1870
+ if (extCheck.state === "ask") {
1871
+ const message = formatExternalDirectoryAskPrompt(
1872
+ toolName,
1873
+ externalDirectoryPath,
1874
+ ctx.cwd,
1875
+ agentName ?? undefined,
1876
+ );
1877
+ if (!canRequestPermissionConfirmation(ctx)) {
1878
+ writeReviewLog("permission_request.blocked", {
1879
+ source: "tool_call",
1880
+ toolCallId: event.toolCallId,
1881
+ toolName,
1882
+ agentName,
1883
+ path: externalDirectoryPath,
1884
+ message,
1885
+ resolution: "confirmation_unavailable",
1886
+ });
1887
+ return {
1888
+ block: true,
1889
+ reason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
1890
+ };
1891
+ }
1892
+
1893
+ const extDecision = await promptPermission(ctx, {
1894
+ requestId: event.toolCallId,
1895
+ source: "tool_call",
1896
+ agentName,
1897
+ message,
1898
+ toolCallId: event.toolCallId,
1899
+ toolName,
1900
+ path: externalDirectoryPath,
1901
+ });
1902
+
1903
+ if (!extDecision.approved) {
1904
+ return {
1905
+ block: true,
1906
+ reason: formatExternalDirectoryUserDeniedReason(
1907
+ toolName,
1908
+ externalDirectoryPath,
1909
+ extDecision.denialReason,
1910
+ ),
1911
+ };
1912
+ }
1913
+ }
1914
+ // state === "allow" → fall through to normal permission check
1915
+ }
1916
+
1917
+ const check = permissionManager.checkPermission(
1918
+ toolName,
1919
+ input,
1920
+ agentName ?? undefined,
1921
+ );
1922
+ const permissionLogContext = getPermissionLogContext(check, input);
1923
+
1924
+ if (check.state === "deny") {
1925
+ writeReviewLog("permission_request.blocked", {
1926
+ source: "tool_call",
1927
+ toolCallId: event.toolCallId,
1928
+ toolName,
1929
+ agentName,
1930
+ ...permissionLogContext,
1931
+ resolution: "policy_denied",
1932
+ });
1933
+ return {
1934
+ block: true,
1935
+ reason: formatDenyReason(check, agentName ?? undefined),
1936
+ };
1937
+ }
1938
+
1939
+ if (check.state === "ask") {
1940
+ const unavailableReason =
1941
+ toolName === "bash" && isToolCallEventType("bash", event)
1942
+ ? `Running bash command '${event.input.command}' requires approval, but no interactive UI is available.`
1943
+ : toolName === "mcp"
1944
+ ? "Using tool 'mcp' requires approval, but no interactive UI is available."
1945
+ : `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
1946
+
1947
+ const message = formatAskPrompt(check, agentName ?? undefined, input);
1948
+ if (!canRequestPermissionConfirmation(ctx)) {
1949
+ writeReviewLog("permission_request.blocked", {
1950
+ source: "tool_call",
1951
+ toolCallId: event.toolCallId,
1952
+ toolName,
1953
+ agentName,
1954
+ message,
1955
+ ...permissionLogContext,
1956
+ resolution: "confirmation_unavailable",
1957
+ });
1958
+ return {
1959
+ block: true,
1960
+ reason: unavailableReason,
1961
+ };
1962
+ }
1963
+
1964
+ const decision = await promptPermission(ctx, {
1965
+ requestId: event.toolCallId,
1966
+ source: "tool_call",
1967
+ agentName,
1968
+ message,
1969
+ toolCallId: event.toolCallId,
1970
+ toolName,
1971
+ ...permissionLogContext,
1972
+ });
1973
+ if (!decision.approved) {
1974
+ return {
1975
+ block: true,
1976
+ reason: formatUserDeniedReason(check, decision.denialReason),
1977
+ };
1978
+ }
1979
+ }
1980
+
1981
+ return {};
1982
+ });
1983
+ }