dsh-context-toolkit 0.2.1 → 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 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 # 58 checks: contract, runtime discovery, tools, gate, knowledge base, observability, version reporting
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
118
  node test-package.mjs # 20 checks: publish manifest and the zero-dependency constraint
119
- node release-check.mjs # 24 checks: release readiness (manifest, deps, tool count, gate, version, SHA coverage, no hardcoded paths)
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 # 58 项:契约、运行时探测、三工具、门禁、知识库、观测、版本自述
116
+ node smoke.mjs # 66 项:契约、运行时探测、三工具、门禁(分段匹配 + 前缀/路径归一 + script_path 覆盖)、资源释放、知识库、观测、版本自述
117
117
  node test-client.mjs # 27 项:浏览器半体契约与双语字典一致性
118
118
  node test-package.mjs # 20 项:发布清单与零依赖约束
119
- node release-check.mjs # 24 项:发布就绪(清单/依赖/工具数/门禁/版本/SHA 覆盖/无硬编码路径)
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,97 @@ 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
- function floodReason(toolName, args) {
300
- const command = typeof args.command === 'string' ? args.command : ''
301
- if (!SHELL_TOOL_NAMES.has(toolName)) return undefined
302
- for (const word of FLOOD_TOOLS) {
303
- if (new RegExp('(^|[ \\t;&|(])' + word + '([ \\t;&|)]|$)', 'i').test(command)) {
304
- return 'dsh-context-toolkit: "' + word + '" pours whole responses into the transcript. '
305
- + 'Use ctk_fetch_and_index(url, source) and then ctk_search(queries), or run it inside '
306
- + 'ctk_execute(language:"javascript", code) and print only the conclusion.'
307
- }
308
- }
309
- return undefined
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)
322
+ }
323
+
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, '')
310
330
  }
311
331
 
312
- /** The flood tool a command line invoked, for the ledger's per-tool counters. */
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 text = String(command || '')
315
- for (const word of FLOOD_TOOLS) {
316
- if (new RegExp('(^|[ \\t;&|(])' + word + '([ \\t;&|)]|$)', 'i').test(text)) return word
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) {
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
+
321
390
  function hasService(ctx, serviceName) {
322
391
  try { return ctx.get(serviceName) !== undefined } catch { return false }
323
392
  }
@@ -336,18 +405,25 @@ function registerRoutingGate(ctx, config, stats) {
336
405
  const toolName = exec && exec.name
337
406
  const args = (exec && exec.arguments) || {}
338
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
+
339
412
  if (config.denyCurlWget !== false) {
340
- const reason = floodReason(toolName, args)
413
+ const reason = floodReason(toolName, args, shellText)
341
414
  if (reason) {
342
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.
343
418
  stats.deniedBytes += typeof args.command === 'string' ? args.command.length : 0
344
- stats.calls[floodToolName(args.command || '')] = (stats.calls[floodToolName(args.command || '')] || 0) + 1
419
+ const denied = floodToolName(shellText)
420
+ stats.calls[denied] = (stats.calls[denied] || 0) + 1
345
421
  return { kind: 'deny', reason }
346
422
  }
347
423
  }
348
424
 
349
425
  if (SHELL_TOOL_NAMES.has(toolName) && config.bashNudgeMinCommandBytes > 0) {
350
- const command = typeof args.command === 'string' ? args.command : ''
426
+ const command = shellText
351
427
  if (command.length > config.bashNudgeMinCommandBytes) {
352
428
  const reason = 'dsh-context-toolkit: this command is about ' + command.length
353
429
  + ' bytes (threshold ' + config.bashNudgeMinCommandBytes + '). If you only need a count, a filter '
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-context-toolkit",
3
- "version": "0.2.1",
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",