@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 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -1,21 +1,12 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
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
- function readConfiguredMcpServerNamesFromConfigPath(
87
- configPath: string,
88
- ): string[] {
89
- try {
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
- type FileCacheEntry<TValue> = {
139
- stamp: string;
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 globalConfigPath: string;
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
- private accumulateConfigIssues(issues: string[]): void {
206
- for (const issue of issues) {
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.accumulatedConfigIssues];
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
- const agentStamp = agentName
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.getPolicyCacheStamp(agentName);
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.loadProjectGlobalConfig();
353
- const agentConfig = this.loadScopeConfig(agentName);
354
- const projectAgentConfig = this.loadProjectScopeConfig(agentName);
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";