pi-permission-system 0.1.6 → 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 +37 -1
- package/README.md +63 -5
- package/package.json +1 -1
- package/src/bash-filter.ts +23 -51
- package/src/common.ts +82 -0
- package/src/index.ts +11 -289
- package/src/permission-manager.ts +300 -244
- package/src/test.ts +270 -34
- package/src/tool-registry.ts +1 -18
- package/src/wildcard-matcher.ts +77 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,43 @@ 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
|
-
## [
|
|
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
|
+
|
|
25
|
+
## [0.1.7] - 2026-03-10
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- `src/common.ts` — Shared utility module with `toRecord()`, `getNonEmptyString()`, `isPermissionState()`, `parseSimpleYamlMap()`, `extractFrontmatter()`
|
|
29
|
+
- `src/wildcard-matcher.ts` — Wildcard pattern compilation and matching with specificity sorting
|
|
30
|
+
- File stamp caching in `PermissionManager` for improved performance
|
|
31
|
+
- `tools.mcp` fallback permission for MCP operations
|
|
32
|
+
- MCP tool permission targets now inferred from configured server names in `mcp.json`
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
- Refactored `bash-filter.ts` to use shared `wildcard-matcher.ts` module
|
|
36
|
+
- Refactored `index.ts` to use shared `common.ts` utilities
|
|
37
|
+
- Refactored `permission-manager.ts` to use shared modules and caching
|
|
38
|
+
- Pre-compiled wildcard patterns are now reused across permission checks
|
|
39
|
+
- Updated README architecture documentation to reflect new module organization
|
|
40
|
+
|
|
41
|
+
### Tests
|
|
42
|
+
- Added tests for MCP proxy tool inferring server-prefixed aliases from configured server names
|
|
43
|
+
- Added tests for `tools.mcp` fallback behavior
|
|
44
|
+
- Added tests for `task` using tool permissions instead of MCP fallback
|
|
9
45
|
|
|
10
46
|
## [0.1.6] - 2026-03-09
|
|
11
47
|
|
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.
|
|
@@ -111,9 +111,13 @@ permission:
|
|
|
111
111
|
tools:
|
|
112
112
|
read: allow
|
|
113
113
|
write: deny
|
|
114
|
+
mcp: allow
|
|
114
115
|
bash:
|
|
115
116
|
git status: allow
|
|
116
117
|
git *: ask
|
|
118
|
+
mcp:
|
|
119
|
+
chrome_devtools_*: deny
|
|
120
|
+
exa_*: allow
|
|
117
121
|
skills:
|
|
118
122
|
"*": ask
|
|
119
123
|
---
|
|
@@ -121,6 +125,8 @@ permission:
|
|
|
121
125
|
|
|
122
126
|
**Precedence:** Agent frontmatter overrides global config (shallow-merged per section).
|
|
123
127
|
|
|
128
|
+
**MCP behavior:** `permission.tools.mcp` is the coarse entry/fallback permission for the built-in `mcp` tool. More specific `permission.mcp` target rules override that fallback when they match.
|
|
129
|
+
|
|
124
130
|
**Limitations:** The frontmatter parser is intentionally minimal. Use only `key: value` scalars and nested maps. Avoid arrays, multi-line scalars, and YAML anchors.
|
|
125
131
|
|
|
126
132
|
---
|
|
@@ -156,18 +162,22 @@ Controls built-in tools by exact name (no wildcards):
|
|
|
156
162
|
| `grep` | Pattern searching |
|
|
157
163
|
| `find` | File discovery |
|
|
158
164
|
| `ls` | Directory listing |
|
|
165
|
+
| `mcp` | MCP proxy tool entry/fallback |
|
|
159
166
|
|
|
160
167
|
```jsonc
|
|
161
168
|
{
|
|
162
169
|
"tools": {
|
|
163
170
|
"read": "allow",
|
|
164
171
|
"write": "deny",
|
|
165
|
-
"edit": "deny"
|
|
172
|
+
"edit": "deny",
|
|
173
|
+
"mcp": "allow"
|
|
166
174
|
}
|
|
167
175
|
}
|
|
168
176
|
```
|
|
169
177
|
|
|
170
178
|
> **Note:** Setting `tools.bash` affects the *default* for bash commands, but `bash` patterns can provide command-level overrides.
|
|
179
|
+
>
|
|
180
|
+
> **Note:** Setting `tools.mcp` controls coarse access to the built-in `mcp` tool. Specific `mcp` rules still override it when a target pattern matches.
|
|
171
181
|
|
|
172
182
|
### `bash`
|
|
173
183
|
|
|
@@ -190,7 +200,7 @@ Command patterns use `*` wildcards and match against the full command string. Pa
|
|
|
190
200
|
|
|
191
201
|
### `mcp`
|
|
192
202
|
|
|
193
|
-
MCP permissions match against derived targets from tool input:
|
|
203
|
+
MCP permissions match against derived targets from tool input. These rules are more specific than `tools.mcp` and override that fallback when a pattern matches:
|
|
194
204
|
|
|
195
205
|
| Target Type | Examples |
|
|
196
206
|
|-------------------|---------------------------------------------|
|
|
@@ -212,6 +222,35 @@ MCP permissions match against derived targets from tool input:
|
|
|
212
222
|
|
|
213
223
|
> **Note:** Baseline discovery targets may auto-allow when you permit any MCP rule.
|
|
214
224
|
|
|
225
|
+
#### MCP Tool Fallback via `tools.mcp`
|
|
226
|
+
|
|
227
|
+
The `mcp` built-in tool can use `tools.mcp` as an entry permission point. This provides a fallback when no specific MCP pattern matches:
|
|
228
|
+
|
|
229
|
+
```jsonc
|
|
230
|
+
{
|
|
231
|
+
"tools": {
|
|
232
|
+
"mcp": "allow"
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
This is useful for per-agent configurations where you want to grant MCP access broadly:
|
|
238
|
+
|
|
239
|
+
```yaml
|
|
240
|
+
# In ~/.pi/agent/agents/researcher.md
|
|
241
|
+
---
|
|
242
|
+
name: researcher
|
|
243
|
+
permission:
|
|
244
|
+
tools:
|
|
245
|
+
mcp: allow
|
|
246
|
+
---
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The permission resolution order for MCP operations:
|
|
250
|
+
1. Specific `mcp` patterns (e.g., `myServer:toolName`, `myServer_*`)
|
|
251
|
+
2. `tools.mcp` fallback (if set)
|
|
252
|
+
3. `defaultPolicy.mcp`
|
|
253
|
+
|
|
215
254
|
### `skills`
|
|
216
255
|
|
|
217
256
|
Skill name patterns use `*` wildcards:
|
|
@@ -324,8 +363,10 @@ This keeps `ask` policies usable even when the original permission check happens
|
|
|
324
363
|
index.ts → Root Pi entrypoint shim
|
|
325
364
|
src/
|
|
326
365
|
├── index.ts → Extension bootstrap, permission checks, and subagent forwarding
|
|
327
|
-
├── permission-manager.ts → Policy loading, merging, and resolution
|
|
328
|
-
├── bash-filter.ts →
|
|
366
|
+
├── permission-manager.ts → Policy loading, merging, and resolution with caching
|
|
367
|
+
├── bash-filter.ts → Bash command wildcard pattern matching
|
|
368
|
+
├── wildcard-matcher.ts → Shared wildcard pattern compilation and matching
|
|
369
|
+
├── common.ts → Shared utilities (YAML parsing, type guards, etc.)
|
|
329
370
|
├── tool-registry.ts → Registered tool name resolution
|
|
330
371
|
├── types.ts → TypeScript type definitions
|
|
331
372
|
└── test.ts → Test runner
|
|
@@ -335,6 +376,23 @@ config/
|
|
|
335
376
|
└── config.example.json → Starter configuration template
|
|
336
377
|
```
|
|
337
378
|
|
|
379
|
+
#### Module Organization
|
|
380
|
+
|
|
381
|
+
The extension uses a modular architecture with shared utilities:
|
|
382
|
+
|
|
383
|
+
| Module | Purpose |
|
|
384
|
+
|--------|---------|
|
|
385
|
+
| `common.ts` | Shared utilities: `toRecord()`, `getNonEmptyString()`, `isPermissionState()`, `parseSimpleYamlMap()`, `extractFrontmatter()` |
|
|
386
|
+
| `wildcard-matcher.ts` | Compile-once wildcard patterns with specificity sorting: `compileWildcardPatterns()`, `findCompiledWildcardMatch()` |
|
|
387
|
+
| `permission-manager.ts` | Policy resolution with file stamp caching for performance |
|
|
388
|
+
| `bash-filter.ts` | Uses shared wildcard matcher for bash command patterns |
|
|
389
|
+
|
|
390
|
+
#### Performance Optimizations
|
|
391
|
+
|
|
392
|
+
- **File stamp caching**: Configurations are cached with file modification timestamps to avoid redundant reads
|
|
393
|
+
- **Pre-compiled patterns**: Wildcard patterns are compiled to regex once and reused across permission checks
|
|
394
|
+
- **Resolved permissions caching**: Merged agent+global permissions are cached per-agent with invalidation on file changes
|
|
395
|
+
|
|
338
396
|
### Threat Model
|
|
339
397
|
|
|
340
398
|
**Goal:** Enforce policy at the host level, not the model level.
|
package/package.json
CHANGED
package/src/bash-filter.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import type { BashPermissions, PermissionState } from "./types.js";
|
|
2
|
+
import {
|
|
3
|
+
compileWildcardPatterns,
|
|
4
|
+
findCompiledWildcardMatch,
|
|
5
|
+
type CompiledWildcardPattern,
|
|
6
|
+
} from "./wildcard-matcher.js";
|
|
2
7
|
|
|
3
|
-
type CompiledPattern =
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
8
|
+
type CompiledPattern = CompiledWildcardPattern<PermissionState>;
|
|
9
|
+
|
|
10
|
+
type BashPermissionSource = BashPermissions | readonly CompiledPattern[];
|
|
11
|
+
|
|
12
|
+
function isCompiledPatternList(value: BashPermissionSource): value is readonly CompiledPattern[] {
|
|
13
|
+
return Array.isArray(value);
|
|
14
|
+
}
|
|
10
15
|
|
|
11
16
|
export interface BashPermissionCheck {
|
|
12
17
|
state: PermissionState;
|
|
@@ -14,59 +19,26 @@ export interface BashPermissionCheck {
|
|
|
14
19
|
command: string;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
|
-
function escapeRegExp(value: string): string {
|
|
18
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function compilePattern(pattern: string, state: PermissionState): CompiledPattern {
|
|
22
|
-
const escaped = pattern
|
|
23
|
-
.split("*")
|
|
24
|
-
.map((part) => escapeRegExp(part))
|
|
25
|
-
.join(".*");
|
|
26
|
-
|
|
27
|
-
const wildcardCount = (pattern.match(/\*/g) || []).length;
|
|
28
|
-
const literalLength = pattern.replace(/\*/g, "").length;
|
|
29
|
-
|
|
30
|
-
return {
|
|
31
|
-
pattern,
|
|
32
|
-
state,
|
|
33
|
-
regex: new RegExp(`^${escaped}$`),
|
|
34
|
-
wildcardCount,
|
|
35
|
-
literalLength,
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function bySpecificity(a: CompiledPattern, b: CompiledPattern): number {
|
|
40
|
-
if (a.wildcardCount !== b.wildcardCount) {
|
|
41
|
-
return a.wildcardCount - b.wildcardCount;
|
|
42
|
-
}
|
|
43
|
-
if (a.literalLength !== b.literalLength) {
|
|
44
|
-
return b.literalLength - a.literalLength;
|
|
45
|
-
}
|
|
46
|
-
return b.pattern.length - a.pattern.length;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
22
|
export class BashFilter {
|
|
50
23
|
private readonly compiledPatterns: CompiledPattern[];
|
|
51
24
|
|
|
52
25
|
constructor(
|
|
53
|
-
|
|
26
|
+
permissions: BashPermissionSource,
|
|
54
27
|
private readonly defaultState: PermissionState,
|
|
55
28
|
) {
|
|
56
|
-
this.compiledPatterns =
|
|
57
|
-
|
|
58
|
-
|
|
29
|
+
this.compiledPatterns = isCompiledPatternList(permissions)
|
|
30
|
+
? [...permissions]
|
|
31
|
+
: compileWildcardPatterns(permissions);
|
|
59
32
|
}
|
|
60
33
|
|
|
61
34
|
check(command: string): BashPermissionCheck {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
35
|
+
const match = findCompiledWildcardMatch(this.compiledPatterns, command);
|
|
36
|
+
if (match) {
|
|
37
|
+
return {
|
|
38
|
+
state: match.state,
|
|
39
|
+
matchedPattern: match.matchedPattern,
|
|
40
|
+
command,
|
|
41
|
+
};
|
|
70
42
|
}
|
|
71
43
|
|
|
72
44
|
return {
|
package/src/common.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { PermissionState } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function toRecord(value: unknown): Record<string, unknown> {
|
|
4
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
5
|
+
return {};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return value as Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getNonEmptyString(value: unknown): string | null {
|
|
12
|
+
if (typeof value !== "string") {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const trimmed = value.trim();
|
|
17
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isPermissionState(value: unknown): value is PermissionState {
|
|
21
|
+
return value === "allow" || value === "deny" || value === "ask";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type StackNode = { indent: number; target: Record<string, unknown> };
|
|
25
|
+
|
|
26
|
+
export function parseSimpleYamlMap(input: string): Record<string, unknown> {
|
|
27
|
+
const root: Record<string, unknown> = {};
|
|
28
|
+
const stack: StackNode[] = [{ indent: -1, target: root }];
|
|
29
|
+
|
|
30
|
+
const lines = input.split(/\r?\n/);
|
|
31
|
+
for (const rawLine of lines) {
|
|
32
|
+
if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
37
|
+
const line = rawLine.trim();
|
|
38
|
+
const separatorIndex = line.indexOf(":");
|
|
39
|
+
if (separatorIndex <= 0) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const key = line.slice(0, separatorIndex).trim().replace(/^['"]|['"]$/g, "");
|
|
44
|
+
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
45
|
+
|
|
46
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
47
|
+
stack.pop();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const current = stack[stack.length - 1].target;
|
|
51
|
+
|
|
52
|
+
if (!rawValue) {
|
|
53
|
+
const child: Record<string, unknown> = {};
|
|
54
|
+
current[key] = child;
|
|
55
|
+
stack.push({ indent, target: child });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let scalar = rawValue;
|
|
60
|
+
if ((scalar.startsWith('"') && scalar.endsWith('"')) || (scalar.startsWith("'") && scalar.endsWith("'"))) {
|
|
61
|
+
scalar = scalar.slice(1, -1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
current[key] = scalar;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return root;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function extractFrontmatter(markdown: string): string {
|
|
71
|
+
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
72
|
+
if (!normalized.startsWith("---\n")) {
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const end = normalized.indexOf("\n---", 4);
|
|
77
|
+
if (end === -1) {
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return normalized.slice(4, end);
|
|
82
|
+
}
|