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:
parent
8c52c3c32e
commit
bba5cb89b3
3 changed files with 681 additions and 22 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue