@gotgenes/pi-permission-system 3.6.0 → 3.8.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 CHANGED
@@ -1,1066 +1,99 @@
1
- import {
2
- existsSync,
3
- mkdirSync,
4
- renameSync,
5
- unlinkSync,
6
- writeFileSync,
7
- } from "node:fs";
8
- import { dirname, join, normalize } from "node:path";
9
- import {
10
- type ExtensionAPI,
11
- type ExtensionCommandContext,
12
- type ExtensionContext,
13
- getAgentDir,
14
- isToolCallEventType,
15
- } from "@mariozechner/pi-coding-agent";
16
- import {
17
- getActiveAgentName,
18
- getActiveAgentNameFromSystemPrompt,
19
- } from "./active-agent";
20
- import {
21
- createActiveToolsCacheKey,
22
- createBeforeAgentStartPromptStateKey,
23
- shouldApplyCachedAgentStartState,
24
- } from "./before-agent-start-cache";
25
- import { getNonEmptyString, toRecord } from "./common";
26
- import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
27
2
  import { registerPermissionSystemCommand } from "./config-modal";
3
+ import { getGlobalConfigPath } from "./config-paths";
4
+ import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
28
5
  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";
38
- import { buildResolvedConfigLogEntry } from "./config-reporter";
39
- import {
40
- DEFAULT_EXTENSION_CONFIG,
41
- EXTENSION_ROOT,
42
- ensurePermissionSystemLogsDirectory,
43
- normalizePermissionSystemConfig,
44
- type PermissionSystemExtensionConfig,
45
- } from "./extension-config";
46
- import {
47
- extractExternalPathsFromBashCommand,
48
- formatBashExternalDirectoryAskPrompt,
49
- formatBashExternalDirectoryDenyReason,
50
- formatExternalDirectoryAskPrompt,
51
- formatExternalDirectoryDenyReason,
52
- formatExternalDirectoryHardStopHint,
53
- formatExternalDirectoryUserDeniedReason,
54
- getPathBearingToolPath,
55
- isPathOutsideWorkingDirectory,
56
- normalizePathForComparison,
57
- PATH_BEARING_TOOLS,
58
- } from "./external-directory";
59
- import { setForwardedPermissionLogger } from "./forwarded-permissions/io";
60
- import {
61
- confirmPermission,
62
- type PermissionForwardingDeps,
63
- processForwardedPermissionRequests,
64
- } from "./forwarded-permissions/polling";
65
- import { createPermissionSystemLogger } from "./logging";
6
+ type HandlerDeps,
7
+ handleBeforeAgentStart,
8
+ handleInput,
9
+ handleResourcesDiscover,
10
+ handleSessionShutdown,
11
+ handleSessionStart,
12
+ handleToolCall,
13
+ } from "./handlers";
66
14
  import {
67
15
  type PermissionPromptDecision,
68
16
  requestPermissionDecisionFromUi,
69
17
  } from "./permission-dialog";
70
- import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
71
- import { applyPermissionGate } from "./permission-gate";
72
- import { PermissionManager } from "./permission-manager";
73
- import {
74
- formatAskPrompt,
75
- formatDenyReason,
76
- formatMissingToolNameReason,
77
- formatSkillAskPrompt,
78
- formatSkillPathAskPrompt,
79
- formatSkillPathDenyReason,
80
- formatUnknownToolReason,
81
- formatUserDeniedReason,
82
- } from "./permission-prompts";
83
- import {
84
- deriveApprovalPrefix,
85
- SessionApprovalCache,
86
- } from "./session-approval-cache";
87
18
  import {
88
- findSkillPathMatch,
89
- resolveSkillPromptEntries,
90
- type SkillPromptEntry,
91
- } from "./skill-prompt-sanitizer";
92
- import {
93
- PERMISSION_SYSTEM_STATUS_KEY,
94
- syncPermissionSystemStatus,
95
- } from "./status";
19
+ createExtensionRuntime,
20
+ createPermissionManagerForCwd,
21
+ logResolvedConfigPaths,
22
+ promptPermission,
23
+ refreshExtensionConfig,
24
+ resolveAgentName,
25
+ saveExtensionConfig,
26
+ startForwardedPermissionPolling,
27
+ stopForwardedPermissionPolling,
28
+ } from "./runtime";
96
29
  import { isSubagentExecutionContext } from "./subagent-context";
97
- import { sanitizeAvailableToolsSection } from "./system-prompt-sanitizer";
98
- import { getPermissionLogContext } from "./tool-input-preview";
99
- import {
100
- checkRequestedToolRegistration,
101
- getToolNameFromValue,
102
- } from "./tool-registry";
103
30
  import {
104
31
  canResolveAskPermissionRequest,
105
32
  shouldAutoApprovePermissionState,
106
33
  } from "./yolo-mode";
107
34
 
108
- const PI_AGENT_DIR = getAgentDir();
109
- const SESSIONS_DIR = join(PI_AGENT_DIR, "sessions");
110
- const SUBAGENT_SESSIONS_DIR = join(PI_AGENT_DIR, "subagent-sessions");
111
- const PERMISSION_FORWARDING_DIR = join(SESSIONS_DIR, "permission-forwarding");
112
-
113
- type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
114
-
115
- let extensionConfig: PermissionSystemExtensionConfig = {
116
- ...DEFAULT_EXTENSION_CONFIG,
117
- };
118
- const GLOBAL_LOGS_DIR = getGlobalLogsDir(PI_AGENT_DIR);
119
- const extensionLogger = createPermissionSystemLogger({
120
- getConfig: () => extensionConfig,
121
- debugLogPath: join(GLOBAL_LOGS_DIR, DEBUG_LOG_FILENAME),
122
- reviewLogPath: join(GLOBAL_LOGS_DIR, REVIEW_LOG_FILENAME),
123
- ensureLogsDirectory: () =>
124
- ensurePermissionSystemLogsDirectory(GLOBAL_LOGS_DIR),
125
- });
126
- const reportedLoggingWarnings = new Set<string>();
127
- let loggingWarningReporter: ((message: string) => void) | null = null;
128
-
129
- function setExtensionConfig(config: PermissionSystemExtensionConfig): void {
130
- extensionConfig = normalizePermissionSystemConfig(config);
131
- }
132
-
133
- function setLoggingWarningReporter(
134
- reporter: ((message: string) => void) | null,
135
- ): void {
136
- loggingWarningReporter = reporter;
137
- }
138
-
139
- function reportLoggingWarning(message: string): void {
140
- if (!loggingWarningReporter || reportedLoggingWarnings.has(message)) {
141
- return;
142
- }
143
-
144
- reportedLoggingWarnings.add(message);
145
- loggingWarningReporter(message);
146
- }
147
-
148
- function writeDebugLog(
149
- event: string,
150
- details: Record<string, unknown> = {},
151
- ): void {
152
- const warning = extensionLogger.debug(event, details);
153
- if (warning) {
154
- reportLoggingWarning(warning);
155
- }
156
- }
157
-
158
- function writeReviewLog(
159
- event: string,
160
- details: Record<string, unknown> = {},
161
- ): void {
162
- const warning = extensionLogger.review(event, details);
163
- if (warning) {
164
- reportLoggingWarning(warning);
165
- }
166
- }
167
-
168
- function extractSkillNameFromInput(text: string): string | null {
169
- const trimmed = text.trim();
170
- if (!trimmed.startsWith("/skill:")) {
171
- return null;
172
- }
173
-
174
- const afterPrefix = trimmed.slice("/skill:".length);
175
- if (!afterPrefix) {
176
- return null;
177
- }
178
-
179
- const firstWhitespace = afterPrefix.search(/\s/);
180
- const skillName = (
181
- firstWhitespace === -1 ? afterPrefix : afterPrefix.slice(0, firstWhitespace)
182
- ).trim();
183
- return skillName || null;
184
- }
185
-
186
- function getEventToolName(event: unknown): string | null {
187
- return getToolNameFromValue(event);
188
- }
189
-
190
- function getEventInput(event: unknown): unknown {
191
- const record = toRecord(event);
192
-
193
- if (record.input !== undefined) {
194
- return record.input;
195
- }
196
-
197
- if (record.arguments !== undefined) {
198
- return record.arguments;
199
- }
200
-
201
- return {};
202
- }
203
-
204
- function canRequestPermissionConfirmation(ctx: ExtensionContext): boolean {
205
- return canResolveAskPermissionRequest({
206
- config: extensionConfig,
207
- hasUI: ctx.hasUI,
208
- isSubagent: isSubagentExecutionContext(ctx, SUBAGENT_SESSIONS_DIR),
209
- });
210
- }
211
-
212
- function derivePiProjectPaths(cwd: string | undefined | null): {
213
- projectGlobalConfigPath: string;
214
- projectAgentsDir: string;
215
- } | null {
216
- if (!cwd) {
217
- return null;
218
- }
219
-
220
- return {
221
- projectGlobalConfigPath: getProjectConfigPath(cwd),
222
- projectAgentsDir: join(cwd, ".pi", "agent", "agents"),
223
- };
224
- }
225
-
226
- function createPermissionManagerForCwd(
227
- cwd: string | undefined | null,
228
- ): PermissionManager {
229
- const agentDir = getAgentDir();
230
- const projectPaths = derivePiProjectPaths(cwd);
231
-
232
- return new PermissionManager({
233
- globalConfigPath: getGlobalConfigPath(agentDir),
234
- projectGlobalConfigPath: projectPaths?.projectGlobalConfigPath,
235
- projectAgentsDir: projectPaths?.projectAgentsDir,
236
- });
237
- }
238
-
239
35
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
240
- let permissionManager = new PermissionManager();
241
- const sessionApprovalCache = new SessionApprovalCache();
242
- let activeSkillEntries: SkillPromptEntry[] = [];
243
- let lastKnownActiveAgentName: string | null = null;
244
- let lastActiveToolsCacheKey: string | null = null;
245
- let lastPromptStateCacheKey: string | null = null;
246
- let permissionForwardingContext: ExtensionContext | null = null;
247
- let permissionForwardingTimer: NodeJS.Timeout | null = null;
248
- let isProcessingForwardedRequests = false;
249
- let runtimeContext: ExtensionContext | null = null;
250
- let lastConfigWarning: string | null = null;
251
-
252
- const invalidateAgentStartCache = (): void => {
253
- activeSkillEntries = [];
254
- lastActiveToolsCacheKey = null;
255
- lastPromptStateCacheKey = null;
256
- };
257
-
258
- const notifyWarning = (message: string): void => {
259
- if (!runtimeContext?.hasUI) {
260
- return;
261
- }
262
-
263
- runtimeContext.ui.notify(message, "warning");
264
- };
265
-
266
- const refreshExtensionConfig = (ctx?: ExtensionContext): void => {
267
- if (ctx) {
268
- runtimeContext = ctx;
269
- }
270
-
271
- const cwd = runtimeContext?.cwd ?? null;
272
- const agentDir = getAgentDir();
273
- const mergeResult = loadAndMergeConfigs(
274
- agentDir,
275
- cwd ?? "",
276
- EXTENSION_ROOT,
277
- );
278
- const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
279
- setExtensionConfig(runtimeConfig);
280
-
281
- if (runtimeContext?.hasUI) {
282
- syncPermissionSystemStatus(runtimeContext, runtimeConfig);
283
- }
284
-
285
- const warning =
286
- mergeResult.issues.length > 0 ? mergeResult.issues.join("\n") : undefined;
287
- if (warning && warning !== lastConfigWarning) {
288
- lastConfigWarning = warning;
289
- notifyWarning(warning);
290
- } else if (!warning) {
291
- lastConfigWarning = null;
292
- }
293
-
294
- writeDebugLog("config.loaded", {
295
- warning: warning ?? null,
296
- debugLog: runtimeConfig.debugLog,
297
- permissionReviewLog: runtimeConfig.permissionReviewLog,
298
- yoloMode: runtimeConfig.yoloMode,
299
- });
300
- };
301
-
302
- const saveExtensionConfig = (
303
- next: PermissionSystemExtensionConfig,
304
- ctx: ExtensionCommandContext,
305
- ): void => {
306
- const normalized = normalizePermissionSystemConfig(next);
307
- const globalPath = getGlobalConfigPath(getAgentDir());
308
-
309
- // Load existing global config and merge runtime knobs into it
310
- const existing = loadUnifiedConfig(globalPath);
311
- const merged = {
312
- ...existing.config,
313
- debugLog: normalized.debugLog,
314
- permissionReviewLog: normalized.permissionReviewLog,
315
- yoloMode: normalized.yoloMode,
316
- };
317
-
318
- const tmpPath = `${globalPath}.tmp`;
319
- try {
320
- mkdirSync(dirname(globalPath), { recursive: true });
321
- writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
322
- renameSync(tmpPath, globalPath);
323
- } catch (error) {
324
- try {
325
- if (existsSync(tmpPath)) {
326
- unlinkSync(tmpPath);
327
- }
328
- } catch {
329
- // Ignore cleanup failures.
330
- }
331
- const message = error instanceof Error ? error.message : String(error);
332
- ctx.ui.notify(
333
- `Failed to save permission-system config at '${globalPath}': ${message}`,
334
- "error",
335
- );
336
- return;
337
- }
338
-
339
- setExtensionConfig(normalized);
340
- syncPermissionSystemStatus(ctx, normalized);
341
- lastConfigWarning = null;
342
-
343
- writeDebugLog("config.saved", {
344
- debugLog: normalized.debugLog,
345
- permissionReviewLog: normalized.permissionReviewLog,
346
- yoloMode: normalized.yoloMode,
347
- });
348
- };
349
-
350
- setLoggingWarningReporter(notifyWarning);
351
- setForwardedPermissionLogger({ writeReviewLog, writeDebugLog });
36
+ const runtime = createExtensionRuntime();
352
37
 
353
38
  const forwardingDeps: PermissionForwardingDeps = {
354
- forwardingDir: PERMISSION_FORWARDING_DIR,
355
- subagentSessionsDir: SUBAGENT_SESSIONS_DIR,
356
- writeReviewLog,
39
+ forwardingDir: runtime.forwardingDir,
40
+ subagentSessionsDir: runtime.subagentSessionsDir,
41
+ logger: {
42
+ writeReviewLog: runtime.writeReviewLog.bind(runtime),
43
+ writeDebugLog: runtime.writeDebugLog.bind(runtime),
44
+ },
45
+ writeReviewLog: runtime.writeReviewLog.bind(runtime),
357
46
  requestPermissionDecisionFromUi,
358
47
  shouldAutoApprove: () =>
359
- shouldAutoApprovePermissionState("ask", extensionConfig),
48
+ shouldAutoApprovePermissionState("ask", runtime.config),
360
49
  };
361
50
 
362
- refreshExtensionConfig();
51
+ refreshExtensionConfig(runtime);
363
52
  registerPermissionSystemCommand(pi, {
364
- getConfig: () => extensionConfig,
365
- setConfig: saveExtensionConfig,
366
- getConfigPath: () => getGlobalConfigPath(getAgentDir()),
53
+ getConfig: () => runtime.config,
54
+ setConfig: (next, ctx) => saveExtensionConfig(runtime, next, ctx),
55
+ getConfigPath: () => getGlobalConfigPath(runtime.agentDir),
367
56
  });
368
57
 
369
- const createPermissionRequestId = (prefix: string): string => {
370
- return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
371
- };
372
-
373
- const reviewPermissionDecision = (
374
- event: string,
375
- details: {
376
- requestId: string;
377
- source: PermissionReviewSource;
378
- agentName: string | null;
379
- message: string;
380
- toolCallId?: string;
381
- toolName?: string;
382
- skillName?: string;
383
- path?: string;
384
- command?: string;
385
- target?: string;
386
- toolInputPreview?: string;
387
- resolution?: string;
388
- denialReason?: string;
389
- },
390
- ): void => {
391
- writeReviewLog(event, {
392
- requestId: details.requestId,
393
- source: details.source,
394
- agentName: details.agentName,
395
- message: details.message,
396
- toolCallId: details.toolCallId ?? null,
397
- toolName: details.toolName ?? null,
398
- skillName: details.skillName ?? null,
399
- path: details.path ?? null,
400
- command: details.command ?? null,
401
- target: details.target ?? null,
402
- toolInputPreview: details.toolInputPreview ?? null,
403
- resolution: details.resolution ?? null,
404
- denialReason: details.denialReason ?? null,
405
- });
406
- };
407
-
408
- const promptPermission = async (
409
- ctx: ExtensionContext,
410
- details: {
411
- requestId: string;
412
- source: PermissionReviewSource;
413
- agentName: string | null;
414
- message: string;
415
- toolCallId?: string;
416
- toolName?: string;
417
- skillName?: string;
418
- path?: string;
419
- command?: string;
420
- target?: string;
421
- toolInputPreview?: string;
422
- },
423
- ): Promise<PermissionPromptDecision> => {
424
- if (shouldAutoApprovePermissionState("ask", extensionConfig)) {
425
- reviewPermissionDecision("permission_request.auto_approved", details);
426
- return { approved: true, state: "approved" };
427
- }
428
-
429
- reviewPermissionDecision("permission_request.waiting", details);
430
-
431
- const decision = await confirmPermission(
432
- ctx,
433
- details.message,
434
- forwardingDeps,
435
- );
436
- reviewPermissionDecision(
437
- decision.approved
438
- ? "permission_request.approved"
439
- : "permission_request.denied",
440
- {
441
- ...details,
442
- resolution: decision.state,
443
- denialReason: decision.denialReason,
444
- },
445
- );
446
- return decision;
447
- };
448
-
449
- const stopForwardedPermissionPolling = (): void => {
450
- if (permissionForwardingTimer) {
451
- clearInterval(permissionForwardingTimer);
452
- permissionForwardingTimer = null;
453
- }
454
-
455
- permissionForwardingContext = null;
456
- isProcessingForwardedRequests = false;
457
- };
458
-
459
- const startForwardedPermissionPolling = (ctx: ExtensionContext): void => {
460
- if (!ctx.hasUI || isSubagentExecutionContext(ctx, SUBAGENT_SESSIONS_DIR)) {
461
- stopForwardedPermissionPolling();
462
- return;
463
- }
464
-
465
- permissionForwardingContext = ctx;
466
- if (permissionForwardingTimer) {
467
- return;
468
- }
469
-
470
- permissionForwardingTimer = setInterval(() => {
471
- if (!permissionForwardingContext || isProcessingForwardedRequests) {
472
- return;
473
- }
474
-
475
- isProcessingForwardedRequests = true;
476
- void processForwardedPermissionRequests(
477
- permissionForwardingContext,
478
- forwardingDeps,
479
- ).finally(() => {
480
- isProcessingForwardedRequests = false;
481
- });
482
- }, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
483
- };
484
-
485
- const resolveAgentName = (
486
- ctx: ExtensionContext,
487
- systemPrompt?: string,
488
- ): string | null => {
489
- const fromSession = getActiveAgentName(ctx);
490
- if (fromSession) {
491
- lastKnownActiveAgentName = fromSession;
492
- return fromSession;
493
- }
494
-
495
- const fromSystemPrompt = getActiveAgentNameFromSystemPrompt(systemPrompt);
496
- if (fromSystemPrompt) {
497
- lastKnownActiveAgentName = fromSystemPrompt;
498
- return fromSystemPrompt;
499
- }
500
-
501
- return lastKnownActiveAgentName;
502
- };
503
-
504
- const shouldExposeTool = (
505
- toolName: string,
506
- agentName: string | null,
507
- ): boolean => {
508
- // Use tool-level permission check for tool injection decisions
509
- // This ensures that agent-specific tool deny rules (e.g., bash: deny) are respected
510
- // before any command-level permissions are considered
511
- const toolPermission = permissionManager.getToolPermission(
512
- toolName,
513
- agentName ?? undefined,
514
- );
515
- return toolPermission !== "deny";
516
- };
517
-
518
- const logResolvedConfigPaths = (): void => {
519
- const policyPaths = permissionManager.getResolvedPolicyPaths();
520
- const cwd = runtimeContext?.cwd ?? null;
521
-
522
- // Detect legacy files for the log entry
523
- const agentDir = getAgentDir();
524
- const legacyGlobalPolicyDetected = existsSync(
525
- getLegacyGlobalPolicyPath(agentDir),
526
- );
527
- const legacyProjectPolicyDetected = cwd
528
- ? existsSync(getLegacyProjectPolicyPath(cwd))
529
- : false;
530
- const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
531
- const newGlobalPath = getGlobalConfigPath(agentDir);
532
- const legacyExtensionConfigDetected =
533
- normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
534
- existsSync(legacyExtConfigPath);
535
-
536
- const entry = buildResolvedConfigLogEntry({
537
- policyPaths,
538
- legacyGlobalPolicyDetected,
539
- legacyProjectPolicyDetected,
540
- legacyExtensionConfigDetected,
541
- });
542
- writeReviewLog(
543
- "config.resolved",
544
- entry as unknown as Record<string, unknown>,
545
- );
546
- writeDebugLog(
547
- "config.resolved",
548
- entry as unknown as Record<string, unknown>,
549
- );
550
- };
551
-
552
- pi.on("session_start", async (event, ctx) => {
553
- runtimeContext = ctx;
554
- refreshExtensionConfig(ctx);
555
- permissionManager = createPermissionManagerForCwd(ctx.cwd);
556
- invalidateAgentStartCache();
557
- lastKnownActiveAgentName = getActiveAgentName(ctx);
558
- startForwardedPermissionPolling(ctx);
559
- logResolvedConfigPaths();
560
-
561
- const policyIssues = permissionManager.getConfigIssues(
562
- lastKnownActiveAgentName,
563
- );
564
- for (const issue of policyIssues) {
565
- notifyWarning(issue);
566
- }
567
-
568
- if (event.reason === "reload") {
569
- writeDebugLog("lifecycle.reload", {
570
- triggeredBy: "session_start",
571
- reason: event.reason,
572
- cwd: ctx.cwd,
573
- });
574
- }
575
- });
576
-
577
- pi.on("resources_discover", async (event, _ctx) => {
578
- if (event.reason === "reload") {
579
- permissionManager = runtimeContext
580
- ? createPermissionManagerForCwd(runtimeContext.cwd)
581
- : new PermissionManager();
582
- invalidateAgentStartCache();
583
- writeDebugLog("lifecycle.reload", {
584
- triggeredBy: "resources_discover",
585
- reason: event.reason,
586
- cwd: runtimeContext?.cwd ?? null,
587
- });
588
- }
589
- });
590
-
591
- pi.on("session_shutdown", async () => {
592
- runtimeContext?.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
593
- runtimeContext = null;
594
- invalidateAgentStartCache();
595
- sessionApprovalCache.clear();
596
- stopForwardedPermissionPolling();
597
- });
598
-
599
- pi.on("before_agent_start", async (event, ctx) => {
600
- runtimeContext = ctx;
601
- refreshExtensionConfig(ctx);
602
- startForwardedPermissionPolling(ctx);
603
- const agentName = resolveAgentName(ctx, event.systemPrompt);
604
- const allTools = pi.getAllTools();
605
- const allowedTools: string[] = [];
606
-
607
- for (const tool of allTools) {
608
- const toolName = getEventToolName(tool);
609
- if (!toolName) {
610
- continue;
611
- }
612
-
613
- if (shouldExposeTool(toolName, agentName)) {
614
- allowedTools.push(toolName);
615
- }
616
- }
617
-
618
- const activeToolsCacheKey = createActiveToolsCacheKey(allowedTools);
619
- if (
620
- shouldApplyCachedAgentStartState(
621
- lastActiveToolsCacheKey,
622
- activeToolsCacheKey,
623
- )
624
- ) {
625
- pi.setActiveTools(allowedTools);
626
- lastActiveToolsCacheKey = activeToolsCacheKey;
627
- }
628
-
629
- const promptStateCacheKey = createBeforeAgentStartPromptStateKey({
630
- agentName,
631
- cwd: ctx.cwd,
632
- permissionStamp: permissionManager.getPolicyCacheStamp(
633
- agentName ?? undefined,
634
- ),
635
- systemPrompt: event.systemPrompt,
636
- allowedToolNames: allowedTools,
637
- });
638
-
639
- if (
640
- !shouldApplyCachedAgentStartState(
641
- lastPromptStateCacheKey,
642
- promptStateCacheKey,
643
- )
644
- ) {
645
- return {};
646
- }
647
-
648
- lastPromptStateCacheKey = promptStateCacheKey;
649
- const toolPromptResult = sanitizeAvailableToolsSection(
650
- event.systemPrompt,
651
- allowedTools,
652
- );
653
- const skillPromptResult = resolveSkillPromptEntries(
654
- toolPromptResult.prompt,
655
- permissionManager,
656
- agentName,
657
- ctx.cwd,
658
- );
659
- activeSkillEntries = skillPromptResult.entries;
660
-
661
- if (skillPromptResult.prompt !== event.systemPrompt) {
662
- return { systemPrompt: skillPromptResult.prompt };
663
- }
664
-
665
- return {};
666
- });
667
-
668
- pi.on("input", async (event, ctx) => {
669
- runtimeContext = ctx;
670
- startForwardedPermissionPolling(ctx);
671
- const skillName = extractSkillNameFromInput(event.text);
672
- if (!skillName) {
673
- return { action: "continue" };
674
- }
675
-
676
- const agentName = resolveAgentName(ctx);
677
- const check = permissionManager.checkPermission(
678
- "skill",
679
- { name: skillName },
680
- agentName ?? undefined,
681
- );
682
-
683
- if (check.state === "deny" && ctx.hasUI) {
684
- const notifyMessage = agentName
685
- ? `Skill '${skillName}' is not permitted for agent '${agentName}'.`
686
- : `Skill '${skillName}' is not permitted by the current skill policy.`;
687
- ctx.ui.notify(notifyMessage, "warning");
688
- }
689
-
690
- const skillInputMessage = formatSkillAskPrompt(
691
- skillName,
692
- agentName ?? undefined,
693
- );
694
- const skillInputGate = await applyPermissionGate({
695
- state: check.state,
696
- canConfirm: canRequestPermissionConfirmation(ctx),
697
- promptForApproval: () =>
698
- promptPermission(ctx, {
699
- requestId: createPermissionRequestId("skill-input"),
700
- source: "skill_input",
701
- agentName,
702
- message: skillInputMessage,
703
- skillName,
704
- }),
705
- writeLog: writeReviewLog,
706
- logContext: {
707
- source: "skill_input",
708
- skillName,
709
- agentName,
710
- message: skillInputMessage,
711
- },
712
- messages: {
713
- denyReason: skillInputMessage,
714
- unavailableReason:
715
- "Skill requires approval, but no interactive UI is available.",
716
- userDeniedReason: () => "User denied skill.",
717
- },
718
- });
719
- if (skillInputGate.action === "block") {
720
- return { action: "handled" };
721
- }
722
-
723
- return { action: "continue" };
724
- });
725
-
726
- pi.on("tool_call", async (event, ctx) => {
727
- runtimeContext = ctx;
728
- startForwardedPermissionPolling(ctx);
729
- const agentName = resolveAgentName(ctx);
730
- const toolName = getEventToolName(event);
731
-
732
- if (!toolName) {
733
- return { block: true, reason: formatMissingToolNameReason() };
734
- }
735
-
736
- const registrationCheck = checkRequestedToolRegistration(
737
- toolName,
738
- pi.getAllTools(),
739
- );
740
- if (registrationCheck.status === "missing-tool-name") {
741
- return { block: true, reason: formatMissingToolNameReason() };
742
- }
743
-
744
- if (registrationCheck.status === "unregistered") {
745
- return {
746
- block: true,
747
- reason: formatUnknownToolReason(
748
- registrationCheck.requestedToolName,
749
- registrationCheck.availableToolNames,
58
+ const createPermissionRequestId = (prefix: string): string =>
59
+ `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
60
+
61
+ const deps: HandlerDeps = {
62
+ runtime,
63
+ createPermissionManagerForCwd: (cwd) =>
64
+ createPermissionManagerForCwd(runtime.agentDir, cwd),
65
+ refreshExtensionConfig: (ctx) => refreshExtensionConfig(runtime, ctx),
66
+ notifyWarning: (message) =>
67
+ runtime.runtimeContext?.ui.notify(message, "warning"),
68
+ logResolvedConfigPaths: () => logResolvedConfigPaths(runtime),
69
+ resolveAgentName: (ctx, systemPrompt) =>
70
+ resolveAgentName(runtime, ctx, systemPrompt),
71
+ canRequestPermissionConfirmation: (ctx) =>
72
+ canResolveAskPermissionRequest({
73
+ config: runtime.config,
74
+ hasUI: ctx.hasUI,
75
+ isSubagent: isSubagentExecutionContext(
76
+ ctx,
77
+ runtime.subagentSessionsDir,
750
78
  ),
751
- };
752
- }
753
-
754
- if (isToolCallEventType("read", event) && activeSkillEntries.length > 0) {
755
- const normalizedReadPath = normalizePathForComparison(
756
- event.input.path,
757
- ctx.cwd,
758
- );
759
- const matchedSkill = findSkillPathMatch(
760
- normalizedReadPath,
761
- activeSkillEntries,
762
- );
763
-
764
- if (matchedSkill) {
765
- const skillReadMessage = formatSkillPathAskPrompt(
766
- matchedSkill,
767
- event.input.path,
768
- agentName ?? undefined,
769
- );
770
- const skillReadGate = await applyPermissionGate({
771
- state: matchedSkill.state,
772
- canConfirm: canRequestPermissionConfirmation(ctx),
773
- promptForApproval: () =>
774
- promptPermission(ctx, {
775
- requestId: event.toolCallId,
776
- source: "skill_read",
777
- agentName,
778
- message: skillReadMessage,
779
- toolCallId: event.toolCallId,
780
- toolName: toolName,
781
- skillName: matchedSkill.name,
782
- path: event.input.path,
783
- }),
784
- writeLog: writeReviewLog,
785
- logContext: {
786
- source: "skill_read",
787
- skillName: matchedSkill.name,
788
- agentName,
789
- path: event.input.path,
790
- message: skillReadMessage,
791
- },
792
- messages: {
793
- denyReason: formatSkillPathDenyReason(
794
- matchedSkill,
795
- event.input.path,
796
- agentName ?? undefined,
797
- ),
798
- unavailableReason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
799
- userDeniedReason: (decision) => {
800
- const denialReason = decision.denialReason
801
- ? ` Reason: ${decision.denialReason}.`
802
- : "";
803
- return `User denied access to skill '${matchedSkill.name}'.${denialReason}`;
804
- },
805
- },
806
- });
807
- if (skillReadGate.action === "block") {
808
- return { block: true, reason: skillReadGate.reason };
809
- }
810
- }
811
- }
812
-
813
- const input = getEventInput(event);
814
- const externalDirectoryPath = ctx.cwd
815
- ? getPathBearingToolPath(toolName, input)
816
- : null;
817
-
818
- if (
819
- ctx.cwd &&
820
- externalDirectoryPath &&
821
- isPathOutsideWorkingDirectory(externalDirectoryPath, ctx.cwd)
822
- ) {
823
- const normalizedExtPath = normalizePathForComparison(
824
- externalDirectoryPath,
825
- ctx.cwd,
826
- );
827
- const sessionPrefix = sessionApprovalCache.findMatchingPrefix(
828
- "external_directory",
829
- normalizedExtPath,
830
- );
831
-
832
- if (sessionPrefix) {
833
- writeReviewLog("permission_request.session_approved", {
834
- source: "tool_call",
835
- toolCallId: event.toolCallId,
836
- toolName,
837
- agentName,
838
- path: externalDirectoryPath,
839
- resolution: "session_approved",
840
- sessionApprovalPrefix: sessionPrefix,
841
- });
842
- // Fall through to normal permission check
843
- } else {
844
- const extCheck = permissionManager.checkPermission(
845
- "external_directory",
846
- {},
847
- agentName ?? undefined,
848
- );
849
-
850
- let extDirDecision: PermissionPromptDecision | null = null;
851
- const extDirMessage = formatExternalDirectoryAskPrompt(
852
- toolName,
853
- externalDirectoryPath,
854
- ctx.cwd,
855
- agentName ?? undefined,
856
- );
857
- const extDirGate = await applyPermissionGate({
858
- state: extCheck.state,
859
- canConfirm: canRequestPermissionConfirmation(ctx),
860
- promptForApproval: async () => {
861
- const decision = await promptPermission(ctx, {
862
- requestId: event.toolCallId,
863
- source: "tool_call",
864
- agentName,
865
- message: extDirMessage,
866
- toolCallId: event.toolCallId,
867
- toolName,
868
- path: externalDirectoryPath,
869
- });
870
- extDirDecision = decision;
871
- return decision;
872
- },
873
- writeLog: writeReviewLog,
874
- logContext: {
875
- source: "tool_call",
876
- toolCallId: event.toolCallId,
877
- toolName,
878
- agentName,
879
- path: externalDirectoryPath,
880
- message: extDirMessage,
881
- },
882
- messages: {
883
- denyReason: formatExternalDirectoryDenyReason(
884
- toolName,
885
- externalDirectoryPath,
886
- ctx.cwd,
887
- agentName ?? undefined,
888
- ),
889
- unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
890
- userDeniedReason: (decision) =>
891
- formatExternalDirectoryUserDeniedReason(
892
- toolName,
893
- externalDirectoryPath,
894
- decision.denialReason,
895
- ),
896
- },
897
- });
898
- if (extDirGate.action === "block") {
899
- return { block: true, reason: extDirGate.reason };
900
- }
901
-
902
- if (extDirDecision?.state === "approved_for_session") {
903
- const prefix = deriveApprovalPrefix(normalizedExtPath);
904
- sessionApprovalCache.approve("external_directory", prefix);
905
- }
906
- }
907
- // Fall through to normal permission check
908
- }
909
-
910
- // Bash external directory gate: extract paths from bash commands
911
- if (ctx.cwd && toolName === "bash") {
912
- const command = getNonEmptyString(toRecord(input).command);
913
- if (command) {
914
- const externalPaths = extractExternalPathsFromBashCommand(
915
- command,
916
- ctx.cwd,
917
- );
918
- if (externalPaths.length > 0) {
919
- // Filter out paths already covered by session approvals
920
- const uncoveredPaths = externalPaths.filter(
921
- (p) => !sessionApprovalCache.has("external_directory", p),
922
- );
923
-
924
- if (uncoveredPaths.length === 0) {
925
- // All external paths are session-approved
926
- writeReviewLog("permission_request.session_approved", {
927
- source: "tool_call",
928
- toolCallId: event.toolCallId,
929
- toolName,
930
- agentName,
931
- command,
932
- externalPaths,
933
- resolution: "session_approved",
934
- });
935
- // Fall through to normal bash permission check
936
- } else {
937
- const extCheck = permissionManager.checkPermission(
938
- "external_directory",
939
- {},
940
- agentName ?? undefined,
941
- );
942
-
943
- let bashExtDecision: PermissionPromptDecision | null = null;
944
- const bashExtMessage = formatBashExternalDirectoryAskPrompt(
945
- command,
946
- uncoveredPaths,
947
- ctx.cwd,
948
- agentName ?? undefined,
949
- );
950
- const bashExtGate = await applyPermissionGate({
951
- state: extCheck.state,
952
- canConfirm: canRequestPermissionConfirmation(ctx),
953
- promptForApproval: async () => {
954
- const decision = await promptPermission(ctx, {
955
- requestId: event.toolCallId,
956
- source: "tool_call",
957
- agentName,
958
- message: bashExtMessage,
959
- toolCallId: event.toolCallId,
960
- toolName,
961
- command,
962
- });
963
- bashExtDecision = decision;
964
- return decision;
965
- },
966
- writeLog: writeReviewLog,
967
- logContext: {
968
- source: "tool_call",
969
- toolCallId: event.toolCallId,
970
- toolName,
971
- agentName,
972
- command,
973
- externalPaths: uncoveredPaths,
974
- message: bashExtMessage,
975
- },
976
- messages: {
977
- denyReason: formatBashExternalDirectoryDenyReason(
978
- command,
979
- uncoveredPaths,
980
- ctx.cwd,
981
- agentName ?? undefined,
982
- ),
983
- unavailableReason: `Bash command '${command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`,
984
- userDeniedReason: (decision) => {
985
- const reasonSuffix = decision.denialReason
986
- ? ` Reason: ${decision.denialReason}.`
987
- : "";
988
- return `User denied external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
989
- },
990
- },
991
- });
992
- if (bashExtGate.action === "block") {
993
- return { block: true, reason: bashExtGate.reason };
994
- }
995
-
996
- if (bashExtDecision?.state === "approved_for_session") {
997
- for (const extPath of uncoveredPaths) {
998
- const prefix = deriveApprovalPrefix(extPath);
999
- sessionApprovalCache.approve("external_directory", prefix);
1000
- }
1001
- }
1002
- }
1003
- // Fall through to normal bash permission check
1004
- }
1005
- }
1006
- }
1007
-
1008
- const check = permissionManager.checkPermission(
1009
- toolName,
1010
- input,
1011
- agentName ?? undefined,
1012
- );
1013
- const permissionLogContext = getPermissionLogContext(
1014
- check,
1015
- input,
1016
- PATH_BEARING_TOOLS,
1017
- );
1018
-
1019
- const toolUnavailableReason =
1020
- toolName === "bash" && isToolCallEventType("bash", event)
1021
- ? `Running bash command '${event.input.command}' requires approval, but no interactive UI is available.`
1022
- : toolName === "mcp"
1023
- ? "Using tool 'mcp' requires approval, but no interactive UI is available."
1024
- : `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
1025
-
1026
- const toolAskMessage = formatAskPrompt(
1027
- check,
1028
- agentName ?? undefined,
1029
- input,
1030
- );
1031
- const toolGate = await applyPermissionGate({
1032
- state: check.state,
1033
- canConfirm: canRequestPermissionConfirmation(ctx),
1034
- promptForApproval: () =>
1035
- promptPermission(ctx, {
1036
- requestId: event.toolCallId,
1037
- source: "tool_call",
1038
- agentName,
1039
- message: toolAskMessage,
1040
- toolCallId: event.toolCallId,
1041
- toolName,
1042
- ...permissionLogContext,
1043
- }),
1044
- writeLog: writeReviewLog,
1045
- logContext: {
1046
- source: "tool_call",
1047
- toolCallId: event.toolCallId,
1048
- toolName,
1049
- agentName,
1050
- message: toolAskMessage,
1051
- ...permissionLogContext,
1052
- },
1053
- messages: {
1054
- denyReason: formatDenyReason(check, agentName ?? undefined),
1055
- unavailableReason: toolUnavailableReason,
1056
- userDeniedReason: (decision) =>
1057
- formatUserDeniedReason(check, decision.denialReason),
1058
- },
1059
- });
1060
- if (toolGate.action === "block") {
1061
- return { block: true, reason: toolGate.reason };
1062
- }
79
+ }),
80
+ promptPermission: (ctx, details) =>
81
+ promptPermission(runtime, forwardingDeps, ctx, details),
82
+ createPermissionRequestId,
83
+ startForwardedPermissionPolling: (ctx) =>
84
+ startForwardedPermissionPolling(runtime, forwardingDeps, ctx),
85
+ stopForwardedPermissionPolling: () =>
86
+ stopForwardedPermissionPolling(runtime),
87
+ getAllTools: () => pi.getAllTools(),
88
+ setActiveTools: (names) => pi.setActiveTools(names),
89
+ };
1063
90
 
1064
- return {};
1065
- });
91
+ pi.on("session_start", (event, ctx) => handleSessionStart(deps, event, ctx));
92
+ pi.on("resources_discover", (event) => handleResourcesDiscover(deps, event));
93
+ pi.on("session_shutdown", () => handleSessionShutdown(deps));
94
+ pi.on("before_agent_start", (event, ctx) =>
95
+ handleBeforeAgentStart(deps, event, ctx),
96
+ );
97
+ pi.on("input", (event, ctx) => handleInput(deps, event, ctx));
98
+ pi.on("tool_call", (event, ctx) => handleToolCall(deps, event, ctx));
1066
99
  }