@aliou/pi-guardrails 0.7.7 → 0.9.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,29 @@
1
1
  import {
2
- ArrayEditor,
2
+ FuzzySelector,
3
3
  getNestedValue,
4
4
  registerSettingsCommand,
5
+ SettingsDetailEditor,
6
+ type SettingsDetailField,
5
7
  type SettingsSection,
8
+ type SettingsTheme,
6
9
  setNestedValue,
10
+ Wizard,
7
11
  } from "@aliou/pi-utils-settings";
8
12
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
- import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
13
+ import {
14
+ type Component,
15
+ Input,
16
+ Key,
17
+ matchesKey,
18
+ type SettingItem,
19
+ type SettingsListTheme,
20
+ } from "@mariozechner/pi-tui";
10
21
  import { PatternEditor } from "../components/pattern-editor";
11
22
  import type {
12
23
  DangerousPattern,
13
24
  GuardrailsConfig,
14
25
  PatternConfig,
26
+ PolicyRule,
15
27
  ResolvedConfig,
16
28
  } from "../config";
17
29
  import { configLoader } from "../config";
@@ -19,9 +31,9 @@ import { configLoader } from "../config";
19
31
  type FeatureKey = keyof ResolvedConfig["features"];
20
32
 
21
33
  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",
34
+ policies: {
35
+ label: "Policies",
36
+ description: "Block or limit file access using named policy rules",
25
37
  },
26
38
  permissionGate: {
27
39
  label: "Permission gate",
@@ -30,6 +42,614 @@ const FEATURE_UI: Record<FeatureKey, { label: string; description: string }> = {
30
42
  },
31
43
  };
32
44
 
45
+ const POLICY_EXAMPLES: Array<{
46
+ label: string;
47
+ description: string;
48
+ rule: PolicyRule;
49
+ }> = [
50
+ {
51
+ label: "Secrets (.env)",
52
+ description: "Block dotenv-like files (glob)",
53
+ rule: {
54
+ id: "example-secret-env-files",
55
+ name: "Secret env files",
56
+ description: "Block .env files and variants",
57
+ patterns: [{ pattern: ".env" }, { pattern: ".env.*" }],
58
+ allowedPatterns: [
59
+ { pattern: ".env.example" },
60
+ { pattern: "*.sample.env" },
61
+ ],
62
+ protection: "noAccess",
63
+ onlyIfExists: true,
64
+ enabled: true,
65
+ },
66
+ },
67
+ {
68
+ label: "Logs (*.log)",
69
+ description: "Mark log files read-only (glob)",
70
+ rule: {
71
+ id: "example-log-files",
72
+ name: "Log files",
73
+ description: "Treat log files as read-only",
74
+ patterns: [{ pattern: "*.log" }, { pattern: "*.out" }],
75
+ protection: "readOnly",
76
+ onlyIfExists: true,
77
+ enabled: true,
78
+ },
79
+ },
80
+ {
81
+ label: "Regex env",
82
+ description: "Regex match for .env and .env.*",
83
+ rule: {
84
+ id: "example-regex-env",
85
+ name: "Regex env files",
86
+ description: "Regex example for env files",
87
+ patterns: [{ pattern: "^\\.env(\\..+)?$", regex: true }],
88
+ allowedPatterns: [{ pattern: "^\\.env\\.example$", regex: true }],
89
+ protection: "noAccess",
90
+ onlyIfExists: true,
91
+ enabled: true,
92
+ },
93
+ },
94
+ ];
95
+
96
+ function toKebabCase(input: string): string {
97
+ return input
98
+ .trim()
99
+ .toLowerCase()
100
+ .replace(/[^a-z0-9]+/g, "-")
101
+ .replace(/^-+|-+$/g, "");
102
+ }
103
+
104
+ function appendPolicyRule(
105
+ config: GuardrailsConfig | null,
106
+ example: PolicyRule,
107
+ ): GuardrailsConfig {
108
+ const next = structuredClone(config ?? {}) as GuardrailsConfig;
109
+ const currentRules = next.policies?.rules ?? [];
110
+
111
+ const existingIds = new Set(currentRules.map((rule) => rule.id));
112
+ const baseId =
113
+ toKebabCase(example.id || example.name || "example") || "example";
114
+ let id = baseId;
115
+ let i = 2;
116
+ while (existingIds.has(id)) {
117
+ id = `${baseId}-${i}`;
118
+ i++;
119
+ }
120
+
121
+ const rule = structuredClone(example);
122
+ rule.id = id;
123
+
124
+ next.policies = {
125
+ ...(next.policies ?? {}),
126
+ rules: [...currentRules, rule],
127
+ };
128
+
129
+ return next;
130
+ }
131
+
132
+ interface NewPolicyDraft {
133
+ name: string;
134
+ id: string;
135
+ protection: PolicyRule["protection"];
136
+ patterns: PatternConfig[];
137
+ }
138
+
139
+ class PolicyNameStep implements Component {
140
+ private readonly input = new Input();
141
+
142
+ constructor(
143
+ private readonly theme: SettingsListTheme,
144
+ private readonly state: NewPolicyDraft,
145
+ private readonly onComplete: () => void,
146
+ ) {
147
+ this.input.setValue(state.name);
148
+ this.input.onSubmit = () => {
149
+ const name = this.input.getValue().trim();
150
+ if (!name) return;
151
+ this.state.name = name;
152
+ if (!this.state.id) {
153
+ this.state.id = toKebabCase(name) || "policy";
154
+ }
155
+ this.onComplete();
156
+ };
157
+ }
158
+
159
+ invalidate() {}
160
+
161
+ render(width: number): string[] {
162
+ return [
163
+ this.theme.hint(" Step 1: Policy name"),
164
+ "",
165
+ ...this.input.render(Math.max(1, width - 2)).map((line) => ` ${line}`),
166
+ "",
167
+ this.theme.hint(" Example: Secret files"),
168
+ this.theme.hint(" Enter to continue"),
169
+ ];
170
+ }
171
+
172
+ handleInput(data: string): void {
173
+ this.input.handleInput(data);
174
+ }
175
+ }
176
+
177
+ class PolicyProtectionStep implements Component {
178
+ private readonly selector: FuzzySelector;
179
+
180
+ constructor(
181
+ theme: SettingsListTheme,
182
+ state: NewPolicyDraft,
183
+ onComplete: () => void,
184
+ ) {
185
+ this.selector = new FuzzySelector({
186
+ label: "Protection",
187
+ items: ["noAccess", "readOnly", "none"],
188
+ currentValue: state.protection,
189
+ theme,
190
+ onSelect: (value) => {
191
+ if (value === "noAccess" || value === "readOnly" || value === "none") {
192
+ state.protection = value;
193
+ onComplete();
194
+ }
195
+ },
196
+ onDone: () => {
197
+ // Esc is handled by Wizard.
198
+ },
199
+ });
200
+ }
201
+
202
+ invalidate(): void {
203
+ this.selector.invalidate?.();
204
+ }
205
+
206
+ render(width: number): string[] {
207
+ return this.selector.render(width);
208
+ }
209
+
210
+ handleInput(data: string): void {
211
+ this.selector.handleInput(data);
212
+ }
213
+ }
214
+
215
+ class PolicyPatternsStep implements Component {
216
+ private readonly editor: PatternEditor;
217
+
218
+ constructor(
219
+ theme: SettingsListTheme,
220
+ state: NewPolicyDraft,
221
+ onComplete: () => void,
222
+ ) {
223
+ this.editor = new PatternEditor({
224
+ label: "Policy patterns",
225
+ context: "file",
226
+ theme,
227
+ items: state.patterns.map((p) => ({
228
+ pattern: p.pattern,
229
+ description: p.pattern,
230
+ regex: p.regex,
231
+ })),
232
+ onSave: (items) => {
233
+ state.patterns = items
234
+ .map((item) => {
235
+ const pattern = item.pattern.trim();
236
+ if (!pattern) return null;
237
+ return {
238
+ pattern,
239
+ ...(item.regex ? { regex: true } : {}),
240
+ };
241
+ })
242
+ .filter((item): item is PatternConfig => item !== null);
243
+ },
244
+ onDone: () => {
245
+ if (state.patterns.length > 0) {
246
+ onComplete();
247
+ }
248
+ },
249
+ });
250
+ }
251
+
252
+ invalidate(): void {
253
+ this.editor.invalidate?.();
254
+ }
255
+
256
+ render(width: number): string[] {
257
+ return this.editor.render(width);
258
+ }
259
+
260
+ handleInput(data: string): void {
261
+ this.editor.handleInput(data);
262
+ }
263
+ }
264
+
265
+ class PolicyReviewStep implements Component {
266
+ constructor(
267
+ private readonly theme: SettingsListTheme,
268
+ private readonly state: NewPolicyDraft,
269
+ ) {}
270
+
271
+ invalidate() {}
272
+
273
+ render(_width: number): string[] {
274
+ const patternPreview =
275
+ this.state.patterns.length > 0
276
+ ? this.state.patterns
277
+ .slice(0, 3)
278
+ .map((p) => `${p.pattern}${p.regex ? " [regex]" : ""}`)
279
+ .join(", ")
280
+ : "(none)";
281
+
282
+ return [
283
+ this.theme.hint(" Review"),
284
+ "",
285
+ this.theme.hint(` Name: ${this.state.name || "(empty)"}`),
286
+ this.theme.hint(` ID: ${this.state.id || "(auto)"}`),
287
+ this.theme.hint(` Protection: ${this.state.protection}`),
288
+ this.theme.hint(` Patterns: ${this.state.patterns.length}`),
289
+ this.theme.hint(` ${patternPreview}`),
290
+ "",
291
+ this.theme.hint(" Ctrl+S: create + open editor · Esc: cancel"),
292
+ ];
293
+ }
294
+
295
+ handleInput(_data: string): void {}
296
+ }
297
+
298
+ class AddRuleSubmenu implements Component {
299
+ private readonly wizard: Wizard;
300
+ private activeEditor: Component | null = null;
301
+
302
+ constructor(
303
+ theme: SettingsTheme,
304
+ onCreate: (draft: NewPolicyDraft) => number | null,
305
+ openEditor: (index: number, done: (value?: string) => void) => Component,
306
+ onDone: (value?: string) => void,
307
+ ) {
308
+ const state: NewPolicyDraft = {
309
+ name: "",
310
+ id: "",
311
+ protection: "readOnly",
312
+ patterns: [],
313
+ };
314
+
315
+ this.wizard = new Wizard({
316
+ title: "Add policy",
317
+ theme,
318
+ steps: [
319
+ {
320
+ label: "Name",
321
+ build: (ctx) =>
322
+ new PolicyNameStep(theme, state, () => {
323
+ ctx.markComplete();
324
+ ctx.goNext();
325
+ }),
326
+ },
327
+ {
328
+ label: "Protection",
329
+ build: (ctx) =>
330
+ new PolicyProtectionStep(theme, state, () => {
331
+ ctx.markComplete();
332
+ ctx.goNext();
333
+ }),
334
+ },
335
+ {
336
+ label: "Patterns",
337
+ build: (ctx) =>
338
+ new PolicyPatternsStep(theme, state, () => {
339
+ if (state.patterns.length === 0) {
340
+ ctx.markIncomplete();
341
+ return;
342
+ }
343
+ ctx.markComplete();
344
+ ctx.goNext();
345
+ }),
346
+ },
347
+ {
348
+ label: "Review",
349
+ build: (ctx) => {
350
+ ctx.markComplete();
351
+ return new PolicyReviewStep(theme, state);
352
+ },
353
+ },
354
+ ],
355
+ onComplete: () => {
356
+ if (!state.name.trim() || state.patterns.length === 0) return;
357
+ const index = onCreate(state);
358
+ if (index === null) return;
359
+ this.activeEditor = openEditor(index, (value) => {
360
+ this.activeEditor = null;
361
+ onDone(value);
362
+ });
363
+ },
364
+ onCancel: () => onDone(),
365
+ hintSuffix: "complete steps · Ctrl+S create",
366
+ minContentHeight: 12,
367
+ });
368
+ }
369
+
370
+ invalidate(): void {
371
+ this.activeEditor?.invalidate?.();
372
+ this.wizard.invalidate?.();
373
+ }
374
+
375
+ render(width: number): string[] {
376
+ if (this.activeEditor) {
377
+ return this.activeEditor.render(width);
378
+ }
379
+ return this.wizard.render(width);
380
+ }
381
+
382
+ handleInput(data: string): void {
383
+ if (this.activeEditor) {
384
+ this.activeEditor.handleInput?.(data);
385
+ return;
386
+ }
387
+ this.wizard.handleInput(data);
388
+ }
389
+ }
390
+
391
+ class ScopePickerSubmenu implements Component {
392
+ private selectedIndex = 0;
393
+
394
+ constructor(
395
+ private readonly theme: SettingsListTheme,
396
+ private readonly scopes: Array<"global" | "local" | "memory">,
397
+ private readonly onSelect: (scope: "global" | "local" | "memory") => void,
398
+ private readonly onDone: (value?: string) => void,
399
+ ) {}
400
+
401
+ invalidate() {}
402
+
403
+ render(_width: number): string[] {
404
+ const lines: string[] = [
405
+ this.theme.label(" Add example to scope", true),
406
+ "",
407
+ this.theme.hint(" Select target scope:"),
408
+ ];
409
+
410
+ for (let i = 0; i < this.scopes.length; i++) {
411
+ const scope = this.scopes[i];
412
+ if (!scope) continue;
413
+ const isSelected = i === this.selectedIndex;
414
+ const prefix = isSelected ? this.theme.cursor : " ";
415
+ lines.push(`${prefix}${this.theme.value(scope, isSelected)}`);
416
+ }
417
+
418
+ lines.push("");
419
+ lines.push(this.theme.hint(" Enter: apply · Esc: back"));
420
+ return lines;
421
+ }
422
+
423
+ handleInput(data: string): void {
424
+ if (matchesKey(data, Key.up) || data === "k") {
425
+ this.selectedIndex =
426
+ this.selectedIndex === 0
427
+ ? this.scopes.length - 1
428
+ : this.selectedIndex - 1;
429
+ return;
430
+ }
431
+
432
+ if (matchesKey(data, Key.down) || data === "j") {
433
+ this.selectedIndex =
434
+ this.selectedIndex === this.scopes.length - 1
435
+ ? 0
436
+ : this.selectedIndex + 1;
437
+ return;
438
+ }
439
+
440
+ if (matchesKey(data, Key.enter)) {
441
+ const scope = this.scopes[this.selectedIndex];
442
+ if (!scope) return;
443
+ this.onSelect(scope);
444
+ this.onDone(`applied to ${scope}`);
445
+ return;
446
+ }
447
+
448
+ if (matchesKey(data, Key.escape)) {
449
+ this.onDone();
450
+ }
451
+ }
452
+ }
453
+
454
+ function createPolicyRuleEditor(options: {
455
+ index: number;
456
+ theme: SettingsListTheme;
457
+ getRule: () => PolicyRule | undefined;
458
+ updateRule: (updater: (rule: PolicyRule) => PolicyRule) => void;
459
+ deleteRule: () => void;
460
+ onDone: (value?: string) => void;
461
+ }): SettingsDetailEditor {
462
+ const { index, theme, getRule, updateRule, deleteRule, onDone } = options;
463
+
464
+ const fields: SettingsDetailField[] = [
465
+ {
466
+ id: "name",
467
+ type: "text",
468
+ label: "Name",
469
+ description: "Display name shown in settings",
470
+ getValue: () => getRule()?.name?.trim() || "",
471
+ setValue: (value) => {
472
+ const next = value.trim();
473
+ updateRule((rule) => ({ ...rule, name: next || undefined }));
474
+ },
475
+ emptyValueText: "(uses id)",
476
+ },
477
+ {
478
+ id: "id",
479
+ type: "text",
480
+ label: "ID",
481
+ description: "Stable identifier used for overrides across scopes",
482
+ getValue: () => getRule()?.id ?? "",
483
+ setValue: (value) => {
484
+ const next = value.trim();
485
+ if (!next) return;
486
+ updateRule((rule) => ({ ...rule, id: next }));
487
+ },
488
+ },
489
+ {
490
+ id: "description",
491
+ type: "text",
492
+ label: "Description",
493
+ description: "Human-readable explanation",
494
+ getValue: () => getRule()?.description?.trim() || "",
495
+ setValue: (value) => {
496
+ const next = value.trim();
497
+ updateRule((rule) => ({ ...rule, description: next || undefined }));
498
+ },
499
+ emptyValueText: "(empty)",
500
+ },
501
+ {
502
+ id: "protection",
503
+ type: "enum",
504
+ label: "Protection",
505
+ description: "noAccess | readOnly | none",
506
+ getValue: () => getRule()?.protection ?? "readOnly",
507
+ setValue: (value) => {
508
+ if (value !== "noAccess" && value !== "readOnly" && value !== "none") {
509
+ return;
510
+ }
511
+ updateRule((rule) => ({ ...rule, protection: value }));
512
+ },
513
+ options: ["noAccess", "readOnly", "none"],
514
+ },
515
+ {
516
+ id: "enabled",
517
+ type: "boolean",
518
+ label: "Enabled",
519
+ description: "Turn this policy on/off",
520
+ getValue: () => getRule()?.enabled !== false,
521
+ setValue: (value) => {
522
+ updateRule((rule) => ({ ...rule, enabled: value }));
523
+ },
524
+ trueLabel: "on",
525
+ falseLabel: "off",
526
+ },
527
+ {
528
+ id: "onlyIfExists",
529
+ type: "boolean",
530
+ label: "Only if exists",
531
+ description: "Only block when file exists on disk",
532
+ getValue: () => getRule()?.onlyIfExists !== false,
533
+ setValue: (value) => {
534
+ updateRule((rule) => ({ ...rule, onlyIfExists: value }));
535
+ },
536
+ trueLabel: "on",
537
+ falseLabel: "off",
538
+ },
539
+ {
540
+ id: "patterns",
541
+ type: "submenu",
542
+ label: "Patterns",
543
+ description: "Files protected by this policy",
544
+ getValue: () => `${getRule()?.patterns?.length ?? 0} items`,
545
+ submenu: (done) => {
546
+ const rule = getRule();
547
+ const items = (rule?.patterns ?? []).map((p) => ({
548
+ pattern: p.pattern,
549
+ description: p.pattern,
550
+ regex: p.regex,
551
+ }));
552
+
553
+ return new PatternEditor({
554
+ label: "Policy patterns",
555
+ items,
556
+ theme,
557
+ context: "file",
558
+ onSave: (newItems) => {
559
+ const patterns: PatternConfig[] = newItems
560
+ .map((p) => {
561
+ const pattern = p.pattern.trim();
562
+ if (!pattern) return null;
563
+ return { pattern, ...(p.regex ? { regex: true } : {}) };
564
+ })
565
+ .filter((item): item is PatternConfig => item !== null);
566
+
567
+ updateRule((current) => ({ ...current, patterns }));
568
+ },
569
+ onDone: () => done(`${getRule()?.patterns?.length ?? 0} items`),
570
+ });
571
+ },
572
+ },
573
+ {
574
+ id: "allowedPatterns",
575
+ type: "submenu",
576
+ label: "Allowed patterns",
577
+ description: "Exceptions",
578
+ getValue: () => `${getRule()?.allowedPatterns?.length ?? 0} items`,
579
+ submenu: (done) => {
580
+ const rule = getRule();
581
+ const items = (rule?.allowedPatterns ?? []).map((p) => ({
582
+ pattern: p.pattern,
583
+ description: p.pattern,
584
+ regex: p.regex,
585
+ }));
586
+
587
+ return new PatternEditor({
588
+ label: "Policy allowed patterns",
589
+ items,
590
+ theme,
591
+ context: "file",
592
+ onSave: (newItems) => {
593
+ const patterns: PatternConfig[] = newItems
594
+ .map((p) => {
595
+ const pattern = p.pattern.trim();
596
+ if (!pattern) return null;
597
+ return { pattern, ...(p.regex ? { regex: true } : {}) };
598
+ })
599
+ .filter((item): item is PatternConfig => item !== null);
600
+
601
+ updateRule((current) => ({
602
+ ...current,
603
+ allowedPatterns: patterns.length > 0 ? patterns : undefined,
604
+ }));
605
+ },
606
+ onDone: () =>
607
+ done(`${getRule()?.allowedPatterns?.length ?? 0} items`),
608
+ });
609
+ },
610
+ },
611
+ {
612
+ id: "blockMessage",
613
+ type: "text",
614
+ label: "Block message",
615
+ description: "Custom block message ({file} supported)",
616
+ getValue: () => getRule()?.blockMessage?.trim() || "",
617
+ setValue: (value) => {
618
+ const next = value.trim();
619
+ updateRule((rule) => ({ ...rule, blockMessage: next || undefined }));
620
+ },
621
+ emptyValueText: "(default)",
622
+ },
623
+ {
624
+ id: "delete",
625
+ type: "action",
626
+ label: "Delete rule",
627
+ description: "Remove this rule",
628
+ getValue: () => "danger",
629
+ onConfirm: () => {
630
+ deleteRule();
631
+ },
632
+ confirmMessage: "Delete this rule? This cannot be undone.",
633
+ },
634
+ ];
635
+
636
+ return new SettingsDetailEditor({
637
+ title: () => {
638
+ const rule = getRule();
639
+ const title = rule?.name?.trim() || rule?.id || `Policy ${index + 1}`;
640
+ return `Policy: ${title}`;
641
+ },
642
+ fields,
643
+ theme,
644
+ onDone,
645
+ getDoneSummary: () => {
646
+ const rule = getRule();
647
+ if (!rule) return "deleted";
648
+ return `${rule.protection}, ${rule.enabled === false ? "disabled" : "enabled"}`;
649
+ },
650
+ });
651
+ }
652
+
33
653
  export function registerGuardrailsSettings(pi: ExtensionAPI): void {
34
654
  registerSettingsCommand<GuardrailsConfig, ResolvedConfig>(pi, {
35
655
  commandName: "guardrails:settings",
@@ -37,46 +657,86 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
37
657
  configStore: configLoader,
38
658
  buildSections: (
39
659
  tabConfig: GuardrailsConfig | null,
40
- resolved: ResolvedConfig,
41
- { setDraft },
660
+ _resolved: ResolvedConfig,
661
+ { setDraft, theme },
42
662
  ): SettingsSection[] => {
43
- const settingsTheme = getSettingsListTheme();
44
- // --- Helpers ---
663
+ const settingsTheme = theme;
664
+ let scopedConfig = structuredClone(tabConfig ?? {}) as GuardrailsConfig;
665
+
666
+ function commitDraft(next: GuardrailsConfig): void {
667
+ scopedConfig = next;
668
+ setDraft(structuredClone(next));
669
+ }
45
670
 
46
671
  function count(id: string): string {
47
672
  const val =
48
- (getNestedValue(tabConfig ?? {}, id) as unknown[] | undefined) ??
49
- (getNestedValue(resolved, id) as unknown[]) ??
50
- [];
673
+ (getNestedValue(scopedConfig, id) as unknown[] | undefined) ?? [];
51
674
  return `${val.length} items`;
52
675
  }
53
676
 
54
677
  function applyDraft(id: string, value: unknown): void {
55
- const updated = structuredClone(tabConfig ?? {}) as GuardrailsConfig;
678
+ const updated = structuredClone(scopedConfig);
56
679
  setNestedValue(updated, id, value);
57
- setDraft(updated);
680
+ commitDraft(updated);
58
681
  }
59
682
 
60
- // --- Submenu factories ---
683
+ function getPolicyRules(): PolicyRule[] {
684
+ return scopedConfig.policies?.rules?.map((r) => ({ ...r })) ?? [];
685
+ }
61
686
 
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
- });
687
+ function setPolicyRules(rules: PolicyRule[]): void {
688
+ const updated = structuredClone(scopedConfig);
689
+ updated.policies = {
690
+ ...(updated.policies ?? {}),
691
+ rules,
79
692
  };
693
+ commitDraft(updated);
694
+ }
695
+
696
+ function updateRule(
697
+ index: number,
698
+ updater: (rule: PolicyRule) => PolicyRule,
699
+ ): void {
700
+ const rules = getPolicyRules();
701
+ const existing = rules[index];
702
+ if (!existing) return;
703
+ rules[index] = updater(existing);
704
+ setPolicyRules(rules);
705
+ }
706
+
707
+ function deleteRule(index: number): void {
708
+ const rules = getPolicyRules();
709
+ if (!rules[index]) return;
710
+ rules.splice(index, 1);
711
+ setPolicyRules(rules);
712
+ }
713
+
714
+ function addRule(draft: NewPolicyDraft): number | null {
715
+ const normalizedName = draft.name.trim();
716
+ if (!normalizedName || draft.patterns.length === 0) return null;
717
+
718
+ const rules = getPolicyRules();
719
+ const baseId = toKebabCase(draft.id || normalizedName) || "policy";
720
+ const existingIds = new Set(rules.map((rule) => rule.id));
721
+
722
+ let id = baseId;
723
+ let i = 2;
724
+ while (existingIds.has(id)) {
725
+ id = `${baseId}-${i}`;
726
+ i++;
727
+ }
728
+
729
+ rules.push({
730
+ id,
731
+ name: normalizedName,
732
+ description: "",
733
+ patterns: draft.patterns,
734
+ protection: draft.protection,
735
+ onlyIfExists: true,
736
+ enabled: true,
737
+ });
738
+ setPolicyRules(rules);
739
+ return rules.length - 1;
80
740
  }
81
741
 
82
742
  function patternSubmenu(
@@ -86,11 +746,9 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
86
746
  ) {
87
747
  return (_val: string, submenuDone: (v?: string) => void) => {
88
748
  const items =
89
- (getNestedValue(tabConfig ?? {}, id) as
749
+ (getNestedValue(scopedConfig, id) as
90
750
  | DangerousPattern[]
91
- | undefined) ??
92
- (getNestedValue(resolved, id) as DangerousPattern[]) ??
93
- [];
751
+ | undefined) ?? [];
94
752
  let latestCount = items.length;
95
753
  return new PatternEditor({
96
754
  label,
@@ -113,10 +771,7 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
113
771
  ) {
114
772
  return (_val: string, submenuDone: (v?: string) => void) => {
115
773
  const currentItems =
116
- (getNestedValue(tabConfig ?? {}, id) as
117
- | PatternConfig[]
118
- | undefined) ??
119
- (getNestedValue(resolved, id) as PatternConfig[]) ??
774
+ (getNestedValue(scopedConfig, id) as PatternConfig[] | undefined) ??
120
775
  [];
121
776
  const items = currentItems.map((p) => ({
122
777
  pattern: p.pattern,
@@ -131,11 +786,15 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
131
786
  context,
132
787
  onSave: (newItems) => {
133
788
  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
- });
789
+ const configs: PatternConfig[] = newItems
790
+ .map((p) => {
791
+ const pattern = p.pattern.trim();
792
+ if (!pattern) return null;
793
+ const cfg: PatternConfig = { pattern };
794
+ if (p.regex) cfg.regex = true;
795
+ return cfg;
796
+ })
797
+ .filter((item): item is PatternConfig => item !== null);
139
798
  applyDraft(id, configs);
140
799
  },
141
800
  onDone: () => submenuDone(`${latestCount} items`),
@@ -143,83 +802,100 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
143
802
  };
144
803
  }
145
804
 
146
- // --- Sections ---
805
+ function hasExplainModelOverride(): boolean {
806
+ return scopedConfig.permissionGate?.explainModel !== undefined;
807
+ }
808
+
809
+ function getExplainModel(): string {
810
+ return scopedConfig.permissionGate?.explainModel?.trim() ?? "";
811
+ }
812
+
813
+ function hasExplainTimeoutOverride(): boolean {
814
+ return scopedConfig.permissionGate?.explainTimeout !== undefined;
815
+ }
816
+
817
+ function getExplainTimeout(): number | null {
818
+ return scopedConfig.permissionGate?.explainTimeout ?? null;
819
+ }
820
+
821
+ const featureItems = (Object.keys(FEATURE_UI) as FeatureKey[])
822
+ .filter((key) => key !== "policies")
823
+ .map((key) => {
824
+ const scopedValue = scopedConfig.features?.[key];
825
+ return {
826
+ id: `features.${key}`,
827
+ label: FEATURE_UI[key].label,
828
+ description: FEATURE_UI[key].description,
829
+ currentValue:
830
+ scopedValue === undefined
831
+ ? "(inherited)"
832
+ : scopedValue
833
+ ? "enabled"
834
+ : "disabled",
835
+ values: ["enabled", "disabled"],
836
+ };
837
+ });
838
+
839
+ const policyRules = getPolicyRules();
147
840
 
148
- const featureItems = (Object.keys(FEATURE_UI) as FeatureKey[]).map(
149
- (key) => ({
150
- id: `features.${key}`,
151
- label: FEATURE_UI[key].label,
152
- description: FEATURE_UI[key].description,
841
+ const openPolicyEditor = (
842
+ index: number,
843
+ submenuDone: (v?: string) => void,
844
+ ): Component =>
845
+ createPolicyRuleEditor({
846
+ index,
847
+ theme: settingsTheme,
848
+ getRule: () => getPolicyRules()[index],
849
+ updateRule: (updater) => updateRule(index, updater),
850
+ deleteRule: () => deleteRule(index),
851
+ onDone: submenuDone,
852
+ });
853
+
854
+ const policyItems: SettingItem[] = [
855
+ {
856
+ id: "features.policies",
857
+ label: " Enabled",
858
+ description: FEATURE_UI.policies.description,
153
859
  currentValue:
154
- (tabConfig?.features?.[key] ?? resolved.features[key])
155
- ? "enabled"
156
- : "disabled",
860
+ scopedConfig.features?.policies === undefined
861
+ ? "(inherited)"
862
+ : scopedConfig.features.policies
863
+ ? "enabled"
864
+ : "disabled",
157
865
  values: ["enabled", "disabled"],
866
+ },
867
+ ...policyRules.map((rule, index) => {
868
+ const label = rule.name?.trim() || rule.id || `Policy ${index + 1}`;
869
+ return {
870
+ id: `policies.rules.${index}`,
871
+ label: ` ${label}`,
872
+ description: rule.description?.trim() || "No description",
873
+ currentValue: `${rule.protection}, ${rule.enabled === false ? "disabled" : "enabled"}`,
874
+ submenu: (_val: string, submenuDone: (v?: string) => void) =>
875
+ openPolicyEditor(index, submenuDone),
876
+ };
158
877
  }),
159
- );
878
+ ];
879
+
880
+ policyItems.push({
881
+ id: "policies.addRule",
882
+ label: " + Add policy",
883
+ description: "Open wizard to create policy",
884
+ currentValue: "",
885
+ submenu: (_val: string, submenuDone: (v?: string) => void) =>
886
+ new AddRuleSubmenu(
887
+ settingsTheme,
888
+ addRule,
889
+ (index, done) => openPolicyEditor(index, done),
890
+ submenuDone,
891
+ ),
892
+ });
160
893
 
161
894
  return [
162
895
  { label: "Features", items: featureItems },
163
896
  {
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
- ],
897
+ label: `Policies (${policyRules.length})`,
898
+ items: policyItems,
223
899
  },
224
900
  {
225
901
  label: "Permission Gate",
@@ -230,10 +906,11 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
230
906
  description:
231
907
  "Show confirmation dialog for dangerous commands (if off, just warns)",
232
908
  currentValue:
233
- (tabConfig?.permissionGate?.requireConfirmation ??
234
- resolved.permissionGate.requireConfirmation)
235
- ? "on"
236
- : "off",
909
+ scopedConfig.permissionGate?.requireConfirmation === undefined
910
+ ? "(inherited)"
911
+ : scopedConfig.permissionGate.requireConfirmation
912
+ ? "on"
913
+ : "off",
237
914
  values: ["on", "off"],
238
915
  },
239
916
  {
@@ -270,9 +947,130 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
270
947
  "command",
271
948
  ),
272
949
  },
950
+ {
951
+ id: "permissionGate.explainCommands",
952
+ label: "Explain commands",
953
+ description:
954
+ "Call an LLM to explain dangerous commands in the confirmation dialog",
955
+ currentValue:
956
+ scopedConfig.permissionGate?.explainCommands === undefined
957
+ ? "(inherited)"
958
+ : scopedConfig.permissionGate.explainCommands
959
+ ? "on"
960
+ : "off",
961
+ values: ["on", "off"],
962
+ },
963
+ {
964
+ id: "permissionGate.explainModel",
965
+ label: "Explain model",
966
+ description: "Model spec in provider/model-id format",
967
+ currentValue: hasExplainModelOverride()
968
+ ? getExplainModel() || "(not set)"
969
+ : "(inherited)",
970
+ submenu: (_val: string, submenuDone: (v?: string) => void) =>
971
+ new SettingsDetailEditor({
972
+ title: "Explain Commands: Model",
973
+ theme: settingsTheme,
974
+ onDone: submenuDone,
975
+ getDoneSummary: () => getExplainModel() || "(not set)",
976
+ fields: [
977
+ {
978
+ id: "permissionGate.explainModel",
979
+ type: "text",
980
+ label: "Model",
981
+ description: "Format: provider/model-id",
982
+ getValue: getExplainModel,
983
+ setValue: (value) => {
984
+ const model = value.trim();
985
+ applyDraft(
986
+ "permissionGate.explainModel",
987
+ model || undefined,
988
+ );
989
+ },
990
+ emptyValueText: "(not set)",
991
+ },
992
+ ],
993
+ }),
994
+ },
995
+ {
996
+ id: "permissionGate.explainTimeout",
997
+ label: "Explain timeout",
998
+ description: "Timeout for LLM explanation in milliseconds",
999
+ currentValue: hasExplainTimeoutOverride()
1000
+ ? `${getExplainTimeout()}ms`
1001
+ : "(inherited)",
1002
+ submenu: (_val: string, submenuDone: (v?: string) => void) =>
1003
+ new SettingsDetailEditor({
1004
+ title: "Explain Commands: Timeout",
1005
+ theme: settingsTheme,
1006
+ onDone: submenuDone,
1007
+ getDoneSummary: () => {
1008
+ const timeout = getExplainTimeout();
1009
+ return timeout === null ? "(not set)" : `${timeout}ms`;
1010
+ },
1011
+ fields: [
1012
+ {
1013
+ id: "permissionGate.explainTimeout",
1014
+ type: "text",
1015
+ label: "Timeout (ms)",
1016
+ description: "Abort explanation call after this many ms",
1017
+ getValue: () => {
1018
+ const timeout = getExplainTimeout();
1019
+ return timeout === null ? "" : String(timeout);
1020
+ },
1021
+ setValue: (value) => {
1022
+ const parsed = Number.parseInt(value.trim(), 10);
1023
+ if (Number.isNaN(parsed) || parsed < 1) return;
1024
+ applyDraft("permissionGate.explainTimeout", parsed);
1025
+ },
1026
+ },
1027
+ ],
1028
+ }),
1029
+ },
273
1030
  ],
274
1031
  },
275
1032
  ];
276
1033
  },
1034
+ extraTabs: [
1035
+ {
1036
+ id: "examples",
1037
+ label: "Examples",
1038
+ buildSections: ({
1039
+ enabledScopes,
1040
+ getDraftForScope,
1041
+ getRawForScope,
1042
+ setDraftForScope,
1043
+ theme,
1044
+ }): SettingsSection[] => {
1045
+ const items: SettingItem[] = POLICY_EXAMPLES.map((example) => ({
1046
+ id: `examples.${example.rule.id}`,
1047
+ label: ` ${example.label}`,
1048
+ description: example.description,
1049
+ currentValue: "apply",
1050
+ submenu: (_val: string, submenuDone: (v?: string) => void) =>
1051
+ new ScopePickerSubmenu(
1052
+ theme,
1053
+ enabledScopes,
1054
+ (targetScope) => {
1055
+ const baseConfig =
1056
+ getDraftForScope(targetScope) ??
1057
+ getRawForScope(targetScope) ??
1058
+ null;
1059
+ const updated = appendPolicyRule(baseConfig, example.rule);
1060
+ setDraftForScope(targetScope, updated);
1061
+ },
1062
+ submenuDone,
1063
+ ),
1064
+ }));
1065
+
1066
+ return [
1067
+ {
1068
+ label: "Policy presets",
1069
+ items,
1070
+ },
1071
+ ];
1072
+ },
1073
+ },
1074
+ ],
277
1075
  });
278
1076
  }