@diegopetrucci/pi-permission-gate 0.1.7 → 0.1.9

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.80.6
1
+ 0.81.1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A small pi extension that prompts for confirmation before running potentially dangerous bash commands or writing to protected paths.
4
4
 
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.
5
+ This is adapted from the original `permission-gate.ts` example in [`earendil-works/pi`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/examples/extensions/permission-gate.ts) and kept basically the same.
6
6
 
7
7
  ## What it checks
8
8
 
package/index.ts CHANGED
@@ -11,8 +11,9 @@ import * as path from "node:path";
11
11
 
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
 
14
- const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
14
+ const dangerousPatterns = [/\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
15
15
  const protectedPathSegments = new Set([".git", "node_modules"]);
16
+ const shellCommandSeparators = new Set([";", "&", "&&", "||", "|", "\n", "(", ")", "{", "}"]);
16
17
  const safeEnvSuffixes = new Set(["example", "examples", "template", "templates"]);
17
18
 
18
19
  type GuardDecision = { block: true; reason: string } | undefined;
@@ -22,6 +23,10 @@ type NormalizedPath = {
22
23
  segments: string[];
23
24
  basename: string;
24
25
  };
26
+ type ShellToken = {
27
+ text: string;
28
+ quote?: '"' | "'";
29
+ };
25
30
 
26
31
  type WriteInput = { path: string; content: string };
27
32
  type EditInput = { path: string; edits: Array<{ oldText: string; newText: string }> };
@@ -73,6 +78,452 @@ function isProtectedPath(normalizedPath: NormalizedPath): boolean {
73
78
  return isProtectedEnvFile(normalizedPath.basename);
74
79
  }
75
80
 
81
+ function findClosingDelimiter(command: string, start: number, delimiter: ')' | '`'): number {
82
+ let quote: '"' | "'" | undefined;
83
+ let escaping = false;
84
+ let depth = delimiter === ')' ? 1 : 0;
85
+
86
+ for (let index = start; index < command.length; index += 1) {
87
+ const char = command[index];
88
+ const next = command[index + 1];
89
+
90
+ if (escaping) {
91
+ escaping = false;
92
+ continue;
93
+ }
94
+
95
+ if (quote) {
96
+ if (char === "\\" && quote === '"') {
97
+ escaping = true;
98
+ continue;
99
+ }
100
+ if (char === quote) {
101
+ quote = undefined;
102
+ }
103
+ continue;
104
+ }
105
+
106
+ if (char === "\\") {
107
+ escaping = true;
108
+ continue;
109
+ }
110
+
111
+ if (char === '"' || char === "'") {
112
+ quote = char;
113
+ continue;
114
+ }
115
+
116
+ if (delimiter === ')' && char === '$' && next === '(') {
117
+ depth += 1;
118
+ index += 1;
119
+ continue;
120
+ }
121
+
122
+ if (char === delimiter) {
123
+ if (delimiter === ')') {
124
+ depth -= 1;
125
+ if (depth === 0) return index;
126
+ continue;
127
+ }
128
+ return index;
129
+ }
130
+ }
131
+
132
+ return -1;
133
+ }
134
+
135
+ function tokenizeShellWords(command: string): ShellToken[] {
136
+ // Intentionally shallow tokenization for reviewable safety checks. This handles
137
+ // simple quoting, command separators, and nested command substitutions, but it does
138
+ // not attempt full shell grammar such as heredocs, arrays, or parameter expansion.
139
+ const tokens: ShellToken[] = [];
140
+ let current = "";
141
+ let currentQuote: '"' | "'" | undefined;
142
+ let escaping = false;
143
+
144
+ const flushCurrent = () => {
145
+ if (!current) return;
146
+ tokens.push({ text: current, quote: currentQuote });
147
+ current = "";
148
+ currentQuote = undefined;
149
+ };
150
+
151
+ for (let index = 0; index < command.length; index += 1) {
152
+ const char = command[index];
153
+ const next = command[index + 1];
154
+
155
+ if (escaping) {
156
+ current += char;
157
+ escaping = false;
158
+ continue;
159
+ }
160
+
161
+ if (currentQuote) {
162
+ if (char === "\\" && currentQuote === '"') {
163
+ escaping = true;
164
+ continue;
165
+ }
166
+ if (char === currentQuote) {
167
+ flushCurrent();
168
+ continue;
169
+ }
170
+ current += char;
171
+ continue;
172
+ }
173
+
174
+ if (char === "\\") {
175
+ escaping = true;
176
+ continue;
177
+ }
178
+
179
+ if (char === '"' || char === "'") {
180
+ flushCurrent();
181
+ currentQuote = char;
182
+ continue;
183
+ }
184
+
185
+ if (char === "$" && next === "(") {
186
+ const closingIndex = findClosingDelimiter(command, index + 2, ')');
187
+ if (closingIndex !== -1) {
188
+ flushCurrent();
189
+ tokens.push({ text: command.slice(index, closingIndex + 1) });
190
+ index = closingIndex;
191
+ continue;
192
+ }
193
+ }
194
+
195
+ if (char === "`") {
196
+ const closingIndex = findClosingDelimiter(command, index + 1, '`');
197
+ if (closingIndex !== -1) {
198
+ flushCurrent();
199
+ tokens.push({ text: command.slice(index, closingIndex + 1) });
200
+ index = closingIndex;
201
+ continue;
202
+ }
203
+ }
204
+
205
+ if (char === "&" && next === "&") {
206
+ flushCurrent();
207
+ tokens.push({ text: "&&" });
208
+ index += 1;
209
+ continue;
210
+ }
211
+
212
+ if (char === "|" && next === "|") {
213
+ flushCurrent();
214
+ tokens.push({ text: "||" });
215
+ index += 1;
216
+ continue;
217
+ }
218
+
219
+ if (char === ";" || char === "&" || char === "|" || char === "\n" || char === "(" || char === ")" || char === "{" || char === "}") {
220
+ flushCurrent();
221
+ tokens.push({ text: char });
222
+ continue;
223
+ }
224
+
225
+ if (/\s/.test(char)) {
226
+ flushCurrent();
227
+ continue;
228
+ }
229
+
230
+ current += char;
231
+ }
232
+
233
+ flushCurrent();
234
+ return tokens;
235
+ }
236
+
237
+ function isShellAssignmentToken(token: string): boolean {
238
+ return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
239
+ }
240
+
241
+ function hasDangerousSubstitution(command: string): boolean {
242
+ let quote: '"' | "'" | undefined;
243
+ let escaping = false;
244
+
245
+ for (let index = 0; index < command.length; index += 1) {
246
+ const char = command[index];
247
+ const next = command[index + 1];
248
+
249
+ if (escaping) {
250
+ escaping = false;
251
+ continue;
252
+ }
253
+
254
+ if (quote === "'") {
255
+ if (char === "'") quote = undefined;
256
+ continue;
257
+ }
258
+
259
+ if (char === "\\") {
260
+ escaping = true;
261
+ continue;
262
+ }
263
+
264
+ if (char === "'") {
265
+ quote = "'";
266
+ continue;
267
+ }
268
+
269
+ if (char === '"') {
270
+ quote = quote === '"' ? undefined : '"';
271
+ continue;
272
+ }
273
+
274
+ if (char === '$' && next === '(') {
275
+ const closingIndex = findClosingDelimiter(command, index + 2, ')');
276
+ if (closingIndex !== -1 && hasDangerousRecursiveRm(command.slice(index + 2, closingIndex))) return true;
277
+ if (closingIndex !== -1) index = closingIndex;
278
+ continue;
279
+ }
280
+
281
+ if (char === '`') {
282
+ const closingIndex = findClosingDelimiter(command, index + 1, '`');
283
+ if (closingIndex !== -1 && hasDangerousRecursiveRm(command.slice(index + 1, closingIndex))) return true;
284
+ if (closingIndex !== -1) index = closingIndex;
285
+ }
286
+ }
287
+
288
+ return false;
289
+ }
290
+
291
+ function basenameToken(token: string): string {
292
+ return path.posix.basename(token);
293
+ }
294
+
295
+ function unwrapLeadingWrappers(
296
+ tokens: ShellToken[],
297
+ startIndex: number,
298
+ ): { headIndex: number; nonExecuting: boolean; consumed: number } {
299
+ let index = startIndex;
300
+
301
+ const consumeOptionValue = (inlinePrefix: string, separateOptions: string[], longOption: string): boolean => {
302
+ const token = tokens[index]?.text;
303
+ if (!token) return false;
304
+ if (token === longOption || separateOptions.includes(token)) {
305
+ index += 1;
306
+ if (index < tokens.length) index += 1;
307
+ return true;
308
+ }
309
+ if (token.startsWith(`${longOption}=`) || (inlinePrefix && token.startsWith(inlinePrefix) && token.length > inlinePrefix.length)) {
310
+ index += 1;
311
+ return true;
312
+ }
313
+ return false;
314
+ };
315
+
316
+ const consumeOptionalLongOption = (inlinePrefix: string, shortOption: string, longOption: string): boolean => {
317
+ const token = tokens[index]?.text;
318
+ if (!token) return false;
319
+ if (token === longOption) {
320
+ index += 1;
321
+ return true;
322
+ }
323
+ if (token === shortOption) {
324
+ index += 1;
325
+ if (index < tokens.length) index += 1;
326
+ return true;
327
+ }
328
+ if (token.startsWith(`${longOption}=`) || token.startsWith(inlinePrefix) && token.length > inlinePrefix.length) {
329
+ index += 1;
330
+ return true;
331
+ }
332
+ return false;
333
+ };
334
+
335
+ while (index < tokens.length && isShellAssignmentToken(tokens[index].text)) index += 1;
336
+ while (tokens[index]?.text === "sudo") index += 1;
337
+
338
+ while (index < tokens.length) {
339
+ const current = tokens[index]?.text;
340
+ const currentBase = current ? basenameToken(current) : undefined;
341
+ if (!current) break;
342
+
343
+ if (current === "command") {
344
+ index += 1;
345
+ while (index < tokens.length) {
346
+ const option = tokens[index]?.text;
347
+ if (!option?.startsWith("-")) break;
348
+ if (option === "--") {
349
+ index += 1;
350
+ break;
351
+ }
352
+ if (option === "-v" || option === "-V") {
353
+ return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
354
+ }
355
+ index += 1;
356
+ }
357
+ continue;
358
+ }
359
+
360
+ if (currentBase === "env") {
361
+ index += 1;
362
+ while (index < tokens.length) {
363
+ const option = tokens[index]?.text;
364
+ if (!option) break;
365
+ if (option === "--") {
366
+ index += 1;
367
+ break;
368
+ }
369
+ if (option === "--help" || option === "--version") {
370
+ return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
371
+ }
372
+ if (isShellAssignmentToken(option)) {
373
+ index += 1;
374
+ continue;
375
+ }
376
+ if (consumeOptionValue("-u", ["-u"], "--unset")) continue;
377
+ if (consumeOptionValue("-C", ["-C"], "--chdir")) continue;
378
+ if (!option.startsWith("-")) break;
379
+ index += 1;
380
+ }
381
+ continue;
382
+ }
383
+
384
+ if (currentBase === "xargs") {
385
+ index += 1;
386
+ while (index < tokens.length) {
387
+ const option = tokens[index]?.text;
388
+ if (!option) break;
389
+ if (option === "--") {
390
+ index += 1;
391
+ break;
392
+ }
393
+ if (option === "--help" || option === "--version") {
394
+ return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
395
+ }
396
+ if (consumeOptionValue("-n", ["-n"], "--max-args")) continue;
397
+ if (consumeOptionValue("-P", ["-P"], "--max-procs")) continue;
398
+ if (consumeOptionalLongOption("-I", "-I", "--replace")) continue;
399
+ if (consumeOptionValue("-a", ["-a"], "--arg-file")) continue;
400
+ if (consumeOptionalLongOption("-E", "-E", "--eof")) continue;
401
+ if (consumeOptionValue("-s", ["-s"], "--max-chars")) continue;
402
+ if (consumeOptionValue("-d", ["-d"], "--delimiter")) continue;
403
+ if (consumeOptionalLongOption("-L", "-L", "--max-lines")) continue;
404
+ if (!option.startsWith("-")) break;
405
+ index += 1;
406
+ }
407
+ continue;
408
+ }
409
+
410
+ break;
411
+ }
412
+
413
+ return { headIndex: index, nonExecuting: false, consumed: index - startIndex };
414
+ }
415
+
416
+ function detectRecursiveForceRm(tokens: ShellToken[], headIndex: number): boolean {
417
+ const commandName = tokens[headIndex];
418
+ if (!commandName || basenameToken(commandName.text) !== "rm") return false;
419
+
420
+ let sawRecursive = false;
421
+ let sawForce = false;
422
+
423
+ for (let index = headIndex + 1; index < tokens.length; index += 1) {
424
+ const token = tokens[index]?.text;
425
+ if (!token) continue;
426
+ if (token === "--") break;
427
+ if (!token.startsWith("-") || token === "-") continue;
428
+ if (token === "--recursive") sawRecursive = true;
429
+ if (token === "--force") sawForce = true;
430
+ if (/^-[^-]+$/.test(token)) {
431
+ const flags = token.slice(1);
432
+ if (flags.includes("r") || flags.includes("R")) sawRecursive = true;
433
+ if (flags.includes("f")) sawForce = true;
434
+ }
435
+ }
436
+
437
+ return sawRecursive && sawForce;
438
+ }
439
+
440
+ function hasDangerousCommandHead(tokens: ShellToken[], headIndex: number): boolean {
441
+ if (detectRecursiveForceRm(tokens, headIndex)) return true;
442
+
443
+ const firstToken = tokens[headIndex];
444
+ if (!firstToken) return false;
445
+
446
+ if (["sh", "bash", "zsh", "dash", "ksh"].includes(basenameToken(firstToken.text))) {
447
+ for (let index = headIndex + 1; index < tokens.length; index += 1) {
448
+ const token = tokens[index]?.text;
449
+ if (!token) break;
450
+ if (token === "--") break;
451
+ if (token === "-c" || (/^-[A-Za-z]+$/.test(token) && token.includes("c"))) {
452
+ const script = tokens[index + 1]?.text;
453
+ return typeof script === "string" && hasDangerousRecursiveRm(script);
454
+ }
455
+ if (!token.startsWith("-")) break;
456
+ }
457
+ }
458
+
459
+ if (firstToken.text === "eval") {
460
+ return headIndex + 1 < tokens.length && hasDangerousRecursiveRm(tokens.slice(headIndex + 1).map(({ text }) => text).join(" "));
461
+ }
462
+
463
+ return false;
464
+ }
465
+
466
+ function hasDangerousCommandSuffix(tokens: ShellToken[]): boolean {
467
+ let index = 0;
468
+
469
+ while (index < tokens.length) {
470
+ const unwrapped = unwrapLeadingWrappers(tokens, index);
471
+ if (unwrapped.nonExecuting) return false;
472
+ if (hasDangerousCommandHead(tokens, unwrapped.headIndex)) return true;
473
+
474
+ // Check every possible executable head once, while skipping known-wrapper
475
+ // option values so data such as an xargs EOF marker is never treated as a command.
476
+ index += unwrapped.consumed + 1;
477
+ }
478
+
479
+ return false;
480
+ }
481
+
482
+ function hasDangerousRecursiveRm(command: string): boolean {
483
+ // Conservative detector: recurse through obvious command-substitution and shell-wrapper
484
+ // surfaces, then inspect argv-like tokens for rm plus recursive+force semantics.
485
+ if (hasDangerousSubstitution(command)) return true;
486
+
487
+ const tokens = tokenizeShellWords(command);
488
+ let currentCommand: ShellToken[] = [];
489
+ let findExecCommand: ShellToken[] | undefined;
490
+
491
+ const evaluateTokens = (commandTokens: ShellToken[]): boolean => hasDangerousCommandSuffix(commandTokens);
492
+
493
+ const resetCurrentCommand = () => {
494
+ currentCommand = [];
495
+ };
496
+
497
+ for (const token of tokens) {
498
+ if (findExecCommand) {
499
+ if (token.text === ";" || token.text === "+") {
500
+ if (evaluateTokens(findExecCommand)) return true;
501
+ findExecCommand = undefined;
502
+ resetCurrentCommand();
503
+ continue;
504
+ }
505
+ findExecCommand.push(token);
506
+ continue;
507
+ }
508
+
509
+ if (token.text === "-exec" || token.text === "-execdir") {
510
+ findExecCommand = [];
511
+ continue;
512
+ }
513
+
514
+ if (shellCommandSeparators.has(token.text)) {
515
+ if (evaluateTokens(currentCommand)) return true;
516
+ resetCurrentCommand();
517
+ continue;
518
+ }
519
+
520
+ currentCommand.push(token);
521
+ }
522
+
523
+ if (findExecCommand && evaluateTokens(findExecCommand)) return true;
524
+ return evaluateTokens(currentCommand);
525
+ }
526
+
76
527
  function validateWriteInput(input: unknown): WriteInput | undefined {
77
528
  if (!isRecord(input)) return undefined;
78
529
  if (typeof input.path !== "string" || typeof input.content !== "string") return undefined;
@@ -131,7 +582,7 @@ export default function (pi: ExtensionAPI) {
131
582
  return { block: true, reason: "Malformed bash command blocked" };
132
583
  }
133
584
 
134
- const isDangerous = dangerousPatterns.some((p) => p.test(command));
585
+ const isDangerous = hasDangerousRecursiveRm(command) || dangerousPatterns.some((p) => p.test(command));
135
586
 
136
587
  if (isDangerous) {
137
588
  if (!ctx.hasUI) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-permission-gate",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "A pi extension that prompts before dangerous bash commands and protected file writes.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -31,5 +31,9 @@
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@earendil-works/pi-coding-agent": "*"
34
+ },
35
+ "type": "module",
36
+ "engines": {
37
+ "node": ">=22.19.0"
34
38
  }
35
39
  }