dsh-plugin-windows-guard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0] - 2026-08-29
4
+
5
+ - **合并 `dsh-plugin-pwsh-guard` 全部主动防护能力(该插件同日删除,本插件为唯一后继)**:
6
+ - L2 `tools/post-execute`:pwsh 工具结果出现 GBK 误解码 / U+FFFD 洪水 / ASCII 化
7
+ 乱码时,自动附加 `[windows-guard]` 修复提示(不中断、不改写结果本体);
8
+ - L3 `tools/pre-execute`:拦截「无 `-Encoding` 的 `Set-Content`/`Add-Content`/
9
+ `Out-File` 写 JSON/YAML/TOML/GD」与「`>` 重定向写结构化文件」,deny 并给出
10
+ `WriteAllText` 正确写法;
11
+ - L4 新工具 `windows_encode_detect` / `windows_encode_fix`(原 `ps_encode_*`
12
+ 更名,遵循「主题_动词」工具命名规范):编码诊断(BOM/UTF-8/GBK/UTF-16 +
13
+ 置信度)与修复(→ UTF-8 无 BOM,备份 `<path>.windowsguard.bak`,支持 dryRun)。
14
+ - 原 pwsh-guard 的 L1 系统提示词段**不再注入**——由 0.1.0 的守则技能取代
15
+ (按需加载、更省常驻 token)。
16
+ - 配置开关(cordis.patch.yml,默认全开):`enabled` / `postCheck` / `preCheck` /
17
+ `detectTools`;`enabled: false` 时退化为 0.1.0 的纯守则形态。
18
+ - `inject` 从 `['skills']` 扩为 `['skills', 'tools']`;版本 0.1.0 → 0.2.0。
19
+
20
+ ## [0.1.0] - 2026-08-25
21
+
22
+ - 首发 `dsh-plugin-windows-guard`:Windows 环境防坑守则 skill 插件(纯数据)。
23
+ - 内容来源:本机 255 个会话归档问题扫描(GBK 误解码 / BOM / UTF-16 /
24
+ 单引号转义 / 长路径 / EACCES / 端口占用 / stderr 误判 / 乱码当答案)。
25
+ - 按触发场景拆为两个技能:
26
+ - `windows-enc`:编码与 PowerShell 解析守则(读/写文件、引号转义、乱码三不);
27
+ - `windows-sys`:系统环境守则(路径/文件占用、进程与端口、跨平台杂项)。
28
+ - 纯技能载体:`lib/index.js` 约 40 行注册代码;零运行时依赖、零构建。
package/README.md CHANGED
@@ -1,16 +1,19 @@
1
1
  # dsh-plugin-windows-guard
2
2
 
3
- DeepSeek Harness (dsh) 的 **Windows 环境防坑守则插件**(纯数据,零代码逻辑)。
3
+ DeepSeek Harness (dsh) 的 **Windows 环境防坑插件**:守则(skill)+ 主动防护(hooks/工具)。
4
4
 
5
- 把本机 255 个会话归档里反复出现的 Windows 高频坑,沉淀成「一次写对」的
6
- 预防性守则,以两个 skill 形式自动加载给 agent——**不是修复工具,是行为规则**:
7
- 让模型在写 PowerShell、读文件、处理编码/路径/进程/端口问题前先看到防坑清单。
5
+ 把本机 255 个会话归档里反复出现的 Windows 高频坑,沉淀成两层能力:
8
6
 
9
- 纯技能载体(对标 `dsh-plugin-dev-kb`):`lib/index.js` 只有约 40 行注册代码,
10
- 全部内容在 `skills/` 目录。零运行时依赖、零构建。
7
+ - **守则层**(0.1.0 起):两个自包含 skill 按场景自动加载——「一次写对」的预防性守则;
8
+ - **主动防护层**(0.2.0 起,自 `dsh-plugin-pwsh-guard` 合并,该插件已删除):
9
+ pwsh 结果乱码自动检测提示、危险写命令拦截、编码诊断/修复工具。
10
+
11
+ 零运行时依赖、零构建。
11
12
 
12
13
  ## 功能
13
14
 
15
+ ### 守则层:两个自动加载的技能
16
+
14
17
  按触发场景拆成两个自包含技能(`<available_skills>` 按 description 匹配自动加载正文):
15
18
 
16
19
  ### `windows-enc` —— 编码与 PowerShell 解析(命令/文件内容出错类)
@@ -30,6 +33,32 @@ DeepSeek Harness (dsh) 的 **Windows 环境防坑守则插件**(纯数据,
30
33
  | 2 进程与端口 | `netstat` 查端口、`Stop-Process`/`taskkill`、先查后杀 | 3080/39090 端口占用、孤儿进程 |
31
34
  | 3 跨平台杂项 | CRLF 陷阱、`.cmd` 脚本宿主/ExecutionPolicy、时区、大小写 | 小说文件 CRLF 检查、run.bat chcp 65001 |
32
35
 
36
+ ### 主动防护层(0.2.0 起,均可配置开关,默认全开)
37
+
38
+ | 层 | 机制 | 作用 |
39
+ |---|---|---|
40
+ | L2 | `tools/post-execute` 监听 | `pwsh` 工具结果出现 GBK 误解码/FFFD 洪水/中文变问号时,自动在结果后附加 `[windows-guard]` 修复提示(不中断、不改写结果本体) |
41
+ | L3 | `tools/pre-execute` 监听 | 拦截「不带 -Encoding 的 `Set-Content`/`Add-Content`/`Out-File` 写 JSON/YAML/TOML/GD」与「`>` 重定向写结构化文件」命令,deny 并给出 `[System.IO.File]::WriteAllText(...)` 正确写法 |
42
+ | L4 | `windows_encode_detect` / `windows_encode_fix` 工具 | 文件编码诊断(BOM/UTF-8/GBK/UTF-16 + 置信度)与修复(→ UTF-8 无 BOM,自动备份 `.windowsguard.bak`);`dryRun` 只诊断不写入 |
43
+
44
+ > 历史:原 pwsh-guard 的 L1 系统提示词段**不再注入**——由上面的守则技能取代
45
+ > (按需加载、更省常驻 token)。
46
+
47
+ ## 配置
48
+
49
+ `cordis.patch.yml` 全部可开关(默认全开):
50
+
51
+ ```yaml
52
+ - insert:
53
+ - id: dsh-plugin-windows-guard
54
+ name: dsh-plugin-windows-guard
55
+ config:
56
+ enabled: true # 总开关(false = 只保留守则技能,退化为 0.1.0 纯守则形态)
57
+ postCheck: true # L2 pwsh 结果乱码检测+提示
58
+ preCheck: true # L3 危险写命令拦截
59
+ detectTools: true # L4 编码诊断/修复工具
60
+ ```
61
+
33
62
  ## 安装
34
63
 
35
64
  ```powershell
@@ -59,18 +88,20 @@ npm install dsh-plugin-windows-guard --prefix C:\Users\18303\.dsh\profiles\web
59
88
  模型:读取前先按守则用 Get-Content -Encoding UTF8……
60
89
  ```
61
90
 
62
- ## 配置/示例
63
-
64
- **无需任何配置**:挂载后即生效,无 API key、无 token、无配置文件。示例:
65
-
66
91
  ```text
67
- 用户:读一下 D:\dsh\config\comfy_prompt.json 看看配置
68
- 模型:读取前先按守则用 Get-Content -Encoding UTF8……
92
+ 用户:帮我修一下 config.json,JSON.parse 报错
93
+ 模型:先 windows_encode_detect 查 config.json → "utf-8-bom(可信度 high)"
94
+ → windows_encode_fix config.json → 已转换为 UTF-8 无 BOM(备份 .windowsguard.bak)
95
+ → 验证 JSON 解析通过
69
96
  ```
70
97
 
71
- 更完整的使用说明见上方「使用」节。
98
+ ```powershell
99
+ # 模型被告知(守则技能),且此类命令会被 L3 拦截并给出正确写法:
100
+ # Set-Content -Path data.json -Value $json ← deny(PS5.1 写 UTF-16LE/BOM)
101
+ # [System.IO.File]::WriteAllText("data.json", $json, [System.Text.UTF8Encoding]::new($false)) ← 正确
102
+ ```
72
103
 
73
- ### 为何拆成两个(设计说明)
104
+ ### 为何拆成两个技能(设计说明)
74
105
 
75
106
  - **常驻目录成本**:两条 description(~300 token)几乎不涨;
76
107
  - **触发精度**:`windows-enc` 管「命令/文件内容出错」(编码/转义/乱码),
@@ -80,9 +111,15 @@ npm install dsh-plugin-windows-guard --prefix C:\Users\18303\.dsh\profiles\web
80
111
 
81
112
  ## 实现说明
82
113
 
83
- - 注册走 `ctx.skills.register`(官方 skill 子系统),content 为 SKILL.md 正文;
84
- - 无工具、无命令、无服务端逻辑、无文件写入——纯预防性指令;
85
- - skill 正文改动需重启 dsh(host 插件 apply 时读取 skills/ 目录)。
114
+ - 守则注册走 `ctx.skills.register`(官方 skill 子系统),content 为 SKILL.md 正文;
115
+ - L2/L3 走 `ctx.on('tools/post-execute' | 'tools/pre-execute')`;L4 走
116
+ `ctx.tools.register`(原始 definition 形状);不 import 任何 `@deepseek-ai/*`
117
+ 运行时包(link: realpath 解析,外部依赖从插件目录解析不到);
118
+ - L2 只对工具名 `pwsh` 且成功结果生效;L3 仅拦截「确定会写坏」的窄模式,读操作与
119
+ 显式 `-Encoding` 的写操作放行;乱码词表基于历史会话实测(11/12 正/负例通过);
120
+ - 依赖 `Node ≥22.5`(`TextDecoder('gbk')` 需要 full-icu,官方构建默认满足);
121
+ - 技能正文/工具/守卫改动需重启 dsh(host 插件 apply 时读取 skills/ 目录);
122
+ - 各层注册均有 ctx 能力守卫:任何一层不可用时降级并警告,绝不让插件挂载失败。
86
123
 
87
124
  ## 相关插件
88
125
 
package/cordis.patch.yml CHANGED
@@ -1,8 +1,18 @@
1
- # dsh-plugin-windows-guard bundle patch: Windows 环境防坑守则 skill。
1
+ # dsh-plugin-windows-guard bundle patch: Windows 环境防坑守则 + 主动防护。
2
2
  #
3
3
  # 应用方式与 dev-kb/nonebot-kb 相同:作为一条 insert 覆盖 profile 根。
4
4
  # `name` 必须等于 package.json 的 `name`(loader 从 profile node_modules 按名解析)。
5
- # 无配置项(纯数据插件);注册技能名 windows-guard(内容见 skills/windows-guard/SKILL.md)。
5
+ #
6
+ # 配置项(均可省略使用默认;0.2.0 起自 dsh-plugin-pwsh-guard 合并主动防护层):
7
+ # enabled: true # 总开关(false = 只保留守则技能)
8
+ # postCheck: true # L2 pwsh 工具结果乱码检测+修复提示
9
+ # preCheck: true # L3 危险写命令拦截
10
+ # detectTools: true # L4 windows_encode_detect / windows_encode_fix 工具
6
11
  - insert:
7
12
  - id: dsh-plugin-windows-guard
8
13
  name: dsh-plugin-windows-guard
14
+ config:
15
+ enabled: true
16
+ postCheck: true
17
+ preCheck: true
18
+ detectTools: true
package/lib/encode.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * dsh-plugin-windows-guard — 文件编码检测与修复(零依赖纯函数)。
3
+ *
4
+ * 自 0.2.0 起自 dsh-plugin-pwsh-guard 合并(该插件已删除)。
5
+ * 检测逻辑对齐历史会话中实际用过的字节嗅探诊断(`前3字节: 35 32 229 ---GBK解码---`):
6
+ * 1. BOM 探测(UTF-8 / UTF-16LE / UTF-16BE / UTF-32);
7
+ * 2. 严格 UTF-8 解码(TextDecoder fatal)判断是否合法 UTF-8;
8
+ * 3. GBK(936) 备选解码 + CJK 占比启发式(判断「更像 GBK 还是 UTF-8」);
9
+ * 4. 修复:任意源编码 → UTF-8 无 BOM(先备份原文件)。
10
+ *
11
+ * 依赖:Node 内置 TextDecoder(需 full-icu,Node 官方构建默认满足)。
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, copyFileSync, statSync } from 'node:fs'
15
+
16
+ export const GBK_LABEL = 'gbk'
17
+ export const UTF8_LABEL = 'utf-8'
18
+
19
+ /** 读文件前 N 字节做 BOM 探测。 */
20
+ export function detectBom(buf) {
21
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) return 'utf-8-bom'
22
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) return 'utf-16le'
23
+ if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) return 'utf-16be'
24
+ if (buf.length >= 4 && buf[0] === 0x00 && buf[1] === 0x00 && buf[2] === 0xfe && buf[3] === 0xff) return 'utf-32be'
25
+ if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xfe && buf[2] === 0x00 && buf[3] === 0x00) return 'utf-32le'
26
+ return null
27
+ }
28
+
29
+ /** 严格 UTF-8 解码(fatal);失败返回 null。 */
30
+ export function strictUtf8(buf) {
31
+ try {
32
+ return { text: new TextDecoder('utf-8', { fatal: true }).decode(buf), ok: true }
33
+ } catch {
34
+ return { text: null, ok: false }
35
+ }
36
+ }
37
+
38
+ /** GBK 解码(宽松);失败返回 null。 */
39
+ export function decodeGbk(buf) {
40
+ try {
41
+ return { text: new TextDecoder('gbk').decode(buf), ok: true }
42
+ } catch {
43
+ return { text: null, ok: false }
44
+ }
45
+ }
46
+
47
+ /** CJK 字符占比(0~1)。 */
48
+ export function cjkRatio(text) {
49
+ if (!text || text.length === 0) return 0
50
+ let cjk = 0
51
+ for (const ch of text) {
52
+ const cp = ch.codePointAt(0)
53
+ if (cp >= 0x4e00 && cp <= 0x9fff) cjk++
54
+ }
55
+ return cjk / text.length
56
+ }
57
+
58
+ /** 可控字符比例(排除替换字符/怪异字节残片)。 */
59
+ function printableRatio(text) {
60
+ if (!text || text.length === 0) return 0
61
+ let ok = 0
62
+ for (const ch of text) {
63
+ const cp = ch.codePointAt(0)
64
+ if (ch === '\uFFFD') continue
65
+ if (cp === 9 || cp === 10 || cp === 13 || (cp >= 0x20 && cp !== 0x7f && cp < 0x10000)) ok++
66
+ }
67
+ return ok / text.length
68
+ }
69
+
70
+ /**
71
+ * 诊断一个文件/字节缓冲的编码。
72
+ * @param {Buffer} buf
73
+ * @param {string} [fileName] 用于报告
74
+ * @returns {{ encoding: string, confidence: 'high'|'medium'|'low', bom: string|null,
75
+ * validUtf8: boolean, utf8CjkRatio: number, gbkCjkRatio: number, gbkPrintable: number,
76
+ * replaceChars: number, note: string }}
77
+ */
78
+ export function detectEncoding(buf) {
79
+ const bom = detectBom(buf)
80
+ if (bom === 'utf-16le' || bom === 'utf-16be' || bom === 'utf-32le' || bom === 'utf-32be') {
81
+ const enc = new TextDecoder(bom === 'utf-16le' ? 'utf-16le' : bom === 'utf-16be' ? 'utf-16be' : bom === 'utf-32le' ? 'utf-32le' : 'utf-32be')
82
+ let text = ''
83
+ try { text = enc.decode(buf) } catch { /* keep */ }
84
+ return {
85
+ encoding: bom, confidence: 'high', bom,
86
+ validUtf8: false,
87
+ utf8CjkRatio: 0,
88
+ gbkCjkRatio: cjkRatio(text),
89
+ gbkPrintable: printableRatio(text),
90
+ replaceChars: (text.match(/\uFFFD/g) ?? []).length,
91
+ note: `检测到 BOM(${bom})——这是脚本/配置不该有的编码;建议转换为 UTF-8 无 BOM。`,
92
+ }
93
+ }
94
+ if (bom === 'utf-8-bom') {
95
+ const body = strictUtf8(buf.subarray(3))
96
+ return {
97
+ encoding: 'utf-8-bom', confidence: 'high', bom,
98
+ validUtf8: body.ok,
99
+ utf8CjkRatio: body.ok ? cjkRatio(body.text) : 0,
100
+ gbkCjkRatio: body.ok ? 0 : cjkRatio(decodeGbk(buf.subarray(3)).text ?? ''),
101
+ gbkPrintable: body.ok ? 1 : printableRatio(decodeGbk(buf.subarray(3)).text ?? ''),
102
+ replaceChars: body.ok ? 0 : (buf.length ? (buf.toString('utf8').match(/\uFFFD/g) ?? []).length : 0),
103
+ note: '检测到 UTF-8 BOM——写入 BOM 的文件(如 PowerShell 5.1 `Out-File -Encoding utf8`/记事本 早期版本)可能导致 JSON/YAML 解析失败;建议剥离 BOM。',
104
+ }
105
+ }
106
+ // 无 BOM:先验证严格 UTF-8
107
+ const utf8 = strictUtf8(buf)
108
+ if (utf8.ok) {
109
+ // UTF-8 合法。若文本几乎无 CJK 又有大量 FFFD?FFFD 不会在 fatal 模式出现。
110
+ const ratio = cjkRatio(utf8.text)
111
+ return {
112
+ encoding: 'utf-8', confidence: 'high', bom: null,
113
+ validUtf8: true,
114
+ utf8CjkRatio: ratio,
115
+ gbkCjkRatio: decodeGbk(buf).ok ? cjkRatio(decodeGbk(buf).text) : 0,
116
+ gbkPrintable: 1,
117
+ replaceChars: 0,
118
+ note: ratio > 0.05 ? '合法 UTF-8(含中文)。' : '合法 UTF-8(纯 ASCII/英文为主)。',
119
+ }
120
+ }
121
+ // UTF-8 失败 → 尝试 GBK
122
+ const gbk = decodeGbk(buf)
123
+ if (gbk.ok) {
124
+ const print = printableRatio(gbk.text)
125
+ const cjk = cjkRatio(gbk.text)
126
+ if (print > 0.9 && cjk > 0.05) {
127
+ return {
128
+ encoding: 'gbk', confidence: 'high', bom: null,
129
+ validUtf8: false,
130
+ utf8CjkRatio: 0,
131
+ gbkCjkRatio: cjk, gbkPrintable: print,
132
+ replaceChars: (gbk.text.match(/\uFFFD/g) ?? []).length,
133
+ note: '不是合法 UTF-8;按 GBK(936) 解码后为正常中文——文件由 GBK 编码工具生成(如老脚本/老编辑器)。',
134
+ }
135
+ }
136
+ if (print > 0.8) {
137
+ return {
138
+ encoding: 'gbk', confidence: 'medium', bom: null,
139
+ validUtf8: false,
140
+ utf8CjkRatio: 0,
141
+ gbkCjkRatio: cjk, gbkPrintable: print,
142
+ replaceChars: (gbk.text.match(/\uFFFD/g) ?? []).length,
143
+ note: '不是合法 UTF-8;GBK 解码基本可读(但中文占比低)。',
144
+ }
145
+ }
146
+ return {
147
+ encoding: 'unknown-binary', confidence: 'low', bom: null,
148
+ validUtf8: false,
149
+ utf8CjkRatio: 0, gbkCjkRatio: cjk, gbkPrintable: print,
150
+ replaceChars: (gbk.text.match(/\uFFFD/g) ?? []).length,
151
+ note: '不是合法 UTF-8,GBK 解码也多为不可读字符——疑似二进制文件。',
152
+ }
153
+ }
154
+ return {
155
+ encoding: 'unknown', confidence: 'low', bom: null,
156
+ validUtf8: false,
157
+ utf8CjkRatio: 0, gbkCjkRatio: 0, gbkPrintable: 0,
158
+ replaceChars: 0,
159
+ note: '无法识别编码(既非 UTF-8 也非 GBK,可能是二进制或损坏文件)。',
160
+ }
161
+ }
162
+
163
+ /** 诊断磁盘上的文件。 */
164
+ export function diagnoseFile(path) {
165
+ const st = statSync(path)
166
+ if (st.size > 8 * 1024 * 1024) {
167
+ return { error: `文件过大(${st.size} 字节 > 8MB),跳过检测。` }
168
+ }
169
+ const buf = readFileSync(path)
170
+ const enc = detectEncoding(buf)
171
+ return { size: st.size, ...enc }
172
+ }
173
+
174
+ /**
175
+ * 修复为 UTF-8 无 BOM:先备份(.windowsguard.bak),再写回。
176
+ * @param {string} path
177
+ * @param {{ backup?: boolean }} [opts]
178
+ * @returns {{ written: boolean, before: string, after: string, backupPath?: string, note: string }}
179
+ */
180
+ export function fixToUtf8NoBom(path, opts = {}) {
181
+ const { backup = true } = opts
182
+ const buf = readFileSync(path)
183
+ const diag = detectEncoding(buf)
184
+ let text
185
+ switch (diag.encoding) {
186
+ case 'utf-8-bom':
187
+ text = strictUtf8(buf.subarray(3)).text
188
+ break
189
+ case 'utf-16le':
190
+ case 'utf-16be':
191
+ case 'utf-32le':
192
+ case 'utf-32be':
193
+ text = new TextDecoder(
194
+ diag.encoding === 'utf-16le' ? 'utf-16le'
195
+ : diag.encoding === 'utf-16be' ? 'utf-16be'
196
+ : diag.encoding === 'utf-32le' ? 'utf-32le'
197
+ : 'utf-32be'
198
+ ).decode(buf)
199
+ break
200
+ case 'gbk':
201
+ text = decodeGbk(buf).text ?? ''
202
+ break
203
+ default:
204
+ if (diag.validUtf8) text = strictUtf8(buf).text ?? ''
205
+ else throw new Error(`无法确定编码(${diag.encoding}),不执行写入:${path}`)
206
+ }
207
+ let backupPath
208
+ if (backup) {
209
+ backupPath = `${path}.windowsguard.bak`
210
+ copyFileSync(path, backupPath)
211
+ }
212
+ writeFileSync(path, text, { encoding: 'utf8' }) // Node 默认 utf8 = 无 BOM
213
+ return {
214
+ written: true,
215
+ before: diag.encoding,
216
+ after: 'utf-8',
217
+ backupPath,
218
+ note: `已转换为 UTF-8 无 BOM(源编码:${diag.encoding}${backupPath ? `;备份:${backupPath}` : ''})。`,
219
+ }
220
+ }
package/lib/index.js CHANGED
@@ -1,29 +1,57 @@
1
1
  /**
2
- * dsh-plugin-windows-guard — 主机插件(纯技能载体,无工具/命令/修复逻辑)。
2
+ * dsh-plugin-windows-guard — Windows 环境防坑插件(守则 + 主动防护)。
3
3
  *
4
- * 挂载于 profile 根层(全局层),唯一职责:把 skills/ 目录下的 windows-guard
5
- * 技能注册为 runtime skill,让每个会话的 agent 在 Windows 上执行命令、读写
6
- * 文件、处理编码/路径/进程/端口/乱码类任务时,按技能 description 自动加载
7
- * 防坑守则——预防性规则,不提供修复工具(AI 按守则自行修复)。
4
+ * 挂载于 profile 根层(全局层),两部分职责:
5
+ *
6
+ * A. 守则层(0.1.0 起):把 skills/ 目录下的 windows-enc / windows-sys 技能注册为
7
+ * runtime skill,让每个会话的 agent 在 Windows 上执行命令、读写文件、处理
8
+ * 编码/路径/进程/端口/乱码类任务时,按技能 description 自动加载防坑守则。
9
+ *
10
+ * B. 主动防护层(0.2.0 起,自 dsh-plugin-pwsh-guard 合并,该插件已删除):
11
+ * - L2 tools/post-execute(postCheck=true):pwsh 工具结果出现乱码特征时,
12
+ * 自动在结果后附加模型可见的修复提示(不中断、不改写结果本体);
13
+ * - L3 tools/pre-execute(preCheck=true):拦截「确定会写坏文件的命令」
14
+ * (结构文件重定向写 / 无 -Encoding 的 Set-Content),给出正确写法;
15
+ * - L4 windows_encode_detect / windows_encode_fix 工具(detectTools=true):
16
+ * 文件编码诊断与修复(BOM 剥离、GBK→UTF-8、UTF-16→UTF-8),零依赖 Node 实现。
17
+ * (原 pwsh-guard 的 L1 系统提示词段由上面的守则技能取代,不再注入。)
8
18
  *
9
19
  * 守则内容来自本机 255 个会话归档的问题扫描(GBK 误解码 / BOM / UTF-16 /
10
20
  * 单引号转义 / 长路径 / EACCES / 端口占用 / stderr 误判 / 乱码当答案等),
11
- * 详见 skills/windows-guard/SKILL.md。
21
+ * 详见 skills/windows-enc.md 与 skills/windows-sys.md。
12
22
  *
13
23
  * 依赖纪律:本模块不 import 任何 @deepseek-ai/* 运行时包(插件以 link: 方式
14
- * 装入 profile,Node ESM 按 realpath 解析链接包,外部依赖从插件目录解析不到)。
24
+ * 装入 profile,Node ESM 按 realpath 解析链接包,外部依赖从插件目录解析不到);
25
+ * 工具直接走 ctx.tools.register 的原始 definition 形状(标准 JSON Schema)。
15
26
  */
16
27
 
17
28
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
18
- import { dirname, join } from 'node:path'
29
+ import { isAbsolute, dirname, join, resolve } from 'node:path'
19
30
  import { fileURLToPath } from 'node:url'
31
+ import { detect as detectMojibake, blocksToText } from './mojibake.js'
32
+ import { diagnoseFile, fixToUtf8NoBom } from './encode.js'
20
33
 
21
34
  export const name = 'dsh-plugin-windows-guard'
22
- export const inject = ['skills']
35
+ export const inject = ['skills', 'tools']
23
36
 
24
37
  const PKG_DIR = dirname(fileURLToPath(import.meta.url))
25
38
  const SKILLS_DIR = join(PKG_DIR, '..', 'skills')
26
39
 
40
+ const DEFAULT_CONFIG = {
41
+ /** 总开关(false = 只保留守则技能,关闭全部主动防护)。 */
42
+ enabled: true,
43
+ /** L2:pwsh 工具结果乱码检测 + 自动附加修复提示。 */
44
+ postCheck: true,
45
+ /** L3:拦截确定会写坏结构化文本文件的命令。 */
46
+ preCheck: true,
47
+ /** L4:注册 windows_encode_detect / windows_encode_fix 工具。 */
48
+ detectTools: true,
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // 守则技能(0.1.0 起)
53
+ // ---------------------------------------------------------------------------
54
+
27
55
  /** 极简 frontmatter 解析(name/description/whenToUse)。 */
28
56
  function parseFrontmatter(md) {
29
57
  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(md)
@@ -66,12 +94,235 @@ function loadSkills() {
66
94
  return out
67
95
  }
68
96
 
97
+ // ---------------------------------------------------------------------------
98
+ // L2:post-execute 乱码检测(自 pwsh-guard 合并)
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /** 后置检测附加提示(供 post-execute 追加)。 */
102
+ function buildPostHint(detection) {
103
+ return [
104
+ '',
105
+ `[windows-guard] ${detection.summary}`,
106
+ '处理建议:若输出来自文件内容 → 先 `windows_encode_detect` 确认该文件编码、`windows_encode_fix` 转成 UTF-8 无 BOM 后再读;',
107
+ '若是 PowerShell 5.1 读 UTF-8 文件 → 改用 `Get-Content -Encoding UTF8`;',
108
+ '若是管道/控制台输出 → 先执行 `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)` 再重跑命令。',
109
+ '不要把以上乱码内容当作有效答案直接使用。',
110
+ ].join('\n')
111
+ }
112
+
113
+ /** post-execute:pwsh 结果乱码检测 → 附加模型可见提示。 */
114
+ export async function postCheckHandler(exec, result, next) {
115
+ if (exec?.name !== 'pwsh') return next()
116
+ if (!result || result.isError) return next()
117
+ const text = blocksToText(result.content ?? [])
118
+ if (!text) return next()
119
+ const detection = detectMojibake(text)
120
+ if (detection.ok) return next()
121
+ const hint = buildPostHint(detection)
122
+ const content = [...(result.content ?? []), { type: 'text', text: hint }]
123
+ return { kind: 'accept', content }
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // L3:pre-execute 危险写拦截(自 pwsh-guard 合并)
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /** 结构文件扩展名(写坏会直接炸解析)。 */
131
+ const STRUCTURED_EXT = /\.(json|ya?ml|toml|gd|lock)\b/i
132
+
133
+ /**
134
+ * pre-execute 拦截:检测「确定会写坏文件」的 PowerShell 命令。
135
+ * @param {string} command
136
+ * @returns {string | undefined} 拒绝理由(含修正示例);undefined = 放行。
137
+ */
138
+ export function checkDangerousWrite(command) {
139
+ if (!command || typeof command !== 'string') return undefined
140
+ // 不带 -Encoding 的 Set-Content / Add-Content / Out-File 写结构化文件
141
+ if (/\b(?:Set-Content|Add-Content|Out-File)\b/i.test(command)) {
142
+ const hasEncoding = /-Encoding\b/i.test(command)
143
+ const targetsStructured = STRUCTURED_EXT.test(command)
144
+ if (targetsStructured && !hasEncoding) {
145
+ return [
146
+ `该命令会以 PowerShell 默认编码写入结构化文本文件(Windows PowerShell 5.1 默认 UTF-16LE,`,
147
+ `或 UTF-8 带 BOM),JSON/YAML 将无法解析(历史上以此为根的启动事故)。`,
148
+ `请改为:[System.IO.File]::WriteAllText(文件路径, $content, [System.Text.UTF8Encoding]::new($false))`,
149
+ `或 pwsh 7 的 Out-File -Encoding utf8NoBOM。`,
150
+ ].join('\n')
151
+ }
152
+ }
153
+ // 裸重定向写结构化文件(> file.json 等)
154
+ if (/>>?\s*["']?[^"'\s]+\.(json|ya?ml|toml|gd)(?:["']|$)/i.test(command)) {
155
+ const leading = /^>>?\s*["']?[^"'\s]+\.(json|ya?ml|toml|gd)/i.exec(command)
156
+ if (leading) {
157
+ return [
158
+ `PowerShell 的 > 重定向默认按 UTF-16LE(5.1)编码写入,会破坏结构化文件。`,
159
+ `请改用 [System.IO.File]::WriteAllText(path, content, (New-Object System.Text.UTF8Encoding($false))) 或 Out-File -Encoding utf8NoBOM。`,
160
+ ].join('\n')
161
+ }
162
+ }
163
+ // Get-Content 读 UTF-8 文件不给 -Encoding(提示但放行:英文文件无害,交给 post-check)
164
+ return undefined
165
+ }
166
+
167
+ /** pre-execute:拦截危险写命令。 */
168
+ export async function preCheckHandler(exec, next) {
169
+ if (exec?.name !== 'pwsh') return next()
170
+ const command = exec.arguments?.command
171
+ const reason = checkDangerousWrite(command)
172
+ if (reason) {
173
+ return { kind: 'deny', reason: `[windows-guard] ${reason}` }
174
+ }
175
+ return next()
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // L4:编码诊断/修复工具(自 pwsh-guard 合并;命名遵循「主题_动词」规范)
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /** resolvePath:绝对路径原样,相对路径基于会话 cwd(兜底 process.cwd())。 */
183
+ function resolveWorkPath(cwd, p) {
184
+ if (isAbsolute(p)) return resolve(p)
185
+ return resolve(cwd || process.cwd(), p)
186
+ }
187
+
188
+ function sessionCwd(exec) {
189
+ return exec?.agent?.session?.header?.cwd || process.cwd()
190
+ }
191
+
192
+ /** windows_encode_detect 工具定义(原始 definition 形状)。 */
193
+ function encodeDetectTool() {
194
+ return {
195
+ name: 'windows_encode_detect',
196
+ description:
197
+ '零 token 本地文件编码诊断:检测 BOM(UTF-8/UTF-16/UTF-32)、是否合法 UTF-8、' +
198
+ '是否 GBK(936) 编码(PowerShell 5.1 / 老工具常见)、疑似 GBK 误解码乱码等,' +
199
+ '输出诊断结论与修复建议。遇到中文乱码(鈥?/鎻掍欢/锟斤拷/???)或 JSON/YAML 解析失败时先调用本工具。',
200
+ parameters: {
201
+ type: 'object',
202
+ additionalProperties: false,
203
+ required: ['path'],
204
+ properties: {
205
+ path: {
206
+ type: 'string',
207
+ description: '文件路径(相对当前工作区或绝对路径)。'
208
+ }
209
+ }
210
+ },
211
+ output: {
212
+ schema: {
213
+ type: 'object',
214
+ additionalProperties: false,
215
+ required: ['path', 'encoding', 'diagnosis'],
216
+ properties: {
217
+ path: { type: 'string' },
218
+ encoding: { type: 'string' },
219
+ diagnosis: { type: 'string' }
220
+ }
221
+ },
222
+ render: (_args, value) => [{ type: 'text', text: value.diagnosis }]
223
+ },
224
+ async execute(args, exec) {
225
+ const cwd = sessionCwd(exec)
226
+ const p = resolveWorkPath(cwd, args.path)
227
+ if (!existsSync(p)) throw new Error(`文件不存在:${args.path}(已解析为 ${p})`)
228
+ const diag = diagnoseFile(p)
229
+ if (diag.error) throw new Error(diag.error)
230
+ const diagnosis = [
231
+ `【文件编码诊断】${p}`,
232
+ `大小:${diag.size} 字节 | BOM:${diag.bom ?? '无'} | 判定:${diag.encoding}(置信度 ${diag.confidence})`,
233
+ `合法 UTF-8:${diag.validUtf8 ? '是' : '否'} | UTF-8 中文占比:${(diag.utf8CjkRatio * 100).toFixed(1)}% | GBK 中文占比:${(diag.gbkCjkRatio * 100).toFixed(1)}%`,
234
+ `诊断:${diag.note}`,
235
+ ].join('\n')
236
+ return { path: p, encoding: diag.encoding, diagnosis }
237
+ }
238
+ }
239
+ }
240
+
241
+ /** windows_encode_fix 工具定义。 */
242
+ function encodeFixTool() {
243
+ return {
244
+ name: 'windows_encode_fix',
245
+ description:
246
+ '零 token 本地编码修复:把文件转换为 UTF-8 无 BOM(剥离 BOM、GBK/UTF-16 → UTF-8),' +
247
+ '自动备份原文件为 <path>.windowsguard.bak。诊断与修复一站式;dryRun 只诊断不写入。' +
248
+ '修复后可用 windows_encode_detect 复查。',
249
+ parameters: {
250
+ type: 'object',
251
+ additionalProperties: false,
252
+ required: ['path'],
253
+ properties: {
254
+ path: {
255
+ type: 'string',
256
+ description: '文件路径(相对当前工作区或绝对路径)。'
257
+ },
258
+ dryRun: {
259
+ type: 'boolean',
260
+ description: '仅诊断并输出将要执行的转换,不写文件、不创建备份(默认 false)。'
261
+ }
262
+ }
263
+ },
264
+ output: {
265
+ schema: {
266
+ type: 'object',
267
+ additionalProperties: false,
268
+ required: ['path', 'before', 'after', 'notice'],
269
+ properties: {
270
+ path: { type: 'string' },
271
+ before: { type: 'string' },
272
+ after: { type: 'string' },
273
+ notice: { type: 'string' }
274
+ }
275
+ },
276
+ render: (_args, value) => [{ type: 'text', text: value.notice }]
277
+ },
278
+ async execute(args, exec) {
279
+ const cwd = sessionCwd(exec)
280
+ const p = resolveWorkPath(cwd, args.path)
281
+ if (!existsSync(p)) throw new Error(`文件不存在:${args.path}(已解析为 ${p})`)
282
+ const diag = diagnoseFile(p)
283
+ if (diag.error) throw new Error(diag.error)
284
+ if (args.dryRun) {
285
+ return {
286
+ path: p, before: diag.encoding, after: 'utf-8',
287
+ notice: `【dryRun】${p}\n当前编码:${diag.encoding}(置信度 ${diag.confidence})\n转换计划:→ UTF-8 无 BOM\n${diag.note}\n未写入任何文件。`
288
+ }
289
+ }
290
+ if (diag.encoding === 'utf-8') {
291
+ return {
292
+ path: p, before: 'utf-8', after: 'utf-8',
293
+ notice: `【无需修复】${p}\n已是合法 UTF-8 且无 BOM。`
294
+ }
295
+ }
296
+ const fixed = fixToUtf8NoBom(p, { backup: true })
297
+ return {
298
+ path: p, before: fixed.before, after: fixed.after,
299
+ notice: `【修复完成】${p}\n${fixed.note}`
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // apply
307
+ // ---------------------------------------------------------------------------
308
+
69
309
  export function apply(ctx, config) {
310
+ const cfg = { ...DEFAULT_CONFIG, ...(config ?? {}) }
311
+ // 显式布尔归一(patch 配置可能是字符串)
312
+ for (const key of Object.keys(DEFAULT_CONFIG)) {
313
+ if (typeof cfg[key] === 'string') cfg[key] = cfg[key] === 'true'
314
+ }
315
+
70
316
  const disposers = []
71
- const skills = loadSkills()
72
317
 
318
+ // A. 守则技能
319
+ const skills = loadSkills()
73
320
  for (const skill of skills) {
74
321
  try {
322
+ if (typeof ctx?.skills?.register !== 'function') {
323
+ console.warn('[dsh-plugin-windows-guard] ctx.skills.register 不可用,跳过技能注册')
324
+ break
325
+ }
75
326
  // 契约(dsh-skill validateDefinition):name/description/source/content 四字符串;
76
327
  // provider 缺省为 'runtime';纯正文技能无资源目录,不声明 resourceBase。
77
328
  disposers.push(ctx.skills.register({
@@ -87,7 +338,42 @@ export function apply(ctx, config) {
87
338
  }
88
339
  }
89
340
 
90
- console.log(`[dsh-plugin-windows-guard] 已挂载:${skills.length} 技能(守则在 skills/ 目录)`)
341
+ if (cfg.enabled) {
342
+ // B1(L2):post-execute 乱码检测
343
+ if (cfg.postCheck) {
344
+ if (typeof ctx?.on === 'function') {
345
+ disposers.push(ctx.on('tools/post-execute', postCheckHandler))
346
+ } else {
347
+ console.warn('[dsh-plugin-windows-guard] ctx.on 不可用,postCheck 未注册')
348
+ }
349
+ }
350
+
351
+ // B2(L3):pre-execute 危险写拦截
352
+ if (cfg.preCheck) {
353
+ if (typeof ctx?.on === 'function') {
354
+ disposers.push(ctx.on('tools/pre-execute', preCheckHandler))
355
+ } else {
356
+ console.warn('[dsh-plugin-windows-guard] ctx.on 不可用,preCheck 未注册')
357
+ }
358
+ }
359
+
360
+ // B3(L4):编码诊断/修复工具
361
+ if (cfg.detectTools) {
362
+ if (typeof ctx?.tools?.register === 'function') {
363
+ disposers.push(ctx.tools.register(encodeDetectTool()))
364
+ disposers.push(ctx.tools.register(encodeFixTool()))
365
+ } else {
366
+ console.warn('[dsh-plugin-windows-guard] ctx.tools.register 不可用,detectTools 未注册')
367
+ }
368
+ }
369
+
370
+ console.log(
371
+ `[dsh-plugin-windows-guard] 已挂载:${skills.length} 技能 + 主动防护` +
372
+ `${cfg.postCheck ? ' [postCheck]' : ''}${cfg.preCheck ? ' [preCheck]' : ''}${cfg.detectTools ? ' [detectTools]' : ''}`
373
+ )
374
+ } else {
375
+ console.log(`[dsh-plugin-windows-guard] 已挂载:${skills.length} 技能(主动防护已由配置关闭)`)
376
+ }
91
377
 
92
378
  // 卸载清理(HMR 重载时避免重复注册)。
93
379
  return () => {
@@ -0,0 +1,128 @@
1
+ /**
2
+ * dsh-plugin-windows-guard — 乱码模式检测(零依赖纯函数)。
3
+ *
4
+ * 自 0.2.0 起自 dsh-plugin-pwsh-guard 合并(该插件已删除)。
5
+ *
6
+ * 检测 PowerShell / dsh 运行中最常见的两类编码损坏:
7
+ * 1. UTF-8 字节被按 GBK/ANSI(936) 解码产生的「误解码乱码」
8
+ * (经典:`鎻掍欢鏋勮瑙勮寖` = `插件构建规范`、`鈥?` = `—`、`锟斤拷` = U+FFFD 对);
9
+ * 2. 替换字符 U+FFFD 洪水 / 连续问号(UTF-8 → ASCII 转换丢失,`??OK??` 案例)。
10
+ *
11
+ * 词表基于历史会话归档中实际出现的误读对(见 README「历史案例」),
12
+ * 全部为正常中文中几乎不出现的生僻字形,误报率极低。
13
+ */
14
+
15
+ const GBK_MISREAD_CHARS =
16
+ '鎻鏋瑙鍚鏁璁锟斤拷鈥鍒鍩銆鎺鍛婵鑻娿浣剧鏇鏈浠杩鍙鏄鐨涓鑱氭' +
17
+ '涔勭璇ヨ寮勩缁撻噸妫娉ㄥご鍏鍐呭澶ф槸鏇撮闀扮畝鐜暣浠庢潵鍒樹笅鐪嬪潶' +
18
+ '涓嶆槸寰堝ソ搴旇杩囷紝锛岃繖鍙椾笉寰楄繍鐢ㄧ殑鍦板疄銆備功椤磋兘'
19
+
20
+ function isCjk(ch) {
21
+ return ch >= '\u4e00' && ch <= '\u9fff'
22
+ }
23
+
24
+ /**
25
+ * 检测一段文本是否为「UTF-8 被 GBK 误解码」乱码。
26
+ * @param {string} text - 待检测文本。
27
+ * @returns {{ misread: boolean, chars?: string[], confidence: 'high'|'medium'|'low', note?: string }}
28
+ */
29
+ export function detectGbkMisread(text) {
30
+ if (typeof text !== 'string' || text.length === 0) {
31
+ return { misread: false, confidence: 'low' }
32
+ }
33
+ const found = new Set()
34
+ for (const ch of text) {
35
+ if (isCjk(ch) && GBK_MISREAD_CHARS.includes(ch)) found.add(ch)
36
+ }
37
+
38
+ // 误解码标点怪串(强特征,单独加权)
39
+ let punctPieces = 0
40
+ const punctPatterns = [
41
+ /鈥[?\?\uFFFD]/g, // em-dash 误读(鈥? / 鈥? / 鈥�)
42
+ /锛[?\?\uFFFD]/g, // 全角逗号/括号误读
43
+ /锛堝/g, // 左括号误读
44
+ /銆[?\?\uFFFD]/g, // 句号误读
45
+ /锘[?\?\uFFFD]/g, // U+FEFF 误读
46
+ ]
47
+ for (const re of punctPatterns) {
48
+ const m = text.match(re)
49
+ if (m) punctPieces += m.length
50
+ }
51
+
52
+ // U+FFFD 洪水(≥5 个)
53
+ const substCount = (text.match(/\uFFFD/g) ?? []).length
54
+ const hasSubstFlood = substCount >= 5
55
+
56
+ if (found.size >= 6) {
57
+ return { misread: true, chars: [...found].slice(0, 16), confidence: 'high' }
58
+ }
59
+ if (found.size >= 5 && punctPieces > 0) {
60
+ return { misread: true, chars: [...found].slice(0, 16), confidence: 'high' }
61
+ }
62
+ if (found.size >= 3) {
63
+ return { misread: true, chars: [...found].slice(0, 16), confidence: 'medium' }
64
+ }
65
+ if (found.size >= 1 && punctPieces >= 1) {
66
+ return { misread: true, chars: [...found].slice(0, 16), confidence: 'medium', note: '含误解码标点组合' }
67
+ }
68
+ if (hasSubstFlood) {
69
+ return { misread: true, confidence: 'medium', note: '大量 U+FFFD 替换字符——原文在某个环节被按错误编码解码' }
70
+ }
71
+ return { misread: false, confidence: 'low' }
72
+ }
73
+
74
+ /**
75
+ * 检测「内容被替换为问号」的 ASCII 化损坏(`??OK??`、`??? skill ????` 案例)。
76
+ * @param {string} text
77
+ * @returns {{ asciiLoss: boolean, score: number }}
78
+ */
79
+ export function detectAsciiLoss(text) {
80
+ if (typeof text !== 'string' || text.length < 12) {
81
+ return { asciiLoss: false, score: 0 }
82
+ }
83
+ const runs = text.match(/\?{2,}/g) ?? []
84
+ if (runs.length === 0) return { asciiLoss: false, score: 0 }
85
+ let score = 0
86
+ for (const run of runs) {
87
+ score += run.length >= 4 ? 2 : 1
88
+ }
89
+ const density = (text.match(/\?/g) ?? []).length / Math.max(1, text.length)
90
+ if (density > 0.03) score += 2
91
+ return { asciiLoss: score >= 2, score }
92
+ }
93
+
94
+ /**
95
+ * 一键检测:给定文本返回规范化的结果摘要(供 post-execute 附加提示)。
96
+ * @param {string} text
97
+ * @returns {{ ok: boolean, kind?: 'gbk-misread'|'ascii-loss', summary: string }}
98
+ */
99
+ export function detect(text) {
100
+ const gbk = detectGbkMisread(text)
101
+ if (gbk.misread) {
102
+ return {
103
+ ok: false,
104
+ kind: 'gbk-misread',
105
+ summary: `疑似 GBK/ANSI 误解码乱码(特征字形:${(gbk.chars ?? []).slice(0, 6).join(' ')}${gbk.note ? `;${gbk.note}` : ''})。`,
106
+ }
107
+ }
108
+ const asc = detectAsciiLoss(text)
109
+ if (asc.asciiLoss) {
110
+ return {
111
+ ok: false,
112
+ kind: 'ascii-loss',
113
+ summary: '疑似编码丢失(中文被替换为问号,常见于 UTF-8→ANSI 转换)。',
114
+ }
115
+ }
116
+ return { ok: true }
117
+ }
118
+
119
+ /** 把 ContentBlock[] 展平为纯文本(用于 post-execute 扫描)。 */
120
+ export function blocksToText(content) {
121
+ if (!Array.isArray(content)) return ''
122
+ const parts = []
123
+ for (const block of content) {
124
+ if (block && typeof block.text === 'string') parts.push(block.text)
125
+ else if (typeof block === 'string') parts.push(block)
126
+ }
127
+ return parts.join('\n')
128
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-windows-guard",
3
- "description": "DeepSeek Harness (dsh) Windows 环境防坑守则 skill 插件(纯数据):GBK/BOM/UTF-16 编码坑、PowerShell 引号转义、长路径/文件占用/EACCES、进程与端口、CRLF、stderr 误判、乱码识别——预防性规则,无修复工具。纯技能载体,零运行时依赖,零构建。",
4
- "version": "0.1.0",
3
+ "description": "DeepSeek Harness (dsh) Windows 环境防坑插件:两个防坑 skill(windows-enc/windows-sys:编码/转义/路径/进程/乱码预防规则)+ 三层主动防护(pwsh 结果乱码检测提示、危险写命令拦截、windows_encode_detect/fix 编码诊断修复工具)。零运行时依赖,零构建。",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -13,6 +13,7 @@
13
13
  "skills",
14
14
  "cordis.patch.yml",
15
15
  "README.md",
16
+ "CHANGELOG.md",
16
17
  "scripts"
17
18
  ],
18
19
  "scripts": {
@@ -34,6 +35,8 @@
34
35
  "cordis",
35
36
  "plugin",
36
37
  "skill",
38
+ "tools",
39
+ "guard",
37
40
  "windows",
38
41
  "powershell",
39
42
  "encoding",
@@ -4,6 +4,9 @@ import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { createRequire } from 'node:module'
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url'
7
+ import { detectGbkMisread, detectAsciiLoss, detect, blocksToText } from '../lib/mojibake.js'
8
+ import { detectEncoding, fixToUtf8NoBom } from '../lib/encode.js'
9
+ import { checkDangerousWrite } from '../lib/index.js'
7
10
 
8
11
  const require = createRequire(import.meta.url)
9
12
  const pkg = require('../package.json')
@@ -27,6 +30,7 @@ t('package.json name/main/patch 一致', () => {
27
30
  assert.equal(pkg.name, 'dsh-plugin-windows-guard')
28
31
  assert.equal(pkg.main, 'lib/index.js')
29
32
  assert.ok(pkg.dsh?.bundle?.patch, '应有 bundle patch')
33
+ assert.equal(pkg.version, '0.2.0', '版本应为 0.2.0')
30
34
  })
31
35
 
32
36
  // ---- 技能文件:拆分后的两个技能 ----
@@ -56,6 +60,12 @@ t('windows-enc 覆盖编码/转义/乱码', () => {
56
60
  assert.ok(md.includes(needle), `windows-enc 缺少: ${needle}`)
57
61
  }
58
62
  })
63
+ t('windows-enc 提及配套工具 windows_encode_detect/fix', () => {
64
+ const md = fs.readFileSync(path.join(SKILLS_DIR, 'windows-enc.md'), 'utf8')
65
+ for (const needle of ['windows_encode_detect', 'windows_encode_fix', 'windowsguard.bak']) {
66
+ assert.ok(md.includes(needle), `windows-enc 缺少工具指引: ${needle}`)
67
+ }
68
+ })
59
69
  t('windows-sys 覆盖路径/进程/跨平台', () => {
60
70
  const md = fs.readFileSync(path.join(SKILLS_DIR, 'windows-sys.md'), 'utf8')
61
71
  for (const needle of ['MAX_PATH', 'Stop-Process', 'netstat', 'autocrlf']) {
@@ -64,11 +74,15 @@ t('windows-sys 覆盖路径/进程/跨平台', () => {
64
74
  })
65
75
 
66
76
  // ---- lib 可加载且导出一致 ----
67
- t('lib/index.js 导出 name/inject/apply', async () => {
77
+ t('lib/index.js 导出 name/inject/apply + 合并层导出', async () => {
68
78
  const mod = await import(pathToFileURL(path.join(PKG_DIR, '..', 'lib', 'index.js')).href)
69
79
  assert.equal(mod.name, 'dsh-plugin-windows-guard')
70
- assert.ok(Array.isArray(mod.inject) && mod.inject.includes('skills'))
80
+ assert.ok(Array.isArray(mod.inject) && mod.inject.includes('skills'), 'inject 应含 skills')
81
+ assert.ok(mod.inject.includes('tools'), 'inject 应含 tools')
71
82
  assert.equal(typeof mod.apply, 'function')
83
+ assert.equal(typeof mod.checkDangerousWrite, 'function')
84
+ assert.equal(typeof mod.postCheckHandler, 'function')
85
+ assert.equal(typeof mod.preCheckHandler, 'function')
72
86
  })
73
87
 
74
88
  // ---- frontmatter 解析一致性(用 index.js 相同的解析逻辑)----
@@ -96,5 +110,80 @@ t('lib 只能发现拆分的两个技能', async () => {
96
110
  assert.deepEqual(registered.sort(), ['windows-enc', 'windows-sys'])
97
111
  })
98
112
 
113
+ // ==== 以下为 0.2.0 自 pwsh-guard 合并的主动防护层测试 ====
114
+
115
+ // ---- mojibake ----
116
+ t('gbk misread 真实样本', () => {
117
+ assert.equal(detectGbkMisread('鎻掍欢鏋勮瑙勮寖锛堢煡璇嗗簱锛?').misread, true)
118
+ assert.equal(detectGbkMisread('# restart-dsh.ps1 鈥?DSH web 涓€閿畨鍏ㄩ噸鍚?').misread, true)
119
+ })
120
+ t('正常文本不误报', () => {
121
+ assert.equal(detectGbkMisread('插件构建规范,启动故障记录。').misread, false)
122
+ assert.equal(detectGbkMisread('PowerShell 读取 Get-Content 文件没有问题。').misread, false)
123
+ })
124
+ t('ascii loss 检测', () => {
125
+ assert.equal(detectAsciiLoss('??? skill ???? novel-format-rules ??,????:???????????????').asciiLoss, true)
126
+ assert.equal(detect('插件构建规范,重启前必跑自检。').ok, true)
127
+ })
128
+ t('blocksToText', () => {
129
+ const blocks = [{ type: 'text', text: 'hello' }, { type: 'tool-use', text: 'x' }]
130
+ assert.equal(blocksToText(blocks).includes('hello'), true)
131
+ })
132
+
133
+ // ---- encode ----
134
+ t('utf8 无 BOM 检出', () => {
135
+ const d = detectEncoding(Buffer.from('插件规范,正常内容。', 'utf8'))
136
+ assert.equal(d.encoding, 'utf-8')
137
+ assert.equal(d.validUtf8, true)
138
+ })
139
+ t('utf8 BOM 检出', () => {
140
+ const d = detectEncoding(Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('{"a":1}', 'utf8')]))
141
+ assert.equal(d.encoding, 'utf-8-bom')
142
+ })
143
+ t('utf16le BOM 检出', () => {
144
+ const d = detectEncoding(Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('中文测试', 'utf16le')]))
145
+ assert.equal(d.encoding, 'utf-16le')
146
+ })
147
+ t('gbk 检出', () => {
148
+ // "插件规范" GBK 字节
149
+ const gbk = Buffer.from([0xb2, 0xe5, 0xbc, 0xfe, 0xb9, 0xe6, 0xb7, 0xb6])
150
+ const d = detectEncoding(gbk)
151
+ assert.equal(d.encoding, 'gbk')
152
+ })
153
+
154
+ // ---- pre-execute 危险写拦截 ----
155
+ t('拦截无编码 Set-Content 写 json', () => {
156
+ const reason = checkDangerousWrite('Set-Content -Path config.json -Value $x')
157
+ assert.ok(reason && reason.includes('WriteAllText'), '应给出 WriteAllText 修正')
158
+ })
159
+ t('拦截 Out-File 写 yaml(无编码)', () => {
160
+ const reason = checkDangerousWrite('$list | Out-File list.yaml')
161
+ assert.ok(reason && reason.includes('WriteAllText'), reason)
162
+ })
163
+ t('放行带编码 Out-File', () => {
164
+ assert.equal(checkDangerousWrite('Out-File -Path x.json -Encoding utf8NoBOM -Value $x'), undefined)
165
+ })
166
+ t('放行读操作', () => {
167
+ assert.equal(checkDangerousWrite('Get-Content -Path readme.md'), undefined)
168
+ assert.equal(checkDangerousWrite("Get-ChildItem | Measure-Object"), undefined)
169
+ })
170
+
171
+ // ---- fix 往返(临时目录,零残留风险)----
172
+ t('fixToUtf8NoBom:GBK → UTF-8 无 BOM + .windowsguard.bak 备份', () => {
173
+ const tmp = path.join(PKG_DIR, '..', '.selfcheck-tmp')
174
+ fs.mkdirSync(tmp, { recursive: true })
175
+ const f = path.join(tmp, 'sample.gbk.txt')
176
+ try {
177
+ fs.writeFileSync(f, Buffer.from([0xb2, 0xe5, 0xbc, 0xfe, 0xb9, 0xe6, 0xb7, 0xb6])) // GBK「插件规范」
178
+ const fixed = fixToUtf8NoBom(f, { backup: true })
179
+ assert.equal(fixed.before, 'gbk')
180
+ assert.equal(fixed.after, 'utf-8')
181
+ assert.ok(fs.existsSync(`${f}.windowsguard.bak`), '备份应存在')
182
+ assert.ok(fs.readFileSync(f, 'utf8').includes('插件'), '修复后应为 UTF-8 中文')
183
+ } finally {
184
+ fs.rmSync(tmp, { recursive: true, force: true })
185
+ }
186
+ })
187
+
99
188
  if (!process.exitCode) console.log(`\n${passed} 项全部通过`)
100
189
  else console.log(`\n${passed} 项通过,存在失败项`)
@@ -1,10 +1,17 @@
1
- // dsh-plugin-windows-guard 服务端冒烟:假 ctx 走注册全链路(纯数据插件)。
1
+ // dsh-plugin-windows-guard 服务端冒烟:假 ctx 走全链路
2
+ // (技能注册 → 工具注册 → pre/post hook 决策 → 工具执行 → 修复往返 → 清理)。零依赖。
2
3
  import assert from 'node:assert'
4
+ import fs from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
3
7
  import * as mod from '../lib/index.js'
4
8
 
5
- // ctx:收集注册与日志
9
+ // ---- 构造假 ctx ----
6
10
  const registered = []
11
+ const registrations = {}
12
+ const listeners = {}
7
13
  const logs = []
14
+
8
15
  const ctx = {
9
16
  skills: {
10
17
  register: (def) => {
@@ -12,15 +19,17 @@ const ctx = {
12
19
  return () => {} // disposer
13
20
  }
14
21
  },
22
+ tools: { register: (def) => { registrations[def.name] = def; return () => {} } },
23
+ on: (event, fn) => { listeners[event] = fn; return () => {} },
15
24
  }
16
25
  const origLog = console.log
17
26
  console.log = (...args) => { logs.push(args.join(' ')) }
18
27
 
19
- mod.apply(ctx)
28
+ const dispose = mod.apply(ctx)
20
29
 
21
30
  console.log = origLog
22
31
 
23
- // 断言:拆分后的两个技能
32
+ // ---- 断言:拆分后的两个技能 ----
24
33
  const names = registered.map((s) => s.name).sort()
25
34
  assert.deepEqual(names, ['windows-enc', 'windows-sys'], '应注册 windows-enc 与 windows-sys')
26
35
  for (const skill of registered) {
@@ -34,4 +43,67 @@ const sys = registered.find((s) => s.name === 'windows-sys')
34
43
  assert.ok(sys.content.includes('Stop-Process'), 'windows-sys 正文含进程守则')
35
44
  assert.ok(logs.some((l) => l.includes('技能已注册:windows-enc')), '日志有注册记录')
36
45
 
37
- console.log(`smoke: 全部通过(注册 ${registered.length} 个技能:${registered.map((s) => s.name).join(', ')})`)
46
+ // ---- 断言:合并的主动防护层(0.2.0)----
47
+ assert.ok(registrations.windows_encode_detect, '注册 windows_encode_detect')
48
+ assert.ok(registrations.windows_encode_fix, '注册 windows_encode_fix')
49
+ assert.ok(listeners['tools/pre-execute'] && listeners['tools/post-execute'], '注册 hooks')
50
+
51
+ // ---- pre-execute 决策 ----
52
+ const denyExec = { name: 'pwsh', arguments: { command: 'Set-Content -Path a.json -Value $x' } }
53
+ const preDeny = await listeners['tools/pre-execute'](denyExec, async () => ({ kind: 'allow' }))
54
+ assert.equal(preDeny.kind, 'deny', '危险写命令被拒')
55
+ assert.ok(preDeny.reason.includes('[windows-guard]'), '拒绝理由带 [windows-guard] 标签')
56
+ const preAllow = await listeners['tools/pre-execute']({ name: 'pwsh', arguments: { command: 'Get-Content x.md' } }, async () => ({ kind: 'allow' }))
57
+ assert.equal(preAllow.kind, 'allow', '读命令放行')
58
+
59
+ // ---- post-execute 决策(乱码 → 附加提示)----
60
+ const postHit = await listeners['tools/post-execute'](
61
+ { name: 'pwsh' },
62
+ { isError: false, content: [{ type: 'text', text: '# DSH 鍚姩鏁呴殰璁板綍锛堜簨鏁呭洓鎺樼栵?' }] },
63
+ async () => ({ kind: 'accept', content: [{ type: 'text', text: 'fallback' }] })
64
+ )
65
+ assert.equal(postHit.kind, 'accept')
66
+ assert.ok(postHit.content.some((c) => typeof c.text === 'string' && c.text.includes('[windows-guard]')), '附加 windows-guard 提示')
67
+ assert.ok(postHit.content.some((c) => typeof c.text === 'string' && c.text.includes('windows_encode_detect')), '提示引导到新工具名')
68
+
69
+ const postClean = await listeners['tools/post-execute'](
70
+ { name: 'pwsh' },
71
+ { isError: false, content: [{ type: 'text', text: 'all good here' }] },
72
+ async () => ({ kind: 'accept', content: [{ type: 'text', text: 'fallback' }] })
73
+ )
74
+ assert.equal(postClean.kind, 'accept')
75
+ assert.ok(!postClean.content.some((c) => typeof c.text === 'string' && c.text.includes('[windows-guard]')), '正常输出不附加')
76
+
77
+ // ---- 非 pwsh 工具不受影响 ----
78
+ const postOther = await listeners['tools/post-execute'](
79
+ { name: 'read' },
80
+ { isError: false, content: [{ type: 'text', text: '鎻掍欢鏋勮瑙勮寖' }] },
81
+ async () => ({ kind: 'accept', content: [{ type: 'text', text: 'fallback' }] })
82
+ )
83
+ assert.equal(postOther.kind, 'accept')
84
+ assert.ok(!postOther.content.some((c) => typeof c.text === 'string' && c.text.includes('[windows-guard]')), '非 pwsh 工具不附加')
85
+
86
+ // ---- 工具执行 ----
87
+ const tmp = join(tmpdir(), 'windowsguard-smoke')
88
+ fs.mkdirSync(tmp, { recursive: true })
89
+ const f = join(tmp, 'sample.gbk.txt')
90
+ fs.writeFileSync(f, Buffer.from([0xb2, 0xe5, 0xbc, 0xfe, 0xb9, 0xe6, 0xb7, 0xb6])) // GBK 插件规范(近似)
91
+ const fakeExec = { agent: { session: { header: { cwd: tmp } } } }
92
+ const detectResult = await registrations.windows_encode_detect.execute({ path: 'sample.gbk.txt' }, fakeExec)
93
+ assert.equal(detectResult.encoding, 'gbk', 'GBK 文件被正确检出')
94
+ const dryRun = await registrations.windows_encode_fix.execute({ path: 'sample.gbk.txt', dryRun: true }, fakeExec)
95
+ assert.ok(dryRun.notice.includes('dryRun'), 'dryRun 不写文件')
96
+ assert.ok(!fs.existsSync(`${f}.windowsguard.bak`), 'dryRun 无备份')
97
+ const fixResult = await registrations.windows_encode_fix.execute({ path: 'sample.gbk.txt' }, fakeExec)
98
+ assert.equal(fixResult.before, 'gbk')
99
+ assert.equal(fixResult.after, 'utf-8')
100
+ const reread = fs.readFileSync(f, 'utf8')
101
+ assert.ok(reread.includes('插件'), '修复后为 UTF-8 中文')
102
+ assert.ok(fs.existsSync(`${f}.windowsguard.bak`), '备份存在(.windowsguard.bak)')
103
+
104
+ // ---- 清理函数可调用(HMR 幂等)----
105
+ dispose()
106
+
107
+ fs.rmSync(tmp, { recursive: true, force: true })
108
+
109
+ console.log(`smoke: 全部通过(技能 ${registered.length} + 工具 ${Object.keys(registrations).length} + hooks ${Object.keys(listeners).length},修复往返 OK)`)
@@ -30,7 +30,11 @@ whenToUse: 读/写文本与配置文件、处理中文输出、命令引号转
30
30
  | pwsh 7 写文件 | `Out-File -Encoding utf8`(5.1 下 = 带 BOM;7 下无 BOM) | `Out-File -Encoding utf8NoBOM`(仅 pwsh 7) |
31
31
 
32
32
  补充:
33
- - **查文件编码**(前 3 字节嗅探):`Format-Hex -Path a.json -Count 3`——
33
+ - **配套零 token 工具**(本插件注册,优先于手写嗅探/转换命令):
34
+ `windows_encode_detect`(诊断 BOM/UTF-8/GBK/UTF-16 + 置信度)与
35
+ `windows_encode_fix`(转成 UTF-8 无 BOM,自动备份 `<path>.windowsguard.bak`,
36
+ 支持 `dryRun`)。遇到乱码或 JSON/YAML 解析失败时,先 detect 后 fix,再复检。
37
+ - **查文件编码**(无工具可用时的替代):`Format-Hex -Path a.json -Count 3`——
34
38
  `EF BB BF` = UTF-8 BOM(解析器可能拒绝);`FF FE` = UTF-16LE(必然炸 JSON);
35
39
  无 BOM 且含 `FF FD` 替换符 = 文件本来就被写坏了。
36
40
  - **GBK→UTF-8 转换**(确证文件是 GBK 时,PowerShell 无直接 cmdlet,用 .NET):
@@ -64,3 +68,6 @@ whenToUse: 读/写文本与配置文件、处理中文输出、命令引号转
64
68
  1. **不猜**——乱码文本语义不可信,别试图「意思理解一下」继续用;
65
69
  2. **不引用**——不要把它当答案、文件名、配置值传给下游;
66
70
  3. **先重读**——回到源头(原文件原命令)按守则重新读/重新执行。
71
+
72
+ 附:pwsh 工具输出出现乱码时,本插件会自动在结果后追加 `[windows-guard]` 修复提示;
73
+ 文件类乱码直接走 `windows_encode_detect` / `windows_encode_fix` 工具链。