mcp-read-file-server 1.5.0 → 1.8.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 +138 -7
- package/SKILL.md +34 -14
- package/index.js +1026 -65
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -25,9 +25,26 @@
|
|
|
25
25
|
* - create_directory 递归创建目录
|
|
26
26
|
* - file_info 查询文件/目录信息
|
|
27
27
|
* - check_status 检查工具运行状态(可实测解密能力)
|
|
28
|
+
* - encryption_profile 查看环境探测结果(扩展名分类/可用进程/最佳组合)
|
|
29
|
+
* - refresh_profile 强制重新探测环境并更新缓存
|
|
30
|
+
* - mark_extension 手动标注扩展名写入策略(protected 保持加密 / unsafe 保持明文 / clear 清除标注)
|
|
31
|
+
*
|
|
32
|
+
* 环境自适应(1.7.0):加密软件按目标扩展名决定是否透明加密且各机策略不同,
|
|
33
|
+
* 本 Server 首次启动时自动探测本机扩展名分类(safe/protected/unsafe)与可用
|
|
34
|
+
* 外部进程(powershell/cmd/robocopy 等),unsafe 扩展名走 safeWrite 中转落盘,
|
|
35
|
+
* 探测结果按 machineId 缓存到 ~/.mcp-encryption-profile.json,换机自动重探。
|
|
36
|
+
*
|
|
37
|
+
* 写入后实时重分类(1.8.0):启动探测只给先验分类,且 Node.js 白名单读回无法
|
|
38
|
+
* 区分「真受控」与「伪受控」。所有直写路径完成后用外部进程读磁盘原始字节实测:
|
|
39
|
+
* 磁盘为密文(命中 %TSD 魔数)→ 自动将该扩展名重分类为 encrypted 并立即用
|
|
40
|
+
* safeWrite 重写为明文;磁盘为明文 → 重分类为 safe。用户可用 mark_extension
|
|
41
|
+
* 手动标注 protected(保持加密,跳过自动纠正)或 unsafe(强制 safeWrite)。
|
|
28
42
|
*/
|
|
29
43
|
const fs = require("fs");
|
|
30
44
|
const path = require("path");
|
|
45
|
+
const os = require("os");
|
|
46
|
+
const crypto = require("crypto");
|
|
47
|
+
const { spawnSync } = require("child_process");
|
|
31
48
|
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
32
49
|
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
33
50
|
const { z } = require("zod");
|
|
@@ -230,6 +247,749 @@ function readFilePrefix(filePath, budgetBytes) {
|
|
|
230
247
|
}
|
|
231
248
|
}
|
|
232
249
|
|
|
250
|
+
// ==================== 环境自适应探测(加密策略感知,1.7.0 新增) ====================
|
|
251
|
+
// 背景:加密软件按「目标文件扩展名」决定是否透明加密,且不同电脑策略不同:
|
|
252
|
+
// safe 写入后磁盘为明文(任何设备可读)
|
|
253
|
+
// protected 写入后磁盘为密文,但 Node.js 白名单读回 == 写入内容(本机自动解密)
|
|
254
|
+
// unsafe 写入后磁盘为密文,且 Node.js 读回 != 写入内容(本机也无法解密,乱码)
|
|
255
|
+
// unsafe 扩展名的写入必须走 safeWrite:先写安全扩展名临时文件(磁盘明文),
|
|
256
|
+
// 再由可用外部进程(非白名单进程,复制不触发透明加密)复制到目标路径。
|
|
257
|
+
// 探测结果按 machineId 缓存,换电脑/缓存过期自动重新探测,不硬编码任何结论。
|
|
258
|
+
|
|
259
|
+
// 探测结果缓存文件(绑定机器,machineId 不一致即重新探测)
|
|
260
|
+
const PROFILE_CACHE_PATH = path.join(os.homedir(), ".mcp-encryption-profile.json");
|
|
261
|
+
// 缓存有效期:30 天(加密策略可能被管理员调整,过期自动重探)
|
|
262
|
+
const PROFILE_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
263
|
+
// 缓存结构版本:结构变更时递增,旧缓存自动作废
|
|
264
|
+
// v2:新增 encryptedExtensions(写入后实测磁盘为密文的扩展名)、
|
|
265
|
+
// userProtectedExtensions(用户手动标注保持加密)、postWriteDetectionEnabled
|
|
266
|
+
const PROFILE_CACHE_VERSION = 2;
|
|
267
|
+
// 候选「安全扩展名」探测清单:仅为探测对象,实测分类因机而异,不做任何硬编码结论
|
|
268
|
+
const PROBE_SAFE_CANDIDATES = [".tmp", ".md", ".txt", ".log", ".dat", ".bak", ".cache", ".temp", ".mcp_tmp"];
|
|
269
|
+
// 候选「其他常见扩展名」探测清单:用于完整分类与交叉验证时寻找 unsafe 样例
|
|
270
|
+
const PROBE_OTHER_CANDIDATES = [".java", ".xml", ".js", ".ts", ".py", ".html", ".json", ".scss", ".css", ".less", ".yaml", ".sql"];
|
|
271
|
+
// 探测内容:长度刻意避开 4096/8192 等加密块对齐值,
|
|
272
|
+
// 使「物理大小 != 内容字节数」成为可靠的密文信号(TSD 密文按固定块大小落盘)
|
|
273
|
+
const PROBE_CONTENT = "mcp-encryption-probe|" + "0123456789abcdef".repeat(9) + "|eol\n";
|
|
274
|
+
const PROBE_BYTES = Buffer.byteLength(PROBE_CONTENT, "utf-8");
|
|
275
|
+
const PROBE_HEX_PREFIX = Buffer.from(PROBE_CONTENT, "utf-8").subarray(0, 16).toString("hex");
|
|
276
|
+
// TSD 加密文件头魔数(%TSD = 25 54 53 44),作为外部进程读原始字节时的密文佐证
|
|
277
|
+
const TSD_MAGIC_HEX = "25545344";
|
|
278
|
+
// 候选外部进程清单:exe 仅作探测对象,可用性实测决定(部分机器 MCP Server 可能无法 spawn)
|
|
279
|
+
const PROCESS_CANDIDATES = [
|
|
280
|
+
{ id: "powershell", exe: "powershell.exe", canReadBytes: true },
|
|
281
|
+
{ id: "pwsh", exe: "pwsh.exe", canReadBytes: true },
|
|
282
|
+
{ id: "cmd", exe: "cmd.exe", canReadBytes: false },
|
|
283
|
+
{ id: "robocopy", exe: "robocopy.exe", canReadBytes: false },
|
|
284
|
+
{ id: "cscript", exe: "cscript.exe", canReadBytes: false },
|
|
285
|
+
];
|
|
286
|
+
// 探测文件命名计数器,保证同一临时目录内文件名唯一
|
|
287
|
+
let probeFileCounter = 0;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 计算机器指纹:hostname + username 的 SHA-256 截断值。
|
|
291
|
+
* 用于绑定探测缓存,换电脑(或换用户)时缓存自动失效并重新探测。
|
|
292
|
+
*/
|
|
293
|
+
function getMachineId() {
|
|
294
|
+
let username = "";
|
|
295
|
+
try { username = os.userInfo().username; } catch (e) { /* 取不到用户名时仅按 hostname 区分 */ }
|
|
296
|
+
return crypto.createHash("sha256").update(os.hostname() + "|" + username).digest("hex").slice(0, 16);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 读取探测结果缓存。校验结构版本、machineId 与有效期,任一不满足返回 null(触发重探)。
|
|
301
|
+
* 缓存文件损坏/不可读时同样返回 null,不影响启动。
|
|
302
|
+
*/
|
|
303
|
+
function loadProfileCache() {
|
|
304
|
+
try {
|
|
305
|
+
const raw = fs.readFileSync(PROFILE_CACHE_PATH, "utf-8");
|
|
306
|
+
const p = JSON.parse(raw);
|
|
307
|
+
if (!p || p.version !== PROFILE_CACHE_VERSION) return null;
|
|
308
|
+
if (p.machineId !== getMachineId()) return null; // 换电脑/换用户
|
|
309
|
+
if (!p.detectedAt || Date.now() - new Date(p.detectedAt).getTime() > PROFILE_CACHE_TTL_MS) return null;
|
|
310
|
+
if (!Array.isArray(p.safeExtensions) || !Array.isArray(p.availableProcesses)) return null;
|
|
311
|
+
// v2 新增字段兜底初始化(防手工编辑缓存导致字段缺失)
|
|
312
|
+
if (!Array.isArray(p.encryptedExtensions)) p.encryptedExtensions = [];
|
|
313
|
+
if (!Array.isArray(p.userProtectedExtensions)) p.userProtectedExtensions = [];
|
|
314
|
+
if (p.postWriteDetectionEnabled === undefined) p.postWriteDetectionEnabled = true;
|
|
315
|
+
return p;
|
|
316
|
+
} catch (e) {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 持久化探测结果到缓存文件(best-effort:写失败仅影响下次启动重探,不阻断运行)。
|
|
323
|
+
*/
|
|
324
|
+
function saveProfileCache(profile) {
|
|
325
|
+
try {
|
|
326
|
+
fs.writeFileSync(PROFILE_CACHE_PATH, JSON.stringify(profile, null, 2), "utf-8");
|
|
327
|
+
} catch (e) { /* 缓存写失败可忽略,下次启动重新探测 */ }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* PowerShell 单引号字符串转义:单引号按 '' doubling,防路径含引号时命令注入/断裂。
|
|
332
|
+
*/
|
|
333
|
+
function psQuote(p) {
|
|
334
|
+
return "'" + String(p).replace(/'/g, "''") + "'";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 通过外部进程(powershell/pwsh)读取文件磁盘原始字节的前 byteCount 字节(十六进制小写)。
|
|
339
|
+
* 外部进程不在加密软件白名单内,读到的是磁盘真实字节(密文文件即密文头部),
|
|
340
|
+
* 这是「磁盘是否密文」最可靠的判据。进程不可用/执行失败/输出异常时返回 null(未知),
|
|
341
|
+
* 调用方需退化为其他判据(物理大小对比、Node 读回对比)。
|
|
342
|
+
*/
|
|
343
|
+
function externalReadHexPrefix(procId, filePath, byteCount) {
|
|
344
|
+
const exe = procId === "pwsh" ? "pwsh.exe" : "powershell.exe";
|
|
345
|
+
const cmd = "$b=[System.IO.File]::ReadAllBytes(" + psQuote(filePath) + ");" +
|
|
346
|
+
"$n=[Math]::Min($b.Length," + byteCount + ");" +
|
|
347
|
+
"if($n -gt 0){[BitConverter]::ToString($b[0..($n-1)])}";
|
|
348
|
+
try {
|
|
349
|
+
const r = spawnSync(exe, ["-NoProfile", "-NonInteractive", "-Command", cmd], { timeout: 10000, windowsHide: true, encoding: "utf-8" });
|
|
350
|
+
if (r.error || r.status !== 0) return null; // EPERM/超时/策略拦截均按未知处理
|
|
351
|
+
const hex = String(r.stdout || "").trim().replace(/-/g, "").toLowerCase();
|
|
352
|
+
return /^[0-9a-f]*$/.test(hex) ? hex : null;
|
|
353
|
+
} catch (e) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* cscript 复制脚本路径(懒初始化):JScript 内容用安全扩展名临时文件落盘,
|
|
360
|
+
* 通过 //E:JScript 强制指定脚本引擎(不受临时文件扩展名限制)。
|
|
361
|
+
*/
|
|
362
|
+
let cscriptHelperPath = null;
|
|
363
|
+
function getCscriptHelperPath() {
|
|
364
|
+
if (cscriptHelperPath && fs.existsSync(cscriptHelperPath)) return cscriptHelperPath;
|
|
365
|
+
const p = path.join(os.tmpdir(), "mcp-enc-copy-" + process.pid + ".tmp");
|
|
366
|
+
fs.writeFileSync(p, 'var f=new ActiveXObject("Scripting.FileSystemObject");f.CopyFile(WScript.Arguments(0),WScript.Arguments(1),true);', "utf-8");
|
|
367
|
+
cscriptHelperPath = p;
|
|
368
|
+
return p;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* 用指定外部进程将 src 复制为 dst(dst 可不同名)。复制动作由非白名单进程执行,
|
|
373
|
+
* 不触发加密软件的透明加密,从而把「安全扩展名的明文临时文件」落到 unsafe 扩展名目标上。
|
|
374
|
+
* 各进程参数差异在内部抹平:robocopy 不支持改名(复制后由 fs.renameSync 改名,
|
|
375
|
+
* rename 不重写文件内容,不触发加密);cmd 用 copy /y;cscript 走 JScript FileSystemObject。
|
|
376
|
+
* 失败抛出 Error(含进程退出码/错误信息),成功返回 undefined。
|
|
377
|
+
*/
|
|
378
|
+
function externalCopyFile(procId, src, dst) {
|
|
379
|
+
const opts = { timeout: 20000, windowsHide: true, encoding: "utf-8" };
|
|
380
|
+
if (procId === "powershell" || procId === "pwsh") {
|
|
381
|
+
const exe = procId === "powershell" ? "powershell.exe" : "pwsh.exe";
|
|
382
|
+
const r = spawnSync(exe, ["-NoProfile", "-NonInteractive", "-Command",
|
|
383
|
+
"Copy-Item -LiteralPath " + psQuote(src) + " -Destination " + psQuote(dst) + " -Force"], opts);
|
|
384
|
+
if (r.error) throw r.error;
|
|
385
|
+
if (r.status !== 0) throw new Error(procId + " 退出码 " + r.status + ": " + String(r.stderr || "").slice(0, 200));
|
|
386
|
+
} else if (procId === "cmd") {
|
|
387
|
+
// windowsVerbatimArguments:参数含空格时 Node 默认会再加一层引号,
|
|
388
|
+
// 导致 cmd /c 收到嵌套引号解析失败("文件名、目录名或卷标语法不正确")
|
|
389
|
+
const r = spawnSync("cmd.exe", ["/c", 'copy /y "' + src + '" "' + dst + '"'], Object.assign({}, opts, { windowsVerbatimArguments: true }));
|
|
390
|
+
if (r.error) throw r.error;
|
|
391
|
+
if (r.status !== 0) throw new Error("cmd 退出码 " + r.status + ": " + String(r.stderr || r.stdout || "").slice(0, 200));
|
|
392
|
+
} else if (procId === "robocopy") {
|
|
393
|
+
// robocopy 语义为「目录到目录 + 文件名」,不支持目标改名;退出码 0-7 均为成功
|
|
394
|
+
const r = spawnSync("robocopy.exe", [path.dirname(src), path.dirname(dst), path.basename(src),
|
|
395
|
+
"/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP"], opts);
|
|
396
|
+
if (r.error) throw r.error;
|
|
397
|
+
if (r.status === null || r.status >= 8) throw new Error("robocopy 退出码 " + r.status);
|
|
398
|
+
const copied = path.join(path.dirname(dst), path.basename(src));
|
|
399
|
+
if (path.resolve(copied) !== path.resolve(dst)) {
|
|
400
|
+
fs.renameSync(copied, dst); // 改名不落盘内容,安全
|
|
401
|
+
}
|
|
402
|
+
} else if (procId === "cscript") {
|
|
403
|
+
const r = spawnSync("cscript.exe", ["//nologo", "//E:JScript", getCscriptHelperPath(), src, dst], opts);
|
|
404
|
+
if (r.error) throw r.error;
|
|
405
|
+
if (r.status !== 0) throw new Error("cscript 退出码 " + r.status + ": " + String(r.stderr || r.stdout || "").slice(0, 200));
|
|
406
|
+
} else {
|
|
407
|
+
throw new Error("未知进程类型: " + procId);
|
|
408
|
+
}
|
|
409
|
+
if (!fs.existsSync(dst)) throw new Error(procId + " 复制后目标文件不存在");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 分类单个扩展名:在指定目录写入探测文件,通过三重交叉验证判断磁盘状态。
|
|
414
|
+
* 判定逻辑:
|
|
415
|
+
* ① Node 读回 != 写入内容 → 必为密文且无法解密(unsafe)
|
|
416
|
+
* ② 读回一致时,外部进程读磁盘原始字节 != 写入前缀(或命中 %TSD 魔数)→ protected
|
|
417
|
+
* ③ 无字节读取器时退化用物理大小对比:stat.size != 内容字节数 → protected
|
|
418
|
+
* ④ 以上均不命中 → safe(磁盘明文)
|
|
419
|
+
* readerProcId 为 null 时跳过方式②(进程不可用环境自动降级)。
|
|
420
|
+
*/
|
|
421
|
+
function classifyExtension(ext, readerProcId, probeDir) {
|
|
422
|
+
const dir = probeDir || os.tmpdir();
|
|
423
|
+
const f = path.join(dir, "mcp-cls-" + process.pid + "-" + (++probeFileCounter) + ext);
|
|
424
|
+
try {
|
|
425
|
+
fs.writeFileSync(f, PROBE_CONTENT, "utf-8");
|
|
426
|
+
let readBack = null;
|
|
427
|
+
try { readBack = fs.readFileSync(f, "utf-8"); } catch (e) { /* 读回失败按不可解密处理 */ }
|
|
428
|
+
let encrypted = readBack !== PROBE_CONTENT; // 判据①:读回不一致即密文(且不可解密)
|
|
429
|
+
if (!encrypted) {
|
|
430
|
+
if (readerProcId) {
|
|
431
|
+
// 判据②:磁盘原始字节对比(最可靠,通用)+ %TSD 魔数(佐证)
|
|
432
|
+
const hex = externalReadHexPrefix(readerProcId, f, 16);
|
|
433
|
+
if (hex !== null) {
|
|
434
|
+
encrypted = hex !== PROBE_HEX_PREFIX || hex.slice(0, 8) === TSD_MAGIC_HEX;
|
|
435
|
+
} else {
|
|
436
|
+
encrypted = isSizeAnomaly(f);
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
// 判据③:物理大小对比(无外部进程可用时的降级方案)
|
|
440
|
+
encrypted = isSizeAnomaly(f);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (!encrypted) return "safe";
|
|
444
|
+
return readBack === PROBE_CONTENT ? "protected" : "unsafe";
|
|
445
|
+
} finally {
|
|
446
|
+
try { fs.rmSync(f, { force: true }); } catch (e) { /* 清理失败可忽略 */ }
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* 物理大小异常检测:探测内容长度刻意避开加密块对齐值,
|
|
452
|
+
* stat.size 与内容字节数不一致即说明落盘的是密文(TSD 密文按固定块大小落盘)。
|
|
453
|
+
*/
|
|
454
|
+
function isSizeAnomaly(filePath) {
|
|
455
|
+
try {
|
|
456
|
+
return fs.statSync(filePath).size !== PROBE_BYTES;
|
|
457
|
+
} catch (e) {
|
|
458
|
+
return false; // stat 失败按非密文处理(后续读回对比兜底)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* 探测单个外部进程的复制能力:用已知安全扩展名的明文文件做一次真实复制,
|
|
464
|
+
* 并以 Node 读回验证复制产物内容一致(进程可能被策略拦截 spawn EPERM,或能启动但写盘受限)。
|
|
465
|
+
*/
|
|
466
|
+
function probeCopier(spec, safeExt, probeDir) {
|
|
467
|
+
const src = path.join(probeDir, "proc-src" + safeExt);
|
|
468
|
+
const dst = path.join(probeDir, "proc-dst" + safeExt);
|
|
469
|
+
try {
|
|
470
|
+
fs.writeFileSync(src, PROBE_CONTENT, "utf-8");
|
|
471
|
+
externalCopyFile(spec.id, src, dst);
|
|
472
|
+
return fs.readFileSync(dst, "utf-8") === PROBE_CONTENT;
|
|
473
|
+
} catch (e) {
|
|
474
|
+
return false;
|
|
475
|
+
} finally {
|
|
476
|
+
try { fs.rmSync(src, { force: true }); } catch (e) { /* 忽略 */ }
|
|
477
|
+
try { fs.rmSync(dst, { force: true }); } catch (e) { /* 忽略 */ }
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* 探测外部进程的原始字节读取能力:读一个已知明文文件的前 16 字节,
|
|
483
|
+
* 输出与期望前缀完全一致才认为可用(防止进程能启动但输出被策略篡改/为空)。
|
|
484
|
+
*/
|
|
485
|
+
function probeReader(procId, safeExt, probeDir) {
|
|
486
|
+
const f = path.join(probeDir, "reader" + safeExt);
|
|
487
|
+
try {
|
|
488
|
+
fs.writeFileSync(f, PROBE_CONTENT, "utf-8");
|
|
489
|
+
return externalReadHexPrefix(procId, f, 16) === PROBE_HEX_PREFIX;
|
|
490
|
+
} catch (e) {
|
|
491
|
+
return false;
|
|
492
|
+
} finally {
|
|
493
|
+
try { fs.rmSync(f, { force: true }); } catch (e) { /* 忽略 */ }
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* 交叉验证一组「安全扩展名 + 外部进程」组合:
|
|
499
|
+
* 将安全扩展名的明文文件经外部进程复制为 unsafe 扩展名目标,
|
|
500
|
+
* Node 读回与写入一致即证明该组合可在 unsafe 扩展名上落盘明文
|
|
501
|
+
* (unsafe 扩展名若被透明加密,Node 读回必不一致)。
|
|
502
|
+
*/
|
|
503
|
+
function crossValidateCombo(safeExt, procId, unsafeExt, probeDir) {
|
|
504
|
+
const src = path.join(probeDir, "xv-src" + safeExt);
|
|
505
|
+
const dst = path.join(probeDir, "xv-dst-" + (++probeFileCounter) + unsafeExt);
|
|
506
|
+
try {
|
|
507
|
+
fs.writeFileSync(src, PROBE_CONTENT, "utf-8");
|
|
508
|
+
externalCopyFile(procId, src, dst);
|
|
509
|
+
return fs.readFileSync(dst, "utf-8") === PROBE_CONTENT;
|
|
510
|
+
} catch (e) {
|
|
511
|
+
return false;
|
|
512
|
+
} finally {
|
|
513
|
+
try { fs.rmSync(src, { force: true }); } catch (e) { /* 忽略 */ }
|
|
514
|
+
try { fs.rmSync(dst, { force: true }); } catch (e) { /* 忽略 */ }
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* 环境探测主函数:在系统临时目录执行全部探测(不污染用户目录),返回 profile 对象。
|
|
520
|
+
* 流程:① 无进程粗分类安全候选,找到至少一个 safe 扩展名(供进程探测造文件)
|
|
521
|
+
* ② 逐个探测外部进程的复制/字节读取能力
|
|
522
|
+
* ③ 用字节读取器(若有)对全部候选扩展名做精确分类
|
|
523
|
+
* ④ 交叉验证「safe 扩展名 × 可用进程」组合,确定 bestCombo
|
|
524
|
+
* 任何单步失败都降级处理,保证返回结构完整的 profile(可能为空列表)。
|
|
525
|
+
*/
|
|
526
|
+
function detectEnvironment() {
|
|
527
|
+
const profile = {
|
|
528
|
+
version: PROFILE_CACHE_VERSION,
|
|
529
|
+
machineId: getMachineId(),
|
|
530
|
+
detectedAt: new Date().toISOString(),
|
|
531
|
+
safeExtensions: [],
|
|
532
|
+
protectedExtensions: [],
|
|
533
|
+
unsafeExtensions: [],
|
|
534
|
+
encryptedExtensions: [],
|
|
535
|
+
userProtectedExtensions: [],
|
|
536
|
+
postWriteDetectionEnabled: true,
|
|
537
|
+
availableProcesses: [],
|
|
538
|
+
byteReader: null,
|
|
539
|
+
bestCombo: null,
|
|
540
|
+
};
|
|
541
|
+
let probeDir = null;
|
|
542
|
+
try {
|
|
543
|
+
probeDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-enc-probe-"));
|
|
544
|
+
// ① 粗分类安全候选(无字节读取器,用大小+读回对比),找到第一个 safe 即停
|
|
545
|
+
let firstSafe = null;
|
|
546
|
+
for (const ext of PROBE_SAFE_CANDIDATES) {
|
|
547
|
+
let cat;
|
|
548
|
+
try { cat = classifyExtension(ext, null, probeDir); } catch (e) { continue; }
|
|
549
|
+
if (cat === "safe") { firstSafe = ext; break; }
|
|
550
|
+
}
|
|
551
|
+
// ② 探测外部进程(需要 safe 扩展名造明文测试文件;无 safe 则进程探测无意义)
|
|
552
|
+
if (firstSafe) {
|
|
553
|
+
for (const spec of PROCESS_CANDIDATES) {
|
|
554
|
+
try {
|
|
555
|
+
if (probeCopier(spec, firstSafe, probeDir)) {
|
|
556
|
+
profile.availableProcesses.push({ id: spec.id, exe: spec.exe });
|
|
557
|
+
if (spec.canReadBytes && !profile.byteReader && probeReader(spec.id, firstSafe, probeDir)) {
|
|
558
|
+
profile.byteReader = spec.id;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
} catch (e) { /* 单个进程探测失败不影响其他候选 */ }
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
// ③ 精确分类全部候选扩展名(有字节读取器时结果最可靠)
|
|
565
|
+
for (const ext of PROBE_SAFE_CANDIDATES.concat(PROBE_OTHER_CANDIDATES)) {
|
|
566
|
+
let cat;
|
|
567
|
+
try { cat = classifyExtension(ext, profile.byteReader, probeDir); } catch (e) { continue; }
|
|
568
|
+
if (cat === "safe") profile.safeExtensions.push(ext);
|
|
569
|
+
else if (cat === "protected") profile.protectedExtensions.push(ext);
|
|
570
|
+
else profile.unsafeExtensions.push(ext);
|
|
571
|
+
}
|
|
572
|
+
// ④ 交叉验证找最佳组合(无 unsafe 扩展名时无需 bestCombo,全部直写即可)
|
|
573
|
+
if (profile.unsafeExtensions.length && profile.availableProcesses.length && profile.safeExtensions.length) {
|
|
574
|
+
const unsafeExt = profile.unsafeExtensions[0];
|
|
575
|
+
let found = null;
|
|
576
|
+
for (const ext of profile.safeExtensions) {
|
|
577
|
+
for (const proc of profile.availableProcesses) {
|
|
578
|
+
try {
|
|
579
|
+
if (crossValidateCombo(ext, proc.id, unsafeExt, probeDir)) { found = { extension: ext, process: proc.id }; break; }
|
|
580
|
+
} catch (e) { /* 继续下一组合 */ }
|
|
581
|
+
}
|
|
582
|
+
if (found) break;
|
|
583
|
+
}
|
|
584
|
+
profile.bestCombo = found;
|
|
585
|
+
}
|
|
586
|
+
} catch (e) {
|
|
587
|
+
profile.detectError = e.message; // 整体探测失败也返回结构完整 profile
|
|
588
|
+
} finally {
|
|
589
|
+
if (probeDir) { try { fs.rmSync(probeDir, { recursive: true, force: true }); } catch (e) { /* 忽略 */ } }
|
|
590
|
+
}
|
|
591
|
+
return profile;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// 内存中缓存的活动 profile(避免每次写入都读盘/探测)
|
|
595
|
+
let activeProfile = null;
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* 获取当前环境 profile(懒加载):优先内存,其次磁盘缓存(校验 machineId/有效期),
|
|
599
|
+
* 最后执行完整探测并落盘缓存。任何环节失败都返回结构完整的降级 profile,绝不阻断工具调用。
|
|
600
|
+
*/
|
|
601
|
+
function getProfile() {
|
|
602
|
+
if (activeProfile) return activeProfile;
|
|
603
|
+
const cached = loadProfileCache();
|
|
604
|
+
if (cached) { activeProfile = cached; return activeProfile; }
|
|
605
|
+
try {
|
|
606
|
+
activeProfile = detectEnvironment();
|
|
607
|
+
saveProfileCache(activeProfile);
|
|
608
|
+
} catch (e) {
|
|
609
|
+
// 探测整体异常时的兜底 profile:全部直写(保持 1.6.0 原始行为)
|
|
610
|
+
activeProfile = {
|
|
611
|
+
version: PROFILE_CACHE_VERSION, machineId: getMachineId(), detectedAt: new Date().toISOString(),
|
|
612
|
+
safeExtensions: [], protectedExtensions: [], unsafeExtensions: [],
|
|
613
|
+
encryptedExtensions: [], userProtectedExtensions: [], postWriteDetectionEnabled: true,
|
|
614
|
+
availableProcesses: [], byteReader: null, bestCombo: null, detectError: e.message,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
// 防御:任何来源的 profile 都补齐 v2 字段(防手工编辑缓存/未来降级路径遗漏)
|
|
618
|
+
if (!Array.isArray(activeProfile.encryptedExtensions)) activeProfile.encryptedExtensions = [];
|
|
619
|
+
if (!Array.isArray(activeProfile.userProtectedExtensions)) activeProfile.userProtectedExtensions = [];
|
|
620
|
+
if (activeProfile.postWriteDetectionEnabled === undefined) activeProfile.postWriteDetectionEnabled = true;
|
|
621
|
+
return activeProfile;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* 查询扩展名分类(写入前调用):先查 profile 已知列表;未知扩展名按需即时探测,
|
|
626
|
+
* 结果并入 profile 并 best-effort 持久化(同一扩展名只探测一次)。
|
|
627
|
+
* targetDir 为目标文件所在目录:加密策略可能按目录范围生效(如系统临时目录常被排除),
|
|
628
|
+
* 按需探测优先在目标目录内进行(探测文件立即删除),不可写时回退系统临时目录。
|
|
629
|
+
*/
|
|
630
|
+
function classifyExtForWrite(ext, targetDir) {
|
|
631
|
+
const p = getProfile();
|
|
632
|
+
if (p.safeExtensions.indexOf(ext) !== -1) return "safe";
|
|
633
|
+
if (p.protectedExtensions.indexOf(ext) !== -1) return "protected";
|
|
634
|
+
if (p.unsafeExtensions.indexOf(ext) !== -1) return "unsafe";
|
|
635
|
+
let cat = "safe"; // 探测异常时默认直写(保持原始行为)
|
|
636
|
+
// 探测目录:优先目标目录(策略按目录生效时结果才准确),不可写则回退 os.tmpdir()
|
|
637
|
+
let probeDir = null;
|
|
638
|
+
if (targetDir) {
|
|
639
|
+
try { fs.accessSync(targetDir, fs.constants.W_OK); probeDir = targetDir; } catch (e) { /* 回退 tmpdir */ }
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
cat = classifyExtension(ext, p.byteReader, probeDir);
|
|
643
|
+
} catch (e) { /* 探测失败按 safe 直写处理 */ }
|
|
644
|
+
const listKey = cat + "Extensions";
|
|
645
|
+
if (Array.isArray(p[listKey]) && p[listKey].indexOf(ext) === -1) {
|
|
646
|
+
p[listKey].push(ext);
|
|
647
|
+
saveProfileCache(p);
|
|
648
|
+
}
|
|
649
|
+
return cat;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* 写入后实时检测目标文件的磁盘真实状态,并按结果更新扩展名分类(1.8.0 新增)。
|
|
654
|
+
* 动机:启动探测只能给出扩展名的先验分类,且 Node.js 白名单读回无法区分
|
|
655
|
+
* 「TSD 真受控」与「策略动态变化/目录级差异导致的伪受控」。写入后用外部进程
|
|
656
|
+
* 读磁盘原始字节是最可靠的实测判据,可即时纠正误分类:
|
|
657
|
+
* - 磁盘字节 == 写入内容前缀 → 磁盘明文 → 该扩展名归入 safe
|
|
658
|
+
* - 磁盘字节命中 %TSD 魔数 → 磁盘密文 → 该扩展名归入 encrypted
|
|
659
|
+
* 返回 { diskPlaintext, category: "safe"|"encrypted"|"unknown" };
|
|
660
|
+
* 无字节读取器/无扩展名/检测异常时返回 unknown 且不改动分类。
|
|
661
|
+
*/
|
|
662
|
+
function detectDiskStateAfterWrite(filePath, expectedContent) {
|
|
663
|
+
try {
|
|
664
|
+
const profile = getProfile();
|
|
665
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
666
|
+
if (!ext || !profile.byteReader) return { diskPlaintext: true, category: "unknown" };
|
|
667
|
+
const hex = externalReadHexPrefix(profile.byteReader, filePath, 16);
|
|
668
|
+
if (hex === null) return { diskPlaintext: true, category: "unknown" };
|
|
669
|
+
const expectedHex = Buffer.from(expectedContent, "utf-8").subarray(0, 16).toString("hex");
|
|
670
|
+
const isTsd = hex.slice(0, 8) === TSD_MAGIC_HEX;
|
|
671
|
+
if (!isTsd && hex === expectedHex) {
|
|
672
|
+
updateExtCategory(ext, "safe");
|
|
673
|
+
return { diskPlaintext: true, category: "safe" };
|
|
674
|
+
}
|
|
675
|
+
if (isTsd) {
|
|
676
|
+
updateExtCategory(ext, "encrypted");
|
|
677
|
+
return { diskPlaintext: false, category: "encrypted" };
|
|
678
|
+
}
|
|
679
|
+
return { diskPlaintext: true, category: "unknown" };
|
|
680
|
+
} catch (e) {
|
|
681
|
+
return { diskPlaintext: true, category: "unknown" };
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* 根据写入后的磁盘实测状态更新扩展名分类,并持久化到缓存(1.8.0 新增)。
|
|
687
|
+
* 从全部分类列表中移除该扩展名后写入目标列表:
|
|
688
|
+
* safe → safeExtensions(直写,磁盘明文)
|
|
689
|
+
* encrypted → encryptedExtensions(磁盘密文,后续写入走 safeWrite 保持明文)
|
|
690
|
+
* 用户手动标注(userProtectedExtensions)优先级最高,自动重分类不会覆盖
|
|
691
|
+
* (标注为 protected 的扩展名不会被自动改判;标注为 unsafe 的扩展名已锁定
|
|
692
|
+
* 走 safeWrite,实测明文时也不回改,由用户用 mark_extension clear 解除)。
|
|
693
|
+
*/
|
|
694
|
+
function updateExtCategory(ext, newCategory) {
|
|
695
|
+
const p = getProfile();
|
|
696
|
+
if (!ext) return;
|
|
697
|
+
// 用户手动标注优先:protected 锁定保持加密,自动检测不得改判
|
|
698
|
+
if (Array.isArray(p.userProtectedExtensions) && p.userProtectedExtensions.indexOf(ext) !== -1) return;
|
|
699
|
+
for (const key of ["safeExtensions", "protectedExtensions", "unsafeExtensions", "encryptedExtensions"]) {
|
|
700
|
+
const arr = p[key];
|
|
701
|
+
if (Array.isArray(arr)) {
|
|
702
|
+
const idx = arr.indexOf(ext);
|
|
703
|
+
if (idx !== -1) arr.splice(idx, 1);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const listKey = newCategory === "safe" ? "safeExtensions" : "encryptedExtensions";
|
|
707
|
+
if (!Array.isArray(p[listKey])) p[listKey] = [];
|
|
708
|
+
if (p[listKey].indexOf(ext) === -1) p[listKey].push(ext);
|
|
709
|
+
saveProfileCache(p);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* 写入后发现磁盘为密文时的拯救流程(1.8.0 新增):
|
|
714
|
+
* 用 safeWrite 将同一份内容重新落盘为明文。返回 safeWriteFile 的结果。
|
|
715
|
+
* 调用方需保证已确认磁盘为密文(detectDiskStateAfterWrite.diskPlaintext === false)。
|
|
716
|
+
*/
|
|
717
|
+
function rescueToPlaintext(filePath, content) {
|
|
718
|
+
return safeWriteFile(filePath, content);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* 写入策略决策(1.8.0 版):优先级从高到低——
|
|
723
|
+
* 1. userProtectedExtensions(用户标注保持加密)→ direct 直写
|
|
724
|
+
* 2. encryptedExtensions(写入后实测磁盘密文)→ safe(safeWrite 保持明文)
|
|
725
|
+
* 3. unsafeExtensions(启动探测 unsafe)→ safe(safeWrite 保持明文)
|
|
726
|
+
* 4. 已知 safe/protected → direct 直写
|
|
727
|
+
* 5. 未知扩展名 → direct_then_verify(先直写,写后实测磁盘状态并自动重分类/纠正)
|
|
728
|
+
*/
|
|
729
|
+
function decideWriteStrategy(filePath) {
|
|
730
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
731
|
+
if (!ext) return { mode: "direct", category: "none" };
|
|
732
|
+
try {
|
|
733
|
+
const p = getProfile();
|
|
734
|
+
if (Array.isArray(p.userProtectedExtensions) && p.userProtectedExtensions.indexOf(ext) !== -1) {
|
|
735
|
+
return { mode: "direct", category: "user_protected" };
|
|
736
|
+
}
|
|
737
|
+
if (Array.isArray(p.encryptedExtensions) && p.encryptedExtensions.indexOf(ext) !== -1) {
|
|
738
|
+
return { mode: "safe", category: "encrypted" };
|
|
739
|
+
}
|
|
740
|
+
if (Array.isArray(p.unsafeExtensions) && p.unsafeExtensions.indexOf(ext) !== -1) {
|
|
741
|
+
return { mode: "safe", category: "unsafe" };
|
|
742
|
+
}
|
|
743
|
+
if (p.safeExtensions.indexOf(ext) !== -1) return { mode: "direct", category: "safe" };
|
|
744
|
+
if (p.protectedExtensions.indexOf(ext) !== -1) return { mode: "direct", category: "protected" };
|
|
745
|
+
return { mode: "direct_then_verify", category: "unknown" };
|
|
746
|
+
} catch (e) {
|
|
747
|
+
return { mode: "direct", category: "unknown" };
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* 构造 safeWrite 可用组合列表:bestCombo 优先,其后为全部 safe扩展名 × 可用进程 的笛卡尔积(去重)。
|
|
753
|
+
*/
|
|
754
|
+
function buildWriteCombos(profile) {
|
|
755
|
+
const combos = [];
|
|
756
|
+
if (profile.bestCombo) combos.push(profile.bestCombo);
|
|
757
|
+
for (const ext of profile.safeExtensions) {
|
|
758
|
+
for (const proc of profile.availableProcesses) {
|
|
759
|
+
combos.push({ extension: ext, process: proc.id });
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const seen = new Set();
|
|
763
|
+
return combos.filter((c) => {
|
|
764
|
+
const k = c.extension + "|" + c.process;
|
|
765
|
+
if (seen.has(k)) return false;
|
|
766
|
+
seen.add(k);
|
|
767
|
+
return true;
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* 验证目标文件已按期望明文落盘:Node 读回与写入内容一致即视为明文
|
|
773
|
+
* (unsafe 扩展名若被透明加密成密文,Node 读回必不一致)。
|
|
774
|
+
* 有字节读取器时再叠加磁盘原始字节对比(双保险,防边缘情况)。
|
|
775
|
+
*/
|
|
776
|
+
function verifyPlaintextOnDisk(targetPath, expectedContent, profile) {
|
|
777
|
+
try {
|
|
778
|
+
if (fs.readFileSync(targetPath, "utf-8") !== expectedContent) return false;
|
|
779
|
+
} catch (e) {
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
if (profile && profile.byteReader) {
|
|
783
|
+
const hex = externalReadHexPrefix(profile.byteReader, targetPath, 16);
|
|
784
|
+
if (hex !== null) {
|
|
785
|
+
const expectedHex = Buffer.from(expectedContent, "utf-8").subarray(0, 16).toString("hex");
|
|
786
|
+
if (hex !== expectedHex || hex.slice(0, 8) === TSD_MAGIC_HEX) return false;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* safeWrite:把内容以明文落盘到 unsafe 扩展名目标。
|
|
794
|
+
* 流程(按组合逐个尝试,bestCombo 优先):
|
|
795
|
+
* 1. fs.writeFileSync 写入 targetPath + 安全扩展名 的临时文件(磁盘明文)
|
|
796
|
+
* 2. 用该组合的外部进程复制临时文件到 targetPath(非白名单进程不触发透明加密)
|
|
797
|
+
* 3. 校验 targetPath 磁盘字节为明文
|
|
798
|
+
* 4. 清理临时文件
|
|
799
|
+
* 返回 { ok, via:{extension,process}, error };全部组合失败返回 { ok:false, error },
|
|
800
|
+
* 由调用方决定是否回退直写(不重删 targetPath,避免破坏已有文件)。
|
|
801
|
+
*/
|
|
802
|
+
function safeWriteFile(targetPath, content) {
|
|
803
|
+
const profile = getProfile();
|
|
804
|
+
const combos = buildWriteCombos(profile);
|
|
805
|
+
if (!combos.length) {
|
|
806
|
+
return { ok: false, error: "无可用组合(未探测到 safe 扩展名或可用外部进程)" };
|
|
807
|
+
}
|
|
808
|
+
const parent = path.dirname(targetPath);
|
|
809
|
+
const errors = [];
|
|
810
|
+
for (const combo of combos) {
|
|
811
|
+
const tmpPath = targetPath + combo.extension;
|
|
812
|
+
try {
|
|
813
|
+
if (parent && !fs.existsSync(parent)) fs.mkdirSync(parent, { recursive: true });
|
|
814
|
+
fs.writeFileSync(tmpPath, content, "utf-8");
|
|
815
|
+
externalCopyFile(combo.process, tmpPath, targetPath);
|
|
816
|
+
if (verifyPlaintextOnDisk(targetPath, content, profile)) {
|
|
817
|
+
return { ok: true, via: combo };
|
|
818
|
+
}
|
|
819
|
+
errors.push(combo.process + "+" + combo.extension + ": 落盘校验非明文");
|
|
820
|
+
} catch (e) {
|
|
821
|
+
errors.push(combo.process + "+" + combo.extension + ": " + e.message);
|
|
822
|
+
} finally {
|
|
823
|
+
try { fs.rmSync(tmpPath, { force: true }); } catch (e) { /* 忽略 */ }
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return { ok: false, error: errors.join(";") || "全部组合失败" };
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* safeCopy 变体:供 copy_path / move_path 使用,把「已有源文件」复制到 unsafe 扩展名目标。
|
|
831
|
+
* 经白名单进程 cpSync 读出明文写入安全扩展名临时文件,再由外部进程复制到目标。
|
|
832
|
+
* 用 Buffer 对比验证(二进制安全)。返回结构同 safeWriteFile。
|
|
833
|
+
*/
|
|
834
|
+
function safeCopyFileTo(source, targetPath) {
|
|
835
|
+
const profile = getProfile();
|
|
836
|
+
const combos = buildWriteCombos(profile);
|
|
837
|
+
if (!combos.length) {
|
|
838
|
+
return { ok: false, error: "无可用组合(未探测到 safe 扩展名或可用外部进程)" };
|
|
839
|
+
}
|
|
840
|
+
const parent = path.dirname(targetPath);
|
|
841
|
+
const errors = [];
|
|
842
|
+
for (const combo of combos) {
|
|
843
|
+
const tmpPath = targetPath + combo.extension;
|
|
844
|
+
try {
|
|
845
|
+
if (parent && !fs.existsSync(parent)) fs.mkdirSync(parent, { recursive: true });
|
|
846
|
+
fs.cpSync(source, tmpPath, { force: true });
|
|
847
|
+
externalCopyFile(combo.process, tmpPath, targetPath);
|
|
848
|
+
// 读回对比(Buffer 级):源经白名单读为明文,目标一致即明文落盘
|
|
849
|
+
let ok = false;
|
|
850
|
+
try { ok = fs.readFileSync(targetPath).equals(fs.readFileSync(source)); } catch (e) { /* 保持 false */ }
|
|
851
|
+
if (ok) return { ok: true, via: combo };
|
|
852
|
+
errors.push(combo.process + "+" + combo.extension + ": 落盘校验不一致");
|
|
853
|
+
} catch (e) {
|
|
854
|
+
errors.push(combo.process + "+" + combo.extension + ": " + e.message);
|
|
855
|
+
} finally {
|
|
856
|
+
try { fs.rmSync(tmpPath, { force: true }); } catch (e) { /* 忽略 */ }
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return { ok: false, error: errors.join(";") || "全部组合失败" };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* 编辑写回统一入口(edit_file 用):unsafe/encrypted 扩展名走 safeWrite 保持磁盘明文,
|
|
864
|
+
* 其余直写。safeWrite 失败回退直写并标记 degraded(附带原因供调用方告警)。
|
|
865
|
+
* direct_then_verify(未知扩展名)与 direct 直写后执行写入后磁盘实测:
|
|
866
|
+
* 发现密文则自动重分类并立即用 safeWrite 重写为明文(autoCorrected 标记)。
|
|
867
|
+
* 返回 { ok, via, degraded, autoCorrected, correctedTo, error }。
|
|
868
|
+
*/
|
|
869
|
+
function writeBackWithStrategy(filePath, payload) {
|
|
870
|
+
const decision = decideWriteStrategy(filePath);
|
|
871
|
+
if (decision.mode === "safe") {
|
|
872
|
+
const r = safeWriteFile(filePath, payload);
|
|
873
|
+
if (r.ok) return { ok: true, via: r.via };
|
|
874
|
+
fs.writeFileSync(filePath, payload, "utf-8"); // 全部组合失败:回退直写并告警
|
|
875
|
+
return { ok: true, degraded: true, error: r.error };
|
|
876
|
+
}
|
|
877
|
+
fs.writeFileSync(filePath, payload, "utf-8");
|
|
878
|
+
// 写入后实测:未知扩展名(direct_then_verify)必测;已知 safe/protected 也复测
|
|
879
|
+
// (目录级策略差异或管理员调整策略时,启动探测结论可能已过期)。
|
|
880
|
+
// user_protected(用户手动标注保持加密)明确跳过:用户意图优先,不做自动纠正;
|
|
881
|
+
// 无字节读取器的环境检测必返回 unknown,自然无开销。
|
|
882
|
+
const p = getProfile();
|
|
883
|
+
if (p.postWriteDetectionEnabled && decision.category !== "user_protected") {
|
|
884
|
+
const ds = detectDiskStateAfterWrite(filePath, payload);
|
|
885
|
+
if (!ds.diskPlaintext) {
|
|
886
|
+
const sw = rescueToPlaintext(filePath, payload);
|
|
887
|
+
if (sw.ok) {
|
|
888
|
+
return { ok: true, autoCorrected: true, correctedTo: ds.category, via: sw.via };
|
|
889
|
+
}
|
|
890
|
+
return { ok: true, degraded: true, error: "写入后磁盘为密文且 safeWrite 纠正失败(" + sw.error + ")" };
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
return { ok: true };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* 生成 profile 概要文本(encryption_profile / refresh_profile / check_status 共用)。
|
|
898
|
+
*/
|
|
899
|
+
function formatProfileSummary(p) {
|
|
900
|
+
const fmt = (arr) => arr.length ? arr.join(" ") : "(无)";
|
|
901
|
+
const procs = p.availableProcesses.map((x) => x.id).join(", ") || "(无)";
|
|
902
|
+
const lines = [
|
|
903
|
+
"机器指纹 machineId: " + p.machineId,
|
|
904
|
+
"探测时间: " + p.detectedAt,
|
|
905
|
+
"safe 扩展名(磁盘明文,直写): " + fmt(p.safeExtensions),
|
|
906
|
+
"protected 扩展名(磁盘密文/白名单可解密,直写): " + fmt(p.protectedExtensions),
|
|
907
|
+
"unsafe 扩展名(磁盘密文/不可解密,走 safeWrite): " + fmt(p.unsafeExtensions),
|
|
908
|
+
"encrypted 扩展名(写入后实测磁盘密文,走 safeWrite): " + fmt(p.encryptedExtensions || []),
|
|
909
|
+
"用户标注保持加密 userProtected: " + fmt(p.userProtectedExtensions || []),
|
|
910
|
+
"写入后实时检测: " + (p.postWriteDetectionEnabled !== false ? "开启" : "关闭"),
|
|
911
|
+
"可用外部进程: " + procs,
|
|
912
|
+
"磁盘字节读取器: " + (p.byteReader || "(无,退化为大小+读回对比)"),
|
|
913
|
+
"最佳组合 bestCombo: " + (p.bestCombo ? p.bestCombo.process + " + " + p.bestCombo.extension : "(无:无 unsafe 扩展名或无可用进程)"),
|
|
914
|
+
"缓存文件: " + PROFILE_CACHE_PATH,
|
|
915
|
+
];
|
|
916
|
+
if (p.detectError) lines.push("探测异常: " + p.detectError);
|
|
917
|
+
return lines.join("\n");
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// 注册 mark_extension 工具(手动标注扩展名写入策略)
|
|
921
|
+
server.tool(
|
|
922
|
+
"mark_extension",
|
|
923
|
+
"手动标注扩展名的写入策略。protected=保持TSD加密直写(如 .java 这类需要保持加密状态的文档);unsafe=强制走 safeWrite 保持明文(如 .scss);clear=清除标注恢复自动探测。标注优先级高于自动探测与写入后重分类。",
|
|
924
|
+
{
|
|
925
|
+
extension: z.string().describe("扩展名,如 .java(可不带点,自动补全并转小写)"),
|
|
926
|
+
category: z.enum(["protected", "unsafe", "clear"]).describe("protected=直写保持加密;unsafe=走safeWrite保持明文;clear=清除手动标注"),
|
|
927
|
+
},
|
|
928
|
+
async ({ extension, category }) => {
|
|
929
|
+
try {
|
|
930
|
+
const p = getProfile();
|
|
931
|
+
const ext = extension.startsWith(".") ? extension.toLowerCase() : "." + extension.toLowerCase();
|
|
932
|
+
if (!Array.isArray(p.userProtectedExtensions)) p.userProtectedExtensions = [];
|
|
933
|
+
const removeFrom = (arr, v) => { const i = arr.indexOf(v); if (i !== -1) arr.splice(i, 1); };
|
|
934
|
+
if (category === "protected") {
|
|
935
|
+
if (p.userProtectedExtensions.indexOf(ext) === -1) p.userProtectedExtensions.push(ext);
|
|
936
|
+
// 保持加密的扩展名不应再走 safeWrite:从 encrypted/unsafe 自动列表移除
|
|
937
|
+
if (Array.isArray(p.encryptedExtensions)) removeFrom(p.encryptedExtensions, ext);
|
|
938
|
+
if (Array.isArray(p.unsafeExtensions)) removeFrom(p.unsafeExtensions, ext);
|
|
939
|
+
if (Array.isArray(p.safeExtensions)) removeFrom(p.safeExtensions, ext);
|
|
940
|
+
saveProfileCache(p);
|
|
941
|
+
return { content: [{ type: "text", text: "✅ 已标注 " + ext + " 为 protected(保持加密):后续写入直接直写,由加密软件加密落盘;写入后检测对该扩展名自动跳过。" }] };
|
|
942
|
+
}
|
|
943
|
+
if (category === "unsafe") {
|
|
944
|
+
removeFrom(p.userProtectedExtensions, ext);
|
|
945
|
+
if (!Array.isArray(p.encryptedExtensions)) p.encryptedExtensions = [];
|
|
946
|
+
if (p.encryptedExtensions.indexOf(ext) === -1) p.encryptedExtensions.push(ext);
|
|
947
|
+
if (Array.isArray(p.safeExtensions)) removeFrom(p.safeExtensions, ext);
|
|
948
|
+
saveProfileCache(p);
|
|
949
|
+
return { content: [{ type: "text", text: "✅ 已标注 " + ext + " 为 unsafe(保持明文):后续写入一律走 safeWrite 保持磁盘明文。" }] };
|
|
950
|
+
}
|
|
951
|
+
// clear:仅移除手动标注,自动分类列表保持现状
|
|
952
|
+
removeFrom(p.userProtectedExtensions, ext);
|
|
953
|
+
saveProfileCache(p);
|
|
954
|
+
return { content: [{ type: "text", text: "✅ 已清除 " + ext + " 的手动标注,恢复自动探测/写入后检测分类。" }] };
|
|
955
|
+
} catch (e) {
|
|
956
|
+
return { content: [{ type: "text", text: "❌ 标注失败: " + e.message }], isError: true };
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
);
|
|
960
|
+
|
|
961
|
+
// 注册 encryption_profile 工具(查看环境探测结果)
|
|
962
|
+
server.tool(
|
|
963
|
+
"encryption_profile",
|
|
964
|
+
"查看当前加密环境探测结果:safe/protected/unsafe 三类扩展名列表、可用外部进程、最佳写入组合(bestCombo)、机器指纹与缓存位置。结果缓存于 ~/.mcp-encryption-profile.json,换电脑自动重探。",
|
|
965
|
+
{},
|
|
966
|
+
{ readOnlyHint: true },
|
|
967
|
+
async () => {
|
|
968
|
+
try {
|
|
969
|
+
const p = getProfile();
|
|
970
|
+
return { content: [{ type: "text", text: "加密环境探测结果(来自" + (loadProfileCache() ? "缓存" : "本次探测") + "):\n" + formatProfileSummary(p) }] };
|
|
971
|
+
} catch (e) {
|
|
972
|
+
return { content: [{ type: "text", text: "❌ 获取探测结果失败: " + e.message }], isError: true };
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
// 注册 refresh_profile 工具(强制重新探测并更新缓存)
|
|
978
|
+
server.tool(
|
|
979
|
+
"refresh_profile",
|
|
980
|
+
"强制重新执行环境探测(扩展名分类 + 外部进程 + 交叉验证)并更新缓存。当加密软件策略变更、切换项目目录策略、或怀疑缓存过期时使用。探测过程在系统临时目录写入临时文件,不污染用户目录。",
|
|
981
|
+
{},
|
|
982
|
+
async () => {
|
|
983
|
+
try {
|
|
984
|
+
activeProfile = detectEnvironment();
|
|
985
|
+
saveProfileCache(activeProfile);
|
|
986
|
+
return { content: [{ type: "text", text: "✅ 已重新探测并更新缓存:\n" + formatProfileSummary(activeProfile) }] };
|
|
987
|
+
} catch (e) {
|
|
988
|
+
return { content: [{ type: "text", text: "❌ 重新探测失败: " + e.message }], isError: true };
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
);
|
|
992
|
+
|
|
233
993
|
// 注册 read_file 工具
|
|
234
994
|
server.tool(
|
|
235
995
|
"read_file",
|
|
@@ -406,7 +1166,42 @@ server.tool(
|
|
|
406
1166
|
if (parent && !fs.existsSync(parent)) {
|
|
407
1167
|
fs.mkdirSync(parent, { recursive: true });
|
|
408
1168
|
}
|
|
1169
|
+
// 环境自适应:unsafe/encrypted 扩展名直写会被透明加密,必须走 safeWrite
|
|
1170
|
+
//(安全扩展名临时文件 + 外部进程复制)保证磁盘明文;未知扩展名先直写,
|
|
1171
|
+
// 写入后用外部进程实测磁盘状态,发现密文自动重分类并立即纠正为明文
|
|
1172
|
+
const decision = decideWriteStrategy(filePath);
|
|
1173
|
+
if (decision.mode === "safe") {
|
|
1174
|
+
let payload = finalContent;
|
|
1175
|
+
if (writeMode === "append" && fs.existsSync(filePath)) {
|
|
1176
|
+
// 追加模式:外部进程只做整文件复制,需先合并原内容再整体中转落盘
|
|
1177
|
+
const prevFull = readFileContent(filePath);
|
|
1178
|
+
if (!prevFull.ok) {
|
|
1179
|
+
return { content: [{ type: "text", text: "❌ 追加失败:目标扩展名为 unsafe 且原文件无法读出明文(可能已处于不可解密状态): " + filePath + "\n" + prevFull.error }], isError: true };
|
|
1180
|
+
}
|
|
1181
|
+
payload = (prevFull.hasBom ? "\uFEFF" : "") + prevFull.content + finalContent;
|
|
1182
|
+
}
|
|
1183
|
+
const r = safeWriteFile(filePath, payload);
|
|
1184
|
+
if (r.ok) {
|
|
1185
|
+
return { content: [{ type: "text", text: "✅ 写入成功" + (writeMode === "append" ? "(追加)" : "") + ": " + filePath + "\nℹ️ 目标扩展名为 " + decision.category + "(写入会加密),已走 safeWrite 明文落盘(" + r.via.process + " + " + r.via.extension + ")" }] };
|
|
1186
|
+
}
|
|
1187
|
+
// 全部组合失败:回退直写并明确告警(兜底行为,保证内容至少落盘)
|
|
1188
|
+
fs.writeFileSync(filePath, payload, { encoding: "utf-8", flag: "w" });
|
|
1189
|
+
return { content: [{ type: "text", text: "⚠️ 已写入但磁盘可能为不可解密密文:目标扩展名为 " + decision.category + " 且 safeWrite 全部组合失败(" + r.error + "),已回退直接写入: " + filePath }], isError: true };
|
|
1190
|
+
}
|
|
409
1191
|
fs.writeFileSync(filePath, finalContent, { encoding: "utf-8", flag: writeMode === "append" ? "a" : "w" });
|
|
1192
|
+
// 写入后实测:未知扩展名必测,已知 safe/protected 也复测(策略可能按目录生效或被调整);
|
|
1193
|
+
// 用户手动标注 protected 的扩展名跳过(用户意图优先)。发现密文自动重分类并纠正为明文
|
|
1194
|
+
const wfProfile = getProfile();
|
|
1195
|
+
if (wfProfile.postWriteDetectionEnabled && decision.category !== "user_protected") {
|
|
1196
|
+
const ds = detectDiskStateAfterWrite(filePath, finalContent);
|
|
1197
|
+
if (!ds.diskPlaintext) {
|
|
1198
|
+
const sw = rescueToPlaintext(filePath, finalContent);
|
|
1199
|
+
if (sw.ok) {
|
|
1200
|
+
return { content: [{ type: "text", text: "✅ 写入成功" + (writeMode === "append" ? "(追加)" : "") + ": " + filePath + "\nℹ️ 首次探测到该扩展名写入后磁盘为密文,已自动重分类并转为 safeWrite 明文落盘(" + sw.via.process + " + " + sw.via.extension + "),后续该扩展名将直接走 safeWrite" }] };
|
|
1201
|
+
}
|
|
1202
|
+
return { content: [{ type: "text", text: "⚠️ 写入后磁盘为密文且自动纠正失败(" + sw.error + "),文件当前可能为密文: " + filePath }], isError: true };
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
410
1205
|
return { content: [{ type: "text", text: "✅ 写入成功" + (writeMode === "append" ? "(追加)" : "") + ": " + filePath }] };
|
|
411
1206
|
} catch (e) {
|
|
412
1207
|
return { content: [{ type: "text", text: "❌ 写入失败: " + e.message }], isError: true };
|
|
@@ -475,26 +1270,119 @@ function eolInsensitiveReplace(original, oldString, replacement, replaceAll, ign
|
|
|
475
1270
|
return { updated, count: positions.length };
|
|
476
1271
|
}
|
|
477
1272
|
|
|
1273
|
+
/**
|
|
1274
|
+
* 匹配失败时的相似行诊断:在文件中找出与 oldString 首行(或整体)最相似的行,
|
|
1275
|
+
* 输出行号与原文,帮助 Agent 快速定位「看起来一样实际差个空白/字符」的位置。
|
|
1276
|
+
* 诊断方式:按公共 8-gram 计数相似度(无依赖的轻量实现),返回最多 3 条候选。
|
|
1277
|
+
*/
|
|
1278
|
+
function diagnoseNoMatch(original, oldString) {
|
|
1279
|
+
// 取 oldString 的首行做行级比对(多数失败源于首行定位错误)
|
|
1280
|
+
const firstLine = oldString.split(/\r?\n/)[0].trim();
|
|
1281
|
+
if (!firstLine || firstLine.length < 6) return "";
|
|
1282
|
+
const grams = new Set();
|
|
1283
|
+
const N = 8;
|
|
1284
|
+
for (let i = 0; i + N <= firstLine.length; i++) grams.add(firstLine.slice(i, i + N));
|
|
1285
|
+
if (!grams.size) return "";
|
|
1286
|
+
const lines = original.split(/\r?\n/);
|
|
1287
|
+
let best = [];
|
|
1288
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1289
|
+
const line = lines[i];
|
|
1290
|
+
if (!line.trim()) continue;
|
|
1291
|
+
let hit = 0;
|
|
1292
|
+
for (let j = 0; j + N <= line.length; j++) {
|
|
1293
|
+
if (grams.has(line.slice(j, j + N))) hit++;
|
|
1294
|
+
}
|
|
1295
|
+
const denom = Math.max(grams.size, Math.ceil(line.trim().length / N));
|
|
1296
|
+
const sim = hit / denom;
|
|
1297
|
+
if (sim > 0.25) best.push({ line: i + 1, sim, text: line.trim().slice(0, 120) });
|
|
1298
|
+
}
|
|
1299
|
+
best.sort((a, b) => b.sim - a.sim);
|
|
1300
|
+
best = best.slice(0, 3);
|
|
1301
|
+
if (!best.length) return "";
|
|
1302
|
+
return "\n可能相关的行(相似度排序,请对照检查空白/缩进/字符差异):\n" +
|
|
1303
|
+
best.map((b) => " 第 " + b.line + " 行 (相似度 " + Math.round(b.sim * 100) + "%): " + b.text).join("\n");
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* 对文本应用一次替换(edit_file 单次模式与批量模式的每个条目共用)。
|
|
1308
|
+
* 两级匹配:①字节级精确匹配 ②换行归一化兜底(CRLF/LF 差异)。
|
|
1309
|
+
* 返回 { ok, updated, count, eolAdapted, error, diagnose };失败时 error 带原因,
|
|
1310
|
+
* diagnose 带相似行诊断(仅字符串模式且完全无命中时提供)。
|
|
1311
|
+
*/
|
|
1312
|
+
function applySingleEdit(original, oldString, newString, useRegex, replaceAll, ignoreCase) {
|
|
1313
|
+
// 替换文本统一按文件主导行尾风格写入(调用方已归一 newString)
|
|
1314
|
+
let matcher = null;
|
|
1315
|
+
if (useRegex) {
|
|
1316
|
+
try {
|
|
1317
|
+
// 正则模式默认附加 m 标志:^/$ 按行锚定(Agent 常用行级正则习惯),JS 无内联标志无法由调用方自行开启
|
|
1318
|
+
matcher = new RegExp(oldString, ((replaceAll ? "g" : "") + "m" + (ignoreCase ? "i" : "")));
|
|
1319
|
+
} catch (e) {
|
|
1320
|
+
return { ok: false, error: "正则表达式无效: " + e.message };
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
if (useRegex) {
|
|
1324
|
+
// 计数与替换均带 m 标志,保持与构造 matcher 时一致
|
|
1325
|
+
const globalMatcher = new RegExp(matcher.source, "gm" + (ignoreCase ? "i" : ""));
|
|
1326
|
+
const matches = original.match(globalMatcher);
|
|
1327
|
+
const count = matches ? matches.length : 0;
|
|
1328
|
+
if (count === 0) return { ok: false, error: "正则未匹配到内容", count: 0 };
|
|
1329
|
+
const replaceMatcher = replaceAll ? globalMatcher : new RegExp(matcher.source, "m" + (ignoreCase ? "i" : ""));
|
|
1330
|
+
return { ok: true, updated: original.replace(replaceMatcher, newString), count, eolAdapted: false };
|
|
1331
|
+
}
|
|
1332
|
+
if (oldString === "") {
|
|
1333
|
+
return { ok: false, error: "oldString 不能为空字符串" };
|
|
1334
|
+
}
|
|
1335
|
+
// 第一优先:原样精确匹配(字节级一致,最安全)
|
|
1336
|
+
const hay = ignoreCase ? original.toLowerCase() : original;
|
|
1337
|
+
const needle = ignoreCase ? oldString.toLowerCase() : oldString;
|
|
1338
|
+
let idx = 0, exactCount = 0;
|
|
1339
|
+
while ((idx = hay.indexOf(needle, idx)) !== -1) { exactCount++; idx += needle.length; }
|
|
1340
|
+
if (exactCount > 0) {
|
|
1341
|
+
let updated;
|
|
1342
|
+
if (replaceAll) {
|
|
1343
|
+
const esc = oldString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1344
|
+
// 用函数替换避免 newString 中的 $& $1 等被误解析为替换模式
|
|
1345
|
+
updated = original.replace(new RegExp(esc, ignoreCase ? "gi" : "g"), () => newString);
|
|
1346
|
+
} else {
|
|
1347
|
+
const pos = hay.indexOf(needle);
|
|
1348
|
+
updated = original.slice(0, pos) + newString + original.slice(pos + oldString.length);
|
|
1349
|
+
}
|
|
1350
|
+
return { ok: true, updated, count: exactCount, eolAdapted: false };
|
|
1351
|
+
}
|
|
1352
|
+
// 第二优先:换行符兼容匹配。文件是 CRLF 而 oldString 用 LF(或相反)时,
|
|
1353
|
+
// 字节级比对必然失败,归一换行后再匹配,命中即记为换行适配替换
|
|
1354
|
+
const adapted = eolInsensitiveReplace(original, oldString, newString, replaceAll, ignoreCase);
|
|
1355
|
+
if (adapted) {
|
|
1356
|
+
return { ok: true, updated: adapted.updated, count: adapted.count, eolAdapted: true };
|
|
1357
|
+
}
|
|
1358
|
+
return { ok: false, error: "未找到匹配内容", count: 0, diagnose: diagnoseNoMatch(original, oldString) };
|
|
1359
|
+
}
|
|
1360
|
+
|
|
478
1361
|
// 注册 edit_file 工具(精确替换,替代受加密影响的 Edit/MultiEdit)
|
|
479
1362
|
server.tool(
|
|
480
1363
|
"edit_file",
|
|
481
|
-
"
|
|
1364
|
+
"对文件内容做精确字符串或正则替换后写回(明文)。支持两种形态:①单次替换(oldString/newString);②批量原子编辑(edits 数组,按序应用,任一条目失败则整体不写盘,避免半改状态)。字符串匹配自动兼容 CRLF/LF 换行差异,替换文本行尾自动跟随文件主导风格。加密软件环境下内置 Edit/MultiEdit 直写会破坏加密,本工具用 Node.js fs 读改写,自动加密落盘。替代内置 Edit/MultiEdit 工具。",
|
|
482
1365
|
{
|
|
483
1366
|
path: z.string().describe("文件路径,支持相对路径或绝对路径"),
|
|
484
|
-
oldString: z.string().describe("
|
|
485
|
-
newString: z.string().describe("
|
|
486
|
-
|
|
487
|
-
|
|
1367
|
+
oldString: z.string().optional().describe("单次模式必填:要被替换的原字符串。useRegex=true 时作为正则表达式"),
|
|
1368
|
+
newString: z.string().optional().describe("单次模式必填:替换后的字符串。正则模式下可用 $1 $2 等捕获组引用"),
|
|
1369
|
+
edits: z.array(z.object({
|
|
1370
|
+
oldString: z.string().describe("要被替换的原字符串(批量条目,不支持正则)"),
|
|
1371
|
+
newString: z.string().describe("替换后的字符串(批量条目)"),
|
|
1372
|
+
replaceAll: z.boolean().optional().describe("是否替换全部匹配项,默认 false 仅替换第一处"),
|
|
1373
|
+
})).optional().describe("批量原子编辑模式:多个替换按序应用,任一条目未匹配则整体不写盘。适合一次完成多处修改(替代 MultiEdit)"),
|
|
1374
|
+
useRegex: z.boolean().optional().describe("单次模式:是否将 oldString 当作正则表达式,默认 false(纯字符串匹配)"),
|
|
1375
|
+
replaceAll: z.boolean().optional().describe("单次模式:是否替换全部匹配项,默认 false 仅替换第一处"),
|
|
488
1376
|
ignoreCase: z.boolean().optional().describe("是否忽略大小写,默认 false。仅在非正则的字符串模式下生效"),
|
|
489
1377
|
},
|
|
490
|
-
async ({ path: filePath, oldString, newString, useRegex, replaceAll, ignoreCase }) => {
|
|
1378
|
+
async ({ path: filePath, oldString, newString, edits, useRegex, replaceAll, ignoreCase }) => {
|
|
491
1379
|
try {
|
|
492
1380
|
// readFileContent 会剥离 BOM 并记录,写回时补回,避免 oldString 匹配首行失败
|
|
493
1381
|
const readResult = readFileContent(filePath);
|
|
494
1382
|
if (!readResult.ok) {
|
|
495
1383
|
return { content: [{ type: "text", text: "❌ " + readResult.error }], isError: true };
|
|
496
1384
|
}
|
|
497
|
-
|
|
1385
|
+
let original = readResult.content;
|
|
498
1386
|
const hasBom = readResult.hasBom;
|
|
499
1387
|
// 非 UTF-8 防护:GBK 等编码按 UTF-8 读入会产生大量 U+FFFD,此时做任何替换再写回,
|
|
500
1388
|
// 原始字节信息会永久丢失(不可逆损坏)。检测到即拒绝编辑并明确提示。
|
|
@@ -504,70 +1392,73 @@ server.tool(
|
|
|
504
1392
|
isError: true,
|
|
505
1393
|
};
|
|
506
1394
|
}
|
|
507
|
-
//
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if (useRegex) {
|
|
512
|
-
try {
|
|
513
|
-
// 正则模式默认附加 m 标志:^/$ 按行锚定(Agent 常用行级正则习惯),JS 无内联标志无法由调用方自行开启
|
|
514
|
-
const flags = (replaceAll ? "g" : "") + "m" + (ignoreCase ? "i" : "");
|
|
515
|
-
matcher = new RegExp(oldString, flags);
|
|
516
|
-
} catch (e) {
|
|
517
|
-
return { content: [{ type: "text", text: "❌ 正则表达式无效: " + e.message }], isError: true };
|
|
518
|
-
}
|
|
1395
|
+
// 形态分派:edits 数组走批量原子模式;否则要求 oldString/newString 成对
|
|
1396
|
+
const isBatch = Array.isArray(edits) && edits.length > 0;
|
|
1397
|
+
if (!isBatch && (oldString === undefined || newString === undefined)) {
|
|
1398
|
+
return { content: [{ type: "text", text: "❌ 参数缺失:请提供 oldString+newString(单次替换),或 edits 数组(批量原子编辑)" }], isError: true };
|
|
519
1399
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
// 用函数替换避免 newString 中的 $& $1 等被误解析为替换模式
|
|
544
|
-
updated = original.replace(new RegExp(esc, ignoreCase ? "gi" : "g"), () => normalizedNew);
|
|
545
|
-
} else {
|
|
546
|
-
const pos = hay.indexOf(needle);
|
|
547
|
-
updated = original.slice(0, pos) + normalizedNew + original.slice(pos + oldString.length);
|
|
1400
|
+
// 文件主导换行风格:所有替换文本统一转成该风格(CRLF 文件写入 CRLF,保持风格统一)
|
|
1401
|
+
const fileEol = detectEol(original);
|
|
1402
|
+
const toFileEol = (s) => normalizeEol(s, fileEol);
|
|
1403
|
+
|
|
1404
|
+
// ---------- 批量原子模式:先全部校验再统一写盘,杜绝半改状态 ----------
|
|
1405
|
+
if (isBatch) {
|
|
1406
|
+
let content = original;
|
|
1407
|
+
let total = 0;
|
|
1408
|
+
const applied = [];
|
|
1409
|
+
// 全部条目按序在内存中应用;任一失败立即返回,文件保持原样
|
|
1410
|
+
for (let i = 0; i < edits.length; i++) {
|
|
1411
|
+
const item = edits[i];
|
|
1412
|
+
const r = applySingleEdit(content, item.oldString, toFileEol(item.newString), false, item.replaceAll === true, false);
|
|
1413
|
+
if (!r.ok) {
|
|
1414
|
+
return {
|
|
1415
|
+
content: [{
|
|
1416
|
+
type: "text",
|
|
1417
|
+
text: "❌ 批量编辑第 " + (i + 1) + "/" + edits.length + " 条失败: " + r.error + "\n已中止,文件未做任何修改(原子模式,前面条目也不会写入): " + filePath +
|
|
1418
|
+
(r.diagnose || "") +
|
|
1419
|
+
"\n提示:批量条目按顺序应用,前面条目的 newString 会改变后续条目的匹配环境,请按文件现状顺序构造。",
|
|
1420
|
+
}],
|
|
1421
|
+
isError: true,
|
|
1422
|
+
};
|
|
548
1423
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
count = adapted.count;
|
|
555
|
-
updated = adapted.updated;
|
|
556
|
-
eolAdapted = true;
|
|
557
|
-
} else {
|
|
558
|
-
count = 0;
|
|
559
|
-
updated = original;
|
|
1424
|
+
if (r.updated === content) {
|
|
1425
|
+
return {
|
|
1426
|
+
content: [{ type: "text", text: "❌ 批量编辑第 " + (i + 1) + "/" + edits.length + " 条替换后内容无变化(newString 与原文相同),已中止: " + filePath }],
|
|
1427
|
+
isError: true,
|
|
1428
|
+
};
|
|
560
1429
|
}
|
|
1430
|
+
content = r.updated;
|
|
1431
|
+
total += r.count;
|
|
1432
|
+
applied.push("#" + (i + 1) + " 替换 " + (item.replaceAll === true ? r.count : 1) + "/" + r.count + " 处");
|
|
561
1433
|
}
|
|
1434
|
+
if (content === original) {
|
|
1435
|
+
return { content: [{ type: "text", text: "⚠️ 批量编辑应用后内容无变化,文件未修改: " + filePath }] };
|
|
1436
|
+
}
|
|
1437
|
+
// 环境自适应:unsafe/encrypted 扩展名走 safeWrite 保持磁盘明文(失败回退直写并告警);
|
|
1438
|
+
// 未知扩展名直写后实测磁盘状态,发现密文自动重分类并纠正
|
|
1439
|
+
const wb = writeBackWithStrategy(filePath, (hasBom ? "\uFEFF" : "") + content);
|
|
1440
|
+
let wbNote = "";
|
|
1441
|
+
if (wb.via && !wb.autoCorrected) wbNote = "\nℹ️ 目标扩展名写入会加密,已走 safeWrite 明文落盘(" + wb.via.process + " + " + wb.via.extension + ")";
|
|
1442
|
+
if (wb.autoCorrected) wbNote = "\nℹ️ 写入后实测磁盘为密文,已自动重分类该扩展名并用 safeWrite 重写为明文(" + wb.via.process + " + " + wb.via.extension + ")";
|
|
1443
|
+
if (wb.degraded) wbNote = "\n⚠️ 目标扩展名写入会加密且 safeWrite 失败(" + wb.error + "),已回退直接写入,磁盘可能为不可解密密文";
|
|
1444
|
+
return {
|
|
1445
|
+
content: [{ type: "text", text: "✅ 批量编辑成功: " + filePath + "\n共 " + edits.length + " 条," + total + " 处替换\n" + applied.join("\n") + wbNote }],
|
|
1446
|
+
};
|
|
562
1447
|
}
|
|
563
|
-
|
|
1448
|
+
|
|
1449
|
+
// ---------- 单次模式(原行为) ----------
|
|
1450
|
+
const normalizedNew = toFileEol(newString);
|
|
1451
|
+
const r = applySingleEdit(original, oldString, normalizedNew, useRegex === true, replaceAll === true, ignoreCase === true);
|
|
1452
|
+
if (!r.ok) {
|
|
564
1453
|
return {
|
|
565
|
-
content: [{ type: "text", text: "⚠️
|
|
1454
|
+
content: [{ type: "text", text: "⚠️ " + r.error + ",文件未修改。请检查 oldString(或正则)是否正确: " + filePath + (r.diagnose || "") + "\n提示:换行风格(CRLF/LF)已自动兼容;重点检查空格、缩进、字符是否与原文完全一致。" }],
|
|
566
1455
|
isError: true,
|
|
567
1456
|
};
|
|
568
1457
|
}
|
|
1458
|
+
const count = r.count;
|
|
1459
|
+
const updated = r.updated;
|
|
569
1460
|
let warning = "";
|
|
570
|
-
if (eolAdapted) {
|
|
1461
|
+
if (r.eolAdapted) {
|
|
571
1462
|
warning = "\nℹ️ 换行符已自动适配:oldString 与文件换行风格不一致(CRLF/LF),已按换行归一化匹配完成替换。";
|
|
572
1463
|
}
|
|
573
1464
|
if (!replaceAll && count > 1) {
|
|
@@ -577,7 +1468,12 @@ server.tool(
|
|
|
577
1468
|
return { content: [{ type: "text", text: "⚠️ 替换后内容无变化,文件未修改: " + filePath }] };
|
|
578
1469
|
}
|
|
579
1470
|
// 原文件带 BOM 时补回,保持文件编码特征不变(部分 Windows 软件依赖 BOM)
|
|
580
|
-
|
|
1471
|
+
// 环境自适应:unsafe/encrypted 扩展名走 safeWrite 保持磁盘明文(失败回退直写并告警);
|
|
1472
|
+
// 未知扩展名直写后实测磁盘状态,发现密文自动重分类并纠正
|
|
1473
|
+
const wbSingle = writeBackWithStrategy(filePath, (hasBom ? "\uFEFF" : "") + updated);
|
|
1474
|
+
if (wbSingle.via && !wbSingle.autoCorrected) warning += "\nℹ️ 目标扩展名写入会加密,已走 safeWrite 明文落盘(" + wbSingle.via.process + " + " + wbSingle.via.extension + ")";
|
|
1475
|
+
if (wbSingle.autoCorrected) warning += "\nℹ️ 写入后实测磁盘为密文,已自动重分类该扩展名并用 safeWrite 重写为明文(" + wbSingle.via.process + " + " + wbSingle.via.extension + ")";
|
|
1476
|
+
if (wbSingle.degraded) warning += "\n⚠️ 目标扩展名写入会加密且 safeWrite 失败(" + wbSingle.error + "),已回退直接写入,磁盘可能为不可解密密文";
|
|
581
1477
|
return {
|
|
582
1478
|
content: [{ type: "text", text: "✅ 替换成功: " + filePath + "\n替换 " + (replaceAll ? count : 1) + "/" + count + " 处" + warning }],
|
|
583
1479
|
};
|
|
@@ -819,6 +1715,13 @@ server.tool(
|
|
|
819
1715
|
{ readOnlyHint: true },
|
|
820
1716
|
async ({ path: filePath }) => {
|
|
821
1717
|
let base = "✅ read-file-server 运行中\n平台: Node.js " + process.version + "\n版本: " + pkg.version + "\n功能: 通过 Node.js fs 读写文件明文(加密软件白名单中的 Node.js 进程自动解密/加密)";
|
|
1718
|
+
// 环境自适应 profile 概要(懒加载,首次调用触发探测或读缓存)
|
|
1719
|
+
try {
|
|
1720
|
+
const prof = getProfile();
|
|
1721
|
+
base += "\n环境探测: safe=" + prof.safeExtensions.length + " 个, protected=" + prof.protectedExtensions.length + " 个, unsafe=" + prof.unsafeExtensions.length + " 个, encrypted=" + (prof.encryptedExtensions || []).length + " 个, 用户标注=" + (prof.userProtectedExtensions || []).length + " 个 | 可用进程: " + (prof.availableProcesses.map((x) => x.id).join(",") || "无") + " | bestCombo: " + (prof.bestCombo ? prof.bestCombo.process + "+" + prof.bestCombo.extension : "无") + "\n详情可用 encryption_profile 工具查看";
|
|
1722
|
+
} catch (e) {
|
|
1723
|
+
base += "\n环境探测: 失败(" + e.message + "),写工具按原始直写行为运行";
|
|
1724
|
+
}
|
|
822
1725
|
if (filePath === undefined) {
|
|
823
1726
|
base += "\n提示: 传入 path 参数可实测解密能力(本次未做实测)";
|
|
824
1727
|
return { content: [{ type: "text", text: base }] };
|
|
@@ -997,7 +1900,33 @@ server.tool(
|
|
|
997
1900
|
if (srcStat.isDirectory() && destStat && destStat.isFile()) {
|
|
998
1901
|
return { content: [{ type: "text", text: "❌ 无法复制:源是目录但目标是已存在的文件: " + finalDest }], isError: true };
|
|
999
1902
|
}
|
|
1903
|
+
// 环境自适应:文件目标扩展名为 unsafe/encrypted 时,cpSync 直写会产生密文,
|
|
1904
|
+
// 改走 safeCopy(安全扩展名中转 + 外部进程落盘);目录暂不逐个处理,维持原行为。
|
|
1905
|
+
// 未知扩展名直写后实测磁盘状态(仅对有内容的源文件),发现密文自动重分类并纠正
|
|
1906
|
+
const copyDecision = srcStat.isFile() ? decideWriteStrategy(finalDest) : null;
|
|
1907
|
+
if (srcStat.isFile() && copyDecision.mode === "safe") {
|
|
1908
|
+
const sc = safeCopyFileTo(source, finalDest);
|
|
1909
|
+
if (sc.ok) {
|
|
1910
|
+
return { content: [{ type: "text", text: "✅ 复制成功: " + source + " -> " + finalDest + "\nℹ️ 目标扩展名为 " + copyDecision.category + "(写入会加密),已走 safeCopy 明文落盘(" + sc.via.process + " + " + sc.via.extension + ")" }] };
|
|
1911
|
+
}
|
|
1912
|
+
// 全部组合失败:回退 cpSync 直写并告警
|
|
1913
|
+
fs.cpSync(source, finalDest, { force: true });
|
|
1914
|
+
return { content: [{ type: "text", text: "⚠️ 已复制但磁盘可能为不可解密密文:目标扩展名为 " + copyDecision.category + " 且 safeCopy 全部组合失败(" + sc.error + "),已回退直接复制: " + finalDest }], isError: true };
|
|
1915
|
+
}
|
|
1000
1916
|
fs.cpSync(source, finalDest, { recursive: srcStat.isDirectory(), force: true });
|
|
1917
|
+
if (srcStat.isFile() && copyDecision && copyDecision.category !== "user_protected" && srcStat.size > 0) {
|
|
1918
|
+
const cpProfile = getProfile();
|
|
1919
|
+
if (cpProfile.postWriteDetectionEnabled) {
|
|
1920
|
+
const ds = detectDiskStateAfterWrite(finalDest, fs.readFileSync(finalDest, "utf-8"));
|
|
1921
|
+
if (!ds.diskPlaintext) {
|
|
1922
|
+
const sc2 = safeCopyFileTo(source, finalDest);
|
|
1923
|
+
if (sc2.ok) {
|
|
1924
|
+
return { content: [{ type: "text", text: "✅ 复制成功: " + source + " -> " + finalDest + "\nℹ️ 复制后实测磁盘为密文,已自动重分类该扩展名并用 safeCopy 重写为明文(" + sc2.via.process + " + " + sc2.via.extension + ")" }] };
|
|
1925
|
+
}
|
|
1926
|
+
return { content: [{ type: "text", text: "⚠️ 复制后磁盘为密文且自动纠正失败(" + sc2.error + "),目标文件可能为密文: " + finalDest }], isError: true };
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1001
1930
|
return { content: [{ type: "text", text: "✅ 复制成功: " + source + " -> " + finalDest + (srcStat.isDirectory() ? "(递归目录)" : "") }] };
|
|
1002
1931
|
} catch (e) {
|
|
1003
1932
|
return { content: [{ type: "text", text: "❌ 复制失败: " + e.message }], isError: true };
|
|
@@ -1033,18 +1962,50 @@ server.tool(
|
|
|
1033
1962
|
} catch (e) {
|
|
1034
1963
|
if (e.code !== "ENOENT") throw e;
|
|
1035
1964
|
}
|
|
1965
|
+
// 环境自适应:文件目标扩展名为 unsafe/encrypted 时,rename 会把源文件的磁盘状态
|
|
1966
|
+
// 原样带到加密扩展名上(密文改名后无法解密),改走 safeCopy 明文落盘后删源。
|
|
1967
|
+
// 未知扩展名 rename 后实测磁盘状态(仅非空文件),发现密文自动重分类并纠正
|
|
1968
|
+
const moveDecision = srcStat.isFile() ? decideWriteStrategy(finalDest) : null;
|
|
1969
|
+
if (srcStat.isFile() && moveDecision.mode === "safe") {
|
|
1970
|
+
const sm = safeCopyFileTo(source, finalDest);
|
|
1971
|
+
if (sm.ok) {
|
|
1972
|
+
fs.rmSync(source, { force: true });
|
|
1973
|
+
return { content: [{ type: "text", text: "✅ 移动成功: " + source + " -> " + finalDest + "\nℹ️ 目标扩展名为 " + moveDecision.category + "(写入会加密),已走 safeCopy 明文落盘(" + sm.via.process + " + " + sm.via.extension + ")并删除源" }] };
|
|
1974
|
+
}
|
|
1975
|
+
// safeCopy 失败则继续走下方 rename 原逻辑(保持兜底行为)
|
|
1976
|
+
}
|
|
1977
|
+
let moved = false;
|
|
1978
|
+
let moveNote = "";
|
|
1036
1979
|
try {
|
|
1037
1980
|
fs.renameSync(source, finalDest);
|
|
1038
|
-
|
|
1981
|
+
moved = true;
|
|
1039
1982
|
} catch (e) {
|
|
1040
1983
|
if (e.code === "EXDEV") {
|
|
1041
1984
|
// 跨盘符:rename 不可用,回退为 cp + rm
|
|
1042
1985
|
fs.cpSync(source, finalDest, { recursive: srcStat.isDirectory(), force: true });
|
|
1043
1986
|
fs.rmSync(source, { recursive: srcStat.isDirectory(), force: true });
|
|
1044
|
-
|
|
1987
|
+
moved = true;
|
|
1988
|
+
moveNote = "(跨盘符,复制后删除源)";
|
|
1989
|
+
} else {
|
|
1990
|
+
throw e;
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
// rename 是否触发透明加密因加密软件实现而异:实测目标磁盘状态,
|
|
1994
|
+
// 密文则用 safeCopy 纠正(此时源已不存在,从目标读回明文经临时文件中转重写)
|
|
1995
|
+
if (srcStat.isFile() && moveDecision && moveDecision.category !== "user_protected" && srcStat.size > 0) {
|
|
1996
|
+
const mvProfile = getProfile();
|
|
1997
|
+
if (mvProfile.postWriteDetectionEnabled) {
|
|
1998
|
+
const ds = detectDiskStateAfterWrite(finalDest, fs.readFileSync(finalDest, "utf-8"));
|
|
1999
|
+
if (!ds.diskPlaintext) {
|
|
2000
|
+
const sm2 = safeCopyFileTo(finalDest, finalDest);
|
|
2001
|
+
if (sm2.ok) {
|
|
2002
|
+
return { content: [{ type: "text", text: "✅ 移动成功" + moveNote + ": " + source + " -> " + finalDest + "\nℹ️ 移动后实测磁盘为密文,已自动重分类该扩展名并用 safeCopy 重写为明文(" + sm2.via.process + " + " + sm2.via.extension + ")" }] };
|
|
2003
|
+
}
|
|
2004
|
+
return { content: [{ type: "text", text: "⚠️ 移动后磁盘为密文且自动纠正失败(" + sm2.error + "),目标文件可能为密文: " + finalDest }], isError: true };
|
|
2005
|
+
}
|
|
1045
2006
|
}
|
|
1046
|
-
throw e;
|
|
1047
2007
|
}
|
|
2008
|
+
return { content: [{ type: "text", text: "✅ 移动成功" + moveNote + ": " + source + " -> " + finalDest }] };
|
|
1048
2009
|
} catch (e) {
|
|
1049
2010
|
if (e.code === "ENOENT") {
|
|
1050
2011
|
return { content: [{ type: "text", text: "❌ 源路径不存在: " + source }], isError: true };
|