@j0hanz/filesystem-mcp 2.8.1 → 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 (113) 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/file-uri.d.ts +12 -3
  16. package/dist/core/file-uri.d.ts.map +1 -1
  17. package/dist/core/file-uri.js +35 -7
  18. package/dist/core/file-uri.js.map +1 -1
  19. package/dist/core/fmt.d.ts +2 -0
  20. package/dist/core/fmt.d.ts.map +1 -1
  21. package/dist/core/fmt.js +16 -0
  22. package/dist/core/fmt.js.map +1 -1
  23. package/dist/core/fs.d.ts +16 -1
  24. package/dist/core/fs.d.ts.map +1 -1
  25. package/dist/core/fs.js +127 -32
  26. package/dist/core/fs.js.map +1 -1
  27. package/dist/core/glob.d.ts +8 -0
  28. package/dist/core/glob.d.ts.map +1 -1
  29. package/dist/core/glob.js +84 -11
  30. package/dist/core/glob.js.map +1 -1
  31. package/dist/core/path-completer.d.ts.map +1 -1
  32. package/dist/core/path-completer.js +5 -3
  33. package/dist/core/path-completer.js.map +1 -1
  34. package/dist/core/path-utils.d.ts +42 -6
  35. package/dist/core/path-utils.d.ts.map +1 -1
  36. package/dist/core/path-utils.js +56 -10
  37. package/dist/core/path-utils.js.map +1 -1
  38. package/dist/core/path.d.ts +111 -7
  39. package/dist/core/path.d.ts.map +1 -1
  40. package/dist/core/path.js +453 -65
  41. package/dist/core/path.js.map +1 -1
  42. package/dist/core/read.d.ts +6 -0
  43. package/dist/core/read.d.ts.map +1 -1
  44. package/dist/core/read.js +26 -3
  45. package/dist/core/read.js.map +1 -1
  46. package/dist/core/schema.d.ts +9 -6
  47. package/dist/core/schema.d.ts.map +1 -1
  48. package/dist/core/schema.js +22 -19
  49. package/dist/core/schema.js.map +1 -1
  50. package/dist/core/search.d.ts +6 -0
  51. package/dist/core/search.d.ts.map +1 -1
  52. package/dist/core/search.js +159 -33
  53. package/dist/core/search.js.map +1 -1
  54. package/dist/core/sensitive.d.ts.map +1 -1
  55. package/dist/core/sensitive.js +64 -15
  56. package/dist/core/sensitive.js.map +1 -1
  57. package/dist/core/store.d.ts +1 -0
  58. package/dist/core/store.d.ts.map +1 -1
  59. package/dist/core/store.js +3 -0
  60. package/dist/core/store.js.map +1 -1
  61. package/dist/core/util.d.ts +6 -1
  62. package/dist/core/util.d.ts.map +1 -1
  63. package/dist/core/util.js +8 -1
  64. package/dist/core/util.js.map +1 -1
  65. package/dist/resources.d.ts.map +1 -1
  66. package/dist/resources.js +18 -9
  67. package/dist/resources.js.map +1 -1
  68. package/dist/server.js +1 -1
  69. package/dist/server.js.map +1 -1
  70. package/dist/tools/create.d.ts.map +1 -1
  71. package/dist/tools/create.js +121 -43
  72. package/dist/tools/create.js.map +1 -1
  73. package/dist/tools/define.d.ts +8 -4
  74. package/dist/tools/define.d.ts.map +1 -1
  75. package/dist/tools/define.js +36 -9
  76. package/dist/tools/define.js.map +1 -1
  77. package/dist/tools/delete.d.ts.map +1 -1
  78. package/dist/tools/delete.js +50 -11
  79. package/dist/tools/delete.js.map +1 -1
  80. package/dist/tools/diff.js +1 -1
  81. package/dist/tools/diff.js.map +1 -1
  82. package/dist/tools/edit.d.ts.map +1 -1
  83. package/dist/tools/edit.js +14 -12
  84. package/dist/tools/edit.js.map +1 -1
  85. package/dist/tools/find-files.d.ts.map +1 -1
  86. package/dist/tools/find-files.js +15 -9
  87. package/dist/tools/find-files.js.map +1 -1
  88. package/dist/tools/list.d.ts.map +1 -1
  89. package/dist/tools/list.js +138 -86
  90. package/dist/tools/list.js.map +1 -1
  91. package/dist/tools/move.d.ts.map +1 -1
  92. package/dist/tools/move.js +72 -26
  93. package/dist/tools/move.js.map +1 -1
  94. package/dist/tools/patch.js +13 -6
  95. package/dist/tools/patch.js.map +1 -1
  96. package/dist/tools/read.d.ts.map +1 -1
  97. package/dist/tools/read.js +114 -45
  98. package/dist/tools/read.js.map +1 -1
  99. package/dist/tools/replace-text.d.ts.map +1 -1
  100. package/dist/tools/replace-text.js +49 -35
  101. package/dist/tools/replace-text.js.map +1 -1
  102. package/dist/tools/search-text.d.ts.map +1 -1
  103. package/dist/tools/search-text.js +15 -33
  104. package/dist/tools/search-text.js.map +1 -1
  105. package/dist/tools/stat.d.ts.map +1 -1
  106. package/dist/tools/stat.js +26 -8
  107. package/dist/tools/stat.js.map +1 -1
  108. package/dist/transport/http.d.ts.map +1 -1
  109. package/dist/transport/http.js +1 -0
  110. package/dist/transport/http.js.map +1 -1
  111. package/dist/transport/stdio.js +1 -1
  112. package/dist/transport/stdio.js.map +1 -1
  113. package/package.json +5 -5
package/dist/core/path.js CHANGED
@@ -1,14 +1,20 @@
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
- import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
4
  import { runExclusive, withAbort } from "./concurrency.js";
5
5
  import { cli } from "./config.js";
6
- import { classifyCauseChain, ErrorCode, FsError, isFsError, isNotFoundErrno, rethrowIfAborted, } from "./errors.js";
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, } 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
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.
@@ -76,24 +82,31 @@ async function resolveRealPath(normalized, signal) {
76
82
  throw error;
77
83
  }
78
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
+ }
79
93
  export async function resolveAllowedDirectoriesState(dirs, signal) {
80
- const primary = normalizeAllowedDirectories(dirs);
81
- const reals = await Promise.all(primary.map((dir) => resolveRealPath(dir, signal)));
82
- return [
83
- ...new Set(primary.flatMap((dir, i) => {
84
- const real = reals[i];
85
- return real && !isSamePath(real, dir) ? [dir, real] : [dir];
86
- })),
87
- ];
94
+ const entries = await resolveRootSpellings(dirs, signal);
95
+ return [...new Set(entries.flatMap((entry) => entry.spellings))];
88
96
  }
89
97
  /**
90
98
  * TOCTOU note: validation returns a plain string and the fs operation is a
91
99
  * later syscall, so a symlink swapped in between could redirect it. Reads and
92
- * appends in core/fs.ts open with O_NOFOLLOW (final component)
93
- * and re-check the fd's real path on Linux (ancestor swap). Residual: write
94
- * paths that resolve a parent by name (`rename`, `mkdir`, `rm`, `cp`) would
95
- * need openat()-relative-to-dirfd, which Node lacks; non-Linux skips the fd
96
- * 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.
97
110
  */
98
111
  export class PathGuard {
99
112
  allowedDirectoriesState;
@@ -115,6 +128,18 @@ export class PathGuard {
115
128
  * field — so the recompute can rebuild from it without re-filtering.
116
129
  */
117
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;
118
143
  rootBoundaries = [];
119
144
  options;
120
145
  constructor(options) {
@@ -123,10 +148,23 @@ export class PathGuard {
123
148
  /**
124
149
  * `expanded` is the containment set (each root plus its realpath alias);
125
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.
126
153
  */
127
- initialize(expanded, roots = expanded) {
154
+ initialize(expanded, roots = expanded, relativeBases) {
128
155
  this.allowedDirectoriesState = normalizeAllowedDirectories(expanded);
129
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
+ };
130
168
  }
131
169
  isInitialized() {
132
170
  return this.allowedDirectoriesState !== undefined;
@@ -165,6 +203,31 @@ export class PathGuard {
165
203
  isSensitive(filePath) {
166
204
  return this.sensitive.isSensitive(filePath);
167
205
  }
206
+ /**
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.
214
+ */
215
+ async isEntryAccessible(entryPath) {
216
+ try {
217
+ const { requestedPath, resolvedPath } = await this.validateExistingPathDetailed(entryPath);
218
+ return !this.isSensitive(requestedPath) && !this.isSensitive(resolvedPath);
219
+ }
220
+ catch (error) {
221
+ if (isFsError(error)) {
222
+ if (SKIPPABLE_FS_CODES.has(error.code))
223
+ return false;
224
+ throw error;
225
+ }
226
+ if (isSkippableErrno(error))
227
+ return false;
228
+ throw error;
229
+ }
230
+ }
168
231
  async validateExistingPath(requestedPath) {
169
232
  const details = await this.validateExistingPathDetailed(requestedPath);
170
233
  return details.resolvedPath;
@@ -230,7 +293,16 @@ export class PathGuard {
230
293
  if (!requested) {
231
294
  continue;
232
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
+ }
233
302
  const normalized = normalizePath(requested);
303
+ if (this.isSensitive(requested) || this.isSensitive(normalized)) {
304
+ continue;
305
+ }
234
306
  if (isPathWithinDirectories(normalized, allowedDirs)) {
235
307
  continue;
236
308
  }
@@ -242,7 +314,7 @@ export class PathGuard {
242
314
  if (await this.isUnsafeGrantTarget(targetDir)) {
243
315
  continue;
244
316
  }
245
- if (grantDirs.includes(targetDir)) {
317
+ if (grantDirs.some((dir) => isSamePath(dir, targetDir))) {
246
318
  continue;
247
319
  }
248
320
  if (!(await this.isWithinBoundary(targetDir))) {
@@ -262,8 +334,12 @@ export class PathGuard {
262
334
  * pre-checks passed. The caller leaves a refused path to fail with
263
335
  * ACCESS_DENIED during the operation. Idempotent: re-granting an already
264
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.
265
341
  */
266
- async applyGrant(targetDir) {
342
+ async applyGrant(targetDir, kind = 'access') {
267
343
  // Defense-in-depth: even a tampered/accepted grant cannot extend roots into
268
344
  // an unsafe path. The precheckAccess guard already refuses to offer these;
269
345
  // this catches a grant that arrived by another route — the legacy stdio
@@ -279,22 +355,32 @@ export class PathGuard {
279
355
  // nothing in this body may take the lock again.
280
356
  return runExclusive(this, async () => {
281
357
  const previous = this.grantedDirectories;
282
- 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
+ };
283
369
  try {
284
370
  await this.recomputeAllowedDirectories();
285
371
  }
286
372
  catch (error) {
287
373
  // A failed recompute leaves the guard with its previous, consistent view.
288
- this.grantedDirectories = previous;
374
+ rollBack();
289
375
  throw error;
290
376
  }
291
377
  // Verify rather than assume: a boundary that no longer covers this
292
378
  // directory would drop it in the recompute above, and reporting success
293
379
  // for a grant that did not land is exactly the failure this replaces.
294
- if (isPathWithinDirectories(normalizePath(targetDir), this.getAllowedDirectories())) {
380
+ if (isPathWithinDirectories(dir, this.getAllowedDirectories())) {
295
381
  return true;
296
382
  }
297
- this.grantedDirectories = previous;
383
+ rollBack();
298
384
  await this.recomputeAllowedDirectories();
299
385
  return false;
300
386
  });
@@ -314,6 +400,10 @@ export class PathGuard {
314
400
  if (!this.allowedDirectoriesState) {
315
401
  throw new FsError(ErrorCode.UNKNOWN, 'PathGuard not initialized. Call initialize() first.', requestedPath);
316
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
+ }
317
407
  if (isWindowsDriveRelativePath(requestedPath)) {
318
408
  throw new FsError(ErrorCode.INVALID_INPUT, 'Drive-relative paths are not allowed. Use C:\\path or C:/path instead of C:path.', requestedPath);
319
409
  }
@@ -370,12 +460,29 @@ export class PathGuard {
370
460
  }
371
461
  async validateExistingPathDetailed(requestedPath) {
372
462
  const { normalizedRequested, allowedDirs, accessDeniedHint } = this.validateAccess(requestedPath);
463
+ let spelled = normalizedRequested;
373
464
  let realPath;
374
465
  try {
375
- realPath = await realpath(normalizedRequested);
466
+ realPath = await realpath(spelled);
376
467
  }
377
468
  catch (error) {
378
- 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
+ }
379
486
  }
380
487
  const normalizedReal = normalizePath(realPath);
381
488
  if (!isPathWithinDirectories(normalizedReal, allowedDirs)) {
@@ -386,9 +493,9 @@ export class PathGuard {
386
493
  // sees the requested/normalized path, not the symlink target.
387
494
  this.assertNotSensitiveFile(normalizedReal, requestedPath);
388
495
  return {
389
- requestedPath: normalizedRequested,
496
+ requestedPath: spelled,
390
497
  resolvedPath: normalizedReal,
391
- isSymlink: !isSamePath(normalizedRequested, normalizedReal),
498
+ isSymlink: !isSamePath(spelled, normalizedReal),
392
499
  };
393
500
  }
394
501
  async validateExistingDirectory(requestedPath) {
@@ -422,6 +529,134 @@ export class PathGuard {
422
529
  }
423
530
  return root;
424
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
+ }
425
660
  isAllowedRoot(normalizedPath) {
426
661
  // isSamePath case-folds on case-insensitive filesystems (win + darwin),
427
662
  // matching the containment checks used everywhere else in the guard. The
@@ -443,6 +678,7 @@ export class PathGuard {
443
678
  async resolveNearestExistingAncestor(requestedPath, currentPath) {
444
679
  const missingSegments = [];
445
680
  let current = currentPath;
681
+ let hops = 0;
446
682
  for (;;) {
447
683
  try {
448
684
  const realAncestor = normalizePath(await realpath(current));
@@ -455,17 +691,30 @@ export class PathGuard {
455
691
  try {
456
692
  const stats = await lstat(current);
457
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
+ }
458
702
  const target = await readlink(current);
459
- const resolvedTarget = isAbsolute(target) ? target : resolve(dirname(current), target);
703
+ const resolvedTarget = isAbsolute(target) ? target : resolve(linkParent, target);
460
704
  const normalizedTarget = normalizePath(resolvedTarget);
461
- const allowedDirs = this.getAllowedDirectories();
462
705
  if (!isPathWithinDirectories(normalizedTarget, allowedDirs)) {
463
706
  this.throwAccessDenied(requestedPath);
464
707
  }
708
+ current =
709
+ missingSegments.length === 0
710
+ ? normalizedTarget
711
+ : normalizePath(join(normalizedTarget, ...missingSegments.reverse()));
712
+ missingSegments.length = 0;
713
+ continue;
465
714
  }
466
715
  }
467
716
  catch (lstatErr) {
468
- if (isFsError(lstatErr) && lstatErr.code === ErrorCode.ACCESS_DENIED) {
717
+ if (isFsError(lstatErr)) {
469
718
  throw lstatErr;
470
719
  }
471
720
  // ENOENT is expected during ancestor walk — the entry simply doesn't exist.
@@ -483,12 +732,126 @@ export class PathGuard {
483
732
  }
484
733
  }
485
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
+ }
486
829
  async validatePathForWrite(requestedPath) {
487
830
  const { normalizedRequested, allowedDirs, accessDeniedHint } = this.validateAccess(requestedPath);
488
- const { realAncestor, resolvedTarget } = await this.resolveNearestExistingAncestor(requestedPath, normalizedRequested);
489
- if (!isPathWithinDirectories(realAncestor, allowedDirs) ||
490
- !isPathWithinDirectories(resolvedTarget, allowedDirs)) {
491
- 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;
492
855
  }
493
856
  // Re-check the resolved target: a symlink inside an allowed root may point
494
857
  // at a sensitive file. Writing through such a link must be blocked too.
@@ -506,7 +869,25 @@ export class PathGuard {
506
869
  if (this.isAllowedRoot(normalizedRequested)) {
507
870
  throw new FsError(ErrorCode.ACCESS_DENIED, 'Deleting a workspace root directory is not allowed', requestedPath);
508
871
  }
509
- 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);
510
891
  let realParent;
511
892
  try {
512
893
  realParent = await realpath(parent);
@@ -518,36 +899,38 @@ export class PathGuard {
518
899
  if (!isPathWithinDirectories(normalizedRealParent, allowedDirs)) {
519
900
  this.throwAccessDenied(requestedPath, accessDeniedHint);
520
901
  }
521
- // Resolve the final component when it exists. For deletion, we ONLY
522
- // block if the real target is outside the sandbox IF the target is
523
- // 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;
524
923
  try {
525
- const stats = await lstat(normalizedRequested);
526
- if (stats.isSymbolicLink()) {
527
- // Symlink: check link sensitivity but don't resolve target.
528
- // The parent check above ensures the link itself is in an allowed root.
529
- this.assertNotSensitiveFile(normalizedRequested, requestedPath);
530
- return normalizedRequested;
531
- }
532
- // Not a symlink: resolve to catch path escapes (e.g. /allowed/dir/../../etc)
533
- // and block sensitive files.
534
- const realTarget = normalizePath(await realpath(normalizedRequested));
535
- if (!isPathWithinDirectories(realTarget, allowedDirs)) {
536
- this.throwAccessDenied(requestedPath, accessDeniedHint);
537
- }
538
- this.assertNotSensitiveFile(realTarget, requestedPath);
539
- return realTarget;
924
+ realTarget = normalizePath(await realpath(target));
540
925
  }
541
- catch (error) {
542
- // A denial raised inside this block (out-of-root real target, or a
543
- // sensitive file reached through a symlinked parent) must propagate.
544
- // Only a probe failure — ENOENT and friends — falls through to the
545
- // parent check, which is sufficient for a path that does not exist.
546
- if (isFsError(error)) {
547
- throw error;
548
- }
926
+ catch {
927
+ return target;
928
+ }
929
+ if (!isPathWithinDirectories(realTarget, allowedDirs)) {
930
+ this.throwAccessDenied(requestedPath, accessDeniedHint);
549
931
  }
550
- return normalizedRequested;
932
+ this.assertNotSensitiveFile(realTarget, requestedPath);
933
+ return realTarget;
551
934
  }
552
935
  async recomputeAllowedDirectories() {
553
936
  const cliAllowedDirs = normalizeAllowedDirectories(this.options?.cliAllowedDirs ?? []);
@@ -590,12 +973,17 @@ export class PathGuard {
590
973
  .kept;
591
974
  }
592
975
  const combined = [...baseline, ...grantsToInclude];
593
- 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)));
594
982
  // Commit boundaries and the allowed set together, after every await has
595
983
  // resolved, so a rejecting recompute leaves the guard's previous,
596
984
  // consistent view intact.
597
985
  this.rootBoundaries = boundaries;
598
- this.initialize(nextState, combined);
986
+ this.initialize(nextState, combined, bases);
599
987
  }
600
988
  }
601
989
  //# sourceMappingURL=path.js.map