fix(genbi): identify Warble by how it was resolved, not by one hash

On the npm-package path the attestation's warble.binarySha256 was a constant.
@warble/cli's bin is a 73-byte trampoline that is byte-identical across every
release; the native executable is downloaded during install into the package's
own node_modules/.bin_real. So the field could not tell 0.6.0 from 0.9.0, and
the per-boot re-hash it was meant to feed re-hashed that same constant — an
executable swapped after the gate ran booted cleanly.

The block is now a discriminated union on how Warble was resolved. A package
install records its version, the pnpm-lock.yaml integrity for that version, a
hash of the in-package resolution logic (run-warble.js, binary.js,
binary-install.js, package.json), and a hash of what that logic extracted. A
checkout keeps the single binary hash, whose meaning was always correct.

The producer and the boot-time verifier derive this through one shared module
rather than a copy each: they must agree exactly, or a launch that verified
refuses to start. Comparison requires the exact key set, so an attestation
carrying a field this build does not understand fails closed instead of
matching on the subset it recognises.

Shown by mutation rather than by the suite passing. Swapping .bin_real/warble
while the trampoline stays byte-identical now fails the boot check where it used
to pass; editing binary-install.js fails it; changing the declared version fails
it with a named reason. The package arm is driven end to end through the real
gate and the real boot verifier, because unit-testing the identity function
alone would leave the CLI-to-attestation-to-boot wiring uncovered — every other
fixture in that suite takes the checkout arm, so the suite could have stayed
green while that path broke.

Still integrity, not provenance: package provenance, and anything fetched at
runtime from outside the package such as the Hub archive, remain uncovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jax Liu
2026-09-02 18:50:28 +08:00
co-authored by Claude Opus 5
parent da44c60a07
commit 804924d9c2
7 changed files with 368 additions and 64 deletions
+20 -12
View File
@@ -110,18 +110,26 @@ pass `--warble-bin` (or `warbleBin`) explicitly**; that is tier 1 and beats
everything.
**This is also how the attested live-BFF flow below identifies Warble.** The
launch gate's attestation records only `warble.binarySha256` — the content
hash of the resolved binary — not which commit or checkout produced it: the
claim is "this is the binary that was verified," not "this binary was built
from this commit, from this tree." `--warble-bin` accepts either the pinned
package's binary or one from a Warble checkout with no difference in how it's
verified: both resolve to a binary that gets hashed the same way, and the gate
no longer requires a `--warble-root` git checkout to hold it. A binary that
does live inside a checkout still works exactly as before — that's how you
attest against your own in-progress Warble build (see the next section) — but
it isn't a requirement any more. What the gate defends against — a binary
swapped after it was attested — is caught by rehashing the same binary path
every time the BFF boots, not by where the binary lives on disk.
attestation names Warble by *how it was resolved*, not by which commit built it:
the claim is "this is which Warble was verified," not "this binary was built from
this tree." For an installed package it records the version, the `pnpm-lock.yaml`
integrity for that version, a hash of the package's own resolution logic
(`run-warble.js`, `binary.js`, `binary-install.js`, `package.json`), and a hash of
what that logic extracted into the package's `node_modules/.bin_real`. For a Warble
checkout it records the content hash of the built executable, as before.
The split exists because the two are not the same kind of thing. `@warble/cli`'s
`bin` is a small trampoline that is byte-identical in every release; the native
executable is downloaded during install. Hashing the resolved `--warble-bin` path
therefore identified nothing on the package path — it could not tell 0.6.0 from
0.9.0 — while on a checkout that same hash is the whole story.
`--warble-bin` accepts either, and a binary that lives inside a checkout still works
exactly as before — that is how you attest against your own in-progress Warble build
(see the next section). What the gate defends against — either binary being swapped
after it was attested — is caught by re-deriving the same identity every time the BFF
boots. **Not covered**: package provenance (npm Sigstore), and anything fetched at
runtime from outside the package, such as the Hub archive downloaded on first compile.
Because pnpm silently accepts (and exits 0 on) an unmet peer-dependency range
between these packages — e.g. an `@warble/codex-local` built against a
+28 -8
View File
@@ -13,10 +13,21 @@ export interface LaunchAttestationPublic {
/** Profiles and their committed IRs live in this package, so their identity is attested here. */
readonly runtimeInputs: { readonly profileTreeSha256: string; readonly setupIrSha256: string; readonly enrichIrSha256: string; readonly analysisIrSha256: string };
};
/** Identified by content hash alone: a pinned npm package has no commit or tree to name. */
readonly warble: {
readonly binarySha256: string;
};
/**
* Identified by how Warble was resolved (decision-87). A pinned npm package reaches its
* executable through a trampoline whose hash is identical across every release, so it is named
* by version, lockfile integrity, its resolver files and the extracted executable. A checkout
* points straight at the built executable, so one content hash is its whole identity.
*/
readonly warble:
| {
readonly resolution: "package";
readonly version: string;
readonly integrity: string;
readonly resolverSha256: string;
readonly extractedTreeSha256: string;
}
| { readonly resolution: "checkout"; readonly binarySha256: string };
readonly runtime:
| { readonly mode: "subscription"; readonly provider: "claude"; readonly dispatcher: "claude-agent-sdk"; readonly agentSdkSha256: string }
| { readonly mode: "subscription"; readonly provider: "codex"; readonly dispatcher: "codex-local"; readonly codexLocalSha256: string; readonly source: "standalone" | "npm:@openai/codex"; readonly executablePathDigest: string; readonly sourceClosureSha256: string; readonly version: string; readonly executableSha256: string };
@@ -38,7 +49,8 @@ function exactKeys(value: UnknownRecord, keys: readonly string[]): boolean {
function digest(value: unknown): value is string { return typeof value === "string" && /^[a-f0-9]{64}$/.test(value); }
function commit(value: unknown): value is string { return typeof value === "string" && value.length > 0 && !value.includes("/") && !value.includes("\\"); }
function codexVersion(value: unknown): value is string { return typeof value === "string" && /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(value); }
function semanticVersion(value: unknown): value is string { return typeof value === "string" && /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(value); }
function integrity(value: unknown): value is string { return typeof value === "string" && /^sha512-[A-Za-z0-9+/]+={0,2}$/.test(value); }
/**
* Validates an exact public shape and returns a newly-allocated projection.
@@ -51,14 +63,20 @@ export function projectPublicLaunchAttestation(value: unknown, expectedMode?: "b
: ["version", "mode", "genbi", "warble", "runtime", "bff", "ui"])) throw new Error("local launch attestation public shape is invalid");
if (value.version !== "genbi-launch-attestation/v1" || value.mode !== "bootstrap" || (expectedMode !== undefined && value.mode !== expectedMode)) throw new Error("local launch attestation public shape is invalid");
if (!record(value.genbi) || !exactKeys(value.genbi, ["rootDigest", "commit", "treeIdentity", "runtimeInputs"]) || !digest(value.genbi.rootDigest) || !commit(value.genbi.commit) || !digest(value.genbi.treeIdentity) || !record(value.genbi.runtimeInputs) || !exactKeys(value.genbi.runtimeInputs, ["profileTreeSha256", "setupIrSha256", "enrichIrSha256", "analysisIrSha256"]) || !digest(value.genbi.runtimeInputs.profileTreeSha256) || !digest(value.genbi.runtimeInputs.setupIrSha256) || !digest(value.genbi.runtimeInputs.enrichIrSha256) || !digest(value.genbi.runtimeInputs.analysisIrSha256)) throw new Error("local launch attestation public shape is invalid");
if (!record(value.warble) || !exactKeys(value.warble, ["binarySha256"]) || !digest(value.warble.binarySha256)) throw new Error("local launch attestation public shape is invalid");
if (!record(value.warble)) throw new Error("local launch attestation public shape is invalid");
const warblePackage = exactKeys(value.warble, ["resolution", "version", "integrity", "resolverSha256", "extractedTreeSha256"])
&& value.warble.resolution === "package" && semanticVersion(value.warble.version) && integrity(value.warble.integrity)
&& digest(value.warble.resolverSha256) && digest(value.warble.extractedTreeSha256);
const warbleCheckout = exactKeys(value.warble, ["resolution", "binarySha256"])
&& value.warble.resolution === "checkout" && digest(value.warble.binarySha256);
if (!warblePackage && !warbleCheckout) throw new Error("local launch attestation public shape is invalid");
if (!record(value.runtime) || value.runtime.mode !== "subscription") throw new Error("local launch attestation public shape is invalid");
const claudeRuntime = exactKeys(value.runtime, ["mode", "provider", "dispatcher", "agentSdkSha256"])
&& value.runtime.provider === "claude" && value.runtime.dispatcher === "claude-agent-sdk" && digest(value.runtime.agentSdkSha256);
const codexRuntime = exactKeys(value.runtime, ["mode", "provider", "dispatcher", "codexLocalSha256", "source", "executablePathDigest", "sourceClosureSha256", "version", "executableSha256"])
&& value.runtime.provider === "codex" && value.runtime.dispatcher === "codex-local" && digest(value.runtime.codexLocalSha256)
&& (value.runtime.source === "standalone" || value.runtime.source === "npm:@openai/codex") && digest(value.runtime.executablePathDigest)
&& digest(value.runtime.sourceClosureSha256) && codexVersion(value.runtime.version) && digest(value.runtime.executableSha256);
&& digest(value.runtime.sourceClosureSha256) && semanticVersion(value.runtime.version) && digest(value.runtime.executableSha256);
if (!claudeRuntime && !codexRuntime) throw new Error("local launch attestation public shape is invalid");
if (!record(value.bff) || !exactKeys(value.bff, ["entrySha256", "closureSha256"]) || !digest(value.bff.entrySha256) || !digest(value.bff.closureSha256)) throw new Error("local launch attestation public shape is invalid");
if (!record(value.ui) || !exactKeys(value.ui, ["rootDigest", "commit", "treeIdentity"]) || !digest(value.ui.rootDigest) || !commit(value.ui.commit) || !digest(value.ui.treeIdentity)) throw new Error("local launch attestation public shape is invalid");
@@ -66,7 +84,9 @@ export function projectPublicLaunchAttestation(value: unknown, expectedMode?: "b
version: value.version,
mode: value.mode,
genbi: { rootDigest: value.genbi.rootDigest, commit: value.genbi.commit, treeIdentity: value.genbi.treeIdentity, runtimeInputs: { profileTreeSha256: value.genbi.runtimeInputs.profileTreeSha256, setupIrSha256: value.genbi.runtimeInputs.setupIrSha256, enrichIrSha256: value.genbi.runtimeInputs.enrichIrSha256, analysisIrSha256: value.genbi.runtimeInputs.analysisIrSha256 } },
warble: { binarySha256: value.warble.binarySha256 },
warble: warblePackage
? { resolution: "package", version: value.warble.version as string, integrity: value.warble.integrity as string, resolverSha256: value.warble.resolverSha256 as string, extractedTreeSha256: value.warble.extractedTreeSha256 as string }
: { resolution: "checkout", binarySha256: value.warble.binarySha256 as string },
runtime: claudeRuntime
? { mode: "subscription", provider: "claude", dispatcher: "claude-agent-sdk", agentSdkSha256: value.runtime.agentSdkSha256 as string }
: { mode: "subscription", provider: "codex", dispatcher: "codex-local", codexLocalSha256: value.runtime.codexLocalSha256 as string, source: value.runtime.source as "standalone" | "npm:@openai/codex", executablePathDigest: value.runtime.executablePathDigest as string, sourceClosureSha256: value.runtime.sourceClosureSha256 as string, version: value.runtime.version as string, executableSha256: value.runtime.executableSha256 as string },
+12 -8
View File
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { accessSync, constants, existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { warbleIdentity, sameWarbleIdentity } from "./warble-identity.mjs";
const fail = (message) => { process.stderr.write(`error: ${message}\n`); process.exit(1); };
const file = process.env.WREN_GENBI_LAUNCH_ATTESTATION;
@@ -82,13 +83,13 @@ if (a.genbi?.commit !== genbi.commit || a.genbi?.rootDigest !== genbi.rootDigest
if (!process.env.WREN_HARNESS_WORKSPACE_ROOT) fail("BFF mode/root does not match local launch attestation");
const modeInput = canonicalBootstrapRoot(process.env.WREN_HARNESS_WORKSPACE_ROOT);
if (a.mode !== "bootstrap" || modeInput !== a?.local?.modeInput) fail("BFF mode/root does not match local launch attestation");
// Warble and its dispatcher binaries are identified by content hash alone (see the
// binarySha256/agentSdkSha256/codexLocalSha256 checks below), not by living inside a
// required checkout root: a pinned npm package install has no working tree to be dirty,
// and its hash is its identity. There is nothing to run cleanGitRoot against for it
// any more — an exact-path match against the local attestation plus a content hash is
// the same standard already applied to codexBin below, which was never containment- or
// git-checked either.
// Warble is identified by how it was resolved (decision-87), not by hashing whatever WARBLE_BIN
// points at: on the package path that is a trampoline whose hash is the same in every release, so
// re-hashing it detected nothing. The package arm re-derives version, lockfile integrity, the
// resolver files and the extracted executable here, so a swap of the real executable after the
// gate ran is caught at this boot rather than only by the operator's one-off compile probe. The
// dispatcher binaries below are real entry points, not trampolines, so their content hashes were
// always meaningful and are unchanged.
const inputs = {};
for (const [env, key, kind, containerRoot] of [
["WREN_HARNESS_WARBLE_BIN", "warbleBin", "file", undefined],
@@ -102,7 +103,10 @@ for (const [env, key, kind, containerRoot] of [
if ((containerRoot !== undefined && !contained(containerRoot, canonical)) || canonical !== a?.local?.[key]) fail(`BFF ${env} does not match local launch attestation`);
inputs[key] = canonical;
}
if (sha256(inputs.warbleBin) !== a?.warble?.binarySha256) fail("BFF Warble binary does not match local launch attestation");
let warbleNow;
try { warbleNow = warbleIdentity(inputs.warbleBin, genbi.root, (candidate) => fail(`BFF Warble package must not contain a symlink: ${candidate}`)); }
catch (error) { fail(`BFF Warble binary does not match local launch attestation: ${error.message}`); }
if (!sameWarbleIdentity(warbleNow, a?.warble)) fail("BFF Warble binary does not match local launch attestation");
const identities = a?.genbi?.runtimeInputs;
if (!identities || hashTree(inputs.profile) !== identities.profileTreeSha256 || sha256(inputs.setupIr) !== identities.setupIrSha256 || sha256(inputs.enrichIr) !== identities.enrichIrSha256 || sha256(inputs.analysisIr) !== identities.analysisIrSha256) fail("BFF profile runtime input content does not match local launch attestation");
if (a?.runtime?.mode !== "subscription" || process.env.WREN_HARNESS_MODE !== "subscription" || process.env.WREN_HARNESS_PROVIDER !== a?.runtime?.provider) fail("BFF runtime does not match local launch attestation");
+33 -24
View File
@@ -6,6 +6,7 @@
* credentials: all Warble calls compile/dispatch local files only.
*/
import { createHash } from "node:crypto";
import { warbleIdentity } from "./warble-identity.mjs";
import { spawnSync } from "node:child_process";
import {
accessSync,
@@ -44,8 +45,8 @@ function usage(message) {
"Usage: pnpm run verify:launch -- --workspace-root <directory> --runtime subscription:claude|subscription:codex --warble-bin <binary> [runtime inputs]",
" Claude runtime: --agent-sdk-bin <binary>",
" Codex runtime: --codex-local-bin <binary> --codex-bin <exact-codex-executable>",
" Warble and its dispatcher binaries are identified by content hash, not by where they",
" live: a pinned npm package install and a Warble checkout both work — see verifyLocalLaunch.",
" Warble is identified by how it was resolved -- an installed package by version, lockfile",
" integrity and what it extracted, a checkout by binary content hash. Both work.",
" An existing project is adopted through the running app, not selected here.",
"Optional: --profile <dir> --setup-ir <file> --enrich-ir <file> --analysis-ir <file>",
" Profiles and their committed IRs live in this package's own profiles/ tree, not the Warble checkout.",
@@ -82,13 +83,13 @@ function parseArgs(argv) {
// gating a bootstrap launch instead would attest a tuple they never asked for.
if (options.project !== undefined) usage("--project is no longer supported — an existing project is adopted through the running app");
if (options.mode !== undefined) usage("--mode is no longer supported — the BFF has a single boot mode");
// Warble is identified by content hash (see writeAttestation), not by living inside a
// Warble is identified by how it was resolved (see writeAttestation), not by living inside a
// required checkout: a pinned npm package has no worktree to name. Rejecting the flag by
// name (same pattern as --project/--mode above) instead of silently ignoring it matters
// because a caller still passing it believes it is constraining or validating the
// binary's source, and a silently-ignored flag would attest a binary whose provenance was
// never actually checked the way the caller expects.
if (options.warbleRoot !== undefined) usage("--warble-root is no longer supported — the Warble binary is identified by content hash, not by a required checkout root");
if (options.warbleRoot !== undefined) usage("--warble-root is no longer supported — Warble is identified by how it was resolved, not by a required checkout root");
for (const required of ["workspaceRoot", "runtime", "warbleBin"]) if (!options[required]) usage(`--${required.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)} is required`);
if (options.runtime !== "subscription:claude" && options.runtime !== "subscription:codex") usage("--runtime must be subscription:claude or subscription:codex");
if (options.runtime === "subscription:claude") {
@@ -172,6 +173,15 @@ function hashTree(root) {
});
visit(root); return digest.digest("hex");
}
function resolveWarbleIdentity(binary, repoRoot) {
try {
return warbleIdentity(binary, repoRoot, (candidate) => { throw new GateError("provenance", `Warble package must not contain a symlink: ${candidate}`); });
} catch (error) {
if (error instanceof GateError) throw error;
throw new GateError("provenance", error.message);
}
}
function forEachSorted(entries, callback) { for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) callback(entry); }
function bootstrapRoot(value) {
@@ -403,15 +413,14 @@ function writeAttestation(result) {
treeIdentity: result.ui.git.treeIdentity,
runtimeInputs: result.profiles.runtimeInputs,
},
// Identified by content hash alone, not by source: a Warble binary can come from a
// pinned npm package (no commit or tree to name) or a developer's own checkout, and
// the two are treated identically here. What used to be a dirty-checkout /
// containment check against a required --warble-root is now just this hash, taken at
// write time and re-verified against the same binary path at every BFF boot
// (verify-bff-attestation.mjs) — a binary swapped after this attestation was written
// is caught there. What is no longer detected: a package binary's own supply chain
// (e.g. npm provenance) isn't verified here — see decision on the attestation shape.
warble: { binarySha256: result.warble.binarySha256 },
// Warble is identified by how it was resolved (decision-87), not by one hash of whatever
// WARBLE_BIN points at. On the package path that path is a 73-byte trampoline whose hash is
// identical across every release, so it identified nothing; the package arm records the
// version, the lockfile integrity, the resolver files and the extracted executable instead.
// The checkout arm keeps the single binary hash, whose meaning is unchanged.
// Still out of scope: package provenance (npm Sigstore) and anything fetched at runtime from
// outside the package, such as the Hub archive.
warble: result.warble.identity,
runtime: result.runtimeBinding.runtime,
bff: { entrySha256: result.bff.build.entrySha256, closureSha256: result.bff.build.closureSha256 },
ui: { rootDigest: result.ui.git.rootDigest, commit: result.ui.git.commit, treeIdentity: result.ui.git.treeIdentity },
@@ -435,16 +444,16 @@ function writeAttestation(result) {
export async function verifyLocalLaunch(options) {
const mode = validateMode(options);
const genbi = git(packageRoot, "GenBI package");
// Warble and its dispatcher binaries are identified by content hash alone, not by where
// they live on disk (see `warble` in the result below and writeAttestation's comment).
// There is no required checkout root to validate them against and no dirty-checkout
// check to run: a pinned npm package install has no working tree to be dirty, and its
// hash *is* its identity. What that used to buy — evidence the binary belongs to the
// attested source, and that it wasn't swapped after verification — is now provided by
// rehashing the same binary path at every BFF boot (verify-bff-attestation.mjs), which
// fails closed when the content differs (exercised by the mutation test in
// test/local-launch-gate.test.ts). A binary that happens to live inside a Warble
// checkout (local Warble development) still works: it is just a binary like any other.
// Warble is identified by how it was resolved, not by where it lives on disk (decision-87;
// see `warble` in the result below). There is no required checkout root and no dirty-checkout
// check: a pinned npm package install has no working tree to be dirty. What that used to buy --
// evidence the binary was not swapped after verification -- comes from re-deriving the same
// identity at every BFF boot (verify-bff-attestation.mjs), which fails closed when it differs.
// The package arm covers the extracted executable itself, so this now holds on that path too;
// it did not while a single hash of the resolved bin path was all that was recorded, because
// on the package path that path is a trampoline identical in every release. The dispatcher
// binaries are real entry points rather than trampolines, so their content hashes were always
// meaningful and keep their existing semantics.
const warbleBin = regularFile(options.warbleBin, "Warble binary");
try { accessSync(warbleBin, constants.X_OK); } catch { throw new GateError("provenance", "Warble binary is not executable"); }
let agentSdkBin;
@@ -490,7 +499,7 @@ export async function verifyLocalLaunch(options) {
mode: "bootstrap",
ui: { packageRoot, git: genbi, launchCommand: "pnpm dev" },
bff: { packageRoot, git: genbi, entry: dist.entry, build: dist, launchCommand: "pnpm run start:bff" },
warble: { binary: warbleBin, binarySha256: hash(warbleBin) },
warble: { binary: warbleBin, identity: resolveWarbleIdentity(warbleBin, genbi.root) },
profiles: { root: profiles, runtimeInputs, profile, setupIr, enrichIr, analysisIr },
...(agentSdkBin ? { agentSdk: { binary: agentSdkBin, binarySha256: hash(agentSdkBin) } } : {}),
...(codexLocalBin && codexBin ? {
+112
View File
@@ -0,0 +1,112 @@
/**
* How the launch gate names the Warble in use (decision-87).
*
* The gate is written once and re-checked at every BFF boot, in two separate scripts. Both must
* agree exactly or a launch that verified will refuse to start, so this lives in one module rather
* than being copied into each -- a contract duplicated across two files is the failure mode this
* package has already been bitten by once.
*
* A pinned npm package does not point at Warble directly: `@warble/cli`'s bin is a 73-byte
* trampoline that is byte-identical across every published release, and the native executable is
* downloaded post-install into the package's own `node_modules/.bin_real`. Hashing the resolved
* bin path therefore identifies nothing on that path -- it cannot tell 0.6.0 from 0.9.0. A
* checkout points straight at the built executable, where that one hash is the whole identity.
*/
import { createHash } from "node:crypto";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
/** Files whose content decides where the executable comes from, and whether it is checked. */
export const RESOLVER_FILES = ["binary-install.js", "binary.js", "package.json", "run-warble.js"];
class WarbleIdentityError extends Error {}
function hashTree(root, onSymlink) {
const digest = createHash("sha256");
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const candidate = path.join(directory, entry.name);
if (entry.isSymbolicLink()) onSymlink(candidate);
else if (entry.isDirectory()) visit(candidate);
else if (entry.isFile()) { digest.update(path.relative(root, candidate)); digest.update("\0"); digest.update(readFileSync(candidate)); }
}
};
visit(root);
return digest.digest("hex");
}
/** The `@warble/cli` package that owns `binary`, or undefined when it is not a package install. */
export function warbleCliPackageRoot(binary) {
let directory = path.dirname(binary);
for (;;) {
const manifest = path.join(directory, "package.json");
if (existsSync(manifest)) {
try { if (JSON.parse(readFileSync(manifest, "utf8")).name === "@warble/cli") return directory; }
catch { /* unreadable manifest: treat as not the package path */ }
return undefined;
}
const parent = path.dirname(directory);
if (parent === directory) return undefined;
directory = parent;
}
}
function lockfileIntegrity(repoRoot, version) {
const lockfile = path.join(repoRoot, "pnpm-lock.yaml");
if (!existsSync(lockfile)) throw new WarbleIdentityError("pnpm-lock.yaml not found: the installed Warble package cannot be identified");
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = readFileSync(lockfile, "utf8").match(new RegExp(`^ '@warble/cli@${escaped}':\\s*\\n\\s*resolution: \\{integrity: (sha512-[^}]+)\\}`, "m"));
if (!match) throw new WarbleIdentityError(`pnpm-lock.yaml has no integrity entry for @warble/cli@${version}`);
return match[1];
}
/**
* Identity of the Warble reached through `binary`, as the attestation records it.
*
* Covers, on the package path: which release (`version`), which published package bytes
* (`integrity`), the in-package resolution logic (`resolverSha256`), and what actually executes
* (`extractedTreeSha256`). Does NOT cover package provenance, nor anything fetched at runtime from
* outside the package -- notably the Hub archive, which is downloaded on first compile and cached
* elsewhere.
*/
export function warbleIdentity(binary, repoRoot, onSymlink) {
const packageRoot = warbleCliPackageRoot(binary);
if (packageRoot === undefined) {
return { resolution: "checkout", binarySha256: createHash("sha256").update(readFileSync(binary)).digest("hex") };
}
const version = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8")).version;
if (typeof version !== "string" || version.length === 0) throw new WarbleIdentityError("@warble/cli package.json declares no version");
const extracted = path.join(packageRoot, "node_modules", ".bin_real");
if (!existsSync(extracted)) throw new WarbleIdentityError("@warble/cli has no extracted executable: the package was never installed");
const digest = createHash("sha256");
for (const name of RESOLVER_FILES) {
const resolverFile = path.join(packageRoot, name);
if (!existsSync(resolverFile)) throw new WarbleIdentityError(`the installed @warble/cli is missing ${name}`);
digest.update(name); digest.update("\0"); digest.update(readFileSync(resolverFile));
}
return {
resolution: "package",
version,
integrity: lockfileIntegrity(repoRoot, version),
resolverSha256: digest.digest("hex"),
// ~12MB, measured at 8ms: no reason to leave what actually runs uncovered.
extractedTreeSha256: hashTree(extracted, onSymlink),
};
}
/**
* True when two identity records name the same Warble in the same way.
*
* Requires the exact key set, not merely agreement on the keys this function knows about: an
* attestation carrying an unrecognised field is one this build does not understand, and a gate
* whose whole purpose is exactness should refuse it rather than compare the subset it recognises.
*/
export function sameWarbleIdentity(left, right) {
if (!left || !right || left.resolution !== right.resolution) return false;
const keys = left.resolution === "package"
? ["resolution", "version", "integrity", "resolverSha256", "extractedTreeSha256"]
: ["resolution", "binarySha256"];
const exact = (value) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
if (!exact(left) || !exact(right)) return false;
return keys.every((key) => typeof left[key] === "string" && left[key] === right[key]);
}
+70 -12
View File
@@ -27,7 +27,7 @@ function attestationFixture() {
version: "genbi-launch-attestation/v1" as const,
mode: "bootstrap" as const,
genbi: { rootDigest: digest, commit: "abc123", treeIdentity: digest, runtimeInputs: { profileTreeSha256: digest, setupIrSha256: digest, enrichIrSha256: digest, analysisIrSha256: digest } },
warble: { binarySha256: digest },
warble: { resolution: "checkout" as const, binarySha256: digest },
runtime: { mode: "subscription" as const, provider: "claude" as const, dispatcher: "claude-agent-sdk" as const, agentSdkSha256: digest },
bff: { entrySha256: digest, closureSha256: digest },
ui: { rootDigest: digest, commit: "abc123", treeIdentity: digest },
@@ -207,14 +207,35 @@ process.stdout.write(JSON.stringify({ manifest_version: '0.1', target: 'codex:lo
writeFileSync(codexBin, "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'codex-cli 0.146.0'; else exit 97; fi\n"); chmodSync(codexBin, 0o700);
writeFileSync(path.join(codexPackage, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.146.0" }));
writeFileSync(staleCodexBin, "#!/bin/sh\necho 'codex-cli 0.145.0'\n"); chmodSync(staleCodexBin, 0o700);
// Warble's binary is identified by content hash alone now (see verify-local-launch.mjs);
// the fixture no longer git-inits its directory, so every test using it by default
// exercises the no-checkout-at-all case (e.g. an npm-package-resolved binary). The
// git-checkout case is covered explicitly by "accepts a Warble binary that lives inside
// a git checkout, dirty or not" below.
// The fixture no longer git-inits its directory, so a binary taken straight from it is not
// inside any checkout. That is the *checkout* arm of the identity split all the same: a bare
// binary with no owning @warble/cli package is identified by its own content hash, which is
// what that arm has always meant. The genuinely package-shaped case needs a package tree
// around the binary and is built by warbleCliPackageInstall below.
return { root, warble, bin, agentSdk, staleAgentSdk, codexLocal, staleCodexLocal, codexPackage, codexBin, staleCodexBin };
}
/**
* The shape a pinned npm install actually has: a trampoline as the package's bin, the resolution
* logic beside it, and the native executable downloaded into the package's own
* node_modules/.bin_real. The version is read from this package's own pin so the lockfile lookup
* resolves against the real pnpm-lock.yaml, as it does in a real launch.
*/
function warbleCliPackageInstall(root: string, executableSource: string) {
const pinned = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8")).dependencies["@warble/cli"];
const installed = path.join(root, "node_modules", "@warble", "cli");
const extracted = path.join(installed, "node_modules", ".bin_real");
mkdirSync(extracted, { recursive: true });
const executable = path.join(extracted, "warble");
writeFileSync(executable, readFileSync(executableSource)); chmodSync(executable, 0o700);
const trampoline = path.join(installed, "run-warble.js");
writeFileSync(trampoline, '#!/bin/sh\nexec "$(dirname "$0")/node_modules/.bin_real/warble" "$@"\n'); chmodSync(trampoline, 0o700);
writeFileSync(path.join(installed, "package.json"), JSON.stringify({ name: "@warble/cli", version: pinned, bin: { warble: "run-warble.js" } }));
writeFileSync(path.join(installed, "binary.js"), "module.exports = require('./binary-install');\n");
writeFileSync(path.join(installed, "binary-install.js"), "// verifies the download against a baked-in digest\n");
return { pinned, trampoline, executable };
}
function run(args: string[], warble: string, env: Record<string, string> = {}) {
return spawnSync(process.execPath, [verifier, ...args, "--runtime", "subscription:claude", "--agent-sdk-bin", path.join(warble, "warble-agent-sdk")], { encoding: "utf8", env: { ...process.env, NODE_ENV: "test", ...env } });
}
@@ -482,11 +503,48 @@ describe("local GenBI launch gate", () => {
expect(result.stdout).toContain("launch gate PASSED");
});
it("accepts a Warble binary that is not inside any Warble checkout at all, as when resolved from an installed npm package", () => {
// Simulates the shape of a pinned npm-installed @warble/cli binary: it lives under
// node_modules, inside no Warble checkout, with nothing named "warble" about its
// containing directory. The gate must accept it purely on the strength of its content
// hash (see writeAttestation/verifyLocalLaunch) — that is the whole point of this change.
// The package arm, driven through the real gate and the real boot verifier rather than through
// warbleIdentity() alone. Without this, the CLI -> attestation -> boot-check wiring for a package
// install has no regression cover: every other fixture here takes the checkout arm, so the suite
// could stay green while that path broke. The mutation at the end is the point -- swapping the
// downloaded executable while the trampoline stays byte-identical is precisely what used to pass.
it("records the package arm end to end and catches an executable swapped behind the trampoline", async () => {
const { root, warble, bin, agentSdk } = fixture();
const workspace = path.join(root, "workspace"); mkdirSync(workspace);
const install = warbleCliPackageInstall(root, bin);
const selected = await verifyLocalLaunch(gateOptions({ skipBuild: true, mode: "bootstrap", workspaceRoot: workspace, warbleBin: install.trampoline }, warble));
const attested = JSON.parse(readFileSync(selected.attestation.file, "utf8")).warble;
expect(attested.resolution).toBe("package");
expect(attested.version).toBe(install.pinned);
expect(attested.integrity).toMatch(/^sha512-/);
expect(attested.resolverSha256).toMatch(/^[a-f0-9]{64}$/);
expect(attested.extractedTreeSha256).toMatch(/^[a-f0-9]{64}$/);
expect(attested.binarySha256).toBeUndefined();
const env = { ...process.env, WREN_GENBI_LAUNCH_ATTESTATION: selected.attestation.file, WREN_HARNESS_WORKSPACE_ROOT: workspace, WREN_HARNESS_WARBLE_BIN: install.trampoline, WREN_HARNESS_PROFILE: path.join(profiles, "genbi-default"), WREN_HARNESS_SETUP_IR: path.join(profiles, "genbi-setup", "ir.golden.json"), WREN_HARNESS_ENRICH_IR: path.join(profiles, "genbi-enrich-context", "ir.golden.json"), WREN_HARNESS_ANALYSIS_IR: path.join(profiles, "genbi-default", "ir.golden.json"), WREN_HARNESS_MODE: "subscription", WREN_HARNESS_PROVIDER: "claude", WREN_HARNESS_AGENT_SDK_BIN: agentSdk };
expect(spawnSync(process.execPath, [bffVerifier], { cwd: packageRoot, encoding: "utf8", env }).status).toBe(0);
const trampolineBefore = createHash("sha256").update(readFileSync(install.trampoline)).digest("hex");
const original = readFileSync(install.executable);
try {
writeFileSync(install.executable, "#!/bin/sh\necho 'IMPOSTOR WARBLE'\n"); chmodSync(install.executable, 0o700);
expect(createHash("sha256").update(readFileSync(install.trampoline)).digest("hex")).toBe(trampolineBefore);
const tampered = spawnSync(process.execPath, [bffVerifier], { cwd: packageRoot, encoding: "utf8", env });
expect(tampered.status).toBe(1);
expect(tampered.stderr).toContain("BFF Warble binary does not match local launch attestation");
} finally {
writeFileSync(install.executable, original); chmodSync(install.executable, 0o700);
}
expect(spawnSync(process.execPath, [bffVerifier], { cwd: packageRoot, encoding: "utf8", env }).status).toBe(0);
});
it("accepts a loose binary under node_modules that no @warble/cli package owns", () => {
// A binary that lives under node_modules but has no owning @warble/cli package.json is not
// the package arm: with nothing to read a version or a lockfile entry from, it is identified
// by its own content hash, exactly as a checkout binary is. The real package shape — a
// trampoline whose hash says nothing, with the executable downloaded beside it — is covered
// by "records the package arm end to end" below.
const { root, warble, bin } = fixture();
const packageBinDir = path.join(root, "node_modules", ".bin");
mkdirSync(packageBinDir, { recursive: true });
@@ -601,7 +659,7 @@ describe("local GenBI launch gate", () => {
const distRoot = path.join(packageRoot, "dist-server"); const entry = path.join(distRoot, "server", "bin.js"); const module = path.join(distRoot, "server", "app.js");
const original = readFileSync(module); const attestation = path.join(mkdtempSync(path.join(os.tmpdir(), "genbi-attestation-")), "attestation.json"); dirs.push(path.dirname(attestation));
const digest = (value: Buffer) => createHash("sha256").update(value).digest("hex"); const placeholder = "0".repeat(64);
writeFileSync(attestation, JSON.stringify({ version: "genbi-launch-attestation/v1", mode: "bootstrap", genbi: { rootDigest: placeholder, commit: "fixture", treeIdentity: placeholder, runtimeInputs: { profileTreeSha256: placeholder, setupIrSha256: placeholder, enrichIrSha256: placeholder, analysisIrSha256: placeholder } }, warble: { binarySha256: placeholder }, runtime: { mode: "subscription", provider: "claude", dispatcher: "claude-agent-sdk", agentSdkSha256: placeholder }, bff: { entrySha256: digest(readFileSync(entry)), closureSha256: hashTree(distRoot) }, ui: { rootDigest: placeholder, commit: "fixture", treeIdentity: placeholder } }));
writeFileSync(attestation, JSON.stringify({ version: "genbi-launch-attestation/v1", mode: "bootstrap", genbi: { rootDigest: placeholder, commit: "fixture", treeIdentity: placeholder, runtimeInputs: { profileTreeSha256: placeholder, setupIrSha256: placeholder, enrichIrSha256: placeholder, analysisIrSha256: placeholder } }, warble: { resolution: "checkout", binarySha256: placeholder }, runtime: { mode: "subscription", provider: "claude", dispatcher: "claude-agent-sdk", agentSdkSha256: placeholder }, bff: { entrySha256: digest(readFileSync(entry)), closureSha256: hashTree(distRoot) }, ui: { rootDigest: placeholder, commit: "fixture", treeIdentity: placeholder } }));
try {
writeFileSync(module, Buffer.concat([original, Buffer.from("\n// tampered fixture\n")]));
const result = spawnSync(process.execPath, [entry], { cwd: packageRoot, encoding: "utf8", env: { ...process.env, WREN_GENBI_LAUNCH_ATTESTATION: attestation, WREN_HARNESS_WORKSPACE_ROOT: path.join(path.dirname(attestation), "workspace") } });
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
// @ts-expect-error -- plain .mjs module shared with the gate scripts
import { warbleIdentity, sameWarbleIdentity, RESOLVER_FILES } from "../scripts/warble-identity.mjs";
const INTEGRITY = "sha512-KWIE5Ax/VzyJNxOFnuaHlu7imx5uhU9WU+S5iEmIv1F8jrbGq2aDFxKuNsujCZqFy+v1gPWa9PNGOXCQwC6edg==";
/**
* A stand-in for an installed `@warble/cli`: the trampoline, the resolution logic beside it, and
* the executable those download into the package's own node_modules/.bin_real.
*/
function packageInstall(overrides: { version?: string; resolver?: string; executable?: string } = {}) {
const repoRoot = mkdtempSync(path.join(tmpdir(), "warble-identity-"));
const packageRoot = path.join(repoRoot, "node_modules", "@warble", "cli");
mkdirSync(packageRoot, { recursive: true });
const version = overrides.version ?? "0.9.0";
writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ name: "@warble/cli", version, bin: { warble: "run-warble.js" } }));
writeFileSync(path.join(packageRoot, "run-warble.js"), '#!/usr/bin/env node\nrequire("./binary").run("warble");\n');
writeFileSync(path.join(packageRoot, "binary.js"), "module.exports = {};\n");
writeFileSync(path.join(packageRoot, "binary-install.js"), overrides.resolver ?? "// verifies the download\n");
const extracted = path.join(packageRoot, "node_modules", ".bin_real");
mkdirSync(extracted, { recursive: true });
writeFileSync(path.join(extracted, "warble"), overrides.executable ?? "the real executable\n");
writeFileSync(path.join(repoRoot, "pnpm-lock.yaml"), `packages:\n\n '@warble/cli@${version}':\n resolution: {integrity: ${INTEGRITY}}\n hasBin: true\n`);
return { repoRoot, bin: path.join(packageRoot, "run-warble.js") };
}
const identify = (install: { repoRoot: string; bin: string }) =>
warbleIdentity(install.bin, install.repoRoot, () => { throw new Error("unexpected symlink"); });
describe("warble identity", () => {
it("names a package install by version, integrity, resolver and what was extracted", () => {
const identity = identify(packageInstall());
expect(identity).toEqual({
resolution: "package",
version: "0.9.0",
integrity: INTEGRITY,
resolverSha256: expect.stringMatching(/^[a-f0-9]{64}$/),
extractedTreeSha256: expect.stringMatching(/^[a-f0-9]{64}$/),
});
});
// The defect this replaces: every published @warble/cli ships a byte-identical trampoline, so
// hashing the resolved bin path produced the same value for every release. Two installs that
// differ only in which Warble they are must not be given the same identity.
it("distinguishes two releases whose trampolines are byte-identical", () => {
const older = identify(packageInstall({ version: "0.8.0" }));
const newer = identify(packageInstall({ version: "0.9.0" }));
expect(older.version).not.toBe(newer.version);
expect(sameWarbleIdentity(older, newer)).toBe(false);
});
it("changes when the extracted executable is swapped and the trampoline is not", () => {
const attested = identify(packageInstall());
const swapped = identify(packageInstall({ executable: "#!/bin/sh\necho IMPOSTOR\n" }));
expect(swapped.extractedTreeSha256).not.toBe(attested.extractedTreeSha256);
expect(sameWarbleIdentity(attested, swapped)).toBe(false);
});
it("changes when the logic that fetches and checks the download is edited", () => {
const attested = identify(packageInstall());
const neutered = identify(packageInstall({ resolver: "// checksum check removed\n" }));
expect(neutered.resolverSha256).not.toBe(attested.resolverSha256);
expect(sameWarbleIdentity(attested, neutered)).toBe(false);
});
it("covers every file that decides where the executable comes from", () => {
expect([...RESOLVER_FILES].sort()).toEqual(["binary-install.js", "binary.js", "package.json", "run-warble.js"]);
});
it("refuses an install the lockfile cannot account for", () => {
const install = packageInstall();
writeFileSync(path.join(install.repoRoot, "pnpm-lock.yaml"), "packages:\n");
expect(() => identify(install)).toThrow(/no integrity entry for @warble\/cli@0\.9\.0/);
});
// An attestation carrying a field this build does not know about is not a match on the subset
// it happens to recognise: the gate exists to be exact, so an unrecognised shape fails closed.
it("refuses an identity that carries an unrecognised field", () => {
const identity = identify(packageInstall());
expect(sameWarbleIdentity(identity, { ...identity, somethingNewer: "x" })).toBe(false);
expect(sameWarbleIdentity(identity, identity)).toBe(true);
});
it("names a checkout binary by its own content, unchanged", () => {
const root = mkdtempSync(path.join(tmpdir(), "warble-checkout-"));
const bin = path.join(root, "warble");
writeFileSync(bin, "built from a checkout\n");
expect(warbleIdentity(bin, root, () => {})).toEqual({ resolution: "checkout", binarySha256: expect.stringMatching(/^[a-f0-9]{64}$/) });
});
});