@gotgenes/pi-permission-system 17.1.0 → 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -0
  3. package/package.json +1 -1
  4. package/src/access-intent/access-intent.ts +6 -3
  5. package/src/access-intent/bash/bash-path-resolver.ts +16 -12
  6. package/src/access-intent/bash/program.ts +0 -4
  7. package/src/access-intent/bash/token-classification.ts +23 -0
  8. package/src/handlers/before-agent-start.ts +1 -2
  9. package/src/handlers/gates/external-directory.ts +2 -12
  10. package/src/handlers/gates/skill-read.ts +4 -8
  11. package/src/handlers/gates/tool-call-gate-pipeline.ts +62 -24
  12. package/src/handlers/gates/tool.ts +10 -14
  13. package/src/index.ts +8 -6
  14. package/src/input-normalizer.ts +54 -56
  15. package/src/path-normalizer.ts +30 -0
  16. package/src/permission-event-rpc.ts +21 -15
  17. package/src/permission-manager.ts +3 -6
  18. package/src/permission-session.ts +0 -5
  19. package/src/permissions-service.ts +33 -12
  20. package/src/skill-prompt-sanitizer.ts +8 -21
  21. package/test/access-intent/bash/program.test.ts +1 -1
  22. package/test/access-intent/bash/token-classification.test.ts +75 -0
  23. package/test/bash-external-directory.test.ts +38 -0
  24. package/test/composition-root.test.ts +22 -0
  25. package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -3
  26. package/test/handlers/gates/external-directory.test.ts +0 -1
  27. package/test/handlers/gates/skill-read.test.ts +16 -12
  28. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +73 -0
  29. package/test/handlers/gates/tool.test.ts +25 -16
  30. package/test/helpers/gate-fixtures.ts +0 -3
  31. package/test/input-normalizer.test.ts +163 -270
  32. package/test/path-normalizer.test.ts +43 -0
  33. package/test/path-utils.test.ts +1 -1
  34. package/test/permission-event-rpc.test.ts +80 -65
  35. package/test/permission-manager-unified.test.ts +134 -145
  36. package/test/permissions-service.test.ts +84 -72
  37. package/test/service.test.ts +56 -103
  38. package/test/skill-prompt-sanitizer.test.ts +31 -65
@@ -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
 
@@ -1,5 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { afterEach, describe, expect, test, vi } from "vitest";
3
+ import { PathNormalizer } from "#src/path-normalizer";
3
4
  import type { ScopedPermissionManager } from "#src/permission-manager";
4
5
  import {
5
6
  findSkillPathMatch,
@@ -30,6 +31,11 @@ afterEach(() => {
30
31
 
31
32
  const CWD = "/projects/my-app";
32
33
 
34
+ // `findSkillPathMatch` only uses the normalizer's platform (it compares two
35
+ // already-absolute paths via `isWithinDirectory`), so this CWD-baked instance
36
+ // serves every call regardless of the entries' cwd.
37
+ const normalizer = new PathNormalizer("linux", CWD);
38
+
33
39
  function makeManager(
34
40
  defaultState: "allow" | "deny" | "ask" = "allow",
35
41
  overrides: Record<string, "allow" | "deny" | "ask"> = {},
@@ -72,13 +78,7 @@ describe("resolveSkillPromptEntries", () => {
72
78
  test("returns unchanged prompt and empty entries when no skills section present", () => {
73
79
  const input = "You are a helpful assistant.";
74
80
  const manager = makeManager("allow");
75
- const result = resolveSkillPromptEntries(
76
- input,
77
- manager,
78
- null,
79
- CWD,
80
- "linux",
81
- );
81
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
82
82
  expect(result.prompt).toBe(input);
83
83
  expect(result.entries).toEqual([]);
84
84
  expect(manager.checkPermission).not.toHaveBeenCalled();
@@ -87,13 +87,7 @@ describe("resolveSkillPromptEntries", () => {
87
87
  test("keeps all skills when all are allowed", () => {
88
88
  const input = availableSkillsSection("librarian", "ask-user");
89
89
  const manager = makeManager("allow");
90
- const result = resolveSkillPromptEntries(
91
- input,
92
- manager,
93
- null,
94
- CWD,
95
- "linux",
96
- );
90
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
97
91
  expect(result.prompt).toContain("librarian");
98
92
  expect(result.prompt).toContain("ask-user");
99
93
  expect(result.entries).toHaveLength(2);
@@ -102,13 +96,7 @@ describe("resolveSkillPromptEntries", () => {
102
96
  test("removes denied skill from section", () => {
103
97
  const input = availableSkillsSection("librarian", "dangerous");
104
98
  const manager = makeManager("allow", { dangerous: "deny" });
105
- const result = resolveSkillPromptEntries(
106
- input,
107
- manager,
108
- null,
109
- CWD,
110
- "linux",
111
- );
99
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
112
100
  expect(result.prompt).toContain("librarian");
113
101
  expect(result.prompt).not.toContain("dangerous");
114
102
  // denied skill is excluded from returned entries
@@ -118,13 +106,7 @@ describe("resolveSkillPromptEntries", () => {
118
106
  test("removes entire section when all skills are denied", () => {
119
107
  const input = `Intro\n${availableSkillsSection("dangerous")}\nOutro`;
120
108
  const manager = makeManager("deny");
121
- const result = resolveSkillPromptEntries(
122
- input,
123
- manager,
124
- null,
125
- CWD,
126
- "linux",
127
- );
109
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
128
110
  expect(result.prompt).not.toContain("<available_skills>");
129
111
  expect(result.prompt).toContain("Intro");
130
112
  expect(result.prompt).toContain("Outro");
@@ -134,13 +116,7 @@ describe("resolveSkillPromptEntries", () => {
134
116
  test("keeps ask-state skills in section and entries", () => {
135
117
  const input = availableSkillsSection("librarian");
136
118
  const manager = makeManager("ask");
137
- const result = resolveSkillPromptEntries(
138
- input,
139
- manager,
140
- null,
141
- CWD,
142
- "linux",
143
- );
119
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
144
120
  expect(result.prompt).toContain("librarian");
145
121
  expect(result.entries).toHaveLength(1);
146
122
  expect(result.entries[0].state).toBe("ask");
@@ -149,7 +125,7 @@ describe("resolveSkillPromptEntries", () => {
149
125
  test("delegates permission check to permissionManager for each skill", () => {
150
126
  const input = availableSkillsSection("alpha", "beta");
151
127
  const manager = makeManager("allow");
152
- resolveSkillPromptEntries(input, manager, null, CWD, "linux");
128
+ resolveSkillPromptEntries(input, manager, null, normalizer);
153
129
  expect(manager.checkPermission).toHaveBeenCalledWith(
154
130
  "skill",
155
131
  { name: "alpha" },
@@ -165,7 +141,7 @@ describe("resolveSkillPromptEntries", () => {
165
141
  test("passes agentName to permissionManager", () => {
166
142
  const input = availableSkillsSection("librarian");
167
143
  const manager = makeManager("allow");
168
- resolveSkillPromptEntries(input, manager, "my-agent", CWD, "linux");
144
+ resolveSkillPromptEntries(input, manager, "my-agent", normalizer);
169
145
  expect(manager.checkPermission).toHaveBeenCalledWith(
170
146
  "skill",
171
147
  { name: "librarian" },
@@ -180,7 +156,7 @@ describe("resolveSkillPromptEntries", () => {
180
156
  availableSkillsSection("librarian"),
181
157
  ].join("\n");
182
158
  const manager = makeManager("allow");
183
- resolveSkillPromptEntries(input, manager, null, CWD, "linux");
159
+ resolveSkillPromptEntries(input, manager, null, normalizer);
184
160
  // Should only be called once despite appearing twice.
185
161
  expect(manager.checkPermission).toHaveBeenCalledTimes(1);
186
162
  });
@@ -189,13 +165,7 @@ describe("resolveSkillPromptEntries", () => {
189
165
  const location = "/skills/librarian/SKILL.md";
190
166
  const input = availableSkillsSection("librarian");
191
167
  const manager = makeManager("allow");
192
- const result = resolveSkillPromptEntries(
193
- input,
194
- manager,
195
- null,
196
- CWD,
197
- "linux",
198
- );
168
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
199
169
  expect(result.entries[0].normalizedLocation).toBe(location);
200
170
  expect(result.entries[0].normalizedBaseDir).toBe("/skills/librarian");
201
171
  });
@@ -205,13 +175,7 @@ describe("resolveSkillPromptEntries", () => {
205
175
  const section2 = availableSkillsSection("beta");
206
176
  const input = `${section1}\n${section2}`;
207
177
  const manager = makeManager("allow", { beta: "deny" });
208
- const result = resolveSkillPromptEntries(
209
- input,
210
- manager,
211
- null,
212
- CWD,
213
- "linux",
214
- );
178
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
215
179
  expect(result.entries.map((e) => e.name)).toContain("alpha");
216
180
  expect(result.entries.map((e) => e.name)).not.toContain("beta");
217
181
  });
@@ -240,12 +204,12 @@ describe("findSkillPathMatch", () => {
240
204
  ];
241
205
 
242
206
  test("returns null for empty normalized path", () => {
243
- expect(findSkillPathMatch("", entries, "linux")).toBeNull();
207
+ expect(findSkillPathMatch("", entries, normalizer)).toBeNull();
244
208
  });
245
209
 
246
210
  test("returns null for empty entries array", () => {
247
211
  expect(
248
- findSkillPathMatch("/skills/librarian/SKILL.md", [], "linux"),
212
+ findSkillPathMatch("/skills/librarian/SKILL.md", [], normalizer),
249
213
  ).toBeNull();
250
214
  });
251
215
 
@@ -253,7 +217,7 @@ describe("findSkillPathMatch", () => {
253
217
  const match = findSkillPathMatch(
254
218
  "/skills/librarian/SKILL.md",
255
219
  entries,
256
- "linux",
220
+ normalizer,
257
221
  );
258
222
  expect(match?.name).toBe("librarian");
259
223
  });
@@ -262,13 +226,17 @@ describe("findSkillPathMatch", () => {
262
226
  const match = findSkillPathMatch(
263
227
  "/skills/librarian/extra/helper.md",
264
228
  entries,
265
- "linux",
229
+ normalizer,
266
230
  );
267
231
  expect(match?.name).toBe("librarian");
268
232
  });
269
233
 
270
234
  test("returns null for path not within any skill directory", () => {
271
- const match = findSkillPathMatch("/other/path/file.md", entries, "linux");
235
+ const match = findSkillPathMatch(
236
+ "/other/path/file.md",
237
+ entries,
238
+ normalizer,
239
+ );
272
240
  expect(match).toBeNull();
273
241
  });
274
242
 
@@ -277,7 +245,7 @@ describe("findSkillPathMatch", () => {
277
245
  const match = findSkillPathMatch(
278
246
  "/skills/librarian-extra/SKILL.md",
279
247
  entries,
280
- "linux",
248
+ normalizer,
281
249
  );
282
250
  expect(match).toBeNull();
283
251
  });
@@ -304,7 +272,7 @@ describe("findSkillPathMatch", () => {
304
272
  const match = findSkillPathMatch(
305
273
  "/skills/parent/child/helper.md",
306
274
  nestedEntries,
307
- "linux",
275
+ normalizer,
308
276
  );
309
277
  expect(match?.name).toBe("child");
310
278
  });
@@ -380,8 +348,7 @@ test("REGRESSION: resolveSkillPromptEntries sanitizes every available_skills blo
380
348
  prompt,
381
349
  asChecker(manager),
382
350
  null,
383
- "/cwd",
384
- "linux",
351
+ new PathNormalizer("linux", "/cwd"),
385
352
  );
386
353
 
387
354
  expect(result.prompt).not.toContain("denied-skill");
@@ -428,20 +395,19 @@ test("REGRESSION: resolveSkillPromptEntries keeps only visible skills available
428
395
  prompt,
429
396
  asChecker(manager),
430
397
  null,
431
- "/cwd",
432
- "linux",
398
+ new PathNormalizer("linux", "/cwd"),
433
399
  );
434
400
  const visiblePath = resolve("/cwd", "./skills/visible/file.ts");
435
401
  const blockedPath = resolve("/cwd", "./skills/blocked/file.ts");
436
402
  const matchedVisibleSkill = findSkillPathMatch(
437
403
  process.platform === "win32" ? visiblePath.toLowerCase() : visiblePath,
438
404
  result.entries,
439
- "linux",
405
+ normalizer,
440
406
  );
441
407
  const matchedBlockedSkill = findSkillPathMatch(
442
408
  process.platform === "win32" ? blockedPath.toLowerCase() : blockedPath,
443
409
  result.entries,
444
- "linux",
410
+ normalizer,
445
411
  );
446
412
 
447
413
  expect(matchedVisibleSkill?.name).toBe("visible-skill");