@gotgenes/pi-permission-system 3.6.0 → 3.7.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.
@@ -0,0 +1,274 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import {
5
+ handleBeforeAgentStart,
6
+ shouldExposeTool,
7
+ } from "../../src/handlers/before-agent-start";
8
+ import type { HandlerDeps } from "../../src/handlers/types";
9
+ import type { PermissionManager } from "../../src/permission-manager";
10
+ import type { SkillPromptEntry } from "../../src/skill-prompt-sanitizer";
11
+
12
+ // ── SDK stubs ──────────────────────────────────────────────────────────────
13
+ vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
14
+ const original =
15
+ await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
16
+ return {
17
+ ...original,
18
+ isToolCallEventType: vi.fn().mockReturnValue(false),
19
+ };
20
+ });
21
+
22
+ // ── helpers ────────────────────────────────────────────────────────────────
23
+
24
+ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
25
+ return {
26
+ cwd: "/test/project",
27
+ hasUI: true,
28
+ ui: {
29
+ setStatus: vi.fn(),
30
+ notify: vi.fn(),
31
+ select: vi.fn(),
32
+ input: vi.fn(),
33
+ },
34
+ sessionManager: {
35
+ getEntries: vi.fn().mockReturnValue([]),
36
+ getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
37
+ addEntry: vi.fn(),
38
+ },
39
+ ...overrides,
40
+ } as unknown as ExtensionContext;
41
+ }
42
+
43
+ function makeEvent(systemPrompt = "You are an assistant.") {
44
+ return { systemPrompt };
45
+ }
46
+
47
+ /** Minimal PermissionManager stub for shouldExposeTool / policy-cache tests. */
48
+ function makePm(
49
+ toolPermission: "allow" | "deny" | "ask" = "allow",
50
+ ): PermissionManager {
51
+ return {
52
+ getToolPermission: vi.fn().mockReturnValue(toolPermission),
53
+ getPolicyCacheStamp: vi.fn().mockReturnValue("stamp-1"),
54
+ getConfigIssues: vi.fn().mockReturnValue([]),
55
+ checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
56
+ } as unknown as PermissionManager;
57
+ }
58
+
59
+ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
60
+ const pm = makePm();
61
+ return {
62
+ getPermissionManager: vi.fn().mockReturnValue(pm),
63
+ setPermissionManager: vi.fn(),
64
+ getRuntimeContext: vi.fn().mockReturnValue(null),
65
+ setRuntimeContext: vi.fn(),
66
+ getActiveSkillEntries: vi.fn().mockReturnValue([] as SkillPromptEntry[]),
67
+ setActiveSkillEntries: vi.fn(),
68
+ getLastKnownActiveAgentName: vi.fn().mockReturnValue(null),
69
+ setLastKnownActiveAgentName: vi.fn(),
70
+ getLastActiveToolsCacheKey: vi.fn().mockReturnValue(null),
71
+ setLastActiveToolsCacheKey: vi.fn(),
72
+ getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
73
+ setLastPromptStateCacheKey: vi.fn(),
74
+ sessionApprovalCache: {
75
+ approve: vi.fn(),
76
+ has: vi.fn(),
77
+ findMatchingPrefix: vi.fn(),
78
+ clear: vi.fn(),
79
+ } as unknown as HandlerDeps["sessionApprovalCache"],
80
+ createPermissionManagerForCwd: vi.fn().mockReturnValue(makePm()),
81
+ refreshExtensionConfig: vi.fn(),
82
+ notifyWarning: vi.fn(),
83
+ logResolvedConfigPaths: vi.fn(),
84
+ resolveAgentName: vi.fn().mockReturnValue(null),
85
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
86
+ promptPermission: vi
87
+ .fn()
88
+ .mockResolvedValue({ approved: true, state: "approved" }),
89
+ createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
90
+ startForwardedPermissionPolling: vi.fn(),
91
+ stopForwardedPermissionPolling: vi.fn(),
92
+ writeReviewLog: vi.fn(),
93
+ writeDebugLog: vi.fn(),
94
+ getAllTools: vi.fn().mockReturnValue([]),
95
+ setActiveTools: vi.fn(),
96
+ ...overrides,
97
+ };
98
+ }
99
+
100
+ // ── shouldExposeTool (pure helper) ─────────────────────────────────────────
101
+
102
+ describe("shouldExposeTool", () => {
103
+ it("returns true when tool permission is allow", () => {
104
+ const pm = makePm("allow");
105
+ expect(shouldExposeTool("read", null, pm)).toBe(true);
106
+ });
107
+
108
+ it("returns true when tool permission is ask", () => {
109
+ const pm = makePm("ask");
110
+ expect(shouldExposeTool("bash", "agent-x", pm)).toBe(true);
111
+ });
112
+
113
+ it("returns false when tool permission is deny", () => {
114
+ const pm = makePm("deny");
115
+ expect(shouldExposeTool("write", null, pm)).toBe(false);
116
+ });
117
+
118
+ it("passes agentName through to getToolPermission", () => {
119
+ const pm = makePm("allow");
120
+ shouldExposeTool("read", "my-agent", pm);
121
+ expect(pm.getToolPermission).toHaveBeenCalledWith("read", "my-agent");
122
+ });
123
+
124
+ it("converts null agentName to undefined for getToolPermission", () => {
125
+ const pm = makePm("allow");
126
+ shouldExposeTool("read", null, pm);
127
+ expect(pm.getToolPermission).toHaveBeenCalledWith("read", undefined);
128
+ });
129
+ });
130
+
131
+ // ── handleBeforeAgentStart ─────────────────────────────────────────────────
132
+
133
+ describe("handleBeforeAgentStart", () => {
134
+ it("refreshes extension config with ctx", async () => {
135
+ const ctx = makeCtx();
136
+ const deps = makeDeps();
137
+ await handleBeforeAgentStart(deps, makeEvent(), ctx);
138
+ expect(deps.refreshExtensionConfig).toHaveBeenCalledWith(ctx);
139
+ });
140
+
141
+ it("starts forwarded permission polling", async () => {
142
+ const ctx = makeCtx();
143
+ const deps = makeDeps();
144
+ await handleBeforeAgentStart(deps, makeEvent(), ctx);
145
+ expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
146
+ });
147
+
148
+ it("resolves agent name using systemPrompt", async () => {
149
+ const ctx = makeCtx();
150
+ const deps = makeDeps();
151
+ await handleBeforeAgentStart(
152
+ deps,
153
+ makeEvent("<active_agent name='x'>"),
154
+ ctx,
155
+ );
156
+ expect(deps.resolveAgentName).toHaveBeenCalledWith(
157
+ ctx,
158
+ "<active_agent name='x'>",
159
+ );
160
+ });
161
+
162
+ it("filters out denied tools from allowed list", async () => {
163
+ const pm = makePm("deny");
164
+ const deps = makeDeps({
165
+ getPermissionManager: vi.fn().mockReturnValue(pm),
166
+ getAllTools: vi
167
+ .fn()
168
+ .mockReturnValue([{ name: "write" }, { name: "read" }]),
169
+ });
170
+ // write is deny, read is deny (same pm stub — both denied)
171
+ await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
172
+ expect(deps.setActiveTools).toHaveBeenCalledWith([]);
173
+ });
174
+
175
+ it("includes allowed and ask tools in the active list", async () => {
176
+ const pm = makePm("allow");
177
+ const deps = makeDeps({
178
+ getPermissionManager: vi.fn().mockReturnValue(pm),
179
+ getAllTools: vi
180
+ .fn()
181
+ .mockReturnValue([{ name: "read" }, { name: "write" }]),
182
+ });
183
+ await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
184
+ expect(deps.setActiveTools).toHaveBeenCalledWith(["read", "write"]);
185
+ });
186
+
187
+ it("updates the active-tools cache key after applying", async () => {
188
+ const deps = makeDeps({
189
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
190
+ getLastActiveToolsCacheKey: vi.fn().mockReturnValue(null),
191
+ });
192
+ await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
193
+ expect(deps.setLastActiveToolsCacheKey).toHaveBeenCalledOnce();
194
+ });
195
+
196
+ it("skips setActiveTools when cache key is unchanged", async () => {
197
+ // Pre-populate the cache key to match what would be computed for ["read"]
198
+ const { createActiveToolsCacheKey } = await import(
199
+ "../../src/before-agent-start-cache"
200
+ );
201
+ const key = createActiveToolsCacheKey(["read"]);
202
+ const deps = makeDeps({
203
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
204
+ getLastActiveToolsCacheKey: vi.fn().mockReturnValue(key),
205
+ });
206
+ await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
207
+ expect(deps.setActiveTools).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it("updates the prompt-state cache key and returns modified systemPrompt", async () => {
211
+ // Provide a systemPrompt that sanitizeAvailableToolsSection will modify:
212
+ // it strips denied tools from the "Available tools:" section.
213
+ const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
214
+ const deps = makeDeps({
215
+ getAllTools: vi.fn().mockReturnValue([]),
216
+ getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
217
+ });
218
+ const result = await handleBeforeAgentStart(
219
+ deps,
220
+ makeEvent(systemPrompt),
221
+ makeCtx(),
222
+ );
223
+ // The prompt was modified, so systemPrompt should be returned
224
+ expect(result).toHaveProperty("systemPrompt");
225
+ expect(deps.setLastPromptStateCacheKey).toHaveBeenCalledOnce();
226
+ });
227
+
228
+ it("returns empty object when systemPrompt is unchanged", async () => {
229
+ const prompt = "No tools section here.";
230
+ const deps = makeDeps({
231
+ getAllTools: vi.fn().mockReturnValue([]),
232
+ getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
233
+ });
234
+ const result = await handleBeforeAgentStart(
235
+ deps,
236
+ makeEvent(prompt),
237
+ makeCtx(),
238
+ );
239
+ expect(result).toEqual({});
240
+ });
241
+
242
+ it("stores resolved skill entries on deps", async () => {
243
+ const deps = makeDeps({
244
+ getAllTools: vi.fn().mockReturnValue([]),
245
+ getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
246
+ });
247
+ await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
248
+ expect(deps.setActiveSkillEntries).toHaveBeenCalledOnce();
249
+ });
250
+
251
+ it("returns empty object and skips prompt work when prompt cache key is unchanged", async () => {
252
+ const { createBeforeAgentStartPromptStateKey } = await import(
253
+ "../../src/before-agent-start-cache"
254
+ );
255
+ const pm = makePm("allow");
256
+ const ctx = makeCtx({ cwd: "/proj" });
257
+ const allowedTools: string[] = ["read"];
258
+ const key = createBeforeAgentStartPromptStateKey({
259
+ agentName: null,
260
+ cwd: "/proj",
261
+ permissionStamp: "stamp-1",
262
+ systemPrompt: "hello",
263
+ allowedToolNames: allowedTools,
264
+ });
265
+ const deps = makeDeps({
266
+ getPermissionManager: vi.fn().mockReturnValue(pm),
267
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
268
+ getLastPromptStateCacheKey: vi.fn().mockReturnValue(key),
269
+ });
270
+ const result = await handleBeforeAgentStart(deps, makeEvent("hello"), ctx);
271
+ expect(result).toEqual({});
272
+ expect(deps.setActiveSkillEntries).not.toHaveBeenCalled();
273
+ });
274
+ });
@@ -0,0 +1,271 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import {
5
+ extractSkillNameFromInput,
6
+ handleInput,
7
+ } from "../../src/handlers/input";
8
+ import type { HandlerDeps } from "../../src/handlers/types";
9
+ import type { SkillPromptEntry } from "../../src/skill-prompt-sanitizer";
10
+
11
+ // ── helpers ────────────────────────────────────────────────────────────────
12
+
13
+ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
14
+ return {
15
+ cwd: "/test/project",
16
+ hasUI: true,
17
+ ui: {
18
+ setStatus: vi.fn(),
19
+ notify: vi.fn(),
20
+ select: vi.fn(),
21
+ input: vi.fn(),
22
+ },
23
+ sessionManager: {
24
+ getEntries: vi.fn().mockReturnValue([]),
25
+ getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
26
+ addEntry: vi.fn(),
27
+ },
28
+ ...overrides,
29
+ } as unknown as ExtensionContext;
30
+ }
31
+
32
+ function makeInputEvent(text: string) {
33
+ return { text };
34
+ }
35
+
36
+ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
37
+ return {
38
+ getPermissionManager: vi.fn().mockReturnValue({
39
+ checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
40
+ getConfigIssues: vi.fn().mockReturnValue([]),
41
+ }),
42
+ setPermissionManager: vi.fn(),
43
+ getRuntimeContext: vi.fn().mockReturnValue(null),
44
+ setRuntimeContext: vi.fn(),
45
+ getActiveSkillEntries: vi.fn().mockReturnValue([] as SkillPromptEntry[]),
46
+ setActiveSkillEntries: vi.fn(),
47
+ getLastKnownActiveAgentName: vi.fn().mockReturnValue(null),
48
+ setLastKnownActiveAgentName: vi.fn(),
49
+ getLastActiveToolsCacheKey: vi.fn().mockReturnValue(null),
50
+ setLastActiveToolsCacheKey: vi.fn(),
51
+ getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
52
+ setLastPromptStateCacheKey: vi.fn(),
53
+ sessionApprovalCache: {
54
+ approve: vi.fn(),
55
+ has: vi.fn(),
56
+ findMatchingPrefix: vi.fn(),
57
+ clear: vi.fn(),
58
+ } as unknown as HandlerDeps["sessionApprovalCache"],
59
+ createPermissionManagerForCwd: vi.fn(),
60
+ refreshExtensionConfig: vi.fn(),
61
+ notifyWarning: vi.fn(),
62
+ logResolvedConfigPaths: vi.fn(),
63
+ resolveAgentName: vi.fn().mockReturnValue(null),
64
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
65
+ promptPermission: vi
66
+ .fn()
67
+ .mockResolvedValue({ approved: true, state: "approved" }),
68
+ createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
69
+ startForwardedPermissionPolling: vi.fn(),
70
+ stopForwardedPermissionPolling: vi.fn(),
71
+ writeReviewLog: vi.fn(),
72
+ writeDebugLog: vi.fn(),
73
+ getAllTools: vi.fn().mockReturnValue([]),
74
+ setActiveTools: vi.fn(),
75
+ ...overrides,
76
+ };
77
+ }
78
+
79
+ // ── extractSkillNameFromInput ──────────────────────────────────────────────
80
+
81
+ describe("extractSkillNameFromInput", () => {
82
+ it("returns null for plain text", () => {
83
+ expect(extractSkillNameFromInput("hello world")).toBeNull();
84
+ });
85
+
86
+ it("returns null for empty string", () => {
87
+ expect(extractSkillNameFromInput("")).toBeNull();
88
+ });
89
+
90
+ it("returns null for bare /skill: with no name", () => {
91
+ expect(extractSkillNameFromInput("/skill:")).toBeNull();
92
+ });
93
+
94
+ it("extracts skill name from /skill:<name>", () => {
95
+ expect(extractSkillNameFromInput("/skill:librarian")).toBe("librarian");
96
+ });
97
+
98
+ it("extracts skill name stopping at whitespace", () => {
99
+ expect(extractSkillNameFromInput("/skill:librarian some extra")).toBe(
100
+ "librarian",
101
+ );
102
+ });
103
+
104
+ it("trims leading whitespace before the prefix", () => {
105
+ expect(extractSkillNameFromInput(" /skill:my-skill")).toBe("my-skill");
106
+ });
107
+
108
+ it("returns null when the skill name after trimming is empty", () => {
109
+ expect(extractSkillNameFromInput("/skill: ")).toBeNull();
110
+ });
111
+ });
112
+
113
+ // ── handleInput ───────────────────────────────────────────────────────────
114
+
115
+ describe("handleInput", () => {
116
+ it("sets runtime context", async () => {
117
+ const ctx = makeCtx();
118
+ const deps = makeDeps();
119
+ await handleInput(deps, makeInputEvent("hello"), ctx);
120
+ expect(deps.setRuntimeContext).toHaveBeenCalledWith(ctx);
121
+ });
122
+
123
+ it("starts forwarded permission polling", async () => {
124
+ const ctx = makeCtx();
125
+ const deps = makeDeps();
126
+ await handleInput(deps, makeInputEvent("hello"), ctx);
127
+ expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
128
+ });
129
+
130
+ it("returns continue for non-skill input", async () => {
131
+ const deps = makeDeps();
132
+ const result = await handleInput(
133
+ deps,
134
+ makeInputEvent("just a message"),
135
+ makeCtx(),
136
+ );
137
+ expect(result).toEqual({ action: "continue" });
138
+ });
139
+
140
+ it("does not check permissions for non-skill input", async () => {
141
+ const deps = makeDeps();
142
+ await handleInput(deps, makeInputEvent("just a message"), makeCtx());
143
+ expect(deps.getPermissionManager().checkPermission).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it("returns continue when skill is allowed", async () => {
147
+ const deps = makeDeps({
148
+ getPermissionManager: vi.fn().mockReturnValue({
149
+ checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
150
+ }),
151
+ });
152
+ const result = await handleInput(
153
+ deps,
154
+ makeInputEvent("/skill:librarian"),
155
+ makeCtx(),
156
+ );
157
+ expect(result).toEqual({ action: "continue" });
158
+ });
159
+
160
+ it("returns handled when skill is denied", async () => {
161
+ const deps = makeDeps({
162
+ getPermissionManager: vi.fn().mockReturnValue({
163
+ checkPermission: vi.fn().mockReturnValue({ state: "deny" }),
164
+ }),
165
+ });
166
+ const result = await handleInput(
167
+ deps,
168
+ makeInputEvent("/skill:librarian"),
169
+ makeCtx(),
170
+ );
171
+ expect(result).toEqual({ action: "handled" });
172
+ });
173
+
174
+ it("shows a warning notification when skill is denied and UI is available", async () => {
175
+ const ctx = makeCtx({ hasUI: true });
176
+ const deps = makeDeps({
177
+ getPermissionManager: vi.fn().mockReturnValue({
178
+ checkPermission: vi.fn().mockReturnValue({ state: "deny" }),
179
+ }),
180
+ });
181
+ await handleInput(deps, makeInputEvent("/skill:librarian"), ctx);
182
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
183
+ expect.stringContaining("librarian"),
184
+ "warning",
185
+ );
186
+ });
187
+
188
+ it("does not show a warning notification when skill is denied and UI is absent", async () => {
189
+ const ctx = makeCtx({ hasUI: false });
190
+ const deps = makeDeps({
191
+ getPermissionManager: vi.fn().mockReturnValue({
192
+ checkPermission: vi.fn().mockReturnValue({ state: "deny" }),
193
+ }),
194
+ });
195
+ await handleInput(deps, makeInputEvent("/skill:librarian"), ctx);
196
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it("returns handled when skill requires approval but no UI is available", async () => {
200
+ const deps = makeDeps({
201
+ getPermissionManager: vi.fn().mockReturnValue({
202
+ checkPermission: vi.fn().mockReturnValue({ state: "ask" }),
203
+ }),
204
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
205
+ });
206
+ const result = await handleInput(
207
+ deps,
208
+ makeInputEvent("/skill:librarian"),
209
+ makeCtx(),
210
+ );
211
+ expect(result).toEqual({ action: "handled" });
212
+ });
213
+
214
+ it("prompts and returns continue when skill ask is approved", async () => {
215
+ const deps = makeDeps({
216
+ getPermissionManager: vi.fn().mockReturnValue({
217
+ checkPermission: vi.fn().mockReturnValue({ state: "ask" }),
218
+ }),
219
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
220
+ promptPermission: vi
221
+ .fn()
222
+ .mockResolvedValue({ approved: true, state: "approved" }),
223
+ });
224
+ const result = await handleInput(
225
+ deps,
226
+ makeInputEvent("/skill:librarian"),
227
+ makeCtx(),
228
+ );
229
+ expect(result).toEqual({ action: "continue" });
230
+ expect(deps.promptPermission).toHaveBeenCalledOnce();
231
+ });
232
+
233
+ it("returns handled when skill ask is denied by user", async () => {
234
+ const deps = makeDeps({
235
+ getPermissionManager: vi.fn().mockReturnValue({
236
+ checkPermission: vi.fn().mockReturnValue({ state: "ask" }),
237
+ }),
238
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
239
+ promptPermission: vi
240
+ .fn()
241
+ .mockResolvedValue({ approved: false, state: "denied" }),
242
+ });
243
+ const result = await handleInput(
244
+ deps,
245
+ makeInputEvent("/skill:librarian"),
246
+ makeCtx(),
247
+ );
248
+ expect(result).toEqual({ action: "handled" });
249
+ });
250
+
251
+ it("passes agentName in the prompt permission request", async () => {
252
+ const deps = makeDeps({
253
+ getPermissionManager: vi.fn().mockReturnValue({
254
+ checkPermission: vi.fn().mockReturnValue({ state: "ask" }),
255
+ }),
256
+ resolveAgentName: vi.fn().mockReturnValue("code-agent"),
257
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
258
+ promptPermission: vi
259
+ .fn()
260
+ .mockResolvedValue({ approved: true, state: "approved" }),
261
+ });
262
+ await handleInput(deps, makeInputEvent("/skill:librarian"), makeCtx());
263
+ expect(deps.promptPermission).toHaveBeenCalledWith(
264
+ expect.anything(),
265
+ expect.objectContaining({
266
+ agentName: "code-agent",
267
+ skillName: "librarian",
268
+ }),
269
+ );
270
+ });
271
+ });