pi-permission-system 0.1.7 → 0.1.8

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.8] - 2026-03-10
9
+
10
+ ### Changed
11
+ - Refactored pattern compilation to support multiple sources for proper global+agent pattern merging
12
+ - Simplified `wildcard-matcher.ts` by removing unused `wildcardCount` and `literalLength` properties
13
+ - `BashFilter` now accepts pre-compiled patterns via `BashPermissionSource` type
14
+ - Replaced `compilePermissionPatterns` with `compilePermissionPatternsFromSources` for cleaner API
15
+
16
+ ### Fixed
17
+ - Permission pattern priority now correctly implements last-match-wins hierarchy (opencode-style)
18
+ - MCP tool-level deny no longer blocks specific MCP allow patterns
19
+
20
+ ### Tests
21
+ - Updated tests to reflect last-match-wins behavior
22
+ - Added test for specific MCP rules winning over `tools.mcp: deny`
23
+ - Rearranged test pattern declarations for clarity
24
+
8
25
  ## [0.1.7] - 2026-03-10
9
26
 
10
27
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 🔐 pi-permission-system
2
2
 
3
- [![Version](https://img.shields.io/badge/version-0.1.7-blue.svg)](package.json)
3
+ [![Version](https://img.shields.io/badge/version-0.1.8-blue.svg)](package.json)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
5
 
6
6
  Permission enforcement extension for the Pi coding agent that provides centralized, deterministic permission gates for tool, bash, MCP, skill, and special operations.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-permission-system",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -7,6 +7,12 @@ import {
7
7
 
8
8
  type CompiledPattern = CompiledWildcardPattern<PermissionState>;
9
9
 
10
+ type BashPermissionSource = BashPermissions | readonly CompiledPattern[];
11
+
12
+ function isCompiledPatternList(value: BashPermissionSource): value is readonly CompiledPattern[] {
13
+ return Array.isArray(value);
14
+ }
15
+
10
16
  export interface BashPermissionCheck {
11
17
  state: PermissionState;
12
18
  matchedPattern?: string;
@@ -17,10 +23,12 @@ export class BashFilter {
17
23
  private readonly compiledPatterns: CompiledPattern[];
18
24
 
19
25
  constructor(
20
- private readonly permissions: BashPermissions,
26
+ permissions: BashPermissionSource,
21
27
  private readonly defaultState: PermissionState,
22
28
  ) {
23
- this.compiledPatterns = compileWildcardPatterns(permissions);
29
+ this.compiledPatterns = isCompiledPatternList(permissions)
30
+ ? [...permissions]
31
+ : compileWildcardPatterns(permissions);
24
32
  }
25
33
 
26
34
  check(command: string): BashPermissionCheck {
@@ -13,7 +13,7 @@ import type {
13
13
  PermissionState,
14
14
  } from "./types.js";
15
15
  import {
16
- compileWildcardPatterns,
16
+ compileWildcardPatternEntries,
17
17
  findCompiledWildcardMatch,
18
18
  findCompiledWildcardMatchForNames,
19
19
  type CompiledWildcardPattern,
@@ -367,14 +367,26 @@ type ResolvedPermissions = {
367
367
  bashFilter: BashFilter;
368
368
  };
369
369
 
370
- function compilePermissionPatterns(
371
- permissions: Record<string, PermissionState> | undefined,
370
+ function compilePermissionPatternsFromSources(
371
+ ...sources: Array<Record<string, PermissionState> | undefined>
372
372
  ): CompiledPermissionPatterns {
373
- if (!permissions || Object.keys(permissions).length === 0) {
373
+ const entries: Array<readonly [string, PermissionState]> = [];
374
+
375
+ for (const source of sources) {
376
+ if (!source) {
377
+ continue;
378
+ }
379
+
380
+ for (const entry of Object.entries(source)) {
381
+ entries.push(entry);
382
+ }
383
+ }
384
+
385
+ if (entries.length === 0) {
374
386
  return [];
375
387
  }
376
388
 
377
- return compileWildcardPatterns(permissions);
389
+ return compileWildcardPatternEntries(entries);
378
390
  }
379
391
 
380
392
  function findCompiledPermissionMatch(patterns: CompiledPermissionPatterns, name: string) {
@@ -542,10 +554,10 @@ export class PermissionManager {
542
554
  globalConfig,
543
555
  agentConfig,
544
556
  merged,
545
- compiledSpecial: compilePermissionPatterns(merged.special),
546
- compiledSkills: compilePermissionPatterns(merged.skills),
547
- compiledMcp: compilePermissionPatterns(merged.mcp),
548
- bashFilter: new BashFilter(merged.bash || {}, bashDefault),
557
+ compiledSpecial: compilePermissionPatternsFromSources(globalConfig.special, agentConfig.special),
558
+ compiledSkills: compilePermissionPatternsFromSources(globalConfig.skills, agentConfig.skills),
559
+ compiledMcp: compilePermissionPatternsFromSources(globalConfig.mcp, agentConfig.mcp),
560
+ bashFilter: new BashFilter(compilePermissionPatternsFromSources(globalConfig.bash, agentConfig.bash), bashDefault),
549
561
  };
550
562
 
551
563
  this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
@@ -609,19 +621,6 @@ export class PermissionManager {
609
621
  if (normalizedToolName === "bash") {
610
622
  const record = toRecord(input);
611
623
  const command = typeof record.command === "string" ? record.command : "";
612
-
613
- const agentBashToolOverride = agentConfig.tools?.bash;
614
- const hasAgentBashPatterns = Object.keys(agentConfig.bash || {}).length > 0;
615
-
616
- if (agentBashToolOverride && !hasAgentBashPatterns) {
617
- return {
618
- toolName,
619
- state: agentBashToolOverride,
620
- command,
621
- source: "bash",
622
- };
623
- }
624
-
625
624
  const result = bashFilter.check(command);
626
625
 
627
626
  return {
@@ -638,15 +637,6 @@ export class PermissionManager {
638
637
  const fallbackTarget = mcpTargets[0] || "mcp";
639
638
  const toolLevelMcpState = merged.tools?.mcp;
640
639
 
641
- if (toolLevelMcpState === "deny") {
642
- return {
643
- toolName,
644
- state: "deny",
645
- target: fallbackTarget,
646
- source: "tool",
647
- };
648
- }
649
-
650
640
  const mcpMatch = findCompiledPermissionMatchForNames(compiledMcp, mcpTargets);
651
641
  if (mcpMatch) {
652
642
  return {
package/src/test.ts CHANGED
@@ -47,13 +47,13 @@ function runTest(name: string, testFn: () => void): void {
47
47
  console.log(`[PASS] ${name}`);
48
48
  }
49
49
 
50
- runTest("BashFilter wildcard and specificity matching", () => {
50
+ runTest("BashFilter uses opencode-style last-match hierarchy", () => {
51
51
  const filter = new BashFilter(
52
52
  {
53
- "git status": "allow",
54
- "git status *": "ask",
55
- "git *": "deny",
56
53
  "*": "ask",
54
+ "git *": "deny",
55
+ "git status *": "ask",
56
+ "git status": "allow",
57
57
  },
58
58
  "deny",
59
59
  );
@@ -98,7 +98,7 @@ runTest("PermissionManager built-in permission checking", () => {
98
98
  }
99
99
  });
100
100
 
101
- runTest("Agent-specific bash override", () => {
101
+ runTest("Bash patterns stay higher priority than tool-level bash fallback", () => {
102
102
  const { manager, cleanup } = createManager(
103
103
  {
104
104
  defaultPolicy: {
@@ -108,28 +108,31 @@ runTest("Agent-specific bash override", () => {
108
108
  skills: "ask",
109
109
  special: "ask",
110
110
  },
111
- tools: {
112
- bash: "allow",
113
- },
114
111
  bash: {
115
- "echo *": "allow",
112
+ "rm -rf *": "deny",
116
113
  },
117
114
  },
118
115
  {
119
116
  reviewer: `---
120
117
  name: reviewer
121
118
  permission:
122
- bash: deny
119
+ tools:
120
+ bash: allow
123
121
  ---
124
122
  `,
125
123
  },
126
124
  );
127
125
 
128
126
  try {
129
- const result = manager.checkPermission("bash", { command: "echo hello" }, "reviewer");
130
- assert.equal(result.state, "deny");
131
- assert.equal(result.source, "bash");
132
- assert.equal(result.matchedPattern, undefined);
127
+ const denied = manager.checkPermission("bash", { command: "rm -rf build" }, "reviewer");
128
+ assert.equal(denied.state, "deny");
129
+ assert.equal(denied.source, "bash");
130
+ assert.equal(denied.matchedPattern, "rm -rf *");
131
+
132
+ const fallback = manager.checkPermission("bash", { command: "echo hello" }, "reviewer");
133
+ assert.equal(fallback.state, "allow");
134
+ assert.equal(fallback.source, "bash");
135
+ assert.equal(fallback.matchedPattern, undefined);
133
136
  } finally {
134
137
  cleanup();
135
138
  }
@@ -145,9 +148,9 @@ runTest("MCP wildcard matching", () => {
145
148
  special: "ask",
146
149
  },
147
150
  mcp: {
151
+ "*": "deny",
148
152
  "subagent_*": "ask",
149
153
  "subagent_query-*": "allow",
150
- "*": "deny",
151
154
  },
152
155
  });
153
156
 
@@ -179,9 +182,9 @@ runTest("Skill permission matching", () => {
179
182
  special: "ask",
180
183
  },
181
184
  skills: {
182
- "requesting-code-review": "allow",
183
- "web-*": "deny",
184
185
  "*": "ask",
186
+ "web-*": "deny",
187
+ "requesting-code-review": "allow",
185
188
  },
186
189
  });
187
190
 
@@ -214,8 +217,8 @@ runTest("MCP proxy tool infers server-prefixed aliases from configured server na
214
217
  special: "ask",
215
218
  },
216
219
  mcp: {
217
- exa_get_code_context_exa: "allow",
218
220
  "exa_*": "deny",
221
+ exa_get_code_context_exa: "allow",
219
222
  },
220
223
  },
221
224
  {},
@@ -246,8 +249,8 @@ runTest("MCP describe mode normalizes qualified tool names without duplicating s
246
249
  special: "ask",
247
250
  },
248
251
  mcp: {
249
- exa_web_search_exa: "allow",
250
252
  "exa_*": "deny",
253
+ exa_web_search_exa: "allow",
251
254
  },
252
255
  },
253
256
  {},
@@ -365,6 +368,49 @@ permission:
365
368
  }
366
369
  });
367
370
 
371
+ runTest("specific MCP rules still win when tools.mcp is deny", () => {
372
+ const { manager, cleanup } = createManager(
373
+ {
374
+ defaultPolicy: {
375
+ tools: "ask",
376
+ bash: "ask",
377
+ mcp: "ask",
378
+ skills: "ask",
379
+ special: "ask",
380
+ },
381
+ },
382
+ {
383
+ reviewer: `---
384
+ name: reviewer
385
+ permission:
386
+ tools:
387
+ mcp: deny
388
+ mcp:
389
+ exa_web_search_exa: allow
390
+ ---
391
+ `,
392
+ },
393
+ {
394
+ mcpServerNames: ["exa"],
395
+ },
396
+ );
397
+
398
+ try {
399
+ const allowed = manager.checkPermission("mcp", { tool: "web_search_exa" }, "reviewer");
400
+ assert.equal(allowed.state, "allow");
401
+ assert.equal(allowed.source, "mcp");
402
+ assert.equal(allowed.matchedPattern, "exa_web_search_exa");
403
+ assert.equal(allowed.target, "exa_web_search_exa");
404
+
405
+ const fallback = manager.checkPermission("mcp", { tool: "other_exa" }, "reviewer");
406
+ assert.equal(fallback.state, "deny");
407
+ assert.equal(fallback.source, "tool");
408
+ assert.equal(fallback.target, "exa_other_exa");
409
+ } finally {
410
+ cleanup();
411
+ }
412
+ });
413
+
368
414
  runTest("partial agent defaultPolicy overrides preserve global defaults", () => {
369
415
  const { manager, cleanup } = createManager(
370
416
  {
@@ -2,8 +2,6 @@ export type CompiledWildcardPattern<TState> = {
2
2
  pattern: string;
3
3
  state: TState;
4
4
  regex: RegExp;
5
- wildcardCount: number;
6
- literalLength: number;
7
5
  };
8
6
 
9
7
  export type WildcardPatternMatch<TState> = {
@@ -26,39 +24,27 @@ export function compileWildcardPattern<TState>(pattern: string, state: TState):
26
24
  pattern,
27
25
  state,
28
26
  regex: new RegExp(`^${escaped}$`),
29
- wildcardCount: (pattern.match(/\*/g) || []).length,
30
- literalLength: pattern.replace(/\*/g, "").length,
31
27
  };
32
28
  }
33
29
 
34
- function compareCompiledPatterns<TState>(
35
- left: CompiledWildcardPattern<TState>,
36
- right: CompiledWildcardPattern<TState>,
37
- ): number {
38
- if (left.wildcardCount !== right.wildcardCount) {
39
- return left.wildcardCount - right.wildcardCount;
40
- }
41
-
42
- if (left.literalLength !== right.literalLength) {
43
- return right.literalLength - left.literalLength;
44
- }
45
-
46
- return right.pattern.length - left.pattern.length;
30
+ export function compileWildcardPatternEntries<TState>(
31
+ entries: Iterable<readonly [string, TState]>,
32
+ ): CompiledWildcardPattern<TState>[] {
33
+ return Array.from(entries, ([pattern, state]) => compileWildcardPattern(pattern, state));
47
34
  }
48
35
 
49
36
  export function compileWildcardPatterns<TState>(
50
37
  patterns: Record<string, TState>,
51
38
  ): CompiledWildcardPattern<TState>[] {
52
- return Object.entries(patterns)
53
- .map(([pattern, state]) => compileWildcardPattern(pattern, state))
54
- .sort(compareCompiledPatterns);
39
+ return compileWildcardPatternEntries(Object.entries(patterns));
55
40
  }
56
41
 
57
42
  export function findCompiledWildcardMatch<TState>(
58
43
  patterns: readonly CompiledWildcardPattern<TState>[],
59
44
  name: string,
60
45
  ): WildcardPatternMatch<TState> | null {
61
- for (const pattern of patterns) {
46
+ for (let index = patterns.length - 1; index >= 0; index -= 1) {
47
+ const pattern = patterns[index];
62
48
  if (pattern.regex.test(name)) {
63
49
  return {
64
50
  state: pattern.state,