@gotgenes/pi-permission-system 9.2.0 → 10.1.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +12 -11
  3. package/package.json +1 -1
  4. package/src/agent-prep-session.ts +28 -0
  5. package/src/decision-reporter.ts +41 -0
  6. package/src/denial-messages.ts +11 -0
  7. package/src/forwarded-permissions/io.ts +29 -0
  8. package/src/forwarded-permissions/permission-forwarder.ts +549 -0
  9. package/src/forwarding-manager.ts +3 -7
  10. package/src/gate-handler-session.ts +13 -0
  11. package/src/gate-prompter.ts +14 -0
  12. package/src/handlers/before-agent-start.ts +2 -3
  13. package/src/handlers/gates/bash-command.ts +4 -18
  14. package/src/handlers/gates/bash-external-directory.ts +3 -15
  15. package/src/handlers/gates/bash-path.ts +3 -16
  16. package/src/handlers/gates/descriptor.ts +0 -28
  17. package/src/handlers/gates/path.ts +3 -15
  18. package/src/handlers/gates/runner.ts +142 -105
  19. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  20. package/src/handlers/gates/skill-input.ts +44 -0
  21. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  22. package/src/handlers/lifecycle.ts +9 -9
  23. package/src/handlers/permission-gate-handler.ts +34 -238
  24. package/src/index.ts +50 -68
  25. package/src/mcp-targets.ts +56 -46
  26. package/src/permission-event-rpc.ts +7 -0
  27. package/src/permission-events.ts +89 -8
  28. package/src/permission-forwarding.ts +23 -0
  29. package/src/permission-prompter.ts +27 -56
  30. package/src/permission-resolver.ts +17 -0
  31. package/src/permission-session.ts +77 -9
  32. package/src/permission-ui-prompt.ts +127 -0
  33. package/src/permissions-service.ts +53 -0
  34. package/src/service-lifecycle.ts +49 -0
  35. package/src/service.ts +17 -0
  36. package/src/session-approval-recorder.ts +6 -0
  37. package/src/session-lifecycle-session.ts +24 -0
  38. package/src/tool-input-preview.ts +0 -62
  39. package/src/tool-input-prompt-formatters.ts +63 -0
  40. package/src/tool-preview-formatter.ts +6 -4
  41. package/test/composition-root.test.ts +5 -0
  42. package/test/decision-reporter.test.ts +112 -0
  43. package/test/denial-messages.test.ts +62 -0
  44. package/test/forwarding-manager.test.ts +26 -44
  45. package/test/handlers/before-agent-start.test.ts +45 -21
  46. package/test/handlers/external-directory-integration.test.ts +86 -22
  47. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  48. package/test/handlers/gates/bash-command.test.ts +49 -90
  49. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  50. package/test/handlers/gates/bash-path.test.ts +63 -148
  51. package/test/handlers/gates/path.test.ts +38 -105
  52. package/test/handlers/gates/runner.test.ts +150 -93
  53. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  54. package/test/handlers/gates/skill-input.test.ts +128 -0
  55. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  56. package/test/handlers/input.test.ts +1 -2
  57. package/test/handlers/lifecycle.test.ts +49 -33
  58. package/test/handlers/tool-call-events.test.ts +1 -1
  59. package/test/helpers/gate-fixtures.ts +147 -16
  60. package/test/helpers/handler-fixtures.ts +143 -27
  61. package/test/mcp-targets.test.ts +55 -0
  62. package/test/permission-event-rpc.test.ts +39 -0
  63. package/test/permission-events.test.ts +78 -10
  64. package/test/permission-forwarder.test.ts +295 -0
  65. package/test/permission-prompter.test.ts +147 -38
  66. package/test/permission-session.test.ts +160 -27
  67. package/test/permission-ui-prompt.test.ts +146 -0
  68. package/test/permissions-service.test.ts +151 -0
  69. package/test/runtime.test.ts +0 -4
  70. package/test/service-lifecycle.test.ts +162 -0
  71. package/test/tool-input-preview.test.ts +0 -111
  72. package/test/tool-input-prompt-formatters.test.ts +115 -0
  73. package/src/forwarded-permissions/polling.ts +0 -379
@@ -1,5 +1,28 @@
1
1
  import { getNonEmptyString, toRecord } from "./common";
2
2
 
3
+ /**
4
+ * An ordered accumulator that owns the uniqueness invariant.
5
+ *
6
+ * `add` ignores null/empty values and silently skips duplicates (first-insertion
7
+ * wins). `toArray` returns the ordered result as an independent copy.
8
+ */
9
+ export class McpTargetList {
10
+ private readonly targets: string[] = [];
11
+
12
+ add(value: string | null): void {
13
+ if (!value) {
14
+ return;
15
+ }
16
+ if (!this.targets.includes(value)) {
17
+ this.targets.push(value);
18
+ }
19
+ }
20
+
21
+ toArray(): string[] {
22
+ return [...this.targets];
23
+ }
24
+ }
25
+
3
26
  /**
4
27
  * Parse a qualified MCP tool name of the form `server:tool`.
5
28
  *
@@ -31,7 +54,7 @@ export function parseQualifiedMcpToolName(
31
54
  function addDerivedMcpServerTargets(
32
55
  toolName: string,
33
56
  configuredServerNames: readonly string[],
34
- pushTarget: (value: string | null) => void,
57
+ targets: McpTargetList,
35
58
  ): void {
36
59
  const trimmedToolName = toolName.trim();
37
60
  if (!trimmedToolName) {
@@ -52,9 +75,9 @@ function addDerivedMcpServerTargets(
52
75
  continue;
53
76
  }
54
77
 
55
- pushTarget(`${trimmedServerName}_${trimmedToolName}`);
56
- pushTarget(`${trimmedServerName}:${trimmedToolName}`);
57
- pushTarget(trimmedServerName);
78
+ targets.add(`${trimmedServerName}_${trimmedToolName}`);
79
+ targets.add(`${trimmedServerName}:${trimmedToolName}`);
80
+ targets.add(trimmedServerName);
58
81
  }
59
82
  }
60
83
 
@@ -62,22 +85,22 @@ function pushMcpToolPermissionTargets(
62
85
  rawReference: string,
63
86
  serverHint: string | null,
64
87
  configuredServerNames: readonly string[],
65
- pushTarget: (value: string | null) => void,
88
+ targets: McpTargetList,
66
89
  ): void {
67
90
  const qualified = parseQualifiedMcpToolName(rawReference);
68
91
  const resolvedServer = serverHint ?? qualified?.server ?? null;
69
92
  const resolvedTool = qualified?.tool ?? rawReference;
70
93
 
71
94
  if (resolvedServer) {
72
- pushTarget(`${resolvedServer}_${resolvedTool}`);
73
- pushTarget(`${resolvedServer}:${resolvedTool}`);
74
- pushTarget(resolvedServer);
95
+ targets.add(`${resolvedServer}_${resolvedTool}`);
96
+ targets.add(`${resolvedServer}:${resolvedTool}`);
97
+ targets.add(resolvedServer);
75
98
  } else {
76
- addDerivedMcpServerTargets(resolvedTool, configuredServerNames, pushTarget);
99
+ addDerivedMcpServerTargets(resolvedTool, configuredServerNames, targets);
77
100
  }
78
101
 
79
- pushTarget(resolvedTool);
80
- pushTarget(rawReference);
102
+ targets.add(resolvedTool);
103
+ targets.add(rawReference);
81
104
  }
82
105
 
83
106
  /**
@@ -98,32 +121,19 @@ export function createMcpPermissionTargets(
98
121
  const describe = getNonEmptyString(record.describe);
99
122
  const search = getNonEmptyString(record.search);
100
123
 
101
- const targets: string[] = [];
102
- const pushTarget = (value: string | null) => {
103
- if (!value) {
104
- return;
105
- }
106
- if (!targets.includes(value)) {
107
- targets.push(value);
108
- }
109
- };
124
+ const targets = new McpTargetList();
110
125
 
111
126
  if (tool) {
112
- pushMcpToolPermissionTargets(
113
- tool,
114
- server,
115
- configuredServerNames,
116
- pushTarget,
117
- );
118
- pushTarget("mcp_call");
119
- return targets;
127
+ pushMcpToolPermissionTargets(tool, server, configuredServerNames, targets);
128
+ targets.add("mcp_call");
129
+ return targets.toArray();
120
130
  }
121
131
 
122
132
  if (connect) {
123
- pushTarget(`mcp_connect_${connect}`);
124
- pushTarget(connect);
125
- pushTarget("mcp_connect");
126
- return targets;
133
+ targets.add(`mcp_connect_${connect}`);
134
+ targets.add(connect);
135
+ targets.add("mcp_connect");
136
+ return targets.toArray();
127
137
  }
128
138
 
129
139
  if (describe) {
@@ -131,30 +141,30 @@ export function createMcpPermissionTargets(
131
141
  describe,
132
142
  server,
133
143
  configuredServerNames,
134
- pushTarget,
144
+ targets,
135
145
  );
136
- pushTarget("mcp_describe");
137
- return targets;
146
+ targets.add("mcp_describe");
147
+ return targets.toArray();
138
148
  }
139
149
 
140
150
  if (search) {
141
151
  if (server) {
142
- pushTarget(`mcp_server_${server}`);
143
- pushTarget(server);
152
+ targets.add(`mcp_server_${server}`);
153
+ targets.add(server);
144
154
  }
145
155
 
146
- pushTarget(search);
147
- pushTarget("mcp_search");
148
- return targets;
156
+ targets.add(search);
157
+ targets.add("mcp_search");
158
+ return targets.toArray();
149
159
  }
150
160
 
151
161
  if (server) {
152
- pushTarget(`mcp_server_${server}`);
153
- pushTarget(server);
154
- pushTarget("mcp_list");
155
- return targets;
162
+ targets.add(`mcp_server_${server}`);
163
+ targets.add(server);
164
+ targets.add("mcp_list");
165
+ return targets.toArray();
156
166
  }
157
167
 
158
- pushTarget("mcp_status");
159
- return targets;
168
+ targets.add("mcp_status");
169
+ return targets.toArray();
160
170
  }
@@ -21,11 +21,13 @@ import type {
21
21
  PermissionsRpcReply,
22
22
  } from "./permission-events";
23
23
  import {
24
+ emitUiPromptEvent,
24
25
  PERMISSIONS_PROTOCOL_VERSION,
25
26
  PERMISSIONS_RPC_CHECK_CHANNEL,
26
27
  PERMISSIONS_RPC_PROMPT_CHANNEL,
27
28
  } from "./permission-events";
28
29
  import type { PermissionManager } from "./permission-manager";
30
+ import { buildRpcUiPrompt } from "./permission-ui-prompt";
29
31
  import type { Rule } from "./rule";
30
32
 
31
33
  /** Dependencies injected into the RPC handler registry. */
@@ -155,6 +157,11 @@ async function handlePromptRpc(
155
157
  ? `Permission request${agentName ? ` from ${agentName}` : ""}`
156
158
  : "Permission request";
157
159
 
160
+ emitUiPromptEvent(
161
+ events,
162
+ buildRpcUiPrompt({ requestId, surface, value, agentName, message }),
163
+ );
164
+
158
165
  const decision = await deps.requestPermissionDecisionFromUi(
159
166
  ctx.ui,
160
167
  title,
@@ -27,6 +27,9 @@ export const PERMISSIONS_PROTOCOL_VERSION = 1;
27
27
  /** Emitted at `session_start`, after the service is published. */
28
28
  export const PERMISSIONS_READY_CHANNEL = "permissions:ready";
29
29
 
30
+ /** Emitted when a permission request is committed to the active UI prompt path. */
31
+ export const PERMISSIONS_UI_PROMPT_CHANNEL = "permissions:ui_prompt";
32
+
30
33
  /** Emitted after every permission gate resolution. */
31
34
  export const PERMISSIONS_DECISION_CHANNEL = "permissions:decision";
32
35
 
@@ -61,9 +64,63 @@ export type PermissionsRpcReply<T = void> =
61
64
 
62
65
  // ── permissions:ready ──────────────────────────────────────────────────────
63
66
 
64
- /** Payload emitted on `permissions:ready`. */
65
- export interface PermissionsReadyEvent {
66
- protocolVersion: number;
67
+ /**
68
+ * Payload emitted on `permissions:ready`.
69
+ *
70
+ * Intentionally empty: the channel is a readiness signal. Version negotiation
71
+ * lives in the RPC envelope (`PermissionsRpcReply`), not in broadcast payloads —
72
+ * the published types plus package semver define the broadcast contract.
73
+ */
74
+ export type PermissionsReadyEvent = Record<string, never>;
75
+
76
+ // ── permissions:ui_prompt ──────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Origin of a UI prompt.
80
+ *
81
+ * Forwarding is orthogonal to origin: a forwarded subagent prompt keeps its
82
+ * original source and is identified by a non-null `forwarding` field, not by a
83
+ * dedicated source value.
84
+ */
85
+ export type PermissionUiPromptSource =
86
+ | "tool_call"
87
+ | "skill_input"
88
+ | "skill_read"
89
+ | "rpc_prompt";
90
+
91
+ /** Forwarding context, present only when a prompt was forwarded from a non-UI subagent. */
92
+ export interface ForwardedPromptContext {
93
+ /** Requesting subagent's display name, when known. */
94
+ requesterAgentName: string | null;
95
+ /** Requesting subagent's session id, when known. */
96
+ requesterSessionId: string | null;
97
+ }
98
+
99
+ /**
100
+ * Payload emitted on `permissions:ui_prompt`, immediately before the active
101
+ * user-facing permission UI is shown.
102
+ *
103
+ * Lean by design: `surface`/`value` are the normalized display projection a
104
+ * notification consumer reads; `source` is the origin; `forwarding` is non-null
105
+ * only for forwarded subagent prompts. There is no `protocolVersion` — the
106
+ * published types plus package semver define the broadcast contract, and
107
+ * consumers should read defensively.
108
+ */
109
+ export interface PermissionUiPromptEvent {
110
+ /** Unique ID for the permission request being prompted. */
111
+ requestId: string;
112
+ /** Prompt origin. */
113
+ source: PermissionUiPromptSource;
114
+ /** Normalized display surface (e.g. "bash", "skill"), when known. */
115
+ surface: string | null;
116
+ /** Normalized display value (command, path, skill name, etc.), when known. */
117
+ value: string | null;
118
+ /** Agent name (when known). */
119
+ agentName: string | null;
120
+ /** Message displayed to the user. */
121
+ message: string;
122
+ /** Forwarding context, or null for a direct prompt. */
123
+ forwarding: ForwardedPromptContext | null;
67
124
  }
68
125
 
69
126
  // ── permissions:decision ───────────────────────────────────────────────────
@@ -164,10 +221,29 @@ export interface PermissionsPromptReplyData {
164
221
  * reacting to ready can immediately resolve `getPermissionsService()`.
165
222
  */
166
223
  export function emitReadyEvent(events: PermissionEventBus): void {
167
- const payload: PermissionsReadyEvent = {
168
- protocolVersion: PERMISSIONS_PROTOCOL_VERSION,
169
- };
170
- events.emit(PERMISSIONS_READY_CHANNEL, payload);
224
+ const payload: PermissionsReadyEvent = {};
225
+ try {
226
+ events.emit(PERMISSIONS_READY_CHANNEL, payload);
227
+ } catch {
228
+ // Broadcasts are best-effort. A throwing listener must not block the
229
+ // permission system from completing session startup.
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Emit a `permissions:ui_prompt` broadcast.
235
+ * Call immediately before invoking the active user-facing permission UI.
236
+ */
237
+ export function emitUiPromptEvent(
238
+ events: PermissionEventBus,
239
+ event: PermissionUiPromptEvent,
240
+ ): void {
241
+ try {
242
+ events.emit(PERMISSIONS_UI_PROMPT_CHANNEL, event);
243
+ } catch {
244
+ // UI-prompt broadcasts are observational. A consumer failure must not block
245
+ // the permission dialog itself.
246
+ }
171
247
  }
172
248
 
173
249
  /**
@@ -178,5 +254,10 @@ export function emitDecisionEvent(
178
254
  events: PermissionEventBus,
179
255
  event: PermissionDecisionEvent,
180
256
  ): void {
181
- events.emit(PERMISSIONS_DECISION_CHANNEL, event);
257
+ try {
258
+ events.emit(PERMISSIONS_DECISION_CHANNEL, event);
259
+ } catch {
260
+ // Broadcasts are best-effort. A throwing listener must not block the
261
+ // permission gate from resolving.
262
+ }
182
263
  }
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
 
3
3
  import type { PermissionDecisionState } from "./permission-dialog";
4
+ import type { PermissionUiPromptSource } from "./permission-events";
4
5
  import type { SubagentSessionRegistry } from "./subagent-registry";
5
6
 
6
7
  export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
@@ -38,6 +39,19 @@ const SESSION_FORWARDING_ROOT_DIRECTORY_NAME = "sessions";
38
39
  const SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME = "requests";
39
40
  const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
40
41
 
42
+ /**
43
+ * Display fields relayed from a forwarding child to the parent UI so the parent
44
+ * can emit a non-degraded `permissions:ui_prompt` event.
45
+ *
46
+ * Carried separately from the prompt message because the parent reconstructs
47
+ * the original event through `buildForwardedUiPrompt`, not from the message text.
48
+ */
49
+ export interface ForwardedPromptDisplay {
50
+ source: PermissionUiPromptSource;
51
+ surface: string | null;
52
+ value: string | null;
53
+ }
54
+
41
55
  export type ForwardedPermissionRequest = {
42
56
  id: string;
43
57
  createdAt: number;
@@ -45,6 +59,15 @@ export type ForwardedPermissionRequest = {
45
59
  targetSessionId: string;
46
60
  requesterAgentName: string;
47
61
  message: string;
62
+ /**
63
+ * Original prompt display fields, persisted so the parent emits a
64
+ * non-degraded event. Optional for version-skew tolerance: a parent on a
65
+ * newer version may read a request written by an older child during an
66
+ * upgrade, in which case the reader defaults `source` to `"tool_call"`.
67
+ */
68
+ source?: PermissionUiPromptSource;
69
+ surface?: string | null;
70
+ value?: string | null;
48
71
  };
49
72
 
50
73
  export type ForwardedPermissionResponse = {
@@ -1,15 +1,12 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { PermissionSystemExtensionConfig } from "./extension-config";
3
- import type { ForwardedPermissionLogger } from "./forwarded-permissions/io";
3
+ import type { ApprovalRequester } from "./forwarded-permissions/permission-forwarder";
4
+ import type { PermissionPromptDecision } from "./permission-dialog";
4
5
  import {
5
- confirmPermission,
6
- type PermissionForwardingDeps,
7
- } from "./forwarded-permissions/polling";
8
- import type {
9
- PermissionPromptDecision,
10
- RequestPermissionOptions,
11
- } from "./permission-dialog";
12
- import type { SubagentSessionRegistry } from "./subagent-registry";
6
+ emitUiPromptEvent,
7
+ type PermissionEventBus,
8
+ } from "./permission-events";
9
+ import { buildDirectUiPrompt } from "./permission-ui-prompt";
13
10
  import { shouldAutoApprovePermissionState } from "./yolo-mode";
14
11
 
15
12
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
@@ -43,27 +40,18 @@ export interface PermissionPrompterApi {
43
40
  * Dependencies required by PermissionPrompter.
44
41
  *
45
42
  * Keeps the prompter's external surface narrow: callers provide config
46
- * access, review-log writing, path constants, and the UI dialog function.
47
- * The prompter synthesises the PermissionForwardingDeps it needs internally.
43
+ * access, review-log writing, the UI-prompt event bus, and the forwarder
44
+ * that owns the UI/subagent-forwarding branching logic.
48
45
  */
49
46
  export interface PermissionPrompterDeps {
50
47
  /** Read current config for yolo-mode check (called at prompt time). */
51
48
  getConfig(): PermissionSystemExtensionConfig;
52
49
  /** Write structured entries to the permission review log. */
53
50
  writeReviewLog(event: string, details: Record<string, unknown>): void;
54
- /** Directory containing subagent session state. */
55
- subagentSessionsDir: string;
56
- /** Directory used for file-based permission forwarding requests/responses. */
57
- forwardingDir: string;
58
- /** In-process subagent session registry for detection and forwarding target resolution. */
59
- registry?: SubagentSessionRegistry;
60
- /** Show the interactive permission dialog in the UI. */
61
- requestPermissionDecisionFromUi(
62
- ui: ExtensionContext["ui"],
63
- title: string,
64
- message: string,
65
- options?: RequestPermissionOptions,
66
- ): Promise<PermissionPromptDecision>;
51
+ /** Event bus used for UI prompt broadcasts. */
52
+ events: PermissionEventBus;
53
+ /** Resolves the permission decision: direct UI dialog or forwarded to parent. */
54
+ forwarder: ApprovalRequester;
67
55
  }
68
56
 
69
57
  /**
@@ -91,11 +79,24 @@ export class PermissionPrompter implements PermissionPrompterApi {
91
79
 
92
80
  this.writeReviewEntry("permission_request.waiting", details);
93
81
 
94
- const decision = await confirmPermission(
82
+ // Build the event once. When this session has UI it broadcasts directly;
83
+ // when it does not (a forwarding subagent), the display fields ride along
84
+ // to the parent so the parent emits a non-degraded event from the
85
+ // forwarded path instead of here.
86
+ const uiPrompt = buildDirectUiPrompt(details);
87
+ if (ctx.hasUI) {
88
+ emitUiPromptEvent(this.deps.events, uiPrompt);
89
+ }
90
+
91
+ const decision = await this.deps.forwarder.requestApproval(
95
92
  ctx,
96
93
  details.message,
97
- this.buildForwardingDeps(),
98
94
  details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
95
+ {
96
+ source: uiPrompt.source,
97
+ surface: uiPrompt.surface,
98
+ value: uiPrompt.value,
99
+ },
99
100
  );
100
101
 
101
102
  this.writeReviewEntry(
@@ -137,34 +138,4 @@ export class PermissionPrompter implements PermissionPrompterApi {
137
138
  denialReason: details.denialReason ?? null,
138
139
  });
139
140
  }
140
-
141
- /**
142
- * Build a PermissionForwardingDeps to pass to confirmPermission.
143
- *
144
- * Yolo-mode is already handled at the prompter level, so shouldAutoApprove
145
- * returns false here (confirmPermission does not call it; only
146
- * processForwardedPermissionRequests does, and that has its own deps).
147
- *
148
- * The logger delegates writeReviewLog to deps and uses a no-op writeDebugLog
149
- * (trace-level forwarding debug is deferred — see open question in the plan).
150
- */
151
- private buildForwardingDeps(): PermissionForwardingDeps {
152
- const { deps } = this;
153
- const logger: ForwardedPermissionLogger = {
154
- // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
155
- writeReviewLog: deps.writeReviewLog,
156
- writeDebugLog: () => undefined,
157
- };
158
- return {
159
- forwardingDir: deps.forwardingDir,
160
- subagentSessionsDir: deps.subagentSessionsDir,
161
- registry: deps.registry,
162
- logger,
163
- // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
164
- writeReviewLog: deps.writeReviewLog,
165
- // eslint-disable-next-line @typescript-eslint/unbound-method -- same as above
166
- requestPermissionDecisionFromUi: deps.requestPermissionDecisionFromUi,
167
- shouldAutoApprove: () => false,
168
- };
169
- }
170
141
  }
@@ -0,0 +1,17 @@
1
+ import type { PermissionCheckResult } from "./types";
2
+
3
+ /**
4
+ * Resolves the effective permission for a surface/input, applying the current
5
+ * session rules internally.
6
+ *
7
+ * Collapses the `checkPermission` + `getSessionRuleset` relay that every gate
8
+ * previously threaded by hand: the ruleset was only ever fetched to be passed
9
+ * straight back into `checkPermission`, so the two are one operation.
10
+ */
11
+ export interface PermissionResolver {
12
+ resolve(
13
+ surface: string,
14
+ input: unknown,
15
+ agentName?: string,
16
+ ): PermissionCheckResult;
17
+ }
@@ -4,18 +4,28 @@ import {
4
4
  getActiveAgentName,
5
5
  getActiveAgentNameFromSystemPrompt,
6
6
  } from "./active-agent";
7
+ import type { AgentPrepSession } from "./agent-prep-session";
7
8
  import type { PermissionSystemExtensionConfig } from "./extension-config";
8
9
  import type { ExtensionPaths } from "./extension-paths";
9
10
  import type { ForwardingController } from "./forwarding-manager";
11
+ import type { GateHandlerSession } from "./gate-handler-session";
12
+ import type { GatePrompter } from "./gate-prompter";
10
13
  import type { PermissionPromptDecision } from "./permission-dialog";
11
14
  import type { PermissionManager } from "./permission-manager";
12
15
  import type { PromptPermissionDetails } from "./permission-prompter";
16
+ import type { PermissionResolver } from "./permission-resolver";
13
17
  import type { Rule } from "./rule";
14
18
  import { createPermissionManagerForCwd } from "./runtime";
15
19
  import type { SessionApproval } from "./session-approval";
20
+ import type { SessionApprovalRecorder } from "./session-approval-recorder";
21
+ import type { SessionLifecycleSession } from "./session-lifecycle-session";
16
22
  import type { SessionLogger } from "./session-logger";
17
23
  import { SessionRules } from "./session-rules";
18
24
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
25
+ import {
26
+ resolveToolPreviewLimits,
27
+ type ToolPreviewFormatterOptions,
28
+ } from "./tool-preview-formatter";
19
29
  import type { PermissionCheckResult, PermissionState } from "./types";
20
30
 
21
31
  /**
@@ -54,7 +64,15 @@ export interface PermissionSessionRuntimeDeps {
54
64
  * - `ForwardingController` — polling lifecycle
55
65
  * - `PermissionSessionRuntimeDeps` — config refresh + log delegates
56
66
  */
57
- export class PermissionSession {
67
+ export class PermissionSession
68
+ implements
69
+ PermissionResolver,
70
+ SessionApprovalRecorder,
71
+ GatePrompter,
72
+ GateHandlerSession,
73
+ AgentPrepSession,
74
+ SessionLifecycleSession
75
+ {
58
76
  private context: ExtensionContext | null = null;
59
77
  private permissionManager: PermissionManager;
60
78
  private readonly sessionRules = new SessionRules();
@@ -110,6 +128,24 @@ export class PermissionSession {
110
128
  );
111
129
  }
112
130
 
131
+ /**
132
+ * Resolve the effective permission for a surface/input, applying the current
133
+ * session rules. Composes `checkPermission` with `getSessionRuleset` so
134
+ * callers never thread the ruleset by hand.
135
+ */
136
+ resolve(
137
+ surface: string,
138
+ input: unknown,
139
+ agentName?: string,
140
+ ): PermissionCheckResult {
141
+ return this.checkPermission(
142
+ surface,
143
+ input,
144
+ agentName,
145
+ this.getSessionRuleset(),
146
+ );
147
+ }
148
+
113
149
  getToolPermission(toolName: string, agentName?: string): PermissionState {
114
150
  return this.permissionManager.getToolPermission(toolName, agentName);
115
151
  }
@@ -251,13 +287,25 @@ export class PermissionSession {
251
287
 
252
288
  // ── Infrastructure paths ───────────────────────────────────────────────
253
289
 
254
- getInfrastructureDirs(): readonly string[] {
255
- return this.paths.piInfrastructureDirs;
290
+ /**
291
+ * Combined infrastructure read directories: static paths from
292
+ * `ExtensionPaths` plus config-derived paths.
293
+ */
294
+ getInfrastructureReadDirs(): string[] {
295
+ return [
296
+ ...this.paths.piInfrastructureDirs,
297
+ ...(this.config.piInfrastructureReadPaths ?? []),
298
+ ];
256
299
  }
257
300
 
258
- /** Config-derived infrastructure read paths (current at call time). */
259
- getInfrastructureReadPaths(): string[] {
260
- return this.config.piInfrastructureReadPaths ?? [];
301
+ /**
302
+ * Resolved tool-preview formatter options from the current config.
303
+ *
304
+ * Replaces the handler's `resolveToolPreviewLimits(session.config)` reach
305
+ * so the pipeline reads a clean value rather than pulling raw config.
306
+ */
307
+ getToolPreviewLimits(): ToolPreviewFormatterOptions {
308
+ return resolveToolPreviewLimits(this.config);
261
309
  }
262
310
 
263
311
  // ── Prompting ──────────────────────────────────────────────────────────
@@ -275,8 +323,28 @@ export class PermissionSession {
275
323
  return this.runtimeDeps.promptPermission(ctx, details);
276
324
  }
277
325
 
278
- /** Generate a unique ID for a permission request. */
279
- createPermissionRequestId(prefix: string): string {
280
- return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
326
+ /**
327
+ * Whether an interactive confirmation is possible using the stored context.
328
+ * Returns `false` when no context is active (before `activate` is called).
329
+ * Implements {@link GatePrompter}.
330
+ */
331
+ canConfirm(): boolean {
332
+ return this.context !== null && this.canPrompt(this.context);
333
+ }
334
+
335
+ /**
336
+ * Prompt the user for a permission decision using the stored context.
337
+ * Throws if no context is active — `canConfirm()` guards this in normal use.
338
+ * Implements {@link GatePrompter}.
339
+ */
340
+ promptPermission(
341
+ details: PromptPermissionDetails,
342
+ ): Promise<PermissionPromptDecision> {
343
+ if (this.context === null) {
344
+ return Promise.reject(
345
+ new Error("promptPermission called before the session was activated"),
346
+ );
347
+ }
348
+ return this.prompt(this.context, details);
281
349
  }
282
350
  }