fix(autoresearch): pass claude prompt via stdin (#2184)

* fix(autoresearch): pass Claude prompt via stdin to prevent shell injection

The modify() function in autoresearch/commands/run.ts interpolated a prompt
string — built from git log messages and scope file names — directly into
a shell command executed by execSync. The double-quote escaping only handled
literal quotes, leaving $(...), backticks and backslashes able to trigger
command substitution.

Switch to the same pattern already used in autoresearch/commands/fix.ts:
pass the prompt via the execSync 'input' option so it is delivered on stdin
and never parsed by the shell.

* fix(autoresearch): invoke Claude without a shell

---------

Co-authored-by: OpenCLI-sol <opencli-sol@users.noreply.github.com>
This commit is contained in:
Sebastion
2026-07-29 13:19:58 +01:00
committed by GitHub
parent ab28a1f2eb
commit b58f26006d
2 changed files with 59 additions and 11 deletions
+36 -11
View File
@@ -11,7 +11,7 @@
* Engine handles commit, verify, guard, keep/discard, and logging.
*/
import { execSync } from 'node:child_process';
import { execFileSync, type ExecFileSyncOptionsWithStringEncoding } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs, type AutoResearchConfig } from '../config.js';
@@ -20,6 +20,31 @@ import { PRESETS } from '../presets/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
const CLAUDE_ALLOWED_TOOLS = 'Bash(npm:*),Bash(npx:*),Bash(git:*),Read,Edit,Write,Glob,Grep';
export function buildClaudeModifyInvocation(prompt: string) {
const options: ExecFileSyncOptionsWithStringEncoding = {
cwd: ROOT,
timeout: 300_000,
encoding: 'utf-8',
input: prompt,
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
};
return {
command: 'claude',
args: [
'-p',
'--dangerously-skip-permissions',
'--allowedTools',
CLAUDE_ALLOWED_TOOLS,
'--output-format',
'text',
'--no-session-persistence',
],
options,
};
}
function buildModifyPrompt(ctx: ModifyContext, config: AutoResearchConfig): string {
const recent = ctx.recentLog.slice(-10).map(r =>
@@ -60,15 +85,13 @@ async function modify(ctx: ModifyContext, config: AutoResearchConfig): Promise<s
console.log(' Claude Code making a change...');
try {
const result = execSync(
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Bash(git:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
{
cwd: ROOT,
timeout: 300_000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
}
// Keep command structure and the repository-derived prompt out of a shell.
// Claude reads the prompt from stdin when -p has no positional prompt.
const invocation = buildClaudeModifyInvocation(prompt);
const result = execFileSync(
invocation.command,
invocation.args,
invocation.options
).trim();
// Extract description from Claude's response (last non-empty line or summary)
@@ -135,4 +158,6 @@ async function main() {
}
}
main();
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main();
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { buildClaudeModifyInvocation } from '../autoresearch/commands/run.js';
describe('autoresearch Claude invocation', () => {
it('passes repository-derived prompts through stdin instead of shell arguments', () => {
const prompt = 'try $(touch /tmp/opencli-pwned) and `id` and "quotes"';
const invocation = buildClaudeModifyInvocation(prompt);
expect(invocation.command).toBe('claude');
expect(invocation.args).toEqual([
'-p',
'--dangerously-skip-permissions',
'--allowedTools',
'Bash(npm:*),Bash(npx:*),Bash(git:*),Read,Edit,Write,Glob,Grep',
'--output-format',
'text',
'--no-session-persistence',
]);
expect(invocation.args.join(' ')).not.toContain(prompt);
expect(invocation.options.input).toBe(prompt);
expect(invocation.options).not.toHaveProperty('shell');
});
});