@aliou/pi-guardrails 0.11.2 → 0.12.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.
Files changed (94) hide show
  1. package/README.md +72 -167
  2. package/extensions/guardrails/commands/examples/index.ts +520 -0
  3. package/extensions/guardrails/commands/onboarding/config.ts +54 -0
  4. package/{src/commands/onboarding-command.ts → extensions/guardrails/commands/onboarding/index.ts} +5 -31
  5. package/extensions/guardrails/commands/settings/add-rule-wizard.ts +267 -0
  6. package/extensions/guardrails/commands/settings/examples.ts +399 -0
  7. package/extensions/guardrails/commands/settings/index.ts +596 -0
  8. package/extensions/guardrails/commands/settings/path-list-editor.ts +158 -0
  9. package/extensions/guardrails/commands/settings/scope-picker-submenu.ts +69 -0
  10. package/extensions/guardrails/commands/settings/utils.ts +108 -0
  11. package/extensions/guardrails/components/onboarding-choice-step.ts +140 -0
  12. package/extensions/guardrails/components/onboarding-finish-step.ts +50 -0
  13. package/extensions/guardrails/components/onboarding-intro-step.ts +30 -0
  14. package/extensions/guardrails/components/onboarding-types.ts +10 -0
  15. package/extensions/guardrails/components/onboarding-wizard.ts +116 -0
  16. package/{src → extensions/guardrails}/components/pattern-editor.ts +11 -10
  17. package/extensions/guardrails/index.ts +106 -0
  18. package/extensions/guardrails/rules.test.ts +107 -0
  19. package/extensions/guardrails/rules.ts +119 -0
  20. package/extensions/guardrails/targets.test.ts +44 -0
  21. package/extensions/guardrails/targets.ts +66 -0
  22. package/extensions/path-access/grants.test.ts +47 -0
  23. package/extensions/path-access/grants.ts +68 -0
  24. package/extensions/path-access/index.ts +143 -0
  25. package/extensions/path-access/prompt.ts +196 -0
  26. package/extensions/path-access/rules.test.ts +46 -0
  27. package/extensions/path-access/rules.ts +37 -0
  28. package/extensions/path-access/targets.test.ts +40 -0
  29. package/extensions/path-access/targets.ts +19 -0
  30. package/extensions/permission-gate/grants.ts +21 -0
  31. package/extensions/permission-gate/index.ts +122 -0
  32. package/extensions/permission-gate/prompt.ts +222 -0
  33. package/extensions/permission-gate/rules.test.ts +132 -0
  34. package/extensions/permission-gate/rules.ts +72 -0
  35. package/package.json +18 -20
  36. package/schema.json +286 -0
  37. package/src/core/check.test.ts +169 -0
  38. package/src/core/check.ts +38 -0
  39. package/src/{hooks/permission-gate/dangerous-commands.test.ts → core/commands/dangerous.test.ts} +134 -2
  40. package/src/{hooks/permission-gate/dangerous-commands.ts → core/commands/dangerous.ts} +119 -1
  41. package/src/core/commands/index.ts +15 -0
  42. package/src/core/index.ts +13 -0
  43. package/src/{utils/path-access.test.ts → core/paths/access.test.ts} +1 -5
  44. package/src/core/paths/index.ts +14 -0
  45. package/src/{utils → core/shell}/command-args.test.ts +31 -20
  46. package/src/core/shell/index.ts +2 -0
  47. package/src/core/types.ts +55 -0
  48. package/src/shared/config/defaults.ts +118 -0
  49. package/src/shared/config/index.ts +17 -0
  50. package/src/shared/config/loader.ts +64 -0
  51. package/src/shared/config/migration/001-v0-format-upgrade.ts +107 -0
  52. package/src/shared/config/migration/002-strip-toolchain-fields.ts +39 -0
  53. package/src/shared/config/migration/003-strip-command-explainer-fields.ts +42 -0
  54. package/src/shared/config/migration/004-env-files-to-policies.ts +87 -0
  55. package/src/shared/config/migration/005-normalize-allowed-paths.ts +43 -0
  56. package/src/shared/config/migration/006-apply-builtin-defaults.ts +19 -0
  57. package/src/shared/config/migration/007-mark-onboarding-done.ts +25 -0
  58. package/src/shared/config/migration/index.ts +44 -0
  59. package/src/shared/config/migration/version.ts +7 -0
  60. package/src/shared/config/types.ts +141 -0
  61. package/src/shared/events.ts +100 -0
  62. package/src/shared/index.ts +6 -0
  63. package/src/shared/matching.test.ts +86 -0
  64. package/src/{utils → shared}/matching.ts +4 -4
  65. package/src/{utils → shared/paths}/bash-paths.test.ts +11 -2
  66. package/src/{utils → shared/paths}/bash-paths.ts +4 -4
  67. package/src/shared/paths/index.ts +1 -0
  68. package/src/shared/warnings.ts +17 -0
  69. package/docs/defaults.md +0 -140
  70. package/docs/examples.md +0 -170
  71. package/src/commands/onboarding.ts +0 -390
  72. package/src/commands/settings-command.ts +0 -1616
  73. package/src/config.ts +0 -392
  74. package/src/hooks/index.ts +0 -11
  75. package/src/hooks/path-access.ts +0 -395
  76. package/src/hooks/permission-gate/index.test.ts +0 -332
  77. package/src/hooks/permission-gate/index.ts +0 -595
  78. package/src/hooks/policies.ts +0 -322
  79. package/src/index.ts +0 -96
  80. package/src/lib/executor.ts +0 -280
  81. package/src/lib/index.ts +0 -16
  82. package/src/lib/model-resolver.ts +0 -47
  83. package/src/lib/timing.ts +0 -42
  84. package/src/lib/types.ts +0 -115
  85. package/src/utils/events.ts +0 -32
  86. package/src/utils/migration.test.ts +0 -58
  87. package/src/utils/migration.ts +0 -340
  88. package/src/utils/warnings.ts +0 -7
  89. /package/src/{utils/path-access.ts → core/paths/access.ts} +0 -0
  90. /package/src/{utils → core/paths}/path.test.ts +0 -0
  91. /package/src/{utils → core/paths}/path.ts +0 -0
  92. /package/src/{utils/shell-utils.ts → core/shell/ast.ts} +0 -0
  93. /package/src/{utils → core/shell}/command-args.ts +0 -0
  94. /package/src/{utils/glob-expander.ts → shared/glob.ts} +0 -0
@@ -1,1616 +0,0 @@
1
- import {
2
- FuzzySelector,
3
- getNestedValue,
4
- registerSettingsCommand,
5
- SettingsDetailEditor,
6
- type SettingsDetailField,
7
- type SettingsSection,
8
- type SettingsTheme,
9
- setNestedValue,
10
- Wizard,
11
- } from "@aliou/pi-utils-settings";
12
- import type { ExtensionAPI } 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";
21
- import { PatternEditor } from "../components/pattern-editor";
22
- import type {
23
- DangerousPattern,
24
- GuardrailsConfig,
25
- PatternConfig,
26
- PolicyRule,
27
- ResolvedConfig,
28
- } from "../config";
29
- import { configLoader } from "../config";
30
- import { normalizeAllowedPaths } from "../utils/migration";
31
-
32
- type FeatureKey = keyof ResolvedConfig["features"];
33
-
34
- const FEATURE_UI: Record<FeatureKey, { label: string; description: string }> = {
35
- policies: {
36
- label: "Policies",
37
- description: "Block or limit file access using named policy rules",
38
- },
39
- permissionGate: {
40
- label: "Permission gate",
41
- description:
42
- "Prompt for confirmation on dangerous commands (rm -rf, sudo, etc.)",
43
- },
44
- pathAccess: {
45
- label: "Path access",
46
- description: "Restrict tool access to the current working directory",
47
- },
48
- };
49
-
50
- const POLICY_EXAMPLES: Array<{
51
- label: string;
52
- description: string;
53
- rule: PolicyRule;
54
- }> = [
55
- {
56
- label: "Secrets (.env)",
57
- description: "Block dotenv-like files (glob)",
58
- rule: {
59
- id: "example-secret-env-files",
60
- name: "Secret env files",
61
- description: "Block .env files and variants",
62
- patterns: [{ pattern: ".env" }, { pattern: ".env.*" }],
63
- allowedPatterns: [
64
- { pattern: ".env.example" },
65
- { pattern: "*.sample.env" },
66
- ],
67
- protection: "noAccess",
68
- onlyIfExists: true,
69
- enabled: true,
70
- },
71
- },
72
- {
73
- label: "Logs (*.log)",
74
- description: "Mark log files read-only (glob)",
75
- rule: {
76
- id: "example-log-files",
77
- name: "Log files",
78
- description: "Treat log files as read-only",
79
- patterns: [{ pattern: "*.log" }, { pattern: "*.out" }],
80
- protection: "readOnly",
81
- onlyIfExists: true,
82
- enabled: true,
83
- },
84
- },
85
- {
86
- label: "Regex env",
87
- description: "Regex match for .env and .env.*",
88
- rule: {
89
- id: "example-regex-env",
90
- name: "Regex env files",
91
- description: "Regex example for env files",
92
- patterns: [{ pattern: "^\\.env(\\..+)?$", regex: true }],
93
- allowedPatterns: [{ pattern: "^\\.env\\.example$", regex: true }],
94
- protection: "noAccess",
95
- onlyIfExists: true,
96
- enabled: true,
97
- },
98
- },
99
- {
100
- label: "SSH keys",
101
- description: "Block access to SSH private keys",
102
- rule: {
103
- id: "example-ssh-keys",
104
- name: "SSH keys",
105
- description: "Block SSH private key files",
106
- patterns: [
107
- { pattern: "*.pem" },
108
- { pattern: "*_rsa" },
109
- { pattern: "*_ed25519" },
110
- ],
111
- allowedPatterns: [{ pattern: "*.pub" }],
112
- protection: "noAccess",
113
- onlyIfExists: true,
114
- enabled: true,
115
- },
116
- },
117
- {
118
- label: "AWS credentials",
119
- description: "Block AWS CLI credentials file",
120
- rule: {
121
- id: "example-aws-credentials",
122
- name: "AWS credentials",
123
- description: "Block AWS credentials and config files",
124
- patterns: [{ pattern: ".aws/credentials" }, { pattern: ".aws/config" }],
125
- protection: "noAccess",
126
- onlyIfExists: true,
127
- enabled: true,
128
- },
129
- },
130
- {
131
- label: "Database files",
132
- description: "Mark SQLite/DB files read-only",
133
- rule: {
134
- id: "example-database-files",
135
- name: "Database files",
136
- description: "Protect database files from modification",
137
- patterns: [
138
- { pattern: "*.db" },
139
- { pattern: "*.sqlite" },
140
- { pattern: "*.sqlite3" },
141
- ],
142
- protection: "readOnly",
143
- onlyIfExists: true,
144
- enabled: true,
145
- },
146
- },
147
- {
148
- label: "Kubernetes secrets",
149
- description: "Block kubeconfig and k8s secrets",
150
- rule: {
151
- id: "example-k8s-secrets",
152
- name: "Kubernetes secrets",
153
- description: "Block kubectl config and secrets",
154
- patterns: [{ pattern: ".kube/config" }, { pattern: "*kubeconfig*" }],
155
- protection: "noAccess",
156
- onlyIfExists: true,
157
- enabled: true,
158
- },
159
- },
160
- {
161
- label: "Certificates",
162
- description: "Block SSL/TLS certificate files",
163
- rule: {
164
- id: "example-certificates",
165
- name: "Certificates",
166
- description: "Block certificate and key files",
167
- patterns: [
168
- { pattern: "*.crt" },
169
- { pattern: "*.key" },
170
- { pattern: "*.p12" },
171
- ],
172
- allowedPatterns: [{ pattern: "*.csr" }],
173
- protection: "noAccess",
174
- onlyIfExists: true,
175
- enabled: true,
176
- },
177
- },
178
- ];
179
-
180
- const COMMAND_EXAMPLES: Array<{
181
- label: string;
182
- description: string;
183
- pattern: DangerousPattern;
184
- }> = [
185
- {
186
- label: "Homebrew",
187
- description: "Block brew commands (use Nix instead)",
188
- pattern: { pattern: "brew", description: "Homebrew package manager" },
189
- },
190
- {
191
- label: "Docker secrets",
192
- description: "Block docker commands that may expose environment secrets",
193
- pattern: {
194
- pattern: "docker inspect",
195
- description: "Docker inspect (may expose env vars)",
196
- },
197
- },
198
- {
199
- label: "Terraform apply",
200
- description: "Require confirmation for infrastructure changes",
201
- pattern: {
202
- pattern: "terraform apply",
203
- description: "Terraform infrastructure changes",
204
- },
205
- },
206
- {
207
- label: "Terraform destroy",
208
- description: "Require confirmation for infrastructure destruction",
209
- pattern: {
210
- pattern: "terraform destroy",
211
- description: "Terraform infrastructure destruction",
212
- },
213
- },
214
- {
215
- label: "kubectl delete",
216
- description: "Require confirmation for k8s resource deletion",
217
- pattern: {
218
- pattern: "kubectl delete",
219
- description: "Kubernetes resource deletion",
220
- },
221
- },
222
- {
223
- label: "docker system prune",
224
- description: "Require confirmation for Docker cleanup",
225
- pattern: {
226
- pattern: "docker system prune",
227
- description: "Docker system cleanup",
228
- },
229
- },
230
- {
231
- label: "git push --force",
232
- description: "Require confirmation for force push",
233
- pattern: { pattern: "git push --force", description: "Git force push" },
234
- },
235
- {
236
- label: "npm publish",
237
- description: "Require confirmation for package publishing",
238
- pattern: { pattern: "npm publish", description: "NPM package publishing" },
239
- },
240
- {
241
- label: "yarn publish",
242
- description: "Require confirmation for package publishing",
243
- pattern: {
244
- pattern: "yarn publish",
245
- description: "Yarn package publishing",
246
- },
247
- },
248
- {
249
- label: "pnpm publish",
250
- description: "Require confirmation for package publishing",
251
- pattern: {
252
- pattern: "pnpm publish",
253
- description: "PNPM package publishing",
254
- },
255
- },
256
- {
257
- label: "drop database",
258
- description: "Require confirmation for database drops",
259
- pattern: { pattern: "DROP DATABASE", description: "SQL database drop" },
260
- },
261
- {
262
- label: "drop table",
263
- description: "Require confirmation for table drops",
264
- pattern: { pattern: "DROP TABLE", description: "SQL table drop" },
265
- },
266
- {
267
- label: "dbt run",
268
- description: "Require confirmation for dbt model runs",
269
- pattern: {
270
- pattern: "dbt run",
271
- description: "dbt model execution",
272
- },
273
- },
274
- {
275
- label: "dbt seed",
276
- description: "Require confirmation for dbt seed data loading",
277
- pattern: {
278
- pattern: "dbt seed",
279
- description: "dbt seed data loading",
280
- },
281
- },
282
- {
283
- label: "aws s3 rm",
284
- description: "Require confirmation for AWS S3 deletions",
285
- pattern: {
286
- pattern: "aws s3 rm",
287
- description: "AWS S3 object deletion",
288
- },
289
- },
290
- {
291
- label: "aws iam",
292
- description: "Require confirmation for AWS IAM changes",
293
- pattern: {
294
- pattern: "aws iam",
295
- description: "AWS IAM permission changes",
296
- },
297
- },
298
- {
299
- label: "aws ec2 terminate",
300
- description: "Require confirmation for EC2 instance termination",
301
- pattern: {
302
- pattern: "aws ec2 terminate-instances",
303
- description: "AWS EC2 instance termination",
304
- },
305
- },
306
- {
307
- label: "kubectl apply",
308
- description: "Require confirmation for k8s resource application",
309
- pattern: {
310
- pattern: "kubectl apply",
311
- description: "Kubernetes resource application",
312
- },
313
- },
314
- {
315
- label: "kubectl scale",
316
- description: "Require confirmation for k8s scaling operations",
317
- pattern: {
318
- pattern: "kubectl scale",
319
- description: "Kubernetes scaling operation",
320
- },
321
- },
322
- {
323
- label: "docker rm",
324
- description: "Require confirmation for Docker container removal",
325
- pattern: {
326
- pattern: "docker rm",
327
- description: "Docker container removal",
328
- },
329
- },
330
- {
331
- label: "docker rmi",
332
- description: "Require confirmation for Docker image removal",
333
- pattern: {
334
- pattern: "docker rmi",
335
- description: "Docker image removal",
336
- },
337
- },
338
- {
339
- label: "docker compose down",
340
- description: "Require confirmation for Docker Compose teardown",
341
- pattern: {
342
- pattern: "docker compose down",
343
- description: "Docker Compose service teardown",
344
- },
345
- },
346
- {
347
- label: "terraform import",
348
- description: "Require confirmation for Terraform resource import",
349
- pattern: {
350
- pattern: "terraform import",
351
- description: "Terraform resource import",
352
- },
353
- },
354
- {
355
- label: "gcloud compute delete",
356
- description: "Require confirmation for GCP compute instance deletion",
357
- pattern: {
358
- pattern: "gcloud compute instances delete",
359
- description: "GCP compute instance deletion",
360
- },
361
- },
362
- {
363
- label: "gcloud iam",
364
- description: "Require confirmation for GCP IAM changes",
365
- pattern: {
366
- pattern: "gcloud iam",
367
- description: "GCP IAM permission changes",
368
- },
369
- },
370
- {
371
- label: "gcloud sql delete",
372
- description: "Require confirmation for GCP SQL instance deletion",
373
- pattern: {
374
- pattern: "gcloud sql instances delete",
375
- description: "GCP Cloud SQL instance deletion",
376
- },
377
- },
378
- ];
379
-
380
- function toKebabCase(input: string): string {
381
- return input
382
- .trim()
383
- .toLowerCase()
384
- .replace(/[^a-z0-9]+/g, "-")
385
- .replace(/^-+|-+$/g, "");
386
- }
387
-
388
- function appendPolicyRule(
389
- config: GuardrailsConfig | null,
390
- example: PolicyRule,
391
- ): GuardrailsConfig {
392
- const next = structuredClone(config ?? {}) as GuardrailsConfig;
393
- const currentRules = next.policies?.rules ?? [];
394
-
395
- const existingIds = new Set(currentRules.map((rule) => rule.id));
396
- const baseId =
397
- toKebabCase(example.id || example.name || "example") || "example";
398
- let id = baseId;
399
- let i = 2;
400
- while (existingIds.has(id)) {
401
- id = `${baseId}-${i}`;
402
- i++;
403
- }
404
-
405
- const rule = structuredClone(example);
406
- rule.id = id;
407
-
408
- next.policies = {
409
- ...(next.policies ?? {}),
410
- rules: [...currentRules, rule],
411
- };
412
-
413
- return next;
414
- }
415
-
416
- function appendDangerousPattern(
417
- config: GuardrailsConfig | null,
418
- pattern: DangerousPattern,
419
- ): GuardrailsConfig {
420
- const next = structuredClone(config ?? {}) as GuardrailsConfig;
421
- const currentPatterns = next.permissionGate?.patterns ?? [];
422
-
423
- const existingPatterns = new Set(currentPatterns.map((p) => p.pattern));
424
- if (existingPatterns.has(pattern.pattern)) {
425
- return next;
426
- }
427
-
428
- next.permissionGate = {
429
- ...(next.permissionGate ?? {}),
430
- patterns: [...currentPatterns, structuredClone(pattern)],
431
- };
432
-
433
- return next;
434
- }
435
-
436
- interface NewPolicyDraft {
437
- name: string;
438
- id: string;
439
- protection: PolicyRule["protection"];
440
- patterns: PatternConfig[];
441
- }
442
-
443
- class PolicyNameStep implements Component {
444
- private readonly input = new Input();
445
-
446
- constructor(
447
- private readonly theme: SettingsListTheme,
448
- private readonly state: NewPolicyDraft,
449
- private readonly onComplete: () => void,
450
- ) {
451
- this.input.setValue(state.name);
452
- this.input.onSubmit = () => {
453
- const name = this.input.getValue().trim();
454
- if (!name) return;
455
- this.state.name = name;
456
- if (!this.state.id) {
457
- this.state.id = toKebabCase(name) || "policy";
458
- }
459
- this.onComplete();
460
- };
461
- }
462
-
463
- invalidate() {}
464
-
465
- render(width: number): string[] {
466
- return [
467
- this.theme.hint(" Step 1: Policy name"),
468
- "",
469
- ...this.input.render(Math.max(1, width - 2)).map((line) => ` ${line}`),
470
- "",
471
- this.theme.hint(" Example: Secret files"),
472
- this.theme.hint(" Enter to continue"),
473
- ];
474
- }
475
-
476
- handleInput(data: string): void {
477
- this.input.handleInput(data);
478
- }
479
- }
480
-
481
- class PolicyProtectionStep implements Component {
482
- private readonly selector: FuzzySelector;
483
-
484
- constructor(
485
- theme: SettingsListTheme,
486
- state: NewPolicyDraft,
487
- onComplete: () => void,
488
- ) {
489
- this.selector = new FuzzySelector({
490
- label: "Protection",
491
- items: ["noAccess", "readOnly", "none"],
492
- currentValue: state.protection,
493
- theme,
494
- onSelect: (value) => {
495
- if (value === "noAccess" || value === "readOnly" || value === "none") {
496
- state.protection = value;
497
- onComplete();
498
- }
499
- },
500
- onDone: () => {
501
- // Esc is handled by Wizard.
502
- },
503
- });
504
- }
505
-
506
- invalidate(): void {
507
- this.selector.invalidate?.();
508
- }
509
-
510
- render(width: number): string[] {
511
- return this.selector.render(width);
512
- }
513
-
514
- handleInput(data: string): void {
515
- this.selector.handleInput(data);
516
- }
517
- }
518
-
519
- class PolicyPatternsStep implements Component {
520
- private readonly editor: PatternEditor;
521
-
522
- constructor(
523
- theme: SettingsListTheme,
524
- state: NewPolicyDraft,
525
- onComplete: () => void,
526
- ) {
527
- this.editor = new PatternEditor({
528
- label: "Policy patterns",
529
- context: "file",
530
- theme,
531
- items: state.patterns.map((p) => ({
532
- pattern: p.pattern,
533
- description: p.pattern,
534
- regex: p.regex,
535
- })),
536
- onSave: (items) => {
537
- state.patterns = items
538
- .map((item) => {
539
- const pattern = item.pattern.trim();
540
- if (!pattern) return null;
541
- return {
542
- pattern,
543
- ...(item.regex ? { regex: true } : {}),
544
- };
545
- })
546
- .filter((item): item is PatternConfig => item !== null);
547
- },
548
- onDone: () => {
549
- if (state.patterns.length > 0) {
550
- onComplete();
551
- }
552
- },
553
- });
554
- }
555
-
556
- invalidate(): void {
557
- this.editor.invalidate?.();
558
- }
559
-
560
- render(width: number): string[] {
561
- return this.editor.render(width);
562
- }
563
-
564
- handleInput(data: string): void {
565
- this.editor.handleInput(data);
566
- }
567
- }
568
-
569
- class PolicyReviewStep implements Component {
570
- constructor(
571
- private readonly theme: SettingsListTheme,
572
- private readonly state: NewPolicyDraft,
573
- ) {}
574
-
575
- invalidate() {}
576
-
577
- render(_width: number): string[] {
578
- const patternPreview =
579
- this.state.patterns.length > 0
580
- ? this.state.patterns
581
- .slice(0, 3)
582
- .map((p) => `${p.pattern}${p.regex ? " [regex]" : ""}`)
583
- .join(", ")
584
- : "(none)";
585
-
586
- return [
587
- this.theme.hint(" Review"),
588
- "",
589
- this.theme.hint(` Name: ${this.state.name || "(empty)"}`),
590
- this.theme.hint(` ID: ${this.state.id || "(auto)"}`),
591
- this.theme.hint(` Protection: ${this.state.protection}`),
592
- this.theme.hint(` Patterns: ${this.state.patterns.length}`),
593
- this.theme.hint(` ${patternPreview}`),
594
- "",
595
- this.theme.hint(" Ctrl+S: create + open editor · Esc: cancel"),
596
- ];
597
- }
598
-
599
- handleInput(_data: string): void {}
600
- }
601
-
602
- class AddRuleSubmenu implements Component {
603
- private readonly wizard: Wizard;
604
- private activeEditor: Component | null = null;
605
-
606
- constructor(
607
- theme: SettingsTheme,
608
- onCreate: (draft: NewPolicyDraft) => number | null,
609
- openEditor: (index: number, done: (value?: string) => void) => Component,
610
- onDone: (value?: string) => void,
611
- ) {
612
- const state: NewPolicyDraft = {
613
- name: "",
614
- id: "",
615
- protection: "readOnly",
616
- patterns: [],
617
- };
618
-
619
- this.wizard = new Wizard({
620
- title: "Add policy",
621
- theme,
622
- steps: [
623
- {
624
- label: "Name",
625
- build: (ctx) =>
626
- new PolicyNameStep(theme, state, () => {
627
- ctx.markComplete();
628
- ctx.goNext();
629
- }),
630
- },
631
- {
632
- label: "Protection",
633
- build: (ctx) =>
634
- new PolicyProtectionStep(theme, state, () => {
635
- ctx.markComplete();
636
- ctx.goNext();
637
- }),
638
- },
639
- {
640
- label: "Patterns",
641
- build: (ctx) =>
642
- new PolicyPatternsStep(theme, state, () => {
643
- if (state.patterns.length === 0) {
644
- ctx.markIncomplete();
645
- return;
646
- }
647
- ctx.markComplete();
648
- ctx.goNext();
649
- }),
650
- },
651
- {
652
- label: "Review",
653
- build: (ctx) => {
654
- ctx.markComplete();
655
- return new PolicyReviewStep(theme, state);
656
- },
657
- },
658
- ],
659
- onComplete: () => {
660
- if (!state.name.trim() || state.patterns.length === 0) return;
661
- const index = onCreate(state);
662
- if (index === null) return;
663
- this.activeEditor = openEditor(index, (value) => {
664
- this.activeEditor = null;
665
- onDone(value);
666
- });
667
- },
668
- onCancel: () => onDone(),
669
- hintSuffix: "complete steps · Ctrl+S create",
670
- minContentHeight: 12,
671
- });
672
- }
673
-
674
- invalidate(): void {
675
- this.activeEditor?.invalidate?.();
676
- this.wizard.invalidate?.();
677
- }
678
-
679
- render(width: number): string[] {
680
- if (this.activeEditor) {
681
- return this.activeEditor.render(width);
682
- }
683
- return this.wizard.render(width);
684
- }
685
-
686
- handleInput(data: string): void {
687
- if (this.activeEditor) {
688
- this.activeEditor.handleInput?.(data);
689
- return;
690
- }
691
- this.wizard.handleInput(data);
692
- }
693
- }
694
-
695
- class PathListEditor implements Component {
696
- private readonly input = new Input();
697
- private items: string[];
698
- private selectedIndex = 0;
699
- private mode: "list" | "add" | "edit" = "list";
700
- private editIndex = -1;
701
-
702
- constructor(
703
- private readonly options: {
704
- label: string;
705
- items: string[];
706
- theme: SettingsListTheme;
707
- onSave: (items: string[]) => void;
708
- onDone: () => void;
709
- maxVisible?: number;
710
- },
711
- ) {
712
- this.items = [...options.items];
713
- this.input.onSubmit = () => this.submit();
714
- this.input.onEscape = () => this.cancel();
715
- }
716
-
717
- invalidate() {}
718
-
719
- render(width: number): string[] {
720
- const lines = [
721
- this.options.theme.label(` ${this.options.label}`, true),
722
- "",
723
- ];
724
- if (this.mode === "add" || this.mode === "edit") {
725
- lines.push(
726
- this.options.theme.hint(
727
- this.mode === "edit" ? " Edit path:" : " New path:",
728
- ),
729
- "",
730
- ...this.input.render(Math.max(1, width - 4)).map((line) => ` ${line}`),
731
- "",
732
- this.options.theme.hint(" Enter: save · Esc: cancel"),
733
- );
734
- return lines;
735
- }
736
-
737
- if (this.items.length === 0) {
738
- lines.push(this.options.theme.hint(" (empty)"));
739
- } else {
740
- const maxVisible = this.options.maxVisible ?? 10;
741
- const startIndex = Math.max(
742
- 0,
743
- Math.min(
744
- this.selectedIndex - Math.floor(maxVisible / 2),
745
- this.items.length - maxVisible,
746
- ),
747
- );
748
- const endIndex = Math.min(startIndex + maxVisible, this.items.length);
749
- for (let i = startIndex; i < endIndex; i++) {
750
- const item = this.items[i];
751
- if (!item) continue;
752
- const isSelected = i === this.selectedIndex;
753
- const prefix = isSelected ? this.options.theme.cursor : " ";
754
- lines.push(prefix + this.options.theme.value(item, isSelected));
755
- }
756
- if (startIndex > 0 || endIndex < this.items.length) {
757
- lines.push(
758
- this.options.theme.hint(
759
- ` (${this.selectedIndex + 1}/${this.items.length})`,
760
- ),
761
- );
762
- }
763
- }
764
-
765
- lines.push("");
766
- lines.push(
767
- this.options.theme.hint(
768
- " a: add · e/Enter: edit · d: delete · Esc: back",
769
- ),
770
- );
771
- return lines;
772
- }
773
-
774
- handleInput(data: string): void {
775
- if (this.mode === "add" || this.mode === "edit") {
776
- this.input.handleInput(data);
777
- return;
778
- }
779
-
780
- if (matchesKey(data, Key.up) || data === "k") {
781
- if (this.items.length === 0) return;
782
- this.selectedIndex =
783
- this.selectedIndex === 0
784
- ? this.items.length - 1
785
- : this.selectedIndex - 1;
786
- } else if (matchesKey(data, Key.down) || data === "j") {
787
- if (this.items.length === 0) return;
788
- this.selectedIndex =
789
- this.selectedIndex === this.items.length - 1
790
- ? 0
791
- : this.selectedIndex + 1;
792
- } else if (data === "a" || data === "A") {
793
- this.mode = "add";
794
- this.input.setValue("");
795
- } else if (data === "e" || data === "E" || matchesKey(data, Key.enter)) {
796
- this.startEdit();
797
- } else if (data === "d" || data === "D") {
798
- this.deleteSelected();
799
- } else if (matchesKey(data, Key.escape)) {
800
- this.options.onDone();
801
- }
802
- }
803
-
804
- private startEdit(): void {
805
- const item = this.items[this.selectedIndex];
806
- if (!item) return;
807
- this.mode = "edit";
808
- this.editIndex = this.selectedIndex;
809
- this.input.setValue(item);
810
- }
811
-
812
- private submit(): void {
813
- const path = this.input.getValue().trim();
814
- if (!path) {
815
- this.cancel();
816
- return;
817
- }
818
-
819
- if (this.mode === "edit") {
820
- this.items[this.editIndex] = path;
821
- } else {
822
- this.items.push(path);
823
- this.selectedIndex = this.items.length - 1;
824
- }
825
- this.items = [...new Set(this.items)];
826
- this.options.onSave([...this.items]);
827
- this.cancel();
828
- }
829
-
830
- private deleteSelected(): void {
831
- if (this.items.length === 0) return;
832
- this.items.splice(this.selectedIndex, 1);
833
- if (this.selectedIndex >= this.items.length) {
834
- this.selectedIndex = Math.max(0, this.items.length - 1);
835
- }
836
- this.options.onSave([...this.items]);
837
- }
838
-
839
- private cancel(): void {
840
- this.mode = "list";
841
- this.editIndex = -1;
842
- this.input.setValue("");
843
- }
844
- }
845
-
846
- class ScopePickerSubmenu implements Component {
847
- private selectedIndex = 0;
848
-
849
- constructor(
850
- private readonly theme: SettingsListTheme,
851
- private readonly scopes: Array<"global" | "local" | "memory">,
852
- private readonly onSelect: (scope: "global" | "local" | "memory") => void,
853
- private readonly onDone: (value?: string) => void,
854
- ) {}
855
-
856
- invalidate() {}
857
-
858
- render(_width: number): string[] {
859
- const lines: string[] = [
860
- this.theme.label(" Add example to scope", true),
861
- "",
862
- this.theme.hint(" Select target scope:"),
863
- ];
864
-
865
- for (let i = 0; i < this.scopes.length; i++) {
866
- const scope = this.scopes[i];
867
- if (!scope) continue;
868
- const isSelected = i === this.selectedIndex;
869
- const prefix = isSelected ? this.theme.cursor : " ";
870
- lines.push(`${prefix}${this.theme.value(scope, isSelected)}`);
871
- }
872
-
873
- lines.push("");
874
- lines.push(this.theme.hint(" Enter: apply · Esc: back"));
875
- return lines;
876
- }
877
-
878
- handleInput(data: string): void {
879
- if (matchesKey(data, Key.up) || data === "k") {
880
- this.selectedIndex =
881
- this.selectedIndex === 0
882
- ? this.scopes.length - 1
883
- : this.selectedIndex - 1;
884
- return;
885
- }
886
-
887
- if (matchesKey(data, Key.down) || data === "j") {
888
- this.selectedIndex =
889
- this.selectedIndex === this.scopes.length - 1
890
- ? 0
891
- : this.selectedIndex + 1;
892
- return;
893
- }
894
-
895
- if (matchesKey(data, Key.enter)) {
896
- const scope = this.scopes[this.selectedIndex];
897
- if (!scope) return;
898
- this.onSelect(scope);
899
- this.onDone(`applied to ${scope}`);
900
- return;
901
- }
902
-
903
- if (matchesKey(data, Key.escape)) {
904
- this.onDone();
905
- }
906
- }
907
- }
908
-
909
- function createPolicyRuleEditor(options: {
910
- index: number;
911
- theme: SettingsListTheme;
912
- getRule: () => PolicyRule | undefined;
913
- updateRule: (updater: (rule: PolicyRule) => PolicyRule) => void;
914
- deleteRule: () => void;
915
- onDone: (value?: string) => void;
916
- }): SettingsDetailEditor {
917
- const { index, theme, getRule, updateRule, deleteRule, onDone } = options;
918
-
919
- const fields: SettingsDetailField[] = [
920
- {
921
- id: "name",
922
- type: "text",
923
- label: "Name",
924
- description: "Display name shown in settings",
925
- getValue: () => getRule()?.name?.trim() || "",
926
- setValue: (value) => {
927
- const next = value.trim();
928
- updateRule((rule) => ({ ...rule, name: next || undefined }));
929
- },
930
- emptyValueText: "(uses id)",
931
- },
932
- {
933
- id: "id",
934
- type: "text",
935
- label: "ID",
936
- description: "Stable identifier used for overrides across scopes",
937
- getValue: () => getRule()?.id ?? "",
938
- setValue: (value) => {
939
- const next = value.trim();
940
- if (!next) return;
941
- updateRule((rule) => ({ ...rule, id: next }));
942
- },
943
- },
944
- {
945
- id: "description",
946
- type: "text",
947
- label: "Description",
948
- description: "Human-readable explanation",
949
- getValue: () => getRule()?.description?.trim() || "",
950
- setValue: (value) => {
951
- const next = value.trim();
952
- updateRule((rule) => ({ ...rule, description: next || undefined }));
953
- },
954
- emptyValueText: "(empty)",
955
- },
956
- {
957
- id: "protection",
958
- type: "enum",
959
- label: "Protection",
960
- description: "noAccess | readOnly | none",
961
- getValue: () => getRule()?.protection ?? "readOnly",
962
- setValue: (value) => {
963
- if (value !== "noAccess" && value !== "readOnly" && value !== "none") {
964
- return;
965
- }
966
- updateRule((rule) => ({ ...rule, protection: value }));
967
- },
968
- options: ["noAccess", "readOnly", "none"],
969
- },
970
- {
971
- id: "enabled",
972
- type: "boolean",
973
- label: "Enabled",
974
- description: "Turn this policy on/off",
975
- getValue: () => getRule()?.enabled !== false,
976
- setValue: (value) => {
977
- updateRule((rule) => ({ ...rule, enabled: value }));
978
- },
979
- trueLabel: "on",
980
- falseLabel: "off",
981
- },
982
- {
983
- id: "onlyIfExists",
984
- type: "boolean",
985
- label: "Only if exists",
986
- description: "Only block when file exists on disk",
987
- getValue: () => getRule()?.onlyIfExists !== false,
988
- setValue: (value) => {
989
- updateRule((rule) => ({ ...rule, onlyIfExists: value }));
990
- },
991
- trueLabel: "on",
992
- falseLabel: "off",
993
- },
994
- {
995
- id: "patterns",
996
- type: "submenu",
997
- label: "Patterns",
998
- description: "Files protected by this policy",
999
- getValue: () => `${getRule()?.patterns?.length ?? 0} items`,
1000
- submenu: (done) => {
1001
- const rule = getRule();
1002
- const items = (rule?.patterns ?? []).map((p) => ({
1003
- pattern: p.pattern,
1004
- description: p.pattern,
1005
- regex: p.regex,
1006
- }));
1007
-
1008
- return new PatternEditor({
1009
- label: "Policy patterns",
1010
- items,
1011
- theme,
1012
- context: "file",
1013
- onSave: (newItems) => {
1014
- const patterns: PatternConfig[] = newItems
1015
- .map((p) => {
1016
- const pattern = p.pattern.trim();
1017
- if (!pattern) return null;
1018
- return { pattern, ...(p.regex ? { regex: true } : {}) };
1019
- })
1020
- .filter((item): item is PatternConfig => item !== null);
1021
-
1022
- updateRule((current) => ({ ...current, patterns }));
1023
- },
1024
- onDone: () => done(`${getRule()?.patterns?.length ?? 0} items`),
1025
- });
1026
- },
1027
- },
1028
- {
1029
- id: "allowedPatterns",
1030
- type: "submenu",
1031
- label: "Allowed patterns",
1032
- description: "Exceptions",
1033
- getValue: () => `${getRule()?.allowedPatterns?.length ?? 0} items`,
1034
- submenu: (done) => {
1035
- const rule = getRule();
1036
- const items = (rule?.allowedPatterns ?? []).map((p) => ({
1037
- pattern: p.pattern,
1038
- description: p.pattern,
1039
- regex: p.regex,
1040
- }));
1041
-
1042
- return new PatternEditor({
1043
- label: "Policy allowed patterns",
1044
- items,
1045
- theme,
1046
- context: "file",
1047
- onSave: (newItems) => {
1048
- const patterns: PatternConfig[] = newItems
1049
- .map((p) => {
1050
- const pattern = p.pattern.trim();
1051
- if (!pattern) return null;
1052
- return { pattern, ...(p.regex ? { regex: true } : {}) };
1053
- })
1054
- .filter((item): item is PatternConfig => item !== null);
1055
-
1056
- updateRule((current) => ({
1057
- ...current,
1058
- allowedPatterns: patterns.length > 0 ? patterns : undefined,
1059
- }));
1060
- },
1061
- onDone: () =>
1062
- done(`${getRule()?.allowedPatterns?.length ?? 0} items`),
1063
- });
1064
- },
1065
- },
1066
- {
1067
- id: "blockMessage",
1068
- type: "text",
1069
- label: "Block message",
1070
- description: "Custom block message ({file} supported)",
1071
- getValue: () => getRule()?.blockMessage?.trim() || "",
1072
- setValue: (value) => {
1073
- const next = value.trim();
1074
- updateRule((rule) => ({ ...rule, blockMessage: next || undefined }));
1075
- },
1076
- emptyValueText: "(default)",
1077
- },
1078
- {
1079
- id: "delete",
1080
- type: "action",
1081
- label: "Delete rule",
1082
- description: "Remove this rule",
1083
- getValue: () => "danger",
1084
- onConfirm: () => {
1085
- deleteRule();
1086
- },
1087
- confirmMessage: "Delete this rule? This cannot be undone.",
1088
- },
1089
- ];
1090
-
1091
- return new SettingsDetailEditor({
1092
- title: () => {
1093
- const rule = getRule();
1094
- const title = rule?.name?.trim() || rule?.id || `Policy ${index + 1}`;
1095
- return `Policy: ${title}`;
1096
- },
1097
- fields,
1098
- theme,
1099
- onDone,
1100
- getDoneSummary: () => {
1101
- const rule = getRule();
1102
- if (!rule) return "deleted";
1103
- return `${rule.protection}, ${rule.enabled === false ? "disabled" : "enabled"}`;
1104
- },
1105
- });
1106
- }
1107
-
1108
- export function registerGuardrailsSettings(pi: ExtensionAPI): void {
1109
- registerSettingsCommand<GuardrailsConfig, ResolvedConfig>(pi, {
1110
- commandName: "guardrails:settings",
1111
- title: "Guardrails Settings",
1112
- configStore: configLoader,
1113
- buildSections: (
1114
- tabConfig: GuardrailsConfig | null,
1115
- _resolved: ResolvedConfig,
1116
- { setDraft, theme, scope },
1117
- ): SettingsSection[] => {
1118
- const settingsTheme = theme;
1119
- let scopedConfig = structuredClone(tabConfig ?? {}) as GuardrailsConfig;
1120
-
1121
- function commitDraft(next: GuardrailsConfig): void {
1122
- scopedConfig = next;
1123
- setDraft(structuredClone(next));
1124
- }
1125
-
1126
- function count(id: string): string {
1127
- const val =
1128
- (getNestedValue(scopedConfig, id) as unknown[] | undefined) ?? [];
1129
- return `${val.length} items`;
1130
- }
1131
-
1132
- function applyDraft(id: string, value: unknown): void {
1133
- const updated = structuredClone(scopedConfig);
1134
- setNestedValue(updated, id, value);
1135
- commitDraft(updated);
1136
- }
1137
-
1138
- function getPolicyRules(): PolicyRule[] {
1139
- return scopedConfig.policies?.rules?.map((r) => ({ ...r })) ?? [];
1140
- }
1141
-
1142
- function setPolicyRules(rules: PolicyRule[]): void {
1143
- const updated = structuredClone(scopedConfig);
1144
- updated.policies = {
1145
- ...(updated.policies ?? {}),
1146
- rules,
1147
- };
1148
- commitDraft(updated);
1149
- }
1150
-
1151
- function updateRule(
1152
- index: number,
1153
- updater: (rule: PolicyRule) => PolicyRule,
1154
- ): void {
1155
- const rules = getPolicyRules();
1156
- const existing = rules[index];
1157
- if (!existing) return;
1158
- rules[index] = updater(existing);
1159
- setPolicyRules(rules);
1160
- }
1161
-
1162
- function deleteRule(index: number): void {
1163
- const rules = getPolicyRules();
1164
- if (!rules[index]) return;
1165
- rules.splice(index, 1);
1166
- setPolicyRules(rules);
1167
- }
1168
-
1169
- function addRule(draft: NewPolicyDraft): number | null {
1170
- const normalizedName = draft.name.trim();
1171
- if (!normalizedName || draft.patterns.length === 0) return null;
1172
-
1173
- const rules = getPolicyRules();
1174
- const baseId = toKebabCase(draft.id || normalizedName) || "policy";
1175
- const existingIds = new Set(rules.map((rule) => rule.id));
1176
-
1177
- let id = baseId;
1178
- let i = 2;
1179
- while (existingIds.has(id)) {
1180
- id = `${baseId}-${i}`;
1181
- i++;
1182
- }
1183
-
1184
- rules.push({
1185
- id,
1186
- name: normalizedName,
1187
- description: "",
1188
- patterns: draft.patterns,
1189
- protection: draft.protection,
1190
- onlyIfExists: true,
1191
- enabled: true,
1192
- });
1193
- setPolicyRules(rules);
1194
- return rules.length - 1;
1195
- }
1196
-
1197
- function patternSubmenu(
1198
- id: string,
1199
- label: string,
1200
- context?: "file" | "command",
1201
- ) {
1202
- return (_val: string, submenuDone: (v?: string) => void) => {
1203
- const items =
1204
- (getNestedValue(scopedConfig, id) as
1205
- | DangerousPattern[]
1206
- | undefined) ?? [];
1207
- let latestCount = items.length;
1208
- return new PatternEditor({
1209
- label,
1210
- items: [...items],
1211
- theme: settingsTheme,
1212
- context,
1213
- onSave: (newItems) => {
1214
- latestCount = newItems.length;
1215
- applyDraft(id, newItems);
1216
- },
1217
- onDone: () => submenuDone(`${latestCount} items`),
1218
- });
1219
- };
1220
- }
1221
-
1222
- function pathListSubmenu(id: string, label: string) {
1223
- return (_val: string, submenuDone: (v?: string) => void) => {
1224
- const items = normalizeAllowedPaths(getNestedValue(scopedConfig, id));
1225
- let latestCount = items.length;
1226
- return new PathListEditor({
1227
- label,
1228
- items,
1229
- theme: settingsTheme,
1230
- onSave: (newItems) => {
1231
- latestCount = newItems.length;
1232
- applyDraft(id, newItems);
1233
- },
1234
- onDone: () => submenuDone(`${latestCount} items`),
1235
- });
1236
- };
1237
- }
1238
-
1239
- function patternConfigSubmenu(
1240
- id: string,
1241
- label: string,
1242
- context?: "file" | "command",
1243
- ) {
1244
- return (_val: string, submenuDone: (v?: string) => void) => {
1245
- const currentItems =
1246
- (getNestedValue(scopedConfig, id) as PatternConfig[] | undefined) ??
1247
- [];
1248
- const items = currentItems.map((p) => ({
1249
- pattern: p.pattern,
1250
- description: p.pattern,
1251
- regex: p.regex,
1252
- }));
1253
- let latestCount = items.length;
1254
- return new PatternEditor({
1255
- label,
1256
- items,
1257
- theme: settingsTheme,
1258
- context,
1259
- onSave: (newItems) => {
1260
- latestCount = newItems.length;
1261
- const configs: PatternConfig[] = newItems
1262
- .map((p) => {
1263
- const pattern = p.pattern.trim();
1264
- if (!pattern) return null;
1265
- const cfg: PatternConfig = { pattern };
1266
- if (p.regex) cfg.regex = true;
1267
- return cfg;
1268
- })
1269
- .filter((item): item is PatternConfig => item !== null);
1270
- applyDraft(id, configs);
1271
- },
1272
- onDone: () => submenuDone(`${latestCount} items`),
1273
- });
1274
- };
1275
- }
1276
-
1277
- function hasExplainModelOverride(): boolean {
1278
- return scopedConfig.permissionGate?.explainModel !== undefined;
1279
- }
1280
-
1281
- function getExplainModel(): string {
1282
- return scopedConfig.permissionGate?.explainModel?.trim() ?? "";
1283
- }
1284
-
1285
- function hasExplainTimeoutOverride(): boolean {
1286
- return scopedConfig.permissionGate?.explainTimeout !== undefined;
1287
- }
1288
-
1289
- function getExplainTimeout(): number | null {
1290
- return scopedConfig.permissionGate?.explainTimeout ?? null;
1291
- }
1292
-
1293
- const featureItems: SettingItem[] = (
1294
- Object.keys(FEATURE_UI) as FeatureKey[]
1295
- )
1296
- .filter((key) => key !== "policies")
1297
- .map((key): SettingItem => {
1298
- const scopedValue = scopedConfig.features?.[key];
1299
- return {
1300
- id: `features.${key}`,
1301
- label: FEATURE_UI[key].label,
1302
- description: FEATURE_UI[key].description,
1303
- currentValue:
1304
- scopedValue === undefined
1305
- ? "(inherited)"
1306
- : scopedValue
1307
- ? "enabled"
1308
- : "disabled",
1309
- values: ["enabled", "disabled"],
1310
- };
1311
- });
1312
-
1313
- if (scope === "global") {
1314
- featureItems.push({
1315
- id: "onboarding.run",
1316
- label: "Onboarding status",
1317
- description: "Use /guardrails:onboarding to run onboarding",
1318
- currentValue:
1319
- scopedConfig.onboarding?.completed === true
1320
- ? "completed"
1321
- : "pending",
1322
- });
1323
- }
1324
-
1325
- const policyRules = getPolicyRules();
1326
-
1327
- const openPolicyEditor = (
1328
- index: number,
1329
- submenuDone: (v?: string) => void,
1330
- ): Component =>
1331
- createPolicyRuleEditor({
1332
- index,
1333
- theme: settingsTheme,
1334
- getRule: () => getPolicyRules()[index],
1335
- updateRule: (updater) => updateRule(index, updater),
1336
- deleteRule: () => deleteRule(index),
1337
- onDone: submenuDone,
1338
- });
1339
-
1340
- const policyItems: SettingItem[] = [
1341
- {
1342
- id: "features.policies",
1343
- label: " Enabled",
1344
- description: FEATURE_UI.policies.description,
1345
- currentValue:
1346
- scopedConfig.features?.policies === undefined
1347
- ? "(inherited)"
1348
- : scopedConfig.features.policies
1349
- ? "enabled"
1350
- : "disabled",
1351
- values: ["enabled", "disabled"],
1352
- },
1353
- ...policyRules.map((rule, index) => {
1354
- const label = rule.name?.trim() || rule.id || `Policy ${index + 1}`;
1355
- return {
1356
- id: `policies.rules.${index}`,
1357
- label: ` ${label}`,
1358
- description: rule.description?.trim() || "No description",
1359
- currentValue: `${rule.protection}, ${rule.enabled === false ? "disabled" : "enabled"}`,
1360
- submenu: (_val: string, submenuDone: (v?: string) => void) =>
1361
- openPolicyEditor(index, submenuDone),
1362
- };
1363
- }),
1364
- ];
1365
-
1366
- policyItems.push({
1367
- id: "policies.addRule",
1368
- label: " + Add policy",
1369
- description: "Open wizard to create policy",
1370
- currentValue: "",
1371
- submenu: (_val: string, submenuDone: (v?: string) => void) =>
1372
- new AddRuleSubmenu(
1373
- settingsTheme,
1374
- addRule,
1375
- (index, done) => openPolicyEditor(index, done),
1376
- submenuDone,
1377
- ),
1378
- });
1379
-
1380
- return [
1381
- { label: "Features", items: featureItems },
1382
- {
1383
- label: `Policies (${policyRules.length})`,
1384
- items: policyItems,
1385
- },
1386
- {
1387
- label: "Path Access",
1388
- items: [
1389
- {
1390
- id: "pathAccess.mode",
1391
- label: "Mode",
1392
- description:
1393
- "allow: no restrictions, ask: prompt for outside paths, block: deny all outside paths",
1394
- currentValue: scopedConfig.pathAccess?.mode ?? "(inherited)",
1395
- values: ["allow", "ask", "block"],
1396
- },
1397
- {
1398
- id: "pathAccess.allowedPaths",
1399
- label: "Allowed paths",
1400
- description:
1401
- "Paths always allowed (trailing / for directories). Supports ~/",
1402
- currentValue: count("pathAccess.allowedPaths"),
1403
- submenu: pathListSubmenu(
1404
- "pathAccess.allowedPaths",
1405
- "Allowed Paths",
1406
- ),
1407
- },
1408
- ],
1409
- },
1410
- {
1411
- label: "Permission Gate",
1412
- items: [
1413
- {
1414
- id: "permissionGate.requireConfirmation",
1415
- label: "Require confirmation",
1416
- description:
1417
- "Show confirmation dialog for dangerous commands (if off, just warns)",
1418
- currentValue:
1419
- scopedConfig.permissionGate?.requireConfirmation === undefined
1420
- ? "(inherited)"
1421
- : scopedConfig.permissionGate.requireConfirmation
1422
- ? "on"
1423
- : "off",
1424
- values: ["on", "off"],
1425
- },
1426
- {
1427
- id: "permissionGate.patterns",
1428
- label: "Dangerous patterns",
1429
- description: "Command patterns that trigger the permission gate",
1430
- currentValue: count("permissionGate.patterns"),
1431
- submenu: patternSubmenu(
1432
- "permissionGate.patterns",
1433
- "Dangerous Patterns",
1434
- "command",
1435
- ),
1436
- },
1437
- {
1438
- id: "permissionGate.allowedPatterns",
1439
- label: "Allowed commands",
1440
- description: "Patterns that bypass the permission gate entirely",
1441
- currentValue: count("permissionGate.allowedPatterns"),
1442
- submenu: patternConfigSubmenu(
1443
- "permissionGate.allowedPatterns",
1444
- "Allowed Commands",
1445
- "command",
1446
- ),
1447
- },
1448
- {
1449
- id: "permissionGate.autoDenyPatterns",
1450
- label: "Auto-deny patterns",
1451
- description:
1452
- "Patterns that block commands immediately without dialog",
1453
- currentValue: count("permissionGate.autoDenyPatterns"),
1454
- submenu: patternConfigSubmenu(
1455
- "permissionGate.autoDenyPatterns",
1456
- "Auto-Deny Patterns",
1457
- "command",
1458
- ),
1459
- },
1460
- {
1461
- id: "permissionGate.explainCommands",
1462
- label: "Explain commands",
1463
- description:
1464
- "Call an LLM to explain dangerous commands in the confirmation dialog",
1465
- currentValue:
1466
- scopedConfig.permissionGate?.explainCommands === undefined
1467
- ? "(inherited)"
1468
- : scopedConfig.permissionGate.explainCommands
1469
- ? "on"
1470
- : "off",
1471
- values: ["on", "off"],
1472
- },
1473
- {
1474
- id: "permissionGate.explainModel",
1475
- label: "Explain model",
1476
- description: "Model spec in provider/model-id format",
1477
- currentValue: hasExplainModelOverride()
1478
- ? getExplainModel() || "(not set)"
1479
- : "(inherited)",
1480
- submenu: (_val: string, submenuDone: (v?: string) => void) =>
1481
- new SettingsDetailEditor({
1482
- title: "Explain Commands: Model",
1483
- theme: settingsTheme,
1484
- onDone: submenuDone,
1485
- getDoneSummary: () => getExplainModel() || "(not set)",
1486
- fields: [
1487
- {
1488
- id: "permissionGate.explainModel",
1489
- type: "text",
1490
- label: "Model",
1491
- description: "Format: provider/model-id",
1492
- getValue: getExplainModel,
1493
- setValue: (value) => {
1494
- const model = value.trim();
1495
- applyDraft(
1496
- "permissionGate.explainModel",
1497
- model || undefined,
1498
- );
1499
- },
1500
- emptyValueText: "(not set)",
1501
- },
1502
- ],
1503
- }),
1504
- },
1505
- {
1506
- id: "permissionGate.explainTimeout",
1507
- label: "Explain timeout",
1508
- description: "Timeout for LLM explanation in milliseconds",
1509
- currentValue: hasExplainTimeoutOverride()
1510
- ? `${getExplainTimeout()}ms`
1511
- : "(inherited)",
1512
- submenu: (_val: string, submenuDone: (v?: string) => void) =>
1513
- new SettingsDetailEditor({
1514
- title: "Explain Commands: Timeout",
1515
- theme: settingsTheme,
1516
- onDone: submenuDone,
1517
- getDoneSummary: () => {
1518
- const timeout = getExplainTimeout();
1519
- return timeout === null ? "(not set)" : `${timeout}ms`;
1520
- },
1521
- fields: [
1522
- {
1523
- id: "permissionGate.explainTimeout",
1524
- type: "text",
1525
- label: "Timeout (ms)",
1526
- description: "Abort explanation call after this many ms",
1527
- getValue: () => {
1528
- const timeout = getExplainTimeout();
1529
- return timeout === null ? "" : String(timeout);
1530
- },
1531
- setValue: (value) => {
1532
- const parsed = Number.parseInt(value.trim(), 10);
1533
- if (Number.isNaN(parsed) || parsed < 1) return;
1534
- applyDraft("permissionGate.explainTimeout", parsed);
1535
- },
1536
- },
1537
- ],
1538
- }),
1539
- },
1540
- ],
1541
- },
1542
- ];
1543
- },
1544
- extraTabs: [
1545
- {
1546
- id: "examples",
1547
- label: "Examples",
1548
- buildSections: ({
1549
- enabledScopes,
1550
- getDraftForScope,
1551
- getRawForScope,
1552
- setDraftForScope,
1553
- theme,
1554
- }): SettingsSection[] => {
1555
- const policyItems: SettingItem[] = POLICY_EXAMPLES.map((example) => ({
1556
- id: `examples.${example.rule.id}`,
1557
- label: ` ${example.label}`,
1558
- description: example.description,
1559
- currentValue: "apply",
1560
- submenu: (_val: string, submenuDone: (v?: string) => void) =>
1561
- new ScopePickerSubmenu(
1562
- theme,
1563
- enabledScopes,
1564
- (targetScope) => {
1565
- const baseConfig =
1566
- getDraftForScope(targetScope) ??
1567
- getRawForScope(targetScope) ??
1568
- null;
1569
- const updated = appendPolicyRule(baseConfig, example.rule);
1570
- setDraftForScope(targetScope, updated);
1571
- },
1572
- submenuDone,
1573
- ),
1574
- }));
1575
-
1576
- const commandItems: SettingItem[] = COMMAND_EXAMPLES.map(
1577
- (example) => ({
1578
- id: `examples.cmd.${example.pattern.pattern}`,
1579
- label: ` ${example.label}`,
1580
- description: example.description,
1581
- currentValue: "add",
1582
- submenu: (_val: string, submenuDone: (v?: string) => void) =>
1583
- new ScopePickerSubmenu(
1584
- theme,
1585
- enabledScopes,
1586
- (targetScope) => {
1587
- const baseConfig =
1588
- getDraftForScope(targetScope) ??
1589
- getRawForScope(targetScope) ??
1590
- null;
1591
- const updated = appendDangerousPattern(
1592
- baseConfig,
1593
- example.pattern,
1594
- );
1595
- setDraftForScope(targetScope, updated);
1596
- },
1597
- submenuDone,
1598
- ),
1599
- }),
1600
- );
1601
-
1602
- return [
1603
- {
1604
- label: "File policy presets",
1605
- items: policyItems,
1606
- },
1607
- {
1608
- label: "Dangerous command presets",
1609
- items: commandItems,
1610
- },
1611
- ];
1612
- },
1613
- },
1614
- ],
1615
- });
1616
- }