@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 +27 -0
- package/package.json +5 -1
- package/src/external-directory.ts +13 -14
- package/tests/bash-external-directory.test.ts +107 -2
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.
|
|
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
|
-
*
|
|
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
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
217
|
-
//
|
|
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(
|