diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01c7d95..421c2b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,9 @@ jobs: env: KNOW_CODE_ATTEST_PASSPHRASE: ci-smoke-attest-passphrase KNOW_CODE_ATTEST_HOME: ${{ runner.temp }}/know-code-attest + + - name: CLI smoke (CI verify, no local seals) + run: npm run smoke:verify + env: + KNOW_CODE_ATTEST_PASSPHRASE: ci-smoke-verify-passphrase + KNOW_CODE_ATTEST_HOME: ${{ runner.temp }}/know-code-verify-attest diff --git a/.github/workflows/know-code.yml b/.github/workflows/know-code.yml index a9b717c..bf0d7cb 100644 --- a/.github/workflows/know-code.yml +++ b/.github/workflows/know-code.yml @@ -14,6 +14,9 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + # Default pull_request checkout is a merge commit (no trailers). + # Verify must run on the PR tip that carries Know-Code-Verified. + ref: ${{ github.event.pull_request.head.sha }} - uses: actions/setup-node@v4 with: @@ -26,5 +29,10 @@ jobs: npm run build npm install -g ./packages/cli + - name: Ensure requireTrailer for CI + run: | + mkdir -p .know-code + printf '{\n "level": "standard",\n "baseBranch": "main",\n "requireTrailer": true\n}\n' > .know-code/config.json + - name: Verify Know-Code-Verified trailer run: know-code verify diff --git a/action/action.yml b/action/action.yml index e4b04f4..ca9f217 100644 --- a/action/action.yml +++ b/action/action.yml @@ -1,9 +1,9 @@ name: know-code verify description: >- - Verify Know-Code-Verified commit trailers. Primary check: HEAD message - matches the current tree hash (empty-tree→index). Secondary: trailers on - commits ahead of the base branch. Use on pull_request only — on a direct - push to the base branch there is no merge-base range to verify. + Verify Know-Code-Verified commit trailers against grounded tree hashes + (index and merge-base→write-tree). Callers must checkout the PR tip + (ref: github.event.pull_request.head.sha) with fetch-depth: 0 — the + default merge commit has no trailers. Use on pull_request only. author: chtnnh branding: icon: shield diff --git a/package-lock.json b/package-lock.json index ecc2005..bb45c6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20613,6 +20613,7 @@ "version": "0.3.0", "license": "MIT", "bin": { + "kc": "bin/know-code.js", "know-code": "bin/know-code.js" }, "devDependencies": { diff --git a/package.json b/package.json index f95306c..e1aac94 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build:docs": "npm run build -w website", "test": "npm run test -w @chtnnh/know-code", "smoke": "bash scripts/smoke-enforcement.sh", + "smoke:verify": "bash scripts/smoke-verify-ci.sh", "link-skills": "node scripts/link-skills.mjs", "sync-hooks": "node scripts/sync-hooks.mjs", "know-code": "node packages/cli/dist/index.js", diff --git a/packages/cli/src/commands-core.test.ts b/packages/cli/src/commands-core.test.ts index ff95a78..d5b51f9 100644 --- a/packages/cli/src/commands-core.test.ts +++ b/packages/cli/src/commands-core.test.ts @@ -257,6 +257,8 @@ describe("commands: config / init / quiz / doctor / reset / ship", () => { // Push-to-base has no merge-base ahead of HEAD — verify must be PR-only. assert.match(yml, /pull_request:/); assert.doesNotMatch(yml, /push:/); + // Default PR checkout is a merge commit without trailers — pin the tip. + assert.match(yml, /github\.event\.pull_request\.head\.sha/); }); it("validateQuiz happy and sad", () => { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 15d4d12..636c398 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -27,6 +27,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: \${{ github.event.pull_request.head.sha }} - uses: ${ACTION_REF} with: diff --git a/packages/cli/src/gate-drift.test.ts b/packages/cli/src/gate-drift.test.ts index 9052096..a372b62 100644 --- a/packages/cli/src/gate-drift.test.ts +++ b/packages/cli/src/gate-drift.test.ts @@ -167,22 +167,22 @@ describe("gate survives commit when tree unchanged (range drift)", () => { ...DEFAULT_CONFIG, rangeMode: "range", }); - assert.notEqual(afterCommit.diffHash, hash); + // Tree-canonical range hash: staged-at-pass === committed tip. + assert.equal(afterCommit.diffHash, hash); assert.equal(materializedTreeOid(repoRoot), gatedTreeOid); - const drift = resolveEffectiveQuizState(repoRoot); - assert.equal(drift.commitDrift, true); - assert.equal(drift.effectiveHash, hash); + const after = resolveEffectiveQuizState(repoRoot); + assert.equal(after.commitDrift, false); + assert.equal(after.effectiveHash, hash); assert.equal(readGate(repoRoot)!.gatedTreeOid, gatedTreeOid); assert.equal(runCheck(repoRoot).allowed, true); - const cfg = { ...DEFAULT_CONFIG, level: "lite" as const, rangeMode: "range" as const }; assert.equal( isSignedGateOpen(repoRoot, readGate(repoRoot), afterCommit.diffHash, "lite"), - false, + true, ); assert.equal( - isSignedGateEffective(repoRoot, readGate(repoRoot), drift, "lite"), + isSignedGateEffective(repoRoot, readGate(repoRoot), after, "lite"), true, ); }); @@ -280,7 +280,8 @@ describe("gate survives commit when tree unchanged (range drift)", () => { assert.equal(materializedTreeOid(repoRoot), gatedTreeOid); const mid = resolveEffectiveQuizState(repoRoot); - assert.equal(mid.commitDrift, true); + // Index tree still equals gated tree → range hash unchanged (no drift). + assert.equal(mid.commitDrift, false); assert.equal(mid.effectiveHash, hash); assert.equal(runCheck(repoRoot).allowed, true); @@ -295,7 +296,7 @@ describe("gate survives commit when tree unchanged (range drift)", () => { assert.equal(materializedTreeOid(repoRoot), gatedTreeOid); const done = resolveEffectiveQuizState(repoRoot); - assert.equal(done.commitDrift, true); + assert.equal(done.commitDrift, false); assert.equal(runCheck(repoRoot).allowed, true); }); }); diff --git a/packages/cli/src/git-env.test.ts b/packages/cli/src/git-env.test.ts index 2724221..398ca65 100644 --- a/packages/cli/src/git-env.test.ts +++ b/packages/cli/src/git-env.test.ts @@ -9,7 +9,9 @@ import { } from "./git-env.js"; import { hasStagedChanges, knowCodeGitEnv } from "./git.js"; import { materializedTreeOid } from "./gate.js"; +import { computeDiffContext, computeRangeDiffContext } from "./hash.js"; import { git, withTempRepo, writeFile } from "./test-helpers.js"; +import { DEFAULT_CONFIG } from "./types.js"; describe("git-env", () => { it("configValueBypassesHooks detects hooksPath and include.path", () => { @@ -81,12 +83,23 @@ describe("git-env", () => { assert.equal(materializedTreeOid(root), fullTree); assert.equal(hasStagedChanges(root), true); + const cfg = { ...DEFAULT_CONFIG, level: "lite" as const }; + const fromOid = git(root, ["rev-parse", "HEAD"]); + const rangeHash = computeRangeDiffContext(root, cfg, fromOid).diffHash; + const indexHash = computeDiffContext(root, cfg).diffHash; + // During the commit git also holds .git/index.lock — write-tree must - // still resolve the real tree (temp-copy fallback). + // still resolve the real tree (temp-copy fallback). Hashing must too: + // raw write-tree + EMPTY_TREE fallback would mint a wrong range/index hash. const lockPath = join(root, ".git", "index.lock"); execFileSync("touch", [lockPath]); try { assert.equal(materializedTreeOid(root), fullTree); + assert.equal( + computeRangeDiffContext(root, cfg, fromOid).diffHash, + rangeHash, + ); + assert.equal(computeDiffContext(root, cfg).diffHash, indexHash); } finally { execFileSync("rm", ["-f", lockPath]); } diff --git a/packages/cli/src/hash.ts b/packages/cli/src/hash.ts index 1af8b8b..074f9d4 100644 --- a/packages/cli/src/hash.ts +++ b/packages/cli/src/hash.ts @@ -3,6 +3,7 @@ import { readConfig } from "./config.js"; import { currentHead, git, + indexTreeOid, mergeBase, resolveBaseRef, revListCount, @@ -36,8 +37,7 @@ export function computeDiffContext( rangeFrom = committed.trim() ? mb : EMPTY_TREE; } - const indexTree = - git(["write-tree"], repoRoot, { allowFail: true }) || EMPTY_TREE; + const indexTree = indexTreeOid(repoRoot) || EMPTY_TREE; const diff = git(["diff", EMPTY_TREE, indexTree], repoRoot, { allowFail: true, }); @@ -51,7 +51,14 @@ export function computeDiffContext( }; } -/** Cumulative hash for fromOid..HEAD plus staged changes. */ +/** + * Cumulative hash for fromOid → current index tree (HEAD + staged). + * + * Tree-canonical on purpose: the same resulting tree must hash the same whether + * the delta is still staged or already committed. Otherwise CI `verify` (no + * local range-seal / gate.json) cannot match a pass-time trailer after + * `know-code commit` lands the quiz tree — the classic dogfood gap. + */ export function computeRangeDiffContext( repoRoot: string, config: Config, @@ -62,20 +69,22 @@ export function computeRangeDiffContext( const headLabel = headRef === EMPTY_TREE ? "HEAD" : headRef; const commitCount = revListCount(repoRoot, fromOid, headLabel); - const rangeDiff = git(["diff", `${fromOid}...HEAD`], repoRoot, { + const fromTree = + fromOid === EMPTY_TREE + ? EMPTY_TREE + : git(["rev-parse", `${fromOid}^{tree}`], repoRoot, { allowFail: true }) || + EMPTY_TREE; + const indexTree = indexTreeOid(repoRoot) || EMPTY_TREE; + const diff = git(["diff", fromTree, indexTree], repoRoot, { allowFail: true, }); - const staged = git(["diff", "--cached"], repoRoot, { allowFail: true }); - const material = staged.trim() - ? `diff:${rangeDiff}\nstaged:${staged}` - : `diff:${rangeDiff}`; return { baseRef, headRef: headRef === EMPTY_TREE ? EMPTY_TREE : headRef, commitRange: `${fromOid}..${headLabel}`, - diff: staged.trim() ? `${rangeDiff}\n---staged---\n${staged}` : rangeDiff, - diffHash: sha256(material), + diff, + diffHash: sha256(`diff:${diff}`), scope: "range", rangeFromOid: fromOid, commitCount, diff --git a/packages/cli/src/verify.test.ts b/packages/cli/src/verify.test.ts index 3412b1d..a7adf27 100644 --- a/packages/cli/src/verify.test.ts +++ b/packages/cli/src/verify.test.ts @@ -60,8 +60,8 @@ describe("verify hash candidates", () => { } }); - it("accepts commit-drift passHash only with grounded gate + gatedTreeOid", () => { - const repo = mkdtempSync(join(tmpdir(), "kc-verify-drift-")); + it("pass-time range trailer matches merge-base..HEAD without commit-drift", () => { + const repo = mkdtempSync(join(tmpdir(), "kc-verify-stable-pass-")); try { git(repo, ["init", "-b", "main", "--template="]); git(repo, ["config", "user.email", "t@test"]); @@ -108,9 +108,14 @@ describe("verify hash candidates", () => { ]); const candidates = collectVerifyHashCandidates(repo, cfg); - const drift = candidates.find((c) => c.label === "commit-drift"); - assert.ok(drift); - assert.equal(drift!.hash, passHash); + // Tree-canonical: no commit-drift needed — tip hash equals pass hash. + assert.equal( + candidates.find((c) => c.label === "commit-drift"), + undefined, + ); + const range = candidates.find((c) => c.label === "merge-base..HEAD"); + assert.ok(range); + assert.equal(range!.hash, passHash); const match = matchHeadTrailer(repo, "HEAD", candidates); assert.ok(match); assert.equal(match!.hash, passHash); @@ -345,4 +350,62 @@ describe("verify hash candidates", () => { rmSync(repo, { recursive: true, force: true }); } }); + + it("range hash is stable across staged → committed (CI dogfood)", () => { + const repo = mkdtempSync(join(tmpdir(), "kc-verify-stable-")); + try { + git(repo, ["init", "-b", "main", "--template="]); + git(repo, ["config", "user.email", "t@test"]); + git(repo, ["config", "user.name", "t"]); + mkdirSync(join(repo, ".know-code"), { recursive: true }); + writeFileSync( + join(repo, ".know-code", "config.json"), + JSON.stringify({ + ...DEFAULT_CONFIG, + level: "lite", + rangeMode: "range", + }), + ); + writeFileSync(join(repo, "f.txt"), "base\n"); + git(repo, ["add", "f.txt"]); + git(repo, ["commit", "-m", "base"]); + const fromOid = git(repo, ["rev-parse", "HEAD"]); + + writeFileSync(join(repo, "g.txt"), "feature\n"); + git(repo, ["add", "g.txt"]); + const stagedHash = computeRangeDiffContext( + repo, + { ...DEFAULT_CONFIG, level: "lite", rangeMode: "range" }, + fromOid, + ).diffHash; + + git(repo, [ + "commit", + "-m", + `feat\n\nKnow-Code-Verified: ${stagedHash}\n`, + ]); + + const committedHash = computeRangeDiffContext( + repo, + { ...DEFAULT_CONFIG, level: "lite", rangeMode: "range" }, + fromOid, + ).diffHash; + assert.equal(committedHash, stagedHash); + + // CI has no seal artifacts — only grounded candidates. + const candidates = collectVerifyHashCandidates(repo, { + ...DEFAULT_CONFIG, + level: "lite", + }); + assert.ok( + candidates.some((c) => c.hash === stagedHash), + "pass-time trailer must be among CI verify candidates", + ); + const match = matchHeadTrailer(repo, "HEAD", candidates); + assert.ok(match); + assert.equal(match!.hash, stagedHash); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); }); diff --git a/scripts/smoke-verify-ci.sh b/scripts/smoke-verify-ci.sh new file mode 100755 index 0000000..4e697a5 --- /dev/null +++ b/scripts/smoke-verify-ci.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# CI-shaped verify smoke: after a real commit trailer, strip local seal/gate +# artifacts and assert know-code verify still passes (what Actions sees). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SMOKE="$(mktemp -d)" +export KNOW_CODE_ATTEST_PASSPHRASE="ci-smoke-verify-passphrase" +export KNOW_CODE_ATTEST_HOME="$(mktemp -d)" +KC="$ROOT/packages/cli/dist/index.js" +cleanup() { rm -rf "$SMOKE" "$KNOW_CODE_ATTEST_HOME"; } +trap cleanup EXIT + +if [[ ! -f "$KC" ]]; then + echo "smoke-verify-ci: missing $KC — run npm run build first" >&2 + exit 1 +fi + +cd "$SMOKE" +git init -b main --template= >/dev/null +git config user.email "smoke-verify@test" +git config user.name "smoke-verify" +# Bare origin so merge-base resolution matches CI (origin/main present). +git init --bare "$SMOKE/remote.git" --template= >/dev/null +git remote add origin "$SMOKE/remote.git" + +echo "base" > README.md +git add README.md +git -c commit.gpgsign=false commit -m "init" >/dev/null +git push -u origin main >/dev/null + +node "$KC" init --level lite --require-trailer +node "$KC" attest-init +node "$KC" range begin + +echo "feature" >> README.md +git add README.md + +HASH="$(node "$KC" hash)" +node "$KC" taught --hash "$HASH" + +ATTEST_JS="$ROOT/packages/cli/dist/attest.js" +node --input-type=module -e " + const { writeAnswers } = await import(process.argv[1]); + writeAnswers(process.cwd(), { + diffHash: process.argv[2], + level: 'lite', + answers: [{ id: 'q1', answer: 'smoke verify' }], + submittedAt: new Date().toISOString() + }); +" "file://${ATTEST_JS}" "$HASH" + +DIGEST="$(node -e "const f=require('fs');const a=JSON.parse(f.readFileSync('.know-code/answers.json','utf8'));console.log(a.answersDigest)")" + +node --input-type=module -e " + import { writeFileSync } from 'node:fs'; + writeFileSync('.know-code/grade-proposal.json', JSON.stringify({ + version: 1, + diffHash: process.argv[1], + answersDigest: process.argv[2], + proposedScore: 1, + passed: true, + perQuestion: [{ id: 'q1', score: 1, feedback: 'ok' }], + rubricVersion: '1', + gradedBy: 'smoke', + gradedAt: new Date().toISOString(), + level: 'lite', + }, null, 2) + '\n'); +" "$HASH" "$DIGEST" + +node "$KC" grade --accept --hash "$HASH" --level lite --passphrase "$KNOW_CODE_ATTEST_PASSPHRASE" +node "$KC" pass --level lite --hash "$HASH" --passphrase "$KNOW_CODE_ATTEST_PASSPHRASE" + +node "$KC" commit -m "feat: smoke verify feature" + +# Mimic Actions: no local trust anchors — only public git history. +rm -f \ + .know-code/gate.json \ + .know-code/range-seal.json \ + .know-code/sealed-head-binding.json \ + .know-code/taught.json \ + .know-code/grade.json \ + .know-code/grade-proposal.json \ + .know-code/answers.json \ + .know-code/quiz.json +printf '%s\n' '{' \ + ' "level": "lite",' \ + ' "baseBranch": "main",' \ + ' "requireTrailer": true,' \ + ' "rangeMode": "auto"' \ + '}' > .know-code/config.json + +set +e +OUT="$(node "$KC" verify 2>&1)" +code=$? +set -e +echo "$OUT" +test "$code" -eq 0 +echo "$OUT" | grep -q "HEAD trailer verified" + +# Negative: fake trailer must fail. +git -c commit.gpgsign=false commit --amend --no-verify -m "$(cat </dev/null + +set +e +node "$KC" verify >/dev/null 2>&1 +bad=$? +set -e +test "$bad" -ne 0 + +echo "SMOKE VERIFY CI OK" diff --git a/website/docs/ci.md b/website/docs/ci.md index 1c9dc2e..e69d028 100644 --- a/website/docs/ci.md +++ b/website/docs/ci.md @@ -26,6 +26,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} - uses: chtnnh/know-code/action@v0.3.0 with: @@ -51,14 +52,16 @@ there is no range to recompute — which is why the workflow runs on ## What verify checks -Default `know-code verify` (one CI command for all merge styles): +Default `know-code verify` (one CI command for all merge styles). CI only sees **public git** — not gitignored `.know-code/` seals. -1. **HEAD trailer** must match one of: - - **merge-base..HEAD** — cumulative diff since the base branch (range batches, squash merges, PR tips) +1. **HEAD trailer** must match a **grounded** candidate: + - **merge-base..HEAD** — tree-canonical range hash (fromOid tree → `write-tree`) - **index** — empty-tree → current tree (single-commit / hotfix) - - **range-seal** or **uniform-trailers** — when present locally + - **uniform-trailers** — only when every commit’s trailer is already a grounded candidate 2. **Fallback:** any commit in `merge-base..HEAD` carries a matching trailer (pre-squash PR branches). +Do **not** rely on local `range-seal` / `commit-drift` for green CI. Checkout the PR tip SHA, not the ephemeral merge commit. Full design: [Verification design](verify.md). + Squash merges only need the **squash commit** to carry a trailer for the combined diff — intermediate commits are not checked. **Strict opt-in:** `--require-range-trailers` — every commit in the range must share the same trailer (rewrite teams only). diff --git a/website/docs/how-it-works.md b/website/docs/how-it-works.md index 3f8bef0..fcb83bb 100644 --- a/website/docs/how-it-works.md +++ b/website/docs/how-it-works.md @@ -62,9 +62,9 @@ Typical batch: 3. Agent lands logical commits with **plain `git commit`** while the gate is open. 4. You `range seal --rewrite` to stamp `Know-Code-Verified` on every commit, then push. -**Commit drift:** after `pass`, the range hash moves as commits land, but the gate stays open while the tree matches `gatedTreeOid` from pass time. That's what makes multi-commit batches work without re-quizzing. +**Tree-stable range hash:** after `pass`, committing the same gated tree keeps the range hash identical (staged-at-pass === tip tree). The gate stays open while the tree matches `gatedTreeOid`. Legacy gates or tree edits may still surface as commit-drift locally — that is not the happy path for CI. -Single-commit hotfix? Skip `range begin` — the hash covers the staged index only. See [Workflows](workflows.md). +Single-commit hotfix? Skip `range begin` — the hash covers the staged index only. See [Workflows](workflows.md) and [Verification design](verify.md). ## What blocks commit and push @@ -95,7 +95,7 @@ The quiz always binds to a **hash of the diff** you're about to ship. | Mode | When | Hash covers | |------|------|-------------| | **Index** | No active range session (or `rangeMode: index`) | Empty tree → current index (staged + HEAD tree) | -| **Range** | `range begin` active (or `rangeMode: range`) | Cumulative diff from merge-base through HEAD + staged | +| **Range** | `range begin` active (or `rangeMode: range`) | Tree of range start → `write-tree` (HEAD + staged; same after commit) | ```bash know-code hash @@ -110,7 +110,7 @@ know-code config --json # shows active scope | **tipHash** | Current `know-code hash` (may differ after commits) | | **trailerHash** | Value in `Know-Code-Verified:` on commit messages | -**Commit drift:** after `pass`, the agent may land several commits. `tipHash` moves with each commit, but the **tree** can stay the same. The gate stays open via `gatedTreeOid` (tree OID recorded at pass) until you change staged content or the working tree. +**Tree-stable tip:** after `pass`, the agent may land several commits. With the tree-canonical formula, `tipHash` matches `passHash` while the gated tree is unchanged. The gate stays open via `gatedTreeOid` until you change staged content or the working tree. `commitDrift` is for legacy/mismatched gates — not what CI uses. ```mermaid flowchart LR diff --git a/website/docs/troubleshooting.md b/website/docs/troubleshooting.md index 751386a..b77d11e 100644 --- a/website/docs/troubleshooting.md +++ b/website/docs/troubleshooting.md @@ -117,7 +117,7 @@ know-code commit -m "fix(cli): thing" ## Hash changed / scope confusion - **Index scope:** hash = empty-tree → index (staged + HEAD tree). Syncing `origin/main` without staging changes usually does not change it. -- **Range scope:** hash = cumulative `fromOid...HEAD` while `range begin` is active. See `know-code config --json`. +- **Range scope:** hash = tree of range start → `write-tree` while `range begin` is active. See `know-code config --json`. ## After `range seal --rewrite` diff --git a/website/docs/verify.md b/website/docs/verify.md new file mode 100644 index 0000000..0d0c50f --- /dev/null +++ b/website/docs/verify.md @@ -0,0 +1,119 @@ +--- +sidebar_position: 12 +title: Verification design +--- + +# Verification design + +This page is the contract for **`know-code verify`** — what CI can prove, how hashes are computed, and how to reproduce CI locally. For the broader product loop see [How it works](how-it-works.md). For what local gates *cannot* guarantee, see the repo’s [threat model](https://github.com/chtnnh/know-code/blob/main/security/threat-model.md) (internal). + +## Threat boundary + +```mermaid +flowchart LR + subgraph local ["Local machine same UID"] + Teach[taught / quiz / grade] + Gate[gate.json seal] + Hooks[git + agent hooks] + end + subgraph publicGit ["Public git objects"] + Tip[PR tip commits] + Trailers[Know-Code-Verified trailers] + end + subgraph ci ["CI runner"] + Verify[know-code verify] + Tree[recomputed tree hashes] + end + Teach --> Gate + Gate --> Hooks + Hooks --> Tip + Tip --> Trailers + Trailers --> Verify + Tree --> Verify +``` + +| Artifact | Trusted in CI? | Why | +|----------|----------------|-----| +| Trailers on the PR tip | **yes** | Public commit objects | +| `merge-base(origin/base, HEAD)` → index tree hash | **yes** | Recomputed on the runner | +| `.know-code/gate.json`, `range-seal.json` | **no** | Gitignored; agent-writable | +| Quiz score / taught seals | **no** | Local attestation only | + +**Honest claim:** CI proves “this tip carries a trailer that matches a grounded hash of the tree ahead of base.” It does **not** prove a human understood the diff, and it does not stop a same-UID agent from forging local seals. + +## Hash formulas + +### Index (hotfix / no active range) + +`sha256("diff:" + git diff empty-tree write-tree)` + +Covers **HEAD + staged** as one tree. Used when `rangeMode` is off or no range session is active. + +### Range (active `range begin` or `rangeMode: range`) + +```text +sha256("diff:" + git diff FROM_TREE INDEX_TREE) +``` + +`FROM_TREE` is the tree of the range start commit. `INDEX_TREE` is `git write-tree` (HEAD plus staged). + +**Tree-canonical:** the same resulting tree hashes the same whether the delta is still staged or already committed. That is required for receipt-mode CI: `know-code commit` stamps the pass-time hash, and CI must recompute that hash from history alone (no `staged:` material, no local seal). + +Sliced pathspec commits keep the same range hash while the index tree still equals `gatedTreeOid` from pass. + +## What `verify` accepts + +`collectVerifyHashCandidates` builds grounded hashes only (never “whatever string is on HEAD”): + +1. **index** — empty-tree → current index tree +2. **merge-base..HEAD** — when ahead of base: range formula from merge-base +3. **uniform-trailers** — only if every commit shares a hash that is already a grounded candidate +4. **range-seal** / **range-seal-pass** — only when local seal files exist and `HEAD === sealedHeadOid` (**not** available in CI) +5. **commit-drift** — local only, when a legacy/mismatched gate hash still matches a stable gated tree + +Match order: HEAD trailer against candidates; if missing, scan trailers in `merge-base..HEAD` (squash-friendly PR branches). + +### Receipt vs rewrite + +| Mode | Trailer on commits | CI needs | +|------|--------------------|----------| +| **receipt** (default here after tree-canonical hash) | Pass-time hash from `know-code commit` | Tip trailer ∈ grounded candidates | +| **rewrite** | `range seal --rewrite` stamps tip hash on every commit | Same; use `--require-range-trailers` if every commit must match | + +## Workflow checklist + +```yaml +on: + pull_request: # not push to base + +jobs: + verify: + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} # not the merge commit + # … install know-code … + - run: | + mkdir -p .know-code + printf '{\n "level": "standard",\n "baseBranch": "main",\n "requireTrailer": true\n}\n' > .know-code/config.json + - run: know-code verify +``` + +`know-code init --workflow` generates the `head.sha` checkout. The composite action writes `requireTrailer: true` when it creates config; the monorepo workflow writes it explicitly. + +## Reproduce CI locally + +```bash +npm run build +npm run smoke:verify +``` + +`scripts/smoke-verify-ci.sh` runs a full range quiz → `know-code commit`, then **deletes** gate/seal/taught artifacts and asserts `know-code verify` still exits 0. A forged trailer must fail. + +## See also + +- [CI & GitHub Action](ci.md) — install / branch protection +- [How it works](how-it-works.md) — local gate + layers +- [Workflows](workflows.md) — receipt vs rewrite +- [Troubleshooting](troubleshooting.md) — CI trailer failures diff --git a/website/docs/workflows.md b/website/docs/workflows.md index a0c0953..617c071 100644 --- a/website/docs/workflows.md +++ b/website/docs/workflows.md @@ -84,10 +84,10 @@ Hooks gate `gh pr create` and `glab mr create`. Complete the quiz pipeline **bef | Mode | When | What happens | |------|------|--------------| -| **receipt** | Trailer on tip commit is enough for CI | Writes signed `range-seal.json` | -| **rewrite** | CI requires trailer on **every** commit (dogfooding default) | `range seal --rewrite` rewrites messages + `git push --force-with-lease` | +| **receipt** | Trailer on the **tip** is enough for CI | Writes signed `range-seal.json` (local only; CI ignores it) | +| **rewrite** | Every commit in the range must share the same trailer | `range seal --rewrite` rewrites messages + `git push --force-with-lease` | -This repo dogfoods **rewrite**. Use `verify --require-range-trailers` in CI when using rewrite. +This repo’s `know-code.yml` is **receipt**: `know-code verify` on the PR tip. Opt into `--require-range-trailers` only when you also rewrite. ## When to `range abort` diff --git a/website/sidebars.ts b/website/sidebars.ts index f5ae20f..27248ed 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -14,6 +14,7 @@ const sidebars: SidebarsConfig = { "levels", "hooks", "ci", + "verify", "team", "troubleshooting", ],