@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
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Filesystem-backed PermissionManager harness for integration tests.
3
+ *
4
+ * Writes a real config file and agents directory to a temp directory so
5
+ * PermissionManager can load them without mocking the file system.
6
+ */
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ import { PermissionManager } from "#src/permission-manager";
12
+ import type { ScopeConfig } from "#src/types";
13
+
14
+ export type CreateManagerOptions = {
15
+ mcpServerNames?: readonly string[];
16
+ };
17
+
18
+ export function createManager(
19
+ config: ScopeConfig,
20
+ agentFiles: Record<string, string> = {},
21
+ options: CreateManagerOptions = {},
22
+ ) {
23
+ const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-test-"));
24
+ const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
25
+ const agentsDir = join(baseDir, "agents");
26
+
27
+ mkdirSync(agentsDir, { recursive: true });
28
+ writeFileSync(
29
+ globalConfigPath,
30
+ `${JSON.stringify(config, null, 2)}\n`,
31
+ "utf8",
32
+ );
33
+
34
+ for (const [name, content] of Object.entries(agentFiles)) {
35
+ writeFileSync(join(agentsDir, `${name}.md`), content, "utf8");
36
+ }
37
+
38
+ const manager = new PermissionManager({
39
+ globalConfigPath,
40
+ agentsDir,
41
+ mcpServerNames: options.mcpServerNames,
42
+ });
43
+
44
+ return {
45
+ manager,
46
+ globalConfigPath,
47
+ cleanup: (): void => {
48
+ rmSync(baseDir, { recursive: true, force: true });
49
+ },
50
+ };
51
+ }
@@ -36,8 +36,10 @@ import {
36
36
  PermissionSession,
37
37
  type PermissionSessionRuntimeDeps,
38
38
  } from "#src/permission-session";
39
+ import { SessionApproval } from "#src/session-approval";
39
40
  import type { SessionLogger } from "#src/session-logger";
40
41
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
42
+ import { makeCtx } from "#test/helpers/handler-fixtures";
41
43
 
42
44
  function makeSkillEntry(
43
45
  name: string,
@@ -93,25 +95,6 @@ function makeForwarding(): ForwardingController {
93
95
  };
94
96
  }
95
97
 
96
- function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
97
- return {
98
- cwd: "/test/project",
99
- hasUI: true,
100
- ui: {
101
- setStatus: vi.fn(),
102
- notify: vi.fn(),
103
- select: vi.fn(),
104
- input: vi.fn(),
105
- },
106
- sessionManager: {
107
- getEntries: vi.fn().mockReturnValue([]),
108
- getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
109
- addEntry: vi.fn(),
110
- },
111
- ...overrides,
112
- } as unknown as ExtensionContext;
113
- }
114
-
115
98
  function makePermissionManager(
116
99
  overrides: Partial<PermissionManager> = {},
117
100
  ): PermissionManager {
@@ -219,9 +202,11 @@ describe("PermissionSession", () => {
219
202
  expect(rules).toEqual([]);
220
203
  });
221
204
 
222
- it("delegates approveSessionRule to internal SessionRules", () => {
205
+ it("delegates recordSessionApproval to internal SessionRules", () => {
223
206
  const { session } = createSession();
224
- session.approveSessionRule("bash", "/usr/bin/*");
207
+ session.recordSessionApproval(
208
+ SessionApproval.single("bash", "/usr/bin/*"),
209
+ );
225
210
  const rules = session.getSessionRuleset();
226
211
  expect(rules).toHaveLength(1);
227
212
  expect(rules[0]).toMatchObject({
@@ -325,7 +310,7 @@ describe("PermissionSession", () => {
325
310
  describe("shutdown", () => {
326
311
  it("clears session rules", () => {
327
312
  const { session } = createSession();
328
- session.approveSessionRule("bash", "*");
313
+ session.recordSessionApproval(SessionApproval.single("bash", "*"));
329
314
  expect(session.getSessionRuleset()).toHaveLength(1);
330
315
 
331
316
  session.shutdown();
@@ -9,7 +9,6 @@ import {
9
9
  import { homedir, tmpdir } from "node:os";
10
10
  import { dirname, join, resolve } from "node:path";
11
11
  import { expect, test } from "vitest";
12
-
13
12
  import {
14
13
  createActiveToolsCacheKey,
15
14
  createBeforeAgentStartPromptStateKey,
@@ -47,45 +46,10 @@ import {
47
46
  canResolveAskPermissionRequest,
48
47
  shouldAutoApprovePermissionState,
49
48
  } from "#src/yolo-mode";
50
-
51
- type CreateManagerOptions = {
52
- mcpServerNames?: readonly string[];
53
- };
54
-
55
- function createManager(
56
- config: ScopeConfig,
57
- agentFiles: Record<string, string> = {},
58
- options: CreateManagerOptions = {},
59
- ) {
60
- const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-test-"));
61
- const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
62
- const agentsDir = join(baseDir, "agents");
63
-
64
- mkdirSync(agentsDir, { recursive: true });
65
- writeFileSync(
66
- globalConfigPath,
67
- `${JSON.stringify(config, null, 2)}\n`,
68
- "utf8",
69
- );
70
-
71
- for (const [name, content] of Object.entries(agentFiles)) {
72
- writeFileSync(join(agentsDir, `${name}.md`), content, "utf8");
73
- }
74
-
75
- const manager = new PermissionManager({
76
- globalConfigPath,
77
- agentsDir,
78
- mcpServerNames: options.mcpServerNames,
79
- });
80
-
81
- return {
82
- manager,
83
- globalConfigPath,
84
- cleanup: (): void => {
85
- rmSync(baseDir, { recursive: true, force: true });
86
- },
87
- };
88
- }
49
+ import {
50
+ type CreateManagerOptions,
51
+ createManager,
52
+ } from "#test/helpers/manager-harness";
89
53
 
90
54
  type MockHandler = (
91
55
  event: Record<string, unknown>,
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { MergedScopes } from "#src/scope-merge";
3
+ import { mergeScopesWithOrigins } from "#src/scope-merge";
4
+
5
+ describe("mergeScopesWithOrigins", () => {
6
+ it("returns empty result for empty scopes array", () => {
7
+ const result: MergedScopes = mergeScopesWithOrigins([]);
8
+ expect(result.mergedPermission).toEqual({});
9
+ expect(result.origins.size).toBe(0);
10
+ });
11
+
12
+ it("attributes a string surface value to the contributing scope via the '*' pattern", () => {
13
+ const result = mergeScopesWithOrigins([
14
+ ["global", { permission: { bash: "allow" } }],
15
+ ]);
16
+ expect(result.mergedPermission).toEqual({ bash: "allow" });
17
+ expect(result.origins.get("bash")?.get("*")).toBe("global");
18
+ });
19
+
20
+ it("attributes each pattern of an object surface value to the contributing scope", () => {
21
+ const result = mergeScopesWithOrigins([
22
+ [
23
+ "project",
24
+ { permission: { bash: { "git *": "allow", "npm *": "deny" } } },
25
+ ],
26
+ ]);
27
+ expect(result.mergedPermission).toEqual({
28
+ bash: { "git *": "allow", "npm *": "deny" },
29
+ });
30
+ expect(result.origins.get("bash")?.get("git *")).toBe("project");
31
+ expect(result.origins.get("bash")?.get("npm *")).toBe("project");
32
+ });
33
+
34
+ it(
35
+ "shallow-merge: patterns not redefined by the higher scope keep their lower-scope origin;" +
36
+ " patterns the higher scope defines switch to the higher scope",
37
+ () => {
38
+ const result = mergeScopesWithOrigins([
39
+ [
40
+ "global",
41
+ { permission: { bash: { "ls *": "allow", "git *": "allow" } } },
42
+ ],
43
+ ["project", { permission: { bash: { "git *": "deny" } } }],
44
+ ]);
45
+ expect(result.mergedPermission).toEqual({
46
+ bash: { "ls *": "allow", "git *": "deny" },
47
+ });
48
+ // "ls *" was not touched by project — retains global attribution
49
+ expect(result.origins.get("bash")?.get("ls *")).toBe("global");
50
+ // "git *" was overridden by project — switches to project attribution
51
+ expect(result.origins.get("bash")?.get("git *")).toBe("project");
52
+ },
53
+ );
54
+
55
+ it("full replacement (string over object): higher scope re-attributes the entire surface to its own origin", () => {
56
+ const result = mergeScopesWithOrigins([
57
+ ["global", { permission: { bash: { "ls *": "allow" } } }],
58
+ ["project", { permission: { bash: "deny" } }],
59
+ ]);
60
+ expect(result.mergedPermission).toEqual({ bash: "deny" });
61
+ // The string value produces a single "*" pattern for the replacing scope
62
+ expect(result.origins.get("bash")?.get("*")).toBe("project");
63
+ // The former "ls *" pattern from global is gone — origins are replaced, not merged
64
+ expect(result.origins.get("bash")?.has("ls *")).toBe(false);
65
+ });
66
+
67
+ it("full replacement (object over string): higher scope re-attributes the entire surface to its own origin", () => {
68
+ const result = mergeScopesWithOrigins([
69
+ ["global", { permission: { bash: "ask" } }],
70
+ ["project", { permission: { bash: { "git *": "deny" } } }],
71
+ ]);
72
+ expect(result.mergedPermission).toEqual({ bash: { "git *": "deny" } });
73
+ // The object value attributes each pattern to the replacing scope
74
+ expect(result.origins.get("bash")?.get("git *")).toBe("project");
75
+ // The former "*" attribution from global is gone
76
+ expect(result.origins.get("bash")?.has("*")).toBe(false);
77
+ });
78
+
79
+ it("applies four-scope precedence in lowest→highest order (global → project → agent → project-agent)", () => {
80
+ const result = mergeScopesWithOrigins([
81
+ ["global", { permission: { read: "ask" } }],
82
+ ["project", { permission: { write: "deny" } }],
83
+ ["agent", { permission: { bash: "deny" } }],
84
+ ["project-agent", { permission: { mcp: "allow" } }],
85
+ ]);
86
+ expect(result.mergedPermission).toEqual({
87
+ read: "ask",
88
+ write: "deny",
89
+ bash: "deny",
90
+ mcp: "allow",
91
+ });
92
+ expect(result.origins.get("read")?.get("*")).toBe("global");
93
+ expect(result.origins.get("write")?.get("*")).toBe("project");
94
+ expect(result.origins.get("bash")?.get("*")).toBe("agent");
95
+ expect(result.origins.get("mcp")?.get("*")).toBe("project-agent");
96
+ });
97
+
98
+ it("skips scopes with no permission key, contributing nothing to either map", () => {
99
+ const result = mergeScopesWithOrigins([
100
+ ["global", {}],
101
+ ["project", { permission: { bash: "allow" } }],
102
+ ]);
103
+ expect(result.mergedPermission).toEqual({ bash: "allow" });
104
+ expect(result.origins.get("bash")?.get("*")).toBe("project");
105
+ });
106
+
107
+ it("attributes the universal '*' surface like any other (downstream reads origins.get('*')?.get('*') for universalFallbackOrigin)", () => {
108
+ const result = mergeScopesWithOrigins([
109
+ ["global", { permission: { "*": "deny" } }],
110
+ ["project", { permission: { "*": "allow" } }],
111
+ ]);
112
+ expect(result.mergedPermission).toEqual({ "*": "allow" });
113
+ // Both scopes write a string — each is a full replacement; project wins last
114
+ expect(result.origins.get("*")?.get("*")).toBe("project");
115
+ });
116
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { SessionApproval } from "#src/session-approval";
4
+
5
+ describe("SessionApproval", () => {
6
+ describe("single", () => {
7
+ it("stores surface and one pattern", () => {
8
+ const approval = SessionApproval.single("bash", "git *");
9
+ expect(approval.surface).toBe("bash");
10
+ expect(approval.patterns).toEqual(["git *"]);
11
+ });
12
+
13
+ it("representativePattern returns the pattern", () => {
14
+ const approval = SessionApproval.single("bash", "git *");
15
+ expect(approval.representativePattern).toBe("git *");
16
+ });
17
+
18
+ it("toGateApproval returns { surface, pattern }", () => {
19
+ const approval = SessionApproval.single("bash", "git *");
20
+ expect(approval.toGateApproval()).toEqual({
21
+ surface: "bash",
22
+ pattern: "git *",
23
+ });
24
+ });
25
+ });
26
+
27
+ describe("multiple", () => {
28
+ it("stores surface and all patterns", () => {
29
+ const approval = SessionApproval.multiple("external_directory", [
30
+ "/outside/a/*",
31
+ "/outside/b/*",
32
+ ]);
33
+ expect(approval.surface).toBe("external_directory");
34
+ expect(approval.patterns).toEqual(["/outside/a/*", "/outside/b/*"]);
35
+ });
36
+
37
+ it("representativePattern returns the first pattern", () => {
38
+ const approval = SessionApproval.multiple("external_directory", [
39
+ "/outside/a/*",
40
+ "/outside/b/*",
41
+ ]);
42
+ expect(approval.representativePattern).toBe("/outside/a/*");
43
+ });
44
+
45
+ it("toGateApproval returns { surface, pattern } using the first pattern", () => {
46
+ const approval = SessionApproval.multiple("external_directory", [
47
+ "/outside/a/*",
48
+ "/outside/b/*",
49
+ ]);
50
+ expect(approval.toGateApproval()).toEqual({
51
+ surface: "external_directory",
52
+ pattern: "/outside/a/*",
53
+ });
54
+ });
55
+
56
+ it("defensive copy — mutating the source array does not affect patterns", () => {
57
+ const source = ["/outside/a/*", "/outside/b/*"];
58
+ const approval = SessionApproval.multiple("external_directory", source);
59
+ source.push("/outside/c/*");
60
+ expect(approval.patterns).toEqual(["/outside/a/*", "/outside/b/*"]);
61
+ });
62
+ });
63
+
64
+ describe("empty patterns (degenerate case)", () => {
65
+ it("representativePattern returns undefined", () => {
66
+ const approval = SessionApproval.multiple("external_directory", []);
67
+ expect(approval.representativePattern).toBeUndefined();
68
+ });
69
+
70
+ it("toGateApproval returns undefined", () => {
71
+ const approval = SessionApproval.multiple("external_directory", []);
72
+ expect(approval.toGateApproval()).toBeUndefined();
73
+ });
74
+ });
75
+ });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { evaluate } from "#src/rule";
4
+ import { SessionApproval } from "#src/session-approval";
4
5
  import { deriveApprovalPattern, SessionRules } from "#src/session-rules";
5
6
 
6
7
  // ── SessionRules ───────────────────────────────────────────────────────────
@@ -66,6 +67,54 @@ describe("SessionRules", () => {
66
67
  });
67
68
  });
68
69
 
70
+ describe("record", () => {
71
+ it("records a single-pattern approval as one rule", () => {
72
+ const rules = new SessionRules();
73
+ rules.record(SessionApproval.single("bash", "git *"));
74
+ expect(rules.getRuleset()).toEqual([
75
+ {
76
+ surface: "bash",
77
+ pattern: "git *",
78
+ action: "allow",
79
+ layer: "session",
80
+ origin: "session",
81
+ },
82
+ ]);
83
+ });
84
+
85
+ it("records a multi-pattern approval as one rule per pattern", () => {
86
+ const rules = new SessionRules();
87
+ rules.record(
88
+ SessionApproval.multiple("external_directory", [
89
+ "/outside/a/*",
90
+ "/outside/b/*",
91
+ ]),
92
+ );
93
+ expect(rules.getRuleset()).toHaveLength(2);
94
+ expect(rules.getRuleset()[0].pattern).toBe("/outside/a/*");
95
+ expect(rules.getRuleset()[1].pattern).toBe("/outside/b/*");
96
+ });
97
+
98
+ it("records each rule with the correct surface", () => {
99
+ const rules = new SessionRules();
100
+ rules.record(
101
+ SessionApproval.multiple("external_directory", [
102
+ "/outside/a/*",
103
+ "/outside/b/*",
104
+ ]),
105
+ );
106
+ for (const rule of rules.getRuleset()) {
107
+ expect(rule.surface).toBe("external_directory");
108
+ }
109
+ });
110
+
111
+ it("records nothing for an empty patterns list", () => {
112
+ const rules = new SessionRules();
113
+ rules.record(SessionApproval.multiple("external_directory", []));
114
+ expect(rules.getRuleset()).toEqual([]);
115
+ });
116
+ });
117
+
69
118
  describe("evaluate() integration", () => {
70
119
  it("returns allow for a path under an approved directory", () => {
71
120
  const session = new SessionRules();