@gotgenes/pi-permission-system 5.4.0 → 5.5.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.
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/permission-manager.ts +28 -279
- package/src/policy-loader.ts +350 -0
- package/tests/permission-manager-unified.test.ts +319 -0
- package/tests/policy-loader.test.ts +561 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ 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
|
+
## [5.5.0](https://github.com/gotgenes/pi-permission-system/compare/v5.4.0...v5.5.0) (2026-05-07)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* extract FilePolicyLoader from PermissionManager ([705d800](https://github.com/gotgenes/pi-permission-system/commit/705d800ec326d99798be2abaeba83235adae55b2))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* pass through npm calls targeting .pi/npm directory ([4104712](https://github.com/gotgenes/pi-permission-system/commit/4104712a17baa2b1eae5fd6388357b672798f8bb))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Documentation
|
|
22
|
+
|
|
23
|
+
* add PolicyLoader to target architecture ([fbbb85f](https://github.com/gotgenes/pi-permission-system/commit/fbbb85f76830880c8b3906f3694c7a7f7bb7fab5))
|
|
24
|
+
* plan extract PolicyLoader from PermissionManager ([#108](https://github.com/gotgenes/pi-permission-system/issues/108)) ([4d5f0df](https://github.com/gotgenes/pi-permission-system/commit/4d5f0df11af4bd87061ca38c19a14cc807dd6e84))
|
|
25
|
+
* **retro:** add retro notes for issue [#107](https://github.com/gotgenes/pi-permission-system/issues/107) ([d979562](https://github.com/gotgenes/pi-permission-system/commit/d979562cee6e2aca0e1f2d232c8d18ddb7926dd4))
|
|
26
|
+
|
|
8
27
|
## [5.4.0](https://github.com/gotgenes/pi-permission-system/compare/v5.3.4...v5.4.0) (2026-05-07)
|
|
9
28
|
|
|
10
29
|
|
package/package.json
CHANGED
|
@@ -1,21 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
extractFrontmatter,
|
|
7
|
-
isPermissionState,
|
|
8
|
-
parseSimpleYamlMap,
|
|
9
|
-
toRecord,
|
|
10
|
-
} from "./common";
|
|
11
|
-
import {
|
|
12
|
-
loadUnifiedConfig,
|
|
13
|
-
normalizeUnifiedConfig,
|
|
14
|
-
stripJsonComments,
|
|
15
|
-
} from "./config-loader";
|
|
16
|
-
import { getGlobalConfigPath } from "./config-paths";
|
|
1
|
+
import { isPermissionState } from "./common";
|
|
17
2
|
import { normalizeInput } from "./input-normalizer";
|
|
18
3
|
import { normalizeFlatConfig } from "./normalize";
|
|
4
|
+
import {
|
|
5
|
+
FilePolicyLoader,
|
|
6
|
+
type PolicyLoader,
|
|
7
|
+
type PolicyLoaderOptions,
|
|
8
|
+
type ResolvedPolicyPaths,
|
|
9
|
+
} from "./policy-loader";
|
|
19
10
|
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
20
11
|
import { evaluate, evaluateFirst } from "./rule";
|
|
21
12
|
import {
|
|
@@ -27,19 +18,8 @@ import type {
|
|
|
27
18
|
FlatPermissionConfig,
|
|
28
19
|
PermissionCheckResult,
|
|
29
20
|
PermissionState,
|
|
30
|
-
ScopeConfig,
|
|
31
21
|
} from "./types";
|
|
32
22
|
|
|
33
|
-
function defaultGlobalConfigPath(): string {
|
|
34
|
-
return getGlobalConfigPath(getAgentDir());
|
|
35
|
-
}
|
|
36
|
-
function defaultAgentsDir(): string {
|
|
37
|
-
return join(getAgentDir(), "agents");
|
|
38
|
-
}
|
|
39
|
-
function defaultGlobalMcpConfigPath(): string {
|
|
40
|
-
return join(getAgentDir(), "mcp.json");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
23
|
const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
|
|
44
24
|
"bash",
|
|
45
25
|
"read",
|
|
@@ -83,49 +63,10 @@ function mergeFlatPermissions(
|
|
|
83
63
|
return merged;
|
|
84
64
|
}
|
|
85
65
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const raw = readFileSync(configPath, "utf-8");
|
|
91
|
-
const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
|
|
92
|
-
const root = toRecord(parsed);
|
|
93
|
-
const serverRecord = toRecord(root.mcpServers ?? root["mcp-servers"]);
|
|
94
|
-
|
|
95
|
-
return Object.keys(serverRecord)
|
|
96
|
-
.map((name) => name.trim())
|
|
97
|
-
.filter((name) => name.length > 0);
|
|
98
|
-
} catch {
|
|
99
|
-
return [];
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function getConfiguredMcpServerNamesFromPaths(
|
|
104
|
-
paths: readonly string[],
|
|
105
|
-
): string[] {
|
|
106
|
-
const seen = new Set<string>();
|
|
107
|
-
|
|
108
|
-
for (const path of paths) {
|
|
109
|
-
for (const name of readConfiguredMcpServerNamesFromConfigPath(path)) {
|
|
110
|
-
seen.add(name);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return [...seen].sort(
|
|
115
|
-
(left, right) => right.length - left.length || left.localeCompare(right),
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export interface ResolvedPolicyPaths {
|
|
120
|
-
globalConfigPath: string;
|
|
121
|
-
globalConfigExists: boolean;
|
|
122
|
-
projectConfigPath: string | null;
|
|
123
|
-
projectConfigExists: boolean;
|
|
124
|
-
agentsDir: string;
|
|
125
|
-
agentsDirExists: boolean;
|
|
126
|
-
projectAgentsDir: string | null;
|
|
127
|
-
projectAgentsDirExists: boolean;
|
|
128
|
-
}
|
|
66
|
+
type FileCacheEntry<TValue> = {
|
|
67
|
+
stamp: string;
|
|
68
|
+
value: TValue;
|
|
69
|
+
};
|
|
129
70
|
|
|
130
71
|
type ResolvedPermissions = {
|
|
131
72
|
/**
|
|
@@ -135,223 +76,47 @@ type ResolvedPermissions = {
|
|
|
135
76
|
composedRules: Ruleset;
|
|
136
77
|
};
|
|
137
78
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
value: TValue;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
function getFileStamp(path: string): string {
|
|
144
|
-
try {
|
|
145
|
-
return String(statSync(path).mtimeMs);
|
|
146
|
-
} catch {
|
|
147
|
-
return "missing";
|
|
148
|
-
}
|
|
79
|
+
export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
80
|
+
policyLoader?: PolicyLoader;
|
|
149
81
|
}
|
|
150
82
|
|
|
151
83
|
export class PermissionManager {
|
|
152
|
-
private readonly
|
|
153
|
-
private readonly agentsDir: string;
|
|
154
|
-
private readonly projectGlobalConfigPath: string | null;
|
|
155
|
-
private readonly projectAgentsDir: string | null;
|
|
156
|
-
private readonly globalMcpConfigPath: string;
|
|
157
|
-
private readonly configuredMcpServerNamesOverride: readonly string[] | null;
|
|
158
|
-
private globalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
|
|
159
|
-
private projectGlobalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
|
|
160
|
-
private readonly agentConfigCache = new Map<
|
|
161
|
-
string,
|
|
162
|
-
FileCacheEntry<ScopeConfig>
|
|
163
|
-
>();
|
|
164
|
-
private readonly projectAgentConfigCache = new Map<
|
|
165
|
-
string,
|
|
166
|
-
FileCacheEntry<ScopeConfig>
|
|
167
|
-
>();
|
|
84
|
+
private readonly loader: PolicyLoader;
|
|
168
85
|
private readonly resolvedPermissionsCache = new Map<
|
|
169
86
|
string,
|
|
170
87
|
FileCacheEntry<ResolvedPermissions>
|
|
171
88
|
>();
|
|
172
|
-
private configuredMcpServerNamesCache: FileCacheEntry<
|
|
173
|
-
readonly string[]
|
|
174
|
-
> | null = null;
|
|
175
|
-
private accumulatedConfigIssues: string[] = [];
|
|
176
|
-
|
|
177
|
-
constructor(
|
|
178
|
-
options: {
|
|
179
|
-
globalConfigPath?: string;
|
|
180
|
-
agentsDir?: string;
|
|
181
|
-
projectGlobalConfigPath?: string;
|
|
182
|
-
projectAgentsDir?: string;
|
|
183
|
-
globalMcpConfigPath?: string;
|
|
184
|
-
mcpServerNames?: readonly string[];
|
|
185
|
-
} = {},
|
|
186
|
-
) {
|
|
187
|
-
this.globalConfigPath =
|
|
188
|
-
options.globalConfigPath || defaultGlobalConfigPath();
|
|
189
|
-
this.agentsDir = options.agentsDir || defaultAgentsDir();
|
|
190
|
-
this.projectGlobalConfigPath = options.projectGlobalConfigPath || null;
|
|
191
|
-
this.projectAgentsDir = options.projectAgentsDir || null;
|
|
192
|
-
this.globalMcpConfigPath =
|
|
193
|
-
options.globalMcpConfigPath || defaultGlobalMcpConfigPath();
|
|
194
|
-
this.configuredMcpServerNamesOverride = options.mcpServerNames
|
|
195
|
-
? [
|
|
196
|
-
...new Set(
|
|
197
|
-
options.mcpServerNames
|
|
198
|
-
.map((name) => name.trim())
|
|
199
|
-
.filter((name) => name.length > 0),
|
|
200
|
-
),
|
|
201
|
-
]
|
|
202
|
-
: null;
|
|
203
|
-
}
|
|
204
89
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (!this.accumulatedConfigIssues.includes(issue)) {
|
|
208
|
-
this.accumulatedConfigIssues.push(issue);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
90
|
+
constructor(options: PermissionManagerOptions = {}) {
|
|
91
|
+
this.loader = options.policyLoader ?? new FilePolicyLoader(options);
|
|
211
92
|
}
|
|
212
93
|
|
|
213
94
|
getConfigIssues(agentName?: string): string[] {
|
|
214
95
|
// Trigger a load/resolve to ensure issues are collected.
|
|
215
96
|
this.resolvePermissions(agentName);
|
|
216
|
-
return [...this.
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
private loadGlobalConfig(): ScopeConfig {
|
|
220
|
-
const stamp = getFileStamp(this.globalConfigPath);
|
|
221
|
-
if (this.globalConfigCache?.stamp === stamp) {
|
|
222
|
-
return this.globalConfigCache.value;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const { config, issues } = loadUnifiedConfig(this.globalConfigPath);
|
|
226
|
-
this.accumulateConfigIssues(issues);
|
|
227
|
-
|
|
228
|
-
const value: ScopeConfig = {
|
|
229
|
-
permission: config.permission,
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
this.globalConfigCache = { stamp, value };
|
|
233
|
-
return value;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
private loadProjectGlobalConfig(): ScopeConfig {
|
|
237
|
-
if (!this.projectGlobalConfigPath) {
|
|
238
|
-
return {};
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const stamp = getFileStamp(this.projectGlobalConfigPath);
|
|
242
|
-
if (this.projectGlobalConfigCache?.stamp === stamp) {
|
|
243
|
-
return this.projectGlobalConfigCache.value;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath);
|
|
247
|
-
this.accumulateConfigIssues(issues);
|
|
248
|
-
|
|
249
|
-
const value: ScopeConfig = {
|
|
250
|
-
permission: config.permission,
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
this.projectGlobalConfigCache = { stamp, value };
|
|
254
|
-
return value;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
private loadScopeConfigFrom(
|
|
258
|
-
dir: string | null,
|
|
259
|
-
cache: Map<string, FileCacheEntry<ScopeConfig>>,
|
|
260
|
-
agentName?: string,
|
|
261
|
-
): ScopeConfig {
|
|
262
|
-
if (!dir || !agentName) {
|
|
263
|
-
return {};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const filePath = join(dir, `${agentName}.md`);
|
|
267
|
-
const stamp = getFileStamp(filePath);
|
|
268
|
-
const cached = cache.get(agentName);
|
|
269
|
-
if (cached?.stamp === stamp) {
|
|
270
|
-
return cached.value;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
let value: ScopeConfig;
|
|
274
|
-
try {
|
|
275
|
-
const markdown = readFileSync(filePath, "utf-8");
|
|
276
|
-
const frontmatter = extractFrontmatter(markdown);
|
|
277
|
-
if (!frontmatter) {
|
|
278
|
-
value = {};
|
|
279
|
-
} else {
|
|
280
|
-
const parsed = parseSimpleYamlMap(frontmatter);
|
|
281
|
-
// Re-use the config-loader normalizer so the flat permission shape
|
|
282
|
-
// is validated the same way as on-disk config files.
|
|
283
|
-
const { config, issues } = normalizeUnifiedConfig(parsed);
|
|
284
|
-
this.accumulateConfigIssues(issues);
|
|
285
|
-
value = { permission: config.permission };
|
|
286
|
-
}
|
|
287
|
-
} catch {
|
|
288
|
-
value = {};
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
cache.set(agentName, { stamp, value });
|
|
292
|
-
return value;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
private loadScopeConfig(agentName?: string): ScopeConfig {
|
|
296
|
-
return this.loadScopeConfigFrom(
|
|
297
|
-
this.agentsDir,
|
|
298
|
-
this.agentConfigCache,
|
|
299
|
-
agentName,
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
private loadProjectScopeConfig(agentName?: string): ScopeConfig {
|
|
304
|
-
return this.loadScopeConfigFrom(
|
|
305
|
-
this.projectAgentsDir,
|
|
306
|
-
this.projectAgentConfigCache,
|
|
307
|
-
agentName,
|
|
308
|
-
);
|
|
97
|
+
return [...this.loader.getConfigIssues()];
|
|
309
98
|
}
|
|
310
99
|
|
|
311
100
|
getResolvedPolicyPaths(): ResolvedPolicyPaths {
|
|
312
|
-
return
|
|
313
|
-
globalConfigPath: this.globalConfigPath,
|
|
314
|
-
globalConfigExists: existsSync(this.globalConfigPath),
|
|
315
|
-
projectConfigPath: this.projectGlobalConfigPath,
|
|
316
|
-
projectConfigExists: this.projectGlobalConfigPath
|
|
317
|
-
? existsSync(this.projectGlobalConfigPath)
|
|
318
|
-
: false,
|
|
319
|
-
agentsDir: this.agentsDir,
|
|
320
|
-
agentsDirExists: existsSync(this.agentsDir),
|
|
321
|
-
projectAgentsDir: this.projectAgentsDir,
|
|
322
|
-
projectAgentsDirExists: this.projectAgentsDir
|
|
323
|
-
? existsSync(this.projectAgentsDir)
|
|
324
|
-
: false,
|
|
325
|
-
};
|
|
101
|
+
return this.loader.getResolvedPolicyPaths();
|
|
326
102
|
}
|
|
327
103
|
|
|
328
104
|
getPolicyCacheStamp(agentName?: string): string {
|
|
329
|
-
|
|
330
|
-
? getFileStamp(join(this.agentsDir, `${agentName}.md`))
|
|
331
|
-
: "missing";
|
|
332
|
-
const projectStamp = this.projectGlobalConfigPath
|
|
333
|
-
? getFileStamp(this.projectGlobalConfigPath)
|
|
334
|
-
: "none";
|
|
335
|
-
const projectAgentStamp =
|
|
336
|
-
this.projectAgentsDir && agentName
|
|
337
|
-
? getFileStamp(join(this.projectAgentsDir, `${agentName}.md`))
|
|
338
|
-
: "none";
|
|
339
|
-
|
|
340
|
-
return `${getFileStamp(this.globalConfigPath)}|${projectStamp}|${agentStamp}|${projectAgentStamp}`;
|
|
105
|
+
return this.loader.getCacheStamp(agentName);
|
|
341
106
|
}
|
|
342
107
|
|
|
343
108
|
private resolvePermissions(agentName?: string): ResolvedPermissions {
|
|
344
109
|
const cacheKey = agentName || "__global__";
|
|
345
|
-
const stamp = this.
|
|
110
|
+
const stamp = this.loader.getCacheStamp(agentName);
|
|
346
111
|
const cached = this.resolvedPermissionsCache.get(cacheKey);
|
|
347
112
|
if (cached?.stamp === stamp) {
|
|
348
113
|
return cached.value;
|
|
349
114
|
}
|
|
350
115
|
|
|
351
|
-
const globalConfig = this.loadGlobalConfig();
|
|
352
|
-
const projectConfig = this.
|
|
353
|
-
const agentConfig = this.
|
|
354
|
-
const projectAgentConfig = this.
|
|
116
|
+
const globalConfig = this.loader.loadGlobalConfig();
|
|
117
|
+
const projectConfig = this.loader.loadProjectConfig();
|
|
118
|
+
const agentConfig = this.loader.loadAgentConfig(agentName);
|
|
119
|
+
const projectAgentConfig = this.loader.loadProjectAgentConfig(agentName);
|
|
355
120
|
|
|
356
121
|
// Merge permission objects across scopes (lowest → highest precedence).
|
|
357
122
|
// Build a parallel origin map that tracks which scope contributed each
|
|
@@ -442,24 +207,6 @@ export class PermissionManager {
|
|
|
442
207
|
return value;
|
|
443
208
|
}
|
|
444
209
|
|
|
445
|
-
private getConfiguredMcpServerNames(): readonly string[] {
|
|
446
|
-
if (this.configuredMcpServerNamesOverride) {
|
|
447
|
-
return this.configuredMcpServerNamesOverride;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const paths = [this.globalMcpConfigPath];
|
|
451
|
-
const stamp = paths
|
|
452
|
-
.map((path) => `${path}:${getFileStamp(path)}`)
|
|
453
|
-
.join("|");
|
|
454
|
-
if (this.configuredMcpServerNamesCache?.stamp === stamp) {
|
|
455
|
-
return this.configuredMcpServerNamesCache.value;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const value = getConfiguredMcpServerNamesFromPaths(paths);
|
|
459
|
-
this.configuredMcpServerNamesCache = { stamp, value };
|
|
460
|
-
return value;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
210
|
/**
|
|
464
211
|
* Return the composed config-layer rules for the given agent scope.
|
|
465
212
|
* Used by the `/permission-system show` command to display effective rules
|
|
@@ -518,7 +265,7 @@ export class PermissionManager {
|
|
|
518
265
|
const { surface, values, resultExtras } = normalizeInput(
|
|
519
266
|
normalizedToolName,
|
|
520
267
|
input,
|
|
521
|
-
this.getConfiguredMcpServerNames(),
|
|
268
|
+
this.loader.getConfiguredMcpServerNames(),
|
|
522
269
|
);
|
|
523
270
|
|
|
524
271
|
const { rule, value } = evaluateFirst(surface, values, fullRules);
|
|
@@ -579,4 +326,6 @@ function deriveSource(
|
|
|
579
326
|
|
|
580
327
|
// Keep isPermissionState and toRecord available for convenience — they are
|
|
581
328
|
// used directly in some handler files that import from permission-manager.
|
|
582
|
-
export { isPermissionState, toRecord };
|
|
329
|
+
export { isPermissionState, toRecord } from "./common";
|
|
330
|
+
// Re-export types that external modules import from this file.
|
|
331
|
+
export type { PolicyLoader, ResolvedPolicyPaths } from "./policy-loader";
|