@gotgenes/pi-permission-system 3.9.0 → 3.11.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.
@@ -0,0 +1,152 @@
1
+ import type { Rule, Ruleset } from "./rule";
2
+ import type { PermissionDefaultPolicy, PermissionState } from "./types";
3
+
4
+ /**
5
+ * Convert the merged `defaultPolicy` into catch-all rules at the lowest
6
+ * priority position in the composed ruleset.
7
+ *
8
+ * Produces 5 rules:
9
+ * 1. `{ surface: "*", pattern: "*" }` — universal fallback (tools default)
10
+ * 2. `{ surface: "bash", pattern: "*" }` — bash default
11
+ * 3. `{ surface: "mcp", pattern: "*" }` — mcp default
12
+ * 4. `{ surface: "skill", pattern: "*" }` — skill default
13
+ * 5. `{ surface: "special", pattern: "*" }` — special / external_directory default
14
+ *
15
+ * All rules carry `layer: "default"`. `evaluate()` ignores this field.
16
+ * The specific per-surface rules come after the universal rule so they win
17
+ * via last-match-wins when a surface-specific default differs from the
18
+ * tools default.
19
+ */
20
+ export function synthesizeDefaults(defaults: PermissionDefaultPolicy): Ruleset {
21
+ return [
22
+ { surface: "*", pattern: "*", action: defaults.tools, layer: "default" },
23
+ { surface: "bash", pattern: "*", action: defaults.bash, layer: "default" },
24
+ { surface: "mcp", pattern: "*", action: defaults.mcp, layer: "default" },
25
+ {
26
+ surface: "skill",
27
+ pattern: "*",
28
+ action: defaults.skills,
29
+ layer: "default",
30
+ },
31
+ {
32
+ surface: "special",
33
+ pattern: "*",
34
+ action: defaults.special,
35
+ layer: "default",
36
+ },
37
+ ];
38
+ }
39
+
40
+ /**
41
+ * Per-scope override shape — the relevant keys extracted from `tools`.
42
+ * `undefined` means the scope did not configure that override.
43
+ */
44
+ export interface OverrideScope {
45
+ bash?: PermissionState;
46
+ mcp?: PermissionState;
47
+ }
48
+
49
+ /**
50
+ * Convert per-scope `tools.bash` / `tools.mcp` entries into catch-all rules
51
+ * placed between defaults and config rules.
52
+ *
53
+ * Scopes must be passed in precedence order (lowest first, e.g. global →
54
+ * project → agent → project-agent). Later scopes produce later rules and
55
+ * therefore win via last-match-wins — identical to the current last-scope-wins
56
+ * logic for `bashDefault` / `mcpToolLevel`.
57
+ *
58
+ * Only scopes that explicitly define a value contribute a rule; `undefined`
59
+ * entries are skipped.
60
+ *
61
+ * All rules carry `layer: "override"`.
62
+ */
63
+ export function synthesizeOverrides(
64
+ scopes: ReadonlyArray<OverrideScope>,
65
+ ): Ruleset {
66
+ const rules: Rule[] = [];
67
+ for (const scope of scopes) {
68
+ if (scope.bash !== undefined) {
69
+ rules.push({
70
+ surface: "bash",
71
+ pattern: "*",
72
+ action: scope.bash,
73
+ layer: "override",
74
+ });
75
+ }
76
+ if (scope.mcp !== undefined) {
77
+ rules.push({
78
+ surface: "mcp",
79
+ pattern: "*",
80
+ action: scope.mcp,
81
+ layer: "override",
82
+ });
83
+ }
84
+ }
85
+ return rules;
86
+ }
87
+
88
+ /**
89
+ * MCP metadata operation targets that are auto-allowed when any explicit MCP
90
+ * allow rule exists in the config layer.
91
+ */
92
+ const MCP_BASELINE_TARGETS: readonly string[] = [
93
+ "mcp_status",
94
+ "mcp_list",
95
+ "mcp_search",
96
+ "mcp_describe",
97
+ "mcp_connect",
98
+ ];
99
+
100
+ /**
101
+ * Conditionally synthesize MCP baseline auto-allow rules.
102
+ *
103
+ * Emits allow rules for the 5 MCP metadata targets only when `configRules`
104
+ * contains at least one `surface: "mcp", action: "allow"` rule. This replicates
105
+ * the `hasAnyMcpAllowRule` heuristic as actual rules.
106
+ *
107
+ * When `defaults.mcp === "allow"`, the synthesized default catch-all already
108
+ * covers all MCP targets — no separate baseline rules are needed (and this
109
+ * function is not called in that case).
110
+ *
111
+ * Baseline rules are placed BEFORE override rules in the composed array so
112
+ * that `tools.mcp` overrides beat baseline (preserving current behaviour where
113
+ * an explicit `tools.mcp` value always terminates the MCP decision).
114
+ *
115
+ * All rules carry `layer: "baseline"`.
116
+ */
117
+ export function synthesizeBaseline(configRules: Ruleset): Ruleset {
118
+ const hasAnyMcpAllow = configRules.some(
119
+ (r) => r.surface === "mcp" && r.action === "allow",
120
+ );
121
+ if (!hasAnyMcpAllow) {
122
+ return [];
123
+ }
124
+ return MCP_BASELINE_TARGETS.map(
125
+ (target): Rule => ({
126
+ surface: "mcp",
127
+ pattern: target,
128
+ action: "allow",
129
+ layer: "baseline",
130
+ }),
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Concatenate all rule layers into a single flat ruleset.
136
+ *
137
+ * Priority order (lowest → highest, i.e. earlier index → later index):
138
+ * defaults → baseline → overrides → config
139
+ *
140
+ * Session rules are NOT included here — they are appended at call-time inside
141
+ * `checkPermission()` so that the cached composed ruleset remains session-agnostic.
142
+ *
143
+ * `evaluate()` scans from the end, so later layers override earlier ones.
144
+ */
145
+ export function composeRuleset(
146
+ defaults: Ruleset,
147
+ baseline: Ruleset,
148
+ overrides: Ruleset,
149
+ config: Ruleset,
150
+ ): Ruleset {
151
+ return [...defaults, ...baseline, ...overrides, ...config];
152
+ }
package/src/types.ts CHANGED
@@ -41,5 +41,5 @@ export interface PermissionCheckResult {
41
41
  matchedPattern?: string;
42
42
  command?: string;
43
43
  target?: string;
44
- source: "tool" | "bash" | "mcp" | "skill" | "special" | "default";
44
+ source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
45
45
  }
@@ -74,12 +74,11 @@ function makeRuntime(
74
74
  lastActiveToolsCacheKey: null,
75
75
  lastPromptStateCacheKey: null,
76
76
  lastConfigWarning: null,
77
- sessionApprovalCache: {
77
+ sessionRules: {
78
78
  approve: vi.fn(),
79
- has: vi.fn(),
80
- findMatchingPrefix: vi.fn(),
79
+ getRuleset: vi.fn().mockReturnValue([]),
81
80
  clear: vi.fn(),
82
- } as unknown as ExtensionRuntime["sessionApprovalCache"],
81
+ } as unknown as ExtensionRuntime["sessionRules"],
83
82
  permissionForwardingContext: null,
84
83
  permissionForwardingTimer: null,
85
84
  isProcessingForwardedRequests: false,
@@ -53,12 +53,11 @@ function makeRuntime(
53
53
  lastActiveToolsCacheKey: null,
54
54
  lastPromptStateCacheKey: null,
55
55
  lastConfigWarning: null,
56
- sessionApprovalCache: {
56
+ sessionRules: {
57
57
  approve: vi.fn(),
58
- has: vi.fn(),
59
- findMatchingPrefix: vi.fn(),
58
+ getRuleset: vi.fn().mockReturnValue([]),
60
59
  clear: vi.fn(),
61
- } as unknown as ExtensionRuntime["sessionApprovalCache"],
60
+ } as unknown as ExtensionRuntime["sessionRules"],
62
61
  permissionForwardingContext: null,
63
62
  permissionForwardingTimer: null,
64
63
  isProcessingForwardedRequests: false,
@@ -8,7 +8,7 @@ import {
8
8
  import type { HandlerDeps } from "../../src/handlers/types";
9
9
  import type { PermissionManager } from "../../src/permission-manager";
10
10
  import type { ExtensionRuntime } from "../../src/runtime";
11
- import type { SessionApprovalCache } from "../../src/session-approval-cache";
11
+ import type { SessionRules } from "../../src/session-rules";
12
12
  import type { SkillPromptEntry } from "../../src/skill-prompt-sanitizer";
13
13
 
14
14
  // ── active-agent stub ──────────────────────────────────────────────────────
@@ -59,13 +59,12 @@ function makePermissionManager(
59
59
  };
60
60
  }
61
61
 
62
- function makeSessionApprovalCache(): SessionApprovalCache {
62
+ function makeSessionRules(): SessionRules {
63
63
  return {
64
64
  approve: vi.fn(),
65
- has: vi.fn().mockReturnValue(false),
66
- findMatchingPrefix: vi.fn().mockReturnValue(null),
65
+ getRuleset: vi.fn().mockReturnValue([]),
67
66
  clear: vi.fn(),
68
- } as unknown as SessionApprovalCache;
67
+ } as unknown as SessionRules;
69
68
  }
70
69
 
71
70
  function makeRuntime(
@@ -85,7 +84,7 @@ function makeRuntime(
85
84
  lastActiveToolsCacheKey: null,
86
85
  lastPromptStateCacheKey: null,
87
86
  lastConfigWarning: null,
88
- sessionApprovalCache: makeSessionApprovalCache(),
87
+ sessionRules: makeSessionRules(),
89
88
  permissionForwardingContext: null,
90
89
  permissionForwardingTimer: null,
91
90
  isProcessingForwardedRequests: false,
@@ -330,10 +329,10 @@ describe("handleSessionShutdown", () => {
330
329
  expect(deps.runtime.lastPromptStateCacheKey).toBeNull();
331
330
  });
332
331
 
333
- it("clears the session approval cache", async () => {
332
+ it("clears the session rules", async () => {
334
333
  const deps = makeDeps();
335
334
  await handleSessionShutdown(deps);
336
- expect(deps.runtime.sessionApprovalCache.clear).toHaveBeenCalledOnce();
335
+ expect(deps.runtime.sessionRules.clear).toHaveBeenCalledOnce();
337
336
  });
338
337
 
339
338
  it("stops forwarded permission polling", async () => {
@@ -74,12 +74,11 @@ function makeRuntime(
74
74
  lastActiveToolsCacheKey: null,
75
75
  lastPromptStateCacheKey: null,
76
76
  lastConfigWarning: null,
77
- sessionApprovalCache: {
77
+ sessionRules: {
78
78
  approve: vi.fn(),
79
- has: vi.fn().mockReturnValue(false),
80
- findMatchingPrefix: vi.fn().mockReturnValue(null),
79
+ getRuleset: vi.fn().mockReturnValue([]),
81
80
  clear: vi.fn(),
82
- } as unknown as ExtensionRuntime["sessionApprovalCache"],
81
+ } as unknown as ExtensionRuntime["sessionRules"],
83
82
  permissionForwardingContext: null,
84
83
  permissionForwardingTimer: null,
85
84
  isProcessingForwardedRequests: false,
@@ -339,12 +338,17 @@ describe("handleToolCall — external-directory gate", () => {
339
338
  it("allows when session has an existing approval for the external path", async () => {
340
339
  const deps = makeDeps({
341
340
  runtime: makeRuntime({
342
- sessionApprovalCache: {
341
+ sessionRules: {
343
342
  approve: vi.fn(),
344
- has: vi.fn().mockReturnValue(false),
345
- findMatchingPrefix: vi.fn().mockReturnValue("/outside/project/"),
343
+ getRuleset: vi.fn().mockReturnValue([
344
+ {
345
+ surface: "external_directory",
346
+ pattern: "/outside/project/*",
347
+ action: "allow",
348
+ },
349
+ ]),
346
350
  clear: vi.fn(),
347
- } as unknown as ExtensionRuntime["sessionApprovalCache"],
351
+ } as unknown as ExtensionRuntime["sessionRules"],
348
352
  }),
349
353
  getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
350
354
  });
@@ -359,18 +363,17 @@ describe("handleToolCall — external-directory gate", () => {
359
363
  });
360
364
 
361
365
  it("approves session when user selects approved_for_session", async () => {
362
- const approveCache = {
366
+ const sessionRules = {
363
367
  approve: vi.fn(),
364
- has: vi.fn().mockReturnValue(false),
365
- findMatchingPrefix: vi.fn().mockReturnValue(null),
368
+ getRuleset: vi.fn().mockReturnValue([]),
366
369
  clear: vi.fn(),
367
- } as unknown as ExtensionRuntime["sessionApprovalCache"];
370
+ } as unknown as ExtensionRuntime["sessionRules"];
368
371
  const deps = makeDeps({
369
372
  runtime: makeRuntime({
370
373
  permissionManager: {
371
374
  checkPermission: vi.fn().mockReturnValue(makePermissionResult("ask")),
372
375
  } as unknown as ExtensionRuntime["permissionManager"],
373
- sessionApprovalCache: approveCache,
376
+ sessionRules,
374
377
  }),
375
378
  getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
376
379
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
@@ -385,7 +388,7 @@ describe("handleToolCall — external-directory gate", () => {
385
388
  input: { path: "/outside/project/file.ts" },
386
389
  };
387
390
  await handleToolCall(deps, event, makeCtx());
388
- expect(approveCache.approve).toHaveBeenCalledWith(
391
+ expect(sessionRules.approve).toHaveBeenCalledWith(
389
392
  "external_directory",
390
393
  expect.any(String),
391
394
  );
@@ -419,13 +422,18 @@ describe("handleToolCall — bash external-directory gate", () => {
419
422
  it("skips bash external gate when all referenced paths are session-approved", async () => {
420
423
  const deps = makeDeps({
421
424
  runtime: makeRuntime({
422
- sessionApprovalCache: {
425
+ sessionRules: {
423
426
  approve: vi.fn(),
424
- // All paths are covered
425
- has: vi.fn().mockReturnValue(true),
426
- findMatchingPrefix: vi.fn().mockReturnValue(null),
427
+ // /outside/project/* covers /outside/project/file.ts
428
+ getRuleset: vi.fn().mockReturnValue([
429
+ {
430
+ surface: "external_directory",
431
+ pattern: "/outside/project/*",
432
+ action: "allow",
433
+ },
434
+ ]),
427
435
  clear: vi.fn(),
428
- } as unknown as ExtensionRuntime["sessionApprovalCache"],
436
+ } as unknown as ExtensionRuntime["sessionRules"],
429
437
  }),
430
438
  getAllTools: vi.fn().mockReturnValue([{ name: "bash" }]),
431
439
  });
@@ -46,7 +46,11 @@ import {
46
46
  checkRequestedToolRegistration,
47
47
  getToolNameFromValue,
48
48
  } from "../src/tool-registry";
49
- import type { PermissionState, ScopeConfig } from "../src/types";
49
+ import type {
50
+ PermissionCheckResult,
51
+ PermissionState,
52
+ ScopeConfig,
53
+ } from "../src/types";
50
54
  import {
51
55
  canResolveAskPermissionRequest,
52
56
  shouldAutoApprovePermissionState,
@@ -2969,3 +2973,193 @@ test("session approval: regular 'Yes' does not create session approval", async (
2969
2973
  rmSync(rootDir, { recursive: true, force: true });
2970
2974
  }
2971
2975
  });
2976
+
2977
+ // ---------------------------------------------------------------------------
2978
+ // Session-aware checkPermission() integration
2979
+ // ---------------------------------------------------------------------------
2980
+
2981
+ test("checkPermission returns source 'session' when session rules cover the external_directory path", () => {
2982
+ const { manager, cleanup } = createManager({
2983
+ defaultPolicy: {
2984
+ tools: "allow",
2985
+ bash: "allow",
2986
+ mcp: "allow",
2987
+ skills: "allow",
2988
+ special: "ask",
2989
+ },
2990
+ });
2991
+
2992
+ try {
2993
+ const sessionRules = [
2994
+ {
2995
+ surface: "external_directory",
2996
+ pattern: "/other/project/*",
2997
+ action: "allow" as const,
2998
+ layer: "session" as const,
2999
+ },
3000
+ ];
3001
+
3002
+ const result = manager.checkPermission(
3003
+ "external_directory",
3004
+ { path: "/other/project/src/foo.ts" },
3005
+ undefined,
3006
+ sessionRules,
3007
+ );
3008
+ assert.equal(result.state, "allow");
3009
+ assert.equal(result.source, "session");
3010
+ assert.equal(result.matchedPattern, "/other/project/*");
3011
+ } finally {
3012
+ cleanup();
3013
+ }
3014
+ });
3015
+
3016
+ test("checkPermission falls back to config policy when session rules do not cover the path", () => {
3017
+ const { manager, cleanup } = createManager({
3018
+ defaultPolicy: {
3019
+ tools: "allow",
3020
+ bash: "allow",
3021
+ mcp: "allow",
3022
+ skills: "allow",
3023
+ special: "deny",
3024
+ },
3025
+ });
3026
+
3027
+ try {
3028
+ const sessionRules = [
3029
+ {
3030
+ surface: "external_directory",
3031
+ pattern: "/other/project/*",
3032
+ action: "allow" as const,
3033
+ layer: "session" as const,
3034
+ },
3035
+ ];
3036
+
3037
+ // Path NOT under /other/project/ — session rules don't match.
3038
+ const result = manager.checkPermission(
3039
+ "external_directory",
3040
+ { path: "/completely/different/path.ts" },
3041
+ undefined,
3042
+ sessionRules,
3043
+ );
3044
+ assert.equal(result.state, "deny");
3045
+ assert.equal(result.source, "special");
3046
+ } finally {
3047
+ cleanup();
3048
+ }
3049
+ });
3050
+
3051
+ test("checkPermission with empty session rules is identical to call without sessionRules arg", () => {
3052
+ const { manager, cleanup } = createManager({
3053
+ defaultPolicy: {
3054
+ tools: "allow",
3055
+ bash: "allow",
3056
+ mcp: "allow",
3057
+ skills: "allow",
3058
+ special: "ask",
3059
+ },
3060
+ special: { external_directory: "deny" },
3061
+ });
3062
+
3063
+ try {
3064
+ const withEmpty = manager.checkPermission(
3065
+ "external_directory",
3066
+ { path: "/other/project/foo.ts" },
3067
+ undefined,
3068
+ [],
3069
+ );
3070
+ const withoutArg = manager.checkPermission("external_directory", {
3071
+ path: "/other/project/foo.ts",
3072
+ });
3073
+ const expected: PermissionCheckResult = {
3074
+ toolName: "external_directory",
3075
+ state: "deny",
3076
+ matchedPattern: "external_directory",
3077
+ source: "special",
3078
+ };
3079
+ assert.deepEqual(withEmpty, expected);
3080
+ assert.deepEqual(withoutArg, expected);
3081
+ } finally {
3082
+ cleanup();
3083
+ }
3084
+ });
3085
+
3086
+ test("session rules for one surface do not affect checks on other surfaces", () => {
3087
+ const { manager, cleanup } = createManager({
3088
+ defaultPolicy: {
3089
+ tools: "ask",
3090
+ bash: "ask",
3091
+ mcp: "ask",
3092
+ skills: "ask",
3093
+ special: "ask",
3094
+ },
3095
+ });
3096
+
3097
+ try {
3098
+ const sessionRules = [
3099
+ {
3100
+ surface: "external_directory",
3101
+ pattern: "/other/project/*",
3102
+ action: "allow" as const,
3103
+ layer: "session" as const,
3104
+ },
3105
+ ];
3106
+
3107
+ // Bash check — session rules should not affect bash decisions.
3108
+ const bashResult = manager.checkPermission(
3109
+ "bash",
3110
+ { command: "git status" },
3111
+ undefined,
3112
+ sessionRules,
3113
+ );
3114
+ assert.equal(bashResult.state, "ask");
3115
+ assert.equal(bashResult.source, "bash");
3116
+
3117
+ // MCP check — session rules should not affect MCP decisions.
3118
+ const mcpResult = manager.checkPermission(
3119
+ "mcp",
3120
+ { tool: "exa:search" },
3121
+ undefined,
3122
+ sessionRules,
3123
+ );
3124
+ assert.equal(mcpResult.state, "ask");
3125
+ assert.equal(mcpResult.source, "default");
3126
+ } finally {
3127
+ cleanup();
3128
+ }
3129
+ });
3130
+
3131
+ test("session rules override config deny for external_directory", () => {
3132
+ const { manager, cleanup } = createManager({
3133
+ defaultPolicy: {
3134
+ tools: "allow",
3135
+ bash: "allow",
3136
+ mcp: "allow",
3137
+ skills: "allow",
3138
+ special: "ask",
3139
+ },
3140
+ special: { external_directory: "deny" },
3141
+ });
3142
+
3143
+ try {
3144
+ const sessionRules = [
3145
+ {
3146
+ surface: "external_directory",
3147
+ pattern: "/other/project/*",
3148
+ action: "allow" as const,
3149
+ layer: "session" as const,
3150
+ },
3151
+ ];
3152
+
3153
+ // Session approval overrides config deny for the covered path.
3154
+ const result = manager.checkPermission(
3155
+ "external_directory",
3156
+ { path: "/other/project/src/foo.ts" },
3157
+ undefined,
3158
+ sessionRules,
3159
+ );
3160
+ assert.equal(result.state, "allow");
3161
+ assert.equal(result.source, "session");
3162
+ } finally {
3163
+ cleanup();
3164
+ }
3165
+ });
@@ -137,4 +137,35 @@ describe("evaluate", () => {
137
137
  expect(result.pattern).toBe("git status");
138
138
  expect(result.action).toBe("ask");
139
139
  });
140
+
141
+ test("rule.layer is ignored by evaluate() — matching is identical with or without it", () => {
142
+ const withLayer: Rule = {
143
+ surface: "bash",
144
+ pattern: "git *",
145
+ action: "allow",
146
+ layer: "config",
147
+ };
148
+ const withoutLayer: Rule = {
149
+ surface: "bash",
150
+ pattern: "git *",
151
+ action: "allow",
152
+ };
153
+ const withDefault: Rule = {
154
+ surface: "bash",
155
+ pattern: "*",
156
+ action: "ask",
157
+ layer: "default",
158
+ };
159
+ // Both rules with and without layer field produce the same match.
160
+ expect(evaluate("bash", "git status", [withLayer]).action).toBe("allow");
161
+ expect(evaluate("bash", "git status", [withoutLayer]).action).toBe("allow");
162
+ // Layer metadata does not affect last-match-wins ordering.
163
+ const ruleset: Rule[] = [withDefault, withLayer];
164
+ expect(evaluate("bash", "git status", ruleset)).toEqual(withLayer);
165
+ // A rule with layer: "default" still wins if it is last in the array.
166
+ const reversedRuleset: Rule[] = [withLayer, withDefault];
167
+ expect(evaluate("bash", "git status", reversedRuleset)).toEqual(
168
+ withDefault,
169
+ );
170
+ });
140
171
  });
@@ -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 ──────────────────────────────────────────