@aliou/pi-guardrails 0.7.6 → 0.8.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,17 +1,25 @@
1
1
  import {
2
- ArrayEditor,
3
2
  getNestedValue,
4
3
  registerSettingsCommand,
4
+ SettingsDetailEditor,
5
+ type SettingsDetailField,
5
6
  type SettingsSection,
6
7
  setNestedValue,
7
8
  } from "@aliou/pi-utils-settings";
8
9
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
10
  import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
11
+ import {
12
+ type Component,
13
+ Input,
14
+ type SettingItem,
15
+ type SettingsListTheme,
16
+ } from "@mariozechner/pi-tui";
10
17
  import { PatternEditor } from "../components/pattern-editor";
11
18
  import type {
12
19
  DangerousPattern,
13
20
  GuardrailsConfig,
14
21
  PatternConfig,
22
+ PolicyRule,
15
23
  ResolvedConfig,
16
24
  } from "../config";
17
25
  import { configLoader } from "../config";
@@ -19,9 +27,9 @@ import { configLoader } from "../config";
19
27
  type FeatureKey = keyof ResolvedConfig["features"];
20
28
 
21
29
  const FEATURE_UI: Record<FeatureKey, { label: string; description: string }> = {
22
- protectEnvFiles: {
23
- label: "Protect .env files",
24
- description: "Block access to .env files containing secrets",
30
+ policies: {
31
+ label: "Policies",
32
+ description: "Block or limit file access using named policy rules",
25
33
  },
26
34
  permissionGate: {
27
35
  label: "Permission gate",
@@ -30,6 +38,277 @@ const FEATURE_UI: Record<FeatureKey, { label: string; description: string }> = {
30
38
  },
31
39
  };
32
40
 
41
+ function toKebabCase(input: string): string {
42
+ return input
43
+ .trim()
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9]+/g, "-")
46
+ .replace(/^-+|-+$/g, "");
47
+ }
48
+
49
+ class AddRuleSubmenu implements Component {
50
+ private readonly onCreate: (name: string) => number | null;
51
+ private readonly openEditor: (
52
+ index: number,
53
+ done: (value?: string) => void,
54
+ ) => Component;
55
+ private readonly onDone: (value?: string) => void;
56
+ private readonly theme: SettingsListTheme;
57
+ private readonly nameInput = new Input();
58
+ private activeEditor: Component | null = null;
59
+
60
+ constructor(
61
+ theme: SettingsListTheme,
62
+ onCreate: (name: string) => number | null,
63
+ openEditor: (index: number, done: (value?: string) => void) => Component,
64
+ onDone: (value?: string) => void,
65
+ ) {
66
+ this.theme = theme;
67
+ this.onCreate = onCreate;
68
+ this.openEditor = openEditor;
69
+ this.onDone = onDone;
70
+
71
+ this.nameInput.onSubmit = () => {
72
+ const name = this.nameInput.getValue().trim();
73
+ if (!name) return;
74
+ const index = this.onCreate(name);
75
+ if (index === null) return;
76
+ this.activeEditor = this.openEditor(index, (value) => {
77
+ this.activeEditor = null;
78
+ this.onDone(value);
79
+ });
80
+ };
81
+ this.nameInput.onEscape = () => this.onDone();
82
+ }
83
+
84
+ invalidate() {
85
+ this.activeEditor?.invalidate?.();
86
+ }
87
+
88
+ render(width: number): string[] {
89
+ if (this.activeEditor) {
90
+ return this.activeEditor.render(width);
91
+ }
92
+
93
+ return [
94
+ this.theme.label("+ Add policy", true),
95
+ "",
96
+ this.theme.hint(" Enter policy name:"),
97
+ ...this.nameInput.render(width - 2).map((line) => ` ${line}`),
98
+ "",
99
+ this.theme.hint(" Enter: create + edit · Esc: back"),
100
+ ];
101
+ }
102
+
103
+ handleInput(data: string): void {
104
+ if (this.activeEditor) {
105
+ this.activeEditor.handleInput?.(data);
106
+ return;
107
+ }
108
+
109
+ this.nameInput.handleInput(data);
110
+ }
111
+ }
112
+
113
+ function createPolicyRuleEditor(options: {
114
+ index: number;
115
+ theme: SettingsListTheme;
116
+ getRule: () => PolicyRule | undefined;
117
+ updateRule: (updater: (rule: PolicyRule) => PolicyRule) => void;
118
+ deleteRule: () => void;
119
+ onDone: (value?: string) => void;
120
+ }): SettingsDetailEditor {
121
+ const { index, theme, getRule, updateRule, deleteRule, onDone } = options;
122
+
123
+ const fields: SettingsDetailField[] = [
124
+ {
125
+ id: "name",
126
+ type: "text",
127
+ label: "Name",
128
+ description: "Display name shown in settings",
129
+ getValue: () => getRule()?.name?.trim() || "",
130
+ setValue: (value) => {
131
+ const next = value.trim();
132
+ updateRule((rule) => ({ ...rule, name: next || undefined }));
133
+ },
134
+ emptyValueText: "(uses id)",
135
+ },
136
+ {
137
+ id: "id",
138
+ type: "text",
139
+ label: "ID",
140
+ description: "Stable identifier used for overrides across scopes",
141
+ getValue: () => getRule()?.id ?? "",
142
+ setValue: (value) => {
143
+ const next = value.trim();
144
+ if (!next) return;
145
+ updateRule((rule) => ({ ...rule, id: next }));
146
+ },
147
+ },
148
+ {
149
+ id: "description",
150
+ type: "text",
151
+ label: "Description",
152
+ description: "Human-readable explanation",
153
+ getValue: () => getRule()?.description?.trim() || "",
154
+ setValue: (value) => {
155
+ const next = value.trim();
156
+ updateRule((rule) => ({ ...rule, description: next || undefined }));
157
+ },
158
+ emptyValueText: "(empty)",
159
+ },
160
+ {
161
+ id: "protection",
162
+ type: "enum",
163
+ label: "Protection",
164
+ description: "noAccess | readOnly | none",
165
+ getValue: () => getRule()?.protection ?? "readOnly",
166
+ setValue: (value) => {
167
+ if (value !== "noAccess" && value !== "readOnly" && value !== "none") {
168
+ return;
169
+ }
170
+ updateRule((rule) => ({ ...rule, protection: value }));
171
+ },
172
+ options: ["noAccess", "readOnly", "none"],
173
+ },
174
+ {
175
+ id: "enabled",
176
+ type: "boolean",
177
+ label: "Enabled",
178
+ description: "Turn this policy on/off",
179
+ getValue: () => getRule()?.enabled !== false,
180
+ setValue: (value) => {
181
+ updateRule((rule) => ({ ...rule, enabled: value }));
182
+ },
183
+ trueLabel: "on",
184
+ falseLabel: "off",
185
+ },
186
+ {
187
+ id: "onlyIfExists",
188
+ type: "boolean",
189
+ label: "Only if exists",
190
+ description: "Only block when file exists on disk",
191
+ getValue: () => getRule()?.onlyIfExists !== false,
192
+ setValue: (value) => {
193
+ updateRule((rule) => ({ ...rule, onlyIfExists: value }));
194
+ },
195
+ trueLabel: "on",
196
+ falseLabel: "off",
197
+ },
198
+ {
199
+ id: "patterns",
200
+ type: "submenu",
201
+ label: "Patterns",
202
+ description: "Files protected by this policy",
203
+ getValue: () => `${getRule()?.patterns?.length ?? 0} items`,
204
+ submenu: (done) => {
205
+ const rule = getRule();
206
+ const items = (rule?.patterns ?? []).map((p) => ({
207
+ pattern: p.pattern,
208
+ description: p.pattern,
209
+ regex: p.regex,
210
+ }));
211
+
212
+ return new PatternEditor({
213
+ label: "Policy patterns",
214
+ items,
215
+ theme,
216
+ context: "file",
217
+ onSave: (newItems) => {
218
+ const patterns: PatternConfig[] = newItems
219
+ .map((p) => {
220
+ const pattern = p.pattern.trim();
221
+ if (!pattern) return null;
222
+ return { pattern, ...(p.regex ? { regex: true } : {}) };
223
+ })
224
+ .filter((item): item is PatternConfig => item !== null);
225
+
226
+ updateRule((current) => ({ ...current, patterns }));
227
+ },
228
+ onDone: () => done(`${getRule()?.patterns?.length ?? 0} items`),
229
+ });
230
+ },
231
+ },
232
+ {
233
+ id: "allowedPatterns",
234
+ type: "submenu",
235
+ label: "Allowed patterns",
236
+ description: "Exceptions",
237
+ getValue: () => `${getRule()?.allowedPatterns?.length ?? 0} items`,
238
+ submenu: (done) => {
239
+ const rule = getRule();
240
+ const items = (rule?.allowedPatterns ?? []).map((p) => ({
241
+ pattern: p.pattern,
242
+ description: p.pattern,
243
+ regex: p.regex,
244
+ }));
245
+
246
+ return new PatternEditor({
247
+ label: "Policy allowed patterns",
248
+ items,
249
+ theme,
250
+ context: "file",
251
+ onSave: (newItems) => {
252
+ const patterns: PatternConfig[] = newItems
253
+ .map((p) => {
254
+ const pattern = p.pattern.trim();
255
+ if (!pattern) return null;
256
+ return { pattern, ...(p.regex ? { regex: true } : {}) };
257
+ })
258
+ .filter((item): item is PatternConfig => item !== null);
259
+
260
+ updateRule((current) => ({
261
+ ...current,
262
+ allowedPatterns: patterns.length > 0 ? patterns : undefined,
263
+ }));
264
+ },
265
+ onDone: () =>
266
+ done(`${getRule()?.allowedPatterns?.length ?? 0} items`),
267
+ });
268
+ },
269
+ },
270
+ {
271
+ id: "blockMessage",
272
+ type: "text",
273
+ label: "Block message",
274
+ description: "Custom block message ({file} supported)",
275
+ getValue: () => getRule()?.blockMessage?.trim() || "",
276
+ setValue: (value) => {
277
+ const next = value.trim();
278
+ updateRule((rule) => ({ ...rule, blockMessage: next || undefined }));
279
+ },
280
+ emptyValueText: "(default)",
281
+ },
282
+ {
283
+ id: "delete",
284
+ type: "action",
285
+ label: "Delete rule",
286
+ description: "Remove this rule",
287
+ getValue: () => "danger",
288
+ onConfirm: () => {
289
+ deleteRule();
290
+ },
291
+ confirmMessage: "Delete this rule? This cannot be undone.",
292
+ },
293
+ ];
294
+
295
+ return new SettingsDetailEditor({
296
+ title: () => {
297
+ const rule = getRule();
298
+ const title = rule?.name?.trim() || rule?.id || `Policy ${index + 1}`;
299
+ return `Policy: ${title}`;
300
+ },
301
+ fields,
302
+ theme,
303
+ onDone,
304
+ getDoneSummary: () => {
305
+ const rule = getRule();
306
+ if (!rule) return "deleted";
307
+ return `${rule.protection}, ${rule.enabled === false ? "disabled" : "enabled"}`;
308
+ },
309
+ });
310
+ }
311
+
33
312
  export function registerGuardrailsSettings(pi: ExtensionAPI): void {
34
313
  registerSettingsCommand<GuardrailsConfig, ResolvedConfig>(pi, {
35
314
  commandName: "guardrails:settings",
@@ -41,7 +320,6 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
41
320
  { setDraft },
42
321
  ): SettingsSection[] => {
43
322
  const settingsTheme = getSettingsListTheme();
44
- // --- Helpers ---
45
323
 
46
324
  function count(id: string): string {
47
325
  const val =
@@ -57,26 +335,66 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
57
335
  setDraft(updated);
58
336
  }
59
337
 
60
- // --- Submenu factories ---
338
+ function getPolicyRules(): PolicyRule[] {
339
+ return (
340
+ tabConfig?.policies?.rules?.map((r) => ({ ...r })) ??
341
+ resolved.policies.rules.map((r) => ({ ...r }))
342
+ );
343
+ }
61
344
 
62
- function stringArraySubmenu(id: string, label: string) {
63
- return (_val: string, submenuDone: (v?: string) => void) => {
64
- const items =
65
- (getNestedValue(tabConfig ?? {}, id) as string[] | undefined) ??
66
- (getNestedValue(resolved, id) as string[]) ??
67
- [];
68
- let latest = [...items];
69
- return new ArrayEditor({
70
- label,
71
- items: [...items],
72
- theme: settingsTheme,
73
- onSave: (newItems) => {
74
- latest = newItems;
75
- applyDraft(id, newItems);
76
- },
77
- onDone: () => submenuDone(`${latest.length} items`),
78
- });
345
+ function setPolicyRules(rules: PolicyRule[]): void {
346
+ const updated = structuredClone(tabConfig ?? {}) as GuardrailsConfig;
347
+ updated.policies = {
348
+ ...(updated.policies ?? {}),
349
+ rules,
79
350
  };
351
+ setDraft(updated);
352
+ }
353
+
354
+ function updateRule(
355
+ index: number,
356
+ updater: (rule: PolicyRule) => PolicyRule,
357
+ ): void {
358
+ const rules = getPolicyRules();
359
+ const existing = rules[index];
360
+ if (!existing) return;
361
+ rules[index] = updater(existing);
362
+ setPolicyRules(rules);
363
+ }
364
+
365
+ function deleteRule(index: number): void {
366
+ const rules = getPolicyRules();
367
+ if (!rules[index]) return;
368
+ rules.splice(index, 1);
369
+ setPolicyRules(rules);
370
+ }
371
+
372
+ function addRule(name: string): number | null {
373
+ const normalizedName = name.trim();
374
+ if (!normalizedName) return null;
375
+
376
+ const rules = getPolicyRules();
377
+ const baseId = toKebabCase(normalizedName) || "policy";
378
+ const existingIds = new Set(rules.map((rule) => rule.id));
379
+
380
+ let id = baseId;
381
+ let i = 2;
382
+ while (existingIds.has(id)) {
383
+ id = `${baseId}-${i}`;
384
+ i++;
385
+ }
386
+
387
+ rules.push({
388
+ id,
389
+ name: normalizedName,
390
+ description: "",
391
+ patterns: [{ pattern: "" }],
392
+ protection: "readOnly",
393
+ onlyIfExists: true,
394
+ enabled: true,
395
+ });
396
+ setPolicyRules(rules);
397
+ return rules.length - 1;
80
398
  }
81
399
 
82
400
  function patternSubmenu(
@@ -131,11 +449,15 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
131
449
  context,
132
450
  onSave: (newItems) => {
133
451
  latestCount = newItems.length;
134
- const configs: PatternConfig[] = newItems.map((p) => {
135
- const cfg: PatternConfig = { pattern: p.pattern };
136
- if (p.regex) cfg.regex = true;
137
- return cfg;
138
- });
452
+ const configs: PatternConfig[] = newItems
453
+ .map((p) => {
454
+ const pattern = p.pattern.trim();
455
+ if (!pattern) return null;
456
+ const cfg: PatternConfig = { pattern };
457
+ if (p.regex) cfg.regex = true;
458
+ return cfg;
459
+ })
460
+ .filter((item): item is PatternConfig => item !== null);
139
461
  applyDraft(id, configs);
140
462
  },
141
463
  onDone: () => submenuDone(`${latestCount} items`),
@@ -143,10 +465,22 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
143
465
  };
144
466
  }
145
467
 
146
- // --- Sections ---
468
+ function getExplainModel(): string {
469
+ const model = tabConfig?.permissionGate?.explainModel;
470
+ if (model !== undefined) return model;
471
+ return resolved.permissionGate.explainModel ?? "";
472
+ }
147
473
 
148
- const featureItems = (Object.keys(FEATURE_UI) as FeatureKey[]).map(
149
- (key) => ({
474
+ function getExplainTimeout(): number {
475
+ return (
476
+ tabConfig?.permissionGate?.explainTimeout ??
477
+ resolved.permissionGate.explainTimeout
478
+ );
479
+ }
480
+
481
+ const featureItems = (Object.keys(FEATURE_UI) as FeatureKey[])
482
+ .filter((key) => key !== "policies")
483
+ .map((key) => ({
150
484
  id: `features.${key}`,
151
485
  label: FEATURE_UI[key].label,
152
486
  description: FEATURE_UI[key].description,
@@ -155,71 +489,66 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
155
489
  ? "enabled"
156
490
  : "disabled",
157
491
  values: ["enabled", "disabled"],
492
+ }));
493
+
494
+ const policyRules = getPolicyRules();
495
+
496
+ const openPolicyEditor = (
497
+ index: number,
498
+ submenuDone: (v?: string) => void,
499
+ ): Component =>
500
+ createPolicyRuleEditor({
501
+ index,
502
+ theme: settingsTheme,
503
+ getRule: () => getPolicyRules()[index],
504
+ updateRule: (updater) => updateRule(index, updater),
505
+ deleteRule: () => deleteRule(index),
506
+ onDone: submenuDone,
507
+ });
508
+
509
+ const policyItems: SettingItem[] = [
510
+ {
511
+ id: "features.policies",
512
+ label: " Enabled",
513
+ description: FEATURE_UI.policies.description,
514
+ currentValue:
515
+ (tabConfig?.features?.policies ?? resolved.features.policies)
516
+ ? "enabled"
517
+ : "disabled",
518
+ values: ["enabled", "disabled"],
519
+ },
520
+ ...policyRules.map((rule, index) => {
521
+ const label = rule.name?.trim() || rule.id || `Policy ${index + 1}`;
522
+ return {
523
+ id: `policies.rules.${index}`,
524
+ label: ` ${label}`,
525
+ description: rule.description?.trim() || "No description",
526
+ currentValue: `${rule.protection}, ${rule.enabled === false ? "disabled" : "enabled"}`,
527
+ submenu: (_val: string, submenuDone: (v?: string) => void) =>
528
+ openPolicyEditor(index, submenuDone),
529
+ };
158
530
  }),
159
- );
531
+ ];
532
+
533
+ policyItems.push({
534
+ id: "policies.addRule",
535
+ label: " + Add policy",
536
+ description: "Create policy, then open editor",
537
+ currentValue: "",
538
+ submenu: (_val: string, submenuDone: (v?: string) => void) =>
539
+ new AddRuleSubmenu(
540
+ settingsTheme,
541
+ addRule,
542
+ (index, done) => openPolicyEditor(index, done),
543
+ submenuDone,
544
+ ),
545
+ });
160
546
 
161
547
  return [
162
548
  { label: "Features", items: featureItems },
163
549
  {
164
- label: "Env Files",
165
- items: [
166
- {
167
- id: "envFiles.onlyBlockIfExists",
168
- label: "Only block existing files",
169
- description:
170
- "Only block .env file access if the file exists on disk",
171
- currentValue:
172
- (tabConfig?.envFiles?.onlyBlockIfExists ??
173
- resolved.envFiles.onlyBlockIfExists)
174
- ? "on"
175
- : "off",
176
- values: ["on", "off"],
177
- },
178
- {
179
- id: "envFiles.protectedPatterns",
180
- label: "Protected patterns",
181
- description: "Patterns for files to protect (e.g. .env.local)",
182
- currentValue: count("envFiles.protectedPatterns"),
183
- submenu: patternConfigSubmenu(
184
- "envFiles.protectedPatterns",
185
- "Protected Patterns",
186
- "file",
187
- ),
188
- },
189
- {
190
- id: "envFiles.allowedPatterns",
191
- label: "Allowed patterns",
192
- description: "Patterns for exceptions (e.g. .env.example)",
193
- currentValue: count("envFiles.allowedPatterns"),
194
- submenu: patternConfigSubmenu(
195
- "envFiles.allowedPatterns",
196
- "Allowed Patterns",
197
- "file",
198
- ),
199
- },
200
- {
201
- id: "envFiles.protectedDirectories",
202
- label: "Protected directories",
203
- description: "Patterns for directories to protect",
204
- currentValue: count("envFiles.protectedDirectories"),
205
- submenu: patternConfigSubmenu(
206
- "envFiles.protectedDirectories",
207
- "Protected Directories",
208
- "file",
209
- ),
210
- },
211
- {
212
- id: "envFiles.protectedTools",
213
- label: "Protected tools",
214
- description:
215
- "Tools to intercept (read, write, edit, bash, grep, find, ls)",
216
- currentValue: count("envFiles.protectedTools"),
217
- submenu: stringArraySubmenu(
218
- "envFiles.protectedTools",
219
- "Protected Tools",
220
- ),
221
- },
222
- ],
550
+ label: `Policies (${policyRules.length})`,
551
+ items: policyItems,
223
552
  },
224
553
  {
225
554
  label: "Permission Gate",
@@ -270,6 +599,75 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
270
599
  "command",
271
600
  ),
272
601
  },
602
+ {
603
+ id: "permissionGate.explainCommands",
604
+ label: "Explain commands",
605
+ description:
606
+ "Call an LLM to explain dangerous commands in the confirmation dialog",
607
+ currentValue:
608
+ (tabConfig?.permissionGate?.explainCommands ??
609
+ resolved.permissionGate.explainCommands)
610
+ ? "on"
611
+ : "off",
612
+ values: ["on", "off"],
613
+ },
614
+ {
615
+ id: "permissionGate.explainModel",
616
+ label: "Explain model",
617
+ description: "Model spec in provider/model-id format",
618
+ currentValue: getExplainModel() || "(not set)",
619
+ submenu: (_val: string, submenuDone: (v?: string) => void) =>
620
+ new SettingsDetailEditor({
621
+ title: "Explain Commands: Model",
622
+ theme: settingsTheme,
623
+ onDone: submenuDone,
624
+ getDoneSummary: () => getExplainModel() || "(not set)",
625
+ fields: [
626
+ {
627
+ id: "permissionGate.explainModel",
628
+ type: "text",
629
+ label: "Model",
630
+ description: "Format: provider/model-id",
631
+ getValue: getExplainModel,
632
+ setValue: (value) => {
633
+ const model = value.trim();
634
+ applyDraft(
635
+ "permissionGate.explainModel",
636
+ model || undefined,
637
+ );
638
+ },
639
+ emptyValueText: "(not set)",
640
+ },
641
+ ],
642
+ }),
643
+ },
644
+ {
645
+ id: "permissionGate.explainTimeout",
646
+ label: "Explain timeout",
647
+ description: "Timeout for LLM explanation in milliseconds",
648
+ currentValue: `${getExplainTimeout()}ms`,
649
+ submenu: (_val: string, submenuDone: (v?: string) => void) =>
650
+ new SettingsDetailEditor({
651
+ title: "Explain Commands: Timeout",
652
+ theme: settingsTheme,
653
+ onDone: submenuDone,
654
+ getDoneSummary: () => `${getExplainTimeout()}ms`,
655
+ fields: [
656
+ {
657
+ id: "permissionGate.explainTimeout",
658
+ type: "text",
659
+ label: "Timeout (ms)",
660
+ description: "Abort explanation call after this many ms",
661
+ getValue: () => String(getExplainTimeout()),
662
+ setValue: (value) => {
663
+ const parsed = Number.parseInt(value.trim(), 10);
664
+ if (Number.isNaN(parsed) || parsed < 1) return;
665
+ applyDraft("permissionGate.explainTimeout", parsed);
666
+ },
667
+ },
668
+ ],
669
+ }),
670
+ },
273
671
  ],
274
672
  },
275
673
  ];