Files
clawhub/scripts/security/run-codex-scan-worker.ts
T
Jesse Merhi 2deb1b75e9 Harden worker secret logging (#2784)
* fix: redact scan worker artifact errors

* fix: harden worker secret logging

* fix: keep worker claim failures failing

* fix: scan diagnostics as files

* fix: pin diagnostics scanner image

* fix: narrow worker redaction boundary

* fix: avoid custom worker secret patterns

* fix: centralize worker transport redaction

* fix: redact persisted worker failure secrets

* fix: fail security worker on claim outages

* fix: redact structured diagnostic secret fields

* fix: harden worker failure redaction boundaries

* fix: cover bracketed worker secret values

* test: enforce worker workflow secret references

* fix: redact quoted worker secret values

* fix: redact diagnostic artifact path labels

* test: cover claim failure redaction

* fix: simplify worker redaction boundary

* test: prove worker logger emits structured events
2026-06-25 11:47:39 +10:00

1304 lines
41 KiB
TypeScript

import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { ConvexHttpClient } from "convex/browser";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import {
detectInjectionPatterns,
parseLlmEvalResponse,
type LlmEvalDimension,
SKILL_SECURITY_EVALUATOR_SYSTEM_PROMPT,
} from "../../convex/lib/securityPrompt";
import { assertCodexWorkerExecutionAllowed, resolveCodexWorkerHome } from "../codex-worker-guard";
import { createWorkerLogger } from "../lib/workerLogger";
import {
maskGitHubActionsSecret,
maskKnownWorkerSecrets,
redactWorkerPublicErrorMessage,
redactWorkerPublicText,
safeWorkerArtifactPathLabel,
} from "../lib/workerRedaction";
type ClaimedJob = {
job: {
_id: string;
leaseToken: string;
targetKind: "skillVersion" | "packageRelease" | "skillScanRequest";
source: string;
hasMaliciousSignal: boolean;
waitForVtUntil: number;
attempts?: number;
};
target: Record<string, unknown> & {
files?: Array<{
path: string;
url: string;
size: number;
sha256: string;
contentType?: string;
}>;
clawpackUrl?: string | null;
};
};
type StoredLlmAnalysis = {
status: string;
verdict?: string;
confidence?: string;
summary?: string;
dimensions?: LlmEvalDimension[];
guidance?: string;
findings?: string;
model?: string;
checkedAt: number;
};
type SkillSpectorIssue = {
issueId: string;
category?: string;
pattern?: string;
severity: string;
confidence?: number;
file?: string;
startLine?: number;
endLine?: number;
explanation: string;
remediation?: string;
finding?: string;
codeSnippet?: string;
};
type SkillSpectorAnalysis = {
status: string;
score?: number;
severity?: string;
recommendation?: string;
issueCount: number;
issues: SkillSpectorIssue[];
scannerVersion?: string;
summary?: string;
error?: string;
checkedAt: number;
};
type CodexCommandDiagnostic = {
args?: string[];
exitCode?: number | null;
rawResult?: string;
stderr?: string;
stdout?: string;
};
type JobDiagnosticInput = {
codex?: CodexCommandDiagnostic;
completedAt: number;
diagnosticsRoot?: string;
error?: string;
job: ClaimedJob;
llmAnalysis?: unknown;
runId?: string;
skillSpector?: CodexCommandDiagnostic;
skillSpectorAnalysis?: unknown;
startedAt: number;
status: "completed" | "failed";
};
type CodexScanWorkerClient = Pick<ConvexHttpClient, "action">;
const DEFAULT_BATCH_LIMIT = 4;
const DEFAULT_MAX_RUNTIME_MS = 40 * 60 * 1000;
const DEFAULT_CODEX_SCAN_TIMEOUT_MS = 20 * 60 * 1000;
const MAX_DIAGNOSTIC_TEXT_CHARS = 20_000;
const MAX_STORED_SKILLSPECTOR_ISSUES = 25;
const MAX_STORED_SKILLSPECTOR_TEXT_CHARS = 2_000;
const MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS = 512;
const DEFAULT_LEASE_MS = 60 * 60 * 1000;
const logger = createWorkerLogger({ name: "security-scan-worker" });
const root = resolve(new URL("../..", import.meta.url).pathname);
const schemaPath = join(root, "scripts/security/codex-scan-output.schema.json");
const DEFAULT_DIAGNOSTICS_ROOT = join(
root,
".artifacts/codex-security-scan",
process.env.GITHUB_RUN_ID ?? `local-${process.pid}`,
);
const LOCAL_CODEX_HOME = join(root, ".codex/runtime/codex-workers/security-scan");
const ARTIFACT_SIGNAL_FILE_EXTENSIONS = new Set([
".cjs",
".css",
".html",
".js",
".json",
".jsx",
".md",
".mjs",
".sh",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
]);
function parseArgs() {
const args = process.argv.slice(2);
const get = (name: string) => {
const index = args.indexOf(name);
return index === -1 ? undefined : args[index + 1];
};
const numberFrom = (value: string | undefined, fallback: number) => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const optionalNumberFrom = (value: string | undefined) => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
};
return {
batchLimit: numberFrom(
get("--batch-limit") ?? get("--limit") ?? process.env.CODEX_SECURITY_SCAN_LIMIT,
DEFAULT_BATCH_LIMIT,
),
maxJobs: optionalNumberFrom(get("--max-jobs") ?? process.env.CODEX_SECURITY_SCAN_MAX_JOBS),
maxRuntimeMs:
numberFrom(
get("--max-runtime-minutes") ?? process.env.CODEX_SECURITY_SCAN_MAX_RUNTIME_MINUTES,
DEFAULT_MAX_RUNTIME_MS / 60_000,
) * 60_000,
leaseMs:
numberFrom(
get("--lease-minutes") ?? process.env.CODEX_SECURITY_SCAN_LEASE_MINUTES,
DEFAULT_LEASE_MS / 60_000,
) * 60_000,
diagnosticsRoot:
get("--diagnostics-dir") ??
process.env.CODEX_SECURITY_SCAN_DIAGNOSTICS_DIR ??
DEFAULT_DIAGNOSTICS_ROOT,
};
}
function requireEnv(name: string) {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is required`);
return value;
}
function safeDiagnosticPathSegment(value: string) {
return value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160) || "job";
}
function redactDiagnosticText(value: string, maxChars = MAX_DIAGNOSTIC_TEXT_CHARS) {
return redactWorkerPublicText(value, maxChars);
}
function redactDiagnosticError(value: string) {
return redactDiagnosticText(value).replace(
/(Codex result did not match ClawScan schema)(?::[\s\S]*)?/i,
"$1: [redacted result body]",
);
}
function sanitizeWorkerErrorMessage(value: string) {
return redactWorkerPublicErrorMessage(redactDiagnosticError(value));
}
const DIAGNOSTIC_CONTENT_TEXT_KEYS = new Set([
"codesnippet",
"content",
"detail",
"evidence",
"explanation",
"finding",
"findings",
"guidance",
"match",
"message",
"note",
"notes",
"output",
"rawresult",
"recommendation",
"result",
"snippet",
"stderr",
"stdout",
"summary",
"text",
"userimpact",
]);
const DIAGNOSTIC_PUBLIC_TEXT_PATHS = new Set([
"codexresult.verdict",
"codexstdout.item.id",
"codexstdout.item.type",
"codexstdout.status",
"codexstdout.type",
"llmanalysis.confidence",
"llmanalysis.status",
"llmanalysis.verdict",
"skillspectoranalysis.issues.*.issueid",
"skillspectoranalysis.issues.*.severity",
"skillspectoranalysis.recommendation",
"skillspectoranalysis.scannerversion",
"skillspectoranalysis.severity",
"skillspectoranalysis.status",
]);
const DIAGNOSTIC_PUBLIC_TEXT_VALUE_PATTERN = /^[A-Za-z0-9_.:@/-]{1,160}$/;
function normalizeDiagnosticKey(key: string) {
return key.replace(/[_-]/g, "").toLowerCase();
}
function diagnosticPathKey(path: string[]) {
return path.map((part) => (part === "*" ? part : normalizeDiagnosticKey(part))).join(".");
}
function isDiagnosticContentTextPath(path: string[]) {
const key = path.at(-1) ?? "";
return DIAGNOSTIC_CONTENT_TEXT_KEYS.has(normalizeDiagnosticKey(key));
}
function isDiagnosticSecretPath(path: string[]) {
const key = normalizeDiagnosticKey(path.at(-1) ?? "");
return /(apikey|authorization|credential|password|secret|token|webhook)/i.test(key);
}
function shouldPreserveDiagnosticText(path: string[], original: string, redacted: string) {
return (
original === redacted &&
DIAGNOSTIC_PUBLIC_TEXT_PATHS.has(diagnosticPathKey(path)) &&
DIAGNOSTIC_PUBLIC_TEXT_VALUE_PATTERN.test(redacted)
);
}
function redactDiagnosticValue(value: unknown, path: string[] = []): unknown {
if (isDiagnosticSecretPath(path)) return "[redacted-secret]";
if (typeof value === "string") {
const redacted = redactDiagnosticText(value, 2_000);
if (shouldPreserveDiagnosticText(path, value, redacted)) return redacted;
return `[redacted ${redacted.length} chars]`;
}
if (Array.isArray(value)) {
if (isDiagnosticContentTextPath(path)) return `[redacted ${value.length} item(s)]`;
return value.map((item) => redactDiagnosticValue(item, [...path, "*"]));
}
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([entryKey, entryValue]) => [
entryKey,
redactDiagnosticValue(entryValue, [...path, entryKey]),
]),
);
}
function redactStructuredDiagnosticText(value: string, rootKey: string) {
const trimmed = value.trim();
if (!trimmed) return "";
try {
return JSON.stringify(redactDiagnosticValue(JSON.parse(trimmed), [rootKey]), null, 2);
} catch {
// Codex --json writes JSONL. Redact parseable lines structurally, then fall back to text redaction.
const lines = value.split("\n");
if (lines.some((line) => line.trim().startsWith("{"))) {
return lines
.map((line) => {
if (!line.trim()) return line;
try {
return JSON.stringify(redactDiagnosticValue(JSON.parse(line), [rootKey]));
} catch {
return redactDiagnosticText(line, 2_000);
}
})
.join("\n");
}
return redactDiagnosticText(value);
}
}
function pickIdentity(record: unknown, fields: string[]) {
if (!record || typeof record !== "object") return undefined;
const source = record as Record<string, unknown>;
const picked: Record<string, unknown> = {};
for (const field of fields) {
if (source[field] !== undefined) picked[field] = source[field];
}
return Object.keys(picked).length > 0 ? picked : undefined;
}
function sanitizedTargetForDiagnostic(target: ClaimedJob["target"]) {
return {
skill: pickIdentity(target.skill, ["_id", "slug", "displayName", "name"]),
version: pickIdentity(target.version, ["_id", "version", "sha256hash"]),
package: pickIdentity(target.package, ["_id", "name", "normalizedName"]),
release: pickIdentity(target.release, ["_id", "version", "integritySha256"]),
files: target.files?.map(({ url: _url, ...file }) => ({
...file,
path: safeWorkerArtifactPathLabel(file.path),
})),
clawpackUrl: Boolean(target.clawpackUrl),
trustedOpenClawPlugin: target.trustedOpenClawPlugin,
};
}
function sanitizedJobForArtifactContext(job: ClaimedJob["job"]) {
const { leaseToken: _leaseToken, ...safeJob } = job;
return safeJob;
}
function sanitizedTargetForArtifactContext(target: ClaimedJob["target"]) {
const { clawpackUrl, files, job: _job, ...safeTarget } = target;
return {
...safeTarget,
files: files?.map(({ url: _url, ...file }) => file),
clawpackUrl: Boolean(clawpackUrl),
};
}
async function writeDiagnosticText(
jobDir: string,
fileName: string,
value: string | undefined,
rootKey: string,
) {
if (value === undefined) return undefined;
const redacted = redactStructuredDiagnosticText(value, rootKey);
await writeFile(join(jobDir, fileName), redacted.endsWith("\n") ? redacted : `${redacted}\n`);
return fileName;
}
export async function writeJobDiagnostic(input: JobDiagnosticInput) {
if (!input.diagnosticsRoot) return;
const jobDir = join(input.diagnosticsRoot, safeDiagnosticPathSegment(input.job.job._id));
await mkdir(jobDir, { recursive: true });
const stdoutPath = await writeDiagnosticText(
jobDir,
"codex.stdout.redacted.jsonl",
input.codex?.stdout,
"codexStdout",
);
const stderrPath = await writeDiagnosticText(
jobDir,
"codex.stderr.redacted.log",
input.codex?.stderr,
"codexStderr",
);
const rawResultPath = await writeDiagnosticText(
jobDir,
"codex-result.redacted.json",
input.codex?.rawResult,
"codexResult",
);
const skillSpectorStdoutPath = await writeDiagnosticText(
jobDir,
"skillspector.stdout.redacted.log",
input.skillSpector?.stdout,
"skillSpectorStdout",
);
const skillSpectorStderrPath = await writeDiagnosticText(
jobDir,
"skillspector.stderr.redacted.log",
input.skillSpector?.stderr,
"skillSpectorStderr",
);
const skillSpectorRawResultPath = await writeDiagnosticText(
jobDir,
"skillspector-result.redacted.json",
input.skillSpector?.rawResult,
"skillSpectorResult",
);
const diagnostic = {
completedAt: input.completedAt,
durationMs: input.completedAt - input.startedAt,
error: input.error ? redactDiagnosticError(input.error) : undefined,
job: {
attempts: input.job.job.attempts,
hasMaliciousSignal: input.job.job.hasMaliciousSignal,
id: input.job.job._id,
source: input.job.job.source,
targetKind: input.job.job.targetKind,
waitForVtUntil: input.job.job.waitForVtUntil,
},
llmAnalysis: redactDiagnosticValue(input.llmAnalysis, ["llmAnalysis"]),
runId: input.runId,
skillSpectorAnalysis: redactDiagnosticValue(input.skillSpectorAnalysis, [
"skillSpectorAnalysis",
]),
startedAt: input.startedAt,
status: input.status,
target: sanitizedTargetForDiagnostic(input.job.target),
codex: {
args: input.codex?.args,
exitCode: input.codex?.exitCode,
rawResultPath,
stderrPath,
stdoutPath,
},
skillSpector: {
args: input.skillSpector?.args,
exitCode: input.skillSpector?.exitCode,
rawResultPath: skillSpectorRawResultPath,
stderrPath: skillSpectorStderrPath,
stdoutPath: skillSpectorStdoutPath,
},
};
await writeFile(join(jobDir, "diagnostic.json"), `${JSON.stringify(diagnostic, null, 2)}\n`);
}
function safeOutputPath(workspace: string, artifactPath: string) {
const normalized = artifactPath.replace(/^\/+/, "");
const out = resolve(workspace, "artifact", normalized);
const artifactRoot = resolve(workspace, "artifact");
if (!out.startsWith(`${artifactRoot}/`) && out !== artifactRoot) {
throw new Error(`Unsafe artifact path: ${safeWorkerArtifactPathLabel(artifactPath)}`);
}
return out;
}
function artifactDownloadDescription(kind: "file" | "clawpack", artifactPath: string) {
const safePath = safeWorkerArtifactPathLabel(artifactPath);
return kind === "file" ? `artifact file ${safePath}` : `artifact tarball ${safePath}`;
}
async function download(url: string, artifact: { kind: "file" | "clawpack"; path: string }) {
maskGitHubActionsSecret(url);
const description = artifactDownloadDescription(artifact.kind, artifact.path);
let response: Response;
try {
response = await fetch(url);
} catch {
throw new Error(`Download failed for ${description}: network error`, {
cause: new Error("network error"),
});
}
if (!response.ok) throw new Error(`Download failed ${response.status} for ${description}`);
return Buffer.from(await response.arrayBuffer());
}
export async function writeArtifactWorkspace(job: ClaimedJob, workspace: string) {
await mkdir(join(workspace, "artifact"), { recursive: true });
const metadata = {
job: sanitizedJobForArtifactContext(job.job),
target: sanitizedTargetForArtifactContext(job.target),
policy: {
virusTotal: "telemetry-only; never final classifier; do not hide solely from VT",
maliciousSignalHold:
"if non-VT malicious signals held the artifact, Codex decides whether to release or hide",
openclawPluginTrust:
"plugins under @openclaw owned by the OpenClaw publisher are trusted unless artifact evidence proves malicious behavior",
},
};
await writeFile(join(workspace, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`);
for (const file of job.target.files ?? []) {
const out = safeOutputPath(workspace, file.path);
await mkdir(dirname(out), { recursive: true });
await writeFile(out, await download(file.url, { kind: "file", path: file.path }));
}
if (job.target.clawpackUrl) {
const tarballPath = join(workspace, "artifact.tgz");
await writeFile(
tarballPath,
await download(job.target.clawpackUrl, { kind: "clawpack", path: "artifact.tgz" }),
);
const listing = await runCommand("tar", ["-tzf", tarballPath], {
cwd: workspace,
timeoutMs: 60_000,
});
for (const entry of listing.stdout.split("\n").filter(Boolean)) {
if (entry.startsWith("/") || entry.split("/").includes("..")) {
throw new Error(`Unsafe tarball entry: ${entry}`);
}
}
const verboseListing = await runCommand("tar", ["-tvzf", tarballPath], {
cwd: workspace,
timeoutMs: 60_000,
});
if (verboseListing.stdout.split("\n").some((line) => /^[lh]/.test(line))) {
throw new Error("Refusing to extract tarball containing links");
}
await runCommand("tar", ["-xzf", tarballPath, "-C", join(workspace, "artifact")], {
cwd: workspace,
timeoutMs: 60_000,
});
}
}
function shouldReadArtifactSignalFile(path: string) {
const lower = path.toLowerCase();
if (lower.endsWith("/skill.md") || lower.endsWith("/package.json")) return true;
return ARTIFACT_SIGNAL_FILE_EXTENSIONS.has(lower.slice(lower.lastIndexOf(".")));
}
async function collectArtifactSignalText(dir: string, maxBytes = 1_000_000) {
let remaining = maxBytes;
const chunks: string[] = [];
async function visit(current: string) {
if (remaining <= 0) return;
for (const entry of await readdir(current, { withFileTypes: true })) {
if (remaining <= 0) return;
const path = join(current, entry.name);
if (entry.isDirectory()) {
await visit(path);
continue;
}
if (!entry.isFile() || !shouldReadArtifactSignalFile(path)) continue;
const bytes = await readFile(path);
const slice = bytes.subarray(0, Math.min(bytes.byteLength, remaining));
chunks.push(slice.toString("utf8"));
remaining -= slice.byteLength;
}
}
await visit(dir);
return chunks.join("\n");
}
async function fileExists(path: string) {
try {
await readFile(path);
return true;
} catch {
return false;
}
}
export async function resolveSkillSpectorScanInput(workspace: string) {
const extractedPackageRoot = join(workspace, "artifact", "package");
const hasClawPackExtraction =
(await fileExists(join(workspace, "artifact.tgz"))) &&
(await fileExists(join(extractedPackageRoot, "package.json")));
return hasClawPackExtraction ? "artifact/package" : "artifact";
}
async function runSkillSpector(
workspace: string,
onDiagnostic: (diagnostic: Partial<CodexCommandDiagnostic>) => void,
) {
const resultPath = join(workspace, "skillspector-report.json");
const scanInput = await resolveSkillSpectorScanInput(workspace);
const args = ["scan", scanInput, "--format", "json", "--output", resultPath];
onDiagnostic({ args });
try {
const output = await runCommand("skillspector", args, {
cwd: workspace,
timeoutMs: codexScanTimeoutMs(),
});
const raw = await readFile(resultPath, "utf8");
onDiagnostic({ exitCode: 0, rawResult: raw, stderr: output.stderr, stdout: output.stdout });
return normalizeSkillSpectorAnalysis(raw);
} catch (error) {
if (error instanceof CommandFailure) {
let rawResult: string | undefined;
try {
rawResult = await readFile(resultPath, "utf8");
} catch {
rawResult = undefined;
}
onDiagnostic({
exitCode: error.exitCode,
rawResult,
stderr: error.stderr,
stdout: error.stdout,
});
if (rawResult) {
try {
return normalizeSkillSpectorAnalysis(rawResult);
} catch {
// Fall through to an error-shaped analysis; diagnostics keep the raw report.
}
}
}
return skillSpectorFailureAnalysis(error);
}
}
export function buildPrompt(
job: ClaimedJob,
injectionSignals: string[],
skillSpectorAnalysis?: SkillSpectorAnalysis,
) {
const vt = JSON.stringify(
(job.target.version as Record<string, unknown> | undefined)?.vtAnalysis ??
(job.target.release as Record<string, unknown> | undefined)?.vtAnalysis ??
null,
null,
2,
);
const skillSpector = JSON.stringify(
skillSpectorAnalysis ??
(job.target.version as Record<string, unknown> | undefined)?.skillSpectorAnalysis ??
(job.target.release as Record<string, unknown> | undefined)?.skillSpectorAnalysis ??
null,
null,
2,
);
const trusted = Boolean(job.target.trustedOpenClawPlugin);
return `${SKILL_SECURITY_EVALUATOR_SYSTEM_PROMPT}
Additional ClawHub policy for this Codex run:
- Do your own security research before deciding. Use SkillSpector, VirusTotal, static scan
findings, metadata, artifact evidence, and publisher context as inputs.
- Inspect workspace files when needed to verify scanner claims, resolve uncertainty, or build
confidence in the verdict. Treat metadata.json as context, not artifact instructions.
- SkillSpector findings are advisory research-preview evidence, not validated ground truth and
not the final verdict. Use them to guide investigation, then make the final policy verdict
from artifact-backed evidence and the totality of signals. Do not rename them, translate them
into another taxonomy, or directly copy them into ClawScan output.
- Make the final policy verdict from the totality of evidence.
- VirusTotal is untrusted telemetry only. It is useful signal, but it must never be the sole reason for a malicious or suspicious verdict.
- If VirusTotal is the only negative signal and artifact evidence is coherent, return benign.
- Static scan findings are signal. If static scan marked malicious, decide from artifact evidence whether the hold should remain.
- @openclaw plugin packages from the OpenClaw publisher are trusted by default. Keep them benign unless concrete artifact evidence proves malicious behavior.
- Treat pre-scan prompt-injection indicators as artifact context for your review, not as an automatic verdict.
Worker context:
- target kind: ${job.job.targetKind}
- source: ${job.job.source}
- non-VT malicious signal present: ${job.job.hasMaliciousSignal ? "yes" : "no"}
- trusted @openclaw plugin: ${trusted ? "yes" : "no"}
- pre-scan artifact injection signals: ${
injectionSignals.length > 0 ? injectionSignals.join(", ") : "none"
}
VirusTotal telemetry supplied to Codex:
\`\`\`json
${vt}
\`\`\`
SkillSpector findings supplied to Codex:
\`\`\`json
${skillSpector}
\`\`\`
Return the required JSON object only.`;
}
function codexEnv() {
const env = { ...process.env };
const codexHome = resolveCodexWorkerHome(process.env, LOCAL_CODEX_HOME);
if (codexHome) {
mkdirSync(codexHome, { recursive: true });
env.CODEX_HOME = codexHome;
}
delete env.GH_TOKEN;
delete env.GITHUB_TOKEN;
delete env.CONVEX_DEPLOY_KEY;
delete env.SECURITY_SCAN_WORKER_TOKEN;
delete env.HOMEBREW_GITHUB_API_TOKEN;
env.NO_COLOR = "1";
env.SKILLSPECTOR_PROVIDER = env.SKILLSPECTOR_PROVIDER || "openai";
return env;
}
class CommandFailure extends Error {
exitCode: number | null;
stderr: string;
stdout: string;
constructor(message: string, exitCode: number | null, stdout: string, stderr: string) {
super(message);
this.name = "CommandFailure";
this.exitCode = exitCode;
this.stdout = stdout;
this.stderr = stderr;
}
}
async function runCommand(
command: string,
args: string[],
options: { cwd: string; input?: string; timeoutMs: number },
) {
return await new Promise<{ stdout: string; stderr: string }>((resolvePromise, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
env: codexEnv(),
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 10_000).unref();
}, options.timeoutMs);
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timeout);
if (code === 0) resolvePromise({ stdout, stderr });
else {
reject(
new CommandFailure(
`${command} ${timedOut ? "timed out" : `exited ${code}`}; see redacted stdout/stderr diagnostics`,
code,
stdout,
stderr,
),
);
}
});
if (options.input) child.stdin.end(options.input);
else child.stdin.end();
});
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function readField(record: Record<string, unknown>, names: string[]) {
for (const name of names) {
if (record[name] !== undefined && record[name] !== null) return record[name];
}
return undefined;
}
function readString(record: Record<string, unknown>, names: string[]) {
const value = readField(record, names);
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed || undefined;
}
if (typeof value === "number" || typeof value === "boolean") return String(value);
return undefined;
}
function readNumber(record: Record<string, unknown>, names: string[]) {
const value = readField(record, names);
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value.replace(/%$/, "").trim());
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function readNestedRecord(record: Record<string, unknown>, names: string[]) {
const value = readField(record, names);
return asRecord(value);
}
function readStringFromNested(
record: Record<string, unknown>,
nestedNames: string[],
fieldNames: string[],
) {
const nested = readNestedRecord(record, nestedNames);
return nested ? readString(nested, fieldNames) : undefined;
}
function readNumberFromNested(
record: Record<string, unknown>,
nestedNames: string[],
fieldNames: string[],
) {
const nested = readNestedRecord(record, nestedNames);
return nested ? readNumber(nested, fieldNames) : undefined;
}
function normalizeConfidence(value: number | undefined) {
if (value === undefined) return undefined;
if (value > 1) return Math.max(0, Math.min(1, value / 100));
return Math.max(0, Math.min(1, value));
}
function normalizeSkillSpectorIssue(input: unknown, index: number): SkillSpectorIssue | null {
const record = asRecord(input);
if (!record) return null;
const issueId =
readString(record, ["rule_id", "ruleId", "issue_id", "issueId", "id", "pattern_id"]) ??
`skillspector-${index + 1}`;
const pattern = readString(record, [
"pattern",
"rule_name",
"ruleName",
"name",
"title",
"message",
]);
const severity = (
readString(record, ["severity", "risk_severity", "level"]) ?? "UNKNOWN"
).toUpperCase();
const explanation =
readString(record, ["explanation", "message", "description", "reason", "details"]) ??
pattern ??
issueId;
const confidence = normalizeConfidence(readNumber(record, ["confidence", "score"]));
const file =
readString(record, ["file", "file_path", "filePath", "path"]) ??
readStringFromNested(record, ["location"], ["file", "path"]);
const startLine =
readNumber(record, ["line", "line_number", "lineNumber", "start_line", "startLine"]) ??
readNumberFromNested(record, ["location"], ["line", "start_line", "startLine"]);
const endLine =
readNumber(record, ["end_line", "endLine"]) ??
readNumberFromNested(record, ["location"], ["end_line", "endLine"]);
return {
issueId,
category: readString(record, ["category", "analyzer", "type"]),
pattern,
severity,
confidence,
file,
startLine,
endLine,
explanation,
remediation: readString(record, ["remediation", "recommendation", "fix", "mitigation"]),
finding: readString(record, ["finding", "match", "evidence"]),
codeSnippet: readString(record, ["code_snippet", "codeSnippet", "snippet"]),
};
}
function truncateStoredSkillSpectorText(
value: string | undefined,
maxChars = MAX_STORED_SKILLSPECTOR_TEXT_CHARS,
) {
if (value === undefined) return undefined;
if (value.length <= maxChars) return value;
return `${value.slice(0, maxChars)}\n...[truncated ${value.length - maxChars} chars]`;
}
function compactSkillSpectorIssue(issue: SkillSpectorIssue): SkillSpectorIssue {
return {
issueId:
truncateStoredSkillSpectorText(issue.issueId, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS) ??
"skillspector-issue",
category: truncateStoredSkillSpectorText(
issue.category,
MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS,
),
pattern: truncateStoredSkillSpectorText(
issue.pattern,
MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS,
),
severity:
truncateStoredSkillSpectorText(issue.severity, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS) ??
"UNKNOWN",
confidence: issue.confidence,
file: truncateStoredSkillSpectorText(issue.file, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS),
startLine: issue.startLine,
endLine: issue.endLine,
explanation:
truncateStoredSkillSpectorText(issue.explanation) ??
"SkillSpector reported this issue without additional explanation.",
remediation: truncateStoredSkillSpectorText(issue.remediation),
finding: truncateStoredSkillSpectorText(issue.finding),
codeSnippet: truncateStoredSkillSpectorText(issue.codeSnippet),
};
}
function normalizeSkillSpectorStatus(params: {
rawStatus?: string;
recommendation?: string;
score?: number;
issueCount: number;
}) {
const rawStatus = params.rawStatus?.trim().toLowerCase();
if (rawStatus) {
if (rawStatus === "benign" || rawStatus === "safe") return "clean";
if (["clean", "suspicious", "malicious", "error", "failed"].includes(rawStatus)) {
return rawStatus;
}
}
const recommendation = params.recommendation?.trim().toLowerCase() ?? "";
if (recommendation.includes("safe")) return "clean";
if (params.issueCount > 0) return "suspicious";
if (typeof params.score === "number" && params.score > 20) return "suspicious";
return "clean";
}
export function normalizeSkillSpectorAnalysis(
raw: string,
checkedAt = Date.now(),
): SkillSpectorAnalysis {
const parsed = JSON.parse(raw) as unknown;
const record = asRecord(parsed);
if (!record) {
return {
status: "error",
issueCount: 0,
issues: [],
error: "SkillSpector returned a non-object JSON report.",
checkedAt,
};
}
const rawIssues = readField(record, [
"filtered_findings",
"filteredFindings",
"findings",
"issues",
"vulnerabilities",
]);
const rawIssueList = Array.isArray(rawIssues) ? rawIssues : [];
const issues = rawIssueList
.slice(0, MAX_STORED_SKILLSPECTOR_ISSUES)
.map((issue, index) => normalizeSkillSpectorIssue(issue, index))
.filter((issue): issue is SkillSpectorIssue => Boolean(issue))
.map(compactSkillSpectorIssue);
const score =
readNumber(record, ["risk_score", "riskScore", "score"]) ??
readNumberFromNested(record, ["risk_assessment", "riskAssessment"], ["score"]);
const severity =
readString(record, ["risk_severity", "riskSeverity", "severity"]) ??
readStringFromNested(record, ["risk_assessment", "riskAssessment"], ["severity"]);
const recommendation =
readString(record, ["risk_recommendation", "riskRecommendation", "recommendation"]) ??
readStringFromNested(
record,
["risk_assessment", "riskAssessment"],
["recommendation", "risk_recommendation", "riskRecommendation"],
);
const issueCount =
readNumber(record, ["issue_count", "issueCount", "finding_count", "findingCount"]) ??
rawIssueList.length;
return {
status: normalizeSkillSpectorStatus({
rawStatus: readString(record, ["status"]),
recommendation,
score,
issueCount,
}),
score,
severity,
recommendation,
issueCount,
issues,
scannerVersion: truncateStoredSkillSpectorText(
readString(record, ["scanner_version", "scannerVersion", "version"]) ??
readStringFromNested(
record,
["metadata"],
["skillspector_version", "skillspectorVersion", "version"],
),
MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS,
),
summary: truncateStoredSkillSpectorText(readString(record, ["summary", "analysis"])),
checkedAt,
};
}
function skillSpectorFailureAnalysis(error: unknown, checkedAt = Date.now()): SkillSpectorAnalysis {
return {
status: "error",
issueCount: 0,
issues: [],
scannerVersion: "skillspector",
error: error instanceof Error ? error.message : String(error),
checkedAt,
};
}
function verdictToStatus(verdict: string) {
return verdict === "benign" ? "clean" : verdict;
}
function toStoredLlmAnalysis(parsed: NonNullable<ReturnType<typeof parseLlmEvalResponse>>) {
return {
status: verdictToStatus(parsed.verdict),
verdict: parsed.verdict,
confidence: parsed.confidence,
summary: parsed.summary,
dimensions: parsed.dimensions,
guidance: parsed.guidance,
findings: parsed.findings || undefined,
model: process.env.CODEX_SECURITY_SCAN_MODEL ?? "gpt-5.5",
checkedAt: Date.now(),
};
}
function codexScanTimeoutMs() {
const parsed = Number(process.env.CODEX_SECURITY_SCAN_TIMEOUT_MS);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CODEX_SCAN_TIMEOUT_MS;
}
async function runCodex(
job: ClaimedJob,
workspace: string,
skillSpectorAnalysis: SkillSpectorAnalysis,
onDiagnostic: (diagnostic: Partial<CodexCommandDiagnostic>) => void,
) {
const resultPath = join(workspace, "codex-result.json");
const args = [
"exec",
"--cd",
workspace,
"--model",
process.env.CODEX_SECURITY_SCAN_MODEL ?? "gpt-5.5",
"--sandbox",
"read-only",
"--skip-git-repo-check",
"--ignore-user-config",
"-c",
"approval_policy=never",
"-c",
`model_reasoning_effort=${process.env.CODEX_SECURITY_SCAN_REASONING_EFFORT ?? "high"}`,
"-c",
`service_tier=${process.env.CODEX_SECURITY_SCAN_SERVICE_TIER ?? "fast"}`,
"-c",
'shell_environment_policy.inherit="core"',
"-c",
"shell_environment_policy.ignore_default_excludes=false",
"--output-schema",
schemaPath,
"--output-last-message",
resultPath,
"--ephemeral",
"--json",
"-",
];
const artifactSignalText = await collectArtifactSignalText(join(workspace, "artifact"));
const injectionSignals = detectInjectionPatterns(artifactSignalText);
const prompt = buildPrompt(job, injectionSignals, skillSpectorAnalysis);
onDiagnostic({ args });
try {
const output = await runCommand("codex", args, {
cwd: workspace,
input: prompt,
timeoutMs: codexScanTimeoutMs(),
});
onDiagnostic({ exitCode: 0, stderr: output.stderr, stdout: output.stdout });
} catch (error) {
if (error instanceof CommandFailure) {
onDiagnostic({
exitCode: error.exitCode,
stderr: error.stderr,
stdout: error.stdout,
});
}
throw error;
}
const raw = await readFile(resultPath, "utf8");
onDiagnostic({ rawResult: raw });
const parsed = parseLlmEvalResponse(raw);
if (!parsed) {
throw new Error(`Codex result did not match ClawScan schema (${raw.length} chars)`);
}
return toStoredLlmAnalysis(parsed);
}
export async function processJob(
client: CodexScanWorkerClient,
token: string,
job: ClaimedJob,
diagnosticsRoot: string | undefined,
): Promise<boolean> {
const workspace = await mkdtemp(join(tmpdir(), `clawhub-codex-scan-${basename(job.job._id)}-`));
const startedAt = Date.now();
const codex: CodexCommandDiagnostic = {};
const skillSpector: CodexCommandDiagnostic = {};
let errorMessage: string | undefined;
let llmAnalysis: StoredLlmAnalysis | undefined;
let skillSpectorAnalysis: SkillSpectorAnalysis | undefined;
let status: JobDiagnosticInput["status"] = "failed";
try {
await writeArtifactWorkspace(job, workspace);
skillSpectorAnalysis = await runSkillSpector(workspace, (next) => {
Object.assign(skillSpector, next);
});
llmAnalysis = await runCodex(job, workspace, skillSpectorAnalysis, (next) => {
Object.assign(codex, next);
});
await client.action(api.securityScan.completeCodexScanJob, {
token,
jobId: job.job._id as Id<"securityScanJobs">,
leaseToken: job.job.leaseToken,
llmAnalysis,
skillSpectorAnalysis,
runId: process.env.GITHUB_RUN_ID,
});
status = "completed";
logger.info(
{
durationMs: Date.now() - startedAt,
event: "security_scan_job_completed",
jobId: job.job._id,
scannerPhase: "complete",
status: llmAnalysis.status,
targetKind: job.job.targetKind,
},
"security scan job completed",
);
return true;
} catch (error) {
errorMessage = sanitizeWorkerErrorMessage(
error instanceof Error ? error.message : String(error),
);
const failResult = (await client.action(api.securityScan.failCodexScanJob, {
token,
jobId: job.job._id as Id<"securityScanJobs">,
leaseToken: job.job.leaseToken,
error: errorMessage,
})) as { retry?: boolean } | undefined;
logger.error(
{
durationMs: Date.now() - startedAt,
event: "security_scan_job_failed",
jobId: job.job._id,
publicReason: errorMessage,
retry: Boolean(failResult?.retry),
scannerPhase: "process",
targetKind: job.job.targetKind,
},
"security scan job failed",
);
return false;
} finally {
try {
await writeJobDiagnostic({
codex,
completedAt: Date.now(),
diagnosticsRoot,
error: errorMessage,
job,
llmAnalysis,
runId: process.env.GITHUB_RUN_ID,
skillSpector,
skillSpectorAnalysis,
startedAt,
status,
});
} catch (diagnosticError) {
const message =
diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError);
logger.error(
{
event: "security_scan_diagnostic_write_failed",
jobId: job.job._id,
publicReason: sanitizeWorkerErrorMessage(message),
},
"security scan diagnostic write failed",
);
}
await rm(workspace, { recursive: true, force: true });
}
}
export async function claimCodexScanJobBatch(
claimLimit: number,
claimOne: () => Promise<ClaimedJob[]>,
) {
const results = await Promise.allSettled(Array.from({ length: claimLimit }, () => claimOne()));
let claimFailures = 0;
const jobs = results.flatMap((result) => {
if (result.status === "fulfilled") return result.value;
claimFailures += 1;
const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
logger.error(
{
event: "security_scan_claim_failed",
publicReason: sanitizeWorkerErrorMessage(message),
scannerPhase: "claim",
},
"failed to claim security scan job",
);
return [];
});
return { claimFailures, jobs };
}
async function main() {
const { batchLimit, maxJobs, maxRuntimeMs, leaseMs, diagnosticsRoot } = parseArgs();
assertCodexWorkerExecutionAllowed(process.env);
maskKnownWorkerSecrets();
const convexUrl = process.env.CONVEX_URL ?? process.env.VITE_CONVEX_URL;
if (!convexUrl) throw new Error("CONVEX_URL or VITE_CONVEX_URL is required");
const token = requireEnv("SECURITY_SCAN_WORKER_TOKEN");
const client = new ConvexHttpClient(convexUrl);
const workerId =
process.env.CODEX_SECURITY_SCAN_WORKER_ID ??
`github-actions:${process.env.GITHUB_RUN_ID ?? process.pid}:${
process.env.GITHUB_RUN_ATTEMPT ?? "1"
}:${process.env.CODEX_SECURITY_SCAN_SHARD ?? process.env.GITHUB_JOB ?? "0"}`;
const startedAt = Date.now();
const claimDeadline = startedAt + maxRuntimeMs;
let totalClaimed = 0;
let totalCompleted = 0;
let totalFailed = 0;
logger.info(
{ diagnosticsRoot, event: "security_scan_diagnostics_directory", workerId },
"security scan diagnostics directory",
);
while (Date.now() < claimDeadline) {
const remainingJobs = maxJobs === undefined ? batchLimit : Math.max(0, maxJobs - totalClaimed);
if (remainingJobs === 0) break;
const claimLimit = Math.min(batchLimit, remainingJobs);
const claimBatch = await claimCodexScanJobBatch(
claimLimit,
async () =>
(await client.action(api.securityScan.claimCodexScanJobs, {
token,
workerId,
limit: 1,
leaseMs,
})) as ClaimedJob[],
);
const { claimFailures, jobs } = claimBatch;
totalFailed += claimFailures;
logger.info(
{
claimed: jobs.length,
claimFailures,
claimLimit,
event: "security_scan_jobs_claimed",
leaseMs,
workerId,
},
"claimed security scan jobs",
);
if (jobs.length === 0) break;
totalClaimed += jobs.length;
const results = await Promise.all(
jobs.map((job) => processJob(client, token, job, diagnosticsRoot)),
);
totalCompleted += results.filter(Boolean).length;
totalFailed += results.filter((ok) => !ok).length;
if (jobs.length < claimLimit) break;
}
logger.info(
{
elapsedMs: Date.now() - startedAt,
event: "security_scan_worker_summary",
totalClaimed,
totalCompleted,
totalFailed,
workerId,
},
"security scan worker summary",
);
if (totalFailed > 0) {
process.exitCode = 1;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
await main();
}