@komarspn/pi-permission-system 16.0.2

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 (203) hide show
  1. package/CHANGELOG.md +2234 -0
  2. package/LICENSE +21 -0
  3. package/README.md +158 -0
  4. package/config/config.example.json +39 -0
  5. package/package.json +82 -0
  6. package/schemas/permissions.schema.json +158 -0
  7. package/src/active-agent.ts +72 -0
  8. package/src/async-cache.ts +21 -0
  9. package/src/bash-arity.ts +210 -0
  10. package/src/builtin-tool-input-formatters.ts +82 -0
  11. package/src/canonicalize-path.ts +30 -0
  12. package/src/common.ts +121 -0
  13. package/src/config-loader.ts +432 -0
  14. package/src/config-modal.ts +259 -0
  15. package/src/config-paths.ts +47 -0
  16. package/src/config-reporter.ts +34 -0
  17. package/src/config-store.ts +222 -0
  18. package/src/decision-audit.ts +75 -0
  19. package/src/decision-reporter.ts +41 -0
  20. package/src/denial-messages.ts +232 -0
  21. package/src/expand-home.ts +28 -0
  22. package/src/extension-config.ts +79 -0
  23. package/src/extension-paths.ts +66 -0
  24. package/src/forwarded-permissions/io.ts +404 -0
  25. package/src/forwarded-permissions/permission-forwarder.ts +580 -0
  26. package/src/forwarding-manager.ts +74 -0
  27. package/src/gate-prompter.ts +12 -0
  28. package/src/handlers/before-agent-start.ts +94 -0
  29. package/src/handlers/gates/bash-command.ts +75 -0
  30. package/src/handlers/gates/bash-external-directory.ts +127 -0
  31. package/src/handlers/gates/bash-path-extractor.ts +15 -0
  32. package/src/handlers/gates/bash-path.ts +152 -0
  33. package/src/handlers/gates/bash-program.ts +1143 -0
  34. package/src/handlers/gates/bash-token-classification.ts +105 -0
  35. package/src/handlers/gates/candidate-check.ts +32 -0
  36. package/src/handlers/gates/descriptor.ts +81 -0
  37. package/src/handlers/gates/external-directory-messages.ts +20 -0
  38. package/src/handlers/gates/external-directory.ts +133 -0
  39. package/src/handlers/gates/helpers.ts +76 -0
  40. package/src/handlers/gates/path.ts +91 -0
  41. package/src/handlers/gates/runner.ts +186 -0
  42. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  43. package/src/handlers/gates/skill-input.ts +46 -0
  44. package/src/handlers/gates/skill-read.ts +87 -0
  45. package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
  46. package/src/handlers/gates/tool.ts +102 -0
  47. package/src/handlers/gates/types.ts +13 -0
  48. package/src/handlers/index.ts +3 -0
  49. package/src/handlers/lifecycle.ts +95 -0
  50. package/src/handlers/permission-gate-handler.ts +190 -0
  51. package/src/handlers/tool-call-boundary.ts +91 -0
  52. package/src/index.ts +225 -0
  53. package/src/input-normalizer.ts +157 -0
  54. package/src/logging.ts +113 -0
  55. package/src/mcp-targets.ts +170 -0
  56. package/src/node-modules-discovery.ts +76 -0
  57. package/src/normalize.ts +43 -0
  58. package/src/path-utils.ts +355 -0
  59. package/src/pattern-suggest.ts +132 -0
  60. package/src/permission-dialog.ts +138 -0
  61. package/src/permission-event-rpc.ts +223 -0
  62. package/src/permission-events.ts +266 -0
  63. package/src/permission-forwarding.ts +188 -0
  64. package/src/permission-gate.ts +94 -0
  65. package/src/permission-manager.ts +392 -0
  66. package/src/permission-merge.ts +32 -0
  67. package/src/permission-prompter.ts +142 -0
  68. package/src/permission-prompts.ts +93 -0
  69. package/src/permission-resolver.ts +109 -0
  70. package/src/permission-session.ts +189 -0
  71. package/src/permission-ui-prompt.ts +127 -0
  72. package/src/permissions-service.ts +63 -0
  73. package/src/persistent-approval-recorder.ts +139 -0
  74. package/src/policy-loader.ts +350 -0
  75. package/src/prompting-gateway.ts +104 -0
  76. package/src/rule.ts +188 -0
  77. package/src/scope-merge.ts +72 -0
  78. package/src/service-lifecycle.ts +49 -0
  79. package/src/service.ts +163 -0
  80. package/src/session-approval-recorder.ts +6 -0
  81. package/src/session-approval.ts +43 -0
  82. package/src/session-logger.ts +91 -0
  83. package/src/session-rules.ts +79 -0
  84. package/src/skill-prompt-sanitizer.ts +292 -0
  85. package/src/status.ts +35 -0
  86. package/src/subagent-context.ts +104 -0
  87. package/src/subagent-lifecycle-events.ts +72 -0
  88. package/src/subagent-registry.ts +105 -0
  89. package/src/synthesize.ts +92 -0
  90. package/src/system-prompt-sanitizer.ts +274 -0
  91. package/src/tool-access-extractor-registry.ts +68 -0
  92. package/src/tool-input-formatter-registry.ts +67 -0
  93. package/src/tool-input-preview.ts +34 -0
  94. package/src/tool-input-prompt-formatters.ts +63 -0
  95. package/src/tool-preview-formatter.ts +207 -0
  96. package/src/tool-registry.ts +148 -0
  97. package/src/types.ts +64 -0
  98. package/src/wildcard-matcher.ts +120 -0
  99. package/src/yolo-mode.ts +30 -0
  100. package/test/active-agent.test.ts +155 -0
  101. package/test/async-cache.test.ts +48 -0
  102. package/test/bash-arity.test.ts +144 -0
  103. package/test/bash-external-directory.test.ts +956 -0
  104. package/test/builtin-tool-input-formatters.test.ts +109 -0
  105. package/test/canonicalize-path.test.ts +93 -0
  106. package/test/common.test.ts +287 -0
  107. package/test/composition-root.test.ts +603 -0
  108. package/test/config-loader.test.ts +740 -0
  109. package/test/config-modal.test.ts +320 -0
  110. package/test/config-paths.test.ts +83 -0
  111. package/test/config-pipeline.test.ts +90 -0
  112. package/test/config-reporter.test.ts +147 -0
  113. package/test/config-store.test.ts +466 -0
  114. package/test/decision-audit.test.ts +72 -0
  115. package/test/decision-reporter.test.ts +112 -0
  116. package/test/denial-messages.test.ts +656 -0
  117. package/test/detect-permissive-bash-fallback.test.ts +56 -0
  118. package/test/expand-home.test.ts +93 -0
  119. package/test/extension-config.test.ts +129 -0
  120. package/test/extension-paths.test.ts +108 -0
  121. package/test/forwarded-permissions/io.test.ts +251 -0
  122. package/test/forwarding-manager.test.ts +194 -0
  123. package/test/handlers/before-agent-start.test.ts +317 -0
  124. package/test/handlers/external-directory-integration.test.ts +623 -0
  125. package/test/handlers/external-directory-session-dedup.test.ts +430 -0
  126. package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
  127. package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
  128. package/test/handlers/gates/bash-command.test.ts +191 -0
  129. package/test/handlers/gates/bash-external-directory.test.ts +269 -0
  130. package/test/handlers/gates/bash-path.test.ts +337 -0
  131. package/test/handlers/gates/bash-program.test.ts +410 -0
  132. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  133. package/test/handlers/gates/candidate-check.test.ts +52 -0
  134. package/test/handlers/gates/external-directory-messages.test.ts +61 -0
  135. package/test/handlers/gates/external-directory.test.ts +259 -0
  136. package/test/handlers/gates/helpers.test.ts +177 -0
  137. package/test/handlers/gates/path.test.ts +294 -0
  138. package/test/handlers/gates/runner.test.ts +447 -0
  139. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  140. package/test/handlers/gates/skill-input.test.ts +131 -0
  141. package/test/handlers/gates/skill-read.test.ts +158 -0
  142. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
  143. package/test/handlers/gates/tool.test.ts +223 -0
  144. package/test/handlers/input-events.test.ts +168 -0
  145. package/test/handlers/input.test.ts +199 -0
  146. package/test/handlers/lifecycle.test.ts +221 -0
  147. package/test/handlers/tool-call-boundary.test.ts +145 -0
  148. package/test/handlers/tool-call-events.test.ts +277 -0
  149. package/test/handlers/tool-call.test.ts +395 -0
  150. package/test/handlers/validate-requested-tool.test.ts +92 -0
  151. package/test/helpers/gate-fixtures.ts +323 -0
  152. package/test/helpers/handler-fixtures.ts +335 -0
  153. package/test/helpers/make-fake-pi.ts +100 -0
  154. package/test/helpers/manager-harness.ts +112 -0
  155. package/test/helpers/session-fixtures.ts +204 -0
  156. package/test/input-normalizer.test.ts +367 -0
  157. package/test/logging.test.ts +51 -0
  158. package/test/mcp-targets.test.ts +233 -0
  159. package/test/node-modules-discovery.test.ts +97 -0
  160. package/test/normalize.test.ts +247 -0
  161. package/test/path-utils.test.ts +650 -0
  162. package/test/pattern-suggest.test.ts +248 -0
  163. package/test/permission-dialog.test.ts +241 -0
  164. package/test/permission-event-rpc.test.ts +541 -0
  165. package/test/permission-events.test.ts +402 -0
  166. package/test/permission-forwarder.test.ts +369 -0
  167. package/test/permission-forwarding.test.ts +315 -0
  168. package/test/permission-gate.test.ts +305 -0
  169. package/test/permission-manager-unified.test.ts +3368 -0
  170. package/test/permission-merge.test.ts +61 -0
  171. package/test/permission-prompter.test.ts +518 -0
  172. package/test/permission-prompts.test.ts +363 -0
  173. package/test/permission-resolver.test.ts +265 -0
  174. package/test/permission-session.test.ts +363 -0
  175. package/test/permission-ui-prompt.test.ts +146 -0
  176. package/test/permissions-service.test.ts +177 -0
  177. package/test/persistent-approval-recorder.test.ts +133 -0
  178. package/test/pi-infrastructure-read.test.ts +369 -0
  179. package/test/policy-loader.test.ts +561 -0
  180. package/test/prompting-gateway.test.ts +230 -0
  181. package/test/rule.test.ts +604 -0
  182. package/test/scope-merge.test.ts +116 -0
  183. package/test/service-lifecycle.test.ts +163 -0
  184. package/test/service.test.ts +308 -0
  185. package/test/session-approval.test.ts +75 -0
  186. package/test/session-logger.test.ts +200 -0
  187. package/test/session-rules.test.ts +304 -0
  188. package/test/session-start.test.ts +112 -0
  189. package/test/skill-prompt-sanitizer.test.ts +374 -0
  190. package/test/status.test.ts +10 -0
  191. package/test/subagent-context.test.ts +326 -0
  192. package/test/subagent-lifecycle-events.test.ts +132 -0
  193. package/test/subagent-registry.test.ts +145 -0
  194. package/test/synthesize.test.ts +300 -0
  195. package/test/system-prompt-sanitizer.test.ts +382 -0
  196. package/test/tool-access-extractor-registry.test.ts +77 -0
  197. package/test/tool-input-formatter-registry.test.ts +75 -0
  198. package/test/tool-input-preview.test.ts +129 -0
  199. package/test/tool-input-prompt-formatters.test.ts +115 -0
  200. package/test/tool-preview-formatter.test.ts +458 -0
  201. package/test/tool-registry.test.ts +197 -0
  202. package/test/wildcard-matcher.test.ts +424 -0
  203. package/test/yolo-mode.test.ts +188 -0
@@ -0,0 +1,95 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { DecisionSummaryWriter } from "#src/decision-audit";
4
+ import type { PermissionResolver } from "#src/permission-resolver";
5
+ import type { PermissionSession } from "#src/permission-session";
6
+ import type { ServiceLifecycle } from "#src/service-lifecycle";
7
+ import type { SessionLogger } from "#src/session-logger";
8
+ import { PERMISSION_SYSTEM_STATUS_KEY } from "#src/status";
9
+
10
+ /** Minimal subset of SessionStartEvent used by this handler. */
11
+ interface SessionStartPayload {
12
+ reason: string;
13
+ }
14
+
15
+ /** Minimal subset of ResourcesDiscoverEvent used by this handler. */
16
+ interface ResourcesDiscoverPayload {
17
+ reason: string;
18
+ }
19
+
20
+ /**
21
+ * Handles session lifecycle events: start, reload, and shutdown.
22
+ *
23
+ * Constructor deps:
24
+ * - `session` — encapsulates all mutable session state and lifecycle operations
25
+ * - `resolver` — owns permission-query surface: `getConfigIssues`
26
+ * - `serviceLifecycle` — owns the process-global service publication;
27
+ * `activate` publishes (skipped for registered subagent children) and emits
28
+ * the ready event; `teardown` unsubscribes all session listeners and unpublishes
29
+ * - `logger` — injected directly; replaces the former `session.logger` reach-through
30
+ * - `audit` — per-session decision counters; its summary is written on shutdown
31
+ */
32
+ export class SessionLifecycleHandler {
33
+ constructor(
34
+ private readonly session: PermissionSession,
35
+ private readonly resolver: PermissionResolver,
36
+ private readonly serviceLifecycle: ServiceLifecycle,
37
+ private readonly logger: SessionLogger,
38
+ private readonly audit: DecisionSummaryWriter,
39
+ ) {}
40
+
41
+ handleSessionStart(
42
+ event: SessionStartPayload,
43
+ ctx: ExtensionContext,
44
+ ): Promise<void> {
45
+ this.session.refreshConfig(ctx);
46
+ this.session.resetForNewSession(ctx);
47
+ this.session.logResolvedConfigPaths();
48
+
49
+ const agentName = this.session.resolveAgentName(ctx);
50
+ const policyIssues = this.resolver.getConfigIssues(agentName ?? undefined);
51
+ for (const issue of policyIssues) {
52
+ this.logger.warn(issue);
53
+ }
54
+
55
+ if (event.reason === "reload") {
56
+ this.logger.debug("lifecycle.reload", {
57
+ triggeredBy: "session_start",
58
+ reason: event.reason,
59
+ cwd: ctx.cwd,
60
+ });
61
+ }
62
+
63
+ // Publish the process-global service now that a ctx (and therefore the
64
+ // session id) is available, so an in-process subagent child can be
65
+ // identified and excluded. Emitting ready here keeps the
66
+ // service-resolvable-when-ready ordering contract.
67
+ this.serviceLifecycle.activate(ctx);
68
+ return Promise.resolve();
69
+ }
70
+
71
+ handleResourcesDiscover(event: ResourcesDiscoverPayload): Promise<void> {
72
+ if (event.reason !== "reload") {
73
+ return Promise.resolve();
74
+ }
75
+
76
+ this.session.reload();
77
+ this.logger.debug("lifecycle.reload", {
78
+ triggeredBy: "resources_discover",
79
+ reason: event.reason,
80
+ cwd: this.session.getRuntimeContext()?.cwd ?? null,
81
+ });
82
+ return Promise.resolve();
83
+ }
84
+
85
+ handleSessionShutdown(): Promise<void> {
86
+ const ctx = this.session.getRuntimeContext();
87
+ if (ctx) {
88
+ ctx.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
89
+ }
90
+ this.audit.writeSummary(this.logger);
91
+ this.session.shutdown();
92
+ this.serviceLifecycle.teardown();
93
+ return Promise.resolve();
94
+ }
95
+ }
@@ -0,0 +1,190 @@
1
+ import type {
2
+ ExtensionContext,
3
+ InputEventResult,
4
+ } from "@earendil-works/pi-coding-agent";
5
+
6
+ import { toRecord } from "#src/common";
7
+ import {
8
+ formatMissingToolNameReason,
9
+ formatUnknownToolReason,
10
+ } from "#src/permission-prompts";
11
+ import type { PermissionSession } from "#src/permission-session";
12
+ import {
13
+ checkRequestedToolRegistration,
14
+ getToolNameFromValue,
15
+ type ToolRegistry,
16
+ } from "#src/tool-registry";
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";
23
+ import type { GateOutcome, ToolCallContext } from "./gates/types";
24
+
25
+ /** Minimal subset of InputEvent used by handleInput. */
26
+ interface InputPayload {
27
+ text: string;
28
+ }
29
+
30
+ /**
31
+ * Handles permission gate events: tool_call and input.
32
+ *
33
+ * Constructor deps:
34
+ * - `session` — state/lifecycle owner: bind per-event context, resolve agent name
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)
39
+ */
40
+ export class PermissionGateHandler {
41
+ constructor(
42
+ private readonly session: PermissionSession,
43
+ private readonly toolRegistry: ToolRegistry,
44
+ private readonly pipeline: ToolCallGatePipeline,
45
+ private readonly skillInputPipeline: SkillInputGatePipeline,
46
+ private readonly runner: GateRunner,
47
+ ) {}
48
+
49
+ async handleToolCall(
50
+ event: unknown,
51
+ ctx: ExtensionContext,
52
+ ): Promise<GateOutcome> {
53
+ this.session.activate(ctx);
54
+
55
+ const validation = validateRequestedTool(event, this.toolRegistry.getAll());
56
+ if (validation.status === "block") {
57
+ return { action: "block", reason: validation.reason };
58
+ }
59
+ const toolName = validation.toolName;
60
+
61
+ const agentName = this.session.resolveAgentName(ctx);
62
+
63
+ const input = getEventInput(event);
64
+ const toolCallId =
65
+ typeof (event as Record<string, unknown>).toolCallId === "string"
66
+ ? ((event as Record<string, unknown>).toolCallId as string)
67
+ : "";
68
+
69
+ const tcc: ToolCallContext = {
70
+ toolName,
71
+ agentName,
72
+ input,
73
+ toolCallId,
74
+ cwd: ctx.cwd,
75
+ };
76
+
77
+ return await this.pipeline.evaluate(tcc, this.runner);
78
+ }
79
+
80
+ async handleInput(
81
+ event: InputPayload,
82
+ ctx: ExtensionContext,
83
+ ): Promise<InputEventResult> {
84
+ this.session.activate(ctx);
85
+
86
+ const skillName = extractSkillNameFromInput(event.text);
87
+ if (!skillName) {
88
+ return { action: "continue" };
89
+ }
90
+
91
+ const agentName = this.session.resolveAgentName(ctx);
92
+ const notifier: GateNotifier = {
93
+ warn: (message) => {
94
+ if (ctx.hasUI) {
95
+ ctx.ui.notify(message, "warning");
96
+ }
97
+ },
98
+ };
99
+ const outcome = await this.skillInputPipeline.evaluate(
100
+ skillName,
101
+ agentName,
102
+ notifier,
103
+ this.runner,
104
+ );
105
+ return outcome.action === "block"
106
+ ? { action: "handled" }
107
+ : { action: "continue" };
108
+ }
109
+ }
110
+
111
+ // ── Pure helpers ─────────────────────────────────────────────────────────
112
+
113
+ /** Discriminated result of validating a tool-call event's name and registration. */
114
+ export type RequestedToolValidation =
115
+ | { status: "ok"; toolName: string }
116
+ | { status: "block"; reason: string };
117
+
118
+ /**
119
+ * Validate the tool name from a raw event against the registered tool list.
120
+ *
121
+ * Composes `getToolNameFromValue` + `checkRequestedToolRegistration` + the
122
+ * two reason formatters and returns a discriminated result so `handleToolCall`
123
+ * reads as a straight validate → proceed path without nested early-returns.
124
+ *
125
+ * Returns the **raw** tool name (not the normalised form) so that
126
+ * `ToolCallContext.toolName` stays identical to the pre-extraction behaviour.
127
+ */
128
+ export function validateRequestedTool(
129
+ event: unknown,
130
+ availableTools: readonly unknown[],
131
+ ): RequestedToolValidation {
132
+ const toolName = getToolNameFromValue(event);
133
+ if (!toolName) {
134
+ return { status: "block", reason: formatMissingToolNameReason() };
135
+ }
136
+ const check = checkRequestedToolRegistration(toolName, availableTools);
137
+ if (check.status === "missing-tool-name") {
138
+ return { status: "block", reason: formatMissingToolNameReason() };
139
+ }
140
+ if (check.status === "unregistered") {
141
+ return {
142
+ status: "block",
143
+ reason: formatUnknownToolReason(
144
+ check.requestedToolName,
145
+ check.availableToolNames,
146
+ ),
147
+ };
148
+ }
149
+ return { status: "ok", toolName };
150
+ }
151
+
152
+ /**
153
+ * Extract the tool input from an event, checking both `input` and `arguments`
154
+ * fields (different Pi SDK versions use different names).
155
+ */
156
+ export function getEventInput(event: unknown): unknown {
157
+ const record = toRecord(event);
158
+
159
+ if (record.input !== undefined) {
160
+ return record.input;
161
+ }
162
+
163
+ if (record.arguments !== undefined) {
164
+ return record.arguments;
165
+ }
166
+
167
+ return {};
168
+ }
169
+
170
+ /**
171
+ * Parse a `/skill:<name>` prefix from user input.
172
+ * Returns the skill name, or null if the text is not a skill invocation.
173
+ */
174
+ export function extractSkillNameFromInput(text: string): string | null {
175
+ const trimmed = text.trim();
176
+ if (!trimmed.startsWith("/skill:")) {
177
+ return null;
178
+ }
179
+
180
+ const afterPrefix = trimmed.slice("/skill:".length);
181
+ if (!afterPrefix) {
182
+ return null;
183
+ }
184
+
185
+ const firstWhitespace = afterPrefix.search(/\s/);
186
+ const skillName = (
187
+ firstWhitespace === -1 ? afterPrefix : afterPrefix.slice(0, firstWhitespace)
188
+ ).trim();
189
+ return skillName || null;
190
+ }
@@ -0,0 +1,91 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { toRecord } from "#src/common";
4
+ import type { DecisionRecorder } from "#src/decision-audit";
5
+ import type { DecisionReporter } from "#src/decision-reporter";
6
+ import type { GateOutcome } from "./gates/types";
7
+
8
+ /** The SDK-facing result shape for a `tool_call` handler. */
9
+ type ToolCallResult = { block?: true; reason?: string };
10
+
11
+ /**
12
+ * Narrow debug surface for the per-call decision trace. The concrete logger
13
+ * self-gates on `debugLog`, so the boundary emits unconditionally and the
14
+ * entry is dropped when the toggle is off (no per-call spam in normal use).
15
+ */
16
+ export interface DecisionTracer {
17
+ debug(event: string, details?: Record<string, unknown>): void;
18
+ }
19
+
20
+ /**
21
+ * The only `tool_call` handler the SDK sees.
22
+ *
23
+ * Guarantees fail-closed: it owns the `try/catch → block` and is the sole place
24
+ * an internal {@link GateOutcome} is translated to the SDK result shape, so
25
+ * "we didn't decide" can never silently mean "allow."
26
+ *
27
+ * The SDK's `emitToolCall` (`@earendil-works/pi-coding-agent`
28
+ * `dist/core/extensions/runner.js`) awaits the registered handler with **no**
29
+ * try/catch — unlike `emitUserBash` directly below it, which catches and
30
+ * continues. A thrown gate therefore yields no `{ block: true }` and the
31
+ * command runs ungated with nothing logged. This boundary absorbs that throw,
32
+ * blocks, and writes a `gate_error` review-log entry.
33
+ *
34
+ * Fail-closed = **block** (not `ask`) for an unexpected exception: the command
35
+ * may be unknown and the prompt infrastructure itself may be what threw, so a
36
+ * hard block is the unambiguous safe outcome.
37
+ */
38
+ export function createFailClosedToolCall(
39
+ gate: (event: unknown, ctx: ExtensionContext) => Promise<GateOutcome>,
40
+ reporter: DecisionReporter,
41
+ audit: DecisionRecorder,
42
+ tracer: DecisionTracer,
43
+ ): (event: unknown, ctx: ExtensionContext) => Promise<ToolCallResult> {
44
+ return async (event, ctx) => {
45
+ try {
46
+ const outcome = await gate(event, ctx);
47
+ audit.recordDecision(outcome.action);
48
+ tracer.debug("permission.decision", {
49
+ toolName: bestEffortToolName(event),
50
+ action: outcome.action,
51
+ ...(outcome.action === "block" ? { reason: outcome.reason } : {}),
52
+ });
53
+ return outcome.action === "block"
54
+ ? { block: true, reason: outcome.reason }
55
+ : {};
56
+ } catch (error) {
57
+ audit.recordError();
58
+ reporter.writeReviewLog("permission_request.blocked", {
59
+ toolName: bestEffortToolName(event),
60
+ command: bestEffortCommand(event),
61
+ resolution: "gate_error",
62
+ error: errorMessage(error),
63
+ });
64
+ return { block: true, reason: formatGateErrorReason(error) };
65
+ }
66
+ };
67
+ }
68
+
69
+ // ── Defensive event readers (never throw) ──────────────────────────────────
70
+
71
+ /** Best-effort tool name from a raw event; never throws. */
72
+ function bestEffortToolName(event: unknown): string {
73
+ const record = toRecord(event);
74
+ const name = record.name ?? record.toolName;
75
+ return typeof name === "string" && name ? name : "<unknown>";
76
+ }
77
+
78
+ /** Best-effort bash command from a raw event; never throws. */
79
+ function bestEffortCommand(event: unknown): string | undefined {
80
+ const record = toRecord(event);
81
+ const input = toRecord(record.input ?? record.arguments);
82
+ return typeof input.command === "string" ? input.command : undefined;
83
+ }
84
+
85
+ function errorMessage(error: unknown): string {
86
+ return error instanceof Error ? error.message : String(error);
87
+ }
88
+
89
+ function formatGateErrorReason(error: unknown): string {
90
+ return `Permission gate failed and blocked the tool call (fail-closed): ${errorMessage(error)}`;
91
+ }
package/src/index.ts ADDED
@@ -0,0 +1,225 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
3
+ import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
4
+ import { registerPermissionSystemCommand } from "./config-modal";
5
+ import { getGlobalConfigPath } from "./config-paths";
6
+ import { ConfigStore } from "./config-store";
7
+ import { DecisionAudit } from "./decision-audit";
8
+ import { GateDecisionReporter } from "./decision-reporter";
9
+ import { computeExtensionPaths } from "./extension-paths";
10
+ import {
11
+ PermissionForwarder,
12
+ type PermissionForwarderDeps,
13
+ } from "./forwarded-permissions/permission-forwarder";
14
+ import { ForwardingManager } from "./forwarding-manager";
15
+ import {
16
+ AgentPrepHandler,
17
+ PermissionGateHandler,
18
+ SessionLifecycleHandler,
19
+ } from "./handlers";
20
+ import { GateRunner } from "./handlers/gates/runner";
21
+ import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeline";
22
+ import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
23
+ import { createFailClosedToolCall } from "./handlers/tool-call-boundary";
24
+ import { requestPermissionDecisionFromUi } from "./permission-dialog";
25
+ import { registerPermissionRpcHandlers } from "./permission-event-rpc";
26
+ import { PermissionManager } from "./permission-manager";
27
+ import { PermissionPrompter } from "./permission-prompter";
28
+ import { PersistentApprovalRecorder } from "./persistent-approval-recorder";
29
+ import { PermissionResolver } from "./permission-resolver";
30
+ import { PermissionSession } from "./permission-session";
31
+ import { LocalPermissionsService } from "./permissions-service";
32
+ import { PromptingGateway } from "./prompting-gateway";
33
+ import { PermissionServiceLifecycle } from "./service-lifecycle";
34
+ import { PermissionSessionLogger } from "./session-logger";
35
+ import { SessionRules } from "./session-rules";
36
+ import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
37
+ import { getSubagentSessionRegistry } from "./subagent-registry";
38
+ import { ToolAccessExtractorRegistry } from "./tool-access-extractor-registry";
39
+ import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
40
+
41
+ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
42
+ const agentDir = getAgentDir();
43
+ // getPackageDir() is Pi's own install dir; auto-allow it for read-only tools
44
+ // so the agent can read Pi's bundled docs/examples regardless of layout.
45
+ const paths = computeExtensionPaths(agentDir, getPackageDir());
46
+ const permissionManager = new PermissionManager({ agentDir });
47
+ const sessionRules = new SessionRules();
48
+ const subagentRegistry = getSubagentSessionRegistry();
49
+ const formatterRegistry = new ToolInputFormatterRegistry();
50
+ registerBuiltinToolInputFormatters(formatterRegistry);
51
+ const accessExtractorRegistry = new ToolAccessExtractorRegistry();
52
+
53
+ // Both `configStore` and `session` are forward-declared so the logger's
54
+ // lazy thunks can close over them without a cast or null-init holder.
55
+ // TypeScript exempts closure captures from definite-assignment analysis;
56
+ // all synchronous reads occur after the assignments below.
57
+ // eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
58
+ let configStore: ConfigStore;
59
+ // eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
60
+ let session: PermissionSession;
61
+
62
+ const logger = new PermissionSessionLogger({
63
+ globalLogsDir: paths.globalLogsDir,
64
+ getConfig: () => configStore.current(),
65
+ notify: (message) => session.notify(message),
66
+ });
67
+
68
+ configStore = new ConfigStore({
69
+ agentDir,
70
+ policyPaths: permissionManager,
71
+ logger,
72
+ });
73
+
74
+ const forwardingDeps: PermissionForwarderDeps = {
75
+ forwardingDir: paths.forwardingDir,
76
+ subagentSessionsDir: paths.subagentSessionsDir,
77
+ registry: subagentRegistry,
78
+ events: pi.events,
79
+ logger,
80
+ requestPermissionDecisionFromUi,
81
+ config: configStore,
82
+ };
83
+ const forwarder = new PermissionForwarder(forwardingDeps);
84
+
85
+ const prompter = new PermissionPrompter({
86
+ config: configStore,
87
+ logger,
88
+ events: pi.events,
89
+ forwarder,
90
+ });
91
+
92
+ const gateway = new PromptingGateway({
93
+ config: configStore,
94
+ subagentSessionsDir: paths.subagentSessionsDir,
95
+ registry: subagentRegistry,
96
+ prompter,
97
+ });
98
+
99
+ session = new PermissionSession(
100
+ paths,
101
+ new ForwardingManager(
102
+ paths.subagentSessionsDir,
103
+ forwarder,
104
+ subagentRegistry,
105
+ ),
106
+ permissionManager,
107
+ sessionRules,
108
+ configStore,
109
+ gateway,
110
+ );
111
+
112
+ // refresh() must run after `session` is assigned: a debug-write IO failure
113
+ // triggers the logger's notify sink — `session.notify(m)` — which no-ops
114
+ // on the null context but requires `session` to be bound.
115
+ configStore.refresh();
116
+
117
+ const configPath = getGlobalConfigPath(agentDir);
118
+ registerPermissionSystemCommand(pi, {
119
+ config: configStore,
120
+ configPath,
121
+ getActiveAgentConfigRules: () =>
122
+ permissionManager.getComposedConfigRules(
123
+ session.lastKnownActiveAgentName ?? undefined,
124
+ ),
125
+ });
126
+
127
+ const rpcHandles = registerPermissionRpcHandlers(pi.events, {
128
+ permissionManager,
129
+ sessionRules,
130
+ session,
131
+ requestPermissionDecisionFromUi,
132
+ logger,
133
+ });
134
+
135
+ const permissionsService = new LocalPermissionsService(
136
+ permissionManager,
137
+ sessionRules,
138
+ formatterRegistry,
139
+ accessExtractorRegistry,
140
+ );
141
+
142
+ // Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
143
+ // sessions register/unregister without the core calling us (ADR 0002).
144
+ const unsubSubagentLifecycle = subscribeSubagentLifecycle(
145
+ pi.events,
146
+ subagentRegistry,
147
+ );
148
+
149
+ // PermissionServiceLifecycle owns the process-global service publication:
150
+ // activate() publishes (skipped for registered subagent children — see #302)
151
+ // and emits ready; teardown() unsubscribes all session listeners and
152
+ // unpublishes. Deferred to session_start because identifying a child
153
+ // requires the session id from ctx, unavailable at factory-init time.
154
+ const serviceLifecycle = new PermissionServiceLifecycle(
155
+ permissionsService,
156
+ subagentRegistry,
157
+ pi.events,
158
+ [rpcHandles.unsubCheck, rpcHandles.unsubPrompt, unsubSubagentLifecycle],
159
+ );
160
+
161
+ const toolRegistry = {
162
+ getAll: () => pi.getAllTools(),
163
+ getActive: () => pi.getActiveTools(),
164
+ setActive: (names: string[]) => pi.setActiveTools(names),
165
+ };
166
+
167
+ const resolver = new PermissionResolver(permissionManager, sessionRules);
168
+
169
+ const audit = new DecisionAudit();
170
+ const lifecycle = new SessionLifecycleHandler(
171
+ session,
172
+ resolver,
173
+ serviceLifecycle,
174
+ logger,
175
+ audit,
176
+ );
177
+ const agentPrep = new AgentPrepHandler(session, resolver, toolRegistry);
178
+
179
+ const reporter = new GateDecisionReporter(logger, pi.events);
180
+ const persistentApprovalRecorder = new PersistentApprovalRecorder({
181
+ agentDir,
182
+ getCwd: () => session.getRuntimeContext()?.cwd,
183
+ logger,
184
+ });
185
+ const gateRunner = new GateRunner(
186
+ resolver,
187
+ sessionRules,
188
+ gateway,
189
+ reporter,
190
+ persistentApprovalRecorder,
191
+ );
192
+ const toolCallGatePipeline = new ToolCallGatePipeline(
193
+ resolver,
194
+ session,
195
+ formatterRegistry,
196
+ accessExtractorRegistry,
197
+ );
198
+ const skillInputGatePipeline = new SkillInputGatePipeline(resolver);
199
+ const gates = new PermissionGateHandler(
200
+ session,
201
+ toolRegistry,
202
+ toolCallGatePipeline,
203
+ skillInputGatePipeline,
204
+ gateRunner,
205
+ );
206
+
207
+ pi.on("session_start", (event, ctx) =>
208
+ lifecycle.handleSessionStart(event, ctx),
209
+ );
210
+ pi.on("resources_discover", (event) =>
211
+ lifecycle.handleResourcesDiscover(event),
212
+ );
213
+ pi.on("session_shutdown", () => lifecycle.handleSessionShutdown());
214
+ pi.on("before_agent_start", (event, ctx) => agentPrep.handle(event, ctx));
215
+ pi.on("input", (event, ctx) => gates.handleInput(event, ctx));
216
+ pi.on(
217
+ "tool_call",
218
+ createFailClosedToolCall(
219
+ (event, ctx) => gates.handleToolCall(event, ctx),
220
+ reporter,
221
+ audit,
222
+ logger,
223
+ ),
224
+ );
225
+ }