@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.
- package/CHANGELOG.md +20 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +96 -12
- package/src/active-agent.ts +58 -0
- package/src/external-directory.ts +113 -0
- package/src/forwarded-permissions/io.ts +328 -0
- package/src/forwarded-permissions/polling.ts +334 -0
- package/src/index.ts +59 -1062
- package/src/permission-prompts.ts +131 -0
- package/src/subagent-context.ts +52 -0
- package/src/tool-input-preview.ts +206 -0
- package/tests/active-agent.test.ts +160 -0
- package/tests/bash-filter.test.ts +142 -0
- package/tests/common.test.ts +189 -0
- package/tests/external-directory.test.ts +254 -0
- package/tests/permission-prompts.test.ts +304 -0
- package/tests/skill-prompt-sanitizer.test.ts +244 -0
- package/tests/subagent-context.test.ts +124 -0
- package/tests/system-prompt-sanitizer.test.ts +186 -0
- package/tests/tool-input-preview.test.ts +455 -0
- package/tests/tool-registry.test.ts +155 -0
- package/tests/wildcard-matcher.test.ts +180 -0
- package/tests/yolo-mode.test.ts +110 -0
package/src/index.ts
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
existsSync,
|
|
3
3
|
mkdirSync,
|
|
4
|
-
readdirSync,
|
|
5
|
-
readFileSync,
|
|
6
4
|
renameSync,
|
|
7
|
-
rmdirSync,
|
|
8
5
|
unlinkSync,
|
|
9
6
|
writeFileSync,
|
|
10
7
|
} from "node:fs";
|
|
11
|
-
import {
|
|
12
|
-
import { dirname, join, normalize, resolve, sep } from "node:path";
|
|
8
|
+
import { dirname, join, normalize } from "node:path";
|
|
13
9
|
import {
|
|
14
10
|
type ExtensionAPI,
|
|
15
11
|
type ExtensionCommandContext,
|
|
@@ -17,12 +13,16 @@ import {
|
|
|
17
13
|
getAgentDir,
|
|
18
14
|
isToolCallEventType,
|
|
19
15
|
} from "@mariozechner/pi-coding-agent";
|
|
16
|
+
import {
|
|
17
|
+
getActiveAgentName,
|
|
18
|
+
getActiveAgentNameFromSystemPrompt,
|
|
19
|
+
} from "./active-agent.js";
|
|
20
20
|
import {
|
|
21
21
|
createActiveToolsCacheKey,
|
|
22
22
|
createBeforeAgentStartPromptStateKey,
|
|
23
23
|
shouldApplyCachedAgentStartState,
|
|
24
24
|
} from "./before-agent-start-cache.js";
|
|
25
|
-
import {
|
|
25
|
+
import { toRecord } from "./common.js";
|
|
26
26
|
import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader.js";
|
|
27
27
|
import { registerPermissionSystemCommand } from "./config-modal.js";
|
|
28
28
|
import {
|
|
@@ -43,24 +43,38 @@ import {
|
|
|
43
43
|
normalizePermissionSystemConfig,
|
|
44
44
|
type PermissionSystemExtensionConfig,
|
|
45
45
|
} from "./extension-config.js";
|
|
46
|
-
import { createPermissionSystemLogger, safeJsonStringify } from "./logging.js";
|
|
47
46
|
import {
|
|
48
|
-
|
|
47
|
+
formatExternalDirectoryAskPrompt,
|
|
48
|
+
formatExternalDirectoryDenyReason,
|
|
49
|
+
formatExternalDirectoryUserDeniedReason,
|
|
50
|
+
getPathBearingToolPath,
|
|
51
|
+
isPathOutsideWorkingDirectory,
|
|
52
|
+
normalizePathForComparison,
|
|
53
|
+
PATH_BEARING_TOOLS,
|
|
54
|
+
} from "./external-directory.js";
|
|
55
|
+
import { setForwardedPermissionLogger } from "./forwarded-permissions/io.js";
|
|
56
|
+
import {
|
|
57
|
+
confirmPermission,
|
|
58
|
+
type PermissionForwardingDeps,
|
|
59
|
+
processForwardedPermissionRequests,
|
|
60
|
+
} from "./forwarded-permissions/polling.js";
|
|
61
|
+
import { createPermissionSystemLogger } from "./logging.js";
|
|
62
|
+
import {
|
|
49
63
|
type PermissionPromptDecision,
|
|
50
64
|
requestPermissionDecisionFromUi,
|
|
51
65
|
} from "./permission-dialog.js";
|
|
52
|
-
import {
|
|
53
|
-
createPermissionForwardingLocation,
|
|
54
|
-
type ForwardedPermissionRequest,
|
|
55
|
-
type ForwardedPermissionResponse,
|
|
56
|
-
isForwardedPermissionRequestForSession,
|
|
57
|
-
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
|
58
|
-
PERMISSION_FORWARDING_TIMEOUT_MS,
|
|
59
|
-
type PermissionForwardingLocation,
|
|
60
|
-
resolvePermissionForwardingTargetSessionId,
|
|
61
|
-
SUBAGENT_ENV_HINT_KEYS,
|
|
62
|
-
} from "./permission-forwarding.js";
|
|
66
|
+
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding.js";
|
|
63
67
|
import { PermissionManager } from "./permission-manager.js";
|
|
68
|
+
import {
|
|
69
|
+
formatAskPrompt,
|
|
70
|
+
formatDenyReason,
|
|
71
|
+
formatMissingToolNameReason,
|
|
72
|
+
formatSkillAskPrompt,
|
|
73
|
+
formatSkillPathAskPrompt,
|
|
74
|
+
formatSkillPathDenyReason,
|
|
75
|
+
formatUnknownToolReason,
|
|
76
|
+
formatUserDeniedReason,
|
|
77
|
+
} from "./permission-prompts.js";
|
|
64
78
|
import {
|
|
65
79
|
findSkillPathMatch,
|
|
66
80
|
resolveSkillPromptEntries,
|
|
@@ -70,12 +84,13 @@ import {
|
|
|
70
84
|
PERMISSION_SYSTEM_STATUS_KEY,
|
|
71
85
|
syncPermissionSystemStatus,
|
|
72
86
|
} from "./status.js";
|
|
87
|
+
import { isSubagentExecutionContext } from "./subagent-context.js";
|
|
73
88
|
import { sanitizeAvailableToolsSection } from "./system-prompt-sanitizer.js";
|
|
89
|
+
import { getPermissionLogContext } from "./tool-input-preview.js";
|
|
74
90
|
import {
|
|
75
91
|
checkRequestedToolRegistration,
|
|
76
92
|
getToolNameFromValue,
|
|
77
93
|
} from "./tool-registry.js";
|
|
78
|
-
import type { PermissionCheckResult } from "./types.js";
|
|
79
94
|
import {
|
|
80
95
|
canResolveAskPermissionRequest,
|
|
81
96
|
shouldAutoApprovePermissionState,
|
|
@@ -86,17 +101,7 @@ const SESSIONS_DIR = join(PI_AGENT_DIR, "sessions");
|
|
|
86
101
|
const SUBAGENT_SESSIONS_DIR = join(PI_AGENT_DIR, "subagent-sessions");
|
|
87
102
|
const PERMISSION_FORWARDING_DIR = join(SESSIONS_DIR, "permission-forwarding");
|
|
88
103
|
|
|
89
|
-
const ACTIVE_AGENT_TAG_REGEX = /<active_agent\s+name=["']([^"']+)["'][^>]*>/i;
|
|
90
|
-
|
|
91
104
|
type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
|
92
|
-
const PATH_BEARING_TOOLS = new Set([
|
|
93
|
-
"read",
|
|
94
|
-
"write",
|
|
95
|
-
"edit",
|
|
96
|
-
"find",
|
|
97
|
-
"grep",
|
|
98
|
-
"ls",
|
|
99
|
-
]);
|
|
100
105
|
|
|
101
106
|
let extensionConfig: PermissionSystemExtensionConfig = {
|
|
102
107
|
...DEFAULT_EXTENSION_CONFIG,
|
|
@@ -151,67 +156,6 @@ function writeReviewLog(
|
|
|
151
156
|
}
|
|
152
157
|
}
|
|
153
158
|
|
|
154
|
-
function normalizePathForComparison(pathValue: string, cwd: string): string {
|
|
155
|
-
const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
|
|
156
|
-
if (!trimmed) {
|
|
157
|
-
return "";
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
161
|
-
|
|
162
|
-
if (normalizedPath === "~") {
|
|
163
|
-
normalizedPath = homedir();
|
|
164
|
-
} else if (
|
|
165
|
-
normalizedPath.startsWith("~/") ||
|
|
166
|
-
normalizedPath.startsWith("~\\")
|
|
167
|
-
) {
|
|
168
|
-
normalizedPath = join(homedir(), normalizedPath.slice(2));
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const absolutePath = resolve(cwd, normalizedPath);
|
|
172
|
-
const normalizedAbsolutePath = normalize(absolutePath);
|
|
173
|
-
return process.platform === "win32"
|
|
174
|
-
? normalizedAbsolutePath.toLowerCase()
|
|
175
|
-
: normalizedAbsolutePath;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function isPathWithinDirectory(pathValue: string, directory: string): boolean {
|
|
179
|
-
if (!pathValue || !directory) {
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (pathValue === directory) {
|
|
184
|
-
return true;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
|
|
188
|
-
return pathValue.startsWith(prefix);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function getPathBearingToolPath(
|
|
192
|
-
toolName: string,
|
|
193
|
-
input: unknown,
|
|
194
|
-
): string | null {
|
|
195
|
-
if (!PATH_BEARING_TOOLS.has(toolName)) {
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
return getNonEmptyString(toRecord(input).path);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function isPathOutsideWorkingDirectory(
|
|
203
|
-
pathValue: string,
|
|
204
|
-
cwd: string,
|
|
205
|
-
): boolean {
|
|
206
|
-
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
207
|
-
const normalizedPath = normalizePathForComparison(pathValue, cwd);
|
|
208
|
-
return Boolean(
|
|
209
|
-
normalizedCwd &&
|
|
210
|
-
normalizedPath &&
|
|
211
|
-
!isPathWithinDirectory(normalizedPath, normalizedCwd),
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
159
|
function extractSkillNameFromInput(text: string): string | null {
|
|
216
160
|
const trimmed = text.trim();
|
|
217
161
|
if (!trimmed.startsWith("/skill:")) {
|
|
@@ -248,981 +192,14 @@ function getEventInput(event: unknown): unknown {
|
|
|
248
192
|
return {};
|
|
249
193
|
}
|
|
250
194
|
|
|
251
|
-
function normalizeAgentName(value: unknown): string | null {
|
|
252
|
-
if (typeof value !== "string") {
|
|
253
|
-
return null;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const trimmed = value.trim();
|
|
257
|
-
return trimmed ? trimmed : null;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function getActiveAgentName(ctx: ExtensionContext): string | null {
|
|
261
|
-
const entries = ctx.sessionManager.getEntries();
|
|
262
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
263
|
-
const entry = entries[i] as {
|
|
264
|
-
type: string;
|
|
265
|
-
customType?: string;
|
|
266
|
-
data?: unknown;
|
|
267
|
-
};
|
|
268
|
-
if (entry.type !== "custom" || entry.customType !== "active_agent") {
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const data = entry.data as { name?: unknown } | undefined;
|
|
273
|
-
const normalizedName = normalizeAgentName(data?.name);
|
|
274
|
-
if (normalizedName) {
|
|
275
|
-
return normalizedName;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (data?.name === null) {
|
|
279
|
-
return null;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
return null;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function getActiveAgentNameFromSystemPrompt(
|
|
287
|
-
systemPrompt: string | undefined,
|
|
288
|
-
): string | null {
|
|
289
|
-
if (!systemPrompt) {
|
|
290
|
-
return null;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const match = systemPrompt.match(ACTIVE_AGENT_TAG_REGEX);
|
|
294
|
-
if (!match?.[1]) {
|
|
295
|
-
return null;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
return normalizeAgentName(match[1]);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function getContextSystemPrompt(ctx: ExtensionContext): string | undefined {
|
|
302
|
-
const getSystemPrompt = toRecord(ctx).getSystemPrompt;
|
|
303
|
-
if (typeof getSystemPrompt !== "function") {
|
|
304
|
-
return undefined;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
try {
|
|
308
|
-
const systemPrompt = getSystemPrompt.call(ctx);
|
|
309
|
-
return typeof systemPrompt === "string" ? systemPrompt : undefined;
|
|
310
|
-
} catch (error) {
|
|
311
|
-
logPermissionForwardingWarning(
|
|
312
|
-
"Failed to read context system prompt for forwarded permission metadata",
|
|
313
|
-
error,
|
|
314
|
-
);
|
|
315
|
-
return undefined;
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function formatMissingToolNameReason(): string {
|
|
320
|
-
return "Tool call was blocked because no tool name was provided. Use a registered tool name from pi.getAllTools().";
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function formatUnknownToolReason(
|
|
324
|
-
toolName: string,
|
|
325
|
-
availableToolNames: readonly string[],
|
|
326
|
-
): string {
|
|
327
|
-
const preview = availableToolNames.slice(0, 10);
|
|
328
|
-
const suffix = availableToolNames.length > preview.length ? ", ..." : "";
|
|
329
|
-
const availableList =
|
|
330
|
-
preview.length > 0 ? `${preview.join(", ")}${suffix}` : "none";
|
|
331
|
-
|
|
332
|
-
const mcpHint =
|
|
333
|
-
toolName === "mcp"
|
|
334
|
-
? ""
|
|
335
|
-
: ' If this was intended as an MCP server tool, call the registered \'mcp\' tool when available (for example: {"tool":"server:tool"}).';
|
|
336
|
-
|
|
337
|
-
return `Tool '${toolName}' is not registered in this runtime and was blocked before permission checks.${mcpHint} Registered tools: ${availableList}.`;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function formatPermissionHardStopHint(result: PermissionCheckResult): string {
|
|
341
|
-
if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
|
|
342
|
-
return "Hard stop: this MCP permission denial is policy-enforced. Do not retry this target, do not run discovery/investigation to bypass it, and report the block to the user.";
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
return "Hard stop: this permission denial is policy-enforced. Do not retry or investigate bypasses; report the block to the user.";
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function formatDenyReason(
|
|
349
|
-
result: PermissionCheckResult,
|
|
350
|
-
agentName?: string,
|
|
351
|
-
): string {
|
|
352
|
-
const parts: string[] = [];
|
|
353
|
-
|
|
354
|
-
if (agentName) {
|
|
355
|
-
parts.push(`Agent '${agentName}'`);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
|
|
359
|
-
parts.push(`is not permitted to run MCP target '${result.target}'`);
|
|
360
|
-
} else {
|
|
361
|
-
parts.push(`is not permitted to run '${result.toolName}'`);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (result.command) {
|
|
365
|
-
parts.push(`command '${result.command}'`);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
if (result.matchedPattern) {
|
|
369
|
-
parts.push(`(matched '${result.matchedPattern}')`);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
return `${parts.join(" ")}. ${formatPermissionHardStopHint(result)}`;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function formatUserDeniedReason(
|
|
376
|
-
result: PermissionCheckResult,
|
|
377
|
-
denialReason?: string,
|
|
378
|
-
): string {
|
|
379
|
-
const base =
|
|
380
|
-
(result.source === "mcp" || result.toolName === "mcp") && result.target
|
|
381
|
-
? `User denied MCP target '${result.target}'.`
|
|
382
|
-
: result.toolName === "bash" && result.command
|
|
383
|
-
? `User denied bash command '${result.command}'.`
|
|
384
|
-
: `User denied tool '${result.toolName}'.`;
|
|
385
|
-
const reasonSuffix = denialReason ? ` Reason: ${denialReason}.` : "";
|
|
386
|
-
|
|
387
|
-
return `${base}${reasonSuffix} ${formatPermissionHardStopHint(result)}`;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
|
|
391
|
-
const TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH = 1000;
|
|
392
|
-
const TOOL_TEXT_SUMMARY_MAX_LENGTH = 80;
|
|
393
|
-
|
|
394
|
-
function truncateInlineText(value: string, maxLength: number): string {
|
|
395
|
-
return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function sanitizeInlineText(
|
|
399
|
-
value: string,
|
|
400
|
-
maxLength = TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
401
|
-
): string {
|
|
402
|
-
const normalized = value.replace(/\s+/g, " ").trim();
|
|
403
|
-
return normalized ? truncateInlineText(normalized, maxLength) : "empty text";
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function countTextLines(value: string): number {
|
|
407
|
-
if (!value) {
|
|
408
|
-
return 0;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
return value.split(/\r\n|\r|\n/).length;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function formatCount(value: number, singular: string, plural: string): string {
|
|
415
|
-
return `${value} ${value === 1 ? singular : plural}`;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function getPromptPath(input: Record<string, unknown>): string | null {
|
|
419
|
-
return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
function formatEditInputForPrompt(input: Record<string, unknown>): string {
|
|
423
|
-
const path = getPromptPath(input);
|
|
424
|
-
const rawEdits = Array.isArray(input.edits)
|
|
425
|
-
? input.edits
|
|
426
|
-
: typeof input.oldText === "string" && typeof input.newText === "string"
|
|
427
|
-
? [{ oldText: input.oldText, newText: input.newText }]
|
|
428
|
-
: [];
|
|
429
|
-
|
|
430
|
-
const edits = rawEdits
|
|
431
|
-
.map((edit) => toRecord(edit))
|
|
432
|
-
.filter(
|
|
433
|
-
(edit) =>
|
|
434
|
-
typeof edit.oldText === "string" && typeof edit.newText === "string",
|
|
435
|
-
);
|
|
436
|
-
|
|
437
|
-
const pathPart = path ? `for '${path}'` : "";
|
|
438
|
-
if (edits.length === 0) {
|
|
439
|
-
return pathPart ? `${pathPart} with edit input` : "with edit input";
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
const firstEdit = edits[0];
|
|
443
|
-
const oldText = String(firstEdit.oldText);
|
|
444
|
-
const newText = String(firstEdit.newText);
|
|
445
|
-
const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
|
|
446
|
-
const extraEdits =
|
|
447
|
-
edits.length > 1
|
|
448
|
-
? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}`
|
|
449
|
-
: "";
|
|
450
|
-
const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
|
|
451
|
-
return pathPart ? `${pathPart} ${summary}` : summary;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function formatWriteInputForPrompt(input: Record<string, unknown>): string {
|
|
455
|
-
const path = getPromptPath(input);
|
|
456
|
-
const content = typeof input.content === "string" ? input.content : "";
|
|
457
|
-
const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
|
|
458
|
-
return path ? `for '${path}' ${summary}` : summary;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function formatReadInputForPrompt(input: Record<string, unknown>): string {
|
|
462
|
-
const path = getPromptPath(input);
|
|
463
|
-
const parts = path ? [`path '${path}'`] : [];
|
|
464
|
-
if (typeof input.offset === "number") {
|
|
465
|
-
parts.push(`offset ${input.offset}`);
|
|
466
|
-
}
|
|
467
|
-
if (typeof input.limit === "number") {
|
|
468
|
-
parts.push(`limit ${input.limit}`);
|
|
469
|
-
}
|
|
470
|
-
return parts.length > 0 ? `for ${parts.join(", ")}` : "";
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
function formatSearchInputForPrompt(
|
|
474
|
-
toolName: string,
|
|
475
|
-
input: Record<string, unknown>,
|
|
476
|
-
): string {
|
|
477
|
-
const parts: string[] = [];
|
|
478
|
-
const path = getPromptPath(input);
|
|
479
|
-
const pattern = getNonEmptyString(input.pattern);
|
|
480
|
-
const glob = getNonEmptyString(input.glob);
|
|
481
|
-
|
|
482
|
-
if (pattern) {
|
|
483
|
-
parts.push(`pattern '${sanitizeInlineText(pattern)}'`);
|
|
484
|
-
}
|
|
485
|
-
if (glob) {
|
|
486
|
-
parts.push(`glob '${sanitizeInlineText(glob)}'`);
|
|
487
|
-
}
|
|
488
|
-
if (path) {
|
|
489
|
-
parts.push(`path '${path}'`);
|
|
490
|
-
} else if (toolName === "find" || toolName === "grep" || toolName === "ls") {
|
|
491
|
-
parts.push("current working directory");
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
return parts.length > 0 ? `for ${parts.join(", ")}` : "";
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
function serializeToolInputPreview(input: unknown): string {
|
|
498
|
-
const serialized = safeJsonStringify(input);
|
|
499
|
-
if (!serialized || serialized === "{}" || serialized === "null") {
|
|
500
|
-
return "";
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
return serialized.replace(/\s+/g, " ").trim();
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
function formatJsonInputForPrompt(input: unknown): string {
|
|
507
|
-
const inline = serializeToolInputPreview(input);
|
|
508
|
-
return inline
|
|
509
|
-
? `with input ${truncateInlineText(inline, TOOL_INPUT_PREVIEW_MAX_LENGTH)}`
|
|
510
|
-
: "";
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
function formatToolInputForPrompt(toolName: string, input: unknown): string {
|
|
514
|
-
const inputRecord = toRecord(input);
|
|
515
|
-
|
|
516
|
-
switch (toolName) {
|
|
517
|
-
case "edit":
|
|
518
|
-
return formatEditInputForPrompt(inputRecord);
|
|
519
|
-
case "write":
|
|
520
|
-
return formatWriteInputForPrompt(inputRecord);
|
|
521
|
-
case "read":
|
|
522
|
-
return formatReadInputForPrompt(inputRecord);
|
|
523
|
-
case "find":
|
|
524
|
-
case "grep":
|
|
525
|
-
case "ls":
|
|
526
|
-
return formatSearchInputForPrompt(toolName, inputRecord);
|
|
527
|
-
default:
|
|
528
|
-
return formatJsonInputForPrompt(input);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
function formatAskPrompt(
|
|
533
|
-
result: PermissionCheckResult,
|
|
534
|
-
agentName?: string,
|
|
535
|
-
input?: unknown,
|
|
536
|
-
): string {
|
|
537
|
-
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
538
|
-
|
|
539
|
-
if (result.toolName === "bash") {
|
|
540
|
-
const patternInfo = result.matchedPattern
|
|
541
|
-
? ` (matched '${result.matchedPattern}')`
|
|
542
|
-
: "";
|
|
543
|
-
return `${subject} requested bash command '${result.command || ""}'${patternInfo}. Allow this command?`;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
|
|
547
|
-
const patternInfo = result.matchedPattern
|
|
548
|
-
? ` (matched '${result.matchedPattern}')`
|
|
549
|
-
: "";
|
|
550
|
-
return `${subject} requested MCP target '${result.target}'${patternInfo}. Allow this call?`;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
const patternInfo = result.matchedPattern
|
|
554
|
-
? ` (matched '${result.matchedPattern}')`
|
|
555
|
-
: "";
|
|
556
|
-
const inputPreview = formatToolInputForPrompt(result.toolName, input);
|
|
557
|
-
const inputSuffix = inputPreview ? ` ${inputPreview}` : "";
|
|
558
|
-
return `${subject} requested tool '${result.toolName}'${patternInfo}${inputSuffix}. Allow this call?`;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
function formatSkillAskPrompt(skillName: string, agentName?: string): string {
|
|
562
|
-
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
563
|
-
return `${subject} requested skill '${skillName}'. Allow loading this skill?`;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
function formatSkillPathAskPrompt(
|
|
567
|
-
skill: SkillPromptEntry,
|
|
568
|
-
readPath: string,
|
|
569
|
-
agentName?: string,
|
|
570
|
-
): string {
|
|
571
|
-
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
572
|
-
return `${subject} requested access to skill '${skill.name}' via '${readPath}'. Allow this read?`;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
function formatSkillPathDenyReason(
|
|
576
|
-
skill: SkillPromptEntry,
|
|
577
|
-
readPath: string,
|
|
578
|
-
agentName?: string,
|
|
579
|
-
): string {
|
|
580
|
-
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
581
|
-
return `${subject} is not permitted to access skill '${skill.name}' via '${readPath}'.`;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function formatExternalDirectoryHardStopHint(): string {
|
|
585
|
-
return "Hard stop: this external directory permission denial is policy-enforced. Do not retry this path, do not attempt a filesystem bypass, and report the block to the user.";
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
function formatExternalDirectoryAskPrompt(
|
|
589
|
-
toolName: string,
|
|
590
|
-
pathValue: string,
|
|
591
|
-
cwd: string,
|
|
592
|
-
agentName?: string,
|
|
593
|
-
): string {
|
|
594
|
-
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
595
|
-
return `${subject} requested tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. Allow this external directory access?`;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
function formatExternalDirectoryDenyReason(
|
|
599
|
-
toolName: string,
|
|
600
|
-
pathValue: string,
|
|
601
|
-
cwd: string,
|
|
602
|
-
agentName?: string,
|
|
603
|
-
): string {
|
|
604
|
-
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
605
|
-
return `${subject} is not permitted to run tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. ${formatExternalDirectoryHardStopHint()}`;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function formatExternalDirectoryUserDeniedReason(
|
|
609
|
-
toolName: string,
|
|
610
|
-
pathValue: string,
|
|
611
|
-
denialReason?: string,
|
|
612
|
-
): string {
|
|
613
|
-
const reasonSuffix = denialReason ? ` Reason: ${denialReason}.` : "";
|
|
614
|
-
return `User denied external directory access for tool '${toolName}' path '${pathValue}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
function formatGenericToolInputForLog(input: unknown): string | undefined {
|
|
618
|
-
const inline = serializeToolInputPreview(input);
|
|
619
|
-
return inline
|
|
620
|
-
? `input ${truncateInlineText(inline, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)}`
|
|
621
|
-
: undefined;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
function getToolInputPreviewForLog(
|
|
625
|
-
result: PermissionCheckResult,
|
|
626
|
-
input: unknown,
|
|
627
|
-
): string | undefined {
|
|
628
|
-
if (
|
|
629
|
-
result.toolName === "bash" ||
|
|
630
|
-
result.toolName === "mcp" ||
|
|
631
|
-
result.source === "mcp"
|
|
632
|
-
) {
|
|
633
|
-
return undefined;
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
if (PATH_BEARING_TOOLS.has(result.toolName)) {
|
|
637
|
-
const inputPreview = formatToolInputForPrompt(result.toolName, input);
|
|
638
|
-
return inputPreview
|
|
639
|
-
? truncateInlineText(inputPreview, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)
|
|
640
|
-
: undefined;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
return formatGenericToolInputForLog(input);
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
function getPermissionLogContext(
|
|
647
|
-
result: PermissionCheckResult,
|
|
648
|
-
input: unknown,
|
|
649
|
-
): { command?: string; target?: string; toolInputPreview?: string } {
|
|
650
|
-
return {
|
|
651
|
-
command: result.command,
|
|
652
|
-
target: result.target,
|
|
653
|
-
toolInputPreview: getToolInputPreviewForLog(result, input),
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
function sleep(ms: number): Promise<void> {
|
|
658
|
-
return new Promise((resolve) => {
|
|
659
|
-
setTimeout(resolve, ms);
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
function normalizeFilesystemPath(pathValue: string): string {
|
|
664
|
-
const normalizedPath = normalize(pathValue);
|
|
665
|
-
return process.platform === "win32"
|
|
666
|
-
? normalizedPath.toLowerCase()
|
|
667
|
-
: normalizedPath;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
function getSessionId(ctx: ExtensionContext): string {
|
|
671
|
-
try {
|
|
672
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
673
|
-
if (typeof sessionId === "string" && sessionId.trim()) {
|
|
674
|
-
return sessionId.trim();
|
|
675
|
-
}
|
|
676
|
-
} catch {}
|
|
677
|
-
|
|
678
|
-
return "unknown";
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
function isSubagentExecutionContext(ctx: ExtensionContext): boolean {
|
|
682
|
-
for (const key of SUBAGENT_ENV_HINT_KEYS) {
|
|
683
|
-
const value = process.env[key];
|
|
684
|
-
if (typeof value === "string" && value.trim()) {
|
|
685
|
-
return true;
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
690
|
-
if (!sessionDir) {
|
|
691
|
-
return false;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
const normalizedSessionDir = normalizeFilesystemPath(sessionDir);
|
|
695
|
-
const normalizedSubagentRoot = normalizeFilesystemPath(SUBAGENT_SESSIONS_DIR);
|
|
696
|
-
return isPathWithinDirectory(normalizedSessionDir, normalizedSubagentRoot);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
195
|
function canRequestPermissionConfirmation(ctx: ExtensionContext): boolean {
|
|
700
196
|
return canResolveAskPermissionRequest({
|
|
701
197
|
config: extensionConfig,
|
|
702
198
|
hasUI: ctx.hasUI,
|
|
703
|
-
isSubagent: isSubagentExecutionContext(ctx),
|
|
199
|
+
isSubagent: isSubagentExecutionContext(ctx, SUBAGENT_SESSIONS_DIR),
|
|
704
200
|
});
|
|
705
201
|
}
|
|
706
202
|
|
|
707
|
-
function formatUnknownErrorMessage(error: unknown): string {
|
|
708
|
-
if (error instanceof Error && error.message) {
|
|
709
|
-
return error.message;
|
|
710
|
-
}
|
|
711
|
-
return String(error);
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
function isErrnoCode(error: unknown, code: string): boolean {
|
|
715
|
-
return Boolean(
|
|
716
|
-
error &&
|
|
717
|
-
typeof error === "object" &&
|
|
718
|
-
"code" in error &&
|
|
719
|
-
(error as { code?: string }).code === code,
|
|
720
|
-
);
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
function logPermissionForwardingWarning(
|
|
724
|
-
message: string,
|
|
725
|
-
error?: unknown,
|
|
726
|
-
): void {
|
|
727
|
-
const details =
|
|
728
|
-
typeof error === "undefined"
|
|
729
|
-
? { message }
|
|
730
|
-
: { message, error: formatUnknownErrorMessage(error) };
|
|
731
|
-
|
|
732
|
-
writeReviewLog("permission_forwarding.warning", details);
|
|
733
|
-
writeDebugLog("permission_forwarding.warning", details);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
function logPermissionForwardingError(message: string, error?: unknown): void {
|
|
737
|
-
const details =
|
|
738
|
-
typeof error === "undefined"
|
|
739
|
-
? { message }
|
|
740
|
-
: { message, error: formatUnknownErrorMessage(error) };
|
|
741
|
-
|
|
742
|
-
writeReviewLog("permission_forwarding.error", details);
|
|
743
|
-
writeDebugLog("permission_forwarding.error", details);
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
function ensureDirectoryExists(path: string, description: string): boolean {
|
|
747
|
-
try {
|
|
748
|
-
mkdirSync(path, { recursive: true });
|
|
749
|
-
return true;
|
|
750
|
-
} catch (error) {
|
|
751
|
-
logPermissionForwardingError(
|
|
752
|
-
`Failed to create ${description} directory '${path}'`,
|
|
753
|
-
error,
|
|
754
|
-
);
|
|
755
|
-
return false;
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
function getPermissionForwardingLocationForSession(
|
|
760
|
-
sessionId: string,
|
|
761
|
-
): PermissionForwardingLocation {
|
|
762
|
-
return createPermissionForwardingLocation(
|
|
763
|
-
PERMISSION_FORWARDING_DIR,
|
|
764
|
-
sessionId,
|
|
765
|
-
);
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
function ensurePermissionForwardingLocation(
|
|
769
|
-
sessionId: string,
|
|
770
|
-
): PermissionForwardingLocation | null {
|
|
771
|
-
let location: PermissionForwardingLocation;
|
|
772
|
-
try {
|
|
773
|
-
location = getPermissionForwardingLocationForSession(sessionId);
|
|
774
|
-
} catch (error) {
|
|
775
|
-
logPermissionForwardingError(
|
|
776
|
-
"Failed to resolve permission forwarding location",
|
|
777
|
-
error,
|
|
778
|
-
);
|
|
779
|
-
return null;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
const sessionRootReady = ensureDirectoryExists(
|
|
783
|
-
location.sessionRootDir,
|
|
784
|
-
"permission forwarding session root",
|
|
785
|
-
);
|
|
786
|
-
const requestsReady = ensureDirectoryExists(
|
|
787
|
-
location.requestsDir,
|
|
788
|
-
"permission forwarding requests",
|
|
789
|
-
);
|
|
790
|
-
const responsesReady = ensureDirectoryExists(
|
|
791
|
-
location.responsesDir,
|
|
792
|
-
"permission forwarding responses",
|
|
793
|
-
);
|
|
794
|
-
|
|
795
|
-
return sessionRootReady && requestsReady && responsesReady ? location : null;
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
function getExistingPermissionForwardingLocation(
|
|
799
|
-
sessionId: string,
|
|
800
|
-
): PermissionForwardingLocation | null {
|
|
801
|
-
let location: PermissionForwardingLocation;
|
|
802
|
-
try {
|
|
803
|
-
location = getPermissionForwardingLocationForSession(sessionId);
|
|
804
|
-
} catch {
|
|
805
|
-
return null;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
return existsSync(location.requestsDir) ? location : null;
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
function tryRemoveDirectoryIfEmpty(path: string, description: string): void {
|
|
812
|
-
if (!existsSync(path)) {
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
let entries: string[];
|
|
817
|
-
try {
|
|
818
|
-
entries = readdirSync(path);
|
|
819
|
-
} catch (error) {
|
|
820
|
-
logPermissionForwardingWarning(
|
|
821
|
-
`Failed to inspect ${description} directory '${path}'`,
|
|
822
|
-
error,
|
|
823
|
-
);
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
if (entries.length > 0) {
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
try {
|
|
832
|
-
rmdirSync(path);
|
|
833
|
-
} catch (error) {
|
|
834
|
-
if (isErrnoCode(error, "ENOENT") || isErrnoCode(error, "ENOTEMPTY")) {
|
|
835
|
-
return;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
logPermissionForwardingWarning(
|
|
839
|
-
`Failed to remove empty ${description} directory '${path}'`,
|
|
840
|
-
error,
|
|
841
|
-
);
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
function cleanupPermissionForwardingLocationIfEmpty(
|
|
846
|
-
location: PermissionForwardingLocation,
|
|
847
|
-
): void {
|
|
848
|
-
tryRemoveDirectoryIfEmpty(
|
|
849
|
-
location.requestsDir,
|
|
850
|
-
`${location.label} permission forwarding requests`,
|
|
851
|
-
);
|
|
852
|
-
tryRemoveDirectoryIfEmpty(
|
|
853
|
-
location.responsesDir,
|
|
854
|
-
`${location.label} permission forwarding responses`,
|
|
855
|
-
);
|
|
856
|
-
tryRemoveDirectoryIfEmpty(
|
|
857
|
-
location.sessionRootDir,
|
|
858
|
-
`${location.label} permission forwarding session root`,
|
|
859
|
-
);
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
function safeDeleteFile(filePath: string, description: string): void {
|
|
863
|
-
try {
|
|
864
|
-
unlinkSync(filePath);
|
|
865
|
-
} catch (error) {
|
|
866
|
-
if (isErrnoCode(error, "ENOENT")) {
|
|
867
|
-
return;
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
logPermissionForwardingWarning(
|
|
871
|
-
`Failed to delete ${description} file '${filePath}'`,
|
|
872
|
-
error,
|
|
873
|
-
);
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
function writeJsonFileAtomic(filePath: string, value: unknown): void {
|
|
878
|
-
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
879
|
-
|
|
880
|
-
try {
|
|
881
|
-
writeFileSync(tempPath, JSON.stringify(value), "utf-8");
|
|
882
|
-
renameSync(tempPath, filePath);
|
|
883
|
-
} catch (error) {
|
|
884
|
-
safeDeleteFile(tempPath, "temporary permission-forwarding");
|
|
885
|
-
throw error;
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
function readForwardedPermissionRequest(
|
|
890
|
-
filePath: string,
|
|
891
|
-
): ForwardedPermissionRequest | null {
|
|
892
|
-
try {
|
|
893
|
-
const raw = readFileSync(filePath, "utf-8");
|
|
894
|
-
const parsed = JSON.parse(raw) as Partial<ForwardedPermissionRequest>;
|
|
895
|
-
if (
|
|
896
|
-
!parsed ||
|
|
897
|
-
typeof parsed.id !== "string" ||
|
|
898
|
-
typeof parsed.createdAt !== "number" ||
|
|
899
|
-
typeof parsed.requesterSessionId !== "string" ||
|
|
900
|
-
typeof parsed.targetSessionId !== "string" ||
|
|
901
|
-
typeof parsed.requesterAgentName !== "string" ||
|
|
902
|
-
typeof parsed.message !== "string"
|
|
903
|
-
) {
|
|
904
|
-
logPermissionForwardingWarning(
|
|
905
|
-
`Ignoring invalid forwarded permission request format in '${filePath}'`,
|
|
906
|
-
);
|
|
907
|
-
return null;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
return {
|
|
911
|
-
id: parsed.id,
|
|
912
|
-
createdAt: parsed.createdAt,
|
|
913
|
-
requesterSessionId: parsed.requesterSessionId,
|
|
914
|
-
targetSessionId: parsed.targetSessionId,
|
|
915
|
-
requesterAgentName: parsed.requesterAgentName,
|
|
916
|
-
message: parsed.message,
|
|
917
|
-
};
|
|
918
|
-
} catch (error) {
|
|
919
|
-
logPermissionForwardingWarning(
|
|
920
|
-
`Failed to read forwarded permission request '${filePath}'`,
|
|
921
|
-
error,
|
|
922
|
-
);
|
|
923
|
-
return null;
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
function readForwardedPermissionResponse(
|
|
928
|
-
filePath: string,
|
|
929
|
-
): ForwardedPermissionResponse | null {
|
|
930
|
-
try {
|
|
931
|
-
const raw = readFileSync(filePath, "utf-8");
|
|
932
|
-
const parsed = JSON.parse(raw) as Partial<ForwardedPermissionResponse>;
|
|
933
|
-
if (
|
|
934
|
-
!parsed ||
|
|
935
|
-
typeof parsed.approved !== "boolean" ||
|
|
936
|
-
!isPermissionDecisionState(parsed.state) ||
|
|
937
|
-
typeof parsed.responderSessionId !== "string"
|
|
938
|
-
) {
|
|
939
|
-
logPermissionForwardingWarning(
|
|
940
|
-
`Ignoring invalid forwarded permission response format in '${filePath}'`,
|
|
941
|
-
);
|
|
942
|
-
return null;
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
return {
|
|
946
|
-
approved: parsed.approved,
|
|
947
|
-
state: parsed.state,
|
|
948
|
-
denialReason:
|
|
949
|
-
typeof parsed.denialReason === "string"
|
|
950
|
-
? parsed.denialReason
|
|
951
|
-
: undefined,
|
|
952
|
-
responderSessionId: parsed.responderSessionId,
|
|
953
|
-
respondedAt:
|
|
954
|
-
typeof parsed.respondedAt === "number"
|
|
955
|
-
? parsed.respondedAt
|
|
956
|
-
: Date.now(),
|
|
957
|
-
};
|
|
958
|
-
} catch (error) {
|
|
959
|
-
logPermissionForwardingWarning(
|
|
960
|
-
`Failed to read forwarded permission response '${filePath}'`,
|
|
961
|
-
error,
|
|
962
|
-
);
|
|
963
|
-
return null;
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
function formatForwardedPermissionPrompt(
|
|
968
|
-
request: ForwardedPermissionRequest,
|
|
969
|
-
): string {
|
|
970
|
-
const agentName = request.requesterAgentName || "unknown";
|
|
971
|
-
const sessionId = request.requesterSessionId || "unknown";
|
|
972
|
-
return [
|
|
973
|
-
`Subagent '${agentName}' requested permission.`,
|
|
974
|
-
`Session ID: ${sessionId}`,
|
|
975
|
-
"",
|
|
976
|
-
request.message,
|
|
977
|
-
].join("\n");
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
async function waitForForwardedPermissionApproval(
|
|
981
|
-
ctx: ExtensionContext,
|
|
982
|
-
message: string,
|
|
983
|
-
): Promise<PermissionPromptDecision> {
|
|
984
|
-
const requesterSessionId = getSessionId(ctx);
|
|
985
|
-
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
986
|
-
hasUI: ctx.hasUI,
|
|
987
|
-
isSubagent: isSubagentExecutionContext(ctx),
|
|
988
|
-
currentSessionId: requesterSessionId,
|
|
989
|
-
env: process.env,
|
|
990
|
-
});
|
|
991
|
-
|
|
992
|
-
if (!targetSessionId) {
|
|
993
|
-
logPermissionForwardingError(
|
|
994
|
-
"Permission forwarding target session could not be resolved from subagent runtime metadata (expected PI_AGENT_ROUTER_PARENT_SESSION_ID)",
|
|
995
|
-
);
|
|
996
|
-
return { approved: false, state: "denied" };
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
const location = ensurePermissionForwardingLocation(targetSessionId);
|
|
1000
|
-
if (!location) {
|
|
1001
|
-
logPermissionForwardingError(
|
|
1002
|
-
`Permission forwarding is unavailable because session-scoped directories could not be prepared for '${targetSessionId}'`,
|
|
1003
|
-
);
|
|
1004
|
-
return { approved: false, state: "denied" };
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
|
|
1008
|
-
const requesterAgentName =
|
|
1009
|
-
getActiveAgentName(ctx) ||
|
|
1010
|
-
getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ||
|
|
1011
|
-
"unknown";
|
|
1012
|
-
const request: ForwardedPermissionRequest = {
|
|
1013
|
-
id: requestId,
|
|
1014
|
-
createdAt: Date.now(),
|
|
1015
|
-
requesterSessionId,
|
|
1016
|
-
targetSessionId,
|
|
1017
|
-
requesterAgentName,
|
|
1018
|
-
message,
|
|
1019
|
-
};
|
|
1020
|
-
|
|
1021
|
-
const requestPath = join(location.requestsDir, `${requestId}.json`);
|
|
1022
|
-
const responsePath = join(location.responsesDir, `${requestId}.json`);
|
|
1023
|
-
|
|
1024
|
-
writeReviewLog("forwarded_permission.request_created", {
|
|
1025
|
-
requestId,
|
|
1026
|
-
requesterAgentName,
|
|
1027
|
-
requesterSessionId: request.requesterSessionId,
|
|
1028
|
-
targetSessionId,
|
|
1029
|
-
requestPath,
|
|
1030
|
-
responsePath,
|
|
1031
|
-
});
|
|
1032
|
-
|
|
1033
|
-
try {
|
|
1034
|
-
writeJsonFileAtomic(requestPath, request);
|
|
1035
|
-
} catch (error) {
|
|
1036
|
-
logPermissionForwardingError(
|
|
1037
|
-
`Failed to write forwarded permission request '${requestPath}'`,
|
|
1038
|
-
error,
|
|
1039
|
-
);
|
|
1040
|
-
return { approved: false, state: "denied" };
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
const deadline = Date.now() + PERMISSION_FORWARDING_TIMEOUT_MS;
|
|
1044
|
-
while (Date.now() < deadline) {
|
|
1045
|
-
if (existsSync(responsePath)) {
|
|
1046
|
-
const response = readForwardedPermissionResponse(responsePath);
|
|
1047
|
-
writeReviewLog("forwarded_permission.response_received", {
|
|
1048
|
-
requestId,
|
|
1049
|
-
approved: response?.approved ?? null,
|
|
1050
|
-
state: response?.state ?? null,
|
|
1051
|
-
denialReason: response?.denialReason ?? null,
|
|
1052
|
-
responderSessionId: response?.responderSessionId ?? null,
|
|
1053
|
-
targetSessionId,
|
|
1054
|
-
responsePath,
|
|
1055
|
-
});
|
|
1056
|
-
safeDeleteFile(responsePath, "forwarded permission response");
|
|
1057
|
-
safeDeleteFile(requestPath, "forwarded permission request");
|
|
1058
|
-
cleanupPermissionForwardingLocationIfEmpty(location);
|
|
1059
|
-
return response ?? { approved: false, state: "denied" };
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
logPermissionForwardingWarning(
|
|
1066
|
-
`Timed out waiting for forwarded permission response '${responsePath}'`,
|
|
1067
|
-
);
|
|
1068
|
-
writeReviewLog("forwarded_permission.response_timed_out", {
|
|
1069
|
-
requestId,
|
|
1070
|
-
requesterAgentName,
|
|
1071
|
-
targetSessionId,
|
|
1072
|
-
responsePath,
|
|
1073
|
-
});
|
|
1074
|
-
safeDeleteFile(requestPath, "forwarded permission request");
|
|
1075
|
-
cleanupPermissionForwardingLocationIfEmpty(location);
|
|
1076
|
-
return { approved: false, state: "denied" };
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
async function processForwardedPermissionRequests(
|
|
1080
|
-
ctx: ExtensionContext,
|
|
1081
|
-
): Promise<void> {
|
|
1082
|
-
if (!ctx.hasUI) {
|
|
1083
|
-
return;
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
const currentSessionId = getSessionId(ctx);
|
|
1087
|
-
const location = getExistingPermissionForwardingLocation(currentSessionId);
|
|
1088
|
-
if (!location) {
|
|
1089
|
-
return;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
let requestFiles: string[] = [];
|
|
1093
|
-
try {
|
|
1094
|
-
requestFiles = readdirSync(location.requestsDir)
|
|
1095
|
-
.filter((name) => name.endsWith(".json"))
|
|
1096
|
-
.sort();
|
|
1097
|
-
} catch (error) {
|
|
1098
|
-
logPermissionForwardingWarning(
|
|
1099
|
-
`Failed to read ${location.label} permission forwarding requests from '${location.requestsDir}'`,
|
|
1100
|
-
error,
|
|
1101
|
-
);
|
|
1102
|
-
return;
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
for (const fileName of requestFiles) {
|
|
1106
|
-
const requestPath = join(location.requestsDir, fileName);
|
|
1107
|
-
const request = readForwardedPermissionRequest(requestPath);
|
|
1108
|
-
if (!request) {
|
|
1109
|
-
safeDeleteFile(
|
|
1110
|
-
requestPath,
|
|
1111
|
-
`${location.label} forwarded permission request`,
|
|
1112
|
-
);
|
|
1113
|
-
continue;
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
|
|
1117
|
-
logPermissionForwardingWarning(
|
|
1118
|
-
`Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
|
|
1119
|
-
);
|
|
1120
|
-
safeDeleteFile(
|
|
1121
|
-
requestPath,
|
|
1122
|
-
`${location.label} forwarded permission request`,
|
|
1123
|
-
);
|
|
1124
|
-
continue;
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
const forwardedPermissionLogDetails = {
|
|
1128
|
-
requestId: request.id,
|
|
1129
|
-
source: location.label,
|
|
1130
|
-
requesterAgentName: request.requesterAgentName,
|
|
1131
|
-
requesterSessionId: request.requesterSessionId,
|
|
1132
|
-
targetSessionId: request.targetSessionId,
|
|
1133
|
-
requestPath,
|
|
1134
|
-
};
|
|
1135
|
-
|
|
1136
|
-
let decision: PermissionPromptDecision = {
|
|
1137
|
-
approved: false,
|
|
1138
|
-
state: "denied",
|
|
1139
|
-
};
|
|
1140
|
-
if (shouldAutoApprovePermissionState("ask", extensionConfig)) {
|
|
1141
|
-
writeReviewLog(
|
|
1142
|
-
"forwarded_permission.auto_approved",
|
|
1143
|
-
forwardedPermissionLogDetails,
|
|
1144
|
-
);
|
|
1145
|
-
decision = { approved: true, state: "approved" };
|
|
1146
|
-
} else {
|
|
1147
|
-
writeReviewLog(
|
|
1148
|
-
"forwarded_permission.prompted",
|
|
1149
|
-
forwardedPermissionLogDetails,
|
|
1150
|
-
);
|
|
1151
|
-
try {
|
|
1152
|
-
decision = await requestPermissionDecisionFromUi(
|
|
1153
|
-
ctx.ui,
|
|
1154
|
-
"Permission Required (Subagent)",
|
|
1155
|
-
formatForwardedPermissionPrompt(request),
|
|
1156
|
-
);
|
|
1157
|
-
} catch (error) {
|
|
1158
|
-
logPermissionForwardingError(
|
|
1159
|
-
"Failed to show forwarded permission confirmation dialog",
|
|
1160
|
-
error,
|
|
1161
|
-
);
|
|
1162
|
-
decision = { approved: false, state: "denied" };
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
|
1167
|
-
writeReviewLog(
|
|
1168
|
-
decision.approved
|
|
1169
|
-
? "forwarded_permission.approved"
|
|
1170
|
-
: "forwarded_permission.denied",
|
|
1171
|
-
{
|
|
1172
|
-
requestId: request.id,
|
|
1173
|
-
source: location.label,
|
|
1174
|
-
requesterAgentName: request.requesterAgentName,
|
|
1175
|
-
requesterSessionId: request.requesterSessionId,
|
|
1176
|
-
targetSessionId: request.targetSessionId,
|
|
1177
|
-
responsePath,
|
|
1178
|
-
resolution: decision.state,
|
|
1179
|
-
denialReason: decision.denialReason ?? null,
|
|
1180
|
-
},
|
|
1181
|
-
);
|
|
1182
|
-
try {
|
|
1183
|
-
writeJsonFileAtomic(responsePath, {
|
|
1184
|
-
approved: decision.approved,
|
|
1185
|
-
state: decision.state,
|
|
1186
|
-
denialReason: decision.denialReason,
|
|
1187
|
-
responderSessionId: currentSessionId,
|
|
1188
|
-
respondedAt: Date.now(),
|
|
1189
|
-
} satisfies ForwardedPermissionResponse);
|
|
1190
|
-
} catch (error) {
|
|
1191
|
-
logPermissionForwardingError(
|
|
1192
|
-
`Failed to write ${location.label} forwarded permission response '${responsePath}'`,
|
|
1193
|
-
error,
|
|
1194
|
-
);
|
|
1195
|
-
continue;
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
safeDeleteFile(
|
|
1199
|
-
requestPath,
|
|
1200
|
-
`${location.label} forwarded permission request`,
|
|
1201
|
-
);
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
cleanupPermissionForwardingLocationIfEmpty(location);
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
async function confirmPermission(
|
|
1208
|
-
ctx: ExtensionContext,
|
|
1209
|
-
message: string,
|
|
1210
|
-
): Promise<PermissionPromptDecision> {
|
|
1211
|
-
if (ctx.hasUI) {
|
|
1212
|
-
return requestPermissionDecisionFromUi(
|
|
1213
|
-
ctx.ui,
|
|
1214
|
-
"Permission Required",
|
|
1215
|
-
message,
|
|
1216
|
-
);
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
if (!isSubagentExecutionContext(ctx)) {
|
|
1220
|
-
return { approved: false, state: "denied" };
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
return waitForForwardedPermissionApproval(ctx, message);
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
203
|
function derivePiProjectPaths(cwd: string | undefined | null): {
|
|
1227
204
|
projectGlobalConfigPath: string;
|
|
1228
205
|
projectAgentsDir: string;
|
|
@@ -1361,6 +338,17 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1361
338
|
};
|
|
1362
339
|
|
|
1363
340
|
setLoggingWarningReporter(notifyWarning);
|
|
341
|
+
setForwardedPermissionLogger({ writeReviewLog, writeDebugLog });
|
|
342
|
+
|
|
343
|
+
const forwardingDeps: PermissionForwardingDeps = {
|
|
344
|
+
forwardingDir: PERMISSION_FORWARDING_DIR,
|
|
345
|
+
subagentSessionsDir: SUBAGENT_SESSIONS_DIR,
|
|
346
|
+
writeReviewLog,
|
|
347
|
+
requestPermissionDecisionFromUi,
|
|
348
|
+
shouldAutoApprove: () =>
|
|
349
|
+
shouldAutoApprovePermissionState("ask", extensionConfig),
|
|
350
|
+
};
|
|
351
|
+
|
|
1364
352
|
refreshExtensionConfig();
|
|
1365
353
|
registerPermissionSystemCommand(pi, {
|
|
1366
354
|
getConfig: () => extensionConfig,
|
|
@@ -1430,7 +418,11 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1430
418
|
|
|
1431
419
|
reviewPermissionDecision("permission_request.waiting", details);
|
|
1432
420
|
|
|
1433
|
-
const decision = await confirmPermission(
|
|
421
|
+
const decision = await confirmPermission(
|
|
422
|
+
ctx,
|
|
423
|
+
details.message,
|
|
424
|
+
forwardingDeps,
|
|
425
|
+
);
|
|
1434
426
|
reviewPermissionDecision(
|
|
1435
427
|
decision.approved
|
|
1436
428
|
? "permission_request.approved"
|
|
@@ -1455,7 +447,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1455
447
|
};
|
|
1456
448
|
|
|
1457
449
|
const startForwardedPermissionPolling = (ctx: ExtensionContext): void => {
|
|
1458
|
-
if (!ctx.hasUI || isSubagentExecutionContext(ctx)) {
|
|
450
|
+
if (!ctx.hasUI || isSubagentExecutionContext(ctx, SUBAGENT_SESSIONS_DIR)) {
|
|
1459
451
|
stopForwardedPermissionPolling();
|
|
1460
452
|
return;
|
|
1461
453
|
}
|
|
@@ -1473,6 +465,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1473
465
|
isProcessingForwardedRequests = true;
|
|
1474
466
|
void processForwardedPermissionRequests(
|
|
1475
467
|
permissionForwardingContext,
|
|
468
|
+
forwardingDeps,
|
|
1476
469
|
).finally(() => {
|
|
1477
470
|
isProcessingForwardedRequests = false;
|
|
1478
471
|
});
|
|
@@ -1909,7 +902,11 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1909
902
|
input,
|
|
1910
903
|
agentName ?? undefined,
|
|
1911
904
|
);
|
|
1912
|
-
const permissionLogContext = getPermissionLogContext(
|
|
905
|
+
const permissionLogContext = getPermissionLogContext(
|
|
906
|
+
check,
|
|
907
|
+
input,
|
|
908
|
+
PATH_BEARING_TOOLS,
|
|
909
|
+
);
|
|
1913
910
|
|
|
1914
911
|
if (check.state === "deny") {
|
|
1915
912
|
writeReviewLog("permission_request.blocked", {
|