feat: add ClawScan shadow diagnostics

Run OSS ClawScan in artifact-only shadow mode after successful production security scans.
This commit is contained in:
Patrick Erichsen
2026-07-06 13:32:41 -07:00
committed by GitHub
parent f41b8062ac
commit e7dd2536c2
3 changed files with 662 additions and 2 deletions
+10
View File
@@ -46,6 +46,10 @@ jobs:
CODEX_SECURITY_SCAN_MAX_JOBS: ${{ inputs['max-jobs'] || '' }}
CODEX_SECURITY_SCAN_MAX_RUNTIME_MINUTES: ${{ inputs['max-runtime-minutes'] || '8' }}
CODEX_SECURITY_SCAN_TIMEOUT_MS: ${{ vars.CODEX_SECURITY_SCAN_TIMEOUT_MS || '240000' }}
CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN: "1"
CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX: ${{ vars.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX || 'docker' }}
CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX_IMAGE: ${{ vars.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX_IMAGE || 'ghcr.io/openclaw/clawscan-runtime@sha256:d85bfe671fe597edc6802f9d6a07dd91b59c69cec4faa6e8f89778037507dc3b' }}
CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_TIMEOUT_MS: ${{ vars.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_TIMEOUT_MS || '300000' }}
CODEX_SECURITY_SCAN_LEASE_MINUTES: "60"
CODEX_SECURITY_SCAN_DIAGNOSTICS_DIR: codex-security-scan-diagnostics-${{ matrix.shard }}
CODEX_SECURITY_SCAN_SHARD: ${{ matrix.shard }}
@@ -68,6 +72,12 @@ jobs:
fi
codex --version
- name: Install ClawScan CLI
run: |
set -euo pipefail
npm install -g @openclaw/clawscan@0.1.1
clawscan --version
- name: Install SkillSpector
run: |
set -euo pipefail
+390 -1
View File
@@ -1,5 +1,5 @@
/* @vitest-environment node */
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -19,6 +19,7 @@ import {
processJob,
resolveSkillSpectorScanInput,
resolveSkillSpectorScanInputs,
runClawScanShadow,
shouldClaimSecurityScanBatch,
writeArtifactWorkspace,
writeJobDiagnostic,
@@ -838,6 +839,309 @@ describe("run-codex-scan-worker diagnostics", () => {
else process.env.GITHUB_ACTIONS = previousGitHubActions;
});
it("runs OSS ClawScan shadow mode with recorded VirusTotal evidence", async () => {
const workspace = await tempDir();
await mkdir(join(workspace, "artifact"), { recursive: true });
await writeFile(join(workspace, "artifact", "SKILL.md"), "# Demo\n");
const fakeClawScan = join(workspace, "fake-clawscan");
await writeFile(
fakeClawScan,
`#!/usr/bin/env bash
set -euo pipefail
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
out="$2"
shift 2
;;
*)
shift
;;
esac
done
mkdir -p "$(dirname "$out")"
cat > "$out" <<'JSON'
{"schemaVersion":"clawscan-run-v1","profile":"clawhub","scanners":{"skillspector":{"status":"completed"},"virustotal":{"status":"completed"},"clawscan-static":{"status":"completed"}},"judge":{"status":"completed","result":{"verdict":"benign","confidence":"high"}}}
JSON
echo "targets: 1"
`,
);
await chmod(fakeClawScan, 0o755);
const previousEnabled = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
const previousCommand = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND;
const previousSandbox = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX;
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = "1";
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND = fakeClawScan;
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX = "docker";
const shadow = await runClawScanShadow(
{
job: {
_id: "securityScanJobs:shadow",
hasMaliciousSignal: false,
leaseToken: "lease-secret",
source: "vt-update",
targetKind: "skillVersion",
waitForVtUntil: 0,
},
target: {
version: {
vtAnalysis: {
status: "clean",
engineStats: { malicious: 0, suspicious: 0 },
},
},
},
},
workspace,
{
checkedAt: 123,
confidence: "medium",
status: "clean",
verdict: "benign",
},
);
expect(shadow).toMatchObject({
prod: { confidence: "medium", status: "clean", verdict: "benign" },
shadow: {
confidence: "high",
judgeStatus: "completed",
profile: "clawhub",
scannerStatuses: {
"clawscan-static": "completed",
skillspector: "completed",
virustotal: "completed",
},
status: "clean",
verdict: "benign",
},
status: "completed",
});
expect(shadow.command).toEqual(
expect.arrayContaining([
fakeClawScan,
"./artifact",
"--profile",
"clawhub",
"--scanner-result",
expect.stringMatching(/^virustotal=/),
"--sandbox",
"docker",
"--sandbox-image",
expect.stringContaining("@sha256:"),
]),
);
expect(shadow.vtFixturePath).toBeDefined();
const vtFixture = JSON.parse(await readFile(String(shadow.vtFixturePath), "utf8"));
expect(vtFixture).toMatchObject({ status: "clean" });
if (previousEnabled === undefined) delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = previousEnabled;
if (previousCommand === undefined)
delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND;
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND = previousCommand;
if (previousSandbox === undefined)
delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX;
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX = previousSandbox;
});
it("marks OSS ClawScan shadow mode failed when the judge fails", async () => {
const workspace = await tempDir();
await mkdir(join(workspace, "artifact"), { recursive: true });
await writeFile(join(workspace, "artifact", "SKILL.md"), "# Demo\n");
const fakeClawScan = join(workspace, "fake-clawscan");
await writeFile(
fakeClawScan,
`#!/usr/bin/env bash
set -euo pipefail
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
out="$2"
shift 2
;;
*)
shift
;;
esac
done
mkdir -p "$(dirname "$out")"
cat > "$out" <<'JSON'
{"schemaVersion":"clawscan-run-v1","profile":"clawhub","scanners":{"skillspector":{"status":"completed"},"virustotal":{"status":"completed"},"clawscan-static":{"status":"completed"}},"judge":{"status":"failed","error":"schema mismatch"}}
JSON
`,
);
await chmod(fakeClawScan, 0o755);
const previousEnabled = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
const previousCommand = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND;
const previousSandbox = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX;
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = "1";
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND = fakeClawScan;
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX = "off";
const shadow = await runClawScanShadow(
{
job: {
_id: "securityScanJobs:shadow-failed",
hasMaliciousSignal: false,
leaseToken: "lease-secret",
source: "publish",
targetKind: "skillVersion",
waitForVtUntil: 0,
},
target: {},
},
workspace,
{
checkedAt: 123,
confidence: "medium",
status: "clean",
verdict: "benign",
},
);
expect(shadow).toMatchObject({
error: "ClawScan judge status was failed",
shadow: {
judgeStatus: "failed",
profile: "clawhub",
scannerStatuses: {
"clawscan-static": "completed",
skillspector: "completed",
virustotal: "completed",
},
},
status: "failed",
});
if (previousEnabled === undefined) delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = previousEnabled;
if (previousCommand === undefined)
delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND;
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND = previousCommand;
if (previousSandbox === undefined)
delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX;
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX = previousSandbox;
});
it("marks OSS ClawScan shadow mode failed when an expected scanner fails", async () => {
const workspace = await tempDir();
await mkdir(join(workspace, "artifact"), { recursive: true });
await writeFile(join(workspace, "artifact", "SKILL.md"), "# Demo\n");
const fakeClawScan = join(workspace, "fake-clawscan");
await writeFile(
fakeClawScan,
`#!/usr/bin/env bash
set -euo pipefail
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
out="$2"
shift 2
;;
*)
shift
;;
esac
done
mkdir -p "$(dirname "$out")"
cat > "$out" <<'JSON'
{"schemaVersion":"clawscan-run-v1","profile":"clawhub","scanners":{"skillspector":{"status":"failed"},"virustotal":{"status":"completed"},"clawscan-static":{"status":"completed"}},"judge":{"status":"completed","result":{"verdict":"benign","confidence":"high"}}}
JSON
`,
);
await chmod(fakeClawScan, 0o755);
const previousEnabled = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
const previousCommand = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND;
const previousSandbox = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX;
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = "1";
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND = fakeClawScan;
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX = "off";
const shadow = await runClawScanShadow(
{
job: {
_id: "securityScanJobs:shadow-scanner-failed",
hasMaliciousSignal: false,
leaseToken: "lease-secret",
source: "publish",
targetKind: "skillVersion",
waitForVtUntil: 0,
},
target: {},
},
workspace,
{
checkedAt: 123,
confidence: "medium",
status: "clean",
verdict: "benign",
},
);
expect(shadow).toMatchObject({
error: "ClawScan scanner skillspector status was failed",
shadow: {
judgeStatus: "completed",
scannerStatuses: {
"clawscan-static": "completed",
skillspector: "failed",
virustotal: "completed",
},
verdict: "benign",
},
status: "failed",
});
if (previousEnabled === undefined) delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = previousEnabled;
if (previousCommand === undefined)
delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND;
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND = previousCommand;
if (previousSandbox === undefined)
delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX;
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX = previousSandbox;
});
it("skips OSS ClawScan shadow mode for unsupported parity contexts", async () => {
const workspace = await tempDir();
const previousEnabled = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = "1";
const shadow = await runClawScanShadow(
{
job: {
_id: "securityScanJobs:shadow-skipped",
hasMaliciousSignal: false,
leaseToken: "lease-secret",
source: "publish",
targetKind: "packageRelease",
waitForVtUntil: 0,
},
target: {},
},
workspace,
{
checkedAt: 123,
confidence: "medium",
status: "clean",
verdict: "benign",
},
);
expect(shadow).toMatchObject({
error: "ClawScan shadow parity is only enabled for skillVersion jobs, got packageRelease",
status: "skipped",
});
if (previousEnabled === undefined) delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = previousEnabled;
});
it("writes redacted Codex diagnostics without copying submitted artifact files or signed URLs", async () => {
const diagnosticsRoot = await tempDir();
const artifactWorkspace = await tempDir();
@@ -884,6 +1188,24 @@ describe("run-codex-scan-worker diagnostics", () => {
},
},
llmAnalysis: { confidence: "low", status: "clean", verdict: "benign" },
clawscanShadow: {
command: ["clawscan", "./artifact", "--profile", "clawhub"],
prod: { confidence: "low", status: "clean", verdict: "benign" },
shadow: {
confidence: "high",
judgeStatus: "completed",
profile: "clawhub",
scannerStatuses: {
"clawscan-static": "completed",
skillspector: "completed",
virustotal: "completed",
},
schemaVersion: "clawscan-run-v1",
status: "clean",
verdict: "benign",
},
status: "completed",
},
skillSpectorAnalysis: {
status: "suspicious",
issueCount: 1,
@@ -961,6 +1283,22 @@ describe("run-codex-scan-worker diagnostics", () => {
status: "failed",
});
expect(diagnostic.job.leaseToken).toBeUndefined();
expect(diagnostic.clawscanShadow).toMatchObject({
prod: { confidence: "low", status: "clean", verdict: "benign" },
shadow: {
confidence: "high",
judgeStatus: "completed",
profile: "clawhub",
scannerStatuses: {
"clawscan-static": "completed",
skillspector: "completed",
virustotal: "completed",
},
status: "clean",
verdict: "benign",
},
status: "completed",
});
expect(diagnostic.error).toBe(
"Codex result did not match ClawScan schema: [redacted result body]",
);
@@ -981,6 +1319,57 @@ describe("run-codex-scan-worker diagnostics", () => {
expect(allDiagnosticText).not.toContain("sk-short-fixture");
expect(allDiagnosticText).not.toContain("quoted artifact payload");
expect(allDiagnosticText).not.toContain("SkillSpector artifact payload");
const comparison = JSON.parse(
await readFile(join(jobDir, "clawscan-shadow-comparison.json"), "utf8"),
);
expect(comparison).toMatchObject({
prod: { status: "clean", verdict: "benign" },
shadow: { status: "clean", verdict: "benign" },
status: "completed",
});
expect(await readdir(jobDir)).not.toContain("artifact");
});
it("preserves sanitized ClawScan shadow failure reasons in comparison artifacts", async () => {
const diagnosticsRoot = await tempDir();
await writeJobDiagnostic({
completedAt: 2,
diagnosticsRoot,
job: {
job: {
_id: "job-shadow-failed",
hasMaliciousSignal: false,
leaseToken: "lease-secret",
source: "publish",
targetKind: "skillVersion",
waitForVtUntil: 0,
},
target: {},
},
clawscanShadow: {
error: "clawscan timed out with api_key=sk-shadow-secret",
status: "failed",
stderr: "provider stderr token=shadow-secret",
},
startedAt: 1,
status: "completed",
});
const comparison = JSON.parse(
await readFile(
join(diagnosticsRoot, "job-shadow-failed", "clawscan-shadow-comparison.json"),
"utf8",
),
);
expect(comparison).toMatchObject({
error: expect.stringContaining("clawscan timed out"),
status: "failed",
stderr: expect.stringMatching(/^\[redacted \d+ chars\]$/),
});
const comparisonText = JSON.stringify(comparison);
expect(comparisonText).not.toContain("sk-shadow-secret");
expect(comparisonText).not.toContain("shadow-secret");
});
});
+262 -1
View File
@@ -93,7 +93,36 @@ type CodexCommandDiagnostic = {
stdout?: string;
};
type ClawScanShadowDiagnostic = {
artifactPath?: string;
command?: string[];
completedAt?: number;
durationMs?: number;
error?: string;
exitCode?: number | null;
prod?: {
confidence?: string;
status?: string;
verdict?: string;
};
shadow?: {
confidence?: string;
judgeStatus?: string;
profile?: string;
scannerStatuses: Record<string, string>;
schemaVersion?: string;
status?: string;
verdict?: string;
};
startedAt?: number;
status: "completed" | "failed" | "skipped";
stdout?: string;
stderr?: string;
vtFixturePath?: string;
};
type JobDiagnosticInput = {
clawscanShadow?: ClawScanShadowDiagnostic;
codex?: CodexCommandDiagnostic;
completedAt: number;
diagnosticsRoot?: string;
@@ -119,6 +148,10 @@ const DEFAULT_BATCH_LIMIT = 4;
const DEFAULT_MAX_RUNTIME_MS = 40 * 60 * 1000;
const DEFAULT_CODEX_SCAN_TIMEOUT_MS = 20 * 60 * 1000;
const CLAIM_WINDOW_SHUTDOWN_BUFFER_MS = 3 * 60 * 1000;
const DEFAULT_CLAWSCAN_SHADOW_TIMEOUT_MS = 20 * 60 * 1000;
const DEFAULT_CLAWSCAN_SHADOW_SANDBOX_IMAGE =
"ghcr.io/openclaw/clawscan-runtime@sha256:d85bfe671fe597edc6802f9d6a07dd91b59c69cec4faa6e8f89778037507dc3b";
const EXPECTED_CLAWHUB_SHADOW_SCANNERS = ["clawscan-static", "skillspector", "virustotal"];
const MAX_DIAGNOSTIC_TEXT_CHARS = 20_000;
const MAX_STORED_SKILLSPECTOR_ISSUES = 25;
const MAX_STORED_SKILLSPECTOR_TEXT_CHARS = 2_000;
@@ -245,6 +278,16 @@ const DIAGNOSTIC_PUBLIC_TEXT_PATHS = new Set([
"codexstdout.item.type",
"codexstdout.status",
"codexstdout.type",
"clawscanshadow.prod.confidence",
"clawscanshadow.prod.status",
"clawscanshadow.prod.verdict",
"clawscanshadow.shadow.confidence",
"clawscanshadow.shadow.judgestatus",
"clawscanshadow.shadow.profile",
"clawscanshadow.shadow.schemaversion",
"clawscanshadow.shadow.status",
"clawscanshadow.shadow.verdict",
"clawscanshadow.status",
"llmanalysis.confidence",
"llmanalysis.status",
"llmanalysis.verdict",
@@ -276,9 +319,12 @@ function isDiagnosticSecretPath(path: string[]) {
}
function shouldPreserveDiagnosticText(path: string[], original: string, redacted: string) {
const key = diagnosticPathKey(path);
if (key === "clawscanshadow.error") return true;
return (
original === redacted &&
DIAGNOSTIC_PUBLIC_TEXT_PATHS.has(diagnosticPathKey(path)) &&
(DIAGNOSTIC_PUBLIC_TEXT_PATHS.has(key) ||
key.startsWith("clawscanshadow.shadow.scannerstatuses.")) &&
DIAGNOSTIC_PUBLIC_TEXT_VALUE_PATTERN.test(redacted)
);
}
@@ -434,6 +480,9 @@ export async function writeJobDiagnostic(input: JobDiagnosticInput) {
},
llmAnalysis: redactDiagnosticValue(input.llmAnalysis, ["llmAnalysis"]),
runId: input.runId,
clawscanShadow: input.clawscanShadow
? redactDiagnosticValue(input.clawscanShadow, ["clawscanShadow"])
: undefined,
skillSpectorAnalysis: redactDiagnosticValue(input.skillSpectorAnalysis, [
"skillSpectorAnalysis",
]),
@@ -457,6 +506,13 @@ export async function writeJobDiagnostic(input: JobDiagnosticInput) {
};
await writeFile(join(jobDir, "diagnostic.json"), `${JSON.stringify(diagnostic, null, 2)}\n`);
if (input.clawscanShadow) {
await writeFile(
join(jobDir, "clawscan-shadow-comparison.json"),
`${JSON.stringify(redactDiagnosticValue(input.clawscanShadow, ["clawscanShadow"]), null, 2)}\n`,
);
}
}
function safeOutputPath(workspace: string, artifactPath: string) {
@@ -1141,6 +1197,16 @@ function codexScanTimeoutMs() {
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CODEX_SCAN_TIMEOUT_MS;
}
function clawScanShadowTimeoutMs() {
const parsed = Number(process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_TIMEOUT_MS);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CLAWSCAN_SHADOW_TIMEOUT_MS;
}
function clawScanShadowEnabled() {
const value = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN?.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
export function minimumClaimWindowMs(maxRuntimeMs: number, scanTimeoutMs: number) {
return Math.min(maxRuntimeMs, scanTimeoutMs + CLAIM_WINDOW_SHUTDOWN_BUFFER_MS);
}
@@ -1219,6 +1285,182 @@ async function runCodex(
return toStoredLlmAnalysis(parsed);
}
function targetVirusTotalAnalysis(job: ClaimedJob) {
return (
(job.target.version as Record<string, unknown> | undefined)?.vtAnalysis ??
(job.target.release as Record<string, unknown> | undefined)?.vtAnalysis ??
null
);
}
async function resolveClawScanShadowTarget(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";
}
function clawScanShadowUnsupportedContext(job: ClaimedJob) {
if (job.job.targetKind !== "skillVersion") {
return `ClawScan shadow parity is only enabled for skillVersion jobs, got ${job.job.targetKind}`;
}
if (job.job.source !== "publish" && job.job.source !== "vt-update") {
return `ClawScan shadow parity is only enabled for publish or vt-update jobs, got ${job.job.source}`;
}
if (job.target.trustedOpenClawPlugin) {
return "ClawScan 0.1.1 cannot preserve trusted OpenClaw plugin context";
}
return undefined;
}
function clawScanShadowVerdictFromArtifact(artifact: unknown) {
const record = asRecord(artifact);
const judge = asRecord(record?.judge);
const result = asRecord(judge?.result);
const scanners = asRecord(record?.scanners);
const scannerStatuses: Record<string, string> = {};
if (scanners) {
for (const [scanner, value] of Object.entries(scanners)) {
const scannerRecord = asRecord(value);
const status = readString(scannerRecord ?? {}, ["status"]);
scannerStatuses[scanner] = status ?? "unknown";
}
}
const verdict = readString(result ?? {}, ["verdict", "status"]);
return {
confidence: readString(result ?? {}, ["confidence"]),
judgeStatus: readString(judge ?? {}, ["status"]),
profile: readString(record ?? {}, ["profile"]),
scannerStatuses,
schemaVersion: readString(record ?? {}, ["schemaVersion"]),
status: verdict ? verdictToStatus(verdict) : undefined,
verdict,
};
}
function validateClawScanShadowResult(shadow: NonNullable<ClawScanShadowDiagnostic["shadow"]>) {
for (const scanner of EXPECTED_CLAWHUB_SHADOW_SCANNERS) {
if (shadow.scannerStatuses[scanner] !== "completed") {
return `ClawScan scanner ${scanner} status was ${shadow.scannerStatuses[scanner] ?? "missing"}`;
}
}
if (shadow.judgeStatus !== "completed") {
return `ClawScan judge status was ${shadow.judgeStatus ?? "missing"}`;
}
if (!shadow.verdict) {
return "ClawScan judge did not return a verdict";
}
return undefined;
}
export async function runClawScanShadow(
job: ClaimedJob,
workspace: string,
llmAnalysis: StoredLlmAnalysis | undefined,
): Promise<ClawScanShadowDiagnostic> {
if (!clawScanShadowEnabled()) {
return { status: "skipped" };
}
const unsupportedContext = clawScanShadowUnsupportedContext(job);
if (unsupportedContext) {
return {
status: "skipped",
error: unsupportedContext,
};
}
if (!llmAnalysis) {
return {
status: "skipped",
error: "authoritative ClawHub scan did not produce llmAnalysis",
};
}
const startedAt = Date.now();
const diagnostic: ClawScanShadowDiagnostic = {
prod: {
confidence: llmAnalysis.confidence,
status: llmAnalysis.status,
verdict: llmAnalysis.verdict,
},
startedAt,
status: "failed",
};
try {
const shadowDir = join(workspace, "clawscan-shadow");
await mkdir(shadowDir, { recursive: true });
const vtFixturePath = join(shadowDir, "virustotal-prod.json");
const artifactPath = join(shadowDir, "artifact.json");
await writeFile(vtFixturePath, `${JSON.stringify(targetVirusTotalAnalysis(job), null, 2)}\n`);
const target = await resolveClawScanShadowTarget(workspace, job);
const command = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND ?? "clawscan";
const sandboxMode = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX ?? "docker";
const sandboxImage =
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX_IMAGE ??
DEFAULT_CLAWSCAN_SHADOW_SANDBOX_IMAGE;
const args = [
target,
"--profile",
"clawhub",
"--scanner-result",
`virustotal=${vtFixturePath}`,
"--output",
artifactPath,
"--sandbox",
sandboxMode,
];
if (sandboxMode === "docker") {
args.push("--sandbox-image", sandboxImage);
}
diagnostic.artifactPath = artifactPath;
diagnostic.command = [command, ...args];
diagnostic.vtFixturePath = vtFixturePath;
const output = await runCommand(command, args, {
cwd: workspace,
timeoutMs: clawScanShadowTimeoutMs(),
});
const raw = await readFile(artifactPath, "utf8");
const artifact = JSON.parse(raw) as unknown;
const completedAt = Date.now();
const shadow = clawScanShadowVerdictFromArtifact(artifact);
const resultError = validateClawScanShadowResult(shadow);
return {
...diagnostic,
completedAt,
durationMs: completedAt - startedAt,
...(resultError ? { error: resultError } : {}),
shadow,
status: resultError ? "failed" : "completed",
stderr: output.stderr,
stdout: output.stdout,
};
} catch (error) {
const completedAt = Date.now();
let exitCode: number | null | undefined;
let stderr: string | undefined;
let stdout: string | undefined;
let publicError = error instanceof Error ? error.message : String(error);
if (error instanceof CommandFailure) {
exitCode = error.exitCode;
stderr = error.stderr;
stdout = error.stdout;
publicError = error.message;
}
return {
...diagnostic,
completedAt,
durationMs: completedAt - startedAt,
error: sanitizeWorkerErrorMessage(publicError),
exitCode,
stderr,
stdout,
status: "failed",
};
}
}
export async function processJob(
client: CodexScanWorkerClient,
token: string,
@@ -1232,6 +1474,7 @@ export async function processJob(
let errorMessage: string | undefined;
let llmAnalysis: StoredLlmAnalysis | undefined;
let skillSpectorAnalysis: SkillSpectorAnalysis | undefined;
let clawscanShadow: ClawScanShadowDiagnostic | undefined;
let status: JobDiagnosticInput["status"] = "failed";
try {
await writeArtifactWorkspace(job, workspace);
@@ -1253,6 +1496,23 @@ export async function processJob(
runId: process.env.GITHUB_RUN_ID,
});
status = "completed";
clawscanShadow = await runClawScanShadow(job, workspace, llmAnalysis);
if (clawscanShadow.status !== "skipped") {
logger.info(
{
durationMs: clawscanShadow.durationMs,
event: "security_scan_clawscan_shadow_completed",
jobId: job.job._id,
prodStatus: llmAnalysis.status,
prodVerdict: llmAnalysis.verdict,
shadowStatus: clawscanShadow.shadow?.status,
shadowVerdict: clawscanShadow.shadow?.verdict,
shadowRunStatus: clawscanShadow.status,
targetKind: job.job.targetKind,
},
"ClawScan shadow run completed",
);
}
logger.info(
{
durationMs: Date.now() - startedAt,
@@ -1297,6 +1557,7 @@ export async function processJob(
await writeJobDiagnostic({
codex,
completedAt: Date.now(),
clawscanShadow,
diagnosticsRoot,
error: errorMessage,
job,