@komarspn/pi-permission-system 16.0.2

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.
Files changed (203) hide show
  1. package/CHANGELOG.md +2234 -0
  2. package/LICENSE +21 -0
  3. package/README.md +158 -0
  4. package/config/config.example.json +39 -0
  5. package/package.json +82 -0
  6. package/schemas/permissions.schema.json +158 -0
  7. package/src/active-agent.ts +72 -0
  8. package/src/async-cache.ts +21 -0
  9. package/src/bash-arity.ts +210 -0
  10. package/src/builtin-tool-input-formatters.ts +82 -0
  11. package/src/canonicalize-path.ts +30 -0
  12. package/src/common.ts +121 -0
  13. package/src/config-loader.ts +432 -0
  14. package/src/config-modal.ts +259 -0
  15. package/src/config-paths.ts +47 -0
  16. package/src/config-reporter.ts +34 -0
  17. package/src/config-store.ts +222 -0
  18. package/src/decision-audit.ts +75 -0
  19. package/src/decision-reporter.ts +41 -0
  20. package/src/denial-messages.ts +232 -0
  21. package/src/expand-home.ts +28 -0
  22. package/src/extension-config.ts +79 -0
  23. package/src/extension-paths.ts +66 -0
  24. package/src/forwarded-permissions/io.ts +404 -0
  25. package/src/forwarded-permissions/permission-forwarder.ts +580 -0
  26. package/src/forwarding-manager.ts +74 -0
  27. package/src/gate-prompter.ts +12 -0
  28. package/src/handlers/before-agent-start.ts +94 -0
  29. package/src/handlers/gates/bash-command.ts +75 -0
  30. package/src/handlers/gates/bash-external-directory.ts +127 -0
  31. package/src/handlers/gates/bash-path-extractor.ts +15 -0
  32. package/src/handlers/gates/bash-path.ts +152 -0
  33. package/src/handlers/gates/bash-program.ts +1143 -0
  34. package/src/handlers/gates/bash-token-classification.ts +105 -0
  35. package/src/handlers/gates/candidate-check.ts +32 -0
  36. package/src/handlers/gates/descriptor.ts +81 -0
  37. package/src/handlers/gates/external-directory-messages.ts +20 -0
  38. package/src/handlers/gates/external-directory.ts +133 -0
  39. package/src/handlers/gates/helpers.ts +76 -0
  40. package/src/handlers/gates/path.ts +91 -0
  41. package/src/handlers/gates/runner.ts +186 -0
  42. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  43. package/src/handlers/gates/skill-input.ts +46 -0
  44. package/src/handlers/gates/skill-read.ts +87 -0
  45. package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
  46. package/src/handlers/gates/tool.ts +102 -0
  47. package/src/handlers/gates/types.ts +13 -0
  48. package/src/handlers/index.ts +3 -0
  49. package/src/handlers/lifecycle.ts +95 -0
  50. package/src/handlers/permission-gate-handler.ts +190 -0
  51. package/src/handlers/tool-call-boundary.ts +91 -0
  52. package/src/index.ts +225 -0
  53. package/src/input-normalizer.ts +157 -0
  54. package/src/logging.ts +113 -0
  55. package/src/mcp-targets.ts +170 -0
  56. package/src/node-modules-discovery.ts +76 -0
  57. package/src/normalize.ts +43 -0
  58. package/src/path-utils.ts +355 -0
  59. package/src/pattern-suggest.ts +132 -0
  60. package/src/permission-dialog.ts +138 -0
  61. package/src/permission-event-rpc.ts +223 -0
  62. package/src/permission-events.ts +266 -0
  63. package/src/permission-forwarding.ts +188 -0
  64. package/src/permission-gate.ts +94 -0
  65. package/src/permission-manager.ts +392 -0
  66. package/src/permission-merge.ts +32 -0
  67. package/src/permission-prompter.ts +142 -0
  68. package/src/permission-prompts.ts +93 -0
  69. package/src/permission-resolver.ts +109 -0
  70. package/src/permission-session.ts +189 -0
  71. package/src/permission-ui-prompt.ts +127 -0
  72. package/src/permissions-service.ts +63 -0
  73. package/src/persistent-approval-recorder.ts +139 -0
  74. package/src/policy-loader.ts +350 -0
  75. package/src/prompting-gateway.ts +104 -0
  76. package/src/rule.ts +188 -0
  77. package/src/scope-merge.ts +72 -0
  78. package/src/service-lifecycle.ts +49 -0
  79. package/src/service.ts +163 -0
  80. package/src/session-approval-recorder.ts +6 -0
  81. package/src/session-approval.ts +43 -0
  82. package/src/session-logger.ts +91 -0
  83. package/src/session-rules.ts +79 -0
  84. package/src/skill-prompt-sanitizer.ts +292 -0
  85. package/src/status.ts +35 -0
  86. package/src/subagent-context.ts +104 -0
  87. package/src/subagent-lifecycle-events.ts +72 -0
  88. package/src/subagent-registry.ts +105 -0
  89. package/src/synthesize.ts +92 -0
  90. package/src/system-prompt-sanitizer.ts +274 -0
  91. package/src/tool-access-extractor-registry.ts +68 -0
  92. package/src/tool-input-formatter-registry.ts +67 -0
  93. package/src/tool-input-preview.ts +34 -0
  94. package/src/tool-input-prompt-formatters.ts +63 -0
  95. package/src/tool-preview-formatter.ts +207 -0
  96. package/src/tool-registry.ts +148 -0
  97. package/src/types.ts +64 -0
  98. package/src/wildcard-matcher.ts +120 -0
  99. package/src/yolo-mode.ts +30 -0
  100. package/test/active-agent.test.ts +155 -0
  101. package/test/async-cache.test.ts +48 -0
  102. package/test/bash-arity.test.ts +144 -0
  103. package/test/bash-external-directory.test.ts +956 -0
  104. package/test/builtin-tool-input-formatters.test.ts +109 -0
  105. package/test/canonicalize-path.test.ts +93 -0
  106. package/test/common.test.ts +287 -0
  107. package/test/composition-root.test.ts +603 -0
  108. package/test/config-loader.test.ts +740 -0
  109. package/test/config-modal.test.ts +320 -0
  110. package/test/config-paths.test.ts +83 -0
  111. package/test/config-pipeline.test.ts +90 -0
  112. package/test/config-reporter.test.ts +147 -0
  113. package/test/config-store.test.ts +466 -0
  114. package/test/decision-audit.test.ts +72 -0
  115. package/test/decision-reporter.test.ts +112 -0
  116. package/test/denial-messages.test.ts +656 -0
  117. package/test/detect-permissive-bash-fallback.test.ts +56 -0
  118. package/test/expand-home.test.ts +93 -0
  119. package/test/extension-config.test.ts +129 -0
  120. package/test/extension-paths.test.ts +108 -0
  121. package/test/forwarded-permissions/io.test.ts +251 -0
  122. package/test/forwarding-manager.test.ts +194 -0
  123. package/test/handlers/before-agent-start.test.ts +317 -0
  124. package/test/handlers/external-directory-integration.test.ts +623 -0
  125. package/test/handlers/external-directory-session-dedup.test.ts +430 -0
  126. package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
  127. package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
  128. package/test/handlers/gates/bash-command.test.ts +191 -0
  129. package/test/handlers/gates/bash-external-directory.test.ts +269 -0
  130. package/test/handlers/gates/bash-path.test.ts +337 -0
  131. package/test/handlers/gates/bash-program.test.ts +410 -0
  132. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  133. package/test/handlers/gates/candidate-check.test.ts +52 -0
  134. package/test/handlers/gates/external-directory-messages.test.ts +61 -0
  135. package/test/handlers/gates/external-directory.test.ts +259 -0
  136. package/test/handlers/gates/helpers.test.ts +177 -0
  137. package/test/handlers/gates/path.test.ts +294 -0
  138. package/test/handlers/gates/runner.test.ts +447 -0
  139. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  140. package/test/handlers/gates/skill-input.test.ts +131 -0
  141. package/test/handlers/gates/skill-read.test.ts +158 -0
  142. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
  143. package/test/handlers/gates/tool.test.ts +223 -0
  144. package/test/handlers/input-events.test.ts +168 -0
  145. package/test/handlers/input.test.ts +199 -0
  146. package/test/handlers/lifecycle.test.ts +221 -0
  147. package/test/handlers/tool-call-boundary.test.ts +145 -0
  148. package/test/handlers/tool-call-events.test.ts +277 -0
  149. package/test/handlers/tool-call.test.ts +395 -0
  150. package/test/handlers/validate-requested-tool.test.ts +92 -0
  151. package/test/helpers/gate-fixtures.ts +323 -0
  152. package/test/helpers/handler-fixtures.ts +335 -0
  153. package/test/helpers/make-fake-pi.ts +100 -0
  154. package/test/helpers/manager-harness.ts +112 -0
  155. package/test/helpers/session-fixtures.ts +204 -0
  156. package/test/input-normalizer.test.ts +367 -0
  157. package/test/logging.test.ts +51 -0
  158. package/test/mcp-targets.test.ts +233 -0
  159. package/test/node-modules-discovery.test.ts +97 -0
  160. package/test/normalize.test.ts +247 -0
  161. package/test/path-utils.test.ts +650 -0
  162. package/test/pattern-suggest.test.ts +248 -0
  163. package/test/permission-dialog.test.ts +241 -0
  164. package/test/permission-event-rpc.test.ts +541 -0
  165. package/test/permission-events.test.ts +402 -0
  166. package/test/permission-forwarder.test.ts +369 -0
  167. package/test/permission-forwarding.test.ts +315 -0
  168. package/test/permission-gate.test.ts +305 -0
  169. package/test/permission-manager-unified.test.ts +3368 -0
  170. package/test/permission-merge.test.ts +61 -0
  171. package/test/permission-prompter.test.ts +518 -0
  172. package/test/permission-prompts.test.ts +363 -0
  173. package/test/permission-resolver.test.ts +265 -0
  174. package/test/permission-session.test.ts +363 -0
  175. package/test/permission-ui-prompt.test.ts +146 -0
  176. package/test/permissions-service.test.ts +177 -0
  177. package/test/persistent-approval-recorder.test.ts +133 -0
  178. package/test/pi-infrastructure-read.test.ts +369 -0
  179. package/test/policy-loader.test.ts +561 -0
  180. package/test/prompting-gateway.test.ts +230 -0
  181. package/test/rule.test.ts +604 -0
  182. package/test/scope-merge.test.ts +116 -0
  183. package/test/service-lifecycle.test.ts +163 -0
  184. package/test/service.test.ts +308 -0
  185. package/test/session-approval.test.ts +75 -0
  186. package/test/session-logger.test.ts +200 -0
  187. package/test/session-rules.test.ts +304 -0
  188. package/test/session-start.test.ts +112 -0
  189. package/test/skill-prompt-sanitizer.test.ts +374 -0
  190. package/test/status.test.ts +10 -0
  191. package/test/subagent-context.test.ts +326 -0
  192. package/test/subagent-lifecycle-events.test.ts +132 -0
  193. package/test/subagent-registry.test.ts +145 -0
  194. package/test/synthesize.test.ts +300 -0
  195. package/test/system-prompt-sanitizer.test.ts +382 -0
  196. package/test/tool-access-extractor-registry.test.ts +77 -0
  197. package/test/tool-input-formatter-registry.test.ts +75 -0
  198. package/test/tool-input-preview.test.ts +129 -0
  199. package/test/tool-input-prompt-formatters.test.ts +115 -0
  200. package/test/tool-preview-formatter.test.ts +458 -0
  201. package/test/tool-registry.test.ts +197 -0
  202. package/test/wildcard-matcher.test.ts +424 -0
  203. package/test/yolo-mode.test.ts +188 -0
@@ -0,0 +1,367 @@
1
+ import { join } from "node:path";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const mockHomedir = vi.hoisted(() => vi.fn(() => "/mock/home"));
5
+
6
+ vi.mock("node:os", () => ({
7
+ homedir: mockHomedir,
8
+ default: { homedir: mockHomedir },
9
+ }));
10
+
11
+ import { normalizeInput } from "#src/input-normalizer";
12
+ import { createMcpPermissionTargets } from "#src/mcp-targets";
13
+
14
+ afterEach(() => {
15
+ mockHomedir.mockClear();
16
+ });
17
+
18
+ describe("normalizeInput — non-MCP surfaces", () => {
19
+ describe("special / path", () => {
20
+ it("uses path from input as the lookup value", () => {
21
+ const result = normalizeInput("path", { path: ".env" }, []);
22
+ expect(result.surface).toBe("path");
23
+ expect(result.values).toEqual([".env"]);
24
+ expect(result.resultExtras).toEqual({});
25
+ });
26
+
27
+ it("falls back to '*' when path is missing", () => {
28
+ const result = normalizeInput("path", {}, []);
29
+ expect(result.values).toEqual(["*"]);
30
+ });
31
+
32
+ it("falls back to '*' when path is not a string", () => {
33
+ const result = normalizeInput("path", { path: 42 }, []);
34
+ expect(result.values).toEqual(["*"]);
35
+ });
36
+
37
+ it("falls back to '*' when path is an empty string", () => {
38
+ const result = normalizeInput("path", { path: "" }, []);
39
+ expect(result.values).toEqual(["*"]);
40
+ });
41
+
42
+ it("falls back to '*' when path is whitespace-only", () => {
43
+ const result = normalizeInput("path", { path: " " }, []);
44
+ expect(result.values).toEqual(["*"]);
45
+ });
46
+
47
+ it("handles null input", () => {
48
+ const result = normalizeInput("path", null, []);
49
+ expect(result.values).toEqual(["*"]);
50
+ });
51
+
52
+ it("expands ~/... path value to absolute home path", () => {
53
+ const result = normalizeInput("path", { path: "~/.ssh/config" }, []);
54
+ expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
55
+ });
56
+
57
+ it("expands $HOME/... path value to absolute home path", () => {
58
+ const result = normalizeInput("path", { path: "$HOME/.ssh/config" }, []);
59
+ expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
60
+ });
61
+
62
+ it("does not expand non-home values", () => {
63
+ const result = normalizeInput("path", { path: ".env" }, []);
64
+ expect(result.values).toEqual([".env"]);
65
+ });
66
+
67
+ it("does not expand the '*' fallback", () => {
68
+ const result = normalizeInput("path", {}, []);
69
+ expect(result.values).toEqual(["*"]);
70
+ });
71
+
72
+ it("adds cwd-normalized and relative aliases when cwd is provided", () => {
73
+ const result = normalizeInput(
74
+ "path",
75
+ { path: "src/App.jsx" },
76
+ [],
77
+ "/workspace/project",
78
+ );
79
+ expect(result.values).toEqual([
80
+ "/workspace/project/src/App.jsx",
81
+ "src/App.jsx",
82
+ ]);
83
+ });
84
+
85
+ it("ignores a user-supplied string pathPolicyValues field", () => {
86
+ const result = normalizeInput(
87
+ "path",
88
+ { path: "src/App.jsx", pathPolicyValues: ["/etc/shadow"] },
89
+ [],
90
+ "/workspace/project",
91
+ );
92
+ expect(result.values).toEqual([
93
+ "/workspace/project/src/App.jsx",
94
+ "src/App.jsx",
95
+ ]);
96
+ });
97
+ });
98
+
99
+ describe("special / external_directory", () => {
100
+ it("uses path from input as the lookup value", () => {
101
+ const result = normalizeInput(
102
+ "external_directory",
103
+ { path: "/other/project" },
104
+ [],
105
+ );
106
+ expect(result.surface).toBe("external_directory");
107
+ expect(result.values).toEqual(["/other/project"]);
108
+ expect(result.resultExtras).toEqual({});
109
+ });
110
+
111
+ it("falls back to '*' when path is missing", () => {
112
+ const result = normalizeInput("external_directory", {}, []);
113
+ expect(result.values).toEqual(["*"]);
114
+ });
115
+
116
+ it("falls back to '*' when path is not a string", () => {
117
+ const result = normalizeInput("external_directory", { path: 42 }, []);
118
+ expect(result.values).toEqual(["*"]);
119
+ });
120
+
121
+ it("falls back to '*' when path is an empty string", () => {
122
+ const result = normalizeInput("external_directory", { path: "" }, []);
123
+ expect(result.values).toEqual(["*"]);
124
+ });
125
+
126
+ it("handles null input", () => {
127
+ const result = normalizeInput("external_directory", null, []);
128
+ expect(result.values).toEqual(["*"]);
129
+ });
130
+
131
+ it("expands ~/... path value to absolute home path", () => {
132
+ const result = normalizeInput(
133
+ "external_directory",
134
+ { path: "~/dev/project" },
135
+ [],
136
+ );
137
+ expect(result.values).toEqual([join("/mock/home", "dev/project")]);
138
+ });
139
+
140
+ it("expands $HOME/... path value to absolute home path", () => {
141
+ const result = normalizeInput(
142
+ "external_directory",
143
+ { path: "$HOME/dev/project" },
144
+ [],
145
+ );
146
+ expect(result.values).toEqual([join("/mock/home", "dev/project")]);
147
+ });
148
+
149
+ it("adds cwd-normalized and relative aliases when cwd is provided", () => {
150
+ const result = normalizeInput(
151
+ "external_directory",
152
+ { path: "src/App.jsx" },
153
+ [],
154
+ "/workspace/project",
155
+ );
156
+ expect(result.values).toEqual([
157
+ "/workspace/project/src/App.jsx",
158
+ "src/App.jsx",
159
+ ]);
160
+ });
161
+ });
162
+
163
+ describe("skill", () => {
164
+ it("uses skill name from input.name", () => {
165
+ const result = normalizeInput("skill", { name: "librarian" }, []);
166
+ expect(result.surface).toBe("skill");
167
+ expect(result.values).toEqual(["librarian"]);
168
+ expect(result.resultExtras).toEqual({});
169
+ });
170
+
171
+ it("falls back to '*' when name is missing", () => {
172
+ const result = normalizeInput("skill", {}, []);
173
+ expect(result.values).toEqual(["*"]);
174
+ });
175
+
176
+ it("falls back to '*' when name is not a string", () => {
177
+ const result = normalizeInput("skill", { name: 99 }, []);
178
+ expect(result.values).toEqual(["*"]);
179
+ });
180
+ });
181
+
182
+ describe("bash", () => {
183
+ it("uses command from input.command", () => {
184
+ const result = normalizeInput("bash", { command: "git status" }, []);
185
+ expect(result.surface).toBe("bash");
186
+ expect(result.values).toEqual(["git status"]);
187
+ expect(result.resultExtras).toEqual({ command: "git status" });
188
+ });
189
+
190
+ it("uses empty string when command is missing", () => {
191
+ const result = normalizeInput("bash", {}, []);
192
+ expect(result.values).toEqual([""]);
193
+ expect(result.resultExtras).toEqual({ command: "" });
194
+ });
195
+
196
+ it("uses empty string when command is not a string", () => {
197
+ const result = normalizeInput("bash", { command: 42 }, []);
198
+ expect(result.values).toEqual([""]);
199
+ expect(result.resultExtras).toEqual({ command: "" });
200
+ });
201
+
202
+ it("strips leading comment lines from values but keeps original in resultExtras", () => {
203
+ const cmd = "# Check debug logs\nfind /home -path '*debug*' -type f";
204
+ const result = normalizeInput("bash", { command: cmd }, []);
205
+ expect(result.values).toEqual(["find /home -path '*debug*' -type f"]);
206
+ expect(result.resultExtras).toEqual({ command: cmd });
207
+ });
208
+
209
+ it("strips multiple comment lines", () => {
210
+ const cmd = "# Step 1\n# Step 2\ngit status --short";
211
+ const result = normalizeInput("bash", { command: cmd }, []);
212
+ expect(result.values).toEqual(["git status --short"]);
213
+ });
214
+
215
+ it("preserves command when no comment lines present", () => {
216
+ const result = normalizeInput(
217
+ "bash",
218
+ { command: "grep -rn foo src/" },
219
+ [],
220
+ );
221
+ expect(result.values).toEqual(["grep -rn foo src/"]);
222
+ });
223
+
224
+ it("falls back to original when all lines are comments", () => {
225
+ const cmd = "# just a comment";
226
+ const result = normalizeInput("bash", { command: cmd }, []);
227
+ expect(result.values).toEqual(["# just a comment"]);
228
+ });
229
+ });
230
+
231
+ describe("path-bearing tools (read, write, edit, grep, find, ls)", () => {
232
+ it("uses input.path as the lookup value when path is present", () => {
233
+ for (const tool of ["read", "write", "edit", "grep", "find", "ls"]) {
234
+ const result = normalizeInput(
235
+ tool,
236
+ { path: "/project/src/main.ts" },
237
+ [],
238
+ );
239
+ expect(result.surface).toBe(tool);
240
+ expect(result.values).toEqual(["/project/src/main.ts"]);
241
+ expect(result.resultExtras).toEqual({});
242
+ }
243
+ });
244
+
245
+ it("falls back to '*' when input.path is missing", () => {
246
+ for (const tool of ["read", "write", "edit", "grep", "find", "ls"]) {
247
+ const result = normalizeInput(tool, {}, []);
248
+ expect(result.values).toEqual(["*"]);
249
+ }
250
+ });
251
+
252
+ it("falls back to '*' when input.path is empty string", () => {
253
+ const result = normalizeInput("read", { path: "" }, []);
254
+ expect(result.values).toEqual(["*"]);
255
+ });
256
+
257
+ it("falls back to '*' when input.path is not a string", () => {
258
+ const result = normalizeInput("write", { path: 42 }, []);
259
+ expect(result.values).toEqual(["*"]);
260
+ });
261
+
262
+ it("falls back to '*' when input is null", () => {
263
+ const result = normalizeInput("edit", null, []);
264
+ expect(result.values).toEqual(["*"]);
265
+ });
266
+
267
+ it("expands ~/... path value to absolute home path", () => {
268
+ const result = normalizeInput("read", { path: "~/.ssh/config" }, []);
269
+ expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
270
+ });
271
+
272
+ it("expands $HOME/... path value to absolute home path", () => {
273
+ const result = normalizeInput("write", { path: "$HOME/.ssh/config" }, []);
274
+ expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
275
+ });
276
+
277
+ it("adds cwd-normalized and relative aliases when cwd is provided", () => {
278
+ const result = normalizeInput(
279
+ "read",
280
+ { path: "src/App.jsx" },
281
+ [],
282
+ "/workspace/project",
283
+ );
284
+ expect(result.values).toEqual([
285
+ "/workspace/project/src/App.jsx",
286
+ "src/App.jsx",
287
+ ]);
288
+ });
289
+ });
290
+
291
+ describe("extension tools (non-path-bearing)", () => {
292
+ it("uses '*' as the lookup value for extension tools", () => {
293
+ const result = normalizeInput("my_extension_tool", { some: "input" }, []);
294
+ expect(result.surface).toBe("my_extension_tool");
295
+ expect(result.values).toEqual(["*"]);
296
+ expect(result.resultExtras).toEqual({});
297
+ });
298
+
299
+ it("uses '*' even when extension tool has a path field", () => {
300
+ const result = normalizeInput(
301
+ "my_extension_tool",
302
+ { path: "/some/path" },
303
+ [],
304
+ );
305
+ expect(result.values).toEqual(["*"]);
306
+ });
307
+ });
308
+ });
309
+
310
+ describe("normalizeInput — MCP surface", () => {
311
+ it("surface is 'mcp'", () => {
312
+ const result = normalizeInput("mcp", { tool: "exa:search" }, []);
313
+ expect(result.surface).toBe("mcp");
314
+ });
315
+
316
+ it("values end with the catch-all 'mcp' target", () => {
317
+ const result = normalizeInput("mcp", { tool: "exa:search" }, []);
318
+ expect(result.values.at(-1)).toBe("mcp");
319
+ });
320
+
321
+ it("values include specific targets before the catch-all for a qualified tool call", () => {
322
+ const result = normalizeInput("mcp", { tool: "exa:search" }, []);
323
+ expect(result.values).toContain("exa_search");
324
+ expect(result.values).toContain("exa:search");
325
+ expect(result.values).toContain("exa");
326
+ expect(result.values).toContain("mcp_call");
327
+ // 'mcp' is always last
328
+ expect(result.values.at(-1)).toBe("mcp");
329
+ });
330
+
331
+ it("matches createMcpPermissionTargets output + 'mcp' appended", () => {
332
+ const rawTargets = createMcpPermissionTargets({ tool: "exa:search" }, [
333
+ "exa",
334
+ ]);
335
+ const result = normalizeInput("mcp", { tool: "exa:search" }, ["exa"]);
336
+ expect(result.values).toEqual([...rawTargets, "mcp"]);
337
+ });
338
+
339
+ it("resultExtras.target is the first specific target (most-specific)", () => {
340
+ const result = normalizeInput("mcp", { tool: "exa:search" }, []);
341
+ expect(result.resultExtras.target).toBe(result.values[0]);
342
+ });
343
+
344
+ it("resultExtras.target is 'mcp' when no specific targets are derived", () => {
345
+ // Empty input → only mcp_status then mcp appended
346
+ const result = normalizeInput("mcp", {}, []);
347
+ expect(result.resultExtras.target).toBe("mcp_status");
348
+ });
349
+
350
+ it("values contain no duplicates", () => {
351
+ const result = normalizeInput("mcp", { tool: "exa:search" }, ["exa"]);
352
+ const unique = [...new Set(result.values)];
353
+ expect(result.values).toEqual(unique);
354
+ });
355
+
356
+ it("produces mcp_status + mcp for status input", () => {
357
+ const result = normalizeInput("mcp", {}, []);
358
+ expect(result.values).toEqual(["mcp_status", "mcp"]);
359
+ });
360
+
361
+ it("produces connect targets + mcp for connect input", () => {
362
+ const result = normalizeInput("mcp", { connect: "exa" }, []);
363
+ expect(result.values).toContain("mcp_connect_exa");
364
+ expect(result.values).toContain("mcp_connect");
365
+ expect(result.values.at(-1)).toBe("mcp");
366
+ });
367
+ });
@@ -0,0 +1,51 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { expect, test } from "vitest";
11
+ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
12
+ import { createPermissionSystemLogger } from "#src/logging";
13
+
14
+ test("Permission-system logger respects debug toggle and keeps review log enabled by default", () => {
15
+ const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-logs-"));
16
+ const logsDir = join(baseDir, "logs");
17
+ const debugLogPath = join(logsDir, "debug.jsonl");
18
+ const reviewLogPath = join(logsDir, "review.jsonl");
19
+ const config = { ...DEFAULT_EXTENSION_CONFIG };
20
+ const logger = createPermissionSystemLogger({
21
+ getConfig: () => config,
22
+ debugLogPath,
23
+ reviewLogPath,
24
+ ensureLogsDirectory: () => {
25
+ mkdirSync(logsDir, { recursive: true });
26
+ return undefined;
27
+ },
28
+ });
29
+
30
+ try {
31
+ const initialDebugWarning = logger.debug("debug.disabled", {
32
+ sample: true,
33
+ });
34
+ const reviewWarning = logger.review("permission_request.waiting", {
35
+ toolName: "write",
36
+ });
37
+
38
+ expect(initialDebugWarning).toBe(undefined);
39
+ expect(reviewWarning).toBe(undefined);
40
+ expect(existsSync(debugLogPath)).toBe(false);
41
+ expect(existsSync(reviewLogPath)).toBe(true);
42
+
43
+ config.debugLog = true;
44
+ const enabledDebugWarning = logger.debug("debug.enabled", { sample: true });
45
+ expect(enabledDebugWarning).toBe(undefined);
46
+ expect(existsSync(debugLogPath)).toBe(true);
47
+ expect(readFileSync(debugLogPath, "utf8")).toMatch(/debug\.enabled/);
48
+ } finally {
49
+ rmSync(baseDir, { recursive: true, force: true });
50
+ }
51
+ });
@@ -0,0 +1,233 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createMcpPermissionTargets,
4
+ McpTargetList,
5
+ parseQualifiedMcpToolName,
6
+ } from "#src/mcp-targets";
7
+
8
+ describe("parseQualifiedMcpToolName", () => {
9
+ it("returns server and tool for a valid qualified name", () => {
10
+ expect(parseQualifiedMcpToolName("exa:search")).toEqual({
11
+ server: "exa",
12
+ tool: "search",
13
+ });
14
+ });
15
+
16
+ it("returns server and tool with surrounding whitespace trimmed", () => {
17
+ expect(parseQualifiedMcpToolName(" exa : search ")).toEqual({
18
+ server: "exa",
19
+ tool: "search",
20
+ });
21
+ });
22
+
23
+ it("returns null for empty string", () => {
24
+ expect(parseQualifiedMcpToolName("")).toBeNull();
25
+ });
26
+
27
+ it("returns null for whitespace-only string", () => {
28
+ expect(parseQualifiedMcpToolName(" ")).toBeNull();
29
+ });
30
+
31
+ it("returns null when colon is the first character", () => {
32
+ expect(parseQualifiedMcpToolName(":search")).toBeNull();
33
+ });
34
+
35
+ it("returns null when colon is the last character", () => {
36
+ expect(parseQualifiedMcpToolName("exa:")).toBeNull();
37
+ });
38
+
39
+ it("returns null for a plain tool name with no colon", () => {
40
+ expect(parseQualifiedMcpToolName("exa_search")).toBeNull();
41
+ });
42
+
43
+ it("returns null when server part is empty after trimming", () => {
44
+ expect(parseQualifiedMcpToolName(" :search")).toBeNull();
45
+ });
46
+
47
+ it("returns null when tool part is empty after trimming", () => {
48
+ expect(parseQualifiedMcpToolName("exa: ")).toBeNull();
49
+ });
50
+ });
51
+
52
+ describe("createMcpPermissionTargets", () => {
53
+ describe("tool call (input.tool)", () => {
54
+ it("produces targets for a bare tool name with no configured servers", () => {
55
+ const targets = createMcpPermissionTargets({ tool: "exa_search" }, []);
56
+ expect(targets).toContain("exa_search");
57
+ expect(targets).toContain("mcp_call");
58
+ });
59
+
60
+ it("produces targets for a qualified tool name (server:tool)", () => {
61
+ const targets = createMcpPermissionTargets({ tool: "exa:search" }, []);
62
+ expect(targets).toContain("exa_search");
63
+ expect(targets).toContain("exa:search");
64
+ expect(targets).toContain("exa");
65
+ expect(targets).toContain("mcp_call");
66
+ });
67
+
68
+ it("produces targets for a tool call with explicit server field", () => {
69
+ const targets = createMcpPermissionTargets(
70
+ { tool: "search", server: "exa" },
71
+ [],
72
+ );
73
+ expect(targets).toContain("exa_search");
74
+ expect(targets).toContain("exa:search");
75
+ expect(targets).toContain("exa");
76
+ expect(targets).toContain("mcp_call");
77
+ });
78
+
79
+ it("derives server targets from configured server names when tool name ends with _<server>", () => {
80
+ const targets = createMcpPermissionTargets({ tool: "exa_search" }, [
81
+ "exa",
82
+ ]);
83
+ // exa_search ends with _exa? No — it ends with _search. This tool name
84
+ // does NOT trigger server derivation because it does not end with _exa.
85
+ expect(targets).toContain("exa_search");
86
+ });
87
+
88
+ it("does not include duplicate entries", () => {
89
+ const targets = createMcpPermissionTargets({ tool: "exa:search" }, [
90
+ "exa",
91
+ ]);
92
+ const unique = [...new Set(targets)];
93
+ expect(targets).toEqual(unique);
94
+ });
95
+ });
96
+
97
+ describe("connect call (input.connect)", () => {
98
+ it("produces targets for a connect operation", () => {
99
+ const targets = createMcpPermissionTargets({ connect: "exa" }, []);
100
+ expect(targets).toContain("mcp_connect_exa");
101
+ expect(targets).toContain("exa");
102
+ expect(targets).toContain("mcp_connect");
103
+ });
104
+
105
+ it("does not include mcp_call for connect operations", () => {
106
+ const targets = createMcpPermissionTargets({ connect: "exa" }, []);
107
+ expect(targets).not.toContain("mcp_call");
108
+ });
109
+ });
110
+
111
+ describe("describe operation (input.describe)", () => {
112
+ it("produces targets for a describe operation on a qualified tool", () => {
113
+ const targets = createMcpPermissionTargets(
114
+ { describe: "exa:search" },
115
+ [],
116
+ );
117
+ expect(targets).toContain("exa_search");
118
+ expect(targets).toContain("exa:search");
119
+ expect(targets).toContain("exa");
120
+ expect(targets).toContain("mcp_describe");
121
+ });
122
+ });
123
+
124
+ describe("search operation (input.search)", () => {
125
+ it("produces mcp_search and the search string as targets", () => {
126
+ const targets = createMcpPermissionTargets({ search: "weather" }, []);
127
+ expect(targets).toContain("weather");
128
+ expect(targets).toContain("mcp_search");
129
+ });
130
+
131
+ it("includes server targets when server is provided alongside search", () => {
132
+ const targets = createMcpPermissionTargets(
133
+ { search: "weather", server: "exa" },
134
+ [],
135
+ );
136
+ expect(targets).toContain("mcp_server_exa");
137
+ expect(targets).toContain("exa");
138
+ expect(targets).toContain("mcp_search");
139
+ });
140
+ });
141
+
142
+ describe("server listing (input.server only)", () => {
143
+ it("produces mcp_list and server-specific targets", () => {
144
+ const targets = createMcpPermissionTargets({ server: "exa" }, []);
145
+ expect(targets).toContain("mcp_server_exa");
146
+ expect(targets).toContain("exa");
147
+ expect(targets).toContain("mcp_list");
148
+ });
149
+ });
150
+
151
+ describe("status (no meaningful input)", () => {
152
+ it("produces mcp_status for empty input", () => {
153
+ const targets = createMcpPermissionTargets({}, []);
154
+ expect(targets).toContain("mcp_status");
155
+ });
156
+
157
+ it("produces mcp_status for null input", () => {
158
+ const targets = createMcpPermissionTargets(null, []);
159
+ expect(targets).toContain("mcp_status");
160
+ });
161
+
162
+ it("produces mcp_status when no server/tool/connect/describe/search present", () => {
163
+ const targets = createMcpPermissionTargets({ unrelated: "value" }, [
164
+ "exa",
165
+ ]);
166
+ expect(targets).toContain("mcp_status");
167
+ });
168
+ });
169
+
170
+ describe("priority ordering", () => {
171
+ it("tool targets appear before mcp_call", () => {
172
+ const targets = createMcpPermissionTargets({ tool: "exa:search" }, []);
173
+ const mcpCallIdx = targets.indexOf("mcp_call");
174
+ const exaSearchIdx = targets.indexOf("exa_search");
175
+ expect(exaSearchIdx).toBeGreaterThanOrEqual(0);
176
+ expect(mcpCallIdx).toBeGreaterThan(exaSearchIdx);
177
+ });
178
+ });
179
+ });
180
+
181
+ describe("McpTargetList", () => {
182
+ describe("add", () => {
183
+ it("ignores null", () => {
184
+ const list = new McpTargetList();
185
+ list.add(null);
186
+ expect(list.toArray()).toEqual([]);
187
+ });
188
+
189
+ it("ignores empty string", () => {
190
+ const list = new McpTargetList();
191
+ list.add("");
192
+ expect(list.toArray()).toEqual([]);
193
+ });
194
+
195
+ it("appends a new value", () => {
196
+ const list = new McpTargetList();
197
+ list.add("exa");
198
+ expect(list.toArray()).toEqual(["exa"]);
199
+ });
200
+
201
+ it("dedups repeated values", () => {
202
+ const list = new McpTargetList();
203
+ list.add("exa");
204
+ list.add("exa");
205
+ expect(list.toArray()).toEqual(["exa"]);
206
+ });
207
+
208
+ it("preserves first-insertion order across a mix of values", () => {
209
+ const list = new McpTargetList();
210
+ list.add("exa_search");
211
+ list.add("exa:search");
212
+ list.add("exa");
213
+ list.add("exa_search"); // duplicate — must not change order
214
+ list.add("mcp_call");
215
+ expect(list.toArray()).toEqual([
216
+ "exa_search",
217
+ "exa:search",
218
+ "exa",
219
+ "mcp_call",
220
+ ]);
221
+ });
222
+ });
223
+
224
+ describe("toArray", () => {
225
+ it("returns an independent copy that does not mutate the list", () => {
226
+ const list = new McpTargetList();
227
+ list.add("exa");
228
+ const first = list.toArray();
229
+ first.push("mutated");
230
+ expect(list.toArray()).toEqual(["exa"]);
231
+ });
232
+ });
233
+ });