@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.
- package/CHANGELOG.md +26 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/agent-prep-session.ts +28 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +11 -0
- package/src/forwarded-permissions/permission-forwarder.ts +549 -0
- package/src/forwarding-manager.ts +3 -7
- package/src/gate-handler-session.ts +13 -0
- package/src/gate-prompter.ts +14 -0
- package/src/handlers/before-agent-start.ts +2 -3
- package/src/handlers/gates/bash-command.ts +4 -18
- package/src/handlers/gates/bash-external-directory.ts +3 -15
- package/src/handlers/gates/bash-path.ts +3 -16
- package/src/handlers/gates/descriptor.ts +0 -28
- package/src/handlers/gates/path.ts +3 -15
- package/src/handlers/gates/runner.ts +142 -105
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +44 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
- package/src/handlers/lifecycle.ts +9 -9
- package/src/handlers/permission-gate-handler.ts +34 -238
- package/src/index.ts +49 -69
- package/src/mcp-targets.ts +56 -46
- package/src/permission-prompter.ts +7 -58
- package/src/permission-resolver.ts +17 -0
- package/src/permission-session.ts +77 -9
- package/src/permissions-service.ts +53 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-lifecycle-session.ts +24 -0
- package/src/tool-input-preview.ts +0 -62
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +6 -4
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +62 -0
- package/test/forwarding-manager.test.ts +26 -44
- package/test/handlers/before-agent-start.test.ts +45 -21
- package/test/handlers/external-directory-integration.test.ts +86 -22
- package/test/handlers/external-directory-session-dedup.test.ts +102 -55
- package/test/handlers/gates/bash-command.test.ts +49 -90
- package/test/handlers/gates/bash-external-directory.test.ts +54 -95
- package/test/handlers/gates/bash-path.test.ts +63 -148
- package/test/handlers/gates/path.test.ts +38 -105
- package/test/handlers/gates/runner.test.ts +150 -93
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +128 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
- package/test/handlers/input.test.ts +1 -2
- package/test/handlers/lifecycle.test.ts +49 -33
- package/test/handlers/tool-call-events.test.ts +1 -1
- package/test/helpers/gate-fixtures.ts +147 -16
- package/test/helpers/handler-fixtures.ts +143 -27
- package/test/mcp-targets.test.ts +55 -0
- package/test/permission-forwarder.test.ts +295 -0
- package/test/permission-forwarding.test.ts +0 -282
- package/test/permission-prompter.test.ts +33 -44
- package/test/permission-session.test.ts +160 -27
- package/test/permissions-service.test.ts +151 -0
- package/test/runtime.test.ts +0 -4
- package/test/service-lifecycle.test.ts +162 -0
- package/test/tool-input-preview.test.ts +0 -111
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/src/forwarded-permissions/polling.ts +0 -411
|
@@ -0,0 +1,549 @@
|
|
|
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
|
+
emitUiPromptEvent,
|
|
16
|
+
type PermissionEventBus,
|
|
17
|
+
} from "#src/permission-events";
|
|
18
|
+
import {
|
|
19
|
+
type ForwardedPermissionRequest,
|
|
20
|
+
type ForwardedPermissionResponse,
|
|
21
|
+
type ForwardedPromptDisplay,
|
|
22
|
+
isForwardedPermissionRequestForSession,
|
|
23
|
+
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
|
24
|
+
PERMISSION_FORWARDING_TIMEOUT_MS,
|
|
25
|
+
type PermissionForwardingLocation,
|
|
26
|
+
resolvePermissionForwardingTargetSessionId,
|
|
27
|
+
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
28
|
+
} from "#src/permission-forwarding";
|
|
29
|
+
import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
|
|
30
|
+
import { isSubagentExecutionContext } from "#src/subagent-context";
|
|
31
|
+
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
cleanupPermissionForwardingLocationIfEmpty,
|
|
35
|
+
ensurePermissionForwardingLocation,
|
|
36
|
+
type ForwardedPermissionLogger,
|
|
37
|
+
getExistingPermissionForwardingLocation,
|
|
38
|
+
listRequestFiles,
|
|
39
|
+
logPermissionForwardingError,
|
|
40
|
+
logPermissionForwardingWarning,
|
|
41
|
+
readForwardedPermissionRequest,
|
|
42
|
+
readForwardedPermissionResponse,
|
|
43
|
+
safeDeleteFile,
|
|
44
|
+
sleep,
|
|
45
|
+
writeJsonFileAtomic,
|
|
46
|
+
} from "./io";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Constructor config for `PermissionForwarder`.
|
|
50
|
+
*
|
|
51
|
+
* Replaces the `PermissionForwardingDeps` interface that was previously
|
|
52
|
+
* threaded into free functions in `polling.ts`. The forwarder consumes it
|
|
53
|
+
* once at construction and stores each member as a private readonly field.
|
|
54
|
+
*/
|
|
55
|
+
export interface PermissionForwarderDeps {
|
|
56
|
+
forwardingDir: string;
|
|
57
|
+
subagentSessionsDir: string;
|
|
58
|
+
/** In-process subagent session registry for detection and forwarding target resolution. */
|
|
59
|
+
registry?: SubagentSessionRegistry;
|
|
60
|
+
/** Event bus used for UI prompt broadcasts. */
|
|
61
|
+
events?: PermissionEventBus;
|
|
62
|
+
logger: ForwardedPermissionLogger;
|
|
63
|
+
writeReviewLog: (event: string, details: Record<string, unknown>) => void;
|
|
64
|
+
requestPermissionDecisionFromUi: (
|
|
65
|
+
ui: ExtensionContext["ui"],
|
|
66
|
+
title: string,
|
|
67
|
+
message: string,
|
|
68
|
+
options?: RequestPermissionOptions,
|
|
69
|
+
) => Promise<PermissionPromptDecision>;
|
|
70
|
+
shouldAutoApprove: () => boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Module-private helpers ────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
function getSessionId(ctx: ExtensionContext): string {
|
|
76
|
+
try {
|
|
77
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
78
|
+
if (typeof sessionId === "string" && sessionId.trim()) {
|
|
79
|
+
return sessionId.trim();
|
|
80
|
+
}
|
|
81
|
+
} catch {}
|
|
82
|
+
|
|
83
|
+
return "unknown";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getContextSystemPrompt(ctx: ExtensionContext): string | undefined {
|
|
87
|
+
const getSystemPrompt = toRecord(ctx).getSystemPrompt;
|
|
88
|
+
if (typeof getSystemPrompt !== "function") {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- getSystemPrompt is a Pi SDK accessor returning any
|
|
94
|
+
const systemPrompt = getSystemPrompt.call(ctx);
|
|
95
|
+
return typeof systemPrompt === "string" ? systemPrompt : undefined;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// No deps available in this helper — warning silently dropped.
|
|
98
|
+
logPermissionForwardingWarning(
|
|
99
|
+
null,
|
|
100
|
+
"Failed to read context system prompt for forwarded permission metadata",
|
|
101
|
+
error,
|
|
102
|
+
);
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formatForwardedPermissionPrompt(
|
|
108
|
+
request: ForwardedPermissionRequest,
|
|
109
|
+
): string {
|
|
110
|
+
const agentName = request.requesterAgentName || "unknown";
|
|
111
|
+
const sessionId = request.requesterSessionId || "unknown";
|
|
112
|
+
return [
|
|
113
|
+
`Subagent '${agentName}' requested permission.`,
|
|
114
|
+
`Session ID: ${sessionId}`,
|
|
115
|
+
"",
|
|
116
|
+
request.message,
|
|
117
|
+
].join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Public seam interfaces ────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Narrow seam describing what `PermissionPrompter` needs from the forwarder:
|
|
124
|
+
* a single method that resolves a permission decision for the current context
|
|
125
|
+
* (prompt directly when the session has UI, otherwise forward to the parent).
|
|
126
|
+
*
|
|
127
|
+
* Depending on the interface (not the concrete `PermissionForwarder`) keeps
|
|
128
|
+
* the prompter's unit tests free of casts — they inject a plain
|
|
129
|
+
* `{ requestApproval: vi.fn() }` mock.
|
|
130
|
+
*/
|
|
131
|
+
export interface ApprovalRequester {
|
|
132
|
+
requestApproval(
|
|
133
|
+
ctx: ExtensionContext,
|
|
134
|
+
message: string,
|
|
135
|
+
options?: RequestPermissionOptions,
|
|
136
|
+
forwarded?: ForwardedPromptDisplay,
|
|
137
|
+
): Promise<PermissionPromptDecision>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Narrow seam describing what `ForwardingManager` needs from the forwarder:
|
|
142
|
+
* a single method that drains this session's forwarded-permission inbox.
|
|
143
|
+
*
|
|
144
|
+
* Depending on the interface (not the concrete `PermissionForwarder`) keeps
|
|
145
|
+
* the manager's unit tests free of casts — they inject a plain
|
|
146
|
+
* `{ processInbox: vi.fn() }` mock.
|
|
147
|
+
*/
|
|
148
|
+
export interface InboxProcessor {
|
|
149
|
+
processInbox(ctx: ExtensionContext): Promise<void>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── PermissionForwarder ───────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Owner of the forwarded-permission behavior.
|
|
156
|
+
*
|
|
157
|
+
* Holds all forwarding state as private readonly fields and provides two
|
|
158
|
+
* public methods (`requestApproval`, `processInbox`) that together encapsulate
|
|
159
|
+
* the full forwarding lifecycle: deciding whether to prompt directly or
|
|
160
|
+
* forward to the parent, building and persisting request files, polling for
|
|
161
|
+
* responses, and processing the parent-session inbox.
|
|
162
|
+
*/
|
|
163
|
+
export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
164
|
+
private readonly forwardingDir: string;
|
|
165
|
+
private readonly subagentSessionsDir: string;
|
|
166
|
+
private readonly registry: SubagentSessionRegistry | undefined;
|
|
167
|
+
private readonly events: PermissionEventBus | undefined;
|
|
168
|
+
private readonly logger: ForwardedPermissionLogger;
|
|
169
|
+
private readonly writeReviewLog: (
|
|
170
|
+
event: string,
|
|
171
|
+
details: Record<string, unknown>,
|
|
172
|
+
) => void;
|
|
173
|
+
private readonly requestPermissionDecisionFromUi: (
|
|
174
|
+
ui: ExtensionContext["ui"],
|
|
175
|
+
title: string,
|
|
176
|
+
message: string,
|
|
177
|
+
options?: RequestPermissionOptions,
|
|
178
|
+
) => Promise<PermissionPromptDecision>;
|
|
179
|
+
private readonly shouldAutoApprove: () => boolean;
|
|
180
|
+
|
|
181
|
+
constructor(deps: PermissionForwarderDeps) {
|
|
182
|
+
this.forwardingDir = deps.forwardingDir;
|
|
183
|
+
this.subagentSessionsDir = deps.subagentSessionsDir;
|
|
184
|
+
this.registry = deps.registry;
|
|
185
|
+
this.events = deps.events;
|
|
186
|
+
this.logger = deps.logger;
|
|
187
|
+
this.writeReviewLog = deps.writeReviewLog;
|
|
188
|
+
this.requestPermissionDecisionFromUi = deps.requestPermissionDecisionFromUi;
|
|
189
|
+
this.shouldAutoApprove = deps.shouldAutoApprove;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Public seam methods ────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolve a permission decision for the current context: prompt directly
|
|
196
|
+
* when this session has UI, otherwise forward to the parent session.
|
|
197
|
+
*/
|
|
198
|
+
requestApproval(
|
|
199
|
+
ctx: ExtensionContext,
|
|
200
|
+
message: string,
|
|
201
|
+
options?: RequestPermissionOptions,
|
|
202
|
+
forwarded?: ForwardedPromptDisplay,
|
|
203
|
+
): Promise<PermissionPromptDecision> {
|
|
204
|
+
if (ctx.hasUI) {
|
|
205
|
+
return this.requestPermissionDecisionFromUi(
|
|
206
|
+
ctx.ui,
|
|
207
|
+
"Permission Required",
|
|
208
|
+
message,
|
|
209
|
+
options,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (
|
|
214
|
+
!isSubagentExecutionContext(ctx, this.subagentSessionsDir, this.registry)
|
|
215
|
+
) {
|
|
216
|
+
return Promise.resolve({ approved: false, state: "denied" });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return this.waitForForwardedApproval(ctx, message, forwarded);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Drain and respond to this session's forwarded-permission inbox. */
|
|
223
|
+
async processInbox(ctx: ExtensionContext): Promise<void> {
|
|
224
|
+
if (!ctx.hasUI) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const currentSessionId = getSessionId(ctx);
|
|
229
|
+
const location = getExistingPermissionForwardingLocation(
|
|
230
|
+
this.forwardingDir,
|
|
231
|
+
currentSessionId,
|
|
232
|
+
);
|
|
233
|
+
if (!location) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const requestFiles = listRequestFiles(this.logger, location.requestsDir);
|
|
238
|
+
if (requestFiles.length === 0) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const fileName of requestFiles) {
|
|
243
|
+
const requestPath = join(location.requestsDir, fileName);
|
|
244
|
+
const request = readForwardedPermissionRequest(this.logger, requestPath);
|
|
245
|
+
if (!request) {
|
|
246
|
+
safeDeleteFile(
|
|
247
|
+
this.logger,
|
|
248
|
+
requestPath,
|
|
249
|
+
`${location.label} forwarded permission request`,
|
|
250
|
+
);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await this.processSingleForwardedRequest(
|
|
255
|
+
ctx,
|
|
256
|
+
request,
|
|
257
|
+
location,
|
|
258
|
+
requestPath,
|
|
259
|
+
currentSessionId,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Private methods ────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
private async waitForForwardedApproval(
|
|
269
|
+
ctx: ExtensionContext,
|
|
270
|
+
message: string,
|
|
271
|
+
forwarded?: ForwardedPromptDisplay,
|
|
272
|
+
): Promise<PermissionPromptDecision> {
|
|
273
|
+
const requesterSessionId = getSessionId(ctx);
|
|
274
|
+
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
275
|
+
hasUI: ctx.hasUI,
|
|
276
|
+
isSubagent: isSubagentExecutionContext(
|
|
277
|
+
ctx,
|
|
278
|
+
this.subagentSessionsDir,
|
|
279
|
+
this.registry,
|
|
280
|
+
),
|
|
281
|
+
currentSessionId: requesterSessionId,
|
|
282
|
+
env: process.env,
|
|
283
|
+
sessionId: requesterSessionId,
|
|
284
|
+
registry: this.registry,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (!targetSessionId) {
|
|
288
|
+
logPermissionForwardingError(
|
|
289
|
+
this.logger,
|
|
290
|
+
`Permission forwarding target session could not be resolved. ` +
|
|
291
|
+
`Checked env vars: ${SUBAGENT_PARENT_SESSION_ENV_CANDIDATES.join(", ")}. ` +
|
|
292
|
+
`If you are using a subagent extension (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.), ` +
|
|
293
|
+
`ask its maintainer to set PI_SUBAGENT_PARENT_SESSION in the child process environment ` +
|
|
294
|
+
`(see https://github.com/gotgenes/pi-permission-system/issues/143).`,
|
|
295
|
+
);
|
|
296
|
+
return { approved: false, state: "denied" };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const location = ensurePermissionForwardingLocation(
|
|
300
|
+
this.logger,
|
|
301
|
+
this.forwardingDir,
|
|
302
|
+
targetSessionId,
|
|
303
|
+
);
|
|
304
|
+
if (!location) {
|
|
305
|
+
logPermissionForwardingError(
|
|
306
|
+
this.logger,
|
|
307
|
+
`Permission forwarding is unavailable because session-scoped directories could not be prepared for '${targetSessionId}'`,
|
|
308
|
+
);
|
|
309
|
+
return { approved: false, state: "denied" };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const request = this.buildForwardedRequest(
|
|
313
|
+
ctx,
|
|
314
|
+
message,
|
|
315
|
+
requesterSessionId,
|
|
316
|
+
targetSessionId,
|
|
317
|
+
forwarded,
|
|
318
|
+
);
|
|
319
|
+
const requestPath = join(location.requestsDir, `${request.id}.json`);
|
|
320
|
+
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
|
321
|
+
|
|
322
|
+
this.writeReviewLog("forwarded_permission.request_created", {
|
|
323
|
+
requestId: request.id,
|
|
324
|
+
requesterAgentName: request.requesterAgentName,
|
|
325
|
+
requesterSessionId: request.requesterSessionId,
|
|
326
|
+
targetSessionId,
|
|
327
|
+
requestPath,
|
|
328
|
+
responsePath,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
writeJsonFileAtomic(this.logger, requestPath, request);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
logPermissionForwardingError(
|
|
335
|
+
this.logger,
|
|
336
|
+
`Failed to write forwarded permission request '${requestPath}'`,
|
|
337
|
+
error,
|
|
338
|
+
);
|
|
339
|
+
return { approved: false, state: "denied" };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return this.pollForForwardedResponse(
|
|
343
|
+
location,
|
|
344
|
+
request,
|
|
345
|
+
requestPath,
|
|
346
|
+
responsePath,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private buildForwardedRequest(
|
|
351
|
+
ctx: ExtensionContext,
|
|
352
|
+
message: string,
|
|
353
|
+
requesterSessionId: string,
|
|
354
|
+
targetSessionId: string,
|
|
355
|
+
forwarded?: ForwardedPromptDisplay,
|
|
356
|
+
): ForwardedPermissionRequest {
|
|
357
|
+
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
|
|
358
|
+
const requesterAgentName =
|
|
359
|
+
getActiveAgentName(ctx) ??
|
|
360
|
+
getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
|
|
361
|
+
"unknown";
|
|
362
|
+
return {
|
|
363
|
+
id: requestId,
|
|
364
|
+
createdAt: Date.now(),
|
|
365
|
+
requesterSessionId,
|
|
366
|
+
targetSessionId,
|
|
367
|
+
requesterAgentName,
|
|
368
|
+
message,
|
|
369
|
+
...(forwarded
|
|
370
|
+
? {
|
|
371
|
+
source: forwarded.source,
|
|
372
|
+
surface: forwarded.surface,
|
|
373
|
+
value: forwarded.value,
|
|
374
|
+
}
|
|
375
|
+
: {}),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private async pollForForwardedResponse(
|
|
380
|
+
location: PermissionForwardingLocation,
|
|
381
|
+
request: ForwardedPermissionRequest,
|
|
382
|
+
requestPath: string,
|
|
383
|
+
responsePath: string,
|
|
384
|
+
): Promise<PermissionPromptDecision> {
|
|
385
|
+
const { id: requestId, requesterAgentName, targetSessionId } = request;
|
|
386
|
+
const deadline = Date.now() + PERMISSION_FORWARDING_TIMEOUT_MS;
|
|
387
|
+
|
|
388
|
+
while (Date.now() < deadline) {
|
|
389
|
+
if (existsSync(responsePath)) {
|
|
390
|
+
const response = readForwardedPermissionResponse(
|
|
391
|
+
this.logger,
|
|
392
|
+
responsePath,
|
|
393
|
+
);
|
|
394
|
+
this.writeReviewLog("forwarded_permission.response_received", {
|
|
395
|
+
requestId,
|
|
396
|
+
approved: response?.approved ?? null,
|
|
397
|
+
state: response?.state ?? null,
|
|
398
|
+
denialReason: response?.denialReason ?? null,
|
|
399
|
+
responderSessionId: response?.responderSessionId ?? null,
|
|
400
|
+
targetSessionId,
|
|
401
|
+
responsePath,
|
|
402
|
+
});
|
|
403
|
+
safeDeleteFile(
|
|
404
|
+
this.logger,
|
|
405
|
+
responsePath,
|
|
406
|
+
"forwarded permission response",
|
|
407
|
+
);
|
|
408
|
+
safeDeleteFile(
|
|
409
|
+
this.logger,
|
|
410
|
+
requestPath,
|
|
411
|
+
"forwarded permission request",
|
|
412
|
+
);
|
|
413
|
+
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
|
414
|
+
return response ?? { approved: false, state: "denied" };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
logPermissionForwardingWarning(
|
|
421
|
+
this.logger,
|
|
422
|
+
`Timed out waiting for forwarded permission response '${responsePath}'`,
|
|
423
|
+
);
|
|
424
|
+
this.writeReviewLog("forwarded_permission.response_timed_out", {
|
|
425
|
+
requestId,
|
|
426
|
+
requesterAgentName,
|
|
427
|
+
targetSessionId,
|
|
428
|
+
responsePath,
|
|
429
|
+
});
|
|
430
|
+
safeDeleteFile(this.logger, requestPath, "forwarded permission request");
|
|
431
|
+
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
|
432
|
+
return { approved: false, state: "denied" };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private async processSingleForwardedRequest(
|
|
436
|
+
ctx: ExtensionContext,
|
|
437
|
+
request: ForwardedPermissionRequest,
|
|
438
|
+
location: PermissionForwardingLocation,
|
|
439
|
+
requestPath: string,
|
|
440
|
+
currentSessionId: string,
|
|
441
|
+
): Promise<void> {
|
|
442
|
+
if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
|
|
443
|
+
logPermissionForwardingWarning(
|
|
444
|
+
this.logger,
|
|
445
|
+
`Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
|
|
446
|
+
);
|
|
447
|
+
safeDeleteFile(
|
|
448
|
+
this.logger,
|
|
449
|
+
requestPath,
|
|
450
|
+
`${location.label} forwarded permission request`,
|
|
451
|
+
);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const forwardedPermissionLogDetails = {
|
|
456
|
+
requestId: request.id,
|
|
457
|
+
source: location.label,
|
|
458
|
+
requesterAgentName: request.requesterAgentName,
|
|
459
|
+
requesterSessionId: request.requesterSessionId,
|
|
460
|
+
targetSessionId: request.targetSessionId,
|
|
461
|
+
requestPath,
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
let decision: PermissionPromptDecision = {
|
|
465
|
+
approved: false,
|
|
466
|
+
state: "denied",
|
|
467
|
+
};
|
|
468
|
+
if (this.shouldAutoApprove()) {
|
|
469
|
+
this.writeReviewLog(
|
|
470
|
+
"forwarded_permission.auto_approved",
|
|
471
|
+
forwardedPermissionLogDetails,
|
|
472
|
+
);
|
|
473
|
+
decision = { approved: true, state: "approved" };
|
|
474
|
+
} else {
|
|
475
|
+
this.writeReviewLog(
|
|
476
|
+
"forwarded_permission.prompted",
|
|
477
|
+
forwardedPermissionLogDetails,
|
|
478
|
+
);
|
|
479
|
+
try {
|
|
480
|
+
const forwardedMessage = formatForwardedPermissionPrompt(request);
|
|
481
|
+
if (this.events) {
|
|
482
|
+
emitUiPromptEvent(
|
|
483
|
+
this.events,
|
|
484
|
+
buildForwardedUiPrompt({
|
|
485
|
+
requestId: request.id,
|
|
486
|
+
message: forwardedMessage,
|
|
487
|
+
requesterAgentName: request.requesterAgentName || null,
|
|
488
|
+
requesterSessionId: request.requesterSessionId || null,
|
|
489
|
+
source: request.source ?? null,
|
|
490
|
+
surface: request.surface ?? null,
|
|
491
|
+
value: request.value ?? null,
|
|
492
|
+
}),
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
decision = await this.requestPermissionDecisionFromUi(
|
|
496
|
+
ctx.ui,
|
|
497
|
+
"Permission Required (Subagent)",
|
|
498
|
+
forwardedMessage,
|
|
499
|
+
);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
logPermissionForwardingError(
|
|
502
|
+
this.logger,
|
|
503
|
+
"Failed to show forwarded permission confirmation dialog",
|
|
504
|
+
error,
|
|
505
|
+
);
|
|
506
|
+
decision = { approved: false, state: "denied" };
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
|
511
|
+
this.writeReviewLog(
|
|
512
|
+
decision.approved
|
|
513
|
+
? "forwarded_permission.approved"
|
|
514
|
+
: "forwarded_permission.denied",
|
|
515
|
+
{
|
|
516
|
+
requestId: request.id,
|
|
517
|
+
source: location.label,
|
|
518
|
+
requesterAgentName: request.requesterAgentName,
|
|
519
|
+
requesterSessionId: request.requesterSessionId,
|
|
520
|
+
targetSessionId: request.targetSessionId,
|
|
521
|
+
responsePath,
|
|
522
|
+
resolution: decision.state,
|
|
523
|
+
denialReason: decision.denialReason ?? null,
|
|
524
|
+
},
|
|
525
|
+
);
|
|
526
|
+
try {
|
|
527
|
+
writeJsonFileAtomic(this.logger, responsePath, {
|
|
528
|
+
approved: decision.approved,
|
|
529
|
+
state: decision.state,
|
|
530
|
+
denialReason: decision.denialReason,
|
|
531
|
+
responderSessionId: currentSessionId,
|
|
532
|
+
respondedAt: Date.now(),
|
|
533
|
+
} satisfies ForwardedPermissionResponse);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
logPermissionForwardingError(
|
|
536
|
+
this.logger,
|
|
537
|
+
`Failed to write ${location.label} forwarded permission response '${responsePath}'`,
|
|
538
|
+
error,
|
|
539
|
+
);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
safeDeleteFile(
|
|
544
|
+
this.logger,
|
|
545
|
+
requestPath,
|
|
546
|
+
`${location.label} forwarded permission request`,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
import { processForwardedPermissionRequests } from "./forwarded-permissions/polling";
|
|
3
|
+
import type { InboxProcessor } from "./forwarded-permissions/permission-forwarder";
|
|
5
4
|
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
|
|
6
5
|
import { isSubagentExecutionContext } from "./subagent-context";
|
|
7
6
|
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
@@ -30,7 +29,7 @@ export class ForwardingManager {
|
|
|
30
29
|
|
|
31
30
|
constructor(
|
|
32
31
|
private readonly subagentSessionsDir: string,
|
|
33
|
-
private readonly
|
|
32
|
+
private readonly forwarder: InboxProcessor,
|
|
34
33
|
private readonly registry?: SubagentSessionRegistry,
|
|
35
34
|
) {}
|
|
36
35
|
|
|
@@ -57,10 +56,7 @@ export class ForwardingManager {
|
|
|
57
56
|
return;
|
|
58
57
|
}
|
|
59
58
|
this.processing = true;
|
|
60
|
-
void
|
|
61
|
-
this.context,
|
|
62
|
-
this.forwardingDeps,
|
|
63
|
-
).finally(() => {
|
|
59
|
+
void this.forwarder.processInbox(this.context).finally(() => {
|
|
64
60
|
this.processing = false;
|
|
65
61
|
});
|
|
66
62
|
}, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The session surface `PermissionGateHandler` invokes directly: bind the
|
|
5
|
+
* per-event context and identify the agent.
|
|
6
|
+
*
|
|
7
|
+
* This is the two-method context role both entry points share after [#329]
|
|
8
|
+
* extracted `SkillInputGatePipeline` to own the skill-input gate assembly.
|
|
9
|
+
*/
|
|
10
|
+
export interface GateHandlerSession {
|
|
11
|
+
activate(ctx: ExtensionContext): void;
|
|
12
|
+
resolveAgentName(ctx: ExtensionContext, systemPrompt?: string): string | null;
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
2
|
+
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The prompting role the gate runner needs: a yes/no on whether an
|
|
6
|
+
* interactive confirmation is possible, and the prompt itself. The context
|
|
7
|
+
* is bound by the implementor, not threaded per call.
|
|
8
|
+
*/
|
|
9
|
+
export interface GatePrompter {
|
|
10
|
+
canConfirm(): boolean;
|
|
11
|
+
promptPermission(
|
|
12
|
+
details: PromptPermissionDetails,
|
|
13
|
+
): Promise<PermissionPromptDecision>;
|
|
14
|
+
}
|
|
@@ -2,12 +2,11 @@ import type {
|
|
|
2
2
|
BeforeAgentStartEventResult,
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
|
|
5
|
+
import type { AgentPrepSession } from "#src/agent-prep-session";
|
|
6
6
|
import {
|
|
7
7
|
createActiveToolsCacheKey,
|
|
8
8
|
createBeforeAgentStartPromptStateKey,
|
|
9
9
|
} from "#src/before-agent-start-cache";
|
|
10
|
-
import type { PermissionSession } from "#src/permission-session";
|
|
11
10
|
import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
|
|
12
11
|
import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
|
|
13
12
|
import { getToolNameFromValue, type ToolRegistry } from "#src/tool-registry";
|
|
@@ -41,7 +40,7 @@ export function shouldExposeTool(
|
|
|
41
40
|
*/
|
|
42
41
|
export class AgentPrepHandler {
|
|
43
42
|
constructor(
|
|
44
|
-
private readonly session:
|
|
43
|
+
private readonly session: AgentPrepSession,
|
|
45
44
|
private readonly toolRegistry: ToolRegistry,
|
|
46
45
|
) {}
|
|
47
46
|
|
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
import type { BashCommand } from "#src/handlers/gates/bash-program";
|
|
2
2
|
import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
|
|
3
|
-
import type {
|
|
3
|
+
import type { PermissionResolver } from "#src/permission-resolver";
|
|
4
4
|
import type { PermissionCheckResult } from "#src/types";
|
|
5
5
|
|
|
6
|
-
/** Function type for checkPermission used by the resolver. */
|
|
7
|
-
type CheckPermissionFn = (
|
|
8
|
-
surface: string,
|
|
9
|
-
input: unknown,
|
|
10
|
-
agentName?: string,
|
|
11
|
-
sessionRules?: Rule[],
|
|
12
|
-
) => PermissionCheckResult;
|
|
13
|
-
|
|
14
6
|
/**
|
|
15
7
|
* Resolve the bash command-pattern decision for a (possibly chained) command.
|
|
16
8
|
*
|
|
@@ -38,20 +30,14 @@ export function resolveBashCommandCheck(
|
|
|
38
30
|
command: string,
|
|
39
31
|
commands: BashCommand[],
|
|
40
32
|
agentName: string | undefined,
|
|
41
|
-
|
|
42
|
-
checkPermission: CheckPermissionFn,
|
|
33
|
+
resolver: PermissionResolver,
|
|
43
34
|
): PermissionCheckResult {
|
|
44
35
|
const results = commands.map((cmd) => {
|
|
45
|
-
const result =
|
|
46
|
-
"bash",
|
|
47
|
-
{ command: cmd.text },
|
|
48
|
-
agentName,
|
|
49
|
-
sessionRules,
|
|
50
|
-
);
|
|
36
|
+
const result = resolver.resolve("bash", { command: cmd.text }, agentName);
|
|
51
37
|
return cmd.context ? { ...result, commandContext: cmd.context } : result;
|
|
52
38
|
});
|
|
53
39
|
return (
|
|
54
40
|
pickMostRestrictive(results) ??
|
|
55
|
-
|
|
41
|
+
resolver.resolve("bash", { command }, agentName)
|
|
56
42
|
);
|
|
57
43
|
}
|