@gotgenes/pi-permission-system 5.9.0 → 5.11.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 +30 -0
- package/package.json +1 -1
- package/src/forwarding-manager.ts +1 -1
- package/src/handlers/before-agent-start.ts +76 -76
- package/src/handlers/gates/descriptor.ts +1 -1
- package/src/handlers/index.ts +6 -15
- package/src/handlers/lifecycle.ts +55 -59
- package/src/handlers/permission-gate-handler.ts +346 -0
- package/src/index.ts +46 -54
- package/src/permission-prompter.ts +23 -6
- package/src/permission-session.ts +281 -0
- package/src/runtime.ts +5 -30
- package/src/session-logger.ts +1 -1
- package/src/skill-prompt-sanitizer.ts +15 -4
- package/src/tool-registry.ts +6 -0
- package/tests/handlers/before-agent-start.test.ts +116 -167
- package/tests/handlers/input-events.test.ts +87 -92
- package/tests/handlers/input.test.ts +98 -128
- package/tests/handlers/lifecycle.test.ts +97 -227
- package/tests/handlers/tool-call-events.test.ts +146 -166
- package/tests/handlers/tool-call.test.ts +102 -97
- package/tests/permission-prompter.test.ts +1 -1
- package/tests/permission-session.test.ts +607 -0
- package/tests/runtime.test.ts +2 -77
- package/src/handlers/input.ts +0 -126
- package/src/handlers/tool-call.ts +0 -210
- package/src/handlers/types.ts +0 -90
|
@@ -2,13 +2,12 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
|
2
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
AgentPrepHandler,
|
|
6
6
|
shouldExposeTool,
|
|
7
7
|
} from "../../src/handlers/before-agent-start";
|
|
8
|
-
import type {
|
|
9
|
-
import type {
|
|
10
|
-
import type {
|
|
11
|
-
import type { SkillPromptEntry } from "../../src/skill-prompt-sanitizer";
|
|
8
|
+
import type { PermissionSession } from "../../src/permission-session";
|
|
9
|
+
import type { ToolRegistry } from "../../src/tool-registry";
|
|
10
|
+
import type { PermissionState } from "../../src/types";
|
|
12
11
|
|
|
13
12
|
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
|
14
13
|
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
|
|
@@ -45,234 +44,184 @@ function makeEvent(systemPrompt = "You are an assistant.") {
|
|
|
45
44
|
return { systemPrompt };
|
|
46
45
|
}
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
): PermissionManager {
|
|
47
|
+
function makeSession(
|
|
48
|
+
overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
|
|
49
|
+
): PermissionSession {
|
|
52
50
|
return {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
|
|
52
|
+
activate: vi.fn(),
|
|
53
|
+
refreshConfig: vi.fn(),
|
|
54
|
+
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
55
|
+
getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
|
|
56
56
|
checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
|
|
57
|
-
|
|
57
|
+
shouldUpdateActiveTools: vi.fn().mockReturnValue(true),
|
|
58
|
+
commitActiveToolsCacheKey: vi.fn(),
|
|
59
|
+
getPolicyCacheStamp: vi.fn().mockReturnValue("stamp-1"),
|
|
60
|
+
shouldUpdatePromptState: vi.fn().mockReturnValue(true),
|
|
61
|
+
commitPromptStateCacheKey: vi.fn(),
|
|
62
|
+
setActiveSkillEntries: vi.fn(),
|
|
63
|
+
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
64
|
+
...overrides,
|
|
65
|
+
} as unknown as PermissionSession;
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
function
|
|
68
|
+
function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
|
|
61
69
|
return {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
activeSkillEntries: [] as SkillPromptEntry[],
|
|
65
|
-
lastKnownActiveAgentName: null,
|
|
66
|
-
lastActiveToolsCacheKey: null,
|
|
67
|
-
lastPromptStateCacheKey: null,
|
|
68
|
-
sessionRules: {
|
|
69
|
-
approve: vi.fn(),
|
|
70
|
-
getRuleset: vi.fn().mockReturnValue([]),
|
|
71
|
-
clear: vi.fn(),
|
|
72
|
-
} as unknown as SessionState["sessionRules"],
|
|
70
|
+
getAll: vi.fn().mockReturnValue([]),
|
|
71
|
+
setActive: vi.fn(),
|
|
73
72
|
...overrides,
|
|
74
73
|
};
|
|
75
74
|
}
|
|
76
75
|
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
.fn()
|
|
90
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
91
|
-
createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
|
|
92
|
-
events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
|
|
93
|
-
forwarding: { start: vi.fn(), stop: vi.fn() },
|
|
94
|
-
stopPermissionRpcHandlers: vi.fn(),
|
|
95
|
-
getAllTools: vi.fn().mockReturnValue([]),
|
|
96
|
-
setActiveTools: vi.fn(),
|
|
97
|
-
...overrides,
|
|
98
|
-
};
|
|
76
|
+
function makeHandler(overrides?: {
|
|
77
|
+
session?: Partial<Record<keyof PermissionSession, unknown>>;
|
|
78
|
+
toolRegistry?: Partial<ToolRegistry>;
|
|
79
|
+
}): {
|
|
80
|
+
handler: AgentPrepHandler;
|
|
81
|
+
session: PermissionSession;
|
|
82
|
+
toolRegistry: ToolRegistry;
|
|
83
|
+
} {
|
|
84
|
+
const session = makeSession(overrides?.session);
|
|
85
|
+
const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
|
|
86
|
+
const handler = new AgentPrepHandler(session, toolRegistry);
|
|
87
|
+
return { handler, session, toolRegistry };
|
|
99
88
|
}
|
|
100
89
|
|
|
101
90
|
// ── shouldExposeTool (pure helper) ─────────────────────────────────────────
|
|
102
91
|
|
|
103
92
|
describe("shouldExposeTool", () => {
|
|
104
93
|
it("returns true when tool permission is allow", () => {
|
|
105
|
-
const
|
|
106
|
-
expect(shouldExposeTool("read", null,
|
|
94
|
+
const getter = vi.fn().mockReturnValue("allow");
|
|
95
|
+
expect(shouldExposeTool("read", null, getter)).toBe(true);
|
|
107
96
|
});
|
|
108
97
|
|
|
109
98
|
it("returns true when tool permission is ask", () => {
|
|
110
|
-
const
|
|
111
|
-
expect(shouldExposeTool("bash", "agent-x",
|
|
99
|
+
const getter = vi.fn().mockReturnValue("ask");
|
|
100
|
+
expect(shouldExposeTool("bash", "agent-x", getter)).toBe(true);
|
|
112
101
|
});
|
|
113
102
|
|
|
114
103
|
it("returns false when tool permission is deny", () => {
|
|
115
|
-
const
|
|
116
|
-
expect(shouldExposeTool("write", null,
|
|
104
|
+
const getter = vi.fn().mockReturnValue("deny");
|
|
105
|
+
expect(shouldExposeTool("write", null, getter)).toBe(false);
|
|
117
106
|
});
|
|
118
107
|
|
|
119
108
|
it("passes agentName through to getToolPermission", () => {
|
|
120
|
-
const
|
|
121
|
-
shouldExposeTool("read", "my-agent",
|
|
122
|
-
expect(
|
|
109
|
+
const getter = vi.fn().mockReturnValue("allow");
|
|
110
|
+
shouldExposeTool("read", "my-agent", getter);
|
|
111
|
+
expect(getter).toHaveBeenCalledWith("read", "my-agent");
|
|
123
112
|
});
|
|
124
113
|
|
|
125
114
|
it("converts null agentName to undefined for getToolPermission", () => {
|
|
126
|
-
const
|
|
127
|
-
shouldExposeTool("read", null,
|
|
128
|
-
expect(
|
|
115
|
+
const getter = vi.fn().mockReturnValue("allow");
|
|
116
|
+
shouldExposeTool("read", null, getter);
|
|
117
|
+
expect(getter).toHaveBeenCalledWith("read", undefined);
|
|
129
118
|
});
|
|
130
119
|
});
|
|
131
120
|
|
|
132
|
-
// ──
|
|
121
|
+
// ── AgentPrepHandler.handle ────────────────────────────────────────────────
|
|
133
122
|
|
|
134
|
-
describe("
|
|
135
|
-
it("
|
|
123
|
+
describe("AgentPrepHandler.handle", () => {
|
|
124
|
+
it("activates the session with ctx", async () => {
|
|
136
125
|
const ctx = makeCtx();
|
|
137
|
-
const
|
|
138
|
-
await
|
|
139
|
-
expect(
|
|
126
|
+
const { handler, session } = makeHandler();
|
|
127
|
+
await handler.handle(makeEvent(), ctx);
|
|
128
|
+
expect(session.activate).toHaveBeenCalledWith(ctx);
|
|
140
129
|
});
|
|
141
130
|
|
|
142
|
-
it("
|
|
131
|
+
it("refreshes config with ctx", async () => {
|
|
143
132
|
const ctx = makeCtx();
|
|
144
|
-
const
|
|
145
|
-
await
|
|
146
|
-
expect(
|
|
133
|
+
const { handler, session } = makeHandler();
|
|
134
|
+
await handler.handle(makeEvent(), ctx);
|
|
135
|
+
expect(session.refreshConfig).toHaveBeenCalledWith(ctx);
|
|
147
136
|
});
|
|
148
137
|
|
|
149
138
|
it("resolves agent name using systemPrompt", async () => {
|
|
150
139
|
const ctx = makeCtx();
|
|
151
|
-
const
|
|
152
|
-
await
|
|
153
|
-
|
|
154
|
-
makeEvent("<active_agent name='x'>"),
|
|
155
|
-
ctx,
|
|
156
|
-
);
|
|
157
|
-
expect(deps.resolveAgentName).toHaveBeenCalledWith(
|
|
140
|
+
const { handler, session } = makeHandler();
|
|
141
|
+
await handler.handle(makeEvent("<active_agent name='x'>"), ctx);
|
|
142
|
+
expect(session.resolveAgentName).toHaveBeenCalledWith(
|
|
158
143
|
ctx,
|
|
159
144
|
"<active_agent name='x'>",
|
|
160
145
|
);
|
|
161
146
|
});
|
|
162
147
|
|
|
163
148
|
it("filters out denied tools from allowed list", async () => {
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
getAllTools: vi
|
|
170
|
-
.fn()
|
|
171
|
-
.mockReturnValue([{ name: "write" }, { name: "read" }]),
|
|
149
|
+
const { handler, toolRegistry } = makeHandler({
|
|
150
|
+
session: { getToolPermission: vi.fn().mockReturnValue("deny") },
|
|
151
|
+
toolRegistry: {
|
|
152
|
+
getAll: vi.fn().mockReturnValue([{ name: "write" }, { name: "read" }]),
|
|
153
|
+
},
|
|
172
154
|
});
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
expect(deps.setActiveTools).toHaveBeenCalledWith([]);
|
|
155
|
+
await handler.handle(makeEvent(), makeCtx());
|
|
156
|
+
expect(toolRegistry.setActive).toHaveBeenCalledWith([]);
|
|
176
157
|
});
|
|
177
158
|
|
|
178
159
|
it("includes allowed and ask tools in the active list", async () => {
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}),
|
|
184
|
-
getAllTools: vi
|
|
185
|
-
.fn()
|
|
186
|
-
.mockReturnValue([{ name: "read" }, { name: "write" }]),
|
|
160
|
+
const { handler, toolRegistry } = makeHandler({
|
|
161
|
+
toolRegistry: {
|
|
162
|
+
getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "write" }]),
|
|
163
|
+
},
|
|
187
164
|
});
|
|
188
|
-
await
|
|
189
|
-
expect(
|
|
165
|
+
await handler.handle(makeEvent(), makeCtx());
|
|
166
|
+
expect(toolRegistry.setActive).toHaveBeenCalledWith(["read", "write"]);
|
|
190
167
|
});
|
|
191
168
|
|
|
192
|
-
it("
|
|
193
|
-
const
|
|
194
|
-
|
|
169
|
+
it("commits active-tools cache key after applying", async () => {
|
|
170
|
+
const { handler, session } = makeHandler({
|
|
171
|
+
toolRegistry: {
|
|
172
|
+
getAll: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
173
|
+
},
|
|
195
174
|
});
|
|
196
|
-
await
|
|
197
|
-
expect(
|
|
175
|
+
await handler.handle(makeEvent(), makeCtx());
|
|
176
|
+
expect(session.commitActiveToolsCacheKey).toHaveBeenCalled();
|
|
198
177
|
});
|
|
199
178
|
|
|
200
|
-
it("skips
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const deps = makeDeps({
|
|
207
|
-
session: makeSession({ lastActiveToolsCacheKey: key }),
|
|
208
|
-
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
179
|
+
it("skips setActive when cache key is unchanged", async () => {
|
|
180
|
+
const { handler, session, toolRegistry } = makeHandler({
|
|
181
|
+
session: { shouldUpdateActiveTools: vi.fn().mockReturnValue(false) },
|
|
182
|
+
toolRegistry: {
|
|
183
|
+
getAll: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
184
|
+
},
|
|
209
185
|
});
|
|
210
|
-
await
|
|
211
|
-
expect(
|
|
186
|
+
await handler.handle(makeEvent(), makeCtx());
|
|
187
|
+
expect(toolRegistry.setActive).not.toHaveBeenCalled();
|
|
188
|
+
expect(session.commitActiveToolsCacheKey).not.toHaveBeenCalled();
|
|
212
189
|
});
|
|
213
190
|
|
|
214
|
-
it("
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
|
|
218
|
-
const deps = makeDeps({
|
|
219
|
-
getAllTools: vi.fn().mockReturnValue([]),
|
|
191
|
+
it("returns empty object when prompt cache is unchanged", async () => {
|
|
192
|
+
const { handler, session } = makeHandler({
|
|
193
|
+
session: { shouldUpdatePromptState: vi.fn().mockReturnValue(false) },
|
|
220
194
|
});
|
|
221
|
-
const result = await
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
makeCtx(),
|
|
225
|
-
);
|
|
226
|
-
// The prompt was modified, so systemPrompt should be returned
|
|
227
|
-
expect(result).toHaveProperty("systemPrompt");
|
|
228
|
-
expect(deps.session.lastPromptStateCacheKey).not.toBeNull();
|
|
195
|
+
const result = await handler.handle(makeEvent(), makeCtx());
|
|
196
|
+
expect(result).toEqual({});
|
|
197
|
+
expect(session.commitPromptStateCacheKey).not.toHaveBeenCalled();
|
|
229
198
|
});
|
|
230
199
|
|
|
231
|
-
it("
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
200
|
+
it("commits prompt-state cache key and processes prompt when cache is new", async () => {
|
|
201
|
+
const { handler, session } = makeHandler();
|
|
202
|
+
await handler.handle(makeEvent(), makeCtx());
|
|
203
|
+
expect(session.commitPromptStateCacheKey).toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("stores resolved skill entries on the session", async () => {
|
|
207
|
+
const { handler, session } = makeHandler();
|
|
208
|
+
await handler.handle(makeEvent(), makeCtx());
|
|
209
|
+
expect(session.setActiveSkillEntries).toHaveBeenCalledWith(
|
|
210
|
+
expect.any(Array),
|
|
240
211
|
);
|
|
241
|
-
expect(result).toEqual({});
|
|
242
212
|
});
|
|
243
213
|
|
|
244
|
-
it("
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
expect(deps.session.activeSkillEntries).toEqual(expect.any(Array));
|
|
214
|
+
it("returns modified systemPrompt when prompt changes", async () => {
|
|
215
|
+
const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
|
|
216
|
+
const { handler } = makeHandler();
|
|
217
|
+
const result = await handler.handle(makeEvent(systemPrompt), makeCtx());
|
|
218
|
+
expect(result).toHaveProperty("systemPrompt");
|
|
250
219
|
});
|
|
251
220
|
|
|
252
|
-
it("returns empty object
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
);
|
|
256
|
-
const pm = makePm("allow");
|
|
257
|
-
const ctx = makeCtx({ cwd: "/proj" });
|
|
258
|
-
const allowedTools: string[] = ["read"];
|
|
259
|
-
const key = createBeforeAgentStartPromptStateKey({
|
|
260
|
-
agentName: null,
|
|
261
|
-
cwd: "/proj",
|
|
262
|
-
permissionStamp: "stamp-1",
|
|
263
|
-
systemPrompt: "hello",
|
|
264
|
-
allowedToolNames: allowedTools,
|
|
265
|
-
});
|
|
266
|
-
const deps = makeDeps({
|
|
267
|
-
session: makeSession({
|
|
268
|
-
permissionManager: pm as unknown as PermissionManager,
|
|
269
|
-
lastPromptStateCacheKey: key,
|
|
270
|
-
}),
|
|
271
|
-
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
272
|
-
});
|
|
273
|
-
const result = await handleBeforeAgentStart(deps, makeEvent("hello"), ctx);
|
|
221
|
+
it("returns empty object when systemPrompt is unchanged", async () => {
|
|
222
|
+
const prompt = "No tools section here.";
|
|
223
|
+
const { handler } = makeHandler();
|
|
224
|
+
const result = await handler.handle(makeEvent(prompt), makeCtx());
|
|
274
225
|
expect(result).toEqual({});
|
|
275
|
-
// activeSkillEntries was not assigned by the handler (early return)
|
|
276
|
-
expect(deps.session.activeSkillEntries).toEqual([]);
|
|
277
226
|
});
|
|
278
227
|
});
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
5
5
|
import { describe, expect, it, vi } from "vitest";
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import type { HandlerDeps } from "../../src/handlers/types";
|
|
7
|
+
import { PermissionGateHandler } from "../../src/handlers/permission-gate-handler";
|
|
9
8
|
import type { PermissionDecisionEvent } from "../../src/permission-events";
|
|
10
9
|
import { PERMISSIONS_DECISION_CHANNEL } from "../../src/permission-events";
|
|
11
|
-
import type {
|
|
10
|
+
import type { PermissionSession } from "../../src/permission-session";
|
|
11
|
+
import type { ToolRegistry } from "../../src/tool-registry";
|
|
12
|
+
import type { PermissionState } from "../../src/types";
|
|
12
13
|
|
|
13
14
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
14
15
|
|
|
@@ -38,60 +39,60 @@ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
|
|
38
39
|
} as unknown as ExtensionContext;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function makeSession(
|
|
42
|
-
return {
|
|
43
|
-
runtimeContext: null,
|
|
44
|
-
permissionManager: {
|
|
45
|
-
checkPermission: vi.fn().mockReturnValue({
|
|
46
|
-
state,
|
|
47
|
-
toolName: "skill",
|
|
48
|
-
source: "skill",
|
|
49
|
-
origin: "global",
|
|
50
|
-
matchedPattern: "*",
|
|
51
|
-
}),
|
|
52
|
-
} as unknown as SessionState["permissionManager"],
|
|
53
|
-
activeSkillEntries: [],
|
|
54
|
-
lastKnownActiveAgentName: null,
|
|
55
|
-
lastActiveToolsCacheKey: null,
|
|
56
|
-
lastPromptStateCacheKey: null,
|
|
57
|
-
sessionRules: {
|
|
58
|
-
approve: vi.fn(),
|
|
59
|
-
getRuleset: vi.fn().mockReturnValue([]),
|
|
60
|
-
clear: vi.fn(),
|
|
61
|
-
} as unknown as SessionState["sessionRules"],
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function makeDeps(
|
|
42
|
+
function makeSession(
|
|
66
43
|
state: "allow" | "deny" | "ask" = "allow",
|
|
67
|
-
overrides: Partial<
|
|
68
|
-
):
|
|
44
|
+
overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
|
|
45
|
+
): PermissionSession {
|
|
69
46
|
return {
|
|
70
|
-
session: makeSession(state),
|
|
71
47
|
logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
|
|
72
|
-
|
|
73
|
-
getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
74
|
-
events: makeEvents(),
|
|
75
|
-
createPermissionManagerForCwd: vi.fn(),
|
|
76
|
-
refreshExtensionConfig: vi.fn(),
|
|
77
|
-
logResolvedConfigPaths: vi.fn(),
|
|
48
|
+
activate: vi.fn(),
|
|
78
49
|
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
50
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
51
|
+
state,
|
|
52
|
+
toolName: "skill",
|
|
53
|
+
source: "skill",
|
|
54
|
+
origin: "global",
|
|
55
|
+
matchedPattern: "*",
|
|
56
|
+
}),
|
|
57
|
+
getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
|
|
58
|
+
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
59
|
+
approveSessionRule: vi.fn(),
|
|
60
|
+
canPrompt: vi.fn().mockReturnValue(true),
|
|
61
|
+
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
83
62
|
createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
|
|
84
|
-
forwarding: { start: vi.fn(), stop: vi.fn() },
|
|
85
|
-
stopPermissionRpcHandlers: vi.fn(),
|
|
86
|
-
getAllTools: vi.fn().mockReturnValue([]),
|
|
87
|
-
setActiveTools: vi.fn(),
|
|
88
63
|
...overrides,
|
|
64
|
+
} as unknown as PermissionSession;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeToolRegistry(): ToolRegistry {
|
|
68
|
+
return {
|
|
69
|
+
getAll: vi.fn().mockReturnValue([]),
|
|
70
|
+
setActive: vi.fn(),
|
|
89
71
|
};
|
|
90
72
|
}
|
|
91
73
|
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
|
|
74
|
+
function makeHandler(
|
|
75
|
+
state: "allow" | "deny" | "ask" = "allow",
|
|
76
|
+
sessionOverrides: Partial<Record<keyof PermissionSession, unknown>> = {},
|
|
77
|
+
): {
|
|
78
|
+
handler: PermissionGateHandler;
|
|
79
|
+
events: ReturnType<typeof makeEvents>;
|
|
80
|
+
} {
|
|
81
|
+
const session = makeSession(state, sessionOverrides);
|
|
82
|
+
const events = makeEvents();
|
|
83
|
+
const handler = new PermissionGateHandler(
|
|
84
|
+
session,
|
|
85
|
+
events,
|
|
86
|
+
makeToolRegistry(),
|
|
87
|
+
);
|
|
88
|
+
return { handler, events };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Extract all permissions:decision payloads from the events.emit mock. */
|
|
92
|
+
function getDecisionEvents(
|
|
93
|
+
events: ReturnType<typeof makeEvents>,
|
|
94
|
+
): PermissionDecisionEvent[] {
|
|
95
|
+
return events.emit.mock.calls
|
|
95
96
|
.filter(([channel]) => channel === PERMISSIONS_DECISION_CHANNEL)
|
|
96
97
|
.map(([, payload]) => payload as PermissionDecisionEvent);
|
|
97
98
|
}
|
|
@@ -100,18 +101,18 @@ function getDecisionEvents(deps: HandlerDeps): PermissionDecisionEvent[] {
|
|
|
100
101
|
|
|
101
102
|
describe("handleInput decision events — skill gate", () => {
|
|
102
103
|
it("does not emit when input is not a skill invocation", async () => {
|
|
103
|
-
const
|
|
104
|
-
await handleInput(
|
|
105
|
-
expect(getDecisionEvents(
|
|
104
|
+
const { handler, events } = makeHandler();
|
|
105
|
+
await handler.handleInput({ text: "hello world" }, makeCtx());
|
|
106
|
+
expect(getDecisionEvents(events)).toHaveLength(0);
|
|
106
107
|
});
|
|
107
108
|
|
|
108
109
|
it("emits allow with policy_allow for an allowed skill", async () => {
|
|
109
|
-
const
|
|
110
|
-
await handleInput(
|
|
110
|
+
const { handler, events } = makeHandler("allow");
|
|
111
|
+
await handler.handleInput({ text: "/skill:librarian" }, makeCtx());
|
|
111
112
|
|
|
112
|
-
const
|
|
113
|
-
expect(
|
|
114
|
-
expect(
|
|
113
|
+
const decisions = getDecisionEvents(events);
|
|
114
|
+
expect(decisions).toHaveLength(1);
|
|
115
|
+
expect(decisions[0]).toMatchObject({
|
|
115
116
|
surface: "skill",
|
|
116
117
|
value: "librarian",
|
|
117
118
|
result: "allow",
|
|
@@ -120,12 +121,12 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
120
121
|
});
|
|
121
122
|
|
|
122
123
|
it("emits deny with policy_deny for a denied skill", async () => {
|
|
123
|
-
const
|
|
124
|
-
await handleInput(
|
|
124
|
+
const { handler, events } = makeHandler("deny");
|
|
125
|
+
await handler.handleInput({ text: "/skill:restricted" }, makeCtx());
|
|
125
126
|
|
|
126
|
-
const
|
|
127
|
-
expect(
|
|
128
|
-
expect(
|
|
127
|
+
const decisions = getDecisionEvents(events);
|
|
128
|
+
expect(decisions).toHaveLength(1);
|
|
129
|
+
expect(decisions[0]).toMatchObject({
|
|
129
130
|
surface: "skill",
|
|
130
131
|
value: "restricted",
|
|
131
132
|
result: "deny",
|
|
@@ -134,16 +135,14 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
134
135
|
});
|
|
135
136
|
|
|
136
137
|
it("emits allow with user_approved when state=ask and user approves", async () => {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
.fn()
|
|
140
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
138
|
+
const { handler, events } = makeHandler("ask", {
|
|
139
|
+
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
141
140
|
});
|
|
142
|
-
await handleInput(
|
|
141
|
+
await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
|
|
143
142
|
|
|
144
|
-
const
|
|
145
|
-
expect(
|
|
146
|
-
expect(
|
|
143
|
+
const decisions = getDecisionEvents(events);
|
|
144
|
+
expect(decisions).toHaveLength(1);
|
|
145
|
+
expect(decisions[0]).toMatchObject({
|
|
147
146
|
surface: "skill",
|
|
148
147
|
value: "explorer",
|
|
149
148
|
result: "allow",
|
|
@@ -152,16 +151,14 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
152
151
|
});
|
|
153
152
|
|
|
154
153
|
it("emits deny with user_denied when state=ask and user denies", async () => {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
.fn()
|
|
158
|
-
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
154
|
+
const { handler, events } = makeHandler("ask", {
|
|
155
|
+
prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
|
|
159
156
|
});
|
|
160
|
-
await handleInput(
|
|
157
|
+
await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
|
|
161
158
|
|
|
162
|
-
const
|
|
163
|
-
expect(
|
|
164
|
-
expect(
|
|
159
|
+
const decisions = getDecisionEvents(events);
|
|
160
|
+
expect(decisions).toHaveLength(1);
|
|
161
|
+
expect(decisions[0]).toMatchObject({
|
|
165
162
|
surface: "skill",
|
|
166
163
|
value: "explorer",
|
|
167
164
|
result: "deny",
|
|
@@ -170,18 +167,17 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
170
167
|
});
|
|
171
168
|
|
|
172
169
|
it("emits deny with confirmation_unavailable when state=ask but no UI", async () => {
|
|
173
|
-
const
|
|
174
|
-
|
|
170
|
+
const { handler, events } = makeHandler("ask", {
|
|
171
|
+
canPrompt: vi.fn().mockReturnValue(false),
|
|
175
172
|
});
|
|
176
|
-
await handleInput(
|
|
177
|
-
deps,
|
|
173
|
+
await handler.handleInput(
|
|
178
174
|
{ text: "/skill:explorer" },
|
|
179
175
|
makeCtx({ hasUI: false }),
|
|
180
176
|
);
|
|
181
177
|
|
|
182
|
-
const
|
|
183
|
-
expect(
|
|
184
|
-
expect(
|
|
178
|
+
const decisions = getDecisionEvents(events);
|
|
179
|
+
expect(decisions).toHaveLength(1);
|
|
180
|
+
expect(decisions[0]).toMatchObject({
|
|
185
181
|
surface: "skill",
|
|
186
182
|
value: "explorer",
|
|
187
183
|
result: "deny",
|
|
@@ -189,20 +185,19 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
189
185
|
});
|
|
190
186
|
});
|
|
191
187
|
|
|
192
|
-
it("emits allow with auto_approved when
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
promptPermission: vi.fn().mockResolvedValue({
|
|
188
|
+
it("emits allow with auto_approved when prompt returns autoApproved:true", async () => {
|
|
189
|
+
const { handler, events } = makeHandler("ask", {
|
|
190
|
+
prompt: vi.fn().mockResolvedValue({
|
|
196
191
|
approved: true,
|
|
197
192
|
state: "approved",
|
|
198
193
|
autoApproved: true,
|
|
199
194
|
}),
|
|
200
195
|
});
|
|
201
|
-
await handleInput(
|
|
196
|
+
await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
|
|
202
197
|
|
|
203
|
-
const
|
|
204
|
-
expect(
|
|
205
|
-
expect(
|
|
198
|
+
const decisions = getDecisionEvents(events);
|
|
199
|
+
expect(decisions).toHaveLength(1);
|
|
200
|
+
expect(decisions[0]).toMatchObject({
|
|
206
201
|
surface: "skill",
|
|
207
202
|
value: "explorer",
|
|
208
203
|
result: "allow",
|