diff --git a/skills/sbom-maker/SKILL.md b/skills/sbom-maker/SKILL.md new file mode 100644 index 000000000..5a5cb2352 --- /dev/null +++ b/skills/sbom-maker/SKILL.md @@ -0,0 +1,39 @@ +--- +name: sbom-maker +description: Fetches a pinned npm lockfile, emits a grounded CycloneDX SBOM with license risks, and stores it as a project-version event that downstream runs can read. +--- + +# SBOM Maker + +Use this skill when security review needs a reproducible bill of materials from a real public project. One governed graph reads an immutable lockfile URL, resolves pinned npm components, appends the result to `data.source`, reads it back, and emits typed outputs only after storage is verified. + +## Inputs + +- `source_handle`: An immutable raw GitHub URL or public GitHub Contents API file URL containing a real `package-lock.json` or `npm-shrinkwrap.json`. The Contents API form must use a hexadecimal commit in its sole `ref` query parameter. Bundled `fixture://` handles are reserved for the harness. +- `lockfile_type`: `package-lock` or `npm-shrinkwrap`. +- `data_source_ref`: Optional logical data-source reference. It defaults to `local://sbom-maker/artifacts`. +- `store_id`: Optional deterministic local fixture store ID. + +## Outputs + +- `source_read`: HTTP or fixture provenance with final URL, status, byte count, timestamp, and SHA-256 content digest. +- `sbom`: CycloneDX 1.5 document for the named project and version. +- `components`: Pinned name, version, license, and exact lockfile evidence location for each dependency. +- `license_summary`: Component total and license counts derived from the lockfile. +- `license_risks`: Strong or weak copyleft findings plus dependencies with missing license evidence. +- `stored_artifact_ref`: Data-source, `software_boms` resource, project-version aggregate ID, idempotency key, and verified readback state. + +## Runtime Contract + +The HTTPS reader only admits `raw.githubusercontent.com` and `api.github.com` file URLs pinned to a hexadecimal commit and caps decoded source bodies at 5 MB. GitHub Contents responses must identify a Base64-encoded file; the output records the repository file URL and blob SHA. Malformed files, unsupported lockfile types, unavailable sources, and unapproved hosts fail before the append step and emit no SBOM. + +Successful runs append a `sbom.generated` event to `software_boms`, keyed by `@`. The idempotency key binds the project-version key to the fetched lockfile digest. The graph then reads that stream and refuses to finalize unless the event is present. + +The package carries the canonical runx `data.local` and `data.sqlite` adapters so registry installs can execute both deterministic harness storage and durable local SQLite storage without private tool catalogs. + +## Harness + +- `supported-source-stored` reads a bundled npm v3 lockfile, generates four typed SBOM outputs, appends the event, reads it back, and seals. +- `malformed-source-refused` reads a malformed fixture and fails at `generate`; no append, readback, or SBOM emit occurs. + +Run locally with `runx harness ./skills/sbom-maker`. A production run should pass an immutable raw lockfile URL and retain the emitted receipt for `runx verify`. diff --git a/skills/sbom-maker/X.yaml b/skills/sbom-maker/X.yaml new file mode 100644 index 000000000..a981c4e04 --- /dev/null +++ b/skills/sbom-maker/X.yaml @@ -0,0 +1,194 @@ +skill: sbom-maker +version: "1.0.0" + +catalog: + kind: graph + audience: public + visibility: public + role: canonical + +policy: + allow: + - provider: data-source + method: READ + scope: runx:data:read + - provider: data-source + method: APPEND + scope: runx:data:append + +harness: + cases: + - name: supported-source-stored + runner: default + inputs: + source_handle: fixture://supported-package-lock.json + lockfile_type: package-lock + data_source_ref: local://sbom-maker/harness + store_id: sbom-maker-supported-v2 + expect: + status: sealed + receipt: + schema: runx.receipt.v1 + state: sealed + disposition: closed + steps: + - generate + - append + - readback + - finalize + - name: malformed-source-refused + runner: validate + inputs: + source_handle: fixture://malformed-lockfile.json + lockfile_type: package-lock + data_source_ref: local://sbom-maker/harness + store_id: sbom-maker-malformed-v1 + expect: + status: failure + receipt: + schema: runx.receipt.v1 + state: failure + disposition: closed + +runners: + validate: + type: cli-tool + command: node + args: + - run.mjs + inputs: + source_handle: + type: string + required: true + lockfile_type: + type: string + required: true + data_source_ref: + type: string + required: false + default: local://sbom-maker/artifacts + store_id: + type: string + required: false + outputs: + sbom_result: object + default: + default: true + type: graph + inputs: + source_handle: + type: string + required: true + description: Immutable raw GitHub or GitHub Contents API lockfile URL, or a bundled fixture source during harness execution. + lockfile_type: + type: string + required: true + description: package-lock or npm-shrinkwrap. + data_source_ref: + type: string + required: false + default: local://sbom-maker/artifacts + description: Logical data source where the addressable SBOM event is appended. + store_id: + type: string + required: false + description: Optional deterministic local fixture store id. + outputs: + source_read: object + sbom: object + components: array + license_summary: object + license_risks: array + stored_artifact_ref: object + graph: + name: sbom-maker-fetch-build-store + steps: + - id: generate + label: fetch pinned lockfile and build grounded SBOM + inputs: + source_handle: "$input.source_handle" + lockfile_type: "$input.lockfile_type" + data_source_ref: "$input.data_source_ref" + store_id: "$input.store_id" + run: + type: cli-tool + command: node + args: + - run.mjs + timeout_seconds: 150 + outputs: + sbom_result: object + sandbox: + profile: network + cwd_policy: skill-directory + network: true + writable_paths: [] + require_enforcement: false + scopes: + - net:allowlist + artifacts: + named_emits: + sbom_result: sbom_result + packets: + sbom_result: runx.sbom.result.v1 + - id: append + label: append SBOM as an addressable project-version event + tool: data.source + scopes: + - runx:data:append + inputs: + operation: append_event + data_source_ref: "$input.data_source_ref" + store_id: "$input.store_id" + context: + resource: generate.sbom_result.data.stored_artifact_ref.resource + aggregate_id: generate.sbom_result.data.stored_artifact_ref.aggregate_id + expected_version: generate.sbom_result.data.stored_artifact_ref.expected_version + idempotency_key: generate.sbom_result.data.stored_artifact_ref.idempotency_key + event: generate.sbom_result.data.storage_event + - id: readback + label: read stored SBOM event for downstream consumption + tool: data.source + scopes: + - runx:data:read + inputs: + operation: read_events + data_source_ref: "$input.data_source_ref" + store_id: "$input.store_id" + limit: 10 + context: + resource: generate.sbom_result.data.stored_artifact_ref.resource + aggregate_id: generate.sbom_result.data.stored_artifact_ref.aggregate_id + - id: finalize + label: emit typed SBOM outputs only after verified readback + context: + generated: generate.sbom_result.data + append_result: append.data_operation_result.data + readback_result: readback.data_operation_result.data + run: + type: cli-tool + command: node + args: + - finalize.mjs + outputs: + source_read: object + sbom: object + components: array + license_summary: object + license_risks: array + stored_artifact_ref: object + artifacts: + named_emits: + source_read: source_read + sbom: sbom + components: components + license_summary: license_summary + license_risks: license_risks + stored_artifact_ref: stored_artifact_ref + packets: + source_read: runx.source.read.v1 + sbom: runx.sbom.cyclonedx.v1 + components: runx.sbom.components.v1 + license_summary: runx.sbom.license_summary.v1 + license_risks: runx.sbom.license_risks.v1 + stored_artifact_ref: runx.sbom.stored_artifact_ref.v1 diff --git a/skills/sbom-maker/evidence/harness-local.json b/skills/sbom-maker/evidence/harness-local.json new file mode 100644 index 000000000..a303f8722 --- /dev/null +++ b/skills/sbom-maker/evidence/harness-local.json @@ -0,0 +1,33 @@ +{ + "schema": "runx.sbom-maker.harness_evidence.v1", + "recorded_at": "2026-07-15T11:25:57Z", + "source_revision_base": "c69bff9dde07f305408013c9aefec7406702d12b", + "runx_version": "runx-cli 0.7.1", + "unit_tests": { + "command": "node --test skills/sbom-maker/sbom-maker.test.mjs", + "status": "passed", + "passed": 14, + "failed": 0 + }, + "harness": { + "command": "runx harness ./skills/sbom-maker", + "status": "passed", + "case_count": 2, + "assertion_error_count": 0, + "graph_case_count": 1, + "cases": [ + { + "name": "supported-source-stored", + "status": "sealed", + "receipt_id": "sha256:e4fca22e49e060c6c880e72be025692e33ca05f73d9356bb42171484eb190abe" + }, + { + "name": "malformed-source-refused", + "status": "refused", + "runtime_status": "failure", + "receipt_id": "sha256:d2fd0d096064f24f3a8b87bda4b33ca10a743fef3167242edfd20dc13ccf43d0", + "reason": "lockfile has no dependency map with pinned components" + } + ] + } +} diff --git a/skills/sbom-maker/finalize.mjs b/skills/sbom-maker/finalize.mjs new file mode 100644 index 000000000..21ad25825 --- /dev/null +++ b/skills/sbom-maker/finalize.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; + +import { finalizeStoredResult } from "./runtime/run.mjs"; + +try { + const inputs = parseInputs(); + const generated = requiredObject(inputs.generated, "generated"); + const appendResult = requiredObject(inputs.append_result, "append_result"); + const readbackResult = requiredObject(inputs.readback_result, "readback_result"); + process.stdout.write(JSON.stringify(finalizeStoredResult({ generated, appendResult, readbackResult }))); +} catch (error) { + const reason = error instanceof Error ? error.message : String(error); + process.stderr.write(`${JSON.stringify({ error: { reason } })}\n`); + process.exitCode = 1; +} + +function parseInputs() { + const raw = process.env.RUNX_INPUTS_PATH + ? readFileSync(process.env.RUNX_INPUTS_PATH, "utf8") + : process.env.RUNX_INPUTS_JSON ?? "{}"; + const value = JSON.parse(raw); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("RUNX_INPUTS_JSON must be an object"); + } + return value; +} + +function requiredObject(value, name) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} is required`); + return value; +} diff --git a/skills/sbom-maker/harness-fixtures/malformed-lockfile.json/manifest.json b/skills/sbom-maker/harness-fixtures/malformed-lockfile.json/manifest.json new file mode 100644 index 000000000..a5a91f74a --- /dev/null +++ b/skills/sbom-maker/harness-fixtures/malformed-lockfile.json/manifest.json @@ -0,0 +1,4 @@ +{ + "invalid": true, + "reason": "no dependency map" +} diff --git a/skills/sbom-maker/harness-fixtures/supported-package-lock.json/manifest.json b/skills/sbom-maker/harness-fixtures/supported-package-lock.json/manifest.json new file mode 100644 index 000000000..6143a428b --- /dev/null +++ b/skills/sbom-maker/harness-fixtures/supported-package-lock.json/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "fixture-app", + "version": "1.2.3", + "lockfileVersion": 3, + "packages": { + "": { + "name": "fixture-app", + "version": "1.2.3", + "license": "MIT" + }, + "node_modules/@scope/alpha": { + "version": "2.0.0", + "license": "Apache-2.0" + }, + "node_modules/beta": { + "version": "3.1.0", + "license": "GPL-3.0-only" + }, + "node_modules/gamma": { + "version": "4.0.0" + } + } +} diff --git a/skills/sbom-maker/run.mjs b/skills/sbom-maker/run.mjs new file mode 100644 index 000000000..9443a89c4 --- /dev/null +++ b/skills/sbom-maker/run.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +import { buildSbomResult, fetchSource } from "./runtime/run.mjs"; + +try { + const inputs = parseInputs(); + const sourceHandle = requiredString(inputs.source_handle, "source_handle"); + const lockfileType = requiredString(inputs.lockfile_type, "lockfile_type"); + const dataSourceRef = requiredString(inputs.data_source_ref, "data_source_ref"); + const storeId = optionalString(inputs.store_id); + const read = await fetchSource(sourceHandle); + const { content, ...sourceRead } = read; + const result = buildSbomResult({ + sourceHandle, + lockfileType, + content, + contentDigest: read.content_digest, + fetchedAt: read.fetched_at, + bytes: read.bytes, + status: read.status, + sourceKind: read.source_kind, + repositoryFileUrl: read.repository_file_url, + blobSha: read.blob_sha, + }); + + result.source_read = sourceRead; + result.stored_artifact_ref = { + data_source_ref: dataSourceRef, + ...(storeId ? { store_id: storeId } : {}), + ...result.stored_artifact_ref, + }; + + process.stdout.write(JSON.stringify({ sbom_result: result })); +} catch (error) { + const reason = error instanceof Error ? error.message : String(error); + process.stdout.write(JSON.stringify({ sbom_result: { status: "refused", reason, sbom_emitted: false } })); + process.stderr.write(`${JSON.stringify({ refusal: { reason, sbom_emitted: false } })}\n`); + process.exitCode = 1; +} + +function parseInputs() { + const raw = process.env.RUNX_INPUTS_JSON; + if (!raw) throw new Error("RUNX_INPUTS_JSON is missing"); + const value = JSON.parse(raw); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("RUNX_INPUTS_JSON must be an object"); + } + return value; +} + +function requiredString(value, name) { + if (typeof value !== "string" || value.trim() === "") throw new Error(`${name} is required`); + return value.trim(); +} + +function optionalString(value) { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} diff --git a/skills/sbom-maker/runtime/run.mjs b/skills/sbom-maker/runtime/run.mjs new file mode 100644 index 000000000..43ce56a68 --- /dev/null +++ b/skills/sbom-maker/runtime/run.mjs @@ -0,0 +1,479 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const MAX_SOURCE_BYTES = 5_000_000; +const ALLOWED_SOURCE_HOSTS = new Set(["api.github.com", "raw.githubusercontent.com"]); +const SUPPORTED_LOCKFILE_TYPES = new Set(["package-lock", "npm-shrinkwrap"]); + +export function normalizeSourceHandle(sourceHandle) { + if (typeof sourceHandle !== "string" || sourceHandle.trim() === "") { + throw new Error("source_handle is required"); + } + + let url; + try { + url = new URL(sourceHandle.trim()); + } catch { + throw new Error("source_handle must be a valid URL"); + } + + if (url.protocol === "fixture:") { + const fixtureName = `${url.hostname}${url.pathname}`.replace(/^\/+|\/+$/gu, ""); + if (!fixtureName || fixtureName.includes("/") || fixtureName.includes("..")) { + throw new Error("fixture source must name one bundled fixture"); + } + return { kind: "fixture", handle: url.href, fixtureName }; + } + + if (url.protocol !== "https:") { + throw new Error("source_handle must use https"); + } + if (url.username || url.password || url.port) { + throw new Error("source_handle must not contain credentials or a custom port"); + } + if (!ALLOWED_SOURCE_HOSTS.has(url.hostname)) { + throw new Error(`source host is not allowed: ${url.hostname}`); + } + if (url.hash) { + throw new Error("source_handle must not contain a query or fragment"); + } + + const segments = url.pathname.split("/").filter(Boolean); + if (url.hostname === "api.github.com") { + const refValues = url.searchParams.getAll("ref"); + if (segments.length < 5 || segments[0] !== "repos" || segments[3] !== "contents") { + throw new Error("GitHub API source must be a repository contents file URL"); + } + if ([...url.searchParams.keys()].some((key) => key !== "ref") || refValues.length !== 1) { + throw new Error("GitHub API source must contain only one ref parameter"); + } + if (!/^[a-f0-9]{12,64}$/iu.test(refValues[0])) { + throw new Error("GitHub API source must be pinned to an immutable commit"); + } + return { + kind: "github_contents", + handle: url.href, + host: url.hostname, + commit: refValues[0], + }; + } + + if (url.search) throw new Error("raw GitHub source must not contain a query"); + if (segments.length < 4 || !/^[a-f0-9]{12,64}$/iu.test(segments[2])) { + throw new Error("raw GitHub source must be pinned to an immutable commit"); + } + + return { kind: "https", handle: url.href, host: url.hostname }; +} + +export async function fetchSource(sourceHandle, options = {}) { + const source = normalizeSourceHandle(sourceHandle); + const now = options.now ?? (() => new Date().toISOString()); + + if (source.kind === "fixture") { + const fixtureUrl = new URL( + `../harness-fixtures/${source.fixtureName}/manifest.json`, + import.meta.url, + ); + const bytes = await readFile(fileURLToPath(fixtureUrl)); + assertBounded(bytes.byteLength); + return sourceRead({ + sourceHandle: source.handle, + finalUrl: source.handle, + status: 200, + bytes, + fetchedAt: now(), + sourceKind: "fixture", + }); + } + + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + const delay = options.delay ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + const timeoutMs = options.timeoutMs ?? 45_000; + let lastError = "unknown source read failure"; + + for (let attempt = 1; attempt <= 3; attempt += 1) { + let currentUrl = source.handle; + const redirects = []; + try { + for (let redirectCount = 0; redirectCount <= 5; redirectCount += 1) { + const response = await fetchImpl(currentUrl, { + method: "GET", + redirect: "manual", + headers: { + accept: "application/json, text/plain;q=0.9", + "user-agent": "runx-sbom-maker/1.0", + }, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location || redirectCount === 5) { + throw new Error("source returned an invalid redirect"); + } + const nextUrl = new URL(location, currentUrl).href; + normalizeSourceHandle(nextUrl); + redirects.push({ status: response.status, from: currentUrl, to: nextUrl }); + currentUrl = nextUrl; + continue; + } + + if (!response.ok) { + throw new Error(`source returned HTTP ${response.status}`); + } + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength)) assertBounded(declaredLength); + const transportBytes = new Uint8Array(await response.arrayBuffer()); + const decoded = source.kind === "github_contents" + ? decodeGitHubContents(transportBytes) + : { bytes: transportBytes, evidence: {} }; + const bytes = decoded.bytes; + assertBounded(bytes.byteLength); + return { + ...sourceRead({ + sourceHandle: source.handle, + finalUrl: response.url || currentUrl, + status: response.status, + bytes, + fetchedAt: now(), + sourceKind: source.kind, + }), + redirects, + attempts: attempt, + ...decoded.evidence, + }; + } + lastError = "redirect limit exceeded"; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + if (attempt < 3) { + await delay(attempt * 1_000); + } + } + + throw new Error(`source read failed after 3 attempts: ${lastError}`); +} + +export function buildSbomResult({ + sourceHandle, + lockfileType, + content, + contentDigest, + fetchedAt, + bytes, + status, + sourceKind, + repositoryFileUrl, + blobSha, +}) { + if (!SUPPORTED_LOCKFILE_TYPES.has(lockfileType)) { + throw new Error(`unsupported lockfile_type: ${lockfileType}`); + } + + let lockfile; + try { + lockfile = JSON.parse(content); + } catch { + throw new Error("lockfile content is not valid JSON"); + } + if (!isRecord(lockfile)) { + throw new Error("lockfile must be a JSON object"); + } + + const rootPackage = isRecord(lockfile.packages) && isRecord(lockfile.packages[""]) + ? lockfile.packages[""] + : {}; + const projectName = firstString(rootPackage.name, lockfile.name, "unnamed-project"); + const projectVersion = firstString(rootPackage.version, lockfile.version, "0.0.0"); + const components = extractComponents(lockfile); + if (components.length === 0) { + throw new Error("lockfile has no dependency map with pinned components"); + } + + components.sort((left, right) => left.name.localeCompare(right.name) + || left.version.localeCompare(right.version) + || left.evidence_location.localeCompare(right.evidence_location)); + + const licenseCounts = {}; + const licenseRisks = []; + for (const component of components) { + licenseCounts[component.license] = (licenseCounts[component.license] ?? 0) + 1; + const risk = licenseRisk(component); + if (risk) licenseRisks.push(risk); + } + + const sourceReadEvidence = { + source_handle: sourceHandle, + final_url: sourceHandle, + source_kind: sourceKind ?? (sourceHandle.startsWith("fixture:") ? "fixture" : "https"), + status, + fetched_at: fetchedAt, + bytes, + content_digest: contentDigest, + }; + const sbom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + serialNumber: `urn:uuid:${digestUuid(contentDigest)}`, + version: 1, + metadata: { + component: { type: "application", name: projectName, version: projectVersion }, + properties: [ + { name: "runx:source_handle", value: sourceHandle }, + { name: "runx:source_digest", value: contentDigest }, + { name: "runx:lockfile_type", value: lockfileType }, + ], + }, + components: components.map(({ evidence_location, ...component }) => ({ + type: "library", + ...component, + properties: [{ name: "runx:evidence_location", value: evidence_location }], + evidence_location, + })), + }; + const licenseSummary = { + total_components: components.length, + license_counts: sortRecord(licenseCounts), + }; + const aggregateId = `${projectName}@${projectVersion}`; + const idempotencyKey = `sbom:${aggregateId}:${contentDigest}`; + const storageEvent = { + type: "sbom.generated", + project: { name: projectName, version: projectVersion }, + lockfile_type: lockfileType, + source_read: { + source_handle: sourceHandle, + source_kind: sourceReadEvidence.source_kind, + status, + bytes, + content_digest: contentDigest, + ...(repositoryFileUrl ? { repository_file_url: repositoryFileUrl } : {}), + ...(blobSha ? { blob_sha: blobSha } : {}), + }, + sbom, + components, + license_summary: licenseSummary, + license_risks: licenseRisks, + }; + + return { + source_read: sourceReadEvidence, + sbom, + components, + license_summary: licenseSummary, + license_risks: licenseRisks, + stored_artifact_ref: { + resource: "software_boms", + aggregate_id: aggregateId, + expected_version: 0, + idempotency_key: idempotencyKey, + read_operation: "read_events", + }, + storage_event: storageEvent, + }; +} + +export function finalizeStoredResult({ generated, appendResult, readbackResult }) { + if (!isRecord(generated)) throw new Error("generated is required"); + if (!isRecord(appendResult)) throw new Error("append_result is required"); + if (!isRecord(readbackResult)) throw new Error("readback_result is required"); + if (!isRecord(generated.stored_artifact_ref)) { + throw new Error("generated.stored_artifact_ref is required"); + } + + if (!new Set(["committed", "idempotent_replay"]).has(appendResult.status)) { + throw new Error(`append did not commit: ${String(appendResult.status ?? "unknown")}`); + } + const eventRef = firstString(appendResult.event_ref); + if (!eventRef) throw new Error("append result has no event_ref"); + + const idempotencyKey = generated.stored_artifact_ref.idempotency_key; + const readbackEvent = Array.isArray(readbackResult.events) + ? readbackResult.events.find((entry) => isRecord(entry) + && entry.event_ref === eventRef + && entry.event_type === "sbom.generated" + && entry.idempotency_key === idempotencyKey) + : undefined; + if (!readbackEvent) throw new Error("stored SBOM event was not present in readback"); + + const providerEvidence = isRecord(appendResult.provider_evidence) ? appendResult.provider_evidence : {}; + return { + source_read: generated.source_read, + sbom: generated.sbom, + components: generated.components, + license_summary: generated.license_summary, + license_risks: generated.license_risks, + stored_artifact_ref: { + ...generated.stored_artifact_ref, + event_ref: eventRef, + event_version: appendResult.after_version, + append_status: appendResult.status, + provider: appendResult.provider, + ...(typeof providerEvidence.adapter === "string" ? { adapter: providerEvidence.adapter } : {}), + ...(typeof providerEvidence.storage_class === "string" + ? { storage_class: providerEvidence.storage_class } + : {}), + readback_verified: true, + append_result_digest: digestObject(appendResult), + readback_result_digest: digestObject(readbackResult), + }, + }; +} + +function extractComponents(lockfile) { + if (isRecord(lockfile.packages)) { + return Object.entries(lockfile.packages) + .filter(([packagePath, details]) => packagePath !== "" && isRecord(details)) + .flatMap(([packagePath, details]) => { + const version = firstString(details.version); + const marker = "node_modules/"; + const markerIndex = packagePath.lastIndexOf(marker); + if (!version || markerIndex < 0) return []; + const name = packagePath.slice(markerIndex + marker.length); + return [component(name, version, details.license, `packages[${JSON.stringify(packagePath)}]`)]; + }); + } + + if (isRecord(lockfile.dependencies)) { + const components = []; + walkClassicDependencies(lockfile.dependencies, "dependencies", components); + return components; + } + + throw new Error("lockfile has no dependency map with pinned components"); +} + +function walkClassicDependencies(dependencies, location, output) { + for (const [name, details] of Object.entries(dependencies)) { + if (!isRecord(details)) continue; + const componentLocation = `${location}[${JSON.stringify(name)}]`; + const version = firstString(details.version); + if (version) output.push(component(name, version, details.license, componentLocation)); + if (isRecord(details.dependencies)) { + walkClassicDependencies(details.dependencies, `${componentLocation}.dependencies`, output); + } + } +} + +function component(name, version, license, evidenceLocation) { + return { + name, + version, + license: normalizeLicense(license), + evidence_location: evidenceLocation, + }; +} + +function normalizeLicense(value) { + if (typeof value === "string" && value.trim()) return value.trim(); + if (isRecord(value) && typeof value.type === "string" && value.type.trim()) return value.type.trim(); + return "UNKNOWN"; +} + +function licenseRisk(componentValue) { + const license = componentValue.license.toUpperCase(); + if (license.includes("AGPL") || license.includes("GPL-3")) { + return { + component: componentValue.name, + version: componentValue.version, + license: componentValue.license, + risk: "high", + reason: "strong copyleft license requires distribution and linking review", + evidence_location: componentValue.evidence_location, + }; + } + if (license.includes("LGPL") || license.includes("MPL")) { + return { + component: componentValue.name, + version: componentValue.version, + license: componentValue.license, + risk: "medium", + reason: "weak copyleft license requires modification and relinking review", + evidence_location: componentValue.evidence_location, + }; + } + if (license === "UNKNOWN") { + return { + component: componentValue.name, + version: componentValue.version, + license: componentValue.license, + risk: "review", + reason: "lockfile contains no license evidence", + evidence_location: componentValue.evidence_location, + }; + } + return null; +} + +function sourceRead({ sourceHandle, finalUrl, status, bytes, fetchedAt, sourceKind }) { + return { + source_handle: sourceHandle, + final_url: finalUrl, + source_kind: sourceKind, + status, + fetched_at: fetchedAt, + bytes: bytes.byteLength, + content_digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + content: new TextDecoder("utf-8", { fatal: false }).decode(bytes), + }; +} + +function decodeGitHubContents(transportBytes) { + let payload; + try { + payload = JSON.parse(new TextDecoder("utf-8", { fatal: false }).decode(transportBytes)); + } catch { + throw new Error("GitHub contents response is not valid JSON"); + } + if (!isRecord(payload) || payload.type !== "file" || payload.encoding !== "base64" + || typeof payload.content !== "string") { + throw new Error("GitHub contents response does not contain a base64 file"); + } + const bytes = new Uint8Array(Buffer.from(payload.content.replace(/\s+/gu, ""), "base64")); + return { + bytes, + evidence: { + ...(typeof payload.sha === "string" ? { blob_sha: payload.sha } : {}), + ...(typeof payload.html_url === "string" ? { repository_file_url: payload.html_url } : {}), + transport_bytes: transportBytes.byteLength, + }, + }; +} + +function assertBounded(byteLength) { + if (!Number.isFinite(byteLength) || byteLength < 0 || byteLength > MAX_SOURCE_BYTES) { + throw new Error(`source exceeds ${MAX_SOURCE_BYTES} byte limit`); + } +} + +function digestUuid(contentDigest) { + const hex = createHash("sha256").update(contentDigest).digest("hex").slice(0, 32).split(""); + hex[12] = "5"; + hex[16] = ((Number.parseInt(hex[16], 16) & 0x3) | 0x8).toString(16); + return `${hex.slice(0, 8).join("")}-${hex.slice(8, 12).join("")}-${hex.slice(12, 16).join("")}-${hex.slice(16, 20).join("")}-${hex.slice(20).join("")}`; +} + +function digestObject(value) { + const json = JSON.stringify(value); + let hash = 2166136261; + for (let index = 0; index < json.length; index += 1) { + hash ^= json.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `fnv1a32:${(hash >>> 0).toString(16).padStart(8, "0")}`; +} + +function sortRecord(record) { + return Object.fromEntries(Object.entries(record).sort(([left], [right]) => left.localeCompare(right))); +} + +function firstString(...values) { + return values.find((value) => typeof value === "string" && value.trim())?.trim(); +} + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/skills/sbom-maker/sbom-maker.test.mjs b/skills/sbom-maker/sbom-maker.test.mjs new file mode 100644 index 000000000..2eed16489 --- /dev/null +++ b/skills/sbom-maker/sbom-maker.test.mjs @@ -0,0 +1,338 @@ +import assert from "node:assert/strict"; +import { + cpSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +import { + buildSbomResult, + fetchSource, + finalizeStoredResult, + normalizeSourceHandle, +} from "./runtime/run.mjs"; + +const packageLock = { + name: "fixture-app", + version: "1.2.3", + lockfileVersion: 3, + packages: { + "": { name: "fixture-app", version: "1.2.3", license: "MIT" }, + "node_modules/@scope/alpha": { version: "2.0.0", license: "Apache-2.0" }, + "node_modules/beta": { version: "3.1.0", license: "GPL-3.0-only" }, + "node_modules/gamma": { version: "4.0.0" }, + }, +}; + +test("normalizes a pinned raw GitHub source", () => { + const source = normalizeSourceHandle( + "https://raw.githubusercontent.com/example/project/0123456789abcdef/package-lock.json", + ); + + assert.equal(source.kind, "https"); + assert.equal(source.host, "raw.githubusercontent.com"); +}); + +test("rejects unapproved source hosts", () => { + assert.throws( + () => normalizeSourceHandle("https://example.com/package-lock.json"), + /source host is not allowed/u, + ); +}); + +test("normalizes a pinned GitHub contents API source", () => { + const source = normalizeSourceHandle( + "https://api.github.com/repos/example/project/contents/package-lock.json?ref=0123456789abcdef", + ); + + assert.equal(source.kind, "github_contents"); + assert.equal(source.commit, "0123456789abcdef"); +}); + +test("fetches a bounded HTTPS source and records provenance", async () => { + const body = JSON.stringify(packageLock); + const response = new Response(body, { + status: 200, + headers: { "content-type": "application/json", "content-length": String(body.length) }, + }); + + const read = await fetchSource( + "https://raw.githubusercontent.com/example/project/0123456789abcdef/package-lock.json", + { fetchImpl: async () => response, now: () => "2026-07-15T10:00:00.000Z" }, + ); + + assert.equal(read.status, 200); + assert.equal(read.fetched_at, "2026-07-15T10:00:00.000Z"); + assert.match(read.content_digest, /^sha256:[a-f0-9]{64}$/u); + assert.equal(read.content, body); +}); + +test("refuses an unreachable source", async () => { + await assert.rejects( + () => fetchSource( + "https://raw.githubusercontent.com/example/project/0123456789abcdef/package-lock.json", + { + fetchImpl: async () => { throw new Error("network unavailable"); }, + delay: async () => {}, + }, + ), + /source read failed after 3 attempts: network unavailable/u, + ); +}); + +test("retries a transient source read failure", async () => { + const body = JSON.stringify(packageLock); + let attempts = 0; + const read = await fetchSource( + "https://raw.githubusercontent.com/example/project/0123456789abcdef/package-lock.json", + { + fetchImpl: async () => { + attempts += 1; + if (attempts === 1) throw new Error("temporary reset"); + return new Response(body, { status: 200 }); + }, + delay: async () => {}, + }, + ); + + assert.equal(attempts, 2); + assert.equal(read.status, 200); +}); + +test("decodes a GitHub contents API response as the source file", async () => { + const body = JSON.stringify(packageLock); + const apiResponse = { + type: "file", + encoding: "base64", + content: Buffer.from(body).toString("base64"), + sha: "a".repeat(40), + html_url: "https://github.com/example/project/blob/0123456789abcdef/package-lock.json", + }; + + const read = await fetchSource( + "https://api.github.com/repos/example/project/contents/package-lock.json?ref=0123456789abcdef", + { fetchImpl: async () => new Response(JSON.stringify(apiResponse), { status: 200 }) }, + ); + + assert.equal(read.content, body); + assert.equal(read.source_kind, "github_contents"); + assert.equal(read.blob_sha, "a".repeat(40)); + assert.equal(read.repository_file_url, apiResponse.html_url); +}); + +test("builds a grounded CycloneDX SBOM and an addressable storage event", () => { + const result = buildSbomResult({ + sourceHandle: "fixture://supported-package-lock.json", + lockfileType: "package-lock", + content: JSON.stringify(packageLock), + contentDigest: "sha256:fixture", + fetchedAt: "2026-07-15T10:00:00.000Z", + bytes: 321, + status: 200, + }); + + assert.equal(result.sbom.bomFormat, "CycloneDX"); + assert.equal(result.sbom.metadata.component.name, "fixture-app"); + assert.equal(result.components.length, 3); + assert.deepEqual( + result.components.map((component) => component.name), + ["@scope/alpha", "beta", "gamma"], + ); + assert.equal(result.components[0].evidence_location, 'packages["node_modules/@scope/alpha"]'); + assert.equal(result.license_summary.license_counts.UNKNOWN, 1); + assert.equal(result.license_risks[0].component, "beta"); + assert.equal(result.storage_event.type, "sbom.generated"); + assert.equal(result.stored_artifact_ref.aggregate_id, "fixture-app@1.2.3"); + assert.match(result.stored_artifact_ref.idempotency_key, /^sbom:fixture-app@1\.2\.3:sha256:fixture$/u); +}); + +test("refuses malformed and unsupported lockfiles", () => { + assert.throws( + () => buildSbomResult({ + sourceHandle: "fixture://malformed-lockfile.json", + lockfileType: "package-lock", + content: '{"invalid":true}', + contentDigest: "sha256:bad", + fetchedAt: "2026-07-15T10:00:00.000Z", + bytes: 16, + status: 200, + }), + /lockfile has no dependency map/u, + ); + + assert.throws( + () => buildSbomResult({ + sourceHandle: "fixture://supported-package-lock.json", + lockfileType: "yarn", + content: JSON.stringify(packageLock), + contentDigest: "sha256:fixture", + fetchedAt: "2026-07-15T10:00:00.000Z", + bytes: 321, + status: 200, + }), + /unsupported lockfile_type/u, + ); +}); + +test("walks classic package-lock dependencies with grounded nested locations", () => { + const result = buildSbomResult({ + sourceHandle: "fixture://classic-package-lock.json", + lockfileType: "npm-shrinkwrap", + content: JSON.stringify({ + name: "classic-app", + version: "0.8.0", + lockfileVersion: 1, + dependencies: { + alpha: { + version: "1.0.0", + license: "MIT", + dependencies: { beta: { version: "2.0.0", license: "BSD-3-Clause" } }, + }, + }, + }), + contentDigest: "sha256:classic", + fetchedAt: "2026-07-15T10:00:00.000Z", + bytes: 250, + status: 200, + }); + + assert.deepEqual(result.components.map((component) => component.name), ["alpha", "beta"]); + assert.equal( + result.components[1].evidence_location, + 'dependencies["alpha"].dependencies["beta"]', + ); +}); + +test("keeps the stored event deterministic for the same source digest", () => { + const base = { + sourceHandle: "fixture://supported-package-lock.json", + lockfileType: "package-lock", + content: JSON.stringify(packageLock), + contentDigest: "sha256:fixture", + bytes: 321, + status: 200, + }; + + const first = buildSbomResult({ ...base, fetchedAt: "2026-07-15T10:00:00.000Z" }); + const second = buildSbomResult({ ...base, fetchedAt: "2026-07-15T11:00:00.000Z" }); + + assert.deepEqual(first.storage_event, second.storage_event); +}); + +test("finalizes only a committed event that was read back", () => { + const generated = buildSbomResult({ + sourceHandle: "fixture://supported-package-lock.json", + lockfileType: "package-lock", + content: JSON.stringify(packageLock), + contentDigest: "sha256:fixture", + fetchedAt: "2026-07-15T10:00:00.000Z", + bytes: 321, + status: 200, + }); + const eventRef = "software_boms:fixture-app@1.2.3:1"; + const appendResult = { + status: "committed", + event_ref: eventRef, + after_version: 1, + provider: "sqlite-event-store", + provider_evidence: { adapter: "data.sqlite", storage_class: "sqlite" }, + }; + const readbackResult = { + status: "read", + events: [{ + event_ref: eventRef, + event_type: "sbom.generated", + idempotency_key: generated.stored_artifact_ref.idempotency_key, + event: generated.storage_event, + }], + }; + + const result = finalizeStoredResult({ generated, appendResult, readbackResult }); + assert.equal(result.stored_artifact_ref.event_ref, eventRef); + assert.equal(result.stored_artifact_ref.storage_class, "sqlite"); + assert.equal(result.stored_artifact_ref.readback_verified, true); +}); + +test("refuses to finalize a conflicted append", () => { + assert.throws( + () => finalizeStoredResult({ + generated: { stored_artifact_ref: { idempotency_key: "key" } }, + appendResult: { status: "conflict" }, + readbackResult: { events: [] }, + }), + /append did not commit/u, + ); +}); + +test("runs from the sidecars retained by registry publishing", () => { + const packageRoot = new URL(".", import.meta.url).pathname; + const stagedRoot = mkdtempSync(join(tmpdir(), "sbom-maker-published-")); + + try { + for (const relative of registryPublishableFiles(packageRoot)) { + const destination = join(stagedRoot, relative); + mkdirSync(dirname(destination), { recursive: true }); + cpSync(join(packageRoot, relative), destination); + } + + const execution = spawnSync(process.execPath, ["run.mjs"], { + cwd: stagedRoot, + encoding: "utf8", + env: { + ...process.env, + RUNX_INPUTS_JSON: JSON.stringify({ + source_handle: "fixture://supported-package-lock.json", + lockfile_type: "package-lock", + data_source_ref: "local://sbom-maker/harness", + store_id: "sbom-maker-publish-layout", + }), + }, + }); + + assert.equal(execution.status, 0, execution.stderr); + const output = JSON.parse(execution.stdout); + assert.equal(output.sbom_result.sbom.metadata.component.name, "fixture-app"); + } finally { + rmSync(stagedRoot, { recursive: true, force: true }); + } +}); + +function registryPublishableFiles(root) { + const excludedDirectories = new Set([ + ".git", + ".runx", + "assets", + "dist", + "fixtures", + "node_modules", + "src", + "target", + ]); + const nestedFileNames = new Set([ + "SKILL.md", + "X.yaml", + "manifest.json", + "run.mjs", + "run.js", + "harness.mjs", + "harness.js", + ]); + const files = ["SKILL.md", "X.yaml", "run.mjs", "finalize.mjs"]; + + for (const entry of readdirSync(root, { recursive: true })) { + const relative = String(entry); + const segments = relative.split("/"); + if (segments.some((segment) => excludedDirectories.has(segment))) continue; + if (!statSync(join(root, relative)).isFile()) continue; + if (segments.length > 1 && nestedFileNames.has(segments.at(-1))) files.push(relative); + } + + return [...new Set(files)].sort(); +} diff --git a/skills/sbom-maker/tools/data/local/manifest.json b/skills/sbom-maker/tools/data/local/manifest.json new file mode 100644 index 000000000..e26b058c3 --- /dev/null +++ b/skills/sbom-maker/tools/data/local/manifest.json @@ -0,0 +1,97 @@ +{ + "schema": "runx.tool.manifest.v1", + "name": "data.local", + "version": "0.1.0", + "description": "Local JSON event-store adapter for the provider-agnostic runx data operation envelope.", + "source": { + "type": "cli-tool", + "command": "node", + "args": ["./run.mjs"] + }, + "inputs": { + "operation": { + "type": "string", + "required": true, + "description": "append_event, read_events, read_projection, or list_stream_heads." + }, + "data_source_ref": { + "type": "string", + "required": true, + "description": "Stable logical data-source reference." + }, + "store_id": { + "type": "string", + "required": false, + "description": "Local fixture store id. Provider adapters may ignore this." + }, + "resource": { + "type": "string", + "required": true, + "description": "Declared resource, stream, table, keyspace, or projection name." + }, + "aggregate_id": { + "type": "string", + "required": false, + "description": "Stream or partition key. Omit for list_stream_heads." + }, + "expected_version": { + "type": "number", + "required": false, + "description": "Required current stream version for append_event." + }, + "idempotency_key": { + "type": "string", + "required": false, + "description": "Stable retry key for append_event." + }, + "event": { + "type": "json", + "required": false, + "description": "Domain event or transition packet for append_event." + }, + "observed_at": { + "type": "string", + "required": false, + "description": "Caller-observed ISO-8601 commit ordering time." + }, + "limit": { + "type": "number", + "required": false, + "default": 50, + "description": "Maximum events returned by read_events." + }, + "after_version": { + "type": "number", + "required": false, + "description": "Return ascending read_events strictly after this version; omit for the latest tail." + }, + "event_types": { + "type": "json", + "required": false, + "description": "Optional array of exact latest event types for list_stream_heads." + }, + "cursor": { + "type": "string", + "required": false, + "description": "Opaque list_stream_heads cursor." + } + }, + "scopes": ["runx:data:read", "runx:data:append"], + "runx": { + "artifacts": { + "named_emits": { + "data_operation_result": "runx.data.operation_result.v1" + }, + "wrap_as": "data_operation_result" + } + }, + "runtime": { + "command": "node", + "args": ["./run.mjs"] + }, + "output": { + "packet": "runx.data.operation_result.v1", + "wrap_as": "data_operation_result" + }, + "toolkit_version": "0.1.4" +} diff --git a/skills/sbom-maker/tools/data/local/run.mjs b/skills/sbom-maker/tools/data/local/run.mjs new file mode 100644 index 000000000..f78f835ba --- /dev/null +++ b/skills/sbom-maker/tools/data/local/run.mjs @@ -0,0 +1,454 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const SCHEMA = "runx.data.operation_result.v1"; +const PROVIDER = "local-json-event-store"; + +const inputs = readInputs(); +const operation = stringInput("operation"); + +let result; +if (operation === "append_event") { + result = appendEvent(inputs); +} else if (operation === "read_events") { + result = readEvents(inputs); +} else if (operation === "read_projection") { + result = readProjection(inputs); +} else if (operation === "list_stream_heads") { + result = listStreamHeads(inputs); +} else { + throw new Error("operation must be append_event, read_events, read_projection, or list_stream_heads"); +} + +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + +function readInputs() { + const raw = process.env.RUNX_INPUTS_PATH + ? fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8") + : process.env.RUNX_INPUTS_JSON || "{}"; + return JSON.parse(raw); +} + +function appendEvent(rawInputs) { + const envelope = baseEnvelope(rawInputs, "append_event"); + const expectedVersion = numberInput("expected_version"); + const idempotencyKey = stringInput("idempotency_key"); + const event = objectInput("event"); + const store = readStore(rawInputs); + const stream = streamFor(store, envelope.resource, envelope.aggregate_id); + const eventDigest = sha256Json(event); + const existing = stream.events.find((entry) => entry.idempotency_key === idempotencyKey); + + if (existing) { + if (existing.event_digest !== eventDigest) { + return conflictResult(envelope, stream, { + idempotency_key: idempotencyKey, + event_digest: eventDigest, + reason: "idempotency key was reused with different event content", + provider_evidence: providerEvidence(store, envelope), + }); + } + return { + ...envelope, + status: "idempotent_replay", + before_version: stream.version, + after_version: stream.version, + idempotency_key: idempotencyKey, + event_ref: existing.event_ref, + event_digest: existing.event_digest, + result_digest: sha256Json(existing), + projection_digest: projectionDigest(stream), + events: [], + rows: [], + redactions: [], + stop_conditions: [], + provider_evidence: providerEvidence(store, envelope), + }; + } + + if (stream.version !== expectedVersion) { + return conflictResult(envelope, stream, { + idempotency_key: idempotencyKey, + event_digest: eventDigest, + reason: `expected version ${expectedVersion}, got ${stream.version}`, + provider_evidence: providerEvidence(store, envelope), + }); + } + + const nextVersion = stream.version + 1; + const eventRef = `${envelope.resource}:${envelope.aggregate_id}:${nextVersion}`; + const record = { + event_ref: eventRef, + version: nextVersion, + event_type: eventType(event), + event, + event_digest: eventDigest, + idempotency_key: idempotencyKey, + committed_at: committedAt(rawInputs.observed_at), + }; + stream.events.push(record); + stream.version = nextVersion; + writeStore(rawInputs, store); + + return { + ...envelope, + status: "committed", + before_version: expectedVersion, + after_version: nextVersion, + idempotency_key: idempotencyKey, + event_ref: eventRef, + event_digest: eventDigest, + result_digest: sha256Json(record), + projection_digest: projectionDigest(stream), + events: [], + rows: [], + redactions: [], + stop_conditions: [], + provider_evidence: providerEvidence(store, envelope), + }; +} + +function conflictResult(envelope, stream, { idempotency_key, event_digest, reason, provider_evidence }) { + const stop = { + code: "conflict", + message: reason, + }; + return { + ...envelope, + status: "conflict", + before_version: stream.version, + after_version: stream.version, + idempotency_key, + event_ref: null, + event_digest, + result_digest: sha256Json(stop), + projection_digest: projectionDigest(stream), + events: [], + rows: [], + redactions: [], + stop_conditions: [stop], + provider_evidence, + }; +} + +function readEvents(rawInputs) { + const envelope = baseEnvelope(rawInputs, "read_events"); + const limit = boundedLimit(rawInputs.limit); + const afterVersion = optionalVersion(rawInputs.after_version, "after_version"); + const store = readStore(rawInputs); + const stream = streamFor(store, envelope.resource, envelope.aggregate_id); + const events = afterVersion === undefined + ? stream.events.slice(Math.max(0, stream.events.length - limit)) + : stream.events.filter((entry) => entry.version > afterVersion).slice(0, limit); + return { + ...envelope, + status: "read", + before_version: stream.version, + after_version: stream.version, + idempotency_key: null, + event_ref: null, + event_digest: null, + result_digest: sha256Json(events), + projection_digest: projectionDigest(stream), + events, + rows: events, + redactions: [], + stop_conditions: [], + provider_evidence: providerEvidence(store, envelope), + }; +} + +function readProjection(rawInputs) { + const envelope = baseEnvelope(rawInputs, "read_projection"); + const store = readStore(rawInputs); + const stream = streamFor(store, envelope.resource, envelope.aggregate_id); + const projection = { + aggregate_id: envelope.aggregate_id, + resource: envelope.resource, + version: stream.version, + event_count: stream.events.length, + last_event_ref: stream.events.at(-1)?.event_ref ?? null, + last_event_type: stream.events.at(-1)?.event_type ?? null, + event_digests: stream.events.map((entry) => entry.event_digest), + }; + return { + ...envelope, + status: "read", + before_version: stream.version, + after_version: stream.version, + idempotency_key: null, + event_ref: null, + event_digest: null, + result_digest: sha256Json(projection), + projection_digest: sha256Json(projection), + projection, + events: [], + rows: [], + redactions: [], + stop_conditions: [], + provider_evidence: providerEvidence(store, envelope), + }; +} + +function listStreamHeads(rawInputs) { + const envelope = baseEnvelope(rawInputs, "list_stream_heads"); + const store = readStore(rawInputs); + const limit = boundedHeadLimit(rawInputs.limit); + const cursor = decodeHeadCursor(rawInputs.cursor); + const eventTypes = new Set(optionalEventTypes(rawInputs.event_types)); + const streams = store.resources[envelope.resource]?.streams ?? {}; + const records = Object.entries(streams) + .map(([aggregateId, stream]) => { + const latest = stream.events.at(-1); + return latest ? { aggregate_id: aggregateId, ...latest } : undefined; + }) + .filter(Boolean) + .filter((entry) => eventTypes.size === 0 || eventTypes.has(entry.event_type)) + .sort(compareStreamHeads) + .filter((entry) => !cursor || compareStreamHeads(entry, cursor) > 0); + const hasMore = records.length > limit; + const rows = records.slice(0, limit); + const nextCursor = hasMore && rows.length > 0 ? encodeHeadCursor(rows.at(-1)) : null; + const page = { + limit, + count: rows.length, + has_more: hasMore, + next_cursor: nextCursor, + }; + return { + ...envelope, + status: "read", + before_version: 0, + after_version: 0, + idempotency_key: null, + event_ref: null, + event_digest: null, + result_digest: sha256Json({ rows, page }), + projection_digest: sha256Json(rows.map((row) => [row.aggregate_id, row.version, row.event_digest])), + projection: page, + events: [], + rows, + redactions: [], + stop_conditions: [], + provider_evidence: providerEvidence(store, envelope), + }; +} + +function baseEnvelope(rawInputs, operation) { + return { + schema: SCHEMA, + data_source_ref: stringInput("data_source_ref"), + provider: PROVIDER, + operation, + resource: safeName(stringInput("resource"), "resource"), + aggregate_id: operation === "list_stream_heads" + ? "stream-heads" + : safeName(stringInput("aggregate_id"), "aggregate_id"), + }; +} + +function streamFor(store, resource, aggregateId) { + store.resources[resource] ??= { streams: {} }; + store.resources[resource].streams[aggregateId] ??= { version: 0, events: [] }; + return store.resources[resource].streams[aggregateId]; +} + +function readStore(rawInputs) { + const file = storePath(rawInputs); + if (!fs.existsSync(file)) { + return { + schema: "runx.local_data_store.v1", + store_id: localStoreId(rawInputs), + resources: {}, + }; + } + const parsed = JSON.parse(fs.readFileSync(file, "utf8")); + if (!parsed || typeof parsed !== "object" || parsed.schema !== "runx.local_data_store.v1") { + throw new Error("local data store file has an invalid schema"); + } + parsed.resources ??= {}; + return parsed; +} + +function writeStore(rawInputs, store) { + const file = storePath(rawInputs); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`); + fs.renameSync(tmp, file); +} + +function storePath(rawInputs) { + const storeId = localStoreId(rawInputs); + return path.join(os.tmpdir(), "runx-data-store", `${storeId}.json`); +} + +function localStoreId(rawInputs) { + if (typeof rawInputs.store_id === "string" && rawInputs.store_id.trim().length > 0) { + return safeName(rawInputs.store_id, "store_id"); + } + const ref = typeof rawInputs.data_source_ref === "string" && rawInputs.data_source_ref.length > 0 + ? rawInputs.data_source_ref + : "default"; + return `source-${crypto.createHash("sha256").update(ref).digest("hex").slice(0, 24)}`; +} + +function providerEvidence(store, envelope) { + return { + provider: PROVIDER, + store_id: store.store_id, + resource: envelope.resource, + aggregate_id: envelope.aggregate_id, + storage_class: "local-fixture", + }; +} + +function projectionDigest(stream) { + return sha256Json({ + version: stream.version, + event_digests: stream.events.map((entry) => entry.event_digest), + }); +} + +function readValue(name) { + return inputs[name]; +} + +function stringInput(name) { + const value = readValue(name); + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${name} is required`); + } + return value.trim(); +} + +function numberInput(name) { + const value = readValue(name); + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative integer`); + } + return value; +} + +function objectInput(name) { + const value = readValue(name); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${name} must be an object`); + } + return value; +} + +function eventType(event) { + const explicit = safeEventToken(event.type) ?? safeEventToken(event.event_type); + if (explicit) return explicit; + const family = safeEventToken(event.effect_family); + const operation = safeEventToken(event.operation); + if (family && operation) return `${family}.${operation}`; + if (operation) return operation; + return "data.event"; +} + +function safeEventToken(value) { + if (typeof value !== "string") return undefined; + const text = value.trim(); + return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(text) ? text : undefined; +} + +function boundedLimit(value) { + if (value === undefined || value === null) return 50; + if (!Number.isInteger(value) || value < 1 || value > 500) { + throw new Error("limit must be an integer from 1 to 500"); + } + return value; +} + +function boundedHeadLimit(value) { + if (value === undefined || value === null) return 50; + if (!Number.isInteger(value) || value < 1 || value > 100) { + throw new Error("list_stream_heads limit must be an integer from 1 to 100"); + } + return value; +} + +function optionalEventTypes(value) { + if (value === undefined || value === null) return []; + if (!Array.isArray(value) || value.length > 20) { + throw new Error("event_types must be an array of at most 20 exact event types"); + } + return Array.from(new Set(value.map((entry) => { + const token = safeEventToken(entry); + if (!token) throw new Error("event_types contains an invalid event type"); + return token; + }))); +} + +function compareStreamHeads(left, right) { + const time = right.committed_at.localeCompare(left.committed_at); + return time === 0 ? left.aggregate_id.localeCompare(right.aggregate_id) : time; +} + +function encodeHeadCursor(row) { + return Buffer.from(JSON.stringify({ + committed_at: row.committed_at, + aggregate_id: row.aggregate_id, + }), "utf8").toString("base64url"); +} + +function decodeHeadCursor(value) { + if (value === undefined || value === null || value === "") return undefined; + if (typeof value !== "string" || value.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw new Error("cursor must be an opaque list_stream_heads cursor"); + } + try { + const decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) throw new Error("invalid cursor"); + if (typeof decoded.committed_at !== "string" || decoded.committed_at.length > 100 || Number.isNaN(Date.parse(decoded.committed_at))) { + throw new Error("invalid committed_at"); + } + return { + committed_at: decoded.committed_at, + aggregate_id: safeName(decoded.aggregate_id, "aggregate_id"), + }; + } catch { + throw new Error("cursor must be an opaque list_stream_heads cursor"); + } +} + +function optionalVersion(value, field) { + if (value === undefined || value === null) return undefined; + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${field} must be a non-negative integer`); + } + return value; +} + +function committedAt(value) { + if (value === undefined || value === null) return "1970-01-01T00:00:00.000Z"; + if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) { + throw new Error("observed_at must be ISO-8601"); + } + return new Date(value).toISOString(); +} + +function safeName(value, field) { + const text = String(value || "").trim(); + const pattern = field === "aggregate_id" + ? /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,191}$/ + : /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + if (!pattern.test(text)) { + throw new Error(`${field} must be a safe identifier`); + } + return text; +} + +function sha256Json(value) { + return `sha256:${crypto.createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +} + +function canonicalJson(value) { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`; +} diff --git a/skills/sbom-maker/tools/data/sqlite/README.md b/skills/sbom-maker/tools/data/sqlite/README.md new file mode 100644 index 000000000..aa4645e75 --- /dev/null +++ b/skills/sbom-maker/tools/data/sqlite/README.md @@ -0,0 +1,63 @@ +# data.sqlite + +`data.sqlite` is the durable local adapter for the provider-agnostic runx data +operation envelope. It is useful for dogfooding real stateful graphs without +standing up hosted infrastructure. Unbound `local://...` data sources resolve to +this adapter by default; pass `store_id` only when a fixture intentionally wants +the JSON `data.local` adapter instead. + +The adapter shells out to `sqlite3`. Set `RUNX_SQLITE_BIN` when the binary is not +on `PATH`. + +The adapter is selected through a data-source binding: + +```json +{ + "data_sources": { + "tenant://example/board": { + "adapter": "data.sqlite", + "database_path": ".runx/data/example-board.sqlite", + "resources": { + "board_events": { + "kind": "event_stream", + "partition_key": "aggregate_id" + } + } + } + } +} +``` + +Graphs still pass only `data_source_ref`, `resource`, `aggregate_id`, +`expected_version`, `idempotency_key`, and operation-specific inputs. The +binding chooses SQLite. + +For unbound `local://...` refs, runx derives a source-scoped database path under +`.runx/data/local-sources/`. When several sources intentionally share one +configured `database_path`, `data.sqlite` still isolates streams by +`data_source_ref`, `resource`, and `aggregate_id`. + +## Operations + +- `append_event` +- `read_events` +- `read_projection` + +Writes require `expected_version` and `idempotency_key`. A retry with the same +idempotency key and same event digest returns `idempotent_replay`. A retry with +the same idempotency key and different event digest returns `conflict`. + +## Path rules + +Relative `database_path` values resolve from `RUNX_CWD`, `INIT_CWD`, or the +current working directory. Absolute paths are rejected unless the binding sets +`allow_absolute_path: true`. + +Provider evidence never includes the absolute database path. + +## Resetting local state + +Delete the relevant file under `.runx/data/local-sources/` for default local +dogfood, or delete the configured `database_path` for a project-specific +binding. Do not reset by changing domain skill inputs; that hides replay +problems instead of clearing local storage. diff --git a/skills/sbom-maker/tools/data/sqlite/manifest.json b/skills/sbom-maker/tools/data/sqlite/manifest.json new file mode 100644 index 000000000..763253d2a --- /dev/null +++ b/skills/sbom-maker/tools/data/sqlite/manifest.json @@ -0,0 +1,102 @@ +{ + "schema": "runx.tool.manifest.v1", + "name": "data.sqlite", + "version": "0.1.0", + "description": "SQLite event-store adapter for the provider-agnostic runx data operation envelope.", + "source": { + "type": "cli-tool", + "command": "node", + "args": ["./run.mjs"] + }, + "inputs": { + "operation": { + "type": "string", + "required": true, + "description": "append_event, read_events, read_projection, or list_stream_heads." + }, + "data_source_ref": { + "type": "string", + "required": true, + "description": "Stable logical data-source reference." + }, + "data_source_binding": { + "type": "json", + "required": false, + "description": "Non-secret data-source binding injected by data.source." + }, + "database_path": { + "type": "string", + "required": false, + "description": "Local SQLite database path for direct harness use. Prefer data_source_binding.database_path." + }, + "resource": { + "type": "string", + "required": true, + "description": "Declared event resource or stream family." + }, + "aggregate_id": { + "type": "string", + "required": false, + "description": "Stream or partition key. Omit for list_stream_heads." + }, + "expected_version": { + "type": "number", + "required": false, + "description": "Required current stream version for append_event." + }, + "idempotency_key": { + "type": "string", + "required": false, + "description": "Stable retry key for append_event." + }, + "event": { + "type": "json", + "required": false, + "description": "Domain event or transition packet for append_event." + }, + "observed_at": { + "type": "string", + "required": false, + "description": "Caller-observed ISO-8601 commit ordering time." + }, + "limit": { + "type": "number", + "required": false, + "default": 50, + "description": "Maximum events returned by read_events." + }, + "after_version": { + "type": "number", + "required": false, + "description": "Return ascending read_events strictly after this version; omit for the latest tail." + }, + "event_types": { + "type": "json", + "required": false, + "description": "Optional array of exact latest event types for list_stream_heads." + }, + "cursor": { + "type": "string", + "required": false, + "description": "Opaque list_stream_heads cursor." + } + }, + "scopes": ["runx:data:read", "runx:data:append"], + "runx": { + "artifacts": { + "named_emits": { + "data_operation_result": "runx.data.operation_result.v1" + }, + "wrap_as": "data_operation_result" + } + }, + "runtime": { + "command": "node", + "args": ["./run.mjs"] + }, + "output": { + "packet": "runx.data.operation_result.v1", + "wrap_as": "data_operation_result" + }, + "toolkit_version": "0.1.4" +} diff --git a/skills/sbom-maker/tools/data/sqlite/run.mjs b/skills/sbom-maker/tools/data/sqlite/run.mjs new file mode 100644 index 000000000..3a84ea3e3 --- /dev/null +++ b/skills/sbom-maker/tools/data/sqlite/run.mjs @@ -0,0 +1,789 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const SCHEMA = "runx.data.operation_result.v1"; +const PROVIDER = "sqlite-event-store"; +const SQLITE_BIN = process.env.RUNX_SQLITE_BIN || "sqlite3"; + +const inputs = readInputs(); +const operation = stringInput("operation"); + +let result; +if (operation === "append_event") { + result = appendEvent(inputs); +} else if (operation === "read_events") { + result = readEvents(inputs); +} else if (operation === "read_projection") { + result = readProjection(inputs); +} else if (operation === "list_stream_heads") { + result = listStreamHeads(inputs); +} else { + throw new Error("operation must be append_event, read_events, read_projection, or list_stream_heads"); +} + +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + +function readInputs() { + const raw = process.env.RUNX_INPUTS_PATH + ? fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8") + : process.env.RUNX_INPUTS_JSON || "{}"; + return JSON.parse(raw); +} + +function appendEvent(rawInputs) { + const database = databasePath(rawInputs); + ensureSchema(database); + + const envelope = baseEnvelope(rawInputs, "append_event"); + const expectedVersion = numberInput("expected_version"); + const idempotencyKey = stringInput("idempotency_key"); + const event = objectInput("event"); + const eventDigest = sha256Json(event); + const current = currentVersion(database, envelope); + const existing = existingEvent(database, envelope, idempotencyKey); + + if (existing) { + if (existing.event_digest !== eventDigest) { + return conflictResult(envelope, current, { + idempotency_key: idempotencyKey, + event_digest: eventDigest, + reason: "idempotency key was reused with different event content", + provider_evidence: providerEvidence(envelope), + }); + } + return { + ...envelope, + status: "idempotent_replay", + before_version: current, + after_version: current, + idempotency_key: idempotencyKey, + event_ref: existing.event_ref, + event_digest: existing.event_digest, + result_digest: sha256Json(existing), + projection_digest: projectionDigest(database, envelope), + events: [], + rows: [], + redactions: [], + stop_conditions: [], + provider_evidence: providerEvidence(envelope), + }; + } + + if (current !== expectedVersion) { + return conflictResult(envelope, current, { + idempotency_key: idempotencyKey, + event_digest: eventDigest, + reason: `expected version ${expectedVersion}, got ${current}`, + provider_evidence: providerEvidence(envelope), + }); + } + + const nextVersion = current + 1; + const eventRef = `${envelope.resource}:${envelope.aggregate_id}:${nextVersion}`; + const record = { + event_ref: eventRef, + version: nextVersion, + event_type: eventType(event), + event, + event_digest: eventDigest, + idempotency_key: idempotencyKey, + committed_at: committedAt(rawInputs.observed_at), + }; + + try { + execSql(database, ` +BEGIN IMMEDIATE; +INSERT INTO runx_events ( + data_source_ref, + resource, + aggregate_id, + version, + idempotency_key, + event_ref, + event_type, + event_digest, + event_json, + committed_at +) VALUES ( + ${sqlString(envelope.data_source_ref)}, + ${sqlString(envelope.resource)}, + ${sqlString(envelope.aggregate_id)}, + ${nextVersion}, + ${sqlString(idempotencyKey)}, + ${sqlString(eventRef)}, + ${sqlString(record.event_type)}, + ${sqlString(eventDigest)}, + ${sqlString(JSON.stringify(event))}, + ${sqlString(record.committed_at)} +); +INSERT INTO runx_stream_heads ( + data_source_ref, + resource, + aggregate_id, + version, + event_ref, + event_type, + event_digest, + idempotency_key, + event_json, + committed_at +) VALUES ( + ${sqlString(envelope.data_source_ref)}, + ${sqlString(envelope.resource)}, + ${sqlString(envelope.aggregate_id)}, + ${nextVersion}, + ${sqlString(eventRef)}, + ${sqlString(record.event_type)}, + ${sqlString(eventDigest)}, + ${sqlString(idempotencyKey)}, + ${sqlString(JSON.stringify(event))}, + ${sqlString(record.committed_at)} +) +ON CONFLICT (data_source_ref, resource, aggregate_id) DO UPDATE SET + version = excluded.version, + event_ref = excluded.event_ref, + event_type = excluded.event_type, + event_digest = excluded.event_digest, + idempotency_key = excluded.idempotency_key, + event_json = excluded.event_json, + committed_at = excluded.committed_at; +COMMIT; +`); + } catch (error) { + const latest = currentVersion(database, envelope); + return conflictResult(envelope, latest, { + idempotency_key: idempotencyKey, + event_digest: eventDigest, + reason: `sqlite append failed after version check: ${error.message}`, + provider_evidence: providerEvidence(envelope), + }); + } + + return { + ...envelope, + status: "committed", + before_version: expectedVersion, + after_version: nextVersion, + idempotency_key: idempotencyKey, + event_ref: eventRef, + event_digest: eventDigest, + result_digest: sha256Json(record), + projection_digest: projectionDigest(database, envelope), + events: [], + rows: [], + redactions: [], + stop_conditions: [], + provider_evidence: providerEvidence(envelope), + }; +} + +function readEvents(rawInputs) { + const database = databasePath(rawInputs); + ensureSchema(database); + + const envelope = baseEnvelope(rawInputs, "read_events"); + const limit = boundedLimit(rawInputs.limit); + const afterVersion = optionalVersion(rawInputs.after_version, "after_version"); + const current = currentVersion(database, envelope); + const rows = afterVersion === undefined ? queryJson(database, ` +SELECT event_ref, version, event_type, event_digest, idempotency_key, committed_at, event_json +FROM runx_events +WHERE data_source_ref = ${sqlString(envelope.data_source_ref)} + AND resource = ${sqlString(envelope.resource)} + AND aggregate_id = ${sqlString(envelope.aggregate_id)} +ORDER BY version DESC +LIMIT ${limit}; +`).reverse() : queryJson(database, ` +SELECT event_ref, version, event_type, event_digest, idempotency_key, committed_at, event_json +FROM runx_events +WHERE data_source_ref = ${sqlString(envelope.data_source_ref)} + AND resource = ${sqlString(envelope.resource)} + AND aggregate_id = ${sqlString(envelope.aggregate_id)} + AND version > ${afterVersion} +ORDER BY version ASC +LIMIT ${limit}; +`); + const events = rows + .map((row) => ({ + event_ref: row.event_ref, + version: Number(row.version), + event_type: row.event_type, + event: JSON.parse(row.event_json), + event_digest: row.event_digest, + idempotency_key: row.idempotency_key, + committed_at: row.committed_at, + })); + + return { + ...envelope, + status: "read", + before_version: current, + after_version: current, + idempotency_key: null, + event_ref: null, + event_digest: null, + result_digest: sha256Json(events), + projection_digest: projectionDigest(database, envelope), + events, + rows: events, + redactions: [], + stop_conditions: [], + provider_evidence: providerEvidence(envelope), + }; +} + +function readProjection(rawInputs) { + const database = databasePath(rawInputs); + ensureSchema(database); + + const envelope = baseEnvelope(rawInputs, "read_projection"); + const eventRows = queryJson(database, ` +SELECT event_ref, event_type, event_digest +FROM runx_events +WHERE data_source_ref = ${sqlString(envelope.data_source_ref)} + AND resource = ${sqlString(envelope.resource)} + AND aggregate_id = ${sqlString(envelope.aggregate_id)} +ORDER BY version ASC; +`); + const projection = { + aggregate_id: envelope.aggregate_id, + resource: envelope.resource, + version: eventRows.length, + event_count: eventRows.length, + last_event_ref: eventRows.at(-1)?.event_ref ?? null, + last_event_type: eventRows.at(-1)?.event_type ?? null, + event_digests: eventRows.map((entry) => entry.event_digest), + }; + return { + ...envelope, + status: "read", + before_version: projection.version, + after_version: projection.version, + idempotency_key: null, + event_ref: null, + event_digest: null, + result_digest: sha256Json(projection), + projection_digest: sha256Json(projection), + projection, + events: [], + rows: [], + redactions: [], + stop_conditions: [], + provider_evidence: providerEvidence(envelope), + }; +} + +function listStreamHeads(rawInputs) { + const database = databasePath(rawInputs); + ensureSchema(database); + + const envelope = baseEnvelope(rawInputs, "list_stream_heads"); + const limit = boundedHeadLimit(rawInputs.limit); + const cursor = decodeHeadCursor(rawInputs.cursor); + const eventTypes = optionalEventTypes(rawInputs.event_types); + const cursorClause = cursor + ? `AND (committed_at < ${sqlString(cursor.committed_at)} OR (committed_at = ${sqlString(cursor.committed_at)} AND aggregate_id > ${sqlString(cursor.aggregate_id)}))` + : ""; + const eventTypeClause = eventTypes.length > 0 + ? `AND event_type IN (${eventTypes.map(sqlString).join(", ")})` + : ""; + const records = queryJson(database, ` +SELECT aggregate_id, version, event_ref, event_type, event_digest, idempotency_key, committed_at, event_json +FROM runx_stream_heads +WHERE data_source_ref = ${sqlString(envelope.data_source_ref)} + AND resource = ${sqlString(envelope.resource)} + ${eventTypeClause} + ${cursorClause} +ORDER BY committed_at DESC, aggregate_id ASC +LIMIT ${limit + 1}; +`).map(streamHeadRecord); + const hasMore = records.length > limit; + const rows = records.slice(0, limit); + const nextCursor = hasMore && rows.length > 0 + ? encodeHeadCursor(rows.at(-1)) + : null; + const page = { + limit, + count: rows.length, + has_more: hasMore, + next_cursor: nextCursor, + }; + return { + ...envelope, + status: "read", + before_version: 0, + after_version: 0, + idempotency_key: null, + event_ref: null, + event_digest: null, + result_digest: sha256Json({ rows, page }), + projection_digest: sha256Json(rows.map((row) => [row.aggregate_id, row.version, row.event_digest])), + projection: page, + events: [], + rows, + redactions: [], + stop_conditions: [], + provider_evidence: providerEvidence(envelope), + }; +} + +function conflictResult(envelope, currentVersionValue, { idempotency_key, event_digest, reason, provider_evidence }) { + const stop = { + code: "conflict", + message: reason, + }; + return { + ...envelope, + status: "conflict", + before_version: currentVersionValue, + after_version: currentVersionValue, + idempotency_key, + event_ref: null, + event_digest, + result_digest: sha256Json(stop), + projection_digest: `sha256:${"0".repeat(64)}`, + events: [], + rows: [], + redactions: [], + stop_conditions: [stop], + provider_evidence, + }; +} + +function baseEnvelope(rawInputs, operation) { + return { + schema: SCHEMA, + data_source_ref: stringInput("data_source_ref"), + provider: PROVIDER, + operation, + resource: safeName(stringInput("resource"), "resource"), + aggregate_id: operation === "list_stream_heads" + ? "stream-heads" + : safeName(stringInput("aggregate_id"), "aggregate_id"), + }; +} + +function ensureSchema(database) { + fs.mkdirSync(path.dirname(database), { recursive: true }); + execSql(database, ` +PRAGMA journal_mode = WAL; +PRAGMA busy_timeout = 5000; +CREATE TABLE IF NOT EXISTS runx_events ( + data_source_ref TEXT NOT NULL DEFAULT '', + resource TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + version INTEGER NOT NULL, + idempotency_key TEXT NOT NULL, + event_ref TEXT NOT NULL, + event_type TEXT NOT NULL, + event_digest TEXT NOT NULL, + event_json TEXT NOT NULL, + committed_at TEXT NOT NULL, + PRIMARY KEY (data_source_ref, resource, aggregate_id, version), + UNIQUE (data_source_ref, resource, aggregate_id, idempotency_key) +); +`); + migrateLegacySchema(database); + execSql(database, ` +CREATE TABLE IF NOT EXISTS runx_stream_heads ( + data_source_ref TEXT NOT NULL, + resource TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + version INTEGER NOT NULL, + event_ref TEXT NOT NULL, + event_type TEXT NOT NULL, + event_digest TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + event_json TEXT NOT NULL, + committed_at TEXT NOT NULL, + PRIMARY KEY (data_source_ref, resource, aggregate_id) +); +CREATE TABLE IF NOT EXISTS runx_data_store_migrations ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL +); +INSERT INTO runx_stream_heads ( + data_source_ref, + resource, + aggregate_id, + version, + event_ref, + event_type, + event_digest, + idempotency_key, + event_json, + committed_at +) +SELECT + events.data_source_ref, + events.resource, + events.aggregate_id, + events.version, + events.event_ref, + events.event_type, + events.event_digest, + events.idempotency_key, + events.event_json, + events.committed_at +FROM runx_events AS events +WHERE NOT EXISTS ( + SELECT 1 FROM runx_data_store_migrations WHERE version = 'stream-heads-v1' +) +AND events.version = ( + SELECT MAX(candidate.version) + FROM runx_events AS candidate + WHERE candidate.data_source_ref = events.data_source_ref + AND candidate.resource = events.resource + AND candidate.aggregate_id = events.aggregate_id +) +ON CONFLICT (data_source_ref, resource, aggregate_id) DO UPDATE SET + version = excluded.version, + event_ref = excluded.event_ref, + event_type = excluded.event_type, + event_digest = excluded.event_digest, + idempotency_key = excluded.idempotency_key, + event_json = excluded.event_json, + committed_at = excluded.committed_at +WHERE excluded.version > runx_stream_heads.version; +INSERT OR IGNORE INTO runx_data_store_migrations (version, applied_at) +VALUES ('stream-heads-v1', '1970-01-01T00:00:00.000Z'); +CREATE UNIQUE INDEX IF NOT EXISTS runx_events_stream_version_v1 + ON runx_events (data_source_ref, resource, aggregate_id, version); +CREATE UNIQUE INDEX IF NOT EXISTS runx_events_stream_idempotency_v1 + ON runx_events (data_source_ref, resource, aggregate_id, idempotency_key); +CREATE INDEX IF NOT EXISTS runx_stream_heads_recent_v1 + ON runx_stream_heads (data_source_ref, resource, committed_at DESC, aggregate_id ASC); +CREATE INDEX IF NOT EXISTS runx_stream_heads_type_recent_v1 + ON runx_stream_heads (data_source_ref, resource, event_type, committed_at DESC, aggregate_id ASC); +`); +} + +function migrateLegacySchema(database) { + const columns = queryJson(database, "PRAGMA table_info(runx_events);").map((column) => column.name); + if (columns.includes("data_source_ref")) return; + + execSql(database, ` +BEGIN IMMEDIATE; +ALTER TABLE runx_events RENAME TO runx_events_legacy_unscoped; +CREATE TABLE runx_events ( + data_source_ref TEXT NOT NULL, + resource TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + version INTEGER NOT NULL, + idempotency_key TEXT NOT NULL, + event_ref TEXT NOT NULL, + event_type TEXT NOT NULL, + event_digest TEXT NOT NULL, + event_json TEXT NOT NULL, + committed_at TEXT NOT NULL, + PRIMARY KEY (data_source_ref, resource, aggregate_id, version), + UNIQUE (data_source_ref, resource, aggregate_id, idempotency_key) +); +INSERT INTO runx_events ( + data_source_ref, + resource, + aggregate_id, + version, + idempotency_key, + event_ref, + event_type, + event_digest, + event_json, + committed_at +) +SELECT + '', + resource, + aggregate_id, + version, + idempotency_key, + event_ref, + event_type, + event_digest, + event_json, + committed_at +FROM runx_events_legacy_unscoped; +DROP TABLE runx_events_legacy_unscoped; +CREATE UNIQUE INDEX IF NOT EXISTS runx_events_stream_version_v1 + ON runx_events (data_source_ref, resource, aggregate_id, version); +CREATE UNIQUE INDEX IF NOT EXISTS runx_events_stream_idempotency_v1 + ON runx_events (data_source_ref, resource, aggregate_id, idempotency_key); +COMMIT; +`); +} + +function currentVersion(database, envelope) { + const rows = queryJson(database, ` +SELECT COALESCE(MAX(version), 0) AS version +FROM runx_events +WHERE data_source_ref = ${sqlString(envelope.data_source_ref)} + AND resource = ${sqlString(envelope.resource)} + AND aggregate_id = ${sqlString(envelope.aggregate_id)}; +`); + return Number(rows[0]?.version ?? 0); +} + +function existingEvent(database, envelope, idempotencyKey) { + const rows = queryJson(database, ` +SELECT event_ref, version, event_type, event_digest, idempotency_key, committed_at, event_json +FROM runx_events +WHERE data_source_ref = ${sqlString(envelope.data_source_ref)} + AND resource = ${sqlString(envelope.resource)} + AND aggregate_id = ${sqlString(envelope.aggregate_id)} + AND idempotency_key = ${sqlString(idempotencyKey)} +LIMIT 1; +`); + const row = rows[0]; + if (!row) return null; + return { + event_ref: row.event_ref, + version: Number(row.version), + event_type: row.event_type, + event: JSON.parse(row.event_json), + event_digest: row.event_digest, + idempotency_key: row.idempotency_key, + committed_at: row.committed_at, + }; +} + +function projectionDigest(database, envelope) { + const rows = queryJson(database, ` +SELECT version, event_digest +FROM runx_events +WHERE data_source_ref = ${sqlString(envelope.data_source_ref)} + AND resource = ${sqlString(envelope.resource)} + AND aggregate_id = ${sqlString(envelope.aggregate_id)} +ORDER BY version ASC; +`); + return sha256Json({ + version: rows.length, + event_digests: rows.map((entry) => entry.event_digest), + }); +} + +function providerEvidence(envelope) { + return { + provider: PROVIDER, + adapter: "data.sqlite", + data_source_ref_digest: sha256Json(envelope.data_source_ref), + resource: envelope.resource, + aggregate_id: envelope.aggregate_id, + storage_class: "sqlite", + }; +} + +function databasePath(rawInputs) { + const binding = rawInputs.data_source_binding && typeof rawInputs.data_source_binding === "object" && !Array.isArray(rawInputs.data_source_binding) + ? rawInputs.data_source_binding + : {}; + const rawPath = typeof binding.database_path === "string" && binding.database_path.trim().length > 0 + ? binding.database_path.trim() + : typeof rawInputs.database_path === "string" && rawInputs.database_path.trim().length > 0 + ? rawInputs.database_path.trim() + : null; + if (!rawPath) { + throw new Error("data.sqlite requires data_source_binding.database_path or database_path"); + } + const root = path.resolve(process.env.RUNX_CWD || process.env.INIT_CWD || process.cwd()); + const allowAbsolute = binding.allow_absolute_path === true || rawInputs.allow_absolute_path === true; + const resolved = path.isAbsolute(rawPath) ? path.resolve(rawPath) : path.resolve(root, rawPath); + if (path.isAbsolute(rawPath) && !allowAbsolute) { + throw new Error("data.sqlite absolute database_path requires allow_absolute_path=true in the operator-owned binding"); + } + if (!allowAbsolute && !isInside(root, resolved)) { + throw new Error("data.sqlite database_path must stay inside RUNX_CWD unless allow_absolute_path=true"); + } + return resolved; +} + +function execSql(database, sql) { + const result = spawnSync(SQLITE_BIN, ["-cmd", ".timeout 5000", database], { + input: sql, + encoding: "utf8", + maxBuffer: 1024 * 1024, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || `sqlite3 exited ${result.status}`).trim()); + } +} + +function queryJson(database, sql) { + const result = spawnSync(SQLITE_BIN, ["-cmd", ".timeout 5000", "-json", database], { + input: sql, + encoding: "utf8", + maxBuffer: 1024 * 1024, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || `sqlite3 exited ${result.status}`).trim()); + } + const text = result.stdout.trim(); + return text ? JSON.parse(text) : []; +} + +function sqlString(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} + +function readValue(name) { + return inputs[name]; +} + +function stringInput(name) { + const value = readValue(name); + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${name} is required`); + } + return value.trim(); +} + +function numberInput(name) { + const value = readValue(name); + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative integer`); + } + return value; +} + +function objectInput(name) { + const value = readValue(name); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${name} must be an object`); + } + return value; +} + +function eventType(event) { + const explicit = safeEventToken(event.type) ?? safeEventToken(event.event_type); + if (explicit) return explicit; + const family = safeEventToken(event.effect_family); + const operation = safeEventToken(event.operation); + if (family && operation) return `${family}.${operation}`; + if (operation) return operation; + return "data.event"; +} + +function safeEventToken(value) { + if (typeof value !== "string") return undefined; + const text = value.trim(); + return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(text) ? text : undefined; +} + +function boundedLimit(value) { + if (value === undefined || value === null) return 50; + if (!Number.isInteger(value) || value < 1 || value > 500) { + throw new Error("limit must be an integer from 1 to 500"); + } + return value; +} + +function boundedHeadLimit(value) { + if (value === undefined || value === null) return 50; + if (!Number.isInteger(value) || value < 1 || value > 100) { + throw new Error("list_stream_heads limit must be an integer from 1 to 100"); + } + return value; +} + +function optionalEventTypes(value) { + if (value === undefined || value === null) return []; + if (!Array.isArray(value) || value.length > 20) { + throw new Error("event_types must be an array of at most 20 exact event types"); + } + return Array.from(new Set(value.map((entry) => { + const token = safeEventToken(entry); + if (!token) throw new Error("event_types contains an invalid event type"); + return token; + }))); +} + +function streamHeadRecord(row) { + return { + aggregate_id: row.aggregate_id, + version: Number(row.version), + event_ref: row.event_ref, + event_type: row.event_type, + event: JSON.parse(row.event_json), + event_digest: row.event_digest, + idempotency_key: row.idempotency_key, + committed_at: row.committed_at, + }; +} + +function encodeHeadCursor(row) { + return Buffer.from(JSON.stringify({ + committed_at: row.committed_at, + aggregate_id: row.aggregate_id, + }), "utf8").toString("base64url"); +} + +function decodeHeadCursor(value) { + if (value === undefined || value === null || value === "") return undefined; + if (typeof value !== "string" || value.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw new Error("cursor must be an opaque list_stream_heads cursor"); + } + try { + const decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) throw new Error("invalid cursor"); + const committedAt = decoded.committed_at; + const aggregateId = decoded.aggregate_id; + if (typeof committedAt !== "string" || committedAt.length > 100 || Number.isNaN(Date.parse(committedAt))) { + throw new Error("invalid committed_at"); + } + return { + committed_at: committedAt, + aggregate_id: safeName(aggregateId, "aggregate_id"), + }; + } catch { + throw new Error("cursor must be an opaque list_stream_heads cursor"); + } +} + +function optionalVersion(value, field) { + if (value === undefined || value === null) return undefined; + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${field} must be a non-negative integer`); + } + return value; +} + +function committedAt(value) { + if (value === undefined || value === null) return "1970-01-01T00:00:00.000Z"; + if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) { + throw new Error("observed_at must be ISO-8601"); + } + return new Date(value).toISOString(); +} + +function safeName(value, field) { + const text = String(value || "").trim(); + const pattern = field === "aggregate_id" + ? /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,191}$/ + : /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + if (!pattern.test(text)) { + throw new Error(`${field} must be a safe identifier`); + } + return text; +} + +function sha256Json(value) { + return `sha256:${crypto.createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +} + +function canonicalJson(value) { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`; +} + +function isInside(root, candidate) { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +}