@gotgenes/pi-permission-system 10.2.0 → 10.3.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,13 @@ 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
+ ## [10.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.2.0...pi-permission-system-v10.3.0) (2026-06-05)
9
+
10
+
11
+ ### Features
12
+
13
+ * add ConfigStore owning extension config state ([5941733](https://github.com/gotgenes/pi-packages/commit/5941733a67c0ad9aef3d3b2e5908a82e76ac8603))
14
+
8
15
  ## [10.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.1.0...pi-permission-system-v10.2.0) (2026-06-04)
9
16
 
10
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "10.2.0",
3
+ "version": "10.3.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -5,6 +5,7 @@ import {
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import { type SettingItem, SettingsList } from "@earendil-works/pi-tui";
7
7
 
8
+ import type { CommandConfigStore } from "./config-store";
8
9
  import {
9
10
  DEFAULT_EXTENSION_CONFIG,
10
11
  type PermissionSystemExtensionConfig,
@@ -12,11 +13,7 @@ import {
12
13
  import type { Ruleset } from "./rule";
13
14
 
14
15
  interface PermissionSystemConfigController {
15
- getConfig(): PermissionSystemExtensionConfig;
16
- setConfig(
17
- next: PermissionSystemExtensionConfig,
18
- ctx: ExtensionCommandContext,
19
- ): void;
16
+ config: CommandConfigStore;
20
17
  getConfigPath(): string;
21
18
  /** Optional: returns the composed config-layer ruleset for origin display. */
22
19
  getComposedRules?(): Ruleset;
@@ -175,15 +172,15 @@ async function openSettingsModal(
175
172
  // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- ctx.ui.custom<void> is valid; rule does not allow void in generic fn call type args
176
173
  await ctx.ui.custom<void>(
177
174
  (_tui, _theme, _keybindings, done) => {
178
- let current = controller.getConfig();
175
+ let current = controller.config.current();
179
176
  const settingsList = new SettingsList(
180
177
  buildSettingItems(current),
181
178
  10,
182
179
  getSettingsListTheme(),
183
180
  (id, newValue) => {
184
181
  current = applySetting(current, id, newValue);
185
- controller.setConfig(current, ctx);
186
- current = controller.getConfig();
182
+ controller.config.save(current, ctx);
183
+ current = controller.config.current();
187
184
  syncSettingValues(settingsList, current);
188
185
  },
189
186
  () => done(),
@@ -208,7 +205,7 @@ function handleArgs(
208
205
  if (normalized === "show") {
209
206
  const rules = controller.getComposedRules?.();
210
207
  ctx.ui.notify(
211
- `permission-system: ${summarizeConfig(controller.getConfig(), rules)}`,
208
+ `permission-system: ${summarizeConfig(controller.config.current(), rules)}`,
212
209
  "info",
213
210
  );
214
211
  return true;
@@ -223,7 +220,7 @@ function handleArgs(
223
220
  }
224
221
 
225
222
  if (normalized === "reset") {
226
- controller.setConfig(cloneDefaultConfig(), ctx);
223
+ controller.config.save(cloneDefaultConfig(), ctx);
227
224
  ctx.ui.notify("Permission system settings reset to defaults.", "info");
228
225
  return true;
229
226
  }
@@ -0,0 +1,243 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ renameSync,
5
+ unlinkSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { dirname, normalize } from "node:path";
9
+ import type {
10
+ ExtensionCommandContext,
11
+ ExtensionContext,
12
+ } from "@earendil-works/pi-coding-agent";
13
+
14
+ import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
15
+ import {
16
+ getGlobalConfigPath,
17
+ getLegacyExtensionConfigPath,
18
+ getLegacyGlobalPolicyPath,
19
+ getLegacyProjectPolicyPath,
20
+ } from "./config-paths";
21
+ import { buildResolvedConfigLogEntry } from "./config-reporter";
22
+ import {
23
+ DEFAULT_EXTENSION_CONFIG,
24
+ EXTENSION_ROOT,
25
+ normalizePermissionSystemConfig,
26
+ type PermissionSystemExtensionConfig,
27
+ } from "./extension-config";
28
+ import type { ResolvedPolicyPaths } from "./policy-loader";
29
+ import { syncPermissionSystemStatus } from "./status";
30
+
31
+ /** Read-only view of the current config — for consumers that only read. */
32
+ export interface ConfigReader {
33
+ current(): PermissionSystemExtensionConfig;
34
+ }
35
+
36
+ /**
37
+ * Narrow subset of `ConfigStore` that `PermissionSession` depends on.
38
+ *
39
+ * Using an interface rather than the concrete class avoids private-member
40
+ * coupling between the class and test doubles.
41
+ */
42
+ export interface SessionConfigStore extends ConfigReader {
43
+ refresh(ctx?: ExtensionContext): void;
44
+ logResolvedPaths(): void;
45
+ }
46
+
47
+ /**
48
+ * Narrow subset of `ConfigStore` for the `/permission-system` command.
49
+ *
50
+ * Using an interface rather than the concrete class avoids private-member
51
+ * coupling between the class and test doubles.
52
+ */
53
+ export interface CommandConfigStore extends ConfigReader {
54
+ save(
55
+ next: PermissionSystemExtensionConfig,
56
+ ctx: ExtensionCommandContext,
57
+ ): void;
58
+ }
59
+
60
+ /**
61
+ * Transitional get/set seam over the runtime-owned context.
62
+ *
63
+ * Retired in Step 4 (#337) when context ownership moves to `PermissionSession`.
64
+ */
65
+ export interface RuntimeContextRef {
66
+ get(): ExtensionContext | null;
67
+ set(ctx: ExtensionContext): void;
68
+ }
69
+
70
+ /** Narrow logging sink — replaced by an injected logger in Step 3 (#336). */
71
+ export interface ConfigStoreLogger {
72
+ writeDebugLog(event: string, details?: Record<string, unknown>): void;
73
+ writeReviewLog(event: string, details?: Record<string, unknown>): void;
74
+ }
75
+
76
+ /** Narrow view of the manager's resolved policy paths (for `logResolvedPaths`). */
77
+ export interface ResolvedPolicyPathProvider {
78
+ getResolvedPolicyPaths(): ResolvedPolicyPaths;
79
+ }
80
+
81
+ export interface ConfigStoreDeps {
82
+ agentDir: string;
83
+ context: RuntimeContextRef;
84
+ policyPaths: ResolvedPolicyPathProvider;
85
+ logger: ConfigStoreLogger;
86
+ }
87
+
88
+ /**
89
+ * Owns the mutable extension config and the operations that read/write it.
90
+ *
91
+ * Replaces the three `(runtime, …)` config free functions
92
+ * (`refreshExtensionConfig`, `saveExtensionConfig`, `logResolvedConfigPaths`)
93
+ * with methods that privately own `config` and `lastConfigWarning`.
94
+ *
95
+ * Implements {@link ConfigReader} so consumers that only read the current config
96
+ * can depend on the narrow interface rather than the full class.
97
+ */
98
+ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
99
+ private config: PermissionSystemExtensionConfig;
100
+ private lastConfigWarning: string | null = null;
101
+
102
+ constructor(private readonly deps: ConfigStoreDeps) {
103
+ this.config = { ...DEFAULT_EXTENSION_CONFIG };
104
+ }
105
+
106
+ /** Return the current extension config. */
107
+ current(): PermissionSystemExtensionConfig {
108
+ return this.config;
109
+ }
110
+
111
+ /**
112
+ * Reload merged config from disk.
113
+ *
114
+ * If `ctx` is provided, updates the stored runtime context via the seam first.
115
+ * Equivalent to `refreshExtensionConfig(runtime, ctx?)`.
116
+ */
117
+ refresh(ctx?: ExtensionContext): void {
118
+ if (ctx) {
119
+ this.deps.context.set(ctx);
120
+ }
121
+ const cwd = this.deps.context.get()?.cwd ?? null;
122
+ const mergeResult = loadAndMergeConfigs(
123
+ this.deps.agentDir,
124
+ cwd ?? "",
125
+ EXTENSION_ROOT,
126
+ );
127
+ const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
128
+ this.config = runtimeConfig;
129
+
130
+ const currentCtx = this.deps.context.get();
131
+ if (currentCtx?.hasUI) {
132
+ syncPermissionSystemStatus(currentCtx, runtimeConfig);
133
+ }
134
+
135
+ const warning =
136
+ mergeResult.issues.length > 0 ? mergeResult.issues.join("\n") : undefined;
137
+
138
+ if (warning && warning !== this.lastConfigWarning) {
139
+ this.lastConfigWarning = warning;
140
+ currentCtx?.ui.notify(warning, "warning");
141
+ } else if (!warning) {
142
+ this.lastConfigWarning = null;
143
+ }
144
+
145
+ this.deps.logger.writeDebugLog("config.loaded", {
146
+ warning: warning ?? null,
147
+ debugLog: runtimeConfig.debugLog,
148
+ permissionReviewLog: runtimeConfig.permissionReviewLog,
149
+ yoloMode: runtimeConfig.yoloMode,
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Save updated runtime knobs to the global config file, then update
155
+ * the current config and sync UI status.
156
+ *
157
+ * Equivalent to `saveExtensionConfig(runtime, next, ctx)`.
158
+ */
159
+ // Called via the CommandConfigStore interface from config-modal.ts — fallow cannot trace through interfaces.
160
+ // fallow-ignore-next-line unused-class-member
161
+ save(
162
+ next: PermissionSystemExtensionConfig,
163
+ ctx: ExtensionCommandContext,
164
+ ): void {
165
+ const normalized = normalizePermissionSystemConfig(next);
166
+ const globalPath = getGlobalConfigPath(this.deps.agentDir);
167
+
168
+ const existing = loadUnifiedConfig(globalPath);
169
+ const merged = {
170
+ ...existing.config,
171
+ debugLog: normalized.debugLog,
172
+ permissionReviewLog: normalized.permissionReviewLog,
173
+ yoloMode: normalized.yoloMode,
174
+ };
175
+
176
+ const tmpPath = `${globalPath}.tmp`;
177
+ try {
178
+ mkdirSync(dirname(globalPath), { recursive: true });
179
+ writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
180
+ renameSync(tmpPath, globalPath);
181
+ } catch (error) {
182
+ try {
183
+ if (existsSync(tmpPath)) {
184
+ unlinkSync(tmpPath);
185
+ }
186
+ } catch {
187
+ // Ignore cleanup failures.
188
+ }
189
+ const message = error instanceof Error ? error.message : String(error);
190
+ ctx.ui.notify(
191
+ `Failed to save permission-system config at '${globalPath}': ${message}`,
192
+ "error",
193
+ );
194
+ return;
195
+ }
196
+
197
+ this.config = normalized;
198
+ syncPermissionSystemStatus(ctx, normalized);
199
+ this.lastConfigWarning = null;
200
+
201
+ this.deps.logger.writeDebugLog("config.saved", {
202
+ debugLog: normalized.debugLog,
203
+ permissionReviewLog: normalized.permissionReviewLog,
204
+ yoloMode: normalized.yoloMode,
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Write the resolved config path set to the review and debug logs.
210
+ *
211
+ * Equivalent to `logResolvedConfigPaths(runtime)`.
212
+ */
213
+ logResolvedPaths(): void {
214
+ const policyPaths = this.deps.policyPaths.getResolvedPolicyPaths();
215
+ const cwd = this.deps.context.get()?.cwd ?? null;
216
+ const { agentDir } = this.deps;
217
+ const legacyGlobalPolicyDetected = existsSync(
218
+ getLegacyGlobalPolicyPath(agentDir),
219
+ );
220
+ const legacyProjectPolicyDetected = cwd
221
+ ? existsSync(getLegacyProjectPolicyPath(cwd))
222
+ : false;
223
+ const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
224
+ const newGlobalPath = getGlobalConfigPath(agentDir);
225
+ const legacyExtensionConfigDetected =
226
+ normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
227
+ existsSync(legacyExtConfigPath);
228
+ const entry = buildResolvedConfigLogEntry({
229
+ policyPaths,
230
+ legacyGlobalPolicyDetected,
231
+ legacyProjectPolicyDetected,
232
+ legacyExtensionConfigDetected,
233
+ });
234
+ this.deps.logger.writeReviewLog(
235
+ "config.resolved",
236
+ entry as unknown as Record<string, unknown>,
237
+ );
238
+ this.deps.logger.writeDebugLog(
239
+ "config.resolved",
240
+ entry as unknown as Record<string, unknown>,
241
+ );
242
+ }
243
+ }
package/src/index.ts CHANGED
@@ -22,12 +22,7 @@ import { PermissionManager } from "./permission-manager";
22
22
  import { PermissionPrompter } from "./permission-prompter";
23
23
  import { PermissionSession } from "./permission-session";
24
24
  import { LocalPermissionsService } from "./permissions-service";
25
- import {
26
- createExtensionRuntime,
27
- logResolvedConfigPaths,
28
- refreshExtensionConfig,
29
- saveExtensionConfig,
30
- } from "./runtime";
25
+ import { createExtensionRuntime } from "./runtime";
31
26
  import { PermissionServiceLifecycle } from "./service-lifecycle";
32
27
  import { createSessionLogger } from "./session-logger";
33
28
  import { isSubagentExecutionContext } from "./subagent-context";
@@ -57,18 +52,18 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
57
52
  writeReviewLog: runtime.writeReviewLog.bind(runtime),
58
53
  requestPermissionDecisionFromUi,
59
54
  shouldAutoApprove: () =>
60
- shouldAutoApprovePermissionState("ask", runtime.config),
55
+ shouldAutoApprovePermissionState("ask", runtime.configStore.current()),
61
56
  };
62
57
  const forwarder = new PermissionForwarder(forwardingDeps);
63
58
 
64
59
  const prompter = new PermissionPrompter({
65
- getConfig: () => runtime.config,
60
+ config: runtime.configStore,
66
61
  writeReviewLog: runtime.writeReviewLog.bind(runtime),
67
62
  events: pi.events,
68
63
  forwarder,
69
64
  });
70
65
 
71
- refreshExtensionConfig(runtime);
66
+ runtime.configStore.refresh();
72
67
 
73
68
  const sessionManager = new PermissionManager({ agentDir: runtime.agentDir });
74
69
 
@@ -81,13 +76,11 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
81
76
  subagentRegistry,
82
77
  ),
83
78
  sessionManager,
79
+ runtime.configStore,
84
80
  {
85
- refreshExtensionConfig: (ctx) => refreshExtensionConfig(runtime, ctx),
86
- logResolvedConfigPaths: () => logResolvedConfigPaths(runtime),
87
- getConfig: () => runtime.config,
88
81
  canRequestPermissionConfirmation: (ctx) =>
89
82
  canResolveAskPermissionRequest({
90
- config: runtime.config,
83
+ config: runtime.configStore.current(),
91
84
  hasUI: ctx.hasUI,
92
85
  isSubagent: isSubagentExecutionContext(
93
86
  ctx,
@@ -100,8 +93,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
100
93
  );
101
94
 
102
95
  registerPermissionSystemCommand(pi, {
103
- getConfig: () => runtime.config,
104
- setConfig: (next, ctx) => saveExtensionConfig(runtime, next, ctx),
96
+ config: runtime.configStore,
105
97
  getConfigPath: () => getGlobalConfigPath(runtime.agentDir),
106
98
  getComposedRules: () =>
107
99
  runtime.permissionManager.getComposedConfigRules(
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { PermissionSystemExtensionConfig } from "./extension-config";
2
+ import type { ConfigReader } from "./config-store";
3
3
  import type { ApprovalRequester } from "./forwarded-permissions/permission-forwarder";
4
4
  import type { PermissionPromptDecision } from "./permission-dialog";
5
5
  import {
@@ -45,7 +45,7 @@ export interface PermissionPrompterApi {
45
45
  */
46
46
  export interface PermissionPrompterDeps {
47
47
  /** Read current config for yolo-mode check (called at prompt time). */
48
- getConfig(): PermissionSystemExtensionConfig;
48
+ config: ConfigReader;
49
49
  /** Write structured entries to the permission review log. */
50
50
  writeReviewLog(event: string, details: Record<string, unknown>): void;
51
51
  /** Event bus used for UI prompt broadcasts. */
@@ -72,7 +72,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
72
72
  ctx: ExtensionContext,
73
73
  details: PromptPermissionDetails,
74
74
  ): Promise<PermissionPromptDecision> {
75
- if (shouldAutoApprovePermissionState("ask", this.deps.getConfig())) {
75
+ if (shouldAutoApprovePermissionState("ask", this.deps.config.current())) {
76
76
  this.writeReviewEntry("permission_request.auto_approved", details);
77
77
  return { approved: true, state: "approved", autoApproved: true };
78
78
  }
@@ -5,6 +5,7 @@ import {
5
5
  getActiveAgentNameFromSystemPrompt,
6
6
  } from "./active-agent";
7
7
  import type { AgentPrepSession } from "./agent-prep-session";
8
+ import type { SessionConfigStore } from "./config-store";
8
9
  import type { PermissionSystemExtensionConfig } from "./extension-config";
9
10
  import type { ExtensionPaths } from "./extension-paths";
10
11
  import type { ForwardingController } from "./forwarding-manager";
@@ -34,12 +35,6 @@ import type { PermissionCheckResult, PermissionState } from "./types";
34
35
  * where the `ExtensionRuntime` is available.
35
36
  */
36
37
  export interface PermissionSessionRuntimeDeps {
37
- /** Reload merged config from disk; optionally update the stored runtime context. */
38
- refreshExtensionConfig(ctx?: ExtensionContext): void;
39
- /** Write the resolved config path set to the review and debug logs. */
40
- logResolvedConfigPaths(): void;
41
- /** Read current extension config (called at query time). */
42
- getConfig(): PermissionSystemExtensionConfig;
43
38
  /** Whether the current context can show an interactive permission prompt. */
44
39
  canRequestPermissionConfirmation(ctx: ExtensionContext): boolean;
45
40
  /** Prompt the user for a permission decision, log the outcome, and return it. */
@@ -61,7 +56,8 @@ export interface PermissionSessionRuntimeDeps {
61
56
  * - `ExtensionPaths` — immutable path constants
62
57
  * - `SessionLogger` — debug + review + warn
63
58
  * - `ForwardingController` — polling lifecycle
64
- * - `PermissionSessionRuntimeDeps` — config refresh + log delegates
59
+ * - `SessionConfigStore` — owns extension config; provides refresh, log, read
60
+ * - `PermissionSessionRuntimeDeps` — prompting + permission-confirmation bridge
65
61
  */
66
62
  export class PermissionSession
67
63
  implements
@@ -84,6 +80,7 @@ export class PermissionSession
84
80
  readonly logger: SessionLogger,
85
81
  private readonly forwarding: ForwardingController,
86
82
  private readonly permissionManager: ScopedPermissionManager,
83
+ private readonly configStore: SessionConfigStore,
87
84
  private readonly runtimeDeps: PermissionSessionRuntimeDeps,
88
85
  ) {}
89
86
 
@@ -260,17 +257,17 @@ export class PermissionSession
260
257
 
261
258
  /** Reload merged config from disk; optionally update the stored runtime context. */
262
259
  refreshConfig(ctx?: ExtensionContext): void {
263
- this.runtimeDeps.refreshExtensionConfig(ctx);
260
+ this.configStore.refresh(ctx);
264
261
  }
265
262
 
266
263
  /** Write the resolved config path set to the review and debug logs. */
267
264
  logResolvedConfigPaths(): void {
268
- this.runtimeDeps.logResolvedConfigPaths();
265
+ this.configStore.logResolvedPaths();
269
266
  }
270
267
 
271
268
  /** Read current extension config. */
272
269
  get config(): PermissionSystemExtensionConfig {
273
- return this.runtimeDeps.getConfig();
270
+ return this.configStore.current();
274
271
  }
275
272
 
276
273
  // ── Infrastructure paths ───────────────────────────────────────────────