@gotgenes/pi-permission-system 3.0.0 → 3.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.
@@ -0,0 +1,328 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ rmdirSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+
12
+ import { isPermissionDecisionState } from "../permission-dialog.js";
13
+ import {
14
+ createPermissionForwardingLocation,
15
+ type ForwardedPermissionRequest,
16
+ type ForwardedPermissionResponse,
17
+ type PermissionForwardingLocation,
18
+ } from "../permission-forwarding.js";
19
+
20
+ type LogFn = (event: string, details: Record<string, unknown>) => void;
21
+
22
+ export interface ForwardedPermissionLogger {
23
+ writeReviewLog: LogFn;
24
+ writeDebugLog: LogFn;
25
+ }
26
+
27
+ let logger: ForwardedPermissionLogger | null = null;
28
+
29
+ export function setForwardedPermissionLogger(
30
+ l: ForwardedPermissionLogger,
31
+ ): void {
32
+ logger = l;
33
+ }
34
+
35
+ export function formatUnknownErrorMessage(error: unknown): string {
36
+ if (error instanceof Error && error.message) {
37
+ return error.message;
38
+ }
39
+ return String(error);
40
+ }
41
+
42
+ export function isErrnoCode(error: unknown, code: string): boolean {
43
+ return Boolean(
44
+ error &&
45
+ typeof error === "object" &&
46
+ "code" in error &&
47
+ (error as { code?: string }).code === code,
48
+ );
49
+ }
50
+
51
+ export function logPermissionForwardingWarning(
52
+ message: string,
53
+ error?: unknown,
54
+ ): void {
55
+ const details =
56
+ typeof error === "undefined"
57
+ ? { message }
58
+ : { message, error: formatUnknownErrorMessage(error) };
59
+
60
+ logger?.writeReviewLog("permission_forwarding.warning", details);
61
+ logger?.writeDebugLog("permission_forwarding.warning", details);
62
+ }
63
+
64
+ export function logPermissionForwardingError(
65
+ message: string,
66
+ error?: unknown,
67
+ ): void {
68
+ const details =
69
+ typeof error === "undefined"
70
+ ? { message }
71
+ : { message, error: formatUnknownErrorMessage(error) };
72
+
73
+ logger?.writeReviewLog("permission_forwarding.error", details);
74
+ logger?.writeDebugLog("permission_forwarding.error", details);
75
+ }
76
+
77
+ export function ensureDirectoryExists(
78
+ path: string,
79
+ description: string,
80
+ ): boolean {
81
+ try {
82
+ mkdirSync(path, { recursive: true });
83
+ return true;
84
+ } catch (error) {
85
+ logPermissionForwardingError(
86
+ `Failed to create ${description} directory '${path}'`,
87
+ error,
88
+ );
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export function getPermissionForwardingLocationForSession(
94
+ forwardingDir: string,
95
+ sessionId: string,
96
+ ): PermissionForwardingLocation {
97
+ return createPermissionForwardingLocation(forwardingDir, sessionId);
98
+ }
99
+
100
+ export function ensurePermissionForwardingLocation(
101
+ forwardingDir: string,
102
+ sessionId: string,
103
+ ): PermissionForwardingLocation | null {
104
+ let location: PermissionForwardingLocation;
105
+ try {
106
+ location = getPermissionForwardingLocationForSession(
107
+ forwardingDir,
108
+ sessionId,
109
+ );
110
+ } catch (error) {
111
+ logPermissionForwardingError(
112
+ "Failed to resolve permission forwarding location",
113
+ error,
114
+ );
115
+ return null;
116
+ }
117
+
118
+ const sessionRootReady = ensureDirectoryExists(
119
+ location.sessionRootDir,
120
+ "permission forwarding session root",
121
+ );
122
+ const requestsReady = ensureDirectoryExists(
123
+ location.requestsDir,
124
+ "permission forwarding requests",
125
+ );
126
+ const responsesReady = ensureDirectoryExists(
127
+ location.responsesDir,
128
+ "permission forwarding responses",
129
+ );
130
+
131
+ return sessionRootReady && requestsReady && responsesReady ? location : null;
132
+ }
133
+
134
+ export function getExistingPermissionForwardingLocation(
135
+ forwardingDir: string,
136
+ sessionId: string,
137
+ ): PermissionForwardingLocation | null {
138
+ let location: PermissionForwardingLocation;
139
+ try {
140
+ location = getPermissionForwardingLocationForSession(
141
+ forwardingDir,
142
+ sessionId,
143
+ );
144
+ } catch {
145
+ return null;
146
+ }
147
+
148
+ return existsSync(location.requestsDir) ? location : null;
149
+ }
150
+
151
+ export function tryRemoveDirectoryIfEmpty(
152
+ path: string,
153
+ description: string,
154
+ ): void {
155
+ if (!existsSync(path)) {
156
+ return;
157
+ }
158
+
159
+ let entries: string[];
160
+ try {
161
+ entries = readdirSync(path);
162
+ } catch (error) {
163
+ logPermissionForwardingWarning(
164
+ `Failed to inspect ${description} directory '${path}'`,
165
+ error,
166
+ );
167
+ return;
168
+ }
169
+
170
+ if (entries.length > 0) {
171
+ return;
172
+ }
173
+
174
+ try {
175
+ rmdirSync(path);
176
+ } catch (error) {
177
+ if (isErrnoCode(error, "ENOENT") || isErrnoCode(error, "ENOTEMPTY")) {
178
+ return;
179
+ }
180
+
181
+ logPermissionForwardingWarning(
182
+ `Failed to remove empty ${description} directory '${path}'`,
183
+ error,
184
+ );
185
+ }
186
+ }
187
+
188
+ export function cleanupPermissionForwardingLocationIfEmpty(
189
+ location: PermissionForwardingLocation,
190
+ ): void {
191
+ tryRemoveDirectoryIfEmpty(
192
+ location.requestsDir,
193
+ `${location.label} permission forwarding requests`,
194
+ );
195
+ tryRemoveDirectoryIfEmpty(
196
+ location.responsesDir,
197
+ `${location.label} permission forwarding responses`,
198
+ );
199
+ tryRemoveDirectoryIfEmpty(
200
+ location.sessionRootDir,
201
+ `${location.label} permission forwarding session root`,
202
+ );
203
+ }
204
+
205
+ export function safeDeleteFile(filePath: string, description: string): void {
206
+ try {
207
+ unlinkSync(filePath);
208
+ } catch (error) {
209
+ if (isErrnoCode(error, "ENOENT")) {
210
+ return;
211
+ }
212
+
213
+ logPermissionForwardingWarning(
214
+ `Failed to delete ${description} file '${filePath}'`,
215
+ error,
216
+ );
217
+ }
218
+ }
219
+
220
+ export function writeJsonFileAtomic(filePath: string, value: unknown): void {
221
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
222
+
223
+ try {
224
+ writeFileSync(tempPath, JSON.stringify(value), "utf-8");
225
+ renameSync(tempPath, filePath);
226
+ } catch (error) {
227
+ safeDeleteFile(tempPath, "temporary permission-forwarding");
228
+ throw error;
229
+ }
230
+ }
231
+
232
+ export function readForwardedPermissionRequest(
233
+ filePath: string,
234
+ ): ForwardedPermissionRequest | null {
235
+ try {
236
+ const raw = readFileSync(filePath, "utf-8");
237
+ const parsed = JSON.parse(raw) as Partial<ForwardedPermissionRequest>;
238
+ if (
239
+ !parsed ||
240
+ typeof parsed.id !== "string" ||
241
+ typeof parsed.createdAt !== "number" ||
242
+ typeof parsed.requesterSessionId !== "string" ||
243
+ typeof parsed.targetSessionId !== "string" ||
244
+ typeof parsed.requesterAgentName !== "string" ||
245
+ typeof parsed.message !== "string"
246
+ ) {
247
+ logPermissionForwardingWarning(
248
+ `Ignoring invalid forwarded permission request format in '${filePath}'`,
249
+ );
250
+ return null;
251
+ }
252
+
253
+ return {
254
+ id: parsed.id,
255
+ createdAt: parsed.createdAt,
256
+ requesterSessionId: parsed.requesterSessionId,
257
+ targetSessionId: parsed.targetSessionId,
258
+ requesterAgentName: parsed.requesterAgentName,
259
+ message: parsed.message,
260
+ };
261
+ } catch (error) {
262
+ logPermissionForwardingWarning(
263
+ `Failed to read forwarded permission request '${filePath}'`,
264
+ error,
265
+ );
266
+ return null;
267
+ }
268
+ }
269
+
270
+ export function readForwardedPermissionResponse(
271
+ filePath: string,
272
+ ): ForwardedPermissionResponse | null {
273
+ try {
274
+ const raw = readFileSync(filePath, "utf-8");
275
+ const parsed = JSON.parse(raw) as Partial<ForwardedPermissionResponse>;
276
+ if (
277
+ !parsed ||
278
+ typeof parsed.approved !== "boolean" ||
279
+ !isPermissionDecisionState(parsed.state) ||
280
+ typeof parsed.responderSessionId !== "string"
281
+ ) {
282
+ logPermissionForwardingWarning(
283
+ `Ignoring invalid forwarded permission response format in '${filePath}'`,
284
+ );
285
+ return null;
286
+ }
287
+
288
+ return {
289
+ approved: parsed.approved,
290
+ state: parsed.state,
291
+ denialReason:
292
+ typeof parsed.denialReason === "string"
293
+ ? parsed.denialReason
294
+ : undefined,
295
+ responderSessionId: parsed.responderSessionId,
296
+ respondedAt:
297
+ typeof parsed.respondedAt === "number"
298
+ ? parsed.respondedAt
299
+ : Date.now(),
300
+ };
301
+ } catch (error) {
302
+ logPermissionForwardingWarning(
303
+ `Failed to read forwarded permission response '${filePath}'`,
304
+ error,
305
+ );
306
+ return null;
307
+ }
308
+ }
309
+
310
+ export function listRequestFiles(requestsDir: string): string[] {
311
+ try {
312
+ return readdirSync(requestsDir)
313
+ .filter((name) => name.endsWith(".json"))
314
+ .sort();
315
+ } catch (error) {
316
+ logPermissionForwardingWarning(
317
+ `Failed to read permission forwarding requests from '${requestsDir}'`,
318
+ error,
319
+ );
320
+ return [];
321
+ }
322
+ }
323
+
324
+ export function sleep(ms: number): Promise<void> {
325
+ return new Promise((resolve) => {
326
+ setTimeout(resolve, ms);
327
+ });
328
+ }
@@ -0,0 +1,334 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
4
+
5
+ import {
6
+ getActiveAgentName,
7
+ getActiveAgentNameFromSystemPrompt,
8
+ } from "../active-agent.js";
9
+ import { toRecord } from "../common.js";
10
+ import type { PermissionPromptDecision } from "../permission-dialog.js";
11
+ import {
12
+ type ForwardedPermissionRequest,
13
+ type ForwardedPermissionResponse,
14
+ isForwardedPermissionRequestForSession,
15
+ PERMISSION_FORWARDING_POLL_INTERVAL_MS,
16
+ PERMISSION_FORWARDING_TIMEOUT_MS,
17
+ resolvePermissionForwardingTargetSessionId,
18
+ } from "../permission-forwarding.js";
19
+ import { isSubagentExecutionContext } from "../subagent-context.js";
20
+
21
+ import {
22
+ cleanupPermissionForwardingLocationIfEmpty,
23
+ ensurePermissionForwardingLocation,
24
+ getExistingPermissionForwardingLocation,
25
+ listRequestFiles,
26
+ logPermissionForwardingError,
27
+ logPermissionForwardingWarning,
28
+ readForwardedPermissionRequest,
29
+ readForwardedPermissionResponse,
30
+ safeDeleteFile,
31
+ sleep,
32
+ writeJsonFileAtomic,
33
+ } from "./io.js";
34
+
35
+ export interface PermissionForwardingDeps {
36
+ forwardingDir: string;
37
+ subagentSessionsDir: string;
38
+ writeReviewLog: (event: string, details: Record<string, unknown>) => void;
39
+ requestPermissionDecisionFromUi: (
40
+ ui: ExtensionContext["ui"],
41
+ title: string,
42
+ message: string,
43
+ ) => Promise<PermissionPromptDecision>;
44
+ shouldAutoApprove: () => boolean;
45
+ }
46
+
47
+ export function getSessionId(ctx: ExtensionContext): string {
48
+ try {
49
+ const sessionId = ctx.sessionManager.getSessionId();
50
+ if (typeof sessionId === "string" && sessionId.trim()) {
51
+ return sessionId.trim();
52
+ }
53
+ } catch {}
54
+
55
+ return "unknown";
56
+ }
57
+
58
+ function getContextSystemPrompt(ctx: ExtensionContext): string | undefined {
59
+ const getSystemPrompt = toRecord(ctx).getSystemPrompt;
60
+ if (typeof getSystemPrompt !== "function") {
61
+ return undefined;
62
+ }
63
+
64
+ try {
65
+ const systemPrompt = getSystemPrompt.call(ctx);
66
+ return typeof systemPrompt === "string" ? systemPrompt : undefined;
67
+ } catch (error) {
68
+ logPermissionForwardingWarning(
69
+ "Failed to read context system prompt for forwarded permission metadata",
70
+ error,
71
+ );
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ export function formatForwardedPermissionPrompt(
77
+ request: ForwardedPermissionRequest,
78
+ ): string {
79
+ const agentName = request.requesterAgentName || "unknown";
80
+ const sessionId = request.requesterSessionId || "unknown";
81
+ return [
82
+ `Subagent '${agentName}' requested permission.`,
83
+ `Session ID: ${sessionId}`,
84
+ "",
85
+ request.message,
86
+ ].join("\n");
87
+ }
88
+
89
+ export async function waitForForwardedPermissionApproval(
90
+ ctx: ExtensionContext,
91
+ message: string,
92
+ deps: PermissionForwardingDeps,
93
+ ): Promise<PermissionPromptDecision> {
94
+ const requesterSessionId = getSessionId(ctx);
95
+ const targetSessionId = resolvePermissionForwardingTargetSessionId({
96
+ hasUI: ctx.hasUI,
97
+ isSubagent: isSubagentExecutionContext(ctx, deps.subagentSessionsDir),
98
+ currentSessionId: requesterSessionId,
99
+ env: process.env,
100
+ });
101
+
102
+ if (!targetSessionId) {
103
+ logPermissionForwardingError(
104
+ "Permission forwarding target session could not be resolved from subagent runtime metadata (expected PI_AGENT_ROUTER_PARENT_SESSION_ID)",
105
+ );
106
+ return { approved: false, state: "denied" };
107
+ }
108
+
109
+ const location = ensurePermissionForwardingLocation(
110
+ deps.forwardingDir,
111
+ targetSessionId,
112
+ );
113
+ if (!location) {
114
+ logPermissionForwardingError(
115
+ `Permission forwarding is unavailable because session-scoped directories could not be prepared for '${targetSessionId}'`,
116
+ );
117
+ return { approved: false, state: "denied" };
118
+ }
119
+
120
+ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
121
+ const requesterAgentName =
122
+ getActiveAgentName(ctx) ||
123
+ getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ||
124
+ "unknown";
125
+ const request: ForwardedPermissionRequest = {
126
+ id: requestId,
127
+ createdAt: Date.now(),
128
+ requesterSessionId,
129
+ targetSessionId,
130
+ requesterAgentName,
131
+ message,
132
+ };
133
+
134
+ const requestPath = join(location.requestsDir, `${requestId}.json`);
135
+ const responsePath = join(location.responsesDir, `${requestId}.json`);
136
+
137
+ deps.writeReviewLog("forwarded_permission.request_created", {
138
+ requestId,
139
+ requesterAgentName,
140
+ requesterSessionId: request.requesterSessionId,
141
+ targetSessionId,
142
+ requestPath,
143
+ responsePath,
144
+ });
145
+
146
+ try {
147
+ writeJsonFileAtomic(requestPath, request);
148
+ } catch (error) {
149
+ logPermissionForwardingError(
150
+ `Failed to write forwarded permission request '${requestPath}'`,
151
+ error,
152
+ );
153
+ return { approved: false, state: "denied" };
154
+ }
155
+
156
+ const deadline = Date.now() + PERMISSION_FORWARDING_TIMEOUT_MS;
157
+ while (Date.now() < deadline) {
158
+ if (existsSync(responsePath)) {
159
+ const response = readForwardedPermissionResponse(responsePath);
160
+ deps.writeReviewLog("forwarded_permission.response_received", {
161
+ requestId,
162
+ approved: response?.approved ?? null,
163
+ state: response?.state ?? null,
164
+ denialReason: response?.denialReason ?? null,
165
+ responderSessionId: response?.responderSessionId ?? null,
166
+ targetSessionId,
167
+ responsePath,
168
+ });
169
+ safeDeleteFile(responsePath, "forwarded permission response");
170
+ safeDeleteFile(requestPath, "forwarded permission request");
171
+ cleanupPermissionForwardingLocationIfEmpty(location);
172
+ return response ?? { approved: false, state: "denied" };
173
+ }
174
+
175
+ await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
176
+ }
177
+
178
+ logPermissionForwardingWarning(
179
+ `Timed out waiting for forwarded permission response '${responsePath}'`,
180
+ );
181
+ deps.writeReviewLog("forwarded_permission.response_timed_out", {
182
+ requestId,
183
+ requesterAgentName,
184
+ targetSessionId,
185
+ responsePath,
186
+ });
187
+ safeDeleteFile(requestPath, "forwarded permission request");
188
+ cleanupPermissionForwardingLocationIfEmpty(location);
189
+ return { approved: false, state: "denied" };
190
+ }
191
+
192
+ export async function processForwardedPermissionRequests(
193
+ ctx: ExtensionContext,
194
+ deps: PermissionForwardingDeps,
195
+ ): Promise<void> {
196
+ if (!ctx.hasUI) {
197
+ return;
198
+ }
199
+
200
+ const currentSessionId = getSessionId(ctx);
201
+ const location = getExistingPermissionForwardingLocation(
202
+ deps.forwardingDir,
203
+ currentSessionId,
204
+ );
205
+ if (!location) {
206
+ return;
207
+ }
208
+
209
+ const requestFiles = listRequestFiles(location.requestsDir);
210
+ if (requestFiles.length === 0) {
211
+ return;
212
+ }
213
+
214
+ for (const fileName of requestFiles) {
215
+ const requestPath = join(location.requestsDir, fileName);
216
+ const request = readForwardedPermissionRequest(requestPath);
217
+ if (!request) {
218
+ safeDeleteFile(
219
+ requestPath,
220
+ `${location.label} forwarded permission request`,
221
+ );
222
+ continue;
223
+ }
224
+
225
+ if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
226
+ logPermissionForwardingWarning(
227
+ `Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
228
+ );
229
+ safeDeleteFile(
230
+ requestPath,
231
+ `${location.label} forwarded permission request`,
232
+ );
233
+ continue;
234
+ }
235
+
236
+ const forwardedPermissionLogDetails = {
237
+ requestId: request.id,
238
+ source: location.label,
239
+ requesterAgentName: request.requesterAgentName,
240
+ requesterSessionId: request.requesterSessionId,
241
+ targetSessionId: request.targetSessionId,
242
+ requestPath,
243
+ };
244
+
245
+ let decision: PermissionPromptDecision = {
246
+ approved: false,
247
+ state: "denied",
248
+ };
249
+ if (deps.shouldAutoApprove()) {
250
+ deps.writeReviewLog(
251
+ "forwarded_permission.auto_approved",
252
+ forwardedPermissionLogDetails,
253
+ );
254
+ decision = { approved: true, state: "approved" };
255
+ } else {
256
+ deps.writeReviewLog(
257
+ "forwarded_permission.prompted",
258
+ forwardedPermissionLogDetails,
259
+ );
260
+ try {
261
+ decision = await deps.requestPermissionDecisionFromUi(
262
+ ctx.ui,
263
+ "Permission Required (Subagent)",
264
+ formatForwardedPermissionPrompt(request),
265
+ );
266
+ } catch (error) {
267
+ logPermissionForwardingError(
268
+ "Failed to show forwarded permission confirmation dialog",
269
+ error,
270
+ );
271
+ decision = { approved: false, state: "denied" };
272
+ }
273
+ }
274
+
275
+ const responsePath = join(location.responsesDir, `${request.id}.json`);
276
+ deps.writeReviewLog(
277
+ decision.approved
278
+ ? "forwarded_permission.approved"
279
+ : "forwarded_permission.denied",
280
+ {
281
+ requestId: request.id,
282
+ source: location.label,
283
+ requesterAgentName: request.requesterAgentName,
284
+ requesterSessionId: request.requesterSessionId,
285
+ targetSessionId: request.targetSessionId,
286
+ responsePath,
287
+ resolution: decision.state,
288
+ denialReason: decision.denialReason ?? null,
289
+ },
290
+ );
291
+ try {
292
+ writeJsonFileAtomic(responsePath, {
293
+ approved: decision.approved,
294
+ state: decision.state,
295
+ denialReason: decision.denialReason,
296
+ responderSessionId: currentSessionId,
297
+ respondedAt: Date.now(),
298
+ } satisfies ForwardedPermissionResponse);
299
+ } catch (error) {
300
+ logPermissionForwardingError(
301
+ `Failed to write ${location.label} forwarded permission response '${responsePath}'`,
302
+ error,
303
+ );
304
+ continue;
305
+ }
306
+
307
+ safeDeleteFile(
308
+ requestPath,
309
+ `${location.label} forwarded permission request`,
310
+ );
311
+ }
312
+
313
+ cleanupPermissionForwardingLocationIfEmpty(location);
314
+ }
315
+
316
+ export async function confirmPermission(
317
+ ctx: ExtensionContext,
318
+ message: string,
319
+ deps: PermissionForwardingDeps,
320
+ ): Promise<PermissionPromptDecision> {
321
+ if (ctx.hasUI) {
322
+ return deps.requestPermissionDecisionFromUi(
323
+ ctx.ui,
324
+ "Permission Required",
325
+ message,
326
+ );
327
+ }
328
+
329
+ if (!isSubagentExecutionContext(ctx, deps.subagentSessionsDir)) {
330
+ return { approved: false, state: "denied" };
331
+ }
332
+
333
+ return waitForForwardedPermissionApproval(ctx, message, deps);
334
+ }