@goodandready/dsh-clinebot 0.6.23 → 0.6.24

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/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.24] - 2026-10-28
9
+
10
+ ### Security
11
+ - **Fail-closed source gate** (#250): without `connection.requestRejection`, remote requests are rejected regardless of spoofable `Origin`/`Referer`/`Sec-Fetch-Site`/`X-Forwarded-Host`; loopback remains allowed.
12
+ - **Allowlisted updater env** (#267): the child install process receives only safe OS/npm/DSH variables, not API keys, auth tokens, proxy credentials or other secrets.
13
+ - **Symlink-safe data paths** (#269): realpath/ancestor resolution rejects symlink escapes and profile/config targets.
14
+ - **Prototype-safe telemetry** (#281): stats and session dictionaries use null-prototype maps; `__proto__` session/model keys no longer mutate `Object.prototype`.
15
+
8
16
  ## [0.6.23] - 2026-10-28
9
17
 
10
18
  ### Fixed
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h3>Native ClineBot / ClinePass Provider Companion for DeepSeek Harness</h3>
6
6
 
7
- <p><b>0.6.23</b> closes residual path/rollback/settings-merge races: profile files cannot be targeted through data paths, orphan credentials are reported explicitly, and account additions merge with concurrent settings. It keeps automatic model/free/dated catalog backfill, DSH <code>0.2.1-rc.4</code>, model-scoped credit cooldowns and 402/413 errors. Chat runs on the native <code>ctx.llm</code> adapter; the legacy <code>llm-pi-ai</code>, loopback proxy and <code>llm/stream</code> path are removed.</p>
7
+ <p><b>0.6.24</b> hardens the source gate, updater environment, symlink-safe data paths and prototype-safe telemetry. It keeps automatic model/free/dated catalog backfill, DSH <code>0.2.1-rc.4</code>, model-scoped credit cooldowns and 402/413 errors. Chat runs on the native <code>ctx.llm</code> adapter; the legacy <code>llm-pi-ai</code>, loopback proxy and <code>llm/stream</code> path are removed.</p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
package/README.ru.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h3>Нативное подключение провайдера ClineBot / ClinePass для DeepSeek Harness</h3>
6
6
 
7
- <p><b>0.6.23</b> закрывает остаточные гонки путей, rollback и настроек: файлы профиля нельзя выбрать через data paths, orphan-credential явно репортится, а добавление аккаунта мержится с параллельными настройками. Сохраняются автодобор каталога моделей/FREE/dated, DSH <code>0.2.1-rc.4</code>, cooldown недостатка кредитов для конкретной модели и ошибки 402/413. Чат работает через нативный адаптер <code>ctx.llm</code>; старые <code>llm-pi-ai</code>, loopback-прокси и <code>llm/stream</code> удалены.</p>
7
+ <p><b>0.6.24</b> ужесточает source gate, окружение updater, symlink-безопасность data paths и защиту телеметрии от prototype pollution. Сохраняются автодобор каталога моделей/FREE/dated, DSH <code>0.2.1-rc.4</code>, cooldown недостатка кредитов для конкретной модели и ошибки 402/413. Чат работает через нативный адаптер <code>ctx.llm</code>; старые <code>llm-pi-ai</code>, loopback-прокси и <code>llm/stream</code> удалены.</p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
package/README.zh.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h3>适用于 DeepSeek Harness 的 ClineBot / ClinePass 原生模型提供商伴侣插件</h3>
6
6
 
7
- <p><b>0.6.23</b> 修复剩余的路径、回滚和设置合并竞态:数据路径不能指向配置文件,回滚失败会明确报告孤立凭据,添加账户会与并发设置合并。保留模型/免费/日期变体自动补齐、DSH <code>0.2.1-rc.4</code>、按模型隔离的余额不足冷却和明确的 402/413 错误。聊天使用原生 <code>ctx.llm</code> 适配器;旧的 <code>llm-pi-ai</code>、回环代理和 <code>llm/stream</code> 已删除。</p>
7
+ <p><b>0.6.24</b> 加固 source gate、更新器环境、符号链接安全的数据路径和原型污染安全的遥测。保留模型/免费/日期变体自动补齐、DSH <code>0.2.1-rc.4</code>、按模型隔离的余额不足冷却和明确的 402/413 错误。聊天使用原生 <code>ctx.llm</code> 适配器;旧的 <code>llm-pi-ai</code>、回环代理和 <code>llm/stream</code> 已删除。</p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
package/lib/http.js CHANGED
@@ -87,6 +87,11 @@ export function isTrustedSettingsRequest(request, ctx = null) {
87
87
  }
88
88
 
89
89
  const remoteLoopback = isLoopbackAddress(request?.socket?.remoteAddress)
90
+ const hasConnectionAuth = Boolean(conn && typeof conn.requestRejection === 'function')
91
+ // Without the host auth service, a remote request cannot prove it is a
92
+ // same-origin browser request: Origin/Referer/Sec-Fetch are caller-controlled.
93
+ if (!hasConnectionAuth && !remoteLoopback) return false
94
+
90
95
  // A forwarding header is only trusted when the actual peer is the local
91
96
  // reverse proxy; never trust it from a LAN/remote client.
92
97
  const forwardedHost = header(request, 'x-forwarded-host')
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs'
1
2
  import os from 'node:os'
2
3
  import path from 'node:path'
3
4
 
@@ -5,6 +6,22 @@ export function dshHome() {
5
6
  return path.resolve(process.env.DSH_HOME || path.join(process.env.HOME || os.homedir(), '.dsh'))
6
7
  }
7
8
 
9
+ function realPath(target) {
10
+ let current = target
11
+ const missing = []
12
+ while (!fs.existsSync(current)) {
13
+ const parent = path.dirname(current)
14
+ if (parent === current) break
15
+ missing.unshift(path.basename(current))
16
+ current = parent
17
+ }
18
+ try {
19
+ return path.join(fs.realpathSync(current), ...missing)
20
+ } catch {
21
+ return target
22
+ }
23
+ }
24
+
8
25
  export function isSafeDataPath(value) {
9
26
  const raw = String(value ?? '').trim()
10
27
  if (!raw) return true
@@ -12,11 +29,12 @@ export function isSafeDataPath(value) {
12
29
  const resolved = raw === '~' || raw.startsWith('~/')
13
30
  ? path.resolve(process.env.HOME || os.homedir(), raw.slice(1))
14
31
  : path.resolve(raw)
15
- const home = dshHome()
16
- if (resolved === home || !resolved.startsWith(`${home}${path.sep}`)) return false
17
- const relative = path.relative(home, resolved).toLowerCase()
32
+ const home = realPath(dshHome())
33
+ const realResolved = realPath(resolved)
34
+ if (realResolved === home || !realResolved.startsWith(`${home}${path.sep}`)) return false
35
+ const relative = path.relative(home, realResolved).toLowerCase()
18
36
  const segments = relative.split(/[\\/]+/)
19
- const base = path.basename(resolved).toLowerCase()
37
+ const base = path.basename(realResolved).toLowerCase()
20
38
  if (segments[0] === 'profiles' && !base.startsWith('clinebot-')) return false
21
39
  if (path.extname(base) !== '.json') return false
22
40
  return !['package.json', 'package-lock.json', 'tsconfig.json', 'settings.json', '.credentials.json'].includes(base)
@@ -0,0 +1,15 @@
1
+ export function safeDict(source) {
2
+ const out = Object.create(null)
3
+ if (source && typeof source === 'object') {
4
+ for (const key of Object.keys(source)) out[key] = source[key]
5
+ }
6
+ return out
7
+ }
8
+
9
+ export function safeNestedDict(source) {
10
+ const out = Object.create(null)
11
+ if (source && typeof source === 'object') {
12
+ for (const key of Object.keys(source)) out[key] = safeDict(source[key])
13
+ }
14
+ return out
15
+ }
@@ -15,6 +15,7 @@ import {
15
15
  pruneRetentionOnStats,
16
16
  } from './stats-metrics.js'
17
17
  import { csvCell } from './csv.js'
18
+ import { safeDict, safeNestedDict } from './safe-dict.js'
18
19
 
19
20
  const MAX_SESSIONS = 500
20
21
  const SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000
@@ -69,13 +70,13 @@ export function createEmptyStats() {
69
70
  createdAt: Date.now(),
70
71
  updatedAt: Date.now(),
71
72
  totals: emptyBucket(),
72
- byDay: {},
73
- byMonth: {},
74
- byModel: {},
75
- byAccount: {},
76
- bySession: {},
77
- byHour: {},
78
- byHourModel: {},
73
+ byDay: safeDict(),
74
+ byMonth: safeDict(),
75
+ byModel: safeDict(),
76
+ byAccount: safeDict(),
77
+ bySession: safeDict(),
78
+ byHour: safeDict(),
79
+ byHourModel: safeNestedDict(),
79
80
  }
80
81
  }
81
82
 
@@ -131,13 +132,13 @@ export function loadStats(customPath) {
131
132
  ...parsed,
132
133
  version: 2,
133
134
  totals: { ...empty.totals, ...parsed.totals },
134
- byDay: parsed.byDay || {},
135
- byMonth: parsed.byMonth || {},
136
- byModel: parsed.byModel || {},
137
- byAccount: parsed.byAccount || {},
138
- bySession: parsed.bySession || {},
139
- byHour: parsed.byHour || {},
140
- byHourModel: parsed.byHourModel || {},
135
+ byDay: safeDict(parsed.byDay),
136
+ byMonth: safeDict(parsed.byMonth),
137
+ byModel: safeDict(parsed.byModel),
138
+ byAccount: safeDict(parsed.byAccount),
139
+ bySession: safeDict(parsed.bySession),
140
+ byHour: safeDict(parsed.byHour),
141
+ byHourModel: safeNestedDict(parsed.byHourModel),
141
142
  }
142
143
  backfillHourlyFromSessions()
143
144
  pruneRetentionOnStats(inMemoryStats, statsRetentionDays)
@@ -237,7 +238,7 @@ function addToBucket(bucket, entry) {
237
238
  }
238
239
 
239
240
  function incrementBucket(container, key, entry) {
240
- if (!container[key]) container[key] = emptyBucket()
241
+ if (!Object.hasOwn(container, key)) container[key] = emptyBucket()
241
242
  addToBucket(container[key], entry)
242
243
  }
243
244
 
@@ -298,7 +299,7 @@ function backfillHourlyFromSessions() {
298
299
  if (bucket.model) hour.model = bucket.model
299
300
  if (bucket.accountId) hour.accountId = bucket.accountId
300
301
  const model = bucket.model || 'unknown'
301
- if (!inMemoryStats.byHourModel[key]) inMemoryStats.byHourModel[key] = {}
302
+ if (!Object.hasOwn(inMemoryStats.byHourModel, key)) inMemoryStats.byHourModel[key] = safeDict()
302
303
  inMemoryStats.byHourModel[key][model] = Number(inMemoryStats.byHourModel[key][model] || 0) + requests
303
304
  }
304
305
  }
@@ -359,7 +360,7 @@ export function recordUsage({
359
360
  const localNow = new Date(now)
360
361
  const hourKey = `${localDateKey(localNow)}T${String(localNow.getHours()).padStart(2, '0')}`
361
362
  incrementBucket(inMemoryStats.byHour, hourKey, entry)
362
- if (!inMemoryStats.byHourModel[hourKey]) inMemoryStats.byHourModel[hourKey] = {}
363
+ if (!Object.hasOwn(inMemoryStats.byHourModel, hourKey)) inMemoryStats.byHourModel[hourKey] = safeDict()
363
364
  inMemoryStats.byHourModel[hourKey][model] = Number(inMemoryStats.byHourModel[hourKey][model] || 0) + 1
364
365
  pruneHours(now)
365
366
  pruneRetentionOnStats(inMemoryStats, statsRetentionDays, now)
package/lib/telemetry.js CHANGED
@@ -2,6 +2,8 @@
2
2
  * In-memory runtime session metrics for ClinePass requests in DeepSeek Harness.
3
3
  */
4
4
 
5
+ import { safeDict } from './safe-dict.js'
6
+
5
7
  export const sessionStats = {
6
8
  totalRequests: 0,
7
9
  successfulRequests: 0,
@@ -15,7 +17,7 @@ export const sessionStats = {
15
17
  lastLatencyMs: null,
16
18
  lastRequestAt: null,
17
19
  lastError: null,
18
- byModel: {},
20
+ byModel: safeDict(),
19
21
  lastSmoke: null,
20
22
  }
21
23
 
@@ -49,8 +51,10 @@ export function recordSessionRequest({
49
51
  sessionStats.totalTokensEst += (Number(promptTokens) || 0) + (Number(completionTokens) || 0)
50
52
 
51
53
  if (model) {
52
- if (!sessionStats.byModel) sessionStats.byModel = {}
53
- const m = sessionStats.byModel[model] || { requests: 0, promptTokens: 0, completionTokens: 0, reasoningTokens: 0 }
54
+ if (!sessionStats.byModel) sessionStats.byModel = safeDict()
55
+ const m = Object.hasOwn(sessionStats.byModel, model)
56
+ ? sessionStats.byModel[model]
57
+ : { requests: 0, promptTokens: 0, completionTokens: 0, reasoningTokens: 0 }
54
58
  m.requests += 1
55
59
  m.promptTokens += Number(promptTokens) || 0
56
60
  m.completionTokens += Number(completionTokens) || 0
@@ -82,6 +86,6 @@ export function resetSessionStats() {
82
86
  sessionStats.lastLatencyMs = null
83
87
  sessionStats.lastRequestAt = null
84
88
  sessionStats.lastError = null
85
- sessionStats.byModel = {}
89
+ sessionStats.byModel = safeDict()
86
90
  sessionStats.lastSmoke = null
87
91
  }
@@ -9,7 +9,7 @@
9
9
  import { createRequire } from 'node:module'
10
10
 
11
11
  const require = createRequire(import.meta.url)
12
- let packageVersion = '0.6.23'
12
+ let packageVersion = '0.6.24'
13
13
  try {
14
14
  packageVersion = require('../../package.json').version || packageVersion
15
15
  } catch {
package/lib/updater.js CHANGED
@@ -14,16 +14,23 @@ import { fileURLToPath } from 'node:url'
14
14
  const UPDATE_HEADER = 'x-dsh-plugin-update'
15
15
  const UPDATE_TIMEOUT_MS = 10 * 60_000
16
16
  const VERSION_CACHE_MS = 5 * 60_000
17
- const SECRET_ENV_RE = /(?:^|_)(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIALS?)(?:_|$)/i
17
+ const SAFE_ENV_KEYS = new Set([
18
+ 'PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'windir', 'ComSpec', 'PATHEXT',
19
+ 'TEMP', 'TMP', 'TMPDIR', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TERM', 'SHELL', 'PWD',
20
+ 'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)',
21
+ 'PROGRAMW6432', 'SYSTEMDRIVE', 'NUMBER_OF_PROCESSORS', 'OS',
22
+ 'PROCESSOR_ARCHITECTURE', 'PROCESSOR_IDENTIFIER', 'USER', 'USERNAME',
23
+ 'LOGNAME', 'HOSTNAME', 'DSH_HOME', 'DSH_PROFILE_DIR', 'NODE_EXTRA_CA_CERTS',
24
+ ])
25
+ const SAFE_NPM_ENV_RE = /^npm_config_(?:registry|proxy|https_proxy|strict_ssl|userconfig|globalconfig|cache|prefix)$/i
18
26
  let latestCache = undefined
19
27
 
20
28
  export function sanitizeChildEnv(env = process.env) {
21
- const out = { ...env, NO_COLOR: '1' }
22
- for (const key of Object.keys(out)) {
23
- if (/^CLINEBOT_/i.test(key) || /^COMMANDCODE_/i.test(key) || SECRET_ENV_RE.test(key) || /AUTH[_-]?TOKEN/i.test(key)) {
24
- delete out[key]
25
- }
29
+ const out = {}
30
+ for (const [key, value] of Object.entries(env)) {
31
+ if (SAFE_ENV_KEYS.has(key) || SAFE_NPM_ENV_RE.test(key)) out[key] = value
26
32
  }
33
+ out.NO_COLOR = '1'
27
34
  return out
28
35
  }
29
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.6.23",
3
+ "version": "0.6.24",
4
4
  "description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
5
5
  "license": "MIT",
6
6
  "type": "module",