@aliou/pi-guardrails 0.4.0 → 0.5.0

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
@@ -36,8 +36,10 @@ pi install npm:@aliou/pi-guardrails
36
36
  ## Features
37
37
 
38
38
  - **prevent-brew**: Blocks Homebrew commands (disabled by default)
39
+ - **prevent-python**: Blocks Python/pip/poetry commands, suggests uv instead (disabled by default)
39
40
  - **protect-env-files**: Prevents access to `.env` files (except `.example`/`.sample`/`.test`)
40
41
  - **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)
41
43
 
42
44
  ## Configuration
43
45
 
@@ -61,8 +63,13 @@ Use `Tab` / `Shift+Tab` to switch tabs. Boolean settings can be toggled directly
61
63
  "enabled": true,
62
64
  "features": {
63
65
  "preventBrew": false,
66
+ "preventPython": false,
64
67
  "protectEnvFiles": true,
65
- "permissionGate": true
68
+ "permissionGate": true,
69
+ "enforcePackageManager": false
70
+ },
71
+ "packageManager": {
72
+ "selected": "npm"
66
73
  },
67
74
  "envFiles": {
68
75
  "protectedPatterns": ["\\.env$", "\\.env\\.local$"],
@@ -96,8 +103,16 @@ All fields are optional. Missing fields use defaults shown above.
96
103
  | Key | Default | Description |
97
104
  |---|---|---|
98
105
  | `preventBrew` | `false` | Block Homebrew install/upgrade commands |
106
+ | `preventPython` | `false` | Block python/pip/poetry commands (use uv instead) |
99
107
  | `protectEnvFiles` | `true` | Block access to `.env` files containing secrets |
100
108
  | `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"` |
101
116
 
102
117
  #### `envFiles`
103
118
 
@@ -156,6 +171,19 @@ Auto-deny certain commands:
156
171
  }
157
172
  ```
158
173
 
174
+ Enforce pnpm as the package manager:
175
+
176
+ ```json
177
+ {
178
+ "features": {
179
+ "enforcePackageManager": true
180
+ },
181
+ "packageManager": {
182
+ "selected": "pnpm"
183
+ }
184
+ }
185
+ ```
186
+
159
187
  ## Events
160
188
 
161
189
  The extension emits events on the pi event bus for inter-extension communication.
@@ -166,7 +194,7 @@ Emitted when a tool call is blocked by any guardrail.
166
194
 
167
195
  ```typescript
168
196
  interface GuardrailsBlockedEvent {
169
- feature: "preventBrew" | "protectEnvFiles" | "permissionGate";
197
+ feature: "preventBrew" | "preventPython" | "protectEnvFiles" | "permissionGate" | "enforcePackageManager";
170
198
  toolName: string;
171
199
  input: Record<string, unknown>;
172
200
  reason: string;
@@ -201,6 +229,17 @@ Blocked patterns:
201
229
  - `brew upgrade`
202
230
  - `brew reinstall`
203
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
+
204
243
  ### protect-env-files
205
244
 
206
245
  Prevents accessing `.env` files that might contain secrets. Only allows access to safe variants:
@@ -225,3 +264,14 @@ Prompts user confirmation before executing dangerous commands:
225
264
  - `chown -R` (recursive ownership change)
226
265
 
227
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.
package/config-schema.ts CHANGED
@@ -12,6 +12,10 @@ export interface GuardrailsConfig {
12
12
  preventPython?: boolean;
13
13
  protectEnvFiles?: boolean;
14
14
  permissionGate?: boolean;
15
+ enforcePackageManager?: boolean;
16
+ };
17
+ packageManager?: {
18
+ selected?: "bun" | "pnpm" | "npm";
15
19
  };
16
20
  envFiles?: {
17
21
  protectedPatterns?: string[];
@@ -38,6 +42,10 @@ export interface ResolvedConfig {
38
42
  preventPython: boolean;
39
43
  protectEnvFiles: boolean;
40
44
  permissionGate: boolean;
45
+ enforcePackageManager: boolean;
46
+ };
47
+ packageManager: {
48
+ selected: "bun" | "pnpm" | "npm";
41
49
  };
42
50
  envFiles: {
43
51
  protectedPatterns: string[];
package/config.ts CHANGED
@@ -19,6 +19,10 @@ const DEFAULT_CONFIG: ResolvedConfig = {
19
19
  preventPython: false,
20
20
  protectEnvFiles: true,
21
21
  permissionGate: true,
22
+ enforcePackageManager: false,
23
+ },
24
+ packageManager: {
25
+ selected: "npm",
22
26
  },
23
27
  envFiles: {
24
28
  protectedPatterns: ["\\.env$", "\\.env\\.local$"],
@@ -98,20 +102,25 @@ class ConfigLoader {
98
102
  return merged;
99
103
  }
100
104
 
101
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
- private mergeInto(target: any, source: any): void {
103
- for (const key in source) {
104
- if (source[key] === undefined) continue;
105
+ private mergeInto<TTarget extends object, TSource extends object>(
106
+ target: TTarget,
107
+ source: TSource,
108
+ ): void {
109
+ const t = target as Record<string, unknown>;
110
+ const s = source as Record<string, unknown>;
111
+
112
+ for (const key in s) {
113
+ if (s[key] === undefined) continue;
105
114
 
106
115
  if (
107
- typeof source[key] === "object" &&
108
- !Array.isArray(source[key]) &&
109
- source[key] !== null
116
+ typeof s[key] === "object" &&
117
+ !Array.isArray(s[key]) &&
118
+ s[key] !== null
110
119
  ) {
111
- if (!target[key]) target[key] = {};
112
- this.mergeInto(target[key], source[key]);
120
+ if (!t[key]) t[key] = {};
121
+ this.mergeInto(t[key] as object, s[key] as object);
113
122
  } else {
114
- target[key] = source[key];
123
+ t[key] = s[key];
115
124
  }
116
125
  }
117
126
  }
@@ -138,7 +147,7 @@ class ConfigLoader {
138
147
  config: GuardrailsConfig,
139
148
  ): Promise<void> {
140
149
  await mkdir(dirname(path), { recursive: true });
141
- await writeFile(path, JSON.stringify(config, null, 2) + "\n", "utf-8");
150
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
142
151
  }
143
152
 
144
153
  hasGlobalConfig(): boolean {
package/events.ts CHANGED
@@ -8,7 +8,8 @@ export interface GuardrailsBlockedEvent {
8
8
  | "preventBrew"
9
9
  | "preventPython"
10
10
  | "protectEnvFiles"
11
- | "permissionGate";
11
+ | "permissionGate"
12
+ | "enforcePackageManager";
12
13
  toolName: string;
13
14
  input: Record<string, unknown>;
14
15
  reason: string;
@@ -0,0 +1,96 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { ResolvedConfig } from "../config-schema";
3
+ import { emitBlocked } from "../events";
4
+
5
+ /**
6
+ * Enforces using a specific Node package manager (bun, pnpm, or npm).
7
+ * Blocks commands using non-selected package managers.
8
+ */
9
+
10
+ const BUN_PATTERN = /\bbun\b/;
11
+ const PNPM_PATTERN = /\bpnpm\b/;
12
+ const NPM_PATTERN = /\bnpm\b/;
13
+
14
+ type PackageManager = "bun" | "pnpm" | "npm";
15
+
16
+ interface ManagerInfo {
17
+ pattern: RegExp;
18
+ name: string;
19
+ installCmd: string;
20
+ addCmd: string;
21
+ runCmd: string;
22
+ }
23
+
24
+ const MANAGER_INFO: Record<PackageManager, ManagerInfo> = {
25
+ bun: {
26
+ pattern: BUN_PATTERN,
27
+ name: "bun",
28
+ installCmd: "bun install",
29
+ addCmd: "bun add <package>",
30
+ runCmd: "bun run <script>",
31
+ },
32
+ pnpm: {
33
+ pattern: PNPM_PATTERN,
34
+ name: "pnpm",
35
+ installCmd: "pnpm install",
36
+ addCmd: "pnpm add <package>",
37
+ runCmd: "pnpm run <script>",
38
+ },
39
+ npm: {
40
+ pattern: NPM_PATTERN,
41
+ name: "npm",
42
+ installCmd: "npm install",
43
+ addCmd: "npm install <package>",
44
+ runCmd: "npm run <script>",
45
+ },
46
+ };
47
+
48
+ export function setupEnforcePackageManagerHook(
49
+ pi: ExtensionAPI,
50
+ config: ResolvedConfig,
51
+ ) {
52
+ if (!config.features.enforcePackageManager) return;
53
+
54
+ const selectedManager = config.packageManager.selected;
55
+ const selected = MANAGER_INFO[selectedManager];
56
+
57
+ // Get all managers that should be blocked (all except the selected one)
58
+ const blockedManagers = (
59
+ Object.keys(MANAGER_INFO) as PackageManager[]
60
+ ).filter((m) => m !== selectedManager);
61
+
62
+ pi.on("tool_call", async (event, ctx) => {
63
+ if (event.toolName !== "bash") return;
64
+
65
+ const command = String(event.input.command ?? "");
66
+
67
+ for (const blockedManager of blockedManagers) {
68
+ const blocked = MANAGER_INFO[blockedManager];
69
+
70
+ if (blocked.pattern.test(command)) {
71
+ ctx.ui.notify(
72
+ `Blocked ${blocked.name} command. Use ${selected.name} instead.`,
73
+ "warning",
74
+ );
75
+
76
+ const reason =
77
+ `This project uses ${selected.name} as its package manager. ` +
78
+ `Use ${selected.name} instead of ${blocked.name}. ` +
79
+ `Run \`${selected.installCmd}\` to install dependencies, ` +
80
+ `\`${selected.addCmd}\` to add packages, ` +
81
+ `and \`${selected.runCmd}\` to run scripts.`;
82
+
83
+ emitBlocked(pi, {
84
+ feature: "enforcePackageManager",
85
+ toolName: "bash",
86
+ input: event.input,
87
+ reason,
88
+ });
89
+
90
+ return { block: true, reason };
91
+ }
92
+ }
93
+
94
+ return;
95
+ });
96
+ }
package/hooks/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import type { ResolvedConfig } from "../config-schema";
3
+ import { setupEnforcePackageManagerHook } from "./enforce-package-manager";
3
4
  import { setupPermissionGateHook } from "./permission-gate";
4
5
  import { setupPreventBrewHook } from "./prevent-brew";
5
6
  import { setupPreventPythonHook } from "./prevent-python";
@@ -10,4 +11,5 @@ export function setupGuardrailsHooks(pi: ExtensionAPI, config: ResolvedConfig) {
10
11
  setupPreventPythonHook(pi, config);
11
12
  setupProtectEnvFilesHook(pi, config);
12
13
  setupPermissionGateHook(pi, config);
14
+ setupEnforcePackageManagerHook(pi, config);
13
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-guardrails",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "keywords": [
@@ -3,12 +3,46 @@ import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
3
3
  import { Key, matchesKey } from "@mariozechner/pi-tui";
4
4
  import { ArrayEditor } from "./array-editor";
5
5
  import { configLoader } from "./config";
6
- import type { GuardrailsConfig } from "./config-schema";
6
+ import type { GuardrailsConfig, ResolvedConfig } from "./config-schema";
7
7
  import { PatternEditor } from "./pattern-editor";
8
8
  import { SectionedSettings, type SettingsSection } from "./sectioned-settings";
9
9
 
10
10
  type Tab = "local" | "global";
11
11
 
12
+ // Typed feature UI definitions. Adding a key to ResolvedConfig.features
13
+ // without adding it here will cause a type error.
14
+ type FeatureKey = keyof ResolvedConfig["features"];
15
+
16
+ interface FeatureUiDef {
17
+ label: string;
18
+ description: string;
19
+ }
20
+
21
+ const FEATURE_UI: Record<FeatureKey, FeatureUiDef> = {
22
+ preventBrew: {
23
+ label: "Prevent Homebrew",
24
+ description: "Block brew commands",
25
+ },
26
+ preventPython: {
27
+ label: "Prevent Python",
28
+ description: "Block python/pip/poetry commands. Use uv instead.",
29
+ },
30
+ protectEnvFiles: {
31
+ label: "Protect .env files",
32
+ description: "Block access to .env files containing secrets",
33
+ },
34
+ permissionGate: {
35
+ label: "Permission gate",
36
+ description:
37
+ "Prompt for confirmation on dangerous commands (rm -rf, sudo, etc.)",
38
+ },
39
+ enforcePackageManager: {
40
+ label: "Enforce package manager",
41
+ description:
42
+ "Enforce using a specific Node package manager (bun, pnpm, or npm)",
43
+ },
44
+ };
45
+
12
46
  export function registerSettingsCommand(pi: ExtensionAPI): void {
13
47
  pi.registerCommand("guardrails:settings", {
14
48
  description: "Configure guardrails (local/global)",
@@ -168,45 +202,26 @@ export function registerSettingsCommand(pi: ExtensionAPI): void {
168
202
  : configLoader.getGlobalConfig();
169
203
  const resolved = configLoader.getConfig();
170
204
 
205
+ const featureItems = (Object.keys(FEATURE_UI) as FeatureKey[]).map(
206
+ (key) => {
207
+ const def = FEATURE_UI[key];
208
+ return {
209
+ id: `features.${key}`,
210
+ label: def.label,
211
+ description: def.description,
212
+ currentValue:
213
+ (config.features?.[key] ?? resolved.features[key])
214
+ ? "enabled"
215
+ : "disabled",
216
+ values: ["enabled", "disabled"],
217
+ };
218
+ },
219
+ );
220
+
171
221
  const sections: SettingsSection[] = [
172
222
  {
173
223
  label: "Features",
174
- items: [
175
- {
176
- id: "features.preventBrew",
177
- label: "Prevent Homebrew",
178
- description: "Block brew commands",
179
- currentValue:
180
- (config.features?.preventBrew ??
181
- resolved.features.preventBrew)
182
- ? "enabled"
183
- : "disabled",
184
- values: ["enabled", "disabled"],
185
- },
186
- {
187
- id: "features.protectEnvFiles",
188
- label: "Protect .env files",
189
- description: "Block access to .env files containing secrets",
190
- currentValue:
191
- (config.features?.protectEnvFiles ??
192
- resolved.features.protectEnvFiles)
193
- ? "enabled"
194
- : "disabled",
195
- values: ["enabled", "disabled"],
196
- },
197
- {
198
- id: "features.permissionGate",
199
- label: "Permission gate",
200
- description:
201
- "Prompt for confirmation on dangerous commands (rm -rf, sudo, etc.)",
202
- currentValue:
203
- (config.features?.permissionGate ??
204
- resolved.features.permissionGate)
205
- ? "enabled"
206
- : "disabled",
207
- values: ["enabled", "disabled"],
208
- },
209
- ],
224
+ items: featureItems,
210
225
  },
211
226
  {
212
227
  label: "Env Files",
@@ -318,6 +333,21 @@ export function registerSettingsCommand(pi: ExtensionAPI): void {
318
333
  },
319
334
  ],
320
335
  },
336
+ {
337
+ label: "Package Manager",
338
+ items: [
339
+ {
340
+ id: "packageManager.selected",
341
+ label: "Selected manager",
342
+ description:
343
+ "Which package manager to enforce (when feature is enabled)",
344
+ currentValue:
345
+ config.packageManager?.selected ??
346
+ resolved.packageManager.selected,
347
+ values: ["npm", "pnpm", "bun"],
348
+ },
349
+ ],
350
+ },
321
351
  ];
322
352
 
323
353
  return new SectionedSettings(
@@ -345,7 +375,7 @@ export function registerSettingsCommand(pi: ExtensionAPI): void {
345
375
  : configLoader.getGlobalConfig();
346
376
  const updated: GuardrailsConfig = structuredClone(config);
347
377
 
348
- // Boolean toggles only - array saves handled by submenus
378
+ // Boolean toggles
349
379
  if (
350
380
  newValue === "enabled" ||
351
381
  newValue === "disabled" ||
@@ -355,6 +385,18 @@ export function registerSettingsCommand(pi: ExtensionAPI): void {
355
385
  const boolVal = newValue === "enabled" || newValue === "on";
356
386
  setNestedValue(updated, id, boolVal);
357
387
 
388
+ const ok = await saveTabConfig(tab, updated);
389
+ if (ok) {
390
+ settings = buildSettings(activeTab);
391
+ tui.requestRender();
392
+ }
393
+ return;
394
+ }
395
+
396
+ // Package manager selection
397
+ if (id === "packageManager.selected") {
398
+ setNestedValue(updated, id, newValue);
399
+
358
400
  const ok = await saveTabConfig(tab, updated);
359
401
  if (ok) {
360
402
  settings = buildSettings(activeTab);