Skip to content

feat(cactus): SHA-256 integrity verification for fetched model assets#530

Merged
sroussey merged 16 commits into
mainfrom
claude/sweet-edison-NSph2-cactus-integrity
May 22, 2026
Merged

feat(cactus): SHA-256 integrity verification for fetched model assets#530
sroussey merged 16 commits into
mainfrom
claude/sweet-edison-NSph2-cactus-integrity

Conversation

@sroussey
Copy link
Copy Markdown
Collaborator

@sroussey sroussey commented May 22, 2026

Replaces closed PR #529 with HIGH-1 only. The runtime-state singleton (HIGH-2 in the original review) was overscoped — under the actual usage pattern (workers import ./ai-runtime, main imports ./ai, never both in one realm), the cross-bundle desync doesn't manifest. The module-level Maps + _testOnly aggregator the maintainer added in #524 are sufficient.

Summary

Anchors the trust boundary for locally-executed model weights at the catalog: every byte loaded from disk, Cache Storage, or the network must hash to the catalog-pinned SHA-256. Anything else is refused.

  • New Cactus_Integrity.ts with verifySha256, CactusIntegrityError, and a CACTUS_HASH_PLACEHOLDER sentinel that bypasses verification with a one-time warning during pre-release dev.
  • Extended CactusAssetSpec with per-file sha256: string (lowercase hex, 64 chars) and size: number (cheap byte-length pre-check).
  • Verification runs BEFORE atomic rename in fetchAssetBytesNode and BEFORE cache.put in fetchAssetBytesBrowser. Corrupt disk/cache entries are evicted and refetched.
  • Inline path.resolve + path.relative containment check at every fs call site, paired with assertSafeModelId / assertSafeFilename allowlists. Closes the CodeQL js/path-injection flags on the asset cache path.
  • Module-load invariant: every non-placeholder asset hash is validated with assertHexSha256 at catalog import time so a malformed entry fails fast.

Breaking change — catalog asset shape

CactusCatalogEntry.assets.{weights,vocab,config} changes from a filename
string to a CactusAssetSpec ({ filename: string; sha256: string; size: number }).
External consumers that read entry.assets.weights as a filename string must
update to entry.assets.weights.filename. Treat as a minor-or-major semver
bump per the package's release convention.

Maintainer action required before release

The catalog asset hashes are placeholders (CACTUS_HASH_PLACEHOLDER / "TODO_FILL_AT_RELEASE"). Populate with real SHA-256 + size values from the pinned HF revision before cutting a release. Recommend a follow-up providers/cactus/scripts/hash-catalog.ts script for automation.

Recommend follow-up to require signed commits on Cactus_ModelCatalog.ts so the trust boundary is anchored at the commit-signing layer.

Test plan

  • Cactus_Integrity.test.ts covers sha256Hex (RFC 6234 "abc" vector), verifySha256 happy path, mismatch, malformed expected, placeholder-skip, error class shape.
  • Existing ai-provider-cactus tests updated for the new spec shape.
  • Build + types + unit tests green.

Generated by Claude Code

sroussey added 7 commits May 22, 2026 14:11
Introduces verifySha256, CactusIntegrityError, and a CACTUS_HASH_PLACEHOLDER
sentinel that skips verification with a one-time warning during pre-release
dev. Anchors the trust boundary for locally-executed model weights.
Per-asset CactusAssetSpec replaces the bare filename string. A module-load
loop validates every non-placeholder hash via assertHexSha256 so malformed
catalog entries fail at import time rather than at first fetch.
Verification runs BEFORE atomic rename so unverified bytes are never
promoted to the cache location. Disk-cache hits are verified too; on
mismatch the file is unlinked and the network path is retried.

Adds inline path.resolve + startsWith containment checks at every fs
call site (CodeQL js/path-injection does not trace through helpers),
plus assertSafeModelId / assertSafeFilename allowlists at function
boundaries as defense-in-depth.
Verification runs BEFORE cache.put so the Cache Storage entry never
contains unverified bytes. Cache hits are verified on read; on mismatch
the entry is deleted and the network path is retried. The replayed
Response preserves content-type / content-length headers.

Mirrors the Node path's assertSafeModelId / assertSafeFilename
allowlists at function boundaries for parity, even though the browser
variant does not touch the filesystem.
Surface the CactusIntegrityError message verbatim — the integrity layer
already phrases SHA-256 vs byte-length mismatches correctly. Emits
progress: undefined on the error-path phase event because there is no
meaningful percentage to report (StreamPhase.progress is required).
Surface the CactusIntegrityError message verbatim — the integrity layer
already phrases SHA-256 vs byte-length mismatches correctly. Emits
progress: undefined on the error-path phase event because there is no
meaningful percentage to report (StreamPhase.progress is required).
Covers sha256Hex against the RFC 6234 "abc" vector, verifySha256 happy
path / mismatch / malformed-expected / placeholder-skip, and the
CactusIntegrityError instance shape. Includes the ArrayBuffer-via-
new ArrayBuffer(n) construction so the test exercises the concrete
BufferSource branch of sha256Hex.
Comment thread providers/cactus/src/ai/common/Cactus_Runtime.ts Fixed
`Path escape detected: ${JSON.stringify(mkdirTarget)} is not within ${JSON.stringify(safeRoot)}`
);
}
await fs.mkdir(mkdirTarget, { recursive: true });
`Path escape detected: ${JSON.stringify(writeTarget)} is not within ${JSON.stringify(resolvedDir)}`
);
}
await fs.writeFile(writeTarget, bytes);
);
}
await fs.writeFile(writeTarget, bytes);
const renameFrom = path.resolve(resolvedDir, `${spec.filename}.tmp`);
`Path escape detected: ${JSON.stringify(renameTo)} is not within ${JSON.stringify(resolvedDir)}`
);
}
await fs.rename(renameFrom, renameTo);
`Path escape detected: ${JSON.stringify(renameTo)} is not within ${JSON.stringify(resolvedDir)}`
);
}
await fs.rename(renameFrom, renameTo);
if (cleanupTarget !== resolvedDir && !cleanupTarget.startsWith(resolvedDir + path.sep)) {
throw err;
}
await fs.unlink(cleanupTarget).catch(() => {});
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 22, 2026

Open in StackBlitz

@workglow/cli

npm i https://pkg.pr.new/@workglow/cli@530

@workglow/ai

npm i https://pkg.pr.new/@workglow/ai@530

@workglow/browser-control

npm i https://pkg.pr.new/@workglow/browser-control@530

@workglow/indexeddb

npm i https://pkg.pr.new/@workglow/indexeddb@530

@workglow/javascript

npm i https://pkg.pr.new/@workglow/javascript@530

@workglow/job-queue

npm i https://pkg.pr.new/@workglow/job-queue@530

@workglow/knowledge-base

npm i https://pkg.pr.new/@workglow/knowledge-base@530

@workglow/mcp

npm i https://pkg.pr.new/@workglow/mcp@530

@workglow/storage

npm i https://pkg.pr.new/@workglow/storage@530

@workglow/task-graph

npm i https://pkg.pr.new/@workglow/task-graph@530

@workglow/tasks

npm i https://pkg.pr.new/@workglow/tasks@530

@workglow/util

npm i https://pkg.pr.new/@workglow/util@530

workglow

npm i https://pkg.pr.new/workglow@530

@workglow/anthropic

npm i https://pkg.pr.new/@workglow/anthropic@530

@workglow/bun-webview

npm i https://pkg.pr.new/@workglow/bun-webview@530

@workglow/cactus

npm i https://pkg.pr.new/@workglow/cactus@530

@workglow/chrome-ai

npm i https://pkg.pr.new/@workglow/chrome-ai@530

@workglow/electron

npm i https://pkg.pr.new/@workglow/electron@530

@workglow/google-gemini

npm i https://pkg.pr.new/@workglow/google-gemini@530

@workglow/huggingface-inference

npm i https://pkg.pr.new/@workglow/huggingface-inference@530

@workglow/huggingface-transformers

npm i https://pkg.pr.new/@workglow/huggingface-transformers@530

@workglow/node-llama-cpp

npm i https://pkg.pr.new/@workglow/node-llama-cpp@530

@workglow/ollama

npm i https://pkg.pr.new/@workglow/ollama@530

@workglow/openai

npm i https://pkg.pr.new/@workglow/openai@530

@workglow/playwright

npm i https://pkg.pr.new/@workglow/playwright@530

@workglow/postgres

npm i https://pkg.pr.new/@workglow/postgres@530

@workglow/sqlite

npm i https://pkg.pr.new/@workglow/sqlite@530

@workglow/supabase

npm i https://pkg.pr.new/@workglow/supabase@530

@workglow/tf-mediapipe

npm i https://pkg.pr.new/@workglow/tf-mediapipe@530

commit: 043057c

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds asset integrity verification and path-safety hardening to the Cactus (needle-rs) provider so locally executed model assets are validated against catalog-pinned SHA-256 digests (with size pre-check support) across Node filesystem and browser Cache Storage.

Changes:

  • Introduces Cactus_Integrity utilities (sha256Hex, verifySha256, CactusIntegrityError) and integrates verification into browser/node asset fetch paths.
  • Extends the model catalog to describe assets as { filename, sha256, size } specs and validates non-placeholder hashes at module load.
  • Adds defense-in-depth path allowlists/containment checks and updates download flows to use the new asset spec shape.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
providers/cactus/src/ai/common/Cactus_Runtime.ts Node+shared runtime: path containment sanitizers; verify size/hash before cache writes and atomic rename; supports asset specs.
providers/cactus/src/ai/common/Cactus_Runtime.browser.ts Browser runtime: verifies cached/network assets before persisting to Cache Storage; supports asset specs.
providers/cactus/src/ai/common/Cactus_ModelCatalog.ts Catalog schema changed to per-asset specs with SHA-256 + size; adds module-load hash validation and assetSpecsOf.
providers/cactus/src/ai/common/Cactus_Integrity.ts New integrity module implementing SHA-256 hashing, verification, placeholder bypass warning, and error type.
providers/cactus/src/ai/common/Cactus_Download.ts Updates download run-fn to iterate asset specs and surface integrity errors via phase events.
providers/cactus/src/ai/common/Cactus_Download.browser.ts Same as node variant for browser bundle.
providers/cactus/src/ai/common/tests/Cactus_Integrity.test.ts Adds unit tests for hashing/verification behavior and error shape.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

)
: path.resolve(models_dir);
const resolvedDir = path.resolve(safeRoot, model_id);
if (resolvedDir !== safeRoot && !resolvedDir.startsWith(safeRoot + path.sep)) {
if (hit) {
return new Uint8Array(await hit.arrayBuffer());
const bytes = new Uint8Array(await hit.arrayBuffer());
try {
Comment on lines +307 to +321
try {
await verifySha256(bytes, spec.sha256, { url: `file:${filePath}`, filename: spec.filename });
return bytes;
} catch (err) {
if (err instanceof CactusIntegrityError) {
// On-disk asset is corrupt; evict and fall through to network.
const unlinkPath = path.resolve(resolvedDir, spec.filename);
if (unlinkPath !== resolvedDir && !unlinkPath.startsWith(resolvedDir + path.sep)) {
throw new Error(
`Path escape detected: ${JSON.stringify(unlinkPath)} is not within ${JSON.stringify(resolvedDir)}`
);
}
await fs.unlink(unlinkPath).catch(() => {});
} else {
throw err;
Comment on lines +354 to +361
try {
const writeTarget = path.resolve(resolvedDir, `${spec.filename}.tmp`);
if (writeTarget !== resolvedDir && !writeTarget.startsWith(resolvedDir + path.sep)) {
throw new Error(
`Path escape detected: ${JSON.stringify(writeTarget)} is not within ${JSON.stringify(resolvedDir)}`
);
}
await fs.writeFile(writeTarget, bytes);
Comment on lines +137 to +148
try {
await verifySha256(bytes, spec.sha256, { url, filename: spec.filename });
return bytes;
} catch (err) {
if (err instanceof CactusIntegrityError) {
try {
await cache.delete(url);
} catch {
/* best effort */
}
} else {
throw err;
Comment on lines +1 to +14
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from "vitest";
import {
CACTUS_HASH_PLACEHOLDER,
CactusIntegrityError,
isHashPlaceholder,
sha256Hex,
verifySha256,
} from "../Cactus_Integrity";
Comment on lines 37 to 47
@@ -18,13 +41,30 @@ export interface CactusCatalogEntry {
readonly hf_repo: string;
readonly revision: string;
readonly assets: {
readonly weights: string;
readonly vocab: string;
readonly config: string;
readonly weights: CactusAssetSpec;
readonly vocab: CactusAssetSpec;
readonly config: CactusAssetSpec;
};
sroussey added 3 commits May 22, 2026 15:03
…eQL-canonical)

The previous `startsWith(root + path.sep)` shape breaks when `root` is a
filesystem root ("/" on POSIX or a drive root like "C:\" on Windows):
the concatenated separator produces "//" / "C:\\" that no legitimate
child can match. Switch every inline check to the canonical
`path.relative` pattern, which is root-safe and the shape CodeQL's
`js/path-injection` query recognizes natively.
Both network paths already byte-length-check `spec.size` before calling
`verifySha256`. The Node disk-read cache-hit path skipped it. Adding the
pre-check inside the existing try-block lets the existing catch branch
(which unlinks the on-disk asset and falls through to refetch) handle
size and hash mismatches uniformly.
The browser network path already byte-length-checks `spec.size` before
calling `verifySha256`. The Cache Storage hit path skipped it. Adding
the pre-check inside the existing try-block lets the existing catch
branch (which deletes the cache entry and falls through to refetch)
handle size and hash mismatches uniformly.
);
}
}
const buf = await fs.readFile(readPath);
sroussey added 6 commits May 22, 2026 15:09
`assertSafeFilename` previously allowed up to 255 chars, but the Node
atomic-write path writes to `${filename}.tmp` before renaming. A 252+
char filename would exceed the 255-byte per-component limit on common
filesystems (ext4, APFS, NTFS) when the `.tmp` suffix is appended.

Tighten the cap to 251 in both Cactus_Runtime.ts and
Cactus_Runtime.browser.ts so the browser-validated filename always
round-trips through the Node atomic-write path.
Apply the same `MAX_FILENAME_LEN = 251` cap as Cactus_Runtime.ts so a
filename that validates in the browser also validates on Node — the
Node atomic-write path appends `.tmp` (4 chars) and would otherwise
exceed the 255-byte per-component limit on common filesystems.
Co-locates the integrity unit test with the other ai-provider-cactus
tests under `packages/test/src/test/ai-provider-cactus/` so it runs in
the same vitest project that the CI pipeline already invokes. The
provider package itself does not run vitest in CI, so the test was
otherwise unreachable.
Routes through `_testOnly` because `Cactus_Integrity` symbols are
internal helpers — exposing them publicly would suggest they are part
of the stable API surface, which they are not. Consumers should rely
on the catalog's `sha256` field and the runtime's automatic
verification.
The integrity helpers (sha256Hex, verifySha256, CactusIntegrityError,
isHashPlaceholder, CACTUS_HASH_PLACEHOLDER) are pure/stateless and the
unit test in packages/test/ needs to import them. Routing through
`_testOnly` (rather than a public re-export) signals that they are
internal — consumers should rely on the catalog's `sha256` field and
the runtime's automatic verification.
Mirror the Node variant: re-export the integrity helpers via `_testOnly`
on the browser entry point so the test can import them from either
target. Pure/stateless helpers, internal only.
@github-actions
Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 62.43% 23738 / 38018
🔵 Statements 62.31% 24567 / 39422
🔵 Functions 63.56% 4517 / 7106
🔵 Branches 50.85% 11542 / 22698
File CoverageNo changed files found.
Generated in workflow #2406 for commit 043057c by the Vitest Coverage Report Action

@sroussey sroussey merged commit 759baba into main May 22, 2026
15 of 16 checks passed
@sroussey sroussey deleted the claude/sweet-edison-NSph2-cactus-integrity branch May 22, 2026 22:48
sroussey added a commit that referenced this pull request May 23, 2026
* fix(cactus): catch only ENOENT in fetchAssetBytesNode

Previously the outer try/catch around the cache-read path swallowed every
filesystem error (EACCES, EIO, EISDIR, EMFILE, ...) and silently fell through
to the network refetch. That masked real misconfigurations as innocuous
cache misses, hiding the underlying cause from operators and forcing
unnecessary HuggingFace fetches.

The catch block now distinguishes:
  - CactusIntegrityError re-throws (unreachable; handled inside),
  - ENOENT falls through (the legitimate cache-miss case),
  - any other code wraps the original error with a descriptive message
    and `cause` so callers can see what actually went wrong.

Regression test in Cactus_Runtime.node.test.ts covers both branches by
driving the production code through real fs state — ENOENT via an empty
temp dir, and a non-ENOENT (EISDIR) by making the asset path a directory.

* fix(cactus): validate model_id inside fetchAssetBytesBrowser

`fetchAssetBytes` (the public entry) already called `assertSafeModelId` once
before delegating, but the helper `fetchAssetBytesBrowser` did not — so a
future refactor that hits the helper from a different code path could
slip a hostile model_id past the allowlist. The Node variant
`fetchAssetBytesNode` already re-validates at the call site; this brings
the browser helper to parity.

The helper now takes `model_id` as an explicit parameter and asserts it
as the first statement, matching the Node helper's pattern. The single
call site in `fetchAssetBytes` threads `model_id` through.

* fix(chrome-ai): touch chat session on each delta to defer idle eviction

The unified session store evicts idle entries after 30 minutes
(WEB_BROWSER_SESSION_IDLE_MS). Before this fix, a single long-running
chat turn whose stream exceeded the idle window would have its cached
session destroyed mid-stream by the idle timer, even though the model
was actively producing output. The next turn would then look up a
now-missing session.

The fix wires `touchWebBrowserSession(sessionId)` into the chat run-fn's
`trackingEmit` helper alongside the existing `deltaEmitted` bookkeeping,
so every `text-delta` event resets the idle clock. Idle eviction still
fires after 30 minutes of true silence (no further deltas), so the
mechanism is preserved — only active streams defer it.

Regression test in WebBrowser_Chat.idleTouch.test.ts: pre-populates the
cache, drives a controllable ReadableStream through two chunks separated
by 25 simulated minutes (50 min total elapsed), and asserts the session
is still cached. A second test confirms post-stream silence still evicts
at the 30-min mark.

* fix(cactus): add hash-catalog script + production placeholder guard

Three changes that close the loop on the SHA-256 integrity machinery
that landed in #530:

1. New `providers/cactus/scripts/hash-catalog.ts` (Bun) fetches every
   asset URL referenced by `CACTUS_CATALOG`, computes `sha256Hex` and
   byte length, prints a JSON report, and rewrites the catalog file in
   place when `--write` is passed. Wired as `bun run hash-catalog` in
   the cactus package. Re-running after a `revision` bump regenerates
   everything; the matcher is conservative and only replaces
   placeholder blocks so partially-populated catalogs are safe.

2. `Cactus_ModelCatalog.ts` gains a production guard: when
   `NODE_ENV === "production"` or `CACTUS_REQUIRE_REAL_HASHES === "1"`,
   module load throws if any asset still has the placeholder sha256
   or a non-positive size. Dev/test stays permissive (no env var) so
   contributors can iterate against `TODO_FILL_AT_RELEASE`. Exposes
   `CATALOG_HAS_PLACEHOLDERS` so release tooling can opt-in to the
   same check.

3. Catalog values populated with real hashes + sizes obtained by
   running the script against the live HuggingFace asset URLs:
     - needle.safetensors  (22,259,039 bytes)
     - vocab.txt           (122,132 bytes)
     - config.json         (320 bytes)
   With real sizes now in place, the network-fallthrough assertion in
   `Cactus_Runtime.node.test.ts` is tightened to assert via the
   downstream integrity rejection (which can only fire if fetch ran),
   keeping the ENOENT-branch coverage intact.

Tests in `Cactus_ModelCatalog.test.ts` verify both env-var-gated
states by spawning a child Bun process with and without
CACTUS_REQUIRE_REAL_HASHES set.

* fix(test): cast WebBrowser_Chat input to AiChatProviderInput in idleTouch test

The run-fn only reads input.messages (and optionally input.temperature) at
runtime, but AiChatProviderInput requires model+prompt at the schema layer,
which the production dispatcher provides but is irrelevant for this unit test.
Match the dispatcher convention (any-typed input) via an explicit cast so the
test compiles under bun run build:types.

Unblocks CI build/CodeQL/publish-preview/vitest jobs that were skipping after
the build job failed on this TS2345.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants