Skip to content

Commit de5d398

Browse files
DeliriumPulseclaude
andcommitted
Release CodeTruss CLI v0.2.40
Syncs the 0.2.40 source from the private monorepo. This sync lands after 0.2.40 was already distributed from the website: the original release train halted on a Windows-only test failure, and the tag, GitHub release, and attestation were never created. The published archive has therefore had no verifiable provenance since it went up, which is the one thing this project promises about every artifact it ships. The content here is 0.2.40 as published. A local rebuild of this tree reproduces the distributed archive byte for byte: codetruss-cli-0.2.40.tgz sha256 5d64313b8b60acbd1f93e2246557967885a98fdc8c486ea7b2a6417fd8acdac2 That is the digest the website serves and the digest recorded in release-reference.json, so the release workflow can rebuild it, assert the match, and attest bytes that were genuinely reproduced rather than asserted. The Windows test isolation fix is included. It touches only files under packages/cli/test, which the package `files` allowlist excludes, so it cannot and does not change the released archive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d79234c commit de5d398

36 files changed

Lines changed: 2909 additions & 83 deletions

CHANGELOG.md

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
CodeTruss CLI follows semantic versioning. Release artifacts and their SHA-256
44
checksums are published at <https://codetruss.com/downloads/codetruss-cli-latest.json>.
55

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

1717
No unreleased changes.
1818

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

21105
- **Two analyzers join the registry, which now holds 15.** Both come from a

packages/cli/CHANGELOG.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,90 @@ checksums are published at <https://codetruss.com/downloads/codetruss-cli-latest
55

66
## Unreleased
77

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

1094
- **Two analyzers join the registry, which now holds 15.** Both come from a

packages/cli/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@codetruss/cli",
3-
"version": "0.2.39",
3+
"version": "0.2.40",
44
"description": "Local-first scope, quality, and verification receipts for coding agents",
55
"license": "SEE LICENSE IN LICENSE",
66
"type": "module",
@@ -42,6 +42,8 @@
4242
"build": "node scripts/build.mjs",
4343
"release:artifact": "node scripts/build-release.mjs",
4444
"verify:artifact": "node scripts/verify-release.mjs",
45+
"release:grammars": "node scripts/build-grammar-packs.mjs",
46+
"verify:grammars": "node scripts/verify-grammar-packs.mjs",
4547
"test:install": "node scripts/test-install.mjs",
4648
"prepack": "pnpm build",
4749
"pretest": "pnpm build",
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* Publish the opt-in tree-sitter grammar packs, and pin their digests into the CLI.
3+
*
4+
* Same discipline as `build-release.mjs`: a versioned artifact is written EXACTLY
5+
* once and never replaced, its sha256 is published beside it, and a manifest
6+
* records what the site is allowed to advertise. The difference is the consumer —
7+
* a CLI tarball is verified by a human running `shasum`, whereas a grammar pack
8+
* is verified by the CLI itself, on download and again on every load, against a
9+
* digest compiled into the binary. That pin is generated here
10+
* (`src/grammar-pack-manifest.ts`) so the published bytes and the expected bytes
11+
* cannot drift apart without this script being re-run.
12+
*/
13+
import { createHash } from 'node:crypto'
14+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
15+
import { dirname, join, resolve } from 'node:path'
16+
import { fileURLToPath } from 'node:url'
17+
import {
18+
GRAMMAR_PACKS,
19+
GRAMMAR_PACK_PROVENANCE,
20+
GRAMMAR_PACK_VERSION,
21+
packDirectoryName,
22+
packFileUrl,
23+
} from './grammar-pack-sources.mjs'
24+
25+
const scriptDir = dirname(fileURLToPath(import.meta.url))
26+
const packageDir = resolve(scriptDir, '..')
27+
const repoRoot = resolve(packageDir, '../..')
28+
const grammarDir = join(repoRoot, 'public', 'downloads', 'grammars')
29+
const moduleDir = join(repoRoot, 'node_modules')
30+
31+
function sha256(bytes) {
32+
return createHash('sha256').update(bytes).digest('hex')
33+
}
34+
35+
/**
36+
* Write a versioned artifact once, or prove the existing one already matches.
37+
*
38+
* A published pack file is immutable. Re-running the build is a no-op when the
39+
* bytes agree and a hard error when they do not, because a CLI already in the
40+
* field pins the old digest and would fail closed against replaced bytes.
41+
*/
42+
async function publishImmutable(path, bytes, label) {
43+
let published
44+
try {
45+
published = await readFile(path)
46+
} catch (error) {
47+
if (error.code !== 'ENOENT') throw error
48+
await writeFile(path, bytes)
49+
return
50+
}
51+
if (!published.equals(bytes)) {
52+
throw new Error(
53+
`refusing to replace immutable ${label}: existing ${sha256(published)}, new ${sha256(bytes)}; `
54+
+ 'bump GRAMMAR_PACK_VERSION in scripts/grammar-pack-sources.mjs',
55+
)
56+
}
57+
}
58+
59+
const manifestPacks = []
60+
61+
for (const pack of GRAMMAR_PACKS) {
62+
const directoryName = packDirectoryName(pack)
63+
const packDir = join(grammarDir, directoryName)
64+
await mkdir(packDir, { recursive: true })
65+
66+
const files = []
67+
for (const file of pack.files) {
68+
const bytes = await readFile(join(moduleDir, ...file.source))
69+
const digest = sha256(bytes)
70+
const target = join(packDir, file.name)
71+
await publishImmutable(target, bytes, `${directoryName}/${file.name}`)
72+
await writeFile(`${target}.sha256`, `${digest} ${file.name}\n`, 'utf8')
73+
files.push({ name: file.name, url: packFileUrl(pack, file.name), bytes: bytes.length, sha256: digest })
74+
}
75+
76+
// A stray file in a published pack directory is a supply-chain question, not
77+
// a tidiness one: the CLI installs whatever the manifest lists, but a reviewer
78+
// diffing the directory must not find bytes nobody accounted for.
79+
const present = (await readdir(packDir)).filter((name) => !name.endsWith('.sha256')).sort()
80+
const expected = pack.files.map((file) => file.name).sort()
81+
if (present.join('\n') !== expected.join('\n')) {
82+
throw new Error(`${directoryName} holds unexpected files: ${present.join(', ')}`)
83+
}
84+
85+
manifestPacks.push({
86+
name: pack.name,
87+
version: GRAMMAR_PACK_VERSION,
88+
language: pack.language,
89+
runtime: GRAMMAR_PACK_PROVENANCE.runtime,
90+
grammar: GRAMMAR_PACK_PROVENANCE.grammar,
91+
files,
92+
})
93+
}
94+
95+
export const GRAMMAR_MANIFEST_NAME = 'codetruss-grammars-latest.json'
96+
97+
const manifest = `${JSON.stringify({ packs: manifestPacks }, null, 2)}\n`
98+
await writeFile(join(grammarDir, GRAMMAR_MANIFEST_NAME), manifest, 'utf8')
99+
100+
/**
101+
* The compiled-in pin.
102+
*
103+
* Generated rather than hand-maintained because a hand-copied digest is a digest
104+
* that eventually disagrees with the bytes, and this one is the only thing
105+
* standing between a user and executing whatever a compromised origin served.
106+
*/
107+
const pin = `/**
108+
* Pinned grammar-pack digests. GENERATED by scripts/build-grammar-packs.mjs.
109+
*
110+
* Do not edit by hand. These digests are what \`codetruss grammars install\`
111+
* checks a download against, and what every subsequent load re-checks on disk.
112+
* A pack whose bytes do not hash to exactly these values is never loaded, and
113+
* the run discloses Python as skipped instead.
114+
*/
115+
116+
export interface PinnedGrammarFile {
117+
name: string
118+
/** Path under the downloads origin, e.g. \`/downloads/grammars/python-1.0.0/…\`. */
119+
url: string
120+
bytes: number
121+
sha256: string
122+
}
123+
124+
export interface PinnedGrammarPack {
125+
name: string
126+
version: string
127+
/** The \`SastLanguage\` this pack enables. */
128+
language: string
129+
runtime: { package: string; version: string }
130+
grammar: { package: string; version: string }
131+
files: PinnedGrammarFile[]
132+
}
133+
134+
export const PINNED_GRAMMAR_PACKS: readonly PinnedGrammarPack[] = ${JSON.stringify(manifestPacks, null, 2)
135+
.split('\n')
136+
.join('\n')}
137+
138+
/** Pack names this CLI build knows how to install. */
139+
export const GRAMMAR_PACK_NAMES: readonly string[] = PINNED_GRAMMAR_PACKS.map((pack) => pack.name)
140+
141+
export function pinnedGrammarPack(name: string): PinnedGrammarPack | undefined {
142+
return PINNED_GRAMMAR_PACKS.find((pack) => pack.name === name)
143+
}
144+
`
145+
await writeFile(join(packageDir, 'src', 'grammar-pack-manifest.ts'), pin, 'utf8')
146+
147+
for (const pack of manifestPacks) {
148+
const total = pack.files.reduce((sum, file) => sum + file.bytes, 0)
149+
process.stdout.write(`grammar pack ${pack.name}-${pack.version}: ${pack.files.length} files, ${total} bytes\n`)
150+
}

0 commit comments

Comments
 (0)