@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 +72 -2
- package/hooks/index.ts +10 -0
- package/hooks/permission-gate.ts +152 -0
- package/hooks/prevent-brew.ts +37 -0
- package/hooks/protect-env-files.ts +128 -0
- package/index.ts +14 -0
- package/package.json +26 -6
package/README.md
CHANGED
|
@@ -1,2 +1,72 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
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
|
|
4
|
-
"description": "Security hooks for Pi coding agent",
|
|
3
|
+
"version": "0.1.0",
|
|
5
4
|
"type": "module",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
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
|
}
|