@gotgenes/pi-permission-system 4.4.1 → 4.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.
- package/CHANGELOG.md +17 -0
- package/package.json +1 -1
- package/src/input-normalizer.ts +94 -0
- package/src/mcp-targets.ts +160 -0
- package/src/permission-manager.ts +53 -310
- package/src/rule.ts +32 -0
- package/tests/input-normalizer.test.ts +150 -0
- package/tests/mcp-targets.test.ts +178 -0
- package/tests/permission-manager-unified.test.ts +375 -0
- package/tests/rule.test.ts +81 -1
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests verifying the unified checkPermission() path.
|
|
3
|
+
*
|
|
4
|
+
* Step 5: session rules concatenated into the composed ruleset.
|
|
5
|
+
* Step 6: all five surfaces produce identical decisions to the old branching code.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { PermissionManager } from "../src/permission-manager";
|
|
12
|
+
import type { Rule, Ruleset } from "../src/rule";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** Manager backed by a missing config file — universal default is "ask". */
|
|
19
|
+
function makeManager(
|
|
20
|
+
mcpServerNames: readonly string[] = [],
|
|
21
|
+
): PermissionManager {
|
|
22
|
+
return new PermissionManager({
|
|
23
|
+
globalConfigPath: "/nonexistent/config.json",
|
|
24
|
+
agentsDir: "/nonexistent/agents",
|
|
25
|
+
mcpServerNames: [...mcpServerNames],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Manager backed by a real on-disk config file written to a temp directory.
|
|
31
|
+
* Returns the manager and a cleanup function.
|
|
32
|
+
*/
|
|
33
|
+
function makeManagerWithConfig(
|
|
34
|
+
permission: Record<string, unknown>,
|
|
35
|
+
mcpServerNames: readonly string[] = [],
|
|
36
|
+
): { manager: PermissionManager; cleanup: () => void } {
|
|
37
|
+
const baseDir = mkdtempSync(join(tmpdir(), "pm-unified-test-"));
|
|
38
|
+
const agentsDir = join(baseDir, "agents");
|
|
39
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
40
|
+
const globalConfigPath = join(baseDir, "config.json");
|
|
41
|
+
writeFileSync(globalConfigPath, JSON.stringify({ permission }, null, 2));
|
|
42
|
+
const manager = new PermissionManager({
|
|
43
|
+
globalConfigPath,
|
|
44
|
+
agentsDir,
|
|
45
|
+
mcpServerNames: [...mcpServerNames],
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
manager,
|
|
49
|
+
cleanup: () => rmSync(baseDir, { recursive: true, force: true }),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sessionAllow = (surface: string, pattern: string): Rule => ({
|
|
54
|
+
surface,
|
|
55
|
+
pattern,
|
|
56
|
+
action: "allow",
|
|
57
|
+
layer: "session",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Step 5: session rules concatenated — wins over config/default
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
describe("checkPermission — session rules", () => {
|
|
65
|
+
it("session rule wins over the universal default (external_directory)", () => {
|
|
66
|
+
const manager = makeManager();
|
|
67
|
+
const sessionRules: Ruleset = [
|
|
68
|
+
sessionAllow("external_directory", "/other/project"),
|
|
69
|
+
];
|
|
70
|
+
const result = manager.checkPermission(
|
|
71
|
+
"external_directory",
|
|
72
|
+
{ path: "/other/project" },
|
|
73
|
+
undefined,
|
|
74
|
+
sessionRules,
|
|
75
|
+
);
|
|
76
|
+
expect(result.state).toBe("allow");
|
|
77
|
+
expect(result.source).toBe("session");
|
|
78
|
+
expect(result.matchedPattern).toBe("/other/project");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("session rule wins over the universal default (skill)", () => {
|
|
82
|
+
const manager = makeManager();
|
|
83
|
+
const sessionRules: Ruleset = [sessionAllow("skill", "librarian")];
|
|
84
|
+
const result = manager.checkPermission(
|
|
85
|
+
"skill",
|
|
86
|
+
{ name: "librarian" },
|
|
87
|
+
undefined,
|
|
88
|
+
sessionRules,
|
|
89
|
+
);
|
|
90
|
+
expect(result.state).toBe("allow");
|
|
91
|
+
expect(result.source).toBe("session");
|
|
92
|
+
expect(result.matchedPattern).toBe("librarian");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("session rule wins over the universal default (bash)", () => {
|
|
96
|
+
const manager = makeManager();
|
|
97
|
+
const sessionRules: Ruleset = [sessionAllow("bash", "git status")];
|
|
98
|
+
const result = manager.checkPermission(
|
|
99
|
+
"bash",
|
|
100
|
+
{ command: "git status" },
|
|
101
|
+
undefined,
|
|
102
|
+
sessionRules,
|
|
103
|
+
);
|
|
104
|
+
expect(result.state).toBe("allow");
|
|
105
|
+
expect(result.source).toBe("session");
|
|
106
|
+
expect(result.matchedPattern).toBe("git status");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("session rule wins over the universal default (tool — read)", () => {
|
|
110
|
+
const manager = makeManager();
|
|
111
|
+
const sessionRules: Ruleset = [sessionAllow("read", "*")];
|
|
112
|
+
const result = manager.checkPermission("read", {}, undefined, sessionRules);
|
|
113
|
+
expect(result.state).toBe("allow");
|
|
114
|
+
expect(result.source).toBe("session");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("session rule wins over the universal default (mcp)", () => {
|
|
118
|
+
const manager = makeManager();
|
|
119
|
+
const sessionRules: Ruleset = [sessionAllow("mcp", "mcp_status")];
|
|
120
|
+
const result = manager.checkPermission("mcp", {}, undefined, sessionRules);
|
|
121
|
+
expect(result.state).toBe("allow");
|
|
122
|
+
expect(result.source).toBe("session");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("no session rules — falls through to default (ask)", () => {
|
|
126
|
+
const manager = makeManager();
|
|
127
|
+
const result = manager.checkPermission("read", {}, undefined, []);
|
|
128
|
+
expect(result.state).toBe("ask");
|
|
129
|
+
expect(result.source).not.toBe("session");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("session rule with narrower pattern does not block a broader command not in session", () => {
|
|
133
|
+
const manager = makeManager();
|
|
134
|
+
// Only "git status" is session-approved; "git push" should fall through to default.
|
|
135
|
+
const sessionRules: Ruleset = [sessionAllow("bash", "git status")];
|
|
136
|
+
const result = manager.checkPermission(
|
|
137
|
+
"bash",
|
|
138
|
+
{ command: "git push origin main" },
|
|
139
|
+
undefined,
|
|
140
|
+
sessionRules,
|
|
141
|
+
);
|
|
142
|
+
expect(result.state).toBe("ask");
|
|
143
|
+
expect(result.source).not.toBe("session");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("session wildcard pattern matches multiple commands", () => {
|
|
147
|
+
const manager = makeManager();
|
|
148
|
+
const sessionRules: Ruleset = [sessionAllow("bash", "git *")];
|
|
149
|
+
const push = manager.checkPermission(
|
|
150
|
+
"bash",
|
|
151
|
+
{ command: "git push origin main" },
|
|
152
|
+
undefined,
|
|
153
|
+
sessionRules,
|
|
154
|
+
);
|
|
155
|
+
const status = manager.checkPermission(
|
|
156
|
+
"bash",
|
|
157
|
+
{ command: "git status" },
|
|
158
|
+
undefined,
|
|
159
|
+
sessionRules,
|
|
160
|
+
);
|
|
161
|
+
expect(push.state).toBe("allow");
|
|
162
|
+
expect(push.source).toBe("session");
|
|
163
|
+
expect(status.state).toBe("allow");
|
|
164
|
+
expect(status.source).toBe("session");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Step 6: source field and matchedPattern for all five surfaces
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
describe("checkPermission — source derivation and matchedPattern", () => {
|
|
173
|
+
describe("external_directory (special surface)", () => {
|
|
174
|
+
it("source is 'special' for a config-matched path", () => {
|
|
175
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
176
|
+
"*": "ask",
|
|
177
|
+
external_directory: { "/trusted/*": "allow" },
|
|
178
|
+
});
|
|
179
|
+
try {
|
|
180
|
+
const result = manager.checkPermission("external_directory", {
|
|
181
|
+
path: "/trusted/repo",
|
|
182
|
+
});
|
|
183
|
+
expect(result.state).toBe("allow");
|
|
184
|
+
expect(result.source).toBe("special");
|
|
185
|
+
expect(result.matchedPattern).toBe("/trusted/*");
|
|
186
|
+
} finally {
|
|
187
|
+
cleanup();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("source is 'special' even for a default match (no config rule)", () => {
|
|
192
|
+
const manager = makeManager();
|
|
193
|
+
const result = manager.checkPermission("external_directory", {
|
|
194
|
+
path: "/some/path",
|
|
195
|
+
});
|
|
196
|
+
expect(result.state).toBe("ask");
|
|
197
|
+
expect(result.source).toBe("special");
|
|
198
|
+
expect(result.matchedPattern).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("matchedPattern is undefined for a default match", () => {
|
|
202
|
+
const manager = makeManager();
|
|
203
|
+
const result = manager.checkPermission("external_directory", {
|
|
204
|
+
path: "/unknown",
|
|
205
|
+
});
|
|
206
|
+
expect(result.matchedPattern).toBeUndefined();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("skill surface", () => {
|
|
211
|
+
it("source is 'skill' for a config-matched skill name", () => {
|
|
212
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
213
|
+
"*": "ask",
|
|
214
|
+
skill: { librarian: "allow" },
|
|
215
|
+
});
|
|
216
|
+
try {
|
|
217
|
+
const result = manager.checkPermission("skill", { name: "librarian" });
|
|
218
|
+
expect(result.state).toBe("allow");
|
|
219
|
+
expect(result.source).toBe("skill");
|
|
220
|
+
expect(result.matchedPattern).toBe("librarian");
|
|
221
|
+
} finally {
|
|
222
|
+
cleanup();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("source is 'skill' even for a default match", () => {
|
|
227
|
+
const manager = makeManager();
|
|
228
|
+
const result = manager.checkPermission("skill", { name: "unknown" });
|
|
229
|
+
expect(result.state).toBe("ask");
|
|
230
|
+
expect(result.source).toBe("skill");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("bash surface", () => {
|
|
235
|
+
it("source is 'bash' and command is included in result", () => {
|
|
236
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
237
|
+
"*": "ask",
|
|
238
|
+
bash: { "git *": "allow" },
|
|
239
|
+
});
|
|
240
|
+
try {
|
|
241
|
+
const result = manager.checkPermission("bash", {
|
|
242
|
+
command: "git status",
|
|
243
|
+
});
|
|
244
|
+
expect(result.state).toBe("allow");
|
|
245
|
+
expect(result.source).toBe("bash");
|
|
246
|
+
expect(result.command).toBe("git status");
|
|
247
|
+
expect(result.matchedPattern).toBe("git *");
|
|
248
|
+
} finally {
|
|
249
|
+
cleanup();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("source is 'bash' even for a default match, command is empty string", () => {
|
|
254
|
+
const manager = makeManager();
|
|
255
|
+
const result = manager.checkPermission("bash", {});
|
|
256
|
+
expect(result.source).toBe("bash");
|
|
257
|
+
expect(result.command).toBe("");
|
|
258
|
+
expect(result.matchedPattern).toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("mcp surface", () => {
|
|
263
|
+
it("source is 'mcp' for a config-matched target", () => {
|
|
264
|
+
const { manager, cleanup } = makeManagerWithConfig(
|
|
265
|
+
{ "*": "ask", mcp: { exa_search: "allow" } },
|
|
266
|
+
["exa"],
|
|
267
|
+
);
|
|
268
|
+
try {
|
|
269
|
+
const result = manager.checkPermission("mcp", {
|
|
270
|
+
tool: "exa:search",
|
|
271
|
+
server: "exa",
|
|
272
|
+
});
|
|
273
|
+
expect(result.state).toBe("allow");
|
|
274
|
+
expect(result.source).toBe("mcp");
|
|
275
|
+
expect(result.matchedPattern).toBe("exa_search");
|
|
276
|
+
expect(result.target).toBeDefined();
|
|
277
|
+
} finally {
|
|
278
|
+
cleanup();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("source is 'default' when all targets match only the synthesized default", () => {
|
|
283
|
+
const manager = makeManager();
|
|
284
|
+
const result = manager.checkPermission("mcp", { tool: "exa:search" });
|
|
285
|
+
expect(result.state).toBe("ask");
|
|
286
|
+
expect(result.source).toBe("default");
|
|
287
|
+
expect(result.matchedPattern).toBeUndefined();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("target field is set for a matched mcp call", () => {
|
|
291
|
+
const { manager, cleanup } = makeManagerWithConfig(
|
|
292
|
+
{ "*": "ask", mcp: { mcp_status: "allow" } },
|
|
293
|
+
[],
|
|
294
|
+
);
|
|
295
|
+
try {
|
|
296
|
+
const result = manager.checkPermission("mcp", {});
|
|
297
|
+
expect(result.target).toBeDefined();
|
|
298
|
+
expect(result.source).toBe("mcp");
|
|
299
|
+
} finally {
|
|
300
|
+
cleanup();
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("tool surfaces", () => {
|
|
306
|
+
it("built-in tool: source is always 'tool' (config match)", () => {
|
|
307
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
308
|
+
"*": "ask",
|
|
309
|
+
read: "allow",
|
|
310
|
+
});
|
|
311
|
+
try {
|
|
312
|
+
const result = manager.checkPermission("read", {});
|
|
313
|
+
expect(result.state).toBe("allow");
|
|
314
|
+
expect(result.source).toBe("tool");
|
|
315
|
+
} finally {
|
|
316
|
+
cleanup();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("built-in tool: source is 'tool' even for a default match", () => {
|
|
321
|
+
const manager = makeManager();
|
|
322
|
+
const result = manager.checkPermission("read", {});
|
|
323
|
+
expect(result.state).toBe("ask");
|
|
324
|
+
expect(result.source).toBe("tool");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("extension tool: source is 'default' when no config rule matches", () => {
|
|
328
|
+
const manager = makeManager();
|
|
329
|
+
const result = manager.checkPermission("my_custom_tool", {});
|
|
330
|
+
expect(result.state).toBe("ask");
|
|
331
|
+
expect(result.source).toBe("default");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("extension tool: source is 'tool' when a config rule matches", () => {
|
|
335
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
336
|
+
"*": "ask",
|
|
337
|
+
my_custom_tool: "allow",
|
|
338
|
+
});
|
|
339
|
+
try {
|
|
340
|
+
const result = manager.checkPermission("my_custom_tool", {});
|
|
341
|
+
expect(result.state).toBe("allow");
|
|
342
|
+
expect(result.source).toBe("tool");
|
|
343
|
+
} finally {
|
|
344
|
+
cleanup();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe("matchedPattern for session rules across surfaces", () => {
|
|
350
|
+
it("matchedPattern is the session rule pattern for a session match (bash)", () => {
|
|
351
|
+
const manager = makeManager();
|
|
352
|
+
const sessionRules: Ruleset = [sessionAllow("bash", "git *")];
|
|
353
|
+
const result = manager.checkPermission(
|
|
354
|
+
"bash",
|
|
355
|
+
{ command: "git status" },
|
|
356
|
+
undefined,
|
|
357
|
+
sessionRules,
|
|
358
|
+
);
|
|
359
|
+
expect(result.matchedPattern).toBe("git *");
|
|
360
|
+
expect(result.source).toBe("session");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("matchedPattern is the session rule pattern for a session match (skill)", () => {
|
|
364
|
+
const manager = makeManager();
|
|
365
|
+
const sessionRules: Ruleset = [sessionAllow("skill", "librarian")];
|
|
366
|
+
const result = manager.checkPermission(
|
|
367
|
+
"skill",
|
|
368
|
+
{ name: "librarian" },
|
|
369
|
+
undefined,
|
|
370
|
+
sessionRules,
|
|
371
|
+
);
|
|
372
|
+
expect(result.matchedPattern).toBe("librarian");
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
});
|
package/tests/rule.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import type { Rule, Ruleset } from "../src/rule";
|
|
3
|
-
import { evaluate } from "../src/rule";
|
|
3
|
+
import { evaluate, evaluateFirst } from "../src/rule";
|
|
4
4
|
|
|
5
5
|
describe("evaluate", () => {
|
|
6
6
|
const allowBashGit: Rule = {
|
|
@@ -169,3 +169,83 @@ describe("evaluate", () => {
|
|
|
169
169
|
);
|
|
170
170
|
});
|
|
171
171
|
});
|
|
172
|
+
|
|
173
|
+
describe("evaluateFirst", () => {
|
|
174
|
+
const defaultRule: Rule = {
|
|
175
|
+
surface: "*",
|
|
176
|
+
pattern: "*",
|
|
177
|
+
action: "ask",
|
|
178
|
+
layer: "default",
|
|
179
|
+
};
|
|
180
|
+
const allowBash: Rule = {
|
|
181
|
+
surface: "bash",
|
|
182
|
+
pattern: "git *",
|
|
183
|
+
action: "allow",
|
|
184
|
+
layer: "config",
|
|
185
|
+
};
|
|
186
|
+
const denyMcp: Rule = {
|
|
187
|
+
surface: "mcp",
|
|
188
|
+
pattern: "exa_search",
|
|
189
|
+
action: "deny",
|
|
190
|
+
layer: "config",
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
test("returns the first candidate that matches a non-default rule", () => {
|
|
194
|
+
const rules: Ruleset = [defaultRule, allowBash];
|
|
195
|
+
const result = evaluateFirst("bash", ["git status", "*"], rules);
|
|
196
|
+
expect(result.rule).toEqual(allowBash);
|
|
197
|
+
expect(result.value).toBe("git status");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("skips candidates that only match the default rule", () => {
|
|
201
|
+
// "npm install" matches only the default; "*" also matches only the
|
|
202
|
+
// default — falls back to first candidate.
|
|
203
|
+
const rules: Ruleset = [defaultRule];
|
|
204
|
+
const result = evaluateFirst("bash", ["npm install", "*"], rules);
|
|
205
|
+
expect(result.rule.layer).toBe("default");
|
|
206
|
+
expect(result.value).toBe("npm install");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("falls back to first candidate when all candidates match only the default", () => {
|
|
210
|
+
const rules: Ruleset = [defaultRule];
|
|
211
|
+
const result = evaluateFirst("bash", ["a", "b", "c"], rules);
|
|
212
|
+
expect(result.value).toBe("a");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("stops at first non-default match, does not continue to remaining candidates", () => {
|
|
216
|
+
// "exa_search" matches denyMcp (non-default). The loop stops there;
|
|
217
|
+
// "mcp" is never evaluated even though it would match a different rule.
|
|
218
|
+
const allowMcpCatchAll: Rule = {
|
|
219
|
+
surface: "mcp",
|
|
220
|
+
pattern: "mcp",
|
|
221
|
+
action: "allow",
|
|
222
|
+
layer: "config",
|
|
223
|
+
};
|
|
224
|
+
const rules: Ruleset = [defaultRule, denyMcp, allowMcpCatchAll];
|
|
225
|
+
const result = evaluateFirst("mcp", ["exa_search", "mcp"], rules);
|
|
226
|
+
expect(result.rule).toEqual(denyMcp);
|
|
227
|
+
expect(result.value).toBe("exa_search");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("skips candidates that match only the default and continues to next", () => {
|
|
231
|
+
// "unknown_tool" matches only the universal default;
|
|
232
|
+
// "exa_search" matches denyMcp (non-default) — that is the result.
|
|
233
|
+
const rules: Ruleset = [defaultRule, denyMcp];
|
|
234
|
+
const result = evaluateFirst("mcp", ["unknown_tool", "exa_search"], rules);
|
|
235
|
+
expect(result.rule).toEqual(denyMcp);
|
|
236
|
+
expect(result.value).toBe("exa_search");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("single-candidate array behaves like evaluate()", () => {
|
|
240
|
+
const rules: Ruleset = [defaultRule, allowBash];
|
|
241
|
+
const result = evaluateFirst("bash", ["git status"], rules);
|
|
242
|
+
expect(result.rule).toEqual(allowBash);
|
|
243
|
+
expect(result.value).toBe("git status");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("uses '*' as fallback value when values array is empty", () => {
|
|
247
|
+
const rules: Ruleset = [defaultRule];
|
|
248
|
+
const result = evaluateFirst("bash", [], rules);
|
|
249
|
+
expect(result.value).toBe("*");
|
|
250
|
+
});
|
|
251
|
+
});
|