dsh-context-toolkit 0.2.0 → 0.2.2
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 +4 -2
- package/README.zh.md +4 -2
- package/index.js +118 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -113,9 +113,10 @@ Tool switches: `tools.execute`, `tools.executeFile`, `tools.batchExecute`, `tool
|
|
|
113
113
|
The offline suites need no DSH runtime:
|
|
114
114
|
|
|
115
115
|
```bash
|
|
116
|
-
node smoke.mjs #
|
|
116
|
+
node smoke.mjs # 66 checks: contract, runtime discovery, tools, gate (segment matching, prefix/path normalisation, script_path coverage), resource disposal, knowledge base, observability, version reporting
|
|
117
117
|
node test-client.mjs # 27 checks: browser-half contract and bilingual dictionary parity
|
|
118
|
-
node test-package.mjs #
|
|
118
|
+
node test-package.mjs # 20 checks: publish manifest and the zero-dependency constraint
|
|
119
|
+
node release-check.mjs # 27 checks: release readiness (manifest, deps, tool count, gate matching, version, SHA coverage, no hardcoded paths)
|
|
119
120
|
```
|
|
120
121
|
|
|
121
122
|
## Known limits
|
|
@@ -124,6 +125,7 @@ node test-package.mjs # 18 checks: publish manifest and the zero-dependency cons
|
|
|
124
125
|
- **`ctk_execute` timeouts are hard kills**: the child is terminated and `timedOut` is reported.
|
|
125
126
|
- **A single run captures at most 256 KB**; anything beyond is counted but not retained. The text handed back is additionally capped at 4000 characters, so even a 1 MB single-line output cannot enter the conversation whole.
|
|
126
127
|
- **The knowledge base deduplicates by path**: filing the same path again replaces the earlier copy.
|
|
128
|
+
- **The gate is precise, not exhaustive.** A flood tool is refused only when it is the command word of some segment — after exec prefixes (`sudo`, `exec`, …) and path/extension normalisation are stripped — and `script_path` is read up to the first 256 KB. A tool a program invokes internally (`python -c 'import wget'`) is outside its remit.
|
|
127
129
|
|
|
128
130
|
## License
|
|
129
131
|
|
package/README.zh.md
CHANGED
|
@@ -113,9 +113,10 @@ await ctk_search({ queries: ['rate limit', 'auth'] }); // 只取相关片段
|
|
|
113
113
|
离线套件不依赖 DSH 运行时:
|
|
114
114
|
|
|
115
115
|
```bash
|
|
116
|
-
node smoke.mjs #
|
|
116
|
+
node smoke.mjs # 66 项:契约、运行时探测、三工具、门禁(分段匹配 + 前缀/路径归一 + script_path 覆盖)、资源释放、知识库、观测、版本自述
|
|
117
117
|
node test-client.mjs # 27 项:浏览器半体契约与双语字典一致性
|
|
118
|
-
node test-package.mjs #
|
|
118
|
+
node test-package.mjs # 20 项:发布清单与零依赖约束
|
|
119
|
+
node release-check.mjs # 27 项:发布就绪(清单/依赖/工具数/门禁匹配/版本/SHA 覆盖/无硬编码路径)
|
|
119
120
|
```
|
|
120
121
|
|
|
121
122
|
## 已知边界
|
|
@@ -124,6 +125,7 @@ node test-package.mjs # 18 项:发布清单与零依赖约束
|
|
|
124
125
|
- **`ctk_execute` 的超时是硬杀**:超时会终止子进程并把 `timedOut` 标出来。
|
|
125
126
|
- **单次捕获上限 256 KB**,超出部分只计数不保留;回给对话的文本另有 4000 字符硬帽,因此 1 MB 的单行输出也不会整段进入上下文。
|
|
126
127
|
- **知识库按路径去重**:同一路径再次归档是替换,不是追加。
|
|
128
|
+
- **门禁精确而非穷尽**:只有当洪水工具是某个分段的「命令词」时才拒绝(会先剥掉 `sudo`/`exec` 等前缀、归一化路径与 `.exe` 等扩展名),`script_path` 最多读取前 256 KB。程序内部发起的调用(如 `python -c 'import wget'`)不在其职责内。
|
|
127
129
|
|
|
128
130
|
## 许可
|
|
129
131
|
|
package/index.js
CHANGED
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
* no third-party runtime dependencies.
|
|
19
19
|
*/
|
|
20
20
|
import { homedir } from 'node:os'
|
|
21
|
-
import { existsSync, statSync, readdirSync } from 'node:fs'
|
|
22
|
-
import { join, isAbsolute, resolve, basename } from 'node:path'
|
|
21
|
+
import { existsSync, statSync, readdirSync, readFileSync } from 'node:fs'
|
|
22
|
+
import { join, isAbsolute, resolve, basename, dirname } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
23
24
|
import { runProcess, availableRuntimes, probeRuntimes, shellPlan, scratchDir } from './lib/runner.js'
|
|
24
25
|
import { mkdtempSync, rmSync } from 'node:fs'
|
|
25
26
|
import { openStore } from './lib/store.js'
|
|
@@ -27,6 +28,26 @@ import { openStore } from './lib/store.js'
|
|
|
27
28
|
export const name = 'dsh-context-toolkit'
|
|
28
29
|
export const inject = ['tools', 'systemPrompt']
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The version this bundle actually ships, read from its own manifest.
|
|
33
|
+
*
|
|
34
|
+
* It used to be hardcoded and drifted: the manifest moved to 0.2.0 while the
|
|
35
|
+
* doctor still reported 0.1.0, so the one line meant to identify the build was
|
|
36
|
+
* the one line that could not be trusted. Reading the manifest keeps the two in
|
|
37
|
+
* step without a release step of its own.
|
|
38
|
+
*/
|
|
39
|
+
export const PLUGIN_VERSION = readManifestVersion()
|
|
40
|
+
|
|
41
|
+
/** Read the neighbouring package.json; a bundled copy still gets a usable value. */
|
|
42
|
+
export function readManifestVersion() {
|
|
43
|
+
try {
|
|
44
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
45
|
+
const parsed = JSON.parse(readFileSync(join(here, 'package.json'), 'utf8'))
|
|
46
|
+
if (parsed && typeof parsed.version === 'string' && parsed.version) return parsed.version
|
|
47
|
+
} catch { /* not resolvable from here: still report something the doctor can print */ }
|
|
48
|
+
return '0.0.0'
|
|
49
|
+
}
|
|
50
|
+
|
|
30
51
|
const z = await loadSchemastery()
|
|
31
52
|
|
|
32
53
|
/**
|
|
@@ -275,28 +296,97 @@ const FLOOD_TOOLS = new Set(['curl', 'wget', 'httpie', 'http', 'aria2c'])
|
|
|
275
296
|
*/
|
|
276
297
|
const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh', 'shell', 'ops_bash', 'bash_persistent', 'pwsh_persistent'])
|
|
277
298
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Words that may legally precede the real command word inside one segment.
|
|
301
|
+
*
|
|
302
|
+
* Without them `sudo curl …` reads as a command called "sudo" and sails through.
|
|
303
|
+
* The list stays short on purpose: anything longer turns into a shell parser, and a
|
|
304
|
+
* parser that gets it wrong either blocks an innocent call or waves a real one past.
|
|
305
|
+
*/
|
|
306
|
+
const EXEC_PREFIXES = new Set(['!', 'sudo', 'command', 'exec', 'nohup', 'time', 'env', 'busybox'])
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Split a command line at every operator that starts a new command.
|
|
310
|
+
*
|
|
311
|
+
* Testing the whole line for a flood tool failed in both directions: it missed a
|
|
312
|
+
* real invocation that was not the leading word statement (`cat big | curl -T -
|
|
313
|
+
* https://x`, `body=$(curl -s https://x)`), and every attempt to widen the pattern
|
|
314
|
+
* to catch those widened what innocent text could match. Splitting first makes the
|
|
315
|
+
* question exact — is a flood tool the *command word* of some segment?
|
|
316
|
+
*/
|
|
317
|
+
function commandSegments(command) {
|
|
318
|
+
return String(command || '')
|
|
319
|
+
.split(/\|\||&&|[|;&\n]|\$\(|`|\{|\}/)
|
|
320
|
+
.map((segment) => segment.trim())
|
|
321
|
+
.filter(Boolean)
|
|
289
322
|
}
|
|
290
323
|
|
|
291
|
-
/** The
|
|
324
|
+
/** The command word of a segment, with leading quotes and exec prefixes removed. */
|
|
325
|
+
function leadingWord(segment) {
|
|
326
|
+
const tokens = segment.split(/\s+/).filter(Boolean)
|
|
327
|
+
let i = 0
|
|
328
|
+
while (i < tokens.length && i < 2 && EXEC_PREFIXES.has(tokens[i].toLowerCase().replace(/^!+/, ''))) i += 1
|
|
329
|
+
return (tokens[i] || '').replace(/^[\"']+|[\"']+$/g, '')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Normalise a command word to the plain tool name (`C:\bin\curl.exe` -> `curl`). */
|
|
333
|
+
function floodNameOf(word) {
|
|
334
|
+
return baseNameOf(word).toLowerCase().replace(/\.(exe|cmd|bat|ps1)$/, '')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The flood tool a command line invokes, or 'other' when it invokes none.
|
|
339
|
+
*
|
|
340
|
+
* Only the command word of a segment counts, so `echo curl-config` and a message
|
|
341
|
+
* that merely contains "wget" stay legal, while `… | curl -s https://x` and
|
|
342
|
+
* `C:\Tools\curl.exe https://x` are refused.
|
|
343
|
+
*/
|
|
292
344
|
export function floodToolName(command) {
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
if (
|
|
345
|
+
for (const segment of commandSegments(command)) {
|
|
346
|
+
const word = leadingWord(segment)
|
|
347
|
+
if (word && FLOOD_TOOLS.has(floodNameOf(word))) return floodNameOf(word)
|
|
296
348
|
}
|
|
297
349
|
return 'other'
|
|
298
350
|
}
|
|
299
351
|
|
|
352
|
+
/**
|
|
353
|
+
* How much of a script file the gate is willing to read.
|
|
354
|
+
*
|
|
355
|
+
* A script is inspected to answer "what will run"; it is not the thing being judged,
|
|
356
|
+
* so the read is bounded rather than slurping a large file on every shell call.
|
|
357
|
+
*/
|
|
358
|
+
const SCRIPT_READ_MAX = 256 * 1024
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The text a shell call will actually run.
|
|
362
|
+
*
|
|
363
|
+
* `ops_bash` runs a *file* when it is given `script_path` instead of `command`, and a
|
|
364
|
+
* gate that reads only `command` therefore sees nothing at all for that call. That is
|
|
365
|
+
* the same bypass the shell-name list fixed, one field over: whichever field carries
|
|
366
|
+
* the payload, the gate has to follow it.
|
|
367
|
+
*/
|
|
368
|
+
function shellTextOf(args) {
|
|
369
|
+
const parts = []
|
|
370
|
+
if (typeof args.command === 'string') parts.push(args.command)
|
|
371
|
+
if (typeof args.script_path === 'string' && args.script_path.trim()) {
|
|
372
|
+
try {
|
|
373
|
+
const st = statSync(args.script_path)
|
|
374
|
+
if (st.isFile()) parts.push(readFileSync(args.script_path, 'utf8').slice(0, SCRIPT_READ_MAX))
|
|
375
|
+
} catch { /* an unreadable script is the shell tool's error to report, not the gate's */ }
|
|
376
|
+
}
|
|
377
|
+
return parts.join('\n')
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function floodReason(toolName, args, text) {
|
|
381
|
+
if (!SHELL_TOOL_NAMES.has(toolName)) return undefined
|
|
382
|
+
const command = text === undefined ? shellTextOf(args) : text
|
|
383
|
+
const word = floodToolName(command)
|
|
384
|
+
if (word === 'other') return undefined
|
|
385
|
+
return 'dsh-context-toolkit: "' + word + '" pours whole responses into the transcript. '
|
|
386
|
+
+ 'Use ctk_fetch_and_index(url, source) and then ctk_search(queries), or run it inside '
|
|
387
|
+
+ 'ctk_execute(language:"javascript", code) and print only the conclusion.'
|
|
388
|
+
}
|
|
389
|
+
|
|
300
390
|
function hasService(ctx, serviceName) {
|
|
301
391
|
try { return ctx.get(serviceName) !== undefined } catch { return false }
|
|
302
392
|
}
|
|
@@ -315,18 +405,25 @@ function registerRoutingGate(ctx, config, stats) {
|
|
|
315
405
|
const toolName = exec && exec.name
|
|
316
406
|
const args = (exec && exec.arguments) || {}
|
|
317
407
|
|
|
408
|
+
// Read the call's text once and reuse it: a script can be 256 KB, and the deny
|
|
409
|
+
// path used to read it three times to answer three questions about one string.
|
|
410
|
+
const shellText = SHELL_TOOL_NAMES.has(toolName) ? shellTextOf(args) : ''
|
|
411
|
+
|
|
318
412
|
if (config.denyCurlWget !== false) {
|
|
319
|
-
const reason = floodReason(toolName, args)
|
|
413
|
+
const reason = floodReason(toolName, args, shellText)
|
|
320
414
|
if (reason) {
|
|
321
415
|
stats.denied += 1
|
|
416
|
+
// The ledger counts what would have entered the conversation. A script file
|
|
417
|
+
// never does, so only the command line itself is measured here.
|
|
322
418
|
stats.deniedBytes += typeof args.command === 'string' ? args.command.length : 0
|
|
323
|
-
|
|
419
|
+
const denied = floodToolName(shellText)
|
|
420
|
+
stats.calls[denied] = (stats.calls[denied] || 0) + 1
|
|
324
421
|
return { kind: 'deny', reason }
|
|
325
422
|
}
|
|
326
423
|
}
|
|
327
424
|
|
|
328
425
|
if (SHELL_TOOL_NAMES.has(toolName) && config.bashNudgeMinCommandBytes > 0) {
|
|
329
|
-
const command =
|
|
426
|
+
const command = shellText
|
|
330
427
|
if (command.length > config.bashNudgeMinCommandBytes) {
|
|
331
428
|
const reason = 'dsh-context-toolkit: this command is about ' + command.length
|
|
332
429
|
+ ' bytes (threshold ' + config.bashNudgeMinCommandBytes + '). If you only need a count, a filter '
|
|
@@ -1223,7 +1320,7 @@ function registerObservabilityTools(ctx, config, stats, store, storageDir, log)
|
|
|
1223
1320
|
|
|
1224
1321
|
// storage
|
|
1225
1322
|
ok('存储目录 Storage', storageDir)
|
|
1226
|
-
ok('版本 Version',
|
|
1323
|
+
ok('版本 Version', PLUGIN_VERSION)
|
|
1227
1324
|
|
|
1228
1325
|
lines.push('')
|
|
1229
1326
|
const failed = lines.filter((l) => l.startsWith('[FAIL]')).length
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-context-toolkit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "DeepSeek Harness bundle: native ctk_* sandbox tools, a routing gate that keeps bulk output out of the transcript, and a local full-text knowledge base. No third-party runtime dependencies.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|