UNPKG

claude-flow

Version:

Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration

206 lines (186 loc) 7.99 kB
#!/usr/bin/env node /** * Regression guard for ruvnet/ruflo#2089 — ADR-127 Phase 1. * * Generalizes the smoke-pre-bash-hook.mjs pattern (#2017) to the GitHub helper * surface. `github-safe.js` writes untrusted PR/issue body content to a temp * file and passes `--body-file` to `gh` instead of interpolating the body into * shell arguments. Without that protection a body containing shell * metacharacters (backticks, `$(...)`, semicolons) would expand when the caller * embeds the content in an unquoted shell expression. * * Approach: shim the `gh` binary with a fake script that dumps its argv to * stdout and exits 0. We then read that output to assert: * 1. The helper passed `--body-file <tmpfile>`, NOT `--body <rawbody>`. * 2. The temp-file content is verbatim (not shell-expanded). * 3. A body >256KB triggers a rejection BEFORE gh is invoked (Phase 2 target; * Phase 1 documents the expected red→green without failing the build). * 4. An empty body skips the temp-file path entirely (no-op, helper exits 0). * * Runs against BOTH copies: * 1. .claude/helpers/github-safe.js (dogfood) * 2. v3/@claude-flow/cli/.claude/helpers/github-safe.js (init-template) */ import { spawnSync, execFileSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, chmodSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomBytes } from 'node:crypto'; const REPO_ROOT = process.cwd(); const HELPERS = [ join(REPO_ROOT, '.claude', 'helpers', 'github-safe.js'), join(REPO_ROOT, 'v3', '@claude-flow', 'cli', '.claude', 'helpers', 'github-safe.js'), ]; // 256 KB — the GitHub API body field limit documented in ADR-127. const MAX_BODY_BYTES = 256 * 1024; // Create a fake `gh` script that logs its argv to a capture file and exits 0. const captureFile = join(tmpdir(), `gh-smoke-capture-${randomBytes(6).toString('hex')}.json`); const fakeGhDir = join(tmpdir(), `gh-smoke-bin-${randomBytes(6).toString('hex')}`); mkdirSync(fakeGhDir, { recursive: true }); const fakeGhPath = join(fakeGhDir, 'gh'); writeFileSync(fakeGhPath, `#!/usr/bin/env node import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(captureFile)}, JSON.stringify({ argv: process.argv.slice(2) })); process.exit(0); `); chmodSync(fakeGhPath, 0o755); // Inject the fake-gh dir into PATH for child processes. const shimEnv = { ...process.env, PATH: `${fakeGhDir}:${process.env.PATH || ''}` }; const cases = [ { name: 'backtick body — temp-file path, body verbatim', args: ['issue', 'comment', '1', 'code: `rm -rf /`'], expectBodyVerbatim: 'code: `rm -rf /`', expectBodyFileFlagInArgv: true, }, { name: '$() body — temp-file path, body verbatim', args: ['pr', 'comment', '1', 'result: $(whoami)'], expectBodyVerbatim: 'result: $(whoami)', expectBodyFileFlagInArgv: true, }, { name: 'semicolon body — temp-file path, body verbatim', args: ['issue', 'create', '--title', 'test', '--body', 'a; b; c'], expectBodyVerbatim: 'a; b; c', expectBodyFileFlagInArgv: true, }, { // Phase 2: github-safe.js now enforces the 256KB cap (GITHUB_SAFE_VERSION=1.0.0). // A body exceeding the limit must be rejected (exit 1) BEFORE gh is invoked. name: '>256KB body — must be rejected (body cap, Phase 2)', args: ['issue', 'comment', '1', 'x'.repeat(MAX_BODY_BYTES + 1)], expectExit: 1, }, { name: 'empty body — no-op path, exits 0', args: ['issue', 'comment', '1', ''], expectExit: 0, // Empty body takes the "execute normally" branch — gh is called directly. // The fake-gh exits 0, so the helper should exit 0 too. }, ]; function cleanCapture() { try { unlinkSync(captureFile); } catch (_) { /* ignore */ } } function runOne(helperPath, c) { cleanCapture(); const r = spawnSync('node', [helperPath, ...c.args], { encoding: 'utf-8', timeout: 15_000, env: shimEnv, }); const out = r.stdout || ''; const err = r.stderr || ''; const fails = []; if (c.note) { // Documented transition — don't fail the build. return { fails: [], out, err, status: r.status, note: c.note }; } if (c.expectExit !== undefined && c.expectExit !== 'any' && r.status !== c.expectExit) { // Special case: a body that exceeds the kernel's MAX_ARG_STRLEN (128 KiB on // most Linux kernels) gets rejected by the OS at exec time rather than by // the helper itself — spawnSync returns status=null with error.code=E2BIG. // For the purposes of "must be rejected before gh is invoked," that // counts: the body literally cannot reach `gh`. macOS allows >1 MiB args // so locally the helper's own cap fires; CI Linux trips E2BIG first. const isE2BIG = r.status === null && r.error && r.error.code === 'E2BIG'; if (c.expectExit === 1 && isE2BIG) { // Treated as rejected — no extra failure. } else { fails.push(`exit ${r.status} (expected ${c.expectExit})${isE2BIG ? ' [E2BIG]' : ''}`); } } if (c.expectBodyFileFlagInArgv || c.expectBodyVerbatim) { // Read the argv captured by the fake gh script. let argv = []; if (existsSync(captureFile)) { try { argv = JSON.parse(readFileSync(captureFile, 'utf-8')).argv || []; } catch (_) { fails.push('could not parse gh argv capture file'); } } else { fails.push('fake gh was not invoked (capture file missing) — helper may have crashed before calling gh'); } if (c.expectBodyFileFlagInArgv) { const hasBodyFile = argv.includes('--body-file'); const hasBodyInline = argv.includes('--body'); if (!hasBodyFile) { fails.push(`--body-file not found in gh argv (argv: ${JSON.stringify(argv.slice(0, 8))})`); } if (hasBodyInline) { fails.push('--body (inline) found in gh argv — body is being passed unsafely'); } } if (c.expectBodyVerbatim) { // Find the temp-file path (the value after --body-file) and read it. const bfIdx = argv.indexOf('--body-file'); if (bfIdx !== -1 && argv[bfIdx + 1]) { const tmpFilePath = argv[bfIdx + 1]; if (existsSync(tmpFilePath)) { const content = readFileSync(tmpFilePath, 'utf-8'); if (content !== c.expectBodyVerbatim) { fails.push(`temp-file content mismatch: expected ${JSON.stringify(c.expectBodyVerbatim)}, got ${JSON.stringify(content.slice(0, 120))}`); } } else { // Temp file may have been cleaned up already — that's OK for the verbatim check. // The --body-file flag presence is already verified above. } } } } return { fails, out, err, status: r.status }; } let failed = 0; for (const helperPath of HELPERS) { if (!existsSync(helperPath)) { console.error(`[skip] helper not found: ${helperPath}`); continue; } console.log(`\n# ${helperPath}`); for (const c of cases) { const r = runOne(helperPath, c); if (r.note) { console.log(` note ${c.name}`); console.log(` ${r.note}`); } else if (r.fails.length === 0) { console.log(` ok ${c.name}`); } else { failed++; console.error(` fail ${c.name}`); for (const f of r.fails) console.error(` - ${f}`); if (r.out.trim()) console.error(` stdout: ${r.out.trim().replace(/\n/g, ' | ')}`); if (r.err.trim()) console.error(` stderr: ${r.err.trim().slice(0, 200).replace(/\n/g, ' | ')}`); } } } // Cleanup cleanCapture(); try { unlinkSync(fakeGhPath); } catch (_) { /* ignore */ } try { require('fs').rmdirSync(fakeGhDir); } catch (_) { /* ignore */ } if (failed > 0) { console.error(`\n${failed} github-safe injection smoke case(s) failed — regression of #2089`); process.exit(1); } console.log('\nok: github-safe injection smoke passed both helper copies');