@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
@@ -26,13 +26,10 @@ import { makeCheckResult } from "#test/helpers/handler-fixtures";
26
26
  */
27
27
  export function makeResolver(defaultCheck?: PermissionCheckResult) {
28
28
  const resolve = vi.fn<ScopedPermissionResolver["resolve"]>();
29
- const resolvePathPolicy =
30
- vi.fn<ScopedPermissionResolver["resolvePathPolicy"]>();
31
29
  if (defaultCheck) {
32
30
  resolve.mockReturnValue(defaultCheck);
33
- resolvePathPolicy.mockReturnValue(defaultCheck);
34
31
  }
35
- return { resolve, resolvePathPolicy };
32
+ return { resolve };
36
33
  }
37
34
 
38
35
  /**
@@ -95,7 +92,6 @@ export function makeGateRunner(
95
92
  overrides: {
96
93
  resolveResult?: PermissionCheckResult;
97
94
  resolve?: ScopedPermissionResolver["resolve"];
98
- resolvePathPolicy?: ScopedPermissionResolver["resolvePathPolicy"];
99
95
  recordSessionApproval?: SessionApprovalRecorder["recordSessionApproval"];
100
96
  canConfirm?: GatePrompter["canConfirm"];
101
97
  prompt?: GatePrompter["prompt"];
@@ -110,13 +106,6 @@ export function makeGateRunner(
110
106
  .mockReturnValue(
111
107
  overrides.resolveResult ?? makeCheckResult({ matchedPattern: "*" }),
112
108
  );
113
- const resolvePathPolicy =
114
- overrides.resolvePathPolicy ??
115
- vi
116
- .fn<ScopedPermissionResolver["resolvePathPolicy"]>()
117
- .mockReturnValue(
118
- overrides.resolveResult ?? makeCheckResult({ matchedPattern: "*" }),
119
- );
120
109
  const recordSessionApproval =
121
110
  overrides.recordSessionApproval ??
122
111
  (vi.fn() as SessionApprovalRecorder["recordSessionApproval"]);
@@ -129,7 +118,7 @@ export function makeGateRunner(
129
118
  .fn<GatePrompter["prompt"]>()
130
119
  .mockResolvedValue({ approved: true, state: "approved" });
131
120
  const runner = new GateRunner(
132
- { resolve, resolvePathPolicy },
121
+ { resolve },
133
122
  { recordSessionApproval },
134
123
  { canConfirm, prompt },
135
124
  reporter,
@@ -138,7 +127,6 @@ export function makeGateRunner(
138
127
  runner,
139
128
  deps: {
140
129
  resolve,
141
- resolvePathPolicy,
142
130
  recordSessionApproval,
143
131
  canConfirm,
144
132
  prompt,
@@ -217,22 +205,22 @@ export function makePathDispatchResolver(
217
205
  defaultResult: PermissionCheckResult,
218
206
  ) {
219
207
  const resolve = vi.fn<ScopedPermissionResolver["resolve"]>();
220
- resolve.mockImplementation((_surface, input) => {
221
- const path = (input as Record<string, unknown>).path;
222
- if (typeof path === "string" && path in byPath) {
223
- return byPath[path];
208
+ resolve.mockImplementation((intent) => {
209
+ if (intent.kind === "tool") {
210
+ const path = (intent.input as Record<string, unknown>).path;
211
+ if (typeof path === "string" && path in byPath) {
212
+ return byPath[path];
213
+ }
214
+ return defaultResult;
224
215
  }
225
- return defaultResult;
226
- });
227
- const resolvePathPolicy =
228
- vi.fn<ScopedPermissionResolver["resolvePathPolicy"]>();
229
- resolvePathPolicy.mockImplementation((values) => {
216
+ const values =
217
+ intent.kind === "access-path" ? intent.path.matchValues() : intent.values;
230
218
  for (const value of values) {
231
219
  if (value in byPath) return byPath[value];
232
220
  }
233
221
  return defaultResult;
234
222
  });
235
- return { resolve, resolvePathPolicy };
223
+ return { resolve };
236
224
  }
237
225
 
238
226
  /**
@@ -4,12 +4,13 @@
4
4
  * `makeHandler` builds a real PermissionSession + PermissionResolver and wires
5
5
  * them into the handler and pipelines exactly as `index.ts` does.
6
6
  * Call-site overrides for permission results flow through
7
- * `permissionManager.checkPermission`; session state overrides are applied
7
+ * `permissionManager.check`; session state overrides are applied
8
8
  * via vi.spyOn on the real session instance.
9
9
  */
10
10
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { vi } from "vitest";
12
12
 
13
+ import type { ResolvedAccessIntent } from "#src/access-intent/access-intent";
13
14
  import { GateDecisionReporter } from "#src/decision-reporter";
14
15
  import type { GatePrompter } from "#src/gate-prompter";
15
16
  import { GateRunner } from "#src/handlers/gates/runner";
@@ -238,21 +239,26 @@ export function makeHandler(overrides?: {
238
239
  const so = overrides?.session;
239
240
  const surfaceCheck = so?.checkPermission;
240
241
  if (surfaceCheck) {
241
- vi.mocked(permissionManager.checkPermission).mockImplementation(
242
- surfaceCheck,
243
- );
244
- // The bash path and external-directory gates resolve through
245
- // checkPathPolicy; route it through the same surface dispatcher (threading
246
- // the real surface) so `path` / `external_directory` overrides apply to
247
- // bash tokens and tool paths alike (#418).
248
- vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
249
- (values, agentName, sessionRules, surface = "path") =>
250
- surfaceCheck(
251
- surface,
252
- { path: values[0] ?? "*" },
253
- agentName,
242
+ // Route the unified check(intent) through the surface dispatcher so
243
+ // makeSurfaceCheck / makeBashCommandCheck overrides apply to all gate
244
+ // paths via the single manager entry point (#478).
245
+ vi.mocked(permissionManager.check).mockImplementation(
246
+ (intent: ResolvedAccessIntent, sessionRules) => {
247
+ if (intent.kind === "path-values") {
248
+ return surfaceCheck(
249
+ intent.surface,
250
+ { path: intent.values[0] ?? "*" },
251
+ intent.agentName,
252
+ sessionRules,
253
+ );
254
+ }
255
+ return surfaceCheck(
256
+ intent.surface,
257
+ intent.input,
258
+ intent.agentName,
254
259
  sessionRules,
255
- ),
260
+ );
261
+ },
256
262
  );
257
263
  }
258
264
  if (so?.getActiveSkillEntries) {
@@ -13,6 +13,7 @@
13
13
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
14
14
  import { vi } from "vitest";
15
15
 
16
+ import type { ResolvedAccessIntent } from "#src/access-intent/access-intent";
16
17
  import type { SessionConfigStore } from "#src/config-store";
17
18
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
18
19
  import type { ExtensionPaths } from "#src/extension-paths";
@@ -87,12 +88,10 @@ export function makeForwarding(): ForwardingController {
87
88
  export function makeFakePermissionManager() {
88
89
  return {
89
90
  configureForCwd: vi.fn<(cwd: string | undefined | null) => void>(),
90
- checkPermission: vi
91
+ check: vi
91
92
  .fn<
92
93
  (
93
- toolName: string,
94
- input: unknown,
95
- agentName?: string,
94
+ intent: ResolvedAccessIntent,
96
95
  sessionRules?: Ruleset,
97
96
  ) => PermissionCheckResult
98
97
  >()
@@ -102,20 +101,6 @@ export function makeFakePermissionManager() {
102
101
  source: "tool",
103
102
  origin: "builtin",
104
103
  }),
105
- checkPathPolicy: vi
106
- .fn<
107
- (
108
- values: readonly string[],
109
- agentName?: string,
110
- sessionRules?: Ruleset,
111
- ) => PermissionCheckResult
112
- >()
113
- .mockReturnValue({
114
- state: "allow",
115
- toolName: "path",
116
- source: "special",
117
- origin: "builtin",
118
- }),
119
104
  getToolPermission: vi
120
105
  .fn<(toolName: string, agentName?: string) => PermissionState>()
121
106
  .mockReturnValue("allow"),
@@ -22,7 +22,6 @@ vi.mock("node:fs", () => ({
22
22
 
23
23
  import {
24
24
  canonicalNormalizePathForComparison,
25
- getExternalDirectoryPolicyValues,
26
25
  getPathBearingToolPath,
27
26
  getPathPolicyValues,
28
27
  getToolInputPath,
@@ -615,36 +614,3 @@ describe("getPathPolicyValues", () => {
615
614
  expect(getPathPolicyValues(" ", { cwd })).toEqual([]);
616
615
  });
617
616
  });
618
-
619
- describe("getExternalDirectoryPolicyValues", () => {
620
- const cwd = "/projects/my-app";
621
-
622
- beforeEach(() => {
623
- realpathSync.mockReset();
624
- realpathSync.mockImplementation((p: string) => p);
625
- });
626
-
627
- test("adds the symlink-resolved alias alongside the typed path", () => {
628
- // /tmp -> /private/tmp (the macOS symlink from the bug report).
629
- realpathSync.mockImplementation((p: string) =>
630
- p.startsWith("/tmp") ? `/private${p}` : p,
631
- );
632
- expect(getExternalDirectoryPolicyValues("/tmp/x", cwd)).toEqual([
633
- "/tmp/x",
634
- "/private/tmp/x",
635
- ]);
636
- });
637
-
638
- test("dedups when the canonical form equals the lexical form", () => {
639
- expect(getExternalDirectoryPolicyValues("/etc/hosts", cwd)).toEqual([
640
- "/etc/hosts",
641
- ]);
642
- });
643
-
644
- test("keeps the relative aliases for an in-cwd token without duplicating", () => {
645
- expect(getExternalDirectoryPolicyValues("src/foo.ts", cwd)).toEqual([
646
- "/projects/my-app/src/foo.ts",
647
- "src/foo.ts",
648
- ]);
649
- });
650
- });
@@ -37,7 +37,7 @@ function makeDeps(
37
37
  ): PermissionRpcDeps {
38
38
  return {
39
39
  permissionManager: {
40
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
40
+ check: vi.fn().mockReturnValue(makeCheckResult("allow")),
41
41
  },
42
42
  sessionRules: { getRuleset: vi.fn().mockReturnValue([]) },
43
43
  session: { getRuntimeContext: vi.fn().mockReturnValue(null) },
@@ -74,7 +74,7 @@ describe("registerPermissionRpcHandlers — permissions:rpc:check", () => {
74
74
  const bus = createEventBus();
75
75
  const deps = makeDeps({
76
76
  permissionManager: {
77
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
77
+ check: vi.fn().mockReturnValue(makeCheckResult("allow")),
78
78
  },
79
79
  });
80
80
  registerPermissionRpcHandlers(bus, deps);
@@ -101,7 +101,7 @@ describe("registerPermissionRpcHandlers — permissions:rpc:check", () => {
101
101
  const bus = createEventBus();
102
102
  const deps = makeDeps({
103
103
  permissionManager: {
104
- checkPermission: vi.fn().mockReturnValue(
104
+ check: vi.fn().mockReturnValue(
105
105
  makeCheckResult("deny", {
106
106
  origin: "project",
107
107
  matchedPattern: "rm *",
@@ -132,7 +132,7 @@ describe("registerPermissionRpcHandlers — permissions:rpc:check", () => {
132
132
  const bus = createEventBus();
133
133
  const deps = makeDeps({
134
134
  permissionManager: {
135
- checkPermission: vi
135
+ check: vi
136
136
  .fn()
137
137
  .mockReturnValue(
138
138
  makeCheckResult("ask", { matchedPattern: undefined }),
@@ -157,11 +157,11 @@ describe("registerPermissionRpcHandlers — permissions:rpc:check", () => {
157
157
  }
158
158
  });
159
159
 
160
- it("passes agentName to checkPermission when provided", async () => {
161
- const checkPermission = vi.fn().mockReturnValue(makeCheckResult("allow"));
160
+ it("passes agentName to check when provided", async () => {
161
+ const check = vi.fn().mockReturnValue(makeCheckResult("allow"));
162
162
  const bus = createEventBus();
163
163
  const deps = makeDeps({
164
- permissionManager: { checkPermission },
164
+ permissionManager: { check },
165
165
  });
166
166
  registerPermissionRpcHandlers(bus, deps);
167
167
 
@@ -177,10 +177,12 @@ describe("registerPermissionRpcHandlers — permissions:rpc:check", () => {
177
177
  });
178
178
  await replyPromise;
179
179
 
180
- expect(checkPermission).toHaveBeenCalledWith(
181
- "bash",
182
- expect.anything(),
183
- "Worker",
180
+ expect(check).toHaveBeenCalledWith(
181
+ expect.objectContaining({
182
+ kind: "tool",
183
+ surface: "bash",
184
+ agentName: "Worker",
185
+ }),
184
186
  expect.anything(),
185
187
  );
186
188
  });
@@ -194,10 +196,10 @@ describe("registerPermissionRpcHandlers — permissions:rpc:check", () => {
194
196
  origin: "session" as const,
195
197
  },
196
198
  ];
197
- const checkPermission = vi.fn().mockReturnValue(makeCheckResult("allow"));
199
+ const check = vi.fn().mockReturnValue(makeCheckResult("allow"));
198
200
  const bus = createEventBus();
199
201
  const deps = makeDeps({
200
- permissionManager: { checkPermission },
202
+ permissionManager: { check },
201
203
  sessionRules: { getRuleset: vi.fn().mockReturnValue(sessionRules) },
202
204
  });
203
205
  registerPermissionRpcHandlers(bus, deps);
@@ -213,10 +215,12 @@ describe("registerPermissionRpcHandlers — permissions:rpc:check", () => {
213
215
  });
214
216
  await replyPromise;
215
217
 
216
- expect(checkPermission).toHaveBeenCalledWith(
217
- "bash",
218
- expect.anything(),
219
- undefined,
218
+ expect(check).toHaveBeenCalledWith(
219
+ expect.objectContaining({
220
+ kind: "tool",
221
+ surface: "bash",
222
+ agentName: undefined,
223
+ }),
220
224
  sessionRules,
221
225
  );
222
226
  });
@@ -243,10 +247,10 @@ describe("registerPermissionRpcHandlers — permissions:rpc:check", () => {
243
247
  });
244
248
 
245
249
  it("unsubCheck stops the handler from firing", async () => {
246
- const checkPermission = vi.fn().mockReturnValue(makeCheckResult("allow"));
250
+ const check = vi.fn().mockReturnValue(makeCheckResult("allow"));
247
251
  const bus = createEventBus();
248
252
  const deps = makeDeps({
249
- permissionManager: { checkPermission },
253
+ permissionManager: { check },
250
254
  });
251
255
  const handles = registerPermissionRpcHandlers(bus, deps);
252
256
  handles.unsubCheck();
@@ -258,7 +262,7 @@ describe("registerPermissionRpcHandlers — permissions:rpc:check", () => {
258
262
 
259
263
  // Give async handlers a chance to fire
260
264
  await new Promise((resolve) => setTimeout(resolve, 10));
261
- expect(checkPermission).not.toHaveBeenCalled();
265
+ expect(check).not.toHaveBeenCalled();
262
266
  });
263
267
  });
264
268