@gotgenes/pi-permission-system 5.2.1 → 5.3.1
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 +33 -0
- package/README.md +40 -702
- package/package.json +1 -1
- package/src/handlers/input.ts +31 -4
- package/src/handlers/lifecycle.ts +1 -0
- package/src/handlers/tool-call.ts +135 -9
- package/src/handlers/types.ts +5 -0
- package/src/index.ts +17 -0
- package/src/permission-dialog.ts +6 -0
- package/src/permission-event-rpc.ts +229 -0
- package/src/permission-events.ts +159 -0
- package/src/permission-prompter.ts +1 -1
- package/tests/handlers/before-agent-start.test.ts +2 -0
- package/tests/handlers/input-events.test.ts +226 -0
- package/tests/handlers/input.test.ts +2 -0
- package/tests/handlers/lifecycle.test.ts +8 -0
- package/tests/handlers/tool-call-events.test.ts +389 -0
- package/tests/handlers/tool-call.test.ts +2 -0
- package/tests/permission-event-rpc.test.ts +499 -0
- package/tests/permission-events.test.ts +299 -0
- package/tests/permission-prompter.test.ts +5 -1
- package/tests/permission-system.test.ts +1 -0
- package/tests/session-start.test.ts +2 -0
package/package.json
CHANGED
package/src/handlers/input.ts
CHANGED
|
@@ -8,6 +8,7 @@ interface InputPayload {
|
|
|
8
8
|
text: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
import { emitDecisionEvent } from "../permission-events";
|
|
11
12
|
import { applyPermissionGate } from "../permission-gate";
|
|
12
13
|
import { formatSkillAskPrompt } from "../permission-prompts";
|
|
13
14
|
import type { HandlerDeps } from "./types";
|
|
@@ -65,17 +66,22 @@ export async function handleInput(
|
|
|
65
66
|
skillName,
|
|
66
67
|
agentName ?? undefined,
|
|
67
68
|
);
|
|
69
|
+
const skillInputCanConfirm = deps.canRequestPermissionConfirmation(ctx);
|
|
70
|
+
let skillInputAutoApproved = false;
|
|
68
71
|
const skillInputGate = await applyPermissionGate({
|
|
69
72
|
state: check.state,
|
|
70
|
-
canConfirm:
|
|
71
|
-
promptForApproval: () =>
|
|
72
|
-
deps.promptPermission(ctx, {
|
|
73
|
+
canConfirm: skillInputCanConfirm,
|
|
74
|
+
promptForApproval: async () => {
|
|
75
|
+
const decision = await deps.promptPermission(ctx, {
|
|
73
76
|
requestId: deps.createPermissionRequestId("skill-input"),
|
|
74
77
|
source: "skill_input",
|
|
75
78
|
agentName,
|
|
76
79
|
message: skillInputMessage,
|
|
77
80
|
skillName,
|
|
78
|
-
})
|
|
81
|
+
});
|
|
82
|
+
skillInputAutoApproved = decision.autoApproved === true;
|
|
83
|
+
return decision;
|
|
84
|
+
},
|
|
79
85
|
writeLog: deps.runtime.writeReviewLog,
|
|
80
86
|
logContext: {
|
|
81
87
|
source: "skill_input",
|
|
@@ -91,6 +97,27 @@ export async function handleInput(
|
|
|
91
97
|
},
|
|
92
98
|
});
|
|
93
99
|
|
|
100
|
+
emitDecisionEvent(deps.events, {
|
|
101
|
+
surface: "skill",
|
|
102
|
+
value: skillName,
|
|
103
|
+
result: skillInputGate.action === "allow" ? "allow" : "deny",
|
|
104
|
+
resolution:
|
|
105
|
+
check.state === "allow"
|
|
106
|
+
? "policy_allow"
|
|
107
|
+
: check.state === "deny"
|
|
108
|
+
? "policy_deny"
|
|
109
|
+
: skillInputGate.action === "allow"
|
|
110
|
+
? skillInputAutoApproved
|
|
111
|
+
? "auto_approved"
|
|
112
|
+
: "user_approved"
|
|
113
|
+
: skillInputCanConfirm
|
|
114
|
+
? "user_denied"
|
|
115
|
+
: "confirmation_unavailable",
|
|
116
|
+
origin: check.origin ?? null,
|
|
117
|
+
agentName: agentName ?? null,
|
|
118
|
+
matchedPattern: check.matchedPattern ?? null,
|
|
119
|
+
});
|
|
120
|
+
|
|
94
121
|
if (skillInputGate.action === "block") {
|
|
95
122
|
return { action: "handled" };
|
|
96
123
|
}
|
|
@@ -21,6 +21,10 @@ import {
|
|
|
21
21
|
} from "../external-directory";
|
|
22
22
|
import { suggestSessionPattern } from "../pattern-suggest";
|
|
23
23
|
import type { PermissionPromptDecision } from "../permission-dialog";
|
|
24
|
+
import {
|
|
25
|
+
emitDecisionEvent,
|
|
26
|
+
type PermissionDecisionResolution,
|
|
27
|
+
} from "../permission-events";
|
|
24
28
|
import { applyPermissionGate } from "../permission-gate";
|
|
25
29
|
import {
|
|
26
30
|
formatAskPrompt,
|
|
@@ -38,8 +42,50 @@ import {
|
|
|
38
42
|
checkRequestedToolRegistration,
|
|
39
43
|
getToolNameFromValue,
|
|
40
44
|
} from "../tool-registry";
|
|
45
|
+
import type { PermissionCheckResult } from "../types";
|
|
41
46
|
import type { HandlerDeps } from "./types";
|
|
42
47
|
|
|
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
|
+
|
|
43
89
|
/**
|
|
44
90
|
* Extract the tool input from an event, checking both `input` and `arguments`
|
|
45
91
|
* fields (different Pi SDK versions use different names).
|
|
@@ -112,9 +158,10 @@ export async function handleToolCall(
|
|
|
112
158
|
readEvent.input.path,
|
|
113
159
|
agentName ?? undefined,
|
|
114
160
|
);
|
|
161
|
+
const skillReadCanConfirm = deps.canRequestPermissionConfirmation(ctx);
|
|
115
162
|
const skillReadGate = await applyPermissionGate({
|
|
116
163
|
state: matchedSkill.state,
|
|
117
|
-
canConfirm:
|
|
164
|
+
canConfirm: skillReadCanConfirm,
|
|
118
165
|
promptForApproval: () =>
|
|
119
166
|
deps.promptPermission(ctx, {
|
|
120
167
|
requestId: (readEvent as { toolCallId: string }).toolCallId,
|
|
@@ -149,6 +196,20 @@ export async function handleToolCall(
|
|
|
149
196
|
},
|
|
150
197
|
},
|
|
151
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
|
+
});
|
|
152
213
|
if (skillReadGate.action === "block") {
|
|
153
214
|
return { block: true, reason: skillReadGate.reason };
|
|
154
215
|
}
|
|
@@ -193,6 +254,15 @@ export async function handleToolCall(
|
|
|
193
254
|
path: externalDirectoryPath,
|
|
194
255
|
},
|
|
195
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
|
+
});
|
|
196
266
|
// Fall through to normal tool-permission check.
|
|
197
267
|
} else {
|
|
198
268
|
const extCheck = deps.runtime.permissionManager.checkPermission(
|
|
@@ -212,6 +282,15 @@ export async function handleToolCall(
|
|
|
212
282
|
resolution: "session_approved",
|
|
213
283
|
sessionApprovalPattern: extCheck.matchedPattern,
|
|
214
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
|
+
});
|
|
215
294
|
// Fall through to normal permission check
|
|
216
295
|
} else {
|
|
217
296
|
let extDirDecision: PermissionPromptDecision | null = null;
|
|
@@ -221,9 +300,10 @@ export async function handleToolCall(
|
|
|
221
300
|
ctx.cwd,
|
|
222
301
|
agentName ?? undefined,
|
|
223
302
|
);
|
|
224
|
-
const
|
|
303
|
+
const extDirCanConfirm = deps.canRequestPermissionConfirmation(ctx);
|
|
304
|
+
const extDirGateResult = await applyPermissionGate({
|
|
225
305
|
state: extCheck.state,
|
|
226
|
-
canConfirm:
|
|
306
|
+
canConfirm: extDirCanConfirm,
|
|
227
307
|
promptForApproval: async () => {
|
|
228
308
|
const decision = await deps.promptPermission(ctx, {
|
|
229
309
|
requestId: (event as { toolCallId: string }).toolCallId,
|
|
@@ -262,8 +342,22 @@ export async function handleToolCall(
|
|
|
262
342
|
),
|
|
263
343
|
},
|
|
264
344
|
});
|
|
265
|
-
|
|
266
|
-
|
|
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 };
|
|
267
361
|
}
|
|
268
362
|
|
|
269
363
|
if (extDirDecision?.state === "approved_for_session") {
|
|
@@ -397,6 +491,15 @@ export async function handleToolCall(
|
|
|
397
491
|
resolution: "session_approved",
|
|
398
492
|
sessionApprovalPattern: check.matchedPattern,
|
|
399
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
|
+
});
|
|
400
503
|
return {};
|
|
401
504
|
}
|
|
402
505
|
|
|
@@ -423,15 +526,17 @@ export async function handleToolCall(
|
|
|
423
526
|
: `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
|
|
424
527
|
|
|
425
528
|
const toolAskMessage = formatAskPrompt(check, agentName ?? undefined, input);
|
|
529
|
+
const toolCanConfirm = deps.canRequestPermissionConfirmation(ctx);
|
|
530
|
+
let toolDecisionAutoApproved = false;
|
|
426
531
|
const toolGate = await applyPermissionGate({
|
|
427
532
|
state: check.state,
|
|
428
|
-
canConfirm:
|
|
533
|
+
canConfirm: toolCanConfirm,
|
|
429
534
|
sessionApproval: {
|
|
430
535
|
surface: suggestion.surface,
|
|
431
536
|
pattern: suggestion.pattern,
|
|
432
537
|
},
|
|
433
|
-
promptForApproval: () =>
|
|
434
|
-
deps.promptPermission(ctx, {
|
|
538
|
+
promptForApproval: async () => {
|
|
539
|
+
const decision = await deps.promptPermission(ctx, {
|
|
435
540
|
requestId: (event as { toolCallId: string }).toolCallId,
|
|
436
541
|
source: "tool_call",
|
|
437
542
|
agentName,
|
|
@@ -440,7 +545,10 @@ export async function handleToolCall(
|
|
|
440
545
|
toolName,
|
|
441
546
|
sessionLabel: suggestion.label,
|
|
442
547
|
...permissionLogContext,
|
|
443
|
-
})
|
|
548
|
+
});
|
|
549
|
+
toolDecisionAutoApproved = decision.autoApproved === true;
|
|
550
|
+
return decision;
|
|
551
|
+
},
|
|
444
552
|
writeLog: deps.runtime.writeReviewLog,
|
|
445
553
|
logContext: {
|
|
446
554
|
source: "tool_call",
|
|
@@ -458,6 +566,24 @@ export async function handleToolCall(
|
|
|
458
566
|
},
|
|
459
567
|
});
|
|
460
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
|
+
});
|
|
586
|
+
|
|
461
587
|
if (toolGate.action === "block") {
|
|
462
588
|
return { block: true, reason: toolGate.reason };
|
|
463
589
|
}
|
package/src/handlers/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
import type { PermissionPromptDecision } from "../permission-dialog";
|
|
4
|
+
import type { PermissionEventBus } from "../permission-events";
|
|
4
5
|
import type { PermissionManager } from "../permission-manager";
|
|
5
6
|
import type { ExtensionRuntime } from "../runtime";
|
|
6
7
|
|
|
@@ -33,6 +34,8 @@ export interface HandlerDeps {
|
|
|
33
34
|
// ── Runtime context ────────────────────────────────────────────────────
|
|
34
35
|
/** All mutable extension state and log-writing methods. */
|
|
35
36
|
readonly runtime: ExtensionRuntime;
|
|
37
|
+
/** Event bus for emitting permissions:decision broadcast events. */
|
|
38
|
+
readonly events: PermissionEventBus;
|
|
36
39
|
|
|
37
40
|
// ── Factories ──────────────────────────────────────────────────────────
|
|
38
41
|
/** Create a new PermissionManager scoped to cwd's config hierarchy. */
|
|
@@ -67,6 +70,8 @@ export interface HandlerDeps {
|
|
|
67
70
|
// ── Forwarding ─────────────────────────────────────────────────────────
|
|
68
71
|
startForwardedPermissionPolling(ctx: ExtensionContext): void;
|
|
69
72
|
stopForwardedPermissionPolling(): void;
|
|
73
|
+
/** Unsubscribe the permissions:rpc:check and permissions:rpc:prompt handlers. */
|
|
74
|
+
stopPermissionRpcHandlers(): void;
|
|
70
75
|
|
|
71
76
|
// ── Pi API subset ──────────────────────────────────────────────────────
|
|
72
77
|
getAllTools(): unknown[];
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
handleToolCall,
|
|
13
13
|
} from "./handlers";
|
|
14
14
|
import { requestPermissionDecisionFromUi } from "./permission-dialog";
|
|
15
|
+
import { registerPermissionRpcHandlers } from "./permission-event-rpc";
|
|
16
|
+
import { emitReadyEvent } from "./permission-events";
|
|
15
17
|
import { PermissionPrompter } from "./permission-prompter";
|
|
16
18
|
import {
|
|
17
19
|
createExtensionRuntime,
|
|
@@ -67,8 +69,17 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
67
69
|
const createPermissionRequestId = (prefix: string): string =>
|
|
68
70
|
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
|
|
69
71
|
|
|
72
|
+
const rpcHandles = registerPermissionRpcHandlers(pi.events, {
|
|
73
|
+
getPermissionManager: () => runtime.permissionManager,
|
|
74
|
+
getSessionRules: () => runtime.sessionRules.getRuleset(),
|
|
75
|
+
getRuntimeContext: () => runtime.runtimeContext,
|
|
76
|
+
requestPermissionDecisionFromUi,
|
|
77
|
+
writeReviewLog: runtime.writeReviewLog.bind(runtime),
|
|
78
|
+
});
|
|
79
|
+
|
|
70
80
|
const deps: HandlerDeps = {
|
|
71
81
|
runtime,
|
|
82
|
+
events: pi.events,
|
|
72
83
|
createPermissionManagerForCwd: (cwd) =>
|
|
73
84
|
createPermissionManagerForCwd(runtime.agentDir, cwd),
|
|
74
85
|
refreshExtensionConfig: (ctx) => refreshExtensionConfig(runtime, ctx),
|
|
@@ -92,10 +103,16 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
92
103
|
startForwardedPermissionPolling(runtime, forwardingDeps, ctx),
|
|
93
104
|
stopForwardedPermissionPolling: () =>
|
|
94
105
|
stopForwardedPermissionPolling(runtime),
|
|
106
|
+
stopPermissionRpcHandlers: () => {
|
|
107
|
+
rpcHandles.unsubCheck();
|
|
108
|
+
rpcHandles.unsubPrompt();
|
|
109
|
+
},
|
|
95
110
|
getAllTools: () => pi.getAllTools(),
|
|
96
111
|
setActiveTools: (names) => pi.setActiveTools(names),
|
|
97
112
|
};
|
|
98
113
|
|
|
114
|
+
emitReadyEvent(pi.events);
|
|
115
|
+
|
|
99
116
|
pi.on("session_start", (event, ctx) => handleSessionStart(deps, event, ctx));
|
|
100
117
|
pi.on("resources_discover", (event) => handleResourcesDiscover(deps, event));
|
|
101
118
|
pi.on("session_shutdown", () => handleSessionShutdown(deps));
|
package/src/permission-dialog.ts
CHANGED
|
@@ -8,6 +8,12 @@ export type PermissionPromptDecision = {
|
|
|
8
8
|
approved: boolean;
|
|
9
9
|
state: PermissionDecisionState;
|
|
10
10
|
denialReason?: string;
|
|
11
|
+
/**
|
|
12
|
+
* True when the decision was made automatically by yolo mode rather than
|
|
13
|
+
* by an interactive user prompt. Used by handlers to emit "auto_approved"
|
|
14
|
+
* rather than "user_approved" in the permissions:decision broadcast.
|
|
15
|
+
*/
|
|
16
|
+
autoApproved?: true;
|
|
11
17
|
};
|
|
12
18
|
|
|
13
19
|
export interface PermissionDecisionUi {
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission event bus RPC handlers.
|
|
3
|
+
*
|
|
4
|
+
* Registers `permissions:rpc:check` and `permissions:rpc:prompt` handlers on
|
|
5
|
+
* the Pi event bus so other extensions can query our policy and forward
|
|
6
|
+
* permission prompts without importing this package.
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import type {
|
|
10
|
+
PermissionPromptDecision,
|
|
11
|
+
RequestPermissionOptions,
|
|
12
|
+
} from "./permission-dialog";
|
|
13
|
+
import type {
|
|
14
|
+
PermissionEventBus,
|
|
15
|
+
PermissionsCheckReplyData,
|
|
16
|
+
PermissionsCheckRequest,
|
|
17
|
+
PermissionsPromptReplyData,
|
|
18
|
+
PermissionsPromptRequest,
|
|
19
|
+
PermissionsRpcReply,
|
|
20
|
+
} from "./permission-events";
|
|
21
|
+
import {
|
|
22
|
+
PERMISSIONS_PROTOCOL_VERSION,
|
|
23
|
+
PERMISSIONS_RPC_CHECK_CHANNEL,
|
|
24
|
+
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
25
|
+
} from "./permission-events";
|
|
26
|
+
import type { PermissionManager } from "./permission-manager";
|
|
27
|
+
import type { Rule } from "./rule";
|
|
28
|
+
|
|
29
|
+
/** Dependencies injected into the RPC handler registry. */
|
|
30
|
+
export interface PermissionRpcDeps {
|
|
31
|
+
/** Returns the current PermissionManager (refreshed on session start). */
|
|
32
|
+
getPermissionManager(): Pick<PermissionManager, "checkPermission">;
|
|
33
|
+
/** Returns the current session rules (highest-priority approvals). */
|
|
34
|
+
getSessionRules(): Rule[];
|
|
35
|
+
/**
|
|
36
|
+
* Returns the current ExtensionContext, or null if no session is active.
|
|
37
|
+
* Used by the prompt handler to check hasUI and access the UI dialog.
|
|
38
|
+
*/
|
|
39
|
+
getRuntimeContext(): ExtensionContext | null;
|
|
40
|
+
/** Show the interactive permission dialog in the parent session UI. */
|
|
41
|
+
requestPermissionDecisionFromUi(
|
|
42
|
+
ui: ExtensionContext["ui"],
|
|
43
|
+
title: string,
|
|
44
|
+
message: string,
|
|
45
|
+
options?: RequestPermissionOptions,
|
|
46
|
+
): Promise<PermissionPromptDecision>;
|
|
47
|
+
/** Write structured entries to the permission review log. */
|
|
48
|
+
writeReviewLog(event: string, details: Record<string, unknown>): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Unsubscribe handles returned from registerPermissionRpcHandlers. */
|
|
52
|
+
export interface PermissionRpcHandles {
|
|
53
|
+
/** Stop the permissions:rpc:check handler. */
|
|
54
|
+
unsubCheck: () => void;
|
|
55
|
+
/** Stop the permissions:rpc:prompt handler. */
|
|
56
|
+
unsubPrompt: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Internal helpers ───────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/** Build a success reply envelope. */
|
|
62
|
+
function successReply<T>(data?: T): PermissionsRpcReply<T> {
|
|
63
|
+
if (data !== undefined) {
|
|
64
|
+
return {
|
|
65
|
+
success: true,
|
|
66
|
+
protocolVersion: PERMISSIONS_PROTOCOL_VERSION,
|
|
67
|
+
data,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { success: true, protocolVersion: PERMISSIONS_PROTOCOL_VERSION };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Build an error reply envelope. */
|
|
74
|
+
function errorReply(error: string): PermissionsRpcReply {
|
|
75
|
+
return {
|
|
76
|
+
success: false,
|
|
77
|
+
protocolVersion: PERMISSIONS_PROTOCOL_VERSION,
|
|
78
|
+
error,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Construct a surface-appropriate input object from a raw value string.
|
|
84
|
+
*
|
|
85
|
+
* Note: MCP inputs are complex (server name + tool name derivation). Callers
|
|
86
|
+
* providing an MCP surface receive a best-effort policy evaluation using the
|
|
87
|
+
* value as a pre-qualified target string. Pass the fully-qualified target
|
|
88
|
+
* (e.g. "exa:search" or "exa") directly.
|
|
89
|
+
*/
|
|
90
|
+
function buildInputForSurface(
|
|
91
|
+
surface: string,
|
|
92
|
+
value: string | undefined,
|
|
93
|
+
): unknown {
|
|
94
|
+
const v = value ?? "";
|
|
95
|
+
if (surface === "bash") return { command: v };
|
|
96
|
+
if (surface === "skill") return { name: v };
|
|
97
|
+
if (surface === "external_directory") return { path: v };
|
|
98
|
+
// MCP and tool surfaces: normalizeInput handles them from the surface alone.
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── RPC handler: permissions:rpc:check ────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function handleCheckRpc(
|
|
105
|
+
raw: unknown,
|
|
106
|
+
events: PermissionEventBus,
|
|
107
|
+
deps: PermissionRpcDeps,
|
|
108
|
+
): void {
|
|
109
|
+
const req = raw as Partial<PermissionsCheckRequest>;
|
|
110
|
+
const { requestId, surface, value, agentName } = req;
|
|
111
|
+
|
|
112
|
+
if (typeof requestId !== "string" || !requestId) {
|
|
113
|
+
// Cannot reply without a requestId — silently discard.
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const replyChannel = `${PERMISSIONS_RPC_CHECK_CHANNEL}:reply:${requestId}`;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
if (typeof surface !== "string" || !surface) {
|
|
121
|
+
events.emit(replyChannel, errorReply("surface is required"));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const input = buildInputForSurface(surface, value);
|
|
126
|
+
const sessionRules = deps.getSessionRules();
|
|
127
|
+
const result = deps
|
|
128
|
+
.getPermissionManager()
|
|
129
|
+
.checkPermission(surface, input, agentName ?? undefined, sessionRules);
|
|
130
|
+
|
|
131
|
+
const data: PermissionsCheckReplyData = {
|
|
132
|
+
result: result.state,
|
|
133
|
+
matchedPattern: result.matchedPattern ?? null,
|
|
134
|
+
origin: result.origin ?? null,
|
|
135
|
+
};
|
|
136
|
+
events.emit(replyChannel, successReply(data));
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
139
|
+
events.emit(replyChannel, errorReply(message));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── RPC handler: permissions:rpc:prompt ───────────────────────────────────
|
|
144
|
+
|
|
145
|
+
async function handlePromptRpc(
|
|
146
|
+
raw: unknown,
|
|
147
|
+
events: PermissionEventBus,
|
|
148
|
+
deps: PermissionRpcDeps,
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const req = raw as Partial<PermissionsPromptRequest>;
|
|
151
|
+
const { requestId, surface, value, agentName, message, sessionLabel } = req;
|
|
152
|
+
|
|
153
|
+
if (typeof requestId !== "string" || !requestId) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const replyChannel = `${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:${requestId}`;
|
|
158
|
+
|
|
159
|
+
const ctx = deps.getRuntimeContext();
|
|
160
|
+
if (!ctx?.hasUI) {
|
|
161
|
+
events.emit(replyChannel, errorReply("no_ui"));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (typeof message !== "string" || !message) {
|
|
166
|
+
events.emit(replyChannel, errorReply("message is required"));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const title = surface
|
|
172
|
+
? `Permission request${agentName ? ` from ${agentName}` : ""}`
|
|
173
|
+
: "Permission request";
|
|
174
|
+
|
|
175
|
+
const decision = await deps.requestPermissionDecisionFromUi(
|
|
176
|
+
ctx.ui,
|
|
177
|
+
title,
|
|
178
|
+
message,
|
|
179
|
+
sessionLabel ? { sessionLabel } : undefined,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
deps.writeReviewLog("permission_request.rpc_prompt", {
|
|
183
|
+
requestId,
|
|
184
|
+
surface: surface ?? null,
|
|
185
|
+
value: value ?? null,
|
|
186
|
+
agentName: agentName ?? null,
|
|
187
|
+
message,
|
|
188
|
+
approved: decision.approved,
|
|
189
|
+
resolution: decision.state,
|
|
190
|
+
denialReason: decision.denialReason ?? null,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const data: PermissionsPromptReplyData = {
|
|
194
|
+
approved: decision.approved,
|
|
195
|
+
state: decision.state,
|
|
196
|
+
...(decision.denialReason !== undefined
|
|
197
|
+
? { denialReason: decision.denialReason }
|
|
198
|
+
: {}),
|
|
199
|
+
};
|
|
200
|
+
events.emit(replyChannel, successReply(data));
|
|
201
|
+
} catch (err) {
|
|
202
|
+
const message_ = err instanceof Error ? err.message : String(err);
|
|
203
|
+
events.emit(replyChannel, errorReply(message_));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Register `permissions:rpc:check` and `permissions:rpc:prompt` handlers on
|
|
211
|
+
* the event bus.
|
|
212
|
+
*
|
|
213
|
+
* Returns unsubscribe handles — call them in session_shutdown to stop the
|
|
214
|
+
* handlers and prevent memory leaks.
|
|
215
|
+
*/
|
|
216
|
+
export function registerPermissionRpcHandlers(
|
|
217
|
+
events: PermissionEventBus,
|
|
218
|
+
deps: PermissionRpcDeps,
|
|
219
|
+
): PermissionRpcHandles {
|
|
220
|
+
const unsubCheck = events.on(PERMISSIONS_RPC_CHECK_CHANNEL, (raw) => {
|
|
221
|
+
handleCheckRpc(raw, events, deps);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const unsubPrompt = events.on(PERMISSIONS_RPC_PROMPT_CHANNEL, (raw) => {
|
|
225
|
+
void handlePromptRpc(raw, events, deps);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return { unsubCheck, unsubPrompt };
|
|
229
|
+
}
|