fix(hooks): the args-heredoc guard reads the quotes before an escaped run, not the whole command

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-01 13:04:09 +01:00
commit 1731e41629
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 357 additions and 24 deletions

View file

@ -318,12 +318,19 @@ const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g,
// 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.
// shell reading alike, when a quote scan of the text before the run ends
// outside every quote and every heredoc body. The scan skips a closed
// '...' or "..." string, a here-string and the body of a heredoc closed
// before the run, so none of these turns the rule off. A quote still open
// before the run, a heredoc not closed before it, and text the scan
// cannot follow (for example $' or $", $( ${ $[, a backquote, a (, a #, a
// continuation, a heredoc word it cannot read, a second < on a heredoc's
// line, a body line of an unquoted heredoc that ends in a backslash) keep
// the rule off: there the shell may read the backslashes once more
// (inside a backquote, or in a heredoc body fed to a shell, measured), so
// the < is read as unescaped. The regex reading takes the shell reading's
// decision for the same <. The < after the escaped one is read as usual,
// so a << right after it is still checked (fix round 4.5b2, part A).
// 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';
@ -343,23 +350,141 @@ 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 <).
// The quote state at `to` of `text` read from `from` for quotes only: 'none'
// (outside every quote), 'open' (inside '...' or "...", or inside an earlier
// heredoc's body) or 'unknown' (the scan cannot follow the shell here).
// `state`, when given, lets the calls on one text resume where the last stopped.
function argsQuoteScan(text, from, to, openers, state) {
const fresh = (pos) => ({ pos, mode: 'none', pending: null, bodyEnd: -1 });
const st = state ? (state.pos <= to ? state : fresh(0)) : fresh(from);
let p = st.pos;
let mode = st.mode;
let pending = st.pending;
let bodyEnd = st.bodyEnd;
const stop = (result) => {
st.pos = p;
st.mode = mode;
st.pending = pending;
st.bodyEnd = bodyEnd;
return result;
};
const unknown = () => {
mode = 'unknown';
return stop('unknown');
};
for (;;) {
if (mode === 'unknown') return stop('unknown');
if (mode === 'body') {
if (to < bodyEnd) {
p = to;
return stop('open');
}
p = bodyEnd;
mode = 'none';
}
// A token that runs past `to` (a heredoc word, a here-string) is not followed.
if (p > to) return unknown();
if (p === to) return stop(mode === 'none' ? 'none' : 'open');
// The line break that ends a heredoc's line: its body starts after it.
if (pending && p === pending.nl) {
if (mode !== 'none' || pending.bodyEnd === null) return unknown();
mode = 'body';
bodyEnd = pending.bodyEnd;
pending = null;
continue;
}
const c = text[p];
const d = text[p + 1];
if (mode === 'single') {
if (c === "'") mode = 'none';
p += 1;
continue;
}
if (c === '\\') {
// The scan never decides a backslash without the character it escapes.
if (p + 1 >= to) return unknown();
if (mode === 'double' && d === '\n') return unknown();
if (d === '\n') return unknown(); // scan: a continuation cannot be followed
p += 2;
continue;
}
if (mode === 'double') {
if (c === '"') mode = 'none';
else if (c === '`' || (c === '$' && (d === '(' || d === '{' || d === '['))) return unknown();
p += 1;
continue;
}
if (c === "'" || c === '"') {
mode = c === "'" ? 'single' : 'double';
p += 1;
continue;
}
if (c === '`' || c === '(' || c === '#') return unknown();
if (c === '$' && (d === "'" || d === '"' || d === '(' || d === '{' || d === '[')) return unknown();
if (c === '<' && d === '<') {
if (text[p + 2] === '<') {
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;) {
const m = (lo + hi) >> 1;
if (openers[m].index < p) lo = m + 1;
else if (openers[m].index > p) hi = m - 1;
else o = openers[m];
}
if (!o || o.unreadable || o.cutOnly || !/^[A-Za-z0-9_]+$/.test(o.word)) return unknown();
let nl = text.indexOf('\n', o.end);
if (nl === -1) nl = text.length;
if (text.slice(o.end, nl).includes('<')) return unknown();
// The closing line: the first line after nl equal to the word (after
// <<- with its leading tabs stripped, nothing else). null: the body
// cannot be followed (an unquoted body line joined to the next).
let close = Infinity;
for (let ls = nl + 1; nl < text.length;) {
const le = text.indexOf('\n', ls);
const line = text.slice(ls, le === -1 ? text.length : le);
if ((o.dash ? line.replace(/^\t+/, '') : line) === o.word) {
close = le === -1 ? text.length : le + 1;
break;
}
if (!o.quoted && line.endsWith('\\')) {
close = null;
break;
}
if (le === -1) break;
ls = le + 1;
}
pending = { nl, bodyEnd: close };
p = o.end;
continue;
}
p += 1;
}
}
// The escape rule cannot apply when no backslash stands directly before a <
// (firstRun is -1: the fast path). The scan state is shared by the escape
// checks of one command, so the text is read once (fix round 4.5b2, part A).
function argsEscapeLimit(text) {
const h = /<(?:\\\n)*</.exec(text);
return { quoteAt: text.search(/['"`]/), heredocEnd: h ? h.index + h[0].length : -1 };
return { firstRun: text.search(/\\</), escaped: new Set(), state: { pos: 0, mode: 'none', pending: null, bodyEnd: -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) {
// run) is a literal < to the shell when the quote scan of the text before the
// run ends outside every quote and heredoc body ('none'). `openers` holds
// every opener before i (fix round 4.5b2, part A).
function argsEscapedLt(text, i, lim, openers) {
if (lim.firstRun === -1) return false;
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);
return b % 2 === 1 && argsQuoteScan(text, 0, s, openers, lim.state) === 'none';
}
// A cut opener (its quote has no partner after it) whose quote closes a
@ -372,7 +497,9 @@ function argsCutInString(text, o) {
}
// Each heredoc operator in `text`, in text order, as the shell reads its word:
// { index (the operator's first <), end, word, quoted, unreadable, cutOnly }.
// { index (the operator's first <), end, word, quoted, unreadable, cutOnly,
// dash (the - of <<- was read) }. Every < it judges escaped goes into
// lim.escaped, which the regex reading reads.
function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
const n = text.length;
// The index after any run of continuations that starts at k.
@ -383,7 +510,11 @@ function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
const openers = [];
let i = text.indexOf('<');
while (i !== -1) {
if (argsEscapedLt(text, i, lim)) { i = text.indexOf('<', i + 1); continue; }
if (argsEscapedLt(text, i, lim, openers)) {
lim.escaped.add(i);
i = text.indexOf('<', i + 1);
continue;
}
let next = i + 1;
let k = skip(i + 1);
if (text[k] === '<') {
@ -396,7 +527,9 @@ function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
next = k + 1; // a here-string: move past it
} else {
let unreadable = false;
let dash = false;
if (text[k] === '-') {
dash = true;
// After a continuation, bash reads <<- and zsh reads - as the word.
if (k !== second + 1) unreadable = true;
k = skip(k + 1);
@ -446,6 +579,7 @@ function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
quoted,
unreadable: unreadable || isCut,
cutOnly: isCut && !unreadable,
dash,
});
}
}
@ -456,18 +590,23 @@ function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
}
if (command.replace(/\\\n/g, '').includes(ARGS_DELIM)) {
// The regex reading and the shell reading. The shell reading's loop records
// the first failing verdict; an error anywhere in either reading blocks.
// The regex reading and the shell reading. The shell reading's openers are
// read first, so the regex reading takes its escape decisions; 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 argsShell = argsShellOpeners(command, argsLim);
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;
// The shell reading's decision for the same <; a < it never reached as
// a start counts as not escaped.
if (argsLim.escaped.has(argsOpener.index)) 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;
@ -476,7 +615,7 @@ if (command.replace(/\\\n/g, '').includes(ARGS_DELIM)) {
if (!quoted || !closesOnceAtEnd) blockArgs(!quoted, false);
}
let bodyFrom = Infinity;
for (const o of argsShellOpeners(command, argsLim)) {
for (const o of argsShell) {
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