@gotgenes/pi-permission-system 9.2.0 → 10.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/README.md +12 -11
- package/package.json +1 -1
- package/src/agent-prep-session.ts +28 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +11 -0
- package/src/forwarded-permissions/io.ts +29 -0
- package/src/forwarded-permissions/permission-forwarder.ts +549 -0
- package/src/forwarding-manager.ts +3 -7
- package/src/gate-handler-session.ts +13 -0
- package/src/gate-prompter.ts +14 -0
- package/src/handlers/before-agent-start.ts +2 -3
- package/src/handlers/gates/bash-command.ts +4 -18
- package/src/handlers/gates/bash-external-directory.ts +3 -15
- package/src/handlers/gates/bash-path.ts +3 -16
- package/src/handlers/gates/descriptor.ts +0 -28
- package/src/handlers/gates/path.ts +3 -15
- package/src/handlers/gates/runner.ts +142 -105
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +44 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
- package/src/handlers/lifecycle.ts +9 -9
- package/src/handlers/permission-gate-handler.ts +34 -238
- package/src/index.ts +50 -68
- package/src/mcp-targets.ts +56 -46
- package/src/permission-event-rpc.ts +7 -0
- package/src/permission-events.ts +89 -8
- package/src/permission-forwarding.ts +23 -0
- package/src/permission-prompter.ts +27 -56
- package/src/permission-resolver.ts +17 -0
- package/src/permission-session.ts +77 -9
- package/src/permission-ui-prompt.ts +127 -0
- package/src/permissions-service.ts +53 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/service.ts +17 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-lifecycle-session.ts +24 -0
- package/src/tool-input-preview.ts +0 -62
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +6 -4
- package/test/composition-root.test.ts +5 -0
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +62 -0
- package/test/forwarding-manager.test.ts +26 -44
- package/test/handlers/before-agent-start.test.ts +45 -21
- package/test/handlers/external-directory-integration.test.ts +86 -22
- package/test/handlers/external-directory-session-dedup.test.ts +102 -55
- package/test/handlers/gates/bash-command.test.ts +49 -90
- package/test/handlers/gates/bash-external-directory.test.ts +54 -95
- package/test/handlers/gates/bash-path.test.ts +63 -148
- package/test/handlers/gates/path.test.ts +38 -105
- package/test/handlers/gates/runner.test.ts +150 -93
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +128 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
- package/test/handlers/input.test.ts +1 -2
- package/test/handlers/lifecycle.test.ts +49 -33
- package/test/handlers/tool-call-events.test.ts +1 -1
- package/test/helpers/gate-fixtures.ts +147 -16
- package/test/helpers/handler-fixtures.ts +143 -27
- package/test/mcp-targets.test.ts +55 -0
- package/test/permission-event-rpc.test.ts +39 -0
- package/test/permission-events.test.ts +78 -10
- package/test/permission-forwarder.test.ts +295 -0
- package/test/permission-prompter.test.ts +147 -38
- package/test/permission-session.test.ts +160 -27
- package/test/permission-ui-prompt.test.ts +146 -0
- package/test/permissions-service.test.ts +151 -0
- package/test/runtime.test.ts +0 -4
- package/test/service-lifecycle.test.ts +162 -0
- package/test/tool-input-preview.test.ts +0 -111
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/src/forwarded-permissions/polling.ts +0 -379
|
@@ -3,15 +3,35 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { vi } from "vitest";
|
|
5
5
|
|
|
6
|
-
import type {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from "#src/handlers/gates/
|
|
6
|
+
import type { DecisionReporter } from "#src/decision-reporter";
|
|
7
|
+
import type { GatePrompter } from "#src/gate-prompter";
|
|
8
|
+
import type { GateDescriptor } from "#src/handlers/gates/descriptor";
|
|
9
|
+
import { GateRunner } from "#src/handlers/gates/runner";
|
|
10
|
+
import type { SkillInputGateInputs } from "#src/handlers/gates/skill-input-gate-pipeline";
|
|
11
|
+
import type { ToolCallGateInputs } from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
10
12
|
import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
13
|
+
import type { PermissionResolver } from "#src/permission-resolver";
|
|
14
|
+
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
15
|
+
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
16
|
+
import type { ToolPreviewFormatterOptions } from "#src/tool-preview-formatter";
|
|
11
17
|
import type { PermissionCheckResult } from "#src/types";
|
|
12
18
|
|
|
13
19
|
import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
|
14
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Permission resolver mock with an optional default check result.
|
|
23
|
+
*
|
|
24
|
+
* Returns a plain object whose `resolve` is a `vi.fn` so callers retain full
|
|
25
|
+
* mock access (`mockReturnValue`, `mockImplementation`, `mock.calls`).
|
|
26
|
+
*/
|
|
27
|
+
export function makeResolver(defaultCheck?: PermissionCheckResult) {
|
|
28
|
+
const resolve = vi.fn<PermissionResolver["resolve"]>();
|
|
29
|
+
if (defaultCheck) {
|
|
30
|
+
resolve.mockReturnValue(defaultCheck);
|
|
31
|
+
}
|
|
32
|
+
return { resolve };
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
/**
|
|
16
36
|
* Gate descriptor factory with runner-test defaults.
|
|
17
37
|
*
|
|
@@ -48,25 +68,70 @@ export function makeDescriptor(
|
|
|
48
68
|
};
|
|
49
69
|
}
|
|
50
70
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Reporter mock with independently inspectable vi.fn() stubs.
|
|
73
|
+
*/
|
|
74
|
+
export function makeReporter(
|
|
75
|
+
overrides: Partial<DecisionReporter> = {},
|
|
76
|
+
): DecisionReporter {
|
|
54
77
|
return {
|
|
55
|
-
checkPermission: vi
|
|
56
|
-
.fn()
|
|
57
|
-
.mockReturnValue(makeCheckResult({ matchedPattern: "*" })),
|
|
58
|
-
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
59
|
-
recordSessionApproval: vi.fn(),
|
|
60
78
|
writeReviewLog: vi.fn(),
|
|
61
79
|
emitDecision: vi.fn(),
|
|
62
|
-
canConfirm: vi.fn().mockReturnValue(true),
|
|
63
|
-
promptPermission: vi
|
|
64
|
-
.fn()
|
|
65
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
66
80
|
...overrides,
|
|
67
81
|
};
|
|
68
82
|
}
|
|
69
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Gate runner factory for `GateRunner` unit tests.
|
|
86
|
+
*
|
|
87
|
+
* Builds one `GateRunner` from four role mocks and returns `{ runner, deps }`
|
|
88
|
+
* so tests can both invoke `runner.run(...)` and assert on the individual
|
|
89
|
+
* mock call records (`deps.reporter.*`, `deps.resolve`, etc.).
|
|
90
|
+
*/
|
|
91
|
+
export function makeGateRunner(
|
|
92
|
+
overrides: {
|
|
93
|
+
resolve?: PermissionResolver["resolve"];
|
|
94
|
+
recordSessionApproval?: SessionApprovalRecorder["recordSessionApproval"];
|
|
95
|
+
canConfirm?: GatePrompter["canConfirm"];
|
|
96
|
+
promptPermission?: GatePrompter["promptPermission"];
|
|
97
|
+
reporter?: Partial<DecisionReporter>;
|
|
98
|
+
} = {},
|
|
99
|
+
) {
|
|
100
|
+
const reporter = makeReporter(overrides.reporter);
|
|
101
|
+
const resolve =
|
|
102
|
+
overrides.resolve ??
|
|
103
|
+
vi
|
|
104
|
+
.fn<PermissionResolver["resolve"]>()
|
|
105
|
+
.mockReturnValue(makeCheckResult({ matchedPattern: "*" }));
|
|
106
|
+
const recordSessionApproval =
|
|
107
|
+
overrides.recordSessionApproval ??
|
|
108
|
+
(vi.fn() as SessionApprovalRecorder["recordSessionApproval"]);
|
|
109
|
+
const canConfirm =
|
|
110
|
+
overrides.canConfirm ??
|
|
111
|
+
(vi.fn().mockReturnValue(true) as GatePrompter["canConfirm"]);
|
|
112
|
+
const promptPermission =
|
|
113
|
+
overrides.promptPermission ??
|
|
114
|
+
vi
|
|
115
|
+
.fn<GatePrompter["promptPermission"]>()
|
|
116
|
+
.mockResolvedValue({ approved: true, state: "approved" });
|
|
117
|
+
const runner = new GateRunner(
|
|
118
|
+
{ resolve },
|
|
119
|
+
{ recordSessionApproval },
|
|
120
|
+
{ canConfirm, promptPermission },
|
|
121
|
+
reporter,
|
|
122
|
+
);
|
|
123
|
+
return {
|
|
124
|
+
runner,
|
|
125
|
+
deps: {
|
|
126
|
+
resolve,
|
|
127
|
+
recordSessionApproval,
|
|
128
|
+
canConfirm,
|
|
129
|
+
promptPermission,
|
|
130
|
+
reporter,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
70
135
|
/**
|
|
71
136
|
* Tool-call context factory with bash defaults.
|
|
72
137
|
*
|
|
@@ -103,3 +168,69 @@ export function makeGateCheckResult(
|
|
|
103
168
|
...overrides,
|
|
104
169
|
};
|
|
105
170
|
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Mock of `ToolCallGateInputs` for `ToolCallGatePipeline` unit tests.
|
|
174
|
+
*
|
|
175
|
+
* Each method is a `vi.fn()` stub so callers retain full mock access
|
|
176
|
+
* (`mock.calls`, `mockReturnValue`, etc.) on the returned object.
|
|
177
|
+
* Pass `overrides` to replace individual stubs without rebuilding the whole
|
|
178
|
+
* mock from scratch.
|
|
179
|
+
*/
|
|
180
|
+
export function makeGateInputs(
|
|
181
|
+
overrides: {
|
|
182
|
+
resolve?: PermissionResolver["resolve"];
|
|
183
|
+
getActiveSkillEntries?: () => SkillPromptEntry[];
|
|
184
|
+
getInfrastructureReadDirs?: () => string[];
|
|
185
|
+
getToolPreviewLimits?: () => ToolPreviewFormatterOptions;
|
|
186
|
+
} = {},
|
|
187
|
+
): ToolCallGateInputs {
|
|
188
|
+
return {
|
|
189
|
+
resolve:
|
|
190
|
+
overrides.resolve ??
|
|
191
|
+
vi.fn<PermissionResolver["resolve"]>().mockReturnValue(makeCheckResult()),
|
|
192
|
+
getActiveSkillEntries:
|
|
193
|
+
overrides.getActiveSkillEntries ??
|
|
194
|
+
vi.fn<() => SkillPromptEntry[]>(() => []),
|
|
195
|
+
getInfrastructureReadDirs:
|
|
196
|
+
overrides.getInfrastructureReadDirs ?? vi.fn<() => string[]>(() => []),
|
|
197
|
+
getToolPreviewLimits:
|
|
198
|
+
overrides.getToolPreviewLimits ??
|
|
199
|
+
vi.fn<() => ToolPreviewFormatterOptions>(() => ({
|
|
200
|
+
toolInputPreviewMaxLength: 500,
|
|
201
|
+
toolTextSummaryMaxLength: 100,
|
|
202
|
+
toolInputLogPreviewMaxLength: 200,
|
|
203
|
+
})),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Mock of `SkillInputGateInputs` for `SkillInputGatePipeline` unit tests.
|
|
209
|
+
*
|
|
210
|
+
* Returns a plain object with a `checkPermission` `vi.fn()` stub so callers
|
|
211
|
+
* retain full mock access (`mockReturnValue`, `mock.calls`, etc.).
|
|
212
|
+
*/
|
|
213
|
+
export function makeSkillInputInputs(
|
|
214
|
+
overrides: { checkPermission?: SkillInputGateInputs["checkPermission"] } = {},
|
|
215
|
+
): SkillInputGateInputs {
|
|
216
|
+
return {
|
|
217
|
+
checkPermission:
|
|
218
|
+
overrides.checkPermission ??
|
|
219
|
+
vi
|
|
220
|
+
.fn<SkillInputGateInputs["checkPermission"]>()
|
|
221
|
+
.mockReturnValue(makeCheckResult()),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Mock `GateNotifier` for `SkillInputGatePipeline` unit tests.
|
|
227
|
+
*
|
|
228
|
+
* Return type is intentionally unannotated so callers retain full `vi.fn()`
|
|
229
|
+
* mock access (`mock.calls`, `toHaveBeenCalledWith`, etc.) — annotating with
|
|
230
|
+
* `GateNotifier` would erase `Mock<...>` methods from the inferred type.
|
|
231
|
+
*/
|
|
232
|
+
export function makeNotifier() {
|
|
233
|
+
return {
|
|
234
|
+
warn: vi.fn<(message: string) => void>(),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -7,14 +7,67 @@
|
|
|
7
7
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { vi } from "vitest";
|
|
9
9
|
|
|
10
|
+
import { GateDecisionReporter } from "#src/decision-reporter";
|
|
10
11
|
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
12
|
+
import type { GateHandlerSession } from "#src/gate-handler-session";
|
|
13
|
+
import type { GatePrompter } from "#src/gate-prompter";
|
|
14
|
+
import { GateRunner } from "#src/handlers/gates/runner";
|
|
15
|
+
import {
|
|
16
|
+
type SkillInputGateInputs,
|
|
17
|
+
SkillInputGatePipeline,
|
|
18
|
+
} from "#src/handlers/gates/skill-input-gate-pipeline";
|
|
19
|
+
import {
|
|
20
|
+
type ToolCallGateInputs,
|
|
21
|
+
ToolCallGatePipeline,
|
|
22
|
+
} from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
11
23
|
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
24
|
+
import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
12
25
|
import type { PermissionDecisionEvent } from "#src/permission-events";
|
|
13
26
|
import { PERMISSIONS_DECISION_CHANNEL } from "#src/permission-events";
|
|
14
|
-
import type {
|
|
27
|
+
import type { PromptPermissionDetails } from "#src/permission-prompter";
|
|
28
|
+
import type { Rule } from "#src/rule";
|
|
29
|
+
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
30
|
+
import type { SessionLogger } from "#src/session-logger";
|
|
31
|
+
import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
|
|
15
32
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
16
33
|
import type { PermissionCheckResult } from "#src/types";
|
|
17
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Precise mock boundary for PermissionGateHandler integration tests.
|
|
37
|
+
*
|
|
38
|
+
* Intersection of every role the handler and its collaborators require,
|
|
39
|
+
* plus the context-bound prompting helpers that GatePrompter delegates to.
|
|
40
|
+
* Without a cast, TypeScript enforces this at the call sites where the
|
|
41
|
+
* mock is passed to GateRunner / ToolCallGatePipeline / PermissionGateHandler.
|
|
42
|
+
*
|
|
43
|
+
* The 4-arg `checkPermission` overrides the 3-arg version from
|
|
44
|
+
* GateHandlerSession so the `resolve` delegation can forward session rules.
|
|
45
|
+
*/
|
|
46
|
+
export type MockGateHandlerSession = ToolCallGateInputs &
|
|
47
|
+
SkillInputGateInputs &
|
|
48
|
+
SessionApprovalRecorder &
|
|
49
|
+
GatePrompter &
|
|
50
|
+
GateHandlerSession & {
|
|
51
|
+
/** Logger source for the reporter the fixture builds. */
|
|
52
|
+
logger: SessionLogger;
|
|
53
|
+
/** Session-rule accessor — used by the resolve delegation. */
|
|
54
|
+
getSessionRuleset(): Rule[];
|
|
55
|
+
/** 4-arg form so the resolve delegation can pass rules. */
|
|
56
|
+
checkPermission(
|
|
57
|
+
surface: string,
|
|
58
|
+
input: unknown,
|
|
59
|
+
agentName?: string,
|
|
60
|
+
rules?: Rule[],
|
|
61
|
+
): PermissionCheckResult;
|
|
62
|
+
/** Context-bound canPrompt — overriding this steers canConfirm. */
|
|
63
|
+
canPrompt(ctx: ExtensionContext): boolean;
|
|
64
|
+
/** Context-bound prompt — overriding this steers promptPermission. */
|
|
65
|
+
prompt(
|
|
66
|
+
ctx: ExtensionContext,
|
|
67
|
+
details: PromptPermissionDetails,
|
|
68
|
+
): Promise<PermissionPromptDecision>;
|
|
69
|
+
};
|
|
70
|
+
|
|
18
71
|
export function makeEvents() {
|
|
19
72
|
return {
|
|
20
73
|
emit: vi.fn(),
|
|
@@ -75,33 +128,86 @@ export function makeCheckResult(
|
|
|
75
128
|
}
|
|
76
129
|
|
|
77
130
|
/**
|
|
78
|
-
* Full-
|
|
131
|
+
* Full-intersection session stub.
|
|
79
132
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
133
|
+
* Uses per-field `??` selection (no spread) so TypeScript verifies every
|
|
134
|
+
* field against `MockGateHandlerSession` individually — a missing field fails
|
|
135
|
+
* `pnpm run check` instead of failing silently at runtime.
|
|
136
|
+
*
|
|
137
|
+
* The `resolve`, `canConfirm`, and `promptPermission` delegations are inlined
|
|
138
|
+
* as closures that read `session` at call time, so overriding `checkPermission`,
|
|
139
|
+
* `canPrompt`, or `prompt` automatically steers them without extra guards.
|
|
82
140
|
*/
|
|
83
141
|
export function makeSession(
|
|
84
|
-
overrides: Partial<
|
|
85
|
-
):
|
|
86
|
-
|
|
87
|
-
logger:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
142
|
+
overrides: Partial<MockGateHandlerSession> = {},
|
|
143
|
+
): MockGateHandlerSession {
|
|
144
|
+
const session: MockGateHandlerSession = {
|
|
145
|
+
logger: overrides.logger ?? {
|
|
146
|
+
debug: vi.fn(),
|
|
147
|
+
review: vi.fn(),
|
|
148
|
+
warn: vi.fn(),
|
|
149
|
+
},
|
|
150
|
+
activate: overrides.activate ?? vi.fn<MockGateHandlerSession["activate"]>(),
|
|
151
|
+
resolveAgentName:
|
|
152
|
+
overrides.resolveAgentName ??
|
|
153
|
+
vi.fn<MockGateHandlerSession["resolveAgentName"]>().mockReturnValue(null),
|
|
154
|
+
checkPermission:
|
|
155
|
+
overrides.checkPermission ??
|
|
156
|
+
vi
|
|
157
|
+
.fn<MockGateHandlerSession["checkPermission"]>()
|
|
158
|
+
.mockReturnValue(makeCheckResult()),
|
|
159
|
+
getSessionRuleset:
|
|
160
|
+
overrides.getSessionRuleset ??
|
|
161
|
+
vi.fn<MockGateHandlerSession["getSessionRuleset"]>().mockReturnValue([]),
|
|
162
|
+
recordSessionApproval:
|
|
163
|
+
overrides.recordSessionApproval ??
|
|
164
|
+
vi.fn<MockGateHandlerSession["recordSessionApproval"]>(),
|
|
165
|
+
getActiveSkillEntries:
|
|
166
|
+
overrides.getActiveSkillEntries ??
|
|
167
|
+
vi
|
|
168
|
+
.fn<MockGateHandlerSession["getActiveSkillEntries"]>()
|
|
169
|
+
.mockReturnValue([]),
|
|
170
|
+
getInfrastructureReadDirs:
|
|
171
|
+
overrides.getInfrastructureReadDirs ??
|
|
172
|
+
vi
|
|
173
|
+
.fn<MockGateHandlerSession["getInfrastructureReadDirs"]>()
|
|
174
|
+
.mockReturnValue(["/test/agent", "/test/agent/git"]),
|
|
175
|
+
getToolPreviewLimits:
|
|
176
|
+
overrides.getToolPreviewLimits ??
|
|
177
|
+
vi
|
|
178
|
+
.fn<MockGateHandlerSession["getToolPreviewLimits"]>()
|
|
179
|
+
.mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
|
|
180
|
+
canPrompt:
|
|
181
|
+
overrides.canPrompt ??
|
|
182
|
+
vi.fn<MockGateHandlerSession["canPrompt"]>().mockReturnValue(true),
|
|
183
|
+
prompt:
|
|
184
|
+
overrides.prompt ??
|
|
185
|
+
vi
|
|
186
|
+
.fn<MockGateHandlerSession["prompt"]>()
|
|
187
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
188
|
+
// Delegations — closures read `session` at call time so overrides win.
|
|
189
|
+
resolve:
|
|
190
|
+
overrides.resolve ??
|
|
191
|
+
vi.fn<MockGateHandlerSession["resolve"]>((surface, input, agentName) =>
|
|
192
|
+
session.checkPermission(
|
|
193
|
+
surface,
|
|
194
|
+
input,
|
|
195
|
+
agentName,
|
|
196
|
+
session.getSessionRuleset(),
|
|
197
|
+
),
|
|
198
|
+
),
|
|
199
|
+
canConfirm:
|
|
200
|
+
overrides.canConfirm ??
|
|
201
|
+
vi.fn<MockGateHandlerSession["canConfirm"]>(() =>
|
|
202
|
+
session.canPrompt(undefined as unknown as ExtensionContext),
|
|
203
|
+
),
|
|
204
|
+
promptPermission:
|
|
205
|
+
overrides.promptPermission ??
|
|
206
|
+
vi.fn<MockGateHandlerSession["promptPermission"]>((details) =>
|
|
207
|
+
session.prompt(undefined as unknown as ExtensionContext, details),
|
|
208
|
+
),
|
|
209
|
+
};
|
|
210
|
+
return session;
|
|
105
211
|
}
|
|
106
212
|
|
|
107
213
|
export function makeToolRegistry(
|
|
@@ -121,13 +227,23 @@ export function makeToolRegistry(
|
|
|
121
227
|
* it needs — handler, events, session, and toolRegistry are all available.
|
|
122
228
|
*/
|
|
123
229
|
export function makeHandler(overrides?: {
|
|
124
|
-
session?: Partial<
|
|
230
|
+
session?: Partial<MockGateHandlerSession>;
|
|
125
231
|
toolRegistry?: Partial<ToolRegistry>;
|
|
126
232
|
}) {
|
|
127
233
|
const session = makeSession(overrides?.session);
|
|
128
234
|
const events = makeEvents();
|
|
129
235
|
const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
|
|
130
|
-
const
|
|
236
|
+
const pipeline = new ToolCallGatePipeline(session);
|
|
237
|
+
const skillInputPipeline = new SkillInputGatePipeline(session);
|
|
238
|
+
const reporter = new GateDecisionReporter(session.logger, events);
|
|
239
|
+
const runner = new GateRunner(session, session, session, reporter);
|
|
240
|
+
const handler = new PermissionGateHandler(
|
|
241
|
+
session,
|
|
242
|
+
toolRegistry,
|
|
243
|
+
pipeline,
|
|
244
|
+
skillInputPipeline,
|
|
245
|
+
runner,
|
|
246
|
+
);
|
|
131
247
|
return { handler, events, session, toolRegistry };
|
|
132
248
|
}
|
|
133
249
|
|
package/test/mcp-targets.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
createMcpPermissionTargets,
|
|
4
|
+
McpTargetList,
|
|
4
5
|
parseQualifiedMcpToolName,
|
|
5
6
|
} from "#src/mcp-targets";
|
|
6
7
|
|
|
@@ -176,3 +177,57 @@ describe("createMcpPermissionTargets", () => {
|
|
|
176
177
|
});
|
|
177
178
|
});
|
|
178
179
|
});
|
|
180
|
+
|
|
181
|
+
describe("McpTargetList", () => {
|
|
182
|
+
describe("add", () => {
|
|
183
|
+
it("ignores null", () => {
|
|
184
|
+
const list = new McpTargetList();
|
|
185
|
+
list.add(null);
|
|
186
|
+
expect(list.toArray()).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("ignores empty string", () => {
|
|
190
|
+
const list = new McpTargetList();
|
|
191
|
+
list.add("");
|
|
192
|
+
expect(list.toArray()).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("appends a new value", () => {
|
|
196
|
+
const list = new McpTargetList();
|
|
197
|
+
list.add("exa");
|
|
198
|
+
expect(list.toArray()).toEqual(["exa"]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("dedups repeated values", () => {
|
|
202
|
+
const list = new McpTargetList();
|
|
203
|
+
list.add("exa");
|
|
204
|
+
list.add("exa");
|
|
205
|
+
expect(list.toArray()).toEqual(["exa"]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("preserves first-insertion order across a mix of values", () => {
|
|
209
|
+
const list = new McpTargetList();
|
|
210
|
+
list.add("exa_search");
|
|
211
|
+
list.add("exa:search");
|
|
212
|
+
list.add("exa");
|
|
213
|
+
list.add("exa_search"); // duplicate — must not change order
|
|
214
|
+
list.add("mcp_call");
|
|
215
|
+
expect(list.toArray()).toEqual([
|
|
216
|
+
"exa_search",
|
|
217
|
+
"exa:search",
|
|
218
|
+
"exa",
|
|
219
|
+
"mcp_call",
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("toArray", () => {
|
|
225
|
+
it("returns an independent copy that does not mutate the list", () => {
|
|
226
|
+
const list = new McpTargetList();
|
|
227
|
+
list.add("exa");
|
|
228
|
+
const first = list.toArray();
|
|
229
|
+
first.push("mutated");
|
|
230
|
+
expect(list.toArray()).toEqual(["exa"]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
PERMISSIONS_PROTOCOL_VERSION,
|
|
14
14
|
PERMISSIONS_RPC_CHECK_CHANNEL,
|
|
15
15
|
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
16
|
+
PERMISSIONS_UI_PROMPT_CHANNEL,
|
|
16
17
|
} from "#src/permission-events";
|
|
17
18
|
|
|
18
19
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
@@ -317,6 +318,44 @@ describe("registerPermissionRpcHandlers — permissions:rpc:prompt", () => {
|
|
|
317
318
|
}
|
|
318
319
|
});
|
|
319
320
|
|
|
321
|
+
it("emits a UI prompt broadcast before awaiting the UI decision", async () => {
|
|
322
|
+
const bus = createEventBus();
|
|
323
|
+
const ctx = makeCtxWithUi();
|
|
324
|
+
const requestUi = vi
|
|
325
|
+
.fn()
|
|
326
|
+
.mockResolvedValue({ approved: true, state: "approved" as const });
|
|
327
|
+
const deps = makeDeps({
|
|
328
|
+
getRuntimeContext: vi.fn().mockReturnValue(ctx),
|
|
329
|
+
requestPermissionDecisionFromUi: requestUi,
|
|
330
|
+
});
|
|
331
|
+
registerPermissionRpcHandlers(bus, deps);
|
|
332
|
+
|
|
333
|
+
const promptPromise = waitForReply(bus, PERMISSIONS_UI_PROMPT_CHANNEL);
|
|
334
|
+
const replyPromise = waitForReply(
|
|
335
|
+
bus,
|
|
336
|
+
`${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:req-prompt-broadcast`,
|
|
337
|
+
);
|
|
338
|
+
bus.emit(PERMISSIONS_RPC_PROMPT_CHANNEL, {
|
|
339
|
+
requestId: "req-prompt-broadcast",
|
|
340
|
+
surface: "bash",
|
|
341
|
+
value: "git push",
|
|
342
|
+
message: "Allow git push?",
|
|
343
|
+
agentName: "Worker",
|
|
344
|
+
sessionLabel: "Allow git *",
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await expect(promptPromise).resolves.toEqual({
|
|
348
|
+
requestId: "req-prompt-broadcast",
|
|
349
|
+
source: "rpc_prompt",
|
|
350
|
+
surface: "bash",
|
|
351
|
+
value: "git push",
|
|
352
|
+
agentName: "Worker",
|
|
353
|
+
message: "Allow git push?",
|
|
354
|
+
forwarding: null,
|
|
355
|
+
});
|
|
356
|
+
await replyPromise;
|
|
357
|
+
});
|
|
358
|
+
|
|
320
359
|
it("passes the message to requestPermissionDecisionFromUi", async () => {
|
|
321
360
|
const bus = createEventBus();
|
|
322
361
|
const ctx = makeCtxWithUi();
|
|
@@ -14,15 +14,18 @@ import type {
|
|
|
14
14
|
PermissionsPromptRequest,
|
|
15
15
|
PermissionsReadyEvent,
|
|
16
16
|
PermissionsRpcReply,
|
|
17
|
+
PermissionUiPromptEvent,
|
|
17
18
|
} from "#src/permission-events";
|
|
18
19
|
import {
|
|
19
20
|
emitDecisionEvent,
|
|
20
21
|
emitReadyEvent,
|
|
22
|
+
emitUiPromptEvent,
|
|
21
23
|
PERMISSIONS_DECISION_CHANNEL,
|
|
22
24
|
PERMISSIONS_PROTOCOL_VERSION,
|
|
23
25
|
PERMISSIONS_READY_CHANNEL,
|
|
24
26
|
PERMISSIONS_RPC_CHECK_CHANNEL,
|
|
25
27
|
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
28
|
+
PERMISSIONS_UI_PROMPT_CHANNEL,
|
|
26
29
|
} from "#src/permission-events";
|
|
27
30
|
|
|
28
31
|
// ── Minimal EventBus stub ──────────────────────────────────────────────────
|
|
@@ -43,6 +46,7 @@ describe("constants", () => {
|
|
|
43
46
|
|
|
44
47
|
it("channel names have the correct values", () => {
|
|
45
48
|
expect(PERMISSIONS_READY_CHANNEL).toBe("permissions:ready");
|
|
49
|
+
expect(PERMISSIONS_UI_PROMPT_CHANNEL).toBe("permissions:ui_prompt");
|
|
46
50
|
expect(PERMISSIONS_DECISION_CHANNEL).toBe("permissions:decision");
|
|
47
51
|
expect(PERMISSIONS_RPC_CHECK_CHANNEL).toBe("permissions:rpc:check");
|
|
48
52
|
expect(PERMISSIONS_RPC_PROMPT_CHANNEL).toBe("permissions:rpc:prompt");
|
|
@@ -52,20 +56,75 @@ describe("constants", () => {
|
|
|
52
56
|
// ── emitReadyEvent ─────────────────────────────────────────────────────────
|
|
53
57
|
|
|
54
58
|
describe("emitReadyEvent", () => {
|
|
55
|
-
it("emits on the permissions:ready channel
|
|
59
|
+
it("emits an empty payload on the permissions:ready channel", () => {
|
|
56
60
|
const bus = makeEventBus();
|
|
57
61
|
emitReadyEvent(bus);
|
|
58
62
|
expect(bus.emit).toHaveBeenCalledOnce();
|
|
59
|
-
expect(bus.emit).toHaveBeenCalledWith("permissions:ready", {
|
|
60
|
-
protocolVersion: 1,
|
|
61
|
-
});
|
|
63
|
+
expect(bus.emit).toHaveBeenCalledWith("permissions:ready", {});
|
|
62
64
|
});
|
|
63
65
|
|
|
64
|
-
it("
|
|
66
|
+
it("carries no protocolVersion (version lives in the RPC envelope)", () => {
|
|
65
67
|
const bus = makeEventBus();
|
|
66
68
|
emitReadyEvent(bus);
|
|
67
69
|
const payload = bus.emit.mock.calls[0][1] as PermissionsReadyEvent;
|
|
68
|
-
expect(
|
|
70
|
+
expect(payload).not.toHaveProperty("protocolVersion");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("swallows event bus errors because broadcasts are best-effort", () => {
|
|
74
|
+
const bus = {
|
|
75
|
+
emit: vi.fn(() => {
|
|
76
|
+
throw new Error("listener failed");
|
|
77
|
+
}),
|
|
78
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
expect(() => emitReadyEvent(bus)).not.toThrow();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── emitUiPromptEvent ──────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe("emitUiPromptEvent", () => {
|
|
88
|
+
function makeUiPromptEvent(
|
|
89
|
+
overrides: Partial<PermissionUiPromptEvent> = {},
|
|
90
|
+
): PermissionUiPromptEvent {
|
|
91
|
+
return {
|
|
92
|
+
requestId: "req-123",
|
|
93
|
+
source: "tool_call",
|
|
94
|
+
surface: "bash",
|
|
95
|
+
value: "git status",
|
|
96
|
+
agentName: "Explore",
|
|
97
|
+
message: "Allow git status?",
|
|
98
|
+
forwarding: null,
|
|
99
|
+
...overrides,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
it("emits on the permissions:ui_prompt channel", () => {
|
|
104
|
+
const bus = makeEventBus();
|
|
105
|
+
emitUiPromptEvent(bus, makeUiPromptEvent());
|
|
106
|
+
expect(bus.emit).toHaveBeenCalledOnce();
|
|
107
|
+
expect(bus.emit.mock.calls[0][0]).toBe("permissions:ui_prompt");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("forwards the full payload unchanged", () => {
|
|
111
|
+
const bus = makeEventBus();
|
|
112
|
+
const event = makeUiPromptEvent({
|
|
113
|
+
forwarding: { requesterAgentName: "Worker", requesterSessionId: "child" },
|
|
114
|
+
});
|
|
115
|
+
emitUiPromptEvent(bus, event);
|
|
116
|
+
expect(bus.emit.mock.calls[0][1]).toEqual(event);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("swallows event bus errors because UI prompt broadcasts are observational", () => {
|
|
120
|
+
const bus = {
|
|
121
|
+
emit: vi.fn(() => {
|
|
122
|
+
throw new Error("listener failed");
|
|
123
|
+
}),
|
|
124
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
expect(() => emitUiPromptEvent(bus, makeUiPromptEvent())).not.toThrow();
|
|
69
128
|
});
|
|
70
129
|
});
|
|
71
130
|
|
|
@@ -143,6 +202,17 @@ describe("emitDecisionEvent", () => {
|
|
|
143
202
|
expect(payload.agentName).toBeNull();
|
|
144
203
|
expect(payload.matchedPattern).toBeNull();
|
|
145
204
|
});
|
|
205
|
+
|
|
206
|
+
it("swallows event bus errors because broadcasts are best-effort", () => {
|
|
207
|
+
const bus = {
|
|
208
|
+
emit: vi.fn(() => {
|
|
209
|
+
throw new Error("listener failed");
|
|
210
|
+
}),
|
|
211
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
expect(() => emitDecisionEvent(bus, makeDecisionEvent())).not.toThrow();
|
|
215
|
+
});
|
|
146
216
|
});
|
|
147
217
|
|
|
148
218
|
// ── Type-shape compile-time checks (runtime assertions on literal values) ──
|
|
@@ -279,7 +349,7 @@ describe("piPermissionSystemExtension ready event wiring", () => {
|
|
|
279
349
|
rmSync(baseDir, { recursive: true, force: true });
|
|
280
350
|
});
|
|
281
351
|
|
|
282
|
-
it("emits permissions:ready
|
|
352
|
+
it("emits permissions:ready at session_start", async () => {
|
|
283
353
|
const emitSpy = vi.fn();
|
|
284
354
|
const handlers = new Map<
|
|
285
355
|
string,
|
|
@@ -324,8 +394,6 @@ describe("piPermissionSystemExtension ready event wiring", () => {
|
|
|
324
394
|
([channel]) => channel === PERMISSIONS_READY_CHANNEL,
|
|
325
395
|
);
|
|
326
396
|
expect(readyCalls).toHaveLength(1);
|
|
327
|
-
expect(readyCalls[0][1]).toEqual({
|
|
328
|
-
protocolVersion: PERMISSIONS_PROTOCOL_VERSION,
|
|
329
|
-
});
|
|
397
|
+
expect(readyCalls[0][1]).toEqual({});
|
|
330
398
|
});
|
|
331
399
|
});
|