diff --git a/packages/cli/src/range-seal-bind.test.ts b/packages/cli/src/range-seal-bind.test.ts index 4b6a8b1..28a7854 100644 --- a/packages/cli/src/range-seal-bind.test.ts +++ b/packages/cli/src/range-seal-bind.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { writeConfig } from "./config.js"; @@ -17,6 +17,42 @@ import type { RangeSealReceipt } from "./types.js"; import { git, withTempRepo, writeFile, commitAll } from "./test-helpers.js"; describe("sealed head binding trust", () => { + it("ignores a stale seal after a newer pass without its own sealed tip", () => { + const { root, cleanup } = withTempRepo("kc-bind-read-supersede-"); + try { + writeFile(root, "f.txt", "base\n"); + commitAll(root, "sealed batch tip"); + const sealedTip = git(root, ["rev-parse", "HEAD"]); + mkdirSync(join(root, ".know-code"), { recursive: true }); + writeConfig(root, { ...DEFAULT_CONFIG, level: "lite", requireAttest: false }); + writeRangeSeal(root, { + version: 1, + diffHash: "a".repeat(64), + rangeFromOid: sealedTip, + commitCount: 1, + sealMode: "receipt", + gateKeyId: "unsigned", + sealedAt: "2026-01-01T00:00:00.000Z", + sealedHeadOid: sealedTip, + }); + writeGate(root, { + version: 1, + diffHash: "b".repeat(64), + level: "lite", + passedAt: "2026-01-02T00:00:00.000Z", + commitRange: `${sealedTip}..HEAD`, + baseRef: sealedTip, + headRef: sealedTip, + gatedTreeOid: git(root, ["rev-parse", "HEAD^{tree}"]), + }); + + assert.equal(sealedHeadBinding(root), null); + assert.equal(headMatchesRangeSeal(root), true); + } finally { + cleanup(); + } + }); + it("ignores unsigned forged range-seal when requireAttest is true", async () => { const attestHome = mkdtempSync(join(tmpdir(), "kc-bind-home-")); const prevHome = process.env.KNOW_CODE_ATTEST_HOME; @@ -78,6 +114,62 @@ describe("sealed head binding trust", () => { } }); + it("supersedes a signed standalone binding only with a newer signed unbound gate", async () => { + const attestHome = mkdtempSync(join(tmpdir(), "kc-bind-supersede-")); + const prevHome = process.env.KNOW_CODE_ATTEST_HOME; + const { root, cleanup } = withTempRepo("kc-bind-signed-supersede-"); + try { + process.env.KNOW_CODE_ATTEST_HOME = attestHome; + initAttestKey(root, "bind-pass"); + writeFile(root, "f.txt", "base\n"); + commitAll(root, "sealed batch tip"); + const sealedTip = git(root, ["rev-parse", "HEAD"]); + mkdirSync(join(root, ".know-code"), { recursive: true }); + writeConfig(root, { ...DEFAULT_CONFIG, level: "lite", requireAttest: true }); + + const binding = await sealPayload( + root, + { + version: 1, + sealedHeadOid: sealedTip, + boundAt: "2026-01-01T00:00:00.000Z", + }, + { passphrase: "bind-pass" }, + ); + writeFileSync( + join(root, ".know-code", "sealed-head-binding.json"), + `${JSON.stringify(binding, null, 2)}\n`, + ); + + const newerGate = { + version: 1 as const, + diffHash: "b".repeat(64), + level: "lite" as const, + passedAt: "2026-01-02T00:00:00.000Z", + commitRange: `${sealedTip}..HEAD`, + baseRef: sealedTip, + headRef: sealedTip, + gatedTreeOid: git(root, ["rev-parse", "HEAD^{tree}"]), + }; + // An agent-written/unsigned gate cannot supersede a human-signed binding. + writeGate(root, newerGate); + assert.equal(sealedHeadBinding(root), sealedTip); + + const signedGate = await sealPayload( + root, + newerGate as unknown as Record, + { passphrase: "bind-pass" }, + ); + writeGate(root, signedGate as typeof newerGate); + assert.equal(sealedHeadBinding(root), null); + } finally { + if (prevHome === undefined) delete process.env.KNOW_CODE_ATTEST_HOME; + else process.env.KNOW_CODE_ATTEST_HOME = prevHome; + rmSync(attestHome, { recursive: true, force: true }); + cleanup(); + } + }); + it("fresh pass consumes stale seal artifacts; active tip binding is kept", async () => { const attestHome = mkdtempSync(join(tmpdir(), "kc-bind-home-")); const prevHome = process.env.KNOW_CODE_ATTEST_HOME; diff --git a/packages/cli/src/range-seal-bind.ts b/packages/cli/src/range-seal-bind.ts index 6363e0f..1959400 100644 --- a/packages/cli/src/range-seal-bind.ts +++ b/packages/cli/src/range-seal-bind.ts @@ -65,19 +65,38 @@ function trustedSealedOid( } } +/** A newer valid pass without its own sealed tip supersedes stale seal artifacts. */ +function newerGateSupersedesSeal(repoRoot: string, sealedAt: string | undefined): boolean { + if (!sealedAt) return false; + const gate = readGateSafe(repoRoot); + if (!gate?.passedAt || gate.sealedHeadOid) return false; + if (readConfig(repoRoot).requireAttest) { + try { + assertSigned(repoRoot, "gate.json", gate as unknown as Record & { sig?: string; keyId?: string }); + } catch { + return false; + } + } + return Date.parse(gate.passedAt) > Date.parse(sealedAt); +} + /** Bound tip from range-seal, sealed-head-binding, or gate (redundant sources). */ export function sealedHeadBinding(repoRoot: string): string | null { const config = readConfig(repoRoot); // Prefer signed range-seal; then gate. Standalone binding file only when attest // is on (unsigned agent-minted binding must not bind HEAD when attest is off). + const seal = readRangeSeal(repoRoot); + const binding = readSealedHeadBindingFile(repoRoot); return ( - trustedSealedOid(repoRoot, "range-seal.json", readRangeSeal(repoRoot)) ?? + (!newerGateSupersedesSeal(repoRoot, seal?.sealedAt) + ? trustedSealedOid(repoRoot, "range-seal.json", seal) + : null) ?? (config.requireAttest - ? trustedSealedOid( + ? !newerGateSupersedesSeal(repoRoot, binding?.boundAt) ? trustedSealedOid( repoRoot, "sealed-head-binding.json", - readSealedHeadBindingFile(repoRoot), - ) + binding, + ) : null : null) ?? trustedSealedOid(repoRoot, "gate.json", readGateSafe(repoRoot)) );