@gotgenes/pi-permission-system 5.3.4 → 5.5.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,320 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { evaluateExternalDirectoryGate } from "../../../src/handlers/gates/external-directory";
4
+ import type { ToolCallContext } from "../../../src/handlers/gates/types";
5
+ import type { HandlerDeps } from "../../../src/handlers/types";
6
+ import type { PermissionEventBus } from "../../../src/permission-events";
7
+ import type { PermissionCheckResult } from "../../../src/types";
8
+
9
+ // ── helpers ────────────────────────────────────────────────────────────────
10
+
11
+ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
12
+ return {
13
+ toolName: "read",
14
+ agentName: null,
15
+ input: { path: "/outside/project/file.ts" },
16
+ toolCallId: "tc-1",
17
+ cwd: "/test/project",
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ function makeCheckResult(
23
+ state: "allow" | "deny" | "ask",
24
+ overrides: Partial<PermissionCheckResult> = {},
25
+ ): PermissionCheckResult {
26
+ return {
27
+ state,
28
+ toolName: "external_directory",
29
+ source: "special",
30
+ origin: "builtin",
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ function makeEvents(): PermissionEventBus {
36
+ return { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) };
37
+ }
38
+
39
+ function makeRuntime(
40
+ overrides: Record<string, unknown> = {},
41
+ ): HandlerDeps["runtime"] {
42
+ return {
43
+ piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
44
+ config: { debugLog: false, permissionReviewLog: true, yoloMode: false },
45
+ runtimeContext: {} as HandlerDeps["runtime"]["runtimeContext"],
46
+ permissionManager: {
47
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
48
+ },
49
+ sessionRules: {
50
+ approve: vi.fn(),
51
+ getRuleset: vi.fn().mockReturnValue([]),
52
+ clear: vi.fn(),
53
+ },
54
+ writeReviewLog: vi.fn(),
55
+ ...overrides,
56
+ } as unknown as HandlerDeps["runtime"];
57
+ }
58
+
59
+ function makeDeps(overrides: Record<string, unknown> = {}): HandlerDeps {
60
+ const { runtime: runtimeOverrides, events, ...rest } = overrides;
61
+ return {
62
+ runtime: makeRuntime(runtimeOverrides as Record<string, unknown>),
63
+ events: events ?? makeEvents(),
64
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
65
+ promptPermission: vi
66
+ .fn()
67
+ .mockResolvedValue({ approved: true, state: "approved" }),
68
+ ...rest,
69
+ } as unknown as HandlerDeps;
70
+ }
71
+
72
+ // ── tests ──────────────────────────────────────────────────────────────────
73
+
74
+ describe("evaluateExternalDirectoryGate", () => {
75
+ it("returns null when no CWD", async () => {
76
+ const tcc = makeTcc({ cwd: undefined });
77
+ const result = await evaluateExternalDirectoryGate(tcc, makeDeps());
78
+ expect(result).toBeNull();
79
+ });
80
+
81
+ it("returns null when tool is not path-bearing", async () => {
82
+ const tcc = makeTcc({ toolName: "bash", input: { command: "ls" } });
83
+ const result = await evaluateExternalDirectoryGate(tcc, makeDeps());
84
+ expect(result).toBeNull();
85
+ });
86
+
87
+ it("returns null when path is inside CWD", async () => {
88
+ const tcc = makeTcc({ input: { path: "/test/project/src/index.ts" } });
89
+ const result = await evaluateExternalDirectoryGate(tcc, makeDeps());
90
+ expect(result).toBeNull();
91
+ });
92
+
93
+ // ── Pi infrastructure read bypass ──────────────────────────────────────
94
+
95
+ it("allows and emits infrastructure_auto_allowed for read targeting infra dir", async () => {
96
+ const events = makeEvents();
97
+ const deps = makeDeps({ events });
98
+ const tcc = makeTcc({
99
+ toolName: "read",
100
+ input: { path: "/test/agent/git/some-package/SKILL.md" },
101
+ });
102
+ const result = await evaluateExternalDirectoryGate(tcc, deps);
103
+ expect(result).toEqual({ action: "allow" });
104
+ expect(events.emit).toHaveBeenCalledWith(
105
+ "permissions:decision",
106
+ expect.objectContaining({
107
+ resolution: "infrastructure_auto_allowed",
108
+ result: "allow",
109
+ }),
110
+ );
111
+ });
112
+
113
+ it("respects config.piInfrastructureReadPaths for bypass", async () => {
114
+ const events = makeEvents();
115
+ const deps = makeDeps({
116
+ runtime: {
117
+ piInfrastructureDirs: [],
118
+ config: {
119
+ debugLog: false,
120
+ permissionReviewLog: true,
121
+ yoloMode: false,
122
+ piInfrastructureReadPaths: ["/custom/infra"],
123
+ },
124
+ },
125
+ events,
126
+ });
127
+ const tcc = makeTcc({
128
+ toolName: "read",
129
+ input: { path: "/custom/infra/SKILL.md" },
130
+ });
131
+ const result = await evaluateExternalDirectoryGate(tcc, deps);
132
+ expect(result).toEqual({ action: "allow" });
133
+ });
134
+
135
+ it("does NOT bypass for write tools targeting infra dirs", async () => {
136
+ const deps = makeDeps({
137
+ runtime: {
138
+ permissionManager: {
139
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("deny")),
140
+ },
141
+ },
142
+ });
143
+ const tcc = makeTcc({
144
+ toolName: "write",
145
+ input: { path: "/test/agent/git/some-file.ts", content: "x" },
146
+ });
147
+ const result = await evaluateExternalDirectoryGate(tcc, deps);
148
+ expect(result).toMatchObject({ action: "block" });
149
+ });
150
+
151
+ // ── Session-rule hit ─────────────────────────────────────────────────────
152
+
153
+ it("allows and emits session_approved when session rule covers the path", async () => {
154
+ const events = makeEvents();
155
+ const deps = makeDeps({
156
+ runtime: {
157
+ permissionManager: {
158
+ checkPermission: vi.fn().mockReturnValue(
159
+ makeCheckResult("allow", {
160
+ source: "session",
161
+ matchedPattern: "/outside/project/*",
162
+ }),
163
+ ),
164
+ },
165
+ sessionRules: {
166
+ approve: vi.fn(),
167
+ getRuleset: vi.fn().mockReturnValue([
168
+ {
169
+ surface: "external_directory",
170
+ pattern: "/outside/project/*",
171
+ action: "allow",
172
+ },
173
+ ]),
174
+ clear: vi.fn(),
175
+ },
176
+ },
177
+ events,
178
+ });
179
+ const tcc = makeTcc();
180
+ const result = await evaluateExternalDirectoryGate(tcc, deps);
181
+ expect(result).toEqual({ action: "allow" });
182
+ expect(events.emit).toHaveBeenCalledWith(
183
+ "permissions:decision",
184
+ expect.objectContaining({
185
+ resolution: "session_approved",
186
+ matchedPattern: "/outside/project/*",
187
+ }),
188
+ );
189
+ });
190
+
191
+ // ── Policy deny ──────────────────────────────────────────────────────────
192
+
193
+ it("blocks and emits policy_deny when policy is deny", async () => {
194
+ const events = makeEvents();
195
+ const deps = makeDeps({
196
+ runtime: {
197
+ permissionManager: {
198
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("deny")),
199
+ },
200
+ },
201
+ events,
202
+ });
203
+ const tcc = makeTcc();
204
+ const result = await evaluateExternalDirectoryGate(tcc, deps);
205
+ expect(result).toMatchObject({ action: "block" });
206
+ expect(events.emit).toHaveBeenCalledWith(
207
+ "permissions:decision",
208
+ expect.objectContaining({
209
+ surface: "external_directory",
210
+ result: "deny",
211
+ resolution: "policy_deny",
212
+ }),
213
+ );
214
+ });
215
+
216
+ // ── Policy ask — user approves once ──────────────────────────────────────
217
+
218
+ it("allows without recording session rule when user approves once", async () => {
219
+ const sessionRules = {
220
+ approve: vi.fn(),
221
+ getRuleset: vi.fn().mockReturnValue([]),
222
+ clear: vi.fn(),
223
+ };
224
+ const deps = makeDeps({
225
+ runtime: {
226
+ permissionManager: {
227
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
228
+ },
229
+ sessionRules,
230
+ },
231
+ promptPermission: vi
232
+ .fn()
233
+ .mockResolvedValue({ approved: true, state: "approved" }),
234
+ });
235
+ const tcc = makeTcc();
236
+ const result = await evaluateExternalDirectoryGate(tcc, deps);
237
+ expect(result).toEqual({ action: "allow" });
238
+ expect(sessionRules.approve).not.toHaveBeenCalled();
239
+ });
240
+
241
+ // ── Policy ask — user approves for session ───────────────────────────────
242
+
243
+ it("records session rule when user approves for session", async () => {
244
+ const sessionRules = {
245
+ approve: vi.fn(),
246
+ getRuleset: vi.fn().mockReturnValue([]),
247
+ clear: vi.fn(),
248
+ };
249
+ const deps = makeDeps({
250
+ runtime: {
251
+ permissionManager: {
252
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
253
+ },
254
+ sessionRules,
255
+ },
256
+ promptPermission: vi
257
+ .fn()
258
+ .mockResolvedValue({ approved: true, state: "approved_for_session" }),
259
+ });
260
+ const tcc = makeTcc();
261
+ const result = await evaluateExternalDirectoryGate(tcc, deps);
262
+ expect(result).toEqual({ action: "allow" });
263
+ expect(sessionRules.approve).toHaveBeenCalledWith(
264
+ "external_directory",
265
+ expect.any(String),
266
+ );
267
+ });
268
+
269
+ // ── Policy ask — user denies ─────────────────────────────────────────────
270
+
271
+ it("blocks and emits user_denied when user denies", async () => {
272
+ const events = makeEvents();
273
+ const deps = makeDeps({
274
+ runtime: {
275
+ permissionManager: {
276
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
277
+ },
278
+ },
279
+ events,
280
+ promptPermission: vi
281
+ .fn()
282
+ .mockResolvedValue({ approved: false, state: "denied" }),
283
+ });
284
+ const tcc = makeTcc();
285
+ const result = await evaluateExternalDirectoryGate(tcc, deps);
286
+ expect(result).toMatchObject({ action: "block" });
287
+ expect(events.emit).toHaveBeenCalledWith(
288
+ "permissions:decision",
289
+ expect.objectContaining({
290
+ result: "deny",
291
+ resolution: "user_denied",
292
+ }),
293
+ );
294
+ });
295
+
296
+ // ── Policy ask — no UI ───────────────────────────────────────────────────
297
+
298
+ it("blocks and emits confirmation_unavailable when no UI", async () => {
299
+ const events = makeEvents();
300
+ const deps = makeDeps({
301
+ runtime: {
302
+ permissionManager: {
303
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
304
+ },
305
+ },
306
+ events,
307
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
308
+ });
309
+ const tcc = makeTcc();
310
+ const result = await evaluateExternalDirectoryGate(tcc, deps);
311
+ expect(result).toMatchObject({ action: "block" });
312
+ expect(events.emit).toHaveBeenCalledWith(
313
+ "permissions:decision",
314
+ expect.objectContaining({
315
+ result: "deny",
316
+ resolution: "confirmation_unavailable",
317
+ }),
318
+ );
319
+ });
320
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ deriveDecisionValue,
5
+ deriveResolution,
6
+ } from "../../../src/handlers/gates/helpers";
7
+
8
+ describe("deriveDecisionValue", () => {
9
+ it("returns command for bash", () => {
10
+ expect(deriveDecisionValue("bash", { command: "git status" })).toBe(
11
+ "git status",
12
+ );
13
+ });
14
+
15
+ it("falls back to toolName when bash has no command", () => {
16
+ expect(deriveDecisionValue("bash", {})).toBe("bash");
17
+ });
18
+
19
+ it("returns target for mcp", () => {
20
+ expect(deriveDecisionValue("mcp", { target: "exa:search" })).toBe(
21
+ "exa:search",
22
+ );
23
+ });
24
+
25
+ it("falls back to toolName when mcp has no target", () => {
26
+ expect(deriveDecisionValue("mcp", {})).toBe("mcp");
27
+ });
28
+
29
+ it("returns toolName for other tools", () => {
30
+ expect(deriveDecisionValue("read", {})).toBe("read");
31
+ expect(deriveDecisionValue("write", { command: "ignored" })).toBe("write");
32
+ });
33
+ });
34
+
35
+ describe("deriveResolution", () => {
36
+ it("returns policy_allow for allow state", () => {
37
+ expect(deriveResolution("allow", "allow", false, true)).toBe(
38
+ "policy_allow",
39
+ );
40
+ });
41
+
42
+ it("returns policy_deny for deny state", () => {
43
+ expect(deriveResolution("deny", "block", false, true)).toBe("policy_deny");
44
+ });
45
+
46
+ it("returns user_approved for ask + allow without session", () => {
47
+ expect(deriveResolution("ask", "allow", false, true)).toBe("user_approved");
48
+ });
49
+
50
+ it("returns user_approved_for_session for ask + allow with session", () => {
51
+ expect(deriveResolution("ask", "allow", true, true)).toBe(
52
+ "user_approved_for_session",
53
+ );
54
+ });
55
+
56
+ it("returns auto_approved when autoApproved flag is set", () => {
57
+ expect(deriveResolution("ask", "allow", false, true, true)).toBe(
58
+ "auto_approved",
59
+ );
60
+ });
61
+
62
+ it("returns user_denied for ask + block with canConfirm", () => {
63
+ expect(deriveResolution("ask", "block", false, true)).toBe("user_denied");
64
+ });
65
+
66
+ it("returns confirmation_unavailable for ask + block without canConfirm", () => {
67
+ expect(deriveResolution("ask", "block", false, false)).toBe(
68
+ "confirmation_unavailable",
69
+ );
70
+ });
71
+ });
@@ -0,0 +1,204 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { evaluateSkillReadGate } from "../../../src/handlers/gates/skill-read";
4
+ import type { ToolCallContext } from "../../../src/handlers/gates/types";
5
+ import type { HandlerDeps } from "../../../src/handlers/types";
6
+ import type { PermissionEventBus } from "../../../src/permission-events";
7
+ import type { SkillPromptEntry } from "../../../src/skill-prompt-sanitizer";
8
+
9
+ // ── SDK stubs ──────────────────────────────────────────────────────────────
10
+ vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
11
+ const original =
12
+ await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
13
+ return { ...original };
14
+ });
15
+
16
+ // ── helpers ────────────────────────────────────────────────────────────────
17
+
18
+ function makeSkillEntry(
19
+ overrides: Partial<SkillPromptEntry> = {},
20
+ ): SkillPromptEntry {
21
+ return {
22
+ name: "librarian",
23
+ description: "Research skills",
24
+ location: "/skills/librarian/SKILL.md",
25
+ state: "ask",
26
+ normalizedLocation: "/skills/librarian/SKILL.md",
27
+ normalizedBaseDir: "/skills/librarian",
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
33
+ return {
34
+ toolName: "read",
35
+ agentName: null,
36
+ input: { path: "/skills/librarian/SKILL.md" },
37
+ toolCallId: "tc-1",
38
+ cwd: "/test/project",
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ function makeEvents(): PermissionEventBus {
44
+ return { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) };
45
+ }
46
+
47
+ function makeRuntime(
48
+ overrides: Record<string, unknown> = {},
49
+ ): HandlerDeps["runtime"] {
50
+ return {
51
+ activeSkillEntries: [],
52
+ writeReviewLog: vi.fn(),
53
+ ...overrides,
54
+ } as unknown as HandlerDeps["runtime"];
55
+ }
56
+
57
+ function makeDeps(overrides: Record<string, unknown> = {}): HandlerDeps {
58
+ const { runtime: runtimeOverrides, events, ...rest } = overrides;
59
+ return {
60
+ runtime: makeRuntime(runtimeOverrides as Record<string, unknown>),
61
+ events: events ?? makeEvents(),
62
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
63
+ promptPermission: vi
64
+ .fn()
65
+ .mockResolvedValue({ approved: true, state: "approved" }),
66
+ ...rest,
67
+ } as unknown as HandlerDeps;
68
+ }
69
+
70
+ // ── tests ──────────────────────────────────────────────────────────────────
71
+
72
+ describe("evaluateSkillReadGate", () => {
73
+ it("returns null when tool is not read", async () => {
74
+ const tcc = makeTcc({ toolName: "write" });
75
+ const deps = makeDeps({
76
+ runtime: { activeSkillEntries: [makeSkillEntry()] },
77
+ });
78
+ const result = await evaluateSkillReadGate(tcc, deps);
79
+ expect(result).toBeNull();
80
+ });
81
+
82
+ it("returns null when no active skill entries", async () => {
83
+ const tcc = makeTcc();
84
+ const deps = makeDeps({ runtime: { activeSkillEntries: [] } });
85
+ const result = await evaluateSkillReadGate(tcc, deps);
86
+ expect(result).toBeNull();
87
+ });
88
+
89
+ it("returns null when read path does not match any skill", async () => {
90
+ const tcc = makeTcc({ input: { path: "/test/project/src/index.ts" } });
91
+ const deps = makeDeps({
92
+ runtime: { activeSkillEntries: [makeSkillEntry()] },
93
+ });
94
+ const result = await evaluateSkillReadGate(tcc, deps);
95
+ expect(result).toBeNull();
96
+ });
97
+
98
+ it("returns allow when skill state is allow", async () => {
99
+ const tcc = makeTcc();
100
+ const deps = makeDeps({
101
+ runtime: {
102
+ activeSkillEntries: [makeSkillEntry({ state: "allow" })],
103
+ },
104
+ });
105
+ const result = await evaluateSkillReadGate(tcc, deps);
106
+ expect(result).toEqual({ action: "allow" });
107
+ });
108
+
109
+ it("returns block when skill state is deny", async () => {
110
+ const tcc = makeTcc();
111
+ const deps = makeDeps({
112
+ runtime: {
113
+ activeSkillEntries: [makeSkillEntry({ state: "deny" })],
114
+ },
115
+ });
116
+ const result = await evaluateSkillReadGate(tcc, deps);
117
+ expect(result).toMatchObject({ action: "block" });
118
+ });
119
+
120
+ it("returns allow when state is ask and user approves", async () => {
121
+ const tcc = makeTcc();
122
+ const deps = makeDeps({
123
+ runtime: {
124
+ activeSkillEntries: [makeSkillEntry({ state: "ask" })],
125
+ },
126
+ promptPermission: vi
127
+ .fn()
128
+ .mockResolvedValue({ approved: true, state: "approved" }),
129
+ });
130
+ const result = await evaluateSkillReadGate(tcc, deps);
131
+ expect(result).toEqual({ action: "allow" });
132
+ });
133
+
134
+ it("returns block when state is ask and user denies", async () => {
135
+ const tcc = makeTcc();
136
+ const deps = makeDeps({
137
+ runtime: {
138
+ activeSkillEntries: [makeSkillEntry({ state: "ask" })],
139
+ },
140
+ promptPermission: vi
141
+ .fn()
142
+ .mockResolvedValue({ approved: false, state: "denied" }),
143
+ });
144
+ const result = await evaluateSkillReadGate(tcc, deps);
145
+ expect(result).toMatchObject({ action: "block" });
146
+ });
147
+
148
+ it("returns block when state is ask and no UI available", async () => {
149
+ const tcc = makeTcc();
150
+ const deps = makeDeps({
151
+ runtime: {
152
+ activeSkillEntries: [makeSkillEntry({ state: "ask" })],
153
+ },
154
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
155
+ });
156
+ const result = await evaluateSkillReadGate(tcc, deps);
157
+ expect(result).toMatchObject({ action: "block" });
158
+ });
159
+
160
+ it("emits decision event with correct fields on deny", async () => {
161
+ const events = makeEvents();
162
+ const tcc = makeTcc({ agentName: "test-agent" });
163
+ const deps = makeDeps({
164
+ runtime: {
165
+ activeSkillEntries: [makeSkillEntry({ state: "deny" })],
166
+ },
167
+ events,
168
+ });
169
+ await evaluateSkillReadGate(tcc, deps);
170
+ expect(events.emit).toHaveBeenCalledWith(
171
+ "permissions:decision",
172
+ expect.objectContaining({
173
+ surface: "skill",
174
+ value: "librarian",
175
+ result: "deny",
176
+ resolution: "policy_deny",
177
+ origin: null,
178
+ agentName: "test-agent",
179
+ matchedPattern: null,
180
+ }),
181
+ );
182
+ });
183
+
184
+ it("emits decision event with correct fields on allow", async () => {
185
+ const events = makeEvents();
186
+ const tcc = makeTcc();
187
+ const deps = makeDeps({
188
+ runtime: {
189
+ activeSkillEntries: [makeSkillEntry({ state: "allow" })],
190
+ },
191
+ events,
192
+ });
193
+ await evaluateSkillReadGate(tcc, deps);
194
+ expect(events.emit).toHaveBeenCalledWith(
195
+ "permissions:decision",
196
+ expect.objectContaining({
197
+ surface: "skill",
198
+ value: "librarian",
199
+ result: "allow",
200
+ resolution: "policy_allow",
201
+ }),
202
+ );
203
+ });
204
+ });