@aliou/pi-guardrails 0.2.1 → 0.4.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 +154 -3
- package/array-editor.ts +213 -0
- package/config-schema.ts +56 -0
- package/config.ts +161 -0
- package/events.ts +36 -0
- package/hooks/index.ts +7 -4
- package/hooks/permission-gate.ts +170 -121
- package/hooks/prevent-brew.ts +26 -22
- package/hooks/prevent-python.ts +45 -0
- package/hooks/protect-env-files.ts +95 -80
- package/index.ts +15 -2
- package/package.json +1 -1
- package/pattern-editor.ts +284 -0
- package/sectioned-settings.ts +345 -0
- package/settings-command.ts +416 -0
package/hooks/permission-gate.ts
CHANGED
|
@@ -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
|
-
*
|
|
16
|
+
* Patterns, confirmation behavior, allow/deny lists are all configurable.
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
}
|
package/hooks/prevent-brew.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { ResolvedConfig } from "../config-schema";
|
|
3
|
+
import { emitBlocked } from "../events";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Blocks all Python-related commands including python, python3, pip, poetry, etc.
|
|
7
|
+
* Use uv for Python package management instead.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PYTHON_PATTERN =
|
|
11
|
+
/\b(python|python3|pip|pip3|poetry|pyenv|virtualenv|venv)\b/;
|
|
12
|
+
|
|
13
|
+
export function setupPreventPythonHook(
|
|
14
|
+
pi: ExtensionAPI,
|
|
15
|
+
config: ResolvedConfig,
|
|
16
|
+
) {
|
|
17
|
+
if (!config.features.preventPython) return;
|
|
18
|
+
|
|
19
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
20
|
+
if (event.toolName !== "bash") return;
|
|
21
|
+
|
|
22
|
+
const command = String(event.input.command ?? "");
|
|
23
|
+
|
|
24
|
+
if (PYTHON_PATTERN.test(command)) {
|
|
25
|
+
ctx.ui.notify("Blocked Python command. Use uv instead.", "warning");
|
|
26
|
+
|
|
27
|
+
const reason =
|
|
28
|
+
"Python is not available globally on this machine. " +
|
|
29
|
+
"Use uv for Python package management instead. " +
|
|
30
|
+
"Run `uv init` to create a new Python project, " +
|
|
31
|
+
"or `uv run python` to run Python scripts. " +
|
|
32
|
+
"Use `uv add` to install packages (replaces pip/poetry).";
|
|
33
|
+
|
|
34
|
+
emitBlocked(pi, {
|
|
35
|
+
feature: "preventPython",
|
|
36
|
+
toolName: "bash",
|
|
37
|
+
input: event.input,
|
|
38
|
+
reason,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return { block: true, reason };
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -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
|
|
7
|
-
*
|
|
8
|
+
* Prevents accessing .env files unless they match an allowed pattern.
|
|
9
|
+
* Protects sensitive environment files from being accessed accidentally.
|
|
8
10
|
*
|
|
9
|
-
* Covers
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
}
|