Skip to content
Open
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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions .github/workflows/know-code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
8 changes: 4 additions & 4 deletions action/action.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: \${{ github.event.pull_request.head.sha }}

- uses: ${ACTION_REF}
with:
Expand Down
19 changes: 10 additions & 9 deletions packages/cli/src/gate-drift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
Expand Down Expand Up @@ -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);

Expand All @@ -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);
});
});
26 changes: 18 additions & 8 deletions packages/cli/src/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -62,20 +69,23 @@ 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 =
git(["write-tree"], repoRoot, { allowFail: true }) || EMPTY_TREE;
const diff = git(["diff", fromTree, indexTree], repoRoot, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Range hash skips lock-safe write-tree

Medium Severity

computeRangeDiffContext now depends on write-tree for the tree-canonical tip, but calls raw git write-tree with an EMPTY_TREE fallback instead of indexTreeOid. Under pre-commit index.lock, that failure yields a wrong range hash (empty-tree material) rather than the real index tree the rest of the gate path already resolves lock-safely.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9e1f785. Configure here.

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,
Expand Down
73 changes: 68 additions & 5 deletions packages/cli/src/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
}
});
});
115 changes: 115 additions & 0 deletions scripts/smoke-verify-ci.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
feat: smoke verify feature

Know-Code-Verified: $(printf 'a%.0s' {1..64})
EOF
)" >/dev/null

set +e
node "$KC" verify >/dev/null 2>&1
bad=$?
set -e
test "$bad" -ne 0

echo "SMOKE VERIFY CI OK"
10 changes: 6 additions & 4 deletions website/docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,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 (`ref: ${{ github.event.pull_request.head.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).
Expand Down
Loading
Loading