fix(hooks): the args-heredoc guard lets through a heredoc operator the shell reads as text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-31 16:42:30 +01:00
commit 9f7ba6503f
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 417 additions and 15 deletions

View file

@ -307,6 +307,23 @@ const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g,
// so a typed argument such as fix "<<" handling passes. Every other later
// opener is checked, because the accepted opener may sit inside a string and
// must not hide a real opener after it.
// Two kinds of << are text to the shell and are skipped (fix round 4.5b,
// part 4):
// - A cut word whose quote closes a string opened earlier on its line (an
// odd count of that quote from the line start to the opener's first <),
// as in grep -n '<<' or a commit message holding "<<". A cut word with
// no string open before it stays unreadable and is blocked as unquoted.
// The skip is safe: the quote has no partner, so the word's body is
// never read as a heredoc body, and each opener is read on its own, so
// skipping it hides no later opener.
// - A < after an odd run of backslashes (counted on the raw text, a line
// break ends the run) starts no opener, in the regex reading and in the
// shell reading alike, but only when no ', " or backquote and no <<
// stands before the run: each of those can make the shell read the
// backslashes once more (inside a backquote, or in an unquoted heredoc
// body fed to a shell, measured), so there the < is read as unescaped.
// The < after the escaped one is read as usual, so a << right after it
// is still checked.
// The shell reading fails closed: an error in it blocks (exit 2), because an
// exit 1 is a non-blocking error and the command would run.
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
@ -326,6 +343,34 @@ function blockArgs(unquoted, unreadable) {
process.exit(2);
}
// Where the backslash rule stops applying in text: the first ', " or
// backquote, and the end of the first << (continuations between the two <).
function argsEscapeLimit(text) {
const h = /<(?:\\\n)*</.exec(text);
return { quoteAt: text.search(/['"`]/), heredocEnd: h ? h.index + h[0].length : -1 };
}
// A < after an odd run of backslashes in the raw text (a line break ends the
// run) is a literal < to the shell, when no ', " or backquote and no <<
// stands before the run: inside quotes, backquotes or a heredoc body fed to
// a shell, a backslash may be read once more.
function argsEscapedLt(text, i, lim) {
let b = 0;
while (i - 1 - b >= 0 && text[i - 1 - b] === '\\') b += 1;
const s = i - b;
return b % 2 === 1 && (lim.quoteAt === -1 || lim.quoteAt >= s) &&
(lim.heredocEnd === -1 || lim.heredocEnd > s);
}
// A cut opener (its quote has no partner after it) whose quote closes a
// string opened earlier on the raw line holding the opener's first <: an
// odd count of that quote character from the line start to the <.
function argsCutInString(text, o) {
const q = text[o.end];
const lineStart = text.lastIndexOf('\n', o.index) + 1;
return text.slice(lineStart, o.index).split(q).length % 2 === 0;
}
// Each heredoc operator in `text`, in text order, as the shell reads its word:
// { index (the operator's first <), end, word, quoted, unreadable, cutOnly }.
function argsShellOpeners(text) {
@ -336,8 +381,10 @@ function argsShellOpeners(text) {
return k;
};
const openers = [];
const lim = argsEscapeLimit(text);
let i = text.indexOf('<');
while (i !== -1) {
if (argsEscapedLt(text, i, lim)) { i = text.indexOf('<', i + 1); continue; }
let next = i + 1;
let k = skip(i + 1);
if (text[k] === '<') {
@ -410,26 +457,29 @@ function argsShellOpeners(text) {
}
if (command.replace(/\\\n/g, '').includes(ARGS_DELIM)) {
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) {
// Some line after the opener's line is non-blank (\s is the set trim() strips).
const hasBody = /\n\s*\S/.test(command.slice(argsOpener.index));
if (!hasBody) continue;
const [, open, close] = argsOpener;
const quoted = open === '\\' ? close === '' : open !== '' && close === open;
if (!quoted || !closesOnceAtEnd) blockArgs(!quoted, false);
}
// The shell reading. The loop records the first failing verdict; an error
// anywhere in it blocks.
// The regex reading and the shell reading. The shell reading's loop records
// the first failing verdict; an error anywhere in either reading blocks.
let argsVerdict = null;
try {
const argsLim = argsEscapeLimit(command);
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) {
if (argsEscapedLt(command, argsOpener.index, argsLim)) continue;
// Some line after the opener's line is non-blank (\s is the set trim() strips).
const hasBody = /\n\s*\S/.test(command.slice(argsOpener.index));
if (!hasBody) continue;
const [, open, close] = argsOpener;
const quoted = open === '\\' ? close === '' : open !== '' && close === open;
if (!quoted || !closesOnceAtEnd) blockArgs(!quoted, false);
}
let bodyFrom = Infinity;
for (const o of argsShellOpeners(command)) {
if (o.index >= bodyFrom && o.cutOnly) continue;
if (o.cutOnly && argsCutInString(command, o)) continue;
// The word is compared trimmed, as the body lines are: a word of D plus
// characters trim() strips (a CR from a CRLF line, a VT, an FF, a
// no-break space) is closed by a line equal to it, which trims to D.