From aa57d06ee9862b1d0cf299f9ebb0f497d071adae Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Mon, 2 Nov 2026 11:09:28 +0700 Subject: [PATCH 01/14] fix: recognize verification commands only in command positions --- lib/verification.js | 33 +++++++++++++++++++++++---------- test/app/turn-lifecycle.test.js | 14 ++++++++++++++ test/verification.test.js | 22 ++++++++++++++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/lib/verification.js b/lib/verification.js index 330d87f..5c3d58b 100644 --- a/lib/verification.js +++ b/lib/verification.js @@ -7,15 +7,15 @@ import { detectSignals } from './signals.js'; // verdict an automatic reuse report carries is read off the final test, build, // lint or type-check command, never off how the turn ended. const VERIFICATION_COMMANDS = [ - /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|lint|typecheck|type-check|check)\b/, - /\bnode\s+--test\b/, - /\b(?:jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|ruff|mypy)\b/, - /\bgo\s+(?:test|build|vet)\b/, - /\bcargo\s+(?:test|build|check|clippy)\b/, - /\bdotnet\s+(?:test|build)\b/, - /\b(?:mvn|gradle|gradlew)\b/, - /\bmake\b/, - /\bclaude\s+plugin\s+(?:validate|test)\b/, + /^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|lint|typecheck|type-check|check)\b/, + /^node\s+--test\b/, + /^(?:jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|ruff|mypy)(?:\s|$)/, + /^go\s+(?:test|build|vet)\b/, + /^cargo\s+(?:test|build|check|clippy)\b/, + /^dotnet\s+(?:test|build)\b/, + /^(?:mvn|gradle|gradlew)(?:\s|$)/, + /^make(?:\s|$)/, + /^claude\s+plugin\s+(?:validate|test)\b/, ]; const FAILURE_SIGNALS = new Set(['test_failure', 'log_error']); @@ -31,8 +31,21 @@ const HEREDOC = /<<-?\s*(['"]?)(\w+)\1([^\n]*)\n[\s\S]*?\n\s*\2[ \t]*(?=\n|$)/g; const withoutHeredocBodies = (command) => command.replace(HEREDOC, '<<$2$3'); +// Keep separators inside strings, backticks and comments out of the command scan. A name in an argument is no +// check. This recognises direct commands and known wrappers; it does not evaluate arbitrary shell control flow. +const SHELL_DATA = /\\[\s\S]|`[^`]*(?:`|$)|'(?:''|[^'])*(?:'|$)|"(?:\\[\s\S]|`[\s\S]|[^"\\`])*(?:"|$)|#[^\r\n]*/g; +const ENVIRONMENT = /^(?:env\s+)?(?:[A-Za-z_]\w*=\S*\s+)*/; +const NPX = /^npx\s+(?:(?:-y|--yes|--no-install)\s+|(?:-p|--package)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/; + export function isVerificationCommand(command) { - return typeof command === 'string' && VERIFICATION_COMMANDS.some((pattern) => pattern.test(withoutHeredocBodies(command))); + if (typeof command !== 'string') return false; + const shell = withoutHeredocBodies(command).replace(SHELL_DATA, '__data__'); + if (shell.includes('$(')) return false; // Do not mistake separators in a nested substitution for command positions. + const commands = shell.split(/[;&|\r\n]+/); + return commands.some((part) => { + const executable = part.trim().replace(ENVIRONMENT, '').replace(NPX, ''); + return VERIFICATION_COMMANDS.some((pattern) => pattern.test(executable)); + }); } /** @param {{ isError?: boolean, text?: string }} result */ diff --git a/test/app/turn-lifecycle.test.js b/test/app/turn-lifecycle.test.js index fdad62f..9b53c1b 100644 --- a/test/app/turn-lifecycle.test.js +++ b/test/app/turn-lifecycle.test.js @@ -35,6 +35,20 @@ test('a recalled strategy ends with the spec line, and its turn end reports it w assert.ok(scheduled.includes('drain')); }); +test('printing a check command does not verify a turn or raise its reuse evidence', async () => { + const { ports } = memoryPorts(); + await promptSubmitted(ports, { sessionId: 's1', prompt: PROMPT, ...WAIT }); + await toolFinished(ports, { sessionId: 's1', note: { kind: 'edit', paths: ['/proj/lib/redis.js'] } }); + await toolFinished(ports, { sessionId: 's1', note: { + kind: 'shell', command: 'echo "npm test"', exit: 0, output: 'npm test', label: 'Run tests', + } }); + const { record, reports } = await end(ports, 's1'); + assert.deepEqual(record.checks, []); + assert.equal((await ports.candidates.read()).s1, undefined); + assert.equal(reports[0].evidence.tier, 'none'); + assert.equal(reports[0].evidence.check, undefined); +}); + test('a verified turn becomes the candidate; a correction drops it before revising the reports', async () => { const { ports, queued } = memoryPorts(); await verifiedTurn(ports, 's1'); diff --git a/test/verification.test.js b/test/verification.test.js index 0191b43..5708f37 100644 --- a/test/verification.test.js +++ b/test/verification.test.js @@ -13,6 +13,28 @@ test('test, build, lint and type-check runs are verifications; other commands ar } }); +test('check names in arguments, quoted data and comments are not executions', () => { + for (const command of [ + 'echo "npm test"', 'echo npm test', 'printf "%s\\n" "make"', 'rg pytest README.md', + 'node -e "console.log(\'npm test\')"', 'git status # npm test', '# npm test\npwd', + 'echo "notes; npm test"', "printf 'notes\nnpm test\n'", 'echo `pwd; npm test`', + 'npx echo npm test', 'npm exec echo "npm test"', + 'echo $(printf x; npm test)', 'echo $(echo $(printf x); npm test)', + ]) { + assert.equal(isVerificationCommand(command), false, command); + } +}); + +test('command positions in chains, pipelines, environment assignments and npx wrappers still count', () => { + for (const command of [ + 'sleep 2; node --test', 'npm test 2>&1 | tail -5', 'CI=1 npm test', 'env CI=1 npm test', + 'npx --yes vitest run', 'npx --package=typescript tsc -p .', 'npx --package typescript tsc -p .', + 'echo "notes; npm test"\nnode --test', + ]) { + assert.equal(isVerificationCommand(command), true, command); + } +}); + test('a non-zero exit fails the verification', () => { assert.equal(verificationFailed({ isError: true, text: '' }), true); }); From 94edc6aa390952fe028181464524854c5d77ed34 Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Mon, 2 Nov 2026 11:48:38 +0700 Subject: [PATCH 02/14] fix: preserve verification through known executable paths --- lib/verification.js | 6 ++++-- test/verification.test.js | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/verification.js b/lib/verification.js index 5c3d58b..22b5451 100644 --- a/lib/verification.js +++ b/lib/verification.js @@ -36,6 +36,7 @@ const withoutHeredocBodies = (command) => command.replace(HEREDOC, '<<$2$3'); const SHELL_DATA = /\\[\s\S]|`[^`]*(?:`|$)|'(?:''|[^'])*(?:'|$)|"(?:\\[\s\S]|`[\s\S]|[^"\\`])*(?:"|$)|#[^\r\n]*/g; const ENVIRONMENT = /^(?:env\s+)?(?:[A-Za-z_]\w*=\S*\s+)*/; const NPX = /^npx\s+(?:(?:-y|--yes|--no-install)\s+|(?:-p|--package)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/; +const withoutExecutablePath = (command) => command.replace(/^\S+/, (token) => token.split(/[\\/]/).at(-1)); export function isVerificationCommand(command) { if (typeof command !== 'string') return false; @@ -43,8 +44,9 @@ export function isVerificationCommand(command) { if (shell.includes('$(')) return false; // Do not mistake separators in a nested substitution for command positions. const commands = shell.split(/[;&|\r\n]+/); return commands.some((part) => { - const executable = part.trim().replace(ENVIRONMENT, '').replace(NPX, ''); - return VERIFICATION_COMMANDS.some((pattern) => pattern.test(executable)); + const executable = withoutExecutablePath(part.trim().replace(ENVIRONMENT, '')); + const check = withoutExecutablePath(executable.replace(NPX, '')); + return VERIFICATION_COMMANDS.some((pattern) => pattern.test(check)); }); } diff --git a/test/verification.test.js b/test/verification.test.js index 5708f37..a4f90b5 100644 --- a/test/verification.test.js +++ b/test/verification.test.js @@ -35,6 +35,22 @@ test('command positions in chains, pipelines, environment assignments and npx wr } }); +test('a known executable reached through a path still verifies, while a path in an argument does not', () => { + for (const command of [ + './gradlew test', './node_modules/.bin/tsc --noEmit', '/usr/bin/make', + 'cd app && ./gradlew test', 'CI=1 ./node_modules/.bin/tsc --noEmit', + '/usr/bin/npx --yes ./node_modules/.bin/tsc --noEmit', + ]) { + assert.equal(isVerificationCommand(command), true, command); + } + for (const command of [ + 'echo ./gradlew test', 'cat ./node_modules/.bin/tsc', 'echo /usr/bin/make', + 'echo "notes; ./gradlew test"', '/usr/bin/npx echo ./gradlew test', './scripts/make-not-a-check', + ]) { + assert.equal(isVerificationCommand(command), false, command); + } +}); + test('a non-zero exit fails the verification', () => { assert.equal(verificationFailed({ isError: true, text: '' }), true); }); From 61b5499fa7cec180372f824c483bdc48c97ede95 Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Tue, 3 Nov 2026 13:59:29 +0700 Subject: [PATCH 03/14] fix: recognize verification after shell data and wrappers --- lib/verification.js | 55 +++++++++++++++++++++++++++++++-------- test/verification.test.js | 8 ++++-- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/lib/verification.js b/lib/verification.js index 22b5451..a338d04 100644 --- a/lib/verification.js +++ b/lib/verification.js @@ -33,20 +33,53 @@ const withoutHeredocBodies = (command) => command.replace(HEREDOC, '<<$2$3'); // Keep separators inside strings, backticks and comments out of the command scan. A name in an argument is no // check. This recognises direct commands and known wrappers; it does not evaluate arbitrary shell control flow. -const SHELL_DATA = /\\[\s\S]|`[^`]*(?:`|$)|'(?:''|[^'])*(?:'|$)|"(?:\\[\s\S]|`[\s\S]|[^"\\`])*(?:"|$)|#[^\r\n]*/g; -const ENVIRONMENT = /^(?:env\s+)?(?:[A-Za-z_]\w*=\S*\s+)*/; -const NPX = /^npx\s+(?:(?:-y|--yes|--no-install)\s+|(?:-p|--package)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/; -const withoutExecutablePath = (command) => command.replace(/^\S+/, (token) => token.split(/[\\/]/).at(-1)); +const SHELL_TOKEN = /\\[\s\S]|\x60[^\x60]*(?:\x60|$)|'(?:''|[^'])*(?:'|$)|"(?:\\[\s\S]|\x60[\s\S]|[^"\\\x60])*(?:"|$)|(? path.replaceAll('\\', '/')); + let depth = 0; + let start = 0; + let output = ''; + for (const { 0: token, index } of input.matchAll(SHELL_TOKEN)) { + if (depth === 0) { + if (token === '(' || token === ')') continue; + output += input.slice(start, index) + '\0'; + start = index + token.length; + } + if (token === '$(' || (depth > 0 && token === '(')) depth += 1; + if (depth > 0 && token === ')') { + depth -= 1; + if (depth === 0) start = index + 1; + } + } + return depth === 0 ? output + input.slice(start) : output; +} + +const WRAPPERS = [ + /^(?:[A-Za-z_]\w*=\S*\s+)+/, + /^env\s+(?:(?:-i|--ignore-environment)\s+|[A-Za-z_]\w*=\S*\s+)*(?:--\s+)?/, + /^npx\s+(?:(?:-y|--yes|--no-install)\s+|(?:-p|--package)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/, + /^(?:sudo|time|nohup|bunx)\s+(?:--\s+)?/, + /^timeout\s+\d+(?:\.\d+)?[smhd]?\s+/, + /^(?:uv|poetry)\s+run\s+/, + /^python(?:3(?:\.\d+)?)?\s+-m\s+(?=pytest(?:\s|$))/, + /^yarn\s+(?=(?:jest|vitest)(?:\s|$))/, +]; +const withoutExecutablePath = (command) => command.replace(/^\S+/, (token) => token.split('/').at(-1).replace(/\.exe$/i, '')); export function isVerificationCommand(command) { if (typeof command !== 'string') return false; - const shell = withoutHeredocBodies(command).replace(SHELL_DATA, '__data__'); - if (shell.includes('$(')) return false; // Do not mistake separators in a nested substitution for command positions. - const commands = shell.split(/[;&|\r\n]+/); - return commands.some((part) => { - const executable = withoutExecutablePath(part.trim().replace(ENVIRONMENT, '')); - const check = withoutExecutablePath(executable.replace(NPX, '')); - return VERIFICATION_COMMANDS.some((pattern) => pattern.test(check)); + return withoutShellData(command).split(/[;&|\r\n]+/).some((part) => { + let check = part.trim().replace(/^(?:(?:if|then|elif|else|do)\s+|[({]\s*)+/, ''); + while (check) { + const wrapper = WRAPPERS.find((pattern) => pattern.test(check)); + const executable = wrapper ? check : withoutExecutablePath(check); + const prefix = wrapper ?? WRAPPERS.find((pattern) => pattern.test(executable)); + if (!prefix) return VERIFICATION_COMMANDS.some((pattern) => pattern.test(executable)); + check = executable.replace(prefix, ''); + } + return false; }); } diff --git a/test/verification.test.js b/test/verification.test.js index a4f90b5..24cc2ca 100644 --- a/test/verification.test.js +++ b/test/verification.test.js @@ -19,7 +19,7 @@ test('check names in arguments, quoted data and comments are not executions', () 'node -e "console.log(\'npm test\')"', 'git status # npm test', '# npm test\npwd', 'echo "notes; npm test"', "printf 'notes\nnpm test\n'", 'echo `pwd; npm test`', 'npx echo npm test', 'npm exec echo "npm test"', - 'echo $(printf x; npm test)', 'echo $(echo $(printf x); npm test)', + 'echo # note; npm test', 'echo $(printf x; npm test)', 'echo $(echo $(printf x); npm test)', ]) { assert.equal(isVerificationCommand(command), false, command); } @@ -29,7 +29,11 @@ test('command positions in chains, pipelines, environment assignments and npx wr for (const command of [ 'sleep 2; node --test', 'npm test 2>&1 | tail -5', 'CI=1 npm test', 'env CI=1 npm test', 'npx --yes vitest run', 'npx --package=typescript tsc -p .', 'npx --package typescript tsc -p .', - 'echo "notes; npm test"\nnode --test', + 'echo "notes; npm test"\nnode --test', 'curl http://x/#f && npm test', 'git log --format=%h#%s; npm test', + 'cd $(git rev-parse --show-toplevel) && npm test', 'cd $(echo "$(pwd)") && npm test', + 'timeout 60 npm test', 'sudo npm test', 'time npm test', 'nohup npm test', 'env -i npm test', + 'yarn vitest', 'bunx vitest', 'python -m pytest', 'uv run pytest', 'poetry run pytest', + '{ npm test; }', 'if true; then npm test; fi', 'C:\\tools\\make.exe', ]) { assert.equal(isVerificationCommand(command), true, command); } From bdea3547473f7057cd7da235535707ad9d26f5dd Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Tue, 3 Nov 2026 15:43:34 +0700 Subject: [PATCH 04/14] fix: recognize Windows shims and literal shell scripts --- lib/verification.js | 8 ++++++-- test/verification.test.js | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/verification.js b/lib/verification.js index a338d04..6101e00 100644 --- a/lib/verification.js +++ b/lib/verification.js @@ -66,11 +66,15 @@ const WRAPPERS = [ /^python(?:3(?:\.\d+)?)?\s+-m\s+(?=pytest(?:\s|$))/, /^yarn\s+(?=(?:jest|vitest)(?:\s|$))/, ]; -const withoutExecutablePath = (command) => command.replace(/^\S+/, (token) => token.split('/').at(-1).replace(/\.exe$/i, '')); +const withoutExecutablePath = (command) => command.replace(/^\S+/, (token) => token.split('/').at(-1).replace(/\.(?:exe|cmd|bat)$/i, '')); + +// Only unwrap a literal script passed directly to a shell. Expansions in a double-quoted script belong to the outer shell. +const SHELL_SCRIPT = /^(?:bash|sh)\s+-c\s+(?:'([^']*)'|"([^"\x60$\\]*)")$/; export function isVerificationCommand(command) { if (typeof command !== 'string') return false; - return withoutShellData(command).split(/[;&|\r\n]+/).some((part) => { + const script = SHELL_SCRIPT.exec(command.trim()); + return withoutShellData(script ? script[1] ?? script[2] : command).split(/[;&|\r\n]+/).some((part) => { let check = part.trim().replace(/^(?:(?:if|then|elif|else|do)\s+|[({]\s*)+/, ''); while (check) { const wrapper = WRAPPERS.find((pattern) => pattern.test(check)); diff --git a/test/verification.test.js b/test/verification.test.js index 24cc2ca..9f9f823 100644 --- a/test/verification.test.js +++ b/test/verification.test.js @@ -19,7 +19,7 @@ test('check names in arguments, quoted data and comments are not executions', () 'node -e "console.log(\'npm test\')"', 'git status # npm test', '# npm test\npwd', 'echo "notes; npm test"', "printf 'notes\nnpm test\n'", 'echo `pwd; npm test`', 'npx echo npm test', 'npm exec echo "npm test"', - 'echo # note; npm test', 'echo $(printf x; npm test)', 'echo $(echo $(printf x); npm test)', + 'echo # note; npm test', 'echo bash -c "npm test"', 'bash -c "echo npm test"', 'echo $(printf x; npm test)', 'echo $(echo $(printf x); npm test)', ]) { assert.equal(isVerificationCommand(command), false, command); } @@ -31,7 +31,7 @@ test('command positions in chains, pipelines, environment assignments and npx wr 'npx --yes vitest run', 'npx --package=typescript tsc -p .', 'npx --package typescript tsc -p .', 'echo "notes; npm test"\nnode --test', 'curl http://x/#f && npm test', 'git log --format=%h#%s; npm test', 'cd $(git rev-parse --show-toplevel) && npm test', 'cd $(echo "$(pwd)") && npm test', - 'timeout 60 npm test', 'sudo npm test', 'time npm test', 'nohup npm test', 'env -i npm test', + 'bash -c "npm test"', "sh -c 'cd app && npm test'", 'timeout 60 npm test', 'sudo npm test', 'time npm test', 'nohup npm test', 'env -i npm test', 'yarn vitest', 'bunx vitest', 'python -m pytest', 'uv run pytest', 'poetry run pytest', '{ npm test; }', 'if true; then npm test; fi', 'C:\\tools\\make.exe', ]) { @@ -41,7 +41,7 @@ test('command positions in chains, pipelines, environment assignments and npx wr test('a known executable reached through a path still verifies, while a path in an argument does not', () => { for (const command of [ - './gradlew test', './node_modules/.bin/tsc --noEmit', '/usr/bin/make', + './gradlew test', 'gradlew.bat test', 'npm.cmd test', 'C:\\tools\\pnpm.cmd lint', './node_modules/.bin/tsc --noEmit', '/usr/bin/make', 'cd app && ./gradlew test', 'CI=1 ./node_modules/.bin/tsc --noEmit', '/usr/bin/npx --yes ./node_modules/.bin/tsc --noEmit', ]) { From fe90c5a7640c562705fd94d2282a4ffae71af682 Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Tue, 3 Nov 2026 15:57:17 +0700 Subject: [PATCH 05/14] fix: recognize supported verification wrapper options --- lib/verification.js | 6 ++++-- test/verification.test.js | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/verification.js b/lib/verification.js index 6101e00..03d534f 100644 --- a/lib/verification.js +++ b/lib/verification.js @@ -60,8 +60,10 @@ const WRAPPERS = [ /^(?:[A-Za-z_]\w*=\S*\s+)+/, /^env\s+(?:(?:-i|--ignore-environment)\s+|[A-Za-z_]\w*=\S*\s+)*(?:--\s+)?/, /^npx\s+(?:(?:-y|--yes|--no-install)\s+|(?:-p|--package)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/, - /^(?:sudo|time|nohup|bunx)\s+(?:--\s+)?/, - /^timeout\s+\d+(?:\.\d+)?[smhd]?\s+/, + /^sudo\s+(?:(?:-n|--non-interactive)\s+)*(?:--\s+)?/, + /^time\s+(?:(?:-p|--portability)\s+)?(?:--\s+)?/, + /^(?:nohup|bunx)\s+(?:--\s+)?/, + /^timeout\s+(?:(?:--foreground|--preserve-status)\s+|(?:-s|--signal)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?\d+(?:\.\d+)?[smhd]?\s+/, /^(?:uv|poetry)\s+run\s+/, /^python(?:3(?:\.\d+)?)?\s+-m\s+(?=pytest(?:\s|$))/, /^yarn\s+(?=(?:jest|vitest)(?:\s|$))/, diff --git a/test/verification.test.js b/test/verification.test.js index 9f9f823..84a6f2d 100644 --- a/test/verification.test.js +++ b/test/verification.test.js @@ -18,7 +18,7 @@ test('check names in arguments, quoted data and comments are not executions', () 'echo "npm test"', 'echo npm test', 'printf "%s\\n" "make"', 'rg pytest README.md', 'node -e "console.log(\'npm test\')"', 'git status # npm test', '# npm test\npwd', 'echo "notes; npm test"', "printf 'notes\nnpm test\n'", 'echo `pwd; npm test`', - 'npx echo npm test', 'npm exec echo "npm test"', + 'npx echo npm test', 'timeout --signal TERM 60 echo npm test', 'sudo -n echo npm test', 'npm exec echo "npm test"', 'echo # note; npm test', 'echo bash -c "npm test"', 'bash -c "echo npm test"', 'echo $(printf x; npm test)', 'echo $(echo $(printf x); npm test)', ]) { assert.equal(isVerificationCommand(command), false, command); @@ -31,7 +31,7 @@ test('command positions in chains, pipelines, environment assignments and npx wr 'npx --yes vitest run', 'npx --package=typescript tsc -p .', 'npx --package typescript tsc -p .', 'echo "notes; npm test"\nnode --test', 'curl http://x/#f && npm test', 'git log --format=%h#%s; npm test', 'cd $(git rev-parse --show-toplevel) && npm test', 'cd $(echo "$(pwd)") && npm test', - 'bash -c "npm test"', "sh -c 'cd app && npm test'", 'timeout 60 npm test', 'sudo npm test', 'time npm test', 'nohup npm test', 'env -i npm test', + 'bash -c "npm test"', "sh -c 'cd app && npm test'", 'timeout 60 npm test', 'timeout --signal=TERM 60 npm test', 'sudo npm test', 'sudo -n npm test', 'time npm test', 'time -p npm test', 'nohup npm test', 'env -i npm test', 'yarn vitest', 'bunx vitest', 'python -m pytest', 'uv run pytest', 'poetry run pytest', '{ npm test; }', 'if true; then npm test; fi', 'C:\\tools\\make.exe', ]) { From ec1270b8c8e34129b1004126dd51c97b187b2703 Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Tue, 3 Nov 2026 18:03:02 +0700 Subject: [PATCH 06/14] fix: recognize quoted verification executables and wrapper options --- lib/verification.js | 45 ++++++++++++++++++++++++++++----------- test/verification.test.js | 24 +++++++++++++++++++++ 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/lib/verification.js b/lib/verification.js index 023d40e..f2bee7f 100644 --- a/lib/verification.js +++ b/lib/verification.js @@ -36,7 +36,7 @@ const withoutHeredocBodies = (command) => command.replace(HEREDOC, '<<$2$3'); const SHELL_TOKEN = /\\[\s\S]|\x60[^\x60]*(?:\x60|$)|'(?:''|[^'])*(?:'|$)|"(?:\\[\s\S]|\x60[\s\S]|[^"\\\x60])*(?:"|$)|(? path.replaceAll('\\', '/')); let depth = 0; let start = 0; @@ -44,7 +44,9 @@ function withoutShellData(command) { for (const { 0: token, index } of input.matchAll(SHELL_TOKEN)) { if (depth === 0) { if (token === '(' || token === ')') continue; - output += input.slice(start, index) + '\0'; + const executable = literalExecutable(token); + const masked = executable ? '\0' + (quotedExecutables.push(executable) - 1) + '\0' : '\0'; + output += input.slice(start, index) + masked; start = index + token.length; } if (token === '$(' || (depth > 0 && token === '(')) depth += 1; @@ -58,32 +60,51 @@ function withoutShellData(command) { const WRAPPERS = [ /^(?:[A-Za-z_]\w*=\S*\s+)+/, - /^env\s+(?:(?:-i|--ignore-environment)\s+|[A-Za-z_]\w*=\S*\s+)*(?:--\s+)?/, + /^env\s+(?:(?:-i|--ignore-environment)\s+|(?:-u|--unset|-C|--chdir)(?:=\S+|\s+\S+)\s+|[A-Za-z_]\w*=\S*\s+)*(?:--\s+)?/, /^npx\s+(?:(?:-y|--yes|--no-install)\s+|(?:-p|--package)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/, - /^sudo\s+(?:(?:-n|--non-interactive)\s+)*(?:--\s+)?/, + /^sudo\s+(?:(?:-n|--non-interactive)\s+|(?:-u|--user|-g|--group)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/, /^time\s+(?:(?:-p|--portability)\s+)?(?:--\s+)?/, - /^(?:nohup|bunx)\s+(?:--\s+)?/, + /^(?:nohup|bunx|exec|!)\s+(?:--\s+)?/, /^timeout\s+(?:(?:--foreground|--preserve-status)\s+|(?:-s|--signal)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?\d+(?:\.\d+)?[smhd]?\s+/, /^(?:uv|poetry)\s+run\s+/, /^python(?:3(?:\.\d+)?)?\s+-m\s+(?=pytest(?:\s|$))/, /^yarn\s+(?=(?:jest|vitest)(?:\s|$))/, ]; -const withoutExecutablePath = (command) => command.replace(/^\S+/, (token) => token.split('/').at(-1).replace(/\.(?:exe|cmd|bat)$/i, '')); +const executableName = (path) => path.split('/').at(-1).replace(/\.(?:exe|cmd|bat)$/i, ''); + +function literalExecutable(token) { + const quote = token[0]; + if ((quote !== "'" && quote !== '"') || token.length < 2 || !token.endsWith(quote)) return null; + const path = token.slice(1, -1); + const windows = /^[A-Za-z]:[\\/]/.test(path); + if (quote === '"' && (/[#$%!\x60"]/.test(path) || (!windows && path.includes('\\')))) return null; + const name = executableName(windows ? path.replaceAll('\\', '/') : path); + return /^[\w.-]+$/.test(name) ? name : null; +} + +function withoutExecutablePath(command, quotedExecutables) { + return command.replace(/^\S+/, (token) => { + const quoted = /^\0(\d+)\0$/.exec(token); + if (quoted) return quotedExecutables[Number(quoted[1])]; + return token.includes('\0') ? token : executableName(token); + }); +} // Only unwrap a literal script passed directly to a shell. Expansions in a double-quoted script belong to the outer shell. const SHELL_SCRIPT = /^(?:bash|sh)\s+-c\s+(?:'([^']*)'|"([^"\x60$\\]*)")$/; export function isVerificationCommand(command) { - if (typeof command !== 'string') return false; + if (typeof command !== 'string' || command.includes('\0')) return false; const script = SHELL_SCRIPT.exec(command.trim()); - return withoutShellData(script ? script[1] ?? script[2] : command).split(/[;&|\r\n]+/).some((part) => { + const quotedExecutables = []; + return withoutShellData(script ? script[1] ?? script[2] : command, quotedExecutables).split(/[;&|\r\n]+/).some((part) => { let check = part.trim().replace(/^(?:(?:if|then|elif|else|do)\s+|[({]\s*)+/, ''); while (check) { + // An assignment may contain slashes but is still a wrapper before the executable. + if (!/^[A-Za-z_]\w*=/.test(check)) check = withoutExecutablePath(check, quotedExecutables); const wrapper = WRAPPERS.find((pattern) => pattern.test(check)); - const executable = wrapper ? check : withoutExecutablePath(check); - const prefix = wrapper ?? WRAPPERS.find((pattern) => pattern.test(executable)); - if (!prefix) return VERIFICATION_COMMANDS.some((pattern) => pattern.test(executable)); - check = executable.replace(prefix, ''); + if (!wrapper) return VERIFICATION_COMMANDS.some((pattern) => pattern.test(check)); + check = check.replace(wrapper, ''); } return false; }); diff --git a/test/verification.test.js b/test/verification.test.js index f3d3776..8415b7e 100644 --- a/test/verification.test.js +++ b/test/verification.test.js @@ -55,6 +55,30 @@ test('a known executable reached through a path still verifies, while a path in } }); +test('supported wrapper options still leave the actual command in command position', () => { + for (const command of [ + 'sudo -u root npm test', 'sudo --user=root -- npm test', 'env -u CI npm test', + 'env --unset=CI --chdir app npm test', 'timeout -s KILL 60 npm test', 'time -p npm test', + 'exec npm test', '! npm test', + ]) assert.equal(isVerificationCommand(command), true, command); + for (const command of [ + 'sudo -u root echo npm test', 'sudo -u npm test', 'env -u npm test', + 'env --chdir app echo npm test', 'exec echo npm test', '! echo npm test', + ]) assert.equal(isVerificationCommand(command), false, command); +}); + +test('literal quoted executables count only in command position', () => { + for (const command of [ + '"./node_modules/.bin/jest"', "'./path with spaces/gradlew' build", + 'sudo -u "build user" "./node_modules/.bin/jest"', '"/usr/bin/env" -u CI "./node_modules/.bin/jest"', + '"C:\\Program Files\\nodejs\\npm.cmd" test', + ]) assert.equal(isVerificationCommand(command), true, command); + for (const command of [ + 'echo "./node_modules/.bin/jest"', '"npm test"', '"$runner/jest"', + '"./node_modules/.bin/jest"-other', '"notes; npm test"', + ]) assert.equal(isVerificationCommand(command), false, command); +}); + test('a non-zero exit fails the verification', () => { assert.equal(verificationFailed({ isError: true, text: '' }), true); }); From 826f5804c0f0358dfd01968d3a9d53bcd507b6f5 Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Tue, 3 Nov 2026 18:33:00 +0700 Subject: [PATCH 07/14] Keep compound shell exits out of check verdicts --- hosts/workbuddy/transcript.js | 8 ++++---- lib/agent/defaults/mcp.js | 3 ++- lib/turn/record.js | 6 +++--- lib/verification.js | 29 +++++++++++++++++++++++----- test/agent/driver.test.js | 9 +++++++++ test/app/turn-lifecycle.test.js | 13 +++++++++++++ test/hosts/workbuddy/readers.test.js | 2 ++ test/verification.test.js | 28 +++++++++++++++++++++++++++ 8 files changed, 85 insertions(+), 13 deletions(-) diff --git a/hosts/workbuddy/transcript.js b/hosts/workbuddy/transcript.js index 2711462..9955b9c 100644 --- a/hosts/workbuddy/transcript.js +++ b/hosts/workbuddy/transcript.js @@ -83,10 +83,10 @@ const shellOutputOf = (text) => { return stdout === -1 ? text : text.slice(stdout); }; -function shellFailed(result) { - if (result.status !== 'completed') return true; +function shellFailed(command, result) { + if (result.status !== 'completed') return verificationFailureOf({ command, exit: 1 }); const exit = EXIT_CODE.exec(result.text); - return verificationFailureOf({ exit: exit ? Number(exit[1]) : null, text: shellOutputOf(result.text) }); + return verificationFailureOf({ command, exit: exit ? Number(exit[1]) : null, text: shellOutputOf(result.text) }); } const isCheck = (call) => SHELL_TOOLS.has(call.name) && call.args.run_in_background !== true && isVerificationCommand(call.args.command); @@ -121,7 +121,7 @@ export function turnActivityOf(records) { edits: calls.filter((call) => EDIT_TOOLS.includes(call.name) && editSucceeded(call.result)).map((call) => editedPathOf(call.args)).filter(Boolean), checks: calls .filter((call) => isCheck(call) && call.result) - .map((call) => ({ command: call.args.command.slice(0, CHECK_COMMAND_MAX_CHARS), failed: shellFailed(call.result) })), + .map((call) => ({ command: call.args.command.slice(0, CHECK_COMMAND_MAX_CHARS), failed: shellFailed(call.args.command, call.result) })), answer: answerOf(records), }; } diff --git a/lib/agent/defaults/mcp.js b/lib/agent/defaults/mcp.js index 382d925..1917b5b 100644 --- a/lib/agent/defaults/mcp.js +++ b/lib/agent/defaults/mcp.js @@ -2,6 +2,7 @@ // Copyright (c) 2026 EvoMap import { AGENT_REPORTED } from '../../app/turn-reports.js'; +import { verificationFailureOf } from '../../verification.js'; import { EVENTS } from '../events.js'; // An agent without hooks reaches Evolver through MCP tools. The connection is the session; a turn runs from the @@ -57,7 +58,7 @@ export const mcpEvidence = Object.freeze({ turnRecord: (payload) => { const checks = list(payload.checks) .filter((check) => typeof check?.command === 'string' && check.command) - .map((check) => ({ command: check.command.slice(0, 200), failed: check.exit !== 0 })); + .map(({ command, exit }) => ({ command: command.slice(0, 200), failed: verificationFailureOf({ command, exit }) })); const files = list(payload.files).filter((file) => typeof file === 'string' && file); return { checks, commands: checks.map((check) => check.command), paths: files, edits: files, answer: '' }; }, diff --git a/lib/turn/record.js b/lib/turn/record.js index bd69b76..bf8f15a 100644 --- a/lib/turn/record.js +++ b/lib/turn/record.js @@ -11,8 +11,8 @@ const CHECK_COMMAND_MAX_CHARS = 200; * { kind: 'edit', paths, lines?, identifiers? } * { kind: 'tool', command?, paths? } * { kind: 'answer', text } the turn's final answer, for a host that tells it before the turn's end - * An exit code decides a check when the host gives one; the output is read too, since a failing run piped - * through `tail` still exits zero. Without an exit code or a supported passing summary, `failed: null` keeps + * An exit code decides a check when it can be attributed to that check; a failing run piped through `tail` + * can still exit zero. Without an attributable exit or a supported passing summary, `failed: null` keeps * the check's result unknown. A command still running has no verdict: a later note with its `callId` * settles it, and one still pending when the turn ends counts as not run. `label` names a check to the person, and * `lines` counts what an edit changed and `identifiers` the names it introduced, where the host says. @@ -45,7 +45,7 @@ export function turnRecordOf(notes) { } function checkFailed(note) { - return verificationFailureOf({ exit: note.exit, text: note.output }); + return verificationFailureOf({ command: note.command, exit: note.exit, text: note.output }); } /** Whether a tool note is a finished test, build, lint or type-check that failed. */ diff --git a/lib/verification.js b/lib/verification.js index f2bee7f..79ad509 100644 --- a/lib/verification.js +++ b/lib/verification.js @@ -93,11 +93,15 @@ function withoutExecutablePath(command, quotedExecutables) { // Only unwrap a literal script passed directly to a shell. Expansions in a double-quoted script belong to the outer shell. const SHELL_SCRIPT = /^(?:bash|sh)\s+-c\s+(?:'([^']*)'|"([^"\x60$\\]*)")$/; +function shellCommandOf(command) { + const script = SHELL_SCRIPT.exec(command.trim()); + return script ? script[1] ?? script[2] : command; +} + export function isVerificationCommand(command) { if (typeof command !== 'string' || command.includes('\0')) return false; - const script = SHELL_SCRIPT.exec(command.trim()); const quotedExecutables = []; - return withoutShellData(script ? script[1] ?? script[2] : command, quotedExecutables).split(/[;&|\r\n]+/).some((part) => { + return withoutShellData(shellCommandOf(command), quotedExecutables).split(/[;&|\r\n]+/).some((part) => { let check = part.trim().replace(/^(?:(?:if|then|elif|else|do)\s+|[({]\s*)+/, ''); while (check) { // An assignment may contain slashes but is still a wrapper before the executable. @@ -123,10 +127,25 @@ const PASSED_COUNT = /^\s*(?:#|ℹ)\s+pass\s+[1-9]\d*\b/m; const ZERO_FAILED_COUNT = /^\s*(?:#|ℹ)\s+fail\s+0\b/m; const FAILED_COUNT = /^\s*(?:#|ℹ)\s+(?:fail|cancelled)\s+[1-9]\d*\b/m; +const SHELL_CONTROL = /[;&|()\r\n]|(?:^|[ \t])!(?:[ \t]|$)|^(?:if|then|elif|else|do|\{)(?:\s|$)/; + +function checkExitOf(command, exit) { + if (!Number.isInteger(exit) || typeof command !== 'string') return exit; + const shell = withoutShellData(shellCommandOf(command), []).replace(/[<>]&|&>|>\|/g, ' ').trim(); + if (!SHELL_CONTROL.test(shell)) return exit; + + // A successful directory change followed by one check implies the check ran and passed. + const [before, after, extra] = shell.split('&&', 3); + if (exit === 0 && after && extra === undefined && /^cd\s+\S/.test(before) + && !SHELL_CONTROL.test(before) && !SHELL_CONTROL.test(after.trim())) return exit; + return null; +} + /** A finished check's failed flag: true = failed, false = passed, null = unknown. */ -export function verificationFailureOf({ exit, text }) { +export function verificationFailureOf({ command, exit, text }) { const output = String(text ?? ''); - if (verificationFailed({ isError: Number.isInteger(exit) && exit !== 0, text: output }) || FAILED_COUNT.test(output)) return true; - if (exit === 0 || (PASSED_COUNT.test(output) && ZERO_FAILED_COUNT.test(output))) return false; + const checkExit = checkExitOf(command, exit); + if (verificationFailed({ isError: Number.isInteger(checkExit) && checkExit !== 0, text: output }) || FAILED_COUNT.test(output)) return true; + if (checkExit === 0 || (PASSED_COUNT.test(output) && ZERO_FAILED_COUNT.test(output))) return false; return null; } diff --git a/test/agent/driver.test.js b/test/agent/driver.test.js index 884f8c4..0041ecc 100644 --- a/test/agent/driver.test.js +++ b/test/agent/driver.test.js @@ -107,6 +107,15 @@ test('the MCP profile turns tool calls into a turn whose report is agent-reporte assert.match(status.content[0].text, /verified turn waiting to be distilled/); }); +test('a successful compound command reported through MCP does not open a distillation candidate', async () => { + const made = memoryPorts(); + const { driver } = driverOf(mcpGeneric, made); + await driver.handle('evolver_recall', { session_id: 'm1', turn_id: 't1', task: PROMPT }); + const reported = await driver.handle('evolver_report', { session_id: 'm1', turn_id: 't1', outcome: 'success', summary: 'Fixed the retries.', checks: [{ command: 'true || npm test', exit: 0 }], files: ['lib/redis.js'] }); + assert.equal(reported.content[0].text, 'Reported.'); + assert.equal((await made.ports.candidates.read()).m1, undefined); +}); + test('a session start answers the engine notice once, and a session end schedules distillation', async () => { const made = memoryPorts(); made.ports.engine.takeUnannounced = async () => ({ status: 'install_failed', error: 'npm missing', at: 1 }); diff --git a/test/app/turn-lifecycle.test.js b/test/app/turn-lifecycle.test.js index 9b53c1b..f257684 100644 --- a/test/app/turn-lifecycle.test.js +++ b/test/app/turn-lifecycle.test.js @@ -49,6 +49,19 @@ test('printing a check command does not verify a turn or raise its reuse evidenc assert.equal(reports[0].evidence.check, undefined); }); +test('a successful shell wrapper does not verify a turn when its check may have been skipped', async () => { + const { ports } = memoryPorts(); + const command = 'true || npm test'; + await promptSubmitted(ports, { sessionId: 's1', prompt: PROMPT, ...WAIT }); + await toolFinished(ports, { sessionId: 's1', note: { kind: 'edit', paths: ['/proj/lib/redis.js'] } }); + await toolFinished(ports, { sessionId: 's1', note: { kind: 'shell', command, exit: 0 } }); + const { record, reports } = await end(ports, 's1'); + assert.deepEqual(record.checks, [{ command, failed: null }]); + assert.equal((await ports.candidates.read()).s1, undefined); + assert.equal(reports[0].evidence.tier, 'none'); + assert.equal(reports[0].evidence.check, undefined); +}); + test('a verified turn becomes the candidate; a correction drops it before revising the reports', async () => { const { ports, queued } = memoryPorts(); await verifiedTurn(ports, 's1'); diff --git a/test/hosts/workbuddy/readers.test.js b/test/hosts/workbuddy/readers.test.js index 6c5aef7..14de844 100644 --- a/test/hosts/workbuddy/readers.test.js +++ b/test/hosts/workbuddy/readers.test.js @@ -86,6 +86,8 @@ test('an edit the host refused is no edited file, and a background run is no che test('a non-zero exit or failure output fails a check; an incomplete call failed; one without a result says nothing', () => { const checksOf = (records) => turnOf(transcriptWith(records.map(line).join('')), null).checks; assert.equal(checksOf(bashRecords('npm run build', shellText('npm run build', 'done', 2)))[0].failed, true); + assert.equal(checksOf(bashRecords('true || npm test', shellText('true || npm test', 'done', 0)))[0].failed, null); + assert.equal(checksOf(bashRecords('false && npm test', shellText('false && npm test', 'done', 1)))[0].failed, null); assert.equal(checksOf(bashRecords('npm test | tail -3', shellText('npm test | tail -3', 'AssertionError: expected 1 to equal 2\n1 test failed', 0)))[0].failed, true); assert.equal(checksOf(bashRecords('npm test -- --grep "test failed"', shellText('npm test', 'ℹ pass 4\nℹ fail 0', 0)))[0].failed, false); assert.equal(checksOf(bashRecords('npm test', 'Interrupted', { status: 'incomplete' }))[0].failed, true); diff --git a/test/verification.test.js b/test/verification.test.js index 8415b7e..219316c 100644 --- a/test/verification.test.js +++ b/test/verification.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { isFailedCheck, turnRecordOf } from '../lib/turn/record.js'; import { isVerificationCommand, verificationFailed, verificationFailureOf } from '../lib/verification.js'; test('test, build, lint and type-check runs are verifications; other commands are not', () => { @@ -129,3 +130,30 @@ test('a heredoc body that names a check is data, not a check; the command lines assert.equal(isVerificationCommand("cat > notes.md < { + const compounds = ['true || npm test', 'npm test || true', 'npm test | tail -5', 'npm test &', + 'npm test; echo done', 'false && npm test', 'echo start\nnpm test', '! npm test', 'time ! npm test', + 'if npm test; then true; fi', 'bash -c "npm test || true"']; + for (const command of compounds) { + const result = { kind: 'shell', command }; + for (const exit of [0, 1]) { + assert.deepEqual(turnRecordOf([{ ...result, exit }]).checks, [{ command, failed: null }]); + assert.equal(isFailedCheck({ ...result, exit }), false, command); + assert.equal(turnRecordOf([{ ...result, exit, output: '# pass 3\n# fail 0' }]).checks[0].failed, false, command); + assert.equal(isFailedCheck({ ...result, exit, output: '# pass 3\n# fail 1' }), true, command); + } + } +}); + +test('literal shell text and descriptor redirects preserve a direct check exit', () => { + const directoryCheck = { kind: 'shell', command: 'cd app && npm test' }; + assert.equal(turnRecordOf([{ ...directoryCheck, exit: 0 }]).checks[0].failed, false); + assert.equal(turnRecordOf([{ ...directoryCheck, exit: 1 }]).checks[0].failed, null); + for (const command of ['npm test -- --grep "a|b; c && d"', 'npm test 2>&1', + 'npm test &> report.log', 'npm test >| report.log', 'npm test # after; ignore', + "sh -c 'npm test'", 'npm test\n', 'npm test < Date: Wed, 4 Nov 2026 09:40:11 +0700 Subject: [PATCH 08/14] Preserve host check failures and align Grok compound evidence --- hosts/workbuddy/transcript.js | 2 +- test/hosts/grok/readers.test.js | 17 ++++++++++++++--- test/hosts/workbuddy/readers.test.js | 1 + 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/hosts/workbuddy/transcript.js b/hosts/workbuddy/transcript.js index 9955b9c..0b93cfc 100644 --- a/hosts/workbuddy/transcript.js +++ b/hosts/workbuddy/transcript.js @@ -84,7 +84,7 @@ const shellOutputOf = (text) => { }; function shellFailed(command, result) { - if (result.status !== 'completed') return verificationFailureOf({ command, exit: 1 }); + if (result.status !== 'completed') return true; const exit = EXIT_CODE.exec(result.text); return verificationFailureOf({ command, exit: exit ? Number(exit[1]) : null, text: shellOutputOf(result.text) }); } diff --git a/test/hosts/grok/readers.test.js b/test/hosts/grok/readers.test.js index 5f15b45..0b766ea 100644 --- a/test/hosts/grok/readers.test.js +++ b/test/hosts/grok/readers.test.js @@ -49,15 +49,26 @@ test('a shell result carries its exit code; failure then pass reads as a fixed c assert.deepEqual(record.paths, ['/Users/you/project/README.md']); }); -test('a call grok moves to the background has no verdict until its task\'s output settles it under the same call id', () => { +test('Grok joins background task output by call id and requires check evidence for a compound command', () => { const started = noteOf('PostToolUse.run_terminal_command.background'); assert.deepEqual(started, { kind: 'shell', command: 'sleep 2; npm test', running: true, callId: 'call_3' }); assert.deepEqual(turnRecordOf([started]).checks, [], 'a check still running is no pass'); const output = noteOf('PostToolUse.get_command_or_subagent_output'); assert.deepEqual(output, { kind: 'shell', command: 'sleep 2; npm test', exit: 1, output: '\n> test\n> sh check.sh\n\n1 failing\n', callId: 'call_3' }); - const record = turnRecordOf([noteOf('PostToolUse.run_terminal_command.backgroundForTaskOutput'), output]); - assert.deepEqual(record.checks, [{ command: 'sleep 2; npm test', failed: true }]); + const pending = noteOf('PostToolUse.run_terminal_command.backgroundForTaskOutput'); + const record = turnRecordOf([pending, output]); + assert.deepEqual(record.checks, [{ command: 'sleep 2; npm test', failed: null }]); assert.deepEqual(record.commands, ['sleep 2; npm test'], 'its output is the same command, not a second one'); + for (const [stdout, failed] of [ + ['done', null], + ['TAP version 13\n# pass 1\n# fail 0\n', false], + ['TAP version 13\n# pass 0\n# fail 1\n', true], + ]) { + const completed = payload('PostToolUse.get_command_or_subagent_output'); + Object.assign(completed.toolResult.Result, { status: 'completed', exit_code: 0, output: stdout }); + const note = grok.evidence.toolNote(completed); + assert.deepEqual(turnRecordOf([pending, note]).checks, [{ command: 'sleep 2; npm test', failed }]); + } const stillRunning = { ...payload('PostToolUse.get_command_or_subagent_output') }; stillRunning.toolResult = { ...stillRunning.toolResult, Result: { ...stillRunning.toolResult.Result, status: 'running', exit_code: null } }; assert.deepEqual(grok.evidence.toolNote(stillRunning), { kind: 'shell', command: 'sleep 2; npm test', running: true, callId: 'call_3' }); diff --git a/test/hosts/workbuddy/readers.test.js b/test/hosts/workbuddy/readers.test.js index 14de844..adf17e8 100644 --- a/test/hosts/workbuddy/readers.test.js +++ b/test/hosts/workbuddy/readers.test.js @@ -91,6 +91,7 @@ test('a non-zero exit or failure output fails a check; an incomplete call failed assert.equal(checksOf(bashRecords('npm test | tail -3', shellText('npm test | tail -3', 'AssertionError: expected 1 to equal 2\n1 test failed', 0)))[0].failed, true); assert.equal(checksOf(bashRecords('npm test -- --grep "test failed"', shellText('npm test', 'ℹ pass 4\nℹ fail 0', 0)))[0].failed, false); assert.equal(checksOf(bashRecords('npm test', 'Interrupted', { status: 'incomplete' }))[0].failed, true); + assert.equal(checksOf(bashRecords('npm test | tail -3', 'Interrupted', { status: 'incomplete' }))[0].failed, true); assert.equal(checksOf(bashRecords('npm test', 'boom\n'))[0].failed, null, 'completed says the tool returned, not that its process exited zero'); assert.deepEqual(checksOf([bashRecords('npm test', '')[0]]), []); assert.equal(checksOf(bashRecords('npm test', shellText('npm test', 'ℹ fail 0', 0)).map((record) => ({ ...record, name: 'PowerShell' }))).length, 1); From 927613aac27ebbd0c865ace6f3a6911f4cb57382 Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Wed, 4 Nov 2026 10:26:52 +0700 Subject: [PATCH 09/14] Recognize literal shell and package manager wrappers --- lib/verification.js | 17 ++++++++++++----- test/verification.test.js | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/lib/verification.js b/lib/verification.js index f2bee7f..a1bbdd3 100644 --- a/lib/verification.js +++ b/lib/verification.js @@ -62,9 +62,12 @@ const WRAPPERS = [ /^(?:[A-Za-z_]\w*=\S*\s+)+/, /^env\s+(?:(?:-i|--ignore-environment)\s+|(?:-u|--unset|-C|--chdir)(?:=\S+|\s+\S+)\s+|[A-Za-z_]\w*=\S*\s+)*(?:--\s+)?/, /^npx\s+(?:(?:-y|--yes|--no-install)\s+|(?:-p|--package)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/, + /^npm\s+exec\s+(?:(?:-y|--yes|--no)\s+|--package(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/, + /^corepack\s+(?=(?:npm|pnpm|yarn)\s)/, + /^command\s+(?:-p\s+)?(?:--\s+)?/, /^sudo\s+(?:(?:-n|--non-interactive)\s+|(?:-u|--user|-g|--group)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/, /^time\s+(?:(?:-p|--portability)\s+)?(?:--\s+)?/, - /^(?:nohup|bunx|exec|!)\s+(?:--\s+)?/, + /^(?:nohup|bunx|exec)\s+(?:--\s+)?/, /^timeout\s+(?:(?:--foreground|--preserve-status)\s+|(?:-s|--signal)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?\d+(?:\.\d+)?[smhd]?\s+/, /^(?:uv|poetry)\s+run\s+/, /^python(?:3(?:\.\d+)?)?\s+-m\s+(?=pytest(?:\s|$))/, @@ -91,14 +94,18 @@ function withoutExecutablePath(command, quotedExecutables) { } // Only unwrap a literal script passed directly to a shell. Expansions in a double-quoted script belong to the outer shell. -const SHELL_SCRIPT = /^(?:bash|sh)\s+-c\s+(?:'([^']*)'|"([^"\x60$\\]*)")$/; +const SHELL_SCRIPT = /^(?:[\w./-]*\/)?(?:bash|sh)[ \t]+-l?c[ \t]+(?:'([^']*)'|"([^"\x60$\\]*)")$/; + +function shellCommandOf(command) { + const script = SHELL_SCRIPT.exec(command.trim()); + return script ? script[1] ?? script[2] : command; +} export function isVerificationCommand(command) { if (typeof command !== 'string' || command.includes('\0')) return false; - const script = SHELL_SCRIPT.exec(command.trim()); const quotedExecutables = []; - return withoutShellData(script ? script[1] ?? script[2] : command, quotedExecutables).split(/[;&|\r\n]+/).some((part) => { - let check = part.trim().replace(/^(?:(?:if|then|elif|else|do)\s+|[({]\s*)+/, ''); + return withoutShellData(shellCommandOf(command), quotedExecutables).split(/[;&|\r\n]+/).some((part) => { + let check = part.trim().replace(/^(?:(?:if|then|elif|else|do|!)\s+|[({]\s*)+/, ''); while (check) { // An assignment may contain slashes but is still a wrapper before the executable. if (!/^[A-Za-z_]\w*=/.test(check)) check = withoutExecutablePath(check, quotedExecutables); diff --git a/test/verification.test.js b/test/verification.test.js index 8415b7e..54c34a9 100644 --- a/test/verification.test.js +++ b/test/verification.test.js @@ -67,6 +67,21 @@ test('supported wrapper options still leave the actual command in command positi ]) assert.equal(isVerificationCommand(command), false, command); }); +test('literal shell and package-manager wrappers recognise only the command they execute', () => { + for (const command of [ + "/bin/bash -c 'npm test'", 'bash -lc "npm test"', "/usr/bin/bash -lc 'cd app && npm test'", + 'corepack pnpm test', 'corepack yarn vitest run', 'command npm test', 'command -p -- npm test', + 'npm exec -- vitest run', 'npm exec --yes --package typescript -- tsc --noEmit', + 'corepack npm exec --package=vitest -- vitest run', + ]) assert.equal(isVerificationCommand(command), true, command); + for (const command of [ + "echo /bin/bash -c 'npm test'", '/bin/bash -c "echo npm test"', 'bash -lc "$CHECK"', "bash -lc\n'npm test'", + 'corepack enable pnpm', 'corepack echo npm test', 'command -v npm test', 'command -V npm test', + 'command -- echo npm test', 'command ! npm test', 'npm exec -- echo "vitest run"', 'npm exec --package=vitest -- echo vitest', + "npm exec -c 'echo vitest run'", + ]) assert.equal(isVerificationCommand(command), false, command); +}); + test('literal quoted executables count only in command position', () => { for (const command of [ '"./node_modules/.bin/jest"', "'./path with spaces/gradlew' build", From b12448d9243804eae212faae67ea8efe91693fd1 Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Wed, 4 Nov 2026 10:27:54 +0700 Subject: [PATCH 10/14] Keep shell negation after the time keyword --- lib/verification.js | 2 +- test/verification.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/verification.js b/lib/verification.js index a1bbdd3..58e3a2d 100644 --- a/lib/verification.js +++ b/lib/verification.js @@ -66,7 +66,7 @@ const WRAPPERS = [ /^corepack\s+(?=(?:npm|pnpm|yarn)\s)/, /^command\s+(?:-p\s+)?(?:--\s+)?/, /^sudo\s+(?:(?:-n|--non-interactive)\s+|(?:-u|--user|-g|--group)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/, - /^time\s+(?:(?:-p|--portability)\s+)?(?:--\s+)?/, + /^time\s+(?:(?:-p|--portability)\s+)?(?:--\s+)?(?:!\s+)?/, /^(?:nohup|bunx|exec)\s+(?:--\s+)?/, /^timeout\s+(?:(?:--foreground|--preserve-status)\s+|(?:-s|--signal)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?\d+(?:\.\d+)?[smhd]?\s+/, /^(?:uv|poetry)\s+run\s+/, diff --git a/test/verification.test.js b/test/verification.test.js index 54c34a9..d00db92 100644 --- a/test/verification.test.js +++ b/test/verification.test.js @@ -59,7 +59,7 @@ test('supported wrapper options still leave the actual command in command positi for (const command of [ 'sudo -u root npm test', 'sudo --user=root -- npm test', 'env -u CI npm test', 'env --unset=CI --chdir app npm test', 'timeout -s KILL 60 npm test', 'time -p npm test', - 'exec npm test', '! npm test', + 'exec npm test', '! npm test', 'time ! npm test', ]) assert.equal(isVerificationCommand(command), true, command); for (const command of [ 'sudo -u root echo npm test', 'sudo -u npm test', 'env -u npm test', From 5714bda7ad09588bd4da3fa4d7507a07ebcdae0e Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Wed, 4 Nov 2026 10:28:48 +0700 Subject: [PATCH 11/14] Keep compound verdicts correct for newly recognized wrappers --- lib/verification.js | 11 +++++++---- test/verification.test.js | 24 +++++++++++++++++++++--- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/lib/verification.js b/lib/verification.js index 79ad509..0e47203 100644 --- a/lib/verification.js +++ b/lib/verification.js @@ -62,9 +62,12 @@ const WRAPPERS = [ /^(?:[A-Za-z_]\w*=\S*\s+)+/, /^env\s+(?:(?:-i|--ignore-environment)\s+|(?:-u|--unset|-C|--chdir)(?:=\S+|\s+\S+)\s+|[A-Za-z_]\w*=\S*\s+)*(?:--\s+)?/, /^npx\s+(?:(?:-y|--yes|--no-install)\s+|(?:-p|--package)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/, + /^npm\s+exec\s+(?:(?:-y|--yes|--no)\s+|--package(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/, + /^corepack\s+(?=(?:npm|pnpm|yarn)\s)/, + /^command\s+(?:-p\s+)?(?:--\s+)?/, /^sudo\s+(?:(?:-n|--non-interactive)\s+|(?:-u|--user|-g|--group)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/, - /^time\s+(?:(?:-p|--portability)\s+)?(?:--\s+)?/, - /^(?:nohup|bunx|exec|!)\s+(?:--\s+)?/, + /^time\s+(?:(?:-p|--portability)\s+)?(?:--\s+)?(?:!\s+)?/, + /^(?:nohup|bunx|exec)\s+(?:--\s+)?/, /^timeout\s+(?:(?:--foreground|--preserve-status)\s+|(?:-s|--signal)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?\d+(?:\.\d+)?[smhd]?\s+/, /^(?:uv|poetry)\s+run\s+/, /^python(?:3(?:\.\d+)?)?\s+-m\s+(?=pytest(?:\s|$))/, @@ -91,7 +94,7 @@ function withoutExecutablePath(command, quotedExecutables) { } // Only unwrap a literal script passed directly to a shell. Expansions in a double-quoted script belong to the outer shell. -const SHELL_SCRIPT = /^(?:bash|sh)\s+-c\s+(?:'([^']*)'|"([^"\x60$\\]*)")$/; +const SHELL_SCRIPT = /^(?:[\w./-]*\/)?(?:bash|sh)[ \t]+-l?c[ \t]+(?:'([^']*)'|"([^"\x60$\\]*)")$/; function shellCommandOf(command) { const script = SHELL_SCRIPT.exec(command.trim()); @@ -102,7 +105,7 @@ export function isVerificationCommand(command) { if (typeof command !== 'string' || command.includes('\0')) return false; const quotedExecutables = []; return withoutShellData(shellCommandOf(command), quotedExecutables).split(/[;&|\r\n]+/).some((part) => { - let check = part.trim().replace(/^(?:(?:if|then|elif|else|do)\s+|[({]\s*)+/, ''); + let check = part.trim().replace(/^(?:(?:if|then|elif|else|do|!)\s+|[({]\s*)+/, ''); while (check) { // An assignment may contain slashes but is still a wrapper before the executable. if (!/^[A-Za-z_]\w*=/.test(check)) check = withoutExecutablePath(check, quotedExecutables); diff --git a/test/verification.test.js b/test/verification.test.js index 219316c..d371684 100644 --- a/test/verification.test.js +++ b/test/verification.test.js @@ -60,7 +60,7 @@ test('supported wrapper options still leave the actual command in command positi for (const command of [ 'sudo -u root npm test', 'sudo --user=root -- npm test', 'env -u CI npm test', 'env --unset=CI --chdir app npm test', 'timeout -s KILL 60 npm test', 'time -p npm test', - 'exec npm test', '! npm test', + 'exec npm test', '! npm test', 'time ! npm test', ]) assert.equal(isVerificationCommand(command), true, command); for (const command of [ 'sudo -u root echo npm test', 'sudo -u npm test', 'env -u npm test', @@ -68,6 +68,21 @@ test('supported wrapper options still leave the actual command in command positi ]) assert.equal(isVerificationCommand(command), false, command); }); +test('literal shell and package-manager wrappers recognise only the command they execute', () => { + for (const command of [ + "/bin/bash -c 'npm test'", 'bash -lc "npm test"', "/usr/bin/bash -lc 'cd app && npm test'", + 'corepack pnpm test', 'corepack yarn vitest run', 'command npm test', 'command -p -- npm test', + 'npm exec -- vitest run', 'npm exec --yes --package typescript -- tsc --noEmit', + 'corepack npm exec --package=vitest -- vitest run', + ]) assert.equal(isVerificationCommand(command), true, command); + for (const command of [ + "echo /bin/bash -c 'npm test'", '/bin/bash -c "echo npm test"', 'bash -lc "$CHECK"', "bash -lc\n'npm test'", + 'corepack enable pnpm', 'corepack echo npm test', 'command -v npm test', 'command -V npm test', + 'command -- echo npm test', 'command ! npm test', 'npm exec -- echo "vitest run"', 'npm exec --package=vitest -- echo vitest', + "npm exec -c 'echo vitest run'", + ]) assert.equal(isVerificationCommand(command), false, command); +}); + test('literal quoted executables count only in command position', () => { for (const command of [ '"./node_modules/.bin/jest"', "'./path with spaces/gradlew' build", @@ -134,7 +149,9 @@ test('a heredoc body that names a check is data, not a check; the command lines test('a compound shell result needs check output before it can verify the check', () => { const compounds = ['true || npm test', 'npm test || true', 'npm test | tail -5', 'npm test &', 'npm test; echo done', 'false && npm test', 'echo start\nnpm test', '! npm test', 'time ! npm test', - 'if npm test; then true; fi', 'bash -c "npm test || true"']; + 'if npm test; then true; fi', 'bash -c "npm test || true"', + "/bin/bash -c 'true || npm test'", 'bash -lc "npm test || true"', + 'corepack pnpm test | tail -5', 'command npm test || true', 'npm exec -- vitest run || true']; for (const command of compounds) { const result = { kind: 'shell', command }; for (const exit of [0, 1]) { @@ -152,7 +169,8 @@ test('literal shell text and descriptor redirects preserve a direct check exit', assert.equal(turnRecordOf([{ ...directoryCheck, exit: 1 }]).checks[0].failed, null); for (const command of ['npm test -- --grep "a|b; c && d"', 'npm test 2>&1', 'npm test &> report.log', 'npm test >| report.log', 'npm test # after; ignore', - "sh -c 'npm test'", 'npm test\n', 'npm test < Date: Wed, 4 Nov 2026 11:43:09 +0700 Subject: [PATCH 12/14] Recognize literal hashes after escapes and pnpm exec checks --- lib/verification.js | 4 +++- test/verification.test.js | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/verification.js b/lib/verification.js index 58e3a2d..1fb3cc4 100644 --- a/lib/verification.js +++ b/lib/verification.js @@ -33,7 +33,8 @@ const withoutHeredocBodies = (command) => command.replace(HEREDOC, '<<$2$3'); // Keep separators inside strings, backticks and comments out of the command scan. A name in an argument is no // check. This recognises direct commands and known wrappers; it does not evaluate arbitrary shell control flow. -const SHELL_TOKEN = /\\[\s\S]|\x60[^\x60]*(?:\x60|$)|'(?:''|[^'])*(?:'|$)|"(?:\\[\s\S]|\x60[\s\S]|[^"\\\x60])*(?:"|$)|(? { + for (const command of [ + String.raw`echo arg\ #literal && npm test`, 'echo arg\\\t#literal; npm test', + String.raw`echo arg\\\ #literal; npm test`, String.raw`echo $(echo arg\ #literal) && npm test`, + String.raw`echo arg\;#literal && npm test`, + ]) assert.equal(isVerificationCommand(command), true, command); + for (const command of [ + String.raw`echo arg\\ # comment; npm test`, String.raw`echo arg\ # comment; npm test`, + 'echo "arg " # "$(; npm test\npwd', + ]) assert.equal(isVerificationCommand(command), false, command); + assert.equal(isVerificationCommand('echo # "$( ignored\nnpm test'), true); +}); + test('command positions in chains, pipelines, environment assignments and npx wrappers still count', () => { for (const command of [ 'sleep 2; node --test', 'npm test 2>&1 | tail -5', 'CI=1 npm test', 'env CI=1 npm test', @@ -73,12 +86,14 @@ test('literal shell and package-manager wrappers recognise only the command they 'corepack pnpm test', 'corepack yarn vitest run', 'command npm test', 'command -p -- npm test', 'npm exec -- vitest run', 'npm exec --yes --package typescript -- tsc --noEmit', 'corepack npm exec --package=vitest -- vitest run', + 'pnpm exec vitest run', 'pnpm exec -- vitest run', 'corepack pnpm exec vitest run', 'pnpm -r exec jest', 'pnpm exec --recursive jest', ]) assert.equal(isVerificationCommand(command), true, command); for (const command of [ "echo /bin/bash -c 'npm test'", '/bin/bash -c "echo npm test"', 'bash -lc "$CHECK"', "bash -lc\n'npm test'", 'corepack enable pnpm', 'corepack echo npm test', 'command -v npm test', 'command -V npm test', 'command -- echo npm test', 'command ! npm test', 'npm exec -- echo "vitest run"', 'npm exec --package=vitest -- echo vitest', "npm exec -c 'echo vitest run'", + 'echo pnpm exec vitest run', 'pnpm exec echo vitest run', 'pnpm exec --help vitest', ]) assert.equal(isVerificationCommand(command), false, command); }); From 10a46ceafa9c28b92c43078495777b7f644b6040 Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Wed, 4 Nov 2026 11:43:09 +0700 Subject: [PATCH 13/14] Recognize literal hashes after escapes and pnpm exec checks (cherry picked from commit c3a2bbcf4da6bb1fd741eb2359f5e2ffdca86f55) --- lib/verification.js | 4 +++- test/verification.test.js | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/verification.js b/lib/verification.js index 0e47203..91486b1 100644 --- a/lib/verification.js +++ b/lib/verification.js @@ -33,7 +33,8 @@ const withoutHeredocBodies = (command) => command.replace(HEREDOC, '<<$2$3'); // Keep separators inside strings, backticks and comments out of the command scan. A name in an argument is no // check. This recognises direct commands and known wrappers; it does not evaluate arbitrary shell control flow. -const SHELL_TOKEN = /\\[\s\S]|\x60[^\x60]*(?:\x60|$)|'(?:''|[^'])*(?:'|$)|"(?:\\[\s\S]|\x60[\s\S]|[^"\\\x60])*(?:"|$)|(? { + for (const command of [ + String.raw`echo arg\ #literal && npm test`, 'echo arg\\\t#literal; npm test', + String.raw`echo arg\\\ #literal; npm test`, String.raw`echo $(echo arg\ #literal) && npm test`, + String.raw`echo arg\;#literal && npm test`, + ]) assert.equal(isVerificationCommand(command), true, command); + for (const command of [ + String.raw`echo arg\\ # comment; npm test`, String.raw`echo arg\ # comment; npm test`, + 'echo "arg " # "$(; npm test\npwd', + ]) assert.equal(isVerificationCommand(command), false, command); + assert.equal(isVerificationCommand('echo # "$( ignored\nnpm test'), true); +}); + test('command positions in chains, pipelines, environment assignments and npx wrappers still count', () => { for (const command of [ 'sleep 2; node --test', 'npm test 2>&1 | tail -5', 'CI=1 npm test', 'env CI=1 npm test', @@ -74,12 +87,14 @@ test('literal shell and package-manager wrappers recognise only the command they 'corepack pnpm test', 'corepack yarn vitest run', 'command npm test', 'command -p -- npm test', 'npm exec -- vitest run', 'npm exec --yes --package typescript -- tsc --noEmit', 'corepack npm exec --package=vitest -- vitest run', + 'pnpm exec vitest run', 'pnpm exec -- vitest run', 'corepack pnpm exec vitest run', 'pnpm -r exec jest', 'pnpm exec --recursive jest', ]) assert.equal(isVerificationCommand(command), true, command); for (const command of [ "echo /bin/bash -c 'npm test'", '/bin/bash -c "echo npm test"', 'bash -lc "$CHECK"', "bash -lc\n'npm test'", 'corepack enable pnpm', 'corepack echo npm test', 'command -v npm test', 'command -V npm test', 'command -- echo npm test', 'command ! npm test', 'npm exec -- echo "vitest run"', 'npm exec --package=vitest -- echo vitest', "npm exec -c 'echo vitest run'", + 'echo pnpm exec vitest run', 'pnpm exec echo vitest run', 'pnpm exec --help vitest', ]) assert.equal(isVerificationCommand(command), false, command); }); From 94cf63c0791827233d4f73eb05606a86bf919b27 Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Thu, 5 Nov 2026 10:50:02 +0700 Subject: [PATCH 14/14] Recognize literal shell scripts in wrapped and chained commands --- lib/verification.js | 51 ++++++++++++++++++++++----------------- test/verification.test.js | 18 ++++++++++++++ 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/lib/verification.js b/lib/verification.js index 1fb3cc4..bd63835 100644 --- a/lib/verification.js +++ b/lib/verification.js @@ -37,16 +37,17 @@ const withoutHeredocBodies = (command) => command.replace(HEREDOC, '<<$2$3'); const SHELL_TOKEN = /\\(?:[ \t;&|()]#|[\s\S])|\x60[^\x60]*(?:\x60|$)|'(?:''|[^'])*(?:'|$)|"(?:\\[\s\S]|\x60[\s\S]|[^"\\\x60])*(?:"|$)|(? path.replaceAll('\\', '/')); +function withoutShellData(command, quotedTokens) { + const shell = withoutHeredocBodies(command); + const input = shell.replace(WINDOWS_PATH, (path) => path.replaceAll('\\', '/')); let depth = 0; let start = 0; let output = ''; for (const { 0: token, index } of input.matchAll(SHELL_TOKEN)) { if (depth === 0) { if (token === '(' || token === ')') continue; - const executable = literalExecutable(token); - const masked = executable ? '\0' + (quotedExecutables.push(executable) - 1) + '\0' : '\0'; + const quoted = token[0] === '"' || token[0] === "'"; + const masked = quoted ? '\0' + (quotedTokens.push(shell.slice(index, index + token.length)) - 1) + '\0' : '\0'; output += input.slice(start, index) + masked; start = index + token.length; } @@ -87,35 +88,41 @@ function literalExecutable(token) { return /^[\w.-]+$/.test(name) ? name : null; } -function withoutExecutablePath(command, quotedExecutables) { +function withoutExecutablePath(command, quotedTokens) { return command.replace(/^\S+/, (token) => { const quoted = /^\0(\d+)\0$/.exec(token); - if (quoted) return quotedExecutables[Number(quoted[1])]; + if (quoted) return literalExecutable(quotedTokens[Number(quoted[1])]) ?? token; return token.includes('\0') ? token : executableName(token); }); } -// Only unwrap a literal script passed directly to a shell. Expansions in a double-quoted script belong to the outer shell. -const SHELL_SCRIPT = /^(?:[\w./-]*\/)?(?:bash|sh)[ \t]+-l?c[ \t]+(?:'([^']*)'|"([^"\x60$\\]*)")$/; +function executableCommandOf(part, quotedTokens) { + let check = part.trim().replace(/^(?:(?:if|then|elif|else|do|!)\s+|[({]\s*)+/, ''); + while (check) { + // An assignment may contain slashes but is still a wrapper before the executable. + if (!/^[A-Za-z_]\w*=/.test(check)) check = withoutExecutablePath(check, quotedTokens); + const wrapper = WRAPPERS.find((pattern) => pattern.test(check)); + if (!wrapper) break; + check = check.replace(wrapper, ''); + } + return check; +} -function shellCommandOf(command) { - const script = SHELL_SCRIPT.exec(command.trim()); - return script ? script[1] ?? script[2] : command; +// Only unwrap a whole literal argument. Double-quoted expansions belong to the outer shell. +function literalScriptOf(check, quotedTokens) { + const argument = /^(?:bash|sh)[ \t]+-l?c[ \t]+\0(\d+)\0$/.exec(check); + if (!argument) return null; + const script = /^(?:'([^']*)'|"([^"\x60$\\]*)")$/.exec(quotedTokens[Number(argument[1])]); + return script ? script[1] ?? script[2] : null; } export function isVerificationCommand(command) { if (typeof command !== 'string' || command.includes('\0')) return false; - const quotedExecutables = []; - return withoutShellData(shellCommandOf(command), quotedExecutables).split(/[;&|\r\n]+/).some((part) => { - let check = part.trim().replace(/^(?:(?:if|then|elif|else|do|!)\s+|[({]\s*)+/, ''); - while (check) { - // An assignment may contain slashes but is still a wrapper before the executable. - if (!/^[A-Za-z_]\w*=/.test(check)) check = withoutExecutablePath(check, quotedExecutables); - const wrapper = WRAPPERS.find((pattern) => pattern.test(check)); - if (!wrapper) return VERIFICATION_COMMANDS.some((pattern) => pattern.test(check)); - check = check.replace(wrapper, ''); - } - return false; + const quotedTokens = []; + return withoutShellData(command, quotedTokens).split(/[;&|\r\n]+/).some((part) => { + const check = executableCommandOf(part, quotedTokens); + const script = literalScriptOf(check, quotedTokens); + return script === null ? VERIFICATION_COMMANDS.some((pattern) => pattern.test(check)) : isVerificationCommand(script); }); } diff --git a/test/verification.test.js b/test/verification.test.js index 8ced2a3..c4a1fb6 100644 --- a/test/verification.test.js +++ b/test/verification.test.js @@ -97,6 +97,24 @@ test('literal shell and package-manager wrappers recognise only the command they ]) assert.equal(isVerificationCommand(command), false, command); }); +test('a literal shell script runs in a chained or wrapped command position', () => { + for (const command of [ + "cd app && /bin/bash -c 'npm test'", + 'env CI=1 bash -c "npm test"', + "sudo -u root -- bash -c 'npm test'", + "cd app && env -i CI=1 sudo -n '/usr/bin/bash' -lc 'npm test'", + ]) assert.equal(isVerificationCommand(command), true, command); + for (const command of [ + "cd app && echo bash -c 'npm test'", + "env CI=1 bash -c 'echo npm test'", + "sudo -u bash -c 'npm test'", + 'sudo -n bash -c "$CHECK"', + 'cd app && bash -c "$(echo npm test)"', + "cd app && bash -c 'npm test'$EXTRA", + "echo $(bash -c 'npm test')", + ]) assert.equal(isVerificationCommand(command), false, command); +}); + test('literal quoted executables count only in command position', () => { for (const command of [ '"./node_modules/.bin/jest"', "'./path with spaces/gradlew' build",