@aliou/pi-guardrails 0.8.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-guardrails",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -29,7 +29,7 @@
29
29
  "README.md"
30
30
  ],
31
31
  "dependencies": {
32
- "@aliou/pi-utils-settings": "^0.7.0",
32
+ "@aliou/pi-utils-settings": "^0.8.0",
33
33
  "@aliou/sh": "^0.1.0"
34
34
  },
35
35
  "peerDependencies": {
@@ -1,16 +1,20 @@
1
1
  import {
2
+ FuzzySelector,
2
3
  getNestedValue,
3
4
  registerSettingsCommand,
4
5
  SettingsDetailEditor,
5
6
  type SettingsDetailField,
6
7
  type SettingsSection,
8
+ type SettingsTheme,
7
9
  setNestedValue,
10
+ Wizard,
8
11
  } from "@aliou/pi-utils-settings";
9
12
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
- import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
11
13
  import {
12
14
  type Component,
13
15
  Input,
16
+ Key,
17
+ matchesKey,
14
18
  type SettingItem,
15
19
  type SettingsListTheme,
16
20
  } from "@mariozechner/pi-tui";
@@ -38,6 +42,57 @@ const FEATURE_UI: Record<FeatureKey, { label: string; description: string }> = {
38
42
  },
39
43
  };
40
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
+
41
96
  function toKebabCase(input: string): string {
42
97
  return input
43
98
  .trim()
@@ -46,67 +101,353 @@ function toKebabCase(input: string): string {
46
101
  .replace(/^-+|-+$/g, "");
47
102
  }
48
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
+
49
298
  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();
299
+ private readonly wizard: Wizard;
58
300
  private activeEditor: Component | null = null;
59
301
 
60
302
  constructor(
61
- theme: SettingsListTheme,
62
- onCreate: (name: string) => number | null,
303
+ theme: SettingsTheme,
304
+ onCreate: (draft: NewPolicyDraft) => number | null,
63
305
  openEditor: (index: number, done: (value?: string) => void) => Component,
64
306
  onDone: (value?: string) => void,
65
307
  ) {
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
- });
308
+ const state: NewPolicyDraft = {
309
+ name: "",
310
+ id: "",
311
+ protection: "readOnly",
312
+ patterns: [],
80
313
  };
81
- this.nameInput.onEscape = () => this.onDone();
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
+ });
82
368
  }
83
369
 
84
- invalidate() {
370
+ invalidate(): void {
85
371
  this.activeEditor?.invalidate?.();
372
+ this.wizard.invalidate?.();
86
373
  }
87
374
 
88
375
  render(width: number): string[] {
89
376
  if (this.activeEditor) {
90
377
  return this.activeEditor.render(width);
91
378
  }
379
+ return this.wizard.render(width);
380
+ }
92
381
 
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}`),
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),
98
406
  "",
99
- this.theme.hint(" Enter: create + edit · Esc: back"),
407
+ this.theme.hint(" Select target scope:"),
100
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;
101
421
  }
102
422
 
103
423
  handleInput(data: string): void {
104
- if (this.activeEditor) {
105
- this.activeEditor.handleInput?.(data);
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;
106
437
  return;
107
438
  }
108
439
 
109
- this.nameInput.handleInput(data);
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
+ }
110
451
  }
111
452
  }
112
453
 
@@ -316,39 +657,40 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
316
657
  configStore: configLoader,
317
658
  buildSections: (
318
659
  tabConfig: GuardrailsConfig | null,
319
- resolved: ResolvedConfig,
320
- { setDraft },
660
+ _resolved: ResolvedConfig,
661
+ { setDraft, theme },
321
662
  ): SettingsSection[] => {
322
- const settingsTheme = getSettingsListTheme();
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
+ }
323
670
 
324
671
  function count(id: string): string {
325
672
  const val =
326
- (getNestedValue(tabConfig ?? {}, id) as unknown[] | undefined) ??
327
- (getNestedValue(resolved, id) as unknown[]) ??
328
- [];
673
+ (getNestedValue(scopedConfig, id) as unknown[] | undefined) ?? [];
329
674
  return `${val.length} items`;
330
675
  }
331
676
 
332
677
  function applyDraft(id: string, value: unknown): void {
333
- const updated = structuredClone(tabConfig ?? {}) as GuardrailsConfig;
678
+ const updated = structuredClone(scopedConfig);
334
679
  setNestedValue(updated, id, value);
335
- setDraft(updated);
680
+ commitDraft(updated);
336
681
  }
337
682
 
338
683
  function getPolicyRules(): PolicyRule[] {
339
- return (
340
- tabConfig?.policies?.rules?.map((r) => ({ ...r })) ??
341
- resolved.policies.rules.map((r) => ({ ...r }))
342
- );
684
+ return scopedConfig.policies?.rules?.map((r) => ({ ...r })) ?? [];
343
685
  }
344
686
 
345
687
  function setPolicyRules(rules: PolicyRule[]): void {
346
- const updated = structuredClone(tabConfig ?? {}) as GuardrailsConfig;
688
+ const updated = structuredClone(scopedConfig);
347
689
  updated.policies = {
348
690
  ...(updated.policies ?? {}),
349
691
  rules,
350
692
  };
351
- setDraft(updated);
693
+ commitDraft(updated);
352
694
  }
353
695
 
354
696
  function updateRule(
@@ -369,12 +711,12 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
369
711
  setPolicyRules(rules);
370
712
  }
371
713
 
372
- function addRule(name: string): number | null {
373
- const normalizedName = name.trim();
374
- if (!normalizedName) return null;
714
+ function addRule(draft: NewPolicyDraft): number | null {
715
+ const normalizedName = draft.name.trim();
716
+ if (!normalizedName || draft.patterns.length === 0) return null;
375
717
 
376
718
  const rules = getPolicyRules();
377
- const baseId = toKebabCase(normalizedName) || "policy";
719
+ const baseId = toKebabCase(draft.id || normalizedName) || "policy";
378
720
  const existingIds = new Set(rules.map((rule) => rule.id));
379
721
 
380
722
  let id = baseId;
@@ -388,8 +730,8 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
388
730
  id,
389
731
  name: normalizedName,
390
732
  description: "",
391
- patterns: [{ pattern: "" }],
392
- protection: "readOnly",
733
+ patterns: draft.patterns,
734
+ protection: draft.protection,
393
735
  onlyIfExists: true,
394
736
  enabled: true,
395
737
  });
@@ -404,11 +746,9 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
404
746
  ) {
405
747
  return (_val: string, submenuDone: (v?: string) => void) => {
406
748
  const items =
407
- (getNestedValue(tabConfig ?? {}, id) as
749
+ (getNestedValue(scopedConfig, id) as
408
750
  | DangerousPattern[]
409
- | undefined) ??
410
- (getNestedValue(resolved, id) as DangerousPattern[]) ??
411
- [];
751
+ | undefined) ?? [];
412
752
  let latestCount = items.length;
413
753
  return new PatternEditor({
414
754
  label,
@@ -431,10 +771,7 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
431
771
  ) {
432
772
  return (_val: string, submenuDone: (v?: string) => void) => {
433
773
  const currentItems =
434
- (getNestedValue(tabConfig ?? {}, id) as
435
- | PatternConfig[]
436
- | undefined) ??
437
- (getNestedValue(resolved, id) as PatternConfig[]) ??
774
+ (getNestedValue(scopedConfig, id) as PatternConfig[] | undefined) ??
438
775
  [];
439
776
  const items = currentItems.map((p) => ({
440
777
  pattern: p.pattern,
@@ -465,31 +802,39 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
465
802
  };
466
803
  }
467
804
 
805
+ function hasExplainModelOverride(): boolean {
806
+ return scopedConfig.permissionGate?.explainModel !== undefined;
807
+ }
808
+
468
809
  function getExplainModel(): string {
469
- const model = tabConfig?.permissionGate?.explainModel;
470
- if (model !== undefined) return model;
471
- return resolved.permissionGate.explainModel ?? "";
810
+ return scopedConfig.permissionGate?.explainModel?.trim() ?? "";
472
811
  }
473
812
 
474
- function getExplainTimeout(): number {
475
- return (
476
- tabConfig?.permissionGate?.explainTimeout ??
477
- resolved.permissionGate.explainTimeout
478
- );
813
+ function hasExplainTimeoutOverride(): boolean {
814
+ return scopedConfig.permissionGate?.explainTimeout !== undefined;
815
+ }
816
+
817
+ function getExplainTimeout(): number | null {
818
+ return scopedConfig.permissionGate?.explainTimeout ?? null;
479
819
  }
480
820
 
481
821
  const featureItems = (Object.keys(FEATURE_UI) as FeatureKey[])
482
822
  .filter((key) => key !== "policies")
483
- .map((key) => ({
484
- id: `features.${key}`,
485
- label: FEATURE_UI[key].label,
486
- description: FEATURE_UI[key].description,
487
- currentValue:
488
- (tabConfig?.features?.[key] ?? resolved.features[key])
489
- ? "enabled"
490
- : "disabled",
491
- values: ["enabled", "disabled"],
492
- }));
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
+ });
493
838
 
494
839
  const policyRules = getPolicyRules();
495
840
 
@@ -512,9 +857,11 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
512
857
  label: " Enabled",
513
858
  description: FEATURE_UI.policies.description,
514
859
  currentValue:
515
- (tabConfig?.features?.policies ?? resolved.features.policies)
516
- ? "enabled"
517
- : "disabled",
860
+ scopedConfig.features?.policies === undefined
861
+ ? "(inherited)"
862
+ : scopedConfig.features.policies
863
+ ? "enabled"
864
+ : "disabled",
518
865
  values: ["enabled", "disabled"],
519
866
  },
520
867
  ...policyRules.map((rule, index) => {
@@ -533,7 +880,7 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
533
880
  policyItems.push({
534
881
  id: "policies.addRule",
535
882
  label: " + Add policy",
536
- description: "Create policy, then open editor",
883
+ description: "Open wizard to create policy",
537
884
  currentValue: "",
538
885
  submenu: (_val: string, submenuDone: (v?: string) => void) =>
539
886
  new AddRuleSubmenu(
@@ -559,10 +906,11 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
559
906
  description:
560
907
  "Show confirmation dialog for dangerous commands (if off, just warns)",
561
908
  currentValue:
562
- (tabConfig?.permissionGate?.requireConfirmation ??
563
- resolved.permissionGate.requireConfirmation)
564
- ? "on"
565
- : "off",
909
+ scopedConfig.permissionGate?.requireConfirmation === undefined
910
+ ? "(inherited)"
911
+ : scopedConfig.permissionGate.requireConfirmation
912
+ ? "on"
913
+ : "off",
566
914
  values: ["on", "off"],
567
915
  },
568
916
  {
@@ -605,17 +953,20 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
605
953
  description:
606
954
  "Call an LLM to explain dangerous commands in the confirmation dialog",
607
955
  currentValue:
608
- (tabConfig?.permissionGate?.explainCommands ??
609
- resolved.permissionGate.explainCommands)
610
- ? "on"
611
- : "off",
956
+ scopedConfig.permissionGate?.explainCommands === undefined
957
+ ? "(inherited)"
958
+ : scopedConfig.permissionGate.explainCommands
959
+ ? "on"
960
+ : "off",
612
961
  values: ["on", "off"],
613
962
  },
614
963
  {
615
964
  id: "permissionGate.explainModel",
616
965
  label: "Explain model",
617
966
  description: "Model spec in provider/model-id format",
618
- currentValue: getExplainModel() || "(not set)",
967
+ currentValue: hasExplainModelOverride()
968
+ ? getExplainModel() || "(not set)"
969
+ : "(inherited)",
619
970
  submenu: (_val: string, submenuDone: (v?: string) => void) =>
620
971
  new SettingsDetailEditor({
621
972
  title: "Explain Commands: Model",
@@ -645,20 +996,28 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
645
996
  id: "permissionGate.explainTimeout",
646
997
  label: "Explain timeout",
647
998
  description: "Timeout for LLM explanation in milliseconds",
648
- currentValue: `${getExplainTimeout()}ms`,
999
+ currentValue: hasExplainTimeoutOverride()
1000
+ ? `${getExplainTimeout()}ms`
1001
+ : "(inherited)",
649
1002
  submenu: (_val: string, submenuDone: (v?: string) => void) =>
650
1003
  new SettingsDetailEditor({
651
1004
  title: "Explain Commands: Timeout",
652
1005
  theme: settingsTheme,
653
1006
  onDone: submenuDone,
654
- getDoneSummary: () => `${getExplainTimeout()}ms`,
1007
+ getDoneSummary: () => {
1008
+ const timeout = getExplainTimeout();
1009
+ return timeout === null ? "(not set)" : `${timeout}ms`;
1010
+ },
655
1011
  fields: [
656
1012
  {
657
1013
  id: "permissionGate.explainTimeout",
658
1014
  type: "text",
659
1015
  label: "Timeout (ms)",
660
1016
  description: "Abort explanation call after this many ms",
661
- getValue: () => String(getExplainTimeout()),
1017
+ getValue: () => {
1018
+ const timeout = getExplainTimeout();
1019
+ return timeout === null ? "" : String(timeout);
1020
+ },
662
1021
  setValue: (value) => {
663
1022
  const parsed = Number.parseInt(value.trim(), 10);
664
1023
  if (Number.isNaN(parsed) || parsed < 1) return;
@@ -672,5 +1031,46 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
672
1031
  },
673
1032
  ];
674
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
+ ],
675
1075
  });
676
1076
  }
@@ -12,7 +12,8 @@ import {
12
12
  *
13
13
  * List mode: navigate, delete with 'd', add with 'a', edit with 'e'/Enter.
14
14
  * Form mode: three-field form (pattern + description + regex toggle),
15
- * Tab to switch fields, Enter to submit, Escape to cancel.
15
+ * Tab to switch fields, Ctrl+R to toggle regex, Enter to submit,
16
+ * Escape to cancel.
16
17
  */
17
18
 
18
19
  export interface PatternItem {
@@ -256,7 +257,7 @@ export class PatternEditor implements Component {
256
257
 
257
258
  lines.push(
258
259
  this.theme.hint(
259
- " Tab: switch field · Space: toggle regex · Enter: next/submit · Esc: cancel",
260
+ " Tab: switch field · Ctrl+R: toggle regex · Enter: next/submit · Esc: cancel",
260
261
  ),
261
262
  );
262
263
 
@@ -308,17 +309,18 @@ export class PatternEditor implements Component {
308
309
  return;
309
310
  }
310
311
 
312
+ if (matchesKey(data, Key.ctrl("r"))) {
313
+ this.regexEnabled = !this.regexEnabled;
314
+ return;
315
+ }
316
+
311
317
  if (matchesKey(data, Key.escape)) {
312
318
  this.cancelForm();
313
319
  return;
314
320
  }
315
321
 
316
- // Regex toggle: space toggles when on regex field
317
322
  if (this.activeField === "regex") {
318
- if (data === " " || matchesKey(data, Key.enter)) {
319
- this.regexEnabled = !this.regexEnabled;
320
- }
321
- // Enter on regex field submits if we already have a pattern
323
+ // Enter on regex field submits if we already have a pattern.
322
324
  if (matchesKey(data, Key.enter) && this.patternInput.getValue().trim()) {
323
325
  this.submitForm();
324
326
  }