dsh-plugin-windows-guard 0.1.0 → 0.2.1

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/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.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -13,6 +13,8 @@
13
13
  "skills",
14
14
  "cordis.patch.yml",
15
15
  "README.md",
16
+ "README.en.md",
17
+ "CHANGELOG.md",
16
18
  "scripts"
17
19
  ],
18
20
  "scripts": {
@@ -34,12 +36,19 @@
34
36
  "cordis",
35
37
  "plugin",
36
38
  "skill",
39
+ "tools",
40
+ "guard",
37
41
  "windows",
38
42
  "powershell",
39
43
  "encoding",
40
44
  "utf-8",
41
45
  "gbk",
42
- "bom"
46
+ "bom",
47
+ "mojibake",
48
+ "garbled-text",
49
+ "powershell-guard",
50
+ "write-guard",
51
+ "hook"
43
52
  ],
44
53
  "license": "MIT",
45
54
  "homepage": "https://github.com/Pasumao/dsh-plugin-windows-guard#readme",
@@ -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} 项通过,存在失败项`)