diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a2c4a7..1e3bf11 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.40 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.40), +The current public release is [v0.2.41 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.41), distributed from . The npm `latest` tag is still [`@codetruss/cli@0.2.24`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.24): @@ -16,6 +16,83 @@ were superseded before distribution. No unreleased changes. +## 0.2.41 — 2026-08-07 + +- **You can dismiss a finding you have judged wrong, in the place the judgement + belongs.** A `codetruss-ignore: ` comment on a finding's own line, or + on a comment-only line directly above it, marks that finding as dismissed. A + marker trailing a line of code governs only that line, so it can never reach a + neighbouring finding its author never looked at. **A dismissal never deletes + anything.** The finding, its location, and the exact reason its author gave + all survive into the signed receipt under a "Suppressed findings" heading, and + the reader decides whether the reason is good — a receipt whose evidence could + be erased by editing a comment would not be evidence, and "nothing was found" + must never be reachable that way. The reason is mandatory for the same reason: + the reason *is* the output, and "someone decided this was fine" is not + evidence. A bare `codetruss-ignore` therefore dismisses nothing and is + reported by location, so a developer who wrote one finds out why it did + nothing. Receipts that dismissed nothing are unchanged, byte for byte. +- **Python SQL injection is now caught through a cursor held in a local.** + `cur = conn.cursor()` then `cur.execute(f"... {user_input}")` — the canonical + psycopg/sqlite3/MySQLdb two-step — was invisible, because the sink test was + lexical and `cur` does not read as a database receiver. The receiver is now + resolved to its binding, so a name bound to a `.cursor()` call counts however + it is spelled, including `with conn.cursor() as cur:`. `executemany` joins + `execute`; `exec` stays name-gated, since a bare `.exec()` is far more often + `RegExp.prototype.exec` than SQL. Generic receiver names were not loosened, so + nothing else lost precision. +- **A stalled grammar-pack download now fails with a sentence instead of + hanging.** `codetruss grammars install` is a foreground command someone is + watching, and `fetch` will wait out a server that writes one byte and holds + the socket open forever. Two clocks bound it — a whole-transfer budget and an + idle budget — and the reason it was abandoned survives into the error, rather + than the bare "This operation was aborted" an abort produces on its own. Both + are generous enough that a slow connection is never mistaken for a hostile + origin. +- **A grammar pack's artifacts are bound by role, not by name prefix.** The + loader picked the first file whose name started with `tree-sitter-`, so a pack + carrying an extra artifact ordered ahead of the real grammar would have had + the extra one loaded — and the pin verifier, which only proved each digest + appeared somewhere, would not have caught it. Each role must now be filled by + exactly one pinned artifact; an ambiguous pack does not resolve at all. A pack + that fails this reports a runtime failure rather than a digest failure, so a + defect in the CLI's own pin never publishes a receipt accusing the user's + install of tampering. +- **`SECURITY.md` now states what the grammar-pack digest pin does not cover.** + The pin protects against a compromised download origin, which is what it was + built for. It cannot protect against a compromised build: the pin, the + published artifact, and the offline check that compares them all derive from + the same `node_modules` on the release machine. `pnpm grammars:attest` narrows + that window — it checks the lockfile digest against the npm registry, verifies + the registry's signature, and compares the downloaded tarball against the + files the pack is cut from — and the document says plainly that it does not + close it. +- **`hooks doctor` names which fields drifted and what to run.** It reported + only that an installed handler "differs", which reads identically for a config + installed several versions ago and a deliberate hand-edit, and named no + remedy. It now lists the drifted field names — enough to diagnose, without + putting handler command text in the message — and names the reinstall command. + This repository's own committed `.codex/hooks.json` was the config that + exposed it: several versions stale, missing `core.longpaths=true` and pinned + to the old Stop timeout. It has been refreshed, and a test now compares the + committed hook configuration against what the installer actually writes, so it + cannot drift again unnoticed. +- **Build attestation is verified against the CodeTruss organisation.** The CLI + repository moved from the `DeliriumPulse` account, and every release still in + circulation has been re-attested under the organisation, so one command + verifies all of them: `gh attestation verify --repo + CodeTruss/codetruss-cli`. The transferred `--repo DeliriumPulse/…` slug + returns HTTP 404 and is no longer advertised anywhere. The published manifest, + the verifier, and the verifier's own tests now derive that command from a + single module rather than each restating it; the Homebrew tap, plugin + marketplace and support links follow the organisation too. +- **Internal: `hooks.ts` is now seven modules behind an unchanged public + surface.** Installation, uninstallation, the doctor, the pre-commit block, the + agent handler shapes, the agent runner and executable resolution each have + their own file. No behaviour changed, and the hook tests are unmodified by + design — an unchanged test suite passing over a moved implementation is the + evidence that the move was only a move. + ## 0.2.40 — 2026-08-07 - **Python can now be analyzed locally, if you ask for it.** `codetruss diff --git a/SECURITY.md b/SECURITY.md index 562f92c..b631391 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,6 +30,49 @@ fix and disclosure timeline with the reporter. Good-faith research that avoids privacy violations, service disruption, data destruction, and access beyond what is needed to demonstrate the issue is welcome. +## Grammar packs: what the digest pin covers, and what it does not + +`codetruss grammars install python` downloads code that this CLI later executes +in your process. Every artifact is pinned to an exact SHA-256 compiled into the +binary, checked as the bytes arrive and re-checked on every load, and the buffer +that was hashed is the buffer that is executed — nothing is re-opened by path. + +That pin protects you against a **compromised download origin**. If codetruss.com +or anything between it and your machine serves different bytes, the install +fails and no Python is analyzed. It is a strong guarantee and it is the one the +pin was built for. + +It does **not** protect you against a compromised **build**. The pin, the +published artifact, and the offline check that compares them are all derived +from the same `node_modules` on the machine that cut the release. Anything able +to write there between `pnpm install` and the release command would poison all +three in one move, and every check would still pass. A digest pin can only ever +say "these are the bytes we published"; it cannot say "these are the bytes we +meant to publish". + +Two things narrow that window, and neither closes it: + +- **`pnpm grammars:attest` (run by the release).** The tarball digest recorded + in `pnpm-lock.yaml` is checked against what the npm registry serves for that + exact version, the registry's ECDSA signature over the version/digest pair is + verified against npm's published keys, the tarball is downloaded and hashed, + and its files are compared against the `node_modules` copies the pack is cut + from. A file dropped into `node_modules` fails the release. This runs on the + release machine, so a compromise deep enough to patch the script is not caught + by the script. +- **`pnpm grammars:verify` (runs in every build).** Re-derives the whole + generated pin from the published artifacts and compares it byte for byte, and + checks that the versions a pack claims are the versions the lockfile pins. It + is an internal-consistency proof, not an independent one. + +There is no reproducible build and no third-party rebuild of these artifacts. +Anyone can check the published bytes for themselves: each artifact is served +with a `.sha256` sidecar under `/downloads/grammars/`, the same digests appear +in the CLI's `src/grammar-pack-manifest.ts`, and the upstream packages +(`web-tree-sitter`, `tree-sitter-wasms`) are copied byte for byte with nothing +recompiled, so a published artifact can be diffed directly against the version +you install yourself. + ## Scope Security-sensitive surfaces include artifact/install integrity, receipt diff --git a/package.json b/package.json index 12f642d..3075109 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,10 @@ "test:install": "pnpm --filter @codetruss/cli test:install", "validate": "pnpm typecheck && pnpm release:artifact && pnpm test && pnpm release:verify && pnpm test:install" }, + "dependencies": { + "tree-sitter-wasms": "0.1.11", + "web-tree-sitter": "0.22.6" + }, "devDependencies": { "@codetruss/analyzer-engine": "workspace:*", "@codetruss/cli": "workspace:*" diff --git a/packages/analyzer-engine/src/index.ts b/packages/analyzer-engine/src/index.ts index df4f111..2403c65 100644 --- a/packages/analyzer-engine/src/index.ts +++ b/packages/analyzer-engine/src/index.ts @@ -1,6 +1,7 @@ export * from './types' export * from './registry' export * from './runner' +export * from './suppression' export * from './scoring' export * from './coverage' export * from './support' diff --git a/packages/analyzer-engine/src/security/rules.ts b/packages/analyzer-engine/src/security/rules.ts index e7cee39..e0447c6 100644 --- a/packages/analyzer-engine/src/security/rules.ts +++ b/packages/analyzer-engine/src/security/rules.ts @@ -90,7 +90,73 @@ const CHILD_PROCESS_RECEIVER = /(^|[._])(child_?process|cp|proc|shell)$/i const SQL_ALWAYS = new Set(['query', 'raw', 'unsafe', 'executequery', 'executeupdate', 'executesql', 'rawquery']) /** Method names that execute SQL only on a DB-ish receiver. */ -const SQL_GATED = new Set(['execute', 'exec']) +const SQL_GATED = new Set(['execute', 'exec', 'executemany']) +/** The DB-API cursor methods among {@link SQL_GATED}: for these the receiver may + * also be proven a cursor by its binding, not just by its name. `exec` stays + * name-gated — a bare `.exec()` is RegExp.prototype.exec far more often than + * it is SQL, and resolving its binding would buy nothing but scan time. */ +const CURSOR_METHODS = new Set(['execute', 'executemany']) + +/** A `.cursor()` call — the DB-API idiom that hands back a cursor. */ +function isCursorFactoryCall(node: SyntaxNode, lang: SastLanguage): boolean { + const c = asCall(unwrap(node, lang), lang) + return !!c && !!c.receiver && lc(c.method) === 'cursor' +} + +/** + * True when this call's receiver is a local BOUND to a DB cursor — + * `cur = conn.cursor()` or `with conn.cursor() as cur:` — rather than a name + * that merely reads DB-ish. + * + * {@link DB_RECEIVER} is a purely lexical test. It catches `cursor.execute(...)` + * and the direct chain `conn.cursor().execute(...)` (whose dotted receiver name + * is `conn.cursor`), but goes blind the moment the cursor is parked in a short + * local — `cur`, `c`, `crsr` — which is how most psycopg/sqlite3/MySQLdb code is + * actually written. Resolving the binding restores that canonical two-step form + * without loosening the lexical gate to generic names, which would cost + * precision everywhere else. + */ +function receiverBindsToCursor(call: NCall, lang: SastLanguage): boolean { + if (!call.receiver) return false + const name = identifierName(call.receiver, lang) + if (!name) return false + // Innermost enclosing scope that binds the name wins (program root last). + let scope: SyntaxNode | null = call.node.parent + for (let i = 0; i < 60 && scope; i++) { + const isRoot = scope.parent === null + if (isFunctionNode(scope, lang) || isRoot) { + const fn = isFunctionNode(scope, lang) ? asFunction(scope, lang) : null + const body = isRoot && !fn ? scope : fn?.body + if (body) { + const defs = [ + ...collectAssignments(body, lang).filter((a) => a.target === name).map((a) => a.value), + ...withAliasDefs(body, name, lang), + ] + if (defs.length > 0) return defs.some((d) => isCursorFactoryCall(d, lang)) + } + } + scope = scope.parent + } + return false +} + +/** `with as name:` bindings in a scope — python models them as an + * `as_pattern`, which {@link collectAssignments} does not treat as a def. */ +function withAliasDefs(body: SyntaxNode, name: string, lang: SastLanguage): SyntaxNode[] { + const out: SyntaxNode[] = [] + const visit = (node: SyntaxNode) => { + if (node !== body && isFunctionNode(node, lang)) return // separate scope + if (node.type === 'as_pattern') { + const alias = node.childForFieldName('alias') + const value = node.namedChildren[0] + const bound = alias ? identifierName(alias.namedChildren[0] ?? alias, lang) : null + if (value && bound === name) out.push(value) + } + for (const c of node.namedChildren) visit(c) + } + visit(body) + return out +} // NOTE: generic receiver names like `client`/`session` are deliberately NOT here. // They match Redis/DB/gRPC/GraphQL clients (`client.get(id)`), which are not HTTP @@ -140,10 +206,13 @@ export const TAINT_SINKS: TaintSink[] = [ title: 'SQL injection', message: 'Untrusted input is concatenated into a SQL query and executed. An attacker can alter the query to read or modify arbitrary data.', remediation: 'Use parameterized queries / prepared statements and pass user input as bound parameters, never string concatenation.', - match(call) { + match(call, lang) { const m = lc(call.method) if (SQL_ALWAYS.has(m)) return [0] - if (SQL_GATED.has(m) && DB_RECEIVER.test(call.receiverName ?? '')) return [0] + if (SQL_GATED.has(m)) { + if (DB_RECEIVER.test(call.receiverName ?? '')) return [0] + if (CURSOR_METHODS.has(m) && receiverBindsToCursor(call, lang)) return [0] + } // Go database/sql, gated on a DB-ish receiver. Context variants take // (ctx, query, ...args) so the SQL string is argument 1, not 0. if (DB_RECEIVER.test(call.receiverName ?? '')) { diff --git a/packages/analyzer-engine/src/suppression.ts b/packages/analyzer-engine/src/suppression.ts new file mode 100644 index 0000000..ebf00f6 --- /dev/null +++ b/packages/analyzer-engine/src/suppression.ts @@ -0,0 +1,105 @@ +import type { AnalyzerFinding, FindingSuppression, RepoIndex } from './types' + +/** + * Inline finding suppression: `codetruss-ignore: `. + * + * A developer who has judged a finding wrong needs a way to say so in the one + * place the judgement belongs — beside the code it is about. The marker mirrors + * `gitleaks:allow` in placement and differs from it in two deliberate ways. + * + * FIRST, a reason is mandatory. The marker's entire output is a line of evidence + * on a signed receipt, and "someone decided this was fine" is not evidence. A + * bare `codetruss-ignore` therefore suppresses nothing and is recorded as + * rejected, so the developer learns why their comment did nothing: a marker that + * silently fails is worse than no marker at all. + * + * SECOND, nothing here deletes a finding. This pass ANNOTATES: the finding keeps + * flowing through the delta, the passes and the receipt, carrying the reason it + * was dismissed. A receipt that quietly dropped a finding because a comment in + * the repository told it to would be a hole in the evidence chain — the whole + * claim of the artifact is that it states what was and was not flagged, and + * "nothing was found" must never be reachable by editing a comment. + */ +const MARKER_RE = /\bcodetruss-ignore\b[ \t]*(:[ \t]*(.*))?/ + +/** + * Comment terminators that would otherwise be read as part of the reason. + * `/* codetruss-ignore: deliberate *\/` is how this gets written in JS, CSS, + * Java and C; in HTML and Markdown it is ``. + */ +const COMMENT_CLOSE_RE = /\s*(\*\/|-->)\s*$/ + +/** + * Reasons are quoted verbatim into a signed receipt that is also validated + * against a bounded schema on sync. A reason longer than this is not a reason, + * and the cap stops one pathological comment from making a receipt unsyncable. + */ +const MAX_REASON_LENGTH = 500 + +interface ParsedMarker { + /** Text after the colon. Empty when the marker gave no reason. */ + reason: string + /** + * Nothing but whitespace and comment punctuation precedes the marker. + * + * A marker trailing a line of CODE was written about that code, so it governs + * only its own line. Letting it reach the line below would silently dismiss a + * neighbouring finding its author never looked at. + */ + commentOnly: boolean +} + +function parseMarker(line: string | undefined): ParsedMarker | null { + if (line === undefined) return null + const match = MARKER_RE.exec(line) + if (!match) return null + const reason = match[2] === undefined ? '' : match[2].replace(COMMENT_CLOSE_RE, '').trim().slice(0, MAX_REASON_LENGTH) + return { reason, commentOnly: !/[A-Za-z0-9_$]/.test(line.slice(0, match.index)) } +} + +/** The marker governing `line`: its own line, else a comment-only line above it. */ +function markerFor(lines: string[], line: number): { reason: string; markerLine: number } | null { + const own = parseMarker(lines[line - 1]) + if (own) return { reason: own.reason, markerLine: line } + const above = parseMarker(lines[line - 2]) + if (above?.commentOnly) return { reason: above.reason, markerLine: line - 1 } + return null +} + +/** + * Record on every finding whether an inline marker dismissed it. + * + * Returns new objects; the input is not mutated. Findings without BOTH a file + * and a line are returned untouched: a repository-level or whole-file finding + * has no line for a comment to sit beside, and picking one — the top of the + * file, the first match — would suppress by guesswork. + */ +export function annotateSuppressions(findings: AnalyzerFinding[], index: RepoIndex): AnalyzerFinding[] { + if (findings.length === 0) return findings + const lineCache = new Map() + const readLines = (path: string): string[] | null => { + const cached = lineCache.get(path) + if (cached !== undefined) return cached + const file = index.files.find((candidate) => candidate.path === path) + // Generated files are read here for the reason the secret scanner reads + // them: what the line says governs, whichever tool wrote it. + const content = file ? file.content ?? file.excludedContent ?? null : null + const lines = content === null ? null : content.split('\n') + lineCache.set(path, lines) + return lines + } + + return findings.map((finding) => { + if (!finding.filePath || !finding.line) return finding + const lines = readLines(finding.filePath) + if (!lines) return finding + const marker = markerFor(lines, finding.line) + if (!marker) return finding + const suppression: FindingSuppression = { + reason: marker.reason, + markerLine: marker.markerLine, + applied: marker.reason.length > 0, + } + return { ...finding, suppression } + }) +} diff --git a/packages/analyzer-engine/src/types.ts b/packages/analyzer-engine/src/types.ts index b39fc75..064ef0b 100644 --- a/packages/analyzer-engine/src/types.ts +++ b/packages/analyzer-engine/src/types.ts @@ -90,6 +90,28 @@ export interface FindingFix { safetyNote: string } +/** + * A developer's inline judgement that one finding is wrong, read from a + * `codetruss-ignore: ` comment beside the code. See `suppression.ts`. + * + * Attached to the finding rather than replacing it. A suppressed finding is + * still produced, still counted in the delta, and still written to the receipt + * — as suppressed, with its reason. Deleting it instead would let a comment in + * the repository decide what the signed evidence is allowed to say. + */ +export interface FindingSuppression { + /** The text after `codetruss-ignore:`. Empty only when `applied` is false. */ + reason: string + /** 1-based line carrying the marker: the finding's own line, or the one above. */ + markerLine: number + /** + * Whether the marker actually dismissed this finding. False for a marker that + * gave no reason — the finding stays reported, and the rejected marker is + * disclosed so a comment is never seen to fail in silence. + */ + applied: boolean +} + export interface AnalyzerFinding { category: FindingCategory severity: FindingSeverity @@ -104,6 +126,8 @@ export interface AnalyzerFinding { effort?: 'low' | 'medium' | 'high' metadata?: Record analyzerId?: string + /** Present only when an inline marker was found beside this finding. */ + suppression?: FindingSuppression } export interface AnalyzerRunResult { diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index f5f7b1a..6df6954 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,83 @@ checksums are published at ` comment on a finding's own line, or + on a comment-only line directly above it, marks that finding as dismissed. A + marker trailing a line of code governs only that line, so it can never reach a + neighbouring finding its author never looked at. **A dismissal never deletes + anything.** The finding, its location, and the exact reason its author gave + all survive into the signed receipt under a "Suppressed findings" heading, and + the reader decides whether the reason is good — a receipt whose evidence could + be erased by editing a comment would not be evidence, and "nothing was found" + must never be reachable that way. The reason is mandatory for the same reason: + the reason *is* the output, and "someone decided this was fine" is not + evidence. A bare `codetruss-ignore` therefore dismisses nothing and is + reported by location, so a developer who wrote one finds out why it did + nothing. Receipts that dismissed nothing are unchanged, byte for byte. +- **Python SQL injection is now caught through a cursor held in a local.** + `cur = conn.cursor()` then `cur.execute(f"... {user_input}")` — the canonical + psycopg/sqlite3/MySQLdb two-step — was invisible, because the sink test was + lexical and `cur` does not read as a database receiver. The receiver is now + resolved to its binding, so a name bound to a `.cursor()` call counts however + it is spelled, including `with conn.cursor() as cur:`. `executemany` joins + `execute`; `exec` stays name-gated, since a bare `.exec()` is far more often + `RegExp.prototype.exec` than SQL. Generic receiver names were not loosened, so + nothing else lost precision. +- **A stalled grammar-pack download now fails with a sentence instead of + hanging.** `codetruss grammars install` is a foreground command someone is + watching, and `fetch` will wait out a server that writes one byte and holds + the socket open forever. Two clocks bound it — a whole-transfer budget and an + idle budget — and the reason it was abandoned survives into the error, rather + than the bare "This operation was aborted" an abort produces on its own. Both + are generous enough that a slow connection is never mistaken for a hostile + origin. +- **A grammar pack's artifacts are bound by role, not by name prefix.** The + loader picked the first file whose name started with `tree-sitter-`, so a pack + carrying an extra artifact ordered ahead of the real grammar would have had + the extra one loaded — and the pin verifier, which only proved each digest + appeared somewhere, would not have caught it. Each role must now be filled by + exactly one pinned artifact; an ambiguous pack does not resolve at all. A pack + that fails this reports a runtime failure rather than a digest failure, so a + defect in the CLI's own pin never publishes a receipt accusing the user's + install of tampering. +- **`SECURITY.md` now states what the grammar-pack digest pin does not cover.** + The pin protects against a compromised download origin, which is what it was + built for. It cannot protect against a compromised build: the pin, the + published artifact, and the offline check that compares them all derive from + the same `node_modules` on the release machine. `pnpm grammars:attest` narrows + that window — it checks the lockfile digest against the npm registry, verifies + the registry's signature, and compares the downloaded tarball against the + files the pack is cut from — and the document says plainly that it does not + close it. +- **`hooks doctor` names which fields drifted and what to run.** It reported + only that an installed handler "differs", which reads identically for a config + installed several versions ago and a deliberate hand-edit, and named no + remedy. It now lists the drifted field names — enough to diagnose, without + putting handler command text in the message — and names the reinstall command. + This repository's own committed `.codex/hooks.json` was the config that + exposed it: several versions stale, missing `core.longpaths=true` and pinned + to the old Stop timeout. It has been refreshed, and a test now compares the + committed hook configuration against what the installer actually writes, so it + cannot drift again unnoticed. +- **Build attestation is verified against the CodeTruss organisation.** The CLI + repository moved from the `DeliriumPulse` account, and every release still in + circulation has been re-attested under the organisation, so one command + verifies all of them: `gh attestation verify --repo + CodeTruss/codetruss-cli`. The transferred `--repo DeliriumPulse/…` slug + returns HTTP 404 and is no longer advertised anywhere. The published manifest, + the verifier, and the verifier's own tests now derive that command from a + single module rather than each restating it; the Homebrew tap, plugin + marketplace and support links follow the organisation too. +- **Internal: `hooks.ts` is now seven modules behind an unchanged public + surface.** Installation, uninstallation, the doctor, the pre-commit block, the + agent handler shapes, the agent runner and executable resolution each have + their own file. No behaviour changed, and the hook tests are unmodified by + design — an unchanged test suite passing over a moved implementation is the + evidence that the move was only a move. + ## 0.2.40 — 2026-08-07 - **Python can now be analyzed locally, if you ask for it.** `codetruss diff --git a/packages/cli/SECURITY.md b/packages/cli/SECURITY.md index 562f92c..b631391 100644 --- a/packages/cli/SECURITY.md +++ b/packages/cli/SECURITY.md @@ -30,6 +30,49 @@ fix and disclosure timeline with the reporter. Good-faith research that avoids privacy violations, service disruption, data destruction, and access beyond what is needed to demonstrate the issue is welcome. +## Grammar packs: what the digest pin covers, and what it does not + +`codetruss grammars install python` downloads code that this CLI later executes +in your process. Every artifact is pinned to an exact SHA-256 compiled into the +binary, checked as the bytes arrive and re-checked on every load, and the buffer +that was hashed is the buffer that is executed — nothing is re-opened by path. + +That pin protects you against a **compromised download origin**. If codetruss.com +or anything between it and your machine serves different bytes, the install +fails and no Python is analyzed. It is a strong guarantee and it is the one the +pin was built for. + +It does **not** protect you against a compromised **build**. The pin, the +published artifact, and the offline check that compares them are all derived +from the same `node_modules` on the machine that cut the release. Anything able +to write there between `pnpm install` and the release command would poison all +three in one move, and every check would still pass. A digest pin can only ever +say "these are the bytes we published"; it cannot say "these are the bytes we +meant to publish". + +Two things narrow that window, and neither closes it: + +- **`pnpm grammars:attest` (run by the release).** The tarball digest recorded + in `pnpm-lock.yaml` is checked against what the npm registry serves for that + exact version, the registry's ECDSA signature over the version/digest pair is + verified against npm's published keys, the tarball is downloaded and hashed, + and its files are compared against the `node_modules` copies the pack is cut + from. A file dropped into `node_modules` fails the release. This runs on the + release machine, so a compromise deep enough to patch the script is not caught + by the script. +- **`pnpm grammars:verify` (runs in every build).** Re-derives the whole + generated pin from the published artifacts and compares it byte for byte, and + checks that the versions a pack claims are the versions the lockfile pins. It + is an internal-consistency proof, not an independent one. + +There is no reproducible build and no third-party rebuild of these artifacts. +Anyone can check the published bytes for themselves: each artifact is served +with a `.sha256` sidecar under `/downloads/grammars/`, the same digests appear +in the CLI's `src/grammar-pack-manifest.ts`, and the upstream packages +(`web-tree-sitter`, `tree-sitter-wasms`) are copied byte for byte with nothing +recompiled, so a published artifact can be diffed directly against the version +you install yourself. + ## Scope Security-sensitive surfaces include artifact/install integrity, receipt diff --git a/packages/cli/package.json b/packages/cli/package.json index 4f84173..f519951 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@codetruss/cli", - "version": "0.2.40", + "version": "0.2.41", "description": "Local-first scope, quality, and verification receipts for coding agents", "license": "SEE LICENSE IN LICENSE", "type": "module", @@ -21,11 +21,11 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/DeliriumPulse/codetruss-cli.git" + "url": "git+https://github.com/CodeTruss/codetruss-cli.git" }, "homepage": "https://codetruss.com/cli", "bugs": { - "url": "https://github.com/DeliriumPulse/codetruss-cli/issues" + "url": "https://github.com/CodeTruss/codetruss-cli/issues" }, "keywords": [ "ai-agent", @@ -42,7 +42,8 @@ "build": "node scripts/build.mjs", "release:artifact": "node scripts/build-release.mjs", "verify:artifact": "node scripts/verify-release.mjs", - "release:grammars": "node scripts/build-grammar-packs.mjs", + "release:grammars": "node scripts/attest-grammar-sources.mjs && node scripts/build-grammar-packs.mjs", + "attest:grammars": "node scripts/attest-grammar-sources.mjs", "verify:grammars": "node scripts/verify-grammar-packs.mjs", "test:install": "node scripts/test-install.mjs", "prepack": "pnpm build", diff --git a/packages/cli/scripts/attest-grammar-sources.mjs b/packages/cli/scripts/attest-grammar-sources.mjs new file mode 100644 index 0000000..2f43ec8 --- /dev/null +++ b/packages/cli/scripts/attest-grammar-sources.mjs @@ -0,0 +1,128 @@ +/** + * Prove a grammar pack's upstream bytes against something other than this + * machine's `node_modules`. + * + * The offline gate (`verify-grammar-packs.mjs`) chains published artifact → + * `node_modules` → generated pin, which is an internal-consistency proof: one + * compromised `node_modules` on the release machine poisons the artifact, the + * pin AND the verifier in a single move, and every check still passes. This + * script is the missing link at the top of that chain. It takes the tarball + * digest the workspace lockfile recorded at install time, confirms the registry + * still serves that exact digest for that version, verifies the registry's + * signature over the pair, downloads the tarball, checks it hashes to the same + * digest, and compares the files inside it against what the build script would + * read out of `node_modules`. + * + * What that establishes, precisely: the bytes this release publishes are the + * bytes npm signed for the versions the lockfile pins. What it does not: it runs + * on the release machine, so a compromise deep enough to patch this script is + * not caught by this script. The point is to close the cheap window — a poisoned + * file dropped into `node_modules` — not to claim a reproducible build. + * + * Network, therefore release-time and not a build gate: `pnpm build` must not + * depend on npm being reachable. Run by `pnpm grammars:release` before anything + * is published, and standalone with `pnpm grammars:attest`. + */ +import { readFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { GRAMMAR_PACKS, GRAMMAR_PACK_PROVENANCE } from './grammar-pack-sources.mjs' +import { lockedSourcePackage } from './lockfile-integrity.mjs' +import { readTarballMembers, subresourceIntegrity, verifyRegistrySignature } from './npm-tarball.mjs' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const packageDir = resolve(scriptDir, '..') +const repoRoot = resolve(packageDir, '../..') +const REGISTRY = 'https://registry.npmjs.org' +const NETWORK_TIMEOUT_MS = 60_000 + +async function fetchJson(url) { + const response = await fetch(url, { signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS) }) + if (!response.ok) throw new Error(`${url} responded ${response.status}`) + return response.json() +} + +async function fetchBytes(url) { + const response = await fetch(url, { signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS) }) + if (!response.ok) throw new Error(`${url} responded ${response.status}`) + return Buffer.from(await response.arrayBuffer()) +} + +export async function attestGrammarSources({ + moduleDir = join(repoRoot, 'node_modules'), + lockfilePath = join(repoRoot, 'pnpm-lock.yaml'), + registry = REGISTRY, +} = {}) { + const lockfile = await readFile(lockfilePath, 'utf8') + const keys = (await fetchJson(`${registry}/-/npm/v1/keys`)).keys ?? [] + const attested = [] + + for (const source of Object.values(GRAMMAR_PACK_PROVENANCE)) { + const { package: name, version } = source + const locked = lockedSourcePackage(lockfile, name, version) + + const metadata = await fetchJson(`${registry}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`) + const dist = metadata.dist ?? {} + // The lockfile is the anchor, not the registry: if they disagree, this + // workspace installed something the registry no longer serves under that + // version, which is exactly the event worth stopping a release for. + if (dist.integrity !== locked.integrity) { + throw new Error( + `${name}@${version}: pnpm-lock.yaml records ${locked.integrity} but the registry serves ${dist.integrity}`, + ) + } + const [signature] = dist.signatures ?? [] + if (!signature) throw new Error(`${name}@${version} carries no registry signature`) + verifyRegistrySignature({ name, version, integrity: dist.integrity, signature, keys }) + + const tarball = await fetchBytes(dist.tarball) + const downloaded = subresourceIntegrity(tarball) + if (downloaded !== locked.integrity) { + throw new Error(`${dist.tarball} hashes to ${downloaded}, expected ${locked.integrity}`) + } + attested.push({ name, version, integrity: locked.integrity, members: readTarballMembers(tarball) }) + } + + const files = [] + for (const pack of GRAMMAR_PACKS) { + for (const file of pack.files) { + const [sourcePackage, ...rest] = file.source + const upstream = attested.find((entry) => entry.name === sourcePackage) + if (!upstream) { + throw new Error(`${pack.name}/${file.name} is cut from ${sourcePackage}, which has no provenance entry`) + } + const member = upstream.members.get(['package', ...rest].join('/')) + if (!member) { + throw new Error( + `${sourcePackage}@${upstream.version} does not contain ${rest.join('/')}; ` + + 'the pack source path is wrong or the tarball layout changed', + ) + } + const installed = await readFile(join(moduleDir, ...file.source)) + if (!member.equals(installed)) { + throw new Error( + `node_modules/${file.source.join('/')} does not match the signed ${sourcePackage}@${upstream.version} ` + + 'tarball; do not publish from this checkout until that is explained', + ) + } + files.push({ + name: file.name, + source: `${sourcePackage}@${upstream.version}`, + sha256: createHash('sha256').update(member).digest('hex'), + }) + } + } + + return { sources: attested.map(({ name, version, integrity }) => ({ name, version, integrity })), files } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { + const result = await attestGrammarSources() + for (const source of result.sources) { + process.stdout.write(`attested ${source.name}@${source.version} against its signed registry tarball\n`) + } + for (const file of result.files) { + process.stdout.write(` ${file.name}: ${file.sha256} (from ${file.source})\n`) + } +} diff --git a/packages/cli/scripts/build-grammar-packs.mjs b/packages/cli/scripts/build-grammar-packs.mjs index 9595270..62af8c2 100644 --- a/packages/cli/scripts/build-grammar-packs.mjs +++ b/packages/cli/scripts/build-grammar-packs.mjs @@ -21,6 +21,7 @@ import { packDirectoryName, packFileUrl, } from './grammar-pack-sources.mjs' +import { renderGrammarPin, renderGrammarSiteManifest } from './grammar-pack-render.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const packageDir = resolve(scriptDir, '..') @@ -70,7 +71,13 @@ for (const pack of GRAMMAR_PACKS) { const target = join(packDir, file.name) await publishImmutable(target, bytes, `${directoryName}/${file.name}`) await writeFile(`${target}.sha256`, `${digest} ${file.name}\n`, 'utf8') - files.push({ name: file.name, url: packFileUrl(pack, file.name), bytes: bytes.length, sha256: digest }) + files.push({ + name: file.name, + role: file.role, + url: packFileUrl(pack, file.name), + bytes: bytes.length, + sha256: digest, + }) } // A stray file in a published pack directory is a supply-chain question, not @@ -94,55 +101,11 @@ for (const pack of GRAMMAR_PACKS) { export const GRAMMAR_MANIFEST_NAME = 'codetruss-grammars-latest.json' -const manifest = `${JSON.stringify({ packs: manifestPacks }, null, 2)}\n` -await writeFile(join(grammarDir, GRAMMAR_MANIFEST_NAME), manifest, 'utf8') - -/** - * The compiled-in pin. - * - * Generated rather than hand-maintained because a hand-copied digest is a digest - * that eventually disagrees with the bytes, and this one is the only thing - * standing between a user and executing whatever a compromised origin served. - */ -const pin = `/** - * Pinned grammar-pack digests. GENERATED by scripts/build-grammar-packs.mjs. - * - * Do not edit by hand. These digests are what \`codetruss grammars install\` - * checks a download against, and what every subsequent load re-checks on disk. - * A pack whose bytes do not hash to exactly these values is never loaded, and - * the run discloses Python as skipped instead. - */ - -export interface PinnedGrammarFile { - name: string - /** Path under the downloads origin, e.g. \`/downloads/grammars/python-1.0.0/…\`. */ - url: string - bytes: number - sha256: string -} - -export interface PinnedGrammarPack { - name: string - version: string - /** The \`SastLanguage\` this pack enables. */ - language: string - runtime: { package: string; version: string } - grammar: { package: string; version: string } - files: PinnedGrammarFile[] -} - -export const PINNED_GRAMMAR_PACKS: readonly PinnedGrammarPack[] = ${JSON.stringify(manifestPacks, null, 2) - .split('\n') - .join('\n')} - -/** Pack names this CLI build knows how to install. */ -export const GRAMMAR_PACK_NAMES: readonly string[] = PINNED_GRAMMAR_PACKS.map((pack) => pack.name) - -export function pinnedGrammarPack(name: string): PinnedGrammarPack | undefined { - return PINNED_GRAMMAR_PACKS.find((pack) => pack.name === name) -} -` -await writeFile(join(packageDir, 'src', 'grammar-pack-manifest.ts'), pin, 'utf8') +// Both generated files come from one renderer, shared with the verifier, so the +// check that they are what this script would write today is a byte comparison +// rather than a re-implementation of the format. +await writeFile(join(grammarDir, GRAMMAR_MANIFEST_NAME), renderGrammarSiteManifest(manifestPacks), 'utf8') +await writeFile(join(packageDir, 'src', 'grammar-pack-manifest.ts'), renderGrammarPin(manifestPacks), 'utf8') for (const pack of manifestPacks) { const total = pack.files.reduce((sum, file) => sum + file.bytes, 0) diff --git a/packages/cli/scripts/build-release.mjs b/packages/cli/scripts/build-release.mjs index b274e21..2825fda 100644 --- a/packages/cli/scripts/build-release.mjs +++ b/packages/cli/scripts/build-release.mjs @@ -7,6 +7,7 @@ import { spawnSync } from 'node:child_process' import { assertChangelogPolicy } from './changelog-policy.mjs' import { buildDeterministicPackageArchive } from './deterministic-package.mjs' import { assertReleasePackagePolicy } from './release-package-policy.mjs' +import { buildReleaseManifest, serialiseReleaseManifest } from './release-metadata.mjs' import { verifyDeterministicPackageArchive } from './verify-deterministic-package.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) @@ -73,19 +74,7 @@ try { await writeFile(`${latest}.sha256`, `${sha256} ${latestName}\n`, 'utf8') await writeFile( join(downloadDir, 'codetruss-cli-latest.json'), - `${JSON.stringify({ - name: pkg.name, - version: pkg.version, - url: `/downloads/${versionedName}`, - latestUrl: `/downloads/${latestName}`, - sha256, - sbomUrl: `/downloads/${versionedSbomName}`, - sbomSha256, - node: pkg.engines.node, - repository: 'https://github.com/DeliriumPulse/codetruss-cli', - releaseUrl: `https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v${pkg.version}`, - attestationCommand: `gh attestation verify ${versionedName} --repo DeliriumPulse/codetruss-cli`, - }, null, 2)}\n`, + serialiseReleaseManifest(buildReleaseManifest({ pkg, sha256, sbomSha256 })), 'utf8', ) diff --git a/packages/cli/scripts/grammar-pack-render.mjs b/packages/cli/scripts/grammar-pack-render.mjs new file mode 100644 index 0000000..b8092d0 --- /dev/null +++ b/packages/cli/scripts/grammar-pack-render.mjs @@ -0,0 +1,110 @@ +/** + * The exact text of the two files the grammar release GENERATES. + * + * Shared by `build-grammar-packs.mjs`, which writes them, and + * `verify-grammar-packs.mjs`, which re-derives them from the published bytes and + * compares. One renderer is the point: a verifier that re-implements the format + * eventually disagrees with the generator over whitespace and gets loosened + * until it proves nothing. Comparing whole files instead of hunting for expected + * substrings is what makes the check total — it fails on a digest bound to the + * wrong name, on an entry nobody published, and on any hand edit to a file whose + * header says not to edit it by hand. + */ + +/** + * What the CLI's loader does with each artifact. + * + * Roles exist because the loader used to pick the grammar with + * `files.find(f => f.name.startsWith('tree-sitter-'))` — the FIRST match. A pack + * carrying an extra `tree-sitter-evil.wasm` ordered ahead of the real grammar + * would have had it loaded as the grammar. Every artifact now says what it is, + * and the loader requires exactly one artifact per role, so "whichever matched + * the name shape first" is not a question that can be asked. + */ +export const GRAMMAR_FILE_ROLES = ['runtime', 'runtime-wasm', 'grammar'] + +/** + * The published index at `/downloads/grammars/codetruss-grammars-latest.json`. + * + * Deliberately carries no `role`. This file is the site's record of what exists, + * where, how large and with which digest; role is an instruction to the CLI's + * loader, and the CLI never fetches this file — it ships the pin instead. Adding + * a field with no consumer would rewrite bytes already served for nothing. + */ +export function renderGrammarSiteManifest(packs) { + const published = packs.map((pack) => ({ + ...pack, + files: pack.files.map((file) => ({ + name: file.name, + url: file.url, + bytes: file.bytes, + sha256: file.sha256, + })), + })) + return `${JSON.stringify({ packs: published }, null, 2)}\n` +} + +/** + * The compiled-in pin, `packages/cli/src/grammar-pack-manifest.ts`. + * + * Generated rather than hand-maintained because a hand-copied digest is a digest + * that eventually disagrees with the bytes, and this one is the only thing + * standing between a user and executing whatever a compromised origin served. + */ +export function renderGrammarPin(packs) { + const pinned = packs.map((pack) => ({ + ...pack, + files: pack.files.map((file) => ({ + name: file.name, + role: file.role, + url: file.url, + bytes: file.bytes, + sha256: file.sha256, + })), + })) + return `/** + * Pinned grammar-pack digests. GENERATED by scripts/build-grammar-packs.mjs. + * + * Do not edit by hand. These digests are what \`codetruss grammars install\` + * checks a download against, and what every subsequent load re-checks on disk. + * A pack whose bytes do not hash to exactly these values is never loaded, and + * the run discloses Python as skipped instead. + * + * \`pnpm grammars:verify\` re-derives this entire file from the published + * artifacts and compares it byte for byte, so an edit here — a digest moved to + * another name, an artifact nobody published, a fourth entry — fails the build + * rather than shipping. + */ + +/** What the loader does with an artifact. Exactly one artifact per role, per pack. */ +export type GrammarFileRole = ${GRAMMAR_FILE_ROLES.map((role) => `'${role}'`).join(' | ')} + +export interface PinnedGrammarFile { + name: string + role: GrammarFileRole + /** Path under the downloads origin, e.g. \`/downloads/grammars/python-1.0.0/…\`. */ + url: string + bytes: number + sha256: string +} + +export interface PinnedGrammarPack { + name: string + version: string + /** The \`SastLanguage\` this pack enables. */ + language: string + runtime: { package: string; version: string } + grammar: { package: string; version: string } + files: PinnedGrammarFile[] +} + +export const PINNED_GRAMMAR_PACKS: readonly PinnedGrammarPack[] = ${JSON.stringify(pinned, null, 2)} + +/** Pack names this CLI build knows how to install. */ +export const GRAMMAR_PACK_NAMES: readonly string[] = PINNED_GRAMMAR_PACKS.map((pack) => pack.name) + +export function pinnedGrammarPack(name: string): PinnedGrammarPack | undefined { + return PINNED_GRAMMAR_PACKS.find((pack) => pack.name === name) +} +` +} diff --git a/packages/cli/scripts/grammar-pack-sources.mjs b/packages/cli/scripts/grammar-pack-sources.mjs index 1ebf4be..2a29920 100644 --- a/packages/cli/scripts/grammar-pack-sources.mjs +++ b/packages/cli/scripts/grammar-pack-sources.mjs @@ -34,15 +34,19 @@ export const GRAMMAR_PACK_PROVENANCE = { * `language` is the {@link SastLanguage} the pack enables. `files` are copied * byte-for-byte; nothing is recompiled, minified or repackaged, so a reviewer * can diff a published artifact against `node_modules` directly. + * + * `role` says what the CLI's loader does with an artifact, and is the only thing + * it selects on — never the shape of the file name. Exactly one artifact per + * role, checked by the loader and by `pnpm grammars:verify`. */ export const GRAMMAR_PACKS = [ { name: 'python', language: 'python', files: [ - { name: 'tree-sitter.js', source: ['web-tree-sitter', 'tree-sitter.js'] }, - { name: 'tree-sitter.wasm', source: ['web-tree-sitter', 'tree-sitter.wasm'] }, - { name: 'tree-sitter-python.wasm', source: ['tree-sitter-wasms', 'out', 'tree-sitter-python.wasm'] }, + { name: 'tree-sitter.js', role: 'runtime', source: ['web-tree-sitter', 'tree-sitter.js'] }, + { name: 'tree-sitter.wasm', role: 'runtime-wasm', source: ['web-tree-sitter', 'tree-sitter.wasm'] }, + { name: 'tree-sitter-python.wasm', role: 'grammar', source: ['tree-sitter-wasms', 'out', 'tree-sitter-python.wasm'] }, ], }, ] diff --git a/packages/cli/scripts/lockfile-integrity.mjs b/packages/cli/scripts/lockfile-integrity.mjs new file mode 100644 index 0000000..73f6efe --- /dev/null +++ b/packages/cli/scripts/lockfile-integrity.mjs @@ -0,0 +1,81 @@ +/** + * What the workspace lockfile says about a package a grammar pack is cut from. + * + * The pack's provenance (`web-tree-sitter@0.22.6`, `tree-sitter-wasms@0.1.11`) + * is hand-written in `grammar-pack-sources.mjs` and, until this existed, was + * checked against nothing: a dependency bump with a stale provenance string + * would have published a pack that misidentified its own source. Reading the + * lockfile makes that string an assertion the build can fail on. + * + * What a lockfile `integrity` covers is worth being precise about, because it + * bounds what any check built on it can claim: it is the digest of the packed + * npm TARBALL, not of the individual files inside it. It cannot be turned into + * a per-file digest without the tarball itself, which pnpm does not keep — its + * store is content-addressed per file. Re-deriving a pack's digests from an + * independently obtained, signed tarball is therefore a network operation and + * lives in `attest-grammar-sources.mjs`, not in this offline gate. + */ + +/** + * A YAML parser this is not. + * + * The lockfile's `packages:` block is machine-generated with a fixed two-space + * shape, and the alternative — a YAML dependency in a script whose entire job is + * checking supply-chain claims — adds a package to trust in order to verify + * packages. Anything this reader fails to understand is reported as "not found", + * which fails the caller closed. + */ +export function lockfileEntries(lockfile, packageName) { + const lines = lockfile.split('\n') + const start = lines.indexOf('packages:') + if (start === -1) throw new Error('pnpm-lock.yaml has no packages section') + + const entries = [] + for (let index = start + 1; index < lines.length; index += 1) { + const line = lines[index] + // A non-blank line at column zero is the next top-level section. + if (line.trim() !== '' && !line.startsWith(' ')) break + // Scoped names are emitted quoted, because `@types/node@20.1.0:` would + // otherwise start with YAML's reserved `@`. + const header = /^ {2}(?:'([^']+)'|(\S+)):\s*$/.exec(line) + const key = header?.[1] ?? header?.[2] + if (!key) continue + // Peer-suffixed keys (`ts-api-utils@2.5.0(typescript@5.9.3)`) carry a second + // `@` inside the parentheses; drop the suffix before splitting on version. + const bare = key.replace(/\([^)]*\)$/, '') + const at = bare.lastIndexOf('@') + if (at <= 0 || bare.slice(0, at) !== packageName) continue + const integrity = /integrity:\s*(sha\d+-[A-Za-z0-9+/=]+)/.exec(lines[index + 1] ?? '') + if (!integrity) continue + entries.push({ version: bare.slice(at + 1), integrity: integrity[1] }) + } + return entries +} + +/** + * The single lockfile entry for a package, or an error naming what is wrong. + * + * "Exactly one" matters: two resolutions of the same package in one tree means + * the bytes a pack was cut from depend on which copy the build script's relative + * path happened to reach, and a provenance string cannot describe that honestly. + */ +export function lockedSourcePackage(lockfile, packageName, expectedVersion) { + const entries = lockfileEntries(lockfile, packageName) + if (entries.length === 0) { + throw new Error(`pnpm-lock.yaml does not pin ${packageName}; grammar pack provenance cannot be checked`) + } + if (entries.length > 1) { + throw new Error( + `pnpm-lock.yaml resolves ${packageName} to ${entries.length} versions ` + + `(${entries.map((entry) => entry.version).join(', ')}); a grammar pack cannot say which one it was cut from`, + ) + } + const [entry] = entries + if (entry.version !== expectedVersion) { + throw new Error( + `grammar pack provenance says ${packageName}@${expectedVersion} but pnpm-lock.yaml pins ` + + `${packageName}@${entry.version}; update GRAMMAR_PACK_PROVENANCE and bump GRAMMAR_PACK_VERSION`, + ) + } + return entry +} diff --git a/packages/cli/scripts/npm-tarball.mjs b/packages/cli/scripts/npm-tarball.mjs new file mode 100644 index 0000000..a785d79 --- /dev/null +++ b/packages/cli/scripts/npm-tarball.mjs @@ -0,0 +1,109 @@ +/** + * Reading and authenticating an npm tarball, without adding a dependency. + * + * Used by `attest-grammar-sources.mjs` to obtain a grammar pack's upstream bytes + * from something other than the release machine's `node_modules`. A tar reader + * and an ECDSA verify are about eighty lines between them; a package that did + * this for us would be one more thing on the release machine that the check is + * supposed to be independent of. + */ +import { createHash, createPublicKey, createVerify } from 'node:crypto' +import { gunzipSync } from 'node:zlib' + +const BLOCK = 512 + +function trimmed(header, offset, length) { + const raw = header.subarray(offset, offset + length).toString('utf8') + const end = raw.indexOf('\0') + return (end === -1 ? raw : raw.slice(0, end)).trim() +} + +function octal(header, offset, length) { + const text = trimmed(header, offset, length) + return text === '' ? 0 : Number.parseInt(text, 8) +} + +/** + * The header's own checksum, so a misparse is loud. + * + * Every failure mode of this reader has to end in "member not found" or "digest + * mismatch", never in silently returning the wrong bytes. Checking the checksum + * is what makes a wrong offset stop immediately instead of walking into the + * middle of a file and calling it a header. + */ +function headerChecksumOk(header) { + const recorded = octal(header, 148, 8) + let sum = 0 + for (let index = 0; index < BLOCK; index += 1) { + sum += index >= 148 && index < 156 ? 0x20 : header[index] + } + return sum === recorded +} + +/** + * Every regular file in a gzipped tar, keyed by its full path. + * + * Handles the ustar name/prefix split and GNU long names. Pax extended headers + * are skipped rather than interpreted: an npm package whose paths need them + * would resolve to the truncated ustar name here, the caller would not find the + * member it asked for, and the attestation fails closed instead of comparing + * against the wrong file. + */ +export function readTarballMembers(tarball) { + const tar = Buffer.from(gunzipSync(tarball)) + const members = new Map() + let longName + let offset = 0 + + while (offset + BLOCK <= tar.length) { + const header = tar.subarray(offset, offset + BLOCK) + // Two zero blocks end the archive; one is enough to stop reading. + if (header.every((byte) => byte === 0)) break + if (!headerChecksumOk(header)) throw new Error(`tar header at offset ${offset} has a bad checksum`) + + const size = octal(header, 124, 12) + const type = String.fromCharCode(header[156] || 0x30) + const name = longName ?? trimmed(header, 0, 100) + const prefix = trimmed(header, 345, 155) + longName = undefined + + offset += BLOCK + const body = tar.subarray(offset, offset + size) + offset += Math.ceil(size / BLOCK) * BLOCK + + if (type === 'L') { + longName = body.toString('utf8').replace(/\0[\s\S]*$/, '') + continue + } + if (type !== '0' && type !== '\0') continue + members.set(prefix ? `${prefix}/${name}` : name, Buffer.from(body)) + } + return members +} + +/** The `sha512-…` form npm and pnpm both record, for a set of bytes. */ +export function subresourceIntegrity(bytes) { + return `sha512-${createHash('sha512').update(bytes).digest('base64')}` +} + +/** + * The registry's signature over `name@version:integrity`. + * + * This is what makes the tarball's digest something other than a number the + * same server chose: npm signs the association between a version and its + * tarball digest with a key published at `/-/npm/v1/keys`, so a registry mirror + * or a proxy cannot rewrite one without the other. + */ +export function verifyRegistrySignature({ name, version, integrity, signature, keys }) { + const key = keys.find((candidate) => candidate.keyid === signature.keyid) + if (!key) throw new Error(`registry has no published key ${signature.keyid} for ${name}@${version}`) + if (key.keytype !== 'ecdsa-sha2-nistp256' || key.scheme !== 'ecdsa-sha2-nistp256') { + throw new Error(`unsupported registry key type ${key.keytype}/${key.scheme}`) + } + const publicKey = createPublicKey({ key: Buffer.from(key.key, 'base64'), format: 'der', type: 'spki' }) + const verified = createVerify('SHA256') + .update(`${name}@${version}:${integrity}`) + .end() + .verify(publicKey, Buffer.from(signature.sig, 'base64')) + if (!verified) throw new Error(`registry signature for ${name}@${version} does not verify`) +} diff --git a/packages/cli/scripts/release-metadata.mjs b/packages/cli/scripts/release-metadata.mjs new file mode 100644 index 0000000..e39d1a5 --- /dev/null +++ b/packages/cli/scripts/release-metadata.mjs @@ -0,0 +1,47 @@ +// Canonical release identity. The published manifest is byte-compared by +// `verify-release.mjs`, so the builder, the verifier, and the verifier's own +// tests must all derive it from this module rather than restating it. + +export const CLI_REPOSITORY_SLUG = 'CodeTruss/codetruss-cli' +export const CLI_REPOSITORY_URL = `https://github.com/${CLI_REPOSITORY_SLUG}` + +// The CLI repository moved from the `DeliriumPulse` account to the `CodeTruss` +// organisation on 2026-08-07. Releases followed the transfer, and every release +// still in circulation has since been re-attested under the organisation, so one +// command covers all of them. The transferred `--repo DeliriumPulse/…` slug +// returns HTTP 404 and must never be advertised. +// +// This deliberately does NOT vary by version. It did briefly, because the +// artifacts built before the move were only attested under the building account +// and needed `--owner DeliriumPulse --signer-workflow …`. Re-attestation removed +// that split; keeping the branch would have published the weaker command for +// versions the simple one now verifies, and contradicted the release notes, +// which print only this form. + +/** The `gh` invocation that verifies any published artifact's provenance. */ +export function attestationCommand(artifactName) { + return `gh attestation verify ${artifactName} --repo ${CLI_REPOSITORY_SLUG}` +} + +/** The canonical `codetruss-cli-latest.json` body, in its byte-compared key order. */ +export function buildReleaseManifest({ pkg, sha256, sbomSha256 }) { + const versionedName = `codetruss-cli-${pkg.version}.tgz` + return { + name: pkg.name, + version: pkg.version, + url: `/downloads/${versionedName}`, + latestUrl: '/downloads/codetruss-cli-latest.tgz', + sha256, + sbomUrl: `/downloads/codetruss-cli-${pkg.version}.sbom.cdx.json`, + sbomSha256, + node: pkg.engines.node, + repository: CLI_REPOSITORY_URL, + releaseUrl: `${CLI_REPOSITORY_URL}/releases/tag/v${pkg.version}`, + attestationCommand: attestationCommand(versionedName), + } +} + +/** Serialised exactly as the published manifest is written and compared. */ +export function serialiseReleaseManifest(manifest) { + return `${JSON.stringify(manifest, null, 2)}\n` +} diff --git a/packages/cli/scripts/release-package-policy.mjs b/packages/cli/scripts/release-package-policy.mjs index a4da426..e220da3 100644 --- a/packages/cli/scripts/release-package-policy.mjs +++ b/packages/cli/scripts/release-package-policy.mjs @@ -1,3 +1,5 @@ +import { CLI_REPOSITORY_URL } from './release-metadata.mjs' + function hasEntries(value) { if (value === undefined || value === null) return false if (Array.isArray(value)) return value.length > 0 @@ -15,9 +17,9 @@ export function assertReleasePackagePolicy(pkg) { throw new Error('published CLI package identity or executable mapping is invalid') } if ( - pkg.repository?.url !== 'git+https://github.com/DeliriumPulse/codetruss-cli.git' + pkg.repository?.url !== `git+${CLI_REPOSITORY_URL}.git` || pkg.homepage !== 'https://codetruss.com/cli' - || pkg.bugs?.url !== 'https://github.com/DeliriumPulse/codetruss-cli/issues' + || pkg.bugs?.url !== `${CLI_REPOSITORY_URL}/issues` ) { throw new Error('published CLI package does not identify the canonical source, product, and support pages') } diff --git a/packages/cli/scripts/test-release-verifier.mjs b/packages/cli/scripts/test-release-verifier.mjs index f18471e..1ad5454 100644 --- a/packages/cli/scripts/test-release-verifier.mjs +++ b/packages/cli/scripts/test-release-verifier.mjs @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { buildDeterministicPackageArchive, PACKAGE_ARCHIVE_FILES } from './deterministic-package.mjs' +import { attestationCommand, buildReleaseManifest, serialiseReleaseManifest } from './release-metadata.mjs' import { verifyRelease } from './verify-release.mjs' const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') @@ -27,23 +28,53 @@ async function writeRelease(archivePackageDir = packageDir) { await writeFile(join(downloadDir, 'codetruss-cli-latest.sbom.cdx.json'), sbom) await writeFile(join(downloadDir, `${versionedName}.sha256`), `${sha256} ${versionedName}\n`) await writeFile(join(downloadDir, `${latestName}.sha256`), `${sha256} ${latestName}\n`) - await writeFile(join(downloadDir, 'codetruss-cli-latest.json'), `${JSON.stringify({ - name: pkg.name, - version: pkg.version, - url: `/downloads/${versionedName}`, - latestUrl: `/downloads/${latestName}`, - sha256, - sbomUrl: `/downloads/${versionedSbomName}`, - sbomSha256, - node: pkg.engines.node, - repository: 'https://github.com/DeliriumPulse/codetruss-cli', - releaseUrl: `https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v${pkg.version}`, - attestationCommand: `gh attestation verify ${versionedName} --repo DeliriumPulse/codetruss-cli`, - }, null, 2)}\n`) + await writeFile( + join(downloadDir, 'codetruss-cli-latest.json'), + serialiseReleaseManifest(buildReleaseManifest({ pkg, sha256, sbomSha256 })), + ) return { versionedName, latestName } } try { + // The published manifest is a copy-paste instruction. A command that names a + // repository GitHub cannot resolve is worse than no command at all, so pin the + // exact string. Every release still in circulation is attested under the + // organisation, so the command does not vary by version — the assertions below + // are what would fail if a version-dependent form were reintroduced. + assert.equal( + attestationCommand('codetruss-cli-0.2.41.tgz'), + 'gh attestation verify codetruss-cli-0.2.41.tgz --repo CodeTruss/codetruss-cli', + ) + for (const version of ['0.2.14', '0.2.36', '0.2.39', '0.2.40', '0.2.41', '0.3.0', '1.0.0']) { + const command = attestationCommand(`codetruss-cli-${version}.tgz`) + assert.equal( + command, + `gh attestation verify codetruss-cli-${version}.tgz --repo CodeTruss/codetruss-cli`, + `${version} must advertise the organisation-scoped command, which is what verifies today`, + ) + assert.doesNotMatch( + command, + /--repo DeliriumPulse\//, + `${version} must not advertise the transferred repository slug, which returns HTTP 404`, + ) + } + + // The live pointer file is served to users between releases, so it must stay + // in step with the generator without waiting for the next artifact build. + const shippedManifest = JSON.parse( + await readFile(join(resolve(packageDir, '../..'), 'public', 'downloads', 'codetruss-cli-latest.json'), 'utf8'), + ) + assert.equal( + shippedManifest.attestationCommand, + attestationCommand(`codetruss-cli-${shippedManifest.version}.tgz`), + 'public/downloads/codetruss-cli-latest.json advertises a stale attestation command', + ) + assert.equal(shippedManifest.repository, 'https://github.com/CodeTruss/codetruss-cli') + assert.equal( + shippedManifest.releaseUrl, + `https://github.com/CodeTruss/codetruss-cli/releases/tag/v${shippedManifest.version}`, + ) + await mkdir(downloadDir, { recursive: true }) const names = await writeRelease() await verifyRelease({ packageDir, downloadDir }) diff --git a/packages/cli/scripts/verify-grammar-packs.mjs b/packages/cli/scripts/verify-grammar-packs.mjs index 0c67032..b1af26c 100644 --- a/packages/cli/scripts/verify-grammar-packs.mjs +++ b/packages/cli/scripts/verify-grammar-packs.mjs @@ -1,6 +1,7 @@ /** * Gate: the site may not advertise a grammar pack it is not serving, and the - * CLI may not ship a digest that no published artifact hashes to. + * CLI may not ship a pin that is not exactly what the published artifacts + * generate. * * Runs in the root `build` chain beside `cli:artifact:verify`. A checksum alone * would only prove the published file is internally consistent; this also @@ -8,6 +9,19 @@ * pack cannot silently diverge from the runtime and grammar the hosted audit * loads. That equality is the entire basis for calling the two paths the same * analysis. + * + * The two GENERATED files are compared whole, against the same renderer that + * writes them. Matching expected substrings instead — the shape this check used + * to have — proves only that each digest appears somewhere in the pin: it says + * nothing about which file name a digest is bound to, and nothing at all about + * entries the pin contains that nobody published. A hand-edited pin carrying the + * three real digests plus a fourth artifact passed that check. + * + * What this gate does NOT establish is stated plainly in `SECURITY.md`: it reads + * `node_modules` on the same machine that produced both the artifact and the + * pin, so it is an internal-consistency proof, not an independent one. The + * independent check — upstream tarball, registry signature, lockfile integrity — + * needs the network and lives in `attest-grammar-sources.mjs`. */ import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' @@ -20,6 +34,8 @@ import { packDirectoryName, packFileUrl, } from './grammar-pack-sources.mjs' +import { GRAMMAR_FILE_ROLES, renderGrammarPin, renderGrammarSiteManifest } from './grammar-pack-render.mjs' +import { lockedSourcePackage } from './lockfile-integrity.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const packageDir = resolve(scriptDir, '..') @@ -29,16 +45,49 @@ function sha256(bytes) { return createHash('sha256').update(bytes).digest('hex') } +/** + * Every artifact says what the loader should do with it, exactly once. + * + * The loader refuses a pack that declares two grammars, but it should never see + * one: this is the hand-edited file where such a pack would be introduced. + */ +function assertRoles(pack, directoryName) { + for (const file of pack.files) { + if (!GRAMMAR_FILE_ROLES.includes(file.role)) { + throw new Error(`${directoryName}/${file.name} declares unknown role ${JSON.stringify(file.role)}`) + } + } + for (const role of GRAMMAR_FILE_ROLES) { + const named = pack.files.filter((file) => file.role === role).map((file) => file.name) + if (named.length !== 1) { + throw new Error( + `${directoryName} declares ${named.length} artifacts for role ${role}` + + `${named.length ? ` (${named.join(', ')})` : ''}; exactly one is required`, + ) + } + } +} + export async function verifyGrammarPacks({ grammarDir = join(repoRoot, 'public', 'downloads', 'grammars'), moduleDir = join(repoRoot, 'node_modules'), pinPath = join(packageDir, 'src', 'grammar-pack-manifest.ts'), + lockfilePath = join(repoRoot, 'pnpm-lock.yaml'), } = {}) { + // The provenance a pack claims must be the version this workspace actually + // installs, or the pack's own description of where its bytes came from is + // unchecked prose. + const lockfile = await readFile(lockfilePath, 'utf8') + for (const source of Object.values(GRAMMAR_PACK_PROVENANCE)) { + lockedSourcePackage(lockfile, source.package, source.version) + } + const manifestPacks = [] for (const pack of GRAMMAR_PACKS) { const directoryName = packDirectoryName(pack) const packDir = join(grammarDir, directoryName) + assertRoles(pack, directoryName) const files = [] for (const file of pack.files) { @@ -69,7 +118,13 @@ export async function verifyGrammarPacks({ if (sidecar !== `${digest} ${file.name}\n`) { throw new Error(`grammar pack ${directoryName}/${file.name}.sha256 does not match its artifact`) } - files.push({ name: file.name, url: packFileUrl(pack, file.name), bytes: published.length, sha256: digest }) + files.push({ + name: file.name, + role: file.role, + url: packFileUrl(pack, file.name), + bytes: published.length, + sha256: digest, + }) } manifestPacks.push({ @@ -82,27 +137,24 @@ export async function verifyGrammarPacks({ }) } - const manifestPath = join(grammarDir, 'codetruss-grammars-latest.json') - const manifest = await readFile(manifestPath, 'utf8') - const expectedManifest = `${JSON.stringify({ packs: manifestPacks }, null, 2)}\n` - if (manifest !== expectedManifest) { + const manifest = await readFile(join(grammarDir, 'codetruss-grammars-latest.json'), 'utf8') + if (manifest !== renderGrammarSiteManifest(manifestPacks)) { throw new Error('codetruss-grammars-latest.json does not match the published packs; run pnpm grammars:release') } // The CLI's compiled-in pin is the security boundary. If it disagrees with the - // published artifact, either every install fails closed or — worse, if the pin - // were stale in the other direction — the CLI would accept bytes nobody - // reviewed. Neither may reach a build. + // published artifacts, either every install fails closed or — worse, if the + // pin were wrong in the other direction — the CLI would accept bytes nobody + // reviewed. Whole-file, because every part of this file is load-bearing: the + // digests, the names they are bound to, the URLs they are fetched from, the + // roles the loader selects on, and the lookup function underneath them. const pin = await readFile(pinPath, 'utf8') - for (const pack of manifestPacks) { - for (const file of pack.files) { - if (!pin.includes(`"sha256": "${file.sha256}"`)) { - throw new Error( - `src/grammar-pack-manifest.ts does not pin ${pack.name}-${pack.version}/${file.name}; ` - + 'run pnpm grammars:release', - ) - } - } + const expectedPin = renderGrammarPin(manifestPacks) + if (pin !== expectedPin) { + throw new Error( + 'src/grammar-pack-manifest.ts is not what the published grammar packs generate ' + + `(${sha256(pin)} vs ${sha256(expectedPin)}); it is generated, not hand-written — run pnpm grammars:release`, + ) } return manifestPacks diff --git a/packages/cli/scripts/verify-release.mjs b/packages/cli/scripts/verify-release.mjs index 534e571..4ac669b 100644 --- a/packages/cli/scripts/verify-release.mjs +++ b/packages/cli/scripts/verify-release.mjs @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { assertReleasePackagePolicy } from './release-package-policy.mjs' +import { buildReleaseManifest, serialiseReleaseManifest } from './release-metadata.mjs' import { cycloneDxSerialNumber } from './generate-sbom.mjs' import { verifyDeterministicPackageArchive } from './verify-deterministic-package.mjs' @@ -24,21 +25,9 @@ export async function verifyRelease({ packageDir = defaultPackageDir, downloadDi const latestSbom = await readFile(join(downloadDir, 'codetruss-cli-latest.sbom.cdx.json')) const versionedSha = digest(versioned) const sbomSha = digest(versionedSbom) - const expectedMetadata = { - name: pkg.name, - version: pkg.version, - url: `/downloads/${versionedName}`, - latestUrl: `/downloads/${latestName}`, - sha256: versionedSha, - sbomUrl: `/downloads/${versionedSbomName}`, - sbomSha256: sbomSha, - node: pkg.engines.node, - repository: 'https://github.com/DeliriumPulse/codetruss-cli', - releaseUrl: `https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v${pkg.version}`, - attestationCommand: `gh attestation verify ${versionedName} --repo DeliriumPulse/codetruss-cli`, - } + const expectedMetadata = buildReleaseManifest({ pkg, sha256: versionedSha, sbomSha256: sbomSha }) const metadataBytes = await readFile(join(downloadDir, 'codetruss-cli-latest.json')) - const expectedMetadataBytes = Buffer.from(`${JSON.stringify(expectedMetadata, null, 2)}\n`, 'utf8') + const expectedMetadataBytes = Buffer.from(serialiseReleaseManifest(expectedMetadata), 'utf8') if (!metadataBytes.equals(expectedMetadataBytes)) { throw new Error(`release metadata is not the canonical manifest for CLI ${pkg.version}; run pnpm cli:release`) } diff --git a/packages/cli/src/analysis.ts b/packages/cli/src/analysis.ts index 152e3f6..b42411e 100644 --- a/packages/cli/src/analysis.ts +++ b/packages/cli/src/analysis.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { runAnalyzers, type AnalyzerFinding, type AnalyzerPass, type IndexCoverage } from '@codetruss/analyzer-engine' +import { annotateSuppressions, runAnalyzers, type AnalyzerFinding, type AnalyzerPass, type IndexCoverage } from '@codetruss/analyzer-engine' import { CLI_SAST_UNCHECKED_CLASSES } from '@codetruss/analyzer-engine/security/local-profile' import { indexRepository } from './indexer.js' import { LOCAL_SAST_PASS_ID, runLocalSast } from './local-sast.js' @@ -18,9 +18,17 @@ export async function analyzeRepository(root: string) { sastUncheckedClasses: CLI_SAST_UNCHECKED_CLASSES, }) const localSast = await runLocalSast(index) + // Inline `codetruss-ignore` markers are read once, over every pass at once, + // so no analyzer can be added later and quietly miss them — and so the + // aggregate list and the per-pass lists cannot disagree about what a + // developer dismissed. The findings are annotated, never removed. + const passes = [...result.passes, localSast.pass].map((pass) => ({ + ...pass, + result: { ...pass.result, findings: annotateSuppressions(pass.result.findings, index) }, + })) return { - findings: [...result.findings, ...localSast.findings], - passes: [...result.passes, localSast.pass], + findings: passes.flatMap((pass) => pass.result.findings), + passes, index, } } finally { @@ -183,6 +191,26 @@ export function changedFindings(findings: AnalyzerFinding[], files: ChangedFile[ }) } +/** Findings a developer dismissed with an inline `codetruss-ignore: ` comment. */ +export function suppressedFindings(findings: AnalyzerFinding[]): AnalyzerFinding[] { + return findings.filter((finding) => finding.suppression?.applied) +} + +/** + * Findings that still count. A marker without a reason suppresses nothing, so + * its finding stays here — the rejected marker is disclosed separately. + */ +export function reportedFindings(findings: AnalyzerFinding[]): AnalyzerFinding[] { + return findings.filter((finding) => !finding.suppression?.applied) +} + +/** `path:line` of every marker that gave no reason, deduplicated for disclosure. */ +export function rejectedSuppressionSites(findings: AnalyzerFinding[]): string[] { + return [...new Set(findings + .filter((finding) => finding.suppression && !finding.suppression.applied) + .map((finding) => `${finding.filePath ?? ''}:${finding.suppression?.markerLine ?? 0}`))].sort() +} + export function computeVerdict(input: { agentExitCode?: number verifications: VerificationResult[] @@ -213,14 +241,19 @@ export function computeVerdict(input: { * does not grant it. Promotion is a deliberate later change, not a default. */ const isLocalSast = (finding: AnalyzerFinding) => finding.analyzerId === LOCAL_SAST_PASS_ID - const blocking = input.findings.filter( + // A dismissed finding stops gating — that is what dismissing it is for. It + // does NOT stop being evidence: it is listed with its reason on the receipt, + // and a PASS reached over one says so in its own reasons below. + const dismissed = suppressedFindings(input.findings) + const findings = reportedFindings(input.findings) + const blocking = findings.filter( (finding) => severityRank[finding.severity] >= severityRank.HIGH && (finding.category === 'SECURITY_HYGIENE' || finding.category === 'DEPENDENCY') && !isLocalSast(finding), ) if (blocking.length) failed.push(`${blocking.length} high/critical security or dependency finding(s) affect changed files`) - const localSastFindings = input.findings.filter(isLocalSast) + const localSastFindings = findings.filter(isLocalSast) if (localSastFindings.length) { const rules = [...new Set(localSastFindings.map((finding) => String(finding.metadata?.ruleId ?? 'security')))].sort() review.push(`${localSastFindings.length} local security finding(s) affect changed files (${rules.join(', ')})`) @@ -235,10 +268,13 @@ export function computeVerdict(input: { if (sensitive.length) review.push(`sensitive surfaces changed: ${sensitive.slice(0, 5).map((file) => `${file.path} (${file.sensitive})`).join(', ')}`) if (deps.length) review.push(`dependency manifests or lockfiles changed: ${deps.slice(0, 5).map((file) => file.path).join(', ')}`) if (input.startDirty) review.push('the working tree was dirty at session start, so exact agent attribution is uncertain') - const reviewFindings = input.findings.filter( + const reviewFindings = findings.filter( (finding) => severityRank[finding.severity] >= severityRank.MEDIUM && !blocking.includes(finding) && !isLocalSast(finding), ) if (reviewFindings.length) review.push(`${reviewFindings.length} medium-or-higher analyzer finding(s) affect changed files`) + if (dismissed.length) { + notes.push(`${dismissed.length} finding(s) on changed files were dismissed by an inline codetruss-ignore comment and are listed with their reasons on this receipt`) + } if (input.llm?.diffCoverage?.truncated) { review.push(`local ${input.llm.provider} review covered ${input.llm.diffCoverage.reviewedBytes} of ${input.llm.diffCoverage.totalBytes} diff bytes`) } @@ -264,9 +300,19 @@ export function analyzerReceipt( _baseline?: Awaited>, delta?: FindingDelta, ): Receipt['analyzers'] { + const relevant = delta ? [...delta.introduced, ...delta.worsened] : analysis.findings + // Dismissals are reported for the WHOLE repository, not only the delta. A + // marker written in an unchanged file is a standing instruction to look away, + // and a receipt that only mentioned the ones touched this turn would let the + // rest accumulate unseen. Both lists are omitted when empty, so a repository + // that dismisses nothing renders exactly the bytes it did before. + const suppressed = suppressedFindings(analysis.findings) + const rejected = rejectedSuppressionSites(analysis.findings) return { passes: analysis.passes, - findings: delta ? [...delta.introduced, ...delta.worsened] : analysis.findings, + findings: reportedFindings(relevant), + ...(suppressed.length ? { suppressed } : {}), + ...(rejected.length ? { rejectedSuppressions: rejected } : {}), analysisProfile: LOCAL_ANALYSIS_PROFILE, delta: delta ? { introduced: delta.introduced.length, diff --git a/packages/cli/src/grammar-pack-manifest.ts b/packages/cli/src/grammar-pack-manifest.ts index 1ab907b..6a3b19b 100644 --- a/packages/cli/src/grammar-pack-manifest.ts +++ b/packages/cli/src/grammar-pack-manifest.ts @@ -5,10 +5,19 @@ * checks a download against, and what every subsequent load re-checks on disk. * A pack whose bytes do not hash to exactly these values is never loaded, and * the run discloses Python as skipped instead. + * + * `pnpm grammars:verify` re-derives this entire file from the published + * artifacts and compares it byte for byte, so an edit here — a digest moved to + * another name, an artifact nobody published, a fourth entry — fails the build + * rather than shipping. */ +/** What the loader does with an artifact. Exactly one artifact per role, per pack. */ +export type GrammarFileRole = 'runtime' | 'runtime-wasm' | 'grammar' + export interface PinnedGrammarFile { name: string + role: GrammarFileRole /** Path under the downloads origin, e.g. `/downloads/grammars/python-1.0.0/…`. */ url: string bytes: number @@ -41,18 +50,21 @@ export const PINNED_GRAMMAR_PACKS: readonly PinnedGrammarPack[] = [ "files": [ { "name": "tree-sitter.js", + "role": "runtime", "url": "/downloads/grammars/python-1.0.0/tree-sitter.js", "bytes": 74197, "sha256": "ddcacb69cd26c07322c51b798a63805fd99c272177c9633a978f3886358ca070" }, { "name": "tree-sitter.wasm", + "role": "runtime-wasm", "url": "/downloads/grammars/python-1.0.0/tree-sitter.wasm", "bytes": 188635, "sha256": "29208e71028ab0c11dfcc941255075aad75545394467aa22d817a6356714090f" }, { "name": "tree-sitter-python.wasm", + "role": "grammar", "url": "/downloads/grammars/python-1.0.0/tree-sitter-python.wasm", "bytes": 476105, "sha256": "9056d0fb0c337810d019fae350e8167786119da98f0f282aceae7ab89ee8253b" diff --git a/packages/cli/src/grammar-pack.ts b/packages/cli/src/grammar-pack.ts index 38a932c..522e363 100644 --- a/packages/cli/src/grammar-pack.ts +++ b/packages/cli/src/grammar-pack.ts @@ -38,9 +38,12 @@ import { PINNED_GRAMMAR_PACKS, pinnedGrammarPack, type PinnedGrammarPack } from * verification or the install somewhere the digest never covered; * - the only origin is the CodeTruss downloads host — never a third-party CDN, * never a redirect off-origin; - * - any failure — absent, short, over-long, wrong digest, unreadable — resolves - * to "pack unavailable", and the run discloses Python as skipped. There is no - * degraded mode in which unverified bytes are executed. + * - a download that stalls is abandoned rather than waited out: an origin that + * never answers, or answers one byte and holds the socket open, ends in an + * error with a reason instead of a hung install; + * - any failure — absent, short, over-long, wrong digest, unreadable, stalled — + * resolves to "pack unavailable", and the run discloses Python as skipped. + * There is no degraded mode in which unverified bytes are executed. */ const PRODUCTION_DOWNLOAD_ORIGIN = 'https://codetruss.com' @@ -279,40 +282,104 @@ export async function inspectGrammarPack( return { status: 'verified', pack, dir, contents } } +/** + * How long one artifact download may take, and how long it may say nothing. + * + * Two clocks, because they catch different failures. The total budget bounds a + * transfer that is progressing but will not finish; the idle budget catches the + * drip feed — a server that writes one byte and then holds the socket open + * forever, which no total-only budget shorter than "forever" ever ends, and + * which `fetch` on its own will wait out indefinitely. `grammars install` is a + * foreground command a person is watching, so an origin that stalls has to fail + * with a sentence, not hang. + * + * The values are generous on purpose: 738 KB over a bad hotel connection is + * still well inside them, and a false abort would look exactly like a tampered + * download to a user who cannot tell the two apart. + */ +export interface GrammarDownloadLimits { + /** Whole-transfer budget for one artifact, from request to last byte. */ + totalMs: number + /** Longest silence allowed — between the request and the first byte, or between two chunks. */ + idleMs: number +} + +const DEFAULT_DOWNLOAD_LIMITS: GrammarDownloadLimits = { totalMs: 120_000, idleMs: 20_000 } + /** * Download one artifact into `target`, verifying as the bytes arrive. * * The pinned length is enforced DURING the stream rather than after it, so a * hostile or broken origin cannot fill the user's disk before the digest gets a * chance to disagree. + * + * Both deadlines drive one `AbortController` rather than `AbortSignal.timeout` + * so the reason the transfer was abandoned survives into the error: an aborted + * `fetch` reports only "This operation was aborted", which tells a user nothing + * about whether their network stalled or the origin is misbehaving. */ -async function downloadVerified(url: string, target: string, expected: { bytes: number; sha256: string }): Promise { - const response = await fetch(url, { redirect: 'error', headers: { accept: 'application/octet-stream' } }) - if (!response.ok) throw new Error(`${url} responded ${response.status}`) - if (!response.body) throw new Error(`${url} returned no body`) +async function downloadVerified( + url: string, + target: string, + expected: { bytes: number; sha256: string }, + limits: GrammarDownloadLimits, +): Promise { + const controller = new AbortController() + /** Set only when a deadline fired, so an abort is reported as itself. */ + let abandoned: string | undefined + const abandon = (reason: string) => { + if (abandoned) return + abandoned = reason + controller.abort() + } + const total = setTimeout(() => abandon(`${url} did not finish within ${limits.totalMs}ms`), limits.totalMs) + let idle: ReturnType | undefined + const restartIdle = () => { + clearTimeout(idle) + idle = setTimeout(() => abandon(`${url} sent no data for ${limits.idleMs}ms`), limits.idleMs) + } - const hash = createHash('sha256') - let received = 0 - const measured = new Readable({ read() {} }) - const source = Readable.fromWeb(response.body as Parameters[0]) - source.on('data', (chunk: Buffer) => { - received += chunk.length - if (received > expected.bytes) { - measured.destroy(new Error(`${url} sent more than the pinned ${expected.bytes} bytes`)) - source.destroy() - return - } - hash.update(chunk) - measured.push(chunk) - }) - source.on('end', () => measured.push(null)) - source.on('error', (error: Error) => measured.destroy(error)) + try { + // Started before the request, so a server that accepts the connection and + // never answers is on the same clock as one that stalls mid-body. + restartIdle() + const response = await fetch(url, { + redirect: 'error', + headers: { accept: 'application/octet-stream' }, + signal: controller.signal, + }) + if (!response.ok) throw new Error(`${url} responded ${response.status}`) + if (!response.body) throw new Error(`${url} returned no body`) - await pipeline(measured, createWriteStream(target, { mode: 0o600 })) + const hash = createHash('sha256') + let received = 0 + const measured = new Readable({ read() {} }) + const source = Readable.fromWeb(response.body as Parameters[0]) + source.on('data', (chunk: Buffer) => { + restartIdle() + received += chunk.length + if (received > expected.bytes) { + measured.destroy(new Error(`${url} sent more than the pinned ${expected.bytes} bytes`)) + source.destroy() + return + } + hash.update(chunk) + measured.push(chunk) + }) + source.on('end', () => measured.push(null)) + source.on('error', (error: Error) => measured.destroy(error)) - if (received !== expected.bytes) throw new Error(`${url} sent ${received} bytes, expected ${expected.bytes}`) - const digest = hash.digest('hex') - if (digest !== expected.sha256) throw new Error(`${url} has digest ${digest}, expected ${expected.sha256}`) + await pipeline(measured, createWriteStream(target, { mode: 0o600 })) + + if (received !== expected.bytes) throw new Error(`${url} sent ${received} bytes, expected ${expected.bytes}`) + const digest = hash.digest('hex') + if (digest !== expected.sha256) throw new Error(`${url} has digest ${digest}, expected ${expected.sha256}`) + } catch (error) { + throw abandoned ? new Error(abandoned) : error + } finally { + clearTimeout(total) + clearTimeout(idle) + } } /** @@ -358,6 +425,7 @@ export interface GrammarInstallResult { export async function installGrammarPack( name: string, env: NodeJS.ProcessEnv = process.env, + limits: GrammarDownloadLimits = DEFAULT_DOWNLOAD_LIMITS, ): Promise { const existing = await inspectGrammarPack(name, env) const pack = existing.pack @@ -375,7 +443,7 @@ export async function installGrammarPack( const scratch = await mkdtemp(join(root, `.${pack.name}-${pack.version}.`)) try { for (const file of pack.files) { - await downloadVerified(`${origin}${file.url}`, join(scratch, file.name), file) + await downloadVerified(`${origin}${file.url}`, join(scratch, file.name), file, limits) } const target = grammarPackDir(pack, env) // A failed or partial previous install is replaced wholesale rather than diff --git a/packages/cli/src/grammar-parser.ts b/packages/cli/src/grammar-parser.ts index 885510b..b6b74f2 100644 --- a/packages/cli/src/grammar-parser.ts +++ b/packages/cli/src/grammar-parser.ts @@ -5,6 +5,7 @@ import { compileFunction } from 'node:vm' import type { ParsedTree, SastLanguage, SastParser, SyntaxNode } from '@codetruss/analyzer-engine/security/lang' import { MAX_SOURCE_BYTES } from '@codetruss/analyzer-engine/security/lang' import { inspectGrammarPack, type GrammarPackState } from './grammar-pack.js' +import type { PinnedGrammarFile, PinnedGrammarPack } from './grammar-pack-manifest.js' /** * Turn a verified grammar pack into the {@link SastParser} the engine expects. @@ -101,12 +102,17 @@ export async function loadGrammarParser( if (state.status === 'failed') return { status: 'failed', kind: 'digest', reason: state.reason } const language = state.pack.language as SastLanguage - const grammarFile = state.pack.files.find((file) => file.name.startsWith('tree-sitter-')) - if (!grammarFile) return { status: 'failed', kind: 'digest', reason: 'pack declares no grammar artifact' } + const artifacts = artifactsByRole(state.pack) + if ('reason' in artifacts) { + // The pack verified; the CLI's own pin is malformed. Calling that a digest + // failure would publish a receipt accusing the user's install of tampering + // over a defect in this binary. + return { status: 'failed', kind: 'runtime', reason: artifacts.reason } + } let cached = runtimeCache.get(state.dir) if (!cached) { - cached = buildParser(state, grammarFile.name, language) + cached = buildParser(state, artifacts, language) runtimeCache.set(state.dir, cached) } const result = await cached @@ -114,6 +120,42 @@ export async function loadGrammarParser( return { status: 'verified', parser: result.parser, languages: new Set([language]) } } +/** The artifact names this loader will execute, resolved by role. */ +interface PackArtifacts { + runtime: string + runtimeWasm: string + grammar: string +} + +/** + * Which artifact fills which role — by the pin's own `role` field, never by the + * shape of a file name. + * + * `files.find((file) => file.name.startsWith('tree-sitter-'))` returned the + * FIRST match, so a pack that carried an extra `tree-sitter-evil.wasm` ordered + * ahead of `tree-sitter-python.wasm` would have had the extra file loaded as the + * grammar — and the pin verifier that only proved each digest appeared somewhere + * in the generated file would not have caught the extra entry. Requiring exactly + * one artifact per role means an ambiguous pack cannot resolve to "whichever one + * came first"; it does not resolve at all. + */ +function artifactsByRole(pack: PinnedGrammarPack): PackArtifacts | { reason: string } { + const named = (role: PinnedGrammarFile['role']): string | { reason: string } => { + const matches = pack.files.filter((file) => file.role === role) + if (matches.length !== 1) { + return { reason: `pack declares ${matches.length} artifacts for role ${role}, expected exactly one` } + } + return matches[0].name + } + const runtime = named('runtime') + const runtimeWasm = named('runtime-wasm') + const grammar = named('grammar') + if (typeof runtime !== 'string') return runtime + if (typeof runtimeWasm !== 'string') return runtimeWasm + if (typeof grammar !== 'string') return grammar + return { runtime, runtimeWasm, grammar } +} + /** * A name emscripten can carry around for the runtime WASM that resolves to no * file anywhere. @@ -153,12 +195,12 @@ function runVerifiedModule(source: Buffer, filename: string, dirname: string): u async function buildParser( state: Extract, - grammarFileName: string, + artifacts: PackArtifacts, language: SastLanguage, ): Promise<{ parser: SastParser } | { reason: string }> { - const runtimeSource = state.contents.get('tree-sitter.js') - const runtimeWasm = state.contents.get('tree-sitter.wasm') - const grammarWasm = state.contents.get(grammarFileName) + const runtimeSource = state.contents.get(artifacts.runtime) + const runtimeWasm = state.contents.get(artifacts.runtimeWasm) + const grammarWasm = state.contents.get(artifacts.grammar) if (!runtimeSource || !runtimeWasm || !grammarWasm) { return { reason: 'verified pack did not carry its own bytes' } } @@ -167,7 +209,7 @@ async function buildParser( try { const TreeSitter = runVerifiedModule( runtimeSource, - join(state.dir, 'tree-sitter.js'), + join(state.dir, artifacts.runtime), state.dir, ) as TreeSitterModule if (typeof TreeSitter?.init !== 'function') { diff --git a/packages/cli/src/hook-agent-config.ts b/packages/cli/src/hook-agent-config.ts new file mode 100644 index 0000000..02d2f9a --- /dev/null +++ b/packages/cli/src/hook-agent-config.ts @@ -0,0 +1,105 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { AgentSurface } from './hook-targets.js' + +export type HookHandler = { type?: string; command?: string; args?: string[]; [key: string]: unknown } +export type HookGroup = { matcher?: string; hooks?: HookHandler[]; [key: string]: unknown } +export type HookDocument = { hooks?: Record; [key: string]: unknown } + +export const AGENT_EVENTS = ['UserPromptSubmit', 'PostToolUse', 'Stop'] as const +export type AgentEvent = typeof AGENT_EVENTS[number] + +// The internal Stop review has a five-minute hard deadline. Keep the installed +// agent envelope wider so it can persist a failure result and clean private Git +// evidence before the host terminates the hook process. +const STOP_HOOK_TIMEOUT_SECONDS = 6 * 60 + +/** The host-owned settings file each agent surface reads its hooks from. */ +export function agentSettingsPath(root: string, surface: AgentSurface): string { + return join(root, surface === 'claude' ? '.claude/settings.json' : '.codex/hooks.json') +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +export async function readHookDocument(path: string): Promise { + let text: string + try { + text = await readFile(path, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} + throw error + } + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch (error) { + throw new Error(`refusing to overwrite invalid JSON in ${path}: ${error instanceof Error ? error.message : String(error)}`) + } + if (!isRecord(parsed)) throw new Error(`refusing to overwrite ${path}: top-level JSON must be an object`) + if (parsed.hooks !== undefined && !isRecord(parsed.hooks)) { + throw new Error(`refusing to overwrite ${path}: hooks must be an object`) + } + return parsed as HookDocument +} + +export function eventGroups(doc: HookDocument, path: string, event: string): HookGroup[] { + doc.hooks ??= {} + const value = doc.hooks[event] + if (value === undefined) { + const groups: HookGroup[] = [] + doc.hooks[event] = groups + return groups + } + if (!Array.isArray(value) || value.some((group) => !isRecord(group))) { + throw new Error(`refusing to overwrite ${path}: hooks.${event} must be an array of objects`) + } + for (const group of value as HookGroup[]) { + if (group.hooks !== undefined && (!Array.isArray(group.hooks) || group.hooks.some((handler) => !isRecord(handler)))) { + throw new Error(`refusing to overwrite ${path}: hooks.${event}[].hooks must be an array of objects`) + } + } + return value as HookGroup[] +} + +export function isCodeTrussHandler(handler: HookHandler): boolean { + return [handler.command, ...(handler.args ?? [])].some((value) => typeof value === 'string' && value.includes('.codetruss/hooks/agent.cjs')) +} + +export function removeCodeTrussHandlers(groups: HookGroup[]): void { + for (let index = groups.length - 1; index >= 0; index--) { + const group = groups[index] + if (!Array.isArray(group.hooks)) continue + group.hooks = group.hooks.filter((handler) => !isCodeTrussHandler(handler)) + if (group.hooks.length === 0) groups.splice(index, 1) + } +} + +function agentCommand(surface: AgentSurface): HookHandler { + if (surface === 'claude') { + return { + command: 'node', + args: ['${CLAUDE_PROJECT_DIR}/.codetruss/hooks/agent.cjs', 'claude'], + } + } + return { + command: 'node "$(git -c core.longpaths=true rev-parse --show-toplevel)/.codetruss/hooks/agent.cjs" codex', + commandWindows: "$root = git -c core.longpaths=true rev-parse --show-toplevel; if ($LASTEXITCODE -eq 0) { node (Join-Path $root '.codetruss/hooks/agent.cjs') codex }", + } +} + +/** The group matcher CodeTruss installs alongside an event's handler, if any. */ +export function agentEventMatcher(event: AgentEvent): string | undefined { + return event === 'PostToolUse' ? 'Edit|Write' : undefined +} + +export function agentHandler(surface: AgentSurface, event: AgentEvent): HookHandler { + const timeout = event === 'PostToolUse' ? 10 : event === 'UserPromptSubmit' ? 60 : STOP_HOOK_TIMEOUT_SECONDS + const statusMessage = event === 'PostToolUse' + ? 'Checking scope with CodeTruss' + : event === 'UserPromptSubmit' + ? 'Capturing CodeTruss turn baseline' + : 'Writing CodeTruss review receipt' + return { type: 'command', ...agentCommand(surface), timeout, statusMessage } +} diff --git a/packages/cli/src/hook-agent-runner.ts b/packages/cli/src/hook-agent-runner.ts new file mode 100644 index 0000000..0ce8dad --- /dev/null +++ b/packages/cli/src/hook-agent-runner.ts @@ -0,0 +1,81 @@ +/** + * The `.codetruss/hooks/agent.cjs` payload installed into a repository. + * + * This is a standalone CommonJS program executed by the host agent, not by this + * CLI, so it is kept verbatim in one place: `inspectRunner` compares the file on + * disk against this exact string to detect drift, which means any incidental + * edit here is a behaviour change for every installed repository. + */ +export const AGENT_RUNNER = `'use strict' +const { existsSync } = require('node:fs') +const { execFileSync, spawnSync } = require('node:child_process') +const { join } = require('node:path') + +const surface = process.argv[2] +const maxInputBytes = 16 * 1024 * 1024 +if (surface !== 'claude' && surface !== 'codex') { + process.stderr.write('codetruss hook: expected claude or codex\\n') + process.exit(3) +} + +function safeFailure(input, message) { + let event + let stopHookActive = false + const textInput = input.toString('utf8') + try { + const parsed = JSON.parse(textInput) + event = parsed.hook_event_name + stopHookActive = parsed.stop_hook_active === true + } catch { + const prefix = textInput.slice(0, 64 * 1024) + event = /"hook_event_name"\\s*:\\s*"([^"]+)"/.exec(prefix)?.[1] + stopHookActive = /"stop_hook_active"\\s*:\\s*true/.test(prefix) + } + const text = ('CodeTruss hook failed safely: ' + message).slice(0, 9000) + if (event === 'UserPromptSubmit' || (event === 'Stop' && !stopHookActive)) { + return { decision: 'block', reason: text } + } + return { systemMessage: text } +} + +const chunks = [] +let inputBytes = 0 +let tooLarge = false +process.stdin.on('data', (value) => { + const chunk = Buffer.from(value) + inputBytes += chunk.length + if (inputBytes > maxInputBytes) tooLarge = true + else chunks.push(chunk) +}) +process.stdin.on('end', () => { + const input = Buffer.concat(chunks) + if (tooLarge) { + process.stdout.write(JSON.stringify(safeFailure(input, 'hook input exceeded 16 MiB')) + '\\n') + process.exit(0) + } + let root + try { + root = execFileSync('git', ['-c', 'core.longpaths=true', 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim() + } catch { + process.stdout.write(JSON.stringify(safeFailure(input, 'could not resolve the Git repository root')) + '\\n') + process.exit(0) + } + const local = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'codetruss.cmd' : 'codetruss') + const command = existsSync(local) ? local : 'codetruss' + const result = spawnSync(command, ['hooks', 'dispatch', surface], { + cwd: root, + input, + encoding: 'utf8', + shell: process.platform === 'win32', + maxBuffer: 64 * 1024, + }) + if (result.error || result.status !== 0) { + const detail = result.error ? result.error.message : (result.stderr || 'dispatch exited with status ' + String(result.status)).trim() + process.stdout.write(JSON.stringify(safeFailure(input, detail)) + '\\n') + process.exit(0) + } + if (result.stderr) process.stderr.write(result.stderr) + if (result.stdout) process.stdout.write(result.stdout) + process.exit(0) +}) +` diff --git a/packages/cli/src/hook-doctor.ts b/packages/cli/src/hook-doctor.ts new file mode 100644 index 0000000..9823220 --- /dev/null +++ b/packages/cli/src/hook-doctor.ts @@ -0,0 +1,297 @@ +import { lstat, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { loadConfig } from './config.js' +import { + AGENT_EVENTS, + agentEventMatcher, + agentHandler, + agentSettingsPath, + eventGroups, + isCodeTrussHandler, + readHookDocument, + type HookDocument, + type HookGroup, + type HookHandler, +} from './hook-agent-config.js' +import { AGENT_RUNNER } from './hook-agent-runner.js' +import { executablePath, installedCliVersion } from './hook-executable.js' +import { BEGIN_MARKER, END_MARKER, effectivePreCommitPath, preCommitBlock } from './hook-pre-commit.js' +import { parseTargets, type AgentSurface, type HookTarget } from './hook-targets.js' +import { CLI_VERSION } from './version.js' +import { verifyCommandTrustStatus } from './verify-trust.js' + +const SUPPORTS_POSIX_FILE_MODES = process.platform !== 'win32' + +export interface HookDoctorCheck { + level: 'ok' | 'warning' | 'error' + target: HookTarget | 'config' | 'runtime' | 'agent-runtime' + message: string + path?: string +} + +export interface HookDoctorResult { + ok: boolean + checks: HookDoctorCheck[] +} + +export type HookHealthStatus = 'not_installed' | 'healthy' | 'warning' | 'unhealthy' + +export interface LocalHookHealth { + preCommit: HookHealthStatus + claude: HookHealthStatus + codex: HookHealthStatus +} + +type AddCheck = (check: HookDoctorCheck) => void + +/** + * The hooks invoke `codetruss` by name, so they run whatever PATH resolves — + * which is not always what was just installed. A stale binary earlier in PATH + * silently shadows the new one, and the installer's own "Ready" message used to + * hide it. Reported as a warning: the hooks still work, they just are not this + * version. Determined by manifest version, so a repository-local install of the + * same version is not mistaken for a shadow. + */ +async function inspectExecutableShadow(executable: string, add: AddCheck): Promise { + const resolved = await installedCliVersion(executable) + if (!resolved || resolved === CLI_VERSION) return + add({ + level: 'warning', + target: 'runtime', + message: `installed hooks resolve codetruss ${resolved}, but this CLI is ${CLI_VERSION}; put the intended install first on PATH or remove the older one`, + path: executable, + }) +} + +const AGENT_HANDLER_FIELDS = ['type', 'command', 'args', 'commandWindows', 'timeout', 'statusMessage'] as const + +/** + * Which fields of an installed agent handler no longer match what this CLI + * would write. Doctor always compared handlers exactly but reported only that + * they "differ", which reads the same for a config installed several versions + * ago as for a deliberate hand-edit. Field names only: enough to diagnose, + * without putting handler command text in the message. + */ +function agentHandlerDrift(handler: HookHandler, expected: HookHandler): string[] { + return AGENT_HANDLER_FIELDS.filter((field) => ( + JSON.stringify(handler[field] ?? null) !== JSON.stringify(expected[field] ?? null) + )) +} + +async function inspectAgentHook(root: string, surface: AgentSurface, add: AddCheck): Promise { + const path = agentSettingsPath(root, surface) + let doc: HookDocument + try { + doc = await readHookDocument(path) + } catch (error) { + add({ level: 'error', target: surface, message: error instanceof Error ? error.message : String(error), path }) + return + } + for (const event of AGENT_EVENTS) { + let groups: HookGroup[] + try { + groups = eventGroups(doc, path, event) + } catch (error) { + add({ level: 'error', target: surface, message: error instanceof Error ? error.message : String(error), path }) + return + } + const installed = groups.flatMap((group) => (group.hooks ?? []).map((handler) => ({ group, handler }))) + .filter(({ handler }) => isCodeTrussHandler(handler)) + if (installed.length !== 1) { + add({ + level: 'error', + target: surface, + message: `${event} must contain exactly one CodeTruss handler (found ${installed.length})`, + path, + }) + continue + } + const expected = agentHandler(surface, event) + const drift = agentHandlerDrift(installed[0].handler, expected) + if (installed[0].group.matcher !== agentEventMatcher(event)) drift.push('matcher') + if (drift.length) { + const detail = `(${drift.join(', ')}); run codetruss hooks install ${surface} to refresh it` + add({ level: 'error', target: surface, message: `${event} handler differs from the current safe installation ${detail}`, path }) + continue + } + add({ level: 'ok', target: surface, message: `${event} handler is current`, path }) + } + try { + const metadata = await lstat(path) + if (SUPPORTS_POSIX_FILE_MODES && (metadata.mode & 0o022) !== 0) { + add({ level: 'error', target: surface, message: 'hook configuration is writable by group or other users', path }) + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + add({ level: 'error', target: surface, message: error instanceof Error ? error.message : String(error), path }) + } + } +} + +async function inspectRunner(root: string, add: AddCheck): Promise { + const path = join(root, '.codetruss', 'hooks', 'agent.cjs') + try { + const [contents, metadata] = await Promise.all([readFile(path, 'utf8'), lstat(path)]) + if (!metadata.isFile()) { + add({ level: 'error', target: 'agent-runtime', message: 'agent hook runner is not a regular file', path }) + } else if (contents !== AGENT_RUNNER) { + add({ level: 'error', target: 'agent-runtime', message: 'agent hook runner differs from this CLI version; reinstall hooks', path }) + } else if (SUPPORTS_POSIX_FILE_MODES && (metadata.mode & 0o022) !== 0) { + add({ level: 'error', target: 'agent-runtime', message: 'agent hook runner is writable by group or other users', path }) + } else { + add({ + level: 'ok', + target: 'agent-runtime', + message: SUPPORTS_POSIX_FILE_MODES + ? 'agent hook runner is current and owner-controlled' + : 'agent hook runner is current; POSIX permission checks do not apply on Windows', + path, + }) + } + } catch (error) { + add({ + level: 'error', + target: 'agent-runtime', + message: (error as NodeJS.ErrnoException).code === 'ENOENT' + ? 'agent hook runner is missing; reinstall hooks' + : error instanceof Error ? error.message : String(error), + path, + }) + } +} + +async function inspectPreCommit(root: string, add: AddCheck): Promise { + const path = effectivePreCommitPath(root) + try { + const [contents, metadata] = await Promise.all([readFile(path, 'utf8'), lstat(path)]) + const beginCount = contents.split(BEGIN_MARKER).length - 1 + const endCount = contents.split(END_MARKER).length - 1 + const begin = contents.indexOf(BEGIN_MARKER) + const end = contents.indexOf(END_MARKER, begin) + END_MARKER.length + if (beginCount !== 1 || endCount !== 1 || begin < 0 || contents.slice(begin, end) !== preCommitBlock()) { + add({ level: 'error', target: 'pre-commit', message: 'installed block is missing, duplicated, or stale; reinstall hooks', path }) + } else { + add({ level: 'ok', target: 'pre-commit', message: 'staged-review block is current', path }) + } + if (!SUPPORTS_POSIX_FILE_MODES) { + add({ level: 'ok', target: 'pre-commit', message: 'hook file is present; POSIX permission checks do not apply on Windows', path }) + } else if ((metadata.mode & 0o100) === 0) { + add({ level: 'error', target: 'pre-commit', message: 'hook is not executable by its owner', path }) + } else if ((metadata.mode & 0o022) !== 0) { + add({ level: 'error', target: 'pre-commit', message: 'hook is writable by group or other users', path }) + } else { + add({ level: 'ok', target: 'pre-commit', message: 'hook permissions are owner-controlled and executable', path }) + } + } catch (error) { + add({ + level: 'error', + target: 'pre-commit', + message: (error as NodeJS.ErrnoException).code === 'ENOENT' + ? 'hook is not installed' + : error instanceof Error ? error.message : String(error), + path, + }) + } +} + +export async function inspectHookDoctor(root: string, target: string): Promise { + const targets = parseTargets(target) + const checks: HookDoctorCheck[] = [] + const add: AddCheck = (check) => checks.push(check) + const agentTargets = targets.filter((name): name is AgentSurface => name !== 'pre-commit') + try { + const config = await loadConfig(root) + if (config.verify.length) { + const trust = await verifyCommandTrustStatus(root, config.verify) + add({ + level: trust.trusted ? 'ok' : 'error', + target: 'config', + message: trust.trusted + ? `repository verification commands are trusted (${trust.hash.slice(0, 12)})` + : `repository verification commands are untrusted (${trust.hash.slice(0, 12)}); inspect them and run codetruss verify-policy trust`, + path: join(root, '.codetruss.yml'), + }) + } + } catch (error) { + add({ level: 'error', target: 'config', message: error instanceof Error ? error.message : String(error), path: join(root, '.codetruss.yml') }) + } + if (agentTargets.length) { + try { + const config = await loadConfig(root) + if (config.allow.length) { + add({ level: 'ok', target: 'config', message: `${config.allow.length} allowed task-scope glob${config.allow.length === 1 ? '' : 's'} configured`, path: join(root, '.codetruss.yml') }) + } else { + add({ level: 'error', target: 'config', message: 'agent hooks require at least one allow glob in .codetruss.yml', path: join(root, '.codetruss.yml') }) + } + } catch (error) { + add({ level: 'error', target: 'config', message: error instanceof Error ? error.message : String(error), path: join(root, '.codetruss.yml') }) + } + await inspectRunner(root, add) + } + const cliPath = await executablePath(root) + if (cliPath) { + add({ level: 'ok', target: 'runtime', message: 'CodeTruss CLI is resolvable by installed hooks', path: cliPath }) + await inspectExecutableShadow(cliPath, add) + } else add({ level: 'error', target: 'runtime', message: 'CodeTruss CLI is not available locally or on PATH' }) + for (const name of targets) { + if (name === 'pre-commit') await inspectPreCommit(root, add) + else { + await inspectAgentHook(root, name, add) + if (name === 'codex') { + add({ + level: 'warning', + target: 'codex', + message: 'hook trust cannot be verified here; open /hooks in Codex and trust this exact project hook. New or changed hook definitions require review again', + path: join(root, '.codex', 'hooks.json'), + }) + } + } + } + const errors = checks.filter((check) => check.level === 'error').length + return { ok: errors === 0, checks } +} + +async function hookInstallations(root: string): Promise> { + const agentPresent = async (surface: AgentSurface): Promise => ( + readFile(agentSettingsPath(root, surface), 'utf8') + .then((text) => text.includes('.codetruss/hooks/agent.cjs'), () => false) + ) + return { + 'pre-commit': await readFile(effectivePreCommitPath(root), 'utf8') + .then((text) => text.includes(BEGIN_MARKER), () => false), + claude: await agentPresent('claude'), + codex: await agentPresent('codex'), + } +} + +/** Privacy-safe health summary: no hook path, command, or diagnostic text leaves this function. */ +export async function inspectLocalHookHealth(root: string): Promise { + const [installed, preCommitDoctor, claudeDoctor, codexDoctor] = await Promise.all([ + hookInstallations(root), + inspectHookDoctor(root, 'pre-commit'), + inspectHookDoctor(root, 'claude'), + inspectHookDoctor(root, 'codex'), + ]) + const doctors: Record = { + 'pre-commit': preCommitDoctor, + claude: claudeDoctor, + codex: codexDoctor, + } + const status = (target: HookTarget): HookHealthStatus => { + if (!installed[target]) return 'not_installed' + const relevant = doctors[target].checks.filter((check) => ( + check.target === target + || check.target === 'runtime' + || check.target === 'agent-runtime' + || check.target === 'config' + )) + if (relevant.some((check) => check.level === 'error')) return 'unhealthy' + if (relevant.some((check) => check.level === 'warning')) return 'warning' + return 'healthy' + } + return { + preCommit: status('pre-commit'), + claude: status('claude'), + codex: status('codex'), + } +} diff --git a/packages/cli/src/hook-executable.ts b/packages/cli/src/hook-executable.ts new file mode 100644 index 0000000..b130548 --- /dev/null +++ b/packages/cli/src/hook-executable.ts @@ -0,0 +1,49 @@ +import { constants as fsConstants } from 'node:fs' +import { access, readFile, realpath } from 'node:fs/promises' +import { delimiter, dirname, join, parse as parsePath } from 'node:path' + +/** The `codetruss` binary the installed hooks will resolve: repository-local first, then PATH. */ +export async function executablePath(root: string): Promise { + const local = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'codetruss.cmd' : 'codetruss') + if (await access(local, fsConstants.X_OK).then(() => true, () => false)) return local + const extensions = process.platform === 'win32' + ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') + : [''] + for (const directory of (process.env.PATH ?? '').split(delimiter).filter(Boolean)) { + for (const extension of extensions) { + const candidate = join(directory, `codetruss${extension.toLowerCase()}`) + if (await access(candidate, fsConstants.X_OK).then(() => true, () => false)) return candidate + } + } + return undefined +} + +/** + * Version of the @codetruss/cli install that owns `executable`, read from its + * package manifest. Never executes the binary: resolving a shadowing install + * must not run whatever happens to be first on PATH. + */ +export async function installedCliVersion(executable: string): Promise { + let cursor: string + try { + cursor = dirname(await realpath(executable)) + } catch { + return undefined + } + const { root } = parsePath(cursor) + while (true) { + try { + const manifest = JSON.parse(await readFile(join(cursor, 'package.json'), 'utf8')) as { + name?: unknown + version?: unknown + } + if (manifest.name === '@codetruss/cli' && typeof manifest.version === 'string') return manifest.version + } catch { + // no manifest here, or unreadable — keep walking up + } + if (cursor === root) return undefined + const parent = dirname(cursor) + if (parent === cursor) return undefined + cursor = parent + } +} diff --git a/packages/cli/src/hook-pre-commit.ts b/packages/cli/src/hook-pre-commit.ts new file mode 100644 index 0000000..832a85e --- /dev/null +++ b/packages/cli/src/hook-pre-commit.ts @@ -0,0 +1,56 @@ +import { isAbsolute, resolve } from 'node:path' +import { runGitText } from './git-process.js' + +const MARKER = 'codetruss-agent-guard' +export const CODETRUSS_PRE_COMMIT_ENV = 'CODETRUSS_INTERNAL_PRE_COMMIT' +export const BEGIN_MARKER = `# ${MARKER}:begin` +export const END_MARKER = `# ${MARKER}:end` + +export function effectivePreCommitPath(root: string): string { + const raw = runGitText(root, ['rev-parse', '--git-path', 'hooks/pre-commit']).trim() + if (!raw) throw new Error('Git did not return an effective pre-commit hook path') + return isAbsolute(raw) ? resolve(raw) : resolve(root, raw) +} + +export function stripCodeTrussPreCommit(existing: string): string { + const begin = existing.indexOf(BEGIN_MARKER) + if (begin >= 0) { + const lineStart = existing.lastIndexOf('\n', begin - 1) + 1 + const end = existing.indexOf(END_MARKER, begin) + if (end < 0) throw new Error('existing CodeTruss pre-commit block is missing its end marker') + const lineEnd = existing.indexOf('\n', end) + return `${existing.slice(0, lineStart)}${lineEnd < 0 ? '' : existing.slice(lineEnd + 1)}`.replace(/\n{3,}$/g, '\n') + } + const legacy = existing.indexOf(`# ${MARKER}`) + if (legacy >= 0) { + const lineStart = existing.lastIndexOf('\n', legacy - 1) + 1 + return existing.slice(0, lineStart).replace(/\n{3,}$/g, '\n') + } + return existing +} + +export function preCommitBlock(): string { + return `${BEGIN_MARKER} +ROOT="$(git -c core.longpaths=true rev-parse --show-toplevel 2>/dev/null)" || exit 0 +CODETRUSS_STATUS=0 +if [ -x "$ROOT/node_modules/.bin/codetruss" ]; then + ${CODETRUSS_PRE_COMMIT_ENV}=1 "$ROOT/node_modules/.bin/codetruss" review --staged --task "pre-commit" || CODETRUSS_STATUS=$? +else + ${CODETRUSS_PRE_COMMIT_ENV}=1 codetruss review --staged --task "pre-commit" || CODETRUSS_STATUS=$? +fi +case "$CODETRUSS_STATUS" in + 0) ;; + 1) + printf '%s\n' 'CodeTruss REVIEW_REQUIRED: receipt created; commit allowed for human review.' >&2 + ;; + 2) + printf '%s\n' 'CodeTruss FAILED: commit blocked. Review the receipt before retrying.' >&2 + exit 2 + ;; + *) + printf '%s\n' "CodeTruss could not produce a trustworthy receipt (exit $CODETRUSS_STATUS); commit blocked." >&2 + exit "$CODETRUSS_STATUS" + ;; +esac +${END_MARKER}` +} diff --git a/packages/cli/src/hook-targets.ts b/packages/cli/src/hook-targets.ts new file mode 100644 index 0000000..e8a0b59 --- /dev/null +++ b/packages/cli/src/hook-targets.ts @@ -0,0 +1,11 @@ +/** The hook surfaces a user can install, inspect, or remove. */ +export type HookTarget = 'pre-commit' | 'claude' | 'codex' + +/** The two agent surfaces; `pre-commit` is a shell hook and handled separately. */ +export type AgentSurface = Exclude + +export function parseTargets(target: string): HookTarget[] { + const valid = new Set(['pre-commit', 'claude', 'codex', 'all']) + if (!valid.has(target)) throw new Error(`unknown hook target ${target}; expected pre-commit, claude, codex, or all`) + return target === 'all' ? ['pre-commit', 'claude', 'codex'] : [target as HookTarget] +} diff --git a/packages/cli/src/hook-writes.ts b/packages/cli/src/hook-writes.ts new file mode 100644 index 0000000..d4b5404 --- /dev/null +++ b/packages/cli/src/hook-writes.ts @@ -0,0 +1,143 @@ +import { randomUUID } from 'node:crypto' +import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { basename, dirname, join, resolve } from 'node:path' + +export interface PlannedWrite { + path: string + contents: Buffer + defaultMode: number + forceMode?: number +} + +interface FileSnapshot { + path: string + exists: boolean + contents?: Buffer + mode?: number +} + +export interface HookInstallPlan { + writes: PlannedWrite[] + installedPaths: string[] +} + +export function plannedWrite(path: string, contents: string | Buffer, defaultMode: number, forceMode?: number): PlannedWrite { + return { + path: resolve(path), + contents: Buffer.isBuffer(contents) ? Buffer.from(contents) : Buffer.from(contents, 'utf8'), + defaultMode, + ...(forceMode === undefined ? {} : { forceMode }), + } +} + +async function snapshotFile(path: string): Promise { + try { + const metadata = await lstat(path) + if (!metadata.isFile()) { + throw new Error(`refusing to replace non-regular hook file ${path}`) + } + return { + path, + exists: true, + contents: await readFile(path), + mode: metadata.mode & 0o777, + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { path, exists: false } + throw error + } +} + +async function snapshotStillMatches(snapshot: FileSnapshot): Promise { + try { + const metadata = await lstat(snapshot.path) + if (!snapshot.exists || !metadata.isFile()) return false + const contents = await readFile(snapshot.path) + return contents.equals(snapshot.contents!) && (metadata.mode & 0o777) === snapshot.mode + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return !snapshot.exists + throw error + } +} + +async function writeTemporaryFile(write: PlannedWrite, mode: number): Promise { + await mkdir(dirname(write.path), { recursive: true }) + const temporary = join(dirname(write.path), `.${basename(write.path)}.codetruss-${process.pid}-${randomUUID()}.tmp`) + try { + await writeFile(temporary, write.contents, { flag: 'wx', mode }) + await chmod(temporary, mode) + return temporary + } catch (error) { + await rm(temporary, { force: true }).catch(() => undefined) + throw error + } +} + +export function mergePlannedWrites(plans: HookInstallPlan[]): { writes: PlannedWrite[]; installedPaths: string[] } { + const writes = new Map() + const installedPaths = new Set() + for (const plan of plans) { + for (const path of plan.installedPaths) installedPaths.add(path) + for (const write of plan.writes) { + const existing = writes.get(write.path) + if (existing && (!existing.contents.equals(write.contents) + || existing.defaultMode !== write.defaultMode || existing.forceMode !== write.forceMode)) { + throw new Error(`hook installation planned conflicting writes to ${write.path}`) + } + writes.set(write.path, write) + } + } + return { writes: [...writes.values()], installedPaths: [...installedPaths] } +} + +/** + * Stage every replacement in its destination directory before publishing any + * of them. If publication fails, restore the exact bytes and mode captured at + * the start of the transaction. A concurrent editor is detected before the + * first rename so CodeTruss never knowingly overwrites a newer hook config. + */ +export async function commitPlannedWrites(writes: PlannedWrite[]): Promise { + const snapshots = new Map() + const temporaryFiles = new Map() + const committed: PlannedWrite[] = [] + for (const write of writes) snapshots.set(write.path, await snapshotFile(write.path)) + try { + for (const write of writes) { + const snapshot = snapshots.get(write.path)! + const mode = write.forceMode ?? snapshot.mode ?? write.defaultMode + temporaryFiles.set(write.path, await writeTemporaryFile(write, mode)) + } + for (const snapshot of snapshots.values()) { + if (!await snapshotStillMatches(snapshot)) { + throw new Error(`hook file changed during installation and was left untouched: ${snapshot.path}`) + } + } + for (const write of writes) { + await rename(temporaryFiles.get(write.path)!, write.path) + temporaryFiles.delete(write.path) + committed.push(write) + } + } catch (error) { + const rollbackErrors: string[] = [] + for (const write of committed.reverse()) { + const snapshot = snapshots.get(write.path)! + try { + if (!snapshot.exists) { + await rm(write.path, { force: true }) + } else { + const restore = plannedWrite(write.path, snapshot.contents!, snapshot.mode!, snapshot.mode!) + const temporary = await writeTemporaryFile(restore, snapshot.mode!) + await rename(temporary, write.path) + } + } catch (rollbackError) { + rollbackErrors.push(`${write.path}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`) + } + } + if (rollbackErrors.length) { + throw new Error(`${error instanceof Error ? error.message : String(error)}; hook rollback also failed: ${rollbackErrors.join('; ')}`) + } + throw error + } finally { + await Promise.all([...temporaryFiles.values()].map((path) => rm(path, { force: true }).catch(() => undefined))) + } +} diff --git a/packages/cli/src/hooks.ts b/packages/cli/src/hooks.ts index 2575eb9..3c424de 100644 --- a/packages/cli/src/hooks.ts +++ b/packages/cli/src/hooks.ts @@ -1,350 +1,49 @@ -import { randomUUID } from 'node:crypto' -import { constants as fsConstants } from 'node:fs' -import { access, chmod, lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises' -import { basename, delimiter, dirname, isAbsolute, join, parse as parsePath, resolve } from 'node:path' +import { readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' import { loadConfig } from './config.js' -import { runGitText } from './git-process.js' -import { CLI_VERSION } from './version.js' +import { + AGENT_EVENTS, + agentEventMatcher, + agentHandler, + agentSettingsPath, + eventGroups, + isCodeTrussHandler, + readHookDocument, + removeCodeTrussHandlers, +} from './hook-agent-config.js' +import { AGENT_RUNNER } from './hook-agent-runner.js' +import { inspectHookDoctor, type HookDoctorResult } from './hook-doctor.js' +import { executablePath } from './hook-executable.js' +import { + BEGIN_MARKER, + effectivePreCommitPath, + preCommitBlock, + stripCodeTrussPreCommit, +} from './hook-pre-commit.js' +import { parseTargets, type AgentSurface, type HookTarget } from './hook-targets.js' +import { commitPlannedWrites, mergePlannedWrites, plannedWrite, type HookInstallPlan } from './hook-writes.js' import { verifyCommandTrustStatus } from './verify-trust.js' -type HookHandler = { type?: string; command?: string; args?: string[]; [key: string]: unknown } -type HookGroup = { matcher?: string; hooks?: HookHandler[]; [key: string]: unknown } -type HookDocument = { hooks?: Record; [key: string]: unknown } -type HookTarget = 'pre-commit' | 'claude' | 'codex' - -export interface HookDoctorCheck { - level: 'ok' | 'warning' | 'error' - target: HookTarget | 'config' | 'runtime' | 'agent-runtime' - message: string - path?: string -} - -export interface HookDoctorResult { - ok: boolean - checks: HookDoctorCheck[] -} - -export type HookHealthStatus = 'not_installed' | 'healthy' | 'warning' | 'unhealthy' - -export interface LocalHookHealth { - preCommit: HookHealthStatus - claude: HookHealthStatus - codex: HookHealthStatus -} - -interface PlannedWrite { - path: string - contents: Buffer - defaultMode: number - forceMode?: number -} - -interface FileSnapshot { - path: string - exists: boolean - contents?: Buffer - mode?: number -} - -interface HookInstallPlan { - writes: PlannedWrite[] - installedPaths: string[] -} - -const MARKER = 'codetruss-agent-guard' -export const CODETRUSS_PRE_COMMIT_ENV = 'CODETRUSS_INTERNAL_PRE_COMMIT' -const SUPPORTS_POSIX_FILE_MODES = process.platform !== 'win32' -const BEGIN_MARKER = `# ${MARKER}:begin` -const END_MARKER = `# ${MARKER}:end` -const AGENT_EVENTS = ['UserPromptSubmit', 'PostToolUse', 'Stop'] as const -// The internal Stop review has a five-minute hard deadline. Keep the installed -// agent envelope wider so it can persist a failure result and clean private Git -// evidence before the host terminates the hook process. -const STOP_HOOK_TIMEOUT_SECONDS = 6 * 60 -const AGENT_RUNNER = `'use strict' -const { existsSync } = require('node:fs') -const { execFileSync, spawnSync } = require('node:child_process') -const { join } = require('node:path') - -const surface = process.argv[2] -const maxInputBytes = 16 * 1024 * 1024 -if (surface !== 'claude' && surface !== 'codex') { - process.stderr.write('codetruss hook: expected claude or codex\\n') - process.exit(3) -} - -function safeFailure(input, message) { - let event - let stopHookActive = false - const textInput = input.toString('utf8') - try { - const parsed = JSON.parse(textInput) - event = parsed.hook_event_name - stopHookActive = parsed.stop_hook_active === true - } catch { - const prefix = textInput.slice(0, 64 * 1024) - event = /"hook_event_name"\\s*:\\s*"([^"]+)"/.exec(prefix)?.[1] - stopHookActive = /"stop_hook_active"\\s*:\\s*true/.test(prefix) - } - const text = ('CodeTruss hook failed safely: ' + message).slice(0, 9000) - if (event === 'UserPromptSubmit' || (event === 'Stop' && !stopHookActive)) { - return { decision: 'block', reason: text } - } - return { systemMessage: text } -} - -const chunks = [] -let inputBytes = 0 -let tooLarge = false -process.stdin.on('data', (value) => { - const chunk = Buffer.from(value) - inputBytes += chunk.length - if (inputBytes > maxInputBytes) tooLarge = true - else chunks.push(chunk) -}) -process.stdin.on('end', () => { - const input = Buffer.concat(chunks) - if (tooLarge) { - process.stdout.write(JSON.stringify(safeFailure(input, 'hook input exceeded 16 MiB')) + '\\n') - process.exit(0) - } - let root - try { - root = execFileSync('git', ['-c', 'core.longpaths=true', 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim() - } catch { - process.stdout.write(JSON.stringify(safeFailure(input, 'could not resolve the Git repository root')) + '\\n') - process.exit(0) - } - const local = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'codetruss.cmd' : 'codetruss') - const command = existsSync(local) ? local : 'codetruss' - const result = spawnSync(command, ['hooks', 'dispatch', surface], { - cwd: root, - input, - encoding: 'utf8', - shell: process.platform === 'win32', - maxBuffer: 64 * 1024, - }) - if (result.error || result.status !== 0) { - const detail = result.error ? result.error.message : (result.stderr || 'dispatch exited with status ' + String(result.status)).trim() - process.stdout.write(JSON.stringify(safeFailure(input, detail)) + '\\n') - process.exit(0) - } - if (result.stderr) process.stderr.write(result.stderr) - if (result.stdout) process.stdout.write(result.stdout) - process.exit(0) -}) -` - -function plannedWrite(path: string, contents: string | Buffer, defaultMode: number, forceMode?: number): PlannedWrite { - return { - path: resolve(path), - contents: Buffer.isBuffer(contents) ? Buffer.from(contents) : Buffer.from(contents, 'utf8'), - defaultMode, - ...(forceMode === undefined ? {} : { forceMode }), - } -} - -async function snapshotFile(path: string): Promise { - try { - const metadata = await lstat(path) - if (!metadata.isFile()) { - throw new Error(`refusing to replace non-regular hook file ${path}`) - } - return { - path, - exists: true, - contents: await readFile(path), - mode: metadata.mode & 0o777, - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { path, exists: false } - throw error - } -} - -async function snapshotStillMatches(snapshot: FileSnapshot): Promise { - try { - const metadata = await lstat(snapshot.path) - if (!snapshot.exists || !metadata.isFile()) return false - const contents = await readFile(snapshot.path) - return contents.equals(snapshot.contents!) && (metadata.mode & 0o777) === snapshot.mode - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return !snapshot.exists - throw error - } -} - -async function writeTemporaryFile(write: PlannedWrite, mode: number): Promise { - await mkdir(dirname(write.path), { recursive: true }) - const temporary = join(dirname(write.path), `.${basename(write.path)}.codetruss-${process.pid}-${randomUUID()}.tmp`) - try { - await writeFile(temporary, write.contents, { flag: 'wx', mode }) - await chmod(temporary, mode) - return temporary - } catch (error) { - await rm(temporary, { force: true }).catch(() => undefined) - throw error - } -} - -function mergePlannedWrites(plans: HookInstallPlan[]): { writes: PlannedWrite[]; installedPaths: string[] } { - const writes = new Map() - const installedPaths = new Set() - for (const plan of plans) { - for (const path of plan.installedPaths) installedPaths.add(path) - for (const write of plan.writes) { - const existing = writes.get(write.path) - if (existing && (!existing.contents.equals(write.contents) - || existing.defaultMode !== write.defaultMode || existing.forceMode !== write.forceMode)) { - throw new Error(`hook installation planned conflicting writes to ${write.path}`) - } - writes.set(write.path, write) - } - } - return { writes: [...writes.values()], installedPaths: [...installedPaths] } -} - -/** - * Stage every replacement in its destination directory before publishing any - * of them. If publication fails, restore the exact bytes and mode captured at - * the start of the transaction. A concurrent editor is detected before the - * first rename so CodeTruss never knowingly overwrites a newer hook config. - */ -async function commitPlannedWrites(writes: PlannedWrite[]): Promise { - const snapshots = new Map() - const temporaryFiles = new Map() - const committed: PlannedWrite[] = [] - for (const write of writes) snapshots.set(write.path, await snapshotFile(write.path)) - try { - for (const write of writes) { - const snapshot = snapshots.get(write.path)! - const mode = write.forceMode ?? snapshot.mode ?? write.defaultMode - temporaryFiles.set(write.path, await writeTemporaryFile(write, mode)) - } - for (const snapshot of snapshots.values()) { - if (!await snapshotStillMatches(snapshot)) { - throw new Error(`hook file changed during installation and was left untouched: ${snapshot.path}`) - } - } - for (const write of writes) { - await rename(temporaryFiles.get(write.path)!, write.path) - temporaryFiles.delete(write.path) - committed.push(write) - } - } catch (error) { - const rollbackErrors: string[] = [] - for (const write of committed.reverse()) { - const snapshot = snapshots.get(write.path)! - try { - if (!snapshot.exists) { - await rm(write.path, { force: true }) - } else { - const restore = plannedWrite(write.path, snapshot.contents!, snapshot.mode!, snapshot.mode!) - const temporary = await writeTemporaryFile(restore, snapshot.mode!) - await rename(temporary, write.path) - } - } catch (rollbackError) { - rollbackErrors.push(`${write.path}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`) - } - } - if (rollbackErrors.length) { - throw new Error(`${error instanceof Error ? error.message : String(error)}; hook rollback also failed: ${rollbackErrors.join('; ')}`) - } - throw error - } finally { - await Promise.all([...temporaryFiles.values()].map((path) => rm(path, { force: true }).catch(() => undefined))) - } -} - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -async function readHookDocument(path: string): Promise { - let text: string - try { - text = await readFile(path, 'utf8') - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} - throw error - } - let parsed: unknown - try { - parsed = JSON.parse(text) - } catch (error) { - throw new Error(`refusing to overwrite invalid JSON in ${path}: ${error instanceof Error ? error.message : String(error)}`) - } - if (!isRecord(parsed)) throw new Error(`refusing to overwrite ${path}: top-level JSON must be an object`) - if (parsed.hooks !== undefined && !isRecord(parsed.hooks)) { - throw new Error(`refusing to overwrite ${path}: hooks must be an object`) - } - return parsed as HookDocument -} - -function eventGroups(doc: HookDocument, path: string, event: string): HookGroup[] { - doc.hooks ??= {} - const value = doc.hooks[event] - if (value === undefined) { - const groups: HookGroup[] = [] - doc.hooks[event] = groups - return groups - } - if (!Array.isArray(value) || value.some((group) => !isRecord(group))) { - throw new Error(`refusing to overwrite ${path}: hooks.${event} must be an array of objects`) - } - for (const group of value as HookGroup[]) { - if (group.hooks !== undefined && (!Array.isArray(group.hooks) || group.hooks.some((handler) => !isRecord(handler)))) { - throw new Error(`refusing to overwrite ${path}: hooks.${event}[].hooks must be an array of objects`) - } - } - return value as HookGroup[] -} - -function isCodeTrussHandler(handler: HookHandler): boolean { - return [handler.command, ...(handler.args ?? [])].some((value) => typeof value === 'string' && value.includes('.codetruss/hooks/agent.cjs')) -} - -function removeCodeTrussHandlers(groups: HookGroup[]): void { - for (let index = groups.length - 1; index >= 0; index--) { - const group = groups[index] - if (!Array.isArray(group.hooks)) continue - group.hooks = group.hooks.filter((handler) => !isCodeTrussHandler(handler)) - if (group.hooks.length === 0) groups.splice(index, 1) - } -} - -function agentCommand(surface: 'claude' | 'codex'): HookHandler { - if (surface === 'claude') { - return { - command: 'node', - args: ['${CLAUDE_PROJECT_DIR}/.codetruss/hooks/agent.cjs', 'claude'], - } - } - return { - command: 'node "$(git -c core.longpaths=true rev-parse --show-toplevel)/.codetruss/hooks/agent.cjs" codex', - commandWindows: "$root = git -c core.longpaths=true rev-parse --show-toplevel; if ($LASTEXITCODE -eq 0) { node (Join-Path $root '.codetruss/hooks/agent.cjs') codex }", - } -} - -function agentHandler(surface: 'claude' | 'codex', event: typeof AGENT_EVENTS[number]): HookHandler { - const timeout = event === 'PostToolUse' ? 10 : event === 'UserPromptSubmit' ? 60 : STOP_HOOK_TIMEOUT_SECONDS - const statusMessage = event === 'PostToolUse' - ? 'Checking scope with CodeTruss' - : event === 'UserPromptSubmit' - ? 'Capturing CodeTruss turn baseline' - : 'Writing CodeTruss review receipt' - return { type: 'command', ...agentCommand(surface), timeout, statusMessage } -} - -async function planAgentHook(root: string, surface: 'claude' | 'codex'): Promise { - const dir = join(root, surface === 'claude' ? '.claude' : '.codex') - const path = join(dir, surface === 'claude' ? 'settings.json' : 'hooks.json') +export { CODETRUSS_PRE_COMMIT_ENV } from './hook-pre-commit.js' +export { installedCliVersion } from './hook-executable.js' +export { + inspectHookDoctor, + inspectLocalHookHealth, + type HookDoctorCheck, + type HookDoctorResult, + type HookHealthStatus, + type LocalHookHealth, +} from './hook-doctor.js' + +async function planAgentHook(root: string, surface: AgentSurface): Promise { + const path = agentSettingsPath(root, surface) const doc = await readHookDocument(path) for (const event of AGENT_EVENTS) { const groups = eventGroups(doc, path, event) removeCodeTrussHandlers(groups) + const matcher = agentEventMatcher(event) groups.push({ - ...(event === 'PostToolUse' ? { matcher: 'Edit|Write' } : {}), + ...(matcher === undefined ? {} : { matcher }), hooks: [agentHandler(surface, event)], }) } @@ -358,55 +57,6 @@ async function planAgentHook(root: string, surface: 'claude' | 'codex'): Promise } } -function effectivePreCommitPath(root: string): string { - const raw = runGitText(root, ['rev-parse', '--git-path', 'hooks/pre-commit']).trim() - if (!raw) throw new Error('Git did not return an effective pre-commit hook path') - return isAbsolute(raw) ? resolve(raw) : resolve(root, raw) -} - -function stripCodeTrussPreCommit(existing: string): string { - const begin = existing.indexOf(BEGIN_MARKER) - if (begin >= 0) { - const lineStart = existing.lastIndexOf('\n', begin - 1) + 1 - const end = existing.indexOf(END_MARKER, begin) - if (end < 0) throw new Error('existing CodeTruss pre-commit block is missing its end marker') - const lineEnd = existing.indexOf('\n', end) - return `${existing.slice(0, lineStart)}${lineEnd < 0 ? '' : existing.slice(lineEnd + 1)}`.replace(/\n{3,}$/g, '\n') - } - const legacy = existing.indexOf(`# ${MARKER}`) - if (legacy >= 0) { - const lineStart = existing.lastIndexOf('\n', legacy - 1) + 1 - return existing.slice(0, lineStart).replace(/\n{3,}$/g, '\n') - } - return existing -} - -function preCommitBlock(): string { - return `${BEGIN_MARKER} -ROOT="$(git -c core.longpaths=true rev-parse --show-toplevel 2>/dev/null)" || exit 0 -CODETRUSS_STATUS=0 -if [ -x "$ROOT/node_modules/.bin/codetruss" ]; then - ${CODETRUSS_PRE_COMMIT_ENV}=1 "$ROOT/node_modules/.bin/codetruss" review --staged --task "pre-commit" || CODETRUSS_STATUS=$? -else - ${CODETRUSS_PRE_COMMIT_ENV}=1 codetruss review --staged --task "pre-commit" || CODETRUSS_STATUS=$? -fi -case "$CODETRUSS_STATUS" in - 0) ;; - 1) - printf '%s\n' 'CodeTruss REVIEW_REQUIRED: receipt created; commit allowed for human review.' >&2 - ;; - 2) - printf '%s\n' 'CodeTruss FAILED: commit blocked. Review the receipt before retrying.' >&2 - exit 2 - ;; - *) - printf '%s\n' "CodeTruss could not produce a trustworthy receipt (exit $CODETRUSS_STATUS); commit blocked." >&2 - exit "$CODETRUSS_STATUS" - ;; -esac -${END_MARKER}` -} - async function planPreCommit(root: string): Promise { const path = effectivePreCommitPath(root) let existing = '' @@ -434,12 +84,6 @@ async function planPreCommit(root: string): Promise { } } -function parseTargets(target: string): HookTarget[] { - const valid = new Set(['pre-commit', 'claude', 'codex', 'all']) - if (!valid.has(target)) throw new Error(`unknown hook target ${target}; expected pre-commit, claude, codex, or all`) - return target === 'all' ? ['pre-commit', 'claude', 'codex'] : [target as HookTarget] -} - async function assertHookPolicyReady(root: string, targets: HookTarget[]): Promise { const config = await loadConfig(root) if (targets.some((target) => target === 'claude' || target === 'codex') && config.allow.length === 0) { @@ -470,9 +114,8 @@ export async function installHooks(root: string, target: string): Promise for (const path of plan.installedPaths) process.stdout.write(`installed ${path}\n`) } -async function uninstallAgentHook(root: string, surface: 'claude' | 'codex'): Promise { - const dir = join(root, surface === 'claude' ? '.claude' : '.codex') - const path = join(dir, surface === 'claude' ? 'settings.json' : 'hooks.json') +async function uninstallAgentHook(root: string, surface: AgentSurface): Promise { + const path = agentSettingsPath(root, surface) const doc = await readHookDocument(path) let changed = false for (const event of AGENT_EVENTS) { @@ -515,308 +158,12 @@ export async function uninstallHooks(root: string, target: string): Promise { - const path = join(root, surface === 'claude' ? '.claude/settings.json' : '.codex/hooks.json') +async function agentInstalled(root: string, surface: AgentSurface): Promise { + const path = agentSettingsPath(root, surface) const doc = await readHookDocument(path) return AGENT_EVENTS.every((event) => eventGroups(doc, path, event).some((group) => group.hooks?.some(isCodeTrussHandler))) } -async function executablePath(root: string): Promise { - const local = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'codetruss.cmd' : 'codetruss') - if (await access(local, fsConstants.X_OK).then(() => true, () => false)) return local - const extensions = process.platform === 'win32' - ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') - : [''] - for (const directory of (process.env.PATH ?? '').split(delimiter).filter(Boolean)) { - for (const extension of extensions) { - const candidate = join(directory, `codetruss${extension.toLowerCase()}`) - if (await access(candidate, fsConstants.X_OK).then(() => true, () => false)) return candidate - } - } - return undefined -} - -/** - * Version of the @codetruss/cli install that owns `executable`, read from its - * package manifest. Never executes the binary: resolving a shadowing install - * must not run whatever happens to be first on PATH. - */ -export async function installedCliVersion(executable: string): Promise { - let cursor: string - try { - cursor = dirname(await realpath(executable)) - } catch { - return undefined - } - const { root } = parsePath(cursor) - while (true) { - try { - const manifest = JSON.parse(await readFile(join(cursor, 'package.json'), 'utf8')) as { - name?: unknown - version?: unknown - } - if (manifest.name === '@codetruss/cli' && typeof manifest.version === 'string') return manifest.version - } catch { - // no manifest here, or unreadable — keep walking up - } - if (cursor === root) return undefined - const parent = dirname(cursor) - if (parent === cursor) return undefined - cursor = parent - } -} - -/** - * The hooks invoke `codetruss` by name, so they run whatever PATH resolves — - * which is not always what was just installed. A stale binary earlier in PATH - * silently shadows the new one, and the installer's own "Ready" message used to - * hide it. Reported as a warning: the hooks still work, they just are not this - * version. Determined by manifest version, so a repository-local install of the - * same version is not mistaken for a shadow. - */ -async function inspectExecutableShadow( - executable: string, - add: (check: HookDoctorCheck) => void, -): Promise { - const resolved = await installedCliVersion(executable) - if (!resolved || resolved === CLI_VERSION) return - add({ - level: 'warning', - target: 'runtime', - message: `installed hooks resolve codetruss ${resolved}, but this CLI is ${CLI_VERSION}; put the intended install first on PATH or remove the older one`, - path: executable, - }) -} - -function exactAgentHandler(handler: HookHandler, expected: HookHandler): boolean { - return handler.type === expected.type - && handler.command === expected.command - && JSON.stringify(handler.args) === JSON.stringify(expected.args) - && handler.commandWindows === expected.commandWindows - && handler.timeout === expected.timeout - && handler.statusMessage === expected.statusMessage -} - -async function inspectAgentHook( - root: string, - surface: 'claude' | 'codex', - add: (check: HookDoctorCheck) => void, -): Promise { - const path = join(root, surface === 'claude' ? '.claude/settings.json' : '.codex/hooks.json') - let doc: HookDocument - try { - doc = await readHookDocument(path) - } catch (error) { - add({ level: 'error', target: surface, message: error instanceof Error ? error.message : String(error), path }) - return - } - for (const event of AGENT_EVENTS) { - let groups: HookGroup[] - try { - groups = eventGroups(doc, path, event) - } catch (error) { - add({ level: 'error', target: surface, message: error instanceof Error ? error.message : String(error), path }) - return - } - const installed = groups.flatMap((group) => (group.hooks ?? []).map((handler) => ({ group, handler }))) - .filter(({ handler }) => isCodeTrussHandler(handler)) - if (installed.length !== 1) { - add({ - level: 'error', - target: surface, - message: `${event} must contain exactly one CodeTruss handler (found ${installed.length})`, - path, - }) - continue - } - const expected = agentHandler(surface, event) - const expectedMatcher = event === 'PostToolUse' ? 'Edit|Write' : undefined - if (!exactAgentHandler(installed[0].handler, expected) || installed[0].group.matcher !== expectedMatcher) { - add({ level: 'error', target: surface, message: `${event} handler differs from the current safe installation`, path }) - continue - } - add({ level: 'ok', target: surface, message: `${event} handler is current`, path }) - } - try { - const metadata = await lstat(path) - if (SUPPORTS_POSIX_FILE_MODES && (metadata.mode & 0o022) !== 0) { - add({ level: 'error', target: surface, message: 'hook configuration is writable by group or other users', path }) - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - add({ level: 'error', target: surface, message: error instanceof Error ? error.message : String(error), path }) - } - } -} - -async function inspectRunner(root: string, add: (check: HookDoctorCheck) => void): Promise { - const path = join(root, '.codetruss', 'hooks', 'agent.cjs') - try { - const [contents, metadata] = await Promise.all([readFile(path, 'utf8'), lstat(path)]) - if (!metadata.isFile()) { - add({ level: 'error', target: 'agent-runtime', message: 'agent hook runner is not a regular file', path }) - } else if (contents !== AGENT_RUNNER) { - add({ level: 'error', target: 'agent-runtime', message: 'agent hook runner differs from this CLI version; reinstall hooks', path }) - } else if (SUPPORTS_POSIX_FILE_MODES && (metadata.mode & 0o022) !== 0) { - add({ level: 'error', target: 'agent-runtime', message: 'agent hook runner is writable by group or other users', path }) - } else { - add({ - level: 'ok', - target: 'agent-runtime', - message: SUPPORTS_POSIX_FILE_MODES - ? 'agent hook runner is current and owner-controlled' - : 'agent hook runner is current; POSIX permission checks do not apply on Windows', - path, - }) - } - } catch (error) { - add({ - level: 'error', - target: 'agent-runtime', - message: (error as NodeJS.ErrnoException).code === 'ENOENT' - ? 'agent hook runner is missing; reinstall hooks' - : error instanceof Error ? error.message : String(error), - path, - }) - } -} - -async function inspectPreCommit(root: string, add: (check: HookDoctorCheck) => void): Promise { - const path = effectivePreCommitPath(root) - try { - const [contents, metadata] = await Promise.all([readFile(path, 'utf8'), lstat(path)]) - const beginCount = contents.split(BEGIN_MARKER).length - 1 - const endCount = contents.split(END_MARKER).length - 1 - const begin = contents.indexOf(BEGIN_MARKER) - const end = contents.indexOf(END_MARKER, begin) + END_MARKER.length - if (beginCount !== 1 || endCount !== 1 || begin < 0 || contents.slice(begin, end) !== preCommitBlock()) { - add({ level: 'error', target: 'pre-commit', message: 'installed block is missing, duplicated, or stale; reinstall hooks', path }) - } else { - add({ level: 'ok', target: 'pre-commit', message: 'staged-review block is current', path }) - } - if (!SUPPORTS_POSIX_FILE_MODES) { - add({ level: 'ok', target: 'pre-commit', message: 'hook file is present; POSIX permission checks do not apply on Windows', path }) - } else if ((metadata.mode & 0o100) === 0) { - add({ level: 'error', target: 'pre-commit', message: 'hook is not executable by its owner', path }) - } else if ((metadata.mode & 0o022) !== 0) { - add({ level: 'error', target: 'pre-commit', message: 'hook is writable by group or other users', path }) - } else { - add({ level: 'ok', target: 'pre-commit', message: 'hook permissions are owner-controlled and executable', path }) - } - } catch (error) { - add({ - level: 'error', - target: 'pre-commit', - message: (error as NodeJS.ErrnoException).code === 'ENOENT' - ? 'hook is not installed' - : error instanceof Error ? error.message : String(error), - path, - }) - } -} - -export async function inspectHookDoctor(root: string, target: string): Promise { - const targets = parseTargets(target) - const checks: HookDoctorCheck[] = [] - const add = (check: HookDoctorCheck) => checks.push(check) - const agentTargets = targets.filter((name): name is 'claude' | 'codex' => name !== 'pre-commit') - try { - const config = await loadConfig(root) - if (config.verify.length) { - const trust = await verifyCommandTrustStatus(root, config.verify) - add({ - level: trust.trusted ? 'ok' : 'error', - target: 'config', - message: trust.trusted - ? `repository verification commands are trusted (${trust.hash.slice(0, 12)})` - : `repository verification commands are untrusted (${trust.hash.slice(0, 12)}); inspect them and run codetruss verify-policy trust`, - path: join(root, '.codetruss.yml'), - }) - } - } catch (error) { - add({ level: 'error', target: 'config', message: error instanceof Error ? error.message : String(error), path: join(root, '.codetruss.yml') }) - } - if (agentTargets.length) { - try { - const config = await loadConfig(root) - if (config.allow.length) { - add({ level: 'ok', target: 'config', message: `${config.allow.length} allowed task-scope glob${config.allow.length === 1 ? '' : 's'} configured`, path: join(root, '.codetruss.yml') }) - } else { - add({ level: 'error', target: 'config', message: 'agent hooks require at least one allow glob in .codetruss.yml', path: join(root, '.codetruss.yml') }) - } - } catch (error) { - add({ level: 'error', target: 'config', message: error instanceof Error ? error.message : String(error), path: join(root, '.codetruss.yml') }) - } - await inspectRunner(root, add) - } - const cliPath = await executablePath(root) - if (cliPath) { - add({ level: 'ok', target: 'runtime', message: 'CodeTruss CLI is resolvable by installed hooks', path: cliPath }) - await inspectExecutableShadow(cliPath, add) - } else add({ level: 'error', target: 'runtime', message: 'CodeTruss CLI is not available locally or on PATH' }) - for (const name of targets) { - if (name === 'pre-commit') await inspectPreCommit(root, add) - else { - await inspectAgentHook(root, name, add) - if (name === 'codex') { - add({ - level: 'warning', - target: 'codex', - message: 'hook trust cannot be verified here; open /hooks in Codex and trust this exact project hook. New or changed hook definitions require review again', - path: join(root, '.codex', 'hooks.json'), - }) - } - } - } - const errors = checks.filter((check) => check.level === 'error').length - return { ok: errors === 0, checks } -} - -async function hookInstallations(root: string): Promise> { - const agentPresent = async (surface: 'claude' | 'codex'): Promise => { - const path = join(root, surface === 'claude' ? '.claude/settings.json' : '.codex/hooks.json') - return readFile(path, 'utf8').then((text) => text.includes('.codetruss/hooks/agent.cjs'), () => false) - } - return { - 'pre-commit': await readFile(effectivePreCommitPath(root), 'utf8') - .then((text) => text.includes(BEGIN_MARKER), () => false), - claude: await agentPresent('claude'), - codex: await agentPresent('codex'), - } -} - -/** Privacy-safe health summary: no hook path, command, or diagnostic text leaves this function. */ -export async function inspectLocalHookHealth(root: string): Promise { - const [installed, preCommitDoctor, claudeDoctor, codexDoctor] = await Promise.all([ - hookInstallations(root), - inspectHookDoctor(root, 'pre-commit'), - inspectHookDoctor(root, 'claude'), - inspectHookDoctor(root, 'codex'), - ]) - const doctors: Record = { - 'pre-commit': preCommitDoctor, - claude: claudeDoctor, - codex: codexDoctor, - } - const status = (target: HookTarget): HookHealthStatus => { - if (!installed[target]) return 'not_installed' - const relevant = doctors[target].checks.filter((check) => ( - check.target === target - || check.target === 'runtime' - || check.target === 'agent-runtime' - || check.target === 'config' - )) - if (relevant.some((check) => check.level === 'error')) return 'unhealthy' - if (relevant.some((check) => check.level === 'warning')) return 'warning' - return 'healthy' - } - return { - preCommit: status('pre-commit'), - claude: status('claude'), - codex: status('codex'), - } -} - export async function doctorHooks(root: string, target: string): Promise { const result = await inspectHookDoctor(root, target) for (const check of result.checks) { @@ -836,7 +183,7 @@ export async function hookStatus(root: string, target: string): Promise { path = effectivePreCommitPath(root) installed = await readFile(path, 'utf8').then((text) => text.includes(BEGIN_MARKER), () => false) } else { - path = join(root, name === 'claude' ? '.claude/settings.json' : '.codex/hooks.json') + path = agentSettingsPath(root, name) installed = await agentInstalled(root, name) } process.stdout.write(`${installed ? 'installed' : 'not installed'}\t${name}\t${path}\n`) diff --git a/packages/cli/src/receipt.ts b/packages/cli/src/receipt.ts index 4c2e955..62e977f 100644 --- a/packages/cli/src/receipt.ts +++ b/packages/cli/src/receipt.ts @@ -456,6 +456,67 @@ function commentSignalLines(receipt: Receipt): string[] { ] } +function tableCell(value: string): string { + return value.replaceAll('|', '\\|') +} + +function findingLocation(finding: Receipt['analyzers']['findings'][number]): string { + return finding.filePath ? `\`${finding.filePath}${finding.line ? `:${finding.line}` : ''}\`` : 'repository' +} + +/** At most this many dismissals are tabulated; the signed JSON always holds them all. */ +const SUPPRESSION_ROW_LIMIT = 100 +/** At most this many reason-less marker locations are named inline. */ +const REJECTED_MARKER_LIMIT = 20 + +/** + * What a `codetruss-ignore` comment did to this analysis. + * + * A dismissed finding is REPORTED as dismissed, never omitted. The product's + * whole claim is that the receipt states what was and was not flagged; a receipt + * that silently dropped a finding because a comment in the repository told it to + * would make "nothing was found" reachable by editing a comment, and the + * signature would then be attesting to a sentence the evidence does not support. + * So the finding, its location, and the exact reason its author gave all survive + * into the signed bytes, and the reader decides whether the reason is good. + * + * Markers that gave no reason are named too. They dismiss nothing — a reason is + * required precisely because the reason is the evidence — and a developer who + * wrote one has to be able to find out why nothing happened. + * + * Emits nothing when a repository dismissed nothing, so every receipt signed + * before suppression existed still renders, and verifies, byte for byte. + */ +function suppressionLines(receipt: Receipt): string[] { + const suppressed = receipt.analyzers.suppressed ?? [] + const rejected = receipt.analyzers.rejectedSuppressions ?? [] + if (suppressed.length === 0 && rejected.length === 0) return [] + const rows = suppressed.slice(0, SUPPRESSION_ROW_LIMIT) + const namedMarkers = rejected.slice(0, REJECTED_MARKER_LIMIT) + return [ + '', + `## Suppressed findings (${suppressed.length})`, + ...(suppressed.length ? [ + '', + 'The analyzers above produced these findings, and a `codetruss-ignore: ` comment in the source dismissed them. They did not affect the verdict. This list covers the whole repository, not only the changed files.', + '', + '| Severity | Analyzer | Location | Finding | Reason given |', + '|---|---|---|---|---|', + ...rows.map((finding) => `| ${finding.severity} | ${finding.analyzerId ?? 'unknown'} | ${findingLocation(finding)} | ${tableCell(finding.title)} | ${tableCell(finding.suppression?.reason ?? '')} |`), + ...(suppressed.length > rows.length + ? ['', `${suppressed.length - rows.length} further dismissed finding(s) are recorded in the signed JSON and not tabulated here.`] + : []), + ] : [ + '', + 'Nothing was dismissed in this repository.', + ]), + ...(rejected.length ? [ + '', + `${rejected.length} \`codetruss-ignore\` marker(s) gave no reason and therefore dismissed nothing: ${namedMarkers.map((site) => `\`${site}\``).join(', ')}${rejected.length > namedMarkers.length ? `, and ${rejected.length - namedMarkers.length} more` : ''}. A dismissal is accepted only as \`codetruss-ignore: \`, because the reason is the evidence. Those findings are still reported above.`, + ] : []), + ] +} + /** Which historical rendering of the analysis block to reproduce. */ type ReceiptMarkdownVariant = 'current' | 'legacy-scores' | 'prior-profile' @@ -494,7 +555,8 @@ function renderMarkdownInternal(receipt: Receipt, variant: ReceiptMarkdownVarian '', '| Severity | Analyzer | Location | Finding |', '|---|---|---|---|', - ...receipt.analyzers.findings.slice(0, 100).map((finding) => `| ${finding.severity} | ${finding.analyzerId ?? 'unknown'} | ${finding.filePath ? `\`${finding.filePath}${finding.line ? `:${finding.line}` : ''}\`` : 'repository'} | ${finding.title.replaceAll('|', '\\|')} |`), + ...receipt.analyzers.findings.slice(0, 100).map((finding) => `| ${finding.severity} | ${finding.analyzerId ?? 'unknown'} | ${findingLocation(finding)} | ${tableCell(finding.title)} |`), + ...suppressionLines(receipt), '', // Emits nothing when no finding carries a fix, so every receipt signed // before suggestions existed still renders to its original bytes. @@ -665,6 +727,10 @@ export async function createSyncEnvelope(receipt: Receipt): Promise Boolean(path) && !pathRelatedToChanges(path, changedPaths))), ] for (const finding of receipt.analyzers.findings) collectPotentialPaths(finding.metadata, possiblePrivatePaths) + for (const finding of receipt.analyzers.suppressed ?? []) { + if (finding.filePath && !pathRelatedToChanges(finding.filePath, changedPaths)) possiblePrivatePaths.push(finding.filePath) + collectPotentialPaths(finding.metadata, possiblePrivatePaths) + } for (const pass of receipt.analyzers.passes) { for (const finding of pass.result.findings) collectPotentialPaths(finding.metadata, possiblePrivatePaths) } @@ -689,7 +755,7 @@ export async function createSyncEnvelope(receipt: Receipt): Promise findings .filter((finding) => !finding.filePath || pathRelatedToChanges(finding.filePath, changedPaths)) .map((finding) => { const sanitized = { @@ -698,12 +764,30 @@ export async function createSyncEnvelope(receipt: Receipt): Promise pathRelatedToChanges(site.slice(0, site.lastIndexOf(':')), changedPaths)) + if (sites.length) synced.analyzers.rejectedSuppressions = sites + else delete synced.analyzers.rejectedSuppressions + } synced.verifications = synced.verifications.map((item) => ({ ...item, command: '[redacted for sync]', output: '' })) synced.evidence = { patchSha256: receipt.evidence.patchSha256, diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 38342c4..df1f3d6 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -146,8 +146,21 @@ export interface LlmReview { interface AnalyzerReceiptEvidence { passes: AnalyzerPass[] - /** Only findings introduced or worsened between the reviewed snapshots. */ + /** Only findings introduced or worsened between the reviewed snapshots, minus any dismissed inline. */ findings: AnalyzerFinding[] + /** + * Findings dismissed by an inline `codetruss-ignore: ` comment, each + * carrying the reason given. Whole-repository, not delta-scoped, and present + * only when this repository dismissed something — which is what keeps every + * receipt signed before suppression existed rendering byte for byte. + */ + suppressed?: AnalyzerFinding[] + /** + * `path:line` of markers that gave no reason and therefore dismissed nothing. + * Their findings stay in `findings`; this exists so a comment is never seen to + * fail in silence. + */ + rejectedSuppressions?: string[] delta?: { introduced: number; worsened: number; recurring: number; resolved: number } index: Pick } diff --git a/packages/cli/test/grammar-loader.test.ts b/packages/cli/test/grammar-loader.test.ts index 5a15a7d..dc8221e 100644 --- a/packages/cli/test/grammar-loader.test.ts +++ b/packages/cli/test/grammar-loader.test.ts @@ -4,7 +4,11 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' -import { pinnedGrammarPack } from '../src/grammar-pack-manifest.js' +import { + pinnedGrammarPack, + type PinnedGrammarFile, + type PinnedGrammarPack, +} from '../src/grammar-pack-manifest.js' import type { GrammarPackState } from '../src/grammar-pack.js' /** @@ -97,6 +101,78 @@ describe('the loader executes verified bytes, never a path', () => { }) }) +/** + * Which artifact is the grammar is a question the pin answers explicitly. + * + * It used to be answered by `files.find(f => f.name.startsWith('tree-sitter-'))` + * — the first match — while the pin verifier only proved that each published + * digest appeared SOMEWHERE in the generated file. The two together meant a pin + * with an extra entry ordered ahead of the real grammar passed review and was + * loaded instead of it. + */ +describe('the grammar is selected by role, not by name shape', () => { + it('ignores an injected artifact that would have won the old name-prefix match', async () => { + const dir = await scratchDir() + const contents = await verifiedContents() + contents.set('tree-sitter-evil.wasm', Buffer.from('not wasm')) + const injected: PinnedGrammarPack = { + ...pack, + files: [ + // No role, because the generated pin had no such field when this shape + // was hand-editable — and ordered first, matching `tree-sitter-`, which + // is exactly what the old `find` resolved as the grammar. + { + name: 'tree-sitter-evil.wasm', + url: '/downloads/grammars/python-1.0.0/tree-sitter-evil.wasm', + bytes: 8, + sha256: 'a'.repeat(64), + } as PinnedGrammarFile, + ...pack.files, + ], + } + + stub.current = { status: 'verified', pack: injected, dir, contents } + const load = await loadGrammarParser('python') + + // Against the old loader the injected file is the grammar, `Language.load` + // is handed `not wasm`, and this is a runtime failure instead. + expect(load.status).toBe('verified') + if (load.status !== 'verified') throw new Error('expected a verified load') + const tree = await load.parser.parse('python', 'import os\nos.system(name)\n') + expect(tree).not.toBeNull() + expect(tree!.hasError).toBe(false) + tree!.release() + }) + + it('refuses a pack that declares two grammars rather than loading the first', async () => { + const dir = await scratchDir() + const contents = await verifiedContents() + contents.set('tree-sitter-evil.wasm', Buffer.from('not wasm')) + const ambiguous: PinnedGrammarPack = { + ...pack, + files: [ + { + name: 'tree-sitter-evil.wasm', + role: 'grammar', + url: '/downloads/grammars/python-1.0.0/tree-sitter-evil.wasm', + bytes: 8, + sha256: 'a'.repeat(64), + }, + ...pack.files, + ], + } + + stub.current = { status: 'verified', pack: ambiguous, dir, contents } + const load = await loadGrammarParser('python') + + // A malformed pin is this binary's defect, not the user's install, so it + // must not render the receipt sentence that accuses their pack of tampering. + expect(load).toMatchObject({ status: 'failed', kind: 'runtime' }) + if (load.status !== 'failed') throw new Error('expected a failed load') + expect(load.reason).toContain('role grammar') + }) +}) + describe('why a pack was unusable is carried, not collapsed', () => { it('calls a pack that failed inspection a digest failure', async () => { const dir = await scratchDir() diff --git a/packages/cli/test/grammar-pack.test.ts b/packages/cli/test/grammar-pack.test.ts index c84cc22..f44f655 100644 --- a/packages/cli/test/grammar-pack.test.ts +++ b/packages/cli/test/grammar-pack.test.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' -import { createServer, type Server } from 'node:http' +import { createServer, type Server, type ServerResponse } from 'node:http' import { chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' +import type { Socket } from 'node:net' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -20,7 +21,10 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') const publishedDir = join(repoRoot, 'public', 'downloads', 'grammars') const cleanup: string[] = [] +/** Per-test servers, torn down with their still-open sockets. */ +const servers: Array<() => Promise> = [] afterEach(async () => { + await Promise.all(servers.splice(0).map((close) => close())) await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true }))) }) @@ -354,6 +358,79 @@ describe.skipIf(process.platform === 'win32')('symlinks and loose permissions', }) }) +/** + * A hostile or broken origin must not be able to hang `grammars install`. + * + * Both cases here are servers that never violate any integrity rule — they just + * never finish. Without a deadline the install waits for them forever, which is + * a foreground command a person is watching with no output and no way to know + * whether it is working. + */ +describe('a stalled download is abandoned, not waited out', () => { + /** A server that answers `handler` and is torn down with the test. */ + async function stallingOrigin(handler: (res: ServerResponse) => void): Promise { + const sockets = new Set() + const stalling = createServer((_req, res) => handler(res)) + stalling.on('connection', (socket) => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + }) + await new Promise((resolve) => stalling.listen(0, '127.0.0.1', resolve)) + servers.push(async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => stalling.close(() => resolve())) + }) + const address = stalling.address() + return `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}` + } + + /** {@link scratchEnv}, pointed at a server that will not finish. */ + async function stalledEnv(handler: (res: ServerResponse) => void): Promise { + return { ...(await scratchEnv()), [DEV_GRAMMAR_ORIGIN_ENV]: await stallingOrigin(handler) } + } + + it('gives up on a body that sends one byte and never ends', async () => { + // The exact shape the review demonstrated: a valid response, one byte, and + // then silence. Nothing about it is short, over-long or mis-hashed — the + // stream simply never ends, so only a clock can end it. + const env = await stalledEnv((res) => { + res.writeHead(200, { 'content-type': 'application/octet-stream' }) + res.write(Buffer.from([0x41])) + }) + + await expect(installGrammarPack('python', env, { totalMs: 5_000, idleMs: 250 })) + .rejects.toThrow(/sent no data for 250ms/) + // A failed install leaves nothing a later run could mistake for a pack. + await expect(inspectGrammarPack('python', env)).resolves.toMatchObject({ status: 'absent' }) + }) + + it('gives up on a server that accepts the connection and never answers', async () => { + const env = await stalledEnv(() => {}) + await expect(installGrammarPack('python', env, { totalMs: 5_000, idleMs: 250 })) + .rejects.toThrow(/sent no data for 250ms/) + }) + + it('gives up on a drip feed that never trips the idle clock', async () => { + // Progress without an end: a byte every 50ms keeps resetting the idle timer, + // so the total budget is the only thing that can stop it. 738 KB at this + // rate would take ten hours. + const env = await stalledEnv((res) => { + res.writeHead(200, { 'content-type': 'application/octet-stream' }) + const drip = setInterval(() => res.write(Buffer.from([0x41])), 50) + res.on('close', () => clearInterval(drip)) + }) + + await expect(installGrammarPack('python', env, { totalMs: 600, idleMs: 5_000 })) + .rejects.toThrow(/did not finish within 600ms/) + }) + + it('leaves a healthy install unaffected by the deadlines', async () => { + const env = await scratchEnv() + await expect(installGrammarPack('python', env, { totalMs: 30_000, idleMs: 10_000 })) + .resolves.toMatchObject({ alreadyInstalled: false }) + }) +}) + describe('uninstalling', () => { it('removes the pack and reports absence as the end state', async () => { const env = await scratchEnv() diff --git a/packages/cli/test/grammar-pin-verify.test.ts b/packages/cli/test/grammar-pin-verify.test.ts new file mode 100644 index 0000000..6fe2b98 --- /dev/null +++ b/packages/cli/test/grammar-pin-verify.test.ts @@ -0,0 +1,156 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { PINNED_GRAMMAR_PACKS } from '../src/grammar-pack-manifest.js' +import { lockedSourcePackage, lockfileEntries } from '../scripts/lockfile-integrity.mjs' +import { verifyGrammarPacks } from '../scripts/verify-grammar-packs.mjs' + +/** + * `pnpm grammars:verify` is the gate a hand-edited pin has to get past. + * + * The pin is generated and says so, which makes it exactly the file a reviewer + * skims. Everything the CLI will download and execute is described there, so the + * gate has to prove the whole file is what the published artifacts generate — + * not that each digest turns up somewhere inside it. + */ +const packageDir = join(dirname(fileURLToPath(import.meta.url)), '..') +const repoRoot = join(packageDir, '..', '..') +const realPinPath = join(packageDir, 'src', 'grammar-pack-manifest.ts') +const realLockfilePath = join(repoRoot, 'pnpm-lock.yaml') + +const cleanup: string[] = [] +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function scratchFile(name: string, contents: string): Promise { + const dir = await mkdtemp(join(tmpdir(), 'codetruss-pin-')) + cleanup.push(dir) + const path = join(dir, name) + await writeFile(path, contents, 'utf8') + return path +} + +/** + * The check this gate used to perform, kept here as the thing being disproved. + * + * Every assertion below that a tampered pin is rejected is paired with the + * observation that this returned true for it — otherwise "the new check fails on + * a bad pin" would not tell anyone whether the old one did too. + */ +function oldSubstringCheck(pin: string): boolean { + return PINNED_GRAMMAR_PACKS.every((pack) => ( + pack.files.every((file) => pin.includes(`"sha256": "${file.sha256}"`)) + )) +} + +describe('the compiled-in pin is verified whole, not sampled', () => { + it('accepts the pin this repository actually ships', async () => { + const packs = await verifyGrammarPacks() + expect(packs).toHaveLength(PINNED_GRAMMAR_PACKS.length) + expect(packs[0].files.map((file) => file.role)).toEqual(['runtime', 'runtime-wasm', 'grammar']) + }) + + it('rejects a fourth artifact added beside the three real digests', async () => { + // F8's bypass verbatim: keep every published digest, add an entry nobody + // published, and order it where the old name-prefix loader would pick it up + // as the grammar. The pack directory check would then demand the extra file + // be present, and the CLI would download and load it. + const real = await readFile(realPinPath, 'utf8') + const injected = [ + ' {', + ' "name": "tree-sitter-evil.wasm",', + ' "role": "grammar",', + ' "url": "/downloads/grammars/python-1.0.0/tree-sitter-evil.wasm",', + ' "bytes": 8,', + ` "sha256": "${'a'.repeat(64)}"`, + ' },', + '', + ].join('\n') + const tampered = real.replace(' "files": [\n', ` "files": [\n${injected}`) + expect(tampered).not.toBe(real) + expect(oldSubstringCheck(tampered)).toBe(true) + + await expect(verifyGrammarPacks({ pinPath: await scratchFile('pin.ts', tampered) })) + .rejects.toThrow(/not what the published grammar packs generate/) + }) + + it('rejects digests bound to the wrong file name', async () => { + // Both real digests are still present — they have simply swapped artifacts, + // so the CLI would demand the runtime hash from the WASM and vice versa. + const real = await readFile(realPinPath, 'utf8') + const [runtime, runtimeWasm] = PINNED_GRAMMAR_PACKS[0].files + const swapped = real + .replace(runtime.sha256, 'PLACEHOLDER') + .replace(runtimeWasm.sha256, runtime.sha256) + .replace('PLACEHOLDER', runtimeWasm.sha256) + expect(swapped).not.toBe(real) + expect(oldSubstringCheck(swapped)).toBe(true) + + await expect(verifyGrammarPacks({ pinPath: await scratchFile('pin.ts', swapped) })) + .rejects.toThrow(/not what the published grammar packs generate/) + }) + + it('rejects a hand edit below the digests, where a skimming reviewer stops', async () => { + // Nothing about the digests changes; `pinnedGrammarPack` simply stops being + // a lookup. The old check read the array and never looked at the code. + const real = await readFile(realPinPath, 'utf8') + const rewritten = real.replace( + 'return PINNED_GRAMMAR_PACKS.find((pack) => pack.name === name)', + 'return PINNED_GRAMMAR_PACKS[0]', + ) + expect(rewritten).not.toBe(real) + expect(oldSubstringCheck(rewritten)).toBe(true) + + await expect(verifyGrammarPacks({ pinPath: await scratchFile('pin.ts', rewritten) })) + .rejects.toThrow(/not what the published grammar packs generate/) + }) +}) + +/** + * The provenance strings are a claim about where a pack's bytes came from, and + * until they were checked against the lockfile they were only a comment: a + * dependency bump with a stale string published a pack that misidentified its + * own source, and nothing failed. + */ +describe('pack provenance is checked against the workspace lockfile', () => { + it('rejects a lockfile that pins a different version than the pack claims', async () => { + const lockfile = [ + 'packages:', + '', + ' web-tree-sitter@0.99.0:', + ' resolution: {integrity: sha512-AAAA==}', + '', + ' tree-sitter-wasms@0.1.11:', + ' resolution: {integrity: sha512-BBBB==}', + '', + ].join('\n') + + await expect(verifyGrammarPacks({ lockfilePath: await scratchFile('pnpm-lock.yaml', lockfile) })) + .rejects.toThrow(/web-tree-sitter@0.22.6 but pnpm-lock.yaml pins web-tree-sitter@0.99.0/) + }) + + it('reads the integrity of a single-version dependency out of the real lockfile', async () => { + const lockfile = await readFile(realLockfilePath, 'utf8') + const entry = lockedSourcePackage(lockfile, 'web-tree-sitter', '0.22.6') + expect(entry.integrity).toMatch(/^sha512-/) + // Scoped names split at the version, not at the scope. + expect(lockfileEntries(lockfile, '@types/node').length).toBeGreaterThan(0) + }) + + it('refuses to guess when a dependency resolves to two versions', () => { + const lockfile = [ + 'packages:', + '', + ' web-tree-sitter@0.22.6:', + ' resolution: {integrity: sha512-AAAA==}', + '', + ' web-tree-sitter@0.23.0:', + ' resolution: {integrity: sha512-BBBB==}', + '', + ].join('\n') + expect(() => lockedSourcePackage(lockfile, 'web-tree-sitter', '0.22.6')).toThrow(/2 versions/) + }) +}) diff --git a/packages/cli/test/grammar-sast.test.ts b/packages/cli/test/grammar-sast.test.ts index 5fe3522..0f48322 100644 --- a/packages/cli/test/grammar-sast.test.ts +++ b/packages/cli/test/grammar-sast.test.ts @@ -73,6 +73,14 @@ async function pythonRepo(): Promise { ' url = request.args.get("url")', ' return requests.get(url).text', '', + '', + '# The DB-API two-step: the cursor is a short local, not a DB-ish name.', + 'def get_user(conn):', + ' user_id = request.args.get("id")', + ' cur = conn.cursor()', + ' cur.execute("SELECT * FROM users WHERE id = " + user_id)', + ' return cur.fetchall()', + '', ].join('\n')) await writeFile(join(root, 'app', 'clean.ts'), 'export const a = 1\n') return root @@ -143,6 +151,7 @@ describe('the local pass with the grammar pack installed', () => { expect(rules).toContain('command-injection') expect(rules).toContain('path-traversal') expect(rules).toContain('ssrf') + expect(rules).toContain('sql-injection') for (const finding of result.findings) { expect(finding.analyzerId).toBe('local-sast') expect(finding.filePath).toBe('app/views.py') diff --git a/packages/cli/test/hooks.test.ts b/packages/cli/test/hooks.test.ts index 199bbec..589c31d 100644 --- a/packages/cli/test/hooks.test.ts +++ b/packages/cli/test/hooks.test.ts @@ -438,6 +438,61 @@ describe('hook installation', () => { } }) + it('names the drifted fields and the remedy when an installed agent handler is stale', async () => { + // The repository's own .codex/hooks.json sat several CLI versions behind + // the installer: no `core.longpaths=true` in the command, and a Stop + // timeout of 300 where the installer had moved to 360. Doctor caught it, + // but said only "differs" — indistinguishable from a deliberate hand-edit, + // and with no command to run next. + const root = await repo() + await writeConfig(root) + const bin = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'codetruss.cmd' : 'codetruss') + await mkdir(dirname(bin), { recursive: true }) + await writeFile(bin, process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n') + await chmod(bin, 0o755) + await installHooks(root, 'codex') + + const path = join(root, '.codex', 'hooks.json') + const document = JSON.parse(await readFile(path, 'utf8')) as { + hooks: Record> }>> + } + const staleHandler = (event: string) => document.hooks[event] + .flatMap((group) => group.hooks) + .find((handler) => String(handler.command).includes('.codetruss/hooks/agent.cjs'))! + const stop = staleHandler('Stop') + stop.timeout = 300 + stop.command = String(stop.command).replace(' -c core.longpaths=true', '') + stop.commandWindows = String(stop.commandWindows).replace(' -c core.longpaths=true', '') + staleHandler('UserPromptSubmit').statusMessage = 'Capturing baseline' + document.hooks.PostToolUse[0].matcher = 'Edit' + await writeFile(path, `${JSON.stringify(document, null, 2)}\n`) + + const doctor = await inspectHookDoctor(root, 'codex') + expect(doctor.ok).toBe(false) + const drifted = (event: string) => doctor.checks + .find((check) => check.target === 'codex' && check.message.startsWith(`${event} handler differs`)) + expect(drifted('Stop')?.message).toBe( + 'Stop handler differs from the current safe installation (command, commandWindows, timeout);' + + ' run codetruss hooks install codex to refresh it', + ) + expect(drifted('UserPromptSubmit')?.message).toBe( + 'UserPromptSubmit handler differs from the current safe installation (statusMessage);' + + ' run codetruss hooks install codex to refresh it', + ) + expect(drifted('PostToolUse')?.message).toBe( + 'PostToolUse handler differs from the current safe installation (matcher);' + + ' run codetruss hooks install codex to refresh it', + ) + for (const event of ['UserPromptSubmit', 'PostToolUse', 'Stop']) { + expect(drifted(event)?.level).toBe('error') + } + + // Reinstalling is the remedy the message names, so it must actually work. + await installHooks(root, 'codex') + const repaired = await inspectHookDoctor(root, 'codex') + expect(repaired.checks.filter((check) => check.message.includes('handler differs'))).toEqual([]) + }) + it('warns when an older install shadows the codetruss the hooks will run', async () => { // D6: the installer's readiness check was a bare `command -v codetruss`, // which succeeds just as happily when an older binary sits earlier in PATH. diff --git a/packages/cli/test/npm-tarball.test.ts b/packages/cli/test/npm-tarball.test.ts new file mode 100644 index 0000000..0579dec --- /dev/null +++ b/packages/cli/test/npm-tarball.test.ts @@ -0,0 +1,79 @@ +import { createSign, generateKeyPairSync } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { readTarballMembers, verifyRegistrySignature } from '../scripts/npm-tarball.mjs' + +/** + * The two primitives the release-time attestation rests on. + * + * `attest-grammar-sources.mjs` itself needs the network, so it is not a test — + * but nothing about reading a tarball or checking a signature does, and those + * are the parts where a quiet bug would turn the attestation into a formality + * that passes on anything. + */ +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +/** A real npm tarball this repository publishes, used as the fixture. */ +const cliTarball = join(repoRoot, 'public', 'downloads', 'codetruss-cli-latest.tgz') + +describe('reading an npm tarball', () => { + it('returns the members of a real published package', async () => { + const members = readTarballMembers(await readFile(cliTarball)) + const manifest = members.get('package/package.json') + expect(manifest).toBeInstanceOf(Buffer) + expect(JSON.parse(manifest!.toString('utf8')).name).toBe('@codetruss/cli') + // Every member is a file the archive actually carries, with its real length. + expect(members.get('package/dist/cli.cjs')!.length).toBeGreaterThan(1_000) + expect(members.get('package/does-not-exist')).toBeUndefined() + }) + + it('refuses an archive whose headers do not check out rather than guessing', async () => { + // Corrupt the length field of the first header. A reader that trusted it + // would resynchronise onto file content, read a data block as a header, and + // hand back whatever it found under whatever name it thought it had. + const { gunzipSync, gzipSync } = await import('node:zlib') + const tar = Buffer.from(gunzipSync(await readFile(cliTarball))) + tar.write('00000000777', 124, 11, 'ascii') + expect(() => readTarballMembers(gzipSync(tar))).toThrow(/bad checksum/) + }) +}) + +describe('verifying a registry signature', () => { + const keyPair = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }) + const keyid = 'SHA256:test' + const keys = [{ + keyid, + keytype: 'ecdsa-sha2-nistp256', + scheme: 'ecdsa-sha2-nistp256', + key: keyPair.publicKey.export({ format: 'der', type: 'spki' }).toString('base64'), + }] + const name = 'web-tree-sitter' + const version = '0.22.6' + const integrity = 'sha512-AAAA==' + const sign = (message: string) => createSign('SHA256') + .update(message) + .end() + .sign(keyPair.privateKey) + .toString('base64') + + it('accepts a signature over exactly name@version:integrity', () => { + const signature = { keyid, sig: sign(`${name}@${version}:${integrity}`) } + expect(() => verifyRegistrySignature({ name, version, integrity, signature, keys })).not.toThrow() + }) + + it('rejects a signature over a different tarball digest', () => { + // The attack the signature exists to stop: a mirror serving the right + // version with someone else's bytes. The digest is inside the signed + // message, so a substituted one cannot carry the old signature. + const signature = { keyid, sig: sign(`${name}@${version}:sha512-BBBB==`) } + expect(() => verifyRegistrySignature({ name, version, integrity, signature, keys })) + .toThrow(/does not verify/) + }) + + it('rejects a key the registry never published', () => { + const signature = { keyid: 'SHA256:unknown', sig: sign(`${name}@${version}:${integrity}`) } + expect(() => verifyRegistrySignature({ name, version, integrity, signature, keys })) + .toThrow(/no published key/) + }) +}) diff --git a/packages/cli/test/release-scripts.d.ts b/packages/cli/test/release-scripts.d.ts new file mode 100644 index 0000000..694c748 --- /dev/null +++ b/packages/cli/test/release-scripts.d.ts @@ -0,0 +1,35 @@ +/** + * Types for the release scripts the tests drive. + * + * `packages/cli/scripts/*.mjs` run under plain node with no build step and are + * deliberately outside the TypeScript program. These declarations exist only so + * the tests that exercise them are type-checked like the rest of the suite. + */ +declare module '*/verify-grammar-packs.mjs' { + export function verifyGrammarPacks(options?: { + grammarDir?: string + moduleDir?: string + pinPath?: string + lockfilePath?: string + }): Promise }>> +} + +declare module '*/lockfile-integrity.mjs' { + export function lockfileEntries(lockfile: string, packageName: string): Array<{ version: string; integrity: string }> + export function lockedSourcePackage( + lockfile: string, + packageName: string, + expectedVersion: string, + ): { version: string; integrity: string } +} + +declare module '*/npm-tarball.mjs' { + export function readTarballMembers(tarball: Uint8Array): Map + export function verifyRegistrySignature(input: { + name: string + version: string + integrity: string + signature: { keyid: string; sig: string } + keys: Array<{ keyid: string; keytype: string; scheme: string; key: string }> + }): void +} diff --git a/packages/cli/test/suppression.test.ts b/packages/cli/test/suppression.test.ts new file mode 100644 index 0000000..71c8dc3 --- /dev/null +++ b/packages/cli/test/suppression.test.ts @@ -0,0 +1,224 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { AnalyzerFinding, RepoIndex } from '@codetruss/analyzer-engine' +import { analyzerReceipt, computeVerdict } from '../src/analysis.js' +import { createSyncEnvelope, newSessionId, renderMarkdown, verifyReceipt, writeReceipt } from '../src/receipt.js' +import { LOCAL_ANALYSIS_PROFILE, type ChangedFile, 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 }) + +function finding(overrides: Partial = {}): AnalyzerFinding { + return { + analyzerId: 'secrets', + category: 'SECURITY_HYGIENE', + severity: 'HIGH', + title: 'Possible Database URL with credentials committed in db.ts', + description: 'Line 4 of src/db.ts appears to contain a credential.', + filePath: 'src/db.ts', + line: 4, + impactScore: 95, + ...overrides, + } +} + +function dismissed(reason: string, overrides: Partial = {}): AnalyzerFinding { + return finding({ ...overrides, suppression: { reason, markerLine: 4, applied: true } }) +} + +const changedFile: ChangedFile = { + path: 'src/db.ts', change: 'modified', classification: 'allowed', dependency: false, additions: 1, deletions: 0, +} + +function verdictInput(findings: AnalyzerFinding[]) { + return { verifications: [], files: [changedFile], startDirty: false, findings } +} + +function analysis(findings: AnalyzerFinding[]) { + return { + findings, + passes: [{ id: 'secrets', result: { findings, complete: true } }], + index: { totalLoc: 10, languages: { TypeScript: 10 }, primaryLanguage: 'TypeScript' } as unknown as RepoIndex, + } +} + +function receiptFixture(root: string, patch = 'diff evidence'): Receipt { + const now = new Date('2026-08-06T09:00:00.123Z') + return { + receiptVersion: 1, sessionId: newSessionId(now), createdAt: now.toISOString(), finishedAt: now.toISOString(), durationMs: 0, + mode: 'review', task: 'suppression receipt', repoRoot: root, 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: [changedFile], + diff: { + sha256: createHash('sha256').update(patch).digest('hex'), + bytes: Buffer.byteLength(patch), + totalBytes: Buffer.byteLength(patch), + truncated: false, + }, + analyzers: { passes: [], findings: [], analysisProfile: LOCAL_ANALYSIS_PROFILE, index: { totalLoc: 0, languages: {}, primaryLanguage: null } }, + verifications: [], coverageNotes: ['local'], verdict: 'PASS', reasons: ['no changes'], evidence: {}, + } +} + +describe('a dismissed finding stops gating', () => { + it('lets a verdict pass over a dismissed high-severity security finding, and says so', () => { + const outcome = computeVerdict(verdictInput([dismissed('rotated; kept until the migration window closes')])) + + expect(outcome.verdict).toBe('PASS') + expect(outcome.reasons).toContain( + '1 finding(s) on changed files were dismissed by an inline codetruss-ignore comment and are listed with their reasons on this receipt', + ) + }) + + it('still fails on the identical finding when nothing dismissed it', () => { + const outcome = computeVerdict(verdictInput([finding()])) + + expect(outcome.verdict).toBe('FAILED') + expect(outcome.reasons[0]).toContain('high/critical security or dependency finding') + }) + + it('still fails when the marker gave no reason, because that dismisses nothing', () => { + const outcome = computeVerdict(verdictInput([ + finding({ suppression: { reason: '', markerLine: 4, applied: false } }), + ])) + + expect(outcome.verdict).toBe('FAILED') + expect(outcome.reasons.join(' ')).not.toContain('dismissed by an inline') + }) + + it('does not count a dismissed medium finding toward review either', () => { + const outcome = computeVerdict(verdictInput([ + dismissed('duplicate of the handler above, intentional', { severity: 'MEDIUM', category: 'TECH_DEBT', impactScore: 40 }), + ])) + + expect(outcome.verdict).toBe('PASS') + }) +}) + +describe('the receipt records what was dismissed', () => { + it('splits the delta into reported and dismissed, and reports dismissals repository-wide', () => { + const reported = finding({ line: 9, title: 'Possible AWS access key committed in aws.ts' }) + const inDelta = dismissed('fixture credential for the local compose stack') + const elsewhere = dismissed('vendored sample kept verbatim', { filePath: 'vendor/sample.ts', line: 2 }) + const rejected = finding({ filePath: 'src/api.ts', line: 12, suppression: { reason: '', markerLine: 11, applied: false } }) + + const envelope = analyzerReceipt( + analysis([reported, inDelta, elsewhere, rejected]), + undefined, + { introduced: [reported, inDelta, rejected], worsened: [], recurring: [elsewhere], resolved: [] }, + ) + + expect(envelope.findings).toEqual([reported, rejected]) + expect(envelope.suppressed).toEqual([inDelta, elsewhere]) + expect(envelope.rejectedSuppressions).toEqual(['src/api.ts:11']) + }) + + it('omits both fields entirely when the repository dismissed nothing', () => { + const envelope = analyzerReceipt(analysis([finding()]), undefined, { + introduced: [finding()], worsened: [], recurring: [], resolved: [], + }) + + expect(envelope).not.toHaveProperty('suppressed') + expect(envelope).not.toHaveProperty('rejectedSuppressions') + }) + + it('names the finding, its location, and the exact reason its author gave', () => { + const receipt = receiptFixture('/tmp/repo') + receipt.analyzers.suppressed = [dismissed('compose-only default | never deployed')] + + const markdown = renderMarkdown(receipt) + + expect(markdown).toContain('## Suppressed findings (1)') + expect(markdown).toContain('This list covers the whole repository, not only the changed files.') + // The pipe in the reason is escaped, so one comment cannot break the table. + expect(markdown).toContain( + '| HIGH | secrets | `src/db.ts:4` | Possible Database URL with credentials committed in db.ts | compose-only default \\| never deployed |', + ) + }) + + it('explains a marker that dismissed nothing rather than letting it fail in silence', () => { + const receipt = receiptFixture('/tmp/repo') + receipt.analyzers.rejectedSuppressions = ['src/api.ts:11'] + + const markdown = renderMarkdown(receipt) + + expect(markdown).toContain('## Suppressed findings (0)') + expect(markdown).toContain('Nothing was dismissed in this repository.') + expect(markdown).toContain('1 `codetruss-ignore` marker(s) gave no reason and therefore dismissed nothing: `src/api.ts:11`') + expect(markdown).toContain('Those findings are still reported above.') + }) + + it('renders a receipt that dismissed nothing exactly as before, so earlier signatures keep verifying', () => { + const receipt = receiptFixture('/tmp/repo') + receipt.analyzers.findings = [finding()] + + expect(renderMarkdown(receipt)).not.toContain('Suppressed findings') + // A receipt whose signed JSON never knew about the field renders the same + // bytes as one that knows about it and has nothing to report. + const aware = structuredClone(receipt) + aware.analyzers.suppressed = [] + aware.analyzers.rejectedSuppressions = [] + expect(renderMarkdown(aware)).toBe(renderMarkdown(receipt)) + }) + + it('signs and verifies a receipt carrying dismissals', async () => { + const root = await mkdtemp(join(tmpdir(), 'codetruss-suppression-')) + const dir = join(root, 'receipts') + process.env.CODETRUSS_SIGNING_KEY = join(root, 'signing.pem') + const receipt = receiptFixture(root) + receipt.analyzers.suppressed = [dismissed('reviewed 2026-08-06, dev-only container')] + receipt.analyzers.rejectedSuppressions = ['src/api.ts:11'] + + const paths = await writeReceipt(dir, receipt, 'diff evidence') + const verified = await verifyReceipt(dir, receipt.sessionId) + const markdown = await readFile(paths.markdown, 'utf8') + + expect(verified.analyzers.suppressed).toHaveLength(1) + expect(markdown).toContain('reviewed 2026-08-06, dev-only container') + }) +}) + +describe('sync keeps dismissals private-safe', () => { + it('drops unrelated dismissals, redacts private paths from a reason, and keeps the related one', async () => { + const root = await mkdtemp(join(tmpdir(), 'codetruss-suppression-sync-')) + const dir = join(root, 'receipts') + process.env.CODETRUSS_SIGNING_KEY = join(root, 'signing.pem') + const receipt = receiptFixture(root, 'private patch') + receipt.analyzers.suppressed = [ + dismissed('mirrors the constant in private/unrelated.ts', { metadata: { otherFile: 'private/unrelated.ts' } }), + dismissed('whole-repo dismissal that must not sync', { filePath: 'private/unrelated.ts', line: 7 }), + ] + receipt.analyzers.rejectedSuppressions = ['src/db.ts:3', 'private/unrelated.ts:6'] + await writeReceipt(dir, receipt, 'private patch') + + const envelope = await createSyncEnvelope(receipt) + const synced = JSON.parse(envelope.signedReceipt) as Receipt + + expect(synced.analyzers.suppressed).toHaveLength(1) + expect(synced.analyzers.suppressed?.[0].filePath).toBe('src/db.ts') + expect(synced.analyzers.suppressed?.[0].suppression?.reason).toBe('mirrors the constant in [redacted unrelated path]') + expect(synced.analyzers.suppressed?.[0]).not.toHaveProperty('metadata') + expect(synced.analyzers.rejectedSuppressions).toEqual(['src/db.ts:3']) + expect(envelope.signedReceipt).not.toContain('private/unrelated.ts') + }) + + it('removes the fields entirely when nothing dismissed relates to a synced path', async () => { + const root = await mkdtemp(join(tmpdir(), 'codetruss-suppression-sync-empty-')) + const dir = join(root, 'receipts') + process.env.CODETRUSS_SIGNING_KEY = join(root, 'signing.pem') + const receipt = receiptFixture(root) + receipt.analyzers.suppressed = [dismissed('unrelated', { filePath: 'private/unrelated.ts', line: 7 })] + receipt.analyzers.rejectedSuppressions = ['private/unrelated.ts:6'] + await writeReceipt(dir, receipt, 'diff evidence') + + const synced = JSON.parse((await createSyncEnvelope(receipt)).signedReceipt) as Receipt + + expect(synced.analyzers).not.toHaveProperty('suppressed') + expect(synced.analyzers).not.toHaveProperty('rejectedSuppressions') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24575c5..bd94cd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,13 @@ settings: importers: .: + dependencies: + tree-sitter-wasms: + specifier: 0.1.11 + version: 0.1.11 + web-tree-sitter: + specifier: 0.22.6 + version: 0.22.6 devDependencies: '@codetruss/analyzer-engine': specifier: workspace:* @@ -557,6 +564,9 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tree-sitter-wasms@0.1.11: + resolution: {integrity: sha512-26sE4+qoTi1CbzHdo9sHs9pRE/jXVFVRigSG/5TNAbwhSMVjHfMAg4UjmOhAFAIx5UxgoQuaURwqhm0SRNrpWA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -657,6 +667,9 @@ packages: jsdom: optional: true + web-tree-sitter@0.22.6: + resolution: {integrity: sha512-hS87TH71Zd6mGAmYCvlgxeGDjqd9GTeqXNqTT+u0Gs51uIozNIaaq/kUAbV/Zf56jb2ZOyG8BxZs2GG9wbLi6Q==} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1057,6 +1070,8 @@ snapshots: tinyrainbow@3.1.0: {} + tree-sitter-wasms@0.1.11: {} + tslib@2.8.1: optional: true @@ -1111,6 +1126,8 @@ snapshots: transitivePeerDependencies: - msw + web-tree-sitter@0.22.6: {} + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 diff --git a/public/downloads/codetruss-cli-0.2.41.sbom.cdx.json b/public/downloads/codetruss-cli-0.2.41.sbom.cdx.json new file mode 100644 index 0000000..6faedbb --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.41.sbom.cdx.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "serialNumber": "urn:uuid:c69a8bce-8d55-58bb-b05e-2933e91d1b21", + "specVersion": "1.6", + "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.41", + "name": "@codetruss/cli", + "version": "0.2.41", + "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.41" + }, + "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.41", + "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.41.tgz b/public/downloads/codetruss-cli-0.2.41.tgz new file mode 100644 index 0000000..5370c27 Binary files /dev/null and b/public/downloads/codetruss-cli-0.2.41.tgz differ diff --git a/public/downloads/codetruss-cli-0.2.41.tgz.sha256 b/public/downloads/codetruss-cli-0.2.41.tgz.sha256 new file mode 100644 index 0000000..5db4f9e --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.41.tgz.sha256 @@ -0,0 +1 @@ +cb0b9d69f15e7113a6a523a6b139ec3150b7d07459e5242b2c047fc9aac57d04 codetruss-cli-0.2.41.tgz diff --git a/public/downloads/codetruss-cli-latest.json b/public/downloads/codetruss-cli-latest.json index ffc0697..c8cf0a6 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.40", - "url": "/downloads/codetruss-cli-0.2.40.tgz", + "version": "0.2.41", + "url": "/downloads/codetruss-cli-0.2.41.tgz", "latestUrl": "/downloads/codetruss-cli-latest.tgz", - "sha256": "5d64313b8b60acbd1f93e2246557967885a98fdc8c486ea7b2a6417fd8acdac2", - "sbomUrl": "/downloads/codetruss-cli-0.2.40.sbom.cdx.json", - "sbomSha256": "a78a5a08993e2266cee606f892d2afe118880605dd911d3f2149f99e4e8001bd", + "sha256": "cb0b9d69f15e7113a6a523a6b139ec3150b7d07459e5242b2c047fc9aac57d04", + "sbomUrl": "/downloads/codetruss-cli-0.2.41.sbom.cdx.json", + "sbomSha256": "9cad0195baee5d1655dab3410ec350f6198be193722badf1cccdd8663c296a4a", "node": ">=20.9.0", - "repository": "https://github.com/DeliriumPulse/codetruss-cli", - "releaseUrl": "https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.40", - "attestationCommand": "gh attestation verify codetruss-cli-0.2.40.tgz --repo DeliriumPulse/codetruss-cli" + "repository": "https://github.com/CodeTruss/codetruss-cli", + "releaseUrl": "https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.41", + "attestationCommand": "gh attestation verify codetruss-cli-0.2.41.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 fe75706..6faedbb 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:87fe717b-5581-5c87-9d3e-571c96b13a30", + "serialNumber": "urn:uuid:c69a8bce-8d55-58bb-b05e-2933e91d1b21", "specVersion": "1.6", "version": 1, "metadata": { "component": { "type": "application", - "bom-ref": "pkg:npm/%40codetruss/cli@0.2.40", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.41", "name": "@codetruss/cli", - "version": "0.2.40", + "version": "0.2.41", "description": "Local-first scope, quality, and verification receipts for coding agents", "licenses": [ { @@ -18,7 +18,7 @@ } } ], - "purl": "pkg:npm/%40codetruss/cli@0.2.40" + "purl": "pkg:npm/%40codetruss/cli@0.2.41" }, "properties": [ { @@ -139,7 +139,7 @@ "dependsOn": [] }, { - "ref": "pkg:npm/%40codetruss/cli@0.2.40", + "ref": "pkg:npm/%40codetruss/cli@0.2.41", "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 4b5b0c6..5370c27 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 a8db5aa..a068e29 100644 --- a/public/downloads/codetruss-cli-latest.tgz.sha256 +++ b/public/downloads/codetruss-cli-latest.tgz.sha256 @@ -1 +1 @@ -5d64313b8b60acbd1f93e2246557967885a98fdc8c486ea7b2a6417fd8acdac2 codetruss-cli-latest.tgz +cb0b9d69f15e7113a6a523a6b139ec3150b7d07459e5242b2c047fc9aac57d04 codetruss-cli-latest.tgz diff --git a/release-reference.json b/release-reference.json index 17042be..dee2195 100644 --- a/release-reference.json +++ b/release-reference.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "version": "0.2.40", - "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.40.tgz", - "archiveSha256": "5d64313b8b60acbd1f93e2246557967885a98fdc8c486ea7b2a6417fd8acdac2", - "sbomSha256": "a78a5a08993e2266cee606f892d2afe118880605dd911d3f2149f99e4e8001bd", - "bundleSha256": "7dcf9a22457c6790c08b2ddc1186f1c73213e58ad6920aa7f164c8ce2d3e9076" + "version": "0.2.41", + "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.41.tgz", + "archiveSha256": "cb0b9d69f15e7113a6a523a6b139ec3150b7d07459e5242b2c047fc9aac57d04", + "sbomSha256": "9cad0195baee5d1655dab3410ec350f6198be193722badf1cccdd8663c296a4a", + "bundleSha256": "6cbc571a3f6a7108dd46e232e79c753b00abdbc6d6ff6798f92728d7e6ac91a7" }