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