@gotgenes/pi-permission-system 17.1.1 → 18.0.1

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 (46) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -0
  3. package/package.json +1 -1
  4. package/src/access-intent/access-intent.ts +14 -4
  5. package/src/access-intent/access-path.ts +1 -1
  6. package/src/access-intent/bash/bash-path-resolver.ts +2 -1
  7. package/src/access-intent/bash/program.ts +0 -4
  8. package/src/access-intent/path-normalization.ts +139 -0
  9. package/src/handlers/gates/external-directory.ts +1 -1
  10. package/src/handlers/gates/path.ts +1 -1
  11. package/src/handlers/gates/tool-call-gate-pipeline.ts +61 -22
  12. package/src/handlers/gates/tool.ts +11 -14
  13. package/src/index.ts +8 -6
  14. package/src/input-normalizer.ts +54 -56
  15. package/src/path-containment.ts +56 -0
  16. package/src/path-normalizer.ts +20 -5
  17. package/src/path-surfaces.ts +30 -0
  18. package/src/pattern-suggest.ts +1 -1
  19. package/src/permission-event-rpc.ts +21 -15
  20. package/src/permission-manager.ts +10 -7
  21. package/src/permission-resolver.ts +5 -0
  22. package/src/permission-session.ts +0 -5
  23. package/src/permissions-service.ts +33 -12
  24. package/src/pi-infrastructure-read.ts +65 -0
  25. package/src/rule.ts +1 -1
  26. package/src/safe-system-paths.ts +18 -0
  27. package/src/tool-input-path.ts +54 -0
  28. package/test/access-intent/bash/program.test.ts +1 -1
  29. package/test/composition-root.test.ts +22 -0
  30. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +73 -0
  31. package/test/handlers/gates/tool.test.ts +25 -16
  32. package/test/helpers/gate-fixtures.ts +0 -3
  33. package/test/input-normalizer.test.ts +163 -270
  34. package/test/path-containment.test.ts +161 -0
  35. package/test/path-normalization.test.ts +233 -0
  36. package/test/path-normalizer.test.ts +30 -0
  37. package/test/path-surfaces.test.ts +55 -0
  38. package/test/permission-event-rpc.test.ts +80 -65
  39. package/test/permission-manager-unified.test.ts +134 -145
  40. package/test/permissions-service.test.ts +84 -72
  41. package/test/pi-infrastructure-read.test.ts +41 -1
  42. package/test/safe-system-paths.test.ts +46 -0
  43. package/test/service.test.ts +56 -103
  44. package/test/tool-input-path.test.ts +84 -0
  45. package/src/path-utils.ts +0 -346
  46. package/test/path-utils.test.ts +0 -695
package/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ 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.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v18.0.0...pi-permission-system-v18.0.1) (2026-06-30)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * **pi-permission-system:** name the path-values boundary contract and guard it ([#506](https://github.com/gotgenes/pi-packages/issues/506)) ([a47648c](https://github.com/gotgenes/pi-packages/commit/a47648c1fcff3226b6ea3269392f9d8548654ee1))
14
+
15
+ ## [18.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v17.1.1...pi-permission-system-v18.0.0) (2026-06-29)
16
+
17
+
18
+ ### ⚠ BREAKING CHANGES
19
+
20
+ * **pi-permission-system:** A permissions:rpc:check query for a `path` / `external_directory` / path-bearing surface now matches the canonical (symlink-resolved) alias, and a `path` / path-bearing query now evaluates the supplied path instead of collapsing to `*`.
21
+ * **pi-permission-system:** A service (`getPermissionsService().checkPermission`) query for a `path` / `external_directory` / path-bearing surface now matches the canonical (symlink-resolved) alias, and a `path` / path-bearing query now evaluates the supplied path instead of collapsing to `*`. A symlinked path can now match a rule on its canonical target.
22
+ * **pi-permission-system:** a per-tool path rule (e.g. `read: deny *.env`) now also fires when a symlink's resolved target matches the pattern, where previously only the lexically-referenced spelling matched. A symlink alias can no longer evade a per-tool deny/allow.
23
+
24
+ ### Features
25
+
26
+ * **pi-permission-system:** match the canonical form on service path queries ([be4a3e7](https://github.com/gotgenes/pi-packages/commit/be4a3e7f48e700db4c667ce8176459a1e89820b4))
27
+ * **pi-permission-system:** match the canonical form on the per-tool path gate ([ad36e78](https://github.com/gotgenes/pi-packages/commit/ad36e7860084be7692cb142f50c8818bd013ec38))
28
+ * **pi-permission-system:** match the canonical form on the RPC check query ([bb04ca5](https://github.com/gotgenes/pi-packages/commit/bb04ca5d0bae265570144f7d76dee2bad9269f94))
29
+
30
+
31
+ ### Bug Fixes
32
+
33
+ * **pi-permission-system:** remove unused join import; annotate closed findings ([#504](https://github.com/gotgenes/pi-packages/issues/504)) ([eb7c7b2](https://github.com/gotgenes/pi-packages/commit/eb7c7b298e21358d831892545b5dd8d3e9fb340a))
34
+
35
+
36
+ ### Documentation
37
+
38
+ * **pi-permission-system:** document canonical per-tool path matching ([bafa492](https://github.com/gotgenes/pi-packages/commit/bafa492cf8517902aea5a44d6ea59d4b33e7f754))
39
+ * **pi-permission-system:** document canonical service/RPC path matching ([35c36fa](https://github.com/gotgenes/pi-packages/commit/35c36fa9f7954aa84eb468954660b5857e067be3))
40
+
8
41
  ## [17.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v17.1.0...pi-permission-system-v17.1.1) (2026-06-29)
9
42
 
10
43
 
package/README.md CHANGED
@@ -73,6 +73,7 @@ A `path` pattern matches both the path as the agent references it and its canoni
73
73
 
74
74
  For per-tool path patterns (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`.
75
75
  This lets you express rules like "allow reads but deny `.env` files" at the individual tool level.
76
+ Like the cross-cutting `path` surface, per-tool patterns match both the referenced path and its canonical (symlink-resolved) form, so a per-tool deny resists symlink-alias evasion.
76
77
  When Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`.
77
78
 
78
79
  The `external_directory` surface is the CWD-boundary gate: it decides whether reaching **outside** the working tree is allowed, and accepts a pattern map so you can allow specific outside-CWD directories without opening up all external access.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "17.1.1",
3
+ "version": "18.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,6 +20,10 @@ export interface ToolAccessIntent {
20
20
  * Not gate-emitted: the resolver produces it internally by unwrapping an
21
21
  * `access-path` intent via `matchValues()`, keeping the low-level manager
22
22
  * string-based (it never imports `AccessPath`). See {@link ResolvedAccessIntent}.
23
+ *
24
+ * This string seam is a deliberate, formalized boundary — not transitional
25
+ * scaffolding to collapse into the manager (ADR-0002,
26
+ * `docs/decisions/0002-path-values-string-boundary.md`).
23
27
  */
24
28
  export interface PathValuesAccessIntent {
25
29
  kind: "path-values";
@@ -32,9 +36,12 @@ export interface PathValuesAccessIntent {
32
36
  /**
33
37
  * An `AccessPath` value object for a path-shaped surface.
34
38
  *
35
- * Emitted by every path gate (the `path` and `external_directory` surfaces);
36
- * lets `AccessPath` flow into the resolver as a first-class variant so the
37
- * resolver not the gate — asks it for `matchValues()` (Tell-Don't-Ask).
39
+ * Built for every path-shaped surface: the cross-cutting `path` and
40
+ * `external_directory` gates, the per-tool path-bearing surfaces
41
+ * (`read`/`write`/`edit`/`grep`/`find`/`ls`, #502), and the service/RPC policy
42
+ * queries for those surfaces (#503). Lets `AccessPath` flow into the resolver
43
+ * as a first-class variant so the resolver — not the producer — asks it for
44
+ * `matchValues()` (Tell-Don't-Ask).
38
45
  */
39
46
  export interface AccessPathAccessIntent {
40
47
  kind: "access-path";
@@ -50,6 +57,9 @@ export type AccessIntent = ToolAccessIntent | AccessPathAccessIntent;
50
57
  * What the manager consumes — the `access-path` variant has already been
51
58
  * unwrapped to `path-values` by the resolver via `path.matchValues()`.
52
59
  *
53
- * The manager stays string-based and never imports `AccessPath`.
60
+ * The manager stays string-based and never imports `AccessPath`: this is the
61
+ * deliberate boundary formalized in ADR-0002
62
+ * (`docs/decisions/0002-path-values-string-boundary.md`), guarded by a
63
+ * `no-restricted-imports` lint rule on `permission-manager.ts`.
54
64
  */
55
65
  export type ResolvedAccessIntent = ToolAccessIntent | PathValuesAccessIntent;
@@ -2,7 +2,7 @@ import {
2
2
  canonicalNormalizePathForComparison,
3
3
  getPathPolicyValues,
4
4
  normalizePathForComparison,
5
- } from "#src/path-utils";
5
+ } from "./path-normalization";
6
6
 
7
7
  /**
8
8
  * A path's two representations held behind type-distinct accessors.
@@ -14,8 +14,9 @@ import {
14
14
  collectRedirectTokens,
15
15
  extractCommandName,
16
16
  } from "#src/access-intent/bash/token-collection";
17
+ import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
17
18
  import type { PathNormalizer } from "#src/path-normalizer";
18
- import { isSafeSystemPath, normalizePathPolicyLiteral } from "#src/path-utils";
19
+ import { isSafeSystemPath } from "#src/safe-system-paths";
19
20
 
20
21
  // ── Internal types ───────────────────────────────────────────────────────────
21
22
 
@@ -73,10 +73,6 @@ export class BashProgram {
73
73
  * back to the whole command so the surface is never evaluated weaker than
74
74
  * before.
75
75
  */
76
- // Used by resolveBashCommandCheck (bash-command.ts) and tests. Fallow's
77
- // syntactic analysis cannot resolve the static-factory return type (private
78
- // ctor), so it reports a false positive here.
79
- // fallow-ignore-next-line unused-class-member
80
76
  commands(): BashCommand[] {
81
77
  return [...this.commandUnits];
82
78
  }
@@ -0,0 +1,139 @@
1
+ import { posix as posixPath, win32 as winPath } from "node:path";
2
+
3
+ import { canonicalizePath } from "#src/canonicalize-path";
4
+ import { expandHomePath } from "#src/expand-home";
5
+ import { isPathWithinDirectory } from "#src/path-containment";
6
+
7
+ /**
8
+ * Representation derivation backing {@link AccessPath}: turn an accessed path
9
+ * into the lexical / canonical / policy-value forms the resolver matches
10
+ * against rules. Pure (no filesystem access except `canonicalizePath`'s
11
+ * best-effort symlink resolution); the `platform` is injected, never read
12
+ * ambiently.
13
+ */
14
+ export function normalizePathForComparison(
15
+ pathValue: string,
16
+ cwd: string,
17
+ platform: NodeJS.Platform,
18
+ ): string {
19
+ const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
20
+ if (!trimmed) {
21
+ return "";
22
+ }
23
+
24
+ let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
25
+ normalizedPath = expandHomePath(normalizedPath);
26
+
27
+ const impl = platform === "win32" ? winPath : posixPath;
28
+ const absolutePath = impl.resolve(cwd, normalizedPath);
29
+ const normalizedAbsolutePath = impl.normalize(absolutePath);
30
+ return platform === "win32"
31
+ ? normalizedAbsolutePath.toLowerCase()
32
+ : normalizedAbsolutePath;
33
+ }
34
+
35
+ export interface PathPolicyValueOptions {
36
+ /**
37
+ * Current Pi working directory. When provided, returned values include a
38
+ * project-relative alias for paths that resolve inside this directory.
39
+ */
40
+ cwd?: string;
41
+ /**
42
+ * Directory used to resolve `pathValue` into an absolute policy value.
43
+ * Defaults to `cwd`. Bash uses this for tokens seen after a literal `cd`.
44
+ */
45
+ resolveBase?: string;
46
+ }
47
+
48
+ /**
49
+ * Normalize a single path-like lookup value without resolving it against CWD.
50
+ *
51
+ * Preserves compatibility with existing relative path rules (`src/*`, `*.env`)
52
+ * while applying the same lexical cleanup as
53
+ * {@link normalizePathForComparison}: trim, strip simple wrapping quotes,
54
+ * strip the OpenCode-style leading `@`, and expand `~` / `$HOME`.
55
+ */
56
+ export function normalizePathPolicyLiteral(pathValue: string): string {
57
+ const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
58
+ if (!trimmed) return "";
59
+ const unprefixed = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
60
+ return expandHomePath(unprefixed);
61
+ }
62
+
63
+ /**
64
+ * Return equivalent lookup values for path-policy matching.
65
+ *
66
+ * The first value is the cwd/effective-base normalized absolute path when a
67
+ * base is available. The later values preserve project-relative and raw
68
+ * relative forms so existing rules like `src/*` and `*.env` continue to match.
69
+ */
70
+ export function getPathPolicyValues(
71
+ pathValue: string,
72
+ options: PathPolicyValueOptions,
73
+ platform: NodeJS.Platform,
74
+ ): string[] {
75
+ const literal = normalizePathPolicyLiteral(pathValue);
76
+ if (!literal) return [];
77
+ if (literal === "*") return ["*"];
78
+
79
+ return [
80
+ ...new Set([
81
+ ...getAbsolutePathPolicyValues(pathValue, options, platform),
82
+ literal,
83
+ ]),
84
+ ];
85
+ }
86
+
87
+ function getAbsolutePathPolicyValues(
88
+ pathValue: string,
89
+ options: PathPolicyValueOptions,
90
+ platform: NodeJS.Platform,
91
+ ): string[] {
92
+ const resolveBase = options.resolveBase ?? options.cwd;
93
+ if (!resolveBase) return [];
94
+
95
+ const absolute = normalizePathForComparison(pathValue, resolveBase, platform);
96
+ if (!absolute) return [];
97
+
98
+ return [
99
+ absolute,
100
+ ...getCwdRelativePathPolicyValues(absolute, options.cwd, platform),
101
+ ];
102
+ }
103
+
104
+ function getCwdRelativePathPolicyValues(
105
+ absolute: string,
106
+ cwd: string | undefined,
107
+ platform: NodeJS.Platform,
108
+ ): string[] {
109
+ if (!cwd) return [];
110
+
111
+ const normalizedCwd = normalizePathForComparison(cwd, cwd, platform);
112
+ if (!normalizedCwd) return [];
113
+ if (
114
+ absolute !== normalizedCwd &&
115
+ !isPathWithinDirectory(absolute, normalizedCwd, platform)
116
+ ) {
117
+ return [];
118
+ }
119
+
120
+ const impl = platform === "win32" ? winPath : posixPath;
121
+ const relativeValue = impl.relative(normalizedCwd, absolute);
122
+ return relativeValue ? [relativeValue] : [];
123
+ }
124
+
125
+ /**
126
+ * Like {@link normalizePathForComparison} but also resolves symlinks via
127
+ * `realpathSync` (best-effort). Use this for containment decisions where the
128
+ * OS-followed path matters, not for pattern matching.
129
+ */
130
+ export function canonicalNormalizePathForComparison(
131
+ pathValue: string,
132
+ cwd: string,
133
+ platform: NodeJS.Platform,
134
+ ): string {
135
+ const lexical = normalizePathForComparison(pathValue, cwd, platform);
136
+ if (!lexical) return "";
137
+ const canonical = canonicalizePath(lexical, platform);
138
+ return platform === "win32" ? canonical.toLowerCase() : canonical;
139
+ }
@@ -1,9 +1,9 @@
1
1
  import type { PathNormalizer } from "#src/path-normalizer";
2
- import { getToolInputPath } from "#src/path-utils";
3
2
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
3
  import { SessionApproval } from "#src/session-approval";
5
4
  import { deriveApprovalPattern } from "#src/session-rules";
6
5
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
6
+ import { getToolInputPath } from "#src/tool-input-path";
7
7
  import type { GateResult } from "./descriptor";
8
8
  import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
9
9
  import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
@@ -1,9 +1,9 @@
1
1
  import type { PathNormalizer } from "#src/path-normalizer";
2
- import { getToolInputPath } from "#src/path-utils";
3
2
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
3
  import { SessionApproval } from "#src/session-approval";
5
4
  import { deriveApprovalPattern } from "#src/session-rules";
6
5
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
6
+ import { getToolInputPath } from "#src/tool-input-path";
7
7
  import type { GateDescriptor, GateResult } from "./descriptor";
8
8
  import type { ToolCallContext } from "./types";
9
9
 
@@ -1,13 +1,16 @@
1
+ import type { AccessPath } from "#src/access-intent/access-path";
1
2
  import { BashProgram } from "#src/access-intent/bash/program";
2
3
  import type { PathNormalizer } from "#src/path-normalizer";
3
4
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
5
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
5
6
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
6
7
  import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
8
+ import { getPathBearingToolPath } from "#src/tool-input-path";
7
9
  import {
8
10
  ToolPreviewFormatter,
9
11
  type ToolPreviewFormatterOptions,
10
12
  } from "#src/tool-preview-formatter";
13
+ import type { PermissionCheckResult } from "#src/types";
11
14
  import { getNonEmptyString, toRecord } from "#src/value-guards";
12
15
  import { resolveBashCommandCheck } from "./bash-command";
13
16
  import { describeBashExternalDirectoryGate } from "./bash-external-directory";
@@ -39,8 +42,6 @@ export interface ToolCallGateInputs {
39
42
  getToolPreviewLimits(): ToolPreviewFormatterOptions;
40
43
  /** The session's path normalizer (platform + cwd baked in). */
41
44
  getPathNormalizer(): PathNormalizer;
42
- /** The host platform injected at the composition root. */
43
- getPlatform(): NodeJS.Platform;
44
45
  }
45
46
 
46
47
  /**
@@ -81,7 +82,6 @@ export class ToolCallGatePipeline {
81
82
  );
82
83
 
83
84
  const infraDirs = this.inputs.getInfrastructureReadDirs();
84
- const platform = this.inputs.getPlatform();
85
85
 
86
86
  const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
87
87
  () =>
@@ -101,29 +101,17 @@ export class ToolCallGatePipeline {
101
101
  () => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
102
102
  () => describeBashPathGate(tcc, bashProgram, this.resolver),
103
103
  () => {
104
- // Bash commands may chain several sub-commands (`a && b`, `a | b`, …);
105
- // evaluate each unit from the shared parse on the bash surface and
106
- // select the most restrictive, rather than matching the whole program
107
- // string (#301). Other tools evaluate their single input directly.
108
- const toolCheck =
109
- tcc.toolName === "bash" && bashProgram
110
- ? resolveBashCommandCheck(
111
- command ?? "",
112
- bashProgram.commands(),
113
- tcc.agentName ?? undefined,
114
- this.resolver,
115
- )
116
- : this.resolver.resolve({
117
- kind: "tool",
118
- surface: tcc.toolName,
119
- input: tcc.input,
120
- agentName: tcc.agentName ?? undefined,
121
- });
104
+ const { toolCheck, accessPath } = this.resolvePerToolCheck(
105
+ tcc,
106
+ bashProgram,
107
+ command,
108
+ normalizer,
109
+ );
122
110
  const toolDescriptor = describeToolGate(
123
111
  tcc,
124
112
  toolCheck,
125
113
  formatter,
126
- platform,
114
+ accessPath,
127
115
  );
128
116
  toolDescriptor.preCheck = toolCheck;
129
117
  return toolDescriptor;
@@ -143,4 +131,55 @@ export class ToolCallGatePipeline {
143
131
 
144
132
  return { action: "allow" };
145
133
  }
134
+
135
+ /**
136
+ * Resolve the per-tool gate's check, choosing the intent by tool shape:
137
+ * bash chains its sub-commands; a path-bearing tool with a path emits an
138
+ * `access-path` intent (so the per-tool surface matches lexical ∪ canonical,
139
+ * #502); every other tool (and a path-bearing tool with no path) keeps the
140
+ * raw `tool` intent the manager normalizes.
141
+ *
142
+ * Returns the `AccessPath` alongside the check so `describeToolGate` derives
143
+ * the session-approval value from `accessPath.value()`.
144
+ */
145
+ private resolvePerToolCheck(
146
+ tcc: ToolCallContext,
147
+ bashProgram: BashProgram | null,
148
+ command: string | null,
149
+ normalizer: PathNormalizer,
150
+ ): { toolCheck: PermissionCheckResult; accessPath?: AccessPath } {
151
+ if (tcc.toolName === "bash" && bashProgram) {
152
+ return {
153
+ toolCheck: resolveBashCommandCheck(
154
+ command ?? "",
155
+ bashProgram.commands(),
156
+ tcc.agentName ?? undefined,
157
+ this.resolver,
158
+ ),
159
+ };
160
+ }
161
+
162
+ const filePath = getPathBearingToolPath(tcc.toolName, tcc.input);
163
+ if (filePath !== null) {
164
+ const accessPath = normalizer.forPath(filePath);
165
+ return {
166
+ accessPath,
167
+ toolCheck: this.resolver.resolve({
168
+ kind: "access-path",
169
+ surface: tcc.toolName,
170
+ path: accessPath,
171
+ agentName: tcc.agentName ?? undefined,
172
+ }),
173
+ };
174
+ }
175
+
176
+ return {
177
+ toolCheck: this.resolver.resolve({
178
+ kind: "tool",
179
+ surface: tcc.toolName,
180
+ input: tcc.input,
181
+ agentName: tcc.agentName ?? undefined,
182
+ }),
183
+ };
184
+ }
146
185
  }
@@ -1,11 +1,9 @@
1
- import {
2
- getPathBearingToolPath,
3
- normalizePathForComparison,
4
- PATH_BEARING_TOOLS,
5
- } from "#src/path-utils";
1
+ import type { AccessPath } from "#src/access-intent/access-path";
2
+ import { PATH_BEARING_TOOLS } from "#src/path-surfaces";
6
3
  import { suggestSessionPattern } from "#src/pattern-suggest";
7
4
  import { formatAskPrompt } from "#src/permission-prompts";
8
5
  import { SessionApproval } from "#src/session-approval";
6
+ import { getPathBearingToolPath } from "#src/tool-input-path";
9
7
  import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
10
8
  import type { PermissionCheckResult } from "#src/types";
11
9
  import type { GateDescriptor } from "./descriptor";
@@ -16,20 +14,19 @@ import type { ToolCallContext } from "./types";
16
14
  * Derive the value used for session-approval pattern suggestions.
17
15
  *
18
16
  * Bash → command string; MCP → qualified target;
19
- * path-bearing tools → the file path resolved to its canonical (cwd-anchored,
20
- * absolute) form so the suggested pattern matches the policy values a later
21
- * call produces; others → catch-all wildcard.
17
+ * path-bearing tools → the `AccessPath`'s lexical absolute form (`value()`),
18
+ * so the suggested pattern matches the policy values a later call produces;
19
+ * others (or a path-bearing tool with no path) → catch-all wildcard.
22
20
  */
23
21
  function deriveSuggestionValue(
24
22
  tcc: ToolCallContext,
25
23
  check: PermissionCheckResult,
26
- platform: NodeJS.Platform,
24
+ accessPath?: AccessPath,
27
25
  ): string {
28
26
  if (tcc.toolName === "bash") return check.command ?? "";
29
27
  if (tcc.toolName === "mcp") return check.target ?? "mcp";
30
- const path = getPathBearingToolPath(tcc.toolName, tcc.input);
31
- if (path === null) return "*";
32
- return normalizePathForComparison(path, tcc.cwd, platform);
28
+ if (accessPath) return accessPath.value();
29
+ return "*";
33
30
  }
34
31
 
35
32
  /**
@@ -42,7 +39,7 @@ export function describeToolGate(
42
39
  tcc: ToolCallContext,
43
40
  check: PermissionCheckResult,
44
41
  formatter: ToolPreviewFormatter,
45
- platform: NodeJS.Platform,
42
+ accessPath?: AccessPath,
46
43
  ): GateDescriptor {
47
44
  const permissionLogContext = formatter.getPermissionLogContext(
48
45
  check,
@@ -53,7 +50,7 @@ export function describeToolGate(
53
50
  // Compute session approval suggestion for the "for this session" option.
54
51
  const suggestion = suggestSessionPattern(
55
52
  tcc.toolName,
56
- deriveSuggestionValue(tcc, check, platform),
53
+ deriveSuggestionValue(tcc, check, accessPath),
57
54
  );
58
55
 
59
56
  const askMessage = formatAskPrompt(
package/src/index.ts CHANGED
@@ -134,17 +134,21 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
134
134
  ),
135
135
  });
136
136
 
137
+ // Resolver composes the manager + session ruleset and owns the
138
+ // access-path → path-values unwrap; the RPC and service route their policy
139
+ // queries through it, so it is constructed before both.
140
+ const resolver = new PermissionResolver(permissionManager, sessionRules);
141
+
137
142
  const rpcHandles = registerPermissionRpcHandlers(pi.events, {
138
- permissionManager,
139
- sessionRules,
143
+ resolver,
140
144
  session,
141
145
  requestPermissionDecisionFromUi,
142
146
  logger,
143
147
  });
144
148
 
145
149
  const permissionsService = new LocalPermissionsService(
146
- permissionManager,
147
- sessionRules,
150
+ resolver,
151
+ session,
148
152
  formatterRegistry,
149
153
  accessExtractorRegistry,
150
154
  );
@@ -174,8 +178,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
174
178
  setActive: (names: string[]) => pi.setActiveTools(names),
175
179
  };
176
180
 
177
- const resolver = new PermissionResolver(permissionManager, sessionRules);
178
-
179
181
  const audit = new DecisionAudit();
180
182
  const lifecycle = new SessionLifecycleHandler(
181
183
  session,