@aliou/pi-guardrails 0.11.0 → 0.11.1

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.11.0",
3
+ "version": "0.11.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -27,6 +27,7 @@ import type {
27
27
  ResolvedConfig,
28
28
  } from "../config";
29
29
  import { configLoader } from "../config";
30
+ import { normalizeAllowedPaths } from "../utils/migration";
30
31
 
31
32
  type FeatureKey = keyof ResolvedConfig["features"];
32
33
 
@@ -691,6 +692,157 @@ class AddRuleSubmenu implements Component {
691
692
  }
692
693
  }
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
+
694
846
  class ScopePickerSubmenu implements Component {
695
847
  private selectedIndex = 0;
696
848
 
@@ -1067,6 +1219,23 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
1067
1219
  };
1068
1220
  }
1069
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
+
1070
1239
  function patternConfigSubmenu(
1071
1240
  id: string,
1072
1241
  label: string,
@@ -1231,10 +1400,9 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
1231
1400
  description:
1232
1401
  "Paths always allowed (trailing / for directories). Supports ~/",
1233
1402
  currentValue: count("pathAccess.allowedPaths"),
1234
- submenu: patternConfigSubmenu(
1403
+ submenu: pathListSubmenu(
1235
1404
  "pathAccess.allowedPaths",
1236
1405
  "Allowed Paths",
1237
- "file",
1238
1406
  ),
1239
1407
  },
1240
1408
  ],
package/src/config.ts CHANGED
@@ -137,10 +137,13 @@ import { ConfigLoader, type Migration } from "@aliou/pi-utils-settings";
137
137
  import {
138
138
  backupConfig,
139
139
  CURRENT_VERSION,
140
+ migrateAllowedPaths,
140
141
  migrateEnvFilesToPolicies,
141
142
  migrateV0,
143
+ needsAllowedPathsMigration,
142
144
  needsEnvFilesToPoliciesMigration,
143
145
  needsMigration,
146
+ normalizeAllowedPaths,
144
147
  } from "./utils/migration";
145
148
  import { pendingWarnings } from "./utils/warnings";
146
149
 
@@ -204,6 +207,11 @@ const migrations: Migration<GuardrailsConfig>[] = [
204
207
  shouldRun: (config) => needsEnvFilesToPoliciesMigration(config),
205
208
  run: (config) => migrateEnvFilesToPolicies(config),
206
209
  },
210
+ {
211
+ name: "normalize-allowed-paths",
212
+ shouldRun: (config) => needsAllowedPathsMigration(config),
213
+ run: (config) => migrateAllowedPaths(config),
214
+ },
207
215
  ];
208
216
 
209
217
  const DEFAULT_CONFIG: ResolvedConfig = {
@@ -374,9 +382,7 @@ export const configLoader = new ConfigLoader<GuardrailsConfig, ResolvedConfig>(
374
382
  local?.pathAccess?.allowedPaths,
375
383
  memory?.pathAccess?.allowedPaths,
376
384
  ]) {
377
- if (paths) {
378
- for (const p of paths) mergedPaths.add(p);
379
- }
385
+ for (const p of normalizeAllowedPaths(paths)) mergedPaths.add(p);
380
386
  }
381
387
  resolved.pathAccess.allowedPaths = [...mergedPaths];
382
388
 
@@ -12,6 +12,7 @@ import {
12
12
  import { configLoader } from "../config";
13
13
  import { extractBashPathCandidates } from "../utils/bash-paths";
14
14
  import { emitBlocked } from "../utils/events";
15
+ import { normalizeAllowedPaths } from "../utils/migration";
15
16
  import {
16
17
  normalizeForDisplay,
17
18
  resolveFromCwd,
@@ -246,9 +247,7 @@ async function persistGrant(
246
247
  unknown
247
248
  >;
248
249
  const pa = (raw.pathAccess ?? {}) as Record<string, unknown>;
249
- const existing = Array.isArray(pa.allowedPaths)
250
- ? (pa.allowedPaths as string[])
251
- : [];
250
+ const existing = normalizeAllowedPaths(pa.allowedPaths);
252
251
 
253
252
  if (existing.includes(storagePath)) return;
254
253
 
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { GuardrailsConfig } from "../config";
3
+ import {
4
+ migrateAllowedPaths,
5
+ needsAllowedPathsMigration,
6
+ normalizeAllowedPaths,
7
+ } from "./migration";
8
+
9
+ describe("allowedPaths migration", () => {
10
+ it("normalizes strings and legacy pattern objects", () => {
11
+ expect(
12
+ normalizeAllowedPaths([
13
+ "/dev/null",
14
+ { pattern: "~/Downloads/" },
15
+ { pattern: " /tmp/file " },
16
+ { pattern: "" },
17
+ { regex: true },
18
+ 42,
19
+ null,
20
+ "/dev/null",
21
+ ]),
22
+ ).toEqual(["/dev/null", "~/Downloads/", "/tmp/file"]);
23
+ });
24
+
25
+ it("detects legacy object-shaped allowed paths", () => {
26
+ const config = {
27
+ pathAccess: {
28
+ allowedPaths: [{ pattern: "/dev/null" }],
29
+ },
30
+ } as unknown as GuardrailsConfig;
31
+
32
+ expect(needsAllowedPathsMigration(config)).toBe(true);
33
+ });
34
+
35
+ it("does not migrate valid string allowed paths", () => {
36
+ const config: GuardrailsConfig = {
37
+ pathAccess: {
38
+ allowedPaths: ["/dev/null"],
39
+ },
40
+ };
41
+
42
+ expect(needsAllowedPathsMigration(config)).toBe(false);
43
+ });
44
+
45
+ it("converts legacy object-shaped allowed paths to strings", () => {
46
+ const config = {
47
+ pathAccess: {
48
+ mode: "block",
49
+ allowedPaths: [{ pattern: "/dev/null" }, "~/Downloads/"],
50
+ },
51
+ } as unknown as GuardrailsConfig;
52
+
53
+ expect(migrateAllowedPaths(config).pathAccess?.allowedPaths).toEqual([
54
+ "/dev/null",
55
+ "~/Downloads/",
56
+ ]);
57
+ });
58
+ });
@@ -151,6 +151,51 @@ export function migrateMarkOnboardingDone(
151
151
  return migrated;
152
152
  }
153
153
 
154
+ /**
155
+ * Migrate allowedPaths entries accidentally written as PatternConfig objects.
156
+ */
157
+ export function needsAllowedPathsMigration(config: GuardrailsConfig): boolean {
158
+ const raw = config as Record<string, unknown>;
159
+ const pathAccess = raw.pathAccess as Record<string, unknown> | undefined;
160
+ if (!Array.isArray(pathAccess?.allowedPaths)) return false;
161
+ return pathAccess.allowedPaths.some((item) => typeof item !== "string");
162
+ }
163
+
164
+ export function normalizeAllowedPaths(items: unknown): string[] {
165
+ if (!Array.isArray(items)) return [];
166
+
167
+ const paths = new Set<string>();
168
+ for (const item of items) {
169
+ let path: string | null = null;
170
+ if (typeof item === "string") {
171
+ path = item;
172
+ } else if (typeof item === "object" && item !== null) {
173
+ const pattern = (item as Record<string, unknown>).pattern;
174
+ if (typeof pattern === "string") path = pattern;
175
+ }
176
+
177
+ const normalized = path?.trim();
178
+ if (normalized) paths.add(normalized);
179
+ }
180
+
181
+ return [...paths];
182
+ }
183
+
184
+ export function migrateAllowedPaths(
185
+ config: GuardrailsConfig,
186
+ ): GuardrailsConfig {
187
+ const migrated = structuredClone(config) as Record<string, unknown>;
188
+ const pathAccess = migrated.pathAccess as Record<string, unknown> | undefined;
189
+ if (!pathAccess) return migrated as GuardrailsConfig;
190
+
191
+ pathAccess.allowedPaths = normalizeAllowedPaths(pathAccess.allowedPaths);
192
+ migrated.version = CURRENT_VERSION;
193
+ pendingWarnings.push(
194
+ "[guardrails] pathAccess.allowedPaths was migrated from pattern objects to path strings.",
195
+ );
196
+ return migrated as GuardrailsConfig;
197
+ }
198
+
154
199
  /**
155
200
  * Migrate deprecated envFiles/protectEnvFiles fields to policies.
156
201
  */