@gotgenes/pi-permission-system 5.4.0 → 5.5.1

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.
@@ -0,0 +1,350 @@
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 { extractFrontmatter, parseSimpleYamlMap, toRecord } from "./common";
6
+ import {
7
+ loadUnifiedConfig,
8
+ normalizeUnifiedConfig,
9
+ stripJsonComments,
10
+ } from "./config-loader";
11
+ import { getGlobalConfigPath } from "./config-paths";
12
+ import type { ScopeConfig } from "./types";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // File-stamp helper
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function getFileStamp(path: string): string {
19
+ try {
20
+ return String(statSync(path).mtimeMs);
21
+ } catch {
22
+ return "missing";
23
+ }
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // MCP server-name reading helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function readConfiguredMcpServerNamesFromConfigPath(
31
+ configPath: string,
32
+ ): string[] {
33
+ try {
34
+ const raw = readFileSync(configPath, "utf-8");
35
+ const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
36
+ const root = toRecord(parsed);
37
+ const serverRecord = toRecord(root.mcpServers ?? root["mcp-servers"]);
38
+
39
+ return Object.keys(serverRecord)
40
+ .map((name) => name.trim())
41
+ .filter((name) => name.length > 0);
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ function getConfiguredMcpServerNamesFromPaths(
48
+ paths: readonly string[],
49
+ ): string[] {
50
+ const seen = new Set<string>();
51
+
52
+ for (const path of paths) {
53
+ for (const name of readConfiguredMcpServerNamesFromConfigPath(path)) {
54
+ seen.add(name);
55
+ }
56
+ }
57
+
58
+ return [...seen].sort(
59
+ (left, right) => right.length - left.length || left.localeCompare(right),
60
+ );
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Resolved policy paths
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export interface ResolvedPolicyPaths {
68
+ globalConfigPath: string;
69
+ globalConfigExists: boolean;
70
+ projectConfigPath: string | null;
71
+ projectConfigExists: boolean;
72
+ agentsDir: string;
73
+ agentsDirExists: boolean;
74
+ projectAgentsDir: string | null;
75
+ projectAgentsDirExists: boolean;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // PolicyLoader interface
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Abstraction over file I/O for loading permission policy from disk.
84
+ * Implementations handle caching, path resolution, and config-issue
85
+ * accumulation. `PermissionManager` depends on this interface so that
86
+ * merge + evaluation logic can be tested with an in-memory stub.
87
+ */
88
+ export interface PolicyLoader {
89
+ loadGlobalConfig(): ScopeConfig;
90
+ loadProjectConfig(): ScopeConfig;
91
+ loadAgentConfig(agentName?: string): ScopeConfig;
92
+ loadProjectAgentConfig(agentName?: string): ScopeConfig;
93
+ getConfiguredMcpServerNames(): readonly string[];
94
+ /** Combined mtime stamp for cache invalidation. */
95
+ getCacheStamp(agentName?: string): string;
96
+ /** Accumulated config-parse issues across all loads. */
97
+ getConfigIssues(): string[];
98
+ /** Resolved paths for the /permission-system show command. */
99
+ getResolvedPolicyPaths(): ResolvedPolicyPaths;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Default path factories (deferred until call-time, not module scope)
104
+ // ---------------------------------------------------------------------------
105
+
106
+ function defaultGlobalConfigPath(): string {
107
+ return getGlobalConfigPath(getAgentDir());
108
+ }
109
+ function defaultAgentsDir(): string {
110
+ return join(getAgentDir(), "agents");
111
+ }
112
+ function defaultGlobalMcpConfigPath(): string {
113
+ return join(getAgentDir(), "mcp.json");
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // File cache helper type
118
+ // ---------------------------------------------------------------------------
119
+
120
+ type FileCacheEntry<TValue> = {
121
+ stamp: string;
122
+ value: TValue;
123
+ };
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Options shared between FilePolicyLoader and the backward-compat
127
+ // PermissionManager constructor.
128
+ // ---------------------------------------------------------------------------
129
+
130
+ export interface PolicyLoaderOptions {
131
+ globalConfigPath?: string;
132
+ agentsDir?: string;
133
+ projectGlobalConfigPath?: string;
134
+ projectAgentsDir?: string;
135
+ globalMcpConfigPath?: string;
136
+ mcpServerNames?: readonly string[];
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // FilePolicyLoader — the production implementation
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Production `PolicyLoader` that reads config files from disk with
145
+ * mtime-based caching.
146
+ */
147
+ export class FilePolicyLoader implements PolicyLoader {
148
+ private readonly globalConfigPath: string;
149
+ private readonly agentsDir: string;
150
+ private readonly projectGlobalConfigPath: string | null;
151
+ private readonly projectAgentsDir: string | null;
152
+ private readonly globalMcpConfigPath: string;
153
+ private readonly configuredMcpServerNamesOverride: readonly string[] | null;
154
+
155
+ private globalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
156
+ private projectGlobalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
157
+ private readonly agentConfigCache = new Map<
158
+ string,
159
+ FileCacheEntry<ScopeConfig>
160
+ >();
161
+ private readonly projectAgentConfigCache = new Map<
162
+ string,
163
+ FileCacheEntry<ScopeConfig>
164
+ >();
165
+ private configuredMcpServerNamesCache: FileCacheEntry<
166
+ readonly string[]
167
+ > | null = null;
168
+ private accumulatedConfigIssues: string[] = [];
169
+
170
+ constructor(options: PolicyLoaderOptions = {}) {
171
+ this.globalConfigPath =
172
+ options.globalConfigPath || defaultGlobalConfigPath();
173
+ this.agentsDir = options.agentsDir || defaultAgentsDir();
174
+ this.projectGlobalConfigPath = options.projectGlobalConfigPath || null;
175
+ this.projectAgentsDir = options.projectAgentsDir || null;
176
+ this.globalMcpConfigPath =
177
+ options.globalMcpConfigPath || defaultGlobalMcpConfigPath();
178
+ this.configuredMcpServerNamesOverride = options.mcpServerNames
179
+ ? [
180
+ ...new Set(
181
+ options.mcpServerNames
182
+ .map((name) => name.trim())
183
+ .filter((name) => name.length > 0),
184
+ ),
185
+ ]
186
+ : null;
187
+ }
188
+
189
+ // ── Config issue accumulation ────────────────────────────────────────
190
+
191
+ private accumulateConfigIssues(issues: string[]): void {
192
+ for (const issue of issues) {
193
+ if (!this.accumulatedConfigIssues.includes(issue)) {
194
+ this.accumulatedConfigIssues.push(issue);
195
+ }
196
+ }
197
+ }
198
+
199
+ getConfigIssues(): string[] {
200
+ return [...this.accumulatedConfigIssues];
201
+ }
202
+
203
+ // ── Scope loaders ────────────────────────────────────────────────────
204
+
205
+ loadGlobalConfig(): ScopeConfig {
206
+ const stamp = getFileStamp(this.globalConfigPath);
207
+ if (this.globalConfigCache?.stamp === stamp) {
208
+ return this.globalConfigCache.value;
209
+ }
210
+
211
+ const { config, issues } = loadUnifiedConfig(this.globalConfigPath);
212
+ this.accumulateConfigIssues(issues);
213
+
214
+ const value: ScopeConfig = {
215
+ permission: config.permission,
216
+ };
217
+
218
+ this.globalConfigCache = { stamp, value };
219
+ return value;
220
+ }
221
+
222
+ loadProjectConfig(): ScopeConfig {
223
+ if (!this.projectGlobalConfigPath) {
224
+ return {};
225
+ }
226
+
227
+ const stamp = getFileStamp(this.projectGlobalConfigPath);
228
+ if (this.projectGlobalConfigCache?.stamp === stamp) {
229
+ return this.projectGlobalConfigCache.value;
230
+ }
231
+
232
+ const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath);
233
+ this.accumulateConfigIssues(issues);
234
+
235
+ const value: ScopeConfig = {
236
+ permission: config.permission,
237
+ };
238
+
239
+ this.projectGlobalConfigCache = { stamp, value };
240
+ return value;
241
+ }
242
+
243
+ private loadScopeConfigFrom(
244
+ dir: string | null,
245
+ cache: Map<string, FileCacheEntry<ScopeConfig>>,
246
+ agentName?: string,
247
+ ): ScopeConfig {
248
+ if (!dir || !agentName) {
249
+ return {};
250
+ }
251
+
252
+ const filePath = join(dir, `${agentName}.md`);
253
+ const stamp = getFileStamp(filePath);
254
+ const cached = cache.get(agentName);
255
+ if (cached?.stamp === stamp) {
256
+ return cached.value;
257
+ }
258
+
259
+ let value: ScopeConfig;
260
+ try {
261
+ const markdown = readFileSync(filePath, "utf-8");
262
+ const frontmatter = extractFrontmatter(markdown);
263
+ if (!frontmatter) {
264
+ value = {};
265
+ } else {
266
+ const parsed = parseSimpleYamlMap(frontmatter);
267
+ const { config, issues } = normalizeUnifiedConfig(parsed);
268
+ this.accumulateConfigIssues(issues);
269
+ value = { permission: config.permission };
270
+ }
271
+ } catch {
272
+ value = {};
273
+ }
274
+
275
+ cache.set(agentName, { stamp, value });
276
+ return value;
277
+ }
278
+
279
+ loadAgentConfig(agentName?: string): ScopeConfig {
280
+ return this.loadScopeConfigFrom(
281
+ this.agentsDir,
282
+ this.agentConfigCache,
283
+ agentName,
284
+ );
285
+ }
286
+
287
+ loadProjectAgentConfig(agentName?: string): ScopeConfig {
288
+ return this.loadScopeConfigFrom(
289
+ this.projectAgentsDir,
290
+ this.projectAgentConfigCache,
291
+ agentName,
292
+ );
293
+ }
294
+
295
+ // ── MCP server names ─────────────────────────────────────────────────
296
+
297
+ getConfiguredMcpServerNames(): readonly string[] {
298
+ if (this.configuredMcpServerNamesOverride) {
299
+ return this.configuredMcpServerNamesOverride;
300
+ }
301
+
302
+ const paths = [this.globalMcpConfigPath];
303
+ const stamp = paths
304
+ .map((path) => `${path}:${getFileStamp(path)}`)
305
+ .join("|");
306
+ if (this.configuredMcpServerNamesCache?.stamp === stamp) {
307
+ return this.configuredMcpServerNamesCache.value;
308
+ }
309
+
310
+ const value = getConfiguredMcpServerNamesFromPaths(paths);
311
+ this.configuredMcpServerNamesCache = { stamp, value };
312
+ return value;
313
+ }
314
+
315
+ // ── Cache stamp ───────────────────────────────────────────────────────
316
+
317
+ getCacheStamp(agentName?: string): string {
318
+ const agentStamp = agentName
319
+ ? getFileStamp(join(this.agentsDir, `${agentName}.md`))
320
+ : "missing";
321
+ const projectStamp = this.projectGlobalConfigPath
322
+ ? getFileStamp(this.projectGlobalConfigPath)
323
+ : "none";
324
+ const projectAgentStamp =
325
+ this.projectAgentsDir && agentName
326
+ ? getFileStamp(join(this.projectAgentsDir, `${agentName}.md`))
327
+ : "none";
328
+
329
+ return `${getFileStamp(this.globalConfigPath)}|${projectStamp}|${agentStamp}|${projectAgentStamp}`;
330
+ }
331
+
332
+ // ── Resolved paths ────────────────────────────────────────────────────
333
+
334
+ getResolvedPolicyPaths(): ResolvedPolicyPaths {
335
+ return {
336
+ globalConfigPath: this.globalConfigPath,
337
+ globalConfigExists: existsSync(this.globalConfigPath),
338
+ projectConfigPath: this.projectGlobalConfigPath,
339
+ projectConfigExists: this.projectGlobalConfigPath
340
+ ? existsSync(this.projectGlobalConfigPath)
341
+ : false,
342
+ agentsDir: this.agentsDir,
343
+ agentsDirExists: existsSync(this.agentsDir),
344
+ projectAgentsDir: this.projectAgentsDir,
345
+ projectAgentsDirExists: this.projectAgentsDir
346
+ ? existsSync(this.projectAgentsDir)
347
+ : false,
348
+ };
349
+ }
350
+ }
package/src/runtime.ts CHANGED
@@ -47,6 +47,21 @@ import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
47
47
  import { syncPermissionSystemStatus } from "./status";
48
48
  import { isSubagentExecutionContext } from "./subagent-context";
49
49
 
50
+ /**
51
+ * Mutable session state — the subset of ExtensionRuntime that handlers
52
+ * read and write. Lifecycle handlers reset fields here on session
53
+ * start/shutdown; gate adapters read permissionManager and sessionRules.
54
+ */
55
+ export interface SessionState {
56
+ runtimeContext: ExtensionContext | null;
57
+ permissionManager: PermissionManager;
58
+ readonly sessionRules: SessionRules;
59
+ activeSkillEntries: SkillPromptEntry[];
60
+ lastKnownActiveAgentName: string | null;
61
+ lastActiveToolsCacheKey: string | null;
62
+ lastPromptStateCacheKey: string | null;
63
+ }
64
+
50
65
  /**
51
66
  * Runtime context object created once inside `piPermissionSystemExtension()`.
52
67
  *
@@ -58,7 +73,7 @@ import { isSubagentExecutionContext } from "./subagent-context";
58
73
  * Tests construct this via `createExtensionRuntime({ agentDir: tmpDir })`
59
74
  * without timing issues around `PI_CODING_AGENT_DIR`.
60
75
  */
61
- export interface ExtensionRuntime {
76
+ export interface ExtensionRuntime extends SessionState {
62
77
  // ── Immutable paths (derived from agentDir at construction) ───────────
63
78
  readonly agentDir: string;
64
79
  readonly sessionsDir: string;
@@ -74,16 +89,9 @@ export interface ExtensionRuntime {
74
89
  */
75
90
  readonly piInfrastructureDirs: string[];
76
91
 
77
- // ── Mutable state ──────────────────────────────────────────────────────
92
+ // ── Mutable state (beyond SessionState) ───────────────────────────────────
78
93
  config: PermissionSystemExtensionConfig;
79
- runtimeContext: ExtensionContext | null;
80
- permissionManager: PermissionManager;
81
- activeSkillEntries: SkillPromptEntry[];
82
- lastKnownActiveAgentName: string | null;
83
- lastActiveToolsCacheKey: string | null;
84
- lastPromptStateCacheKey: string | null;
85
94
  lastConfigWarning: string | null;
86
- readonly sessionRules: SessionRules;
87
95
 
88
96
  // ── Forwarding polling state ───────────────────────────────────────────
89
97
  permissionForwardingContext: ExtensionContext | null;
@@ -7,7 +7,7 @@ import {
7
7
  } from "../../src/handlers/before-agent-start";
8
8
  import type { HandlerDeps } from "../../src/handlers/types";
9
9
  import type { PermissionManager } from "../../src/permission-manager";
10
- import type { ExtensionRuntime } from "../../src/runtime";
10
+ import type { SessionState } from "../../src/runtime";
11
11
  import type { SkillPromptEntry } from "../../src/skill-prompt-sanitizer";
12
12
 
13
13
  // ── SDK stubs ──────────────────────────────────────────────────────────────
@@ -57,40 +57,30 @@ function makePm(
57
57
  } as unknown as PermissionManager;
58
58
  }
59
59
 
60
- function makeRuntime(
61
- overrides: Partial<ExtensionRuntime> = {},
62
- ): ExtensionRuntime {
60
+ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
63
61
  return {
64
- agentDir: "/test/agent",
65
- sessionsDir: "/test/agent/sessions",
66
- subagentSessionsDir: "/test/agent/subagent-sessions",
67
- forwardingDir: "/test/agent/sessions/permission-forwarding",
68
- globalLogsDir: "/test/agent/extensions/pi-permission-system/logs",
69
- config: { debugLog: false, permissionReviewLog: true, yoloMode: false },
70
62
  runtimeContext: null,
71
63
  permissionManager: makePm() as unknown as PermissionManager,
72
64
  activeSkillEntries: [] as SkillPromptEntry[],
73
65
  lastKnownActiveAgentName: null,
74
66
  lastActiveToolsCacheKey: null,
75
67
  lastPromptStateCacheKey: null,
76
- lastConfigWarning: null,
77
68
  sessionRules: {
78
69
  approve: vi.fn(),
79
70
  getRuleset: vi.fn().mockReturnValue([]),
80
71
  clear: vi.fn(),
81
- } as unknown as ExtensionRuntime["sessionRules"],
82
- permissionForwardingContext: null,
83
- permissionForwardingTimer: null,
84
- isProcessingForwardedRequests: false,
85
- writeDebugLog: vi.fn(),
86
- writeReviewLog: vi.fn(),
72
+ } as unknown as SessionState["sessionRules"],
87
73
  ...overrides,
88
- } as ExtensionRuntime;
74
+ };
89
75
  }
90
76
 
91
77
  function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
92
78
  return {
93
- runtime: makeRuntime(),
79
+ session: makeSession(),
80
+ writeDebugLog: vi.fn(),
81
+ writeReviewLog: vi.fn(),
82
+ piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
83
+ getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
94
84
  createPermissionManagerForCwd: vi.fn().mockReturnValue(makePm()),
95
85
  refreshExtensionConfig: vi.fn(),
96
86
  notifyWarning: vi.fn(),
@@ -176,7 +166,7 @@ describe("handleBeforeAgentStart", () => {
176
166
  it("filters out denied tools from allowed list", async () => {
177
167
  const pm = makePm("deny");
178
168
  const deps = makeDeps({
179
- runtime: makeRuntime({
169
+ session: makeSession({
180
170
  permissionManager: pm as unknown as PermissionManager,
181
171
  }),
182
172
  getAllTools: vi
@@ -191,7 +181,7 @@ describe("handleBeforeAgentStart", () => {
191
181
  it("includes allowed and ask tools in the active list", async () => {
192
182
  const pm = makePm("allow");
193
183
  const deps = makeDeps({
194
- runtime: makeRuntime({
184
+ session: makeSession({
195
185
  permissionManager: pm as unknown as PermissionManager,
196
186
  }),
197
187
  getAllTools: vi
@@ -207,7 +197,7 @@ describe("handleBeforeAgentStart", () => {
207
197
  getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
208
198
  });
209
199
  await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
210
- expect(deps.runtime.lastActiveToolsCacheKey).not.toBeNull();
200
+ expect(deps.session.lastActiveToolsCacheKey).not.toBeNull();
211
201
  });
212
202
 
213
203
  it("skips setActiveTools when cache key is unchanged", async () => {
@@ -217,7 +207,7 @@ describe("handleBeforeAgentStart", () => {
217
207
  );
218
208
  const key = createActiveToolsCacheKey(["read"]);
219
209
  const deps = makeDeps({
220
- runtime: makeRuntime({ lastActiveToolsCacheKey: key }),
210
+ session: makeSession({ lastActiveToolsCacheKey: key }),
221
211
  getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
222
212
  });
223
213
  await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
@@ -238,7 +228,7 @@ describe("handleBeforeAgentStart", () => {
238
228
  );
239
229
  // The prompt was modified, so systemPrompt should be returned
240
230
  expect(result).toHaveProperty("systemPrompt");
241
- expect(deps.runtime.lastPromptStateCacheKey).not.toBeNull();
231
+ expect(deps.session.lastPromptStateCacheKey).not.toBeNull();
242
232
  });
243
233
 
244
234
  it("returns empty object when systemPrompt is unchanged", async () => {
@@ -259,7 +249,7 @@ describe("handleBeforeAgentStart", () => {
259
249
  getAllTools: vi.fn().mockReturnValue([]),
260
250
  });
261
251
  await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
262
- expect(deps.runtime.activeSkillEntries).toEqual(expect.any(Array));
252
+ expect(deps.session.activeSkillEntries).toEqual(expect.any(Array));
263
253
  });
264
254
 
265
255
  it("returns empty object and skips prompt work when prompt cache key is unchanged", async () => {
@@ -277,7 +267,7 @@ describe("handleBeforeAgentStart", () => {
277
267
  allowedToolNames: allowedTools,
278
268
  });
279
269
  const deps = makeDeps({
280
- runtime: makeRuntime({
270
+ session: makeSession({
281
271
  permissionManager: pm as unknown as PermissionManager,
282
272
  lastPromptStateCacheKey: key,
283
273
  }),
@@ -286,6 +276,6 @@ describe("handleBeforeAgentStart", () => {
286
276
  const result = await handleBeforeAgentStart(deps, makeEvent("hello"), ctx);
287
277
  expect(result).toEqual({});
288
278
  // activeSkillEntries was not assigned by the handler (early return)
289
- expect(deps.runtime.activeSkillEntries).toEqual([]);
279
+ expect(deps.session.activeSkillEntries).toEqual([]);
290
280
  });
291
281
  });