@aliou/pi-guardrails 0.2.0 → 0.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.
@@ -0,0 +1,416 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
3
+ import { Key, matchesKey } from "@mariozechner/pi-tui";
4
+ import { ArrayEditor } from "./array-editor";
5
+ import { configLoader } from "./config";
6
+ import type { GuardrailsConfig } from "./config-schema";
7
+ import { PatternEditor } from "./pattern-editor";
8
+ import { SectionedSettings, type SettingsSection } from "./sectioned-settings";
9
+
10
+ type Tab = "local" | "global";
11
+
12
+ export function registerSettingsCommand(pi: ExtensionAPI): void {
13
+ pi.registerCommand("guardrails:settings", {
14
+ description: "Configure guardrails (local/global)",
15
+ handler: async (_args, ctx) => {
16
+ let activeTab: Tab = configLoader.hasProjectConfig() ? "local" : "global";
17
+
18
+ await ctx.ui.custom((tui, theme, _kb, done) => {
19
+ let settings: SectionedSettings | null = null;
20
+ const settingsTheme = getSettingsListTheme();
21
+
22
+ // --- Helpers ---
23
+
24
+ function getTabConfig(): GuardrailsConfig {
25
+ return activeTab === "local"
26
+ ? configLoader.getProjectConfig()
27
+ : configLoader.getGlobalConfig();
28
+ }
29
+
30
+ async function saveTabConfig(
31
+ tab: Tab,
32
+ config: GuardrailsConfig,
33
+ ): Promise<boolean> {
34
+ try {
35
+ if (tab === "local") {
36
+ await configLoader.saveProject(config);
37
+ } else {
38
+ await configLoader.saveGlobal(config);
39
+ }
40
+ ctx.ui.notify(`guardrails: saved to ${tab} config`, "info");
41
+ return true;
42
+ } catch (error) {
43
+ ctx.ui.notify(`guardrails: failed to save: ${error}`, "error");
44
+ return false;
45
+ }
46
+ }
47
+
48
+ function setNestedValue(
49
+ config: GuardrailsConfig,
50
+ id: string,
51
+ value: unknown,
52
+ ): void {
53
+ const parts = id.split(".");
54
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic config path traversal
55
+ let target: any = config;
56
+ for (let i = 0; i < parts.length - 1; i++) {
57
+ const key = parts[i] as string;
58
+ if (!target[key]) target[key] = {};
59
+ target = target[key];
60
+ }
61
+ target[parts[parts.length - 1] as string] = value;
62
+ }
63
+
64
+ function getNestedValue(config: GuardrailsConfig, id: string): unknown {
65
+ const parts = id.split(".");
66
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic config path traversal
67
+ let target: any = config;
68
+ for (const part of parts) {
69
+ if (target == null) return undefined;
70
+ target = target[part];
71
+ }
72
+ return target;
73
+ }
74
+
75
+ function formatCount(id: string): string {
76
+ const config = getTabConfig();
77
+ const resolved = configLoader.getConfig();
78
+ const val =
79
+ (getNestedValue(config, id) as unknown[] | undefined) ??
80
+ (getNestedValue(resolved, id) as unknown[]) ??
81
+ [];
82
+ return `${val.length} items`;
83
+ }
84
+
85
+ // --- Submenu factories ---
86
+
87
+ function stringArraySubmenu(
88
+ id: string,
89
+ label: string,
90
+ ): (
91
+ currentValue: string,
92
+ done: (selectedValue?: string) => void,
93
+ ) => ArrayEditor {
94
+ return (_currentValue, submenuDone) => {
95
+ const config = getTabConfig();
96
+ const resolved = configLoader.getConfig();
97
+ const currentItems =
98
+ (getNestedValue(config, id) as string[] | undefined) ??
99
+ (getNestedValue(resolved, id) as string[]) ??
100
+ [];
101
+
102
+ return new ArrayEditor({
103
+ label,
104
+ items: [...currentItems],
105
+ theme: settingsTheme,
106
+ onSave: (items) => {
107
+ const updated: GuardrailsConfig = structuredClone(config);
108
+ setNestedValue(updated, id, items);
109
+ void saveTabConfig(activeTab, updated).then((ok) => {
110
+ if (ok) tui.requestRender();
111
+ });
112
+ },
113
+ onDone: () => {
114
+ submenuDone(formatCount(id));
115
+ settings = buildSettings(activeTab);
116
+ tui.requestRender();
117
+ },
118
+ });
119
+ };
120
+ }
121
+
122
+ function patternArraySubmenu(
123
+ id: string,
124
+ label: string,
125
+ ): (
126
+ currentValue: string,
127
+ done: (selectedValue?: string) => void,
128
+ ) => PatternEditor {
129
+ return (_currentValue, submenuDone) => {
130
+ const config = getTabConfig();
131
+ const resolved = configLoader.getConfig();
132
+ const currentPatterns =
133
+ (getNestedValue(config, id) as
134
+ | Array<{ pattern: string; description: string }>
135
+ | undefined) ??
136
+ (getNestedValue(resolved, id) as Array<{
137
+ pattern: string;
138
+ description: string;
139
+ }>) ??
140
+ [];
141
+
142
+ return new PatternEditor({
143
+ label,
144
+ items: [...currentPatterns],
145
+ theme: settingsTheme,
146
+ onSave: (patterns) => {
147
+ const updated: GuardrailsConfig = structuredClone(config);
148
+ setNestedValue(updated, id, patterns);
149
+ void saveTabConfig(activeTab, updated).then((ok) => {
150
+ if (ok) tui.requestRender();
151
+ });
152
+ },
153
+ onDone: () => {
154
+ submenuDone(formatCount(id));
155
+ settings = buildSettings(activeTab);
156
+ tui.requestRender();
157
+ },
158
+ });
159
+ };
160
+ }
161
+
162
+ // --- Build sections ---
163
+
164
+ function buildSettings(tab: Tab): SectionedSettings {
165
+ const config =
166
+ tab === "local"
167
+ ? configLoader.getProjectConfig()
168
+ : configLoader.getGlobalConfig();
169
+ const resolved = configLoader.getConfig();
170
+
171
+ const sections: SettingsSection[] = [
172
+ {
173
+ label: "Features",
174
+ items: [
175
+ {
176
+ id: "features.preventBrew",
177
+ label: "Prevent Homebrew",
178
+ description: "Block brew commands",
179
+ currentValue:
180
+ (config.features?.preventBrew ??
181
+ resolved.features.preventBrew)
182
+ ? "enabled"
183
+ : "disabled",
184
+ values: ["enabled", "disabled"],
185
+ },
186
+ {
187
+ id: "features.protectEnvFiles",
188
+ label: "Protect .env files",
189
+ description: "Block access to .env files containing secrets",
190
+ currentValue:
191
+ (config.features?.protectEnvFiles ??
192
+ resolved.features.protectEnvFiles)
193
+ ? "enabled"
194
+ : "disabled",
195
+ values: ["enabled", "disabled"],
196
+ },
197
+ {
198
+ id: "features.permissionGate",
199
+ label: "Permission gate",
200
+ description:
201
+ "Prompt for confirmation on dangerous commands (rm -rf, sudo, etc.)",
202
+ currentValue:
203
+ (config.features?.permissionGate ??
204
+ resolved.features.permissionGate)
205
+ ? "enabled"
206
+ : "disabled",
207
+ values: ["enabled", "disabled"],
208
+ },
209
+ ],
210
+ },
211
+ {
212
+ label: "Env Files",
213
+ items: [
214
+ {
215
+ id: "envFiles.onlyBlockIfExists",
216
+ label: "Only block existing files",
217
+ description:
218
+ "Only block .env file access if the file exists on disk",
219
+ currentValue:
220
+ (config.envFiles?.onlyBlockIfExists ??
221
+ resolved.envFiles.onlyBlockIfExists)
222
+ ? "on"
223
+ : "off",
224
+ values: ["on", "off"],
225
+ },
226
+ {
227
+ id: "envFiles.protectedPatterns",
228
+ label: "Protected patterns",
229
+ description:
230
+ "Regex patterns for files to protect (e.g. \\.env$)",
231
+ currentValue: formatCount("envFiles.protectedPatterns"),
232
+ submenu: stringArraySubmenu(
233
+ "envFiles.protectedPatterns",
234
+ "Protected Patterns",
235
+ ),
236
+ },
237
+ {
238
+ id: "envFiles.allowedPatterns",
239
+ label: "Allowed patterns",
240
+ description:
241
+ "Regex patterns for exceptions (e.g. \\.env\\.example$)",
242
+ currentValue: formatCount("envFiles.allowedPatterns"),
243
+ submenu: stringArraySubmenu(
244
+ "envFiles.allowedPatterns",
245
+ "Allowed Patterns",
246
+ ),
247
+ },
248
+ {
249
+ id: "envFiles.protectedDirectories",
250
+ label: "Protected directories",
251
+ description: "Regex patterns for directories to protect",
252
+ currentValue: formatCount("envFiles.protectedDirectories"),
253
+ submenu: stringArraySubmenu(
254
+ "envFiles.protectedDirectories",
255
+ "Protected Directories",
256
+ ),
257
+ },
258
+ {
259
+ id: "envFiles.protectedTools",
260
+ label: "Protected tools",
261
+ description:
262
+ "Tools to intercept (read, write, edit, bash, grep, find, ls)",
263
+ currentValue: formatCount("envFiles.protectedTools"),
264
+ submenu: stringArraySubmenu(
265
+ "envFiles.protectedTools",
266
+ "Protected Tools",
267
+ ),
268
+ },
269
+ ],
270
+ },
271
+ {
272
+ label: "Permission Gate",
273
+ items: [
274
+ {
275
+ id: "permissionGate.requireConfirmation",
276
+ label: "Require confirmation",
277
+ description:
278
+ "Show confirmation dialog for dangerous commands (if off, just warns)",
279
+ currentValue:
280
+ (config.permissionGate?.requireConfirmation ??
281
+ resolved.permissionGate.requireConfirmation)
282
+ ? "on"
283
+ : "off",
284
+ values: ["on", "off"],
285
+ },
286
+ {
287
+ id: "permissionGate.patterns",
288
+ label: "Dangerous patterns",
289
+ description:
290
+ "Command patterns that trigger the permission gate",
291
+ currentValue: formatCount("permissionGate.patterns"),
292
+ submenu: patternArraySubmenu(
293
+ "permissionGate.patterns",
294
+ "Dangerous Patterns",
295
+ ),
296
+ },
297
+ {
298
+ id: "permissionGate.allowedPatterns",
299
+ label: "Allowed commands",
300
+ description:
301
+ "Regex patterns that bypass the permission gate entirely",
302
+ currentValue: formatCount("permissionGate.allowedPatterns"),
303
+ submenu: stringArraySubmenu(
304
+ "permissionGate.allowedPatterns",
305
+ "Allowed Commands",
306
+ ),
307
+ },
308
+ {
309
+ id: "permissionGate.autoDenyPatterns",
310
+ label: "Auto-deny patterns",
311
+ description:
312
+ "Regex patterns that are blocked immediately without dialog",
313
+ currentValue: formatCount("permissionGate.autoDenyPatterns"),
314
+ submenu: stringArraySubmenu(
315
+ "permissionGate.autoDenyPatterns",
316
+ "Auto-Deny Patterns",
317
+ ),
318
+ },
319
+ ],
320
+ },
321
+ ];
322
+
323
+ return new SectionedSettings(
324
+ sections,
325
+ 15,
326
+ settingsTheme,
327
+ (id, newValue) => {
328
+ void handleSettingChange(tab, id, newValue);
329
+ },
330
+ () => done(undefined),
331
+ { enableSearch: true },
332
+ );
333
+ }
334
+
335
+ // --- Change handler ---
336
+
337
+ async function handleSettingChange(
338
+ tab: Tab,
339
+ id: string,
340
+ newValue: string,
341
+ ): Promise<void> {
342
+ const config =
343
+ tab === "local"
344
+ ? configLoader.getProjectConfig()
345
+ : configLoader.getGlobalConfig();
346
+ const updated: GuardrailsConfig = structuredClone(config);
347
+
348
+ // Boolean toggles only - array saves handled by submenus
349
+ if (
350
+ newValue === "enabled" ||
351
+ newValue === "disabled" ||
352
+ newValue === "on" ||
353
+ newValue === "off"
354
+ ) {
355
+ const boolVal = newValue === "enabled" || newValue === "on";
356
+ setNestedValue(updated, id, boolVal);
357
+
358
+ const ok = await saveTabConfig(tab, updated);
359
+ if (ok) {
360
+ settings = buildSettings(activeTab);
361
+ tui.requestRender();
362
+ }
363
+ }
364
+ }
365
+
366
+ // --- Tab rendering ---
367
+
368
+ function renderTabs(): string[] {
369
+ const localLabel =
370
+ activeTab === "local"
371
+ ? theme.bg("selectedBg", theme.fg("accent", " Local "))
372
+ : theme.fg("dim", " Local ");
373
+ const globalLabel =
374
+ activeTab === "global"
375
+ ? theme.bg("selectedBg", theme.fg("accent", " Global "))
376
+ : theme.fg("dim", " Global ");
377
+
378
+ return ["", ` ${localLabel} ${globalLabel}`, ""];
379
+ }
380
+
381
+ function handleTabSwitch(data: string): boolean {
382
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab"))) {
383
+ activeTab = activeTab === "local" ? "global" : "local";
384
+ settings = buildSettings(activeTab);
385
+ tui.requestRender();
386
+ return true;
387
+ }
388
+ return false;
389
+ }
390
+
391
+ // --- Init ---
392
+
393
+ settings = buildSettings(activeTab);
394
+
395
+ return {
396
+ render(width: number) {
397
+ const lines: string[] = [];
398
+ lines.push(theme.fg("accent", theme.bold("Guardrails Settings")));
399
+ lines.push(...renderTabs());
400
+ lines.push(...(settings?.render(width) ?? []));
401
+ return lines;
402
+ },
403
+ invalidate() {
404
+ settings?.invalidate?.();
405
+ },
406
+ handleInput(data: string) {
407
+ // Don't switch tabs when a submenu is active (it needs Tab)
408
+ if (!settings?.hasActiveSubmenu() && handleTabSwitch(data)) return;
409
+ settings?.handleInput?.(data);
410
+ tui.requestRender();
411
+ },
412
+ };
413
+ });
414
+ },
415
+ });
416
+ }