@gotgenes/pi-permission-system 13.1.2 → 13.2.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,23 @@ 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
+ ## [13.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.1.2...pi-permission-system-v13.2.0) (2026-06-17)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** add external-directory typed+resolved policy aliases ([#418](https://github.com/gotgenes/pi-packages/issues/418)) ([ae653d1](https://github.com/gotgenes/pi-packages/commit/ae653d11fa52403cc5a78cd0148bc102de923d3c))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **pi-permission-system:** match external_directory patterns against typed and resolved paths ([#418](https://github.com/gotgenes/pi-packages/issues/418)) ([d08e645](https://github.com/gotgenes/pi-packages/commit/d08e64509d980c818708ecd2b7152ba6fc05946d))
19
+
20
+
21
+ ### Documentation
22
+
23
+ * **pi-permission-system:** document external_directory symlink alias matching ([#418](https://github.com/gotgenes/pi-packages/issues/418)) ([8760273](https://github.com/gotgenes/pi-packages/commit/876027313457591ab5f175c689aa3074143db388))
24
+
8
25
  ## [13.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.1.1...pi-permission-system-v13.1.2) (2026-06-16)
9
26
 
10
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "13.1.2",
3
+ "version": "13.2.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,4 +1,5 @@
1
1
  import { getNonEmptyString, toRecord } from "#src/common";
2
+ import { getExternalDirectoryPolicyValues } from "#src/path-utils";
2
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
4
  import { SessionApproval } from "#src/session-approval";
4
5
  import { deriveApprovalPattern } from "#src/session-rules";
@@ -41,10 +42,13 @@ export function describeBashExternalDirectoryGate(
41
42
  check: PermissionCheckResult;
42
43
  }> = [];
43
44
  for (const p of externalPaths) {
44
- const check = resolver.resolve(
45
- "external_directory",
46
- { path: p },
45
+ // Match each path against both its typed and symlink-resolved aliases on
46
+ // the external_directory surface, so a config pattern on either form
47
+ // applies (#418).
48
+ const check = resolver.resolvePathPolicy(
49
+ getExternalDirectoryPolicyValues(p, tcc.cwd),
47
50
  tcc.agentName ?? undefined,
51
+ "external_directory",
48
52
  );
49
53
  if (check.state !== "allow") {
50
54
  uncoveredEntries.push({ path: p, check });
@@ -199,13 +199,19 @@ export class BashProgram {
199
199
  }
200
200
 
201
201
  /**
202
- * Deduplicated paths that resolve outside `cwd`.
202
+ * Deduplicated paths that resolve outside `cwd`, in their lexical (as-typed,
203
+ * normalized but not symlink-resolved) form.
203
204
  *
204
205
  * Each candidate is resolved against the effective working directory in force
205
206
  * where it appears, projected by folding a sequence of current-shell `cd`
206
207
  * commands (joined by `&&`, `||`, `;`, or a newline). A `cd` inside a
207
208
  * pipeline or a backgrounded command runs in a subshell and does not update
208
209
  * the running directory.
210
+ *
211
+ * The outside-`cwd` decision and the dedup identity use the canonical
212
+ * (symlink-resolved) form, but the returned value is the lexical form so
213
+ * `external_directory` config patterns match the path as the user typed it
214
+ * (#418); the gate re-derives the canonical alias for matching.
209
215
  */
210
216
  externalPaths(cwd: string): string[] {
211
217
  const normalizedCwd = canonicalizePath(
@@ -224,36 +230,37 @@ export class BashProgram {
224
230
  // display path). Absolute / `~` candidates are base-independent and
225
231
  // resolve normally below.
226
232
  if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
227
- const normalized = canonicalizePath(
228
- normalizePathForComparison(candidate, cwd),
229
- );
233
+ const lexical = normalizePathForComparison(candidate, cwd);
234
+ const canonical = canonicalizePath(lexical);
230
235
  if (
231
- normalized &&
236
+ canonical &&
232
237
  normalizedCwd !== "" &&
233
- !isSafeSystemPath(normalized) &&
234
- !seen.has(normalized)
238
+ !isSafeSystemPath(canonical) &&
239
+ !seen.has(canonical)
235
240
  ) {
236
- seen.add(normalized);
237
- externalPaths.push(normalized);
241
+ seen.add(canonical);
242
+ externalPaths.push(lexical);
238
243
  }
239
244
  continue;
240
245
  }
241
246
 
242
247
  const resolveBase =
243
248
  base.kind === "known" ? resolve(cwd, base.offset) : cwd;
244
- const normalized = canonicalizePath(
245
- normalizePathForComparison(candidate, resolveBase),
246
- );
247
- if (!normalized) continue;
249
+ const lexical = normalizePathForComparison(candidate, resolveBase);
250
+ if (!lexical) continue;
251
+ // The boundary decision and dedup identity use the canonical
252
+ // (symlink-resolved) form, but the returned value is the lexical form so
253
+ // config patterns match the path as the user typed it (#418).
254
+ const canonical = canonicalizePath(lexical);
248
255
 
249
256
  if (
250
257
  normalizedCwd !== "" &&
251
- !isSafeSystemPath(normalized) &&
252
- !isPathWithinDirectory(normalized, normalizedCwd) &&
253
- !seen.has(normalized)
258
+ !isSafeSystemPath(canonical) &&
259
+ !isPathWithinDirectory(canonical, normalizedCwd) &&
260
+ !seen.has(canonical)
254
261
  ) {
255
- seen.add(normalized);
256
- externalPaths.push(normalized);
262
+ seen.add(canonical);
263
+ externalPaths.push(lexical);
257
264
  }
258
265
  }
259
266
 
@@ -1,9 +1,12 @@
1
1
  import {
2
2
  canonicalNormalizePathForComparison,
3
+ getExternalDirectoryPolicyValues,
3
4
  getToolInputPath,
4
5
  isPathOutsideWorkingDirectory,
5
6
  isPiInfrastructureRead,
7
+ normalizePathForComparison,
6
8
  } from "#src/path-utils";
9
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
7
10
  import { SessionApproval } from "#src/session-approval";
8
11
  import { deriveApprovalPattern } from "#src/session-rules";
9
12
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
@@ -22,6 +25,7 @@ import type { ToolCallContext } from "./types";
22
25
  export function describeExternalDirectoryGate(
23
26
  tcc: ToolCallContext,
24
27
  infraDirs: string[],
28
+ resolver: ScopedPermissionResolver,
25
29
  extractors?: ToolAccessExtractorLookup,
26
30
  ): GateResult {
27
31
  if (!tcc.cwd) return null;
@@ -37,14 +41,17 @@ export function describeExternalDirectoryGate(
37
41
  return null;
38
42
  }
39
43
 
40
- const normalizedExtPath = canonicalNormalizePathForComparison(
44
+ // The boundary decision (above) and the infrastructure-read containment
45
+ // check (below) use the canonical, symlink-resolved path; pattern matching
46
+ // uses the typed and resolved aliases (#418).
47
+ const canonicalExtPath = canonicalNormalizePathForComparison(
41
48
  externalDirectoryPath,
42
49
  tcc.cwd,
43
50
  );
44
51
 
45
52
  // ── Pi infrastructure read bypass ──────────────────────────────────────
46
53
  if (
47
- isPiInfrastructureRead(tcc.toolName, normalizedExtPath, infraDirs, tcc.cwd)
54
+ isPiInfrastructureRead(tcc.toolName, canonicalExtPath, infraDirs, tcc.cwd)
48
55
  ) {
49
56
  return {
50
57
  action: "allow",
@@ -78,11 +85,22 @@ export function describeExternalDirectoryGate(
78
85
  tcc.agentName ?? undefined,
79
86
  );
80
87
 
81
- const pattern = deriveApprovalPattern(normalizedExtPath);
88
+ // Match against both the typed and symlink-resolved aliases on the
89
+ // external_directory surface, so a config pattern on either form applies
90
+ // (#418). The runner consumes this preCheck and skips its own resolve.
91
+ const preCheck = resolver.resolvePathPolicy(
92
+ getExternalDirectoryPolicyValues(externalDirectoryPath, tcc.cwd),
93
+ tcc.agentName ?? undefined,
94
+ "external_directory",
95
+ );
96
+ const pattern = deriveApprovalPattern(
97
+ normalizePathForComparison(externalDirectoryPath, tcc.cwd),
98
+ );
82
99
 
83
100
  return {
84
101
  surface: "external_directory",
85
- input: { path: normalizedExtPath },
102
+ input: {},
103
+ preCheck,
86
104
  denialContext: {
87
105
  kind: "external_directory",
88
106
  toolName: tcc.toolName,
@@ -81,7 +81,12 @@ export class ToolCallGatePipeline {
81
81
  describeSkillReadGate(tcc, () => this.inputs.getActiveSkillEntries()),
82
82
  () => describePathGate(tcc, this.resolver, this.customExtractors),
83
83
  () =>
84
- describeExternalDirectoryGate(tcc, infraDirs, this.customExtractors),
84
+ describeExternalDirectoryGate(
85
+ tcc,
86
+ infraDirs,
87
+ this.resolver,
88
+ this.customExtractors,
89
+ ),
85
90
  () => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
86
91
  () => describeBashPathGate(tcc, bashProgram, this.resolver),
87
92
  () => {
package/src/path-utils.ts CHANGED
@@ -112,6 +112,26 @@ export function getPathPolicyValues(
112
112
  ];
113
113
  }
114
114
 
115
+ /**
116
+ * Equivalent `external_directory` policy-match values for a path: the lexical
117
+ * (as-typed) alias list plus the canonical (symlink-resolved) absolute path.
118
+ *
119
+ * The outside-CWD boundary decision uses the canonical form separately; this
120
+ * helper exists only for pattern matching, so a user's pattern on the typed
121
+ * path (`/tmp/*`) and on the resolved path (`/private/tmp/*`) both match under
122
+ * the last-match-wins alias evaluation. On systems where the path is not a
123
+ * symlink the canonical form equals the lexical absolute alias and the `Set`
124
+ * collapses it, leaving today's behavior unchanged.
125
+ */
126
+ export function getExternalDirectoryPolicyValues(
127
+ pathValue: string,
128
+ cwd: string,
129
+ ): string[] {
130
+ const lexical = getPathPolicyValues(pathValue, { cwd });
131
+ const canonical = canonicalNormalizePathForComparison(pathValue, cwd);
132
+ return canonical ? [...new Set([...lexical, canonical])] : lexical;
133
+ }
134
+
115
135
  function getAbsolutePathPolicyValues(
116
136
  pathValue: string,
117
137
  options: PathPolicyValueOptions,
@@ -65,15 +65,18 @@ export interface ScopedPermissionManager {
65
65
  sessionRules?: Ruleset,
66
66
  ): PermissionCheckResult;
67
67
  /**
68
- * Evaluate the cross-cutting `path` surface against a caller-supplied set of
69
- * equivalent policy values (e.g. bash tokens already resolved against a
70
- * preceding literal `cd`). The values are trusted because they are computed
71
- * internally, never read from a field on raw tool input.
68
+ * Evaluate a path-shaped surface (`path` or `external_directory`) against a
69
+ * caller-supplied set of equivalent policy values (e.g. bash tokens already
70
+ * resolved against a preceding literal `cd`, or a path's typed and
71
+ * symlink-resolved aliases). The values are trusted because they are computed
72
+ * internally, never read from a field on raw tool input. `surface` defaults
73
+ * to `path`.
72
74
  */
73
75
  checkPathPolicy(
74
76
  values: readonly string[],
75
77
  agentName?: string,
76
78
  sessionRules?: Ruleset,
79
+ surface?: string,
77
80
  ): PermissionCheckResult;
78
81
  getToolPermission(toolName: string, agentName?: string): PermissionState;
79
82
  getConfigIssues(agentName?: string): string[];
@@ -277,6 +280,7 @@ export class PermissionManager implements ScopedPermissionManager {
277
280
  values: readonly string[],
278
281
  agentName?: string,
279
282
  sessionRules?: Ruleset,
283
+ surface = "path",
280
284
  ): PermissionCheckResult {
281
285
  const { composedRules } = this.resolvePermissions(agentName);
282
286
  const fullRules: Ruleset = sessionRules?.length
@@ -285,11 +289,11 @@ export class PermissionManager implements ScopedPermissionManager {
285
289
 
286
290
  const lookupValues = values.length > 0 ? [...values] : ["*"];
287
291
  return buildCheckResult(
288
- "path",
292
+ surface,
289
293
  lookupValues,
290
294
  {},
291
- "path",
292
- "path",
295
+ surface,
296
+ surface,
293
297
  fullRules,
294
298
  );
295
299
  }
@@ -18,13 +18,16 @@ export interface ScopedPermissionResolver {
18
18
  agentName?: string,
19
19
  ): PermissionCheckResult;
20
20
  /**
21
- * Resolve the cross-cutting `path` surface against a caller-supplied set of
22
- * equivalent policy values, applying the current session rules. Used by the
23
- * bash path gate, which computes cd-aware policy values per token.
21
+ * Resolve a path-shaped surface against a caller-supplied set of equivalent
22
+ * policy values, applying the current session rules. Used by the bash path
23
+ * gate (`path`) and the external-directory gates (`external_directory`),
24
+ * which compute equivalent path aliases per token. `surface` defaults to
25
+ * `path`.
24
26
  */
25
27
  resolvePathPolicy(
26
28
  values: readonly string[],
27
29
  agentName?: string,
30
+ surface?: string,
28
31
  ): PermissionCheckResult;
29
32
  }
30
33
 
@@ -63,17 +66,22 @@ export class PermissionResolver implements ScopedPermissionResolver {
63
66
  }
64
67
 
65
68
  /**
66
- * Resolve the `path` surface for precomputed policy values, composing the
67
- * current session ruleset so callers never thread it by hand.
69
+ * Resolve a path-shaped surface (`path` or `external_directory`) for
70
+ * precomputed policy values, composing the current session ruleset so callers
71
+ * never thread it by hand. `surface` defaults to `path`; the external-directory
72
+ * gates pass `external_directory` so a path's typed and symlink-resolved
73
+ * aliases match against the `external_directory` rules.
68
74
  */
69
75
  resolvePathPolicy(
70
76
  values: readonly string[],
71
77
  agentName?: string,
78
+ surface = "path",
72
79
  ): PermissionCheckResult {
73
80
  return this.permissionManager.checkPathPolicy(
74
81
  values,
75
82
  agentName,
76
83
  this.sessionRules.getRuleset(),
84
+ surface,
77
85
  );
78
86
  }
79
87
 
@@ -98,6 +98,19 @@ function makeDeduplicatingHandler(prompter?: GatePrompter): {
98
98
  },
99
99
  );
100
100
 
101
+ // The external-directory gates resolve through checkPathPolicy (#418); route
102
+ // it through the same configured checkPermission so session-approval dedup
103
+ // applies to the typed path alias.
104
+ vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
105
+ (values, agentName, rules, surface = "path") =>
106
+ permissionManager.checkPermission(
107
+ surface,
108
+ { path: values[0] ?? "*" },
109
+ agentName,
110
+ rules,
111
+ ),
112
+ );
113
+
101
114
  const events = makeEvents();
102
115
  const reporter = new GateDecisionReporter(logger, events);
103
116
  const resolvedPrompter: GatePrompter = prompter ?? {
@@ -351,6 +364,18 @@ describe("session shutdown clears external-directory approvals", () => {
351
364
  },
352
365
  );
353
366
 
367
+ // The external-directory tool gate resolves through checkPathPolicy (#418);
368
+ // route it through the same configured checkPermission.
369
+ vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
370
+ (values, agentName, rules, surface = "path") =>
371
+ permissionManager.checkPermission(
372
+ surface,
373
+ { path: values[0] ?? "*" },
374
+ agentName,
375
+ rules,
376
+ ),
377
+ );
378
+
354
379
  const events = makeEvents();
355
380
  const reporter = new GateDecisionReporter(logger, events);
356
381
  const prompter: GatePrompter = {
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Acceptance test for issue #418.
3
+ *
4
+ * Reproduces the reported bug with a real symlink (no `realpathSync` mock):
5
+ * an `external_directory` allow configured for the path as the user types it
6
+ * (`<link>/*`) must allow access even though the OS resolves `<link>` to a
7
+ * different canonical directory. Exercised end-to-end through the real
8
+ * `PermissionManager` + `PermissionResolver` for both a path-bearing tool and
9
+ * a bash command, and for an allow keyed on the symlink-resolved form too.
10
+ */
11
+
12
+ import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
16
+
17
+ import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
18
+ import { BashProgram } from "#src/handlers/gates/bash-program";
19
+ import {
20
+ type GateDescriptor,
21
+ isGateBypass,
22
+ isGateDescriptor,
23
+ } from "#src/handlers/gates/descriptor";
24
+ import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
25
+ import type { ToolCallContext } from "#src/handlers/gates/types";
26
+ import { PermissionResolver } from "#src/permission-resolver";
27
+ import { SessionRules } from "#src/session-rules";
28
+ import type { ScopeConfig } from "#src/types";
29
+
30
+ import { createManager } from "#test/helpers/manager-harness";
31
+
32
+ // ── real symlink fixture ─────────────────────────────────────────────────────
33
+
34
+ let realDir: string;
35
+ let linkDir: string;
36
+ let cwd: string;
37
+ const tempRoots: string[] = [];
38
+
39
+ function mkTemp(prefix: string): string {
40
+ const dir = mkdtempSync(join(tmpdir(), prefix));
41
+ tempRoots.push(dir);
42
+ return dir;
43
+ }
44
+
45
+ beforeEach(() => {
46
+ realDir = mkTemp("ext-real-");
47
+ const linkParent = mkTemp("ext-link-");
48
+ linkDir = join(linkParent, "link");
49
+ symlinkSync(realDir, linkDir);
50
+ cwd = mkTemp("ext-cwd-");
51
+ });
52
+
53
+ afterEach(() => {
54
+ while (tempRoots.length > 0) {
55
+ const dir = tempRoots.pop();
56
+ if (dir) rmSync(dir, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ function makeResolver(config: ScopeConfig) {
61
+ const { manager, cleanup } = createManager(config);
62
+ manager.configureForCwd(cwd);
63
+ const resolver = new PermissionResolver(manager, new SessionRules());
64
+ return { resolver, cleanup };
65
+ }
66
+
67
+ function readTcc(): ToolCallContext {
68
+ return {
69
+ toolName: "read",
70
+ agentName: null,
71
+ input: { path: join(linkDir, "file.ts") },
72
+ toolCallId: "tc-1",
73
+ cwd,
74
+ };
75
+ }
76
+
77
+ // ── tests ────────────────────────────────────────────────────────────────────
78
+
79
+ describe("external_directory symlink acceptance (#418)", () => {
80
+ it("allows a path-bearing tool when the allow is keyed on the typed (symlinked) path", () => {
81
+ const { resolver, cleanup } = makeResolver({
82
+ permission: {
83
+ external_directory: { "*": "ask", [`${linkDir}/*`]: "allow" },
84
+ },
85
+ });
86
+ try {
87
+ const result = describeExternalDirectoryGate(readTcc(), [], resolver);
88
+ expect(isGateDescriptor(result)).toBe(true);
89
+ expect((result as GateDescriptor).preCheck?.state).toBe("allow");
90
+ } finally {
91
+ cleanup();
92
+ }
93
+ });
94
+
95
+ it("allows a path-bearing tool when the allow is keyed on the resolved path", () => {
96
+ // Key the allow on the fully symlink-resolved directory (on macOS the
97
+ // tmpdir root itself is a symlink, e.g. /var -> /private/var).
98
+ const resolved = realpathSync(realDir);
99
+ const { resolver, cleanup } = makeResolver({
100
+ permission: {
101
+ external_directory: { "*": "ask", [`${resolved}/*`]: "allow" },
102
+ },
103
+ });
104
+ try {
105
+ const result = describeExternalDirectoryGate(readTcc(), [], resolver);
106
+ expect(isGateDescriptor(result)).toBe(true);
107
+ expect((result as GateDescriptor).preCheck?.state).toBe("allow");
108
+ } finally {
109
+ cleanup();
110
+ }
111
+ });
112
+
113
+ it("still prompts (ask) when no external_directory allow matches", () => {
114
+ const { resolver, cleanup } = makeResolver({
115
+ permission: { external_directory: { "*": "ask" } },
116
+ });
117
+ try {
118
+ const result = describeExternalDirectoryGate(readTcc(), [], resolver);
119
+ expect(isGateDescriptor(result)).toBe(true);
120
+ expect((result as GateDescriptor).preCheck?.state).toBe("ask");
121
+ } finally {
122
+ cleanup();
123
+ }
124
+ });
125
+
126
+ it("allows a bash command referencing the typed (symlinked) path", async () => {
127
+ const { resolver, cleanup } = makeResolver({
128
+ permission: {
129
+ external_directory: { "*": "ask", [`${linkDir}/*`]: "allow" },
130
+ },
131
+ });
132
+ try {
133
+ const command = `cat ${join(linkDir, "file.ts")}`;
134
+ const tcc: ToolCallContext = {
135
+ toolName: "bash",
136
+ agentName: null,
137
+ input: { command },
138
+ toolCallId: "tc-2",
139
+ cwd,
140
+ };
141
+ const program = await BashProgram.parse(command);
142
+ const result = describeBashExternalDirectoryGate(tcc, program, resolver);
143
+ // All external paths are covered by the allow → bypass, no prompt.
144
+ expect(isGateBypass(result)).toBe(true);
145
+ } finally {
146
+ cleanup();
147
+ }
148
+ });
149
+ });
@@ -84,6 +84,20 @@ describe("describeBashExternalDirectoryGate", () => {
84
84
  expect(result).toBeNull();
85
85
  });
86
86
 
87
+ it("resolves each external path on the external_directory surface via resolvePathPolicy (#418)", async () => {
88
+ const resolver = makeResolver(makeCheckResult("ask"));
89
+ await describeGate(
90
+ makeTcc({ input: { command: "cat /outside/a.ts" } }),
91
+ resolver,
92
+ );
93
+ expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
94
+ ["/outside/a.ts"],
95
+ undefined,
96
+ "external_directory",
97
+ );
98
+ expect(resolver.resolve).not.toHaveBeenCalled();
99
+ });
100
+
87
101
  it("returns GateBypass when all external paths are session-covered", async () => {
88
102
  const resolver = makeResolver(
89
103
  makeCheckResult("allow", { source: "session" }),
@@ -116,11 +130,12 @@ describe("describeBashExternalDirectoryGate", () => {
116
130
  // not just session-level allow. This was the bug: source !== "session"
117
131
  // kept config-allowed paths in the uncovered set.
118
132
  const resolver = makeResolver();
119
- resolver.resolve.mockImplementation((_surface: string, input: unknown) => {
120
- if ((input as Record<string, unknown>).path)
121
- return makeCheckResult("allow", { source: "special" });
122
- return makeCheckResult("ask");
123
- });
133
+ resolver.resolvePathPolicy.mockImplementation(
134
+ (values: readonly string[]) =>
135
+ values.length > 0
136
+ ? makeCheckResult("allow", { source: "special" })
137
+ : makeCheckResult("ask"),
138
+ );
124
139
  const result = await describeGate(makeTcc(), resolver);
125
140
  expect(result).not.toBeNull();
126
141
  expect(isGateBypass(result)).toBe(true);
@@ -131,12 +146,12 @@ describe("describeBashExternalDirectoryGate", () => {
131
146
  // silently downgrading a config-level deny to ask. After the fix, the
132
147
  // descriptor's preCheck is derived from the actual path check result.
133
148
  const resolver = makeResolver();
134
- resolver.resolve.mockImplementation((_surface: string, input: unknown) => {
135
- if ((input as Record<string, unknown>).path)
136
- return makeCheckResult("deny", { source: "special" });
137
- // Path-less catch-all returns ask — should NOT be used as preCheck.
138
- return makeCheckResult("ask");
139
- });
149
+ resolver.resolvePathPolicy.mockImplementation(
150
+ (values: readonly string[]) =>
151
+ values.length > 0
152
+ ? makeCheckResult("deny", { source: "special" })
153
+ : makeCheckResult("ask"),
154
+ );
140
155
  const result = await describeGate(makeTcc(), resolver);
141
156
  expect(isGateDescriptor(result)).toBe(true);
142
157
  const desc = result as GateDescriptor;
@@ -192,11 +207,12 @@ describe("describeBashExternalDirectoryGate", () => {
192
207
  it("config-allowed path is excluded; remaining ask path produces a descriptor", async () => {
193
208
  // One path config-allowed, one config-ask → descriptor with only the ask path.
194
209
  const resolver = makeResolver();
195
- resolver.resolve.mockImplementation((_surface: string, input: unknown) => {
196
- if ((input as Record<string, unknown>).path === "/outside/a.ts")
197
- return makeCheckResult("allow", { source: "special" });
198
- return makeCheckResult("ask");
199
- });
210
+ resolver.resolvePathPolicy.mockImplementation(
211
+ (values: readonly string[]) =>
212
+ values.includes("/outside/a.ts")
213
+ ? makeCheckResult("allow", { source: "special" })
214
+ : makeCheckResult("ask"),
215
+ );
200
216
  const result = await describeGate(
201
217
  makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
202
218
  resolver,
@@ -212,11 +228,12 @@ describe("describeBashExternalDirectoryGate", () => {
212
228
  it("config-denied path makes worstCheck deny even when another path is ask", async () => {
213
229
  // One path config-denied, one config-ask → descriptor with preCheck.state === "deny".
214
230
  const resolver = makeResolver();
215
- resolver.resolve.mockImplementation((_surface: string, input: unknown) => {
216
- if ((input as Record<string, unknown>).path === "/outside/a.ts")
217
- return makeCheckResult("deny", { source: "special" });
218
- return makeCheckResult("ask");
219
- });
231
+ resolver.resolvePathPolicy.mockImplementation(
232
+ (values: readonly string[]) =>
233
+ values.includes("/outside/a.ts")
234
+ ? makeCheckResult("deny", { source: "special" })
235
+ : makeCheckResult("ask"),
236
+ );
220
237
  const result = await describeGate(
221
238
  makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
222
239
  resolver,
@@ -232,12 +249,12 @@ describe("describeBashExternalDirectoryGate", () => {
232
249
 
233
250
  it("only includes uncovered paths when some are session-covered", async () => {
234
251
  const resolver = makeResolver();
235
- resolver.resolve.mockImplementation((_surface: string, input: unknown) => {
236
- if ((input as Record<string, unknown>).path === "/outside/a.ts") {
237
- return makeCheckResult("allow", { source: "session" });
238
- }
239
- return makeCheckResult("ask");
240
- });
252
+ resolver.resolvePathPolicy.mockImplementation(
253
+ (values: readonly string[]) =>
254
+ values.includes("/outside/a.ts")
255
+ ? makeCheckResult("allow", { source: "session" })
256
+ : makeCheckResult("ask"),
257
+ );
241
258
  const result = await describeGate(
242
259
  makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
243
260
  resolver,
@@ -188,11 +188,12 @@ describe("BashProgram", () => {
188
188
  });
189
189
  });
190
190
 
191
- it("flags an absolute in-cwd path that resolves externally via a symlink", async () => {
191
+ it("flags an absolute in-cwd path that resolves externally via a symlink, returning the typed form", async () => {
192
192
  // The strict classifier only processes absolute tokens, so the escape
193
193
  // surface is `cat /cwd/link/hosts` (absolute) where `link -> /etc`.
194
- // Without canonicalization: /projects/my-app/link/hosts looks internal.
195
- // With canonicalization: realpathSync resolves it to /etc/hosts.
194
+ // The boundary decision still uses the canonical form (so the path is
195
+ // flagged), but the returned value is the typed/lexical form so config
196
+ // patterns match the path as the user wrote it (#418).
196
197
  realpathSync.mockImplementation((p: string) => {
197
198
  if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
198
199
  return p;
@@ -200,7 +201,9 @@ describe("BashProgram", () => {
200
201
  const program = await BashProgram.parse(
201
202
  "cat /projects/my-app/link/hosts",
202
203
  );
203
- expect(program.externalPaths(cwd)).toContain("/etc/hosts");
204
+ const external = program.externalPaths(cwd);
205
+ expect(external).toContain("/projects/my-app/link/hosts");
206
+ expect(external).not.toContain("/etc/hosts");
204
207
  });
205
208
 
206
209
  it("does not flag a token that resolves within a symlinked cwd", async () => {
@@ -7,6 +7,10 @@ import type {
7
7
  import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
8
8
  import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
9
9
  import type { ToolCallContext } from "#src/handlers/gates/types";
10
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
11
+ import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
12
+ import { makeResolver } from "#test/helpers/gate-fixtures";
13
+ import { makeCheckResult } from "#test/helpers/handler-fixtures";
10
14
 
11
15
  // ── helpers ───────────────────────────��────────────────────────────��───────
12
16
 
@@ -21,18 +25,31 @@ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
21
25
  };
22
26
  }
23
27
 
28
+ // Default resolver for descriptor-shape tests that do not assert the resolved
29
+ // state: returns `ask` for the external_directory surface so a descriptor is
30
+ // produced. Tests that assert the typed+resolved matching pass an explicit
31
+ // resolver to `describeExternalDirectoryGate` directly.
32
+ function gateUnderTest(
33
+ tcc: ToolCallContext,
34
+ infraDirs: string[],
35
+ extractors?: ToolAccessExtractorLookup,
36
+ resolver: ScopedPermissionResolver = makeResolver(
37
+ makeCheckResult({ state: "ask", toolName: "external_directory" }),
38
+ ),
39
+ ) {
40
+ return describeExternalDirectoryGate(tcc, infraDirs, resolver, extractors);
41
+ }
42
+
24
43
  // ── tests ────────────────────��────────────────────────────────────��────────
25
44
 
26
45
  describe("describeExternalDirectoryGate", () => {
27
46
  it("returns null when no CWD", () => {
28
- const result = describeExternalDirectoryGate(makeTcc({ cwd: undefined }), [
29
- "/test/agent",
30
- ]);
47
+ const result = gateUnderTest(makeTcc({ cwd: undefined }), ["/test/agent"]);
31
48
  expect(result).toBeNull();
32
49
  });
33
50
 
34
51
  it("returns null when tool is not path-bearing", () => {
35
- const result = describeExternalDirectoryGate(
52
+ const result = gateUnderTest(
36
53
  makeTcc({ toolName: "bash", input: { command: "ls" } }),
37
54
  ["/test/agent"],
38
55
  );
@@ -40,7 +57,7 @@ describe("describeExternalDirectoryGate", () => {
40
57
  });
41
58
 
42
59
  it("returns null when path is inside CWD", () => {
43
- const result = describeExternalDirectoryGate(
60
+ const result = gateUnderTest(
44
61
  makeTcc({ input: { path: "/test/project/src/index.ts" } }),
45
62
  ["/test/agent"],
46
63
  );
@@ -50,7 +67,7 @@ describe("describeExternalDirectoryGate", () => {
50
67
  // ── Pi infrastructure read bypass ─────────────────���────────────────────
51
68
 
52
69
  it("returns GateBypass for read targeting an infra dir", () => {
53
- const result = describeExternalDirectoryGate(
70
+ const result = gateUnderTest(
54
71
  makeTcc({
55
72
  toolName: "read",
56
73
  input: { path: "/test/agent/git/some-package/SKILL.md" },
@@ -71,7 +88,7 @@ describe("describeExternalDirectoryGate", () => {
71
88
  });
72
89
 
73
90
  it("returns GateBypass respecting custom infraDirs", () => {
74
- const result = describeExternalDirectoryGate(
91
+ const result = gateUnderTest(
75
92
  makeTcc({
76
93
  toolName: "read",
77
94
  input: { path: "/custom/infra/SKILL.md" },
@@ -82,7 +99,7 @@ describe("describeExternalDirectoryGate", () => {
82
99
  });
83
100
 
84
101
  it("does NOT bypass for write tools targeting infra dirs", () => {
85
- const result = describeExternalDirectoryGate(
102
+ const result = gateUnderTest(
86
103
  makeTcc({
87
104
  toolName: "write",
88
105
  input: { path: "/test/agent/git/some-file.ts", content: "x" },
@@ -97,14 +114,14 @@ describe("describeExternalDirectoryGate", () => {
97
114
  // ── GateDescriptor for external paths ─────────────────────────────────��
98
115
 
99
116
  it("returns GateDescriptor with surface 'external_directory'", () => {
100
- const result = describeExternalDirectoryGate(makeTcc(), ["/test/agent"]);
117
+ const result = gateUnderTest(makeTcc(), ["/test/agent"]);
101
118
  expect(isGateDescriptor(result)).toBe(true);
102
119
  const desc = result as GateDescriptor;
103
120
  expect(desc.surface).toBe("external_directory");
104
121
  });
105
122
 
106
123
  it("decision value is the external path", () => {
107
- const result = describeExternalDirectoryGate(
124
+ const result = gateUnderTest(
108
125
  makeTcc({ input: { path: "/outside/project/file.ts" } }),
109
126
  ["/test/agent"],
110
127
  ) as GateDescriptor;
@@ -112,16 +129,36 @@ describe("describeExternalDirectoryGate", () => {
112
129
  expect(result.decision.surface).toBe("external_directory");
113
130
  });
114
131
 
115
- it("input contains normalized path for checkPermission", () => {
116
- const result = describeExternalDirectoryGate(
132
+ it("carries a precomputed preCheck and an empty input (matching is done by the gate)", () => {
133
+ const result = gateUnderTest(
117
134
  makeTcc({ input: { path: "/outside/project/file.ts" } }),
118
135
  ["/test/agent"],
119
136
  ) as GateDescriptor;
120
- expect(result.input).toHaveProperty("path");
137
+ expect(result.input).toEqual({});
138
+ expect(result.preCheck).toBeDefined();
139
+ expect(result.preCheck?.state).toBe("ask");
140
+ });
141
+
142
+ it("resolves the typed and symlink-resolved aliases on the external_directory surface (#418)", () => {
143
+ const resolver = makeResolver(
144
+ makeCheckResult({ state: "ask", toolName: "external_directory" }),
145
+ );
146
+ gateUnderTest(
147
+ makeTcc({ input: { path: "/outside/project/file.ts" } }),
148
+ ["/test/agent"],
149
+ undefined,
150
+ resolver,
151
+ );
152
+ expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
153
+ ["/outside/project/file.ts"],
154
+ undefined,
155
+ "external_directory",
156
+ );
157
+ expect(resolver.resolve).not.toHaveBeenCalled();
121
158
  });
122
159
 
123
160
  it("sessionApproval uses deriveApprovalPattern", () => {
124
- const result = describeExternalDirectoryGate(
161
+ const result = gateUnderTest(
125
162
  makeTcc({ input: { path: "/outside/project/file.ts" } }),
126
163
  ["/test/agent"],
127
164
  ) as GateDescriptor;
@@ -131,7 +168,7 @@ describe("describeExternalDirectoryGate", () => {
131
168
  });
132
169
 
133
170
  it("denialContext contains the external path and cwd", () => {
134
- const result = describeExternalDirectoryGate(
171
+ const result = gateUnderTest(
135
172
  makeTcc({ input: { path: "/outside/project/file.ts" } }),
136
173
  ["/test/agent"],
137
174
  ) as GateDescriptor;
@@ -144,7 +181,7 @@ describe("describeExternalDirectoryGate", () => {
144
181
  });
145
182
 
146
183
  it("promptDetails includes path and tool_call source", () => {
147
- const result = describeExternalDirectoryGate(
184
+ const result = gateUnderTest(
148
185
  makeTcc({ toolName: "read", agentName: "agent-1", toolCallId: "tc-5" }),
149
186
  ["/test/agent"],
150
187
  ) as GateDescriptor;
@@ -158,9 +195,7 @@ describe("describeExternalDirectoryGate", () => {
158
195
  });
159
196
 
160
197
  it("logContext includes path and message", () => {
161
- const result = describeExternalDirectoryGate(makeTcc(), [
162
- "/test/agent",
163
- ]) as GateDescriptor;
198
+ const result = gateUnderTest(makeTcc(), ["/test/agent"]) as GateDescriptor;
164
199
  expect(result.logContext).toMatchObject({
165
200
  source: "tool_call",
166
201
  path: "/outside/project/file.ts",
@@ -173,7 +208,7 @@ describe("describeExternalDirectoryGate", () => {
173
208
 
174
209
  describe("describeExternalDirectoryGate — extension and MCP tools (#352)", () => {
175
210
  it("gates an extension tool with an external input.path", () => {
176
- const result = describeExternalDirectoryGate(
211
+ const result = gateUnderTest(
177
212
  makeTcc({
178
213
  toolName: "my-ext",
179
214
  input: { path: "/outside/project/file.ts" },
@@ -185,7 +220,7 @@ describe("describeExternalDirectoryGate — extension and MCP tools (#352)", ()
185
220
  });
186
221
 
187
222
  it("gates an MCP tool with an external arguments.path", () => {
188
- const result = describeExternalDirectoryGate(
223
+ const result = gateUnderTest(
189
224
  makeTcc({
190
225
  toolName: "mcp",
191
226
  input: { arguments: { path: "/outside/project/file.ts" } },
@@ -203,7 +238,7 @@ describe("describeExternalDirectoryGate — extension and MCP tools (#352)", ()
203
238
  typeof input.target === "string" ? input.target : undefined
204
239
  : undefined,
205
240
  };
206
- const result = describeExternalDirectoryGate(
241
+ const result = gateUnderTest(
207
242
  makeTcc({ toolName: "ffgrep", input: { target: "/outside/project/x" } }),
208
243
  ["/test/agent"],
209
244
  extractors,
@@ -212,7 +247,7 @@ describe("describeExternalDirectoryGate — extension and MCP tools (#352)", ()
212
247
  });
213
248
 
214
249
  it("returns null for an extension tool whose path is inside cwd", () => {
215
- const result = describeExternalDirectoryGate(
250
+ const result = gateUnderTest(
216
251
  makeTcc({
217
252
  toolName: "my-ext",
218
253
  input: { path: "/test/project/src/x.ts" },
@@ -241,12 +241,14 @@ export function makeHandler(overrides?: {
241
241
  vi.mocked(permissionManager.checkPermission).mockImplementation(
242
242
  surfaceCheck,
243
243
  );
244
- // The bash path gate resolves through checkPathPolicy; route it through
245
- // the same surface dispatcher so `path` overrides apply to bash tokens.
244
+ // The bash path and external-directory gates resolve through
245
+ // checkPathPolicy; route it through the same surface dispatcher (threading
246
+ // the real surface) so `path` / `external_directory` overrides apply to
247
+ // bash tokens and tool paths alike (#418).
246
248
  vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
247
- (values, agentName, sessionRules) =>
249
+ (values, agentName, sessionRules, surface = "path") =>
248
250
  surfaceCheck(
249
- "path",
251
+ surface,
250
252
  { path: values[0] ?? "*" },
251
253
  agentName,
252
254
  sessionRules,
@@ -22,6 +22,7 @@ vi.mock("node:fs", () => ({
22
22
 
23
23
  import {
24
24
  canonicalNormalizePathForComparison,
25
+ getExternalDirectoryPolicyValues,
25
26
  getPathBearingToolPath,
26
27
  getPathPolicyValues,
27
28
  getToolInputPath,
@@ -614,3 +615,36 @@ describe("getPathPolicyValues", () => {
614
615
  expect(getPathPolicyValues(" ", { cwd })).toEqual([]);
615
616
  });
616
617
  });
618
+
619
+ describe("getExternalDirectoryPolicyValues", () => {
620
+ const cwd = "/projects/my-app";
621
+
622
+ beforeEach(() => {
623
+ realpathSync.mockReset();
624
+ realpathSync.mockImplementation((p: string) => p);
625
+ });
626
+
627
+ test("adds the symlink-resolved alias alongside the typed path", () => {
628
+ // /tmp -> /private/tmp (the macOS symlink from the bug report).
629
+ realpathSync.mockImplementation((p: string) =>
630
+ p.startsWith("/tmp") ? `/private${p}` : p,
631
+ );
632
+ expect(getExternalDirectoryPolicyValues("/tmp/x", cwd)).toEqual([
633
+ "/tmp/x",
634
+ "/private/tmp/x",
635
+ ]);
636
+ });
637
+
638
+ test("dedups when the canonical form equals the lexical form", () => {
639
+ expect(getExternalDirectoryPolicyValues("/etc/hosts", cwd)).toEqual([
640
+ "/etc/hosts",
641
+ ]);
642
+ });
643
+
644
+ test("keeps the relative aliases for an in-cwd token without duplicating", () => {
645
+ expect(getExternalDirectoryPolicyValues("src/foo.ts", cwd)).toEqual([
646
+ "/projects/my-app/src/foo.ts",
647
+ "src/foo.ts",
648
+ ]);
649
+ });
650
+ });
@@ -3279,4 +3279,40 @@ describe("checkPathPolicy", () => {
3279
3279
  cleanup();
3280
3280
  }
3281
3281
  });
3282
+
3283
+ it("evaluates against the external_directory surface when one is provided", () => {
3284
+ const { manager, cleanup } = makeManagerWithConfig({
3285
+ external_directory: { "*": "ask", "/tmp/*": "allow" },
3286
+ });
3287
+ try {
3288
+ const result = manager.checkPathPolicy(
3289
+ ["/tmp/x"],
3290
+ undefined,
3291
+ undefined,
3292
+ "external_directory",
3293
+ );
3294
+ expect(result.state).toBe("allow");
3295
+ expect(result.matchedPattern).toBe("/tmp/*");
3296
+ expect(result.source).toBe("special");
3297
+ expect(result.toolName).toBe("external_directory");
3298
+ } finally {
3299
+ cleanup();
3300
+ }
3301
+ });
3302
+
3303
+ it("defaults to the path surface when no surface is provided", () => {
3304
+ const { manager, cleanup } = makeManagerWithConfig({
3305
+ external_directory: { "*": "ask", "/tmp/*": "allow" },
3306
+ path: { "*": "allow" },
3307
+ });
3308
+ try {
3309
+ // No path rule denies; the external_directory allow must NOT apply here.
3310
+ const result = manager.checkPathPolicy(["/tmp/x"]);
3311
+ expect(result.toolName).toBe("path");
3312
+ expect(result.state).toBe("allow");
3313
+ expect(result.matchedPattern).toBe("*");
3314
+ } finally {
3315
+ cleanup();
3316
+ }
3317
+ });
3282
3318
  });
@@ -143,6 +143,20 @@ describe("PermissionResolver", () => {
143
143
  ["/proj/src/a.ts", "src/a.ts"],
144
144
  "agent-x",
145
145
  [],
146
+ "path",
147
+ );
148
+ });
149
+
150
+ it("forwards an explicit surface to checkPathPolicy", () => {
151
+ const { resolver, permissionManager } = makeResolver();
152
+
153
+ resolver.resolvePathPolicy(["/tmp/x"], "agent-x", "external_directory");
154
+
155
+ expect(permissionManager.checkPathPolicy).toHaveBeenCalledWith(
156
+ ["/tmp/x"],
157
+ "agent-x",
158
+ [],
159
+ "external_directory",
146
160
  );
147
161
  });
148
162