fix(hooks): the args-heredoc guard blocks a heredoc operator a second shell would read, with a bypass corpus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 09:57:28 +01:00
commit ebec492f6a
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
5 changed files with 878 additions and 3 deletions

View file

@ -335,6 +335,17 @@ const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g,
// 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).
// A < followed (continuations skipped) by an odd run of backslashes and a
// second < is a heredoc operator a second shell may read: the outer shell
// removes the backslashes, so eval, bash -c or sh -c reads << there. It is
// blocked as an unquoted opener the guard could not read when the word after
// the second < names the delimiter as a second shell reads it (every
// backslash-newline removed, then a leading -, blanks, quotes and
// backslashes) and a non-blank line follows anywhere after the opener's line
// (the body may sit inside the quotes that carry the word). It is judged
// before every skip. A one-line form has no such line and passes. The rule
// does not look for the second shell, so the same text with none is blocked
// as well (fix round 4.5b2, part A, decision 107).
// 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';
@ -504,9 +515,30 @@ function argsCutInString(text, o) {
return text.slice(lineStart, o.index).split(q).length % 2 === 0;
}
// After the second < of a twice-escaped operator (p), does the word a second
// shell reads name the delimiter? Every backslash-newline is removed, then a
// leading -, blanks, quotes and backslashes (fix round 4.5b2, part A,
// decision 107).
function argsRereadNames(text, p) {
for (;;) {
if (text[p] === '\\' && text[p + 1] === '\n') { p += 2; continue; }
if ('- \t\'"\\'.includes(text[p]) && p < text.length) { p += 1; continue; }
break;
}
let j = 0;
while (j < ARGS_DELIM.length && p < text.length) {
if (text[p] === '\\' && text[p + 1] === '\n') { p += 2; continue; }
if (text[p] !== ARGS_DELIM[j]) return false;
p += 1;
j += 1;
}
return j === ARGS_DELIM.length;
}
// Each heredoc operator in `text`, in text order, as the shell reads its word:
// { index (the operator's first <), end, word, quoted, unreadable, cutOnly,
// dash (the - of <<- was read) }. Every < it judges escaped goes into
// dash (the - of <<- was read), reread (a twice-escaped operator a second
// shell reads, decision 107) }. Every < it judges escaped goes into
// lim.escaped, which the regex reading reads.
function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
const n = text.length;
@ -518,6 +550,14 @@ function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
const openers = [];
let i = text.indexOf('<');
while (i !== -1) {
{
const r = skip(i + 1);
let b = 0;
while (text[r + b] === '\\') b += 1;
if (b % 2 === 1 && text[r + b] === '<' && argsRereadNames(text, r + b + 1)) {
openers.push({ index: i, end: r + b + 1, word: '', quoted: false, unreadable: true, cutOnly: false, dash: false, reread: true });
}
}
if (argsEscapedLt(text, i, lim, openers)) {
lim.escaped.add(i);
i = text.indexOf('<', i + 1);
@ -624,6 +664,13 @@ if (command.replace(/\\\n/g, '').includes(ARGS_DELIM)) {
}
let bodyFrom = Infinity;
for (const o of argsShell) {
if (o.reread) {
if (/\n\s*\S/.test(command.slice(o.index))) {
argsVerdict = [true, true];
break;
}
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