@gotgenes/pi-permission-system 3.6.0 → 3.8.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 +36 -0
- package/package.json +1 -1
- package/src/forwarded-permissions/io.ts +47 -12
- package/src/forwarded-permissions/polling.ts +33 -11
- package/src/handlers/before-agent-start.ts +112 -0
- package/src/handlers/index.ts +16 -0
- package/src/handlers/input.ts +99 -0
- package/src/handlers/lifecycle.ts +81 -0
- package/src/handlers/tool-call.ts +410 -0
- package/src/handlers/types.ts +72 -0
- package/src/index.ts +73 -1040
- package/src/runtime.ts +484 -0
- package/tests/forwarded-permissions/io.test.ts +135 -0
- package/tests/handlers/before-agent-start.test.ts +290 -0
- package/tests/handlers/input.test.ts +301 -0
- package/tests/handlers/lifecycle.test.ts +352 -0
- package/tests/handlers/tool-call.test.ts +441 -0
- package/tests/runtime.test.ts +618 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { getActiveAgentName } from "../active-agent";
|
|
4
|
+
import { PERMISSION_SYSTEM_STATUS_KEY } from "../status";
|
|
5
|
+
import type { HandlerDeps } from "./types";
|
|
6
|
+
|
|
7
|
+
/** Minimal subset of SessionStartEvent used by this handler. */
|
|
8
|
+
interface SessionStartPayload {
|
|
9
|
+
reason: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Minimal subset of ResourcesDiscoverEvent used by this handler. */
|
|
13
|
+
interface ResourcesDiscoverPayload {
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function handleSessionStart(
|
|
18
|
+
deps: HandlerDeps,
|
|
19
|
+
event: SessionStartPayload,
|
|
20
|
+
ctx: ExtensionContext,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
deps.runtime.runtimeContext = ctx;
|
|
23
|
+
deps.refreshExtensionConfig(ctx);
|
|
24
|
+
deps.runtime.permissionManager = deps.createPermissionManagerForCwd(ctx.cwd);
|
|
25
|
+
deps.runtime.activeSkillEntries = [];
|
|
26
|
+
deps.runtime.lastActiveToolsCacheKey = null;
|
|
27
|
+
deps.runtime.lastPromptStateCacheKey = null;
|
|
28
|
+
deps.runtime.lastKnownActiveAgentName = getActiveAgentName(ctx);
|
|
29
|
+
deps.startForwardedPermissionPolling(ctx);
|
|
30
|
+
deps.logResolvedConfigPaths();
|
|
31
|
+
|
|
32
|
+
const agentName = deps.runtime.lastKnownActiveAgentName;
|
|
33
|
+
const policyIssues =
|
|
34
|
+
deps.runtime.permissionManager.getConfigIssues(agentName);
|
|
35
|
+
for (const issue of policyIssues) {
|
|
36
|
+
deps.notifyWarning(issue);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (event.reason === "reload") {
|
|
40
|
+
deps.runtime.writeDebugLog("lifecycle.reload", {
|
|
41
|
+
triggeredBy: "session_start",
|
|
42
|
+
reason: event.reason,
|
|
43
|
+
cwd: ctx.cwd,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function handleResourcesDiscover(
|
|
49
|
+
deps: HandlerDeps,
|
|
50
|
+
event: ResourcesDiscoverPayload,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
if (event.reason !== "reload") {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { runtimeContext } = deps.runtime;
|
|
57
|
+
deps.runtime.permissionManager = deps.createPermissionManagerForCwd(
|
|
58
|
+
runtimeContext?.cwd,
|
|
59
|
+
);
|
|
60
|
+
deps.runtime.activeSkillEntries = [];
|
|
61
|
+
deps.runtime.lastActiveToolsCacheKey = null;
|
|
62
|
+
deps.runtime.lastPromptStateCacheKey = null;
|
|
63
|
+
deps.runtime.writeDebugLog("lifecycle.reload", {
|
|
64
|
+
triggeredBy: "resources_discover",
|
|
65
|
+
reason: event.reason,
|
|
66
|
+
cwd: runtimeContext?.cwd ?? null,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function handleSessionShutdown(deps: HandlerDeps): Promise<void> {
|
|
71
|
+
const { runtimeContext } = deps.runtime;
|
|
72
|
+
if (runtimeContext) {
|
|
73
|
+
runtimeContext.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
|
|
74
|
+
}
|
|
75
|
+
deps.runtime.runtimeContext = null;
|
|
76
|
+
deps.runtime.activeSkillEntries = [];
|
|
77
|
+
deps.runtime.lastActiveToolsCacheKey = null;
|
|
78
|
+
deps.runtime.lastPromptStateCacheKey = null;
|
|
79
|
+
deps.runtime.sessionApprovalCache.clear();
|
|
80
|
+
deps.stopForwardedPermissionPolling();
|
|
81
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionContext,
|
|
3
|
+
ToolCallEvent,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
import { getNonEmptyString, toRecord } from "../common";
|
|
8
|
+
import {
|
|
9
|
+
extractExternalPathsFromBashCommand,
|
|
10
|
+
formatBashExternalDirectoryAskPrompt,
|
|
11
|
+
formatBashExternalDirectoryDenyReason,
|
|
12
|
+
formatExternalDirectoryAskPrompt,
|
|
13
|
+
formatExternalDirectoryDenyReason,
|
|
14
|
+
formatExternalDirectoryHardStopHint,
|
|
15
|
+
formatExternalDirectoryUserDeniedReason,
|
|
16
|
+
getPathBearingToolPath,
|
|
17
|
+
isPathOutsideWorkingDirectory,
|
|
18
|
+
normalizePathForComparison,
|
|
19
|
+
PATH_BEARING_TOOLS,
|
|
20
|
+
} from "../external-directory";
|
|
21
|
+
import type { PermissionPromptDecision } from "../permission-dialog";
|
|
22
|
+
import { applyPermissionGate } from "../permission-gate";
|
|
23
|
+
import {
|
|
24
|
+
formatAskPrompt,
|
|
25
|
+
formatDenyReason,
|
|
26
|
+
formatMissingToolNameReason,
|
|
27
|
+
formatSkillPathAskPrompt,
|
|
28
|
+
formatSkillPathDenyReason,
|
|
29
|
+
formatUnknownToolReason,
|
|
30
|
+
formatUserDeniedReason,
|
|
31
|
+
} from "../permission-prompts";
|
|
32
|
+
import { deriveApprovalPrefix } from "../session-approval-cache";
|
|
33
|
+
import { findSkillPathMatch } from "../skill-prompt-sanitizer";
|
|
34
|
+
import { getPermissionLogContext } from "../tool-input-preview";
|
|
35
|
+
import {
|
|
36
|
+
checkRequestedToolRegistration,
|
|
37
|
+
getToolNameFromValue,
|
|
38
|
+
} from "../tool-registry";
|
|
39
|
+
import type { HandlerDeps } from "./types";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract the tool input from an event, checking both `input` and `arguments`
|
|
43
|
+
* fields (different Pi SDK versions use different names).
|
|
44
|
+
*/
|
|
45
|
+
export function getEventInput(event: unknown): unknown {
|
|
46
|
+
const record = toRecord(event);
|
|
47
|
+
|
|
48
|
+
if (record.input !== undefined) {
|
|
49
|
+
return record.input;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (record.arguments !== undefined) {
|
|
53
|
+
return record.arguments;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function handleToolCall(
|
|
60
|
+
deps: HandlerDeps,
|
|
61
|
+
event: unknown,
|
|
62
|
+
ctx: ExtensionContext,
|
|
63
|
+
): Promise<{ block?: true; reason?: string }> {
|
|
64
|
+
deps.runtime.runtimeContext = ctx;
|
|
65
|
+
deps.startForwardedPermissionPolling(ctx);
|
|
66
|
+
|
|
67
|
+
const agentName = deps.resolveAgentName(ctx);
|
|
68
|
+
const toolName = getToolNameFromValue(event);
|
|
69
|
+
|
|
70
|
+
if (!toolName) {
|
|
71
|
+
return { block: true, reason: formatMissingToolNameReason() };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const registrationCheck = checkRequestedToolRegistration(
|
|
75
|
+
toolName,
|
|
76
|
+
deps.getAllTools(),
|
|
77
|
+
);
|
|
78
|
+
if (registrationCheck.status === "missing-tool-name") {
|
|
79
|
+
return { block: true, reason: formatMissingToolNameReason() };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (registrationCheck.status === "unregistered") {
|
|
83
|
+
return {
|
|
84
|
+
block: true,
|
|
85
|
+
reason: formatUnknownToolReason(
|
|
86
|
+
registrationCheck.requestedToolName,
|
|
87
|
+
registrationCheck.availableToolNames,
|
|
88
|
+
),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Skill-read gate ──────────────────────────────────────────────────────
|
|
93
|
+
if (
|
|
94
|
+
isToolCallEventType("read", event as ToolCallEvent) &&
|
|
95
|
+
deps.runtime.activeSkillEntries.length > 0
|
|
96
|
+
) {
|
|
97
|
+
const normalizedReadPath = normalizePathForComparison(
|
|
98
|
+
(event as ToolCallEvent & { input: { path: string } }).input.path,
|
|
99
|
+
ctx.cwd,
|
|
100
|
+
);
|
|
101
|
+
const matchedSkill = findSkillPathMatch(
|
|
102
|
+
normalizedReadPath,
|
|
103
|
+
deps.runtime.activeSkillEntries,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (matchedSkill) {
|
|
107
|
+
const readEvent = event as ToolCallEvent & { input: { path: string } };
|
|
108
|
+
const skillReadMessage = formatSkillPathAskPrompt(
|
|
109
|
+
matchedSkill,
|
|
110
|
+
readEvent.input.path,
|
|
111
|
+
agentName ?? undefined,
|
|
112
|
+
);
|
|
113
|
+
const skillReadGate = await applyPermissionGate({
|
|
114
|
+
state: matchedSkill.state,
|
|
115
|
+
canConfirm: deps.canRequestPermissionConfirmation(ctx),
|
|
116
|
+
promptForApproval: () =>
|
|
117
|
+
deps.promptPermission(ctx, {
|
|
118
|
+
requestId: (readEvent as { toolCallId: string }).toolCallId,
|
|
119
|
+
source: "skill_read",
|
|
120
|
+
agentName,
|
|
121
|
+
message: skillReadMessage,
|
|
122
|
+
toolCallId: (readEvent as { toolCallId: string }).toolCallId,
|
|
123
|
+
toolName,
|
|
124
|
+
skillName: matchedSkill.name,
|
|
125
|
+
path: readEvent.input.path,
|
|
126
|
+
}),
|
|
127
|
+
writeLog: deps.runtime.writeReviewLog,
|
|
128
|
+
logContext: {
|
|
129
|
+
source: "skill_read",
|
|
130
|
+
skillName: matchedSkill.name,
|
|
131
|
+
agentName,
|
|
132
|
+
path: readEvent.input.path,
|
|
133
|
+
message: skillReadMessage,
|
|
134
|
+
},
|
|
135
|
+
messages: {
|
|
136
|
+
denyReason: formatSkillPathDenyReason(
|
|
137
|
+
matchedSkill,
|
|
138
|
+
readEvent.input.path,
|
|
139
|
+
agentName ?? undefined,
|
|
140
|
+
),
|
|
141
|
+
unavailableReason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
|
|
142
|
+
userDeniedReason: (decision) => {
|
|
143
|
+
const denialReason = decision.denialReason
|
|
144
|
+
? ` Reason: ${decision.denialReason}.`
|
|
145
|
+
: "";
|
|
146
|
+
return `User denied access to skill '${matchedSkill.name}'.${denialReason}`;
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
if (skillReadGate.action === "block") {
|
|
151
|
+
return { block: true, reason: skillReadGate.reason };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const input = getEventInput(event);
|
|
157
|
+
|
|
158
|
+
// ── External-directory gate (file tools) ─────────────────────────────────
|
|
159
|
+
const externalDirectoryPath = ctx.cwd
|
|
160
|
+
? getPathBearingToolPath(toolName, input)
|
|
161
|
+
: null;
|
|
162
|
+
|
|
163
|
+
if (
|
|
164
|
+
ctx.cwd &&
|
|
165
|
+
externalDirectoryPath &&
|
|
166
|
+
isPathOutsideWorkingDirectory(externalDirectoryPath, ctx.cwd)
|
|
167
|
+
) {
|
|
168
|
+
const normalizedExtPath = normalizePathForComparison(
|
|
169
|
+
externalDirectoryPath,
|
|
170
|
+
ctx.cwd,
|
|
171
|
+
);
|
|
172
|
+
const sessionPrefix = deps.runtime.sessionApprovalCache.findMatchingPrefix(
|
|
173
|
+
"external_directory",
|
|
174
|
+
normalizedExtPath,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
if (sessionPrefix) {
|
|
178
|
+
deps.runtime.writeReviewLog("permission_request.session_approved", {
|
|
179
|
+
source: "tool_call",
|
|
180
|
+
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
181
|
+
toolName,
|
|
182
|
+
agentName,
|
|
183
|
+
path: externalDirectoryPath,
|
|
184
|
+
resolution: "session_approved",
|
|
185
|
+
sessionApprovalPrefix: sessionPrefix,
|
|
186
|
+
});
|
|
187
|
+
// Fall through to normal permission check
|
|
188
|
+
} else {
|
|
189
|
+
const extCheck = deps.runtime.permissionManager.checkPermission(
|
|
190
|
+
"external_directory",
|
|
191
|
+
{},
|
|
192
|
+
agentName ?? undefined,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
let extDirDecision: PermissionPromptDecision | null = null;
|
|
196
|
+
const extDirMessage = formatExternalDirectoryAskPrompt(
|
|
197
|
+
toolName,
|
|
198
|
+
externalDirectoryPath,
|
|
199
|
+
ctx.cwd,
|
|
200
|
+
agentName ?? undefined,
|
|
201
|
+
);
|
|
202
|
+
const extDirGate = await applyPermissionGate({
|
|
203
|
+
state: extCheck.state,
|
|
204
|
+
canConfirm: deps.canRequestPermissionConfirmation(ctx),
|
|
205
|
+
promptForApproval: async () => {
|
|
206
|
+
const decision = await deps.promptPermission(ctx, {
|
|
207
|
+
requestId: (event as { toolCallId: string }).toolCallId,
|
|
208
|
+
source: "tool_call",
|
|
209
|
+
agentName,
|
|
210
|
+
message: extDirMessage,
|
|
211
|
+
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
212
|
+
toolName,
|
|
213
|
+
path: externalDirectoryPath,
|
|
214
|
+
});
|
|
215
|
+
extDirDecision = decision;
|
|
216
|
+
return decision;
|
|
217
|
+
},
|
|
218
|
+
writeLog: deps.runtime.writeReviewLog,
|
|
219
|
+
logContext: {
|
|
220
|
+
source: "tool_call",
|
|
221
|
+
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
222
|
+
toolName,
|
|
223
|
+
agentName,
|
|
224
|
+
path: externalDirectoryPath,
|
|
225
|
+
message: extDirMessage,
|
|
226
|
+
},
|
|
227
|
+
messages: {
|
|
228
|
+
denyReason: formatExternalDirectoryDenyReason(
|
|
229
|
+
toolName,
|
|
230
|
+
externalDirectoryPath,
|
|
231
|
+
ctx.cwd,
|
|
232
|
+
agentName ?? undefined,
|
|
233
|
+
),
|
|
234
|
+
unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
|
|
235
|
+
userDeniedReason: (decision) =>
|
|
236
|
+
formatExternalDirectoryUserDeniedReason(
|
|
237
|
+
toolName,
|
|
238
|
+
externalDirectoryPath,
|
|
239
|
+
decision.denialReason,
|
|
240
|
+
),
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
if (extDirGate.action === "block") {
|
|
244
|
+
return { block: true, reason: extDirGate.reason };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (extDirDecision?.state === "approved_for_session") {
|
|
248
|
+
const prefix = deriveApprovalPrefix(normalizedExtPath);
|
|
249
|
+
deps.runtime.sessionApprovalCache.approve("external_directory", prefix);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// Fall through to normal permission check
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Bash external-directory gate ─────────────────────────────────────────
|
|
256
|
+
if (ctx.cwd && toolName === "bash") {
|
|
257
|
+
const command = getNonEmptyString(toRecord(input).command);
|
|
258
|
+
if (command) {
|
|
259
|
+
const externalPaths = extractExternalPathsFromBashCommand(
|
|
260
|
+
command,
|
|
261
|
+
ctx.cwd,
|
|
262
|
+
);
|
|
263
|
+
if (externalPaths.length > 0) {
|
|
264
|
+
const uncoveredPaths = externalPaths.filter(
|
|
265
|
+
(p) =>
|
|
266
|
+
!deps.runtime.sessionApprovalCache.has("external_directory", p),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
if (uncoveredPaths.length === 0) {
|
|
270
|
+
deps.runtime.writeReviewLog("permission_request.session_approved", {
|
|
271
|
+
source: "tool_call",
|
|
272
|
+
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
273
|
+
toolName,
|
|
274
|
+
agentName,
|
|
275
|
+
command,
|
|
276
|
+
externalPaths,
|
|
277
|
+
resolution: "session_approved",
|
|
278
|
+
});
|
|
279
|
+
// Fall through to normal bash permission check
|
|
280
|
+
} else {
|
|
281
|
+
const extCheck = deps.runtime.permissionManager.checkPermission(
|
|
282
|
+
"external_directory",
|
|
283
|
+
{},
|
|
284
|
+
agentName ?? undefined,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
let bashExtDecision: PermissionPromptDecision | null = null;
|
|
288
|
+
const bashExtMessage = formatBashExternalDirectoryAskPrompt(
|
|
289
|
+
command,
|
|
290
|
+
uncoveredPaths,
|
|
291
|
+
ctx.cwd,
|
|
292
|
+
agentName ?? undefined,
|
|
293
|
+
);
|
|
294
|
+
const bashExtGate = await applyPermissionGate({
|
|
295
|
+
state: extCheck.state,
|
|
296
|
+
canConfirm: deps.canRequestPermissionConfirmation(ctx),
|
|
297
|
+
promptForApproval: async () => {
|
|
298
|
+
const decision = await deps.promptPermission(ctx, {
|
|
299
|
+
requestId: (event as { toolCallId: string }).toolCallId,
|
|
300
|
+
source: "tool_call",
|
|
301
|
+
agentName,
|
|
302
|
+
message: bashExtMessage,
|
|
303
|
+
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
304
|
+
toolName,
|
|
305
|
+
command,
|
|
306
|
+
});
|
|
307
|
+
bashExtDecision = decision;
|
|
308
|
+
return decision;
|
|
309
|
+
},
|
|
310
|
+
writeLog: deps.runtime.writeReviewLog,
|
|
311
|
+
logContext: {
|
|
312
|
+
source: "tool_call",
|
|
313
|
+
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
314
|
+
toolName,
|
|
315
|
+
agentName,
|
|
316
|
+
command,
|
|
317
|
+
externalPaths: uncoveredPaths,
|
|
318
|
+
message: bashExtMessage,
|
|
319
|
+
},
|
|
320
|
+
messages: {
|
|
321
|
+
denyReason: formatBashExternalDirectoryDenyReason(
|
|
322
|
+
command,
|
|
323
|
+
uncoveredPaths,
|
|
324
|
+
ctx.cwd,
|
|
325
|
+
agentName ?? undefined,
|
|
326
|
+
),
|
|
327
|
+
unavailableReason: `Bash command '${command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`,
|
|
328
|
+
userDeniedReason: (decision) => {
|
|
329
|
+
const reasonSuffix = decision.denialReason
|
|
330
|
+
? ` Reason: ${decision.denialReason}.`
|
|
331
|
+
: "";
|
|
332
|
+
return `User denied external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
if (bashExtGate.action === "block") {
|
|
337
|
+
return { block: true, reason: bashExtGate.reason };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (bashExtDecision?.state === "approved_for_session") {
|
|
341
|
+
for (const extPath of uncoveredPaths) {
|
|
342
|
+
const prefix = deriveApprovalPrefix(extPath);
|
|
343
|
+
deps.runtime.sessionApprovalCache.approve(
|
|
344
|
+
"external_directory",
|
|
345
|
+
prefix,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// Fall through to normal bash permission check
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── Normal tool permission gate ───────────────────────────────────────────
|
|
356
|
+
const check = deps.runtime.permissionManager.checkPermission(
|
|
357
|
+
toolName,
|
|
358
|
+
input,
|
|
359
|
+
agentName ?? undefined,
|
|
360
|
+
);
|
|
361
|
+
const permissionLogContext = getPermissionLogContext(
|
|
362
|
+
check,
|
|
363
|
+
input,
|
|
364
|
+
PATH_BEARING_TOOLS,
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const toolUnavailableReason =
|
|
368
|
+
toolName === "bash" && isToolCallEventType("bash", event as ToolCallEvent)
|
|
369
|
+
? `Running bash command '${(event as ToolCallEvent & { input: { command: string } }).input.command}' requires approval, but no interactive UI is available.`
|
|
370
|
+
: toolName === "mcp"
|
|
371
|
+
? "Using tool 'mcp' requires approval, but no interactive UI is available."
|
|
372
|
+
: `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
|
|
373
|
+
|
|
374
|
+
const toolAskMessage = formatAskPrompt(check, agentName ?? undefined, input);
|
|
375
|
+
const toolGate = await applyPermissionGate({
|
|
376
|
+
state: check.state,
|
|
377
|
+
canConfirm: deps.canRequestPermissionConfirmation(ctx),
|
|
378
|
+
promptForApproval: () =>
|
|
379
|
+
deps.promptPermission(ctx, {
|
|
380
|
+
requestId: (event as { toolCallId: string }).toolCallId,
|
|
381
|
+
source: "tool_call",
|
|
382
|
+
agentName,
|
|
383
|
+
message: toolAskMessage,
|
|
384
|
+
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
385
|
+
toolName,
|
|
386
|
+
...permissionLogContext,
|
|
387
|
+
}),
|
|
388
|
+
writeLog: deps.runtime.writeReviewLog,
|
|
389
|
+
logContext: {
|
|
390
|
+
source: "tool_call",
|
|
391
|
+
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
392
|
+
toolName,
|
|
393
|
+
agentName,
|
|
394
|
+
message: toolAskMessage,
|
|
395
|
+
...permissionLogContext,
|
|
396
|
+
},
|
|
397
|
+
messages: {
|
|
398
|
+
denyReason: formatDenyReason(check, agentName ?? undefined),
|
|
399
|
+
unavailableReason: toolUnavailableReason,
|
|
400
|
+
userDeniedReason: (decision) =>
|
|
401
|
+
formatUserDeniedReason(check, decision.denialReason),
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
if (toolGate.action === "block") {
|
|
406
|
+
return { block: true, reason: toolGate.reason };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return {};
|
|
410
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { PermissionPromptDecision } from "../permission-dialog";
|
|
4
|
+
import type { PermissionManager } from "../permission-manager";
|
|
5
|
+
import type { ExtensionRuntime } from "../runtime";
|
|
6
|
+
|
|
7
|
+
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
|
8
|
+
|
|
9
|
+
/** Details passed when prompting the user for a permission decision. */
|
|
10
|
+
export interface PromptPermissionDetails {
|
|
11
|
+
requestId: string;
|
|
12
|
+
source: PermissionReviewSource;
|
|
13
|
+
agentName: string | null;
|
|
14
|
+
message: string;
|
|
15
|
+
toolCallId?: string;
|
|
16
|
+
toolName?: string;
|
|
17
|
+
skillName?: string;
|
|
18
|
+
path?: string;
|
|
19
|
+
command?: string;
|
|
20
|
+
target?: string;
|
|
21
|
+
toolInputPreview?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Explicit dependency bag passed to each extracted event handler.
|
|
26
|
+
*
|
|
27
|
+
* Mutable state lives in `runtime`; handlers read and write `deps.runtime.*`
|
|
28
|
+
* directly instead of going through getter/setter pairs.
|
|
29
|
+
*/
|
|
30
|
+
export interface HandlerDeps {
|
|
31
|
+
// ── Runtime context ────────────────────────────────────────────────────
|
|
32
|
+
/** All mutable extension state and log-writing methods. */
|
|
33
|
+
readonly runtime: ExtensionRuntime;
|
|
34
|
+
|
|
35
|
+
// ── Factories ──────────────────────────────────────────────────────────
|
|
36
|
+
/** Create a new PermissionManager scoped to cwd's config hierarchy. */
|
|
37
|
+
createPermissionManagerForCwd(
|
|
38
|
+
cwd: string | undefined | null,
|
|
39
|
+
): PermissionManager;
|
|
40
|
+
|
|
41
|
+
// ── Config & lifecycle helpers ─────────────────────────────────────────
|
|
42
|
+
/** Reload merged config from disk; optionally update the stored runtime context. */
|
|
43
|
+
refreshExtensionConfig(ctx?: ExtensionContext): void;
|
|
44
|
+
/** Show a warning notification to the user (no-op when no UI is available). */
|
|
45
|
+
notifyWarning(message: string): void;
|
|
46
|
+
/** Write the resolved config path set to the review and debug logs. */
|
|
47
|
+
logResolvedConfigPaths(): void;
|
|
48
|
+
|
|
49
|
+
// ── Permission helpers ─────────────────────────────────────────────────
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the active agent name from the session context or system prompt.
|
|
52
|
+
* Updates runtime.lastKnownActiveAgentName as a side effect.
|
|
53
|
+
*/
|
|
54
|
+
resolveAgentName(ctx: ExtensionContext, systemPrompt?: string): string | null;
|
|
55
|
+
/** Whether the current context can show an interactive permission prompt. */
|
|
56
|
+
canRequestPermissionConfirmation(ctx: ExtensionContext): boolean;
|
|
57
|
+
/** Prompt the user for a permission decision, log the outcome, and return it. */
|
|
58
|
+
promptPermission(
|
|
59
|
+
ctx: ExtensionContext,
|
|
60
|
+
details: PromptPermissionDetails,
|
|
61
|
+
): Promise<PermissionPromptDecision>;
|
|
62
|
+
/** Generate a unique ID for a permission request. */
|
|
63
|
+
createPermissionRequestId(prefix: string): string;
|
|
64
|
+
|
|
65
|
+
// ── Forwarding ─────────────────────────────────────────────────────────
|
|
66
|
+
startForwardedPermissionPolling(ctx: ExtensionContext): void;
|
|
67
|
+
stopForwardedPermissionPolling(): void;
|
|
68
|
+
|
|
69
|
+
// ── Pi API subset ──────────────────────────────────────────────────────
|
|
70
|
+
getAllTools(): unknown[];
|
|
71
|
+
setActiveTools(names: string[]): void;
|
|
72
|
+
}
|