fix(hooks): the args-heredoc guard reads the opener's word as the shell does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-28 23:41:19 +01:00
commit bba5cb89b3
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 681 additions and 22 deletions

View file

@ -264,17 +264,153 @@ 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). 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. A
// command that never names the delimiter skips the whole check.
// bodies (hooks/lib/bash-view.mjs). A command that never names the delimiter
// skips the whole check.
// Every opener in the command is read in two ways, and the command is blocked
// when either reading blocks it (the union):
// 1. A regex finds <<D, <<'D', <<"D" and <<\D, with an optional - and
// spaces (fix round 4.5b and its part 2).
// 2. A shell reading (fix round 4.5b, part 3) starts at every < in the
// text, wherever it stands; like the regex, it does not lex the command
// around it. A backslash directly followed by a line break is a
// continuation: removed, not quoting. The word after << (and an optional
// -, spaces and tabs) is read as the shell reads it: a backslash makes
// the next character literal and quotes the word; '...', "...", $'...'
// and $"..." quote it, so a partly quoted word such as
// <<"VOYAGE"_ARGS_EOF_7F3C9E counts. The word ends at unquoted
// whitespace (a line break included),
// at one of ; & | ( ) < > or at the end of the text. Its body lines are
// the raw lines after the line on which the word ends: continuations
// change only how the word is read, never the line count.
// bash reads a heredoc body only from the lines after the opener's line, so
// an opener with no non-blank line after it (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 (its body is not
// expanded) and exactly one line, trimmed, equals the delimiter with every
// line after that one empty (the regex reading counts every line of the
// command, the shell reading the body lines).
// A word the guard cannot read is treated as unquoted, whatever its letters:
// a quote with no partner anywhere in the rest of the text (the word is cut
// there, so its line is the quote's line), any backslash inside "...",
// $"..." or $'...' (no escape there is decoded), a backslash at the end of
// the text, a - reached only after a continuation (bash reads <<-, zsh
// reads the - as part of the word), and a continuation between a $ and the
// quote it opens. The word is compared with the delimiter after trim(), the
// 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
// 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
// must not hide a real opener after it.
// 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';
function blockArgs(unquoted, unreadable) {
const why = unquoted
? `The heredoc that hands the arguments to the arg parser opens ${ARGS_DELIM} unquoted, so the shell would expand its body. ` +
`Way forward: open it as <<'${ARGS_DELIM}'.`
: `The arguments hold a line equal to the heredoc's closing line ${ARGS_DELIM}, or text follows that closing line, ` +
'so the shell would run what follows as commands. Way forward: take that line out of the arguments and run the command again.';
const unread = unreadable ? " The guard could not read the opener's word, so it treats it as unquoted." : '';
process.stderr.write(
'[voyage] BLOCKED: args-heredoc-closed-early\n' +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
` ${why}${unread}\n`
);
process.exit(2);
}
// 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) {
const n = text.length;
// The index after any run of continuations that starts at k.
const skip = (k) => {
while (text[k] === '\\' && text[k + 1] === '\n') k += 2;
return k;
};
const openers = [];
let i = text.indexOf('<');
while (i !== -1) {
let next = i + 1;
let k = skip(i + 1);
if (text[k] === '<') {
const second = k;
// The scan resumes right after the second <, never at the word's end,
// so a quoted word that spans lines cannot hide a < inside it.
next = second + 1;
k = skip(second + 1);
if (text[k] === '<') {
next = k + 1; // a here-string: move past it
} else {
let unreadable = false;
if (text[k] === '-') {
// After a continuation, bash reads <<- and zsh reads - as the word.
if (k !== second + 1) unreadable = true;
k = skip(k + 1);
}
while (k < n) {
if (text[k] === ' ' || text[k] === '\t') k += 1;
else if (text[k] === '\\' && text[k + 1] === '\n') k += 2;
else break;
}
const start = k;
let word = '';
let quoted = false;
let cut = -1;
while (k < n) {
const c = text[k];
if (c === ' ' || c === '\t' || c === '\n' || ';&|()<>'.includes(c)) break;
if (c === '\\') {
if (text[k + 1] === '\n') { k += 2; continue; }
if (k + 1 >= n) { unreadable = true; k += 1; break; }
word += text[k + 1];
quoted = true;
k += 2;
continue;
}
// A $ opens $'...' or $"..." also across continuations; a
// continuation between the $ and the quote makes the word unreadable.
const q1 = c === '$' ? skip(k + 1) : k + 1;
const dollar = c === '$' && (text[q1] === "'" || text[q1] === '"');
if (dollar && q1 !== k + 1) unreadable = true;
if (c === "'" || c === '"' || dollar) {
const open = dollar ? q1 : k;
const q = text[open];
const close = text.indexOf(q, open + 1);
if (close === -1) { cut = open; break; }
const inner = text.slice(open + 1, close);
if ((q === '"' || dollar) && inner.includes('\\')) unreadable = true;
word += inner;
quoted = true;
k = close + 1;
continue;
}
word += c;
k += 1;
}
const isCut = cut !== -1;
if (k > start || unreadable || isCut) {
openers.push({
index: i,
end: isCut ? cut : k,
word,
quoted,
unreadable: unreadable || isCut,
cutOnly: isCut && !unreadable,
});
}
}
}
i = text.indexOf('<', next);
}
return openers;
}
if (command.includes(ARGS_DELIM)) {
const argsOpeners = command.matchAll(new RegExp(`<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`, 'g'));
const argsLines = command.split('\n');
@ -287,20 +423,37 @@ if (command.includes(ARGS_DELIM)) {
if (!hasBody) continue;
const [, open, close] = argsOpener;
const quoted = open === '\\' ? close === '' : open !== '' && close === open;
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. ` +
`Way forward: open it as <<'${ARGS_DELIM}'.`
: `The arguments hold a line equal to the heredoc's closing line ${ARGS_DELIM}, or text follows that closing line, ` +
'so the shell would run what follows as commands. Way forward: take that line out of the arguments and run the command again.';
process.stderr.write(
'[voyage] BLOCKED: args-heredoc-closed-early\n' +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
` ${why}\n`
);
process.exit(2);
}
if (!quoted || !closesOnceAtEnd) blockArgs(!quoted, false);
}
// The shell reading. The loop records the first failing verdict; an error
// anywhere in it blocks.
let argsVerdict = null;
try {
let bodyFrom = Infinity;
for (const o of argsShellOpeners(command)) {
if (o.index >= bodyFrom && o.cutOnly) 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.
if (o.word.trim() !== ARGS_DELIM && !o.unreadable) continue;
const nl = command.indexOf('\n', o.end);
if (nl === -1) continue;
// The raw lines after the line on which the word ends.
const body = argsLines.slice(command.slice(0, nl).split('\n').length);
if (!body.some((line) => line.trim() !== '')) continue;
const isQuoted = o.quoted && !o.unreadable;
const closing = body.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : []));
if (isQuoted && closing.length === 1 && body.slice(closing[0] + 1).every((line) => line.trim() === '')) {
if (bodyFrom === Infinity) bodyFrom = nl + 1;
continue;
}
argsVerdict = [!isQuoted, o.unreadable];
break;
}
} catch {
blockArgs(true, true);
}
if (argsVerdict) blockArgs(argsVerdict[0], argsVerdict[1]);
}
// END args-heredoc-closed-early