mcp-read-file-server 1.9.3 → 2.1.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 +73 -50
- package/SKILL.md +7 -3
- package/lib/encryption.js +53 -12
- package/lib/files.js +14 -6
- package/lib/server.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,15 +4,26 @@
|
|
|
4
4
|
|
|
5
5
|
加密环境文件操作工具。当 Node.js 是加密软件白名单进程时,通过 fs 模块自动解密读写文件明文,替代 AI Agent 内置文件工具,解决加密环境下读到密文的问题。适用于任何支持 MCP 协议的 AI Agent。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## 最新版本:2.1.0(相对 1.0)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
本节以仓库 `v1.0` 标签(包版本 `1.0.0`)为基线,汇总当前版本的变化,不再逐条保留中间版本的更新说明。
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
| 方面 | 1.0 | 2.1.0 |
|
|
12
|
+
|------|-----|-------|
|
|
13
|
+
| 文件写入 | 由 Node 直接读改写,依赖本机透明加密行为 | 统一暂存、备份、提交及终验;失败回滚,恢复失败保留 `recoveryPath` |
|
|
14
|
+
| 加密策略 | 没有按原文件状态选择写入策略 | `auto` 比较原文件的 Node 与外部读取视图:原明文继续验证明文,原受保护文件保留受控写入;新建文件不凭 Node 可读就自动加密 |
|
|
15
|
+
| 工具范围 | 8 个基础读写、搜索及目录工具 | 18 个工具,增加分页、列目录、文件查找、复制/移动/删除和 4 个策略诊断/管理工具 |
|
|
16
|
+
| 编辑准确性 | 单次字符串或正则替换 | 批量原子编辑、CRLF/LF 适配、BOM 保留、预览、预期匹配数和 hash 冲突保护 |
|
|
17
|
+
| 读取与性能 | 同步文件操作和基础搜索 | 异步 I/O、流式分页、有界输出;正则与字面量计算在可终止 worker 中执行;glob 累计预算并让出主线程 |
|
|
18
|
+
| 并发与异常 | 基础异常返回 | 跨实例路径/子树锁;目录逐文件校验;部分完成、源保留、回滚和清理错误分别报告 |
|
|
19
|
+
| 边界保护 | 主要依赖进程文件权限 | 可配置允许根、只读和禁删;识别真实路径/链接及受保护根;拒绝目录双向祖先重叠 |
|
|
20
|
+
| 分发与验证 | 本地脚本启动,无仓库测试套件 | npm CLI 入口、模块化源码、结构化响应、回归/真实 stdio/Windows 适配测试及 CI;最低 Node.js 20 |
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
**本次重点修复**:原始明文文件经 MCP 编辑后出现加密内容,而 Node 读回正常、IDEA 却显示密文。修复基于每个文件的写入前状态,适用于所有扩展名、未知后缀、无扩展名和点文件;不对 SCSS 做特殊判断。文本工具仍只接受有效 UTF-8,二进制文件通过复制/移动使用相同的提交保护。
|
|
14
23
|
|
|
15
|
-
|
|
24
|
+
**累计修复与优化**:拒绝非法 UTF-8、UTF-16 和 NUL 文本的破坏性编辑;修复分页边界、换行匹配、复制移动重叠、并发追加及部分失败状态;安全中转失败不再回退直写;锁和临时文件清理失败不会掩盖主操作结果;工作线程和扫描预算防止复杂表达式阻塞服务。依赖锁定与 overrides 用于复现已验证的依赖树。
|
|
25
|
+
|
|
26
|
+
**升级行为变化**:显式 `writePolicy` 优先,其次是持久人工策略,再由 `auto` 观察文件状态。Windows 的 `auto` 缺少可用外部读取器时返回 `DISK_UNVERIFIED`,不会猜测后继续写入;需要新建受保护文件时明确指定 `preserve` 或配置人工 `protected`。更新后重启全部 MCP 实例,并用 `check_status` 确认运行版本为 `2.1.0`。本地修改不会自动发布到 npm。
|
|
16
27
|
|
|
17
28
|
## 适用场景
|
|
18
29
|
|
|
@@ -50,40 +61,44 @@ AI Agent --(MCP/stdio)--> Node.js MCP Server(index.js) --(lib/ 模块)--> fs
|
|
|
50
61
|
- `lib/regex.js` / `lib/regex-worker.js` 用户正则与字面量编辑在可终止 worker 中执行(单次计算默认最多 1 秒,并受请求总预算约束)
|
|
51
62
|
- `lib/server.js` 注册全部 18 个 MCP 工具,统一 structuredContent 与超时/只读包装
|
|
52
63
|
|
|
53
|
-
##
|
|
64
|
+
## 环境自适应
|
|
54
65
|
|
|
55
66
|
### 解决的问题
|
|
56
67
|
|
|
57
|
-
|
|
68
|
+
加密行为可能随目录、文件类型和进程变化。Node 能读到明文,并不表示 IDEA 或其他编辑器也能解密。新建探测样本的分类只描述进程观察,不能替代原文件的状态:
|
|
69
|
+
|
|
70
|
+
| 探测分类 | 实际观察 | 新建文件的 auto 策略 |
|
|
71
|
+
|----------|----------|----------------------|
|
|
72
|
+
| **safe** | Node 和外部读取视图均与探测载荷一致 | 允许先写暂存文件,暂存及最终路径仍须通过明文校验 |
|
|
73
|
+
| **protected** | Node 读回正确,外部读取视图不同 | 不推断其他编辑器可解密,使用安全中转并验证明文 |
|
|
74
|
+
| **unsafe** | Node 读回已经与探测载荷不同 | 使用安全中转并验证明文 |
|
|
75
|
+
| **unknown** | 无法取得外部读取结果 | 不能宣称安全;有读取器时只允许通过严格明文校验后提交 |
|
|
58
76
|
|
|
59
|
-
|
|
60
|
-
|-----------|------|---------|-----------|
|
|
61
|
-
| **safe** | 写入后磁盘是明文 | 正常 | 直接写(原始行为) |
|
|
62
|
-
| **protected** | 写入后磁盘是密文,但 Node.js 白名单读回自动解密 | 本机正常 | 直接写(保持加密保护) |
|
|
63
|
-
| **unsafe** | 写入后被加密,但该类型不在保护列表,**任何进程都无法解密** | 磁盘密文乱码,文件损坏 | 自动走 safeWrite |
|
|
77
|
+
已有文件的状态不缓存、不按后缀共享:每次 `auto` 都比较该文件的 Node 指纹与外部进程指纹。两者一致时要求明文提交;两者不同时走受控写入,并检查外部视图没有意外变成预期明文。复制/移动覆盖已有目标时参考目标状态,新目标参考源文件状态;目录内逐文件执行。
|
|
64
78
|
|
|
65
79
|
### safeWrite 原理
|
|
66
80
|
|
|
67
|
-
|
|
81
|
+
明文暂存写入出现内容不一致、目录探测不适合直接写入,或明确要求安全中转时,流程切换为:
|
|
68
82
|
|
|
69
83
|
```
|
|
70
|
-
1.
|
|
71
|
-
2.
|
|
72
|
-
|
|
73
|
-
3. 用独立读取器(PowerShell 流式 SHA256+size)校验目标文件磁盘指纹为明文
|
|
84
|
+
1. 写入目标目录下 .mcp-safe-<uuid><候选扩展名> 的随机临时文件,并验证其内容
|
|
85
|
+
2. 用可用外部进程(powershell/pwsh/cmd/robocopy/cscript)复制到 .mcp-stage-<uuid><目标扩展名>
|
|
86
|
+
3. 用Node和外部读取器(PowerShell 流式 SHA256+size)验证暂存文件与预期载荷一致
|
|
74
87
|
4. 清理临时文件
|
|
75
88
|
5. 失败则遍历全部「安全扩展名 × 可用进程」组合重试;
|
|
76
|
-
|
|
89
|
+
全部失败直接报错(SAFE_WRITE_FAILED),不再回退未经验证的写入
|
|
77
90
|
```
|
|
78
91
|
|
|
92
|
+
暂存成功后仍需 rename 提交及最终路径校验,最终校验失败会回滚。外部进程是否会加密、是否会自动解密均不能仅凭进程名称判断;上述校验是进程可见字节对照,不是绕过驱动读取原始磁盘。
|
|
93
|
+
|
|
79
94
|
### 探测与缓存
|
|
80
95
|
|
|
81
|
-
-
|
|
82
|
-
-
|
|
83
|
-
- **自动缓存位置**:`~/.mcp-encryption-profile.json`(结构 v3),按 machineId(hostname+username
|
|
96
|
+
- **首次需要自动策略(或缓存失效)时探测**:在系统临时目录用候选扩展名写入样本,通过 Node 与外部进程指纹对照分类,并探测可用复制进程
|
|
97
|
+
- **目录级探测**:无原文件或复制源状态可参考的新建路径,按「目标目录 × 扩展名」创建随机样本(`.mcp-probe-<uuid><ext>`,写完即删),观察缓存在内存 scopes 中。已有文件优先逐文件观察,不受旧目录分类覆盖
|
|
98
|
+
- **自动缓存位置**:`~/.mcp-encryption-profile.json`(结构 v3),按 machineId(hostname+username 哈希)绑定,换电脑/换用户重新探测;有效期 30 天。兼容有效的 v2/v3 缓存,丢弃旧 scopes;v2 人工 protected 一次性迁移到独立策略目录
|
|
84
99
|
- **人工策略独立存储**:`~/.mcp-file-policies/` 每个扩展名一个文件,刷新探测、TTL 过期、服务重启都不会删除人工标注
|
|
85
100
|
- **查看/刷新**:用 `encryption_profile` 工具查看当前探测结果与人工策略;加密策略变更后用 `refresh_profile` 强制重探(只刷新自动探测,不动人工策略);`inspect_write_strategy` 可预览某个目标路径将采用的写入策略而不修改目标文件
|
|
86
|
-
-
|
|
101
|
+
- **无外部读取器时**:Windows 的 auto 返回 `DISK_UNVERIFIED`;非 Windows 且没有该后缀的加密观察时保留 Node 内容校验,返回 unknown 并告警。显式 plaintext 在所有平台都必须有可用外部读取器。配置过的读取器临时失败时不得降级为成功
|
|
87
102
|
|
|
88
103
|
### 写入策略(writePolicy)
|
|
89
104
|
|
|
@@ -91,21 +106,23 @@ write_file / edit_file / copy_path / move_path 均支持 `writePolicy` 参数:
|
|
|
91
106
|
|
|
92
107
|
| 值 | 语义 |
|
|
93
108
|
|----|------|
|
|
94
|
-
| `auto`(默认) |
|
|
95
|
-
| `preserve` |
|
|
96
|
-
| `plaintext` |
|
|
109
|
+
| `auto`(默认) | 人工标注优先;否则原明文要求明文终验,原受保护文件保留受控写入;新文件要求明文,不自动沿用探测样本的加密状态 |
|
|
110
|
+
| `preserve` | 显式受控写入,经暂存替换并校验 Node 内容;不保证原来的明文状态,也不保证其他编辑器能解密 |
|
|
111
|
+
| `plaintext` | 显式安全中转,必须验证暂存及最终路径的外部指纹与预期明文一致;失败中止或回滚 |
|
|
112
|
+
|
|
113
|
+
返回的 `strategy.basis` 区分 `explicit`(本次显式策略)、`override`(人工标注)、`target`(原目标)、`source`(新复制目标的源)、`new_file`(全新文件)和 `unverified`。`originalState` 记录自动决策依据;`category` 描述观察结果,不是编辑器兼容性认证。`user_unsafe` 在显式 plaintext 下也会返回,不表示新增了永久标注。
|
|
97
114
|
|
|
98
115
|
### mark_extension 手动标注
|
|
99
116
|
|
|
100
|
-
|
|
117
|
+
当需要固定某类文件的写入方式时手动标注(本次显式 writePolicy 优先,其次人工标注,再次自动状态判断;标注持久化,重启/刷新不丢失):
|
|
101
118
|
|
|
102
119
|
| 场景 | 调用 | 效果 |
|
|
103
120
|
|------|------|------|
|
|
104
|
-
| `.java`
|
|
105
|
-
| `.
|
|
121
|
+
| `.java` 需要受控写入 | `mark_extension(".java", "protected")` | auto 采用 preserve;仍需确认实际使用的编辑器能正常读取 |
|
|
122
|
+
| `.custom` 需要固定明文写入 | `mark_extension(".custom", "unsafe")` | auto 强制使用安全中转及明文校验 |
|
|
106
123
|
| 恢复自动分类 | `mark_extension(".java", "clear")` | 写入墓碑清除标注,恢复实时探测 |
|
|
107
124
|
|
|
108
|
-
##
|
|
125
|
+
## 写入保证
|
|
109
126
|
|
|
110
127
|
所有文本修改(write_file / edit_file)与文件复制/移动都经过统一的可回滚提交流程:
|
|
111
128
|
|
|
@@ -114,13 +131,13 @@ write_file / edit_file / copy_path / move_path 均支持 `writePolicy` 参数:
|
|
|
114
131
|
→ 同目录随机独占暂存 .mcp-stage-<uuid><ext>(外部进程中转时经安全扩展名)
|
|
115
132
|
→ fsync 刷盘 → 再次比对改前指纹(防并发改动)
|
|
116
133
|
→ 原文件 rename 为 .mcp-backup-<uuid><ext> → 暂存 rename 到位
|
|
117
|
-
→
|
|
134
|
+
→ 指纹终验(SHA256+size;自动状态策略同时检查外部读取视图)
|
|
118
135
|
→ 成功删除备份;任一步失败自动回滚,回滚失败返回 recoveryPath(备份不得删除)
|
|
119
136
|
```
|
|
120
137
|
|
|
121
138
|
- **完整载荷**:追加模式先在内存合成「原内容+新增」完整内容再走事务,纠正/重写不会丢原文与 BOM
|
|
122
139
|
- **safeWrite 失败即中止**:不再回退直写破坏原文(SAFE_WRITE_FAILED,changed=false)
|
|
123
|
-
- **跨实例锁**:使用同一 `MCP_PROFILE_DIR` 的实例经 `.mcp-file-locks/` 登记整组路径,同路径及祖先/后代相互排斥,无关路径可并行(等待 5 秒超时 FILE_BUSY);`expectedHash` 可检测其他编辑器造成的版本变化(CONFLICT
|
|
140
|
+
- **跨实例锁**:使用同一 `MCP_PROFILE_DIR` 的实例经 `.mcp-file-locks/` 登记整组路径,同路径及祖先/后代相互排斥,无关路径可并行(等待 5 秒超时 FILE_BUSY);`expectedHash` 可检测其他编辑器造成的版本变化(CONFLICT)。升级时应重启全部 MCP 实例,避免旧进程继续执行旧的锁和写入策略
|
|
124
141
|
- **清理状态**:提交或锁清理失败会附带 `cleanupErrors`;主操作已成功时保留成功结果和真实 `changed`,错误时保留原错误及 `recoveryPath`。不要因清理告警重复追加内容
|
|
125
142
|
- **递归删除**:逐项执行,失败时返回 `changed`、`partial`(最多100项)、`removedCount`、`partialTruncated`、`failedPath`;受一万项和128层预算限制,不是整树事务
|
|
126
143
|
- **断电/强杀残留**:两次 rename 之间的极端崩溃可能留下 `.mcp-backup-*` 与 `.mcp-stage-*`,先核对内容与时间再人工恢复,禁止直接批量清理
|
|
@@ -128,7 +145,8 @@ write_file / edit_file / copy_path / move_path 均支持 `writePolicy` 参数:
|
|
|
128
145
|
- **移动失败的源变化**:`sourceRetained` 表示本次是否尚未删除任何源文件或源目录;`removedSourceCount`、`removedSourcePaths`(最多100项)、`removedSourcePathsTruncated` 报告已经删除的源项。失败仍保留已完成子项的 `cleanupErrors`
|
|
129
146
|
- **策略探测互斥**:`inspect_write_strategy` 持有目标父目录锁,创建和清理探测样本完成后才允许该目录被复制、移动或删除
|
|
130
147
|
- **glob总预算**:生产搜索与查找使用异步匹配,合并重复模式和分支;一次请求的展开后累计长度最多10万,编译和全部路径匹配共用5000万工作单元预算。约每16384工作单元让出事件循环,检查取消与截止时间;超过限额返回 `GLOB_LIMIT`,超时返回 `TIMEOUT`
|
|
131
|
-
- **diskState 三态**:`plaintext
|
|
148
|
+
- **diskState 三态**:`plaintext`(Node 与外部视图均匹配预期明文)、`preserved`(Node 内容通过受控写入校验,不保证其他编辑器可解密)、`unknown`(仅内容校验,不能声称已验证明文)。自动保留原保护状态还返回 `protectionObserved:true`,表示外部视图仍不同,并非密钥或加密完整性认证
|
|
149
|
+
- **校验边界**:如果外部读取器也被透明解密,两种视图一致仍不能证明原始磁盘未加密。上线前用真实驱动及目标编辑器验收;已经损坏或已经加密的异常文件不会因升级而自动修复
|
|
132
150
|
|
|
133
151
|
## 文件结构
|
|
134
152
|
|
|
@@ -391,23 +409,23 @@ find_files 之外的文件名查找也优先用 MCP 工具。
|
|
|
391
409
|
- 建目录/查信息 → create_directory / file_info
|
|
392
410
|
|
|
393
411
|
## 使用规则
|
|
394
|
-
1. 会话开始先调 check_status
|
|
412
|
+
1. 会话开始先调 check_status 确认服务版本与运行状态;它不自动证明解密正常。环境不明时调
|
|
395
413
|
encryption_profile 查看本机扩展名分类与人工策略;
|
|
396
414
|
写入前可用 inspect_write_strategy 预览目标路径的写入策略。
|
|
397
|
-
2.
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
415
|
+
2. write_file/edit_file/copy_path/move_path 默认 writePolicy=auto:
|
|
416
|
+
原明文文件保持明文校验,原受保护文件保持受控写入,新文件默认要求明文。
|
|
417
|
+
明确需要受控写入用 preserve,明确需要明文用 plaintext。
|
|
418
|
+
preserved 不证明 IDEA 等其他程序能解密;Windows 缺少外部读取器时 auto 拒绝写入。
|
|
401
419
|
3. 需要长期保持加密/明文的扩展名:用 mark_extension(".java", "protected")
|
|
402
|
-
或 mark_extension(".
|
|
420
|
+
或 mark_extension(".custom", "unsafe") 标注一次永久生效(独立存储,
|
|
403
421
|
重启与刷新探测不丢失);mark_extension(".ext", "clear") 恢复自动。
|
|
404
422
|
4. edit_file 前必须先 read_file 拿原文,oldString 从原文原样复制
|
|
405
423
|
(含空格与缩进;CRLF/LF 换行差异会自动兼容,无需手工处理);
|
|
406
424
|
重要修改先 dryRun=true 预览;可用 expectedHash 防止覆盖他人改动。
|
|
407
425
|
5. 路径一律使用绝对路径。
|
|
408
426
|
6. 写工具返回 isError 时先看 structuredContent 的 code:
|
|
409
|
-
SAFE_WRITE_FAILED/DISK_MISMATCH
|
|
410
|
-
|
|
427
|
+
SAFE_WRITE_FAILED/DISK_MISMATCH 表示写入校验失败;检查 changed 和恢复信息,
|
|
428
|
+
不要改用普通 shell 覆盖。出现 recoveryPath 说明回滚
|
|
411
429
|
也失败,保留该备份并报告用户,禁止盲目重试或删除备份。
|
|
412
430
|
7. edit_file 匹配失败时,按返回的「可能相关的行」诊断修正 oldString,
|
|
413
431
|
不要盲目重试。
|
|
@@ -499,15 +517,15 @@ npm test # 仓库内回归/协议/适配测试(Node 内置 test runner
|
|
|
499
517
|
npm audit --omit=dev
|
|
500
518
|
```
|
|
501
519
|
|
|
502
|
-
测试位于 `test
|
|
520
|
+
测试位于 `test/`,包含基础回归、真实 stdio、Windows 适配器、边界/并发修复以及 `write-policy.test.js` 通用写入状态回归;模拟读取视图与真实驱动验收分开,不触碰真实 profile。可通过 `MCP_TEST_ROOT` 指定独立测试目录。CI 配置覆盖 Windows/Linux × Node 20/22/24。运行依赖:MCP SDK 1.30.0、Zod 4.4.3,间接依赖 fast-uri/qs/Hono 通过 overrides 限定修复版本。
|
|
521
|
+
|
|
522
|
+
本次自动化验收计划见 `test/write-policy-plan.md`。真实加密电脑需分别检查原始明文样本及原受保护样本;先确认基线,再用 auto 编辑并从磁盘重新打开,核对实际编辑器结果。模拟测试通过不等于真实驱动全部兼容。
|
|
503
523
|
|
|
504
524
|
## 故障排查
|
|
505
525
|
|
|
506
526
|
### 读取到的仍是密文
|
|
507
527
|
|
|
508
|
-
|
|
509
|
-
- 联系加密软件管理员,将 `node.exe` 加入白名单
|
|
510
|
-
- 确认加密软件的受信任进程列表中包含 Node.js
|
|
528
|
+
可能是 Node.js 未被授权解密,也可能是该文件类型、路径或原文件状态不满足解密条件。先保留原文件,确认运行入口与实际 `node.exe` 路径,再核对加密软件配置;不能只凭“程序在白名单”就认定所有文件均可解密。
|
|
511
529
|
|
|
512
530
|
### MCP Server 无法启动
|
|
513
531
|
|
|
@@ -520,17 +538,22 @@ cd mcp-read-file-server && npm ci
|
|
|
520
538
|
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' | node index.js
|
|
521
539
|
```
|
|
522
540
|
|
|
523
|
-
###
|
|
541
|
+
### MCP 返回成功,但编辑器打开显示密文
|
|
524
542
|
|
|
525
|
-
|
|
543
|
+
Node 可读不等于其他编辑器可读,不能只凭锁图标判断状态。当前版本对所有文件类型统一处理原明文状态,不需要为 SCSS 等后缀写特例。若仍出现异常:
|
|
526
544
|
|
|
527
|
-
1.
|
|
528
|
-
2.
|
|
529
|
-
3.
|
|
545
|
+
1. 用 `check_status` 确认实际服务为 2.1.0,保留异常文件和当次完整响应,不直接覆盖修复。
|
|
546
|
+
2. 查看 `strategy.basis/originalState`、`diskState/diskVerified` 和 warnings,确认是否有显式 preserve 或人工 protected 覆盖自动决策。
|
|
547
|
+
3. 使用独立副本验证所需策略,并检查外部读取器是否同样被透明解密。已经异常的受保护文件不会被 auto 自动解密;需要恢复时先核对原始备份。
|
|
530
548
|
|
|
531
549
|
### 写工具返回 SAFE_WRITE_FAILED / DISK_MISMATCH
|
|
532
550
|
|
|
533
|
-
|
|
551
|
+
safeWrite 全部组合失败返回 `SAFE_WRITE_FAILED`;最终路径校验不一致返回 `DISK_MISMATCH` 并尝试回滚。不回退未经验证的写入,是否恢复成功以 `changed/recoveryPath/rollbackError` 为准。检查目标目录权限、外部进程可用性和实际读取视图;环境已发生变化时再考虑 `refresh_profile` 重探。
|
|
552
|
+
|
|
553
|
+
### 写工具返回 DISK_UNVERIFIED / PROTECTION_MISMATCH
|
|
554
|
+
|
|
555
|
+
- `DISK_UNVERIFIED`:无法取得所需外部读取视图。Windows auto 不能据此猜测原文件状态;检查读取器可用性,不要为了通过测试盲目切到 preserve。
|
|
556
|
+
- `PROTECTION_MISMATCH`:自动保留受保护文件时,暂存或最终目标的外部视图变成了预期明文;为避免静默改变保护状态而中止或回滚。确实需要明文时应明确指定 plaintext,并用独立样本验证。
|
|
534
557
|
|
|
535
558
|
### 写工具返回 FILE_BUSY / CONFLICT
|
|
536
559
|
|
package/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: 在Node.js为加密软件白名单进程的环境中,使用文件
|
|
|
5
5
|
|
|
6
6
|
# 加密环境文件操作
|
|
7
7
|
|
|
8
|
-
使用前确认Node.js受信任,并配置read-file-server。版本1.
|
|
8
|
+
使用前确认Node.js受信任,并配置read-file-server。版本2.1.0提供18个工具,最低Node20。
|
|
9
9
|
|
|
10
10
|
## 工具选择
|
|
11
11
|
|
|
@@ -21,7 +21,7 @@ description: 在Node.js为加密软件白名单进程的环境中,使用文件
|
|
|
21
21
|
|
|
22
22
|
1. 优先读取structuredContent的ok、code、changed、data和warnings,不能只看人类文本。
|
|
23
23
|
2. isError=true时可能存在目录操作部分目标;查看changed/partial/sourceRetained。递归删除还需查看removedCount/partialTruncated/failedPath。单文件回滚失败时查看recoveryPath并保留备份。cleanupErrors只是清理诊断,成功修改后不得因清理告警重复追加。
|
|
24
|
-
3. contentVerified表示Node可见内容一致;diskVerified
|
|
24
|
+
3. contentVerified表示Node可见内容一致;diskVerified表示外部进程可见字节也与预期载荷一致,不是绕过透明解密读取原始磁盘。preserved不保证IDEA等其他程序能解密,unknown不能声称已验证明文。
|
|
25
25
|
4. check_status基础调用不探测环境。文件可读取也不能直接推断解密正常,可信expectedHash匹配才提供明确内容对照。
|
|
26
26
|
5. 文本工具只支持有效UTF8;UTF16、GBK、非法字节或NUL被拒绝时,必须先明确转换编码,不能强制按UTF8写回。
|
|
27
27
|
|
|
@@ -29,7 +29,11 @@ description: 在Node.js为加密软件白名单进程的环境中,使用文件
|
|
|
29
29
|
|
|
30
30
|
- mark_extension(category=protected)永久记录保持受控写入,等价于默认使用preserve;unsafe要求验证磁盘明文;clear可清除两者。
|
|
31
31
|
- refresh_profile只更新自动观察,不删除手工策略。
|
|
32
|
-
- writePolicy=auto
|
|
32
|
+
- writePolicy=auto在没有人工覆盖时逐文件观察原状态:原明文保持明文校验,原受保护文件保持受控写入;新文件默认要求明文,不凭探测样本的protected分类自动加密。所有扩展名及无后缀文件使用同一规则。
|
|
33
|
+
- 复制/移动覆盖已有目标参考目标原状态;新目标参考源状态。目录逐文件判断,不能拿一个后缀的探测结果代替所有文件的基线。
|
|
34
|
+
- 显式writePolicy优先于人工标注;preserve只校验Node内容并告警,不保证原明文状态或编辑器可读。plaintext要求外部明文验证,失败中止或回滚。
|
|
35
|
+
- strategy.basis说明explicit/override/target/source/new_file/unverified;originalState记录auto观察到的基线,文件状态不缓存。user_unsafe也可能只是当次显式plaintext,不代表新增永久标注。
|
|
36
|
+
- Windows auto缺少外部读取器时返回DISK_UNVERIFIED;已配置读取器但本次读取失败也中止。PROTECTION_MISMATCH表示原受保护文件将意外变为明文;不得绕过错误继续写入。
|
|
33
37
|
- 旧v2人工unsafe和自动encrypted记录无法区分;升级后需要永久强制明文的后缀应重新标注unsafe。protected会迁移。
|
|
34
38
|
- 使用绝对路径;相对路径以MCP_BASE_DIR或服务启动目录为基准。
|
|
35
39
|
|
package/lib/encryption.js
CHANGED
|
@@ -48,7 +48,7 @@ function execute(exe, args, options = {}) {
|
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/**
|
|
51
|
+
/** 流式计算外部进程可见字节指纹;普通文件读取仍可能受透明解密影响。 */
|
|
52
52
|
async function inspectDisk(proc, file, options = {}) {
|
|
53
53
|
if (!proc) return null;
|
|
54
54
|
const code = "$ErrorActionPreference='Stop';$f=[IO.File]::OpenRead(" + quote(file) + ');try{' +
|
|
@@ -235,25 +235,60 @@ function createEncryption(options = {}) {
|
|
|
235
235
|
return fresh;
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
/**
|
|
239
|
-
async function
|
|
238
|
+
/** 比较同一文件的Node与外部读取视图,并拒绝读取期间发生的内容变化。 */
|
|
239
|
+
async function observe(file, expected, profile, context) {
|
|
240
|
+
const external = await reader(profile.byteReader, file, context);
|
|
241
|
+
checkBudget(context.signal, context.deadline);
|
|
242
|
+
if (!external) throw fault('DISK_UNVERIFIED', '无法确认写入前的文件状态,未提交修改');
|
|
243
|
+
const current = await fingerprint(file, context);
|
|
244
|
+
if (current.hash !== expected.hash || current.size !== expected.size) throw fault('CONFLICT', '文件在状态检查期间已改变,未提交修改');
|
|
245
|
+
return external.hash === expected.hash && external.size === expected.size ? 'plaintext' : 'protected';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 显式策略优先;auto按原文件状态决策,新复制目标参考源文件,新建文件要求明文校验。 */
|
|
249
|
+
async function strategy(file, requested = 'auto', context = {}, reference = {}) {
|
|
240
250
|
const ext = path.extname(file).toLowerCase();
|
|
241
251
|
const override = await getOverride(ext);
|
|
242
|
-
|
|
243
|
-
if (requested === '
|
|
252
|
+
const basis = requested === 'auto' ? 'override' : 'explicit';
|
|
253
|
+
if (requested === 'preserve' || (requested === 'auto' && override === 'protected')) return { mode: 'preserve', category: 'user_protected', extension: ext, basis };
|
|
254
|
+
if (requested === 'plaintext' || (requested === 'auto' && override === 'unsafe')) return { mode: 'plaintext', category: 'user_unsafe', extension: ext, basis };
|
|
244
255
|
const p = await getProfile(context);
|
|
245
256
|
const dir = await fs.realpath(path.dirname(file));
|
|
257
|
+
if (!p.byteReader) {
|
|
258
|
+
// Windows透明加密环境中,缺少外部视图时不能猜测原文件状态并继续写入。
|
|
259
|
+
if (process.platform === 'win32' || p.unsafeExtensions.includes(ext) || p.encryptedExtensions.includes(ext) || p.protectedExtensions.includes(ext)) {
|
|
260
|
+
throw fault('DISK_UNVERIFIED', 'auto需要可用的外部读取器判断文件状态;请检查环境或明确指定writePolicy');
|
|
261
|
+
}
|
|
262
|
+
return { mode: 'auto', category: 'unknown', extension: ext, scope: dir, basis: 'unverified', originalState: 'unknown' };
|
|
263
|
+
}
|
|
264
|
+
let original = reference.target;
|
|
265
|
+
if (original === undefined) {
|
|
266
|
+
try {
|
|
267
|
+
if (!(await fs.stat(file)).isFile()) throw fault('NOT_FILE', '目标不是普通文件');
|
|
268
|
+
original = await fingerprint(file, context);
|
|
269
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; original = null; }
|
|
270
|
+
}
|
|
271
|
+
const source = !original && reference.source;
|
|
272
|
+
const expected = original || source?.fingerprint;
|
|
273
|
+
if (expected) {
|
|
274
|
+
const originalState = await observe(source ? source.file : file, expected, p, context);
|
|
275
|
+
return { mode: originalState === 'plaintext' ? 'plaintext' : 'preserve',
|
|
276
|
+
verifyMode: originalState === 'plaintext' ? 'plaintext' : 'protected',
|
|
277
|
+
allowDirect: true, category: originalState === 'plaintext' ? 'safe' : 'protected',
|
|
278
|
+
extension: ext, scope: dir, basis: source ? 'source' : 'target', originalState };
|
|
279
|
+
}
|
|
246
280
|
const key = dir + '|' + ext;
|
|
247
281
|
let category = p.scopes[key];
|
|
248
282
|
if (!category) {
|
|
249
|
-
category = await classify(ext
|
|
283
|
+
category = await classify(ext, dir, p, context);
|
|
250
284
|
if (category === 'unknown' && (p.unsafeExtensions.includes(ext) || p.encryptedExtensions.includes(ext))) category = 'unsafe';
|
|
251
285
|
p.scopes[key] = category;
|
|
252
286
|
}
|
|
253
|
-
|
|
287
|
+
// 新建样本中的Node可读不代表IDEA等其他程序也可读,不能据此自动选择preserve。
|
|
288
|
+
return { mode: 'plaintext', allowDirect: category === 'safe', category, extension: ext, scope: dir, basis: 'new_file', originalState: 'missing' };
|
|
254
289
|
}
|
|
255
290
|
|
|
256
|
-
/**
|
|
291
|
+
/** 校验进程可见内容;自动保留受保护状态时还要求外部视图保持不同,不能证明其他编辑器可解密。 */
|
|
257
292
|
async function verify(file, expected, mode, context = {}) {
|
|
258
293
|
const own = await fingerprint(file, context);
|
|
259
294
|
if (own.hash !== expected.hash || own.size !== expected.size) throw fault('CONTENT_MISMATCH', '写入内容校验不一致');
|
|
@@ -261,19 +296,25 @@ function createEncryption(options = {}) {
|
|
|
261
296
|
const p = await getProfile(context);
|
|
262
297
|
const raw = await reader(p.byteReader, file, context);
|
|
263
298
|
if (!raw) {
|
|
264
|
-
if (mode === 'plaintext' || p.byteReader) throw fault('DISK_UNVERIFIED', '
|
|
299
|
+
if (mode === 'plaintext' || mode === 'protected' || p.byteReader) throw fault('DISK_UNVERIFIED', '无法验证外部读取视图,保留原文件');
|
|
265
300
|
return { contentVerified: true, diskState: 'unknown', diskVerified: false };
|
|
266
301
|
}
|
|
302
|
+
if (mode === 'protected') {
|
|
303
|
+
if (raw.hash === expected.hash && raw.size === expected.size) throw fault('PROTECTION_MISMATCH', '原受保护文件的外部读取视图变为明文,已拒绝提交');
|
|
304
|
+
return { contentVerified: true, diskState: 'preserved', diskVerified: false, protectionObserved: true };
|
|
305
|
+
}
|
|
267
306
|
if (raw.hash !== expected.hash || raw.size !== expected.size) throw fault('DISK_MISMATCH', '磁盘字节与预期明文不一致');
|
|
268
307
|
return { contentVerified: true, diskState: 'plaintext', diskVerified: true };
|
|
269
308
|
}
|
|
270
309
|
|
|
271
310
|
/** 为目标生成可校验的暂存文件;所有候选失败即中止,禁止回退破坏原文。 */
|
|
272
311
|
async function prepare(stage, write, expected, decision, context = {}) {
|
|
273
|
-
if (decision.mode !== 'plaintext') {
|
|
312
|
+
if (decision.mode !== 'plaintext' || decision.allowDirect) {
|
|
274
313
|
await write(stage);
|
|
275
|
-
try { return await verify(stage, expected, decision.mode, context); }
|
|
276
|
-
catch (error) {
|
|
314
|
+
try { return await verify(stage, expected, decision.verifyMode || decision.mode, context); }
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (decision.mode === 'preserve' || !['DISK_MISMATCH', 'CONTENT_MISMATCH'].includes(error.code)) throw error;
|
|
317
|
+
}
|
|
277
318
|
await fs.rm(stage, { force: true });
|
|
278
319
|
}
|
|
279
320
|
const p = await getProfile(context);
|
package/lib/files.js
CHANGED
|
@@ -67,17 +67,25 @@ function createFiles(encryption, options = {}) {
|
|
|
67
67
|
if (current?.hash !== before?.hash) throw fault('CONFLICT', '文件在操作期间已改变,未覆盖新内容');
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** 将校验范围明确告知调用者,Node可读不能代替其他编辑器的解密验收。 */
|
|
71
|
+
function verificationWarnings(verified) {
|
|
72
|
+
if (verified.diskState === 'unknown') return ['内容已校验,但没有外部读取器确认文件存储状态'];
|
|
73
|
+
if (verified.diskState === 'preserved') return ['仅确认Node可见内容;preserved不保证IDEA等其他程序能够解密'];
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
|
|
70
77
|
/** 暂存、校验、备份、提交、再校验;异常时恢复原目标或保留可恢复备份。 */
|
|
71
78
|
async function commit(file, writer, expected, options = {}) {
|
|
72
79
|
const before = await previous(file, options);
|
|
73
80
|
if (options.expectedHash !== undefined && options.expectedHash !== (before?.hash ?? null)) throw fault('CONFLICT', 'expectedHash 与当前文件不一致');
|
|
74
81
|
if (before && options.overwrite === false) throw fault('ALREADY_EXISTS', '目标已存在,overwrite=false');
|
|
75
82
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
76
|
-
const decision = await encryption.strategy(file, options.writePolicy, options);
|
|
83
|
+
const decision = await encryption.strategy(file, options.writePolicy, options, { target: before, source: options.sourceSnapshot });
|
|
84
|
+
const verifyMode = decision.verifyMode || decision.mode;
|
|
77
85
|
if (before?.hash === expected.hash) {
|
|
78
86
|
try {
|
|
79
|
-
const verified = await encryption.verify(file, expected,
|
|
80
|
-
return { changed: false, hash: expected.hash, ...verified };
|
|
87
|
+
const verified = await encryption.verify(file, expected, verifyMode, options);
|
|
88
|
+
return { changed: false, hash: expected.hash, ...verified, strategy: decision, warnings: verificationWarnings(verified) };
|
|
81
89
|
} catch (error) { if (error.code !== 'DISK_MISMATCH') throw error; }
|
|
82
90
|
}
|
|
83
91
|
const suffix = path.extname(file);
|
|
@@ -100,9 +108,9 @@ function createFiles(encryption, options = {}) {
|
|
|
100
108
|
if (before) { await fs.rename(file, backup); backedUp = true; }
|
|
101
109
|
await fs.rename(stage, file);
|
|
102
110
|
installed = true;
|
|
103
|
-
const verified = await encryption.verify(file, expected, prepared.via ? 'plaintext' :
|
|
111
|
+
const verified = await encryption.verify(file, expected, prepared.via ? 'plaintext' : verifyMode, options);
|
|
104
112
|
if (backedUp) { await fs.rm(backup); backedUp = false; }
|
|
105
|
-
outcome = { changed: true, hash: expected.hash, size: expected.size, ...verified, strategy: decision, ...(prepared.via ? { via: prepared.via } : {}), warnings: verified
|
|
113
|
+
outcome = { changed: true, hash: expected.hash, size: expected.size, ...verified, strategy: decision, ...(prepared.via ? { via: prepared.via } : {}), warnings: verificationWarnings(verified) };
|
|
106
114
|
} catch (error) {
|
|
107
115
|
// 回滚不受已取消的请求预算影响,优先恢复原始文件。
|
|
108
116
|
try {
|
|
@@ -135,7 +143,7 @@ function createFiles(encryption, options = {}) {
|
|
|
135
143
|
const targetStat = await statMaybe(destination);
|
|
136
144
|
if (pathKey(source) === pathKey(destination) || (targetStat && sourceStat.dev === targetStat.dev && sourceStat.ino === targetStat.ino)) return { changed: false, sameFile: true };
|
|
137
145
|
const expected = await fingerprint(source, settings);
|
|
138
|
-
const result = await commit(destination, target => pipeline(nativeFs.createReadStream(source), nativeFs.createWriteStream(target, { flags: 'wx', mode: sourceStat.mode }), { signal: settings.signal }), expected, settings);
|
|
146
|
+
const result = await commit(destination, target => pipeline(nativeFs.createReadStream(source), nativeFs.createWriteStream(target, { flags: 'wx', mode: sourceStat.mode }), { signal: settings.signal }), expected, { ...settings, sourceSnapshot: { file: source, fingerprint: expected } });
|
|
139
147
|
return { ...result, sourceHash: expected.hash };
|
|
140
148
|
}
|
|
141
149
|
|
package/lib/server.js
CHANGED
|
@@ -310,7 +310,7 @@ function createServer(options = {}) {
|
|
|
310
310
|
const data = await encryption.mark(args.extension, args.category);
|
|
311
311
|
return result('策略已更新: ' + data.extension + ' = ' + data.category, { ...data, changed: true });
|
|
312
312
|
});
|
|
313
|
-
register('inspect_write_strategy', '
|
|
313
|
+
register('inspect_write_strategy', '检查写入策略:已有文件比较Node与外部读取视图,新建路径可在父目录创建探测样本;不修改目标文件。', { path: pathSchema, writePolicy: policySchema, timeoutMs: timeoutSchema }, false, async (args, context) => {
|
|
314
314
|
const file = await files.resolve(args.path);
|
|
315
315
|
return files.locked([path.dirname(file)], async () => {
|
|
316
316
|
const data = await encryption.strategy(file, args.writePolicy, context);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-read-file-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "加密环境文件操作 MCP 工具。当 Node.js 是加密软件白名单进程时,通过 fs 模块自动解密读写文件明文,替代 AI Agent 内置文件工具,适用于任何支持 MCP 协议的 Agent。",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "index.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
18
|
"start": "node index.js",
|
|
19
|
-
"test": "node --test --test-reporter=tap --test-concurrency=1 test/regression.test.js test/protocol.test.js test/adapters.test.js test/review-fixes.test.js test/resilience.test.js test/followup-fixes.test.js",
|
|
19
|
+
"test": "node --test --test-reporter=tap --test-concurrency=1 test/regression.test.js test/protocol.test.js test/adapters.test.js test/review-fixes.test.js test/resilience.test.js test/followup-fixes.test.js test/write-policy.test.js",
|
|
20
20
|
"check": "node scripts/check.js"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|