Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 85 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
CodeTruss CLI follows semantic versioning. Release artifacts and their SHA-256
checksums are published at <https://codetruss.com/downloads/codetruss-cli-latest.json>.

The current public release is [v0.2.39 on GitHub](https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.39),
The current public release is [v0.2.40 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.40),
distributed from <https://codetruss.com/downloads/codetruss-cli-latest.json>.
The npm `latest` tag is still
[`@codetruss/cli@0.2.24`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.24):
Expand All @@ -16,6 +16,90 @@ were superseded before distribution.

No unreleased changes.

## 0.2.40 — 2026-08-07

- **Python can now be analyzed locally, if you ask for it.** `codetruss
grammars install python` downloads the `web-tree-sitter` runtime and the
compiled Python grammar (722 KB) into your data directory — XDG on macOS and
Linux, `LOCALAPPDATA` on Windows. Nothing is bundled in the tarball, nothing
is fetched during an analysis, and no other command installs it for you. The
CLI ships a hand-written JavaScript parser precisely because these grammars
are several times its entire release budget, and that trade is unchanged for
anyone who does not run this command. `codetruss grammars list|status|
uninstall` round out the group; `status` exits non-zero when a pack is
missing or fails verification, so it can gate a setup script.
- **The pack is pinned, verified as it arrives, and verified again every time
it is loaded.** Each artifact's SHA-256 is compiled into the CLI at build
time. The download is hashed as it streams, with the pinned length enforced
mid-stream so a wrong or hostile origin cannot write an unbounded file to
disk; artifacts land in a scratch directory and are moved into place only
after every one of them verifies, so a pack directory is never half-installed.
The only download origin is `codetruss.com` — no third-party CDN, and
redirects are refused. Hashing is streamed in-process, never shelled out to
`shasum` or `Get-FileHash`. **Every** failure — absent, truncated, over-long,
wrong digest, unreadable, or an unexpected extra file in the pack directory —
resolves to "pack unavailable", and the run reports Python as skipped. There
is no path on which unverified bytes are executed.
- **Python runs the complete rule pack, not the reduced JavaScript subset.**
That subset exists because a hand-written parser might disagree with
tree-sitter, and only rules proven to agree were admitted. A grammar pack *is*
the hosted parser and the hosted grammar, so there is no divergence to guard
against — and narrowing it would report less than the same code receives in a
hosted scan, for no gain in precision. Command injection, path traversal,
SSRF and insecure deserialization are checked in Python locally; they remain
unchecked in JavaScript, TypeScript and TSX, and the receipt keeps saying so.
- **Verified against the hosted path over 233 real Python files** — the
full-stack FastAPI template, three further repositories, and a synthetic
fixture covering each rule class. Both parsers produced the same 11 findings,
with **zero divergence in either direction**.
- **Receipts move to the `local-registry-v4` profile, which states what the run
actually did about Python.** The pass set is unchanged from v3; the wording
had to change, because v3 says flatly that the local pass covers "JavaScript,
TypeScript and TSX only" and that Python received no security analysis, and
that is false whenever a pack is installed. There are now three
distinguishable statements instead of one frozen sentence: **absent** names
the Python file count and the command that would cover them, **verified**
names the rule pack and the file count while keeping the JavaScript subset's
limits scoped to JavaScript, and a **failed** pack now says *which* kind of
failure it was — a digest mismatch (the pack does not match what this CLI
published, so reinstall), a runtime that would not start on this machine even
though the digests matched, or a scan that threw partway and had its partial
results discarded. Only a real digest mismatch renders the tampering sentence;
an out-of-memory error no longer accuses your install of not matching the
published digests. Every failure branch closes with the provable "No findings
from this pack were reported" in place of the wider absolute claim.
`local-registry-v3` keeps a frozen renderer, so receipts signed by 0.2.39
still verify byte-for-byte.
- **The bytes that are verified are now the exact bytes that execute.** The
loader used to hash each artifact by path and then re-open the same path to
`require()` it, so the file that was hashed and the file that ran were two
separate reads with a window between them — three digests and a directory
listing wide enough for another process with write access to the pack
directory to swap a hostile `tree-sitter.js` in after the check and have it
executed. `inspectGrammarPack` now reads each artifact once and returns the
buffer it hashed; the runtime is compiled from that buffer and the two WASM
artifacts are handed to `web-tree-sitter` as in-memory `Uint8Array`s
(`wasmBinary` and `Language.load`), so nothing is ever resolved from a path a
second time. Artifacts are opened `O_NOFOLLOW` and rejected unless they are
regular files; a symlinked pack root, a pack root not owned by the current
user, or one writable by group or other is refused, and a loose root created
by an earlier CLI is tightened to `0700` on install. A local same-user race
that reliably executed attacker code against the previous loader now fails
every attempt.
- **Fixed: Python was silently dropped from the second half of every review.**
The tree-sitter runtime reassigns its own entry in Node's module cache while
initializing, so loading it a second time in one process returned the wrong
object. A review analyzes twice — once for the baseline tree, once for the
final tree — which meant the final analysis quietly failed to load the grammar
and reported Python as unanalyzable even with a healthy pack installed. The
runtime is now loaded once per process. Digests are still re-checked on every
load; only the runtime construction is reused.
- **Fixed: the Windows data directory was resolved with POSIX path rules.**
`LOCALAPPDATA` was checked with a path test that treats `C:\Users\…` as
relative anywhere other than Windows, which made the branch correct on Windows
and unverifiable everywhere else. It now names the Windows path flavour
explicitly, and is covered by a test that runs on every platform.

## 0.2.39 — 2026-08-07

- **Two analyzers join the registry, which now holds 15.** Both come from a
Expand Down
84 changes: 84 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,90 @@ checksums are published at <https://codetruss.com/downloads/codetruss-cli-latest

## Unreleased

## 0.2.40 — 2026-08-07

- **Python can now be analyzed locally, if you ask for it.** `codetruss
grammars install python` downloads the `web-tree-sitter` runtime and the
compiled Python grammar (722 KB) into your data directory — XDG on macOS and
Linux, `LOCALAPPDATA` on Windows. Nothing is bundled in the tarball, nothing
is fetched during an analysis, and no other command installs it for you. The
CLI ships a hand-written JavaScript parser precisely because these grammars
are several times its entire release budget, and that trade is unchanged for
anyone who does not run this command. `codetruss grammars list|status|
uninstall` round out the group; `status` exits non-zero when a pack is
missing or fails verification, so it can gate a setup script.
- **The pack is pinned, verified as it arrives, and verified again every time
it is loaded.** Each artifact's SHA-256 is compiled into the CLI at build
time. The download is hashed as it streams, with the pinned length enforced
mid-stream so a wrong or hostile origin cannot write an unbounded file to
disk; artifacts land in a scratch directory and are moved into place only
after every one of them verifies, so a pack directory is never half-installed.
The only download origin is `codetruss.com` — no third-party CDN, and
redirects are refused. Hashing is streamed in-process, never shelled out to
`shasum` or `Get-FileHash`. **Every** failure — absent, truncated, over-long,
wrong digest, unreadable, or an unexpected extra file in the pack directory —
resolves to "pack unavailable", and the run reports Python as skipped. There
is no path on which unverified bytes are executed.
- **Python runs the complete rule pack, not the reduced JavaScript subset.**
That subset exists because a hand-written parser might disagree with
tree-sitter, and only rules proven to agree were admitted. A grammar pack *is*
the hosted parser and the hosted grammar, so there is no divergence to guard
against — and narrowing it would report less than the same code receives in a
hosted scan, for no gain in precision. Command injection, path traversal,
SSRF and insecure deserialization are checked in Python locally; they remain
unchecked in JavaScript, TypeScript and TSX, and the receipt keeps saying so.
- **Verified against the hosted path over 233 real Python files** — the
full-stack FastAPI template, three further repositories, and a synthetic
fixture covering each rule class. Both parsers produced the same 11 findings,
with **zero divergence in either direction**.
- **Receipts move to the `local-registry-v4` profile, which states what the run
actually did about Python.** The pass set is unchanged from v3; the wording
had to change, because v3 says flatly that the local pass covers "JavaScript,
TypeScript and TSX only" and that Python received no security analysis, and
that is false whenever a pack is installed. There are now three
distinguishable statements instead of one frozen sentence: **absent** names
the Python file count and the command that would cover them, **verified**
names the rule pack and the file count while keeping the JavaScript subset's
limits scoped to JavaScript, and a **failed** pack now says *which* kind of
failure it was — a digest mismatch (the pack does not match what this CLI
published, so reinstall), a runtime that would not start on this machine even
though the digests matched, or a scan that threw partway and had its partial
results discarded. Only a real digest mismatch renders the tampering sentence;
an out-of-memory error no longer accuses your install of not matching the
published digests. Every failure branch closes with the provable "No findings
from this pack were reported" in place of the wider absolute claim.
`local-registry-v3` keeps a frozen renderer, so receipts signed by 0.2.39
still verify byte-for-byte.
- **The bytes that are verified are now the exact bytes that execute.** The
loader used to hash each artifact by path and then re-open the same path to
`require()` it, so the file that was hashed and the file that ran were two
separate reads with a window between them — three digests and a directory
listing wide enough for another process with write access to the pack
directory to swap a hostile `tree-sitter.js` in after the check and have it
executed. `inspectGrammarPack` now reads each artifact once and returns the
buffer it hashed; the runtime is compiled from that buffer and the two WASM
artifacts are handed to `web-tree-sitter` as in-memory `Uint8Array`s
(`wasmBinary` and `Language.load`), so nothing is ever resolved from a path a
second time. Artifacts are opened `O_NOFOLLOW` and rejected unless they are
regular files; a symlinked pack root, a pack root not owned by the current
user, or one writable by group or other is refused, and a loose root created
by an earlier CLI is tightened to `0700` on install. A local same-user race
that reliably executed attacker code against the previous loader now fails
every attempt.
- **Fixed: Python was silently dropped from the second half of every review.**
The tree-sitter runtime reassigns its own entry in Node's module cache while
initializing, so loading it a second time in one process returned the wrong
object. A review analyzes twice — once for the baseline tree, once for the
final tree — which meant the final analysis quietly failed to load the grammar
and reported Python as unanalyzable even with a healthy pack installed. The
runtime is now loaded once per process. Digests are still re-checked on every
load; only the runtime construction is reused.
- **Fixed: the Windows data directory was resolved with POSIX path rules.**
`LOCALAPPDATA` was checked with a path test that treats `C:\Users\…` as
relative anywhere other than Windows, which made the branch correct on Windows
and unverifiable everywhere else. It now names the Windows path flavour
explicitly, and is covered by a test that runs on every platform.

## 0.2.39 — 2026-08-07

- **Two analyzers join the registry, which now holds 15.** Both come from a
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codetruss/cli",
"version": "0.2.39",
"version": "0.2.40",
"description": "Local-first scope, quality, and verification receipts for coding agents",
"license": "SEE LICENSE IN LICENSE",
"type": "module",
Expand Down Expand Up @@ -42,6 +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",
"verify:grammars": "node scripts/verify-grammar-packs.mjs",
"test:install": "node scripts/test-install.mjs",
"prepack": "pnpm build",
"pretest": "pnpm build",
Expand Down
150 changes: 150 additions & 0 deletions packages/cli/scripts/build-grammar-packs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Publish the opt-in tree-sitter grammar packs, and pin their digests into the CLI.
*
* Same discipline as `build-release.mjs`: a versioned artifact is written EXACTLY
* once and never replaced, its sha256 is published beside it, and a manifest
* records what the site is allowed to advertise. The difference is the consumer —
* a CLI tarball is verified by a human running `shasum`, whereas a grammar pack
* is verified by the CLI itself, on download and again on every load, against a
* digest compiled into the binary. That pin is generated here
* (`src/grammar-pack-manifest.ts`) so the published bytes and the expected bytes
* cannot drift apart without this script being re-run.
*/
import { createHash } from 'node:crypto'
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
GRAMMAR_PACKS,
GRAMMAR_PACK_PROVENANCE,
GRAMMAR_PACK_VERSION,
packDirectoryName,
packFileUrl,
} from './grammar-pack-sources.mjs'

const scriptDir = dirname(fileURLToPath(import.meta.url))
const packageDir = resolve(scriptDir, '..')
const repoRoot = resolve(packageDir, '../..')
const grammarDir = join(repoRoot, 'public', 'downloads', 'grammars')
const moduleDir = join(repoRoot, 'node_modules')

function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}

/**
* Write a versioned artifact once, or prove the existing one already matches.
*
* A published pack file is immutable. Re-running the build is a no-op when the
* bytes agree and a hard error when they do not, because a CLI already in the
* field pins the old digest and would fail closed against replaced bytes.
*/
async function publishImmutable(path, bytes, label) {
let published
try {
published = await readFile(path)
} catch (error) {
if (error.code !== 'ENOENT') throw error
await writeFile(path, bytes)
return
}
if (!published.equals(bytes)) {
throw new Error(
`refusing to replace immutable ${label}: existing ${sha256(published)}, new ${sha256(bytes)}; `
+ 'bump GRAMMAR_PACK_VERSION in scripts/grammar-pack-sources.mjs',
)
}
}

const manifestPacks = []

for (const pack of GRAMMAR_PACKS) {
const directoryName = packDirectoryName(pack)
const packDir = join(grammarDir, directoryName)
await mkdir(packDir, { recursive: true })

const files = []
for (const file of pack.files) {
const bytes = await readFile(join(moduleDir, ...file.source))
const digest = sha256(bytes)
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 })
}

// A stray file in a published pack directory is a supply-chain question, not
// a tidiness one: the CLI installs whatever the manifest lists, but a reviewer
// diffing the directory must not find bytes nobody accounted for.
const present = (await readdir(packDir)).filter((name) => !name.endsWith('.sha256')).sort()
const expected = pack.files.map((file) => file.name).sort()
if (present.join('\n') !== expected.join('\n')) {
throw new Error(`${directoryName} holds unexpected files: ${present.join(', ')}`)
}

manifestPacks.push({
name: pack.name,
version: GRAMMAR_PACK_VERSION,
language: pack.language,
runtime: GRAMMAR_PACK_PROVENANCE.runtime,
grammar: GRAMMAR_PACK_PROVENANCE.grammar,
files,
})
}

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')

for (const pack of manifestPacks) {
const total = pack.files.reduce((sum, file) => sum + file.bytes, 0)
process.stdout.write(`grammar pack ${pack.name}-${pack.version}: ${pack.files.length} files, ${total} bytes\n`)
}
Loading