Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,7 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe

> [!warning]
>
> **Loader-affecting variables are always rejected:** Variables that make a spawned Node.js process or OS loader execute an attacker-chosen file — `NODE_OPTIONS`, `npm_config_node_options` (and npm's config-file redirects `npm_config_userconfig`, `npm_config_globalconfig`, `npm_config_script_shell`, `npm_config_prefix`), `NODE_PATH`, `LD_PRELOAD`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`, `BASH_ENV`, `ZDOTDIR`, and exported bash function definitions (`BASH_FUNC_*`) — are never loaded from `.env` files (any scope, including `.qwen/.env` and user-level files) or from the top-level `settings.json` `env` section. A workspace-controlled value there could hijack module resolution for every subprocess Qwen Code spawns, so Qwen Code prints a warning when it rejects such a key (once per process, per key and source — in a multi-workspace daemon each workspace's rejection is reported separately). To use one of these variables, export it in the environment you launch Qwen Code from; sessions hosted by a `qwen serve` daemon deliberately do not inherit them, while direct editor (ACP) sessions and the plain CLI keep the exported value. Library _search_ paths (`LD_LIBRARY_PATH`, `DYLD_LIBRARY_PATH`) and the interactive-shell-only `ENV` are intentionally not on this list — rejecting them breaks mainstream toolchains (`ENV=production`, conda/CUDA library dirs) — but a project `.env` still cannot apply them on reload. This rejection applies to the top-level `env` section only: per-server `mcpServers[].env` and per-hook `hooks[].env` are intentionally scoped to that server or hook and still apply (both surfaces are gated by folder trust for workspace-provided configs). Separately, a project `.env` can never set `QWEN_CLI_ENTRY` (the daemon's session-process entrypoint), `NODE_EXTRA_CA_CERTS`, or `DEV` (the dev-harness launch marker); those stay settable from the shell environment or a user-level `.env`. Upgrade note: before this denylist existed, some of these keys could load from `.env` files or `settings.json` `env` on some paths; they are now rejected everywhere with a warning, and a `qwen serve` daemon no longer passes inherited values of them to session subprocesses.
> **Loader-affecting variables are always rejected:** Variables that make a spawned Node.js process or OS loader execute an attacker-chosen file — `NODE_OPTIONS`, `npm_config_node_options` (and npm's config-file redirects `npm_config_userconfig`, `npm_config_globalconfig`, `npm_config_script_shell`, `npm_config_prefix`), `NODE_PATH`, `OPENSSL_CONF` (dlopens an attacker OpenSSL engine at startup), `NODE_REPL_EXTERNAL_MODULE`, `npm_config_node_gyp`, `npm_config_init_module`, `LD_PRELOAD`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`, `BASH_ENV`, `ZDOTDIR`, and exported bash function definitions (`BASH_FUNC_*`) — are never loaded from `.env` files (any scope, including `.qwen/.env` and user-level files) or from the top-level `settings.json` `env` section. A workspace-controlled value there could hijack module resolution for every subprocess Qwen Code spawns, so Qwen Code prints a warning when it rejects such a key (once per process, per key and source — in a multi-workspace daemon each workspace's rejection is reported separately). To use one of these variables, export it in the environment you launch Qwen Code from; sessions hosted by a `qwen serve` daemon deliberately do not inherit them, while direct editor (ACP) sessions and the plain CLI keep the exported value. Library _search_ paths (`LD_LIBRARY_PATH`, `DYLD_LIBRARY_PATH`) and the interactive-shell-only `ENV` are intentionally not on this list — rejecting them breaks mainstream toolchains (`ENV=production`, conda/CUDA library dirs) — but a project `.env` still cannot apply them on reload. This rejection applies to the top-level `env` section only: per-server `mcpServers[].env` and per-hook `hooks[].env` are intentionally scoped to that server or hook and still apply (both surfaces are gated by folder trust for workspace-provided configs). Separately, a project `.env` can never set `QWEN_CLI_ENTRY` (the daemon's session-process entrypoint), `DEV` (the dev-harness launch marker), the TLS trust-anchor variables (`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `CURL_CA_BUNDLE`, `REQUESTS_CA_BUNDLE`, `GIT_SSL_CAINFO` — an attacker CA there would enable MITM of the token-bearing traffic a session's `git`/`npm`/`pip`/`curl` calls make), the git command-execution variables (`GIT_SSH_COMMAND`, `GIT_SSH`, `GIT_EXTERNAL_DIFF`, `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_COUNT`, `GIT_CONFIG_PARAMETERS` and the numbered `GIT_CONFIG_KEY_<n>`/`GIT_CONFIG_VALUE_<n>` pairs — git runs these on any session `git` invocation), or the node-gyp interpreter-selection variables (`NODE_GYP_FORCE_PYTHON`, `npm_config_python`, `PYTHON` — run as the build Python during native-addon installs). Those stay settable from the shell environment or a user-level `.env`; unlike the loader list above they are rejected from project files only, so a value you export yourself is preserved. Upgrade note: before this denylist existed, some of these keys could load from `.env` files or `settings.json` `env` on some paths; they are now rejected everywhere with a warning, and a `qwen serve` daemon no longer passes inherited values of them to session subprocesses.

### Environment Variables Table

Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/commands/channel/daemon-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const mockResolveProxyUrl = vi.hoisted(() =>
vi.fn((_cliProxy?: string, settingsProxy?: string) => settingsProxy),
);
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
const mockWriteStderrLineSafe = vi.hoisted(() => vi.fn());
const mockWriteStdoutLine = vi.hoisted(() => vi.fn());
const mockSelectFirstModel = vi.hoisted(() =>
vi.fn(
Expand Down Expand Up @@ -219,7 +220,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({

vi.mock('../../utils/stdioHelpers.js', () => ({
writeStderrLine: mockWriteStderrLine,
writeStderrLineSafe: vi.fn(),
writeStderrLineSafe: mockWriteStderrLineSafe,
writeStdoutLine: mockWriteStdoutLine,
}));

Expand Down Expand Up @@ -2033,6 +2034,13 @@ describe('daemonWorkerCommand', () => {

expect(process.env['NODE_OPTIONS']).toBeUndefined();
expect(process.env['npm_config_node-options']).toBeUndefined();
// Pin the channel-boundary breadcrumb, not just the removal: a refactor
// onto the silent scrubInheritedLoaderEnv variant deletes the keys the
// same way but drops the operator diagnostic — the reason the *AndReport*
// helper exists.
expect(mockWriteStderrLineSafe).toHaveBeenCalledWith(
expect.stringContaining('scrubbed inherited loader env vars'),
Comment on lines +2041 to +2042

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.

Deferred to a follow-up. Valid probe: the suite stays green with the PR's source change reverted, so it does not gate this PR's behavior. Adding a daemon-worker case exercising a key this PR adds to the denylist (e.g. OPENSSL_CONF or npm_config_node_gyp) is planned for the follow-up round.

中文说明

延迟到下一轮。探针有效:回退本 PR 的源码改动后该套件仍为绿色,因此它并未钉住本 PR 的行为。计划在下一轮增加一个使用本 PR 新增拒绝键(如 OPENSSL_CONF 或 npm_config_node_gyp)的 daemon-worker 用例。

);
});

it('scrubs daemon connection env when required env validation fails', async () => {
Expand Down
71 changes: 71 additions & 0 deletions packages/cli/src/config/environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ const TRACKED_ENV = [
'Node_Options',
'ZDOTDIR',
'BASH_FUNC_id%%',
'OPENSSL_CONF',
'NODE_REPL_EXTERNAL_MODULE',
'npm_config_node_gyp',
'npm_config_init_module',
'SSL_CERT_FILE',
'GIT_SSH_COMMAND',
'GIT_SSH',
'GIT_CONFIG_COUNT',
'GIT_CONFIG_PARAMETERS',
'GIT_CONFIG_KEY_0',
'GIT_CONFIG_VALUE_0',
'PYTHON',
'NODE_COMPILE_CACHE',
'NODE_DISABLE_COMPILE_CACHE',
'NODE_EXTRA_CA_CERTS',
Expand Down Expand Up @@ -307,6 +319,65 @@ describe('loadEnvironment', () => {
expect(process.env['RUNTIME_DOTENV']).toBe('allowed');
});

// #8663 follow-up: pure-injection loader keys (dlopen/require/exec redirects)
// join the scrubbed loader set and are rejected from every .env scope.
it('never applies the follow-up code-injection loader keys from .env', () => {
const workspace = makeWorkspace();
fs.writeFileSync(
path.join(workspace, '.env'),
[
'OPENSSL_CONF=/workspace-a/evil.cnf',
'NODE_REPL_EXTERNAL_MODULE=/workspace-a/hook.js',
'npm_config_node_gyp=/workspace-a/evil-gyp.js',
'npm_config_init_module=/workspace-a/evil-init.js',
'RUNTIME_DOTENV=allowed',
'',
].join('\n'),
);

loadEnvironment(testSettings({}), workspace);

expect(process.env['OPENSSL_CONF']).toBeUndefined();
expect(process.env['NODE_REPL_EXTERNAL_MODULE']).toBeUndefined();
expect(process.env['npm_config_node_gyp']).toBeUndefined();
expect(process.env['npm_config_init_module']).toBeUndefined();
expect(process.env['RUNTIME_DOTENV']).toBe('allowed');
});

// #8663 follow-up: TLS trust anchors, the git command-exec family (incl.
// numbered GIT_CONFIG_KEY_/VALUE_ pairs), and node-gyp interpreter selection
// join the hardcoded reject-from-project-.env tier.
it('never applies the follow-up TLS/git/interpreter keys from a project .env', () => {
const workspace = makeWorkspace();
fs.writeFileSync(
path.join(workspace, '.env'),
[
'SSL_CERT_FILE=/workspace-a/evil-ca.pem',
'GIT_SSH_COMMAND=/workspace-a/evil-ssh.sh',
'GIT_SSH=/workspace-a/evil-legacy-ssh.sh',
'GIT_CONFIG_COUNT=1',
'GIT_CONFIG_PARAMETERS=core.hooksPath=/workspace-a/evil-hooks',
'GIT_CONFIG_KEY_0=core.hooksPath',
'GIT_CONFIG_VALUE_0=/workspace-a/evil-hooks',
'PYTHON=/workspace-a/evil-python',
'RUNTIME_DOTENV=allowed',
'',
].join('\n'),
);

loadEnvironment(testSettings({}), workspace);

expect(process.env['SSL_CERT_FILE']).toBeUndefined();
expect(process.env['GIT_SSH_COMMAND']).toBeUndefined();
expect(process.env['GIT_SSH']).toBeUndefined();
expect(process.env['GIT_CONFIG_COUNT']).toBeUndefined();
expect(process.env['GIT_CONFIG_PARAMETERS']).toBeUndefined();
expect(process.env['GIT_CONFIG_KEY_0']).toBeUndefined();
expect(process.env['GIT_CONFIG_VALUE_0']).toBeUndefined();
expect(process.env['PYTHON']).toBeUndefined();
expect(process.env['RUNTIME_DOTENV']).toBe('allowed');
});

// The privileged <workspace>/.qwen/.env scope deliberately bypasses
// excludedEnvVars and is discovered before the plain .env, so exempting it
// from the loader denylist must not ship green.
Expand Down
Loading
Loading