@gotgenes/pi-permission-system 4.7.0 → 4.8.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 +25 -3
- package/config/config.example.json +4 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +2 -2
- package/src/expand-home.ts +28 -0
- package/src/wildcard-matcher.ts +4 -1
- package/tests/expand-home.test.ts +93 -0
- package/tests/permission-manager-unified.test.ts +74 -1
- package/tests/wildcard-matcher.test.ts +58 -0
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
|
+
## [4.8.0](https://github.com/gotgenes/pi-permission-system/compare/v4.7.0...v4.8.0) (2026-05-05)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add expandHomePath utility for ~ and $HOME expansion ([18264e1](https://github.com/gotgenes/pi-permission-system/commit/18264e104f6aa12ca004a127d0f7b09b9e4fb740))
|
|
14
|
+
* expand ~ and $HOME in wildcard patterns at compile time ([3c7e0c2](https://github.com/gotgenes/pi-permission-system/commit/3c7e0c2ab92c1e6bb58fdab32cfd9ae2c72e100a))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* document ~/$HOME pattern expansion in schema, example config, and README ([8ad5190](https://github.com/gotgenes/pi-permission-system/commit/8ad51909512ca294c83876868407866290234882))
|
|
20
|
+
* plan home directory expansion in permission patterns ([#53](https://github.com/gotgenes/pi-permission-system/issues/53)) ([b5b77b6](https://github.com/gotgenes/pi-permission-system/commit/b5b77b640006b67b51420135be8fb78484c9d9a1))
|
|
21
|
+
* **retro:** add retro notes for issue [#52](https://github.com/gotgenes/pi-permission-system/issues/52) ([7fc8113](https://github.com/gotgenes/pi-permission-system/commit/7fc8113390fd1dd9cf09c05e903d597c16d80104))
|
|
22
|
+
* sleep before pulling release commit and tag ([af701b5](https://github.com/gotgenes/pi-permission-system/commit/af701b543b20f274ca9f8aa904af0a39bc232c26))
|
|
23
|
+
|
|
8
24
|
## [4.7.0](https://github.com/gotgenes/pi-permission-system/compare/v4.6.0...v4.7.0) (2026-05-05)
|
|
9
25
|
|
|
10
26
|
|
package/README.md
CHANGED
|
@@ -156,7 +156,7 @@ The `permission` object maps surface names to actions:
|
|
|
156
156
|
| `bash` | string or object | Bash catch-all or `{ pattern: action }` map |
|
|
157
157
|
| `mcp` | string or object | MCP catch-all or `{ pattern: action }` map |
|
|
158
158
|
| `skill` | string or object | Skill catch-all or `{ pattern: action }` map |
|
|
159
|
-
| `external_directory` | string | Controls access to paths outside `cwd
|
|
159
|
+
| `external_directory` | string or object | Controls access to paths outside `cwd`; supports `~/` and `$HOME/` patterns |
|
|
160
160
|
|
|
161
161
|
> **Note:** Trailing commas are **not** supported. If parsing fails, the extension falls back to `ask` for all categories.
|
|
162
162
|
|
|
@@ -330,14 +330,36 @@ Skill name patterns use `*` wildcards (note: surface is `skill`, not `skills`):
|
|
|
330
330
|
}
|
|
331
331
|
```
|
|
332
332
|
|
|
333
|
+
### Home directory expansion in patterns
|
|
334
|
+
|
|
335
|
+
Pattern keys in any permission surface can start with `~/` or `$HOME/` (or be exactly `~` / `$HOME`).
|
|
336
|
+
They are expanded to the OS home directory at match time, so configs are portable across machines and users.
|
|
337
|
+
|
|
338
|
+
```jsonc
|
|
339
|
+
{
|
|
340
|
+
"permission": {
|
|
341
|
+
"external_directory": {
|
|
342
|
+
"*": "ask",
|
|
343
|
+
"~/development/*": "allow"
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
The pattern is stored and displayed as written (e.g. `~/development/*`) in logs and approval dialogs.
|
|
350
|
+
|
|
333
351
|
### `external_directory` surface
|
|
334
352
|
|
|
335
|
-
Controls access to paths outside the active working directory
|
|
353
|
+
Controls access to paths outside the active working directory.
|
|
354
|
+
Use a pattern map to allow specific directories without opening all external access:
|
|
336
355
|
|
|
337
356
|
```jsonc
|
|
338
357
|
{
|
|
339
358
|
"permission": {
|
|
340
|
-
"external_directory":
|
|
359
|
+
"external_directory": {
|
|
360
|
+
"*": "ask",
|
|
361
|
+
"~/development/*": "allow"
|
|
362
|
+
}
|
|
341
363
|
}
|
|
342
364
|
}
|
|
343
365
|
```
|
package/package.json
CHANGED
|
@@ -88,10 +88,10 @@
|
|
|
88
88
|
},
|
|
89
89
|
"permissionMap": {
|
|
90
90
|
"description": "A map of wildcard patterns to permission states. Last matching pattern wins.",
|
|
91
|
-
"markdownDescription": "A map of wildcard patterns to permission states.\n\nUse `*` for wildcard matching. When multiple patterns match, the **last matching rule wins** — put broad catch-alls first and specific overrides after them.",
|
|
91
|
+
"markdownDescription": "A map of wildcard patterns to permission states.\n\nUse `*` for wildcard matching. When multiple patterns match, the **last matching rule wins** — put broad catch-alls first and specific overrides after them.\n\nPattern keys support home directory expansion:\n- `~/path` or `$HOME/path` — expanded to the OS home directory at match time.\n- `~` or `$HOME` alone — expands to the home directory itself.\n\nThe stored pattern is always shown in logs and approval dialogs as written (e.g. `~/dev/*`).",
|
|
92
92
|
"type": "object",
|
|
93
93
|
"propertyNames": {
|
|
94
|
-
"description": "A non-empty pattern string. Use * for wildcard matching.",
|
|
94
|
+
"description": "A non-empty pattern string. Use * for wildcard matching. Prefix with ~/ or $HOME/ for home-relative paths.",
|
|
95
95
|
"type": "string",
|
|
96
96
|
"minLength": 1
|
|
97
97
|
},
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Expand `~` and `$HOME` prefixes in a pattern to the OS home directory.
|
|
6
|
+
*
|
|
7
|
+
* Supported forms:
|
|
8
|
+
* - `~` → `homedir()`
|
|
9
|
+
* - `~/path` → `homedir()/path`
|
|
10
|
+
* - `~\path` → `homedir()\path` (Windows)
|
|
11
|
+
* - `$HOME` → `homedir()`
|
|
12
|
+
* - `$HOME/path` → `homedir()/path`
|
|
13
|
+
* - `$HOME\path` → `homedir()\path` (Windows)
|
|
14
|
+
*
|
|
15
|
+
* All other patterns are returned unchanged.
|
|
16
|
+
*/
|
|
17
|
+
export function expandHomePath(pattern: string): string {
|
|
18
|
+
if (pattern === "~" || pattern === "$HOME") {
|
|
19
|
+
return homedir();
|
|
20
|
+
}
|
|
21
|
+
if (pattern.startsWith("~/") || pattern.startsWith("~\\")) {
|
|
22
|
+
return join(homedir(), pattern.slice(2));
|
|
23
|
+
}
|
|
24
|
+
if (pattern.startsWith("$HOME/") || pattern.startsWith("$HOME\\")) {
|
|
25
|
+
return join(homedir(), pattern.slice(6));
|
|
26
|
+
}
|
|
27
|
+
return pattern;
|
|
28
|
+
}
|
package/src/wildcard-matcher.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { expandHomePath } from "./expand-home";
|
|
2
|
+
|
|
1
3
|
export type CompiledWildcardPattern<TState> = {
|
|
2
4
|
pattern: string;
|
|
3
5
|
state: TState;
|
|
@@ -18,7 +20,8 @@ export function compileWildcardPattern<TState>(
|
|
|
18
20
|
pattern: string,
|
|
19
21
|
state: TState,
|
|
20
22
|
): CompiledWildcardPattern<TState> {
|
|
21
|
-
const
|
|
23
|
+
const expanded = expandHomePath(pattern);
|
|
24
|
+
const escaped = expanded
|
|
22
25
|
.split("*")
|
|
23
26
|
.map((part) => escapeRegExp(part))
|
|
24
27
|
.join(".*");
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const mockHomedir = vi.hoisted(() => vi.fn(() => "/home/testuser"));
|
|
5
|
+
|
|
6
|
+
vi.mock("node:os", () => ({
|
|
7
|
+
homedir: mockHomedir,
|
|
8
|
+
default: { homedir: mockHomedir },
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { expandHomePath } from "../src/expand-home";
|
|
12
|
+
|
|
13
|
+
const FAKE_HOME = "/home/testuser";
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
mockHomedir.mockClear();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("expandHomePath", () => {
|
|
20
|
+
describe("~ expansion", () => {
|
|
21
|
+
test("bare ~ expands to homedir()", () => {
|
|
22
|
+
expect(expandHomePath("~")).toBe(FAKE_HOME);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("~/path expands to homedir()/path", () => {
|
|
26
|
+
expect(expandHomePath("~/dev/project")).toBe(
|
|
27
|
+
join(FAKE_HOME, "dev/project"),
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("~/path/* expands to homedir()/path/*", () => {
|
|
32
|
+
expect(expandHomePath("~/dev/*")).toBe(join(FAKE_HOME, "dev/*"));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("~\\ (Windows separator) expands to homedir() + rest", () => {
|
|
36
|
+
expect(expandHomePath("~\\dev\\project")).toBe(
|
|
37
|
+
join(FAKE_HOME, "dev\\project"),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("~username (no separator) is not expanded (no-op)", () => {
|
|
42
|
+
expect(expandHomePath("~username")).toBe("~username");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("$HOME expansion", () => {
|
|
47
|
+
test("bare $HOME expands to homedir()", () => {
|
|
48
|
+
expect(expandHomePath("$HOME")).toBe(FAKE_HOME);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("$HOME/path expands to homedir()/path", () => {
|
|
52
|
+
expect(expandHomePath("$HOME/dev/project")).toBe(
|
|
53
|
+
join(FAKE_HOME, "dev/project"),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("$HOME/path/* expands to homedir()/path/*", () => {
|
|
58
|
+
expect(expandHomePath("$HOME/dev/*")).toBe(join(FAKE_HOME, "dev/*"));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("$HOME\\ (Windows separator) expands to homedir() + rest", () => {
|
|
62
|
+
expect(expandHomePath("$HOME\\dev\\project")).toBe(
|
|
63
|
+
join(FAKE_HOME, "dev\\project"),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("$HOMEDIR (no separator) is not expanded (no-op)", () => {
|
|
68
|
+
expect(expandHomePath("$HOMEDIR")).toBe("$HOMEDIR");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("no-op patterns", () => {
|
|
73
|
+
test("absolute path is unchanged", () => {
|
|
74
|
+
expect(expandHomePath("/usr/local/bin")).toBe("/usr/local/bin");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("relative path is unchanged", () => {
|
|
78
|
+
expect(expandHomePath("dev/project")).toBe("dev/project");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("glob-only pattern is unchanged", () => {
|
|
82
|
+
expect(expandHomePath("*")).toBe("*");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("empty string is unchanged", () => {
|
|
86
|
+
expect(expandHomePath("")).toBe("");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("bash command pattern starting with a word is unchanged", () => {
|
|
90
|
+
expect(expandHomePath("git push *")).toBe("git push *");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Step 6: all five surfaces produce identical decisions to the old branching code.
|
|
6
6
|
*/
|
|
7
7
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
-
import { tmpdir } from "node:os";
|
|
8
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { describe, expect, it } from "vitest";
|
|
11
11
|
import { PermissionManager } from "../src/permission-manager";
|
|
@@ -373,3 +373,76 @@ describe("checkPermission — source derivation and matchedPattern", () => {
|
|
|
373
373
|
});
|
|
374
374
|
});
|
|
375
375
|
});
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Home directory expansion in external_directory patterns
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
describe("checkPermission — home path expansion in external_directory rules", () => {
|
|
382
|
+
it("~/glob pattern allows a path under the real home directory", () => {
|
|
383
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
384
|
+
"*": "ask",
|
|
385
|
+
external_directory: { "~/trusted/*": "allow" },
|
|
386
|
+
});
|
|
387
|
+
try {
|
|
388
|
+
const result = manager.checkPermission("external_directory", {
|
|
389
|
+
path: join(homedir(), "trusted/repo"),
|
|
390
|
+
});
|
|
391
|
+
expect(result.state).toBe("allow");
|
|
392
|
+
expect(result.source).toBe("special");
|
|
393
|
+
expect(result.matchedPattern).toBe("~/trusted/*");
|
|
394
|
+
} finally {
|
|
395
|
+
cleanup();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("$HOME/glob pattern allows a path under the real home directory", () => {
|
|
400
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
401
|
+
"*": "ask",
|
|
402
|
+
external_directory: { "$HOME/trusted/*": "allow" },
|
|
403
|
+
});
|
|
404
|
+
try {
|
|
405
|
+
const result = manager.checkPermission("external_directory", {
|
|
406
|
+
path: join(homedir(), "trusted/repo"),
|
|
407
|
+
});
|
|
408
|
+
expect(result.state).toBe("allow");
|
|
409
|
+
expect(result.source).toBe("special");
|
|
410
|
+
expect(result.matchedPattern).toBe("$HOME/trusted/*");
|
|
411
|
+
} finally {
|
|
412
|
+
cleanup();
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("~/glob deny rule blocks a path under home", () => {
|
|
417
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
418
|
+
"*": "allow",
|
|
419
|
+
external_directory: { "~/private/*": "deny" },
|
|
420
|
+
});
|
|
421
|
+
try {
|
|
422
|
+
const result = manager.checkPermission("external_directory", {
|
|
423
|
+
path: join(homedir(), "private/secrets.txt"),
|
|
424
|
+
});
|
|
425
|
+
expect(result.state).toBe("deny");
|
|
426
|
+
expect(result.matchedPattern).toBe("~/private/*");
|
|
427
|
+
} finally {
|
|
428
|
+
cleanup();
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("~/glob pattern does not match a path outside home", () => {
|
|
433
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
434
|
+
"*": "ask",
|
|
435
|
+
external_directory: { "~/trusted/*": "allow" },
|
|
436
|
+
});
|
|
437
|
+
try {
|
|
438
|
+
const result = manager.checkPermission("external_directory", {
|
|
439
|
+
path: "/tmp/not-home/file",
|
|
440
|
+
});
|
|
441
|
+
// Falls back to the "*": "ask" default — no allow from the ~/trusted/* rule.
|
|
442
|
+
expect(result.state).toBe("ask");
|
|
443
|
+
expect(result.matchedPattern).toBeUndefined();
|
|
444
|
+
} finally {
|
|
445
|
+
cleanup();
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
});
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
3
|
|
|
4
|
+
const mockHomedir = vi.hoisted(() => vi.fn(() => "/home/testuser"));
|
|
5
|
+
|
|
6
|
+
vi.mock("node:os", () => ({
|
|
7
|
+
homedir: mockHomedir,
|
|
8
|
+
default: { homedir: mockHomedir },
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const FAKE_HOME = "/home/testuser";
|
|
12
|
+
|
|
3
13
|
import {
|
|
4
14
|
compileWildcardPattern,
|
|
5
15
|
compileWildcardPatternEntries,
|
|
@@ -9,6 +19,7 @@ import {
|
|
|
9
19
|
} from "../src/wildcard-matcher";
|
|
10
20
|
|
|
11
21
|
afterEach(() => {
|
|
22
|
+
mockHomedir.mockClear();
|
|
12
23
|
vi.restoreAllMocks();
|
|
13
24
|
});
|
|
14
25
|
|
|
@@ -233,3 +244,50 @@ describe("wildcardMatch", () => {
|
|
|
233
244
|
expect(wildcardMatch("tool.name", "toolXname")).toBe(false);
|
|
234
245
|
});
|
|
235
246
|
});
|
|
247
|
+
|
|
248
|
+
describe("home path expansion in patterns", () => {
|
|
249
|
+
test("wildcardMatch expands ~ prefix in pattern before matching", () => {
|
|
250
|
+
const expandedPath = join(FAKE_HOME, "dev/project");
|
|
251
|
+
expect(wildcardMatch("~/dev/project", expandedPath)).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("wildcardMatch expands ~/glob in pattern", () => {
|
|
255
|
+
const expandedFile = join(FAKE_HOME, "dev/project/file.ts");
|
|
256
|
+
expect(wildcardMatch("~/dev/*", expandedFile)).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("wildcardMatch ~/glob does not match a different home directory", () => {
|
|
260
|
+
expect(wildcardMatch("~/dev/*", "/other/user/dev/file.ts")).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("wildcardMatch expands $HOME prefix in pattern before matching", () => {
|
|
264
|
+
const expandedPath = join(FAKE_HOME, "dev/project");
|
|
265
|
+
expect(wildcardMatch("$HOME/dev/project", expandedPath)).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("wildcardMatch expands $HOME/glob in pattern", () => {
|
|
269
|
+
const expandedFile = join(FAKE_HOME, "work/file.ts");
|
|
270
|
+
expect(wildcardMatch("$HOME/work/*", expandedFile)).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("compileWildcardPattern retains original ~ pattern in .pattern field", () => {
|
|
274
|
+
const compiled = compileWildcardPattern("~/dev/*", "allow");
|
|
275
|
+
expect(compiled.pattern).toBe("~/dev/*");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("compileWildcardPattern retains original $HOME pattern in .pattern field", () => {
|
|
279
|
+
const compiled = compileWildcardPattern("$HOME/dev/*", "allow");
|
|
280
|
+
expect(compiled.pattern).toBe("$HOME/dev/*");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("compileWildcardPattern expanded regex matches the expanded path", () => {
|
|
284
|
+
const compiled = compileWildcardPattern("~/dev/*", "allow");
|
|
285
|
+
const expandedFile = join(FAKE_HOME, "dev/file.ts");
|
|
286
|
+
expect(compiled.regex.test(expandedFile)).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("non-home pattern is unaffected", () => {
|
|
290
|
+
expect(wildcardMatch("/absolute/path/*", "/absolute/path/file")).toBe(true);
|
|
291
|
+
expect(wildcardMatch("/absolute/path/*", "/other/file")).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
});
|