mcp-read-file-server 1.6.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.
Files changed (4) hide show
  1. package/README.md +135 -6
  2. package/SKILL.md +17 -9
  3. package/index.js +877 -6
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  加密环境文件操作工具。当 Node.js 是加密软件白名单进程时,通过 fs 模块自动解密读写文件明文,替代 AI Agent 内置文件工具,解决加密环境下读到密文的问题。适用于任何支持 MCP 协议的 AI Agent。
6
6
 
7
+ **v1.7.0 环境自适应**:自动探测本机加密策略(哪些扩展名会被透明加密、哪些进程可用),对「会被加密且无法解密」的扩展名(如部分机器上的 `.scss`/`.css`)自动走 safeWrite 中转落盘明文,无需任何手工配置,换电脑自动重新探测。详见下文「环境自适应」。
8
+
9
+ **v1.8.0 写入后实时重分类**:启动探测只给出先验分类,且 Node.js 白名单读回无法区分「真受控(TSD 管控文档)」与「伪受控」。1.8.0 起所有直写路径完成后,用外部进程读取目标文件磁盘原始字节实测:发现密文(命中 `%TSD` 魔数)自动将该扩展名重分类为 encrypted 并立即用 safeWrite 重写为明文;实测明文则重分类为 safe。对需要**保持加密**的扩展名(如 `.java`),用 `mark_extension` 手动标注 protected 后,写入直写保持加密且跳过自动纠正。
10
+
7
11
  ## 适用场景
8
12
 
9
13
  电脑安装了文件加密软件(如天锐绿盾、IP-Guard、亿赛通、深信服等),磁盘上的文件是密文。AI Agent(Claude Code、Cursor、Windsurf、Cline 等)是独立进程,内置文件工具不在白名单内,只能读到密文。而 Node.js 进程在白名单内,通过 MCP Server 提供的替代工具可以正常读写明文。
@@ -31,6 +35,66 @@
31
35
  AI Agent --(MCP/stdio)--> Node.js MCP Server --(fs.readFileSync)--> 读取明文
32
36
  ```
33
37
 
38
+ ## 环境自适应(v1.7.0+)
39
+
40
+ ### 解决的问题
41
+
42
+ 加密软件对「写入是否透明加密」是**按目标文件扩展名**决定的,且每台电脑的策略不同:
43
+
44
+ | 扩展名分类 | 含义 | 直写后果 | 本工具策略 |
45
+ |-----------|------|---------|-----------|
46
+ | **safe** | 写入后磁盘是明文 | 正常 | 直接写(原始行为) |
47
+ | **protected** | 写入后磁盘是密文,但 Node.js 白名单读回自动解密 | 本机正常 | 直接写(保持加密保护) |
48
+ | **unsafe** | 写入后被加密,但该类型不在保护列表,**任何进程都无法解密** | 磁盘密文乱码,文件损坏 | 自动走 safeWrite |
49
+
50
+ ### safeWrite 原理
51
+
52
+ 对 unsafe 扩展名目标,写入流程自动切换为:
53
+
54
+ ```
55
+ 1. fs.writeFileSync 写入 目标路径+安全扩展名 的临时文件(安全类型 → 磁盘明文)
56
+ 2. 用探测到的可用外部进程(powershell/cmd/robocopy/cscript 等)复制临时文件到目标路径
57
+ (外部进程不在白名单内,复制动作不触发透明加密 → 目标落盘为明文)
58
+ 3. 校验目标文件磁盘字节为明文
59
+ 4. 清理临时文件
60
+ 5. 失败则遍历全部「安全扩展名 × 可用进程」组合重试;全部失败回退直写并明确告警
61
+ ```
62
+
63
+ ### 探测与缓存
64
+
65
+ - **首次启动(或缓存失效)自动探测**:依次用候选扩展名写入临时文件,通过「外部进程读磁盘原始字节 + 物理大小对比 + Node 读回对比」三重交叉验证分类;再探测可用的外部进程并做复制交叉验证
66
+ - **探测全程在系统临时目录进行**,不污染用户目录;对未在候选清单中的扩展名,首次写入时按需即时探测(在目标文件所在目录进行,兼容按目录生效的策略)
67
+ - **缓存位置**:`~/.mcp-encryption-profile.json`,按 machineId(hostname+username 哈希)绑定,**换电脑/换用户自动重新探测**;缓存有效期 30 天
68
+ - **查看/刷新**:用 `encryption_profile` 工具查看当前探测结果;加密策略变更后用 `refresh_profile` 强制重探
69
+ - **无外部进程可用时**(如进程被策略禁止 spawn):自动降级为大小+读回对比探测,unsafe 写入回退直写并告警,不影响其他功能
70
+
71
+ ### 写入后实时重分类(v1.8.0+)
72
+
73
+ 启动探测的分类结论是先验的(在系统临时目录采样),且 Node.js 白名单读回无法区分「真受控文档」与「伪受控」——两者在白名单进程里都能读到明文。1.8.0 起引入**写入后实测**兜底:
74
+
75
+ ```
76
+ 写入完成 → 外部进程读目标文件磁盘前 16 字节
77
+ ├─ 与写入内容前缀一致 → 磁盘明文 → 扩展名重分类为 safe
78
+ ├─ 命中 %TSD 魔数 → 磁盘密文 → 扩展名重分类为 encrypted,
79
+ │ 并立即用 safeWrite 重写为明文(自动纠正)
80
+ └─ 检测不可用 → 保持原分类(无任何副作用)
81
+ ```
82
+
83
+ - **首次写入新扩展名**:先直写,实测发现加密 → 自动重分类 + 立即纠正为明文,并提示已切换策略;第二次起该扩展名直接走 safeWrite
84
+ - **目录级策略差异**:已知 safe/protected 的扩展名在写入后也会复测,策略被管理员调整或按目录生效时可自动纠正误分类
85
+ - **用户标注优先**:`mark_extension` 标注为 protected 的扩展名保持加密直写,跳过自动纠正(适合 `.java` 这类需要保持加密状态的受控文档);标注为 unsafe 的扩展名强制走 safeWrite 保持明文
86
+ - **缓存结构 v2**:新增 `encryptedExtensions`(写入后实测密文的扩展名)与 `userProtectedExtensions`(用户标注),旧版缓存自动作废重探
87
+
88
+ ### mark_extension 手动标注
89
+
90
+ 当自动分类不符合预期时手动干预(标注优先级高于一切自动分类):
91
+
92
+ | 场景 | 调用 | 效果 |
93
+ |----------------------------------------------|------|------|
94
+ | `.java` 需要保持 TSD 加密(company受控文档) | `mark_extension(".java", "protected")` | 直写保持加密,Notepad 打开正常,跳过写入后自动纠正 |
95
+ | `.scss` 必须保持明文(自动误判为 protected) | `mark_extension(".scss", "unsafe")` | 强制走 safeWrite 保持明文 |
96
+ | 恢复自动分类 | `mark_extension(".java", "clear")` | 清除手动标注 |
97
+
34
98
  ## 文件结构
35
99
 
36
100
  ```
@@ -39,7 +103,7 @@ mcp-read-file-server/
39
103
  ├── SKILL.md # 配套 Skill(可选,让 AI 学会自动选用本工具)
40
104
  ├── index.js # MCP Server 主程序(含 shebang,可作可执行入口)
41
105
  ├── package.json # 包配置(bin/files/依赖声明,可 npm publish)
42
- ├── .gitignore # Git 忽略规则
106
+ ├── .gitignore # Git 忽略规则
43
107
  └── node_modules/ # 依赖(@modelcontextprotocol/sdk、zod,不随包发布)
44
108
  ```
45
109
 
@@ -181,17 +245,20 @@ claude mcp list
181
245
  | `read_file` | Read | 读取单个文件明文(超大文件自动截断) | `path` |
182
246
  | `read_files` | 多次 Read | 批量读取多个文件明文(数组或逗号分隔字符串) | `paths` |
183
247
  | `read_file_partial` | Read(局部) | 局部读取文件(前N字符 / 指定行范围) | `path`、`mode`、`charCount`、`startLine`、`endLine` |
184
- | `write_file` | Write | 写入文件(支持追加模式 / 行尾风格 / BOM 保留) | `path`、`content`、`mode`、`eol` |
185
- | `edit_file` | Edit/MultiEdit | 精确替换后写回(CRLF/LF 自动兼容、BOM 保留、正则多行模式、`edits` 批量原子编辑、失败附相似行诊断) | `path`、`oldString`、`newString`、`edits`、`useRegex`、`replaceAll`、`ignoreCase` |
248
+ | `write_file` | Write | 写入文件(支持追加模式 / 行尾风格 / BOM 保留;unsafe 扩展名自动 safeWrite 明文落盘) | `path`、`content`、`mode`、`eol` |
249
+ | `edit_file` | Edit/MultiEdit | 精确替换后写回(CRLF/LF 自动兼容、BOM 保留、正则多行模式、`edits` 批量原子编辑、失败附相似行诊断;unsafe 扩展名自动 safeWrite) | `path`、`oldString`、`newString`、`edits`、`useRegex`、`replaceAll`、`ignoreCase` |
186
250
  | `search_files` | Grep | 递归搜索文件内容(支持 `**` 目录通配、跳过二进制/超大文件) | `pattern`、`path`、`include`、`exclude`、`ignoreCase`、`onlyMatching`、`maxResults` |
187
251
  | `find_files` | Glob | 按文件名 glob 递归查找(如 `**/*.test.js`) | `pattern`、`path`、`maxResults` |
188
252
  | `list_directory` | LS | 列出目录内容(类型/大小/时间) | `path`、`showHidden` |
189
- | `copy_path` | bash cp | 复制文件/目录(递归;加密环境必须经白名单进程) | `source`、`destination` |
190
- | `move_path` | bash mv | 移动/重命名(跨盘符自动回退复制+删除) | `source`、`destination` |
253
+ | `copy_path` | bash cp | 复制文件/目录(递归;加密环境必须经白名单进程;unsafe 目标自动 safeCopy) | `source`、`destination` |
254
+ | `move_path` | bash mv | 移动/重命名(跨盘符自动回退复制+删除;unsafe 目标自动明文落盘) | `source`、`destination` |
191
255
  | `remove_path` | bash rm | 删除文件/目录(默认递归,谨慎使用) | `path`、`recursive` |
192
256
  | `create_directory` | - | 递归创建目录 | `path` |
193
257
  | `file_info` | - | 查询文件/目录信息(含明文大小、符号链接) | `path` |
194
- | `check_status` | - | 检查运行状态(可实测解密能力) | `path`(可选) |
258
+ | `check_status` | - | 检查运行状态(可实测解密能力,输出含环境探测概要) | `path`(可选) |
259
+ | `encryption_profile` | - | 查看环境探测结果(扩展名三分类、可用进程、最佳组合、缓存位置) | 无 |
260
+ | `refresh_profile` | - | 强制重新探测环境并更新缓存(加密策略变更后使用) | 无 |
261
+ | `mark_extension` | `extension`, `category` | 手动标注扩展名写入策略:protected=保持加密直写,unsafe=强制 safeWrite 明文,clear=清除标注 | 无 |
195
262
 
196
263
  ### `read_file_partial` 参数详解
197
264
 
@@ -238,6 +305,52 @@ Windows 下文件多为 CRLF 换行,而 AI Agent 生成的多行 `oldString`
238
305
 
239
306
  配置好后,在 Agent 中直接说需求即可。Agent 会自动调用 MCP 工具读写文件明文。
240
307
 
308
+ ## 推荐提示词(Prompt)
309
+
310
+ 把下面这段提示词放入 Agent 的系统提示/记忆文件(Claude Code 放 `CLAUDE.md`,Cursor 放 `.cursor/rules`,其他 Agent 放对应的全局指令位置),可让 Agent 在加密环境下**始终正确地**使用本工具,避免误用内置工具读到密文或写出不可解密的乱码文件:
311
+
312
+ ```markdown
313
+ # 文件操作强制规则(加密软件环境)
314
+
315
+ 本机装有文件加密软件(天锐绿盾/TSD/IP-Guard/亿赛通等),磁盘文件为密文。
316
+ 所有文件操作必须使用 mcp__read-file-server__* 工具,严禁使用内置
317
+ Read/Write/Edit/MultiEdit/Grep/LS 及 Bash 的 cat/sed/grep/cp/mv/rm
318
+ (内置工具不在加密软件白名单内,会读到密文或产出任何进程都无法解密的乱码文件)。
319
+ find_files 之外的文件名查找也优先用 MCP 工具。
320
+
321
+ ## 工具映射
322
+ - 读单个文件 → mcp__read-file-server__read_file
323
+ - 读多个文件 → mcp__read-file-server__read_files(批量,一次调用)
324
+ - 局部读取 → read_file_partial(大文件分页:mode=chars 或 mode=lines)
325
+ - 新建/覆盖写 → write_file
326
+ - 修改文件 → edit_file(多处修改必须用 edits 数组一次提交,禁止逐条调用)
327
+ - 搜索内容 → search_files(include 限定类型,maxResults 控制数量)
328
+ - 按文件名查找 → find_files
329
+ - 列目录 → list_directory
330
+ - 复制/移动/删除 → copy_path / move_path / remove_path
331
+ - 建目录/查信息 → create_directory / file_info
332
+
333
+ ## 使用规则
334
+ 1. 会话开始先调 check_status 确认白名单解密正常;环境不明时调
335
+ encryption_profile 查看本机扩展名分类(safe/protected/unsafe/encrypted)与可用进程。
336
+ 2. 写任何扩展名的文件都不用关心加密细节:write_file/edit_file/copy_path/
337
+ move_path 已内置环境自适应与写入后实时检测——写入后磁盘为密文的扩展名
338
+ 会被自动识别并立即重写为明文,后续同类文件自动走 safeWrite。
339
+ 3. 需要保持加密状态的扩展名(如受控的 .java 文档):用
340
+ mark_extension(".java", "protected") 标注一次即可,之后写入直写保持加密;
341
+ 反之若某扩展名被误判导致写入后变密文,用 mark_extension(".ext", "unsafe")
342
+ 强制保持明文。标注一次永久生效(缓存在本机)。
343
+ 4. edit_file 前必须先 read_file 拿原文,oldString 从原文原样复制
344
+ (含空格与缩进;CRLF/LF 换行差异会自动兼容,无需手工处理)。
345
+ 5. 路径一律使用绝对路径。
346
+ 6. 若写工具返回「safeWrite 失败/回退直接写入」告警,先调 refresh_profile
347
+ 重新探测环境,再重试写入;仍失败则把告警原文报告给用户。
348
+ 7. edit_file 匹配失败时,按返回的「可能相关的行」诊断修正 oldString,
349
+ 不要盲目重试。
350
+ ```
351
+
352
+ > 该提示词与 `SKILL.md` 二选一即可:Agent 支持 Skill 机制(Claude Code 等)时装 SKILL.md;不支持或想要更强约束时,直接把上面的提示词写进全局指令。
353
+
241
354
  ## 配套 Skill(可选)
242
355
 
243
356
  本工具附带一份 Skill:`SKILL.md`,位于本目录根下。
@@ -332,6 +445,18 @@ cd mcp-read-file-server && npm install
332
445
  echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' | node index.js
333
446
  ```
334
447
 
448
+ ### 写入 .scss/.css 等文件后显示乱码(密文)
449
+
450
+ 该扩展名在本机属于 unsafe 类型(加密但不自动解密)。v1.7.0+ 会自动走 safeWrite 规避;若仍出现乱码:
451
+
452
+ 1. 调 `refresh_profile` 强制重新探测(策略可能变更或缓存过期)
453
+ 2. 调 `encryption_profile` 确认该扩展名已被正确识别为 unsafe、且存在可用外部进程与 bestCombo
454
+ 3. 若显示「可用外部进程: (无)」,说明 MCP Server 进程被策略禁止 spawn 子进程,需联系管理员放行 powershell/cmd,或接受直写加密后由白名单应用打开
455
+
456
+ ### 写工具返回「safeWrite 失败,已回退直接写入」告警
457
+
458
+ 说明所有「安全扩展名 × 外部进程」组合都验证失败(常见原因:外部进程对目标目录无写权限)。处理:调 `refresh_profile` 重探;检查目标目录权限;换目录重试。回退写入的文件在本机可能显示乱码,建议删除后重新写入。
459
+
335
460
  ### Agent 连不上 MCP Server
336
461
 
337
462
  包本身正常但 Agent 连不上时,按以下顺序排查:
@@ -349,3 +474,7 @@ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":
349
474
  npx -y mcp-read-file-server # 能启动=包没问题,问题在 Agent 配置/环境
350
475
  ```
351
476
  能启动并卡住等输入,说明包正常,需检查 Agent 的配置 JSON 格式与 `command` 写法。
477
+
478
+
479
+ ---
480
+ [![MCP Badge](https://lobehub.com/badge/mcp/hebulin-mcp-read-file-server)](https://lobehub.com/mcp/hebulin-mcp-read-file-server)
package/SKILL.md CHANGED
@@ -40,6 +40,9 @@ description: 在文件加密软件(天锐绿盾 / IP-Guard / 亿赛通 / 深
40
40
  | 创建目录 | (无) | `mcp__read-file-server__create_directory` |
41
41
  | 查文件信息 | (无) | `mcp__read-file-server__file_info` |
42
42
  | 健康检查 | (无) | `mcp__read-file-server__check_status` |
43
+ | 查看环境探测结果 | (无) | `mcp__read-file-server__encryption_profile` |
44
+ | 重新探测环境(策略变更后) | (无) | `mcp__read-file-server__refresh_profile` |
45
+ | 手动标注扩展名保持加密/明文 | (无) | `mcp__read-file-server__mark_extension` |
43
46
 
44
47
  > **强约束**:在加密环境下,**禁止使用** `Read/Write/Edit/MultiEdit/Grep/Glob/LS` 内置工具与 `Bash` 的 `cp/mv/rm` 文件操作--它们会读到密文、写出密文或破坏加密结构。
45
48
 
@@ -147,15 +150,17 @@ description: 在文件加密软件(天锐绿盾 / IP-Guard / 亿赛通 / 深
147
150
 
148
151
  ## 六、注意事项
149
152
 
150
- 1. **路径**:用**绝对路径**最稳(如 `D:/AiJiamiToolsPlugins/...`),相对路径以 MCP Server 启动目录为基准
151
- 2. **`edit_file` 前必读**:必须先 `read_file` 拿到明文,再从原文里**原样复制** `oldString`,否则会因为空格/缩进不匹配而失败
152
- 3. **换行风格无需担心**:文件是 CRLF 而 `oldString` LF(或相反)时,`edit_file` 会自动归一换行后匹配;`newString` 行尾也会自动跟随文件主导风格,不会产生混行
153
- 4. **`write_file` 是覆盖写**:会清空原文件再写入,重要文件修改前建议先 `read_file` 备份内容
154
- 5. **`search_files` / `find_files` 自动跳过**:`node_modules`、`.git`、`target`、`build`、`dist`、`.svn`、`bin`、`obj`、`out`、`vendor` 与 `.` 开头的隐藏文件/目录;`search_files` 另跳过二进制与超过 5MB 的文件
155
- 6. **大批量搜索**:用 `maxResults` 控制返回数量,避免一次性返回过多结果
156
- 7. **工具调用顺序**:复杂任务先 `check_status`(可传 path 实测解密)确认 MCP 正常,再正式操作
157
- 8. **`remove_path` 不可恢复**:递归删除前建议先 `list_directory` 确认内容
158
- 9. **`read_files` 路径含逗号时必须传数组**:Windows 路径可合法包含英文逗号,逗号分隔字符串形式会被错误切分
153
+ 1. **环境自适应(v1.7.0+)**:写工具(write_file/edit_file/copy_path/move_path)已内置扩展名分类感知,unsafe 扩展名(直写会变不可解密密文的类型,如部分机器的 .scss/.css)自动走 safeWrite 明文落盘,**无需任何特殊处理**;策略变更或换电脑后探测缓存自动失效重探,也可手动调 `refresh_profile`
154
+ 2. **写入后实时重分类(v1.8.0+)**:直写完成后会用外部进程实测磁盘字节,发现密文自动重分类该扩展名并立即重写为明文(返回中会有「已自动重分类」提示);已知 safe/protected 扩展名也会复测,目录级策略差异可自动纠正。需要**保持加密**的扩展名(如公司受控的 .java),用 `mark_extension(".java", "protected")` 标注后直写保持加密并跳过自动纠正
155
+ 3. **路径**:用**绝对路径**最稳(如 `D:/AiJiamiToolsPlugins/...`),相对路径以 MCP Server 启动目录为基准
156
+ 4. **`edit_file` 前必读**:必须先 `read_file` 拿到明文,再从原文里**原样复制** `oldString`,否则会因为空格/缩进不匹配而失败
157
+ 5. **换行风格无需担心**:文件是 CRLF `oldString` 是 LF(或相反)时,`edit_file` 会自动归一换行后匹配;`newString` 行尾也会自动跟随文件主导风格,不会产生混行
158
+ 6. **`write_file` 是覆盖写**:会清空原文件再写入,重要文件修改前建议先 `read_file` 备份内容
159
+ 7. **`search_files` / `find_files` 自动跳过**:`node_modules`、`.git`、`target`、`build`、`dist`、`.svn`、`bin`、`obj`、`out`、`vendor` `.` 开头的隐藏文件/目录;`search_files` 另跳过二进制与超过 5MB 的文件
160
+ 8. **大批量搜索**:用 `maxResults` 控制返回数量,避免一次性返回过多结果
161
+ 9. **工具调用顺序**:复杂任务先 `check_status`(可传 path 实测解密)确认 MCP 正常,再正式操作
162
+ 10. **`remove_path` 不可恢复**:递归删除前建议先 `list_directory` 确认内容
163
+ 11. **`read_files` 路径含逗号时必须传数组**:Windows 路径可合法包含英文逗号,逗号分隔字符串形式会被错误切分
159
164
 
160
165
  ---
161
166
 
@@ -163,6 +168,9 @@ description: 在文件加密软件(天锐绿盾 / IP-Guard / 亿赛通 / 深
163
168
 
164
169
  | 现象 | 可能原因 | 解决方案 |
165
170
  |------|----------|----------|
171
+ | 写入 .scss/.css 后文件显示乱码(密文) | 该扩展名为 unsafe 且 safeWrite 未生效(缓存过期/策略变更) | 调 `refresh_profile` 重探后重写;用 `encryption_profile` 确认分类与可用进程;1.8.0+ 写入后会自动实测纠正,若仍乱码用 `mark_extension(".scss", "unsafe")` 强制明文 |
172
+ | 写入 .java 后被自动转明文(需要保持加密) | 1.8.0+ 写入后检测发现密文自动纠正为明文 | 用 `mark_extension(".java", "protected")` 标注保持加密,后续直写不再自动纠正 |
173
+ | 写工具提示「safeWrite 失败,已回退直接写入」 | 无可用外部进程或全部组合验证失败 | 调 `refresh_profile`;确认 MCP Server 进程可 spawn powershell/cmd;删除乱码文件后重写 |
166
174
  | 读到的还是密文/乱码 | Node.js 不在加密软件白名单 | 联系管理员把 `node.exe` 加入白名单 |
167
175
  | `mcp__read-file-server__*` 工具全部不可见 | MCP Server 未配置或未启动 | 见 `README.md` 配置 `.mcp.json` |
168
176
  | `edit_file` 报"未找到匹配内容" | `oldString` 拼写、缩进不对(换行 CRLF/LF 差异与 BOM 已自动兼容) | 重新 `read_file` 复制原文,**不要凭记忆写**;重点检查空格与缩进 |
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 };
@@ -639,9 +1434,15 @@ server.tool(
639
1434
  if (content === original) {
640
1435
  return { content: [{ type: "text", text: "⚠️ 批量编辑应用后内容无变化,文件未修改: " + filePath }] };
641
1436
  }
642
- fs.writeFileSync(filePath, (hasBom ? "\uFEFF" : "") + content, "utf-8");
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 + "),已回退直接写入,磁盘可能为不可解密密文";
643
1444
  return {
644
- content: [{ type: "text", text: "✅ 批量编辑成功: " + filePath + "\n共 " + edits.length + " 条," + total + " 处替换\n" + applied.join("\n") }],
1445
+ content: [{ type: "text", text: "✅ 批量编辑成功: " + filePath + "\n共 " + edits.length + " 条," + total + " 处替换\n" + applied.join("\n") + wbNote }],
645
1446
  };
646
1447
  }
647
1448
 
@@ -667,7 +1468,12 @@ server.tool(
667
1468
  return { content: [{ type: "text", text: "⚠️ 替换后内容无变化,文件未修改: " + filePath }] };
668
1469
  }
669
1470
  // 原文件带 BOM 时补回,保持文件编码特征不变(部分 Windows 软件依赖 BOM)
670
- fs.writeFileSync(filePath, (hasBom ? "\uFEFF" : "") + updated, "utf-8");
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 + "),已回退直接写入,磁盘可能为不可解密密文";
671
1477
  return {
672
1478
  content: [{ type: "text", text: "✅ 替换成功: " + filePath + "\n替换 " + (replaceAll ? count : 1) + "/" + count + " 处" + warning }],
673
1479
  };
@@ -909,6 +1715,13 @@ server.tool(
909
1715
  { readOnlyHint: true },
910
1716
  async ({ path: filePath }) => {
911
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
+ }
912
1725
  if (filePath === undefined) {
913
1726
  base += "\n提示: 传入 path 参数可实测解密能力(本次未做实测)";
914
1727
  return { content: [{ type: "text", text: base }] };
@@ -1087,7 +1900,33 @@ server.tool(
1087
1900
  if (srcStat.isDirectory() && destStat && destStat.isFile()) {
1088
1901
  return { content: [{ type: "text", text: "❌ 无法复制:源是目录但目标是已存在的文件: " + finalDest }], isError: true };
1089
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
+ }
1090
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
+ }
1091
1930
  return { content: [{ type: "text", text: "✅ 复制成功: " + source + " -> " + finalDest + (srcStat.isDirectory() ? "(递归目录)" : "") }] };
1092
1931
  } catch (e) {
1093
1932
  return { content: [{ type: "text", text: "❌ 复制失败: " + e.message }], isError: true };
@@ -1123,18 +1962,50 @@ server.tool(
1123
1962
  } catch (e) {
1124
1963
  if (e.code !== "ENOENT") throw e;
1125
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 = "";
1126
1979
  try {
1127
1980
  fs.renameSync(source, finalDest);
1128
- return { content: [{ type: "text", text: "✅ 移动成功: " + source + " -> " + finalDest }] };
1981
+ moved = true;
1129
1982
  } catch (e) {
1130
1983
  if (e.code === "EXDEV") {
1131
1984
  // 跨盘符:rename 不可用,回退为 cp + rm
1132
1985
  fs.cpSync(source, finalDest, { recursive: srcStat.isDirectory(), force: true });
1133
1986
  fs.rmSync(source, { recursive: srcStat.isDirectory(), force: true });
1134
- return { content: [{ type: "text", text: "✅ 移动成功(跨盘符,复制后删除源): " + source + " -> " + finalDest }] };
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
+ }
1135
2006
  }
1136
- throw e;
1137
2007
  }
2008
+ return { content: [{ type: "text", text: "✅ 移动成功" + moveNote + ": " + source + " -> " + finalDest }] };
1138
2009
  } catch (e) {
1139
2010
  if (e.code === "ENOENT") {
1140
2011
  return { content: [{ type: "text", text: "❌ 源路径不存在: " + source }], isError: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-read-file-server",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "加密环境文件操作 MCP 工具。当 Node.js 是加密软件白名单进程时,通过 fs 模块自动解密读写文件明文,替代 AI Agent 内置文件工具,适用于任何支持 MCP 协议的 Agent。",
5
5
  "type": "commonjs",
6
6
  "main": "index.js",