/* RFC-0875: nachweis.measure.cloudflare-agent-readiness command handler — submits an Unlisted scan to the Cloudflare URL Scanner API, polls for completion, parses Agent Readiness dimensions, builds AssessmentBundleV1, delegates to nachweis.assessment.ingest. Does not publish or approve — use nachweis.publish and nachweis.approve for gate progression. Does not create public derivatives — use nachweis.public-derivative. Does not scrape isitagentready.com HTML — uses official Cloudflare URL Scanner API only. Does not expose Cloudflare API tokens in logs, bundles, Bordbuch, or raw artifacts. Does not turn provider scores into certification claims. Does not hard-code dimension count or screenshot values — parser reads from API response. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. RFC-1237: resolve dry-run via context.dryRun fallback — consumeCommonFlags strips --dry-run into context.dryRun before dispatch, so a flags-only read was permanently false under werkstatt run (enforced by KERNEL-FLAG-10). RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt RFC-1254: drop unused io-migration leftovers RFC-0875 */ import path from "node:path"; import { tmpdir } from "node:os"; import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-engine/kernel"; import { assessmentBundleV1Schema, isNachweisEntitled, makeSkipResult, type AssessmentBundleV1, type AssessmentBundleArtifact, type AssessmentIngestResult, } from "./nachweis-io.ts"; import { runNachweisAssessmentIngest } from "./nachweis-assessment-ingest.ts"; import { failResult } from "@warpgogol/werkstatt-shared/checks/result-helpers"; // ── Types ─────────────────────────────────────────────────────────────────── export interface CloudflareAgentReadinessMeasureResult { command: "nachweis.measure.cloudflare-agent-readiness"; status: "ok" | "error" | "skip"; systemId: string; seriesId: string; observationId: string; scanId: string; ingest?: AssessmentIngestResult; code?: string; } // ── Constants ─────────────────────────────────────────────────────────────── const DEFAULT_POLL_INTERVAL_MS = 15_000; const DEFAULT_MAX_ELAPSED_MS = 300_000; // 5 minutes const CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4/accounts"; // Cloudflare Agent Readiness reports an ordinal level 0..5 (5 = "Agent-Native"). const CF_AGENT_READINESS_MAX_LEVEL = 5; // ── Flag helpers ──────────────────────────────────────────────────────────── function flagString(input: KernelCommandInput, key: string): string | undefined { const v = input.flags[key]; return typeof v === "string" ? v : undefined; } function flagBool(input: KernelCommandInput, key: string): boolean { const v = input.flags[key]; return v === true || v === "true"; } function flagInt(input: KernelCommandInput, key: string, defaultValue: number): number { const v = input.flags[key]; if (v == null) return defaultValue; const n = parseInt(String(v), 10); return Number.isFinite(n) ? n : defaultValue; } // ── Cloudflare API types ──────────────────────────────────────────────────── interface CloudflareScanSubmissionResponse { uuid?: string; result?: { uuid?: string; tasks?: Array<{ uuid?: string; status?: string }>; }; success?: boolean; errors?: Array<{ code?: number; message?: string }>; } interface CloudflareAgentReadinessCheck { status?: string; details?: unknown; durationMs?: number; } interface CloudflareAgentReadinessResult { level?: number; levelName?: string; overall?: number; checks?: Record; } interface CloudflareScanResultResponse { task?: { status?: string; success?: boolean; time?: string; timeEnd?: string; url?: string; uuid?: string; }; meta?: { processors?: { agentReadiness?: CloudflareAgentReadinessResult; }; }; success?: boolean; } // ── Parser ────────────────────────────────────────────────────────────────── interface ParsedDimension { id: string; providerLabel: string; score?: number; numerator?: number; denominator?: number; status?: "pass" | "fail" | "not-checked"; level?: string; } /** * Parse Agent Readiness dimensions from the Cloudflare API response. * * Uses explicit field paths from the documented API schema: * result.agentReadiness.checks..status * result.agentReadiness.checks..details * * The check `status` field is a string: "pass", "fail", or "not-checked". * When `details` contains a numeric score or pass-count, it is extracted. * * This parser MUST NOT guess field paths heuristically. If the expected * `agentReadiness` object is absent, the parser fails with * ASSESSMENT_SCHEMA_UNSUPPORTED. */ export function parseAgentReadiness(raw: CloudflareScanResultResponse): { overall?: { score?: number; level?: string }; dimensions: ParsedDimension[]; } { const ar = raw.meta?.processors?.agentReadiness; if (!ar || typeof ar !== "object") { throw new SchemaUnsupportedError("meta.processors.agentReadiness not found in response"); } const dimensions: ParsedDimension[] = []; const checks = ar.checks ?? {}; // Checks are nested two levels: checks.. // Each category becomes a dimension with numerator/denominator counting. for (const [catId, catChecks] of Object.entries(checks)) { if (!catChecks || typeof catChecks !== "object") continue; let passed = 0; let total = 0; let allPass = true; for (const [, check] of Object.entries( catChecks as Record, )) { if (!check || typeof check !== "object") continue; const status = check.status; if (status === "neutral" || status === "Neutral") continue; total++; if (status === "pass" || status === "Pass") { passed++; } else if (status === "fail" || status === "Fail") { allPass = false; } } if (total === 0) continue; const dim: ParsedDimension = { id: catId, providerLabel: humanizeDimensionLabel(catId), numerator: passed, denominator: total, status: allPass && passed === total ? "pass" : passed > 0 ? "fail" : "not-checked", }; dimensions.push(dim); } const overall: { score?: number; level?: string } = {}; // `overall` is the provider's 0-100 score; `level` is an ordinal 0-5 readiness // level — never store the raw level in the 0-100 score field. if (typeof ar.overall === "number" && ar.overall >= 0 && ar.overall <= 100) { overall.score = Math.round(ar.overall); } else if (typeof ar.level === "number" && ar.level >= 0) { overall.score = Math.round((ar.level / CF_AGENT_READINESS_MAX_LEVEL) * 100); } if (typeof ar.levelName === "string" && ar.levelName.length > 0) { overall.level = ar.levelName; } else if (typeof ar.level === "number") { overall.level = `Level ${ar.level}`; } return { overall: Object.keys(overall).length > 0 ? overall : undefined, dimensions }; } class SchemaUnsupportedError extends Error { constructor(message: string) { super(message); this.name = "SchemaUnsupportedError"; } } function humanizeDimensionLabel(id: string): string { return id .replace(/([A-Z])/g, " $1") .replace(/^./, (c) => c.toUpperCase()) .trim(); } // ── API helpers ───────────────────────────────────────────────────────────── function makeApiHeaders(apiToken: string): Record { return { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json", }; } function makeScanEndpoint(accountId: string): string { return `${CLOUDFLARE_API_BASE}/${accountId}/urlscanner/v2/scan`; } function makeResultEndpoint(accountId: string, scanId: string): string { return `${CLOUDFLARE_API_BASE}/${accountId}/urlscanner/v2/result/${scanId}`; } async function submitScan( accountId: string, apiToken: string, url: string, ): Promise<{ scanId: string; rawResponse: string }> { const endpoint = makeScanEndpoint(accountId); const body = JSON.stringify({ url, visibility: "Unlisted", agentReadiness: true, }); const response = await fetch(endpoint, { method: "POST", headers: makeApiHeaders(apiToken), body, }); const responseText = await response.text(); const parsed = JSON.parse(responseText) as CloudflareScanSubmissionResponse; const scanId = parsed.uuid ?? parsed.result?.uuid ?? parsed.result?.tasks?.[0]?.uuid; if (!scanId) { if (!response.ok) { throw new SubmissionError( `POST ${endpoint} returned HTTP ${response.status}: ${responseText.slice(0, 500)}`, ); } throw new SubmissionError("Submission response missing result.uuid or result.tasks[0].uuid"); } return { scanId, rawResponse: responseText }; } class SubmissionError extends Error { constructor(message: string) { super(message); this.name = "SubmissionError"; } } interface PollResult { rawResponse: string; parsed: CloudflareScanResultResponse; } async function pollForResult( accountId: string, apiToken: string, scanId: string, logger: { info: (msg: string) => void }, ): Promise { const endpoint = makeResultEndpoint(accountId, scanId); const startTime = Date.now(); const pollIntervalRaw = Number(process.env.CLOUDFLARE_AR_POLL_INTERVAL_MS); const pollIntervalMs = Number.isFinite(pollIntervalRaw) && pollIntervalRaw > 0 ? pollIntervalRaw : DEFAULT_POLL_INTERVAL_MS; const maxElapsedRaw = Number(process.env.CLOUDFLARE_AR_MAX_ELAPSED_MS); const maxElapsedMs = Number.isFinite(maxElapsedRaw) && maxElapsedRaw > 0 ? maxElapsedRaw : DEFAULT_MAX_ELAPSED_MS; for (;;) { const elapsed = Date.now() - startTime; if (elapsed >= maxElapsedMs) { throw new PollTimeoutError(`Polling exceeded ${maxElapsedMs}ms for scan ${scanId}`); } const response = await fetch(endpoint, { method: "GET", headers: makeApiHeaders(apiToken), }); if (response.status === 404) { logger.info( `[nachweis.measure.cloudflare-agent-readiness] scan ${scanId} still in progress (${elapsed}ms elapsed)`, ); await sleep(pollIntervalMs); continue; } if (!response.ok) { const text = await response.text(); throw new PollError( `GET ${endpoint} returned HTTP ${response.status}: ${text.slice(0, 500)}`, ); } const responseText = await response.text(); const rawParsed = JSON.parse(responseText) as { result?: CloudflareScanResultResponse; } & CloudflareScanResultResponse; // Cloudflare API v4 wraps payload under `result` — unwrap if present const parsed = (rawParsed.result ?? rawParsed) as CloudflareScanResultResponse; const taskStatus = parsed.task?.status; const taskStatusLower = taskStatus?.toLowerCase(); const taskSuccess = parsed.task?.success; if (taskStatusLower === "finished" && taskSuccess === false) { throw new ScanFailedError(`Provider reported task.success=false for scan ${scanId}`); } if (taskStatusLower === "finished" && taskSuccess === true) { return { rawResponse: responseText, parsed }; } // Still in progress (Queued, InProgress, or unknown) logger.info( `[nachweis.measure.cloudflare-agent-readiness] scan ${scanId} status=${taskStatus ?? "unknown"} (${elapsed}ms elapsed)`, ); await sleep(pollIntervalMs); } } class PollTimeoutError extends Error { constructor(message: string) { super(message); this.name = "PollTimeoutError"; } } class PollError extends Error { constructor(message: string) { super(message); this.name = "PollError"; } } class ScanFailedError extends Error { constructor(message: string) { super(message); this.name = "ScanFailedError"; } } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } // ── Bundle builder ────────────────────────────────────────────────────────── interface MeasureOptions { systemId: string; url: string; seriesId: string; authorizationBasis: "site-owner" | "service-contract" | "explicit-operator"; methodologyId: string; methodologyVersion: string; freshnessDays: number; dryRun: boolean; } function buildAssessmentBundle( options: MeasureOptions, scanId: string, parsed: CloudflareScanResultResponse, parsedResult: ReturnType, ): AssessmentBundleV1 { const observedAt = parsed.task?.timeEnd ?? parsed.task?.time ?? new Date().toISOString(); const observationId = `cf-ar-${scanId.slice(0, 12)}`; const slug = `cloudflare-cf-ar-01`; const artifacts: AssessmentBundleArtifact[] = [ { key: "cloudflare-submission", role: "summary", file: "cloudflare-submission.json", mediaType: "application/json", canonical: false, }, { key: "cloudflare-result", role: "raw-result", file: "cloudflare-result.json", mediaType: "application/json", canonical: true, }, { key: "provider-parser-metadata", role: "methodology", file: "provider-parser-metadata.json", mediaType: "application/json", canonical: false, }, ]; const dimensions = parsedResult.dimensions.map((d) => { const dim: Record = { id: d.id, providerLabel: d.providerLabel, }; if (d.score != null) dim.score = d.score; if (d.numerator != null) dim.numerator = d.numerator; if (d.denominator != null) dim.denominator = d.denominator; if (d.status != null) dim.status = d.status; if (d.level != null) dim.level = d.level; return dim as AssessmentBundleV1["result"]["dimensions"][number]; }); const result: AssessmentBundleV1["result"] = { dimensions, }; if (parsedResult.overall?.score != null || parsedResult.overall?.level != null) { result.overall = {}; if (parsedResult.overall?.score != null) result.overall.score = parsedResult.overall.score; if (parsedResult.overall?.level != null) result.overall.level = parsedResult.overall.level; } return { schemaVersion: "nachweis-assessment-bundle@1", systemId: options.systemId, slug, title: { de: "Cloudflare Agent Readiness Messung", en: "Cloudflare Agent Readiness Measurement", }, seriesId: options.seriesId, observationId, subject: { url: options.url, }, provider: { id: "cloudflare", name: "Cloudflare", homepage: "https://www.cloudflare.com", }, tool: { id: "cloudflare-url-scanner-agent-readiness", name: "Cloudflare URL Scanner — Agent Readiness", }, execution: { mode: "provider-run", authorizationBasis: options.authorizationBasis, }, observedAt, methodology: { id: options.methodologyId, version: options.methodologyVersion, runCount: 1, aggregation: "provider", }, result, freshness: { maxAgeDays: options.freshnessDays }, artifacts, }; } // ── Error result helper ───────────────────────────────────────────────────── function makeErrorMeasureResult( systemId: string, seriesId: string, code: string, summary: string, ): KernelCommandResult { return { ...failResult("nachweis.cloudflare.agent.readiness.measure", [ "nachweis.cloudflare.agent.readiness.measure failed", ]), data: { command: "nachweis.measure.cloudflare-agent-readiness", status: "error", systemId, seriesId, observationId: "", scanId: "", code, }, summary, }; } // ── Command handler ───────────────────────────────────────────────────────── export async function runNachweisCloudflareAgentReadinessMeasure( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const systemId = flagString(input, "system") ?? context.site?.name; const url = flagString(input, "url"); const seriesId = flagString(input, "series-id") ?? "cloudflare-agent-readiness-pilot"; const authorizationBasis = (flagString(input, "authorization-basis") ?? "site-owner") as "site-owner" | "service-contract" | "explicit-operator"; const methodologyFlag = flagString(input, "methodology") ?? "CF-AR-01@1.0"; const freshnessDays = flagInt(input, "freshness-days", 30); const dryRun = context.dryRun === true || flagBool(input, "dry-run"); const jsonOutput = flagBool(input, "json"); if (!systemId) { throw new Error("[nachweis.measure.cloudflare-agent-readiness] --system is required"); } if (!url) { throw new Error("[nachweis.measure.cloudflare-agent-readiness] --url is required"); } if (!url.startsWith("https://")) { return makeErrorMeasureResult( systemId, seriesId, "CLOUDFLARE_URL_INVALID", `[nachweis.measure.cloudflare-agent-readiness] CLOUDFLARE_URL_INVALID: --url must be an HTTPS URL`, ); } const methodologyParts = methodologyFlag.split("@"); if (methodologyParts.length !== 2 || !methodologyParts[0] || !methodologyParts[1]) { return makeErrorMeasureResult( systemId, seriesId, "CLOUDFLARE_METHODOLOGY_INVALID", `[nachweis.measure.cloudflare-agent-readiness] CLOUDFLARE_METHODOLOGY_INVALID: --methodology must be in format @`, ); } const methodologyId = methodologyParts[0]!; const methodologyVersion = methodologyParts[1]!; const entitled = await isNachweisEntitled(workspaceRoot, systemId); if (!entitled) { return makeSkipResult( "nachweis.measure.cloudflare-agent-readiness", systemId, ) as unknown as KernelCommandResult; } const options: MeasureOptions = { systemId, url, seriesId, authorizationBasis, methodologyId, methodologyVersion, freshnessDays, dryRun, }; if (dryRun) { const dryRunResult: CloudflareAgentReadinessMeasureResult = { command: "nachweis.measure.cloudflare-agent-readiness", status: "ok", systemId, seriesId, observationId: "", scanId: "", }; return { data: dryRunResult, exitCode: 0, summary: `[nachweis.measure.cloudflare-agent-readiness] ${systemId}: DRY RUN — would submit Cloudflare URL Scanner scan against ${url}`, }; } const accountId = process.env.CLOUDFLARE_URL_SCANNER_ACCOUNT_ID; const apiToken = process.env.CLOUDFLARE_URL_SCANNER_API_TOKEN; if (!accountId || !apiToken) { return makeErrorMeasureResult( systemId, seriesId, "CLOUDFLARE_CREDENTIALS_MISSING", `[nachweis.measure.cloudflare-agent-readiness] CLOUDFLARE_CREDENTIALS_MISSING: CLOUDFLARE_URL_SCANNER_ACCOUNT_ID and CLOUDFLARE_URL_SCANNER_API_TOKEN env vars are required`, ); } const workDir = await context.io.mkdtemp(path.join(tmpdir(), "cloudflare-ar-measure-")); try { let scanId: string; let submissionRaw: string; try { logger.info( `[nachweis.measure.cloudflare-agent-readiness] submitting Unlisted scan for ${url}`, ); const submission = await submitScan(accountId, apiToken, url); scanId = submission.scanId; submissionRaw = submission.rawResponse; } catch (err) { const message = err instanceof Error ? err.message : String(err); return makeErrorMeasureResult( systemId, seriesId, "CLOUDFLARE_SUBMISSION_FAILED", `[nachweis.measure.cloudflare-agent-readiness] CLOUDFLARE_SUBMISSION_FAILED: ${message}`, ); } await context.io.writeFile(path.join(workDir, "cloudflare-submission.json"), submissionRaw); let pollResult: PollResult; try { logger.info( `[nachweis.measure.cloudflare-agent-readiness] polling for scan ${scanId} completion`, ); pollResult = await pollForResult(accountId, apiToken, scanId, logger); } catch (err) { if (err instanceof PollTimeoutError) { return makeErrorMeasureResult( systemId, seriesId, "CLOUDFLARE_SCAN_TIMEOUT", `[nachweis.measure.cloudflare-agent-readiness] CLOUDFLARE_SCAN_TIMEOUT: ${err.message}`, ); } if (err instanceof ScanFailedError) { return makeErrorMeasureResult( systemId, seriesId, "CLOUDFLARE_SCAN_FAILED", `[nachweis.measure.cloudflare-agent-readiness] CLOUDFLARE_SCAN_FAILED: ${err.message}`, ); } const message = err instanceof Error ? err.message : String(err); return makeErrorMeasureResult( systemId, seriesId, "CLOUDFLARE_SCAN_FAILED", `[nachweis.measure.cloudflare-agent-readiness] CLOUDFLARE_SCAN_FAILED: ${message}`, ); } await context.io.writeFile( path.join(workDir, "cloudflare-result.json"), pollResult.rawResponse, ); let parsedDimensions: ReturnType; try { parsedDimensions = parseAgentReadiness(pollResult.parsed); } catch (err) { if (err instanceof SchemaUnsupportedError) { return makeErrorMeasureResult( systemId, seriesId, "ASSESSMENT_SCHEMA_UNSUPPORTED", `[nachweis.measure.cloudflare-agent-readiness] ASSESSMENT_SCHEMA_UNSUPPORTED: ${err.message}`, ); } throw err; } const parserMetadata = { parserVersion: "cf-ar-01", fieldPaths: { overall: "result.meta.processors.agentReadiness.overall", level: "result.meta.processors.agentReadiness.level", levelName: "result.meta.processors.agentReadiness.levelName", checks: "result.meta.processors.agentReadiness.checks..status", checkDetails: "result.meta.processors.agentReadiness.checks..details", }, parsedAt: new Date().toISOString(), }; await context.io.writeFile( path.join(workDir, "provider-parser-metadata.json"), JSON.stringify(parserMetadata, null, 2), ); const bundle = buildAssessmentBundle(options, scanId, pollResult.parsed, parsedDimensions); const parsed = assessmentBundleV1Schema.safeParse(bundle); if (!parsed.success) { return makeErrorMeasureResult( systemId, seriesId, "ASSESSMENT_BUNDLE_INVALID", `[nachweis.measure.cloudflare-agent-readiness] ASSESSMENT_BUNDLE_INVALID: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`, ); } const bundlePath = path.join(workDir, "assessment-bundle.json"); await context.io.writeFile(bundlePath, JSON.stringify(bundle, null, 2)); logger.info( `[nachweis.measure.cloudflare-agent-readiness] scan ${scanId} complete, delegating to nachweis.assessment.ingest`, ); const ingestInput: KernelCommandInput = { argv: [], flags: { system: systemId, bundle: bundlePath, "dry-run": false, }, }; const ingestResult = await runNachweisAssessmentIngest(ingestInput, context); if (ingestResult.exitCode !== 0) { return makeErrorMeasureResult( systemId, seriesId, "ASSESSMENT_INGEST_FAILED", `[nachweis.measure.cloudflare-agent-readiness] ASSESSMENT_INGEST_FAILED: ${ingestResult.summary}`, ); } const measureResult: CloudflareAgentReadinessMeasureResult = { command: "nachweis.measure.cloudflare-agent-readiness", status: "ok", systemId, seriesId, observationId: bundle.observationId, scanId, ingest: ingestResult.data, }; const summary = jsonOutput ? JSON.stringify(measureResult, null, 2) : `[nachweis.measure.cloudflare-agent-readiness] ${systemId}: scan ${scanId} complete, observationId=${bundle.observationId}`; return { data: measureResult, exitCode: 0, summary, }; } finally { await context.io.rm(workDir, { recursive: true }); } }