@gotgenes/pi-permission-system 4.2.0 → 4.4.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/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.4.0](https://github.com/gotgenes/pi-permission-system/compare/v4.3.0...v4.4.0) (2026-05-05)
9
+
10
+
11
+ ### Features
12
+
13
+ * wire PermissionPrompter and remove runtime promptPermission ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([8e0980a](https://github.com/gotgenes/pi-permission-system/commit/8e0980a207f9f665ad2af3ad635d73e28d313c91))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * add permission-prompter architecture note ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([6cc1b60](https://github.com/gotgenes/pi-permission-system/commit/6cc1b6089a332f262919d20ad27d5ca7a69b0b6d))
19
+ * add permission-prompter to target architecture module map ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([c5cf101](https://github.com/gotgenes/pi-permission-system/commit/c5cf101af827dc108c66c5dffff94e05d4234e4c))
20
+ * add permission-prompter to v3 architecture module map ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([94be5b5](https://github.com/gotgenes/pi-permission-system/commit/94be5b58b9018b1918102089bb2fff8401d8bad5))
21
+ * plan extract PermissionPrompter class ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([50fcf34](https://github.com/gotgenes/pi-permission-system/commit/50fcf3400ed8574b036cacd2afc1b9e2161d41c6))
22
+ * remove interim permission-prompter from target architecture map ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([f300f08](https://github.com/gotgenes/pi-permission-system/commit/f300f086b42d9d52ff0668b63a191addebe3140e))
23
+ * **retro:** add retro notes for issue [#51](https://github.com/gotgenes/pi-permission-system/issues/51) ([79a564d](https://github.com/gotgenes/pi-permission-system/commit/79a564d24977da7cffa3d9d65391a75dd0d7e99c))
24
+ * update target architecture for completed work and new issues ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([e661345](https://github.com/gotgenes/pi-permission-system/commit/e661345c8a699e702cbaa9adb7d61c80a25010d2))
25
+
26
+ ## [4.3.0](https://github.com/gotgenes/pi-permission-system/compare/v4.2.0...v4.3.0) (2026-05-04)
27
+
28
+
29
+ ### Features
30
+
31
+ * add pattern-suggest module for session approval patterns ([0752604](https://github.com/gotgenes/pi-permission-system/commit/0752604ea63a3bcbf4a8d15f6a9760dde4de9b9d))
32
+ * dynamic session approval label in permission dialog ([4737f0d](https://github.com/gotgenes/pi-permission-system/commit/4737f0dbe69b579dca057a149788408cb63e52ec))
33
+ * extend checkPermission session evaluation to all surfaces ([ffc6731](https://github.com/gotgenes/pi-permission-system/commit/ffc67312e09fb0df0c4dbcb572a625b92c3cd018))
34
+ * extend permission gate with sessionApproval pass-through ([a77bad7](https://github.com/gotgenes/pi-permission-system/commit/a77bad7d9193a5500678bf18ac7854da0be2e79f))
35
+ * generalize session approvals to all permission surfaces ([#51](https://github.com/gotgenes/pi-permission-system/issues/51)) ([2fcc2e3](https://github.com/gotgenes/pi-permission-system/commit/2fcc2e37db4f704702fd3d4c64a1388ab417c407))
36
+
37
+
38
+ ### Documentation
39
+
40
+ * document generalized session approvals ([#51](https://github.com/gotgenes/pi-permission-system/issues/51)) ([233666e](https://github.com/gotgenes/pi-permission-system/commit/233666e496dd81165dec44ef4242bca090750edd))
41
+ * plan generalized session approvals for all surfaces ([#51](https://github.com/gotgenes/pi-permission-system/issues/51)) ([3b40cf9](https://github.com/gotgenes/pi-permission-system/commit/3b40cf954c9f598c7c3c199a4137e897c4fce4b2))
42
+ * **retro:** add retro notes for issue [#74](https://github.com/gotgenes/pi-permission-system/issues/74) ([0eb2ea0](https://github.com/gotgenes/pi-permission-system/commit/0eb2ea001669cba1436d564af9e28ca0ff26c77e))
43
+
8
44
  ## [4.2.0](https://github.com/gotgenes/pi-permission-system/compare/v4.1.1...v4.2.0) (2026-05-04)
9
45
 
10
46
 
package/README.md CHANGED
@@ -437,20 +437,28 @@ Current agent requested tool 'edit' for '.gitignore' (1 replacement: edit #1 rep
437
437
 
438
438
  ### Session-Scoped Approvals
439
439
 
440
- When `external_directory` resolves to `ask`, the permission dialog offers four options:
440
+ When any permission resolves to `ask`, the permission dialog offers four options:
441
441
 
442
442
  ```text
443
- Yes | Yes, for this session | No | No, provide reason
443
+ Yes | Yes, allow "<pattern>" for this session | No | No, provide reason
444
444
  ```
445
445
 
446
- Selecting **Yes, for this session** approves the current request and caches the directory prefix so that subsequent accesses under the same directory skip the prompt for the remainder of the session.
447
- For example, approving access to `~/other-project/src/foo.ts` covers all paths under `~/other-project/src/` until the session ends.
446
+ Selecting **Yes, allow "\<pattern\>" for this session** approves the current request and records the suggested wildcard pattern as a session rule.
447
+ Subsequent requests that match the pattern skip the prompt for the remainder of the session.
448
448
 
449
- Session approvals are ephemeral — they are never persisted to disk and are cleared on `session_shutdown`.
450
- The review log records these decisions with `resolution: "session_approved"` so they remain auditable.
449
+ The suggested pattern is surface-specific:
450
+
451
+ |Surface|Example request|Suggested session pattern|
452
+ |---|---|---|
453
+ |bash|`git status --short`|`git *`|
454
+ |mcp (qualified)|`exa:search`|`exa:*`|
455
+ |mcp (munged)|`exa_search`|`exa_*`|
456
+ |skill|`librarian`|`librarian`|
457
+ |tool (read, write, …)|`read`|`*`|
458
+ |external_directory|`/other/project/src/foo.ts`|`/other/project/src/*`|
451
459
 
452
- This is currently scoped to the `external_directory` surface only.
453
- Other permission surfaces (tools, bash patterns, MCP, skills) always use the standard one-time approval flow.
460
+ Session approvals are ephemeral — they are never persisted to disk and are cleared on `session_shutdown`.
461
+ The review log records these decisions: `resolution: "approved_for_session"` when the user approves, and `resolution: "session_approved"` when a later request is matched by an existing session rule.
454
462
 
455
463
  ### Subagent Permission Forwarding
456
464
 
@@ -496,7 +504,8 @@ This makes it easy to verify which files the extension actually loaded:
496
504
  index.ts → Root Pi entrypoint shim
497
505
  src/
498
506
  ├── index.ts → Extension bootstrap, permission checks, readable prompts, review logging, reload handling, and subagent forwarding
499
- ├── session-rules.ts Ephemeral session-scoped approval rules (Ruleset-based, external-directory access)
507
+ ├── pattern-suggest.ts Per-surface session approval pattern suggestions
508
+ ├── session-rules.ts → Ephemeral session-scoped approval rules (Ruleset-based, wildcard patterns across all surfaces)
500
509
  ├── config-loader.ts → Unified config loader, merger, and legacy-path detection
501
510
  ├── config-paths.ts → Path derivation for global, project, and legacy config locations
502
511
  ├── config-reporter.ts → Resolved config path reporting for diagnostic logs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -7,7 +7,10 @@ import {
7
7
  getActiveAgentNameFromSystemPrompt,
8
8
  } from "../active-agent";
9
9
  import { toRecord } from "../common";
10
- import type { PermissionPromptDecision } from "../permission-dialog";
10
+ import type {
11
+ PermissionPromptDecision,
12
+ RequestPermissionOptions,
13
+ } from "../permission-dialog";
11
14
  import {
12
15
  type ForwardedPermissionRequest,
13
16
  type ForwardedPermissionResponse,
@@ -42,6 +45,7 @@ export interface PermissionForwardingDeps {
42
45
  ui: ExtensionContext["ui"],
43
46
  title: string,
44
47
  message: string,
48
+ options?: RequestPermissionOptions,
45
49
  ) => Promise<PermissionPromptDecision>;
46
50
  shouldAutoApprove: () => boolean;
47
51
  }
@@ -339,12 +343,14 @@ export async function confirmPermission(
339
343
  ctx: ExtensionContext,
340
344
  message: string,
341
345
  deps: PermissionForwardingDeps,
346
+ options?: RequestPermissionOptions,
342
347
  ): Promise<PermissionPromptDecision> {
343
348
  if (ctx.hasUI) {
344
349
  return deps.requestPermissionDecisionFromUi(
345
350
  ctx.ui,
346
351
  "Permission Required",
347
352
  message,
353
+ options,
348
354
  );
349
355
  }
350
356
 
@@ -18,6 +18,7 @@ import {
18
18
  normalizePathForComparison,
19
19
  PATH_BEARING_TOOLS,
20
20
  } from "../external-directory";
21
+ import { suggestSessionPattern } from "../pattern-suggest";
21
22
  import type { PermissionPromptDecision } from "../permission-dialog";
22
23
  import { applyPermissionGate } from "../permission-gate";
23
24
  import {
@@ -359,12 +360,35 @@ export async function handleToolCall(
359
360
  agentName ?? undefined,
360
361
  deps.runtime.sessionRules.getRuleset(),
361
362
  );
363
+
364
+ // Session-hit: already approved by a session rule — skip the gate entirely.
365
+ if (check.source === "session") {
366
+ deps.runtime.writeReviewLog("permission_request.session_approved", {
367
+ source: "tool_call",
368
+ toolCallId: (event as { toolCallId: string }).toolCallId,
369
+ toolName,
370
+ agentName,
371
+ resolution: "session_approved",
372
+ sessionApprovalPattern: check.matchedPattern,
373
+ });
374
+ return {};
375
+ }
376
+
362
377
  const permissionLogContext = getPermissionLogContext(
363
378
  check,
364
379
  input,
365
380
  PATH_BEARING_TOOLS,
366
381
  );
367
382
 
383
+ // Compute session approval suggestion for the "for this session" option.
384
+ const suggestionValue =
385
+ toolName === "bash"
386
+ ? (check.command ?? "")
387
+ : toolName === "mcp"
388
+ ? (check.target ?? "mcp")
389
+ : "*";
390
+ const suggestion = suggestSessionPattern(toolName, suggestionValue);
391
+
368
392
  const toolUnavailableReason =
369
393
  toolName === "bash" && isToolCallEventType("bash", event as ToolCallEvent)
370
394
  ? `Running bash command '${(event as ToolCallEvent & { input: { command: string } }).input.command}' requires approval, but no interactive UI is available.`
@@ -376,6 +400,10 @@ export async function handleToolCall(
376
400
  const toolGate = await applyPermissionGate({
377
401
  state: check.state,
378
402
  canConfirm: deps.canRequestPermissionConfirmation(ctx),
403
+ sessionApproval: {
404
+ surface: suggestion.surface,
405
+ pattern: suggestion.pattern,
406
+ },
379
407
  promptForApproval: () =>
380
408
  deps.promptPermission(ctx, {
381
409
  requestId: (event as { toolCallId: string }).toolCallId,
@@ -384,6 +412,7 @@ export async function handleToolCall(
384
412
  message: toolAskMessage,
385
413
  toolCallId: (event as { toolCallId: string }).toolCallId,
386
414
  toolName,
415
+ sessionLabel: suggestion.label,
387
416
  ...permissionLogContext,
388
417
  }),
389
418
  writeLog: deps.runtime.writeReviewLog,
@@ -407,5 +436,12 @@ export async function handleToolCall(
407
436
  return { block: true, reason: toolGate.reason };
408
437
  }
409
438
 
439
+ if (toolGate.sessionApproval) {
440
+ deps.runtime.sessionRules.approve(
441
+ toolGate.sessionApproval.surface,
442
+ toolGate.sessionApproval.pattern,
443
+ );
444
+ }
445
+
410
446
  return {};
411
447
  }
@@ -19,6 +19,8 @@ export interface PromptPermissionDetails {
19
19
  command?: string;
20
20
  target?: string;
21
21
  toolInputPreview?: string;
22
+ /** Override label for the "for this session" dialog option. */
23
+ sessionLabel?: string;
22
24
  }
23
25
 
24
26
  /**
package/src/index.ts CHANGED
@@ -12,11 +12,11 @@ import {
12
12
  handleToolCall,
13
13
  } from "./handlers";
14
14
  import { requestPermissionDecisionFromUi } from "./permission-dialog";
15
+ import { PermissionPrompter } from "./permission-prompter";
15
16
  import {
16
17
  createExtensionRuntime,
17
18
  createPermissionManagerForCwd,
18
19
  logResolvedConfigPaths,
19
- promptPermission,
20
20
  refreshExtensionConfig,
21
21
  resolveAgentName,
22
22
  saveExtensionConfig,
@@ -32,6 +32,14 @@ import {
32
32
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
33
33
  const runtime = createExtensionRuntime();
34
34
 
35
+ const prompter = new PermissionPrompter({
36
+ getConfig: () => runtime.config,
37
+ writeReviewLog: runtime.writeReviewLog.bind(runtime),
38
+ subagentSessionsDir: runtime.subagentSessionsDir,
39
+ forwardingDir: runtime.forwardingDir,
40
+ requestPermissionDecisionFromUi,
41
+ });
42
+
35
43
  const forwardingDeps: PermissionForwardingDeps = {
36
44
  forwardingDir: runtime.forwardingDir,
37
45
  subagentSessionsDir: runtime.subagentSessionsDir,
@@ -74,8 +82,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
74
82
  runtime.subagentSessionsDir,
75
83
  ),
76
84
  }),
77
- promptPermission: (ctx, details) =>
78
- promptPermission(runtime, forwardingDeps, ctx, details),
85
+ promptPermission: (ctx, details) => prompter.prompt(ctx, details),
79
86
  createPermissionRequestId,
80
87
  startForwardedPermissionPolling: (ctx) =>
81
88
  startForwardedPermissionPolling(runtime, forwardingDeps, ctx),
@@ -0,0 +1,91 @@
1
+ import { deriveApprovalPattern } from "./session-rules";
2
+
3
+ /** The suggestion returned for a "Yes, for this session" dialog option. */
4
+ export interface SessionApprovalSuggestion {
5
+ /** The permission surface this approval applies to. */
6
+ surface: string;
7
+ /** The wildcard pattern to store as a session rule. */
8
+ pattern: string;
9
+ /** Human-readable label for the "for session" dialog option. */
10
+ label: string;
11
+ }
12
+
13
+ /**
14
+ * Suggest a bash session-approval pattern from a command string.
15
+ *
16
+ * Heuristic: split on the first space to get the base command.
17
+ * Multi-word commands → `<command> *`.
18
+ * Single-word commands → exact command (no wildcard).
19
+ *
20
+ * This is intentionally conservative. The arity table (#52) will refine
21
+ * suggestions later (e.g. `git checkout *` instead of `git *`).
22
+ */
23
+ export function suggestBashPattern(command: string): string {
24
+ const trimmed = command.trim();
25
+ const spaceIndex = trimmed.indexOf(" ");
26
+ if (spaceIndex === -1) {
27
+ return trimmed;
28
+ }
29
+ return `${trimmed.slice(0, spaceIndex)} *`;
30
+ }
31
+
32
+ /**
33
+ * Suggest an MCP session-approval pattern from a resolved target string.
34
+ *
35
+ * - Qualified target (`server:tool`) → `server:*`
36
+ * - Munged target (`server_tool`) → `server_*`
37
+ * - Bare target (no separator) → `*`
38
+ */
39
+ export function suggestMcpPattern(target: string): string {
40
+ const trimmed = target.trim();
41
+
42
+ const colonIndex = trimmed.indexOf(":");
43
+ if (colonIndex > 0) {
44
+ return `${trimmed.slice(0, colonIndex)}:*`;
45
+ }
46
+
47
+ const underscoreIndex = trimmed.indexOf("_");
48
+ if (underscoreIndex > 0) {
49
+ return `${trimmed.slice(0, underscoreIndex)}_*`;
50
+ }
51
+
52
+ return "*";
53
+ }
54
+
55
+ function buildLabel(pattern: string): string {
56
+ return `Yes, allow "${pattern}" for this session`;
57
+ }
58
+
59
+ /**
60
+ * Suggest a session-approval pattern for the given permission surface and value.
61
+ *
62
+ * Returns a `SessionApprovalSuggestion` with the surface, the wildcard pattern
63
+ * to store in `SessionRules`, and a human-readable dialog label.
64
+ */
65
+ export function suggestSessionPattern(
66
+ surface: string,
67
+ value: string,
68
+ ): SessionApprovalSuggestion {
69
+ let pattern: string;
70
+
71
+ switch (surface) {
72
+ case "bash":
73
+ pattern = suggestBashPattern(value);
74
+ break;
75
+ case "mcp":
76
+ pattern = suggestMcpPattern(value);
77
+ break;
78
+ case "skill":
79
+ pattern = value;
80
+ break;
81
+ case "external_directory":
82
+ pattern = deriveApprovalPattern(value);
83
+ break;
84
+ default:
85
+ // Tool surfaces (read, write, edit, grep, find, ls, extension tools)
86
+ pattern = "*";
87
+ break;
88
+ }
89
+
90
+ return { surface, pattern, label: buildLabel(pattern) };
91
+ }
@@ -64,13 +64,27 @@ export function isPermissionDecisionState(
64
64
  );
65
65
  }
66
66
 
67
+ export interface RequestPermissionOptions {
68
+ /** Override the "for this session" option label (e.g. to show the suggested pattern). */
69
+ sessionLabel?: string;
70
+ }
71
+
67
72
  export async function requestPermissionDecisionFromUi(
68
73
  ui: PermissionDecisionUi,
69
74
  title: string,
70
75
  message: string,
76
+ options?: RequestPermissionOptions,
71
77
  ): Promise<PermissionPromptDecision> {
78
+ const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
79
+ const decisionOptions = [
80
+ APPROVE_OPTION,
81
+ sessionOption,
82
+ DENY_OPTION,
83
+ DENY_WITH_REASON_OPTION,
84
+ ] as const;
85
+
72
86
  const selected = await ui.select(`${title}\n${message}`, [
73
- ...PERMISSION_DECISION_OPTIONS,
87
+ ...decisionOptions,
74
88
  ]);
75
89
 
76
90
  if (selected === APPROVE_OPTION) {
@@ -80,7 +94,7 @@ export async function requestPermissionDecisionFromUi(
80
94
  };
81
95
  }
82
96
 
83
- if (selected === APPROVE_FOR_SESSION_OPTION) {
97
+ if (selected === sessionOption) {
84
98
  return {
85
99
  approved: true,
86
100
  state: "approved_for_session",
@@ -2,7 +2,7 @@ import type { PermissionPromptDecision } from "./permission-dialog";
2
2
 
3
3
  /** Result of applying the permission gate. */
4
4
  export type PermissionGateResult =
5
- | { action: "allow" }
5
+ | { action: "allow"; sessionApproval?: { surface: string; pattern: string } }
6
6
  | { action: "block"; reason: string };
7
7
 
8
8
  /** Everything the gate needs — no direct dependency on ExtensionContext. */
@@ -16,6 +16,13 @@ export interface PermissionGateParams {
16
16
  /** Prompt the user for approval. Only called when state === "ask" and canConfirm is true. */
17
17
  promptForApproval: () => Promise<PermissionPromptDecision>;
18
18
 
19
+ /**
20
+ * Session approval suggestion to record when the user selects
21
+ * "for this session". When present and the decision is `approved_for_session`,
22
+ * the result carries the suggestion back to the caller for recording.
23
+ */
24
+ sessionApproval?: { surface: string; pattern: string };
25
+
19
26
  /** Write a review-log entry. Called for deny and ask-but-unavailable paths. */
20
27
  writeLog: (event: string, extra: Record<string, unknown>) => void;
21
28
 
@@ -68,6 +75,9 @@ export async function applyPermissionGate(
68
75
  if (!decision.approved) {
69
76
  return { action: "block", reason: messages.userDeniedReason(decision) };
70
77
  }
78
+ if (decision.state === "approved_for_session" && params.sessionApproval) {
79
+ return { action: "allow", sessionApproval: params.sessionApproval };
80
+ }
71
81
  }
72
82
 
73
83
  return { action: "allow" };
@@ -493,6 +493,20 @@ export class PermissionManager {
493
493
  if (normalizedToolName === "skill") {
494
494
  const skillName = toRecord(input).name;
495
495
  const lookupValue = typeof skillName === "string" ? skillName : "*";
496
+
497
+ // Session check.
498
+ if (sessionRules && sessionRules.length > 0) {
499
+ const sessionRule = evaluate("skill", lookupValue, sessionRules);
500
+ if (sessionRules.includes(sessionRule)) {
501
+ return {
502
+ toolName,
503
+ state: "allow",
504
+ matchedPattern: sessionRule.pattern,
505
+ source: "session",
506
+ };
507
+ }
508
+ }
509
+
496
510
  const rule = evaluate("skill", lookupValue, composedRules);
497
511
  return {
498
512
  toolName,
@@ -506,6 +520,21 @@ export class PermissionManager {
506
520
  if (normalizedToolName === "bash") {
507
521
  const record = toRecord(input);
508
522
  const command = typeof record.command === "string" ? record.command : "";
523
+
524
+ // Session check.
525
+ if (sessionRules && sessionRules.length > 0) {
526
+ const sessionRule = evaluate("bash", command, sessionRules);
527
+ if (sessionRules.includes(sessionRule)) {
528
+ return {
529
+ toolName,
530
+ state: "allow",
531
+ command,
532
+ matchedPattern: sessionRule.pattern,
533
+ source: "session",
534
+ };
535
+ }
536
+ }
537
+
509
538
  const rule = evaluate("bash", command, composedRules);
510
539
  return {
511
540
  toolName,
@@ -527,6 +556,22 @@ export class PermissionManager {
527
556
  ];
528
557
  const fallbackTarget = mcpTargets[0] || "mcp";
529
558
 
559
+ // Session check: try each candidate target against session rules.
560
+ if (sessionRules && sessionRules.length > 0) {
561
+ for (const target of mcpTargets) {
562
+ const sessionRule = evaluate("mcp", target, sessionRules);
563
+ if (sessionRules.includes(sessionRule)) {
564
+ return {
565
+ toolName,
566
+ state: "allow",
567
+ matchedPattern: sessionRule.pattern,
568
+ target,
569
+ source: "session",
570
+ };
571
+ }
572
+ }
573
+ }
574
+
530
575
  // Try each candidate target. Stop on the first non-default match.
531
576
  for (const target of mcpTargets) {
532
577
  const rule = evaluate("mcp", target, composedRules);
@@ -552,6 +597,20 @@ export class PermissionManager {
552
597
  }
553
598
 
554
599
  // --- Tools (read, write, edit, grep, find, ls, extension tools) ---
600
+
601
+ // Session check.
602
+ if (sessionRules && sessionRules.length > 0) {
603
+ const sessionRule = evaluate(normalizedToolName, "*", sessionRules);
604
+ if (sessionRules.includes(sessionRule)) {
605
+ return {
606
+ toolName,
607
+ state: "allow",
608
+ matchedPattern: sessionRule.pattern,
609
+ source: "session",
610
+ };
611
+ }
612
+ }
613
+
555
614
  const rule = evaluate(normalizedToolName, "*", composedRules);
556
615
 
557
616
  if (BUILT_IN_TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
@@ -0,0 +1,146 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import type { PermissionSystemExtensionConfig } from "./extension-config";
3
+ import type { ForwardedPermissionLogger } from "./forwarded-permissions/io";
4
+ import {
5
+ confirmPermission,
6
+ type PermissionForwardingDeps,
7
+ } from "./forwarded-permissions/polling";
8
+ import type { PromptPermissionDetails } from "./handlers/types";
9
+ import type {
10
+ PermissionPromptDecision,
11
+ RequestPermissionOptions,
12
+ } from "./permission-dialog";
13
+ import { shouldAutoApprovePermissionState } from "./yolo-mode";
14
+
15
+ /** Mockable contract exposed to handlers via HandlerDeps. */
16
+ export interface PermissionPrompterApi {
17
+ prompt(
18
+ ctx: ExtensionContext,
19
+ details: PromptPermissionDetails,
20
+ ): Promise<PermissionPromptDecision>;
21
+ }
22
+
23
+ /**
24
+ * Dependencies required by PermissionPrompter.
25
+ *
26
+ * Keeps the prompter's external surface narrow: callers provide config
27
+ * access, review-log writing, path constants, and the UI dialog function.
28
+ * The prompter synthesises the PermissionForwardingDeps it needs internally.
29
+ */
30
+ export interface PermissionPrompterDeps {
31
+ /** Read current config for yolo-mode check (called at prompt time). */
32
+ getConfig(): PermissionSystemExtensionConfig;
33
+ /** Write structured entries to the permission review log. */
34
+ writeReviewLog(event: string, details: Record<string, unknown>): void;
35
+ /** Directory containing subagent session state. */
36
+ subagentSessionsDir: string;
37
+ /** Directory used for file-based permission forwarding requests/responses. */
38
+ forwardingDir: string;
39
+ /** Show the interactive permission dialog in the UI. */
40
+ requestPermissionDecisionFromUi(
41
+ ui: ExtensionContext["ui"],
42
+ title: string,
43
+ message: string,
44
+ options?: RequestPermissionOptions,
45
+ ): Promise<PermissionPromptDecision>;
46
+ }
47
+
48
+ /**
49
+ * Encapsulates the full permission-prompt flow:
50
+ * 1. Yolo-mode auto-approval check.
51
+ * 2. Review-log "waiting" entry.
52
+ * 3. UI-present vs. subagent-forwarding branching (via confirmPermission).
53
+ * 4. Review-log "approved" / "denied" entry.
54
+ *
55
+ * Injecting a single PermissionPrompter instance into HandlerDeps means
56
+ * adding a new prompt parameter (e.g. a future sessionLabel variant) only
57
+ * requires changing PromptPermissionDetails and this class — not the full
58
+ * 4-file threading chain.
59
+ */
60
+ export class PermissionPrompter implements PermissionPrompterApi {
61
+ constructor(private readonly deps: PermissionPrompterDeps) {}
62
+
63
+ async prompt(
64
+ ctx: ExtensionContext,
65
+ details: PromptPermissionDetails,
66
+ ): Promise<PermissionPromptDecision> {
67
+ if (shouldAutoApprovePermissionState("ask", this.deps.getConfig())) {
68
+ this.writeReviewEntry("permission_request.auto_approved", details);
69
+ return { approved: true, state: "approved" };
70
+ }
71
+
72
+ this.writeReviewEntry("permission_request.waiting", details);
73
+
74
+ const decision = await confirmPermission(
75
+ ctx,
76
+ details.message,
77
+ this.buildForwardingDeps(),
78
+ details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
79
+ );
80
+
81
+ this.writeReviewEntry(
82
+ decision.approved
83
+ ? "permission_request.approved"
84
+ : "permission_request.denied",
85
+ {
86
+ ...details,
87
+ resolution: decision.state,
88
+ denialReason: decision.denialReason,
89
+ },
90
+ );
91
+
92
+ return decision;
93
+ }
94
+
95
+ // ── Private helpers ──────────────────────────────────────────────────────
96
+
97
+ private writeReviewEntry(
98
+ event: string,
99
+ details: PromptPermissionDetails & {
100
+ resolution?: string;
101
+ denialReason?: string;
102
+ },
103
+ ): void {
104
+ this.deps.writeReviewLog(event, {
105
+ requestId: details.requestId,
106
+ source: details.source,
107
+ agentName: details.agentName,
108
+ message: details.message,
109
+ toolCallId: details.toolCallId ?? null,
110
+ toolName: details.toolName ?? null,
111
+ skillName: details.skillName ?? null,
112
+ path: details.path ?? null,
113
+ command: details.command ?? null,
114
+ target: details.target ?? null,
115
+ toolInputPreview: details.toolInputPreview ?? null,
116
+ resolution: details.resolution ?? null,
117
+ denialReason: details.denialReason ?? null,
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Build a PermissionForwardingDeps to pass to confirmPermission.
123
+ *
124
+ * Yolo-mode is already handled at the prompter level, so shouldAutoApprove
125
+ * returns false here (confirmPermission does not call it; only
126
+ * processForwardedPermissionRequests does, and that has its own deps).
127
+ *
128
+ * The logger delegates writeReviewLog to deps and uses a no-op writeDebugLog
129
+ * (trace-level forwarding debug is deferred — see open question in the plan).
130
+ */
131
+ private buildForwardingDeps(): PermissionForwardingDeps {
132
+ const { deps } = this;
133
+ const logger: ForwardedPermissionLogger = {
134
+ writeReviewLog: deps.writeReviewLog,
135
+ writeDebugLog: () => undefined,
136
+ };
137
+ return {
138
+ forwardingDir: deps.forwardingDir,
139
+ subagentSessionsDir: deps.subagentSessionsDir,
140
+ logger,
141
+ writeReviewLog: deps.writeReviewLog,
142
+ requestPermissionDecisionFromUi: deps.requestPermissionDecisionFromUi,
143
+ shouldAutoApprove: () => false,
144
+ };
145
+ }
146
+ }