@gotgenes/pi-permission-system 16.0.2 → 16.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/package.json +1 -1
  3. package/src/access-intent/access-intent.ts +57 -0
  4. package/src/access-intent/access-path.ts +88 -0
  5. package/src/access-intent/bash/cwd-projection.ts +9 -4
  6. package/src/access-intent/bash/program.ts +8 -8
  7. package/src/handlers/gates/bash-command.ts +18 -3
  8. package/src/handlers/gates/bash-external-directory.ts +16 -30
  9. package/src/handlers/gates/bash-path-extractor.ts +9 -3
  10. package/src/handlers/gates/bash-path.ts +6 -4
  11. package/src/handlers/gates/external-directory-policy.ts +66 -0
  12. package/src/handlers/gates/external-directory.ts +8 -9
  13. package/src/handlers/gates/path.ts +6 -5
  14. package/src/handlers/gates/runner.ts +6 -5
  15. package/src/handlers/gates/tool-call-gate-pipeline.ts +6 -5
  16. package/src/path-utils.ts +0 -20
  17. package/src/permission-event-rpc.ts +4 -6
  18. package/src/permission-manager.ts +39 -55
  19. package/src/permission-resolver.ts +45 -57
  20. package/src/permissions-service.ts +2 -4
  21. package/src/skill-prompt-sanitizer.ts +2 -2
  22. package/test/access-intent/access-path.test.ts +107 -0
  23. package/test/access-intent/bash/program.test.ts +30 -12
  24. package/test/handlers/before-agent-start.test.ts +6 -9
  25. package/test/handlers/external-directory-session-dedup.test.ts +16 -40
  26. package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -3
  27. package/test/handlers/gates/bash-command.test.ts +26 -23
  28. package/test/handlers/gates/bash-external-directory.test.ts +36 -32
  29. package/test/handlers/gates/bash-path.test.ts +12 -8
  30. package/test/handlers/gates/external-directory-policy.test.ts +124 -0
  31. package/test/handlers/gates/external-directory.test.ts +11 -5
  32. package/test/handlers/gates/path.test.ts +30 -25
  33. package/test/handlers/gates/runner.test.ts +6 -1
  34. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +4 -3
  35. package/test/handlers/input.test.ts +1 -1
  36. package/test/helpers/gate-fixtures.ts +12 -24
  37. package/test/helpers/handler-fixtures.ts +21 -15
  38. package/test/helpers/session-fixtures.ts +3 -18
  39. package/test/path-utils.test.ts +0 -34
  40. package/test/permission-event-rpc.test.ts +24 -20
  41. package/test/permission-manager-unified.test.ts +445 -209
  42. package/test/permission-resolver.test.ts +112 -89
  43. package/test/permissions-service.test.ts +10 -7
  44. package/test/skill-prompt-sanitizer.test.ts +30 -7
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ 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
+ ## [16.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.1.0...pi-permission-system-v16.2.0) (2026-06-26)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** add ScopedPermissionManager.check(intent) ([#478](https://github.com/gotgenes/pi-packages/issues/478)) ([7cb600a](https://github.com/gotgenes/pi-packages/commit/7cb600a11efd4bc60bd31c70d85550c7e1b28058))
14
+ * **pi-permission-system:** narrow ScopedPermissionResolver to resolve(intent) ([#478](https://github.com/gotgenes/pi-packages/issues/478)) ([908176f](https://github.com/gotgenes/pi-packages/commit/908176f8341e05d9181d5cf22ede10a6d5a470d0))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * **pi-permission-system:** record resolve(intent) narrowing ([#478](https://github.com/gotgenes/pi-packages/issues/478)) ([fb8f986](https://github.com/gotgenes/pi-packages/commit/fb8f986e15e5db20d84030d0f77b2aa4aad39aab))
20
+
21
+ ## [16.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.0.2...pi-permission-system-v16.1.0) (2026-06-26)
22
+
23
+
24
+ ### Features
25
+
26
+ * **pi-permission-system:** introduce AccessPath value object ([c00d5c5](https://github.com/gotgenes/pi-packages/commit/c00d5c580a828234fafd7946be4e81313665d25d)), closes [#476](https://github.com/gotgenes/pi-packages/issues/476)
27
+
8
28
  ## [16.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.0.1...pi-permission-system-v16.0.2) (2026-06-25)
9
29
 
10
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "16.0.2",
3
+ "version": "16.2.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,57 @@
1
+ import type { AccessPath } from "#src/access-intent/access-path";
2
+
3
+ /**
4
+ * Raw tool input the manager must normalize (path / bash / MCP / extension tools).
5
+ *
6
+ * The `surface` is the tool name fed to `normalizeInput` (e.g. `"read"`, `"bash"`,
7
+ * an MCP server name).
8
+ */
9
+ export interface ToolAccessIntent {
10
+ kind: "tool";
11
+ /** Tool name fed to input normalization. */
12
+ surface: string;
13
+ input: unknown;
14
+ agentName?: string;
15
+ }
16
+
17
+ /**
18
+ * Precomputed equivalent policy values for a path-shaped surface.
19
+ *
20
+ * Used by the bash-path gate (`path` surface), whose cd-resolved lexical
21
+ * `string[]` carry no canonical-boundary notion.
22
+ */
23
+ export interface PathValuesAccessIntent {
24
+ kind: "path-values";
25
+ /** `"path"` or `"external_directory"`. */
26
+ surface: string;
27
+ values: readonly string[];
28
+ agentName?: string;
29
+ }
30
+
31
+ /**
32
+ * An `AccessPath` value object for a path-shaped surface.
33
+ *
34
+ * Used by the external-directory gates; lets `AccessPath` flow into the
35
+ * resolver as a first-class variant so the resolver — not the gate — asks it
36
+ * for `matchValues()` (Tell-Don't-Ask).
37
+ */
38
+ export interface AccessPathAccessIntent {
39
+ kind: "access-path";
40
+ surface: string;
41
+ path: AccessPath;
42
+ agentName?: string;
43
+ }
44
+
45
+ /** What a gate emits — the full three-variant union. */
46
+ export type AccessIntent =
47
+ | ToolAccessIntent
48
+ | PathValuesAccessIntent
49
+ | AccessPathAccessIntent;
50
+
51
+ /**
52
+ * What the manager consumes — the `access-path` variant has already been
53
+ * unwrapped to `path-values` by the resolver via `path.matchValues()`.
54
+ *
55
+ * The manager stays string-based and never imports `AccessPath`.
56
+ */
57
+ export type ResolvedAccessIntent = ToolAccessIntent | PathValuesAccessIntent;
@@ -0,0 +1,88 @@
1
+ import {
2
+ canonicalNormalizePathForComparison,
3
+ getPathPolicyValues,
4
+ normalizePathForComparison,
5
+ } from "#src/path-utils";
6
+
7
+ /**
8
+ * A path's two representations held behind type-distinct accessors.
9
+ *
10
+ * A single `string` carrying both meanings was the root cause of [#418]:
11
+ * both external-directory gates matched config patterns against the
12
+ * symlink-resolved (canonical) path instead of the typed (lexical) path,
13
+ * defeating a configured `/tmp/*` allow.
14
+ *
15
+ * `AccessPath` makes the misuse a compile error:
16
+ * - {@link matchValues} returns `string[]` — the lexical alias union ∪ canonical,
17
+ * for `external_directory` pattern matching.
18
+ * - {@link boundaryValue} returns `string` — the canonical form, for
19
+ * outside-CWD containment and infra-read checks.
20
+ * - {@link value} returns `string` — the lexical absolute form, for display,
21
+ * approval patterns, decision values, and logs.
22
+ *
23
+ * Construct via {@link forExternalDirectory}; the constructor is private.
24
+ */
25
+ export class AccessPath {
26
+ private constructor(
27
+ private readonly lexical: string,
28
+ private readonly matchAliases: readonly string[],
29
+ private readonly canonical: string,
30
+ ) {}
31
+
32
+ /**
33
+ * Pattern-match values for the `external_directory` surface: the lexical
34
+ * alias union plus the canonical alias, so a config pattern on either the
35
+ * typed form (`/tmp/*`) or the symlink-resolved form (`/private/tmp/*`)
36
+ * matches (#418).
37
+ *
38
+ * Collapses to the lexical aliases when the canonical equals one of them
39
+ * (e.g. when the path is not a symlink).
40
+ */
41
+ matchValues(): string[] {
42
+ return this.canonical
43
+ ? [...new Set([...this.matchAliases, this.canonical])]
44
+ : [...this.matchAliases];
45
+ }
46
+
47
+ /**
48
+ * Canonical (symlink-resolved, win32-lowercased) form, for the outside-CWD
49
+ * boundary decision and Pi infrastructure-read containment checks.
50
+ *
51
+ * Returns `""` when the path could not be resolved (empty input).
52
+ */
53
+ boundaryValue(): string {
54
+ return this.canonical;
55
+ }
56
+
57
+ /**
58
+ * Lexical (as-typed, normalized but not symlink-resolved) form, for display,
59
+ * approval patterns, decision values, and log messages.
60
+ *
61
+ * Returns `""` for empty input.
62
+ */
63
+ value(): string {
64
+ return this.lexical;
65
+ }
66
+
67
+ /**
68
+ * Build an `AccessPath` for a tool-input or bash-token path resolved against
69
+ * `cwd`.
70
+ *
71
+ * - `matchValues()` returns the same set as the former
72
+ * `getExternalDirectoryPolicyValues(pathValue, cwd)` — the lexical alias
73
+ * union from `getPathPolicyValues` plus the canonical alias from
74
+ * `canonicalNormalizePathForComparison` (#418).
75
+ * - `boundaryValue()` returns `canonicalNormalizePathForComparison(pathValue, cwd)`,
76
+ * which is win32-lowercased (#382) — do not substitute a raw
77
+ * `canonicalizePath` output here.
78
+ * - `value()` returns `normalizePathForComparison(pathValue, cwd)`, the
79
+ * absolute lexical form.
80
+ */
81
+ static forExternalDirectory(pathValue: string, cwd: string): AccessPath {
82
+ return new AccessPath(
83
+ normalizePathForComparison(pathValue, cwd),
84
+ getPathPolicyValues(pathValue, { cwd }),
85
+ canonicalNormalizePathForComparison(pathValue, cwd),
86
+ );
87
+ }
88
+ }
@@ -1,4 +1,5 @@
1
1
  import { isAbsolute, join, resolve } from "node:path";
2
+ import { AccessPath } from "#src/access-intent/access-path";
2
3
  import {
3
4
  ARG_NODE_TYPES,
4
5
  SKIP_SUBTREE_TYPES,
@@ -409,11 +410,11 @@ function getPolicyValuesForRuleCandidate(
409
410
  export function projectExternalPaths(
410
411
  candidates: readonly PathCandidate[],
411
412
  cwd: string,
412
- ): string[] {
413
+ ): AccessPath[] {
413
414
  const normalizedCwd = canonicalizePath(normalizePathForComparison(cwd, cwd));
414
415
 
415
416
  const seen = new Set<string>();
416
- const externalPaths: string[] = [];
417
+ const externalPaths: AccessPath[] = [];
417
418
 
418
419
  for (const { token, base } of candidates) {
419
420
  const candidate = classifyTokenAsPathCandidate(token);
@@ -432,7 +433,9 @@ export function projectExternalPaths(
432
433
  !seen.has(canonical)
433
434
  ) {
434
435
  seen.add(canonical);
435
- externalPaths.push(lexical);
436
+ // The factory recomputes the canonical via canonicalNormalizePathForComparison
437
+ // (win32-lowercased, #382) rather than reusing the raw canonicalizePath output.
438
+ externalPaths.push(AccessPath.forExternalDirectory(lexical, cwd));
436
439
  }
437
440
  continue;
438
441
  }
@@ -452,7 +455,9 @@ export function projectExternalPaths(
452
455
  !seen.has(canonical)
453
456
  ) {
454
457
  seen.add(canonical);
455
- externalPaths.push(lexical);
458
+ // The factory recomputes the canonical via canonicalNormalizePathForComparison
459
+ // (win32-lowercased, #382) rather than reusing the raw canonicalizePath output.
460
+ externalPaths.push(AccessPath.forExternalDirectory(lexical, cwd));
456
461
  }
457
462
  }
458
463
 
@@ -1,3 +1,4 @@
1
+ import type { AccessPath } from "#src/access-intent/access-path";
1
2
  import {
2
3
  type BashCommand,
3
4
  collectCommands,
@@ -25,7 +26,7 @@ export type { BashCommand, BashPathRuleCandidate };
25
26
  export class BashProgram {
26
27
  private constructor(
27
28
  private readonly commandUnits: readonly BashCommand[],
28
- private readonly resolvedExternalPaths: readonly string[],
29
+ private readonly resolvedExternalPaths: readonly AccessPath[],
29
30
  private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
30
31
  ) {}
31
32
 
@@ -73,16 +74,15 @@ export class BashProgram {
73
74
  }
74
75
 
75
76
  /**
76
- * Deduplicated paths that resolve outside `cwd`, in their lexical (as-typed,
77
- * normalized but not symlink-resolved) form.
77
+ * Deduplicated paths that resolve outside `cwd`, as {@link AccessPath} value
78
+ * objects holding both the lexical (as-typed) and canonical (symlink-resolved)
79
+ * forms behind distinct accessors.
78
80
  *
79
81
  * Resolved eagerly at parse time against the `cwd` supplied to `parse()`.
80
- * The outside-`cwd` decision and the dedup identity use the canonical
81
- * (symlink-resolved) form, but the returned value is the lexical form so
82
- * `external_directory` config patterns match the path as the user typed it
83
- * (#418); the gate re-derives the canonical alias for matching.
82
+ * Use `.matchValues()` for `external_directory` pattern matching and
83
+ * `.boundaryValue()` for containment checks; `.value()` for display and logs.
84
84
  */
85
- externalPaths(): string[] {
85
+ externalPaths(): AccessPath[] {
86
86
  return [...this.resolvedExternalPaths];
87
87
  }
88
88
 
@@ -38,7 +38,12 @@ export function resolveBashCommandCheck(
38
38
  ): PermissionCheckResult {
39
39
  if (commands.length === 0) {
40
40
  if (isTriviallyEmptyCommand(command)) {
41
- return resolver.resolve("bash", { command }, agentName);
41
+ return resolver.resolve({
42
+ kind: "tool",
43
+ surface: "bash",
44
+ input: { command },
45
+ agentName,
46
+ });
42
47
  }
43
48
  return {
44
49
  state: "ask",
@@ -51,12 +56,22 @@ export function resolveBashCommandCheck(
51
56
  }
52
57
 
53
58
  const results = commands.map((cmd) => {
54
- const result = resolver.resolve("bash", { command: cmd.text }, agentName);
59
+ const result = resolver.resolve({
60
+ kind: "tool",
61
+ surface: "bash",
62
+ input: { command: cmd.text },
63
+ agentName,
64
+ });
55
65
  return cmd.context ? { ...result, commandContext: cmd.context } : result;
56
66
  });
57
67
  return (
58
68
  pickMostRestrictive(results) ??
59
- resolver.resolve("bash", { command }, agentName)
69
+ resolver.resolve({
70
+ kind: "tool",
71
+ surface: "bash",
72
+ input: { command },
73
+ agentName,
74
+ })
60
75
  );
61
76
  }
62
77
 
@@ -1,13 +1,11 @@
1
1
  import type { BashProgram } from "#src/access-intent/bash/program";
2
2
  import { getNonEmptyString, toRecord } from "#src/common";
3
- import { getExternalDirectoryPolicyValues } from "#src/path-utils";
4
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
5
4
  import { SessionApproval } from "#src/session-approval";
6
5
  import { deriveApprovalPattern } from "#src/session-rules";
7
- import type { PermissionCheckResult } from "#src/types";
8
- import { pickMostRestrictive } from "./candidate-check";
9
6
  import type { GateResult } from "./descriptor";
10
7
  import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
8
+ import { selectUncoveredExternalPaths } from "./external-directory-policy";
11
9
  import type { ToolCallContext } from "./types";
12
10
 
13
11
  /**
@@ -34,27 +32,17 @@ export function describeBashExternalDirectoryGate(
34
32
  const externalPaths = bashProgram.externalPaths();
35
33
  if (externalPaths.length === 0) return null;
36
34
 
37
- // Collect paths whose resolved state is not already "allow".
38
- // Checking state (not source) ensures config-level allow rules (source: "special")
39
- // suppress the prompt just as session-level allow rules (source: "session") do.
40
- const uncoveredEntries: Array<{
41
- path: string;
42
- check: PermissionCheckResult;
43
- }> = [];
44
- for (const p of externalPaths) {
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),
35
+ // Resolve every external path on the external_directory surface and keep the
36
+ // ones not already allowed (config-level allows suppress the prompt just as
37
+ // session-level allows do); the shared helper single-sources the #418 alias
38
+ // matching and the worst-uncovered selection.
39
+ const { uncovered: uncoveredEntries, worstCheck } =
40
+ selectUncoveredExternalPaths(
41
+ externalPaths,
42
+ resolver,
50
43
  tcc.agentName ?? undefined,
51
- "external_directory",
52
44
  );
53
- if (check.state !== "allow") {
54
- uncoveredEntries.push({ path: p, check });
55
- }
56
- }
57
- const uncoveredPaths = uncoveredEntries.map(({ path }) => path);
45
+ const uncoveredPaths = uncoveredEntries.map(({ path }) => path.value());
58
46
 
59
47
  if (uncoveredPaths.length === 0) {
60
48
  return {
@@ -67,19 +55,17 @@ export function describeBashExternalDirectoryGate(
67
55
  toolName: tcc.toolName,
68
56
  agentName: tcc.agentName,
69
57
  command,
70
- externalPaths,
58
+ externalPaths: externalPaths.map((p) => p.value()),
71
59
  resolution: "session_approved",
72
60
  },
73
61
  },
74
62
  };
75
63
  }
76
64
 
77
- // Use the most restrictive check among uncovered paths as the pre-check result.
78
- // This ensures a config-level "deny" rule is not downgraded to "ask" by the
79
- // generic "*" catch-all that the old path-less checkPermission call returned.
80
- const worstCheck =
81
- pickMostRestrictive(uncoveredEntries.map(({ check }) => check)) ??
82
- uncoveredEntries[0].check;
65
+ // After the early bypass, at least one path is uncovered, so worstCheck is
66
+ // defined; the fallback keeps TypeScript happy across the early return. A
67
+ // config-level "deny" is preserved (not downgraded to the catch-all "ask").
68
+ const preCheck = worstCheck ?? uncoveredEntries[0].check;
83
69
 
84
70
  const bashExtMessage = formatBashExternalDirectoryAskPrompt(
85
71
  command,
@@ -122,6 +108,6 @@ export function describeBashExternalDirectoryGate(
122
108
  surface: "external_directory",
123
109
  value: command,
124
110
  },
125
- preCheck: worstCheck,
111
+ preCheck,
126
112
  };
127
113
  }
@@ -4,12 +4,18 @@ import { BashProgram } from "#src/access-intent/bash/program";
4
4
  * Extract paths from a bash command string that resolve outside CWD.
5
5
  *
6
6
  * Thin facade over {@link BashProgram.externalPaths}; parses the command and
7
- * returns the cd-aware external paths. See `BashProgram` for the parsing and
8
- * resolution semantics.
7
+ * returns the cd-aware external paths in their lexical (as-typed) string form.
8
+ * See `BashProgram` for the parsing and resolution semantics.
9
+ *
10
+ * Returns `string[]` (not `AccessPath[]`) so the large projection-correctness
11
+ * test suite in `bash-external-directory.test.ts` can assert path values
12
+ * without migrating every call site.
9
13
  */
10
14
  export async function extractExternalPathsFromBashCommand(
11
15
  command: string,
12
16
  cwd: string,
13
17
  ): Promise<string[]> {
14
- return (await BashProgram.parse(command, cwd)).externalPaths();
18
+ return (await BashProgram.parse(command, cwd))
19
+ .externalPaths()
20
+ .map((p) => p.value());
15
21
  }
@@ -51,10 +51,12 @@ export function describeBashPathGate(
51
51
  let allSessionCovered = true;
52
52
 
53
53
  for (const { token, policyValues } of candidates) {
54
- const check = resolver.resolvePathPolicy(
55
- policyValues,
56
- tcc.agentName ?? undefined,
57
- );
54
+ const check = resolver.resolve({
55
+ kind: "path-values",
56
+ surface: "path",
57
+ values: policyValues,
58
+ agentName: tcc.agentName ?? undefined,
59
+ });
58
60
 
59
61
  // No explicit path rule matched — only the universal default fired.
60
62
  // Treat this token as unrestricted to preserve backward compatibility
@@ -0,0 +1,66 @@
1
+ import type { AccessPath } from "#src/access-intent/access-path";
2
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
+ import type { PermissionCheckResult } from "#src/types";
4
+ import { pickMostRestrictive } from "./candidate-check";
5
+
6
+ /** An external path whose resolved `external_directory` state is not "allow". */
7
+ export interface UncoveredExternalPath {
8
+ path: AccessPath;
9
+ check: PermissionCheckResult;
10
+ }
11
+
12
+ /** The uncovered external paths plus the most restrictive check among them. */
13
+ export interface UncoveredExternalPaths {
14
+ uncovered: UncoveredExternalPath[];
15
+ /** Worst check among uncovered paths; `undefined` only when none are uncovered. */
16
+ worstCheck: PermissionCheckResult | undefined;
17
+ }
18
+
19
+ /**
20
+ * Resolve one external path's policy on the `external_directory` surface.
21
+ *
22
+ * Emits an `access-path` {@link AccessIntent}; the resolver unwraps it via
23
+ * {@link AccessPath.matchValues} so a config pattern on either the typed or
24
+ * symlink-resolved alias applies (#418). This is the single source for the
25
+ * external-directory resolve that the two external-directory gates previously
26
+ * duplicated.
27
+ */
28
+ export function resolveExternalDirectoryPolicy(
29
+ path: AccessPath,
30
+ resolver: ScopedPermissionResolver,
31
+ agentName: string | undefined,
32
+ ): PermissionCheckResult {
33
+ return resolver.resolve({
34
+ kind: "access-path",
35
+ surface: "external_directory",
36
+ path,
37
+ agentName,
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Resolve a set of external paths and select those not already allowed.
43
+ *
44
+ * Each path is resolved via {@link resolveExternalDirectoryPolicy}; entries
45
+ * whose state is not "allow" are collected (filtering on state, not source, so
46
+ * config-level allow rules suppress the prompt just as session-level allow
47
+ * rules do), and the most restrictive uncovered check is returned so a config
48
+ * "deny" is not downgraded to the catch-all "ask".
49
+ */
50
+ export function selectUncoveredExternalPaths(
51
+ paths: readonly AccessPath[],
52
+ resolver: ScopedPermissionResolver,
53
+ agentName: string | undefined,
54
+ ): UncoveredExternalPaths {
55
+ const uncovered: UncoveredExternalPath[] = [];
56
+ for (const path of paths) {
57
+ const check = resolveExternalDirectoryPolicy(path, resolver, agentName);
58
+ if (check.state !== "allow") {
59
+ uncovered.push({ path, check });
60
+ }
61
+ }
62
+ return {
63
+ uncovered,
64
+ worstCheck: pickMostRestrictive(uncovered.map(({ check }) => check)),
65
+ };
66
+ }
@@ -1,6 +1,5 @@
1
+ import { AccessPath } from "#src/access-intent/access-path";
1
2
  import {
2
- canonicalNormalizePathForComparison,
3
- getExternalDirectoryPolicyValues,
4
3
  getToolInputPath,
5
4
  isPathOutsideWorkingDirectory,
6
5
  isPiInfrastructureRead,
@@ -12,6 +11,7 @@ import { deriveApprovalPattern } from "#src/session-rules";
12
11
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
13
12
  import type { GateResult } from "./descriptor";
14
13
  import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
14
+ import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
15
15
  import type { ToolCallContext } from "./types";
16
16
 
17
17
  /**
@@ -42,10 +42,11 @@ export function describeExternalDirectoryGate(
42
42
  // The boundary decision (above) and the infrastructure-read containment
43
43
  // check (below) use the canonical, symlink-resolved path; pattern matching
44
44
  // uses the typed and resolved aliases (#418).
45
- const canonicalExtPath = canonicalNormalizePathForComparison(
45
+ const accessPath = AccessPath.forExternalDirectory(
46
46
  externalDirectoryPath,
47
47
  tcc.cwd,
48
48
  );
49
+ const canonicalExtPath = accessPath.boundaryValue();
49
50
 
50
51
  // ── Pi infrastructure read bypass ──────────────────────────────────────
51
52
  if (
@@ -83,13 +84,11 @@ export function describeExternalDirectoryGate(
83
84
  tcc.agentName ?? undefined,
84
85
  );
85
86
 
86
- // Match against both the typed and symlink-resolved aliases on the
87
- // external_directory surface, so a config pattern on either form applies
88
- // (#418). The runner consumes this preCheck and skips its own resolve.
89
- const preCheck = resolver.resolvePathPolicy(
90
- getExternalDirectoryPolicyValues(externalDirectoryPath, tcc.cwd),
87
+ // The runner consumes this preCheck and skips its own resolve.
88
+ const preCheck = resolveExternalDirectoryPolicy(
89
+ accessPath,
90
+ resolver,
91
91
  tcc.agentName ?? undefined,
92
- "external_directory",
93
92
  );
94
93
  const pattern = deriveApprovalPattern(
95
94
  normalizePathForComparison(externalDirectoryPath, tcc.cwd),
@@ -22,11 +22,12 @@ export function describePathGate(
22
22
  const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
23
23
  if (!filePath) return null;
24
24
 
25
- const check = resolver.resolve(
26
- "path",
27
- { path: filePath },
28
- tcc.agentName ?? undefined,
29
- );
25
+ const check = resolver.resolve({
26
+ kind: "tool",
27
+ surface: "path",
28
+ input: { path: filePath },
29
+ agentName: tcc.agentName ?? undefined,
30
+ });
30
31
 
31
32
  if (check.state === "allow") return null;
32
33
 
@@ -77,11 +77,12 @@ export class GateRunner {
77
77
  origin: "builtin",
78
78
  };
79
79
  } else {
80
- check = this.resolver.resolve(
81
- descriptor.surface,
82
- descriptor.input,
83
- agentName ?? undefined,
84
- );
80
+ check = this.resolver.resolve({
81
+ kind: "tool",
82
+ surface: descriptor.surface,
83
+ input: descriptor.input,
84
+ agentName: agentName ?? undefined,
85
+ });
85
86
  }
86
87
 
87
88
  // 2. Session-hit fast path
@@ -102,11 +102,12 @@ export class ToolCallGatePipeline {
102
102
  tcc.agentName ?? undefined,
103
103
  this.resolver,
104
104
  )
105
- : this.resolver.resolve(
106
- tcc.toolName,
107
- tcc.input,
108
- tcc.agentName ?? undefined,
109
- );
105
+ : this.resolver.resolve({
106
+ kind: "tool",
107
+ surface: tcc.toolName,
108
+ input: tcc.input,
109
+ agentName: tcc.agentName ?? undefined,
110
+ });
110
111
  const toolDescriptor = describeToolGate(tcc, toolCheck, formatter);
111
112
  toolDescriptor.preCheck = toolCheck;
112
113
  return toolDescriptor;
package/src/path-utils.ts CHANGED
@@ -112,26 +112,6 @@ 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
-
135
115
  function getAbsolutePathPolicyValues(
136
116
  pathValue: string,
137
117
  options: PathPolicyValueOptions,
@@ -26,7 +26,7 @@ import {
26
26
  PERMISSIONS_RPC_CHECK_CHANNEL,
27
27
  PERMISSIONS_RPC_PROMPT_CHANNEL,
28
28
  } from "./permission-events";
29
- import type { PermissionManager } from "./permission-manager";
29
+ import type { ScopedPermissionManager } from "./permission-manager";
30
30
  import { buildRpcUiPrompt } from "./permission-ui-prompt";
31
31
  import type { ReviewLogger } from "./session-logger";
32
32
  import type { SessionRules } from "./session-rules";
@@ -34,7 +34,7 @@ import type { SessionRules } from "./session-rules";
34
34
  /** Dependencies injected into the RPC handler registry. */
35
35
  export interface PermissionRpcDeps {
36
36
  /** The shared PermissionManager instance. */
37
- permissionManager: Pick<PermissionManager, "checkPermission">;
37
+ permissionManager: Pick<ScopedPermissionManager, "check">;
38
38
  /** The shared SessionRules instance. */
39
39
  sessionRules: Pick<SessionRules, "getRuleset">;
40
40
  /**
@@ -109,10 +109,8 @@ function handleCheckRpc(
109
109
 
110
110
  const input = buildInputForSurface(surface, value);
111
111
  const sessionRules = deps.sessionRules.getRuleset();
112
- const result = deps.permissionManager.checkPermission(
113
- surface,
114
- input,
115
- agentName ?? undefined,
112
+ const result = deps.permissionManager.check(
113
+ { kind: "tool", surface, input, agentName: agentName ?? undefined },
116
114
  sessionRules,
117
115
  );
118
116