dsh-tool-folder 0.2.3 → 0.2.5
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 +38 -0
- package/lib/bm25.js +15 -0
- package/lib/chainguard.js +40 -0
- package/lib/index.js +35 -43
- package/lib/obfuscation.js +11 -2
- package/lib/semantic.js +5 -3
- package/lib/ux.js +136 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -149,6 +149,44 @@ were never unregistered, so nothing else changes.
|
|
|
149
149
|
|
|
150
150
|
## Changelog
|
|
151
151
|
|
|
152
|
+
### v0.2.5 — red-team firewall fixes + retrieval hardening (2026-09-02)
|
|
153
|
+
1. **Exec-tool recognition (red-team F1)** — bare-name matching let MCP-prefixed
|
|
154
|
+
exec tools (`mcp__windows__Cmd`) bypass the high-risk gate; `isExecTool` now
|
|
155
|
+
tokenizes the full name and matches every segment (all segments participate —
|
|
156
|
+
conservative by design, over-recognition is ~free on the gate).
|
|
157
|
+
2. **False-positive roots A/B** — Windows paths are base64-shaped (57-char fake
|
|
158
|
+
runs) and the bare `&` of `2>&1` looked like an exec verb; a legit gitleaks
|
|
159
|
+
staged scan was blocked. Path-fingerprint exclusion + `EXEC_VERB` tightening.
|
|
160
|
+
2/5 measured FPs → 0/5; regression suite `chainguard-fp.test.js` (114 total green).
|
|
161
|
+
3. **Command-substitution download-exec legs** — `$(curl …)` / backtick /
|
|
162
|
+
`iwr` shapes (no pipe, invisible to the download-pipe pattern).
|
|
163
|
+
4. **BM25 sub-word split** — hyphen/underscore tool names were single index
|
|
164
|
+
terms (`mcp__codegraph__codegraph_explore`); measured recall 0/10 → 10/10.
|
|
165
|
+
5. **fallbackCatalog round-robin** — pure lexicographic slice hid whole servers
|
|
166
|
+
(`mcp__*`) from the fallback catalog; now round-robins across servers.
|
|
167
|
+
6. **serverOf unified** — apply-local `split("__")` copy deleted; module-level
|
|
168
|
+
grouping (native/openhands families) is the single source for search results
|
|
169
|
+
and sibling closure. `embedTimeoutMs` configurable (default 8000).
|
|
170
|
+
7. Two-axis audit (2026-09-02): comments/tests now match the real conservative
|
|
171
|
+
design; one-shot repro script removed.
|
|
172
|
+
|
|
173
|
+
### v0.2.4 — P1: batch tools_schema + sibling closure (2026-08-31)
|
|
174
|
+
1. **Batch expand** — `tools_schema` accepts `names: [...]` to expand several
|
|
175
|
+
tools in one call (`{ found, results: [...] }`); a single `name` keeps the
|
|
176
|
+
legacy 6-field shape byte-for-byte.
|
|
177
|
+
2. **Sibling closure** (`closureSize`, default 0 = off, mirrors the
|
|
178
|
+
`maxWarmTools` compat precedent): when enabled, expanding a tool also lists
|
|
179
|
+
lightweight sibling entries (name + 120-char description) from the same
|
|
180
|
+
server prefix, so the model can batch-expand what it actually needs —
|
|
181
|
+
kills the "expanded A, now I need B" round-trip. `closure: false` opts out
|
|
182
|
+
per call.
|
|
183
|
+
3. Output contract moved to a pure function (`buildSchemaResponse`, ux.js) —
|
|
184
|
+
11 new tests pin single/batch/missing/siblings branches (94 total green).
|
|
185
|
+
4. Two-axis audit (Standards + Spec): 1 Critical fixed during review — the
|
|
186
|
+
single/batch routing condition was inverted (old `name`-only calls would
|
|
187
|
+
have returned the batch shape); contract tests now catch that class
|
|
188
|
+
instantly. `siblingClosure` dead code removed.
|
|
189
|
+
|
|
152
190
|
### v0.2.3 — fold-ux: search fallback catalog + query expansion + stable results + discovery stats (2026-08-30)
|
|
153
191
|
1. **Zero-match fallback catalog** (`fallbackCatalogSize`, default 30): when
|
|
154
192
|
`tools_search` finds nothing, it returns a name-sorted lightweight catalog
|
package/lib/bm25.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* bm25.js — zero-dependency BM25 lexical search for tool retrieval.
|
|
3
3
|
*
|
|
4
|
+
* VENDOR SOURCE (2026-09-02): dsh-skill-folder/lib/bm25.js vendors this file
|
|
5
|
+
* verbatim (comments may diverge per repo). Modify here, then mirror the code
|
|
6
|
+
* change into the skill repo. Sync gate: `node E:\DSH-Data\scripts\check-vendor-sync.mjs`.
|
|
7
|
+
* semantic.js is intentionally DIVERGED (tool has incremental cache/sha1) and
|
|
8
|
+
* is NOT covered by the gate.
|
|
9
|
+
*
|
|
4
10
|
* Design (ported from the xiaowan agent's ToolBM25Index idea; the algorithm is
|
|
5
11
|
* the reference, this code is a clean-room JS implementation):
|
|
6
12
|
* - BM25 with standard k1=1.2, b=0.75 (RAG-MCP / BoR validated the
|
|
@@ -32,6 +38,15 @@ function tokenize(text) {
|
|
|
32
38
|
for (const m of s.matchAll(/[a-z0-9][a-z0-9_+-]{1,}/g)) {
|
|
33
39
|
const t = m[0];
|
|
34
40
|
if (!EN_STOP.has(t)) out.push(t);
|
|
41
|
+
// Sub-words of hyphen/underscore compounds. Without this, a tool name like
|
|
42
|
+
// "mcp__open-design__start_run" is ONE term, so querying "design" scores
|
|
43
|
+
// zero. The whole token is kept too, so exact-name matches and existing
|
|
44
|
+
// IDF are unchanged; sub-words only ADD recall.
|
|
45
|
+
if (t.length > 2 && (t.includes("-") || t.includes("_"))) {
|
|
46
|
+
for (const part of t.split(/[-_]+/)) {
|
|
47
|
+
if (part.length > 1 && part !== t && !EN_STOP.has(part)) out.push(part);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
35
50
|
}
|
|
36
51
|
// CJK bigrams: "开源框架" -> "开源","源框","框架". Bigrams beat single
|
|
37
52
|
// chars for short tool descriptions and are dependency-free.
|
package/lib/chainguard.js
CHANGED
|
@@ -24,6 +24,9 @@ const HIGH_RISK_PATTERNS = [
|
|
|
24
24
|
// download → execute chain
|
|
25
25
|
[/(wget|curl|powershell|pwsh).{0,80}(\||;|&&).{0,30}(sh|bash|powershell|pwsh|cmd|start)\b/i, "download-pipe-execute"],
|
|
26
26
|
[/invoke-expression|iex\s*\(|iwr\s+.*\|.*iex/i, "powershell download-exec"],
|
|
27
|
+
// command substitution download → execute (bash $() and backticks; no pipe,
|
|
28
|
+
// so the download-pipe pattern above cannot see it). Added 2026-09-02.
|
|
29
|
+
[/\$\s*\(\s*(?:curl|wget|iwr)[^)]{0,120}\)|\`(?:curl|wget|iwr)[^\`]{0,120}\`/i, "download-exec via command substitution"],
|
|
27
30
|
// credential / exfil
|
|
28
31
|
// NOTE: `\\+` (one-or-more literal backslashes) instead of `\\` — argv text
|
|
29
32
|
// reaches verdict() both as a raw string (single backslash) and as
|
|
@@ -58,6 +61,43 @@ function matchHighRisk(text) {
|
|
|
58
61
|
return null;
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
/**
|
|
65
|
+
* 是否「执行类工具」——参数是真实要执行的命令/脚本,需要高危硬拦。
|
|
66
|
+
* 内容类工具(write/edit/read/apply_patch 等)的参数是文件内容/文本,
|
|
67
|
+
* 本身不执行,含危险命令字面量(讨论/测试/文档)不应触发硬拦。
|
|
68
|
+
*
|
|
69
|
+
* 2026-08-30: 修复 ChainGuard 对所有工具参数跑高危正则导致的误拦。
|
|
70
|
+
* 2026-09-02 (red-team F1): bare-name matching let MCP-prefixed exec tools
|
|
71
|
+
* (`mcp__windows__Cmd`) bypass the gate — tokenize the full name and match
|
|
72
|
+
* every segment against the verb list. Over-recognition is ~free here: the
|
|
73
|
+
* gate only decides "run this argv past the firewall", and a non-command
|
|
74
|
+
* argv has no high-risk literals to trip; missing one tool is a bypass.
|
|
75
|
+
* @param {string} name - full tool name (mcp__<server>__<tool> or bare).
|
|
76
|
+
*/
|
|
77
|
+
export function isExecTool(name) {
|
|
78
|
+
if (!name) return false;
|
|
79
|
+
const n = String(name).toLowerCase();
|
|
80
|
+
const EXEC_TOKENS = new Set([
|
|
81
|
+
"cmd", "command", "console", "terminal", "shell", "bash", "pwsh", "powershell",
|
|
82
|
+
"exec", "execute", "executor", "run", "runner", "sh", "zsh", "script", "ps1",
|
|
83
|
+
]);
|
|
84
|
+
// Legacy fast paths kept for bare names and native prefixes.
|
|
85
|
+
const EXEC_NAMES = ["pwsh", "shell", "bash", "cmd", "exec", "terminal", "run", "console"];
|
|
86
|
+
if (EXEC_NAMES.includes(n)) return true;
|
|
87
|
+
if (n.endsWith("__app")) return true; // mcp__windows__App:launch_executable 模式可启动任意可执行文件
|
|
88
|
+
if (n.startsWith("run_") || n.startsWith("exec_") || n.startsWith("execute_")) return true;
|
|
89
|
+
if (n.startsWith("ssh_") || n.startsWith("wsl")) return true;
|
|
90
|
+
// Segment match: tokenize on mcp__ separator and _ - . (runbook-style names).
|
|
91
|
+
// All segments except the `mcp` marker participate — a server name carrying
|
|
92
|
+
// an exec word (e.g. `pwsh` in mcp__pwsh__list_tools) is itself an exec
|
|
93
|
+
// signal, while `mcp__github__get_file_contents` has none. Over-recognition
|
|
94
|
+
// is ~free (gate only); under-recognition is a bypass.
|
|
95
|
+
const tokens = n.split(/__|_|-|\./).filter((t) => t.length > 0);
|
|
96
|
+
const meaningful = n.startsWith("mcp") && tokens[0] === "mcp" ? tokens.slice(1) : tokens;
|
|
97
|
+
if (meaningful.length === 0) return false;
|
|
98
|
+
return meaningful.some((t) => EXEC_TOKENS.has(t));
|
|
99
|
+
}
|
|
100
|
+
|
|
61
101
|
export function verdict(argvText) {
|
|
62
102
|
if (!argvText) return null;
|
|
63
103
|
|
package/lib/index.js
CHANGED
|
@@ -32,12 +32,12 @@ import fs from "node:fs";
|
|
|
32
32
|
import path from "node:path";
|
|
33
33
|
import z from "@deepseek-ai/schemastery";
|
|
34
34
|
import { buildIndex, search } from "./bm25.js";
|
|
35
|
-
import { compressTool, normalizeDescription
|
|
35
|
+
import { compressTool, normalizeDescription } from "./schema.js";
|
|
36
36
|
import { selectionCoverage, isHotTool, prioritizeExact, touchWarm, evictWarm, missingMetaTools } from "./metrics.js";
|
|
37
37
|
import { tierForBudget } from "./budget.js";
|
|
38
38
|
import { readState, writeState } from "./storage.js";
|
|
39
|
-
import { expandQuery, fallbackCatalog, createSearchCache, recordDiscoveries, serializeDiscoveries, deserializeDiscoveries } from "./ux.js";
|
|
40
|
-
import { ChainGuard, verdict } from "./chainguard.js";
|
|
39
|
+
import { expandQuery, fallbackCatalog, createSearchCache, recordDiscoveries, serializeDiscoveries, deserializeDiscoveries, siblingClosure, buildSchemaResponse } from "./ux.js";
|
|
40
|
+
import { ChainGuard, verdict, isExecTool } from "./chainguard.js";
|
|
41
41
|
import { matchCategory } from "./category.js";
|
|
42
42
|
import { toonifyValue } from "./toonify.js";
|
|
43
43
|
import { configureSemantic, buildSemanticIndex, searchSemantic, rrfFuse } from "./semantic.js";
|
|
@@ -80,6 +80,7 @@ export const Config = z.object({
|
|
|
80
80
|
semanticEnabled: z.boolean().default(true).description("语义检索腿(本地 Ollama bge-m3):tools_search 用 BM25+语义 RRF 混合,中英跨语言命中"),
|
|
81
81
|
ollamaBase: z.string().default("http://127.0.0.1:11434").description("Ollama 地址(semanticEnabled 时使用)"),
|
|
82
82
|
embedModel: z.string().default("bge-m3").description("embedding 模型(semanticEnabled 时使用)"),
|
|
83
|
+
embedTimeoutMs: z.number().min(100).default(8000).description("embedding 请求超时(毫秒;Ollama 慢时可调大)"),
|
|
83
84
|
disclosureBudget: z.number().min(0).max(200000).default(0)
|
|
84
85
|
.description("分级披露预算(字节):0=关闭。开启后可见工具 schema 估算字节超预算时,动态段按 完整schema→name+desc→仅name→折叠进catalog 逐级降级(core/hot 永不降级)"),
|
|
85
86
|
maxWarmTools: z.number().min(0).max(50).default(0)
|
|
@@ -88,6 +89,8 @@ export const Config = z.object({
|
|
|
88
89
|
.description("P0: tools_search 零匹配时回退的轻目录条数(name+80字描述,名字典序);0=关闭回退(返回空=旧行为)"),
|
|
89
90
|
queryExpandEnabled: z.boolean().default(true)
|
|
90
91
|
.description("P0: 中文意图→英文同义词扩展(只加词不改原词,扩大 BM25/语义召回;精确名匹配不受影响)"),
|
|
92
|
+
closureSize: z.number().min(0).max(20).default(0)
|
|
93
|
+
.description("P1: tools_schema 展开时附带同 server 兄弟工具的轻目录条数(name+120字描述,名字典序);0=关闭(默认关=无配置升级输出逐字段等价,显式开启才启用,与 maxWarmTools 先例一致)"),
|
|
91
94
|
});
|
|
92
95
|
|
|
93
96
|
const DEFAULTS = {
|
|
@@ -122,10 +125,12 @@ const DEFAULTS = {
|
|
|
122
125
|
semanticEnabled: true, // P0-1: bge-m3 semantic leg for tools_search (RRF hybrid)
|
|
123
126
|
ollamaBase: "http://127.0.0.1:11434",
|
|
124
127
|
embedModel: "bge-m3",
|
|
128
|
+
embedTimeoutMs: 8000, // P3-2: embedding 请求超时(毫秒)
|
|
125
129
|
disclosureBudget: 0, // v0.2.2: 分级披露预算(0=关闭)
|
|
126
130
|
maxWarmTools: 0, // v0.2.2: warm LRU 上限(0=关闭,默认关=无配置升级逐字节等价)
|
|
127
131
|
fallbackCatalogSize: 30, // P0: 零匹配回退轻目录条数(0=关闭)
|
|
128
132
|
queryExpandEnabled: true, // P0: 中文→英文 query 扩展
|
|
133
|
+
closureSize: 0, // P1: tools_schema 同 server 兄弟闭包(默认关=逐字段等价,显式开启)
|
|
129
134
|
};
|
|
130
135
|
|
|
131
136
|
function feedbackPath(cfg) {
|
|
@@ -197,24 +202,6 @@ function serverOf(name) {
|
|
|
197
202
|
return "native";
|
|
198
203
|
}
|
|
199
204
|
|
|
200
|
-
/**
|
|
201
|
-
* 是否「执行类工具」——参数是真实要执行的命令/脚本,需要高危硬拦。
|
|
202
|
-
* 内容类工具(write/edit/read/apply_patch 等)的参数是文件内容/文本,
|
|
203
|
-
* 本身不执行,含危险命令字面量(讨论/测试/文档)不应触发硬拦。
|
|
204
|
-
* 2026-08-30:修复 ChainGuard 对所有工具参数跑高危正则导致的误拦。
|
|
205
|
-
*/
|
|
206
|
-
function isExecTool(name) {
|
|
207
|
-
if (!name) return false;
|
|
208
|
-
const n = String(name).toLowerCase();
|
|
209
|
-
const EXEC_NAMES = ["pwsh", "shell", "bash", "cmd", "exec", "terminal", "run", "console"];
|
|
210
|
-
if (EXEC_NAMES.includes(n)) return true;
|
|
211
|
-
if (n.includes("powershell")) return true; // mcp__windows__PowerShell 等(参数即命令)
|
|
212
|
-
if (n.endsWith("__app")) return true; // mcp__windows__App:launch_executable 模式可启动任意可执行文件
|
|
213
|
-
if (n.startsWith("run_") || n.startsWith("exec_") || n.startsWith("execute_")) return true;
|
|
214
|
-
if (n.startsWith("ssh_") || n.startsWith("wsl")) return true;
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
205
|
function summarizeCaps(tools) {
|
|
219
206
|
// Pull the first few distinct capability keywords from descriptions.
|
|
220
207
|
const seen = new Set();
|
|
@@ -276,7 +263,7 @@ export function apply(ctx, config) {
|
|
|
276
263
|
// Semantic leg (P0-1): point the shared module at our config; the index is
|
|
277
264
|
// built lazily on first tools_search call (doc embeddings cached on disk,
|
|
278
265
|
// so the ~10s cold start happens once, then it's a cache hit).
|
|
279
|
-
configureSemantic({ ollamaBase: cfg.ollamaBase, embedModel: cfg.embedModel });
|
|
266
|
+
configureSemantic({ ollamaBase: cfg.ollamaBase, embedModel: cfg.embedModel, embedTimeoutMs: cfg.embedTimeoutMs });
|
|
280
267
|
let semIndexPromise = null;
|
|
281
268
|
const getSemanticIndex = () => {
|
|
282
269
|
if (!semIndexPromise) {
|
|
@@ -381,9 +368,12 @@ export function apply(ctx, config) {
|
|
|
381
368
|
// P0-②: 检索结果缓存(同 query 60s 内稳定;LRU 200 条;进程级共享)。
|
|
382
369
|
const searchCache = createSearchCache(200, 60000);
|
|
383
370
|
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
|
|
371
|
+
// serverOf is the module-level function defined above (mcp__server__tool →
|
|
372
|
+
// "mcp__server", openhands_* → "openhands", plain native tools → "native").
|
|
373
|
+
// P3-1 (2026-09-02): the previous apply-local copy (split("__").slice(0,2))
|
|
374
|
+
// let plain tools be their own "server", diverging from catalogText grouping
|
|
375
|
+
// and making sibling closure useless for native/openhands families. One
|
|
376
|
+
// definition, one口径.
|
|
387
377
|
|
|
388
378
|
// Pattern matching shared by deny (P0) and include (P2-1): exact name or
|
|
389
379
|
// "prefix*" matches any tool starting with that prefix.
|
|
@@ -597,15 +587,24 @@ export function apply(ctx, config) {
|
|
|
597
587
|
const schemaTool = defineTool({
|
|
598
588
|
name: "tools_schema",
|
|
599
589
|
description:
|
|
600
|
-
"Get the complete parameter schema of one
|
|
590
|
+
"Get the complete parameter schema of one or more tools by name. Use after " +
|
|
601
591
|
"tools_search narrows candidates, or before calling a tool whose exact " +
|
|
602
592
|
"arguments you need. Returns the full JSON schema of parameters plus the " +
|
|
603
|
-
"full description
|
|
593
|
+
"full description; sibling tools from the same server are listed alongside " +
|
|
594
|
+
"(lightweight) so you can batch-expand what you need.",
|
|
604
595
|
parameters: {
|
|
605
596
|
name: {
|
|
606
597
|
type: "string",
|
|
607
|
-
|
|
608
|
-
|
|
598
|
+
description: "Exact tool name, e.g. 'mcp__viking__remember' (either name or names).",
|
|
599
|
+
},
|
|
600
|
+
names: {
|
|
601
|
+
type: "array",
|
|
602
|
+
items: { type: "string" },
|
|
603
|
+
description: "Batch: exact tool names to expand in one call (either name or names).",
|
|
604
|
+
},
|
|
605
|
+
closure: {
|
|
606
|
+
type: "boolean",
|
|
607
|
+
description: "Include lightweight sibling tools from the same server (default true).",
|
|
609
608
|
},
|
|
610
609
|
},
|
|
611
610
|
output: {
|
|
@@ -614,21 +613,14 @@ export function apply(ctx, config) {
|
|
|
614
613
|
},
|
|
615
614
|
isConcurrencySafe: () => true,
|
|
616
615
|
async execute(args) {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
616
|
+
// P1: 批量展开(names 数组)+ 同 server 兄弟闭包(closureSize 条轻目录)。
|
|
617
|
+
// 输出契约全部在 buildSchemaResponse(纯函数,lib/ux.js 可单测)。
|
|
618
|
+
const res = buildSchemaResponse(allTools, args, { closureSize: cfg.closureSize, serverOf });
|
|
620
619
|
// v0.2.2: 展开过 schema 的工具名进入 warm 集合,下一轮保持注入。
|
|
621
|
-
touchWarmNames(
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
server: serverOf(t.name),
|
|
626
|
-
description: t.description || "",
|
|
627
|
-
// C4 defense: even a schema containing `const: -0` must come back as
|
|
628
|
-
// lossless JSON.
|
|
629
|
-
parameters: sanitizeLossless(t.parameters ?? {}),
|
|
630
|
-
required: Array.isArray(t.parameters?.required) ? t.parameters.required : [],
|
|
631
|
-
};
|
|
620
|
+
touchWarmNames(
|
|
621
|
+
Array.isArray(res.results) ? res.results.map((r) => r.name) : res.found && res.name ? [res.name] : [],
|
|
622
|
+
);
|
|
623
|
+
return res;
|
|
632
624
|
},
|
|
633
625
|
});
|
|
634
626
|
try {
|
package/lib/obfuscation.js
CHANGED
|
@@ -65,7 +65,15 @@ const RE_BITSADMIN = /bitsadmin\s+\/transfer\s+\S+\s+\/download/i;
|
|
|
65
65
|
|
|
66
66
|
// A long base64 blob co-located with an execution verb in the same command.
|
|
67
67
|
const RE_B64_PAYLOAD = /[A-Za-z0-9+/]{40,}={0,2}/;
|
|
68
|
-
|
|
68
|
+
// Path fingerprint: >=3 camel-case segments joined by '/' (C:/Users/L/AppData/...).
|
|
69
|
+
// '/' is a base64 char, so long Windows/URL paths form fake base64 runs; real
|
|
70
|
+
// base64 payloads almost never contain 3+ `/Upper`-word segments. Fixed 2026-09-02
|
|
71
|
+
// (measured: 57-char WinGet path run + `2>&1` was blocked as base64-embedded-command).
|
|
72
|
+
const RE_PATHISH_RUN = /(?:\/[A-Z][A-Za-z]{1,}){3,}/;
|
|
73
|
+
// Exec verb used by the b64/hex legs. NOTE: no bare `&` — `2>&1` redirection
|
|
74
|
+
// and `& ` call operators are not payload-exec evidence by themselves; the
|
|
75
|
+
// verb must be an actual command word (fixed 2026-09-02, fp-repro 2/5).
|
|
76
|
+
const EXEC_VERB = /(?:iex|invoke-expression|invoke-command|iwr|irm|start-process|start-job|&\s*(?:iex|invoke|iwr|irm|cmd|powershell|pwsh|sh|bash|python|node|mshta|rundll32|regsvr32|wscript|cscript)|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
77
|
|
|
70
78
|
// Full-width Latin letters (curl) — never legit in a command line.
|
|
71
79
|
const RE_FULLWIDTH = /[\uFF21-\uFF3A\uFF41-\uFF5A]/;
|
|
@@ -97,7 +105,8 @@ export function detectObfuscation(rawText, cleanText) {
|
|
|
97
105
|
if (RE_BITSADMIN.test(probe)) {
|
|
98
106
|
return { label: "bitsadmin-download-exec", detail: "bitsadmin /transfer 下载执行" };
|
|
99
107
|
}
|
|
100
|
-
|
|
108
|
+
const b64Run = RE_B64_PAYLOAD.exec(probe);
|
|
109
|
+
if (b64Run && !RE_PATHISH_RUN.test(b64Run[0]) && EXEC_VERB.test(probe)) {
|
|
101
110
|
return { label: "base64-embedded-command", detail: "长 base64 载荷 + 执行动词共存" };
|
|
102
111
|
}
|
|
103
112
|
if (RE_FULLWIDTH.test(raw)) {
|
package/lib/semantic.js
CHANGED
|
@@ -86,20 +86,22 @@ function writeCacheFileAt(p, obj) {
|
|
|
86
86
|
|
|
87
87
|
let _model = DEFAULT_MODEL;
|
|
88
88
|
let _base = OLLAMA_BASE;
|
|
89
|
+
let _timeout = EMBED_TIMEOUT_MS; // P3-2 (2026-09-02): configurable via configureSemantic
|
|
89
90
|
|
|
90
91
|
/**
|
|
91
92
|
* Configure the endpoint (called by plugin apply with cfg.ollamaBase/model).
|
|
92
|
-
* @param {{ollamaBase?: string, embedModel?: string}} cfg
|
|
93
|
+
* @param {{ollamaBase?: string, embedModel?: string, embedTimeoutMs?: number}} cfg
|
|
93
94
|
*/
|
|
94
95
|
export function configureSemantic(cfg = {}) {
|
|
95
96
|
if (cfg && typeof cfg === "object") {
|
|
96
97
|
if (typeof cfg.ollamaBase === "string" && cfg.ollamaBase) _base = cfg.ollamaBase;
|
|
97
98
|
if (typeof cfg.embedModel === "string" && cfg.embedModel) _model = cfg.embedModel;
|
|
99
|
+
if (Number.isFinite(cfg.embedTimeoutMs) && cfg.embedTimeoutMs >= 100) _timeout = cfg.embedTimeoutMs;
|
|
98
100
|
}
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
/** One embedding vector for a text, or null on any failure. */
|
|
102
|
-
export async function embedText(text, timeoutMs =
|
|
104
|
+
export async function embedText(text, timeoutMs = _timeout) {
|
|
103
105
|
const s = String(text || "").trim();
|
|
104
106
|
if (!s) return null;
|
|
105
107
|
try {
|
|
@@ -164,7 +166,7 @@ export function cosine(a, b) {
|
|
|
164
166
|
* real ~/.dsh/state unless omitted).
|
|
165
167
|
* @returns {Promise<{vectors: Map<string, number[]>, available: boolean}>}
|
|
166
168
|
*/
|
|
167
|
-
export async function buildSemanticIndex(docs, timeoutMs =
|
|
169
|
+
export async function buildSemanticIndex(docs, timeoutMs = _timeout, opts = {}) {
|
|
168
170
|
const out = { vectors: new Map(), available: false };
|
|
169
171
|
const list = Array.isArray(docs) ? docs.filter((d) => d && d.id && d.text) : [];
|
|
170
172
|
if (list.length === 0) return out;
|
package/lib/ux.js
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* 同会话重复搜索返回同一结果集(漂移的最小修)
|
|
11
11
|
* ④ recordDiscoveries 发现率统计(name→count,落盘可恢复)——低频工具名单
|
|
12
12
|
* 反哺描述改进(官方「监控发现率」建议的本地等价物)
|
|
13
|
+
* P1 ⑤ siblingClosure 同 server 兄弟闭包 ⑥ buildSchemaResponse tools_schema
|
|
14
|
+
* 输出契约(单数/批量/siblings/missing 全分支,可单测)
|
|
13
15
|
*/
|
|
16
|
+
import { sanitizeLossless } from "./schema.js";
|
|
14
17
|
|
|
15
18
|
/* ------------------------- ① query 扩展词表 ------------------------- */
|
|
16
19
|
|
|
@@ -84,8 +87,14 @@ export function expandQuery(q) {
|
|
|
84
87
|
/* ------------------------- ② 零匹配回退轻目录 ------------------------- */
|
|
85
88
|
|
|
86
89
|
/**
|
|
87
|
-
*
|
|
90
|
+
* 轻目录:跨 server 轮转配额(name + 描述截 80 + server)。
|
|
88
91
|
* limit<=0 → [](关闭回退,行为与旧版一致)。
|
|
92
|
+
*
|
|
93
|
+
* 2026-09-02(审计修复):纯字典序 slice(0, limit) 会让名字靠后的 server
|
|
94
|
+
* (mcp__windows 等)在截断后全部不可见 → 模型零匹配回退时误判"没有该能力"。
|
|
95
|
+
* 改为:按 server 分组(组内字典序)→ round-robin 出队——小 server 必有代表、
|
|
96
|
+
* 大 server 拿多数、同输入同输出(确定性)。server 函数为每工具独立名时行为
|
|
97
|
+
* 与旧字典序一致(每组 1 个,轮转 = 字典序)。
|
|
89
98
|
* @param {Array<{name:string,description?:string}>} allTools
|
|
90
99
|
* @param {number} limit
|
|
91
100
|
* @param {(name:string)=>string} [serverOf]
|
|
@@ -95,14 +104,28 @@ export function fallbackCatalog(allTools, limit, serverOf) {
|
|
|
95
104
|
const lim = Math.max(0, Number(limit) || 0);
|
|
96
105
|
if (!lim || !Array.isArray(allTools) || allTools.length === 0) return [];
|
|
97
106
|
const svc = typeof serverOf === "function" ? serverOf : () => "";
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
.
|
|
101
|
-
.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
107
|
+
const byServer = new Map();
|
|
108
|
+
for (const t of allTools) {
|
|
109
|
+
const s = svc(t.name) || "(none)";
|
|
110
|
+
if (!byServer.has(s)) byServer.set(s, []);
|
|
111
|
+
byServer.get(s).push(t);
|
|
112
|
+
}
|
|
113
|
+
// Deterministic: server order by name, tools inside each server sorted by name.
|
|
114
|
+
const queues = [...byServer.entries()]
|
|
115
|
+
.sort((a, b) => String(a[0]).localeCompare(String(b[0])))
|
|
116
|
+
.map(([, ts]) => ts.slice().sort((a, b) => String(a.name).localeCompare(String(b.name))));
|
|
117
|
+
const out = [];
|
|
118
|
+
while (out.length < lim && queues.some((q) => q.length > 0)) {
|
|
119
|
+
for (const q of queues) {
|
|
120
|
+
if (out.length >= lim) break;
|
|
121
|
+
if (q.length > 0) out.push(q.shift());
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out.map((t) => ({
|
|
125
|
+
name: String(t.name || ""),
|
|
126
|
+
description: String(t.description || "").slice(0, 80),
|
|
127
|
+
server: svc(t.name),
|
|
128
|
+
}));
|
|
106
129
|
}
|
|
107
130
|
|
|
108
131
|
/* ------------------------- ③ 检索结果缓存 ------------------------- */
|
|
@@ -176,3 +199,107 @@ export function deserializeDiscoveries(list) {
|
|
|
176
199
|
}
|
|
177
200
|
return m;
|
|
178
201
|
}
|
|
202
|
+
|
|
203
|
+
/* ------------------------- P1: 依赖闭包 ------------------------- */
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 同 server 兄弟闭包(graph-tool-call 的本地简化:server 前缀 = 依赖分组代理)。
|
|
207
|
+
* 展开目标工具时顺带返回同 server 的其它工具轻目录(name+120 字描述),
|
|
208
|
+
* 模型看到兄弟名字后可批量展开——减少「展开 A 发现还要 B」的往返税。
|
|
209
|
+
* ponytail: server 前缀是粗粒度依赖近似;精确 producer 图需要宿主工具元数据,
|
|
210
|
+
* 真需要时再按参数类型启发补。
|
|
211
|
+
* @param {Array<{name:string,description?:string}>} allTools
|
|
212
|
+
* @param {Array<string>} expandedNames 已展开的工具名
|
|
213
|
+
* @param {number} limit 附带兄弟总数上限(0=关闭)
|
|
214
|
+
* @param {(name:string)=>string} [serverOf] server 前缀提取
|
|
215
|
+
* @returns {Array<{name:string,description:string}>}
|
|
216
|
+
*/
|
|
217
|
+
export function siblingClosure(allTools, expandedNames, limit, serverOf) {
|
|
218
|
+
const lim = Math.max(0, Number(limit) || 0);
|
|
219
|
+
if (!lim || !Array.isArray(allTools) || !Array.isArray(expandedNames) || expandedNames.length === 0) return [];
|
|
220
|
+
const svc = typeof serverOf === "function" ? serverOf : (n) => String(n).split("__").slice(0, 2).join("__");
|
|
221
|
+
const servers = new Set(expandedNames.map((n) => svc(String(n))).filter(Boolean));
|
|
222
|
+
if (servers.size === 0) return [];
|
|
223
|
+
const skip = new Set(expandedNames);
|
|
224
|
+
const out = [];
|
|
225
|
+
for (const t of [...allTools].sort((a, b) => String(a.name).localeCompare(String(b.name)))) {
|
|
226
|
+
if (out.length >= lim) break;
|
|
227
|
+
const nm = String(t.name || "");
|
|
228
|
+
if (!nm || skip.has(nm)) continue;
|
|
229
|
+
if (servers.has(svc(nm))) {
|
|
230
|
+
out.push({ name: nm, description: String(t.description || "").slice(0, 120) });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ------------------------- P1: tools_schema 输出契约 ------------------------- */
|
|
237
|
+
|
|
238
|
+
const defaultServerOf = (name) => String(name).split("__").slice(0, 2).join("__");
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* tools_schema 的纯输出构造(从 index.js execute 提取,契约可单测——single 判定
|
|
242
|
+
* 写反曾逃过 87 条纯函数测试,根因是 execute 无测试;提取后契约分支全部可钉死)。
|
|
243
|
+
* 单数调用(name 或 names 单元素,去重后 1 个)→ 旧版逐字段结构;
|
|
244
|
+
* 批量(去重后 >1 个)→ { found, results: [...], siblings?, missing? }。
|
|
245
|
+
* @param {Array<{name:string,description?:string,parameters?:object}>} allTools
|
|
246
|
+
* @param {{name?:string,names?:Array<string>,closure?:boolean}} args
|
|
247
|
+
* @param {{closureSize?:number,serverOf?:Function}} [opts]
|
|
248
|
+
*/
|
|
249
|
+
export function buildSchemaResponse(allTools, args, opts = {}) {
|
|
250
|
+
const closureSize = Math.max(0, Number(opts.closureSize) || 0);
|
|
251
|
+
const svc = typeof opts.serverOf === "function" ? opts.serverOf : defaultServerOf;
|
|
252
|
+
const primary = String(args?.name || "");
|
|
253
|
+
const batch = (Array.isArray(args?.names) ? args.names : []).map((n) => String(n)).filter(Boolean);
|
|
254
|
+
const want = [...new Set([...batch, ...(primary ? [primary] : [])])];
|
|
255
|
+
if (want.length === 0) return { found: false, reason: "no name" };
|
|
256
|
+
const expanded = [];
|
|
257
|
+
const missing = [];
|
|
258
|
+
for (const n of want) {
|
|
259
|
+
const t = (allTools || []).find((x) => x && x.name === n);
|
|
260
|
+
if (!t) {
|
|
261
|
+
missing.push(n);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
expanded.push({
|
|
265
|
+
name: t.name,
|
|
266
|
+
server: svc(t.name),
|
|
267
|
+
description: t.description || "",
|
|
268
|
+
// C4 defense: even a schema containing `const: -0` must come back as
|
|
269
|
+
// lossless JSON.
|
|
270
|
+
parameters: sanitizeLossless(t.parameters ?? {}),
|
|
271
|
+
required: Array.isArray(t.parameters?.required) ? t.parameters.required : [],
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
// ⚠️ 判定只看 want.length——旧实现用 !primary 导致只传 name 的调用误走批量结构。
|
|
275
|
+
const single = want.length === 1;
|
|
276
|
+
if (single) {
|
|
277
|
+
if (expanded.length === 0) return { name: want[0], found: false, reason: "unknown tool" };
|
|
278
|
+
const e = expanded[0];
|
|
279
|
+
const out = {
|
|
280
|
+
name: e.name,
|
|
281
|
+
found: true,
|
|
282
|
+
server: e.server,
|
|
283
|
+
description: e.description,
|
|
284
|
+
parameters: e.parameters,
|
|
285
|
+
required: e.required,
|
|
286
|
+
};
|
|
287
|
+
if (closureSize > 0 && args?.closure !== false) {
|
|
288
|
+
out.siblings = siblingClosure(allTools, [e.name], closureSize, svc);
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
const out = {
|
|
293
|
+
found: expanded.length > 0,
|
|
294
|
+
...(expanded.length > 0
|
|
295
|
+
? {
|
|
296
|
+
results: expanded,
|
|
297
|
+
...(closureSize > 0 && args?.closure !== false
|
|
298
|
+
? { siblings: siblingClosure(allTools, expanded.map((e) => e.name), closureSize, svc) }
|
|
299
|
+
: {}),
|
|
300
|
+
}
|
|
301
|
+
: {}),
|
|
302
|
+
...(missing.length > 0 ? { missing } : {}),
|
|
303
|
+
};
|
|
304
|
+
return out;
|
|
305
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tool-folder",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|