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