zen-gitsync 2.15.33 → 2.16.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/README.md +25 -0
- package/package.json +2 -1
- package/scripts/run-tests.cjs +1 -0
- package/src/cli/ai/agent.js +845 -0
- package/src/cli/ai/agent.test.js +114 -0
- package/src/cli/ai/images.js +211 -0
- package/src/cli/ai/images.test.js +98 -0
- package/src/cli/ai/safety.js +215 -0
- package/src/cli/ai/safety.test.js +158 -0
- package/src/cli/ai/streamFilter.js +82 -0
- package/src/cli/ai/streamFilter.test.js +88 -0
- package/src/cli/ai/termui.js +378 -0
- package/src/cli/ai/termui.test.js +215 -0
- package/src/cli/ai/tools.js +482 -0
- package/src/cli/ai/tools.test.js +190 -0
- package/src/gitCommit.js +9 -0
- package/src/ui/public/assets/AppVersionBadge-CS7Trvp-.css +1 -0
- package/src/ui/public/assets/AppVersionBadge-DJyfXR_Q.js +2 -0
- package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +1 -0
- package/src/ui/public/assets/BranchSelector-ClnVPC0Q.js +1 -0
- package/src/ui/public/assets/CommitForm-BihM26_4.js +6 -0
- package/src/ui/public/assets/CommitForm-DdK8dymc.css +1 -0
- package/src/ui/public/assets/CommonDialog-CYoIF2fg.js +1 -0
- package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +1 -0
- package/src/ui/public/assets/EditorView-BVod-rjn.css +1 -0
- package/src/ui/public/assets/EditorView-DYJQT-Xj.js +0 -0
- package/src/ui/public/assets/FlowExecutionViewer-CPblwVA1.js +1 -0
- package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-Cc-Tnhng.js +21 -0
- package/src/ui/public/assets/LogList-CeFFytjB.css +1 -0
- package/src/ui/public/assets/LogList-va6ANtbL.js +15 -0
- package/src/ui/public/assets/MindmapView-3rMfOoNm.css +1 -0
- package/src/ui/public/assets/MindmapView-C_9P5ijq.js +1 -0
- package/src/ui/public/assets/MonitorView-DRPECOVW.js +1 -0
- package/src/ui/public/assets/MonitorView-TaS4kL0Y.css +1 -0
- package/src/ui/public/assets/ProjectStartupButton-DSovbKPE.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-BRGNlbeO.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-C_cBtHcN.js +5 -0
- package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +1 -0
- package/src/ui/public/assets/SourceMapView-ZiCL8mS8.js +3 -0
- package/src/ui/public/assets/SvgIcon-C9YQSvGU.js +1 -0
- package/src/ui/public/assets/{SvgIcon-BQ6bR7l0.css → SvgIcon-CDNsk4-Q.css} +1 -1
- package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +1 -0
- package/src/ui/public/assets/UserInputNode-CR6o8zJG.js +1 -0
- package/src/ui/public/assets/WorkbenchView-CJJrBqBH.js +28 -0
- package/src/ui/public/assets/{WorkbenchView-CQfjF0Gr.css → WorkbenchView-DbF4hH5s.css} +1 -1
- package/src/ui/public/assets/{_plugin-vue_export-helper-CzpRIaQR.js → _plugin-vue_export-helper-CHQqCylN.js} +1 -1
- package/src/ui/public/assets/{configStore-DaWjJLTW.js → configStore-Bw9U4AHx.js} +1 -1
- package/src/ui/public/assets/element-plus-CyGhXd7X.js +25 -0
- package/src/ui/public/assets/{flow-mindmap-2CcykUk_.js → flow-mindmap-CUvJaYn2.js} +1 -1
- package/src/ui/public/assets/index-BzNpkvm5.js +68 -0
- package/src/ui/public/assets/{index-CWoujklW.css → index-CaIu73UM.css} +1 -1
- package/src/ui/public/assets/socket-io-BSgn8Jx2.js +1 -0
- package/src/ui/public/assets/{vendor-CG4K5XlB.js → vendor-CU5NYUKJ.js} +44 -37
- package/src/ui/public/assets/vue-flow-CWmfpfPN.js +39 -0
- package/src/ui/public/index.html +12 -12
- package/src/utils/index.js +5 -0
- package/src/ui/public/assets/AppVersionBadge-Ckwa7eM9.js +0 -2
- package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +0 -1
- package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +0 -1
- package/src/ui/public/assets/BranchSelector-e0465kxE.js +0 -1
- package/src/ui/public/assets/CommitForm-862NAe0q.js +0 -6
- package/src/ui/public/assets/CommitForm-CUvl0VcF.css +0 -1
- package/src/ui/public/assets/CommonDialog-BVPvUoQ0.js +0 -1
- package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
- package/src/ui/public/assets/EditorView-CRYGVw_r.css +0 -1
- package/src/ui/public/assets/EditorView-sZoyx-nK.js +0 -0
- package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-CowPPIpm.js +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-Dno2fn7O.js +0 -21
- package/src/ui/public/assets/LogList-DJYpDUTZ.css +0 -1
- package/src/ui/public/assets/LogList-Dy42jDbI.js +0 -7
- package/src/ui/public/assets/MindmapView-CWoqfZRL.css +0 -1
- package/src/ui/public/assets/MindmapView-Ds2XDHgS.js +0 -1
- package/src/ui/public/assets/MonitorView-DxFogQIt.css +0 -1
- package/src/ui/public/assets/MonitorView-Dy158z7E.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +0 -1
- package/src/ui/public/assets/ProjectStartupButton-eozWTyGl.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-CTfNrrx4.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-d0klonVb.css +0 -1
- package/src/ui/public/assets/SourceMapView-Bl5jEKf6.js +0 -3
- package/src/ui/public/assets/SourceMapView-DG_a_076.css +0 -1
- package/src/ui/public/assets/SvgIcon-D7FdXvWv.js +0 -1
- package/src/ui/public/assets/UserInputNode-Bcxe_5-C.js +0 -1
- package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +0 -1
- package/src/ui/public/assets/WorkbenchView-CZSPrO3Q.js +0 -12
- package/src/ui/public/assets/element-plus-d0668myq.js +0 -17
- package/src/ui/public/assets/index-CG24iOR5.js +0 -38
- package/src/ui/public/assets/socket-io-B1foIbJS.js +0 -1
- package/src/ui/public/assets/vue-flow-CVRzDji7.js +0 -39
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// src/cli/ai/agent.js 中 truncateDisplay(回显截断)的单元测试。
|
|
16
|
+
// 源自实测踩坑:git status 输出 614 字符被截断 —— 只省 14 字符没意义,
|
|
17
|
+
// 且切口把 "src/cli/ai/agent.js" 从中间切断显示成 "rc/cli/ai/agent.js"。
|
|
18
|
+
import { test } from 'node:test'
|
|
19
|
+
import assert from 'node:assert/strict'
|
|
20
|
+
import { truncateDisplay, stripStaleImages } from './agent.js'
|
|
21
|
+
|
|
22
|
+
const LIMIT = 600
|
|
23
|
+
|
|
24
|
+
test('短文本原样返回', () => {
|
|
25
|
+
assert.equal(truncateDisplay('hello'), 'hello')
|
|
26
|
+
assert.equal(truncateDisplay(''), '')
|
|
27
|
+
assert.equal(truncateDisplay(null), '')
|
|
28
|
+
assert.equal(truncateDisplay(undefined), '')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('只超出一点点(<200 字符)不截断 — git status 614 字符回归', () => {
|
|
32
|
+
// 构造恰好超过 limit 14 字符的文本(复现实测场景)
|
|
33
|
+
const text = 'x'.repeat(LIMIT + 14)
|
|
34
|
+
assert.equal(truncateDisplay(text, LIMIT), text)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('超过阈值才截断,且包含省略标记', () => {
|
|
38
|
+
const text = 'a'.repeat(LIMIT + 500)
|
|
39
|
+
const r = truncateDisplay(text, LIMIT)
|
|
40
|
+
assert.match(r, /回显省略 \d+ 字符/)
|
|
41
|
+
assert.ok(r.length < text.length)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('切口对齐整行边界,不把路径/单词从中间切断', () => {
|
|
45
|
+
// 每行 50 字符,构造 30 行的文本
|
|
46
|
+
const lines = Array.from({ length: 30 }, (_, i) => `line-${String(i).padStart(2, '0')}-${'x'.repeat(41)}`)
|
|
47
|
+
const text = lines.join('\n') // 30 * 50 + 29 ≈ 1529 字符,远超 limit
|
|
48
|
+
const r = truncateDisplay(text, LIMIT)
|
|
49
|
+
const parts = r.split('\n')
|
|
50
|
+
const markerIdx = parts.findIndex(l => l.includes('回显省略'))
|
|
51
|
+
assert.ok(markerIdx > 0, '应有省略标记行')
|
|
52
|
+
// 标记行之前的那行,必须是原文里的某一整行(不是半拉行)
|
|
53
|
+
const headLine = parts[markerIdx - 1]
|
|
54
|
+
assert.ok(lines.includes(headLine), `头部末行应为完整行,实际: ${headLine}`)
|
|
55
|
+
// 标记行之后的那行,也必须是原文里的某一整行
|
|
56
|
+
const tailLine = parts[markerIdx + 1]
|
|
57
|
+
assert.ok(lines.includes(tailLine), `尾部首行应为完整行,实际: ${tailLine}`)
|
|
58
|
+
// 尾部最后一行是原文最后一行
|
|
59
|
+
assert.ok(r.endsWith(lines[lines.length - 1]))
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('单行超长文本也能截断(无换行可对齐时按字符截)', () => {
|
|
63
|
+
const text = 'y'.repeat(LIMIT + 500)
|
|
64
|
+
const r = truncateDisplay(text, LIMIT)
|
|
65
|
+
assert.match(r, /回显省略/)
|
|
66
|
+
assert.ok(r.startsWith('yyy'))
|
|
67
|
+
assert.ok(r.endsWith('yyy'))
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// ── stripStaleImages:多模态历史的图片瘦身 ──
|
|
71
|
+
test('stripStaleImages: 只保留最近一条带图消息的图片,更早的降级为占位文字', () => {
|
|
72
|
+
const img = (n) => ({ type: 'image_url', image_url: { url: `data:image/png;base64,${n}` } })
|
|
73
|
+
const messages = [
|
|
74
|
+
{ role: 'system', content: 'sys' },
|
|
75
|
+
{ role: 'user', content: [{ type: 'text', text: '第一问' }, img('AAA')] },
|
|
76
|
+
{ role: 'assistant', content: '第一答' },
|
|
77
|
+
{ role: 'user', content: [{ type: 'text', text: '第二问' }, img('BBB')] },
|
|
78
|
+
{ role: 'assistant', content: '第二答' },
|
|
79
|
+
{ role: 'user', content: [{ type: 'text', text: '第三问' }, img('CCC')] },
|
|
80
|
+
]
|
|
81
|
+
stripStaleImages(messages, 'zh-CN')
|
|
82
|
+
// 最近一条(第三问)图片原样保留
|
|
83
|
+
assert.equal(messages[5].content[1].type, 'image_url')
|
|
84
|
+
// 更早的图片变成文字占位
|
|
85
|
+
for (const i of [1, 3]) {
|
|
86
|
+
assert.equal(messages[i].content[1].type, 'text')
|
|
87
|
+
assert.match(messages[i].content[1].text, /图片/)
|
|
88
|
+
}
|
|
89
|
+
// 文本部件不动
|
|
90
|
+
assert.equal(messages[1].content[0].text, '第一问')
|
|
91
|
+
// 纯字符串 content 的消息不受影响
|
|
92
|
+
assert.equal(messages[0].content, 'sys')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('stripStaleImages: 没有图片时完全不动', () => {
|
|
96
|
+
const messages = [
|
|
97
|
+
{ role: 'system', content: 'sys' },
|
|
98
|
+
{ role: 'user', content: '纯文本问题' },
|
|
99
|
+
{ role: 'user', content: [{ type: 'text', text: '多部件但没图' }] },
|
|
100
|
+
]
|
|
101
|
+
const snapshot = JSON.parse(JSON.stringify(messages))
|
|
102
|
+
stripStaleImages(messages, 'zh-CN')
|
|
103
|
+
assert.deepEqual(messages, snapshot)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('stripStaleImages: en locale 用英文占位符', () => {
|
|
107
|
+
const messages = [
|
|
108
|
+
{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:...' } }] },
|
|
109
|
+
{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:...' } }] },
|
|
110
|
+
]
|
|
111
|
+
stripStaleImages(messages, 'en-US')
|
|
112
|
+
assert.equal(messages[0].content[0].text, '[image omitted from history]')
|
|
113
|
+
assert.equal(messages[1].content[0].type, 'image_url')
|
|
114
|
+
})
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// g ai 图片附件 — 剪贴板图片读取 + 本地图片转 data URL。
|
|
16
|
+
//
|
|
17
|
+
// 触发方式:REPL 里按 Alt+V(或 /image <路径> 附加本地文件)。
|
|
18
|
+
// 图片保存到 ~/.git-commit-tool/ai-images/,发送时编码为 base64 data URL,
|
|
19
|
+
// 以 OpenAI 多模态格式(image_url)拼进 user 消息。
|
|
20
|
+
//
|
|
21
|
+
// 平台实现:
|
|
22
|
+
// - Windows: powershell.exe -STA 调 System.Windows.Forms.Clipboard.GetImage()
|
|
23
|
+
// (Win10+ 自带 PowerShell 5.1,-STA 是剪贴板访问的必要条件)
|
|
24
|
+
// - macOS: osascript 读 «class PNGf» 剪贴板数据
|
|
25
|
+
// - Linux: Wayland 用 wl-paste,X11 用 xclip(都没有则报不支持)
|
|
26
|
+
//
|
|
27
|
+
// 注意:读的是**本机**剪贴板 — ssh/远程终端里剪贴板通常没有图片,
|
|
28
|
+
// 此时返回空,由调用方提示用户改用 /image <路径>。
|
|
29
|
+
|
|
30
|
+
import { execFile } from 'node:child_process'
|
|
31
|
+
import { promises as fsp } from 'node:fs'
|
|
32
|
+
import os from 'node:os'
|
|
33
|
+
import path from 'node:path'
|
|
34
|
+
|
|
35
|
+
const IMAGE_DIR = path.join(os.homedir(), '.git-commit-tool', 'ai-images')
|
|
36
|
+
const MAX_KEEP = 20 // 目录里最多保留的历史图片数(超出按时间最旧先删)
|
|
37
|
+
const CLIP_TIMEOUT_MS = 15000 // 剪贴板读取超时(PowerShell 冷启动可能要几秒)
|
|
38
|
+
|
|
39
|
+
const MIME_BY_EXT = {
|
|
40
|
+
'.png': 'image/png',
|
|
41
|
+
'.jpg': 'image/jpeg',
|
|
42
|
+
'.jpeg': 'image/jpeg',
|
|
43
|
+
'.gif': 'image/gif',
|
|
44
|
+
'.webp': 'image/webp',
|
|
45
|
+
'.bmp': 'image/bmp',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ──────────────────────────────────────────────
|
|
49
|
+
// 小工具
|
|
50
|
+
// ──────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** execFile 的 Promise 包装;stdout/stderr 给 Buffer,调用方自行解码 */
|
|
53
|
+
function execFileP(cmd, args, options = {}) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
execFile(cmd, args, { timeout: CLIP_TIMEOUT_MS, windowsHide: true, ...options },
|
|
56
|
+
(err, stdout, stderr) => {
|
|
57
|
+
resolve({
|
|
58
|
+
ok: !err,
|
|
59
|
+
code: typeof err?.code === 'number' ? err.code : (err ? -1 : 0),
|
|
60
|
+
stdout: stdout ?? Buffer.alloc(0),
|
|
61
|
+
stderr: stderr ?? Buffer.alloc(0),
|
|
62
|
+
error: err ? String(err.message || err) : null,
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatBytes(n) {
|
|
69
|
+
n = Number(n) || 0
|
|
70
|
+
if (n < 1024) return `${n} B`
|
|
71
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
|
72
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 清理历史图片:按修改时间保留最新 MAX_KEEP 张(尽力而为,失败不阻塞) */
|
|
76
|
+
async function pruneOldImages() {
|
|
77
|
+
try {
|
|
78
|
+
const names = await fsp.readdir(IMAGE_DIR)
|
|
79
|
+
const files = []
|
|
80
|
+
for (const name of names) {
|
|
81
|
+
if (!name.startsWith('clip-') || !name.endsWith('.png')) continue
|
|
82
|
+
const full = path.join(IMAGE_DIR, name)
|
|
83
|
+
try {
|
|
84
|
+
const st = await fsp.stat(full)
|
|
85
|
+
files.push({ full, mtime: st.mtimeMs })
|
|
86
|
+
} catch { /* 单个文件失败忽略 */ }
|
|
87
|
+
}
|
|
88
|
+
files.sort((a, b) => b.mtime - a.mtime)
|
|
89
|
+
for (const f of files.slice(MAX_KEEP)) {
|
|
90
|
+
await fsp.unlink(f.full).catch(() => {})
|
|
91
|
+
}
|
|
92
|
+
} catch { /* 目录不存在等情况直接跳过 */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ──────────────────────────────────────────────
|
|
96
|
+
// 各平台剪贴板读取:成功把图片写入 file,失败返回 false
|
|
97
|
+
// ──────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
async function saveClipboardWindows(file) {
|
|
100
|
+
// -STA:Clipboard 必须运行在单线程单元
|
|
101
|
+
// 输出路径走环境变量传递 — 实测 -Command 后的位置参数会被**拼进命令串**再解析,
|
|
102
|
+
// 路径里的反斜杠直接变成语法错误(ParserError: UnexpectedToken)
|
|
103
|
+
const script = [
|
|
104
|
+
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing;',
|
|
105
|
+
'$img = [System.Windows.Forms.Clipboard]::GetImage();',
|
|
106
|
+
'if ($null -eq $img) { exit 3 }',
|
|
107
|
+
'$img.Save($env:G_AI_CLIP_OUT, [System.Drawing.Imaging.ImageFormat]::Png);',
|
|
108
|
+
'$img.Dispose(); exit 0',
|
|
109
|
+
].join(' ')
|
|
110
|
+
const r = await execFileP('powershell.exe', [
|
|
111
|
+
'-NoProfile', '-NonInteractive', '-STA', '-Command', script,
|
|
112
|
+
], { env: { ...process.env, G_AI_CLIP_OUT: file } })
|
|
113
|
+
return r.ok && r.code === 0
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function saveClipboardMac(file) {
|
|
117
|
+
// «class PNGf» 是 AppleScript 的 PNG 剪贴板类型;剪贴板无图片时 osascript 非零退出
|
|
118
|
+
// 输出路径走 system attribute 读环境变量,避免路径拼进脚本字符串的引号问题
|
|
119
|
+
const script = [
|
|
120
|
+
'set outPath to system attribute "G_AI_CLIP_OUT"',
|
|
121
|
+
'set pngData to the clipboard as «class PNGf»',
|
|
122
|
+
'set fp to open for access (POSIX file outPath) with write permission',
|
|
123
|
+
'set eof fp to 0',
|
|
124
|
+
'write pngData to fp',
|
|
125
|
+
'close access fp',
|
|
126
|
+
].join('\n')
|
|
127
|
+
const r = await execFileP('osascript', ['-e', script], { env: { ...process.env, G_AI_CLIP_OUT: file } })
|
|
128
|
+
return r.ok && r.code === 0
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function saveClipboardLinux(file) {
|
|
132
|
+
// 优先 Wayland 的 wl-paste;失败再试 X11 的 xclip
|
|
133
|
+
const wayland = await execFileP('wl-paste', ['--no-newline', '--type', 'image/png'])
|
|
134
|
+
if (wayland.ok && wayland.stdout.length > 0) {
|
|
135
|
+
await fsp.writeFile(file, wayland.stdout)
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
const x11 = await execFileP('xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o'])
|
|
139
|
+
if (x11.ok && x11.stdout.length > 0) {
|
|
140
|
+
await fsp.writeFile(file, x11.stdout)
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ──────────────────────────────────────────────
|
|
147
|
+
// 对外 API
|
|
148
|
+
// ──────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 读取剪贴板图片,保存为 PNG。
|
|
152
|
+
* @returns {Promise<{path: string, bytes: number} | null>}
|
|
153
|
+
* 成功返回文件路径与字节数;剪贴板无图片 / 平台不支持 / 超时返回 null
|
|
154
|
+
*/
|
|
155
|
+
export async function readClipboardImage() {
|
|
156
|
+
await fsp.mkdir(IMAGE_DIR, { recursive: true })
|
|
157
|
+
const file = path.join(IMAGE_DIR, `clip-${Date.now()}.png`)
|
|
158
|
+
|
|
159
|
+
let saved = false
|
|
160
|
+
try {
|
|
161
|
+
if (process.platform === 'win32') saved = await saveClipboardWindows(file)
|
|
162
|
+
else if (process.platform === 'darwin') saved = await saveClipboardMac(file)
|
|
163
|
+
else saved = await saveClipboardLinux(file)
|
|
164
|
+
} catch {
|
|
165
|
+
saved = false
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!saved) {
|
|
169
|
+
await fsp.unlink(file).catch(() => {})
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const st = await fsp.stat(file)
|
|
175
|
+
if (st.size === 0) {
|
|
176
|
+
await fsp.unlink(file).catch(() => {})
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
pruneOldImages() // 不 await,后台清理
|
|
180
|
+
return { path: file, bytes: st.size }
|
|
181
|
+
} catch {
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 校验本地图片文件,返回 {path, bytes};不存在/不是支持的格式返回 null
|
|
188
|
+
*/
|
|
189
|
+
export async function checkImageFile(filePath) {
|
|
190
|
+
try {
|
|
191
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
192
|
+
if (!MIME_BY_EXT[ext]) return null
|
|
193
|
+
const st = await fsp.stat(filePath)
|
|
194
|
+
if (!st.isFile() || st.size === 0) return null
|
|
195
|
+
return { path: filePath, bytes: st.size }
|
|
196
|
+
} catch {
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 图片文件 → OpenAI 多模态格式需要的 base64 data URL
|
|
203
|
+
*/
|
|
204
|
+
export async function imageToDataUrl(filePath) {
|
|
205
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
206
|
+
const mime = MIME_BY_EXT[ext] || 'image/png'
|
|
207
|
+
const buf = await fsp.readFile(filePath)
|
|
208
|
+
return `data:${mime};base64,${buf.toString('base64')}`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export default { readClipboardImage, checkImageFile, imageToDataUrl, formatBytes }
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// src/cli/ai/images.js 单元测试。
|
|
16
|
+
// 不覆盖 readClipboardImage(依赖真实剪贴板与平台工具,CI 上不可控),
|
|
17
|
+
// 只测纯函数与本地文件逻辑。
|
|
18
|
+
import { test } from 'node:test'
|
|
19
|
+
import assert from 'node:assert/strict'
|
|
20
|
+
import { promises as fsp } from 'node:fs'
|
|
21
|
+
import os from 'node:os'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
import { formatBytes, checkImageFile, imageToDataUrl } from './images.js'
|
|
24
|
+
|
|
25
|
+
// 最小合法 PNG(1x1 透明像素),base64 编码共 68 字节
|
|
26
|
+
const PNG_1X1_B64 =
|
|
27
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
|
|
28
|
+
|
|
29
|
+
test('formatBytes: B / KB / MB 三档', () => {
|
|
30
|
+
assert.equal(formatBytes(0), '0 B')
|
|
31
|
+
assert.equal(formatBytes(512), '512 B')
|
|
32
|
+
assert.equal(formatBytes(2048), '2.0 KB')
|
|
33
|
+
assert.equal(formatBytes(5 * 1024 * 1024), '5.0 MB')
|
|
34
|
+
assert.equal(formatBytes(undefined), '0 B')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('checkImageFile: 不存在的路径返回 null', async () => {
|
|
38
|
+
assert.equal(await checkImageFile(path.join(os.tmpdir(), 'g-ai-no-such-file.png')), null)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('checkImageFile: 不支持的扩展名返回 null', async () => {
|
|
42
|
+
const f = path.join(os.tmpdir(), `g-ai-test-${Date.now()}.txt`)
|
|
43
|
+
await fsp.writeFile(f, 'hello')
|
|
44
|
+
try {
|
|
45
|
+
assert.equal(await checkImageFile(f), null)
|
|
46
|
+
} finally {
|
|
47
|
+
await fsp.unlink(f)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('checkImageFile: 合法 PNG 返回路径与字节数', async () => {
|
|
52
|
+
const f = path.join(os.tmpdir(), `g-ai-test-${Date.now()}.png`)
|
|
53
|
+
const pngBuf = Buffer.from(PNG_1X1_B64, 'base64')
|
|
54
|
+
await fsp.writeFile(f, pngBuf)
|
|
55
|
+
try {
|
|
56
|
+
const r = await checkImageFile(f)
|
|
57
|
+
assert.ok(r, '应识别 PNG')
|
|
58
|
+
assert.equal(r.path, f)
|
|
59
|
+
assert.equal(r.bytes, pngBuf.length)
|
|
60
|
+
} finally {
|
|
61
|
+
await fsp.unlink(f)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('checkImageFile: 空文件返回 null', async () => {
|
|
66
|
+
const f = path.join(os.tmpdir(), `g-ai-test-empty-${Date.now()}.png`)
|
|
67
|
+
await fsp.writeFile(f, '')
|
|
68
|
+
try {
|
|
69
|
+
assert.equal(await checkImageFile(f), null)
|
|
70
|
+
} finally {
|
|
71
|
+
await fsp.unlink(f)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('imageToDataUrl: 生成 data URL 且 base64 内容可还原', async () => {
|
|
76
|
+
const f = path.join(os.tmpdir(), `g-ai-test-du-${Date.now()}.png`)
|
|
77
|
+
const original = Buffer.from(PNG_1X1_B64, 'base64')
|
|
78
|
+
await fsp.writeFile(f, original)
|
|
79
|
+
try {
|
|
80
|
+
const url = await imageToDataUrl(f)
|
|
81
|
+
assert.ok(url.startsWith('data:image/png;base64,'), `应为 png data URL,实际前缀: ${url.slice(0, 30)}`)
|
|
82
|
+
const b64 = url.slice('data:image/png;base64,'.length)
|
|
83
|
+
assert.ok(Buffer.from(b64, 'base64').equals(original), 'base64 应能还原原文件')
|
|
84
|
+
} finally {
|
|
85
|
+
await fsp.unlink(f)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('imageToDataUrl: jpg 扩展名映射到 image/jpeg', async () => {
|
|
90
|
+
const f = path.join(os.tmpdir(), `g-ai-test-du-${Date.now()}.jpg`)
|
|
91
|
+
await fsp.writeFile(f, Buffer.from('fake-jpeg-bytes'))
|
|
92
|
+
try {
|
|
93
|
+
const url = await imageToDataUrl(f)
|
|
94
|
+
assert.ok(url.startsWith('data:image/jpeg;base64,'))
|
|
95
|
+
} finally {
|
|
96
|
+
await fsp.unlink(f)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// g ai 智能体的危险命令守卫 — 这是智能体的**唯一硬性限制**。
|
|
16
|
+
//
|
|
17
|
+
// 设计原则(用户明确授权的权限模型):
|
|
18
|
+
// - 工作目录内:所有操作直接放行
|
|
19
|
+
// - 其他目录:同样可读写、可修改
|
|
20
|
+
// - 唯一红线:不得执行"会把系统搞崩"的命令 —— 本模块负责拦截
|
|
21
|
+
//
|
|
22
|
+
// 拦截清单刻意保持克制,只覆盖不可逆的系统级破坏:
|
|
23
|
+
// 1. 递归删除根目录/系统目录/用户 home(rm -rf / · rd /s C:\ · Remove-Item C:\ -Recurse)
|
|
24
|
+
// 2. 格式化/抹盘(format · mkfs · diskpart · dd of=/dev/* · 重定向写块设备)
|
|
25
|
+
// 3. 关机/重启(shutdown · reboot · poweroff · halt · Stop-Computer)
|
|
26
|
+
// 4. fork bomb
|
|
27
|
+
// 5. 删除注册表主键(reg delete HKLM 等)
|
|
28
|
+
// 6. 引导配置破坏(bcdedit · bootrec)
|
|
29
|
+
// 7. 递归改根目录权限/属主(chmod -R / · chown -R /)
|
|
30
|
+
//
|
|
31
|
+
// 像 `rm -rf node_modules`、`git clean -fdx`、`del /s /q build` 这类项目内
|
|
32
|
+
// 破坏性操作**不拦截** —— 那是用户的正常工作流,模型自己会判断。
|
|
33
|
+
//
|
|
34
|
+
// 纯函数,不依赖 fs / child_process,便于单测。
|
|
35
|
+
|
|
36
|
+
// rm / del / rd 等命令的"危险目标"判定:
|
|
37
|
+
// 递归删除时,目标落在这个集合里才算系统级破坏
|
|
38
|
+
const ROOT_TARGETS_UNIX = new Set([
|
|
39
|
+
'/', '/*', '~', '~/', '$HOME', '${HOME}',
|
|
40
|
+
'/etc', '/usr', '/bin', '/sbin', '/boot', '/lib', '/lib64',
|
|
41
|
+
'/var', '/opt', '/root', '/System', '/Library', '/Applications',
|
|
42
|
+
'/windows', '/Windows', '/WINDOWS',
|
|
43
|
+
'%SystemDrive%', '%systemroot%', '%SystemRoot%', '%windir%',
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
// 判断一个 token 是否是"盘符根"(C:\ C:/ C: \ /c /c/ D:\ 等)
|
|
47
|
+
// Git Bash 下盘符挂载为 /c /d 形式,cmd/PowerShell 下是 C:\ 形式
|
|
48
|
+
function isDriveRootToken(token) {
|
|
49
|
+
const t = token.replace(/["']/g, '')
|
|
50
|
+
// C:\ C:/ C:\* C:/ C:
|
|
51
|
+
if (/^[a-zA-Z]:[\\/]?\*?(\.\*)?$/.test(t)) return true
|
|
52
|
+
// Git Bash 挂载点: /c /c/ /d /d/ ...(仅单字母)
|
|
53
|
+
if (/^\/[a-zA-Z]\/?\*?$/.test(t)) return true
|
|
54
|
+
// 反斜杠根
|
|
55
|
+
if (t === '\\' || t === '\\*') return true
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 判断 token 是否是 Unix 根/系统目录目标
|
|
60
|
+
function isRootTargetToken(token) {
|
|
61
|
+
const t = token.replace(/["']/g, '')
|
|
62
|
+
if (ROOT_TARGETS_UNIX.has(t)) return true
|
|
63
|
+
// 带尾通配:/usr/* /etc/* 之类
|
|
64
|
+
if (t.endsWith('/*') && ROOT_TARGETS_UNIX.has(t.slice(0, -2))) return true
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 从命令串中抽出所有 "命令段"(按 ; && || | 切分),返回小写化前的原文段
|
|
69
|
+
function splitCommandSegments(cmd) {
|
|
70
|
+
return String(cmd).split(/&&|\|\||[;|]/).map(s => s.trim()).filter(Boolean)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 解析命令段:返回小写命令名(跳过 sudo / doas 前缀) + 原始参数串
|
|
74
|
+
// 注意:args 必须用匹配到的原始串长度切片,不能用 indexOf(小写名) —
|
|
75
|
+
// 混合大小写命令(Remove-Item 等)会 indexOf 失败导致切片错位
|
|
76
|
+
function parseSegment(seg) {
|
|
77
|
+
const m = seg.match(/^(?:sudo|doas)\s+(\S+)|^(\S+)/)
|
|
78
|
+
const name = (m?.[1] || m?.[2] || '')
|
|
79
|
+
const args = m ? seg.slice(m[0].length) : seg
|
|
80
|
+
return { head: name.toLowerCase(), args }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 段是否有递归/强制类 flag(-r -R -f -rf -fr --recursive /s 等)
|
|
84
|
+
function hasRecursiveFlag(args, style) {
|
|
85
|
+
if (style === 'unix') {
|
|
86
|
+
return /(^|\s)-[a-zA-Z]*(r|R)[a-zA-Z]*(?=\s|$)/.test(args) || /--recursive\b/.test(args)
|
|
87
|
+
}
|
|
88
|
+
// cmd 风格: /s
|
|
89
|
+
return /(^|\s)\/s(?=\s|$)/i.test(args)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 检查一条 shell 命令是否属于"会把系统搞崩"的红线操作。
|
|
94
|
+
*
|
|
95
|
+
* @param {unknown} cmd - 待检查的命令字符串
|
|
96
|
+
* @returns {{ blocked: boolean, reason: string|null }}
|
|
97
|
+
* blocked=true 时 reason 给出中文原因(会回喂给模型,让它换方案)
|
|
98
|
+
*/
|
|
99
|
+
export function checkDangerousCommand(cmd) {
|
|
100
|
+
if (typeof cmd !== 'string' || !cmd.trim()) {
|
|
101
|
+
return { blocked: false, reason: null }
|
|
102
|
+
}
|
|
103
|
+
const text = cmd
|
|
104
|
+
|
|
105
|
+
// ── 1. fork bomb(经典 :(){ :|:& };: 及变体)──
|
|
106
|
+
if (/:\s*\(\s*\)\s*\{[^}]*:\s*\|\s*:[^}]*&[^}]*\}/.test(text)) {
|
|
107
|
+
return { blocked: true, reason: '检测到 fork bomb(:(){ :|:& };:),会耗尽系统进程资源' }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── 2. dd / 重定向直接写块设备 ──
|
|
111
|
+
if (/\bdd\b[^;&|]*\bof=["']?\/dev\//i.test(text)) {
|
|
112
|
+
return { blocked: true, reason: '检测到 dd 直接写块设备(of=/dev/*),会物理抹掉磁盘数据' }
|
|
113
|
+
}
|
|
114
|
+
if (/>\s*\/dev\/(sd|hd|nvme|mmcblk|disk)/i.test(text)) {
|
|
115
|
+
return { blocked: true, reason: '检测到重定向写块设备(/dev/sd* 等),会物理抹掉磁盘数据' }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── 3. 注册表主键删除 ──
|
|
119
|
+
if (/\breg\s+delete\s+"?(HKLM|HKCR|HKEY_LOCAL_MACHINE|HKEY_CLASSES_ROOT)\b/i.test(text)) {
|
|
120
|
+
return { blocked: true, reason: '检测到删除注册表主键(HKLM/HKCR),会破坏系统配置' }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── 4. 逐段检查命令名级红线 ──
|
|
124
|
+
for (const seg of splitCommandSegments(text)) {
|
|
125
|
+
const { head, args } = parseSegment(seg)
|
|
126
|
+
|
|
127
|
+
// 关机/重启/停机
|
|
128
|
+
if (/^(shutdown|reboot|poweroff|halt|init|telinit|stop-computer|restart-computer)$/i.test(head)) {
|
|
129
|
+
// init 只有 0/6 才是关机/重启
|
|
130
|
+
if (/^(init|telinit)$/i.test(head) && !/\b[06]\b/.test(args)) continue
|
|
131
|
+
return { blocked: true, reason: `检测到关机/重启命令(${head}),会中断整台机器` }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 格式化/分区/引导破坏
|
|
135
|
+
if (/^(format|format\.com|mkfs(\.\w+)?|diskpart|bcdedit|bootrec|wipefs|fdisk|parted)$/i.test(head)) {
|
|
136
|
+
return { blocked: true, reason: `检测到磁盘格式化/分区/引导操作(${head}),会造成不可逆数据丢失` }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// rm 递归删除系统目标
|
|
140
|
+
if (head === 'rm') {
|
|
141
|
+
if (hasRecursiveFlag(args, 'unix')) {
|
|
142
|
+
const tokens = args.split(/\s+/).filter(t => t && !t.startsWith('-'))
|
|
143
|
+
for (const tok of tokens) {
|
|
144
|
+
if (isDriveRootToken(tok) || isRootTargetToken(tok)) {
|
|
145
|
+
return { blocked: true, reason: `检测到 rm 递归删除系统根目录/盘符根(${tok}),会摧毁整个文件系统` }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// shred 直接抹设备
|
|
153
|
+
if (head === 'shred' && /\/dev\//.test(args)) {
|
|
154
|
+
return { blocked: true, reason: '检测到 shred 抹除块设备,会物理摧毁磁盘数据' }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// rd / rmdir /s 删盘符根或系统目录(cmd)
|
|
158
|
+
if (/^(rd|rmdir)$/i.test(head)) {
|
|
159
|
+
if (hasRecursiveFlag(args, 'cmd')) {
|
|
160
|
+
const tokens = args.split(/\s+/).filter(t => t && !t.startsWith('/'))
|
|
161
|
+
for (const tok of tokens) {
|
|
162
|
+
const clean = tok.replace(/["']/g, '').replace(/\\\*$/, '').replace(/\\\.\*$/, '')
|
|
163
|
+
if (isDriveRootToken(clean) || /^%system|^%win/i.test(clean) || /^[a-zA-Z]:[\\/]+(windows|program files)/i.test(clean)) {
|
|
164
|
+
return { blocked: true, reason: `检测到 rd /s 删除盘符根/系统目录(${tok}),会摧毁整个磁盘内容` }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// del /s 清盘符根(cmd)
|
|
172
|
+
if (head === 'del' || head === 'erase') {
|
|
173
|
+
if (hasRecursiveFlag(args, 'cmd')) {
|
|
174
|
+
const tokens = args.split(/\s+/).filter(t => t && !t.startsWith('/'))
|
|
175
|
+
for (const tok of tokens) {
|
|
176
|
+
const clean = tok.replace(/["']/g, '')
|
|
177
|
+
// C:\* C:\*.* C:\ C:/
|
|
178
|
+
if (isDriveRootToken(clean) || /^[a-zA-Z]:[\\/]\*(\.\*)?$/.test(clean)) {
|
|
179
|
+
return { blocked: true, reason: `检测到 del /s 清空盘符根(${tok}),会删除整个磁盘文件` }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// chmod / chown 递归动根目录
|
|
187
|
+
if (head === 'chmod' || head === 'chown' || head === 'chgrp') {
|
|
188
|
+
if (hasRecursiveFlag(args, 'unix')) {
|
|
189
|
+
const tokens = args.split(/\s+/).filter(t => t && !t.startsWith('-'))
|
|
190
|
+
for (const tok of tokens) {
|
|
191
|
+
if (isRootTargetToken(tok) || tok === '/') {
|
|
192
|
+
return { blocked: true, reason: `检测到 ${head} -R 递归改根目录权限/属主(${tok}),会让系统无法正常运行` }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// PowerShell Remove-Item -Recurse 删根
|
|
200
|
+
if (head === 'remove-item' || head === 'ri') {
|
|
201
|
+
if (/\-r(ecurse)?\b/i.test(args)) {
|
|
202
|
+
if (/\$env:(SystemDrive|windir|SystemRoot)/i.test(args)
|
|
203
|
+
|| /(^|\s)["']?[a-zA-Z]:[\\/]\*?["']?(\s|$)/.test(args)
|
|
204
|
+
|| /(^|\s)["']?~["']?(\s|$)/.test(args)) {
|
|
205
|
+
return { blocked: true, reason: '检测到 Remove-Item -Recurse 删除盘符根/系统目录/home,会摧毁大量数据' }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { blocked: false, reason: null }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export default { checkDangerousCommand }
|