@goodandready/dsh-clinebot 0.6.22 → 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,21 @@ 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
+
16
+ ## [0.6.23] - 2026-10-28
17
+
18
+ ### Fixed
19
+ - **Profile-file path guard** (#269): data paths must be `.json`, must stay inside `$DSH_HOME`, and cannot point at `profiles/**/package.json`, settings, credentials or TypeScript configs.
20
+ - **Explicit credential rollback failure** (#272): if settings persistence and credential rollback both fail, the API now reports `partial`/`credentialOrphan` with the env name and keeps the key available for cleanup/retry instead of hiding it.
21
+ - **Settings merge on account add** (#280): account additions use `settingsApi.update({ accounts })` and a fresh config read, preserving concurrent `timeoutMs` and other setting changes.
22
+
8
23
  ## [0.6.22] - 2026-10-28
9
24
 
10
25
  ### 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.22</b> hardens updater credentials, provider failover, concurrent account changes, price-catalog/path access, CSV export and stream limits. 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.22</b> ужесточает работу с credentials в updater, failover провайдера, конкурентные изменения аккаунтов, доступ к URL/путям, CSV-экспорт и лимиты потоков. Сохраняются автодобор каталога моделей/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.22</b> 加强更新器凭据隔离、提供方故障转移、并发账户变更、目录 URL/路径访问、CSV 导出和流限制。保留模型/免费/日期变体自动补齐、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,6 +29,13 @@ 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
- return resolved === home || resolved.startsWith(`${home}${path.sep}`)
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()
36
+ const segments = relative.split(/[\\/]+/)
37
+ const base = path.basename(realResolved).toLowerCase()
38
+ if (segments[0] === 'profiles' && !base.startsWith('clinebot-')) return false
39
+ if (path.extname(base) !== '.json') return false
40
+ return !['package.json', 'package-lock.json', 'tsconfig.json', 'settings.json', '.credentials.json'].includes(base)
17
41
  }
@@ -66,30 +66,46 @@ export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettin
66
66
  try {
67
67
  if (previous.source === 'credentials' && previous.value) {
68
68
  await saveCredentialKey(ctx, targetEnv, previous.value)
69
- } else {
70
- await deleteCredentialKey(ctx, targetEnv)
69
+ return true
71
70
  }
71
+ const result = await deleteCredentialKey(ctx, targetEnv)
72
+ return Boolean(result?.ok)
72
73
  } catch {
73
- /* best-effort rollback */
74
+ return false
74
75
  }
75
76
  }
76
77
 
77
78
  await saveCredentialKey(ctx, targetEnv, apiKey)
78
79
 
79
- const idx = existingAccounts.findIndex((a) => a && a.apiKeyEnv === targetEnv)
80
+ const fresh = plainConfig(live())
81
+ const freshAccounts = Array.isArray(fresh.accounts) ? [...fresh.accounts] : []
82
+ const idx = freshAccounts.findIndex((a) => a && a.apiKeyEnv === targetEnv)
80
83
  const accountEntry = { label: accountLabel, apiKeyEnv: targetEnv }
81
84
  if (idx >= 0) {
82
- existingAccounts[idx] = accountEntry
85
+ freshAccounts[idx] = accountEntry
83
86
  } else {
84
- existingAccounts.push(accountEntry)
87
+ freshAccounts.push(accountEntry)
85
88
  }
86
89
 
87
- const next = plainConfig(Config({ ...curr, accounts: existingAccounts }))
90
+ const next = plainConfig(Config({ ...fresh, accounts: freshAccounts }))
88
91
  const settingsApi = getSettingsApi()
89
92
  try {
90
- await settingsApi.replace(next)
93
+ if (typeof settingsApi.update === 'function') {
94
+ await settingsApi.update({ accounts: freshAccounts })
95
+ } else {
96
+ await settingsApi.replace(next)
97
+ }
91
98
  } catch (err) {
92
- await restoreCredential()
99
+ const restored = await restoreCredential()
100
+ if (!restored) {
101
+ pendingSecretCleanupAccounts.add(targetEnv)
102
+ const partial = new Error(`Account settings were not persisted and credential rollback failed for "${targetEnv}". Use account delete with secret cleanup to retry.`)
103
+ partial.status = 500
104
+ partial.partial = true
105
+ partial.credentialOrphan = true
106
+ partial.apiKeyEnv = targetEnv
107
+ throw partial
108
+ }
93
109
  throw err
94
110
  }
95
111
  await syncProviderState(next)
@@ -150,7 +166,13 @@ export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettin
150
166
  writeJson(res, 200, { ok: true, ...result })
151
167
  } catch (err) {
152
168
  const status = typeof err?.status === 'number' ? err.status : 500
153
- writeJson(res, status, { ok: false, error: String(err?.message || err) })
169
+ writeJson(res, status, {
170
+ ok: false,
171
+ error: String(err?.message || err),
172
+ ...(err?.partial ? { partial: true } : {}),
173
+ ...(err?.credentialOrphan ? { credentialOrphan: true } : {}),
174
+ ...(err?.apiKeyEnv ? { apiKeyEnv: err.apiKeyEnv } : {}),
175
+ })
154
176
  }
155
177
  },
156
178
  }), 'dsh-clinebot: /accounts')
@@ -394,7 +416,13 @@ export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettin
394
416
  writeJson(res, 200, { ok: true, ...result })
395
417
  } catch (err) {
396
418
  const status = typeof err?.status === 'number' ? err.status : 500
397
- writeJson(res, status, { ok: false, error: String(err?.message || err) })
419
+ writeJson(res, status, {
420
+ ok: false,
421
+ error: String(err?.message || err),
422
+ ...(err?.partial ? { partial: true } : {}),
423
+ ...(err?.credentialOrphan ? { credentialOrphan: true } : {}),
424
+ ...(err?.apiKeyEnv ? { apiKeyEnv: err.apiKeyEnv } : {}),
425
+ })
398
426
  }
399
427
  },
400
428
  }), 'dsh-clinebot: /keys/discovered/import')
@@ -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.22'
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.22",
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",