fix(hooks): the args-heredoc guard checks every opener with a line after it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-28 15:36:21 +01:00
commit eca974aaa6
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 37 additions and 11 deletions

View file

@ -264,20 +264,26 @@ 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). Openers <<'D', <<"D" and <<\D are quoted
// (the body is not expanded); any other opener of the delimiter is treated as
// unquoted and blocked, since its body would expand. A quoted opener passes
// only when exactly one line, trimmed, equals the delimiter and every line
// after it is empty.
// 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.
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
const argsOpener = new RegExp(`<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`).exec(command);
if (argsOpener) {
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) {
const hasBody = command.slice(argsOpener.index).split('\n').slice(1).some((l) => l.trim() !== '');
if (!hasBody) continue;
const [, open, close] = argsOpener;
const quoted = open === '\\' ? close === '' : open !== '' && close === open;
const lines = command.split('\n');
const closing = lines.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : []));
const closesOnceAtEnd = closing.length === 1 &&
lines.slice(closing[0] + 1).every((line) => line.trim() === '');
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. ` +

View file

@ -417,3 +417,23 @@ test('[ARG] every shipped parser heredoc block passes the guard', async () => {
await expectArgsAllowed(command);
}
});
// HDG (fix round 4.5b, part 2) -- bash reads a heredoc body only from the
// lines after the opener's line. A command that only mentions the opener, with
// no non-blank line after it, has nothing that can close early and nothing to
// expand, so it passes. Every opener with a line after it is checked, so a
// quoted first mention cannot hide a later unquoted heredoc.
test('[HDG] a command that only mentions the opener on its last line passes, and every opener with a line after it is checked', async () => {
await expectArgsAllowed(`grep -n "<<'${ARGS_DELIM}'" commands/trekreview.md`);
await expectArgsAllowed(`grep -n "<<'${ARGS_DELIM}'" commands/trekreview.md\n`);
await expectArgsAllowed(`grep -n "<<${ARGS_DELIM}" commands/trekreview.md\n`);
await expectArgsAllowed(`echo hi\ngrep -n "<<'${ARGS_DELIM}'" f`);
// A quoted first mention must not hide a later unquoted heredoc.
await expectArgsBlocked(`echo "<<'${ARGS_DELIM}'"\n${PARSER_LINE} <<${ARGS_DELIM}\nbrief.md $(touch PWNED)\n${ARGS_DELIM}`);
// Control: a real heredoc of the delimiter is checked as before.
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}' 2>&1\nbrief.md\n${ARGS_DELIM}\ntouch PWNED`);
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}'\n\n${ARGS_DELIM}\ntouch PWNED`);
await expectArgsBlocked(`echo hi\n${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
await expectArgsBlocked(`${PARSER_LINE} <<${ARGS_DELIM}\nbrief.md\n${ARGS_DELIM}`);
});