@gotgenes/pi-permission-system 10.5.2 → 10.6.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 CHANGED
@@ -5,6 +5,32 @@ 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.6.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.5.3...pi-permission-system-v10.6.0) (2026-06-08)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** add best-effort canonicalizePath helper ([5b5002e](https://github.com/gotgenes/pi-packages/commit/5b5002e1b5400485f30a9f22440a88d14ed5135d))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **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))
19
+ * **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))
20
+
21
+
22
+ ### Documentation
23
+
24
+ * **pi-permission-system:** note symlink canonicalization in architecture ([b758a48](https://github.com/gotgenes/pi-packages/commit/b758a48bc55485ad9e751543db59858886ba360c))
25
+
26
+ ## [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)
27
+
28
+
29
+ ### Bug Fixes
30
+
31
+ * merge tool preview length fields across config layers ([803fbb4](https://github.com/gotgenes/pi-packages/commit/803fbb4a118d4c26dc7b23fcec3f88d23aec0065))
32
+ * parse tool preview length fields in unified config loader ([3241956](https://github.com/gotgenes/pi-packages/commit/3241956b5656bc44788061c4a0a4ee334cb3ace5))
33
+
8
34
  ## [10.5.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.5.1...pi-permission-system-v10.5.2) (2026-06-08)
9
35
 
10
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "10.5.2",
3
+ "version": "10.6.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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,13 @@ 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 a positive integer; otherwise `undefined`. */
21
+ export function normalizeOptionalPositiveInt(raw: unknown): number | undefined {
22
+ return typeof raw === "number" && Number.isInteger(raw) && raw > 0
23
+ ? raw
24
+ : undefined;
25
+ }
26
+
20
27
  export function isPermissionState(value: unknown): value is PermissionState {
21
28
  return value === "allow" || value === "deny" || value === "ask";
22
29
  }
@@ -1,7 +1,11 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { normalize } from "node:path";
3
3
 
4
- import { isPermissionState, toRecord } from "./common";
4
+ import {
5
+ isPermissionState,
6
+ normalizeOptionalPositiveInt,
7
+ toRecord,
8
+ } from "./common";
5
9
  import {
6
10
  getGlobalConfigPath,
7
11
  getLegacyExtensionConfigPath,
@@ -21,6 +25,8 @@ export interface UnifiedPermissionConfig {
21
25
  debugLog?: boolean;
22
26
  permissionReviewLog?: boolean;
23
27
  yoloMode?: boolean;
28
+ toolInputPreviewMaxLength?: number;
29
+ toolTextSummaryMaxLength?: number;
24
30
 
25
31
  // Flat permission policy
26
32
  permission?: FlatPermissionConfig;
@@ -183,6 +189,18 @@ export function normalizeUnifiedConfig(raw: unknown): {
183
189
  const yoloMode = normalizeOptionalBoolean(record.yoloMode);
184
190
  if (yoloMode !== undefined) config.yoloMode = yoloMode;
185
191
 
192
+ const toolInputPreviewMaxLength = normalizeOptionalPositiveInt(
193
+ record.toolInputPreviewMaxLength,
194
+ );
195
+ if (toolInputPreviewMaxLength !== undefined)
196
+ config.toolInputPreviewMaxLength = toolInputPreviewMaxLength;
197
+
198
+ const toolTextSummaryMaxLength = normalizeOptionalPositiveInt(
199
+ record.toolTextSummaryMaxLength,
200
+ );
201
+ if (toolTextSummaryMaxLength !== undefined)
202
+ config.toolTextSummaryMaxLength = toolTextSummaryMaxLength;
203
+
186
204
  // Flat permission policy
187
205
  const permission = normalizeFlatPermissionValue(record.permission);
188
206
  if (permission !== undefined) config.permission = permission;
@@ -202,7 +220,7 @@ export function mergeUnifiedConfigs(
202
220
  ): UnifiedPermissionConfig {
203
221
  const merged: UnifiedPermissionConfig = {};
204
222
 
205
- // Scalars: override replaces base when defined
223
+ // Boolean scalars: override replaces base when defined
206
224
  for (const key of ["debugLog", "permissionReviewLog", "yoloMode"] as const) {
207
225
  const value = override[key] ?? base[key];
208
226
  if (value !== undefined) {
@@ -210,6 +228,17 @@ export function mergeUnifiedConfigs(
210
228
  }
211
229
  }
212
230
 
231
+ // Number scalars: override replaces base when defined
232
+ for (const key of [
233
+ "toolInputPreviewMaxLength",
234
+ "toolTextSummaryMaxLength",
235
+ ] as const) {
236
+ const value = override[key] ?? base[key];
237
+ if (value !== undefined) {
238
+ merged[key] = value;
239
+ }
240
+ }
241
+
213
242
  // Permission: deep-shallow merge
214
243
  const basePerm = base.permission;
215
244
  const overridePerm = override.permission;
@@ -2,7 +2,7 @@ import { mkdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
- import { toRecord } from "./common";
5
+ import { normalizeOptionalPositiveInt, toRecord } from "./common";
6
6
 
7
7
  export const EXTENSION_ID = "pi-permission-system";
8
8
 
@@ -46,13 +46,6 @@ export function detectMisplacedPermissionKeys(
46
46
  return Object.keys(raw).filter((key) => PERMISSION_POLICY_KEYS.has(key));
47
47
  }
48
48
 
49
- /** Returns `raw` if it is a positive integer; otherwise `undefined`. */
50
- export function normalizeOptionalPositiveInt(raw: unknown): number | undefined {
51
- return typeof raw === "number" && Number.isInteger(raw) && raw > 0
52
- ? raw
53
- : undefined;
54
- }
55
-
56
49
  export function normalizePermissionSystemConfig(
57
50
  raw: unknown,
58
51
  ): PermissionSystemExtensionConfig {
@@ -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 = normalizePathForComparison(cwd, cwd);
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 = normalizePathForComparison(candidate, cwd);
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 = normalizePathForComparison(candidate, resolveBase);
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 = normalizePathForComparison(
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 = normalizePathForComparison(cwd, cwd);
97
- const normalizedPath = normalizePathForComparison(pathValue, cwd);
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
+ });
@@ -1,9 +1,10 @@
1
- import { afterEach, describe, expect, test, vi } from "vitest";
1
+ import { afterEach, describe, expect, it, test, vi } from "vitest";
2
2
 
3
3
  import {
4
4
  extractFrontmatter,
5
5
  getNonEmptyString,
6
6
  isPermissionState,
7
+ normalizeOptionalPositiveInt,
7
8
  parseSimpleYamlMap,
8
9
  toRecord,
9
10
  } from "#src/common";
@@ -187,3 +188,33 @@ describe("parseSimpleYamlMap", () => {
187
188
  expect(result["quoted-key"]).toBe("value");
188
189
  });
189
190
  });
191
+
192
+ describe("normalizeOptionalPositiveInt", () => {
193
+ it("returns the value for a valid positive integer", () => {
194
+ expect(normalizeOptionalPositiveInt(1)).toBe(1);
195
+ expect(normalizeOptionalPositiveInt(200)).toBe(200);
196
+ expect(normalizeOptionalPositiveInt(9999)).toBe(9999);
197
+ });
198
+
199
+ it("returns undefined for zero", () => {
200
+ expect(normalizeOptionalPositiveInt(0)).toBeUndefined();
201
+ });
202
+
203
+ it("returns undefined for negative integers", () => {
204
+ expect(normalizeOptionalPositiveInt(-1)).toBeUndefined();
205
+ expect(normalizeOptionalPositiveInt(-100)).toBeUndefined();
206
+ });
207
+
208
+ it("returns undefined for non-integer numbers (floats)", () => {
209
+ expect(normalizeOptionalPositiveInt(400.5)).toBeUndefined();
210
+ expect(normalizeOptionalPositiveInt(1.1)).toBeUndefined();
211
+ });
212
+
213
+ it("returns undefined for non-number types", () => {
214
+ expect(normalizeOptionalPositiveInt("200")).toBeUndefined();
215
+ expect(normalizeOptionalPositiveInt(true)).toBeUndefined();
216
+ expect(normalizeOptionalPositiveInt(null)).toBeUndefined();
217
+ expect(normalizeOptionalPositiveInt(undefined)).toBeUndefined();
218
+ expect(normalizeOptionalPositiveInt({})).toBeUndefined();
219
+ });
220
+ });
@@ -258,6 +258,72 @@ describe("loadUnifiedConfig", () => {
258
258
  const result = loadUnifiedConfig(configPath);
259
259
  expect(result.config.permission).toBeUndefined();
260
260
  });
261
+
262
+ it("parses toolInputPreviewMaxLength when a valid positive integer is present", () => {
263
+ const configPath = join(tempDir, "config.json");
264
+ writeFileSync(
265
+ configPath,
266
+ JSON.stringify({ toolInputPreviewMaxLength: 1000 }),
267
+ );
268
+ const result = loadUnifiedConfig(configPath);
269
+ expect(result.config.toolInputPreviewMaxLength).toBe(1000);
270
+ });
271
+
272
+ it("parses toolTextSummaryMaxLength when a valid positive integer is present", () => {
273
+ const configPath = join(tempDir, "config.json");
274
+ writeFileSync(
275
+ configPath,
276
+ JSON.stringify({ toolTextSummaryMaxLength: 120 }),
277
+ );
278
+ const result = loadUnifiedConfig(configPath);
279
+ expect(result.config.toolTextSummaryMaxLength).toBe(120);
280
+ });
281
+
282
+ it("omits toolInputPreviewMaxLength when absent", () => {
283
+ const configPath = join(tempDir, "config.json");
284
+ writeFileSync(configPath, JSON.stringify({ debugLog: false }));
285
+ const result = loadUnifiedConfig(configPath);
286
+ expect(result.config).not.toHaveProperty("toolInputPreviewMaxLength");
287
+ });
288
+
289
+ it("omits toolTextSummaryMaxLength when absent", () => {
290
+ const configPath = join(tempDir, "config.json");
291
+ writeFileSync(configPath, JSON.stringify({ debugLog: false }));
292
+ const result = loadUnifiedConfig(configPath);
293
+ expect(result.config).not.toHaveProperty("toolTextSummaryMaxLength");
294
+ });
295
+
296
+ it.each([
297
+ ["zero", 0],
298
+ ["negative", -1],
299
+ ["float", 1.5],
300
+ ["string", "200"],
301
+ ["boolean", true],
302
+ ] as const)("omits toolInputPreviewMaxLength for invalid value: %s", (_label, value) => {
303
+ const configPath = join(tempDir, "config.json");
304
+ writeFileSync(
305
+ configPath,
306
+ JSON.stringify({ toolInputPreviewMaxLength: value }),
307
+ );
308
+ const result = loadUnifiedConfig(configPath);
309
+ expect(result.config).not.toHaveProperty("toolInputPreviewMaxLength");
310
+ });
311
+
312
+ it.each([
313
+ ["zero", 0],
314
+ ["negative", -1],
315
+ ["float", 1.5],
316
+ ["string", "80"],
317
+ ["boolean", false],
318
+ ] as const)("omits toolTextSummaryMaxLength for invalid value: %s", (_label, value) => {
319
+ const configPath = join(tempDir, "config.json");
320
+ writeFileSync(
321
+ configPath,
322
+ JSON.stringify({ toolTextSummaryMaxLength: value }),
323
+ );
324
+ const result = loadUnifiedConfig(configPath);
325
+ expect(result.config).not.toHaveProperty("toolTextSummaryMaxLength");
326
+ });
261
327
  });
262
328
 
263
329
  describe("mergeUnifiedConfigs", () => {
@@ -352,6 +418,48 @@ describe("mergeUnifiedConfigs", () => {
352
418
  expect(merged).not.toHaveProperty("permissionReviewLog");
353
419
  expect(merged).not.toHaveProperty("permission");
354
420
  });
421
+
422
+ it("override toolInputPreviewMaxLength replaces base value", () => {
423
+ const merged = mergeUnifiedConfigs(
424
+ { toolInputPreviewMaxLength: 200 },
425
+ { toolInputPreviewMaxLength: 1000 },
426
+ );
427
+ expect(merged.toolInputPreviewMaxLength).toBe(1000);
428
+ });
429
+
430
+ it("base toolInputPreviewMaxLength survives when override omits it", () => {
431
+ const merged = mergeUnifiedConfigs(
432
+ { toolInputPreviewMaxLength: 500 },
433
+ { debugLog: true },
434
+ );
435
+ expect(merged.toolInputPreviewMaxLength).toBe(500);
436
+ });
437
+
438
+ it("toolInputPreviewMaxLength is absent when both base and override omit it", () => {
439
+ const merged = mergeUnifiedConfigs({ debugLog: true }, { yoloMode: false });
440
+ expect(merged).not.toHaveProperty("toolInputPreviewMaxLength");
441
+ });
442
+
443
+ it("override toolTextSummaryMaxLength replaces base value", () => {
444
+ const merged = mergeUnifiedConfigs(
445
+ { toolTextSummaryMaxLength: 80 },
446
+ { toolTextSummaryMaxLength: 200 },
447
+ );
448
+ expect(merged.toolTextSummaryMaxLength).toBe(200);
449
+ });
450
+
451
+ it("base toolTextSummaryMaxLength survives when override omits it", () => {
452
+ const merged = mergeUnifiedConfigs(
453
+ { toolTextSummaryMaxLength: 120 },
454
+ { debugLog: false },
455
+ );
456
+ expect(merged.toolTextSummaryMaxLength).toBe(120);
457
+ });
458
+
459
+ it("toolTextSummaryMaxLength is absent when both base and override omit it", () => {
460
+ const merged = mergeUnifiedConfigs({}, { permissionReviewLog: true });
461
+ expect(merged).not.toHaveProperty("toolTextSummaryMaxLength");
462
+ });
355
463
  });
356
464
 
357
465
  describe("loadAndMergeConfigs", () => {
@@ -371,6 +371,20 @@ describe("ConfigStore", () => {
371
371
  store.save({ ...DEFAULT_EXTENSION_CONFIG }, ctx);
372
372
  expect(mockUnlinkSync).toHaveBeenCalled();
373
373
  });
374
+
375
+ it("preserves an existing global toolInputPreviewMaxLength on save", () => {
376
+ const { store } = makeStore();
377
+ // Simulate a global config.json that already has the preview-length field.
378
+ mockLoadUnifiedConfig.mockReturnValue({
379
+ config: { toolInputPreviewMaxLength: 800 },
380
+ });
381
+ store.save({ ...DEFAULT_EXTENSION_CONFIG }, makeCommandCtx());
382
+ expect(mockWriteFileSync).toHaveBeenCalledWith(
383
+ expect.stringContaining(".tmp"),
384
+ expect.stringContaining('"toolInputPreviewMaxLength": 800'),
385
+ "utf-8",
386
+ );
387
+ });
374
388
  });
375
389
 
376
390
  // ── logResolvedPaths() ─────────────────────────────────────────────────
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import {
4
4
  detectMisplacedPermissionKeys,
5
- normalizeOptionalPositiveInt,
6
5
  normalizePermissionSystemConfig,
7
6
  } from "#src/extension-config";
8
7
 
@@ -75,36 +74,6 @@ describe("detectMisplacedPermissionKeys", () => {
75
74
  });
76
75
  });
77
76
 
78
- describe("normalizeOptionalPositiveInt", () => {
79
- it("returns the value for a valid positive integer", () => {
80
- expect(normalizeOptionalPositiveInt(1)).toBe(1);
81
- expect(normalizeOptionalPositiveInt(200)).toBe(200);
82
- expect(normalizeOptionalPositiveInt(9999)).toBe(9999);
83
- });
84
-
85
- it("returns undefined for zero", () => {
86
- expect(normalizeOptionalPositiveInt(0)).toBeUndefined();
87
- });
88
-
89
- it("returns undefined for negative integers", () => {
90
- expect(normalizeOptionalPositiveInt(-1)).toBeUndefined();
91
- expect(normalizeOptionalPositiveInt(-100)).toBeUndefined();
92
- });
93
-
94
- it("returns undefined for non-integer numbers (floats)", () => {
95
- expect(normalizeOptionalPositiveInt(400.5)).toBeUndefined();
96
- expect(normalizeOptionalPositiveInt(1.1)).toBeUndefined();
97
- });
98
-
99
- it("returns undefined for non-number types", () => {
100
- expect(normalizeOptionalPositiveInt("200")).toBeUndefined();
101
- expect(normalizeOptionalPositiveInt(true)).toBeUndefined();
102
- expect(normalizeOptionalPositiveInt(null)).toBeUndefined();
103
- expect(normalizeOptionalPositiveInt(undefined)).toBeUndefined();
104
- expect(normalizeOptionalPositiveInt({})).toBeUndefined();
105
- });
106
- });
107
-
108
77
  describe("normalizePermissionSystemConfig", () => {
109
78
  it("normalizes a valid config object", () => {
110
79
  const result = normalizePermissionSystemConfig({
@@ -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", () => {
@@ -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", () => {