@gotgenes/pi-permission-system 4.0.1 → 4.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/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.1.0](https://github.com/gotgenes/pi-permission-system/compare/v4.0.1...v4.1.0) (2026-05-04)
9
+
10
+
11
+ ### Features
12
+
13
+ * replace regex tokenizer with shell-quote ([#72](https://github.com/gotgenes/pi-permission-system/issues/72)) ([1568992](https://github.com/gotgenes/pi-permission-system/commit/1568992fb82b45c84b10e3cc9c50777f42d30dfa))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * plan shell-quote tokenizer migration ([#72](https://github.com/gotgenes/pi-permission-system/issues/72)) ([0390e06](https://github.com/gotgenes/pi-permission-system/commit/0390e06de5ca0e5cc79e438f2a94db610ca90289))
19
+ * **retro:** add retro notes for issue [#68](https://github.com/gotgenes/pi-permission-system/issues/68) ([4775453](https://github.com/gotgenes/pi-permission-system/commit/47754539806fbe15f739ea0eaa4a100a69db82ef))
20
+
8
21
  ## [4.0.1](https://github.com/gotgenes/pi-permission-system/compare/v4.0.0...v4.0.1) (2026-05-04)
9
22
 
10
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -56,10 +56,14 @@
56
56
  "@mariozechner/pi-coding-agent": "^0.72.1",
57
57
  "@mariozechner/pi-tui": "^0.72.1",
58
58
  "@types/node": "^25.6.0",
59
+ "@types/shell-quote": "^1.7.5",
59
60
  "markdownlint-cli2": "^0.22.1",
60
61
  "typescript": "6.0.3",
61
62
  "vitest": "^4.1.5"
62
63
  },
64
+ "dependencies": {
65
+ "shell-quote": "^1.8.3"
66
+ },
63
67
  "scripts": {
64
68
  "build": "tsc -p tsconfig.json",
65
69
  "lint": "biome check .",
@@ -1,6 +1,8 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join, normalize, resolve, sep } from "node:path";
3
3
 
4
+ import { parse } from "shell-quote";
5
+
4
6
  import { getNonEmptyString, toRecord } from "./common";
5
7
 
6
8
  /**
@@ -196,27 +198,20 @@ function classifyTokenAsPathCandidate(token: string): string | null {
196
198
  return null;
197
199
  }
198
200
 
199
- /**
200
- * Strips content inside single and double quotes from a command string.
201
- * Replaces quoted segments with empty string so paths inside quotes are not tokenized.
202
- * This is a simple regex approach — it cannot handle escaped quotes within strings.
203
- */
204
- function stripQuotedStrings(command: string): string {
205
- return command.replace(/"[^"]*"/g, "").replace(/'[^']*'/g, "");
206
- }
207
-
208
201
  /**
209
202
  * Extracts paths from a bash command string that resolve outside CWD.
210
- * This is a best-effort heuristic (token splitting, not full shell parsing).
203
+ * Uses shell-quote to tokenize so that quoted strings, operators, and comments
204
+ * are handled correctly, eliminating false positives from the old regex approach.
211
205
  */
212
206
  export function extractExternalPathsFromBashCommand(
213
207
  command: string,
214
208
  cwd: string,
215
209
  ): string[] {
216
- // Strip quoted strings to avoid false positives on paths in messages
217
- const unquoted = stripQuotedStrings(command);
218
- // Split on shell metacharacters to isolate tokens
219
- const tokens = unquoted.split(/[|;&><\s]+/).filter(Boolean);
210
+ // shell-quote parse() returns strings (resolved arguments), operator objects,
211
+ // glob objects, and comment objects. Only string entries are path candidates.
212
+ const tokens = parse(command).filter(
213
+ (entry): entry is string => typeof entry === "string",
214
+ );
220
215
  const seen = new Set<string>();
221
216
  const externalPaths: string[] = [];
222
217
 
@@ -213,8 +213,9 @@ describe("extractExternalPathsFromBashCommand", () => {
213
213
  expect(result).toContain("/etc/hosts");
214
214
  });
215
215
 
216
- test.fails("escaped quotes inside strings cause false positive (known limitation)", () => {
217
- // The regex-based quote stripping can't handle escaped quotes
216
+ test("does not flag path when adjacent quoted segments form one word", () => {
217
+ // shell-quote concatenates adjacent quoted/unquoted segments into one word:
218
+ // '"path is "/etc/hosts""' → 'path is /etc/hosts' (one token, not a path candidate).
218
219
  const result = extractExternalPathsFromBashCommand(
219
220
  'echo "path is "/etc/hosts""',
220
221
  cwd,
@@ -300,6 +301,15 @@ describe("extractExternalPathsFromBashCommand", () => {
300
301
  expect(result).toHaveLength(0);
301
302
  });
302
303
 
304
+ test("bare-slash guard is still needed: shell-quote emits / as a string token", () => {
305
+ // shell-quote.parse('echo /') returns ['echo', '/'] — the bare slash IS a string
306
+ // token, not an operator. classifyTokenAsPathCandidate must still reject it.
307
+ // This test documents that the /^\/+$/ guard remains a necessary
308
+ // defense-in-depth layer even with shell-quote as the tokenizer.
309
+ const result = extractExternalPathsFromBashCommand("echo /", cwd);
310
+ expect(result).toHaveLength(0);
311
+ });
312
+
303
313
  test("still flags real external path alongside //", () => {
304
314
  const result = extractExternalPathsFromBashCommand(
305
315
  "cat /etc/hosts; echo //",
@@ -310,6 +320,70 @@ describe("extractExternalPathsFromBashCommand", () => {
310
320
  });
311
321
  });
312
322
 
323
+ describe("node -e and multi-line commands", () => {
324
+ test("does not flag path inside single-quoted string in node -e argument", () => {
325
+ const result = extractExternalPathsFromBashCommand(
326
+ "node -e \"const p = '/etc/hosts'; console.log(p);\"",
327
+ cwd,
328
+ );
329
+ expect(result).toHaveLength(0);
330
+ });
331
+
332
+ test("does not flag path inside multi-line node -e argument", () => {
333
+ // Actual newlines inside the double-quoted -e argument.
334
+ const cmd =
335
+ "node -e \"\nimport('x').then(() => {\n console.log('/etc/hosts');\n});\n\"";
336
+ const result = extractExternalPathsFromBashCommand(cmd, cwd);
337
+ expect(result).toHaveLength(0);
338
+ });
339
+
340
+ test("does not flag path that appears after escaped quote in multi-line node -e argument", () => {
341
+ // This is the shape of the command that triggered a prompt during dog-fooding.
342
+ // The outer \"...\" arg contains both actual newlines and \\" escape sequences,
343
+ // with /etc/hosts appearing after a \\" boundary.
344
+ const cmd = [
345
+ 'node -e "',
346
+ "import('shell-quote').then(({ parse }) => {",
347
+ " const cmd = \\\"cat << 'EOF'\\n/etc/hosts\\nsome content\\nEOF\\\";",
348
+ " console.log(JSON.stringify(parse(cmd)));",
349
+ "});",
350
+ '"',
351
+ ].join("\n");
352
+ const result = extractExternalPathsFromBashCommand(cmd, cwd);
353
+ expect(result).toHaveLength(0);
354
+ });
355
+ });
356
+
357
+ describe("shell-quote tokenizer edge cases", () => {
358
+ test("does not flag path inside string when escaped quote is present", () => {
359
+ // stripQuotedStrings regex breaks at \" — content after it leaks into the token stream.
360
+ // shell-quote correctly parses the escaped quote and keeps the path inside the string.
361
+ const result = extractExternalPathsFromBashCommand(
362
+ 'git commit -m "fix: update \\"the /etc/hosts\\" handler"',
363
+ cwd,
364
+ );
365
+ expect(result).toHaveLength(0);
366
+ });
367
+
368
+ test("does not flag path appearing only in a shell comment", () => {
369
+ const result = extractExternalPathsFromBashCommand(
370
+ "echo hello # /etc/shadow",
371
+ cwd,
372
+ );
373
+ expect(result).toHaveLength(0);
374
+ });
375
+
376
+ test("flags real path before comment but not path inside comment", () => {
377
+ const result = extractExternalPathsFromBashCommand(
378
+ "cat /etc/hosts # see also /etc/shadow",
379
+ cwd,
380
+ );
381
+ expect(result).toContain("/etc/hosts");
382
+ expect(result).not.toContain("/etc/shadow");
383
+ expect(result).toHaveLength(1);
384
+ });
385
+ });
386
+
313
387
  describe("deduplication", () => {
314
388
  test("returns deduplicated paths", () => {
315
389
  const result = extractExternalPathsFromBashCommand(