@gotgenes/pi-permission-system 16.2.1 → 17.1.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 (61) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +2 -1
  3. package/package.json +1 -1
  4. package/src/access-intent/access-intent.ts +8 -10
  5. package/src/access-intent/access-path.ts +39 -16
  6. package/src/access-intent/bash/bash-path-resolver.ts +531 -0
  7. package/src/access-intent/bash/program.ts +21 -14
  8. package/src/access-intent/bash/token-classification.ts +1 -1
  9. package/src/canonicalize-path.ts +11 -5
  10. package/src/forwarded-permissions/permission-forwarder.ts +11 -1
  11. package/src/forwarding-manager.ts +7 -1
  12. package/src/handlers/before-agent-start.ts +1 -0
  13. package/src/handlers/gates/bash-path-extractor.ts +7 -5
  14. package/src/handlers/gates/bash-path.ts +13 -13
  15. package/src/handlers/gates/external-directory.ts +14 -16
  16. package/src/handlers/gates/path.ts +12 -7
  17. package/src/handlers/gates/skill-read.ts +7 -1
  18. package/src/handlers/gates/tool-call-gate-pipeline.ts +21 -4
  19. package/src/handlers/gates/tool.ts +4 -2
  20. package/src/index.ts +12 -1
  21. package/src/input-normalizer.ts +9 -4
  22. package/src/path-normalizer.ts +70 -0
  23. package/src/path-utils.ts +39 -28
  24. package/src/permission-manager.ts +21 -7
  25. package/src/permission-session.ts +35 -2
  26. package/src/prompting-gateway.ts +3 -0
  27. package/src/rule.ts +8 -6
  28. package/src/skill-prompt-sanitizer.ts +15 -4
  29. package/src/subagent-context.ts +17 -9
  30. package/test/access-intent/access-path.test.ts +124 -15
  31. package/test/access-intent/bash/program.test.ts +178 -80
  32. package/test/bash-external-directory.test.ts +15 -1
  33. package/test/canonicalize-path.test.ts +34 -8
  34. package/test/forwarding-manager.test.ts +7 -1
  35. package/test/handlers/external-directory-symlink-acceptance.test.ts +26 -4
  36. package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -1
  37. package/test/handlers/gates/bash-external-directory.test.ts +5 -2
  38. package/test/handlers/gates/bash-path.test.ts +14 -9
  39. package/test/handlers/gates/external-directory-policy.test.ts +28 -18
  40. package/test/handlers/gates/external-directory.test.ts +9 -1
  41. package/test/handlers/gates/path.test.ts +90 -20
  42. package/test/handlers/gates/skill-read.test.ts +22 -13
  43. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -1
  44. package/test/handlers/gates/tool.test.ts +13 -1
  45. package/test/helpers/gate-fixtures.ts +11 -2
  46. package/test/helpers/session-fixtures.ts +3 -0
  47. package/test/input-normalizer.test.ts +104 -39
  48. package/test/path-normalizer.test.ts +123 -0
  49. package/test/path-utils.test.ts +130 -51
  50. package/test/permission-forwarder.test.ts +1 -0
  51. package/test/permission-manager-unified.test.ts +40 -0
  52. package/test/permission-resolver.test.ts +14 -32
  53. package/test/permission-session.test.ts +41 -0
  54. package/test/pi-infrastructure-read.test.ts +27 -4
  55. package/test/prompting-gateway.test.ts +1 -0
  56. package/test/rule.test.ts +88 -42
  57. package/test/session-rules.test.ts +21 -4
  58. package/test/skill-prompt-sanitizer.test.ts +69 -14
  59. package/test/subagent-context.test.ts +77 -31
  60. package/test/synthesize.test.ts +13 -11
  61. package/src/access-intent/bash/cwd-projection.ts +0 -498
@@ -21,12 +21,14 @@ describe("canonicalizePath", () => {
21
21
  });
22
22
 
23
23
  test("returns empty string for empty input", () => {
24
- expect(canonicalizePath("")).toBe("");
24
+ expect(canonicalizePath("", "linux")).toBe("");
25
25
  });
26
26
 
27
27
  test("returns realpathSync result when path exists", () => {
28
28
  realpathSync.mockReturnValueOnce("/real/projects/app");
29
- expect(canonicalizePath("/projects/link")).toBe("/real/projects/app");
29
+ expect(canonicalizePath("/projects/link", "linux")).toBe(
30
+ "/real/projects/app",
31
+ );
30
32
  });
31
33
 
32
34
  test("re-appends a non-existent leaf to the canonical parent", () => {
@@ -35,7 +37,7 @@ describe("canonicalizePath", () => {
35
37
  throw enoent("/projects/app/new-file.ts");
36
38
  })
37
39
  .mockReturnValueOnce("/canonical/app");
38
- expect(canonicalizePath("/projects/app/new-file.ts")).toBe(
40
+ expect(canonicalizePath("/projects/app/new-file.ts", "linux")).toBe(
39
41
  "/canonical/app/new-file.ts",
40
42
  );
41
43
  });
@@ -52,7 +54,7 @@ describe("canonicalizePath", () => {
52
54
  throw enoent("/projects/app");
53
55
  })
54
56
  .mockReturnValueOnce("/canonical/projects");
55
- expect(canonicalizePath("/projects/app/src/new-file.ts")).toBe(
57
+ expect(canonicalizePath("/projects/app/src/new-file.ts", "linux")).toBe(
56
58
  "/canonical/projects/app/src/new-file.ts",
57
59
  );
58
60
  });
@@ -61,7 +63,7 @@ describe("canonicalizePath", () => {
61
63
  realpathSync.mockImplementation(() => {
62
64
  throw enoent("");
63
65
  });
64
- expect(canonicalizePath("/nonexistent/path/file.ts")).toBe(
66
+ expect(canonicalizePath("/nonexistent/path/file.ts", "linux")).toBe(
65
67
  "/nonexistent/path/file.ts",
66
68
  );
67
69
  });
@@ -70,14 +72,18 @@ describe("canonicalizePath", () => {
70
72
  realpathSync.mockImplementation(() => {
71
73
  throw Object.assign(new Error("ELOOP"), { code: "ELOOP" });
72
74
  });
73
- expect(canonicalizePath("/some/looping/path")).toBe("/some/looping/path");
75
+ expect(canonicalizePath("/some/looping/path", "linux")).toBe(
76
+ "/some/looping/path",
77
+ );
74
78
  });
75
79
 
76
80
  test("returns input unchanged on EACCES (permission denied)", () => {
77
81
  realpathSync.mockImplementation(() => {
78
82
  throw Object.assign(new Error("EACCES"), { code: "EACCES" });
79
83
  });
80
- expect(canonicalizePath("/restricted/path")).toBe("/restricted/path");
84
+ expect(canonicalizePath("/restricted/path", "linux")).toBe(
85
+ "/restricted/path",
86
+ );
81
87
  });
82
88
 
83
89
  test("handles ENOTDIR by walking up (like ENOENT)", () => {
@@ -86,8 +92,28 @@ describe("canonicalizePath", () => {
86
92
  throw Object.assign(new Error("ENOTDIR"), { code: "ENOTDIR" });
87
93
  })
88
94
  .mockReturnValueOnce("/real/parent");
89
- expect(canonicalizePath("/real/parent/not-a-dir")).toBe(
95
+ expect(canonicalizePath("/real/parent/not-a-dir", "linux")).toBe(
90
96
  "/real/parent/not-a-dir",
91
97
  );
92
98
  });
99
+
100
+ // ── injected platform flavor (win32-separator splitting) ──────────────
101
+
102
+ test("win32: splits and rejoins on the backslash separator", () => {
103
+ realpathSync
104
+ .mockImplementationOnce(() => {
105
+ throw enoent("C:\\projects\\link\\file.ts");
106
+ })
107
+ .mockReturnValueOnce("C:\\real\\app");
108
+ expect(canonicalizePath("C:\\projects\\link\\file.ts", "win32")).toBe(
109
+ "C:\\real\\app\\file.ts",
110
+ );
111
+ });
112
+
113
+ test("win32: resolves an existing path via realpathSync", () => {
114
+ realpathSync.mockReturnValueOnce("C:\\real\\app");
115
+ expect(canonicalizePath("C:\\projects\\link", "win32")).toBe(
116
+ "C:\\real\\app",
117
+ );
118
+ });
93
119
  });
@@ -30,7 +30,11 @@ function makeForwarder() {
30
30
  }
31
31
 
32
32
  function makeManager() {
33
- return new ForwardingManager("/agent/subagent-sessions", makeForwarder());
33
+ return new ForwardingManager(
34
+ "/agent/subagent-sessions",
35
+ makeForwarder(),
36
+ "linux",
37
+ );
34
38
  }
35
39
 
36
40
  // ── Tests ─────────────────────────────────────────────────────────────────
@@ -180,6 +184,7 @@ describe("ForwardingManager", () => {
180
184
  const manager = new ForwardingManager(
181
185
  "/custom/subagent-dir",
182
186
  makeForwarder(),
187
+ "linux",
183
188
  );
184
189
  const ctx = makeCtx();
185
190
  manager.start(ctx);
@@ -187,6 +192,7 @@ describe("ForwardingManager", () => {
187
192
  expect(mockIsSubagentExecutionContext).toHaveBeenCalledWith(
188
193
  ctx,
189
194
  "/custom/subagent-dir",
195
+ "linux",
190
196
  undefined,
191
197
  );
192
198
  });
@@ -22,6 +22,7 @@ import {
22
22
  } from "#src/handlers/gates/descriptor";
23
23
  import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
24
24
  import type { ToolCallContext } from "#src/handlers/gates/types";
25
+ import { PathNormalizer } from "#src/path-normalizer";
25
26
  import { PermissionResolver } from "#src/permission-resolver";
26
27
  import { SessionRules } from "#src/session-rules";
27
28
  import type { ScopeConfig } from "#src/types";
@@ -83,7 +84,13 @@ describe("external_directory symlink acceptance (#418)", () => {
83
84
  },
84
85
  });
85
86
  try {
86
- const result = describeExternalDirectoryGate(readTcc(), [], resolver);
87
+ const result = describeExternalDirectoryGate(
88
+ readTcc(),
89
+ [],
90
+ resolver,
91
+ new PathNormalizer(process.platform, cwd),
92
+ "linux",
93
+ );
87
94
  expect(isGateDescriptor(result)).toBe(true);
88
95
  expect((result as GateDescriptor).preCheck?.state).toBe("allow");
89
96
  } finally {
@@ -101,7 +108,13 @@ describe("external_directory symlink acceptance (#418)", () => {
101
108
  },
102
109
  });
103
110
  try {
104
- const result = describeExternalDirectoryGate(readTcc(), [], resolver);
111
+ const result = describeExternalDirectoryGate(
112
+ readTcc(),
113
+ [],
114
+ resolver,
115
+ new PathNormalizer(process.platform, cwd),
116
+ "linux",
117
+ );
105
118
  expect(isGateDescriptor(result)).toBe(true);
106
119
  expect((result as GateDescriptor).preCheck?.state).toBe("allow");
107
120
  } finally {
@@ -114,7 +127,13 @@ describe("external_directory symlink acceptance (#418)", () => {
114
127
  permission: { external_directory: { "*": "ask" } },
115
128
  });
116
129
  try {
117
- const result = describeExternalDirectoryGate(readTcc(), [], resolver);
130
+ const result = describeExternalDirectoryGate(
131
+ readTcc(),
132
+ [],
133
+ resolver,
134
+ new PathNormalizer(process.platform, cwd),
135
+ "linux",
136
+ );
118
137
  expect(isGateDescriptor(result)).toBe(true);
119
138
  expect((result as GateDescriptor).preCheck?.state).toBe("ask");
120
139
  } finally {
@@ -137,7 +156,10 @@ describe("external_directory symlink acceptance (#418)", () => {
137
156
  toolCallId: "tc-2",
138
157
  cwd,
139
158
  };
140
- const program = await BashProgram.parse(command, cwd);
159
+ const program = await BashProgram.parse(
160
+ command,
161
+ new PathNormalizer(process.platform, cwd),
162
+ );
141
163
  const result = describeBashExternalDirectoryGate(tcc, program, resolver);
142
164
  // All external paths are covered by the allow → bypass, no prompt.
143
165
  expect(isGateBypass(result)).toBe(true);
@@ -12,6 +12,7 @@
12
12
  import { describe, expect, it } from "vitest";
13
13
  import { BashProgram } from "#src/access-intent/bash/program";
14
14
  import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
15
+ import { PathNormalizer } from "#src/path-normalizer";
15
16
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
16
17
  import type { PermissionState } from "#src/types";
17
18
 
@@ -48,7 +49,10 @@ async function decide(
48
49
  command: string,
49
50
  resolver: ScopedPermissionResolver,
50
51
  ): Promise<PermissionState> {
51
- const program = await BashProgram.parse(command, "/cwd");
52
+ const program = await BashProgram.parse(
53
+ command,
54
+ new PathNormalizer(process.platform, "/cwd"),
55
+ );
52
56
  return resolveBashCommandCheck(
53
57
  command,
54
58
  program.commands(),
@@ -9,6 +9,7 @@ import type {
9
9
  } from "#src/handlers/gates/descriptor";
10
10
  import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
11
11
  import type { ToolCallContext } from "#src/handlers/gates/types";
12
+ import { PathNormalizer } from "#src/path-normalizer";
12
13
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
13
14
  import type { PermissionCheckResult } from "#src/types";
14
15
  import { getNonEmptyString, toRecord } from "#src/value-guards";
@@ -44,7 +45,6 @@ function makeCheckResult(
44
45
  /** Extract the policy match values a resolve(intent) call carries. */
45
46
  function intentValues(intent: AccessIntent): readonly string[] {
46
47
  if (intent.kind === "access-path") return intent.path.matchValues();
47
- if (intent.kind === "path-values") return intent.values;
48
48
  return [];
49
49
  }
50
50
 
@@ -60,7 +60,10 @@ async function describeGate(
60
60
  const command = getNonEmptyString(toRecord(tcc.input).command);
61
61
  const bashProgram =
62
62
  tcc.toolName === "bash" && command
63
- ? await BashProgram.parse(command, tcc.cwd)
63
+ ? await BashProgram.parse(
64
+ command,
65
+ new PathNormalizer(process.platform, tcc.cwd),
66
+ )
64
67
  : null;
65
68
  return describeBashExternalDirectoryGate(tcc, bashProgram, resolver);
66
69
  }
@@ -9,6 +9,7 @@ vi.mock("node:os", () => {
9
9
  };
10
10
  });
11
11
 
12
+ import { AccessPath } from "#src/access-intent/access-path";
12
13
  import { BashProgram } from "#src/access-intent/bash/program";
13
14
  import { describeBashPathGate } from "#src/handlers/gates/bash-path";
14
15
  import type {
@@ -18,6 +19,7 @@ import type {
18
19
  } from "#src/handlers/gates/descriptor";
19
20
  import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
20
21
  import type { ToolCallContext } from "#src/handlers/gates/types";
22
+ import { PathNormalizer } from "#src/path-normalizer";
21
23
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
22
24
  import { getNonEmptyString, toRecord } from "#src/value-guards";
23
25
 
@@ -44,7 +46,10 @@ async function describeGate(
44
46
  const command = getNonEmptyString(toRecord(tcc.input).command);
45
47
  const bashProgram =
46
48
  tcc.toolName === "bash" && command
47
- ? await BashProgram.parse(command, tcc.cwd)
49
+ ? await BashProgram.parse(
50
+ command,
51
+ new PathNormalizer(process.platform, tcc.cwd),
52
+ )
48
53
  : null;
49
54
  return describeBashPathGate(tcc, bashProgram, resolver);
50
55
  }
@@ -229,13 +234,13 @@ describe("describeBashPathGate", () => {
229
234
  )) as GateDescriptor;
230
235
 
231
236
  expect(resolver.resolve).toHaveBeenCalledWith({
232
- kind: "path-values",
237
+ kind: "access-path",
233
238
  surface: "path",
234
- values: [
235
- "/test/project/nested/src/file.txt",
236
- "nested/src/file.txt",
237
- "src/file.txt",
238
- ],
239
+ path: AccessPath.forPath("src/file.txt", {
240
+ cwd: "/test/project",
241
+ resolveBase: "/test/project/nested",
242
+ platform: "linux",
243
+ }),
239
244
  agentName: undefined,
240
245
  });
241
246
  // The raw token drives the prompt, denial context, and session approval.
@@ -256,9 +261,9 @@ describe("describeBashPathGate", () => {
256
261
  );
257
262
 
258
263
  expect(resolver.resolve).toHaveBeenCalledWith({
259
- kind: "path-values",
264
+ kind: "access-path",
260
265
  surface: "path",
261
- values: ["src/foo.ts"],
266
+ path: AccessPath.forLiteral("src/foo.ts"),
262
267
  agentName: undefined,
263
268
  });
264
269
  });
@@ -25,7 +25,10 @@ function makeCheckResult(
25
25
 
26
26
  describe("resolveExternalDirectoryPolicy", () => {
27
27
  it("resolves the path's match aliases on the external_directory surface (#418)", () => {
28
- const path = AccessPath.forExternalDirectory("/outside/a.ts", cwd);
28
+ const path = AccessPath.forPath("/outside/a.ts", {
29
+ cwd,
30
+ platform: "linux",
31
+ });
29
32
  const resolver = makeResolver(makeCheckResult("ask"));
30
33
 
31
34
  const result = resolveExternalDirectoryPolicy(path, resolver, undefined);
@@ -40,7 +43,10 @@ describe("resolveExternalDirectoryPolicy", () => {
40
43
  });
41
44
 
42
45
  it("threads the agent name through to the resolver", () => {
43
- const path = AccessPath.forExternalDirectory("/outside/a.ts", cwd);
46
+ const path = AccessPath.forPath("/outside/a.ts", {
47
+ cwd,
48
+ platform: "linux",
49
+ });
44
50
  const resolver = makeResolver(makeCheckResult("allow"));
45
51
 
46
52
  resolveExternalDirectoryPolicy(path, resolver, "reviewer");
@@ -57,8 +63,8 @@ describe("resolveExternalDirectoryPolicy", () => {
57
63
  describe("selectUncoveredExternalPaths", () => {
58
64
  it("returns no uncovered paths when every path resolves to allow", () => {
59
65
  const paths = [
60
- AccessPath.forExternalDirectory("/outside/a.ts", cwd),
61
- AccessPath.forExternalDirectory("/outside/b.ts", cwd),
66
+ AccessPath.forPath("/outside/a.ts", { cwd, platform: "linux" }),
67
+ AccessPath.forPath("/outside/b.ts", { cwd, platform: "linux" }),
62
68
  ];
63
69
  const resolver = makeResolver(makeCheckResult("allow"));
64
70
 
@@ -73,16 +79,18 @@ describe("selectUncoveredExternalPaths", () => {
73
79
  });
74
80
 
75
81
  it("collects only paths whose resolved state is not allow", () => {
76
- const allowed = AccessPath.forExternalDirectory("/outside/ok.ts", cwd);
77
- const asked = AccessPath.forExternalDirectory("/outside/ask.ts", cwd);
82
+ const allowed = AccessPath.forPath("/outside/ok.ts", {
83
+ cwd,
84
+ platform: "linux",
85
+ });
86
+ const asked = AccessPath.forPath("/outside/ask.ts", {
87
+ cwd,
88
+ platform: "linux",
89
+ });
78
90
  const resolver = makeResolver();
79
91
  resolver.resolve.mockImplementation((intent) => {
80
92
  const values =
81
- intent.kind === "access-path"
82
- ? intent.path.matchValues()
83
- : intent.kind === "path-values"
84
- ? intent.values
85
- : [];
93
+ intent.kind === "access-path" ? intent.path.matchValues() : [];
86
94
  return values.includes("/outside/ok.ts")
87
95
  ? makeCheckResult("allow")
88
96
  : makeCheckResult("ask");
@@ -98,16 +106,18 @@ describe("selectUncoveredExternalPaths", () => {
98
106
  });
99
107
 
100
108
  it("returns the most restrictive uncovered check as worstCheck (deny > ask)", () => {
101
- const asked = AccessPath.forExternalDirectory("/outside/ask.ts", cwd);
102
- const denied = AccessPath.forExternalDirectory("/outside/deny.ts", cwd);
109
+ const asked = AccessPath.forPath("/outside/ask.ts", {
110
+ cwd,
111
+ platform: "linux",
112
+ });
113
+ const denied = AccessPath.forPath("/outside/deny.ts", {
114
+ cwd,
115
+ platform: "linux",
116
+ });
103
117
  const resolver = makeResolver();
104
118
  resolver.resolve.mockImplementation((intent) => {
105
119
  const values =
106
- intent.kind === "access-path"
107
- ? intent.path.matchValues()
108
- : intent.kind === "path-values"
109
- ? intent.values
110
- : [];
120
+ intent.kind === "access-path" ? intent.path.matchValues() : [];
111
121
  return values.includes("/outside/deny.ts")
112
122
  ? makeCheckResult("deny")
113
123
  : makeCheckResult("ask");
@@ -7,6 +7,7 @@ import type {
7
7
  import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
8
8
  import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
9
9
  import type { ToolCallContext } from "#src/handlers/gates/types";
10
+ import { PathNormalizer } from "#src/path-normalizer";
10
11
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
11
12
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
12
13
  import { makeResolver } from "#test/helpers/gate-fixtures";
@@ -37,7 +38,14 @@ function gateUnderTest(
37
38
  makeCheckResult({ state: "ask", toolName: "external_directory" }),
38
39
  ),
39
40
  ) {
40
- return describeExternalDirectoryGate(tcc, infraDirs, resolver, extractors);
41
+ return describeExternalDirectoryGate(
42
+ tcc,
43
+ infraDirs,
44
+ resolver,
45
+ new PathNormalizer(process.platform, tcc.cwd),
46
+ "linux",
47
+ extractors,
48
+ );
41
49
  }
42
50
 
43
51
  // ── tests ────────────────────��────────────────────────────────────��────────