@gotgenes/pi-permission-system 5.3.4 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+
3
+ /** Outcome of a single permission gate evaluation. */
4
+ export type GateOutcome =
5
+ | { action: "allow" }
6
+ | { action: "block"; reason: string };
7
+
8
+ /** Pre-validated context shared across all gates. */
9
+ export interface ToolCallContext {
10
+ toolName: string;
11
+ agentName: string | null;
12
+ input: unknown;
13
+ toolCallId: string;
14
+ cwd: string | undefined;
15
+ }
@@ -1,91 +1,21 @@
1
- import type {
2
- ExtensionContext,
3
- ToolCallEvent,
4
- } from "@mariozechner/pi-coding-agent";
5
- import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
6
2
 
7
- import { getNonEmptyString, toRecord } from "../common";
3
+ import { toRecord } from "../common";
8
4
  import {
9
- extractExternalPathsFromBashCommand,
10
- formatBashExternalDirectoryAskPrompt,
11
- formatBashExternalDirectoryDenyReason,
12
- formatExternalDirectoryAskPrompt,
13
- formatExternalDirectoryDenyReason,
14
- formatExternalDirectoryHardStopHint,
15
- formatExternalDirectoryUserDeniedReason,
16
- getPathBearingToolPath,
17
- isPathOutsideWorkingDirectory,
18
- isPiInfrastructureRead,
19
- normalizePathForComparison,
20
- PATH_BEARING_TOOLS,
21
- } from "../external-directory";
22
- import { suggestSessionPattern } from "../pattern-suggest";
23
- import type { PermissionPromptDecision } from "../permission-dialog";
24
- import {
25
- emitDecisionEvent,
26
- type PermissionDecisionResolution,
27
- } from "../permission-events";
28
- import { applyPermissionGate } from "../permission-gate";
29
- import {
30
- formatAskPrompt,
31
- formatDenyReason,
32
5
  formatMissingToolNameReason,
33
- formatSkillPathAskPrompt,
34
- formatSkillPathDenyReason,
35
6
  formatUnknownToolReason,
36
- formatUserDeniedReason,
37
7
  } from "../permission-prompts";
38
- import { deriveApprovalPattern } from "../session-rules";
39
- import { findSkillPathMatch } from "../skill-prompt-sanitizer";
40
- import { getPermissionLogContext } from "../tool-input-preview";
41
8
  import {
42
9
  checkRequestedToolRegistration,
43
10
  getToolNameFromValue,
44
11
  } from "../tool-registry";
45
- import type { PermissionCheckResult } from "../types";
12
+ import { evaluateBashExternalDirectoryGate } from "./gates/bash-external-directory";
13
+ import { evaluateExternalDirectoryGate } from "./gates/external-directory";
14
+ import { evaluateSkillReadGate } from "./gates/skill-read";
15
+ import { evaluateToolGate } from "./gates/tool";
16
+ import type { ToolCallContext } from "./gates/types";
46
17
  import type { HandlerDeps } from "./types";
47
18
 
48
- // ── Emission helper ────────────────────────────────────────────────────────
49
-
50
- /**
51
- * Derive the human-readable value for a decision event from a check result.
52
- * Bash → extracted command; MCP → qualified target; others → tool name.
53
- */
54
- function deriveDecisionValue(
55
- toolName: string,
56
- check: Pick<PermissionCheckResult, "command" | "target">,
57
- ): string {
58
- if (toolName === "bash") return check.command ?? toolName;
59
- if (toolName === "mcp") return check.target ?? toolName;
60
- return toolName;
61
- }
62
-
63
- /**
64
- * Map the gate outcome back to a PermissionDecisionResolution.
65
- *
66
- * @param state - The permission state passed to the gate.
67
- * @param action - The gate's resulting action ("allow" | "block").
68
- * @param hasSession - True when the gate result carries a sessionApproval
69
- * (indicates the user chose "for this session").
70
- * @param canConfirm - Whether an interactive prompt was available.
71
- */
72
- function deriveResolution(
73
- state: "allow" | "deny" | "ask",
74
- action: "allow" | "block",
75
- hasSession: boolean,
76
- canConfirm: boolean,
77
- autoApproved = false,
78
- ): PermissionDecisionResolution {
79
- if (state === "allow") return "policy_allow";
80
- if (state === "deny") return "policy_deny";
81
- // state === "ask"
82
- if (action === "allow") {
83
- if (autoApproved) return "auto_approved";
84
- return hasSession ? "user_approved_for_session" : "user_approved";
85
- }
86
- return canConfirm ? "user_denied" : "confirmation_unavailable";
87
- }
88
-
89
19
  /**
90
20
  * Extract the tool input from an event, checking both `input` and `arguments`
91
21
  * fields (different Pi SDK versions use different names).
@@ -137,462 +67,42 @@ export async function handleToolCall(
137
67
  };
138
68
  }
139
69
 
140
- // ── Skill-read gate ──────────────────────────────────────────────────────
141
- if (
142
- isToolCallEventType("read", event as ToolCallEvent) &&
143
- deps.runtime.activeSkillEntries.length > 0
144
- ) {
145
- const normalizedReadPath = normalizePathForComparison(
146
- (event as ToolCallEvent & { input: { path: string } }).input.path,
147
- ctx.cwd,
148
- );
149
- const matchedSkill = findSkillPathMatch(
150
- normalizedReadPath,
151
- deps.runtime.activeSkillEntries,
152
- );
153
-
154
- if (matchedSkill) {
155
- const readEvent = event as ToolCallEvent & { input: { path: string } };
156
- const skillReadMessage = formatSkillPathAskPrompt(
157
- matchedSkill,
158
- readEvent.input.path,
159
- agentName ?? undefined,
160
- );
161
- const skillReadCanConfirm = deps.canRequestPermissionConfirmation(ctx);
162
- const skillReadGate = await applyPermissionGate({
163
- state: matchedSkill.state,
164
- canConfirm: skillReadCanConfirm,
165
- promptForApproval: () =>
166
- deps.promptPermission(ctx, {
167
- requestId: (readEvent as { toolCallId: string }).toolCallId,
168
- source: "skill_read",
169
- agentName,
170
- message: skillReadMessage,
171
- toolCallId: (readEvent as { toolCallId: string }).toolCallId,
172
- toolName,
173
- skillName: matchedSkill.name,
174
- path: readEvent.input.path,
175
- }),
176
- writeLog: deps.runtime.writeReviewLog,
177
- logContext: {
178
- source: "skill_read",
179
- skillName: matchedSkill.name,
180
- agentName,
181
- path: readEvent.input.path,
182
- message: skillReadMessage,
183
- },
184
- messages: {
185
- denyReason: formatSkillPathDenyReason(
186
- matchedSkill,
187
- readEvent.input.path,
188
- agentName ?? undefined,
189
- ),
190
- unavailableReason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
191
- userDeniedReason: (decision) => {
192
- const denialReason = decision.denialReason
193
- ? ` Reason: ${decision.denialReason}.`
194
- : "";
195
- return `User denied access to skill '${matchedSkill.name}'.${denialReason}`;
196
- },
197
- },
198
- });
199
- emitDecisionEvent(deps.events, {
200
- surface: "skill",
201
- value: matchedSkill.name,
202
- result: skillReadGate.action === "allow" ? "allow" : "deny",
203
- resolution: deriveResolution(
204
- matchedSkill.state,
205
- skillReadGate.action,
206
- false,
207
- skillReadCanConfirm,
208
- ),
209
- origin: null,
210
- agentName: agentName ?? null,
211
- matchedPattern: null,
212
- });
213
- if (skillReadGate.action === "block") {
214
- return { block: true, reason: skillReadGate.reason };
215
- }
216
- }
217
- }
218
-
219
70
  const input = getEventInput(event);
71
+ const toolCallId =
72
+ typeof (event as Record<string, unknown>).toolCallId === "string"
73
+ ? ((event as Record<string, unknown>).toolCallId as string)
74
+ : "";
220
75
 
221
- // ── External-directory gate (file tools) ─────────────────────────────────
222
- const externalDirectoryPath = ctx.cwd
223
- ? getPathBearingToolPath(toolName, input)
224
- : null;
225
-
226
- if (
227
- ctx.cwd &&
228
- externalDirectoryPath &&
229
- isPathOutsideWorkingDirectory(externalDirectoryPath, ctx.cwd)
230
- ) {
231
- const normalizedExtPath = normalizePathForComparison(
232
- externalDirectoryPath,
233
- ctx.cwd,
234
- );
235
-
236
- // ── Pi infrastructure read bypass ──────────────────────────────────
237
- // Auto-allow read-only tools targeting Pi infrastructure directories
238
- // (agent dir, global node_modules, project-local .pi/npm|git, and
239
- // any user-configured extras). Writes are never bypassed.
240
- const allInfraDirs = [
241
- ...deps.runtime.piInfrastructureDirs,
242
- ...(deps.runtime.config.piInfrastructureReadPaths ?? []),
243
- ];
244
- if (
245
- isPiInfrastructureRead(toolName, normalizedExtPath, allInfraDirs, ctx.cwd)
246
- ) {
247
- deps.runtime.writeReviewLog(
248
- "permission_request.infrastructure_auto_allowed",
249
- {
250
- source: "tool_call",
251
- toolCallId: (event as { toolCallId: string }).toolCallId,
252
- toolName,
253
- agentName,
254
- path: externalDirectoryPath,
255
- },
256
- );
257
- emitDecisionEvent(deps.events, {
258
- surface: toolName,
259
- value: externalDirectoryPath,
260
- result: "allow",
261
- resolution: "infrastructure_auto_allowed",
262
- origin: null,
263
- agentName: agentName ?? null,
264
- matchedPattern: null,
265
- });
266
- // Fall through to normal tool-permission check.
267
- } else {
268
- const extCheck = deps.runtime.permissionManager.checkPermission(
269
- "external_directory",
270
- { path: normalizedExtPath },
271
- agentName ?? undefined,
272
- deps.runtime.sessionRules.getRuleset(),
273
- );
274
-
275
- if (extCheck.source === "session") {
276
- deps.runtime.writeReviewLog("permission_request.session_approved", {
277
- source: "tool_call",
278
- toolCallId: (event as { toolCallId: string }).toolCallId,
279
- toolName,
280
- agentName,
281
- path: externalDirectoryPath,
282
- resolution: "session_approved",
283
- sessionApprovalPattern: extCheck.matchedPattern,
284
- });
285
- emitDecisionEvent(deps.events, {
286
- surface: "external_directory",
287
- value: externalDirectoryPath,
288
- result: "allow",
289
- resolution: "session_approved",
290
- origin: extCheck.origin ?? null,
291
- agentName: agentName ?? null,
292
- matchedPattern: extCheck.matchedPattern ?? null,
293
- });
294
- // Fall through to normal permission check
295
- } else {
296
- let extDirDecision: PermissionPromptDecision | null = null;
297
- const extDirMessage = formatExternalDirectoryAskPrompt(
298
- toolName,
299
- externalDirectoryPath,
300
- ctx.cwd,
301
- agentName ?? undefined,
302
- );
303
- const extDirCanConfirm = deps.canRequestPermissionConfirmation(ctx);
304
- const extDirGateResult = await applyPermissionGate({
305
- state: extCheck.state,
306
- canConfirm: extDirCanConfirm,
307
- promptForApproval: async () => {
308
- const decision = await deps.promptPermission(ctx, {
309
- requestId: (event as { toolCallId: string }).toolCallId,
310
- source: "tool_call",
311
- agentName,
312
- message: extDirMessage,
313
- toolCallId: (event as { toolCallId: string }).toolCallId,
314
- toolName,
315
- path: externalDirectoryPath,
316
- });
317
- extDirDecision = decision;
318
- return decision;
319
- },
320
- writeLog: deps.runtime.writeReviewLog,
321
- logContext: {
322
- source: "tool_call",
323
- toolCallId: (event as { toolCallId: string }).toolCallId,
324
- toolName,
325
- agentName,
326
- path: externalDirectoryPath,
327
- message: extDirMessage,
328
- },
329
- messages: {
330
- denyReason: formatExternalDirectoryDenyReason(
331
- toolName,
332
- externalDirectoryPath,
333
- ctx.cwd,
334
- agentName ?? undefined,
335
- ),
336
- unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
337
- userDeniedReason: (decision) =>
338
- formatExternalDirectoryUserDeniedReason(
339
- toolName,
340
- externalDirectoryPath,
341
- decision.denialReason,
342
- ),
343
- },
344
- });
345
- emitDecisionEvent(deps.events, {
346
- surface: "external_directory",
347
- value: externalDirectoryPath,
348
- result: extDirGateResult.action === "allow" ? "allow" : "deny",
349
- resolution: deriveResolution(
350
- extCheck.state,
351
- extDirGateResult.action,
352
- extDirDecision?.state === "approved_for_session",
353
- extDirCanConfirm,
354
- ),
355
- origin: extCheck.origin ?? null,
356
- agentName: agentName ?? null,
357
- matchedPattern: extCheck.matchedPattern ?? null,
358
- });
359
- if (extDirGateResult.action === "block") {
360
- return { block: true, reason: extDirGateResult.reason };
361
- }
362
-
363
- if (extDirDecision?.state === "approved_for_session") {
364
- const pattern = deriveApprovalPattern(normalizedExtPath);
365
- deps.runtime.sessionRules.approve("external_directory", pattern);
366
- }
367
- }
368
- } // end else (not Pi infrastructure read)
369
- // Fall through to normal permission check
370
- }
371
-
372
- // ── Bash external-directory gate ─────────────────────────────────────────
373
- if (ctx.cwd && toolName === "bash") {
374
- const command = getNonEmptyString(toRecord(input).command);
375
- if (command) {
376
- const externalPaths = await extractExternalPathsFromBashCommand(
377
- command,
378
- ctx.cwd,
379
- );
380
- if (externalPaths.length > 0) {
381
- const bashSessionRules = deps.runtime.sessionRules.getRuleset();
382
- const uncoveredPaths = externalPaths.filter(
383
- (p) =>
384
- deps.runtime.permissionManager.checkPermission(
385
- "external_directory",
386
- { path: p },
387
- agentName ?? undefined,
388
- bashSessionRules,
389
- ).source !== "session",
390
- );
391
-
392
- if (uncoveredPaths.length === 0) {
393
- deps.runtime.writeReviewLog("permission_request.session_approved", {
394
- source: "tool_call",
395
- toolCallId: (event as { toolCallId: string }).toolCallId,
396
- toolName,
397
- agentName,
398
- command,
399
- externalPaths,
400
- resolution: "session_approved",
401
- });
402
- // Fall through to normal bash permission check
403
- } else {
404
- // Get the config-level policy (no path → no session check).
405
- const extCheck = deps.runtime.permissionManager.checkPermission(
406
- "external_directory",
407
- {},
408
- agentName ?? undefined,
409
- );
410
-
411
- let bashExtDecision: PermissionPromptDecision | null = null;
412
- const bashExtMessage = formatBashExternalDirectoryAskPrompt(
413
- command,
414
- uncoveredPaths,
415
- ctx.cwd,
416
- agentName ?? undefined,
417
- );
418
- const bashExtGate = await applyPermissionGate({
419
- state: extCheck.state,
420
- canConfirm: deps.canRequestPermissionConfirmation(ctx),
421
- promptForApproval: async () => {
422
- const decision = await deps.promptPermission(ctx, {
423
- requestId: (event as { toolCallId: string }).toolCallId,
424
- source: "tool_call",
425
- agentName,
426
- message: bashExtMessage,
427
- toolCallId: (event as { toolCallId: string }).toolCallId,
428
- toolName,
429
- command,
430
- });
431
- bashExtDecision = decision;
432
- return decision;
433
- },
434
- writeLog: deps.runtime.writeReviewLog,
435
- logContext: {
436
- source: "tool_call",
437
- toolCallId: (event as { toolCallId: string }).toolCallId,
438
- toolName,
439
- agentName,
440
- command,
441
- externalPaths: uncoveredPaths,
442
- message: bashExtMessage,
443
- },
444
- messages: {
445
- denyReason: formatBashExternalDirectoryDenyReason(
446
- command,
447
- uncoveredPaths,
448
- ctx.cwd,
449
- agentName ?? undefined,
450
- ),
451
- unavailableReason: `Bash command '${command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`,
452
- userDeniedReason: (decision) => {
453
- const reasonSuffix = decision.denialReason
454
- ? ` Reason: ${decision.denialReason}.`
455
- : "";
456
- return `User denied external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
457
- },
458
- },
459
- });
460
- if (bashExtGate.action === "block") {
461
- return { block: true, reason: bashExtGate.reason };
462
- }
463
-
464
- if (bashExtDecision?.state === "approved_for_session") {
465
- for (const extPath of uncoveredPaths) {
466
- const pattern = deriveApprovalPattern(extPath);
467
- deps.runtime.sessionRules.approve("external_directory", pattern);
468
- }
469
- }
470
- }
471
- // Fall through to normal bash permission check
472
- }
473
- }
474
- }
475
-
476
- // ── Normal tool permission gate ───────────────────────────────────────────
477
- const check = deps.runtime.permissionManager.checkPermission(
76
+ const tcc: ToolCallContext = {
478
77
  toolName,
78
+ agentName,
479
79
  input,
480
- agentName ?? undefined,
481
- deps.runtime.sessionRules.getRuleset(),
482
- );
80
+ toolCallId,
81
+ cwd: ctx.cwd,
82
+ };
483
83
 
484
- // Session-hit: already approved by a session rule — skip the gate entirely.
485
- if (check.source === "session") {
486
- deps.runtime.writeReviewLog("permission_request.session_approved", {
487
- source: "tool_call",
488
- toolCallId: (event as { toolCallId: string }).toolCallId,
489
- toolName,
490
- agentName,
491
- resolution: "session_approved",
492
- sessionApprovalPattern: check.matchedPattern,
493
- });
494
- emitDecisionEvent(deps.events, {
495
- surface: toolName,
496
- value: deriveDecisionValue(toolName, check),
497
- result: "allow",
498
- resolution: "session_approved",
499
- origin: check.origin ?? null,
500
- agentName: agentName ?? null,
501
- matchedPattern: check.matchedPattern ?? null,
502
- });
503
- return {};
84
+ // ── Skill-read gate ──────────────────────────────────────────────────────
85
+ const skillResult = await evaluateSkillReadGate(tcc, deps);
86
+ if (skillResult?.action === "block") {
87
+ return { block: true, reason: skillResult.reason };
504
88
  }
505
89
 
506
- const permissionLogContext = getPermissionLogContext(
507
- check,
508
- input,
509
- PATH_BEARING_TOOLS,
510
- );
511
-
512
- // Compute session approval suggestion for the "for this session" option.
513
- const suggestionValue =
514
- toolName === "bash"
515
- ? (check.command ?? "")
516
- : toolName === "mcp"
517
- ? (check.target ?? "mcp")
518
- : "*";
519
- const suggestion = suggestSessionPattern(toolName, suggestionValue);
520
-
521
- const toolUnavailableReason =
522
- toolName === "bash" && isToolCallEventType("bash", event as ToolCallEvent)
523
- ? `Running bash command '${(event as ToolCallEvent & { input: { command: string } }).input.command}' requires approval, but no interactive UI is available.`
524
- : toolName === "mcp"
525
- ? "Using tool 'mcp' requires approval, but no interactive UI is available."
526
- : `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
527
-
528
- const toolAskMessage = formatAskPrompt(check, agentName ?? undefined, input);
529
- const toolCanConfirm = deps.canRequestPermissionConfirmation(ctx);
530
- let toolDecisionAutoApproved = false;
531
- const toolGate = await applyPermissionGate({
532
- state: check.state,
533
- canConfirm: toolCanConfirm,
534
- sessionApproval: {
535
- surface: suggestion.surface,
536
- pattern: suggestion.pattern,
537
- },
538
- promptForApproval: async () => {
539
- const decision = await deps.promptPermission(ctx, {
540
- requestId: (event as { toolCallId: string }).toolCallId,
541
- source: "tool_call",
542
- agentName,
543
- message: toolAskMessage,
544
- toolCallId: (event as { toolCallId: string }).toolCallId,
545
- toolName,
546
- sessionLabel: suggestion.label,
547
- ...permissionLogContext,
548
- });
549
- toolDecisionAutoApproved = decision.autoApproved === true;
550
- return decision;
551
- },
552
- writeLog: deps.runtime.writeReviewLog,
553
- logContext: {
554
- source: "tool_call",
555
- toolCallId: (event as { toolCallId: string }).toolCallId,
556
- toolName,
557
- agentName,
558
- message: toolAskMessage,
559
- ...permissionLogContext,
560
- },
561
- messages: {
562
- denyReason: formatDenyReason(check, agentName ?? undefined),
563
- unavailableReason: toolUnavailableReason,
564
- userDeniedReason: (decision) =>
565
- formatUserDeniedReason(check, decision.denialReason),
566
- },
567
- });
568
-
569
- const toolGateHasSession =
570
- toolGate.action === "allow" && toolGate.sessionApproval !== undefined;
571
- emitDecisionEvent(deps.events, {
572
- surface: toolName,
573
- value: deriveDecisionValue(toolName, check),
574
- result: toolGate.action === "allow" ? "allow" : "deny",
575
- resolution: deriveResolution(
576
- check.state,
577
- toolGate.action,
578
- toolGateHasSession,
579
- toolCanConfirm,
580
- toolDecisionAutoApproved,
581
- ),
582
- origin: check.origin ?? null,
583
- agentName: agentName ?? null,
584
- matchedPattern: check.matchedPattern ?? null,
585
- });
90
+ // ── External-directory gate (file tools) ─────────────────────────────────
91
+ const extDirResult = await evaluateExternalDirectoryGate(tcc, deps);
92
+ if (extDirResult?.action === "block") {
93
+ return { block: true, reason: extDirResult.reason };
94
+ }
586
95
 
587
- if (toolGate.action === "block") {
588
- return { block: true, reason: toolGate.reason };
96
+ // ── Bash external-directory gate ─────────────────────────────────────────
97
+ const bashExtResult = await evaluateBashExternalDirectoryGate(tcc, deps);
98
+ if (bashExtResult?.action === "block") {
99
+ return { block: true, reason: bashExtResult.reason };
589
100
  }
590
101
 
591
- if (toolGate.sessionApproval) {
592
- deps.runtime.sessionRules.approve(
593
- toolGate.sessionApproval.surface,
594
- toolGate.sessionApproval.pattern,
595
- );
102
+ // ── Normal tool permission gate ──────────────────────────────────────────
103
+ const toolResult = await evaluateToolGate(tcc, deps);
104
+ if (toolResult.action === "block") {
105
+ return { block: true, reason: toolResult.reason };
596
106
  }
597
107
 
598
108
  return {};