@gotgenes/pi-permission-system 3.5.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.
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/handlers/before-agent-start.ts +112 -0
- package/src/handlers/index.ts +16 -0
- package/src/handlers/input.ts +97 -0
- package/src/handlers/lifecycle.ts +80 -0
- package/src/handlers/tool-call.ts +400 -0
- package/src/handlers/types.ts +95 -0
- package/src/index.ts +101 -701
- package/src/permission-manager.ts +69 -37
- package/src/rule.ts +58 -0
- package/src/wildcard-matcher.ts +9 -0
- package/tests/handlers/before-agent-start.test.ts +274 -0
- package/tests/handlers/input.test.ts +271 -0
- package/tests/handlers/lifecycle.test.ts +331 -0
- package/tests/handlers/tool-call.test.ts +418 -0
- package/tests/rule.test.ts +158 -0
- package/tests/wildcard-matcher.test.ts +39 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { getEventInput, handleToolCall } from "../../src/handlers/tool-call";
|
|
5
|
+
import type { HandlerDeps } from "../../src/handlers/types";
|
|
6
|
+
import type { PermissionCheckResult } from "../../src/types";
|
|
7
|
+
|
|
8
|
+
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
|
9
|
+
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
|
|
10
|
+
const original =
|
|
11
|
+
await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
|
|
12
|
+
return { ...original };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function makeCtx(
|
|
18
|
+
overrides: Partial<ExtensionContext> & { cwd?: string } = {},
|
|
19
|
+
): ExtensionContext {
|
|
20
|
+
return {
|
|
21
|
+
cwd: "/test/project",
|
|
22
|
+
hasUI: true,
|
|
23
|
+
ui: {
|
|
24
|
+
setStatus: vi.fn(),
|
|
25
|
+
notify: vi.fn(),
|
|
26
|
+
select: vi.fn(),
|
|
27
|
+
input: vi.fn(),
|
|
28
|
+
},
|
|
29
|
+
sessionManager: {
|
|
30
|
+
getEntries: vi.fn().mockReturnValue([]),
|
|
31
|
+
getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
|
|
32
|
+
addEntry: vi.fn(),
|
|
33
|
+
},
|
|
34
|
+
...overrides,
|
|
35
|
+
} as unknown as ExtensionContext;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeToolCallEvent(
|
|
39
|
+
toolName: string,
|
|
40
|
+
extraFields: Record<string, unknown> = {},
|
|
41
|
+
) {
|
|
42
|
+
return {
|
|
43
|
+
type: "tool_call",
|
|
44
|
+
toolCallId: "tc-1",
|
|
45
|
+
name: toolName,
|
|
46
|
+
input: {},
|
|
47
|
+
...extraFields,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makePermissionResult(
|
|
52
|
+
state: "allow" | "deny" | "ask",
|
|
53
|
+
): PermissionCheckResult {
|
|
54
|
+
return { state, toolName: "read", source: "tool" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
|
|
58
|
+
return {
|
|
59
|
+
getPermissionManager: vi.fn().mockReturnValue({
|
|
60
|
+
checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
|
|
61
|
+
}),
|
|
62
|
+
setPermissionManager: vi.fn(),
|
|
63
|
+
getRuntimeContext: vi.fn().mockReturnValue(null),
|
|
64
|
+
setRuntimeContext: vi.fn(),
|
|
65
|
+
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
66
|
+
setActiveSkillEntries: vi.fn(),
|
|
67
|
+
getLastKnownActiveAgentName: vi.fn().mockReturnValue(null),
|
|
68
|
+
setLastKnownActiveAgentName: vi.fn(),
|
|
69
|
+
getLastActiveToolsCacheKey: vi.fn().mockReturnValue(null),
|
|
70
|
+
setLastActiveToolsCacheKey: vi.fn(),
|
|
71
|
+
getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
|
|
72
|
+
setLastPromptStateCacheKey: vi.fn(),
|
|
73
|
+
sessionApprovalCache: {
|
|
74
|
+
approve: vi.fn(),
|
|
75
|
+
has: vi.fn().mockReturnValue(false),
|
|
76
|
+
findMatchingPrefix: vi.fn().mockReturnValue(null),
|
|
77
|
+
clear: vi.fn(),
|
|
78
|
+
} as unknown as HandlerDeps["sessionApprovalCache"],
|
|
79
|
+
createPermissionManagerForCwd: vi.fn(),
|
|
80
|
+
refreshExtensionConfig: vi.fn(),
|
|
81
|
+
notifyWarning: vi.fn(),
|
|
82
|
+
logResolvedConfigPaths: vi.fn(),
|
|
83
|
+
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
84
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
85
|
+
promptPermission: vi
|
|
86
|
+
.fn()
|
|
87
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
88
|
+
createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
|
|
89
|
+
startForwardedPermissionPolling: vi.fn(),
|
|
90
|
+
stopForwardedPermissionPolling: vi.fn(),
|
|
91
|
+
writeReviewLog: vi.fn(),
|
|
92
|
+
writeDebugLog: vi.fn(),
|
|
93
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
|
|
94
|
+
setActiveTools: vi.fn(),
|
|
95
|
+
...overrides,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── getEventInput ──────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe("getEventInput", () => {
|
|
102
|
+
it("returns the input field when present", () => {
|
|
103
|
+
expect(getEventInput({ input: { path: "/foo" } })).toEqual({
|
|
104
|
+
path: "/foo",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns the arguments field when input is absent", () => {
|
|
109
|
+
expect(getEventInput({ arguments: { command: "ls" } })).toEqual({
|
|
110
|
+
command: "ls",
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns empty object when neither field is present", () => {
|
|
115
|
+
expect(getEventInput({ type: "tool_call" })).toEqual({});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("prefers input over arguments when both are present", () => {
|
|
119
|
+
expect(getEventInput({ input: { a: 1 }, arguments: { b: 2 } })).toEqual({
|
|
120
|
+
a: 1,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── handleToolCall ─────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
describe("handleToolCall", () => {
|
|
128
|
+
it("sets runtime context", async () => {
|
|
129
|
+
const ctx = makeCtx();
|
|
130
|
+
const deps = makeDeps();
|
|
131
|
+
await handleToolCall(deps, makeToolCallEvent("read"), ctx);
|
|
132
|
+
expect(deps.setRuntimeContext).toHaveBeenCalledWith(ctx);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("starts forwarded permission polling", async () => {
|
|
136
|
+
const ctx = makeCtx();
|
|
137
|
+
const deps = makeDeps();
|
|
138
|
+
await handleToolCall(deps, makeToolCallEvent("read"), ctx);
|
|
139
|
+
expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("blocks when tool name cannot be resolved", async () => {
|
|
143
|
+
const deps = makeDeps();
|
|
144
|
+
// An event with no recognisable name field
|
|
145
|
+
const result = await handleToolCall(deps, { type: "tool_call" }, makeCtx());
|
|
146
|
+
expect(result).toEqual({
|
|
147
|
+
block: true,
|
|
148
|
+
reason: expect.stringContaining("tool"),
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("blocks when tool is not registered", async () => {
|
|
153
|
+
const deps = makeDeps({
|
|
154
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
155
|
+
});
|
|
156
|
+
const result = await handleToolCall(
|
|
157
|
+
deps,
|
|
158
|
+
makeToolCallEvent("unknown-tool"),
|
|
159
|
+
makeCtx(),
|
|
160
|
+
);
|
|
161
|
+
expect(result).toMatchObject({ block: true });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("returns empty object when tool is allowed", async () => {
|
|
165
|
+
const deps = makeDeps({
|
|
166
|
+
getPermissionManager: vi.fn().mockReturnValue({
|
|
167
|
+
checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
const result = await handleToolCall(
|
|
171
|
+
deps,
|
|
172
|
+
makeToolCallEvent("read"),
|
|
173
|
+
makeCtx(),
|
|
174
|
+
);
|
|
175
|
+
expect(result).toEqual({});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("blocks when tool is denied by policy", async () => {
|
|
179
|
+
const deps = makeDeps({
|
|
180
|
+
getPermissionManager: vi.fn().mockReturnValue({
|
|
181
|
+
checkPermission: vi.fn().mockReturnValue(makePermissionResult("deny")),
|
|
182
|
+
}),
|
|
183
|
+
});
|
|
184
|
+
const result = await handleToolCall(
|
|
185
|
+
deps,
|
|
186
|
+
makeToolCallEvent("read"),
|
|
187
|
+
makeCtx(),
|
|
188
|
+
);
|
|
189
|
+
expect(result).toMatchObject({ block: true });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("blocks when tool ask has no UI available", async () => {
|
|
193
|
+
const deps = makeDeps({
|
|
194
|
+
getPermissionManager: vi.fn().mockReturnValue({
|
|
195
|
+
checkPermission: vi.fn().mockReturnValue(makePermissionResult("ask")),
|
|
196
|
+
}),
|
|
197
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
|
|
198
|
+
});
|
|
199
|
+
const result = await handleToolCall(
|
|
200
|
+
deps,
|
|
201
|
+
makeToolCallEvent("read"),
|
|
202
|
+
makeCtx(),
|
|
203
|
+
);
|
|
204
|
+
expect(result).toMatchObject({ block: true });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("allows when user approves the ask prompt", async () => {
|
|
208
|
+
const deps = makeDeps({
|
|
209
|
+
getPermissionManager: vi.fn().mockReturnValue({
|
|
210
|
+
checkPermission: vi.fn().mockReturnValue(makePermissionResult("ask")),
|
|
211
|
+
}),
|
|
212
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
213
|
+
promptPermission: vi
|
|
214
|
+
.fn()
|
|
215
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
216
|
+
});
|
|
217
|
+
const result = await handleToolCall(
|
|
218
|
+
deps,
|
|
219
|
+
makeToolCallEvent("read"),
|
|
220
|
+
makeCtx(),
|
|
221
|
+
);
|
|
222
|
+
expect(result).toEqual({});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("blocks when user denies the ask prompt", async () => {
|
|
226
|
+
const deps = makeDeps({
|
|
227
|
+
getPermissionManager: vi.fn().mockReturnValue({
|
|
228
|
+
checkPermission: vi.fn().mockReturnValue(makePermissionResult("ask")),
|
|
229
|
+
}),
|
|
230
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
231
|
+
promptPermission: vi
|
|
232
|
+
.fn()
|
|
233
|
+
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
234
|
+
});
|
|
235
|
+
const result = await handleToolCall(
|
|
236
|
+
deps,
|
|
237
|
+
makeToolCallEvent("read"),
|
|
238
|
+
makeCtx(),
|
|
239
|
+
);
|
|
240
|
+
expect(result).toMatchObject({ block: true });
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ── skill-read gate ────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
describe("handleToolCall — skill-read gate", () => {
|
|
247
|
+
it("blocks a read of a denied skill path", async () => {
|
|
248
|
+
const skillEntry = {
|
|
249
|
+
name: "librarian",
|
|
250
|
+
description: "Research skills",
|
|
251
|
+
location: "/skills/librarian/SKILL.md",
|
|
252
|
+
state: "deny" as const,
|
|
253
|
+
normalizedLocation: "/skills/librarian/SKILL.md",
|
|
254
|
+
normalizedBaseDir: "/skills/librarian",
|
|
255
|
+
};
|
|
256
|
+
const deps = makeDeps({
|
|
257
|
+
getActiveSkillEntries: vi.fn().mockReturnValue([skillEntry]),
|
|
258
|
+
getAllTools: vi.fn().mockReturnValue([{ toolName: "read" }]),
|
|
259
|
+
getPermissionManager: vi.fn().mockReturnValue({
|
|
260
|
+
checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
|
|
261
|
+
}),
|
|
262
|
+
});
|
|
263
|
+
const event = {
|
|
264
|
+
type: "tool_call",
|
|
265
|
+
toolCallId: "tc-skill",
|
|
266
|
+
toolName: "read",
|
|
267
|
+
input: { path: "/skills/librarian/SKILL.md" },
|
|
268
|
+
};
|
|
269
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
270
|
+
expect(result).toMatchObject({ block: true });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("allows a read of a non-skill path even when skill entries are present", async () => {
|
|
274
|
+
const skillEntry = {
|
|
275
|
+
name: "librarian",
|
|
276
|
+
description: "Research skills",
|
|
277
|
+
location: "/skills/librarian/SKILL.md",
|
|
278
|
+
state: "deny" as const,
|
|
279
|
+
normalizedLocation: "/skills/librarian/SKILL.md",
|
|
280
|
+
normalizedBaseDir: "/skills/librarian",
|
|
281
|
+
};
|
|
282
|
+
const deps = makeDeps({
|
|
283
|
+
getActiveSkillEntries: vi.fn().mockReturnValue([skillEntry]),
|
|
284
|
+
getAllTools: vi.fn().mockReturnValue([{ toolName: "read" }]),
|
|
285
|
+
getPermissionManager: vi.fn().mockReturnValue({
|
|
286
|
+
checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
|
|
287
|
+
}),
|
|
288
|
+
});
|
|
289
|
+
const event = {
|
|
290
|
+
type: "tool_call",
|
|
291
|
+
toolCallId: "tc-ok",
|
|
292
|
+
toolName: "read",
|
|
293
|
+
input: { path: "/test/project/src/index.ts" },
|
|
294
|
+
};
|
|
295
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
296
|
+
expect(result).toEqual({});
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ── external-directory gate ────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
describe("handleToolCall — external-directory gate", () => {
|
|
303
|
+
it("blocks a read of a path outside cwd when policy is deny", async () => {
|
|
304
|
+
const deps = makeDeps({
|
|
305
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
306
|
+
getPermissionManager: vi.fn().mockReturnValue({
|
|
307
|
+
checkPermission: vi.fn().mockReturnValue(makePermissionResult("deny")),
|
|
308
|
+
}),
|
|
309
|
+
});
|
|
310
|
+
const event = {
|
|
311
|
+
type: "tool_call",
|
|
312
|
+
toolCallId: "tc-ext",
|
|
313
|
+
name: "read",
|
|
314
|
+
input: { path: "/outside/project/file.ts" },
|
|
315
|
+
};
|
|
316
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
317
|
+
expect(result).toMatchObject({ block: true });
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("allows when session has an existing approval for the external path", async () => {
|
|
321
|
+
const deps = makeDeps({
|
|
322
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
323
|
+
getPermissionManager: vi.fn().mockReturnValue({
|
|
324
|
+
checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
|
|
325
|
+
}),
|
|
326
|
+
sessionApprovalCache: {
|
|
327
|
+
approve: vi.fn(),
|
|
328
|
+
has: vi.fn().mockReturnValue(false),
|
|
329
|
+
findMatchingPrefix: vi.fn().mockReturnValue("/outside/project/"),
|
|
330
|
+
clear: vi.fn(),
|
|
331
|
+
} as unknown as HandlerDeps["sessionApprovalCache"],
|
|
332
|
+
});
|
|
333
|
+
const event = {
|
|
334
|
+
type: "tool_call",
|
|
335
|
+
toolCallId: "tc-session",
|
|
336
|
+
name: "read",
|
|
337
|
+
input: { path: "/outside/project/file.ts" },
|
|
338
|
+
};
|
|
339
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
340
|
+
expect(result).toEqual({});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("approves session when user selects approved_for_session", async () => {
|
|
344
|
+
const approveCache = {
|
|
345
|
+
approve: vi.fn(),
|
|
346
|
+
has: vi.fn().mockReturnValue(false),
|
|
347
|
+
findMatchingPrefix: vi.fn().mockReturnValue(null),
|
|
348
|
+
clear: vi.fn(),
|
|
349
|
+
} as unknown as HandlerDeps["sessionApprovalCache"];
|
|
350
|
+
const deps = makeDeps({
|
|
351
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
352
|
+
getPermissionManager: vi.fn().mockReturnValue({
|
|
353
|
+
checkPermission: vi.fn().mockReturnValue(makePermissionResult("ask")),
|
|
354
|
+
}),
|
|
355
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
356
|
+
promptPermission: vi
|
|
357
|
+
.fn()
|
|
358
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
359
|
+
sessionApprovalCache: approveCache,
|
|
360
|
+
});
|
|
361
|
+
const event = {
|
|
362
|
+
type: "tool_call",
|
|
363
|
+
toolCallId: "tc-sess-approve",
|
|
364
|
+
name: "read",
|
|
365
|
+
input: { path: "/outside/project/file.ts" },
|
|
366
|
+
};
|
|
367
|
+
await handleToolCall(deps, event, makeCtx());
|
|
368
|
+
expect(approveCache.approve).toHaveBeenCalledWith(
|
|
369
|
+
"external_directory",
|
|
370
|
+
expect.any(String),
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ── bash external-directory gate ──────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
describe("handleToolCall — bash external-directory gate", () => {
|
|
378
|
+
it("blocks a bash command referencing an external path when policy is deny", async () => {
|
|
379
|
+
const deps = makeDeps({
|
|
380
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "bash" }]),
|
|
381
|
+
getPermissionManager: vi.fn().mockReturnValue({
|
|
382
|
+
checkPermission: vi.fn().mockReturnValue(makePermissionResult("deny")),
|
|
383
|
+
}),
|
|
384
|
+
});
|
|
385
|
+
const event = {
|
|
386
|
+
type: "tool_call",
|
|
387
|
+
toolCallId: "tc-bash-ext",
|
|
388
|
+
name: "bash",
|
|
389
|
+
input: { command: "cat /outside/project/file.ts" },
|
|
390
|
+
};
|
|
391
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
392
|
+
expect(result).toMatchObject({ block: true });
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("skips bash external gate when all referenced paths are session-approved", async () => {
|
|
396
|
+
const deps = makeDeps({
|
|
397
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "bash" }]),
|
|
398
|
+
getPermissionManager: vi.fn().mockReturnValue({
|
|
399
|
+
checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
|
|
400
|
+
}),
|
|
401
|
+
sessionApprovalCache: {
|
|
402
|
+
approve: vi.fn(),
|
|
403
|
+
// All paths are covered
|
|
404
|
+
has: vi.fn().mockReturnValue(true),
|
|
405
|
+
findMatchingPrefix: vi.fn().mockReturnValue(null),
|
|
406
|
+
clear: vi.fn(),
|
|
407
|
+
} as unknown as HandlerDeps["sessionApprovalCache"],
|
|
408
|
+
});
|
|
409
|
+
const event = {
|
|
410
|
+
type: "tool_call",
|
|
411
|
+
toolCallId: "tc-bash-sess",
|
|
412
|
+
name: "bash",
|
|
413
|
+
input: { command: "cat /outside/project/file.ts" },
|
|
414
|
+
};
|
|
415
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
416
|
+
expect(result).toEqual({});
|
|
417
|
+
});
|
|
418
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import type { Rule, Ruleset } from "../src/rule";
|
|
3
|
+
import { evaluate, getDefaultAction } from "../src/rule";
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe("getDefaultAction", () => {
|
|
10
|
+
test("returns 'ask' for bash surface", () => {
|
|
11
|
+
expect(getDefaultAction("bash")).toBe("ask");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("returns 'ask' for mcp surface", () => {
|
|
15
|
+
expect(getDefaultAction("mcp")).toBe("ask");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("returns 'ask' for skill surface", () => {
|
|
19
|
+
expect(getDefaultAction("skill")).toBe("ask");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("returns 'ask' for special surface", () => {
|
|
23
|
+
expect(getDefaultAction("special")).toBe("ask");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("returns 'ask' for tools surface", () => {
|
|
27
|
+
expect(getDefaultAction("tools")).toBe("ask");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("returns 'ask' for unknown surface (least privilege)", () => {
|
|
31
|
+
expect(getDefaultAction("unknown_surface")).toBe("ask");
|
|
32
|
+
expect(getDefaultAction("")).toBe("ask");
|
|
33
|
+
expect(getDefaultAction("external_directory")).toBe("ask");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("evaluate", () => {
|
|
38
|
+
const allowBashGit: Rule = {
|
|
39
|
+
surface: "bash",
|
|
40
|
+
pattern: "git *",
|
|
41
|
+
action: "allow",
|
|
42
|
+
};
|
|
43
|
+
const denyBashGitPush: Rule = {
|
|
44
|
+
surface: "bash",
|
|
45
|
+
pattern: "git push *",
|
|
46
|
+
action: "deny",
|
|
47
|
+
};
|
|
48
|
+
const allowRead: Rule = { surface: "read", pattern: "*", action: "allow" };
|
|
49
|
+
const askMcp: Rule = { surface: "mcp", pattern: "*", action: "ask" };
|
|
50
|
+
const allowSkillLibrarian: Rule = {
|
|
51
|
+
surface: "skill",
|
|
52
|
+
pattern: "librarian",
|
|
53
|
+
action: "allow",
|
|
54
|
+
};
|
|
55
|
+
const askSpecialExtDir: Rule = {
|
|
56
|
+
surface: "special",
|
|
57
|
+
pattern: "external_directory",
|
|
58
|
+
action: "ask",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
test("returns matching rule when a rule matches", () => {
|
|
62
|
+
const ruleset: Ruleset = [allowBashGit];
|
|
63
|
+
const result = evaluate("bash", "git status", ruleset);
|
|
64
|
+
expect(result).toEqual(allowBashGit);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("returns synthetic rule with default action when no rules match", () => {
|
|
68
|
+
const result = evaluate("bash", "npm install", [allowBashGit]);
|
|
69
|
+
expect(result.surface).toBe("bash");
|
|
70
|
+
expect(result.pattern).toBe("npm install");
|
|
71
|
+
expect(result.action).toBe("ask"); // getDefaultAction("bash")
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("returns synthetic rule for empty ruleset", () => {
|
|
75
|
+
const result = evaluate("mcp", "exa_search", []);
|
|
76
|
+
expect(result.surface).toBe("mcp");
|
|
77
|
+
expect(result.pattern).toBe("exa_search");
|
|
78
|
+
expect(result.action).toBe("ask");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("matches rules for all permission surfaces", () => {
|
|
82
|
+
expect(evaluate("read", "src/foo.ts", [allowRead]).action).toBe("allow");
|
|
83
|
+
expect(evaluate("mcp", "exa_search", [askMcp]).action).toBe("ask");
|
|
84
|
+
expect(evaluate("skill", "librarian", [allowSkillLibrarian]).action).toBe(
|
|
85
|
+
"allow",
|
|
86
|
+
);
|
|
87
|
+
expect(
|
|
88
|
+
evaluate("special", "external_directory", [askSpecialExtDir]).action,
|
|
89
|
+
).toBe("ask");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("last-match-wins: later conflicting rule overrides earlier", () => {
|
|
93
|
+
const ruleset: Ruleset = [allowBashGit, denyBashGitPush];
|
|
94
|
+
const result = evaluate("bash", "git push origin main", ruleset);
|
|
95
|
+
expect(result).toEqual(denyBashGitPush);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("last-match-wins: broad deny followed by specific allow", () => {
|
|
99
|
+
const denyAll: Rule = { surface: "bash", pattern: "*", action: "deny" };
|
|
100
|
+
const allowStatus: Rule = {
|
|
101
|
+
surface: "bash",
|
|
102
|
+
pattern: "git status",
|
|
103
|
+
action: "allow",
|
|
104
|
+
};
|
|
105
|
+
const result = evaluate("bash", "git status", [denyAll, allowStatus]);
|
|
106
|
+
expect(result).toEqual(allowStatus);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("wildcard surface in rule matches any surface value", () => {
|
|
110
|
+
const universalAllow: Rule = {
|
|
111
|
+
surface: "*",
|
|
112
|
+
pattern: "*",
|
|
113
|
+
action: "allow",
|
|
114
|
+
};
|
|
115
|
+
expect(evaluate("bash", "anything", [universalAllow]).action).toBe("allow");
|
|
116
|
+
expect(evaluate("mcp", "something", [universalAllow]).action).toBe("allow");
|
|
117
|
+
expect(evaluate("skill", "librarian", [universalAllow]).action).toBe(
|
|
118
|
+
"allow",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("specific surface rule does not match a different surface", () => {
|
|
123
|
+
const ruleset: Ruleset = [allowBashGit];
|
|
124
|
+
// bash rule should not match mcp surface
|
|
125
|
+
const result = evaluate("mcp", "git status", ruleset);
|
|
126
|
+
expect(result.action).toBe("ask"); // falls back to default
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("multiple rulesets: rules from later rulesets take priority", () => {
|
|
130
|
+
const globalRules: Ruleset = [
|
|
131
|
+
{ surface: "bash", pattern: "git *", action: "ask" },
|
|
132
|
+
];
|
|
133
|
+
const agentRules: Ruleset = [
|
|
134
|
+
{ surface: "bash", pattern: "git *", action: "allow" },
|
|
135
|
+
];
|
|
136
|
+
const result = evaluate("bash", "git status", globalRules, agentRules);
|
|
137
|
+
expect(result.action).toBe("allow"); // agent rule wins
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("multiple rulesets: earlier rulesets used when later rulesets have no match", () => {
|
|
141
|
+
const globalRules: Ruleset = [
|
|
142
|
+
{ surface: "bash", pattern: "git *", action: "allow" },
|
|
143
|
+
];
|
|
144
|
+
const agentRules: Ruleset = [
|
|
145
|
+
{ surface: "bash", pattern: "npm *", action: "deny" },
|
|
146
|
+
];
|
|
147
|
+
// git status matches global but not agent rule
|
|
148
|
+
const result = evaluate("bash", "git status", globalRules, agentRules);
|
|
149
|
+
expect(result.action).toBe("allow"); // global rule is the last match for this pattern
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("no rulesets at all returns synthetic default", () => {
|
|
153
|
+
const result = evaluate("bash", "git status");
|
|
154
|
+
expect(result.surface).toBe("bash");
|
|
155
|
+
expect(result.pattern).toBe("git status");
|
|
156
|
+
expect(result.action).toBe("ask");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
compileWildcardPatternEntries,
|
|
6
6
|
findCompiledWildcardMatch,
|
|
7
7
|
findCompiledWildcardMatchForNames,
|
|
8
|
+
wildcardMatch,
|
|
8
9
|
} from "../src/wildcard-matcher";
|
|
9
10
|
|
|
10
11
|
afterEach(() => {
|
|
@@ -178,3 +179,41 @@ describe("findCompiledWildcardMatchForNames", () => {
|
|
|
178
179
|
expect(compiled.regex.test("echo hello")).toBe(false);
|
|
179
180
|
});
|
|
180
181
|
});
|
|
182
|
+
|
|
183
|
+
describe("wildcardMatch", () => {
|
|
184
|
+
test("'*' pattern matches any value", () => {
|
|
185
|
+
expect(wildcardMatch("*", "anything")).toBe(true);
|
|
186
|
+
expect(wildcardMatch("*", "")).toBe(true);
|
|
187
|
+
expect(wildcardMatch("*", "bash")).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("exact pattern matches identical value", () => {
|
|
191
|
+
expect(wildcardMatch("read", "read")).toBe(true);
|
|
192
|
+
expect(wildcardMatch("external_directory", "external_directory")).toBe(
|
|
193
|
+
true,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("exact pattern does not match a different value", () => {
|
|
198
|
+
expect(wildcardMatch("read", "write")).toBe(false);
|
|
199
|
+
expect(wildcardMatch("read", "readonly")).toBe(false);
|
|
200
|
+
expect(wildcardMatch("read", "read ")).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("glob pattern matches with wildcard", () => {
|
|
204
|
+
expect(wildcardMatch("git *", "git status")).toBe(true);
|
|
205
|
+
expect(wildcardMatch("git *", "git push origin main")).toBe(true);
|
|
206
|
+
expect(wildcardMatch("git *", "npm install")).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("glob with no trailing space matches longer string", () => {
|
|
210
|
+
expect(wildcardMatch("git*", "git")).toBe(true);
|
|
211
|
+
expect(wildcardMatch("git*", "git status")).toBe(true);
|
|
212
|
+
expect(wildcardMatch("git*", "npm")).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("regex special characters in pattern are treated as literals", () => {
|
|
216
|
+
expect(wildcardMatch("tool.name", "tool.name")).toBe(true);
|
|
217
|
+
expect(wildcardMatch("tool.name", "toolXname")).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
});
|