104 lines
5.3 KiB
JavaScript
104 lines
5.3 KiB
JavaScript
// tests/hooks/bash-guard-args-gate.test.mjs
|
|
// [CSG] [CTB] Fix round 4.5b, part 4 (decision 90).
|
|
// Defects: the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs ran
|
|
// its whole check only when the command held the delimiter literally, so an
|
|
// unquoted opener whose delimiter is split by a backslash-newline, with a body
|
|
// and no closing line, was never read: the guard exited 0 and the shell
|
|
// expanded the body (CSG). And the branch that makes the guard fail closed
|
|
// when its own shell reading throws had no test (CTB).
|
|
// The helpers ARGS_DELIM, ARGS_RULE, PARSER_LINE, bashInput,
|
|
// expectArgsBlocked, expectArgsAllowed, UNQUOTED and expectUnquoted are copied
|
|
// from tests/hooks/bash-guard-opener-word.test.mjs:22-53, not imported,
|
|
// because importing a test file would run its tests. The guard is only
|
|
// spawned, so this file loads at 591f3c4 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 } 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)}`);
|
|
}
|
|
|
|
const D = ARGS_DELIM;
|
|
|
|
test('[CSG] the guard checks an opener whose delimiter is split by a backslash-newline, with a body and no closing line', async () => {
|
|
// Red: the command never holds the delimiter literally; the shell removes
|
|
// the continuation, reads the delimiter as the word and expands the body.
|
|
await expectUnquoted(`${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED)`);
|
|
await expectUnquoted(`${PARSER_LINE} <<VOYAGE_ARGS_EOF\\\n_7F3C9E\nbrief.md $(touch PWNED)`);
|
|
|
|
// Control: an ordinary unquoted heredoc that names no delimiter passes.
|
|
await expectArgsAllowed('cat <<EOF\n$(date)\nEOF');
|
|
});
|
|
|
|
// The preload makes String.prototype.indexOf throw when it is called with '<'
|
|
// from inside argsShellOpeners, so the shell reading throws. It is written
|
|
// into a temp directory, never under tests/, and only makes the hook block.
|
|
const PRELOAD = `const orig = String.prototype.indexOf;
|
|
String.prototype.indexOf = function (search, ...rest) {
|
|
if (search === '<' && /argsShellOpeners/.test(new Error().stack)) {
|
|
process.stderr.write('[ctb-preload] thrown\\n');
|
|
throw new Error('CTB forced read error');
|
|
}
|
|
return orig.call(this, search, ...rest);
|
|
};
|
|
`;
|
|
|
|
const preloadDir = tmp('voyage-ctb-');
|
|
after(() => rmSync(preloadDir, { recursive: true, force: true }));
|
|
|
|
test('[CTB] an error inside the shell reading blocks the command (exit 2), never lets it through', async () => {
|
|
const file = join(preloadDir, 'ctb-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('[ctb-preload] thrown'), `the preload did not throw inside the shell reading: ${stderr}`);
|
|
assert.strictEqual(code, 2, `the guard did not block when its shell reading threw (exit ${code}): ${stderr}`);
|
|
assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early: ${stderr}`);
|
|
assert.ok(stderr.includes("The guard could not read the opener's word"), `the could-not-read suffix is missing: ${stderr}`);
|
|
assert.ok(!stderr.includes('CTB 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}`);
|
|
});
|