fix(ci): unescape node -e inline programs in one pass

validateInlineJs unescaped the shell body with four chained replaces,
so a backslash pair followed by n or t was unescaped twice: the pair
became one backslash, then the new backslash-n became a newline. A
valid hook command such as console.log('\\n') failed validation with
an unterminated string. CodeQL flags the chain as double unescaping.

One regex pass maps each escape exactly once. The regression test
feeds the escaped-backslash program through the validator CLI.
This commit is contained in:
Affaan Mustafa authored and Affaan Mustafa committed 2026-10-19 22:03:57 -04:00
1 parent d3c49a80df
commit 944ea9d050
2 files changed
+23 -1

No files matched your search

+8 -1
View File
@@ -87,6 +87,13 @@ function isStringRecord(value) {
&& Object.values(value).every(item => typeof item === 'string'); && Object.values(value).every(item => typeof item === 'string');
} }
const INLINE_ESCAPES = { '\\': '\\', '"': '"', n: '\n', t: '\t' };
/** Unescape a shell double-quoted `node -e` body in one pass, so `\\n` stays a literal backslash and n. */
function unescapeInlineJs(source) {
return source.replace(/\\(["\\nt])/g, (match, ch) => INLINE_ESCAPES[ch]);
}
/** /**
* Compile the inline program of a `node -e "..."` command so a typo in * Compile the inline program of a `node -e "..."` command so a typo in
* hooks.json fails validation instead of every hook invocation. * hooks.json fails validation instead of every hook invocation.
@@ -96,7 +103,7 @@ function validateInlineJs(command, label) {
const nodeEMatch = command.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s|$)/s); const nodeEMatch = command.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s|$)/s);
if (!nodeEMatch) return false; if (!nodeEMatch) return false;
try { try {
new vm.Script(nodeEMatch[1].replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t')); new vm.Script(unescapeInlineJs(nodeEMatch[1]));
return false; return false;
} catch (syntaxErr) { } catch (syntaxErr) {
console.error(`ERROR: ${label} has invalid inline JS: ${syntaxErr.message}`); console.error(`ERROR: ${label} has invalid inline JS: ${syntaxErr.message}`);
+15
View File
@@ -1039,6 +1039,21 @@ function runTests() {
cleanupTestDir(testDir); cleanupTestDir(testDir);
})) passed++; else failed++; })) passed++; else failed++;
if (test('unescapes inline JS in one pass so an escaped backslash before n stays literal', () => {
const testDir = createTestDir();
const hooksFile = path.join(testDir, 'hooks.json');
// Shell body `console.log('\\n')` is a valid program; a sequential unescape turned it into a raw newline.
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ id: 'test:escaped-backslash-inline-js', matcher: 'test', hooks: [{ type: 'command', command: 'node -e "console.log(\'\\\\n\')"' }] }]
}
}));
const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile);
assert.strictEqual(result.code, 0, `Should pass escaped-backslash inline JS, stderr: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('validates array command format', () => { if (test('validates array command format', () => {
const testDir = createTestDir(); const testDir = createTestDir();
const hooksFile = path.join(testDir, 'hooks.json'); const hooksFile = path.join(testDir, 'hooks.json');