From 10fa9effbb58f456304fd056df90ea37edbfd055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Thu, 9 Jul 2026 07:25:52 +0800 Subject: [PATCH] fix(shell): avoid self-kill from pgrep selectors (#6544) * fix(shell): avoid self-kill from pgrep selectors Fixes #6246 * fix(shell): handle pgrep review cases --- .../tools/__snapshots__/shell.test.ts.snap | 2 + packages/core/src/tools/shell.ts | 4 +- packages/core/src/utils/shell-utils.test.ts | 16 ++ packages/core/src/utils/shell-utils.ts | 184 +++++++++++++++++- 4 files changed, 201 insertions(+), 5 deletions(-) diff --git a/packages/core/src/tools/__snapshots__/shell.test.ts.snap b/packages/core/src/tools/__snapshots__/shell.test.ts.snap index a0e9dc3259..ecd911768d 100644 --- a/packages/core/src/tools/__snapshots__/shell.test.ts.snap +++ b/packages/core/src/tools/__snapshots__/shell.test.ts.snap @@ -52,6 +52,7 @@ IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO N - Any command expected to run indefinitely until manually stopped - Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`. + - To stop a background command started by this tool, use \`task_stop\` when a task id is available. Do not use broad process-name kills such as \`kill $(pgrep node)\`, \`pkill node\`, or \`killall node\`; use a specific PID or process group id where supported. - Use foreground execution (is_background: false) for: - One-time commands: \`ls\`, \`cat\`, \`grep\` - Build commands: \`npm run build\`, \`make\` @@ -106,6 +107,7 @@ IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO N - Web servers: \`python -m http.server\`, \`php -S localhost:8000\` - Any command expected to run indefinitely until manually stopped + - To stop a background command started by this tool, use \`task_stop\` when a task id is available. Do not use broad process-name kills such as \`kill $(pgrep node)\`, \`pkill node\`, or \`killall node\`; use a specific PID or process group id where supported. - Use foreground execution (is_background: false) for: - One-time commands: \`ls\`, \`cat\`, \`grep\` - Build commands: \`npm run build\`, \`make\` diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index e05997825d..4ed6de1a7f 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -4740,6 +4740,8 @@ function getShellToolDescription(): string { const processGroupNote = isWindows ? '' : '\n - Command is executed as a subprocess that leads its own process group. Command process group can be terminated as `kill -- -PGID` or signaled as `kill -s SIGNAL -- -PGID`.'; + const processStopNote = + '\n - To stop a background command started by this tool, use `task_stop` when a task id is available. Do not use broad process-name kills such as `kill $(pgrep node)`, `pkill node`, or `killall node`; use a specific PID or process group id where supported.'; return `Executes a given shell command (as \`${executionWrapper}\`) in a subprocess with optional timeout, ensuring proper handling and security measures. @@ -4775,7 +4777,7 @@ ${getShellCommandSequencingGuidance(shellConfiguration)} - Database servers: \`mongod\`, \`mysql\`, \`redis-server\` - Web servers: \`python -m http.server\`, \`php -S localhost:8000\` - Any command expected to run indefinitely until manually stopped -${processGroupNote} +${processGroupNote}${processStopNote} - Use foreground execution (is_background: false) for: - One-time commands: \`ls\`, \`cat\`, \`grep\` - Build commands: \`npm run build\`, \`make\` diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index 6054656906..e330e251e8 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -606,6 +606,18 @@ describe('detectSelfKillCommand', () => { expect(detectSelfKillCommand('command -p killall node')).toBe(true); }); + it('detects kill commands using pgrep selectors for qwen-code hosts', () => { + expect(detectSelfKillCommand('kill -9 $(pgrep node)')).toBe(true); + expect(detectSelfKillCommand('kill $(pgrep -f node)')).toBe(true); + expect(detectSelfKillCommand('kill -9 $(pgrep node | head -1)')).toBe(true); + expect(detectSelfKillCommand('kill -9 `pgrep node | head -1`')).toBe(true); + expect(detectSelfKillCommand('pgrep node | xargs kill')).toBe(true); + expect(detectSelfKillCommand('pgrep node | xargs sudo kill')).toBe(true); + expect(detectSelfKillCommand('pgrep node | xargs -I {} kill -9 {}')).toBe( + true, + ); + }); + it('detects taskkill inline and dash-prefixed image options', () => { expect(detectSelfKillCommand('taskkill /IM:node.exe /F')).toBe(true); expect( @@ -648,6 +660,10 @@ describe('detectSelfKillCommand', () => { expect(detectSelfKillCommand('pkill -f vite')).toBe(false); expect(detectSelfKillCommand('pkill -f "node server.js"')).toBe(false); expect(detectSelfKillCommand('pkill -9f "node server.js"')).toBe(false); + expect(detectSelfKillCommand('kill -9 $(pgrep vite)')).toBe(false); + expect(detectSelfKillCommand('kill -9 $(pgrep -f "node server.js")')).toBe( + false, + ); expect(detectSelfKillCommand('pkill -F qwen-code.pid vite')).toBe(false); expect(detectSelfKillCommand('taskkill /IM notepad.exe')).toBe(false); }); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index adb52746ab..6e5476fb97 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -208,6 +208,8 @@ export function splitCommands(command: string): string[] { let currentCommand = ''; let inSingleQuotes = false; let inDoubleQuotes = false; + let inBackticks = false; + let substitutionDepth = 0; let i = 0; const previousNonWhitespaceChar = (index: number): string | undefined => { @@ -235,13 +237,42 @@ export function splitCommands(command: string): string[] { continue; } - if (char === "'" && !inDoubleQuotes) { + if (!inSingleQuotes && char === '`') { + inBackticks = !inBackticks; + } else if ( + !inSingleQuotes && + !inBackticks && + char === '$' && + nextChar === '(' + ) { + substitutionDepth++; + currentCommand += '$('; + i += 2; + continue; + } else if (!inBackticks && substitutionDepth > 0 && char === ')') { + substitutionDepth--; + } else if ( + !inBackticks && + substitutionDepth === 0 && + char === "'" && + !inDoubleQuotes + ) { inSingleQuotes = !inSingleQuotes; - } else if (char === '"' && !inSingleQuotes) { + } else if ( + !inBackticks && + substitutionDepth === 0 && + char === '"' && + !inSingleQuotes + ) { inDoubleQuotes = !inDoubleQuotes; } - if (!inSingleQuotes && !inDoubleQuotes) { + if ( + !inSingleQuotes && + !inDoubleQuotes && + !inBackticks && + substitutionDepth === 0 + ) { if ( (char === '&' && nextChar === '&') || (char === '|' && (nextChar === '|' || nextChar === '&')) @@ -460,6 +491,28 @@ const PKILL_OPTIONS_WITH_VALUES = new Set([ '--euid', ]); +const XARGS_OPTIONS_WITH_VALUES = new Set([ + '-I', + '-n', + '-P', + '-s', + '-E', + '-d', + '-L', + '-l', + '-a', + '-J', + '-R', + '--replace', + '--max-args', + '--max-procs', + '--max-chars', + '--eof', + '--delimiter', + '--max-lines', + '--arg-file', +]); + export const SHELL_SELF_KILL_REJECTION = 'Blocked: this command may terminate the running qwen-code process because it targets all node/qwen-code processes. Use task_stop for managed background shells, or kill a specific PID instead.'; @@ -736,13 +789,126 @@ function pkillTargetsSelf(tokens: string[]): boolean { return args.some((arg) => matchesSelfProcessPattern(arg)); } +function pgrepTargetsSelf(tokens: string[]): boolean { + return pkillTargetsSelf(['pkill', ...tokens.slice(1)]); +} + +function pgrepSubstitutionArgs(segment: string): string[] { + let quote: '"' | "'" | '' = ''; + let escaped = false; + const args: string[] = []; + + for (let index = 0; index < segment.length; index++) { + const char = segment[index]!; + + if (quote === "'") { + if (char === "'") quote = ''; + continue; + } + + if (escaped) { + escaped = false; + continue; + } + + if (char === '\\') { + escaped = true; + continue; + } + + if (char === '"') { + quote = quote === '"' ? '' : '"'; + continue; + } + + if (char === "'" && quote === '') { + quote = "'"; + continue; + } + + if (char === '`') { + let end = index + 1; + let innerEscaped = false; + for (; end < segment.length; end++) { + const innerChar = segment[end]!; + if (innerEscaped) { + innerEscaped = false; + continue; + } + if (innerChar === '\\') { + innerEscaped = true; + continue; + } + if (innerChar === '`') break; + } + if (end >= segment.length) { + break; + } + + const inner = segment.slice(index + 1, end).trim(); + const match = inner.match(/^pgrep\b(.*)$/i); + if (match) { + args.push(match[1] ?? ''); + } + index = end; + continue; + } + + if (char !== '$' || segment[index + 1] !== '(') { + continue; + } + + const end = segment.indexOf(')', index + 2); + if (end === -1) { + break; + } + + const inner = segment.slice(index + 2, end).trim(); + const match = inner.match(/^pgrep\b(.*)$/i); + if (match) { + args.push(match[1] ?? ''); + } + index = end; + } + + return args; +} + +function killCommandTargetsSelf(segment: string): boolean { + return pgrepSubstitutionArgs(segment).some((args) => { + const parsed = parseShellSegment(`pgrep ${args}`); + return parsed !== null && pgrepTargetsSelf(parsed); + }); +} + +function xargsInvokesKill(segment: string | undefined): boolean { + if (!segment) { + return false; + } + const parsed = parseShellSegment(segment); + if (!parsed) { + return false; + } + const tokens = unwrapExecutionPrefixes(parsed); + if (normalizeExecutableName(tokens[0] ?? '') !== 'xargs') { + return false; + } + + const commandTokens = unwrapExecutionPrefixes( + commandArguments(tokens, XARGS_OPTIONS_WITH_VALUES), + ); + const command = commandTokens.find((token) => !isOptionToken(token)); + return normalizeExecutableName(command ?? '') === 'kill'; +} + export function detectSelfKillCommand(command: string): boolean { if (!/kill/i.test(command)) { return false; } const segments = getCommandSegments(command); - for (const segment of segments) { + for (let index = 0; index < segments.length; index++) { + const segment = segments[index]!; const parsed = parseShellSegment(segment); if (!parsed) { continue; @@ -763,6 +929,16 @@ export function detectSelfKillCommand(command: string): boolean { if (root === 'pkill' && pkillTargetsSelf(tokens)) { return true; } + if (root === 'kill' && killCommandTargetsSelf(segment)) { + return true; + } + if (root === 'pgrep' && pgrepTargetsSelf(tokens)) { + for (let j = index + 1; j < segments.length; j++) { + if (xargsInvokesKill(segments[j])) { + return true; + } + } + } } return false;