@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.
@@ -19,25 +19,25 @@ export async function handleSessionStart(
19
19
  event: SessionStartPayload,
20
20
  ctx: ExtensionContext,
21
21
  ): Promise<void> {
22
- deps.runtime.runtimeContext = ctx;
22
+ deps.session.runtimeContext = ctx;
23
23
  deps.refreshExtensionConfig(ctx);
24
- deps.runtime.permissionManager = deps.createPermissionManagerForCwd(ctx.cwd);
25
- deps.runtime.activeSkillEntries = [];
26
- deps.runtime.lastActiveToolsCacheKey = null;
27
- deps.runtime.lastPromptStateCacheKey = null;
28
- deps.runtime.lastKnownActiveAgentName = getActiveAgentName(ctx);
24
+ deps.session.permissionManager = deps.createPermissionManagerForCwd(ctx.cwd);
25
+ deps.session.activeSkillEntries = [];
26
+ deps.session.lastActiveToolsCacheKey = null;
27
+ deps.session.lastPromptStateCacheKey = null;
28
+ deps.session.lastKnownActiveAgentName = getActiveAgentName(ctx);
29
29
  deps.startForwardedPermissionPolling(ctx);
30
30
  deps.logResolvedConfigPaths();
31
31
 
32
- const agentName = deps.runtime.lastKnownActiveAgentName;
32
+ const agentName = deps.session.lastKnownActiveAgentName;
33
33
  const policyIssues =
34
- deps.runtime.permissionManager.getConfigIssues(agentName);
34
+ deps.session.permissionManager.getConfigIssues(agentName);
35
35
  for (const issue of policyIssues) {
36
36
  deps.notifyWarning(issue);
37
37
  }
38
38
 
39
39
  if (event.reason === "reload") {
40
- deps.runtime.writeDebugLog("lifecycle.reload", {
40
+ deps.writeDebugLog("lifecycle.reload", {
41
41
  triggeredBy: "session_start",
42
42
  reason: event.reason,
43
43
  cwd: ctx.cwd,
@@ -53,14 +53,14 @@ export async function handleResourcesDiscover(
53
53
  return;
54
54
  }
55
55
 
56
- const { runtimeContext } = deps.runtime;
57
- deps.runtime.permissionManager = deps.createPermissionManagerForCwd(
56
+ const { runtimeContext } = deps.session;
57
+ deps.session.permissionManager = deps.createPermissionManagerForCwd(
58
58
  runtimeContext?.cwd,
59
59
  );
60
- deps.runtime.activeSkillEntries = [];
61
- deps.runtime.lastActiveToolsCacheKey = null;
62
- deps.runtime.lastPromptStateCacheKey = null;
63
- deps.runtime.writeDebugLog("lifecycle.reload", {
60
+ deps.session.activeSkillEntries = [];
61
+ deps.session.lastActiveToolsCacheKey = null;
62
+ deps.session.lastPromptStateCacheKey = null;
63
+ deps.writeDebugLog("lifecycle.reload", {
64
64
  triggeredBy: "resources_discover",
65
65
  reason: event.reason,
66
66
  cwd: runtimeContext?.cwd ?? null,
@@ -68,15 +68,15 @@ export async function handleResourcesDiscover(
68
68
  }
69
69
 
70
70
  export async function handleSessionShutdown(deps: HandlerDeps): Promise<void> {
71
- const { runtimeContext } = deps.runtime;
71
+ const { runtimeContext } = deps.session;
72
72
  if (runtimeContext) {
73
73
  runtimeContext.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
74
74
  }
75
- deps.runtime.runtimeContext = null;
76
- deps.runtime.activeSkillEntries = [];
77
- deps.runtime.lastActiveToolsCacheKey = null;
78
- deps.runtime.lastPromptStateCacheKey = null;
79
- deps.runtime.sessionRules.clear();
75
+ deps.session.runtimeContext = null;
76
+ deps.session.activeSkillEntries = [];
77
+ deps.session.lastActiveToolsCacheKey = null;
78
+ deps.session.lastPromptStateCacheKey = null;
79
+ deps.session.sessionRules.clear();
80
80
  deps.stopForwardedPermissionPolling();
81
81
  deps.stopPermissionRpcHandlers();
82
82
  }
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
 
3
3
  import { toRecord } from "../common";
4
+ import { emitDecisionEvent } from "../permission-events";
4
5
  import {
5
6
  formatMissingToolNameReason,
6
7
  formatUnknownToolReason,
@@ -13,8 +14,14 @@ import { evaluateBashExternalDirectoryGate } from "./gates/bash-external-directo
13
14
  import { evaluateExternalDirectoryGate } from "./gates/external-directory";
14
15
  import { evaluateSkillReadGate } from "./gates/skill-read";
15
16
  import { evaluateToolGate } from "./gates/tool";
16
- import type { ToolCallContext } from "./gates/types";
17
- import type { HandlerDeps } from "./types";
17
+ import type {
18
+ BashExternalDirectoryGateDeps,
19
+ ExternalDirectoryGateDeps,
20
+ SkillReadGateDeps,
21
+ ToolCallContext,
22
+ ToolGateDeps,
23
+ } from "./gates/types";
24
+ import type { HandlerDeps, PromptPermissionDetails } from "./types";
18
25
 
19
26
  /**
20
27
  * Extract the tool input from an event, checking both `input` and `arguments`
@@ -39,7 +46,7 @@ export async function handleToolCall(
39
46
  event: unknown,
40
47
  ctx: ExtensionContext,
41
48
  ): Promise<{ block?: true; reason?: string }> {
42
- deps.runtime.runtimeContext = ctx;
49
+ deps.session.runtimeContext = ctx;
43
50
  deps.startForwardedPermissionPolling(ctx);
44
51
 
45
52
  const agentName = deps.resolveAgentName(ctx);
@@ -81,26 +88,89 @@ export async function handleToolCall(
81
88
  cwd: ctx.cwd,
82
89
  };
83
90
 
91
+ // ── Shared gate adapter closures ───────────────────────────────────────
92
+ const canConfirm = () => deps.canRequestPermissionConfirmation(ctx);
93
+ const promptPermission = (details: PromptPermissionDetails) =>
94
+ deps.promptPermission(ctx, details);
95
+ const emitDecision = (e: Parameters<ToolGateDeps["emitDecision"]>[0]) =>
96
+ emitDecisionEvent(deps.events, e);
97
+ const { writeReviewLog } = deps;
98
+ const checkPermission: ToolGateDeps["checkPermission"] = (
99
+ surface,
100
+ input,
101
+ agent,
102
+ sessionRules,
103
+ ) =>
104
+ deps.session.permissionManager.checkPermission(
105
+ surface,
106
+ input,
107
+ agent,
108
+ sessionRules,
109
+ );
110
+ const getSessionRuleset = () => deps.session.sessionRules.getRuleset();
111
+ const approveSessionRule = (surface: string, pattern: string) =>
112
+ deps.session.sessionRules.approve(surface, pattern);
113
+
84
114
  // ── Skill-read gate ──────────────────────────────────────────────────────
85
- const skillResult = await evaluateSkillReadGate(tcc, deps);
115
+ const skillReadGateDeps: SkillReadGateDeps = {
116
+ getActiveSkillEntries: () => deps.session.activeSkillEntries,
117
+ writeReviewLog,
118
+ emitDecision,
119
+ canConfirm,
120
+ promptPermission,
121
+ };
122
+ const skillResult = await evaluateSkillReadGate(tcc, skillReadGateDeps);
86
123
  if (skillResult?.action === "block") {
87
124
  return { block: true, reason: skillResult.reason };
88
125
  }
89
126
 
90
127
  // ── External-directory gate (file tools) ─────────────────────────────────
91
- const extDirResult = await evaluateExternalDirectoryGate(tcc, deps);
128
+ const extDirGateDeps: ExternalDirectoryGateDeps = {
129
+ checkPermission,
130
+ getSessionRuleset,
131
+ approveSessionRule,
132
+ writeReviewLog,
133
+ emitDecision,
134
+ canConfirm,
135
+ promptPermission,
136
+ getInfrastructureDirs: () => [
137
+ ...deps.piInfrastructureDirs,
138
+ ...deps.getPiInfrastructureReadPaths(),
139
+ ],
140
+ };
141
+ const extDirResult = await evaluateExternalDirectoryGate(tcc, extDirGateDeps);
92
142
  if (extDirResult?.action === "block") {
93
143
  return { block: true, reason: extDirResult.reason };
94
144
  }
95
145
 
96
146
  // ── Bash external-directory gate ─────────────────────────────────────────
97
- const bashExtResult = await evaluateBashExternalDirectoryGate(tcc, deps);
147
+ const bashExtGateDeps: BashExternalDirectoryGateDeps = {
148
+ checkPermission,
149
+ getSessionRuleset,
150
+ approveSessionRule,
151
+ writeReviewLog,
152
+ canConfirm,
153
+ promptPermission,
154
+ };
155
+ const bashExtResult = await evaluateBashExternalDirectoryGate(
156
+ tcc,
157
+ bashExtGateDeps,
158
+ );
98
159
  if (bashExtResult?.action === "block") {
99
160
  return { block: true, reason: bashExtResult.reason };
100
161
  }
101
162
 
102
163
  // ── Normal tool permission gate ──────────────────────────────────────────
103
- const toolResult = await evaluateToolGate(tcc, deps);
164
+ const toolGateDeps: ToolGateDeps = {
165
+ checkPermission,
166
+ getSessionRuleset,
167
+ approveSessionRule,
168
+ writeReviewLog,
169
+ emitDecision,
170
+ canConfirm,
171
+ promptPermission,
172
+ };
173
+ const toolResult = await evaluateToolGate(tcc, toolGateDeps);
104
174
  if (toolResult.action === "block") {
105
175
  return { block: true, reason: toolResult.reason };
106
176
  }
@@ -3,7 +3,7 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
3
3
  import type { PermissionPromptDecision } from "../permission-dialog";
4
4
  import type { PermissionEventBus } from "../permission-events";
5
5
  import type { PermissionManager } from "../permission-manager";
6
- import type { ExtensionRuntime } from "../runtime";
6
+ import type { SessionState } from "../runtime";
7
7
 
8
8
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
9
9
 
@@ -27,13 +27,26 @@ export interface PromptPermissionDetails {
27
27
  /**
28
28
  * Explicit dependency bag passed to each extracted event handler.
29
29
  *
30
- * Mutable state lives in `runtime`; handlers read and write `deps.runtime.*`
31
- * directly instead of going through getter/setter pairs.
30
+ * Mutable session state lives in `session`; handlers read and write
31
+ * `deps.session.*` directly. Logging, infrastructure paths, and the
32
+ * event bus are promoted to top-level fields so handlers and gate
33
+ * adapters never reach through nested objects for leaf operations.
32
34
  */
33
35
  export interface HandlerDeps {
34
- // ── Runtime context ────────────────────────────────────────────────────
35
- /** All mutable extension state and log-writing methods. */
36
- readonly runtime: ExtensionRuntime;
36
+ // ── Session state ─────────────────────────────────────────────────────
37
+ /** Mutable session state: permissionManager, sessionRules, cache keys. */
38
+ readonly session: SessionState;
39
+
40
+ // ── Logging (promoted from runtime) ───────────────────────────────────
41
+ writeDebugLog(event: string, details?: Record<string, unknown>): void;
42
+ writeReviewLog(event: string, details?: Record<string, unknown>): void;
43
+
44
+ // ── Immutable infrastructure paths ───────────────────────────────────
45
+ readonly piInfrastructureDirs: string[];
46
+ /** Returns config-derived infrastructure read paths (current at call time). */
47
+ getPiInfrastructureReadPaths(): string[];
48
+
49
+ // ── Event bus ────────────────────────────────────────────────────────
37
50
  /** Event bus for emitting permissions:decision broadcast events. */
38
51
  readonly events: PermissionEventBus;
39
52
 
@@ -54,7 +67,7 @@ export interface HandlerDeps {
54
67
  // ── Permission helpers ─────────────────────────────────────────────────
55
68
  /**
56
69
  * Resolve the active agent name from the session context or system prompt.
57
- * Updates runtime.lastKnownActiveAgentName as a side effect.
70
+ * Updates session.lastKnownActiveAgentName as a side effect.
58
71
  */
59
72
  resolveAgentName(ctx: ExtensionContext, systemPrompt?: string): string | null;
60
73
  /** Whether the current context can show an interactive permission prompt. */
package/src/index.ts CHANGED
@@ -78,7 +78,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
78
78
  });
79
79
 
80
80
  const deps: HandlerDeps = {
81
- runtime,
81
+ session: runtime,
82
+ writeDebugLog: (event, details) => runtime.writeDebugLog(event, details),
83
+ writeReviewLog: (event, details) => runtime.writeReviewLog(event, details),
84
+ piInfrastructureDirs: runtime.piInfrastructureDirs,
85
+ getPiInfrastructureReadPaths: () =>
86
+ runtime.config.piInfrastructureReadPaths ?? [],
82
87
  events: pi.events,
83
88
  createPermissionManagerForCwd: (cwd) =>
84
89
  createPermissionManagerForCwd(runtime.agentDir, cwd),
@@ -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";