pi-permission-system 0.2.2 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ 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
+ ## [0.3.1] - 2026-03-24
9
+
10
+ ### Added
11
+ - Permission system status module (`status.ts`) to expose yolo mode status to the UI
12
+ - `syncPermissionSystemStatus()` function to sync status with the TUI status bar
13
+ - `PERMISSION_SYSTEM_STATUS_KEY` and `PERMISSION_SYSTEM_YOLO_STATUS_VALUE` constants for status identification
14
+
15
+ ### Changed
16
+ - Integrated status sync on config load, config save, and extension unload
17
+ - Status is only exposed when yolo mode is enabled
18
+
19
+ ### Tests
20
+ - Added test for permission-system status being undefined when yolo mode is disabled and "yolo" when enabled
21
+
22
+ ## [0.3.0] - 2026-03-23
23
+
24
+ ### Added
25
+ - Yolo mode for auto-approval when enabled — bypasses permission prompts for streamlined workflows
26
+ - Permission forwarding system for subagent-to-primary IPC communication
27
+ - Configuration modal UI with Zellij integration (`config-modal.ts`, `zellij-modal.ts`)
28
+ - `permission-forwarding.ts` module for subagent permission request routing
29
+ - `yolo-mode.ts` module for automatic permission approval when yolo mode is active
30
+
31
+ ### Changed
32
+ - Updated `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` peer dependencies to ^0.62.0
33
+ - Refactored `index.ts` to export new permission resolution utilities
34
+ - Expanded `extension-config.ts` with config normalization for new features
35
+ - Added `types-shims.d.ts` for Zellij modal type definitions
36
+
37
+ ### Tests
38
+ - Added comprehensive tests for config modal functionality
39
+ - Added tests for permission forwarding behavior
40
+
8
41
  ## [0.2.2] - 2026-03-13
9
42
 
10
43
  ### Changed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 🔐 pi-permission-system
2
2
 
3
- [![Version](https://img.shields.io/badge/version-0.2.1-blue.svg)](package.json)
3
+ [![Version](https://img.shields.io/badge/version-0.3.1-blue.svg)](package.json)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
5
 
6
6
  Permission enforcement extension for the Pi coding agent that provides centralized, deterministic permission gates for tool, bash, MCP, skill, and special operations.
package/config.json CHANGED
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "debugLog": false,
3
- "permissionReviewLog": true
3
+ "permissionReviewLog": true,
4
+ "yoloMode": true
4
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-permission-system",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -21,7 +21,7 @@
21
21
  "scripts": {
22
22
  "build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
23
23
  "lint": "npm run build",
24
- "test": "bun ./src/test.ts",
24
+ "test": "bun ./src/test.ts && bun ./src/config-modal-test.ts",
25
25
  "check": "npm run lint && npm run test"
26
26
  },
27
27
  "keywords": [
@@ -54,7 +54,8 @@
54
54
  ]
55
55
  },
56
56
  "peerDependencies": {
57
- "@mariozechner/pi-coding-agent": "*",
58
- "@sinclair/typebox": "*"
57
+ "@mariozechner/pi-coding-agent": "^0.62.0",
58
+ "@mariozechner/pi-tui": "^0.62.0",
59
+ "@sinclair/typebox": "^0.34.48"
59
60
  }
60
61
  }
@@ -0,0 +1,217 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { mock } from "bun:test";
6
+
7
+ import {
8
+ DEFAULT_EXTENSION_CONFIG,
9
+ loadPermissionSystemConfig,
10
+ savePermissionSystemConfig,
11
+ type PermissionSystemExtensionConfig,
12
+ } from "./extension-config.js";
13
+
14
+ mock.module("@mariozechner/pi-coding-agent", () => ({
15
+ getSettingsListTheme: () => ({}),
16
+ }));
17
+
18
+ mock.module("@mariozechner/pi-tui", () => ({
19
+ Box: class {},
20
+ Container: class {
21
+ addChild(): void {}
22
+ render(): string[] {
23
+ return [];
24
+ }
25
+ invalidate(): void {}
26
+ },
27
+ SettingsList: class {
28
+ handleInput(): void {}
29
+ updateValue(): void {}
30
+ render(): string[] {
31
+ return [];
32
+ }
33
+ invalidate(): void {}
34
+ },
35
+ Spacer: class {},
36
+ Text: class {},
37
+ truncateToWidth: (text: string) => text,
38
+ visibleWidth: (text: string) => text.length,
39
+ }));
40
+
41
+ const { registerPermissionSystemCommand } = await import("./config-modal.js");
42
+
43
+ type Notification = { message: string; level: "info" | "warning" | "error" };
44
+
45
+ type CommandContextStub = {
46
+ hasUI: boolean;
47
+ ui: {
48
+ notify(message: string, level: "info" | "warning" | "error"): void;
49
+ custom<T>(renderer: (...args: unknown[]) => unknown, options?: unknown): Promise<T>;
50
+ };
51
+ };
52
+
53
+ function runTest(name: string, testFn: () => void): void {
54
+ testFn();
55
+ console.log(`[PASS] ${name}`);
56
+ }
57
+
58
+ async function runAsyncTest(name: string, testFn: () => Promise<void>): Promise<void> {
59
+ await testFn();
60
+ console.log(`[PASS] ${name}`);
61
+ }
62
+
63
+ function createCommandContext(
64
+ hasUI: boolean,
65
+ ): { ctx: CommandContextStub; notifications: Notification[]; getCustomCalls(): number } {
66
+ const notifications: Notification[] = [];
67
+ let customCalls = 0;
68
+
69
+ return {
70
+ ctx: {
71
+ hasUI,
72
+ ui: {
73
+ notify(message: string, level: "info" | "warning" | "error") {
74
+ notifications.push({ message, level });
75
+ },
76
+ async custom<T>(_renderer: (...args: unknown[]) => unknown, _options?: unknown): Promise<T> {
77
+ customCalls += 1;
78
+ return undefined as T;
79
+ },
80
+ },
81
+ },
82
+ notifications,
83
+ getCustomCalls: () => customCalls,
84
+ };
85
+ }
86
+
87
+ function lastNotification(notifications: Notification[]): Notification {
88
+ return notifications[notifications.length - 1] as Notification;
89
+ }
90
+
91
+ runTest("permission-system command completions expose top-level config actions", () => {
92
+ const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-command-completions-"));
93
+ const configPath = join(baseDir, "config.json");
94
+ let config: PermissionSystemExtensionConfig = { ...DEFAULT_EXTENSION_CONFIG };
95
+
96
+ try {
97
+ const controller = {
98
+ getConfig: () => config,
99
+ setConfig: (next: PermissionSystemExtensionConfig) => {
100
+ config = next;
101
+ },
102
+ getConfigPath: () => configPath,
103
+ };
104
+
105
+ let definition: {
106
+ description: string;
107
+ getArgumentCompletions?: (argumentPrefix: string) => Array<{ value: string; label: string; description?: string }> | null;
108
+ handler: (args: string, ctx: CommandContextStub) => Promise<void>;
109
+ } | null = null;
110
+
111
+ registerPermissionSystemCommand(
112
+ {
113
+ registerCommand(_name: string, nextDefinition: typeof definition) {
114
+ definition = nextDefinition;
115
+ },
116
+ } as never,
117
+ controller as never,
118
+ );
119
+
120
+ assert.ok(definition !== null);
121
+ assert.ok(typeof definition?.getArgumentCompletions === "function");
122
+
123
+ const topLevel = definition?.getArgumentCompletions?.("");
124
+ assert.ok(Array.isArray(topLevel));
125
+ assert.ok(topLevel?.some((item) => item.value === "show"));
126
+ assert.ok(topLevel?.some((item) => item.value === "reset"));
127
+
128
+ const filtered = definition?.getArgumentCompletions?.("pa");
129
+ assert.deepEqual(filtered?.map((item) => item.value), ["path"]);
130
+ assert.equal(definition?.getArgumentCompletions?.("path extra"), null);
131
+ assert.equal(definition?.getArgumentCompletions?.("zzz"), null);
132
+ } finally {
133
+ rmSync(baseDir, { recursive: true, force: true });
134
+ }
135
+ });
136
+
137
+ await runAsyncTest("permission-system command handlers manage config summary, persistence, and modal routing", async () => {
138
+ const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-command-"));
139
+ const configPath = join(baseDir, "config.json");
140
+ let config: PermissionSystemExtensionConfig = {
141
+ debugLog: true,
142
+ permissionReviewLog: false,
143
+ yoloMode: true,
144
+ };
145
+
146
+ try {
147
+ const initialSave = savePermissionSystemConfig(config, configPath);
148
+ assert.equal(initialSave.success, true);
149
+
150
+ const controller = {
151
+ getConfig: () => config,
152
+ setConfig: (next: PermissionSystemExtensionConfig) => {
153
+ const normalized = loadPermissionSystemConfig(configPath).config;
154
+ const saved = savePermissionSystemConfig(next, configPath);
155
+ assert.equal(saved.success, true);
156
+ config = loadPermissionSystemConfig(configPath).config;
157
+ assert.notDeepEqual(config, normalized);
158
+ },
159
+ getConfigPath: () => configPath,
160
+ };
161
+
162
+ let registeredName = "";
163
+ let definition: {
164
+ description: string;
165
+ getArgumentCompletions?: (argumentPrefix: string) => Array<{ value: string; label: string; description?: string }> | null;
166
+ handler: (args: string, ctx: CommandContextStub) => Promise<void>;
167
+ } | null = null;
168
+
169
+ registerPermissionSystemCommand(
170
+ {
171
+ registerCommand(name: string, nextDefinition: typeof definition) {
172
+ registeredName = name;
173
+ definition = nextDefinition;
174
+ },
175
+ } as never,
176
+ controller as never,
177
+ );
178
+
179
+ assert.equal(registeredName, "permission-system");
180
+ assert.ok(definition !== null);
181
+ assert.ok((definition?.description ?? "").includes("Configure pi-permission-system"));
182
+
183
+ const infoCtx = createCommandContext(true);
184
+ await definition?.handler("show", infoCtx.ctx);
185
+ assert.ok(lastNotification(infoCtx.notifications).message.includes("yoloMode=on"));
186
+ assert.ok(lastNotification(infoCtx.notifications).message.includes("debugLog=on"));
187
+
188
+ await definition?.handler("path", infoCtx.ctx);
189
+ assert.equal(lastNotification(infoCtx.notifications).message, `permission-system config: ${configPath}`);
190
+
191
+ await definition?.handler("help", infoCtx.ctx);
192
+ assert.ok(lastNotification(infoCtx.notifications).message.includes("Usage: /permission-system"));
193
+
194
+ await definition?.handler("reset", infoCtx.ctx);
195
+ assert.deepEqual(config, DEFAULT_EXTENSION_CONFIG);
196
+ assert.equal(lastNotification(infoCtx.notifications).message, "Permission system settings reset to defaults.");
197
+
198
+ const persisted = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
199
+ assert.deepEqual(persisted, DEFAULT_EXTENSION_CONFIG);
200
+
201
+ await definition?.handler("unknown", infoCtx.ctx);
202
+ assert.equal(lastNotification(infoCtx.notifications).level, "warning");
203
+ assert.ok(lastNotification(infoCtx.notifications).message.includes("Usage: /permission-system"));
204
+
205
+ const headlessCtx = createCommandContext(false);
206
+ await definition?.handler("", headlessCtx.ctx);
207
+ assert.equal(lastNotification(headlessCtx.notifications).message, "/permission-system requires interactive TUI mode.");
208
+
209
+ const modalCtx = createCommandContext(true);
210
+ await definition?.handler("", modalCtx.ctx);
211
+ assert.equal(modalCtx.getCustomCalls(), 1);
212
+ } finally {
213
+ rmSync(baseDir, { recursive: true, force: true });
214
+ }
215
+ });
216
+
217
+ console.log("All permission-system config-modal tests passed.");
@@ -0,0 +1,231 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
+ import type { SettingItem } from "@mariozechner/pi-tui";
3
+
4
+ import { DEFAULT_EXTENSION_CONFIG, type PermissionSystemExtensionConfig } from "./extension-config.js";
5
+ import { ZellijModal, ZellijSettingsModal } from "./zellij-modal.js";
6
+
7
+ interface PermissionSystemConfigController {
8
+ getConfig(): PermissionSystemExtensionConfig;
9
+ setConfig(next: PermissionSystemExtensionConfig, ctx: ExtensionCommandContext): void;
10
+ getConfigPath(): string;
11
+ }
12
+
13
+ interface SettingValueSyncTarget {
14
+ updateValue(id: string, value: string): void;
15
+ }
16
+
17
+ const ON_OFF = ["on", "off"];
18
+ const COMMAND_ARGUMENTS = [
19
+ {
20
+ value: "show",
21
+ label: "Show active settings",
22
+ description: "Display the current permission-system config summary",
23
+ },
24
+ {
25
+ value: "path",
26
+ label: "Show config path",
27
+ description: "Display the config.json path used by pi-permission-system",
28
+ },
29
+ {
30
+ value: "reset",
31
+ label: "Reset defaults",
32
+ description: "Restore default yolo/logging settings and persist them",
33
+ },
34
+ {
35
+ value: "help",
36
+ label: "Show help",
37
+ description: "Display command usage",
38
+ },
39
+ ] as const;
40
+ const USAGE_TEXT = "Usage: /permission-system [show|path|reset|help] (or run /permission-system with no args to open settings modal)";
41
+
42
+ function cloneDefaultConfig(): PermissionSystemExtensionConfig {
43
+ return {
44
+ debugLog: DEFAULT_EXTENSION_CONFIG.debugLog,
45
+ permissionReviewLog: DEFAULT_EXTENSION_CONFIG.permissionReviewLog,
46
+ yoloMode: DEFAULT_EXTENSION_CONFIG.yoloMode,
47
+ };
48
+ }
49
+
50
+ function toOnOff(value: boolean): string {
51
+ return value ? "on" : "off";
52
+ }
53
+
54
+ function summarizeConfig(config: PermissionSystemExtensionConfig): string {
55
+ return [
56
+ `yoloMode=${toOnOff(config.yoloMode)}`,
57
+ `permissionReviewLog=${toOnOff(config.permissionReviewLog)}`,
58
+ `debugLog=${toOnOff(config.debugLog)}`,
59
+ ].join(", ");
60
+ }
61
+
62
+ function buildSettingItems(config: PermissionSystemExtensionConfig): SettingItem[] {
63
+ return [
64
+ {
65
+ id: "yoloMode",
66
+ label: "YOLO mode",
67
+ description: "Auto-approve ask-state permission checks, including subagent approval forwarding",
68
+ currentValue: toOnOff(config.yoloMode),
69
+ values: ON_OFF,
70
+ },
71
+ {
72
+ id: "permissionReviewLog",
73
+ label: "Permission review log",
74
+ description: "Write permission request and decision audit events to the extension logs directory",
75
+ currentValue: toOnOff(config.permissionReviewLog),
76
+ values: ON_OFF,
77
+ },
78
+ {
79
+ id: "debugLog",
80
+ label: "Debug logging",
81
+ description: "Write verbose permission-system diagnostics to the extension logs directory",
82
+ currentValue: toOnOff(config.debugLog),
83
+ values: ON_OFF,
84
+ },
85
+ ];
86
+ }
87
+
88
+ function applySetting(
89
+ config: PermissionSystemExtensionConfig,
90
+ id: string,
91
+ value: string,
92
+ ): PermissionSystemExtensionConfig {
93
+ switch (id) {
94
+ case "yoloMode":
95
+ return { ...config, yoloMode: value === "on" };
96
+ case "permissionReviewLog":
97
+ return { ...config, permissionReviewLog: value === "on" };
98
+ case "debugLog":
99
+ return { ...config, debugLog: value === "on" };
100
+ default:
101
+ return config;
102
+ }
103
+ }
104
+
105
+ function syncSettingValues(settingsList: SettingValueSyncTarget, config: PermissionSystemExtensionConfig): void {
106
+ settingsList.updateValue("yoloMode", toOnOff(config.yoloMode));
107
+ settingsList.updateValue("permissionReviewLog", toOnOff(config.permissionReviewLog));
108
+ settingsList.updateValue("debugLog", toOnOff(config.debugLog));
109
+ }
110
+
111
+ function getArgumentCompletions(argumentPrefix: string): Array<{ value: string; label: string; description: string }> | null {
112
+ const normalized = argumentPrefix.trim().toLowerCase();
113
+ if (normalized.includes(" ")) {
114
+ return null;
115
+ }
116
+
117
+ const filtered = COMMAND_ARGUMENTS.filter((item) => item.value.startsWith(normalized));
118
+ return filtered.length > 0 ? [...filtered] : null;
119
+ }
120
+
121
+ async function openSettingsModal(ctx: ExtensionCommandContext, controller: PermissionSystemConfigController): Promise<void> {
122
+ const overlayOptions = { anchor: "center" as const, width: 82, maxHeight: "85%" as const, margin: 1 };
123
+
124
+ await ctx.ui.custom<void>(
125
+ (tui, theme, _keybindings, done) => {
126
+ let current = controller.getConfig();
127
+ let settingsModal: ZellijSettingsModal | null = null;
128
+
129
+ settingsModal = new ZellijSettingsModal(
130
+ {
131
+ title: "Permission System Settings",
132
+ description: "Local extension options for permission logging and auto-approval behavior",
133
+ settings: buildSettingItems(current),
134
+ onChange: (id, newValue) => {
135
+ current = applySetting(current, id, newValue);
136
+ controller.setConfig(current, ctx);
137
+ current = controller.getConfig();
138
+ if (settingsModal) {
139
+ syncSettingValues(settingsModal, current);
140
+ }
141
+ },
142
+ onClose: () => done(),
143
+ helpText: `/permission-system show • /permission-system reset • ${controller.getConfigPath()}`,
144
+ enableSearch: true,
145
+ },
146
+ theme,
147
+ );
148
+
149
+ const modal = new ZellijModal(
150
+ settingsModal,
151
+ {
152
+ borderStyle: "rounded",
153
+ titleBar: {
154
+ left: "Permission System Settings",
155
+ right: "pi-permission-system",
156
+ },
157
+ helpUndertitle: {
158
+ text: "Esc: close | ↑↓: navigate | Space: toggle",
159
+ color: "dim",
160
+ },
161
+ overlay: overlayOptions,
162
+ },
163
+ theme,
164
+ );
165
+
166
+ return {
167
+ render(width: number) {
168
+ return modal.renderModal(width).lines;
169
+ },
170
+ invalidate() {
171
+ modal.invalidate();
172
+ },
173
+ handleInput(data: string) {
174
+ modal.handleInput(data);
175
+ tui.requestRender();
176
+ },
177
+ };
178
+ },
179
+ { overlay: true, overlayOptions },
180
+ );
181
+ }
182
+
183
+ function handleArgs(args: string, ctx: ExtensionCommandContext, controller: PermissionSystemConfigController): boolean {
184
+ const normalized = args.trim().toLowerCase();
185
+ if (!normalized) {
186
+ return false;
187
+ }
188
+
189
+ if (normalized === "show") {
190
+ ctx.ui.notify(`permission-system: ${summarizeConfig(controller.getConfig())}`, "info");
191
+ return true;
192
+ }
193
+
194
+ if (normalized === "path") {
195
+ ctx.ui.notify(`permission-system config: ${controller.getConfigPath()}`, "info");
196
+ return true;
197
+ }
198
+
199
+ if (normalized === "reset") {
200
+ controller.setConfig(cloneDefaultConfig(), ctx);
201
+ ctx.ui.notify("Permission system settings reset to defaults.", "info");
202
+ return true;
203
+ }
204
+
205
+ if (normalized === "help") {
206
+ ctx.ui.notify(USAGE_TEXT, "info");
207
+ return true;
208
+ }
209
+
210
+ ctx.ui.notify(USAGE_TEXT, "warning");
211
+ return true;
212
+ }
213
+
214
+ export function registerPermissionSystemCommand(pi: ExtensionAPI, controller: PermissionSystemConfigController): void {
215
+ pi.registerCommand("permission-system", {
216
+ description: "Configure pi-permission-system logging and yolo-mode behavior",
217
+ getArgumentCompletions,
218
+ handler: async (args, ctx) => {
219
+ if (handleArgs(args, ctx, controller)) {
220
+ return;
221
+ }
222
+
223
+ if (!ctx.hasUI) {
224
+ ctx.ui.notify("/permission-system requires interactive TUI mode.", "warning");
225
+ return;
226
+ }
227
+
228
+ await openSettingsModal(ctx, controller);
229
+ },
230
+ });
231
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
@@ -9,6 +9,7 @@ export const EXTENSION_ID = "pi-permission-system";
9
9
  export interface PermissionSystemExtensionConfig {
10
10
  debugLog: boolean;
11
11
  permissionReviewLog: boolean;
12
+ yoloMode: boolean;
12
13
  }
13
14
 
14
15
  export interface PermissionSystemConfigLoadResult {
@@ -17,9 +18,15 @@ export interface PermissionSystemConfigLoadResult {
17
18
  warning?: string;
18
19
  }
19
20
 
21
+ export interface PermissionSystemConfigSaveResult {
22
+ success: boolean;
23
+ error?: string;
24
+ }
25
+
20
26
  export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
21
27
  debugLog: false,
22
28
  permissionReviewLog: true,
29
+ yoloMode: false,
23
30
  };
24
31
 
25
32
  export function resolveExtensionRoot(moduleUrl = import.meta.url): string {
@@ -36,6 +43,7 @@ function cloneDefaultConfig(): PermissionSystemExtensionConfig {
36
43
  return {
37
44
  debugLog: DEFAULT_EXTENSION_CONFIG.debugLog,
38
45
  permissionReviewLog: DEFAULT_EXTENSION_CONFIG.permissionReviewLog,
46
+ yoloMode: DEFAULT_EXTENSION_CONFIG.yoloMode,
39
47
  };
40
48
  }
41
49
 
@@ -43,11 +51,12 @@ function createDefaultConfigContent(): string {
43
51
  return `${JSON.stringify(DEFAULT_EXTENSION_CONFIG, null, 2)}\n`;
44
52
  }
45
53
 
46
- function normalizeConfig(raw: unknown): PermissionSystemExtensionConfig {
54
+ export function normalizePermissionSystemConfig(raw: unknown): PermissionSystemExtensionConfig {
47
55
  const record = toRecord(raw);
48
56
  return {
49
57
  debugLog: record.debugLog === true,
50
58
  permissionReviewLog: record.permissionReviewLog !== false,
59
+ yoloMode: record.yoloMode === true,
51
60
  };
52
61
  }
53
62
 
@@ -79,7 +88,7 @@ export function loadPermissionSystemConfig(configPath = CONFIG_PATH): Permission
79
88
  try {
80
89
  const raw = readFileSync(configPath, "utf-8");
81
90
  const parsed = JSON.parse(raw) as unknown;
82
- const config = normalizeConfig(parsed);
91
+ const config = normalizePermissionSystemConfig(parsed);
83
92
  return {
84
93
  config,
85
94
  created: ensureResult.created,
@@ -95,6 +104,39 @@ export function loadPermissionSystemConfig(configPath = CONFIG_PATH): Permission
95
104
  }
96
105
  }
97
106
 
107
+ export function savePermissionSystemConfig(
108
+ config: PermissionSystemExtensionConfig,
109
+ configPath = CONFIG_PATH,
110
+ ): PermissionSystemConfigSaveResult {
111
+ const normalized = normalizePermissionSystemConfig(config);
112
+ const tmpPath = `${configPath}.tmp`;
113
+
114
+ try {
115
+ ensureConfigDirectory(configPath);
116
+ writeFileSync(tmpPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf-8");
117
+ renameSync(tmpPath, configPath);
118
+ return { success: true };
119
+ } catch (error) {
120
+ try {
121
+ if (existsSync(tmpPath)) {
122
+ unlinkSync(tmpPath);
123
+ }
124
+ } catch {
125
+ // Ignore cleanup failures.
126
+ }
127
+
128
+ const message = error instanceof Error ? error.message : String(error);
129
+ return {
130
+ success: false,
131
+ error: `Failed to save permission-system config at '${configPath}': ${message}`,
132
+ };
133
+ }
134
+ }
135
+
136
+ export function getPermissionSystemConfigPath(configPath = CONFIG_PATH): string {
137
+ return configPath;
138
+ }
139
+
98
140
  export function ensurePermissionSystemLogsDirectory(logsDir = LOGS_DIR): string | undefined {
99
141
  try {
100
142
  mkdirSync(logsDir, { recursive: true });