@aliou/pi-guardrails 0.0.1 → 0.1.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
@@ -1,2 +1,72 @@
1
- # @aliou/pi-guardrails
2
- Placeholder for initial npm publish.
1
+ # Guardrails
2
+
3
+ Security hooks to prevent potentially dangerous operations.
4
+
5
+ ## Installation
6
+
7
+ Install via the pi-extensions package:
8
+
9
+ ```bash
10
+ pi install git:github.com/aliou/pi-extensions
11
+ ```
12
+
13
+ Or selectively in your `settings.json`:
14
+
15
+ ```json
16
+ {
17
+ "packages": [
18
+ {
19
+ "source": "git:github.com/aliou/pi-extensions",
20
+ "extensions": ["extensions/guardrails"]
21
+ }
22
+ ]
23
+ }
24
+ ```
25
+
26
+ Or from npm:
27
+
28
+ ```bash
29
+ pi install npm:@aliou/pi-guardrails
30
+ ```
31
+
32
+ ## Features
33
+
34
+ - **prevent-brew**: Blocks Homebrew commands (project uses Nix)
35
+ - **protect-env-files**: Prevents access to `.env` files (except `.example`/`.sample`/`.test`)
36
+ - **permission-gate**: Prompts for confirmation on dangerous commands
37
+
38
+ ## Hooks
39
+
40
+ ### prevent-brew
41
+
42
+ Blocks bash commands that attempt to install packages using Homebrew. Notifies the user that the project uses Nix for package management.
43
+
44
+ Blocked patterns:
45
+ - `brew install`
46
+ - `brew cask install`
47
+ - `brew bundle`
48
+ - `brew upgrade`
49
+ - `brew reinstall`
50
+
51
+ ### protect-env-files
52
+
53
+ Prevents accessing `.env` files that might contain secrets. Only allows access to safe variants:
54
+ - `.env.example`
55
+ - `.env.sample`
56
+ - `.env.test`
57
+ - `*.example.env`
58
+ - `*.sample.env`
59
+ - `*.test.env`
60
+
61
+ Covers tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`
62
+
63
+ ### permission-gate
64
+
65
+ Prompts user confirmation before executing dangerous commands:
66
+ - `rm -rf` (recursive force delete)
67
+ - `sudo` (superuser command)
68
+ - `: | sh` (piped shell execution)
69
+ - `dd if=` (disk write operation)
70
+ - `mkfs.` (filesystem format)
71
+ - `chmod -R 777` (insecure recursive permissions)
72
+ - `chown -R` (recursive ownership change)
package/hooks/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { setupPermissionGateHook } from "./permission-gate";
3
+ import { setupPreventBrewHook } from "./prevent-brew";
4
+ import { setupProtectEnvFilesHook } from "./protect-env-files";
5
+
6
+ export function setupGuardrailsHooks(pi: ExtensionAPI) {
7
+ setupPreventBrewHook(pi);
8
+ setupProtectEnvFilesHook(pi);
9
+ setupPermissionGateHook(pi);
10
+ }
@@ -0,0 +1,152 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
3
+ import {
4
+ Container,
5
+ Key,
6
+ matchesKey,
7
+ Spacer,
8
+ Text,
9
+ wrapTextWithAnsi,
10
+ } from "@mariozechner/pi-tui";
11
+
12
+ /**
13
+ * Permission gate that prompts user confirmation for dangerous commands.
14
+ * Blocks patterns like rm -rf, sudo, and piped shell execution.
15
+ */
16
+
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
+ }
30
+
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
+ pi.on("tool_call", async (event, ctx) => {
46
+ if (event.toolName !== "bash") return;
47
+
48
+ const command = String(event.input.command ?? "");
49
+
50
+ for (const { pattern, description } of DANGEROUS_PATTERNS) {
51
+ if (pattern.test(command)) {
52
+ // Emit notification before showing confirm dialog
53
+ emitNotification(
54
+ pi,
55
+ `Dangerous command: ${description}`,
56
+ ATTENTION_SOUND,
57
+ );
58
+
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
+ );
143
+
144
+ if (!proceed) {
145
+ return { block: true, reason: "User denied dangerous command" };
146
+ }
147
+ break;
148
+ }
149
+ }
150
+ return;
151
+ });
152
+ }
@@ -0,0 +1,37 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+
3
+ /**
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
+ */
7
+
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
+ ];
15
+
16
+ export function setupPreventBrewHook(pi: ExtensionAPI) {
17
+ pi.on("tool_call", async (event, ctx) => {
18
+ if (event.toolName !== "bash") return;
19
+
20
+ const command = String(event.input.command ?? "");
21
+
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
+ }
34
+ }
35
+ return;
36
+ });
37
+ }
@@ -0,0 +1,128 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+
5
+ /**
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
+ *
9
+ * Covers native tools: read, write, edit, bash, grep, find, ls
10
+ */
11
+
12
+ const ENV_FILE_PATTERN = /\.env$/i;
13
+ const ALLOWED_SUFFIXES =
14
+ /\.(example|sample|test)\.env$|\.env\.(example|sample|test)$/i;
15
+
16
+ async function fileExists(filePath: string): Promise<boolean> {
17
+ try {
18
+ await stat(resolve(filePath));
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ async function isProtectedEnvFile(filePath: string): Promise<boolean> {
26
+ if (!ENV_FILE_PATTERN.test(filePath)) {
27
+ return false;
28
+ }
29
+
30
+ if (ALLOWED_SUFFIXES.test(filePath)) {
31
+ return false;
32
+ }
33
+
34
+ // Only block if file actually exists on disk
35
+ return fileExists(filePath);
36
+ }
37
+
38
+ // -------------------------------------------------------------------
39
+ // Tool protection rule interface
40
+ // -------------------------------------------------------------------
41
+
42
+ interface ToolProtectionRule {
43
+ /** Tool names this rule applies to */
44
+ tools: string[];
45
+ /** Extract paths/targets from tool input that need checking */
46
+ extractTargets: (input: Record<string, unknown>) => string[];
47
+ /** Check if a target should be blocked */
48
+ shouldBlock: (target: string) => Promise<boolean>;
49
+ /** Generate block message for a target */
50
+ blockMessage: (target: string) => string;
51
+ }
52
+
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] : [];
64
+ },
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);
84
+ }
85
+ }
86
+
87
+ return files;
88
+ },
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
+ }
102
+
103
+ // -------------------------------------------------------------------
104
+ // Hook
105
+ // -------------------------------------------------------------------
106
+
107
+ export function setupProtectEnvFilesHook(pi: ExtensionAPI) {
108
+ pi.on("tool_call", async (event, ctx) => {
109
+ const rule = rulesByTool.get(event.toolName);
110
+ if (!rule) return;
111
+
112
+ const targets = rule.extractTargets(event.input);
113
+
114
+ for (const target of targets) {
115
+ 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
+ };
124
+ }
125
+ }
126
+ return;
127
+ });
128
+ }
package/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { setupGuardrailsHooks } from "./hooks";
3
+
4
+ /**
5
+ * Guardrails Extension
6
+ *
7
+ * Security hooks to prevent potentially dangerous operations:
8
+ * - prevent-brew: Blocks Homebrew commands (project uses Nix)
9
+ * - protect-env-files: Prevents access to .env files (except .example/.sample/.test)
10
+ * - permission-gate: Prompts for confirmation on dangerous commands
11
+ */
12
+ export default function (pi: ExtensionAPI) {
13
+ setupGuardrailsHooks(pi);
14
+ }
package/package.json CHANGED
@@ -1,14 +1,34 @@
1
1
  {
2
2
  "name": "@aliou/pi-guardrails",
3
- "version": "0.0.1",
4
- "description": "Security hooks for Pi coding agent",
3
+ "version": "0.1.0",
5
4
  "type": "module",
6
- "keywords": ["pi-package", "pi-extension", "pi", "guardrails", "security"],
7
- "publishConfig": {
8
- "access": "public"
9
- },
5
+ "private": false,
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi",
10
+ "guardrails",
11
+ "security"
12
+ ],
10
13
  "repository": {
11
14
  "type": "git",
12
15
  "url": "https://github.com/aliou/pi-extensions"
16
+ },
17
+ "pi": {
18
+ "extensions": [
19
+ "./index.ts"
20
+ ]
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "files": [
26
+ "*.ts",
27
+ "hooks",
28
+ "README.md"
29
+ ],
30
+ "peerDependencies": {
31
+ "@mariozechner/pi-coding-agent": ">=0.49.0",
32
+ "@mariozechner/pi-tui": ">=0.49.0"
13
33
  }
14
34
  }