@diegopetrucci/pi-permission-gate 0.1.4 → 0.1.8

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.
@@ -1 +1 @@
1
- 0.79.10
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
- If pi is running without an interactive UI, it blocks matching commands by default.
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
- - Only inspects the `bash` tool.
45
- - Prompts with a simple `Yes` / `No` selector before allowing dangerous commands.
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
- * Patterns checked: rm -rf, sudo, chmod/chown 777
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
- export default function (pi: ExtensionAPI) {
11
- const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
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 !== "bash") return undefined;
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
- const command = event.input.command as string;
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
- if (isDangerous) {
20
- if (!ctx.hasUI) {
21
- // In non-interactive mode, block by default
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
- const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]);
148
+ return undefined;
149
+ }
26
150
 
27
- if (choice !== "Yes") {
28
- return { block: true, reason: "Blocked by user" };
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",
4
- "description": "A pi extension that prompts before dangerous bash commands.",
3
+ "version": "0.1.8",
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": {
@@ -29,5 +31,9 @@
29
31
  },
30
32
  "peerDependencies": {
31
33
  "@earendil-works/pi-coding-agent": "*"
34
+ },
35
+ "type": "module",
36
+ "engines": {
37
+ "node": ">=22.19.0"
32
38
  }
33
39
  }