@gotgenes/pi-permission-system 3.5.0 → 3.7.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.
@@ -12,6 +12,8 @@ import {
12
12
  } from "./common";
13
13
  import { loadUnifiedConfig, stripJsonComments } from "./config-loader";
14
14
  import { getGlobalConfigPath } from "./config-paths";
15
+ import type { Rule, Ruleset } from "./rule";
16
+ import { evaluate } from "./rule";
15
17
  import type {
16
18
  AgentPermissions,
17
19
  BashPermissions,
@@ -375,6 +377,8 @@ type ResolvedPermissions = {
375
377
  globalConfig: GlobalPermissionConfig;
376
378
  agentConfig: AgentPermissions;
377
379
  merged: GlobalPermissionConfig;
380
+ compiledBash: CompiledPermissionPatterns;
381
+ bashDefault: PermissionState;
378
382
  compiledSpecial: CompiledPermissionPatterns;
379
383
  compiledSkills: CompiledPermissionPatterns;
380
384
  compiledMcp: CompiledPermissionPatterns;
@@ -403,6 +407,21 @@ function compilePermissionPatternsFromSources(
403
407
  return compileWildcardPatternEntries(entries);
404
408
  }
405
409
 
410
+ /**
411
+ * Convert compiled wildcard patterns into a Ruleset for use with evaluate().
412
+ * The returned Rule objects are the same references as the input; evaluate()
413
+ * uses reference equality to distinguish an explicit match from the synthetic
414
+ * default it returns when nothing matches.
415
+ */
416
+ function compiledToRuleset(
417
+ surface: string,
418
+ patterns: CompiledPermissionPatterns,
419
+ ): Ruleset {
420
+ return patterns.map(
421
+ (p): Rule => ({ surface, pattern: p.pattern, action: p.state }),
422
+ );
423
+ }
424
+
406
425
  function findCompiledPermissionMatch(
407
426
  patterns: CompiledPermissionPatterns,
408
427
  name: string,
@@ -701,10 +720,18 @@ export class PermissionManager {
701
720
  projectConfig.tools?.bash ||
702
721
  merged.tools?.bash ||
703
722
  merged.defaultPolicy.bash;
723
+ const compiledBash = compilePermissionPatternsFromSources(
724
+ globalConfig.bash,
725
+ projectConfig.bash,
726
+ agentConfig.bash,
727
+ projectAgentConfig.bash,
728
+ );
704
729
  const value: ResolvedPermissions = {
705
730
  globalConfig,
706
731
  agentConfig,
707
732
  merged,
733
+ compiledBash,
734
+ bashDefault,
708
735
  compiledSpecial: compilePermissionPatternsFromSources(
709
736
  globalConfig.special,
710
737
  projectConfig.special,
@@ -723,15 +750,7 @@ export class PermissionManager {
723
750
  agentConfig.mcp,
724
751
  projectAgentConfig.mcp,
725
752
  ),
726
- bashFilter: new BashFilter(
727
- compilePermissionPatternsFromSources(
728
- globalConfig.bash,
729
- projectConfig.bash,
730
- agentConfig.bash,
731
- projectAgentConfig.bash,
732
- ),
733
- bashDefault,
734
- ),
753
+ bashFilter: new BashFilter(compiledBash, bashDefault),
735
754
  };
736
755
 
737
756
  this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
@@ -804,22 +823,23 @@ export class PermissionManager {
804
823
  const {
805
824
  agentConfig: _agentConfig,
806
825
  merged,
826
+ compiledBash,
827
+ bashDefault,
807
828
  compiledSpecial,
808
829
  compiledSkills,
809
830
  compiledMcp,
810
- bashFilter,
831
+ bashFilter: _bashFilter,
811
832
  } = this.resolvePermissions(agentName);
812
833
  const normalizedToolName = toolName.trim();
813
834
 
814
835
  if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
815
- const result = findCompiledPermissionMatch(
816
- compiledSpecial,
817
- normalizedToolName,
818
- );
836
+ const specialRuleset = compiledToRuleset("special", compiledSpecial);
837
+ const rule = evaluate("special", normalizedToolName, specialRuleset);
838
+ const explicit = specialRuleset.includes(rule);
819
839
  return {
820
840
  toolName,
821
- state: result?.state || merged.defaultPolicy.special,
822
- matchedPattern: result?.matchedPattern,
841
+ state: explicit ? rule.action : merged.defaultPolicy.special,
842
+ matchedPattern: explicit ? rule.pattern : undefined,
823
843
  source: "special",
824
844
  };
825
845
  }
@@ -827,15 +847,16 @@ export class PermissionManager {
827
847
  if (normalizedToolName === "skill") {
828
848
  const skillName = toRecord(input).name;
829
849
  if (typeof skillName === "string") {
830
- const result = findCompiledPermissionMatch(compiledSkills, skillName);
850
+ const skillRuleset = compiledToRuleset("skill", compiledSkills);
851
+ const rule = evaluate("skill", skillName, skillRuleset);
852
+ const explicit = skillRuleset.includes(rule);
831
853
  return {
832
854
  toolName,
833
- state: result?.state || merged.defaultPolicy.skills,
834
- matchedPattern: result?.matchedPattern,
855
+ state: explicit ? rule.action : merged.defaultPolicy.skills,
856
+ matchedPattern: explicit ? rule.pattern : undefined,
835
857
  source: "skill",
836
858
  };
837
859
  }
838
-
839
860
  return {
840
861
  toolName,
841
862
  state: merged.defaultPolicy.skills,
@@ -846,13 +867,14 @@ export class PermissionManager {
846
867
  if (normalizedToolName === "bash") {
847
868
  const record = toRecord(input);
848
869
  const command = typeof record.command === "string" ? record.command : "";
849
- const result = bashFilter.check(command);
850
-
870
+ const bashRuleset = compiledToRuleset("bash", compiledBash);
871
+ const rule = evaluate("bash", command, bashRuleset);
872
+ const explicit = bashRuleset.includes(rule);
851
873
  return {
852
874
  toolName,
853
- state: result.state,
854
- command: result.command,
855
- matchedPattern: result.matchedPattern,
875
+ state: explicit ? rule.action : bashDefault,
876
+ command,
877
+ matchedPattern: explicit ? rule.pattern : undefined,
856
878
  source: "bash",
857
879
  };
858
880
  }
@@ -868,16 +890,21 @@ export class PermissionManager {
868
890
  const fallbackTarget = mcpTargets[0] || "mcp";
869
891
  const toolLevelMcpState = merged.tools?.mcp;
870
892
 
871
- const mcpMatch = findCompiledPermissionMatchForNames(
872
- compiledMcp,
873
- mcpTargets,
874
- );
875
- if (mcpMatch) {
893
+ const mcpRuleset = compiledToRuleset("mcp", compiledMcp);
894
+ let mcpExplicitMatch: { target: string; rule: Rule } | null = null;
895
+ for (const target of mcpTargets) {
896
+ const rule = evaluate("mcp", target, mcpRuleset);
897
+ if (mcpRuleset.includes(rule)) {
898
+ mcpExplicitMatch = { target, rule };
899
+ break;
900
+ }
901
+ }
902
+ if (mcpExplicitMatch) {
876
903
  return {
877
904
  toolName,
878
- state: mcpMatch.state,
879
- matchedPattern: mcpMatch.matchedPattern,
880
- target: mcpMatch.matchedName,
905
+ state: mcpExplicitMatch.rule.action,
906
+ matchedPattern: mcpExplicitMatch.rule.pattern,
907
+ target: mcpExplicitMatch.target,
881
908
  source: "mcp",
882
909
  };
883
910
  }
@@ -916,19 +943,24 @@ export class PermissionManager {
916
943
  };
917
944
  }
918
945
 
946
+ const toolRuleset: Ruleset = Object.entries(merged.tools ?? {}).map(
947
+ ([name, action]) => ({ surface: "tool", pattern: name, action }),
948
+ );
949
+ const toolRule = evaluate("tool", normalizedToolName, toolRuleset);
950
+ const explicitTool = toolRuleset.includes(toolRule);
951
+
919
952
  if (BUILT_IN_TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
920
953
  return {
921
954
  toolName,
922
- state: merged.tools?.[normalizedToolName] || merged.defaultPolicy.tools,
955
+ state: explicitTool ? toolRule.action : merged.defaultPolicy.tools,
923
956
  source: "tool",
924
957
  };
925
958
  }
926
959
 
927
- const explicitToolPermission = merged.tools?.[normalizedToolName];
928
- if (explicitToolPermission) {
960
+ if (explicitTool) {
929
961
  return {
930
962
  toolName,
931
- state: explicitToolPermission,
963
+ state: toolRule.action,
932
964
  source: "tool",
933
965
  };
934
966
  }
package/src/rule.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { PermissionState } from "./types";
2
+ import { wildcardMatch } from "./wildcard-matcher";
3
+
4
+ /** A single permission rule — the atomic unit of policy. */
5
+ export interface Rule {
6
+ /** The permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
7
+ surface: string;
8
+ /** The match pattern: a command glob, tool name, skill name, or "*". */
9
+ pattern: string;
10
+ /** The permission decision. */
11
+ action: PermissionState;
12
+ }
13
+
14
+ /** An ordered list of rules. Later rules take priority (last-match-wins). */
15
+ export type Ruleset = Rule[];
16
+
17
+ const SURFACE_DEFAULTS: Record<string, PermissionState> = {
18
+ tools: "ask",
19
+ bash: "ask",
20
+ mcp: "ask",
21
+ skill: "ask",
22
+ special: "ask",
23
+ };
24
+
25
+ /**
26
+ * Returns the default action for a surface when no rules match.
27
+ * Defaults to "ask" for unknown surfaces (least privilege).
28
+ */
29
+ export function getDefaultAction(surface: string): PermissionState {
30
+ return SURFACE_DEFAULTS[surface] ?? "ask";
31
+ }
32
+
33
+ /**
34
+ * Pure permission evaluation.
35
+ *
36
+ * Flattens all provided rulesets and returns the last rule whose surface and
37
+ * pattern both wildcard-match the supplied values (last-match-wins, so later
38
+ * rulesets / later entries have higher priority).
39
+ *
40
+ * When no rule matches, returns a synthetic rule using getDefaultAction().
41
+ */
42
+ export function evaluate(
43
+ surface: string,
44
+ pattern: string,
45
+ ...rulesets: Ruleset[]
46
+ ): Rule {
47
+ const rules = rulesets.flat();
48
+ for (let i = rules.length - 1; i >= 0; i -= 1) {
49
+ const rule = rules[i];
50
+ if (
51
+ wildcardMatch(rule.surface, surface) &&
52
+ wildcardMatch(rule.pattern, pattern)
53
+ ) {
54
+ return rule;
55
+ }
56
+ }
57
+ return { surface, pattern, action: getDefaultAction(surface) };
58
+ }
@@ -62,6 +62,15 @@ export function findCompiledWildcardMatch<TState>(
62
62
  return null;
63
63
  }
64
64
 
65
+ /**
66
+ * Test whether `value` matches `pattern` using wildcard rules.
67
+ * `*` in the pattern matches any sequence of characters (including empty).
68
+ * Used by evaluate() for rule matching.
69
+ */
70
+ export function wildcardMatch(pattern: string, value: string): boolean {
71
+ return compileWildcardPattern(pattern, null).regex.test(value);
72
+ }
73
+
65
74
  export function findCompiledWildcardMatchForNames<TState>(
66
75
  patterns: readonly CompiledWildcardPattern<TState>[],
67
76
  names: readonly string[],
@@ -0,0 +1,274 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import {
5
+ handleBeforeAgentStart,
6
+ shouldExposeTool,
7
+ } from "../../src/handlers/before-agent-start";
8
+ import type { HandlerDeps } from "../../src/handlers/types";
9
+ import type { PermissionManager } from "../../src/permission-manager";
10
+ import type { SkillPromptEntry } from "../../src/skill-prompt-sanitizer";
11
+
12
+ // ── SDK stubs ──────────────────────────────────────────────────────────────
13
+ vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
14
+ const original =
15
+ await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
16
+ return {
17
+ ...original,
18
+ isToolCallEventType: vi.fn().mockReturnValue(false),
19
+ };
20
+ });
21
+
22
+ // ── helpers ────────────────────────────────────────────────────────────────
23
+
24
+ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
25
+ return {
26
+ cwd: "/test/project",
27
+ hasUI: true,
28
+ ui: {
29
+ setStatus: vi.fn(),
30
+ notify: vi.fn(),
31
+ select: vi.fn(),
32
+ input: vi.fn(),
33
+ },
34
+ sessionManager: {
35
+ getEntries: vi.fn().mockReturnValue([]),
36
+ getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
37
+ addEntry: vi.fn(),
38
+ },
39
+ ...overrides,
40
+ } as unknown as ExtensionContext;
41
+ }
42
+
43
+ function makeEvent(systemPrompt = "You are an assistant.") {
44
+ return { systemPrompt };
45
+ }
46
+
47
+ /** Minimal PermissionManager stub for shouldExposeTool / policy-cache tests. */
48
+ function makePm(
49
+ toolPermission: "allow" | "deny" | "ask" = "allow",
50
+ ): PermissionManager {
51
+ return {
52
+ getToolPermission: vi.fn().mockReturnValue(toolPermission),
53
+ getPolicyCacheStamp: vi.fn().mockReturnValue("stamp-1"),
54
+ getConfigIssues: vi.fn().mockReturnValue([]),
55
+ checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
56
+ } as unknown as PermissionManager;
57
+ }
58
+
59
+ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
60
+ const pm = makePm();
61
+ return {
62
+ getPermissionManager: vi.fn().mockReturnValue(pm),
63
+ setPermissionManager: vi.fn(),
64
+ getRuntimeContext: vi.fn().mockReturnValue(null),
65
+ setRuntimeContext: vi.fn(),
66
+ getActiveSkillEntries: vi.fn().mockReturnValue([] as SkillPromptEntry[]),
67
+ setActiveSkillEntries: vi.fn(),
68
+ getLastKnownActiveAgentName: vi.fn().mockReturnValue(null),
69
+ setLastKnownActiveAgentName: vi.fn(),
70
+ getLastActiveToolsCacheKey: vi.fn().mockReturnValue(null),
71
+ setLastActiveToolsCacheKey: vi.fn(),
72
+ getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
73
+ setLastPromptStateCacheKey: vi.fn(),
74
+ sessionApprovalCache: {
75
+ approve: vi.fn(),
76
+ has: vi.fn(),
77
+ findMatchingPrefix: vi.fn(),
78
+ clear: vi.fn(),
79
+ } as unknown as HandlerDeps["sessionApprovalCache"],
80
+ createPermissionManagerForCwd: vi.fn().mockReturnValue(makePm()),
81
+ refreshExtensionConfig: vi.fn(),
82
+ notifyWarning: vi.fn(),
83
+ logResolvedConfigPaths: vi.fn(),
84
+ resolveAgentName: vi.fn().mockReturnValue(null),
85
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
86
+ promptPermission: vi
87
+ .fn()
88
+ .mockResolvedValue({ approved: true, state: "approved" }),
89
+ createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
90
+ startForwardedPermissionPolling: vi.fn(),
91
+ stopForwardedPermissionPolling: vi.fn(),
92
+ writeReviewLog: vi.fn(),
93
+ writeDebugLog: vi.fn(),
94
+ getAllTools: vi.fn().mockReturnValue([]),
95
+ setActiveTools: vi.fn(),
96
+ ...overrides,
97
+ };
98
+ }
99
+
100
+ // ── shouldExposeTool (pure helper) ─────────────────────────────────────────
101
+
102
+ describe("shouldExposeTool", () => {
103
+ it("returns true when tool permission is allow", () => {
104
+ const pm = makePm("allow");
105
+ expect(shouldExposeTool("read", null, pm)).toBe(true);
106
+ });
107
+
108
+ it("returns true when tool permission is ask", () => {
109
+ const pm = makePm("ask");
110
+ expect(shouldExposeTool("bash", "agent-x", pm)).toBe(true);
111
+ });
112
+
113
+ it("returns false when tool permission is deny", () => {
114
+ const pm = makePm("deny");
115
+ expect(shouldExposeTool("write", null, pm)).toBe(false);
116
+ });
117
+
118
+ it("passes agentName through to getToolPermission", () => {
119
+ const pm = makePm("allow");
120
+ shouldExposeTool("read", "my-agent", pm);
121
+ expect(pm.getToolPermission).toHaveBeenCalledWith("read", "my-agent");
122
+ });
123
+
124
+ it("converts null agentName to undefined for getToolPermission", () => {
125
+ const pm = makePm("allow");
126
+ shouldExposeTool("read", null, pm);
127
+ expect(pm.getToolPermission).toHaveBeenCalledWith("read", undefined);
128
+ });
129
+ });
130
+
131
+ // ── handleBeforeAgentStart ─────────────────────────────────────────────────
132
+
133
+ describe("handleBeforeAgentStart", () => {
134
+ it("refreshes extension config with ctx", async () => {
135
+ const ctx = makeCtx();
136
+ const deps = makeDeps();
137
+ await handleBeforeAgentStart(deps, makeEvent(), ctx);
138
+ expect(deps.refreshExtensionConfig).toHaveBeenCalledWith(ctx);
139
+ });
140
+
141
+ it("starts forwarded permission polling", async () => {
142
+ const ctx = makeCtx();
143
+ const deps = makeDeps();
144
+ await handleBeforeAgentStart(deps, makeEvent(), ctx);
145
+ expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
146
+ });
147
+
148
+ it("resolves agent name using systemPrompt", async () => {
149
+ const ctx = makeCtx();
150
+ const deps = makeDeps();
151
+ await handleBeforeAgentStart(
152
+ deps,
153
+ makeEvent("<active_agent name='x'>"),
154
+ ctx,
155
+ );
156
+ expect(deps.resolveAgentName).toHaveBeenCalledWith(
157
+ ctx,
158
+ "<active_agent name='x'>",
159
+ );
160
+ });
161
+
162
+ it("filters out denied tools from allowed list", async () => {
163
+ const pm = makePm("deny");
164
+ const deps = makeDeps({
165
+ getPermissionManager: vi.fn().mockReturnValue(pm),
166
+ getAllTools: vi
167
+ .fn()
168
+ .mockReturnValue([{ name: "write" }, { name: "read" }]),
169
+ });
170
+ // write is deny, read is deny (same pm stub — both denied)
171
+ await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
172
+ expect(deps.setActiveTools).toHaveBeenCalledWith([]);
173
+ });
174
+
175
+ it("includes allowed and ask tools in the active list", async () => {
176
+ const pm = makePm("allow");
177
+ const deps = makeDeps({
178
+ getPermissionManager: vi.fn().mockReturnValue(pm),
179
+ getAllTools: vi
180
+ .fn()
181
+ .mockReturnValue([{ name: "read" }, { name: "write" }]),
182
+ });
183
+ await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
184
+ expect(deps.setActiveTools).toHaveBeenCalledWith(["read", "write"]);
185
+ });
186
+
187
+ it("updates the active-tools cache key after applying", async () => {
188
+ const deps = makeDeps({
189
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
190
+ getLastActiveToolsCacheKey: vi.fn().mockReturnValue(null),
191
+ });
192
+ await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
193
+ expect(deps.setLastActiveToolsCacheKey).toHaveBeenCalledOnce();
194
+ });
195
+
196
+ it("skips setActiveTools when cache key is unchanged", async () => {
197
+ // Pre-populate the cache key to match what would be computed for ["read"]
198
+ const { createActiveToolsCacheKey } = await import(
199
+ "../../src/before-agent-start-cache"
200
+ );
201
+ const key = createActiveToolsCacheKey(["read"]);
202
+ const deps = makeDeps({
203
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
204
+ getLastActiveToolsCacheKey: vi.fn().mockReturnValue(key),
205
+ });
206
+ await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
207
+ expect(deps.setActiveTools).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it("updates the prompt-state cache key and returns modified systemPrompt", async () => {
211
+ // Provide a systemPrompt that sanitizeAvailableToolsSection will modify:
212
+ // it strips denied tools from the "Available tools:" section.
213
+ const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
214
+ const deps = makeDeps({
215
+ getAllTools: vi.fn().mockReturnValue([]),
216
+ getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
217
+ });
218
+ const result = await handleBeforeAgentStart(
219
+ deps,
220
+ makeEvent(systemPrompt),
221
+ makeCtx(),
222
+ );
223
+ // The prompt was modified, so systemPrompt should be returned
224
+ expect(result).toHaveProperty("systemPrompt");
225
+ expect(deps.setLastPromptStateCacheKey).toHaveBeenCalledOnce();
226
+ });
227
+
228
+ it("returns empty object when systemPrompt is unchanged", async () => {
229
+ const prompt = "No tools section here.";
230
+ const deps = makeDeps({
231
+ getAllTools: vi.fn().mockReturnValue([]),
232
+ getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
233
+ });
234
+ const result = await handleBeforeAgentStart(
235
+ deps,
236
+ makeEvent(prompt),
237
+ makeCtx(),
238
+ );
239
+ expect(result).toEqual({});
240
+ });
241
+
242
+ it("stores resolved skill entries on deps", async () => {
243
+ const deps = makeDeps({
244
+ getAllTools: vi.fn().mockReturnValue([]),
245
+ getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
246
+ });
247
+ await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
248
+ expect(deps.setActiveSkillEntries).toHaveBeenCalledOnce();
249
+ });
250
+
251
+ it("returns empty object and skips prompt work when prompt cache key is unchanged", async () => {
252
+ const { createBeforeAgentStartPromptStateKey } = await import(
253
+ "../../src/before-agent-start-cache"
254
+ );
255
+ const pm = makePm("allow");
256
+ const ctx = makeCtx({ cwd: "/proj" });
257
+ const allowedTools: string[] = ["read"];
258
+ const key = createBeforeAgentStartPromptStateKey({
259
+ agentName: null,
260
+ cwd: "/proj",
261
+ permissionStamp: "stamp-1",
262
+ systemPrompt: "hello",
263
+ allowedToolNames: allowedTools,
264
+ });
265
+ const deps = makeDeps({
266
+ getPermissionManager: vi.fn().mockReturnValue(pm),
267
+ getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
268
+ getLastPromptStateCacheKey: vi.fn().mockReturnValue(key),
269
+ });
270
+ const result = await handleBeforeAgentStart(deps, makeEvent("hello"), ctx);
271
+ expect(result).toEqual({});
272
+ expect(deps.setActiveSkillEntries).not.toHaveBeenCalled();
273
+ });
274
+ });