fix(core): Sanitize internal daemon secrets from shell subprocess environments#6606
fix(core): Sanitize internal daemon secrets from shell subprocess environments#6606jadelike-wine wants to merge 21 commits into
Conversation
…tive with ACP child Extract the env-scrub helper from acp-bridge into core as shared `scrubChildEnv` and `collectSensitiveShellEnvKeys`, and use both to strip secret-semantic env vars (API keys, tokens, credentials, passwords, private keys, …) before spawning the shell subprocess. The model's own commands run there — including auto-allowed read-only `printenv`/`env` — so inherititing the daemon's env verbatim would let a `printenv QWEN_SERVER_TOKEN` exfiltrate tokens straight into the tool result. The ACP child deliberately keeps a NARROW scrub set because it is itself a `qwen` agent that MUST inherit provider API keys to call models; the two paths share one primitive but NOT one denylist — unifying them would reintroduce the leak or break the child. `collectSensitiveShellEnvKeys` drives the shell path's BROAD set, parametrized on the live `process.env`.
…scrub primitive(QwenLM#6601) Drop the broad user-credential denylist (GH_TOKEN, AWS_*, NPM_TOKEN, …) from child-env scrubbing — those belong to normal shell/MCP workflows and were wrongly stripped. Now only daemon/internal Qwen keys are stripped, and the scrub/collect primitive is shared between core (ACP/shell/MCP) and desktop (session-tools-core / mcp client) subprocess paths.
|
Qwen precheck requires maintainer approval before automated triage/review. Head SHA: Reason:
A maintainer with write access can inspect the PR and manually request a run with |
Windows environment variables are case-insensitive, so PATH and Path can both exist. Normalize them before scrubbing so discovery + execution child processes receive a single merged PATH and never a split/partial value. Co-Authored-By: Claude Opus 4.7 <[email protected]>
… subprocess env Untrusted scripts spawned by the desktop session tools were inheriting provider API keys. Block LLM/AWS/GitHub/Google/Stripe/NPM credential keys in the sanitized environment alongside existing daemon-internal scrubbing. Co-Authored-By: Claude Opus 4.7 <[email protected]>
… sandbox subprocess env" This reverts commit 1530d8b.
wenshao
left a comment
There was a problem hiding this comment.
[Critical] packages/desktop/packages/shared/src/mcp/validation.ts:309 — The production StdioClientTransport at line 309 uses unsanitized { ...processEnv, ...env } while the probe at line 271 (changed by this diff) correctly uses createSanitizedChildEnv. The MCP handshake subprocess inherits QWEN_SERVER_TOKEN.
— qwen3.7-max via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
Not reviewed: reverse audit of chunks 2, 3, 5, 8, 11 — the auditors failed to execute (subagent execution failed).
Not reviewed: reverse audit — its prompt was built, but no agent was launched with it that opened its brief, so the reverse-audit pass did not run.
[Critical] packages/cli/src/serve/run-qwen-serve.ts:3220 — persistDisabledSkillsFn calls loadSettings(workspace) without credentialStore. This is the only remaining daemon call site that omits it. When loadSettings triggers loadEnvironment, any QWEN_CUSTOM_API_KEY_* in workspace .env files is written back to process.env (since credentialStore is undefined, writeEnvKey falls back to process.env), re-entering the daemon's OS-visible environment after scrubDaemonProcessEnv removed it. Fix: pass { credentialStore } to the loadSettings call.
— qwen-latest-series-invite-beta-v92 via Qwen Code /review
…ests - run-qwen-serve.ts: persistDisabledSkillsFn passes credentialStore to loadSettings so workspace .env QWEN_CUSTOM_API_KEY_* is not written back to process.env after scrubDaemonProcessEnv - qwen-agent.ts: extract buildQwenAcpEnv() exported helper from private SharedQwenAcpProcess.buildEnv so the env-scrub path is unit-testable - validation.test.ts: preserve spawnSync when mocking child_process by spreading realChildProcess before overriding spawn - resolve-voice-config.ts: drop dead credentialProvider param from buildModelsConfig; voice path uses mergeCredentialStore instead - install.test.ts: cover credential-store redirection and rollback on refreshAuth failure - modelsConfig.ts: debug message references credential provider instead of process.env Addresses wenshao review on PR QwenLM#6606 (2026-07-18).
|
@qwen-code /resolve |
…nv features Merge origin/main into pr-6606 branch, resolving conflicts in: - run-qwen-serve.ts: keep both credentialStore (PR) and skillInstallEnv (main) properties in all three workspace service creation sites - workspace-skills-status.ts: combine PR's credentialStore passthrough with main's invalidate method on WorkspaceSkillsStatusProvider
|
Qwen Code resolved the merge conflicts and pushed the branch update. Merge Conflict Resolution Summary — PR #6606Branch: Conflicted Files1.
|
🩺 serve daemon A/BBuilt the PR base vs this PR head ✅ No response changes against the PR base across 4 scenario(s). — Qwen Code · serve A/B |
|
@qwen-code /resolve |
…runtime isolation
|
Qwen Code resolved the merge conflicts and pushed the branch update. Merge Conflict Resolution SummaryPR: #6606 — fix(core): Sanitize internal daemon secrets from shell subprocess environments Conflicted file
|
Round-5 re-verification @
|
| # | Finding (round 4) | Status |
|---|---|---|
| 1 | editor.test.ts not updated → ubuntu CI red |
✅ Fixed — 152 tests pass locally; CI test steps now pass on head |
| 2 | Terminal-editor spawnSync unscrubbed |
✅ Fixed (editor.ts:337) |
| 3 | prepareSettingWrite re-leak (no credentialStore) |
❌ Still present — see Critical A |
| 4 | setWorkspaceSkillEnabled re-leak |
✅ Fixed (workspace-service/index.ts:708) |
| 5 | Qualified voice routes missing store | ✅ Fixed — createRuntimeVoiceDeps merges per request |
| 6 | Qualified voice WS missing store | ✅ Fixed — per-connection merge in server.ts |
| 7 | setWorkspaceVoiceSettings validation env |
✅ Fixed — store snapshot merged before validateWorkspaceVoiceState |
| 8 | Desktop mcp/client.ts drops user config.env credentials |
✅ Fixed + test |
| 9 | Desktop mcp/validation.ts same override-drop |
✅ Fixed + tests (explicit GITHUB_TOKEN covered) |
| 10 | Inert ConfigParameters.credentialProvider plumbing |
✅ Fixed — removed |
| 11 | LspServerManager.test.ts leaked env stubs |
✅ Fixed — vi.unstubAllEnvs() |
Round-3 Critical (persistDisabledSkillsFn without store) is also fixed, with a non-vacuous test (run-qwen-serve.test.ts writes a workspace .env custom key and asserts process.env stays clean).
[Critical A] prepareSettingWrite still re-leaks custom keys into the daemon's OS-visible env
packages/cli/src/serve/routes/workspace-settings.ts:185 — unchanged from my previous review:
const existing =
loadSettings(workspace).forScope(scope).settings.mcpServers ?? {};The reply on that thread says "Fixed in the updated persistence path", but the fix only covered the persistSetting/persistSettings closures in run-qwen-serve.ts. prepareSettingWrite is a different call site and is reached from both the primary route (workspace-settings.ts:391, POST /workspace/settings) and the qualified route (workspace-settings.ts:581) whenever the key is mcpServers.
Failure scenario, unchanged from before: daemon boots → scrubDaemonProcessEnv() removes QWEN_CUSTOM_API_KEY_* from process.env → a client writes the mcpServers setting → loadSettings(workspace) runs loadEnvironment(…, undefined) → for a custom key present in the workspace .env or settings.env (which is exactly where applyProviderInstallPlan persists installed provider keys), readEnvKey sees it unset and writeEnvKey(key, value, undefined) falls back to process.env — the credential re-enters /proc/<pid>/environ / ps eww, defeating the boot scrub.
Fix: thread credentialStore into prepareSettingWrite and call loadSettings(workspace, { credentialStore }) — or { skipLoadEnvironment: true }, since this call only reads mcpServers from settings scopes and doesn't need env application at all.
[Critical B] check:serve-fast-path-bundle fails on this head — qqqys's blocker is unaddressed
packages/cli/src/serve/run-qwen-serve.ts:48-51 still has the module-scope value import:
import {
createCredentialStore,
createCredentialProvider,
} from '@qwen-code/qwen-code-core';run-qwen-serve.ts is one of the checker's SERVE_PRE_LISTEN_ROOTS, and the core public barrel statically re-exports from forbidden modules (e.g. packages/core/src/index.ts:175 re-exports from ./tools/shell.js) and drags the forbidden vendor packages. This is not hypothetical — on this exact head (2de8c5d13), Test (ubuntu-latest, Node 22.x) now gets past all test steps (the editor.test.ts fix worked) and fails at precisely the "Check serve fast-path bundle closure" step:
Serve fast-path bundle closure includes pre-listen runtime modules:
- glob vendor package
input: node_modules/glob/node_modules/brace-expansion/index.js
static path: dist/chunks/run-qwen-serve-K6TPFIRT.js -> dist/chunks/chunk-P4NFORE5.js
- @iarna/toml vendor package
input: node_modules/@iarna/toml/lib/parser.js
static path: dist/chunks/run-qwen-serve-K6TPFIRT.js -> dist/chunks/chunk-P4NFORE5.js
... (5,747,889-byte chunk pulled into the pre-listen closure)
The type-only imports (CredentialStore) are fine — they erase at compile time. The value import is the problem. Since captureDaemonCredentialStore() runs pre-listen, a post-listen dynamic import isn't the right shape either. credential-provider.ts is dependency-free (pure functions over a Map), so the clean fixes are: add a slim subpath export (e.g. @qwen-code/qwen-code-core/credentials → models/credential-provider.js) and import from that, or lift the three tiny factories into a module the fast path is allowed to reach.
New findings (this round)
[Important] ide-client.ts:1083 widens the IDE stdio child env instead of narrowing it. On main, new StdioClientTransport({ command, args }) passes no env, so the MCP SDK uses getDefaultEnvironment() — only HOME/LOGNAME/PATH/SHELL/TERM/USER (plus the Windows equivalents), i.e. zero credentials. This PR changes it to the full process.env minus the 4 internal Qwen keys, so the IDE companion child now inherits OPENAI_API_KEY, GH_TOKEN, AWS_*, and everything else it never saw before — the opposite of this PR's goal, and the updated test (ide-client.test.ts asserting OPENAI_API_KEY is preserved) locks the widening in. Fix: drop the env option (the SDK default is already credential-free), or pass scrubChildEnv(getDefaultEnvironment(), …) if you want the scrub to be explicit.
[Suggestion] Primary-workspace voice env is a boot-time snapshot; qualified routes re-merge per request. In createServeApp the primary route gets env: mergeCredentialStore(getRuntimeEffectiveEnv(primaryRuntime.env), deps.credentialStore) evaluated once, and workspace-voice.ts then uses that frozen deps.env for validation (:448) and transcription (:568). A provider installed or a key rotated after boot updates the store but never reaches the primary route's env, while /workspaces/:workspace/voice/* rebuilds via createRuntimeVoiceDeps per request and stays fresh. In practice readApiKey's settings.env fallback rescues installed providers, so this is consistency rather than breakage — but a per-request merge (mirroring createRuntimeVoiceDeps) would remove the asymmetry. Related nit: resolve-voice-config.ts:96 still calls loadSettings(workspaceCwd, options.env ? { skipLoadEnvironment: true } : true) without credentialStore; harmless today because every daemon caller supplies env, but it's the same class as Critical A if a daemon path ever calls it without one.
[Suggestion] Desktop ACP children lose QWEN_CUSTOM_API_KEY_* with no re-injection path. buildQwenAcpEnv (desktop qwen-agent.ts) scrubs the custom-key pattern and scrubChildEnv refuses override re-introduction — but unlike the CLI daemon's ACP spawn (which injects credentialStore.snapshot() into sourceEnv precisely because ACP children are agents that call models), the desktop has no equivalent. The child normally recovers the keys from its own settings.env at boot, so the standard install flow works; a key that exists only in the parent environment (manually exported) now silently breaks custom-provider auth in desktop sessions. Worth a comment in buildQwenAcpEnv documenting the recovery assumption, or aligning with the ACP policy of the CLI path.
Verification evidence
- Head greps confirming each fixed call site (
workspace-service/index.ts:508/515/522/592/669/708, all ~20loadSettingssites inrun-qwen-serve.tscarrycredentialStore, qualified voice/WS merges, desktop client/validation override order). - Local test runs on the PR head:
editor.test.ts152 ✅,child-env-scrub.test.ts11 ✅,credential-provider.test.ts29 ✅,child-env-scrub.integration.test.ts(realnode -espawns throughShellExecutionService, both PTY and child_process paths) 7 ✅, acp-bridgespawnChannel.test.ts32 ✅. - CI on head
2de8c5d13: every test step green; sole failure is the fast-path closure step quoted above.
The scrub direction is right on every other spawn path I audited (each strictly narrows the child env), the two-context ACP-vs-shell policy documentation in spawnChannel.ts is genuinely useful, and the test coverage — Windows case-insensitivity, override-smuggling, real-spawn integration — is exemplary. Once A and B are fixed (both are small, well-localized changes) this is mergeable from my side.
中文摘要
第 5 轮复核(head 2de8c5d13):上一轮 11 条 findings 中 10 条已修复并验证通过(含 editor 测试、终端编辑器 scrub、语音 qualified 路由、桌面端 MCP override 保留等)。仍有 2 条阻塞项:
-
[Critical A]
workspace-settings.ts:185的prepareSettingWrite仍以无credentialStore的方式调用loadSettings(workspace)。回复说"已修复",但实际只修了run-qwen-serve.ts里的persistSetting闭包,这个调用点没改。写入mcpServers设置(主路由:391与 qualified 路由:581均可触发)会经loadEnvironment(…, undefined)把 workspace.env/settings.env中的QWEN_CUSTOM_API_KEY_*写回守护进程的process.env,重新暴露给ps eww//proc/<pid>/environ,绕过本 PR 的启动时清理。修复:传{ credentialStore }或{ skipLoadEnvironment: true }。 -
[Critical B] qqqys 指出的 fast-path 阻塞项未处理:
run-qwen-serve.ts:48-51仍在模块顶层以值导入方式引入 core barrel。当前 head 的 CI 已实际复现:Test (ubuntu)所有测试步骤通过后,恰好在 "Check serve fast-path bundle closure" 一步失败(run-qwen-serve-K6TPFIRT.js把 glob、@iarna/toml等禁止的 vendor 包拖进 5.7MB 的 pre-listen chunk)。credential-provider.ts本身零依赖,建议增加 slim 子路径导出(如@qwen-code/qwen-code-core/credentials)或把这三个小工厂函数移到 fast path 允许到达的模块。
新发现:[Important] ide-client.ts:1083 把 IDE stdio 子进程环境从 SDK 默认的最小集合(约 6 个无凭据变量)扩大为完整 process.env 减 4 个内部键 —— 与本 PR 目标相反,OPENAI_API_KEY/GH_TOKEN/AWS_* 等首次暴露给该子进程;建议去掉 env 选项或改为 scrub getDefaultEnvironment()。另有两条 Suggestion:主 workspace 语音路由的 env 是启动时快照(qualified 路由是每请求合并,存在不一致);桌面端 ACP 子进程剥离 QWEN_CUSTOM_API_KEY_* 且无重注入机制(正常安装流程可经 settings.env 恢复,仅手动 export 的场景会静默失效)。
本地验证:editor 152 / scrub 11 / credential-provider 29 / 真实 spawn 集成 7 / spawnChannel 32 全部通过。A、B 两处改动都很小,修复后我这边即可合入。
Allow the serve layer to pass a credentialStore into loadSettings so workspace-scoped MCP server configuration can resolve stored credentials instead of relying on the default resolution path. Adds the /credentials subpath export on @qwen-code/qwen-code-core for the serve fast-path bundle, and drops the now-unused env scrubbing from IdeClient's stdio transport. Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
Addressed the remaining Round-5 blockers in e0293d2:
Verified locally:
I could not complete the local full typecheck / serve-fast-path bundle run because the installed local |
|
@qwen-code /resolve |
…pace trust features
|
Qwen Code resolved the merge conflicts and pushed the branch update. Merge conflict resolution for PR #6606Root causePR #6606 ( The two changes collided on 28 files: every spawn site, every Textual or semanticBoth textual and semantic. The core env-sanitization conflicts are semantic — both sides replace the same // PR (#6606): parameterized denylist + anti-reintroduction guard
env: scrubChildEnv(
normalizePathEnvForWindows(process.env),
collectSensitiveShellEnvKeys(process.env),
{ QWEN_CODE: '1', TERM: 'xterm-256color', ...overrides },
),
// Base (#6601): simple delete-list spread
env: {
...normalizePathEnvForWindows(sanitizeChildEnv(process.env)),
QWEN_CODE: '1', TERM: 'xterm-256color', ...overrides,
},Resolution: kept the PR's The remaining ~22 conflicts are textual — both sides added new fields to the same interfaces or new parameters to the same function calls ( What is load-bearing
What I could not verifyNo build, typecheck, lint, or tests were run. Specific risks:
中文说明PR #6606 合并冲突解决根因:PR #6606 引入了 解决方式:
关键约束: 未验证项:未运行构建/类型检查/测试。 |
What this PR does
Adds a shared environment sanitization utility for child processes and applies it to model-controlled execution paths.
The shell execution path now removes Qwen internal daemon secrets, such as QWEN_SERVER_TOKEN, before spawning child processes.
The sanitization scope intentionally only covers internal Qwen credentials. User-provided credentials required for normal workflows, such as GitHub, AWS, and npm authentication variables, remain available to shell commands.
Why it's needed
Shell subprocesses previously inherited the full daemon process environment through process.env.
This allowed model-controlled shell commands to access daemon-level credentials that should never be exposed outside the Qwen Code process boundary.
For example, QWEN_SERVER_TOKEN could be read from shell commands executed by the agent.
This change closes that defense-in-depth gap while preserving existing user workflows that rely on inherited third-party credentials.
Reviewer Test Plan
How to verify
export QWEN_SERVER_TOKEN=test-secret-token
printenv QWEN_SERVER_TOKEN
Expected:
The variable should not be visible in the shell subprocess environment.
export GH_TOKEN=test-github-token
Run:
printenv GH_TOKEN
Expected:
GH_TOKEN should still be available to shell commands.
Evidence (Before & After)
N/A - Non-user-visible security hardening change.
Tested on
Environment (optional)
Qwen Code v0.19.8
macOS arm64
Node.js v24.3.0
Verified with local CLI execution and unit tests.
Risk & Scope
Main risk or tradeoff:
The sanitization intentionally only removes internal Qwen daemon secrets. Third-party user credentials remain available to preserve shell workflows such as gh, aws, and npm commands.
Not validated / out of scope:
This PR does not change user credential handling or introduce permission prompts for shell environment access.
Breaking changes / migration notes:
Commands that previously relied on accessing Qwen internal daemon credentials from the shell environment will no longer work.
Linked Issues
Fixes #6601
中文说明
本 PR 做了什么
新增共享环境变量清理逻辑,并应用到受模型控制的子进程执行路径。
Shell 子进程启动前会移除 Qwen 内部 daemon 密钥,例如 QWEN_SERVER_TOKEN。
清理范围仅针对 Qwen 内部凭据,不影响用户正常使用 GitHub、AWS、npm 等需要环境变量认证的工作流。
为什么需要
之前 shell 子进程会完整继承 daemon 的 process.env。
这导致模型执行的 shell 命令可能读取 daemon 级别的认证信息。
例如 QWEN_SERVER_TOKEN 可能通过 shell 命令暴露。
本修改关闭该安全风险,同时保持用户现有工作流兼容。