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/CHANGELOG.md +33 -0
- package/README.en.md +178 -0
- package/README.md +89 -30
- package/cordis.patch.yml +12 -2
- package/lib/encode.js +220 -0
- package/lib/index.js +297 -11
- package/lib/mojibake.js +128 -0
- package/package.json +12 -3
- package/scripts/selfcheck.mjs +91 -2
- package/scripts/smoke-server.mjs +77 -5
- package/skills/windows-enc.md +8 -1
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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-
|
|
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
|
-
|
|
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 () => {
|
package/lib/mojibake.js
ADDED
|
@@ -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
|
|
4
|
-
"version": "0.1
|
|
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",
|
package/scripts/selfcheck.mjs
CHANGED
|
@@ -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} 项通过,存在失败项`)
|