Files
ECC/scripts/lib/astra-review/codex.js
T
kenima-arcandClaude Fable 5.1 946b80198f feat: add /astra-review cross-provider code review via Codex CLI
Send Claude-written code to GPT-6-Astra (Codex CLI, ChatGPT login) for an
independent review and return a structured PASS/FAIL verdict with
severity-tagged findings.

- commands/astra-review.md (+ ja-JP): review, fix, re-run workflow
- scripts/astra-review.js + scripts/lib/astra-review/{scope,prompt,codex}.js
- scopes: uncommitted, --base, --commit, --files, --files-from-commit
- reviewer isolation: read-only sandbox, web search off, user config
  skipped, every configured MCP server disabled by name, filtered env
- safe collection: no symlink following, repo containment, --no-ext-diff,
  NUL-delimited inventories, explicit failure on overflow
- 87 tests; registry, agent.yaml, package files, install manifest updated

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 00:07:00 +09:00

204 lines
7.8 KiB
JavaScript

/**
* Astra review: invoke the Codex CLI (ChatGPT login) with GPT-6-Astra
* in a read-only sandbox and return the parsed structured verdict.
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { REVIEW_SCHEMA, parseReviewOutput } = require('./prompt');
const DEFAULT_MODEL = 'gpt-6-astra';
const DEFAULT_TIMEOUT_MS = 300_000;
const MIN_TIMEOUT_MS = 30_000;
const MAX_TIMEOUT_MS = 900_000;
const MAX_OUTPUT_BUFFER = 4 * 1024 * 1024;
const ENV_ALLOWLIST = Object.freeze([
'PATH', 'HOME', 'USERPROFILE', 'CODEX_HOME',
'TMPDIR', 'TMP', 'TEMP', 'SystemRoot', 'ComSpec', 'PATHEXT', 'LANG', 'LC_ALL',
]);
/**
* Only pass through what Codex needs. API keys and other secrets stay out.
* Keys match case-insensitively (Windows exposes `Path`, not `PATH`) and keep
* their original spelling so the child process sees what the OS expects.
*/
function buildEnvironment(sourceEnv = process.env) {
const allowed = new Set(ENV_ALLOWLIST.map((name) => name.toUpperCase()));
return Object.fromEntries(
Object.entries(sourceEnv).filter(([name, value]) => value && allowed.has(name.toUpperCase()))
);
}
// `codex -c` treats dots as key separators and does not support quoted keys,
// so a dotted server name cannot be disabled reliably. Refuse instead of guessing.
const MCP_SERVER_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
const MCP_LIST_TIMEOUT_MS = 15_000;
/**
* Codex merges `mcp_servers={}` with the loaded config instead of clearing it,
* so every configured server is disabled by name. The user config file is
* skipped as well (auth is separate and still applies). Because exec then no
* longer sees user-level servers, a bare `<name>.enabled=false` would create an
* entry without a transport and Codex rejects it; the override therefore
* carries an inert stdio transport alongside enabled=false.
* @param {{cwd: string, model: string, outputFile: string, schemaFile: string, mcpServers?: string[]}} input
* @returns {string[]}
*/
function buildCodexArgs(input) {
const mcpDisables = (input.mcpServers || []).flatMap((name) => {
if (!MCP_SERVER_NAME.test(name)) {
throw new Error(`cannot isolate MCP server name "${name}" (only letters, digits, - and _ can be disabled via codex -c); rename or disable it in the Codex config first`);
}
return ['--config', `mcp_servers.${name}={command="true",enabled=false}`];
});
return [
'--ask-for-approval', 'never',
'exec',
'--ignore-user-config',
'--sandbox', 'read-only',
'--cd', input.cwd,
'--color', 'never',
'--skip-git-repo-check',
'-m', input.model,
'--config', 'web_search="disabled"',
...mcpDisables,
'--output-schema', input.schemaFile,
'--output-last-message', input.outputFile,
'-',
];
}
/**
* Names of every MCP server Codex would load, so each can be disabled explicitly.
* @param {{command: string, prefixArgs: string[]}} launcher
* @param {{spawnSync?: Function, env?: object}} [deps]
* @returns {string[]}
*/
function listConfiguredMcpServers(launcher, deps = {}) {
const spawn = deps.spawnSync || spawnSync;
const result = spawn(launcher.command, [...launcher.prefixArgs, 'mcp', 'list', '--json'], {
env: deps.env || process.env,
encoding: 'utf8',
timeout: MCP_LIST_TIMEOUT_MS,
maxBuffer: MAX_OUTPUT_BUFFER,
windowsHide: true,
});
if (result.error) throw translateSpawnError(result.error);
let servers;
try {
servers = JSON.parse(result.stdout || '');
} catch (error) {
throw new Error(`MCP servers could not be listed (${error.message}); refusing to review without isolation`);
}
if (result.status !== 0 || !Array.isArray(servers)) {
const detail = (result.stderr || '').trim().split('\n').slice(-1)[0];
throw new Error(`MCP servers could not be listed${detail ? `: ${detail}` : ''}; refusing to review without isolation`);
}
return servers.map((server) => String(server && server.name));
}
/**
* The npm-installed Codex CLI is a `codex.cmd` shim on Windows, which Node
* cannot spawn without a shell. Run its JavaScript entry point through node
* instead; a native codex.exe is used as-is. Elsewhere `codex` on PATH is enough.
* @param {{platform?: string, env?: object, existsSync?: Function, execPath?: string}} [deps]
* @returns {{command: string, prefixArgs: string[]}}
*/
function resolveCodexCommand(deps = {}) {
const platform = deps.platform || process.platform;
if (platform !== 'win32') return { command: 'codex', prefixArgs: [] };
const env = deps.env || process.env;
const exists = deps.existsSync || fs.existsSync;
const execPath = deps.execPath || process.execPath;
const pathValue = env.PATH || env.Path || env.path || '';
const dirs = pathValue.split(';').filter(Boolean);
const exeDir = dirs.find((dir) => exists(path.win32.join(dir, 'codex.exe')));
if (exeDir) return { command: path.win32.join(exeDir, 'codex.exe'), prefixArgs: [] };
const shimDir = dirs.find((dir) => exists(path.win32.join(dir, 'codex.cmd')));
if (shimDir) {
const entry = path.win32.join(shimDir, 'node_modules', '@openai', 'codex', 'bin', 'codex.js');
if (exists(entry)) return { command: execPath, prefixArgs: [entry] };
}
return { command: 'codex', prefixArgs: [] };
}
function translateSpawnError(error) {
if (error.code === 'ENOENT') return new Error('Codex CLI is not installed (install: npm i -g @openai/codex)');
if (error.code === 'ETIMEDOUT') return new Error('Codex review timed out');
return new Error(`Codex invocation failed: ${error.message}`);
}
function readLastMessage(outputFile) {
let text;
try {
text = fs.readFileSync(outputFile, 'utf8').trim();
} catch (error) {
throw new Error(`Codex returned no final response: ${error.message}`);
}
if (!text) throw new Error('Codex returned an empty final response');
return text;
}
/**
* @param {{prompt: string, cwd: string, model: string, timeoutMs: number}} input
* @param {{spawnSync?: Function, env?: object, resolveCodexCommand?: Function}} [deps]
* @returns {object} Parsed review
*/
function runCodexReview(input, deps = {}) {
if (!input.prompt || !input.prompt.trim()) throw new Error('review prompt is empty');
if (input.timeoutMs < MIN_TIMEOUT_MS || input.timeoutMs > MAX_TIMEOUT_MS) {
throw new Error(`timeout must be between ${MIN_TIMEOUT_MS / 1000} and ${MAX_TIMEOUT_MS / 1000} seconds`);
}
const spawn = deps.spawnSync || spawnSync;
const environment = buildEnvironment(deps.env || process.env);
const launcher = (deps.resolveCodexCommand || resolveCodexCommand)({ env: environment });
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-astra-review-'));
const outputFile = path.join(tempDir, 'last-message.txt');
const schemaFile = path.join(tempDir, 'review-schema.json');
try {
fs.writeFileSync(schemaFile, JSON.stringify(REVIEW_SCHEMA), 'utf8');
const mcpServers = listConfiguredMcpServers(launcher, { spawnSync: spawn, env: environment });
const args = buildCodexArgs({ cwd: input.cwd, model: input.model, outputFile, schemaFile, mcpServers });
const result = spawn(launcher.command, [...launcher.prefixArgs, ...args], {
cwd: input.cwd,
env: environment,
input: input.prompt,
encoding: 'utf8',
timeout: input.timeoutMs,
maxBuffer: MAX_OUTPUT_BUFFER,
windowsHide: true,
});
if (result.error) throw translateSpawnError(result.error);
if (result.status !== 0) {
const detail = (result.stderr || '').trim().split('\n').slice(-1)[0];
throw new Error(`Codex review failed${detail ? `: ${detail}` : ''}`);
}
return parseReviewOutput(readLastMessage(outputFile));
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
module.exports = {
DEFAULT_MODEL,
DEFAULT_TIMEOUT_MS,
MAX_TIMEOUT_MS,
MIN_TIMEOUT_MS,
buildCodexArgs,
buildEnvironment,
listConfiguredMcpServers,
resolveCodexCommand,
runCodexReview,
};