@aliou/pi-guardrails 0.5.4 → 0.6.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.
@@ -8,16 +8,17 @@ import {
8
8
  } from "@mariozechner/pi-tui";
9
9
 
10
10
  /**
11
- * A submenu component for editing an array of {pattern, description} objects.
11
+ * A submenu component for editing an array of {pattern, description, regex?} objects.
12
12
  *
13
13
  * List mode: navigate, delete with 'd', add with 'a', edit with 'e'/Enter.
14
- * Form mode: two-field form (pattern + description), Tab to switch fields,
15
- * Enter to submit, Escape to cancel.
14
+ * Form mode: three-field form (pattern + description + regex toggle),
15
+ * Tab to switch fields, Enter to submit, Escape to cancel.
16
16
  */
17
17
 
18
18
  export interface PatternItem {
19
19
  pattern: string;
20
20
  description: string;
21
+ regex?: boolean;
21
22
  }
22
23
 
23
24
  export interface PatternEditorOptions {
@@ -26,10 +27,12 @@ export interface PatternEditorOptions {
26
27
  theme: SettingsListTheme;
27
28
  onSave: (items: PatternItem[]) => void;
28
29
  onDone: () => void;
30
+ /** Context hint for the pattern field label. */
31
+ context?: "file" | "command";
29
32
  maxVisible?: number;
30
33
  }
31
34
 
32
- type Field = "pattern" | "description";
35
+ type Field = "pattern" | "description" | "regex";
33
36
 
34
37
  export class PatternEditor implements Component {
35
38
  private items: PatternItem[];
@@ -37,6 +40,7 @@ export class PatternEditor implements Component {
37
40
  private theme: SettingsListTheme;
38
41
  private onSave: (items: PatternItem[]) => void;
39
42
  private onDone: () => void;
43
+ private context: "file" | "command";
40
44
  private selectedIndex = 0;
41
45
  private maxVisible: number;
42
46
  private mode: "list" | "add" | "edit" = "list";
@@ -46,6 +50,7 @@ export class PatternEditor implements Component {
46
50
  private patternInput: Input;
47
51
  private descriptionInput: Input;
48
52
  private activeField: Field = "pattern";
53
+ private regexEnabled = false;
49
54
 
50
55
  constructor(options: PatternEditorOptions) {
51
56
  this.items = [...options.items];
@@ -53,6 +58,7 @@ export class PatternEditor implements Component {
53
58
  this.theme = options.theme;
54
59
  this.onSave = options.onSave;
55
60
  this.onDone = options.onDone;
61
+ this.context = options.context ?? "command";
56
62
  this.maxVisible = options.maxVisible ?? 10;
57
63
 
58
64
  this.patternInput = new Input();
@@ -72,7 +78,13 @@ export class PatternEditor implements Component {
72
78
  return;
73
79
  }
74
80
 
75
- // If on description field (or pattern is empty), submit
81
+ // If on description field, move to regex toggle
82
+ if (this.activeField === "description") {
83
+ this.activeField = "regex";
84
+ return;
85
+ }
86
+
87
+ // If on regex field, submit
76
88
  this.submitForm();
77
89
  }
78
90
 
@@ -89,6 +101,9 @@ export class PatternEditor implements Component {
89
101
  pattern,
90
102
  description: description || pattern,
91
103
  };
104
+ if (this.regexEnabled) {
105
+ item.regex = true;
106
+ }
92
107
 
93
108
  if (this.mode === "edit") {
94
109
  this.items[this.editIndex] = item;
@@ -105,6 +120,7 @@ export class PatternEditor implements Component {
105
120
  this.mode = "list";
106
121
  this.editIndex = -1;
107
122
  this.activeField = "pattern";
123
+ this.regexEnabled = false;
108
124
  this.patternInput.setValue("");
109
125
  this.descriptionInput.setValue("");
110
126
  }
@@ -118,6 +134,7 @@ export class PatternEditor implements Component {
118
134
  this.activeField = "pattern";
119
135
  this.patternInput.setValue(item.pattern);
120
136
  this.descriptionInput.setValue(item.description);
137
+ this.regexEnabled = item.regex ?? false;
121
138
  }
122
139
 
123
140
  private deleteSelected() {
@@ -167,7 +184,8 @@ export class PatternEditor implements Component {
167
184
  const prefix = isSelected ? this.theme.cursor : " ";
168
185
  const prefixWidth = visibleWidth(prefix);
169
186
  const maxItemWidth = width - prefixWidth - 2;
170
- const display = `${item.description} (${item.pattern})`;
187
+ const regexTag = item.regex ? " [regex]" : "";
188
+ const display = `${item.description} (${item.pattern})${regexTag}`;
171
189
  const text = this.theme.value(
172
190
  truncateToWidth(display, maxItemWidth, ""),
173
191
  isSelected,
@@ -197,13 +215,16 @@ export class PatternEditor implements Component {
197
215
 
198
216
  const patternActive = this.activeField === "pattern";
199
217
  const descActive = this.activeField === "description";
218
+ const regexActive = this.activeField === "regex";
200
219
 
201
220
  // Title
202
221
  lines.push(this.theme.hint(isEdit ? " Edit pattern:" : " New pattern:"));
203
222
  lines.push("");
204
223
 
205
224
  // Pattern field
206
- const patternLabel = " Pattern (regex):";
225
+ const patternHint =
226
+ this.context === "file" ? "(glob or regex)" : "(substring or regex)";
227
+ const patternLabel = ` Pattern ${patternHint}:`;
207
228
  lines.push(
208
229
  patternActive
209
230
  ? this.theme.label(patternLabel, true)
@@ -222,8 +243,21 @@ export class PatternEditor implements Component {
222
243
  lines.push(` ${this.descriptionInput.render(inputWidth).join("")}`);
223
244
  lines.push("");
224
245
 
246
+ // Regex toggle
247
+ const regexLabel = " Regex:";
248
+ const regexValue = this.regexEnabled ? "on" : "off";
249
+ const regexDisplay = `${regexLabel} ${regexValue}`;
225
250
  lines.push(
226
- this.theme.hint(" Tab: switch field · Enter: next/submit · Esc: cancel"),
251
+ regexActive
252
+ ? this.theme.label(regexDisplay, true)
253
+ : this.theme.hint(regexDisplay),
254
+ );
255
+ lines.push("");
256
+
257
+ lines.push(
258
+ this.theme.hint(
259
+ " Tab: switch field · Space: toggle regex · Enter: next/submit · Esc: cancel",
260
+ ),
227
261
  );
228
262
 
229
263
  return lines;
@@ -251,6 +285,7 @@ export class PatternEditor implements Component {
251
285
  } else if (data === "a" || data === "A") {
252
286
  this.mode = "add";
253
287
  this.activeField = "pattern";
288
+ this.regexEnabled = false;
254
289
  this.patternInput.setValue("");
255
290
  this.descriptionInput.setValue("");
256
291
  } else if (data === "e" || data === "E" || matchesKey(data, Key.enter)) {
@@ -264,8 +299,12 @@ export class PatternEditor implements Component {
264
299
 
265
300
  private handleFormInput(data: string) {
266
301
  if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab"))) {
267
- this.activeField =
268
- this.activeField === "pattern" ? "description" : "pattern";
302
+ const fields: Field[] = ["pattern", "description", "regex"];
303
+ const idx = fields.indexOf(this.activeField);
304
+ const dir = matchesKey(data, Key.shift("tab")) ? -1 : 1;
305
+ this.activeField = fields[
306
+ (idx + dir + fields.length) % fields.length
307
+ ] as Field;
269
308
  return;
270
309
  }
271
310
 
@@ -274,6 +313,18 @@ export class PatternEditor implements Component {
274
313
  return;
275
314
  }
276
315
 
316
+ // Regex toggle: space toggles when on regex field
317
+ if (this.activeField === "regex") {
318
+ if (data === " " || matchesKey(data, Key.enter)) {
319
+ this.regexEnabled = !this.regexEnabled;
320
+ }
321
+ // Enter on regex field submits if we already have a pattern
322
+ if (matchesKey(data, Key.enter) && this.patternInput.getValue().trim()) {
323
+ this.submitForm();
324
+ }
325
+ return;
326
+ }
327
+
277
328
  // Delegate to active input
278
329
  const activeInput =
279
330
  this.activeField === "pattern"
package/config.ts CHANGED
@@ -1,40 +1,174 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import { dirname, resolve } from "node:path";
4
- import type { GuardrailsConfig, ResolvedConfig } from "./config-schema";
1
+ /**
2
+ * Configuration schema for the guardrails extension.
3
+ *
4
+ * GuardrailsConfig is the user-facing schema (all fields optional).
5
+ * ResolvedConfig is the internal schema (all fields required, defaults applied).
6
+ */
7
+
8
+ /**
9
+ * A pattern with explicit matching mode.
10
+ * Default: glob for files, substring for commands.
11
+ * regex: true means full regex matching.
12
+ */
13
+ export interface PatternConfig {
14
+ pattern: string;
15
+ regex?: boolean;
16
+ }
5
17
 
6
- const GLOBAL_CONFIG_PATH = resolve(
7
- homedir(),
8
- ".pi/agent/extensions/guardrails.json",
9
- );
10
- const PROJECT_CONFIG_PATH = resolve(
11
- process.cwd(),
12
- ".pi/extensions/guardrails.json",
13
- );
18
+ /**
19
+ * Permission gate pattern. When regex is false (default), the pattern
20
+ * is matched as substring against the raw command string.
21
+ * When regex is true, uses full regex against the raw string.
22
+ */
23
+ export interface DangerousPattern extends PatternConfig {
24
+ description: string;
25
+ }
26
+
27
+ export interface GuardrailsConfig {
28
+ version?: string;
29
+ enabled?: boolean;
30
+ features?: {
31
+ protectEnvFiles?: boolean;
32
+ permissionGate?: boolean;
33
+ };
34
+ envFiles?: {
35
+ protectedPatterns?: PatternConfig[];
36
+ allowedPatterns?: PatternConfig[];
37
+ protectedDirectories?: PatternConfig[];
38
+ protectedTools?: string[];
39
+ onlyBlockIfExists?: boolean;
40
+ blockMessage?: string;
41
+ };
42
+ permissionGate?: {
43
+ patterns?: DangerousPattern[];
44
+ /** If set, replaces the default patterns entirely. */
45
+ customPatterns?: DangerousPattern[];
46
+ requireConfirmation?: boolean;
47
+ allowedPatterns?: PatternConfig[];
48
+ autoDenyPatterns?: PatternConfig[];
49
+ };
50
+ }
51
+
52
+ export interface ResolvedConfig {
53
+ version: string;
54
+ enabled: boolean;
55
+ features: {
56
+ protectEnvFiles: boolean;
57
+ permissionGate: boolean;
58
+ };
59
+ envFiles: {
60
+ protectedPatterns: PatternConfig[];
61
+ allowedPatterns: PatternConfig[];
62
+ protectedDirectories: PatternConfig[];
63
+ protectedTools: string[];
64
+ onlyBlockIfExists: boolean;
65
+ blockMessage: string;
66
+ };
67
+ permissionGate: {
68
+ patterns: DangerousPattern[];
69
+ /** When true, use hardcoded structural matchers for built-in patterns.
70
+ * Set to false when customPatterns replaces the defaults. */
71
+ useBuiltinMatchers: boolean;
72
+ requireConfirmation: boolean;
73
+ allowedPatterns: PatternConfig[];
74
+ autoDenyPatterns: PatternConfig[];
75
+ };
76
+ }
77
+
78
+ import { ConfigLoader, type Migration } from "@aliou/pi-utils-settings";
79
+ import {
80
+ backupConfig,
81
+ CURRENT_VERSION,
82
+ migrateV0,
83
+ needsMigration,
84
+ } from "./utils/migration";
85
+
86
+ /**
87
+ * Config fields removed in the toolchain extraction.
88
+ * Old configs containing these are auto-cleaned on first load.
89
+ */
90
+ const REMOVED_FEATURE_KEYS = [
91
+ "preventBrew",
92
+ "preventPython",
93
+ "enforcePackageManager",
94
+ ] as const;
95
+
96
+ const TOOLCHAIN_MIGRATION_VERSION = "0.7.0-20260204";
97
+
98
+ function hasRemovedFields(config: GuardrailsConfig): boolean {
99
+ const raw = config as Record<string, unknown>;
100
+ const features = raw.features as Record<string, unknown> | undefined;
101
+ if (features) {
102
+ for (const key of REMOVED_FEATURE_KEYS) {
103
+ if (key in features) return true;
104
+ }
105
+ }
106
+ return "packageManager" in raw;
107
+ }
108
+
109
+ function stripRemovedFields(config: GuardrailsConfig): GuardrailsConfig {
110
+ const cleaned = structuredClone(config) as Record<string, unknown>;
111
+ const features = cleaned.features as Record<string, unknown> | undefined;
112
+ if (features) {
113
+ for (const key of REMOVED_FEATURE_KEYS) {
114
+ delete features[key];
115
+ }
116
+ }
117
+ delete cleaned.packageManager;
118
+ cleaned.version = TOOLCHAIN_MIGRATION_VERSION;
119
+ return cleaned as GuardrailsConfig;
120
+ }
121
+
122
+ const migrations: Migration<GuardrailsConfig>[] = [
123
+ {
124
+ name: "v0-format-upgrade",
125
+ shouldRun: (config) => needsMigration(config),
126
+ run: async (config, filePath) => {
127
+ await backupConfig(filePath);
128
+ return migrateV0(config);
129
+ },
130
+ },
131
+ {
132
+ name: "strip-toolchain-fields",
133
+ shouldRun: (config) => hasRemovedFields(config),
134
+ run: (config) => {
135
+ const version = (config as Record<string, unknown>).version as
136
+ | string
137
+ | undefined;
138
+ if (!version || version < TOOLCHAIN_MIGRATION_VERSION) {
139
+ console.error(
140
+ "[guardrails] preventBrew, preventPython, enforcePackageManager, and packageManager " +
141
+ "have been removed from guardrails and moved to @aliou/pi-toolchain. " +
142
+ "These fields will be stripped from your config.",
143
+ );
144
+ }
145
+ return stripRemovedFields(config);
146
+ },
147
+ },
148
+ ];
14
149
 
15
150
  const DEFAULT_CONFIG: ResolvedConfig = {
151
+ version: CURRENT_VERSION,
16
152
  enabled: true,
17
153
  features: {
18
- preventBrew: false,
19
- preventPython: false,
20
154
  protectEnvFiles: true,
21
155
  permissionGate: true,
22
- enforcePackageManager: false,
23
- },
24
- packageManager: {
25
- selected: "npm",
26
156
  },
27
157
  envFiles: {
28
158
  protectedPatterns: [
29
- "\\.env$",
30
- "\\.env\\.local$",
31
- "\\.env\\.production$",
32
- "\\.env\\.prod$",
33
- "\\.dev\\.vars$",
159
+ { pattern: ".env" },
160
+ { pattern: ".env.local" },
161
+ { pattern: ".env.production" },
162
+ { pattern: ".env.prod" },
163
+ { pattern: ".dev.vars" },
34
164
  ],
35
165
  allowedPatterns: [
36
- "\\.(example|sample|test)\\.env$",
37
- "\\.env\\.(example|sample|test)$",
166
+ { pattern: "*.example.env" },
167
+ { pattern: "*.sample.env" },
168
+ { pattern: "*.test.env" },
169
+ { pattern: ".env.example" },
170
+ { pattern: ".env.sample" },
171
+ { pattern: ".env.test" },
38
172
  ],
39
173
  protectedDirectories: [],
40
174
  protectedTools: ["read", "write", "edit", "bash", "grep", "find", "ls"],
@@ -46,131 +180,40 @@ const DEFAULT_CONFIG: ResolvedConfig = {
46
180
  },
47
181
  permissionGate: {
48
182
  patterns: [
49
- { pattern: "rm\\s+-rf", description: "recursive force delete" },
50
- { pattern: "\\bsudo\\b", description: "superuser command" },
51
- { pattern: ":\\s*\\|\\s*sh", description: "piped shell execution" },
52
- { pattern: "\\bdd\\s+if=", description: "disk write operation" },
53
- { pattern: "mkfs\\.", description: "filesystem format" },
183
+ { pattern: "rm -rf", description: "recursive force delete" },
184
+ { pattern: "sudo", description: "superuser command" },
185
+ { pattern: "dd if=", description: "disk write operation" },
186
+ { pattern: "mkfs.", description: "filesystem format" },
54
187
  {
55
- pattern: "\\bchmod\\s+-R\\s+777",
188
+ pattern: "chmod -R 777",
56
189
  description: "insecure recursive permissions",
57
190
  },
58
- {
59
- pattern: "\\bchown\\s+-R",
60
- description: "recursive ownership change",
61
- },
191
+ { pattern: "chown -R", description: "recursive ownership change" },
62
192
  ],
193
+ useBuiltinMatchers: true,
63
194
  requireConfirmation: true,
64
195
  allowedPatterns: [],
65
196
  autoDenyPatterns: [],
66
197
  },
67
198
  };
68
199
 
69
- class ConfigLoader {
70
- private globalConfig: GuardrailsConfig | null = null;
71
- private projectConfig: GuardrailsConfig | null = null;
72
- private resolved: ResolvedConfig | null = null;
73
-
74
- async load(): Promise<void> {
75
- this.globalConfig = await this.loadConfigFile(GLOBAL_CONFIG_PATH);
76
- this.projectConfig = await this.loadConfigFile(PROJECT_CONFIG_PATH);
77
- this.resolved = this.mergeConfigs();
78
- }
79
-
80
- private async loadConfigFile(path: string): Promise<GuardrailsConfig | null> {
81
- try {
82
- const content = await readFile(path, "utf-8");
83
- return JSON.parse(content) as GuardrailsConfig;
84
- } catch {
85
- return null;
86
- }
87
- }
88
-
89
- private mergeConfigs(): ResolvedConfig {
90
- const merged = structuredClone(DEFAULT_CONFIG);
91
-
92
- if (this.globalConfig) {
93
- this.mergeInto(merged, this.globalConfig);
94
- }
95
- if (this.projectConfig) {
96
- this.mergeInto(merged, this.projectConfig);
97
- }
98
-
99
- // customPatterns replaces entire patterns array
100
- if (this.projectConfig?.permissionGate?.customPatterns) {
101
- merged.permissionGate.patterns =
102
- this.projectConfig.permissionGate.customPatterns;
103
- } else if (this.globalConfig?.permissionGate?.customPatterns) {
104
- merged.permissionGate.patterns =
105
- this.globalConfig.permissionGate.customPatterns;
106
- }
107
-
108
- return merged;
109
- }
110
-
111
- private mergeInto<TTarget extends object, TSource extends object>(
112
- target: TTarget,
113
- source: TSource,
114
- ): void {
115
- const t = target as Record<string, unknown>;
116
- const s = source as Record<string, unknown>;
117
-
118
- for (const key in s) {
119
- if (s[key] === undefined) continue;
120
-
121
- if (
122
- typeof s[key] === "object" &&
123
- !Array.isArray(s[key]) &&
124
- s[key] !== null
125
- ) {
126
- if (!t[key]) t[key] = {};
127
- this.mergeInto(t[key] as object, s[key] as object);
128
- } else {
129
- t[key] = s[key];
200
+ export const configLoader = new ConfigLoader<GuardrailsConfig, ResolvedConfig>(
201
+ "guardrails",
202
+ DEFAULT_CONFIG,
203
+ {
204
+ migrations,
205
+ afterMerge: (resolved, global, project) => {
206
+ // customPatterns replaces the entire patterns array and disables
207
+ // built-in structural matchers (user owns all matching).
208
+ if (project?.permissionGate?.customPatterns) {
209
+ resolved.permissionGate.patterns =
210
+ project.permissionGate.customPatterns;
211
+ resolved.permissionGate.useBuiltinMatchers = false;
212
+ } else if (global?.permissionGate?.customPatterns) {
213
+ resolved.permissionGate.patterns = global.permissionGate.customPatterns;
214
+ resolved.permissionGate.useBuiltinMatchers = false;
130
215
  }
131
- }
132
- }
133
-
134
- getConfig(): ResolvedConfig {
135
- if (!this.resolved) {
136
- throw new Error("Config not loaded. Call load() first.");
137
- }
138
- return this.resolved;
139
- }
140
-
141
- async saveGlobal(config: GuardrailsConfig): Promise<void> {
142
- await this.saveConfigFile(GLOBAL_CONFIG_PATH, config);
143
- await this.load();
144
- }
145
-
146
- async saveProject(config: GuardrailsConfig): Promise<void> {
147
- await this.saveConfigFile(PROJECT_CONFIG_PATH, config);
148
- await this.load();
149
- }
150
-
151
- private async saveConfigFile(
152
- path: string,
153
- config: GuardrailsConfig,
154
- ): Promise<void> {
155
- await mkdir(dirname(path), { recursive: true });
156
- await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
157
- }
158
-
159
- hasGlobalConfig(): boolean {
160
- return this.globalConfig !== null;
161
- }
162
-
163
- hasProjectConfig(): boolean {
164
- return this.projectConfig !== null;
165
- }
166
-
167
- getGlobalConfig(): GuardrailsConfig {
168
- return this.globalConfig ?? {};
169
- }
170
-
171
- getProjectConfig(): GuardrailsConfig {
172
- return this.projectConfig ?? {};
173
- }
174
- }
175
-
176
- export const configLoader = new ConfigLoader();
216
+ return resolved;
217
+ },
218
+ },
219
+ );
package/hooks/index.ts CHANGED
@@ -1,15 +1,9 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import type { ResolvedConfig } from "../config-schema";
3
- import { setupEnforcePackageManagerHook } from "./enforce-package-manager";
2
+ import type { ResolvedConfig } from "../config";
4
3
  import { setupPermissionGateHook } from "./permission-gate";
5
- import { setupPreventBrewHook } from "./prevent-brew";
6
- import { setupPreventPythonHook } from "./prevent-python";
7
4
  import { setupProtectEnvFilesHook } from "./protect-env-files";
8
5
 
9
6
  export function setupGuardrailsHooks(pi: ExtensionAPI, config: ResolvedConfig) {
10
- setupPreventBrewHook(pi, config);
11
- setupPreventPythonHook(pi, config);
12
7
  setupProtectEnvFilesHook(pi, config);
13
8
  setupPermissionGateHook(pi, config);
14
- setupEnforcePackageManagerHook(pi, config);
15
9
  }