@gotgenes/pi-permission-system 16.0.2 → 16.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/package.json +1 -1
  3. package/src/access-intent/access-intent.ts +57 -0
  4. package/src/access-intent/access-path.ts +88 -0
  5. package/src/access-intent/bash/cwd-projection.ts +9 -4
  6. package/src/access-intent/bash/program.ts +8 -8
  7. package/src/handlers/gates/bash-command.ts +18 -3
  8. package/src/handlers/gates/bash-external-directory.ts +16 -30
  9. package/src/handlers/gates/bash-path-extractor.ts +9 -3
  10. package/src/handlers/gates/bash-path.ts +6 -4
  11. package/src/handlers/gates/external-directory-policy.ts +66 -0
  12. package/src/handlers/gates/external-directory.ts +8 -9
  13. package/src/handlers/gates/path.ts +6 -5
  14. package/src/handlers/gates/runner.ts +6 -5
  15. package/src/handlers/gates/tool-call-gate-pipeline.ts +6 -5
  16. package/src/path-utils.ts +0 -20
  17. package/src/permission-event-rpc.ts +4 -6
  18. package/src/permission-manager.ts +39 -55
  19. package/src/permission-resolver.ts +45 -57
  20. package/src/permissions-service.ts +2 -4
  21. package/src/skill-prompt-sanitizer.ts +2 -2
  22. package/test/access-intent/access-path.test.ts +107 -0
  23. package/test/access-intent/bash/program.test.ts +30 -12
  24. package/test/handlers/before-agent-start.test.ts +6 -9
  25. package/test/handlers/external-directory-session-dedup.test.ts +16 -40
  26. package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -3
  27. package/test/handlers/gates/bash-command.test.ts +26 -23
  28. package/test/handlers/gates/bash-external-directory.test.ts +36 -32
  29. package/test/handlers/gates/bash-path.test.ts +12 -8
  30. package/test/handlers/gates/external-directory-policy.test.ts +124 -0
  31. package/test/handlers/gates/external-directory.test.ts +11 -5
  32. package/test/handlers/gates/path.test.ts +30 -25
  33. package/test/handlers/gates/runner.test.ts +6 -1
  34. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +4 -3
  35. package/test/handlers/input.test.ts +1 -1
  36. package/test/helpers/gate-fixtures.ts +12 -24
  37. package/test/helpers/handler-fixtures.ts +21 -15
  38. package/test/helpers/session-fixtures.ts +3 -18
  39. package/test/path-utils.test.ts +0 -34
  40. package/test/permission-event-rpc.test.ts +24 -20
  41. package/test/permission-manager-unified.test.ts +445 -209
  42. package/test/permission-resolver.test.ts +112 -89
  43. package/test/permissions-service.test.ts +10 -7
  44. package/test/skill-prompt-sanitizer.test.ts +30 -7
@@ -1,4 +1,6 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { ResolvedAccessIntent } from "#src/access-intent/access-intent";
3
+ import { AccessPath } from "#src/access-intent/access-path";
2
4
  import type { ScopedPermissionManager } from "#src/permission-manager";
3
5
  import { PermissionResolver } from "#src/permission-resolver";
4
6
  import type { Ruleset } from "#src/rule";
@@ -9,12 +11,10 @@ import type { PermissionCheckResult, PermissionState } from "#src/types";
9
11
  function makePermissionManager() {
10
12
  return {
11
13
  configureForCwd: vi.fn<(cwd: string | undefined | null) => void>(),
12
- checkPermission: vi
14
+ check: vi
13
15
  .fn<
14
16
  (
15
- toolName: string,
16
- input: unknown,
17
- agentName?: string,
17
+ intent: ResolvedAccessIntent,
18
18
  sessionRules?: Ruleset,
19
19
  ) => PermissionCheckResult
20
20
  >()
@@ -24,20 +24,6 @@ function makePermissionManager() {
24
24
  source: "tool",
25
25
  origin: "builtin",
26
26
  }),
27
- checkPathPolicy: vi
28
- .fn<
29
- (
30
- values: readonly string[],
31
- agentName?: string,
32
- sessionRules?: Ruleset,
33
- ) => PermissionCheckResult
34
- >()
35
- .mockReturnValue({
36
- state: "allow",
37
- toolName: "path",
38
- source: "special",
39
- origin: "builtin",
40
- }),
41
27
  getToolPermission: vi
42
28
  .fn<(toolName: string, agentName?: string) => PermissionState>()
43
29
  .mockReturnValue("allow"),
@@ -62,29 +48,24 @@ beforeEach(() => {
62
48
  });
63
49
 
64
50
  describe("PermissionResolver", () => {
65
- describe("resolve", () => {
66
- it("forwards surface, input, and agentName, applying the empty session ruleset", () => {
67
- const { resolver, permissionManager } = makeResolver();
68
-
69
- resolver.resolve("bash", { command: "ls" }, "agent-x");
70
-
71
- expect(permissionManager.checkPermission).toHaveBeenCalledWith(
72
- "bash",
73
- { command: "ls" },
74
- "agent-x",
75
- [],
76
- );
77
- });
78
-
79
- it("defaults agentName to undefined when omitted", () => {
51
+ describe("resolve — tool intent", () => {
52
+ it("forwards a tool intent with the empty session ruleset", () => {
80
53
  const { resolver, permissionManager } = makeResolver();
81
54
 
82
- resolver.resolve("read", { path: ".env" });
55
+ resolver.resolve({
56
+ kind: "tool",
57
+ surface: "bash",
58
+ input: { command: "ls" },
59
+ agentName: "agent-x",
60
+ });
83
61
 
84
- expect(permissionManager.checkPermission).toHaveBeenCalledWith(
85
- "read",
86
- { path: ".env" },
87
- undefined,
62
+ expect(permissionManager.check).toHaveBeenCalledWith(
63
+ {
64
+ kind: "tool",
65
+ surface: "bash",
66
+ input: { command: "ls" },
67
+ agentName: "agent-x",
68
+ },
88
69
  [],
89
70
  );
90
71
  });
@@ -94,13 +75,16 @@ describe("PermissionResolver", () => {
94
75
  const sessionRules = new SessionRules();
95
76
  const { resolver } = makeResolver(pm, sessionRules);
96
77
 
97
- // Record an approval directly into the shared SessionRules instance.
98
78
  sessionRules.recordSessionApproval(
99
79
  SessionApproval.single("bash", "git *"),
100
80
  );
101
- resolver.resolve("bash", { command: "git status" });
81
+ resolver.resolve({
82
+ kind: "tool",
83
+ surface: "bash",
84
+ input: { command: "git status" },
85
+ });
102
86
 
103
- const passedRules = vi.mocked(pm.checkPermission).mock.calls[0][3];
87
+ const passedRules = vi.mocked(pm.check).mock.calls[0][1];
104
88
  expect(passedRules).toHaveLength(1);
105
89
  expect(passedRules?.[0]).toMatchObject({
106
90
  surface: "bash",
@@ -109,9 +93,9 @@ describe("PermissionResolver", () => {
109
93
  });
110
94
  });
111
95
 
112
- it("returns the PermissionManager's check result", () => {
96
+ it("returns the manager's check result", () => {
113
97
  const pm = makePermissionManager();
114
- vi.mocked(pm.checkPermission).mockReturnValue({
98
+ vi.mocked(pm.check).mockReturnValue({
115
99
  state: "deny",
116
100
  toolName: "bash",
117
101
  source: "bash",
@@ -120,7 +104,11 @@ describe("PermissionResolver", () => {
120
104
  });
121
105
  const { resolver } = makeResolver(pm);
122
106
 
123
- const result = resolver.resolve("bash", { command: "rm -rf /" });
107
+ const result = resolver.resolve({
108
+ kind: "tool",
109
+ surface: "bash",
110
+ input: { command: "rm -rf /" },
111
+ });
124
112
 
125
113
  expect(result).toEqual({
126
114
  state: "deny",
@@ -132,30 +120,25 @@ describe("PermissionResolver", () => {
132
120
  });
133
121
  });
134
122
 
135
- describe("resolvePathPolicy", () => {
136
- it("forwards values and agentName with the current session ruleset", () => {
123
+ describe("resolve — path-values intent", () => {
124
+ it("forwards a path-values intent with the current session ruleset", () => {
137
125
  const { resolver, permissionManager } = makeResolver();
138
126
 
139
- resolver.resolvePathPolicy(["/proj/src/a.ts", "src/a.ts"], "agent-x");
140
-
141
- expect(permissionManager.checkPathPolicy).toHaveBeenCalledWith(
142
- ["/proj/src/a.ts", "src/a.ts"],
143
- "agent-x",
144
- [],
145
- "path",
146
- );
147
- });
148
-
149
- it("forwards an explicit surface to checkPathPolicy", () => {
150
- const { resolver, permissionManager } = makeResolver();
151
-
152
- resolver.resolvePathPolicy(["/tmp/x"], "agent-x", "external_directory");
127
+ resolver.resolve({
128
+ kind: "path-values",
129
+ surface: "path",
130
+ values: ["/proj/src/a.ts", "src/a.ts"],
131
+ agentName: "agent-x",
132
+ });
153
133
 
154
- expect(permissionManager.checkPathPolicy).toHaveBeenCalledWith(
155
- ["/tmp/x"],
156
- "agent-x",
134
+ expect(permissionManager.check).toHaveBeenCalledWith(
135
+ {
136
+ kind: "path-values",
137
+ surface: "path",
138
+ values: ["/proj/src/a.ts", "src/a.ts"],
139
+ agentName: "agent-x",
140
+ },
157
141
  [],
158
- "external_directory",
159
142
  );
160
143
  });
161
144
 
@@ -167,9 +150,13 @@ describe("PermissionResolver", () => {
167
150
  sessionRules.recordSessionApproval(
168
151
  SessionApproval.single("path", "src/*"),
169
152
  );
170
- resolver.resolvePathPolicy(["src/a.ts"]);
153
+ resolver.resolve({
154
+ kind: "path-values",
155
+ surface: "path",
156
+ values: ["src/a.ts"],
157
+ });
171
158
 
172
- const passedRules = vi.mocked(pm.checkPathPolicy).mock.calls[0][2];
159
+ const passedRules = vi.mocked(pm.check).mock.calls[0][1];
173
160
  expect(passedRules).toHaveLength(1);
174
161
  expect(passedRules?.[0]).toMatchObject({
175
162
  surface: "path",
@@ -177,45 +164,78 @@ describe("PermissionResolver", () => {
177
164
  action: "allow",
178
165
  });
179
166
  });
167
+ });
180
168
 
181
- it("returns the PermissionManager's check result", () => {
169
+ describe("resolve access-path intent", () => {
170
+ it("unwraps the AccessPath via matchValues() into a path-values intent", () => {
171
+ const { resolver, permissionManager } = makeResolver();
172
+ const accessPath = AccessPath.forExternalDirectory(
173
+ "/tmp/x",
174
+ "/workspace",
175
+ );
176
+
177
+ resolver.resolve({
178
+ kind: "access-path",
179
+ surface: "external_directory",
180
+ path: accessPath,
181
+ agentName: "agent-x",
182
+ });
183
+
184
+ expect(permissionManager.check).toHaveBeenCalledWith(
185
+ {
186
+ kind: "path-values",
187
+ surface: "external_directory",
188
+ values: accessPath.matchValues(),
189
+ agentName: "agent-x",
190
+ },
191
+ [],
192
+ );
193
+ });
194
+
195
+ it("returns the manager's check result for an access-path intent", () => {
182
196
  const pm = makePermissionManager();
183
- vi.mocked(pm.checkPathPolicy).mockReturnValue({
197
+ vi.mocked(pm.check).mockReturnValue({
184
198
  state: "deny",
185
- toolName: "path",
199
+ toolName: "external_directory",
186
200
  source: "special",
187
201
  origin: "global",
188
- matchedPattern: "src/*",
202
+ matchedPattern: "/tmp/*",
189
203
  });
190
204
  const { resolver } = makeResolver(pm);
205
+ const accessPath = AccessPath.forExternalDirectory(
206
+ "/tmp/x",
207
+ "/workspace",
208
+ );
191
209
 
192
- const result = resolver.resolvePathPolicy(["src/a.ts"]);
193
-
194
- expect(result).toEqual({
195
- state: "deny",
196
- toolName: "path",
197
- source: "special",
198
- origin: "global",
199
- matchedPattern: "src/*",
210
+ const result = resolver.resolve({
211
+ kind: "access-path",
212
+ surface: "external_directory",
213
+ path: accessPath,
200
214
  });
215
+
216
+ expect(result.state).toBe("deny");
217
+ expect(result.matchedPattern).toBe("/tmp/*");
201
218
  });
202
219
  });
203
220
 
204
- describe("checkPermission", () => {
205
- it("delegates to permissionManager.checkPermission with the given args", () => {
221
+ describe("checkPermission (raw, off-interface)", () => {
222
+ it("delegates to manager.check as a tool intent without session rules", () => {
206
223
  const { resolver, permissionManager } = makeResolver();
207
224
 
208
225
  resolver.checkPermission("bash", { command: "ls" }, "agent-1");
209
226
 
210
- expect(permissionManager.checkPermission).toHaveBeenCalledWith(
211
- "bash",
212
- { command: "ls" },
213
- "agent-1",
227
+ expect(permissionManager.check).toHaveBeenCalledWith(
228
+ {
229
+ kind: "tool",
230
+ surface: "bash",
231
+ input: { command: "ls" },
232
+ agentName: "agent-1",
233
+ },
214
234
  undefined,
215
235
  );
216
236
  });
217
237
 
218
- it("passes optional sessionRules through when supplied", () => {
238
+ it("passes optional sessionRules as the second arg to check", () => {
219
239
  const { resolver, permissionManager } = makeResolver();
220
240
  const extraRules: Ruleset = [
221
241
  { surface: "bash", pattern: "*", action: "allow", origin: "session" },
@@ -228,10 +248,13 @@ describe("PermissionResolver", () => {
228
248
  extraRules,
229
249
  );
230
250
 
231
- expect(permissionManager.checkPermission).toHaveBeenCalledWith(
232
- "bash",
233
- { command: "ls" },
234
- undefined,
251
+ expect(permissionManager.check).toHaveBeenCalledWith(
252
+ {
253
+ kind: "tool",
254
+ surface: "bash",
255
+ input: { command: "ls" },
256
+ agentName: undefined,
257
+ },
235
258
  extraRules,
236
259
  );
237
260
  });
@@ -96,7 +96,7 @@ describe("checkPermission", () => {
96
96
  expect(mockBuildInputForSurface).toHaveBeenCalledWith("read", undefined);
97
97
  });
98
98
 
99
- it("calls permissionManager.checkPermission with surface, built input, agentName, and current ruleset", () => {
99
+ it("calls permissionManager.check with a tool intent, built input, agentName, and current ruleset", () => {
100
100
  const ruleset: Ruleset = [
101
101
  { surface: "bash", pattern: "*", action: "allow", origin: "global" },
102
102
  ];
@@ -106,19 +106,22 @@ describe("checkPermission", () => {
106
106
  sessionRules: makeSessionRules(ruleset),
107
107
  });
108
108
  service.checkPermission("bash", "echo hi", "my-agent");
109
- expect(permissionManager.checkPermission).toHaveBeenCalledWith(
110
- "bash",
111
- builtInput,
112
- "my-agent",
109
+ expect(permissionManager.check).toHaveBeenCalledWith(
110
+ {
111
+ kind: "tool",
112
+ surface: "bash",
113
+ input: builtInput,
114
+ agentName: "my-agent",
115
+ },
113
116
  ruleset,
114
117
  );
115
118
  void sessionRules; // used indirectly
116
119
  });
117
120
 
118
- it("returns the result from permissionManager.checkPermission", () => {
121
+ it("returns the result from permissionManager.check", () => {
119
122
  const expected = makeCheckResult({ state: "deny", toolName: "bash" });
120
123
  const { service, permissionManager } = makeService();
121
- vi.mocked(permissionManager.checkPermission).mockReturnValue(expected);
124
+ vi.mocked(permissionManager.check).mockReturnValue(expected);
122
125
  const result = service.checkPermission("bash", "rm -rf /");
123
126
  expect(result).toBe(expected);
124
127
  });
@@ -1,14 +1,27 @@
1
1
  import { resolve } from "node:path";
2
2
  import { afterEach, describe, expect, test, vi } from "vitest";
3
- import type { PermissionManager } from "#src/permission-manager";
3
+ import type { ScopedPermissionManager } from "#src/permission-manager";
4
4
  import {
5
5
  findSkillPathMatch,
6
6
  parseAllSkillPromptSections,
7
7
  resolveSkillPromptEntries,
8
+ type SkillPermissionChecker,
8
9
  } from "#src/skill-prompt-sanitizer";
9
10
  import type { PermissionCheckResult } from "#src/types";
10
11
  import { createManager } from "#test/helpers/manager-harness";
11
12
 
13
+ /**
14
+ * Adapt a real `PermissionManager` to the raw `SkillPermissionChecker`
15
+ * contract, mirroring how `PermissionResolver.checkPermission` delegates to
16
+ * `manager.check` with a tool intent (#478).
17
+ */
18
+ function asChecker(manager: ScopedPermissionManager): SkillPermissionChecker {
19
+ return {
20
+ checkPermission: (surface, input, agentName) =>
21
+ manager.check({ kind: "tool", surface, input, agentName }),
22
+ };
23
+ }
24
+
12
25
  afterEach(() => {
13
26
  vi.restoreAllMocks();
14
27
  });
@@ -20,16 +33,16 @@ const CWD = "/projects/my-app";
20
33
  function makeManager(
21
34
  defaultState: "allow" | "deny" | "ask" = "allow",
22
35
  overrides: Record<string, "allow" | "deny" | "ask"> = {},
23
- ): PermissionManager {
36
+ ): SkillPermissionChecker {
24
37
  return {
25
38
  checkPermission: vi.fn(
26
- (_surface: string, input: { name?: string }): PermissionCheckResult => {
27
- const name = input.name ?? "";
39
+ (_surface: string, input: unknown): PermissionCheckResult => {
40
+ const name = (input as { name?: string }).name ?? "";
28
41
  const state = overrides[name] ?? defaultState;
29
42
  return { toolName: "skill", state, source: "tool", origin: "builtin" };
30
43
  },
31
44
  ),
32
- } as unknown as PermissionManager;
45
+ };
33
46
  }
34
47
 
35
48
  function skillBlock(
@@ -312,7 +325,12 @@ test("REGRESSION: resolveSkillPromptEntries sanitizes every available_skills blo
312
325
  "System prompt end",
313
326
  ].join("\n");
314
327
 
315
- const result = resolveSkillPromptEntries(prompt, manager, null, "/cwd");
328
+ const result = resolveSkillPromptEntries(
329
+ prompt,
330
+ asChecker(manager),
331
+ null,
332
+ "/cwd",
333
+ );
316
334
 
317
335
  expect(result.prompt).not.toContain("denied-skill");
318
336
  expect(result.prompt).toContain("visible-skill");
@@ -354,7 +372,12 @@ test("REGRESSION: resolveSkillPromptEntries keeps only visible skills available
354
372
  "System prompt end",
355
373
  ].join("\n");
356
374
 
357
- const result = resolveSkillPromptEntries(prompt, manager, null, "/cwd");
375
+ const result = resolveSkillPromptEntries(
376
+ prompt,
377
+ asChecker(manager),
378
+ null,
379
+ "/cwd",
380
+ );
358
381
  const visiblePath = resolve("/cwd", "./skills/visible/file.ts");
359
382
  const blockedPath = resolve("/cwd", "./skills/blocked/file.ts");
360
383
  const matchedVisibleSkill = findSkillPathMatch(