@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,65 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { expandHomePath } from "./expand-home";
|
|
4
|
+
import { isPathWithinDirectory } from "./path-containment";
|
|
5
|
+
import { READ_ONLY_PATH_BEARING_TOOLS } from "./path-surfaces";
|
|
6
|
+
import { wildcardMatch } from "./wildcard-matcher";
|
|
7
|
+
|
|
8
|
+
function containsGlobChars(value: string): boolean {
|
|
9
|
+
return value.includes("*") || value.includes("?");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns true if the given tool + normalized path combination qualifies for
|
|
14
|
+
* automatic allow as a Pi infrastructure read.
|
|
15
|
+
*
|
|
16
|
+
* A path qualifies when:
|
|
17
|
+
* 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
|
|
18
|
+
* 2. The normalized path is within one of the provided `infrastructureDirs`
|
|
19
|
+
* OR within the project-local Pi package directories
|
|
20
|
+
* (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
|
|
21
|
+
*
|
|
22
|
+
* `infrastructureDirs` entries may be absolute paths or patterns containing
|
|
23
|
+
* `~`/`$HOME` (expanded at call time) or glob characters (`*`, `?`).
|
|
24
|
+
* Project-local paths are computed fresh from `cwd` on each call so they
|
|
25
|
+
* follow working-directory changes without a runtime rebuild.
|
|
26
|
+
*/
|
|
27
|
+
export function isPiInfrastructureRead(
|
|
28
|
+
toolName: string,
|
|
29
|
+
normalizedPath: string,
|
|
30
|
+
infrastructureDirs: readonly string[],
|
|
31
|
+
cwd: string,
|
|
32
|
+
platform: NodeJS.Platform,
|
|
33
|
+
): boolean {
|
|
34
|
+
if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// On Windows the path value is canonicalized + lowercased; fold case (and
|
|
39
|
+
// separators) so mixed-case infra dirs and glob patterns still match.
|
|
40
|
+
const matchOptions =
|
|
41
|
+
platform === "win32"
|
|
42
|
+
? { caseInsensitive: true, windowsSeparators: true }
|
|
43
|
+
: undefined;
|
|
44
|
+
|
|
45
|
+
for (const dir of infrastructureDirs) {
|
|
46
|
+
if (containsGlobChars(dir)) {
|
|
47
|
+
if (wildcardMatch(dir, normalizedPath, matchOptions)) return true;
|
|
48
|
+
} else {
|
|
49
|
+
if (isPathWithinDirectory(normalizedPath, expandHomePath(dir), platform))
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Project-local Pi packages — checked fresh every call so CWD changes work.
|
|
55
|
+
const projectNpmDir = join(cwd, ".pi", "npm");
|
|
56
|
+
const projectGitDir = join(cwd, ".pi", "git");
|
|
57
|
+
if (isPathWithinDirectory(normalizedPath, projectNpmDir, platform)) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (isPathWithinDirectory(normalizedPath, projectGitDir, platform)) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return false;
|
|
65
|
+
}
|
package/src/rule.ts
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paths that are universally safe and should never trigger external-directory checks.
|
|
3
|
+
* These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
|
|
4
|
+
*/
|
|
5
|
+
export const SAFE_SYSTEM_PATHS: ReadonlySet<string> = new Set([
|
|
6
|
+
"/dev/null",
|
|
7
|
+
"/dev/stdin",
|
|
8
|
+
"/dev/stdout",
|
|
9
|
+
"/dev/stderr",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns true if the given normalized path is a safe OS device file
|
|
14
|
+
* that should never trigger external-directory checks.
|
|
15
|
+
*/
|
|
16
|
+
export function isSafeSystemPath(normalizedPath: string): boolean {
|
|
17
|
+
return SAFE_SYSTEM_PATHS.has(normalizedPath);
|
|
18
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { PATH_BEARING_TOOLS } from "./path-surfaces";
|
|
2
|
+
import type { ToolAccessExtractorLookup } from "./tool-access-extractor-registry";
|
|
3
|
+
import { getNonEmptyString, toRecord } from "./value-guards";
|
|
4
|
+
|
|
5
|
+
export function getPathBearingToolPath(
|
|
6
|
+
toolName: string,
|
|
7
|
+
input: unknown,
|
|
8
|
+
): string | null {
|
|
9
|
+
if (!PATH_BEARING_TOOLS.has(toolName)) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return getNonEmptyString(toRecord(input).path);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract the filesystem path a tool will access, for the cross-cutting `path`
|
|
18
|
+
* and `external_directory` gates.
|
|
19
|
+
*
|
|
20
|
+
* Unlike {@link getPathBearingToolPath} (built-in tools only), this recognizes
|
|
21
|
+
* extension and MCP tools so they are no longer exempt from path gating:
|
|
22
|
+
*
|
|
23
|
+
* - `bash` → `null` (bash has its own token-based path gates).
|
|
24
|
+
* - Built-in path-bearing tools → `input.path`.
|
|
25
|
+
* - `mcp` → `input.arguments.path`.
|
|
26
|
+
* - Any other tool → a registered {@link ToolAccessExtractor}'s path, else the
|
|
27
|
+
* default `input.path` convention.
|
|
28
|
+
*/
|
|
29
|
+
export function getToolInputPath(
|
|
30
|
+
toolName: string,
|
|
31
|
+
input: unknown,
|
|
32
|
+
extractors?: ToolAccessExtractorLookup,
|
|
33
|
+
): string | null {
|
|
34
|
+
if (toolName === "bash") {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const record = toRecord(input);
|
|
39
|
+
|
|
40
|
+
if (PATH_BEARING_TOOLS.has(toolName)) {
|
|
41
|
+
return getNonEmptyString(record.path);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (toolName === "mcp") {
|
|
45
|
+
return getNonEmptyString(toRecord(record.arguments).path);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const custom = extractors?.get(toolName);
|
|
49
|
+
if (custom) {
|
|
50
|
+
return getNonEmptyString(custom(record));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return getNonEmptyString(record.path);
|
|
54
|
+
}
|
|
@@ -191,6 +191,67 @@ describe("AccessPath.forPath", () => {
|
|
|
191
191
|
});
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
+
describe("resolvedAlias()", () => {
|
|
195
|
+
const cwd = "/projects/my-app";
|
|
196
|
+
|
|
197
|
+
beforeEach(() => {
|
|
198
|
+
realpathSync.mockReset();
|
|
199
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("returns the canonical form when a symlink resolves elsewhere", () => {
|
|
203
|
+
realpathSync.mockImplementation((p: string) =>
|
|
204
|
+
p === "/projects/my-app/demo-symlink-passwd" ? "/etc/passwd" : p,
|
|
205
|
+
);
|
|
206
|
+
expect(
|
|
207
|
+
AccessPath.forPath("demo-symlink-passwd", {
|
|
208
|
+
cwd,
|
|
209
|
+
platform: "linux",
|
|
210
|
+
}).resolvedAlias(),
|
|
211
|
+
).toBe("/etc/passwd");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("returns undefined when the path has no symlinks (canonical equals lexical)", () => {
|
|
215
|
+
expect(
|
|
216
|
+
AccessPath.forPath("/etc/hosts", {
|
|
217
|
+
cwd,
|
|
218
|
+
platform: "linux",
|
|
219
|
+
}).resolvedAlias(),
|
|
220
|
+
).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("returns undefined for a literal-only path (no canonical)", () => {
|
|
224
|
+
expect(AccessPath.forLiteral("foo.ts").resolvedAlias()).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("returns undefined for empty input", () => {
|
|
228
|
+
expect(
|
|
229
|
+
AccessPath.forPath("", { cwd, platform: "linux" }).resolvedAlias(),
|
|
230
|
+
).toBeUndefined();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("win32: returns the lowercased canonical form for a real symlink target", () => {
|
|
234
|
+
realpathSync.mockImplementation((p: string) =>
|
|
235
|
+
p === "c:\\projects\\app\\link" ? "C:\\Real\\App" : p,
|
|
236
|
+
);
|
|
237
|
+
expect(
|
|
238
|
+
AccessPath.forPath("link", {
|
|
239
|
+
cwd: "C:\\Projects\\App",
|
|
240
|
+
platform: "win32",
|
|
241
|
+
}).resolvedAlias(),
|
|
242
|
+
).toBe("c:\\real\\app");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("win32: returns undefined for a case-only difference (both forms lowercased)", () => {
|
|
246
|
+
expect(
|
|
247
|
+
AccessPath.forPath("src\\foo.ts", {
|
|
248
|
+
cwd: "C:\\Projects\\App",
|
|
249
|
+
platform: "win32",
|
|
250
|
+
}).resolvedAlias(),
|
|
251
|
+
).toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
194
255
|
describe("AccessPath.forLiteral", () => {
|
|
195
256
|
beforeEach(() => {
|
|
196
257
|
realpathSync.mockReset();
|
|
@@ -924,7 +924,7 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
|
924
924
|
test("includes command, external paths, and CWD", () => {
|
|
925
925
|
const result = formatBashExternalDirectoryAskPrompt(
|
|
926
926
|
"cat /etc/hosts",
|
|
927
|
-
["/etc/hosts"],
|
|
927
|
+
[{ path: "/etc/hosts" }],
|
|
928
928
|
"/projects/my-app",
|
|
929
929
|
);
|
|
930
930
|
expect(result).toContain("cat /etc/hosts");
|
|
@@ -935,7 +935,7 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
|
935
935
|
test("includes agent name when provided", () => {
|
|
936
936
|
const result = formatBashExternalDirectoryAskPrompt(
|
|
937
937
|
"cat /etc/hosts",
|
|
938
|
-
["/etc/hosts"],
|
|
938
|
+
[{ path: "/etc/hosts" }],
|
|
939
939
|
"/projects/my-app",
|
|
940
940
|
"my-agent",
|
|
941
941
|
);
|
|
@@ -945,12 +945,26 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
|
945
945
|
test("shows multiple external paths", () => {
|
|
946
946
|
const result = formatBashExternalDirectoryAskPrompt(
|
|
947
947
|
"diff /etc/hosts /var/log/syslog",
|
|
948
|
-
["/etc/hosts", "/var/log/syslog"],
|
|
948
|
+
[{ path: "/etc/hosts" }, { path: "/var/log/syslog" }],
|
|
949
949
|
"/projects/my-app",
|
|
950
950
|
);
|
|
951
951
|
expect(result).toContain("/etc/hosts");
|
|
952
952
|
expect(result).toContain("/var/log/syslog");
|
|
953
953
|
});
|
|
954
|
+
|
|
955
|
+
test("discloses the resolved target per path when it differs", () => {
|
|
956
|
+
const result = formatBashExternalDirectoryAskPrompt(
|
|
957
|
+
"cat demo-symlink-passwd /etc/hosts",
|
|
958
|
+
[
|
|
959
|
+
{ path: "demo-symlink-passwd", resolvedPath: "/etc/passwd" },
|
|
960
|
+
{ path: "/etc/hosts" },
|
|
961
|
+
],
|
|
962
|
+
"/projects/my-app",
|
|
963
|
+
);
|
|
964
|
+
expect(result).toBe(
|
|
965
|
+
"Current agent requested bash command 'cat demo-symlink-passwd /etc/hosts' which references path(s) outside working directory '/projects/my-app': demo-symlink-passwd (resolves to '/etc/passwd'), /etc/hosts. Allow this external directory access?",
|
|
966
|
+
);
|
|
967
|
+
});
|
|
954
968
|
});
|
|
955
969
|
|
|
956
970
|
describe("Windows drive-letter paths (win32 semantics)", () => {
|
|
@@ -996,7 +1010,7 @@ describe("bash external-directory denial messages (centralized)", () => {
|
|
|
996
1010
|
const result = formatDenyReason({
|
|
997
1011
|
kind: "bash_external_directory",
|
|
998
1012
|
command: "cat /etc/hosts",
|
|
999
|
-
externalPaths: ["/etc/hosts"],
|
|
1013
|
+
externalPaths: [{ path: "/etc/hosts" }],
|
|
1000
1014
|
cwd: "/projects/my-app",
|
|
1001
1015
|
});
|
|
1002
1016
|
expect(result).toContain("cat /etc/hosts");
|
|
@@ -242,6 +242,20 @@ describe("formatDenyReason", () => {
|
|
|
242
242
|
"[pi-permission-system] Agent 'sec-agent' is not permitted to run tool 'read' for path '/etc/passwd' outside working directory '/project'.",
|
|
243
243
|
);
|
|
244
244
|
});
|
|
245
|
+
|
|
246
|
+
test("discloses the resolved path when it differs from the typed path", () => {
|
|
247
|
+
expect(
|
|
248
|
+
formatDenyReason({
|
|
249
|
+
kind: "external_directory",
|
|
250
|
+
toolName: "read",
|
|
251
|
+
pathValue: "demo-symlink-passwd",
|
|
252
|
+
resolvedPath: "/etc/passwd",
|
|
253
|
+
cwd: "/project",
|
|
254
|
+
}),
|
|
255
|
+
).toBe(
|
|
256
|
+
"[pi-permission-system] Current agent is not permitted to run tool 'read' for path 'demo-symlink-passwd' (resolves to '/etc/passwd') outside working directory '/project'.",
|
|
257
|
+
);
|
|
258
|
+
});
|
|
245
259
|
});
|
|
246
260
|
|
|
247
261
|
describe("bash_external_directory context", () => {
|
|
@@ -250,7 +264,7 @@ describe("formatDenyReason", () => {
|
|
|
250
264
|
formatDenyReason({
|
|
251
265
|
kind: "bash_external_directory",
|
|
252
266
|
command: "cat /etc/hosts",
|
|
253
|
-
externalPaths: ["/etc/hosts"],
|
|
267
|
+
externalPaths: [{ path: "/etc/hosts" }],
|
|
254
268
|
cwd: "/project",
|
|
255
269
|
}),
|
|
256
270
|
).toBe(
|
|
@@ -263,7 +277,7 @@ describe("formatDenyReason", () => {
|
|
|
263
277
|
formatDenyReason({
|
|
264
278
|
kind: "bash_external_directory",
|
|
265
279
|
command: "cp /etc/hosts /tmp/out",
|
|
266
|
-
externalPaths: ["/etc/hosts", "/tmp/out"],
|
|
280
|
+
externalPaths: [{ path: "/etc/hosts" }, { path: "/tmp/out" }],
|
|
267
281
|
cwd: "/project",
|
|
268
282
|
agentName: "my-agent",
|
|
269
283
|
}),
|
|
@@ -271,6 +285,22 @@ describe("formatDenyReason", () => {
|
|
|
271
285
|
"[pi-permission-system] Agent 'my-agent' is not permitted to run bash command 'cp /etc/hosts /tmp/out' which references path(s) outside working directory '/project': /etc/hosts, /tmp/out.",
|
|
272
286
|
);
|
|
273
287
|
});
|
|
288
|
+
|
|
289
|
+
test("discloses resolved targets per path when they differ", () => {
|
|
290
|
+
expect(
|
|
291
|
+
formatDenyReason({
|
|
292
|
+
kind: "bash_external_directory",
|
|
293
|
+
command: "cat demo-symlink-passwd /etc/hosts",
|
|
294
|
+
externalPaths: [
|
|
295
|
+
{ path: "demo-symlink-passwd", resolvedPath: "/etc/passwd" },
|
|
296
|
+
{ path: "/etc/hosts" },
|
|
297
|
+
],
|
|
298
|
+
cwd: "/project",
|
|
299
|
+
}),
|
|
300
|
+
).toBe(
|
|
301
|
+
"[pi-permission-system] Current agent is not permitted to run bash command 'cat demo-symlink-passwd /etc/hosts' which references path(s) outside working directory '/project': demo-symlink-passwd (resolves to '/etc/passwd'), /etc/hosts.",
|
|
302
|
+
);
|
|
303
|
+
});
|
|
274
304
|
});
|
|
275
305
|
|
|
276
306
|
describe("bash_path context", () => {
|
|
@@ -403,12 +433,26 @@ describe("formatUnavailableReason", () => {
|
|
|
403
433
|
);
|
|
404
434
|
});
|
|
405
435
|
|
|
436
|
+
test("external_directory discloses the resolved path when it differs from the typed path", () => {
|
|
437
|
+
expect(
|
|
438
|
+
formatUnavailableReason({
|
|
439
|
+
kind: "external_directory",
|
|
440
|
+
toolName: "read",
|
|
441
|
+
pathValue: "demo-symlink-passwd",
|
|
442
|
+
resolvedPath: "/etc/passwd",
|
|
443
|
+
cwd: "/project",
|
|
444
|
+
}),
|
|
445
|
+
).toBe(
|
|
446
|
+
"[pi-permission-system] Accessing 'demo-symlink-passwd' (resolves to '/etc/passwd') outside the working directory requires approval, but no interactive UI is available.",
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
|
|
406
450
|
test("bash_external_directory", () => {
|
|
407
451
|
expect(
|
|
408
452
|
formatUnavailableReason({
|
|
409
453
|
kind: "bash_external_directory",
|
|
410
454
|
command: "cat /etc/hosts",
|
|
411
|
-
externalPaths: ["/etc/hosts"],
|
|
455
|
+
externalPaths: [{ path: "/etc/hosts" }],
|
|
412
456
|
cwd: "/project",
|
|
413
457
|
}),
|
|
414
458
|
).toBe(
|
|
@@ -539,6 +583,20 @@ describe("formatUserDeniedReason", () => {
|
|
|
539
583
|
"[pi-permission-system] User denied external directory access for tool 'edit' path '/etc/hosts'. Reason: too risky.",
|
|
540
584
|
);
|
|
541
585
|
});
|
|
586
|
+
|
|
587
|
+
test("discloses the resolved path when it differs from the typed path", () => {
|
|
588
|
+
expect(
|
|
589
|
+
formatUserDeniedReason({
|
|
590
|
+
kind: "external_directory",
|
|
591
|
+
toolName: "edit",
|
|
592
|
+
pathValue: "demo-symlink-hosts",
|
|
593
|
+
resolvedPath: "/etc/hosts",
|
|
594
|
+
cwd: "/project",
|
|
595
|
+
}),
|
|
596
|
+
).toBe(
|
|
597
|
+
"[pi-permission-system] User denied external directory access for tool 'edit' path 'demo-symlink-hosts' (resolves to '/etc/hosts').",
|
|
598
|
+
);
|
|
599
|
+
});
|
|
542
600
|
});
|
|
543
601
|
|
|
544
602
|
describe("bash_external_directory context", () => {
|
|
@@ -547,7 +605,7 @@ describe("formatUserDeniedReason", () => {
|
|
|
547
605
|
formatUserDeniedReason({
|
|
548
606
|
kind: "bash_external_directory",
|
|
549
607
|
command: "rm /etc/hosts",
|
|
550
|
-
externalPaths: ["/etc/hosts"],
|
|
608
|
+
externalPaths: [{ path: "/etc/hosts" }],
|
|
551
609
|
cwd: "/project",
|
|
552
610
|
}),
|
|
553
611
|
).toBe(
|
|
@@ -561,7 +619,7 @@ describe("formatUserDeniedReason", () => {
|
|
|
561
619
|
{
|
|
562
620
|
kind: "bash_external_directory",
|
|
563
621
|
command: "rm /etc/hosts",
|
|
564
|
-
externalPaths: ["/etc/hosts"],
|
|
622
|
+
externalPaths: [{ path: "/etc/hosts" }],
|
|
565
623
|
cwd: "/project",
|
|
566
624
|
},
|
|
567
625
|
"dangerous",
|
|
@@ -47,7 +47,12 @@ describe("external_directory helper regression guard", () => {
|
|
|
47
47
|
it("formatExternalDirectoryAskPrompt is a callable function", () => {
|
|
48
48
|
expect(typeof formatExternalDirectoryAskPrompt).toBe("function");
|
|
49
49
|
expect(
|
|
50
|
-
formatExternalDirectoryAskPrompt(
|
|
50
|
+
formatExternalDirectoryAskPrompt(
|
|
51
|
+
"read",
|
|
52
|
+
"/outside/file",
|
|
53
|
+
undefined,
|
|
54
|
+
"/project",
|
|
55
|
+
),
|
|
51
56
|
).toContain("/outside/file");
|
|
52
57
|
});
|
|
53
58
|
|
|
@@ -15,6 +15,7 @@ describe("formatExternalDirectoryAskPrompt", () => {
|
|
|
15
15
|
const result = formatExternalDirectoryAskPrompt(
|
|
16
16
|
"read",
|
|
17
17
|
"/etc/passwd",
|
|
18
|
+
undefined,
|
|
18
19
|
"/projects/my-app",
|
|
19
20
|
);
|
|
20
21
|
expect(result).toContain("Current agent");
|
|
@@ -27,6 +28,7 @@ describe("formatExternalDirectoryAskPrompt", () => {
|
|
|
27
28
|
const result = formatExternalDirectoryAskPrompt(
|
|
28
29
|
"write",
|
|
29
30
|
"/tmp/out.txt",
|
|
31
|
+
undefined,
|
|
30
32
|
"/projects/my-app",
|
|
31
33
|
"my-agent",
|
|
32
34
|
);
|
|
@@ -34,13 +36,35 @@ describe("formatExternalDirectoryAskPrompt", () => {
|
|
|
34
36
|
expect(result).toContain("write");
|
|
35
37
|
expect(result).toContain("/tmp/out.txt");
|
|
36
38
|
});
|
|
39
|
+
|
|
40
|
+
test("discloses the resolved path when it differs from the typed path", () => {
|
|
41
|
+
const result = formatExternalDirectoryAskPrompt(
|
|
42
|
+
"read",
|
|
43
|
+
"demo-symlink-passwd",
|
|
44
|
+
"/etc/passwd",
|
|
45
|
+
"/projects/my-app",
|
|
46
|
+
);
|
|
47
|
+
expect(result).toBe(
|
|
48
|
+
"Current agent requested tool 'read' for path 'demo-symlink-passwd' (resolves to '/etc/passwd') outside working directory '/projects/my-app'. Allow this external directory access?",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("omits the disclosure when resolvedPath is undefined", () => {
|
|
53
|
+
const result = formatExternalDirectoryAskPrompt(
|
|
54
|
+
"read",
|
|
55
|
+
"/etc/passwd",
|
|
56
|
+
undefined,
|
|
57
|
+
"/projects/my-app",
|
|
58
|
+
);
|
|
59
|
+
expect(result).not.toContain("resolves to");
|
|
60
|
+
});
|
|
37
61
|
});
|
|
38
62
|
|
|
39
63
|
describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
40
64
|
test("includes command, paths, cwd, and agent name", () => {
|
|
41
65
|
const result = formatBashExternalDirectoryAskPrompt(
|
|
42
66
|
"cat /etc/passwd",
|
|
43
|
-
["/etc/passwd"],
|
|
67
|
+
[{ path: "/etc/passwd" }],
|
|
44
68
|
"/projects/my-app",
|
|
45
69
|
"my-agent",
|
|
46
70
|
);
|
|
@@ -53,7 +77,7 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
|
53
77
|
test("uses 'Current agent' when no agent name provided", () => {
|
|
54
78
|
const result = formatBashExternalDirectoryAskPrompt(
|
|
55
79
|
"ls /tmp",
|
|
56
|
-
["/tmp"],
|
|
80
|
+
[{ path: "/tmp" }],
|
|
57
81
|
"/projects/my-app",
|
|
58
82
|
);
|
|
59
83
|
expect(result).toContain("Current agent");
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock node:fs so the discriminator test can assert realpathSync is untouched.
|
|
4
|
+
const realpathSync = vi.hoisted(() =>
|
|
5
|
+
vi.fn<(path: string) => string>((p) => p),
|
|
6
|
+
);
|
|
7
|
+
vi.mock("node:fs", () => ({
|
|
8
|
+
realpathSync,
|
|
9
|
+
default: { realpathSync },
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
isPathOutsideWorkingDirectory,
|
|
14
|
+
isPathWithinDirectory,
|
|
15
|
+
} from "#src/path-containment";
|
|
16
|
+
|
|
17
|
+
describe("isPathWithinDirectory", () => {
|
|
18
|
+
test("returns true when path equals directory", () => {
|
|
19
|
+
expect(isPathWithinDirectory("/a/b", "/a/b", "linux")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("returns true when path is a direct child", () => {
|
|
23
|
+
expect(isPathWithinDirectory("/a/b/c", "/a/b", "linux")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("returns true when path is a deep descendant", () => {
|
|
27
|
+
expect(isPathWithinDirectory("/a/b/c/d/e", "/a/b", "linux")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("returns false when path is a sibling directory", () => {
|
|
31
|
+
expect(isPathWithinDirectory("/a/bc", "/a/b", "linux")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("returns false when path is outside the directory", () => {
|
|
35
|
+
expect(isPathWithinDirectory("/other/path", "/a/b", "linux")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("returns false for empty path", () => {
|
|
39
|
+
expect(isPathWithinDirectory("", "/a/b", "linux")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("returns false for empty directory", () => {
|
|
43
|
+
expect(isPathWithinDirectory("/a/b", "", "linux")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ── platform-aware containment (Windows is case-insensitive) ────────────
|
|
47
|
+
|
|
48
|
+
test("win32: folds case for a case-different descendant", () => {
|
|
49
|
+
expect(
|
|
50
|
+
isPathWithinDirectory(
|
|
51
|
+
"c:\\users\\foo\\dir\\sub\\x.md",
|
|
52
|
+
"C:\\Users\\Foo\\dir",
|
|
53
|
+
"win32",
|
|
54
|
+
),
|
|
55
|
+
).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("win32: folds case when path equals directory in different case", () => {
|
|
59
|
+
expect(
|
|
60
|
+
isPathWithinDirectory(
|
|
61
|
+
"c:\\users\\foo\\dir\\sub",
|
|
62
|
+
"C:\\USERS\\foo\\DIR",
|
|
63
|
+
"win32",
|
|
64
|
+
),
|
|
65
|
+
).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("win32: rejects a sibling directory", () => {
|
|
69
|
+
expect(
|
|
70
|
+
isPathWithinDirectory(
|
|
71
|
+
"C:\\Users\\Foo\\other",
|
|
72
|
+
"C:\\Users\\Foo\\dir",
|
|
73
|
+
"win32",
|
|
74
|
+
),
|
|
75
|
+
).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("posix platform stays case-sensitive", () => {
|
|
79
|
+
expect(isPathWithinDirectory("/a/B/c", "/a/b", "linux")).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("isPathOutsideWorkingDirectory", () => {
|
|
84
|
+
// Pure geometry over already-canonical operands: the caller (PathNormalizer)
|
|
85
|
+
// prepares the canonical path and cwd; this predicate never canonicalizes.
|
|
86
|
+
const canonicalCwd = "/projects/my-app";
|
|
87
|
+
|
|
88
|
+
test("does not canonicalize its operands (no filesystem access)", () => {
|
|
89
|
+
realpathSync.mockClear();
|
|
90
|
+
isPathOutsideWorkingDirectory(
|
|
91
|
+
"/projects/my-app/src",
|
|
92
|
+
canonicalCwd,
|
|
93
|
+
"linux",
|
|
94
|
+
);
|
|
95
|
+
expect(realpathSync).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("returns false when path is inside cwd", () => {
|
|
99
|
+
expect(
|
|
100
|
+
isPathOutsideWorkingDirectory(
|
|
101
|
+
"/projects/my-app/src",
|
|
102
|
+
canonicalCwd,
|
|
103
|
+
"linux",
|
|
104
|
+
),
|
|
105
|
+
).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("returns false when path equals cwd", () => {
|
|
109
|
+
expect(
|
|
110
|
+
isPathOutsideWorkingDirectory("/projects/my-app", canonicalCwd, "linux"),
|
|
111
|
+
).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("returns true when path is outside cwd", () => {
|
|
115
|
+
expect(
|
|
116
|
+
isPathOutsideWorkingDirectory("/etc/passwd", canonicalCwd, "linux"),
|
|
117
|
+
).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("returns false for an empty canonical path", () => {
|
|
121
|
+
expect(isPathOutsideWorkingDirectory("", canonicalCwd, "linux")).toBe(
|
|
122
|
+
false,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("returns false for an empty canonical cwd", () => {
|
|
127
|
+
expect(isPathOutsideWorkingDirectory("/etc/passwd", "", "linux")).toBe(
|
|
128
|
+
false,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("returns false for /dev/null (safe system path)", () => {
|
|
133
|
+
expect(
|
|
134
|
+
isPathOutsideWorkingDirectory("/dev/null", canonicalCwd, "linux"),
|
|
135
|
+
).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("returns false for /dev/stdin (safe system path)", () => {
|
|
139
|
+
expect(
|
|
140
|
+
isPathOutsideWorkingDirectory("/dev/stdin", canonicalCwd, "linux"),
|
|
141
|
+
).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("returns false for /dev/stdout (safe system path)", () => {
|
|
145
|
+
expect(
|
|
146
|
+
isPathOutsideWorkingDirectory("/dev/stdout", canonicalCwd, "linux"),
|
|
147
|
+
).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("returns false for /dev/stderr (safe system path)", () => {
|
|
151
|
+
expect(
|
|
152
|
+
isPathOutsideWorkingDirectory("/dev/stderr", canonicalCwd, "linux"),
|
|
153
|
+
).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("returns true for /dev/null/subdir (not a safe path)", () => {
|
|
157
|
+
expect(
|
|
158
|
+
isPathOutsideWorkingDirectory("/dev/null/subdir", canonicalCwd, "linux"),
|
|
159
|
+
).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|