dsh-vaultwarden 0.8.8 → 0.8.9

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
@@ -4,6 +4,20 @@ All notable changes to `dsh-vaultwarden` are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.8.9] — 2026-10-19
8
+
9
+ ### 安全
10
+
11
+ - 修复「重新验证」授权过期后条目明文仍留在面板上的问题:窗口一失效,详情页立即收回为门禁态,并重新向宿主取一次答案。
12
+ - 修复「重新验证」授权过期后不再轮询倒计时的问题:解锁后的答案不含 `repromptRequired`,轮询因此提前停止,授权窗口永远到不了点。
13
+ - `bitwarden_get` 的 `field=all` 不再返回「隐藏」型自定义字段的值(占位 `<hidden>`),需要时用 `field=fields` 显式取;面板自身的读取不受影响。
14
+ - 检索结果不再回传排序分 `score`。
15
+ - 网络错误消息只报服务器 origin,不再回带完整接口路径。
16
+
17
+ ### 修复
18
+
19
+ - 落盘写入改为独占创建 + 随机临时名 + fsync + 目录 fsync:临时文件被预置为 0644 时权限不再被沿用,崩溃不再可能留下 0 字节记录。
20
+ - 修正面板自定义字段注释与宿主投影不符的问题。
7
21
  ## [0.8.8] — 2026-10-18
8
22
 
9
23
  ### 新增
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Private atomic file writes: the one place every on-disk record goes through.
3
+ *
4
+ * Why this exists
5
+ * ---------------
6
+ * `renameSync` already made readers safe — they see the old inode or the new
7
+ * one, never half a file. The writer side was the weak half, for three reasons
8
+ * 0.8.8 shipped with:
9
+ *
10
+ * 1. The temp name was `${path}.${pid}.tmp`, and `writeFileSync`'s `mode` is
11
+ * ignored for a file that already exists. A leftover 0644 temp file (from a
12
+ * crash, or from anything else that can write the directory) kept its 0644
13
+ * while the complete record — master key included — was written into it,
14
+ * until the following `chmodSync` narrowed it. An `wx` open refuses an
15
+ * existing path outright instead of reusing it, and a fresh inode takes the
16
+ * stated mode even under a permissive umask, so that window is gone.
17
+ * 2. worker_threads share one `process.pid`, so two threads could pick the
18
+ * same temp path. The suffix is random now.
19
+ * 3. A rename still in the page cache can land before its data, so a power cut
20
+ * could leave a zero-length target — which `load()` reads as "corrupt",
21
+ * deletes, and the operator is silently signed out. fsync before the rename
22
+ * (and on the directory after it) is what makes the rename a promise
23
+ * instead of a hope.
24
+ *
25
+ * Note on scope: this file makes a completed write atomic and durable. It does
26
+ * NOT serialize two writers. That is the remaining gap — `touchSession()` does
27
+ * a lock-free read-modify-write (`vault.js`), and two DSH instances sharing
28
+ * `~/.dsh` can resurrect a rotated refresh token over each other's session.
29
+ * Closing that needs a cross-process lock (`withFileLock` from
30
+ * `@deepseek-ai/dsh-atomic-write`); see `.research/atomic-audit.md`.
31
+ */
32
+ import { randomBytes } from 'node:crypto'
33
+ import { closeSync, fchmodSync, fsyncSync, mkdirSync, openSync, renameSync, rmSync, writeSync } from 'node:fs'
34
+ import { dirname } from 'node:path'
35
+
36
+ /** Default bits for a record that must stay readable only by its owner. */
37
+ const PRIVATE_MODE = 0o600
38
+
39
+ /** Create the parent tree privately. Existing directories keep their mode. */
40
+ export function ensurePrivateDir(path, dirMode = 0o700) {
41
+ mkdirSync(dirname(path), { recursive: true, mode: dirMode })
42
+ }
43
+
44
+ /**
45
+ * Replace `path` with `content` in one atomic, durable step: exclusive-create
46
+ * a random-suffix sibling, flush it, rename it over the target, then flush the
47
+ * directory entry.
48
+ *
49
+ * @param {string} path final path receiving the content
50
+ * @param {string|Buffer} content complete next content
51
+ * @param {{mode?: number}} [options] permission bits for the replacement inode
52
+ */
53
+ export function writeFilePrivate(path, content, options = {}) {
54
+ const mode = options.mode ?? PRIVATE_MODE
55
+ ensurePrivateDir(path)
56
+ const temporary = `${path}.${randomBytes(6).toString('hex')}.tmp`
57
+ let fd = null
58
+ try {
59
+ // `wx` is the point: it refuses to reuse — or follow — whatever sits at the
60
+ // temp path. fchmod then pins the exact bits, so the record is never wider
61
+ // than asked, not even for the duration of one syscall.
62
+ fd = openSync(temporary, 'wx', mode)
63
+ fchmodSync(fd, mode)
64
+ writeSync(fd, content)
65
+ fsyncSync(fd)
66
+ closeSync(fd)
67
+ fd = null
68
+ renameSync(temporary, path)
69
+ } catch (error) {
70
+ if (fd !== null) {
71
+ try {
72
+ closeSync(fd)
73
+ } catch {
74
+ /* already closed */
75
+ }
76
+ }
77
+ try {
78
+ rmSync(temporary, { force: true })
79
+ } catch {
80
+ /* a temp file that cannot be removed is replaced by the next write */
81
+ }
82
+ throw error
83
+ }
84
+ // The rename is only durable once the directory entry itself is on disk.
85
+ // A filesystem that refuses to fsync a directory costs durability, not
86
+ // correctness, so this stays best-effort.
87
+ try {
88
+ const dir = openSync(dirname(path), 'r')
89
+ try {
90
+ fsyncSync(dir)
91
+ } finally {
92
+ closeSync(dir)
93
+ }
94
+ } catch {
95
+ /* platforms that cannot open or sync a directory */
96
+ }
97
+ }
@@ -43,9 +43,10 @@
43
43
  * read is the cheap guarantee that nothing can hide behind a probe bug.
44
44
  */
45
45
  import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
46
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
46
+ import { existsSync, readFileSync, rmSync, statSync } from 'node:fs'
47
47
  import { dirname, join } from 'node:path'
48
48
  import { gunzipSync, gzipSync } from 'node:zlib'
49
+ import { writeFilePrivate } from './atomic-file.js'
49
50
 
50
51
  /** File format version; a mismatch discards the stored cache. */
51
52
  const CACHE_VERSION = 2
@@ -149,21 +150,11 @@ export class CacheStore {
149
150
  const canonical = this.#canonical({ ...record, version: CACHE_VERSION, savedAt: record.savedAt ?? this.now() })
150
151
  const mac = this.#sign(canonical)
151
152
  if (!mac) return false // without a key the record could never be verified
152
- const temporary = `${this.path}.${process.pid}.tmp`
153
153
  try {
154
154
  const payload = gzipSync(JSON.stringify({ ...canonical, mac }))
155
- mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
156
- writeFileSync(temporary, payload, { mode: 0o600 })
157
- // writeFileSync's mode is masked by umask, so set it explicitly.
158
- chmodSync(temporary, 0o600)
159
- renameSync(temporary, this.path)
155
+ writeFilePrivate(this.path, payload, { mode: 0o600 })
160
156
  return true
161
157
  } catch {
162
- try {
163
- rmSync(temporary, { force: true })
164
- } catch {
165
- /* nothing else to do */
166
- }
167
158
  return false
168
159
  }
169
160
  }
@@ -225,19 +216,10 @@ export class CacheStore {
225
216
  const existing = this.#readKey()
226
217
  if (existing) return existing
227
218
  const key = randomBytes(KEY_BYTES)
228
- const temporary = `${this.keyPath}.${process.pid}.tmp`
229
219
  try {
230
- mkdirSync(dirname(this.keyPath), { recursive: true, mode: 0o700 })
231
- writeFileSync(temporary, key, { mode: 0o600 })
232
- chmodSync(temporary, 0o600)
233
- renameSync(temporary, this.keyPath)
220
+ writeFilePrivate(this.keyPath, key, { mode: 0o600 })
234
221
  return key
235
222
  } catch {
236
- try {
237
- rmSync(temporary, { force: true })
238
- } catch {
239
- /* nothing else to do */
240
- }
241
223
  return null
242
224
  }
243
225
  }
package/lib/client.js CHANGED
@@ -396,7 +396,7 @@ const nextOffsetOf = (payload, fallback) => {
396
396
  * `scripts/build.sh` stamps both constants from package.json on every
397
397
  * build, so this fallback is never hand-maintained and cannot drift.
398
398
  */
399
- const BUILD_VERSION = '0.8.8'
399
+ const BUILD_VERSION = '0.8.9'
400
400
  const BUILD_REPOSITORY = 'https://github.com/BuvkB/dsh-vaultwarden'
401
401
  /**
402
402
  * Turn a package.json repository url into something a browser can open.
@@ -3955,6 +3955,23 @@ const nextOffsetOf = (payload, fallback) => {
3955
3955
  const [accessMode, setAccessMode] = useState('readonly')
3956
3956
  // Bumped after a write, so the open entry re-reads its new state.
3957
3957
  const [detailGen, setDetailGen] = useState(0)
3958
+ /**
3959
+ * What is left of an entry once the host's answer is taken away: the
3960
+ * identity a locked pane still renders, and nothing else.
3961
+ *
3962
+ * `projectItem` answers a gated entry with exactly these keys (id, name,
3963
+ * type, uris, repromptRequired, hint). Reusing that shape here means the
3964
+ * pane does not need a second branch: it falls into the same
3965
+ * `value.repromptRequired` render it would have got from the host, and
3966
+ * the re-read that follows simply confirms it.
3967
+ */
3968
+ const gatedStub = (value) => ({
3969
+ id: value?.id ?? null,
3970
+ name: value?.name ?? null,
3971
+ type: value?.type ?? null,
3972
+ uris: value?.uris,
3973
+ repromptRequired: true,
3974
+ })
3958
3975
  /**
3959
3976
  * The write step waiting for confirmation, or null. One slot for every
3960
3977
  * action (archive, trash, restore, move): the confirmation is always the
@@ -4759,7 +4776,15 @@ const nextOffsetOf = (payload, fallback) => {
4759
4776
  // was only a number — the reveal flag, the narrow card/SSH/note payloads
4760
4777
  // and an open editor all outlived it, so a 5-minute proof left those
4761
4778
  // secrets readable for as long as the entry stayed selected.
4762
- const gateOnScreen = detail.status === 'ready' && Boolean(detail.value?.repromptRequired)
4779
+ // The countdown is polled while a gated entry is on screen, and also
4780
+ // while a grant is still in force over an entry that HAS been unlocked:
4781
+ // the host's answer for an unlocked entry is the plaintext, which no
4782
+ // longer carries `repromptRequired`, so keying the poll on that flag
4783
+ // alone stopped watching the very window it is supposed to close — the
4784
+ // pane then kept the plaintext after the proof lapsed. `grant.granted`
4785
+ // is what keeps the watch alive for exactly as long as there is
4786
+ // something left to take away.
4787
+ const gateOnScreen = detail.status === 'ready' && (Boolean(detail.value?.repromptRequired) || grant.granted)
4763
4788
  const grantRef = useRef(grant)
4764
4789
  grantRef.current = grant
4765
4790
  useEffect(() => {
@@ -4779,6 +4804,20 @@ const nextOffsetOf = (payload, fallback) => {
4779
4804
  setRevealed(false)
4780
4805
  setTypeFields({})
4781
4806
  closeEditor()
4807
+ // The pane itself has to go back behind the gate. Clearing the
4808
+ // reveal flag only re-masked ONE row: `detail` still held the
4809
+ // host's full plaintext, so the password row's own 显示 toggle
4810
+ // put the value straight back on screen (and so did the notes
4811
+ // and custom-field rows) long after the proof expired — the
4812
+ // exact opposite of what this effect is for.
4813
+ //
4814
+ // Two steps, and the order matters. First the plaintext leaves
4815
+ // React state here, synchronously, so no rejected or slow re-read
4816
+ // can leave it readable in between. Then `detailGen` makes the
4817
+ // host answer again, which restores the authoritative gated
4818
+ // payload (id/name/type/uris only).
4819
+ setDetail((previous) => (previous.status === 'ready' ? { status: 'ready', value: gatedStub(previous.value) } : previous))
4820
+ setDetailGen((previous) => previous + 1)
4782
4821
  }
4783
4822
  } catch {
4784
4823
  /* the host is busy or the panel is closing; next tick retries */
@@ -5791,11 +5830,13 @@ const nextOffsetOf = (payload, fallback) => {
5791
5830
  *
5792
5831
  * Every value is masked, like a card number or a private key:
5793
5832
  * Bitwarden has a "hidden" field type that carries PINs, API keys and
5794
- * recovery codes, and the panel is never told which type a field is —
5795
- * the host's projection drops `fields[].type`. So the row cannot
5796
- * depend on knowing it, and an always-revealable row is the only safe
5797
- * default. A reader who wants the value clicks 显示, or copies it
5798
- * through CopyButton without ever putting it on screen.
5833
+ * recovery codes. The host does send `fields[].type` (it is what keeps
5834
+ * hidden values out of the model-facing `all` projection), but this
5835
+ * pane does not branch on it on purpose: the panel is where the
5836
+ * account's owner reads their own vault, so every field starts masked
5837
+ * and the human clicks 显示 — the same treatment the card number and
5838
+ * the private key get, hidden or not. The `type` is still carried in
5839
+ * the payload if a future row wants to label it.
5799
5840
  */
5800
5841
  const fieldsSection = (value.fields ?? []).length
5801
5842
  ? h(
package/lib/index.js CHANGED
@@ -77,7 +77,7 @@ const PROMPT_TEXT = [
77
77
  '只有当 bitwarden_status 显示未配置、不可达或登录失败时,才请用户在 DSH 设置 → 插件 → bitwarden 中补全 serverUrl/email/主密码。',
78
78
  '取到的明文凭据只用于完成任务,不要回显、不要写入文件或日志。',
79
79
  '遇到 repromptRequired 的条目(Bitwarden 里对该条目开了「重新验证」)时不要反复试探:工具调用没有参数能绕开这道门,请转告用户在「设置 → 凭据库」里打开该条目并输入主密码,验证后短时间内可读取,或到官方客户端查看。',
80
- 'bitwarden_get 的 field=all 只回密码、用户名、网址、备注与自定义字段:TOTP 验证码只在下一次过期前有意义,卡号/身份信息/安全笔记/SSH 私钥需要点名 field=totp / card / identity / secureNote / sshKey 单独取。',
80
+ 'bitwarden_get 的 field=all 只回密码、用户名、网址、备注与普通自定义字段:TOTP 验证码只在下一次过期前有意义,卡号/身份信息/安全笔记/SSH 私钥/隐藏型字段需要点名 field=totp / card / identity / secureNote / sshKey / fields 单独取。',
81
81
  '取 TOTP 一律传 waitFresh: true:剩余不足 minRemaining(默认 20 秒)会等到下一个 30 秒窗口再返回,避免拿到来不及提交的废码;返回值里的 freshFor 是这枚码还剩多少秒可用。',
82
82
  '写入(bitwarden_create/update/delete/restore)默认关闭:accessMode=readonly 一律拒绝,ask 需用户逐次确认,auto 才直接写回;个人条目与组织条目都支持(创建组织条目传 organizationId + collectionIds)。',
83
83
  'bitwarden_delete 默认软删除进回收站(bitwarden_restore 可恢复);permanent: true 必须同时传 confirm: true 才会彻底删除。',
@@ -401,14 +401,14 @@ export function apply(ctx, config = {}) {
401
401
  ctx.tools.register(
402
402
  defineTool({
403
403
  name: 'bitwarden_get',
404
- description: '取出某条凭据的密码、用户名、当前 TOTP 码或自定义字段(id 或名称定位)。默认 field=all 只回常用字段;卡号、身份信息、安全笔记与 SSH 私钥必须点名各自的 field。',
404
+ description: '取出某条凭据的密码、用户名、当前 TOTP 码或自定义字段(id 或名称定位)。默认 field=all 只回常用字段;卡号、身份信息、安全笔记、SSH 私钥与隐藏型自定义字段必须点名各自的 field。',
405
405
  parameters: {
406
406
  id: { type: 'string', description: '条目 id(来自 bitwarden_find)' },
407
407
  name: { type: 'string', description: '或条目名称(需唯一匹配)' },
408
408
  field: {
409
409
  type: 'string',
410
410
  enum: ['all', 'password', 'username', 'totp', 'notes', 'fields', 'card', 'identity', 'sshKey', 'secureNote', 'passwordHistory'],
411
- description: '取哪个字段,默认 all。all 不含 TOTP 验证码(30 秒即失效)与卡号/身份信息/安全笔记/SSH 私钥/历史密码,只用 hasTotp/hasCard 等布尔位与 passwordHistoryCount 告知存在',
411
+ description: '取哪个字段,默认 all。all 不含 TOTP 验证码(30 秒即失效)、卡号/身份信息/安全笔记正文/SSH 私钥/历史密码,以及「隐藏」型自定义字段的值(占位 <hidden>,需 field=fields 显式取),只用 hasTotp/hasCard 等布尔位与 passwordHistoryCount 告知存在',
412
412
  },
413
413
  includeTrashed: {
414
414
  type: 'boolean',
@@ -431,6 +431,10 @@ export function apply(ctx, config = {}) {
431
431
  includeTrashed: Boolean(args.includeTrashed),
432
432
  waitFresh: Boolean(args.waitFresh),
433
433
  minRemaining: args.minRemaining,
434
+ // This is the model-facing call: hidden custom field values are
435
+ // withheld from `all` here and only here. The panel's own
436
+ // reveal (vw/reveal) keeps them so it can render them masked.
437
+ maskHiddenFields: true,
434
438
  })
435
439
  } catch (error) {
436
440
  return failure(error)
@@ -28,8 +28,9 @@
28
28
  * the deadline, so a session that is used regularly never expires, while one
29
29
  * left alone past `maxAgeDays` is dropped.
30
30
  */
31
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
32
- import { dirname, join } from 'node:path'
31
+ import { existsSync, readFileSync, rmSync, statSync } from 'node:fs'
32
+ import { join } from 'node:path'
33
+ import { writeFilePrivate } from './atomic-file.js'
33
34
 
34
35
  /** File format version; a mismatch discards the stored session. */
35
36
  const STORE_VERSION = 1
@@ -118,8 +119,14 @@ export class SessionStore {
118
119
  }
119
120
 
120
121
  /**
121
- * Write the session. The write is atomic (temp file + rename) so a crash
122
- * cannot leave a half-written file that fails to parse on the next boot.
122
+ * Write the session. The write is atomic and durable (exclusive-create temp
123
+ * file, fsync, rename, directory fsync) so a crash cannot leave a
124
+ * half-written file that fails to parse on the next boot — see
125
+ * lib/atomic-file.js for why each step is there.
126
+ *
127
+ * Serializing two writers is still NOT done here: this method can be
128
+ * overtaken by another process doing its own read-modify-write. Tracked in
129
+ * `.research/atomic-audit.md` (cross-process lock).
123
130
  * @returns {boolean} whether the session was persisted
124
131
  */
125
132
  save(record) {
@@ -129,20 +136,10 @@ export class SessionStore {
129
136
  }
130
137
  if (!record || (!record.refreshToken && !record.accessToken)) return false
131
138
  const payload = JSON.stringify({ ...record, version: STORE_VERSION, lastUsedAt: record.lastUsedAt ?? this.now() }, null, 2)
132
- const temporary = `${this.path}.${process.pid}.tmp`
133
139
  try {
134
- mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
135
- writeFileSync(temporary, payload, { mode: 0o600 })
136
- // writeFileSync's mode is masked by umask, so set it explicitly.
137
- chmodSync(temporary, 0o600)
138
- renameSync(temporary, this.path)
140
+ writeFilePrivate(this.path, payload, { mode: 0o600 })
139
141
  return true
140
142
  } catch {
141
- try {
142
- rmSync(temporary, { force: true })
143
- } catch {
144
- /* nothing else to do */
145
- }
146
143
  return false
147
144
  }
148
145
  }
package/lib/vault.js CHANGED
@@ -173,7 +173,19 @@ export async function httpJson(fetchImpl, url, options = {}, timeoutMs = DEFAULT
173
173
  try {
174
174
  response = await fetchImpl(url, { method, headers, body, signal: controller.signal })
175
175
  } catch (error) {
176
- const reason = signal?.aborted ? '请求已取消' : `无法连接 ${url}(${error?.message ?? error})`
176
+ // Origin only, never the full URL: the path names the endpoint being
177
+ // probed (/identity/connect/token, /api/sync), and this message ends up in
178
+ // the panel's sync tooltip and in `bitwarden_status`. The rest of the
179
+ // plugin already refuses to hand out deployment details (`gateway.js`
180
+ // strips the session file's absolute path for the same reason).
181
+ const origin = (() => {
182
+ try {
183
+ return new URL(url).origin
184
+ } catch {
185
+ return '服务器'
186
+ }
187
+ })()
188
+ const reason = signal?.aborted ? '请求已取消' : `无法连接 ${origin}(${error?.message ?? error})`
177
189
  throw new VaultError(reason, {
178
190
  code: 'network_error',
179
191
  hint: '检查网络连通性、TLS 证书与服务地址;自签证书需在服务器侧换成受信任证书',
@@ -565,8 +577,12 @@ function decryptCipher(cipher, userKey, orgKeys, folderNames, collectionNames) {
565
577
  }
566
578
  const dec = (value) => decryptString(value, key)
567
579
  const uris = (cipher.login?.uris ?? []).map((entry) => dec(entry?.uri)).filter(Boolean)
580
+ // `type` is kept because it is not decoration: Bitwarden's 1 means a hidden
581
+ // field (PIN, API key, recovery code) — the one kind an official client never
582
+ // puts on screen unasked. `projectItem` uses it to keep the wider `all`
583
+ // projection from handing those values to the model.
568
584
  const fields = (cipher.fields ?? [])
569
- .map((field) => ({ name: dec(field?.name), value: dec(field?.value), type: field?.type }))
585
+ .map((field) => ({ name: dec(field?.name), value: dec(field?.value), type: Number(field?.type ?? 0) }))
570
586
  .filter((field) => field.name || field.value)
571
587
  // Attachment metadata only: the file names are encrypted with the item key
572
588
  // and the sizes are plain, but the bytes are a separate encrypted blob behind
@@ -788,7 +804,16 @@ function resolveItem(vault, ref) {
788
804
  fail(`凭据库中没有匹配 "${needle}" 的条目`, 'not_found', '换关键词调用 bitwarden_find,或请用户确认条目标题')
789
805
  }
790
806
 
791
- function projectItem(item, field, at, confirm = false) {
807
+ /**
808
+ * What `field: 'all'` puts in place of a hidden custom field's value.
809
+ *
810
+ * A marker, not a mask: the caller has to be able to tell "this is a secret I
811
+ * have not been given" from "this field is empty", and the name it reads back
812
+ * is what it passes to `field: 'fields'` when it genuinely needs the value.
813
+ */
814
+ const HIDDEN_FIELD_VALUE = '<hidden>'
815
+
816
+ function projectItem(item, field, at, confirm = false, options = {}) {
792
817
  const base = { id: item.id, name: item.name, type: item.type }
793
818
  // A trashed item is still readable by id, but the caller must never mistake
794
819
  // it for a live one (its password may already be gone from the account).
@@ -828,6 +853,8 @@ function projectItem(item, field, at, confirm = false) {
828
853
  return { ...base, totp: withTotp() ?? null }
829
854
  case 'notes':
830
855
  return { ...base, notes: truncate(item.notes ?? '') ?? null }
856
+ // Naming this field IS the ask: a caller that wants a hidden value has to
857
+ // say so, exactly like the card number and the SSH key below.
831
858
  case 'fields':
832
859
  return { ...base, fields: item.fields }
833
860
  // The narrow cases below exist so that `all` can stop being a shotgun.
@@ -872,8 +899,17 @@ function projectItem(item, field, at, confirm = false) {
872
899
  // For a secure note the `notes` field IS the secret, so `all` leaves it
873
900
  // out the same way it leaves out the card number: the caller has to
874
901
  // name `field=secureNote` to get the body.
902
+ // Ordinary notes ride along (they are usually "which tenant was this
903
+ // again"), but a secure note's body does not — see `secureNote` below.
875
904
  notes: item.notes && item.type !== 'secureNote' ? truncate(item.notes) : undefined,
876
- fields: item.fields.length ? item.fields : undefined,
905
+ // Custom fields, with hidden ones withheld. Bitwarden's `type: 1` is
906
+ // where people put the PIN, the API key and the recovery code, and
907
+ // `all` was the one projection that still handed those values over
908
+ // without being asked for them. The name and the placeholder stay so a
909
+ // caller can see the field exists and name it back (field: 'fields').
910
+ fields: item.fields.length
911
+ ? item.fields.map((field) => (field.type === 1 && options.maskHiddenFields ? { name: field.name, type: field.type, value: HIDDEN_FIELD_VALUE } : field))
912
+ : undefined,
877
913
  // Presence flags instead of the payloads. Card numbers, identity
878
914
  // numbers, secure notes and SSH keys each need a dedicated `field`
879
915
  // call now; `all` still tells the caller they exist and what shape.
@@ -1185,7 +1221,11 @@ function searchPayload(vault, query, limit, { allowEmpty = false, offset = 0, in
1185
1221
  hasMore: start + page.length < scored.length,
1186
1222
  vaultItems: pool.length,
1187
1223
  hint: scored.length ? '用 bitwarden_get(id 或 name)取出密码等字段' : '换个关键词,或先确认条目是否存在于该账户',
1188
- items: page.map(({ item, score }) => ({ ...summary(item), score })),
1224
+ // The score stays inside this function: it is a ranking device, and its
1225
+ // value band tells the caller which KIND of field matched (20 = custom
1226
+ // field, 10 = notes), which is an existence oracle for anyone probing a
1227
+ // guessed token prefix. Callers get the order, not the arithmetic.
1228
+ items: page.map(({ item }) => summary(item)),
1189
1229
  }
1190
1230
  const archivedCount = vault.items.length - vault.items.filter((item) => !item.archived).length
1191
1231
  if (archivedCount && !includeArchived) {
@@ -2617,7 +2657,16 @@ export class VaultClient {
2617
2657
  }
2618
2658
  return JSON.stringify(projected, null, 2)
2619
2659
  }
2620
- return JSON.stringify(projectItem(item, field, this.now(), this.repromptGranted), null, 2)
2660
+ // `maskHiddenFields` is the MODEL-facing boundary, not a display rule:
2661
+ // the host's own panel (revealEntry) is the account owner reading their
2662
+ // own vault, so it keeps the values it then renders masked behind a
2663
+ // click. A tool call is a different principal and does not get them for
2664
+ // free — see the `all` projection in projectItem.
2665
+ return JSON.stringify(
2666
+ projectItem(item, field, this.now(), this.repromptGranted, { maskHiddenFields: Boolean(options.maskHiddenFields) }),
2667
+ null,
2668
+ 2,
2669
+ )
2621
2670
  }
2622
2671
 
2623
2672
  /** Configuration / connectivity / unlock report. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vaultwarden",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "description": "Vaultwarden/Bitwarden real-time sync for DeepSeek Harness: WebSocket push notifications, on-disk vault cache with revision probing, read/write tools and an entry browser panel. Independent implementation; see THIRD-PARTY-NOTICES.md.",
5
5
  "repository": {
6
6
  "type": "git",
package/scripts/build.sh CHANGED
@@ -132,6 +132,13 @@ if [ "${SKIP_TESTS:-0}" != "1" ]; then
132
132
  echo "=== write-back regression test (trash / concurrency / field preservation) ==="
133
133
  node test/write-back.test.mjs
134
134
 
135
+ # The on-disk privacy contract every record obeys: exclusive-create temp path
136
+ # (a wider file already sitting there is never reused), 0600 carried by the
137
+ # fresh inode instead of a chmod afterwards, no predictable temp name, and a
138
+ # clean failure that leaves neither debris nor a half-written record.
139
+ echo "=== atomic file privacy test ==="
140
+ node test/atomic-file.test.mjs
141
+
135
142
  # Host-entry smoke test: imports lib/index.js and runs apply() against a fake
136
143
  # host context, so a broken Config schema or registration path fails here
137
144
  # instead of at startup.