fix(hooks): the args-heredoc guard judges a split delimiter as written out, also inside a string

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 08:52:12 +01:00
commit 7ab4100489
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 200 additions and 11 deletions

View file

@ -302,7 +302,8 @@ const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g,
// same trim the body lines get, so a CR (a CRLF line), VT, FF or other
// character trim() strips around the word does not hide it.
// After a quoted opener that passes, a later opener is skipped only when its
// word is cut by an unpaired quote and is readable otherwise: the shell meets
// word is cut by an unpaired quote and is readable otherwise and not split by
// a backslash-newline (fix round 4.5b2, part A, decision 126): the shell meets
// the same unpaired quote, stops with a parse error and runs nothing after it,
// 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
@ -313,9 +314,12 @@ const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g,
// 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.
// The quote has no partner, so the outer shell never reads the word's
// body as a heredoc body; a second shell (bash -c, eval) may, and the
// regex reading still reads such a << when the delimiter follows it. An
// opener whose raw span holds a backslash-newline has no regex match, so
// it is never skipped (fix round 4.5b2, part A, decision 123). Each
// opener is read on its own, so skipping one 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, when a quote scan of the text before the run ends
@ -426,10 +430,6 @@ function argsQuoteScan(text, from, to, openers, state) {
p += 3; // a here-string is text
continue;
}
if (!openers) {
p += 2;
continue;
}
// The opener record at p (openers are in text order).
let o = null;
for (let lo = 0, hi = openers.length - 1; lo <= hi && !o;) {
@ -487,10 +487,18 @@ function argsEscapedLt(text, i, lim, openers) {
return b % 2 === 1 && argsQuoteScan(text, 0, s, openers, lim.state) === 'none';
}
// An opener whose raw span holds a backslash-newline. The regex reading cannot
// see such an opener, so it is never skipped, as string text or as body text
// (fix round 4.5b2, part A, decisions 123 and 126).
function argsSplit(text, o) {
return text.slice(o.index, o.end).includes('\\\n');
}
// 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) {
if (argsSplit(text, o)) return false;
const q = text[o.end];
const lineStart = text.lastIndexOf('\n', o.index) + 1;
return text.slice(lineStart, o.index).split(q).length % 2 === 0;
@ -616,8 +624,27 @@ if (command.replace(/\\\n/g, '').includes(ARGS_DELIM)) {
}
let bodyFrom = Infinity;
for (const o of argsShell) {
if (o.index >= bodyFrom && o.cutOnly) continue;
if (o.index >= bodyFrom && o.cutOnly && !argsSplit(command, o)) continue;
if (o.cutOnly && argsCutInString(command, o)) continue;
// A delimiter split by a backslash-newline whose word has a suffix is
// judged as the regex reading judges the same opener written out (fix
// round 4.5b2, part A).
if (!o.unreadable && argsSplit(command, o)) {
const w = o.word.trim();
if (w.startsWith(ARGS_DELIM) && w.length > ARGS_DELIM.length) {
const m = new RegExp(`^<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`).exec(command.slice(o.index, o.end).replace(/\\\n/g, ''));
if (!m) continue;
const snl = command.indexOf('\n', o.end);
if (snl === -1) continue;
const sbody = argsLines.slice(command.slice(0, snl).split('\n').length);
if (!sbody.some((line) => line.trim() !== '')) continue;
const [, sopen, sclose] = m;
const squoted = sopen === '\\' ? sclose === '' : sopen !== '' && sclose === sopen;
if (squoted && closesOnceAtEnd) continue;
argsVerdict = [!squoted, false];
break;
}
}
// 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.