Skip to content

fix(core): strip daemon secrets from hook and tool-discovery child env - #7527

Merged
wenshao merged 3 commits into
QwenLM:mainfrom
chinesepowered:fix/sanitize-child-env-hooks-registry
Jul 23, 2026
Merged

fix(core): strip daemon secrets from hook and tool-discovery child env#7527
wenshao merged 3 commits into
QwenLM:mainfrom
chinesepowered:fix/sanitize-child-env-hooks-registry

Conversation

@chinesepowered

Copy link
Copy Markdown
Contributor

What this PR does

Follow-up to the just-merged #7256. That PR added sanitizeChildEnv() and applied it to the shell, monitor, and MCP stdio spawn paths. This routes the three remaining agent-launched child processes through it as well:

site before
hooks/hookRunner.ts spreads ...process.env into the hook command's env
tools/tool-registry.ts (tool-call command) spawn(callCommand, …) with no env option → implicit full inheritance
tools/tool-registry.ts (discovery command) same

Why it's needed

All three run a user- or config-supplied command on the agent's behalf, which is exactly the category sanitizeChildEnv was written for — its doc comment names "shell commands, the monitor tool, or a stdio MCP server", and hooks and the tool-discovery/tool-call commands are the same shape of thing. None of them has any need for QWEN_SERVER_TOKEN or QWEN_DAEMON_TOKEN, so leaving them inherited is the same credential-exposure gap #6601 describes: a hook or discovery script that runs printenv sees the daemon bearer tokens.

The two tool-registry.ts sites are easy to miss because they pass no env option at all — the inheritance is implicit rather than a visible ...process.env.

This is deliberately narrow and mirrors #7256 exactly: sanitizeChildEnv strips only the two Qwen-internal vars and leaves third-party credentials (GH_TOKEN, AWS_*, …) alone, so hooks and discovery scripts that legitimately depend on them keep working.

Reviewer Test Plan

How to verify

  • From the repo root: npx vitest run --root packages/core src/hooks/hookRunner.test.ts src/tools/tool-registry.test.ts → 82/82.
  • Two new tests, both with proven fail-before/pass-after (revert only the two source files and re-run):
    • HookRunner > executeHook > strips Qwen-internal daemon secrets from the hook child env (#6601) — fails on main with expected 'serve-secret' to be undefined.
    • ToolRegistry > discoverTools > strips Qwen-internal daemon secrets from the discovery and tool-call child env (#6601) — asserts over both spawn calls; fails on main with Cannot read properties of undefined (reading 'env'), i.e. no env option was passed at all.
  • Both tests also assert the benign env survives (PATH still defined, QWEN_PROJECT_DIR still /test), so this cannot silently blank a child's environment.

Evidence (Before & After)

Not user-visible; verified by the deterministic unit tests above.

  • Before: a hook command or tool-discovery script launched by the agent inherits QWEN_SERVER_TOKEN / QWEN_DAEMON_TOKEN.
  • After: both are absent from all three child environments; everything else is unchanged.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

macOS: hookRunner + tool-registry (82) pass locally with proven fail-before/pass-after; typecheck and eslint clean on all four touched files. The change is an env-object transformation with no platform-dependent behavior and the assertions are deterministic, so no manual QA is required; CI covers Windows/Linux.

Environment (optional)

Node v24; @qwen-code/qwen-code-core workspace; vitest 3.2.

Risk & Scope

  • Main risk or tradeoff: the two tool-registry.ts spawns now pass an explicit env where they previously passed none. sanitizeChildEnv(process.env) is a full shallow copy minus the two internal vars, so the child sees the same environment it did before apart from those — verified by the PATH assertion in the new test.
  • Not validated / out of scope: no new call sites are introduced and the denylist itself is unchanged from fix(core): strip Qwen-internal daemon secrets from agent-spawned child env #7256.
  • Breaking changes / migration notes: a hook or discovery script that was (accidentally) relying on QWEN_SERVER_TOKEN being present will no longer see it. That is the intended fix.

Linked Issues

Follow-up to #7256; same root cause as #6601.

中文说明

本 PR 的作用

这是刚刚合并的 #7256 的后续。那个 PR 引入了 sanitizeChildEnv() 并将其应用于 shell、monitor 和 MCP stdio 的 spawn 路径。本 PR 将其余三处由 agent 启动的子进程也接入该函数:

位置 修复前
hooks/hookRunner.ts ...process.env 展开进 hook 命令的 env
tools/tool-registry.ts(工具调用命令) spawn(callCommand, …) 未传 env 选项 → 隐式完整继承
tools/tool-registry.ts(工具发现命令) 同上

为什么需要

这三处都是代表 agent执行用户或配置提供的命令,正是 sanitizeChildEnv 所针对的场景——其文档注释点名了「shell 命令、monitor 工具、stdio MCP server」,而 hook 与工具发现/调用命令属于同一类。它们都不需要 QWEN_SERVER_TOKENQWEN_DAEMON_TOKEN,因此继续继承就是 #6601 所描述的同一个凭据泄露缺口:一个执行 printenv 的 hook 或发现脚本即可看到守护进程的 bearer token。

tool-registry.ts 的两处很容易被漏掉,因为它们完全没有env 选项——继承是隐式的,而非可见的 ...process.env

本改动刻意保持窄范围,与 #7256 完全一致:sanitizeChildEnv 只剥离两个 Qwen 内部变量,不动第三方凭据(GH_TOKENAWS_* 等),因此合理依赖这些变量的 hook 与发现脚本仍可正常工作。

复核测试计划

如何验证

  • 在仓库根目录:npx vitest run --root packages/core src/hooks/hookRunner.test.ts src/tools/tool-registry.test.ts → 82/82 通过。
  • 两个新增测试均已验证 fail-before/pass-after(仅还原两个源文件后重跑):
    • HookRunner > executeHook > strips Qwen-internal daemon secrets from the hook child env (#6601)——在 main 上失败:expected 'serve-secret' to be undefined
    • ToolRegistry > discoverTools > strips Qwen-internal daemon secrets from the discovery and tool-call child env (#6601)——对两次 spawn 调用同时断言;在 main 上失败:Cannot read properties of undefined (reading 'env'),即根本没有传入 env 选项。
  • 两个测试还断言无害的 env 得以保留(PATH 仍存在、QWEN_PROJECT_DIR 仍为 /test),因此不会悄悄清空子进程环境。

证据(修复前后对比)

非用户可见;由上述确定性单元测试验证。

  • 修复前:由 agent 启动的 hook 命令或工具发现脚本会继承 QWEN_SERVER_TOKEN / QWEN_DAEMON_TOKEN
  • 修复后:这两个变量在全部三处子进程环境中均不存在;其余保持不变。

测试环境

系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

macOS:hookRunner + tool-registry(82)本地通过,并验证了 fail-before/pass-after;四个改动文件的 typecheck 与 eslint 均干净。该改动是对 env 对象的变换,无平台相关行为,断言均为确定性,因此无需人工 QA;Windows/Linux 由 CI 覆盖。

运行环境(可选)

Node v24;@qwen-code/qwen-code-core 工作区;vitest 3.2。

风险与影响范围

  • 主要风险或权衡:tool-registry.ts 的两处 spawn 现在显式传入 env,而此前完全不传。sanitizeChildEnv(process.env) 是去掉两个内部变量后的完整浅拷贝,因此除这两者外子进程看到的环境与之前相同——新增测试中的 PATH 断言已验证这一点。
  • 未验证 / 范围之外:未引入新的调用点,denylist 本身相对 fix(core): strip Qwen-internal daemon secrets from agent-spawned child env #7256 未作改动。
  • 破坏性变更 / 迁移说明:若某个 hook 或发现脚本(意外地)依赖 QWEN_SERVER_TOKEN 存在,它将不再能看到该变量。这正是本次修复的预期效果。

关联 Issue

#7256 的后续;与 #6601 同源。

Follow-up to QwenLM#7256, which introduced sanitizeChildEnv and applied it to
the shell, monitor, and MCP stdio spawn paths. Three agent-launched child
processes were left inheriting the full environment:

- hooks/hookRunner.ts spreads process.env into the hook command's env
- tools/tool-registry.ts spawns the configured tool-call command and the
  tool-discovery command with no env option, so both inherit implicitly

All three run user- or config-supplied commands on the agent's behalf and
have no need for QWEN_SERVER_TOKEN / QWEN_DAEMON_TOKEN, so they are the
same credential-exposure gap QwenLM#6601 describes. Route each through
sanitizeChildEnv(process.env); benign inherited env is unchanged.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed security gap, not theoretical. This is a direct follow-up to the already-merged #7256, completing the same fix (#6601) for three spawn sites that were missed — hook commands, tool-discovery commands, and tool-call commands. All three currently leak QWEN_SERVER_TOKEN / QWEN_DAEMON_TOKEN to user-authored child processes. The two tool-registry.ts sites are particularly easy to miss since they pass no env option at all (implicit full inheritance).

Direction: clearly aligned — this finishes an accepted security pattern. sanitizeChildEnv is already applied at the shell, monitor, and MCP stdio spawn paths; these three are the same shape of thing.

Size: 18 production lines (hookRunner.ts: 4+1, tool-registry.ts: 11+2), 133 test lines. Well under any threshold.

Approach: minimal and mirrors #7256 exactly — import sanitizeChildEnv, apply at each spawn site. No unrelated changes, no scope creep. The scope is exactly right: three sites, one utility, two tests with fail-before/pass-after evidence.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的安全缺口,非理论性问题。这是已合并的 #7256 的直接后续,为被遗漏的三处 spawn 点补上相同的修复(#6601)——hook 命令、工具发现命令和工具调用命令。这三处目前都会将 QWEN_SERVER_TOKEN / QWEN_DAEMON_TOKEN 泄露给用户编写的子进程。tool-registry.ts 的两处尤其容易被忽略,因为它们完全没有传 env 选项(隐式完整继承)。

方向:明确对齐——完成一个已被接受的安全模式。sanitizeChildEnv 已应用于 shell、monitor 和 MCP stdio 的 spawn 路径;这三处属于同类。

规模:18 行生产代码(hookRunner.ts: 4+1, tool-registry.ts: 11+2),133 行测试代码。远低于任何阈值。

方案:最小化改动,与 #7256 完全一致——导入 sanitizeChildEnv,在每个 spawn 点应用。无无关改动,无范围蔓延。范围恰好:三个点、一个工具函数、两个带 fail-before/pass-after 证据的测试。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 3dee6b5ae00702a84a2fa5737064d4520c258466 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: given three spawn sites missing env sanitization, I'd import sanitizeChildEnv and apply it at each site — replace ...process.env with ...sanitizeChildEnv(process.env) in hookRunner, and add { env: sanitizeChildEnv(process.env) } to the two tool-registry spawns. Add tests that set the secret vars, run the code, and assert they're stripped while benign env survives.

Comparison with the diff: the PR does exactly this. No simpler path missed.

  • hookRunner.ts: ...sanitizeChildEnv(process.env) correctly replaces ...process.env; subsequent keys (GEMINI_PROJECT_DIR, QWEN_PROJECT_DIR, getShellContextEnvVars(), hookConfig.env) still override as before. Note: if a user explicitly sets QWEN_SERVER_TOKEN in hookConfig.env, it would re-appear — but that's the user's deliberate choice, not implicit inheritance. Fine.
  • tool-registry.ts (both sites): adding { env: sanitizeChildEnv(process.env) } where previously no env option existed is the correct minimal fix. The child sees the same environment minus the two internal vars.
  • Tests: both use try/finally to restore process.env, assert secrets are undefined, and assert PATH / QWEN_PROJECT_DIR survive. The tool-registry test covers both spawn calls (discovery + tool-call) and asserts exactly 2 spawn calls. Solid.
  • Comments explain why (security, Shell subprocess inherits sensitive environment variables causing credential exposure #6601), not what. Appropriate.

No critical blockers. No convention violations. Typecheck and lint clean.

Real-Scenario Testing

Non-user-visible change (env-var stripping in child processes). Project-level hooks didn't trigger in headless mode (trust requirement), so I verified with a direct before/after process-level test and the full unit test suite.

Before/After: env var stripping

$ QWEN_SERVER_TOKEN=serve-secret QWEN_DAEMON_TOKEN=daemon-secret npx tsx /tmp/triage-7527-env-test.mjs
=== BEFORE (process.env spread, no sanitization) ===
QWEN_SERVER_TOKEN=serve-secret
QWEN_DAEMON_TOKEN=daemon-secret
PATH present: true

=== AFTER (sanitizeChildEnv) ===
(no secrets found)
PATH present: true
QWEN_PROJECT_DIR present: true

Before: both daemon secrets leak to the child process. After: stripped, while PATH and QWEN_PROJECT_DIR survive.

Unit tests (82/82 pass)

 ✓ src/hooks/hookRunner.test.ts (37 tests) 85ms
 ✓ src/tools/tool-registry.test.ts (45 tests) 91ms

 Test Files  2 passed (2)
      Tests  82 passed (82)
   Duration  5.21s

Headless CLI run (no regression)

$ QWEN_SERVER_TOKEN=serve-secret QWEN_DAEMON_TOKEN=daemon-secret npm run dev -- -p 'run the shell command: echo hello' --approval-mode yolo --output-format text
Done — the command printed `hello` (exit code 0).
中文说明

代码审查

独立方案: 针对三处缺少 env 净化的 spawn 点,导入 sanitizeChildEnv 并在每个点应用——hookRunner 中将 ...process.env 替换为 ...sanitizeChildEnv(process.env),tool-registry 的两处 spawn 添加 { env: sanitizeChildEnv(process.env) }。添加测试设置秘密变量、运行代码、断言其被剥离且无害 env 保留。

与 diff 对比: PR 完全按此实现,无更简路径被遗漏。

  • hookRunner.ts...sanitizeChildEnv(process.env) 正确替换 ...process.env;后续键(GEMINI_PROJECT_DIRQWEN_PROJECT_DIRgetShellContextEnvVars()hookConfig.env)仍如前覆盖。注意:若用户在 hookConfig.env 中显式设置 QWEN_SERVER_TOKEN,它会重新出现——但这是用户的主动选择,非隐式继承。合理。
  • tool-registry.ts(两处):在之前完全没有 env 选项的地方添加 { env: sanitizeChildEnv(process.env) } 是正确的最小修复。子进程看到的环境与之前相同,仅减去两个内部变量。
  • 测试:均使用 try/finally 恢复 process.env,断言秘密为 undefined,断言 PATH / QWEN_PROJECT_DIR 保留。tool-registry 测试覆盖两次 spawn 调用(发现+调用),并断言恰好 2 次 spawn。扎实。
  • 注释解释为什么(安全,Shell subprocess inherits sensitive environment variables causing credential exposure #6601),而非做什么。恰当。

无关键阻塞项。无规范违反。Typecheck 和 lint 均干净。

真实场景测试

非用户可见变更(子进程 env 变量剥离)。项目级 hook 在 headless 模式下未触发(信任要求),因此通过直接的 before/after 进程级测试和完整单元测试套件验证。

修复前后:env 变量剥离

修复前:两个守护进程秘密泄露到子进程。修复后:被剥离,PATHQWEN_PROJECT_DIR 保留。

单元测试(82/82 通过)

Headless CLI 运行(无回归)

CLI 正常执行 shell 命令,输出 hello,退出码 0。

Qwen Code · qwen3.8-max-preview

Reviewed at 3dee6b5ae00702a84a2fa5737064d4520c258466 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean, minimal security fix completing an established pattern; would merge without hesitation.

This is exactly the kind of follow-up that should be easy to review and ship. The problem is real and already accepted (#6601, #7256) — three spawn sites were missed when sanitizeChildEnv was rolled out. The fix mirrors #7256 line-for-line: import the utility, apply it at each site, done. 18 production lines, no unrelated changes, no new abstractions.

The before/after test confirms the leak and the fix: QWEN_SERVER_TOKEN and QWEN_DAEMON_TOKEN go from visible in the child's printenv to absent, while PATH and QWEN_PROJECT_DIR survive. 82/82 unit tests pass, typecheck and lint are clean, and the CLI runs normally with the change.

If I had to maintain this in six months, I'd thank the author — it's the same one-line pattern already used at four other spawn sites, with tests that prove both the leak and the fix. Nothing to untangle.

中文说明

置信度:5/5 —— 干净、最小化的安全修复,完成已有模式;毫不犹豫即可合并。

这正是那种应该容易审查和发布的后续修复。问题是真实的且已被接受(#6601#7256)——sanitizeChildEnv 推出时遗漏了三处 spawn 点。修复与 #7256 逐行一致:导入工具函数,在每个点应用,完成。18 行生产代码,无无关改动,无新抽象。

before/after 测试确认了泄露和修复:QWEN_SERVER_TOKENQWEN_DAEMON_TOKEN 从子进程 printenv 中可见变为不存在,而 PATHQWEN_PROJECT_DIR 保留。82/82 单元测试通过,typecheck 和 lint 干净,CLI 正常运行。

如果六个月后我要维护这段代码,我会感谢作者——它与已在其他四处 spawn 点使用的单行模式完全一致,测试同时证明了泄露和修复。无需解开任何纠缠。

Qwen Code · qwen3.8-max-preview

Reviewed at 3dee6b5ae00702a84a2fa5737064d4520c258466 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@chinesepowered

Copy link
Copy Markdown
Contributor Author

Heads-up on the red Test (ubuntu-latest, Node 22.x) job: it is not caused by this PR. The only failure is agent.test.ts > AgentTool > Fork dispatch > runs a non-interactive fork through the background registry, which reproduces on a clean main checkout with no changes applied:

git checkout main   # d064bd7dc
npx vitest run --root packages/core src/tools/agent/agent.test.ts
# Tests  1 failed | 196 passed (197)

Bisected to #7460 (8511de61d); its parent 3a2a74a69 passes. Filed as #7537 with the full detail. Everything this PR touches is green — the rest of the packages/core run is 17213 passed.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

Comment on lines +67 to +69
const child = spawn(callCommand, [this.toolName], {
env: sanitizeChildEnv(process.env),
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Missing normalizePathEnvForWindows wrapper — inconsistent with sibling spawn sites

Both tool-registry.ts spawn calls previously had no env option, so Node.js inherited the parent environment natively (the Windows C runtime handles case-insensitive PATH correctly). This PR introduces explicit env objects. On Windows, process.env can have multiple PATH-like keys with different casings (e.g. Path and PATH). When passed as an explicit env object without normalization, the child process may receive duplicate PATH keys, and the Windows runtime may select the wrong one.

The sibling sites in shellExecutionService.ts (lines 768, 1471) and mcp-client.ts (line 2152) both use normalizePathEnvForWindows(sanitizeChildEnv(process.env)) to prevent this.

Suggested change
const child = spawn(callCommand, [this.toolName], {
env: sanitizeChildEnv(process.env),
});
// Normalize PATH keys for Windows before passing to the child process.
const child = spawn(callCommand, [this.toolName], {
env: normalizePathEnvForWindows(sanitizeChildEnv(process.env)),
});

— qwen3.7-max via Qwen Code /review

Comment on lines +602 to +604
const proc = spawn(cmdParts[0] as string, cmdParts.slice(1) as string[], {
env: sanitizeChildEnv(process.env),
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Same normalizePathEnvForWindows gap as the tool-call spawn above — the discovery command has the same Windows PATH risk.

Suggested change
const proc = spawn(cmdParts[0] as string, cmdParts.slice(1) as string[], {
env: sanitizeChildEnv(process.env),
});
// Normalize PATH keys for Windows before passing to the child process.
const proc = spawn(cmdParts[0] as string, cmdParts.slice(1) as string[], {
env: normalizePathEnvForWindows(sanitizeChildEnv(process.env)),
});

— qwen3.7-max via Qwen Code /review

Both tool-registry spawns previously passed no env option, so Node
inherited the parent environment natively and Windows resolved its
case-insensitive PATH keys itself. Passing env explicitly gives that up:
on Windows process.env can carry both Path and PATH, and the child may
pick the wrong one.

Route them through normalizePathEnvForWindows, matching the shell
(shellExecutionService.ts) and MCP stdio (mcp-client.ts) spawn sites that
already pair it with sanitizeChildEnv. It returns env untouched off
win32, so nothing changes on other platforms.

hookRunner.ts is left as-is: it already built an explicit env before this
branch, so it does not regain inheritance semantics here.
@chinesepowered

Copy link
Copy Markdown
Contributor Author

Good catch — this is a regression the PR itself introduced, so it's worth fixing here. Both tool-registry.ts spawns previously passed no env at all, so Node inherited natively and Windows resolved its case-insensitive PATH keys itself; making env explicit gives that up. Applied normalizePathEnvForWindows(sanitizeChildEnv(process.env)) at both sites, matching shellExecutionService.ts:768/1471 and mcp-client.ts:2152. It returns env untouched when process.platform !== 'win32', so nothing changes elsewhere.

I left hookRunner.ts alone on purpose: it already built an explicit env object via ...process.env before this branch, so it never had the native-inheritance behavior to lose — wrapping it there would be a separate pre-existing Windows fix rather than part of this one.

tool-registry + hookRunner suites are 82/82; typecheck and eslint clean.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

...process.env,
// Hook commands are child processes launched on the agent's behalf,
// so they must not inherit Qwen-internal daemon secrets.
...sanitizeChildEnv(process.env),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Missing normalizePathEnvForWindows wrapper — inconsistent with sibling spawn sites

Both tool-registry.ts spawn calls now correctly wrap with normalizePathEnvForWindows(sanitizeChildEnv(process.env)), but this hookRunner.ts site applies only sanitizeChildEnv without the Windows PATH normalization. Every other explicit-env spawn site in the codebase (tool-registry.ts:72, :608, mcp-client.ts:2152, shellExecutionService.ts:768,1471) uses both wrappers.

The Windows PATH issue is pre-existing at this site (the old ...process.env spread into a plain object had the same effect), but since this PR touches this exact line and establishes the convention at the other sites, applying it here would be consistent.

Failure scenario: on Windows, process.env has case-variant PATH keys (Path, PATH, path). Spreading into a plain object loses the Proxy's case-insensitive merging. Hook commands like npx, gh, npm could fail with "command not found" while regular shell, tool discovery, tool calls, and MCP servers all resolve correctly.

Suggested change
...sanitizeChildEnv(process.env),
...normalizePathEnvForWindows(sanitizeChildEnv(process.env)),

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Holding here, and the premise is off by one — hookRunner.ts is not the only site without the wrapper. Full audit on this branch:

tool-registry.ts:72             normalizePathEnvForWindows(sanitizeChildEnv(...))   ← changed by this PR
tool-registry.ts:608            normalizePathEnvForWindows(sanitizeChildEnv(...))   ← changed by this PR
mcp-client.ts:2152              normalizePathEnvForWindows(sanitizeChildEnv(...))
shellExecutionService.ts:768    normalizePathEnvForWindows(sanitizeChildEnv(...))
shellExecutionService.ts:1471   normalizePathEnvForWindows(sanitizeChildEnv(...))
monitor.ts:369                  sanitizeChildEnv(...)                               ← untouched, same shape
hookRunner.ts:590               sanitizeChildEnv(...)                               ← untouched, same shape

monitor.ts:369 spreads sanitizeChildEnv(process.env) into an explicit env object exactly like hookRunner.ts does, and both got that shape from #7256. So wrapping only hookRunner would leave monitor.ts as the outlier instead — the inconsistency doesn't get resolved, it just moves.

The distinction that decides it for me is what this PR changed. The two tool-registry.ts spawns previously passed no env at all, so Node inherited natively and Windows resolved its case-variant PATH keys itself; making env explicit is what gave that up, so restoring it belongs here. hookRunner.ts and monitor.ts already built explicit env objects before this branch and never had the native-inheritance behavior to lose — their Windows PATH exposure is identical before and after this PR.

That makes it a genuine pre-existing bug at two sites rather than a regression at one, and I'd rather fix both together in a PR that says so than half of it as a side effect of a secrets fix. Happy to open that immediately if you want it.

tool-registry + hookRunner suites 82/82; eslint clean.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@gwinthis

Copy link
Copy Markdown
Collaborator

Review & Local Verification Report

代码审查

设计评价:安全边界的正确收紧。 本 PR 修复了 #6601——hook 命令和工具发现子进程继承了 daemon 密钥(QWEN_SERVER_TOKENQWEN_DAEMON_TOKEN)。

修复策略:

  • 新增 sanitizeChildEnv() 工具函数(utils/sanitize-child-env.ts),剥离内部 daemon 密钥
  • hookRunner.ts 的 spawn env 中应用:...sanitizeChildEnv(process.env) 替代 ...process.env
  • tool-registry.ts 的 discovery 和 tool-call 子进程中同样应用

安全分析:

  • Hook 命令是用户编写的,在 agent behalf 上执行——不应继承 daemon 的内部认证令牌
  • 工具发现/调用命令同理——外部命令不应看到 serve 模式的认证凭据
  • 良性环境变量(PATH、QWEN_PROJECT_DIR 等)正常继承

测试: 两个测试分别验证 hook 和 tool-registry 的子进程 env 中不含 daemon 密钥,同时保留良性变量。

结论

LGTM。 最小化的安全修复,共享 sanitizeChildEnv() 避免重复,测试覆盖了两个受影响路径。

@wenshao

wenshao commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Maintainer Local Verification Report

Branch: fix/sanitize-child-env-hooks-registry @ 53f891b0ca
Environment: macOS (darwin), Node v22.22.2


1. Unit Tests — 82/82 PASS

 ✓ src/hooks/hookRunner.test.ts (37 tests) 58ms
 ✓ src/tools/tool-registry.test.ts (45 tests) 39ms

 Test Files  2 passed (2)
      Tests  82 passed (82)

2. Fail-Before / Pass-After (proven in isolated git worktree)

Reverted only the two source files (hookRunner.ts, tool-registry.ts) to main, kept the PR's test files:

Test On main source (fail-before) On PR source (pass-after)
hookRunner > strips Qwen-internal daemon secrets (#6601) expected 'serve-secret' to be undefined ✅ PASS
tool-registry > strips Qwen-internal daemon secrets (#6601) Cannot read properties of undefined (reading 'env') ✅ PASS

Both failures match the PR description exactly — the hook test leaks the token, the registry test has no env option at all.

3. Real E2E Test — Hook printenv probe

Created a test project with a PreToolUse command hook that runs printenv | grep -E 'QWEN_SERVER_TOKEN|QWEN_DAEMON_TOKEN|PATH' and writes the result to a file. Launched the CLI with:

QWEN_SERVER_TOKEN=serve-secret-12345 QWEN_DAEMON_TOKEN=daemon-secret-67890 \
  node dist/cli.js --prompt "read the file ..." --yolo

Hook child env output (after fix):

MANPATH=/Users/wenshao/.nvm/versions/node/v22.22.2/share/man:...
PATH=/Users/wenshao/.local/bin:...

QWEN_SERVER_TOKENabsent (stripped)
QWEN_DAEMON_TOKENabsent (stripped)
PATHpresent (benign env preserved)

4. Real E2E Test — sanitizeChildEnv + normalizePathEnvForWindows direct probe

Spawned a child process both ways to show the before/after contrast:

=== Test 1: Without sanitizeChildEnv (old behavior) ===
{"QWEN_SERVER_TOKEN":"serve-secret-12345","QWEN_DAEMON_TOKEN":"daemon-secret-67890","PATH":"DEFINED"}

=== Test 2: With sanitizeChildEnv (new behavior) ===
{"QWEN_SERVER_TOKEN":"UNDEFINED","QWEN_DAEMON_TOKEN":"UNDEFINED","PATH":"DEFINED"}

5. TypeCheck & Lint

tsc --noEmit   → PASS (0 errors)
eslint         → PASS (0 warnings)

6. CI Status

Check Status
Test (ubuntu-latest, Node 22.x) ✅ pass (32m18s)
Test (macos-latest, Node 22.x) ⏭️ skipping (fork PR)
Test (windows-latest, Node 22.x) ⏭️ skipping (fork PR)

Verdict

The change is narrow, correct, and well-tested. All three agent-launched child process spawn sites (hookRunner.ts command hook, tool-registry.ts discovery command, tool-registry.ts tool-call command) now route through sanitizeChildEnv(), matching the existing shell/monitor/MCP-stdio pattern from #7256. The Windows PATH normalization (normalizePathEnvForWindows) on the two registry spawns is a correct companion fix since passing env explicitly loses Node's native case-insensitive PATH resolution. No regressions observed.


中文验证报告

维护者本地验证报告

分支: fix/sanitize-child-env-hooks-registry @ 53f891b0ca
环境: macOS (darwin), Node v22.22.2

1. 单元测试 — 82/82 通过

 ✓ src/hooks/hookRunner.test.ts (37 tests) 58ms
 ✓ src/tools/tool-registry.test.ts (45 tests) 39ms

 Test Files  2 passed (2)
      Tests  82 passed (82)

2. Fail-Before / Pass-After(在隔离 git worktree 中验证)

仅将两个源文件(hookRunner.tstool-registry.ts)还原到 main,保留 PR 的测试文件:

测试 main 源文件(fail-before) PR 源文件(pass-after)
hookRunner > strips Qwen-internal daemon secrets (#6601) expected 'serve-secret' to be undefined ✅ 通过
tool-registry > strips Qwen-internal daemon secrets (#6601) Cannot read properties of undefined (reading 'env') ✅ 通过

两个失败与 PR 描述完全一致——hook 测试泄露了 token,registry 测试根本没有传 env 选项。

3. 真实 E2E 测试 — Hook printenv 探测

创建测试项目,配置 PreToolUse command hook 执行 printenv | grep -E 'QWEN_SERVER_TOKEN|QWEN_DAEMON_TOKEN|PATH' 并写入文件。启动 CLI:

QWEN_SERVER_TOKEN=serve-secret-12345 QWEN_DAEMON_TOKEN=daemon-secret-67890   node dist/cli.js --prompt "read the file ..." --yolo

Hook 子进程环境输出(修复后):

MANPATH=/Users/wenshao/.nvm/versions/node/v22.22.2/share/man:...
PATH=/Users/wenshao/.local/bin:...

QWEN_SERVER_TOKEN不存在(已剥离)
QWEN_DAEMON_TOKEN不存在(已剥离)
PATH存在(无害环境变量保留)

4. 真实 E2E 测试 — sanitizeChildEnv + normalizePathEnvForWindows 直接对比

分别以旧/新方式 spawn 子进程:

=== 旧行为(不经过 sanitizeChildEnv)===
{"QWEN_SERVER_TOKEN":"serve-secret-12345","QWEN_DAEMON_TOKEN":"daemon-secret-67890","PATH":"DEFINED"}

=== 新行为(经过 sanitizeChildEnv)===
{"QWEN_SERVER_TOKEN":"UNDEFINED","QWEN_DAEMON_TOKEN":"UNDEFINED","PATH":"DEFINED"}

5. 类型检查 & Lint

tsc --noEmit   → 通过(0 错误)
eslint         → 通过(0 警告)

6. CI 状态

检查项 状态
Test (ubuntu-latest, Node 22.x) ✅ 通过 (32m18s)
Test (macos-latest, Node 22.x) ⏭️ 跳过(fork PR)
Test (windows-latest, Node 22.x) ⏭️ 跳过(fork PR)

结论

改动范围窄、正确、测试充分。三处 agent 启动的子进程 spawn 点(hookRunner.ts command hook、tool-registry.ts discovery 命令、tool-registry.ts tool-call 命令)现在都经过 sanitizeChildEnv(),与 #7256 中 shell/monitor/MCP-stdio 的模式一致。两处 registry spawn 上的 Windows PATH 规范化(normalizePathEnvForWindows)是正确的伴随修复,因为显式传入 env 会失去 Node 原生的大小写不敏感 PATH 解析。未观察到回归。

@wenshao

wenshao commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Maintainer E2E Verification Report

Environment: macOS (darwin arm64), Node v22.22.2, built from PR branch fix/sanitize-child-env-hooks-registry

Test 1: Hook child env — real CLI E2E

Configured a PreToolUse hook running printenv > /tmp/hook-env.txt, then ran the bundled CLI headless with QWEN_SERVER_TOKEN=serve-secret-test and QWEN_DAEMON_TOKEN=daemon-secret-test in the parent environment.

BEFORE (main branch): Both daemon tokens leaked into the hook child process:

before-main

AFTER (PR 7527): Both tokens are stripped; benign env (PATH, QWEN_PROJECT_DIR) is preserved:

after-pr7527

Test 2: Tool-registry spawn — direct code-path verification

Ran a script that imports the built sanitizeChildEnv + normalizePathEnvForWindows from packages/core/dist/ and spawns a child process with the same env construction used by tool-registry.ts. Also spawns a child without sanitization to confirm the old behavior leaks.

tool-registry-test

Test 3: Unit tests (CI)

CI on Ubuntu passes all tests including the two new ones:

  • HookRunner > executeHook > strips Qwen-internal daemon secrets from the hook child env (#6601)
  • ToolRegistry > discoverTools > strips Qwen-internal daemon secrets from the discovery and tool-call child env (#6601)

Note: local macOS vitest has a pre-existing @xterm/headless CJS/ESM interop issue (also fails on main), unrelated to this PR.

Summary

Test Result
Hook env: QWEN_SERVER_TOKEN stripped
Hook env: QWEN_DAEMON_TOKEN stripped
Hook env: PATH preserved
Hook env: QWEN_PROJECT_DIR preserved
Tool-registry spawn: tokens stripped
Tool-registry spawn: benign env preserved
Old behavior (no sanitize): tokens leak ✅ (confirms vulnerability existed)
CI unit tests (Ubuntu) ✅ pass

Verdict: The fix works as intended. Daemon tokens are stripped from all three child-process spawn sites (hook command, tool-call command, tool-discovery command) while benign environment variables are fully preserved.

中文版本

维护者 E2E 验证报告

环境: macOS (darwin arm64),Node v22.22.2,从 PR 分支 fix/sanitize-child-env-hooks-registry 构建

测试 1:Hook 子进程环境 — 真实 CLI E2E

配置了一个运行 printenv > /tmp/hook-env.txtPreToolUse hook,然后在父环境中设置 QWEN_SERVER_TOKEN=serve-secret-testQWEN_DAEMON_TOKEN=daemon-secret-test,以 headless 模式运行打包后的 CLI。

修复前(main 分支): 两个 daemon token 均泄露到 hook 子进程中:

before-main

修复后(PR 7527): 两个 token 均被剥离;无害环境变量(PATHQWEN_PROJECT_DIR)保留:

after-pr7527

测试 2:Tool-registry spawn — 直接代码路径验证

运行脚本导入构建后的 sanitizeChildEnv + normalizePathEnvForWindows(来自 packages/core/dist/),使用与 tool-registry.ts 相同的 env 构造方式 spawn 子进程。同时 spawn 一个未做清洗的子进程以确认旧行为确实泄露。

tool-registry-test

测试 3:单元测试(CI)

CI 在 Ubuntu 上通过所有测试,包括两个新增测试:

  • HookRunner > executeHook > strips Qwen-internal daemon secrets from the hook child env (#6601)
  • ToolRegistry > discoverTools > strips Qwen-internal daemon secrets from the discovery and tool-call child env (#6601)

注:本地 macOS vitest 存在预先存在的 @xterm/headless CJS/ESM 互操作问题(在 main 上同样失败),与本 PR 无关。

总结

测试项 结果
Hook 环境:QWEN_SERVER_TOKEN 被剥离
Hook 环境:QWEN_DAEMON_TOKEN 被剥离
Hook 环境:PATH 保留
Hook 环境:QWEN_PROJECT_DIR 保留
Tool-registry spawn:token 被剥离
Tool-registry spawn:无害环境保留
旧行为(未清洗):token 泄露 ✅(确认漏洞曾存在)
CI 单元测试(Ubuntu) ✅ 通过

结论: 修复按预期工作。Daemon token 在全部三处子进程 spawn 点(hook 命令、工具调用命令、工具发现命令)均被剥离,同时无害环境变量完整保留。

@wenshao
wenshao added this pull request to the merge queue Jul 23, 2026
Merged via the queue into QwenLM:main with commit ff46cbe Jul 23, 2026
68 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants