@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/src/rule.ts CHANGED
@@ -45,3 +45,35 @@ export function evaluate(
45
45
  }
46
46
  return { surface, pattern, action: defaultAction ?? "ask" };
47
47
  }
48
+
49
+ /**
50
+ * Evaluate a surface against an ordered list of candidate values, stopping at
51
+ * the first candidate that matches a non-default rule (last-match-wins within
52
+ * each candidate, first-non-default-wins across candidates).
53
+ *
54
+ * Used by MCP (multi-candidate target list) and, uniformly, by all other
55
+ * surfaces (single-element candidate list).
56
+ *
57
+ * Returns the matched rule and the candidate value that produced it.
58
+ * When every candidate matches only the synthesized default, falls back to
59
+ * evaluating the first candidate so the caller always receives a concrete
60
+ * result.
61
+ */
62
+ export function evaluateFirst(
63
+ surface: string,
64
+ values: string[],
65
+ rules: Ruleset,
66
+ ): { rule: Rule; value: string } {
67
+ for (const value of values) {
68
+ const rule = evaluate(surface, value, rules);
69
+ if (rule.layer !== "default") {
70
+ return { rule, value };
71
+ }
72
+ }
73
+ // All candidates matched only the synthesized default — use the first.
74
+ const fallbackValue = values[0] ?? "*";
75
+ return {
76
+ rule: evaluate(surface, fallbackValue, rules),
77
+ value: fallbackValue,
78
+ };
79
+ }
@@ -0,0 +1,150 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeInput } from "../src/input-normalizer";
3
+ import { createMcpPermissionTargets } from "../src/mcp-targets";
4
+
5
+ describe("normalizeInput — non-MCP surfaces", () => {
6
+ describe("special / external_directory", () => {
7
+ it("uses path from input as the lookup value", () => {
8
+ const result = normalizeInput(
9
+ "external_directory",
10
+ { path: "/other/project" },
11
+ [],
12
+ );
13
+ expect(result.surface).toBe("external_directory");
14
+ expect(result.values).toEqual(["/other/project"]);
15
+ expect(result.resultExtras).toEqual({});
16
+ });
17
+
18
+ it("falls back to '*' when path is missing", () => {
19
+ const result = normalizeInput("external_directory", {}, []);
20
+ expect(result.values).toEqual(["*"]);
21
+ });
22
+
23
+ it("falls back to '*' when path is not a string", () => {
24
+ const result = normalizeInput("external_directory", { path: 42 }, []);
25
+ expect(result.values).toEqual(["*"]);
26
+ });
27
+
28
+ it("handles null input", () => {
29
+ const result = normalizeInput("external_directory", null, []);
30
+ expect(result.values).toEqual(["*"]);
31
+ });
32
+ });
33
+
34
+ describe("skill", () => {
35
+ it("uses skill name from input.name", () => {
36
+ const result = normalizeInput("skill", { name: "librarian" }, []);
37
+ expect(result.surface).toBe("skill");
38
+ expect(result.values).toEqual(["librarian"]);
39
+ expect(result.resultExtras).toEqual({});
40
+ });
41
+
42
+ it("falls back to '*' when name is missing", () => {
43
+ const result = normalizeInput("skill", {}, []);
44
+ expect(result.values).toEqual(["*"]);
45
+ });
46
+
47
+ it("falls back to '*' when name is not a string", () => {
48
+ const result = normalizeInput("skill", { name: 99 }, []);
49
+ expect(result.values).toEqual(["*"]);
50
+ });
51
+ });
52
+
53
+ describe("bash", () => {
54
+ it("uses command from input.command", () => {
55
+ const result = normalizeInput("bash", { command: "git status" }, []);
56
+ expect(result.surface).toBe("bash");
57
+ expect(result.values).toEqual(["git status"]);
58
+ expect(result.resultExtras).toEqual({ command: "git status" });
59
+ });
60
+
61
+ it("uses empty string when command is missing", () => {
62
+ const result = normalizeInput("bash", {}, []);
63
+ expect(result.values).toEqual([""]);
64
+ expect(result.resultExtras).toEqual({ command: "" });
65
+ });
66
+
67
+ it("uses empty string when command is not a string", () => {
68
+ const result = normalizeInput("bash", { command: 42 }, []);
69
+ expect(result.values).toEqual([""]);
70
+ expect(result.resultExtras).toEqual({ command: "" });
71
+ });
72
+ });
73
+
74
+ describe("tool surfaces (read, write, edit, grep, find, ls, extension tools)", () => {
75
+ it("uses '*' as the lookup value for built-in tools", () => {
76
+ for (const tool of ["read", "write", "edit", "grep", "find", "ls"]) {
77
+ const result = normalizeInput(tool, {}, []);
78
+ expect(result.surface).toBe(tool);
79
+ expect(result.values).toEqual(["*"]);
80
+ expect(result.resultExtras).toEqual({});
81
+ }
82
+ });
83
+
84
+ it("uses '*' as the lookup value for extension tools", () => {
85
+ const result = normalizeInput("my_extension_tool", { some: "input" }, []);
86
+ expect(result.surface).toBe("my_extension_tool");
87
+ expect(result.values).toEqual(["*"]);
88
+ expect(result.resultExtras).toEqual({});
89
+ });
90
+ });
91
+ });
92
+
93
+ describe("normalizeInput — MCP surface", () => {
94
+ it("surface is 'mcp'", () => {
95
+ const result = normalizeInput("mcp", { tool: "exa:search" }, []);
96
+ expect(result.surface).toBe("mcp");
97
+ });
98
+
99
+ it("values end with the catch-all 'mcp' target", () => {
100
+ const result = normalizeInput("mcp", { tool: "exa:search" }, []);
101
+ expect(result.values.at(-1)).toBe("mcp");
102
+ });
103
+
104
+ it("values include specific targets before the catch-all for a qualified tool call", () => {
105
+ const result = normalizeInput("mcp", { tool: "exa:search" }, []);
106
+ expect(result.values).toContain("exa_search");
107
+ expect(result.values).toContain("exa:search");
108
+ expect(result.values).toContain("exa");
109
+ expect(result.values).toContain("mcp_call");
110
+ // 'mcp' is always last
111
+ expect(result.values.at(-1)).toBe("mcp");
112
+ });
113
+
114
+ it("matches createMcpPermissionTargets output + 'mcp' appended", () => {
115
+ const rawTargets = createMcpPermissionTargets({ tool: "exa:search" }, [
116
+ "exa",
117
+ ]);
118
+ const result = normalizeInput("mcp", { tool: "exa:search" }, ["exa"]);
119
+ expect(result.values).toEqual([...rawTargets, "mcp"]);
120
+ });
121
+
122
+ it("resultExtras.target is the first specific target (most-specific)", () => {
123
+ const result = normalizeInput("mcp", { tool: "exa:search" }, []);
124
+ expect(result.resultExtras.target).toBe(result.values[0]);
125
+ });
126
+
127
+ it("resultExtras.target is 'mcp' when no specific targets are derived", () => {
128
+ // Empty input → only mcp_status then mcp appended
129
+ const result = normalizeInput("mcp", {}, []);
130
+ expect(result.resultExtras.target).toBe("mcp_status");
131
+ });
132
+
133
+ it("values contain no duplicates", () => {
134
+ const result = normalizeInput("mcp", { tool: "exa:search" }, ["exa"]);
135
+ const unique = [...new Set(result.values)];
136
+ expect(result.values).toEqual(unique);
137
+ });
138
+
139
+ it("produces mcp_status + mcp for status input", () => {
140
+ const result = normalizeInput("mcp", {}, []);
141
+ expect(result.values).toEqual(["mcp_status", "mcp"]);
142
+ });
143
+
144
+ it("produces connect targets + mcp for connect input", () => {
145
+ const result = normalizeInput("mcp", { connect: "exa" }, []);
146
+ expect(result.values).toContain("mcp_connect_exa");
147
+ expect(result.values).toContain("mcp_connect");
148
+ expect(result.values.at(-1)).toBe("mcp");
149
+ });
150
+ });
@@ -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
+ });