Files
clawhub/scripts/security/run-codex-scan-worker.ts
T
Vincent Koc b15bd52506 fix(security): prevent package scans from exhausting worker memory (#3393)
* fix(security): scope SkillSpector to bundled roots

* fix(security): bound bundled SkillSpector scans

* chore(security): format SkillSpector worker changes

* fix(security): import path separator for scan roots
2026-08-04 14:53:24 +08:00

2174 lines
71 KiB
TypeScript

import { spawn } from "node:child_process";
import { mkdirSync, readFileSync } from "node:fs";
import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve, sep } 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 { parseLlmEvalResponse, type LlmEvalDimension } from "../../convex/lib/securityPrompt";
import { assertCodexWorkerExecutionAllowed, resolveCodexWorkerHome } from "../codex-worker-guard";
import { materializeVerifiedArtifactFiles } from "../lib/artifactMaterialization";
import { createWorkerLogger } from "../lib/workerLogger";
import {
maskGitHubActionsSecret,
maskKnownWorkerSecrets,
redactWorkerPublicErrorMessage,
redactWorkerPublicText,
safeWorkerArtifactPathLabel,
} from "../lib/workerRedaction";
import {
calculateSecurityScanWorkerHealthSummary,
renderSecurityScanWorkerSummaryMarkdown,
type SecurityScanJobHealth,
type SecurityScanQueueHealth,
} from "./security-scan-worker-summary";
export 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;
release?: Record<string, unknown> & {
artifactKind?: "legacy-zip" | "npm-pack";
};
};
};
type ClaimedJobLease = ClaimedJob["job"];
export 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;
};
export type SkillSpectorAnalysis = {
status: string;
score?: number;
severity?: string;
recommendation?: string;
issueCount: number;
issues: SkillSpectorIssue[];
scannerVersion?: string;
summary?: string;
error?: string;
checkedAt: number;
};
type SkillSpectorScannerResult = {
applicable: boolean;
error?: string;
filtered_findings: SkillSpectorIssue[];
issue_count: number;
metadata?: {
skillspector_version: string;
};
risk_assessment: {
recommendation?: string;
score?: number;
severity?: string;
};
status: string;
summary?: string;
};
type BundledSkillSpectorScanInput = {
rootPath: string;
scanPath: string;
};
type ClawScanCommandDiagnostic = {
args?: string[];
artifactPath?: string;
exitCode?: number | null;
rawArtifact?: string;
stderr?: string;
stdout?: string;
timedOut?: boolean;
mapping?: {
judge?: {
outputSchemaSha256?: string;
promptSha256?: string;
status?: string;
verdict?: string;
};
scanners?: {
skillspectorStatus?: string;
staticStatus?: string;
};
};
};
type JobDiagnosticInput = {
clawscan?: ClawScanCommandDiagnostic;
completedAt: number;
diagnosticsRoot?: string;
error?: string;
job: ClaimedJob;
llmAnalysis?: unknown;
runId?: string;
skillSpectorAnalysis?: unknown;
startedAt: number;
status: "completed" | "failed";
};
type CodexScanWorkerClient = Pick<ConvexHttpClient, "action">;
type ProcessJobResult = {
completed: boolean;
hardFailed: boolean;
retryableFailed: boolean;
};
const DEFAULT_BATCH_LIMIT = 4;
const DEFAULT_MAX_RUNTIME_MS = 40 * 60 * 1000;
const DEFAULT_CLAWSCAN_TIMEOUT_MS = 20 * 60 * 1000;
const REQUIRED_CLAWHUB_SCANNERS = ["clawscan-static", "skillspector"];
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");
type ClawHubOutputSchemaContract = {
allowedConfidence: Set<string>;
allowedDimensionStatus: Set<string>;
allowedVerdict: Set<string>;
requiredDimensionFieldKeys: string[];
requiredDimensionKeys: string[];
requiredFindingKeys: string[];
requiredResultKeys: string[];
};
function readSchemaStringArray(value: unknown, context: string) {
if (!Array.isArray(value) || value.length === 0) {
throw new Error(`${context} was missing`);
}
const values = value
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean);
if (values.length !== value.length) {
throw new Error(`${context} must be a string array`);
}
return values;
}
function loadClawHubOutputSchemaContract(path: string): ClawHubOutputSchemaContract {
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
throw new Error(`Failed to parse output schema at ${path}`, { cause: error });
}
const schema = asRecord(parsed);
const properties = asRecord(schema?.properties);
const dimensions = asRecord(properties?.dimensions);
const dimensionProperties = asRecord(dimensions?.properties);
const requiredDimensionKeys = readSchemaStringArray(
dimensions?.required,
"Output schema dimensions.required",
);
const firstDimension = asRecord(dimensionProperties?.[requiredDimensionKeys[0] ?? ""]);
const firstDimensionProperties = asRecord(firstDimension?.properties);
const dimensionStatus = asRecord(firstDimensionProperties?.status);
const findings = asRecord(properties?.scan_findings_in_context);
const findingItems = asRecord(findings?.items);
const verdictSchema = asRecord(properties?.verdict);
const confidenceSchema = asRecord(properties?.confidence);
return {
allowedConfidence: new Set(
readSchemaStringArray(confidenceSchema?.enum, "Output schema confidence enum"),
),
allowedDimensionStatus: new Set(
readSchemaStringArray(dimensionStatus?.enum, "Output schema dimension status enum"),
),
allowedVerdict: new Set(
readSchemaStringArray(verdictSchema?.enum, "Output schema verdict enum"),
),
requiredDimensionFieldKeys: readSchemaStringArray(
firstDimension?.required,
"Output schema dimension required fields",
),
requiredDimensionKeys,
requiredFindingKeys: readSchemaStringArray(
findingItems?.required,
"Output schema finding required fields",
),
requiredResultKeys: readSchemaStringArray(schema?.required, "Output schema required fields"),
};
}
const CLAWHUB_OUTPUT_SCHEMA_CONTRACT = loadClawHubOutputSchemaContract(schemaPath);
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;
};
const laneValue = get("--lane") ?? process.env.CODEX_SECURITY_SCAN_LANE;
const lane: "priority" | "shared" | "catalog" =
laneValue === "priority" || laneValue === "catalog" ? laneValue : "shared";
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,
lane,
diagnosticsRoot:
get("--diagnostics-dir") ??
process.env.CODEX_SECURITY_SCAN_DIAGNOSTICS_DIR ??
DEFAULT_DIAGNOSTICS_ROOT,
};
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
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 redactDiagnosticTextUncapped(value: string) {
return redactDiagnosticText(value, Number.POSITIVE_INFINITY);
}
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));
}
function redactEvidenceJsonValue(value: unknown, path: string[] = []): unknown {
if (isDiagnosticSecretPath(path)) return "[redacted-secret]";
if (typeof value === "string") return redactDiagnosticTextUncapped(value);
if (Array.isArray(value)) {
return value.map((entry) => redactEvidenceJsonValue(entry, [...path, "*"]));
}
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([entryKey, entryValue]) => [
entryKey,
redactEvidenceJsonValue(entryValue, [...path, entryKey]),
]),
);
}
function redactEvidenceText(value: string, rootPath: string[]) {
try {
return JSON.stringify(redactEvidenceJsonValue(JSON.parse(value), rootPath), null, 2);
} catch {
return redactDiagnosticTextUncapped(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([
"clawscanartifact.profile",
"clawscanartifact.schemaversion",
"clawscanartifact.judge.status",
"clawscanartifact.judge.promptpath",
"clawscanartifact.judge.outputschemapath",
"clawscanartifact.judge.promptsha",
"clawscanartifact.judge.outputschemasha",
"clawscanartifact.judge.result.verdict",
"clawscanartifact.judge.result.confidence",
"clawscanartifact.judge.result.summary",
"clawscanartifact.scanners.*.status",
"clawscanartifact.scanners.*.outputpath",
"clawscanmapping.judge.status",
"clawscanmapping.judge.verdict",
"clawscanmapping.judge.promptsha256",
"clawscanmapping.judge.outputschemasha256",
"clawscanmapping.scanners.skillspectorstatus",
"clawscanmapping.scanners.staticstatus",
"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) {
const key = diagnosticPathKey(path);
return (
original === redacted &&
(DIAGNOSTIC_PUBLIC_TEXT_PATHS.has(key) || key.startsWith("clawscanartifact.env.")) &&
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 redactCompleteDiagnosticText(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. Preserve every line while applying the same secret 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 redactDiagnosticTextUncapped(line);
}
})
.join("\n");
}
return redactDiagnosticTextUncapped(value);
}
}
function normalizedScannerOutputPath(value: string) {
const normalized = value
.trim()
.replaceAll("\\", "/")
.replace(/^\.\/+/, "")
.replace(/\/+$/, "");
if (
!normalized ||
normalized.startsWith("/") ||
normalized.split("/").some((segment) => segment === "..") ||
!/^[A-Za-z0-9._/-]+$/.test(normalized)
) {
return undefined;
}
return normalized;
}
type ClawScanScannerOutputReference = {
scanner: string;
outputPath: string;
};
function clawScanScannerOutputReferences(rawArtifact: string): ClawScanScannerOutputReference[] {
let parsed: unknown;
try {
parsed = JSON.parse(rawArtifact);
} catch {
return [];
}
const artifact = asRecord(parsed);
const scanners = asRecord(artifact?.scanners);
if (!scanners) return [];
const references: ClawScanScannerOutputReference[] = [];
for (const [scanner, value] of Object.entries(scanners)) {
const scannerRecord = asRecord(value);
const outputPath =
readString(scannerRecord ?? {}, ["outputPath", "output_path", "outputpath"]) ?? undefined;
if (outputPath) {
references.push({ scanner, outputPath });
}
}
return references;
}
function safeScannerOutputSourcePath(artifactPath: string, relativeOutputPath: string) {
const artifactDir = resolve(dirname(artifactPath));
const source = resolve(artifactDir, relativeOutputPath);
if (!source.startsWith(`${artifactDir}/`) && source !== artifactDir) {
throw new Error(
`Unsafe scanner output path: ${safeWorkerArtifactPathLabel(relativeOutputPath)}`,
);
}
return source;
}
type CopiedScannerOutputDiagnostic = {
scanner: string;
outputPath: string;
diagnosticPath?: string;
status: "copied" | "missing" | "skipped";
};
async function copyClawScanScannerOutputs(input: {
artifactPath: string;
rawArtifact: string;
jobDir: string;
}): Promise<CopiedScannerOutputDiagnostic[]> {
const references = clawScanScannerOutputReferences(input.rawArtifact);
const copied: CopiedScannerOutputDiagnostic[] = [];
for (const reference of references) {
const normalizedOutputPath = normalizedScannerOutputPath(reference.outputPath);
if (!normalizedOutputPath) {
copied.push({
scanner: reference.scanner,
outputPath: safeWorkerArtifactPathLabel(reference.outputPath),
status: "skipped",
});
continue;
}
const sourcePath = safeScannerOutputSourcePath(input.artifactPath, normalizedOutputPath);
if (!(await fileExists(sourcePath))) {
copied.push({
scanner: reference.scanner,
outputPath: normalizedOutputPath,
status: "missing",
});
continue;
}
const diagnosticPath = join("clawscan-scanner-outputs", normalizedOutputPath);
const destinationPath = join(input.jobDir, diagnosticPath);
await mkdir(dirname(destinationPath), { recursive: true });
const scannerOutput = await readFile(sourcePath, "utf8");
const redactedOutput = redactEvidenceText(scannerOutput, [
"clawscanScannerOutput",
reference.scanner,
]);
await writeFile(
destinationPath,
redactedOutput.endsWith("\n") ? redactedOutput : `${redactedOutput}\n`,
);
copied.push({
diagnosticPath,
outputPath: normalizedOutputPath,
scanner: reference.scanner,
status: "copied",
});
}
return copied;
}
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,
options?: {
maxChars?: number;
preRedacted?: boolean;
structured?: boolean;
},
) {
if (value === undefined) return undefined;
const redacted =
options?.preRedacted === true
? value
: options?.structured === false
? redactDiagnosticText(value, options.maxChars)
: redactCompleteDiagnosticText(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 clawscanStdoutPath = await writeDiagnosticText(
jobDir,
"clawscan.stdout.redacted.log",
input.clawscan?.stdout,
"clawscanStdout",
);
const clawscanStderrPath = await writeDiagnosticText(
jobDir,
"clawscan.stderr.redacted.log",
input.clawscan?.stderr,
"clawscanStderr",
);
const clawscanArtifactPath = await writeDiagnosticText(
jobDir,
"clawscan-artifact.redacted.json",
input.clawscan?.rawArtifact
? redactEvidenceText(input.clawscan.rawArtifact, ["clawscanArtifact"])
: undefined,
"clawscanArtifact",
{
maxChars: Number.POSITIVE_INFINITY,
preRedacted: true,
structured: false,
},
);
const clawscanScannerOutputs =
input.clawscan?.rawArtifact && input.clawscan?.artifactPath
? await copyClawScanScannerOutputs({
artifactPath: input.clawscan.artifactPath,
jobDir,
rawArtifact: input.clawscan.rawArtifact,
})
: [];
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,
clawscan: input.clawscan
? {
...(asRecord(redactDiagnosticValue(input.clawscan, ["clawscan"])) ?? {}),
mapping: input.clawscan.mapping
? redactDiagnosticValue(input.clawscan.mapping, ["clawscanMapping"])
: undefined,
}
: undefined,
skillSpectorAnalysis: redactDiagnosticValue(input.skillSpectorAnalysis, [
"skillSpectorAnalysis",
]),
startedAt: input.startedAt,
status: input.status,
target: sanitizedTargetForDiagnostic(input.job.target),
clawscanResult: {
args: input.clawscan?.args,
exitCode: input.clawscan?.exitCode,
mapping: input.clawscan?.mapping
? redactDiagnosticValue(input.clawscan.mapping, ["clawscanMapping"])
: undefined,
rawArtifactPath: clawscanArtifactPath,
scannerOutputFiles: clawscanScannerOutputs,
stderrPath: clawscanStderrPath,
stdoutPath: clawscanStdoutPath,
},
};
await writeFile(join(jobDir, "diagnostic.json"), `${JSON.stringify(diagnostic, null, 2)}\n`);
}
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`);
await materializeVerifiedArtifactFiles({
artifactRoot: join(workspace, "artifact"),
files: job.target.files ?? [],
download: async (file) => await download(file.url, { kind: "file", path: file.path }),
});
// Legacy ZIP releases are already materialized above as individually verified files.
// Their archival copy is not an npm tarball and makes GNU tar fail before scanning.
const clawpackUrl =
job.target.release?.artifactKind === "legacy-zip" ? null : job.target.clawpackUrl;
if (clawpackUrl) {
const tarballPath = join(workspace, "artifact.tgz");
await writeFile(
tarballPath,
await download(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,
});
}
}
async function fileExists(path: string) {
try {
await readFile(path);
return true;
} catch {
return false;
}
}
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;
timedOut: boolean;
constructor(
message: string,
exitCode: number | null,
stdout: string,
stderr: string,
timedOut: boolean,
) {
super(message);
this.name = "CommandFailure";
this.exitCode = exitCode;
this.stdout = stdout;
this.stderr = stderr;
this.timedOut = timedOut;
}
}
async function runCommand(
command: string,
args: string[],
options: { cwd: string; input?: string; omitEnv?: string[]; timeoutMs: number },
) {
return await new Promise<{ stdout: string; stderr: string }>((resolvePromise, reject) => {
const env = codexEnv();
for (const name of options.omitEnv ?? []) delete env[name];
const child = spawn(command, args, {
cwd: options.cwd,
detached: process.platform !== "win32",
env,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
let forceKillTimeout: NodeJS.Timeout | undefined;
const killProcessTree = (signal: NodeJS.Signals) => {
if (process.platform !== "win32" && child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// The process group may already have exited; fall back to the direct child.
}
}
child.kill(signal);
};
const timeout = setTimeout(() => {
timedOut = true;
killProcessTree("SIGTERM");
forceKillTimeout = setTimeout(() => killProcessTree("SIGKILL"), 10_000);
forceKillTimeout.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);
if (timedOut) killProcessTree("SIGKILL");
if (forceKillTimeout) clearTimeout(forceKillTimeout);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timeout);
if (timedOut) killProcessTree("SIGKILL");
if (forceKillTimeout) clearTimeout(forceKillTimeout);
if (code === 0) resolvePromise({ stdout, stderr });
else {
reject(
new CommandFailure(
`${command} ${timedOut ? "timed out" : `exited ${code}`}; see redacted stdout/stderr diagnostics`,
code,
stdout,
stderr,
timedOut,
),
);
}
});
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 verdictToStatus(verdict: string) {
return verdict === "benign" ? "clean" : verdict;
}
function toStoredLlmAnalysis(
parsed: NonNullable<ReturnType<typeof parseLlmEvalResponse>>,
checkedAt = Date.now(),
) {
return {
status: verdictToStatus(parsed.verdict),
verdict: parsed.verdict,
confidence: parsed.confidence,
summary: parsed.summary,
dimensions: parsed.dimensions,
guidance: parsed.guidance,
findings: parsed.findings || undefined,
checkedAt,
};
}
function clawScanTimeoutMs() {
const parsed = Number(process.env.CODEX_SECURITY_SCAN_CLAWSCAN_TIMEOUT_MS);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CLAWSCAN_TIMEOUT_MS;
}
function normalizedBundledSkillRoot(value: unknown) {
if (typeof value !== "string") return null;
const normalized = value
.trim()
.replaceAll("\\", "/")
.replace(/^\.\/+/, "")
.replace(/\/+$/, "");
if (
!normalized ||
normalized === "." ||
normalized.startsWith("/") ||
normalized.split("/").some((segment) => segment === "..")
) {
return null;
}
return normalized;
}
function bundledSkillRootsForJob(job: ClaimedJob) {
if (job.job.targetKind !== "packageRelease") return [];
const release = asRecord(job.target.release);
const pluginManifestSummary = asRecord(release?.pluginManifestSummary);
const bundledSkills = pluginManifestSummary?.bundledSkills;
if (!Array.isArray(bundledSkills)) return [];
return [
...new Set(
bundledSkills
.map((skill) => normalizedBundledSkillRoot(asRecord(skill)?.rootPath))
.filter((rootPath): rootPath is string => Boolean(rootPath)),
),
].sort();
}
export async function resolveBundledSkillSpectorScanInputs(workspace: string, job: ClaimedJob) {
if (job.job.targetKind !== "packageRelease") return [];
const packageRoot = await resolveClawScanTarget(workspace, job);
const artifactRoot = resolve(workspace, packageRoot);
return bundledSkillRootsForJob(job)
.map((rootPath) => {
const skillRoot = resolve(artifactRoot, rootPath);
return skillRoot.startsWith(`${artifactRoot}${sep}`)
? { rootPath, scanPath: join(packageRoot, rootPath) }
: null;
})
.filter((input): input is BundledSkillSpectorScanInput => Boolean(input));
}
function packageRelativeSkillSpectorIssueFile(rootPath: string, file: string) {
const normalized = file
.trim()
.replaceAll("\\", "/")
.replace(/^\.\/+/, "");
if (normalized === rootPath || normalized.startsWith(`${rootPath}/`)) return normalized;
const rootedSuffix = `/${rootPath}/`;
const rootedIndex = normalized.lastIndexOf(rootedSuffix);
if (rootedIndex >= 0) return normalized.slice(rootedIndex + 1);
const relative = normalized
.split("/")
.filter((segment) => segment && segment !== "." && segment !== "..")
.join("/");
return relative ? `${rootPath}/${relative}` : rootPath;
}
function prefixSkillSpectorFindingPaths(
analysis: SkillSpectorAnalysis,
rootPath: string,
): SkillSpectorAnalysis {
return {
...analysis,
issues: analysis.issues.map((issue) =>
issue.file
? {
...issue,
file: packageRelativeSkillSpectorIssueFile(rootPath, issue.file),
}
: issue,
),
};
}
function compareSkillSpectorIssues(left: SkillSpectorIssue, right: SkillSpectorIssue) {
return JSON.stringify(left).localeCompare(JSON.stringify(right));
}
export function aggregateSkillSpectorAnalyses(
analyses: SkillSpectorAnalysis[],
): SkillSpectorScannerResult {
if (analyses.length === 0) {
return {
applicable: false,
status: "clean",
risk_assessment: {
score: 0,
severity: "NONE",
recommendation: "NOT_APPLICABLE",
},
issue_count: 0,
filtered_findings: [],
metadata: { skillspector_version: "skillspector" },
summary: "Package declares no bundled skills; SkillSpector was not applicable.",
};
}
if (analyses.length === 1) {
const [analysis] = analyses;
return {
applicable: true,
status: analysis.status,
risk_assessment: {
score: analysis.score,
severity: analysis.severity,
recommendation: analysis.recommendation,
},
issue_count: analysis.issueCount,
filtered_findings: analysis.issues,
metadata: analysis.scannerVersion
? { skillspector_version: analysis.scannerVersion }
: undefined,
summary: analysis.summary,
error: analysis.error,
};
}
const statuses = analyses.map((analysis) => analysis.status);
const status = statuses.some((value) => value === "error" || value === "failed")
? "error"
: statuses.includes("malicious")
? "malicious"
: statuses.includes("suspicious")
? "suspicious"
: "clean";
const severityRank = ["NONE", "UNKNOWN", "LOW", "MEDIUM", "HIGH", "CRITICAL"];
const severity = analyses
.map((analysis) => analysis.severity?.toUpperCase())
.filter((value): value is string => Boolean(value))
.sort(
(left, right) =>
severityRank.indexOf(right) - severityRank.indexOf(left) || left.localeCompare(right),
)[0];
const recommendations = [
...new Set(analyses.map((analysis) => analysis.recommendation).filter(Boolean)),
].sort();
const scannerVersions = [
...new Set(analyses.map((analysis) => analysis.scannerVersion).filter(Boolean)),
].sort();
const summaries = analyses
.map((analysis) => analysis.summary)
.filter((summary): summary is string => Boolean(summary))
.sort();
const errors = analyses
.map((analysis) => analysis.error)
.filter((error): error is string => Boolean(error))
.sort();
return {
applicable: true,
status,
risk_assessment: {
score: Math.max(...analyses.map((analysis) => analysis.score ?? 0)),
severity,
recommendation: recommendations.length > 0 ? recommendations.join("; ") : undefined,
},
issue_count: analyses.reduce((total, analysis) => total + analysis.issueCount, 0),
filtered_findings: analyses
.flatMap((analysis) => analysis.issues)
.sort(compareSkillSpectorIssues)
.slice(0, MAX_STORED_SKILLSPECTOR_ISSUES),
metadata:
scannerVersions.length > 0 ? { skillspector_version: scannerVersions.join(", ") } : undefined,
summary:
summaries.length > 0
? `Scanned ${analyses.length} bundled skills. ${summaries.join(" ")}`
: `Scanned ${analyses.length} bundled skills.`,
error: errors.length > 0 ? errors.join("; ") : undefined,
};
}
async function runBundledSkillSpector(
workspace: string,
scanInputs: BundledSkillSpectorScanInput[],
) {
const analyses: SkillSpectorAnalysis[] = [];
const deadlineMs = Date.now() + clawScanTimeoutMs();
for (const [index, scanInput] of scanInputs.entries()) {
const remainingMs = deadlineMs - Date.now();
if (remainingMs <= 0) {
throw new Error("SkillSpector bundled-skill scan deadline exceeded");
}
const resultPath = join(workspace, `skillspector-report-${index}.json`);
const args = ["scan", scanInput.scanPath, "--format", "json", "--output", resultPath];
try {
await runCommand("skillspector", args, {
cwd: workspace,
timeoutMs: remainingMs,
});
analyses.push(
prefixSkillSpectorFindingPaths(
normalizeSkillSpectorAnalysis(await readFile(resultPath, "utf8"), 0),
scanInput.rootPath,
),
);
} catch (error) {
if (error instanceof CommandFailure) {
const rawResult = await readFile(resultPath, "utf8").catch(() => undefined);
if (rawResult) {
const analysis = prefixSkillSpectorFindingPaths(
normalizeSkillSpectorAnalysis(rawResult, 0),
scanInput.rootPath,
);
const validFindingsExit =
error.exitCode === 1 &&
(analysis.status === "suspicious" || analysis.status === "malicious") &&
analysis.issueCount > 0 &&
analysis.issues.length > 0;
if (validFindingsExit) {
analyses.push(analysis);
continue;
}
}
}
throw error;
}
}
return aggregateSkillSpectorAnalyses(analyses);
}
const REQUIRED_CLAWHUB_RESULT_KEYS = [
...CLAWHUB_OUTPUT_SCHEMA_CONTRACT.requiredResultKeys,
"artifact_inspection",
];
const REQUIRED_CLAWHUB_DIMENSION_KEYS = CLAWHUB_OUTPUT_SCHEMA_CONTRACT.requiredDimensionKeys;
const REQUIRED_CLAWHUB_DIMENSION_FIELD_KEYS =
CLAWHUB_OUTPUT_SCHEMA_CONTRACT.requiredDimensionFieldKeys;
const REQUIRED_CLAWHUB_FINDING_KEYS = CLAWHUB_OUTPUT_SCHEMA_CONTRACT.requiredFindingKeys;
const REQUIRED_CLAWHUB_ARTIFACT_INSPECTION_KEYS = [
"status",
"challenge",
"required_file_sha256",
"files_inspected",
];
const ALLOWED_CLAWHUB_DIMENSION_STATUSES = CLAWHUB_OUTPUT_SCHEMA_CONTRACT.allowedDimensionStatus;
function assertExactObjectKeys(
record: Record<string, unknown>,
expectedKeys: readonly string[],
context: string,
) {
const unexpected = Object.keys(record).filter((key) => !expectedKeys.includes(key));
if (unexpected.length > 0) {
throw new Error(`${context} included unexpected field(s): ${unexpected.join(", ")}`);
}
const missing = expectedKeys.filter((key) => record[key] === undefined);
if (missing.length > 0) {
throw new Error(`${context} missing required field(s): ${missing.join(", ")}`);
}
}
function validateClawScanJudgeResultShape(result: Record<string, unknown>) {
assertExactObjectKeys(result, REQUIRED_CLAWHUB_RESULT_KEYS, "ClawScan judge result");
const verdict = readString(result, ["verdict"]);
if (!verdict || !CLAWHUB_OUTPUT_SCHEMA_CONTRACT.allowedVerdict.has(verdict)) {
throw new Error(`ClawScan judge result verdict was ${verdict ?? "missing"}`);
}
const confidence = readString(result, ["confidence"]);
if (!confidence || !CLAWHUB_OUTPUT_SCHEMA_CONTRACT.allowedConfidence.has(confidence)) {
throw new Error(`ClawScan judge result confidence was ${confidence ?? "missing"}`);
}
if (typeof result.summary !== "string") {
throw new Error("ClawScan judge result summary was missing");
}
if (typeof result.user_guidance !== "string") {
throw new Error("ClawScan judge result user_guidance was missing");
}
const artifactInspection = asRecord(result.artifact_inspection);
if (!artifactInspection) {
throw new Error("ClawScan judge result artifact_inspection was missing");
}
assertExactObjectKeys(
artifactInspection,
REQUIRED_CLAWHUB_ARTIFACT_INSPECTION_KEYS,
"ClawScan artifact inspection",
);
const inspectionStatus = readString(artifactInspection, ["status"]);
if (inspectionStatus !== "completed") {
throw new Error(`ClawScan artifact inspection status was ${inspectionStatus ?? "missing"}`);
}
const inspectionChallenge = readString(artifactInspection, ["challenge"]);
if (!inspectionChallenge) {
throw new Error("ClawScan artifact inspection challenge was missing");
}
const requiredFileSha256 = readString(artifactInspection, ["required_file_sha256"]);
if (!requiredFileSha256 || !/^[a-f0-9]{64}$/.test(requiredFileSha256)) {
throw new Error("ClawScan artifact inspection required_file_sha256 was invalid");
}
const inspectedFiles = artifactInspection.files_inspected;
if (
!Array.isArray(inspectedFiles) ||
inspectedFiles.length === 0 ||
inspectedFiles.some((file) => typeof file !== "string" || !file.startsWith("artifact/"))
) {
throw new Error("ClawScan artifact inspection files_inspected was invalid");
}
const dimensions = asRecord(result.dimensions);
if (!dimensions) {
throw new Error("ClawScan judge result dimensions was missing");
}
assertExactObjectKeys(dimensions, REQUIRED_CLAWHUB_DIMENSION_KEYS, "ClawScan judge dimensions");
for (const dimensionKey of REQUIRED_CLAWHUB_DIMENSION_KEYS) {
const dimension = asRecord(dimensions[dimensionKey]);
if (!dimension) {
throw new Error(`ClawScan judge dimension ${dimensionKey} was missing`);
}
assertExactObjectKeys(
dimension,
REQUIRED_CLAWHUB_DIMENSION_FIELD_KEYS,
`ClawScan judge dimension ${dimensionKey}`,
);
const status = readString(dimension, ["status"]);
if (!status || !ALLOWED_CLAWHUB_DIMENSION_STATUSES.has(status)) {
throw new Error(`ClawScan judge dimension ${dimensionKey} status was ${status ?? "missing"}`);
}
if (typeof dimension.detail !== "string") {
throw new Error(`ClawScan judge dimension ${dimensionKey} detail was missing`);
}
}
if (!Array.isArray(result.scan_findings_in_context)) {
throw new Error("ClawScan judge result scan_findings_in_context was missing");
}
for (const [index, findingValue] of result.scan_findings_in_context.entries()) {
const finding = asRecord(findingValue);
if (!finding) {
throw new Error(`ClawScan finding ${index + 1} was not an object`);
}
assertExactObjectKeys(finding, REQUIRED_CLAWHUB_FINDING_KEYS, `ClawScan finding ${index + 1}`);
if (typeof finding.ruleId !== "string") {
throw new Error(`ClawScan finding ${index + 1} ruleId was missing`);
}
if (typeof finding.expected_for_purpose !== "boolean") {
throw new Error(`ClawScan finding ${index + 1} expected_for_purpose was missing`);
}
if (typeof finding.note !== "string") {
throw new Error(`ClawScan finding ${index + 1} note was missing`);
}
}
}
function artifactCompletedAtMs(artifact: Record<string, unknown>) {
const completedAt = readString(artifact, ["completedAt"]);
const parsed = completedAt ? Date.parse(completedAt) : Number.NaN;
if (!Number.isFinite(parsed)) {
throw new Error(`ClawScan artifact completedAt was ${completedAt ?? "missing"}`);
}
return parsed;
}
function readClawScanScannerStatuses(
artifact: Record<string, unknown>,
scannerSet = REQUIRED_CLAWHUB_SCANNERS,
) {
const scanners = asRecord(artifact.scanners);
const scannerStatuses: Record<string, string> = {};
for (const scanner of scannerSet) {
const scannerRecord = asRecord(scanners?.[scanner]);
scannerStatuses[scanner] = readString(scannerRecord ?? {}, ["status"]) ?? "missing";
}
return scannerStatuses;
}
function clawScanDiagnosticMapping(artifact: Record<string, unknown>) {
const judge = asRecord(artifact.judge);
const result = asRecord(judge?.result);
const scannerStatuses = readClawScanScannerStatuses(artifact);
return {
judge: {
outputSchemaSha256: readString(judge ?? {}, ["outputSchemaSha256", "outputSchemaSHA"]),
promptSha256: readString(judge ?? {}, ["promptSha256", "promptSHA"]),
status: readString(judge ?? {}, ["status"]),
verdict: readString(result ?? {}, ["verdict"]),
},
scanners: {
skillspectorStatus: scannerStatuses.skillspector,
staticStatus: scannerStatuses["clawscan-static"],
},
};
}
function validateClawScanArtifactForClawHubProfile(artifact: Record<string, unknown>) {
const schemaVersion = readString(artifact, ["schemaVersion"]);
if (schemaVersion !== "clawscan-run-v1") {
throw new Error(`ClawScan artifact schemaVersion was ${schemaVersion ?? "missing"}`);
}
const profile = readString(artifact, ["profile"]);
if (profile !== "clawhub") {
throw new Error(`ClawScan artifact profile was ${profile ?? "missing"}`);
}
const scannerStatuses = readClawScanScannerStatuses(artifact);
const allowedScannerStatuses: Record<string, Set<string>> = {
"clawscan-static": new Set(["completed"]),
skillspector: new Set(["completed"]),
};
for (const [scanner, status] of Object.entries(scannerStatuses)) {
const allowed = allowedScannerStatuses[scanner] ?? new Set(["completed"]);
if (!allowed.has(status)) {
throw new Error(`ClawScan scanner ${scanner} status was ${status}`);
}
}
const judge = asRecord(artifact.judge);
if (!judge) throw new Error("ClawScan artifact judge result was missing");
const judgeStatus = readString(judge, ["status"]);
if (judgeStatus !== "completed") {
throw new Error(`ClawScan judge status was ${judgeStatus ?? "missing"}`);
}
const result = asRecord(judge.result);
if (!result) {
throw new Error("ClawScan judge did not include a JSON object result");
}
validateClawScanJudgeResultShape(result);
const parsed = parseLlmEvalResponse(JSON.stringify(result));
if (!parsed) {
throw new Error("ClawScan judge result did not match the ClawHub output schema");
}
const scanners = asRecord(artifact.scanners);
const skillSpector = asRecord(scanners?.skillspector);
if (!skillSpector || skillSpector.raw === undefined) {
throw new Error("ClawScan skillspector scanner output was missing");
}
const rawSkillSpector =
typeof skillSpector.raw === "string" ? skillSpector.raw : JSON.stringify(skillSpector.raw);
const checkedAt = artifactCompletedAtMs(artifact);
return {
llmAnalysis: toStoredLlmAnalysis(parsed, checkedAt),
mapping: clawScanDiagnosticMapping(artifact),
skillSpectorAnalysis: normalizeSkillSpectorAnalysis(rawSkillSpector, checkedAt),
};
}
export async function runClawScan(
job: ClaimedJob,
workspace: string,
onDiagnostic: (diagnostic: Partial<ClawScanCommandDiagnostic>) => void,
) {
const command = process.env.CODEX_SECURITY_SCAN_CLAWSCAN_COMMAND ?? "clawscan";
const artifactPath = join(workspace, "clawscan-artifact.json");
const target = await resolveClawScanTarget(workspace, job);
const args = [target, "--profile", "clawhub"];
if (job.job.targetKind === "packageRelease") {
// SkillSpector only understands skills. ClawHub owns the plugin manifest
// boundary, so never let ClawScan fall back to scanning the whole package.
const scanInputs = await resolveBundledSkillSpectorScanInputs(workspace, job);
const skillSpectorResultPath = join(workspace, "skillspector-aggregate.json");
const skillSpectorResult = await runBundledSkillSpector(workspace, scanInputs);
await writeFile(
skillSpectorResultPath,
`${JSON.stringify(skillSpectorResult, null, 2)}\n`,
"utf8",
);
args.push("--scanner-result", `skillspector=${skillSpectorResultPath}`);
}
args.push("--output", artifactPath);
onDiagnostic({ args: [command, ...args], artifactPath });
const captureArtifact = async () => {
if (!(await fileExists(artifactPath))) return undefined;
const rawArtifact = await readFile(artifactPath, "utf8");
onDiagnostic({ rawArtifact });
let parsedArtifact: unknown;
try {
parsedArtifact = JSON.parse(rawArtifact);
} catch {
return undefined;
}
const artifact = asRecord(parsedArtifact);
if (artifact) onDiagnostic({ mapping: clawScanDiagnosticMapping(artifact) });
return artifact;
};
try {
const output = await runCommand(command, args, {
cwd: workspace,
omitEnv: ["VIRUSTOTAL_API_KEY"],
timeoutMs: clawScanTimeoutMs(),
});
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,
timedOut: error.timedOut,
});
}
await captureArtifact();
throw error;
}
const artifact = await captureArtifact();
if (!artifact) throw new Error("ClawScan did not emit a valid JSON artifact");
const mapped = validateClawScanArtifactForClawHubProfile(artifact);
onDiagnostic({ mapping: mapped.mapping });
return mapped;
}
async function resolveClawScanTarget(workspace: string, job: ClaimedJob) {
if (job.job.targetKind === "packageRelease") {
const packageRoot = join(workspace, "artifact", "package");
if (await fileExists(join(packageRoot, "package.json"))) return "./artifact/package";
}
return "./artifact";
}
export function scanHealthClassification(input: {
clawscan: ClawScanCommandDiagnostic;
errorMessage?: string;
status: "completed" | "failed";
}) {
const timedOut = Boolean(input.clawscan.timedOut);
const scannerStatuses = Object.values(input.clawscan.mapping?.scanners ?? {}).filter(
(status): status is string => Boolean(status),
);
let scannerStageFailed = scannerStatuses.some(
(status) => status !== "completed" && status !== "missing",
);
const judgeStatus = input.clawscan.mapping?.judge?.status;
let judgeStageFailed = Boolean(judgeStatus && judgeStatus !== "completed");
scannerStageFailed ||= /ClawScan scanner/i.test(input.errorMessage ?? "");
judgeStageFailed ||= /ClawScan (artifact )?judge|output schema/i.test(input.errorMessage ?? "");
const failureStage =
input.status === "failed"
? scannerStageFailed
? "scanner"
: judgeStageFailed
? "judge"
: "unclassified"
: undefined;
return {
failureStage,
judgeStageFailed,
scannerStageFailed,
timedOut,
} as const;
}
export async function processJob(
client: CodexScanWorkerClient,
token: string,
job: ClaimedJob,
diagnosticsRoot: string | undefined,
onHealth?: (health: SecurityScanJobHealth) => void,
): Promise<ProcessJobResult> {
const workspace = await mkdtemp(join(tmpdir(), `clawhub-codex-scan-${basename(job.job._id)}-`));
const startedAt = Date.now();
const clawscan: ClawScanCommandDiagnostic = {};
let errorMessage: string | undefined;
let scanCompletedAt: number | undefined;
let llmAnalysis: StoredLlmAnalysis | undefined;
let skillSpectorAnalysis: SkillSpectorAnalysis | undefined;
let status: JobDiagnosticInput["status"] = "failed";
try {
await writeArtifactWorkspace(job, workspace);
const mapped = await runClawScan(job, workspace, (next) => {
Object.assign(clawscan, next);
});
llmAnalysis = mapped.llmAnalysis;
skillSpectorAnalysis = mapped.skillSpectorAnalysis;
if (!llmAnalysis) throw new Error("Security scan did not produce llmAnalysis");
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,
});
scanCompletedAt = Date.now();
status = "completed";
logger.info(
{
durationMs: Date.now() - startedAt,
event: "security_scan_job_completed",
implementation: "clawscan",
jobId: job.job._id,
scannerPhase: "complete",
status: llmAnalysis.status,
targetKind: job.job.targetKind,
},
"security scan job completed",
);
const health = scanHealthClassification({
clawscan,
status: "completed",
});
onHealth?.({
verdict: llmAnalysis.verdict,
completed: true,
durationMs: (scanCompletedAt ?? Date.now()) - startedAt,
...health,
});
return { completed: true, hardFailed: false, retryableFailed: false };
} 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",
);
const completedAt = Date.now();
const health = scanHealthClassification({
clawscan,
errorMessage,
status: "failed",
});
onHealth?.({
completed: false,
durationMs: completedAt - startedAt,
...health,
});
return {
completed: false,
hardFailed: !failResult?.retry,
retryableFailed: Boolean(failResult?.retry),
};
} finally {
try {
await writeJobDiagnostic({
completedAt: Date.now(),
clawscan,
diagnosticsRoot,
error: errorMessage,
job,
llmAnalysis,
runId: process.env.GITHUB_RUN_ID,
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 runContinuouslyRefilledWorkerPool<TJob>(options: {
concurrency: number;
maxJobs: number | undefined;
canClaim: (totalClaimed: number) => boolean;
claimJobs: (limit: number) => Promise<{ claimedCount: number; jobs: TJob[] }>;
processClaimedJob: (job: TJob) => Promise<ProcessJobResult>;
idlePollMs?: number;
sleep?: (ms: number) => Promise<unknown>;
}) {
const active = new Set<Promise<ProcessJobResult>>();
const sleepImpl = options.sleep ?? sleep;
let queueDrained = false;
let totalClaimed = 0;
let totalCompleted = 0;
let totalFailed = 0;
let totalRetryableFailed = 0;
let totalClaimFailures = 0;
while (active.size > 0 || (!queueDrained && options.canClaim(totalClaimed))) {
while (active.size < options.concurrency && !queueDrained && options.canClaim(totalClaimed)) {
const remainingJobs =
options.maxJobs === undefined
? options.concurrency - active.size
: Math.min(
options.concurrency - active.size,
Math.max(0, options.maxJobs - totalClaimed),
);
if (remainingJobs === 0) {
queueDrained = true;
break;
}
let claimedCount: number;
let jobs: TJob[];
try {
({ claimedCount, jobs } = await options.claimJobs(remainingJobs));
} catch (error) {
totalClaimFailures += 1;
totalFailed += 1;
queueDrained = true;
const message = error instanceof Error ? error.message : String(error);
logger.error(
{
event: "security_scan_claim_failed",
publicReason: sanitizeWorkerErrorMessage(message),
requested: remainingJobs,
scannerPhase: "claim",
},
"failed to claim security scan jobs",
);
break;
}
totalClaimed += claimedCount;
if (claimedCount < remainingJobs) {
queueDrained = true;
}
for (const job of jobs) {
const task = options.processClaimedJob(job).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
logger.error(
{
event: "security_scan_unhandled_process_failure",
publicReason: sanitizeWorkerErrorMessage(message),
scannerPhase: "process",
},
"security scan job escaped worker error handling",
);
return {
completed: false,
hardFailed: true,
retryableFailed: false,
};
});
active.add(task);
}
if (jobs.length === 0 && queueDrained) break;
}
if (active.size > 0) {
const settled = await Promise.race(
[...active].map(async (task) => ({ result: await task, task })),
);
active.delete(settled.task);
if (settled.result.completed) totalCompleted += 1;
if (settled.result.hardFailed) totalFailed += 1;
if (settled.result.retryableFailed) totalRetryableFailed += 1;
if (active.size > 0 || !queueDrained) continue;
}
const maxJobsReached = options.maxJobs !== undefined && totalClaimed >= options.maxJobs;
if (queueDrained && !maxJobsReached && options.idlePollMs && options.canClaim(totalClaimed)) {
await sleepImpl(options.idlePollMs);
queueDrained = false;
continue;
}
break;
}
return {
totalClaimed,
totalClaimFailures,
totalCompleted,
totalFailed,
totalRetryableFailed,
};
}
export async function publishWorkerHealthSummary(
diagnosticsRoot: string,
summary: ReturnType<typeof calculateSecurityScanWorkerHealthSummary>,
) {
await mkdir(diagnosticsRoot, { recursive: true });
await writeFile(
join(diagnosticsRoot, "worker-summary.json"),
`${JSON.stringify(summary, null, 2)}\n`,
);
const stepSummaryPath = process.env.GITHUB_STEP_SUMMARY?.trim();
if (stepSummaryPath) {
await appendFile(stepSummaryPath, renderSecurityScanWorkerSummaryMarkdown(summary));
}
}
async function main() {
const { batchLimit, maxJobs, maxRuntimeMs, leaseMs, lane, 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;
const outcomes: SecurityScanJobHealth[] = [];
logger.info(
{ diagnosticsRoot, event: "security_scan_diagnostics_directory", lane, workerId },
"security scan diagnostics directory",
);
const sharedShardIndex = Number(
process.env.CODEX_SECURITY_SCAN_SHARD?.match(/shared-(\d+)/)?.[1] ?? 0,
);
if (lane === "shared" && sharedShardIndex > 0) {
await sleep(sharedShardIndex * 250);
}
const stats = await runContinuouslyRefilledWorkerPool({
concurrency: batchLimit,
maxJobs,
canClaim: () => Date.now() < claimDeadline,
claimJobs: async (limit) => {
const leases = (await client.action(api.securityScan.claimCodexScanJobLeases, {
token,
workerId,
lane,
limit,
leaseMs,
})) as ClaimedJobLease[];
const hydrated = await Promise.all(
leases.map(async (lease) => {
try {
return {
job: (await client.action(api.securityScan.hydrateCodexScanJob, {
token,
workerId,
jobId: lease._id as Id<"securityScanJobs">,
leaseToken: lease.leaseToken,
})) as ClaimedJob | null,
requeued: false,
};
} catch (error) {
const message = sanitizeWorkerErrorMessage(
error instanceof Error ? error.message : String(error),
);
await client.action(api.securityScan.requeueCodexScanJobLease, {
token,
workerId,
jobId: lease._id as Id<"securityScanJobs">,
leaseToken: lease.leaseToken,
});
logger.error(
{
event: "security_scan_hydration_failed",
jobId: lease._id,
publicReason: message,
scannerPhase: "hydrate",
},
"failed to hydrate security scan job",
);
return { job: null, requeued: true };
}
}),
);
const jobs = hydrated
.map((outcome) => outcome.job)
.filter((job): job is ClaimedJob => job !== null);
const requeued = hydrated.filter((outcome) => outcome.requeued).length;
logger.info(
{
claimed: leases.length,
event: "security_scan_jobs_claimed",
hydrated: jobs.length,
lane,
leaseMs,
requeued,
requested: limit,
workerId,
},
"claimed security scan jobs",
);
return { claimedCount: leases.length, jobs };
},
processClaimedJob: async (job) => {
const processStartedAt = Date.now();
let reported = false;
try {
const result = await processJob(client, token, job, diagnosticsRoot, (health) => {
reported = true;
outcomes.push(health);
});
if (!reported) {
outcomes.push({
completed: result.completed,
durationMs: Date.now() - processStartedAt,
failureStage: result.completed ? undefined : "unclassified",
judgeStageFailed: false,
scannerStageFailed: false,
timedOut: false,
});
}
return result;
} catch (error) {
if (!reported) {
outcomes.push({
completed: false,
durationMs: Date.now() - processStartedAt,
failureStage: "unclassified",
judgeStageFailed: false,
scannerStageFailed: false,
timedOut: false,
});
}
throw error;
}
},
idlePollMs: lane === "priority" ? 15_000 : undefined,
});
let queueHealth: SecurityScanQueueHealth | undefined;
let queueHealthError: string | undefined;
try {
queueHealth = (await client.action(api.securityScan.getCodexScanQueueHealth, {
token,
})) as SecurityScanQueueHealth;
} catch (error) {
queueHealthError = sanitizeWorkerErrorMessage(
error instanceof Error ? error.message : String(error),
);
logger.error(
{
event: "security_scan_queue_health_failed",
publicReason: queueHealthError,
workerId,
},
"failed to read security scan queue health",
);
}
const remainingRuntimeMs = claimDeadline - Date.now();
if (remainingRuntimeMs <= 0) {
logger.info(
{
event: "security_scan_claim_window_closed",
remainingRuntimeMs,
workerId,
},
"stopping before claiming another security scan job",
);
}
logger.info(
{
elapsedMs: Date.now() - startedAt,
event: "security_scan_worker_summary",
lane,
...stats,
workerId,
},
"security scan worker summary",
);
const summary = calculateSecurityScanWorkerHealthSummary({
durationMs: Date.now() - startedAt,
outcomes,
pool: stats,
queueHealth,
queueHealthError,
workerId,
});
await publishWorkerHealthSummary(diagnosticsRoot, summary);
if (stats.totalFailed > 0) {
process.exitCode = 1;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
await main();
}