Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions .github/workflows/replay-runner-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Reproducible replay-runner image CI (#9214, epic #8534). Proves the pinned, minimal attested-backtest-run
# image actually BUILDS and RUNS correctly. The fast, cheap drift check (comparing the committed manifest's
# declared-inputs digest against a fresh one) is already wired into `npm run test:ci` (per-PR, blocking) --
# this workflow adds the one thing that check can't do without Docker: prove the pinned Dockerfile still
# builds and the resulting image actually produces an attested run.
#
# Push-to-main only, same reasoning as selfhost.yml's own build-boot job: a full Docker build is a slow,
# expensive check relative to this repo's PR volume, ci.yml's fast manifest-drift check already blocks the
# common failure mode (a source/lockfile/Dockerfile edit without updating the committed manifest) on every
# PR, and a build-breaking change is still caught within minutes of landing on main.
name: replay-runner-image

on:
push:
branches: [main]
paths:
- "scripts/replay-runner/**"
- "scripts/attested-backtest-run.ts"
- "scripts/attested-backtest-run-core.ts"
- "scripts/backtest-corpus-export-core.ts"
- "scripts/snp-attester.ts"
- "scripts/replay-runner-image-manifest.ts"
- "scripts/replay-runner-image-manifest-core.ts"
- "scripts/replay-runner-image-manifest.json"
- "packages/loopover-engine/src/calibration/**"
- "packages/loopover-engine/package.json"
- ".github/workflows/replay-runner-image.yml"

permissions:
contents: read

concurrency:
group: replay-runner-image-${{ github.sha }}
cancel-in-progress: true

jobs:
build-and-run:
name: build + attested-run smoke test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: .nvmrc
cache: "npm"

- name: Install deps
run: npm ci --ignore-scripts

- name: Verify the committed manifest matches the checked-out tree
run: npm run replay-runner-manifest:check

- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4

- name: Build the replay-runner image
run: |
docker buildx build \
--cache-from type=gha \
--cache-to type=gha,mode=max \
--load \
-f scripts/replay-runner/Dockerfile \
-t loopover-replay-runner:ci .

- name: Confirm the runtime user is non-root
run: |
user="$(docker run --rm --entrypoint whoami loopover-replay-runner:ci)"
test "$user" = "replay-runner"

- name: Build a throwaway corpus fixture
run: |
node --experimental-strip-types -e '
import { buildBacktestCorpusManifest } from "./scripts/backtest-corpus-export-core.ts";
import { writeFileSync } from "node:fs";
const cases = [{ targetKey: "acme/widgets#1", ruleId: "linked_issue_scope_mismatch", fired: true, overridden: false }];
writeFileSync("/tmp/ci-corpus.json", JSON.stringify(buildBacktestCorpusManifest("linked_issue_scope_mismatch", cases)));
'

- name: Run an attested pass with the sample attester, with NO network access
run: |
docker run --rm --network none \
-v /tmp/ci-corpus.json:/data/corpus.json:ro \
loopover-replay-runner:ci \
--rule-id linked_issue_scope_mismatch --corpus /data/corpus.json \
--head-sha 0000000000000000000000000000000000000001 \
--base-sha 0000000000000000000000000000000000000002 \
--repo acme/widgets --pr 1 \
| tee /tmp/ci-result.json
outcome_status="$(node -e 'console.log(JSON.parse(require("node:fs").readFileSync("/tmp/ci-result.json","utf8")).status)')"
test "$outcome_status" = "attested"

- name: Confirm the fail-closed path (TEE claimed, no reachable hardware)
run: |
set +e
docker run --rm --network none \
-v /tmp/ci-corpus.json:/data/corpus.json:ro \
loopover-replay-runner:ci \
--rule-id linked_issue_scope_mismatch --corpus /data/corpus.json \
--head-sha 0000000000000000000000000000000000000001 \
--base-sha 0000000000000000000000000000000000000002 \
--repo acme/widgets --pr 1 \
--attester snp --measurement "$(printf 'a%.0s' {1..64})" --runtime-claim tee
code=$?
set -e
test "$code" -eq 1

- name: Free disk
if: always()
run: docker image prune -af
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@
"docs:drift-check": "node --experimental-strip-types scripts/check-docs-drift.ts",
"coverage-boltons:check": "node --experimental-strip-types scripts/check-coverage-bolt-on-filenames.ts",
"import-specifiers:check": "node --experimental-strip-types scripts/check-import-specifiers.ts",
"replay-runner-manifest": "tsx scripts/replay-runner-image-manifest.ts",
"replay-runner-manifest:write": "tsx scripts/replay-runner-image-manifest.ts --write",
"replay-runner-manifest:check": "tsx scripts/replay-runner-image-manifest.ts --check",
"roadmap:drift-check": "node --experimental-strip-types scripts/check-roadmap-issue-drift.ts",
"branding-drift:check": "node --experimental-strip-types scripts/check-branding-drift.ts",
"branding-drift:update": "node --experimental-strip-types scripts/check-branding-drift.ts --update",
Expand Down Expand Up @@ -109,7 +112,7 @@
"test:smoke:browser:install": "playwright install chromium",
"test:smoke:browser": "node --experimental-strip-types scripts/smoke-ui-browser.ts",
"pretest:ci": "npm run check-node-version",
"test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
"test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run replay-runner-manifest:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
"test:release": "npm run test:ci && npm run changelog:check",
"test:release:mcp": "npm run test:ci",
"test:watch": "vitest",
Expand Down
12 changes: 12 additions & 0 deletions packages/loopover-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@
"./signals/slop": {
"types": "./dist/signals/slop.d.ts",
"default": "./dist/signals/slop.js"
},
"./calibration/attester": {
"types": "./dist/calibration/attester.d.ts",
"default": "./dist/calibration/attester.js"
},
"./calibration/attestation-envelope": {
"types": "./dist/calibration/attestation-envelope.d.ts",
"default": "./dist/calibration/attestation-envelope.js"
},
"./calibration/backtest-corpus": {
"types": "./dist/calibration/backtest-corpus.d.ts",
"default": "./dist/calibration/backtest-corpus.js"
}
},
"files": [
Expand Down
14 changes: 11 additions & 3 deletions scripts/attested-backtest-run-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,20 @@
// The fail-closed rule lives here rather than in the CLI because it is the security-relevant decision in this
// feature: a runtime that CLAIMS a TEE must never be allowed to record a run as if attestation succeeded when
// it did not (#9211). Keeping it pure is what lets it be exhaustively tested without hardware.
import type { AttestationEnvelope } from "@loopover/engine";

import { sqlStringLiteral } from "./backtest-logic-check-core";
import type { AttestationEnvelope } from "@loopover/engine/calibration/attestation-envelope";

export const ATTESTED_BACKTEST_EVENT_TYPE = "calibration.attested_backtest_run";

/** Single-quoted SQL string literal — mirrors backtest-corpus-export.ts's and backtest-logic-check-core.ts's
* own copies exactly. Duplicated here rather than imported from backtest-logic-check-core.ts on purpose:
* that file's own top-level `@loopover/engine` barrel import (unrelated exports it needs for its own
* purpose) would otherwise be dragged into this module's evaluation graph too -- and, via the runner's
* reproducible replay-runner image (#9214), into the measured container's trusted computing base, which is
* supposed to hold nothing beyond what replay itself needs. */
function sqlStringLiteral(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}

/** What the runtime claims about itself, independent of what the attester actually returned. `none` is an
* ordinary un-attested dev run; `tee` asserts the workload believes it is inside a TEE runtime class. */
export type RuntimeAttestationClaim = "none" | "tee";
Expand Down
10 changes: 9 additions & 1 deletion scripts/attested-backtest-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,15 @@ import { spawnSync } from "node:child_process";
import { randomBytes, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";

import { assembleAttestationEnvelope, createSampleAttester, type Attester, type BacktestCase } from "@loopover/engine";
// Subpath imports, not the engine barrel (#9214): the barrel's `export *` graph is reachable from
// miner/repo-map.ts, which has a top-level `import Parser from "web-tree-sitter"` -- a static import that
// forces web-tree-sitter (and, via its own on-disk resolution at call time, tree-sitter-wasms) onto any
// image that loads the barrel, even though this script never calls into repo-map's functions. The reproducible
// replay-runner image (scripts/replay-runner/Dockerfile) is measured inside an eventual TEE; minimizing what
// it depends on is a real security property (a smaller trusted computing base), not just a size optimization.
// Same rationale + mechanism as src/db/repositories.ts's `@loopover/engine/parse-pull-request-target-key`.
import { assembleAttestationEnvelope, createSampleAttester, type Attester } from "@loopover/engine/calibration/attester";
import type { BacktestCase } from "@loopover/engine/calibration/backtest-corpus";

import { checksumCases } from "./backtest-corpus-export-core";
import {
Expand Down
2 changes: 1 addition & 1 deletion scripts/backtest-corpus-export-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// without re-querying D1. No IO here — the CLI (backtest-corpus-export.ts) does the wrangler/D1 reads and the
// file write — so this stays unit-testable. Mirrors scripts/export-d1-core.ts's pure-core / thin-IO split.
import { createHash } from "node:crypto";
import type { BacktestCase } from "@loopover/engine";
import type { BacktestCase } from "@loopover/engine/calibration/backtest-corpus";

export type BacktestCorpusManifest = {
ruleId: string;
Expand Down
116 changes: 116 additions & 0 deletions scripts/replay-runner-image-manifest-core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Pure core for the replay-runner image's reproducibility manifest (#9214, epic #8534). Attestation over an
// unpinned, irreproducible workload is theater -- the launch measurement inside an envelope must correspond
// to an image anyone can independently rebuild and check. This module defines that check as a DECLARED-INPUTS
// digest (Dockerfile + lockfile + pinned base image + the exact source tree the image copies in), not as a
// digest of the BUILT Docker image itself.
//
// That choice is deliberate and documented (see replay-runner/README.md's "What is and isn't reproducible"
// section): `npm ci` against a committed lockfile and `tsc` compilation are both fully deterministic given
// identical inputs, so a declared-inputs digest is a sound proxy for "this exact image, rebuilt, is this exact
// image" -- but the DOCKER IMAGE ID itself (`docker inspect --format '{{.Id}}'`) is not claimed reproducible
// here: layer metadata (file mtimes, ownership bits as recorded by different Docker/BuildKit versions) is a
// known source of non-determinism this manifest does not attempt to solve. Anyone can independently verify
// the declared inputs never drifted; verifying the exact built image ID matches too is a stronger, separate
// claim this repo does not make.
import { createHash } from "node:crypto";

export type ReplayRunnerImageManifest = {
/** Bump when the digest's INPUT SET changes (a file added/removed from `sourceFiles`, or the hash algorithm
* changes) -- so an old manifest is never silently compared against a new definition of "the inputs". */
schemaVersion: 1;
/** The pinned base image reference, digest-qualified (`node:22-slim@sha256:...`) -- copied verbatim from the
* Dockerfile's `FROM` line, never re-derived, so a base-image bump is visible as a manifest diff. */
baseImageRef: string;
dockerfileSha256: string;
packageLockSha256: string;
/** Path -> sha256, sorted by path, of every source file the image's build stage copies in. A file added to
* the image without a matching entry here is exactly the drift this manifest exists to catch. */
sourceFiles: Record<string, string>;
/** Single sha256 over the canonicalized whole (see {@link buildReplayRunnerImageManifest}) -- the one value
* a third party compares to confirm nothing above drifted, without diffing every field by hand. */
digest: string;
};

function sha256(content: string): string {
return createHash("sha256").update(content).digest("hex");
}

/** Canonicalize the manifest's own comparable fields into deterministic JSON before hashing. `sourceFiles`
* is trusted to arrive PRE-SORTED -- {@link buildReplayRunnerImageManifest} (this function's only caller)
* already sorts it before calling in, so re-sorting here would be redundant, untestable defensive code (its
* "out of order" branch could never fire against the one real call site). The property-order stability this
* function provides is JSON.stringify's own guarantee for string-keyed objects, not a re-sort. */
function canonicalize(input: {
baseImageRef: string;
dockerfileSha256: string;
packageLockSha256: string;
sourceFiles: Record<string, string>;
}): string {
return JSON.stringify({
baseImageRef: input.baseImageRef,
dockerfileSha256: input.dockerfileSha256,
packageLockSha256: input.packageLockSha256,
sourceFiles: input.sourceFiles,
});
}

/**
* Build the manifest from raw inputs: the Dockerfile's and lockfile's own text, the pinned base image
* reference, and a map of every source-file path the image copies in to that file's own text. Pure -- no
* filesystem reads here, so the CLI (which does the reads) stays the only IO-touching layer, and this stays
* unit-testable without a real checkout.
*/
export function buildReplayRunnerImageManifest(input: {
baseImageRef: string;
dockerfileContent: string;
packageLockContent: string;
sourceFileContents: Record<string, string>;
}): ReplayRunnerImageManifest {
// Two-way, not the three-way `a < b ? -1 : a > b ? 1 : 0` idiom sibling modules use (export-d1-core.ts's
// canonicalizeRow, backtest-corpus-export-core.ts's checksumCases): Object.entries keys are always
// distinct, so the "equal" arm those siblings carry can never fire here and would be untestable dead code.
const sourceFiles = Object.fromEntries(
Object.entries(input.sourceFileContents)
.map(([path, content]) => [path, sha256(content)] as const)
.sort(([a], [b]) => (a < b ? -1 : 1)),
);
const dockerfileSha256 = sha256(input.dockerfileContent);
const packageLockSha256 = sha256(input.packageLockContent);
const digest = sha256(canonicalize({ baseImageRef: input.baseImageRef, dockerfileSha256, packageLockSha256, sourceFiles }));
return { schemaVersion: 1, baseImageRef: input.baseImageRef, dockerfileSha256, packageLockSha256, sourceFiles, digest };
}

export type ManifestDriftResult =
| { drifted: false }
| { drifted: true; reasons: string[] };

/**
* Compare a freshly-built manifest against the committed one. Reports EVERY differing field (base image,
* Dockerfile, lockfile, each added/removed/changed source file) rather than only the terminal digest
* mismatch, so a drift report tells a reviewer exactly what moved instead of just that something did.
*/
export function checkReplayRunnerImageManifestDrift(committed: ReplayRunnerImageManifest, fresh: ReplayRunnerImageManifest): ManifestDriftResult {
const reasons: string[] = [];
if (committed.schemaVersion !== fresh.schemaVersion) {
reasons.push(`schemaVersion: committed ${committed.schemaVersion}, fresh ${fresh.schemaVersion}`);
}
if (committed.baseImageRef !== fresh.baseImageRef) {
reasons.push(`baseImageRef: committed ${committed.baseImageRef}, fresh ${fresh.baseImageRef}`);
}
if (committed.dockerfileSha256 !== fresh.dockerfileSha256) {
reasons.push(`Dockerfile changed: committed ${committed.dockerfileSha256}, fresh ${fresh.dockerfileSha256}`);
}
if (committed.packageLockSha256 !== fresh.packageLockSha256) {
reasons.push(`package-lock.json changed: committed ${committed.packageLockSha256}, fresh ${fresh.packageLockSha256}`);
}
const committedPaths = new Set(Object.keys(committed.sourceFiles));
const freshPaths = new Set(Object.keys(fresh.sourceFiles));
for (const path of committedPaths) {
if (!freshPaths.has(path)) reasons.push(`source file removed: ${path}`);
else if (committed.sourceFiles[path] !== fresh.sourceFiles[path]) reasons.push(`source file changed: ${path}`);
}
for (const path of freshPaths) {
if (!committedPaths.has(path)) reasons.push(`source file added: ${path}`);
}
return reasons.length === 0 ? { drifted: false } : { drifted: true, reasons };
}
Loading
Loading