@gotgenes/pi-permission-system 5.6.1 → 5.6.2

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,21 @@ 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
+ ## [5.6.2](https://github.com/gotgenes/pi-permission-system/compare/v5.6.1...v5.6.2) (2026-05-07)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * clarify bash arity table usage difference with OpenCode ([b387480](https://github.com/gotgenes/pi-permission-system/commit/b3874801a5084fa762cf521b743c21e3ed328d79))
14
+ * clarify doom_loop is not a Pi surface, not just deprecated ([8c38ab2](https://github.com/gotgenes/pi-permission-system/commit/8c38ab24dbcc4ecb1da8baa1276facfdfa21e785))
15
+ * detail superior bash path extraction vs OpenCode's allowlist approach ([b16767b](https://github.com/gotgenes/pi-permission-system/commit/b16767b5df0d9d8cf960a47f4bfbaa788ee21def))
16
+ * merge doom_loop into OpenCode-only surfaces row ([85756a7](https://github.com/gotgenes/pi-permission-system/commit/85756a72917d43931c9026c0120d6f2731bfae5e))
17
+ * move bash arity/tree-sitter to shared concepts (both at parity) ([6fd7cdc](https://github.com/gotgenes/pi-permission-system/commit/6fd7cdcad2917ab98fb80f80fe2abc8cc5c6bd36))
18
+ * plan deduplicate shared helpers ([#109](https://github.com/gotgenes/pi-permission-system/issues/109)) ([52bff2e](https://github.com/gotgenes/pi-permission-system/commit/52bff2ef7cf0f5d64cf27f90381b558ed8427ac6))
19
+ * plan split external-directory into focused modules ([#110](https://github.com/gotgenes/pi-permission-system/issues/110)) ([b2a4610](https://github.com/gotgenes/pi-permission-system/commit/b2a4610430e27f8ec9456c70e31ce54aa866ac30))
20
+ * **retro:** add retro notes for issue [#106](https://github.com/gotgenes/pi-permission-system/issues/106) ([a945814](https://github.com/gotgenes/pi-permission-system/commit/a945814799264396fa8fc94249791fdbc22b58c1))
21
+ * update target architecture for extracted helpers ([52693d6](https://github.com/gotgenes/pi-permission-system/commit/52693d6c0b0164d38b2746a40df9aab7031b47b2))
22
+
8
23
  ## [5.6.1](https://github.com/gotgenes/pi-permission-system/compare/v5.6.0...v5.6.1) (2026-05-07)
9
24
 
10
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.6.1",
3
+ "version": "5.6.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -9,6 +9,7 @@ import {
9
9
  getLegacyProjectPolicyPath,
10
10
  getProjectConfigPath,
11
11
  } from "./config-paths";
12
+ import { mergeFlatPermissions } from "./permission-merge";
12
13
  import type { FlatPermissionConfig } from "./types";
13
14
 
14
15
  /**
@@ -151,35 +152,6 @@ function normalizeFlatPermissionValue(
151
152
  return hasAny ? normalized : undefined;
152
153
  }
153
154
 
154
- /**
155
- * Deep-shallow merge two flat permission configs.
156
- * - Both objects for same key → shallow-merge the pattern maps.
157
- * - Otherwise → override replaces base.
158
- */
159
- function mergeFlatPermissions(
160
- base: FlatPermissionConfig,
161
- override: FlatPermissionConfig,
162
- ): FlatPermissionConfig {
163
- const merged: FlatPermissionConfig = { ...base };
164
- for (const [key, value] of Object.entries(override)) {
165
- const baseVal = merged[key];
166
- if (
167
- typeof baseVal === "object" &&
168
- baseVal !== null &&
169
- typeof value === "object" &&
170
- value !== null
171
- ) {
172
- merged[key] = {
173
- ...(baseVal as Record<string, import("./types").PermissionState>),
174
- ...(value as Record<string, import("./types").PermissionState>),
175
- };
176
- } else {
177
- merged[key] = value;
178
- }
179
- }
180
- return merged;
181
- }
182
-
183
155
  /**
184
156
  * Normalize raw parsed JSON into the unified config shape.
185
157
  */
@@ -1,12 +1,21 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
- import { homedir } from "node:os";
5
4
  import { basename, dirname, join, normalize, resolve, sep } from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
6
 
8
7
  import { getNonEmptyString, toRecord } from "./common";
9
8
 
9
+ export {
10
+ isPathWithinDirectory,
11
+ normalizePathForComparison,
12
+ } from "./path-utils";
13
+
14
+ import {
15
+ isPathWithinDirectory,
16
+ normalizePathForComparison,
17
+ } from "./path-utils";
18
+
10
19
  /**
11
20
  * Walk up the directory tree from the given file URL until a directory
12
21
  * literally named `node_modules` is found.
@@ -161,49 +170,6 @@ export const PATH_BEARING_TOOLS = new Set([
161
170
  "ls",
162
171
  ]);
163
172
 
164
- export function normalizePathForComparison(
165
- pathValue: string,
166
- cwd: string,
167
- ): string {
168
- const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
169
- if (!trimmed) {
170
- return "";
171
- }
172
-
173
- let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
174
-
175
- if (normalizedPath === "~") {
176
- normalizedPath = homedir();
177
- } else if (
178
- normalizedPath.startsWith("~/") ||
179
- normalizedPath.startsWith("~\\")
180
- ) {
181
- normalizedPath = join(homedir(), normalizedPath.slice(2));
182
- }
183
-
184
- const absolutePath = resolve(cwd, normalizedPath);
185
- const normalizedAbsolutePath = normalize(absolutePath);
186
- return process.platform === "win32"
187
- ? normalizedAbsolutePath.toLowerCase()
188
- : normalizedAbsolutePath;
189
- }
190
-
191
- export function isPathWithinDirectory(
192
- pathValue: string,
193
- directory: string,
194
- ): boolean {
195
- if (!pathValue || !directory) {
196
- return false;
197
- }
198
-
199
- if (pathValue === directory) {
200
- return true;
201
- }
202
-
203
- const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
204
- return pathValue.startsWith(prefix);
205
- }
206
-
207
173
  export function getPathBearingToolPath(
208
174
  toolName: string,
209
175
  input: unknown,
@@ -5,8 +5,8 @@ import {
5
5
  getPathBearingToolPath,
6
6
  isPathOutsideWorkingDirectory,
7
7
  isPiInfrastructureRead,
8
- normalizePathForComparison,
9
8
  } from "../../external-directory";
9
+ import { normalizePathForComparison } from "../../path-utils";
10
10
  import { deriveApprovalPattern } from "../../session-rules";
11
11
  import type { GateResult } from "./descriptor";
12
12
  import type { ToolCallContext } from "./types";
@@ -1,5 +1,5 @@
1
1
  import { toRecord } from "../../common";
2
- import { normalizePathForComparison } from "../../external-directory";
2
+ import { normalizePathForComparison } from "../../path-utils";
3
3
  import {
4
4
  formatSkillPathAskPrompt,
5
5
  formatSkillPathDenyReason,
@@ -0,0 +1,45 @@
1
+ import { homedir } from "node:os";
2
+ import { join, normalize, resolve, sep } from "node:path";
3
+
4
+ export function normalizePathForComparison(
5
+ pathValue: string,
6
+ cwd: string,
7
+ ): string {
8
+ const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
9
+ if (!trimmed) {
10
+ return "";
11
+ }
12
+
13
+ let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
14
+
15
+ if (normalizedPath === "~") {
16
+ normalizedPath = homedir();
17
+ } else if (
18
+ normalizedPath.startsWith("~/") ||
19
+ normalizedPath.startsWith("~\\")
20
+ ) {
21
+ normalizedPath = join(homedir(), normalizedPath.slice(2));
22
+ }
23
+
24
+ const absolutePath = resolve(cwd, normalizedPath);
25
+ const normalizedAbsolutePath = normalize(absolutePath);
26
+ return process.platform === "win32"
27
+ ? normalizedAbsolutePath.toLowerCase()
28
+ : normalizedAbsolutePath;
29
+ }
30
+
31
+ export function isPathWithinDirectory(
32
+ pathValue: string,
33
+ directory: string,
34
+ ): boolean {
35
+ if (!pathValue || !directory) {
36
+ return false;
37
+ }
38
+
39
+ if (pathValue === directory) {
40
+ return true;
41
+ }
42
+
43
+ const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
44
+ return pathValue.startsWith(prefix);
45
+ }
@@ -1,6 +1,7 @@
1
1
  import { isPermissionState } from "./common";
2
2
  import { normalizeInput } from "./input-normalizer";
3
3
  import { normalizeFlatConfig } from "./normalize";
4
+ import { mergeFlatPermissions } from "./permission-merge";
4
5
  import {
5
6
  FilePolicyLoader,
6
7
  type PolicyLoader,
@@ -34,35 +35,6 @@ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
34
35
  /** Universal fallback when permission["*"] is absent from all scopes. */
35
36
  const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
36
37
 
37
- /**
38
- * Deep-shallow merge two flat permission configs.
39
- * Both objects → shallow-merge the pattern maps.
40
- * Otherwise → override replaces base.
41
- */
42
- function mergeFlatPermissions(
43
- base: FlatPermissionConfig,
44
- override: FlatPermissionConfig,
45
- ): FlatPermissionConfig {
46
- const merged: FlatPermissionConfig = { ...base };
47
- for (const [key, value] of Object.entries(override)) {
48
- const baseVal = merged[key];
49
- if (
50
- typeof baseVal === "object" &&
51
- baseVal !== null &&
52
- typeof value === "object" &&
53
- value !== null
54
- ) {
55
- merged[key] = {
56
- ...(baseVal as Record<string, PermissionState>),
57
- ...(value as Record<string, PermissionState>),
58
- };
59
- } else {
60
- merged[key] = value;
61
- }
62
- }
63
- return merged;
64
- }
65
-
66
38
  type FileCacheEntry<TValue> = {
67
39
  stamp: string;
68
40
  value: TValue;
@@ -0,0 +1,30 @@
1
+ import type { FlatPermissionConfig, PermissionState } from "./types";
2
+
3
+ /**
4
+ * Deep-shallow merge two flat permission configs.
5
+ * Both objects → shallow-merge the pattern maps.
6
+ * Otherwise → override replaces base.
7
+ */
8
+ export function mergeFlatPermissions(
9
+ base: FlatPermissionConfig,
10
+ override: FlatPermissionConfig,
11
+ ): FlatPermissionConfig {
12
+ const merged: FlatPermissionConfig = { ...base };
13
+ for (const [key, value] of Object.entries(override)) {
14
+ const baseVal = merged[key];
15
+ if (
16
+ typeof baseVal === "object" &&
17
+ baseVal !== null &&
18
+ typeof value === "object" &&
19
+ value !== null
20
+ ) {
21
+ merged[key] = {
22
+ ...(baseVal as Record<string, PermissionState>),
23
+ ...(value as Record<string, PermissionState>),
24
+ };
25
+ } else {
26
+ merged[key] = value;
27
+ }
28
+ }
29
+ return merged;
30
+ }
@@ -1,6 +1,9 @@
1
- import { homedir } from "node:os";
2
- import { dirname, join, normalize, resolve, sep } from "node:path";
1
+ import { dirname } from "node:path";
3
2
 
3
+ import {
4
+ isPathWithinDirectory,
5
+ normalizePathForComparison,
6
+ } from "./path-utils";
4
7
  import type { PermissionManager } from "./permission-manager";
5
8
  import type { PermissionState } from "./types";
6
9
 
@@ -50,43 +53,6 @@ function encodeXml(value: string): string {
50
53
  .replace(/'/g, "&apos;");
51
54
  }
52
55
 
53
- function normalizePathForComparison(pathValue: string, cwd: string): string {
54
- const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
55
- if (!trimmed) {
56
- return "";
57
- }
58
-
59
- let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
60
-
61
- if (normalizedPath === "~") {
62
- normalizedPath = homedir();
63
- } else if (
64
- normalizedPath.startsWith("~/") ||
65
- normalizedPath.startsWith("~\\")
66
- ) {
67
- normalizedPath = join(homedir(), normalizedPath.slice(2));
68
- }
69
-
70
- const absolutePath = resolve(cwd, normalizedPath);
71
- const normalizedAbsolutePath = normalize(absolutePath);
72
- return process.platform === "win32"
73
- ? normalizedAbsolutePath.toLowerCase()
74
- : normalizedAbsolutePath;
75
- }
76
-
77
- function isPathWithinDirectory(pathValue: string, directory: string): boolean {
78
- if (!pathValue || !directory) {
79
- return false;
80
- }
81
-
82
- if (pathValue === directory) {
83
- return true;
84
- }
85
-
86
- const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
87
- return pathValue.startsWith(prefix);
88
- }
89
-
90
56
  function parseSkillEntries(sectionBody: string): ParsedSkillPromptEntry[] {
91
57
  const entries: ParsedSkillPromptEntry[] = [];
92
58
  const skillBlockRegex = new RegExp(SKILL_BLOCK_PATTERN, "g");
@@ -1,4 +1,3 @@
1
- import { join } from "node:path";
2
1
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
3
2
 
4
3
  // Hoisted stubs for mocks that reference them in vi.mock factories.
@@ -36,9 +35,7 @@ import {
36
35
  formatExternalDirectoryUserDeniedReason,
37
36
  getPathBearingToolPath,
38
37
  isPathOutsideWorkingDirectory,
39
- isPathWithinDirectory,
40
38
  isSafeSystemPath,
41
- normalizePathForComparison,
42
39
  PATH_BEARING_TOOLS,
43
40
  SAFE_SYSTEM_PATHS,
44
41
  } from "../src/external-directory";
@@ -103,82 +100,6 @@ describe("isSafeSystemPath", () => {
103
100
  });
104
101
  });
105
102
 
106
- describe("normalizePathForComparison", () => {
107
- const cwd = "/projects/my-app";
108
-
109
- test("resolves absolute path unchanged", () => {
110
- expect(normalizePathForComparison("/usr/local/bin", cwd)).toBe(
111
- "/usr/local/bin",
112
- );
113
- });
114
-
115
- test("resolves relative path against cwd", () => {
116
- expect(normalizePathForComparison("src/foo.ts", cwd)).toBe(
117
- "/projects/my-app/src/foo.ts",
118
- );
119
- });
120
-
121
- test("expands bare ~ to homedir", () => {
122
- expect(normalizePathForComparison("~", cwd)).toBe("/mock/home");
123
- });
124
-
125
- test("expands ~/... to homedir-relative path", () => {
126
- expect(normalizePathForComparison("~/docs/readme.md", cwd)).toBe(
127
- join("/mock/home", "docs/readme.md"),
128
- );
129
- });
130
-
131
- test("strips leading @ before resolving", () => {
132
- expect(normalizePathForComparison("@/usr/local/bin", cwd)).toBe(
133
- "/usr/local/bin",
134
- );
135
- });
136
-
137
- test("strips surrounding quotes", () => {
138
- expect(normalizePathForComparison("'/usr/local/bin'", cwd)).toBe(
139
- "/usr/local/bin",
140
- );
141
- expect(normalizePathForComparison('"/usr/local/bin"', cwd)).toBe(
142
- "/usr/local/bin",
143
- );
144
- });
145
-
146
- test("returns empty string for blank/whitespace-only path", () => {
147
- expect(normalizePathForComparison("", cwd)).toBe("");
148
- expect(normalizePathForComparison(" ", cwd)).toBe("");
149
- });
150
- });
151
-
152
- describe("isPathWithinDirectory", () => {
153
- test("returns true when path equals directory", () => {
154
- expect(isPathWithinDirectory("/a/b", "/a/b")).toBe(true);
155
- });
156
-
157
- test("returns true when path is a direct child", () => {
158
- expect(isPathWithinDirectory("/a/b/c", "/a/b")).toBe(true);
159
- });
160
-
161
- test("returns true when path is a deep descendant", () => {
162
- expect(isPathWithinDirectory("/a/b/c/d/e", "/a/b")).toBe(true);
163
- });
164
-
165
- test("returns false when path is a sibling directory", () => {
166
- expect(isPathWithinDirectory("/a/bc", "/a/b")).toBe(false);
167
- });
168
-
169
- test("returns false when path is outside the directory", () => {
170
- expect(isPathWithinDirectory("/other/path", "/a/b")).toBe(false);
171
- });
172
-
173
- test("returns false for empty path", () => {
174
- expect(isPathWithinDirectory("", "/a/b")).toBe(false);
175
- });
176
-
177
- test("returns false for empty directory", () => {
178
- expect(isPathWithinDirectory("/a/b", "")).toBe(false);
179
- });
180
- });
181
-
182
103
  describe("getPathBearingToolPath", () => {
183
104
  test("returns path for a path-bearing tool", () => {
184
105
  expect(getPathBearingToolPath("read", { path: "/src/foo.ts" })).toBe(
@@ -0,0 +1,92 @@
1
+ import { join } from "node:path";
2
+ import { describe, expect, test, vi } from "vitest";
3
+
4
+ // Mock node:os so tilde-expansion is deterministic across platforms.
5
+ vi.mock("node:os", () => {
6
+ const homedir = vi.fn(() => "/mock/home");
7
+ return {
8
+ homedir,
9
+ default: { homedir },
10
+ };
11
+ });
12
+
13
+ import {
14
+ isPathWithinDirectory,
15
+ normalizePathForComparison,
16
+ } from "../src/path-utils";
17
+
18
+ describe("normalizePathForComparison", () => {
19
+ const cwd = "/projects/my-app";
20
+
21
+ test("resolves absolute path unchanged", () => {
22
+ expect(normalizePathForComparison("/usr/local/bin", cwd)).toBe(
23
+ "/usr/local/bin",
24
+ );
25
+ });
26
+
27
+ test("resolves relative path against cwd", () => {
28
+ expect(normalizePathForComparison("src/foo.ts", cwd)).toBe(
29
+ "/projects/my-app/src/foo.ts",
30
+ );
31
+ });
32
+
33
+ test("expands bare ~ to homedir", () => {
34
+ expect(normalizePathForComparison("~", cwd)).toBe("/mock/home");
35
+ });
36
+
37
+ test("expands ~/... to homedir-relative path", () => {
38
+ expect(normalizePathForComparison("~/docs/readme.md", cwd)).toBe(
39
+ join("/mock/home", "docs/readme.md"),
40
+ );
41
+ });
42
+
43
+ test("strips leading @ before resolving", () => {
44
+ expect(normalizePathForComparison("@/usr/local/bin", cwd)).toBe(
45
+ "/usr/local/bin",
46
+ );
47
+ });
48
+
49
+ test("strips surrounding quotes", () => {
50
+ expect(normalizePathForComparison("'/usr/local/bin'", cwd)).toBe(
51
+ "/usr/local/bin",
52
+ );
53
+ expect(normalizePathForComparison('"/usr/local/bin"', cwd)).toBe(
54
+ "/usr/local/bin",
55
+ );
56
+ });
57
+
58
+ test("returns empty string for blank/whitespace-only path", () => {
59
+ expect(normalizePathForComparison("", cwd)).toBe("");
60
+ expect(normalizePathForComparison(" ", cwd)).toBe("");
61
+ });
62
+ });
63
+
64
+ describe("isPathWithinDirectory", () => {
65
+ test("returns true when path equals directory", () => {
66
+ expect(isPathWithinDirectory("/a/b", "/a/b")).toBe(true);
67
+ });
68
+
69
+ test("returns true when path is a direct child", () => {
70
+ expect(isPathWithinDirectory("/a/b/c", "/a/b")).toBe(true);
71
+ });
72
+
73
+ test("returns true when path is a deep descendant", () => {
74
+ expect(isPathWithinDirectory("/a/b/c/d/e", "/a/b")).toBe(true);
75
+ });
76
+
77
+ test("returns false when path is a sibling directory", () => {
78
+ expect(isPathWithinDirectory("/a/bc", "/a/b")).toBe(false);
79
+ });
80
+
81
+ test("returns false when path is outside the directory", () => {
82
+ expect(isPathWithinDirectory("/other/path", "/a/b")).toBe(false);
83
+ });
84
+
85
+ test("returns false for empty path", () => {
86
+ expect(isPathWithinDirectory("", "/a/b")).toBe(false);
87
+ });
88
+
89
+ test("returns false for empty directory", () => {
90
+ expect(isPathWithinDirectory("/a/b", "")).toBe(false);
91
+ });
92
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import { mergeFlatPermissions } from "../src/permission-merge";
4
+
5
+ describe("mergeFlatPermissions", () => {
6
+ test("string replaces string", () => {
7
+ const result = mergeFlatPermissions({ tools: "ask" }, { tools: "allow" });
8
+ expect(result).toEqual({ tools: "allow" });
9
+ });
10
+
11
+ test("both objects → shallow-merge pattern maps", () => {
12
+ const result = mergeFlatPermissions(
13
+ { bash: { "rm *": "deny", "git *": "ask" } },
14
+ { bash: { "rm *": "allow", "npm *": "allow" } },
15
+ );
16
+ expect(result).toEqual({
17
+ bash: { "rm *": "allow", "git *": "ask", "npm *": "allow" },
18
+ });
19
+ });
20
+
21
+ test("object replaces string", () => {
22
+ const result = mergeFlatPermissions(
23
+ { tools: "ask" },
24
+ { tools: { Write: "deny" } },
25
+ );
26
+ expect(result).toEqual({ tools: { Write: "deny" } });
27
+ });
28
+
29
+ test("string replaces object", () => {
30
+ const result = mergeFlatPermissions(
31
+ { tools: { Write: "deny" } },
32
+ { tools: "allow" },
33
+ );
34
+ expect(result).toEqual({ tools: "allow" });
35
+ });
36
+
37
+ test("empty override returns base unchanged", () => {
38
+ const base = { tools: "ask" as const, bash: { "rm *": "deny" as const } };
39
+ const result = mergeFlatPermissions(base, {});
40
+ expect(result).toEqual(base);
41
+ });
42
+
43
+ test("empty base returns override", () => {
44
+ const override = { tools: "allow" as const };
45
+ const result = mergeFlatPermissions({}, override);
46
+ expect(result).toEqual(override);
47
+ });
48
+
49
+ test("preserves keys only in base", () => {
50
+ const result = mergeFlatPermissions(
51
+ { tools: "ask", bash: "deny" },
52
+ { tools: "allow" },
53
+ );
54
+ expect(result).toEqual({ tools: "allow", bash: "deny" });
55
+ });
56
+
57
+ test("adds keys only in override", () => {
58
+ const result = mergeFlatPermissions({ tools: "ask" }, { bash: "allow" });
59
+ expect(result).toEqual({ tools: "ask", bash: "allow" });
60
+ });
61
+ });