@gotgenes/pi-permission-system 38.0.1 → 38.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ 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
+ ## [38.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v38.0.1...pi-permission-system-v38.0.2) (2026-10-02)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** a path rule applies to the file a built-in tool opens, however the path is spelled ([33579c5](https://github.com/gotgenes/pi-packages/commit/33579c56f085ed982189e0f75764f60abb183f1e)), closes [#997](https://github.com/gotgenes/pi-packages/issues/997)
14
+ * **pi-permission-system:** an outside file spelled as a file URL still asks for external-directory access ([1d7df71](https://github.com/gotgenes/pi-packages/commit/1d7df71d590c32a2b3fee5f28462058d3775d662)), closes [#997](https://github.com/gotgenes/pi-packages/issues/997)
15
+ * **pi-permission-system:** a permissions-service path query sees the file the tool would open ([9d4f0f3](https://github.com/gotgenes/pi-packages/commit/9d4f0f3e29ff1224a50321c95ed0ddc65b6f1c20)), closes [#997](https://github.com/gotgenes/pi-packages/issues/997)
16
+ * **pi-permission-system:** a path prompt shows the file a rewritten spelling resolves to ([3e47dd3](https://github.com/gotgenes/pi-packages/commit/3e47dd32c32bb9e9b13b1469a983244ad6f01250)), closes [#997](https://github.com/gotgenes/pi-packages/issues/997)
17
+
18
+ ### Documentation
19
+
20
+ * **pi-permission-system:** document that built-in tool paths are gated as the file Pi opens ([81b3eb1](https://github.com/gotgenes/pi-packages/commit/81b3eb1e2150100ab49cef9c6e759f000b2720e0)), closes [#997](https://github.com/gotgenes/pi-packages/issues/997)
21
+ * **pi-permission-system:** scope the $HOME expansion note to bash and extension tool paths ([8a31bf2](https://github.com/gotgenes/pi-packages/commit/8a31bf26e31ad2d1dacb5286bb576d3033687699)), closes [#997](https://github.com/gotgenes/pi-packages/issues/997)
22
+
8
23
  ## [38.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v38.0.0...pi-permission-system-v38.0.1) (2026-10-02)
9
24
 
10
25
 
package/README.md CHANGED
@@ -75,6 +75,7 @@ See [docs/configuration.md](docs/configuration.md#inline-permission-dialog-tui)
75
75
  The `path` surface is a cross-cutting gate that applies to **all** file access — Pi tools, bash commands, MCP calls, and extension tools alike.
76
76
  Extension and MCP tools that operate on paths (via `input.path`, MCP's `input.arguments.path`, or a registered access extractor) are gated by default, so a `path` deny cannot be overridden by a per-tool allow — making it the right place to protect sensitive files like `.env` or `~/.ssh/*` from every tool at once.
77
77
  A `path` pattern matches both the path as the agent references it and its canonical (symlink-resolved) form, so a deny still fires when a symlink aliases a sensitive target.
78
+ For Pi's built-in file tools, "the path as the agent references it" is the file the tool actually opens: Pi's resolver decodes a `file://` URL, turns Unicode spaces into spaces, and for `read` falls back to macOS's curly-quote and screenshot spellings, so a deny on the file on disk fires however the path was spelled.
78
79
 
79
80
  For per-tool path patterns (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`.
80
81
  This lets you express rules like "allow reads but deny `.env` files" at the individual tool level.
@@ -687,6 +687,12 @@ If it denies, the command is blocked without reaching subsequent gates — no wa
687
687
 
688
688
  Path patterns match both the path **as the agent references it** and its canonical (symlink-resolved) form, so a deny on a sensitive spelling cannot be evaded through a symlink alias (see Symlinked paths below).
689
689
 
690
+ For Pi's built-in file tools (`read`, `write`, `edit`, `ls`, `find`, `grep`), the path the agent references is the file the tool opens, not the literal argument.
691
+ Pi resolves that argument itself before touching the disk: it decodes a `file://` URL, turns Unicode spaces into ordinary spaces, translates a Git Bash, Cygwin, or WSL drive path (`/c/…`, `/cygdrive/c/…`, `/mnt/c/…`) on Windows, and for `read` alone falls back to the macOS screenshot (`\u202FPM`), NFD, and curly-quote (`’`) spellings of a name that does not exist.
692
+ The gates match the file that resolution names, so a deny keyed on the file on disk fires however it was spelled, and an ask shows the file under "resolves to" when the spelling was rewritten.
693
+ Pi does not expand `$HOME` or strip quotes from a tool's path, so neither do these gates: `read $HOME/.ssh/config` names `<cwd>/$HOME/.ssh/config`, the file the tool would open.
694
+ Extension and MCP tool paths keep their literal spelling.
695
+
690
696
  For bash commands, the extension extracts path-candidate tokens from the command (dot-files like `.env`, relative paths like `src/foo.ts`, and absolute paths) and evaluates each against the path rules.
691
697
  The most restrictive result across all tokens determines the outcome.
692
698
  When the current working directory is known, relative bash tokens are matched with cwd-normalized policy values, resolved against the effective directory after literal `cd` commands; a token after a non-literal `cd` (e.g. `cd "$DIR"`) stays conservative and matches only its literal form.
@@ -825,7 +831,7 @@ The governing record is [ADR 0009](https://github.com/gotgenes/pi-packages/blob/
825
831
 
826
832
  #### Symlinked paths
827
833
 
828
- A `path`, `external_directory`, or per-tool file-pattern rule (`read`/`write`/`edit`/`grep`/`find`/`ls`) matches the path **as the agent references it** and the OS-resolved (symlink-followed) path.
834
+ A `path`, `external_directory`, or per-tool file-pattern rule (`read`/`write`/`edit`/`grep`/`find`/`ls`) matches the path **as the agent references it** (for a built-in file tool, the file Pi opens) and the OS-resolved (symlink-followed) path.
829
835
  This matters on macOS, where `/tmp` is a symlink to `/private/tmp`: a rule keyed on `/tmp/*` allows access via `/tmp` even though the access resolves to `/private/tmp`, and a rule keyed on `/private/tmp/*` works too.
830
836
 
831
837
  ```jsonc
@@ -1100,8 +1106,9 @@ A prefix is recognized only when it stands alone or precedes a separator, so a l
1100
1106
 
1101
1107
  The pattern is stored and displayed as written (e.g. `~/development/*`) in logs and approval dialogs.
1102
1108
 
1103
- Path **values** supplied by tool calls and bash commands are expanded the same way.
1104
- This means `~/...`, `$HOME/...`, `${HOME}/...`, and the fully-expanded absolute form all match a single home-anchored pattern: a `read` tool called with path `~/.ssh/config`, `$HOME/.ssh/config`, `${HOME}/.ssh/config`, or `/Users/me/.ssh/config` is all caught by a `"~/.ssh/*": "deny"` rule.
1109
+ Path **values** supplied by bash commands and extension tools are expanded the same way.
1110
+ This means `~/...`, `$HOME/...`, `${HOME}/...`, and the fully-expanded absolute form all match a single home-anchored pattern: `cat ~/.ssh/config`, `cat $HOME/.ssh/config`, or `cat /Users/me/.ssh/config` is caught by a `"~/.ssh/*": "deny"` rule.
1111
+ Pi's built-in file tools expand only `~`, as Pi itself does: a `read` of `~/.ssh/config` or `/Users/me/.ssh/config` is caught, while a `read` of `$HOME/.ssh/config` opens `<cwd>/$HOME/.ssh/config` and is matched as that file.
1105
1112
 
1106
1113
  ---
1107
1114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "38.0.1",
3
+ "version": "38.0.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,7 +1,10 @@
1
+ import { canonicalizePath } from "#src/path/canonicalize-path";
2
+ import type { NativeToolTarget } from "#src/path/native-tool-target";
1
3
  import type { PathFlavor } from "#src/path/path-flavor";
2
4
 
3
5
  import {
4
6
  canonicalNormalizePathForComparison,
7
+ cwdRelativePolicyValues,
5
8
  getPathPolicyValues,
6
9
  normalizePathForComparison,
7
10
  } from "./path-normalization";
@@ -25,7 +28,8 @@ import {
25
28
  * only when it names a location distinct from the lexical form, for
26
29
  * disclosing a symlink target in a prompt or denial message.
27
30
  *
28
- * Construct via {@link forPath} (resolved, with optional cd-folded base) or
31
+ * Construct via {@link forPath} (resolved, with optional cd-folded base),
32
+ * {@link forNativeTarget} (the file a built-in Pi tool opens), or
29
33
  * {@link forLiteral} (literal-only, for an unknown base); the constructor is
30
34
  * private.
31
35
  */
@@ -34,6 +38,7 @@ export class AccessPath {
34
38
  private readonly lexical: string,
35
39
  private readonly matchAliases: readonly string[],
36
40
  private readonly canonical: string,
41
+ private readonly rewritten = false,
37
42
  ) {}
38
43
 
39
44
  /**
@@ -72,16 +77,17 @@ export class AccessPath {
72
77
  }
73
78
 
74
79
  /**
75
- * The canonical (symlink-resolved) form when it names a location distinct
76
- * from the lexical form — for disclosing the resolved target in a prompt or
77
- * denial message. `undefined` when the path is not a symlink (canonical
78
- * equals lexical) or has no canonical (literal-only / empty input).
80
+ * The location the access reaches when it differs from what was typed — for
81
+ * disclosing it in a prompt or denial message: the canonical
82
+ * (symlink-resolved) form when it is distinct from the lexical form, else the
83
+ * lexical form itself when a built-in tool's resolver rewrote the typed
84
+ * spelling ({@link forNativeTarget}). `undefined` otherwise.
79
85
  */
80
86
  resolvedAlias(): string | undefined {
81
- if (!this.canonical || this.canonical === this.lexical) {
82
- return undefined;
87
+ if (this.canonical && this.canonical !== this.lexical) {
88
+ return this.canonical;
83
89
  }
84
- return this.canonical;
90
+ return this.rewritten ? this.lexical : undefined;
85
91
  }
86
92
 
87
93
  /**
@@ -115,6 +121,38 @@ export class AccessPath {
115
121
  );
116
122
  }
117
123
 
124
+ /**
125
+ * Build an `AccessPath` for the file a built-in Pi file tool opens, from its
126
+ * already-resolved {@link NativeToolTarget}.
127
+ *
128
+ * The target is taken as the tool will open it: no literal cleanup (trim,
129
+ * quote strip, `$HOME`), since Pi's resolver applies none. `matchValues()`
130
+ * is the target, its cwd-relative alias, and the tool's relative spelling,
131
+ * plus the canonical form — the same array {@link forPath} builds for a
132
+ * relative or already-normalized spelling the tool does not rewrite. An
133
+ * absolute spelling with `..` or doubled separators keeps no as-typed alias:
134
+ * `/tmp/../etc/passwd` matches as `/etc/passwd`, the file opened, so a rule
135
+ * on `/tmp/*` no longer covers it.
136
+ */
137
+ static forNativeTarget(
138
+ native: NativeToolTarget,
139
+ options: { cwd: string; flavor: PathFlavor },
140
+ ): AccessPath {
141
+ const { cwd, flavor } = options;
142
+ const lexical = flavor.comparable(native.target, cwd);
143
+ const aliases = [
144
+ lexical,
145
+ ...cwdRelativePolicyValues(lexical, cwd, flavor),
146
+ ...(native.relativeSpelling ? [native.relativeSpelling] : []),
147
+ ];
148
+ return new AccessPath(
149
+ lexical,
150
+ [...new Set(aliases)],
151
+ flavor.fold(canonicalizePath(lexical, flavor)),
152
+ native.rewritten,
153
+ );
154
+ }
155
+
118
156
  /**
119
157
  * Build a literal-only `AccessPath` for a path whose effective base is
120
158
  * unknown (a relative bash token after a non-literal `cd`).
@@ -21,7 +21,8 @@ import { classifyToolKind } from "./tool-kind";
21
21
  * For a path-shaped surface (`path`, `external_directory`, or a path-bearing
22
22
  * tool) carrying a non-empty value, it builds an `AccessPath` and emits an
23
23
  * `access-path` intent, so the resolver matches the lexical aliases ∪ canonical
24
- * (symlink-resolved) set — at parity with the gates (#486, #502). An `mcp`
24
+ * (symlink-resolved) set — at parity with the gates (#486, #502); a built-in
25
+ * tool surface resolves the value to the file that tool would open. An `mcp`
25
26
  * query carrying a value evaluates that value as-is: an MCP target is already
26
27
  * a candidate name, and rebuilding proxy input from it would derive only the
27
28
  * status probe `mcp_status`. Every other surface, and any value-less
@@ -39,7 +40,7 @@ export function buildAccessIntentForSurface(
39
40
  return {
40
41
  kind: "access-path",
41
42
  surface,
42
- path: normalizer.forPath(pathValue),
43
+ path: normalizer.forToolPath(surface, pathValue),
43
44
  agentName,
44
45
  };
45
46
  }
@@ -83,17 +83,22 @@ function getAbsolutePathPolicyValues(
83
83
 
84
84
  return [
85
85
  absolute,
86
- ...getCwdRelativePathPolicyValues(absolute, options.cwd, flavor),
86
+ ...(options.cwd
87
+ ? cwdRelativePolicyValues(absolute, options.cwd, flavor)
88
+ : []),
87
89
  ];
88
90
  }
89
91
 
90
- function getCwdRelativePathPolicyValues(
92
+ /**
93
+ * The project-relative alias of an already-absolute comparison value: `[rel]`
94
+ * when `absolute` lies strictly inside `cwd`, otherwise `[]`. Lets a relative
95
+ * rule (`src/*`) match a path however it was spelled.
96
+ */
97
+ export function cwdRelativePolicyValues(
91
98
  absolute: string,
92
- cwd: string | undefined,
99
+ cwd: string,
93
100
  flavor: PathFlavor,
94
101
  ): string[] {
95
- if (!cwd) return [];
96
-
97
102
  const normalizedCwd = normalizePathForComparison(cwd, cwd, flavor);
98
103
  if (!normalizedCwd) return [];
99
104
  if (absolute !== normalizedCwd && !flavor.isWithin(absolute, normalizedCwd)) {
@@ -36,15 +36,20 @@ export function describeExternalDirectoryGate(
36
36
  );
37
37
  if (!externalDirectoryPath) return null;
38
38
 
39
- if (!normalizer.isOutsideWorkingDirectory(externalDirectoryPath)) {
39
+ // The boundary decision and the infrastructure-read containment check use
40
+ // the canonical, symlink-resolved path; pattern matching uses the typed and
41
+ // resolved aliases (#418). A built-in tool's path is the file Pi's resolver
42
+ // opens, so a `file://` spelling of an outside file is judged outside.
43
+ const accessPath = normalizer.forToolPath(
44
+ tcc.toolName,
45
+ externalDirectoryPath,
46
+ );
47
+ if (
48
+ !normalizer.isBoundaryOutsideWorkingDirectory(accessPath.boundaryValue())
49
+ ) {
40
50
  return null;
41
51
  }
42
52
 
43
- // The boundary decision (above) and the infrastructure-read containment
44
- // check (below) use the canonical, symlink-resolved path; pattern matching
45
- // uses the typed and resolved aliases (#418).
46
- const accessPath = normalizer.forPath(externalDirectoryPath);
47
-
48
53
  // ── Pi infrastructure read bypass ──────────────────────────────────────
49
54
  if (normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)) {
50
55
  return {
@@ -41,8 +41,9 @@ export function describePathGate(
41
41
 
42
42
  // Emit an access-path intent so the resolver matches the lexical aliases
43
43
  // *and* the canonical (symlink-resolved) form, the same set
44
- // `external_directory` matches (#418, #486).
45
- const accessPath = normalizer.forPath(filePath);
44
+ // `external_directory` matches (#418, #486). A built-in tool's path is the
45
+ // file Pi's resolver opens, not the spelling the model typed.
46
+ const accessPath = normalizer.forToolPath(tcc.toolName, filePath);
46
47
  const check = resolver.resolve({
47
48
  kind: "access-path",
48
49
  surface,
@@ -64,6 +65,7 @@ export function describePathGate(
64
65
  const payload = buildPathAskPayload({
65
66
  toolName: tcc.toolName,
66
67
  pathValue: filePath,
68
+ resolvedPath: accessPath.resolvedAlias(),
67
69
  agentName: tcc.agentName,
68
70
  matchedPattern: check.matchedPattern,
69
71
  surface,
@@ -205,7 +205,7 @@ export class ToolCallGatePipeline {
205
205
 
206
206
  const filePath = getPathBearingToolPath(tcc.toolName, tcc.input);
207
207
  if (filePath !== null) {
208
- const accessPath = normalizer.forPath(filePath);
208
+ const accessPath = normalizer.forToolPath(tcc.toolName, filePath);
209
209
  return {
210
210
  pathAccess: {
211
211
  path: accessPath,
@@ -0,0 +1,137 @@
1
+ import { homedir } from "node:os";
2
+
3
+ import type { PathFlavor } from "./path-flavor";
4
+
5
+ /**
6
+ * The file one of Pi's built-in file tools (`read`, `write`, `edit`, `ls`,
7
+ * `find`, `grep`) opens for a `path` argument.
8
+ *
9
+ * Pi resolves that argument through its own resolver before touching the disk
10
+ * (`core/tools/path-utils.ts` `resolveToCwd`, plus `resolveReadPath` for
11
+ * `read`), and the resolver rewrites some spellings. A gate that matched the
12
+ * typed spelling would check one file while the tool opened another, so the
13
+ * path gates evaluate this target instead.
14
+ */
15
+ export interface NativeToolTarget {
16
+ /** Absolute path the built-in tool opens. */
17
+ readonly target: string;
18
+ /**
19
+ * True when Pi's resolver changed the spelling in a way the typed path does
20
+ * not show: a Unicode space, a win32 drive mount, a `file://` URL, or a
21
+ * `read` fallback. `@`, `~`, and cwd resolution are not rewrites.
22
+ */
23
+ readonly rewritten: boolean;
24
+ /**
25
+ * The normalized spelling before cwd resolution, when it is relative and no
26
+ * read fallback fired, so a relative rule keeps matching it.
27
+ */
28
+ readonly relativeSpelling?: string;
29
+ }
30
+
31
+ /** What {@link resolveNativeToolTarget} needs besides the typed path. */
32
+ export interface NativeToolTargetOptions {
33
+ cwd: string;
34
+ flavor: PathFlavor;
35
+ /** Whether the tool also tries `read`'s variant spellings. */
36
+ readFallbacks: boolean;
37
+ /** Existence probe, following symlinks like Pi's `access(F_OK)`. */
38
+ exists: (absolutePath: string) => boolean;
39
+ }
40
+
41
+ /**
42
+ * Mirror Pi's built-in tool path resolution, in Pi's order: Unicode spaces,
43
+ * one leading `@`, the win32 drive mount, `~`, a `file://` URL, cwd
44
+ * resolution, then (for `read`) the variant spellings Pi tries when the
45
+ * resolved path does not exist.
46
+ */
47
+ export function resolveNativeToolTarget(
48
+ rawPath: string,
49
+ options: NativeToolTargetOptions,
50
+ ): NativeToolTarget {
51
+ const { cwd, flavor } = options;
52
+ const spelling = normalizeToolPathSpelling(rawPath, flavor);
53
+ const resolved = flavor.impl.resolve(cwd, spelling.value);
54
+ const fallback = options.readFallbacks
55
+ ? findReadVariant(resolved, options.exists)
56
+ : undefined;
57
+ if (fallback !== undefined) {
58
+ return { target: fallback, rewritten: true };
59
+ }
60
+ return {
61
+ target: resolved,
62
+ rewritten: spelling.rewritten,
63
+ ...(flavor.impl.isAbsolute(spelling.value)
64
+ ? {}
65
+ : { relativeSpelling: spelling.value }),
66
+ };
67
+ }
68
+
69
+ /** Pi's `normalizeUnicodeSpaces` class. */
70
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
71
+
72
+ const NARROW_NO_BREAK_SPACE = "\u202F";
73
+
74
+ interface NormalizedSpelling {
75
+ value: string;
76
+ rewritten: boolean;
77
+ }
78
+
79
+ /** Pi's `normalizePath` with `normalizeUnicodeSpaces` and `stripAtPrefix`. */
80
+ function normalizeToolPathSpelling(
81
+ rawPath: string,
82
+ flavor: PathFlavor,
83
+ ): NormalizedSpelling {
84
+ const spaced = rawPath.replace(UNICODE_SPACES, " ");
85
+ const unprefixed = spaced.startsWith("@") ? spaced.slice(1) : spaced;
86
+ const driveMapped = flavor.toolShellPath(unprefixed);
87
+ const rewritten = spaced !== rawPath || driveMapped !== unprefixed;
88
+
89
+ const home = expandToolHome(driveMapped, flavor);
90
+ if (home !== undefined) return { value: home, rewritten };
91
+
92
+ if (driveMapped.startsWith("file://")) {
93
+ try {
94
+ return { value: flavor.fileUrlToPath(driveMapped), rewritten: true };
95
+ } catch {
96
+ // Pi's tool throws here and opens nothing; keep the spelling as typed.
97
+ }
98
+ }
99
+ return { value: driveMapped, rewritten };
100
+ }
101
+
102
+ /**
103
+ * Pi's tilde expansion: `~` alone, `~/`, and on win32 `~\`. Unlike
104
+ * `expandHomePath`, it leaves `$HOME` and `${HOME}` literal.
105
+ */
106
+ function expandToolHome(value: string, flavor: PathFlavor): string | undefined {
107
+ if (value === "~") return homedir();
108
+ const separators = flavor.hasPathSeparator("\\") ? ["~/", "~\\"] : ["~/"];
109
+ if (separators.some((prefix) => value.startsWith(prefix))) {
110
+ return flavor.impl.join(homedir(), value.slice(2));
111
+ }
112
+ return undefined;
113
+ }
114
+
115
+ /**
116
+ * Pi's `resolveReadPath` fallbacks: the first existing of the macOS AM/PM,
117
+ * NFD, curly-quote, and NFD + curly-quote variants, tried only when the
118
+ * resolved path itself does not exist. `undefined` when none applies.
119
+ */
120
+ function findReadVariant(
121
+ resolved: string,
122
+ exists: (absolutePath: string) => boolean,
123
+ ): string | undefined {
124
+ if (exists(resolved)) return undefined;
125
+ const nfd = resolved.normalize("NFD");
126
+ const variants = [
127
+ resolved.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`),
128
+ nfd,
129
+ toCurlyQuotes(resolved),
130
+ toCurlyQuotes(nfd),
131
+ ];
132
+ return variants.find((variant) => variant !== resolved && exists(variant));
133
+ }
134
+
135
+ function toCurlyQuotes(value: string): string {
136
+ return value.replace(/'/g, "\u2019");
137
+ }
@@ -5,7 +5,7 @@ import { isSafeSystemPath } from "./safe-system-paths";
5
5
  * Pure geometry: is `canonicalPath` outside `canonicalCwd`?
6
6
  *
7
7
  * Both operands must already be canonical (symlink-resolved, win32-lowercased)
8
- * — the caller prepares them (see {@link PathNormalizer.isOutsideWorkingDirectory}).
8
+ * — the caller prepares them (see {@link PathNormalizer.isBoundaryOutsideWorkingDirectory}).
9
9
  * This predicate touches no filesystem and does no derivation; the containment
10
10
  * geometry lives on {@link PathFlavor.isWithin}.
11
11
  */
@@ -1,5 +1,6 @@
1
1
  import type { PlatformPath } from "node:path";
2
2
  import { posix as posixPath, win32 as winPath } from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
 
4
5
  import {
5
6
  type BashTokenShape,
@@ -66,6 +67,20 @@ export interface PathFlavor {
66
67
  * every token is an ordinary path, so the shape is always `{ kind: "plain" }`.
67
68
  */
68
69
  bashTokenShape(token: string): BashTokenShape;
70
+ /**
71
+ * The drive translation Pi's built-in file tools apply to their `path`
72
+ * argument: on win32, a Git Bash / MSYS / Cygwin / WSL drive path (`/c/x`,
73
+ * `/mnt/c/x`, `/cygdrive/c/x`) becomes `C:\x`; on POSIX the value is
74
+ * unchanged. Mirrors Pi's `normalizeWindowsShellPath`, which recognizes more
75
+ * mount spellings than a bash token's {@link bashTokenShape}.
76
+ */
77
+ toolShellPath(value: string): string;
78
+ /**
79
+ * Decode a `file://` URL into this platform's path, as Pi's built-in file
80
+ * tools do. Throws like Node's `fileURLToPath` for a URL the platform
81
+ * cannot express (a POSIX URL naming a host).
82
+ */
83
+ fileUrlToPath(url: string): string;
69
84
  }
70
85
 
71
86
  class PlatformPathFlavor implements PathFlavor {
@@ -87,6 +102,14 @@ class PlatformPathFlavor implements PathFlavor {
87
102
  return this.windows ? value.toLowerCase() : value;
88
103
  }
89
104
 
105
+ toolShellPath(value: string): string {
106
+ return this.windows ? toWindowsDrivePath(value) : value;
107
+ }
108
+
109
+ fileUrlToPath(url: string): string {
110
+ return fileURLToPath(url, { windows: this.windows });
111
+ }
112
+
90
113
  comparable(pathValue: string, base: string): string {
91
114
  return this.fold(this.impl.normalize(this.impl.resolve(base, pathValue)));
92
115
  }
@@ -132,3 +155,26 @@ export const win32PathFlavor: PathFlavor = new PlatformPathFlavor(
132
155
  export function pathFlavorForPlatform(platform: NodeJS.Platform): PathFlavor {
133
156
  return platform === "win32" ? win32PathFlavor : posixPathFlavor;
134
157
  }
158
+
159
+ /** A drive path as Pi's file tools spell it: optional WSL/Cygwin mount, a letter, an optional tail. */
160
+ const TOOL_DRIVE_PATH_PATTERN = /^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i;
161
+
162
+ /**
163
+ * Pi's `normalizeWindowsShellPath`: a UNC (`//…`) or backslashed value is
164
+ * already native and passes through, as does anything not shaped like a drive.
165
+ */
166
+ function toWindowsDrivePath(value: string): string {
167
+ if (
168
+ !value.startsWith("/") ||
169
+ value.startsWith("//") ||
170
+ value.includes("\\")
171
+ ) {
172
+ return value;
173
+ }
174
+ const match = TOOL_DRIVE_PATH_PATTERN.exec(value);
175
+ if (!match) return value;
176
+ const [, letter] = match;
177
+ // The tail group is optional, so it is absent for a bare drive (`/c`).
178
+ const tail = match.at(2) ?? "";
179
+ return `${letter.toUpperCase()}:\\${tail.replaceAll("/", "\\")}`;
180
+ }
@@ -1,11 +1,13 @@
1
- import { lstatSync } from "node:fs";
1
+ import { existsSync, lstatSync } from "node:fs";
2
2
  import { AccessPath } from "#src/access-intent/access-path";
3
3
  import {
4
4
  canonicalNormalizePathForComparison,
5
5
  normalizePathForComparison,
6
6
  normalizePathPolicyLiteral,
7
7
  } from "#src/access-intent/path-normalization";
8
+ import { classifyToolKind } from "#src/access-intent/tool-kind";
8
9
  import { deriveApprovalPattern } from "./approval-pattern";
10
+ import { resolveNativeToolTarget } from "./native-tool-target";
9
11
  import { isPathOutsideWorkingDirectory } from "./path-containment";
10
12
  import type { PathFlavor } from "./path-flavor";
11
13
  import { isPiInfrastructureRead } from "./pi-infrastructure-read";
@@ -60,6 +62,33 @@ export class PathNormalizer {
60
62
  });
61
63
  }
62
64
 
65
+ /**
66
+ * Build an AccessPath for a tool call's path argument.
67
+ *
68
+ * A built-in file tool (`read`/`write`/`edit`/`ls`/`find`/`grep`) resolves
69
+ * the argument through Pi's own resolver, which rewrites some spellings and,
70
+ * for `read`, tries variant spellings that exist; the AccessPath is the file
71
+ * the tool opens ({@link resolveNativeToolTarget}). Every other tool's path
72
+ * is its own to interpret, so it keeps {@link forPath}.
73
+ *
74
+ * The variant probe is `existsSync` (`access(F_OK)`, following symlinks),
75
+ * matching Pi's; unlike {@link entryExists}'s `lstat`, a dangling symlink
76
+ * at the typed spelling does not count, so Pi tries the variants and so do we.
77
+ */
78
+ forToolPath(toolName: string, rawPath: string): AccessPath {
79
+ if (classifyToolKind(toolName) !== "path") return this.forPath(rawPath);
80
+ const native = resolveNativeToolTarget(rawPath, {
81
+ cwd: this.cwd,
82
+ flavor: this.flavor,
83
+ readFallbacks: toolName.trim() === "read",
84
+ exists: (absolutePath) => existsSync(absolutePath),
85
+ });
86
+ return AccessPath.forNativeTarget(native, {
87
+ cwd: this.cwd,
88
+ flavor: this.flavor,
89
+ });
90
+ }
91
+
63
92
  /** Build a literal-only AccessPath (unknown base after a non-literal `cd`). */
64
93
  forLiteral(literal: string): AccessPath {
65
94
  return AccessPath.forLiteral(literal);
@@ -157,28 +186,13 @@ export class PathNormalizer {
157
186
  return this.flavor.isWithin(pathValue, directory);
158
187
  }
159
188
 
160
- /** Canonical (symlink-resolved) outside-cwd test against the baked cwd. */
161
- isOutsideWorkingDirectory(pathValue: string): boolean {
162
- const canonicalPath = canonicalNormalizePathForComparison(
163
- pathValue,
164
- this.cwd,
165
- this.flavor,
166
- );
167
- return isPathOutsideWorkingDirectory(
168
- canonicalPath,
169
- this.canonicalCwd,
170
- this.flavor,
171
- );
172
- }
173
-
174
189
  /**
175
190
  * Outside-cwd test for an already-canonical boundary value (from
176
191
  * {@link AccessPath.boundaryValue}), against the baked cwd.
177
192
  *
178
- * Unlike {@link isOutsideWorkingDirectory}, it does not re-derive the
179
- * canonical form — the caller passes a value the {@link AccessPath} already
180
- * canonicalized, so a device's preserved `/dev/null` reaches the pure check's
181
- * `isSafeSystemPath` exclusion intact.
193
+ * It does not re-derive the canonical form — the caller passes a value the
194
+ * {@link AccessPath} already canonicalized, so a device's preserved
195
+ * `/dev/null` reaches the pure check's `isSafeSystemPath` exclusion intact.
182
196
  */
183
197
  isBoundaryOutsideWorkingDirectory(canonicalPath: string): boolean {
184
198
  return isPathOutsideWorkingDirectory(
@@ -14,6 +14,12 @@ interface PathAskFacts {
14
14
  toolName: string;
15
15
  /** The path as the caller typed it — what the user recognizes. */
16
16
  pathValue: string;
17
+ /**
18
+ * Where the typed path lands when that is somewhere else: its symlink
19
+ * target, or the file a built-in tool's resolver opens for a rewritten
20
+ * spelling.
21
+ */
22
+ resolvedPath?: string;
17
23
  agentName: string | null;
18
24
  matchedPattern?: string;
19
25
  /**
@@ -26,13 +32,11 @@ interface PathAskFacts {
26
32
 
27
33
  /** A tool ask gated by an explicit `path`-family rule. */
28
34
  export function buildPathAskPayload(facts: PathAskFacts): PromptPayload {
29
- return pathPayload("path", facts, []);
35
+ return pathPayload("path", facts, resolvedAliasEvidence(facts.resolvedPath));
30
36
  }
31
37
 
32
- /** The facts the external-directory gate adds: the boundary and the alias. */
38
+ /** The facts the external-directory gate adds: the boundary. */
33
39
  interface ExternalDirectoryAskFacts extends PathAskFacts {
34
- /** The canonical location, when it names somewhere other than the typed path. */
35
- resolvedPath?: string;
36
40
  /** The working directory the path escapes. */
37
41
  cwd: string;
38
42
  }