@gotgenes/pi-permission-system 4.0.0 → 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,33 @@ 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
+
21
+ ## [4.0.1](https://github.com/gotgenes/pi-permission-system/compare/v4.0.0...v4.0.1) (2026-05-04)
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * skip bare-slash tokens in bash external-directory extraction ([#68](https://github.com/gotgenes/pi-permission-system/issues/68)) ([84f9a88](https://github.com/gotgenes/pi-permission-system/commit/84f9a88243c0033ddf1ca72894ceb42eb0f5f298))
27
+
28
+
29
+ ### Documentation
30
+
31
+ * plan skip bare-slash tokens in external-directory extraction ([#68](https://github.com/gotgenes/pi-permission-system/issues/68)) ([f4fded8](https://github.com/gotgenes/pi-permission-system/commit/f4fded847ab7f4c8b82ebcd08edb0cb640d18fa7))
32
+ * plan skip bare-slash tokens in external-directory extraction ([#68](https://github.com/gotgenes/pi-permission-system/issues/68)) ([f33964a](https://github.com/gotgenes/pi-permission-system/commit/f33964a34da726e3667319bf2015193de171767c))
33
+ * **retro:** add retro notes for issue [#66](https://github.com/gotgenes/pi-permission-system/issues/66) ([61d7e5c](https://github.com/gotgenes/pi-permission-system/commit/61d7e5ca30c20c48f52152f9443ede1900010410))
34
+
8
35
  ## [4.0.0](https://github.com/gotgenes/pi-permission-system/compare/v3.11.0...v4.0.0) (2026-05-04)
9
36
 
10
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "4.0.0",
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
  /**
@@ -184,6 +186,10 @@ function classifyTokenAsPathCandidate(token: string): string | null {
184
186
  // Skip @scope/package patterns
185
187
  if (token.startsWith("@") && !token.startsWith("@/")) return null;
186
188
 
189
+ // Skip bare-slash tokens (// JS comments, lone /, etc.) — they resolve to root
190
+ // and are never meaningful path arguments in practice.
191
+ if (/^\/+$/.test(token)) return null;
192
+
187
193
  // Must look like a path: starts with /, ~/, or contains ..
188
194
  if (token.startsWith("/")) return token;
189
195
  if (token.startsWith("~/")) return token;
@@ -192,27 +198,20 @@ function classifyTokenAsPathCandidate(token: string): string | null {
192
198
  return null;
193
199
  }
194
200
 
195
- /**
196
- * Strips content inside single and double quotes from a command string.
197
- * Replaces quoted segments with empty string so paths inside quotes are not tokenized.
198
- * This is a simple regex approach — it cannot handle escaped quotes within strings.
199
- */
200
- function stripQuotedStrings(command: string): string {
201
- return command.replace(/"[^"]*"/g, "").replace(/'[^']*'/g, "");
202
- }
203
-
204
201
  /**
205
202
  * Extracts paths from a bash command string that resolve outside CWD.
206
- * 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.
207
205
  */
208
206
  export function extractExternalPathsFromBashCommand(
209
207
  command: string,
210
208
  cwd: string,
211
209
  ): string[] {
212
- // Strip quoted strings to avoid false positives on paths in messages
213
- const unquoted = stripQuotedStrings(command);
214
- // Split on shell metacharacters to isolate tokens
215
- 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
+ );
216
215
  const seen = new Set<string>();
217
216
  const externalPaths: string[] = [];
218
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,
@@ -279,6 +280,110 @@ describe("extractExternalPathsFromBashCommand", () => {
279
280
  });
280
281
  });
281
282
 
283
+ describe("bare-slash tokens are skipped", () => {
284
+ test("does not flag // token", () => {
285
+ const result = extractExternalPathsFromBashCommand("echo //", cwd);
286
+ expect(result).toHaveLength(0);
287
+ });
288
+
289
+ test("does not flag / token", () => {
290
+ const result = extractExternalPathsFromBashCommand("echo /", cwd);
291
+ expect(result).toHaveLength(0);
292
+ });
293
+
294
+ test("does not flag /// token", () => {
295
+ const result = extractExternalPathsFromBashCommand("echo ///", cwd);
296
+ expect(result).toHaveLength(0);
297
+ });
298
+
299
+ test("does not flag // in echo with other args", () => {
300
+ const result = extractExternalPathsFromBashCommand("echo // hello", cwd);
301
+ expect(result).toHaveLength(0);
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
+
313
+ test("still flags real external path alongside //", () => {
314
+ const result = extractExternalPathsFromBashCommand(
315
+ "cat /etc/hosts; echo //",
316
+ cwd,
317
+ );
318
+ expect(result).toContain("/etc/hosts");
319
+ expect(result).toHaveLength(1);
320
+ });
321
+ });
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
+
282
387
  describe("deduplication", () => {
283
388
  test("returns deduplicated paths", () => {
284
389
  const result = extractExternalPathsFromBashCommand(