@aliou/pi-guardrails 0.2.0 → 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.
@@ -8,142 +8,191 @@ import {
8
8
  Text,
9
9
  wrapTextWithAnsi,
10
10
  } from "@mariozechner/pi-tui";
11
+ import type { ResolvedConfig } from "../config-schema";
12
+ import { emitBlocked, emitDangerous } from "../events";
11
13
 
12
14
  /**
13
15
  * Permission gate that prompts user confirmation for dangerous commands.
14
- * Blocks patterns like rm -rf, sudo, and piped shell execution.
16
+ * Patterns, confirmation behavior, allow/deny lists are all configurable.
15
17
  */
16
18
 
17
- // Notification event channel (duplicated for decoupling)
18
- const NOTIFICATION_EVENT = "ad:notification";
19
- const ATTENTION_SOUND = "/System/Library/Sounds/Ping.aiff";
20
-
21
- interface NotificationEvent {
22
- message: string;
23
- sound?: string;
24
- }
25
-
26
- function emitNotification(pi: ExtensionAPI, message: string, sound?: string) {
27
- const event: NotificationEvent = { message, sound };
28
- pi.events.emit(NOTIFICATION_EVENT, event);
29
- }
19
+ export function setupPermissionGateHook(
20
+ pi: ExtensionAPI,
21
+ config: ResolvedConfig,
22
+ ) {
23
+ if (!config.features.permissionGate) return;
24
+
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
+ );
45
+
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);
30
71
 
31
- const DANGEROUS_PATTERNS = [
32
- { pattern: /rm\s+-rf/, description: "recursive force delete" },
33
- { pattern: /\bsudo\b/, description: "superuser command" },
34
- { pattern: /:\s*\|\s*sh/, description: "piped shell execution" },
35
- { pattern: /\bdd\s+if=/, description: "disk write operation" },
36
- { pattern: /mkfs\./, description: "filesystem format" },
37
- {
38
- pattern: /\bchmod\s+-R\s+777/,
39
- description: "insecure recursive permissions",
40
- },
41
- { pattern: /\bchown\s+-R/, description: "recursive ownership change" },
42
- ];
43
-
44
- export function setupPermissionGateHook(pi: ExtensionAPI) {
45
72
  pi.on("tool_call", async (event, ctx) => {
46
73
  if (event.toolName !== "bash") return;
47
74
 
48
75
  const command = String(event.input.command ?? "");
49
76
 
50
- for (const { pattern, description } of DANGEROUS_PATTERNS) {
77
+ // Check allowed patterns first (bypass)
78
+ for (const pattern of allowedPatterns) {
79
+ if (pattern.test(command)) return;
80
+ }
81
+
82
+ // Check auto-deny patterns
83
+ for (const pattern of autoDenyPatterns) {
51
84
  if (pattern.test(command)) {
52
- // Emit notification before showing confirm dialog
53
- emitNotification(
54
- pi,
55
- `Dangerous command: ${description}`,
56
- ATTENTION_SOUND,
57
- );
85
+ ctx.ui.notify("Blocked dangerous command (auto-deny)", "error");
58
86
 
59
- const proceed = await ctx.ui.custom<boolean>(
60
- (_tui, theme, _kb, done) => {
61
- const container = new Container();
62
-
63
- // Red border styling
64
- const redBorder = (s: string) => theme.fg("error", s);
65
-
66
- // Top border
67
- container.addChild(new DynamicBorder(redBorder));
68
-
69
- // Title
70
- container.addChild(
71
- new Text(
72
- theme.fg("error", theme.bold("Dangerous Command Detected")),
73
- 1,
74
- 0,
75
- ),
76
- );
77
- container.addChild(new Spacer(1));
78
-
79
- // Description
80
- container.addChild(
81
- new Text(
82
- theme.fg("warning", `This command contains ${description}:`),
83
- 1,
84
- 0,
85
- ),
86
- );
87
- container.addChild(new Spacer(1));
88
-
89
- // Full command with border
90
- container.addChild(
91
- new DynamicBorder((s: string) => theme.fg("muted", s)),
92
- );
93
- const commandText = new Text("", 1, 0);
94
- container.addChild(commandText);
95
- container.addChild(
96
- new DynamicBorder((s: string) => theme.fg("muted", s)),
97
- );
98
- container.addChild(new Spacer(1));
99
-
100
- // Prompt
101
- container.addChild(
102
- new Text(theme.fg("text", "Allow execution?"), 1, 0),
103
- );
104
- container.addChild(new Spacer(1));
105
-
106
- // Help text
107
- container.addChild(
108
- new Text(theme.fg("dim", "y/enter: allow • n/esc: deny"), 1, 0),
109
- );
110
-
111
- // Bottom border
112
- container.addChild(new DynamicBorder(redBorder));
113
-
114
- return {
115
- render: (width: number) => {
116
- // Update command text with proper wrapping for current width
117
- const wrappedCommand = wrapTextWithAnsi(
118
- theme.fg("text", command),
119
- width - 4,
120
- ).join("\n");
121
- commandText.setText(wrappedCommand);
122
- return container.render(width);
123
- },
124
- invalidate: () => container.invalidate(),
125
- handleInput: (data: string) => {
126
- if (
127
- matchesKey(data, Key.enter) ||
128
- data === "y" ||
129
- data === "Y"
130
- ) {
131
- done(true);
132
- } else if (
133
- matchesKey(data, Key.escape) ||
134
- data === "n" ||
135
- data === "N"
136
- ) {
137
- done(false);
138
- }
139
- },
140
- };
141
- },
142
- );
87
+ const reason =
88
+ "Command matched auto-deny pattern and was blocked automatically.";
89
+
90
+ emitBlocked(pi, {
91
+ feature: "permissionGate",
92
+ toolName: "bash",
93
+ input: event.input,
94
+ reason,
95
+ });
143
96
 
144
- if (!proceed) {
145
- return { block: true, reason: "User denied dangerous command" };
97
+ return { block: true, reason };
98
+ }
99
+ }
100
+
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
+ );
146
194
  }
195
+
147
196
  break;
148
197
  }
149
198
  }
@@ -1,36 +1,40 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { ResolvedConfig } from "../config-schema";
3
+ import { emitBlocked } from "../events";
2
4
 
3
5
  /**
4
- * Prevents bash tool calls that attempt to install packages using Homebrew.
5
- * Reminds the user that this project uses Nix for package management.
6
+ * Blocks all brew commands. Homebrew is not installed on this machine.
6
7
  */
7
8
 
8
- const BREW_INSTALL_PATTERNS = [
9
- /\bbrew\s+install\b/,
10
- /\bbrew\s+cask\s+install\b/,
11
- /\bbrew\s+bundle\b/,
12
- /\bbrew\s+upgrade\b/,
13
- /\bbrew\s+reinstall\b/,
14
- ];
9
+ const BREW_PATTERN = /\bbrew\b/;
10
+
11
+ export function setupPreventBrewHook(pi: ExtensionAPI, config: ResolvedConfig) {
12
+ if (!config.features.preventBrew) return;
15
13
 
16
- export function setupPreventBrewHook(pi: ExtensionAPI) {
17
14
  pi.on("tool_call", async (event, ctx) => {
18
15
  if (event.toolName !== "bash") return;
19
16
 
20
17
  const command = String(event.input.command ?? "");
21
18
 
22
- for (const pattern of BREW_INSTALL_PATTERNS) {
23
- if (pattern.test(command)) {
24
- ctx.ui.notify(
25
- "Blocked brew command. This project uses Nix for package management.",
26
- "warning",
27
- );
28
- return {
29
- block: true,
30
- reason:
31
- "Homebrew is not used in this project. Please use Nix for package management instead. Run packages via nix-shell or add them to the project's Nix configuration.",
32
- };
33
- }
19
+ if (BREW_PATTERN.test(command)) {
20
+ ctx.ui.notify(
21
+ "Blocked brew command. Homebrew is not installed.",
22
+ "warning",
23
+ );
24
+
25
+ const reason =
26
+ "Homebrew is not installed on this machine. " +
27
+ "Use Nix for package management instead. " +
28
+ "Run packages via nix-shell or add them to the project's Nix configuration.";
29
+
30
+ emitBlocked(pi, {
31
+ feature: "preventBrew",
32
+ toolName: "bash",
33
+ input: event.input,
34
+ reason,
35
+ });
36
+
37
+ return { block: true, reason };
34
38
  }
35
39
  return;
36
40
  });
@@ -1,18 +1,16 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import type { ResolvedConfig } from "../config-schema";
5
+ import { emitBlocked } from "../events";
4
6
 
5
7
  /**
6
- * Prevents accessing .env files unless they are suffixed with example, sample, or test.
7
- * This protects sensitive environment files from being accessed accidentally.
8
+ * Prevents accessing .env files unless they match an allowed pattern.
9
+ * Protects sensitive environment files from being accessed accidentally.
8
10
  *
9
- * Covers native tools: read, write, edit, bash, grep, find, ls
11
+ * Covers configurable set of tools (default: read, write, edit, bash, grep, find, ls).
10
12
  */
11
13
 
12
- const ENV_FILE_PATTERN = /\.env$/i;
13
- const ALLOWED_SUFFIXES =
14
- /\.(example|sample|test)\.env$|\.env\.(example|sample|test)$/i;
15
-
16
14
  async function fileExists(filePath: string): Promise<boolean> {
17
15
  try {
18
16
  await stat(resolve(filePath));
@@ -22,89 +20,102 @@ async function fileExists(filePath: string): Promise<boolean> {
22
20
  }
23
21
  }
24
22
 
25
- async function isProtectedEnvFile(filePath: string): Promise<boolean> {
26
- if (!ENV_FILE_PATTERN.test(filePath)) {
27
- return false;
28
- }
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
+ }
29
35
 
30
- if (ALLOWED_SUFFIXES.test(filePath)) {
31
- return false;
36
+ async function isProtectedEnvFile(
37
+ filePath: string,
38
+ config: ResolvedConfig,
39
+ ): Promise<boolean> {
40
+ const protectedRegexes = compilePatterns(config.envFiles.protectedPatterns);
41
+ const isProtected = protectedRegexes.some((r) => r.test(filePath));
42
+ if (!isProtected) return false;
43
+
44
+ const allowedRegexes = compilePatterns(config.envFiles.allowedPatterns);
45
+ const isAllowed = allowedRegexes.some((r) => r.test(filePath));
46
+ if (isAllowed) return false;
47
+
48
+ // Check protected directories (if any configured)
49
+ if (config.envFiles.protectedDirectories.length > 0) {
50
+ const dirRegexes = compilePatterns(config.envFiles.protectedDirectories);
51
+ const inProtectedDir = dirRegexes.some((r) => r.test(filePath));
52
+ if (inProtectedDir) {
53
+ return config.envFiles.onlyBlockIfExists
54
+ ? await fileExists(filePath)
55
+ : true;
56
+ }
32
57
  }
33
58
 
34
- // Only block if file actually exists on disk
35
- return fileExists(filePath);
59
+ return config.envFiles.onlyBlockIfExists ? await fileExists(filePath) : true;
36
60
  }
37
61
 
38
- // -------------------------------------------------------------------
39
- // Tool protection rule interface
40
- // -------------------------------------------------------------------
41
-
42
62
  interface ToolProtectionRule {
43
- /** Tool names this rule applies to */
44
63
  tools: string[];
45
- /** Extract paths/targets from tool input that need checking */
46
64
  extractTargets: (input: Record<string, unknown>) => string[];
47
- /** Check if a target should be blocked */
48
65
  shouldBlock: (target: string) => Promise<boolean>;
49
- /** Generate block message for a target */
50
66
  blockMessage: (target: string) => string;
51
67
  }
52
68
 
53
- // -------------------------------------------------------------------
54
- // Protection rules
55
- // -------------------------------------------------------------------
56
-
57
- const protectionRules: ToolProtectionRule[] = [
58
- {
59
- // Tools that use path/file_path input parameter
60
- tools: ["read", "write", "edit", "grep", "find", "ls"],
61
- extractTargets: (input) => {
62
- const path = String(input.file_path ?? input.path ?? "");
63
- return path ? [path] : [];
69
+ export function setupProtectEnvFilesHook(
70
+ pi: ExtensionAPI,
71
+ config: ResolvedConfig,
72
+ ) {
73
+ if (!config.features.protectEnvFiles) return;
74
+
75
+ const protectionRules: ToolProtectionRule[] = [
76
+ {
77
+ tools: config.envFiles.protectedTools.filter((t) =>
78
+ ["read", "write", "edit", "grep", "find", "ls"].includes(t),
79
+ ),
80
+ extractTargets: (input) => {
81
+ const path = String(input.file_path ?? input.path ?? "");
82
+ return path ? [path] : [];
83
+ },
84
+ shouldBlock: (target) => isProtectedEnvFile(target, config),
85
+ blockMessage: (target) =>
86
+ config.envFiles.blockMessage.replace("{file}", target),
64
87
  },
65
- shouldBlock: isProtectedEnvFile,
66
- blockMessage: (target) =>
67
- `Accessing ${target} is not allowed. Environment files containing secrets are protected. Explain to the user why you want to access this .env file, and if changes are needed ask the user to make them. Only .env.example, .env.sample, or .env.test files can be accessed.`,
68
- },
69
- {
70
- // Bash needs to parse command string for .env references
71
- tools: ["bash"],
72
- extractTargets: (input) => {
73
- const command = String(input.command ?? "");
74
- const files: string[] = [];
75
-
76
- // Match .env file references in bash commands
77
- const envFileRegex =
78
- /(?:^|\s|[<>|;&"'`])([^\s<>|;&"'`]*\.env)(?:\s|$|[<>|;&"'`])/gi;
79
-
80
- for (const match of command.matchAll(envFileRegex)) {
81
- const file = match[1];
82
- if (file) {
83
- files.push(file);
88
+ {
89
+ tools: config.envFiles.protectedTools.includes("bash") ? ["bash"] : [],
90
+ extractTargets: (input) => {
91
+ const command = String(input.command ?? "");
92
+ const files: string[] = [];
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);
84
100
  }
85
- }
86
101
 
87
- return files;
102
+ return files;
103
+ },
104
+ shouldBlock: (target) => isProtectedEnvFile(target, config),
105
+ blockMessage: (target) =>
106
+ `Command references protected file ${target}. ` +
107
+ config.envFiles.blockMessage.replace("{file}", target),
88
108
  },
89
- shouldBlock: isProtectedEnvFile,
90
- blockMessage: (target) =>
91
- `Command references protected file ${target}. Environment files containing secrets are protected. Explain to the user why you want to access this .env file, and if changes are needed ask the user to make them. Only .env.example, .env.sample, or .env.test files can be accessed.`,
92
- },
93
- ];
94
-
95
- // Build lookup: tool name -> rule
96
- const rulesByTool = new Map<string, ToolProtectionRule>();
97
- for (const rule of protectionRules) {
98
- for (const tool of rule.tools) {
99
- rulesByTool.set(tool, rule);
100
- }
101
- }
109
+ ];
102
110
 
103
- // -------------------------------------------------------------------
104
- // Hook
105
- // -------------------------------------------------------------------
111
+ // Build lookup: tool name -> rule
112
+ const rulesByTool = new Map<string, ToolProtectionRule>();
113
+ for (const rule of protectionRules) {
114
+ for (const tool of rule.tools) {
115
+ rulesByTool.set(tool, rule);
116
+ }
117
+ }
106
118
 
107
- export function setupProtectEnvFilesHook(pi: ExtensionAPI) {
108
119
  pi.on("tool_call", async (event, ctx) => {
109
120
  const rule = rulesByTool.get(event.toolName);
110
121
  if (!rule) return;
@@ -113,14 +124,18 @@ export function setupProtectEnvFilesHook(pi: ExtensionAPI) {
113
124
 
114
125
  for (const target of targets) {
115
126
  if (await rule.shouldBlock(target)) {
116
- ctx.ui.notify(
117
- `Blocked access to protected .env file: ${target}`,
118
- "warning",
119
- );
120
- return {
121
- block: true,
122
- reason: rule.blockMessage(target),
123
- };
127
+ ctx.ui.notify(`Blocked access to protected file: ${target}`, "warning");
128
+
129
+ const reason = rule.blockMessage(target);
130
+
131
+ emitBlocked(pi, {
132
+ feature: "protectEnvFiles",
133
+ toolName: event.toolName,
134
+ input: event.input,
135
+ reason,
136
+ });
137
+
138
+ return { block: true, reason };
124
139
  }
125
140
  }
126
141
  return;
package/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { configLoader } from "./config";
2
3
  import { setupGuardrailsHooks } from "./hooks";
4
+ import { registerSettingsCommand } from "./settings-command";
3
5
 
4
6
  /**
5
7
  * Guardrails Extension
@@ -8,7 +10,18 @@ import { setupGuardrailsHooks } from "./hooks";
8
10
  * - prevent-brew: Blocks Homebrew commands (project uses Nix)
9
11
  * - protect-env-files: Prevents access to .env files (except .example/.sample/.test)
10
12
  * - permission-gate: Prompts for confirmation on dangerous commands
13
+ *
14
+ * Configuration:
15
+ * - Global: ~/.pi/agent/extensions/guardrails.json
16
+ * - Project: .pi/extensions/guardrails.json
17
+ * - Command: /guardrails:settings
11
18
  */
12
- export default function (pi: ExtensionAPI) {
13
- setupGuardrailsHooks(pi);
19
+ export default async function (pi: ExtensionAPI) {
20
+ await configLoader.load();
21
+ const config = configLoader.getConfig();
22
+
23
+ if (!config.enabled) return;
24
+
25
+ setupGuardrailsHooks(pi, config);
26
+ registerSettingsCommand(pi);
14
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-guardrails",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "keywords": [
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "peerDependencies": {
31
- "@mariozechner/pi-coding-agent": ">=0.49.0",
32
- "@mariozechner/pi-tui": ">=0.49.0"
31
+ "@mariozechner/pi-coding-agent": "0.50.0",
32
+ "@mariozechner/pi-tui": "0.50.0"
33
33
  }
34
34
  }