dsh-context-toolkit 0.2.1 → 0.2.3
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 +3 -2
- package/README.zh.md +3 -2
- package/index.js +120 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -113,10 +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 # 68 checks: contract, runtime discovery, tools, gate (segment matching, prefix/path normalisation, script_path coverage and relative-path alignment), resource disposal, knowledge base, observability, version reporting
|
|
117
117
|
node test-client.mjs # 27 checks: browser-half contract and bilingual dictionary parity
|
|
118
118
|
node test-package.mjs # 20 checks: publish manifest and the zero-dependency constraint
|
|
119
|
-
node release-check.mjs #
|
|
119
|
+
node release-check.mjs # 27 checks: release readiness (manifest, deps, tool count, gate matching, version, SHA coverage, no hardcoded paths)
|
|
120
120
|
```
|
|
121
121
|
|
|
122
122
|
## Known limits
|
|
@@ -125,6 +125,7 @@ node release-check.mjs # 24 checks: release readiness (manifest, deps, tool coun
|
|
|
125
125
|
- **`ctk_execute` timeouts are hard kills**: the child is terminated and `timedOut` is reported.
|
|
126
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.
|
|
127
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.
|
|
128
129
|
|
|
129
130
|
## License
|
|
130
131
|
|
package/README.zh.md
CHANGED
|
@@ -113,10 +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 # 68 项:契约、运行时探测、三工具、门禁(分段匹配 + 前缀/路径归一 + script_path 覆盖与相对路径对齐)、资源释放、知识库、观测、版本自述
|
|
117
117
|
node test-client.mjs # 27 项:浏览器半体契约与双语字典一致性
|
|
118
118
|
node test-package.mjs # 20 项:发布清单与零依赖约束
|
|
119
|
-
node release-check.mjs #
|
|
119
|
+
node release-check.mjs # 27 项:发布就绪(清单/依赖/工具数/门禁匹配/版本/SHA 覆盖/无硬编码路径)
|
|
120
120
|
```
|
|
121
121
|
|
|
122
122
|
## 已知边界
|
|
@@ -125,6 +125,7 @@ node release-check.mjs # 24 项:发布就绪(清单/依赖/工具数/门禁/
|
|
|
125
125
|
- **`ctk_execute` 的超时是硬杀**:超时会终止子进程并把 `timedOut` 标出来。
|
|
126
126
|
- **单次捕获上限 256 KB**,超出部分只计数不保留;回给对话的文本另有 4000 字符硬帽,因此 1 MB 的单行输出也不会整段进入上下文。
|
|
127
127
|
- **知识库按路径去重**:同一路径再次归档是替换,不是追加。
|
|
128
|
+
- **门禁精确而非穷尽**:只有当洪水工具是某个分段的「命令词」时才拒绝(会先剥掉 `sudo`/`exec` 等前缀、归一化路径与 `.exe` 等扩展名),`script_path` 最多读取前 256 KB。程序内部发起的调用(如 `python -c 'import wget'`)不在其职责内。
|
|
128
129
|
|
|
129
130
|
## 许可
|
|
130
131
|
|
package/index.js
CHANGED
|
@@ -296,28 +296,122 @@ const FLOOD_TOOLS = new Set(['curl', 'wget', 'httpie', 'http', 'aria2c'])
|
|
|
296
296
|
*/
|
|
297
297
|
const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh', 'shell', 'ops_bash', 'bash_persistent', 'pwsh_persistent'])
|
|
298
298
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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)
|
|
310
322
|
}
|
|
311
323
|
|
|
312
|
-
/** 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
|
+
*/
|
|
313
344
|
export function floodToolName(command) {
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
if (
|
|
345
|
+
for (const segment of commandSegments(command)) {
|
|
346
|
+
const word = leadingWord(segment)
|
|
347
|
+
if (word && FLOOD_TOOLS.has(floodNameOf(word))) return floodNameOf(word)
|
|
317
348
|
}
|
|
318
349
|
return 'other'
|
|
319
350
|
}
|
|
320
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, exec) {
|
|
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
|
+
const path = resolveScriptPath(args.script_path, args.cwd, exec)
|
|
373
|
+
try {
|
|
374
|
+
const st = statSync(path)
|
|
375
|
+
if (st.isFile()) parts.push(readFileSync(path, 'utf8').slice(0, SCRIPT_READ_MAX))
|
|
376
|
+
} catch { /* an unreadable script is the shell tool's error to report, not the gate's */ }
|
|
377
|
+
}
|
|
378
|
+
return parts.join('\n')
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Where a relative script_path actually points.
|
|
383
|
+
*
|
|
384
|
+
* The host resolves a relative script against the *session* working directory, while
|
|
385
|
+
* statSync resolves it against this process's cwd — the install directory. The two
|
|
386
|
+
* disagree, so reading the raw string saw nothing for a relative script_path and waved
|
|
387
|
+
* the call through even though the host would run it happily. Aligning the base closes
|
|
388
|
+
* the last way a shell call can carry a payload the gate cannot see: whenever the host
|
|
389
|
+
* can run the file, the gate resolves the same file.
|
|
390
|
+
*/
|
|
391
|
+
function resolveScriptPath(requested, requestedCwd, exec) {
|
|
392
|
+
if (isAbsolute(requested)) return requested
|
|
393
|
+
let session = null
|
|
394
|
+
try {
|
|
395
|
+
session = exec && exec.agent && exec.agent.session && exec.agent.session.header
|
|
396
|
+
? exec.agent.session.header.cwd : null
|
|
397
|
+
} catch { /* no session on this exec: fall back to the process default */ }
|
|
398
|
+
const base = session || process.cwd()
|
|
399
|
+
const cwd = typeof requestedCwd === 'string' && requestedCwd.trim()
|
|
400
|
+
? (isAbsolute(requestedCwd) ? requestedCwd : resolve(base, requestedCwd))
|
|
401
|
+
: base
|
|
402
|
+
return resolve(cwd, requested)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function floodReason(toolName, args, text) {
|
|
406
|
+
if (!SHELL_TOOL_NAMES.has(toolName)) return undefined
|
|
407
|
+
const command = text === undefined ? shellTextOf(args) : text
|
|
408
|
+
const word = floodToolName(command)
|
|
409
|
+
if (word === 'other') return undefined
|
|
410
|
+
return 'dsh-context-toolkit: "' + word + '" pours whole responses into the transcript. '
|
|
411
|
+
+ 'Use ctk_fetch_and_index(url, source) and then ctk_search(queries), or run it inside '
|
|
412
|
+
+ 'ctk_execute(language:"javascript", code) and print only the conclusion.'
|
|
413
|
+
}
|
|
414
|
+
|
|
321
415
|
function hasService(ctx, serviceName) {
|
|
322
416
|
try { return ctx.get(serviceName) !== undefined } catch { return false }
|
|
323
417
|
}
|
|
@@ -336,18 +430,25 @@ function registerRoutingGate(ctx, config, stats) {
|
|
|
336
430
|
const toolName = exec && exec.name
|
|
337
431
|
const args = (exec && exec.arguments) || {}
|
|
338
432
|
|
|
433
|
+
// Read the call's text once and reuse it: a script can be 256 KB, and the deny
|
|
434
|
+
// path used to read it three times to answer three questions about one string.
|
|
435
|
+
const shellText = SHELL_TOOL_NAMES.has(toolName) ? shellTextOf(args, exec) : ''
|
|
436
|
+
|
|
339
437
|
if (config.denyCurlWget !== false) {
|
|
340
|
-
const reason = floodReason(toolName, args)
|
|
438
|
+
const reason = floodReason(toolName, args, shellText)
|
|
341
439
|
if (reason) {
|
|
342
440
|
stats.denied += 1
|
|
441
|
+
// The ledger counts what would have entered the conversation. A script file
|
|
442
|
+
// never does, so only the command line itself is measured here.
|
|
343
443
|
stats.deniedBytes += typeof args.command === 'string' ? args.command.length : 0
|
|
344
|
-
|
|
444
|
+
const denied = floodToolName(shellText)
|
|
445
|
+
stats.calls[denied] = (stats.calls[denied] || 0) + 1
|
|
345
446
|
return { kind: 'deny', reason }
|
|
346
447
|
}
|
|
347
448
|
}
|
|
348
449
|
|
|
349
450
|
if (SHELL_TOOL_NAMES.has(toolName) && config.bashNudgeMinCommandBytes > 0) {
|
|
350
|
-
const command =
|
|
451
|
+
const command = shellText
|
|
351
452
|
if (command.length > config.bashNudgeMinCommandBytes) {
|
|
352
453
|
const reason = 'dsh-context-toolkit: this command is about ' + command.length
|
|
353
454
|
+ ' bytes (threshold ' + config.bashNudgeMinCommandBytes + '). If you only need a count, a filter '
|
|
@@ -383,7 +484,7 @@ function registerRoutingGate(ctx, config, stats) {
|
|
|
383
484
|
})
|
|
384
485
|
|
|
385
486
|
if (config.denyCurlWget !== false && ctx.tools && typeof ctx.tools.guard === 'function') {
|
|
386
|
-
ctx.tools.guard((exec) =>
|
|
487
|
+
ctx.tools.guard((exec) => { const a = (exec && exec.arguments) || {}; return floodReason(exec && exec.name, a, shellTextOf(a, exec)) })
|
|
387
488
|
}
|
|
388
489
|
}
|
|
389
490
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-context-toolkit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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",
|