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 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 # 52 checks: contract, runtime discovery, tools, gate, knowledge base, observability
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 # 18 checks: publish manifest and the zero-dependency constraint
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 # 52 项:契约、运行时探测、三工具、门禁、知识库、观测
116
+ node smoke.mjs # 66 项:契约、运行时探测、三工具、门禁(分段匹配 + 前缀/路径归一 + script_path 覆盖)、资源释放、知识库、观测、版本自述
117
117
  node test-client.mjs # 27 项:浏览器半体契约与双语字典一致性
118
- node test-package.mjs # 18 项:发布清单与零依赖约束
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
- function floodReason(toolName, args) {
279
- const command = typeof args.command === 'string' ? args.command : ''
280
- if (!SHELL_TOOL_NAMES.has(toolName)) return undefined
281
- for (const word of FLOOD_TOOLS) {
282
- if (new RegExp('(^|[ \\t;&|(])' + word + '([ \\t;&|)]|$)', 'i').test(command)) {
283
- return 'dsh-context-toolkit: "' + word + '" pours whole responses into the transcript. '
284
- + 'Use ctk_fetch_and_index(url, source) and then ctk_search(queries), or run it inside '
285
- + 'ctk_execute(language:"javascript", code) and print only the conclusion.'
286
- }
287
- }
288
- 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)
289
322
  }
290
323
 
291
- /** 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
+ */
292
344
  export function floodToolName(command) {
293
- const text = String(command || '')
294
- for (const word of FLOOD_TOOLS) {
295
- 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)
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
- 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
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 = typeof args.command === 'string' ? args.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', '0.1.0')
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.0",
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",