From e7dd2536c2eefad6979b07c75493f7784b44504d Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Mon, 6 Jul 2026 13:32:41 -0700 Subject: [PATCH] feat: add ClawScan shadow diagnostics Run OSS ClawScan in artifact-only shadow mode after successful production security scans. --- .github/workflows/security-scan-codex.yml | 10 + .../security/run-codex-scan-worker.test.ts | 391 +++++++++++++++++- scripts/security/run-codex-scan-worker.ts | 263 +++++++++++- 3 files changed, 662 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-scan-codex.yml b/.github/workflows/security-scan-codex.yml index c6ff3f4c..11a4b326 100644 --- a/.github/workflows/security-scan-codex.yml +++ b/.github/workflows/security-scan-codex.yml @@ -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 diff --git a/scripts/security/run-codex-scan-worker.test.ts b/scripts/security/run-codex-scan-worker.test.ts index 7156f239..b5a72514 100644 --- a/scripts/security/run-codex-scan-worker.test.ts +++ b/scripts/security/run-codex-scan-worker.test.ts @@ -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"); + }); }); diff --git a/scripts/security/run-codex-scan-worker.ts b/scripts/security/run-codex-scan-worker.ts index ed30a80d..12b2fd73 100644 --- a/scripts/security/run-codex-scan-worker.ts +++ b/scripts/security/run-codex-scan-worker.ts @@ -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; + 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 | undefined)?.vtAnalysis ?? + (job.target.release as Record | 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 = {}; + 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) { + 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 { + 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,