@aliou/pi-guardrails 0.5.4 → 0.6.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
@@ -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.
package/config-schema.ts CHANGED
@@ -5,60 +5,72 @@
5
5
  * ResolvedConfig is the internal schema (all fields required, defaults applied).
6
6
  */
7
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
+ }
17
+
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
+
8
27
  export interface GuardrailsConfig {
28
+ version?: string;
9
29
  enabled?: boolean;
10
30
  features?: {
11
- preventBrew?: boolean;
12
- preventPython?: boolean;
13
31
  protectEnvFiles?: boolean;
14
32
  permissionGate?: boolean;
15
- enforcePackageManager?: boolean;
16
- };
17
- packageManager?: {
18
- selected?: "bun" | "pnpm" | "npm";
19
33
  };
20
34
  envFiles?: {
21
- protectedPatterns?: string[];
22
- allowedPatterns?: string[];
23
- protectedDirectories?: string[];
35
+ protectedPatterns?: PatternConfig[];
36
+ allowedPatterns?: PatternConfig[];
37
+ protectedDirectories?: PatternConfig[];
24
38
  protectedTools?: string[];
25
39
  onlyBlockIfExists?: boolean;
26
40
  blockMessage?: string;
27
41
  };
28
42
  permissionGate?: {
29
- patterns?: Array<{ pattern: string; description: string }>;
43
+ patterns?: DangerousPattern[];
30
44
  /** If set, replaces the default patterns entirely. */
31
- customPatterns?: Array<{ pattern: string; description: string }>;
45
+ customPatterns?: DangerousPattern[];
32
46
  requireConfirmation?: boolean;
33
- allowedPatterns?: string[];
34
- autoDenyPatterns?: string[];
47
+ allowedPatterns?: PatternConfig[];
48
+ autoDenyPatterns?: PatternConfig[];
35
49
  };
36
50
  }
37
51
 
38
52
  export interface ResolvedConfig {
53
+ version: string;
39
54
  enabled: boolean;
40
55
  features: {
41
- preventBrew: boolean;
42
- preventPython: boolean;
43
56
  protectEnvFiles: boolean;
44
57
  permissionGate: boolean;
45
- enforcePackageManager: boolean;
46
- };
47
- packageManager: {
48
- selected: "bun" | "pnpm" | "npm";
49
58
  };
50
59
  envFiles: {
51
- protectedPatterns: string[];
52
- allowedPatterns: string[];
53
- protectedDirectories: string[];
60
+ protectedPatterns: PatternConfig[];
61
+ allowedPatterns: PatternConfig[];
62
+ protectedDirectories: PatternConfig[];
54
63
  protectedTools: string[];
55
64
  onlyBlockIfExists: boolean;
56
65
  blockMessage: string;
57
66
  };
58
67
  permissionGate: {
59
- patterns: Array<{ pattern: string; description: string }>;
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;
60
72
  requireConfirmation: boolean;
61
- allowedPatterns: string[];
62
- autoDenyPatterns: string[];
73
+ allowedPatterns: PatternConfig[];
74
+ autoDenyPatterns: PatternConfig[];
63
75
  };
64
76
  }
package/config.ts CHANGED
@@ -1,40 +1,98 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import { dirname, resolve } from "node:path";
1
+ import { ConfigLoader, type Migration } from "@aliou/pi-utils-settings";
4
2
  import type { GuardrailsConfig, ResolvedConfig } from "./config-schema";
3
+ import {
4
+ backupConfig,
5
+ CURRENT_VERSION,
6
+ migrateV0,
7
+ needsMigration,
8
+ } from "./migration";
9
+
10
+ /**
11
+ * Config fields removed in the toolchain extraction.
12
+ * Old configs containing these are auto-cleaned on first load.
13
+ */
14
+ const REMOVED_FEATURE_KEYS = [
15
+ "preventBrew",
16
+ "preventPython",
17
+ "enforcePackageManager",
18
+ ] as const;
19
+
20
+ const TOOLCHAIN_MIGRATION_VERSION = "0.7.0-20260204";
21
+
22
+ function hasRemovedFields(config: GuardrailsConfig): boolean {
23
+ const raw = config as Record<string, unknown>;
24
+ const features = raw.features as Record<string, unknown> | undefined;
25
+ if (features) {
26
+ for (const key of REMOVED_FEATURE_KEYS) {
27
+ if (key in features) return true;
28
+ }
29
+ }
30
+ return "packageManager" in raw;
31
+ }
5
32
 
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
- );
33
+ function stripRemovedFields(config: GuardrailsConfig): GuardrailsConfig {
34
+ const cleaned = structuredClone(config) as Record<string, unknown>;
35
+ const features = cleaned.features as Record<string, unknown> | undefined;
36
+ if (features) {
37
+ for (const key of REMOVED_FEATURE_KEYS) {
38
+ delete features[key];
39
+ }
40
+ }
41
+ delete cleaned.packageManager;
42
+ cleaned.version = TOOLCHAIN_MIGRATION_VERSION;
43
+ return cleaned as GuardrailsConfig;
44
+ }
45
+
46
+ const migrations: Migration<GuardrailsConfig>[] = [
47
+ {
48
+ name: "v0-format-upgrade",
49
+ shouldRun: (config) => needsMigration(config),
50
+ run: async (config, filePath) => {
51
+ await backupConfig(filePath);
52
+ return migrateV0(config);
53
+ },
54
+ },
55
+ {
56
+ name: "strip-toolchain-fields",
57
+ shouldRun: (config) => hasRemovedFields(config),
58
+ run: (config) => {
59
+ const version = (config as Record<string, unknown>).version as
60
+ | string
61
+ | undefined;
62
+ if (!version || version < TOOLCHAIN_MIGRATION_VERSION) {
63
+ console.error(
64
+ "[guardrails] preventBrew, preventPython, enforcePackageManager, and packageManager " +
65
+ "have been removed from guardrails and moved to @aliou/pi-toolchain. " +
66
+ "These fields will be stripped from your config.",
67
+ );
68
+ }
69
+ return stripRemovedFields(config);
70
+ },
71
+ },
72
+ ];
14
73
 
15
74
  const DEFAULT_CONFIG: ResolvedConfig = {
75
+ version: CURRENT_VERSION,
16
76
  enabled: true,
17
77
  features: {
18
- preventBrew: false,
19
- preventPython: false,
20
78
  protectEnvFiles: true,
21
79
  permissionGate: true,
22
- enforcePackageManager: false,
23
- },
24
- packageManager: {
25
- selected: "npm",
26
80
  },
27
81
  envFiles: {
28
82
  protectedPatterns: [
29
- "\\.env$",
30
- "\\.env\\.local$",
31
- "\\.env\\.production$",
32
- "\\.env\\.prod$",
33
- "\\.dev\\.vars$",
83
+ { pattern: ".env" },
84
+ { pattern: ".env.local" },
85
+ { pattern: ".env.production" },
86
+ { pattern: ".env.prod" },
87
+ { pattern: ".dev.vars" },
34
88
  ],
35
89
  allowedPatterns: [
36
- "\\.(example|sample|test)\\.env$",
37
- "\\.env\\.(example|sample|test)$",
90
+ { pattern: "*.example.env" },
91
+ { pattern: "*.sample.env" },
92
+ { pattern: "*.test.env" },
93
+ { pattern: ".env.example" },
94
+ { pattern: ".env.sample" },
95
+ { pattern: ".env.test" },
38
96
  ],
39
97
  protectedDirectories: [],
40
98
  protectedTools: ["read", "write", "edit", "bash", "grep", "find", "ls"],
@@ -46,131 +104,40 @@ const DEFAULT_CONFIG: ResolvedConfig = {
46
104
  },
47
105
  permissionGate: {
48
106
  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" },
107
+ { pattern: "rm -rf", description: "recursive force delete" },
108
+ { pattern: "sudo", description: "superuser command" },
109
+ { pattern: "dd if=", description: "disk write operation" },
110
+ { pattern: "mkfs.", description: "filesystem format" },
54
111
  {
55
- pattern: "\\bchmod\\s+-R\\s+777",
112
+ pattern: "chmod -R 777",
56
113
  description: "insecure recursive permissions",
57
114
  },
58
- {
59
- pattern: "\\bchown\\s+-R",
60
- description: "recursive ownership change",
61
- },
115
+ { pattern: "chown -R", description: "recursive ownership change" },
62
116
  ],
117
+ useBuiltinMatchers: true,
63
118
  requireConfirmation: true,
64
119
  allowedPatterns: [],
65
120
  autoDenyPatterns: [],
66
121
  },
67
122
  };
68
123
 
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];
124
+ export const configLoader = new ConfigLoader<GuardrailsConfig, ResolvedConfig>(
125
+ "guardrails",
126
+ DEFAULT_CONFIG,
127
+ {
128
+ migrations,
129
+ afterMerge: (resolved, global, project) => {
130
+ // customPatterns replaces the entire patterns array and disables
131
+ // built-in structural matchers (user owns all matching).
132
+ if (project?.permissionGate?.customPatterns) {
133
+ resolved.permissionGate.patterns =
134
+ project.permissionGate.customPatterns;
135
+ resolved.permissionGate.useBuiltinMatchers = false;
136
+ } else if (global?.permissionGate?.customPatterns) {
137
+ resolved.permissionGate.patterns = global.permissionGate.customPatterns;
138
+ resolved.permissionGate.useBuiltinMatchers = false;
130
139
  }
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();
140
+ return resolved;
141
+ },
142
+ },
143
+ );
package/events.ts CHANGED
@@ -4,12 +4,7 @@ export const GUARDRAILS_BLOCKED_EVENT = "guardrails:blocked";
4
4
  export const GUARDRAILS_DANGEROUS_EVENT = "guardrails:dangerous";
5
5
 
6
6
  export interface GuardrailsBlockedEvent {
7
- feature:
8
- | "preventBrew"
9
- | "preventPython"
10
- | "protectEnvFiles"
11
- | "permissionGate"
12
- | "enforcePackageManager";
7
+ feature: "protectEnvFiles" | "permissionGate";
13
8
  toolName: string;
14
9
  input: Record<string, unknown>;
15
10
  reason: string;