@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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Glob expansion using `fd` for env file protection.
3
+ *
4
+ * When a bash command contains shell globs referencing env files
5
+ * (e.g. `.env*`), we expand them against the filesystem to check
6
+ * if any expanded path matches a protected pattern.
7
+ */
8
+
9
+ import { execFile } from "node:child_process";
10
+ import { resolve } from "node:path";
11
+
12
+ interface ExpandGlobOptions {
13
+ cwd?: string;
14
+ maxDepth?: number;
15
+ maxResults?: number;
16
+ timeout?: number;
17
+ }
18
+
19
+ /**
20
+ * Expand a glob pattern using `fd`.
21
+ * Returns matching file paths, or empty array on failure.
22
+ *
23
+ * fd is available at `~/.pi/agent/bin/fd` (in pi's PATH).
24
+ */
25
+ export async function expandGlob(
26
+ pattern: string,
27
+ options: ExpandGlobOptions = {},
28
+ ): Promise<string[]> {
29
+ const {
30
+ cwd = process.cwd(),
31
+ maxDepth = 3,
32
+ maxResults = 50,
33
+ timeout = 2000,
34
+ } = options;
35
+
36
+ // Convert glob to fd-compatible regex.
37
+ // fd uses regex by default, so we convert glob chars.
38
+ const fdPattern = globToFdRegex(pattern);
39
+
40
+ return new Promise((res) => {
41
+ const args = [
42
+ "--type",
43
+ "f",
44
+ "--max-depth",
45
+ String(maxDepth),
46
+ "--max-results",
47
+ String(maxResults),
48
+ "--no-ignore",
49
+ "--hidden",
50
+ fdPattern,
51
+ ];
52
+
53
+ const child = execFile("fd", args, { cwd, timeout }, (err, stdout) => {
54
+ if (err) {
55
+ res([]);
56
+ return;
57
+ }
58
+
59
+ const files = stdout
60
+ .trim()
61
+ .split("\n")
62
+ .filter(Boolean)
63
+ .map((f) => resolve(cwd, f));
64
+
65
+ res(files);
66
+ });
67
+
68
+ // Safety net: kill if timeout isn't handled by execFile
69
+ setTimeout(() => {
70
+ child.kill();
71
+ res([]);
72
+ }, timeout + 500);
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Convert a shell glob to an fd-compatible regex pattern.
78
+ * Handles `*`, `?`, and character classes `[...]`.
79
+ */
80
+ function globToFdRegex(glob: string): string {
81
+ let regex = "";
82
+ let i = 0;
83
+ while (i < glob.length) {
84
+ const ch = glob[i] as string;
85
+ switch (ch) {
86
+ case "*":
87
+ regex += "[^/]*";
88
+ break;
89
+ case "?":
90
+ regex += "[^/]";
91
+ break;
92
+ case "[": {
93
+ // Pass character classes through
94
+ const end = glob.indexOf("]", i + 1);
95
+ if (end !== -1) {
96
+ regex += glob.slice(i, end + 1);
97
+ i = end;
98
+ } else {
99
+ regex += "\\[";
100
+ }
101
+ break;
102
+ }
103
+ case ".":
104
+ case "(":
105
+ case ")":
106
+ case "+":
107
+ case "^":
108
+ case "$":
109
+ case "{":
110
+ case "}":
111
+ case "|":
112
+ case "\\":
113
+ regex += `\\${ch}`;
114
+ break;
115
+ default:
116
+ regex += ch;
117
+ }
118
+ i++;
119
+ }
120
+ return `^${regex}$`;
121
+ }
122
+
123
+ /**
124
+ * Check if a string contains shell glob characters.
125
+ */
126
+ export function hasGlobChars(s: string): boolean {
127
+ return /[*?[\]]/.test(s);
128
+ }
package/hooks/index.ts CHANGED
@@ -1,15 +1,9 @@
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";
4
3
  import { setupPermissionGateHook } from "./permission-gate";
5
- import { setupPreventBrewHook } from "./prevent-brew";
6
- import { setupPreventPythonHook } from "./prevent-python";
7
4
  import { setupProtectEnvFilesHook } from "./protect-env-files";
8
5
 
9
6
  export function setupGuardrailsHooks(pi: ExtensionAPI, config: ResolvedConfig) {
10
- setupPreventBrewHook(pi, config);
11
- setupPreventPythonHook(pi, config);
12
7
  setupProtectEnvFilesHook(pi, config);
13
8
  setupPermissionGateHook(pi, config);
14
- setupEnforcePackageManagerHook(pi, config);
15
9
  }
@@ -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,158 @@ import {
8
9
  Text,
9
10
  wrapTextWithAnsi,
10
11
  } from "@mariozechner/pi-tui";
11
- import type { ResolvedConfig } from "../config-schema";
12
+ import type { DangerousPattern, ResolvedConfig } from "../config-schema";
12
13
  import { emitBlocked, emitDangerous } from "../events";
14
+ import { type CompiledPattern, compileCommandPatterns } from "../matching";
15
+ import { walkCommands, wordToString } from "../shell-utils";
13
16
 
14
17
  /**
15
18
  * Permission gate that prompts user confirmation for dangerous commands.
16
- * Patterns, confirmation behavior, allow/deny lists are all configurable.
19
+ *
20
+ * Built-in dangerous patterns are matched structurally via AST parsing.
21
+ * User custom patterns use substring/regex matching on the raw string.
22
+ * Allowed/auto-deny patterns match against the raw command string.
17
23
  */
18
24
 
25
+ /**
26
+ * Structural matcher for a built-in dangerous command.
27
+ * Returns a description if matched, undefined otherwise.
28
+ */
29
+ type StructuralMatcher = (words: string[]) => string | undefined;
30
+
31
+ /**
32
+ * Built-in dangerous command matchers. These check the parsed command
33
+ * structure instead of regex against the raw string.
34
+ */
35
+ const BUILTIN_MATCHERS: StructuralMatcher[] = [
36
+ // rm -rf
37
+ (words) => {
38
+ if (words[0] !== "rm") return undefined;
39
+ const hasRF = words.some(
40
+ (w) =>
41
+ w === "-rf" ||
42
+ w === "-fr" ||
43
+ (w.startsWith("-") && w.includes("r") && w.includes("f")),
44
+ );
45
+ return hasRF ? "recursive force delete" : undefined;
46
+ },
47
+ // sudo
48
+ (words) => (words[0] === "sudo" ? "superuser command" : undefined),
49
+ // dd if=
50
+ (words) => {
51
+ if (words[0] !== "dd") return undefined;
52
+ return words.some((w) => w.startsWith("if="))
53
+ ? "disk write operation"
54
+ : undefined;
55
+ },
56
+ // mkfs.*
57
+ (words) => (words[0]?.startsWith("mkfs.") ? "filesystem format" : undefined),
58
+ // chmod -R 777
59
+ (words) => {
60
+ if (words[0] !== "chmod") return undefined;
61
+ return words.includes("-R") && words.includes("777")
62
+ ? "insecure recursive permissions"
63
+ : undefined;
64
+ },
65
+ // chown -R
66
+ (words) => {
67
+ if (words[0] !== "chown") return undefined;
68
+ return words.includes("-R") ? "recursive ownership change" : undefined;
69
+ },
70
+ ];
71
+
72
+ interface DangerMatch {
73
+ description: string;
74
+ pattern: string;
75
+ }
76
+
77
+ /**
78
+ * Check a parsed command against built-in structural matchers.
79
+ */
80
+ function checkBuiltinDangerous(words: string[]): DangerMatch | undefined {
81
+ if (words.length === 0) return undefined;
82
+ for (const matcher of BUILTIN_MATCHERS) {
83
+ const desc = matcher(words);
84
+ if (desc) return { description: desc, pattern: "(structural)" };
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ /**
90
+ * Check a command string against dangerous patterns.
91
+ *
92
+ * When useBuiltinMatchers is true (default patterns): tries structural AST
93
+ * matching first, falls back to substring match on parse failure.
94
+ *
95
+ * When useBuiltinMatchers is false (customPatterns replaced defaults): skips
96
+ * structural matchers entirely, uses compiled patterns (substring/regex)
97
+ * against the raw command string.
98
+ */
99
+ function findDangerousMatch(
100
+ command: string,
101
+ compiledPatterns: CompiledPattern[],
102
+ useBuiltinMatchers: boolean,
103
+ fallbackPatterns: DangerousPattern[],
104
+ ): DangerMatch | undefined {
105
+ if (useBuiltinMatchers) {
106
+ // Try structural matching first
107
+ try {
108
+ const { ast } = parse(command);
109
+ let match: DangerMatch | undefined;
110
+ walkCommands(ast, (cmd) => {
111
+ const words = (cmd.words ?? []).map(wordToString);
112
+ const result = checkBuiltinDangerous(words);
113
+ if (result) {
114
+ match = result;
115
+ return true;
116
+ }
117
+ return false;
118
+ });
119
+ if (match) return match;
120
+ } catch {
121
+ // Parse failed -- fall back to substring matching on raw string
122
+ for (const p of fallbackPatterns) {
123
+ if (command.includes(p.pattern)) {
124
+ return { description: p.description, pattern: p.pattern };
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ // Check compiled patterns (substring/regex on raw string).
131
+ // When customPatterns replaces defaults, this is the only matching path.
132
+ for (const cp of compiledPatterns) {
133
+ if (cp.test(command)) {
134
+ const src = cp.source as DangerousPattern;
135
+ return { description: src.description, pattern: src.pattern };
136
+ }
137
+ }
138
+
139
+ return undefined;
140
+ }
141
+
19
142
  export function setupPermissionGateHook(
20
143
  pi: ExtensionAPI,
21
144
  config: ResolvedConfig,
22
145
  ) {
23
146
  if (!config.features.permissionGate) return;
24
147
 
25
- // Compile patterns, skipping invalid regex
26
- const dangerousPatterns = config.permissionGate.patterns
27
- .map((p) => {
28
- try {
29
- return {
30
- pattern: new RegExp(p.pattern),
31
- description: p.description,
32
- rawPattern: p.pattern,
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
- );
148
+ // Compile all configured patterns for substring/regex matching.
149
+ // When useBuiltinMatchers is true (defaults), these act as a supplement
150
+ // to the structural matchers. When false (customPatterns), these are the
151
+ // only matching path.
152
+ const compiledPatterns = compileCommandPatterns(
153
+ config.permissionGate.patterns,
154
+ );
155
+ const { useBuiltinMatchers } = config.permissionGate;
156
+ const fallbackPatterns = config.permissionGate.patterns;
45
157
 
46
- const allowedPatterns = config.permissionGate.allowedPatterns
47
- .map((p) => {
48
- try {
49
- return new RegExp(p);
50
- } catch {
51
- console.error(
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);
158
+ const allowedPatterns = compileCommandPatterns(
159
+ config.permissionGate.allowedPatterns,
160
+ );
161
+ const autoDenyPatterns = compileCommandPatterns(
162
+ config.permissionGate.autoDenyPatterns,
163
+ );
71
164
 
72
165
  pi.on("tool_call", async (event, ctx) => {
73
166
  if (event.toolName !== "bash") return;
@@ -98,104 +191,112 @@ export function setupPermissionGateHook(
98
191
  }
99
192
  }
100
193
 
101
- // Check dangerous patterns
102
- for (const { pattern, description, rawPattern } of dangerousPatterns) {
103
- if (pattern.test(command)) {
104
- // Emit dangerous event (presenter will play sound)
105
- emitDangerous(pi, { command, description, pattern: rawPattern });
106
-
107
- if (config.permissionGate.requireConfirmation) {
108
- const proceed = await ctx.ui.custom<boolean>(
109
- (_tui, theme, _kb, done) => {
110
- const container = new Container();
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
- }
194
+ // Check dangerous patterns (structural + compiled)
195
+ const match = findDangerousMatch(
196
+ command,
197
+ compiledPatterns,
198
+ useBuiltinMatchers,
199
+ fallbackPatterns,
200
+ );
201
+ if (!match) return;
202
+
203
+ const { description, pattern: rawPattern } = match;
204
+
205
+ // Emit dangerous event (presenter will play sound)
206
+ emitDangerous(pi, { command, description, pattern: rawPattern });
207
+
208
+ if (config.permissionGate.requireConfirmation) {
209
+ // In print/RPC mode, block by default (safe fallback)
210
+ if (!ctx.hasUI) {
211
+ const reason = `Dangerous command blocked (no UI to confirm): ${description}`;
212
+ emitBlocked(pi, {
213
+ feature: "permissionGate",
214
+ toolName: "bash",
215
+ input: event.input,
216
+ reason,
217
+ });
218
+ return { block: true, reason };
219
+ }
220
+
221
+ const proceed = await ctx.ui.custom<boolean>((_tui, theme, _kb, done) => {
222
+ const container = new Container();
223
+ const redBorder = (s: string) => theme.fg("error", s);
224
+
225
+ container.addChild(new DynamicBorder(redBorder));
226
+ container.addChild(
227
+ new Text(
228
+ theme.fg("error", theme.bold("Dangerous Command Detected")),
229
+ 1,
230
+ 0,
231
+ ),
232
+ );
233
+ container.addChild(new Spacer(1));
234
+ container.addChild(
235
+ new Text(
236
+ theme.fg("warning", `This command contains ${description}:`),
237
+ 1,
238
+ 0,
239
+ ),
240
+ );
241
+ container.addChild(new Spacer(1));
242
+ container.addChild(
243
+ new DynamicBorder((s: string) => theme.fg("muted", s)),
244
+ );
245
+ const commandText = new Text("", 1, 0);
246
+ container.addChild(commandText);
247
+ container.addChild(
248
+ new DynamicBorder((s: string) => theme.fg("muted", s)),
249
+ );
250
+ container.addChild(new Spacer(1));
251
+ container.addChild(
252
+ new Text(theme.fg("text", "Allow execution?"), 1, 0),
253
+ );
254
+ container.addChild(new Spacer(1));
255
+ container.addChild(
256
+ new Text(theme.fg("dim", "y/enter: allow • n/esc: deny"), 1, 0),
257
+ );
258
+ container.addChild(new DynamicBorder(redBorder));
259
+
260
+ return {
261
+ render: (width: number) => {
262
+ const wrappedCommand = wrapTextWithAnsi(
263
+ theme.fg("text", command),
264
+ width - 4,
265
+ ).join("\n");
266
+ commandText.setText(wrappedCommand);
267
+ return container.render(width);
268
+ },
269
+ invalidate: () => container.invalidate(),
270
+ handleInput: (data: string) => {
271
+ if (matchesKey(data, Key.enter) || data === "y" || data === "Y") {
272
+ done(true);
273
+ } else if (
274
+ matchesKey(data, Key.escape) ||
275
+ data === "n" ||
276
+ data === "N"
277
+ ) {
278
+ done(false);
279
+ }
280
+ },
281
+ };
282
+ });
283
+
284
+ if (!proceed) {
285
+ emitBlocked(pi, {
286
+ feature: "permissionGate",
287
+ toolName: "bash",
288
+ input: event.input,
289
+ reason: "User denied dangerous command",
290
+ userDenied: true,
291
+ });
195
292
 
196
- break;
293
+ return { block: true, reason: "User denied dangerous command" };
197
294
  }
295
+ } else {
296
+ // No confirmation required - just notify and allow
297
+ ctx.ui.notify(`Dangerous command detected: ${description}`, "warning");
198
298
  }
299
+
199
300
  return;
200
301
  });
201
302
  }