@gotgenes/pi-permission-system 8.1.0 → 8.2.1

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 +15 -0
  2. package/package.json +1 -1
  3. package/src/config-loader.ts +53 -46
  4. package/src/handlers/gates/bash-external-directory.ts +2 -4
  5. package/src/handlers/gates/bash-path-extractor.ts +135 -169
  6. package/src/handlers/gates/bash-path.ts +2 -4
  7. package/src/handlers/gates/bash-token-classification.ts +105 -0
  8. package/src/handlers/gates/descriptor.ts +6 -6
  9. package/src/handlers/gates/external-directory.ts +2 -4
  10. package/src/handlers/gates/helpers.ts +30 -1
  11. package/src/handlers/gates/path.ts +2 -4
  12. package/src/handlers/gates/runner.ts +29 -56
  13. package/src/handlers/gates/tool.ts +5 -4
  14. package/src/handlers/permission-gate-handler.ts +4 -3
  15. package/src/permission-manager.ts +6 -49
  16. package/src/permission-session.ts +3 -2
  17. package/src/scope-merge.ts +72 -0
  18. package/src/session-approval.ts +43 -0
  19. package/src/session-rules.ts +13 -0
  20. package/test/config-loader.test.ts +82 -0
  21. package/test/handlers/before-agent-start.test.ts +2 -20
  22. package/test/handlers/external-directory-integration.test.ts +44 -82
  23. package/test/handlers/external-directory-session-dedup.test.ts +17 -41
  24. package/test/handlers/gates/bash-external-directory.test.ts +11 -9
  25. package/test/handlers/gates/bash-path.test.ts +5 -26
  26. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  27. package/test/handlers/gates/external-directory.test.ts +2 -5
  28. package/test/handlers/gates/helpers.test.ts +81 -0
  29. package/test/handlers/gates/path.test.ts +5 -14
  30. package/test/handlers/gates/runner.test.ts +95 -113
  31. package/test/handlers/gates/tool.test.ts +2 -2
  32. package/test/handlers/input-events.test.ts +42 -95
  33. package/test/handlers/input.test.ts +3 -71
  34. package/test/handlers/lifecycle.test.ts +3 -20
  35. package/test/handlers/tool-call-events.test.ts +30 -127
  36. package/test/handlers/tool-call.test.ts +21 -110
  37. package/test/helpers/gate-fixtures.ts +105 -0
  38. package/test/helpers/handler-fixtures.ts +141 -0
  39. package/test/helpers/manager-harness.ts +51 -0
  40. package/test/permission-session.test.ts +7 -22
  41. package/test/permission-system.test.ts +4 -40
  42. package/test/scope-merge.test.ts +116 -0
  43. package/test/session-approval.test.ts +75 -0
  44. package/test/session-rules.test.ts +49 -0
@@ -15,39 +15,18 @@ import type {
15
15
  GateDescriptor,
16
16
  } from "#src/handlers/gates/descriptor";
17
17
  import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
18
- import type { ToolCallContext } from "#src/handlers/gates/types";
19
18
  import type { Rule } from "#src/rule";
20
19
  import type { PermissionCheckResult } from "#src/types";
21
20
 
21
+ import {
22
+ makeGateCheckResult as makeCheckResult,
23
+ makeTcc,
24
+ } from "#test/helpers/gate-fixtures";
25
+
22
26
  afterEach(() => {
23
27
  vi.restoreAllMocks();
24
28
  });
25
29
 
26
- // ── helpers ────────────────────────────────────────────────────────────────
27
-
28
- function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
29
- return {
30
- toolName: "bash",
31
- agentName: null,
32
- input: { command: "cat .env" },
33
- toolCallId: "tc-1",
34
- cwd: "/test/project",
35
- ...overrides,
36
- };
37
- }
38
-
39
- function makeCheckResult(
40
- overrides: Partial<PermissionCheckResult> = {},
41
- ): PermissionCheckResult {
42
- return {
43
- toolName: "path",
44
- state: "allow",
45
- source: "special",
46
- origin: "global",
47
- ...overrides,
48
- };
49
- }
50
-
51
30
  type CheckPermissionFn = (
52
31
  surface: string,
53
32
  input: unknown,
@@ -0,0 +1,241 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import {
4
+ classifyTokenAsPathCandidate,
5
+ classifyTokenAsRuleCandidate,
6
+ } from "#src/handlers/gates/bash-token-classification";
7
+
8
+ // ── Shared rejection behaviour ─────────────────────────────────────────────
9
+ //
10
+ // Both classifiers delegate to the private `rejectNonPathToken` predicate for
11
+ // the seven shared rejection cases tested below. Testing via both exports
12
+ // pins that predicate through each caller.
13
+
14
+ describe("classifyTokenAsPathCandidate", () => {
15
+ describe("shared rejection: rejectNonPathToken", () => {
16
+ test("empty string → null", () => {
17
+ expect(classifyTokenAsPathCandidate("")).toBeNull();
18
+ });
19
+
20
+ test("flag (leading dash) → null", () => {
21
+ expect(classifyTokenAsPathCandidate("-r")).toBeNull();
22
+ expect(classifyTokenAsPathCandidate("--recursive")).toBeNull();
23
+ });
24
+
25
+ test("env assignment (= before any /) → null", () => {
26
+ expect(classifyTokenAsPathCandidate("FOO=/bar")).toBeNull();
27
+ expect(classifyTokenAsPathCandidate("HOME=/home/user")).toBeNull();
28
+ });
29
+
30
+ test("env-like token where = comes after / is NOT rejected as assignment", () => {
31
+ // /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues
32
+ // Starts with /, so path candidate accepts it.
33
+ expect(classifyTokenAsPathCandidate("/foo=bar")).toBe("/foo=bar");
34
+ });
35
+
36
+ test("URL → null", () => {
37
+ expect(classifyTokenAsPathCandidate("https://example.com")).toBeNull();
38
+ expect(classifyTokenAsPathCandidate("http://localhost:3000")).toBeNull();
39
+ expect(classifyTokenAsPathCandidate("file:///tmp/foo")).toBeNull();
40
+ expect(
41
+ classifyTokenAsPathCandidate("git+ssh://github.com/a/b"),
42
+ ).toBeNull();
43
+ });
44
+
45
+ test("@scope/package → null", () => {
46
+ expect(classifyTokenAsPathCandidate("@foo/bar")).toBeNull();
47
+ expect(classifyTokenAsPathCandidate("@scope/pkg")).toBeNull();
48
+ });
49
+
50
+ test("@/ prefix is NOT rejected (it looks like an absolute-rooted scoped path)", () => {
51
+ // @/ passes the @ guard; then for path candidate it doesn't start with /
52
+ // or ~/, and doesn't contain .., so it returns null anyway from the
53
+ // acceptance gate — but the rejection is not due to the @ guard.
54
+ // This test documents that @/ is not rejected by the shared rejection.
55
+ // The path classifier then rejects it for not matching any acceptance shape.
56
+ expect(classifyTokenAsPathCandidate("@/foo/bar")).toBeNull();
57
+ });
58
+
59
+ test("bare-slash token → null", () => {
60
+ expect(classifyTokenAsPathCandidate("/")).toBeNull();
61
+ expect(classifyTokenAsPathCandidate("//")).toBeNull();
62
+ expect(classifyTokenAsPathCandidate("///")).toBeNull();
63
+ });
64
+
65
+ test("regex metacharacters → null", () => {
66
+ // REGEX_METACHAR_PATTERN: .*, .+, \|, \(, \), [...], ^/
67
+ expect(classifyTokenAsPathCandidate("foo.*")).toBeNull();
68
+ expect(classifyTokenAsPathCandidate("bar.+")).toBeNull();
69
+ expect(classifyTokenAsPathCandidate("a\\|b")).toBeNull();
70
+ expect(classifyTokenAsPathCandidate("\\(group\\)")).toBeNull();
71
+ expect(classifyTokenAsPathCandidate("[abc]")).toBeNull();
72
+ expect(classifyTokenAsPathCandidate("^/start")).toBeNull();
73
+ });
74
+ });
75
+
76
+ describe("path-candidate acceptance gate", () => {
77
+ test("absolute path (starts with /) → returned as-is", () => {
78
+ expect(classifyTokenAsPathCandidate("/etc/hosts")).toBe("/etc/hosts");
79
+ expect(classifyTokenAsPathCandidate("/tmp")).toBe("/tmp");
80
+ expect(classifyTokenAsPathCandidate("/home/user/file.txt")).toBe(
81
+ "/home/user/file.txt",
82
+ );
83
+ });
84
+
85
+ test("home-relative path (starts with ~/) → returned as-is", () => {
86
+ expect(classifyTokenAsPathCandidate("~/Documents")).toBe("~/Documents");
87
+ expect(classifyTokenAsPathCandidate("~/.ssh/config")).toBe(
88
+ "~/.ssh/config",
89
+ );
90
+ });
91
+
92
+ test("parent-traversal (contains ..) → returned as-is", () => {
93
+ expect(classifyTokenAsPathCandidate("../../etc/passwd")).toBe(
94
+ "../../etc/passwd",
95
+ );
96
+ expect(classifyTokenAsPathCandidate("../foo")).toBe("../foo");
97
+ expect(classifyTokenAsPathCandidate("..")).toBe("..");
98
+ });
99
+
100
+ test("plain word with no path shape → null", () => {
101
+ expect(classifyTokenAsPathCandidate("hello")).toBeNull();
102
+ expect(classifyTokenAsPathCandidate("myfile.txt")).toBeNull();
103
+ });
104
+
105
+ test("dot-file (starts with .) → null (strict path gate)", () => {
106
+ // Path candidate does NOT accept dot-files; rule candidate does.
107
+ expect(classifyTokenAsPathCandidate(".env")).toBeNull();
108
+ expect(classifyTokenAsPathCandidate(".gitignore")).toBeNull();
109
+ });
110
+
111
+ test("relative path with / but no leading / or ~/ → null (strict path gate)", () => {
112
+ // Path candidate does NOT accept bare relative paths; rule candidate does.
113
+ expect(classifyTokenAsPathCandidate("src/foo.ts")).toBeNull();
114
+ expect(classifyTokenAsPathCandidate("./build")).toBeNull();
115
+ });
116
+ });
117
+ });
118
+
119
+ describe("classifyTokenAsRuleCandidate", () => {
120
+ describe("shared rejection: rejectNonPathToken", () => {
121
+ test("empty string → null", () => {
122
+ expect(classifyTokenAsRuleCandidate("")).toBeNull();
123
+ });
124
+
125
+ test("flag (leading dash) → null", () => {
126
+ expect(classifyTokenAsRuleCandidate("-r")).toBeNull();
127
+ expect(classifyTokenAsRuleCandidate("--recursive")).toBeNull();
128
+ });
129
+
130
+ test("env assignment (= before any /) → null", () => {
131
+ expect(classifyTokenAsRuleCandidate("FOO=/bar")).toBeNull();
132
+ expect(classifyTokenAsRuleCandidate("HOME=/home/user")).toBeNull();
133
+ });
134
+
135
+ test("env-like token where = comes after / is NOT rejected as assignment", () => {
136
+ // /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues.
137
+ // Contains /, so rule candidate accepts it.
138
+ expect(classifyTokenAsRuleCandidate("/foo=bar")).toBe("/foo=bar");
139
+ });
140
+
141
+ test("URL → null", () => {
142
+ expect(classifyTokenAsRuleCandidate("https://example.com")).toBeNull();
143
+ expect(classifyTokenAsRuleCandidate("http://localhost:3000")).toBeNull();
144
+ expect(classifyTokenAsRuleCandidate("file:///tmp/foo")).toBeNull();
145
+ });
146
+
147
+ test("@scope/package → null", () => {
148
+ expect(classifyTokenAsRuleCandidate("@foo/bar")).toBeNull();
149
+ expect(classifyTokenAsRuleCandidate("@scope/pkg")).toBeNull();
150
+ });
151
+
152
+ test("bare-slash token → null", () => {
153
+ expect(classifyTokenAsRuleCandidate("/")).toBeNull();
154
+ expect(classifyTokenAsRuleCandidate("//")).toBeNull();
155
+ });
156
+
157
+ test("regex metacharacters → null", () => {
158
+ expect(classifyTokenAsRuleCandidate("foo.*")).toBeNull();
159
+ expect(classifyTokenAsRuleCandidate("bar.+")).toBeNull();
160
+ expect(classifyTokenAsRuleCandidate("a\\|b")).toBeNull();
161
+ expect(classifyTokenAsRuleCandidate("[abc]")).toBeNull();
162
+ expect(classifyTokenAsRuleCandidate("^/start")).toBeNull();
163
+ });
164
+ });
165
+
166
+ describe("rule-candidate acceptance gate (broader than path)", () => {
167
+ test("absolute path (starts with /) → returned as-is", () => {
168
+ expect(classifyTokenAsRuleCandidate("/etc/hosts")).toBe("/etc/hosts");
169
+ });
170
+
171
+ test("home-relative path (starts with ~/) → returned as-is", () => {
172
+ expect(classifyTokenAsRuleCandidate("~/Documents")).toBe("~/Documents");
173
+ });
174
+
175
+ test("parent-traversal (contains ..) → returned as-is", () => {
176
+ expect(classifyTokenAsRuleCandidate("../foo")).toBe("../foo");
177
+ expect(classifyTokenAsRuleCandidate("..")).toBe("..");
178
+ });
179
+
180
+ test("dot-file (starts with .) → returned as-is", () => {
181
+ // Rule candidate accepts dot-files; path candidate does not.
182
+ expect(classifyTokenAsRuleCandidate(".env")).toBe(".env");
183
+ expect(classifyTokenAsRuleCandidate(".gitignore")).toBe(".gitignore");
184
+ });
185
+
186
+ test("current-dir relative (starts with ./) → returned as-is", () => {
187
+ expect(classifyTokenAsRuleCandidate("./src")).toBe("./src");
188
+ expect(classifyTokenAsRuleCandidate("./build/output.js")).toBe(
189
+ "./build/output.js",
190
+ );
191
+ });
192
+
193
+ test("relative path containing / → returned as-is", () => {
194
+ // Rule candidate accepts any token with / (not already rejected).
195
+ expect(classifyTokenAsRuleCandidate("src/foo.ts")).toBe("src/foo.ts");
196
+ expect(classifyTokenAsRuleCandidate("packages/pi-foo/index.ts")).toBe(
197
+ "packages/pi-foo/index.ts",
198
+ );
199
+ });
200
+
201
+ test("plain word with no path shape → null", () => {
202
+ expect(classifyTokenAsRuleCandidate("hello")).toBeNull();
203
+ expect(classifyTokenAsRuleCandidate("myfile.txt")).toBeNull();
204
+ });
205
+ });
206
+
207
+ describe("rule-vs-path divergence", () => {
208
+ const dotFiles = [".env", ".gitignore", ".eslintrc"];
209
+ const relPaths = ["src/index.ts", "lib/utils.js", "config/settings.json"];
210
+
211
+ for (const tok of dotFiles) {
212
+ test(`dot-file "${tok}": rule accepts, path rejects`, () => {
213
+ expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
214
+ expect(classifyTokenAsPathCandidate(tok)).toBeNull();
215
+ });
216
+ }
217
+
218
+ for (const tok of relPaths) {
219
+ test(`relative path "${tok}": rule accepts, path rejects`, () => {
220
+ expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
221
+ expect(classifyTokenAsPathCandidate(tok)).toBeNull();
222
+ });
223
+ }
224
+
225
+ const sharedAccepted = ["/etc/hosts", "~/docs", "../sibling"];
226
+ for (const tok of sharedAccepted) {
227
+ test(`"${tok}": both classifiers accept`, () => {
228
+ expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
229
+ expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
230
+ });
231
+ }
232
+
233
+ const sharedRejected = ["hello", "--flag", "FOO=/bar", "https://x.com"];
234
+ for (const tok of sharedRejected) {
235
+ test(`"${tok}": both classifiers reject`, () => {
236
+ expect(classifyTokenAsRuleCandidate(tok)).toBeNull();
237
+ expect(classifyTokenAsPathCandidate(tok)).toBeNull();
238
+ });
239
+ }
240
+ });
241
+ });
@@ -126,11 +126,8 @@ describe("describeExternalDirectoryGate", () => {
126
126
  ["/test/agent"],
127
127
  ) as GateDescriptor;
128
128
  expect(result.sessionApproval).toBeDefined();
129
- expect(result.sessionApproval).toHaveProperty(
130
- "surface",
131
- "external_directory",
132
- );
133
- expect(result.sessionApproval).toHaveProperty("pattern");
129
+ expect(result.sessionApproval?.surface).toBe("external_directory");
130
+ expect(result.sessionApproval?.representativePattern).toBeDefined();
134
131
  });
135
132
 
136
133
  it("denialContext contains the external path and cwd", () => {
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
3
  import {
4
+ buildDecisionEvent,
4
5
  deriveDecisionValue,
5
6
  deriveResolution,
6
7
  } from "#src/handlers/gates/helpers";
8
+ import type { PermissionCheckResult } from "#src/types";
7
9
 
8
10
  describe("deriveDecisionValue", () => {
9
11
  it("returns command for bash", () => {
@@ -82,3 +84,82 @@ describe("deriveResolution", () => {
82
84
  );
83
85
  });
84
86
  });
87
+
88
+ describe("buildDecisionEvent", () => {
89
+ function makeCheck(
90
+ overrides: Partial<PermissionCheckResult> = {},
91
+ ): PermissionCheckResult {
92
+ return {
93
+ state: "allow",
94
+ toolName: "read",
95
+ source: "tool",
96
+ origin: "builtin",
97
+ matchedPattern: "*",
98
+ ...overrides,
99
+ };
100
+ }
101
+
102
+ it("builds a decision event with all fields populated", () => {
103
+ const event = buildDecisionEvent(
104
+ { surface: "read", value: "read" },
105
+ makeCheck({ origin: "global", matchedPattern: "read" }),
106
+ "test-agent",
107
+ "allow",
108
+ "policy_allow",
109
+ );
110
+ expect(event).toEqual({
111
+ surface: "read",
112
+ value: "read",
113
+ result: "allow",
114
+ resolution: "policy_allow",
115
+ origin: "global",
116
+ agentName: "test-agent",
117
+ matchedPattern: "read",
118
+ });
119
+ });
120
+
121
+ it("normalises undefined origin to null", () => {
122
+ const event = buildDecisionEvent(
123
+ { surface: "bash", value: "git status" },
124
+ makeCheck({ origin: undefined }),
125
+ null,
126
+ "allow",
127
+ "user_approved",
128
+ );
129
+ expect(event.origin).toBeNull();
130
+ });
131
+
132
+ it("normalises null agentName to null", () => {
133
+ const event = buildDecisionEvent(
134
+ { surface: "read", value: "read" },
135
+ makeCheck(),
136
+ null,
137
+ "deny",
138
+ "policy_deny",
139
+ );
140
+ expect(event.agentName).toBeNull();
141
+ });
142
+
143
+ it("normalises undefined matchedPattern to null", () => {
144
+ const event = buildDecisionEvent(
145
+ { surface: "read", value: "read" },
146
+ makeCheck({ matchedPattern: undefined }),
147
+ null,
148
+ "deny",
149
+ "policy_deny",
150
+ );
151
+ expect(event.matchedPattern).toBeNull();
152
+ });
153
+
154
+ it("passes result and resolution through", () => {
155
+ const event = buildDecisionEvent(
156
+ { surface: "bash", value: "rm -rf /" },
157
+ makeCheck(),
158
+ null,
159
+ "deny",
160
+ "user_denied",
161
+ );
162
+ expect(event.result).toBe("deny");
163
+ expect(event.resolution).toBe("user_denied");
164
+ });
165
+ });
@@ -7,8 +7,11 @@ import type { ToolCallContext } from "#src/handlers/gates/types";
7
7
  import type { Rule } from "#src/rule";
8
8
  import type { PermissionCheckResult } from "#src/types";
9
9
 
10
+ import { makeGateCheckResult as makeCheckResult } from "#test/helpers/gate-fixtures";
11
+
10
12
  // ── helpers ────────────────────────────────────────────────────────────────
11
13
 
14
+ // path.test.ts uses read-tool defaults; the shared makeTcc uses bash defaults.
12
15
  function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
13
16
  return {
14
17
  toolName: "read",
@@ -20,18 +23,6 @@ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
20
23
  };
21
24
  }
22
25
 
23
- function makeCheckResult(
24
- overrides: Partial<PermissionCheckResult> = {},
25
- ): PermissionCheckResult {
26
- return {
27
- toolName: "path",
28
- state: "allow",
29
- source: "special",
30
- origin: "global",
31
- ...overrides,
32
- };
33
- }
34
-
35
26
  type CheckPermissionFn = (
36
27
  surface: string,
37
28
  input: unknown,
@@ -160,8 +151,8 @@ describe("describePathGate", () => {
160
151
  getSessionRuleset,
161
152
  ) as GateDescriptor;
162
153
  expect(result.sessionApproval).toBeDefined();
163
- expect(result.sessionApproval).toHaveProperty("surface", "path");
164
- expect(result.sessionApproval).toHaveProperty("pattern");
154
+ expect(result.sessionApproval?.surface).toBe("path");
155
+ expect(result.sessionApproval?.representativePattern).toBeDefined();
165
156
  });
166
157
 
167
158
  it("descriptor denialContext references the file path and tool name", () => {