pi-permission-system 0.1.6 → 0.1.7

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,7 +5,26 @@ 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
- ## [Unreleased]
8
+ ## [0.1.7] - 2026-03-10
9
+
10
+ ### Added
11
+ - `src/common.ts` — Shared utility module with `toRecord()`, `getNonEmptyString()`, `isPermissionState()`, `parseSimpleYamlMap()`, `extractFrontmatter()`
12
+ - `src/wildcard-matcher.ts` — Wildcard pattern compilation and matching with specificity sorting
13
+ - File stamp caching in `PermissionManager` for improved performance
14
+ - `tools.mcp` fallback permission for MCP operations
15
+ - MCP tool permission targets now inferred from configured server names in `mcp.json`
16
+
17
+ ### Changed
18
+ - Refactored `bash-filter.ts` to use shared `wildcard-matcher.ts` module
19
+ - Refactored `index.ts` to use shared `common.ts` utilities
20
+ - Refactored `permission-manager.ts` to use shared modules and caching
21
+ - Pre-compiled wildcard patterns are now reused across permission checks
22
+ - Updated README architecture documentation to reflect new module organization
23
+
24
+ ### Tests
25
+ - Added tests for MCP proxy tool inferring server-prefixed aliases from configured server names
26
+ - Added tests for `tools.mcp` fallback behavior
27
+ - Added tests for `task` using tool permissions instead of MCP fallback
9
28
 
10
29
  ## [0.1.6] - 2026-03-09
11
30
 
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.6-blue.svg)](package.json)
3
+ [![Version](https://img.shields.io/badge/version-0.1.7-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.
@@ -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 → Wildcard pattern matching with specificity sorting
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-permission-system",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -1,12 +1,11 @@
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
- pattern: string;
5
- state: PermissionState;
6
- regex: RegExp;
7
- wildcardCount: number;
8
- literalLength: number;
9
- };
8
+ type CompiledPattern = CompiledWildcardPattern<PermissionState>;
10
9
 
11
10
  export interface BashPermissionCheck {
12
11
  state: PermissionState;
@@ -14,38 +13,6 @@ export interface BashPermissionCheck {
14
13
  command: string;
15
14
  }
16
15
 
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
16
  export class BashFilter {
50
17
  private readonly compiledPatterns: CompiledPattern[];
51
18
 
@@ -53,20 +20,17 @@ export class BashFilter {
53
20
  private readonly permissions: BashPermissions,
54
21
  private readonly defaultState: PermissionState,
55
22
  ) {
56
- this.compiledPatterns = Object.entries(permissions)
57
- .map(([pattern, state]) => compilePattern(pattern, state))
58
- .sort(bySpecificity);
23
+ this.compiledPatterns = compileWildcardPatterns(permissions);
59
24
  }
60
25
 
61
26
  check(command: string): BashPermissionCheck {
62
- for (const pattern of this.compiledPatterns) {
63
- if (pattern.regex.test(command)) {
64
- return {
65
- state: pattern.state,
66
- matchedPattern: pattern.pattern,
67
- command,
68
- };
69
- }
27
+ const match = findCompiledWildcardMatch(this.compiledPatterns, command);
28
+ if (match) {
29
+ return {
30
+ state: match.state,
31
+ matchedPattern: match.matchedPattern,
32
+ command,
33
+ };
70
34
  }
71
35
 
72
36
  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
+ }