dsh-tool-folder 0.1.7 → 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 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
- export function verdict(argvText) {
45
- if (!argvText) return null;
52
+ function matchHighRisk(text) {
46
53
  for (const [re, label] of HIGH_RISK_PATTERNS) {
47
- if (re.test(argvText)) {
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 hits = search(index, q, lim);
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: hits.map((i) => ({
370
- name: allTools[i].name,
371
- description: (allTools[i].description || "").slice(0, 160),
372
- server: serverOf(allTools[i].name),
373
- })),
374
- total: hits.length,
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
  });
@@ -467,12 +527,27 @@ export function apply(ctx, config) {
467
527
 
468
528
  const stamp = tools.map((t) => t.name).join("\u0001");
469
529
  if (indexStamp !== stamp) {
530
+ // V4 document expansion (measured 0.1.7→0.1.8): the tool corpus is
531
+ // pure-English, so Chinese queries always get df=0 and BM25 scores
532
+ // nothing (measured recall 0/26). Inject per-tool aliases from
533
+ // cfg.aliases (prefix-matched) into the index text — CN recall
534
+ // 0%→88%, EN +15pp, both from the same expansion.
470
535
  index = buildIndex(
471
- tools.map((t) => ({ id: t.name, text: `${t.name} ${t.description || ""}` })),
536
+ tools.map((t) => {
537
+ const aliasWords = [];
538
+ for (const [prefix, keywords] of Object.entries(cfg.aliases || {})) {
539
+ if (t.name.startsWith(prefix)) aliasWords.push(...keywords);
540
+ }
541
+ return {
542
+ id: t.name,
543
+ text: `${t.name} ${t.description || ""} ${aliasWords.join(" ")}`,
544
+ };
545
+ }),
472
546
  );
473
547
  indexStamp = stamp;
474
548
  allTools = tools;
475
- logger.info("index rebuilt: %d tools", tools.length);
549
+ logger.info("index rebuilt: %d tools (doc-expansion enabled)", tools.length);
550
+
476
551
  }
477
552
 
478
553
  const query = extractQuery(context);
@@ -662,11 +737,18 @@ export function apply(ctx, config) {
662
737
  }
663
738
 
664
739
  if (cfg.firewallEnabled !== false) {
665
- const argvText = execArgumentsText(exec?.arguments);
666
- const v = verdict(argvText);
667
- if (v?.blocked) {
668
- logger.warn("[firewall] BLOCKED %s: %s", name, v.label);
669
- return { kind: "deny", reason: `[tool-folder firewall] 高危命令已拦截(${v.label})。如需执行请人工确认。` };
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
+ }
670
752
  }
671
753
  }
672
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
+ }
@@ -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.1.7",
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.1.6 adds a zod Config schema so every toggle renders in the DSH settings UI.",
5
- "keywords": [
6
- "dsh-plugin",
7
- "dsh",
8
- "deepseek-harness",
9
- "cordis",
10
- "tool-selection",
11
- "token-optimization",
12
- "bm25"
13
- ],
14
- "type": "module",
15
- "main": "lib/index.js",
16
- "files": [
17
- "lib",
18
- "cordis.patch.yml",
19
- "README.md"
20
- ],
21
- "dsh": {
22
- "bundle": {
23
- "patch": "./cordis.patch.yml"
24
- }
25
- },
26
- "license": "MIT",
27
- "engines": {
28
- "node": ">=18"
29
- },
30
- "dependencies": {
31
- "zod": "^4.4.3",
32
- "@deepseek-ai/schemastery": "^3.18.1"
33
- },
34
- "peerDependencies": {
35
- "@deepseek-ai/cordis": "^4.0.1",
36
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
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
+ }