fix: bootstrap plugin-installed hook commands safely

This commit is contained in:
Affaan Mustafa
2026-04-14 20:24:21 -07:00
parent 48a30b53c8
commit 1b7c5789fc
9 changed files with 564 additions and 95 deletions

View File

@@ -110,24 +110,70 @@ function runHookCommand(command, input = {}, env = {}, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
const isWindows = process.platform === 'win32';
const mergedEnv = { ...process.env, CLAUDE_PLUGIN_ROOT: REPO_ROOT, ...env };
if (Array.isArray(command)) {
const [program, ...args] = command;
const proc = spawn(program, args, { env: mergedEnv, stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
let timer;
proc.stdout.on('data', data => stdout += data);
proc.stderr.on('data', data => stderr += data);
proc.stdin.on('error', (err) => {
if (err.code !== 'EPIPE' && err.code !== 'EOF') {
if (timer) clearTimeout(timer);
reject(err);
}
});
if (input && Object.keys(input).length > 0) {
proc.stdin.write(JSON.stringify(input));
}
proc.stdin.end();
timer = setTimeout(() => {
proc.kill(isWindows ? undefined : 'SIGKILL');
reject(new Error(`Hook command timed out after ${timeoutMs}ms`));
}, timeoutMs);
proc.on('close', code => {
clearTimeout(timer);
resolve({ code, stdout, stderr });
});
proc.on('error', err => {
clearTimeout(timer);
reject(err);
});
return;
}
const resolvedCommand = command.replace(
/\$\{([A-Z_][A-Z0-9_]*)\}/g,
(_, name) => String(mergedEnv[name] || '')
);
const nodeMatch = resolvedCommand.match(/^node\s+"([^"]+)"\s*(.*)$/);
const useDirectNodeSpawn = Boolean(nodeMatch);
const inlineNodeMatch = resolvedCommand.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s+(.*))?$/s);
const fileNodeMatch = resolvedCommand.match(/^node\s+"([^"]+)"\s*(.*)$/);
const useDirectNodeSpawn = Boolean(inlineNodeMatch || fileNodeMatch);
const shell = isWindows ? 'cmd' : 'bash';
const shellArgs = isWindows ? ['/d', '/s', '/c', resolvedCommand] : ['-lc', resolvedCommand];
const nodeArgs = nodeMatch
? [
nodeMatch[1],
...Array.from(
nodeMatch[2].matchAll(/"([^"]*)"|(\S+)/g),
m => m[1] !== undefined ? m[1] : m[2]
)
]
: [];
const splitArgs = value => Array.from(
String(value || '').matchAll(/"([^"]*)"|(\S+)/g),
m => m[1] !== undefined ? m[1] : m[2]
);
const unescapeInlineJs = value => value
.replace(/\\\\/g, '\\')
.replace(/\\"/g, '"')
.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t');
const nodeArgs = inlineNodeMatch
? ['-e', unescapeInlineJs(inlineNodeMatch[1]), ...splitArgs(inlineNodeMatch[2])]
: fileNodeMatch
? [fileNodeMatch[1], ...splitArgs(fileNodeMatch[2])]
: [];
const proc = useDirectNodeSpawn
? spawn('node', nodeArgs, { env: mergedEnv, stdio: ['pipe', 'pipe', 'pipe'] })
@@ -899,16 +945,22 @@ async function runTests() {
assert.ok(asyncHook.hooks[0].timeout > 0, 'Timeout should be positive');
const command = asyncHook.hooks[0].command;
const isNodeInline = command.startsWith('node -e');
const isNodeScript = command.startsWith('node "');
const commandText = Array.isArray(command) ? command.join(' ') : command;
const isNodeInline =
(Array.isArray(command) && command[0] === 'node' && command[1] === '-e') ||
commandText.startsWith('node -e');
const isNodeScript =
(Array.isArray(command) && command[0] === 'node' && typeof command[1] === 'string' && command[1].endsWith('.js')) ||
commandText.startsWith('node "');
const isShellWrapper =
command.startsWith('bash "') ||
command.startsWith('sh "') ||
command.startsWith('bash -lc ') ||
command.startsWith('sh -c ');
(Array.isArray(command) && (command[0] === 'bash' || command[0] === 'sh')) ||
commandText.startsWith('bash "') ||
commandText.startsWith('sh "') ||
commandText.startsWith('bash -lc ') ||
commandText.startsWith('sh -c ');
assert.ok(
isNodeInline || isNodeScript || isShellWrapper,
`Async hook command should be runnable (node -e, node script, or shell wrapper), got: ${command.substring(0, 80)}`
`Async hook command should be runnable (node -e, node script, or shell wrapper), got: ${commandText.substring(0, 80)}`
);
})) passed++; else failed++;
@@ -920,19 +972,28 @@ async function runTests() {
for (const hook of hookDef.hooks) {
assert.ok(hook.command, `Hook in ${hookType} should have command field`);
const isInline = hook.command.startsWith('node -e');
const isFilePath = hook.command.startsWith('node "');
const isNpx = hook.command.startsWith('npx ');
const command = hook.command;
const commandText = Array.isArray(command) ? command.join(' ') : command;
const isInline =
(Array.isArray(command) && command[0] === 'node' && command[1] === '-e') ||
commandText.startsWith('node -e');
const isFilePath =
(Array.isArray(command) && command[0] === 'node' && typeof command[1] === 'string' && command[1].endsWith('.js')) ||
commandText.startsWith('node "');
const isNpx = (Array.isArray(command) && command[0] === 'npx') || commandText.startsWith('npx ');
const isShellWrapper =
hook.command.startsWith('bash "') ||
hook.command.startsWith('sh "') ||
hook.command.startsWith('bash -lc ') ||
hook.command.startsWith('sh -c ');
const isShellScriptPath = hook.command.endsWith('.sh');
(Array.isArray(command) && (command[0] === 'bash' || command[0] === 'sh')) ||
commandText.startsWith('bash "') ||
commandText.startsWith('sh "') ||
commandText.startsWith('bash -lc ') ||
commandText.startsWith('sh -c ');
const isShellScriptPath =
(Array.isArray(command) && typeof command[0] === 'string' && command[0].endsWith('.sh')) ||
commandText.endsWith('.sh');
assert.ok(
isInline || isFilePath || isNpx || isShellWrapper || isShellScriptPath,
`Hook command in ${hookType} should be node -e, node script, npx, or shell wrapper/script, got: ${hook.command.substring(0, 80)}`
`Hook command in ${hookType} should be node -e, node script, npx, or shell wrapper/script, got: ${commandText.substring(0, 80)}`
);
}
}