Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
43 changes: 43 additions & 0 deletions packages/core/src/hooks/hookRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,49 @@ describe('HookRunner', () => {
expect(mockSpawn).toHaveBeenCalled();
});

it('strips Qwen-internal daemon secrets from the hook child env (#6601)', async () => {
const originalServerToken = process.env['QWEN_SERVER_TOKEN'];
const originalDaemonToken = process.env['QWEN_DAEMON_TOKEN'];
process.env['QWEN_SERVER_TOKEN'] = 'serve-secret';
process.env['QWEN_DAEMON_TOKEN'] = 'daemon-secret';
try {
const mockProcess = createMockProcess(0, 'hello');
mockSpawn.mockImplementation(() => mockProcess);

const hookConfig: HookConfig = {
type: HookType.Command,
command: 'echo hello',
source: HooksConfigSource.Project,
};

await hookRunner.executeHook(
hookConfig,
HookEventName.PreToolUse,
createMockInput(),
);

const spawnOptions = mockSpawn.mock.calls[0][2];
// A user-authored hook command is a child process launched on the
// agent's behalf; internal daemon secrets must not leak into it.
expect(spawnOptions.env['QWEN_SERVER_TOKEN']).toBeUndefined();
expect(spawnOptions.env['QWEN_DAEMON_TOKEN']).toBeUndefined();
// Benign inherited env and the hook's own vars are still present.
expect(spawnOptions.env['PATH']).toBeDefined();
expect(spawnOptions.env['QWEN_PROJECT_DIR']).toBe('/test');
} finally {
if (originalServerToken === undefined) {
delete process.env['QWEN_SERVER_TOKEN'];
} else {
process.env['QWEN_SERVER_TOKEN'] = originalServerToken;
}
if (originalDaemonToken === undefined) {
delete process.env['QWEN_DAEMON_TOKEN'];
} else {
process.env['QWEN_DAEMON_TOKEN'] = originalDaemonToken;
}
}
});

it('should return failure for non-zero exit code', async () => {
const mockProcess = createMockProcess(1, '', 'error');
mockSpawn.mockImplementation(() => mockProcess);
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/hooks/hookRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { PromptHookRunner } from './promptHookRunner.js';
import { AsyncHookRegistry, generateHookId } from './asyncHookRegistry.js';
import type { Config } from '../config/config.js';
import { getShellContextEnvVars } from '../utils/shellContextEnv.js';
import { sanitizeChildEnv } from '../utils/sanitize-child-env.js';

const debugLogger = createDebugLogger('TRUSTED_HOOKS');

Expand Down Expand Up @@ -584,7 +585,9 @@ export class HookRunner {
);

const env = {
...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
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.

GEMINI_PROJECT_DIR: input.cwd,
CLAUDE_PROJECT_DIR: input.cwd, // For compatibility
QWEN_PROJECT_DIR: input.cwd, // For Qwen Code compatibility
Expand Down
90 changes: 90 additions & 0 deletions packages/core/src/tools/tool-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,96 @@ describe('ToolRegistry', () => {
});
});

it('strips Qwen-internal daemon secrets from the discovery and tool-call child env (#6601)', async () => {
const originalServerToken = process.env['QWEN_SERVER_TOKEN'];
const originalDaemonToken = process.env['QWEN_DAEMON_TOKEN'];
process.env['QWEN_SERVER_TOKEN'] = 'serve-secret';
process.env['QWEN_DAEMON_TOKEN'] = 'daemon-secret';
try {
mockConfigGetToolDiscoveryCommand.mockReturnValue(
'my-discovery-command',
);
vi.spyOn(config, 'getToolCallCommand').mockReturnValue(
'my-call-command',
);

const toolDeclaration: FunctionDeclaration = {
name: 'secret-probe',
description: 'A tool',
parametersJsonSchema: { type: 'object', properties: {} },
};

const mockSpawn = vi.mocked(spawn);
const discoveryProcess = {
stdout: { on: vi.fn(), removeListener: vi.fn() },
stderr: { on: vi.fn(), removeListener: vi.fn() },
on: vi.fn(),
};
mockSpawn.mockReturnValueOnce(discoveryProcess as any);
discoveryProcess.stdout.on.mockImplementation((event, callback) => {
if (event === 'data') {
callback(
Buffer.from(
JSON.stringify([{ functionDeclarations: [toolDeclaration] }]),
),
);
}
});
discoveryProcess.on.mockImplementation((event, callback) => {
if (event === 'close') {
callback(0);
}
});

await toolRegistry.discoverAllTools();
const discoveredTool = toolRegistry.getTool('secret-probe');
expect(discoveredTool).toBeDefined();

const executionProcess = {
stdout: { on: vi.fn(), removeListener: vi.fn() },
stderr: { on: vi.fn(), removeListener: vi.fn() },
stdin: { write: vi.fn(), end: vi.fn() },
on: vi.fn(),
connected: true,
disconnect: vi.fn(),
removeListener: vi.fn(),
};
mockSpawn.mockReturnValueOnce(executionProcess as any);
executionProcess.on.mockImplementation((event, callback) => {
if (event === 'close') {
callback(0);
}
});

await (discoveredTool as DiscoveredTool)
.build({})
.execute(new AbortController().signal);

// Both the discovery command and the tool-call command are child
// processes launched on the agent's behalf, so neither may inherit
// the internal daemon secrets.
for (const call of mockSpawn.mock.calls) {
const env = (call[2] as { env: NodeJS.ProcessEnv }).env;
expect(env['QWEN_SERVER_TOKEN']).toBeUndefined();
expect(env['QWEN_DAEMON_TOKEN']).toBeUndefined();
// Benign inherited env is preserved.
expect(env['PATH']).toBeDefined();
}
expect(mockSpawn.mock.calls).toHaveLength(2);
} finally {
if (originalServerToken === undefined) {
delete process.env['QWEN_SERVER_TOKEN'];
} else {
process.env['QWEN_SERVER_TOKEN'] = originalServerToken;
}
if (originalDaemonToken === undefined) {
delete process.env['QWEN_DAEMON_TOKEN'];
} else {
process.env['QWEN_DAEMON_TOKEN'] = originalDaemonToken;
}
}
});

it('should return a DISCOVERED_TOOL_EXECUTION_ERROR on tool failure', async () => {
const discoveryCommand = 'my-discovery-command';
mockConfigGetToolDiscoveryCommand.mockReturnValue(discoveryCommand);
Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/tools/tool-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { ToolErrorType } from './tool-error.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import type { EventEmitter } from 'node:events';
import { createDebugLogger } from '../utils/debugLogger.js';
import { sanitizeChildEnv } from '../utils/sanitize-child-env.js';
import { normalizePathEnvForWindows } from '../utils/windowsPath.js';
import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
import { normalizeMcpToolName } from '../utils/tool-name-utils.js';

Expand Down Expand Up @@ -61,7 +63,14 @@ class DiscoveredToolInvocation extends BaseToolInvocation<
_updateOutput?: (output: ToolResultDisplay) => void,
): Promise<ToolResult> {
const callCommand = this.config.getToolCallCommand()!;
const child = spawn(callCommand, [this.toolName]);
// The user-configured tool-call command is a child process launched on the
// agent's behalf, so it must not inherit Qwen-internal daemon secrets.
// Passing `env` explicitly loses the native inheritance that resolved
// Windows' case-insensitive PATH keys, so normalize as the shell and MCP
// spawn sites do (a no-op off win32).
const child = spawn(callCommand, [this.toolName], {
env: normalizePathEnvForWindows(sanitizeChildEnv(process.env)),
});
child.stdin.write(JSON.stringify(this.params));
child.stdin.end();

Expand Down Expand Up @@ -592,7 +601,12 @@ export class ToolRegistry {
'Tool discovery command is empty or contains only whitespace.',
);
}
const proc = spawn(cmdParts[0] as string, cmdParts.slice(1) as string[]);
// Same as the tool-call command above: the discovery command is
// agent-launched, must not inherit Qwen-internal daemon secrets, and
// needs the Windows PATH normalization that comes with an explicit env.
const proc = spawn(cmdParts[0] as string, cmdParts.slice(1) as string[], {
env: normalizePathEnvForWindows(sanitizeChildEnv(process.env)),
});
let stdout = '';
const stdoutDecoder = new StringDecoder('utf8');
let stderr = '';
Expand Down
Loading