@gotgenes/pi-permission-system 10.5.3 → 10.7.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 +30 -0
- package/package.json +1 -1
- package/src/canonicalize-path.ts +30 -0
- package/src/common.ts +10 -0
- package/src/config-loader.ts +17 -0
- package/src/extension-config.ts +8 -7
- package/src/handlers/gates/bash-program.ts +10 -4
- package/src/handlers/gates/external-directory.ts +2 -2
- package/src/path-utils.ts +18 -2
- package/test/bash-external-directory.test.ts +8 -0
- package/test/canonicalize-path.test.ts +93 -0
- package/test/common.test.ts +39 -0
- package/test/config-loader.test.ts +71 -0
- package/test/config-store.test.ts +26 -0
- package/test/handlers/gates/bash-program.test.ts +43 -1
- package/test/path-utils.test.ts +69 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ 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
|
+
## [10.7.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.6.0...pi-permission-system-v10.7.0) (2026-06-09)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add normalizeOptionalStringArray to common ([8be9154](https://github.com/gotgenes/pi-packages/commit/8be9154d7a492f13526f7bd8d4e33fc2e209f98d))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* carry piInfrastructureReadPaths through the unified config loader ([#347](https://github.com/gotgenes/pi-packages/issues/347)) ([51bc145](https://github.com/gotgenes/pi-packages/commit/51bc145c15cc54bc69333d1e6cc48c74dda267d1))
|
|
19
|
+
|
|
20
|
+
## [10.6.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.5.3...pi-permission-system-v10.6.0) (2026-06-08)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Features
|
|
24
|
+
|
|
25
|
+
* **pi-permission-system:** add best-effort canonicalizePath helper ([5b5002e](https://github.com/gotgenes/pi-packages/commit/5b5002e1b5400485f30a9f22440a88d14ed5135d))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Bug Fixes
|
|
29
|
+
|
|
30
|
+
* **pi-permission-system:** canonicalize bash external-path containment ([#345](https://github.com/gotgenes/pi-packages/issues/345)) ([89f8e9b](https://github.com/gotgenes/pi-packages/commit/89f8e9bb35cd268e46a2b124663f44c11a44be97))
|
|
31
|
+
* **pi-permission-system:** canonicalize tool-call external-directory containment ([#345](https://github.com/gotgenes/pi-packages/issues/345)) ([d7f3bd1](https://github.com/gotgenes/pi-packages/commit/d7f3bd1c02d115621cd87065de240b816837065f))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
### Documentation
|
|
35
|
+
|
|
36
|
+
* **pi-permission-system:** note symlink canonicalization in architecture ([b758a48](https://github.com/gotgenes/pi-packages/commit/b758a48bc55485ad9e751543db59858886ba360c))
|
|
37
|
+
|
|
8
38
|
## [10.5.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.5.2...pi-permission-system-v10.5.3) (2026-06-08)
|
|
9
39
|
|
|
10
40
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve symlinks in an absolute path, best-effort.
|
|
6
|
+
*
|
|
7
|
+
* Splits the path into components and tries `realpathSync` from the full path
|
|
8
|
+
* down to `/`, re-appending the non-existent tail to the first ancestor that
|
|
9
|
+
* resolves. Returns the input unchanged when no ancestor resolves (unreachable
|
|
10
|
+
* in practice since `/` always exists) or when a non-ENOENT/ENOTDIR error is
|
|
11
|
+
* encountered (e.g. `EACCES`, `ELOOP`), so callers fall back to lexical
|
|
12
|
+
* containment for paths that cannot be resolved.
|
|
13
|
+
*/
|
|
14
|
+
export function canonicalizePath(absolutePath: string): string {
|
|
15
|
+
if (!absolutePath) return absolutePath;
|
|
16
|
+
|
|
17
|
+
const parts = absolutePath.split("/").filter(Boolean);
|
|
18
|
+
for (let i = parts.length; i >= 0; i--) {
|
|
19
|
+
const candidate = "/" + parts.slice(0, i).join("/");
|
|
20
|
+
try {
|
|
21
|
+
const real = realpathSync(candidate);
|
|
22
|
+
const tail = parts.slice(i);
|
|
23
|
+
return tail.length === 0 ? real : join(real, ...tail);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
26
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") return absolutePath;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return absolutePath;
|
|
30
|
+
}
|
package/src/common.ts
CHANGED
|
@@ -17,6 +17,16 @@ export function getNonEmptyString(value: unknown): string | null {
|
|
|
17
17
|
return trimmed.length > 0 ? trimmed : null;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** Returns `raw` if it is an array of strings; otherwise `undefined`. */
|
|
21
|
+
export function normalizeOptionalStringArray(
|
|
22
|
+
raw: unknown,
|
|
23
|
+
): string[] | undefined {
|
|
24
|
+
return Array.isArray(raw) &&
|
|
25
|
+
raw.every((p): p is string => typeof p === "string")
|
|
26
|
+
? raw
|
|
27
|
+
: undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
/** Returns `raw` if it is a positive integer; otherwise `undefined`. */
|
|
21
31
|
export function normalizeOptionalPositiveInt(raw: unknown): number | undefined {
|
|
22
32
|
return typeof raw === "number" && Number.isInteger(raw) && raw > 0
|
package/src/config-loader.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { normalize } from "node:path";
|
|
|
4
4
|
import {
|
|
5
5
|
isPermissionState,
|
|
6
6
|
normalizeOptionalPositiveInt,
|
|
7
|
+
normalizeOptionalStringArray,
|
|
7
8
|
toRecord,
|
|
8
9
|
} from "./common";
|
|
9
10
|
import {
|
|
@@ -27,6 +28,7 @@ export interface UnifiedPermissionConfig {
|
|
|
27
28
|
yoloMode?: boolean;
|
|
28
29
|
toolInputPreviewMaxLength?: number;
|
|
29
30
|
toolTextSummaryMaxLength?: number;
|
|
31
|
+
piInfrastructureReadPaths?: string[];
|
|
30
32
|
|
|
31
33
|
// Flat permission policy
|
|
32
34
|
permission?: FlatPermissionConfig;
|
|
@@ -201,6 +203,12 @@ export function normalizeUnifiedConfig(raw: unknown): {
|
|
|
201
203
|
if (toolTextSummaryMaxLength !== undefined)
|
|
202
204
|
config.toolTextSummaryMaxLength = toolTextSummaryMaxLength;
|
|
203
205
|
|
|
206
|
+
const piInfrastructureReadPaths = normalizeOptionalStringArray(
|
|
207
|
+
record.piInfrastructureReadPaths,
|
|
208
|
+
);
|
|
209
|
+
if (piInfrastructureReadPaths !== undefined)
|
|
210
|
+
config.piInfrastructureReadPaths = piInfrastructureReadPaths;
|
|
211
|
+
|
|
204
212
|
// Flat permission policy
|
|
205
213
|
const permission = normalizeFlatPermissionValue(record.permission);
|
|
206
214
|
if (permission !== undefined) config.permission = permission;
|
|
@@ -213,6 +221,8 @@ export function normalizeUnifiedConfig(raw: unknown): {
|
|
|
213
221
|
* - `permission` is deep-shallow merged (surface-level object maps are shallow-merged).
|
|
214
222
|
* - Scalar fields (debugLog, permissionReviewLog, yoloMode) are replaced when
|
|
215
223
|
* present in the override.
|
|
224
|
+
* - Array fields (piInfrastructureReadPaths) replace the base when present in
|
|
225
|
+
* the override (override-wins, same as scalars).
|
|
216
226
|
*/
|
|
217
227
|
export function mergeUnifiedConfigs(
|
|
218
228
|
base: UnifiedPermissionConfig,
|
|
@@ -239,6 +249,13 @@ export function mergeUnifiedConfigs(
|
|
|
239
249
|
}
|
|
240
250
|
}
|
|
241
251
|
|
|
252
|
+
// Array fields: override replaces base when defined
|
|
253
|
+
const piInfrastructureReadPaths =
|
|
254
|
+
override.piInfrastructureReadPaths ?? base.piInfrastructureReadPaths;
|
|
255
|
+
if (piInfrastructureReadPaths !== undefined) {
|
|
256
|
+
merged.piInfrastructureReadPaths = piInfrastructureReadPaths;
|
|
257
|
+
}
|
|
258
|
+
|
|
242
259
|
// Permission: deep-shallow merge
|
|
243
260
|
const basePerm = base.permission;
|
|
244
261
|
const overridePerm = override.permission;
|
package/src/extension-config.ts
CHANGED
|
@@ -2,7 +2,11 @@ import { mkdirSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
normalizeOptionalPositiveInt,
|
|
7
|
+
normalizeOptionalStringArray,
|
|
8
|
+
toRecord,
|
|
9
|
+
} from "./common";
|
|
6
10
|
|
|
7
11
|
export const EXTENSION_ID = "pi-permission-system";
|
|
8
12
|
|
|
@@ -50,12 +54,9 @@ export function normalizePermissionSystemConfig(
|
|
|
50
54
|
raw: unknown,
|
|
51
55
|
): PermissionSystemExtensionConfig {
|
|
52
56
|
const record = toRecord(raw);
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
rawPaths.every((p): p is string => typeof p === "string")
|
|
57
|
-
? rawPaths
|
|
58
|
-
: undefined;
|
|
57
|
+
const piInfrastructureReadPaths = normalizeOptionalStringArray(
|
|
58
|
+
record.piInfrastructureReadPaths,
|
|
59
|
+
);
|
|
59
60
|
const result: PermissionSystemExtensionConfig = {
|
|
60
61
|
debugLog: record.debugLog === true,
|
|
61
62
|
permissionReviewLog: record.permissionReviewLog !== false,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
3
|
-
|
|
3
|
+
import { canonicalizePath } from "#src/canonicalize-path";
|
|
4
4
|
import {
|
|
5
5
|
classifyTokenAsPathCandidate,
|
|
6
6
|
classifyTokenAsRuleCandidate,
|
|
@@ -186,7 +186,9 @@ export class BashProgram {
|
|
|
186
186
|
* the running directory.
|
|
187
187
|
*/
|
|
188
188
|
externalPaths(cwd: string): string[] {
|
|
189
|
-
const normalizedCwd =
|
|
189
|
+
const normalizedCwd = canonicalizePath(
|
|
190
|
+
normalizePathForComparison(cwd, cwd),
|
|
191
|
+
);
|
|
190
192
|
|
|
191
193
|
const seen = new Set<string>();
|
|
192
194
|
const externalPaths: string[] = [];
|
|
@@ -200,7 +202,9 @@ export class BashProgram {
|
|
|
200
202
|
// display path). Absolute / `~` candidates are base-independent and
|
|
201
203
|
// resolve normally below.
|
|
202
204
|
if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
|
|
203
|
-
const normalized =
|
|
205
|
+
const normalized = canonicalizePath(
|
|
206
|
+
normalizePathForComparison(candidate, cwd),
|
|
207
|
+
);
|
|
204
208
|
if (
|
|
205
209
|
normalized &&
|
|
206
210
|
normalizedCwd !== "" &&
|
|
@@ -215,7 +219,9 @@ export class BashProgram {
|
|
|
215
219
|
|
|
216
220
|
const resolveBase =
|
|
217
221
|
base.kind === "known" ? resolve(cwd, base.offset) : cwd;
|
|
218
|
-
const normalized =
|
|
222
|
+
const normalized = canonicalizePath(
|
|
223
|
+
normalizePathForComparison(candidate, resolveBase),
|
|
224
|
+
);
|
|
219
225
|
if (!normalized) continue;
|
|
220
226
|
|
|
221
227
|
if (
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
+
canonicalNormalizePathForComparison,
|
|
2
3
|
getPathBearingToolPath,
|
|
3
4
|
isPathOutsideWorkingDirectory,
|
|
4
5
|
isPiInfrastructureRead,
|
|
5
|
-
normalizePathForComparison,
|
|
6
6
|
} from "#src/path-utils";
|
|
7
7
|
import { SessionApproval } from "#src/session-approval";
|
|
8
8
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
@@ -31,7 +31,7 @@ export function describeExternalDirectoryGate(
|
|
|
31
31
|
return null;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const normalizedExtPath =
|
|
34
|
+
const normalizedExtPath = canonicalNormalizePathForComparison(
|
|
35
35
|
externalDirectoryPath,
|
|
36
36
|
tcc.cwd,
|
|
37
37
|
);
|
package/src/path-utils.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join, normalize, resolve, sep } from "node:path";
|
|
2
2
|
|
|
3
|
+
import { canonicalizePath } from "./canonicalize-path";
|
|
3
4
|
import { getNonEmptyString, toRecord } from "./common";
|
|
4
5
|
import { expandHomePath } from "./expand-home";
|
|
5
6
|
import { wildcardMatch } from "./wildcard-matcher";
|
|
@@ -89,12 +90,27 @@ export function getPathBearingToolPath(
|
|
|
89
90
|
return getNonEmptyString(toRecord(input).path);
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Like {@link normalizePathForComparison} but also resolves symlinks via
|
|
95
|
+
* `realpathSync` (best-effort). Use this for containment decisions where the
|
|
96
|
+
* OS-followed path matters, not for pattern matching.
|
|
97
|
+
*/
|
|
98
|
+
export function canonicalNormalizePathForComparison(
|
|
99
|
+
pathValue: string,
|
|
100
|
+
cwd: string,
|
|
101
|
+
): string {
|
|
102
|
+
const lexical = normalizePathForComparison(pathValue, cwd);
|
|
103
|
+
if (!lexical) return "";
|
|
104
|
+
const canonical = canonicalizePath(lexical);
|
|
105
|
+
return process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
106
|
+
}
|
|
107
|
+
|
|
92
108
|
export function isPathOutsideWorkingDirectory(
|
|
93
109
|
pathValue: string,
|
|
94
110
|
cwd: string,
|
|
95
111
|
): boolean {
|
|
96
|
-
const normalizedCwd =
|
|
97
|
-
const normalizedPath =
|
|
112
|
+
const normalizedCwd = canonicalNormalizePathForComparison(cwd, cwd);
|
|
113
|
+
const normalizedPath = canonicalNormalizePathForComparison(pathValue, cwd);
|
|
98
114
|
if (!normalizedCwd || !normalizedPath) {
|
|
99
115
|
return false;
|
|
100
116
|
}
|
|
@@ -9,6 +9,14 @@ vi.mock("node:os", () => {
|
|
|
9
9
|
};
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
+
// Mock node:fs with an identity realpathSync so canonicalizePath
|
|
13
|
+
// (used by BashProgram.externalPaths) leaves test paths unchanged and
|
|
14
|
+
// existing expected-value literals remain accurate across platforms.
|
|
15
|
+
vi.mock("node:fs", () => ({
|
|
16
|
+
realpathSync: (p: string) => p,
|
|
17
|
+
default: { realpathSync: (p: string) => p },
|
|
18
|
+
}));
|
|
19
|
+
|
|
12
20
|
import { formatDenyReason } from "#src/denial-messages";
|
|
13
21
|
import {
|
|
14
22
|
extractExternalPathsFromBashCommand,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const realpathSync = vi.hoisted(() => vi.fn<(path: string) => string>());
|
|
4
|
+
|
|
5
|
+
vi.mock("node:fs", () => ({
|
|
6
|
+
realpathSync,
|
|
7
|
+
default: { realpathSync },
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { canonicalizePath } from "#src/canonicalize-path";
|
|
11
|
+
|
|
12
|
+
function enoent(p: string): NodeJS.ErrnoException {
|
|
13
|
+
return Object.assign(new Error(`ENOENT: no such file or directory '${p}'`), {
|
|
14
|
+
code: "ENOENT",
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("canonicalizePath", () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
realpathSync.mockReset();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns empty string for empty input", () => {
|
|
24
|
+
expect(canonicalizePath("")).toBe("");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("returns realpathSync result when path exists", () => {
|
|
28
|
+
realpathSync.mockReturnValueOnce("/real/projects/app");
|
|
29
|
+
expect(canonicalizePath("/projects/link")).toBe("/real/projects/app");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("re-appends a non-existent leaf to the canonical parent", () => {
|
|
33
|
+
realpathSync
|
|
34
|
+
.mockImplementationOnce(() => {
|
|
35
|
+
throw enoent("/projects/app/new-file.ts");
|
|
36
|
+
})
|
|
37
|
+
.mockReturnValueOnce("/canonical/app");
|
|
38
|
+
expect(canonicalizePath("/projects/app/new-file.ts")).toBe(
|
|
39
|
+
"/canonical/app/new-file.ts",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("walks up multiple levels for a deeply non-existent path", () => {
|
|
44
|
+
realpathSync
|
|
45
|
+
.mockImplementationOnce(() => {
|
|
46
|
+
throw enoent("/projects/app/src/new-file.ts");
|
|
47
|
+
})
|
|
48
|
+
.mockImplementationOnce(() => {
|
|
49
|
+
throw enoent("/projects/app/src");
|
|
50
|
+
})
|
|
51
|
+
.mockImplementationOnce(() => {
|
|
52
|
+
throw enoent("/projects/app");
|
|
53
|
+
})
|
|
54
|
+
.mockReturnValueOnce("/canonical/projects");
|
|
55
|
+
expect(canonicalizePath("/projects/app/src/new-file.ts")).toBe(
|
|
56
|
+
"/canonical/projects/app/src/new-file.ts",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("returns input unchanged when walk reaches filesystem root (all ENOENT)", () => {
|
|
61
|
+
realpathSync.mockImplementation(() => {
|
|
62
|
+
throw enoent("");
|
|
63
|
+
});
|
|
64
|
+
expect(canonicalizePath("/nonexistent/path/file.ts")).toBe(
|
|
65
|
+
"/nonexistent/path/file.ts",
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("returns input unchanged on ELOOP (symlink loop)", () => {
|
|
70
|
+
realpathSync.mockImplementation(() => {
|
|
71
|
+
throw Object.assign(new Error("ELOOP"), { code: "ELOOP" });
|
|
72
|
+
});
|
|
73
|
+
expect(canonicalizePath("/some/looping/path")).toBe("/some/looping/path");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("returns input unchanged on EACCES (permission denied)", () => {
|
|
77
|
+
realpathSync.mockImplementation(() => {
|
|
78
|
+
throw Object.assign(new Error("EACCES"), { code: "EACCES" });
|
|
79
|
+
});
|
|
80
|
+
expect(canonicalizePath("/restricted/path")).toBe("/restricted/path");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("handles ENOTDIR by walking up (like ENOENT)", () => {
|
|
84
|
+
realpathSync
|
|
85
|
+
.mockImplementationOnce(() => {
|
|
86
|
+
throw Object.assign(new Error("ENOTDIR"), { code: "ENOTDIR" });
|
|
87
|
+
})
|
|
88
|
+
.mockReturnValueOnce("/real/parent");
|
|
89
|
+
expect(canonicalizePath("/real/parent/not-a-dir")).toBe(
|
|
90
|
+
"/real/parent/not-a-dir",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/test/common.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
getNonEmptyString,
|
|
6
6
|
isPermissionState,
|
|
7
7
|
normalizeOptionalPositiveInt,
|
|
8
|
+
normalizeOptionalStringArray,
|
|
8
9
|
parseSimpleYamlMap,
|
|
9
10
|
toRecord,
|
|
10
11
|
} from "#src/common";
|
|
@@ -189,6 +190,44 @@ describe("parseSimpleYamlMap", () => {
|
|
|
189
190
|
});
|
|
190
191
|
});
|
|
191
192
|
|
|
193
|
+
describe("normalizeOptionalStringArray", () => {
|
|
194
|
+
it("returns the array for a valid string array", () => {
|
|
195
|
+
expect(normalizeOptionalStringArray(["a", "b", "c"])).toEqual([
|
|
196
|
+
"a",
|
|
197
|
+
"b",
|
|
198
|
+
"c",
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("returns an empty array for an empty array", () => {
|
|
203
|
+
expect(normalizeOptionalStringArray([])).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("returns undefined for a plain string", () => {
|
|
207
|
+
expect(normalizeOptionalStringArray("x")).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("returns undefined for a number", () => {
|
|
211
|
+
expect(normalizeOptionalStringArray(42)).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("returns undefined for a plain object", () => {
|
|
215
|
+
expect(normalizeOptionalStringArray({ a: "b" })).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("returns undefined for a mixed-type array", () => {
|
|
219
|
+
expect(normalizeOptionalStringArray(["a", 1])).toBeUndefined();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns undefined for undefined", () => {
|
|
223
|
+
expect(normalizeOptionalStringArray(undefined)).toBeUndefined();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("returns undefined for null", () => {
|
|
227
|
+
expect(normalizeOptionalStringArray(null)).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
192
231
|
describe("normalizeOptionalPositiveInt", () => {
|
|
193
232
|
it("returns the value for a valid positive integer", () => {
|
|
194
233
|
expect(normalizeOptionalPositiveInt(1)).toBe(1);
|
|
@@ -324,6 +324,48 @@ describe("loadUnifiedConfig", () => {
|
|
|
324
324
|
const result = loadUnifiedConfig(configPath);
|
|
325
325
|
expect(result.config).not.toHaveProperty("toolTextSummaryMaxLength");
|
|
326
326
|
});
|
|
327
|
+
|
|
328
|
+
it("parses piInfrastructureReadPaths when a valid string array is present", () => {
|
|
329
|
+
const configPath = join(tempDir, "config.json");
|
|
330
|
+
writeFileSync(
|
|
331
|
+
configPath,
|
|
332
|
+
JSON.stringify({ piInfrastructureReadPaths: ["/extra/path"] }),
|
|
333
|
+
);
|
|
334
|
+
const result = loadUnifiedConfig(configPath);
|
|
335
|
+
expect(result.config.piInfrastructureReadPaths).toEqual(["/extra/path"]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("parses piInfrastructureReadPaths as empty array when set to []", () => {
|
|
339
|
+
const configPath = join(tempDir, "config.json");
|
|
340
|
+
writeFileSync(
|
|
341
|
+
configPath,
|
|
342
|
+
JSON.stringify({ piInfrastructureReadPaths: [] }),
|
|
343
|
+
);
|
|
344
|
+
const result = loadUnifiedConfig(configPath);
|
|
345
|
+
expect(result.config.piInfrastructureReadPaths).toEqual([]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("omits piInfrastructureReadPaths when absent", () => {
|
|
349
|
+
const configPath = join(tempDir, "config.json");
|
|
350
|
+
writeFileSync(configPath, JSON.stringify({ debugLog: false }));
|
|
351
|
+
const result = loadUnifiedConfig(configPath);
|
|
352
|
+
expect(result.config).not.toHaveProperty("piInfrastructureReadPaths");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it.each([
|
|
356
|
+
["string", "not-an-array"],
|
|
357
|
+
["number", 42],
|
|
358
|
+
["mixed-type array", ["a", 1]],
|
|
359
|
+
["object", { a: "b" }],
|
|
360
|
+
] as const)("omits piInfrastructureReadPaths for invalid value: %s", (_label, value) => {
|
|
361
|
+
const configPath = join(tempDir, "config.json");
|
|
362
|
+
writeFileSync(
|
|
363
|
+
configPath,
|
|
364
|
+
JSON.stringify({ piInfrastructureReadPaths: value }),
|
|
365
|
+
);
|
|
366
|
+
const result = loadUnifiedConfig(configPath);
|
|
367
|
+
expect(result.config).not.toHaveProperty("piInfrastructureReadPaths");
|
|
368
|
+
});
|
|
327
369
|
});
|
|
328
370
|
|
|
329
371
|
describe("mergeUnifiedConfigs", () => {
|
|
@@ -460,6 +502,35 @@ describe("mergeUnifiedConfigs", () => {
|
|
|
460
502
|
const merged = mergeUnifiedConfigs({}, { permissionReviewLog: true });
|
|
461
503
|
expect(merged).not.toHaveProperty("toolTextSummaryMaxLength");
|
|
462
504
|
});
|
|
505
|
+
|
|
506
|
+
it("override piInfrastructureReadPaths replaces base array", () => {
|
|
507
|
+
const merged = mergeUnifiedConfigs(
|
|
508
|
+
{ piInfrastructureReadPaths: ["/base/path"] },
|
|
509
|
+
{ piInfrastructureReadPaths: ["/override/path"] },
|
|
510
|
+
);
|
|
511
|
+
expect(merged.piInfrastructureReadPaths).toEqual(["/override/path"]);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("base piInfrastructureReadPaths survives when override omits it", () => {
|
|
515
|
+
const merged = mergeUnifiedConfigs(
|
|
516
|
+
{ piInfrastructureReadPaths: ["/kept/path"] },
|
|
517
|
+
{ debugLog: true },
|
|
518
|
+
);
|
|
519
|
+
expect(merged.piInfrastructureReadPaths).toEqual(["/kept/path"]);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("piInfrastructureReadPaths is absent when both base and override omit it", () => {
|
|
523
|
+
const merged = mergeUnifiedConfigs({ debugLog: true }, { yoloMode: false });
|
|
524
|
+
expect(merged).not.toHaveProperty("piInfrastructureReadPaths");
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("override piInfrastructureReadPaths as empty array replaces non-empty base", () => {
|
|
528
|
+
const merged = mergeUnifiedConfigs(
|
|
529
|
+
{ piInfrastructureReadPaths: ["/base/path"] },
|
|
530
|
+
{ piInfrastructureReadPaths: [] },
|
|
531
|
+
);
|
|
532
|
+
expect(merged.piInfrastructureReadPaths).toEqual([]);
|
|
533
|
+
});
|
|
463
534
|
});
|
|
464
535
|
|
|
465
536
|
describe("loadAndMergeConfigs", () => {
|
|
@@ -293,6 +293,18 @@ describe("ConfigStore", () => {
|
|
|
293
293
|
store.refresh(ctx);
|
|
294
294
|
expect(mockSyncPermissionSystemStatus).not.toHaveBeenCalled();
|
|
295
295
|
});
|
|
296
|
+
|
|
297
|
+
it("carries piInfrastructureReadPaths from merged config into current()", () => {
|
|
298
|
+
const { store } = makeStore();
|
|
299
|
+
mockLoadAndMergeConfigs.mockReturnValue({
|
|
300
|
+
merged: { piInfrastructureReadPaths: ["/extra/path"] },
|
|
301
|
+
issues: [],
|
|
302
|
+
});
|
|
303
|
+
store.refresh();
|
|
304
|
+
expect(store.current().piInfrastructureReadPaths).toEqual([
|
|
305
|
+
"/extra/path",
|
|
306
|
+
]);
|
|
307
|
+
});
|
|
296
308
|
});
|
|
297
309
|
|
|
298
310
|
// ── save() ─────────────────────────────────────────────────────────────
|
|
@@ -385,6 +397,20 @@ describe("ConfigStore", () => {
|
|
|
385
397
|
"utf-8",
|
|
386
398
|
);
|
|
387
399
|
});
|
|
400
|
+
|
|
401
|
+
it("preserves an existing global piInfrastructureReadPaths on save", () => {
|
|
402
|
+
const { store } = makeStore();
|
|
403
|
+
// Simulate a global config.json that already has the infra-paths field.
|
|
404
|
+
mockLoadUnifiedConfig.mockReturnValue({
|
|
405
|
+
config: { piInfrastructureReadPaths: ["/extra/path"] },
|
|
406
|
+
});
|
|
407
|
+
store.save({ ...DEFAULT_EXTENSION_CONFIG }, makeCommandCtx());
|
|
408
|
+
expect(mockWriteFileSync).toHaveBeenCalledWith(
|
|
409
|
+
expect.stringContaining(".tmp"),
|
|
410
|
+
expect.stringContaining('"piInfrastructureReadPaths"'),
|
|
411
|
+
"utf-8",
|
|
412
|
+
);
|
|
413
|
+
});
|
|
388
414
|
});
|
|
389
415
|
|
|
390
416
|
// ── logResolvedPaths() ─────────────────────────────────────────────────
|
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
|
|
4
|
+
// Default is identity so all existing lexical tests are unaffected.
|
|
5
|
+
const realpathSync = vi.hoisted(() =>
|
|
6
|
+
vi.fn<(path: string) => string>((p) => p),
|
|
7
|
+
);
|
|
8
|
+
vi.mock("node:fs", () => ({
|
|
9
|
+
realpathSync,
|
|
10
|
+
default: { realpathSync },
|
|
11
|
+
}));
|
|
2
12
|
|
|
3
13
|
import { BashProgram } from "#src/handlers/gates/bash-program";
|
|
4
14
|
|
|
@@ -23,6 +33,11 @@ describe("BashProgram", () => {
|
|
|
23
33
|
describe("externalPaths", () => {
|
|
24
34
|
const cwd = "/projects/my-app";
|
|
25
35
|
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
realpathSync.mockReset();
|
|
38
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
39
|
+
});
|
|
40
|
+
|
|
26
41
|
it("returns absolute paths resolving outside cwd", async () => {
|
|
27
42
|
const program = await BashProgram.parse("cat /etc/hosts");
|
|
28
43
|
// Subset matcher: the path is normalized before comparison.
|
|
@@ -142,6 +157,33 @@ describe("BashProgram", () => {
|
|
|
142
157
|
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
143
158
|
});
|
|
144
159
|
});
|
|
160
|
+
|
|
161
|
+
it("flags an absolute in-cwd path that resolves externally via a symlink", async () => {
|
|
162
|
+
// The strict classifier only processes absolute tokens, so the escape
|
|
163
|
+
// surface is `cat /cwd/link/hosts` (absolute) where `link -> /etc`.
|
|
164
|
+
// Without canonicalization: /projects/my-app/link/hosts looks internal.
|
|
165
|
+
// With canonicalization: realpathSync resolves it to /etc/hosts.
|
|
166
|
+
realpathSync.mockImplementation((p: string) => {
|
|
167
|
+
if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
|
|
168
|
+
return p;
|
|
169
|
+
});
|
|
170
|
+
const program = await BashProgram.parse(
|
|
171
|
+
"cat /projects/my-app/link/hosts",
|
|
172
|
+
);
|
|
173
|
+
expect(program.externalPaths(cwd)).toContain("/etc/hosts");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("does not flag a token that resolves within a symlinked cwd", async () => {
|
|
177
|
+
// Simulates /tmp -> /private/tmp on macOS; cwd is the canonical form.
|
|
178
|
+
const symlinkCwd = "/private/tmp";
|
|
179
|
+
realpathSync.mockImplementation((p: string) => {
|
|
180
|
+
if (p === "/tmp") return "/private/tmp";
|
|
181
|
+
if (p.startsWith("/tmp/")) return "/private/tmp" + p.slice(4);
|
|
182
|
+
return p;
|
|
183
|
+
});
|
|
184
|
+
const program = await BashProgram.parse("cat /tmp/workspace/file.ts");
|
|
185
|
+
expect(program.externalPaths(symlinkCwd)).toHaveLength(0);
|
|
186
|
+
});
|
|
145
187
|
});
|
|
146
188
|
|
|
147
189
|
describe("commands", () => {
|
package/test/path-utils.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
3
3
|
|
|
4
4
|
// Mock node:os so tilde-expansion is deterministic across platforms.
|
|
5
5
|
vi.mock("node:os", () => {
|
|
@@ -10,7 +10,18 @@ vi.mock("node:os", () => {
|
|
|
10
10
|
};
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
|
|
14
|
+
// Default implementation is identity — existing lexical tests are unaffected.
|
|
15
|
+
const realpathSync = vi.hoisted(() =>
|
|
16
|
+
vi.fn<(path: string) => string>((p) => p),
|
|
17
|
+
);
|
|
18
|
+
vi.mock("node:fs", () => ({
|
|
19
|
+
realpathSync,
|
|
20
|
+
default: { realpathSync },
|
|
21
|
+
}));
|
|
22
|
+
|
|
13
23
|
import {
|
|
24
|
+
canonicalNormalizePathForComparison,
|
|
14
25
|
getPathBearingToolPath,
|
|
15
26
|
isPathOutsideWorkingDirectory,
|
|
16
27
|
isPathWithinDirectory,
|
|
@@ -200,6 +211,12 @@ describe("getPathBearingToolPath", () => {
|
|
|
200
211
|
describe("isPathOutsideWorkingDirectory", () => {
|
|
201
212
|
const cwd = "/projects/my-app";
|
|
202
213
|
|
|
214
|
+
beforeEach(() => {
|
|
215
|
+
// Reset then restore the identity default so symlink tests don't bleed.
|
|
216
|
+
realpathSync.mockReset();
|
|
217
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
218
|
+
});
|
|
219
|
+
|
|
203
220
|
test("returns false when path is inside cwd", () => {
|
|
204
221
|
expect(isPathOutsideWorkingDirectory("/projects/my-app/src", cwd)).toBe(
|
|
205
222
|
false,
|
|
@@ -245,6 +262,57 @@ describe("isPathOutsideWorkingDirectory", () => {
|
|
|
245
262
|
test("returns true for /dev/null/subdir (not a safe path)", () => {
|
|
246
263
|
expect(isPathOutsideWorkingDirectory("/dev/null/subdir", cwd)).toBe(true);
|
|
247
264
|
});
|
|
265
|
+
|
|
266
|
+
test("returns true for in-cwd symlink that resolves to external path", () => {
|
|
267
|
+
// ./link -> /etc: realpathSync resolves the full token in one call.
|
|
268
|
+
realpathSync.mockImplementation((p: string) => {
|
|
269
|
+
if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
|
|
270
|
+
return p;
|
|
271
|
+
});
|
|
272
|
+
expect(isPathOutsideWorkingDirectory("./link/hosts", cwd)).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("returns false for path inside a symlinked cwd", () => {
|
|
276
|
+
// /tmp -> /private/tmp on macOS; cwd reported as /private/tmp.
|
|
277
|
+
const symlinkCwd = "/private/tmp";
|
|
278
|
+
realpathSync.mockImplementation((p: string) => {
|
|
279
|
+
if (p.startsWith("/tmp/")) return "/private/tmp" + p.slice(4);
|
|
280
|
+
if (p === "/tmp") return "/private/tmp";
|
|
281
|
+
return p;
|
|
282
|
+
});
|
|
283
|
+
expect(
|
|
284
|
+
isPathOutsideWorkingDirectory("/tmp/workspace/file.ts", symlinkCwd),
|
|
285
|
+
).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("canonicalNormalizePathForComparison", () => {
|
|
290
|
+
const cwd = "/projects/my-app";
|
|
291
|
+
|
|
292
|
+
beforeEach(() => {
|
|
293
|
+
realpathSync.mockReset();
|
|
294
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("returns canonical form of an existing path", () => {
|
|
298
|
+
realpathSync.mockImplementation((p: string) => {
|
|
299
|
+
if (p === "/projects/link") return "/real/projects/app";
|
|
300
|
+
return p;
|
|
301
|
+
});
|
|
302
|
+
expect(canonicalNormalizePathForComparison("/projects/link", cwd)).toBe(
|
|
303
|
+
"/real/projects/app",
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("returns empty string for empty input", () => {
|
|
308
|
+
expect(canonicalNormalizePathForComparison("", cwd)).toBe("");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("returns lexical form when no symlinks (identity realpathSync)", () => {
|
|
312
|
+
expect(
|
|
313
|
+
canonicalNormalizePathForComparison("/projects/my-app/src/index.ts", cwd),
|
|
314
|
+
).toBe("/projects/my-app/src/index.ts");
|
|
315
|
+
});
|
|
248
316
|
});
|
|
249
317
|
|
|
250
318
|
describe("isPiInfrastructureRead", () => {
|