@gotgenes/pi-permission-system 18.0.0 → 18.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.
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +8 -1
- package/src/access-intent/access-path.ts +17 -1
- package/src/access-intent/bash/bash-path-resolver.ts +2 -1
- package/src/access-intent/path-normalization.ts +139 -0
- package/src/denial-messages.ts +28 -5
- package/src/handlers/gates/bash-external-directory.ts +7 -2
- package/src/handlers/gates/external-directory-messages.ts +11 -3
- package/src/handlers/gates/external-directory.ts +4 -1
- package/src/handlers/gates/path.ts +1 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -1
- package/src/handlers/gates/tool.ts +2 -1
- package/src/input-normalizer.ts +1 -1
- package/src/path-containment.ts +56 -0
- package/src/path-normalizer.ts +20 -5
- package/src/path-surfaces.ts +30 -0
- package/src/pattern-suggest.ts +1 -1
- package/src/permission-manager.ts +7 -1
- package/src/permission-resolver.ts +5 -0
- package/src/pi-infrastructure-read.ts +65 -0
- package/src/rule.ts +1 -1
- package/src/safe-system-paths.ts +18 -0
- package/src/tool-input-path.ts +54 -0
- package/test/access-intent/access-path.test.ts +61 -0
- package/test/bash-external-directory.test.ts +18 -4
- package/test/denial-messages.test.ts +63 -5
- package/test/handlers/external-directory-integration.test.ts +6 -1
- package/test/handlers/gates/external-directory-messages.test.ts +26 -2
- package/test/path-containment.test.ts +161 -0
- package/test/path-normalization.test.ts +233 -0
- package/test/path-normalizer.test.ts +30 -0
- package/test/path-surfaces.test.ts +55 -0
- package/test/permission-manager-unified.test.ts +1 -1
- package/test/pi-infrastructure-read.test.ts +41 -1
- package/test/safe-system-paths.test.ts +46 -0
- package/test/tool-input-path.test.ts +84 -0
- package/src/path-utils.ts +0 -346
- package/test/path-utils.test.ts +0 -695
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// Mock node:os so tilde-expansion is deterministic across platforms.
|
|
5
|
+
vi.mock("node:os", () => {
|
|
6
|
+
const homedir = vi.fn(() => "/mock/home");
|
|
7
|
+
return {
|
|
8
|
+
homedir,
|
|
9
|
+
default: { homedir },
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
|
|
14
|
+
// Default implementation is identity — existing lexical tests are unaffected.
|
|
15
|
+
const realpathSync = vi.hoisted(() =>
|
|
16
|
+
vi.fn<(path: string) => string>((p) => p),
|
|
17
|
+
);
|
|
18
|
+
vi.mock("node:fs", () => ({
|
|
19
|
+
realpathSync,
|
|
20
|
+
default: { realpathSync },
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
canonicalNormalizePathForComparison,
|
|
25
|
+
getPathPolicyValues,
|
|
26
|
+
normalizePathForComparison,
|
|
27
|
+
normalizePathPolicyLiteral,
|
|
28
|
+
} from "#src/access-intent/path-normalization";
|
|
29
|
+
|
|
30
|
+
describe("normalizePathForComparison", () => {
|
|
31
|
+
const cwd = "/projects/my-app";
|
|
32
|
+
|
|
33
|
+
test("resolves absolute path unchanged", () => {
|
|
34
|
+
expect(normalizePathForComparison("/usr/local/bin", cwd, "linux")).toBe(
|
|
35
|
+
"/usr/local/bin",
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("resolves relative path against cwd", () => {
|
|
40
|
+
expect(normalizePathForComparison("src/foo.ts", cwd, "linux")).toBe(
|
|
41
|
+
"/projects/my-app/src/foo.ts",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("expands bare ~ to homedir", () => {
|
|
46
|
+
expect(normalizePathForComparison("~", cwd, "linux")).toBe("/mock/home");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("expands ~/... to homedir-relative path", () => {
|
|
50
|
+
expect(normalizePathForComparison("~/docs/readme.md", cwd, "linux")).toBe(
|
|
51
|
+
join("/mock/home", "docs/readme.md"),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("expands bare $HOME to homedir", () => {
|
|
56
|
+
expect(normalizePathForComparison("$HOME", cwd, "linux")).toBe(
|
|
57
|
+
"/mock/home",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("expands $HOME/... to homedir-relative path", () => {
|
|
62
|
+
expect(normalizePathForComparison("$HOME/.ssh/config", cwd, "linux")).toBe(
|
|
63
|
+
join("/mock/home", ".ssh/config"),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("strips leading @ before resolving", () => {
|
|
68
|
+
expect(normalizePathForComparison("@/usr/local/bin", cwd, "linux")).toBe(
|
|
69
|
+
"/usr/local/bin",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("strips surrounding quotes", () => {
|
|
74
|
+
expect(normalizePathForComparison("'/usr/local/bin'", cwd, "linux")).toBe(
|
|
75
|
+
"/usr/local/bin",
|
|
76
|
+
);
|
|
77
|
+
expect(normalizePathForComparison('"/usr/local/bin"', cwd, "linux")).toBe(
|
|
78
|
+
"/usr/local/bin",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("returns empty string for blank/whitespace-only path", () => {
|
|
83
|
+
expect(normalizePathForComparison("", cwd, "linux")).toBe("");
|
|
84
|
+
expect(normalizePathForComparison(" ", cwd, "linux")).toBe("");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── injected platform flavor (Windows is case-folded, win32-resolved) ────
|
|
88
|
+
|
|
89
|
+
test("win32: lowercases the resolved absolute path", () => {
|
|
90
|
+
expect(
|
|
91
|
+
normalizePathForComparison(
|
|
92
|
+
"C:\\Users\\Foo\\Bar.txt",
|
|
93
|
+
"C:\\Projects",
|
|
94
|
+
"win32",
|
|
95
|
+
),
|
|
96
|
+
).toBe("c:\\users\\foo\\bar.txt");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("win32: resolves a relative path against cwd with win32 rules", () => {
|
|
100
|
+
expect(
|
|
101
|
+
normalizePathForComparison("src\\foo.ts", "C:\\Projects\\App", "win32"),
|
|
102
|
+
).toBe("c:\\projects\\app\\src\\foo.ts");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("posix platform leaves case untouched", () => {
|
|
106
|
+
expect(
|
|
107
|
+
normalizePathForComparison("/Projects/App/Src.ts", cwd, "linux"),
|
|
108
|
+
).toBe("/Projects/App/Src.ts");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("canonicalNormalizePathForComparison", () => {
|
|
113
|
+
const cwd = "/projects/my-app";
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
realpathSync.mockReset();
|
|
117
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("returns canonical form of an existing path", () => {
|
|
121
|
+
realpathSync.mockImplementation((p: string) => {
|
|
122
|
+
if (p === "/projects/link") return "/real/projects/app";
|
|
123
|
+
return p;
|
|
124
|
+
});
|
|
125
|
+
expect(
|
|
126
|
+
canonicalNormalizePathForComparison("/projects/link", cwd, "linux"),
|
|
127
|
+
).toBe("/real/projects/app");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("returns empty string for empty input", () => {
|
|
131
|
+
expect(canonicalNormalizePathForComparison("", cwd, "linux")).toBe("");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("returns lexical form when no symlinks (identity realpathSync)", () => {
|
|
135
|
+
expect(
|
|
136
|
+
canonicalNormalizePathForComparison(
|
|
137
|
+
"/projects/my-app/src/index.ts",
|
|
138
|
+
cwd,
|
|
139
|
+
"linux",
|
|
140
|
+
),
|
|
141
|
+
).toBe("/projects/my-app/src/index.ts");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("win32: lowercases the canonical form", () => {
|
|
145
|
+
expect(
|
|
146
|
+
canonicalNormalizePathForComparison(
|
|
147
|
+
"C:\\Projects\\App\\Src",
|
|
148
|
+
"C:\\Projects\\App",
|
|
149
|
+
"win32",
|
|
150
|
+
),
|
|
151
|
+
).toBe("c:\\projects\\app\\src");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("normalizePathPolicyLiteral", () => {
|
|
156
|
+
test("returns a relative token unchanged", () => {
|
|
157
|
+
expect(normalizePathPolicyLiteral("src/foo.ts")).toBe("src/foo.ts");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("trims and strips simple wrapping quotes", () => {
|
|
161
|
+
expect(normalizePathPolicyLiteral(" 'src/foo.ts' ")).toBe("src/foo.ts");
|
|
162
|
+
expect(normalizePathPolicyLiteral('"a/b"')).toBe("a/b");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("strips a leading @ prefix", () => {
|
|
166
|
+
expect(normalizePathPolicyLiteral("@src/foo.ts")).toBe("src/foo.ts");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("expands ~ to the home directory", () => {
|
|
170
|
+
expect(normalizePathPolicyLiteral("~/docs/readme.md")).toBe(
|
|
171
|
+
join("/mock/home", "docs/readme.md"),
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("does not resolve a relative value against any cwd", () => {
|
|
176
|
+
expect(normalizePathPolicyLiteral("foo.ts")).toBe("foo.ts");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("returns empty string for blank input", () => {
|
|
180
|
+
expect(normalizePathPolicyLiteral(" ")).toBe("");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("preserves the surface catch-all", () => {
|
|
184
|
+
expect(normalizePathPolicyLiteral("*")).toBe("*");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("getPathPolicyValues", () => {
|
|
189
|
+
const cwd = "/projects/my-app";
|
|
190
|
+
|
|
191
|
+
test("returns only the literal when no base is available", () => {
|
|
192
|
+
expect(getPathPolicyValues("src/foo.ts", {}, "linux")).toEqual([
|
|
193
|
+
"src/foo.ts",
|
|
194
|
+
]);
|
|
195
|
+
expect(getPathPolicyValues("src/foo.ts", {}, "linux")).toEqual([
|
|
196
|
+
"src/foo.ts",
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("adds absolute and project-relative aliases for a relative token", () => {
|
|
201
|
+
expect(getPathPolicyValues("src/foo.ts", { cwd }, "linux")).toEqual([
|
|
202
|
+
"/projects/my-app/src/foo.ts",
|
|
203
|
+
"src/foo.ts",
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("omits the relative alias for a token outside cwd", () => {
|
|
208
|
+
expect(getPathPolicyValues("/etc/hosts", { cwd }, "linux")).toEqual([
|
|
209
|
+
"/etc/hosts",
|
|
210
|
+
]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("resolves against resolveBase while aliasing relative to cwd", () => {
|
|
214
|
+
expect(
|
|
215
|
+
getPathPolicyValues(
|
|
216
|
+
"foo.txt",
|
|
217
|
+
{
|
|
218
|
+
cwd,
|
|
219
|
+
resolveBase: "/projects/my-app/nested",
|
|
220
|
+
},
|
|
221
|
+
"linux",
|
|
222
|
+
),
|
|
223
|
+
).toEqual(["/projects/my-app/nested/foo.txt", "nested/foo.txt", "foo.txt"]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("preserves the surface catch-all", () => {
|
|
227
|
+
expect(getPathPolicyValues("*", { cwd }, "linux")).toEqual(["*"]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("returns empty for blank input", () => {
|
|
231
|
+
expect(getPathPolicyValues(" ", { cwd }, "linux")).toEqual([]);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -78,6 +78,36 @@ describe("PathNormalizer", () => {
|
|
|
78
78
|
expect(normalizer.isOutsideWorkingDirectory("/etc/hosts")).toBe(true);
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
test("isOutsideWorkingDirectory expands a home-relative token", () => {
|
|
82
|
+
expect(normalizer.isOutsideWorkingDirectory("~/secrets")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("isOutsideWorkingDirectory resolves a relative token inside cwd", () => {
|
|
86
|
+
expect(normalizer.isOutsideWorkingDirectory("src/index.ts")).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("isOutsideWorkingDirectory follows an in-cwd symlink to an external target", () => {
|
|
90
|
+
// ./link -> /etc: realpathSync resolves the full token in one call.
|
|
91
|
+
realpathSync.mockImplementation((p: string) => {
|
|
92
|
+
if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
|
|
93
|
+
return p;
|
|
94
|
+
});
|
|
95
|
+
expect(normalizer.isOutsideWorkingDirectory("./link/hosts")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("isOutsideWorkingDirectory keeps a path inside a symlinked cwd", () => {
|
|
99
|
+
// /tmp -> /private/tmp on macOS; cwd reported as the resolved /private/tmp.
|
|
100
|
+
realpathSync.mockImplementation((p: string) => {
|
|
101
|
+
if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`;
|
|
102
|
+
if (p === "/tmp") return "/private/tmp";
|
|
103
|
+
return p;
|
|
104
|
+
});
|
|
105
|
+
const symlinkNormalizer = new PathNormalizer("linux", "/private/tmp");
|
|
106
|
+
expect(
|
|
107
|
+
symlinkNormalizer.isOutsideWorkingDirectory("/tmp/workspace/file.ts"),
|
|
108
|
+
).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
81
111
|
test("comparableValue returns the lexical absolute form (no FS)", () => {
|
|
82
112
|
expect(normalizer.comparableValue("src/foo.ts")).toBe(
|
|
83
113
|
"/projects/my-app/src/foo.ts",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
PATH_BEARING_TOOLS,
|
|
5
|
+
PATH_SURFACES,
|
|
6
|
+
READ_ONLY_PATH_BEARING_TOOLS,
|
|
7
|
+
} from "#src/path-surfaces";
|
|
8
|
+
|
|
9
|
+
describe("PATH_BEARING_TOOLS", () => {
|
|
10
|
+
test("contains the expected tool names", () => {
|
|
11
|
+
for (const tool of ["read", "write", "edit", "find", "grep", "ls"]) {
|
|
12
|
+
expect(PATH_BEARING_TOOLS.has(tool)).toBe(true);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("does not contain bash or mcp", () => {
|
|
17
|
+
expect(PATH_BEARING_TOOLS.has("bash")).toBe(false);
|
|
18
|
+
expect(PATH_BEARING_TOOLS.has("mcp")).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("READ_ONLY_PATH_BEARING_TOOLS", () => {
|
|
23
|
+
test("contains read, find, grep, ls", () => {
|
|
24
|
+
for (const tool of ["read", "find", "grep", "ls"]) {
|
|
25
|
+
expect(READ_ONLY_PATH_BEARING_TOOLS.has(tool)).toBe(true);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("does not contain write or edit", () => {
|
|
30
|
+
expect(READ_ONLY_PATH_BEARING_TOOLS.has("write")).toBe(false);
|
|
31
|
+
expect(READ_ONLY_PATH_BEARING_TOOLS.has("edit")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("PATH_SURFACES", () => {
|
|
36
|
+
test("contains the path-bearing tools plus the cross-cutting gates", () => {
|
|
37
|
+
for (const surface of [
|
|
38
|
+
"read",
|
|
39
|
+
"write",
|
|
40
|
+
"edit",
|
|
41
|
+
"find",
|
|
42
|
+
"grep",
|
|
43
|
+
"ls",
|
|
44
|
+
"external_directory",
|
|
45
|
+
"path",
|
|
46
|
+
]) {
|
|
47
|
+
expect(PATH_SURFACES.has(surface)).toBe(true);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("does not contain bash or mcp", () => {
|
|
52
|
+
expect(PATH_SURFACES.has("bash")).toBe(false);
|
|
53
|
+
expect(PATH_SURFACES.has("mcp")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -9,12 +9,12 @@ import { homedir, tmpdir } from "node:os";
|
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { describe, expect, it, test } from "vitest";
|
|
11
11
|
import type { ResolvedAccessIntent } from "#src/access-intent/access-intent";
|
|
12
|
+
import { getPathPolicyValues } from "#src/access-intent/path-normalization";
|
|
12
13
|
import {
|
|
13
14
|
getGlobalConfigPath,
|
|
14
15
|
getProjectAgentsDir,
|
|
15
16
|
getProjectConfigPath,
|
|
16
17
|
} from "#src/config-paths";
|
|
17
|
-
import { getPathPolicyValues } from "#src/path-utils";
|
|
18
18
|
import {
|
|
19
19
|
PermissionManager,
|
|
20
20
|
type ScopedPermissionManager,
|
|
@@ -16,7 +16,7 @@ vi.mock("node:child_process", () => ({
|
|
|
16
16
|
}));
|
|
17
17
|
|
|
18
18
|
import { discoverGlobalNodeModulesRoot } from "#src/node-modules-discovery";
|
|
19
|
-
import { isPiInfrastructureRead } from "#src/
|
|
19
|
+
import { isPiInfrastructureRead } from "#src/pi-infrastructure-read";
|
|
20
20
|
|
|
21
21
|
// ── discoverGlobalNodeModulesRoot ──────────────────────────────────────────
|
|
22
22
|
|
|
@@ -390,3 +390,43 @@ describe("isPiInfrastructureRead with glob patterns", () => {
|
|
|
390
390
|
).toBe(false);
|
|
391
391
|
});
|
|
392
392
|
});
|
|
393
|
+
|
|
394
|
+
// ── isPiInfrastructureRead — win32 case-insensitive matching ───────────────
|
|
395
|
+
|
|
396
|
+
describe("isPiInfrastructureRead on win32", () => {
|
|
397
|
+
test("plain infra dir matches a case-different path", () => {
|
|
398
|
+
expect(
|
|
399
|
+
isPiInfrastructureRead(
|
|
400
|
+
"read",
|
|
401
|
+
"c:\\users\\foo\\.pi\\agent\\config.json",
|
|
402
|
+
["C:\\Users\\Foo\\.pi\\agent"],
|
|
403
|
+
"C:\\proj",
|
|
404
|
+
"win32",
|
|
405
|
+
),
|
|
406
|
+
).toBe(true);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("glob infra dir matches case-insensitively", () => {
|
|
410
|
+
expect(
|
|
411
|
+
isPiInfrastructureRead(
|
|
412
|
+
"read",
|
|
413
|
+
"c:\\users\\foo\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\skill.md",
|
|
414
|
+
["C:\\Users\\Foo\\**\\pi-coding-agent\\**"],
|
|
415
|
+
"C:\\proj",
|
|
416
|
+
"win32",
|
|
417
|
+
),
|
|
418
|
+
).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("rejects a path outside every infra dir", () => {
|
|
422
|
+
expect(
|
|
423
|
+
isPiInfrastructureRead(
|
|
424
|
+
"read",
|
|
425
|
+
"c:\\windows\\system32\\drivers\\etc\\hosts",
|
|
426
|
+
["C:\\Users\\Foo\\.pi\\agent"],
|
|
427
|
+
"C:\\proj",
|
|
428
|
+
"win32",
|
|
429
|
+
),
|
|
430
|
+
).toBe(false);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { isSafeSystemPath, SAFE_SYSTEM_PATHS } from "#src/safe-system-paths";
|
|
4
|
+
|
|
5
|
+
describe("SAFE_SYSTEM_PATHS", () => {
|
|
6
|
+
test("contains /dev/null, /dev/stdin, /dev/stdout, /dev/stderr", () => {
|
|
7
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/null")).toBe(true);
|
|
8
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/stdin")).toBe(true);
|
|
9
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/stdout")).toBe(true);
|
|
10
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/stderr")).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("isSafeSystemPath", () => {
|
|
15
|
+
test("returns true for /dev/null", () => {
|
|
16
|
+
expect(isSafeSystemPath("/dev/null")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns true for /dev/stdin", () => {
|
|
20
|
+
expect(isSafeSystemPath("/dev/stdin")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns true for /dev/stdout", () => {
|
|
24
|
+
expect(isSafeSystemPath("/dev/stdout")).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("returns true for /dev/stderr", () => {
|
|
28
|
+
expect(isSafeSystemPath("/dev/stderr")).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("returns false for an arbitrary absolute path", () => {
|
|
32
|
+
expect(isSafeSystemPath("/etc/passwd")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("returns false for a path prefixed with a safe system path", () => {
|
|
36
|
+
expect(isSafeSystemPath("/dev/null/subdir")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns false for an empty string", () => {
|
|
40
|
+
expect(isSafeSystemPath("")).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("returns false for a relative path", () => {
|
|
44
|
+
expect(isSafeSystemPath("dev/null")).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
3
|
+
import { getPathBearingToolPath, getToolInputPath } from "#src/tool-input-path";
|
|
4
|
+
|
|
5
|
+
describe("getPathBearingToolPath", () => {
|
|
6
|
+
test("returns path for a path-bearing tool", () => {
|
|
7
|
+
expect(getPathBearingToolPath("read", { path: "/src/foo.ts" })).toBe(
|
|
8
|
+
"/src/foo.ts",
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("returns null for a non-path-bearing tool", () => {
|
|
13
|
+
expect(getPathBearingToolPath("bash", { path: "/src/foo.ts" })).toBeNull();
|
|
14
|
+
expect(getPathBearingToolPath("mcp", { path: "/src/foo.ts" })).toBeNull();
|
|
15
|
+
expect(getPathBearingToolPath("task", { path: "/src/foo.ts" })).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("returns null when input has no path", () => {
|
|
19
|
+
expect(getPathBearingToolPath("read", {})).toBeNull();
|
|
20
|
+
expect(getPathBearingToolPath("read", { path: "" })).toBeNull();
|
|
21
|
+
expect(getPathBearingToolPath("read", null)).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("getToolInputPath", () => {
|
|
26
|
+
function lookupOf(
|
|
27
|
+
toolName: string,
|
|
28
|
+
extractor: (input: Record<string, unknown>) => string | undefined,
|
|
29
|
+
): ToolAccessExtractorLookup {
|
|
30
|
+
return {
|
|
31
|
+
get: (name) => (name === toolName ? extractor : undefined),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("returns input.path for a built-in path-bearing tool", () => {
|
|
36
|
+
expect(getToolInputPath("read", { path: "/src/foo.ts" })).toBe(
|
|
37
|
+
"/src/foo.ts",
|
|
38
|
+
);
|
|
39
|
+
expect(getToolInputPath("write", { path: "/src/bar.ts" })).toBe(
|
|
40
|
+
"/src/bar.ts",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns null for bash", () => {
|
|
45
|
+
expect(getToolInputPath("bash", { path: "/src/foo.ts" })).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("returns the MCP arguments.path for an mcp call", () => {
|
|
49
|
+
expect(getToolInputPath("mcp", { arguments: { path: "/etc/hosts" } })).toBe(
|
|
50
|
+
"/etc/hosts",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("returns null for an mcp call without an arguments.path", () => {
|
|
55
|
+
expect(getToolInputPath("mcp", { arguments: { query: "x" } })).toBeNull();
|
|
56
|
+
expect(getToolInputPath("mcp", {})).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("defaults to input.path for an unregistered extension tool", () => {
|
|
60
|
+
expect(getToolInputPath("my-ext", { path: "/work/file.txt" })).toBe(
|
|
61
|
+
"/work/file.txt",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("returns null for an extension tool without a path", () => {
|
|
66
|
+
expect(getToolInputPath("my-ext", { other: true })).toBeNull();
|
|
67
|
+
expect(getToolInputPath("my-ext", { path: "" })).toBeNull();
|
|
68
|
+
expect(getToolInputPath("my-ext", null)).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("uses a registered extractor's path over the default convention", () => {
|
|
72
|
+
const extractors = lookupOf("ffgrep", (input) =>
|
|
73
|
+
typeof input.target === "string" ? input.target : undefined,
|
|
74
|
+
);
|
|
75
|
+
expect(
|
|
76
|
+
getToolInputPath("ffgrep", { target: "/etc/passwd" }, extractors),
|
|
77
|
+
).toBe("/etc/passwd");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("returns null when a registered extractor declines", () => {
|
|
81
|
+
const extractors = lookupOf("ffgrep", () => undefined);
|
|
82
|
+
expect(getToolInputPath("ffgrep", { target: "x" }, extractors)).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
});
|