@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
|
@@ -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
|
+
});
|
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
// ──
|
|
3
|
+
// ── Injected mock ───────────────────────────────────────────────────────────
|
|
4
4
|
|
|
5
|
-
const
|
|
6
|
-
mockConfirmPermission: vi.fn(),
|
|
7
|
-
}));
|
|
5
|
+
const mockRequestApproval = vi.fn();
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
confirmPermission: mockConfirmPermission,
|
|
11
|
-
processForwardedPermissionRequests: vi.fn().mockResolvedValue(undefined),
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
|
-
// ── Imports (after mocks) ───────────────────────────────────────────────────
|
|
7
|
+
// ── Imports ─────────────────────────────────────────────────────────────────
|
|
15
8
|
|
|
16
9
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
10
|
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
@@ -51,11 +44,8 @@ function makeDeps(
|
|
|
51
44
|
return {
|
|
52
45
|
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: false }),
|
|
53
46
|
writeReviewLog: vi.fn(),
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
requestPermissionDecisionFromUi: vi
|
|
57
|
-
.fn()
|
|
58
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
47
|
+
events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
|
|
48
|
+
forwarder: { requestApproval: mockRequestApproval },
|
|
59
49
|
...overrides,
|
|
60
50
|
};
|
|
61
51
|
}
|
|
@@ -64,15 +54,24 @@ function makeDeps(
|
|
|
64
54
|
|
|
65
55
|
describe("PermissionPrompter", () => {
|
|
66
56
|
beforeEach(() => {
|
|
67
|
-
|
|
57
|
+
mockRequestApproval.mockReset();
|
|
58
|
+
mockRequestApproval.mockResolvedValue({
|
|
59
|
+
approved: true,
|
|
60
|
+
state: "approved",
|
|
61
|
+
});
|
|
68
62
|
});
|
|
69
63
|
|
|
70
64
|
// ── Yolo-mode auto-approve ───────────────────────────────────────────────
|
|
71
65
|
|
|
72
66
|
describe("yolo-mode auto-approve", () => {
|
|
73
67
|
it("returns approved without calling confirmPermission when yoloMode is true", async () => {
|
|
68
|
+
const events = {
|
|
69
|
+
emit: vi.fn(),
|
|
70
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
71
|
+
};
|
|
74
72
|
const deps = makeDeps({
|
|
75
73
|
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
|
|
74
|
+
events,
|
|
76
75
|
});
|
|
77
76
|
const prompter = new PermissionPrompter(deps);
|
|
78
77
|
|
|
@@ -83,7 +82,11 @@ describe("PermissionPrompter", () => {
|
|
|
83
82
|
state: "approved",
|
|
84
83
|
autoApproved: true,
|
|
85
84
|
});
|
|
86
|
-
expect(
|
|
85
|
+
expect(mockRequestApproval).not.toHaveBeenCalled();
|
|
86
|
+
expect(events.emit).not.toHaveBeenCalledWith(
|
|
87
|
+
"permissions:ui_prompt",
|
|
88
|
+
expect.anything(),
|
|
89
|
+
);
|
|
87
90
|
});
|
|
88
91
|
|
|
89
92
|
it("logs permission_request.auto_approved in yolo mode", async () => {
|
|
@@ -126,7 +129,7 @@ describe("PermissionPrompter", () => {
|
|
|
126
129
|
|
|
127
130
|
await prompter.prompt(makeCtx(true), makeDetails());
|
|
128
131
|
|
|
129
|
-
expect(
|
|
132
|
+
expect(mockRequestApproval).not.toHaveBeenCalled();
|
|
130
133
|
});
|
|
131
134
|
});
|
|
132
135
|
|
|
@@ -139,7 +142,7 @@ describe("PermissionPrompter", () => {
|
|
|
139
142
|
approved: true,
|
|
140
143
|
state: "approved",
|
|
141
144
|
};
|
|
142
|
-
|
|
145
|
+
mockRequestApproval.mockResolvedValue(approved);
|
|
143
146
|
const deps = makeDeps({ writeReviewLog });
|
|
144
147
|
const prompter = new PermissionPrompter(deps);
|
|
145
148
|
|
|
@@ -154,9 +157,93 @@ describe("PermissionPrompter", () => {
|
|
|
154
157
|
);
|
|
155
158
|
});
|
|
156
159
|
|
|
160
|
+
it("emits a UI prompt event with normalized surface and value when the session has UI", async () => {
|
|
161
|
+
const events = {
|
|
162
|
+
emit: vi.fn(),
|
|
163
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
164
|
+
};
|
|
165
|
+
mockRequestApproval.mockResolvedValue({
|
|
166
|
+
approved: true,
|
|
167
|
+
state: "approved",
|
|
168
|
+
});
|
|
169
|
+
const deps = makeDeps({ events });
|
|
170
|
+
const prompter = new PermissionPrompter(deps);
|
|
171
|
+
|
|
172
|
+
await prompter.prompt(
|
|
173
|
+
makeCtx(true),
|
|
174
|
+
makeDetails({
|
|
175
|
+
toolName: "bash",
|
|
176
|
+
command: "git push",
|
|
177
|
+
toolInputPreview: "git push",
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", {
|
|
182
|
+
requestId: "req-123",
|
|
183
|
+
source: "tool_call",
|
|
184
|
+
surface: "bash",
|
|
185
|
+
value: "git push",
|
|
186
|
+
agentName: "test-agent",
|
|
187
|
+
message: "Allow read?",
|
|
188
|
+
forwarding: null,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("normalizes skill UI prompt events to the skill surface", async () => {
|
|
193
|
+
const events = {
|
|
194
|
+
emit: vi.fn(),
|
|
195
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
196
|
+
};
|
|
197
|
+
mockRequestApproval.mockResolvedValue({
|
|
198
|
+
approved: true,
|
|
199
|
+
state: "approved",
|
|
200
|
+
});
|
|
201
|
+
const deps = makeDeps({ events });
|
|
202
|
+
const prompter = new PermissionPrompter(deps);
|
|
203
|
+
|
|
204
|
+
await prompter.prompt(
|
|
205
|
+
makeCtx(true),
|
|
206
|
+
makeDetails({
|
|
207
|
+
source: "skill_input",
|
|
208
|
+
toolName: undefined,
|
|
209
|
+
skillName: "deploy-helper",
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", {
|
|
214
|
+
requestId: "req-123",
|
|
215
|
+
source: "skill_input",
|
|
216
|
+
surface: "skill",
|
|
217
|
+
value: "deploy-helper",
|
|
218
|
+
agentName: "test-agent",
|
|
219
|
+
message: "Allow read?",
|
|
220
|
+
forwarding: null,
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("does not emit a UI prompt event when the session has no UI", async () => {
|
|
225
|
+
const events = {
|
|
226
|
+
emit: vi.fn(),
|
|
227
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
228
|
+
};
|
|
229
|
+
mockRequestApproval.mockResolvedValue({
|
|
230
|
+
approved: true,
|
|
231
|
+
state: "approved",
|
|
232
|
+
});
|
|
233
|
+
const deps = makeDeps({ events });
|
|
234
|
+
const prompter = new PermissionPrompter(deps);
|
|
235
|
+
|
|
236
|
+
await prompter.prompt(makeCtx(false), makeDetails());
|
|
237
|
+
|
|
238
|
+
expect(events.emit).not.toHaveBeenCalledWith(
|
|
239
|
+
"permissions:ui_prompt",
|
|
240
|
+
expect.anything(),
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
157
244
|
it("logs permission_request.approved when confirmPermission returns approved", async () => {
|
|
158
245
|
const writeReviewLog = vi.fn();
|
|
159
|
-
|
|
246
|
+
mockRequestApproval.mockResolvedValue({
|
|
160
247
|
approved: true,
|
|
161
248
|
state: "approved",
|
|
162
249
|
});
|
|
@@ -176,7 +263,7 @@ describe("PermissionPrompter", () => {
|
|
|
176
263
|
|
|
177
264
|
it("logs permission_request.denied when confirmPermission returns denied", async () => {
|
|
178
265
|
const writeReviewLog = vi.fn();
|
|
179
|
-
|
|
266
|
+
mockRequestApproval.mockResolvedValue({
|
|
180
267
|
approved: false,
|
|
181
268
|
state: "denied",
|
|
182
269
|
});
|
|
@@ -196,7 +283,7 @@ describe("PermissionPrompter", () => {
|
|
|
196
283
|
|
|
197
284
|
it("logs permission_request.denied with denialReason when present", async () => {
|
|
198
285
|
const writeReviewLog = vi.fn();
|
|
199
|
-
|
|
286
|
+
mockRequestApproval.mockResolvedValue({
|
|
200
287
|
approved: false,
|
|
201
288
|
state: "denied_with_reason",
|
|
202
289
|
denialReason: "too sensitive",
|
|
@@ -220,7 +307,7 @@ describe("PermissionPrompter", () => {
|
|
|
220
307
|
state: "denied_with_reason",
|
|
221
308
|
denialReason: "sensitive",
|
|
222
309
|
};
|
|
223
|
-
|
|
310
|
+
mockRequestApproval.mockResolvedValue(decision);
|
|
224
311
|
const deps = makeDeps();
|
|
225
312
|
const prompter = new PermissionPrompter(deps);
|
|
226
313
|
|
|
@@ -230,7 +317,7 @@ describe("PermissionPrompter", () => {
|
|
|
230
317
|
});
|
|
231
318
|
|
|
232
319
|
it("passes sessionLabel option to confirmPermission when present", async () => {
|
|
233
|
-
|
|
320
|
+
mockRequestApproval.mockResolvedValue({
|
|
234
321
|
approved: true,
|
|
235
322
|
state: "approved",
|
|
236
323
|
});
|
|
@@ -240,16 +327,38 @@ describe("PermissionPrompter", () => {
|
|
|
240
327
|
|
|
241
328
|
await prompter.prompt(makeCtx(true), details);
|
|
242
329
|
|
|
243
|
-
expect(
|
|
330
|
+
expect(mockRequestApproval).toHaveBeenCalledWith(
|
|
244
331
|
expect.anything(),
|
|
245
332
|
expect.any(String),
|
|
246
|
-
expect.anything(),
|
|
247
333
|
{ sessionLabel: "Yes, for 'read' tool" },
|
|
334
|
+
{ source: "tool_call", surface: "read", value: "read" },
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("passes the display fields (source/surface/value) to confirmPermission", async () => {
|
|
339
|
+
mockRequestApproval.mockResolvedValue({
|
|
340
|
+
approved: true,
|
|
341
|
+
state: "approved",
|
|
342
|
+
});
|
|
343
|
+
const deps = makeDeps();
|
|
344
|
+
const prompter = new PermissionPrompter(deps);
|
|
345
|
+
const details = makeDetails({
|
|
346
|
+
toolName: "bash",
|
|
347
|
+
command: "git push",
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
await prompter.prompt(makeCtx(false), details);
|
|
351
|
+
|
|
352
|
+
expect(mockRequestApproval).toHaveBeenCalledWith(
|
|
353
|
+
expect.anything(),
|
|
354
|
+
expect.any(String),
|
|
355
|
+
undefined,
|
|
356
|
+
{ source: "tool_call", surface: "bash", value: "git push" },
|
|
248
357
|
);
|
|
249
358
|
});
|
|
250
359
|
|
|
251
360
|
it("passes undefined options to confirmPermission when sessionLabel is absent", async () => {
|
|
252
|
-
|
|
361
|
+
mockRequestApproval.mockResolvedValue({
|
|
253
362
|
approved: true,
|
|
254
363
|
state: "approved",
|
|
255
364
|
});
|
|
@@ -258,16 +367,16 @@ describe("PermissionPrompter", () => {
|
|
|
258
367
|
|
|
259
368
|
await prompter.prompt(makeCtx(true), makeDetails());
|
|
260
369
|
|
|
261
|
-
expect(
|
|
370
|
+
expect(mockRequestApproval).toHaveBeenCalledWith(
|
|
262
371
|
expect.anything(),
|
|
263
372
|
expect.any(String),
|
|
264
|
-
expect.anything(),
|
|
265
373
|
undefined,
|
|
374
|
+
{ source: "tool_call", surface: "read", value: "read" },
|
|
266
375
|
);
|
|
267
376
|
});
|
|
268
377
|
|
|
269
378
|
it("passes the message from details to confirmPermission", async () => {
|
|
270
|
-
|
|
379
|
+
mockRequestApproval.mockResolvedValue({
|
|
271
380
|
approved: true,
|
|
272
381
|
state: "approved",
|
|
273
382
|
});
|
|
@@ -277,11 +386,11 @@ describe("PermissionPrompter", () => {
|
|
|
277
386
|
|
|
278
387
|
await prompter.prompt(makeCtx(true), details);
|
|
279
388
|
|
|
280
|
-
expect(
|
|
389
|
+
expect(mockRequestApproval).toHaveBeenCalledWith(
|
|
281
390
|
expect.anything(),
|
|
282
391
|
"Allow bash: git status?",
|
|
283
|
-
expect.anything(),
|
|
284
392
|
undefined,
|
|
393
|
+
{ source: "tool_call", surface: "read", value: "read" },
|
|
285
394
|
);
|
|
286
395
|
});
|
|
287
396
|
});
|
|
@@ -291,7 +400,7 @@ describe("PermissionPrompter", () => {
|
|
|
291
400
|
describe("review log fields", () => {
|
|
292
401
|
it("includes all standard fields in the waiting log entry", async () => {
|
|
293
402
|
const writeReviewLog = vi.fn();
|
|
294
|
-
|
|
403
|
+
mockRequestApproval.mockResolvedValue({
|
|
295
404
|
approved: true,
|
|
296
405
|
state: "approved",
|
|
297
406
|
});
|
|
@@ -328,7 +437,7 @@ describe("PermissionPrompter", () => {
|
|
|
328
437
|
|
|
329
438
|
it("uses null for optional fields not present in details", async () => {
|
|
330
439
|
const writeReviewLog = vi.fn();
|
|
331
|
-
|
|
440
|
+
mockRequestApproval.mockResolvedValue({
|
|
332
441
|
approved: true,
|
|
333
442
|
state: "approved",
|
|
334
443
|
});
|
|
@@ -359,13 +468,13 @@ describe("PermissionPrompter", () => {
|
|
|
359
468
|
approved: true,
|
|
360
469
|
state: "approved",
|
|
361
470
|
};
|
|
362
|
-
|
|
471
|
+
mockRequestApproval.mockResolvedValue(forwarded);
|
|
363
472
|
const deps = makeDeps();
|
|
364
473
|
const prompter = new PermissionPrompter(deps);
|
|
365
474
|
|
|
366
475
|
await prompter.prompt(makeCtx(false), makeDetails());
|
|
367
476
|
|
|
368
|
-
expect(
|
|
477
|
+
expect(mockRequestApproval).toHaveBeenCalled();
|
|
369
478
|
});
|
|
370
479
|
|
|
371
480
|
it("returns the decision from confirmPermission in the subagent path", async () => {
|
|
@@ -373,7 +482,7 @@ describe("PermissionPrompter", () => {
|
|
|
373
482
|
approved: false,
|
|
374
483
|
state: "denied",
|
|
375
484
|
};
|
|
376
|
-
|
|
485
|
+
mockRequestApproval.mockResolvedValue(forwarded);
|
|
377
486
|
const deps = makeDeps();
|
|
378
487
|
const prompter = new PermissionPrompter(deps);
|
|
379
488
|
|
|
@@ -384,7 +493,7 @@ describe("PermissionPrompter", () => {
|
|
|
384
493
|
|
|
385
494
|
it("logs the outcome when confirmPermission resolves via forwarding", async () => {
|
|
386
495
|
const writeReviewLog = vi.fn();
|
|
387
|
-
|
|
496
|
+
mockRequestApproval.mockResolvedValue({
|
|
388
497
|
approved: true,
|
|
389
498
|
state: "approved",
|
|
390
499
|
});
|