dsh-tool-folder 0.1.2 → 0.1.6
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 +150 -15
- package/cordis.patch.yml +14 -0
- package/lib/bm25.js +0 -1
- package/lib/category.js +34 -0
- package/lib/chainguard.js +93 -0
- package/lib/index.js +347 -43
- package/lib/metrics.js +14 -4
- package/lib/schema.js +112 -21
- package/lib/toonify.js +37 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -20,6 +20,18 @@ replaced object is authoritative (model-selection plugin does the same).
|
|
|
20
20
|
Execution is NOT gated on the current turn's tools — folded tools still
|
|
21
21
|
resolve through the full registry — so folding never removes capability.
|
|
22
22
|
|
|
23
|
+
Feedback and the firewall bind to the *execution* events, not the prompt
|
|
24
|
+
assembly:
|
|
25
|
+
- `tools/pre-execute` — listener receives `(exec, next)`. Return
|
|
26
|
+
`{ kind: "deny", reason }` to refuse the call, or call `next()` to allow.
|
|
27
|
+
`exec = { callId, name, arguments, agent, signal }` (there is no `.deny()`
|
|
28
|
+
method on exec — the contract is the returned decision object).
|
|
29
|
+
- `tools/post-execute` — listener receives `(exec, result, next)` and fires
|
|
30
|
+
once per executed tool; `exec.name` / `exec.arguments` feed heat counting,
|
|
31
|
+
exfil-chain detection, coverage recording and session persistence.
|
|
32
|
+
(`agent/pre-step` carries `{ messages, position, signal }` only — no
|
|
33
|
+
toolCalls — so it cannot drive feedback; verified against dsh-agent-loop.)
|
|
34
|
+
|
|
23
35
|
## Six layers — all implemented
|
|
24
36
|
|
|
25
37
|
| Layer | Mechanism | Status |
|
|
@@ -35,12 +47,18 @@ resolve through the full registry — so folding never removes capability.
|
|
|
35
47
|
| Segment | Content | Cost |
|
|
36
48
|
|---|---|---|
|
|
37
49
|
| core | per-agent core + heat-promoted tools, full schema | small, constant |
|
|
38
|
-
| dyn | BM25 top-K for the current query + CN-alias server
|
|
50
|
+
| dyn | BM25 top-K for the current query + CN-alias server top-K | small, per request |
|
|
39
51
|
| folded | everything else — dropped from the schema | zero |
|
|
40
52
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
(
|
|
53
|
+
An alias hit no longer pulls the whole server in: the matched server's tools
|
|
54
|
+
are ranked by BM25 (with the matched alias keywords bridged into the query to
|
|
55
|
+
cross the CN/EN gap) and the top `min(3, serverSize)` are loaded. When the
|
|
56
|
+
query yields no lexical signal at all, a stable name-order subset is loaded so
|
|
57
|
+
the alias still helps.
|
|
58
|
+
|
|
59
|
+
Optionally (`catalogEnabled: true`, default) folded tools are listed as a
|
|
60
|
+
one-line catalog section so the model still knows they exist and can call them
|
|
61
|
+
by name (execution-side fallback still works).
|
|
44
62
|
|
|
45
63
|
## Install
|
|
46
64
|
|
|
@@ -77,26 +95,144 @@ were never unregistered, so nothing else changes.
|
|
|
77
95
|
| enabled | true | master switch |
|
|
78
96
|
| perAgent | {} | `{agentId: {core: [...]}}` overrides |
|
|
79
97
|
| core | [] | always-loaded tools (fallback for unknown agents) |
|
|
98
|
+
| deny | [] | never inject + refuse execution: exact name, or `prefix*` matches a whole server. Deny wins over core, include and the catalog too. |
|
|
80
99
|
| topK | 6 | BM25 dynamic segment size |
|
|
81
100
|
| hotThreshold | 3 | folded tool called N times → auto-promote to core |
|
|
82
|
-
| catalogEnabled |
|
|
101
|
+
| catalogEnabled | true | append one-line catalog section for folded tools (fold safety net) |
|
|
102
|
+
| schemaToolEnabled | true | register the `tools_schema` meta tool (full parameter schema of one tool by name) |
|
|
103
|
+
| compressLevel | off | L1 compression tier: `off` \| `light` \| `standard` \| `aggressive`. `standard` trims descriptions to 200 chars + param descriptions to 120; `aggressive` additionally drops optional parameters (required ⊆ properties guaranteed constructively). Applies to dyn segment only — core/hot never trimmed. |
|
|
104
|
+
| compressEnabled | false | deprecated compat: `compressLevel: off` + `compressEnabled: true` is treated as `standard` |
|
|
105
|
+
| normalizeDescriptions | false | dyn-segment description hygiene: injection-marker removal + whitespace normalization + first-sentence keep + 300-char cap |
|
|
106
|
+
| category | {} | intent routing `{ "记忆/回忆/remember": ["mcp__viking"], ... }`. Key words are `/`-separated (OR). Category wins over aliases; each matched server loads per-server top-3. |
|
|
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
|
+
| 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) |
|
|
83
109
|
| maxFoldMs | 50 | hard cap; beyond this keep the full list |
|
|
84
110
|
| feedbackFile | '' | feedback JSON path (default `$DSH_HOME/logs/tool-folder/`) |
|
|
85
|
-
| aliases | built-in | CN-intent keyword → server prefix table |
|
|
111
|
+
| aliases | built-in | CN-intent keyword → server prefix table (per-server top-K) |
|
|
86
112
|
|
|
87
113
|
## Files
|
|
88
114
|
|
|
89
115
|
- `lib/bm25.js` — zero-dep BM25 (k1=1.2, b=0.75; CN bigrams + EN tokens)
|
|
116
|
+
- `lib/schema.js` — L1 tiered compression (`compressLevel`) + description normalization + lossless-JSON sanitizer
|
|
117
|
+
- `lib/category.js` — P1-1 intent-category routing (pure function, unit-testable)
|
|
118
|
+
- `lib/toonify.js` — P2-2 long JSON result compaction (pure function)
|
|
90
119
|
- `lib/index.js` — assemble hook, three-segment fold, heat feedback, safety
|
|
91
120
|
- `cordis.patch.yml` — bundle insert declaration
|
|
92
121
|
- `test.js` — simulated-cordis harness: `node test.js`
|
|
93
122
|
|
|
94
123
|
## Known limits
|
|
95
124
|
|
|
96
|
-
- BM25 cannot cross the CN-query / EN-description language gap; the
|
|
97
|
-
table covers common CN intents
|
|
98
|
-
|
|
99
|
-
|
|
125
|
+
- BM25 cannot cross the CN-query / EN-description language gap on its own; the
|
|
126
|
+
alias table covers common CN intents and now bridges its EN keywords into the
|
|
127
|
+
per-server BM25 scoring. A future query-rewrite leg (ARK LLM, proven in the
|
|
128
|
+
GPT Researcher smart retriever) removes this entirely.
|
|
129
|
+
- Alias hits load the server's top-3 relevant tools, not the whole server. A
|
|
130
|
+
server whose most relevant tool ranks low on a bridged CN query may miss it —
|
|
131
|
+
the `tools_search` meta-tool and heat promotion are the recovery paths.
|
|
132
|
+
- `catalogEnabled` render behavior needs one runtime verification pass
|
|
133
|
+
(section render is verified; the catalog text itself is standard).
|
|
134
|
+
- `toonifyResults` mutation of the result object in the host's post-execute
|
|
135
|
+
waterfall is not yet proven end-to-end (the listener's return is a gate, not
|
|
136
|
+
the result body) — the pure function + unit tests are in; runtime propagation
|
|
137
|
+
needs one verification pass. Default off until then.
|
|
138
|
+
- `aggressive` compression hides optional parameters from the model, so a
|
|
139
|
+
model that would have used them can't (execution still validates against the
|
|
140
|
+
registry's original schema — C6). `standard` is the safe default tier;
|
|
141
|
+
`aggressive` is opt-in.
|
|
142
|
+
|
|
143
|
+
## Changelog
|
|
144
|
+
|
|
145
|
+
### v0.1.6 — settings UI schema (2026-08-25)
|
|
146
|
+
1. **`Config` schema exported (schemastery)**: every toggle now renders as a
|
|
147
|
+
native form in the DSH settings UI — no more hand-editing YAML. The schema
|
|
148
|
+
mirrors `DEFAULTS` one-to-one (18 fields: enabled / core / deny / topK /
|
|
149
|
+
hotThreshold / catalogEnabled / compressLevel / compressEnabled /
|
|
150
|
+
schemaToolEnabled / normalizeDescriptions / category / include /
|
|
151
|
+
toonifyResults / toolSearchEnabled / firewallEnabled / maxFoldMs /
|
|
152
|
+
feedbackFile / aliases), with per-field Chinese descriptions and range
|
|
153
|
+
constraints (topK 0-50, hotThreshold ≥1, maxFoldMs 0-1000). Invalid values
|
|
154
|
+
are rejected with precise messages ("expected off | light | standard |
|
|
155
|
+
aggressive but got X"). Same pattern as @deepseek-ai/dsh-tool-todo
|
|
156
|
+
(`z.object` + `.default()`, schemastery `~standard` bridge).
|
|
157
|
+
2. **Dependencies**: `@deepseek-ai/schemastery` + `zod` (both `dependencies`,
|
|
158
|
+
matching the official plugin pattern — the zod adapter is required by the
|
|
159
|
+
`~standard` bridge).
|
|
160
|
+
|
|
161
|
+
### v0.1.5 — tools_schema + tiered compression + intent routing (2026-08-25)
|
|
162
|
+
1. **P0-1 `tools_schema` meta tool**: model-driven full-schema discovery for one
|
|
163
|
+
tool by name (complement to `tools_search`). Output is `{schema:{type:"json"}}`
|
|
164
|
+
(host contract C3 — a strict object schema would reject arbitrary tool
|
|
165
|
+
schemas with "is not a declared property"). Its return value is deep-washed
|
|
166
|
+
by `sanitizeLossless` (C4: `const:-0`/NaN can never leak). Meta tools are now
|
|
167
|
+
managed by a shared `META_TOOLS` block that keeps both `tools_search` and
|
|
168
|
+
`tools_schema` always visible.
|
|
169
|
+
2. **P0-2 `compressLevel` tiers**: `light`/`standard`/`aggressive` replace the
|
|
170
|
+
single conservative trim. `aggressive` drops optional parameters and rebuilds
|
|
171
|
+
`properties` from `required`, so `required ⊆ properties` holds constructively
|
|
172
|
+
(C8). Required properties are never dropped; `$ref`/`oneOf`/`items` deep
|
|
173
|
+
structure is never touched; non object-root parameter schemas get
|
|
174
|
+
description-only trimming. `compressEnabled: true` remains as a deprecated
|
|
175
|
+
compat alias for `standard`.
|
|
176
|
+
3. **P1-1 `category` intent routing**: `{ "记忆/回忆": ["mcp__viking"] }` routes a
|
|
177
|
+
query to explicit server prefixes (per-server top-3, BM25-bridged) and wins
|
|
178
|
+
over the alias table. Default `{}` = zero behavior change.
|
|
179
|
+
4. **P1-2 `normalizeDescriptions`**: dyn-segment descriptions are sanitized
|
|
180
|
+
(prompt-injection markers removed), whitespace-collapsed, first-sentence
|
|
181
|
+
kept, and capped at 300 chars. Core/hot descriptions are never touched.
|
|
182
|
+
5. **P2-1 `include` whitelist**: exact name or `prefix*`; non-empty → only
|
|
183
|
+
matching tools enter the injection pool. Meta tools are exempt (a whitelist
|
|
184
|
+
can never hide the discovery tools), deny still wins, and execution is never
|
|
185
|
+
gated by include (C5).
|
|
186
|
+
6. **P2-2 `toonifyResults`** (default off): post-execute compaction of long JSON
|
|
187
|
+
text blocks (>2000 chars) — drop empty fields, truncate long strings. Any
|
|
188
|
+
parse/compact failure leaves the original result untouched. Pure function in
|
|
189
|
+
`lib/toonify.js`; host waterfall propagation still to be confirmed at runtime
|
|
190
|
+
(see Known limits).
|
|
191
|
+
7. **Tests**: 33 → comprehensive suite (~50+ checks) covering every new feature
|
|
192
|
+
plus regression guards for the red lines ($ref untouched, core untouched,
|
|
193
|
+
required ⊆ properties, lossless JSON, deny-wins-over-include).
|
|
194
|
+
|
|
195
|
+
### v0.1.4 — audit fixes (2026-08-25)
|
|
196
|
+
1. **P0 firewall contract fixed** (`tools/pre-execute`): the listener now
|
|
197
|
+
returns `{ kind: "deny", reason }` to refuse (host contract verified:
|
|
198
|
+
`prepareExecution` reads `gate.kind` / `gate.reason`; `exec` has
|
|
199
|
+
`{ callId, name, arguments, agent, signal }` — the old `payload.deny()`
|
|
200
|
+
call and `toolName/argv` fields never existed, so high-risk blocks silently
|
|
201
|
+
never fired).
|
|
202
|
+
2. **P0 feedback loop revived** (`tools/post-execute`): heat counting,
|
|
203
|
+
exfil-chain recording, coverage and session persistence moved off
|
|
204
|
+
`agent/pre-step` (whose payload has no toolCalls) onto `tools/post-execute`
|
|
205
|
+
(fires per executed tool with `exec.name`/`exec.arguments`). Feedback now
|
|
206
|
+
actually persists and heat-promotes across restarts.
|
|
207
|
+
3. **P0 ESM crash removed**: `require("node:fs")` under `"type": "module"`
|
|
208
|
+
threw ReferenceError and silently killed all feedback persistence; replaced
|
|
209
|
+
with top-level `import fs` / `import path`. Also fixed a parameter-shadow
|
|
210
|
+
bug (`path` param hid the `node:path` module in `persistFeedback`).
|
|
211
|
+
4. **P1 alias whole-server pull fixed**: alias hits now load a per-server
|
|
212
|
+
BM25 top-K (`min(3, serverSize)`) with alias keywords bridged into the
|
|
213
|
+
query, instead of dragging in every tool of a matched server.
|
|
214
|
+
5. **P1 deny config added**: `deny: []` (exact name or `prefix*`) removes a
|
|
215
|
+
tool from the injected surface AND refuses its execution at
|
|
216
|
+
`tools/pre-execute`. Deny wins over core and the catalog.
|
|
217
|
+
6. **P2 stopword over-removal fixed**: `list/get/set/make/use(ing)` removed
|
|
218
|
+
from the EN stopword set — they are tool-name/description high-frequency
|
|
219
|
+
terms (`list_sessions`, `get_config`) and were eroding retrieval signal.
|
|
220
|
+
7. **P2 -0 defense**: metrics now normalize `-0` → `0` (DSH lossless JSON
|
|
221
|
+
rejects `-0`).
|
|
222
|
+
8. **P2 default flip**: `catalogEnabled` defaults to `true` (fold safety net —
|
|
223
|
+
the model can see that hidden tools exist). `compressEnabled` stays off
|
|
224
|
+
(L1 not fully verified).
|
|
225
|
+
9. **Tests**: suite extended 15 → 29 checks, covering the deny-return
|
|
226
|
+
contract, `tools/post-execute` receiving `exec.name`, deny config
|
|
227
|
+
(inject + execution), alias per-server top-K, no-`require` ESM hygiene, and
|
|
228
|
+
no-`-0` metric outputs.
|
|
229
|
+
10. **NEW-1 (QA finding, LOW)**: three backslash-sensitive firewall regexes
|
|
230
|
+
(`HKLM\SAM`, `\system32\config`, `\Run`) silently never matched when argv
|
|
231
|
+
reached `verdict()` JSON-encoded (single backslash → `\\`). Changed `\\`
|
|
232
|
+
to `\\+` so both raw-string and JSON-encoded paths are blocked. Added 4
|
|
233
|
+
regression checks (suite now 33).
|
|
234
|
+
|
|
235
|
+
### v0.1.3 — initial release (2026-08-24)
|
|
100
236
|
|
|
101
237
|
## Quality check (2026-08-24, real 45-tool set)
|
|
102
238
|
|
|
@@ -106,16 +242,15 @@ Full-pipeline test with the REAL tool list (converted to DSH public names
|
|
|
106
242
|
| query | result | verdict |
|
|
107
243
|
|---|---|---|
|
|
108
244
|
| delegate a coding task to openhands | 7 openhands tools | ✅ |
|
|
109
|
-
| 帮我记住这个事实 / 查记忆 记住 回忆 |
|
|
245
|
+
| 帮我记住这个事实 / 查记忆 记住 回忆 | top-3 viking tools (alias, per-server top-K) | ✅ |
|
|
110
246
|
| fetch library documentation | 2 context7 tools | ✅ |
|
|
111
|
-
| start a learning session |
|
|
247
|
+
| start a learning session | top-3 deeptutor tools (alias, per-server top-K) | ✅ |
|
|
112
248
|
| 复杂推理任务 深度思考 | deeptutor deep_* + reasonix + openhands | ✅ |
|
|
113
249
|
| 搜索代码 semantic search | viking grep/glob + reasonix code | ✅ |
|
|
114
250
|
|
|
115
251
|
- Fold ratio: 45 tools → 2-18 visible per query (avg ~8) ≈ **-82% schema tokens**
|
|
116
|
-
-
|
|
117
|
-
|
|
118
|
-
top-K refinement.
|
|
252
|
+
- v0.1.4: alias hits are capped at the server's top-3 by relevance instead of
|
|
253
|
+
pulling the whole server in (the v2 per-server top-K refinement is now live).
|
|
119
254
|
- BM25 shows minor lexical noise on some queries (e.g. `cancel_watch` on an
|
|
120
255
|
openhands query) — the model filters this during synthesis.
|
|
121
256
|
|
package/cordis.patch.yml
CHANGED
|
@@ -36,6 +36,20 @@
|
|
|
36
36
|
topK: 6
|
|
37
37
|
# Folded tool called this many times auto-promotes into core.
|
|
38
38
|
hotThreshold: 3
|
|
39
|
+
# v0.1.5 — meta tool: full parameter schema of one tool by name.
|
|
40
|
+
schemaToolEnabled: true
|
|
41
|
+
# L1 compression tier: off | light | standard | aggressive.
|
|
42
|
+
compressLevel: off
|
|
43
|
+
# Deprecated compat: compressLevel "off" + compressEnabled true → "standard".
|
|
44
|
+
compressEnabled: false
|
|
45
|
+
# Normalize dyn descriptions (injection sanitize + first sentence + 300 chars).
|
|
46
|
+
normalizeDescriptions: false
|
|
47
|
+
# Intent routing: { "记忆/回忆": ["mcp__viking"] } — category wins over aliases.
|
|
48
|
+
category: {}
|
|
49
|
+
# Whitelist: ["mcp__openhands*", "mcp__viking__remember"]; empty = all (minus deny).
|
|
50
|
+
include: []
|
|
51
|
+
# Compact long JSON tool results (drop empty fields + truncate long strings).
|
|
52
|
+
toonifyResults: false
|
|
39
53
|
# One-line catalog section for folded tools (model knows they exist).
|
|
40
54
|
# Off until section render behavior is verified at runtime.
|
|
41
55
|
catalogEnabled: false
|
package/lib/bm25.js
CHANGED
|
@@ -22,7 +22,6 @@ const EN_STOP = new Set([
|
|
|
22
22
|
"it", "this", "that", "these", "those", "i", "you", "he", "she", "we",
|
|
23
23
|
"they", "do", "does", "did", "have", "has", "had", "not", "no", "yes",
|
|
24
24
|
"will", "would", "can", "could", "should", "may", "might", "must",
|
|
25
|
-
"use", "used", "using", "get", "gets", "got", "set", "make", "list",
|
|
26
25
|
"your", "my", "our", "their", "its", "all", "any", "some", "each",
|
|
27
26
|
]);
|
|
28
27
|
|
package/lib/category.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* category.js — P1-1 intent-category routing for the dynamic tool segment.
|
|
3
|
+
*
|
|
4
|
+
* Operator-configurable `category` map: `{ "记忆/回忆/remember": ["mcp__viking"], ... }`
|
|
5
|
+
* The key is a `/`-separated list of intent words (OR semantics — any word
|
|
6
|
+
* contained in the query matches). The value is a list of server prefixes to
|
|
7
|
+
* load per-server top-K tools from.
|
|
8
|
+
*
|
|
9
|
+
* Pure function, zero dependencies, unit-testable without the plugin context.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Match a lowercased query against the category map.
|
|
14
|
+
* @param {string} queryLower pre-lowercased user query
|
|
15
|
+
* @param {object} category `{ "word1/word2": ["serverPrefix", ...], ... }`
|
|
16
|
+
* @returns {{matched: boolean, matches: Array<{server: string, keywords: string[]}>}}
|
|
17
|
+
* `matches` entries carry the server prefix and the hit keywords (used to
|
|
18
|
+
* bridge CN intent words into the per-server BM25 scoring).
|
|
19
|
+
*/
|
|
20
|
+
export function matchCategory(queryLower, category) {
|
|
21
|
+
const matches = [];
|
|
22
|
+
for (const [key, servers] of Object.entries(category || {})) {
|
|
23
|
+
const keywords = String(key)
|
|
24
|
+
.split(/[/,,、]/)
|
|
25
|
+
.map((s) => s.trim().toLowerCase())
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
const hit = keywords.filter((k) => queryLower.includes(k));
|
|
28
|
+
if (hit.length === 0 || !Array.isArray(servers)) continue;
|
|
29
|
+
for (const server of servers) {
|
|
30
|
+
matches.push({ server: String(server), keywords: hit });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { matched: matches.length > 0, matches };
|
|
34
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// ChainGuard — session-level tool-call firewall (ported from xiaowan tool_firewall T3).
|
|
2
|
+
//
|
|
3
|
+
// Two layers:
|
|
4
|
+
// 1. ChainGuard.check() — high-risk command verdict (AEGIS-style hard block list).
|
|
5
|
+
// 2. ChainGuard.record() — session window of tool calls; ChainGuard.checkChain()
|
|
6
|
+
// detects "sensitive read → external exfil" chains (Invariant-style: a single
|
|
7
|
+
// call is harmless, the SEQUENCE is the attack).
|
|
8
|
+
//
|
|
9
|
+
// Zero dependencies, pure regex + window buffer. Runs in the cordis plugin sandbox.
|
|
10
|
+
|
|
11
|
+
const HIGH_RISK_PATTERNS = [
|
|
12
|
+
// destructive filesystem
|
|
13
|
+
[/rm\s+-rf\s+[\\/]?[A-Za-z]:[\\/]|rm\s+-rf\s+\/|rm\s+-rf\s+[*?]/i, "recursive root delete"],
|
|
14
|
+
[/format\s+[A-Za-z]:|format\s+\/q/i, "disk format"],
|
|
15
|
+
[/del\s+\/f\s+\/s\s+\/q/i, "force recursive delete"],
|
|
16
|
+
// download → execute chain
|
|
17
|
+
[/(wget|curl|powershell|pwsh).{0,80}(\||;|&&).{0,30}(sh|bash|powershell|pwsh|cmd|start)\b/i, "download-pipe-execute"],
|
|
18
|
+
[/invoke-expression|iex\s*\(|iwr\s+.*\|.*iex/i, "powershell download-exec"],
|
|
19
|
+
// credential / exfil
|
|
20
|
+
// NOTE: `\\+` (one-or-more literal backslashes) instead of `\\` — argv text
|
|
21
|
+
// reaches verdict() both as a raw string (single backslash) and as
|
|
22
|
+
// JSON.stringify(object) (backslash escaped to `\\`). A bare `\\` only
|
|
23
|
+
// matches the raw path, silently missing JSON-encoded calls (NEW-1).
|
|
24
|
+
[/(net\s+user|whoami\s+\/all|reg\s+query\s+HKLM\\+SAM)/i, "credential harvest"],
|
|
25
|
+
[/copy\s+.*\\+system32\\+config|type\s+.*\\+(ntuser\.dat|SAM|SYSTEM)/i, "system file exfil"],
|
|
26
|
+
// shadow / persistence
|
|
27
|
+
[/schtasks\s+\/create|reg\s+add\s+.*\\+run/i, "persistence install"],
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// Tools that READ sensitive local state (file system, memory, secrets).
|
|
31
|
+
const SENSITIVE_READ = new Set([
|
|
32
|
+
"read", "glob", "grep", "edit",
|
|
33
|
+
"mcp__viking__find", "mcp__viking__glob", "mcp__viking__read",
|
|
34
|
+
"ssh_download", "ssh_list", "job_output", "describe_image", "read_image",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Tools that SEND data to an external surface (network, memory writes, uploads).
|
|
38
|
+
const EXTERNAL_SEND = new Set([
|
|
39
|
+
"mcp__viking__remember", "mcp__viking__add_resource", "mcp__viking__forget",
|
|
40
|
+
"ssh_upload", "send_message", "web_search",
|
|
41
|
+
"mcp__openhands__openhands_create_conversation",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export function verdict(argvText) {
|
|
45
|
+
if (!argvText) return null;
|
|
46
|
+
for (const [re, label] of HIGH_RISK_PATTERNS) {
|
|
47
|
+
if (re.test(argvText)) {
|
|
48
|
+
return { blocked: true, label, pattern: String(re) };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { blocked: false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ChainGuard {
|
|
55
|
+
constructor({ window = 30 } = {}) {
|
|
56
|
+
this.window = window;
|
|
57
|
+
this.history = []; // [{tool, argv, ts}]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
record(tool, argv = "") {
|
|
61
|
+
this.history.push({ tool, argv: String(argv || "").slice(0, 300), ts: Date.now() });
|
|
62
|
+
if (this.history.length > this.window) {
|
|
63
|
+
this.history.splice(0, this.history.length - this.window);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Sensitive read followed within 3 steps by an external send → exfil chain.
|
|
68
|
+
checkChain() {
|
|
69
|
+
if (this.history.length < 2) return null;
|
|
70
|
+
for (let i = 0; i < this.history.length; i++) {
|
|
71
|
+
const h = this.history[i];
|
|
72
|
+
if (!SENSITIVE_READ.has(h.tool)) continue;
|
|
73
|
+
const lookahead = this.history.slice(i + 1, i + 4);
|
|
74
|
+
for (let j = 0; j < lookahead.length; j++) {
|
|
75
|
+
if (EXTERNAL_SEND.has(lookahead[j].tool)) {
|
|
76
|
+
return {
|
|
77
|
+
risk: "high",
|
|
78
|
+
label: "sensitive-read → external-send chain",
|
|
79
|
+
chain: [
|
|
80
|
+
`${h.tool}:${String(h.argv).slice(0, 60)}`,
|
|
81
|
+
...lookahead.slice(0, j + 1).map((x) => `${x.tool}:${String(x.argv).slice(0, 60)}`),
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
reset() {
|
|
91
|
+
this.history = [];
|
|
92
|
+
}
|
|
93
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -17,27 +17,79 @@
|
|
|
17
17
|
* buildRequest uses assembly.tools as the ONLY source of the API tools field.
|
|
18
18
|
* Execution is NOT gated on the current turn's tools, so folding never
|
|
19
19
|
* removes capability.
|
|
20
|
+
* `tools/pre-execute` — listener receives (exec, next); return
|
|
21
|
+
* { kind: "deny", reason } to refuse, or call next() to allow.
|
|
22
|
+
* exec = { callId, name, arguments, agent, signal } (no .deny() method).
|
|
23
|
+
* `tools/post-execute` — listener receives (exec, result, next); fires once
|
|
24
|
+
* per executed tool. exec.name/exec.arguments feed the L3/L4 feedback
|
|
25
|
+
* loop. (`agent/pre-step` carries { messages, position, signal } only —
|
|
26
|
+
* no toolCalls — so it is useless for feedback.)
|
|
20
27
|
*
|
|
21
28
|
* Safety: any error falls back to the untouched assembly. Performance: BM25
|
|
22
29
|
* in-memory + sync; a maxFoldMs hard cap keeps the full list if we overrun.
|
|
23
30
|
*/
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import z from "@deepseek-ai/schemastery";
|
|
24
34
|
import { buildIndex, search } from "./bm25.js";
|
|
25
|
-
import { compressTool } from "./schema.js";
|
|
35
|
+
import { compressTool, normalizeDescription, sanitizeLossless } from "./schema.js";
|
|
26
36
|
import { selectionCoverage } from "./metrics.js";
|
|
37
|
+
import { ChainGuard, verdict } from "./chainguard.js";
|
|
38
|
+
import { matchCategory } from "./category.js";
|
|
39
|
+
import { toonifyValue } from "./toonify.js";
|
|
27
40
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
28
41
|
|
|
29
42
|
export const name = "tool-folder";
|
|
30
43
|
export const inject = ["systemPrompt", "agents", "tools"];
|
|
31
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Schemastery configuration schema — renders the plugin's settings form in the
|
|
47
|
+
* DSH settings UI (per cordis `runtime.Config["~standard"]`). Mirrors DEFAULTS
|
|
48
|
+
* below; the UI exposes every toggle/knob this plugin has.
|
|
49
|
+
* Same pattern as @deepseek-ai/dsh-tool-todo (z.object + .default()).
|
|
50
|
+
*/
|
|
51
|
+
const COMPRESS_LEVELS = ["off", "light", "standard", "aggressive"];
|
|
52
|
+
export const Config = z.object({
|
|
53
|
+
enabled: z.boolean().default(true).description("总开关:启用工具折叠"),
|
|
54
|
+
core: z.array(z.string()).default([]).description("每轮恒显示的工具(平台核心,永不折叠)"),
|
|
55
|
+
perAgent: z.dict(z.object({
|
|
56
|
+
core: z.array(z.string()).default([]).description("该 agent 的常驻工具覆盖(未列出 agent 回退到全局 core)"),
|
|
57
|
+
})).default({}).description("按 agent 覆盖常驻工具:{\"standard\": {\"core\": [...]}}"),
|
|
58
|
+
deny: z.array(z.string()).default([]).description("禁用工具:精确名或 prefix* 前缀,命中不注入+拒绝执行"),
|
|
59
|
+
topK: z.number().min(0).max(50).default(6).description("BM25 动态检索每轮最多注入的工具数"),
|
|
60
|
+
hotThreshold: z.number().min(1).default(3).description("被折叠工具调用几次后自动晋升为常驻"),
|
|
61
|
+
catalogEnabled: z.boolean().default(true).description("折叠安全网:把被藏工具列表写进提示词,模型知道它们存在"),
|
|
62
|
+
compressLevel: z.union(COMPRESS_LEVELS).default("off").description("L1 schema 压缩级别:off 不压缩 / light 裁描述 / standard 裁描述+参数说明 / aggressive 删可选参数"),
|
|
63
|
+
compressEnabled: z.boolean().default(false).description("兼容开关:compressLevel=off 且此开=true 时按 standard 处理"),
|
|
64
|
+
schemaToolEnabled: z.boolean().default(true).description("注册 tools_schema 元工具:按名称展开任意工具的完整参数 schema"),
|
|
65
|
+
normalizeDescriptions: z.boolean().default(false).description("描述标准化:注入消毒+首句提取+300 字符(只作用于动态段)"),
|
|
66
|
+
category: z.dict(z.array(z.string())).default({}).description("意图路由:{\"记忆/回忆\": [\"mcp__viking\"]},优先级 category > aliases > BM25"),
|
|
67
|
+
include: z.array(z.string()).default([]).description("白名单:[\"mcp__openhands*\"],空=全部(deny 仍优先,meta 工具豁免)"),
|
|
68
|
+
toonifyResults: z.boolean().default(false).description("长 JSON 结果紧凑化(删空字段/截长串,默认关)"),
|
|
69
|
+
toolSearchEnabled: z.boolean().default(true).description("注册 tools_search 元工具:检索被折叠的工具目录"),
|
|
70
|
+
firewallEnabled: z.boolean().default(true).description("ChainGuard 防火墙:高危命令硬拦截+外泄链检测"),
|
|
71
|
+
maxFoldMs: z.number().min(0).max(1000).default(50).description("折叠超时上限(ms),超时回退完整工具列表"),
|
|
72
|
+
feedbackFile: z.string().default("").description("反馈数据落盘路径(空=DSH_HOME/logs/tool-folder/)"),
|
|
73
|
+
aliases: z.dict(z.array(z.string())).default({}).description("中文意图→server 别名表"),
|
|
74
|
+
});
|
|
75
|
+
|
|
32
76
|
const DEFAULTS = {
|
|
33
77
|
enabled: true,
|
|
34
78
|
perAgent: {},
|
|
35
79
|
core: [],
|
|
80
|
+
deny: [], // never inject + refuse execution (exact name or "prefix*")
|
|
36
81
|
topK: 6,
|
|
37
82
|
hotThreshold: 3,
|
|
38
|
-
catalogEnabled:
|
|
39
|
-
compressEnabled: false,
|
|
83
|
+
catalogEnabled: true, // safety net: folded tools stay discoverable by name
|
|
84
|
+
compressEnabled: false, // deprecated compat: compressLevel "off" + true → "standard"
|
|
85
|
+
compressLevel: "off", // "off" | "light" | "standard" | "aggressive"
|
|
86
|
+
schemaToolEnabled: true, // P0-1: register the tools_schema meta tool
|
|
87
|
+
normalizeDescriptions: false, // P1-2: sanitize+first-sentence+300 dyn descriptions
|
|
88
|
+
category: {}, // P1-1: { "记忆/回忆": ["mcp__viking"], ... } intent routing
|
|
89
|
+
include: [], // P2-1: whitelist ("prefix*" or exact name); empty = all (minus deny)
|
|
90
|
+
toonifyResults: false, // P2-2: compact long JSON results (default off)
|
|
40
91
|
toolSearchEnabled: true,
|
|
92
|
+
firewallEnabled: true, // ChainGuard: high-risk hard block + exfil chain warn
|
|
41
93
|
maxFoldMs: 50,
|
|
42
94
|
feedbackFile: "",
|
|
43
95
|
aliases: {
|
|
@@ -64,26 +116,35 @@ function feedbackPath(cfg) {
|
|
|
64
116
|
}
|
|
65
117
|
}
|
|
66
118
|
|
|
67
|
-
function loadFeedback(
|
|
119
|
+
function loadFeedback(file, log) {
|
|
68
120
|
try {
|
|
69
|
-
|
|
70
|
-
if (fs.existsSync(path)) return JSON.parse(fs.readFileSync(path, "utf8"));
|
|
121
|
+
if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
71
122
|
} catch (e) {
|
|
72
123
|
log?.warn?.("feedback load failed (%s) — starting empty", e?.message);
|
|
73
124
|
}
|
|
74
125
|
return { calls: {}, sessions: [] };
|
|
75
126
|
}
|
|
76
127
|
|
|
77
|
-
function persistFeedback(
|
|
128
|
+
function persistFeedback(file, fb, log) {
|
|
78
129
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
fs.writeFileSync(path, JSON.stringify(fb, null, 2), "utf8");
|
|
130
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
131
|
+
fs.writeFileSync(file, JSON.stringify(fb, null, 2), "utf8");
|
|
82
132
|
} catch (e) {
|
|
83
133
|
log?.warn?.("feedback persist failed (%s) — non-fatal", e?.message);
|
|
84
134
|
}
|
|
85
135
|
}
|
|
86
136
|
|
|
137
|
+
/** Serialize an exec.arguments payload (object) into text for firewall verdicts. */
|
|
138
|
+
function execArgumentsText(args) {
|
|
139
|
+
if (args == null) return "";
|
|
140
|
+
if (typeof args === "string") return args;
|
|
141
|
+
try {
|
|
142
|
+
return JSON.stringify(args);
|
|
143
|
+
} catch {
|
|
144
|
+
return String(args);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
87
148
|
function extractQuery(context) {
|
|
88
149
|
const signal = context && context.signal;
|
|
89
150
|
if (!signal) return "";
|
|
@@ -116,6 +177,19 @@ function catalogText(tools) {
|
|
|
116
177
|
|
|
117
178
|
export function apply(ctx, config) {
|
|
118
179
|
const cfg = { ...DEFAULTS, ...(config || {}) };
|
|
180
|
+
|
|
181
|
+
// T01: normalize the v0.1.5 config surface right after the DEFAULTS merge.
|
|
182
|
+
// - compressLevel accepts only the four valid tiers; anything else → "off".
|
|
183
|
+
// - compressEnabled is the deprecated compat switch: "off" + true → "standard".
|
|
184
|
+
// - include patterns are trimmed/non-empty; empty list = include-all.
|
|
185
|
+
// - category must be a plain object; malformed values are ignored.
|
|
186
|
+
const VALID_LEVELS = ["off", "light", "standard", "aggressive"];
|
|
187
|
+
const compressLevel = VALID_LEVELS.includes(cfg.compressLevel) ? cfg.compressLevel : "off";
|
|
188
|
+
const effectiveLevel = compressLevel !== "off" ? compressLevel : (cfg.compressEnabled ? "standard" : "off");
|
|
189
|
+
const includePatterns = (cfg.include || []).map((n) => String(n).trim()).filter(Boolean);
|
|
190
|
+
const includeAll = includePatterns.length === 0;
|
|
191
|
+
const categoryCfg = (cfg.category && typeof cfg.category === "object") ? cfg.category : {};
|
|
192
|
+
|
|
119
193
|
const logger = ctx.logger("tool-folder");
|
|
120
194
|
if (!cfg.enabled) {
|
|
121
195
|
logger.info("disabled by config");
|
|
@@ -129,21 +203,81 @@ export function apply(ctx, config) {
|
|
|
129
203
|
let allTools = [];
|
|
130
204
|
let lastVisible = { agentId: "", names: [], ts: 0 };
|
|
131
205
|
const disposers = [];
|
|
206
|
+
const chain = new ChainGuard({ window: 30 }); // firewall: exfil-chain + high-risk
|
|
132
207
|
|
|
133
|
-
|
|
208
|
+
// "mcp__viking__remember" → "mcp__viking"; plain tools ("tool-bash") stay
|
|
209
|
+
// as themselves (single segment survives slice(0,2)).
|
|
210
|
+
const serverOf = (name) => String(name).split("__").slice(0, 2).join("__");
|
|
211
|
+
|
|
212
|
+
// Pattern matching shared by deny (P0) and include (P2-1): exact name or
|
|
213
|
+
// "prefix*" matches any tool starting with that prefix.
|
|
214
|
+
function matchesAnyPattern(name, patterns) {
|
|
215
|
+
if (!name || !patterns || patterns.length === 0) return false;
|
|
216
|
+
const n = String(name);
|
|
217
|
+
for (const p of patterns) {
|
|
218
|
+
if (p.endsWith("*")) {
|
|
219
|
+
if (n.startsWith(p.slice(0, -1))) return true;
|
|
220
|
+
} else if (n === p) {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
// deny config: exact name, or "prefix*" matches any tool starting with it.
|
|
227
|
+
// Deny wins over core, include and the catalog (a denied tool is not listed).
|
|
228
|
+
const denyPatterns = (cfg.deny || []).map((n) => String(n).trim()).filter(Boolean);
|
|
229
|
+
const isDenied = (n) => matchesAnyPattern(n, denyPatterns);
|
|
230
|
+
|
|
231
|
+
// Per-server top-K: rank the matched server's tools by BM25 relevance with
|
|
232
|
+
// the matched intent keywords bridged into the query. When there is no
|
|
233
|
+
// lexical signal at all, fall back to a stable name-order subset so the
|
|
234
|
+
// intent route still helps. Used by both aliases and category routing.
|
|
235
|
+
function addPerServerTopK(dyn, tools, serverPrefix, bridgedQuery, maxK) {
|
|
236
|
+
const serverIndices = [];
|
|
237
|
+
for (let i = 0; i < tools.length; i++) {
|
|
238
|
+
if (tools[i].name.startsWith(serverPrefix)) serverIndices.push(i);
|
|
239
|
+
}
|
|
240
|
+
if (serverIndices.length === 0) return;
|
|
241
|
+
const k = Math.min(maxK, serverIndices.length);
|
|
242
|
+
let ranked = [];
|
|
243
|
+
if (index) {
|
|
244
|
+
ranked = search(index, bridgedQuery, serverIndices.length, {
|
|
245
|
+
filter: (i) => tools[i].name.startsWith(serverPrefix),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (ranked.length === 0) {
|
|
249
|
+
// No lexical signal at all → stable name order so the route still helps.
|
|
250
|
+
serverIndices.sort((a, b) => (tools[a].name < tools[b].name ? -1 : 1));
|
|
251
|
+
for (const i of serverIndices.slice(0, k)) dyn.add(tools[i].name);
|
|
252
|
+
} else {
|
|
253
|
+
for (const i of ranked.slice(0, k)) dyn.add(tools[i].name);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
134
256
|
|
|
135
257
|
function buildDynamicSet(tools, query) {
|
|
136
258
|
const dyn = new Set();
|
|
137
|
-
if (query
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
259
|
+
if (!query) return dyn;
|
|
260
|
+
const q = query.toLowerCase();
|
|
261
|
+
// P1-1: category wins over aliases. When a category key matches, only the
|
|
262
|
+
// category's servers are routed per-server top-K; the aliases branch is
|
|
263
|
+
// skipped. The generic BM25 leg is still merged as a union afterwards.
|
|
264
|
+
const cat = matchCategory(q, categoryCfg);
|
|
265
|
+
if (cat.matched) {
|
|
266
|
+
for (const m of cat.matches) {
|
|
267
|
+
addPerServerTopK(dyn, tools, m.server, `${query} ${m.keywords.join(" ")}`, 3);
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
// aliases fallback (existing behavior preserved verbatim)
|
|
141
271
|
for (const [prefix, keywords] of Object.entries(cfg.aliases || {})) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
272
|
+
const matched = keywords.filter((k) => q.includes(String(k).toLowerCase()));
|
|
273
|
+
if (matched.length === 0) continue;
|
|
274
|
+
addPerServerTopK(dyn, tools, prefix, `${query} ${matched.join(" ")}`, 3);
|
|
145
275
|
}
|
|
146
276
|
}
|
|
277
|
+
// Generic BM25 last as a union (result set unchanged vs. previous order).
|
|
278
|
+
if (cfg.topK > 0 && index) {
|
|
279
|
+
for (const i of search(index, query, cfg.topK)) dyn.add(tools[i].name);
|
|
280
|
+
}
|
|
147
281
|
return dyn;
|
|
148
282
|
}
|
|
149
283
|
|
|
@@ -212,13 +346,79 @@ export function apply(ctx, config) {
|
|
|
212
346
|
}
|
|
213
347
|
}
|
|
214
348
|
|
|
349
|
+
// L4: `tools_schema` — model-driven full-schema discovery for one tool.
|
|
350
|
+
// Complementary to tools_search: search narrows candidates, this returns the
|
|
351
|
+
// exact parameter schema + full description of one named tool.
|
|
352
|
+
// RED LINE (C3): output MUST be `{ schema: { type: "json" } }` — the return
|
|
353
|
+
// value is an arbitrary tool schema, and a strict object schema with
|
|
354
|
+
// additionalProperties:false would fail the host's "declared property"
|
|
355
|
+
// validation (ToolOutputError). The `type:"json"` annotation schema only
|
|
356
|
+
// checks lossless JSON (C4).
|
|
357
|
+
if (cfg.schemaToolEnabled) {
|
|
358
|
+
const schemaTool = defineTool({
|
|
359
|
+
name: "tools_schema",
|
|
360
|
+
description:
|
|
361
|
+
"Get the complete parameter schema of one tool by name. Use after " +
|
|
362
|
+
"tools_search narrows candidates, or before calling a tool whose exact " +
|
|
363
|
+
"arguments you need. Returns the full JSON schema of parameters plus the " +
|
|
364
|
+
"full description.",
|
|
365
|
+
parameters: {
|
|
366
|
+
name: {
|
|
367
|
+
type: "string",
|
|
368
|
+
required: true,
|
|
369
|
+
description: "Exact tool name, e.g. 'mcp__viking__remember'.",
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
output: {
|
|
373
|
+
schema: { type: "json" },
|
|
374
|
+
render: (_a, v) => [{ type: "text", text: JSON.stringify(v, null, 2) }],
|
|
375
|
+
},
|
|
376
|
+
isConcurrencySafe: () => true,
|
|
377
|
+
async execute(args) {
|
|
378
|
+
const name = String(args?.name || "");
|
|
379
|
+
const t = allTools.find((x) => x.name === name);
|
|
380
|
+
if (!t) return { name, found: false, reason: "unknown tool" }; // never throw
|
|
381
|
+
return {
|
|
382
|
+
name: t.name,
|
|
383
|
+
found: true,
|
|
384
|
+
server: serverOf(t.name),
|
|
385
|
+
description: t.description || "",
|
|
386
|
+
// C4 defense: even a schema containing `const: -0` must come back as
|
|
387
|
+
// lossless JSON.
|
|
388
|
+
parameters: sanitizeLossless(t.parameters ?? {}),
|
|
389
|
+
required: Array.isArray(t.parameters?.required) ? t.parameters.required : [],
|
|
390
|
+
};
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
try {
|
|
394
|
+
ctx.tools.register(schemaTool);
|
|
395
|
+
logger.info("tools_schema registered");
|
|
396
|
+
} catch (e) {
|
|
397
|
+
logger.warn("tools_schema register failed: %s", e?.message);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
215
401
|
disposers.push(
|
|
216
402
|
ctx.on("system-prompt/assemble", async (assembly, context, next) => {
|
|
217
403
|
const t0 = Date.now();
|
|
218
404
|
try {
|
|
219
405
|
const assembled = await next();
|
|
220
|
-
const
|
|
221
|
-
if (!Array.isArray(
|
|
406
|
+
const assembledTools = assembled.tools || [];
|
|
407
|
+
if (!Array.isArray(assembledTools)) return assembled;
|
|
408
|
+
// P2-1 include whitelist + deny config remove tools from the injected
|
|
409
|
+
// surface BEFORE folding. Semantics: include non-empty → whitelist mode,
|
|
410
|
+
// only matching tools enter the pool (injection surface + catalog +
|
|
411
|
+
// tools_search/tools_schema search scope); execution is NOT gated here
|
|
412
|
+
// (C5 — pre-execute only checks deny + firewall). Deny still wins.
|
|
413
|
+
// Meta tools are exempt from include so a whitelist can never hide the
|
|
414
|
+
// discovery tools themselves (that would break the always-visible loop).
|
|
415
|
+
const isMetaTool = (n) => n === "tools_search" || n === "tools_schema";
|
|
416
|
+
const tools = assembledTools.filter(
|
|
417
|
+
(t) =>
|
|
418
|
+
!isDenied(t?.name) &&
|
|
419
|
+
(isMetaTool(t?.name) || includeAll || matchesAnyPattern(t?.name, includePatterns)),
|
|
420
|
+
);
|
|
421
|
+
if (tools.length === 0) return { ...assembled, tools };
|
|
222
422
|
|
|
223
423
|
const agentId = context?.agent?.id || "default";
|
|
224
424
|
const coreNames = new Set(cfg.perAgent?.[agentId]?.core || cfg.core || []);
|
|
@@ -244,19 +444,46 @@ export function apply(ctx, config) {
|
|
|
244
444
|
// L5: stable ordering — core by config order, hot by name, dyn by name.
|
|
245
445
|
// A deterministic byte order keeps the prompt prefix cacheable.
|
|
246
446
|
const coreTools = tools.filter((t) => coreNames.has(t.name));
|
|
247
|
-
const hotTools = tools
|
|
248
|
-
|
|
447
|
+
const hotTools = tools
|
|
448
|
+
.filter((t) => !coreNames.has(t.name) && hotNames.has(t.name))
|
|
449
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
450
|
+
const dynTools = tools
|
|
451
|
+
.filter(
|
|
452
|
+
(t) =>
|
|
453
|
+
!coreNames.has(t.name) &&
|
|
454
|
+
!hotNames.has(t.name) &&
|
|
455
|
+
dynNames.has(t.name),
|
|
456
|
+
)
|
|
457
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
249
458
|
|
|
250
|
-
// L1:
|
|
251
|
-
|
|
459
|
+
// L1: schema compression + description normalization on the dynamic
|
|
460
|
+
// segment only. core/hot never pass through this pipe (P0 red line).
|
|
461
|
+
const trimmed =
|
|
462
|
+
effectiveLevel !== "off" || cfg.normalizeDescriptions
|
|
463
|
+
? dynTools.map((t) => {
|
|
464
|
+
let out = t;
|
|
465
|
+
if (cfg.normalizeDescriptions && typeof t.description === "string") {
|
|
466
|
+
out = { ...out, description: normalizeDescription(t.description) };
|
|
467
|
+
}
|
|
468
|
+
return effectiveLevel !== "off"
|
|
469
|
+
? compressTool(out, { level: effectiveLevel })
|
|
470
|
+
: out;
|
|
471
|
+
})
|
|
472
|
+
: dynTools;
|
|
252
473
|
|
|
253
474
|
const visible = [...coreTools, ...hotTools, ...trimmed];
|
|
254
475
|
|
|
255
|
-
// The meta-
|
|
256
|
-
// for folded tools (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
476
|
+
// The meta-tools must always stay visible or the model can never ask
|
|
477
|
+
// for folded tools (they would be hidden by their own fold).
|
|
478
|
+
const META_TOOLS = [
|
|
479
|
+
{ name: "tools_search", enabled: cfg.toolSearchEnabled },
|
|
480
|
+
{ name: "tools_schema", enabled: cfg.schemaToolEnabled },
|
|
481
|
+
];
|
|
482
|
+
for (const m of META_TOOLS) {
|
|
483
|
+
if (m.enabled && !visible.some((t) => t.name === m.name)) {
|
|
484
|
+
const meta = tools.find((t) => t.name === m.name);
|
|
485
|
+
if (meta) visible.push(meta);
|
|
486
|
+
}
|
|
260
487
|
}
|
|
261
488
|
|
|
262
489
|
const visibleNames = visible.map((t) => t.name);
|
|
@@ -265,7 +492,11 @@ export function apply(ctx, config) {
|
|
|
265
492
|
let sections = assembled.sections;
|
|
266
493
|
if (cfg.catalogEnabled) {
|
|
267
494
|
const folded = tools.filter(
|
|
268
|
-
(t) =>
|
|
495
|
+
(t) =>
|
|
496
|
+
!isMetaTool(t.name) &&
|
|
497
|
+
!coreNames.has(t.name) &&
|
|
498
|
+
!hotNames.has(t.name) &&
|
|
499
|
+
!dynNames.has(t.name),
|
|
269
500
|
);
|
|
270
501
|
if (folded.length > 0) {
|
|
271
502
|
sections = [...(assembled.sections || []), {
|
|
@@ -293,22 +524,65 @@ export function apply(ctx, config) {
|
|
|
293
524
|
);
|
|
294
525
|
|
|
295
526
|
// L3 + L4: heat feedback, coverage recording, and folded-call detection.
|
|
527
|
+
// Bound to `tools/post-execute` — payload = (exec, result), exec.name /
|
|
528
|
+
// exec.arguments are available. (`agent/pre-step` was the original hook but
|
|
529
|
+
// its payload is { messages, ...position, signal } — no toolCalls — so the
|
|
530
|
+
// feedback loop had no data. Verified against dsh-agent-loop preStep().)
|
|
531
|
+
// This listener is deliberately side-effect only: it always returns next()
|
|
532
|
+
// and never throws, so a feedback bug can never turn a tool result into
|
|
533
|
+
// an isError (postExecute wraps a throwing listener as failure).
|
|
296
534
|
disposers.push(
|
|
297
|
-
ctx.on("
|
|
535
|
+
ctx.on("tools/post-execute", (exec, _result, next) => {
|
|
298
536
|
try {
|
|
299
|
-
const
|
|
300
|
-
if (!
|
|
301
|
-
|
|
537
|
+
const name = exec?.name;
|
|
538
|
+
if (!name) return next();
|
|
539
|
+
|
|
540
|
+
// P2-2 toonify (opt-in, default off): compact long JSON text blocks in
|
|
541
|
+
// the result (drop empty fields + truncate long strings). Only fires
|
|
542
|
+
// when the text block is >2000 chars AND parses as JSON; any failure
|
|
543
|
+
// leaves the original block untouched — a compaction bug can never
|
|
544
|
+
// corrupt a tool result.
|
|
545
|
+
if (cfg.toonifyResults && _result && Array.isArray(_result.content)) {
|
|
546
|
+
_result.content = _result.content.map((block) => {
|
|
547
|
+
if (
|
|
548
|
+
block?.type !== "text" ||
|
|
549
|
+
typeof block.text !== "string" ||
|
|
550
|
+
block.text.length <= 2000
|
|
551
|
+
) {
|
|
552
|
+
return block;
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
const v = JSON.parse(block.text);
|
|
556
|
+
const compact = JSON.stringify(toonifyValue(v), null, 2);
|
|
557
|
+
return compact.length < block.text.length ? { ...block, text: compact } : block;
|
|
558
|
+
} catch {
|
|
559
|
+
return block;
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
}
|
|
302
563
|
|
|
303
564
|
// Heat counting (L4 feedback).
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
565
|
+
feedback.calls[name] = (feedback.calls[name] || 0) + 1;
|
|
566
|
+
|
|
567
|
+
// Firewall: record the call sequence and flag exfil chains.
|
|
568
|
+
if (cfg.firewallEnabled !== false) {
|
|
569
|
+
chain.record(name, execArgumentsText(exec?.arguments));
|
|
570
|
+
const exfil = chain.checkChain();
|
|
571
|
+
if (exfil) {
|
|
572
|
+
logger.warn(
|
|
573
|
+
"[firewall] %s: %s",
|
|
574
|
+
exfil.label, exfil.chain.join(" → "),
|
|
575
|
+
);
|
|
576
|
+
feedback.firewall = feedback.firewall || [];
|
|
577
|
+
feedback.firewall.push({ ts: Date.now(), ...exfil });
|
|
578
|
+
if (feedback.firewall.length > 20) {
|
|
579
|
+
feedback.firewall.splice(0, feedback.firewall.length - 20);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
308
582
|
}
|
|
309
583
|
|
|
310
584
|
// Folded-call detection: did the model call something we hid?
|
|
311
|
-
const cov = selectionCoverage(lastVisible.names,
|
|
585
|
+
const cov = selectionCoverage(lastVisible.names, [name]);
|
|
312
586
|
if (cov.uncovered.length > 0) {
|
|
313
587
|
logger.warn(
|
|
314
588
|
"folded tools called (coverage=%.2f): %s — promoting via heat",
|
|
@@ -321,17 +595,47 @@ export function apply(ctx, config) {
|
|
|
321
595
|
ts: Date.now(),
|
|
322
596
|
agent: lastVisible.agentId,
|
|
323
597
|
visible: lastVisible.names,
|
|
324
|
-
calls:
|
|
598
|
+
calls: [name],
|
|
325
599
|
coverage: cov.coverage,
|
|
326
600
|
uncovered: cov.uncovered,
|
|
327
601
|
});
|
|
328
602
|
if (feedback.sessions.length > 50) feedback.sessions.splice(0, feedback.sessions.length - 50);
|
|
329
603
|
|
|
330
|
-
|
|
604
|
+
persistFeedback(fbPath, feedback, logger);
|
|
331
605
|
} catch (e) {
|
|
332
|
-
logger.warn("
|
|
606
|
+
logger.warn("post-execute hook error (%s) — best-effort", e?.message);
|
|
333
607
|
}
|
|
334
|
-
return next
|
|
608
|
+
return next();
|
|
609
|
+
}),
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
// Firewall hard-block + deny config: refuse execution at tools/pre-execute.
|
|
613
|
+
// Host contract (dsh-tools prepareExecution): the waterfall's gate is the
|
|
614
|
+
// last value; return { kind: "deny", reason } to refuse, or next() to allow.
|
|
615
|
+
// exec carries { callId, name, arguments, agent, signal } — no .deny().
|
|
616
|
+
disposers.push(
|
|
617
|
+
ctx.on("tools/pre-execute", (exec, next) => {
|
|
618
|
+
try {
|
|
619
|
+
const name = exec?.name;
|
|
620
|
+
|
|
621
|
+
// deny config: refuse execution regardless of firewallEnabled.
|
|
622
|
+
if (isDenied(name)) {
|
|
623
|
+
logger.warn("[deny] tool disabled by config: %s", name);
|
|
624
|
+
return { kind: "deny", reason: `[tool-folder] 工具 ${name} 已被禁用(deny 配置)。` };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (cfg.firewallEnabled !== false) {
|
|
628
|
+
const argvText = execArgumentsText(exec?.arguments);
|
|
629
|
+
const v = verdict(argvText);
|
|
630
|
+
if (v?.blocked) {
|
|
631
|
+
logger.warn("[firewall] BLOCKED %s: %s", name, v.label);
|
|
632
|
+
return { kind: "deny", reason: `[tool-folder firewall] 高危命令已拦截(${v.label})。如需执行请人工确认。` };
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
} catch (e) {
|
|
636
|
+
logger.warn("pre-execute hook error (%s) — best-effort", e?.message);
|
|
637
|
+
}
|
|
638
|
+
return next();
|
|
335
639
|
}),
|
|
336
640
|
);
|
|
337
641
|
|
package/lib/metrics.js
CHANGED
|
@@ -16,6 +16,16 @@ function norm(s) {
|
|
|
16
16
|
return String(s || "").toLowerCase().replace(/[?#].*$/, "");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Round to 3 decimals and normalize -0 to +0. DSH's lossless JSON validation
|
|
21
|
+
* rejects -0 (a value that survives JSON round-trips as "0" but fails a
|
|
22
|
+
* strict Object.is check), so every public metric must never emit -0.
|
|
23
|
+
*/
|
|
24
|
+
function round3(x) {
|
|
25
|
+
const r = Math.round(x * 1000) / 1000;
|
|
26
|
+
return r === 0 ? 0 : r;
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
export function retrievalMetrics(relevant, retrieved, k = null) {
|
|
20
30
|
const top = k ? retrieved.slice(0, k) : retrieved;
|
|
21
31
|
const rel = relevant.map(norm).filter(Boolean);
|
|
@@ -31,10 +41,10 @@ export function retrievalMetrics(relevant, retrieved, k = null) {
|
|
|
31
41
|
const recall = tp / rel.length;
|
|
32
42
|
const f1 = precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : 0;
|
|
33
43
|
return {
|
|
34
|
-
precision:
|
|
35
|
-
recall:
|
|
44
|
+
precision: round3(precision),
|
|
45
|
+
recall: round3(recall),
|
|
36
46
|
hit: tp > 0,
|
|
37
|
-
f1:
|
|
47
|
+
f1: round3(f1),
|
|
38
48
|
n: top.length,
|
|
39
49
|
};
|
|
40
50
|
}
|
|
@@ -45,7 +55,7 @@ export function selectionCoverage(visibleNames, calledNames) {
|
|
|
45
55
|
if (calls.length === 0) return { coverage: 1, uncovered: [] };
|
|
46
56
|
const uncovered = calls.filter((c) => !vis.has(c));
|
|
47
57
|
return {
|
|
48
|
-
coverage:
|
|
58
|
+
coverage: round3((calls.length - uncovered.length) / calls.length),
|
|
49
59
|
uncovered,
|
|
50
60
|
};
|
|
51
61
|
}
|
package/lib/schema.js
CHANGED
|
@@ -1,38 +1,129 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* schema.js — L1
|
|
2
|
+
* schema.js — L1 schema compression + description normalization.
|
|
3
3
|
*
|
|
4
4
|
* DO NOT deduplicate with JSON $ref: OpenAI/ARK function-calling providers do
|
|
5
5
|
* not reliably resolve $ref in tool parameters, so a broken schema would
|
|
6
6
|
* silently kill a tool. Instead we trim what is provably redundant:
|
|
7
|
-
* - tool description beyond `descLimit`
|
|
8
|
-
* - parameter description beyond `paramDescLimit`
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* - tool description beyond the level's `descLimit`
|
|
8
|
+
* - parameter description beyond the level's `paramDescLimit`
|
|
9
|
+
* - `aggressive` additionally drops OPTIONAL parameters — properties are
|
|
10
|
+
* rebuilt from the required list, so `required ⊆ properties` holds
|
|
11
|
+
* constructively (host contract C8: providers reject a schema whose
|
|
12
|
+
* required names are not all declared in properties).
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
* and left untouched. Off by default (`
|
|
14
|
+
* Compression applies ONLY to dynamically-loaded tools; core tools are
|
|
15
|
+
* operator-chosen and left untouched. Off by default (`compressLevel: "off"`).
|
|
16
|
+
*
|
|
17
|
+
* normalizeDescription() sanitizes injection markers, normalizes whitespace,
|
|
18
|
+
* keeps the first sentence, and truncates at 300 chars. It also applies only
|
|
19
|
+
* to the dynamic segment (core/hot descriptions are never rewritten).
|
|
14
20
|
*/
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
const LEVELS = {
|
|
23
|
+
light: { descLimit: 500, paramDescLimit: Infinity, dropOptional: false },
|
|
24
|
+
standard: { descLimit: 200, paramDescLimit: 120, dropOptional: false },
|
|
25
|
+
aggressive: { descLimit: 120, paramDescLimit: 60, dropOptional: true },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compress one tool's schema at a given level.
|
|
30
|
+
* @param {object} tool tool definition {name, description, parameters, ...}
|
|
31
|
+
* @param {{level?: "off"|"light"|"standard"|"aggressive"}} [opts]
|
|
32
|
+
* @returns {object} a shallow copy of the tool; the original is never mutated.
|
|
33
|
+
*/
|
|
34
|
+
export function compressTool(tool, { level = "off" } = {}) {
|
|
35
|
+
if (level === "off") return tool;
|
|
36
|
+
const t = LEVELS[level] ?? LEVELS.standard;
|
|
19
37
|
const out = { ...tool };
|
|
20
38
|
|
|
21
|
-
if (typeof out.description === "string" && out.description.length > descLimit) {
|
|
22
|
-
out.description = out.description.slice(0, descLimit) + " …";
|
|
39
|
+
if (typeof out.description === "string" && out.description.length > t.descLimit) {
|
|
40
|
+
out.description = out.description.slice(0, t.descLimit) + " …";
|
|
23
41
|
}
|
|
24
42
|
|
|
25
43
|
const params = out.parameters;
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
44
|
+
if (
|
|
45
|
+
!params ||
|
|
46
|
+
typeof params !== "object" ||
|
|
47
|
+
!params.properties ||
|
|
48
|
+
typeof params.properties !== "object"
|
|
49
|
+
) {
|
|
50
|
+
// Non object-root (array-root / no properties): trim the description only.
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const required = Array.isArray(params.required)
|
|
55
|
+
? params.required.filter((n) => typeof n === "string")
|
|
56
|
+
: [];
|
|
57
|
+
// Constructive guarantee (C8): properties are rebuilt from the required list
|
|
58
|
+
// under `dropOptional`, so `required ⊆ properties` holds by construction.
|
|
59
|
+
// Required properties are NEVER dropped (missing required → ToolArgsError).
|
|
60
|
+
const keep = new Set(t.dropOptional ? required : Object.keys(params.properties));
|
|
61
|
+
const props = {};
|
|
62
|
+
for (const [key, raw] of Object.entries(params.properties)) {
|
|
63
|
+
if (!keep.has(key)) continue;
|
|
64
|
+
const p = { ...raw }; // never touch $ref/oneOf/items/properties deep structure
|
|
65
|
+
if (typeof p.description === "string" && p.description.length > t.paramDescLimit) {
|
|
66
|
+
p.description = p.description.slice(0, t.paramDescLimit) + " …";
|
|
34
67
|
}
|
|
35
|
-
|
|
68
|
+
props[key] = p;
|
|
69
|
+
}
|
|
70
|
+
out.parameters = { ...params, properties: props };
|
|
71
|
+
if (t.dropOptional) {
|
|
72
|
+
// C3d / C8: malformed inputs may list `required` names that name no
|
|
73
|
+
// property at all (JSON Schema allows it, e.g. required:["ghost"] with
|
|
74
|
+
// properties:{real:{...}}). After the aggressive rebuild those names are
|
|
75
|
+
// gone from properties, so an unfiltered required would violate
|
|
76
|
+
// `required ⊆ properties` and providers (ARK/OpenAI) could reject the
|
|
77
|
+
// tool. Filter the emitted required list down to the kept property names
|
|
78
|
+
// so the guarantee holds for ANY input, well-formed or not.
|
|
79
|
+
out.parameters.required = required.filter((n) => Object.hasOwn(props, n));
|
|
36
80
|
}
|
|
37
81
|
return out;
|
|
38
82
|
}
|
|
83
|
+
|
|
84
|
+
/** Injection / prompt-leak markers removed from tool descriptions. */
|
|
85
|
+
const INJECTION_MARKERS = [
|
|
86
|
+
/\bignore\s+(all\s+)?(previous|prior|above)\b/i,
|
|
87
|
+
/\bsystem\s*:/,
|
|
88
|
+
/\[\s*\/?\s*system\s*\]/i,
|
|
89
|
+
/^\s*(你|you)\s*(现在|现在开始|are)\b/i,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Normalize a tool description: strip injection markers, collapse whitespace,
|
|
94
|
+
* keep the first sentence, truncate at 300 chars.
|
|
95
|
+
* @param {string} desc
|
|
96
|
+
* @param {{firstSentence?: boolean}} [opts]
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function normalizeDescription(desc, { firstSentence = true } = {}) {
|
|
100
|
+
let s = String(desc || "");
|
|
101
|
+
for (const re of INJECTION_MARKERS) s = s.replace(re, " "); // injection sanitize
|
|
102
|
+
s = s.replace(/\s+/g, " ").replace(/[。..!!]{2,}/g, "。").trim();
|
|
103
|
+
if (firstSentence) {
|
|
104
|
+
const m = s.match(/^.{1,120}?[。.!?;;]/);
|
|
105
|
+
if (m) s = m[0];
|
|
106
|
+
}
|
|
107
|
+
return s.length > 300 ? s.slice(0, 300) + " …" : s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Deep-sanitize a value into lossless JSON (host contract C4: -0/NaN/Infinity
|
|
112
|
+
* are rejected by the host's lossless JSON validation). Numbers are mapped to
|
|
113
|
+
* a finite non-negative-zero value; plain objects/arrays are walked; strings,
|
|
114
|
+
* booleans, null pass through untouched.
|
|
115
|
+
* @param {unknown} v
|
|
116
|
+
* @returns {unknown} a plain JSON-safe projection
|
|
117
|
+
*/
|
|
118
|
+
export function sanitizeLossless(v) {
|
|
119
|
+
if (typeof v === "number") {
|
|
120
|
+
return Number.isFinite(v) && !Object.is(v, -0) ? v : 0;
|
|
121
|
+
}
|
|
122
|
+
if (Array.isArray(v)) return v.map(sanitizeLossless);
|
|
123
|
+
if (v && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype) {
|
|
124
|
+
const out = {};
|
|
125
|
+
for (const k of Object.keys(v)) out[k] = sanitizeLossless(v[k]);
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
return v; // string / boolean / null / undefined (undefined never enters a plain object walk)
|
|
129
|
+
}
|
package/lib/toonify.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* toonify.js — P2-2 long JSON result compaction (opt-in, default off).
|
|
3
|
+
*
|
|
4
|
+
* Recursively drops empty fields (null/undefined/""/[]/{}) and truncates
|
|
5
|
+
* strings longer than `maxStr`. Keys are NEVER shortened — renaming keys
|
|
6
|
+
* would break consumer semantics, so the default behavior only shrinks
|
|
7
|
+
* values. Output is built from plain literals, so it stays lossless JSON
|
|
8
|
+
* (no -0 / NaN / sparse arrays, host contract C4).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compact a JSON value: drop empty fields, truncate long strings.
|
|
13
|
+
* @param {unknown} value
|
|
14
|
+
* @param {{maxStr?: number}} [opts]
|
|
15
|
+
* @returns {unknown} `undefined` signals "drop this field"; otherwise a
|
|
16
|
+
* lossless-JSON-safe projection.
|
|
17
|
+
*/
|
|
18
|
+
export function toonifyValue(value, { maxStr = 200 } = {}) {
|
|
19
|
+
if (value === null || value === undefined) return undefined; // drop empty
|
|
20
|
+
if (typeof value === "string") {
|
|
21
|
+
const s = value.trim();
|
|
22
|
+
return s.length === 0 ? undefined : s.length > maxStr ? s.slice(0, maxStr) + "…" : s;
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
const out = value.map((v) => toonifyValue(v, { maxStr })).filter((v) => v !== undefined);
|
|
26
|
+
return out.length === 0 ? undefined : out;
|
|
27
|
+
}
|
|
28
|
+
if (typeof value === "object") {
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const k of Object.keys(value)) {
|
|
31
|
+
const v = toonifyValue(value[k], { maxStr });
|
|
32
|
+
if (v !== undefined) out[k] = v;
|
|
33
|
+
}
|
|
34
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
35
|
+
}
|
|
36
|
+
return value; // number / boolean unchanged (no -0 arithmetic here)
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tool-folder",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Fold the DSH tool surface per request
|
|
3
|
+
"version": "0.1.6",
|
|
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
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
7
7
|
"dsh",
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"engines": {
|
|
28
28
|
"node": ">=18"
|
|
29
29
|
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"zod": "^4.4.3",
|
|
32
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
33
|
+
},
|
|
30
34
|
"peerDependencies": {
|
|
31
35
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
32
36
|
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
|