diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b3ca2..ca06144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ CodeTruss CLI follows semantic versioning. Release artifacts and their SHA-256 checksums are published at . -The current public release is [v0.2.43 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.43), +The current public release is [v0.2.44 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.44), distributed from . The npm `latest` tag is still [`@codetruss/cli@0.2.41`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.41): @@ -16,6 +16,43 @@ were superseded before distribution. No unreleased changes. +## 0.2.44 — 2026-08-07 + +- **The person you hand a receipt to can now check it.** Until this release a + receipt could only be verified by the repository that produced it: `codetruss + verify` measures a receipt against the signing keys the local `.codetruss.yml` + pins, so the client, auditor, or acquirer the evidence was written for got + `receipt signer does not match trusted key ` and stopped there. That + is most of the point of handing someone a receipt, and it did not work. The + gap was concrete rather than theoretical: publishing one of our own receipts + publicly required shipping a bespoke standalone verifier alongside it, because + the CLI would not check another install's receipt. `codetruss verify-receipt + ` is the supported path. It needs nothing but the files — + no checkout, no account, no configuration — and it reports two claims + separately, because they are two different facts and merging them would be a + lie. **Integrity** is that these bytes have not changed since they were + signed; it is established from the receipt alone, by checking the signature + under the key the receipt carries, reproducing the Markdown byte-for-byte from + the signed JSON, and matching the recorded digests. **Provenance** is that a + party you trust signed them, and it is established only against a + `--public-key` you obtained from that party some other way. A receipt vouching + for its own key proves nothing about who wrote it — forging one takes a + keypair and a minute — so a run without a supplied key can never print a + verified result or exit 0. That ceiling is the feature, not a missing half of + one. The exit codes carry the distinction into scripts: 0 for both claims, 1 + for bytes that are intact but unattributed, 2 for bytes that are not what was + signed. When integrity fails, provenance is not evaluated at all and says so, + rather than printing a key match over altered bytes. Evidence a publisher + withheld — usually the patch, the only part of a receipt that quotes source — + is reported as unchecked next to the digest the signature does cover, and the + integrity line names the hole instead of reading clean. `codetruss verify` is + unchanged and still requires a trusted key; its refusal now names the command + that can check a foreign receipt instead of dead-ending. Both paths run one + shared check list against one shared set of accepted Markdown renderings, so + neither can drift into checking less than it claims, and every superseded + profile wording stays reproducible, so receipts signed by older releases keep + verifying byte-for-byte. + ## 0.2.43 — 2026-08-07 - **A release can no longer reach you carrying code that does not compile.** diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 345553e..0d4bc5d 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,43 @@ checksums are published at does not match trusted key ` and stopped there. That + is most of the point of handing someone a receipt, and it did not work. The + gap was concrete rather than theoretical: publishing one of our own receipts + publicly required shipping a bespoke standalone verifier alongside it, because + the CLI would not check another install's receipt. `codetruss verify-receipt + ` is the supported path. It needs nothing but the files — + no checkout, no account, no configuration — and it reports two claims + separately, because they are two different facts and merging them would be a + lie. **Integrity** is that these bytes have not changed since they were + signed; it is established from the receipt alone, by checking the signature + under the key the receipt carries, reproducing the Markdown byte-for-byte from + the signed JSON, and matching the recorded digests. **Provenance** is that a + party you trust signed them, and it is established only against a + `--public-key` you obtained from that party some other way. A receipt vouching + for its own key proves nothing about who wrote it — forging one takes a + keypair and a minute — so a run without a supplied key can never print a + verified result or exit 0. That ceiling is the feature, not a missing half of + one. The exit codes carry the distinction into scripts: 0 for both claims, 1 + for bytes that are intact but unattributed, 2 for bytes that are not what was + signed. When integrity fails, provenance is not evaluated at all and says so, + rather than printing a key match over altered bytes. Evidence a publisher + withheld — usually the patch, the only part of a receipt that quotes source — + is reported as unchecked next to the digest the signature does cover, and the + integrity line names the hole instead of reading clean. `codetruss verify` is + unchanged and still requires a trusted key; its refusal now names the command + that can check a foreign receipt instead of dead-ending. Both paths run one + shared check list against one shared set of accepted Markdown renderings, so + neither can drift into checking less than it claims, and every superseded + profile wording stays reproducible, so receipts signed by older releases keep + verifying byte-for-byte. + ## 0.2.43 — 2026-08-07 - **A release can no longer reach you carrying code that does not compile.** diff --git a/packages/cli/README.md b/packages/cli/README.md index 26ebe86..aabfe82 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -48,7 +48,18 @@ dependencies. The shell installers resolve a versioned artifact and verify its published SHA-256 digest before installation. Every release also includes a deterministic CycloneDX SBOM, changelog, and security policy. -Deterministic `run`, `review`, `report`, `list`, `metrics`, `init`, `verify`, and hook +`codetruss verify latest` re-checks a receipt against the signing keys this +repository trusts. Anyone you hand a receipt to has no such key, so +`codetruss verify-receipt ` checks it for them, outside any +repository, and reports two claims it never merges: **integrity** — these bytes +have not changed since they were signed — established from the receipt alone, and +**provenance** — a party you trust signed them — established only against a +`--public-key` obtained from that party out of band. A receipt vouching for its +own key proves nothing about who wrote it, so a run without a supplied key exits +`1` (intact but unattributed); altered bytes exit `2`. + +Deterministic `run`, `review`, `report`, `list`, `metrics`, `init`, `verify`, +`verify-receipt`, and hook checks run on-machine without contacting CodeTruss. Installation fetches release metadata and package bytes from CodeTruss. `auth login` contacts CodeTruss device/session endpoints but uploads no source, patch, or receipt. `auth status` @@ -56,10 +67,12 @@ contacts the session endpoint to verify the saved credential, and `auth logout` contacts it to revoke the credential before deleting the local copy; neither sends source, patches, or receipts. `--llm --provider anthropic|openai|claude` opts into provider review using your API key or authenticated local Claude Code. -Local receipts identify the 15-pass `local-registry-v3` profile and show hosted -Health scores as N/A. The hosted symbol graph and the full SAST rule pack run -only in the hosted full audit, so the CLI never infers a complete score from its -smaller local pass set. +Local receipts identify the 15-pass `local-registry-v4` profile and show hosted +Health scores as N/A. Its local security pass covers JavaScript, TypeScript, and +TSX, plus Python when the optional grammar pack is installed; each receipt states +which of those actually ran rather than assuming. The hosted symbol graph and the +full SAST rule pack run only in the hosted full audit, so the CLI never infers a +complete score from its smaller local pass set. CodeTruss supplies a bounded task, reviewed diff prefix, and fixed review schema; the provider client may add its own runtime instructions or metadata. The receipt discloses reviewed versus total diff bytes, and truncation prevents `PASS`. diff --git a/packages/cli/package.json b/packages/cli/package.json index 5eadad6..eab054d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@codetruss/cli", - "version": "0.2.43", + "version": "0.2.44", "description": "Local-first scope, quality, and verification receipts for coding agents", "license": "SEE LICENSE IN LICENSE", "type": "module", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b220360..57930e1 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -53,6 +53,7 @@ import { CLI_VERSION } from './version.js' import { assertLocalEvidencePathsIgnored, ensureLocalEvidenceProtected } from './local-evidence.js' import { guidedSetup } from './setup.js' import { runGrammarsCommand } from './grammar-command.js' +import { runVerifyReceiptCommand } from './verify-receipt-command.js' interface Parsed { command: string; positionals: string[]; values: Map; booleans: Set; agent: string[] } @@ -75,6 +76,7 @@ const COMMAND_OPTION_SCHEMAS: Readonly> = { list: { booleans: ['json'], maxPositionals: 0, agent: 'forbidden' }, metrics: { booleans: ['json'], maxPositionals: 0, agent: 'forbidden' }, verify: { maxPositionals: 1, agent: 'forbidden' }, + 'verify-receipt': { values: ['public-key'], maxPositionals: 1, agent: 'forbidden' }, 'verify-policy': { maxPositionals: 1, agent: 'forbidden' }, sync: { booleans: ['dry-run'], maxPositionals: 1, agent: 'forbidden' }, hooks: { maxPositionals: 2, agent: 'forbidden' }, @@ -312,13 +314,19 @@ Usage: codetruss setup [--allow GLOB] [--deny GLOB] [--hooks all|pre-commit|claude|codex|none] [--trust-verify] [--yes] codetruss init [--allow GLOB] [--deny GLOB] [--force] codetruss verify [id|latest] + codetruss verify-receipt [--public-key FILE] codetruss sync [id|latest] [--dry-run] codetruss auth login|status|logout codetruss verify-policy [status|trust|trust-key|revoke] codetruss hooks install|status|doctor|uninstall [pre-commit|claude|codex|all] codetruss grammars list|status|install|uninstall [python] -Exit codes: PASS=0, REVIEW_REQUIRED=1, FAILED=2, usage/environment=3.` +verify checks a receipt this repository's own trusted key signed. verify-receipt +checks one you were handed: it establishes integrity from the receipt itself, and +provenance only against a --public-key you obtained from the signer out of band. + +Exit codes: PASS=0, REVIEW_REQUIRED=1, FAILED=2, usage/environment=3. +verify-receipt: integrity and provenance=0, intact but unattributed=1, not intact=2.` } async function executeReview(parsed: Parsed, root: string, liveConfig: CliConfig): Promise { @@ -730,6 +738,14 @@ async function main(argv = process.argv.slice(2)): Promise { if (parsed.command === 'grammars') { return runGrammarsCommand(parsed.positionals[0] ?? 'status', parsed.positionals[1]) } + // Checking a receipt someone handed you is not a repository operation: the + // reader may hold nothing but the receipt, and requiring a checkout would put + // them back where they started. + if (parsed.command === 'verify-receipt') { + const target = parsed.positionals[0] + if (!target) throw new Error('verify-receipt requires the path to a receipt .json file (or the directory holding it)') + return runVerifyReceiptCommand(target, many(parsed, 'public-key', [])) + } const root = findRepoRoot() if (parsed.command === 'setup') { if (parsed.positionals.length || parsed.agent.length) throw new Error('setup does not accept positional arguments or a command after --') diff --git a/packages/cli/src/receipt.ts b/packages/cli/src/receipt.ts index 62e977f..a2204d0 100644 --- a/packages/cli/src/receipt.ts +++ b/packages/cli/src/receipt.ts @@ -1,5 +1,5 @@ import { chmod, mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises' -import { basename, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { suggestedFixLines } from './fix-suggestions.js' import { loadSigningKey, @@ -653,15 +653,156 @@ export async function resolveReceipt(dir: string, id = 'latest'): Promise<{ rece return { receipt: JSON.parse(await readFile(jsonPath, 'utf8')) as Receipt, jsonPath } } -export async function verifyReceipt(dir: string, id = 'latest', pinnedPublicKey?: string | string[]): Promise { - const { receipt, jsonPath } = await resolveReceipt(dir, id) +/** + * Every rendering this exact signed JSON could legitimately have produced. + * + * Rewording a disclosure must not invalidate receipts already on disk, so each + * superseded wording stays reproducible for verification only. Shared by the + * repository path and the third-party path so the two can never disagree about + * which bytes the signature covers. + */ +export function acceptedMarkdownRenderings(receipt: Receipt): string[] { + const profile = 'analysisProfile' in receipt.analyzers ? receipt.analyzers.analysisProfile : null + return [ + renderMarkdown(receipt), + // Superseded wordings are accepted only for the profile version that could + // have produced them. A `local-registry-v2` receipt was never written by a + // CLI that omitted SAST, so its Markdown must not be allowed to say so. + ...(!profile ? [renderLegacyMarkdown(receipt)] : []), + ...(profile?.id === 'local-registry-v1' ? [renderPriorProfileMarkdown(receipt)] : []), + ] +} + +/** Structural facts that must hold before any signature check means anything. */ +function receiptStructureError(receipt: Receipt): string | null { if (receipt.git && ![receipt.git.baselineTree, receipt.git.finalTree].every((oid) => /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(oid))) { - throw new Error('receipt evidence tree object id is invalid') + return 'receipt evidence tree object id is invalid' } if (receipt.policy && !/^[0-9a-f]{64}$/.test(receipt.policy.sha256)) { - throw new Error('receipt policy SHA-256 is invalid') + return 'receipt policy SHA-256 is invalid' + } + if (!receipt.evidence.publicKey || !receipt.evidence.signatureFile) return 'receipt is unsigned' + return null +} + +/** + * A receipt names its own companion files, and a receipt handed over by a + * stranger names them with bytes that stranger chose. Resolve them as plain + * names beside the receipt so a hostile `signatureFile` cannot reach anywhere + * else on the reader's disk. + */ +function companionPath(dir: string, name: string, label: string): string { + if (name !== basename(name) || name.includes('/') || name.includes('\\') || name === '.' || name === '..') { + throw new Error(`receipt ${label} must be a plain file name beside the receipt`) + } + return join(dir, name) +} + +/** One check over the bytes the signature covers. */ +export interface ReceiptContentCheck { + label: string + status: 'ok' | 'failed' | 'skipped' + /** Why it failed, or what could not be checked and why. */ + detail?: string +} + +/** + * Every check the signature makes possible, in order, against `publicKey`. + * + * This is the whole of what a signature can settle, and both verification paths + * run exactly it: `verifyReceipt` throws on the first failure after deciding the + * key is trusted, while the third-party path reports the list against the key + * the receipt carries. Splitting the two would let one of them quietly check + * less than the other claims. + */ +async function receiptContentChecks( + jsonPath: string, + receipt: Receipt, + publicKey: string, + /** False when a named companion may legitimately be absent, as in a published bundle. */ + requireCompanions: boolean, +): Promise { + const dir = dirname(jsonPath) + const checks: ReceiptContentCheck[] = [] + const read = async (name: string, label: string): Promise => { + try { + return await readFile(companionPath(dir, name, label)) + } catch (error) { + if (!requireCompanions && (error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + } + } + + const jsonBytes = await readFile(jsonPath) + const signatureName = receipt.evidence.signatureFile as string + const signatureBytes = await read(signatureName, 'signature file') + const signatureLabel = 'ed25519 signature over the receipt JSON' + if (!signatureBytes) { + checks.push({ label: signatureLabel, status: 'failed', detail: `${signatureName} is not beside the receipt` }) + } else if (!verifyBytes(jsonBytes, publicKey, signatureBytes.toString('utf8').trim())) { + checks.push({ label: signatureLabel, status: 'failed', detail: 'receipt signature does not match' }) + } else { + checks.push({ label: signatureLabel, status: 'ok' }) + } + + const markdownName = `${receipt.sessionId}.md` + const markdownBytes = await read(markdownName, 'Markdown receipt') + const renderingLabel = 'Markdown rendering reproduces from the signed JSON' + const digestLabel = 'Markdown SHA-256 matches the signed receipt' + if (!markdownBytes) { + checks.push({ label: renderingLabel, status: 'failed', detail: `${markdownName} is not beside the receipt` }) + checks.push({ label: digestLabel, status: 'skipped', detail: 'the Markdown rendering is not present' }) + } else { + const markdown = markdownBytes.toString('utf8') + const reproduced = acceptedMarkdownRenderings(receipt).includes(markdown) + checks.push(reproduced + ? { label: renderingLabel, status: 'ok' } + : { label: renderingLabel, status: 'failed', detail: 'Markdown receipt does not match the signed JSON' }) + checks.push(sha256(markdown) === receipt.evidence.markdownSha256 + ? { label: digestLabel, status: 'ok' } + : { label: digestLabel, status: 'failed', detail: 'Markdown receipt hash does not match' }) + } + + if (receipt.evidence.patchFile) { + const patch = await read(receipt.evidence.patchFile, 'captured patch') + const patchLabels = [ + 'captured patch SHA-256 matches the recorded digest', + 'captured patch matches the signed diff hash', + 'captured patch byte count matches the signed receipt', + 'captured patch truncation metadata is consistent', + ] + if (!patch) { + // Withholding the patch is a normal thing to do — it is the only part of + // a receipt that quotes source. Its digest is inside the signed JSON, so + // say what was not checked rather than pass over it in silence. + checks.push({ + label: 'captured patch matches the signed receipt', + status: 'skipped', + detail: `${receipt.evidence.patchFile} was not published with this receipt; its signed SHA-256 is ${receipt.evidence.patchSha256 ?? '(not recorded)'}`, + }) + } else { + const totalBytes = receipt.diff.totalBytes ?? receipt.diff.bytes + const conditions: Array<[boolean, string]> = [ + [sha256(patch) === receipt.evidence.patchSha256, 'captured patch hash does not match'], + [sha256(patch) === receipt.diff.sha256, 'captured patch does not match the signed diff hash'], + [patch.length === receipt.diff.bytes, 'captured patch byte count does not match the signed receipt'], + [ + totalBytes >= receipt.diff.bytes && receipt.diff.truncated === (totalBytes > receipt.diff.bytes), + 'captured patch truncation metadata is inconsistent', + ], + ] + for (const [index, [held, failure]] of conditions.entries()) { + checks.push(held ? { label: patchLabels[index], status: 'ok' } : { label: patchLabels[index], status: 'failed', detail: failure }) + } + } } - if (!receipt.evidence.publicKey || !receipt.evidence.signatureFile) throw new Error('receipt is unsigned') + return checks +} + +export async function verifyReceipt(dir: string, id = 'latest', pinnedPublicKey?: string | string[]): Promise { + const { receipt, jsonPath } = await resolveReceipt(dir, id) + const structureError = receiptStructureError(receipt) + if (structureError) throw new Error(structureError) // A repository may trust several signers (one per developer). The receipt is // valid when its embedded key is one of them, and the signature is then // checked against that exact key — so a teammate's receipt verifies without @@ -670,43 +811,94 @@ export async function verifyReceipt(dir: string, id = 'latest', pinnedPublicKey? const trustedPublicKeys = pins.length > 0 ? pins.map((pin) => normalizePublicKey(pin)) : [(await loadSigningKey()).publicKey] - const embeddedPublicKey = normalizePublicKey(receipt.evidence.publicKey) + const embeddedPublicKey = normalizePublicKey(receipt.evidence.publicKey as string) const embeddedFingerprint = publicKeyFingerprint(embeddedPublicKey) const trustedFingerprints = trustedPublicKeys.map((key) => publicKeyFingerprint(key)) if (receipt.evidence.keyFingerprint !== embeddedFingerprint) throw new Error('receipt signing fingerprint does not match its public key') if (!trustedFingerprints.includes(embeddedFingerprint)) { - throw new Error(`receipt signer ${embeddedFingerprint} does not match trusted key ${trustedFingerprints.join(', ')}`) + // A receipt from another install is the expected case for anyone handed + // evidence, not a malfunction. Refusing it without naming the supported way + // to check it is what sent the first such reader off to write their own + // verifier. + throw new Error( + `receipt signer ${embeddedFingerprint} does not match trusted key ${trustedFingerprints.join(', ')}; ` + + `this receipt was produced by another install — check it with codetruss verify-receipt ${join(dir, `${receipt.sessionId}.json`)} ` + + '[--public-key ]', + ) } const trustedPublicKey = trustedPublicKeys[trustedFingerprints.indexOf(embeddedFingerprint)] - const jsonBytes = await readFile(jsonPath) - const signature = (await readFile(join(dir, receipt.evidence.signatureFile), 'utf8')).trim() - if (!verifyBytes(jsonBytes, trustedPublicKey, signature)) throw new Error('receipt signature does not match') - const markdown = await readFile(join(dir, `${receipt.sessionId}.md`), 'utf8') - // Every rendering this exact signed JSON could legitimately have produced. - // Rewording a disclosure must not invalidate receipts already on disk, so - // each superseded wording stays reproducible for verification only. - const profile = 'analysisProfile' in receipt.analyzers ? receipt.analyzers.analysisProfile : null - const accepted = [ - renderMarkdown(receipt), - // Superseded wordings are accepted only for the profile version that could - // have produced them. A `local-registry-v2` receipt was never written by a - // CLI that omitted SAST, so its Markdown must not be allowed to say so. - ...(!profile ? [renderLegacyMarkdown(receipt)] : []), - ...(profile?.id === 'local-registry-v1' ? [renderPriorProfileMarkdown(receipt)] : []), + const checks = await receiptContentChecks(jsonPath, receipt, trustedPublicKey, true) + const failed = checks.find((check) => check.status === 'failed') + if (failed) throw new Error(failed.detail) + return receipt +} + +/** + * What can be established about a receipt whose signer you have not pinned. + * + * Integrity and provenance are separate facts and this type keeps them + * separate: `checks` settles whether the bytes are what was signed, and the + * signer fingerprint is only ever a claim the receipt makes about itself. + * Deciding whose key that is takes a key obtained from somewhere other than + * this file, which is the caller's job. + */ +export interface ReceiptIntegrityResult { + receipt: Receipt + /** The key the receipt carries — evidence of nothing on its own. */ + signerFingerprint: string | null + checks: ReceiptContentCheck[] + /** True only when every check that could run held and none failed. */ + intact: boolean +} + +/** + * Verify a receipt file against the key inside it, trusting nothing on this + * machine. Establishes integrity only; see `ReceiptIntegrityResult`. + */ +export async function verifyReceiptIntegrity(jsonPath: string): Promise { + let receipt: Receipt + try { + receipt = JSON.parse(await readFile(jsonPath, 'utf8')) as Receipt + } catch (error) { + throw new Error(`could not read a CodeTruss receipt at ${jsonPath}: ${error instanceof Error ? error.message : String(error)}`) + } + if (!receipt || typeof receipt !== 'object' || typeof receipt.sessionId !== 'string' || !receipt.evidence) { + throw new Error(`${jsonPath} is not a CodeTruss receipt`) + } + const structureError = receiptStructureError(receipt) + if (structureError) { + return { receipt, signerFingerprint: null, checks: [{ label: 'receipt is well-formed and signed', status: 'failed', detail: structureError }], intact: false } + } + let embeddedPublicKey: string + try { + embeddedPublicKey = normalizePublicKey(receipt.evidence.publicKey as string) + } catch { + return { receipt, signerFingerprint: null, checks: [{ label: 'receipt carries a usable public key', status: 'failed', detail: 'the embedded public key is not a readable key' }], intact: false } + } + const embeddedFingerprint = publicKeyFingerprint(embeddedPublicKey) + const fingerprintLabel = 'recorded key fingerprint matches the embedded public key' + const checks: ReceiptContentCheck[] = [ + receipt.evidence.keyFingerprint === embeddedFingerprint + ? { label: fingerprintLabel, status: 'ok' } + : { label: fingerprintLabel, status: 'failed', detail: 'receipt signing fingerprint does not match its public key' }, ] - if (!accepted.includes(markdown)) throw new Error('Markdown receipt does not match the signed JSON') - if (sha256(markdown) !== receipt.evidence.markdownSha256) throw new Error('Markdown receipt hash does not match') - if (receipt.evidence.patchFile) { - const patch = await readFile(join(dir, receipt.evidence.patchFile)) - if (sha256(patch) !== receipt.evidence.patchSha256) throw new Error('captured patch hash does not match') - if (sha256(patch) !== receipt.diff.sha256) throw new Error('captured patch does not match the signed diff hash') - if (patch.length !== receipt.diff.bytes) throw new Error('captured patch byte count does not match the signed receipt') - const totalBytes = receipt.diff.totalBytes ?? receipt.diff.bytes - if (totalBytes < receipt.diff.bytes || receipt.diff.truncated !== (totalBytes > receipt.diff.bytes)) { - throw new Error('captured patch truncation metadata is inconsistent') - } + try { + checks.push(...await receiptContentChecks(jsonPath, receipt, embeddedPublicKey, false)) + } catch (error) { + // Anything that stops a check from running leaves integrity unestablished, + // which is the honest outcome — never a pass by omission. + checks.push({ + label: 'the evidence this receipt names could be read', + status: 'failed', + detail: error instanceof Error ? error.message : String(error), + }) + } + return { + receipt, + signerFingerprint: embeddedFingerprint, + checks, + intact: !checks.some((check) => check.status === 'failed'), } - return receipt } /** Create a signed, privacy-minimized copy for an explicit hosted sync. */ diff --git a/packages/cli/src/verify-receipt-command.ts b/packages/cli/src/verify-receipt-command.ts new file mode 100644 index 0000000..164429f --- /dev/null +++ b/packages/cli/src/verify-receipt-command.ts @@ -0,0 +1,135 @@ +import { readFile, readdir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { verifyReceiptIntegrity, type ReceiptContentCheck } from './receipt.js' +import { normalizePublicKey, publicKeyFingerprint } from './signing.js' + +/** + * `codetruss verify-receipt` — the supported way to check a receipt you did not + * produce. + * + * The product's claim is that a receipt can be re-checked later. Until this + * command existed that was only true for the machine that signed it: `codetruss + * verify` measures a receipt against the keys THIS repository pins, which is + * right for your own history and useless to the client, auditor or acquirer the + * receipt was written for. They were left to write their own verifier. + * + * It has to say two different things without ever letting them blur: + * + * INTEGRITY — these bytes have not changed since they were signed. Anyone can + * check it, using the key inside the receipt and the digests it records. + * + * PROVENANCE — a named party signed them. Checkable ONLY against a key that + * arrived some other way. A receipt vouching for its own key establishes + * nothing about who wrote it: forging one takes a keypair and a minute. + * + * So a bare run can never print the word "verified" on its own, and a green + * exit is reachable only once the reader has supplied a key from outside the + * file. Reporting integrity as though it settled authorship would be precisely + * the failure this product exists to prevent. + */ + +/** Both claims hold. */ +const EXIT_VERIFIED = 0 +/** The bytes are intact, but nothing here says who signed them. */ +const EXIT_INTACT_UNATTRIBUTED = 1 +/** These bytes are not what was signed. */ +const EXIT_NOT_INTACT = 2 + +interface SuppliedKey { + source: string + fingerprint: string +} + +async function readSuppliedKeys(paths: string[]): Promise { + const keys: SuppliedKey[] = [] + for (const path of paths) { + let pem: string + try { + pem = await readFile(path, 'utf8') + } catch (error) { + throw new Error(`could not read the public key at ${path}: ${error instanceof Error ? error.message : String(error)}`) + } + try { + keys.push({ source: path, fingerprint: publicKeyFingerprint(normalizePublicKey(pem)) }) + } catch { + throw new Error(`${path} is not a PEM public key`) + } + } + return keys +} + +/** Accept the receipt itself, or the directory a published bundle arrives as. */ +async function resolveReceiptPath(path: string): Promise { + const isDirectory = await stat(path).then((entry) => entry.isDirectory(), (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') throw new Error(`no such path: ${path}`) + throw error + }) + if (!isDirectory) return path + const found = (await readdir(path)).filter((name) => name.endsWith('.json')).sort() + if (found.length === 0) throw new Error(`${path} contains no receipt .json file`) + if (found.length > 1) throw new Error(`${path} contains ${found.length} .json files; name the one to check (${found.join(', ')})`) + return join(path, found[0]) +} + +function checkLine(check: ReceiptContentCheck): string { + const marker = check.status === 'ok' ? 'ok ' : check.status === 'failed' ? 'FAIL' : 'skip' + return ` ${marker} ${check.label}${check.detail ? `\n ${check.detail}` : ''}\n` +} + +export async function runVerifyReceiptCommand( + path: string, + publicKeyPaths: string[] = [], + write: (text: string) => void = (text) => process.stdout.write(text), +): Promise { + const suppliedKeys = await readSuppliedKeys(publicKeyPaths) + const jsonPath = await resolveReceiptPath(path) + const result = await verifyReceiptIntegrity(jsonPath) + + write(`receipt ${result.receipt.sessionId} — verdict ${result.receipt.verdict}\n`) + write(`signing key ${result.signerFingerprint ?? '(unreadable)'} — carried by the receipt, which is not evidence of who made it\n\n`) + + // "Established" must never quietly mean "established over the parts that + // happened to be here". A withheld file is a hole in the evidence and the + // headline is where a reader will look for it. + const skipped = result.checks.filter((check) => check.status === 'skipped').length + const unchecked = result.intact && skipped > 0 + ? ` — over what was published; ${skipped} recorded piece${skipped === 1 ? '' : 's'} of evidence ${skipped === 1 ? 'is' : 'are'} absent and unchecked` + : '' + write(`integrity: ${result.intact ? 'ESTABLISHED' : 'NOT ESTABLISHED'}${unchecked}\n`) + for (const check of result.checks) write(checkLine(check)) + write('\n') + + if (!result.intact) { + // Whose key signed altered bytes is not a question worth answering: the + // signature did not hold, so nothing here attributes anything to anyone. + write('provenance: NOT CHECKED\n') + write(' Provenance means nothing over bytes that are not what was signed.\n\n') + write('These bytes are not the bytes that were signed. Do not rely on this receipt.\n') + return EXIT_NOT_INTACT + } + + const matched = suppliedKeys.find((key) => key.fingerprint === result.signerFingerprint) + if (matched) { + write('provenance: ESTABLISHED\n') + write(` The receipt is signed by the key you supplied (${matched.source}).\n\n`) + write( + 'Both claims hold: these bytes are unchanged since signing, and they were signed by that key.\n' + + 'Neither proves the analysis described here actually ran, or that its conclusions are correct.\n', + ) + return EXIT_VERIFIED + } + + write('provenance: NOT ESTABLISHED\n') + write(suppliedKeys.length === 0 + ? ' No --public-key was supplied, so the only key available is the one this receipt carries.\n' + + ' Anyone can generate a key and sign a receipt of their own with it.\n' + : ` This receipt is signed by ${result.signerFingerprint}, which is not ${suppliedKeys.length === 1 ? 'the key' : 'among the keys'} you supplied ` + + `(${suppliedKeys.map((key) => `${key.fingerprint} from ${key.source}`).join(', ')}).\n`) + write('\n') + write( + 'Integrity alone proves these bytes have not changed since someone signed them.\n' + + 'It does not prove who that someone is, that the analysis ran, or that its conclusions are correct.\n' + + `${suppliedKeys.length === 0 ? 'Get the signer\'s public key from them directly — not from this file — and re-run with --public-key .\n' : 'Until that key is one you obtained from the party you mean to trust, this receipt is unattributed.\n'}`, + ) + return EXIT_INTACT_UNATTRIBUTED +} diff --git a/packages/cli/test/command-e2e.test.ts b/packages/cli/test/command-e2e.test.ts index d9f104c..c1d36cd 100644 --- a/packages/cli/test/command-e2e.test.ts +++ b/packages/cli/test/command-e2e.test.ts @@ -1047,4 +1047,45 @@ describe('CLI snapshot and delta enforcement', () => { expect(preview.stdout).not.toContain(root) expect(preview.stdout).not.toContain('process.exit(0)') }, 30_000) + + it('checks a delivered receipt outside any repository, and separates integrity from provenance', async () => { + const producer = await repository() + await writeFile(join(producer, 'example.ts'), 'export const value = 1\n') + git(producer, 'add', 'example.ts') + git(producer, 'commit', '--quiet', '-m', 'baseline') + await writeFile(join(producer, 'example.ts'), 'export const value = 2\n') + expect(runCli(producer, ['review', '--task', 'Change the value']).status).toBe(1) + + // What a client, auditor or acquirer actually receives: the receipt files, + // no repository, no key, no CodeTruss install of the producer's. + const reader = await mkdtemp(join(tmpdir(), 'codetruss-delivered-')) + cleanup.push(reader, `${reader}-home`) + const delivered = join(reader, 'evidence') + await mkdir(delivered) + const producedDir = join(producer, '.codetruss', 'receipts') + const receipt = await latestReceipt(producer) + for (const suffix of ['.json', '.md', '.sig']) { + await writeFile(join(delivered, `${receipt.sessionId}${suffix}`), await readFile(join(producedDir, `${receipt.sessionId}${suffix}`))) + } + + const alone = runCli(reader, ['verify-receipt', delivered]) + expect(alone.status, `${alone.stderr}\n${alone.stdout}`).toBe(1) + expect(alone.stdout).toContain('integrity: ESTABLISHED') + expect(alone.stdout).toContain('provenance: NOT ESTABLISHED') + expect(alone.stderr).not.toContain('Git repository') + + // The key arriving from the producer rather than from the receipt is the + // whole of what makes the next run mean anything more than the last one. + const keyPath = join(reader, 'producer.pem') + await writeFile(keyPath, receipt.evidence.publicKey!) + const attributed = runCli(reader, ['verify-receipt', join(delivered, `${receipt.sessionId}.json`), '--public-key', keyPath]) + expect(attributed.status, `${attributed.stderr}\n${attributed.stdout}`).toBe(0) + expect(attributed.stdout).toContain('provenance: ESTABLISHED') + + await writeFile(join(delivered, `${receipt.sessionId}.md`), '# CodeTruss receipt — PASS\n') + const tampered = runCli(reader, ['verify-receipt', delivered, '--public-key', keyPath]) + expect(tampered.status).toBe(2) + expect(tampered.stdout).toContain('integrity: NOT ESTABLISHED') + expect(tampered.stdout).toContain('provenance: NOT CHECKED') + }, 30_000) }) diff --git a/packages/cli/test/verify-receipt.test.ts b/packages/cli/test/verify-receipt.test.ts new file mode 100644 index 0000000..60499aa --- /dev/null +++ b/packages/cli/test/verify-receipt.test.ts @@ -0,0 +1,254 @@ +import { createHash, generateKeyPairSync, sign } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { renderMarkdown, verifyReceipt, verifyReceiptIntegrity, writeReceipt } from '../src/receipt.js' +import { runVerifyReceiptCommand } from '../src/verify-receipt-command.js' +import { loadSigningKey, sha256 } from '../src/signing.js' +import { LOCAL_ANALYSIS_PROFILE, type Receipt } from '../src/types.js' + +const originalKey = process.env.CODETRUSS_SIGNING_KEY +afterEach(() => { if (originalKey === undefined) delete process.env.CODETRUSS_SIGNING_KEY; else process.env.CODETRUSS_SIGNING_KEY = originalKey }) + +/** + * The rendering of the fixture below, one digest per profile version. + * + * These are recomputed only when the fixture itself changes — never to + * accommodate a reworded profile block, which is the exact edit they exist to + * stop. A superseded block is the wording some receipt on someone else's disk + * was signed against; changing it silently un-verifies that receipt. + */ +const FROZEN_MARKDOWN_SHA256: Record = { + 'local-registry-v1': '928da3c48ba873b8234573eea6510b0dc80b32ff12d822f51a68a23943097597', + 'local-registry-v2': '113d62dee72cce21ca004ce3bf748cc8ff08a8bcceacfdc65100c5b21487dea2', + 'local-registry-v3': 'f38a8bde572f2c83ddcd76e167762acc80c0e603d2e45a43d3b4dd86aef1579a', + 'local-registry-v4': '288de3e266dd208735854a35ec467caa44307414121020e062eec58e808cb66b', +} + +/** Every analysis profile whose Markdown wording is frozen inside signed receipts. */ +const FROZEN_PROFILES = [ + { id: 'local-registry-v1', profile: { id: 'local-registry-v1', omittedPasses: ['graph', 'sast'], scoreStatus: 'not-computed' } }, + { id: 'local-registry-v2', profile: { id: 'local-registry-v2', omittedPasses: ['graph'], localPasses: ['local-sast'], scoreStatus: 'not-computed' } }, + { id: 'local-registry-v3', profile: { id: 'local-registry-v3', omittedPasses: ['graph'], localPasses: ['local-sast'], scoreStatus: 'not-computed' } }, + { id: 'local-registry-v4', profile: LOCAL_ANALYSIS_PROFILE }, +] as const + +function fixture(profile: unknown = LOCAL_ANALYSIS_PROFILE, patch = 'diff evidence'): Receipt { + const now = new Date('2026-07-12T21:00:00.123Z') + return { + receiptVersion: 1, sessionId: '20260712T210000123Z-abcdef', createdAt: now.toISOString(), finishedAt: now.toISOString(), durationMs: 0, + mode: 'review', task: 'third-party receipt', repoRoot: '/repo', startCommit: 'abc', endCommit: 'abc', + git: { baselineTree: 'a'.repeat(40), finalTree: 'b'.repeat(40) }, policy: { sha256: 'c'.repeat(64) }, startDirty: false, startDirtyFiles: [], + scope: { allow: ['src/**'], deny: [] }, files: [], diff: { + sha256: createHash('sha256').update(patch).digest('hex'), + bytes: Buffer.byteLength(patch), + totalBytes: Buffer.byteLength(patch), + truncated: false, + }, + analyzers: { + passes: [], findings: [], index: { totalLoc: 0, languages: {}, primaryLanguage: null }, + ...(profile ? { analysisProfile: profile } : { scores: { health: 100, debt: 100, architecture: 100, security: 100, docs: 100 } }), + } as Receipt['analyzers'], + verifications: [], coverageNotes: ['local'], verdict: 'PASS', reasons: ['no changes'], evidence: {}, + } +} + +/** + * A receipt produced somewhere else: signed by a key that is not the one this + * machine holds, and delivered as bytes on disk. + */ +async function foreignReceipt(profile: unknown = LOCAL_ANALYSIS_PROFILE): Promise<{ + dir: string + jsonPath: string + receipt: Receipt + signerPublicKeyPath: string + signerFingerprint: string +}> { + const root = await mkdtemp(join(tmpdir(), 'codetruss-foreign-')) + const dir = join(root, 'delivered') + process.env.CODETRUSS_SIGNING_KEY = join(root, 'their-signing.pem') + const receipt = fixture(profile) + const paths = await writeReceipt(dir, receipt, 'diff evidence') + const signer = await loadSigningKey() + const signerPublicKeyPath = join(root, 'their-public.pem') + await writeFile(signerPublicKeyPath, signer.publicKey) + // The reader's own install holds a different key, as any real reader's would. + process.env.CODETRUSS_SIGNING_KEY = join(root, 'my-signing.pem') + await loadSigningKey(true) + return { dir, jsonPath: paths.json, receipt, signerPublicKeyPath, signerFingerprint: signer.fingerprint } +} + +function capture(): { write: (text: string) => void; text: () => string } { + const chunks: string[] = [] + return { write: (text) => { chunks.push(text) }, text: () => chunks.join('') } +} + +describe('verifying a receipt you did not produce', () => { + it('establishes integrity from the receipt alone and refuses to call that verified', async () => { + const { jsonPath, signerFingerprint } = await foreignReceipt() + const output = capture() + + const code = await runVerifyReceiptCommand(jsonPath, [], output.write) + + expect(code).toBe(1) + expect(output.text()).toContain('integrity: ESTABLISHED') + expect(output.text()).toContain('provenance: NOT ESTABLISHED') + expect(output.text()).toContain(signerFingerprint) + // The one sentence that keeps the two claims apart. + expect(output.text()).toContain('It does not prove who that someone is') + expect(output.text()).toContain('--public-key') + // A green word, or a green exit, would let a reader stop reading here. + expect(output.text()).not.toContain('VERIFIED') + }) + + it('establishes provenance only against a key supplied out of band', async () => { + const { jsonPath, signerPublicKeyPath } = await foreignReceipt() + const output = capture() + + const code = await runVerifyReceiptCommand(jsonPath, [signerPublicKeyPath], output.write) + + expect(code).toBe(0) + expect(output.text()).toContain('integrity: ESTABLISHED') + expect(output.text()).toContain('provenance: ESTABLISHED') + expect(output.text()).toContain(signerPublicKeyPath) + // Even both claims together stop short of the run having happened. + expect(output.text()).toContain('Neither proves the analysis described here actually ran') + }) + + it('accepts the delivered directory as well as the receipt file', async () => { + const { dir, signerPublicKeyPath } = await foreignReceipt() + const output = capture() + + expect(await runVerifyReceiptCommand(dir, [signerPublicKeyPath], output.write)).toBe(0) + expect(output.text()).toContain('integrity: ESTABLISHED') + }) + + it('reports a tampered receipt as not intact and does not attribute it to anyone', async () => { + const { jsonPath, dir, receipt, signerPublicKeyPath } = await foreignReceipt() + const markdownPath = join(dir, `${receipt.sessionId}.md`) + await writeFile(markdownPath, `${await readFile(markdownPath, 'utf8')}\n- and nothing was found\n`) + const output = capture() + + const code = await runVerifyReceiptCommand(jsonPath, [signerPublicKeyPath], output.write) + + expect(code).toBe(2) + expect(output.text()).toContain('integrity: NOT ESTABLISHED') + expect(output.text()).toContain('FAIL Markdown rendering reproduces from the signed JSON') + // A key match must never print as provenance over bytes that failed. + expect(output.text()).toContain('provenance: NOT CHECKED') + expect(output.text()).not.toContain('provenance: ESTABLISHED') + expect(output.text()).toContain('Do not rely on this receipt') + }) + + it('reports an edited-and-resigned receipt as intact but not from the key you trust', async () => { + // The forgery this separation exists to catch: internally consistent bytes, + // a perfectly valid signature, and a signer nobody asked you to trust. + const { jsonPath, dir, receipt, signerPublicKeyPath } = await foreignReceipt() + const forged = JSON.parse(await readFile(jsonPath, 'utf8')) as Receipt + forged.verdict = 'PASS' + forged.reasons = ['nothing to report'] + const attacker = generateKeyPairSync('ed25519') + forged.evidence.publicKey = attacker.publicKey.export({ type: 'spki', format: 'pem' }).toString() + forged.evidence.keyFingerprint = sha256(attacker.publicKey.export({ type: 'spki', format: 'der' })).slice(0, 16) + forged.evidence.markdownSha256 = sha256(renderMarkdown(forged)) + const forgedJson = `${JSON.stringify(forged, null, 2)}\n` + await writeFile(jsonPath, forgedJson) + await writeFile(join(dir, `${receipt.sessionId}.md`), renderMarkdown(forged)) + await writeFile(join(dir, `${receipt.sessionId}.sig`), `${sign(null, Buffer.from(forgedJson), attacker.privateKey).toString('base64')}\n`) + const output = capture() + + const code = await runVerifyReceiptCommand(jsonPath, [signerPublicKeyPath], output.write) + + expect(code).toBe(1) + expect(output.text()).toContain('integrity: ESTABLISHED') + expect(output.text()).toContain('provenance: NOT ESTABLISHED') + expect(output.text()).toContain('is not the key you supplied') + }) + + it('says what could not be checked when the patch is withheld, rather than passing over it', async () => { + const { jsonPath, dir, receipt, signerPublicKeyPath } = await foreignReceipt() + await rm(join(dir, `${receipt.sessionId}.patch`)) + const output = capture() + + const code = await runVerifyReceiptCommand(jsonPath, [signerPublicKeyPath], output.write) + + expect(code).toBe(0) + expect(output.text()).toContain('skip captured patch matches the signed receipt') + expect(output.text()).toContain('was not published with this receipt; its signed SHA-256 is') + // The headline must carry the hole in the evidence, not only the check list. + expect(output.text()).toContain('integrity: ESTABLISHED — over what was published; 1 recorded piece of evidence is absent and unchecked') + }) + + it('will not follow a companion file name that points outside the delivered receipt', async () => { + const { jsonPath } = await foreignReceipt() + const hostile = JSON.parse(await readFile(jsonPath, 'utf8')) as Receipt + hostile.evidence.signatureFile = '../../../../etc/passwd' + await writeFile(jsonPath, `${JSON.stringify(hostile, null, 2)}\n`) + + const result = await verifyReceiptIntegrity(jsonPath) + + expect(result.intact).toBe(false) + expect(result.checks.some((check) => check.detail?.includes('plain file name beside the receipt'))).toBe(true) + }) + + it('refuses a receipt whose recorded fingerprint does not match its own embedded key', async () => { + const { jsonPath } = await foreignReceipt() + const edited = JSON.parse(await readFile(jsonPath, 'utf8')) as Receipt + edited.evidence.keyFingerprint = '0'.repeat(16) + await writeFile(jsonPath, `${JSON.stringify(edited, null, 2)}\n`) + const output = capture() + + expect(await runVerifyReceiptCommand(jsonPath, [], output.write)).toBe(2) + expect(output.text()).toContain('FAIL recorded key fingerprint matches the embedded public key') + }) +}) + +describe('the repository path is unchanged', () => { + it('still refuses a foreign receipt, and now names the command that can check it', async () => { + const { dir, receipt } = await foreignReceipt() + + await expect(verifyReceipt(dir, receipt.sessionId)).rejects.toThrow(/does not match trusted key/) + await expect(verifyReceipt(dir, receipt.sessionId)).rejects.toThrow(/codetruss verify-receipt/) + }) + + it('still verifies a receipt the repository does trust', async () => { + const root = await mkdtemp(join(tmpdir(), 'codetruss-trusted-')) + const dir = join(root, 'receipts') + process.env.CODETRUSS_SIGNING_KEY = join(root, 'signing.pem') + const receipt = fixture() + await writeReceipt(dir, receipt, 'diff evidence') + const key = await loadSigningKey() + + await expect(verifyReceipt(dir, receipt.sessionId, [key.publicKey])).resolves.toMatchObject({ verdict: 'PASS' }) + }) +}) + +describe('frozen profile renderings', () => { + it.each(FROZEN_PROFILES)('verifies a $id receipt through both paths', async ({ profile }) => { + const { dir, jsonPath, receipt, signerPublicKeyPath } = await foreignReceipt(profile) + + // The trusted path, given the signer's key, and the third-party path must + // accept exactly the same signed Markdown bytes. + await expect(verifyReceipt(dir, receipt.sessionId, [await readFile(signerPublicKeyPath, 'utf8')])) + .resolves.toMatchObject({ verdict: 'PASS' }) + const result = await verifyReceiptIntegrity(jsonPath) + expect(result.intact).toBe(true) + expect(result.checks.filter((check) => check.status === 'failed')).toEqual([]) + }) + + it.each(FROZEN_PROFILES)('renders $id to the exact bytes its signature covers', async ({ profile }) => { + const { dir, receipt } = await foreignReceipt(profile) + const markdown = await readFile(join(dir, `${receipt.sessionId}.md`), 'utf8') + + // The digest of the rendering, pinned. A superseded profile block may never + // be reworded: receipts already signed against it would stop verifying. + expect(sha256(markdown)).toBe(FROZEN_MARKDOWN_SHA256[profile.id]) + }) + + it('accepts a legacy score-bearing receipt through the third-party path', async () => { + const { jsonPath } = await foreignReceipt(null) + + await expect(verifyReceiptIntegrity(jsonPath)).resolves.toMatchObject({ intact: true }) + }) +}) diff --git a/public/downloads/codetruss-cli-0.2.44.sbom.cdx.json b/public/downloads/codetruss-cli-0.2.44.sbom.cdx.json new file mode 100644 index 0000000..6d293c5 --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.44.sbom.cdx.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "serialNumber": "urn:uuid:07c0de0b-d5eb-51d9-94cd-389e5ce448ff", + "specVersion": "1.6", + "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.44", + "name": "@codetruss/cli", + "version": "0.2.44", + "description": "Local-first scope, quality, and verification receipts for coding agents", + "licenses": [ + { + "license": { + "name": "CodeTruss CLI Proprietary License" + } + } + ], + "purl": "pkg:npm/%40codetruss/cli@0.2.44" + }, + "properties": [ + { + "name": "codetruss:distribution", + "value": "single-file JavaScript bundle" + }, + { + "name": "codetruss:runtimeDependencies", + "value": "0" + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "name": "@codetruss/analyzer-engine", + "version": "0.1.0", + "licenses": [ + { + "license": { + "name": "CodeTruss CLI Proprietary License" + } + } + ], + "purl": "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/balanced-match@4.0.4", + "name": "balanced-match", + "version": "4.0.4", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "purl": "pkg:npm/balanced-match@4.0.4", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/brace-expansion@5.0.7", + "name": "brace-expansion", + "version": "5.0.7", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "purl": "pkg:npm/brace-expansion@5.0.7", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/minimatch@10.2.5", + "name": "minimatch", + "version": "10.2.5", + "licenses": [ + { + "license": { + "id": "BlueOak-1.0.0" + } + } + ], + "purl": "pkg:npm/minimatch@10.2.5", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/yaml@2.9.0", + "name": "yaml", + "version": "2.9.0", + "licenses": [ + { + "license": { + "id": "ISC" + } + } + ], + "purl": "pkg:npm/yaml@2.9.0", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "dependsOn": [] + }, + { + "ref": "pkg:npm/%40codetruss/cli@0.2.44", + "dependsOn": [ + "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "pkg:npm/minimatch@10.2.5", + "pkg:npm/yaml@2.9.0" + ] + }, + { + "ref": "pkg:npm/balanced-match@4.0.4", + "dependsOn": [] + }, + { + "ref": "pkg:npm/brace-expansion@5.0.7", + "dependsOn": [ + "pkg:npm/balanced-match@4.0.4" + ] + }, + { + "ref": "pkg:npm/minimatch@10.2.5", + "dependsOn": [ + "pkg:npm/brace-expansion@5.0.7" + ] + }, + { + "ref": "pkg:npm/yaml@2.9.0", + "dependsOn": [] + } + ] +} diff --git a/public/downloads/codetruss-cli-0.2.44.tgz b/public/downloads/codetruss-cli-0.2.44.tgz new file mode 100644 index 0000000..9bfb2a8 Binary files /dev/null and b/public/downloads/codetruss-cli-0.2.44.tgz differ diff --git a/public/downloads/codetruss-cli-0.2.44.tgz.sha256 b/public/downloads/codetruss-cli-0.2.44.tgz.sha256 new file mode 100644 index 0000000..1fa74d9 --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.44.tgz.sha256 @@ -0,0 +1 @@ +8a405b77b2042c8daca6f2def782fa8e38a2ffcec4ebe9643631d3491af50884 codetruss-cli-0.2.44.tgz diff --git a/public/downloads/codetruss-cli-latest.json b/public/downloads/codetruss-cli-latest.json index 751a778..3b92d3c 100644 --- a/public/downloads/codetruss-cli-latest.json +++ b/public/downloads/codetruss-cli-latest.json @@ -1,13 +1,13 @@ { "name": "@codetruss/cli", - "version": "0.2.43", - "url": "/downloads/codetruss-cli-0.2.43.tgz", + "version": "0.2.44", + "url": "/downloads/codetruss-cli-0.2.44.tgz", "latestUrl": "/downloads/codetruss-cli-latest.tgz", - "sha256": "e759a7ffce29ae0a0ba61aeb9bd8819cd75adb114abd1410a7e309d6f8a923c3", - "sbomUrl": "/downloads/codetruss-cli-0.2.43.sbom.cdx.json", - "sbomSha256": "18fc146333cb4afb963eca9542f09e597e0667ffd3e5210950606087aa69eefc", + "sha256": "8a405b77b2042c8daca6f2def782fa8e38a2ffcec4ebe9643631d3491af50884", + "sbomUrl": "/downloads/codetruss-cli-0.2.44.sbom.cdx.json", + "sbomSha256": "0a0f28ffcc31affe5a1557ecd172aefde43df72184ab4806da6dd7249a7a8268", "node": ">=20.9.0", "repository": "https://github.com/CodeTruss/codetruss-cli", - "releaseUrl": "https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.43", - "attestationCommand": "gh attestation verify codetruss-cli-0.2.43.tgz --repo CodeTruss/codetruss-cli" + "releaseUrl": "https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.44", + "attestationCommand": "gh attestation verify codetruss-cli-0.2.44.tgz --repo CodeTruss/codetruss-cli" } diff --git a/public/downloads/codetruss-cli-latest.sbom.cdx.json b/public/downloads/codetruss-cli-latest.sbom.cdx.json index 37c5ddf..6d293c5 100644 --- a/public/downloads/codetruss-cli-latest.sbom.cdx.json +++ b/public/downloads/codetruss-cli-latest.sbom.cdx.json @@ -1,15 +1,15 @@ { "$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json", "bomFormat": "CycloneDX", - "serialNumber": "urn:uuid:2705e33b-069a-5e1c-bdeb-35df5e5aaab4", + "serialNumber": "urn:uuid:07c0de0b-d5eb-51d9-94cd-389e5ce448ff", "specVersion": "1.6", "version": 1, "metadata": { "component": { "type": "application", - "bom-ref": "pkg:npm/%40codetruss/cli@0.2.43", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.44", "name": "@codetruss/cli", - "version": "0.2.43", + "version": "0.2.44", "description": "Local-first scope, quality, and verification receipts for coding agents", "licenses": [ { @@ -18,7 +18,7 @@ } } ], - "purl": "pkg:npm/%40codetruss/cli@0.2.43" + "purl": "pkg:npm/%40codetruss/cli@0.2.44" }, "properties": [ { @@ -139,7 +139,7 @@ "dependsOn": [] }, { - "ref": "pkg:npm/%40codetruss/cli@0.2.43", + "ref": "pkg:npm/%40codetruss/cli@0.2.44", "dependsOn": [ "pkg:npm/%40codetruss/analyzer-engine@0.1.0", "pkg:npm/minimatch@10.2.5", diff --git a/public/downloads/codetruss-cli-latest.tgz b/public/downloads/codetruss-cli-latest.tgz index 19e8e73..9bfb2a8 100644 Binary files a/public/downloads/codetruss-cli-latest.tgz and b/public/downloads/codetruss-cli-latest.tgz differ diff --git a/public/downloads/codetruss-cli-latest.tgz.sha256 b/public/downloads/codetruss-cli-latest.tgz.sha256 index b864423..84b4cee 100644 --- a/public/downloads/codetruss-cli-latest.tgz.sha256 +++ b/public/downloads/codetruss-cli-latest.tgz.sha256 @@ -1 +1 @@ -e759a7ffce29ae0a0ba61aeb9bd8819cd75adb114abd1410a7e309d6f8a923c3 codetruss-cli-latest.tgz +8a405b77b2042c8daca6f2def782fa8e38a2ffcec4ebe9643631d3491af50884 codetruss-cli-latest.tgz diff --git a/release-reference.json b/release-reference.json index df8fe06..7adad50 100644 --- a/release-reference.json +++ b/release-reference.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "version": "0.2.43", - "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.43.tgz", - "archiveSha256": "e759a7ffce29ae0a0ba61aeb9bd8819cd75adb114abd1410a7e309d6f8a923c3", - "sbomSha256": "18fc146333cb4afb963eca9542f09e597e0667ffd3e5210950606087aa69eefc", - "bundleSha256": "c7a44e0966d1b140c82dc08aabf569214c16684ccda800bb7368404c0fc09fe1" + "version": "0.2.44", + "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.44.tgz", + "archiveSha256": "8a405b77b2042c8daca6f2def782fa8e38a2ffcec4ebe9643631d3491af50884", + "sbomSha256": "0a0f28ffcc31affe5a1557ecd172aefde43df72184ab4806da6dd7249a7a8268", + "bundleSha256": "33baae597f5f307ddb24e222c00ccef2d2a34090a3aa50b34279545e65ab73b9" }