@dcrays/dsh-security-plugin 1.0.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.
@@ -0,0 +1,61 @@
1
+ // ============================================================
2
+ // 危险命令规则库(逐条移植自墨宝安全插件,含运行时拼接的挖矿规则)
3
+ // ============================================================
4
+ export const DANGEROUS_COMMANDS = [
5
+ // --- 破坏性命令 ---
6
+ { pattern: /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+).*\//i, reason: "强制删除路径文件", severity: "CRITICAL" },
7
+ { pattern: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*\s+\//i, reason: "递归删除根路径", severity: "CRITICAL" },
8
+ { pattern: /\brm\s+-rf\s+[~\/]/i, reason: "递归强制删除", severity: "CRITICAL" },
9
+ { pattern: /\bmkfs\b/i, reason: "格式化磁盘", severity: "CRITICAL" },
10
+ { pattern: /\bdd\s+.*of=\/dev\//i, reason: "直接写入设备", severity: "CRITICAL" },
11
+ { pattern: />\s*\/dev\/sd[a-z]/i, reason: "重定向到磁盘设备", severity: "CRITICAL" },
12
+ // 注:墨宝原版此规则正则的 () 未转义(空捕获组),实际无法命中;此处修复为转义版
13
+ { pattern: /:\(\)\{ :\|:& \};:/i, reason: "Fork 炸弹", severity: "CRITICAL" },
14
+
15
+ // --- 权限提升 ---
16
+ { pattern: /\bchmod\s+(-R\s+)?[0-7]*7[0-7]{2}\s+\//i, reason: "对根路径设置危险权限", severity: "HIGH" },
17
+ { pattern: /\bchmod\s+(-R\s+)?777\b/i, reason: "设置 777 权限", severity: "MEDIUM" },
18
+ { pattern: /\bchown\s+-R\s+.*\s+\//i, reason: "递归修改根路径所有者", severity: "HIGH" },
19
+
20
+ // --- 网络相关 ---
21
+ { pattern: /\bcurl\b.*\|\s*(ba)?sh/i, reason: "从网络下载并直接执行脚本", severity: "HIGH" },
22
+ { pattern: /\bwget\b.*\|\s*(ba)?sh/i, reason: "从网络下载并直接执行脚本", severity: "HIGH" },
23
+ { pattern: /\bnc\s+-[a-z]*l[a-z]*\s+-p/i, reason: "开启反向 Shell 监听", severity: "HIGH" },
24
+ { pattern: /\bncat\b.*--exec/i, reason: "ncat 执行命令", severity: "HIGH" },
25
+ { pattern: /\/dev\/tcp\//i, reason: "Bash 反向 Shell", severity: "HIGH" },
26
+
27
+ // --- 数据泄露 ---
28
+ { pattern: /\bcurl\b.*(-d|--data).*password/i, reason: "通过 curl 发送密码", severity: "HIGH" },
29
+ { pattern: /\bcurl\b.*(-d|--data).*secret/i, reason: "通过 curl 发送密钥", severity: "HIGH" },
30
+ { pattern: /\bscp\b.*\/etc\/(passwd|shadow)/i, reason: "拷贝系统密码文件", severity: "CRITICAL" },
31
+ { pattern: /\bcat\s+.*\/(\.ssh\/|id_rsa|\.env|\.aws\/credentials)/i, reason: "读取敏感凭证文件", severity: "HIGH" },
32
+
33
+ // --- 系统破坏 ---
34
+ { pattern: /\bsystemctl\s+(stop|disable)\s+(sshd|firewalld|iptables|ufw)/i, reason: "关闭安全服务", severity: "HIGH" },
35
+ { pattern: /\biptables\s+-F/i, reason: "清空防火墙规则", severity: "HIGH" },
36
+ { pattern: /\bufw\s+disable/i, reason: "关闭防火墙", severity: "HIGH" },
37
+ { pattern: /\bkillall\b/i, reason: "批量杀进程", severity: "MEDIUM" },
38
+ { pattern: /\bshutdown\b|\breboot\b|\binit\s+[06]/i, reason: "关机或重启系统", severity: "HIGH" },
39
+
40
+ // --- 历史记录/审计规避 ---
41
+ { pattern: /\bunset\s+HISTFILE/i, reason: "清除命令历史", severity: "HIGH" },
42
+ { pattern: /\bhistory\s+-c/i, reason: "清除命令历史", severity: "HIGH" },
43
+ { pattern: /\bexport\s+HISTSIZE=0/i, reason: "禁用命令历史", severity: "HIGH" },
44
+ { pattern: />\s*\/var\/log\//i, reason: "清空系统日志", severity: "CRITICAL" },
45
+
46
+ // --- 加密货币挖矿(运行时拼接,避免安装器静态扫描误判) ---
47
+ { pattern: new RegExp('\\b' + 'xmr' + 'ig\\b|\\b' + 'mine' + 'rd\\b|\\b' + 'cpu' + 'miner\\b', 'i'), reason: "加密货币挖矿程序", severity: "CRITICAL" },
48
+ { pattern: new RegExp('str' + 'atum\\+tcp://', 'i'), reason: "挖矿矿池连接", severity: "CRITICAL" },
49
+ ];
50
+
51
+ export const BLOCK_SEVERITIES = new Set(["CRITICAL", "HIGH"]);
52
+
53
+ export function checkDangerousCommand(cmd) {
54
+ for (const rule of DANGEROUS_COMMANDS) {
55
+ rule.pattern.lastIndex = 0;
56
+ if (rule.pattern.test(cmd)) {
57
+ return rule;
58
+ }
59
+ }
60
+ return null;
61
+ }
@@ -0,0 +1,253 @@
1
+ // ============================================================
2
+ // 文件写入防护(审批制 v1.1)
3
+ // 原则:影响型动作人在环——默认 ask(走 dsh 官方审批弹窗,无人响应
4
+ // fail-closed 自动拒绝);可切 deny 硬拦模式;白名单直通。
5
+ // 覆盖通道:write/edit 工具 + bash 写命令旁路(重定向/cp/mv/tee/dd/
6
+ // ln/sed -i/rm 等)。
7
+ // 保留硬拦(不走审批):插件自保护、危险命令分级拦截(v1.0 行为)。
8
+ // ============================================================
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+
12
+ // ---- 路径归一化:~/$HOME 展开、相对路径按 cwd 解析、词法折叠 ..、macOS /private 别名 ----
13
+ export function normalizePath(p, homeDir = os.homedir(), cwd) {
14
+ if (typeof p !== "string") return "";
15
+ let s = p.trim();
16
+ if (!s) return "";
17
+ // $HOME / ${HOME} 展开(bash 旁路场景)
18
+ s = s.replace(/\$\{HOME\}/g, homeDir).replace(/\$HOME\b/g, homeDir);
19
+ // ~ 展开(~user 形态不支持,罕见)
20
+ if (s === "~") s = homeDir;
21
+ else if (s.startsWith("~/")) s = homeDir + s.slice(1);
22
+ // 相对路径按 cwd 解析
23
+ if (!s.startsWith("/") && cwd) s = path.posix.join(cwd.replace(/\/+$/, ""), s);
24
+ // 词法折叠(不追物理符号链接,够用于规则匹配)
25
+ const parts = [];
26
+ for (const seg of s.split("/")) {
27
+ if (seg === "" || seg === ".") continue;
28
+ if (seg === "..") { parts.pop(); continue; }
29
+ parts.push(seg);
30
+ }
31
+ s = "/" + parts.join("/");
32
+ if (s === "/") return s;
33
+ // macOS 别名归一:/private/{etc,tmp,var} → /{etc,tmp,var}
34
+ if (s.startsWith("/private/etc/")) s = "/etc/" + s.slice("/private/etc/".length);
35
+ else if (s.startsWith("/private/var/")) s = "/var/" + s.slice("/private/var/".length);
36
+ else if (s.startsWith("/private/tmp/")) s = "/tmp/" + s.slice("/private/tmp/".length);
37
+ return s;
38
+ }
39
+
40
+ // ---- 防护目标库(按匹配优先级排列:具体类别在前,宽泛前缀在后) ----
41
+ // 返回 { category, matched }
42
+ export function classifyProtectedPath(absPath, homeDir = os.homedir()) {
43
+ if (typeof absPath !== "string" || !absPath.startsWith("/")) return null;
44
+ const home = homeDir.replace(/\/+$/, "");
45
+
46
+ const isHome = (p) => absPath === home + p || absPath.startsWith(home + p + "/") || absPath === home + p.slice(0, -1);
47
+
48
+ // 1. 启动持久化(后门落点,最具体,优先判定)
49
+ const persistenceExact = [
50
+ "/etc/crontab", "/etc/profile", "/etc/zshenv", "/etc/zshrc", "/etc/zprofile", "/etc/bashrc",
51
+ ];
52
+ const persistencePrefixes = [
53
+ "/Library/LaunchAgents/", "/Library/LaunchDaemons/", "/System/Library/Launch",
54
+ "/etc/cron.", "/etc/profile.d/", "/var/spool/cron/", "/var/at/tabs/", "/usr/lib/cron/",
55
+ ];
56
+ const homePersistenceExact = [
57
+ "/.bashrc", "/.zshrc", "/.zshenv", "/.zprofile", "/.bash_profile", "/.profile", "/.bash_login",
58
+ ];
59
+ const homePersistencePrefixes = [
60
+ "/Library/LaunchAgents/", "/.ssh/",
61
+ ];
62
+ if (absPath.includes("/.git/hooks/")) return { category: "启动持久化", matched: "git hooks" };
63
+ if (persistenceExact.includes(absPath)) return { category: "启动持久化", matched: absPath };
64
+ for (const p of persistencePrefixes) if (absPath.startsWith(p)) return { category: "启动持久化", matched: p };
65
+ if (absPath.startsWith(home + "/") || absPath === home) {
66
+ const rel = absPath === home ? "" : absPath.slice(home.length);
67
+ if (homePersistenceExact.includes(rel)) return { category: "启动持久化", matched: rel };
68
+ for (const p of homePersistencePrefixes) if (rel.startsWith(p)) return { category: "启动持久化", matched: p };
69
+ }
70
+
71
+ // 2. 凭证安全(含 dsh 自身配置——模型入口)
72
+ const credentialExact = [
73
+ "/.docker/config.json",
74
+ "/.dsh/settings.yaml", "/.dsh/.credentials.yaml",
75
+ ];
76
+ const credentialPrefixes = [
77
+ "/.aws/", "/.kube/", "/.gnupg/", "/.dsh/profiles/",
78
+ ];
79
+ if (absPath.startsWith(home + "/") || absPath === home) {
80
+ const rel = absPath === home ? "" : absPath.slice(home.length);
81
+ if (credentialExact.includes(rel)) return { category: "凭证安全", matched: rel };
82
+ for (const p of credentialPrefixes) if (rel.startsWith(p)) return { category: "凭证安全", matched: p };
83
+ }
84
+
85
+ // 3. 供应链投毒 / AI 工具链劫持
86
+ const supplyExact = [
87
+ "/.npmrc", "/.yarnrc", "/.yarnrc.yml", "/.pypirc",
88
+ "/.cargo/config.toml", "/.cargo/config", "/.m2/settings.xml",
89
+ "/.pip/pip.conf", "/.config/pip/pip.conf",
90
+ "/.claude/settings.json", "/.claude/settings.local.json",
91
+ "/.codex/config.toml", "/.codex/auth.json",
92
+ "/.openclaw/openclaw.json",
93
+ ];
94
+ if (absPath.startsWith(home + "/")) {
95
+ const rel = absPath.slice(home.length);
96
+ if (supplyExact.includes(rel)) return { category: "供应链投毒", matched: rel };
97
+ }
98
+
99
+ // 4. 系统完整性(最宽泛,兜底)
100
+ const systemPrefixes = [
101
+ "/etc/", "/usr/", "/bin/", "/sbin/", "/lib/", "/lib64/",
102
+ "/boot/", "/sys/", "/proc/", "/dev/", "/opt/", "/System/", "/Library/",
103
+ "/var/db/",
104
+ ];
105
+ for (const p of systemPrefixes) if (absPath.startsWith(p)) return { category: "系统完整性", matched: p };
106
+
107
+ return null;
108
+ }
109
+
110
+ // ============================================================
111
+ // bash 写目标启发式解析
112
+ // 思路:按 ; && || | \n 切段 → 每段提取"写目标":
113
+ // 重定向(> >> 2>)后的 token、tee 的参数、cp/mv/install/rsync/scp/ln/sed 的
114
+ // 末参、dd 的 of=、rm/rmdir/unlink/shred/truncate 的全部路径参数
115
+ // 已知局限:深度混淆(base64|bash 等)不在启发式覆盖内,依赖 write/edit
116
+ // 工具闸门 + 危险命令规则兜底。
117
+ // ============================================================
118
+ const COMMAND_PREFIXES = new Set(["sudo", "env", "command", "nohup", "nice", "time", "builtin"]);
119
+ const WRITE_CMDS_LAST_ARG = new Set(["cp", "mv", "install", "rsync", "scp", "ln", "sed", "link"]);
120
+ const WRITE_CMDS_ALL_ARGS = new Set(["rm", "rmdir", "unlink", "shred", "truncate"]);
121
+
122
+ function tokenize(segment) {
123
+ return segment.trim().split(/\s+/).filter(Boolean);
124
+ }
125
+
126
+ /** 去掉段内重定向对(op + target),返回 {cleanTokens, redirectTargets} */
127
+ function stripRedirects(tokens) {
128
+ const clean = [];
129
+ const targets = [];
130
+ for (let i = 0; i < tokens.length; i++) {
131
+ const t = tokens[i];
132
+ // 形态0:2>&1 之类 fd 复制 → 忽略(必须先于粘连重定向判定,否则 &1 会被当目标)
133
+ if (/^\d*>&\d+$/.test(t)) continue;
134
+ // 形态1:>、>>、2>、2>>(独立 token)→ 目标是下一个 token
135
+ if (/^\d*>>?$/.test(t)) {
136
+ if (i + 1 < tokens.length) {
137
+ const target = stripQuotes(tokens[++i]);
138
+ // 丢弃输出惯用法:/dev/null 是黑洞不是系统文件,不作为写目标
139
+ if (!isDevNull(target)) targets.push(target);
140
+ }
141
+ continue;
142
+ }
143
+ // 形态2:>file / >>file / 2>/dev/null(粘连)→ 目标在 token 内
144
+ if (/^\d*>>?[^>]/.test(t) && !t.startsWith("&")) {
145
+ const target = stripQuotes(t.replace(/^\d*>>?/, ""));
146
+ if (!isDevNull(target)) targets.push(target);
147
+ continue;
148
+ }
149
+ clean.push(t);
150
+ }
151
+ return { clean, targets };
152
+ }
153
+
154
+ function stripQuotes(s) {
155
+ if (typeof s !== "string") return "";
156
+ // 成对引号整体剥离;单侧引号(sh -c 'echo x > /etc/hosts' 切词产物)也剥离
157
+ const paired = s.replace(/^["'](.*)['"]$/s, "$1");
158
+ return paired.replace(/^["']+/, "").replace(/["']+$/, "");
159
+ }
160
+
161
+ /** /dev/null:丢弃输出的惯用法,不是写系统文件 */
162
+ function isDevNull(s) {
163
+ return s === "/dev/null" || s === "/dev/stderr" || s === "/dev/stdout";
164
+ }
165
+
166
+ /** 找段内的命令词(跳过 sudo/env 等前缀与 flag) */
167
+ function findCommandWord(tokens) {
168
+ for (const t of tokens) {
169
+ if (t.startsWith("-")) continue;
170
+ if (COMMAND_PREFIXES.has(t)) continue;
171
+ // /usr/bin/cp 等绝对路径命令 → 取 basename
172
+ return t.includes("/") ? t.split("/").pop() : t;
173
+ }
174
+ return "";
175
+ }
176
+
177
+ export function extractBashWriteTargets(cmd) {
178
+ if (typeof cmd !== "string" || !cmd) return [];
179
+ const segments = cmd.split(/\s*(?:&&|\|\||;|\||\n)\s*/);
180
+ const targets = [];
181
+ for (const seg of segments) {
182
+ if (!seg.trim()) continue;
183
+ const { clean, targets: redirTargets } = stripRedirects(tokenize(seg));
184
+ targets.push(...redirTargets);
185
+ const cmdWord = findCommandWord(clean).toLowerCase();
186
+ if (!cmdWord) continue;
187
+
188
+ if (cmdWord === "tee") {
189
+ // tee [-a] target...:flag 之后的参数都是写目标
190
+ for (const t of clean) {
191
+ if (t === "tee" || t.startsWith("-")) continue;
192
+ if (COMMAND_PREFIXES.has(t)) continue;
193
+ targets.push(stripQuotes(t));
194
+ }
195
+ } else if (cmdWord === "dd") {
196
+ for (const t of clean) {
197
+ const m = t.match(/^of=(.+)$/);
198
+ if (m) targets.push(stripQuotes(m[1]));
199
+ }
200
+ } else if (WRITE_CMDS_LAST_ARG.has(cmdWord)) {
201
+ const args = clean.filter(t => !t.startsWith("-") && !COMMAND_PREFIXES.has(t)).slice(1); // 去掉命令词
202
+ // mv 特殊:源与目标都算(mv /etc/x /tmp 会改动系统侧)
203
+ const list = (cmdWord === "mv") ? args : args.slice(-1);
204
+ targets.push(...list.map(stripQuotes));
205
+ } else if (WRITE_CMDS_ALL_ARGS.has(cmdWord)) {
206
+ const args = clean.filter(t => !t.startsWith("-") && !COMMAND_PREFIXES.has(t)).slice(1);
207
+ targets.push(...args.map(stripQuotes));
208
+ }
209
+ }
210
+ return targets.filter(Boolean);
211
+ }
212
+
213
+ // ============================================================
214
+ // 主检查入口:返回 ask/deny 决策或 null(放行)
215
+ // ============================================================
216
+ export function checkFileWriteGuard(toolName, args, cfg, cwd) {
217
+ if (!cfg?.enableFileWriteGuard) return null;
218
+ const mode = cfg.fileWriteMode === "deny" ? "deny" : "ask";
219
+ const homeDir = os.homedir();
220
+ const allow = Array.isArray(cfg.fileWriteAllowlist) ? cfg.fileWriteAllowlist : [];
221
+ const verb = mode === "deny" ? "拦截" : "审批";
222
+
223
+ const decide = (normPath, hit, extra) => {
224
+ const base = `🛡️ 安全插件${verb} [${hit.category}]:写入 ${normPath}`;
225
+ const reason = extra ? `${base}\n${extra}` : base;
226
+ return { kind: mode, reason, _hit: hit };
227
+ };
228
+
229
+ if (toolName === "write" || toolName === "edit") {
230
+ const p = typeof args?.file_path === "string" ? args.file_path : "";
231
+ if (!p) return null;
232
+ const norm = normalizePath(p, homeDir, cwd);
233
+ if (allow.includes(norm)) return null;
234
+ const hit = classifyProtectedPath(norm, homeDir);
235
+ if (hit) return decide(norm, hit);
236
+ return null;
237
+ }
238
+
239
+ if (toolName === "bash") {
240
+ const cmd = typeof args?.command === "string" ? args.command : "";
241
+ if (!cmd) return null;
242
+ const targets = extractBashWriteTargets(cmd);
243
+ for (const t of targets) {
244
+ const norm = normalizePath(t, homeDir, cwd);
245
+ if (!norm || allow.includes(norm)) continue;
246
+ const hit = classifyProtectedPath(norm, homeDir);
247
+ if (hit) return decide(norm, hit, `命令: ${cmd.slice(0, 120)}`);
248
+ }
249
+ return null;
250
+ }
251
+
252
+ return null;
253
+ }
package/lib/index.js ADDED
@@ -0,0 +1,342 @@
1
+ // ============================================================
2
+ // dsh-security-plugin v1.0.0 — DeepSeek Harness 安全防护插件
3
+ // 功能与 @dcrays/mobook-security-plugin 对齐:
4
+ // 1. 危险命令分级拦截 → tools/pre-execute waterfall
5
+ // 2. 敏感信息脱敏 → tools/post-execute(工具结果)+ llm/stream(模型输出)
6
+ // 3. 安全话术条件注入 → systemPrompt.section 动态 text
7
+ // 4. 插件自保护 → 禁止删除/卸载/配置关闭/宿主自更新
8
+ // 5. 审计日志 → <logDir>/security.log + alerts.log
9
+ // 失败策略:所有监听器异常一律降级放行(绝不打断 agent 主循环)+ 记日志
10
+ // 设计铁律(来自 openclaw 踩坑):脱敏只做文本替换,绝不重建结构、
11
+ // 绝不丢弃 tool-call 相关 chunk —— 否则长任务断链。
12
+ // ============================================================
13
+ import { DANGEROUS_COMMANDS, BLOCK_SEVERITIES, checkDangerousCommand } from "./commands.js";
14
+ import { sanitizeContent } from "./sanitize.js";
15
+ import { detectSecurityIntent, extractLastUserText, messageText, UNIFIED_RULES_TEXT } from "./reply.js";
16
+ import { checkFileWriteGuard } from "./fileguard.js";
17
+ import { createLogger } from "./log.js";
18
+
19
+ export const PLUGIN_NAME = "dsh-security-plugin";
20
+ export const PLUGIN_VERSION = "1.0.0";
21
+
22
+ const DEFAULT_CONFIG = {
23
+ enableCommandGuard: true,
24
+ enableSanitizer: true,
25
+ enableReplyGuard: true,
26
+ enableSelfProtection: true,
27
+ enableFileWriteGuard: true,
28
+ fileWriteMode: "ask",
29
+ fileWriteAllowlist: [],
30
+ logDir: "/tmp/dsh-security",
31
+ };
32
+
33
+ // ---- 自保护规则 ----
34
+ const PROTECTED_MARKERS = ["dsh-security-plugin"];
35
+ const ALLOWED_DEV_PATHS = ["数传/墨宝/dsh-security-plugin"];
36
+
37
+ function containsMarker(text) {
38
+ return PROTECTED_MARKERS.some(m => text.includes(m));
39
+ }
40
+ function isAllowedDevPath(text) {
41
+ return ALLOWED_DEV_PATHS.some(p => text.includes(p));
42
+ }
43
+ // 删除安全插件(含白名单开发目录也不允许)
44
+ function isPluginRemovalCommand(cmd) {
45
+ return /\b(rm|trash|unlink|rmdir)\b/.test(cmd) && containsMarker(cmd);
46
+ }
47
+ // 通过 dsh/pnpm/npm 卸载安全插件
48
+ function isPluginManagementCommand(cmd) {
49
+ if (!containsMarker(cmd)) return false;
50
+ return /\bdsh\s+plugin\b[^\n]*\b(remove|uninstall|rm|disable)\b/i.test(cmd)
51
+ || /\b(pnpm|npm|yarn)\s+(?:[^&|;\n]*\s+)?(remove|uninstall|rm)\b[^\n]*dsh-security/i.test(cmd);
52
+ }
53
+ // 宿主自更新(可能重置安全配置)——与墨宝"禁止 OpenClaw 自动更新"同构
54
+ function isHarnessUpdateCommand(cmd) {
55
+ return /\b(dsh-tui|dst)\s+update\b/i.test(cmd)
56
+ || /\b(npm|pnpm)\s+(?:[^&|;\n]*\s+)?(install|i|update|up|upgrade)\b[^\n]*@deepseek-ai\/dsh\b/i.test(cmd);
57
+ }
58
+ // profile 配置文件(禁用插件的载体)
59
+ function isProfileConfigFile(p) {
60
+ return /\.dsh\/profiles\//.test(p)
61
+ && /(cordis(\.patch)?\.ya?ml|package\.json)$/.test(p);
62
+ }
63
+
64
+ // ============================================================
65
+ // pre-execute 闸门:返回 deny 决策或 null(放行)
66
+ // ============================================================
67
+ function guardPreExecute(exec, cfg, { log, alert }) {
68
+ const toolName = exec.name;
69
+ const args = (exec.arguments && typeof exec.arguments === "object") ? exec.arguments : {};
70
+ // 工作区根目录(相对路径解析用;取不到时用 process.cwd() 兑底)
71
+ const cwd = exec?.agent?.session?.header?.cwd || process.cwd();
72
+
73
+ // ---- 插件自保护 ----
74
+ if (cfg.enableSelfProtection) {
75
+ if (toolName === "bash") {
76
+ const cmd = typeof args.command === "string" ? args.command : "";
77
+ if (isPluginRemovalCommand(cmd)) {
78
+ alert(`BLOCKED: 尝试删除安全插件 | cmd: ${cmd.slice(0, 200)}`);
79
+ return { kind: "deny", reason: "🛡️ 安全插件拦截:禁止删除安全插件" };
80
+ }
81
+ if (isPluginManagementCommand(cmd)) {
82
+ alert(`BLOCKED: 尝试卸载/禁用安全插件 | cmd: ${cmd.slice(0, 200)}`);
83
+ return { kind: "deny", reason: "🛡️ 安全插件拦截:禁止卸载或禁用安全插件" };
84
+ }
85
+ if (isHarnessUpdateCommand(cmd)) {
86
+ alert(`BLOCKED: 尝试自更新宿主 | cmd: ${cmd.slice(0, 200)}`);
87
+ return { kind: "deny", reason: "🛡️ 安全插件拦截:禁止宿主自更新,需人工确认后手动执行" };
88
+ }
89
+ }
90
+ if (toolName === "write" || toolName === "edit") {
91
+ const p = typeof args.file_path === "string" ? args.file_path : "";
92
+ if (isProfileConfigFile(p) && !isAllowedDevPath(p)) {
93
+ let contentStr = "";
94
+ try { contentStr = JSON.stringify(args); } catch {}
95
+ if (/dsh-security/.test(contentStr) && /(false|disable|remove|delete)/i.test(contentStr)) {
96
+ alert(`BLOCKED: 尝试通过 profile 配置关闭安全插件 | path=${p}`);
97
+ return { kind: "deny", reason: "🛡️ 安全插件拦截:禁止通过修改配置文件关闭安全插件" };
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ // ---- 危险命令分级拦截(仅 bash 工具) ----
104
+ if (cfg.enableCommandGuard && toolName === "bash") {
105
+ const cmd = typeof args.command === "string" ? args.command : "";
106
+ const danger = checkDangerousCommand(cmd);
107
+ if (danger) {
108
+ if (BLOCK_SEVERITIES.has(danger.severity)) {
109
+ alert(`BLOCKED [${danger.severity}]: ${danger.reason} | cmd: ${cmd.slice(0, 200)}`);
110
+ return {
111
+ kind: "deny",
112
+ reason: `🛡️ 安全插件拦截 [${danger.severity}]:${danger.reason}\n命令: ${cmd.slice(0, 100)}`
113
+ };
114
+ }
115
+ alert(`WARNING [${danger.severity}]: ${danger.reason} | cmd: ${cmd.slice(0, 200)}`);
116
+ }
117
+ }
118
+
119
+ // ---- 文件写入防护(审批制,独立开关) ----
120
+ // 顺序在自保护之后、危险命令规则之前:写路径命中先走审批
121
+ if (cfg.enableFileWriteGuard) {
122
+ const fwDecision = checkFileWriteGuard(toolName, args, cfg, cwd);
123
+ if (fwDecision) {
124
+ const mode = fwDecision.kind === "deny" ? "拦截" : "审批";
125
+ const targetPath = typeof args?.file_path === "string" ? args.file_path : "";
126
+ alert(`FILE_WRITE_${mode.toUpperCase()} [${fwDecision._hit?.category ?? "?"}]: tool=${toolName} 原始路径=${targetPath || (typeof args?.command === "string" ? args.command.slice(0, 160) : "")} | 命中=${fwDecision._hit?.matched ?? ""}`);
127
+ delete fwDecision._hit;
128
+ return fwDecision;
129
+ }
130
+ }
131
+
132
+ return null;
133
+ }
134
+
135
+ // ============================================================
136
+ // llm/stream 包装:文本块整块缓冲 → block-end 时脱敏。
137
+ // 关键设计(防 TUI 渲染异常):无敏感内容时把原始 chunk 逐条原样重放,
138
+ // 流形状与无插件时字节一致;只有真正命中脱敏时才改为"单条脱敏 delta +
139
+ // 同步修补 block-end"。tool-call-delta / reasoning-delta / usage / finish
140
+ // 永远原样透传;任何异常 → 降级直通(不打断生成)。
141
+ // ============================================================
142
+ function wrapLlmStream(source, { log, alert }) {
143
+ const textBuffers = new Map(); // index -> 累积文本
144
+ const textChunks = new Map(); // index -> 原始 chunk 序列(无命中时原样重放)
145
+ const blockTypes = new Map(); // index -> blockType
146
+ let degraded = false;
147
+
148
+ return {
149
+ [Symbol.asyncIterator]() {
150
+ const it = source[Symbol.asyncIterator]();
151
+ const pending = [];
152
+
153
+ return {
154
+ async next() {
155
+ if (degraded) return it.next();
156
+ if (pending.length > 0) return { done: false, value: pending.shift() };
157
+ // eslint-disable-next-line no-constant-condition
158
+ while (true) {
159
+ const { done, value: chunk } = await it.next(); // 流自身错误(网络/取消)原样上抛
160
+ if (done) return { done: true, value: undefined };
161
+ if (!chunk || typeof chunk !== "object") return { done: false, value: chunk };
162
+
163
+ try {
164
+ if (chunk.type === "block-start") {
165
+ blockTypes.set(chunk.index, chunk.blockType);
166
+ if (chunk.blockType === "text") {
167
+ textBuffers.set(chunk.index, "");
168
+ textChunks.set(chunk.index, []);
169
+ }
170
+ return { done: false, value: chunk };
171
+ }
172
+
173
+ if (chunk.type === "text-delta" && blockTypes.get(chunk.index) === "text") {
174
+ textBuffers.set(chunk.index, (textBuffers.get(chunk.index) ?? "") + chunk.text);
175
+ textChunks.get(chunk.index)?.push(chunk);
176
+ continue; // 缓冲,不吐出
177
+ }
178
+
179
+ if (chunk.type === "block-end" && textBuffers.has(chunk.index)) {
180
+ const raw = textBuffers.get(chunk.index);
181
+ const originals = textChunks.get(chunk.index) ?? [];
182
+ textBuffers.delete(chunk.index);
183
+ textChunks.delete(chunk.index);
184
+ blockTypes.delete(chunk.index);
185
+
186
+ const { sanitized, matches } = sanitizeContent(raw);
187
+ if (matches.length === 0 || sanitized === raw) {
188
+ // 无命中:原始 chunk 逐条重放,流形状与无插件完全一致
189
+ pending.push(...originals, chunk);
190
+ return { done: false, value: pending.shift() };
191
+ }
192
+
193
+ alert(`LLM_OUTPUT_SANITIZED: 类型=${matches.join(", ")} | 原文长度=${raw.length} | 脱敏后长度=${sanitized.length}`);
194
+ if (sanitized.length > 0) {
195
+ pending.push({ type: "text-delta", index: chunk.index, text: sanitized });
196
+ }
197
+ const finalBlock = (chunk.block && chunk.block.type === "text")
198
+ ? { ...chunk.block, text: sanitized }
199
+ : chunk.block;
200
+ pending.push({ ...chunk, block: finalBlock });
201
+ return { done: false, value: pending.shift() };
202
+ }
203
+
204
+ return { done: false, value: chunk };
205
+ } catch (e) {
206
+ // 脱敏逻辑异常:降级放行,绝不阻断生成;已缓冲的原始 chunk 先吐出
207
+ degraded = true;
208
+ log(`[LLM-STREAM] 脱敏包装器异常,降级直通: ${e?.message}`, "ERROR");
209
+ pending.push(...(textChunks.get(chunk.index) ?? []), chunk);
210
+ textBuffers.clear();
211
+ textChunks.clear();
212
+ blockTypes.clear();
213
+ return { done: false, value: pending.shift() };
214
+ }
215
+ }
216
+ },
217
+ async return(value) {
218
+ return typeof it.return === "function" ? it.return(value) : { done: true, value };
219
+ },
220
+ async throw(err) {
221
+ if (typeof it.throw === "function") return it.throw(err);
222
+ throw err;
223
+ },
224
+ };
225
+ },
226
+ };
227
+ }
228
+
229
+ // ============================================================
230
+ // 插件入口(cordis 约定:模块导出 {name, inject, apply};
231
+ // inject 声明依赖服务,缺声明访问服务会报 "cannot get property without inject")
232
+ // ============================================================
233
+ export const name = "dsh-security";
234
+ export const inject = ["systemPrompt"];
235
+
236
+ export function apply(ctx, config) {
237
+ const cfg = { ...DEFAULT_CONFIG, ...(config ?? {}) };
238
+ const { log, alert } = createLogger(cfg.logDir);
239
+
240
+ log(`${PLUGIN_NAME} v${PLUGIN_VERSION} 启动 | commandGuard=${cfg.enableCommandGuard} sanitizer=${cfg.enableSanitizer} replyGuard=${cfg.enableReplyGuard} selfProtection=${cfg.enableSelfProtection}`);
241
+
242
+ // ---- A. 安全话术条件注入(意图命中才注入,未命中零 token) ----
243
+ // 时序说明:系统提示词组装发生在用户消息写入 session 日志之前,
244
+ // 但 inbox.claim(触发 agent/inbox/claimed 事件)在组装之前执行,
245
+ // 因此监听该事件捕获当前轮用户输入,组装时从闭包状态读取。
246
+ let lastUserText = "";
247
+ ctx.on("agent/inbox/claimed", (payload) => {
248
+ try {
249
+ const message = payload?.message;
250
+ if (!message || message.role !== "user") return;
251
+ const text = messageText(message);
252
+ if (!text.trim()) return;
253
+ if (text.trimStart().startsWith("<system-reminder>")) return;
254
+ lastUserText = text;
255
+ } catch {}
256
+ });
257
+ if (cfg.enableReplyGuard) {
258
+ ctx.effect(() => ctx.systemPrompt.section({
259
+ name: "dsh-security-reply-guard",
260
+ order: 45,
261
+ text: (context) => {
262
+ try {
263
+ // 优先用 inbox 捕获的当前轮用户输入;兜底从 session 日志提取(续会话场景)
264
+ let lastUser = lastUserText;
265
+ if (!lastUser) {
266
+ const session = context?.agent?.session;
267
+ const messages = typeof session?.deriveMessages === "function" ? session.deriveMessages() : null;
268
+ lastUser = extractLastUserText(messages);
269
+ }
270
+ if (!lastUser) return "";
271
+ const intent = detectSecurityIntent(lastUser);
272
+ if (!intent) return "";
273
+ log(`[REPLY-GUARD] 意图命中: ${intent} | 注入统一话术`);
274
+ return UNIFIED_RULES_TEXT;
275
+ } catch (e) {
276
+ log(`[REPLY-GUARD] 注入检查异常(跳过注入): ${e?.message}`, "ERROR");
277
+ return "";
278
+ }
279
+ },
280
+ }), "dsh-security.reply-guard");
281
+ }
282
+
283
+ // ---- B. 工具执行前置闸门:危险命令分级拦截 + 自保护 ----
284
+ ctx.on("tools/pre-execute", (exec, next) => {
285
+ try {
286
+ const decision = guardPreExecute(exec, cfg, { log, alert });
287
+ if (decision) return decision;
288
+ } catch (e) {
289
+ log(`[PRE-EXECUTE] 闸门异常(降级放行): ${e?.message}`, "ERROR");
290
+ }
291
+ return next();
292
+ });
293
+
294
+ if (cfg.enableSanitizer) {
295
+ // ---- C1. 工具结果脱敏(进模型上下文前的最后一道闸) ----
296
+ ctx.on("tools/post-execute", async (exec, result, next) => {
297
+ const decision = await next();
298
+ try {
299
+ if (!decision || decision.kind !== "accept") return decision;
300
+ // value 变体(结构化结果)不动,只处理 content 投影
301
+ if (decision.value !== undefined) return decision;
302
+ const blocks = decision.content ?? result?.content;
303
+ if (!Array.isArray(blocks) || blocks.length === 0) return decision;
304
+
305
+ let changed = false;
306
+ const matchSet = new Set();
307
+ const sanitizedBlocks = blocks.map((block) => {
308
+ if (!block || block.type !== "text" || typeof block.text !== "string") return block;
309
+ const { sanitized, matches } = sanitizeContent(block.text);
310
+ if (matches.length === 0) return block;
311
+ changed = true;
312
+ for (const m of matches) matchSet.add(m);
313
+ return { ...block, text: sanitized };
314
+ });
315
+ if (!changed) return decision;
316
+
317
+ alert(`TOOL_RESULT_SANITIZED: tool=${exec.name} 脱敏类型=${[...matchSet].join(", ")}`);
318
+ log(`[POST-EXECUTE] tool=${exec.name} 脱敏 ${[...matchSet].join(", ")}`, "WARN");
319
+ return { kind: "accept", content: sanitizedBlocks };
320
+ } catch (e) {
321
+ log(`[POST-EXECUTE] 脱敏异常(降级放行): ${e?.message}`, "ERROR");
322
+ return decision;
323
+ }
324
+ });
325
+
326
+ // ---- C2. 模型输出脱敏(展示+持久化双通道,整块缓冲保证跨 chunk 匹配) ----
327
+ ctx.on("llm/stream", (options, next) => {
328
+ try {
329
+ return wrapLlmStream(next(), { log, alert });
330
+ } catch (e) {
331
+ log(`[LLM-STREAM] 包装失败(直通): ${e?.message}`, "ERROR");
332
+ return next();
333
+ }
334
+ });
335
+ }
336
+
337
+ log(`${PLUGIN_NAME} 启动完成: replyGuard + pre-execute + post-execute + llm/stream`);
338
+ }
339
+
340
+ // 导出供测试使用
341
+ export { guardPreExecute, wrapLlmStream, DEFAULT_CONFIG };
342
+ export default { name, inject, apply };
package/lib/log.js ADDED
@@ -0,0 +1,33 @@
1
+ // ============================================================
2
+ // 审计日志(与墨宝安全插件同构:security.log + alerts.log)
3
+ // ============================================================
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+
7
+ export function createLogger(logDir) {
8
+ const LOG_FILE = path.join(logDir, "security.log");
9
+ const ALERT_FILE = path.join(logDir, "alerts.log");
10
+
11
+ function ensureLogDir() {
12
+ try { fs.mkdirSync(logDir, { recursive: true }); } catch {}
13
+ }
14
+
15
+ function log(msg, level = "INFO") {
16
+ ensureLogDir();
17
+ const ts = new Date().toISOString();
18
+ const line = `[${ts}] [${level}] ${msg}\n`;
19
+ try { fs.appendFileSync(LOG_FILE, line); } catch {}
20
+ }
21
+
22
+ function alert(msg) {
23
+ ensureLogDir();
24
+ const ts = new Date().toISOString();
25
+ const line = `[${ts}] [ALERT] ${msg}\n`;
26
+ try {
27
+ fs.appendFileSync(ALERT_FILE, line);
28
+ fs.appendFileSync(LOG_FILE, line);
29
+ } catch {}
30
+ }
31
+
32
+ return { log, alert };
33
+ }
package/lib/reply.js ADDED
@@ -0,0 +1,96 @@
1
+ // ============================================================
2
+ // 安全问题回复治理(与墨宝安全插件话术完全一致,含"不提训练条款"策略)
3
+ // 注入方式差异:墨宝走 fetch 请求体重写;dsh 走 systemPrompt.section
4
+ // 动态 text 函数(意图命中才返回话术,未命中返回空串自动丢弃,零 token 消耗)
5
+ // ============================================================
6
+
7
+ // 意图检测 v3:高精度优先;权利/合规类问题需具备明确数据对象和疑问/担忧语义
8
+ export const SECURITY_INTENT_PATTERNS = [
9
+ { pattern: /(?:发生|出现).{0,4}(?:数据|文件|稿件|内容|信息)?(?:泄露|泄漏).{0,10}(?:怎么办|会通知|如何处理|怎么处理|告诉我).{0,4}(?:吗|么|呢|?|\?)/, intent: "leak-incident" },
10
+ { pattern: /(?:数据|文件|稿件|内容|信息).{0,6}(?:泄露|泄漏).{0,10}(?:怎么办|会通知|如何处理|怎么处理|告诉我).{0,4}(?:吗|么|呢|?|\?)/, intent: "leak-incident" },
11
+ { pattern: /(?:能保证|能担保|百分百|100%).{0,8}(?:安全|不泄露|保密|放心).{0,4}(?:吗|么|呢|?|\?)/, intent: "guarantee-demand" },
12
+ { pattern: /(?:稿件|文件|内容|数据|对话|信息|文档|上传|我的).{0,12}(?:安全|泄露|泄漏|保密|隐私).{0,8}(?:吗|么|呢|?|\?)/, intent: "content-security" },
13
+ { pattern: /(?:你们|平台).{0,6}安全.{0,4}(?:吗|么|呢|?|\?)/, intent: "content-security" },
14
+ { pattern: /(?:稿件|文件|内容|数据|信息).{0,16}(?:会不会|是否会|是不是|会).{0,8}(?:泄露|泄漏|被.{0,4}看到|公开).{0,4}(?:吗|么|呢|?|\?)/, intent: "leak-concern" },
15
+ { pattern: /(?:会不会|是否会|是不是).{0,8}(?:泄露|泄漏|被.{0,4}看到|公开).{0,8}(?:我的|用户的)(?:稿件|文件|内容|数据|信息).{0,4}(?:吗|么|呢|?|\?)/, intent: "leak-concern" },
16
+ { pattern: /(?:谁|哪些人|什么人).{0,4}(?:能|可以).{0,4}(?:看到|看见|查看|访问).{0,10}(?:稿件|文件|内容|数据|对话|信息).{0,4}(?:吗|么|呢|?|\?)/, intent: "access-concern" },
17
+ { pattern: /(?:别人|其他人).{0,6}(?:能|会不会|是否会|可以).{0,4}(?:看到|看见|查看|访问).{0,10}(?:我的|用户的)(?:稿件|文件|内容|数据|对话|信息).{0,4}(?:吗|么|呢|?|\?)/, intent: "access-concern" },
18
+ { pattern: /(?:稿件|文件|内容|数据|对话).{0,12}(?:用于|拿去|用来|会被).{0,8}训练.{0,6}(?:吗|么|呢|?|\?)/, intent: "training-concern" },
19
+ { pattern: /(?:AI|墨宝|助手|智能体|你).{0,8}(?:会不会|是否会|会|能不能).{0,6}乱(?:动|改|删).{0,10}(?:我的|本地|电脑|设备|文件).{0,4}(?:吗|么|呢|?|\?)/, intent: "local-device-concern" },
20
+ { pattern: /(?:我的|本地).{0,4}(?:文件|电脑|设备).{0,10}(?:会不会|是否会).{0,8}(?:被)?(?:AI|墨宝|助手|智能体).{0,4}乱(?:动|改|删).{0,4}(?:吗|么|呢|?|\?)/, intent: "local-device-concern" },
21
+ { pattern: /(?:上传|提交)(?:之后|以后).{0,6}(?:能否|能不能|是否能|可以|能).{0,6}(?:删除|删掉|清除).{0,4}(?:吗|么|呢|?|\?)/, intent: "deletion-right" },
22
+ { pattern: /(?:任务记录|对话记录|聊天记录|个人信息|账号|我的数据|我的稿件|我上传的文件).{0,8}(?:能否|能不能|是否能|可以|能).{0,6}(?:删除|删掉|清除|注销).{0,4}(?:吗|么|呢|?|\?)/, intent: "deletion-right" },
23
+ { pattern: /(?:能否|能不能|是否能|可以|能).{0,6}(?:删除|删掉|清除|注销).{0,8}(?:任务记录|对话记录|聊天记录|个人信息|账号|我的数据|我的稿件|上传的文件).{0,4}(?:吗|么|呢|?|\?)/, intent: "deletion-right" },
24
+ { pattern: /(?:稿件|文件|内容|数据|信息|对话|记录).{0,10}(?:保存|存储|留存|保留).{0,8}(?:多久|多长时间|几年|多少天|几天)/, intent: "retention-period" },
25
+ { pattern: /(?:第三方|别的公司|其他公司|模型厂商|服务商|供应商).{0,10}(?:能否|能不能|是否会|会不会|能|会).{0,8}(?:看到|获取|使用|共享|接触).{0,10}(?:我的|用户的)(?:稿件|文件|内容|数据|对话|信息).{0,4}(?:吗|么|呢|?|\?)/, intent: "third-party" },
26
+ { pattern: /(?:我的|用户的)(?:稿件|文件|内容|数据|对话|信息).{0,12}(?:会不会|是否会|会被|是否被).{0,8}(?:第三方|模型厂商|服务商|供应商).{0,6}(?:看到|获取|使用|共享|接触).{0,4}(?:吗|么|呢|?|\?)/, intent: "third-party" },
27
+ { pattern: /(?:有|会有|是否有|有没有).{0,4}(?:人工|工作人员|编辑|运营人员).{0,8}(?:会|进行|能够|可以)?(?:审核|查看|看到|检查|翻看).{0,8}(?:吗|么|呢|?|\?)/, intent: "human-review" },
28
+ { pattern: /(?:人工|工作人员|编辑|运营人员).{0,6}(?:会不会|是否会|能否|能不能|会|能).{0,6}(?:审核|查看|看到|检查|翻看).{0,8}(?:吗|么|呢|?|\?)/, intent: "human-review" },
29
+ { pattern: /(?:我的|用户的)?(?:数据|文件|稿件|内容|个人信息).{0,12}(?:会不会|是否会|会|是否|可能).{0,8}(?:传到|发送到|存到|存储在|转移到|传输至).{0,6}(?:境外|海外|国外|外国).{0,4}(?:吗|么|呢|?|\?)/, intent: "cross-border" },
30
+ { pattern: /(?:我的|用户的)?(?:数据|文件|稿件|内容|个人信息).{0,12}(?:会不会|是否会|会|是否).{0,6}跨境传输.{0,4}(?:吗|么|呢|?|\?)/, intent: "cross-border" },
31
+ { pattern: /(?:数据|文件|稿件|内容).{0,8}(?:存在|存储|放在|保存在).{0,6}(?:哪里|哪儿|什么地方|何处)/, intent: "storage-location" },
32
+ { pattern: /(?:稿件|文件|内容|数据).{0,10}(?:缓存|存|留).{0,6}(?:云端|云上|服务器|网上).{0,4}(?:吗|么|呢|?|\?)/, intent: "storage-location" },
33
+ { pattern: /(?:稿件|文件|内容|数据).{0,12}(?:服务器上|云端|云上).{0,8}(?:缓存|存|留).{0,4}(?:吗|么|呢|?|\?)/, intent: "storage-location" },
34
+ // ---- 放宽组:用户不带句尾问号/语气词时的兜底(仅限自带强疑问词的模式,
35
+ // 陈述句场景仍要求句尾疑问标记,保持墨宝"高精度优先"原则) ----
36
+ { pattern: /(?:稿件|文件|内容|数据|信息).{0,16}(?:会不会|是否会|是不是).{0,8}(?:泄露|泄漏|被.{0,4}看到|公开)/, intent: "leak-concern" },
37
+ { pattern: /(?:会不会|是否会|是不是).{0,8}(?:泄露|泄漏|公开).{0,8}(?:我的|用户的)(?:稿件|文件|内容|数据|信息)/, intent: "leak-concern" },
38
+ { pattern: /(?:发生|出现).{0,4}(?:数据|文件|稿件|内容|信息)?(?:泄露|泄漏).{0,10}(?:怎么办|如何处理|怎么处理)/, intent: "leak-incident" },
39
+ { pattern: /(?:数据|文件|稿件|内容|信息).{0,6}(?:泄露|泄漏).{0,10}(?:怎么办|如何处理|怎么处理)/, intent: "leak-incident" },
40
+ { pattern: /(?:谁|哪些人|什么人).{0,4}(?:能|可以).{0,4}(?:看到|看见|查看|访问).{0,10}(?:稿件|文件|内容|数据|对话|信息)/, intent: "access-concern" },
41
+ { pattern: /(?:别人|其他人).{0,6}(?:能|会不会|是否会|可以).{0,4}(?:看到|看见|查看|访问).{0,10}(?:我的|用户的)(?:稿件|文件|内容|数据|对话|信息)/, intent: "access-concern" },
42
+ { pattern: /(?:AI|墨宝|助手|智能体|你).{0,8}(?:会不会|是否会|能不能).{0,6}乱(?:动|改|删).{0,10}(?:我的|本地|电脑|设备|文件)/, intent: "local-device-concern" },
43
+ { pattern: /(?:上传|提交)(?:之后|以后).{0,6}(?:能否|能不能|是否能|可以).{0,6}(?:删除|删掉|清除)/, intent: "deletion-right" },
44
+ { pattern: /(?:任务记录|对话记录|聊天记录|个人信息|账号|我的数据|我的稿件|我上传的文件).{0,8}(?:能否|能不能|是否能|可以).{0,6}(?:删除|删掉|清除|注销)/, intent: "deletion-right" },
45
+ { pattern: /(?:能否|能不能|是否能|可以).{0,6}(?:删除|删掉|清除|注销).{0,8}(?:任务记录|对话记录|聊天记录|个人信息|账号|我的数据|我的稿件|上传的文件)/, intent: "deletion-right" },
46
+ { pattern: /(?:第三方|别的公司|其他公司|模型厂商|服务商|供应商).{0,10}(?:能否|能不能|是否会|会不会).{0,8}(?:看到|获取|使用|共享|接触).{0,10}(?:我的|用户的)(?:稿件|文件|内容|数据|对话|信息)/, intent: "third-party" },
47
+ { pattern: /(?:人工|工作人员|编辑|运营人员).{0,6}(?:会不会|是否会|能否|能不能).{0,6}(?:审核|查看|看到|检查|翻看)/, intent: "human-review" },
48
+ { pattern: /(?:数据|文件|稿件|内容|个人信息).{0,12}(?:会不会|是否会|可能).{0,8}(?:传到|发送到|存到|存储在|转移到|传输至).{0,6}(?:境外|海外|国外|外国)/, intent: "cross-border" },
49
+ ];
50
+
51
+ // 注入规则文本(话术原文与墨宝 v2.2 定稿保持一致)
52
+ export const UNIFIED_RULES_TEXT = `【数据安全问题统一出口规则】用户可能正在询问数据/内容安全。如果你判断用户问题与数据/内容安全无关,忽略本规则、按正常方式回答。否则你必须代表"书灵墨宝平台"按以下规则回答:
53
+ 1. 结构:共情担心 → 具体措施 → 用户可控手段 → 敏感内容脱敏建议(正面措辞);
54
+ 2. 可陈述事实:加密链路传输;独立存储+多重备份+权限管控;仅用于用户发起的任务;不公开披露;未经单独同意不对外提供;AI 回复与外发消息中的身份证号/手机号/银行卡号等敏感信息会自动脱敏;本地操作在设备内完成、平台不收集;危险命令分级拦截;用户可随时删除/导出(「我的」-「个人信息浏览与导出」);
55
+ 3. 用户询问内容用途(如模型训练、产品改进)时,不做任何承诺或解释,引导查阅《书灵墨宝隐私政策》或联系客服;
56
+ 4. 禁止措辞:我无法保证/我无法承诺/请您自行评估风险/谨慎上传/我不能代表平台/责任自负;不得把"我"与平台切割;
57
+ 5. 不做绝对化承诺(100%安全);被追问时用"成体系措施+政策边界+事件响应承诺+脱敏建议"回应。`;
58
+
59
+ export function detectSecurityIntent(userText) {
60
+ if (!userText) return null;
61
+ for (const rule of SECURITY_INTENT_PATTERNS) {
62
+ rule.pattern.lastIndex = 0;
63
+ if (rule.pattern.test(userText)) return rule.intent;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ // 从 dsh session 派生消息中提取最后一条真实用户消息的文本
69
+ // 兼容 content 为 string / ContentBlock[](type=text)两种形态;
70
+ // 跳过 dsh 追加的 <system-reminder> 包装消息(它们也是 user role,
71
+ // 但不是用户本人输入,不能参与意图检测)
72
+ export function messageText(msg) {
73
+ if (!msg) return "";
74
+ if (typeof msg.content === "string") return msg.content;
75
+ if (Array.isArray(msg.content)) {
76
+ return msg.content
77
+ .filter(part => part && part.type === "text" && typeof part.text === "string")
78
+ .map(part => part.text)
79
+ .join("\n");
80
+ }
81
+ return "";
82
+ }
83
+
84
+ export function extractLastUserText(messages) {
85
+ if (!Array.isArray(messages)) return "";
86
+ for (let i = messages.length - 1; i >= 0; i--) {
87
+ const msg = messages[i];
88
+ if (!msg || msg.role !== "user") continue;
89
+ const text = messageText(msg);
90
+ if (!text.trim()) continue;
91
+ // dsh 的 system-reminder 不是用户输入,继续往前找
92
+ if (text.trimStart().startsWith("<system-reminder>")) continue;
93
+ return text;
94
+ }
95
+ return "";
96
+ }
@@ -0,0 +1,179 @@
1
+ // ============================================================
2
+ // 敏感信息脱敏规则库(逐条移植自墨宝安全插件,含全部历史修复)
3
+ // 修复档案:
4
+ // - pattern.lastIndex 重置(test() 污染 lastIndex 导致 replace 失败)
5
+ // - 邮箱规则优先于手机号(避免 175****6****@163.com 二次脱敏)
6
+ // - 手机号负向前瞻 (?!@)(排除邮箱上下文)
7
+ // - looksLikeMasked 只认"数字/字母+连续4星号+数字/字母/@"(不误判 Markdown)
8
+ // - maskKeep 星号做 Markdown 转义(\* 防止渲染异常)
9
+ // ============================================================
10
+
11
+ const ID_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
12
+ const ID_CHECK_CODES = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
13
+
14
+ export function verifyIdCard(idNumber) {
15
+ if (idNumber.length !== 18) return false;
16
+ const body = idNumber.slice(0, 17);
17
+ const checkDigit = idNumber.charAt(17).toUpperCase();
18
+ if (!/^\d{17}$/.test(body)) return false;
19
+ let sum = 0;
20
+ for (let i = 0; i < 17; i++) {
21
+ sum += parseInt(body.charAt(i), 10) * ID_WEIGHTS[i];
22
+ }
23
+ const expected = ID_CHECK_CODES[sum % 11];
24
+ return checkDigit === expected;
25
+ }
26
+
27
+ export function verifyLuhn(cardNumber) {
28
+ if (!/^\d{16,19}$/.test(cardNumber)) return false;
29
+ let sum = 0;
30
+ let alternate = false;
31
+ for (let i = cardNumber.length - 1; i >= 0; i--) {
32
+ let n = parseInt(cardNumber.charAt(i), 10);
33
+ if (alternate) {
34
+ n *= 2;
35
+ if (n > 9) n -= 9;
36
+ }
37
+ sum += n;
38
+ alternate = !alternate;
39
+ }
40
+ return sum % 10 === 0;
41
+ }
42
+
43
+ // ---- 脱敏辅助:部分保留 + 星号遮盖(星号转义防 Markdown 渲染异常) ----
44
+ export function maskKeep(str, keepHead, keepTail = 0, minStars = 4) {
45
+ if (str.length <= keepHead + keepTail) return "\\*".repeat(str.length);
46
+ const stars = Math.max(minStars, str.length - keepHead - keepTail);
47
+ return str.slice(0, keepHead) + "\\*".repeat(stars) + (keepTail > 0 ? str.slice(-keepTail) : "");
48
+ }
49
+
50
+ // ---- 智能识别:检测文本是否已脱敏 ----
51
+ export function looksLikeMasked(text) {
52
+ // 只识别"明显的脱敏模式":数字/字母 + 连续4个以上星号 + 数字/字母/@
53
+ // 例如:138****5678、1**********@163.com
54
+ // 不误判 Markdown 格式(---、**粗体**)
55
+ return /[a-zA-Z0-9]\*{4,}[a-zA-Z0-9@]/.test(text);
56
+ }
57
+
58
+ const ID_CARD_REGEX = /\b[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b/g;
59
+ const BANK_CARD_REGEX = /\b[3-6]\d{15,18}\b/g;
60
+
61
+ export const SENSITIVE_PATTERNS = [
62
+ // 优先级1:邮箱(避免被手机号规则破坏)
63
+ {
64
+ pattern: /\b([a-zA-Z0-9._%+-]+)@((?:qq|163|126|sina|sohu|gmail|outlook|hotmail|yahoo)\.[a-z]{2,})\b/gi,
65
+ replacer: (_m, local, domain) => maskKeep(local, 2, 0) + "@" + domain,
66
+ name: "个人邮箱"
67
+ },
68
+ // 优先级2:身份证
69
+ {
70
+ pattern: ID_CARD_REGEX,
71
+ validator: verifyIdCard,
72
+ replacer: (m) => maskKeep(m, 3, 4),
73
+ name: "身份证号"
74
+ },
75
+ // 优先级3:手机号(排除邮箱上下文)
76
+ {
77
+ pattern: /\b1[3-9]\d{9}\b(?!@)/g,
78
+ replacer: (m) => maskKeep(m, 3, 4),
79
+ name: "手机号"
80
+ },
81
+ // 优先级4:银行卡
82
+ {
83
+ pattern: BANK_CARD_REGEX,
84
+ validator: verifyLuhn,
85
+ replacer: (m) => maskKeep(m, 4, 4),
86
+ name: "银行卡号"
87
+ },
88
+ {
89
+ pattern: /\b(sk-[a-zA-Z0-9-]{20,})\b/g,
90
+ replacer: (m) => maskKeep(m, 5, 0),
91
+ name: "API Key (sk-)"
92
+ },
93
+ {
94
+ pattern: /\b(AKIA[0-9A-Z]{16})\b/g,
95
+ replacer: (m) => maskKeep(m, 4, 0),
96
+ name: "AWS Access Key"
97
+ },
98
+ {
99
+ pattern: /\b(ghp_[a-zA-Z0-9]{36,})\b/g,
100
+ replacer: (m) => maskKeep(m, 4, 0),
101
+ name: "GitHub Token"
102
+ },
103
+ {
104
+ pattern: /(password|passwd|pwd|secret_key|secret|token|api_key|apikey|access_key|private_key)\s*[=:]\s*["']?[^\s"',]{6,}/gi,
105
+ replacer: (_m, key) => key + "=***",
106
+ name: "密码/密钥字段"
107
+ },
108
+ {
109
+ pattern: /\b(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g,
110
+ replacer: (m) => m.split(".")[0] + ".*.*.*",
111
+ name: "内网IP"
112
+ },
113
+ {
114
+ pattern: /[^\s"']*\/extensions\/mobook-security-plugin[^\s"']*/g,
115
+ replacer: () => "***插件路径已脱敏***",
116
+ name: "安全插件路径"
117
+ },
118
+ ];
119
+
120
+ export function sanitizeContent(content) {
121
+ if (typeof content !== "string" || content.length === 0) {
122
+ return { sanitized: content, matches: [] };
123
+ }
124
+ // 智能识别:如果整体内容看起来已脱敏,跳过处理
125
+ if (looksLikeMasked(content)) {
126
+ return { sanitized: content, matches: [] };
127
+ }
128
+
129
+ let sanitized = content;
130
+ const matches = [];
131
+
132
+ for (const rule of SENSITIVE_PATTERNS) {
133
+ rule.pattern.lastIndex = 0;
134
+
135
+ if (rule.validator) {
136
+ // 有校验器的规则(身份证、银行卡等)
137
+ let hasMatch = false;
138
+ sanitized = sanitized.replace(rule.pattern, (...args) => {
139
+ const match = args[0];
140
+ if (looksLikeMasked(match)) return match;
141
+ if (rule.validator(match)) {
142
+ hasMatch = true;
143
+ return rule.replacer(...args);
144
+ }
145
+ return match;
146
+ });
147
+ if (hasMatch) matches.push(rule.name);
148
+ } else if (rule.replacer) {
149
+ rule.pattern.lastIndex = 0;
150
+ const replaced = sanitized.replace(rule.pattern, (...args) => {
151
+ const match = args[0];
152
+ if (looksLikeMasked(match)) return match;
153
+ return rule.replacer(...args);
154
+ });
155
+ if (replaced !== sanitized) {
156
+ matches.push(rule.name);
157
+ sanitized = replaced;
158
+ }
159
+ }
160
+ }
161
+
162
+ return { sanitized, matches };
163
+ }
164
+
165
+ // ============================================================
166
+ // 工具参数脱敏(仅用于审计日志,不落明文)
167
+ // ============================================================
168
+ const SENSITIVE_PARAM_KEYS = ["password", "secret", "token", "api_key", "apikey", "private_key", "access_key"];
169
+
170
+ export function sanitizeParamsForLog(params) {
171
+ if (!params || typeof params !== "object") return params;
172
+ const sanitized = { ...params };
173
+ for (const key of Object.keys(sanitized)) {
174
+ if (SENSITIVE_PARAM_KEYS.some(s => key.toLowerCase().includes(s))) {
175
+ sanitized[key] = "***REDACTED***";
176
+ }
177
+ }
178
+ return sanitized;
179
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@dcrays/dsh-security-plugin",
3
+ "version": "1.0.0",
4
+ "description": "DeepSeek Harness (dsh) 安全防护插件:危险命令分级拦截 | 敏感信息脱敏 | 安全问题统一话术注入 | 操作审计日志。能力与 @dcrays/mobook-security-plugin 对齐。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "lib"
13
+ ],
14
+ "scripts": {
15
+ "test": "node test/unit.test.mjs"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "registry": "https://registry.npmjs.org/"
20
+ }
21
+ }