@goodandready/dsh-clinebot 0.6.26 → 0.6.27

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,16 @@ 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.27] - 2026-11-05
9
+
10
+ ### Security
11
+ - **Updater Windows environment (#289)**: the environment allowlist is case-insensitive, so Windows `Path` and `ProgramFiles(x86)` survive into the child installer.
12
+ - **Updater registry credentials (#290)**: npm registry/proxy URL userinfo is stripped before the child installer starts.
13
+ - **Complete write-path guard (#288)**: adapter telemetry and the models sync route no longer write stats or price caches outside `$DSH_HOME`; invalid paths fall back instead of being used.
14
+ - **Dangling symlink guard (#291)**: `isSafeDataPath()` rejects symlinks whose target does not exist, closing the remaining symlink escape from #269.
15
+ - **Native settings HTTPS gate (#292)**: remote chat and usage requests reject plain HTTP `baseUrl` even when the value came from native settings.
16
+ - **Credential name allowlist (#293)**: `apiKeyEnv`, account env names and credential resolution accept only `CLINEBOT_API_KEY` names; invalid names cannot read arbitrary environment credentials.
17
+
8
18
  ## [0.6.26] - 2026-11-05
9
19
 
10
20
  ### Security
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.26</b> hardens updater proxy credentials, native settings data paths and prototype-safe price/config dictionaries. It keeps DSH <code>0.2.2-rc.1</code> support, automatic model/free/dated catalog backfill, 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.27</b> completes the native-settings security boundary: HTTPS-only remote endpoints, allowlisted credential names, path-safe telemetry/price writes, dangling-symlink rejection and a Windows-safe updater environment. It keeps DSH <code>0.2.2-rc.1</code> support, automatic model/free/dated catalog backfill, 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.26</b> закрывает proxy credentials в updater, пути данных из native settings и prototype-safe словари цен/конфигурации. Сохраняются поддержка DSH <code>0.2.2-rc.1</code>, автодобор каталога моделей/FREE/dated, cooldown недостатка кредитов для конкретной модели и ошибки 402/413. Чат работает через нативный адаптер <code>ctx.llm</code>; старые <code>llm-pi-ai</code>, loopback-прокси и <code>llm/stream</code> удалены.</p>
7
+ <p><b>0.6.27</b> завершает security-границу native settings: HTTPS-only внешние endpoints, allowlist имён credentials, безопасные пути telemetry/price, отклонение dangling symlink и Windows-safe окружение updater. Сохраняются поддержка DSH <code>0.2.2-rc.1</code>, автодобор каталога моделей/FREE/dated, 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.26</b> 加固 updater 代理凭据、native settings 数据路径和原型安全的价目/配置字典。保留 DSH <code>0.2.2-rc.1</code> 支持、模型/免费/日期变体自动补齐、按模型隔离的余额不足冷却和明确的 402/413 错误。聊天使用原生 <code>ctx.llm</code> 适配器;旧的 <code>llm-pi-ai</code>、回环代理和 <code>llm/stream</code> 已删除。</p>
7
+ <p><b>0.6.27</b> 完善 native settings 安全边界:远程端点仅允许 HTTPS、凭据名称白名单、遥测/价格安全路径、拒绝悬空符号链接,以及 Windows 安全的 updater 环境。保留 DSH <code>0.2.2-rc.1</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>
@@ -1,6 +1,7 @@
1
1
  import { estimateCost } from './model-prices.js'
2
2
  import { recordSessionRequest } from './telemetry.js'
3
3
  import { recordUsage } from './stats-storage.js'
4
+ import { isSafeDataPath } from './path-safety.js'
4
5
 
5
6
  /**
6
7
  * Record one attempt's session and persistent telemetry.
@@ -59,7 +60,7 @@ export function recordAttemptTelemetry({
59
60
  totalTokens,
60
61
  isError,
61
62
  is429,
62
- statsPath: pub.statsPath,
63
+ statsPath: isSafeDataPath(pub.statsPath) ? pub.statsPath : undefined,
63
64
  sessionId: options.sessionId ? String(options.sessionId) : undefined,
64
65
  purpose: options.purpose,
65
66
  ttftMs: ttftMs ?? undefined,
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
+ import { isSecureRemoteBaseUrl } from './http.js'
2
3
  export {
3
4
  resolveApiKey,
4
5
  saveCredentialKey,
@@ -172,6 +173,9 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
172
173
  if (!apiKey) {
173
174
  return { ok: false, error: 'API key is missing' }
174
175
  }
176
+ if (!isSecureRemoteBaseUrl(base)) {
177
+ return { ok: false, error: 'Insecure protocol: remote usage requests require https:' }
178
+ }
175
179
 
176
180
  const cacheKey = buildUsageCacheKey(base, apiKey)
177
181
  const now = Date.now()
@@ -4,6 +4,16 @@
4
4
  */
5
5
 
6
6
  export const DEFAULT_API_KEY_ENV = 'CLINEBOT_API_KEY'
7
+ const API_KEY_ENV_RE = /^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/
8
+
9
+ export function isAllowedApiKeyEnv(value) {
10
+ return API_KEY_ENV_RE.test(String(value || '').trim())
11
+ }
12
+
13
+ function isAllowedCredentialDeletion(value) {
14
+ const name = String(value || '').trim()
15
+ return isAllowedApiKeyEnv(name) || name === 'CLINEBOT_LOCAL_PROXY_TOKEN'
16
+ }
7
17
 
8
18
  /**
9
19
  * Resolve the optional credentials service without tripping Cordis's
@@ -38,10 +48,11 @@ async function toCredentialRef(name) {
38
48
  }
39
49
 
40
50
  export function resolveApiKey(apiKeyEnv, env = process.env) {
41
- const name = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
51
+ const requested = String(apiKeyEnv || '').trim()
52
+ const name = requested || DEFAULT_API_KEY_ENV
42
53
  return {
43
54
  envName: name,
44
- value: String(env[name] || ''),
55
+ value: isAllowedApiKeyEnv(name) ? String(env[name] || '') : '',
45
56
  }
46
57
  }
47
58
 
@@ -49,6 +60,9 @@ export async function saveCredentialKey(ctx, apiKeyEnv, apiKey) {
49
60
  const name = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
50
61
  const value = String(apiKey || '').trim()
51
62
 
63
+ if (!isAllowedApiKeyEnv(name)) {
64
+ throw new Error('Disallowed apiKeyEnv: only CLINEBOT_API_KEY names are allowed')
65
+ }
52
66
  if (!value) {
53
67
  throw new Error('API key cannot be empty')
54
68
  }
@@ -65,7 +79,7 @@ export async function saveCredentialKey(ctx, apiKeyEnv, apiKey) {
65
79
 
66
80
  export async function deleteCredentialKey(ctx, apiKeyEnv) {
67
81
  const name = String(apiKeyEnv || '').trim()
68
- if (!name) return { ok: false }
82
+ if (!name || !isAllowedCredentialDeletion(name)) return { ok: false }
69
83
  const credentials = credentialsService(ctx)
70
84
  if (!credentials) return { ok: false }
71
85
  try {
@@ -81,7 +95,11 @@ export async function deleteCredentialKey(ctx, apiKeyEnv) {
81
95
  }
82
96
 
83
97
  export async function resolveKeyValue(ctx, apiKeyEnv) {
84
- const refName = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
98
+ const requested = String(apiKeyEnv || '').trim()
99
+ const refName = requested || DEFAULT_API_KEY_ENV
100
+ if (!isAllowedApiKeyEnv(refName)) {
101
+ return { envName: refName, value: '', source: 'invalid' }
102
+ }
85
103
  const creds = credentialsService(ctx)
86
104
  if (creds && typeof creds.resolve === 'function') {
87
105
  try {
package/lib/http.js CHANGED
@@ -53,6 +53,16 @@ function isLoopbackAddress(value) {
53
53
  || address.startsWith('::ffff:127.')
54
54
  }
55
55
 
56
+ export function isSecureRemoteBaseUrl(value) {
57
+ try {
58
+ const url = new URL(String(value || ''))
59
+ return url.protocol === 'https:'
60
+ || (url.protocol === 'http:' && isLoopbackAddress(url.hostname))
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
56
66
  export function getConnection(ctx) {
57
67
  try {
58
68
  const fromCtx = ctx?.reflect?.get
@@ -10,6 +10,11 @@ function realPath(target) {
10
10
  let current = target
11
11
  const missing = []
12
12
  while (!fs.existsSync(current)) {
13
+ try {
14
+ if (fs.lstatSync(current).isSymbolicLink()) return null
15
+ } catch {
16
+ /* path does not exist */
17
+ }
13
18
  const parent = path.dirname(current)
14
19
  if (parent === current) break
15
20
  missing.unshift(path.basename(current))
@@ -18,7 +23,7 @@ function realPath(target) {
18
23
  try {
19
24
  return path.join(fs.realpathSync(current), ...missing)
20
25
  } catch {
21
- return target
26
+ return null
22
27
  }
23
28
  }
24
29
 
@@ -31,6 +36,7 @@ export function isSafeDataPath(value) {
31
36
  : path.resolve(raw)
32
37
  const home = realPath(dshHome())
33
38
  const realResolved = realPath(resolved)
39
+ if (!home || !realResolved) return false
34
40
  if (realResolved === home || !realResolved.startsWith(`${home}${path.sep}`)) return false
35
41
  const relative = path.relative(home, realResolved).toLowerCase()
36
42
  const segments = relative.split(/[\\/]+/)
@@ -67,7 +67,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
67
67
  try {
68
68
  priceSync = await ensurePublicPricesForModels(addedModels.map((model) => model.id), {
69
69
  url: pub.priceCatalogUrl,
70
- cachePath: pub.priceCatalogPath,
70
+ cachePath: isSafeDataPath(pub.priceCatalogPath) ? pub.priceCatalogPath : undefined,
71
71
  enabled: pub.priceAutoSync !== false,
72
72
  })
73
73
  } catch {
@@ -7,9 +7,10 @@
7
7
  */
8
8
 
9
9
  import { createRequire } from 'node:module'
10
+ import { isSecureRemoteBaseUrl } from '../http.js'
10
11
 
11
12
  const require = createRequire(import.meta.url)
12
- let packageVersion = '0.6.26'
13
+ let packageVersion = '0.6.27'
13
14
  try {
14
15
  packageVersion = require('../../package.json').version || packageVersion
15
16
  } catch {
@@ -216,6 +217,9 @@ export async function requestChatCompletion({
216
217
  connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
217
218
  extraHeaders = {},
218
219
  }) {
220
+ if (!isSecureRemoteBaseUrl(baseUrl)) {
221
+ throw new Error('Insecure protocol: remote chat requests require https:')
222
+ }
219
223
  const connectCtrl = new AbortController()
220
224
  let timedOut = false
221
225
  const timer = setTimeout(() => {
package/lib/updater.js CHANGED
@@ -22,10 +22,12 @@ const SAFE_ENV_KEYS = new Set([
22
22
  'PROCESSOR_ARCHITECTURE', 'PROCESSOR_IDENTIFIER', 'USER', 'USERNAME',
23
23
  'LOGNAME', 'HOSTNAME', 'DSH_HOME', 'DSH_PROFILE_DIR', 'NODE_EXTRA_CA_CERTS',
24
24
  ])
25
+ const SAFE_ENV_KEYS_LOWER = new Set([...SAFE_ENV_KEYS].map((key) => key.toLowerCase()))
25
26
  const SAFE_NPM_ENV_RE = /^npm_config_(?:registry|proxy|https_proxy|strict_ssl|userconfig|globalconfig|cache|prefix)$/i
27
+ const NPM_URL_ENV_RE = /^npm_config_(?:registry|proxy|https_proxy)$/i
26
28
  let latestCache = undefined
27
29
 
28
- function sanitizeProxy(value) {
30
+ function sanitizeUrlCredentials(value) {
29
31
  try {
30
32
  const url = new URL(String(value))
31
33
  url.username = ''
@@ -39,9 +41,9 @@ function sanitizeProxy(value) {
39
41
  export function sanitizeChildEnv(env = process.env) {
40
42
  const out = {}
41
43
  for (const [key, value] of Object.entries(env)) {
42
- if (!SAFE_ENV_KEYS.has(key) && !SAFE_NPM_ENV_RE.test(key)) continue
43
- if (/proxy/i.test(key)) {
44
- const clean = sanitizeProxy(value)
44
+ if (!SAFE_ENV_KEYS_LOWER.has(key.toLowerCase()) && !SAFE_NPM_ENV_RE.test(key)) continue
45
+ if (NPM_URL_ENV_RE.test(key)) {
46
+ const clean = sanitizeUrlCredentials(value)
45
47
  if (clean) out[key] = clean
46
48
  } else {
47
49
  out[key] = value
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.6.26",
3
+ "version": "0.6.27",
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",