@gotgenes/pi-permission-system 4.4.1 → 4.6.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 +31 -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 +36 -8
- package/src/wildcard-matcher.ts +7 -12
- 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,178 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createMcpPermissionTargets,
|
|
4
|
+
parseQualifiedMcpToolName,
|
|
5
|
+
} from "../src/mcp-targets";
|
|
6
|
+
|
|
7
|
+
describe("parseQualifiedMcpToolName", () => {
|
|
8
|
+
it("returns server and tool for a valid qualified name", () => {
|
|
9
|
+
expect(parseQualifiedMcpToolName("exa:search")).toEqual({
|
|
10
|
+
server: "exa",
|
|
11
|
+
tool: "search",
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns server and tool with surrounding whitespace trimmed", () => {
|
|
16
|
+
expect(parseQualifiedMcpToolName(" exa : search ")).toEqual({
|
|
17
|
+
server: "exa",
|
|
18
|
+
tool: "search",
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns null for empty string", () => {
|
|
23
|
+
expect(parseQualifiedMcpToolName("")).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns null for whitespace-only string", () => {
|
|
27
|
+
expect(parseQualifiedMcpToolName(" ")).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns null when colon is the first character", () => {
|
|
31
|
+
expect(parseQualifiedMcpToolName(":search")).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns null when colon is the last character", () => {
|
|
35
|
+
expect(parseQualifiedMcpToolName("exa:")).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns null for a plain tool name with no colon", () => {
|
|
39
|
+
expect(parseQualifiedMcpToolName("exa_search")).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns null when server part is empty after trimming", () => {
|
|
43
|
+
expect(parseQualifiedMcpToolName(" :search")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns null when tool part is empty after trimming", () => {
|
|
47
|
+
expect(parseQualifiedMcpToolName("exa: ")).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("createMcpPermissionTargets", () => {
|
|
52
|
+
describe("tool call (input.tool)", () => {
|
|
53
|
+
it("produces targets for a bare tool name with no configured servers", () => {
|
|
54
|
+
const targets = createMcpPermissionTargets({ tool: "exa_search" }, []);
|
|
55
|
+
expect(targets).toContain("exa_search");
|
|
56
|
+
expect(targets).toContain("mcp_call");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("produces targets for a qualified tool name (server:tool)", () => {
|
|
60
|
+
const targets = createMcpPermissionTargets({ tool: "exa:search" }, []);
|
|
61
|
+
expect(targets).toContain("exa_search");
|
|
62
|
+
expect(targets).toContain("exa:search");
|
|
63
|
+
expect(targets).toContain("exa");
|
|
64
|
+
expect(targets).toContain("mcp_call");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("produces targets for a tool call with explicit server field", () => {
|
|
68
|
+
const targets = createMcpPermissionTargets(
|
|
69
|
+
{ tool: "search", server: "exa" },
|
|
70
|
+
[],
|
|
71
|
+
);
|
|
72
|
+
expect(targets).toContain("exa_search");
|
|
73
|
+
expect(targets).toContain("exa:search");
|
|
74
|
+
expect(targets).toContain("exa");
|
|
75
|
+
expect(targets).toContain("mcp_call");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("derives server targets from configured server names when tool name ends with _<server>", () => {
|
|
79
|
+
const targets = createMcpPermissionTargets({ tool: "exa_search" }, [
|
|
80
|
+
"exa",
|
|
81
|
+
]);
|
|
82
|
+
// exa_search ends with _exa? No — it ends with _search. This tool name
|
|
83
|
+
// does NOT trigger server derivation because it does not end with _exa.
|
|
84
|
+
expect(targets).toContain("exa_search");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not include duplicate entries", () => {
|
|
88
|
+
const targets = createMcpPermissionTargets({ tool: "exa:search" }, [
|
|
89
|
+
"exa",
|
|
90
|
+
]);
|
|
91
|
+
const unique = [...new Set(targets)];
|
|
92
|
+
expect(targets).toEqual(unique);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("connect call (input.connect)", () => {
|
|
97
|
+
it("produces targets for a connect operation", () => {
|
|
98
|
+
const targets = createMcpPermissionTargets({ connect: "exa" }, []);
|
|
99
|
+
expect(targets).toContain("mcp_connect_exa");
|
|
100
|
+
expect(targets).toContain("exa");
|
|
101
|
+
expect(targets).toContain("mcp_connect");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("does not include mcp_call for connect operations", () => {
|
|
105
|
+
const targets = createMcpPermissionTargets({ connect: "exa" }, []);
|
|
106
|
+
expect(targets).not.toContain("mcp_call");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("describe operation (input.describe)", () => {
|
|
111
|
+
it("produces targets for a describe operation on a qualified tool", () => {
|
|
112
|
+
const targets = createMcpPermissionTargets(
|
|
113
|
+
{ describe: "exa:search" },
|
|
114
|
+
[],
|
|
115
|
+
);
|
|
116
|
+
expect(targets).toContain("exa_search");
|
|
117
|
+
expect(targets).toContain("exa:search");
|
|
118
|
+
expect(targets).toContain("exa");
|
|
119
|
+
expect(targets).toContain("mcp_describe");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("search operation (input.search)", () => {
|
|
124
|
+
it("produces mcp_search and the search string as targets", () => {
|
|
125
|
+
const targets = createMcpPermissionTargets({ search: "weather" }, []);
|
|
126
|
+
expect(targets).toContain("weather");
|
|
127
|
+
expect(targets).toContain("mcp_search");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("includes server targets when server is provided alongside search", () => {
|
|
131
|
+
const targets = createMcpPermissionTargets(
|
|
132
|
+
{ search: "weather", server: "exa" },
|
|
133
|
+
[],
|
|
134
|
+
);
|
|
135
|
+
expect(targets).toContain("mcp_server_exa");
|
|
136
|
+
expect(targets).toContain("exa");
|
|
137
|
+
expect(targets).toContain("mcp_search");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("server listing (input.server only)", () => {
|
|
142
|
+
it("produces mcp_list and server-specific targets", () => {
|
|
143
|
+
const targets = createMcpPermissionTargets({ server: "exa" }, []);
|
|
144
|
+
expect(targets).toContain("mcp_server_exa");
|
|
145
|
+
expect(targets).toContain("exa");
|
|
146
|
+
expect(targets).toContain("mcp_list");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("status (no meaningful input)", () => {
|
|
151
|
+
it("produces mcp_status for empty input", () => {
|
|
152
|
+
const targets = createMcpPermissionTargets({}, []);
|
|
153
|
+
expect(targets).toContain("mcp_status");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("produces mcp_status for null input", () => {
|
|
157
|
+
const targets = createMcpPermissionTargets(null, []);
|
|
158
|
+
expect(targets).toContain("mcp_status");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("produces mcp_status when no server/tool/connect/describe/search present", () => {
|
|
162
|
+
const targets = createMcpPermissionTargets({ unrelated: "value" }, [
|
|
163
|
+
"exa",
|
|
164
|
+
]);
|
|
165
|
+
expect(targets).toContain("mcp_status");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("priority ordering", () => {
|
|
170
|
+
it("tool targets appear before mcp_call", () => {
|
|
171
|
+
const targets = createMcpPermissionTargets({ tool: "exa:search" }, []);
|
|
172
|
+
const mcpCallIdx = targets.indexOf("mcp_call");
|
|
173
|
+
const exaSearchIdx = targets.indexOf("exa_search");
|
|
174
|
+
expect(exaSearchIdx).toBeGreaterThanOrEqual(0);
|
|
175
|
+
expect(mcpCallIdx).toBeGreaterThan(exaSearchIdx);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -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
|
+
});
|