@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 +63 -94
- package/commands/settings-command.ts +278 -0
- package/{pattern-editor.ts → components/pattern-editor.ts} +61 -10
- package/config.ts +185 -142
- package/hooks/index.ts +1 -7
- package/hooks/permission-gate.ts +247 -143
- package/hooks/protect-env-files.ts +122 -45
- package/index.ts +6 -3
- package/package.json +9 -3
- package/{events.ts → utils/events.ts} +1 -6
- package/utils/glob-expander.ts +128 -0
- package/utils/matching.ts +119 -0
- package/utils/migration.ts +135 -0
- package/utils/shell-utils.ts +139 -0
- package/array-editor.ts +0 -213
- package/config-schema.ts +0 -64
- package/hooks/enforce-package-manager.ts +0 -96
- package/hooks/prevent-brew.ts +0 -41
- package/hooks/prevent-python.ts +0 -45
- package/sectioned-settings.ts +0 -345
- package/settings-command.ts +0 -458
package/hooks/permission-gate.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parse } from "@aliou/sh";
|
|
1
2
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
3
|
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
3
4
|
import {
|
|
@@ -8,66 +9,161 @@ import {
|
|
|
8
9
|
Text,
|
|
9
10
|
wrapTextWithAnsi,
|
|
10
11
|
} from "@mariozechner/pi-tui";
|
|
11
|
-
import type { ResolvedConfig } from "../config
|
|
12
|
-
import { emitBlocked, emitDangerous } from "../events";
|
|
12
|
+
import type { DangerousPattern, ResolvedConfig } from "../config";
|
|
13
|
+
import { emitBlocked, emitDangerous } from "../utils/events";
|
|
14
|
+
import {
|
|
15
|
+
type CompiledPattern,
|
|
16
|
+
compileCommandPatterns,
|
|
17
|
+
} from "../utils/matching";
|
|
18
|
+
import { walkCommands, wordToString } from "../utils/shell-utils";
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
21
|
* Permission gate that prompts user confirmation for dangerous commands.
|
|
16
|
-
*
|
|
22
|
+
*
|
|
23
|
+
* Built-in dangerous patterns are matched structurally via AST parsing.
|
|
24
|
+
* User custom patterns use substring/regex matching on the raw string.
|
|
25
|
+
* Allowed/auto-deny patterns match against the raw command string.
|
|
17
26
|
*/
|
|
18
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Structural matcher for a built-in dangerous command.
|
|
30
|
+
* Returns a description if matched, undefined otherwise.
|
|
31
|
+
*/
|
|
32
|
+
type StructuralMatcher = (words: string[]) => string | undefined;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Built-in dangerous command matchers. These check the parsed command
|
|
36
|
+
* structure instead of regex against the raw string.
|
|
37
|
+
*/
|
|
38
|
+
const BUILTIN_MATCHERS: StructuralMatcher[] = [
|
|
39
|
+
// rm -rf
|
|
40
|
+
(words) => {
|
|
41
|
+
if (words[0] !== "rm") return undefined;
|
|
42
|
+
const hasRF = words.some(
|
|
43
|
+
(w) =>
|
|
44
|
+
w === "-rf" ||
|
|
45
|
+
w === "-fr" ||
|
|
46
|
+
(w.startsWith("-") && w.includes("r") && w.includes("f")),
|
|
47
|
+
);
|
|
48
|
+
return hasRF ? "recursive force delete" : undefined;
|
|
49
|
+
},
|
|
50
|
+
// sudo
|
|
51
|
+
(words) => (words[0] === "sudo" ? "superuser command" : undefined),
|
|
52
|
+
// dd if=
|
|
53
|
+
(words) => {
|
|
54
|
+
if (words[0] !== "dd") return undefined;
|
|
55
|
+
return words.some((w) => w.startsWith("if="))
|
|
56
|
+
? "disk write operation"
|
|
57
|
+
: undefined;
|
|
58
|
+
},
|
|
59
|
+
// mkfs.*
|
|
60
|
+
(words) => (words[0]?.startsWith("mkfs.") ? "filesystem format" : undefined),
|
|
61
|
+
// chmod -R 777
|
|
62
|
+
(words) => {
|
|
63
|
+
if (words[0] !== "chmod") return undefined;
|
|
64
|
+
return words.includes("-R") && words.includes("777")
|
|
65
|
+
? "insecure recursive permissions"
|
|
66
|
+
: undefined;
|
|
67
|
+
},
|
|
68
|
+
// chown -R
|
|
69
|
+
(words) => {
|
|
70
|
+
if (words[0] !== "chown") return undefined;
|
|
71
|
+
return words.includes("-R") ? "recursive ownership change" : undefined;
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
interface DangerMatch {
|
|
76
|
+
description: string;
|
|
77
|
+
pattern: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check a parsed command against built-in structural matchers.
|
|
82
|
+
*/
|
|
83
|
+
function checkBuiltinDangerous(words: string[]): DangerMatch | undefined {
|
|
84
|
+
if (words.length === 0) return undefined;
|
|
85
|
+
for (const matcher of BUILTIN_MATCHERS) {
|
|
86
|
+
const desc = matcher(words);
|
|
87
|
+
if (desc) return { description: desc, pattern: "(structural)" };
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check a command string against dangerous patterns.
|
|
94
|
+
*
|
|
95
|
+
* When useBuiltinMatchers is true (default patterns): tries structural AST
|
|
96
|
+
* matching first, falls back to substring match on parse failure.
|
|
97
|
+
*
|
|
98
|
+
* When useBuiltinMatchers is false (customPatterns replaced defaults): skips
|
|
99
|
+
* structural matchers entirely, uses compiled patterns (substring/regex)
|
|
100
|
+
* against the raw command string.
|
|
101
|
+
*/
|
|
102
|
+
function findDangerousMatch(
|
|
103
|
+
command: string,
|
|
104
|
+
compiledPatterns: CompiledPattern[],
|
|
105
|
+
useBuiltinMatchers: boolean,
|
|
106
|
+
fallbackPatterns: DangerousPattern[],
|
|
107
|
+
): DangerMatch | undefined {
|
|
108
|
+
if (useBuiltinMatchers) {
|
|
109
|
+
// Try structural matching first
|
|
110
|
+
try {
|
|
111
|
+
const { ast } = parse(command);
|
|
112
|
+
let match: DangerMatch | undefined;
|
|
113
|
+
walkCommands(ast, (cmd) => {
|
|
114
|
+
const words = (cmd.words ?? []).map(wordToString);
|
|
115
|
+
const result = checkBuiltinDangerous(words);
|
|
116
|
+
if (result) {
|
|
117
|
+
match = result;
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
});
|
|
122
|
+
if (match) return match;
|
|
123
|
+
} catch {
|
|
124
|
+
// Parse failed -- fall back to substring matching on raw string
|
|
125
|
+
for (const p of fallbackPatterns) {
|
|
126
|
+
if (command.includes(p.pattern)) {
|
|
127
|
+
return { description: p.description, pattern: p.pattern };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check compiled patterns (substring/regex on raw string).
|
|
134
|
+
// When customPatterns replaces defaults, this is the only matching path.
|
|
135
|
+
for (const cp of compiledPatterns) {
|
|
136
|
+
if (cp.test(command)) {
|
|
137
|
+
const src = cp.source as DangerousPattern;
|
|
138
|
+
return { description: src.description, pattern: src.pattern };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
19
145
|
export function setupPermissionGateHook(
|
|
20
146
|
pi: ExtensionAPI,
|
|
21
147
|
config: ResolvedConfig,
|
|
22
148
|
) {
|
|
23
149
|
if (!config.features.permissionGate) return;
|
|
24
150
|
|
|
25
|
-
// Compile patterns
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
} catch {
|
|
35
|
-
console.error(
|
|
36
|
-
`Invalid regex in guardrails permission-gate config: ${p.pattern}`,
|
|
37
|
-
);
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
})
|
|
41
|
-
.filter(
|
|
42
|
-
(p): p is { pattern: RegExp; description: string; rawPattern: string } =>
|
|
43
|
-
p !== null,
|
|
44
|
-
);
|
|
151
|
+
// Compile all configured patterns for substring/regex matching.
|
|
152
|
+
// When useBuiltinMatchers is true (defaults), these act as a supplement
|
|
153
|
+
// to the structural matchers. When false (customPatterns), these are the
|
|
154
|
+
// only matching path.
|
|
155
|
+
const compiledPatterns = compileCommandPatterns(
|
|
156
|
+
config.permissionGate.patterns,
|
|
157
|
+
);
|
|
158
|
+
const { useBuiltinMatchers } = config.permissionGate;
|
|
159
|
+
const fallbackPatterns = config.permissionGate.patterns;
|
|
45
160
|
|
|
46
|
-
const allowedPatterns =
|
|
47
|
-
.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
`Invalid regex in guardrails allowedPatterns config: ${p}`,
|
|
53
|
-
);
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
})
|
|
57
|
-
.filter((r): r is RegExp => r !== null);
|
|
58
|
-
|
|
59
|
-
const autoDenyPatterns = config.permissionGate.autoDenyPatterns
|
|
60
|
-
.map((p) => {
|
|
61
|
-
try {
|
|
62
|
-
return new RegExp(p);
|
|
63
|
-
} catch {
|
|
64
|
-
console.error(
|
|
65
|
-
`Invalid regex in guardrails autoDenyPatterns config: ${p}`,
|
|
66
|
-
);
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
})
|
|
70
|
-
.filter((r): r is RegExp => r !== null);
|
|
161
|
+
const allowedPatterns = compileCommandPatterns(
|
|
162
|
+
config.permissionGate.allowedPatterns,
|
|
163
|
+
);
|
|
164
|
+
const autoDenyPatterns = compileCommandPatterns(
|
|
165
|
+
config.permissionGate.autoDenyPatterns,
|
|
166
|
+
);
|
|
71
167
|
|
|
72
168
|
pi.on("tool_call", async (event, ctx) => {
|
|
73
169
|
if (event.toolName !== "bash") return;
|
|
@@ -98,104 +194,112 @@ export function setupPermissionGateHook(
|
|
|
98
194
|
}
|
|
99
195
|
}
|
|
100
196
|
|
|
101
|
-
// Check dangerous patterns
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const redBorder = (s: string) => theme.fg("error", s);
|
|
112
|
-
|
|
113
|
-
container.addChild(new DynamicBorder(redBorder));
|
|
114
|
-
container.addChild(
|
|
115
|
-
new Text(
|
|
116
|
-
theme.fg("error", theme.bold("Dangerous Command Detected")),
|
|
117
|
-
1,
|
|
118
|
-
0,
|
|
119
|
-
),
|
|
120
|
-
);
|
|
121
|
-
container.addChild(new Spacer(1));
|
|
122
|
-
container.addChild(
|
|
123
|
-
new Text(
|
|
124
|
-
theme.fg("warning", `This command contains ${description}:`),
|
|
125
|
-
1,
|
|
126
|
-
0,
|
|
127
|
-
),
|
|
128
|
-
);
|
|
129
|
-
container.addChild(new Spacer(1));
|
|
130
|
-
container.addChild(
|
|
131
|
-
new DynamicBorder((s: string) => theme.fg("muted", s)),
|
|
132
|
-
);
|
|
133
|
-
const commandText = new Text("", 1, 0);
|
|
134
|
-
container.addChild(commandText);
|
|
135
|
-
container.addChild(
|
|
136
|
-
new DynamicBorder((s: string) => theme.fg("muted", s)),
|
|
137
|
-
);
|
|
138
|
-
container.addChild(new Spacer(1));
|
|
139
|
-
container.addChild(
|
|
140
|
-
new Text(theme.fg("text", "Allow execution?"), 1, 0),
|
|
141
|
-
);
|
|
142
|
-
container.addChild(new Spacer(1));
|
|
143
|
-
container.addChild(
|
|
144
|
-
new Text(theme.fg("dim", "y/enter: allow • n/esc: deny"), 1, 0),
|
|
145
|
-
);
|
|
146
|
-
container.addChild(new DynamicBorder(redBorder));
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
render: (width: number) => {
|
|
150
|
-
const wrappedCommand = wrapTextWithAnsi(
|
|
151
|
-
theme.fg("text", command),
|
|
152
|
-
width - 4,
|
|
153
|
-
).join("\n");
|
|
154
|
-
commandText.setText(wrappedCommand);
|
|
155
|
-
return container.render(width);
|
|
156
|
-
},
|
|
157
|
-
invalidate: () => container.invalidate(),
|
|
158
|
-
handleInput: (data: string) => {
|
|
159
|
-
if (
|
|
160
|
-
matchesKey(data, Key.enter) ||
|
|
161
|
-
data === "y" ||
|
|
162
|
-
data === "Y"
|
|
163
|
-
) {
|
|
164
|
-
done(true);
|
|
165
|
-
} else if (
|
|
166
|
-
matchesKey(data, Key.escape) ||
|
|
167
|
-
data === "n" ||
|
|
168
|
-
data === "N"
|
|
169
|
-
) {
|
|
170
|
-
done(false);
|
|
171
|
-
}
|
|
172
|
-
},
|
|
173
|
-
};
|
|
174
|
-
},
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
if (!proceed) {
|
|
178
|
-
emitBlocked(pi, {
|
|
179
|
-
feature: "permissionGate",
|
|
180
|
-
toolName: "bash",
|
|
181
|
-
input: event.input,
|
|
182
|
-
reason: "User denied dangerous command",
|
|
183
|
-
userDenied: true,
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
return { block: true, reason: "User denied dangerous command" };
|
|
187
|
-
}
|
|
188
|
-
} else {
|
|
189
|
-
// No confirmation required - just notify and allow
|
|
190
|
-
ctx.ui.notify(
|
|
191
|
-
`Dangerous command detected: ${description}`,
|
|
192
|
-
"warning",
|
|
193
|
-
);
|
|
194
|
-
}
|
|
197
|
+
// Check dangerous patterns (structural + compiled)
|
|
198
|
+
const match = findDangerousMatch(
|
|
199
|
+
command,
|
|
200
|
+
compiledPatterns,
|
|
201
|
+
useBuiltinMatchers,
|
|
202
|
+
fallbackPatterns,
|
|
203
|
+
);
|
|
204
|
+
if (!match) return;
|
|
205
|
+
|
|
206
|
+
const { description, pattern: rawPattern } = match;
|
|
195
207
|
|
|
196
|
-
|
|
208
|
+
// Emit dangerous event (presenter will play sound)
|
|
209
|
+
emitDangerous(pi, { command, description, pattern: rawPattern });
|
|
210
|
+
|
|
211
|
+
if (config.permissionGate.requireConfirmation) {
|
|
212
|
+
// In print/RPC mode, block by default (safe fallback)
|
|
213
|
+
if (!ctx.hasUI) {
|
|
214
|
+
const reason = `Dangerous command blocked (no UI to confirm): ${description}`;
|
|
215
|
+
emitBlocked(pi, {
|
|
216
|
+
feature: "permissionGate",
|
|
217
|
+
toolName: "bash",
|
|
218
|
+
input: event.input,
|
|
219
|
+
reason,
|
|
220
|
+
});
|
|
221
|
+
return { block: true, reason };
|
|
197
222
|
}
|
|
223
|
+
|
|
224
|
+
const proceed = await ctx.ui.custom<boolean>((_tui, theme, _kb, done) => {
|
|
225
|
+
const container = new Container();
|
|
226
|
+
const redBorder = (s: string) => theme.fg("error", s);
|
|
227
|
+
|
|
228
|
+
container.addChild(new DynamicBorder(redBorder));
|
|
229
|
+
container.addChild(
|
|
230
|
+
new Text(
|
|
231
|
+
theme.fg("error", theme.bold("Dangerous Command Detected")),
|
|
232
|
+
1,
|
|
233
|
+
0,
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
container.addChild(new Spacer(1));
|
|
237
|
+
container.addChild(
|
|
238
|
+
new Text(
|
|
239
|
+
theme.fg("warning", `This command contains ${description}:`),
|
|
240
|
+
1,
|
|
241
|
+
0,
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
container.addChild(new Spacer(1));
|
|
245
|
+
container.addChild(
|
|
246
|
+
new DynamicBorder((s: string) => theme.fg("muted", s)),
|
|
247
|
+
);
|
|
248
|
+
const commandText = new Text("", 1, 0);
|
|
249
|
+
container.addChild(commandText);
|
|
250
|
+
container.addChild(
|
|
251
|
+
new DynamicBorder((s: string) => theme.fg("muted", s)),
|
|
252
|
+
);
|
|
253
|
+
container.addChild(new Spacer(1));
|
|
254
|
+
container.addChild(
|
|
255
|
+
new Text(theme.fg("text", "Allow execution?"), 1, 0),
|
|
256
|
+
);
|
|
257
|
+
container.addChild(new Spacer(1));
|
|
258
|
+
container.addChild(
|
|
259
|
+
new Text(theme.fg("dim", "y/enter: allow • n/esc: deny"), 1, 0),
|
|
260
|
+
);
|
|
261
|
+
container.addChild(new DynamicBorder(redBorder));
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
render: (width: number) => {
|
|
265
|
+
const wrappedCommand = wrapTextWithAnsi(
|
|
266
|
+
theme.fg("text", command),
|
|
267
|
+
width - 4,
|
|
268
|
+
).join("\n");
|
|
269
|
+
commandText.setText(wrappedCommand);
|
|
270
|
+
return container.render(width);
|
|
271
|
+
},
|
|
272
|
+
invalidate: () => container.invalidate(),
|
|
273
|
+
handleInput: (data: string) => {
|
|
274
|
+
if (matchesKey(data, Key.enter) || data === "y" || data === "Y") {
|
|
275
|
+
done(true);
|
|
276
|
+
} else if (
|
|
277
|
+
matchesKey(data, Key.escape) ||
|
|
278
|
+
data === "n" ||
|
|
279
|
+
data === "N"
|
|
280
|
+
) {
|
|
281
|
+
done(false);
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (!proceed) {
|
|
288
|
+
emitBlocked(pi, {
|
|
289
|
+
feature: "permissionGate",
|
|
290
|
+
toolName: "bash",
|
|
291
|
+
input: event.input,
|
|
292
|
+
reason: "User denied dangerous command",
|
|
293
|
+
userDenied: true,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
return { block: true, reason: "User denied dangerous command" };
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
// No confirmation required - just notify and allow
|
|
300
|
+
ctx.ui.notify(`Dangerous command detected: ${description}`, "warning");
|
|
198
301
|
}
|
|
302
|
+
|
|
199
303
|
return;
|
|
200
304
|
});
|
|
201
305
|
}
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { stat } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { parse } from "@aliou/sh";
|
|
3
4
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
4
|
-
import type { ResolvedConfig } from "../config
|
|
5
|
-
import { emitBlocked } from "../events";
|
|
5
|
+
import type { ResolvedConfig } from "../config";
|
|
6
|
+
import { emitBlocked } from "../utils/events";
|
|
7
|
+
import { expandGlob, hasGlobChars } from "../utils/glob-expander";
|
|
8
|
+
import { type CompiledPattern, compileFilePatterns } from "../utils/matching";
|
|
9
|
+
import { walkCommands, wordToString } from "../utils/shell-utils";
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* Prevents accessing .env files unless they match an allowed pattern.
|
|
9
13
|
* Protects sensitive environment files from being accessed accidentally.
|
|
10
14
|
*
|
|
11
|
-
*
|
|
15
|
+
* Uses AST-based parsing for bash commands to extract file references,
|
|
16
|
+
* with glob expansion via `fd` when args contain shell glob characters.
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
19
|
async function fileExists(filePath: string): Promise<boolean> {
|
|
@@ -20,48 +25,115 @@ async function fileExists(filePath: string): Promise<boolean> {
|
|
|
20
25
|
}
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
function compilePatterns(patterns: string[]): RegExp[] {
|
|
24
|
-
return patterns
|
|
25
|
-
.map((p) => {
|
|
26
|
-
try {
|
|
27
|
-
return new RegExp(p, "i");
|
|
28
|
-
} catch {
|
|
29
|
-
console.error(`Invalid regex pattern in guardrails config: ${p}`);
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
})
|
|
33
|
-
.filter((r): r is RegExp => r !== null);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
28
|
async function isProtectedEnvFile(
|
|
37
29
|
filePath: string,
|
|
38
|
-
|
|
30
|
+
protectedPatterns: CompiledPattern[],
|
|
31
|
+
allowedPatterns: CompiledPattern[],
|
|
32
|
+
dirPatterns: CompiledPattern[],
|
|
33
|
+
onlyBlockIfExists: boolean,
|
|
39
34
|
): Promise<boolean> {
|
|
40
|
-
const
|
|
41
|
-
const isProtected = protectedRegexes.some((r) => r.test(filePath));
|
|
35
|
+
const isProtected = protectedPatterns.some((p) => p.test(filePath));
|
|
42
36
|
if (!isProtected) return false;
|
|
43
37
|
|
|
44
|
-
const
|
|
45
|
-
const isAllowed = allowedRegexes.some((r) => r.test(filePath));
|
|
38
|
+
const isAllowed = allowedPatterns.some((p) => p.test(filePath));
|
|
46
39
|
if (isAllowed) return false;
|
|
47
40
|
|
|
48
41
|
// Check protected directories (if any configured)
|
|
49
|
-
if (
|
|
50
|
-
const
|
|
51
|
-
const inProtectedDir = dirRegexes.some((r) => r.test(filePath));
|
|
42
|
+
if (dirPatterns.length > 0) {
|
|
43
|
+
const inProtectedDir = dirPatterns.some((p) => p.test(filePath));
|
|
52
44
|
if (inProtectedDir) {
|
|
53
|
-
return
|
|
54
|
-
|
|
55
|
-
|
|
45
|
+
return onlyBlockIfExists ? await fileExists(filePath) : true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return onlyBlockIfExists ? await fileExists(filePath) : true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract file references from a bash command using AST parsing.
|
|
54
|
+
* Falls back to regex extraction on parse failure.
|
|
55
|
+
*/
|
|
56
|
+
async function extractBashFileTargets(command: string): Promise<string[]> {
|
|
57
|
+
try {
|
|
58
|
+
const { ast } = parse(command);
|
|
59
|
+
const files: string[] = [];
|
|
60
|
+
|
|
61
|
+
walkCommands(ast, (cmd) => {
|
|
62
|
+
const words = (cmd.words ?? []).map(wordToString);
|
|
63
|
+
// Skip command name (words[0]), check args for env file references
|
|
64
|
+
for (let i = 1; i < words.length; i++) {
|
|
65
|
+
const arg = words[i] as string;
|
|
66
|
+
if (isEnvLikeReference(arg)) {
|
|
67
|
+
files.push(arg);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Also check redirect targets
|
|
72
|
+
for (const redir of cmd.redirects ?? []) {
|
|
73
|
+
const target = wordToString(redir.target);
|
|
74
|
+
if (isEnvLikeReference(target)) {
|
|
75
|
+
files.push(target);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Expand globs
|
|
82
|
+
const expanded: string[] = [];
|
|
83
|
+
for (const file of files) {
|
|
84
|
+
if (hasGlobChars(file)) {
|
|
85
|
+
const matches = await expandGlob(file);
|
|
86
|
+
if (matches.length > 0) {
|
|
87
|
+
expanded.push(...matches);
|
|
88
|
+
} else {
|
|
89
|
+
// Expansion returned nothing -- could be fd not found or no matches.
|
|
90
|
+
// Keep original as-is so pattern matching can still catch it.
|
|
91
|
+
expanded.push(file);
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
expanded.push(file);
|
|
95
|
+
}
|
|
56
96
|
}
|
|
97
|
+
|
|
98
|
+
return expanded;
|
|
99
|
+
} catch {
|
|
100
|
+
// Fallback: regex extraction from raw string
|
|
101
|
+
return extractEnvFilesRegex(command);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if a string looks like an env file reference.
|
|
107
|
+
* Matches anything containing ".env" as a path component.
|
|
108
|
+
*/
|
|
109
|
+
function isEnvLikeReference(arg: string): boolean {
|
|
110
|
+
// Must contain ".env" somewhere
|
|
111
|
+
if (!arg.includes(".env") && !arg.includes(".dev.vars")) return false;
|
|
112
|
+
// Skip flags
|
|
113
|
+
if (arg.startsWith("-") && !arg.startsWith("-/") && !arg.startsWith("-."))
|
|
114
|
+
return false;
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Fallback regex extraction for env file references in bash commands.
|
|
120
|
+
*/
|
|
121
|
+
function extractEnvFilesRegex(command: string): string[] {
|
|
122
|
+
const files: string[] = [];
|
|
123
|
+
const envFileRegex =
|
|
124
|
+
/(?:^|\s|[<>|;&"'`])([^\s<>|;&"'`]*\.env[^\s<>|;&"'`]*)(?:\s|$|[<>|;&"'`])/gi;
|
|
125
|
+
|
|
126
|
+
for (const match of command.matchAll(envFileRegex)) {
|
|
127
|
+
const file = match[1];
|
|
128
|
+
if (file) files.push(file);
|
|
57
129
|
}
|
|
58
130
|
|
|
59
|
-
return
|
|
131
|
+
return files;
|
|
60
132
|
}
|
|
61
133
|
|
|
62
134
|
interface ToolProtectionRule {
|
|
63
135
|
tools: string[];
|
|
64
|
-
extractTargets: (input: Record<string, unknown>) => string[]
|
|
136
|
+
extractTargets: (input: Record<string, unknown>) => Promise<string[]>;
|
|
65
137
|
shouldBlock: (target: string) => Promise<boolean>;
|
|
66
138
|
blockMessage: (target: string) => string;
|
|
67
139
|
}
|
|
@@ -72,36 +144,41 @@ export function setupProtectEnvFilesHook(
|
|
|
72
144
|
) {
|
|
73
145
|
if (!config.features.protectEnvFiles) return;
|
|
74
146
|
|
|
147
|
+
const protectedPatterns = compileFilePatterns(
|
|
148
|
+
config.envFiles.protectedPatterns,
|
|
149
|
+
);
|
|
150
|
+
const allowedPatterns = compileFilePatterns(config.envFiles.allowedPatterns);
|
|
151
|
+
const dirPatterns = compileFilePatterns(config.envFiles.protectedDirectories);
|
|
152
|
+
|
|
153
|
+
const shouldBlock = (target: string) =>
|
|
154
|
+
isProtectedEnvFile(
|
|
155
|
+
target,
|
|
156
|
+
protectedPatterns,
|
|
157
|
+
allowedPatterns,
|
|
158
|
+
dirPatterns,
|
|
159
|
+
config.envFiles.onlyBlockIfExists,
|
|
160
|
+
);
|
|
161
|
+
|
|
75
162
|
const protectionRules: ToolProtectionRule[] = [
|
|
76
163
|
{
|
|
77
164
|
tools: config.envFiles.protectedTools.filter((t) =>
|
|
78
165
|
["read", "write", "edit", "grep", "find", "ls"].includes(t),
|
|
79
166
|
),
|
|
80
|
-
extractTargets: (input) => {
|
|
167
|
+
extractTargets: async (input) => {
|
|
81
168
|
const path = String(input.file_path ?? input.path ?? "");
|
|
82
169
|
return path ? [path] : [];
|
|
83
170
|
},
|
|
84
|
-
shouldBlock
|
|
171
|
+
shouldBlock,
|
|
85
172
|
blockMessage: (target) =>
|
|
86
173
|
config.envFiles.blockMessage.replace("{file}", target),
|
|
87
174
|
},
|
|
88
175
|
{
|
|
89
176
|
tools: config.envFiles.protectedTools.includes("bash") ? ["bash"] : [],
|
|
90
|
-
extractTargets: (input) => {
|
|
177
|
+
extractTargets: async (input) => {
|
|
91
178
|
const command = String(input.command ?? "");
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const envFileRegex =
|
|
95
|
-
/(?:^|\s|[<>|;&"'`])([^\s<>|;&"'`]*\.env[^\s<>|;&"'`]*)(?:\s|$|[<>|;&"'`])/gi;
|
|
96
|
-
|
|
97
|
-
for (const match of command.matchAll(envFileRegex)) {
|
|
98
|
-
const file = match[1];
|
|
99
|
-
if (file) files.push(file);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return files;
|
|
179
|
+
return extractBashFileTargets(command);
|
|
103
180
|
},
|
|
104
|
-
shouldBlock
|
|
181
|
+
shouldBlock,
|
|
105
182
|
blockMessage: (target) =>
|
|
106
183
|
`Command references protected file ${target}. ` +
|
|
107
184
|
config.envFiles.blockMessage.replace("{file}", target),
|
|
@@ -120,7 +197,7 @@ export function setupProtectEnvFilesHook(
|
|
|
120
197
|
const rule = rulesByTool.get(event.toolName);
|
|
121
198
|
if (!rule) return;
|
|
122
199
|
|
|
123
|
-
const targets = rule.extractTargets(event.input);
|
|
200
|
+
const targets = await rule.extractTargets(event.input);
|
|
124
201
|
|
|
125
202
|
for (const target of targets) {
|
|
126
203
|
if (await rule.shouldBlock(target)) {
|
package/index.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { registerGuardrailsSettings } from "./commands/settings-command";
|
|
2
3
|
import { configLoader } from "./config";
|
|
3
4
|
import { setupGuardrailsHooks } from "./hooks";
|
|
4
|
-
import { registerSettingsCommand } from "./settings-command";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Guardrails Extension
|
|
8
8
|
*
|
|
9
9
|
* Security hooks to prevent potentially dangerous operations:
|
|
10
|
-
* - prevent-brew: Blocks Homebrew commands (project uses Nix)
|
|
11
10
|
* - protect-env-files: Prevents access to .env files (except .example/.sample/.test)
|
|
12
11
|
* - permission-gate: Prompts for confirmation on dangerous commands
|
|
13
12
|
*
|
|
13
|
+
* Toolchain features (preventBrew, preventPython, enforcePackageManager,
|
|
14
|
+
* packageManager) have been moved to @aliou/pi-toolchain. Old configs
|
|
15
|
+
* containing these fields are auto-migrated on first load.
|
|
16
|
+
*
|
|
14
17
|
* Configuration:
|
|
15
18
|
* - Global: ~/.pi/agent/extensions/guardrails.json
|
|
16
19
|
* - Project: .pi/extensions/guardrails.json
|
|
@@ -23,5 +26,5 @@ export default async function (pi: ExtensionAPI) {
|
|
|
23
26
|
if (!config.enabled) return;
|
|
24
27
|
|
|
25
28
|
setupGuardrailsHooks(pi, config);
|
|
26
|
-
|
|
29
|
+
registerGuardrailsSettings(pi);
|
|
27
30
|
}
|