@j0hanz/filesystem-mcp 2.8.1 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -27
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +10 -1
- package/dist/cli-help.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -3
- package/dist/cli.js.map +1 -1
- package/dist/core/concurrency.d.ts +4 -4
- package/dist/core/concurrency.d.ts.map +1 -1
- package/dist/core/concurrency.js +1 -1
- package/dist/core/concurrency.js.map +1 -1
- package/dist/core/config.d.ts +2 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +7 -1
- package/dist/core/errors.js.map +1 -1
- package/dist/core/file-uri.d.ts +12 -3
- package/dist/core/file-uri.d.ts.map +1 -1
- package/dist/core/file-uri.js +35 -7
- package/dist/core/file-uri.js.map +1 -1
- package/dist/core/fmt.d.ts +2 -0
- package/dist/core/fmt.d.ts.map +1 -1
- package/dist/core/fmt.js +16 -0
- package/dist/core/fmt.js.map +1 -1
- package/dist/core/fs.d.ts +16 -1
- package/dist/core/fs.d.ts.map +1 -1
- package/dist/core/fs.js +127 -32
- package/dist/core/fs.js.map +1 -1
- package/dist/core/glob.d.ts +15 -0
- package/dist/core/glob.d.ts.map +1 -1
- package/dist/core/glob.js +119 -14
- package/dist/core/glob.js.map +1 -1
- package/dist/core/path-completer.d.ts.map +1 -1
- package/dist/core/path-completer.js +5 -3
- package/dist/core/path-completer.js.map +1 -1
- package/dist/core/path-utils.d.ts +42 -6
- package/dist/core/path-utils.d.ts.map +1 -1
- package/dist/core/path-utils.js +56 -10
- package/dist/core/path-utils.js.map +1 -1
- package/dist/core/path.d.ts +111 -7
- package/dist/core/path.d.ts.map +1 -1
- package/dist/core/path.js +453 -65
- package/dist/core/path.js.map +1 -1
- package/dist/core/read.d.ts +6 -0
- package/dist/core/read.d.ts.map +1 -1
- package/dist/core/read.js +26 -3
- package/dist/core/read.js.map +1 -1
- package/dist/core/schema.d.ts +9 -6
- package/dist/core/schema.d.ts.map +1 -1
- package/dist/core/schema.js +22 -19
- package/dist/core/schema.js.map +1 -1
- package/dist/core/search.d.ts +6 -0
- package/dist/core/search.d.ts.map +1 -1
- package/dist/core/search.js +161 -33
- package/dist/core/search.js.map +1 -1
- package/dist/core/sensitive.d.ts.map +1 -1
- package/dist/core/sensitive.js +64 -15
- package/dist/core/sensitive.js.map +1 -1
- package/dist/core/store.d.ts +1 -0
- package/dist/core/store.d.ts.map +1 -1
- package/dist/core/store.js +3 -0
- package/dist/core/store.js.map +1 -1
- package/dist/core/util.d.ts +12 -1
- package/dist/core/util.d.ts.map +1 -1
- package/dist/core/util.js +16 -1
- package/dist/core/util.js.map +1 -1
- package/dist/resources.d.ts.map +1 -1
- package/dist/resources.js +20 -11
- package/dist/resources.js.map +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/create.d.ts.map +1 -1
- package/dist/tools/create.js +121 -43
- package/dist/tools/create.js.map +1 -1
- package/dist/tools/define.d.ts +8 -4
- package/dist/tools/define.d.ts.map +1 -1
- package/dist/tools/define.js +36 -9
- package/dist/tools/define.js.map +1 -1
- package/dist/tools/delete.d.ts.map +1 -1
- package/dist/tools/delete.js +50 -11
- package/dist/tools/delete.js.map +1 -1
- package/dist/tools/diff.js +1 -1
- package/dist/tools/diff.js.map +1 -1
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +14 -12
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/find-files.d.ts.map +1 -1
- package/dist/tools/find-files.js +15 -9
- package/dist/tools/find-files.js.map +1 -1
- package/dist/tools/list.d.ts.map +1 -1
- package/dist/tools/list.js +141 -86
- package/dist/tools/list.js.map +1 -1
- package/dist/tools/move.d.ts.map +1 -1
- package/dist/tools/move.js +72 -26
- package/dist/tools/move.js.map +1 -1
- package/dist/tools/patch.js +13 -6
- package/dist/tools/patch.js.map +1 -1
- package/dist/tools/read.d.ts.map +1 -1
- package/dist/tools/read.js +113 -48
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/replace-text.d.ts.map +1 -1
- package/dist/tools/replace-text.js +50 -35
- package/dist/tools/replace-text.js.map +1 -1
- package/dist/tools/search-text.d.ts.map +1 -1
- package/dist/tools/search-text.js +15 -33
- package/dist/tools/search-text.js.map +1 -1
- package/dist/tools/stat.d.ts.map +1 -1
- package/dist/tools/stat.js +26 -8
- package/dist/tools/stat.js.map +1 -1
- package/dist/transport/http.d.ts.map +1 -1
- package/dist/transport/http.js +1 -0
- package/dist/transport/http.js.map +1 -1
- package/dist/transport/stdio.js +1 -1
- package/dist/transport/stdio.js.map +1 -1
- 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
|
|
81
|
-
|
|
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
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
380
|
+
if (isPathWithinDirectories(dir, this.getAllowedDirectories())) {
|
|
295
381
|
return true;
|
|
296
382
|
}
|
|
297
|
-
|
|
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(
|
|
466
|
+
realPath = await realpath(spelled);
|
|
376
467
|
}
|
|
377
468
|
catch (error) {
|
|
378
|
-
|
|
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:
|
|
496
|
+
requestedPath: spelled,
|
|
390
497
|
resolvedPath: normalizedReal,
|
|
391
|
-
isSymlink: !isSamePath(
|
|
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(
|
|
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)
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
!isPathWithinDirectories(
|
|
491
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
|
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
|