@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 +15 -0
- package/package.json +1 -1
- package/src/config-loader.ts +1 -29
- package/src/external-directory.ts +10 -44
- package/src/handlers/gates/external-directory.ts +1 -1
- package/src/handlers/gates/skill-read.ts +1 -1
- package/src/path-utils.ts +45 -0
- package/src/permission-manager.ts +1 -29
- package/src/permission-merge.ts +30 -0
- package/src/skill-prompt-sanitizer.ts +5 -39
- package/tests/external-directory.test.ts +0 -79
- package/tests/path-utils.test.ts +92 -0
- package/tests/permission-merge.test.ts +61 -0
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
package/src/config-loader.ts
CHANGED
|
@@ -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";
|
|
@@ -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 {
|
|
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, "'");
|
|
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
|
+
});
|