@gotgenes/pi-permission-system 17.0.0 → 17.1.1
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 +14 -0
- package/package.json +1 -1
- package/src/access-intent/access-path.ts +5 -5
- package/src/access-intent/bash/bash-path-resolver.ts +535 -0
- package/src/access-intent/bash/program.ts +21 -14
- package/src/access-intent/bash/token-classification.ts +24 -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 -1
- package/src/handlers/gates/bash-path-extractor.ts +7 -5
- package/src/handlers/gates/external-directory.ts +7 -18
- package/src/handlers/gates/path.ts +3 -2
- package/src/handlers/gates/skill-read.ts +4 -2
- package/src/handlers/gates/tool-call-gate-pipeline.ts +20 -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 +100 -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 +8 -10
- package/src/subagent-context.ts +17 -9
- package/test/access-intent/access-path.test.ts +73 -24
- package/test/access-intent/bash/program.test.ts +131 -65
- package/test/access-intent/bash/token-classification.test.ts +75 -0
- package/test/bash-external-directory.test.ts +53 -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 +23 -4
- package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -1
- package/test/handlers/gates/bash-external-directory.test.ts +5 -1
- package/test/handlers/gates/bash-path.test.ts +6 -1
- package/test/handlers/gates/external-directory-policy.test.ts +26 -8
- package/test/handlers/gates/external-directory.test.ts +8 -1
- package/test/handlers/gates/path.test.ts +53 -14
- package/test/handlers/gates/skill-read.test.ts +26 -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 +10 -0
- package/test/helpers/session-fixtures.ts +3 -0
- package/test/input-normalizer.test.ts +104 -39
- package/test/path-normalizer.test.ts +166 -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 +12 -3
- 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 +37 -16
- package/test/subagent-context.test.ts +77 -31
- package/test/synthesize.test.ts +13 -11
- package/src/access-intent/bash/cwd-projection.ts +0 -500
|
@@ -18,13 +18,27 @@ vi.mock("node:fs", () => ({
|
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
20
|
import { formatDenyReason } from "#src/denial-messages";
|
|
21
|
-
import { extractExternalPathsFromBashCommand } from "#src/handlers/gates/bash-path-extractor";
|
|
21
|
+
import { extractExternalPathsFromBashCommand as extractWithNormalizer } from "#src/handlers/gates/bash-path-extractor";
|
|
22
22
|
import { formatBashExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
|
|
23
|
+
import { PathNormalizer } from "#src/path-normalizer";
|
|
23
24
|
|
|
24
25
|
afterEach(() => {
|
|
25
26
|
vi.restoreAllMocks();
|
|
26
27
|
});
|
|
27
28
|
|
|
29
|
+
// The production facade now takes a PathNormalizer (platform + cwd baked in);
|
|
30
|
+
// this wrapper preserves the (command, cwd) call shape the suite uses
|
|
31
|
+
// throughout so the projection-correctness assertions stay unchanged.
|
|
32
|
+
function extractExternalPathsFromBashCommand(
|
|
33
|
+
command: string,
|
|
34
|
+
cwd: string,
|
|
35
|
+
): Promise<string[]> {
|
|
36
|
+
return extractWithNormalizer(
|
|
37
|
+
command,
|
|
38
|
+
new PathNormalizer(process.platform, cwd),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
describe("extractExternalPathsFromBashCommand", () => {
|
|
29
43
|
const cwd = "/projects/my-app";
|
|
30
44
|
|
|
@@ -939,6 +953,44 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
|
939
953
|
});
|
|
940
954
|
});
|
|
941
955
|
|
|
956
|
+
describe("Windows drive-letter paths (win32 semantics)", () => {
|
|
957
|
+
const windowsCwd = "C:/projects/app";
|
|
958
|
+
|
|
959
|
+
async function extractWin32(command: string): Promise<string[]> {
|
|
960
|
+
return extractWithNormalizer(
|
|
961
|
+
command,
|
|
962
|
+
new PathNormalizer("win32", windowsCwd),
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
test("forward-slash drive path outside CWD is flagged", async () => {
|
|
967
|
+
const result = await extractWin32("cat C:/Windows/win.ini");
|
|
968
|
+
expect(result).not.toHaveLength(0);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
test("different drive letter outside CWD is flagged", async () => {
|
|
972
|
+
const result = await extractWin32("cat D:/secrets/password.txt");
|
|
973
|
+
expect(result).not.toHaveLength(0);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
test("drive path inside CWD is not flagged (known base)", async () => {
|
|
977
|
+
const result = await extractWin32("cat C:/projects/app/inside.txt");
|
|
978
|
+
expect(result).toHaveLength(0);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
test("drive path inside CWD is not flagged after non-literal cd (unknown base)", async () => {
|
|
982
|
+
// Before the isRelativeCandidate conversion, the hand-rolled startsWith("/")
|
|
983
|
+
// check treats C:/ as relative on win32, so the unknown-base conservative
|
|
984
|
+
// branch fires and over-flags an inside-CWD drive path.
|
|
985
|
+
// After the conversion (!normalizer.isAbsolute), C:/ is absolute on win32
|
|
986
|
+
// and routes to the resolved branch with its inside-CWD check.
|
|
987
|
+
const result = await extractWin32(
|
|
988
|
+
'cd "$D" && cat C:/projects/app/inside.txt',
|
|
989
|
+
);
|
|
990
|
+
expect(result).toHaveLength(0);
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
|
|
942
994
|
describe("bash external-directory denial messages (centralized)", () => {
|
|
943
995
|
test("denial message includes command, paths, and extension tag", () => {
|
|
944
996
|
const result = formatDenyReason({
|
|
@@ -21,12 +21,14 @@ describe("canonicalizePath", () => {
|
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
test("returns empty string for empty input", () => {
|
|
24
|
-
expect(canonicalizePath("")).toBe("");
|
|
24
|
+
expect(canonicalizePath("", "linux")).toBe("");
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
test("returns realpathSync result when path exists", () => {
|
|
28
28
|
realpathSync.mockReturnValueOnce("/real/projects/app");
|
|
29
|
-
expect(canonicalizePath("/projects/link")).toBe(
|
|
29
|
+
expect(canonicalizePath("/projects/link", "linux")).toBe(
|
|
30
|
+
"/real/projects/app",
|
|
31
|
+
);
|
|
30
32
|
});
|
|
31
33
|
|
|
32
34
|
test("re-appends a non-existent leaf to the canonical parent", () => {
|
|
@@ -35,7 +37,7 @@ describe("canonicalizePath", () => {
|
|
|
35
37
|
throw enoent("/projects/app/new-file.ts");
|
|
36
38
|
})
|
|
37
39
|
.mockReturnValueOnce("/canonical/app");
|
|
38
|
-
expect(canonicalizePath("/projects/app/new-file.ts")).toBe(
|
|
40
|
+
expect(canonicalizePath("/projects/app/new-file.ts", "linux")).toBe(
|
|
39
41
|
"/canonical/app/new-file.ts",
|
|
40
42
|
);
|
|
41
43
|
});
|
|
@@ -52,7 +54,7 @@ describe("canonicalizePath", () => {
|
|
|
52
54
|
throw enoent("/projects/app");
|
|
53
55
|
})
|
|
54
56
|
.mockReturnValueOnce("/canonical/projects");
|
|
55
|
-
expect(canonicalizePath("/projects/app/src/new-file.ts")).toBe(
|
|
57
|
+
expect(canonicalizePath("/projects/app/src/new-file.ts", "linux")).toBe(
|
|
56
58
|
"/canonical/projects/app/src/new-file.ts",
|
|
57
59
|
);
|
|
58
60
|
});
|
|
@@ -61,7 +63,7 @@ describe("canonicalizePath", () => {
|
|
|
61
63
|
realpathSync.mockImplementation(() => {
|
|
62
64
|
throw enoent("");
|
|
63
65
|
});
|
|
64
|
-
expect(canonicalizePath("/nonexistent/path/file.ts")).toBe(
|
|
66
|
+
expect(canonicalizePath("/nonexistent/path/file.ts", "linux")).toBe(
|
|
65
67
|
"/nonexistent/path/file.ts",
|
|
66
68
|
);
|
|
67
69
|
});
|
|
@@ -70,14 +72,18 @@ describe("canonicalizePath", () => {
|
|
|
70
72
|
realpathSync.mockImplementation(() => {
|
|
71
73
|
throw Object.assign(new Error("ELOOP"), { code: "ELOOP" });
|
|
72
74
|
});
|
|
73
|
-
expect(canonicalizePath("/some/looping/path")).toBe(
|
|
75
|
+
expect(canonicalizePath("/some/looping/path", "linux")).toBe(
|
|
76
|
+
"/some/looping/path",
|
|
77
|
+
);
|
|
74
78
|
});
|
|
75
79
|
|
|
76
80
|
test("returns input unchanged on EACCES (permission denied)", () => {
|
|
77
81
|
realpathSync.mockImplementation(() => {
|
|
78
82
|
throw Object.assign(new Error("EACCES"), { code: "EACCES" });
|
|
79
83
|
});
|
|
80
|
-
expect(canonicalizePath("/restricted/path")).toBe(
|
|
84
|
+
expect(canonicalizePath("/restricted/path", "linux")).toBe(
|
|
85
|
+
"/restricted/path",
|
|
86
|
+
);
|
|
81
87
|
});
|
|
82
88
|
|
|
83
89
|
test("handles ENOTDIR by walking up (like ENOENT)", () => {
|
|
@@ -86,8 +92,28 @@ describe("canonicalizePath", () => {
|
|
|
86
92
|
throw Object.assign(new Error("ENOTDIR"), { code: "ENOTDIR" });
|
|
87
93
|
})
|
|
88
94
|
.mockReturnValueOnce("/real/parent");
|
|
89
|
-
expect(canonicalizePath("/real/parent/not-a-dir")).toBe(
|
|
95
|
+
expect(canonicalizePath("/real/parent/not-a-dir", "linux")).toBe(
|
|
90
96
|
"/real/parent/not-a-dir",
|
|
91
97
|
);
|
|
92
98
|
});
|
|
99
|
+
|
|
100
|
+
// ── injected platform flavor (win32-separator splitting) ──────────────
|
|
101
|
+
|
|
102
|
+
test("win32: splits and rejoins on the backslash separator", () => {
|
|
103
|
+
realpathSync
|
|
104
|
+
.mockImplementationOnce(() => {
|
|
105
|
+
throw enoent("C:\\projects\\link\\file.ts");
|
|
106
|
+
})
|
|
107
|
+
.mockReturnValueOnce("C:\\real\\app");
|
|
108
|
+
expect(canonicalizePath("C:\\projects\\link\\file.ts", "win32")).toBe(
|
|
109
|
+
"C:\\real\\app\\file.ts",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("win32: resolves an existing path via realpathSync", () => {
|
|
114
|
+
realpathSync.mockReturnValueOnce("C:\\real\\app");
|
|
115
|
+
expect(canonicalizePath("C:\\projects\\link", "win32")).toBe(
|
|
116
|
+
"C:\\real\\app",
|
|
117
|
+
);
|
|
118
|
+
});
|
|
93
119
|
});
|
|
@@ -30,7 +30,11 @@ function makeForwarder() {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function makeManager() {
|
|
33
|
-
return new ForwardingManager(
|
|
33
|
+
return new ForwardingManager(
|
|
34
|
+
"/agent/subagent-sessions",
|
|
35
|
+
makeForwarder(),
|
|
36
|
+
"linux",
|
|
37
|
+
);
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
@@ -180,6 +184,7 @@ describe("ForwardingManager", () => {
|
|
|
180
184
|
const manager = new ForwardingManager(
|
|
181
185
|
"/custom/subagent-dir",
|
|
182
186
|
makeForwarder(),
|
|
187
|
+
"linux",
|
|
183
188
|
);
|
|
184
189
|
const ctx = makeCtx();
|
|
185
190
|
manager.start(ctx);
|
|
@@ -187,6 +192,7 @@ describe("ForwardingManager", () => {
|
|
|
187
192
|
expect(mockIsSubagentExecutionContext).toHaveBeenCalledWith(
|
|
188
193
|
ctx,
|
|
189
194
|
"/custom/subagent-dir",
|
|
195
|
+
"linux",
|
|
190
196
|
undefined,
|
|
191
197
|
);
|
|
192
198
|
});
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from "#src/handlers/gates/descriptor";
|
|
23
23
|
import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
|
|
24
24
|
import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
25
|
+
import { PathNormalizer } from "#src/path-normalizer";
|
|
25
26
|
import { PermissionResolver } from "#src/permission-resolver";
|
|
26
27
|
import { SessionRules } from "#src/session-rules";
|
|
27
28
|
import type { ScopeConfig } from "#src/types";
|
|
@@ -83,7 +84,12 @@ describe("external_directory symlink acceptance (#418)", () => {
|
|
|
83
84
|
},
|
|
84
85
|
});
|
|
85
86
|
try {
|
|
86
|
-
const result = describeExternalDirectoryGate(
|
|
87
|
+
const result = describeExternalDirectoryGate(
|
|
88
|
+
readTcc(),
|
|
89
|
+
[],
|
|
90
|
+
resolver,
|
|
91
|
+
new PathNormalizer(process.platform, cwd),
|
|
92
|
+
);
|
|
87
93
|
expect(isGateDescriptor(result)).toBe(true);
|
|
88
94
|
expect((result as GateDescriptor).preCheck?.state).toBe("allow");
|
|
89
95
|
} finally {
|
|
@@ -101,7 +107,12 @@ describe("external_directory symlink acceptance (#418)", () => {
|
|
|
101
107
|
},
|
|
102
108
|
});
|
|
103
109
|
try {
|
|
104
|
-
const result = describeExternalDirectoryGate(
|
|
110
|
+
const result = describeExternalDirectoryGate(
|
|
111
|
+
readTcc(),
|
|
112
|
+
[],
|
|
113
|
+
resolver,
|
|
114
|
+
new PathNormalizer(process.platform, cwd),
|
|
115
|
+
);
|
|
105
116
|
expect(isGateDescriptor(result)).toBe(true);
|
|
106
117
|
expect((result as GateDescriptor).preCheck?.state).toBe("allow");
|
|
107
118
|
} finally {
|
|
@@ -114,7 +125,12 @@ describe("external_directory symlink acceptance (#418)", () => {
|
|
|
114
125
|
permission: { external_directory: { "*": "ask" } },
|
|
115
126
|
});
|
|
116
127
|
try {
|
|
117
|
-
const result = describeExternalDirectoryGate(
|
|
128
|
+
const result = describeExternalDirectoryGate(
|
|
129
|
+
readTcc(),
|
|
130
|
+
[],
|
|
131
|
+
resolver,
|
|
132
|
+
new PathNormalizer(process.platform, cwd),
|
|
133
|
+
);
|
|
118
134
|
expect(isGateDescriptor(result)).toBe(true);
|
|
119
135
|
expect((result as GateDescriptor).preCheck?.state).toBe("ask");
|
|
120
136
|
} finally {
|
|
@@ -137,7 +153,10 @@ describe("external_directory symlink acceptance (#418)", () => {
|
|
|
137
153
|
toolCallId: "tc-2",
|
|
138
154
|
cwd,
|
|
139
155
|
};
|
|
140
|
-
const program = await BashProgram.parse(
|
|
156
|
+
const program = await BashProgram.parse(
|
|
157
|
+
command,
|
|
158
|
+
new PathNormalizer(process.platform, cwd),
|
|
159
|
+
);
|
|
141
160
|
const result = describeBashExternalDirectoryGate(tcc, program, resolver);
|
|
142
161
|
// All external paths are covered by the allow → bypass, no prompt.
|
|
143
162
|
expect(isGateBypass(result)).toBe(true);
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { describe, expect, it } from "vitest";
|
|
13
13
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
14
14
|
import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
|
|
15
|
+
import { PathNormalizer } from "#src/path-normalizer";
|
|
15
16
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
16
17
|
import type { PermissionState } from "#src/types";
|
|
17
18
|
|
|
@@ -48,7 +49,10 @@ async function decide(
|
|
|
48
49
|
command: string,
|
|
49
50
|
resolver: ScopedPermissionResolver,
|
|
50
51
|
): Promise<PermissionState> {
|
|
51
|
-
const program = await BashProgram.parse(
|
|
52
|
+
const program = await BashProgram.parse(
|
|
53
|
+
command,
|
|
54
|
+
new PathNormalizer(process.platform, "/cwd"),
|
|
55
|
+
);
|
|
52
56
|
return resolveBashCommandCheck(
|
|
53
57
|
command,
|
|
54
58
|
program.commands(),
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
} from "#src/handlers/gates/descriptor";
|
|
10
10
|
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
|
|
11
11
|
import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
12
|
+
import { PathNormalizer } from "#src/path-normalizer";
|
|
12
13
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
13
14
|
import type { PermissionCheckResult } from "#src/types";
|
|
14
15
|
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
@@ -59,7 +60,10 @@ async function describeGate(
|
|
|
59
60
|
const command = getNonEmptyString(toRecord(tcc.input).command);
|
|
60
61
|
const bashProgram =
|
|
61
62
|
tcc.toolName === "bash" && command
|
|
62
|
-
? await BashProgram.parse(
|
|
63
|
+
? await BashProgram.parse(
|
|
64
|
+
command,
|
|
65
|
+
new PathNormalizer(process.platform, tcc.cwd),
|
|
66
|
+
)
|
|
63
67
|
: null;
|
|
64
68
|
return describeBashExternalDirectoryGate(tcc, bashProgram, resolver);
|
|
65
69
|
}
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
} from "#src/handlers/gates/descriptor";
|
|
20
20
|
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
|
|
21
21
|
import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
22
|
+
import { PathNormalizer } from "#src/path-normalizer";
|
|
22
23
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
23
24
|
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
24
25
|
|
|
@@ -45,7 +46,10 @@ async function describeGate(
|
|
|
45
46
|
const command = getNonEmptyString(toRecord(tcc.input).command);
|
|
46
47
|
const bashProgram =
|
|
47
48
|
tcc.toolName === "bash" && command
|
|
48
|
-
? await BashProgram.parse(
|
|
49
|
+
? await BashProgram.parse(
|
|
50
|
+
command,
|
|
51
|
+
new PathNormalizer(process.platform, tcc.cwd),
|
|
52
|
+
)
|
|
49
53
|
: null;
|
|
50
54
|
return describeBashPathGate(tcc, bashProgram, resolver);
|
|
51
55
|
}
|
|
@@ -235,6 +239,7 @@ describe("describeBashPathGate", () => {
|
|
|
235
239
|
path: AccessPath.forPath("src/file.txt", {
|
|
236
240
|
cwd: "/test/project",
|
|
237
241
|
resolveBase: "/test/project/nested",
|
|
242
|
+
platform: "linux",
|
|
238
243
|
}),
|
|
239
244
|
agentName: undefined,
|
|
240
245
|
});
|
|
@@ -25,7 +25,10 @@ function makeCheckResult(
|
|
|
25
25
|
|
|
26
26
|
describe("resolveExternalDirectoryPolicy", () => {
|
|
27
27
|
it("resolves the path's match aliases on the external_directory surface (#418)", () => {
|
|
28
|
-
const path = AccessPath.forPath("/outside/a.ts", {
|
|
28
|
+
const path = AccessPath.forPath("/outside/a.ts", {
|
|
29
|
+
cwd,
|
|
30
|
+
platform: "linux",
|
|
31
|
+
});
|
|
29
32
|
const resolver = makeResolver(makeCheckResult("ask"));
|
|
30
33
|
|
|
31
34
|
const result = resolveExternalDirectoryPolicy(path, resolver, undefined);
|
|
@@ -40,7 +43,10 @@ describe("resolveExternalDirectoryPolicy", () => {
|
|
|
40
43
|
});
|
|
41
44
|
|
|
42
45
|
it("threads the agent name through to the resolver", () => {
|
|
43
|
-
const path = AccessPath.forPath("/outside/a.ts", {
|
|
46
|
+
const path = AccessPath.forPath("/outside/a.ts", {
|
|
47
|
+
cwd,
|
|
48
|
+
platform: "linux",
|
|
49
|
+
});
|
|
44
50
|
const resolver = makeResolver(makeCheckResult("allow"));
|
|
45
51
|
|
|
46
52
|
resolveExternalDirectoryPolicy(path, resolver, "reviewer");
|
|
@@ -57,8 +63,8 @@ describe("resolveExternalDirectoryPolicy", () => {
|
|
|
57
63
|
describe("selectUncoveredExternalPaths", () => {
|
|
58
64
|
it("returns no uncovered paths when every path resolves to allow", () => {
|
|
59
65
|
const paths = [
|
|
60
|
-
AccessPath.forPath("/outside/a.ts", { cwd }),
|
|
61
|
-
AccessPath.forPath("/outside/b.ts", { cwd }),
|
|
66
|
+
AccessPath.forPath("/outside/a.ts", { cwd, platform: "linux" }),
|
|
67
|
+
AccessPath.forPath("/outside/b.ts", { cwd, platform: "linux" }),
|
|
62
68
|
];
|
|
63
69
|
const resolver = makeResolver(makeCheckResult("allow"));
|
|
64
70
|
|
|
@@ -73,8 +79,14 @@ describe("selectUncoveredExternalPaths", () => {
|
|
|
73
79
|
});
|
|
74
80
|
|
|
75
81
|
it("collects only paths whose resolved state is not allow", () => {
|
|
76
|
-
const allowed = AccessPath.forPath("/outside/ok.ts", {
|
|
77
|
-
|
|
82
|
+
const allowed = AccessPath.forPath("/outside/ok.ts", {
|
|
83
|
+
cwd,
|
|
84
|
+
platform: "linux",
|
|
85
|
+
});
|
|
86
|
+
const asked = AccessPath.forPath("/outside/ask.ts", {
|
|
87
|
+
cwd,
|
|
88
|
+
platform: "linux",
|
|
89
|
+
});
|
|
78
90
|
const resolver = makeResolver();
|
|
79
91
|
resolver.resolve.mockImplementation((intent) => {
|
|
80
92
|
const values =
|
|
@@ -94,8 +106,14 @@ describe("selectUncoveredExternalPaths", () => {
|
|
|
94
106
|
});
|
|
95
107
|
|
|
96
108
|
it("returns the most restrictive uncovered check as worstCheck (deny > ask)", () => {
|
|
97
|
-
const asked = AccessPath.forPath("/outside/ask.ts", {
|
|
98
|
-
|
|
109
|
+
const asked = AccessPath.forPath("/outside/ask.ts", {
|
|
110
|
+
cwd,
|
|
111
|
+
platform: "linux",
|
|
112
|
+
});
|
|
113
|
+
const denied = AccessPath.forPath("/outside/deny.ts", {
|
|
114
|
+
cwd,
|
|
115
|
+
platform: "linux",
|
|
116
|
+
});
|
|
99
117
|
const resolver = makeResolver();
|
|
100
118
|
resolver.resolve.mockImplementation((intent) => {
|
|
101
119
|
const values =
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
|
|
8
8
|
import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
|
|
9
9
|
import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
10
|
+
import { PathNormalizer } from "#src/path-normalizer";
|
|
10
11
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
11
12
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
12
13
|
import { makeResolver } from "#test/helpers/gate-fixtures";
|
|
@@ -37,7 +38,13 @@ function gateUnderTest(
|
|
|
37
38
|
makeCheckResult({ state: "ask", toolName: "external_directory" }),
|
|
38
39
|
),
|
|
39
40
|
) {
|
|
40
|
-
return describeExternalDirectoryGate(
|
|
41
|
+
return describeExternalDirectoryGate(
|
|
42
|
+
tcc,
|
|
43
|
+
infraDirs,
|
|
44
|
+
resolver,
|
|
45
|
+
new PathNormalizer(process.platform, tcc.cwd),
|
|
46
|
+
extractors,
|
|
47
|
+
);
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
// ── tests ────────────────────��────────────────────────────────────��────────
|
|
@@ -15,6 +15,7 @@ import type { GateDescriptor } from "#src/handlers/gates/descriptor";
|
|
|
15
15
|
import { isGateDescriptor } from "#src/handlers/gates/descriptor";
|
|
16
16
|
import { describePathGate } from "#src/handlers/gates/path";
|
|
17
17
|
import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
18
|
+
import { PathNormalizer } from "#src/path-normalizer";
|
|
18
19
|
|
|
19
20
|
import {
|
|
20
21
|
makeGateCheckResult as makeCheckResult,
|
|
@@ -35,6 +36,10 @@ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
// The gate reads the path normalizer (platform + cwd baked in) from the
|
|
40
|
+
// session; here it is bound to the makeTcc default cwd.
|
|
41
|
+
const normalizer = new PathNormalizer(process.platform, "/test/project");
|
|
42
|
+
|
|
38
43
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
39
44
|
|
|
40
45
|
describe("describePathGate", () => {
|
|
@@ -48,6 +53,7 @@ describe("describePathGate", () => {
|
|
|
48
53
|
const result = describePathGate(
|
|
49
54
|
makeTcc({ toolName: "bash", input: { command: "ls" } }),
|
|
50
55
|
resolver,
|
|
56
|
+
normalizer,
|
|
51
57
|
);
|
|
52
58
|
expect(result).toBeNull();
|
|
53
59
|
expect(resolver.resolve).not.toHaveBeenCalled();
|
|
@@ -58,13 +64,14 @@ describe("describePathGate", () => {
|
|
|
58
64
|
const result = describePathGate(
|
|
59
65
|
makeTcc({ toolName: "read", input: {} }),
|
|
60
66
|
resolver,
|
|
67
|
+
normalizer,
|
|
61
68
|
);
|
|
62
69
|
expect(result).toBeNull();
|
|
63
70
|
});
|
|
64
71
|
|
|
65
72
|
it("returns null when path check result is allow", () => {
|
|
66
73
|
const resolver = makeResolver(makeCheckResult({ state: "allow" }));
|
|
67
|
-
const result = describePathGate(makeTcc(), resolver);
|
|
74
|
+
const result = describePathGate(makeTcc(), resolver, normalizer);
|
|
68
75
|
expect(result).toBeNull();
|
|
69
76
|
});
|
|
70
77
|
|
|
@@ -77,7 +84,7 @@ describe("describePathGate", () => {
|
|
|
77
84
|
origin: "builtin",
|
|
78
85
|
}),
|
|
79
86
|
);
|
|
80
|
-
const result = describePathGate(makeTcc(), resolver);
|
|
87
|
+
const result = describePathGate(makeTcc(), resolver, normalizer);
|
|
81
88
|
expect(result).toBeNull();
|
|
82
89
|
});
|
|
83
90
|
|
|
@@ -90,7 +97,7 @@ describe("describePathGate", () => {
|
|
|
90
97
|
origin: "global",
|
|
91
98
|
}),
|
|
92
99
|
);
|
|
93
|
-
const result = describePathGate(makeTcc(), resolver);
|
|
100
|
+
const result = describePathGate(makeTcc(), resolver, normalizer);
|
|
94
101
|
expect(result).not.toBeNull();
|
|
95
102
|
expect(isGateDescriptor(result)).toBe(true);
|
|
96
103
|
});
|
|
@@ -99,7 +106,7 @@ describe("describePathGate", () => {
|
|
|
99
106
|
const resolver = makeResolver(
|
|
100
107
|
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
|
|
101
108
|
);
|
|
102
|
-
const result = describePathGate(makeTcc(), resolver);
|
|
109
|
+
const result = describePathGate(makeTcc(), resolver, normalizer);
|
|
103
110
|
expect(result).not.toBeNull();
|
|
104
111
|
expect(isGateDescriptor(result)).toBe(true);
|
|
105
112
|
const desc = result as GateDescriptor;
|
|
@@ -111,7 +118,7 @@ describe("describePathGate", () => {
|
|
|
111
118
|
const resolver = makeResolver(
|
|
112
119
|
makeCheckResult({ state: "ask", matchedPattern: "*.env" }),
|
|
113
120
|
);
|
|
114
|
-
const result = describePathGate(makeTcc(), resolver);
|
|
121
|
+
const result = describePathGate(makeTcc(), resolver, normalizer);
|
|
115
122
|
expect(result).not.toBeNull();
|
|
116
123
|
expect(isGateDescriptor(result)).toBe(true);
|
|
117
124
|
const desc = result as GateDescriptor;
|
|
@@ -126,6 +133,7 @@ describe("describePathGate", () => {
|
|
|
126
133
|
const result = describePathGate(
|
|
127
134
|
makeTcc({ input: { path: "/test/project/src/.env" } }),
|
|
128
135
|
resolver,
|
|
136
|
+
normalizer,
|
|
129
137
|
) as GateDescriptor;
|
|
130
138
|
expect(result.sessionApproval).toBeDefined();
|
|
131
139
|
expect(result.sessionApproval?.surface).toBe("path");
|
|
@@ -139,6 +147,7 @@ describe("describePathGate", () => {
|
|
|
139
147
|
const result = describePathGate(
|
|
140
148
|
makeTcc({ input: { path: "index.html" }, cwd: "/test/project" }),
|
|
141
149
|
resolver,
|
|
150
|
+
normalizer,
|
|
142
151
|
) as GateDescriptor;
|
|
143
152
|
expect(result.sessionApproval?.surface).toBe("path");
|
|
144
153
|
expect(result.sessionApproval?.representativePattern).toBe(
|
|
@@ -150,7 +159,11 @@ describe("describePathGate", () => {
|
|
|
150
159
|
const resolver = makeResolver(
|
|
151
160
|
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
|
|
152
161
|
);
|
|
153
|
-
const result = describePathGate(
|
|
162
|
+
const result = describePathGate(
|
|
163
|
+
makeTcc(),
|
|
164
|
+
resolver,
|
|
165
|
+
normalizer,
|
|
166
|
+
) as GateDescriptor;
|
|
154
167
|
expect(result.denialContext).toEqual({
|
|
155
168
|
kind: "path",
|
|
156
169
|
toolName: "read",
|
|
@@ -163,18 +176,25 @@ describe("describePathGate", () => {
|
|
|
163
176
|
const resolver = makeResolver(
|
|
164
177
|
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
|
|
165
178
|
);
|
|
166
|
-
const result = describePathGate(
|
|
179
|
+
const result = describePathGate(
|
|
180
|
+
makeTcc(),
|
|
181
|
+
resolver,
|
|
182
|
+
normalizer,
|
|
183
|
+
) as GateDescriptor;
|
|
167
184
|
expect(result.decision.surface).toBe("path");
|
|
168
185
|
expect(result.decision.value).toBe(".env");
|
|
169
186
|
});
|
|
170
187
|
|
|
171
188
|
it("resolves the path surface with an access-path intent and agent name", () => {
|
|
172
189
|
const resolver = makeResolver(makeCheckResult({ state: "allow" }));
|
|
173
|
-
describePathGate(makeTcc({ agentName: "my-agent" }), resolver);
|
|
190
|
+
describePathGate(makeTcc({ agentName: "my-agent" }), resolver, normalizer);
|
|
174
191
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
175
192
|
kind: "access-path",
|
|
176
193
|
surface: "path",
|
|
177
|
-
path: AccessPath.forPath(".env", {
|
|
194
|
+
path: AccessPath.forPath(".env", {
|
|
195
|
+
cwd: "/test/project",
|
|
196
|
+
platform: "linux",
|
|
197
|
+
}),
|
|
178
198
|
agentName: "my-agent",
|
|
179
199
|
});
|
|
180
200
|
});
|
|
@@ -185,7 +205,7 @@ describe("describePathGate", () => {
|
|
|
185
205
|
p === "/test/project/.env" ? "/vault/secret.env" : p,
|
|
186
206
|
);
|
|
187
207
|
const resolver = makeResolver(makeCheckResult({ state: "allow" }));
|
|
188
|
-
describePathGate(makeTcc(), resolver);
|
|
208
|
+
describePathGate(makeTcc(), resolver, normalizer);
|
|
189
209
|
|
|
190
210
|
const intent = resolver.resolve.mock.lastCall?.[0];
|
|
191
211
|
expect(intent?.kind).toBe("access-path");
|
|
@@ -209,6 +229,7 @@ describe("describePathGate — home-relative paths", () => {
|
|
|
209
229
|
const result = describePathGate(
|
|
210
230
|
makeTcc({ input: { path: "~/.ssh/config" } }),
|
|
211
231
|
resolver,
|
|
232
|
+
normalizer,
|
|
212
233
|
) as GateDescriptor;
|
|
213
234
|
|
|
214
235
|
expect(isGateDescriptor(result)).toBe(true);
|
|
@@ -222,7 +243,10 @@ describe("describePathGate — home-relative paths", () => {
|
|
|
222
243
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
223
244
|
kind: "access-path",
|
|
224
245
|
surface: "path",
|
|
225
|
-
path: AccessPath.forPath("~/.ssh/config", {
|
|
246
|
+
path: AccessPath.forPath("~/.ssh/config", {
|
|
247
|
+
cwd: "/test/project",
|
|
248
|
+
platform: "linux",
|
|
249
|
+
}),
|
|
226
250
|
agentName: undefined,
|
|
227
251
|
});
|
|
228
252
|
});
|
|
@@ -234,6 +258,7 @@ describe("describePathGate — home-relative paths", () => {
|
|
|
234
258
|
const result = describePathGate(
|
|
235
259
|
makeTcc({ input: { path: "$HOME/.ssh/config" } }),
|
|
236
260
|
resolver,
|
|
261
|
+
normalizer,
|
|
237
262
|
) as GateDescriptor;
|
|
238
263
|
|
|
239
264
|
expect(isGateDescriptor(result)).toBe(true);
|
|
@@ -249,6 +274,7 @@ describe("describePathGate — home-relative paths", () => {
|
|
|
249
274
|
const result = describePathGate(
|
|
250
275
|
makeTcc({ input: { path: "~/.ssh/config" } }),
|
|
251
276
|
resolver,
|
|
277
|
+
normalizer,
|
|
252
278
|
);
|
|
253
279
|
expect(result).toBeNull();
|
|
254
280
|
});
|
|
@@ -274,12 +300,16 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
|
274
300
|
const result = describePathGate(
|
|
275
301
|
makeTcc({ toolName: "my-ext", input: { path: ".env" } }),
|
|
276
302
|
resolver,
|
|
303
|
+
normalizer,
|
|
277
304
|
);
|
|
278
305
|
expect(isGateDescriptor(result)).toBe(true);
|
|
279
306
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
280
307
|
kind: "access-path",
|
|
281
308
|
surface: "path",
|
|
282
|
-
path: AccessPath.forPath(".env", {
|
|
309
|
+
path: AccessPath.forPath(".env", {
|
|
310
|
+
cwd: "/test/project",
|
|
311
|
+
platform: "linux",
|
|
312
|
+
}),
|
|
283
313
|
agentName: undefined,
|
|
284
314
|
});
|
|
285
315
|
});
|
|
@@ -291,12 +321,16 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
|
291
321
|
const result = describePathGate(
|
|
292
322
|
makeTcc({ toolName: "mcp", input: { arguments: { path: ".env" } } }),
|
|
293
323
|
resolver,
|
|
324
|
+
normalizer,
|
|
294
325
|
);
|
|
295
326
|
expect(isGateDescriptor(result)).toBe(true);
|
|
296
327
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
297
328
|
kind: "access-path",
|
|
298
329
|
surface: "path",
|
|
299
|
-
path: AccessPath.forPath(".env", {
|
|
330
|
+
path: AccessPath.forPath(".env", {
|
|
331
|
+
cwd: "/test/project",
|
|
332
|
+
platform: "linux",
|
|
333
|
+
}),
|
|
300
334
|
agentName: undefined,
|
|
301
335
|
});
|
|
302
336
|
});
|
|
@@ -308,12 +342,16 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
|
308
342
|
describePathGate(
|
|
309
343
|
makeTcc({ toolName: "ffgrep", input: { target: "/etc/passwd" } }),
|
|
310
344
|
resolver,
|
|
345
|
+
normalizer,
|
|
311
346
|
extractorLookup("ffgrep", "target"),
|
|
312
347
|
);
|
|
313
348
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
314
349
|
kind: "access-path",
|
|
315
350
|
surface: "path",
|
|
316
|
-
path: AccessPath.forPath("/etc/passwd", {
|
|
351
|
+
path: AccessPath.forPath("/etc/passwd", {
|
|
352
|
+
cwd: "/test/project",
|
|
353
|
+
platform: "linux",
|
|
354
|
+
}),
|
|
317
355
|
agentName: undefined,
|
|
318
356
|
});
|
|
319
357
|
});
|
|
@@ -323,6 +361,7 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
|
323
361
|
const result = describePathGate(
|
|
324
362
|
makeTcc({ toolName: "my-ext", input: { other: true } }),
|
|
325
363
|
resolver,
|
|
364
|
+
normalizer,
|
|
326
365
|
);
|
|
327
366
|
expect(result).toBeNull();
|
|
328
367
|
expect(resolver.resolve).not.toHaveBeenCalled();
|