@gotgenes/pi-permission-system 0.7.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.
@@ -0,0 +1,248 @@
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 { test, vi } from "vitest";
6
+ import { registerPermissionSystemCommand } from "../src/config-modal.js";
7
+ import {
8
+ DEFAULT_EXTENSION_CONFIG,
9
+ loadPermissionSystemConfig,
10
+ type PermissionSystemExtensionConfig,
11
+ savePermissionSystemConfig,
12
+ } from "../src/extension-config.js";
13
+
14
+ vi.mock("@mariozechner/pi-coding-agent", () => ({
15
+ getSettingsListTheme: () => ({}),
16
+ }));
17
+
18
+ vi.mock("@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
+ type Notification = { message: string; level: "info" | "warning" | "error" };
42
+
43
+ type CommandContextStub = {
44
+ hasUI: boolean;
45
+ ui: {
46
+ notify(message: string, level: "info" | "warning" | "error"): void;
47
+ custom<T>(
48
+ renderer: (...args: unknown[]) => unknown,
49
+ options?: unknown,
50
+ ): Promise<T>;
51
+ };
52
+ };
53
+
54
+ function createCommandContext(hasUI: boolean): {
55
+ ctx: CommandContextStub;
56
+ notifications: Notification[];
57
+ getCustomCalls(): number;
58
+ } {
59
+ const notifications: Notification[] = [];
60
+ let customCalls = 0;
61
+
62
+ return {
63
+ ctx: {
64
+ hasUI,
65
+ ui: {
66
+ notify(message: string, level: "info" | "warning" | "error") {
67
+ notifications.push({ message, level });
68
+ },
69
+ async custom<T>(
70
+ _renderer: (...args: unknown[]) => unknown,
71
+ _options?: unknown,
72
+ ): Promise<T> {
73
+ customCalls += 1;
74
+ return undefined as T;
75
+ },
76
+ },
77
+ },
78
+ notifications,
79
+ getCustomCalls: () => customCalls,
80
+ };
81
+ }
82
+
83
+ function lastNotification(notifications: Notification[]): Notification {
84
+ return notifications[notifications.length - 1] as Notification;
85
+ }
86
+
87
+ test("permission-system command completions expose top-level config actions", () => {
88
+ const baseDir = mkdtempSync(
89
+ join(tmpdir(), "pi-permission-system-command-completions-"),
90
+ );
91
+ const configPath = join(baseDir, "config.json");
92
+ let config: PermissionSystemExtensionConfig = { ...DEFAULT_EXTENSION_CONFIG };
93
+
94
+ try {
95
+ const controller = {
96
+ getConfig: () => config,
97
+ setConfig: (next: PermissionSystemExtensionConfig) => {
98
+ config = next;
99
+ },
100
+ getConfigPath: () => configPath,
101
+ };
102
+
103
+ let definition: {
104
+ description: string;
105
+ getArgumentCompletions?: (
106
+ argumentPrefix: string,
107
+ ) => 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(
130
+ filtered?.map((item) => item.value),
131
+ ["path"],
132
+ );
133
+ assert.equal(definition?.getArgumentCompletions?.("path extra"), null);
134
+ assert.equal(definition?.getArgumentCompletions?.("zzz"), null);
135
+ } finally {
136
+ rmSync(baseDir, { recursive: true, force: true });
137
+ }
138
+ });
139
+
140
+ test("permission-system command handlers manage config summary, persistence, and modal routing", async () => {
141
+ const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-command-"));
142
+ const configPath = join(baseDir, "config.json");
143
+ let config: PermissionSystemExtensionConfig = {
144
+ debugLog: true,
145
+ permissionReviewLog: false,
146
+ yoloMode: true,
147
+ };
148
+
149
+ try {
150
+ const initialSave = savePermissionSystemConfig(config, configPath);
151
+ assert.equal(initialSave.success, true);
152
+
153
+ const controller = {
154
+ getConfig: () => config,
155
+ setConfig: (next: PermissionSystemExtensionConfig) => {
156
+ const normalized = loadPermissionSystemConfig(configPath).config;
157
+ const saved = savePermissionSystemConfig(next, configPath);
158
+ assert.equal(saved.success, true);
159
+ config = loadPermissionSystemConfig(configPath).config;
160
+ assert.notDeepEqual(config, normalized);
161
+ },
162
+ getConfigPath: () => configPath,
163
+ };
164
+
165
+ let registeredName = "";
166
+ let definition: {
167
+ description: string;
168
+ getArgumentCompletions?: (
169
+ argumentPrefix: string,
170
+ ) => Array<{ value: string; label: string; description?: string }> | null;
171
+ handler: (args: string, ctx: CommandContextStub) => Promise<void>;
172
+ } | null = null;
173
+
174
+ registerPermissionSystemCommand(
175
+ {
176
+ registerCommand(name: string, nextDefinition: typeof definition) {
177
+ registeredName = name;
178
+ definition = nextDefinition;
179
+ },
180
+ } as never,
181
+ controller as never,
182
+ );
183
+
184
+ assert.equal(registeredName, "permission-system");
185
+ assert.ok(definition !== null);
186
+ assert.ok(
187
+ (definition?.description ?? "").includes(
188
+ "Configure pi-permission-system",
189
+ ),
190
+ );
191
+
192
+ const infoCtx = createCommandContext(true);
193
+ await definition?.handler("show", infoCtx.ctx);
194
+ assert.ok(
195
+ lastNotification(infoCtx.notifications).message.includes("yoloMode=on"),
196
+ );
197
+ assert.ok(
198
+ lastNotification(infoCtx.notifications).message.includes("debugLog=on"),
199
+ );
200
+
201
+ await definition?.handler("path", infoCtx.ctx);
202
+ assert.equal(
203
+ lastNotification(infoCtx.notifications).message,
204
+ `permission-system config: ${configPath}`,
205
+ );
206
+
207
+ await definition?.handler("help", infoCtx.ctx);
208
+ assert.ok(
209
+ lastNotification(infoCtx.notifications).message.includes(
210
+ "Usage: /permission-system",
211
+ ),
212
+ );
213
+
214
+ await definition?.handler("reset", infoCtx.ctx);
215
+ assert.deepEqual(config, DEFAULT_EXTENSION_CONFIG);
216
+ assert.equal(
217
+ lastNotification(infoCtx.notifications).message,
218
+ "Permission system settings reset to defaults.",
219
+ );
220
+
221
+ const persisted = JSON.parse(readFileSync(configPath, "utf8")) as Record<
222
+ string,
223
+ unknown
224
+ >;
225
+ assert.deepEqual(persisted, DEFAULT_EXTENSION_CONFIG);
226
+
227
+ await definition?.handler("unknown", infoCtx.ctx);
228
+ assert.equal(lastNotification(infoCtx.notifications).level, "warning");
229
+ assert.ok(
230
+ lastNotification(infoCtx.notifications).message.includes(
231
+ "Usage: /permission-system",
232
+ ),
233
+ );
234
+
235
+ const headlessCtx = createCommandContext(false);
236
+ await definition?.handler("", headlessCtx.ctx);
237
+ assert.equal(
238
+ lastNotification(headlessCtx.notifications).message,
239
+ "/permission-system requires interactive TUI mode.",
240
+ );
241
+
242
+ const modalCtx = createCommandContext(true);
243
+ await definition?.handler("", modalCtx.ctx);
244
+ assert.equal(modalCtx.getCustomCalls(), 1);
245
+ } finally {
246
+ rmSync(baseDir, { recursive: true, force: true });
247
+ }
248
+ });
@@ -0,0 +1,139 @@
1
+ import assert from "node:assert/strict";
2
+ import {
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { test } from "vitest";
12
+ import { buildResolvedConfigLogEntry } from "../src/config-reporter.js";
13
+ import { createPermissionSystemLogger } from "../src/logging.js";
14
+ import type { ResolvedPolicyPaths } from "../src/permission-manager.js";
15
+ import { PermissionManager } from "../src/permission-manager.js";
16
+
17
+ test("buildResolvedConfigLogEntry merges extension config path with policy paths", () => {
18
+ const policyPaths: ResolvedPolicyPaths = {
19
+ globalConfigPath: "/home/user/.pi/agent/pi-permissions.jsonc",
20
+ globalConfigExists: true,
21
+ projectConfigPath: "/projects/my-app/.pi/agent/pi-permissions.jsonc",
22
+ projectConfigExists: false,
23
+ agentsDir: "/home/user/.pi/agent/agents",
24
+ agentsDirExists: true,
25
+ projectAgentsDir: "/projects/my-app/.pi/agent/agents",
26
+ projectAgentsDirExists: false,
27
+ };
28
+
29
+ const result = buildResolvedConfigLogEntry(
30
+ "/ext/pi-permission-system/config.json",
31
+ true,
32
+ policyPaths,
33
+ );
34
+
35
+ assert.equal(
36
+ result.extensionConfigPath,
37
+ "/ext/pi-permission-system/config.json",
38
+ );
39
+ assert.equal(result.extensionConfigExists, true);
40
+ assert.equal(
41
+ result.globalConfigPath,
42
+ "/home/user/.pi/agent/pi-permissions.jsonc",
43
+ );
44
+ assert.equal(result.globalConfigExists, true);
45
+ assert.equal(
46
+ result.projectConfigPath,
47
+ "/projects/my-app/.pi/agent/pi-permissions.jsonc",
48
+ );
49
+ assert.equal(result.projectConfigExists, false);
50
+ assert.equal(result.agentsDir, "/home/user/.pi/agent/agents");
51
+ assert.equal(result.agentsDirExists, true);
52
+ assert.equal(result.projectAgentsDir, "/projects/my-app/.pi/agent/agents");
53
+ assert.equal(result.projectAgentsDirExists, false);
54
+ });
55
+
56
+ test("buildResolvedConfigLogEntry handles null project paths", () => {
57
+ const policyPaths: ResolvedPolicyPaths = {
58
+ globalConfigPath: "/home/user/.pi/agent/pi-permissions.jsonc",
59
+ globalConfigExists: false,
60
+ projectConfigPath: null,
61
+ projectConfigExists: false,
62
+ agentsDir: "/home/user/.pi/agent/agents",
63
+ agentsDirExists: false,
64
+ projectAgentsDir: null,
65
+ projectAgentsDirExists: false,
66
+ };
67
+
68
+ const result = buildResolvedConfigLogEntry(
69
+ "/ext/config.json",
70
+ false,
71
+ policyPaths,
72
+ );
73
+
74
+ assert.equal(result.extensionConfigPath, "/ext/config.json");
75
+ assert.equal(result.extensionConfigExists, false);
76
+ assert.equal(result.projectConfigPath, null);
77
+ assert.equal(result.projectConfigExists, false);
78
+ assert.equal(result.projectAgentsDir, null);
79
+ assert.equal(result.projectAgentsDirExists, false);
80
+ });
81
+
82
+ test("config.resolved entry appears in review log via logger", () => {
83
+ const tempDir = mkdtempSync(join(tmpdir(), "config-resolved-log-"));
84
+ try {
85
+ const logsDir = join(tempDir, "logs");
86
+ mkdirSync(logsDir, { recursive: true });
87
+ const reviewLogPath = join(logsDir, "review.jsonl");
88
+
89
+ const globalConfigPath = join(tempDir, "pi-permissions.jsonc");
90
+ writeFileSync(globalConfigPath, "{}", "utf-8");
91
+ const agentsDir = join(tempDir, "agents");
92
+
93
+ const pm = new PermissionManager({
94
+ globalConfigPath,
95
+ agentsDir,
96
+ });
97
+
98
+ const extensionConfigPath = join(tempDir, "config.json");
99
+ writeFileSync(extensionConfigPath, "{}", "utf-8");
100
+
101
+ const logger = createPermissionSystemLogger({
102
+ getConfig: () => ({
103
+ debugLog: false,
104
+ permissionReviewLog: true,
105
+ yoloMode: false,
106
+ }),
107
+ reviewLogPath,
108
+ ensureLogsDirectory: () => undefined,
109
+ });
110
+
111
+ const policyPaths = pm.getResolvedPolicyPaths();
112
+ const entry = buildResolvedConfigLogEntry(
113
+ extensionConfigPath,
114
+ true,
115
+ policyPaths,
116
+ );
117
+ logger.review(
118
+ "config.resolved",
119
+ entry as unknown as Record<string, unknown>,
120
+ );
121
+
122
+ const logContent = readFileSync(reviewLogPath, "utf-8").trim();
123
+ const parsed = JSON.parse(logContent) as Record<string, unknown>;
124
+
125
+ assert.equal(parsed.event, "config.resolved");
126
+ assert.equal(parsed.extensionConfigPath, extensionConfigPath);
127
+ assert.equal(parsed.extensionConfigExists, true);
128
+ assert.equal(parsed.globalConfigPath, globalConfigPath);
129
+ assert.equal(parsed.globalConfigExists, true);
130
+ assert.equal(parsed.agentsDir, agentsDir);
131
+ assert.equal(parsed.agentsDirExists, false);
132
+ assert.equal(parsed.projectConfigPath, null);
133
+ assert.equal(parsed.projectConfigExists, false);
134
+ assert.equal(parsed.projectAgentsDir, null);
135
+ assert.equal(parsed.projectAgentsDirExists, false);
136
+ } finally {
137
+ rmSync(tempDir, { recursive: true, force: true });
138
+ }
139
+ });
@@ -0,0 +1,120 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+
6
+ import {
7
+ detectMisplacedPermissionKeys,
8
+ loadPermissionSystemConfig,
9
+ } from "../src/extension-config.js";
10
+
11
+ describe("detectMisplacedPermissionKeys", () => {
12
+ it("returns an empty array for a record with only valid extension keys", () => {
13
+ const result = detectMisplacedPermissionKeys({
14
+ debugLog: true,
15
+ permissionReviewLog: true,
16
+ yoloMode: false,
17
+ });
18
+ expect(result).toEqual([]);
19
+ });
20
+
21
+ it("returns an empty array for an empty record", () => {
22
+ const result = detectMisplacedPermissionKeys({});
23
+ expect(result).toEqual([]);
24
+ });
25
+
26
+ it("returns misplaced key names when permission-rule keys are present", () => {
27
+ const result = detectMisplacedPermissionKeys({
28
+ debugLog: true,
29
+ defaultPolicy: { tools: "ask" },
30
+ bash: { "git status": "allow" },
31
+ });
32
+ expect(result).toEqual(["defaultPolicy", "bash"]);
33
+ });
34
+
35
+ it("detects all known permission-rule keys", () => {
36
+ const result = detectMisplacedPermissionKeys({
37
+ defaultPolicy: {},
38
+ tools: {},
39
+ bash: {},
40
+ mcp: {},
41
+ skills: {},
42
+ special: {},
43
+ external_directory: {},
44
+ doom_loop: {},
45
+ });
46
+ expect(result).toEqual([
47
+ "defaultPolicy",
48
+ "tools",
49
+ "bash",
50
+ "mcp",
51
+ "skills",
52
+ "special",
53
+ "external_directory",
54
+ "doom_loop",
55
+ ]);
56
+ });
57
+
58
+ it("ignores unknown keys that are not permission-rule keys", () => {
59
+ const result = detectMisplacedPermissionKeys({
60
+ debugLog: true,
61
+ someRandomKey: "value",
62
+ });
63
+ expect(result).toEqual([]);
64
+ });
65
+ });
66
+
67
+ describe("loadPermissionSystemConfig", () => {
68
+ let tempDir: string;
69
+ let configPath: string;
70
+
71
+ beforeEach(() => {
72
+ tempDir = mkdtempSync(join(tmpdir(), "perm-config-test-"));
73
+ configPath = join(tempDir, "config.json");
74
+ });
75
+
76
+ afterEach(() => {
77
+ rmSync(tempDir, { recursive: true, force: true });
78
+ });
79
+
80
+ it("returns no warning for a clean config", () => {
81
+ writeFileSync(
82
+ configPath,
83
+ JSON.stringify({
84
+ debugLog: false,
85
+ permissionReviewLog: true,
86
+ yoloMode: false,
87
+ }),
88
+ );
89
+ const result = loadPermissionSystemConfig(configPath);
90
+ expect(result.warning).toBeUndefined();
91
+ });
92
+
93
+ it("returns a warning naming misplaced permission-rule keys", () => {
94
+ writeFileSync(
95
+ configPath,
96
+ JSON.stringify({
97
+ debugLog: true,
98
+ defaultPolicy: { tools: "ask" },
99
+ bash: { "git status": "allow" },
100
+ }),
101
+ );
102
+ const result = loadPermissionSystemConfig(configPath);
103
+ expect(result.warning).toBeDefined();
104
+ expect(result.warning).toContain("defaultPolicy");
105
+ expect(result.warning).toContain("bash");
106
+ expect(result.warning).toContain("pi-permissions.jsonc");
107
+ });
108
+
109
+ it("still returns the valid extension config fields when misplaced keys are present", () => {
110
+ writeFileSync(
111
+ configPath,
112
+ JSON.stringify({
113
+ debugLog: true,
114
+ bash: { "git status": "allow" },
115
+ }),
116
+ );
117
+ const result = loadPermissionSystemConfig(configPath);
118
+ expect(result.config.debugLog).toBe(true);
119
+ });
120
+ });