@gotgenes/pi-permission-system 5.15.0 → 5.17.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.
@@ -1,4 +1,5 @@
1
1
  import { prefix } from "./bash-arity";
2
+ import { PATH_BEARING_TOOLS } from "./path-utils";
2
3
  import { deriveApprovalPattern } from "./session-rules";
3
4
 
4
5
  /** The suggestion returned for a "Yes, for this session" dialog option. */
@@ -69,7 +70,11 @@ function buildLabel(pattern: string, surface: string): string {
69
70
  case "external_directory":
70
71
  return `Yes, allow access to external directory "${pattern}" for this session`;
71
72
  default:
72
- // Tool surfaces (read, write, edit, grep, find, ls, extension tools)
73
+ // Path-bearing tools with a specific path pattern show the pattern.
74
+ if (PATH_BEARING_TOOLS.has(surface) && pattern !== "*") {
75
+ return `Yes, allow ${surface} "${pattern}" for this session`;
76
+ }
77
+ // Tool surfaces with catch-all or extension tools.
73
78
  return `Yes, allow tool "${surface}" for this session`;
74
79
  }
75
80
  }
@@ -100,7 +105,12 @@ export function suggestSessionPattern(
100
105
  pattern = deriveApprovalPattern(value);
101
106
  break;
102
107
  default:
103
- // Tool surfaces (read, write, edit, grep, find, ls, extension tools)
108
+ // Path-bearing tools: derive a directory-scoped pattern from the path.
109
+ if (PATH_BEARING_TOOLS.has(surface) && value !== "*") {
110
+ pattern = deriveApprovalPattern(value);
111
+ break;
112
+ }
113
+ // Extension tools / fallback.
104
114
  pattern = "*";
105
115
  break;
106
116
  }
@@ -30,7 +30,7 @@ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
30
30
  "find",
31
31
  "ls",
32
32
  ]);
33
- const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
33
+ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
34
34
 
35
35
  /** Universal fallback when permission["*"] is absent from all scopes. */
36
36
  const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
package/src/rule.ts CHANGED
@@ -79,6 +79,33 @@ export function evaluate(
79
79
  * evaluating the first candidate so the caller always receives a concrete
80
80
  * result.
81
81
  */
82
+ /**
83
+ * Evaluate a surface against multiple values, returning the most restrictive
84
+ * non-allow result (deny > ask > allow).
85
+ *
86
+ * Used by the cross-cutting `path` surface to aggregate permission decisions
87
+ * across multiple file paths extracted from a single tool call or bash command.
88
+ *
89
+ * Returns `null` when all values evaluate to `allow` (no restriction).
90
+ * Returns the first `deny` immediately (short-circuit).
91
+ * Returns the first `ask` if no `deny` is found.
92
+ */
93
+ export function evaluateMostRestrictive(
94
+ surface: string,
95
+ values: string[],
96
+ rules: Ruleset,
97
+ ): { rule: Rule; value: string } | null {
98
+ let worst: { rule: Rule; value: string } | null = null;
99
+ for (const value of values) {
100
+ const rule = evaluate(surface, value, rules);
101
+ if (rule.action === "deny") return { rule, value };
102
+ if (rule.action === "ask" && worst?.rule.action !== "ask") {
103
+ worst = { rule, value };
104
+ }
105
+ }
106
+ return worst;
107
+ }
108
+
82
109
  export function evaluateFirst(
83
110
  surface: string,
84
111
  values: string[],
@@ -9,7 +9,10 @@ vi.mock("node:os", () => {
9
9
  };
10
10
  });
11
11
 
12
- import { extractExternalPathsFromBashCommand } from "../src/handlers/gates/bash-path-extractor";
12
+ import {
13
+ extractExternalPathsFromBashCommand,
14
+ extractTokensForPathRules,
15
+ } from "../src/handlers/gates/bash-path-extractor";
13
16
  import {
14
17
  formatBashExternalDirectoryAskPrompt,
15
18
  formatBashExternalDirectoryDenyReason,
@@ -887,3 +890,80 @@ describe("formatBashExternalDirectoryDenyReason", () => {
887
890
  expect(result).toContain("my-agent");
888
891
  });
889
892
  });
893
+
894
+ describe("extractTokensForPathRules", () => {
895
+ test("extracts dot-files: cat .env", async () => {
896
+ const tokens = await extractTokensForPathRules("cat .env");
897
+ expect(tokens).toContain(".env");
898
+ });
899
+
900
+ test("extracts relative dot-paths: git add src/.env", async () => {
901
+ const tokens = await extractTokensForPathRules("git add src/.env");
902
+ expect(tokens).toContain("src/.env");
903
+ });
904
+
905
+ test("extracts nothing from plain words: echo hello", async () => {
906
+ const tokens = await extractTokensForPathRules("echo hello");
907
+ expect(tokens).toHaveLength(0);
908
+ });
909
+
910
+ test("extracts ./src and skips flags: rm -rf ./src", async () => {
911
+ const tokens = await extractTokensForPathRules("rm -rf ./src");
912
+ expect(tokens).toContain("./src");
913
+ expect(tokens).not.toContain("-rf");
914
+ });
915
+
916
+ test("extracts absolute paths: cat /etc/hosts", async () => {
917
+ const tokens = await extractTokensForPathRules("cat /etc/hosts");
918
+ expect(tokens).toContain("/etc/hosts");
919
+ });
920
+
921
+ test("skips URLs: curl https://example.com", async () => {
922
+ const tokens = await extractTokensForPathRules("curl https://example.com");
923
+ expect(tokens).not.toContain("https://example.com");
924
+ });
925
+
926
+ test("extracts slash-containing tokens: cat src/foo.ts", async () => {
927
+ const tokens = await extractTokensForPathRules("cat src/foo.ts");
928
+ expect(tokens).toContain("src/foo.ts");
929
+ });
930
+
931
+ test("skips heredoc content", async () => {
932
+ const tokens = await extractTokensForPathRules("cat <<EOF\n.env\nEOF");
933
+ expect(tokens).not.toContain(".env");
934
+ });
935
+
936
+ test("skips @scope/package patterns", async () => {
937
+ const tokens = await extractTokensForPathRules(
938
+ "npm install @scope/package",
939
+ );
940
+ expect(tokens).not.toContain("@scope/package");
941
+ });
942
+
943
+ test("skips env assignments", async () => {
944
+ const tokens = await extractTokensForPathRules("FOO=/bar command");
945
+ expect(tokens).not.toContain("FOO=/bar");
946
+ });
947
+
948
+ test("skips bare-slash tokens", async () => {
949
+ const tokens = await extractTokensForPathRules("ls /");
950
+ expect(tokens).not.toContain("/");
951
+ });
952
+
953
+ test("extracts redirect targets: echo test > .env", async () => {
954
+ const tokens = await extractTokensForPathRules("echo test > .env");
955
+ expect(tokens).toContain(".env");
956
+ });
957
+
958
+ test("extracts multiple path tokens: cp .env .env.backup", async () => {
959
+ const tokens = await extractTokensForPathRules("cp .env .env.backup");
960
+ expect(tokens).toContain(".env");
961
+ expect(tokens).toContain(".env.backup");
962
+ });
963
+
964
+ test("deduplicates repeated tokens", async () => {
965
+ const tokens = await extractTokensForPathRules("cat .env && rm .env");
966
+ const envCount = tokens.filter((t) => t === ".env").length;
967
+ expect(envCount).toBe(1);
968
+ });
969
+ });
@@ -47,9 +47,29 @@ function makeCheckPermission(
47
47
  return vi
48
48
  .fn()
49
49
  .mockImplementation((surface: string): PermissionCheckResult => {
50
- const state =
51
- surface === "external_directory" ? externalDirectoryState : toolState;
52
- return { state, toolName: surface, source: "tool", origin: "builtin" };
50
+ if (surface === "external_directory") {
51
+ return {
52
+ state: externalDirectoryState,
53
+ toolName: surface,
54
+ source: "tool",
55
+ origin: "builtin",
56
+ };
57
+ }
58
+ // The cross-cutting path gate runs before ext-dir; keep it transparent.
59
+ if (surface === "path") {
60
+ return {
61
+ state: "allow",
62
+ toolName: surface,
63
+ source: "special",
64
+ origin: "builtin",
65
+ };
66
+ }
67
+ return {
68
+ state: toolState,
69
+ toolName: surface,
70
+ source: "tool",
71
+ origin: "builtin",
72
+ };
53
73
  });
54
74
  }
55
75
 
@@ -294,6 +314,67 @@ describe("external_directory policy state — allow", () => {
294
314
  });
295
315
  });
296
316
 
317
+ // #144: allow external reads, gate external writes
318
+ describe("external_directory — allow external reads, gate external writes (#144)", () => {
319
+ it("allows read of external path when external_directory and read are both allow", async () => {
320
+ const { handler } = makeHandler({
321
+ session: { checkPermission: makeCheckPermission("allow", "allow") },
322
+ });
323
+ const event = makeToolCallEvent("read", { path: EXTERNAL_PATH });
324
+ const result = await handler.handleToolCall(event, makeCtx());
325
+ expect(result).toEqual({});
326
+ });
327
+
328
+ it("prompts for write to external path when external_directory allows but write is ask", async () => {
329
+ const prompt = vi
330
+ .fn()
331
+ .mockResolvedValue({ approved: true, state: "approved" });
332
+ const { handler } = makeHandler({
333
+ session: {
334
+ checkPermission: makeCheckPermission("allow", "ask"),
335
+ prompt,
336
+ },
337
+ });
338
+ const event = makeToolCallEvent("write", { path: EXTERNAL_PATH });
339
+ const result = await handler.handleToolCall(event, makeCtx());
340
+ // external_directory passes; write gate prompts and user approves
341
+ expect(result).toEqual({});
342
+ expect(prompt).toHaveBeenCalledOnce();
343
+ });
344
+
345
+ it("blocks write to external path when external_directory allows but write is deny", async () => {
346
+ const { handler } = makeHandler({
347
+ session: { checkPermission: makeCheckPermission("allow", "deny") },
348
+ });
349
+ const event = makeToolCallEvent("write", { path: EXTERNAL_PATH });
350
+ const result = await handler.handleToolCall(event, makeCtx());
351
+ expect(result.block).toBe(true);
352
+ });
353
+
354
+ it("emits separate decision events for external_directory and write surfaces", async () => {
355
+ const { handler, events } = makeHandler({
356
+ session: { checkPermission: makeCheckPermission("allow", "deny") },
357
+ });
358
+ const event = makeToolCallEvent("write", { path: EXTERNAL_PATH });
359
+ await handler.handleToolCall(event, makeCtx());
360
+ const decisions = getDecisionEvents(events);
361
+ const extDirDecision = decisions.find(
362
+ (d) => d.surface === "external_directory",
363
+ );
364
+ const writeDecision = decisions.find((d) => d.surface === "write");
365
+ expect(extDirDecision).toMatchObject({
366
+ surface: "external_directory",
367
+ result: "allow",
368
+ resolution: "policy_allow",
369
+ });
370
+ expect(writeDecision).toMatchObject({
371
+ surface: "write",
372
+ result: "deny",
373
+ resolution: "policy_deny",
374
+ });
375
+ });
376
+ });
377
+
297
378
  describe("external_directory policy state — deny", () => {
298
379
  it("blocks with reason containing the external path", async () => {
299
380
  const { handler } = makeHandler({
@@ -0,0 +1,260 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Mock node:os so tilde-expansion is deterministic across platforms.
4
+ vi.mock("node:os", () => {
5
+ const homedir = vi.fn(() => "/mock/home");
6
+ return {
7
+ homedir,
8
+ default: { homedir },
9
+ };
10
+ });
11
+
12
+ import { describeBashPathGate } from "../../../src/handlers/gates/bash-path";
13
+ import type {
14
+ GateBypass,
15
+ GateDescriptor,
16
+ } from "../../../src/handlers/gates/descriptor";
17
+ import {
18
+ isGateBypass,
19
+ isGateDescriptor,
20
+ } from "../../../src/handlers/gates/descriptor";
21
+ import type { ToolCallContext } from "../../../src/handlers/gates/types";
22
+ import type { Rule } from "../../../src/rule";
23
+ import type { PermissionCheckResult } from "../../../src/types";
24
+
25
+ afterEach(() => {
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ // ── helpers ────────────────────────────────────────────────────────────────
30
+
31
+ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
32
+ return {
33
+ toolName: "bash",
34
+ agentName: null,
35
+ input: { command: "cat .env" },
36
+ toolCallId: "tc-1",
37
+ cwd: "/test/project",
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ function makeCheckResult(
43
+ overrides: Partial<PermissionCheckResult> = {},
44
+ ): PermissionCheckResult {
45
+ return {
46
+ toolName: "path",
47
+ state: "allow",
48
+ source: "special",
49
+ origin: "global",
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ type CheckPermissionFn = (
55
+ surface: string,
56
+ input: unknown,
57
+ agentName?: string,
58
+ sessionRules?: Rule[],
59
+ ) => PermissionCheckResult;
60
+
61
+ // ── tests ──────────────────────────────────────────────────────────────────
62
+
63
+ describe("describeBashPathGate", () => {
64
+ it("returns null for non-bash tools", async () => {
65
+ const checkPermission = vi.fn<CheckPermissionFn>();
66
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
67
+ const result = await describeBashPathGate(
68
+ makeTcc({ toolName: "read", input: { path: ".env" } }),
69
+ checkPermission,
70
+ getSessionRuleset,
71
+ );
72
+ expect(result).toBeNull();
73
+ });
74
+
75
+ it("returns null when no tokens are extracted", async () => {
76
+ const checkPermission = vi.fn<CheckPermissionFn>();
77
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
78
+ const result = await describeBashPathGate(
79
+ makeTcc({ input: { command: "echo hello" } }),
80
+ checkPermission,
81
+ getSessionRuleset,
82
+ );
83
+ expect(result).toBeNull();
84
+ });
85
+
86
+ it("returns null when all tokens evaluate to allow", async () => {
87
+ const checkPermission = vi
88
+ .fn<CheckPermissionFn>()
89
+ .mockReturnValue(makeCheckResult({ state: "allow" }));
90
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
91
+ const result = await describeBashPathGate(
92
+ makeTcc({ input: { command: "cat .env" } }),
93
+ checkPermission,
94
+ getSessionRuleset,
95
+ );
96
+ expect(result).toBeNull();
97
+ });
98
+
99
+ it("returns GateDescriptor when a token evaluates to deny", async () => {
100
+ const checkPermission = vi.fn<CheckPermissionFn>().mockReturnValue(
101
+ makeCheckResult({
102
+ state: "deny",
103
+ matchedPattern: "*.env",
104
+ }),
105
+ );
106
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
107
+ const result = await describeBashPathGate(
108
+ makeTcc({ input: { command: "cat .env" } }),
109
+ checkPermission,
110
+ getSessionRuleset,
111
+ );
112
+ expect(result).not.toBeNull();
113
+ expect(isGateDescriptor(result)).toBe(true);
114
+ const desc = result as GateDescriptor;
115
+ expect(desc.surface).toBe("path");
116
+ expect(desc.preCheck?.state).toBe("deny");
117
+ });
118
+
119
+ it("returns GateDescriptor when a token evaluates to ask", async () => {
120
+ const checkPermission = vi
121
+ .fn<CheckPermissionFn>()
122
+ .mockReturnValue(makeCheckResult({ state: "ask" }));
123
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
124
+ const result = await describeBashPathGate(
125
+ makeTcc({ input: { command: "cat .env" } }),
126
+ checkPermission,
127
+ getSessionRuleset,
128
+ );
129
+ expect(result).not.toBeNull();
130
+ expect(isGateDescriptor(result)).toBe(true);
131
+ const desc = result as GateDescriptor;
132
+ expect(desc.preCheck?.state).toBe("ask");
133
+ });
134
+
135
+ it("descriptor includes triggering token in prompt message", async () => {
136
+ const checkPermission = vi
137
+ .fn<CheckPermissionFn>()
138
+ .mockReturnValue(makeCheckResult({ state: "deny" }));
139
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
140
+ const result = (await describeBashPathGate(
141
+ makeTcc({ input: { command: "cat .env" } }),
142
+ checkPermission,
143
+ getSessionRuleset,
144
+ )) as GateDescriptor;
145
+ expect(result.messages.denyReason).toContain(".env");
146
+ expect(result.promptDetails.message).toContain(".env");
147
+ });
148
+
149
+ it("descriptor decision uses surface 'path'", async () => {
150
+ const checkPermission = vi
151
+ .fn<CheckPermissionFn>()
152
+ .mockReturnValue(makeCheckResult({ state: "deny" }));
153
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
154
+ const result = (await describeBashPathGate(
155
+ makeTcc({ input: { command: "cat .env" } }),
156
+ checkPermission,
157
+ getSessionRuleset,
158
+ )) as GateDescriptor;
159
+ expect(result.decision.surface).toBe("path");
160
+ });
161
+
162
+ it("returns GateBypass when session rule covers the path", async () => {
163
+ const checkPermission = vi
164
+ .fn<CheckPermissionFn>()
165
+ .mockReturnValue(makeCheckResult({ state: "allow", source: "session" }));
166
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([
167
+ {
168
+ surface: "path",
169
+ pattern: "*",
170
+ action: "allow",
171
+ layer: "session",
172
+ origin: "session",
173
+ },
174
+ ]);
175
+ const result = await describeBashPathGate(
176
+ makeTcc({ input: { command: "cat .env" } }),
177
+ checkPermission,
178
+ getSessionRuleset,
179
+ );
180
+ expect(result).not.toBeNull();
181
+ expect(isGateBypass(result)).toBe(true);
182
+ expect((result as GateBypass).action).toBe("allow");
183
+ });
184
+
185
+ it("returns null when command is missing", async () => {
186
+ const checkPermission = vi.fn<CheckPermissionFn>();
187
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
188
+ const result = await describeBashPathGate(
189
+ makeTcc({ input: {} }),
190
+ checkPermission,
191
+ getSessionRuleset,
192
+ );
193
+ expect(result).toBeNull();
194
+ });
195
+
196
+ it("evaluates most restrictive across multiple tokens", async () => {
197
+ const checkPermission = vi
198
+ .fn<CheckPermissionFn>()
199
+ .mockImplementation((_surface, input) => {
200
+ const record = input as Record<string, unknown>;
201
+ if (record.path === "src/foo.ts") {
202
+ return makeCheckResult({ state: "allow" });
203
+ }
204
+ return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
205
+ });
206
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
207
+ const result = await describeBashPathGate(
208
+ makeTcc({ input: { command: "cat src/foo.ts .env" } }),
209
+ checkPermission,
210
+ getSessionRuleset,
211
+ );
212
+ expect(result).not.toBeNull();
213
+ expect(isGateDescriptor(result)).toBe(true);
214
+ expect((result as GateDescriptor).preCheck?.state).toBe("deny");
215
+ });
216
+
217
+ it("deny wins in multi-token: cp .env README.md", async () => {
218
+ const checkPermission = vi
219
+ .fn<CheckPermissionFn>()
220
+ .mockImplementation((_surface, input) => {
221
+ const record = input as Record<string, unknown>;
222
+ if (record.path === ".env") {
223
+ return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
224
+ }
225
+ return makeCheckResult({ state: "allow" });
226
+ });
227
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
228
+ const result = await describeBashPathGate(
229
+ makeTcc({ input: { command: "cp .env README.md" } }),
230
+ checkPermission,
231
+ getSessionRuleset,
232
+ );
233
+ expect(result).not.toBeNull();
234
+ expect(isGateDescriptor(result)).toBe(true);
235
+ const desc = result as GateDescriptor;
236
+ expect(desc.preCheck?.state).toBe("deny");
237
+ expect(desc.decision.value).toBe(".env");
238
+ });
239
+
240
+ it("extracts redirect target: echo test > .env triggers deny", async () => {
241
+ const checkPermission = vi
242
+ .fn<CheckPermissionFn>()
243
+ .mockImplementation((_surface, input) => {
244
+ const record = input as Record<string, unknown>;
245
+ if (record.path === ".env") {
246
+ return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
247
+ }
248
+ return makeCheckResult({ state: "allow" });
249
+ });
250
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
251
+ const result = await describeBashPathGate(
252
+ makeTcc({ input: { command: "echo test > .env" } }),
253
+ checkPermission,
254
+ getSessionRuleset,
255
+ );
256
+ expect(result).not.toBeNull();
257
+ expect(isGateDescriptor(result)).toBe(true);
258
+ expect((result as GateDescriptor).preCheck?.state).toBe("deny");
259
+ });
260
+ });
@@ -26,9 +26,22 @@ describe("deriveDecisionValue", () => {
26
26
  expect(deriveDecisionValue("mcp", {})).toBe("mcp");
27
27
  });
28
28
 
29
- it("returns toolName for other tools", () => {
29
+ it("returns toolName for non-path-bearing tools", () => {
30
+ expect(deriveDecisionValue("my_extension_tool", {})).toBe(
31
+ "my_extension_tool",
32
+ );
33
+ });
34
+
35
+ it("returns path for path-bearing tools when path is provided", () => {
36
+ expect(deriveDecisionValue("read", {}, "/project/src/main.ts")).toBe(
37
+ "/project/src/main.ts",
38
+ );
39
+ expect(deriveDecisionValue("write", {}, "src/.env")).toBe("src/.env");
40
+ });
41
+
42
+ it("falls back to toolName for path-bearing tools when path is missing", () => {
30
43
  expect(deriveDecisionValue("read", {})).toBe("read");
31
- expect(deriveDecisionValue("write", { command: "ignored" })).toBe("write");
44
+ expect(deriveDecisionValue("write", {}, undefined)).toBe("write");
32
45
  });
33
46
  });
34
47