@aliou/pi-guardrails 0.8.0 → 0.9.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/README.md +1 -1
- package/package.json +2 -2
- package/src/commands/settings-command.ts +498 -98
- package/src/components/pattern-editor.ts +9 -7
- package/src/hooks/policies.ts +29 -10
- package/src/utils/matching.ts +27 -39
package/README.md
CHANGED
|
@@ -93,7 +93,7 @@ All fields optional. Missing fields use defaults.
|
|
|
93
93
|
Each rule has:
|
|
94
94
|
|
|
95
95
|
- `id`: stable identifier used for overrides across scopes.
|
|
96
|
-
- `patterns`: files to match (glob by default, regex if `regex: true`).
|
|
96
|
+
- `patterns`: files to match (glob by default, regex if `regex: true`). Glob semantics: patterns containing `/` match the full relative path; patterns without `/` match basename only.
|
|
97
97
|
- `allowedPatterns`: exceptions.
|
|
98
98
|
- `protection`:
|
|
99
99
|
- `noAccess`: block `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-guardrails",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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.
|
|
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
|
|
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:
|
|
62
|
-
onCreate: (
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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("
|
|
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 (
|
|
105
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
{ setDraft },
|
|
660
|
+
_resolved: ResolvedConfig,
|
|
661
|
+
{ setDraft, theme },
|
|
321
662
|
): SettingsSection[] => {
|
|
322
|
-
const settingsTheme =
|
|
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(
|
|
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(
|
|
678
|
+
const updated = structuredClone(scopedConfig);
|
|
334
679
|
setNestedValue(updated, id, value);
|
|
335
|
-
|
|
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(
|
|
688
|
+
const updated = structuredClone(scopedConfig);
|
|
347
689
|
updated.policies = {
|
|
348
690
|
...(updated.policies ?? {}),
|
|
349
691
|
rules,
|
|
350
692
|
};
|
|
351
|
-
|
|
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(
|
|
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:
|
|
392
|
-
protection:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
470
|
-
if (model !== undefined) return model;
|
|
471
|
-
return resolved.permissionGate.explainModel ?? "";
|
|
810
|
+
return scopedConfig.permissionGate?.explainModel?.trim() ?? "";
|
|
472
811
|
}
|
|
473
812
|
|
|
474
|
-
function
|
|
475
|
-
return
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
516
|
-
? "
|
|
517
|
-
:
|
|
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: "
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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:
|
|
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:
|
|
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: () =>
|
|
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: () =>
|
|
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,
|
|
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 ·
|
|
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
|
-
|
|
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
|
}
|
package/src/hooks/policies.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { stat } from "node:fs/promises";
|
|
2
|
-
import { resolve } from "node:path";
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
3
|
import { parse } from "@aliou/sh";
|
|
4
4
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
5
5
|
import type { PolicyRule, Protection, ResolvedConfig } from "../config";
|
|
6
6
|
import { emitBlocked } from "../utils/events";
|
|
7
7
|
import { expandGlob, hasGlobChars } from "../utils/glob-expander";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type CompiledPattern,
|
|
10
|
+
compileFilePatterns,
|
|
11
|
+
normalizeFilePath,
|
|
12
|
+
} from "../utils/matching";
|
|
9
13
|
import { walkCommands, wordToString } from "../utils/shell-utils";
|
|
10
14
|
import { pendingWarnings } from "../utils/warnings";
|
|
11
15
|
|
|
@@ -113,6 +117,16 @@ function maybePathLike(token: string): boolean {
|
|
|
113
117
|
);
|
|
114
118
|
}
|
|
115
119
|
|
|
120
|
+
function normalizeTargetForPolicy(filePath: string, cwd: string): string {
|
|
121
|
+
const absolute = resolve(cwd, filePath);
|
|
122
|
+
const rel = relative(cwd, absolute);
|
|
123
|
+
|
|
124
|
+
const candidate =
|
|
125
|
+
rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : absolute;
|
|
126
|
+
|
|
127
|
+
return normalizeFilePath(candidate);
|
|
128
|
+
}
|
|
129
|
+
|
|
116
130
|
function matchesAnyPolicyPattern(
|
|
117
131
|
filePath: string,
|
|
118
132
|
rules: CompiledRule[],
|
|
@@ -135,6 +149,7 @@ async function expandCandidate(candidate: string): Promise<string[]> {
|
|
|
135
149
|
async function extractBashFileTargets(
|
|
136
150
|
command: string,
|
|
137
151
|
rules: CompiledRule[],
|
|
152
|
+
cwd: string,
|
|
138
153
|
): Promise<string[]> {
|
|
139
154
|
const targets = new Set<string>();
|
|
140
155
|
|
|
@@ -143,8 +158,9 @@ async function extractBashFileTargets(
|
|
|
143
158
|
|
|
144
159
|
const expanded = await expandCandidate(candidate);
|
|
145
160
|
for (const file of expanded) {
|
|
146
|
-
|
|
147
|
-
|
|
161
|
+
const normalized = normalizeTargetForPolicy(file, cwd);
|
|
162
|
+
if (matchesAnyPolicyPattern(normalized, rules)) {
|
|
163
|
+
targets.add(normalized);
|
|
148
164
|
}
|
|
149
165
|
}
|
|
150
166
|
};
|
|
@@ -182,8 +198,9 @@ async function extractBashFileTargets(
|
|
|
182
198
|
|
|
183
199
|
const expanded = await expandCandidate(token);
|
|
184
200
|
for (const file of expanded) {
|
|
185
|
-
|
|
186
|
-
|
|
201
|
+
const normalized = normalizeTargetForPolicy(file, cwd);
|
|
202
|
+
if (matchesAnyPolicyPattern(normalized, rules)) {
|
|
203
|
+
targets.add(normalized);
|
|
187
204
|
}
|
|
188
205
|
}
|
|
189
206
|
}
|
|
@@ -259,14 +276,16 @@ export function setupPoliciesHook(pi: ExtensionAPI, config: ResolvedConfig) {
|
|
|
259
276
|
targets = extractPathTarget(event.input);
|
|
260
277
|
} else if (toolName === "bash") {
|
|
261
278
|
const command = String(event.input.command ?? "");
|
|
262
|
-
targets = await extractBashFileTargets(command, compiledRules);
|
|
279
|
+
targets = await extractBashFileTargets(command, compiledRules, ctx.cwd);
|
|
263
280
|
} else {
|
|
264
281
|
return;
|
|
265
282
|
}
|
|
266
283
|
|
|
267
284
|
for (const target of targets) {
|
|
285
|
+
const normalizedTarget = normalizeTargetForPolicy(target, ctx.cwd);
|
|
286
|
+
|
|
268
287
|
const effective = await getEffectiveProtection(
|
|
269
|
-
|
|
288
|
+
normalizedTarget,
|
|
270
289
|
compiledRules,
|
|
271
290
|
ctx.cwd,
|
|
272
291
|
);
|
|
@@ -276,11 +295,11 @@ export function setupPoliciesHook(pi: ExtensionAPI, config: ResolvedConfig) {
|
|
|
276
295
|
if (!blockedTools.has(toolName)) continue;
|
|
277
296
|
|
|
278
297
|
ctx.ui.notify(
|
|
279
|
-
`Blocked ${toolName} on protected file: ${
|
|
298
|
+
`Blocked ${toolName} on protected file: ${normalizedTarget} (${effective.ruleId})`,
|
|
280
299
|
"warning",
|
|
281
300
|
);
|
|
282
301
|
|
|
283
|
-
const reason = effective.blockMessage.replace("{file}",
|
|
302
|
+
const reason = effective.blockMessage.replace("{file}", normalizedTarget);
|
|
284
303
|
|
|
285
304
|
emitBlocked(pi, {
|
|
286
305
|
feature: "policies",
|
package/src/utils/matching.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Both support `regex: true` for full regex matching.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { matchesGlob } from "node:path";
|
|
11
12
|
import type { PatternConfig } from "../config";
|
|
12
13
|
import { pendingWarnings } from "./warnings";
|
|
13
14
|
|
|
@@ -17,51 +18,34 @@ export interface CompiledPattern {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* Normalize file paths before matching.
|
|
22
|
+
* - Use forward slashes for cross-platform consistency.
|
|
23
|
+
* - Drop leading "./" segments.
|
|
24
|
+
* - Collapse duplicate slashes.
|
|
23
25
|
*/
|
|
24
|
-
export function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
break;
|
|
31
|
-
case "?":
|
|
32
|
-
regex += "[^/]";
|
|
33
|
-
break;
|
|
34
|
-
case ".":
|
|
35
|
-
case "(":
|
|
36
|
-
case ")":
|
|
37
|
-
case "+":
|
|
38
|
-
case "^":
|
|
39
|
-
case "$":
|
|
40
|
-
case "{":
|
|
41
|
-
case "}":
|
|
42
|
-
case "|":
|
|
43
|
-
case "\\":
|
|
44
|
-
case "[":
|
|
45
|
-
case "]":
|
|
46
|
-
regex += `\\${ch}`;
|
|
47
|
-
break;
|
|
48
|
-
default:
|
|
49
|
-
regex += ch;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return new RegExp(`^${regex}$`, "i");
|
|
26
|
+
export function normalizeFilePath(input: string): string {
|
|
27
|
+
const normalized = input
|
|
28
|
+
.replaceAll("\\", "/")
|
|
29
|
+
.replace(/^(?:\.\/)+/, "")
|
|
30
|
+
.replace(/\/{2,}/g, "/");
|
|
31
|
+
return normalized;
|
|
53
32
|
}
|
|
54
33
|
|
|
55
34
|
/**
|
|
56
35
|
* Compile a single pattern for file-context matching.
|
|
57
|
-
* Default: glob
|
|
58
|
-
*
|
|
36
|
+
* Default: glob matching.
|
|
37
|
+
* - If pattern includes `/`, match full normalized relative path.
|
|
38
|
+
* - Otherwise, match basename only (backward compatible).
|
|
39
|
+
* regex: true -> full regex (case-insensitive) against normalized path.
|
|
59
40
|
*/
|
|
60
41
|
export function compileFilePattern(config: PatternConfig): CompiledPattern {
|
|
61
42
|
if (config.regex) {
|
|
62
43
|
try {
|
|
63
44
|
const re = new RegExp(config.pattern, "i");
|
|
64
|
-
return {
|
|
45
|
+
return {
|
|
46
|
+
test: (input) => re.test(normalizeFilePath(input)),
|
|
47
|
+
source: config,
|
|
48
|
+
};
|
|
65
49
|
} catch {
|
|
66
50
|
pendingWarnings.push(
|
|
67
51
|
`Invalid regex in guardrails config: ${config.pattern}`,
|
|
@@ -70,12 +54,16 @@ export function compileFilePattern(config: PatternConfig): CompiledPattern {
|
|
|
70
54
|
}
|
|
71
55
|
}
|
|
72
56
|
|
|
73
|
-
const
|
|
57
|
+
const matchFullPath = config.pattern.includes("/");
|
|
58
|
+
|
|
74
59
|
return {
|
|
75
60
|
test: (input) => {
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
61
|
+
const normalized = normalizeFilePath(input);
|
|
62
|
+
const candidate = matchFullPath
|
|
63
|
+
? normalized
|
|
64
|
+
: (normalized.split("/").pop() ?? normalized);
|
|
65
|
+
|
|
66
|
+
return matchesGlob(candidate, config.pattern);
|
|
79
67
|
},
|
|
80
68
|
source: config,
|
|
81
69
|
};
|