@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.
- package/README.md +32 -26
- 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/cursor.d.ts +8 -0
- package/dist/core/cursor.d.ts.map +1 -1
- package/dist/core/cursor.js +14 -0
- package/dist/core/cursor.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 +191 -72
- package/dist/core/fs.js.map +1 -1
- package/dist/core/glob.d.ts +8 -0
- package/dist/core/glob.d.ts.map +1 -1
- package/dist/core/glob.js +84 -11
- package/dist/core/glob.js.map +1 -1
- package/dist/core/input-required.d.ts +7 -0
- package/dist/core/input-required.d.ts.map +1 -1
- package/dist/core/input-required.js +13 -0
- package/dist/core/input-required.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 +51 -12
- package/dist/core/path-utils.d.ts.map +1 -1
- package/dist/core/path-utils.js +69 -29
- package/dist/core/path-utils.js.map +1 -1
- package/dist/core/path.d.ts +108 -21
- package/dist/core/path.d.ts.map +1 -1
- package/dist/core/path.js +436 -83
- package/dist/core/path.js.map +1 -1
- package/dist/core/read.d.ts +13 -3
- package/dist/core/read.d.ts.map +1 -1
- package/dist/core/read.js +46 -40
- 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 +159 -33
- package/dist/core/search.js.map +1 -1
- package/dist/core/sensitive.d.ts.map +1 -1
- package/dist/core/sensitive.js +71 -29
- 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 +24 -1
- package/dist/core/util.js.map +1 -1
- package/dist/prompts.d.ts +1 -1
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +1 -1
- package/dist/prompts.js.map +1 -1
- package/dist/resources.d.ts +1 -2
- package/dist/resources.d.ts.map +1 -1
- package/dist/resources.js +20 -13
- package/dist/resources.js.map +1 -1
- package/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/dist/tools/create.d.ts.map +1 -1
- package/dist/tools/create.js +122 -45
- 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 +57 -17
- 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 +20 -20
- package/dist/tools/find-files.js.map +1 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +1 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/list.d.ts.map +1 -1
- package/dist/tools/list.js +142 -73
- package/dist/tools/list.js.map +1 -1
- package/dist/tools/move.d.ts.map +1 -1
- package/dist/tools/move.js +81 -36
- 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 +114 -45
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/replace-text.d.ts.map +1 -1
- package/dist/tools/replace-text.js +49 -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 +20 -49
- 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-policy.d.ts +3 -2
- package/dist/transport/http-policy.d.ts.map +1 -1
- package/dist/transport/http-policy.js +3 -2
- package/dist/transport/http-policy.js.map +1 -1
- package/dist/transport/http.d.ts.map +1 -1
- package/dist/transport/http.js +2 -2
- 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,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,
|
|
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
|
|
85
|
-
|
|
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
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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
|
|
190
|
-
return !isSensitive(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
380
|
+
if (isPathWithinDirectories(dir, this.getAllowedDirectories())) {
|
|
330
381
|
return true;
|
|
331
382
|
}
|
|
332
|
-
|
|
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(
|
|
466
|
+
realPath = await realpath(spelled);
|
|
411
467
|
}
|
|
412
468
|
catch (error) {
|
|
413
|
-
|
|
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:
|
|
496
|
+
requestedPath: spelled,
|
|
425
497
|
resolvedPath: normalizedReal,
|
|
426
|
-
isSymlink: !isSamePath(
|
|
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(
|
|
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)
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
!isPathWithinDirectories(
|
|
526
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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
|
|
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
|