@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.
package/README.md CHANGED
@@ -35,11 +35,12 @@ pi install npm:@aliou/pi-guardrails
35
35
 
36
36
  ## Features
37
37
 
38
- - **prevent-brew**: Blocks Homebrew commands (disabled by default)
39
- - **prevent-python**: Blocks Python/pip/poetry commands, suggests uv instead (disabled by default)
40
38
  - **protect-env-files**: Prevents access to `.env` files (except `.example`/`.sample`/`.test`)
41
39
  - **permission-gate**: Prompts for confirmation on dangerous commands
42
- - **enforce-package-manager**: Enforces a specific Node package manager (npm, pnpm, or bun) (disabled by default)
40
+
41
+ All hooks use structural shell parsing via `@aliou/sh` to avoid false positives from keywords inside commit messages, grep patterns, heredocs, or file paths. On parse failure, each hook falls back to regex matching (previous behavior).
42
+
43
+ > **Migration note**: The `preventBrew`, `preventPython`, `enforcePackageManager`, and `packageManager` fields have been removed from guardrails and moved to the [`@aliou/pi-toolchain`](../toolchain) extension. Old configs containing these fields are auto-cleaned on first load with a one-time warning. Install `@aliou/pi-toolchain` and configure `.pi/extensions/toolchain.json` instead.
43
44
 
44
45
  ## Configuration
45
46
 
@@ -54,28 +55,35 @@ Run `/guardrails:settings` to open an interactive settings UI with two tabs:
54
55
  - **Local**: edit project-scoped config (`.pi/extensions/guardrails.json`)
55
56
  - **Global**: edit global config (`~/.pi/agent/extensions/guardrails.json`)
56
57
 
57
- Use `Tab` / `Shift+Tab` to switch tabs. Boolean settings can be toggled directly. Array/object settings (patterns, tools) require manual JSON editing.
58
+ Use `Tab` / `Shift+Tab` to switch tabs. Boolean settings can be toggled directly.
59
+
60
+ ### Migration from v0
61
+
62
+ Configs without a `version` field are automatically migrated on first load. The migration:
63
+ - Backs up the original as `guardrails.v0.json`
64
+ - Converts all string patterns to `{ pattern, regex: true }` to preserve behavior
65
+ - Adds a `version` field
58
66
 
59
67
  ### Configuration Schema
60
68
 
61
69
  ```json
62
70
  {
71
+ "version": "0.7.0-20260204",
63
72
  "enabled": true,
64
73
  "features": {
65
- "preventBrew": false,
66
- "preventPython": false,
67
74
  "protectEnvFiles": true,
68
- "permissionGate": true,
69
- "enforcePackageManager": false
70
- },
71
- "packageManager": {
72
- "selected": "npm"
75
+ "permissionGate": true
73
76
  },
74
77
  "envFiles": {
75
- "protectedPatterns": ["\\.env$", "\\.env\\.local$"],
78
+ "protectedPatterns": [
79
+ { "pattern": ".env" },
80
+ { "pattern": ".env.local" },
81
+ { "pattern": ".env.production" }
82
+ ],
76
83
  "allowedPatterns": [
77
- "\\.(example|sample|test)\\.env$",
78
- "\\.env\\.(example|sample|test)$"
84
+ { "pattern": ".env.example" },
85
+ { "pattern": ".env.sample" },
86
+ { "pattern": "*.example.env" }
79
87
  ],
80
88
  "protectedDirectories": [],
81
89
  "protectedTools": ["read", "write", "edit", "bash", "grep", "find", "ls"],
@@ -84,7 +92,8 @@ Use `Tab` / `Shift+Tab` to switch tabs. Boolean settings can be toggled directly
84
92
  },
85
93
  "permissionGate": {
86
94
  "patterns": [
87
- { "pattern": "rm\\s+-rf", "description": "recursive force delete" }
95
+ { "pattern": "rm -rf", "description": "recursive force delete" },
96
+ { "pattern": "sudo", "description": "superuser command" }
88
97
  ],
89
98
  "customPatterns": [],
90
99
  "requireConfirmation": true,
@@ -96,31 +105,36 @@ Use `Tab` / `Shift+Tab` to switch tabs. Boolean settings can be toggled directly
96
105
 
97
106
  All fields are optional. Missing fields use defaults shown above.
98
107
 
108
+ ### Pattern Format
109
+
110
+ Patterns support two modes controlled by the `regex` flag:
111
+
112
+ **File patterns** (envFiles section):
113
+ - Default (`regex` omitted or `false`): glob matching against the filename. `*` matches any non-`/` chars, `?` matches a single char. Example: `.env.*` matches `.env.local`, `.env.production`.
114
+ - `regex: true`: full regex (case-insensitive) against the full path. Example: `{ "pattern": "\\.env$", "regex": true }`.
115
+
116
+ **Command patterns** (permissionGate section):
117
+ - Default (`regex` omitted or `false`): substring matching against the raw command string. Example: `"rm -rf"` matches any command containing `rm -rf`.
118
+ - `regex: true`: full regex against the raw command string. Example: `{ "pattern": "rm\\s+-rf", "regex": true }`.
119
+
120
+ Built-in dangerous command patterns (`rm -rf`, `sudo`, `dd if=`, `mkfs.*`, `chmod -R 777`, `chown -R`) are matched structurally via AST parsing, independent of the pattern format.
121
+
99
122
  ### Configuration Details
100
123
 
101
124
  #### `features`
102
125
 
103
126
  | Key | Default | Description |
104
127
  |---|---|---|
105
- | `preventBrew` | `false` | Block Homebrew install/upgrade commands |
106
- | `preventPython` | `false` | Block python/pip/poetry commands (use uv instead) |
107
128
  | `protectEnvFiles` | `true` | Block access to `.env` files containing secrets |
108
129
  | `permissionGate` | `true` | Prompt for confirmation on dangerous commands |
109
- | `enforcePackageManager` | `false` | Enforce a specific Node package manager |
110
-
111
- #### `packageManager`
112
-
113
- | Key | Default | Description |
114
- |---|---|---|
115
- | `selected` | `"npm"` | Package manager to enforce: `"npm"`, `"pnpm"`, or `"bun"` |
116
130
 
117
131
  #### `envFiles`
118
132
 
119
133
  | Key | Default | Description |
120
134
  |---|---|---|
121
- | `protectedPatterns` | `["\\.env$", "\\.env\\.local$"]` | Regex patterns for files to protect |
122
- | `allowedPatterns` | `["\\.(example\|sample\|test)\\.env$", ...]` | Regex patterns for allowed exceptions |
123
- | `protectedDirectories` | `[]` | Regex patterns for directories to protect |
135
+ | `protectedPatterns` | `[".env", ".env.local", ...]` | Patterns for files to protect (glob by default) |
136
+ | `allowedPatterns` | `[".env.example", "*.example.env", ...]` | Patterns for allowed exceptions |
137
+ | `protectedDirectories` | `[]` | Patterns for directories to protect |
124
138
  | `protectedTools` | `["read", "write", "edit", "bash", "grep", "find", "ls"]` | Tools to intercept |
125
139
  | `onlyBlockIfExists` | `true` | Only block if the file exists on disk |
126
140
  | `blockMessage` | See defaults | Message shown when blocked. Supports `{file}` placeholder |
@@ -132,54 +146,47 @@ All fields are optional. Missing fields use defaults shown above.
132
146
  | `patterns` | See defaults | Array of `{ pattern, description }` for dangerous commands |
133
147
  | `customPatterns` | Not set | If set, replaces `patterns` entirely |
134
148
  | `requireConfirmation` | `true` | Show confirmation dialog (if `false`, just warns) |
135
- | `allowedPatterns` | `[]` | Regex patterns that bypass the gate |
136
- | `autoDenyPatterns` | `[]` | Regex patterns that are blocked immediately without dialog |
149
+ | `allowedPatterns` | `[]` | Patterns that bypass the gate |
150
+ | `autoDenyPatterns` | `[]` | Patterns that are blocked immediately without dialog |
137
151
 
138
152
  ### Examples
139
153
 
140
- Enable `prevent-brew` for a project using Nix:
141
-
142
- ```json
143
- {
144
- "features": {
145
- "preventBrew": true
146
- }
147
- }
148
- ```
149
-
150
- Add a custom dangerous command pattern:
154
+ Add a custom dangerous command pattern (substring match):
151
155
 
152
156
  ```json
153
157
  {
154
158
  "permissionGate": {
155
159
  "patterns": [
156
- { "pattern": "rm\\s+-rf", "description": "recursive force delete" },
157
- { "pattern": "\\bsudo\\b", "description": "superuser command" },
158
- { "pattern": "docker\\s+system\\s+prune", "description": "docker system prune" }
160
+ { "pattern": "rm -rf", "description": "recursive force delete" },
161
+ { "pattern": "sudo", "description": "superuser command" },
162
+ { "pattern": "docker system prune", "description": "docker system prune" }
159
163
  ]
160
164
  }
161
165
  }
162
166
  ```
163
167
 
164
- Auto-deny certain commands:
168
+ Add a regex-based pattern:
165
169
 
166
170
  ```json
167
171
  {
168
172
  "permissionGate": {
169
- "autoDenyPatterns": ["rm\\s+-rf\\s+/(?!tmp)"]
173
+ "patterns": [
174
+ { "pattern": "rm\\s+-rf\\s+/(?!tmp)", "description": "rm -rf outside /tmp", "regex": true }
175
+ ]
170
176
  }
171
177
  }
172
178
  ```
173
179
 
174
- Enforce pnpm as the package manager:
180
+ Protect env files with glob patterns:
175
181
 
176
182
  ```json
177
183
  {
178
- "features": {
179
- "enforcePackageManager": true
180
- },
181
- "packageManager": {
182
- "selected": "pnpm"
184
+ "envFiles": {
185
+ "protectedPatterns": [
186
+ { "pattern": ".env" },
187
+ { "pattern": ".env.*" },
188
+ { "pattern": ".dev.vars" }
189
+ ]
183
190
  }
184
191
  }
185
192
  ```
@@ -194,7 +201,7 @@ Emitted when a tool call is blocked by any guardrail.
194
201
 
195
202
  ```typescript
196
203
  interface GuardrailsBlockedEvent {
197
- feature: "preventBrew" | "preventPython" | "protectEnvFiles" | "permissionGate" | "enforcePackageManager";
204
+ feature: "protectEnvFiles" | "permissionGate";
198
205
  toolName: string;
199
206
  input: Record<string, unknown>;
200
207
  reason: string;
@@ -218,37 +225,11 @@ The [presenter extension](../presenter) listens for `guardrails:dangerous` event
218
225
 
219
226
  ## Hooks
220
227
 
221
- ### prevent-brew
222
-
223
- Blocks bash commands that attempt to install packages using Homebrew. Disabled by default. Enable via config if your project uses Nix.
224
-
225
- Blocked patterns:
226
- - `brew install`
227
- - `brew cask install`
228
- - `brew bundle`
229
- - `brew upgrade`
230
- - `brew reinstall`
231
-
232
- ### prevent-python
233
-
234
- Blocks bash commands that use Python tooling directly. Disabled by default. Enable if your project uses uv for Python management.
235
-
236
- Blocked patterns:
237
- - `python`, `python3`
238
- - `pip`, `pip3`
239
- - `poetry`
240
- - `pyenv`
241
- - `virtualenv`, `venv`
242
-
243
228
  ### protect-env-files
244
229
 
245
- Prevents accessing `.env` files that might contain secrets. Only allows access to safe variants:
246
- - `.env.example`
247
- - `.env.sample`
248
- - `.env.test`
249
- - `*.example.env`
250
- - `*.sample.env`
251
- - `*.test.env`
230
+ Prevents accessing `.env` files that might contain secrets. Only allows access to safe variants like `.env.example`, `.env.sample`, `.env.test`.
231
+
232
+ Shell globs (e.g. `.env*`) are expanded via `fd` to check if any expanded path matches a protected pattern.
252
233
 
253
234
  Covers tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls` (configurable).
254
235
 
@@ -257,21 +238,9 @@ Covers tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls` (configurabl
257
238
  Prompts user confirmation before executing dangerous commands:
258
239
  - `rm -rf` (recursive force delete)
259
240
  - `sudo` (superuser command)
260
- - `: | sh` (piped shell execution)
261
241
  - `dd if=` (disk write operation)
262
242
  - `mkfs.` (filesystem format)
263
243
  - `chmod -R 777` (insecure recursive permissions)
264
244
  - `chown -R` (recursive ownership change)
265
245
 
266
- All patterns are configurable. Supports allow-lists and auto-deny lists.
267
-
268
- ### enforce-package-manager
269
-
270
- Enforces using a specific Node package manager. Disabled by default. When enabled, blocks commands using non-selected package managers.
271
-
272
- Configure via `packageManager.selected`:
273
- - `"npm"` (default)
274
- - `"pnpm"`
275
- - `"bun"`
276
-
277
- Example: If `selected` is `"pnpm"`, running `npm install` or `bun add` will be blocked with a message instructing the agent to use `pnpm` instead.
246
+ Built-in patterns are matched structurally (AST-based). Custom patterns use substring or regex matching. Supports allow-lists and auto-deny lists.
@@ -0,0 +1,278 @@
1
+ import {
2
+ ArrayEditor,
3
+ getNestedValue,
4
+ registerSettingsCommand,
5
+ type SettingsSection,
6
+ setNestedValue,
7
+ } from "@aliou/pi-utils-settings";
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
10
+ import { PatternEditor } from "../components/pattern-editor";
11
+ import type {
12
+ DangerousPattern,
13
+ GuardrailsConfig,
14
+ PatternConfig,
15
+ ResolvedConfig,
16
+ } from "../config";
17
+ import { configLoader } from "../config";
18
+
19
+ type FeatureKey = keyof ResolvedConfig["features"];
20
+
21
+ const FEATURE_UI: Record<FeatureKey, { label: string; description: string }> = {
22
+ protectEnvFiles: {
23
+ label: "Protect .env files",
24
+ description: "Block access to .env files containing secrets",
25
+ },
26
+ permissionGate: {
27
+ label: "Permission gate",
28
+ description:
29
+ "Prompt for confirmation on dangerous commands (rm -rf, sudo, etc.)",
30
+ },
31
+ };
32
+
33
+ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
34
+ registerSettingsCommand<GuardrailsConfig, ResolvedConfig>(pi, {
35
+ commandName: "guardrails:settings",
36
+ title: "Guardrails Settings",
37
+ configStore: configLoader,
38
+ buildSections: (
39
+ tabConfig: GuardrailsConfig | null,
40
+ resolved: ResolvedConfig,
41
+ { setDraft },
42
+ ): SettingsSection[] => {
43
+ const settingsTheme = getSettingsListTheme();
44
+ // --- Helpers ---
45
+
46
+ function count(id: string): string {
47
+ const val =
48
+ (getNestedValue(tabConfig ?? {}, id) as unknown[] | undefined) ??
49
+ (getNestedValue(resolved, id) as unknown[]) ??
50
+ [];
51
+ return `${val.length} items`;
52
+ }
53
+
54
+ function applyDraft(id: string, value: unknown): void {
55
+ const updated = structuredClone(tabConfig ?? {}) as GuardrailsConfig;
56
+ setNestedValue(updated, id, value);
57
+ setDraft(updated);
58
+ }
59
+
60
+ // --- Submenu factories ---
61
+
62
+ function stringArraySubmenu(id: string, label: string) {
63
+ return (_val: string, submenuDone: (v?: string) => void) => {
64
+ const items =
65
+ (getNestedValue(tabConfig ?? {}, id) as string[] | undefined) ??
66
+ (getNestedValue(resolved, id) as string[]) ??
67
+ [];
68
+ let latest = [...items];
69
+ return new ArrayEditor({
70
+ label,
71
+ items: [...items],
72
+ theme: settingsTheme,
73
+ onSave: (newItems) => {
74
+ latest = newItems;
75
+ applyDraft(id, newItems);
76
+ },
77
+ onDone: () => submenuDone(`${latest.length} items`),
78
+ });
79
+ };
80
+ }
81
+
82
+ function patternSubmenu(
83
+ id: string,
84
+ label: string,
85
+ context?: "file" | "command",
86
+ ) {
87
+ return (_val: string, submenuDone: (v?: string) => void) => {
88
+ const items =
89
+ (getNestedValue(tabConfig ?? {}, id) as
90
+ | DangerousPattern[]
91
+ | undefined) ??
92
+ (getNestedValue(resolved, id) as DangerousPattern[]) ??
93
+ [];
94
+ let latestCount = items.length;
95
+ return new PatternEditor({
96
+ label,
97
+ items: [...items],
98
+ theme: settingsTheme,
99
+ context,
100
+ onSave: (newItems) => {
101
+ latestCount = newItems.length;
102
+ applyDraft(id, newItems);
103
+ },
104
+ onDone: () => submenuDone(`${latestCount} items`),
105
+ });
106
+ };
107
+ }
108
+
109
+ function patternConfigSubmenu(
110
+ id: string,
111
+ label: string,
112
+ context?: "file" | "command",
113
+ ) {
114
+ return (_val: string, submenuDone: (v?: string) => void) => {
115
+ const currentItems =
116
+ (getNestedValue(tabConfig ?? {}, id) as
117
+ | PatternConfig[]
118
+ | undefined) ??
119
+ (getNestedValue(resolved, id) as PatternConfig[]) ??
120
+ [];
121
+ const items = currentItems.map((p) => ({
122
+ pattern: p.pattern,
123
+ description: p.pattern,
124
+ regex: p.regex,
125
+ }));
126
+ let latestCount = items.length;
127
+ return new PatternEditor({
128
+ label,
129
+ items,
130
+ theme: settingsTheme,
131
+ context,
132
+ onSave: (newItems) => {
133
+ latestCount = newItems.length;
134
+ const configs: PatternConfig[] = newItems.map((p) => {
135
+ const cfg: PatternConfig = { pattern: p.pattern };
136
+ if (p.regex) cfg.regex = true;
137
+ return cfg;
138
+ });
139
+ applyDraft(id, configs);
140
+ },
141
+ onDone: () => submenuDone(`${latestCount} items`),
142
+ });
143
+ };
144
+ }
145
+
146
+ // --- Sections ---
147
+
148
+ const featureItems = (Object.keys(FEATURE_UI) as FeatureKey[]).map(
149
+ (key) => ({
150
+ id: `features.${key}`,
151
+ label: FEATURE_UI[key].label,
152
+ description: FEATURE_UI[key].description,
153
+ currentValue:
154
+ (tabConfig?.features?.[key] ?? resolved.features[key])
155
+ ? "enabled"
156
+ : "disabled",
157
+ values: ["enabled", "disabled"],
158
+ }),
159
+ );
160
+
161
+ return [
162
+ { label: "Features", items: featureItems },
163
+ {
164
+ label: "Env Files",
165
+ items: [
166
+ {
167
+ id: "envFiles.onlyBlockIfExists",
168
+ label: "Only block existing files",
169
+ description:
170
+ "Only block .env file access if the file exists on disk",
171
+ currentValue:
172
+ (tabConfig?.envFiles?.onlyBlockIfExists ??
173
+ resolved.envFiles.onlyBlockIfExists)
174
+ ? "on"
175
+ : "off",
176
+ values: ["on", "off"],
177
+ },
178
+ {
179
+ id: "envFiles.protectedPatterns",
180
+ label: "Protected patterns",
181
+ description: "Patterns for files to protect (e.g. .env.local)",
182
+ currentValue: count("envFiles.protectedPatterns"),
183
+ submenu: patternConfigSubmenu(
184
+ "envFiles.protectedPatterns",
185
+ "Protected Patterns",
186
+ "file",
187
+ ),
188
+ },
189
+ {
190
+ id: "envFiles.allowedPatterns",
191
+ label: "Allowed patterns",
192
+ description: "Patterns for exceptions (e.g. .env.example)",
193
+ currentValue: count("envFiles.allowedPatterns"),
194
+ submenu: patternConfigSubmenu(
195
+ "envFiles.allowedPatterns",
196
+ "Allowed Patterns",
197
+ "file",
198
+ ),
199
+ },
200
+ {
201
+ id: "envFiles.protectedDirectories",
202
+ label: "Protected directories",
203
+ description: "Patterns for directories to protect",
204
+ currentValue: count("envFiles.protectedDirectories"),
205
+ submenu: patternConfigSubmenu(
206
+ "envFiles.protectedDirectories",
207
+ "Protected Directories",
208
+ "file",
209
+ ),
210
+ },
211
+ {
212
+ id: "envFiles.protectedTools",
213
+ label: "Protected tools",
214
+ description:
215
+ "Tools to intercept (read, write, edit, bash, grep, find, ls)",
216
+ currentValue: count("envFiles.protectedTools"),
217
+ submenu: stringArraySubmenu(
218
+ "envFiles.protectedTools",
219
+ "Protected Tools",
220
+ ),
221
+ },
222
+ ],
223
+ },
224
+ {
225
+ label: "Permission Gate",
226
+ items: [
227
+ {
228
+ id: "permissionGate.requireConfirmation",
229
+ label: "Require confirmation",
230
+ description:
231
+ "Show confirmation dialog for dangerous commands (if off, just warns)",
232
+ currentValue:
233
+ (tabConfig?.permissionGate?.requireConfirmation ??
234
+ resolved.permissionGate.requireConfirmation)
235
+ ? "on"
236
+ : "off",
237
+ values: ["on", "off"],
238
+ },
239
+ {
240
+ id: "permissionGate.patterns",
241
+ label: "Dangerous patterns",
242
+ description: "Command patterns that trigger the permission gate",
243
+ currentValue: count("permissionGate.patterns"),
244
+ submenu: patternSubmenu(
245
+ "permissionGate.patterns",
246
+ "Dangerous Patterns",
247
+ "command",
248
+ ),
249
+ },
250
+ {
251
+ id: "permissionGate.allowedPatterns",
252
+ label: "Allowed commands",
253
+ description: "Patterns that bypass the permission gate entirely",
254
+ currentValue: count("permissionGate.allowedPatterns"),
255
+ submenu: patternConfigSubmenu(
256
+ "permissionGate.allowedPatterns",
257
+ "Allowed Commands",
258
+ "command",
259
+ ),
260
+ },
261
+ {
262
+ id: "permissionGate.autoDenyPatterns",
263
+ label: "Auto-deny patterns",
264
+ description:
265
+ "Patterns that block commands immediately without dialog",
266
+ currentValue: count("permissionGate.autoDenyPatterns"),
267
+ submenu: patternConfigSubmenu(
268
+ "permissionGate.autoDenyPatterns",
269
+ "Auto-Deny Patterns",
270
+ "command",
271
+ ),
272
+ },
273
+ ],
274
+ },
275
+ ];
276
+ },
277
+ });
278
+ }