@shieldfive/mcp 0.2.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/CHANGELOG.md +200 -0
- package/LICENSE +201 -0
- package/README.md +365 -0
- package/SECURITY.md +144 -0
- package/package.json +49 -0
- package/src/format.mjs +157 -0
- package/src/fsops.mjs +361 -0
- package/src/limits.mjs +55 -0
- package/src/plans.mjs +188 -0
- package/src/roots.mjs +336 -0
- package/src/scan.mjs +269 -0
- package/src/server.mjs +363 -0
- package/src/tools/mutate.mjs +780 -0
- package/src/tools/read.mjs +515 -0
- package/src/trash.mjs +264 -0
package/src/plans.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Binding a confirmed call to the preview the user approved.
|
|
2
|
+
//
|
|
3
|
+
// Every mutating tool plans twice: once for the preview, and again when it is
|
|
4
|
+
// called back with confirm: true. Until now the two plans were unrelated. The
|
|
5
|
+
// user saw "12 files, 4.1 GB" in the preview, said yes, and the confirmed call
|
|
6
|
+
// went out and planned again from scratch — against whatever the tree looked
|
|
7
|
+
// like by then. If a directory had grown, or a destination had appeared, or the
|
|
8
|
+
// file behind a path had been replaced, the call did something the user had
|
|
9
|
+
// never been shown. The pre-publish review recorded this as D3, and README,
|
|
10
|
+
// SECURITY.md and the CHANGELOG all said it was not covered.
|
|
11
|
+
//
|
|
12
|
+
// The fix is the usual two-phase shape. A preview registers a fingerprint of
|
|
13
|
+
// what it planned and hands back an opaque `plan_token`. A confirmed call must
|
|
14
|
+
// carry that token; the tool re-plans as before, fingerprints the result the
|
|
15
|
+
// same way, and refuses if the two differ — naming what changed, so the model
|
|
16
|
+
// can show the user a new plan rather than guess.
|
|
17
|
+
//
|
|
18
|
+
// Three properties matter:
|
|
19
|
+
//
|
|
20
|
+
// 1. The token is issued by the server and means nothing outside it. It is
|
|
21
|
+
// random, not a hash of the plan, so nothing a client can compute
|
|
22
|
+
// authorizes a change.
|
|
23
|
+
// 2. It is single use. One approval performs one change; a token cannot be
|
|
24
|
+
// replayed against a tree that has moved on.
|
|
25
|
+
// 3. It expires. An approval from an hour ago is not consent to act on a
|
|
26
|
+
// directory nobody has looked at since.
|
|
27
|
+
//
|
|
28
|
+
// What this does NOT do is close the time-of-check/time-of-use window: the
|
|
29
|
+
// fingerprint is taken during the confirmed call, and the filesystem can still
|
|
30
|
+
// change between that and the write itself. The per-tool checks immediately
|
|
31
|
+
// before each write are what narrow that gap, and SECURITY.md states the limit.
|
|
32
|
+
|
|
33
|
+
import { randomUUID } from 'node:crypto'
|
|
34
|
+
|
|
35
|
+
import { quote } from './format.mjs'
|
|
36
|
+
import { ToolError } from './roots.mjs'
|
|
37
|
+
|
|
38
|
+
/** How long an approved plan stays good for. */
|
|
39
|
+
export const PLAN_TTL_MS = 10 * 60 * 1000
|
|
40
|
+
|
|
41
|
+
/** How many plans are remembered at once. Oldest first out. */
|
|
42
|
+
export const PLAN_CAPACITY = 64
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The identity of a filesystem entry, as far as a plan is concerned.
|
|
46
|
+
*
|
|
47
|
+
* Device and inode say it is the same entry rather than the same path — a path
|
|
48
|
+
* swapped for another file between the preview and the confirmation is a
|
|
49
|
+
* different plan even when every visible field matches. Size and mtime catch a
|
|
50
|
+
* file rewritten in place, where the inode does not change.
|
|
51
|
+
*/
|
|
52
|
+
export function entryId(stats) {
|
|
53
|
+
if (!stats) return 'absent'
|
|
54
|
+
const kind = stats.isSymbolicLink()
|
|
55
|
+
? 'symlink'
|
|
56
|
+
: stats.isDirectory()
|
|
57
|
+
? 'directory'
|
|
58
|
+
: stats.isFile()
|
|
59
|
+
? 'file'
|
|
60
|
+
: 'special'
|
|
61
|
+
return `${kind}:${stats.dev}:${stats.ino}:${stats.mtimeMs}:${stats.size}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Stable JSON: object keys in a fixed order, so two equal plans stringify alike. */
|
|
65
|
+
function canonical(value) {
|
|
66
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
|
67
|
+
if (value && typeof value === 'object') {
|
|
68
|
+
return `{${Object.keys(value)
|
|
69
|
+
.sort()
|
|
70
|
+
.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
|
|
71
|
+
.join(',')}}`
|
|
72
|
+
}
|
|
73
|
+
return JSON.stringify(value ?? null)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A store of approved plans.
|
|
78
|
+
*
|
|
79
|
+
* In memory and per process on purpose: a plan is an approval inside one
|
|
80
|
+
* conversation, and a server that has restarted has no conversation to honour
|
|
81
|
+
* it in.
|
|
82
|
+
*/
|
|
83
|
+
export function createPlanStore({ now = () => Date.now(), ttlMs = PLAN_TTL_MS, capacity = PLAN_CAPACITY } = {}) {
|
|
84
|
+
const plans = new Map()
|
|
85
|
+
|
|
86
|
+
// Map iterates in insertion order, so the first key is always the oldest.
|
|
87
|
+
function sweep(at) {
|
|
88
|
+
for (const [token, plan] of plans) {
|
|
89
|
+
if (at - plan.issuedAt >= ttlMs) plans.delete(token)
|
|
90
|
+
}
|
|
91
|
+
while (plans.size > capacity) plans.delete(plans.keys().next().value)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
/** Register what a preview planned; returns the token that approves it. */
|
|
96
|
+
issue(fingerprint) {
|
|
97
|
+
const at = now()
|
|
98
|
+
const token = `plan_${randomUUID().replace(/-/g, '')}`
|
|
99
|
+
plans.set(token, { body: canonical(fingerprint), issuedAt: at })
|
|
100
|
+
sweep(at)
|
|
101
|
+
return token
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Consume a token.
|
|
106
|
+
*
|
|
107
|
+
* Returns the approved body, or a reason it cannot be used. Consuming
|
|
108
|
+
* happens whether or not the body matches: a token that has been answered
|
|
109
|
+
* once is spent, and a mismatch means the caller has to look at a new plan
|
|
110
|
+
* anyway.
|
|
111
|
+
*/
|
|
112
|
+
take(token) {
|
|
113
|
+
const plan = plans.get(token)
|
|
114
|
+
if (!plan) return { ok: false, reason: 'unknown' }
|
|
115
|
+
plans.delete(token)
|
|
116
|
+
if (now() - plan.issuedAt >= ttlMs) return { ok: false, reason: 'expired' }
|
|
117
|
+
return { ok: true, body: plan.body }
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
get size() {
|
|
121
|
+
return plans.size
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Which fields differ between the plan that was approved and the one now. */
|
|
127
|
+
function differences(approved, fresh) {
|
|
128
|
+
let a
|
|
129
|
+
try {
|
|
130
|
+
a = JSON.parse(approved)
|
|
131
|
+
} catch {
|
|
132
|
+
return ['the plan']
|
|
133
|
+
}
|
|
134
|
+
const b = JSON.parse(canonical(fresh))
|
|
135
|
+
const out = []
|
|
136
|
+
for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
137
|
+
const before = canonical(a[key])
|
|
138
|
+
const after = canonical(b[key])
|
|
139
|
+
if (before !== after) out.push(key)
|
|
140
|
+
}
|
|
141
|
+
return out.length ? out : ['the plan']
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Refuse a confirmed call that is not the plan the user approved.
|
|
146
|
+
*
|
|
147
|
+
* Called before anything is written, and after the tool has re-planned, so the
|
|
148
|
+
* comparison is between two plans built the same way from two readings of the
|
|
149
|
+
* filesystem.
|
|
150
|
+
*/
|
|
151
|
+
export function requireApprovedPlan(ctx, token, fingerprint) {
|
|
152
|
+
const store = ctx.plans
|
|
153
|
+
if (!store) {
|
|
154
|
+
throw new ToolError(
|
|
155
|
+
'plan_store_missing',
|
|
156
|
+
'Refused: this server has no plan store, so a confirmed call cannot be checked ' +
|
|
157
|
+
'against the plan it claims to perform. Nothing was changed.',
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
if (typeof token !== 'string' || !token) {
|
|
161
|
+
throw new ToolError(
|
|
162
|
+
'plan_token_required',
|
|
163
|
+
'Refused: confirm: true needs the plan_token from the preview of this exact call. ' +
|
|
164
|
+
'Call this tool without confirm, show the user what it reports, and pass the ' +
|
|
165
|
+
'plan_token it returns. Nothing was changed.',
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
const taken = store.take(token)
|
|
169
|
+
if (!taken.ok) {
|
|
170
|
+
throw new ToolError(
|
|
171
|
+
taken.reason === 'expired' ? 'plan_expired' : 'unknown_plan_token',
|
|
172
|
+
taken.reason === 'expired'
|
|
173
|
+
? `Refused: plan ${quote(token)} is older than ${Math.round(PLAN_TTL_MS / 60000)} minutes. ` +
|
|
174
|
+
'Plan again and confirm the new plan. Nothing was changed.'
|
|
175
|
+
: `Refused: plan ${quote(token)} is not a plan this server issued, or it has already ` +
|
|
176
|
+
'been used. Each plan performs one change. Nothing was changed.',
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
const body = canonical(fingerprint)
|
|
180
|
+
if (body !== taken.body) {
|
|
181
|
+
throw new ToolError(
|
|
182
|
+
'plan_changed',
|
|
183
|
+
`Refused: ${differences(taken.body, fingerprint).join(', ')} changed between the plan and ` +
|
|
184
|
+
'this call, so performing it would not do what was approved. Nothing was changed; ' +
|
|
185
|
+
'call again without confirm to see the current plan.',
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/roots.mjs
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// Path containment — the security core of this server.
|
|
2
|
+
//
|
|
3
|
+
// Every path that reaches the filesystem passes through here first. One rule:
|
|
4
|
+
// a path is usable only if its real path, with every symlink resolved, sits
|
|
5
|
+
// inside one of the roots the user configured at startup. Nothing else grants
|
|
6
|
+
// access, and there is no override flag.
|
|
7
|
+
//
|
|
8
|
+
// Why realpath rather than string prefixing. A string check on the path the
|
|
9
|
+
// caller supplied is defeated by `..`, and a check after `path.resolve` is still
|
|
10
|
+
// defeated by a symlink: `/allowed/link -> /etc` resolves to a string under
|
|
11
|
+
// /allowed while reading /etc. Resolving symlinks first is what closes that, and
|
|
12
|
+
// it is why the directory walk in scan.mjs uses lstat and never follows a link.
|
|
13
|
+
//
|
|
14
|
+
// The boundary test is separator-aware. `/data/roots-evil` must not match the
|
|
15
|
+
// root `/data/root` just because the string starts with it.
|
|
16
|
+
|
|
17
|
+
import { realpath, lstat } from 'node:fs/promises'
|
|
18
|
+
import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
19
|
+
|
|
20
|
+
import { quote } from './format.mjs'
|
|
21
|
+
|
|
22
|
+
/** A refusal the model is meant to read and act on, not a crash. */
|
|
23
|
+
export class ToolError extends Error {
|
|
24
|
+
constructor(code, message, detail = undefined) {
|
|
25
|
+
super(message)
|
|
26
|
+
this.name = 'ToolError'
|
|
27
|
+
this.code = code
|
|
28
|
+
if (detail !== undefined) this.detail = detail
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The longest path argument accepted: PATH_MAX on Linux, and more than macOS
|
|
34
|
+
* accepts. The cap is not about the filesystem. Without it a 5 MB path argument
|
|
35
|
+
* was reflected verbatim into the error and into the model's context.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_PATH_CHARS = 4096
|
|
38
|
+
|
|
39
|
+
export const NO_ROOTS_MESSAGE =
|
|
40
|
+
'No allowed roots are configured, so this server can read nothing. ' +
|
|
41
|
+
'Start it with one or more directories: `shieldfive-mcp /Users/you/Documents ' +
|
|
42
|
+
'/Volumes/Archive`, or set SHIELDFIVE_MCP_ROOTS to a ' +
|
|
43
|
+
`${JSON.stringify(delimiter)}-separated list. Roots are the only paths this ` +
|
|
44
|
+
'server may touch; it has no default and will not guess one.'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve configured roots to real paths.
|
|
48
|
+
*
|
|
49
|
+
* A root that does not exist, or is not a directory, is dropped with a reason
|
|
50
|
+
* rather than silently ignored — a typo in a client config should be visible,
|
|
51
|
+
* not just produce an empty file listing.
|
|
52
|
+
*
|
|
53
|
+
* Root candidates are trimmed, unlike tool arguments: they come from a config
|
|
54
|
+
* file or a shell variable a person typed, where stray whitespace around a
|
|
55
|
+
* separator is common, and every rejection is logged at startup.
|
|
56
|
+
*/
|
|
57
|
+
export async function resolveRoots(candidates) {
|
|
58
|
+
const roots = []
|
|
59
|
+
const rejected = []
|
|
60
|
+
|
|
61
|
+
for (const raw of candidates) {
|
|
62
|
+
const trimmed = String(raw).trim()
|
|
63
|
+
if (!trimmed) continue
|
|
64
|
+
|
|
65
|
+
if (!isAbsolute(trimmed)) {
|
|
66
|
+
rejected.push({ path: trimmed, reason: 'not an absolute path' })
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let real
|
|
71
|
+
try {
|
|
72
|
+
real = await realpath(trimmed)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
rejected.push({
|
|
75
|
+
path: trimmed,
|
|
76
|
+
reason: err.code === 'ENOENT' ? 'does not exist' : `unreadable (${err.code})`,
|
|
77
|
+
})
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let stats
|
|
82
|
+
try {
|
|
83
|
+
stats = await lstat(real)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
rejected.push({ path: trimmed, reason: `unreadable (${err.code})` })
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (!stats.isDirectory()) {
|
|
89
|
+
rejected.push({ path: trimmed, reason: 'not a directory' })
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (roots.some((r) => r.realPath === real)) continue
|
|
94
|
+
roots.push({ configured: trimmed, realPath: real })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Drop a root nested inside another so a file is never reported twice and
|
|
98
|
+
// containment has a single answer.
|
|
99
|
+
const kept = roots.filter(
|
|
100
|
+
(r) => !roots.some((other) => other !== r && isInside(r.realPath, other.realPath)),
|
|
101
|
+
)
|
|
102
|
+
for (const r of roots) {
|
|
103
|
+
if (!kept.includes(r)) {
|
|
104
|
+
rejected.push({
|
|
105
|
+
path: r.configured,
|
|
106
|
+
reason: 'nested inside another configured root',
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { roots: kept, rejected }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** True when `child` is `parent` itself or sits beneath it. Separator-aware. */
|
|
115
|
+
export function isInside(child, parent) {
|
|
116
|
+
if (child === parent) return true
|
|
117
|
+
return child.startsWith(parent.endsWith(sep) ? parent : parent + sep)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Read root candidates from argv and the environment. */
|
|
121
|
+
export function rootCandidatesFrom(argv, env) {
|
|
122
|
+
const fromArgs = argv.filter((a) => !a.startsWith('-'))
|
|
123
|
+
const fromEnv = (env.SHIELDFIVE_MCP_ROOTS ?? '').split(delimiter)
|
|
124
|
+
return [...fromArgs, ...fromEnv]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve a caller-supplied path that must already exist, following links.
|
|
129
|
+
*
|
|
130
|
+
* Returns the REAL path. This is for what is read or walked; a path a tool is
|
|
131
|
+
* about to move, rename or trash goes through resolveEntry() instead, so the
|
|
132
|
+
* tool acts on the link it was given rather than on what the link points to.
|
|
133
|
+
*/
|
|
134
|
+
export async function resolveExisting(rootSet, input, { what = 'path' } = {}) {
|
|
135
|
+
assertRoots(rootSet)
|
|
136
|
+
const requested = requireAbsolute(input, what)
|
|
137
|
+
|
|
138
|
+
let real
|
|
139
|
+
try {
|
|
140
|
+
real = await realpath(requested)
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (err.code === 'ENOENT') {
|
|
143
|
+
throw new ToolError('not_found', `No such ${what}: ${quote(requested)}`)
|
|
144
|
+
}
|
|
145
|
+
throw new ToolError('unreadable', `Cannot read ${what} ${quote(requested)} (${err.code})`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { realPath: real, root: requireContained(rootSet, real, requested, what) }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resolve a path that may not exist yet — a move destination, a new folder.
|
|
153
|
+
*
|
|
154
|
+
* The nearest existing ancestor is realpath'd and the remaining segments are
|
|
155
|
+
* re-appended, so a destination under a symlinked parent is caught before the
|
|
156
|
+
* write rather than after it.
|
|
157
|
+
*/
|
|
158
|
+
export async function resolveTarget(rootSet, input, { what = 'destination' } = {}) {
|
|
159
|
+
assertRoots(rootSet)
|
|
160
|
+
const requested = requireAbsolute(input, what)
|
|
161
|
+
const { realPath, exists } = await resolveNearest(requested, what)
|
|
162
|
+
return { realPath, root: requireContained(rootSet, realPath, requested, what), exists }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve a directory entry a tool is about to move, rename or trash.
|
|
167
|
+
*
|
|
168
|
+
* The parent is realpath'd and the last component is not: a symlink named here
|
|
169
|
+
* is the thing acted on, never its target. Following it made trash_local on a
|
|
170
|
+
* shortcut trash the folder behind it, and rename_local rename the file a link
|
|
171
|
+
* pointed to, leaving the link dangling. Containment is checked on the entry's
|
|
172
|
+
* own position, which is all the operation touches.
|
|
173
|
+
*
|
|
174
|
+
* `stats` is the entry's lstat.
|
|
175
|
+
*/
|
|
176
|
+
export async function resolveEntry(rootSet, input, { what = 'path' } = {}) {
|
|
177
|
+
assertRoots(rootSet)
|
|
178
|
+
const requested = requireAbsolute(input, what)
|
|
179
|
+
const parent = dirname(requested)
|
|
180
|
+
if (parent === requested) {
|
|
181
|
+
throw new ToolError('invalid_path', `${what} ${quote(requested)} is a filesystem root, not an entry.`)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let realParent
|
|
185
|
+
try {
|
|
186
|
+
realParent = await realpath(parent)
|
|
187
|
+
} catch (err) {
|
|
188
|
+
throw entryError(err, what, requested)
|
|
189
|
+
}
|
|
190
|
+
const realPath = join(realParent, basename(requested))
|
|
191
|
+
|
|
192
|
+
let stats
|
|
193
|
+
try {
|
|
194
|
+
stats = await lstat(realPath)
|
|
195
|
+
} catch (err) {
|
|
196
|
+
throw entryError(err, what, requested)
|
|
197
|
+
}
|
|
198
|
+
return { realPath, root: requireContained(rootSet, realPath, requested, what), stats }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolve where a move or a rename would put something.
|
|
203
|
+
*
|
|
204
|
+
* Like resolveTarget(), except that the last component is inspected without
|
|
205
|
+
* being followed and only its own position has to be inside a root. `stats` is
|
|
206
|
+
* the lstat of whatever is already there, or null. A symlink at the
|
|
207
|
+
* destination, dangling or not, is an existing entry to report and to refuse or
|
|
208
|
+
* displace, not a way through to its target: a dangling one used to read as a
|
|
209
|
+
* free name, and a copy then wrote through it to wherever it pointed.
|
|
210
|
+
*/
|
|
211
|
+
export async function resolveDestination(rootSet, input, { what = 'destination' } = {}) {
|
|
212
|
+
assertRoots(rootSet)
|
|
213
|
+
const requested = requireAbsolute(input, what)
|
|
214
|
+
const parent = dirname(requested)
|
|
215
|
+
if (parent === requested) {
|
|
216
|
+
throw new ToolError('invalid_path', `${what} ${quote(requested)} is a filesystem root, not an entry.`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const nearest = await resolveNearest(parent, what)
|
|
220
|
+
const realPath = join(nearest.realPath, basename(requested))
|
|
221
|
+
let stats = null
|
|
222
|
+
if (nearest.exists) {
|
|
223
|
+
try {
|
|
224
|
+
stats = await lstat(realPath)
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (err.code !== 'ENOENT') {
|
|
227
|
+
throw new ToolError('unreadable', `Cannot read ${what} ${quote(requested)} (${err.code})`)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { realPath, root: requireContained(rootSet, realPath, requested, what), stats }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Realpath the nearest existing ancestor of `requested` and re-append the rest.
|
|
236
|
+
*
|
|
237
|
+
* A component that exists but does not resolve is a dangling symlink. realpath
|
|
238
|
+
* reports ENOENT for it exactly as for a missing path, and taking that at its
|
|
239
|
+
* word made the link look like a free, contained name that a write would then
|
|
240
|
+
* follow out of the root. It is refused.
|
|
241
|
+
*/
|
|
242
|
+
async function resolveNearest(requested, what) {
|
|
243
|
+
const trailing = []
|
|
244
|
+
let probe = requested
|
|
245
|
+
for (;;) {
|
|
246
|
+
let real
|
|
247
|
+
try {
|
|
248
|
+
real = await realpath(probe)
|
|
249
|
+
} catch (err) {
|
|
250
|
+
if (err.code !== 'ENOENT') {
|
|
251
|
+
throw new ToolError('unreadable', `Cannot resolve ${what} ${quote(requested)} (${err.code})`)
|
|
252
|
+
}
|
|
253
|
+
if (await isSymlink(probe)) {
|
|
254
|
+
throw new ToolError(
|
|
255
|
+
'dangling_symlink',
|
|
256
|
+
`Refused: ${quote(probe)} is a symlink whose target does not exist. This server ` +
|
|
257
|
+
'does not write through links, and will not treat a broken one as a free name. ' +
|
|
258
|
+
'Remove or repair the link first.',
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
const parent = resolve(probe, '..')
|
|
262
|
+
if (parent === probe) {
|
|
263
|
+
throw new ToolError('not_found', `No existing ancestor for ${quote(requested)}`)
|
|
264
|
+
}
|
|
265
|
+
trailing.unshift(probe.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)))
|
|
266
|
+
probe = parent
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
return { realPath: trailing.length ? join(real, ...trailing) : real, exists: trailing.length === 0 }
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function isSymlink(path) {
|
|
274
|
+
try {
|
|
275
|
+
return (await lstat(path)).isSymbolicLink()
|
|
276
|
+
} catch {
|
|
277
|
+
return false
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function entryError(err, what, requested) {
|
|
282
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
|
|
283
|
+
return new ToolError('not_found', `No such ${what}: ${quote(requested)}`)
|
|
284
|
+
}
|
|
285
|
+
return new ToolError('unreadable', `Cannot read ${what} ${quote(requested)} (${err.code})`)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function assertRoots(rootSet) {
|
|
289
|
+
if (!rootSet || rootSet.length === 0) {
|
|
290
|
+
throw new ToolError('no_roots', NO_ROOTS_MESSAGE)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A path argument, exactly as given.
|
|
296
|
+
*
|
|
297
|
+
* Nothing is trimmed. "report " and "report" are different files, and trimming
|
|
298
|
+
* made a request for the first act on the second.
|
|
299
|
+
*/
|
|
300
|
+
function requireAbsolute(input, what) {
|
|
301
|
+
if (typeof input !== 'string' || input === '') {
|
|
302
|
+
throw new ToolError('invalid_path', `A ${what} is required.`)
|
|
303
|
+
}
|
|
304
|
+
if (input.length > MAX_PATH_CHARS) {
|
|
305
|
+
throw new ToolError(
|
|
306
|
+
'invalid_path',
|
|
307
|
+
`${what} is ${input.length.toLocaleString('en-US')} characters long, more than any ` +
|
|
308
|
+
`filesystem accepts. It starts ${quote(input, 80)}.`,
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
if (input.includes('\0')) {
|
|
312
|
+
throw new ToolError('invalid_path', `${what} contains a NUL byte, which no path can: ${quote(input)}.`)
|
|
313
|
+
}
|
|
314
|
+
if (!isAbsolute(input)) {
|
|
315
|
+
throw new ToolError(
|
|
316
|
+
'invalid_path',
|
|
317
|
+
`${what} must be an absolute path; got ${quote(input)}. ` +
|
|
318
|
+
'This server resolves nothing against a working directory, because it has ' +
|
|
319
|
+
'no meaningful one.',
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
return resolve(input)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function requireContained(rootSet, real, requested, what) {
|
|
326
|
+
const root = rootSet.find((r) => isInside(real, r.realPath))
|
|
327
|
+
if (!root) {
|
|
328
|
+
throw new ToolError(
|
|
329
|
+
'outside_roots',
|
|
330
|
+
`Refused: ${what} ${quote(requested)} resolves to ${quote(real)}, which is outside ` +
|
|
331
|
+
`every configured root (${rootSet.map((r) => r.realPath).join(', ')}). ` +
|
|
332
|
+
'Add the directory at startup if this is intended; there is no override.',
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
return root
|
|
336
|
+
}
|