fix(hooks): the args-heredoc guard checks every opener with a line after it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-28 15:36:21 +01:00
commit eca974aaa6
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 37 additions and 11 deletions

View file

@ -264,20 +264,26 @@ const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g,
// delimiter would close the heredoc there, and the shell would run every line
// after it as a command (fix round 4.5b, R6). This check reads the raw
// command: `normalized` and `commandView` collapse lines and drop heredoc
// bodies (hooks/lib/bash-view.mjs). Openers <<'D', <<"D" and <<\D are quoted
// (the body is not expanded); any other opener of the delimiter is treated as
// unquoted and blocked, since its body would expand. A quoted opener passes
// only when exactly one line, trimmed, equals the delimiter and every line
// after it is empty.
// bodies (hooks/lib/bash-view.mjs). Every opener of the delimiter in the
// command is checked, so a quoted first mention cannot hide a later unquoted
// heredoc. bash reads a heredoc body only from the lines after the opener's
// line, so an opener with no non-blank line after its own line (a one-line
// grep or commit message that only mentions it) has no body: nothing can
// close early and an unquoted opener has nothing to expand, so it is skipped.
// An opener with a non-blank line after it is blocked unless it is quoted
// (<<'D', <<"D" or <<\D, whose body is not expanded) and exactly one line,
// trimmed, equals the delimiter with every line after that one empty.
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
const argsOpener = new RegExp(`<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`).exec(command);
if (argsOpener) {
const argsOpeners = command.matchAll(new RegExp(`<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`, 'g'));
const argsLines = command.split('\n');
const argsClosing = argsLines.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : []));
const closesOnceAtEnd = argsClosing.length === 1 &&
argsLines.slice(argsClosing[0] + 1).every((line) => line.trim() === '');
for (const argsOpener of argsOpeners) {
const hasBody = command.slice(argsOpener.index).split('\n').slice(1).some((l) => l.trim() !== '');
if (!hasBody) continue;
const [, open, close] = argsOpener;
const quoted = open === '\\' ? close === '' : open !== '' && close === open;
const lines = command.split('\n');
const closing = lines.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : []));
const closesOnceAtEnd = closing.length === 1 &&
lines.slice(closing[0] + 1).every((line) => line.trim() === '');
if (!quoted || !closesOnceAtEnd) {
const why = !quoted
? `The heredoc that hands the arguments to the arg parser opens ${ARGS_DELIM} unquoted, so the shell would expand its body. ` +