@gotgenes/pi-permission-system 16.2.1 → 17.1.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 +26 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +8 -10
- package/src/access-intent/access-path.ts +39 -16
- package/src/access-intent/bash/bash-path-resolver.ts +531 -0
- package/src/access-intent/bash/program.ts +21 -14
- package/src/access-intent/bash/token-classification.ts +1 -1
- package/src/canonicalize-path.ts +11 -5
- package/src/forwarded-permissions/permission-forwarder.ts +11 -1
- package/src/forwarding-manager.ts +7 -1
- package/src/handlers/before-agent-start.ts +1 -0
- package/src/handlers/gates/bash-path-extractor.ts +7 -5
- package/src/handlers/gates/bash-path.ts +13 -13
- package/src/handlers/gates/external-directory.ts +14 -16
- package/src/handlers/gates/path.ts +12 -7
- package/src/handlers/gates/skill-read.ts +7 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +21 -4
- package/src/handlers/gates/tool.ts +4 -2
- package/src/index.ts +12 -1
- package/src/input-normalizer.ts +9 -4
- package/src/path-normalizer.ts +70 -0
- package/src/path-utils.ts +39 -28
- package/src/permission-manager.ts +21 -7
- package/src/permission-session.ts +35 -2
- package/src/prompting-gateway.ts +3 -0
- package/src/rule.ts +8 -6
- package/src/skill-prompt-sanitizer.ts +15 -4
- package/src/subagent-context.ts +17 -9
- package/test/access-intent/access-path.test.ts +124 -15
- package/test/access-intent/bash/program.test.ts +178 -80
- package/test/bash-external-directory.test.ts +15 -1
- package/test/canonicalize-path.test.ts +34 -8
- package/test/forwarding-manager.test.ts +7 -1
- package/test/handlers/external-directory-symlink-acceptance.test.ts +26 -4
- package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -1
- package/test/handlers/gates/bash-external-directory.test.ts +5 -2
- package/test/handlers/gates/bash-path.test.ts +14 -9
- package/test/handlers/gates/external-directory-policy.test.ts +28 -18
- package/test/handlers/gates/external-directory.test.ts +9 -1
- package/test/handlers/gates/path.test.ts +90 -20
- package/test/handlers/gates/skill-read.test.ts +22 -13
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -1
- package/test/handlers/gates/tool.test.ts +13 -1
- package/test/helpers/gate-fixtures.ts +11 -2
- package/test/helpers/session-fixtures.ts +3 -0
- package/test/input-normalizer.test.ts +104 -39
- package/test/path-normalizer.test.ts +123 -0
- package/test/path-utils.test.ts +130 -51
- package/test/permission-forwarder.test.ts +1 -0
- package/test/permission-manager-unified.test.ts +40 -0
- package/test/permission-resolver.test.ts +14 -32
- package/test/permission-session.test.ts +41 -0
- package/test/pi-infrastructure-read.test.ts +27 -4
- package/test/prompting-gateway.test.ts +1 -0
- package/test/rule.test.ts +88 -42
- package/test/session-rules.test.ts +21 -4
- package/test/skill-prompt-sanitizer.test.ts +69 -14
- package/test/subagent-context.test.ts +77 -31
- package/test/synthesize.test.ts +13 -11
- package/src/access-intent/bash/cwd-projection.ts +0 -498
|
@@ -18,54 +18,64 @@ afterEach(() => {
|
|
|
18
18
|
describe("normalizeInput — non-MCP surfaces", () => {
|
|
19
19
|
describe("special / path", () => {
|
|
20
20
|
it("uses path from input as the lookup value", () => {
|
|
21
|
-
const result = normalizeInput("path", { path: ".env" }, []);
|
|
21
|
+
const result = normalizeInput("path", { path: ".env" }, [], "linux");
|
|
22
22
|
expect(result.surface).toBe("path");
|
|
23
23
|
expect(result.values).toEqual([".env"]);
|
|
24
24
|
expect(result.resultExtras).toEqual({});
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
it("falls back to '*' when path is missing", () => {
|
|
28
|
-
const result = normalizeInput("path", {}, []);
|
|
28
|
+
const result = normalizeInput("path", {}, [], "linux");
|
|
29
29
|
expect(result.values).toEqual(["*"]);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
it("falls back to '*' when path is not a string", () => {
|
|
33
|
-
const result = normalizeInput("path", { path: 42 }, []);
|
|
33
|
+
const result = normalizeInput("path", { path: 42 }, [], "linux");
|
|
34
34
|
expect(result.values).toEqual(["*"]);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
it("falls back to '*' when path is an empty string", () => {
|
|
38
|
-
const result = normalizeInput("path", { path: "" }, []);
|
|
38
|
+
const result = normalizeInput("path", { path: "" }, [], "linux");
|
|
39
39
|
expect(result.values).toEqual(["*"]);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
it("falls back to '*' when path is whitespace-only", () => {
|
|
43
|
-
const result = normalizeInput("path", { path: " " }, []);
|
|
43
|
+
const result = normalizeInput("path", { path: " " }, [], "linux");
|
|
44
44
|
expect(result.values).toEqual(["*"]);
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
it("handles null input", () => {
|
|
48
|
-
const result = normalizeInput("path", null, []);
|
|
48
|
+
const result = normalizeInput("path", null, [], "linux");
|
|
49
49
|
expect(result.values).toEqual(["*"]);
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
it("expands ~/... path value to absolute home path", () => {
|
|
53
|
-
const result = normalizeInput(
|
|
53
|
+
const result = normalizeInput(
|
|
54
|
+
"path",
|
|
55
|
+
{ path: "~/.ssh/config" },
|
|
56
|
+
[],
|
|
57
|
+
"linux",
|
|
58
|
+
);
|
|
54
59
|
expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
|
|
55
60
|
});
|
|
56
61
|
|
|
57
62
|
it("expands $HOME/... path value to absolute home path", () => {
|
|
58
|
-
const result = normalizeInput(
|
|
63
|
+
const result = normalizeInput(
|
|
64
|
+
"path",
|
|
65
|
+
{ path: "$HOME/.ssh/config" },
|
|
66
|
+
[],
|
|
67
|
+
"linux",
|
|
68
|
+
);
|
|
59
69
|
expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
|
|
60
70
|
});
|
|
61
71
|
|
|
62
72
|
it("does not expand non-home values", () => {
|
|
63
|
-
const result = normalizeInput("path", { path: ".env" }, []);
|
|
73
|
+
const result = normalizeInput("path", { path: ".env" }, [], "linux");
|
|
64
74
|
expect(result.values).toEqual([".env"]);
|
|
65
75
|
});
|
|
66
76
|
|
|
67
77
|
it("does not expand the '*' fallback", () => {
|
|
68
|
-
const result = normalizeInput("path", {}, []);
|
|
78
|
+
const result = normalizeInput("path", {}, [], "linux");
|
|
69
79
|
expect(result.values).toEqual(["*"]);
|
|
70
80
|
});
|
|
71
81
|
|
|
@@ -74,6 +84,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
74
84
|
"path",
|
|
75
85
|
{ path: "src/App.jsx" },
|
|
76
86
|
[],
|
|
87
|
+
"linux",
|
|
77
88
|
"/workspace/project",
|
|
78
89
|
);
|
|
79
90
|
expect(result.values).toEqual([
|
|
@@ -87,6 +98,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
87
98
|
"path",
|
|
88
99
|
{ path: "src/App.jsx", pathPolicyValues: ["/etc/shadow"] },
|
|
89
100
|
[],
|
|
101
|
+
"linux",
|
|
90
102
|
"/workspace/project",
|
|
91
103
|
);
|
|
92
104
|
expect(result.values).toEqual([
|
|
@@ -102,6 +114,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
102
114
|
"external_directory",
|
|
103
115
|
{ path: "/other/project" },
|
|
104
116
|
[],
|
|
117
|
+
"linux",
|
|
105
118
|
);
|
|
106
119
|
expect(result.surface).toBe("external_directory");
|
|
107
120
|
expect(result.values).toEqual(["/other/project"]);
|
|
@@ -109,22 +122,32 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
109
122
|
});
|
|
110
123
|
|
|
111
124
|
it("falls back to '*' when path is missing", () => {
|
|
112
|
-
const result = normalizeInput("external_directory", {}, []);
|
|
125
|
+
const result = normalizeInput("external_directory", {}, [], "linux");
|
|
113
126
|
expect(result.values).toEqual(["*"]);
|
|
114
127
|
});
|
|
115
128
|
|
|
116
129
|
it("falls back to '*' when path is not a string", () => {
|
|
117
|
-
const result = normalizeInput(
|
|
130
|
+
const result = normalizeInput(
|
|
131
|
+
"external_directory",
|
|
132
|
+
{ path: 42 },
|
|
133
|
+
[],
|
|
134
|
+
"linux",
|
|
135
|
+
);
|
|
118
136
|
expect(result.values).toEqual(["*"]);
|
|
119
137
|
});
|
|
120
138
|
|
|
121
139
|
it("falls back to '*' when path is an empty string", () => {
|
|
122
|
-
const result = normalizeInput(
|
|
140
|
+
const result = normalizeInput(
|
|
141
|
+
"external_directory",
|
|
142
|
+
{ path: "" },
|
|
143
|
+
[],
|
|
144
|
+
"linux",
|
|
145
|
+
);
|
|
123
146
|
expect(result.values).toEqual(["*"]);
|
|
124
147
|
});
|
|
125
148
|
|
|
126
149
|
it("handles null input", () => {
|
|
127
|
-
const result = normalizeInput("external_directory", null, []);
|
|
150
|
+
const result = normalizeInput("external_directory", null, [], "linux");
|
|
128
151
|
expect(result.values).toEqual(["*"]);
|
|
129
152
|
});
|
|
130
153
|
|
|
@@ -133,6 +156,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
133
156
|
"external_directory",
|
|
134
157
|
{ path: "~/dev/project" },
|
|
135
158
|
[],
|
|
159
|
+
"linux",
|
|
136
160
|
);
|
|
137
161
|
expect(result.values).toEqual([join("/mock/home", "dev/project")]);
|
|
138
162
|
});
|
|
@@ -142,6 +166,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
142
166
|
"external_directory",
|
|
143
167
|
{ path: "$HOME/dev/project" },
|
|
144
168
|
[],
|
|
169
|
+
"linux",
|
|
145
170
|
);
|
|
146
171
|
expect(result.values).toEqual([join("/mock/home", "dev/project")]);
|
|
147
172
|
});
|
|
@@ -151,6 +176,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
151
176
|
"external_directory",
|
|
152
177
|
{ path: "src/App.jsx" },
|
|
153
178
|
[],
|
|
179
|
+
"linux",
|
|
154
180
|
"/workspace/project",
|
|
155
181
|
);
|
|
156
182
|
expect(result.values).toEqual([
|
|
@@ -162,53 +188,63 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
162
188
|
|
|
163
189
|
describe("skill", () => {
|
|
164
190
|
it("uses skill name from input.name", () => {
|
|
165
|
-
const result = normalizeInput(
|
|
191
|
+
const result = normalizeInput(
|
|
192
|
+
"skill",
|
|
193
|
+
{ name: "librarian" },
|
|
194
|
+
[],
|
|
195
|
+
"linux",
|
|
196
|
+
);
|
|
166
197
|
expect(result.surface).toBe("skill");
|
|
167
198
|
expect(result.values).toEqual(["librarian"]);
|
|
168
199
|
expect(result.resultExtras).toEqual({});
|
|
169
200
|
});
|
|
170
201
|
|
|
171
202
|
it("falls back to '*' when name is missing", () => {
|
|
172
|
-
const result = normalizeInput("skill", {}, []);
|
|
203
|
+
const result = normalizeInput("skill", {}, [], "linux");
|
|
173
204
|
expect(result.values).toEqual(["*"]);
|
|
174
205
|
});
|
|
175
206
|
|
|
176
207
|
it("falls back to '*' when name is not a string", () => {
|
|
177
|
-
const result = normalizeInput("skill", { name: 99 }, []);
|
|
208
|
+
const result = normalizeInput("skill", { name: 99 }, [], "linux");
|
|
178
209
|
expect(result.values).toEqual(["*"]);
|
|
179
210
|
});
|
|
180
211
|
});
|
|
181
212
|
|
|
182
213
|
describe("bash", () => {
|
|
183
214
|
it("uses command from input.command", () => {
|
|
184
|
-
const result = normalizeInput(
|
|
215
|
+
const result = normalizeInput(
|
|
216
|
+
"bash",
|
|
217
|
+
{ command: "git status" },
|
|
218
|
+
[],
|
|
219
|
+
"linux",
|
|
220
|
+
);
|
|
185
221
|
expect(result.surface).toBe("bash");
|
|
186
222
|
expect(result.values).toEqual(["git status"]);
|
|
187
223
|
expect(result.resultExtras).toEqual({ command: "git status" });
|
|
188
224
|
});
|
|
189
225
|
|
|
190
226
|
it("uses empty string when command is missing", () => {
|
|
191
|
-
const result = normalizeInput("bash", {}, []);
|
|
227
|
+
const result = normalizeInput("bash", {}, [], "linux");
|
|
192
228
|
expect(result.values).toEqual([""]);
|
|
193
229
|
expect(result.resultExtras).toEqual({ command: "" });
|
|
194
230
|
});
|
|
195
231
|
|
|
196
232
|
it("uses empty string when command is not a string", () => {
|
|
197
|
-
const result = normalizeInput("bash", { command: 42 }, []);
|
|
233
|
+
const result = normalizeInput("bash", { command: 42 }, [], "linux");
|
|
198
234
|
expect(result.values).toEqual([""]);
|
|
199
235
|
expect(result.resultExtras).toEqual({ command: "" });
|
|
200
236
|
});
|
|
201
237
|
|
|
202
238
|
it("strips leading comment lines from values but keeps original in resultExtras", () => {
|
|
203
239
|
const cmd = "# Check debug logs\nfind /home -path '*debug*' -type f";
|
|
204
|
-
const result = normalizeInput("bash", { command: cmd }, []);
|
|
240
|
+
const result = normalizeInput("bash", { command: cmd }, [], "linux");
|
|
205
241
|
expect(result.values).toEqual(["find /home -path '*debug*' -type f"]);
|
|
206
242
|
expect(result.resultExtras).toEqual({ command: cmd });
|
|
207
243
|
});
|
|
208
244
|
|
|
209
245
|
it("strips multiple comment lines", () => {
|
|
210
246
|
const cmd = "# Step 1\n# Step 2\ngit status --short";
|
|
211
|
-
const result = normalizeInput("bash", { command: cmd }, []);
|
|
247
|
+
const result = normalizeInput("bash", { command: cmd }, [], "linux");
|
|
212
248
|
expect(result.values).toEqual(["git status --short"]);
|
|
213
249
|
});
|
|
214
250
|
|
|
@@ -217,13 +253,14 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
217
253
|
"bash",
|
|
218
254
|
{ command: "grep -rn foo src/" },
|
|
219
255
|
[],
|
|
256
|
+
"linux",
|
|
220
257
|
);
|
|
221
258
|
expect(result.values).toEqual(["grep -rn foo src/"]);
|
|
222
259
|
});
|
|
223
260
|
|
|
224
261
|
it("falls back to original when all lines are comments", () => {
|
|
225
262
|
const cmd = "# just a comment";
|
|
226
|
-
const result = normalizeInput("bash", { command: cmd }, []);
|
|
263
|
+
const result = normalizeInput("bash", { command: cmd }, [], "linux");
|
|
227
264
|
expect(result.values).toEqual(["# just a comment"]);
|
|
228
265
|
});
|
|
229
266
|
});
|
|
@@ -235,6 +272,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
235
272
|
tool,
|
|
236
273
|
{ path: "/project/src/main.ts" },
|
|
237
274
|
[],
|
|
275
|
+
"linux",
|
|
238
276
|
);
|
|
239
277
|
expect(result.surface).toBe(tool);
|
|
240
278
|
expect(result.values).toEqual(["/project/src/main.ts"]);
|
|
@@ -244,33 +282,43 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
244
282
|
|
|
245
283
|
it("falls back to '*' when input.path is missing", () => {
|
|
246
284
|
for (const tool of ["read", "write", "edit", "grep", "find", "ls"]) {
|
|
247
|
-
const result = normalizeInput(tool, {}, []);
|
|
285
|
+
const result = normalizeInput(tool, {}, [], "linux");
|
|
248
286
|
expect(result.values).toEqual(["*"]);
|
|
249
287
|
}
|
|
250
288
|
});
|
|
251
289
|
|
|
252
290
|
it("falls back to '*' when input.path is empty string", () => {
|
|
253
|
-
const result = normalizeInput("read", { path: "" }, []);
|
|
291
|
+
const result = normalizeInput("read", { path: "" }, [], "linux");
|
|
254
292
|
expect(result.values).toEqual(["*"]);
|
|
255
293
|
});
|
|
256
294
|
|
|
257
295
|
it("falls back to '*' when input.path is not a string", () => {
|
|
258
|
-
const result = normalizeInput("write", { path: 42 }, []);
|
|
296
|
+
const result = normalizeInput("write", { path: 42 }, [], "linux");
|
|
259
297
|
expect(result.values).toEqual(["*"]);
|
|
260
298
|
});
|
|
261
299
|
|
|
262
300
|
it("falls back to '*' when input is null", () => {
|
|
263
|
-
const result = normalizeInput("edit", null, []);
|
|
301
|
+
const result = normalizeInput("edit", null, [], "linux");
|
|
264
302
|
expect(result.values).toEqual(["*"]);
|
|
265
303
|
});
|
|
266
304
|
|
|
267
305
|
it("expands ~/... path value to absolute home path", () => {
|
|
268
|
-
const result = normalizeInput(
|
|
306
|
+
const result = normalizeInput(
|
|
307
|
+
"read",
|
|
308
|
+
{ path: "~/.ssh/config" },
|
|
309
|
+
[],
|
|
310
|
+
"linux",
|
|
311
|
+
);
|
|
269
312
|
expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
|
|
270
313
|
});
|
|
271
314
|
|
|
272
315
|
it("expands $HOME/... path value to absolute home path", () => {
|
|
273
|
-
const result = normalizeInput(
|
|
316
|
+
const result = normalizeInput(
|
|
317
|
+
"write",
|
|
318
|
+
{ path: "$HOME/.ssh/config" },
|
|
319
|
+
[],
|
|
320
|
+
"linux",
|
|
321
|
+
);
|
|
274
322
|
expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
|
|
275
323
|
});
|
|
276
324
|
|
|
@@ -279,6 +327,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
279
327
|
"read",
|
|
280
328
|
{ path: "src/App.jsx" },
|
|
281
329
|
[],
|
|
330
|
+
"linux",
|
|
282
331
|
"/workspace/project",
|
|
283
332
|
);
|
|
284
333
|
expect(result.values).toEqual([
|
|
@@ -290,7 +339,12 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
290
339
|
|
|
291
340
|
describe("extension tools (non-path-bearing)", () => {
|
|
292
341
|
it("uses '*' as the lookup value for extension tools", () => {
|
|
293
|
-
const result = normalizeInput(
|
|
342
|
+
const result = normalizeInput(
|
|
343
|
+
"my_extension_tool",
|
|
344
|
+
{ some: "input" },
|
|
345
|
+
[],
|
|
346
|
+
"linux",
|
|
347
|
+
);
|
|
294
348
|
expect(result.surface).toBe("my_extension_tool");
|
|
295
349
|
expect(result.values).toEqual(["*"]);
|
|
296
350
|
expect(result.resultExtras).toEqual({});
|
|
@@ -301,6 +355,7 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
301
355
|
"my_extension_tool",
|
|
302
356
|
{ path: "/some/path" },
|
|
303
357
|
[],
|
|
358
|
+
"linux",
|
|
304
359
|
);
|
|
305
360
|
expect(result.values).toEqual(["*"]);
|
|
306
361
|
});
|
|
@@ -309,17 +364,17 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
309
364
|
|
|
310
365
|
describe("normalizeInput — MCP surface", () => {
|
|
311
366
|
it("surface is 'mcp'", () => {
|
|
312
|
-
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
|
|
367
|
+
const result = normalizeInput("mcp", { tool: "exa:search" }, [], "linux");
|
|
313
368
|
expect(result.surface).toBe("mcp");
|
|
314
369
|
});
|
|
315
370
|
|
|
316
371
|
it("values end with the catch-all 'mcp' target", () => {
|
|
317
|
-
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
|
|
372
|
+
const result = normalizeInput("mcp", { tool: "exa:search" }, [], "linux");
|
|
318
373
|
expect(result.values.at(-1)).toBe("mcp");
|
|
319
374
|
});
|
|
320
375
|
|
|
321
376
|
it("values include specific targets before the catch-all for a qualified tool call", () => {
|
|
322
|
-
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
|
|
377
|
+
const result = normalizeInput("mcp", { tool: "exa:search" }, [], "linux");
|
|
323
378
|
expect(result.values).toContain("exa_search");
|
|
324
379
|
expect(result.values).toContain("exa:search");
|
|
325
380
|
expect(result.values).toContain("exa");
|
|
@@ -332,34 +387,44 @@ describe("normalizeInput — MCP surface", () => {
|
|
|
332
387
|
const rawTargets = createMcpPermissionTargets({ tool: "exa:search" }, [
|
|
333
388
|
"exa",
|
|
334
389
|
]);
|
|
335
|
-
const result = normalizeInput(
|
|
390
|
+
const result = normalizeInput(
|
|
391
|
+
"mcp",
|
|
392
|
+
{ tool: "exa:search" },
|
|
393
|
+
["exa"],
|
|
394
|
+
"linux",
|
|
395
|
+
);
|
|
336
396
|
expect(result.values).toEqual([...rawTargets, "mcp"]);
|
|
337
397
|
});
|
|
338
398
|
|
|
339
399
|
it("resultExtras.target is the first specific target (most-specific)", () => {
|
|
340
|
-
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
|
|
400
|
+
const result = normalizeInput("mcp", { tool: "exa:search" }, [], "linux");
|
|
341
401
|
expect(result.resultExtras.target).toBe(result.values[0]);
|
|
342
402
|
});
|
|
343
403
|
|
|
344
404
|
it("resultExtras.target is 'mcp' when no specific targets are derived", () => {
|
|
345
405
|
// Empty input → only mcp_status then mcp appended
|
|
346
|
-
const result = normalizeInput("mcp", {}, []);
|
|
406
|
+
const result = normalizeInput("mcp", {}, [], "linux");
|
|
347
407
|
expect(result.resultExtras.target).toBe("mcp_status");
|
|
348
408
|
});
|
|
349
409
|
|
|
350
410
|
it("values contain no duplicates", () => {
|
|
351
|
-
const result = normalizeInput(
|
|
411
|
+
const result = normalizeInput(
|
|
412
|
+
"mcp",
|
|
413
|
+
{ tool: "exa:search" },
|
|
414
|
+
["exa"],
|
|
415
|
+
"linux",
|
|
416
|
+
);
|
|
352
417
|
const unique = [...new Set(result.values)];
|
|
353
418
|
expect(result.values).toEqual(unique);
|
|
354
419
|
});
|
|
355
420
|
|
|
356
421
|
it("produces mcp_status + mcp for status input", () => {
|
|
357
|
-
const result = normalizeInput("mcp", {}, []);
|
|
422
|
+
const result = normalizeInput("mcp", {}, [], "linux");
|
|
358
423
|
expect(result.values).toEqual(["mcp_status", "mcp"]);
|
|
359
424
|
});
|
|
360
425
|
|
|
361
426
|
it("produces connect targets + mcp for connect input", () => {
|
|
362
|
-
const result = normalizeInput("mcp", { connect: "exa" }, []);
|
|
427
|
+
const result = normalizeInput("mcp", { connect: "exa" }, [], "linux");
|
|
363
428
|
expect(result.values).toContain("mcp_connect_exa");
|
|
364
429
|
expect(result.values).toContain("mcp_connect");
|
|
365
430
|
expect(result.values.at(-1)).toBe("mcp");
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock node:os so tilde-expansion is deterministic across platforms.
|
|
4
|
+
vi.mock("node:os", () => {
|
|
5
|
+
const homedir = vi.fn(() => "/mock/home");
|
|
6
|
+
return {
|
|
7
|
+
homedir,
|
|
8
|
+
default: { homedir },
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
|
|
13
|
+
// Default implementation is identity — lexical assertions are unaffected.
|
|
14
|
+
const realpathSync = vi.hoisted(() =>
|
|
15
|
+
vi.fn<(path: string) => string>((p) => p),
|
|
16
|
+
);
|
|
17
|
+
vi.mock("node:fs", () => ({
|
|
18
|
+
realpathSync,
|
|
19
|
+
default: { realpathSync },
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { PathNormalizer } from "#src/path-normalizer";
|
|
23
|
+
|
|
24
|
+
describe("PathNormalizer", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
realpathSync.mockReset();
|
|
27
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("posix flavor", () => {
|
|
31
|
+
const normalizer = new PathNormalizer("linux", "/projects/my-app");
|
|
32
|
+
|
|
33
|
+
test("forPath builds an AccessPath resolved against the baked cwd", () => {
|
|
34
|
+
const ap = normalizer.forPath("src/foo.ts");
|
|
35
|
+
expect(ap.value()).toBe("/projects/my-app/src/foo.ts");
|
|
36
|
+
expect(ap.matchValues()).toEqual([
|
|
37
|
+
"/projects/my-app/src/foo.ts",
|
|
38
|
+
"src/foo.ts",
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("forPath honors an explicit resolveBase", () => {
|
|
43
|
+
const ap = normalizer.forPath("foo.ts", {
|
|
44
|
+
resolveBase: "/projects/my-app/sub",
|
|
45
|
+
});
|
|
46
|
+
expect(ap.value()).toBe("/projects/my-app/sub/foo.ts");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("forLiteral builds a literal-only AccessPath", () => {
|
|
50
|
+
const ap = normalizer.forLiteral("foo.ts");
|
|
51
|
+
expect(ap.matchValues()).toEqual(["foo.ts"]);
|
|
52
|
+
expect(ap.boundaryValue()).toBe("");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("isAbsolute uses posix rules", () => {
|
|
56
|
+
expect(normalizer.isAbsolute("/etc/hosts")).toBe(true);
|
|
57
|
+
expect(normalizer.isAbsolute("rel/path")).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("resolveBase resolves an offset against the baked cwd", () => {
|
|
61
|
+
expect(normalizer.resolveBase("sub")).toBe("/projects/my-app/sub");
|
|
62
|
+
expect(normalizer.resolveBase("/abs")).toBe("/abs");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("joinBase joins an offset with a relative target", () => {
|
|
66
|
+
expect(normalizer.joinBase("sub", "nested")).toBe("sub/nested");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("isWithinDirectory decides containment", () => {
|
|
70
|
+
expect(normalizer.isWithinDirectory("/a/b/c", "/a/b")).toBe(true);
|
|
71
|
+
expect(normalizer.isWithinDirectory("/a/x", "/a/b")).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("isOutsideWorkingDirectory tests against the baked cwd", () => {
|
|
75
|
+
expect(normalizer.isOutsideWorkingDirectory("/projects/my-app/src")).toBe(
|
|
76
|
+
false,
|
|
77
|
+
);
|
|
78
|
+
expect(normalizer.isOutsideWorkingDirectory("/etc/hosts")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("win32 flavor", () => {
|
|
83
|
+
const normalizer = new PathNormalizer("win32", "C:\\Projects\\App");
|
|
84
|
+
|
|
85
|
+
test("forPath builds a case-folded AccessPath with win32 rules", () => {
|
|
86
|
+
const ap = normalizer.forPath("src\\foo.ts");
|
|
87
|
+
expect(ap.value()).toBe("c:\\projects\\app\\src\\foo.ts");
|
|
88
|
+
expect(ap.matchValues()).toEqual([
|
|
89
|
+
"c:\\projects\\app\\src\\foo.ts",
|
|
90
|
+
"src\\foo.ts",
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("isAbsolute uses win32 rules", () => {
|
|
95
|
+
expect(normalizer.isAbsolute("C:\\Users\\foo")).toBe(true);
|
|
96
|
+
expect(normalizer.isAbsolute("rel\\path")).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("resolveBase resolves an offset against the baked cwd", () => {
|
|
100
|
+
expect(normalizer.resolveBase("sub")).toBe("C:\\Projects\\App\\sub");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("joinBase joins an offset with a relative target", () => {
|
|
104
|
+
expect(normalizer.joinBase("sub", "nested")).toBe("sub\\nested");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("isWithinDirectory folds case", () => {
|
|
108
|
+
expect(
|
|
109
|
+
normalizer.isWithinDirectory(
|
|
110
|
+
"c:\\users\\foo\\dir\\sub",
|
|
111
|
+
"C:\\Users\\Foo\\dir",
|
|
112
|
+
),
|
|
113
|
+
).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("isOutsideWorkingDirectory case-folds against the baked cwd", () => {
|
|
117
|
+
expect(
|
|
118
|
+
normalizer.isOutsideWorkingDirectory("c:\\projects\\app\\src"),
|
|
119
|
+
).toBe(false);
|
|
120
|
+
expect(normalizer.isOutsideWorkingDirectory("C:\\Other\\dir")).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|