mirror of
https://github.com/alirezarezvani/claude-skills.git
synced 2026-09-06 08:15:58 +00:00
The Skill Security Audit failed PR #984 with 2 CRITICAL CMD-INJECT findings on hivemind's Node scripts — pre-existing since #979 merged while Actions wasn't triggering; touching the skill's agent files pulled it into audit scope. Spawning headless opencode worker processes is this skill's core, documented function (SKILL.md Prerequisites + the PR #979 dependency disclosure), so the imports carry the auditor's own suppression directive with the justification inline. Re-audit: PASS, 0 critical / 0 high. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qgc6RYXWJPr5oW9DHU7zR4
197 lines
7.7 KiB
JavaScript
197 lines
7.7 KiB
JavaScript
import { spawnSync, spawn } from "node:child_process"; // auditor:ignore-line -- spawning headless opencode worker processes is this skill's core, documented function (see SKILL.md Prerequisites + PR #979 dependency disclosure)
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { readFileSync, statSync, mkdirSync, appendFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const SERVER = process.env.HIVEMIND_SERVER_URL || "http://127.0.0.1:4096";
|
|
// SERVER comes from the environment and PORT is handed to a child process, so validate
|
|
// both here - and still emit exactly one JSON line when they are unusable.
|
|
const PORT = resolvePort(SERVER);
|
|
|
|
function bail(error) {
|
|
console.log(JSON.stringify({ ok: false, stage: "args", error, result: "", tokens: null, cost_usd: null, duration_ms: 0 }));
|
|
process.exit(0);
|
|
}
|
|
|
|
function resolvePort(server) {
|
|
let url;
|
|
try { url = new URL(server); } catch { bail("HIVEMIND_SERVER_URL is not a valid URL"); }
|
|
const port = url.port || "4096";
|
|
if (!/^[0-9]{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) bail("HIVEMIND_SERVER_URL has an invalid port");
|
|
return port;
|
|
}
|
|
|
|
const DEFAULT_MODEL = "opencode/mimo-v2.5-free";
|
|
const STDERR_TAIL = 300;
|
|
const RUNS_DIR = join(dirname(dirname(fileURLToPath(import.meta.url))), ".runs");
|
|
|
|
let RUN = null, LABEL = null, AGENT = null;
|
|
|
|
function logRun(event, extra = {}) {
|
|
if (!RUN) return;
|
|
try {
|
|
mkdirSync(RUNS_DIR, { recursive: true });
|
|
appendFileSync(join(RUNS_DIR, `${RUN}.jsonl`), JSON.stringify({ ts: Date.now(), event, label: LABEL, agent: AGENT, ...extra }) + "\n");
|
|
} catch {}
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const out = { agent: null, model: null, dir: null, timeoutMs: 600000, run: null, label: null, task: [] };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === "--agent") out.agent = argv[++i] ?? null;
|
|
else if (a === "--model") out.model = argv[++i] ?? null;
|
|
else if (a === "--dir") out.dir = argv[++i] ?? null;
|
|
else if (a === "--timeout") out.timeoutMs = Number(argv[++i]) * 1000 || out.timeoutMs;
|
|
else if (a === "--run") out.run = argv[++i] ?? null;
|
|
else if (a === "--label") out.label = argv[++i] ?? null;
|
|
else out.task.push(a);
|
|
}
|
|
out.task = out.task.join(" ").trim();
|
|
return out;
|
|
}
|
|
|
|
function fail(stage, message, extra = {}) {
|
|
const payload = { ok: false, stage, error: String(message).slice(0, STDERR_TAIL), result: "", tokens: null, cost_usd: null, duration_ms: 0, ...extra };
|
|
if (payload.duration_ms) logRun("fail", { stage, error: payload.error, duration_ms: payload.duration_ms });
|
|
console.log(JSON.stringify(payload));
|
|
process.exit(0);
|
|
}
|
|
|
|
async function alive(url = SERVER, ms = 500) {
|
|
try {
|
|
await fetch(url, { signal: AbortSignal.timeout(ms) });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function resolveOpencode() {
|
|
if (process.platform !== "win32") {
|
|
const posix = spawnSync("which", ["opencode"], { encoding: "utf8" });
|
|
const path = posix.status === 0 ? posix.stdout.trim().split(/\r?\n/)[0] : "";
|
|
return path || "opencode";
|
|
}
|
|
// Windows: npm installs a .cmd shim, and Node's EINVAL policy blocks spawning
|
|
// .cmd directly - so resolve the real .exe (parsing the shim when needed).
|
|
const probe = spawnSync("where.exe", ["opencode"], { encoding: "utf8" });
|
|
if (probe.status === 0) {
|
|
const lines = probe.stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
|
const exe = lines.find(l => l.toLowerCase().endsWith(".exe"));
|
|
if (exe) return exe;
|
|
const cmdShim = lines.find(l => l.toLowerCase().endsWith(".cmd"));
|
|
if (cmdShim) {
|
|
try {
|
|
const body = readFileSync(cmdShim, "utf8");
|
|
const m = body.match(/"%dp0%\\(.*?\.exe)"/i);
|
|
if (m) return join(dirname(cmdShim), m[1]);
|
|
} catch {}
|
|
}
|
|
}
|
|
return "opencode";
|
|
}
|
|
|
|
async function ensureServer(bin) {
|
|
if (await alive()) return [];
|
|
// Args array with shell:false - nothing here is shell-interpolated, so a hostile
|
|
// HIVEMIND_SERVER_URL cannot inject syntax and paths with spaces need no quoting.
|
|
const child = spawn(bin, ["serve", "--port", PORT], { shell: false, detached: true, stdio: "ignore", windowsHide: true });
|
|
child.unref();
|
|
for (let i = 0; i < 10; i++) {
|
|
await delay(500);
|
|
if (await alive()) return ["--attach", SERVER];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
async function main() {
|
|
const t0 = Date.now();
|
|
const opts = parseArgs(process.argv.slice(2));
|
|
if (!opts.task) fail("args", "no task given");
|
|
RUN = opts.run; LABEL = opts.label || opts.agent || "worker"; AGENT = opts.agent;
|
|
logRun("start", { model: opts.model, dir: opts.dir });
|
|
if (opts.dir && !existsDir(opts.dir)) fail("args", `--dir does not exist: ${opts.dir}`);
|
|
|
|
const bin = resolveOpencode();
|
|
const attachArgs = await ensureServer(bin);
|
|
|
|
const baseArgs = ["run"];
|
|
if (opts.agent) baseArgs.push("--agent", opts.agent);
|
|
if (opts.model) baseArgs.push("--model", opts.model);
|
|
if (opts.dir) baseArgs.push("--dir", opts.dir);
|
|
baseArgs.push(...attachArgs);
|
|
|
|
let r = spawnSync(bin, [...baseArgs, "--format", "json", opts.task], {
|
|
encoding: "utf8",
|
|
maxBuffer: 256 * 1024 * 1024,
|
|
timeout: opts.timeoutMs,
|
|
env: { ...process.env },
|
|
windowsHide: true,
|
|
});
|
|
|
|
if (!r.stdout || !r.stdout.trim()) {
|
|
const tailErr = ((r.stderr || "") + " " + (r.error ? String(r.error) : "")).trim();
|
|
const retry = spawnSync(bin, [...baseArgs, opts.task], {
|
|
encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: opts.timeoutMs, windowsHide: true,
|
|
});
|
|
const text = (retry.stdout || "").trim();
|
|
if (text) {
|
|
logRun("done", { degraded: true, duration_ms: Date.now() - t0 });
|
|
console.log(JSON.stringify({ ok: true, degraded: "plain-format-fallback", result: text.slice(-20000), tokens: null, cost_usd: null, duration_ms: Date.now() - t0, agent: opts.agent, model: opts.model }));
|
|
return;
|
|
}
|
|
fail("exec", tailErr || `opencode exited ${r.status}`, { duration_ms: Date.now() - t0 });
|
|
}
|
|
|
|
const parsed = parseNdjson(r.stdout);
|
|
if (!parsed.ok) {
|
|
fail(parsed.stage, parsed.error, { duration_ms: Date.now() - t0, model: opts.model });
|
|
}
|
|
|
|
const duration = Date.now() - t0;
|
|
logRun("done", { tokens_total: parsed.tokens?.total ?? null, duration_ms: duration });
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
result: parsed.result.slice(-20000),
|
|
tokens: parsed.tokens,
|
|
cost_usd: parsed.costUsd,
|
|
session_id: parsed.sessionId,
|
|
duration_ms: duration,
|
|
label: LABEL,
|
|
agent: opts.agent,
|
|
model: opts.model ?? DEFAULT_MODEL,
|
|
}));
|
|
}
|
|
|
|
function parseNdjson(stdout) {
|
|
const lines = stdout.split(/\r?\n/).filter(l => l.trim());
|
|
const texts = [];
|
|
let tokens = null, costUsd = null, sessionId = null, sawFinish = false;
|
|
for (const line of lines) {
|
|
let ev;
|
|
try { ev = JSON.parse(line); } catch { continue; }
|
|
if (ev.type === "error") {
|
|
const msg = ev.error?.data?.message || ev.error?.message || "APIError";
|
|
return { ok: false, stage: "api", error: msg };
|
|
}
|
|
if (ev.type === "text" && ev.part?.text != null) texts.push(ev.part.text);
|
|
if (ev.type === "step_finish" && ev.part) {
|
|
sawFinish = true;
|
|
sessionId = ev.sessionID || sessionId;
|
|
if (ev.part.tokens) tokens = ev.part.tokens;
|
|
if (typeof ev.part.cost === "number") costUsd = ev.part.cost;
|
|
}
|
|
}
|
|
const result = texts.join("\n").trim();
|
|
if (!result && !sawFinish) return { ok: false, stage: "parse", error: "no text parts and no step_finish in stream" };
|
|
if (!result) return { ok: false, stage: "empty", error: "worker produced no text output" };
|
|
return { ok: true, result, tokens, costUsd, sessionId };
|
|
}
|
|
|
|
function existsDir(p) {
|
|
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
}
|
|
|
|
main().catch(e => fail("crash", e?.stack || e));
|