dsh-tool-folder 0.1.8 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,63 @@ were never unregistered, so nothing else changes.
142
149
 
143
150
  ## Changelog
144
151
 
152
+ ### v0.2.1 — heat decay + exact-name priority (2026-08-30)
153
+ 1. **Hot heat decay** (`hotWindowDays`, default 3): a tool is promoted to
154
+ always-visible only when its call count stays inside the sliding window —
155
+ the old code promoted forever, so any tool used 3 times was permanently
156
+ resident and the fold silently decayed to zero over time. Legacy
157
+ `feedback.json` number entries are read as out-of-window → their stale
158
+ heat expires naturally. Set `hotWindowDays: 0` to keep the old behavior.
159
+ 2. **Exact-name priority in `tools_search`**: exact tool-name match always
160
+ ranks first, a ≥4-char query that is a name substring is pulled ahead of
161
+ BM25/semantic hits (deterministic fallback chain). Results stay
162
+ deduplicated.
163
+ 3. 40 tests green (11 new: window decay / legacy compat / priority order).
164
+
165
+ ### v0.2.0 — first npm release (2026-08-30)
166
+ This is the first version published to npm. It bundles the v0.1.7 firewall
167
+ scope fix (exec-only hard block) and the v0.1.8 semantic leg +
168
+ anti-obfuscation work documented below. The 0.1.x line on npm predates all
169
+ of it; if you installed from npm before, upgrade to 0.2.0.
170
+
171
+ > The v0.1.7 / v0.1.8 entries below use local internal iteration numbers —
172
+ > the npm 0.1.x packages do NOT contain this work. Everything here ships in
173
+ > 0.2.0 only.
174
+
175
+ ### v0.1.8 (internal) — semantic leg + anti-obfuscation (2026-08-30)
176
+ 1. **tools_search semantic leg**: BM25 + local bge-m3 (Ollama) RRF hybrid —
177
+ Chinese intents hit English-only tools that share no surface tokens (the
178
+ documented BM25 gap, README Known limits). Doc embeddings are cached to
179
+ `~/.dsh/state/semantic-cache.json` keyed by content fingerprint; the
180
+ ~10s cold start for 300+ tools happens once. Any failure (Ollama down,
181
+ timeout, cache unreadable) degrades to pure BM25.
182
+ 2. **ChainGuard anti-obfuscation leg** (`lib/obfuscation.js`): `verdict()` now
183
+ runs three legs — (a) literal patterns on the raw text; (b) de-obfuscated
184
+ text (quoted concatenation `"ne"+"t user"` / `-join` rebuild) re-run
185
+ through the literal patterns; (c) encoded shapes that no literal pattern
186
+ can see: `-enc` base64, `certutil -decode` chains, `bitsadmin /transfer`,
187
+ long base64 payload + exec verb, full-width homoglyphs (`curl`),
188
+ combining-mark mangling, hex-escaped bytes. All pure regex/string math,
189
+ zero deps, sync (pre-execute is a sync callback). Covered by 22 unit
190
+ tests (all pass); harmless concatenation (`"Get"+"-ChildItem"`) still
191
+ passes.
192
+ 3. New config: `semanticEnabled` / `ollamaBase` / `embedModel`.
193
+
194
+ ### v0.1.7 (internal) — ChainGuard false-positive fix (2026-08-30)
195
+ 1. **P0 firewall scoped to exec tools**: `tools/pre-execute` high-risk hard
196
+ block (`verdict()`) now applies ONLY to exec-type tools (`isExecTool`:
197
+ pwsh/shell/bash/cmd/exec/run/terminal + `run_*`/`exec_*`/`ssh_*`/wsl
198
+ prefixes). Previously it ran against EVERY tool's arguments, so
199
+ content-type tools (write/edit/read — whose arguments are file contents or
200
+ text) were hard-blocked whenever their payload merely CONTAINED dangerous
201
+ command literals (e.g. writing a test script, a security analysis, or a
202
+ doc that quotes dangerous command examples). Verified: content write with
203
+ such literals was blocked before, is allowed after; exec tools still
204
+ hard-block real dangerous commands. `isExecTool` classification covered by
205
+ a 25-case unit check (all pass).
206
+ 2. **Scope kept**: exfil-chain detection (`checkChain`, post-execute, warn-only)
207
+ and `deny` config are unchanged — they never hard-blocked content tools.
208
+
145
209
  ### v0.1.6 — settings UI schema (2026-08-25)
146
210
  1. **`Config` schema exported (schemastery)**: every toggle now renders as a
147
211
  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
@@ -33,10 +33,11 @@ import path from "node:path";
33
33
  import z from "@deepseek-ai/schemastery";
34
34
  import { buildIndex, search } from "./bm25.js";
35
35
  import { compressTool, normalizeDescription, sanitizeLossless } from "./schema.js";
36
- import { selectionCoverage } from "./metrics.js";
36
+ import { selectionCoverage, isHotTool, prioritizeExact } 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";
@@ -58,6 +59,8 @@ export const Config = z.object({
58
59
  deny: z.array(z.string()).default([]).description("禁用工具:精确名或 prefix* 前缀,命中不注入+拒绝执行"),
59
60
  topK: z.number().min(0).max(50).default(6).description("BM25 动态检索每轮最多注入的工具数"),
60
61
  hotThreshold: z.number().min(1).default(3).description("被折叠工具调用几次后自动晋升为常驻"),
62
+ hotWindowDays: z.number().min(0).max(365).default(3)
63
+ .description("hot 热度的滑动窗口(天):窗口内达标才常驻,超窗自动衰减;0=永不过期"),
61
64
  catalogEnabled: z.boolean().default(true).description("折叠安全网:把被藏工具列表写进提示词,模型知道它们存在"),
62
65
  compressLevel: z.union(COMPRESS_LEVELS).default("off").description("L1 schema 压缩级别:off 不压缩 / light 裁描述 / standard 裁描述+参数说明 / aggressive 删可选参数"),
63
66
  compressEnabled: z.boolean().default(false).description("兼容开关:compressLevel=off 且此开=true 时按 standard 处理"),
@@ -71,6 +74,9 @@ export const Config = z.object({
71
74
  maxFoldMs: z.number().min(0).max(1000).default(50).description("折叠超时上限(ms),超时回退完整工具列表"),
72
75
  feedbackFile: z.string().default("").description("反馈数据落盘路径(空=DSH_HOME/logs/tool-folder/)"),
73
76
  aliases: z.dict(z.array(z.string())).default({}).description("中文意图→server 别名表"),
77
+ semanticEnabled: z.boolean().default(true).description("语义检索腿(本地 Ollama bge-m3):tools_search 用 BM25+语义 RRF 混合,中英跨语言命中"),
78
+ ollamaBase: z.string().default("http://127.0.0.1:11434").description("Ollama 地址(semanticEnabled 时使用)"),
79
+ embedModel: z.string().default("bge-m3").description("embedding 模型(semanticEnabled 时使用)"),
74
80
  });
75
81
 
76
82
  const DEFAULTS = {
@@ -80,6 +86,7 @@ const DEFAULTS = {
80
86
  deny: [], // never inject + refuse execution (exact name or "prefix*")
81
87
  topK: 6,
82
88
  hotThreshold: 3,
89
+ hotWindowDays: 3, // v0.2.1: sliding-window heat decay (0 = never expire)
83
90
  catalogEnabled: true, // safety net: folded tools stay discoverable by name
84
91
  compressEnabled: false, // deprecated compat: compressLevel "off" + true → "standard"
85
92
  compressLevel: "off", // "off" | "light" | "standard" | "aggressive"
@@ -101,6 +108,9 @@ const DEFAULTS = {
101
108
  "mcp__open-design": ["设计", "design"],
102
109
  "mcp__serena": ["代码搜索", "搜代码", "search code", "serena"],
103
110
  },
111
+ semanticEnabled: true, // P0-1: bge-m3 semantic leg for tools_search (RRF hybrid)
112
+ ollamaBase: "http://127.0.0.1:11434",
113
+ embedModel: "bge-m3",
104
114
  };
105
115
 
106
116
  function feedbackPath(cfg) {
@@ -172,6 +182,24 @@ function serverOf(name) {
172
182
  return "native";
173
183
  }
174
184
 
185
+ /**
186
+ * 是否「执行类工具」——参数是真实要执行的命令/脚本,需要高危硬拦。
187
+ * 内容类工具(write/edit/read/apply_patch 等)的参数是文件内容/文本,
188
+ * 本身不执行,含危险命令字面量(讨论/测试/文档)不应触发硬拦。
189
+ * 2026-08-30:修复 ChainGuard 对所有工具参数跑高危正则导致的误拦。
190
+ */
191
+ function isExecTool(name) {
192
+ if (!name) return false;
193
+ const n = String(name).toLowerCase();
194
+ const EXEC_NAMES = ["pwsh", "shell", "bash", "cmd", "exec", "terminal", "run", "console"];
195
+ if (EXEC_NAMES.includes(n)) return true;
196
+ if (n.includes("powershell")) return true; // mcp__windows__PowerShell 等(参数即命令)
197
+ if (n.endsWith("__app")) return true; // mcp__windows__App:launch_executable 模式可启动任意可执行文件
198
+ if (n.startsWith("run_") || n.startsWith("exec_") || n.startsWith("execute_")) return true;
199
+ if (n.startsWith("ssh_") || n.startsWith("wsl")) return true;
200
+ return false;
201
+ }
202
+
175
203
  function summarizeCaps(tools) {
176
204
  // Pull the first few distinct capability keywords from descriptions.
177
205
  const seen = new Set();
@@ -230,6 +258,21 @@ export function apply(ctx, config) {
230
258
  return;
231
259
  }
232
260
 
261
+ // Semantic leg (P0-1): point the shared module at our config; the index is
262
+ // built lazily on first tools_search call (doc embeddings cached on disk,
263
+ // so the ~10s cold start happens once, then it's a cache hit).
264
+ configureSemantic({ ollamaBase: cfg.ollamaBase, embedModel: cfg.embedModel });
265
+ let semIndexPromise = null;
266
+ const getSemanticIndex = () => {
267
+ if (!semIndexPromise) {
268
+ semIndexPromise = (async () => {
269
+ const docs = allTools.map((t) => ({ id: t.name, text: `${t.name} ${t.description || ""}` }));
270
+ return buildSemanticIndex(docs);
271
+ })();
272
+ }
273
+ return semIndexPromise;
274
+ };
275
+
233
276
  const fbPath = feedbackPath(cfg);
234
277
  const feedback = loadFeedback(fbPath, logger);
235
278
  let index = null;
@@ -363,15 +406,41 @@ export function apply(ctx, config) {
363
406
  const q = String(args.query || "");
364
407
  const lim = args.limit ?? 8;
365
408
  if (!index || !q) return { query: q, results: [], total: 0 };
366
- const hits = search(index, q, lim);
409
+ // v0.2.1: exact-name > name-substring > BM25 priority (deterministic
410
+ // fallback chain, borrowed from Hermes-style tool search).
411
+ const bm25Hits = prioritizeExact(
412
+ allTools,
413
+ q,
414
+ search(index, q, lim * 2).map((i) => ({ id: allTools[i].name })),
415
+ );
416
+ let fused = bm25Hits;
417
+ // Semantic leg (P0-1): RRF-fuse BM25 with local bge-m3 similarity.
418
+ // Any failure (Ollama down / timeout / cache miss on error) degrades
419
+ // to BM25-only — the hybrid must never be slower or worse than today.
420
+ if (cfg.semanticEnabled !== false) {
421
+ try {
422
+ const sem = await getSemanticIndex();
423
+ if (sem?.available) {
424
+ const docs = allTools.map((t) => ({ id: t.name, text: `${t.name} ${t.description || ""}` }));
425
+ const semHits = await searchSemantic(sem.vectors, docs, q);
426
+ fused = rrfFuse(bm25Hits, semHits.slice(0, lim * 2), lim);
427
+ }
428
+ } catch (e) {
429
+ logger.warn("semantic leg failed (%s) — BM25 only", e?.message);
430
+ }
431
+ }
432
+ const byName = new Map(allTools.map((t) => [t.name, t]));
367
433
  return {
368
434
  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,
435
+ results: fused.map((h) => {
436
+ const t = byName.get(h.id) || {};
437
+ return {
438
+ name: h.id,
439
+ description: (t.description || "").slice(0, 160),
440
+ server: serverOf(h.id),
441
+ };
442
+ }),
443
+ total: fused.length,
375
444
  };
376
445
  },
377
446
  });
@@ -459,10 +528,12 @@ export function apply(ctx, config) {
459
528
 
460
529
  const agentId = context?.agent?.id || "default";
461
530
  const coreNames = new Set(cfg.perAgent?.[agentId]?.core || cfg.core || []);
531
+ // v0.2.1: hot = sliding-window decay (hotWindowDays). Tools not called
532
+ // inside the window lose their historical heat — the old code promoted
533
+ // forever, which slowly nullified the fold.
462
534
  const hotNames = new Set(
463
- Object.entries(feedback.calls || {})
464
- .filter(([, c]) => c >= cfg.hotThreshold)
465
- .map(([n]) => n),
535
+ Object.keys(feedback.calls || {}).filter((n) =>
536
+ isHotTool(feedback.calls, n, cfg.hotThreshold, cfg.hotWindowDays * 86400000)),
466
537
  );
467
538
 
468
539
  const stamp = tools.map((t) => t.name).join("\u0001");
@@ -613,8 +684,14 @@ export function apply(ctx, config) {
613
684
  });
614
685
  }
615
686
 
616
- // Heat counting (L4 feedback).
617
- feedback.calls[name] = (feedback.calls[name] || 0) + 1;
687
+ // Heat counting (L4 feedback). v0.2.1: upgrade to { n, ts } so the
688
+ // hot gate can decay stale heat; legacy number entries are kept
689
+ // readable by isHotTool (ts=0 → outside any positive window).
690
+ const prev = feedback.calls[name];
691
+ feedback.calls[name] = {
692
+ n: (typeof prev === "number" ? prev : prev?.n || 0) + 1,
693
+ ts: Date.now(),
694
+ };
618
695
 
619
696
  // Firewall: record the call sequence and flag exfil chains.
620
697
  if (cfg.firewallEnabled !== false) {
@@ -677,11 +754,18 @@ export function apply(ctx, config) {
677
754
  }
678
755
 
679
756
  if (cfg.firewallEnabled !== false) {
680
- const argvText = execArgumentsText(exec?.arguments);
681
- const v = verdict(argvText);
682
- if (v?.blocked) {
683
- logger.warn("[firewall] BLOCKED %s: %s", name, v.label);
684
- return { kind: "deny", reason: `[tool-folder firewall] 高危命令已拦截(${v.label})。如需执行请人工确认。` };
757
+ // 2026-08-30 修复:高危硬拦只对「执行类工具」生效。
758
+ // 原实现对所有工具的 arguments 跑高危正则,导致内容类工具
759
+ // (write/edit/read 等参数是文件内容/文本)只要含危险命令的
760
+ // 字面量(如讨论、测试、文档中的示例)就被误拦。
761
+ // 执行类工具的参数才是真实要执行的命令,需要硬拦。
762
+ if (isExecTool(name)) {
763
+ const argvText = execArgumentsText(exec?.arguments);
764
+ const v = verdict(argvText);
765
+ if (v?.blocked) {
766
+ logger.warn("[firewall] BLOCKED %s: %s", name, v.label);
767
+ return { kind: "deny", reason: `[tool-folder firewall] 高危命令已拦截(${v.label})。如需执行请人工确认。` };
768
+ }
685
769
  }
686
770
  }
687
771
  } catch (e) {
package/lib/metrics.js CHANGED
@@ -59,3 +59,68 @@ export function selectionCoverage(visibleNames, calledNames) {
59
59
  uncovered,
60
60
  };
61
61
  }
62
+
63
+ /**
64
+ * Hot-promotion gate with sliding-window decay (v0.2.1).
65
+ * feedback.calls entries may be:
66
+ * - new shape { n, ts } — n calls, ts = last call epoch ms;
67
+ * - legacy shape (number) — pre-v0.2.1 counts with no timestamp.
68
+ * A tool is hot when n >= hotThreshold AND (windowMs <= 0 OR the last call
69
+ * is inside the window). Legacy numbers carry ts=0 → outside any positive
70
+ * window → not hot (a tool that hasn't been called since the upgrade
71
+ * naturally loses its historical heat — this is the decay the old code
72
+ * lacked). windowMs=0 keeps the legacy forever-hot behavior for opt-out.
73
+ * @param {object|null} calls - feedback.calls map.
74
+ * @param {string} name - tool name.
75
+ * @param {number} hotThreshold - min calls to be hot.
76
+ * @param {number} windowMs - decay window; <=0 disables time checks.
77
+ * @returns {boolean}
78
+ */
79
+ export function isHotTool(calls, name, hotThreshold, windowMs) {
80
+ if (!calls || typeof calls !== "object") return false;
81
+ const v = calls[name];
82
+ if (v == null) return false;
83
+ const n = typeof v === "number" ? v : v.n;
84
+ const ts = typeof v === "number" ? 0 : v.ts;
85
+ if (!(n >= hotThreshold)) return false;
86
+ if (windowMs > 0 && !(ts > 0 && Date.now() - ts <= windowMs)) return false;
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Deterministic retrieval priority (v0.2.1, borrowed from Hermes-style
92
+ * "exact name > name token > description" fallback chains): the query's
93
+ * exact tool-name match always ranks first; a ≥4-char query that is a
94
+ * substring of a tool name is pulled ahead of BM25/semantic hits. Everything
95
+ * else keeps the given hit order, deduplicated.
96
+ * @param {Array<{name: string}>} docs - the tool corpus (allTools).
97
+ * @param {string} q - the search query.
98
+ * @param {Array<{id: string, score: number}>} hits - ranked hits.
99
+ * @returns {Array<{id: string, score: number}>} re-ordered hits.
100
+ */
101
+ export function prioritizeExact(docs, q, hits) {
102
+ const query = String(q || "").trim().toLowerCase();
103
+ if (!query || !Array.isArray(docs) || !Array.isArray(hits)) return hits;
104
+ const ordered = [];
105
+ const seen = new Set();
106
+ const exact = docs.find((d) => String(d.name || "").toLowerCase() === query);
107
+ if (exact) {
108
+ ordered.push({ id: exact.name, score: 0 });
109
+ seen.add(exact.name);
110
+ }
111
+ if (query.length >= 4) {
112
+ for (const d of docs) {
113
+ if (seen.has(d.name)) continue;
114
+ if (String(d.name || "").toLowerCase().includes(query)) {
115
+ ordered.push({ id: d.name, score: 0 });
116
+ seen.add(d.name);
117
+ }
118
+ }
119
+ }
120
+ for (const h of hits) {
121
+ if (!h || seen.has(h.id)) continue;
122
+ ordered.push(h);
123
+ seen.add(h.id);
124
+ }
125
+ return ordered;
126
+ }
@@ -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
1
  {
2
- "name": "dsh-tool-folder",
3
- "version": "0.1.8",
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
- }
2
+ "name": "dsh-tool-folder",
3
+ "version": "0.2.1",
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": "\u003e=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
+ }