dsh-tool-folder 0.2.4 → 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 CHANGED
@@ -149,6 +149,27 @@ 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
+
152
173
  ### v0.2.4 — P1: batch tools_schema + sibling closure (2026-08-31)
153
174
  1. **Batch expand** — `tools_schema` accepts `names: [...]` to expand several
154
175
  tools in one call (`{ found, results: [...] }`); a single `name` keeps the
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
@@ -37,7 +37,7 @@ import { selectionCoverage, isHotTool, prioritizeExact, touchWarm, evictWarm, mi
37
37
  import { tierForBudget } from "./budget.js";
38
38
  import { readState, writeState } from "./storage.js";
39
39
  import { expandQuery, fallbackCatalog, createSearchCache, recordDiscoveries, serializeDiscoveries, deserializeDiscoveries, siblingClosure, buildSchemaResponse } from "./ux.js";
40
- import { ChainGuard, verdict } from "./chainguard.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)
@@ -124,6 +125,7 @@ const DEFAULTS = {
124
125
  semanticEnabled: true, // P0-1: bge-m3 semantic leg for tools_search (RRF hybrid)
125
126
  ollamaBase: "http://127.0.0.1:11434",
126
127
  embedModel: "bge-m3",
128
+ embedTimeoutMs: 8000, // P3-2: embedding 请求超时(毫秒)
127
129
  disclosureBudget: 0, // v0.2.2: 分级披露预算(0=关闭)
128
130
  maxWarmTools: 0, // v0.2.2: warm LRU 上限(0=关闭,默认关=无配置升级逐字节等价)
129
131
  fallbackCatalogSize: 30, // P0: 零匹配回退轻目录条数(0=关闭)
@@ -200,24 +202,6 @@ function serverOf(name) {
200
202
  return "native";
201
203
  }
202
204
 
203
- /**
204
- * 是否「执行类工具」——参数是真实要执行的命令/脚本,需要高危硬拦。
205
- * 内容类工具(write/edit/read/apply_patch 等)的参数是文件内容/文本,
206
- * 本身不执行,含危险命令字面量(讨论/测试/文档)不应触发硬拦。
207
- * 2026-08-30:修复 ChainGuard 对所有工具参数跑高危正则导致的误拦。
208
- */
209
- function isExecTool(name) {
210
- if (!name) return false;
211
- const n = String(name).toLowerCase();
212
- const EXEC_NAMES = ["pwsh", "shell", "bash", "cmd", "exec", "terminal", "run", "console"];
213
- if (EXEC_NAMES.includes(n)) return true;
214
- if (n.includes("powershell")) return true; // mcp__windows__PowerShell 等(参数即命令)
215
- if (n.endsWith("__app")) return true; // mcp__windows__App:launch_executable 模式可启动任意可执行文件
216
- if (n.startsWith("run_") || n.startsWith("exec_") || n.startsWith("execute_")) return true;
217
- if (n.startsWith("ssh_") || n.startsWith("wsl")) return true;
218
- return false;
219
- }
220
-
221
205
  function summarizeCaps(tools) {
222
206
  // Pull the first few distinct capability keywords from descriptions.
223
207
  const seen = new Set();
@@ -279,7 +263,7 @@ export function apply(ctx, config) {
279
263
  // Semantic leg (P0-1): point the shared module at our config; the index is
280
264
  // built lazily on first tools_search call (doc embeddings cached on disk,
281
265
  // so the ~10s cold start happens once, then it's a cache hit).
282
- configureSemantic({ ollamaBase: cfg.ollamaBase, embedModel: cfg.embedModel });
266
+ configureSemantic({ ollamaBase: cfg.ollamaBase, embedModel: cfg.embedModel, embedTimeoutMs: cfg.embedTimeoutMs });
283
267
  let semIndexPromise = null;
284
268
  const getSemanticIndex = () => {
285
269
  if (!semIndexPromise) {
@@ -384,9 +368,12 @@ export function apply(ctx, config) {
384
368
  // P0-②: 检索结果缓存(同 query 60s 内稳定;LRU 200 条;进程级共享)。
385
369
  const searchCache = createSearchCache(200, 60000);
386
370
 
387
- // "mcp__viking__remember" → "mcp__viking"; plain tools ("tool-bash") stay
388
- // as themselves (single segment survives slice(0,2)).
389
- const serverOf = (name) => String(name).split("__").slice(0, 2).join("__");
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口径.
390
377
 
391
378
  // Pattern matching shared by deny (P0) and include (P2-1): exact name or
392
379
  // "prefix*" matches any tool starting with that prefix.
@@ -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
- 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;
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
- if (RE_B64_PAYLOAD.test(probe) && EXEC_VERB.test(probe)) {
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 = EMBED_TIMEOUT_MS) {
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 = EMBED_TIMEOUT_MS, opts = {}) {
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
@@ -87,8 +87,14 @@ export function expandQuery(q) {
87
87
  /* ------------------------- ② 零匹配回退轻目录 ------------------------- */
88
88
 
89
89
  /**
90
- * 轻目录:按名字典序排序取前 limit 个(name + 描述截 80 + server)。
90
+ * 轻目录:跨 server 轮转配额(name + 描述截 80 + server)。
91
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 个,轮转 = 字典序)。
92
98
  * @param {Array<{name:string,description?:string}>} allTools
93
99
  * @param {number} limit
94
100
  * @param {(name:string)=>string} [serverOf]
@@ -98,14 +104,28 @@ export function fallbackCatalog(allTools, limit, serverOf) {
98
104
  const lim = Math.max(0, Number(limit) || 0);
99
105
  if (!lim || !Array.isArray(allTools) || allTools.length === 0) return [];
100
106
  const svc = typeof serverOf === "function" ? serverOf : () => "";
101
- return [...allTools]
102
- .sort((a, b) => String(a.name).localeCompare(String(b.name)))
103
- .slice(0, lim)
104
- .map((t) => ({
105
- name: String(t.name || ""),
106
- description: String(t.description || "").slice(0, 80),
107
- server: svc(t.name),
108
- }));
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
+ }));
109
129
  }
110
130
 
111
131
  /* ------------------------- ③ 检索结果缓存 ------------------------- */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tool-folder",
3
- "version": "0.2.4",
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",