@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.
@@ -0,0 +1,32 @@
1
+ import type { PermissionCheckResult, PermissionState } from "#src/types";
2
+
3
+ /** Restrictiveness ordering: deny is the most restrictive, allow the least. */
4
+ const RESTRICTIVENESS: Record<PermissionState, number> = {
5
+ allow: 0,
6
+ ask: 1,
7
+ deny: 2,
8
+ };
9
+
10
+ /**
11
+ * Select the most restrictive permission result from a list (deny > ask > allow).
12
+ *
13
+ * The first occurrence wins on ties, so a caller passing results in candidate
14
+ * order receives the earliest worst case. Returns `undefined` for an empty list.
15
+ *
16
+ * Shared by the bash gates (path, external-directory) to combine the per-candidate
17
+ * `checkPermission` results their tree-sitter token extraction produces.
18
+ */
19
+ export function pickMostRestrictive(
20
+ results: readonly PermissionCheckResult[],
21
+ ): PermissionCheckResult | undefined {
22
+ let worst: PermissionCheckResult | undefined;
23
+ for (const result of results) {
24
+ if (
25
+ worst === undefined ||
26
+ RESTRICTIVENESS[result.state] > RESTRICTIVENESS[worst.state]
27
+ ) {
28
+ worst = result;
29
+ }
30
+ }
31
+ return worst;
32
+ }
@@ -3,7 +3,7 @@ import type {
3
3
  InputEventResult,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
 
6
- import { toRecord } from "#src/common";
6
+ import { getNonEmptyString, toRecord } from "#src/common";
7
7
  import {
8
8
  emitDecisionEvent,
9
9
  type PermissionEventBus,
@@ -26,8 +26,10 @@ import {
26
26
  getToolNameFromValue,
27
27
  type ToolRegistry,
28
28
  } from "#src/tool-registry";
29
+ import { resolveBashCommandCheck } from "./gates/bash-command";
29
30
  import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
30
31
  import { describeBashPathGate } from "./gates/bash-path";
32
+ import { BashProgram } from "./gates/bash-program";
31
33
  import type { GateResult, GateRunnerDeps } from "./gates/descriptor";
32
34
  import { isGateBypass } from "./gates/descriptor";
33
35
  import { describeExternalDirectoryGate } from "./gates/external-directory";
@@ -86,6 +88,14 @@ export class PermissionGateHandler {
86
88
  cwd: ctx.cwd,
87
89
  };
88
90
 
91
+ // Parse the bash command exactly once per tool_call; the three bash gates
92
+ // share this single BashProgram instead of each re-parsing (#308).
93
+ const command = getNonEmptyString(toRecord(tcc.input).command);
94
+ const bashProgram =
95
+ tcc.toolName === "bash" && command
96
+ ? await BashProgram.parse(command)
97
+ : null;
98
+
89
99
  // ── Shared gate adapter closures ─────────────────────────────────────
90
100
  const canConfirm = () => this.session.canPrompt(ctx);
91
101
  const promptPermission = (details: PromptPermissionDetails) =>
@@ -165,17 +175,37 @@ export class PermissionGateHandler {
165
175
  () =>
166
176
  describeBashExternalDirectoryGate(
167
177
  tcc,
178
+ bashProgram,
179
+ checkPermission,
180
+ getSessionRuleset,
181
+ ),
182
+ () =>
183
+ describeBashPathGate(
184
+ tcc,
185
+ bashProgram,
168
186
  checkPermission,
169
187
  getSessionRuleset,
170
188
  ),
171
- () => describeBashPathGate(tcc, checkPermission, getSessionRuleset),
172
189
  () => {
173
- const toolCheck = checkPermission(
174
- tcc.toolName,
175
- tcc.input,
176
- tcc.agentName ?? undefined,
177
- getSessionRuleset(),
178
- );
190
+ // Bash commands may chain several sub-commands (`a && b`, `a | b`, …);
191
+ // evaluate each unit from the shared parse on the bash surface and
192
+ // select the most restrictive, rather than matching the whole program
193
+ // string (#301). Other tools evaluate their single input directly.
194
+ const toolCheck =
195
+ tcc.toolName === "bash" && bashProgram
196
+ ? resolveBashCommandCheck(
197
+ command ?? "",
198
+ bashProgram.commands(),
199
+ tcc.agentName ?? undefined,
200
+ getSessionRuleset(),
201
+ checkPermission,
202
+ )
203
+ : checkPermission(
204
+ tcc.toolName,
205
+ tcc.input,
206
+ tcc.agentName ?? undefined,
207
+ getSessionRuleset(),
208
+ );
179
209
  const toolDescriptor = describeToolGate(tcc, toolCheck, formatter);
180
210
  toolDescriptor.preCheck = toolCheck;
181
211
  return toolDescriptor;
@@ -1,3 +1,4 @@
1
+ import { matchQualifier } from "./denial-messages";
1
2
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
2
3
  import type { ToolPreviewFormatter } from "./tool-preview-formatter";
3
4
  import type { PermissionCheckResult } from "./types";
@@ -36,10 +37,12 @@ export function formatAskPrompt(
36
37
  const subject = agentName ? `Agent '${agentName}'` : "Current agent";
37
38
 
38
39
  if (result.toolName === "bash") {
39
- const patternInfo = result.matchedPattern
40
- ? ` (matched '${result.matchedPattern}')`
41
- : "";
42
- return `${subject} requested bash command '${result.command ?? ""}'${patternInfo}. Allow this command?`;
40
+ const qualifier = matchQualifier(
41
+ result.matchedPattern,
42
+ result.commandContext,
43
+ );
44
+ const qualifierInfo = qualifier ? ` ${qualifier}` : "";
45
+ return `${subject} requested bash command '${result.command ?? ""}'${qualifierInfo}. Allow this command?`;
43
46
  }
44
47
 
45
48
  if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
package/src/types.ts CHANGED
@@ -22,6 +22,15 @@ export interface ScopeConfig {
22
22
  permission?: FlatPermissionConfig;
23
23
  }
24
24
 
25
+ /**
26
+ * Execution context of a bash command nested inside a substitution or subshell.
27
+ * Absent for current-shell (top-level) commands.
28
+ */
29
+ export type BashCommandContext =
30
+ | "command_substitution"
31
+ | "process_substitution"
32
+ | "subshell";
33
+
25
34
  export interface PermissionCheckResult {
26
35
  toolName: string;
27
36
  state: PermissionState;
@@ -31,4 +40,10 @@ export interface PermissionCheckResult {
31
40
  source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
32
41
  /** Which source contributed the winning rule. */
33
42
  origin: RuleOrigin;
43
+ /**
44
+ * Execution context of the offending nested command, when the winning bash
45
+ * unit came from a substitution or subshell. Absent for current-shell
46
+ * (top-level) commands.
47
+ */
48
+ commandContext?: BashCommandContext;
34
49
  }
@@ -98,6 +98,22 @@ describe("formatDenyReason", () => {
98
98
  );
99
99
  });
100
100
 
101
+ test("bash with nested execution context", () => {
102
+ expect(
103
+ formatDenyReason(
104
+ toolCtx(
105
+ toolCheck("bash", {
106
+ command: "rm -rf foo",
107
+ matchedPattern: "rm *",
108
+ commandContext: "command_substitution",
109
+ }),
110
+ ),
111
+ ),
112
+ ).toBe(
113
+ "[pi-permission-system] is not permitted to run 'bash' command 'rm -rf foo' (matched 'rm *', inside command substitution).",
114
+ );
115
+ });
116
+
101
117
  test("MCP source with target on non-mcp toolName", () => {
102
118
  expect(
103
119
  formatDenyReason(
@@ -0,0 +1,205 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
4
+ import type { Rule } from "#src/rule";
5
+ import type { PermissionCheckResult } from "#src/types";
6
+
7
+ import { makeCheckResult } from "#test/helpers/handler-fixtures";
8
+
9
+ type CheckPermissionFn = (
10
+ surface: string,
11
+ input: unknown,
12
+ agentName?: string,
13
+ sessionRules?: Rule[],
14
+ ) => PermissionCheckResult;
15
+
16
+ /** Build a bash-surface check result for a single command unit. */
17
+ function bashResult(
18
+ state: PermissionCheckResult["state"],
19
+ command: string,
20
+ matchedPattern?: string,
21
+ ): PermissionCheckResult {
22
+ return makeCheckResult({ state, source: "bash", command, matchedPattern });
23
+ }
24
+
25
+ describe("resolveBashCommandCheck", () => {
26
+ it("passes a single command straight through", () => {
27
+ const checkPermission = vi
28
+ .fn<CheckPermissionFn>()
29
+ .mockReturnValue(bashResult("allow", "npm install pkg", "npm *"));
30
+
31
+ const result = resolveBashCommandCheck(
32
+ "npm install pkg",
33
+ [{ text: "npm install pkg" }],
34
+ undefined,
35
+ [],
36
+ checkPermission,
37
+ );
38
+
39
+ expect(result.state).toBe("allow");
40
+ expect(checkPermission).toHaveBeenCalledTimes(1);
41
+ expect(checkPermission).toHaveBeenCalledWith(
42
+ "bash",
43
+ { command: "npm install pkg" },
44
+ undefined,
45
+ [],
46
+ );
47
+ });
48
+
49
+ it("denies the chain when any sub-command is denied, reporting that command's pattern", () => {
50
+ const checkPermission = vi
51
+ .fn<CheckPermissionFn>()
52
+ .mockImplementation((_surface, input) => {
53
+ const command = (input as { command: string }).command;
54
+ return command.startsWith("npm")
55
+ ? bashResult("deny", command, "npm *")
56
+ : bashResult("allow", command, "cd *");
57
+ });
58
+
59
+ const result = resolveBashCommandCheck(
60
+ "cd /p && npm install pkg",
61
+ [{ text: "cd /p" }, { text: "npm install pkg" }],
62
+ undefined,
63
+ [],
64
+ checkPermission,
65
+ );
66
+
67
+ expect(result.state).toBe("deny");
68
+ expect(result.matchedPattern).toBe("npm *");
69
+ expect(result.command).toBe("npm install pkg");
70
+ });
71
+
72
+ it("asks when a sub-command asks and none denies", () => {
73
+ const checkPermission = vi
74
+ .fn<CheckPermissionFn>()
75
+ .mockImplementation((_surface, input) => {
76
+ const command = (input as { command: string }).command;
77
+ return command.startsWith("git")
78
+ ? bashResult("ask", command, "git *")
79
+ : bashResult("allow", command, "cd *");
80
+ });
81
+
82
+ const result = resolveBashCommandCheck(
83
+ "cd /p && git push",
84
+ [{ text: "cd /p" }, { text: "git push" }],
85
+ undefined,
86
+ [],
87
+ checkPermission,
88
+ );
89
+
90
+ expect(result.state).toBe("ask");
91
+ expect(result.matchedPattern).toBe("git *");
92
+ expect(result.command).toBe("git push");
93
+ });
94
+
95
+ it("returns the first allow result when every sub-command is allowed", () => {
96
+ const checkPermission = vi
97
+ .fn<CheckPermissionFn>()
98
+ .mockImplementation((_surface, input) => {
99
+ const command = (input as { command: string }).command;
100
+ return bashResult("allow", command, `${command} *`);
101
+ });
102
+
103
+ const result = resolveBashCommandCheck(
104
+ "a && b",
105
+ [{ text: "a" }, { text: "b" }],
106
+ undefined,
107
+ [],
108
+ checkPermission,
109
+ );
110
+
111
+ expect(result.state).toBe("allow");
112
+ expect(result.matchedPattern).toBe("a *");
113
+ });
114
+
115
+ it("falls back to the whole command when no top-level commands are found", () => {
116
+ const checkPermission = vi
117
+ .fn<CheckPermissionFn>()
118
+ .mockReturnValue(bashResult("ask", "( rm x )", "*"));
119
+
120
+ const result = resolveBashCommandCheck(
121
+ "( rm x )",
122
+ [],
123
+ undefined,
124
+ [],
125
+ checkPermission,
126
+ );
127
+
128
+ expect(result.state).toBe("ask");
129
+ expect(result.commandContext).toBeUndefined();
130
+ expect(checkPermission).toHaveBeenCalledTimes(1);
131
+ expect(checkPermission).toHaveBeenCalledWith(
132
+ "bash",
133
+ { command: "( rm x )" },
134
+ undefined,
135
+ [],
136
+ );
137
+ });
138
+
139
+ it("forwards the agent name and session rules to each sub-command check", () => {
140
+ const sessionRules: Rule[] = [
141
+ { surface: "bash", pattern: "npm *", action: "allow", origin: "session" },
142
+ ];
143
+ const checkPermission = vi
144
+ .fn<CheckPermissionFn>()
145
+ .mockReturnValue(bashResult("allow", "npm i"));
146
+
147
+ resolveBashCommandCheck(
148
+ "npm i",
149
+ [{ text: "npm i" }],
150
+ "agent-x",
151
+ sessionRules,
152
+ checkPermission,
153
+ );
154
+
155
+ expect(checkPermission).toHaveBeenCalledWith(
156
+ "bash",
157
+ { command: "npm i" },
158
+ "agent-x",
159
+ sessionRules,
160
+ );
161
+ });
162
+
163
+ it("tags the winning result with the offending command's execution context", () => {
164
+ const checkPermission = vi
165
+ .fn<CheckPermissionFn>()
166
+ .mockImplementation((_surface, input) => {
167
+ const command = (input as { command: string }).command;
168
+ return command.startsWith("rm")
169
+ ? bashResult("deny", command, "rm *")
170
+ : bashResult("allow", command, "echo *");
171
+ });
172
+
173
+ const result = resolveBashCommandCheck(
174
+ "echo $(rm -rf foo)",
175
+ [
176
+ { text: "echo $(rm -rf foo)" },
177
+ { text: "rm -rf foo", context: "command_substitution" },
178
+ ],
179
+ undefined,
180
+ [],
181
+ checkPermission,
182
+ );
183
+
184
+ expect(result.state).toBe("deny");
185
+ expect(result.command).toBe("rm -rf foo");
186
+ expect(result.commandContext).toBe("command_substitution");
187
+ });
188
+
189
+ it("leaves commandContext unset when the winning command is top-level", () => {
190
+ const checkPermission = vi
191
+ .fn<CheckPermissionFn>()
192
+ .mockReturnValue(bashResult("deny", "rm -rf foo", "rm *"));
193
+
194
+ const result = resolveBashCommandCheck(
195
+ "rm -rf foo",
196
+ [{ text: "rm -rf foo" }],
197
+ undefined,
198
+ [],
199
+ checkPermission,
200
+ );
201
+
202
+ expect(result.state).toBe("deny");
203
+ expect(result.commandContext).toBeUndefined();
204
+ });
205
+ });
@@ -1,8 +1,11 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
+ import { getNonEmptyString, toRecord } from "#src/common";
2
3
  import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
4
+ import { BashProgram } from "#src/handlers/gates/bash-program";
3
5
  import type {
4
6
  GateBypass,
5
7
  GateDescriptor,
8
+ GateResult,
6
9
  } from "#src/handlers/gates/descriptor";
7
10
  import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
8
11
  import type { ToolCallContext } from "#src/handlers/gates/types";
@@ -34,11 +37,34 @@ function makeCheckResult(
34
37
  };
35
38
  }
36
39
 
40
+ /**
41
+ * Mirror the handler's parse-once derivation: parse the bash command into a
42
+ * shared `BashProgram` and inject it, exactly as `permission-gate-handler.ts`
43
+ * does, so the gate is exercised through the production wiring.
44
+ */
45
+ async function describeGate(
46
+ tcc: ToolCallContext,
47
+ checkPermission: Parameters<typeof describeBashExternalDirectoryGate>[2],
48
+ getSessionRuleset: Parameters<typeof describeBashExternalDirectoryGate>[3],
49
+ ): Promise<GateResult> {
50
+ const command = getNonEmptyString(toRecord(tcc.input).command);
51
+ const bashProgram =
52
+ tcc.toolName === "bash" && command
53
+ ? await BashProgram.parse(command)
54
+ : null;
55
+ return describeBashExternalDirectoryGate(
56
+ tcc,
57
+ bashProgram,
58
+ checkPermission,
59
+ getSessionRuleset,
60
+ );
61
+ }
62
+
37
63
  // ── tests ──────────────────────────────────────────────────────────────────
38
64
 
39
65
  describe("describeBashExternalDirectoryGate", () => {
40
66
  it("returns null when tool is not bash", async () => {
41
- const result = await describeBashExternalDirectoryGate(
67
+ const result = await describeGate(
42
68
  makeTcc({ toolName: "read" }),
43
69
  vi.fn().mockReturnValue(makeCheckResult("ask")),
44
70
  vi.fn().mockReturnValue([]),
@@ -47,7 +73,7 @@ describe("describeBashExternalDirectoryGate", () => {
47
73
  });
48
74
 
49
75
  it("returns null when no CWD", async () => {
50
- const result = await describeBashExternalDirectoryGate(
76
+ const result = await describeGate(
51
77
  makeTcc({ cwd: undefined }),
52
78
  vi.fn().mockReturnValue(makeCheckResult("ask")),
53
79
  vi.fn().mockReturnValue([]),
@@ -56,7 +82,7 @@ describe("describeBashExternalDirectoryGate", () => {
56
82
  });
57
83
 
58
84
  it("returns null when command has no external paths", async () => {
59
- const result = await describeBashExternalDirectoryGate(
85
+ const result = await describeGate(
60
86
  makeTcc({ input: { command: "ls -la" } }),
61
87
  vi.fn().mockReturnValue(makeCheckResult("ask")),
62
88
  vi.fn().mockReturnValue([]),
@@ -68,7 +94,7 @@ describe("describeBashExternalDirectoryGate", () => {
68
94
  const checkPermission = vi
69
95
  .fn()
70
96
  .mockReturnValue(makeCheckResult("allow", { source: "session" }));
71
- const result = await describeBashExternalDirectoryGate(
97
+ const result = await describeGate(
72
98
  makeTcc(),
73
99
  checkPermission,
74
100
  vi.fn().mockReturnValue([]),
@@ -85,7 +111,7 @@ describe("describeBashExternalDirectoryGate", () => {
85
111
 
86
112
  it("returns GateDescriptor with multi-pattern sessionApproval for uncovered paths", async () => {
87
113
  const checkPermission = vi.fn().mockReturnValue(makeCheckResult("ask"));
88
- const result = await describeBashExternalDirectoryGate(
114
+ const result = await describeGate(
89
115
  makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
90
116
  checkPermission,
91
117
  vi.fn().mockReturnValue([]),
@@ -110,7 +136,7 @@ describe("describeBashExternalDirectoryGate", () => {
110
136
  return makeCheckResult("ask");
111
137
  },
112
138
  );
113
- const result = await describeBashExternalDirectoryGate(
139
+ const result = await describeGate(
114
140
  makeTcc(),
115
141
  checkPermission,
116
142
  vi.fn().mockReturnValue([]),
@@ -132,7 +158,7 @@ describe("describeBashExternalDirectoryGate", () => {
132
158
  return makeCheckResult("ask");
133
159
  },
134
160
  );
135
- const result = await describeBashExternalDirectoryGate(
161
+ const result = await describeGate(
136
162
  makeTcc(),
137
163
  checkPermission,
138
164
  vi.fn().mockReturnValue([]),
@@ -143,7 +169,7 @@ describe("describeBashExternalDirectoryGate", () => {
143
169
  });
144
170
 
145
171
  it("descriptor surface is 'external_directory'", async () => {
146
- const result = await describeBashExternalDirectoryGate(
172
+ const result = await describeGate(
147
173
  makeTcc(),
148
174
  vi.fn().mockReturnValue(makeCheckResult("ask")),
149
175
  vi.fn().mockReturnValue([]),
@@ -153,7 +179,7 @@ describe("describeBashExternalDirectoryGate", () => {
153
179
  });
154
180
 
155
181
  it("descriptor decision surface is 'external_directory'", async () => {
156
- const result = await describeBashExternalDirectoryGate(
182
+ const result = await describeGate(
157
183
  makeTcc(),
158
184
  vi.fn().mockReturnValue(makeCheckResult("ask")),
159
185
  vi.fn().mockReturnValue([]),
@@ -163,7 +189,7 @@ describe("describeBashExternalDirectoryGate", () => {
163
189
  });
164
190
 
165
191
  it("denialContext contains the command and external paths", async () => {
166
- const result = await describeBashExternalDirectoryGate(
192
+ const result = await describeGate(
167
193
  makeTcc({ input: { command: "cat /outside/file.ts" } }),
168
194
  vi.fn().mockReturnValue(makeCheckResult("ask")),
169
195
  vi.fn().mockReturnValue([]),
@@ -177,7 +203,7 @@ describe("describeBashExternalDirectoryGate", () => {
177
203
  });
178
204
 
179
205
  it("promptDetails includes command and tool_call source", async () => {
180
- const result = await describeBashExternalDirectoryGate(
206
+ const result = await describeGate(
181
207
  makeTcc({ agentName: "agent-1", toolCallId: "tc-5" }),
182
208
  vi.fn().mockReturnValue(makeCheckResult("ask")),
183
209
  vi.fn().mockReturnValue([]),
@@ -203,7 +229,7 @@ describe("describeBashExternalDirectoryGate", () => {
203
229
  return makeCheckResult("ask");
204
230
  },
205
231
  );
206
- const result = await describeBashExternalDirectoryGate(
232
+ const result = await describeGate(
207
233
  makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
208
234
  checkPermission,
209
235
  vi.fn().mockReturnValue([]),
@@ -227,7 +253,7 @@ describe("describeBashExternalDirectoryGate", () => {
227
253
  return makeCheckResult("ask");
228
254
  },
229
255
  );
230
- const result = await describeBashExternalDirectoryGate(
256
+ const result = await describeGate(
231
257
  makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
232
258
  checkPermission,
233
259
  vi.fn().mockReturnValue([]),
@@ -252,7 +278,7 @@ describe("describeBashExternalDirectoryGate", () => {
252
278
  return makeCheckResult("ask");
253
279
  },
254
280
  );
255
- const result = await describeBashExternalDirectoryGate(
281
+ const result = await describeGate(
256
282
  makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
257
283
  checkPermission,
258
284
  vi.fn().mockReturnValue([]),