feat(cli): support a user-configurable keyterms file for voice dictation#5817
Conversation
Voice dictation biased the ASR with a hardcoded keyterm list, so users had no way to improve recognition of domain-specific terms. Add an optional general.voice.keytermsFile setting plus auto-discovery of .qwen/voice-keyterms.txt; its terms (one per line, # comments) merge with the globals, deduped case-insensitively and capped by count and length. The file is read only in a trusted workspace and must be a regular, non-symlink file under a size bound, so a cloned or untrusted repo cannot plant a file or a symlink to a secret that would exfiltrate to the ASR provider on the next dictation. Applies to the Qwen ASR transports only; DashScope fun-asr/paraformer use a separate vocabulary_id mechanism. Closes QwenLM#5816 Co-Authored-By: Qwen-Coder <[email protected]>
|
Re-triage — author pushed 3 new commits ( Template looks good ✓ — all required sections present, bilingual, test plan gives exact commands. On direction: aligned. Issue #5816 is a clear, well-scoped feature request — users with domain-specific vocabulary (product names, internal services, non-English terms) have no way to extend the hardcoded global keyterm list, and the ASR API already accepts vocabulary biasing. This fills the gap. Mirrors Claude Code's voice keyterms capability; no direct Claude Code CHANGELOG reference but the feature area is clearly relevant. On approach: scope is tight. One new setting, one file reader with thorough security guards, threaded into two existing call sites. Three new commits since last triage address every prior concern: (1) Moving on to code review. 🔍 中文说明重新分流 — 作者在上次评审后又推送了 3 个提交( 模板完整 ✓ — 所有必填章节齐全,双语,测试计划给出了具体命令。 方向:对齐。Issue #5816 范围清晰 — 拥有领域专有词汇的用户无法扩展写死的全局热词表,而 ASR API 本就接受词汇偏置提示。与 Claude Code 的 voice keyterms 功能对标。 方案:范围紧凑。一个新设置、一个带完整安全闸门的文件读取器,接入两个已有调用点。3 个新提交逐一回应了之前的意见:(1) 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
|
Re-triage — re-reviewed against HEAD Code reviewReviewed the full diff (8 files, +901/-13) and the 3 incremental commits ( Independent proposal (written before reading the diff): I'd add a
No correctness bugs, no security holes, no AGENTS.md violations found. Reuse check: Test resultsReal-scenario testingN/A — this is a non-TUI feature (voice dictation keyterm biasing, internal to the ASR pipeline). The behavior is fully verified by 96 unit tests covering: auto-discovery, explicit paths (absolute + relative + tilde), multi-scope fallback (system/user/auto-discovered, empty-file fallback), comment/blank-line parsing (including 中文说明重新分流 — 基于 HEAD 代码审查审阅了完整 diff(8 个文件,+901/-13)及 3 个增量提交。实现干净、聚焦、安全加固充分。 独立方案(读 diff 前写的):我会加
未发现正确性 bug、安全漏洞或 AGENTS.md 违规。 复用检查: 测试结果typecheck、prettier、eslint 均通过。 真实场景测试N/A — 非 TUI 功能(语音听写热词偏置,ASR 管道内部)。行为由 96 个单元测试完整覆盖。真机麦克风测试需要真实 ASR 端点和音频硬件。 — Qwen Code · qwen3.7-max |
|
Re-triage — final assessment against HEAD Stepping back: this PR does exactly what it says — lets users extend the voice dictation vocabulary with a simple one-term-per-line file. The motivation is genuine (domain-specific proper nouns get mangled by ASR), the implementation is straightforward, and the security model is thorough beyond what I'd have written myself. Going back to my independent proposal: the PR matches it and exceeds it on security (TOCTOU protection, hardlink rejection, The code is the kind I'd want to maintain: small functions with clear names, security guards at the boundary, graceful fallback everywhere (bad path → globals, untrusted workspace → globals, empty file → next scope → globals). 96 tests, all green. Typecheck, lint, and formatting clean. The only thing I'd flag as a mild concern is that the Approving. ✅ 中文说明重新分流 — 基于 HEAD 退一步看:这个 PR 做了它说要做的事 — 让用户用一个简单的一行一术语文件扩展语音听写词汇。动机真实(领域专有名词被 ASR 识别错),实现直接,安全模型比我自己会写的更充分。 回到我的独立方案:PR 与之匹配并在安全层面超越(TOCTOU 保护、硬链接拒绝、 代码是我希望维护的那种:小函数、清晰命名、边界安全闸门、处处优雅降级。96 个测试全绿。typecheck、lint、格式化均通过。 唯一的小顾虑是 批准合并 ✅ — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Typecheck regression: adding keytermsFile to the voice schema narrows the inferred Settings type, causing settings.merged.general?.voice?.refineTranscript in InputPrompt.tsx:376 to fail TS2339. Fix: add refineTranscript to the voice section of SETTINGS_SCHEMA (it is used in code/tests but was never declared). Once tsc --noEmit is green, this is ready to ship. See review comments above for details.
| if (!filePath) { | ||
| return []; | ||
| } | ||
| const stat = fs.lstatSync(filePath, { throwIfNoEntry: false }); |
There was a problem hiding this comment.
[Critical] readUserKeyterms only lstats the final path component and never canonicalizes or contains the resolved path, so it can read an arbitrary regular file and ship its contents to the remote ASR provider (corpus_text / system message). The leaf-only symlink check is bypassable. I confirmed each of these firsthand against this code (each returns the target's contents, capped to ~2000 chars):
- Parent-directory symlink — default path, no setting needed. Commit
.qwenitself as a symlink to a dir containing a realvoice-keyterms.txt;lstatSync('<ws>/.qwen/voice-keyterms.txt')follows the symlinked parent and sees a regular file, so the guard passes. This is exactly the "no secret exfiltration" scenario the symlink test claims to cover — but that test only makes the leaf a symlink (voice-keyterms.test.ts:162). - Configured absolute path outside the workspace.
keytermsFile: "/etc/passwd"→ 30 passwd lines exfiltrated; newline-delimited secrets (~/.ssh/id_rsa,.env,~/.aws/credentials) leak nearly in full. - Configured
../traversal.keytermsFile: "../../secret.txt"escapes the workspace root (the relative branchpath.resolve(path.dirname(qwenDir), configured)does no containment).
Precondition: a trusted workspace — and for the configured-path cases, a workspace-scoped .qwen/settings.json, which merges into settings.merged and overrides the user's value. So a cloned repo a user later trusts can plant the parent-symlink (vector 1, no setting) or a keytermsFile setting (vectors 2–3). It's gated by trust, but it defeats the anti-exfiltration defense this PR explicitly adds (the symlink rejection + the trusted-mode "no secret exfiltration" test), so by the PR's own threat model it's in scope.
Suggested fix — don't trust a leaf stat; canonicalize and constrain by origin:
- Honor the explicit
keytermsFilesetting only from user/system scope, not workspace (read it fromsettings.user/system rather thansettings.merged). This is the cleanest fix: it stops a repo from weaponizing the setting while preserving the intended use of a user-chosen absolute path. - For auto-discovery and relative paths, canonicalize and contain —
realpathSyncthe resolved path (resolves intermediate and leaf symlinks plus../) and require it to stay withinrealpathSync(workspaceRoot):
const real = fs.realpathSync(filePath);
const root = fs.realpathSync(workspaceRoot); // path.dirname(qwenDir)
if (real !== root && !real.startsWith(root + path.sep)) return [];
const stat = fs.statSync(real, { throwIfNoEntry: false });
if (!stat || !stat.isFile() || stat.nlink > 1 || stat.size > MAX_KEYTERMS_FILE_BYTES) return [];realpathSyncalone does not catch a hardlink at the leaf (no separate real path) — rejectnlink > 1too, as above.- Don't rely on
isTrustedas the sole boundary: in the daemon, trust is derived fromprocess.cwd()(not the bound--workspace) and defaults totrueon an unknown path, so it doesn't reliably reflect the workspace whose file is read. - Add regression tests for the three escape cases above asserting globals-only (they fail against the current code, then pin the fix).
— claude-opus-4-8[1m] via Qwen Code /qreview
| if (!workspacePath) { | ||
| return undefined; | ||
| } | ||
| const qwenDir = path.dirname(workspacePath); |
There was a problem hiding this comment.
[Critical] resolveKeytermsFile accepts absolute paths as-is and resolves relative paths against the workspace root with no containment check. A trusted workspace's .qwen/settings.json could set keytermsFile to /home/user/.ssh/id_rsa or ../../.aws/credentials, and those file contents (up to 64 KB) would be read and sent to the remote ASR provider. The isTrusted gate is the only defense, but once a workspace is trusted, there is no second check on what path is being read.
The codebase already has isSubpath from @qwen-code/qwen-code-core used in 50+ locations for exactly this kind of confinement (sandbox, stats, export, etc.). Consider adding a containment check:
| const qwenDir = path.dirname(workspacePath); | |
| const workspaceRoot = path.dirname(qwenDir); | |
| if (configured) { | |
| const resolved = path.isAbsolute(configured) | |
| ? configured | |
| : path.resolve(workspaceRoot, configured); | |
| if (!isSubpath(workspaceRoot, resolved)) { | |
| return undefined; | |
| } | |
| return resolved; | |
| } |
— qwen3.7-max via Qwen Code /review
| !stat || | ||
| stat.isSymbolicLink() || | ||
| !stat.isFile() || | ||
| stat.size > MAX_KEYTERMS_FILE_BYTES |
There was a problem hiding this comment.
[Suggestion] There is a TOCTOU race between fs.lstatSync (symlink/regular-file check) and fs.readFileSync (the actual read). Between the two syscalls, a concurrent process could replace the regular file with a symlink to a sensitive target.
The codebase already has an O_NOFOLLOW pattern in customBanner.ts that makes the open+stat+read atomic. Consider adopting it here:
| stat.size > MAX_KEYTERMS_FILE_BYTES | |
| const fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); | |
| try { | |
| const stat = fs.fstatSync(fd); | |
| if (!stat.isFile() || stat.size > MAX_KEYTERMS_FILE_BYTES) { | |
| return []; | |
| } | |
| return parseKeyterms(fs.readFileSync(fd, 'utf-8')); | |
| } finally { | |
| fs.closeSync(fd); | |
| } |
— qwen3.7-max via Qwen Code /review
| const filePath = resolveKeytermsFile(settings); | ||
| if (!filePath) { | ||
| return []; | ||
| } |
There was a problem hiding this comment.
[Suggestion] Every failure path in readUserKeyterms silently returns [] — untrusted workspace, no workspace path, symlink rejected, oversized file, I/O error. Users whose custom keyterms are silently ignored have no way to diagnose why.
Every sibling file in voice/ (voice-transcriber.ts, voice-stream-session.ts, qwen-asr-realtime-session.ts, etc.) uses createDebugLogger from @qwen-code/qwen-code-core. Consider importing it and logging at each early-return branch so the next person debugging "my keyterms file isn't being picked up" has breadcrumbs.
— qwen3.7-max via Qwen Code /review
| expect(streamConfig.keytermsContext).toContain('Paraformer'); | ||
| expect(streamConfig.keytermsContext).toContain('Qwen'); // globals too | ||
| } finally { | ||
| fs.rmSync(workspaceDir, { recursive: true, force: true }); |
There was a problem hiding this comment.
[Suggestion] The PR modifies buildKeytermsContext in both the realtime path (resolveVoiceStreamConfig) and the batch path (transcribeVoiceAudio), but this test only verifies custom-term threading for the realtime path. No test verifies a custom term reaches the batch transcription request body. Consider adding a complementary batch-path test to ensure both call sites are covered.
— qwen3.7-max via Qwen Code /review
| expect(terms).not.toContain('SECRETKEYMATERIAL'); | ||
| expect(terms).toContain('TypeScript'); // globals only | ||
| }); | ||
|
|
There was a problem hiding this comment.
[Suggestion] This symlink-rejection test only covers the auto-discovery path (.qwen/voice-keyterms.txt). The explicit keytermsFile path goes through resolveKeytermsFile first, then hits the same lstatSync check — but there's no test verifying the guard applies to explicitly configured paths too. A future refactor that separates the two resolution paths could accidentally drop the symlink guard from the explicit path. Consider adding a test that sets keytermsFile to a symlink and asserts the content is not loaded.
— qwen3.7-max via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
— qwen3.7-max via Qwen Code /review
| .filter((line) => line.length > 0 && !line.startsWith('#')); | ||
| } | ||
|
|
||
| function readKeytermsFileSetting(settings: LoadedSettings): string | undefined { |
There was a problem hiding this comment.
[Suggestion] readKeytermsFileSetting deliberately skips workspace scope (only reads system + user), which is a security boundary: workspace settings originate from the repo and could plant absolute paths that bypass the containment check. But nothing at this call site explains why workspace is excluded.
A future maintainer might see this as an oversight and add settings.workspace?.settings to the chain, unknowingly opening a keyterms-file exfiltration vector via a cloned repo's .qwen/settings.json.
| function readKeytermsFileSetting(settings: LoadedSettings): string | undefined { | |
| function readKeytermsFileSetting(settings: LoadedSettings): string | undefined { | |
| // Intentionally skip workspace scope: repos could plant absolute paths | |
| // that bypass containment and exfiltrate via the ASR provider. | |
| return ( | |
| readKeytermsFileSettingFromScope(settings.system?.settings) ?? | |
| readKeytermsFileSettingFromScope(settings.user?.settings) | |
| ); | |
| } |
— qwen3.7-max via Qwen Code /review
| !stat || | ||
| stat.isSymbolicLink() || | ||
| !stat.isFile() || | ||
| stat.nlink > 1 || |
There was a problem hiding this comment.
[Suggestion] The nlink > 1 hard-link guard is checked in both canonicalizeKeytermsFile (here) and readRegularFileNoFollow, but the test suite only covers the symlink equivalent (3 tests). There's no test that creates a hard-linked file and asserts rejection.
Hard links can be created trivially on macOS/Linux with fs.linkSync(source, dest). Adding a test would be straightforward:
it('does not read a hard-linked keyterms file', () => {
const secret = path.join(workspaceDir, 'secret.txt');
const link = path.join(qwenDir, 'voice-keyterms.txt');
fs.writeFileSync(secret, 'HardLinkSecret\n');
fs.linkSync(secret, link);
const terms = buildVoiceKeyterms(makeSettings(workspaceDir));
expect(terms).not.toContain('HardLinkSecret');
expect(terms).toContain('TypeScript');
});— qwen3.7-max via Qwen Code /review
| ): string | undefined { | ||
| const value = ( | ||
| settings?.general as { voice?: { keytermsFile?: unknown } } | undefined | ||
| )?.voice?.keytermsFile; |
There was a problem hiding this comment.
[Suggestion] The as cast here asserts the exact type that optional chaining already produces from the parameter annotation. Since settings is typed as { general?: { voice?: { keytermsFile?: unknown } } } | undefined, the expression settings?.general already produces { voice?: { keytermsFile?: unknown } } | undefined — identical to the cast target.
| )?.voice?.keytermsFile; | |
| const value = settings?.general?.voice?.keytermsFile; |
The result is unknown either way, and the next line (typeof value !== 'string') narrows it.
— qwen3.7-max via Qwen Code /review
| expect(terms.join(' ').length).toBeLessThanOrEqual(2000); | ||
| }); | ||
|
|
||
| it('caps by total length for a file of few long terms', () => { |
There was a problem hiding this comment.
[Suggestion] This test doesn't actually exercise the char budget (MAX_KEYTERMS_CHARS). All 40 terms are 'x'.repeat(80) — identical strings — so dedupeKeyterms collapses them to a single unique term. The result is 31 globals + 1 user term = 32 terms at ~300 chars, well under both the 200-term count cap and the 2000-char budget. All three assertions pass for the wrong reason. If the char cap were accidentally removed from capKeyterms, this test would still pass.
| it('caps by total length for a file of few long terms', () => { | |
| it('caps by total length for a file of few long terms', () => { | |
| // 40 × distinct 80-char terms blow the 2000-char budget long before the | |
| // 200-term count cap, so the char budget must bind. | |
| const long = Array.from({ length: 40 }, (_, i) => `t${i}_${'x'.repeat(73)}`).join('\n'); |
— qwen3.7-max via Qwen Code /review
| path: path.join(workspaceDir, '.qwen', 'settings.json'), | ||
| settings: { general: workspaceVoiceSettings }, | ||
| }, | ||
| system: { settings: {} }, |
There was a problem hiding this comment.
[Suggestion] makeSettings always constructs system: { settings: {} } and the keytermsFile parameter is only placed in user.settings.general.voice. No test verifies that a system-scoped keytermsFile is read, or exercises the system-over-user precedence of readKeytermsFileSetting. If the scope chain were accidentally reordered, no test would catch it.
Consider adding two tests: (1) a system-scoped keytermsFile is honored when no user setting exists, and (2) when both system and user scopes set keytermsFile, verify which wins.
— qwen3.7-max via Qwen Code /review
| return { | ||
| filePath: isAbsolute | ||
| ? configured | ||
| : path.resolve(workspaceRoot, configured), |
There was a problem hiding this comment.
[Suggestion] Tilde (~) is not expanded in the keytermsFile setting value. path.isAbsolute('~/terms.txt') returns false on POSIX, so it falls through to path.resolve(workspaceRoot, configured) which resolves to <workspaceRoot>/~/terms.txt — a path that doesn't exist. The file silently fails to load and the user gets globals-only keyterms with no diagnostic.
Tilde expansion is a common user expectation for file-path settings. The codebase already has resolvePath in @qwen-code/qwen-code-core that handles tilde expansion and is used elsewhere for user-facing path resolution.
| : path.resolve(workspaceRoot, configured), | |
| : resolvePath(workspaceRoot, configured), |
— qwen3.7-max via Qwen Code /review
✅ Maintainer local-verification report — PR #5817Verdict: LGTM — recommend merge. This is a security-sensitive feature (a user file whose contents are sent to a remote ASR provider), but the implementation is carefully layered and every security guard is covered by a test that genuinely fails when the guard is removed (proven by mutation testing below). Built & verified locally against PR head What it doesAdds The security model (the crux — verified against the real code)Because the file's contents leave the machine, the read is gated by layered, independently-tested defenses:
I independently confirmed the load-bearing assumptions: Verification performed (PR head
|
| Check | Result |
|---|---|
vitest (3 changed files) |
✓ 80/80 (22 keyterms + 31 transcriber + 27 settingsSchema) |
vitest (whole voice/ dir + schema) |
✓ 147/147, 12 files — no regression |
| Security mutation testing | ✓ 6 guards disabled one-by-one, each killed by exactly the right test(s) — matrix below |
| Typecheck (PR files) | ✓ 0 errors in the changed files |
| Prettier | ✓ source/test files clean; generated schema is prettier-ignored and byte-identical to prettier's own output |
| Generated schema sync | ✓ settings.schema.json entry is byte-identical to the generator's deterministic mapping of the settingsSchema.ts node |
| Caller audit | ✓ buildVoiceKeyterms / buildKeytermsContext have no missed callers; buildVoiceKeyterms() stays backward-compatible |
| End-to-end | ✓ transcriber tests read a real temp keyterms file and assert the term lands in the actual request body (realtime + batch) |
Security mutation matrix — proves the guards aren't decorative (each mutation applied in isolation, then reverted):
| Guard disabled | Test(s) that failed |
|---|---|
trust gate (isTrusted) |
does not read a keyterms file in an untrusted workspace |
lstat → stat (follow symlink) |
does not follow a symlinked keyterms file + …symlinked explicit keytermsFile |
nlink > 1 (hard-link) |
does not read a hard-linked keyterms file |
isSubpath containment |
…through a symlinked .qwen directory + does not let relative keytermsFile escape the workspace root |
| + workspace scope to setting read | ignores workspace-scoped keytermsFile settings |
| 64 KB size cap | ignores a keyterms file larger than the size cap |
Note the clean separation: the lstat no-follow catches a symlinked file (final component), while isSubpath catches a symlinked directory (intermediate component) and relative escapes — two distinct vectors, two distinct guards, each independently tested.
Minor observations (all by-design / non-blocking)
- An in-workspace symlink to a keyterms file is also rejected (the no-symlink rule is absolute). Slightly stricter than strictly necessary, but the safe default — worth one line in the docs so a user who symlinks their glossary isn't surprised.
- Residual TOCTOU on an intermediate path component (swapped between
realpathandopen) is outside the stated threat model (untrusted repo content, not a concurrent local attacker); the realistic final-component vectors are covered byO_NOFOLLOW+ the fd re-fstat. - On Windows,
O_NOFOLLOWis absent, so the no-follow open degrades to a plain read — therelstat+realpath+isSubpathremain the primary defense (acceptable).
Environment notes (not PR issues)
- Branch is slightly behind
main(stillMERGEABLE). - I couldn't run the schema generator locally (the symlinked
core/distin my env predates an unrelated export,DEFAULT_QWEN_CUSTOM_IGNORE_FILE_NAMES); instead I verified sync by the generator's deterministic mapping + a byte-for-byte description comparison, which is equivalent for a single added leaf. - The lone typecheck error in my source-only setup is an unrelated
@lydell/node-ptyexports-map quirk incoresource the PR doesn't touch.
No manual audio/dictation A/B was needed: the threading is a pure data path and the transcriber tests already drive it end-to-end with a real file and assert on the real outbound request body.
🇨🇳 中文版(完整对应)
✅ 维护者本地验证报告 — PR #5817
结论:LGTM,建议合并。 这是一个安全敏感特性(用户文件内容会被发送到远端 ASR 服务),但实现采用了分层防御,且每一道安全防线都有一个"去掉它就会挂"的测试覆盖(下方变异测试已证明)。在隔离 worktree 中基于 PR head 0c3351f 本地构建并验证。
功能
新增 general.voice.keytermsFile——用户自建的词表文件(每行一个词,# 注释),用于偏置 Qwen ASR 转写。解析显式设置(绝对路径,或相对工作区根的相对路径)或项目本地默认文件 .qwen/voice-keyterms.txt,与静态全局词表合并(去重 + 限额),并贯通到实时 corpus_text 与批量 system message。仅 Qwen ASR 通道消费。
安全模型(核心 —— 已对照真实代码核实)
由于文件内容会离开本机,读取被多层、各自独立测试的防御所守护:
| 防线 | 机制 | 阻断的威胁 |
|---|---|---|
| 信任门 | if (!settings.isTrusted) return [](覆盖显式设置与自动发现的默认文件) |
不受信任/克隆仓库植入文件 |
| 作用域排除 | 设置只从 system + user 读,绝不读 workspace 作用域 | 仓库 .qwen/settings.json 指向绝对路径秘密文件 |
| 禁软链(文件) | lstatSync 拒绝 isSymbolicLink() / 非常规文件 |
.qwen/voice-keyterms.txt → ~/.ssh/id_rsa |
| 禁软链(目录) | realpathSync + isSubpath(realRoot, realFile) |
软链的 .qwen/ 目录逃逸出工作区 |
| 禁硬链 | 拒绝 nlink > 1(lstat 与 open 后 fstat 双重) |
工作区内指向秘密文件的硬链接 |
| 包含校验 | 相对/默认路径必须解析在工作区根内部 | ../../etc/passwd 式相对逃逸 |
| 大小上限 | 64 KB(lstat + fstat) | 内存爆掉 / 把超大文件发出去 |
| TOCTOU 复核 | O_NOFOLLOW 打开 + 用 fd fstat 复核 isFile/nlink/size |
stat 之后被换成软链/硬链 |
我独立核实了关键假设:workspace.path 是 <root>/.qwen/settings.json,因此 dirname(dirname(...)) = <root> 正确,默认文件解析为 <root>/.qwen/voice-keyterms.txt;isSubpath 是正确的拒绝 .. 的包含检查;resolvePath 做 ~/%userprofile% 展开;且包含检查两侧都做了 realpathSync(因此 macOS 的 /var→/private/var 不会误判)。
已执行的验证
| 检查项 | 结果 |
|---|---|
vitest(3 个改动文件) |
✓ 80/80(22 keyterms + 31 transcriber + 27 settingsSchema) |
vitest(整个 voice/ 目录 + schema) |
✓ 147/147,12 个文件 —— 无回归 |
| 安全变异测试 | ✓ 6 道防线逐一禁用,每道都被对应测试精准杀死 —— 见下表 |
| 类型检查(PR 文件) | ✓ 改动文件 0 错误 |
| Prettier | ✓ 源码/测试文件干净;生成的 schema 被 prettier 忽略,且与 prettier 自身输出逐字节一致 |
| 生成 schema 同步 | ✓ settings.schema.json 条目与生成器对 settingsSchema.ts 节点的确定性映射逐字节一致 |
| 调用方审计 | ✓ buildVoiceKeyterms / buildKeytermsContext 无遗漏调用方;buildVoiceKeyterms() 保持向后兼容 |
| 端到端 | ✓ transcriber 测试读取真实临时词表文件,并断言该词出现在真实请求体中(实时 + 批量) |
安全变异矩阵 —— 证明防线不是摆设(每个变异单独施加后回滚):
| 被禁用的防线 | 失败的测试 |
|---|---|
信任门(isTrusted) |
does not read a keyterms file in an untrusted workspace |
lstat → stat(跟随软链) |
does not follow a symlinked keyterms file + …symlinked explicit keytermsFile |
nlink > 1(硬链) |
does not read a hard-linked keyterms file |
isSubpath 包含校验 |
…through a symlinked .qwen directory + does not let relative keytermsFile escape the workspace root |
| 给设置读取加上 workspace 作用域 | ignores workspace-scoped keytermsFile settings |
| 64 KB 大小上限 | ignores a keyterms file larger than the size cap |
注意防线的清晰分工:lstat 不跟随软链拦的是软链文件(最后一段路径),isSubpath 拦的是软链目录(中间路径段)与相对逃逸——两种不同的攻击向量,两道不同的防线,各自独立被测。
次要观察(均属设计取舍 / 不阻塞)
- 工作区内部指向词表文件的软链也会被拒("禁软链"是绝对的)。比严格必要略紧,但是更安全的默认值——值得在文档加一行,免得用户软链自己的词表却不生效时困惑。
- 中间路径段的残余 TOCTOU(在
realpath与open之间被替换)不在所述威胁模型内(针对的是不受信任的仓库内容,而非并发的本地攻击者);现实中的最后一段路径向量已由O_NOFOLLOW+ fd 复核fstat覆盖。 - Windows 上没有
O_NOFOLLOW,no-follow 打开退化为普通读取——那里lstat+realpath+isSubpath仍是主防御(可接受)。
环境说明(非本 PR 问题)
- 分支略落后于
main(仍MERGEABLE)。 - 本地跑不了 schema 生成器(我环境里符号链接的
core/dist早于一个无关导出DEFAULT_QWEN_CUSTOM_IGNORE_FILE_NAMES);改用生成器的确定性映射 + 逐字节描述对比来验证同步,对单个新增叶子节点而言等价。 - 我源码直连方式下唯一的类型检查报错,是与本 PR 无关、
core源码里@lydell/node-pty的 exports 映射怪癖。
无需人工音频/听写 A/B:贯通是纯数据路径,transcriber 测试已用真实文件端到端驱动并断言真实出站请求体。
Verified locally by the maintainer (@wenshao) in an isolated worktree against PR head 0c3351f: vitest 147/147, 6-guard security mutation matrix (each guard independently test-covered), PR-file typecheck clean, generated schema byte-identical & in sync.
| } | ||
| return realFilePath; | ||
| } | ||
|
|
There was a problem hiding this comment.
[Critical] On Windows, O_NOFOLLOW is undefined (typeof fs.constants.O_NOFOLLOW is not 'number'), so this falls back to plain O_RDONLY — the fd follows symlinks. The prior lstatSync in canonicalizeKeytermsFile catches symlinks, but there's a TOCTOU window between lstatSync and openSync that O_NOFOLLOW closes on POSIX and leaves open on Windows.
The symlink tests all run on macOS/Linux where O_NOFOLLOW works, so the Windows code path is unverified.
Consider adding a post-fstatSync check: re-run fs.fstatSync(fd) and compare ino/dev against the earlier lstatSync result, or fall back to [] on Windows entirely.
— qwen3.7-max via Qwen Code /review
| /** Parse a keyterms file: one term per line, `#` comments and blanks ignored. */ | ||
| function parseKeyterms(content: string): string[] { | ||
| return content | ||
| .split(/\r?\n/) |
There was a problem hiding this comment.
[Critical] System scope has higher priority than user scope here (system ?? user). Combined with mustBeInWorkspace: false for absolute paths (line 175), a system-scoped keytermsFile can point to any absolute path on disk — the contents are silently uploaded to the remote ASR provider for every user on the machine.
On a shared workstation (CI server, corporate machine), anyone who can write to the system settings file can force exfiltration of arbitrary readable files via dictation.
Consider inverting precedence so user scope overrides system scope:
| .split(/\r?\n/) | |
| return ( | |
| readKeytermsFileSettingFromScope(settings.user?.settings) ?? | |
| readKeytermsFileSettingFromScope(settings.system?.settings) | |
| ); |
— qwen3.7-max via Qwen Code /review
| fs.closeSync(fd); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
[Suggestion] Inline # comments are not stripped. A line like Kubernetes # container orchestration becomes a single malformed keyterm instead of extracting just Kubernetes. The schema description says "#" for comments, which users will interpret as supporting inline comments (consistent with shell/config conventions).
| } | |
| function parseKeyterms(content: string): string[] { | |
| return content | |
| .split(/\r?\n/) | |
| .map((line) => line.replace(/#.*$/, '').trim()) | |
| .filter((line) => line.length > 0); | |
| } |
Alternatively, update the schema description to clarify that only full-line comments are supported.
— qwen3.7-max via Qwen Code /review
| mustBeInWorkspace: true, | ||
| }; | ||
| } | ||
|
|
There was a problem hiding this comment.
[Suggestion] The function name canonicalizeKeytermsFile suggests path normalization (realpath), but it performs five security validations: symlink rejection, file-type check, hard-link count, file-size cap, and workspace containment. A future maintainer may not recognize this as the security gate.
Consider renaming to validateKeytermsFile or validateAndCanonicalizeKeytermsFile.
— qwen3.7-max via Qwen Code /review
| * unknown (e.g. minimal/stream-json settings with an empty workspace path). | ||
| */ | ||
| function resolveKeytermsFile( | ||
| settings: LoadedSettings, |
There was a problem hiding this comment.
[Suggestion] The workspace root is derived via path.dirname(path.dirname(workspacePath)), assuming workspace.path always ends in .qwen/settings.json. If the settings file location ever changes, the containment check silently uses a wrong root — either incorrectly rejecting valid files or allowing files outside the actual workspace.
Consider validating the assumption (path.basename(qwenDir) === '.qwen') or accepting the workspace root as an explicit parameter on LoadedSettings.
— qwen3.7-max via Qwen Code /review
| * dictation is never broken by a bad path. Only the Qwen ASR transports consume | ||
| * this; DashScope (fun-asr/paraformer) uses a separate vocabulary_id mechanism. | ||
| */ | ||
| export function buildVoiceKeyterms(settings?: LoadedSettings): string[] { |
There was a problem hiding this comment.
[Suggestion] Each call to buildVoiceKeyterms re-reads the file from disk, re-parses, re-dedupes, and re-caps. In the daemon serve path where multiple dictation sessions may share a workspace, this is redundant I/O.
Consider caching the result keyed on file path + mtime from the lstatSync already performed.
— qwen3.7-max via Qwen Code /review
| const terms = buildVoiceKeyterms(makeSettings(workspaceDir)); | ||
|
|
||
| expect(raceState.swapped).toBe(true); | ||
| expect(terms).not.toContain('SwapSecret'); |
There was a problem hiding this comment.
[Critical] This newly added race-condition test fails against the current implementation, which means the TOCTOU mitigation it describes is not actually in place.
Reproduced locally with npx vitest run src/ui/voice/voice-keyterms-race.test.ts:
FAIL buildVoiceKeyterms race checks > does not read a keyterms file swapped in before open
AssertionError: expected [ 'Qwen', 'MCP', 'grep', …(29) ] to not include 'SwapSecret'
❯ src/ui/voice/voice-keyterms-race.test.ts:85:23
The mock atomically swaps .qwen/voice-keyterms.txt from SafeTerm\n to SwapSecret\n (new inode via rmSync + writeFileSync) between lstatSync and openSync in the read path. The post-open fstatSync(fd) dev/ino check in readRegularFileNoFollow was supposed to catch this, but SwapSecret still lands in the returned keyterms list. Concretely, that means the current canonicalizeKeytermsFile → readRegularFileNoFollow handoff has a live TOCTOU window: an attacker (or just a concurrent build task / misbehaving tool) with write access to the workspace can substitute sensitive content between the two syscalls and have it shipped to the remote ASR provider as corpus_text / system-message bias.
The existing Critical comments on voice-keyterms.ts flag the TOCTOU concern conceptually; this comment adds the empirical proof via the PR's own test suite. CI will stay red on Test (ubuntu-latest, Node 22.x) until this is resolved.
Suggested fix: open the file once with O_NOFOLLOW | O_RDONLY before realpathSync, pass the resulting fd through to the reader, and verify dev/ino via fstat on the same fd that is then used for readFileSync. That collapses the lstat → open window to a single syscall. Sketch:
const fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
try {
const stat = fs.fstatSync(fd);
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink > 1 ||
stat.size > MAX_KEYTERMS_FILE_BYTES) return undefined;
// workspace containment via realpath of the fd path (readlink /proc/self/fd/N)
return fs.readFileSync(fd, 'utf-8');
} finally {
fs.closeSync(fd);
}— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
— qwen3.7-max via Qwen Code /review
| const next = chars + term.length + (out.length > 0 ? 1 : 0); | ||
| if (next > MAX_KEYTERMS_CHARS) { | ||
| break; | ||
| } |
There was a problem hiding this comment.
[Suggestion] capKeyterms uses break when a single term exceeds the remaining char budget, which drops all subsequent terms — including shorter ones that would fit. For example, if the remaining budget is 50 chars and the next term is 80 chars followed by ten 4-char terms, all eleven terms are dropped even though the ten short terms would fit.
The term-count cap (out.length >= MAX_KEYTERMS) correctly uses break (once you hit 200 terms, no more can fit regardless of length), but the char-budget cap should skip the oversized term and try the next.
| } | |
| if (next > MAX_KEYTERMS_CHARS) { | |
| continue; | |
| } |
— qwen3.7-max via Qwen Code /review
| function parseKeyterms(content: string): string[] { | ||
| return content | ||
| .split(/\r?\n/) | ||
| .map((line) => line.replace(/#.*$/, '').trim()) |
There was a problem hiding this comment.
[Suggestion] The regex /#.*$/ strips everything from the first # onward, silently truncating terms that legitimately contain #. For a developer-focused feature, common language names like C#, F#, or identifiers like ASP.NET#Core become C, F, ASP.NET respectively. The existing inline comment at line 251 has the analysis backwards — it says inline comments are not stripped, but they are over-stripped.
Consider only treating # as a comment marker when preceded by whitespace or at the start of the line:
| .map((line) => line.replace(/#.*$/, '').trim()) | |
| .map((line) => line.replace(/(?:^|\s+)#.*$/, '').trim()) |
Alternatively, document that # within a term is not supported and recommend C Sharp instead.
— qwen3.7-max via Qwen Code /review
| } | ||
|
|
||
| function buildKeytermsContext(): string | undefined { | ||
| function buildKeytermsContext(settings: LoadedSettings): string | undefined { |
There was a problem hiding this comment.
[Suggestion] The outer try/catch here returns undefined on error, stripping all keyterms — including the hardcoded globals like "Qwen", "TypeScript", "npm". But buildVoiceKeyterms already has its own internal catch-all that guarantees it never throws (it returns [...GLOBAL_KEYTERMS] on any error). The outer catch can only fire if keyterms.join(' ') throws, which is impossible for a string array.
If a future refactor introduces an unexpected throw inside buildVoiceKeyterms, this wrapper silently degrades to zero keyterms instead of the intended graceful degradation to globals. Either remove the redundant try/catch (since buildVoiceKeyterms already guarantees it never throws), or change the catch to return GLOBAL_KEYTERMS.join(' ') as a belt-and-suspenders fallback.
— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No blockers found. One suggestion below on the char budget measurement. The rest of the implementation — security guards, test coverage (83 tests passing), dedup/cap logic, settings integration — looks solid.
— qwen3.7-max via Qwen Code /review
| // degrades gracefully. The static globals always fit, leaving the rest for user | ||
| // terms. | ||
| const MAX_KEYTERMS = 200; | ||
| const MAX_KEYTERMS_CHARS = 2000; |
There was a problem hiding this comment.
[Suggestion] capKeyterms measures the budget in string.length (UTF-16 code units), but the joined string is serialized to UTF-8 for the HTTP request. For non-ASCII terms the byte cost exceeds the code-unit cost: CJK characters consume 3 UTF-8 bytes per code unit, supplementary-plane characters consume 4 bytes per 2 code units. A file of ~2000 CJK characters passes the cap yet produces ~6000 UTF-8 bytes on the wire.
Since the PR description explicitly mentions non-English terms as a use case, consider measuring in UTF-8 bytes:
| const MAX_KEYTERMS_CHARS = 2000; | |
| const MAX_KEYTERMS_CHARS = 2000; | |
| // Budget is in UTF-8 bytes (not code units) — the joined string is serialized | |
| // to UTF-8 for the wire, and non-ASCII terms (CJK etc.) cost more bytes than | |
| // string.length suggests. Buffer.byteLength is used in capKeyterms to match. |
And in capKeyterms, use Buffer.byteLength(term, 'utf-8') instead of term.length.
— qwen3.7-max via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
Independent verification on HEAD 338c6e36d: tsc --noEmit clean, eslint clean, 83/83 voice + schema tests pass — including the TOCTOU race test (run 10×, no flake under the default isolated config). The earlier blocking typecheck-regression report (refineTranscript / InputPrompt.tsx:376 TS2339) does not reproduce on this HEAD — refineTranscript is still present in the voice schema (settingsSchema.ts:421) and that access typechecks. The security hardening (trusted-workspace gate, symlink/hard-link rejection, realpath+isSubpath containment, system-scope forced in-workspace, O_NOFOLLOW + fd re-stat) holds against the documented threat model, and most prior Critical comments are confirmed already-fixed against current code. Two non-blocking test-coverage gaps inline.
— claude-opus-4-8 via Claude Code /qreview
| stat.ino !== expectedStat.ino || | ||
| stat.mode !== expectedStat.mode || | ||
| stat.size !== expectedStat.size || | ||
| stat.mtimeMs !== expectedStat.mtimeMs || |
There was a problem hiding this comment.
[Suggestion] The fd re-stat guard compares six identity fields, but the mtimeMs/ctimeMs branches are effectively dead-untested. I removed both comparisons and all 56 voice tests still pass. The race test (voice-keyterms-race.test.ts) swaps via rmSync+writeFileSync with a different-length payload (SwapSecret\n 11B vs SafeTerm\n 9B), so the swap is already caught by the ino+size checks — the timestamp branches, which are the only thing that catches a same-inode, same-length in-place rewrite, are never exercised. Consider a race variant whose openSync mock rewrites the file in place with identical byte length but different content (e.g. SafeTerm\n → EvilTrmX\n, both 9 bytes), so mtimeMs/ctimeMs is the sole catcher and a regression dropping it turns the suite red.
— claude-opus-4-8 via Claude Code /qreview
| return { | ||
| filePath: isAbsolute ? expanded : path.resolve(workspaceRoot, expanded), | ||
| workspaceRoot, | ||
| mustBeInWorkspace: configured.scope === 'system' || !isAbsolute, |
There was a problem hiding this comment.
[Suggestion] This mustBeInWorkspace asymmetry is security-load-bearing: a user-scoped absolute path is intentionally allowed to resolve outside the workspace, while a system-scoped one is forced inside. Only the rejection side is pinned by a test (voice-keyterms.test.ts:170). The "honors an explicit absolute keytermsFile" test (:106) writes its file inside workspaceDir, so it passes identically whether mustBeInWorkspace is true or false and doesn't lock in the intended behavior. A future change to mustBeInWorkspace: !isAbsolute (also containing user absolutes) would silently break the documented "absolute, or relative to the workspace root" contract with zero test failure. Suggest adding the mirror of the :170 test — a user-scoped absolute path to a file outside workspaceDir, asserting its term is included.
— claude-opus-4-8 via Claude Code /qreview
|
@qwen-code /triage |
✅ Local real-build verification — PR #5817Maintainer verification before merge. This feature has no TUI surface (as the PR states), and an end-to-end mic→ASR run needs a live microphone + a paid Qwen ASR endpoint, so the meaningful "real test" here is exercising the real built code against real filesystem inputs — especially the security mitigations, since a keyterms file's contents are shipped to a remote ASR provider. I ran an independent behavioral harness (my own cases, not the author's tests) against the real
1. Independent behavioral harness — real
|
| # | Scenario (real files) | Observed real output | ✓ |
|---|---|---|---|
| S1 | auto-discovered .qwen/voice-keyterms.txt with custom terms + # comments |
Foobar Widget & AcmeService present; trailing # comment stripped; globals kept; len 34 (31+3) |
✅ headline: custom terms reach the list |
| S2 | .qwen/voice-keyterms.txt symlinked to an out-of-workspace id_rsa |
secret_leaked: false; output is globals-only (len 31) |
✅ exfiltration blocked |
| S3 | same custom file, untrusted workspace | custom term absent; globals-only | ✅ trust gate |
| S4 | keytermsFile from workspace scope vs user scope |
workspace-scope ignored; user-scope honored | ✅ anti-exfil scope rule |
| S5 | 500-term file | capped at len 200 / 1630 bytes (≤200, ≤2000) | ✅ count + byte caps |
| S6 | file qwen / mcp / QWEN / Foobar / foobar |
Qwen & MCP keep canonical casing; no lowercase dup; Foobar once |
✅ case-insensitive dedupe |
| S7 | file >64 KiB | term absent; globals-only | ✅ file-size cap |
| S8 | no file present | globals-only (no throw) | ✅ safe fallback |
| S9 | user-scope relative ../../../../etc/hosts |
rejected; globals-only | ✅ path-traversal guard |
The security-critical claims all hold against real filesystem conditions: a symlink to a secret is rejected (lstat + O_NOFOLLOW), workspace-scope settings can't plant a path, relative paths can't escape the workspace (realpath + isSubpath), oversized files and large lists degrade gracefully, and a missing/bad path always falls back to the globals without throwing.
2. Unit suites — 86 pass
packages/cli, --no-file-parallelism:
voice-keyterms.test.ts(26),voice-keyterms-race.test.ts(2 — incl. a real TOCTOU test: "does not read a keyterms file swapped in before open"),voice-transcriber.test.ts,settingsSchema.test.ts(27) → 86 passed (4 files). The transcriber suite covers the wiring (a custom term reachingkeytermsContext/corpus_textinresolveVoiceStreamConfig).
3. Static checks (PR worktree)
tsc --noEmit(packages/cli) → exit 0eslinton changed files → exit 0prettier --checkon changed files → All matched files use Prettier code style!
4. Real-binary boot
Built the real CLI and booted it with general.voice.keytermsFile set in settings.json and a project-local .qwen/voice-keyterms.txt present → clean start, no schema warning, no "unknown setting", no crash. The new setting integrates without regression.
Coverage note
Not run (out of scope, matching the PR's own statement): a live microphone / ASR end-to-end transcription, and the DashScope fun-asr / paraformer realtime path (separate vocabulary_id mechanism). The keyterm→transport wiring is covered by voice-transcriber.test.ts; this verification independently confirms the file-loading + merge + security logic against real I/O.
Verdict
Reproduces and behaves as described; the security mitigations hold against real filesystem conditions; no regression observed. Tests / typecheck / lint / prettier are green and the binary integrates the setting cleanly. Safe to merge from this verification's standpoint.
🇨🇳 中文版(完整对应)
✅ 本地真实构建验证 — PR #5817
维护者合并前验证。该功能没有 TUI 可见面(PR 自己也这么说),而真机麦克风→ASR 端到端需要实体麦克风 + 付费的 Qwen ASR 端点,所以这里有意义的"真实测试"是用真实构建出的代码跑真实文件系统输入——尤其是安全防护,因为热词文件内容会被发送到远端 ASR 服务商。我跑了一个独立行为 harness(我自己的用例,不是作者的测试)直接驱动真实的 buildVoiceKeyterms,外加完整单测、静态检查、真实二进制启动。
环境: PR head
ee2144f1(merge-basef6c8ee1e),独立 worktree + 独立npm ci。harness 导入真实的buildVoiceKeyterms,对真实临时文件(fs.writeFileSync/fs.symlinkSync)运行。macOS,node v22。
1. 独立行为 harness —— 真实 buildVoiceKeyterms,真实文件
每行都在磁盘上建真实文件并读回函数的真实输出。globals = 31 个内置词(Qwen、MCP…)。
| # | 场景(真实文件) | 观察到的真实输出 | ✓ |
|---|---|---|---|
| S1 | 自动发现 .qwen/voice-keyterms.txt,含自定义词 + # 注释 |
Foobar Widget 和 AcmeService 存在;尾部 # 注释被剥离;globals 保留;len 34(31+3) |
✅ 头号:自定义词进入列表 |
| S2 | .qwen/voice-keyterms.txt 符号链接指向工作区外的 id_rsa |
secret_leaked: false;输出仅 globals(len 31) |
✅ 外泄被阻断 |
| S3 | 同一自定义文件,不可信工作区 | 自定义词缺失;仅 globals | ✅ 信任闸门 |
| S4 | workspace 作用域 vs user 作用域 的 keytermsFile |
workspace 作用域被忽略;user 作用域生效 | ✅ 防外泄作用域规则 |
| S5 | 500 词文件 | 封顶到 len 200 / 1630 字节(≤200,≤2000) | ✅ 数量+字节上限 |
| S6 | 文件 qwen / mcp / QWEN / Foobar / foobar |
Qwen、MCP 保留规范大小写;无小写重复;Foobar 仅一次 |
✅ 大小写不敏感去重 |
| S7 | >64 KiB 文件 | 词缺失;仅 globals | ✅ 文件大小上限 |
| S8 | 无文件 | 仅 globals(不抛错) | ✅ 安全回落 |
| S9 | user 作用域相对路径 ../../../../etc/hosts |
被拒;仅 globals | ✅ 路径穿越防护 |
安全关键的几条主张在真实文件系统条件下全部成立:符号链接指向密钥被拒(lstat + O_NOFOLLOW),workspace 作用域设置无法塞路径,相对路径无法逃出工作区(realpath + isSubpath),超大文件与超长列表优雅降级,缺失/坏路径永远回落到 globals 且不抛错。
2. 单元测试 —— 86 通过
packages/cli,--no-file-parallelism:voice-keyterms.test.ts(26)、voice-keyterms-race.test.ts(2,含真实 TOCTOU 测试:"open 前被换掉的文件不会被读")、voice-transcriber.test.ts、settingsSchema.test.ts(27)→ 86 通过(4 文件)。transcriber 套件覆盖了接线(自定义词进到 resolveVoiceStreamConfig 的 keytermsContext / corpus_text)。
3. 静态检查(PR worktree)
tsc --noEmit(packages/cli)→ exit 0- 改动文件
eslint→ exit 0 - 改动文件
prettier --check→ 全部符合 Prettier 风格!
4. 真实二进制启动
构建真实 CLI 并以 settings.json 里设置了 general.voice.keytermsFile、且项目下存在 .qwen/voice-keyterms.txt 的状态启动 → 干净启动,无 schema 警告、无"unknown setting"、无崩溃。新设置无回归地接入。
覆盖说明
未做(范围外,与 PR 自述一致):真机麦克风 / ASR 端到端转写,以及 DashScope fun-asr / paraformer 实时通道(另一套 vocabulary_id 机制)。热词→通道接线由 voice-transcriber.test.ts 覆盖;本次验证独立确认了文件加载 + 合并 + 安全逻辑对真实 I/O 的行为。
结论
完整复现、行为符合描述;安全防护在真实文件系统条件下成立;未见回归。 测试 / typecheck / lint / prettier 全绿,二进制干净接入该设置。就本次验证而言,可以安全合并。
🤖 Verified locally with the real built code. Method: isolated worktree + self-contained npm ci + an independent behavioral harness driving the real buildVoiceKeyterms against real files (incl. a symlink-to-secret) + unit suites + tsc/eslint/prettier + a real-binary boot.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
|
Hi @qqqys — heads up, this PR now has merge conflicts with Conflicting files:
The conflicts come from #5765 ( |
| function readKeytermsFileSetting( | ||
| settings: LoadedSettings, | ||
| ): KeytermsFileSetting | undefined { | ||
| const system = readKeytermsFileSettingFromScope(settings.system?.settings); |
There was a problem hiding this comment.
[Suggestion] readKeytermsFileSetting returns the system-scoped path immediately (line 229-231) when present, without checking whether the file will actually pass validation downstream. If the system-scoped file is outside the workspace, canonicalizeKeytermsFile rejects it and readUserKeyterms returns [] — the user-scoped setting is never consulted as a fallback.
Consider restructuring so that when the resolved file fails validation, the code falls through to the next scope:
| const system = readKeytermsFileSettingFromScope(settings.system?.settings); | |
| function readKeytermsFileSetting( | |
| settings: LoadedSettings, | |
| ): KeytermsFileSetting | undefined { | |
| const system = readKeytermsFileSettingFromScope(settings.system?.settings); | |
| if (system) { | |
| return { path: system, scope: 'system' }; | |
| } | |
| const user = readKeytermsFileSettingFromScope(settings.user?.settings); | |
| return user ? { path: user, scope: 'user' } : undefined; | |
| } |
Alternatively, move the validation into readKeytermsFileSetting itself and retry with the user scope when the system scope fails. A test covering "system-scoped outside workspace + user-scoped inside workspace" would lock in the expected behavior.
— qwen3.7-max via Qwen Code /review
| @@ -19,4 +60,312 @@ describe('buildVoiceKeyterms', () => { | |||
| expect(terms).not.toContain('qwen-code'); | |||
There was a problem hiding this comment.
[Suggestion] The test suite has strong coverage for the main paths, but a few edge cases are untested:
- CRLF line endings:
parseKeytermssplits on/\r?\n/but all test fixtures use\nonly. A test with'Kubernetes\r\nGraphQL\r\n'would guard against regressions. - Empty string
keytermsFile:readKeytermsFileSettingFromScopetrims and rejects empty strings, but no test passeskeytermsFile: ''to verify fallthrough to auto-discovery. - Missing workspace:
resolveKeytermsFilereturnsundefinedwhensettings.workspace?.pathis absent, but no test exercises this path.
Each is a small addition that pins behavior for realistic edge cases.
— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Code Review Summary
5 findings (2 Suggestions, 2 Needs Human Review, 1 Critical withdrawn after verification)
Suggestions
-
Missing tests for two edge cases (voice-keyterms.ts line 56)
resolveKeytermsFilesreturning[]when no workspace path exists is never exercised- Schema default
keytermsFile: ''(empty string) is never tested — should fall through to auto-discovery
-
Zero diagnostic logging (voice-keyterms.ts line 89)
- All functions silently return
[]on failure - Users who configure
keytermsFilehave no way to discover why terms aren't loading - Add
createDebugLogger('VOICE_KEYTERMS')consistent with neighboring services
- All functions silently return
Needs Human Review
isKeytermEchothresholdMIN_KEYTERM_SET_ECHO_RATIO = 0.3calibrated for 31-term global set; degrades when user keyterms are merged (potential 200+ terms)nlink > 1hardlink check is inert on Windows (NTFS always reports nlink=1)
Security Review
The TOCTOU race prevention (lstat + O_NOFOLLOW + fd re-stat), symlink/hardlink rejection, isSubpath containment, and trusted workspace gate are well-implemented. No security issues found.
Verified as Correct
- Settings wiring:
buildKeytermsContext(args.settings)correctly passes settings at both call sites - UTF-8 byte accounting via
Buffer.byteLength— already fixed parseKeytermsregex preservingC#/F#— already fixed
| return [...GLOBAL_KEYTERMS]; | ||
| const DEFAULT_KEYTERMS_FILENAME = 'voice-keyterms.txt'; | ||
| const MAX_KEYTERMS = 200; | ||
| const MAX_KEYTERMS_BYTES = 2000; |
There was a problem hiding this comment.
[Suggestion] Two untested edge cases:
-
The
if (!workspacePath) { return []; }early-return inresolveKeytermsFilesis never exercised — all tests provide a valid workspace path. This is a realistic scenario (CLI run outside a project). -
The schema default for
keytermsFileis''(empty string), but no test verifies what happens when a user has this default. ThereadKeytermsFileSettingFromScopefunction filters it out (trimmed.length > 0 ? trimmed : undefined), falling through to auto-discovery — but this path should be explicitly tested to prevent regressions.
| bytes + Buffer.byteLength(term, 'utf8') + (out.length > 0 ? 1 : 0); | ||
| if (next > MAX_KEYTERMS_BYTES) { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
[Suggestion] Every function in this file silently returns [] on failure. Users who explicitly configure keytermsFile have no way to discover why their terms aren't loading — symlink rejection, containment failure, file-too-large, parse error all produce the same silence.
Consider adding createDebugLogger('VOICE_KEYTERMS') (consistent with voice-transcriber.ts and other services) and logging at warn level when a configured path fails validation, and at debug level for auto-discovery misses.
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
[Suggestion] isKeytermEcho detection sensitivity degrades with user keyterms — keytermRatio >= 0.3 where keytermRatio = overlap / keyset.size was tuned for ~32 globals. With 200 user keyterms, the same 10 echoed tokens yield 10/200 = 0.05, well below threshold. Partial echoes of the bias list slip past as garbled transcripts. Consider making the threshold scale-invariant (e.g., absolute overlap count or adaptive ratio based on keyset size). (This finding could not be mapped to a diff line — isKeytermEcho at voice-transcriber.ts:429 is unchanged code.)
— qwen3.7-max via Qwen Code /review
| ) { | ||
| return undefined; | ||
| } | ||
| return fs.readFileSync(fd, 'utf-8'); |
There was a problem hiding this comment.
[Critical] stat.size-only enforcement of MAX_KEYTERMS_FILE_BYTES is bypassable via Linux pseudo-filesystems. /proc files report stat.size = 0 via both lstat and fstat, but readFileSync(fd, 'utf-8') returns actual content. A user-scope absolute path to /proc/self/environ passes all guards (isFile=true, nlink=1, size=0, mustBeInWorkspace=false) and returns environment variables — including secrets like API keys — as keyterms to the ASR provider.
| return fs.readFileSync(fd, 'utf-8'); | |
| const content = fs.readFileSync(fd, 'utf-8'); | |
| if (Buffer.byteLength(content, 'utf-8') > MAX_KEYTERMS_FILE_BYTES) { | |
| return undefined; | |
| } | |
| return content; |
— qwen3.7-max via Qwen Code /review
| if (content === undefined) { | ||
| continue; | ||
| } | ||
| return parseKeyterms(content); |
There was a problem hiding this comment.
[Suggestion] Scope fallback short-circuited by empty-after-parse files — when a system-scoped keyterms file contains only comments/blank lines, parseKeyterms returns [] (not undefined), and this return exits immediately without consulting user-scope files. A comment-only system file silently suppresses user-scope keyterms.
| return parseKeyterms(content); | |
| const parsed = parseKeyterms(content); | |
| if (parsed.length > 0) { | |
| return parsed; | |
| } |
— qwen3.7-max via Qwen Code /review
| typeof fs.constants.O_NOFOLLOW === 'number' | ||
| ? fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | ||
| : fs.constants.O_RDONLY; | ||
| fd = fs.openSync(filePath, flags); |
There was a problem hiding this comment.
[Suggestion] FIFO TOCTOU gap can hang the event loop — between lstatSync (isFile()=true) and this openSync(O_RDONLY | O_NOFOLLOW), a local attacker can replace the file with a FIFO via rename(). O_NOFOLLOW only blocks symlinks on Linux; openSync on a FIFO with no writer blocks the Node.js event loop indefinitely. Consider adding O_NONBLOCK to the open flags, or rejecting paths whose parent directory is writable by others.
— qwen3.7-max via Qwen Code /review
|
@qwen-code /triage |
| function parseKeyterms(content: string): string[] { | ||
| return content | ||
| .split(/\r?\n/) | ||
| .map((line) => line.replace(/(?:^|\s+)#.*$/, '').trim()) |
There was a problem hiding this comment.
[Suggestion] The regex /(?:^|\s+)#.*$/ silently truncates terms containing # preceded by whitespace. Issue #42 → Issue, PR #5817 → PR, Section #3 → Section. Users naturally write issue references and version labels — exactly the domain-specific terms this feature exists to bias toward.
Restricting to full-line comments preserves Issue #42 and C#/F# while still stripping # project terms and # indented comment:
| .map((line) => line.replace(/(?:^|\s+)#.*$/, '').trim()) | |
| .map((line) => line.replace(/^\s*#.*$/, '').trim()) |
— qwen3.7-max via Qwen Code /review
| transcriptRatio >= 0.9 && | ||
| keytermRatio >= MIN_KEYTERM_SET_ECHO_RATIO; | ||
| (keytermRatio >= MIN_KEYTERM_SET_ECHO_RATIO || | ||
| overlap >= MIN_ABSOLUTE_KEYTERM_ECHO_TOKENS); |
There was a problem hiding this comment.
[Suggestion] isKeytermEcho tokenizes the keyterms context via tokenize(), which strips non-alphanumeric characters and splits on whitespace. Multi-word keyterms ("pull request", "machine learning") become individual tokens, inflating keyset.size from ~31 globals to 200+ fragments. Compound terms ("pre-commit", "end-to-end") are similarly fragmented.
With the new || overlap >= MIN_ABSOLUTE_KEYTERM_ECHO_TOKENS branch now being the dominant detection path for large keysets (the ratio check becomes impossible to trigger when keyset.size is 200+), the inflated keyset makes the absolute threshold more prone to false positives from common-word fragment matches. All existing tests use only single-word keyterms, so this mismatch is untested.
Consider passing the keyterms array instead of the joined string, or adding tests with multi-word and compound keyterms to pin the expected behavior.
— qwen3.7-max via Qwen Code /review
| overlap >= MIN_KEYTERM_ECHO_TOKENS && | ||
| transcriptRatio >= 0.9 && | ||
| keytermRatio >= MIN_KEYTERM_SET_ECHO_RATIO; | ||
| (keytermRatio >= MIN_KEYTERM_SET_ECHO_RATIO || |
There was a problem hiding this comment.
[Suggestion] The debug log at line 433 records only transcriptRatio and keytermRatio, but not overlap or keyset.size. When the new absolute-overlap branch fires (which becomes the dominant path with large keysets), the log shows a low keytermRatio (e.g., 0.05) that appears inconsistent with a "likely echo" diagnosis — making the root cause invisible without instrumenting and redeploying.
Include the missing fields and indicate which branch matched:
| (keytermRatio >= MIN_KEYTERM_SET_ECHO_RATIO || | |
| overlap >= MIN_ABSOLUTE_KEYTERM_ECHO_TOKENS); | |
| if (isEcho) { | |
| const branch = keytermRatio >= MIN_KEYTERM_SET_ECHO_RATIO ? 'ratio' : 'absolute'; | |
| debugLogger.debug( | |
| `[voice] dropped likely keyterm echo (${branch}): overlap=${overlap} keysetSize=${keyset.size} transcriptRatio=${transcriptRatio.toFixed(2)} keytermRatio=${keytermRatio.toFixed(2)} text="${transcript}"`, | |
| ); |
— qwen3.7-max via Qwen Code /review
Maintainer verification — local real-environment test of this PRI verified this PR end‑to‑end on Linux (the platform the PR table marks Verdict: ✅ Looks correct, safe, and ready to merge. All four PR-touched suites pass on Linux, every security gate is a genuine guard (proven by toggling it off), and the shipped (compiled) artifact carries a custom keyterm onto the wire while blocking every exfiltration vector. Two non-blocking notes at the end. 1) Unit tests on Linux
(The PR body says "71 tests"; the suite is actually 96 across those 4 files — the count predates the 2) A/B proof that each security gate is real (not a tautological test)For every gate I neutralized only that gate in
Source was confirmed pristine ( Bonus finding — the size and symlink defenses are layered (good). Disabling the two 3) End-to-end on the compiled artifact (shipped
|
| 范围 | 文件 | 用例 | 结果 |
|---|---|---|---|
PR 改动的套件(voice-keyterms、voice-keyterms-race、voice-transcriber、settingsSchema) |
4 | 96 | ✅ 全过 |
| 整个 voice 相关回归扫描 | 18 | 251 | ✅ 全过 —— 无连带破坏 |
(PR 正文写「71 个用例」,实际是 4 个文件 96 个 —— 这个数字早于新增的 voice-keyterms-race.test.ts。覆盖更多,不是问题。)
2) A/B 证明每道安全闸门是「真闸门」(而非空测试)
对每道闸门,我只在 voice-keyterms.ts 里把这一处关掉(vitest 直接跑源码 TS,无需重新构建),重跑对应测试,再还原。如果一个测试在闸门被移除后仍然通过,说明它其实没在测东西 —— 而这些都如期变红:
| 安全闸门 | 守护测试 | 原始 | 关闭闸门后 |
|---|---|---|---|
| 不可信工作区闸门 | …untrusted workspace |
✅ 通过 | 🔴 失败 |
| 工作区包含校验(realpath 子路径) | 逃逸根目录 · system 域指向区外 · 软链接 .qwen |
✅ 通过(×3) | 🔴 失败(×3) |
TOCTOU fstat 二次校验 |
race:open 前被换 · 原地改写 | ✅ 通过(×2) | 🔴 失败(×2) |
软链接拒绝(~/.ssh/id_rsa 攻击向量) |
…symlinked keyterms file |
✅ 通过 | 🔴 失败 |
硬链接拒绝(nlink > 1) |
…hard-linked keyterms file |
✅ 通过 | 🔴 失败 |
| 文件大小上限(64 KiB) | …larger than the size cap |
✅ 通过 | 🔴 失败 |
矩阵跑完后确认源码已还原干净(git status clean)。
额外发现 —— 大小与软链接防护是多层的(很好)。 只关掉两处 stat.size 检查并不能让大小上限测试失败:读取后的 Buffer.byteLength(content) 检查仍会拒绝超大文件,必须三层全关才会变红。软链接同理:必须同时移除 lstat 的软链接/常规文件检查和 fstat 二次校验,密钥才会泄漏。纵深防御成立。
3) 在编译产物上端到端(发布的 dist/ JS,真实磁盘文件)
重新构建 cli 包,导入真实编译后的 resolveVoiceStreamConfig / transcribeVoiceAudio / buildVoiceKeyterms(正是 TUI 和 serve 守护进程调用的函数),对真实文件运行。16/16 断言通过:
- ✅ 实时通道:自动发现的
.qwen/voice-keyterms.txt热词进入corpus_text;#注释被剥离;全局词合并。 - ✅ 批处理通道:自定义热词进入 system 消息(对捕获到的外发 POST body 做断言)。
- ✅ 不可信工作区 → 自定义热词被丢弃,仍下发全局词(优雅降级)。
- ✅ 软链接默认文件 → 密钥不会被外泄。
- ✅ 区外绝对路径:system 域被拦截、user 域被允许(显式选择加入)—— 这个不对称是对的。
- ✅ 大小写不敏感去重并保留全局原始大小写;术语数(200)与总字节(2000)两个上限都成立。
- ✅ 向后兼容:零参数
buildVoiceKeyterms()仍返回全局词。
4) 真实 loadSettings + 文件夹信任链路
第 3 部分是手搓的 settings 对象;这里驱动真实的已构建 settings 加载器与真实的文件夹信任子系统(隔离的 HOME + QWEN_CODE_TRUSTED_FOLDERS_PATH)。8/8 通过:
- ✅ 默认工作区 →
isTrusted=true→ 热词生效。 - ✅ 开启
folderTrust+DO_NOT_TRUST→isTrusted=false→ 热词被丢弃(真实闸门)。 - ✅ 开启
folderTrust+TRUST_FOLDER→isTrusted=true→ 热词生效。
5) 在 Linux 复现 PR 自述的检查
- ✅
tsc --noEmit(cli)—— 干净。 - ✅ 对全部 8 个改动文件
prettier --check—— 干净。
范围 / 诚实说明
无头环境无法做「真实麦克风 → 在线 ASR」的运行,因此我没有驱动物理录音器。但我用真实 settings 运行了录音器喂入的同一批函数(transcribeVoiceAudio / resolveVoiceStreamConfig),并捕获了将要外发的请求。守护进程路由 POST /workspace/voice/transcribe 调用的正是同一个 transcribeVoiceAudio;且 serve 本身未被本 PR 改动。
不阻塞的小建议
- 描述里的用例数(71)已过期 —— 现在是 96,建议更新或干脆不写具体数字。
- 一处值得加一行代码注释的设计: workspace 域的
keytermsFile被刻意忽略(只读system+user域),这样仓库里 check-in 的.qwen/settings.json无法重定向路径。这个取舍是对的 —— 在readKeytermsFileSettings处加一句注释能省去下一个读者的疑惑。
整体做得很扎实,安全姿态确实到位。
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No review findings. Downgraded from Approve to Comment: CI still running.
— qwen3.7-max via Qwen Code /review
What this PR does
Lets users extend the voice-dictation keyterm vocabulary with their own terms. Until now the ASR bias list was a hardcoded set of generic developer terms with no way to override it. This adds an optional
general.voice.keytermsFilesetting that points at a keyterms file (absolute, or relative to the workspace root), and when it is unset the project-local.qwen/voice-keyterms.txtis auto-loaded if present. The file is one term per line with#comments and blank lines ignored; its terms merge with the built-in globals, are deduped case-insensitively (globals keep their canonical casing), and are capped by term count and total length so a large file degrades gracefully. The merged list reaches the Qwen ASR realtime transport ascorpus_textand the batch transport as a leading system message — exactly where the global list was already wired.Why it's needed
Domain-specific proper nouns — product names, internal service names, non-English terms — are frequently mis-transcribed, and there was no supported way to bias the ASR toward them. The underlying Qwen ASR API already accepts a vocabulary-biasing hint and the CLI already plumbs the global keyterm list into both transports, so the only missing piece was a user-facing way to contribute terms.
Reviewer Test Plan
How to verify
Run the unit suites:
npx vitest run packages/cli/src/ui/voice/voice-keyterms.test.ts packages/cli/src/ui/voice/voice-transcriber.test.ts packages/cli/src/config/settingsSchema.test.ts— 71 tests pass. They cover auto-discovery of.qwen/voice-keyterms.txt, an explicit absolute or relativekeytermsFilewinning over auto-discovery, comment/blank-line parsing (including indented comments), case-insensitive dedupe preserving the global casing, missing-file fallback to globals, the term-count and total-length caps (each exercised independently), the trusted-workspace gate, symlink rejection, and the file-size cap, plus an end-to-end check that a custom term actually reacheskeytermsContextinresolveVoiceStreamConfig.npm run typecheckfor the cli package andprettier --checkon the changed files are both clean.Optional manual check: select a Qwen ASR voice model (
qwen3-asr-flash-realtimeorqwen3-asr-flash), create.qwen/voice-keyterms.txtcontaining a term you usually get wrong, dictate it, and confirm it now transcribes correctly.Evidence (Before & After)
N/A — no user-visible TUI change. The behavior is verified by the unit suites described above.
Tested on
Environment (optional)
Unit tests +
tsc --noEmitonly (npx vitest run), on macOS. No live microphone / ASR end-to-end run was performed.Risk & Scope
~/.ssh/id_rsa) that would exfiltrate on the next dictation. Reading never throws — a bad path falls back to the built-in globals, so dictation cannot be broken by it.fun-asr/paraformerrealtime transport (those models bias via a stateful pre-registeredvocabulary_id, not an inline hint, which is a separate larger change); and a live microphone/ASR end-to-end run.buildVoiceKeytermsgains an optional parameter (existing zero-arg callers are unaffected) and the new setting defaults to empty.Linked Issues
Closes #5816
中文说明
这个 PR 做了什么
让用户能用自己的术语扩展语音听写的热词表。此前 ASR 偏置词表是一份写死的通用开发词汇,无法覆盖。本次新增可选设置
general.voice.keytermsFile,指向一个热词文件(绝对路径,或相对工作区根目录);未配置时,若项目下存在.qwen/voice-keyterms.txt则自动加载。文件格式为一行一个术语,#注释与空行忽略;术语与内置全局表合并,大小写不敏感去重(全局表保留其原始大小写),并按术语数量与总长度封顶,使大文件优雅降级。合并后的列表在 Qwen ASR 实时通道作为corpus_text下发、在批处理通道作为前置 system 消息下发 —— 正是全局表原本已接入的位置。为什么需要
领域内的专有名词(产品名、内部服务名、非英文术语)经常被识别错,而此前没有受支持的方式去引导 ASR。底层 Qwen ASR API 本就接受热词偏置提示,CLI 也已把全局热词表接入两个通道,唯一缺的就是一个让用户贡献术语的入口。
评审验证计划
如何验证
跑单元测试:
npx vitest run packages/cli/src/ui/voice/voice-keyterms.test.ts packages/cli/src/ui/voice/voice-transcriber.test.ts packages/cli/src/config/settingsSchema.test.ts—— 71 个用例通过。覆盖:.qwen/voice-keyterms.txt自动发现、显式绝对/相对keytermsFile胜过自动发现、注释/空行解析(含带缩进的注释)、大小写不敏感去重并保留全局大小写、文件缺失回落到全局表、术语数量与总长度两个上限(各自独立触发)、可信工作区闸门、符号链接拒绝、文件大小上限,以及一个端到端检查:自定义术语确实进到resolveVoiceStreamConfig的keytermsContext。cli 包的npm run typecheck与改动文件的prettier --check均通过。可选手动验证:选一个 Qwen ASR 语音模型(
qwen3-asr-flash-realtime或qwen3-asr-flash),建一个.qwen/voice-keyterms.txt写入你常被识别错的术语,念一遍,确认现在能正确转写。证据(前后对比)
N/A —— 无用户可见的 TUI 变化。行为由上述单元测试验证。
测试平台
仅 macOS 上跑了单元测试 +
tsc --noEmit;Windows / Linux 未测;未做真机麦克风 / ASR 端到端验证。运行环境(可选)
仅单元测试 +
tsc --noEmit(npx vitest run),macOS。未做真机麦克风 / ASR 端到端运行。风险与范围
~/.ssh/id_rsa之类密钥的符号链接)在下次听写时外泄。读取永不抛错 —— 路径有问题就回落到内置全局表,不会弄坏听写。fun-asr/paraformer实时通道(这些模型走的是预注册vocabulary_id的有状态机制,而非内联提示,属另一块更大的改动);以及真机麦克风 / ASR 端到端验证。buildVoiceKeyterms仅新增一个可选参数(原有零参调用不受影响),新设置默认为空。关联 Issue
Closes #5816