@diegopetrucci/pi-permission-gate 0.1.3 → 0.1.7
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/.pi-fleet-tested-version +1 -1
- package/README.md +11 -4
- package/index.ts +157 -14
- package/package.json +5 -3
package/.pi-fleet-tested-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.80.6
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# permission-gate
|
|
2
2
|
|
|
3
|
-
A small pi extension that prompts for confirmation before running potentially dangerous bash commands.
|
|
3
|
+
A small pi extension that prompts for confirmation before running potentially dangerous bash commands or writing to protected paths.
|
|
4
4
|
|
|
5
5
|
This is adapted from the original `permission-gate.ts` example in [`earendil-works/pi-mono`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/examples/extensions/permission-gate.ts) and kept basically the same.
|
|
6
6
|
|
|
@@ -9,8 +9,14 @@ This is adapted from the original `permission-gate.ts` example in [`earendil-wor
|
|
|
9
9
|
- `rm -rf`
|
|
10
10
|
- `sudo`
|
|
11
11
|
- `chmod` / `chown` with `777`
|
|
12
|
+
- direct `write` / `edit` calls touching normalized protected paths:
|
|
13
|
+
- exact `.git` path segments
|
|
14
|
+
- exact `node_modules` path segments
|
|
15
|
+
- secret-bearing `.env` files such as `.env` and `.env.production`
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
Safe `.env` templates/examples such as `.env.example` and `.env.production.template` are allowed.
|
|
18
|
+
|
|
19
|
+
If pi is running without an interactive UI, it blocks matching commands and protected path writes by default.
|
|
14
20
|
|
|
15
21
|
## Install
|
|
16
22
|
|
|
@@ -41,5 +47,6 @@ Then reload pi:
|
|
|
41
47
|
## Notes
|
|
42
48
|
|
|
43
49
|
- Hooks the `tool_call` event.
|
|
44
|
-
-
|
|
45
|
-
-
|
|
50
|
+
- Inspects `bash`, `write`, and `edit` tool calls.
|
|
51
|
+
- Normalizes relative/absolute paths before matching so traversal tricks do not bypass the guard.
|
|
52
|
+
- Prompts with a simple `Yes` / `No` selector before allowing dangerous commands or protected path writes.
|
package/index.ts
CHANGED
|
@@ -1,32 +1,175 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Permission Gate Extension
|
|
3
3
|
*
|
|
4
|
-
* Prompts for confirmation before running potentially dangerous bash commands
|
|
5
|
-
*
|
|
4
|
+
* Prompts for confirmation before running potentially dangerous bash commands
|
|
5
|
+
* or modifying protected paths via write/edit.
|
|
6
|
+
* Protected paths include exact .git and node_modules path segments plus
|
|
7
|
+
* secret-bearing .env files (excluding example/template variants).
|
|
6
8
|
*/
|
|
7
9
|
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
|
|
8
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
|
15
|
+
const protectedPathSegments = new Set([".git", "node_modules"]);
|
|
16
|
+
const safeEnvSuffixes = new Set(["example", "examples", "template", "templates"]);
|
|
17
|
+
|
|
18
|
+
type GuardDecision = { block: true; reason: string } | undefined;
|
|
19
|
+
type NormalizedPath = {
|
|
20
|
+
original: string;
|
|
21
|
+
displayPath: string;
|
|
22
|
+
segments: string[];
|
|
23
|
+
basename: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type WriteInput = { path: string; content: string };
|
|
27
|
+
type EditInput = { path: string; edits: Array<{ oldText: string; newText: string }> };
|
|
28
|
+
|
|
29
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
30
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeToolPath(rawPath: string): NormalizedPath | undefined {
|
|
34
|
+
const trimmed = rawPath.trim();
|
|
35
|
+
if (!trimmed) return undefined;
|
|
36
|
+
|
|
37
|
+
// Pi's built-in path tools treat a leading @ as path syntax and strip it
|
|
38
|
+
// before execution, so inspect the same effective target.
|
|
39
|
+
const withoutPathSigil = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
40
|
+
if (!withoutPathSigil) return undefined;
|
|
41
|
+
|
|
42
|
+
const withPosixSeparators = withoutPathSigil.replace(/\\+/g, "/");
|
|
43
|
+
const normalizedPath = path.posix.normalize(
|
|
44
|
+
withPosixSeparators.startsWith("/") ? withPosixSeparators : path.posix.join("/", withPosixSeparators),
|
|
45
|
+
);
|
|
46
|
+
const segments = normalizedPath.split("/").filter(Boolean);
|
|
47
|
+
const basename = segments.at(-1) ?? "";
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
original: rawPath,
|
|
51
|
+
displayPath: withPosixSeparators,
|
|
52
|
+
segments,
|
|
53
|
+
basename,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isSafeEnvTemplate(basename: string): boolean {
|
|
58
|
+
const normalizedBasename = basename.toLowerCase();
|
|
59
|
+
if (!normalizedBasename.startsWith(".env.")) return false;
|
|
60
|
+
const terminalSuffix = normalizedBasename.split(".").at(-1) ?? "";
|
|
61
|
+
return safeEnvSuffixes.has(terminalSuffix);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isProtectedEnvFile(basename: string): boolean {
|
|
65
|
+
const normalizedBasename = basename.toLowerCase();
|
|
66
|
+
if (normalizedBasename === ".env") return true;
|
|
67
|
+
if (!normalizedBasename.startsWith(".env.")) return false;
|
|
68
|
+
return !isSafeEnvTemplate(normalizedBasename);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isProtectedPath(normalizedPath: NormalizedPath): boolean {
|
|
72
|
+
if (normalizedPath.segments.some((segment) => protectedPathSegments.has(segment.toLowerCase()))) return true;
|
|
73
|
+
return isProtectedEnvFile(normalizedPath.basename);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateWriteInput(input: unknown): WriteInput | undefined {
|
|
77
|
+
if (!isRecord(input)) return undefined;
|
|
78
|
+
if (typeof input.path !== "string" || typeof input.content !== "string") return undefined;
|
|
79
|
+
if (!normalizeToolPath(input.path)) return undefined;
|
|
80
|
+
return { path: input.path, content: input.content };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateEditInput(input: unknown): EditInput | undefined {
|
|
84
|
+
if (!isRecord(input)) return undefined;
|
|
85
|
+
if (typeof input.path !== "string" || !Array.isArray(input.edits)) return undefined;
|
|
86
|
+
if (!normalizeToolPath(input.path)) return undefined;
|
|
12
87
|
|
|
88
|
+
const edits = input.edits;
|
|
89
|
+
if (
|
|
90
|
+
edits.some(
|
|
91
|
+
(edit) =>
|
|
92
|
+
!isRecord(edit) || typeof edit.oldText !== "string" || typeof edit.newText !== "string",
|
|
93
|
+
)
|
|
94
|
+
) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
path: input.path,
|
|
100
|
+
edits: edits as Array<{ oldText: string; newText: string }>,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function confirmProtectedPathAction(
|
|
105
|
+
toolName: "write" | "edit",
|
|
106
|
+
normalizedPath: NormalizedPath,
|
|
107
|
+
ctx: { hasUI?: boolean; ui?: { select(prompt: string, options: string[]): Promise<string | undefined> } },
|
|
108
|
+
): Promise<GuardDecision> {
|
|
109
|
+
if (!isProtectedPath(normalizedPath)) return undefined;
|
|
110
|
+
if (!ctx.hasUI || !ctx.ui) {
|
|
111
|
+
return { block: true, reason: `Protected path blocked (${toolName} without UI confirmation): ${normalizedPath.displayPath}` };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const choice = await ctx.ui.select(
|
|
115
|
+
`⚠️ Protected path ${toolName} request:\n\n ${normalizedPath.displayPath}\n\nAllow?`,
|
|
116
|
+
["Yes", "No"],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (choice !== "Yes") {
|
|
120
|
+
return { block: true, reason: "Blocked by user" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export default function (pi: ExtensionAPI) {
|
|
13
127
|
pi.on("tool_call", async (event, ctx) => {
|
|
14
|
-
if (event.toolName
|
|
128
|
+
if (event.toolName === "bash") {
|
|
129
|
+
const command = event.input?.command;
|
|
130
|
+
if (typeof command !== "string" || command.trim() === "") {
|
|
131
|
+
return { block: true, reason: "Malformed bash command blocked" };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const isDangerous = dangerousPatterns.some((p) => p.test(command));
|
|
135
|
+
|
|
136
|
+
if (isDangerous) {
|
|
137
|
+
if (!ctx.hasUI) {
|
|
138
|
+
return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
|
|
139
|
+
}
|
|
15
140
|
|
|
16
|
-
|
|
17
|
-
const isDangerous = dangerousPatterns.some((p) => p.test(command));
|
|
141
|
+
const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]);
|
|
18
142
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
|
|
143
|
+
if (choice !== "Yes") {
|
|
144
|
+
return { block: true, reason: "Blocked by user" };
|
|
145
|
+
}
|
|
23
146
|
}
|
|
24
147
|
|
|
25
|
-
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
26
150
|
|
|
27
|
-
|
|
28
|
-
|
|
151
|
+
if (event.toolName === "write") {
|
|
152
|
+
const input = validateWriteInput(event.input);
|
|
153
|
+
if (!input) {
|
|
154
|
+
return { block: true, reason: "Malformed write input blocked" };
|
|
155
|
+
}
|
|
156
|
+
const normalizedPath = normalizeToolPath(input.path);
|
|
157
|
+
if (!normalizedPath) {
|
|
158
|
+
return { block: true, reason: "Malformed write input blocked" };
|
|
159
|
+
}
|
|
160
|
+
return confirmProtectedPathAction("write", normalizedPath, ctx);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (event.toolName === "edit") {
|
|
164
|
+
const input = validateEditInput(event.input);
|
|
165
|
+
if (!input) {
|
|
166
|
+
return { block: true, reason: "Malformed edit input blocked" };
|
|
167
|
+
}
|
|
168
|
+
const normalizedPath = normalizeToolPath(input.path);
|
|
169
|
+
if (!normalizedPath) {
|
|
170
|
+
return { block: true, reason: "Malformed edit input blocked" };
|
|
29
171
|
}
|
|
172
|
+
return confirmProtectedPathAction("edit", normalizedPath, ctx);
|
|
30
173
|
}
|
|
31
174
|
|
|
32
175
|
return undefined;
|
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diegopetrucci/pi-permission-gate",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "A pi extension that prompts before dangerous bash commands.",
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"description": "A pi extension that prompts before dangerous bash commands and protected file writes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi",
|
|
8
8
|
"security",
|
|
9
|
-
"bash"
|
|
9
|
+
"bash",
|
|
10
|
+
"permissions",
|
|
11
|
+
"file-safety"
|
|
10
12
|
],
|
|
11
13
|
"license": "MIT",
|
|
12
14
|
"repository": {
|