@gotgenes/pi-permission-system 17.1.1 → 18.0.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,34 +1,42 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import type { ScopedPermissionManager } from "#src/permission-manager";
1
+ import { beforeEach, describe, expect, it, type Mock, vi } from "vitest";
2
+ import type { AccessIntent } from "#src/access-intent/access-intent";
3
+ import { PathNormalizer } from "#src/path-normalizer";
3
4
  import { LocalPermissionsService } from "#src/permissions-service";
4
- import type { Ruleset } from "#src/rule";
5
- import type { SessionRules } from "#src/session-rules";
6
5
  import type { ToolAccessExtractorRegistrar } from "#src/tool-access-extractor-registry";
7
6
  import type {
8
7
  ToolInputFormatter,
9
8
  ToolInputFormatterRegistrar,
10
9
  } from "#src/tool-input-formatter-registry";
10
+ import type { PermissionCheckResult, PermissionState } from "#src/types";
11
11
 
12
12
  import { makeCheckResult } from "#test/helpers/handler-fixtures";
13
- import { makeFakePermissionManager } from "#test/helpers/session-fixtures";
14
13
 
15
- // ── input-normalizer stub ──────────────────────────────────────────────────
16
-
17
- const mockBuildInputForSurface = vi.hoisted(() =>
18
- vi.fn<(surface: string, value?: string) => unknown>(),
14
+ // Mock node:fs so realpathSync (the canonical alias) is controllable.
15
+ const realpathSync = vi.hoisted(() =>
16
+ vi.fn<(path: string) => string>((p) => p),
19
17
  );
20
-
21
- vi.mock("#src/input-normalizer", () => ({
22
- buildInputForSurface: mockBuildInputForSurface,
18
+ vi.mock("node:fs", () => ({
19
+ realpathSync,
20
+ default: { realpathSync },
23
21
  }));
24
22
 
25
23
  // ── helpers ────────────────────────────────────────────────────────────────
26
24
 
27
- function makeSessionRules(
28
- rules: Ruleset = [],
29
- ): Pick<SessionRules, "getRuleset"> {
25
+ interface FakeResolver {
26
+ resolve: Mock<(intent: AccessIntent) => PermissionCheckResult>;
27
+ getToolPermission: Mock<
28
+ (toolName: string, agentName?: string) => PermissionState
29
+ >;
30
+ }
31
+
32
+ function makeResolver(): FakeResolver {
30
33
  return {
31
- getRuleset: vi.fn<SessionRules["getRuleset"]>().mockReturnValue(rules),
34
+ resolve: vi
35
+ .fn<(intent: AccessIntent) => PermissionCheckResult>()
36
+ .mockReturnValue(makeCheckResult()),
37
+ getToolPermission: vi
38
+ .fn<(toolName: string, agentName?: string) => PermissionState>()
39
+ .mockReturnValue("ask"),
32
40
  };
33
41
  }
34
42
 
@@ -49,90 +57,97 @@ function makeAccessExtractorRegistry(): ToolAccessExtractorRegistrar {
49
57
  }
50
58
 
51
59
  function makeService(overrides?: {
52
- permissionManager?: ScopedPermissionManager;
53
- sessionRules?: Pick<SessionRules, "getRuleset">;
60
+ resolver?: FakeResolver;
54
61
  formatterRegistry?: ToolInputFormatterRegistrar;
55
62
  accessExtractorRegistry?: ToolAccessExtractorRegistrar;
56
63
  }) {
57
- const permissionManager =
58
- overrides?.permissionManager ?? makeFakePermissionManager();
59
- const sessionRules = overrides?.sessionRules ?? makeSessionRules();
64
+ const resolver = overrides?.resolver ?? makeResolver();
65
+ // The published service always answers against the parent session's cwd.
66
+ const session = { getPathNormalizer: () => normalizer };
60
67
  const formatterRegistry =
61
68
  overrides?.formatterRegistry ?? makeFormatterRegistry();
62
69
  const accessExtractorRegistry =
63
70
  overrides?.accessExtractorRegistry ?? makeAccessExtractorRegistry();
64
71
  const service = new LocalPermissionsService(
65
- permissionManager,
66
- sessionRules,
72
+ resolver,
73
+ session,
67
74
  formatterRegistry,
68
75
  accessExtractorRegistry,
69
76
  );
70
- return {
71
- service,
72
- permissionManager,
73
- sessionRules,
74
- formatterRegistry,
75
- accessExtractorRegistry,
76
- };
77
+ return { service, resolver, formatterRegistry, accessExtractorRegistry };
77
78
  }
78
79
 
80
+ const normalizer = new PathNormalizer("linux", "/test/project");
81
+
79
82
  // ── tests ──────────────────────────────────────────────────────────────────
80
83
 
81
84
  beforeEach(() => {
82
- mockBuildInputForSurface.mockReset();
83
- mockBuildInputForSurface.mockReturnValue({ type: "tool-input" });
85
+ realpathSync.mockReset();
86
+ realpathSync.mockImplementation((p: string) => p);
84
87
  });
85
88
 
86
89
  describe("checkPermission", () => {
87
- it("builds the surface input from surface and value", () => {
88
- const { service } = makeService();
89
- service.checkPermission("bash", "echo hi");
90
- expect(mockBuildInputForSurface).toHaveBeenCalledWith("bash", "echo hi");
90
+ it("resolves a non-path surface through a tool intent", () => {
91
+ const { service, resolver } = makeService();
92
+ service.checkPermission("bash", "echo hi", "my-agent");
93
+ expect(resolver.resolve).toHaveBeenCalledWith({
94
+ kind: "tool",
95
+ surface: "bash",
96
+ input: { command: "echo hi" },
97
+ agentName: "my-agent",
98
+ });
91
99
  });
92
100
 
93
- it("builds the surface input with undefined value when value is omitted", () => {
94
- const { service } = makeService();
95
- service.checkPermission("read");
96
- expect(mockBuildInputForSurface).toHaveBeenCalledWith("read", undefined);
101
+ it("resolves an external_directory path query through an access-path intent matching the canonical alias", () => {
102
+ realpathSync.mockImplementation((p: string) =>
103
+ p === "/test/project/link" ? "/test/project/real" : p,
104
+ );
105
+ const { service, resolver } = makeService();
106
+ service.checkPermission("external_directory", "link");
107
+ expect(resolver.resolve).toHaveBeenCalledTimes(1);
108
+ const intent = resolver.resolve.mock.calls[0][0];
109
+ expect(intent.kind).toBe("access-path");
110
+ if (intent.kind === "access-path") {
111
+ expect(intent.surface).toBe("external_directory");
112
+ expect(intent.path.matchValues()).toContain("/test/project/real");
113
+ }
97
114
  });
98
115
 
99
- it("calls permissionManager.check with a tool intent, built input, agentName, and current ruleset", () => {
100
- const ruleset: Ruleset = [
101
- { surface: "bash", pattern: "*", action: "allow", origin: "global" },
102
- ];
103
- const builtInput = { type: "bash-input" };
104
- mockBuildInputForSurface.mockReturnValue(builtInput);
105
- const { service, permissionManager, sessionRules } = makeService({
106
- sessionRules: makeSessionRules(ruleset),
107
- });
108
- service.checkPermission("bash", "echo hi", "my-agent");
109
- expect(permissionManager.check).toHaveBeenCalledWith(
110
- {
111
- kind: "tool",
112
- surface: "bash",
113
- input: builtInput,
114
- agentName: "my-agent",
115
- },
116
- ruleset,
117
- );
118
- void sessionRules; // used indirectly
116
+ it("resolves a path-bearing tool query (read) through an access-path intent", () => {
117
+ const { service, resolver } = makeService();
118
+ service.checkPermission("read", "/test/project/.env");
119
+ const intent = resolver.resolve.mock.calls[0][0];
120
+ expect(intent.kind).toBe("access-path");
121
+ if (intent.kind === "access-path") {
122
+ expect(intent.surface).toBe("read");
123
+ expect(intent.path.value()).toBe("/test/project/.env");
124
+ }
125
+ });
126
+
127
+ it("falls back to a tool intent for a value-less path query", () => {
128
+ const { service, resolver } = makeService();
129
+ service.checkPermission("path");
130
+ const intent = resolver.resolve.mock.calls[0][0];
131
+ expect(intent.kind).toBe("tool");
119
132
  });
120
133
 
121
- it("returns the result from permissionManager.check", () => {
134
+ it("returns the result from resolver.resolve", () => {
122
135
  const expected = makeCheckResult({ state: "deny", toolName: "bash" });
123
- const { service, permissionManager } = makeService();
124
- vi.mocked(permissionManager.check).mockReturnValue(expected);
136
+ const resolver = makeResolver();
137
+ resolver.resolve.mockReturnValue(expected);
138
+ const { service } = makeService({ resolver });
125
139
  const result = service.checkPermission("bash", "rm -rf /");
126
140
  expect(result).toBe(expected);
127
141
  });
128
142
  });
129
143
 
130
144
  describe("getToolPermission", () => {
131
- it("delegates to permissionManager.getToolPermission", () => {
132
- const { service, permissionManager } = makeService();
133
- vi.mocked(permissionManager.getToolPermission).mockReturnValue("deny");
145
+ it("delegates to resolver.getToolPermission", () => {
146
+ const resolver = makeResolver();
147
+ resolver.getToolPermission.mockReturnValue("deny");
148
+ const { service } = makeService({ resolver });
134
149
  const result = service.getToolPermission("write", "my-agent");
135
- expect(permissionManager.getToolPermission).toHaveBeenCalledWith(
150
+ expect(resolver.getToolPermission).toHaveBeenCalledWith(
136
151
  "write",
137
152
  "my-agent",
138
153
  );
@@ -140,12 +155,9 @@ describe("getToolPermission", () => {
140
155
  });
141
156
 
142
157
  it("omits agentName when not provided", () => {
143
- const { service, permissionManager } = makeService();
158
+ const { service, resolver } = makeService();
144
159
  service.getToolPermission("read");
145
- expect(permissionManager.getToolPermission).toHaveBeenCalledWith(
146
- "read",
147
- undefined,
148
- );
160
+ expect(resolver.getToolPermission).toHaveBeenCalledWith("read", undefined);
149
161
  });
150
162
  });
151
163
 
@@ -1,5 +1,7 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { buildInputForSurface } from "#src/input-normalizer";
2
+ import type { AccessIntent } from "#src/access-intent/access-intent";
3
+ import { PathNormalizer } from "#src/path-normalizer";
4
+ import { LocalPermissionsService } from "#src/permissions-service";
3
5
  import type { PermissionsService } from "#src/service";
4
6
  import {
5
7
  getPermissionsService,
@@ -8,7 +10,7 @@ import {
8
10
  } from "#src/service";
9
11
  import { ToolAccessExtractorRegistry } from "#src/tool-access-extractor-registry";
10
12
  import { ToolInputFormatterRegistry } from "#src/tool-input-formatter-registry";
11
- import type { PermissionCheckResult } from "#src/types";
13
+ import type { PermissionCheckResult, PermissionState } from "#src/types";
12
14
 
13
15
  // ── helpers ────────────────────────────────────────────────────────────────
14
16
 
@@ -77,7 +79,7 @@ describe("globalThis accessor", () => {
77
79
 
78
80
  // ── service adapter delegation ─────────────────────────────────────────────
79
81
 
80
- describe("service adapter delegation", () => {
82
+ describe("service round-trip through the global slot", () => {
81
83
  afterEach(() => {
82
84
  const current = getPermissionsService();
83
85
  if (current) {
@@ -93,117 +95,68 @@ describe("service adapter delegation", () => {
93
95
  origin: "global",
94
96
  };
95
97
 
96
- it("checkPermission delegates surface and value through buildInputForSurface", () => {
97
- const checkPermission = vi.fn().mockReturnValue(fakeResult);
98
- const sessionRules = [
99
- {
100
- surface: "bash",
101
- pattern: "*",
102
- action: "allow" as const,
103
- layer: "session" as const,
104
- origin: "session" as const,
105
- },
106
- ];
107
-
108
- // Build the adapter the same way index.ts will
109
- const service = makeService({
110
- checkPermission(surface, value, agentName) {
111
- const input = buildInputForSurface(surface, value);
112
- return checkPermission(surface, input, agentName, sessionRules);
113
- },
114
- });
115
-
116
- publishPermissionsService(service);
117
- const retrieved = getPermissionsService()!;
118
- const result = retrieved.checkPermission("bash", "git push");
119
-
120
- expect(result).toBe(fakeResult);
121
- expect(checkPermission).toHaveBeenCalledWith(
122
- "bash",
123
- { command: "git push" },
124
- undefined,
125
- sessionRules,
98
+ function makeResolver() {
99
+ return {
100
+ resolve: vi
101
+ .fn<(intent: AccessIntent) => PermissionCheckResult>()
102
+ .mockReturnValue(fakeResult),
103
+ getToolPermission: vi
104
+ .fn<(toolName: string, agentName?: string) => PermissionState>()
105
+ .mockReturnValue("ask"),
106
+ };
107
+ }
108
+
109
+ function publishLocalService(resolver: ReturnType<typeof makeResolver>) {
110
+ publishPermissionsService(
111
+ new LocalPermissionsService(
112
+ resolver,
113
+ {
114
+ getPathNormalizer: () => new PathNormalizer("linux", "/test/project"),
115
+ },
116
+ new ToolInputFormatterRegistry(),
117
+ new ToolAccessExtractorRegistry(),
118
+ ),
126
119
  );
127
- });
120
+ }
128
121
 
129
- it("checkPermission passes agentName through", () => {
130
- const checkPermission = vi.fn().mockReturnValue(fakeResult);
131
-
132
- const service = makeService({
133
- checkPermission(surface, value, agentName) {
134
- const input = buildInputForSurface(surface, value);
135
- return checkPermission(surface, input, agentName, []);
136
- },
137
- });
138
-
139
- publishPermissionsService(service);
140
- getPermissionsService()!.checkPermission("skill", "my-skill", "Explore");
141
-
142
- expect(checkPermission).toHaveBeenCalledWith(
143
- "skill",
144
- { name: "my-skill" },
122
+ it("resolves a non-path query via a tool intent", () => {
123
+ const resolver = makeResolver();
124
+ publishLocalService(resolver);
125
+ const result = getPermissionsService()!.checkPermission(
126
+ "bash",
127
+ "git push",
145
128
  "Explore",
146
- [],
147
129
  );
130
+ expect(result).toBe(fakeResult);
131
+ expect(resolver.resolve).toHaveBeenCalledWith({
132
+ kind: "tool",
133
+ surface: "bash",
134
+ input: { command: "git push" },
135
+ agentName: "Explore",
136
+ });
148
137
  });
149
138
 
150
- it("getToolPermission delegates to the permission manager", () => {
151
- const getToolPermissionFn = vi.fn(
152
- (_t: string, _a?: string): "deny" => "deny",
153
- );
154
- const service: PermissionsService = {
155
- checkPermission: vi.fn(),
156
- getToolPermission(toolName, agentName) {
157
- return getToolPermissionFn(toolName, agentName);
158
- },
159
- registerToolInputFormatter: vi.fn(),
160
- registerToolAccessExtractor: vi.fn(),
161
- };
139
+ it("resolves a path-surface query via an access-path intent", () => {
140
+ const resolver = makeResolver();
141
+ publishLocalService(resolver);
142
+ getPermissionsService()!.checkPermission("read", "/test/project/.env");
143
+ const intent = resolver.resolve.mock.calls[0][0];
144
+ expect(intent.kind).toBe("access-path");
145
+ if (intent.kind === "access-path") {
146
+ expect(intent.surface).toBe("read");
147
+ }
148
+ });
162
149
 
163
- publishPermissionsService(service);
150
+ it("delegates getToolPermission through the resolver", () => {
151
+ const resolver = makeResolver();
152
+ resolver.getToolPermission.mockReturnValue("deny");
153
+ publishLocalService(resolver);
164
154
  const result = getPermissionsService()!.getToolPermission(
165
- "bash",
155
+ "write",
166
156
  "Explore",
167
157
  );
168
-
169
158
  expect(result).toBe("deny");
170
- expect(getToolPermissionFn).toHaveBeenCalledWith("bash", "Explore");
171
- });
172
-
173
- it("getToolPermission works without agentName", () => {
174
- const getToolPermissionFn = vi.fn(
175
- (_t: string, _a?: string): "ask" => "ask",
176
- );
177
- const service: PermissionsService = {
178
- checkPermission: vi.fn(),
179
- getToolPermission(toolName, agentName) {
180
- return getToolPermissionFn(toolName, agentName);
181
- },
182
- registerToolInputFormatter: vi.fn(),
183
- registerToolAccessExtractor: vi.fn(),
184
- };
185
-
186
- publishPermissionsService(service);
187
- const result = getPermissionsService()!.getToolPermission("write");
188
-
189
- expect(result).toBe("ask");
190
- expect(getToolPermissionFn).toHaveBeenCalledWith("write", undefined);
191
- });
192
-
193
- it("checkPermission uses empty object for unknown surfaces", () => {
194
- const checkPermission = vi.fn().mockReturnValue(fakeResult);
195
-
196
- const service = makeService({
197
- checkPermission(surface, value, agentName) {
198
- const input = buildInputForSurface(surface, value);
199
- return checkPermission(surface, input, agentName, []);
200
- },
201
- });
202
-
203
- publishPermissionsService(service);
204
- getPermissionsService()!.checkPermission("read", "/tmp/file");
205
-
206
- expect(checkPermission).toHaveBeenCalledWith("read", {}, undefined, []);
159
+ expect(resolver.getToolPermission).toHaveBeenCalledWith("write", "Explore");
207
160
  });
208
161
  });
209
162