@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 +1 -1
- package/src/commands/settings-command.ts +170 -2
- package/src/config.ts +9 -3
- package/src/hooks/path-access.ts +2 -3
- package/src/utils/migration.test.ts +58 -0
- package/src/utils/migration.ts +45 -0
package/package.json
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
package/src/hooks/path-access.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
});
|
package/src/utils/migration.ts
CHANGED
|
@@ -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
|
*/
|