From 91fc34e6d6c506dab11d7567df7c7ab2fa0d5aa5 Mon Sep 17 00:00:00 2001 From: L4XB Date: Fri, 23 Oct 2026 07:45:32 +0200 Subject: [PATCH] fix(hooks): end echo output with its newline when group writes are joined echo writes a newline unless -n is given, so { echo 'git pu'; echo 'sh --no-verify'; } | sh feeds sh two separate lines. The joined read glued them into git push --no-verify and blocked the group. echoText() and printf's %s\n branch now end their text with the newline they write, while echo -n and printf without one still join directly. --- scripts/hooks/block-no-verify.js | 13 +++++++++++-- tests/hooks/block-no-verify.test.js | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/hooks/block-no-verify.js b/scripts/hooks/block-no-verify.js index 01d215113..ea466fcd8 100644 --- a/scripts/hooks/block-no-verify.js +++ b/scripts/hooks/block-no-verify.js @@ -833,9 +833,18 @@ function groupSources(scope, budget) { // $'...' in shell-scan, only word boundaries are decoded, not numeric escapes. const PRINTF_SPACE_ESCAPES = { n: '\n', t: '\t', r: '\r', v: '\v', f: '\f', '\\': '\\' }; +// echo ends its output with a newline unless an -n option asks it not to, so +// two echoes in a group write two lines, not one joined command. +function echoText(words) { + const options = words.slice(1).filter(word => /^-[neE]+$/.test(word.value)); + const text = words.slice(1).filter(word => !options.includes(word)).map(word => word.value).join(' '); + return options.some(word => word.value.includes('n')) ? text : `${text}\n`; +} + function printfText(words) { const format = words[1]?.value || ''; - if (format === '%s' || format === '%s\\n') return words.slice(2).map(word => word.value).join('\n'); + if (format === '%s') return words.slice(2).map(word => word.value).join('\n'); + if (format === '%s\\n') return words.slice(2).map(word => `${word.value}\n`).join(''); const decoded = format.replace(/\\([ntrvf\\])/g, (escape, c) => PRINTF_SPACE_ESCAPES[c]); return [decoded, ...words.slice(2).map(word => word.value)].join(' '); } @@ -850,7 +859,7 @@ function pipelineSources(command, budget) { const { words } = executableWords(current.words, budget); for (const word of words) budget.spend(word.value.length + 3); const name = basename(words[0]?.value || ''); - if (name === 'echo') sources.push({ text: words.slice(1).filter(word => !/^-[neE]+$/.test(word.value)).map(word => word.value).join(' ') }); + if (name === 'echo') sources.push({ text: echoText(words) }); if (name === 'printf' && words[1]?.value !== '-v') sources.push({ text: printfText(words) }); if (!DATA_COMMANDS.has(name)) { // Foreign transformations can introduce literal bypasses into executable diff --git a/tests/hooks/block-no-verify.test.js b/tests/hooks/block-no-verify.test.js index 383e97891..16cd94397 100644 --- a/tests/hooks/block-no-verify.test.js +++ b/tests/hooks/block-no-verify.test.js @@ -284,6 +284,7 @@ const nonLeakingPayloads = [ ['allows the distinct executable with a literal escaped dot', '"git""\\.exe" commit -n -m x'], // A complete echo operand is data; accepted role repair intentionally corrects the authored broad-blocking expectation. ['allows quoted echo data (corrected author expectation)', 'echo "git commit -n"'], + ['allows two echo lines in a group piped to sh', "{ echo 'git pu'; echo 'sh --no-verify'; } | sh"], ['python heredoc string with later bash -n', 'python3 - <<\'PY\'\nold="git add -A\\nif ! git diff --cached --quiet; then\\n git commit -q -m \\"vault sync"\nPY\nbash -n vault-sync.sh'], ['assignment string with later bash -n', 'old="git commit -q -m x"; bash -n x.sh'], ['plain commit followed by later-line bash -n', 'git commit -m x\nbash -n s.sh'],