dsh-tool-folder 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/cordis.patch.yml +4 -0
- package/lib/chainguard.js +40 -3
- package/lib/index.js +79 -12
- package/lib/obfuscation.js +124 -0
- package/lib/semantic.js +239 -0
- package/package.json +48 -38
package/README.md
CHANGED
|
@@ -107,14 +107,21 @@ were never unregistered, so nothing else changes.
|
|
|
107
107
|
| include | [] | whitelist: exact name or `prefix*`. Non-empty → only matching tools are injected (meta tools exempt; deny still wins). Execution is NOT gated by include. |
|
|
108
108
|
| toonifyResults | false | compact long JSON results after execution (drop empty fields + truncate strings; only when the text block is >2000 chars and parses as JSON) |
|
|
109
109
|
| maxFoldMs | 50 | hard cap; beyond this keep the full list |
|
|
110
|
+
| semanticEnabled | true | **v0.1.8**: tools_search semantic leg — BM25 + local bge-m3 (Ollama) RRF hybrid. Chinese intents hit English-only tools (the documented BM25 gap). Index lazily built + disk-cached (`~/.dsh/state/semantic-cache.json`); Ollama offline/timeout degrades to BM25-only, never slower or worse. |
|
|
111
|
+
| ollamaBase | http://127.0.0.1:11434 | Ollama endpoint for the semantic leg |
|
|
112
|
+
| embedModel | bge-m3 | embedding model for the semantic leg |
|
|
110
113
|
| feedbackFile | '' | feedback JSON path (default `$DSH_HOME/logs/tool-folder/`) |
|
|
111
114
|
| aliases | built-in | CN-intent keyword → server prefix table (per-server top-K) |
|
|
112
115
|
|
|
113
116
|
## Files
|
|
114
117
|
|
|
115
118
|
- `lib/bm25.js` — zero-dep BM25 (k1=1.2, b=0.75; CN bigrams + EN tokens)
|
|
119
|
+
- `lib/semantic.js` — v0.2.0 semantic leg: local bge-m3 embeddings (Ollama) + RRF fusion, disk-cached
|
|
120
|
+
- `lib/chainguard.js` — ChainGuard firewall: literal patterns + exfil-chain detection + deobfuscation
|
|
121
|
+
- `lib/obfuscation.js` — v0.2.0 anti-obfuscation: concat rebuild, `-enc`/certutil/bitsadmin shapes, homoglyphs
|
|
116
122
|
- `lib/schema.js` — L1 tiered compression (`compressLevel`) + description normalization + lossless-JSON sanitizer
|
|
117
123
|
- `lib/category.js` — P1-1 intent-category routing (pure function, unit-testable)
|
|
124
|
+
- `lib/metrics.js` — heat/feedback persistence (fold promotion signals)
|
|
118
125
|
- `lib/toonify.js` — P2-2 long JSON result compaction (pure function)
|
|
119
126
|
- `lib/index.js` — assemble hook, three-segment fold, heat feedback, safety
|
|
120
127
|
- `cordis.patch.yml` — bundle insert declaration
|
|
@@ -142,6 +149,50 @@ were never unregistered, so nothing else changes.
|
|
|
142
149
|
|
|
143
150
|
## Changelog
|
|
144
151
|
|
|
152
|
+
### v0.2.0 — first npm release (2026-08-30)
|
|
153
|
+
This is the first version published to npm. It bundles the v0.1.7 firewall
|
|
154
|
+
scope fix (exec-only hard block) and the v0.1.8 semantic leg +
|
|
155
|
+
anti-obfuscation work documented below. The 0.1.x line on npm predates all
|
|
156
|
+
of it; if you installed from npm before, upgrade to 0.2.0.
|
|
157
|
+
|
|
158
|
+
> The v0.1.7 / v0.1.8 entries below use local internal iteration numbers —
|
|
159
|
+
> the npm 0.1.x packages do NOT contain this work. Everything here ships in
|
|
160
|
+
> 0.2.0 only.
|
|
161
|
+
|
|
162
|
+
### v0.1.8 (internal) — semantic leg + anti-obfuscation (2026-08-30)
|
|
163
|
+
1. **tools_search semantic leg**: BM25 + local bge-m3 (Ollama) RRF hybrid —
|
|
164
|
+
Chinese intents hit English-only tools that share no surface tokens (the
|
|
165
|
+
documented BM25 gap, README Known limits). Doc embeddings are cached to
|
|
166
|
+
`~/.dsh/state/semantic-cache.json` keyed by content fingerprint; the
|
|
167
|
+
~10s cold start for 300+ tools happens once. Any failure (Ollama down,
|
|
168
|
+
timeout, cache unreadable) degrades to pure BM25.
|
|
169
|
+
2. **ChainGuard anti-obfuscation leg** (`lib/obfuscation.js`): `verdict()` now
|
|
170
|
+
runs three legs — (a) literal patterns on the raw text; (b) de-obfuscated
|
|
171
|
+
text (quoted concatenation `"ne"+"t user"` / `-join` rebuild) re-run
|
|
172
|
+
through the literal patterns; (c) encoded shapes that no literal pattern
|
|
173
|
+
can see: `-enc` base64, `certutil -decode` chains, `bitsadmin /transfer`,
|
|
174
|
+
long base64 payload + exec verb, full-width homoglyphs (`curl`),
|
|
175
|
+
combining-mark mangling, hex-escaped bytes. All pure regex/string math,
|
|
176
|
+
zero deps, sync (pre-execute is a sync callback). Covered by 22 unit
|
|
177
|
+
tests (all pass); harmless concatenation (`"Get"+"-ChildItem"`) still
|
|
178
|
+
passes.
|
|
179
|
+
3. New config: `semanticEnabled` / `ollamaBase` / `embedModel`.
|
|
180
|
+
|
|
181
|
+
### v0.1.7 (internal) — ChainGuard false-positive fix (2026-08-30)
|
|
182
|
+
1. **P0 firewall scoped to exec tools**: `tools/pre-execute` high-risk hard
|
|
183
|
+
block (`verdict()`) now applies ONLY to exec-type tools (`isExecTool`:
|
|
184
|
+
pwsh/shell/bash/cmd/exec/run/terminal + `run_*`/`exec_*`/`ssh_*`/wsl
|
|
185
|
+
prefixes). Previously it ran against EVERY tool's arguments, so
|
|
186
|
+
content-type tools (write/edit/read — whose arguments are file contents or
|
|
187
|
+
text) were hard-blocked whenever their payload merely CONTAINED dangerous
|
|
188
|
+
command literals (e.g. writing a test script, a security analysis, or a
|
|
189
|
+
doc that quotes dangerous command examples). Verified: content write with
|
|
190
|
+
such literals was blocked before, is allowed after; exec tools still
|
|
191
|
+
hard-block real dangerous commands. `isExecTool` classification covered by
|
|
192
|
+
a 25-case unit check (all pass).
|
|
193
|
+
2. **Scope kept**: exfil-chain detection (`checkChain`, post-execute, warn-only)
|
|
194
|
+
and `deny` config are unchanged — they never hard-blocked content tools.
|
|
195
|
+
|
|
145
196
|
### v0.1.6 — settings UI schema (2026-08-25)
|
|
146
197
|
1. **`Config` schema exported (schemastery)**: every toggle now renders as a
|
|
147
198
|
native form in the DSH settings UI — no more hand-editing YAML. The schema
|
package/cordis.patch.yml
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
# merging. To override any value, restate the complete config under the same
|
|
9
9
|
# `id: tool-folder` in your profile's own `cordis.patch.yml` (or a `--patch`
|
|
10
10
|
# overlay). `!!js` expressions are allowed (e.g. env binding).
|
|
11
|
+
#
|
|
12
|
+
# v0.2.0: semanticEnabled (default true) adds the bge-m3 hybrid leg to
|
|
13
|
+
# tools_search; it degrades to BM25-only when Ollama is absent — no config
|
|
14
|
+
# needed here, code defaults apply for every field not listed below.
|
|
11
15
|
- insert:
|
|
12
16
|
- id: tool-folder
|
|
13
17
|
name: 'dsh-tool-folder'
|
package/lib/chainguard.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { deobfuscate, detectObfuscation } from "./obfuscation.js";
|
|
2
|
+
|
|
1
3
|
// ChainGuard — session-level tool-call firewall (ported from xiaowan tool_firewall T3).
|
|
2
4
|
//
|
|
3
5
|
// Two layers:
|
|
@@ -7,6 +9,12 @@
|
|
|
7
9
|
// call is harmless, the SEQUENCE is the attack).
|
|
8
10
|
//
|
|
9
11
|
// Zero dependencies, pure regex + window buffer. Runs in the cordis plugin sandbox.
|
|
12
|
+
//
|
|
13
|
+
// v0.1.8 (2026-08-30): obfuscation leg. verdict() now runs the literal
|
|
14
|
+
// patterns on the raw text, then de-obfuscates (quoted concatenation /
|
|
15
|
+
// -join) and re-runs them, then checks encoded shapes (-enc base64,
|
|
16
|
+
// certutil/bitsadmin decoder chains, base64 payload + exec verb, full-width
|
|
17
|
+
// and combining-mark homoglyphs, hex-escaped bytes).
|
|
10
18
|
|
|
11
19
|
const HIGH_RISK_PATTERNS = [
|
|
12
20
|
// destructive filesystem
|
|
@@ -41,13 +49,42 @@ const EXTERNAL_SEND = new Set([
|
|
|
41
49
|
"mcp__openhands__openhands_create_conversation",
|
|
42
50
|
]);
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
if (!argvText) return null;
|
|
52
|
+
function matchHighRisk(text) {
|
|
46
53
|
for (const [re, label] of HIGH_RISK_PATTERNS) {
|
|
47
|
-
if (re.test(
|
|
54
|
+
if (re.test(text)) {
|
|
48
55
|
return { blocked: true, label, pattern: String(re) };
|
|
49
56
|
}
|
|
50
57
|
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function verdict(argvText) {
|
|
62
|
+
if (!argvText) return null;
|
|
63
|
+
|
|
64
|
+
// Leg 1: literal patterns on the raw text (fast path).
|
|
65
|
+
const direct = matchHighRisk(argvText);
|
|
66
|
+
if (direct) return direct;
|
|
67
|
+
|
|
68
|
+
// Leg 2: de-obfuscated text (quoted concatenation / -join rebuild), then
|
|
69
|
+
// re-run the literal patterns — `"ne"+"t user"` must land like `net user`.
|
|
70
|
+
const clean = deobfuscate(argvText);
|
|
71
|
+
if (clean !== argvText) {
|
|
72
|
+
const rebuilt = matchHighRisk(clean);
|
|
73
|
+
if (rebuilt) {
|
|
74
|
+
return {
|
|
75
|
+
blocked: true,
|
|
76
|
+
label: rebuilt.label + "(拼接混淆还原后命中)",
|
|
77
|
+
pattern: String(rebuilt.pattern) + " | deobfuscated",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Leg 3: encoded shapes that no literal pattern can see.
|
|
83
|
+
const obf = detectObfuscation(argvText, clean);
|
|
84
|
+
if (obf) {
|
|
85
|
+
return { blocked: true, label: obf.label, detail: obf.detail };
|
|
86
|
+
}
|
|
87
|
+
|
|
51
88
|
return { blocked: false };
|
|
52
89
|
}
|
|
53
90
|
|
package/lib/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import { selectionCoverage } from "./metrics.js";
|
|
|
37
37
|
import { ChainGuard, verdict } from "./chainguard.js";
|
|
38
38
|
import { matchCategory } from "./category.js";
|
|
39
39
|
import { toonifyValue } from "./toonify.js";
|
|
40
|
+
import { configureSemantic, buildSemanticIndex, searchSemantic, rrfFuse } from "./semantic.js";
|
|
40
41
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
41
42
|
|
|
42
43
|
export const name = "tool-folder";
|
|
@@ -71,6 +72,9 @@ export const Config = z.object({
|
|
|
71
72
|
maxFoldMs: z.number().min(0).max(1000).default(50).description("折叠超时上限(ms),超时回退完整工具列表"),
|
|
72
73
|
feedbackFile: z.string().default("").description("反馈数据落盘路径(空=DSH_HOME/logs/tool-folder/)"),
|
|
73
74
|
aliases: z.dict(z.array(z.string())).default({}).description("中文意图→server 别名表"),
|
|
75
|
+
semanticEnabled: z.boolean().default(true).description("语义检索腿(本地 Ollama bge-m3):tools_search 用 BM25+语义 RRF 混合,中英跨语言命中"),
|
|
76
|
+
ollamaBase: z.string().default("http://127.0.0.1:11434").description("Ollama 地址(semanticEnabled 时使用)"),
|
|
77
|
+
embedModel: z.string().default("bge-m3").description("embedding 模型(semanticEnabled 时使用)"),
|
|
74
78
|
});
|
|
75
79
|
|
|
76
80
|
const DEFAULTS = {
|
|
@@ -101,6 +105,9 @@ const DEFAULTS = {
|
|
|
101
105
|
"mcp__open-design": ["设计", "design"],
|
|
102
106
|
"mcp__serena": ["代码搜索", "搜代码", "search code", "serena"],
|
|
103
107
|
},
|
|
108
|
+
semanticEnabled: true, // P0-1: bge-m3 semantic leg for tools_search (RRF hybrid)
|
|
109
|
+
ollamaBase: "http://127.0.0.1:11434",
|
|
110
|
+
embedModel: "bge-m3",
|
|
104
111
|
};
|
|
105
112
|
|
|
106
113
|
function feedbackPath(cfg) {
|
|
@@ -172,6 +179,24 @@ function serverOf(name) {
|
|
|
172
179
|
return "native";
|
|
173
180
|
}
|
|
174
181
|
|
|
182
|
+
/**
|
|
183
|
+
* 是否「执行类工具」——参数是真实要执行的命令/脚本,需要高危硬拦。
|
|
184
|
+
* 内容类工具(write/edit/read/apply_patch 等)的参数是文件内容/文本,
|
|
185
|
+
* 本身不执行,含危险命令字面量(讨论/测试/文档)不应触发硬拦。
|
|
186
|
+
* 2026-08-30:修复 ChainGuard 对所有工具参数跑高危正则导致的误拦。
|
|
187
|
+
*/
|
|
188
|
+
function isExecTool(name) {
|
|
189
|
+
if (!name) return false;
|
|
190
|
+
const n = String(name).toLowerCase();
|
|
191
|
+
const EXEC_NAMES = ["pwsh", "shell", "bash", "cmd", "exec", "terminal", "run", "console"];
|
|
192
|
+
if (EXEC_NAMES.includes(n)) return true;
|
|
193
|
+
if (n.includes("powershell")) return true; // mcp__windows__PowerShell 等(参数即命令)
|
|
194
|
+
if (n.endsWith("__app")) return true; // mcp__windows__App:launch_executable 模式可启动任意可执行文件
|
|
195
|
+
if (n.startsWith("run_") || n.startsWith("exec_") || n.startsWith("execute_")) return true;
|
|
196
|
+
if (n.startsWith("ssh_") || n.startsWith("wsl")) return true;
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
175
200
|
function summarizeCaps(tools) {
|
|
176
201
|
// Pull the first few distinct capability keywords from descriptions.
|
|
177
202
|
const seen = new Set();
|
|
@@ -230,6 +255,21 @@ export function apply(ctx, config) {
|
|
|
230
255
|
return;
|
|
231
256
|
}
|
|
232
257
|
|
|
258
|
+
// Semantic leg (P0-1): point the shared module at our config; the index is
|
|
259
|
+
// built lazily on first tools_search call (doc embeddings cached on disk,
|
|
260
|
+
// so the ~10s cold start happens once, then it's a cache hit).
|
|
261
|
+
configureSemantic({ ollamaBase: cfg.ollamaBase, embedModel: cfg.embedModel });
|
|
262
|
+
let semIndexPromise = null;
|
|
263
|
+
const getSemanticIndex = () => {
|
|
264
|
+
if (!semIndexPromise) {
|
|
265
|
+
semIndexPromise = (async () => {
|
|
266
|
+
const docs = allTools.map((t) => ({ id: t.name, text: `${t.name} ${t.description || ""}` }));
|
|
267
|
+
return buildSemanticIndex(docs);
|
|
268
|
+
})();
|
|
269
|
+
}
|
|
270
|
+
return semIndexPromise;
|
|
271
|
+
};
|
|
272
|
+
|
|
233
273
|
const fbPath = feedbackPath(cfg);
|
|
234
274
|
const feedback = loadFeedback(fbPath, logger);
|
|
235
275
|
let index = null;
|
|
@@ -363,15 +403,35 @@ export function apply(ctx, config) {
|
|
|
363
403
|
const q = String(args.query || "");
|
|
364
404
|
const lim = args.limit ?? 8;
|
|
365
405
|
if (!index || !q) return { query: q, results: [], total: 0 };
|
|
366
|
-
const
|
|
406
|
+
const bm25Hits = search(index, q, lim * 2).map((i) => ({ id: allTools[i].name }));
|
|
407
|
+
let fused = bm25Hits;
|
|
408
|
+
// Semantic leg (P0-1): RRF-fuse BM25 with local bge-m3 similarity.
|
|
409
|
+
// Any failure (Ollama down / timeout / cache miss on error) degrades
|
|
410
|
+
// to BM25-only — the hybrid must never be slower or worse than today.
|
|
411
|
+
if (cfg.semanticEnabled !== false) {
|
|
412
|
+
try {
|
|
413
|
+
const sem = await getSemanticIndex();
|
|
414
|
+
if (sem?.available) {
|
|
415
|
+
const docs = allTools.map((t) => ({ id: t.name, text: `${t.name} ${t.description || ""}` }));
|
|
416
|
+
const semHits = await searchSemantic(sem.vectors, docs, q);
|
|
417
|
+
fused = rrfFuse(bm25Hits, semHits.slice(0, lim * 2), lim);
|
|
418
|
+
}
|
|
419
|
+
} catch (e) {
|
|
420
|
+
logger.warn("semantic leg failed (%s) — BM25 only", e?.message);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const byName = new Map(allTools.map((t) => [t.name, t]));
|
|
367
424
|
return {
|
|
368
425
|
query: q,
|
|
369
|
-
results:
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
426
|
+
results: fused.map((h) => {
|
|
427
|
+
const t = byName.get(h.id) || {};
|
|
428
|
+
return {
|
|
429
|
+
name: h.id,
|
|
430
|
+
description: (t.description || "").slice(0, 160),
|
|
431
|
+
server: serverOf(h.id),
|
|
432
|
+
};
|
|
433
|
+
}),
|
|
434
|
+
total: fused.length,
|
|
375
435
|
};
|
|
376
436
|
},
|
|
377
437
|
});
|
|
@@ -677,11 +737,18 @@ export function apply(ctx, config) {
|
|
|
677
737
|
}
|
|
678
738
|
|
|
679
739
|
if (cfg.firewallEnabled !== false) {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
740
|
+
// 2026-08-30 修复:高危硬拦只对「执行类工具」生效。
|
|
741
|
+
// 原实现对所有工具的 arguments 跑高危正则,导致内容类工具
|
|
742
|
+
// (write/edit/read 等参数是文件内容/文本)只要含危险命令的
|
|
743
|
+
// 字面量(如讨论、测试、文档中的示例)就被误拦。
|
|
744
|
+
// 执行类工具的参数才是真实要执行的命令,需要硬拦。
|
|
745
|
+
if (isExecTool(name)) {
|
|
746
|
+
const argvText = execArgumentsText(exec?.arguments);
|
|
747
|
+
const v = verdict(argvText);
|
|
748
|
+
if (v?.blocked) {
|
|
749
|
+
logger.warn("[firewall] BLOCKED %s: %s", name, v.label);
|
|
750
|
+
return { kind: "deny", reason: `[tool-folder firewall] 高危命令已拦截(${v.label})。如需执行请人工确认。` };
|
|
751
|
+
}
|
|
685
752
|
}
|
|
686
753
|
}
|
|
687
754
|
} catch (e) {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* obfuscation.js — anti-obfuscation detection for ChainGuard (2026-08-30).
|
|
3
|
+
*
|
|
4
|
+
* ChainGuard's hard-block layer is regex over the argv text. Attackers (and
|
|
5
|
+
* red-team prompts) routinely bypass literal patterns with:
|
|
6
|
+
* - string concatenation `"ne"+"t user"` / 'r'+'m' / `r``m`
|
|
7
|
+
* - encoded commands `powershell -enc <base64>` / `-EncodedCommand`
|
|
8
|
+
* - decoder chains `certutil -decode in.b64 out.exe && out.exe`
|
|
9
|
+
* - download-exec `bitsadmin /transfer /download` (+ /start)
|
|
10
|
+
* - embedded payloads base64 blob near iex / iwr | iex / & / cmd /c
|
|
11
|
+
* - homoglyph/full-width curl / c̶u̶r̶l (combining marks)
|
|
12
|
+
*
|
|
13
|
+
* This module is pure string math: zero dependencies, synchronous, safe in
|
|
14
|
+
* any sandbox. `verdict()` in chainguard.js calls it as the second leg —
|
|
15
|
+
* first the literal patterns on the raw text, then this layer on the
|
|
16
|
+
* de-obfuscated text and on the encoded shapes.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/* ------------------------------------------------------------------ */
|
|
20
|
+
/* 1. De-obfuscation: rebuild concatenated strings into plain text */
|
|
21
|
+
/* ------------------------------------------------------------------ */
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Collapse quoted concatenations: "ne"+"t" / 'ne'+'t' / "ne" + 't' →
|
|
25
|
+
* "net". Iterates until fixpoint (nested joins). Returns the rebuilt text
|
|
26
|
+
* or the original when nothing changed.
|
|
27
|
+
*/
|
|
28
|
+
export function deobfuscate(text) {
|
|
29
|
+
let s = String(text || "");
|
|
30
|
+
let prev = "";
|
|
31
|
+
let guard = 0;
|
|
32
|
+
while (s !== prev && guard++ < 8) {
|
|
33
|
+
prev = s;
|
|
34
|
+
// double-quoted pairs: "a" + "b"
|
|
35
|
+
s = s.replace(/"([^"\n]{0,60})"\s*\+\s*"([^"\n]{0,60})"/g, '"$1$2"');
|
|
36
|
+
// single-quoted pairs: 'a' + 'b'
|
|
37
|
+
s = s.replace(/'([^'\n]{0,60})'\s*\+\s*'([^'\n]{0,60})'/g, "'$1$2'");
|
|
38
|
+
// mixed quotes: "a" + 'b'
|
|
39
|
+
s = s.replace(/"([^"\n]{0,60})"\s*\+\s*'([^'\n]{0,60})'/g, '"$1$2"');
|
|
40
|
+
s = s.replace(/'([^'\n]{0,60})'\s*\+\s*"([^"\n]{0,60})"/g, '"$1$2"');
|
|
41
|
+
// PowerShell backtick escape joins: r``m or r`+`m style is rarer; skip.
|
|
42
|
+
// PowerShell -join: ('a','b') -join '' → ab (only when -join '' or "")
|
|
43
|
+
s = s.replace(/\(\s*'([^'\n]*?)'\s*(?:,\s*'([^'\n]*?)'\s*)+\)\s*-join\s*(''|"")/g, (_m, first, rest) => {
|
|
44
|
+
const parts = [first];
|
|
45
|
+
if (rest) parts.push(rest);
|
|
46
|
+
return `'${parts.join("")}'`;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return s;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ------------------------------------------------------------------ */
|
|
53
|
+
/* 2. Encoded / decoder-chain shapes */
|
|
54
|
+
/* ------------------------------------------------------------------ */
|
|
55
|
+
|
|
56
|
+
// PowerShell -enc / -EncodedCommand followed by a base64 payload.
|
|
57
|
+
const RE_ENC = /(?:-enc(?:odedcommand)?|--encodedcommand)\s*[=: ]\s*([A-Za-z0-9+/]{16,}={0,2})/i;
|
|
58
|
+
|
|
59
|
+
// certutil decode chain (Windows LOLBin): decodes a file, then the decoded
|
|
60
|
+
// artifact is executable in the same command.
|
|
61
|
+
const RE_CERTUTIL = /certutil\s+(?:-[a-z]*decode(?:hex)?\s+[^\s|;&]+|.*-decode(?:hex)?\s+[^\s|;&]+\s+[^\s|;&]+)/i;
|
|
62
|
+
|
|
63
|
+
// bitsadmin transfer + start (download → execute without touching disk).
|
|
64
|
+
const RE_BITSADMIN = /bitsadmin\s+\/transfer\s+\S+\s+\/download/i;
|
|
65
|
+
|
|
66
|
+
// A long base64 blob co-located with an execution verb in the same command.
|
|
67
|
+
const RE_B64_PAYLOAD = /[A-Za-z0-9+/]{40,}={0,2}/;
|
|
68
|
+
const EXEC_VERB = /(?:iex|invoke-expression|invoke-command|iwr|irm|start-process|start-job|&|cmd\s*\/[cC]|sh\s+-c|bash\s+-c|python\s+-[cC]|node\s+-e|powershell|pwsh|mshta|rundll32|regsvr32|wscript|cscript)\b/i;
|
|
69
|
+
|
|
70
|
+
// Full-width Latin letters (curl) — never legit in a command line.
|
|
71
|
+
const RE_FULLWIDTH = /[\uFF21-\uFF3A\uFF41-\uFF5A]/;
|
|
72
|
+
|
|
73
|
+
// Combining diacritical marks (homoglyph mangling: c̶u̶r̶l).
|
|
74
|
+
const RE_COMBINING = /[\u0300-\u036F\u1AB0-\u1AFF\u20D0-\u20FF]/;
|
|
75
|
+
|
|
76
|
+
// Escaped hex byte sequences (e.g. \x63\x75\x72\x6c) co-located with exec.
|
|
77
|
+
const RE_HEXBYTES = /(?:\\x[0-9a-fA-F]{2}){3,}/;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Detect encoded / obfuscated command shapes.
|
|
81
|
+
* @param {string} rawText original argv text
|
|
82
|
+
* @param {string} [cleanText] de-obfuscated text (pass to avoid double work)
|
|
83
|
+
* @returns {null | {label: string, detail?: string}}
|
|
84
|
+
*/
|
|
85
|
+
export function detectObfuscation(rawText, cleanText) {
|
|
86
|
+
const raw = String(rawText || "");
|
|
87
|
+
const clean = cleanText === undefined ? deobfuscate(raw) : cleanText;
|
|
88
|
+
const probe = clean !== raw ? clean : raw;
|
|
89
|
+
|
|
90
|
+
const enc = RE_ENC.exec(probe);
|
|
91
|
+
if (enc) {
|
|
92
|
+
return { label: "encoded-command", detail: `-enc base64 载荷(前 24 字符 ${enc[1].slice(0, 24)}…)` };
|
|
93
|
+
}
|
|
94
|
+
if (RE_CERTUTIL.test(probe)) {
|
|
95
|
+
return { label: "certutil-decode-chain", detail: "certutil 解码 + 执行链" };
|
|
96
|
+
}
|
|
97
|
+
if (RE_BITSADMIN.test(probe)) {
|
|
98
|
+
return { label: "bitsadmin-download-exec", detail: "bitsadmin /transfer 下载执行" };
|
|
99
|
+
}
|
|
100
|
+
if (RE_B64_PAYLOAD.test(probe) && EXEC_VERB.test(probe)) {
|
|
101
|
+
return { label: "base64-embedded-command", detail: "长 base64 载荷 + 执行动词共存" };
|
|
102
|
+
}
|
|
103
|
+
if (RE_FULLWIDTH.test(raw)) {
|
|
104
|
+
return { label: "fullwidth-homoglyph", detail: "全角字母(命令混淆)" };
|
|
105
|
+
}
|
|
106
|
+
if (RE_COMBINING.test(raw)) {
|
|
107
|
+
return { label: "combining-mark-obfuscation", detail: "组合变音标记(同形符混淆)" };
|
|
108
|
+
}
|
|
109
|
+
if (RE_HEXBYTES.test(probe) && EXEC_VERB.test(probe)) {
|
|
110
|
+
return { label: "hex-encoded-command", detail: "\\xNN 字节编码 + 执行动词" };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Combined check used by verdict(): returns the first obfuscation hit, or
|
|
117
|
+
* null when the text looks clean.
|
|
118
|
+
*/
|
|
119
|
+
export function checkObfuscation(text) {
|
|
120
|
+
const raw = String(text || "");
|
|
121
|
+
if (!raw) return null;
|
|
122
|
+
const clean = deobfuscate(raw);
|
|
123
|
+
return detectObfuscation(raw, clean);
|
|
124
|
+
}
|
package/lib/semantic.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* semantic.js — local semantic retrieval leg via Ollama bge-m3 (2026-08-30).
|
|
3
|
+
*
|
|
4
|
+
* Complements the BM25 lexical leg (bm25.js) with a cross-lingual semantic
|
|
5
|
+
* leg: Chinese intents can hit English-only tool/skill descriptions that
|
|
6
|
+
* share no surface tokens (the documented BM25 gap). Uses the machine's
|
|
7
|
+
* local Ollama (`http://127.0.0.1:11434/api/embed`, model bge-m3 — 1024 dims,
|
|
8
|
+
* ~1.2GB, already installed per ~/.dsh config). No npm deps, no network egress.
|
|
9
|
+
*
|
|
10
|
+
* Hard-fail design: every external path (fetch, fs cache) is optional. If
|
|
11
|
+
* Ollama is down, the cache is unreadable, or anything throws, callers get
|
|
12
|
+
* null/[] and their existing BM25 path just keeps working.
|
|
13
|
+
*
|
|
14
|
+
* Cache: document embeddings are keyed by text and persisted to
|
|
15
|
+
* `~/.dsh/state/semantic-cache.json` (fingerprint = hash of all doc texts), so
|
|
16
|
+
* the ~10s cold start for 300+ tools happens once, not per session.
|
|
17
|
+
* Query embeddings are cached in memory only (per-process, tiny).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const OLLAMA_BASE = "http://127.0.0.1:11434";
|
|
21
|
+
const DEFAULT_MODEL = "bge-m3";
|
|
22
|
+
const EMBED_TIMEOUT_MS = 8000;
|
|
23
|
+
const CACHE_FILE = ".dsh/state/semantic-cache.json";
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
|
|
27
|
+
/* ------------------------------------------------------------------ */
|
|
28
|
+
/* hash + fs helpers (all fail-safe) */
|
|
29
|
+
/* ------------------------------------------------------------------ */
|
|
30
|
+
|
|
31
|
+
/** FNV-1a 32-bit hex — fast, dependency-free fingerprint. */
|
|
32
|
+
function hash(text) {
|
|
33
|
+
let h = 0x811c9dc5;
|
|
34
|
+
const s = String(text || "");
|
|
35
|
+
for (let i = 0; i < s.length; i++) {
|
|
36
|
+
h ^= s.charCodeAt(i);
|
|
37
|
+
h = (h * 0x01000193) >>> 0;
|
|
38
|
+
}
|
|
39
|
+
return h.toString(16).padStart(8, "0");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function homeDir() {
|
|
43
|
+
return process.env.USERPROFILE || process.env.HOME || "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readCacheFile() {
|
|
47
|
+
try {
|
|
48
|
+
const p = homeDir() + "/" + CACHE_FILE;
|
|
49
|
+
if (!p || !fs.existsSync(p)) return null;
|
|
50
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
51
|
+
const j = JSON.parse(raw);
|
|
52
|
+
if (j && typeof j === "object") return j;
|
|
53
|
+
} catch {
|
|
54
|
+
/* unreadable → miss */
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeCacheFile(obj) {
|
|
60
|
+
try {
|
|
61
|
+
const p = homeDir() + "/" + CACHE_FILE;
|
|
62
|
+
const dir = p.slice(0, p.lastIndexOf("/"));
|
|
63
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
64
|
+
fs.writeFileSync(p, JSON.stringify(obj), "utf8");
|
|
65
|
+
} catch {
|
|
66
|
+
/* cache write is best-effort */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* ------------------------------------------------------------------ */
|
|
71
|
+
/* Ollama embedding (fail-safe fetch) */
|
|
72
|
+
/* ------------------------------------------------------------------ */
|
|
73
|
+
|
|
74
|
+
let _model = DEFAULT_MODEL;
|
|
75
|
+
let _base = OLLAMA_BASE;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Configure the endpoint (called by plugin apply with cfg.ollamaBase/model).
|
|
79
|
+
* @param {{ollamaBase?: string, embedModel?: string}} cfg
|
|
80
|
+
*/
|
|
81
|
+
export function configureSemantic(cfg = {}) {
|
|
82
|
+
if (cfg && typeof cfg === "object") {
|
|
83
|
+
if (typeof cfg.ollamaBase === "string" && cfg.ollamaBase) _base = cfg.ollamaBase;
|
|
84
|
+
if (typeof cfg.embedModel === "string" && cfg.embedModel) _model = cfg.embedModel;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One embedding vector for a text, or null on any failure. */
|
|
89
|
+
export async function embedText(text, timeoutMs = EMBED_TIMEOUT_MS) {
|
|
90
|
+
const s = String(text || "").trim();
|
|
91
|
+
if (!s) return null;
|
|
92
|
+
try {
|
|
93
|
+
const ctrl = new AbortController();
|
|
94
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch(_base + "/api/embed", {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify({ model: _model, input: s }),
|
|
100
|
+
signal: ctrl.signal,
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) return null;
|
|
103
|
+
const j = await res.json();
|
|
104
|
+
const v = j?.embeddings?.[0];
|
|
105
|
+
return Array.isArray(v) && v.length > 0 ? v : null;
|
|
106
|
+
} finally {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
return null; // offline / timeout / bad response → lexical leg only
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Cosine similarity in [0,1] (both vectors plain arrays). */
|
|
115
|
+
export function cosine(a, b) {
|
|
116
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) return 0;
|
|
117
|
+
let dot = 0;
|
|
118
|
+
let na = 0;
|
|
119
|
+
let nb = 0;
|
|
120
|
+
for (let i = 0; i < a.length; i++) {
|
|
121
|
+
dot += a[i] * b[i];
|
|
122
|
+
na += a[i] * a[i];
|
|
123
|
+
nb += b[i] * b[i];
|
|
124
|
+
}
|
|
125
|
+
if (na === 0 || nb === 0) return 0;
|
|
126
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ------------------------------------------------------------------ */
|
|
130
|
+
/* Semantic index with persistent doc-embedding cache */
|
|
131
|
+
/* ------------------------------------------------------------------ */
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build a semantic index over docs. Doc embeddings come from the on-disk
|
|
135
|
+
* cache when the fingerprint matches; otherwise they are computed once and
|
|
136
|
+
* persisted. Never blocks forever: total batch time is bounded and a failure
|
|
137
|
+
* returns an index whose vectors are simply empty (lexical leg still works).
|
|
138
|
+
*
|
|
139
|
+
* @param {Array<{id: string, text: string}>} docs
|
|
140
|
+
* @param {number} [timeoutMs] per-embed timeout
|
|
141
|
+
* @returns {Promise<{vectors: Map<string, number[]>, available: boolean}>}
|
|
142
|
+
*/
|
|
143
|
+
export async function buildSemanticIndex(docs, timeoutMs = EMBED_TIMEOUT_MS) {
|
|
144
|
+
const out = { vectors: new Map(), available: false };
|
|
145
|
+
const list = Array.isArray(docs) ? docs.filter((d) => d && d.id && d.text) : [];
|
|
146
|
+
if (list.length === 0) return out;
|
|
147
|
+
|
|
148
|
+
// Fast path: cache hit.
|
|
149
|
+
const fp = hash(list.map((d) => d.id + "\u0000" + d.text).join("\u0001"));
|
|
150
|
+
const cache = readCacheFile();
|
|
151
|
+
const cached = cache && cache.fp === fp && cache.vec && typeof cache.vec === "object" ? cache.vec : null;
|
|
152
|
+
if (cached) {
|
|
153
|
+
for (const d of list) {
|
|
154
|
+
const v = cached[d.id];
|
|
155
|
+
if (Array.isArray(v) && v.length > 0) out.vectors.set(d.id, v);
|
|
156
|
+
}
|
|
157
|
+
if (out.vectors.size === list.length) {
|
|
158
|
+
out.available = true;
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Miss: embed each doc (sequential — CPU-bound local model, avoids
|
|
164
|
+
// overloading Ollama), bounded by an overall deadline.
|
|
165
|
+
const deadline = Date.now() + 60_000;
|
|
166
|
+
const fresh = {};
|
|
167
|
+
let ok = 0;
|
|
168
|
+
for (const d of list) {
|
|
169
|
+
if (Date.now() > deadline) break;
|
|
170
|
+
const v = await embedText(d.text, timeoutMs);
|
|
171
|
+
if (v) {
|
|
172
|
+
fresh[d.id] = v;
|
|
173
|
+
out.vectors.set(d.id, v);
|
|
174
|
+
ok++;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (ok === list.length) {
|
|
178
|
+
out.available = true;
|
|
179
|
+
writeCacheFile({ fp, vec: fresh }); // persist only on full success
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Rank docs by semantic similarity to a query.
|
|
186
|
+
* @param {Map<string, number[]>} vectors id → vector
|
|
187
|
+
* @param {Array<{id: string, text: string}>} docs
|
|
188
|
+
* @param {string} query
|
|
189
|
+
* @returns {Promise<Array<{id: string, score: number}>>} sorted desc, score = cosine
|
|
190
|
+
*/
|
|
191
|
+
export async function searchSemantic(vectors, docs, query) {
|
|
192
|
+
const q = String(query || "").trim();
|
|
193
|
+
if (!q || !vectors || vectors.size === 0) return [];
|
|
194
|
+
const qv = await embedText(q);
|
|
195
|
+
if (!qv) return [];
|
|
196
|
+
const scored = [];
|
|
197
|
+
for (const d of docs) {
|
|
198
|
+
const v = vectors.get(d.id);
|
|
199
|
+
if (!v) continue;
|
|
200
|
+
const c = cosine(qv, v);
|
|
201
|
+
if (c > 0) scored.push({ id: d.id, score: c });
|
|
202
|
+
}
|
|
203
|
+
scored.sort((a, b) => b.score - a.score);
|
|
204
|
+
return scored;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* ------------------------------------------------------------------ */
|
|
208
|
+
/* RRF hybrid: merge a BM25 ranking and a semantic ranking */
|
|
209
|
+
/* ------------------------------------------------------------------ */
|
|
210
|
+
|
|
211
|
+
const RRF_K = 60;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Reciprocal-rank fusion of two ranked id lists.
|
|
215
|
+
* @param {Array<{id: string}>} bm25Ranked BM25 hits in rank order
|
|
216
|
+
* @param {Array<{id: string}>} semRanked semantic hits in rank order
|
|
217
|
+
* @param {number} topK
|
|
218
|
+
* @returns {Array<{id: string, rrf: number, from: string}>} fused, sorted desc
|
|
219
|
+
*/
|
|
220
|
+
export function rrfFuse(bm25Ranked, semRanked, topK) {
|
|
221
|
+
const scores = new Map();
|
|
222
|
+
const from = new Map();
|
|
223
|
+
bm25Ranked.forEach((h, i) => {
|
|
224
|
+
const key = h.id;
|
|
225
|
+
scores.set(key, (scores.get(key) || 0) + 1 / (RRF_K + i + 1));
|
|
226
|
+
from.set(key, (from.get(key) || "") + "bm25 ");
|
|
227
|
+
});
|
|
228
|
+
semRanked.forEach((h, i) => {
|
|
229
|
+
const key = h.id;
|
|
230
|
+
scores.set(key, (scores.get(key) || 0) + 1 / (RRF_K + i + 1));
|
|
231
|
+
from.set(key, (from.get(key) || "") + "sem ");
|
|
232
|
+
});
|
|
233
|
+
const k = Number(topK);
|
|
234
|
+
const n = Number.isFinite(k) && k > 0 ? Math.floor(k) : 5;
|
|
235
|
+
return [...scores.entries()]
|
|
236
|
+
.sort((a, b) => b[1] - a[1])
|
|
237
|
+
.slice(0, n)
|
|
238
|
+
.map(([id, r]) => ({ id, rrf: r, from: from.get(id)?.trim() || "" }));
|
|
239
|
+
}
|
package/package.json
CHANGED
|
@@ -1,38 +1,48 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-tool-folder",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Fold the DSH tool surface per request + ChainGuard firewall (high-risk block + exfil-chain detection). Shrinks schema tokens 80-90% while keeping selection accuracy. v0.
|
|
5
|
-
"keywords": [
|
|
6
|
-
"dsh-plugin",
|
|
7
|
-
"dsh",
|
|
8
|
-
"deepseek-harness",
|
|
9
|
-
"cordis",
|
|
10
|
-
"tool-selection",
|
|
11
|
-
"token-optimization",
|
|
12
|
-
"bm25"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
},
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
},
|
|
34
|
-
"
|
|
35
|
-
"@deepseek-ai/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-tool-folder",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Fold the DSH tool surface per request + ChainGuard firewall (high-risk block + exfil-chain detection + anti-obfuscation) + BM25/bge-m3 hybrid tools_search. Shrinks schema tokens 80-90% while keeping selection accuracy. v0.2.0 adds a semantic retrieval leg (local Ollama bge-m3, RRF hybrid) and ChainGuard obfuscation detection (concat-rebuild / -enc / certutil / bitsadmin / homoglyphs).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh-plugin",
|
|
7
|
+
"dsh",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"cordis",
|
|
10
|
+
"tool-selection",
|
|
11
|
+
"token-optimization",
|
|
12
|
+
"bm25",
|
|
13
|
+
"semantic-search",
|
|
14
|
+
"security",
|
|
15
|
+
"firewall",
|
|
16
|
+
"obfuscation"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "lib/index.js",
|
|
20
|
+
"files": [
|
|
21
|
+
"lib",
|
|
22
|
+
"cordis.patch.yml",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"dsh": {
|
|
26
|
+
"bundle": {
|
|
27
|
+
"patch": "./cordis.patch.yml"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
39
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "node --test \"test/*.test.js\""
|
|
43
|
+
},
|
|
44
|
+
"exports": {
|
|
45
|
+
".": "./lib/index.js",
|
|
46
|
+
"./lib/*": "./lib/*"
|
|
47
|
+
}
|
|
48
|
+
}
|