@gotgenes/pi-permission-system 10.0.0 → 10.2.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 +33 -0
- package/README.md +1 -1
- 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/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 +53 -69
- package/src/mcp-targets.ts +56 -46
- package/src/permission-manager.ts +69 -3
- package/src/permission-prompter.ts +7 -58
- package/src/permission-resolver.ts +17 -0
- package/src/permission-session.ts +83 -27
- package/src/permissions-service.ts +53 -0
- package/src/runtime.ts +1 -37
- package/src/service-lifecycle.ts +49 -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/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 +83 -114
- 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 +54 -157
- package/test/handlers/gates/path.test.ts +38 -105
- package/test/handlers/gates/runner.test.ts +151 -186
- 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/handlers/tool-call.test.ts +44 -153
- package/test/helpers/gate-fixtures.ts +212 -17
- package/test/helpers/handler-fixtures.ts +226 -29
- package/test/mcp-targets.test.ts +55 -0
- package/test/permission-forwarder.test.ts +295 -0
- package/test/permission-forwarding.test.ts +0 -282
- package/test/permission-manager-unified.test.ts +159 -1
- package/test/permission-prompter.test.ts +33 -44
- package/test/permission-session.test.ts +211 -105
- package/test/permissions-service.test.ts +151 -0
- package/test/runtime.test.ts +2 -86
- 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 -411
|
@@ -7,13 +7,66 @@
|
|
|
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
|
-
import type { PermissionCheckResult } from "#src/types";
|
|
33
|
+
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
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
|
+
};
|
|
17
70
|
|
|
18
71
|
export function makeEvents() {
|
|
19
72
|
return {
|
|
@@ -75,33 +128,86 @@ export function makeCheckResult(
|
|
|
75
128
|
}
|
|
76
129
|
|
|
77
130
|
/**
|
|
78
|
-
* Full-
|
|
131
|
+
* Full-intersection session stub.
|
|
132
|
+
*
|
|
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.
|
|
79
136
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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(
|
|
@@ -114,6 +220,78 @@ export function makeToolRegistry(
|
|
|
114
220
|
};
|
|
115
221
|
}
|
|
116
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Surface-dispatching `checkPermission` mock.
|
|
225
|
+
*
|
|
226
|
+
* Builds a `vi.fn()` that returns a `PermissionCheckResult` for each surface,
|
|
227
|
+
* using `bySurface[surface]` when matched and `defaultResult` otherwise.
|
|
228
|
+
* Default fields: `toolName` = the surface string, `source: "tool"`,
|
|
229
|
+
* `origin: "builtin"` — callers override by including the field in the
|
|
230
|
+
* per-surface or default partial (e.g. `{ path: { state: "allow", source: "special" } }`).
|
|
231
|
+
*
|
|
232
|
+
* Return type is intentionally unannotated so callers retain full `vi.fn()`
|
|
233
|
+
* mock access (`mock.calls`, `toHaveBeenCalledWith`, etc.).
|
|
234
|
+
*/
|
|
235
|
+
export function makeSurfaceCheck(
|
|
236
|
+
bySurface: Record<
|
|
237
|
+
string,
|
|
238
|
+
Partial<PermissionCheckResult> & { state: PermissionState }
|
|
239
|
+
>,
|
|
240
|
+
defaultResult: Partial<PermissionCheckResult> & { state: PermissionState } = {
|
|
241
|
+
state: "allow",
|
|
242
|
+
},
|
|
243
|
+
) {
|
|
244
|
+
return vi
|
|
245
|
+
.fn<MockGateHandlerSession["checkPermission"]>()
|
|
246
|
+
.mockImplementation((surface): PermissionCheckResult => {
|
|
247
|
+
const base = bySurface[surface] ?? defaultResult;
|
|
248
|
+
return {
|
|
249
|
+
toolName: surface,
|
|
250
|
+
source: "tool",
|
|
251
|
+
origin: "builtin",
|
|
252
|
+
...base,
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Bash-surface `checkPermission` mock that dispatches on a command regex.
|
|
259
|
+
*
|
|
260
|
+
* For the `bash` surface: returns a deny result when `opts.deny` matches the
|
|
261
|
+
* command, and an allow result otherwise. For all other surfaces, returns a
|
|
262
|
+
* plain allow result.
|
|
263
|
+
*
|
|
264
|
+
* Return type is intentionally unannotated so callers retain full `vi.fn()`
|
|
265
|
+
* mock access.
|
|
266
|
+
*/
|
|
267
|
+
export function makeBashCommandCheck(opts: {
|
|
268
|
+
deny: RegExp;
|
|
269
|
+
denyMatched: string;
|
|
270
|
+
allowMatched?: string;
|
|
271
|
+
}) {
|
|
272
|
+
return vi
|
|
273
|
+
.fn<MockGateHandlerSession["checkPermission"]>()
|
|
274
|
+
.mockImplementation((surface, input): PermissionCheckResult => {
|
|
275
|
+
if (surface === "bash") {
|
|
276
|
+
const command = (input as { command?: string }).command ?? "";
|
|
277
|
+
return opts.deny.test(command)
|
|
278
|
+
? makeCheckResult({
|
|
279
|
+
state: "deny",
|
|
280
|
+
source: "bash",
|
|
281
|
+
command,
|
|
282
|
+
matchedPattern: opts.denyMatched,
|
|
283
|
+
})
|
|
284
|
+
: makeCheckResult({
|
|
285
|
+
state: "allow",
|
|
286
|
+
source: "bash",
|
|
287
|
+
command,
|
|
288
|
+
matchedPattern: opts.allowMatched,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return makeCheckResult({ state: "allow" });
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
117
295
|
/**
|
|
118
296
|
* Constructs a PermissionGateHandler with mocked collaborators.
|
|
119
297
|
*
|
|
@@ -121,13 +299,32 @@ export function makeToolRegistry(
|
|
|
121
299
|
* it needs — handler, events, session, and toolRegistry are all available.
|
|
122
300
|
*/
|
|
123
301
|
export function makeHandler(overrides?: {
|
|
124
|
-
session?: Partial<
|
|
302
|
+
session?: Partial<MockGateHandlerSession>;
|
|
125
303
|
toolRegistry?: Partial<ToolRegistry>;
|
|
304
|
+
/** Sugar: builds the `getAll` mock from a list of tool names. */
|
|
305
|
+
tools?: string[];
|
|
126
306
|
}) {
|
|
127
307
|
const session = makeSession(overrides?.session);
|
|
128
308
|
const events = makeEvents();
|
|
129
|
-
const toolRegistry =
|
|
130
|
-
|
|
309
|
+
const toolRegistry =
|
|
310
|
+
overrides?.tools !== undefined
|
|
311
|
+
? makeToolRegistry({
|
|
312
|
+
getAll: vi
|
|
313
|
+
.fn()
|
|
314
|
+
.mockReturnValue(overrides.tools.map((name) => ({ name }))),
|
|
315
|
+
})
|
|
316
|
+
: makeToolRegistry(overrides?.toolRegistry);
|
|
317
|
+
const pipeline = new ToolCallGatePipeline(session);
|
|
318
|
+
const skillInputPipeline = new SkillInputGatePipeline(session);
|
|
319
|
+
const reporter = new GateDecisionReporter(session.logger, events);
|
|
320
|
+
const runner = new GateRunner(session, session, session, reporter);
|
|
321
|
+
const handler = new PermissionGateHandler(
|
|
322
|
+
session,
|
|
323
|
+
toolRegistry,
|
|
324
|
+
pipeline,
|
|
325
|
+
skillInputPipeline,
|
|
326
|
+
runner,
|
|
327
|
+
);
|
|
131
328
|
return { handler, events, session, toolRegistry };
|
|
132
329
|
}
|
|
133
330
|
|
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
|
+
});
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
PermissionForwarder,
|
|
9
|
+
type PermissionForwarderDeps,
|
|
10
|
+
} from "#src/forwarded-permissions/permission-forwarder";
|
|
11
|
+
import { createPermissionForwardingLocation } from "#src/permission-forwarding";
|
|
12
|
+
|
|
13
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function makeDeps(
|
|
16
|
+
overrides: Partial<PermissionForwarderDeps> = {},
|
|
17
|
+
): PermissionForwarderDeps {
|
|
18
|
+
return {
|
|
19
|
+
forwardingDir: "/tmp/forwarding",
|
|
20
|
+
subagentSessionsDir: "/tmp/subagents",
|
|
21
|
+
logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
|
|
22
|
+
writeReviewLog: vi.fn(),
|
|
23
|
+
requestPermissionDecisionFromUi: vi
|
|
24
|
+
.fn()
|
|
25
|
+
.mockResolvedValue({ approved: true, state: "approved" as const }),
|
|
26
|
+
shouldAutoApprove: () => false,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.unstubAllEnvs();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ── requestApproval ───────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe("requestApproval — UI fast path", () => {
|
|
38
|
+
test("calls requestPermissionDecisionFromUi but does not emit a UI prompt event (the prompter does)", async () => {
|
|
39
|
+
const events = {
|
|
40
|
+
emit: vi.fn(),
|
|
41
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
42
|
+
};
|
|
43
|
+
const requestPermissionDecisionFromUi = vi
|
|
44
|
+
.fn()
|
|
45
|
+
.mockResolvedValue({ approved: true, state: "approved" as const });
|
|
46
|
+
|
|
47
|
+
const forwarder = new PermissionForwarder(
|
|
48
|
+
makeDeps({ events, requestPermissionDecisionFromUi }),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
await forwarder.requestApproval(
|
|
52
|
+
{
|
|
53
|
+
hasUI: true,
|
|
54
|
+
ui: { select: vi.fn(), input: vi.fn() },
|
|
55
|
+
} as unknown as ExtensionContext,
|
|
56
|
+
"Allow git push?",
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
|
|
60
|
+
expect(events.emit).not.toHaveBeenCalledWith(
|
|
61
|
+
"permissions:ui_prompt",
|
|
62
|
+
expect.anything(),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("requestApproval — non-UI, non-subagent path", () => {
|
|
68
|
+
test("returns denied without showing a dialog or emitting when there is no active UI", async () => {
|
|
69
|
+
const events = {
|
|
70
|
+
emit: vi.fn(),
|
|
71
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
72
|
+
};
|
|
73
|
+
const requestPermissionDecisionFromUi = vi.fn();
|
|
74
|
+
|
|
75
|
+
const forwarder = new PermissionForwarder(
|
|
76
|
+
makeDeps({ events, requestPermissionDecisionFromUi }),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const result = await forwarder.requestApproval(
|
|
80
|
+
{
|
|
81
|
+
hasUI: false,
|
|
82
|
+
sessionManager: {
|
|
83
|
+
getSessionDir: vi.fn().mockReturnValue(null),
|
|
84
|
+
},
|
|
85
|
+
} as unknown as ExtensionContext,
|
|
86
|
+
"Allow git push?",
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(result).toEqual({ approved: false, state: "denied" });
|
|
90
|
+
expect(events.emit).not.toHaveBeenCalledWith(
|
|
91
|
+
"permissions:ui_prompt",
|
|
92
|
+
expect.anything(),
|
|
93
|
+
);
|
|
94
|
+
expect(requestPermissionDecisionFromUi).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── processInbox ──────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
describe("processInbox", () => {
|
|
101
|
+
test("emits a UI prompt event before showing a forwarded permission dialog", async () => {
|
|
102
|
+
const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
|
|
103
|
+
try {
|
|
104
|
+
const forwardingDir = join(root, "forwarding");
|
|
105
|
+
const location = createPermissionForwardingLocation(
|
|
106
|
+
forwardingDir,
|
|
107
|
+
"parent-session",
|
|
108
|
+
);
|
|
109
|
+
mkdirSync(location.requestsDir, { recursive: true });
|
|
110
|
+
mkdirSync(location.responsesDir, { recursive: true });
|
|
111
|
+
writeFileSync(
|
|
112
|
+
join(location.requestsDir, "req-forwarded.json"),
|
|
113
|
+
JSON.stringify({
|
|
114
|
+
id: "req-forwarded",
|
|
115
|
+
createdAt: Date.now(),
|
|
116
|
+
requesterSessionId: "child-session",
|
|
117
|
+
targetSessionId: "parent-session",
|
|
118
|
+
requesterAgentName: "Explore",
|
|
119
|
+
message: "Allow git push?",
|
|
120
|
+
}),
|
|
121
|
+
"utf-8",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const events = {
|
|
125
|
+
emit: vi.fn(),
|
|
126
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
127
|
+
};
|
|
128
|
+
const requestPermissionDecisionFromUi = vi
|
|
129
|
+
.fn()
|
|
130
|
+
.mockResolvedValue({ approved: true, state: "approved" as const });
|
|
131
|
+
|
|
132
|
+
const forwarder = new PermissionForwarder(
|
|
133
|
+
makeDeps({
|
|
134
|
+
forwardingDir,
|
|
135
|
+
events,
|
|
136
|
+
requestPermissionDecisionFromUi,
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
await forwarder.processInbox({
|
|
141
|
+
hasUI: true,
|
|
142
|
+
ui: { select: vi.fn(), input: vi.fn() },
|
|
143
|
+
sessionManager: {
|
|
144
|
+
getSessionId: vi.fn().mockReturnValue("parent-session"),
|
|
145
|
+
},
|
|
146
|
+
} as unknown as ExtensionContext);
|
|
147
|
+
|
|
148
|
+
expect(events.emit).toHaveBeenCalledWith(
|
|
149
|
+
"permissions:ui_prompt",
|
|
150
|
+
expect.objectContaining({
|
|
151
|
+
requestId: "req-forwarded",
|
|
152
|
+
source: "tool_call",
|
|
153
|
+
surface: null,
|
|
154
|
+
value: null,
|
|
155
|
+
agentName: "Explore",
|
|
156
|
+
message: expect.stringContaining("Allow git push?"),
|
|
157
|
+
forwarding: {
|
|
158
|
+
requesterAgentName: "Explore",
|
|
159
|
+
requesterSessionId: "child-session",
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
|
|
164
|
+
} finally {
|
|
165
|
+
rmSync(root, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("emits a non-degraded UI prompt event when the request carries display fields", async () => {
|
|
170
|
+
const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
|
|
171
|
+
try {
|
|
172
|
+
const forwardingDir = join(root, "forwarding");
|
|
173
|
+
const location = createPermissionForwardingLocation(
|
|
174
|
+
forwardingDir,
|
|
175
|
+
"parent-session",
|
|
176
|
+
);
|
|
177
|
+
mkdirSync(location.requestsDir, { recursive: true });
|
|
178
|
+
mkdirSync(location.responsesDir, { recursive: true });
|
|
179
|
+
writeFileSync(
|
|
180
|
+
join(location.requestsDir, "req-forwarded-rich.json"),
|
|
181
|
+
JSON.stringify({
|
|
182
|
+
id: "req-forwarded-rich",
|
|
183
|
+
createdAt: Date.now(),
|
|
184
|
+
requesterSessionId: "child-session",
|
|
185
|
+
targetSessionId: "parent-session",
|
|
186
|
+
requesterAgentName: "Explore",
|
|
187
|
+
message: "Allow git push?",
|
|
188
|
+
source: "tool_call",
|
|
189
|
+
surface: "bash",
|
|
190
|
+
value: "git push",
|
|
191
|
+
}),
|
|
192
|
+
"utf-8",
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const events = {
|
|
196
|
+
emit: vi.fn(),
|
|
197
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
198
|
+
};
|
|
199
|
+
const requestPermissionDecisionFromUi = vi
|
|
200
|
+
.fn()
|
|
201
|
+
.mockResolvedValue({ approved: true, state: "approved" as const });
|
|
202
|
+
|
|
203
|
+
const forwarder = new PermissionForwarder(
|
|
204
|
+
makeDeps({
|
|
205
|
+
forwardingDir,
|
|
206
|
+
events,
|
|
207
|
+
requestPermissionDecisionFromUi,
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
await forwarder.processInbox({
|
|
212
|
+
hasUI: true,
|
|
213
|
+
ui: { select: vi.fn(), input: vi.fn() },
|
|
214
|
+
sessionManager: {
|
|
215
|
+
getSessionId: vi.fn().mockReturnValue("parent-session"),
|
|
216
|
+
},
|
|
217
|
+
} as unknown as ExtensionContext);
|
|
218
|
+
|
|
219
|
+
expect(events.emit).toHaveBeenCalledWith(
|
|
220
|
+
"permissions:ui_prompt",
|
|
221
|
+
expect.objectContaining({
|
|
222
|
+
requestId: "req-forwarded-rich",
|
|
223
|
+
source: "tool_call",
|
|
224
|
+
surface: "bash",
|
|
225
|
+
value: "git push",
|
|
226
|
+
agentName: "Explore",
|
|
227
|
+
message: expect.stringContaining("Allow git push?"),
|
|
228
|
+
forwarding: {
|
|
229
|
+
requesterAgentName: "Explore",
|
|
230
|
+
requesterSessionId: "child-session",
|
|
231
|
+
},
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
|
|
235
|
+
} finally {
|
|
236
|
+
rmSync(root, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("does not emit a UI prompt event when forwarded permission auto-approves", async () => {
|
|
241
|
+
const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
|
|
242
|
+
try {
|
|
243
|
+
const forwardingDir = join(root, "forwarding");
|
|
244
|
+
const location = createPermissionForwardingLocation(
|
|
245
|
+
forwardingDir,
|
|
246
|
+
"parent-session",
|
|
247
|
+
);
|
|
248
|
+
mkdirSync(location.requestsDir, { recursive: true });
|
|
249
|
+
mkdirSync(location.responsesDir, { recursive: true });
|
|
250
|
+
writeFileSync(
|
|
251
|
+
join(location.requestsDir, "req-forwarded-auto.json"),
|
|
252
|
+
JSON.stringify({
|
|
253
|
+
id: "req-forwarded-auto",
|
|
254
|
+
createdAt: Date.now(),
|
|
255
|
+
requesterSessionId: "child-session",
|
|
256
|
+
targetSessionId: "parent-session",
|
|
257
|
+
requesterAgentName: "Explore",
|
|
258
|
+
message: "Allow git push?",
|
|
259
|
+
}),
|
|
260
|
+
"utf-8",
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const events = {
|
|
264
|
+
emit: vi.fn(),
|
|
265
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
266
|
+
};
|
|
267
|
+
const requestPermissionDecisionFromUi = vi.fn();
|
|
268
|
+
|
|
269
|
+
const forwarder = new PermissionForwarder(
|
|
270
|
+
makeDeps({
|
|
271
|
+
forwardingDir,
|
|
272
|
+
events,
|
|
273
|
+
requestPermissionDecisionFromUi,
|
|
274
|
+
shouldAutoApprove: () => true,
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
await forwarder.processInbox({
|
|
279
|
+
hasUI: true,
|
|
280
|
+
ui: { select: vi.fn(), input: vi.fn() },
|
|
281
|
+
sessionManager: {
|
|
282
|
+
getSessionId: vi.fn().mockReturnValue("parent-session"),
|
|
283
|
+
},
|
|
284
|
+
} as unknown as ExtensionContext);
|
|
285
|
+
|
|
286
|
+
expect(events.emit).not.toHaveBeenCalledWith(
|
|
287
|
+
"permissions:ui_prompt",
|
|
288
|
+
expect.anything(),
|
|
289
|
+
);
|
|
290
|
+
expect(requestPermissionDecisionFromUi).not.toHaveBeenCalled();
|
|
291
|
+
} finally {
|
|
292
|
+
rmSync(root, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
});
|