@gotgenes/pi-permission-system 3.8.0 → 3.10.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,38 +1,6 @@
1
- import { afterEach, describe, expect, test, vi } from "vitest";
1
+ import { describe, expect, test } from "vitest";
2
2
  import type { Rule, Ruleset } from "../src/rule";
3
- import { evaluate, getDefaultAction } from "../src/rule";
4
-
5
- afterEach(() => {
6
- vi.restoreAllMocks();
7
- });
8
-
9
- describe("getDefaultAction", () => {
10
- test("returns 'ask' for bash surface", () => {
11
- expect(getDefaultAction("bash")).toBe("ask");
12
- });
13
-
14
- test("returns 'ask' for mcp surface", () => {
15
- expect(getDefaultAction("mcp")).toBe("ask");
16
- });
17
-
18
- test("returns 'ask' for skill surface", () => {
19
- expect(getDefaultAction("skill")).toBe("ask");
20
- });
21
-
22
- test("returns 'ask' for special surface", () => {
23
- expect(getDefaultAction("special")).toBe("ask");
24
- });
25
-
26
- test("returns 'ask' for tools surface", () => {
27
- expect(getDefaultAction("tools")).toBe("ask");
28
- });
29
-
30
- test("returns 'ask' for unknown surface (least privilege)", () => {
31
- expect(getDefaultAction("unknown_surface")).toBe("ask");
32
- expect(getDefaultAction("")).toBe("ask");
33
- expect(getDefaultAction("external_directory")).toBe("ask");
34
- });
35
- });
3
+ import { evaluate } from "../src/rule";
36
4
 
37
5
  describe("evaluate", () => {
38
6
  const allowBashGit: Rule = {
@@ -64,11 +32,23 @@ describe("evaluate", () => {
64
32
  expect(result).toEqual(allowBashGit);
65
33
  });
66
34
 
67
- test("returns synthetic rule with default action when no rules match", () => {
35
+ test("returns synthetic rule with 'ask' when no rules match and no defaultAction", () => {
68
36
  const result = evaluate("bash", "npm install", [allowBashGit]);
69
37
  expect(result.surface).toBe("bash");
70
38
  expect(result.pattern).toBe("npm install");
71
- expect(result.action).toBe("ask"); // getDefaultAction("bash")
39
+ expect(result.action).toBe("ask");
40
+ });
41
+
42
+ test("returns synthetic rule with custom defaultAction when no rules match", () => {
43
+ const result = evaluate("bash", "npm install", [allowBashGit], "deny");
44
+ expect(result.surface).toBe("bash");
45
+ expect(result.pattern).toBe("npm install");
46
+ expect(result.action).toBe("deny");
47
+ });
48
+
49
+ test("defaultAction does not affect matched rules", () => {
50
+ const result = evaluate("bash", "git status", [allowBashGit], "deny");
51
+ expect(result).toEqual(allowBashGit);
72
52
  });
73
53
 
74
54
  test("returns synthetic rule for empty ruleset", () => {
@@ -126,18 +106,19 @@ describe("evaluate", () => {
126
106
  expect(result.action).toBe("ask"); // falls back to default
127
107
  });
128
108
 
129
- test("multiple rulesets: rules from later rulesets take priority", () => {
109
+ test("merged rulesets: rules from later scope take priority", () => {
130
110
  const globalRules: Ruleset = [
131
111
  { surface: "bash", pattern: "git *", action: "ask" },
132
112
  ];
133
113
  const agentRules: Ruleset = [
134
114
  { surface: "bash", pattern: "git *", action: "allow" },
135
115
  ];
136
- const result = evaluate("bash", "git status", globalRules, agentRules);
116
+ const merged = [...globalRules, ...agentRules];
117
+ const result = evaluate("bash", "git status", merged);
137
118
  expect(result.action).toBe("allow"); // agent rule wins
138
119
  });
139
120
 
140
- test("multiple rulesets: earlier rulesets used when later rulesets have no match", () => {
121
+ test("merged rulesets: earlier scope used when later scope has no match", () => {
141
122
  const globalRules: Ruleset = [
142
123
  { surface: "bash", pattern: "git *", action: "allow" },
143
124
  ];
@@ -145,12 +126,13 @@ describe("evaluate", () => {
145
126
  { surface: "bash", pattern: "npm *", action: "deny" },
146
127
  ];
147
128
  // git status matches global but not agent rule
148
- const result = evaluate("bash", "git status", globalRules, agentRules);
129
+ const merged = [...globalRules, ...agentRules];
130
+ const result = evaluate("bash", "git status", merged);
149
131
  expect(result.action).toBe("allow"); // global rule is the last match for this pattern
150
132
  });
151
133
 
152
- test("no rulesets at all returns synthetic default", () => {
153
- const result = evaluate("bash", "git status");
134
+ test("empty ruleset returns synthetic default", () => {
135
+ const result = evaluate("bash", "git status", []);
154
136
  expect(result.surface).toBe("bash");
155
137
  expect(result.pattern).toBe("git status");
156
138
  expect(result.action).toBe("ask");
@@ -68,8 +68,9 @@ vi.mock("../src/subagent-context", () => ({
68
68
  isSubagentExecutionContext: vi.fn().mockReturnValue(false),
69
69
  }));
70
70
 
71
- vi.mock("../src/session-approval-cache", () => ({
72
- SessionApprovalCache: vi.fn(),
71
+ vi.mock("../src/session-rules", () => ({
72
+ SessionRules: vi.fn(),
73
+ deriveApprovalPattern: vi.fn(),
73
74
  }));
74
75
 
75
76
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
@@ -184,9 +185,9 @@ describe("createExtensionRuntime", () => {
184
185
  expect(runtime.isProcessingForwardedRequests).toBe(false);
185
186
  });
186
187
 
187
- it("creates a sessionApprovalCache instance", () => {
188
+ it("creates a sessionRules instance", () => {
188
189
  const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
189
- expect(runtime.sessionApprovalCache).toBeDefined();
190
+ expect(runtime.sessionRules).toBeDefined();
190
191
  });
191
192
 
192
193
  // ── Mutable state is writable ──────────────────────────────────────────
@@ -0,0 +1,225 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { evaluate } from "../src/rule";
4
+ import { deriveApprovalPattern, SessionRules } from "../src/session-rules";
5
+
6
+ // ── SessionRules ───────────────────────────────────────────────────────────
7
+
8
+ describe("SessionRules", () => {
9
+ describe("getRuleset", () => {
10
+ it("returns an empty ruleset initially", () => {
11
+ const rules = new SessionRules();
12
+ expect(rules.getRuleset()).toEqual([]);
13
+ });
14
+
15
+ it("returns a ruleset containing approved rules", () => {
16
+ const rules = new SessionRules();
17
+ rules.approve("external_directory", "/other/project/*");
18
+ expect(rules.getRuleset()).toEqual([
19
+ {
20
+ surface: "external_directory",
21
+ pattern: "/other/project/*",
22
+ action: "allow",
23
+ },
24
+ ]);
25
+ });
26
+
27
+ it("returns a defensive copy — mutations do not affect internal state", () => {
28
+ const rules = new SessionRules();
29
+ rules.approve("external_directory", "/other/project/*");
30
+ const copy = rules.getRuleset();
31
+ copy.push({ surface: "bash", pattern: "*", action: "deny" });
32
+ expect(rules.getRuleset()).toHaveLength(1);
33
+ });
34
+
35
+ it("accumulates multiple approved patterns", () => {
36
+ const rules = new SessionRules();
37
+ rules.approve("external_directory", "/project-a/*");
38
+ rules.approve("external_directory", "/project-b/*");
39
+ expect(rules.getRuleset()).toHaveLength(2);
40
+ });
41
+ });
42
+
43
+ describe("clear", () => {
44
+ it("removes all session rules", () => {
45
+ const rules = new SessionRules();
46
+ rules.approve("external_directory", "/other/project/*");
47
+ rules.approve("external_directory", "/another/path/*");
48
+ rules.clear();
49
+ expect(rules.getRuleset()).toEqual([]);
50
+ });
51
+
52
+ it("allows new approvals after clearing", () => {
53
+ const rules = new SessionRules();
54
+ rules.approve("external_directory", "/old/path/*");
55
+ rules.clear();
56
+ rules.approve("external_directory", "/new/path/*");
57
+ expect(rules.getRuleset()).toHaveLength(1);
58
+ expect(rules.getRuleset()[0].pattern).toBe("/new/path/*");
59
+ });
60
+ });
61
+
62
+ describe("evaluate() integration", () => {
63
+ it("returns allow for a path under an approved directory", () => {
64
+ const session = new SessionRules();
65
+ session.approve("external_directory", "/other/project/*");
66
+ const result = evaluate(
67
+ "external_directory",
68
+ "/other/project/src/foo.ts",
69
+ session.getRuleset(),
70
+ );
71
+ expect(result.action).toBe("allow");
72
+ });
73
+
74
+ it("returns ask (default) for a path outside approved directories", () => {
75
+ const session = new SessionRules();
76
+ session.approve("external_directory", "/other/project/*");
77
+ const result = evaluate(
78
+ "external_directory",
79
+ "/other/unrelated/file.ts",
80
+ session.getRuleset(),
81
+ );
82
+ // No rule matches — evaluate returns synthetic rule with default action "ask"
83
+ expect(result.action).toBe("ask");
84
+ });
85
+
86
+ it("does not match a sibling directory that shares a string prefix", () => {
87
+ const session = new SessionRules();
88
+ session.approve("external_directory", "/other/project/*");
89
+ const result = evaluate(
90
+ "external_directory",
91
+ "/other/project-b/foo.ts",
92
+ session.getRuleset(),
93
+ );
94
+ expect(result.action).toBe("ask");
95
+ });
96
+
97
+ it("matches the directory itself (trailing slash)", () => {
98
+ const session = new SessionRules();
99
+ session.approve("external_directory", "/other/project/src/*");
100
+ // The * in wildcardMatch maps to .* which matches zero chars — so /src/ is covered.
101
+ const result = evaluate(
102
+ "external_directory",
103
+ "/other/project/src/",
104
+ session.getRuleset(),
105
+ );
106
+ expect(result.action).toBe("allow");
107
+ });
108
+
109
+ it("handles multiple approved directories", () => {
110
+ const session = new SessionRules();
111
+ session.approve("external_directory", "/project-a/*");
112
+ session.approve("external_directory", "/project-b/*");
113
+ expect(
114
+ evaluate(
115
+ "external_directory",
116
+ "/project-a/foo.ts",
117
+ session.getRuleset(),
118
+ ).action,
119
+ ).toBe("allow");
120
+ expect(
121
+ evaluate(
122
+ "external_directory",
123
+ "/project-b/bar.ts",
124
+ session.getRuleset(),
125
+ ).action,
126
+ ).toBe("allow");
127
+ expect(
128
+ evaluate(
129
+ "external_directory",
130
+ "/project-c/baz.ts",
131
+ session.getRuleset(),
132
+ ).action,
133
+ ).toBe("ask");
134
+ });
135
+
136
+ it("does not match a different surface", () => {
137
+ const session = new SessionRules();
138
+ session.approve("external_directory", "/other/project/*");
139
+ const result = evaluate(
140
+ "bash",
141
+ "/other/project/foo.ts",
142
+ session.getRuleset(),
143
+ );
144
+ expect(result.action).toBe("ask");
145
+ });
146
+
147
+ it("returns allow after clearing and re-approving", () => {
148
+ const session = new SessionRules();
149
+ session.approve("external_directory", "/old/project/*");
150
+ session.clear();
151
+ session.approve("external_directory", "/new/project/*");
152
+ expect(
153
+ evaluate(
154
+ "external_directory",
155
+ "/old/project/file.ts",
156
+ session.getRuleset(),
157
+ ).action,
158
+ ).toBe("ask");
159
+ expect(
160
+ evaluate(
161
+ "external_directory",
162
+ "/new/project/file.ts",
163
+ session.getRuleset(),
164
+ ).action,
165
+ ).toBe("allow");
166
+ });
167
+ });
168
+ });
169
+
170
+ // ── deriveApprovalPattern ──────────────────────────────────────────────────
171
+
172
+ describe("deriveApprovalPattern", () => {
173
+ it("returns parent directory glob for a file path", () => {
174
+ expect(deriveApprovalPattern("/other/project/src/foo.ts")).toBe(
175
+ "/other/project/src/*",
176
+ );
177
+ });
178
+
179
+ it("returns directory glob when path already ends with separator", () => {
180
+ expect(deriveApprovalPattern("/other/project/src/")).toBe(
181
+ "/other/project/src/*",
182
+ );
183
+ });
184
+
185
+ it("returns parent directory glob for a directory-like path without trailing separator", () => {
186
+ // Cannot distinguish dir from file — dirname is the safe choice
187
+ expect(deriveApprovalPattern("/other/project/src")).toBe(
188
+ "/other/project/*",
189
+ );
190
+ });
191
+
192
+ it("handles root path", () => {
193
+ expect(deriveApprovalPattern("/")).toBe("/*");
194
+ });
195
+
196
+ it("handles single-level path", () => {
197
+ expect(deriveApprovalPattern("/foo")).toBe("/*");
198
+ });
199
+
200
+ it("produces a pattern that matches paths under the approved directory", () => {
201
+ const pattern = deriveApprovalPattern("/other/project/src/foo.ts");
202
+ const session = new SessionRules();
203
+ session.approve("external_directory", pattern);
204
+ expect(
205
+ evaluate(
206
+ "external_directory",
207
+ "/other/project/src/bar.ts",
208
+ session.getRuleset(),
209
+ ).action,
210
+ ).toBe("allow");
211
+ });
212
+
213
+ it("produces a pattern that does not match sibling directories", () => {
214
+ const pattern = deriveApprovalPattern("/other/project/src/foo.ts");
215
+ const session = new SessionRules();
216
+ session.approve("external_directory", pattern);
217
+ expect(
218
+ evaluate(
219
+ "external_directory",
220
+ "/other/project/lib/bar.ts",
221
+ session.getRuleset(),
222
+ ).action,
223
+ ).toBe("ask");
224
+ });
225
+ });
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
5
5
  import { getGlobalConfigPath } from "../src/config-paths";
6
6
  import { DEFAULT_EXTENSION_CONFIG } from "../src/extension-config";
7
7
  import piPermissionSystemExtension from "../src/index";
8
- import type { GlobalPermissionConfig } from "../src/types";
8
+ import type { ScopeConfig } from "../src/types";
9
9
 
10
10
  type MockHandler = (
11
11
  event: Record<string, unknown>,
@@ -26,7 +26,7 @@ describe("session_start handler consolidation", () => {
26
26
  mkdirSync(join(baseDir, "agents"), { recursive: true });
27
27
  mkdirSync(dirname(globalConfigPath), { recursive: true });
28
28
 
29
- const config: GlobalPermissionConfig = {
29
+ const config: ScopeConfig = {
30
30
  defaultPolicy: {
31
31
  tools: "ask",
32
32
  bash: "ask",
@@ -1,51 +0,0 @@
1
- import type { BashPermissions, PermissionState } from "./types";
2
- import {
3
- type CompiledWildcardPattern,
4
- compileWildcardPatterns,
5
- findCompiledWildcardMatch,
6
- } from "./wildcard-matcher";
7
-
8
- type CompiledPattern = CompiledWildcardPattern<PermissionState>;
9
-
10
- type BashPermissionSource = BashPermissions | readonly CompiledPattern[];
11
-
12
- function isCompiledPatternList(
13
- value: BashPermissionSource,
14
- ): value is readonly CompiledPattern[] {
15
- return Array.isArray(value);
16
- }
17
-
18
- export interface BashPermissionCheck {
19
- state: PermissionState;
20
- matchedPattern?: string;
21
- command: string;
22
- }
23
-
24
- export class BashFilter {
25
- private readonly compiledPatterns: CompiledPattern[];
26
-
27
- constructor(
28
- permissions: BashPermissionSource,
29
- private readonly defaultState: PermissionState,
30
- ) {
31
- this.compiledPatterns = isCompiledPatternList(permissions)
32
- ? [...permissions]
33
- : compileWildcardPatterns(permissions);
34
- }
35
-
36
- check(command: string): BashPermissionCheck {
37
- const match = findCompiledWildcardMatch(this.compiledPatterns, command);
38
- if (match) {
39
- return {
40
- state: match.state,
41
- matchedPattern: match.matchedPattern,
42
- command,
43
- };
44
- }
45
-
46
- return {
47
- state: this.defaultState,
48
- command,
49
- };
50
- }
51
- }
@@ -1,81 +0,0 @@
1
- import { dirname, sep } from "node:path";
2
-
3
- import { isPathWithinDirectory } from "./external-directory";
4
-
5
- /**
6
- * Ephemeral in-memory cache of session-scoped permission approvals.
7
- * Keyed by permission surface (e.g. "external_directory"), values are
8
- * normalized directory prefixes that have been approved for the session.
9
- *
10
- * Cleared on session_shutdown — never persisted to disk.
11
- */
12
- export class SessionApprovalCache {
13
- private approvals = new Map<string, Set<string>>();
14
-
15
- /** Record a directory prefix as approved for the given surface. */
16
- approve(surface: string, prefix: string): void {
17
- let prefixes = this.approvals.get(surface);
18
- if (!prefixes) {
19
- prefixes = new Set();
20
- this.approvals.set(surface, prefixes);
21
- }
22
- prefixes.add(prefix);
23
- }
24
-
25
- /**
26
- * Check whether a path falls under any approved prefix for the given surface.
27
- * Uses `isPathWithinDirectory()` for correct separator-aware prefix matching.
28
- */
29
- has(surface: string, path: string): boolean {
30
- const prefixes = this.approvals.get(surface);
31
- if (!prefixes) {
32
- return false;
33
- }
34
- for (const prefix of prefixes) {
35
- if (isPathWithinDirectory(path, prefix)) {
36
- return true;
37
- }
38
- }
39
- return false;
40
- }
41
-
42
- /** Find and return the matching approved prefix, or null if none matches. */
43
- findMatchingPrefix(surface: string, path: string): string | null {
44
- const prefixes = this.approvals.get(surface);
45
- if (!prefixes) {
46
- return null;
47
- }
48
- for (const prefix of prefixes) {
49
- if (isPathWithinDirectory(path, prefix)) {
50
- return prefix;
51
- }
52
- }
53
- return null;
54
- }
55
-
56
- /** Remove all session approvals. */
57
- clear(): void {
58
- this.approvals.clear();
59
- }
60
- }
61
-
62
- /**
63
- * Derive the directory prefix to approve from a normalized path.
64
- * Returns `dirname(path)` with a trailing separator so that
65
- * prefix matching via `isPathWithinDirectory()` works correctly.
66
- *
67
- * For paths that already end with a separator (directories),
68
- * the trailing separator is stripped by dirname and re-added.
69
- */
70
- export function deriveApprovalPrefix(normalizedPath: string): string {
71
- // If the path already ends with a separator, it's a directory — return as-is.
72
- if (normalizedPath.endsWith(sep)) {
73
- return normalizedPath;
74
- }
75
- const dir = dirname(normalizedPath);
76
- if (dir === normalizedPath) {
77
- // Root path — dirname('/') === '/'
78
- return dir;
79
- }
80
- return dir.endsWith(sep) ? dir : `${dir}${sep}`;
81
- }
@@ -1,142 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
-
3
- import type { PermissionState } from "../src/types";
4
-
5
- // Mock wildcard-matcher before importing the module under test.
6
- vi.mock("../src/wildcard-matcher.js", () => ({
7
- compileWildcardPatterns: vi.fn((patterns: Record<string, PermissionState>) =>
8
- Object.entries(patterns).map(([pattern, state]) => ({
9
- pattern,
10
- state,
11
- regex: new RegExp(`^${pattern.replace(/\*/g, ".*")}$`),
12
- })),
13
- ),
14
- findCompiledWildcardMatch: vi.fn(),
15
- }));
16
-
17
- import { BashFilter } from "../src/bash-filter";
18
- import {
19
- compileWildcardPatterns,
20
- findCompiledWildcardMatch,
21
- } from "../src/wildcard-matcher";
22
-
23
- const mockedCompilePatterns = vi.mocked(compileWildcardPatterns);
24
- const mockedFindMatch = vi.mocked(findCompiledWildcardMatch);
25
-
26
- beforeEach(() => {
27
- mockedCompilePatterns.mockClear();
28
- mockedFindMatch.mockReset();
29
- });
30
-
31
- afterEach(() => {
32
- vi.restoreAllMocks();
33
- });
34
-
35
- describe("BashFilter.check", () => {
36
- test("returns matched state when wildcard-matcher finds a pattern match", () => {
37
- mockedFindMatch.mockReturnValue({
38
- state: "allow",
39
- matchedPattern: "git *",
40
- matchedName: "git status",
41
- });
42
-
43
- const filter = new BashFilter({ "git *": "allow" }, "ask");
44
- const result = filter.check("git status");
45
-
46
- expect(result.state).toBe("allow");
47
- expect(result.matchedPattern).toBe("git *");
48
- expect(result.command).toBe("git status");
49
- expect(mockedFindMatch).toHaveBeenCalledOnce();
50
- });
51
-
52
- test("returns default state when wildcard-matcher returns null", () => {
53
- mockedFindMatch.mockReturnValue(null);
54
-
55
- const filter = new BashFilter({ "git *": "allow" }, "ask");
56
- const result = filter.check("npm install");
57
-
58
- expect(result.state).toBe("ask");
59
- expect(result.matchedPattern).toBeUndefined();
60
- expect(result.command).toBe("npm install");
61
- });
62
-
63
- test("delegates pattern compilation to compileWildcardPatterns", () => {
64
- mockedFindMatch.mockReturnValue(null);
65
-
66
- const permissions: Record<string, PermissionState> = {
67
- "git *": "allow",
68
- "npm *": "deny",
69
- };
70
- new BashFilter(permissions, "ask");
71
-
72
- expect(compileWildcardPatterns).toHaveBeenCalledWith(permissions);
73
- });
74
-
75
- test("default fallback is the configured defaultState", () => {
76
- mockedFindMatch.mockReturnValue(null);
77
-
78
- const denyFilter = new BashFilter({}, "deny");
79
- expect(denyFilter.check("anything").state).toBe("deny");
80
-
81
- const allowFilter = new BashFilter({}, "allow");
82
- expect(allowFilter.check("anything").state).toBe("allow");
83
- });
84
-
85
- test("passes command string to findCompiledWildcardMatch", () => {
86
- mockedFindMatch.mockReturnValue(null);
87
-
88
- const filter = new BashFilter({}, "ask");
89
- filter.check("echo hello");
90
-
91
- expect(mockedFindMatch).toHaveBeenCalledWith(
92
- expect.any(Array),
93
- "echo hello",
94
- );
95
- });
96
-
97
- test("empty command falls through to default state", () => {
98
- mockedFindMatch.mockReturnValue(null);
99
-
100
- const filter = new BashFilter({}, "ask");
101
- const result = filter.check("");
102
-
103
- expect(result.state).toBe("ask");
104
- expect(result.command).toBe("");
105
- });
106
-
107
- test("accepts pre-compiled pattern list instead of permissions object", () => {
108
- mockedFindMatch.mockReturnValue({
109
- state: "deny",
110
- matchedPattern: "rm *",
111
- matchedName: "rm -rf /",
112
- });
113
-
114
- const compiledPatterns = [
115
- {
116
- pattern: "rm *",
117
- state: "deny" as const,
118
- regex: /^rm .*$/,
119
- },
120
- ];
121
- const filter = new BashFilter(compiledPatterns, "ask");
122
- const result = filter.check("rm -rf /");
123
-
124
- // compileWildcardPatterns should NOT be called for a pre-compiled list
125
- expect(compileWildcardPatterns).not.toHaveBeenCalled();
126
- expect(result.state).toBe("deny");
127
- });
128
-
129
- test("last-match-wins: matched pattern state overrides default", () => {
130
- mockedFindMatch.mockReturnValue({
131
- state: "deny",
132
- matchedPattern: "rm *",
133
- matchedName: "rm -rf /",
134
- });
135
-
136
- const filter = new BashFilter({ "rm *": "deny" }, "allow");
137
- const result = filter.check("rm -rf /");
138
-
139
- expect(result.state).toBe("deny");
140
- expect(result.matchedPattern).toBe("rm *");
141
- });
142
- });