@gotgenes/pi-permission-system 3.1.0 → 3.2.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 +16 -0
- package/README.md +1 -1
- package/package.json +3 -8
- package/src/external-directory.ts +26 -5
- package/tests/bash-external-directory.test.ts +56 -0
- package/tests/external-directory.test.ts +65 -0
- package/index.ts +0 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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
|
+
## [3.2.0](https://github.com/gotgenes/pi-permission-system/compare/v3.1.0...v3.2.0) (2026-05-03)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add SAFE_SYSTEM_PATHS allowlist and isSafeSystemPath helper ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([331b53f](https://github.com/gotgenes/pi-permission-system/commit/331b53f1a6425c7ee641127cbc82b5aada1e7018))
|
|
14
|
+
* filter safe system paths from bash external path extraction ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([a0a907f](https://github.com/gotgenes/pi-permission-system/commit/a0a907f020cbf08722ea47be11d3f67fc95ef448))
|
|
15
|
+
* skip safe system paths in isPathOutsideWorkingDirectory ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([360594c](https://github.com/gotgenes/pi-permission-system/commit/360594c8ddfd6f7f45abe04352a514de292df357))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Documentation
|
|
19
|
+
|
|
20
|
+
* clarify /dev/null redirect risks in plan [#44](https://github.com/gotgenes/pi-permission-system/issues/44) ([00c61e7](https://github.com/gotgenes/pi-permission-system/commit/00c61e75eb5bf3cd9d5fc3297024ef9642655b86))
|
|
21
|
+
* note safe system path allowlist in external-directory section ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([eaec9ae](https://github.com/gotgenes/pi-permission-system/commit/eaec9ae4ad88155bf2630bba9607920cbbdc8583))
|
|
22
|
+
* plan auto-allow /dev/null in external directory checks ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([90b94f4](https://github.com/gotgenes/pi-permission-system/commit/90b94f4e0ae01b3a9f9dc90c5426720742a652e2))
|
|
23
|
+
|
|
8
24
|
## [3.1.0](https://github.com/gotgenes/pi-permission-system/compare/v3.0.5...v3.1.0) (2026-05-03)
|
|
9
25
|
|
|
10
26
|
|
package/README.md
CHANGED
|
@@ -367,7 +367,7 @@ Reserved permission checks:
|
|
|
367
367
|
|
|
368
368
|
`external_directory` is evaluated before the normal tool permission check. For example, `tools.read: "allow"` can permit ordinary reads while `special.external_directory: "ask"` still requires confirmation before reading `../outside.txt` or an absolute path outside `ctx.cwd`. Optional-path search tools (`find`, `grep`, `ls`) skip this check when no `path` is provided because they default to the active working directory.
|
|
369
369
|
|
|
370
|
-
Bash commands are also covered: the extension extracts path-like tokens from the command string and applies the same gate when any resolve outside `ctx.cwd`. Quoted strings are stripped first to reduce false positives (e.g., paths inside `git commit -m "..."` messages). This is a best-effort heuristic — variable expansion, subshells, and escaped quotes are not parsed.
|
|
370
|
+
Bash commands are also covered: the extension extracts path-like tokens from the command string and applies the same gate when any resolve outside `ctx.cwd`. Quoted strings are stripped first to reduce false positives (e.g., paths inside `git commit -m "..."` messages). This is a best-effort heuristic — variable expansion, subshells, and escaped quotes are not parsed. OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded from this check — they cannot hold or leak data and commonly appear in stderr-redirect idioms such as `command 2>/dev/null`.
|
|
371
371
|
|
|
372
372
|
---
|
|
373
373
|
|
package/package.json
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-permission-system",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./index.ts",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./index.ts"
|
|
9
|
-
},
|
|
10
6
|
"files": [
|
|
11
|
-
"index.ts",
|
|
12
7
|
"src",
|
|
13
8
|
"tests",
|
|
14
9
|
"config/config.example.json",
|
|
@@ -49,7 +44,7 @@
|
|
|
49
44
|
},
|
|
50
45
|
"pi": {
|
|
51
46
|
"extensions": [
|
|
52
|
-
"./index.ts"
|
|
47
|
+
"./src/index.ts"
|
|
53
48
|
]
|
|
54
49
|
},
|
|
55
50
|
"peerDependencies": {
|
|
@@ -71,7 +66,7 @@
|
|
|
71
66
|
"lint:fix": "biome check --write .",
|
|
72
67
|
"lint:md": "markdownlint-cli2 '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
|
|
73
68
|
"lint:md:fix": "markdownlint-cli2 --fix '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
|
|
74
|
-
"lint:imports": "! grep -rn --include='*.ts' 'from \"\\.[./][^\"]*\\.js\"' src/ tests/
|
|
69
|
+
"lint:imports": "! grep -rn --include='*.ts' 'from \"\\.[./][^\"]*\\.js\"' src/ tests/",
|
|
75
70
|
"lint:all": "pnpm run lint && pnpm run lint:md && pnpm run lint:imports",
|
|
76
71
|
"format": "biome format --write .",
|
|
77
72
|
"test": "vitest run",
|
|
@@ -3,6 +3,25 @@ import { join, normalize, resolve, sep } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { getNonEmptyString, toRecord } from "./common";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Paths that are universally safe and should never trigger external-directory checks.
|
|
8
|
+
* These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
|
|
9
|
+
*/
|
|
10
|
+
export const SAFE_SYSTEM_PATHS: ReadonlySet<string> = new Set([
|
|
11
|
+
"/dev/null",
|
|
12
|
+
"/dev/stdin",
|
|
13
|
+
"/dev/stdout",
|
|
14
|
+
"/dev/stderr",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns true if the given normalized path is a safe OS device file
|
|
19
|
+
* that should never trigger external-directory checks.
|
|
20
|
+
*/
|
|
21
|
+
export function isSafeSystemPath(normalizedPath: string): boolean {
|
|
22
|
+
return SAFE_SYSTEM_PATHS.has(normalizedPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
6
25
|
export const PATH_BEARING_TOOLS = new Set([
|
|
7
26
|
"read",
|
|
8
27
|
"write",
|
|
@@ -72,11 +91,13 @@ export function isPathOutsideWorkingDirectory(
|
|
|
72
91
|
): boolean {
|
|
73
92
|
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
74
93
|
const normalizedPath = normalizePathForComparison(pathValue, cwd);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
94
|
+
if (!normalizedCwd || !normalizedPath) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
if (isSafeSystemPath(normalizedPath)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
return !isPathWithinDirectory(normalizedPath, normalizedCwd);
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
export function formatExternalDirectoryHardStopHint(): string {
|
|
@@ -223,6 +223,62 @@ describe("extractExternalPathsFromBashCommand", () => {
|
|
|
223
223
|
});
|
|
224
224
|
});
|
|
225
225
|
|
|
226
|
+
describe("safe system paths are filtered", () => {
|
|
227
|
+
test("does not flag /dev/null in stderr redirect", () => {
|
|
228
|
+
const result = extractExternalPathsFromBashCommand(
|
|
229
|
+
"command 2>/dev/null",
|
|
230
|
+
cwd,
|
|
231
|
+
);
|
|
232
|
+
expect(result).toHaveLength(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("does not flag /dev/null as a redirect target", () => {
|
|
236
|
+
const result = extractExternalPathsFromBashCommand(
|
|
237
|
+
"echo hello > /dev/null",
|
|
238
|
+
cwd,
|
|
239
|
+
);
|
|
240
|
+
expect(result).toHaveLength(0);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("does not flag /dev/stdin", () => {
|
|
244
|
+
const result = extractExternalPathsFromBashCommand("cat /dev/stdin", cwd);
|
|
245
|
+
expect(result).toHaveLength(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("does not flag /dev/stdout", () => {
|
|
249
|
+
const result = extractExternalPathsFromBashCommand(
|
|
250
|
+
"cat /dev/stdout",
|
|
251
|
+
cwd,
|
|
252
|
+
);
|
|
253
|
+
expect(result).toHaveLength(0);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("does not flag /dev/stderr", () => {
|
|
257
|
+
const result = extractExternalPathsFromBashCommand(
|
|
258
|
+
"cat /dev/stderr",
|
|
259
|
+
cwd,
|
|
260
|
+
);
|
|
261
|
+
expect(result).toHaveLength(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("still flags a real external path alongside /dev/null", () => {
|
|
265
|
+
const result = extractExternalPathsFromBashCommand(
|
|
266
|
+
"cat /etc/hosts 2>/dev/null",
|
|
267
|
+
cwd,
|
|
268
|
+
);
|
|
269
|
+
expect(result).toContain("/etc/hosts");
|
|
270
|
+
expect(result).not.toContain("/dev/null");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("does not flag /dev/null/subdir (not a safe path)", () => {
|
|
274
|
+
const result = extractExternalPathsFromBashCommand(
|
|
275
|
+
"cat /dev/null/subdir",
|
|
276
|
+
cwd,
|
|
277
|
+
);
|
|
278
|
+
expect(result).toContain("/dev/null/subdir");
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
226
282
|
describe("deduplication", () => {
|
|
227
283
|
test("returns deduplicated paths", () => {
|
|
228
284
|
const result = extractExternalPathsFromBashCommand(
|
|
@@ -18,8 +18,10 @@ import {
|
|
|
18
18
|
getPathBearingToolPath,
|
|
19
19
|
isPathOutsideWorkingDirectory,
|
|
20
20
|
isPathWithinDirectory,
|
|
21
|
+
isSafeSystemPath,
|
|
21
22
|
normalizePathForComparison,
|
|
22
23
|
PATH_BEARING_TOOLS,
|
|
24
|
+
SAFE_SYSTEM_PATHS,
|
|
23
25
|
} from "../src/external-directory";
|
|
24
26
|
|
|
25
27
|
afterEach(() => {
|
|
@@ -39,6 +41,49 @@ describe("PATH_BEARING_TOOLS", () => {
|
|
|
39
41
|
});
|
|
40
42
|
});
|
|
41
43
|
|
|
44
|
+
describe("SAFE_SYSTEM_PATHS", () => {
|
|
45
|
+
test("contains /dev/null, /dev/stdin, /dev/stdout, /dev/stderr", () => {
|
|
46
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/null")).toBe(true);
|
|
47
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/stdin")).toBe(true);
|
|
48
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/stdout")).toBe(true);
|
|
49
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/stderr")).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("isSafeSystemPath", () => {
|
|
54
|
+
test("returns true for /dev/null", () => {
|
|
55
|
+
expect(isSafeSystemPath("/dev/null")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("returns true for /dev/stdin", () => {
|
|
59
|
+
expect(isSafeSystemPath("/dev/stdin")).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("returns true for /dev/stdout", () => {
|
|
63
|
+
expect(isSafeSystemPath("/dev/stdout")).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("returns true for /dev/stderr", () => {
|
|
67
|
+
expect(isSafeSystemPath("/dev/stderr")).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("returns false for an arbitrary absolute path", () => {
|
|
71
|
+
expect(isSafeSystemPath("/etc/passwd")).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("returns false for a path prefixed with a safe system path", () => {
|
|
75
|
+
expect(isSafeSystemPath("/dev/null/subdir")).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("returns false for an empty string", () => {
|
|
79
|
+
expect(isSafeSystemPath("")).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("returns false for a relative path", () => {
|
|
83
|
+
expect(isSafeSystemPath("dev/null")).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
42
87
|
describe("normalizePathForComparison", () => {
|
|
43
88
|
const cwd = "/projects/my-app";
|
|
44
89
|
|
|
@@ -163,6 +208,26 @@ describe("isPathOutsideWorkingDirectory", () => {
|
|
|
163
208
|
test("returns false for empty path (normalizes to empty string)", () => {
|
|
164
209
|
expect(isPathOutsideWorkingDirectory("", cwd)).toBe(false);
|
|
165
210
|
});
|
|
211
|
+
|
|
212
|
+
test("returns false for /dev/null regardless of cwd", () => {
|
|
213
|
+
expect(isPathOutsideWorkingDirectory("/dev/null", cwd)).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("returns false for /dev/stdin regardless of cwd", () => {
|
|
217
|
+
expect(isPathOutsideWorkingDirectory("/dev/stdin", cwd)).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("returns false for /dev/stdout regardless of cwd", () => {
|
|
221
|
+
expect(isPathOutsideWorkingDirectory("/dev/stdout", cwd)).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("returns false for /dev/stderr regardless of cwd", () => {
|
|
225
|
+
expect(isPathOutsideWorkingDirectory("/dev/stderr", cwd)).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("returns true for /dev/null/subdir (not a safe path)", () => {
|
|
229
|
+
expect(isPathOutsideWorkingDirectory("/dev/null/subdir", cwd)).toBe(true);
|
|
230
|
+
});
|
|
166
231
|
});
|
|
167
232
|
|
|
168
233
|
describe("formatExternalDirectoryHardStopHint", () => {
|
package/index.ts
DELETED