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 +17 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/bash-filter.ts +10 -2
- package/src/permission-manager.ts +21 -31
- package/src/test.ts +65 -19
- package/src/wildcard-matcher.ts +7 -21
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
|
-
[](package.json)
|
|
4
4
|
[](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
package/src/bash-filter.ts
CHANGED
|
@@ -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
|
-
|
|
26
|
+
permissions: BashPermissionSource,
|
|
21
27
|
private readonly defaultState: PermissionState,
|
|
22
28
|
) {
|
|
23
|
-
this.compiledPatterns =
|
|
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
|
-
|
|
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
|
|
371
|
-
|
|
370
|
+
function compilePermissionPatternsFromSources(
|
|
371
|
+
...sources: Array<Record<string, PermissionState> | undefined>
|
|
372
372
|
): CompiledPermissionPatterns {
|
|
373
|
-
|
|
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
|
|
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:
|
|
546
|
-
compiledSkills:
|
|
547
|
-
compiledMcp:
|
|
548
|
-
bashFilter: new BashFilter(
|
|
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
|
|
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("
|
|
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
|
-
"
|
|
112
|
+
"rm -rf *": "deny",
|
|
116
113
|
},
|
|
117
114
|
},
|
|
118
115
|
{
|
|
119
116
|
reviewer: `---
|
|
120
117
|
name: reviewer
|
|
121
118
|
permission:
|
|
122
|
-
|
|
119
|
+
tools:
|
|
120
|
+
bash: allow
|
|
123
121
|
---
|
|
124
122
|
`,
|
|
125
123
|
},
|
|
126
124
|
);
|
|
127
125
|
|
|
128
126
|
try {
|
|
129
|
-
const
|
|
130
|
-
assert.equal(
|
|
131
|
-
assert.equal(
|
|
132
|
-
assert.equal(
|
|
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
|
{
|
package/src/wildcard-matcher.ts
CHANGED
|
@@ -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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
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 (
|
|
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,
|