@gotgenes/pi-permission-system 10.0.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 (64) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  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/permission-forwarder.ts +549 -0
  8. package/src/forwarding-manager.ts +3 -7
  9. package/src/gate-handler-session.ts +13 -0
  10. package/src/gate-prompter.ts +14 -0
  11. package/src/handlers/before-agent-start.ts +2 -3
  12. package/src/handlers/gates/bash-command.ts +4 -18
  13. package/src/handlers/gates/bash-external-directory.ts +3 -15
  14. package/src/handlers/gates/bash-path.ts +3 -16
  15. package/src/handlers/gates/descriptor.ts +0 -28
  16. package/src/handlers/gates/path.ts +3 -15
  17. package/src/handlers/gates/runner.ts +142 -105
  18. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  19. package/src/handlers/gates/skill-input.ts +44 -0
  20. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  21. package/src/handlers/lifecycle.ts +9 -9
  22. package/src/handlers/permission-gate-handler.ts +34 -238
  23. package/src/index.ts +49 -69
  24. package/src/mcp-targets.ts +56 -46
  25. package/src/permission-prompter.ts +7 -58
  26. package/src/permission-resolver.ts +17 -0
  27. package/src/permission-session.ts +77 -9
  28. package/src/permissions-service.ts +53 -0
  29. package/src/service-lifecycle.ts +49 -0
  30. package/src/session-approval-recorder.ts +6 -0
  31. package/src/session-lifecycle-session.ts +24 -0
  32. package/src/tool-input-preview.ts +0 -62
  33. package/src/tool-input-prompt-formatters.ts +63 -0
  34. package/src/tool-preview-formatter.ts +6 -4
  35. package/test/decision-reporter.test.ts +112 -0
  36. package/test/denial-messages.test.ts +62 -0
  37. package/test/forwarding-manager.test.ts +26 -44
  38. package/test/handlers/before-agent-start.test.ts +45 -21
  39. package/test/handlers/external-directory-integration.test.ts +86 -22
  40. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  41. package/test/handlers/gates/bash-command.test.ts +49 -90
  42. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  43. package/test/handlers/gates/bash-path.test.ts +63 -148
  44. package/test/handlers/gates/path.test.ts +38 -105
  45. package/test/handlers/gates/runner.test.ts +150 -93
  46. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  47. package/test/handlers/gates/skill-input.test.ts +128 -0
  48. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  49. package/test/handlers/input.test.ts +1 -2
  50. package/test/handlers/lifecycle.test.ts +49 -33
  51. package/test/handlers/tool-call-events.test.ts +1 -1
  52. package/test/helpers/gate-fixtures.ts +147 -16
  53. package/test/helpers/handler-fixtures.ts +143 -27
  54. package/test/mcp-targets.test.ts +55 -0
  55. package/test/permission-forwarder.test.ts +295 -0
  56. package/test/permission-forwarding.test.ts +0 -282
  57. package/test/permission-prompter.test.ts +33 -44
  58. package/test/permission-session.test.ts +160 -27
  59. package/test/permissions-service.test.ts +151 -0
  60. package/test/runtime.test.ts +0 -4
  61. package/test/service-lifecycle.test.ts +162 -0
  62. package/test/tool-input-preview.test.ts +0 -111
  63. package/test/tool-input-prompt-formatters.test.ts +115 -0
  64. package/src/forwarded-permissions/polling.ts +0 -411
@@ -0,0 +1,120 @@
1
+ import { getNonEmptyString, toRecord } from "#src/common";
2
+ import type { PermissionResolver } from "#src/permission-resolver";
3
+ import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
4
+ import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
5
+ import {
6
+ ToolPreviewFormatter,
7
+ type ToolPreviewFormatterOptions,
8
+ } from "#src/tool-preview-formatter";
9
+ import { resolveBashCommandCheck } from "./bash-command";
10
+ import { describeBashExternalDirectoryGate } from "./bash-external-directory";
11
+ import { describeBashPathGate } from "./bash-path";
12
+ import { BashProgram } from "./bash-program";
13
+ import type { GateResult } from "./descriptor";
14
+ import { describeExternalDirectoryGate } from "./external-directory";
15
+ import { describePathGate } from "./path";
16
+ import type { GateRunner } from "./runner";
17
+ import { describeSkillReadGate } from "./skill-read";
18
+ import { describeToolGate } from "./tool";
19
+ import type { GateOutcome, ToolCallContext } from "./types";
20
+
21
+ /**
22
+ * Narrow interface the pipeline needs from its session-side dependency.
23
+ *
24
+ * Extends `PermissionResolver` (the `resolve` method gate factories use)
25
+ * with the three query methods needed to assemble gate inputs.
26
+ *
27
+ * `PermissionSession` satisfies this structurally at the construction call
28
+ * site; no `implements` clause is needed and would create a layer-inversion
29
+ * import from the domain module into the handler layer.
30
+ */
31
+ export interface ToolCallGateInputs extends PermissionResolver {
32
+ /** Active skill prompt entries for the skill-read gate. */
33
+ getActiveSkillEntries(): SkillPromptEntry[];
34
+ /** Combined infrastructure read directories (static + config-derived). */
35
+ getInfrastructureReadDirs(): string[];
36
+ /** Resolved tool-preview formatter options from the current config. */
37
+ getToolPreviewLimits(): ToolPreviewFormatterOptions;
38
+ }
39
+
40
+ /**
41
+ * Owns the ordered tool-call gate-producer assembly and the run loop.
42
+ *
43
+ * Constructed once in the composition root and injected into
44
+ * `PermissionGateHandler`. `evaluate(tcc, runner)` encapsulates:
45
+ * - bash-command extraction and single `BashProgram.parse` (#308)
46
+ * - `ToolPreviewFormatter` construction from `getToolPreviewLimits()`
47
+ * - infrastructure-dir list from `getInfrastructureReadDirs()`
48
+ * - all six gate producers in their prescribed order
49
+ * - the run loop that returns the first block outcome, or allow
50
+ */
51
+ export class ToolCallGatePipeline {
52
+ constructor(
53
+ private readonly inputs: ToolCallGateInputs,
54
+ private readonly customFormatters?: ToolInputFormatterLookup,
55
+ ) {}
56
+
57
+ async evaluate(
58
+ tcc: ToolCallContext,
59
+ runner: GateRunner,
60
+ ): Promise<GateOutcome> {
61
+ // Parse the bash command exactly once per evaluate; the three bash gates
62
+ // share this single BashProgram instead of each re-parsing (#308).
63
+ const command = getNonEmptyString(toRecord(tcc.input).command);
64
+ const bashProgram =
65
+ tcc.toolName === "bash" && command
66
+ ? await BashProgram.parse(command)
67
+ : null;
68
+
69
+ const formatter = new ToolPreviewFormatter(
70
+ this.inputs.getToolPreviewLimits(),
71
+ this.customFormatters,
72
+ );
73
+
74
+ const infraDirs = this.inputs.getInfrastructureReadDirs();
75
+
76
+ const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
77
+ () =>
78
+ describeSkillReadGate(tcc, () => this.inputs.getActiveSkillEntries()),
79
+ () => describePathGate(tcc, this.inputs),
80
+ () => describeExternalDirectoryGate(tcc, infraDirs),
81
+ () => describeBashExternalDirectoryGate(tcc, bashProgram, this.inputs),
82
+ () => describeBashPathGate(tcc, bashProgram, this.inputs),
83
+ () => {
84
+ // Bash commands may chain several sub-commands (`a && b`, `a | b`, …);
85
+ // evaluate each unit from the shared parse on the bash surface and
86
+ // select the most restrictive, rather than matching the whole program
87
+ // string (#301). Other tools evaluate their single input directly.
88
+ const toolCheck =
89
+ tcc.toolName === "bash" && bashProgram
90
+ ? resolveBashCommandCheck(
91
+ command ?? "",
92
+ bashProgram.commands(),
93
+ tcc.agentName ?? undefined,
94
+ this.inputs,
95
+ )
96
+ : this.inputs.resolve(
97
+ tcc.toolName,
98
+ tcc.input,
99
+ tcc.agentName ?? undefined,
100
+ );
101
+ const toolDescriptor = describeToolGate(tcc, toolCheck, formatter);
102
+ toolDescriptor.preCheck = toolCheck;
103
+ return toolDescriptor;
104
+ },
105
+ ];
106
+
107
+ for (const produce of gateProducers) {
108
+ const outcome = await runner.run(
109
+ await produce(),
110
+ tcc.agentName,
111
+ tcc.toolCallId,
112
+ );
113
+ if (outcome.action === "block") {
114
+ return outcome;
115
+ }
116
+ }
117
+
118
+ return { action: "allow" };
119
+ }
120
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
- import type { PermissionSession } from "#src/permission-session";
3
+ import type { ServiceLifecycle } from "#src/service-lifecycle";
4
+ import type { SessionLifecycleSession } from "#src/session-lifecycle-session";
4
5
  import { PERMISSION_SYSTEM_STATUS_KEY } from "#src/status";
5
6
 
6
7
  /** Minimal subset of SessionStartEvent used by this handler. */
@@ -18,15 +19,14 @@ interface ResourcesDiscoverPayload {
18
19
  *
19
20
  * Constructor deps:
20
21
  * - `session` — encapsulates all mutable session state
21
- * - `activateService` — publishes the process-global service for this session
22
- * (skipped for in-process subagent children) and emits the ready event
23
- * - `cleanupRpc` unsubscribes RPC handlers on shutdown
22
+ * - `serviceLifecycle` — owns the process-global service publication;
23
+ * `activate` publishes (skipped for registered subagent children) and emits
24
+ * the ready event; `teardown` unsubscribes all session listeners and unpublishes
24
25
  */
25
26
  export class SessionLifecycleHandler {
26
27
  constructor(
27
- private readonly session: PermissionSession,
28
- private readonly activateService: (ctx: ExtensionContext) => void,
29
- private readonly cleanupRpc: () => void,
28
+ private readonly session: SessionLifecycleSession,
29
+ private readonly serviceLifecycle: ServiceLifecycle,
30
30
  ) {}
31
31
 
32
32
  handleSessionStart(
@@ -55,7 +55,7 @@ export class SessionLifecycleHandler {
55
55
  // session id) is available, so an in-process subagent child can be
56
56
  // identified and excluded. Emitting ready here keeps the
57
57
  // service-resolvable-when-ready ordering contract.
58
- this.activateService(ctx);
58
+ this.serviceLifecycle.activate(ctx);
59
59
  return Promise.resolve();
60
60
  }
61
61
 
@@ -79,7 +79,7 @@ export class SessionLifecycleHandler {
79
79
  ctx.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
80
80
  }
81
81
  this.session.shutdown();
82
- this.cleanupRpc();
82
+ this.serviceLifecycle.teardown();
83
83
  return Promise.resolve();
84
84
  }
85
85
  }
@@ -3,40 +3,23 @@ import type {
3
3
  InputEventResult,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
 
6
- import { getNonEmptyString, toRecord } from "#src/common";
7
- import {
8
- emitDecisionEvent,
9
- type PermissionEventBus,
10
- } from "#src/permission-events";
11
- import { applyPermissionGate } from "#src/permission-gate";
12
- import type { PromptPermissionDetails } from "#src/permission-prompter";
6
+ import { toRecord } from "#src/common";
7
+ import type { GateHandlerSession } from "#src/gate-handler-session";
13
8
  import {
14
9
  formatMissingToolNameReason,
15
- formatSkillAskPrompt,
16
10
  formatUnknownToolReason,
17
11
  } from "#src/permission-prompts";
18
- import type { PermissionSession } from "#src/permission-session";
19
- import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
20
- import {
21
- resolveToolPreviewLimits,
22
- ToolPreviewFormatter,
23
- } from "#src/tool-preview-formatter";
24
12
  import {
25
13
  checkRequestedToolRegistration,
26
14
  getToolNameFromValue,
27
15
  type ToolRegistry,
28
16
  } from "#src/tool-registry";
29
- import { resolveBashCommandCheck } from "./gates/bash-command";
30
- import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
31
- import { describeBashPathGate } from "./gates/bash-path";
32
- import { BashProgram } from "./gates/bash-program";
33
- import type { GateResult, GateRunnerDeps } from "./gates/descriptor";
34
- import { isGateBypass } from "./gates/descriptor";
35
- import { describeExternalDirectoryGate } from "./gates/external-directory";
36
- import { describePathGate } from "./gates/path";
37
- import { runGateCheck } from "./gates/runner";
38
- import { describeSkillReadGate } from "./gates/skill-read";
39
- import { describeToolGate } from "./gates/tool";
17
+ import type { GateRunner } from "./gates/runner";
18
+ import type {
19
+ GateNotifier,
20
+ SkillInputGatePipeline,
21
+ } from "./gates/skill-input-gate-pipeline";
22
+ import type { ToolCallGatePipeline } from "./gates/tool-call-gate-pipeline";
40
23
  import type { ToolCallContext } from "./gates/types";
41
24
 
42
25
  /** Minimal subset of InputEvent used by handleInput. */
@@ -48,16 +31,19 @@ interface InputPayload {
48
31
  * Handles permission gate events: tool_call and input.
49
32
  *
50
33
  * Constructor deps:
51
- * - `session` — encapsulates all mutable session state and permission operations
52
- * - `events` — event bus for emitting permissions:decision broadcasts
34
+ * - `session` — narrow two-method context role: bind per-event context, resolve agent name
53
35
  * - `toolRegistry` — Pi tool API subset (getAll + setActive)
36
+ * - `pipeline` — owns tool-call gate-producer assembly and the run loop
37
+ * - `skillInputPipeline` — owns skill-input gate assembly (pre-check, notify, run)
38
+ * - `runner` — pre-built gate runner (constructed in the composition root)
54
39
  */
55
40
  export class PermissionGateHandler {
56
41
  constructor(
57
- private readonly session: PermissionSession,
58
- private readonly events: PermissionEventBus,
42
+ private readonly session: GateHandlerSession,
59
43
  private readonly toolRegistry: ToolRegistry,
60
- private readonly customFormatters?: ToolInputFormatterLookup,
44
+ private readonly pipeline: ToolCallGatePipeline,
45
+ private readonly skillInputPipeline: SkillInputGatePipeline,
46
+ private readonly runner: GateRunner,
61
47
  ) {}
62
48
 
63
49
  async handleToolCall(
@@ -88,138 +74,10 @@ export class PermissionGateHandler {
88
74
  cwd: ctx.cwd,
89
75
  };
90
76
 
91
- // Parse the bash command exactly once per tool_call; the three bash gates
92
- // share this single BashProgram instead of each re-parsing (#308).
93
- const command = getNonEmptyString(toRecord(tcc.input).command);
94
- const bashProgram =
95
- tcc.toolName === "bash" && command
96
- ? await BashProgram.parse(command)
97
- : null;
98
-
99
- // ── Shared gate adapter closures ─────────────────────────────────────
100
- const canConfirm = () => this.session.canPrompt(ctx);
101
- const promptPermission = (details: PromptPermissionDetails) =>
102
- this.session.prompt(ctx, details);
103
- const emitDecision: GateRunnerDeps["emitDecision"] = (e) =>
104
- emitDecisionEvent(this.events, e);
105
- // eslint-disable-next-line @typescript-eslint/unbound-method -- logger.review is a plain function closure; no this-binding issue
106
- const writeReviewLog = this.session.logger.review;
107
- const checkPermission: GateRunnerDeps["checkPermission"] = (
108
- surface,
109
- input,
110
- agent,
111
- sessionRules,
112
- ) => this.session.checkPermission(surface, input, agent, sessionRules);
113
- const getSessionRuleset = () => this.session.getSessionRuleset();
114
- const recordSessionApproval: GateRunnerDeps["recordSessionApproval"] = (
115
- approval,
116
- ) => this.session.recordSessionApproval(approval);
117
-
118
- // ── Shared runner deps (built once, reused for all gates) ────────────
119
- const runnerDeps: GateRunnerDeps = {
120
- checkPermission,
121
- getSessionRuleset,
122
- recordSessionApproval,
123
- writeReviewLog,
124
- emitDecision,
125
- canConfirm,
126
- promptPermission,
127
- };
128
-
129
- // ── Unified gate executor ─────────────────────────────────────────────
130
- // Handles the bypass log/emit branch, calls runGateCheck for descriptors,
131
- // and returns a block result or undefined (allow / no-op).
132
- const runGate = async (
133
- gate: GateResult,
134
- ): Promise<{ block: true; reason: string } | undefined> => {
135
- if (!gate) {
136
- return undefined;
137
- }
138
- if (isGateBypass(gate)) {
139
- if (gate.log) {
140
- writeReviewLog(gate.log.event, gate.log.details);
141
- }
142
- if (gate.decision) {
143
- emitDecision(gate.decision);
144
- }
145
- return undefined;
146
- }
147
- const result = await runGateCheck(
148
- gate,
149
- tcc.agentName,
150
- tcc.toolCallId,
151
- runnerDeps,
152
- );
153
- return result.action === "block"
154
- ? { block: true, reason: result.reason }
155
- : undefined;
156
- };
157
-
158
- const formatter = new ToolPreviewFormatter(
159
- resolveToolPreviewLimits(this.session.config),
160
- this.customFormatters,
161
- );
162
-
163
- // ── Ordered gate pipeline ─────────────────────────────────────────────
164
- // infraDirs is computed once, outside the pipeline, exactly as before.
165
- const infraDirs = [
166
- ...this.session.getInfrastructureDirs(),
167
- ...this.session.getInfrastructureReadPaths(),
168
- ];
169
-
170
- const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
171
- () =>
172
- describeSkillReadGate(tcc, () => this.session.getActiveSkillEntries()),
173
- () => describePathGate(tcc, checkPermission, getSessionRuleset),
174
- () => describeExternalDirectoryGate(tcc, infraDirs),
175
- () =>
176
- describeBashExternalDirectoryGate(
177
- tcc,
178
- bashProgram,
179
- checkPermission,
180
- getSessionRuleset,
181
- ),
182
- () =>
183
- describeBashPathGate(
184
- tcc,
185
- bashProgram,
186
- checkPermission,
187
- getSessionRuleset,
188
- ),
189
- () => {
190
- // Bash commands may chain several sub-commands (`a && b`, `a | b`, …);
191
- // evaluate each unit from the shared parse on the bash surface and
192
- // select the most restrictive, rather than matching the whole program
193
- // string (#301). Other tools evaluate their single input directly.
194
- const toolCheck =
195
- tcc.toolName === "bash" && bashProgram
196
- ? resolveBashCommandCheck(
197
- command ?? "",
198
- bashProgram.commands(),
199
- tcc.agentName ?? undefined,
200
- getSessionRuleset(),
201
- checkPermission,
202
- )
203
- : checkPermission(
204
- tcc.toolName,
205
- tcc.input,
206
- tcc.agentName ?? undefined,
207
- getSessionRuleset(),
208
- );
209
- const toolDescriptor = describeToolGate(tcc, toolCheck, formatter);
210
- toolDescriptor.preCheck = toolCheck;
211
- return toolDescriptor;
212
- },
213
- ];
214
-
215
- for (const produce of gateProducers) {
216
- const blocked = await runGate(await produce());
217
- if (blocked) {
218
- return blocked;
219
- }
220
- }
221
-
222
- return {};
77
+ const outcome = await this.pipeline.evaluate(tcc, this.runner);
78
+ return outcome.action === "block"
79
+ ? { block: true, reason: outcome.reason }
80
+ : {};
223
81
  }
224
82
 
225
83
  async handleInput(
@@ -234,84 +92,22 @@ export class PermissionGateHandler {
234
92
  }
235
93
 
236
94
  const agentName = this.session.resolveAgentName(ctx);
237
- const check = this.session.checkPermission(
238
- "skill",
239
- { name: skillName },
240
- agentName ?? undefined,
241
- );
242
-
243
- if (check.state === "deny" && ctx.hasUI) {
244
- const notifyMessage = agentName
245
- ? `Skill '${skillName}' is not permitted for agent '${agentName}'.`
246
- : `Skill '${skillName}' is not permitted by the current skill policy.`;
247
- ctx.ui.notify(notifyMessage, "warning");
248
- }
249
-
250
- const skillInputMessage = formatSkillAskPrompt(
95
+ const notifier: GateNotifier = {
96
+ warn: (message) => {
97
+ if (ctx.hasUI) {
98
+ ctx.ui.notify(message, "warning");
99
+ }
100
+ },
101
+ };
102
+ const outcome = await this.skillInputPipeline.evaluate(
251
103
  skillName,
252
- agentName ?? undefined,
104
+ agentName,
105
+ notifier,
106
+ this.runner,
253
107
  );
254
- const skillInputCanConfirm = this.session.canPrompt(ctx);
255
- let skillInputAutoApproved = false;
256
- const skillInputGate = await applyPermissionGate({
257
- state: check.state,
258
- canConfirm: skillInputCanConfirm,
259
- promptForApproval: async () => {
260
- const decision = await this.session.prompt(ctx, {
261
- requestId: this.session.createPermissionRequestId("skill-input"),
262
- source: "skill_input",
263
- agentName,
264
- message: skillInputMessage,
265
- skillName,
266
- });
267
- skillInputAutoApproved = decision.autoApproved === true;
268
- return decision;
269
- },
270
- // eslint-disable-next-line @typescript-eslint/unbound-method -- logger.review is a plain function closure; no this-binding issue
271
- writeLog: this.session.logger.review,
272
- logContext: {
273
- source: "skill_input",
274
- skillName,
275
- agentName,
276
- message: skillInputMessage,
277
- },
278
- messages: {
279
- denyReason: skillInputMessage,
280
- unavailableReason:
281
- "Skill requires approval, but no interactive UI is available.",
282
- userDeniedReason: () => "User denied skill.",
283
- },
284
- });
285
-
286
- emitDecisionEvent(this.events, {
287
- surface: "skill",
288
- value: skillName,
289
- result: skillInputGate.action === "allow" ? "allow" : "deny",
290
- /* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive fallback; TypeScript narrows check.state before the ternary's else branch */
291
- resolution:
292
- check.state === "allow"
293
- ? "policy_allow"
294
- : check.state === "deny"
295
- ? "policy_deny"
296
- : skillInputGate.action === "allow"
297
- ? skillInputAutoApproved
298
- ? "auto_approved"
299
- : "user_approved"
300
- : skillInputCanConfirm
301
- ? "user_denied"
302
- : "confirmation_unavailable",
303
- /* eslint-enable @typescript-eslint/no-unnecessary-condition */
304
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
305
- origin: check.origin ?? null,
306
- agentName: agentName ?? null,
307
- matchedPattern: check.matchedPattern ?? null,
308
- });
309
-
310
- if (skillInputGate.action === "block") {
311
- return { action: "handled" };
312
- }
313
-
314
- return { action: "continue" };
108
+ return outcome.action === "block"
109
+ ? { action: "handled" }
110
+ : { action: "continue" };
315
111
  }
316
112
  }
317
113
 
package/src/index.ts CHANGED
@@ -1,39 +1,35 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionContext,
4
- } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
2
  import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
6
3
  import { registerPermissionSystemCommand } from "./config-modal";
7
4
  import { getGlobalConfigPath } from "./config-paths";
8
- import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
5
+ import { GateDecisionReporter } from "./decision-reporter";
6
+ import {
7
+ PermissionForwarder,
8
+ type PermissionForwarderDeps,
9
+ } from "./forwarded-permissions/permission-forwarder";
9
10
  import { ForwardingManager } from "./forwarding-manager";
10
11
  import {
11
12
  AgentPrepHandler,
12
13
  PermissionGateHandler,
13
14
  SessionLifecycleHandler,
14
15
  } from "./handlers";
15
- import { buildInputForSurface } from "./input-normalizer";
16
+ import { GateRunner } from "./handlers/gates/runner";
17
+ import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeline";
18
+ import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
16
19
  import { requestPermissionDecisionFromUi } from "./permission-dialog";
17
20
  import { registerPermissionRpcHandlers } from "./permission-event-rpc";
18
- import { emitReadyEvent } from "./permission-events";
19
21
  import { PermissionPrompter } from "./permission-prompter";
20
22
  import { PermissionSession } from "./permission-session";
23
+ import { LocalPermissionsService } from "./permissions-service";
21
24
  import {
22
25
  createExtensionRuntime,
23
26
  logResolvedConfigPaths,
24
27
  refreshExtensionConfig,
25
28
  saveExtensionConfig,
26
29
  } from "./runtime";
27
- import type { PermissionsService } from "./service";
28
- import {
29
- publishPermissionsService,
30
- unpublishPermissionsService,
31
- } from "./service";
30
+ import { PermissionServiceLifecycle } from "./service-lifecycle";
32
31
  import { createSessionLogger } from "./session-logger";
33
- import {
34
- isRegisteredSubagentChild,
35
- isSubagentExecutionContext,
36
- } from "./subagent-context";
32
+ import { isSubagentExecutionContext } from "./subagent-context";
37
33
  import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
38
34
  import { getSubagentSessionRegistry } from "./subagent-registry";
39
35
  import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
@@ -48,17 +44,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
48
44
  const formatterRegistry = new ToolInputFormatterRegistry();
49
45
  registerBuiltinToolInputFormatters(formatterRegistry);
50
46
 
51
- const prompter = new PermissionPrompter({
52
- getConfig: () => runtime.config,
53
- writeReviewLog: runtime.writeReviewLog.bind(runtime),
54
- subagentSessionsDir: runtime.subagentSessionsDir,
55
- forwardingDir: runtime.forwardingDir,
56
- registry: subagentRegistry,
57
- events: pi.events,
58
- requestPermissionDecisionFromUi,
59
- });
60
-
61
- const forwardingDeps: PermissionForwardingDeps = {
47
+ const forwardingDeps: PermissionForwarderDeps = {
62
48
  forwardingDir: runtime.forwardingDir,
63
49
  subagentSessionsDir: runtime.subagentSessionsDir,
64
50
  registry: subagentRegistry,
@@ -72,6 +58,14 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
72
58
  shouldAutoApprove: () =>
73
59
  shouldAutoApprovePermissionState("ask", runtime.config),
74
60
  };
61
+ const forwarder = new PermissionForwarder(forwardingDeps);
62
+
63
+ const prompter = new PermissionPrompter({
64
+ getConfig: () => runtime.config,
65
+ writeReviewLog: runtime.writeReviewLog.bind(runtime),
66
+ events: pi.events,
67
+ forwarder,
68
+ });
75
69
 
76
70
  refreshExtensionConfig(runtime);
77
71
 
@@ -80,7 +74,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
80
74
  createSessionLogger(runtime),
81
75
  new ForwardingManager(
82
76
  runtime.subagentSessionsDir,
83
- forwardingDeps,
77
+ forwarder,
84
78
  subagentRegistry,
85
79
  ),
86
80
  {
@@ -119,36 +113,11 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
119
113
  writeReviewLog: runtime.writeReviewLog.bind(runtime),
120
114
  });
121
115
 
122
- const permissionsService: PermissionsService = {
123
- checkPermission(surface, value, agentName) {
124
- const input = buildInputForSurface(surface, value);
125
- const sessionRules = runtime.sessionRules.getRuleset();
126
- return runtime.permissionManager.checkPermission(
127
- surface,
128
- input,
129
- agentName,
130
- sessionRules,
131
- );
132
- },
133
- getToolPermission(toolName, agentName) {
134
- return runtime.permissionManager.getToolPermission(toolName, agentName);
135
- },
136
- registerToolInputFormatter(toolName, formatter) {
137
- return formatterRegistry.register(toolName, formatter);
138
- },
139
- };
140
-
141
- // Publish the service to the process-global slot only when this instance is
142
- // not an in-process subagent child, then emit ready. Deferred to
143
- // session_start (vs. factory init) because identifying a child requires the
144
- // session id from ctx, which the factory body does not have. A registered
145
- // child therefore never clobbers the parent's published service. See #302.
146
- const activateServiceForSession = (ctx: ExtensionContext): void => {
147
- if (!isRegisteredSubagentChild(ctx, subagentRegistry)) {
148
- publishPermissionsService(permissionsService);
149
- }
150
- emitReadyEvent(pi.events);
151
- };
116
+ const permissionsService = new LocalPermissionsService(
117
+ runtime.permissionManager,
118
+ runtime.sessionRules,
119
+ formatterRegistry,
120
+ );
152
121
 
153
122
  // Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
154
123
  // sessions register/unregister without the core calling us (ADR 0002).
@@ -157,27 +126,38 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
157
126
  subagentRegistry,
158
127
  );
159
128
 
129
+ // PermissionServiceLifecycle owns the process-global service publication:
130
+ // activate() publishes (skipped for registered subagent children — see #302)
131
+ // and emits ready; teardown() unsubscribes all session listeners and
132
+ // unpublishes. Deferred to session_start because identifying a child
133
+ // requires the session id from ctx, unavailable at factory-init time.
134
+ const serviceLifecycle = new PermissionServiceLifecycle(
135
+ permissionsService,
136
+ subagentRegistry,
137
+ pi.events,
138
+ [rpcHandles.unsubCheck, rpcHandles.unsubPrompt, unsubSubagentLifecycle],
139
+ );
140
+
160
141
  const toolRegistry = {
161
142
  getAll: () => pi.getAllTools(),
162
143
  setActive: (names: string[]) => pi.setActiveTools(names),
163
144
  };
164
145
 
165
- const lifecycle = new SessionLifecycleHandler(
146
+ const lifecycle = new SessionLifecycleHandler(session, serviceLifecycle);
147
+ const agentPrep = new AgentPrepHandler(session, toolRegistry);
148
+ const reporter = new GateDecisionReporter(session.logger, pi.events);
149
+ const gateRunner = new GateRunner(session, session, session, reporter);
150
+ const toolCallGatePipeline = new ToolCallGatePipeline(
166
151
  session,
167
- activateServiceForSession,
168
- () => {
169
- rpcHandles.unsubCheck();
170
- rpcHandles.unsubPrompt();
171
- unsubSubagentLifecycle();
172
- unpublishPermissionsService(permissionsService);
173
- },
152
+ formatterRegistry,
174
153
  );
175
- const agentPrep = new AgentPrepHandler(session, toolRegistry);
154
+ const skillInputGatePipeline = new SkillInputGatePipeline(session);
176
155
  const gates = new PermissionGateHandler(
177
156
  session,
178
- pi.events,
179
157
  toolRegistry,
180
- formatterRegistry,
158
+ toolCallGatePipeline,
159
+ skillInputGatePipeline,
160
+ gateRunner,
181
161
  );
182
162
 
183
163
  pi.on("session_start", (event, ctx) =>