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 +64 -0
- package/cordis.patch.yml +4 -0
- package/lib/chainguard.js +40 -3
- package/lib/index.js +102 -18
- package/lib/metrics.js +65 -0
- package/lib/obfuscation.js +124 -0
- package/lib/semantic.js +239 -0
- package/package.json +47 -37
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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:
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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.
|
|
464
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
+
}
|
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
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
}
|