@gotgenes/pi-permission-system 18.0.1 → 18.0.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,15 @@ 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
+ ## [18.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v18.0.1...pi-permission-system-v18.0.2) (2026-07-01)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** add AccessPath.resolvedAlias() for symlink-target disclosure ([cf6e25c](https://github.com/gotgenes/pi-packages/commit/cf6e25c8a5b672fbab4b41a21240af8ca818971b))
14
+ * **pi-permission-system:** disclose resolved symlink target in tool external-directory messages ([1225e2b](https://github.com/gotgenes/pi-packages/commit/1225e2bcface907a0a4407aadcd744c2398edc19))
15
+ * **pi-permission-system:** disclose resolved symlink targets in bash external-directory messages ([f9fab42](https://github.com/gotgenes/pi-packages/commit/f9fab42b758dd106e67b83a0e3c95fe6fc216687))
16
+
8
17
  ## [18.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v18.0.0...pi-permission-system-v18.0.1) (2026-06-30)
9
18
 
10
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "18.0.1",
3
+ "version": "18.0.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,6 +19,9 @@ import {
19
19
  * outside-CWD containment and infra-read checks.
20
20
  * - {@link value} returns `string` — the lexical absolute form, for display,
21
21
  * approval patterns, decision values, and logs.
22
+ * - {@link resolvedAlias} returns `string | undefined` — the canonical form
23
+ * only when it names a location distinct from the lexical form, for
24
+ * disclosing a symlink target in a prompt or denial message.
22
25
  *
23
26
  * Construct via {@link forPath} (resolved, with optional cd-folded base) or
24
27
  * {@link forLiteral} (literal-only, for an unknown base); the constructor is
@@ -66,6 +69,19 @@ export class AccessPath {
66
69
  return this.lexical;
67
70
  }
68
71
 
72
+ /**
73
+ * The canonical (symlink-resolved) form when it names a location distinct
74
+ * from the lexical form — for disclosing the resolved target in a prompt or
75
+ * denial message. `undefined` when the path is not a symlink (canonical
76
+ * equals lexical) or has no canonical (literal-only / empty input).
77
+ */
78
+ resolvedAlias(): string | undefined {
79
+ if (!this.canonical || this.canonical === this.lexical) {
80
+ return undefined;
81
+ }
82
+ return this.canonical;
83
+ }
84
+
69
85
  /**
70
86
  * Build an `AccessPath` for a tool-input or bash-token path, resolved against
71
87
  * `resolveBase` (the cd-folded effective directory; defaults to `cwd`).
@@ -5,6 +5,21 @@ import type { BashCommandContext, PermissionCheckResult } from "./types";
5
5
 
6
6
  export const EXTENSION_TAG = `[${EXTENSION_ID}]`;
7
7
 
8
+ // ── External-path resolved-target disclosure ────────────────────────────────
9
+
10
+ /** A displayed external path paired with its resolved target, when distinct. */
11
+ export interface ExternalPathDisclosure {
12
+ /** The path as displayed (typed for tools, lexical-absolute for bash). */
13
+ path: string;
14
+ /** The canonical symlink-resolved target; present only when it differs. */
15
+ resolvedPath?: string;
16
+ }
17
+
18
+ /** ` (resolves to '<canonical>')` when a distinct target exists, else `""`. */
19
+ export function resolvesToSuffix(resolvedPath?: string): string {
20
+ return resolvedPath ? ` (resolves to '${resolvedPath}')` : "";
21
+ }
22
+
8
23
  // ── Denial context discriminated union ─────────────────────────────────────
9
24
 
10
25
  export type DenialContext =
@@ -24,13 +39,14 @@ export type DenialContext =
24
39
  kind: "external_directory";
25
40
  toolName: string;
26
41
  pathValue: string;
42
+ resolvedPath?: string;
27
43
  cwd: string;
28
44
  agentName?: string;
29
45
  }
30
46
  | {
31
47
  kind: "bash_external_directory";
32
48
  command: string;
33
- externalPaths: string[];
49
+ externalPaths: ExternalPathDisclosure[];
34
50
  cwd: string;
35
51
  agentName?: string;
36
52
  }
@@ -89,9 +105,9 @@ function buildDenyBody(ctx: DenialContext): string {
89
105
  case "path":
90
106
  return `${subject(ctx.agentName)} is not permitted to access path '${ctx.pathValue}' via tool '${ctx.toolName}'.`;
91
107
  case "external_directory":
92
- return `${subject(ctx.agentName)} is not permitted to run tool '${ctx.toolName}' for path '${ctx.pathValue}' outside working directory '${ctx.cwd}'.`;
108
+ return `${subject(ctx.agentName)} is not permitted to run tool '${ctx.toolName}' for path '${ctx.pathValue}'${resolvesToSuffix(ctx.resolvedPath)} outside working directory '${ctx.cwd}'.`;
93
109
  case "bash_external_directory":
94
- return `${subject(ctx.agentName)} is not permitted to run bash command '${ctx.command}' which references path(s) outside working directory '${ctx.cwd}': ${ctx.externalPaths.join(", ")}.`;
110
+ return `${subject(ctx.agentName)} is not permitted to run bash command '${ctx.command}' which references path(s) outside working directory '${ctx.cwd}': ${formatExternalPathList(ctx.externalPaths)}.`;
95
111
  case "bash_path":
96
112
  return `${subject(ctx.agentName)} is not permitted to access path '${ctx.pathValue}' via tool 'bash'.`;
97
113
  case "skill_read":
@@ -185,7 +201,7 @@ function buildUnavailableBody(ctx: DenialContext): string {
185
201
  case "path":
186
202
  return `Accessing '${ctx.pathValue}' requires approval, but no interactive UI is available.`;
187
203
  case "external_directory":
188
- return `Accessing '${ctx.pathValue}' outside the working directory requires approval, but no interactive UI is available.`;
204
+ return `Accessing '${ctx.pathValue}'${resolvesToSuffix(ctx.resolvedPath)} outside the working directory requires approval, but no interactive UI is available.`;
189
205
  case "bash_external_directory":
190
206
  return `Bash command '${ctx.command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`;
191
207
  case "bash_path":
@@ -215,7 +231,7 @@ function buildUserDeniedBody(
215
231
  case "path":
216
232
  return `User denied access to path '${ctx.pathValue}'.${reasonSuffix(denialReason)}`;
217
233
  case "external_directory":
218
- return `User denied external directory access for tool '${ctx.toolName}' path '${ctx.pathValue}'.${reasonSuffix(denialReason)}`;
234
+ return `User denied external directory access for tool '${ctx.toolName}' path '${ctx.pathValue}'${resolvesToSuffix(ctx.resolvedPath)}.${reasonSuffix(denialReason)}`;
219
235
  case "bash_external_directory":
220
236
  return `User denied external directory access for bash command '${ctx.command}'.${reasonSuffix(denialReason)}`;
221
237
  case "bash_path":
@@ -230,3 +246,10 @@ function buildUserDeniedBody(
230
246
  function isMcpCheck(check: PermissionCheckResult): boolean {
231
247
  return (check.source === "mcp" || check.toolName === "mcp") && !!check.target;
232
248
  }
249
+
250
+ /** Render an external-path disclosure list for the bash deny body's path clause. */
251
+ function formatExternalPathList(paths: ExternalPathDisclosure[]): string {
252
+ return paths
253
+ .map(({ path, resolvedPath }) => `${path}${resolvesToSuffix(resolvedPath)}`)
254
+ .join(", ");
255
+ }
@@ -67,9 +67,14 @@ export function describeBashExternalDirectoryGate(
67
67
  // config-level "deny" is preserved (not downgraded to the catch-all "ask").
68
68
  const preCheck = worstCheck ?? uncoveredEntries[0].check;
69
69
 
70
+ const disclosures = uncoveredEntries.map(({ path }) => ({
71
+ path: path.value(),
72
+ resolvedPath: path.resolvedAlias(),
73
+ }));
74
+
70
75
  const bashExtMessage = formatBashExternalDirectoryAskPrompt(
71
76
  command,
72
- uncoveredPaths,
77
+ disclosures,
73
78
  tcc.cwd,
74
79
  tcc.agentName ?? undefined,
75
80
  );
@@ -82,7 +87,7 @@ export function describeBashExternalDirectoryGate(
82
87
  denialContext: {
83
88
  kind: "bash_external_directory",
84
89
  command,
85
- externalPaths: uncoveredPaths,
90
+ externalPaths: disclosures,
86
91
  cwd: tcc.cwd,
87
92
  agentName: tcc.agentName ?? undefined,
88
93
  },
@@ -1,20 +1,28 @@
1
+ import {
2
+ type ExternalPathDisclosure,
3
+ resolvesToSuffix,
4
+ } from "#src/denial-messages";
5
+
1
6
  export function formatExternalDirectoryAskPrompt(
2
7
  toolName: string,
3
8
  pathValue: string,
9
+ resolvedPath: string | undefined,
4
10
  cwd: string,
5
11
  agentName?: string,
6
12
  ): string {
7
13
  const subject = agentName ? `Agent '${agentName}'` : "Current agent";
8
- return `${subject} requested tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. Allow this external directory access?`;
14
+ return `${subject} requested tool '${toolName}' for path '${pathValue}'${resolvesToSuffix(resolvedPath)} outside working directory '${cwd}'. Allow this external directory access?`;
9
15
  }
10
16
 
11
17
  export function formatBashExternalDirectoryAskPrompt(
12
18
  command: string,
13
- externalPaths: string[],
19
+ externalPaths: ExternalPathDisclosure[],
14
20
  cwd: string,
15
21
  agentName?: string,
16
22
  ): string {
17
23
  const subject = agentName ? `Agent '${agentName}'` : "Current agent";
18
- const pathList = externalPaths.join(", ");
24
+ const pathList = externalPaths
25
+ .map(({ path, resolvedPath }) => `${path}${resolvesToSuffix(resolvedPath)}`)
26
+ .join(", ");
19
27
  return `${subject} requested bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. Allow this external directory access?`;
20
28
  }
@@ -67,9 +67,11 @@ export function describeExternalDirectoryGate(
67
67
  }
68
68
 
69
69
  // ── Build descriptor for permission check ───────────────────────────────
70
+ const resolvedAlias = accessPath.resolvedAlias();
70
71
  const extDirMessage = formatExternalDirectoryAskPrompt(
71
72
  tcc.toolName,
72
73
  externalDirectoryPath,
74
+ resolvedAlias,
73
75
  tcc.cwd,
74
76
  tcc.agentName ?? undefined,
75
77
  );
@@ -90,6 +92,7 @@ export function describeExternalDirectoryGate(
90
92
  kind: "external_directory",
91
93
  toolName: tcc.toolName,
92
94
  pathValue: externalDirectoryPath,
95
+ resolvedPath: resolvedAlias,
93
96
  cwd: tcc.cwd,
94
97
  agentName: tcc.agentName ?? undefined,
95
98
  },
@@ -191,6 +191,67 @@ describe("AccessPath.forPath", () => {
191
191
  });
192
192
  });
193
193
 
194
+ describe("resolvedAlias()", () => {
195
+ const cwd = "/projects/my-app";
196
+
197
+ beforeEach(() => {
198
+ realpathSync.mockReset();
199
+ realpathSync.mockImplementation((p: string) => p);
200
+ });
201
+
202
+ test("returns the canonical form when a symlink resolves elsewhere", () => {
203
+ realpathSync.mockImplementation((p: string) =>
204
+ p === "/projects/my-app/demo-symlink-passwd" ? "/etc/passwd" : p,
205
+ );
206
+ expect(
207
+ AccessPath.forPath("demo-symlink-passwd", {
208
+ cwd,
209
+ platform: "linux",
210
+ }).resolvedAlias(),
211
+ ).toBe("/etc/passwd");
212
+ });
213
+
214
+ test("returns undefined when the path has no symlinks (canonical equals lexical)", () => {
215
+ expect(
216
+ AccessPath.forPath("/etc/hosts", {
217
+ cwd,
218
+ platform: "linux",
219
+ }).resolvedAlias(),
220
+ ).toBeUndefined();
221
+ });
222
+
223
+ test("returns undefined for a literal-only path (no canonical)", () => {
224
+ expect(AccessPath.forLiteral("foo.ts").resolvedAlias()).toBeUndefined();
225
+ });
226
+
227
+ test("returns undefined for empty input", () => {
228
+ expect(
229
+ AccessPath.forPath("", { cwd, platform: "linux" }).resolvedAlias(),
230
+ ).toBeUndefined();
231
+ });
232
+
233
+ test("win32: returns the lowercased canonical form for a real symlink target", () => {
234
+ realpathSync.mockImplementation((p: string) =>
235
+ p === "c:\\projects\\app\\link" ? "C:\\Real\\App" : p,
236
+ );
237
+ expect(
238
+ AccessPath.forPath("link", {
239
+ cwd: "C:\\Projects\\App",
240
+ platform: "win32",
241
+ }).resolvedAlias(),
242
+ ).toBe("c:\\real\\app");
243
+ });
244
+
245
+ test("win32: returns undefined for a case-only difference (both forms lowercased)", () => {
246
+ expect(
247
+ AccessPath.forPath("src\\foo.ts", {
248
+ cwd: "C:\\Projects\\App",
249
+ platform: "win32",
250
+ }).resolvedAlias(),
251
+ ).toBeUndefined();
252
+ });
253
+ });
254
+
194
255
  describe("AccessPath.forLiteral", () => {
195
256
  beforeEach(() => {
196
257
  realpathSync.mockReset();
@@ -924,7 +924,7 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
924
924
  test("includes command, external paths, and CWD", () => {
925
925
  const result = formatBashExternalDirectoryAskPrompt(
926
926
  "cat /etc/hosts",
927
- ["/etc/hosts"],
927
+ [{ path: "/etc/hosts" }],
928
928
  "/projects/my-app",
929
929
  );
930
930
  expect(result).toContain("cat /etc/hosts");
@@ -935,7 +935,7 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
935
935
  test("includes agent name when provided", () => {
936
936
  const result = formatBashExternalDirectoryAskPrompt(
937
937
  "cat /etc/hosts",
938
- ["/etc/hosts"],
938
+ [{ path: "/etc/hosts" }],
939
939
  "/projects/my-app",
940
940
  "my-agent",
941
941
  );
@@ -945,12 +945,26 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
945
945
  test("shows multiple external paths", () => {
946
946
  const result = formatBashExternalDirectoryAskPrompt(
947
947
  "diff /etc/hosts /var/log/syslog",
948
- ["/etc/hosts", "/var/log/syslog"],
948
+ [{ path: "/etc/hosts" }, { path: "/var/log/syslog" }],
949
949
  "/projects/my-app",
950
950
  );
951
951
  expect(result).toContain("/etc/hosts");
952
952
  expect(result).toContain("/var/log/syslog");
953
953
  });
954
+
955
+ test("discloses the resolved target per path when it differs", () => {
956
+ const result = formatBashExternalDirectoryAskPrompt(
957
+ "cat demo-symlink-passwd /etc/hosts",
958
+ [
959
+ { path: "demo-symlink-passwd", resolvedPath: "/etc/passwd" },
960
+ { path: "/etc/hosts" },
961
+ ],
962
+ "/projects/my-app",
963
+ );
964
+ expect(result).toBe(
965
+ "Current agent requested bash command 'cat demo-symlink-passwd /etc/hosts' which references path(s) outside working directory '/projects/my-app': demo-symlink-passwd (resolves to '/etc/passwd'), /etc/hosts. Allow this external directory access?",
966
+ );
967
+ });
954
968
  });
955
969
 
956
970
  describe("Windows drive-letter paths (win32 semantics)", () => {
@@ -996,7 +1010,7 @@ describe("bash external-directory denial messages (centralized)", () => {
996
1010
  const result = formatDenyReason({
997
1011
  kind: "bash_external_directory",
998
1012
  command: "cat /etc/hosts",
999
- externalPaths: ["/etc/hosts"],
1013
+ externalPaths: [{ path: "/etc/hosts" }],
1000
1014
  cwd: "/projects/my-app",
1001
1015
  });
1002
1016
  expect(result).toContain("cat /etc/hosts");
@@ -242,6 +242,20 @@ describe("formatDenyReason", () => {
242
242
  "[pi-permission-system] Agent 'sec-agent' is not permitted to run tool 'read' for path '/etc/passwd' outside working directory '/project'.",
243
243
  );
244
244
  });
245
+
246
+ test("discloses the resolved path when it differs from the typed path", () => {
247
+ expect(
248
+ formatDenyReason({
249
+ kind: "external_directory",
250
+ toolName: "read",
251
+ pathValue: "demo-symlink-passwd",
252
+ resolvedPath: "/etc/passwd",
253
+ cwd: "/project",
254
+ }),
255
+ ).toBe(
256
+ "[pi-permission-system] Current agent is not permitted to run tool 'read' for path 'demo-symlink-passwd' (resolves to '/etc/passwd') outside working directory '/project'.",
257
+ );
258
+ });
245
259
  });
246
260
 
247
261
  describe("bash_external_directory context", () => {
@@ -250,7 +264,7 @@ describe("formatDenyReason", () => {
250
264
  formatDenyReason({
251
265
  kind: "bash_external_directory",
252
266
  command: "cat /etc/hosts",
253
- externalPaths: ["/etc/hosts"],
267
+ externalPaths: [{ path: "/etc/hosts" }],
254
268
  cwd: "/project",
255
269
  }),
256
270
  ).toBe(
@@ -263,7 +277,7 @@ describe("formatDenyReason", () => {
263
277
  formatDenyReason({
264
278
  kind: "bash_external_directory",
265
279
  command: "cp /etc/hosts /tmp/out",
266
- externalPaths: ["/etc/hosts", "/tmp/out"],
280
+ externalPaths: [{ path: "/etc/hosts" }, { path: "/tmp/out" }],
267
281
  cwd: "/project",
268
282
  agentName: "my-agent",
269
283
  }),
@@ -271,6 +285,22 @@ describe("formatDenyReason", () => {
271
285
  "[pi-permission-system] Agent 'my-agent' is not permitted to run bash command 'cp /etc/hosts /tmp/out' which references path(s) outside working directory '/project': /etc/hosts, /tmp/out.",
272
286
  );
273
287
  });
288
+
289
+ test("discloses resolved targets per path when they differ", () => {
290
+ expect(
291
+ formatDenyReason({
292
+ kind: "bash_external_directory",
293
+ command: "cat demo-symlink-passwd /etc/hosts",
294
+ externalPaths: [
295
+ { path: "demo-symlink-passwd", resolvedPath: "/etc/passwd" },
296
+ { path: "/etc/hosts" },
297
+ ],
298
+ cwd: "/project",
299
+ }),
300
+ ).toBe(
301
+ "[pi-permission-system] Current agent is not permitted to run bash command 'cat demo-symlink-passwd /etc/hosts' which references path(s) outside working directory '/project': demo-symlink-passwd (resolves to '/etc/passwd'), /etc/hosts.",
302
+ );
303
+ });
274
304
  });
275
305
 
276
306
  describe("bash_path context", () => {
@@ -403,12 +433,26 @@ describe("formatUnavailableReason", () => {
403
433
  );
404
434
  });
405
435
 
436
+ test("external_directory discloses the resolved path when it differs from the typed path", () => {
437
+ expect(
438
+ formatUnavailableReason({
439
+ kind: "external_directory",
440
+ toolName: "read",
441
+ pathValue: "demo-symlink-passwd",
442
+ resolvedPath: "/etc/passwd",
443
+ cwd: "/project",
444
+ }),
445
+ ).toBe(
446
+ "[pi-permission-system] Accessing 'demo-symlink-passwd' (resolves to '/etc/passwd') outside the working directory requires approval, but no interactive UI is available.",
447
+ );
448
+ });
449
+
406
450
  test("bash_external_directory", () => {
407
451
  expect(
408
452
  formatUnavailableReason({
409
453
  kind: "bash_external_directory",
410
454
  command: "cat /etc/hosts",
411
- externalPaths: ["/etc/hosts"],
455
+ externalPaths: [{ path: "/etc/hosts" }],
412
456
  cwd: "/project",
413
457
  }),
414
458
  ).toBe(
@@ -539,6 +583,20 @@ describe("formatUserDeniedReason", () => {
539
583
  "[pi-permission-system] User denied external directory access for tool 'edit' path '/etc/hosts'. Reason: too risky.",
540
584
  );
541
585
  });
586
+
587
+ test("discloses the resolved path when it differs from the typed path", () => {
588
+ expect(
589
+ formatUserDeniedReason({
590
+ kind: "external_directory",
591
+ toolName: "edit",
592
+ pathValue: "demo-symlink-hosts",
593
+ resolvedPath: "/etc/hosts",
594
+ cwd: "/project",
595
+ }),
596
+ ).toBe(
597
+ "[pi-permission-system] User denied external directory access for tool 'edit' path 'demo-symlink-hosts' (resolves to '/etc/hosts').",
598
+ );
599
+ });
542
600
  });
543
601
 
544
602
  describe("bash_external_directory context", () => {
@@ -547,7 +605,7 @@ describe("formatUserDeniedReason", () => {
547
605
  formatUserDeniedReason({
548
606
  kind: "bash_external_directory",
549
607
  command: "rm /etc/hosts",
550
- externalPaths: ["/etc/hosts"],
608
+ externalPaths: [{ path: "/etc/hosts" }],
551
609
  cwd: "/project",
552
610
  }),
553
611
  ).toBe(
@@ -561,7 +619,7 @@ describe("formatUserDeniedReason", () => {
561
619
  {
562
620
  kind: "bash_external_directory",
563
621
  command: "rm /etc/hosts",
564
- externalPaths: ["/etc/hosts"],
622
+ externalPaths: [{ path: "/etc/hosts" }],
565
623
  cwd: "/project",
566
624
  },
567
625
  "dangerous",
@@ -47,7 +47,12 @@ describe("external_directory helper regression guard", () => {
47
47
  it("formatExternalDirectoryAskPrompt is a callable function", () => {
48
48
  expect(typeof formatExternalDirectoryAskPrompt).toBe("function");
49
49
  expect(
50
- formatExternalDirectoryAskPrompt("read", "/outside/file", "/project"),
50
+ formatExternalDirectoryAskPrompt(
51
+ "read",
52
+ "/outside/file",
53
+ undefined,
54
+ "/project",
55
+ ),
51
56
  ).toContain("/outside/file");
52
57
  });
53
58
 
@@ -15,6 +15,7 @@ describe("formatExternalDirectoryAskPrompt", () => {
15
15
  const result = formatExternalDirectoryAskPrompt(
16
16
  "read",
17
17
  "/etc/passwd",
18
+ undefined,
18
19
  "/projects/my-app",
19
20
  );
20
21
  expect(result).toContain("Current agent");
@@ -27,6 +28,7 @@ describe("formatExternalDirectoryAskPrompt", () => {
27
28
  const result = formatExternalDirectoryAskPrompt(
28
29
  "write",
29
30
  "/tmp/out.txt",
31
+ undefined,
30
32
  "/projects/my-app",
31
33
  "my-agent",
32
34
  );
@@ -34,13 +36,35 @@ describe("formatExternalDirectoryAskPrompt", () => {
34
36
  expect(result).toContain("write");
35
37
  expect(result).toContain("/tmp/out.txt");
36
38
  });
39
+
40
+ test("discloses the resolved path when it differs from the typed path", () => {
41
+ const result = formatExternalDirectoryAskPrompt(
42
+ "read",
43
+ "demo-symlink-passwd",
44
+ "/etc/passwd",
45
+ "/projects/my-app",
46
+ );
47
+ expect(result).toBe(
48
+ "Current agent requested tool 'read' for path 'demo-symlink-passwd' (resolves to '/etc/passwd') outside working directory '/projects/my-app'. Allow this external directory access?",
49
+ );
50
+ });
51
+
52
+ test("omits the disclosure when resolvedPath is undefined", () => {
53
+ const result = formatExternalDirectoryAskPrompt(
54
+ "read",
55
+ "/etc/passwd",
56
+ undefined,
57
+ "/projects/my-app",
58
+ );
59
+ expect(result).not.toContain("resolves to");
60
+ });
37
61
  });
38
62
 
39
63
  describe("formatBashExternalDirectoryAskPrompt", () => {
40
64
  test("includes command, paths, cwd, and agent name", () => {
41
65
  const result = formatBashExternalDirectoryAskPrompt(
42
66
  "cat /etc/passwd",
43
- ["/etc/passwd"],
67
+ [{ path: "/etc/passwd" }],
44
68
  "/projects/my-app",
45
69
  "my-agent",
46
70
  );
@@ -53,7 +77,7 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
53
77
  test("uses 'Current agent' when no agent name provided", () => {
54
78
  const result = formatBashExternalDirectoryAskPrompt(
55
79
  "ls /tmp",
56
- ["/tmp"],
80
+ [{ path: "/tmp" }],
57
81
  "/projects/my-app",
58
82
  );
59
83
  expect(result).toContain("Current agent");