@aliou/pi-guardrails 0.2.1 → 0.3.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 +154 -3
- package/array-editor.ts +213 -0
- package/config-schema.ts +54 -0
- package/config.ts +160 -0
- package/events.ts +32 -0
- package/hooks/index.ts +5 -4
- package/hooks/permission-gate.ts +170 -121
- package/hooks/prevent-brew.ts +26 -22
- package/hooks/protect-env-files.ts +95 -80
- package/index.ts +15 -2
- package/package.json +1 -1
- package/pattern-editor.ts +284 -0
- package/sectioned-settings.ts +345 -0
- package/settings-command.ts +416 -0
package/README.md
CHANGED
|
@@ -35,15 +35,164 @@ pi install npm:@aliou/pi-guardrails
|
|
|
35
35
|
|
|
36
36
|
## Features
|
|
37
37
|
|
|
38
|
-
- **prevent-brew**: Blocks Homebrew commands (
|
|
38
|
+
- **prevent-brew**: Blocks Homebrew commands (disabled by default)
|
|
39
39
|
- **protect-env-files**: Prevents access to `.env` files (except `.example`/`.sample`/`.test`)
|
|
40
40
|
- **permission-gate**: Prompts for confirmation on dangerous commands
|
|
41
41
|
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
Configuration is loaded from two optional JSON files, merged in order (project overrides global):
|
|
45
|
+
|
|
46
|
+
- **Global**: `~/.pi/agent/extensions/guardrails.json`
|
|
47
|
+
- **Project**: `.pi/extensions/guardrails.json`
|
|
48
|
+
|
|
49
|
+
### Settings Command
|
|
50
|
+
|
|
51
|
+
Run `/guardrails:settings` to open an interactive settings UI with two tabs:
|
|
52
|
+
- **Local**: edit project-scoped config (`.pi/extensions/guardrails.json`)
|
|
53
|
+
- **Global**: edit global config (`~/.pi/agent/extensions/guardrails.json`)
|
|
54
|
+
|
|
55
|
+
Use `Tab` / `Shift+Tab` to switch tabs. Boolean settings can be toggled directly. Array/object settings (patterns, tools) require manual JSON editing.
|
|
56
|
+
|
|
57
|
+
### Configuration Schema
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"enabled": true,
|
|
62
|
+
"features": {
|
|
63
|
+
"preventBrew": false,
|
|
64
|
+
"protectEnvFiles": true,
|
|
65
|
+
"permissionGate": true
|
|
66
|
+
},
|
|
67
|
+
"envFiles": {
|
|
68
|
+
"protectedPatterns": ["\\.env$", "\\.env\\.local$"],
|
|
69
|
+
"allowedPatterns": [
|
|
70
|
+
"\\.(example|sample|test)\\.env$",
|
|
71
|
+
"\\.env\\.(example|sample|test)$"
|
|
72
|
+
],
|
|
73
|
+
"protectedDirectories": [],
|
|
74
|
+
"protectedTools": ["read", "write", "edit", "bash", "grep", "find", "ls"],
|
|
75
|
+
"onlyBlockIfExists": true,
|
|
76
|
+
"blockMessage": "Accessing {file} is not allowed. ..."
|
|
77
|
+
},
|
|
78
|
+
"permissionGate": {
|
|
79
|
+
"patterns": [
|
|
80
|
+
{ "pattern": "rm\\s+-rf", "description": "recursive force delete" }
|
|
81
|
+
],
|
|
82
|
+
"customPatterns": [],
|
|
83
|
+
"requireConfirmation": true,
|
|
84
|
+
"allowedPatterns": [],
|
|
85
|
+
"autoDenyPatterns": []
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
All fields are optional. Missing fields use defaults shown above.
|
|
91
|
+
|
|
92
|
+
### Configuration Details
|
|
93
|
+
|
|
94
|
+
#### `features`
|
|
95
|
+
|
|
96
|
+
| Key | Default | Description |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `preventBrew` | `false` | Block Homebrew install/upgrade commands |
|
|
99
|
+
| `protectEnvFiles` | `true` | Block access to `.env` files containing secrets |
|
|
100
|
+
| `permissionGate` | `true` | Prompt for confirmation on dangerous commands |
|
|
101
|
+
|
|
102
|
+
#### `envFiles`
|
|
103
|
+
|
|
104
|
+
| Key | Default | Description |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `protectedPatterns` | `["\\.env$", "\\.env\\.local$"]` | Regex patterns for files to protect |
|
|
107
|
+
| `allowedPatterns` | `["\\.(example\|sample\|test)\\.env$", ...]` | Regex patterns for allowed exceptions |
|
|
108
|
+
| `protectedDirectories` | `[]` | Regex patterns for directories to protect |
|
|
109
|
+
| `protectedTools` | `["read", "write", "edit", "bash", "grep", "find", "ls"]` | Tools to intercept |
|
|
110
|
+
| `onlyBlockIfExists` | `true` | Only block if the file exists on disk |
|
|
111
|
+
| `blockMessage` | See defaults | Message shown when blocked. Supports `{file}` placeholder |
|
|
112
|
+
|
|
113
|
+
#### `permissionGate`
|
|
114
|
+
|
|
115
|
+
| Key | Default | Description |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `patterns` | See defaults | Array of `{ pattern, description }` for dangerous commands |
|
|
118
|
+
| `customPatterns` | Not set | If set, replaces `patterns` entirely |
|
|
119
|
+
| `requireConfirmation` | `true` | Show confirmation dialog (if `false`, just warns) |
|
|
120
|
+
| `allowedPatterns` | `[]` | Regex patterns that bypass the gate |
|
|
121
|
+
| `autoDenyPatterns` | `[]` | Regex patterns that are blocked immediately without dialog |
|
|
122
|
+
|
|
123
|
+
### Examples
|
|
124
|
+
|
|
125
|
+
Enable `prevent-brew` for a project using Nix:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"features": {
|
|
130
|
+
"preventBrew": true
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Add a custom dangerous command pattern:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"permissionGate": {
|
|
140
|
+
"patterns": [
|
|
141
|
+
{ "pattern": "rm\\s+-rf", "description": "recursive force delete" },
|
|
142
|
+
{ "pattern": "\\bsudo\\b", "description": "superuser command" },
|
|
143
|
+
{ "pattern": "docker\\s+system\\s+prune", "description": "docker system prune" }
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Auto-deny certain commands:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"permissionGate": {
|
|
154
|
+
"autoDenyPatterns": ["rm\\s+-rf\\s+/(?!tmp)"]
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Events
|
|
160
|
+
|
|
161
|
+
The extension emits events on the pi event bus for inter-extension communication.
|
|
162
|
+
|
|
163
|
+
### `guardrails:blocked`
|
|
164
|
+
|
|
165
|
+
Emitted when a tool call is blocked by any guardrail.
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
interface GuardrailsBlockedEvent {
|
|
169
|
+
feature: "preventBrew" | "protectEnvFiles" | "permissionGate";
|
|
170
|
+
toolName: string;
|
|
171
|
+
input: Record<string, unknown>;
|
|
172
|
+
reason: string;
|
|
173
|
+
userDenied?: boolean;
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### `guardrails:dangerous`
|
|
178
|
+
|
|
179
|
+
Emitted when a dangerous command is detected (before the confirmation dialog).
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
interface GuardrailsDangerousEvent {
|
|
183
|
+
command: string;
|
|
184
|
+
description: string;
|
|
185
|
+
pattern: string;
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The [presenter extension](../presenter) listens for `guardrails:dangerous` events and plays a notification sound.
|
|
190
|
+
|
|
42
191
|
## Hooks
|
|
43
192
|
|
|
44
193
|
### prevent-brew
|
|
45
194
|
|
|
46
|
-
Blocks bash commands that attempt to install packages using Homebrew.
|
|
195
|
+
Blocks bash commands that attempt to install packages using Homebrew. Disabled by default. Enable via config if your project uses Nix.
|
|
47
196
|
|
|
48
197
|
Blocked patterns:
|
|
49
198
|
- `brew install`
|
|
@@ -62,7 +211,7 @@ Prevents accessing `.env` files that might contain secrets. Only allows access t
|
|
|
62
211
|
- `*.sample.env`
|
|
63
212
|
- `*.test.env`
|
|
64
213
|
|
|
65
|
-
Covers tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`
|
|
214
|
+
Covers tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls` (configurable).
|
|
66
215
|
|
|
67
216
|
### permission-gate
|
|
68
217
|
|
|
@@ -74,3 +223,5 @@ Prompts user confirmation before executing dangerous commands:
|
|
|
74
223
|
- `mkfs.` (filesystem format)
|
|
75
224
|
- `chmod -R 777` (insecure recursive permissions)
|
|
76
225
|
- `chown -R` (recursive ownership change)
|
|
226
|
+
|
|
227
|
+
All patterns are configurable. Supports allow-lists and auto-deny lists.
|
package/array-editor.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { Component, SettingsListTheme } from "@mariozechner/pi-tui";
|
|
2
|
+
import {
|
|
3
|
+
Input,
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
} from "@mariozechner/pi-tui";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A submenu component for editing string arrays inside a SettingsList.
|
|
12
|
+
*
|
|
13
|
+
* Modes:
|
|
14
|
+
* - list: navigate items, delete with 'd', add with 'a', edit with 'e'/Enter
|
|
15
|
+
* - add: text input for new item, confirm with Enter, cancel with Escape
|
|
16
|
+
* - edit: text input pre-filled with current value, Enter saves, Escape cancels
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface ArrayEditorOptions {
|
|
20
|
+
label: string;
|
|
21
|
+
items: string[];
|
|
22
|
+
theme: SettingsListTheme;
|
|
23
|
+
onSave: (items: string[]) => void;
|
|
24
|
+
onDone: () => void;
|
|
25
|
+
/** Max visible items before scrolling */
|
|
26
|
+
maxVisible?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ArrayEditor implements Component {
|
|
30
|
+
private items: string[];
|
|
31
|
+
private label: string;
|
|
32
|
+
private theme: SettingsListTheme;
|
|
33
|
+
private onSave: (items: string[]) => void;
|
|
34
|
+
private onDone: () => void;
|
|
35
|
+
private selectedIndex = 0;
|
|
36
|
+
private maxVisible: number;
|
|
37
|
+
private mode: "list" | "add" | "edit" = "list";
|
|
38
|
+
private input: Input;
|
|
39
|
+
private editIndex = -1;
|
|
40
|
+
|
|
41
|
+
constructor(options: ArrayEditorOptions) {
|
|
42
|
+
this.items = [...options.items];
|
|
43
|
+
this.label = options.label;
|
|
44
|
+
this.theme = options.theme;
|
|
45
|
+
this.onSave = options.onSave;
|
|
46
|
+
this.onDone = options.onDone;
|
|
47
|
+
this.maxVisible = options.maxVisible ?? 10;
|
|
48
|
+
this.input = new Input();
|
|
49
|
+
this.input.onSubmit = (value: string) => {
|
|
50
|
+
if (this.mode === "edit") {
|
|
51
|
+
this.submitEdit(value);
|
|
52
|
+
} else {
|
|
53
|
+
this.submitAdd(value);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
this.input.onEscape = () => {
|
|
57
|
+
this.mode = "list";
|
|
58
|
+
this.editIndex = -1;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private submitAdd(value: string) {
|
|
63
|
+
const trimmed = value.trim();
|
|
64
|
+
if (!trimmed) {
|
|
65
|
+
this.mode = "list";
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
this.items.push(trimmed);
|
|
69
|
+
this.selectedIndex = this.items.length - 1;
|
|
70
|
+
this.save();
|
|
71
|
+
this.mode = "list";
|
|
72
|
+
this.input.setValue("");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private submitEdit(value: string) {
|
|
76
|
+
const trimmed = value.trim();
|
|
77
|
+
if (!trimmed) {
|
|
78
|
+
// Empty value = cancel edit
|
|
79
|
+
this.mode = "list";
|
|
80
|
+
this.editIndex = -1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.items[this.editIndex] = trimmed;
|
|
84
|
+
this.save();
|
|
85
|
+
this.mode = "list";
|
|
86
|
+
this.editIndex = -1;
|
|
87
|
+
this.input.setValue("");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private deleteSelected() {
|
|
91
|
+
if (this.items.length === 0) return;
|
|
92
|
+
this.items.splice(this.selectedIndex, 1);
|
|
93
|
+
if (this.selectedIndex >= this.items.length) {
|
|
94
|
+
this.selectedIndex = Math.max(0, this.items.length - 1);
|
|
95
|
+
}
|
|
96
|
+
this.save();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private startEdit() {
|
|
100
|
+
if (this.items.length === 0) return;
|
|
101
|
+
this.editIndex = this.selectedIndex;
|
|
102
|
+
this.mode = "edit";
|
|
103
|
+
this.input.setValue(this.items[this.selectedIndex] as string);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private save() {
|
|
107
|
+
this.onSave([...this.items]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
invalidate() {}
|
|
111
|
+
|
|
112
|
+
render(width: number): string[] {
|
|
113
|
+
const lines: string[] = [];
|
|
114
|
+
|
|
115
|
+
// Header
|
|
116
|
+
lines.push(this.theme.label(` ${this.label}`, true));
|
|
117
|
+
lines.push("");
|
|
118
|
+
|
|
119
|
+
if (this.mode === "add" || this.mode === "edit") {
|
|
120
|
+
return [...lines, ...this.renderInputMode(width)];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return [...lines, ...this.renderListMode(width)];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private renderListMode(width: number): string[] {
|
|
127
|
+
const lines: string[] = [];
|
|
128
|
+
|
|
129
|
+
if (this.items.length === 0) {
|
|
130
|
+
lines.push(this.theme.hint(" (empty)"));
|
|
131
|
+
} else {
|
|
132
|
+
const startIndex = Math.max(
|
|
133
|
+
0,
|
|
134
|
+
Math.min(
|
|
135
|
+
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
|
136
|
+
this.items.length - this.maxVisible,
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
const endIndex = Math.min(
|
|
140
|
+
startIndex + this.maxVisible,
|
|
141
|
+
this.items.length,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
145
|
+
const item = this.items[i];
|
|
146
|
+
if (!item) continue;
|
|
147
|
+
const isSelected = i === this.selectedIndex;
|
|
148
|
+
const prefix = isSelected ? this.theme.cursor : " ";
|
|
149
|
+
const prefixWidth = visibleWidth(prefix);
|
|
150
|
+
const maxItemWidth = width - prefixWidth - 2;
|
|
151
|
+
const text = this.theme.value(
|
|
152
|
+
truncateToWidth(item, maxItemWidth, ""),
|
|
153
|
+
isSelected,
|
|
154
|
+
);
|
|
155
|
+
lines.push(prefix + text);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (startIndex > 0 || endIndex < this.items.length) {
|
|
159
|
+
lines.push(
|
|
160
|
+
this.theme.hint(` (${this.selectedIndex + 1}/${this.items.length})`),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
lines.push("");
|
|
166
|
+
lines.push(
|
|
167
|
+
this.theme.hint(" a: add · e/Enter: edit · d: delete · Esc: back"),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return lines;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private renderInputMode(width: number): string[] {
|
|
174
|
+
const lines: string[] = [];
|
|
175
|
+
const label = this.mode === "edit" ? " Edit item:" : " New item:";
|
|
176
|
+
lines.push(this.theme.hint(label));
|
|
177
|
+
lines.push(` ${this.input.render(width - 4).join("")}`);
|
|
178
|
+
lines.push("");
|
|
179
|
+
lines.push(this.theme.hint(" Enter: confirm · Esc: cancel"));
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
handleInput(data: string) {
|
|
184
|
+
if (this.mode === "add" || this.mode === "edit") {
|
|
185
|
+
this.input.handleInput(data);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// List mode
|
|
190
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
191
|
+
if (this.items.length === 0) return;
|
|
192
|
+
this.selectedIndex =
|
|
193
|
+
this.selectedIndex === 0
|
|
194
|
+
? this.items.length - 1
|
|
195
|
+
: this.selectedIndex - 1;
|
|
196
|
+
} else if (matchesKey(data, Key.down) || data === "j") {
|
|
197
|
+
if (this.items.length === 0) return;
|
|
198
|
+
this.selectedIndex =
|
|
199
|
+
this.selectedIndex === this.items.length - 1
|
|
200
|
+
? 0
|
|
201
|
+
: this.selectedIndex + 1;
|
|
202
|
+
} else if (data === "a" || data === "A") {
|
|
203
|
+
this.mode = "add";
|
|
204
|
+
this.input.setValue("");
|
|
205
|
+
} else if (data === "e" || data === "E" || matchesKey(data, Key.enter)) {
|
|
206
|
+
this.startEdit();
|
|
207
|
+
} else if (data === "d" || data === "D") {
|
|
208
|
+
this.deleteSelected();
|
|
209
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
210
|
+
this.onDone();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
package/config-schema.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
export interface GuardrailsConfig {
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
features?: {
|
|
11
|
+
preventBrew?: boolean;
|
|
12
|
+
protectEnvFiles?: boolean;
|
|
13
|
+
permissionGate?: boolean;
|
|
14
|
+
};
|
|
15
|
+
envFiles?: {
|
|
16
|
+
protectedPatterns?: string[];
|
|
17
|
+
allowedPatterns?: string[];
|
|
18
|
+
protectedDirectories?: string[];
|
|
19
|
+
protectedTools?: string[];
|
|
20
|
+
onlyBlockIfExists?: boolean;
|
|
21
|
+
blockMessage?: string;
|
|
22
|
+
};
|
|
23
|
+
permissionGate?: {
|
|
24
|
+
patterns?: Array<{ pattern: string; description: string }>;
|
|
25
|
+
/** If set, replaces the default patterns entirely. */
|
|
26
|
+
customPatterns?: Array<{ pattern: string; description: string }>;
|
|
27
|
+
requireConfirmation?: boolean;
|
|
28
|
+
allowedPatterns?: string[];
|
|
29
|
+
autoDenyPatterns?: string[];
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ResolvedConfig {
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
features: {
|
|
36
|
+
preventBrew: boolean;
|
|
37
|
+
protectEnvFiles: boolean;
|
|
38
|
+
permissionGate: boolean;
|
|
39
|
+
};
|
|
40
|
+
envFiles: {
|
|
41
|
+
protectedPatterns: string[];
|
|
42
|
+
allowedPatterns: string[];
|
|
43
|
+
protectedDirectories: string[];
|
|
44
|
+
protectedTools: string[];
|
|
45
|
+
onlyBlockIfExists: boolean;
|
|
46
|
+
blockMessage: string;
|
|
47
|
+
};
|
|
48
|
+
permissionGate: {
|
|
49
|
+
patterns: Array<{ pattern: string; description: string }>;
|
|
50
|
+
requireConfirmation: boolean;
|
|
51
|
+
allowedPatterns: string[];
|
|
52
|
+
autoDenyPatterns: string[];
|
|
53
|
+
};
|
|
54
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
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";
|
|
5
|
+
|
|
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
|
+
);
|
|
14
|
+
|
|
15
|
+
const DEFAULT_CONFIG: ResolvedConfig = {
|
|
16
|
+
enabled: true,
|
|
17
|
+
features: {
|
|
18
|
+
preventBrew: false,
|
|
19
|
+
protectEnvFiles: true,
|
|
20
|
+
permissionGate: true,
|
|
21
|
+
},
|
|
22
|
+
envFiles: {
|
|
23
|
+
protectedPatterns: ["\\.env$", "\\.env\\.local$"],
|
|
24
|
+
allowedPatterns: [
|
|
25
|
+
"\\.(example|sample|test)\\.env$",
|
|
26
|
+
"\\.env\\.(example|sample|test)$",
|
|
27
|
+
],
|
|
28
|
+
protectedDirectories: [],
|
|
29
|
+
protectedTools: ["read", "write", "edit", "bash", "grep", "find", "ls"],
|
|
30
|
+
onlyBlockIfExists: true,
|
|
31
|
+
blockMessage:
|
|
32
|
+
"Accessing {file} is not allowed. Environment files containing secrets are protected. " +
|
|
33
|
+
"Explain to the user why you want to access this .env file, and if changes are needed ask the user to make them. " +
|
|
34
|
+
"Only .env.example, .env.sample, or .env.test files can be accessed.",
|
|
35
|
+
},
|
|
36
|
+
permissionGate: {
|
|
37
|
+
patterns: [
|
|
38
|
+
{ pattern: "rm\\s+-rf", description: "recursive force delete" },
|
|
39
|
+
{ pattern: "\\bsudo\\b", description: "superuser command" },
|
|
40
|
+
{ pattern: ":\\s*\\|\\s*sh", description: "piped shell execution" },
|
|
41
|
+
{ pattern: "\\bdd\\s+if=", description: "disk write operation" },
|
|
42
|
+
{ pattern: "mkfs\\.", description: "filesystem format" },
|
|
43
|
+
{
|
|
44
|
+
pattern: "\\bchmod\\s+-R\\s+777",
|
|
45
|
+
description: "insecure recursive permissions",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
pattern: "\\bchown\\s+-R",
|
|
49
|
+
description: "recursive ownership change",
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
requireConfirmation: true,
|
|
53
|
+
allowedPatterns: [],
|
|
54
|
+
autoDenyPatterns: [],
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
class ConfigLoader {
|
|
59
|
+
private globalConfig: GuardrailsConfig | null = null;
|
|
60
|
+
private projectConfig: GuardrailsConfig | null = null;
|
|
61
|
+
private resolved: ResolvedConfig | null = null;
|
|
62
|
+
|
|
63
|
+
async load(): Promise<void> {
|
|
64
|
+
this.globalConfig = await this.loadConfigFile(GLOBAL_CONFIG_PATH);
|
|
65
|
+
this.projectConfig = await this.loadConfigFile(PROJECT_CONFIG_PATH);
|
|
66
|
+
this.resolved = this.mergeConfigs();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async loadConfigFile(path: string): Promise<GuardrailsConfig | null> {
|
|
70
|
+
try {
|
|
71
|
+
const content = await readFile(path, "utf-8");
|
|
72
|
+
return JSON.parse(content) as GuardrailsConfig;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private mergeConfigs(): ResolvedConfig {
|
|
79
|
+
const merged = structuredClone(DEFAULT_CONFIG);
|
|
80
|
+
|
|
81
|
+
if (this.globalConfig) {
|
|
82
|
+
this.mergeInto(merged, this.globalConfig);
|
|
83
|
+
}
|
|
84
|
+
if (this.projectConfig) {
|
|
85
|
+
this.mergeInto(merged, this.projectConfig);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// customPatterns replaces entire patterns array
|
|
89
|
+
if (this.projectConfig?.permissionGate?.customPatterns) {
|
|
90
|
+
merged.permissionGate.patterns =
|
|
91
|
+
this.projectConfig.permissionGate.customPatterns;
|
|
92
|
+
} else if (this.globalConfig?.permissionGate?.customPatterns) {
|
|
93
|
+
merged.permissionGate.patterns =
|
|
94
|
+
this.globalConfig.permissionGate.customPatterns;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return merged;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
101
|
+
private mergeInto(target: any, source: any): void {
|
|
102
|
+
for (const key in source) {
|
|
103
|
+
if (source[key] === undefined) continue;
|
|
104
|
+
|
|
105
|
+
if (
|
|
106
|
+
typeof source[key] === "object" &&
|
|
107
|
+
!Array.isArray(source[key]) &&
|
|
108
|
+
source[key] !== null
|
|
109
|
+
) {
|
|
110
|
+
if (!target[key]) target[key] = {};
|
|
111
|
+
this.mergeInto(target[key], source[key]);
|
|
112
|
+
} else {
|
|
113
|
+
target[key] = source[key];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
getConfig(): ResolvedConfig {
|
|
119
|
+
if (!this.resolved) {
|
|
120
|
+
throw new Error("Config not loaded. Call load() first.");
|
|
121
|
+
}
|
|
122
|
+
return this.resolved;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async saveGlobal(config: GuardrailsConfig): Promise<void> {
|
|
126
|
+
await this.saveConfigFile(GLOBAL_CONFIG_PATH, config);
|
|
127
|
+
await this.load();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async saveProject(config: GuardrailsConfig): Promise<void> {
|
|
131
|
+
await this.saveConfigFile(PROJECT_CONFIG_PATH, config);
|
|
132
|
+
await this.load();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private async saveConfigFile(
|
|
136
|
+
path: string,
|
|
137
|
+
config: GuardrailsConfig,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
await mkdir(dirname(path), { recursive: true });
|
|
140
|
+
await writeFile(path, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
hasGlobalConfig(): boolean {
|
|
144
|
+
return this.globalConfig !== null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
hasProjectConfig(): boolean {
|
|
148
|
+
return this.projectConfig !== null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
getGlobalConfig(): GuardrailsConfig {
|
|
152
|
+
return this.globalConfig ?? {};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
getProjectConfig(): GuardrailsConfig {
|
|
156
|
+
return this.projectConfig ?? {};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const configLoader = new ConfigLoader();
|
package/events.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const GUARDRAILS_BLOCKED_EVENT = "guardrails:blocked";
|
|
4
|
+
export const GUARDRAILS_DANGEROUS_EVENT = "guardrails:dangerous";
|
|
5
|
+
|
|
6
|
+
export interface GuardrailsBlockedEvent {
|
|
7
|
+
feature: "preventBrew" | "protectEnvFiles" | "permissionGate";
|
|
8
|
+
toolName: string;
|
|
9
|
+
input: Record<string, unknown>;
|
|
10
|
+
reason: string;
|
|
11
|
+
userDenied?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GuardrailsDangerousEvent {
|
|
15
|
+
command: string;
|
|
16
|
+
description: string;
|
|
17
|
+
pattern: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function emitBlocked(
|
|
21
|
+
pi: ExtensionAPI,
|
|
22
|
+
event: GuardrailsBlockedEvent,
|
|
23
|
+
): void {
|
|
24
|
+
pi.events.emit(GUARDRAILS_BLOCKED_EVENT, event);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function emitDangerous(
|
|
28
|
+
pi: ExtensionAPI,
|
|
29
|
+
event: GuardrailsDangerousEvent,
|
|
30
|
+
): void {
|
|
31
|
+
pi.events.emit(GUARDRAILS_DANGEROUS_EVENT, event);
|
|
32
|
+
}
|
package/hooks/index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { ResolvedConfig } from "../config-schema";
|
|
2
3
|
import { setupPermissionGateHook } from "./permission-gate";
|
|
3
4
|
import { setupPreventBrewHook } from "./prevent-brew";
|
|
4
5
|
import { setupProtectEnvFilesHook } from "./protect-env-files";
|
|
5
6
|
|
|
6
|
-
export function setupGuardrailsHooks(pi: ExtensionAPI) {
|
|
7
|
-
setupPreventBrewHook(pi);
|
|
8
|
-
setupProtectEnvFilesHook(pi);
|
|
9
|
-
setupPermissionGateHook(pi);
|
|
7
|
+
export function setupGuardrailsHooks(pi: ExtensionAPI, config: ResolvedConfig) {
|
|
8
|
+
setupPreventBrewHook(pi, config);
|
|
9
|
+
setupProtectEnvFilesHook(pi, config);
|
|
10
|
+
setupPermissionGateHook(pi, config);
|
|
10
11
|
}
|