From fc557e57ff63ea5498e47c04f9a1e397f6cd8e07 Mon Sep 17 00:00:00 2001 From: L4XB Date: Fri, 23 Oct 2026 07:33:24 +0200 Subject: [PATCH] fix(hooks): also read a group's writes joined in the order the shell receives them The commands in a group write one stream into the pipe, so a bypass split across them, as in { printf 'git pu'; printf 'sh --no-verify'; } | sh, was read as two harmless fragments. groupSources() now adds the outputs joined in order next to the separate ones. The group handling moves out of pipelineSources() to keep it under the complexity limit. --- scripts/hooks/block-no-verify.js | 21 ++++++++++++++------- tests/hooks/block-no-verify.test.js | 2 ++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/scripts/hooks/block-no-verify.js b/scripts/hooks/block-no-verify.js index 75755260f..1eff156d7 100644 --- a/scripts/hooks/block-no-verify.js +++ b/scripts/hooks/block-no-verify.js @@ -815,18 +815,25 @@ function commandRole(words, budget) { return { kind: 'opaque', stdin: true }; } +// A brace group or subshell writes what its own commands print into the pipe: +// `{ echo 'git push --no-verify'; } | sh` feeds sh like `echo ... | sh`. The +// writes form one stream, so they are also read joined in order: +// `{ printf 'git pu'; printf 'sh --no-verify'; } | sh`. +function groupSources(scope, budget) { + const written = []; + for (const inner of scanShell(scope.body, budget).commands) { + if (!inner.pipeTo) written.push(...pipelineSources(inner, budget)); + } + if (written.length > 1) written.push({ text: written.map(source => source.text).join(''), opaque: written.some(source => source.opaque) }); + return written; +} + // Literal producers only. Unmodeled transformations remain conservative rather // than executing a formatter, interpreter, shell or user-supplied command. function pipelineSources(command, budget) { const sources = []; for (let current = command; current; current = current.pipeFrom) { - // A brace group or subshell writes what its own commands print into the - // pipe: `{ echo 'git push --no-verify'; } | sh` feeds sh like `echo ... | sh`. - if (current.closedScope) { - for (const inner of scanShell(current.closedScope.body, budget).commands) { - if (!inner.pipeTo) sources.push(...pipelineSources(inner, budget)); - } - } + if (current.closedScope) sources.push(...groupSources(current.closedScope, budget)); budget.spend(current.words.length + 1); const { words } = executableWords(current.words, budget); for (const word of words) budget.spend(word.value.length + 3); diff --git a/tests/hooks/block-no-verify.test.js b/tests/hooks/block-no-verify.test.js index 5dac62720..1bbec6131 100644 --- a/tests/hooks/block-no-verify.test.js +++ b/tests/hooks/block-no-verify.test.js @@ -239,6 +239,8 @@ const executingPayloads = [ ['pipe subshell data to bash', "(echo 'git push --no-verify'; printf x) | bash"], ['pipe nested group data to sh', "{ (printf '%s\\n' 'git commit -n -m x'); } | sh"], ['pipe group pipeline data to sh', "{ echo 'git push --no-verify' | grep git; } | sh"], + ['pipe consecutive group writes to sh', "{ printf 'git pu'; printf 'sh --no-verify'; } | sh"], + ['pipe echo -n and echo group writes to bash', "{ echo -n 'git commit '; echo '--no-verify -m x'; } | bash"], ['execute data through xargs and bash -c', "printf '%s\\n' 'git commit -n -m x' | xargs -I CMD bash -c CMD"], ['execute command substitution text through bash', "echo '$(git commit -n -m x)' | bash"], ['execute bash here-string', "bash <<< 'git commit -n -m x'"],