@j0hanz/filesystem-mcp 2.8.0 → 2.9.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 (134) hide show
  1. package/README.md +32 -26
  2. package/dist/cli-help.d.ts.map +1 -1
  3. package/dist/cli-help.js +10 -1
  4. package/dist/cli-help.js.map +1 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +10 -3
  7. package/dist/cli.js.map +1 -1
  8. package/dist/core/concurrency.d.ts +4 -4
  9. package/dist/core/concurrency.d.ts.map +1 -1
  10. package/dist/core/concurrency.js +1 -1
  11. package/dist/core/concurrency.js.map +1 -1
  12. package/dist/core/config.d.ts +2 -0
  13. package/dist/core/config.d.ts.map +1 -1
  14. package/dist/core/config.js.map +1 -1
  15. package/dist/core/cursor.d.ts +8 -0
  16. package/dist/core/cursor.d.ts.map +1 -1
  17. package/dist/core/cursor.js +14 -0
  18. package/dist/core/cursor.js.map +1 -1
  19. package/dist/core/file-uri.d.ts +12 -3
  20. package/dist/core/file-uri.d.ts.map +1 -1
  21. package/dist/core/file-uri.js +35 -7
  22. package/dist/core/file-uri.js.map +1 -1
  23. package/dist/core/fmt.d.ts +2 -0
  24. package/dist/core/fmt.d.ts.map +1 -1
  25. package/dist/core/fmt.js +16 -0
  26. package/dist/core/fmt.js.map +1 -1
  27. package/dist/core/fs.d.ts +16 -1
  28. package/dist/core/fs.d.ts.map +1 -1
  29. package/dist/core/fs.js +191 -72
  30. package/dist/core/fs.js.map +1 -1
  31. package/dist/core/glob.d.ts +8 -0
  32. package/dist/core/glob.d.ts.map +1 -1
  33. package/dist/core/glob.js +84 -11
  34. package/dist/core/glob.js.map +1 -1
  35. package/dist/core/input-required.d.ts +7 -0
  36. package/dist/core/input-required.d.ts.map +1 -1
  37. package/dist/core/input-required.js +13 -0
  38. package/dist/core/input-required.js.map +1 -1
  39. package/dist/core/path-completer.d.ts.map +1 -1
  40. package/dist/core/path-completer.js +5 -3
  41. package/dist/core/path-completer.js.map +1 -1
  42. package/dist/core/path-utils.d.ts +51 -12
  43. package/dist/core/path-utils.d.ts.map +1 -1
  44. package/dist/core/path-utils.js +69 -29
  45. package/dist/core/path-utils.js.map +1 -1
  46. package/dist/core/path.d.ts +108 -21
  47. package/dist/core/path.d.ts.map +1 -1
  48. package/dist/core/path.js +436 -83
  49. package/dist/core/path.js.map +1 -1
  50. package/dist/core/read.d.ts +13 -3
  51. package/dist/core/read.d.ts.map +1 -1
  52. package/dist/core/read.js +46 -40
  53. package/dist/core/read.js.map +1 -1
  54. package/dist/core/schema.d.ts +9 -6
  55. package/dist/core/schema.d.ts.map +1 -1
  56. package/dist/core/schema.js +22 -19
  57. package/dist/core/schema.js.map +1 -1
  58. package/dist/core/search.d.ts +6 -0
  59. package/dist/core/search.d.ts.map +1 -1
  60. package/dist/core/search.js +159 -33
  61. package/dist/core/search.js.map +1 -1
  62. package/dist/core/sensitive.d.ts.map +1 -1
  63. package/dist/core/sensitive.js +71 -29
  64. package/dist/core/sensitive.js.map +1 -1
  65. package/dist/core/store.d.ts +1 -0
  66. package/dist/core/store.d.ts.map +1 -1
  67. package/dist/core/store.js +3 -0
  68. package/dist/core/store.js.map +1 -1
  69. package/dist/core/util.d.ts +12 -1
  70. package/dist/core/util.d.ts.map +1 -1
  71. package/dist/core/util.js +24 -1
  72. package/dist/core/util.js.map +1 -1
  73. package/dist/prompts.d.ts +1 -1
  74. package/dist/prompts.d.ts.map +1 -1
  75. package/dist/prompts.js +1 -1
  76. package/dist/prompts.js.map +1 -1
  77. package/dist/resources.d.ts +1 -2
  78. package/dist/resources.d.ts.map +1 -1
  79. package/dist/resources.js +20 -13
  80. package/dist/resources.js.map +1 -1
  81. package/dist/server.js +2 -2
  82. package/dist/server.js.map +1 -1
  83. package/dist/tools/create.d.ts.map +1 -1
  84. package/dist/tools/create.js +122 -45
  85. package/dist/tools/create.js.map +1 -1
  86. package/dist/tools/define.d.ts +8 -4
  87. package/dist/tools/define.d.ts.map +1 -1
  88. package/dist/tools/define.js +36 -9
  89. package/dist/tools/define.js.map +1 -1
  90. package/dist/tools/delete.d.ts.map +1 -1
  91. package/dist/tools/delete.js +57 -17
  92. package/dist/tools/delete.js.map +1 -1
  93. package/dist/tools/diff.js +1 -1
  94. package/dist/tools/diff.js.map +1 -1
  95. package/dist/tools/edit.d.ts.map +1 -1
  96. package/dist/tools/edit.js +14 -12
  97. package/dist/tools/edit.js.map +1 -1
  98. package/dist/tools/find-files.d.ts.map +1 -1
  99. package/dist/tools/find-files.js +20 -20
  100. package/dist/tools/find-files.js.map +1 -1
  101. package/dist/tools/index.d.ts +1 -1
  102. package/dist/tools/index.d.ts.map +1 -1
  103. package/dist/tools/index.js +1 -1
  104. package/dist/tools/index.js.map +1 -1
  105. package/dist/tools/list.d.ts.map +1 -1
  106. package/dist/tools/list.js +142 -73
  107. package/dist/tools/list.js.map +1 -1
  108. package/dist/tools/move.d.ts.map +1 -1
  109. package/dist/tools/move.js +81 -36
  110. package/dist/tools/move.js.map +1 -1
  111. package/dist/tools/patch.js +13 -6
  112. package/dist/tools/patch.js.map +1 -1
  113. package/dist/tools/read.d.ts.map +1 -1
  114. package/dist/tools/read.js +114 -45
  115. package/dist/tools/read.js.map +1 -1
  116. package/dist/tools/replace-text.d.ts.map +1 -1
  117. package/dist/tools/replace-text.js +49 -35
  118. package/dist/tools/replace-text.js.map +1 -1
  119. package/dist/tools/search-text.d.ts.map +1 -1
  120. package/dist/tools/search-text.js +20 -49
  121. package/dist/tools/search-text.js.map +1 -1
  122. package/dist/tools/stat.d.ts.map +1 -1
  123. package/dist/tools/stat.js +26 -8
  124. package/dist/tools/stat.js.map +1 -1
  125. package/dist/transport/http-policy.d.ts +3 -2
  126. package/dist/transport/http-policy.d.ts.map +1 -1
  127. package/dist/transport/http-policy.js +3 -2
  128. package/dist/transport/http-policy.js.map +1 -1
  129. package/dist/transport/http.d.ts.map +1 -1
  130. package/dist/transport/http.js +2 -2
  131. package/dist/transport/http.js.map +1 -1
  132. package/dist/transport/stdio.js +1 -1
  133. package/dist/transport/stdio.js.map +1 -1
  134. package/package.json +5 -5
package/dist/core/path.js CHANGED
@@ -1,4 +1,4 @@
1
- import { lstat, readlink, realpath, stat } from 'node:fs/promises';
1
+ import { lstat, readdir, readlink, realpath, stat } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
4
  import { runExclusive, withAbort } from "./concurrency.js";
@@ -6,9 +6,15 @@ import { cli } from "./config.js";
6
6
  import { classifyCauseChain, ErrorCode, FsError, isFsError, isNotFoundErrno, isSkippableErrno, rethrowIfAborted, SKIPPABLE_FS_CODES, } from "./errors.js";
7
7
  import { Logger } from "./observability.js";
8
8
  import { findProjectRoot, isUnsafeCwdPath, resolveConfiguredDirs } from "./path-discovery.js";
9
- import { getReservedDeviceNameForPath, IS_WINDOWS, isPathInsideDirectory, isPathWithinDirectories, isSamePath, isWindowsDriveRelativePath, normalizeAllowedDirectory, normalizePath, parseTrueEnvFlag, toPosixPath, } from "./path-utils.js";
9
+ import { escapeNonAscii, expandHome, getReservedDeviceNameForPath, IS_WINDOWS, isPathInsideDirectory, isPathWithinDirectories, isSamePath, isUnresolvableDrivePath, isWindowsDrivePath, isWindowsDriveRelativePath, mayHaveEquivalentSpelling, normalizeAllowedDirectory, normalizePath, toPosixRelative, unicodeMatchKey, } from "./path-utils.js";
10
10
  import { SensitiveMatcher } from "./sensitive.js";
11
- import { ROOTS_TIMEOUT_MS } from "./util.js";
11
+ import { parseTrueEnvFlag, ROOTS_TIMEOUT_MS } from "./util.js";
12
+ // Allowed-directory assembly and the PathGuard that enforces it. The
13
+ // sensitive-file denylist lives in sensitive.ts, and the character-level
14
+ // primitives (IS_WINDOWS, isAlpha, isSlash, toPosixPath) live in
15
+ // path-utils.ts.
16
+ /** Links followed, or respelling rounds run, before a path is refused (Linux ELOOP's 40). */
17
+ const MAX_SYMLINK_HOPS = 40;
12
18
  /**
13
19
  * The real path of `normalizedPath`, or the one it would have if it does not
14
20
  * exist yet: realpath of its nearest existing ancestor plus the missing suffix.
@@ -53,10 +59,6 @@ async function partitionRootsWithin(roots, bounds, signal) {
53
59
  }
54
60
  return { kept, dropped };
55
61
  }
56
- /** `path.relative` with forward slashes, so displayed paths match across platforms. */
57
- export function toPosixRelative(from, to) {
58
- return toPosixPath(relative(from, to));
59
- }
60
62
  // ---------------------------------------------------------------------------
61
63
  // Resolver pipeline
62
64
  // ---------------------------------------------------------------------------
@@ -80,24 +82,31 @@ async function resolveRealPath(normalized, signal) {
80
82
  throw error;
81
83
  }
82
84
  }
85
+ async function resolveRootSpellings(dirs, signal) {
86
+ const roots = normalizeAllowedDirectories(dirs);
87
+ const reals = await Promise.all(roots.map((dir) => resolveRealPath(dir, signal)));
88
+ return roots.map((root, i) => {
89
+ const real = reals[i];
90
+ return { root, spellings: real && !isSamePath(real, root) ? [root, real] : [root] };
91
+ });
92
+ }
83
93
  export async function resolveAllowedDirectoriesState(dirs, signal) {
84
- const primary = normalizeAllowedDirectories(dirs);
85
- const reals = await Promise.all(primary.map((dir) => resolveRealPath(dir, signal)));
86
- return [
87
- ...new Set(primary.flatMap((dir, i) => {
88
- const real = reals[i];
89
- return real && !isSamePath(real, dir) ? [dir, real] : [dir];
90
- })),
91
- ];
94
+ const entries = await resolveRootSpellings(dirs, signal);
95
+ return [...new Set(entries.flatMap((entry) => entry.spellings))];
92
96
  }
93
97
  /**
94
98
  * TOCTOU note: validation returns a plain string and the fs operation is a
95
99
  * later syscall, so a symlink swapped in between could redirect it. Reads and
96
- * appends in core/fs.ts and core/read.ts open with O_NOFOLLOW (final component)
97
- * and re-check the fd's real path on Linux (ancestor swap). Residual: write
98
- * paths that resolve a parent by name (`rename`, `mkdir`, `rm`, `cp`) would
99
- * need openat()-relative-to-dirfd, which Node lacks; non-Linux skips the fd
100
- * re-check.
100
+ * appends in core/fs.ts open with O_NOFOLLOW (final component) and re-check the
101
+ * fd's real path on Linux (ancestor swap). stat's directory count opens and
102
+ * re-checks the directory, and reads entries through that descriptor, only on
103
+ * Linux with procfs; elsewhere it reads the directory by path, so the swap
104
+ * window stays there, like the read path's non-Linux residual. Residuals: the
105
+ * directory walker behind list and the search tools (core/glob.ts) still
106
+ * resolves each entry by path, as the count does for a symlinked entry;
107
+ * write paths that resolve a parent by name
108
+ * (`rename`, `mkdir`, `rm`, `cp`) would need openat()-relative-to-dirfd, which
109
+ * Node lacks; non-Linux skips the fd re-check.
101
110
  */
102
111
  export class PathGuard {
103
112
  allowedDirectoriesState;
@@ -119,6 +128,18 @@ export class PathGuard {
119
128
  * field — so the recompute can rebuild from it without re-filtering.
120
129
  */
121
130
  grantedDirectories = [];
131
+ /**
132
+ * The grants a user approved for a path a tool asked about, as opposed to
133
+ * the workspace roots a client declared. Relative paths never resolve
134
+ * against these: one approval must not change what a relative path names,
135
+ * neither in a later round of the same call nor in a later call.
136
+ */
137
+ accessGrants = [];
138
+ /**
139
+ * Where relative paths resolve: the roots minus `accessGrants`, each with
140
+ * its spellings, plus every spelling flattened for containment.
141
+ */
142
+ relativeBases;
122
143
  rootBoundaries = [];
123
144
  options;
124
145
  constructor(options) {
@@ -127,10 +148,23 @@ export class PathGuard {
127
148
  /**
128
149
  * `expanded` is the containment set (each root plus its realpath alias);
129
150
  * `roots` is what a caller choosing "the root" sees, one entry per root.
151
+ * `relativeBases` lists the roots relative paths resolve against, with
152
+ * their spellings; without it, every root, each spelled only as given.
130
153
  */
131
- initialize(expanded, roots = expanded) {
154
+ initialize(expanded, roots = expanded, relativeBases) {
132
155
  this.allowedDirectoriesState = normalizeAllowedDirectories(expanded);
133
156
  this.rootsState = normalizeAllowedDirectories(roots);
157
+ const entries = (relativeBases ?? this.rootsState.map((root) => ({ root, spellings: [root] }))).map((entry) => ({
158
+ root: normalizeAllowedDirectory(entry.root),
159
+ spellings: normalizeAllowedDirectories(entry.spellings),
160
+ }));
161
+ this.relativeBases = {
162
+ entries,
163
+ roots: entries.map((entry) => entry.root),
164
+ expanded: relativeBases
165
+ ? normalizeAllowedDirectories(entries.flatMap((entry) => entry.spellings))
166
+ : this.allowedDirectoriesState,
167
+ };
134
168
  }
135
169
  isInitialized() {
136
170
  return this.allowedDirectoriesState !== undefined;
@@ -170,24 +204,18 @@ export class PathGuard {
170
204
  return this.sensitive.isSensitive(filePath);
171
205
  }
172
206
  /**
173
- * True when `entryPath` is both within the guard's allowed directories and
174
- * not sensitive, checking the requested AND resolved paths. EVERY entry is
175
- * realpath-resolved via validateExistingPathDetailed: a symlinked ancestor
176
- * directory pointing outside the sandbox would otherwise pass the lexical
177
- * containment check (fs.glob follows symlinks and yields external entries as
178
- * non-symlink dirents). validateExistingPathDetailed re-checks containment on
179
- * the real path against this.allowedDirectoriesState (the guard's full
180
- * allowed set — a superset of the single-root `bounds` callers pass) and
181
- * re-checks sensitivity on the resolved target, throwing ACCESS_DENIED for
182
- * escapes/sensitive, which the catch below turns into a filter. Skippable
183
- * errno/fs errors return false (the entry is filtered, not fatal). The
184
- * accepted TOCTOU window is documented at the class docstring above.
207
+ * True when a walked entry is inside the allowed directories and not sensitive,
208
+ * on both its requested and resolved paths. Every entry is realpath-resolved: a
209
+ * symlinked ancestor pointing outside the sandbox would otherwise pass the
210
+ * lexical check (fs.glob follows symlinks and yields external entries as
211
+ * non-symlink dirents). Escapes and sensitive targets throw ACCESS_DENIED and
212
+ * skippable errors filter the entry; anything else is fatal. The accepted
213
+ * TOCTOU window is documented on this class.
185
214
  */
186
215
  async isEntryAccessible(entryPath) {
187
- const isSensitive = (requestedPath, resolvedPath) => this.isSensitive(requestedPath) || this.isSensitive(resolvedPath);
188
216
  try {
189
- const validated = await this.validateExistingPathDetailed(entryPath);
190
- return !isSensitive(validated.requestedPath, validated.resolvedPath);
217
+ const { requestedPath, resolvedPath } = await this.validateExistingPathDetailed(entryPath);
218
+ return !this.isSensitive(requestedPath) && !this.isSensitive(resolvedPath);
191
219
  }
192
220
  catch (error) {
193
221
  if (isFsError(error)) {
@@ -265,7 +293,16 @@ export class PathGuard {
265
293
  if (!requested) {
266
294
  continue;
267
295
  }
296
+ // Validation refuses it for its shape whatever is granted, so asking the
297
+ // user to grant the working directory it resolves under is pointless.
298
+ if (isUnresolvableDrivePath(requested) ||
299
+ (IS_WINDOWS && getReservedDeviceNameForPath(requested) !== undefined)) {
300
+ continue;
301
+ }
268
302
  const normalized = normalizePath(requested);
303
+ if (this.isSensitive(requested) || this.isSensitive(normalized)) {
304
+ continue;
305
+ }
269
306
  if (isPathWithinDirectories(normalized, allowedDirs)) {
270
307
  continue;
271
308
  }
@@ -277,7 +314,7 @@ export class PathGuard {
277
314
  if (await this.isUnsafeGrantTarget(targetDir)) {
278
315
  continue;
279
316
  }
280
- if (grantDirs.includes(targetDir)) {
317
+ if (grantDirs.some((dir) => isSamePath(dir, targetDir))) {
281
318
  continue;
282
319
  }
283
320
  if (!(await this.isWithinBoundary(targetDir))) {
@@ -297,8 +334,12 @@ export class PathGuard {
297
334
  * pre-checks passed. The caller leaves a refused path to fail with
298
335
  * ACCESS_DENIED during the operation. Idempotent: re-granting an already
299
336
  * allowed directory is a no-op via the dedup in `initialize`.
337
+ *
338
+ * `kind` says where the grant came from: `access` for a path the user
339
+ * approved when a tool asked, `workspace` for a root the client declared.
340
+ * Only a workspace root becomes a place relative paths resolve.
300
341
  */
301
- async applyGrant(targetDir) {
342
+ async applyGrant(targetDir, kind = 'access') {
302
343
  // Defense-in-depth: even a tampered/accepted grant cannot extend roots into
303
344
  // an unsafe path. The precheckAccess guard already refuses to offer these;
304
345
  // this catches a grant that arrived by another route — the legacy stdio
@@ -314,22 +355,32 @@ export class PathGuard {
314
355
  // nothing in this body may take the lock again.
315
356
  return runExclusive(this, async () => {
316
357
  const previous = this.grantedDirectories;
317
- this.grantedDirectories = [...previous, normalizePath(targetDir)];
358
+ const previousAccess = this.accessGrants;
359
+ const dir = normalizePath(targetDir);
360
+ this.grantedDirectories = [...previous, dir];
361
+ this.accessGrants =
362
+ kind === 'access'
363
+ ? [...previousAccess, dir]
364
+ : previousAccess.filter((grant) => !isSamePath(grant, dir));
365
+ const rollBack = () => {
366
+ this.grantedDirectories = previous;
367
+ this.accessGrants = previousAccess;
368
+ };
318
369
  try {
319
370
  await this.recomputeAllowedDirectories();
320
371
  }
321
372
  catch (error) {
322
373
  // A failed recompute leaves the guard with its previous, consistent view.
323
- this.grantedDirectories = previous;
374
+ rollBack();
324
375
  throw error;
325
376
  }
326
377
  // Verify rather than assume: a boundary that no longer covers this
327
378
  // directory would drop it in the recompute above, and reporting success
328
379
  // for a grant that did not land is exactly the failure this replaces.
329
- if (isPathWithinDirectories(normalizePath(targetDir), this.getAllowedDirectories())) {
380
+ if (isPathWithinDirectories(dir, this.getAllowedDirectories())) {
330
381
  return true;
331
382
  }
332
- this.grantedDirectories = previous;
383
+ rollBack();
333
384
  await this.recomputeAllowedDirectories();
334
385
  return false;
335
386
  });
@@ -349,6 +400,10 @@ export class PathGuard {
349
400
  if (!this.allowedDirectoriesState) {
350
401
  throw new FsError(ErrorCode.UNKNOWN, 'PathGuard not initialized. Call initialize() first.', requestedPath);
351
402
  }
403
+ // Before the drive-relative check, whose advice (`use C:\path`) is wrong here.
404
+ if (!IS_WINDOWS && isWindowsDrivePath(requestedPath)) {
405
+ throw new FsError(ErrorCode.INVALID_INPUT, `Windows-style path received on a POSIX host (${process.platform}). Use a POSIX path, such as one under an allowed root.`, requestedPath, undefined, 'Run list_roots to see the allowed directories.');
406
+ }
352
407
  if (isWindowsDriveRelativePath(requestedPath)) {
353
408
  throw new FsError(ErrorCode.INVALID_INPUT, 'Drive-relative paths are not allowed. Use C:\\path or C:/path instead of C:path.', requestedPath);
354
409
  }
@@ -405,12 +460,29 @@ export class PathGuard {
405
460
  }
406
461
  async validateExistingPathDetailed(requestedPath) {
407
462
  const { normalizedRequested, allowedDirs, accessDeniedHint } = this.validateAccess(requestedPath);
463
+ let spelled = normalizedRequested;
408
464
  let realPath;
409
465
  try {
410
- realPath = await realpath(normalizedRequested);
466
+ realPath = await realpath(spelled);
411
467
  }
412
468
  catch (error) {
413
- realPath = await this.handleRealpathError(error, normalizedRequested, allowedDirs, accessDeniedHint, requestedPath);
469
+ // Not on disk as typed; it may be under another Unicode spelling (#101).
470
+ const respelled = isNotFoundErrno(error)
471
+ ? await this.respellMissing(spelled, allowedDirs, accessDeniedHint, requestedPath)
472
+ : undefined;
473
+ if (respelled === undefined) {
474
+ realPath = await this.handleRealpathError(error, spelled, allowedDirs, accessDeniedHint, requestedPath);
475
+ }
476
+ else {
477
+ this.assertNotSensitiveFile(respelled, requestedPath);
478
+ spelled = respelled;
479
+ try {
480
+ realPath = await realpath(spelled);
481
+ }
482
+ catch (retryError) {
483
+ realPath = await this.handleRealpathError(retryError, spelled, allowedDirs, accessDeniedHint, requestedPath);
484
+ }
485
+ }
414
486
  }
415
487
  const normalizedReal = normalizePath(realPath);
416
488
  if (!isPathWithinDirectories(normalizedReal, allowedDirs)) {
@@ -421,9 +493,9 @@ export class PathGuard {
421
493
  // sees the requested/normalized path, not the symlink target.
422
494
  this.assertNotSensitiveFile(normalizedReal, requestedPath);
423
495
  return {
424
- requestedPath: normalizedRequested,
496
+ requestedPath: spelled,
425
497
  resolvedPath: normalizedReal,
426
- isSymlink: !isSamePath(normalizedRequested, normalizedReal),
498
+ isSymlink: !isSamePath(spelled, normalizedReal),
427
499
  };
428
500
  }
429
501
  async validateExistingDirectory(requestedPath) {
@@ -457,6 +529,134 @@ export class PathGuard {
457
529
  }
458
530
  return root;
459
531
  }
532
+ /**
533
+ * Where the relative paths a caller sent should point, keyed by the spelling
534
+ * sent; a path that keeps its meaning is absent. A relative path resolves
535
+ * against the working directory, as it always has, while that lands inside
536
+ * a root. When it does not — a desktop host starts the server outside the
537
+ * project, and an HTTP client never shares its working directory — it
538
+ * resolves against the roots: the one root, or with several, the one it
539
+ * exists under. Searches print paths relative to the root (`resultPaths`),
540
+ * so this is what lets one tool's output feed the next.
541
+ *
542
+ * "Roots" here are the configured ones and those a client declared, never an
543
+ * access grant (see `accessGrants`). Absolute, `~` and drive paths keep their
544
+ * meaning, as does every path when there is no such root; validation judges
545
+ * them as before. A Windows drive path on POSIX is left for validation to
546
+ * refuse: joined to a root, `C:\x` would be an absolute path that passes. A
547
+ * relative path that exists under several roots, or under none of several,
548
+ * is refused rather than guessed.
549
+ */
550
+ async resolveRelativePaths(paths) {
551
+ const resolved = new Map();
552
+ const bases = this.relativeBases;
553
+ if (!bases || bases.roots.length === 0)
554
+ return resolved;
555
+ for (const requested of new Set(paths)) {
556
+ if (requested.trim().length === 0 ||
557
+ isUnresolvableDrivePath(requested) ||
558
+ isAbsolute(expandHome(requested)) ||
559
+ isPathWithinDirectories(normalizePath(requested), bases.expanded)) {
560
+ continue;
561
+ }
562
+ resolved.set(requested, await this.resolveAgainstRoots(requested, bases.roots));
563
+ }
564
+ return resolved;
565
+ }
566
+ async resolveAgainstRoots(requested, roots) {
567
+ const candidates = roots.map((root) => join(root, requested));
568
+ const [first] = candidates;
569
+ if (first === undefined || candidates.length === 1)
570
+ return first ?? requested;
571
+ // Each candidate is probed through validation itself, so the probe learns
572
+ // nothing validation would not tell the caller: a denylisted name is
573
+ // refused before the filesystem is touched, and a path through a symlink
574
+ // out of the roots is refused whether or not its target exists.
575
+ const existing = [];
576
+ let refused;
577
+ for (const candidate of candidates) {
578
+ try {
579
+ await this.validateExistingPath(candidate);
580
+ existing.push(candidate);
581
+ }
582
+ catch (error) {
583
+ if (isFsError(error) && error.code === ErrorCode.NOT_FOUND)
584
+ continue;
585
+ refused ??= candidate;
586
+ }
587
+ }
588
+ const [only] = existing;
589
+ if (only !== undefined && existing.length === 1)
590
+ return only;
591
+ // Validation then refuses it again, with its own reason.
592
+ if (existing.length === 0 && refused !== undefined)
593
+ return refused;
594
+ throw new FsError(ErrorCode.INVALID_INPUT, existing.length === 0
595
+ ? `Relative path exists under none of the ${String(roots.length)} roots, so it names no one file. Pass an absolute path under one of: ${roots.join(', ')}.`
596
+ : `Relative path exists under more than one root: ${existing.join(', ')}. Pass the absolute path you mean.`, requested);
597
+ }
598
+ /**
599
+ * How a tool prints the paths a walk of `walkedDir` found, so the caller can
600
+ * pass each one straight back: relative to the root holding `walkedDir`
601
+ * when `resolveRelativePaths` maps that spelling back to the same path,
602
+ * absolute otherwise. A scoped search of `src` prints `src/a.ts`, not
603
+ * `a.ts`, which would name `<root>/a.ts`. `root` is the root as configured,
604
+ * as `list_roots` shows it, even when the walk went through its realpath.
605
+ *
606
+ * The absolute fallback covers a working directory in a subfolder of a root
607
+ * (relative paths resolve there), a directory under an access grant only
608
+ * (relative paths never resolve there), several roots with the working
609
+ * directory outside them (a name two roots share would read back as
610
+ * ambiguous, and which names are shared can change between the print and
611
+ * the read), and a name that would not read back as given: `~`, blank, or a
612
+ * POSIX name holding a backslash or starting with a drive (`C:`).
613
+ */
614
+ resultPaths(walkedDir) {
615
+ const walked = normalizePath(walkedDir);
616
+ const bases = this.relativeBases;
617
+ let found;
618
+ for (const entry of bases?.entries ?? []) {
619
+ for (const spelling of entry.spellings) {
620
+ if (isPathInsideDirectory(spelling, walked) &&
621
+ (found === undefined || spelling.length > found.spelling.length)) {
622
+ found = { root: entry.root, spelling };
623
+ }
624
+ }
625
+ }
626
+ if (found === undefined || bases === undefined) {
627
+ return { root: walked, atRoot: false, show: (absolutePath) => absolutePath };
628
+ }
629
+ const { root, spelling } = found;
630
+ const allowed = bases.expanded;
631
+ const severalRoots = bases.roots.length > 1;
632
+ return {
633
+ root,
634
+ atRoot: isSamePath(spelling, walked),
635
+ show: (absolutePath) => {
636
+ // Windows separators become `/`, which every tool accepts there; on
637
+ // POSIX a backslash is part of a name and must stay one.
638
+ const rel = IS_WINDOWS
639
+ ? toPosixRelative(spelling, absolutePath)
640
+ : relative(spelling, absolutePath);
641
+ if (rel.trim().length === 0 ||
642
+ rel === '..' ||
643
+ rel.startsWith('../') ||
644
+ isAbsolute(rel) ||
645
+ isUnresolvableDrivePath(rel) ||
646
+ expandHome(rel) !== rel ||
647
+ !isSamePath(resolve(spelling, rel), absolutePath)) {
648
+ return absolutePath;
649
+ }
650
+ // Mirrors resolveRelativePaths: a spelling that lands inside a root
651
+ // from the working directory resolves there, so it must land here.
652
+ const fromCwd = normalizePath(rel);
653
+ if (isPathWithinDirectories(fromCwd, allowed)) {
654
+ return isSamePath(fromCwd, absolutePath) ? rel : absolutePath;
655
+ }
656
+ return severalRoots ? absolutePath : rel;
657
+ },
658
+ };
659
+ }
460
660
  isAllowedRoot(normalizedPath) {
461
661
  // isSamePath case-folds on case-insensitive filesystems (win + darwin),
462
662
  // matching the containment checks used everywhere else in the guard. The
@@ -478,6 +678,7 @@ export class PathGuard {
478
678
  async resolveNearestExistingAncestor(requestedPath, currentPath) {
479
679
  const missingSegments = [];
480
680
  let current = currentPath;
681
+ let hops = 0;
481
682
  for (;;) {
482
683
  try {
483
684
  const realAncestor = normalizePath(await realpath(current));
@@ -490,17 +691,30 @@ export class PathGuard {
490
691
  try {
491
692
  const stats = await lstat(current);
492
693
  if (stats.isSymbolicLink()) {
694
+ if (++hops > MAX_SYMLINK_HOPS) {
695
+ throw new FsError(ErrorCode.SYMLINK_NOT_ALLOWED, 'Too many symbolic links encountered', requestedPath);
696
+ }
697
+ const allowedDirs = this.getAllowedDirectories();
698
+ const linkParent = normalizePath(await realpath(dirname(current)));
699
+ if (!isPathWithinDirectories(linkParent, allowedDirs)) {
700
+ this.throwAccessDenied(requestedPath);
701
+ }
493
702
  const target = await readlink(current);
494
- const resolvedTarget = isAbsolute(target) ? target : resolve(dirname(current), target);
703
+ const resolvedTarget = isAbsolute(target) ? target : resolve(linkParent, target);
495
704
  const normalizedTarget = normalizePath(resolvedTarget);
496
- const allowedDirs = this.getAllowedDirectories();
497
705
  if (!isPathWithinDirectories(normalizedTarget, allowedDirs)) {
498
706
  this.throwAccessDenied(requestedPath);
499
707
  }
708
+ current =
709
+ missingSegments.length === 0
710
+ ? normalizedTarget
711
+ : normalizePath(join(normalizedTarget, ...missingSegments.reverse()));
712
+ missingSegments.length = 0;
713
+ continue;
500
714
  }
501
715
  }
502
716
  catch (lstatErr) {
503
- if (isFsError(lstatErr) && lstatErr.code === ErrorCode.ACCESS_DENIED) {
717
+ if (isFsError(lstatErr)) {
504
718
  throw lstatErr;
505
719
  }
506
720
  // ENOENT is expected during ancestor walk — the entry simply doesn't exist.
@@ -518,12 +732,126 @@ export class PathGuard {
518
732
  }
519
733
  }
520
734
  }
735
+ /**
736
+ * `normalized` re-spelled with the on-disk names of the components that are
737
+ * missing as typed but present under another Unicode spelling (#101), or
738
+ * `undefined` when no component changed. Only names below a root are
739
+ * matched, never the root's own. Each directory is scanned only after its
740
+ * real path is checked to be inside the roots, and each matched entry is
741
+ * realpathed and checked again, so a link found this way cannot lead out
742
+ * (`resolveLast: false` leaves the last one unresolved, for delete). The
743
+ * first component with no match ends the walk; it and the rest stay as
744
+ * typed, so a write still creates them. The result is a spelling only:
745
+ * the caller runs its own containment and denylist checks on it.
746
+ */
747
+ async respellMissing(normalized, allowedDirs, accessDeniedHint, requestedPath, resolveLast = true) {
748
+ const root = this.allowedRootContaining(normalized);
749
+ if (root === undefined || !mayHaveEquivalentSpelling(normalized.slice(root.length))) {
750
+ return undefined;
751
+ }
752
+ const missing = [];
753
+ let ancestor = normalized;
754
+ let real;
755
+ for (;;) {
756
+ try {
757
+ real = normalizePath(await realpath(ancestor));
758
+ break;
759
+ }
760
+ catch (error) {
761
+ if (!isNotFoundErrno(error) || isSamePath(ancestor, root))
762
+ return undefined;
763
+ missing.unshift(basename(ancestor));
764
+ ancestor = dirname(ancestor);
765
+ }
766
+ }
767
+ // An ancestor that resolves out of the roots is the caller's to refuse.
768
+ if (!isPathWithinDirectories(real, allowedDirs))
769
+ return undefined;
770
+ const spelled = [];
771
+ for (const [index, name] of missing.entries()) {
772
+ let onDisk = name;
773
+ if (mayHaveEquivalentSpelling(name)) {
774
+ // The scan must not reveal what the denylist hides: a name it covers
775
+ // under the resolved parent is refused as the exact spelling is, and
776
+ // a directory `list` would refuse is never read.
777
+ this.assertNotSensitiveFile(join(real, ...missing.slice(index)), requestedPath);
778
+ if (this.isSensitive(real))
779
+ break;
780
+ onDisk = await this.findEquivalentEntry(real, name, requestedPath);
781
+ }
782
+ if (onDisk === undefined)
783
+ break;
784
+ spelled.push(onDisk);
785
+ if (!resolveLast && index === missing.length - 1)
786
+ break;
787
+ try {
788
+ real = normalizePath(await realpath(join(ancestor, ...spelled)));
789
+ }
790
+ catch {
791
+ // Missing as typed, or a dangling link: the caller's checks judge it.
792
+ break;
793
+ }
794
+ if (!isPathWithinDirectories(real, allowedDirs)) {
795
+ this.throwAccessDenied(requestedPath, accessDeniedHint);
796
+ }
797
+ }
798
+ if (spelled.every((name, index) => name === missing[index]))
799
+ return undefined;
800
+ return join(ancestor, ...spelled, ...missing.slice(spelled.length));
801
+ }
802
+ /**
803
+ * The entry of `realDir` that `name` names: `name` itself when it is
804
+ * there, else the one entry with the same `unicodeMatchKey`. Two or more
805
+ * such entries are refused, never chosen between. `undefined` when none
806
+ * matches or the directory cannot be read.
807
+ */
808
+ async findEquivalentEntry(realDir, name, requestedPath) {
809
+ let entries;
810
+ try {
811
+ entries = await readdir(realDir);
812
+ }
813
+ catch {
814
+ return undefined;
815
+ }
816
+ if (entries.includes(name))
817
+ return name;
818
+ const key = unicodeMatchKey(name);
819
+ const matches = entries.filter((entry) => unicodeMatchKey(entry) === key);
820
+ // A match the denylist covers is refused here, as a lone one would be by
821
+ // the caller, so the ambiguity error below never lists a hidden name.
822
+ for (const match of matches)
823
+ this.assertNotSensitiveFile(join(realDir, match), requestedPath);
824
+ if (matches.length > 1) {
825
+ throw new FsError(ErrorCode.INVALID_INPUT, `Ambiguous Unicode path component "${name}": ${String(matches.length)} entries in ${realDir} match it (${matches.map(escapeNonAscii).join(', ')}). Pass the exact name, using \\u escapes for the characters shown that way.`, requestedPath);
826
+ }
827
+ return matches[0];
828
+ }
521
829
  async validatePathForWrite(requestedPath) {
522
830
  const { normalizedRequested, allowedDirs, accessDeniedHint } = this.validateAccess(requestedPath);
523
- const { realAncestor, resolvedTarget } = await this.resolveNearestExistingAncestor(requestedPath, normalizedRequested);
524
- if (!isPathWithinDirectories(realAncestor, allowedDirs) ||
525
- !isPathWithinDirectories(resolvedTarget, allowedDirs)) {
526
- this.throwAccessDenied(requestedPath, accessDeniedHint);
831
+ let { realAncestor, resolvedTarget } = await this.resolveNearestExistingAncestor(requestedPath, normalizedRequested);
832
+ const assertInside = () => {
833
+ if (!isPathWithinDirectories(realAncestor, allowedDirs) ||
834
+ !isPathWithinDirectories(resolvedTarget, allowedDirs)) {
835
+ this.throwAccessDenied(requestedPath, accessDeniedHint);
836
+ }
837
+ };
838
+ assertInside();
839
+ // Part of it is not on disk as typed; it may be under another Unicode
840
+ // spelling (#101). A followed link may also name a missing target spelled
841
+ // differently on disk, and respelling a parent can expose such a link, so
842
+ // repeat until the resolution settles, re-running the checks each round.
843
+ let candidate = normalizedRequested;
844
+ for (let round = 0; realAncestor !== resolvedTarget && round < MAX_SYMLINK_HOPS; round++) {
845
+ const respelled = (await this.respellMissing(candidate, allowedDirs, accessDeniedHint, requestedPath)) ??
846
+ (isSamePath(resolvedTarget, candidate)
847
+ ? undefined
848
+ : await this.respellMissing(resolvedTarget, allowedDirs, accessDeniedHint, requestedPath));
849
+ if (respelled === undefined)
850
+ break;
851
+ this.assertNotSensitiveFile(respelled, requestedPath);
852
+ ({ realAncestor, resolvedTarget } = await this.resolveNearestExistingAncestor(requestedPath, respelled));
853
+ assertInside();
854
+ candidate = respelled;
527
855
  }
528
856
  // Re-check the resolved target: a symlink inside an allowed root may point
529
857
  // at a sensitive file. Writing through such a link must be blocked too.
@@ -541,7 +869,25 @@ export class PathGuard {
541
869
  if (this.isAllowedRoot(normalizedRequested)) {
542
870
  throw new FsError(ErrorCode.ACCESS_DENIED, 'Deleting a workspace root directory is not allowed', requestedPath);
543
871
  }
544
- const parent = dirname(normalizedRequested);
872
+ // Probe the final component before the parent, so a target that is not on
873
+ // disk as typed can be re-spelled (#101) before either is checked.
874
+ let target = normalizedRequested;
875
+ let stats;
876
+ try {
877
+ stats = await lstat(target);
878
+ }
879
+ catch (error) {
880
+ // The link itself is deleted, so the last component stays unresolved.
881
+ const respelled = isNotFoundErrno(error)
882
+ ? await this.respellMissing(target, allowedDirs, accessDeniedHint, requestedPath, false)
883
+ : undefined;
884
+ if (respelled !== undefined) {
885
+ this.assertNotSensitiveFile(respelled, requestedPath);
886
+ target = respelled;
887
+ stats = await lstat(target).catch(() => undefined);
888
+ }
889
+ }
890
+ const parent = dirname(target);
545
891
  let realParent;
546
892
  try {
547
893
  realParent = await realpath(parent);
@@ -553,36 +899,38 @@ export class PathGuard {
553
899
  if (!isPathWithinDirectories(normalizedRealParent, allowedDirs)) {
554
900
  this.throwAccessDenied(requestedPath, accessDeniedHint);
555
901
  }
556
- // Resolve the final component when it exists. For deletion, we ONLY
557
- // block if the real target is outside the sandbox IF the target is
558
- // NOT a symlink. Deleting a symlink is safe even if it points outside.
902
+ // A target that does not exist needs only the parent check above.
903
+ if (stats === undefined)
904
+ return target;
905
+ // For deletion, we ONLY block if the real target is outside the sandbox
906
+ // IF the target is NOT a symlink. Deleting a symlink is safe even if it
907
+ // points outside.
908
+ if (stats.isSymbolicLink()) {
909
+ // Symlink: check link sensitivity but don't resolve target.
910
+ // The parent check above ensures the link itself is in an allowed root.
911
+ // Spelled through the validated real parent, so every path to one link
912
+ // (an aliased parent, another Unicode spelling) names it the same way.
913
+ const link = join(normalizedRealParent, basename(target));
914
+ this.assertNotSensitiveFile(target, requestedPath);
915
+ this.assertNotSensitiveFile(link, requestedPath);
916
+ return link;
917
+ }
918
+ // Not a symlink: resolve to catch path escapes (e.g. /allowed/dir/../../etc)
919
+ // and block sensitive files. A probe failure (ENOENT and friends) falls
920
+ // back to the parent check, which is sufficient for a path that does not
921
+ // exist.
922
+ let realTarget;
559
923
  try {
560
- const stats = await lstat(normalizedRequested);
561
- if (stats.isSymbolicLink()) {
562
- // Symlink: check link sensitivity but don't resolve target.
563
- // The parent check above ensures the link itself is in an allowed root.
564
- this.assertNotSensitiveFile(normalizedRequested, requestedPath);
565
- return normalizedRequested;
566
- }
567
- // Not a symlink: resolve to catch path escapes (e.g. /allowed/dir/../../etc)
568
- // and block sensitive files.
569
- const realTarget = normalizePath(await realpath(normalizedRequested));
570
- if (!isPathWithinDirectories(realTarget, allowedDirs)) {
571
- this.throwAccessDenied(requestedPath, accessDeniedHint);
572
- }
573
- this.assertNotSensitiveFile(realTarget, requestedPath);
574
- return realTarget;
924
+ realTarget = normalizePath(await realpath(target));
575
925
  }
576
- catch (error) {
577
- // A denial raised inside this block (out-of-root real target, or a
578
- // sensitive file reached through a symlinked parent) must propagate.
579
- // Only a probe failure — ENOENT and friends — falls through to the
580
- // parent check, which is sufficient for a path that does not exist.
581
- if (isFsError(error)) {
582
- throw error;
583
- }
926
+ catch {
927
+ return target;
928
+ }
929
+ if (!isPathWithinDirectories(realTarget, allowedDirs)) {
930
+ this.throwAccessDenied(requestedPath, accessDeniedHint);
584
931
  }
585
- return normalizedRequested;
932
+ this.assertNotSensitiveFile(realTarget, requestedPath);
933
+ return realTarget;
586
934
  }
587
935
  async recomputeAllowedDirectories() {
588
936
  const cliAllowedDirs = normalizeAllowedDirectories(this.options?.cliAllowedDirs ?? []);
@@ -625,12 +973,17 @@ export class PathGuard {
625
973
  .kept;
626
974
  }
627
975
  const combined = [...baseline, ...grantsToInclude];
628
- const nextState = await resolveAllowedDirectoriesState(combined, signal);
976
+ const spellings = await resolveRootSpellings(combined, signal);
977
+ const nextState = [...new Set(spellings.flatMap((entry) => entry.spellings))];
978
+ // A configured root stays a base even when it was also granted.
979
+ const accessGrants = this.accessGrants;
980
+ const bases = spellings.filter((entry) => baseline.some((dir) => isSamePath(dir, entry.root)) ||
981
+ !accessGrants.some((grant) => isSamePath(grant, entry.root)));
629
982
  // Commit boundaries and the allowed set together, after every await has
630
983
  // resolved, so a rejecting recompute leaves the guard's previous,
631
984
  // consistent view intact.
632
985
  this.rootBoundaries = boundaries;
633
- this.initialize(nextState, combined);
986
+ this.initialize(nextState, combined, bases);
634
987
  }
635
988
  }
636
989
  //# sourceMappingURL=path.js.map