Skip to content

v2.8.0 release notes

Release Date: 2026-09-02 Upstream Base: CyberChef v11.4.0 (unchanged) Licence: GPL-3.0-or-later Node: >=24 <27

The server now runs on ARM, ships 30% smaller, and can be told to fail closed on an air-gapped host. Opening Phase 6.

Half of what the plan asked for was already done, and finding that out is most of what shaped this release.

The plan described a server that no longer exists

Section titled “The plan described a server that no longer exists”

release-v2.8.0.md was last updated in December 2025. Measured against the code:

Plan says “currently” Plan target Measured
Cold start ~3s <1s 158 ms
Memory idle ~150MB <100MB 96 MB RSS
Lazy loading: to build shipped in v2.6.0
Startup optimization: to build shipped in v2.6.0
Image ~200MB <50MB 643 MB

Three of eight features were delivered by v2.6.0, and the cold-start target is beaten six times over before this release starts — v2.6.0’s deferred operation barrel is the “OperationLoader” the plan sketches. Its image-size baseline is wrong by 3.4x, which matters because the target was set against it.

643 MB → 453 MB, and 1,190 packages → 432

Section titled “643 MB → 453 MB, and 1,190 packages → 432”

Dockerfile.mcp ran npm ci (everything) and then rm -rf on a hardcoded list of nine package globs. The tree has 1,310 paths, of which 885 are dev-only:

typescript 24 MB · @rolldown 19 MB · @octokit 18 MB
@codecov/bundler-plugin-core 14 MB · @babel 14 MB · lightningcss 10 MB

A hardcoded list cannot keep pace with a tree that size. The v2.0.0 plan said exactly this — “replace that list with npm prune --omit=dev — and it never landed; the list stayed and the tree grew around it. It is a security result as much as a size one: every dev dependency in the runtime image is attack surface and a standing Trivy finding.

Verified by running the full suite against production-only dependencies, not by a smoke test. A four-path check cannot prove a prune is safe across 504 operations:

241 Node-API tests PASSING
2289 operation tests PASSING

A local build shipped 240 MB of Docusaurus. .dockerignore had node_modules, which Docker matches only from the context root — so docs-site/node_modules was copied in whole. CI never saw it because CI checks out clean, which means a local build and a CI build produced materially different images and nothing reported the difference.

And the developer’s saved recipes. recipes.json and recipes.json.backup — the recipe store — were sitting in /app. Anyone building locally baked their own saved recipes into a layer and would have published them by pushing it. Mode 0600 stops mattering once a file is inside an image.

Images are now published for linux/arm64 alongside linux/amd64: Apple Silicon, AWS Graviton, Raspberry Pi 4/5. docker pull resolves the right one.

I argued for restructuring the release workflow into a native-runner matrix, on the grounds that emulating a 1,300-package install would take 30–60 minutes. That estimate was wrong by an order of magnitude:

linux/arm64 under QEMU: 4m 46s
linux/amd64 native: ~4m 30s

The reason is the prune. The production tree is pure JavaScript and WebAssembly, so almost nothing compiles under emulation — and before the prune it carried @rolldown/binding-linux-x64-gnu and lightningcss-linux-x64-gnu, which would not have built for arm64 at all. The prune and arm64 support turned out to be the same work.

At 4m46s a three-job matrix with a digest hand-off is not worth its complexity, so the workflow keeps its single job and the number is recorded in it for whoever re-opens the question.

The check that matters is not “it built”. @napi-rs/nice (via piscina) ships its platform binaries as optional dependencies, so an install that silently resolved the wrong one still produces a working image that fails later, in the worker pool, on someone else’s machine. So the verification asserts the binding:

/app/node_modules/@napi-rs/nice-linux-arm64-gnu

A native-arm job now proves this on every pull request, so the release workflow is never the first place arm64 is attempted — published tags are immutable, and a tag-time failure is the expensive one. The release also fails if the published manifest is not multi-platform: a build that silently degrades to one platform still produces a green workflow and a published release, and breaks only when an arm64 user pulls it.

linux/arm/v7 is not published. cgr.dev/chainguard/node publishes amd64 and arm64 and nothing else, so a Raspberry Pi 3 would mean changing base image entirely — giving up the distroless runtime, digest pinning and non-root default. Reported rather than quietly dropped.

Terminal window
CYBERCHEF_OFFLINE=true

Most of this already worked. 502 of the 504 operations are pure functions over bytes. The plan’s list of network dependencies is mostly wrong: there is no plugin loader (ADR 0002), the server exports no telemetry of its own (it depends on the OpenTelemetry API only), and JWKS discovery was bounded in v2.6.0.

Exactly two operations leave the process — HTTP request and DNS over HTTPS — and without a switch they do not fail cleanly on an air-gapped host. They hang until the OS gives up on an unroutable connection, holding a concurrency slot throughout. The switch turns that into an immediate refusal naming the operation.

The guard is on the recipe, not the tool name. cyberchef_bake is not a network tool, and bake({recipe: [{op: "HTTP request"}]}) is a network call. That is the v2.5.0 authorisation bypass exactly: the scope check sat below the meta-tool branches, every unit test passed, and the guard was simply never reached.

There is also no single choke point — bakeOnCore, executeInWorker and the Node API’s bake are three separate entries — so the check is applied at all four sites the call graph reaches, the direct-operation guard sits above the worker/streaming split, and the suite walks each path by name.

It is a posture, not a sandbox. It refuses operations the server knows to be networked; it cannot stop a process opening a socket. Use it and a NetworkPolicy — the switch gives the caller a clear error, the namespace makes it true.

<50 MB is not reachable. @jimp (89 MB) and tesseract.js-core (44 MB) are production dependencies of real operations — image manipulation and OCR. A server exposing 504 operations including those cannot be a 50 MB image. 30% was what was available without dropping capability, and it was taken.

19 MB of web-app leftovers, measured and deferred. Five production dependencies have zero imports anywhere in src/bootstrap, jquery, bootstrap-material-design, bootstrap-colorpicker, snackbarjs — inherited from upstream’s web app, stripped here in v1.7.1. Removing them is not one line: scripts/patch-dependencies.mjs still patches snackbarjs, and editing the dependency manifest of a fork whose upstream declares them invites a sync conflict. Checked whether it was a security question, which would have changed the answer: npm audit --omit=dev reports four low-severity advisories, none on these five, and zero open Dependabot alerts. 4.2% for a multi-file change across the fork boundary, in a release that already took 30%.

Resource profiles would be config theatre. Two of the five fields in the plan’s schema describe things that do not exist — lazyLoading has been unconditional since v2.6.0, and there is no per-deployment operation limit. The other three are already environment variables. The real question behind it, “what should I set on a small box”, is answered as a table in the edge deployment guide rather than as a new configuration layer.

Variable Default Meaning
CYBERCHEF_OFFLINE false Refuse HTTP request and DNS over HTTPS

Nothing changes unless you opt in. CYBERCHEF_OFFLINE is off by default and every operation behaves as before. The image is smaller and now resolves for arm64; if you pin by digest, the digest changed.

One thing to know if you build the image yourself: .dockerignore now excludes docs-site, deploy, examples, patches, images and any nested node_modules. If you were relying on one of those being present in /app, it is not any more — none is read by the server.

npm run lint clean
npm run test:mcp 1338 passed (51 files)
npm test 241 Node-API + 2289 operations passing
npm run test:coverage 95.85 stmts · 90.19 branches · 96.66 funcs · 96.67 lines — gate passes
actionlint clean on both modified workflows
helm lint clean
prune verification full suite re-run against production-only dependencies
arm64 built, served 28 tools, executed a recipe, resolved the arm64 binding

Four mutations verified on the offline guard alone, including moving it below the worker/streaming split — which is the v2.5.0 failure shape, and the one a reviewer would not spot by reading.