194 lines
10 KiB
JavaScript
194 lines
10 KiB
JavaScript
// tests/hooks/bash-guard-4-5b2-a.test.mjs
|
|
// [RXT] [CNT] [RSK] [QSC] [SFX] [ESR] [EVL] Fix round 4.5b2, part A (decisions 91 and 107).
|
|
// Defects: the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs
|
|
// turned its escape rule off for the whole command when a ', " or backquote
|
|
// or a << stood anywhere before an escaped <, so a here-string, a closed
|
|
// heredoc or a closed quote earlier in the command made the guard refuse a
|
|
// command in which the shell reads \< as a literal < (ESR). Two branches part
|
|
// 4 added had no test: the try that makes an error inside the regex reading's
|
|
// escape limit block (RXT), and the continuation-split outer opener that keeps
|
|
// the escape rule off (CNT).
|
|
// The helpers bashInput, ARGS_DELIM, ARGS_RULE, PARSER_LINE,
|
|
// expectArgsBlocked, expectArgsAllowed, UNQUOTED, expectUnquoted,
|
|
// CLOSED_EARLY, expectClosedEarly, D and BODY are copied from
|
|
// tests/hooks/bash-guard-false-stops.test.mjs:26-68, not imported, because
|
|
// importing a test file would run its tests. The guard is only spawned, so
|
|
// this file loads at e2d6bec and its red is an assertion.
|
|
|
|
import { test, after } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { writeFileSync, rmSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { runHook, runHookWithEnv } from '../helpers/hook-helper.mjs';
|
|
import { tmp, pluginCopy } from './guard-helpers.mjs';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = join(HERE, '..', '..');
|
|
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
|
|
|
function bashInput(command) {
|
|
return { tool_name: 'Bash', tool_input: { command } };
|
|
}
|
|
|
|
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
|
|
const ARGS_RULE = /args-heredoc-closed-early/;
|
|
const PARSER_LINE = 'node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin';
|
|
|
|
async function expectArgsBlocked(command) {
|
|
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
|
|
assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early for: ${JSON.stringify(command)}`);
|
|
assert.strictEqual(code, 2, `the guard did not block: ${JSON.stringify(command)}`);
|
|
return stderr;
|
|
}
|
|
|
|
async function expectArgsAllowed(command) {
|
|
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
|
|
assert.doesNotMatch(stderr, ARGS_RULE, `the guard refused a well-formed heredoc: ${JSON.stringify(command)}`);
|
|
assert.strictEqual(code, 0, `the guard refused a well-formed heredoc: ${stderr}`);
|
|
}
|
|
|
|
// The unquoted reason: the guard blocks and says the opener is unquoted.
|
|
const UNQUOTED = /opens VOYAGE_ARGS_EOF_7F3C9E unquoted/;
|
|
|
|
async function expectUnquoted(command) {
|
|
const stderr = await expectArgsBlocked(command);
|
|
assert.match(stderr, UNQUOTED, `the guard did not give the unquoted reason for: ${JSON.stringify(command)}`);
|
|
}
|
|
|
|
// The closed-early reason: a line equal to the closing line, then more text.
|
|
const CLOSED_EARLY = /hold a line equal to the heredoc's closing line/;
|
|
|
|
async function expectClosedEarly(command) {
|
|
const stderr = await expectArgsBlocked(command);
|
|
assert.match(stderr, CLOSED_EARLY, `the guard did not give the closed-early reason for: ${JSON.stringify(command)}`);
|
|
assert.doesNotMatch(stderr, UNQUOTED, `the guard gave the unquoted reason for: ${JSON.stringify(command)}`);
|
|
}
|
|
|
|
const D = ARGS_DELIM;
|
|
// The body line and the closing line of the escape-rule controls.
|
|
const BODY = `\nbrief.md $(touch PWNED)\n${D}`;
|
|
|
|
test('[ESR] a here-string before an escaped < leaves the rule on', async () => {
|
|
// Red: the here-string <<< is text to the quote scan, so \< is a literal <
|
|
// and the next line runs as a command.
|
|
await expectArgsAllowed(`cat <<<x; echo \\<<${D}\nnext`);
|
|
|
|
// Control: a # comment holding a ' before bash -c "... (catalogue E1): the
|
|
// scan cannot follow a comment, so the rule is off and the inner shell
|
|
// opens an unquoted heredoc.
|
|
await expectUnquoted(`# don't\nbash -c "# it's\ncat - \\\\\\<<${D}\n\\$(touch PWNED)\n${D}"`);
|
|
// Control: an arithmetic shift $((1<<X)) before the run (catalogue E2): the
|
|
// scan cannot follow $(, so the rule is off.
|
|
await expectUnquoted(`echo $((1<<X))\nbash -c ":\nX\ncat - \\\\\\<<${D}\n\\$(touch PWNED)\n${D}"`);
|
|
// Control: a continuation before the run: the scan cannot follow it, so the
|
|
// rule is off.
|
|
await expectUnquoted(`echo a\\\nb && echo \\<<${D}${BODY}`);
|
|
});
|
|
|
|
test("[ESR] a closed quoted heredoc before an escaped < leaves the rule on, and a quote in its body counts for nothing", async () => {
|
|
// Red: the heredoc EOF is closed before the run and the ' in its body is
|
|
// body text, so \< is a literal < and the next line runs as a command.
|
|
await expectArgsAllowed(`cat <<'EOF'\ndon't\nEOF\necho \\<<${D}\nnext`);
|
|
|
|
// Control: an earlier heredoc not yet closed: the run stands in its body,
|
|
// so the rule is off.
|
|
await expectUnquoted(`cat <<'EOF'\necho \\<<${D}${BODY}`);
|
|
// Control: the would-be closing line is EOF plus a trailing blank, which
|
|
// does not close the heredoc, so the run stands in its body.
|
|
await expectUnquoted(`cat <<'EOF'\nEOF \necho \\<<${D}${BODY}\nEOF`);
|
|
// Control: the would-be closing line is EOF plus a CR, which does not
|
|
// close the heredoc, so the run stands in its body.
|
|
await expectUnquoted(`cat <<'EOF'\nEOF\r\necho \\<<${D}${BODY}\nEOF`);
|
|
// Control: the would-be closing line is a tab plus EOF under << without -,
|
|
// which does not close the heredoc, so the run stands in its body.
|
|
await expectUnquoted(`cat <<'EOF'\n\tEOF\necho \\<<${D}${BODY}\nEOF`);
|
|
// Control: two heredocs on one line: the scan cannot follow them, so the
|
|
// rule is off.
|
|
await expectUnquoted(`cat <<A <<B\nA\necho \\<<${D}${BODY}\nB`);
|
|
// Control: a continuation inside an earlier unquoted heredoc's body
|
|
// (catalogue CJ): the shell joins x\ to the next line, so EOF does not
|
|
// close the body there and the scan cannot follow it; the rule is off.
|
|
await expectUnquoted(`echo \\<x; bash <<EOF\nx\\\nEOF\ncat - \\\\\\<<${D}\n\\$(touch PWNED)\n${D}\nEOF`);
|
|
});
|
|
|
|
test('[ESR] a quote closed before an escaped < leaves the rule on', async () => {
|
|
// Red. Moved from tests/hooks/bash-guard-false-stops.test.mjs:117-118
|
|
// (decision 106): the ' is closed before the run, so the shell reads \< as a
|
|
// literal <.
|
|
await expectArgsAllowed(`echo 'x' && echo \\<<${D}${BODY}`);
|
|
|
|
// Control: a " still open before the run: inside "..." \< keeps its
|
|
// backslash, so the rule is off.
|
|
await expectUnquoted(`echo "y \\<<${D}${BODY}`);
|
|
// Control: a $' string before the run: the scan does not decode it, so the
|
|
// rule is off.
|
|
await expectUnquoted(`echo $'x' && echo \\<<${D}${BODY}`);
|
|
// Control: a closed backquote before the run: the scan cannot follow a
|
|
// backquote, so the rule is off.
|
|
await expectUnquoted(`x=\`date\`; echo \\<<${D}${BODY}`);
|
|
// Control that passes: a first odd run of three. The shell reads \\ and \<
|
|
// and then a <D redirection. The run starts two characters before the
|
|
// backslash the fast path finds, so this pins the rule against comparing
|
|
// that backslash with the run start.
|
|
await expectArgsAllowed(`echo \\\\\\<<${D}\nnext`);
|
|
});
|
|
|
|
// The preload makes String.prototype.search throw when it is called from
|
|
// inside argsEscapeLimit, so the regex reading's escape limit throws. It is
|
|
// written into a temp directory, never under tests/, and only makes the hook
|
|
// block.
|
|
const PRELOAD = `const orig = String.prototype.search;
|
|
String.prototype.search = function (...args) {
|
|
if (/argsEscapeLimit/.test(new Error().stack)) {
|
|
process.stderr.write('[rxt-preload] thrown\\n');
|
|
throw new Error('RXT forced read error');
|
|
}
|
|
return orig.apply(this, args);
|
|
};
|
|
`;
|
|
|
|
const preloadDir = tmp('voyage-rxt-');
|
|
after(() => rmSync(preloadDir, { recursive: true, force: true }));
|
|
|
|
test("[RXT] an error inside the regex reading's escape limit blocks the command (exit 2), never lets it through", async () => {
|
|
const file = join(preloadDir, 'rxt-preload.mjs');
|
|
writeFileSync(file, PRELOAD);
|
|
const forced = { NODE_OPTIONS: '--import=' + pathToFileURL(file).href };
|
|
const shipped = `${PARSER_LINE} <<'${D}'\nbrief.md\n${D}`;
|
|
|
|
// Red, forced: the shipped heredoc under the preload is blocked by the catch.
|
|
const { code, stderr } = await runHookWithEnv(PRE_BASH, bashInput(shipped), forced);
|
|
assert.ok(stderr.includes('[rxt-preload] thrown'), `the preload did not throw inside the escape limit: ${stderr}`);
|
|
assert.strictEqual(code, 2, `the guard did not block when its escape limit threw (exit ${code}): ${stderr}`);
|
|
assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early: ${stderr}`);
|
|
assert.ok(!stderr.includes('RXT forced read error'), `the error was not caught (an uncaught throw exits 1): ${stderr}`);
|
|
|
|
// Control: the same command with no preload passes, so the block can come
|
|
// only from the catch.
|
|
await expectArgsAllowed(shipped);
|
|
// Control: a command with no delimiter passes under the preload.
|
|
const plain = await runHookWithEnv(PRE_BASH, bashInput('ls -la'), forced);
|
|
assert.strictEqual(plain.code, 0, `ls -la was refused under the preload: ${plain.stderr}`);
|
|
});
|
|
|
|
test('[CNT] a heredoc opener split between its two < still turns the escape rule off', async () => {
|
|
// Red: the outer opener <\ + line break + <EOF is a heredoc whose body is
|
|
// fed to bash, which reads the backslashes once more. Measured: /bin/bash
|
|
// 3.2.57 expands the inner body; zsh 5.9 gives a parse error. At e2d6bec the
|
|
// base's first-<< search keeps the rule off; on the branch the quote scan's
|
|
// continuation check does.
|
|
await expectUnquoted(`bash <\\\n<EOF\ncat - \\\\\\<<${D}\n\\$(touch PWNED)\n${D}\nEOF`);
|
|
});
|
|
|
|
test('part-A scan: a long command with many escaped < is read in linear time', async () => {
|
|
// About 870 KB: 20000 escaped < before the shipped quoted heredoc. The quote
|
|
// scan resumes where its last call stopped, so the text is read once.
|
|
const many = ('\\<' + 'x'.repeat(40) + ' ').repeat(20000);
|
|
const command = `echo ${many}<<'${D}'\nbrief.md\n${D}`;
|
|
const started = Date.now();
|
|
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
|
|
const elapsed = Date.now() - started;
|
|
assert.strictEqual(code, 0, `the guard did not pass the long command (exit ${code}): ${stderr.slice(0, 300)}`);
|
|
assert.ok(elapsed < 15000, `the guard took ${elapsed} ms on ${command.length} characters (limit 15000 ms)`);
|
|
});
|