mcp-read-file-server 1.6.0 → 1.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 +551 -350
- package/SKILL.md +44 -192
- package/index.js +9 -1183
- package/lib/encryption.js +317 -0
- package/lib/files.js +248 -0
- package/lib/patterns.js +175 -0
- package/lib/regex-worker.js +43 -0
- package/lib/regex.js +55 -0
- package/lib/server.js +314 -0
- package/lib/text.js +172 -0
- package/package.json +11 -5
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/** 异步环境探测、独立用户策略和目录感知的写入校验。 */
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const { spawn } = require('node:child_process');
|
|
7
|
+
const { performance } = require('node:perf_hooks');
|
|
8
|
+
const { fault, checkBudget, fingerprint, payloadFingerprint } = require('./text');
|
|
9
|
+
const SAFE_CANDIDATES = ['.tmp', '.md', '.txt', '.log', '.dat', '.bak', '.cache', '.temp', '.mcp_tmp'];
|
|
10
|
+
const OTHER_CANDIDATES = ['.java', '.js', '.ts', '.css', '.scss', '.json', '.xml', '.py'];
|
|
11
|
+
const TTL = 30 * 86400000;
|
|
12
|
+
const PROBE = Buffer.from('mcp-encryption-probe|' + '0123456789abcdef'.repeat(9) + '|eol\n');
|
|
13
|
+
|
|
14
|
+
/** 限制扩展名为单一后缀,禁止把策略名称解释为路径。 */
|
|
15
|
+
function extension(value) {
|
|
16
|
+
const ext = (value.startsWith('.') ? value : '.' + value).toLowerCase();
|
|
17
|
+
if (!/^\.[a-z0-9_+-]{1,32}$/.test(ext)) throw fault('INVALID_EXTENSION', '扩展名必须是单个有效后缀,如 .java');
|
|
18
|
+
return ext;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** PowerShell 字面量转义,不接受控制字符和 Windows 不合法引号。 */
|
|
22
|
+
function quote(value) { return "'" + value.replace(/'/g, "''") + "'"; }
|
|
23
|
+
|
|
24
|
+
/** 运行受超时、取消和输出预算限制的外部进程。 */
|
|
25
|
+
function execute(exe, args, options = {}) {
|
|
26
|
+
checkBudget(options.signal, options.deadline);
|
|
27
|
+
const timeout = Math.max(1, Math.min(5000, (options.deadline ?? Infinity) - performance.now()));
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const child = spawn(exe, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], ...options.spawnOptions });
|
|
30
|
+
let stdout = '';
|
|
31
|
+
let stderr = '';
|
|
32
|
+
let failure = null;
|
|
33
|
+
const abort = () => { failure = fault('CANCELLED', '外部进程已取消'); child.kill(); };
|
|
34
|
+
const timer = setTimeout(() => { failure = fault('PROCESS_TIMEOUT', '外部进程超时: ' + exe); child.kill(); }, timeout);
|
|
35
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
36
|
+
child.stdout.setEncoding('utf8');
|
|
37
|
+
child.stderr.setEncoding('utf8');
|
|
38
|
+
child.stdout.on('data', chunk => { stdout += chunk; if (stdout.length > 65536) { failure = fault('OUTPUT_LIMIT', '外部进程输出超过预算'); child.kill(); } });
|
|
39
|
+
child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-4096); });
|
|
40
|
+
child.on('error', error => { failure = error; });
|
|
41
|
+
child.on('close', code => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
options.signal?.removeEventListener('abort', abort);
|
|
44
|
+
if (failure) reject(failure);
|
|
45
|
+
else if (!(options.successCodes || [0]).includes(code)) reject(fault('PROCESS_FAILED', exe + ': ' + stderr.trim()));
|
|
46
|
+
else resolve(stdout.trim());
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 流式计算磁盘原始字节指纹;外部进程不可用时返回未知,不认证明文。 */
|
|
52
|
+
async function inspectDisk(proc, file, options = {}) {
|
|
53
|
+
if (!proc) return null;
|
|
54
|
+
const code = "$ErrorActionPreference='Stop';$f=[IO.File]::OpenRead(" + quote(file) + ');try{' +
|
|
55
|
+
'$b=New-Object byte[] 16;$n=$f.Read($b,0,16);$prefix=\'\';if($n -gt 0){$prefix=([BitConverter]::ToString($b,0,$n)).Replace(\'-\',\'\').ToLowerInvariant()};' +
|
|
56
|
+
'$null=$f.Seek(0,0);$sha=[Security.Cryptography.SHA256]::Create();try{$hash=([BitConverter]::ToString($sha.ComputeHash($f))).Replace(\'-\',\'\').ToLowerInvariant()}finally{$sha.Dispose()};' +
|
|
57
|
+
'[pscustomobject]@{hash=$hash;size=$f.Length;prefix=$prefix}|ConvertTo-Json -Compress}finally{$f.Dispose()}';
|
|
58
|
+
try {
|
|
59
|
+
const raw = await execute(proc === 'pwsh' ? 'pwsh.exe' : 'powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', code], options);
|
|
60
|
+
const data = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
61
|
+
if (!/^[a-f0-9]{64}$/.test(data.hash) || !Number.isSafeInteger(data.size) || !/^[a-f0-9]{0,32}$/.test(data.prefix)) return null;
|
|
62
|
+
return data;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error.code === 'CANCELLED' || error.code === 'TIMEOUT') throw error;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 按独立路径参数或受控环境变量复制文件,不移动或删除源。 */
|
|
70
|
+
async function externalCopy(proc, source, destination, options = {}) {
|
|
71
|
+
if (/["\r\n\0]/.test(source + destination)) throw fault('INVALID_PATH', '外部复制路径含不支持的字符');
|
|
72
|
+
if (proc === 'powershell' || proc === 'pwsh') {
|
|
73
|
+
await execute(proc === 'pwsh' ? 'pwsh.exe' : 'powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', "$ErrorActionPreference='Stop';[IO.File]::Copy(" + quote(source) + ',' + quote(destination) + ',$true)'], options);
|
|
74
|
+
} else if (proc === 'cmd') {
|
|
75
|
+
await execute('cmd.exe', ['/d', '/v:off', '/s', '/c', 'copy /b /y "%MCP_COPY_SOURCE%" "%MCP_COPY_DEST%"'], {
|
|
76
|
+
...options, spawnOptions: { windowsVerbatimArguments: true, env: { ...process.env, MCP_COPY_SOURCE: source, MCP_COPY_DEST: destination } }
|
|
77
|
+
});
|
|
78
|
+
} else if (proc === 'robocopy') {
|
|
79
|
+
const dir = await fs.mkdtemp(path.join(path.dirname(destination), '.mcp-robo-'));
|
|
80
|
+
try {
|
|
81
|
+
await execute('robocopy.exe', [path.dirname(source), dir, path.basename(source), '/R:0', '/W:0', '/NFL', '/NDL', '/NJH', '/NJS', '/NP'], { ...options, successCodes: [0, 1, 2, 3, 4, 5, 6, 7] });
|
|
82
|
+
await fs.rename(path.join(dir, path.basename(source)), destination);
|
|
83
|
+
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
|
84
|
+
} else if (proc === 'cscript') {
|
|
85
|
+
const helper = path.join(path.dirname(destination), '.mcp-copy-' + crypto.randomUUID() + '.tmp');
|
|
86
|
+
try {
|
|
87
|
+
await fs.writeFile(helper, 'var f=new ActiveXObject("Scripting.FileSystemObject");f.CopyFile(WScript.Arguments(0),WScript.Arguments(1),true);', { flag: 'wx' });
|
|
88
|
+
await execute('cscript.exe', ['//nologo', '//E:JScript', helper, source, destination], options);
|
|
89
|
+
} finally { await fs.rm(helper, { force: true }); }
|
|
90
|
+
} else throw fault('INVALID_PROCESS', '未知复制进程');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 使用独占临时文件和 rename 保存小型配置,不暴露半个 JSON。 */
|
|
94
|
+
async function atomicJson(file, value) {
|
|
95
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
96
|
+
const temp = file + '.' + crypto.randomUUID() + '.tmp';
|
|
97
|
+
try { await fs.writeFile(temp, JSON.stringify(value, null, 2), { flag: 'wx', mode: 0o600 }); await fs.rename(temp, file); }
|
|
98
|
+
finally { await fs.rm(temp, { force: true }); }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 创建可注入磁盘读取和复制适配器的策略管理器,测试不需要修改真实用户缓存。 */
|
|
102
|
+
function createEncryption(options = {}) {
|
|
103
|
+
const stateDir = path.resolve(options.stateDir || process.env.MCP_PROFILE_DIR || os.homedir());
|
|
104
|
+
const cachePath = path.join(stateDir, '.mcp-encryption-profile.json');
|
|
105
|
+
const policyDir = path.join(stateDir, '.mcp-file-policies');
|
|
106
|
+
const machineId = crypto.createHash('sha256').update(os.hostname() + '|' + os.userInfo().username).digest('hex').slice(0, 16);
|
|
107
|
+
const reader = options.inspectDisk || inspectDisk;
|
|
108
|
+
const copier = options.externalCopy || externalCopy;
|
|
109
|
+
let loading = null;
|
|
110
|
+
let active = null;
|
|
111
|
+
|
|
112
|
+
/** 创建字段完整的自动观察缓存,用户策略不会放入其中。 */
|
|
113
|
+
function emptyProfile() {
|
|
114
|
+
return { version: 3, machineId, detectedAt: new Date().toISOString(), safeExtensions: [], protectedExtensions: [], unsafeExtensions: [], encryptedExtensions: [], availableProcesses: [], byteReader: null, bestCombo: null, scopes: {} };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 严格校验缓存时间、列表与读取器;非法缓存不会变成安全结论。 */
|
|
118
|
+
async function loadCache() {
|
|
119
|
+
try {
|
|
120
|
+
const p = JSON.parse(await fs.readFile(cachePath, 'utf8'));
|
|
121
|
+
const date = Date.parse(p.detectedAt);
|
|
122
|
+
if (![2, 3].includes(p.version) || p.machineId !== machineId || !Number.isFinite(date) || date > Date.now() + 60000 || Date.now() - date > TTL) return null;
|
|
123
|
+
for (const key of ['safeExtensions', 'protectedExtensions', 'unsafeExtensions', 'encryptedExtensions']) {
|
|
124
|
+
if (!Array.isArray(p[key]) || p[key].length > 1000 || !p[key].every(x => typeof x === 'string' && /^\.[a-z0-9_+-]{1,32}$/.test(x))) return null;
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(p.availableProcesses) || !p.availableProcesses.every(x => ['powershell', 'pwsh', 'cmd', 'robocopy', 'cscript'].includes(x.id))) return null;
|
|
127
|
+
if (p.byteReader !== null && !['powershell', 'pwsh'].includes(p.byteReader)) return null;
|
|
128
|
+
return { ...p, version: 3, scopes: {} };
|
|
129
|
+
} catch { return null; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 用户策略按扩展名单独存文件,避免不同扩展名的多实例更新互相覆盖。 */
|
|
133
|
+
function policyPath(ext) { return path.join(policyDir, crypto.createHash('sha256').update(ext).digest('hex') + '.json'); }
|
|
134
|
+
|
|
135
|
+
/** 一次性迁移旧版 protected 手工标注,即使旧缓存过期也保留用户意图。 */
|
|
136
|
+
async function migrate() {
|
|
137
|
+
const marker = path.join(policyDir, 'migration-v2.json');
|
|
138
|
+
try { await fs.access(marker); return; } catch {}
|
|
139
|
+
let legacy;
|
|
140
|
+
try { legacy = JSON.parse(await fs.readFile(cachePath, 'utf8')); } catch { legacy = {}; }
|
|
141
|
+
if (legacy.machineId === machineId && Array.isArray(legacy.userProtectedExtensions)) {
|
|
142
|
+
await fs.mkdir(policyDir, { recursive: true });
|
|
143
|
+
for (const value of legacy.userProtectedExtensions) {
|
|
144
|
+
try {
|
|
145
|
+
const ext = extension(value);
|
|
146
|
+
await fs.writeFile(policyPath(ext), JSON.stringify({ extension: ext, category: 'protected' }), { flag: 'wx', mode: 0o600 });
|
|
147
|
+
} catch (error) { if (error.code !== 'EEXIST' && error.code !== 'INVALID_EXTENSION') throw error; }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
await atomicJson(marker, { migratedAt: new Date().toISOString() });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 每次读策略都重读对应文件,使其他 MCP 进程的标注立即可见。 */
|
|
154
|
+
async function getOverride(ext) {
|
|
155
|
+
await migrate();
|
|
156
|
+
if (!ext) return null;
|
|
157
|
+
try {
|
|
158
|
+
const data = JSON.parse(await fs.readFile(policyPath(ext), 'utf8'));
|
|
159
|
+
if (data.extension !== ext || !['protected', 'unsafe', 'clear'].includes(data.category)) throw fault('INVALID_POLICY', '手工策略文件损坏');
|
|
160
|
+
return data.category === 'clear' ? null : data.category;
|
|
161
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 写入手工策略或显式清除墓碑,刷新与旧版迁移不能重新引入已清除标注。 */
|
|
165
|
+
async function mark(value, category) {
|
|
166
|
+
const ext = extension(value);
|
|
167
|
+
await migrate();
|
|
168
|
+
await atomicJson(policyPath(ext), { extension: ext, category, updatedAt: new Date().toISOString() });
|
|
169
|
+
return { extension: ext, category };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 在指定目录使用随机独占样本分类;不以系统临时目录的分类覆盖其他目录。 */
|
|
173
|
+
async function classify(ext, dir, profile, context = {}) {
|
|
174
|
+
const temp = path.join(dir, '.mcp-probe-' + crypto.randomUUID() + ext);
|
|
175
|
+
try {
|
|
176
|
+
await fs.writeFile(temp, PROBE, { flag: 'wx', mode: 0o600 });
|
|
177
|
+
const own = await fingerprint(temp, context);
|
|
178
|
+
const expected = payloadFingerprint(PROBE);
|
|
179
|
+
if (own.hash !== expected.hash) return 'unsafe';
|
|
180
|
+
const raw = await reader(profile.byteReader, temp, context);
|
|
181
|
+
if (!raw) return 'unknown';
|
|
182
|
+
return raw.hash === expected.hash ? 'safe' : 'protected';
|
|
183
|
+
} finally { await fs.rm(temp, { force: true }); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 异步探测可用读取/复制器;总截止时间限制候选遍历。 */
|
|
187
|
+
async function detect(context = {}) {
|
|
188
|
+
const p = emptyProfile();
|
|
189
|
+
if (options.profile) return { ...p, ...options.profile };
|
|
190
|
+
if (process.platform !== 'win32') return p;
|
|
191
|
+
const dir = await fs.mkdtemp(path.join(options.probeDir || os.tmpdir(), 'mcp-enc-probe-'));
|
|
192
|
+
const source = path.join(dir, 'source.tmp');
|
|
193
|
+
const target = path.join(dir, 'target.tmp');
|
|
194
|
+
try {
|
|
195
|
+
await fs.writeFile(source, PROBE, { flag: 'wx' });
|
|
196
|
+
for (const id of ['powershell', 'pwsh', 'cmd', 'robocopy', 'cscript']) {
|
|
197
|
+
checkBudget(context.signal, context.deadline);
|
|
198
|
+
try {
|
|
199
|
+
await copier(id, source, target, context);
|
|
200
|
+
if ((await fingerprint(target, context)).hash === payloadFingerprint(PROBE).hash) p.availableProcesses.push({ id });
|
|
201
|
+
if (!p.byteReader && ['powershell', 'pwsh'].includes(id)) {
|
|
202
|
+
const raw = await reader(id, source, context);
|
|
203
|
+
if (raw?.hash === payloadFingerprint(PROBE).hash) p.byteReader = id;
|
|
204
|
+
}
|
|
205
|
+
} catch (error) { if (['TIMEOUT', 'CANCELLED'].includes(error.code)) throw error; }
|
|
206
|
+
await fs.rm(target, { force: true });
|
|
207
|
+
}
|
|
208
|
+
for (const ext of SAFE_CANDIDATES.concat(OTHER_CANDIDATES)) {
|
|
209
|
+
checkBudget(context.signal, context.deadline);
|
|
210
|
+
const category = await classify(ext, dir, p, context);
|
|
211
|
+
if (category !== 'unknown') p[category + 'Extensions'].push(ext);
|
|
212
|
+
}
|
|
213
|
+
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
|
214
|
+
return p;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 获取自动观察缓存,首次探测异步完成且多个请求共享同一任务。 */
|
|
218
|
+
async function getProfile(context = {}) {
|
|
219
|
+
if (active && Date.now() - Date.parse(active.detectedAt) <= TTL) return active;
|
|
220
|
+
if (!loading) loading = (async () => {
|
|
221
|
+
await migrate();
|
|
222
|
+
active = await loadCache();
|
|
223
|
+
if (!active) { active = await detect(context); await atomicJson(cachePath, active); }
|
|
224
|
+
return active;
|
|
225
|
+
})().finally(() => { loading = null; });
|
|
226
|
+
return loading;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 只刷新自动观察值,人工策略单独保存且不受 TTL 影响。 */
|
|
230
|
+
async function refresh(context = {}) {
|
|
231
|
+
await migrate();
|
|
232
|
+
const fresh = await detect(context);
|
|
233
|
+
await atomicJson(cachePath, fresh);
|
|
234
|
+
active = fresh;
|
|
235
|
+
return fresh;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 解析显式策略与人工覆盖;未知目录在写入前用独立样本探测。 */
|
|
239
|
+
async function strategy(file, requested = 'auto', context = {}) {
|
|
240
|
+
const ext = path.extname(file).toLowerCase();
|
|
241
|
+
const override = await getOverride(ext);
|
|
242
|
+
if (requested === 'preserve' || (requested === 'auto' && override === 'protected')) return { mode: 'preserve', category: 'user_protected', extension: ext };
|
|
243
|
+
if (requested === 'plaintext' || (requested === 'auto' && override === 'unsafe')) return { mode: 'plaintext', category: 'user_unsafe', extension: ext };
|
|
244
|
+
const p = await getProfile(context);
|
|
245
|
+
const dir = await fs.realpath(path.dirname(file));
|
|
246
|
+
const key = dir + '|' + ext;
|
|
247
|
+
let category = p.scopes[key];
|
|
248
|
+
if (!category) {
|
|
249
|
+
category = await classify(ext || '.mcp-noext', dir, p, context);
|
|
250
|
+
if (category === 'unknown' && (p.unsafeExtensions.includes(ext) || p.encryptedExtensions.includes(ext))) category = 'unsafe';
|
|
251
|
+
p.scopes[key] = category;
|
|
252
|
+
}
|
|
253
|
+
return { mode: category === 'unsafe' ? 'plaintext' : category === 'protected' ? 'preserve' : 'auto', category, extension: ext, scope: dir };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 校验 Node 可见内容和原始磁盘状态,unknown 从不声称已验证明文。 */
|
|
257
|
+
async function verify(file, expected, mode, context = {}) {
|
|
258
|
+
const own = await fingerprint(file, context);
|
|
259
|
+
if (own.hash !== expected.hash || own.size !== expected.size) throw fault('CONTENT_MISMATCH', '写入内容校验不一致');
|
|
260
|
+
if (mode === 'preserve') return { contentVerified: true, diskState: 'preserved', diskVerified: false };
|
|
261
|
+
const p = await getProfile(context);
|
|
262
|
+
const raw = await reader(p.byteReader, file, context);
|
|
263
|
+
if (!raw) {
|
|
264
|
+
if (mode === 'plaintext' || p.byteReader) throw fault('DISK_UNVERIFIED', '无法验证磁盘明文状态,保留原文件');
|
|
265
|
+
return { contentVerified: true, diskState: 'unknown', diskVerified: false };
|
|
266
|
+
}
|
|
267
|
+
if (raw.hash !== expected.hash || raw.size !== expected.size) throw fault('DISK_MISMATCH', '磁盘字节与预期明文不一致');
|
|
268
|
+
return { contentVerified: true, diskState: 'plaintext', diskVerified: true };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 为目标生成可校验的暂存文件;所有候选失败即中止,禁止回退破坏原文。 */
|
|
272
|
+
async function prepare(stage, write, expected, decision, context = {}) {
|
|
273
|
+
if (decision.mode !== 'plaintext') {
|
|
274
|
+
await write(stage);
|
|
275
|
+
try { return await verify(stage, expected, decision.mode, context); }
|
|
276
|
+
catch (error) { if (error.code !== 'DISK_MISMATCH' || decision.mode === 'preserve') throw error; }
|
|
277
|
+
await fs.rm(stage, { force: true });
|
|
278
|
+
}
|
|
279
|
+
const p = await getProfile(context);
|
|
280
|
+
if (!p.byteReader) throw fault('DISK_UNVERIFIED', '强制明文写入需要可用的独立磁盘读取器');
|
|
281
|
+
const errors = [];
|
|
282
|
+
for (const ext of [...new Set(p.safeExtensions.concat(SAFE_CANDIDATES))]) {
|
|
283
|
+
checkBudget(context.signal, context.deadline);
|
|
284
|
+
const temp = path.join(path.dirname(stage), '.mcp-safe-' + crypto.randomUUID() + ext);
|
|
285
|
+
try {
|
|
286
|
+
await write(temp);
|
|
287
|
+
try { await verify(temp, expected, 'plaintext', context); }
|
|
288
|
+
catch (error) { errors.push(error.code); continue; }
|
|
289
|
+
for (const proc of p.availableProcesses) {
|
|
290
|
+
checkBudget(context.signal, context.deadline);
|
|
291
|
+
try {
|
|
292
|
+
await copier(proc.id, temp, stage, context);
|
|
293
|
+
const result = await verify(stage, expected, 'plaintext', context);
|
|
294
|
+
return { ...result, via: { extension: ext, process: proc.id } };
|
|
295
|
+
} catch (error) { errors.push(error.code || error.message); await fs.rm(stage, { force: true }); }
|
|
296
|
+
}
|
|
297
|
+
} finally { await fs.rm(temp, { force: true }); }
|
|
298
|
+
}
|
|
299
|
+
throw fault('SAFE_WRITE_FAILED', '安全写入组合全部失败,原文件保持不变', { reasons: errors.slice(0, 10) });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** 返回诊断信息与持久手工策略,避免把用户标注藏在自动分类里。 */
|
|
303
|
+
async function summary(context = {}) {
|
|
304
|
+
const p = await getProfile(context);
|
|
305
|
+
const overrides = [];
|
|
306
|
+
for (const entry of await fs.readdir(policyDir, { withFileTypes: true })) {
|
|
307
|
+
if (!/^[a-f0-9]{64}\.json$/.test(entry.name)) continue;
|
|
308
|
+
const data = JSON.parse(await fs.readFile(path.join(policyDir, entry.name), 'utf8'));
|
|
309
|
+
if (data.category !== 'clear') overrides.push(data);
|
|
310
|
+
}
|
|
311
|
+
return { ...p, cachePath, policyDir, overrides };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return { stateDir, cachePath, policyDir, loadCache, getProfile, refresh, mark, getOverride, strategy, verify, prepare, summary };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = { createEncryption, inspectDisk, externalCopy, execute, atomicJson, extension };
|
package/lib/files.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/** 路径边界、跨实例写锁、可回滚提交和逐文件目录操作。 */
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const nativeFs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const { pipeline } = require('node:stream/promises');
|
|
7
|
+
const { setTimeout: delay } = require('node:timers/promises');
|
|
8
|
+
const { performance } = require('node:perf_hooks');
|
|
9
|
+
const { fault, fingerprint, payloadFingerprint, checkBudget } = require('./text');
|
|
10
|
+
|
|
11
|
+
/** 对 Windows 文件路径统一大小写,用于同一路径和锁键比较。 */
|
|
12
|
+
function pathKey(file) { return process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file); }
|
|
13
|
+
|
|
14
|
+
/** 判定 candidate 是否等于 base 或位于其子树,避免字符串前缀误判。 */
|
|
15
|
+
function inside(base, candidate) {
|
|
16
|
+
const rel = path.relative(pathKey(base), pathKey(candidate));
|
|
17
|
+
return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 从最近存在的祖先解析真实路径,识别目录符号链接和 junction 越界。 */
|
|
21
|
+
async function canonical(file) {
|
|
22
|
+
try { return await fs.realpath(file); }
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error.code !== 'ENOENT') throw error;
|
|
25
|
+
const parent = path.dirname(file);
|
|
26
|
+
if (parent === file) throw error;
|
|
27
|
+
return path.join(await canonical(parent), path.basename(file));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 获取可不存在的路径状态,其他访问错误保持为错误。 */
|
|
32
|
+
async function statMaybe(file) {
|
|
33
|
+
try { return await fs.lstat(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 创建带可选根目录和只读模式的文件操作服务。 */
|
|
37
|
+
function createFiles(encryption, options = {}) {
|
|
38
|
+
const baseDir = path.resolve(options.baseDir || process.env.MCP_BASE_DIR || process.cwd());
|
|
39
|
+
const readOnly = options.readOnly ?? process.env.MCP_READ_ONLY === '1';
|
|
40
|
+
const allowDelete = options.allowDelete ?? process.env.MCP_DISABLE_DELETE !== '1';
|
|
41
|
+
const configuredRoots = options.allowedRoots ?? (process.env.MCP_ALLOWED_ROOTS ? JSON.parse(process.env.MCP_ALLOWED_ROOTS) : []);
|
|
42
|
+
if (!Array.isArray(configuredRoots) || !configuredRoots.every(x => typeof x === 'string' && path.isAbsolute(x))) throw fault('INVALID_CONFIG', 'MCP_ALLOWED_ROOTS 必须是绝对路径 JSON 数组');
|
|
43
|
+
const rootsPromise = Promise.all(configuredRoots.map(canonical));
|
|
44
|
+
|
|
45
|
+
/** 在操作前解析路径并执行服务端权限范围检查,删除链接时只解析其父目录。 */
|
|
46
|
+
async function resolve(value, { write = false, leaf = false, destructive = false } = {}) {
|
|
47
|
+
if (typeof value !== 'string' || !value || /[\0\r\n]/.test(value)) throw fault('INVALID_PATH', '必须提供有效路径');
|
|
48
|
+
if (write && readOnly) throw fault('READ_ONLY', '服务以只读模式运行');
|
|
49
|
+
const absolute = path.resolve(baseDir, value);
|
|
50
|
+
const real = leaf ? path.join(await canonical(path.dirname(absolute)), path.basename(absolute)) : await canonical(absolute);
|
|
51
|
+
const roots = await rootsPromise;
|
|
52
|
+
if (roots.length && !roots.some(root => inside(root, real))) throw fault('PATH_OUTSIDE_ROOTS', '路径不在允许的根目录内');
|
|
53
|
+
if (destructive && (pathKey(real) === pathKey(path.parse(real).root) || pathKey(real) === pathKey(baseDir) || roots.some(root => pathKey(root) === pathKey(real)))) {
|
|
54
|
+
throw fault('PROTECTED_ROOT', '不能删除或移动盘符根、工作目录或配置根目录本身');
|
|
55
|
+
}
|
|
56
|
+
return real;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 为多个路径按稳定顺序获取独占锁,避免多实例覆盖和死锁。 */
|
|
60
|
+
async function locked(files, action, context = {}) {
|
|
61
|
+
const dir = path.join(encryption.stateDir, '.mcp-file-locks');
|
|
62
|
+
await fs.mkdir(dir, { recursive: true });
|
|
63
|
+
const held = [];
|
|
64
|
+
const deadline = Math.min(context.deadline ?? Infinity, performance.now() + 5000);
|
|
65
|
+
try {
|
|
66
|
+
for (const file of [...new Set(files.map(pathKey))].sort()) {
|
|
67
|
+
const lock = path.join(dir, crypto.createHash('sha256').update(file).digest('hex') + '.lock');
|
|
68
|
+
while (true) {
|
|
69
|
+
checkBudget(context.signal, context.deadline);
|
|
70
|
+
try {
|
|
71
|
+
const handle = await fs.open(lock, 'wx', 0o600);
|
|
72
|
+
held.push(lock);
|
|
73
|
+
try { await handle.writeFile(JSON.stringify({ pid: process.pid, file, createdAt: new Date().toISOString() })); }
|
|
74
|
+
finally { await handle.close(); }
|
|
75
|
+
break;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error.code !== 'EEXIST') throw error;
|
|
78
|
+
if (performance.now() > deadline) throw fault('FILE_BUSY', '文件正被其他操作占用;若进程异常退出,请核对锁文件: ' + lock);
|
|
79
|
+
await delay(30);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return await action();
|
|
84
|
+
} finally { for (const lock of held.reverse()) await fs.rm(lock, { force: true }); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 取得文件修改前指纹,不把目录或特殊设备当作普通文件写入。 */
|
|
88
|
+
async function previous(file, context) {
|
|
89
|
+
const stat = await statMaybe(file);
|
|
90
|
+
if (!stat) return null;
|
|
91
|
+
if (!stat.isFile()) throw fault('NOT_FILE', '目标不是普通文件');
|
|
92
|
+
return { ...(await fingerprint(file, context)), mode: stat.mode };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 比较读取时版本和当前文件,避免基于过期内容提交。 */
|
|
96
|
+
async function assertUnchanged(file, before, context) {
|
|
97
|
+
const current = await previous(file, context);
|
|
98
|
+
if (current?.hash !== before?.hash) throw fault('CONFLICT', '文件在操作期间已改变,未覆盖新内容');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 暂存、校验、备份、提交、再校验;异常时恢复原目标或保留可恢复备份。 */
|
|
102
|
+
async function commit(file, writer, expected, options = {}) {
|
|
103
|
+
const before = await previous(file, options);
|
|
104
|
+
if (options.expectedHash !== undefined && options.expectedHash !== (before?.hash ?? null)) throw fault('CONFLICT', 'expectedHash 与当前文件不一致');
|
|
105
|
+
if (before && options.overwrite === false) throw fault('ALREADY_EXISTS', '目标已存在,overwrite=false');
|
|
106
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
107
|
+
const decision = await encryption.strategy(file, options.writePolicy, options);
|
|
108
|
+
if (before?.hash === expected.hash) {
|
|
109
|
+
try {
|
|
110
|
+
const verified = await encryption.verify(file, expected, decision.mode, options);
|
|
111
|
+
return { changed: false, hash: expected.hash, ...verified };
|
|
112
|
+
} catch (error) { if (error.code !== 'DISK_MISMATCH') throw error; }
|
|
113
|
+
}
|
|
114
|
+
const suffix = path.extname(file);
|
|
115
|
+
const token = crypto.randomUUID();
|
|
116
|
+
const stage = path.join(path.dirname(file), '.mcp-stage-' + token + suffix);
|
|
117
|
+
const backup = path.join(path.dirname(file), '.mcp-backup-' + token + suffix);
|
|
118
|
+
let backedUp = false;
|
|
119
|
+
let installed = false;
|
|
120
|
+
let retainBackup = false;
|
|
121
|
+
try {
|
|
122
|
+
const prepared = await encryption.prepare(stage, writer, expected, decision, options);
|
|
123
|
+
// Windows FlushFileBuffers需要可写句柄,刷盘后再恢复原权限。
|
|
124
|
+
const stageHandle = await fs.open(stage, 'r+');
|
|
125
|
+
try { await stageHandle.sync(); } finally { await stageHandle.close(); }
|
|
126
|
+
if (before) await fs.chmod(stage, before.mode);
|
|
127
|
+
checkBudget(options.signal, options.deadline);
|
|
128
|
+
await assertUnchanged(file, before, options);
|
|
129
|
+
if (before) { await fs.rename(file, backup); backedUp = true; }
|
|
130
|
+
await fs.rename(stage, file);
|
|
131
|
+
installed = true;
|
|
132
|
+
const verified = await encryption.verify(file, expected, prepared.via ? 'plaintext' : decision.mode, options);
|
|
133
|
+
if (backedUp) { await fs.rm(backup); backedUp = false; }
|
|
134
|
+
return { changed: true, hash: expected.hash, size: expected.size, ...verified, strategy: decision, ...(prepared.via ? { via: prepared.via } : {}), warnings: verified.diskState === 'unknown' ? ['内容已校验,但没有独立读取器证明磁盘为明文'] : [] };
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// 回滚不受已取消的请求预算影响,优先恢复原始文件。
|
|
137
|
+
try {
|
|
138
|
+
if (backedUp) { await fs.rename(backup, file); backedUp = false; installed = false; }
|
|
139
|
+
else if (installed) { await fs.rm(file, { force: true }); installed = false; }
|
|
140
|
+
error.changed = false;
|
|
141
|
+
} catch (rollbackError) {
|
|
142
|
+
retainBackup = backedUp;
|
|
143
|
+
error.changed = installed;
|
|
144
|
+
error.recoveryPath = backedUp ? backup : null;
|
|
145
|
+
error.rollbackError = rollbackError.message;
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
} finally {
|
|
149
|
+
await fs.rm(stage, { force: true });
|
|
150
|
+
if (!retainBackup && backedUp) await fs.rm(backup, { force: true });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 提交文本载荷,写入者使用独占创建防止临时名冲突。 */
|
|
155
|
+
async function write(file, payload, settings = {}) {
|
|
156
|
+
if (typeof payload === 'string' && payload.isWellFormed && !payload.isWellFormed()) throw fault('INVALID_UNICODE', '写入内容含孤立代理项,已拒绝替换成乱码');
|
|
157
|
+
const expected = payloadFingerprint(payload);
|
|
158
|
+
return commit(file, destination => fs.writeFile(destination, payload, { flag: 'wx', mode: 0o600 }), expected, settings);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 流式复制时保持源明文字节,使用事先取得的独立指纹验证。 */
|
|
162
|
+
async function copyOne(source, destination, settings = {}) {
|
|
163
|
+
const sourceStat = await fs.lstat(source);
|
|
164
|
+
const targetStat = await statMaybe(destination);
|
|
165
|
+
if (pathKey(source) === pathKey(destination) || (targetStat && sourceStat.dev === targetStat.dev && sourceStat.ino === targetStat.ino)) return { changed: false, sameFile: true };
|
|
166
|
+
const expected = await fingerprint(source, settings);
|
|
167
|
+
const result = await commit(destination, target => pipeline(nativeFs.createReadStream(source), nativeFs.createWriteStream(target, { flags: 'wx', mode: sourceStat.mode }), { signal: settings.signal }), expected, settings);
|
|
168
|
+
return { ...result, sourceHash: expected.hash };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 对目录逐文件执行相同写入策略;任何失败保留源树并报告部分目标。 */
|
|
172
|
+
async function transfer(sourceValue, destinationValue, move, settings = {}) {
|
|
173
|
+
const source = await resolve(sourceValue, { write: move, leaf: true, destructive: move });
|
|
174
|
+
let destination = await resolve(destinationValue, { write: true });
|
|
175
|
+
const sourceStat = await fs.lstat(source);
|
|
176
|
+
const destinationStat = await statMaybe(destination);
|
|
177
|
+
if (destinationStat?.isDirectory()) destination = path.join(destination, path.basename(source));
|
|
178
|
+
destination = await resolve(destination, { write: true });
|
|
179
|
+
if (pathKey(source) === pathKey(destination)) return { changed: false, sameFile: true, source, destination };
|
|
180
|
+
if (sourceStat.isDirectory() && inside(source, destination)) throw fault('RECURSIVE_TARGET', '目标不能位于源目录内部');
|
|
181
|
+
return locked([source, destination], async () => {
|
|
182
|
+
const completed = [];
|
|
183
|
+
const createdDirectories = [];
|
|
184
|
+
const sourceDirectories = [];
|
|
185
|
+
let removedSources = 0;
|
|
186
|
+
/** 逐项遍历,不跟随符号链接,避免跨根和意外递归。 */
|
|
187
|
+
async function visit(src, dst) {
|
|
188
|
+
checkBudget(settings.signal, settings.deadline);
|
|
189
|
+
if (completed.length + createdDirectories.length >= 10000) throw fault('ITEM_LIMIT', '目录操作超过一万项预算');
|
|
190
|
+
await resolve(src, { leaf: true });
|
|
191
|
+
await resolve(dst, { write: true });
|
|
192
|
+
const st = await fs.lstat(src);
|
|
193
|
+
if (st.isSymbolicLink()) throw fault('SYMLINK_COPY_UNSUPPORTED', '目录复制/移动暂不自动处理符号链接,请单独处理: ' + src);
|
|
194
|
+
if (st.isDirectory()) {
|
|
195
|
+
sourceDirectories.push(src);
|
|
196
|
+
const old = await statMaybe(dst);
|
|
197
|
+
if (old && !old.isDirectory()) throw fault('TYPE_CONFLICT', '目录目标类型冲突');
|
|
198
|
+
if (!old) { await fs.mkdir(dst, { recursive: true }); createdDirectories.push(dst); }
|
|
199
|
+
for (const entry of await fs.readdir(src)) await visit(path.join(src, entry), path.join(dst, entry));
|
|
200
|
+
} else if (st.isFile()) {
|
|
201
|
+
const result = await copyOne(src, dst, settings);
|
|
202
|
+
if (move && result.sameFile) throw fault('SAME_FILE_ALIAS', '源与目标为同一文件的别名,拒绝删源');
|
|
203
|
+
completed.push({ source: src, destination: dst, ...result });
|
|
204
|
+
} else throw fault('SPECIAL_FILE', '不支持复制特殊设备文件');
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
await visit(source, destination);
|
|
208
|
+
if (move) {
|
|
209
|
+
// 删除源前再次比较独立指纹,避免复制期间源内容改变后被删除。
|
|
210
|
+
for (const item of completed) {
|
|
211
|
+
if ((await fingerprint(item.source, settings)).hash !== item.sourceHash) throw fault('CONFLICT', '源在复制期间被修改,已保留源文件');
|
|
212
|
+
if ((await fingerprint(item.destination, settings)).hash !== item.sourceHash) throw fault('CONTENT_MISMATCH', '目标在复制后被修改,已保留源文件');
|
|
213
|
+
}
|
|
214
|
+
// 只删除本次已经复制且验证的文件;后来新增的源文件绝不递归删掉。
|
|
215
|
+
for (const item of completed) {
|
|
216
|
+
if ((await fingerprint(item.source, settings)).hash !== item.sourceHash) throw fault('CONFLICT', '源文件在删源前被修改,已保留');
|
|
217
|
+
await fs.unlink(item.source);
|
|
218
|
+
removedSources++;
|
|
219
|
+
}
|
|
220
|
+
for (const dir of sourceDirectories.reverse()) await fs.rmdir(dir);
|
|
221
|
+
}
|
|
222
|
+
return { changed: move || completed.some(x => x.changed) || createdDirectories.length > 0, source, destination, files: completed.length, moved: move, warnings: completed.flatMap(x => x.warnings || []).slice(0, 10) };
|
|
223
|
+
} catch (error) {
|
|
224
|
+
error.changed = completed.some(x => x.changed) || createdDirectories.length > 0;
|
|
225
|
+
error.partial = completed.map(x => x.destination).slice(0, 100);
|
|
226
|
+
error.sourceRetained = removedSources === 0;
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}, settings);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 删除单个文件、链接或目录;空目录非递归删除使用 rmdir。 */
|
|
233
|
+
async function remove(value, settings = {}) {
|
|
234
|
+
if (!allowDelete) throw fault('DELETE_DISABLED', '服务已禁用删除工具');
|
|
235
|
+
const file = await resolve(value, { write: !settings.dryRun, leaf: true, destructive: true });
|
|
236
|
+
return locked([file], async () => {
|
|
237
|
+
const st = await fs.lstat(file);
|
|
238
|
+
if (settings.dryRun) return { changed: false, dryRun: true, path: file, type: st.isDirectory() ? 'directory' : st.isSymbolicLink() ? 'symlink' : 'file' };
|
|
239
|
+
if (st.isDirectory() && settings.recursive === false) await fs.rmdir(file);
|
|
240
|
+
else await fs.rm(file, { recursive: st.isDirectory(), force: false });
|
|
241
|
+
return { changed: true, path: file };
|
|
242
|
+
}, settings);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { baseDir, readOnly, allowDelete, resolve, locked, previous, write, transfer, remove };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = { createFiles, pathKey, inside, canonical, statMaybe };
|