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 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 # 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 # 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 # 68 项:契约、运行时探测、三工具、门禁(分段匹配 + 前缀/路径归一 + 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,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
- 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)
310
322
  }
311
323
 
312
- /** The flood tool a command line invoked, for the ledger's per-tool counters. */
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 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, 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
- stats.calls[floodToolName(args.command || '')] = (stats.calls[floodToolName(args.command || '')] || 0) + 1
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 = typeof args.command === 'string' ? args.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) => floodReason(exec && exec.name, (exec && exec.arguments) || {}))
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.1",
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",