feat(modules): artifact verification library + helpthread-module CLI (HT-116) - #186
Conversation
…(HT-116)
src/modules/artifact/ is the catalog-neutral verification library the
engine will also call (HT-119): canonical manifest, ed25519 signature
verification with strict base64url decoding, and a tar extractor that
refuses traversal, symlinks, hardlinks, devices, duplicates, and
escapes, under explicit size caps. ONE implementation, so the CLI can
never accept what the engine would reject.
cli/ is a new workspace publishing `helpthread-module`:
install <slug> — resolve from the catalog feed, prompt for a license
key (never logged or persisted), download, verify
against a COMPILED-IN trust store, safely extract,
print env vars split by owner, and hand off the
deploy command rather than running it.
verify <tarball> — fully offline verification against the same trust
store. No marketplace involved; this is the path a
fork keeps.
Marketplace-specific bits (catalog origin, license-key header) live in a
thin adapter under cli/src, never in the library.
Golden conformance vectors pin the REAL published draft-assistant 0.3.0
manifest and signature, proving this independent implementation agrees
byte-for-byte with the marketplace's separate one — a canonicalization
divergence between the two repos becomes a red test, not a customer
incident.
Verified: the offline `verify` command passes against the real GitHub
release assets, and fails with a clear sha256 message when a byte is
flipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… leakage, bounded input Different-vendor pass: 8 findings. Fixed here: CRITICAL — a valid signature proves an artifact is AUTHENTIC, not that it is the one requested. Every first-party release shares a signing key, so a compromised marketplace could answer a request for module A with a genuinely-signed module B and every check passed. install now binds the verified manifest's own `module` and `semver` to what was asked for and served, and refuses otherwise. HIGH — the download request is the one call carrying the license key, and the server fully controls its error body; it could echo the key back into a message the CLI printed. Only a pattern-checked error CODE is taken from the response now; the human-readable text is generated locally. HIGH — gunzipSync allocated the whole archive before any cap applied, so a small highly-compressible archive exhausted memory before refusal. zlib's maxOutputLength now refuses during decompression. A cap enforced after allocation is not a cap. HIGH — the tarball download was unbounded; a hostile signed-URL host could exhaust memory before verification ever ran. Bounded locally, not from Content-Length (which the server also controls). MEDIUM — the destination check used statSync, so a symlinked target passed isDirectory() while containment checks reasoned about the link's path. lstat + explicit refusal. MEDIUM — temp-file cleanup was not in a finally, leaving paid software in the system temp directory on several failure paths. Two pre-existing tests asserted the old behavior and were updated to the new; both changes are improvements, noted in the tests themselves. 1863 tests. Real offline verify against the published 0.3.0 assets still passes end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis change adds the ChangesModule distribution
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
Sequence Diagram(s)sequenceDiagram
participant Operator
participant CLI
participant Marketplace
participant Verifier
participant Extractor
Operator->>CLI: run install
CLI->>Marketplace: resolve module and request download
Marketplace-->>CLI: served version and tarball
CLI->>Verifier: validate digest, size, key, signature, module, and version
Verifier-->>CLI: verified manifest
CLI->>Extractor: safely extract artifact
Extractor-->>CLI: extracted module files
CLI-->>Operator: configuration and deployment instructions
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| }) | ||
|
|
||
| it('rejects a manifest with an added unknown key', () => { | ||
| const withExtra = MANIFEST_JSON.replace('{', '{"unexpected":"field",') |
There was a problem hiding this comment.
Actionable comments posted: 13
🧹 Nitpick comments (11)
tests/modules/artifact/module-config.test.ts (1)
8-9: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueSoften the "byte-for-byte" claim.
The fixture is rebuilt with
JSON.stringifyfrom an object literal. The test therefore asserts the shape and values, not the exact bytes of the shipped file.tests/modules/artifact/vectors.test.tsuses a literal string where byte fidelity matters. Either state that this fixture mirrors the shipped values, or inline the literal JSON text.♻️ Proposed wording
-/** The REAL module.config.json shipped in the draft-assistant 0.3.0 release artifact, byte-for-byte. */ +/** The values of the module.config.json shipped in the draft-assistant 0.3.0 release artifact. Re-serialized here, so this fixture is value-identical, not byte-identical. */🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/modules/artifact/module-config.test.ts` around lines 8 - 9, Update the REAL_DRAFT_ASSISTANT_CONFIG comment to describe that the fixture mirrors the shipped configuration’s values and structure, rather than claiming byte-for-byte identity; keep the existing JSON.stringify-based fixture unchanged.tests/modules/artifact/vectors.test.ts (2)
58-64: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAlign the comment with the mutation the test performs.
The comment states that the final character changes from
9to8. The code removes"}and appends8"}, sosourceRevisionbecomes23230a98. The assertion still holds, but the comment describes different bytes than the test uses.♻️ Proposed comment or code alignment
- // Flip the final character of sourceRevision from '9' to '8'. + // Replace the final character of sourceRevision, '9', with '8'. expect(MANIFEST_JSON.endsWith('"sourceRevision":"23230a9"}')).toBe(true) - const flipped = `${MANIFEST_JSON.slice(0, -2)}8"}` + const flipped = `${MANIFEST_JSON.slice(0, -3)}8"}`🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/modules/artifact/vectors.test.ts` around lines 58 - 64, Update the comment in the test case around verifyManifestSignature to accurately describe the mutation performed: the sourceRevision changes from 23230a9 to 23230a98 by appending 8 before the closing quote and brace. Keep the existing mutation and assertions unchanged.
83-86: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a vector for the non-canonical base64url branch.
decodeBase64UrlStricthas three rejection paths: alphabet, decoded length, and the re-encode round trip. The tests cover the alphabet path at Line 69 and the success path here. The round-trip path stays untested. A signature whose final character sets non-zero padding bits decodes to 64 bytes and passes the first two checks, so this branch is the one most likely to regress silently.♻️ Proposed additional vector
it('decodeBase64UrlStrict independently confirms the key and signature decode to the expected lengths', () => { expect(decodeBase64UrlStrict(RIQ_2026_PUBLIC_KEY, 32).length).toBe(32) expect(decodeBase64UrlStrict(SIGNATURE, 64).length).toBe(64) }) + + it('rejects a signature that decodes to 64 bytes from a non-canonical encoding', () => { + // Same 64 decoded bytes, but the final character carries non-zero + // padding bits, so the re-encode round trip differs from the input. + const nonCanonical = `${SIGNATURE.slice(0, -1)}${SIGNATURE.endsWith('g') ? 'h' : 'g'}` + expect(nonCanonical).not.toBe(SIGNATURE) + expect(() => decodeBase64UrlStrict(nonCanonical, 64)).toThrow(/canonical/) + })🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/modules/artifact/vectors.test.ts` around lines 83 - 86, Add a test case alongside the existing decodeBase64UrlStrict tests that uses a non-canonical base64url signature vector whose final character contains non-zero padding bits. Assert that it is rejected by the re-encode round-trip validation while still representing the expected decoded length, covering the branch distinct from alphabet and length failures.cli/src/verify.ts (1)
31-54: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueExtract the repeated read-and-wrap block.
The three read blocks differ only in the path, the encoding, and the label. A small helper removes the duplication and keeps the error text consistent if a fourth input appears later.
♻️ Proposed refactor
- let tarballBytes: Buffer - let manifestJson: string - let signature: string - try { - tarballBytes = fsImpl.readFileSync(options.tarballPath) - } catch (err) { - throw new VerifyCommandError( - `could not read tarball '${options.tarballPath}': ${err instanceof Error ? err.message : String(err)}`, - ) - } - try { - manifestJson = fsImpl.readFileSync(options.manifestPath, 'utf8') - } catch (err) { - throw new VerifyCommandError( - `could not read manifest '${options.manifestPath}': ${err instanceof Error ? err.message : String(err)}`, - ) - } - try { - signature = fsImpl.readFileSync(options.signaturePath, 'utf8').trim() - } catch (err) { - throw new VerifyCommandError( - `could not read signature '${options.signaturePath}': ${err instanceof Error ? err.message : String(err)}`, - ) - } + function read(label: string, filePath: string): Buffer + function read(label: string, filePath: string, encoding: 'utf8'): string + function read(label: string, filePath: string, encoding?: 'utf8'): Buffer | string { + try { + return encoding ? fsImpl.readFileSync(filePath, encoding) : fsImpl.readFileSync(filePath) + } catch (err) { + throw new VerifyCommandError( + `could not read ${label} '${filePath}': ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + const tarballBytes = read('tarball', options.tarballPath) + const manifestJson = read('manifest', options.manifestPath, 'utf8') + const signature = read('signature', options.signaturePath, 'utf8').trim()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/src/verify.ts` around lines 31 - 54, Extract the repeated file-reading and VerifyCommandError-wrapping logic from the tarball, manifest, and signature reads into a small helper. Have the helper accept the file path, optional encoding, and input label, preserve the existing error message format, and reuse it at each call site while retaining signature trimming.cli/src/verify-core.ts (1)
78-85: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winRestrict the trust-store lookup to own string properties.
trustStoreis a plain object literal, so it inherits fromObject.prototype. A manifest that setskeyIdtoconstructor,toString, orvalueOfmakestrustStore[manifest.keyId]return an inherited function. That value is truthy, so thisuntrusted-keyguard does not fire, and the code passes a function intoverifyManifestSignature.Verification still fails, because
decodeBase64UrlStrictcoerces the value to a string and rejects it on the alphabet check. So this is not a bypass. The result is a wrong reason code: the operator seesbad-signaturefor a key the CLI never trusted, and the safety of the path depends on a coercion in a different module.cli/src/install.tsat Line 172 uses the same lookup shape and reports key presence the same way.🛡️ Proposed hardening
- const trustedKey = trustStore[manifest.keyId] - if (!trustedKey) { + const trustedKey = Object.hasOwn(trustStore, manifest.keyId) + ? trustStore[manifest.keyId] + : undefined + if (typeof trustedKey !== 'string' || trustedKey.length === 0) { return { ok: false, code: 'untrusted-key', message: `manifest was signed with keyId '${manifest.keyId}', which this CLI does not trust`, } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/src/verify-core.ts` around lines 78 - 85, Update the trustStore lookup in the verification flow around trustedKey to accept only own properties whose values are strings, using an own-property check before reading or validating the stored key. Ensure inherited names such as constructor, toString, and valueOf follow the existing untrusted-key response, and apply the same protection to the corresponding key-presence lookup in install.ts.tests/cli/verify-core.test.ts (1)
19-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a passing case through
verifyArtifact.Every case in this file asserts
ok: false. No test assertsok: truethroughverifyArtifact, because the real 88,107-byte tarball is not in the repository.tests/modules/artifact/vectors.test.tscovers the success path only at theverifyManifestSignaturelevel, so it does not exercise howverifyArtifactwires the digest check, the size check, and the trust-store lookup into a success result. A wiring defect on that path would pass CI today.You do not need the real artifact. Generate an ed25519 key pair in the test, sign a canonical manifest that describes a small local buffer, and pass a local trust store that holds the generated public key.
💚 Proposed positive-path test
+import { generateKeyPairSync, sign as cryptoSign } from 'node:crypto' +import { canonicalizeManifest, sha256Hex } from '../../src/modules/artifact/index.js' + +it('returns ok for a locally signed manifest that matches the bytes', () => { + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + const rawPublicKey = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32) + const localTrustStore = { 'test-key': rawPublicKey.toString('base64url') } + + const bytes = Buffer.from('not a real tarball, but real bytes') + const manifest = { + schema: 'helpthread-module-manifest/1' as const, + module: 'draft-assistant', + semver: '0.3.0', + artifactSha256: sha256Hex(bytes), + artifactBytes: bytes.length, + sourceRevision: '23230a9', + minEngineApi: '1.0.0', + builtAt: '2026-08-04T01:24:51.266Z', + keyId: 'test-key', + } + const canonical = canonicalizeManifest(manifest) + const signature = cryptoSign(null, Buffer.from(canonical, 'utf8'), privateKey).toString( + 'base64url', + ) + + const result = verifyArtifact(bytes, canonical, signature, localTrustStore) + expect(result.ok).toBe(true) + if (result.ok) expect(result.manifest.module).toBe('draft-assistant') +})🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/cli/verify-core.test.ts` around lines 19 - 26, Add a positive-path test in the verifyArtifact test suite that generates an Ed25519 key pair, signs a canonical manifest describing a small local buffer, and builds a trust store containing the generated public key. Pass the matching buffer, manifest, signature, and trust store to verifyArtifact, then assert ok is true, covering digest, size, signature, and trust-store wiring.tests/cli/catalog.test.ts (1)
215-227: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a test for the
MAX_ARTIFACT_BYTESceiling.The
fetchTarballblock covers success and a non-ok status, but not the size refusal, which is the security control in that function. A test that returns an oversizedArrayBufferlocks the limit in place.💚 Proposed test
it('throws CatalogError on a non-ok response', async () => { const fetchImpl = fakeFetch(() => ({ ok: false, status: 403 })) await expect(fetchTarball('https://signed.example/x', fetchImpl)).rejects.toThrow(CatalogError) }) + + it('refuses a body larger than MAX_ARTIFACT_BYTES', async () => { + const oversized = new ArrayBuffer(MAX_ARTIFACT_BYTES + 1) + const fetchImpl = fakeFetch(() => ({ ok: true, status: 200, arrayBuffer: oversized })) + await expect(fetchTarball('https://signed.example/x', fetchImpl)).rejects.toThrow( + /exceeds the .* ceiling/, + ) + })Import
MAX_ARTIFACT_BYTESfrom../../cli/src/catalog.jsalongside the existing imports.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/cli/catalog.test.ts` around lines 215 - 227, Add a fetchTarball test covering the MAX_ARTIFACT_BYTES ceiling, importing that constant alongside the existing catalog imports. Have the fake fetch return an ArrayBuffer larger than the limit with an otherwise successful response, and assert that fetchTarball rejects with CatalogError.cli/src/catalog.ts (2)
14-24: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winConsider a request timeout for every catalog call.
FetchLikecarries no abort signal, sofetchCatalog,requestDownloadUrl, andfetchTarballcan each hang until the operating system gives up. The PR bounds artifact size but not time. Add an optionalsignaltoFetchLikeand pass anAbortSignal.timeout(...)fromcli/src/main.ts.Also applies to: 140-162
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/src/catalog.ts` around lines 14 - 24, Update the FetchLike init type and the catalog request functions fetchCatalog, requestDownloadUrl, and fetchTarball to accept and forward an optional AbortSignal. In cli/src/main.ts, supply an AbortSignal.timeout(...) for each catalog call so requests have an explicit deadline while preserving the existing artifact-size limits and response handling.
82-91: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
compareSemverranks a prerelease above its own release.
'1.0.0-rc.1'.split('.')yields['1','0','0-rc','1'], andNumber.parseInt('0-rc', 10)is0, so the string compares as1.0.0.1and wins against1.0.0. If the catalog ever publishes a prerelease, an unpinnedinstallselects it as "latest". The doc comment accepts malformed segments, so this is a hardening suggestion rather than a present defect: filterresolveVersioncandidates to strict^\d+\.\d+\.\d+$versions, or compare with a semver library.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/src/catalog.ts` around lines 82 - 91, Harden latest-version selection so prerelease strings cannot outrank stable releases: in the resolveVersion candidate selection flow, filter versions using the strict /^\d+\.\d+\.\d+$/ shape before calling compareSemver, while preserving compareSemver’s malformed-segment fallback behavior.cli/src/install.ts (1)
121-148: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winNothing reads
tmpTarballPath, so the temp write only spills paid bytes to disk.
safeExtractreceivestarballBytesfrom memory on Line 212.tmpTarballPathis written on Line 125 and never read again. The write therefore adds a copy of the proprietary artifact on disk, and thetry/finallyblock exists only to remove a file that need not be created. Remove the temp write and the cleanup, or read the extraction input from that path if an on-disk copy is intended.♻️ Proposed simplification
deps.log('4. Downloading the release artifact...') const tarballBytes = await fetchTarball(download.downloadUrl, deps.fetchImpl) - const tmpDir = fsImpl.mkdtempSync(path.join(os.tmpdir(), 'helpthread-module-')) - const tmpTarballPath = path.join(tmpDir, `${mod.slug}-${servedEntry.version}.tar.gz`) - fsImpl.writeFileSync(tmpTarballPath, tarballBytes) - - // Everything from here runs inside try/finally: this is PAID, proprietary - // software sitting in a world-readable temp directory, and any failure - // below — a refused destination, a bad archive entry, a missing config — - // would otherwise leave it there indefinitely. Cleanup must not depend on - // reaching a particular branch. - try { - await installVerifiedArtifact({ - deps, - fsImpl, - trustStore, - options, - mod, - servedEntry, - tarballBytes, - }) - } finally { - try { - fsImpl.rmSync(tmpDir, { recursive: true, force: true }) - } catch { - // best-effort cleanup - } - } + await installVerifiedArtifact({ + deps, + fsImpl, + trustStore, + options, + mod, + servedEntry, + tarballBytes, + }) }The
node:osimport becomes unused after this change.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/src/install.ts` around lines 121 - 148, Remove the unused temporary artifact path and write in the installation flow around installVerifiedArtifact, along with the surrounding try/finally cleanup that only removes that directory. Continue passing the in-memory tarballBytes to installVerifiedArtifact and remove the now-unused node:os import.tests/cli/install.test.ts (1)
66-74: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winIsolate
HELPTHREAD_LICENSE_KEYin these tests.
runInstallreadsprocess.env.HELPTHREAD_LICENSE_KEYbefore it callsdeps.getLicenseKey(). No test in this file sets or clears that variable. If a developer machine or CI job exports it, the license-key tests at Lines 218-274 and Lines 581-656 stop exercising what they claim: the injected secret is never used, so "the thrown error does not contain the key" passes for the wrong reason. The comment on Line 176,'unused-because-env-var-set', also describes a variable that this file never sets.Clear the variable in
beforeEachand set it explicitly in any test that wants the env path.💚 Proposed change
-import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'beforeEach(() => { workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ht116-install-')) + // `runInstall` prefers this variable over `deps.getLicenseKey`; an + // inherited value would make the leak assertions vacuous. + vi.stubEnv('HELPTHREAD_LICENSE_KEY', '') + vi.stubEnv('HELPTHREAD_CATALOG_ORIGIN', '') }) afterEach(() => { + vi.unstubAllEnvs() fs.rmSync(workDir, { recursive: true, force: true }) })Then change Line 176 to a plain
getLicenseKey: async () => 'test-license-key'.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/cli/install.test.ts` around lines 66 - 74, Update the test setup around beforeEach to clear process.env.HELPTHREAD_LICENSE_KEY before each test, and explicitly set it only in tests that exercise the environment-variable path. In the mock dependency near the existing “unused-because-env-var-set” comment, remove that misleading comment and keep getLicenseKey returning the test license key normally.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cli/package.json`:
- Around line 4-15: Set the package manifest’s private field to true to block
publication while repository-relative imports remain. Keep the existing bin,
engine, and dependency configuration unchanged until the CLI is converted to
packaged dependencies and a release-ready artifact.
In `@cli/src/args.ts`:
- Around line 57-67: Reject tokens beginning with an option marker when parsing
required values: update the --catalog, --version, and --dir handling in
cli/src/args.ts lines 57-67, and the --manifest and --signature handling in
cli/src/args.ts lines 119-125, so missing values raise ArgsError instead of
consuming the next option. Add regression cases in tests/cli/args.test.ts lines
58-62 for --catalog --force and lines 81-90 for --manifest --signature a.sig,
verifying both are rejected.
In `@cli/src/catalog.ts`:
- Around line 65-75: Update fetchCatalog to validate the parsed JSON before
returning it: require a non-null, non-array object with a valid modules
collection suitable for findModule’s catalog.modules.find usage. For any invalid
response shape, throw CatalogError instead of returning an unchecked cast;
preserve the existing HTTP error handling and valid CatalogResponse path.
- Around line 163-186: Validate the response body in the download request flow
before returning from fetchTarball: ensure downloadUrl is a string and its
parsed URL uses only the https: or http: scheme. Throw CatalogError for missing,
malformed, or disallowed values, and return the validated DownloadResponse only
after this guard.
- Around line 188-212: Update FetchLike and fetchTarball so the response body is
consumed incrementally through its stream rather than res.arrayBuffer(); track
accumulated bytes, abort or reject as soon as the total exceeds
MAX_ARTIFACT_BYTES, and return a Buffer only after the stream completes within
the limit. Preserve the existing HTTP error handling and CatalogError message
behavior while adding the required body accessor to FetchLike.
In `@cli/src/install.ts`:
- Around line 105-119: Update the install version-selection logic around
servedVersion and servedEntry so an explicit options.version pin cannot be
substituted: when options.version is set and servedVersion differs from the
requested version, throw an InstallError explaining that the marketplace served
a different version. Keep the existing entitlement NOTE and substitution
behavior for unpinned installs.
- Around line 77-81: Update the licenseKey resolution in the install flow to
treat an empty HELPTHREAD_LICENSE_KEY value as absent, falling back to
deps.getLicenseKey() when the environment variable is unset or empty. Preserve
the existing InstallError for cases where both sources provide no license key.
- Around line 229-249: Update prepareDestDir to use lstatSync instead of
statSync when inspecting destDir, and explicitly reject symbolic links before
checking contents or removing entries. Preserve the existing non-directory error
and --force cleanup behavior for real directories.
In `@cli/src/prompt.ts`:
- Around line 22-27: Update the non-TTY branch in the prompt flow to settle the
promise when readline closes before the question callback provides an answer:
reject with the missing-license error, while avoiding duplicate settlement after
a valid answer. Also listen for readline.error and reject with that error,
ensuring the interface is closed appropriately.
In `@src/modules/artifact/extract.ts`:
- Around line 247-299: Update the extraction planning around isDirectory,
dedupeKey, and toWrite to retain explicit directory entries, build the complete
directory/file plan before any filesystem writes, and reject conflicts where a
file path is an ancestor of another entry such as a and a/b. After validation,
create all declared directories and required parent directories before writing
files, preserving the existing safety checks and ensuring no partial filesystem
state is created by plan conflicts.
- Around line 51-58: The decompression limit currently reuses
MAX_TOTAL_UNCOMPRESSED_BYTES even though that constant governs extracted entry
payload. Add a distinct bounded TAR-stream cap with enough allowance for
headers, padding, and the trailer, use it for gunzipSync’s maxOutputLength, and
continue enforcing MAX_TOTAL_UNCOMPRESSED_BYTES through totalBytes for payload
data.
In `@tests/cli/install.test.ts`:
- Around line 182-189: Update runInstall in cli/src/install.ts to compare
config.module with verification.manifest.module and reject installation when the
slugs differ. Add or adjust the install test fixture so module.config.json uses
a different module slug from the verified manifest, and assert that the mismatch
is reported and installation does not proceed.
In `@tests/modules/artifact/extract.test.ts`:
- Around line 130-159: The boundary tests in extract.test.ts currently allocate
production-sized buffers through buildTarGz, including the MAX_SINGLE_FILE_BYTES
and MAX_TOTAL_UNCOMPRESSED_BYTES cases. Update safeExtract and its test setup to
support injected or reduced limits, then exercise these tests with small limits
and correspondingly small buffers while preserving the single-file and
cumulative-size boundary assertions. Keep validation of the real 512 MiB
production limits in a separate limited integration test.
---
Nitpick comments:
In `@cli/src/catalog.ts`:
- Around line 14-24: Update the FetchLike init type and the catalog request
functions fetchCatalog, requestDownloadUrl, and fetchTarball to accept and
forward an optional AbortSignal. In cli/src/main.ts, supply an
AbortSignal.timeout(...) for each catalog call so requests have an explicit
deadline while preserving the existing artifact-size limits and response
handling.
- Around line 82-91: Harden latest-version selection so prerelease strings
cannot outrank stable releases: in the resolveVersion candidate selection flow,
filter versions using the strict /^\d+\.\d+\.\d+$/ shape before calling
compareSemver, while preserving compareSemver’s malformed-segment fallback
behavior.
In `@cli/src/install.ts`:
- Around line 121-148: Remove the unused temporary artifact path and write in
the installation flow around installVerifiedArtifact, along with the surrounding
try/finally cleanup that only removes that directory. Continue passing the
in-memory tarballBytes to installVerifiedArtifact and remove the now-unused
node:os import.
In `@cli/src/verify-core.ts`:
- Around line 78-85: Update the trustStore lookup in the verification flow
around trustedKey to accept only own properties whose values are strings, using
an own-property check before reading or validating the stored key. Ensure
inherited names such as constructor, toString, and valueOf follow the existing
untrusted-key response, and apply the same protection to the corresponding
key-presence lookup in install.ts.
In `@cli/src/verify.ts`:
- Around line 31-54: Extract the repeated file-reading and
VerifyCommandError-wrapping logic from the tarball, manifest, and signature
reads into a small helper. Have the helper accept the file path, optional
encoding, and input label, preserve the existing error message format, and reuse
it at each call site while retaining signature trimming.
In `@tests/cli/catalog.test.ts`:
- Around line 215-227: Add a fetchTarball test covering the MAX_ARTIFACT_BYTES
ceiling, importing that constant alongside the existing catalog imports. Have
the fake fetch return an ArrayBuffer larger than the limit with an otherwise
successful response, and assert that fetchTarball rejects with CatalogError.
In `@tests/cli/install.test.ts`:
- Around line 66-74: Update the test setup around beforeEach to clear
process.env.HELPTHREAD_LICENSE_KEY before each test, and explicitly set it only
in tests that exercise the environment-variable path. In the mock dependency
near the existing “unused-because-env-var-set” comment, remove that misleading
comment and keep getLicenseKey returning the test license key normally.
In `@tests/cli/verify-core.test.ts`:
- Around line 19-26: Add a positive-path test in the verifyArtifact test suite
that generates an Ed25519 key pair, signs a canonical manifest describing a
small local buffer, and builds a trust store containing the generated public
key. Pass the matching buffer, manifest, signature, and trust store to
verifyArtifact, then assert ok is true, covering digest, size, signature, and
trust-store wiring.
In `@tests/modules/artifact/module-config.test.ts`:
- Around line 8-9: Update the REAL_DRAFT_ASSISTANT_CONFIG comment to describe
that the fixture mirrors the shipped configuration’s values and structure,
rather than claiming byte-for-byte identity; keep the existing
JSON.stringify-based fixture unchanged.
In `@tests/modules/artifact/vectors.test.ts`:
- Around line 58-64: Update the comment in the test case around
verifyManifestSignature to accurately describe the mutation performed: the
sourceRevision changes from 23230a9 to 23230a98 by appending 8 before the
closing quote and brace. Keep the existing mutation and assertions unchanged.
- Around line 83-86: Add a test case alongside the existing
decodeBase64UrlStrict tests that uses a non-canonical base64url signature vector
whose final character contains non-zero padding bits. Assert that it is rejected
by the re-encode round-trip validation while still representing the expected
decoded length, covering the branch distinct from alphabet and length failures.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 37e48213-657e-40d3-a14c-2df0b2096b12
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (27)
cli/README.mdcli/bin/helpthread-module.jscli/package.jsoncli/src/args.tscli/src/catalog.tscli/src/env-summary.tscli/src/install.tscli/src/main.tscli/src/prompt.tscli/src/trust-store.tscli/src/verify-core.tscli/src/verify.tspackage.jsonsrc/modules/artifact/extract.tssrc/modules/artifact/index.tssrc/modules/artifact/manifest.tssrc/modules/artifact/module-config.tstests/cli/args.test.tstests/cli/catalog.test.tstests/cli/env-summary.test.tstests/cli/install.test.tstests/cli/verify-core.test.tstests/modules/artifact/extract.test.tstests/modules/artifact/module-config.test.tstests/modules/artifact/tar-helpers.tstests/modules/artifact/vectors.test.tstsconfig.json
| if (!res.ok) { | ||
| // Only the CODE is taken from the response, never the message. This is | ||
| // the one request that carries the license key, and the server fully | ||
| // controls its own error body — a compromised or hostile marketplace | ||
| // could echo the key back inside `error.message`, which would then be | ||
| // printed to the terminal and into any shell transcript or CI log. The | ||
| // code is a short machine token from a known vocabulary; the | ||
| // human-readable text is generated locally instead. | ||
| let detail = `HTTP ${res.status}` | ||
| try { | ||
| const body = (await res.json()) as { error?: { code?: string } } | ||
| const code = body?.error?.code | ||
| if (typeof code === 'string' && /^[a-z0-9_]{1,64}$/.test(code)) { | ||
| detail = `${detail} (${code})` | ||
| } | ||
| } catch { | ||
| // Non-JSON error body — fall back to the bare status. | ||
| } | ||
| throw new CatalogError( | ||
| `download request failed: ${detail}. A 401 usually means the license key was rejected; a 410 means that release was yanked.`, | ||
| ) | ||
| } | ||
| return (await res.json()) as DownloadResponse | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Validate downloadUrl before returning it.
Line 185 casts the body without checking it. If the server omits downloadUrl, fetchTarball receives undefined and the CLI issues a request to the literal string undefined, which produces a confusing failure far from its cause. The scheme is also unchecked, so the server chooses the protocol the CLI dereferences. Check that downloadUrl is a string with an https: (or http:) scheme, and reject anything else with a CatalogError.
🛡️ Proposed guard
- return (await res.json()) as DownloadResponse
+ const body = (await res.json()) as DownloadResponse | null
+ if (!body || typeof body.downloadUrl !== 'string') {
+ throw new CatalogError('download response did not contain a `downloadUrl` string')
+ }
+ let parsed: URL
+ try {
+ parsed = new URL(body.downloadUrl)
+ } catch {
+ throw new CatalogError('download response contained a malformed `downloadUrl`')
+ }
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
+ throw new CatalogError(
+ `refusing a '${parsed.protocol}' download URL: only http(s) artifacts are fetched.`,
+ )
+ }
+ return body📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (!res.ok) { | |
| // Only the CODE is taken from the response, never the message. This is | |
| // the one request that carries the license key, and the server fully | |
| // controls its own error body — a compromised or hostile marketplace | |
| // could echo the key back inside `error.message`, which would then be | |
| // printed to the terminal and into any shell transcript or CI log. The | |
| // code is a short machine token from a known vocabulary; the | |
| // human-readable text is generated locally instead. | |
| let detail = `HTTP ${res.status}` | |
| try { | |
| const body = (await res.json()) as { error?: { code?: string } } | |
| const code = body?.error?.code | |
| if (typeof code === 'string' && /^[a-z0-9_]{1,64}$/.test(code)) { | |
| detail = `${detail} (${code})` | |
| } | |
| } catch { | |
| // Non-JSON error body — fall back to the bare status. | |
| } | |
| throw new CatalogError( | |
| `download request failed: ${detail}. A 401 usually means the license key was rejected; a 410 means that release was yanked.`, | |
| ) | |
| } | |
| return (await res.json()) as DownloadResponse | |
| } | |
| if (!res.ok) { | |
| // Only the CODE is taken from the response, never the message. This is | |
| // the one request that carries the license key, and the server fully | |
| // controls its own error body — a compromised or hostile marketplace | |
| // could echo the key back inside `error.message`, which would then be | |
| // printed to the terminal and into any shell transcript or CI log. The | |
| // code is a short machine token from a known vocabulary; the | |
| // human-readable text is generated locally instead. | |
| let detail = `HTTP ${res.status}` | |
| try { | |
| const body = (await res.json()) as { error?: { code?: string } } | |
| const code = body?.error?.code | |
| if (typeof code === 'string' && /^[a-z0-9_]{1,64}$/.test(code)) { | |
| detail = `${detail} (${code})` | |
| } | |
| } catch { | |
| // Non-JSON error body — fall back to the bare status. | |
| } | |
| throw new CatalogError( | |
| `download request failed: ${detail}. A 401 usually means the license key was rejected; a 410 means that release was yanked.`, | |
| ) | |
| } | |
| const body = (await res.json()) as DownloadResponse | null | |
| if (!body || typeof body.downloadUrl !== 'string') { | |
| throw new CatalogError('download response did not contain a `downloadUrl` string') | |
| } | |
| let parsed: URL | |
| try { | |
| parsed = new URL(body.downloadUrl) | |
| } catch { | |
| throw new CatalogError('download response contained a malformed `downloadUrl`') | |
| } | |
| if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { | |
| throw new CatalogError( | |
| `refusing a '${parsed.protocol}' download URL: only http(s) artifacts are fetched.`, | |
| ) | |
| } | |
| return body |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cli/src/catalog.ts` around lines 163 - 186, Validate the response body in the
download request flow before returning from fetchTarball: ensure downloadUrl is
a string and its parsed URL uses only the https: or http: scheme. Throw
CatalogError for missing, malformed, or disallowed values, and return the
validated DownloadResponse only after this guard.
| // Directory entries carry no useful bytes and are not extracted as | ||
| // files, but their path still needs every safety check above/below — | ||
| // a directory entry is just as capable of encoding `../../etc` as a | ||
| // file entry is. | ||
| const isDirectory = typeflag === '5' | ||
|
|
||
| if (path.isAbsolute(name) || name.startsWith('/')) { | ||
| throw new UnsafeArchiveError(`archive contains an absolute path, refused: '${name}'`) | ||
| } | ||
| const segments = name.split('/') | ||
| if (segments.some((s) => s === '..')) { | ||
| throw new UnsafeArchiveError(`archive contains a path-traversal entry, refused: '${name}'`) | ||
| } | ||
| const nameNoTrailingSlash = name.replace(/\/+$/, '') | ||
| const normalizedNoTrailingSlash = path.normalize(name).replace(/\/+$/, '') | ||
| if (nameNoTrailingSlash !== normalizedNoTrailingSlash) { | ||
| throw new UnsafeArchiveError(`archive contains a non-normalized path, refused: '${name}'`) | ||
| } | ||
|
|
||
| const resolved = path.resolve(resolvedDest, name) | ||
| if (resolved !== resolvedDest && !resolved.startsWith(resolvedDest + path.sep)) { | ||
| throw new UnsafeArchiveError( | ||
| `archive entry resolves outside the extraction target, refused: '${name}'`, | ||
| ) | ||
| } | ||
|
|
||
| const dedupeKey = name.replace(/\/+$/, '') | ||
| if (seenNames.has(dedupeKey)) { | ||
| throw new UnsafeArchiveError(`archive contains a duplicate entry, refused: '${name}'`) | ||
| } | ||
| seenNames.add(dedupeKey) | ||
|
|
||
| if (isDirectory) continue | ||
|
|
||
| if (size > MAX_SINGLE_FILE_BYTES) { | ||
| throw new UnsafeArchiveError( | ||
| `archive entry '${name}' (${size} bytes) exceeds the maximum single-file size (${MAX_SINGLE_FILE_BYTES})`, | ||
| ) | ||
| } | ||
| totalBytes += size | ||
| if (totalBytes > MAX_TOTAL_UNCOMPRESSED_BYTES) { | ||
| throw new UnsafeArchiveError( | ||
| `archive exceeds the maximum total uncompressed size (${MAX_TOTAL_UNCOMPRESSED_BYTES})`, | ||
| ) | ||
| } | ||
|
|
||
| toWrite.push({ fullPath: resolved, data }) | ||
| } | ||
|
|
||
| for (const { fullPath, data } of toWrite) { | ||
| fs.mkdirSync(path.dirname(fullPath), { recursive: true }) | ||
| fs.writeFileSync(fullPath, data) | ||
| written.push(fullPath) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Locate extract.ts"
fd -a 'extract\.ts$' . | sed 's#^\./##'
echo
echo "Outline src/modules/artifact/extract.ts"
ast-grep outline src/modules/artifact/extract.ts 2>/dev/null || true
echo
echo "Relevant source lines 1-330"
sed -n '1,330p' src/modules/artifact/extract.ts | nl -ba
echo
echo "Search MAX constants and constants definitions"
rg -n "MAX_SINGLE_FILE_BYTES|MAX_TOTAL_UNCOMPRESSED_BYTES|safeExtract|MAX_SINGLE|TOTAL.*BYTES" src/modules/artifact/extract.ts package.json src test tests __tests__ 2>/dev/null | head -200Repository: Helpthread/helpthread
Length of output: 943
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Relevant source lines 1-330"
sed -n '1,330p' src/modules/artifact/extract.ts | awk '{printf "%4d\t%s\n", NR, $0}'
echo
echo "Search constants and constants definitions"
rg -n "MAX_SINGLE_FILE_BYTES|MAX_TOTAL_UNCOMPRESSED_BYTES|safeExtract|MAX_SINGLE|TOTAL.*BYTES" src modules test tests __tests__ 2>/dev/null | head -200 || true
echo
echo "Find test files mentioning extract/unsafe/archive"
rg -n "safeExtract|UnsafeArchiveError|duplicate entry|path-traversal|absolute path|empty directory|\\/a\\/[b]|a\\/[b]|mkdirSync|writeFileSync" src test tests __tests__ 2>/dev/null | head -300 || trueRepository: Helpthread/helpthread
Length of output: 27975
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Tar helpers"
sed -n '1,120p' tests/modules/artifact/tar-helpers.ts | awk '{printf "%4d\t%s\n", NR, $0}'
echo
echo "Extract test lines around caps/happy/future tests"
sed -n '1,260p' tests/modules/artifact/extract.test.ts | awk '{printf "%4d\t%s\n", NR, $0}'
echo
echo "Node behavior probe: mkdirSync recursive on file ancestor and path.contains semantics"
node - <<'JS'
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
(async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'coderabbit-probe-'));
try {
const file = path.join(tmp, 'a');
const child = path.join(tmp, 'a', 'b');
await fs.writeFile(file, 'x');
const existsFile = await fs.access(file).then(() => true).catch(() => false);
const existsAncestor = await fs.access(parentOf(file)).then(() => true).catch(() => false);
let failedWrite = false;
try {
await fs.writeFile(child, 'y');
} catch (err) {
failedWrite = true;
}
let mkdirFailed = false;
try {
await fs.mkdir(path.dirname(child), { recursive: true });
} catch (err) {
mkdirFailed = true;
}
const resolvedDest = path.resolve(tmp);
const resolvedA = path.resolve(tmp, 'a');
const resolvedDir = path.parse(resolvedA).dir;
const ancestorConflict = resolvedDir !== resolvedDest && !resolvedDir.startsWith(resolvedDest + path.sep);
console.log(JSON.stringify({
exists_file_after_failed_dir_write: existsFile,
exists_ancestor_after_failed_dir_write: existsAncestor,
failed_write_child_after_file_ancestor: failedWrite,
failed_mkdir_child_after_file_ancestor: mkdirFailed,
ancestor_conflict_using_dir_startswith: ancestorConflict
}, null, 2));
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
})();
function parentOf(p) { return path.resolve(p, '..'); }
JSRepository: Helpthread/helpthread
Length of output: 12661
Build and validate the complete filesystem plan before writing.
Line 303 skips typeflag === '5' entries, so an explicit empty directory is not extracted. Line 298 also writes ancestor files before descendant paths are checked: an archive with a and a/b can leave a written before mkdirSync(path.dirname(a/b), { recursive: true }) fails. Build both declared directories and files upfront, reject file-ancestor conflicts such as a plus a/b, then create directories before writing files.
🧰 Tools
🪛 ast-grep (0.45.0)
[warning] 297-297: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use.
Context: fs.writeFileSync(fullPath, data)
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').
(detect-non-literal-fs-filename-typescript)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/modules/artifact/extract.ts` around lines 247 - 299, Update the
extraction planning around isDirectory, dedupeKey, and toWrite to retain
explicit directory entries, build the complete directory/file plan before any
filesystem writes, and reject conflicts where a file path is an ancestor of
another entry such as a and a/b. After validation, create all declared
directories and required parent directories before writing files, preserving the
existing safety checks and ensuring no partial filesystem state is created by
plan conflicts.
…d verifier 11 real and fixed, 1 correctly rejected. The 64 MiB download ceiling was checked after arrayBuffer() had already buffered the whole body, so a hostile signed-URL host exhausted memory before the check ran. It now streams and aborts on the running total. A cap enforced after allocation is not a cap — the same defect the engine's catalog client had, in a second codebase. An explicit --version pin was silently substituted when entitlement served a different release. Pinning is stated intent — often reproducing a known-good deploy — so a mismatch now refuses and names both versions. Unpinned resolution keeps accepting what entitlement serves. --force deleted the destination's contents before validating it, so a symlinked destination lost its real directory's contents to an extract that then refused anyway. Validation now precedes deletion, via lstat. An exported-but-empty HELPTHREAD_LICENSE_KEY suppressed the prompt and failed later with a confusing message; empty and whitespace-only now count as absent. Catalog and download responses are validated rather than cast. The hidden prompt settles on stdin close/error instead of hanging. Option parsing rejects a flag as another flag's value. The gzip-stream cap is now distinct from the TAR payload cap, so many small files can no longer trip a content cap they never reached. module.config.json's own module field is now bound to the verified manifest. Rejected: "build and validate the plan before writing" — already true; the write loop only runs after validation completes. Added the regression test that was missing to prove it. Tests no longer allocate 512 MiB (caps are injectable) and no longer depend on the developer's shell having HELPTHREAD_LICENSE_KEY unset. 1879 tests, typecheck clean, Biome 346 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@coderabbitai full review |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
Actionable comments posted: 7
♻️ Duplicate comments (1)
cli/src/catalog.ts (1)
234-249: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winSensitive Data Exposure (CWE-319): Cleartext Transmission of Sensitive Information
Reachability: External
Reachability path
● Entry cli/bin/helpthread-module.js │ ▼ ● Hop cli/src/main.ts:20 main │ ▼ ● Sink cli/src/catalog.tsRequire HTTPS for every Module artifact-download hop.
The marketplace controls
downloadUrl, and the current check permits any non-empty string beforefetchTarballdereferences it. Anhttp:URL exposes the Module artifact to a network observer. IffetchImplfollows redirects, an HTTPS URL can also downgrade without an adapter policy. The GET omits the license key, and signature checks preserve integrity, but neither control protects artifact confidentiality. Enforce HTTPS atfetchTarball, and reject redirects until redirect targets receive the same HTTPS validation. Add direct-HTTP and downgrade-redirect tests.Also applies to: 291-292
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/src/catalog.ts` around lines 234 - 249, Enforce HTTPS for every artifact-download request in fetchTarball, including validating the initial downloadUrl and rejecting redirects before following them so an HTTPS URL cannot downgrade to HTTP. Preserve the existing malformed-response validation in the download flow, and add coverage for direct HTTP URLs and HTTPS-to-HTTP redirect attempts.
🧹 Nitpick comments (11)
src/modules/artifact/manifest.ts (1)
197-197: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueSection header sits below the code it describes.
The header at Line 197 announces signature verification, but
verifyManifestSignatureis defined at Lines 95-122. OnlydecodeBase64UrlStrictfollows the header. Move the header above Line 85, or rename it to// --- base64url decoding ---.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/modules/artifact/manifest.ts` at line 197, Align the section header in manifest.ts with the code it describes: move the “signature verification” header above verifyManifestSignature, or rename the existing header to identify the decodeBase64UrlStrict section.tests/modules/artifact/vectors.test.ts (2)
83-86: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a vector for the non-canonical base64url branch.
decodeBase64UrlStrictinsrc/modules/artifact/manifest.tsperforms three checks: alphabet, exact decoded length, and a re-encode round trip. This suite covers the alphabet check at Lines 66-72 and the length check at Lines 83-86. The round-trip check has no test. That branch is the one that rejects a signature whose trailing unused bits are non-zero, so it protects against signature malleability. Add one case that asserts a rejection there.🧪 Proposed additional vector
it('decodeBase64UrlStrict independently confirms the key and signature decode to the expected lengths', () => { expect(decodeBase64UrlStrict(RIQ_2026_PUBLIC_KEY, 32).length).toBe(32) expect(decodeBase64UrlStrict(SIGNATURE, 64).length).toBe(64) }) + + it('rejects a non-canonical base64url encoding of the real signature', () => { + // Same 64 bytes, but the final character sets unused trailing bits, + // so Buffer re-encodes it to a different string. + const raw = decodeBase64UrlStrict(SIGNATURE, 64) + const nonCanonical = `${SIGNATURE.slice(0, -1)}${SIGNATURE.endsWith('g') ? 'h' : 'g'}` + expect(Buffer.from(nonCanonical, 'base64url').equals(raw)).toBe(true) + expect(nonCanonical).not.toBe(SIGNATURE) + expect(() => decodeBase64UrlStrict(nonCanonical, 64)).toThrow(/canonical/) + expect(verifyManifestSignature(MANIFEST_JSON, nonCanonical, RIQ_2026_PUBLIC_KEY)).toBe(false) + })Confirm the substituted character produces the same bytes before you commit the case; adjust it if the assertion at the top of the test fails.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/modules/artifact/vectors.test.ts` around lines 83 - 86, Add a test case in the decodeBase64UrlStrict tests that uses a base64url string with non-zero trailing unused bits while preserving the expected decoded length, then assert it is rejected by the re-encode round-trip validation. Keep the existing alphabet and length vectors unchanged, and verify the substituted character still decodes to the same bytes before finalizing the vector.
30-31: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueFix the inaccurate comment on
UNRELATED_PUBLIC_KEY.The comment says "32 zero bytes re-keyed via a distinct value". That describes nothing concrete, and the value is not derived from zero bytes. State the real provenance: an independently generated Ed25519 public key that is unrelated to
riq-2026.📝 Proposed comment fix
-/** A different, unrelated valid ed25519 public key (32 zero bytes re-keyed via a distinct value) — used to prove the verifier is actually checking the key, not just "some" signature shape. Generated once, not derived from anything secret. */ +/** A second, independently generated ed25519 public key, unrelated to `riq-2026` — used to prove the verifier checks the specific key, not just "some" valid signature shape. Its private half is not retained anywhere. */🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/modules/artifact/vectors.test.ts` around lines 30 - 31, Update the documentation comment for UNRELATED_PUBLIC_KEY to state that it is an independently generated Ed25519 public key unrelated to riq-2026, removing the inaccurate description about zero bytes and re-keying.tests/modules/artifact/module-config.test.ts (1)
8-9: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueThe fixture is not byte-for-byte.
The comment claims the fixture matches the shipped
module.config.jsonbyte-for-byte.JSON.stringifyon an object literal produces the key order and spacing written here, not the bytes of the released file.parseModuleConfigis insensitive to both, so the tests remain valid. Reword the comment to say the fixture reproduces the shipped content, or embed the literal string astests/modules/artifact/vectors.test.tsdoes at its Line 23.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/modules/artifact/module-config.test.ts` around lines 8 - 9, Update the REAL_DRAFT_ASSISTANT_CONFIG comment to state that the fixture reproduces the shipped module.config.json content rather than matching it byte-for-byte, unless replacing the JSON.stringify fixture with the exact literal file contents is preferred.src/modules/artifact/module-config.ts (1)
57-58: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider sharing the validation scaffolding with
manifest.ts.
src/modules/artifact/manifest.tsand this file each implement the same four checks: JSON-object top level, unknown-key rejection, missing-key rejection, and non-empty-string validation. The error text differs only by a prefix. Extract the helpers into an internal module, for examplesrc/modules/artifact/parse-util.ts, and give it a message prefix parameter. Both parsers then share one strictness policy, so a future fix applies to both schemas.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/modules/artifact/module-config.ts` around lines 57 - 58, Extract the duplicated validation helpers used by the module-config and manifest parsers into a shared internal utility, such as parse-util.ts, parameterized by the error-message prefix. Update both parsers to reuse it for top-level object checks, unknown-key rejection, missing-key rejection, and non-empty-string validation while preserving their existing schema-specific keys and error prefixes.cli/src/verify-core.ts (1)
61-76: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueConsider checking the byte length before the SHA-256.
The size comparison is constant time. The hash walks the whole buffer. Checking
artifactBytesfirst rejects a wrong-size tarball without hashing it. The download path already caps the tarball size, so the current order is not a real cost. If you reorder, update the doc comment at Lines 41-45 and the expected codes intests/cli/verify-core.test.ts, which relies on the documented order.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/src/verify-core.ts` around lines 61 - 76, The verify flow should check tarball byte length before computing SHA-256 to reject size mismatches without hashing. Reorder the checks in the verification function, update the nearby doc comment describing validation order, and adjust the expected error-code order in verify-core tests to match.tests/cli/verify-core.test.ts (2)
19-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winNo test asserts the success path of
verifyArtifact.All five cases assert
result.ok === false. An implementation that always returned a failure would pass this whole suite. The success path is the one that gates installation, and it is the only path that exercisescanonicalizeManifest, theTRUST_STOREhit, and themanifestfield onVerifyOk.The real 88,107-byte tarball is not committed, so add the case with generated material instead: create a small buffer, build a manifest that describes its real SHA-256 and length, sign it with a locally generated Ed25519 key, and pass a local trust store. Add a separate assertion that
TRUST_STOREcontains theriq-2026entry, so the pinned key stays covered.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/cli/verify-core.test.ts` around lines 19 - 26, Add a successful verifyArtifact test using a generated small buffer, a manifest containing its actual SHA-256 and length, a locally generated Ed25519 signature, and a matching local trust store; assert result.ok is true and validate the returned manifest. Also add a separate assertion that TRUST_STORE includes the riq-2026 entry, while preserving the existing failure cases.
50-71: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse computed digest values for the buffer vectors.
The pinned digests are correct, but lines 37, 61, and 81 still hard-code
artifactSha256values for generated empty and zero-filled buffers. Define a test-local helper such assha256Hex(Buffer.alloc(length))so future edits cannot drift from the buffer length without failing the test.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/cli/verify-core.test.ts` around lines 50 - 71, Replace the hard-coded artifactSha256 values for generated empty and zero-filled buffers in the affected tests with values computed by a test-local sha256Hex helper applied to Buffer.alloc(length). Update each relevant manifest construction, including the untrustedKeyManifest case, while preserving the existing buffer lengths and assertions.cli/src/verify.ts (1)
31-54: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueCollapse the three read blocks into one helper.
The three
try/catchblocks differ only by the path, the label, and the encoding. A single helper removes the repetition and keeps the error text uniform.♻️ Proposed refactor
- let tarballBytes: Buffer - let manifestJson: string - let signature: string - try { - tarballBytes = fsImpl.readFileSync(options.tarballPath) - } catch (err) { - throw new VerifyCommandError( - `could not read tarball '${options.tarballPath}': ${err instanceof Error ? err.message : String(err)}`, - ) - } - try { - manifestJson = fsImpl.readFileSync(options.manifestPath, 'utf8') - } catch (err) { - throw new VerifyCommandError( - `could not read manifest '${options.manifestPath}': ${err instanceof Error ? err.message : String(err)}`, - ) - } - try { - signature = fsImpl.readFileSync(options.signaturePath, 'utf8').trim() - } catch (err) { - throw new VerifyCommandError( - `could not read signature '${options.signaturePath}': ${err instanceof Error ? err.message : String(err)}`, - ) - } + function read(label: string, filePath: string): Buffer { + try { + return fsImpl.readFileSync(filePath) + } catch (err) { + throw new VerifyCommandError( + `could not read ${label} '${filePath}': ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + const tarballBytes = read('tarball', options.tarballPath) + const manifestJson = read('manifest', options.manifestPath).toString('utf8') + const signature = read('signature', options.signaturePath).toString('utf8').trim()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/src/verify.ts` around lines 31 - 54, Extract the repeated file-reading and VerifyCommandError handling into one local helper near the verification flow, accepting the file path, descriptive label, and optional encoding. Use this helper to initialize tarballBytes, manifestJson, and signature, preserving signature trimming and the existing uniform error messages.cli/src/install.ts (1)
137-164: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRemove the unused temp-file write.
Line 141 writes the full artifact to
tmpTarballPath, but nothing reads it.installVerifiedArtifactreceivestarballBytes, andsafeExtractalso takes the buffer. The write only adds a full-size disk write per install and keeps the temp directory (and itsfinallycleanup) alive for no consumer. If a later step needs the file on disk, keep it and add that consumer; otherwise drop the temp directory entirely.♻️ Proposed simplification
deps.log('4. Downloading the release artifact...') const tarballBytes = await fetchTarball(download.downloadUrl, deps.fetchImpl) - const tmpDir = fsImpl.mkdtempSync(path.join(os.tmpdir(), 'helpthread-module-')) - const tmpTarballPath = path.join(tmpDir, `${mod.slug}-${servedEntry.version}.tar.gz`) - fsImpl.writeFileSync(tmpTarballPath, tarballBytes) - - try { - await installVerifiedArtifact({ - deps, - fsImpl, - trustStore, - options, - mod, - servedEntry, - tarballBytes, - }) - } finally { - try { - fsImpl.rmSync(tmpDir, { recursive: true, force: true }) - } catch { - // best-effort cleanup - } - } + + await installVerifiedArtifact({ + deps, + fsImpl, + trustStore, + options, + mod, + servedEntry, + tarballBytes, + }) }The
node:osimport at Line 11 then becomes unused.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/src/install.ts` around lines 137 - 164, Remove the unused temporary-directory and tarball-file creation around installVerifiedArtifact, including the writeFileSync call and related path/os usage. Preserve passing tarballBytes to installVerifiedArtifact and remove the now-unnecessary finally cleanup block and node:os import.tests/cli/install.test.ts (1)
733-821: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd the positive case for
$HELPTHREAD_LICENSE_KEY.Both tests here cover the "absent" side of the check at cli/src/install.ts lines 82-84. No test covers the "present" side, so a regression that always prompts would still pass. Add a case that sets a non-empty value and asserts
getLicenseKeyis not called.💚 Proposed test
+ it('uses the env var and does not prompt when it holds a value', async () => { + process.env.HELPTHREAD_LICENSE_KEY = 'env-supplied-key' + const fixture = buildSignedFixture({ moduleSlug: 'fixture-module', version: '1.0.0' }) + const fetchImpl = fakeFetch({ + 'https://catalog.example/api/v1/modules': () => ({ + ok: true, + status: 200, + json: fixture.catalog, + }), + 'https://catalog.example/api/v1/download': () => ({ + ok: false, + status: 401, + json: { error: { code: 'unauthorized' } }, + }), + }) + let promptCalled = false + await expect( + runInstall( + { + help: false, + moduleSlug: 'fixture-module', + catalogOrigin: 'https://catalog.example', + version: undefined, + dir: path.join(workDir, 'env-key'), + force: false, + }, + { + fetchImpl, + getLicenseKey: async () => { + promptCalled = true + return 'k' + }, + log: () => {}, + }, + ), + ).rejects.toThrow(/download request failed/) + expect(promptCalled).toBe(false) + })🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/cli/install.test.ts` around lines 733 - 821, Add a positive-case test alongside the existing empty and whitespace-only cases in the runInstall test suite: set HELPTHREAD_LICENSE_KEY to a non-empty value, provide the required catalog/download fixtures, and assert the injected getLicenseKey callback is not called while preserving the expected successful or download-path outcome.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cli/src/catalog.ts`:
- Around line 98-109: Update assertCatalogResponse to validate every entry in
each module’s versions array: require a non-empty string version field and, when
provided, a boolean yanked field; reject null or malformed entries with
CatalogError before resolveVersion processes them. Add malformed-version tests
covering missing/empty or non-string version values and invalid yanked values.
- Around line 307-320: Update fetchTarball’s async-iterable response path to use
a bounded sink, coalescing incoming chunks into fixed-size blocks and enforcing
a cap on retained block/object count in addition to MAX_ARTIFACT_BYTES. Ensure
tiny chunks cannot cause unbounded growth before Buffer.concat, while preserving
the existing byte-limit CatalogError behavior.
In `@cli/src/main.ts`:
- Around line 44-57: Introduce and export a dedicated PromptError in prompt.ts,
and use it for both promptHidden cancellation and closed-input rejection paths.
Update the runInstall catch block in main.ts to recognize PromptError alongside
InstallError and return the clean exit status without printing a stack trace.
In `@cli/src/verify-core.ts`:
- Around line 78-85: Update the trust-store lookups in verifyManifest and the
install flow’s servedEntry manifest-key check to require an own property and a
string-valued entry before accepting it. Reject inherited keys such as
constructor, toString, and __proto__ as untrusted, preserving the existing
untrusted-key handling and avoiding prototype-chain resolution.
In `@src/modules/artifact/extract.ts`:
- Around line 100-105: Update the numeric header parsing near the raw field
conversion to validate that the entire trimmed text contains only octal digits
before calling Number.parseInt; reject any non-octal character, including 8 or
9, with UnsafeArchiveError. Add a test covering a tar size field containing 8 or
9 and assert extraction rejects it.
In `@tests/cli/catalog.test.ts`:
- Around line 209-238: The test around requestDownloadUrl must verify that the
server-supplied error message is not exposed. Change the mocked error message to
include a unique sentinel key and assert the thrown error string excludes that
sentinel, while retaining the unauthorized code assertion and existing
secret-key check.
In `@tests/modules/artifact/vectors.test.ts`:
- Around line 58-64: Correct the mutation in the test case “rejects a manifest
with one flipped byte” so it replaces the final sourceRevision digit 9 with 8
rather than appending 8. Preserve the existing assertions and
verifyManifestSignature call while ensuring the resulting manifest ends with
"23230a8"}.
---
Duplicate comments:
In `@cli/src/catalog.ts`:
- Around line 234-249: Enforce HTTPS for every artifact-download request in
fetchTarball, including validating the initial downloadUrl and rejecting
redirects before following them so an HTTPS URL cannot downgrade to HTTP.
Preserve the existing malformed-response validation in the download flow, and
add coverage for direct HTTP URLs and HTTPS-to-HTTP redirect attempts.
---
Nitpick comments:
In `@cli/src/install.ts`:
- Around line 137-164: Remove the unused temporary-directory and tarball-file
creation around installVerifiedArtifact, including the writeFileSync call and
related path/os usage. Preserve passing tarballBytes to installVerifiedArtifact
and remove the now-unnecessary finally cleanup block and node:os import.
In `@cli/src/verify-core.ts`:
- Around line 61-76: The verify flow should check tarball byte length before
computing SHA-256 to reject size mismatches without hashing. Reorder the checks
in the verification function, update the nearby doc comment describing
validation order, and adjust the expected error-code order in verify-core tests
to match.
In `@cli/src/verify.ts`:
- Around line 31-54: Extract the repeated file-reading and VerifyCommandError
handling into one local helper near the verification flow, accepting the file
path, descriptive label, and optional encoding. Use this helper to initialize
tarballBytes, manifestJson, and signature, preserving signature trimming and the
existing uniform error messages.
In `@src/modules/artifact/manifest.ts`:
- Line 197: Align the section header in manifest.ts with the code it describes:
move the “signature verification” header above verifyManifestSignature, or
rename the existing header to identify the decodeBase64UrlStrict section.
In `@src/modules/artifact/module-config.ts`:
- Around line 57-58: Extract the duplicated validation helpers used by the
module-config and manifest parsers into a shared internal utility, such as
parse-util.ts, parameterized by the error-message prefix. Update both parsers to
reuse it for top-level object checks, unknown-key rejection, missing-key
rejection, and non-empty-string validation while preserving their existing
schema-specific keys and error prefixes.
In `@tests/cli/install.test.ts`:
- Around line 733-821: Add a positive-case test alongside the existing empty and
whitespace-only cases in the runInstall test suite: set HELPTHREAD_LICENSE_KEY
to a non-empty value, provide the required catalog/download fixtures, and assert
the injected getLicenseKey callback is not called while preserving the expected
successful or download-path outcome.
In `@tests/cli/verify-core.test.ts`:
- Around line 19-26: Add a successful verifyArtifact test using a generated
small buffer, a manifest containing its actual SHA-256 and length, a locally
generated Ed25519 signature, and a matching local trust store; assert result.ok
is true and validate the returned manifest. Also add a separate assertion that
TRUST_STORE includes the riq-2026 entry, while preserving the existing failure
cases.
- Around line 50-71: Replace the hard-coded artifactSha256 values for generated
empty and zero-filled buffers in the affected tests with values computed by a
test-local sha256Hex helper applied to Buffer.alloc(length). Update each
relevant manifest construction, including the untrustedKeyManifest case, while
preserving the existing buffer lengths and assertions.
In `@tests/modules/artifact/module-config.test.ts`:
- Around line 8-9: Update the REAL_DRAFT_ASSISTANT_CONFIG comment to state that
the fixture reproduces the shipped module.config.json content rather than
matching it byte-for-byte, unless replacing the JSON.stringify fixture with the
exact literal file contents is preferred.
In `@tests/modules/artifact/vectors.test.ts`:
- Around line 83-86: Add a test case in the decodeBase64UrlStrict tests that
uses a base64url string with non-zero trailing unused bits while preserving the
expected decoded length, then assert it is rejected by the re-encode round-trip
validation. Keep the existing alphabet and length vectors unchanged, and verify
the substituted character still decodes to the same bytes before finalizing the
vector.
- Around line 30-31: Update the documentation comment for UNRELATED_PUBLIC_KEY
to state that it is an independently generated Ed25519 public key unrelated to
riq-2026, removing the inaccurate description about zero bytes and re-keying.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 31d7d3e9-d6d8-4b93-b355-cd22895e58a0
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (27)
cli/README.mdcli/bin/helpthread-module.jscli/package.jsoncli/src/args.tscli/src/catalog.tscli/src/env-summary.tscli/src/install.tscli/src/main.tscli/src/prompt.tscli/src/trust-store.tscli/src/verify-core.tscli/src/verify.tspackage.jsonsrc/modules/artifact/extract.tssrc/modules/artifact/index.tssrc/modules/artifact/manifest.tssrc/modules/artifact/module-config.tstests/cli/args.test.tstests/cli/catalog.test.tstests/cli/env-summary.test.tstests/cli/install.test.tstests/cli/verify-core.test.tstests/modules/artifact/extract.test.tstests/modules/artifact/module-config.test.tstests/modules/artifact/tar-helpers.tstests/modules/artifact/vectors.test.tstsconfig.json
| for (const mod of (body as { modules: unknown[] }).modules) { | ||
| if ( | ||
| typeof mod !== 'object' || | ||
| mod === null || | ||
| typeof (mod as { slug?: unknown }).slug !== 'string' || | ||
| !Array.isArray((mod as { versions?: unknown }).versions) | ||
| ) { | ||
| throw new CatalogError( | ||
| "catalog response is malformed: a module entry is missing a string 'slug' or array 'versions'", | ||
| ) | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Validate each catalog version entry at the boundary.
assertCatalogResponse accepts versions: [null] and non-string version values. resolveVersion then reads v.yanked or calls compareSemver(v.version, ...), which throws a raw TypeError. Validate each version entry for a non-empty string version and a boolean yanked when present. Throw CatalogError and add malformed-version tests.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cli/src/catalog.ts` around lines 98 - 109, Update assertCatalogResponse to
validate every entry in each module’s versions array: require a non-empty string
version field and, when provided, a boolean yanked field; reject null or
malformed entries with CatalogError before resolveVersion processes them. Add
malformed-version tests covering missing/empty or non-string version values and
invalid yanked values.
| const iterableBody = asAsyncIterable(res.body) | ||
| if (iterableBody != null) { | ||
| const chunks: Uint8Array[] = [] | ||
| let total = 0 | ||
| for await (const chunk of iterableBody) { | ||
| total += chunk.byteLength | ||
| if (total > MAX_ARTIFACT_BYTES) { | ||
| throw new CatalogError( | ||
| `refusing the download: exceeded the ${MAX_ARTIFACT_BYTES}-byte ceiling for a module artifact before the body finished.`, | ||
| ) | ||
| } | ||
| chunks.push(chunk) | ||
| } | ||
| return Buffer.concat(chunks.map((c) => Buffer.from(c))) |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Locate catalog and fetchTarball definitions:"
fd -a 'catalog\.ts$|install\.ts$' . | sed 's#^\./##' | sed -n '1,40p'
echo
echo "Search MAX_ARTIFACT_BYTES and fetchTarball definitions/usages:"
rg -n "MAX_ARTIFACT_BYTES|function fetchTarball|const fetchTarball|async .*fetchTarball|asAsyncIterable|fetchTarball\(" .
echo
echo "Inspect cli/src/catalog.ts size and relevant section:"
wc -l cli/src/catalog.ts
sed -n '260,340p' cli/src/catalog.ts
echo
echo "Inspect tests around fetchTarball behavior:"
sed -n '250,335p' tests/cli/catalog.test.tsRepository: Helpthread/helpthread
Length of output: 8282
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Show fetchTarball test tail with exact lines 310-325:"
sed -n '310,325p' tests/cli/catalog.test.ts
echo
echo "Read-only behavioral probe: artifact size, retained object count, and Buffer.from overhead for a fixed-size-slice attack under MAX_ARTIFACT_BYTES"
python3 - <<'PY'
MAX_ARTIFACT_BYTES = 64 * 1024 * 1024
def simulate_chunk_limit(chunk_size):
# Modeled on cli/src/catalog.ts: append every chunk and perform another Buffer.from(c) before concat.
chunks = []
extra = []
total = 0
while total + chunk_size <= MAX_ARTIFACT_BYTES:
chunk = bytearray(chunk_size)
chunks.append(chunk)
extra.append(bytes(chunk))
total += chunk_size
return {
"chunk_size": chunk_size,
"total_bytes": total,
"retained_chunk_count": len(chunks),
"retained_buffer_count": len(extra),
"retained_raw_bytes": len(b"".join(chunks)),
"retained_buffer_bytes": len(b"".join(extra)),
"retained_array_overhead_bytes": sys.getsizeof(chunks)
+ sum(sys.getsizeof(c) for c in chunks)
+ sys.getsizeof(extra) + sum(sys.getsizeof(b) for b in extra),
}
import sys
for chunk_size in [1, 16, 1024, 1024 * 1024]:
print(simulate_chunk_limit(chunk_size))
PYRepository: Helpthread/helpthread
Length of output: 1678
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "FetchCatalog and requestDownloadUrl behavior around signed URL origin:"
sed -n '1,120p' cli/src/catalog.ts
rg -n "requestDownloadUrl|downloadUrl|allowedHosts|signed-URL|sign" cli/src/catalog.ts cli/src/install.ts
echo
echo "Find files mentioning marketplace/signed URL origin:"
rg -n "marketplace|signed|downloadUrl|allowedHosts|origin" cli/src tests -g '*.ts' | sed -n '1,200p'Repository: Helpthread/helpthread
Length of output: 17169
Denial of Service (CWE-400): Uncontrolled Resource Consumption
Reachability: External
Reachability path
● Entry
cli/bin/helpthread-module.js
│
▼
● Hop
cli/src/main.ts:20
main
│
▼
● Sink
cli/src/catalog.ts
Cap retained stream chunks in fetchTarball.
fetchTarball bounds total bytes, but the hosted signed URL controls chunk boundaries. A malicious origin can send many tiny chunks below MAX_ARTIFACT_BYTES; each chunk is retained, then a Buffer.from(chunk) is created before Buffer.concat. This bypasses the byte-only cap and can exhaust memory through object/Buffer overhead. Use a bounded sink such as fixed-size coalesced blocks with an object/count cap so retained object count cannot grow with chunk size.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cli/src/catalog.ts` around lines 307 - 320, Update fetchTarball’s
async-iterable response path to use a bounded sink, coalescing incoming chunks
into fixed-size blocks and enforcing a cap on retained block/object count in
addition to MAX_ARTIFACT_BYTES. Ensure tiny chunks cannot cause unbounded growth
before Buffer.concat, while preserving the existing byte-limit CatalogError
behavior.
| try { | ||
| await runInstall(parsed, { | ||
| fetchImpl: fetch, | ||
| getLicenseKey: () => promptHidden('License key: '), | ||
| log: (line) => console.log(line), | ||
| }) | ||
| return 0 | ||
| } catch (err) { | ||
| if (err instanceof InstallError) { | ||
| console.error(`Error: ${err.message}`) | ||
| return 1 | ||
| } | ||
| throw err | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Map prompt cancellation to a clean exit.
promptHidden rejects with a plain Error for "prompt cancelled" (Ctrl-C) and for "prompt input closed before a value was read" (closed pipe). Neither is an InstallError, so Line 98 prints a full stack trace for two ordinary operator outcomes. Give the prompt a dedicated error class and map it here, next to ArgsError and VerifyCommandError.
🐛 Proposed fix
In cli/src/prompt.ts:
+export class PromptError extends Error {
+ constructor(message: string) {
+ super(message)
+ this.name = 'PromptError'
+ }
+}Then replace each new Error(...) rejection in promptHidden with new PromptError(...), and in cli/src/main.ts:
-import { promptHidden } from './prompt.js'
+import { PromptError, promptHidden } from './prompt.js'
@@
} catch (err) {
- if (err instanceof InstallError) {
+ if (err instanceof InstallError || err instanceof PromptError) {
console.error(`Error: ${err.message}`)
return 1
}
throw err
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cli/src/main.ts` around lines 44 - 57, Introduce and export a dedicated
PromptError in prompt.ts, and use it for both promptHidden cancellation and
closed-input rejection paths. Update the runInstall catch block in main.ts to
recognize PromptError alongside InstallError and return the clean exit status
without printing a stack trace.
| const trustedKey = trustStore[manifest.keyId] | ||
| if (!trustedKey) { | ||
| return { | ||
| ok: false, | ||
| code: 'untrusted-key', | ||
| message: `manifest was signed with keyId '${manifest.keyId}', which this CLI does not trust`, | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Use an own-property lookup for the trust-store key.
manifest.keyId is attacker-influenced. parseManifest only requires a non-empty string, so keyId can be constructor, toString, or __proto__. Line 78 indexes the plain trustStore object, so those values resolve to inherited Object.prototype members, and the truthiness check at Line 79 passes.
This is not a verification bypass. decodeBase64UrlStrict in src/modules/artifact/manifest.ts applies BASE64URL_RE to the coerced value and rejects it, so verifyManifestSignature returns false. The artifact is still refused.
Two problems remain. First, the operator sees bad-signature when the real condition is an untrusted key. Second, the safe outcome depends on a strictness check in a different module, so a future change to decodeBase64UrlStrict could turn this into a defect. Reject non-own and non-string entries at the lookup.
The same inherited-lookup pattern appears in cli/src/install.ts at the trustStore[servedEntry.manifestKeyId] !== undefined check. Apply the same fix there.
🛡️ Proposed fix
- const trustedKey = trustStore[manifest.keyId]
- if (!trustedKey) {
+ const trustedKey = Object.hasOwn(trustStore, manifest.keyId)
+ ? trustStore[manifest.keyId]
+ : undefined
+ if (typeof trustedKey !== 'string' || trustedKey.length === 0) {
return {
ok: false,
code: 'untrusted-key',
message: `manifest was signed with keyId '${manifest.keyId}', which this CLI does not trust`,
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const trustedKey = trustStore[manifest.keyId] | |
| if (!trustedKey) { | |
| return { | |
| ok: false, | |
| code: 'untrusted-key', | |
| message: `manifest was signed with keyId '${manifest.keyId}', which this CLI does not trust`, | |
| } | |
| } | |
| const trustedKey = Object.hasOwn(trustStore, manifest.keyId) | |
| ? trustStore[manifest.keyId] | |
| : undefined | |
| if (typeof trustedKey !== 'string' || trustedKey.length === 0) { | |
| return { | |
| ok: false, | |
| code: 'untrusted-key', | |
| message: `manifest was signed with keyId '${manifest.keyId}', which this CLI does not trust`, | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cli/src/verify-core.ts` around lines 78 - 85, Update the trust-store lookups
in verifyManifest and the install flow’s servedEntry manifest-key check to
require an own property and a string-valued entry before accepting it. Reject
inherited keys such as constructor, toString, and __proto__ as untrusted,
preserving the existing untrusted-key handling and avoiding prototype-chain
resolution.
| const text = raw.toString('latin1').replace(/\0/g, ' ').trim() | ||
| if (text === '') return 0 | ||
| const value = Number.parseInt(text, 8) | ||
| if (!Number.isFinite(value) || value < 0) { | ||
| throw new UnsafeArchiveError(`tar entry has a malformed numeric header field: '${text}'`) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Reject numeric fields that contain non-octal characters.
Number.parseInt(text, 8) accepts a valid prefix and ignores an invalid suffix. For example, it parses "00000000009" as zero. This permits a malformed USTAR size field.
Validate the complete field before parsing it. Add a test that uses 8 or 9 in the size field.
Proposed fix
const text = raw.toString('latin1').replace(/\0/g, ' ').trim()
if (text === '') return 0
+ if (!/^[0-7]+$/.test(text)) {
+ throw new UnsafeArchiveError(`tar entry has a malformed numeric header field: '${text}'`)
+ }
const value = Number.parseInt(text, 8)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const text = raw.toString('latin1').replace(/\0/g, ' ').trim() | |
| if (text === '') return 0 | |
| const value = Number.parseInt(text, 8) | |
| if (!Number.isFinite(value) || value < 0) { | |
| throw new UnsafeArchiveError(`tar entry has a malformed numeric header field: '${text}'`) | |
| } | |
| const text = raw.toString('latin1').replace(/\0/g, ' ').trim() | |
| if (text === '') return 0 | |
| if (!/^[0-7]+$/.test(text)) { | |
| throw new UnsafeArchiveError(`tar entry has a malformed numeric header field: '${text}'`) | |
| } | |
| const value = Number.parseInt(text, 8) | |
| if (!Number.isFinite(value) || value < 0) { | |
| throw new UnsafeArchiveError(`tar entry has a malformed numeric header field: '${text}'`) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/modules/artifact/extract.ts` around lines 100 - 105, Update the numeric
header parsing near the raw field conversion to validate that the entire trimmed
text contains only octal digits before calling Number.parseInt; reject any
non-octal character, including 8 or 9, with UnsafeArchiveError. Add a test
covering a tar size field containing 8 or 9 and assert extraction rejects it.
| it('reports the error CODE but never the server-supplied message text', async () => { | ||
| const fetchImpl = fakeFetch(() => ({ | ||
| ok: false, | ||
| status: 401, | ||
| json: { error: { code: 'unauthorized', message: 'Missing or invalid license key.' } }, | ||
| })) | ||
| await expect( | ||
| requestDownloadUrl( | ||
| 'https://marketplace.example', | ||
| fetchImpl, | ||
| 'sk_super_secret_value', | ||
| 'draft-assistant', | ||
| '0.3.0', | ||
| ), | ||
| // The code survives (useful, machine-generated, short). The server's | ||
| // free-text message does not — this is the one request carrying the | ||
| // license key, and a hostile endpoint controls that string. | ||
| ).rejects.toThrow(/unauthorized/) | ||
| try { | ||
| await requestDownloadUrl( | ||
| 'https://marketplace.example', | ||
| fetchImpl, | ||
| 'sk_super_secret_value', | ||
| 'draft-assistant', | ||
| '0.3.0', | ||
| ) | ||
| } catch (err) { | ||
| expect(String(err)).not.toContain('sk_super_secret_value') | ||
| } | ||
| }) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Assert that the remote error message is absent.
The mock supplies error.message: "Missing or invalid license key.", but the assertion checks only for the unrelated local license key. If requestDownloadUrl starts echoing error.message, this test still passes. Assert that the mocked message is absent, and include a sentinel key in that message to cover reflection.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/cli/catalog.test.ts` around lines 209 - 238, The test around
requestDownloadUrl must verify that the server-supplied error message is not
exposed. Change the mocked error message to include a unique sentinel key and
assert the thrown error string excludes that sentinel, while retaining the
unauthorized code assertion and existing secret-key check.
| it('rejects a manifest with one flipped byte', () => { | ||
| // Flip the final character of sourceRevision from '9' to '8'. | ||
| expect(MANIFEST_JSON.endsWith('"sourceRevision":"23230a9"}')).toBe(true) | ||
| const flipped = `${MANIFEST_JSON.slice(0, -2)}8"}` | ||
| expect(flipped).not.toBe(MANIFEST_JSON) | ||
| expect(verifyManifestSignature(flipped, SIGNATURE, RIQ_2026_PUBLIC_KEY)).toBe(false) | ||
| }) |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
The mutation appends a character; it does not flip one.
MANIFEST_JSON ends with "23230a9"}. MANIFEST_JSON.slice(0, -2) removes only " and }, so flipped carries "sourceRevision":"23230a98". The comment at Line 59 states the final 9 becomes 8. The assertion still passes, because any change breaks the signature. Align the code with the stated intent so the vector stays readable.
🧪 Proposed fix to actually flip the final character
it('rejects a manifest with one flipped byte', () => {
// Flip the final character of sourceRevision from '9' to '8'.
expect(MANIFEST_JSON.endsWith('"sourceRevision":"23230a9"}')).toBe(true)
- const flipped = `${MANIFEST_JSON.slice(0, -2)}8"}`
+ const flipped = `${MANIFEST_JSON.slice(0, -3)}8"}`
+ expect(flipped.endsWith('"sourceRevision":"23230a8"}')).toBe(true)
expect(flipped).not.toBe(MANIFEST_JSON)
expect(verifyManifestSignature(flipped, SIGNATURE, RIQ_2026_PUBLIC_KEY)).toBe(false)
})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| it('rejects a manifest with one flipped byte', () => { | |
| // Flip the final character of sourceRevision from '9' to '8'. | |
| expect(MANIFEST_JSON.endsWith('"sourceRevision":"23230a9"}')).toBe(true) | |
| const flipped = `${MANIFEST_JSON.slice(0, -2)}8"}` | |
| expect(flipped).not.toBe(MANIFEST_JSON) | |
| expect(verifyManifestSignature(flipped, SIGNATURE, RIQ_2026_PUBLIC_KEY)).toBe(false) | |
| }) | |
| it('rejects a manifest with one flipped byte', () => { | |
| // Flip the final character of sourceRevision from '9' to '8'. | |
| expect(MANIFEST_JSON.endsWith('"sourceRevision":"23230a9"}')).toBe(true) | |
| const flipped = `${MANIFEST_JSON.slice(0, -3)}8"}` | |
| expect(flipped.endsWith('"sourceRevision":"23230a8"}')).toBe(true) | |
| expect(flipped).not.toBe(MANIFEST_JSON) | |
| expect(verifyManifestSignature(flipped, SIGNATURE, RIQ_2026_PUBLIC_KEY)).toBe(false) | |
| }) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/modules/artifact/vectors.test.ts` around lines 58 - 64, Correct the
mutation in the test case “rejects a manifest with one flipped byte” so it
replaces the final sourceRevision digit 9 with 8 rather than appending 8.
Preserve the existing assertions and verifyManifestSignature call while ensuring
the resulting manifest ends with "23230a8"}.
🟢 SAFE TO MERGE
Gates green on this head (Quality: typecheck/lint/test/coverage; gitleaks; CodeQL adjudicated below). Maintainer instructed merge, 2026-08-04: "merge both PRs".
CodeRabbit: 12 findings — 11 real and fixed, 1 correctly rejected (the "validate before writing" concern was already true; a regression test was added to prove it rather than changing working code). Re-review requested after the fix push and returned clean. Fix commit
cfb4246.CodeQL: fail adjudicated as not-ours. Zero alerts in
src/orcli/on this branch. The three high-severity items are OpenSSF Scorecard meta-checks already failing onmain— "0/28 approved changesets" (structural to a solo project), "repository created within 90 days", and an unpinned action in a workflow file. None introduced here.The sharpest catch: the CLI's 64 MiB download ceiling was enforced after
arrayBuffer()had buffered the whole body, so a hostile signed-URL host could exhaust memory before the check ran. It now streams and aborts on the running total. Same defect class Codex found in the engine's catalog client — the same mistake made twice in two codebases, caught by two different reviewers.Original verdict at open — 🟡 NEEDS YOUR DECISION
Code done, reviewed, and green (1863 tests, typecheck, Biome 346 files). One decision is mine and needs your confirmation; one known gap is ticketed rather than fixed.
Codex (adversarial, in place of CodeRabbit — not installed on this org): 8 findings — 6 fixed here, 1 ticketed (HT-121), 1 accepted. It found a real substitution attack that the three prior review layers on this workstream would not have caught.
Decision provenance
The finding worth your attention
A valid signature proves an artifact is authentic. It does not prove it is the artifact you asked for. Every first-party release is signed by the same key, so a compromised marketplace could answer a request for KB Manager with a genuinely-signed Draft Assistant tarball — correct signature, correct digest, correct everything — and the install would succeed. Now the verified manifest's own
moduleandsemvermust match what was requested and served. Pinned by a test that constructs exactly that attack.Also fixed: the license key could be echoed back by a hostile endpoint into an error the CLI printed (only a pattern-checked error code is taken from the response now);
gunzipSyncallocated before any size cap applied, so a small highly-compressible archive exhausted memory before refusal; the download was unbounded; a symlinked destination bypassed containment checks; temp cleanup wasn't in afinally, leaving paid software in/tmpon failure paths.What this adds
src/modules/artifact/— the catalog-neutral verification library the engine will also call (HT-119): canonical manifest, ed25519 verification with strict base64url decoding, and a tar extractor that refuses traversal, symlinks, hardlinks, devices, duplicates and escapes under explicit caps. Nothing marketplace-specific in it.cli/—helpthread-module, withinstall <slug>(resolve → prompt for key → download → verify against a compiled-in trust store → safe-extract → print env vars split by owner → hand off the deploy command) andverify <tarball>(fully offline, no marketplace — the path a fork keeps).Golden conformance vectors pin the real published draft-assistant 0.3.0 manifest and signature. This library is an independent second implementation of a format whose first implementation lives in the private marketplace repo; a canonicalization divergence between them would mean valid releases failing or invalid ones passing. Now it's a red test.
Verified (observed, not reported)
biome check346 filesverify→OK. module: draft-assistant version: 0.3.0 keyId: riq-2026, exit 0Known gap, ticketed not fixed
HT-121 — the CLI is not publishable yet. It imports the shared library by repo-relative path and runs TS via
tsx; both are fine in-repo and wrong in a published package. Not a live defect (nothing is published), but it blocksnpm publish, and the ticket requires verifying by actually packing and installing into an isolated dir rather than by reasoning.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
helpthread-moduleCLI with install and offline verification commands.Documentation
Tests