@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
@@ -9,10 +9,6 @@ import { safeJsonStringify } from "#src/logging";
9
9
  import {
10
10
  countTextLines,
11
11
  formatCount,
12
- formatEditInputForPrompt,
13
- formatReadInputForPrompt,
14
- formatWriteInputForPrompt,
15
- getPromptPath,
16
12
  serializeToolInputPreview,
17
13
  TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
18
14
  TOOL_INPUT_PREVIEW_MAX_LENGTH,
@@ -102,113 +98,6 @@ describe("formatCount", () => {
102
98
  });
103
99
  });
104
100
 
105
- describe("getPromptPath", () => {
106
- test("returns path from 'path' key", () => {
107
- expect(getPromptPath({ path: "/foo/bar" })).toBe("/foo/bar");
108
- });
109
-
110
- test("falls back to 'file_path' key", () => {
111
- expect(getPromptPath({ file_path: "/baz" })).toBe("/baz");
112
- });
113
-
114
- test("returns null when neither key is present", () => {
115
- expect(getPromptPath({})).toBeNull();
116
- });
117
-
118
- test("returns null when path is empty string", () => {
119
- expect(getPromptPath({ path: "" })).toBeNull();
120
- });
121
- });
122
-
123
- describe("formatEditInputForPrompt", () => {
124
- test("returns path-only description when no edits provided", () => {
125
- const result = formatEditInputForPrompt({ path: "/foo.ts" });
126
- expect(result).toBe("for '/foo.ts' with edit input");
127
- });
128
-
129
- test("formats single replacement with line counts", () => {
130
- const result = formatEditInputForPrompt({
131
- path: "/foo.ts",
132
- edits: [{ oldText: "line1\nline2", newText: "replaced" }],
133
- });
134
- expect(result).toContain("for '/foo.ts'");
135
- expect(result).toContain("1 replacement");
136
- expect(result).toContain("2 lines");
137
- expect(result).toContain("1 line");
138
- });
139
-
140
- test("formats multiple replacements mentioning additional edits", () => {
141
- const result = formatEditInputForPrompt({
142
- path: "/foo.ts",
143
- edits: [
144
- { oldText: "a", newText: "b" },
145
- { oldText: "c", newText: "d" },
146
- { oldText: "e", newText: "f" },
147
- ],
148
- });
149
- expect(result).toContain("3 replacements");
150
- expect(result).toContain("2 additional edits");
151
- });
152
-
153
- test("falls back to oldText/newText when no edits array", () => {
154
- const result = formatEditInputForPrompt({
155
- path: "/bar.ts",
156
- oldText: "old",
157
- newText: "new",
158
- });
159
- expect(result).toContain("for '/bar.ts'");
160
- expect(result).toContain("1 replacement");
161
- });
162
-
163
- test("works without a path", () => {
164
- const result = formatEditInputForPrompt({
165
- edits: [{ oldText: "x", newText: "y" }],
166
- });
167
- expect(result).not.toContain("for '");
168
- expect(result).toContain("1 replacement");
169
- });
170
- });
171
-
172
- describe("formatWriteInputForPrompt", () => {
173
- test("includes path, line count, and character count", () => {
174
- const result = formatWriteInputForPrompt({
175
- path: "/out.ts",
176
- content: "line1\nline2",
177
- });
178
- expect(result).toContain("for '/out.ts'");
179
- expect(result).toContain("2 lines");
180
- expect(result).toContain("11 characters");
181
- });
182
-
183
- test("handles missing content as empty", () => {
184
- const result = formatWriteInputForPrompt({ path: "/out.ts" });
185
- expect(result).toContain("0 lines");
186
- expect(result).toContain("0 characters");
187
- });
188
- });
189
-
190
- describe("formatReadInputForPrompt", () => {
191
- test("includes path", () => {
192
- expect(formatReadInputForPrompt({ path: "/src/foo.ts" })).toBe(
193
- "for path '/src/foo.ts'",
194
- );
195
- });
196
-
197
- test("includes offset and limit when present", () => {
198
- const result = formatReadInputForPrompt({
199
- path: "/x",
200
- offset: 10,
201
- limit: 50,
202
- });
203
- expect(result).toContain("offset 10");
204
- expect(result).toContain("limit 50");
205
- });
206
-
207
- test("returns empty string when no path and no options", () => {
208
- expect(formatReadInputForPrompt({})).toBe("");
209
- });
210
- });
211
-
212
101
  describe("serializeToolInputPreview", () => {
213
102
  test("delegates serialization to safeJsonStringify", () => {
214
103
  mockedStringify.mockReturnValue('{"key":"value"}');
@@ -0,0 +1,115 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import {
4
+ formatEditInputForPrompt,
5
+ formatReadInputForPrompt,
6
+ formatWriteInputForPrompt,
7
+ getPromptPath,
8
+ } from "#src/tool-input-prompt-formatters";
9
+
10
+ describe("getPromptPath", () => {
11
+ test("returns path from 'path' key", () => {
12
+ expect(getPromptPath({ path: "/foo/bar" })).toBe("/foo/bar");
13
+ });
14
+
15
+ test("falls back to 'file_path' key", () => {
16
+ expect(getPromptPath({ file_path: "/baz" })).toBe("/baz");
17
+ });
18
+
19
+ test("returns null when neither key is present", () => {
20
+ expect(getPromptPath({})).toBeNull();
21
+ });
22
+
23
+ test("returns null when path is empty string", () => {
24
+ expect(getPromptPath({ path: "" })).toBeNull();
25
+ });
26
+ });
27
+
28
+ describe("formatEditInputForPrompt", () => {
29
+ test("returns path-only description when no edits provided", () => {
30
+ const result = formatEditInputForPrompt({ path: "/foo.ts" });
31
+ expect(result).toBe("for '/foo.ts' with edit input");
32
+ });
33
+
34
+ test("formats single replacement with line counts", () => {
35
+ const result = formatEditInputForPrompt({
36
+ path: "/foo.ts",
37
+ edits: [{ oldText: "line1\nline2", newText: "replaced" }],
38
+ });
39
+ expect(result).toContain("for '/foo.ts'");
40
+ expect(result).toContain("1 replacement");
41
+ expect(result).toContain("2 lines");
42
+ expect(result).toContain("1 line");
43
+ });
44
+
45
+ test("formats multiple replacements mentioning additional edits", () => {
46
+ const result = formatEditInputForPrompt({
47
+ path: "/foo.ts",
48
+ edits: [
49
+ { oldText: "a", newText: "b" },
50
+ { oldText: "c", newText: "d" },
51
+ { oldText: "e", newText: "f" },
52
+ ],
53
+ });
54
+ expect(result).toContain("3 replacements");
55
+ expect(result).toContain("2 additional edits");
56
+ });
57
+
58
+ test("falls back to oldText/newText when no edits array", () => {
59
+ const result = formatEditInputForPrompt({
60
+ path: "/bar.ts",
61
+ oldText: "old",
62
+ newText: "new",
63
+ });
64
+ expect(result).toContain("for '/bar.ts'");
65
+ expect(result).toContain("1 replacement");
66
+ });
67
+
68
+ test("works without a path", () => {
69
+ const result = formatEditInputForPrompt({
70
+ edits: [{ oldText: "x", newText: "y" }],
71
+ });
72
+ expect(result).not.toContain("for '");
73
+ expect(result).toContain("1 replacement");
74
+ });
75
+ });
76
+
77
+ describe("formatWriteInputForPrompt", () => {
78
+ test("includes path, line count, and character count", () => {
79
+ const result = formatWriteInputForPrompt({
80
+ path: "/out.ts",
81
+ content: "line1\nline2",
82
+ });
83
+ expect(result).toContain("for '/out.ts'");
84
+ expect(result).toContain("2 lines");
85
+ expect(result).toContain("11 characters");
86
+ });
87
+
88
+ test("handles missing content as empty", () => {
89
+ const result = formatWriteInputForPrompt({ path: "/out.ts" });
90
+ expect(result).toContain("0 lines");
91
+ expect(result).toContain("0 characters");
92
+ });
93
+ });
94
+
95
+ describe("formatReadInputForPrompt", () => {
96
+ test("includes path", () => {
97
+ expect(formatReadInputForPrompt({ path: "/src/foo.ts" })).toBe(
98
+ "for path '/src/foo.ts'",
99
+ );
100
+ });
101
+
102
+ test("includes offset and limit when present", () => {
103
+ const result = formatReadInputForPrompt({
104
+ path: "/x",
105
+ offset: 10,
106
+ limit: 50,
107
+ });
108
+ expect(result).toContain("offset 10");
109
+ expect(result).toContain("limit 50");
110
+ });
111
+
112
+ test("returns empty string when no path and no options", () => {
113
+ expect(formatReadInputForPrompt({})).toBe("");
114
+ });
115
+ });
@@ -1,379 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { join } from "node:path";
3
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
-
5
- import {
6
- getActiveAgentName,
7
- getActiveAgentNameFromSystemPrompt,
8
- } from "#src/active-agent";
9
- import { toRecord } from "#src/common";
10
- import type {
11
- PermissionPromptDecision,
12
- RequestPermissionOptions,
13
- } from "#src/permission-dialog";
14
- import {
15
- type ForwardedPermissionRequest,
16
- type ForwardedPermissionResponse,
17
- isForwardedPermissionRequestForSession,
18
- PERMISSION_FORWARDING_POLL_INTERVAL_MS,
19
- PERMISSION_FORWARDING_TIMEOUT_MS,
20
- resolvePermissionForwardingTargetSessionId,
21
- SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
22
- } from "#src/permission-forwarding";
23
- import { isSubagentExecutionContext } from "#src/subagent-context";
24
- import type { SubagentSessionRegistry } from "#src/subagent-registry";
25
-
26
- import {
27
- cleanupPermissionForwardingLocationIfEmpty,
28
- ensurePermissionForwardingLocation,
29
- type ForwardedPermissionLogger,
30
- getExistingPermissionForwardingLocation,
31
- listRequestFiles,
32
- logPermissionForwardingError,
33
- logPermissionForwardingWarning,
34
- readForwardedPermissionRequest,
35
- readForwardedPermissionResponse,
36
- safeDeleteFile,
37
- sleep,
38
- writeJsonFileAtomic,
39
- } from "./io";
40
-
41
- export interface PermissionForwardingDeps {
42
- forwardingDir: string;
43
- subagentSessionsDir: string;
44
- /** In-process subagent session registry for detection and forwarding target resolution. */
45
- registry?: SubagentSessionRegistry;
46
- logger: ForwardedPermissionLogger;
47
- writeReviewLog: (event: string, details: Record<string, unknown>) => void;
48
- requestPermissionDecisionFromUi: (
49
- ui: ExtensionContext["ui"],
50
- title: string,
51
- message: string,
52
- options?: RequestPermissionOptions,
53
- ) => Promise<PermissionPromptDecision>;
54
- shouldAutoApprove: () => boolean;
55
- }
56
-
57
- export function getSessionId(ctx: ExtensionContext): string {
58
- try {
59
- const sessionId = ctx.sessionManager.getSessionId();
60
- if (typeof sessionId === "string" && sessionId.trim()) {
61
- return sessionId.trim();
62
- }
63
- } catch {}
64
-
65
- return "unknown";
66
- }
67
-
68
- function getContextSystemPrompt(ctx: ExtensionContext): string | undefined {
69
- const getSystemPrompt = toRecord(ctx).getSystemPrompt;
70
- if (typeof getSystemPrompt !== "function") {
71
- return undefined;
72
- }
73
-
74
- try {
75
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- getSystemPrompt is a Pi SDK accessor returning any
76
- const systemPrompt = getSystemPrompt.call(ctx);
77
- return typeof systemPrompt === "string" ? systemPrompt : undefined;
78
- } catch (error) {
79
- // No deps available in this helper — warning silently dropped.
80
- logPermissionForwardingWarning(
81
- null,
82
- "Failed to read context system prompt for forwarded permission metadata",
83
- error,
84
- );
85
- return undefined;
86
- }
87
- }
88
-
89
- export function formatForwardedPermissionPrompt(
90
- request: ForwardedPermissionRequest,
91
- ): string {
92
- const agentName = request.requesterAgentName || "unknown";
93
- const sessionId = request.requesterSessionId || "unknown";
94
- return [
95
- `Subagent '${agentName}' requested permission.`,
96
- `Session ID: ${sessionId}`,
97
- "",
98
- request.message,
99
- ].join("\n");
100
- }
101
-
102
- export async function waitForForwardedPermissionApproval(
103
- ctx: ExtensionContext,
104
- message: string,
105
- deps: PermissionForwardingDeps,
106
- ): Promise<PermissionPromptDecision> {
107
- const requesterSessionId = getSessionId(ctx);
108
- const targetSessionId = resolvePermissionForwardingTargetSessionId({
109
- hasUI: ctx.hasUI,
110
- isSubagent: isSubagentExecutionContext(
111
- ctx,
112
- deps.subagentSessionsDir,
113
- deps.registry,
114
- ),
115
- currentSessionId: requesterSessionId,
116
- env: process.env,
117
- sessionId: requesterSessionId,
118
- registry: deps.registry,
119
- });
120
-
121
- if (!targetSessionId) {
122
- logPermissionForwardingError(
123
- deps.logger,
124
- `Permission forwarding target session could not be resolved. ` +
125
- `Checked env vars: ${SUBAGENT_PARENT_SESSION_ENV_CANDIDATES.join(", ")}. ` +
126
- `If you are using a subagent extension (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.), ` +
127
- `ask its maintainer to set PI_SUBAGENT_PARENT_SESSION in the child process environment ` +
128
- `(see https://github.com/gotgenes/pi-permission-system/issues/143).`,
129
- );
130
- return { approved: false, state: "denied" };
131
- }
132
-
133
- const location = ensurePermissionForwardingLocation(
134
- deps.logger,
135
- deps.forwardingDir,
136
- targetSessionId,
137
- );
138
- if (!location) {
139
- logPermissionForwardingError(
140
- deps.logger,
141
- `Permission forwarding is unavailable because session-scoped directories could not be prepared for '${targetSessionId}'`,
142
- );
143
- return { approved: false, state: "denied" };
144
- }
145
-
146
- const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
147
- const requesterAgentName =
148
- getActiveAgentName(ctx) ??
149
- getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
150
- "unknown";
151
- const request: ForwardedPermissionRequest = {
152
- id: requestId,
153
- createdAt: Date.now(),
154
- requesterSessionId,
155
- targetSessionId,
156
- requesterAgentName,
157
- message,
158
- };
159
-
160
- const requestPath = join(location.requestsDir, `${requestId}.json`);
161
- const responsePath = join(location.responsesDir, `${requestId}.json`);
162
-
163
- deps.writeReviewLog("forwarded_permission.request_created", {
164
- requestId,
165
- requesterAgentName,
166
- requesterSessionId: request.requesterSessionId,
167
- targetSessionId,
168
- requestPath,
169
- responsePath,
170
- });
171
-
172
- try {
173
- writeJsonFileAtomic(deps.logger, requestPath, request);
174
- } catch (error) {
175
- logPermissionForwardingError(
176
- deps.logger,
177
- `Failed to write forwarded permission request '${requestPath}'`,
178
- error,
179
- );
180
- return { approved: false, state: "denied" };
181
- }
182
-
183
- const deadline = Date.now() + PERMISSION_FORWARDING_TIMEOUT_MS;
184
- while (Date.now() < deadline) {
185
- if (existsSync(responsePath)) {
186
- const response = readForwardedPermissionResponse(
187
- deps.logger,
188
- responsePath,
189
- );
190
- deps.writeReviewLog("forwarded_permission.response_received", {
191
- requestId,
192
- approved: response?.approved ?? null,
193
- state: response?.state ?? null,
194
- denialReason: response?.denialReason ?? null,
195
- responderSessionId: response?.responderSessionId ?? null,
196
- targetSessionId,
197
- responsePath,
198
- });
199
- safeDeleteFile(
200
- deps.logger,
201
- responsePath,
202
- "forwarded permission response",
203
- );
204
- safeDeleteFile(deps.logger, requestPath, "forwarded permission request");
205
- cleanupPermissionForwardingLocationIfEmpty(deps.logger, location);
206
- return response ?? { approved: false, state: "denied" };
207
- }
208
-
209
- await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
210
- }
211
-
212
- logPermissionForwardingWarning(
213
- deps.logger,
214
- `Timed out waiting for forwarded permission response '${responsePath}'`,
215
- );
216
- deps.writeReviewLog("forwarded_permission.response_timed_out", {
217
- requestId,
218
- requesterAgentName,
219
- targetSessionId,
220
- responsePath,
221
- });
222
- safeDeleteFile(deps.logger, requestPath, "forwarded permission request");
223
- cleanupPermissionForwardingLocationIfEmpty(deps.logger, location);
224
- return { approved: false, state: "denied" };
225
- }
226
-
227
- export async function processForwardedPermissionRequests(
228
- ctx: ExtensionContext,
229
- deps: PermissionForwardingDeps,
230
- ): Promise<void> {
231
- if (!ctx.hasUI) {
232
- return;
233
- }
234
-
235
- const currentSessionId = getSessionId(ctx);
236
- const location = getExistingPermissionForwardingLocation(
237
- deps.forwardingDir,
238
- currentSessionId,
239
- );
240
- if (!location) {
241
- return;
242
- }
243
-
244
- const requestFiles = listRequestFiles(deps.logger, location.requestsDir);
245
- if (requestFiles.length === 0) {
246
- return;
247
- }
248
-
249
- for (const fileName of requestFiles) {
250
- const requestPath = join(location.requestsDir, fileName);
251
- const request = readForwardedPermissionRequest(deps.logger, requestPath);
252
- if (!request) {
253
- safeDeleteFile(
254
- deps.logger,
255
- requestPath,
256
- `${location.label} forwarded permission request`,
257
- );
258
- continue;
259
- }
260
-
261
- if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
262
- logPermissionForwardingWarning(
263
- deps.logger,
264
- `Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
265
- );
266
- safeDeleteFile(
267
- deps.logger,
268
- requestPath,
269
- `${location.label} forwarded permission request`,
270
- );
271
- continue;
272
- }
273
-
274
- const forwardedPermissionLogDetails = {
275
- requestId: request.id,
276
- source: location.label,
277
- requesterAgentName: request.requesterAgentName,
278
- requesterSessionId: request.requesterSessionId,
279
- targetSessionId: request.targetSessionId,
280
- requestPath,
281
- };
282
-
283
- let decision: PermissionPromptDecision = {
284
- approved: false,
285
- state: "denied",
286
- };
287
- if (deps.shouldAutoApprove()) {
288
- deps.writeReviewLog(
289
- "forwarded_permission.auto_approved",
290
- forwardedPermissionLogDetails,
291
- );
292
- decision = { approved: true, state: "approved" };
293
- } else {
294
- deps.writeReviewLog(
295
- "forwarded_permission.prompted",
296
- forwardedPermissionLogDetails,
297
- );
298
- try {
299
- decision = await deps.requestPermissionDecisionFromUi(
300
- ctx.ui,
301
- "Permission Required (Subagent)",
302
- formatForwardedPermissionPrompt(request),
303
- );
304
- } catch (error) {
305
- logPermissionForwardingError(
306
- deps.logger,
307
- "Failed to show forwarded permission confirmation dialog",
308
- error,
309
- );
310
- decision = { approved: false, state: "denied" };
311
- }
312
- }
313
-
314
- const responsePath = join(location.responsesDir, `${request.id}.json`);
315
- deps.writeReviewLog(
316
- decision.approved
317
- ? "forwarded_permission.approved"
318
- : "forwarded_permission.denied",
319
- {
320
- requestId: request.id,
321
- source: location.label,
322
- requesterAgentName: request.requesterAgentName,
323
- requesterSessionId: request.requesterSessionId,
324
- targetSessionId: request.targetSessionId,
325
- responsePath,
326
- resolution: decision.state,
327
- denialReason: decision.denialReason ?? null,
328
- },
329
- );
330
- try {
331
- writeJsonFileAtomic(deps.logger, responsePath, {
332
- approved: decision.approved,
333
- state: decision.state,
334
- denialReason: decision.denialReason,
335
- responderSessionId: currentSessionId,
336
- respondedAt: Date.now(),
337
- } satisfies ForwardedPermissionResponse);
338
- } catch (error) {
339
- logPermissionForwardingError(
340
- deps.logger,
341
- `Failed to write ${location.label} forwarded permission response '${responsePath}'`,
342
- error,
343
- );
344
- continue;
345
- }
346
-
347
- safeDeleteFile(
348
- deps.logger,
349
- requestPath,
350
- `${location.label} forwarded permission request`,
351
- );
352
- }
353
-
354
- cleanupPermissionForwardingLocationIfEmpty(deps.logger, location);
355
- }
356
-
357
- export async function confirmPermission(
358
- ctx: ExtensionContext,
359
- message: string,
360
- deps: PermissionForwardingDeps,
361
- options?: RequestPermissionOptions,
362
- ): Promise<PermissionPromptDecision> {
363
- if (ctx.hasUI) {
364
- return deps.requestPermissionDecisionFromUi(
365
- ctx.ui,
366
- "Permission Required",
367
- message,
368
- options,
369
- );
370
- }
371
-
372
- if (
373
- !isSubagentExecutionContext(ctx, deps.subagentSessionsDir, deps.registry)
374
- ) {
375
- return { approved: false, state: "denied" };
376
- }
377
-
378
- return waitForForwardedPermissionApproval(ctx, message, deps);
379
- }