From aa57d06ee9862b1d0cf299f9ebb0f497d071adae Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Mon, 2 Nov 2026 11:09:28 +0700 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 927613aac27ebbd0c865ace6f3a6911f4cb57382 Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Wed, 4 Nov 2026 10:26:52 +0700 Subject: [PATCH 7/9] 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 8/9] 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 c3a2bbcf4da6bb1fd741eb2359f5e2ffdca86f55 Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Wed, 4 Nov 2026 11:43:09 +0700 Subject: [PATCH 9/9] 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); });