@gotgenes/pi-permission-system 5.3.4 → 5.5.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 +34 -0
- package/package.json +1 -1
- package/src/handlers/gates/bash-external-directory.ts +134 -0
- package/src/handlers/gates/external-directory.ts +189 -0
- package/src/handlers/gates/helpers.ts +41 -0
- package/src/handlers/gates/index.ts +6 -0
- package/src/handlers/gates/skill-read.ts +111 -0
- package/src/handlers/gates/tool.ts +160 -0
- package/src/handlers/gates/types.ts +15 -0
- package/src/handlers/tool-call.ts +33 -523
- package/src/permission-manager.ts +28 -279
- package/src/policy-loader.ts +350 -0
- package/tests/handlers/gates/bash-external-directory.test.ts +247 -0
- package/tests/handlers/gates/external-directory.test.ts +320 -0
- package/tests/handlers/gates/helpers.test.ts +71 -0
- package/tests/handlers/gates/skill-read.test.ts +204 -0
- package/tests/handlers/gates/tool.test.ts +417 -0
- package/tests/handlers/tool-call.test.ts +0 -504
- package/tests/permission-manager-unified.test.ts +319 -0
- package/tests/policy-loader.test.ts +561 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Outcome of a single permission gate evaluation. */
|
|
4
|
+
export type GateOutcome =
|
|
5
|
+
| { action: "allow" }
|
|
6
|
+
| { action: "block"; reason: string };
|
|
7
|
+
|
|
8
|
+
/** Pre-validated context shared across all gates. */
|
|
9
|
+
export interface ToolCallContext {
|
|
10
|
+
toolName: string;
|
|
11
|
+
agentName: string | null;
|
|
12
|
+
input: unknown;
|
|
13
|
+
toolCallId: string;
|
|
14
|
+
cwd: string | undefined;
|
|
15
|
+
}
|
|
@@ -1,91 +1,21 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ExtensionContext,
|
|
3
|
-
ToolCallEvent,
|
|
4
|
-
} from "@mariozechner/pi-coding-agent";
|
|
5
|
-
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
6
2
|
|
|
7
|
-
import {
|
|
3
|
+
import { toRecord } from "../common";
|
|
8
4
|
import {
|
|
9
|
-
extractExternalPathsFromBashCommand,
|
|
10
|
-
formatBashExternalDirectoryAskPrompt,
|
|
11
|
-
formatBashExternalDirectoryDenyReason,
|
|
12
|
-
formatExternalDirectoryAskPrompt,
|
|
13
|
-
formatExternalDirectoryDenyReason,
|
|
14
|
-
formatExternalDirectoryHardStopHint,
|
|
15
|
-
formatExternalDirectoryUserDeniedReason,
|
|
16
|
-
getPathBearingToolPath,
|
|
17
|
-
isPathOutsideWorkingDirectory,
|
|
18
|
-
isPiInfrastructureRead,
|
|
19
|
-
normalizePathForComparison,
|
|
20
|
-
PATH_BEARING_TOOLS,
|
|
21
|
-
} from "../external-directory";
|
|
22
|
-
import { suggestSessionPattern } from "../pattern-suggest";
|
|
23
|
-
import type { PermissionPromptDecision } from "../permission-dialog";
|
|
24
|
-
import {
|
|
25
|
-
emitDecisionEvent,
|
|
26
|
-
type PermissionDecisionResolution,
|
|
27
|
-
} from "../permission-events";
|
|
28
|
-
import { applyPermissionGate } from "../permission-gate";
|
|
29
|
-
import {
|
|
30
|
-
formatAskPrompt,
|
|
31
|
-
formatDenyReason,
|
|
32
5
|
formatMissingToolNameReason,
|
|
33
|
-
formatSkillPathAskPrompt,
|
|
34
|
-
formatSkillPathDenyReason,
|
|
35
6
|
formatUnknownToolReason,
|
|
36
|
-
formatUserDeniedReason,
|
|
37
7
|
} from "../permission-prompts";
|
|
38
|
-
import { deriveApprovalPattern } from "../session-rules";
|
|
39
|
-
import { findSkillPathMatch } from "../skill-prompt-sanitizer";
|
|
40
|
-
import { getPermissionLogContext } from "../tool-input-preview";
|
|
41
8
|
import {
|
|
42
9
|
checkRequestedToolRegistration,
|
|
43
10
|
getToolNameFromValue,
|
|
44
11
|
} from "../tool-registry";
|
|
45
|
-
import
|
|
12
|
+
import { evaluateBashExternalDirectoryGate } from "./gates/bash-external-directory";
|
|
13
|
+
import { evaluateExternalDirectoryGate } from "./gates/external-directory";
|
|
14
|
+
import { evaluateSkillReadGate } from "./gates/skill-read";
|
|
15
|
+
import { evaluateToolGate } from "./gates/tool";
|
|
16
|
+
import type { ToolCallContext } from "./gates/types";
|
|
46
17
|
import type { HandlerDeps } from "./types";
|
|
47
18
|
|
|
48
|
-
// ── Emission helper ────────────────────────────────────────────────────────
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Derive the human-readable value for a decision event from a check result.
|
|
52
|
-
* Bash → extracted command; MCP → qualified target; others → tool name.
|
|
53
|
-
*/
|
|
54
|
-
function deriveDecisionValue(
|
|
55
|
-
toolName: string,
|
|
56
|
-
check: Pick<PermissionCheckResult, "command" | "target">,
|
|
57
|
-
): string {
|
|
58
|
-
if (toolName === "bash") return check.command ?? toolName;
|
|
59
|
-
if (toolName === "mcp") return check.target ?? toolName;
|
|
60
|
-
return toolName;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Map the gate outcome back to a PermissionDecisionResolution.
|
|
65
|
-
*
|
|
66
|
-
* @param state - The permission state passed to the gate.
|
|
67
|
-
* @param action - The gate's resulting action ("allow" | "block").
|
|
68
|
-
* @param hasSession - True when the gate result carries a sessionApproval
|
|
69
|
-
* (indicates the user chose "for this session").
|
|
70
|
-
* @param canConfirm - Whether an interactive prompt was available.
|
|
71
|
-
*/
|
|
72
|
-
function deriveResolution(
|
|
73
|
-
state: "allow" | "deny" | "ask",
|
|
74
|
-
action: "allow" | "block",
|
|
75
|
-
hasSession: boolean,
|
|
76
|
-
canConfirm: boolean,
|
|
77
|
-
autoApproved = false,
|
|
78
|
-
): PermissionDecisionResolution {
|
|
79
|
-
if (state === "allow") return "policy_allow";
|
|
80
|
-
if (state === "deny") return "policy_deny";
|
|
81
|
-
// state === "ask"
|
|
82
|
-
if (action === "allow") {
|
|
83
|
-
if (autoApproved) return "auto_approved";
|
|
84
|
-
return hasSession ? "user_approved_for_session" : "user_approved";
|
|
85
|
-
}
|
|
86
|
-
return canConfirm ? "user_denied" : "confirmation_unavailable";
|
|
87
|
-
}
|
|
88
|
-
|
|
89
19
|
/**
|
|
90
20
|
* Extract the tool input from an event, checking both `input` and `arguments`
|
|
91
21
|
* fields (different Pi SDK versions use different names).
|
|
@@ -137,462 +67,42 @@ export async function handleToolCall(
|
|
|
137
67
|
};
|
|
138
68
|
}
|
|
139
69
|
|
|
140
|
-
// ── Skill-read gate ──────────────────────────────────────────────────────
|
|
141
|
-
if (
|
|
142
|
-
isToolCallEventType("read", event as ToolCallEvent) &&
|
|
143
|
-
deps.runtime.activeSkillEntries.length > 0
|
|
144
|
-
) {
|
|
145
|
-
const normalizedReadPath = normalizePathForComparison(
|
|
146
|
-
(event as ToolCallEvent & { input: { path: string } }).input.path,
|
|
147
|
-
ctx.cwd,
|
|
148
|
-
);
|
|
149
|
-
const matchedSkill = findSkillPathMatch(
|
|
150
|
-
normalizedReadPath,
|
|
151
|
-
deps.runtime.activeSkillEntries,
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
if (matchedSkill) {
|
|
155
|
-
const readEvent = event as ToolCallEvent & { input: { path: string } };
|
|
156
|
-
const skillReadMessage = formatSkillPathAskPrompt(
|
|
157
|
-
matchedSkill,
|
|
158
|
-
readEvent.input.path,
|
|
159
|
-
agentName ?? undefined,
|
|
160
|
-
);
|
|
161
|
-
const skillReadCanConfirm = deps.canRequestPermissionConfirmation(ctx);
|
|
162
|
-
const skillReadGate = await applyPermissionGate({
|
|
163
|
-
state: matchedSkill.state,
|
|
164
|
-
canConfirm: skillReadCanConfirm,
|
|
165
|
-
promptForApproval: () =>
|
|
166
|
-
deps.promptPermission(ctx, {
|
|
167
|
-
requestId: (readEvent as { toolCallId: string }).toolCallId,
|
|
168
|
-
source: "skill_read",
|
|
169
|
-
agentName,
|
|
170
|
-
message: skillReadMessage,
|
|
171
|
-
toolCallId: (readEvent as { toolCallId: string }).toolCallId,
|
|
172
|
-
toolName,
|
|
173
|
-
skillName: matchedSkill.name,
|
|
174
|
-
path: readEvent.input.path,
|
|
175
|
-
}),
|
|
176
|
-
writeLog: deps.runtime.writeReviewLog,
|
|
177
|
-
logContext: {
|
|
178
|
-
source: "skill_read",
|
|
179
|
-
skillName: matchedSkill.name,
|
|
180
|
-
agentName,
|
|
181
|
-
path: readEvent.input.path,
|
|
182
|
-
message: skillReadMessage,
|
|
183
|
-
},
|
|
184
|
-
messages: {
|
|
185
|
-
denyReason: formatSkillPathDenyReason(
|
|
186
|
-
matchedSkill,
|
|
187
|
-
readEvent.input.path,
|
|
188
|
-
agentName ?? undefined,
|
|
189
|
-
),
|
|
190
|
-
unavailableReason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
|
|
191
|
-
userDeniedReason: (decision) => {
|
|
192
|
-
const denialReason = decision.denialReason
|
|
193
|
-
? ` Reason: ${decision.denialReason}.`
|
|
194
|
-
: "";
|
|
195
|
-
return `User denied access to skill '${matchedSkill.name}'.${denialReason}`;
|
|
196
|
-
},
|
|
197
|
-
},
|
|
198
|
-
});
|
|
199
|
-
emitDecisionEvent(deps.events, {
|
|
200
|
-
surface: "skill",
|
|
201
|
-
value: matchedSkill.name,
|
|
202
|
-
result: skillReadGate.action === "allow" ? "allow" : "deny",
|
|
203
|
-
resolution: deriveResolution(
|
|
204
|
-
matchedSkill.state,
|
|
205
|
-
skillReadGate.action,
|
|
206
|
-
false,
|
|
207
|
-
skillReadCanConfirm,
|
|
208
|
-
),
|
|
209
|
-
origin: null,
|
|
210
|
-
agentName: agentName ?? null,
|
|
211
|
-
matchedPattern: null,
|
|
212
|
-
});
|
|
213
|
-
if (skillReadGate.action === "block") {
|
|
214
|
-
return { block: true, reason: skillReadGate.reason };
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
70
|
const input = getEventInput(event);
|
|
71
|
+
const toolCallId =
|
|
72
|
+
typeof (event as Record<string, unknown>).toolCallId === "string"
|
|
73
|
+
? ((event as Record<string, unknown>).toolCallId as string)
|
|
74
|
+
: "";
|
|
220
75
|
|
|
221
|
-
|
|
222
|
-
const externalDirectoryPath = ctx.cwd
|
|
223
|
-
? getPathBearingToolPath(toolName, input)
|
|
224
|
-
: null;
|
|
225
|
-
|
|
226
|
-
if (
|
|
227
|
-
ctx.cwd &&
|
|
228
|
-
externalDirectoryPath &&
|
|
229
|
-
isPathOutsideWorkingDirectory(externalDirectoryPath, ctx.cwd)
|
|
230
|
-
) {
|
|
231
|
-
const normalizedExtPath = normalizePathForComparison(
|
|
232
|
-
externalDirectoryPath,
|
|
233
|
-
ctx.cwd,
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
// ── Pi infrastructure read bypass ──────────────────────────────────
|
|
237
|
-
// Auto-allow read-only tools targeting Pi infrastructure directories
|
|
238
|
-
// (agent dir, global node_modules, project-local .pi/npm|git, and
|
|
239
|
-
// any user-configured extras). Writes are never bypassed.
|
|
240
|
-
const allInfraDirs = [
|
|
241
|
-
...deps.runtime.piInfrastructureDirs,
|
|
242
|
-
...(deps.runtime.config.piInfrastructureReadPaths ?? []),
|
|
243
|
-
];
|
|
244
|
-
if (
|
|
245
|
-
isPiInfrastructureRead(toolName, normalizedExtPath, allInfraDirs, ctx.cwd)
|
|
246
|
-
) {
|
|
247
|
-
deps.runtime.writeReviewLog(
|
|
248
|
-
"permission_request.infrastructure_auto_allowed",
|
|
249
|
-
{
|
|
250
|
-
source: "tool_call",
|
|
251
|
-
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
252
|
-
toolName,
|
|
253
|
-
agentName,
|
|
254
|
-
path: externalDirectoryPath,
|
|
255
|
-
},
|
|
256
|
-
);
|
|
257
|
-
emitDecisionEvent(deps.events, {
|
|
258
|
-
surface: toolName,
|
|
259
|
-
value: externalDirectoryPath,
|
|
260
|
-
result: "allow",
|
|
261
|
-
resolution: "infrastructure_auto_allowed",
|
|
262
|
-
origin: null,
|
|
263
|
-
agentName: agentName ?? null,
|
|
264
|
-
matchedPattern: null,
|
|
265
|
-
});
|
|
266
|
-
// Fall through to normal tool-permission check.
|
|
267
|
-
} else {
|
|
268
|
-
const extCheck = deps.runtime.permissionManager.checkPermission(
|
|
269
|
-
"external_directory",
|
|
270
|
-
{ path: normalizedExtPath },
|
|
271
|
-
agentName ?? undefined,
|
|
272
|
-
deps.runtime.sessionRules.getRuleset(),
|
|
273
|
-
);
|
|
274
|
-
|
|
275
|
-
if (extCheck.source === "session") {
|
|
276
|
-
deps.runtime.writeReviewLog("permission_request.session_approved", {
|
|
277
|
-
source: "tool_call",
|
|
278
|
-
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
279
|
-
toolName,
|
|
280
|
-
agentName,
|
|
281
|
-
path: externalDirectoryPath,
|
|
282
|
-
resolution: "session_approved",
|
|
283
|
-
sessionApprovalPattern: extCheck.matchedPattern,
|
|
284
|
-
});
|
|
285
|
-
emitDecisionEvent(deps.events, {
|
|
286
|
-
surface: "external_directory",
|
|
287
|
-
value: externalDirectoryPath,
|
|
288
|
-
result: "allow",
|
|
289
|
-
resolution: "session_approved",
|
|
290
|
-
origin: extCheck.origin ?? null,
|
|
291
|
-
agentName: agentName ?? null,
|
|
292
|
-
matchedPattern: extCheck.matchedPattern ?? null,
|
|
293
|
-
});
|
|
294
|
-
// Fall through to normal permission check
|
|
295
|
-
} else {
|
|
296
|
-
let extDirDecision: PermissionPromptDecision | null = null;
|
|
297
|
-
const extDirMessage = formatExternalDirectoryAskPrompt(
|
|
298
|
-
toolName,
|
|
299
|
-
externalDirectoryPath,
|
|
300
|
-
ctx.cwd,
|
|
301
|
-
agentName ?? undefined,
|
|
302
|
-
);
|
|
303
|
-
const extDirCanConfirm = deps.canRequestPermissionConfirmation(ctx);
|
|
304
|
-
const extDirGateResult = await applyPermissionGate({
|
|
305
|
-
state: extCheck.state,
|
|
306
|
-
canConfirm: extDirCanConfirm,
|
|
307
|
-
promptForApproval: async () => {
|
|
308
|
-
const decision = await deps.promptPermission(ctx, {
|
|
309
|
-
requestId: (event as { toolCallId: string }).toolCallId,
|
|
310
|
-
source: "tool_call",
|
|
311
|
-
agentName,
|
|
312
|
-
message: extDirMessage,
|
|
313
|
-
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
314
|
-
toolName,
|
|
315
|
-
path: externalDirectoryPath,
|
|
316
|
-
});
|
|
317
|
-
extDirDecision = decision;
|
|
318
|
-
return decision;
|
|
319
|
-
},
|
|
320
|
-
writeLog: deps.runtime.writeReviewLog,
|
|
321
|
-
logContext: {
|
|
322
|
-
source: "tool_call",
|
|
323
|
-
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
324
|
-
toolName,
|
|
325
|
-
agentName,
|
|
326
|
-
path: externalDirectoryPath,
|
|
327
|
-
message: extDirMessage,
|
|
328
|
-
},
|
|
329
|
-
messages: {
|
|
330
|
-
denyReason: formatExternalDirectoryDenyReason(
|
|
331
|
-
toolName,
|
|
332
|
-
externalDirectoryPath,
|
|
333
|
-
ctx.cwd,
|
|
334
|
-
agentName ?? undefined,
|
|
335
|
-
),
|
|
336
|
-
unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
|
|
337
|
-
userDeniedReason: (decision) =>
|
|
338
|
-
formatExternalDirectoryUserDeniedReason(
|
|
339
|
-
toolName,
|
|
340
|
-
externalDirectoryPath,
|
|
341
|
-
decision.denialReason,
|
|
342
|
-
),
|
|
343
|
-
},
|
|
344
|
-
});
|
|
345
|
-
emitDecisionEvent(deps.events, {
|
|
346
|
-
surface: "external_directory",
|
|
347
|
-
value: externalDirectoryPath,
|
|
348
|
-
result: extDirGateResult.action === "allow" ? "allow" : "deny",
|
|
349
|
-
resolution: deriveResolution(
|
|
350
|
-
extCheck.state,
|
|
351
|
-
extDirGateResult.action,
|
|
352
|
-
extDirDecision?.state === "approved_for_session",
|
|
353
|
-
extDirCanConfirm,
|
|
354
|
-
),
|
|
355
|
-
origin: extCheck.origin ?? null,
|
|
356
|
-
agentName: agentName ?? null,
|
|
357
|
-
matchedPattern: extCheck.matchedPattern ?? null,
|
|
358
|
-
});
|
|
359
|
-
if (extDirGateResult.action === "block") {
|
|
360
|
-
return { block: true, reason: extDirGateResult.reason };
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
if (extDirDecision?.state === "approved_for_session") {
|
|
364
|
-
const pattern = deriveApprovalPattern(normalizedExtPath);
|
|
365
|
-
deps.runtime.sessionRules.approve("external_directory", pattern);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
} // end else (not Pi infrastructure read)
|
|
369
|
-
// Fall through to normal permission check
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// ── Bash external-directory gate ─────────────────────────────────────────
|
|
373
|
-
if (ctx.cwd && toolName === "bash") {
|
|
374
|
-
const command = getNonEmptyString(toRecord(input).command);
|
|
375
|
-
if (command) {
|
|
376
|
-
const externalPaths = await extractExternalPathsFromBashCommand(
|
|
377
|
-
command,
|
|
378
|
-
ctx.cwd,
|
|
379
|
-
);
|
|
380
|
-
if (externalPaths.length > 0) {
|
|
381
|
-
const bashSessionRules = deps.runtime.sessionRules.getRuleset();
|
|
382
|
-
const uncoveredPaths = externalPaths.filter(
|
|
383
|
-
(p) =>
|
|
384
|
-
deps.runtime.permissionManager.checkPermission(
|
|
385
|
-
"external_directory",
|
|
386
|
-
{ path: p },
|
|
387
|
-
agentName ?? undefined,
|
|
388
|
-
bashSessionRules,
|
|
389
|
-
).source !== "session",
|
|
390
|
-
);
|
|
391
|
-
|
|
392
|
-
if (uncoveredPaths.length === 0) {
|
|
393
|
-
deps.runtime.writeReviewLog("permission_request.session_approved", {
|
|
394
|
-
source: "tool_call",
|
|
395
|
-
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
396
|
-
toolName,
|
|
397
|
-
agentName,
|
|
398
|
-
command,
|
|
399
|
-
externalPaths,
|
|
400
|
-
resolution: "session_approved",
|
|
401
|
-
});
|
|
402
|
-
// Fall through to normal bash permission check
|
|
403
|
-
} else {
|
|
404
|
-
// Get the config-level policy (no path → no session check).
|
|
405
|
-
const extCheck = deps.runtime.permissionManager.checkPermission(
|
|
406
|
-
"external_directory",
|
|
407
|
-
{},
|
|
408
|
-
agentName ?? undefined,
|
|
409
|
-
);
|
|
410
|
-
|
|
411
|
-
let bashExtDecision: PermissionPromptDecision | null = null;
|
|
412
|
-
const bashExtMessage = formatBashExternalDirectoryAskPrompt(
|
|
413
|
-
command,
|
|
414
|
-
uncoveredPaths,
|
|
415
|
-
ctx.cwd,
|
|
416
|
-
agentName ?? undefined,
|
|
417
|
-
);
|
|
418
|
-
const bashExtGate = await applyPermissionGate({
|
|
419
|
-
state: extCheck.state,
|
|
420
|
-
canConfirm: deps.canRequestPermissionConfirmation(ctx),
|
|
421
|
-
promptForApproval: async () => {
|
|
422
|
-
const decision = await deps.promptPermission(ctx, {
|
|
423
|
-
requestId: (event as { toolCallId: string }).toolCallId,
|
|
424
|
-
source: "tool_call",
|
|
425
|
-
agentName,
|
|
426
|
-
message: bashExtMessage,
|
|
427
|
-
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
428
|
-
toolName,
|
|
429
|
-
command,
|
|
430
|
-
});
|
|
431
|
-
bashExtDecision = decision;
|
|
432
|
-
return decision;
|
|
433
|
-
},
|
|
434
|
-
writeLog: deps.runtime.writeReviewLog,
|
|
435
|
-
logContext: {
|
|
436
|
-
source: "tool_call",
|
|
437
|
-
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
438
|
-
toolName,
|
|
439
|
-
agentName,
|
|
440
|
-
command,
|
|
441
|
-
externalPaths: uncoveredPaths,
|
|
442
|
-
message: bashExtMessage,
|
|
443
|
-
},
|
|
444
|
-
messages: {
|
|
445
|
-
denyReason: formatBashExternalDirectoryDenyReason(
|
|
446
|
-
command,
|
|
447
|
-
uncoveredPaths,
|
|
448
|
-
ctx.cwd,
|
|
449
|
-
agentName ?? undefined,
|
|
450
|
-
),
|
|
451
|
-
unavailableReason: `Bash command '${command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`,
|
|
452
|
-
userDeniedReason: (decision) => {
|
|
453
|
-
const reasonSuffix = decision.denialReason
|
|
454
|
-
? ` Reason: ${decision.denialReason}.`
|
|
455
|
-
: "";
|
|
456
|
-
return `User denied external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
|
|
457
|
-
},
|
|
458
|
-
},
|
|
459
|
-
});
|
|
460
|
-
if (bashExtGate.action === "block") {
|
|
461
|
-
return { block: true, reason: bashExtGate.reason };
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
if (bashExtDecision?.state === "approved_for_session") {
|
|
465
|
-
for (const extPath of uncoveredPaths) {
|
|
466
|
-
const pattern = deriveApprovalPattern(extPath);
|
|
467
|
-
deps.runtime.sessionRules.approve("external_directory", pattern);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
// Fall through to normal bash permission check
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// ── Normal tool permission gate ───────────────────────────────────────────
|
|
477
|
-
const check = deps.runtime.permissionManager.checkPermission(
|
|
76
|
+
const tcc: ToolCallContext = {
|
|
478
77
|
toolName,
|
|
78
|
+
agentName,
|
|
479
79
|
input,
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
80
|
+
toolCallId,
|
|
81
|
+
cwd: ctx.cwd,
|
|
82
|
+
};
|
|
483
83
|
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
489
|
-
toolName,
|
|
490
|
-
agentName,
|
|
491
|
-
resolution: "session_approved",
|
|
492
|
-
sessionApprovalPattern: check.matchedPattern,
|
|
493
|
-
});
|
|
494
|
-
emitDecisionEvent(deps.events, {
|
|
495
|
-
surface: toolName,
|
|
496
|
-
value: deriveDecisionValue(toolName, check),
|
|
497
|
-
result: "allow",
|
|
498
|
-
resolution: "session_approved",
|
|
499
|
-
origin: check.origin ?? null,
|
|
500
|
-
agentName: agentName ?? null,
|
|
501
|
-
matchedPattern: check.matchedPattern ?? null,
|
|
502
|
-
});
|
|
503
|
-
return {};
|
|
84
|
+
// ── Skill-read gate ──────────────────────────────────────────────────────
|
|
85
|
+
const skillResult = await evaluateSkillReadGate(tcc, deps);
|
|
86
|
+
if (skillResult?.action === "block") {
|
|
87
|
+
return { block: true, reason: skillResult.reason };
|
|
504
88
|
}
|
|
505
89
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
// Compute session approval suggestion for the "for this session" option.
|
|
513
|
-
const suggestionValue =
|
|
514
|
-
toolName === "bash"
|
|
515
|
-
? (check.command ?? "")
|
|
516
|
-
: toolName === "mcp"
|
|
517
|
-
? (check.target ?? "mcp")
|
|
518
|
-
: "*";
|
|
519
|
-
const suggestion = suggestSessionPattern(toolName, suggestionValue);
|
|
520
|
-
|
|
521
|
-
const toolUnavailableReason =
|
|
522
|
-
toolName === "bash" && isToolCallEventType("bash", event as ToolCallEvent)
|
|
523
|
-
? `Running bash command '${(event as ToolCallEvent & { input: { command: string } }).input.command}' requires approval, but no interactive UI is available.`
|
|
524
|
-
: toolName === "mcp"
|
|
525
|
-
? "Using tool 'mcp' requires approval, but no interactive UI is available."
|
|
526
|
-
: `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
|
|
527
|
-
|
|
528
|
-
const toolAskMessage = formatAskPrompt(check, agentName ?? undefined, input);
|
|
529
|
-
const toolCanConfirm = deps.canRequestPermissionConfirmation(ctx);
|
|
530
|
-
let toolDecisionAutoApproved = false;
|
|
531
|
-
const toolGate = await applyPermissionGate({
|
|
532
|
-
state: check.state,
|
|
533
|
-
canConfirm: toolCanConfirm,
|
|
534
|
-
sessionApproval: {
|
|
535
|
-
surface: suggestion.surface,
|
|
536
|
-
pattern: suggestion.pattern,
|
|
537
|
-
},
|
|
538
|
-
promptForApproval: async () => {
|
|
539
|
-
const decision = await deps.promptPermission(ctx, {
|
|
540
|
-
requestId: (event as { toolCallId: string }).toolCallId,
|
|
541
|
-
source: "tool_call",
|
|
542
|
-
agentName,
|
|
543
|
-
message: toolAskMessage,
|
|
544
|
-
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
545
|
-
toolName,
|
|
546
|
-
sessionLabel: suggestion.label,
|
|
547
|
-
...permissionLogContext,
|
|
548
|
-
});
|
|
549
|
-
toolDecisionAutoApproved = decision.autoApproved === true;
|
|
550
|
-
return decision;
|
|
551
|
-
},
|
|
552
|
-
writeLog: deps.runtime.writeReviewLog,
|
|
553
|
-
logContext: {
|
|
554
|
-
source: "tool_call",
|
|
555
|
-
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
556
|
-
toolName,
|
|
557
|
-
agentName,
|
|
558
|
-
message: toolAskMessage,
|
|
559
|
-
...permissionLogContext,
|
|
560
|
-
},
|
|
561
|
-
messages: {
|
|
562
|
-
denyReason: formatDenyReason(check, agentName ?? undefined),
|
|
563
|
-
unavailableReason: toolUnavailableReason,
|
|
564
|
-
userDeniedReason: (decision) =>
|
|
565
|
-
formatUserDeniedReason(check, decision.denialReason),
|
|
566
|
-
},
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
const toolGateHasSession =
|
|
570
|
-
toolGate.action === "allow" && toolGate.sessionApproval !== undefined;
|
|
571
|
-
emitDecisionEvent(deps.events, {
|
|
572
|
-
surface: toolName,
|
|
573
|
-
value: deriveDecisionValue(toolName, check),
|
|
574
|
-
result: toolGate.action === "allow" ? "allow" : "deny",
|
|
575
|
-
resolution: deriveResolution(
|
|
576
|
-
check.state,
|
|
577
|
-
toolGate.action,
|
|
578
|
-
toolGateHasSession,
|
|
579
|
-
toolCanConfirm,
|
|
580
|
-
toolDecisionAutoApproved,
|
|
581
|
-
),
|
|
582
|
-
origin: check.origin ?? null,
|
|
583
|
-
agentName: agentName ?? null,
|
|
584
|
-
matchedPattern: check.matchedPattern ?? null,
|
|
585
|
-
});
|
|
90
|
+
// ── External-directory gate (file tools) ─────────────────────────────────
|
|
91
|
+
const extDirResult = await evaluateExternalDirectoryGate(tcc, deps);
|
|
92
|
+
if (extDirResult?.action === "block") {
|
|
93
|
+
return { block: true, reason: extDirResult.reason };
|
|
94
|
+
}
|
|
586
95
|
|
|
587
|
-
|
|
588
|
-
|
|
96
|
+
// ── Bash external-directory gate ─────────────────────────────────────────
|
|
97
|
+
const bashExtResult = await evaluateBashExternalDirectoryGate(tcc, deps);
|
|
98
|
+
if (bashExtResult?.action === "block") {
|
|
99
|
+
return { block: true, reason: bashExtResult.reason };
|
|
589
100
|
}
|
|
590
101
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
);
|
|
102
|
+
// ── Normal tool permission gate ──────────────────────────────────────────
|
|
103
|
+
const toolResult = await evaluateToolGate(tcc, deps);
|
|
104
|
+
if (toolResult.action === "block") {
|
|
105
|
+
return { block: true, reason: toolResult.reason };
|
|
596
106
|
}
|
|
597
107
|
|
|
598
108
|
return {};
|