Skip to content

v3.9.0 release notes

There is no v3.9.0 charter. docs/planning/v3/charters/ holds five files and none of them names this version, so RE-MEASURE.md step 6 applies — “if the charter is empty, say so and pick different work” — and the scope was derived from measurement instead: the carried-forward list, the open issue tracker, the open Dependabot pull requests, and a full dependency and toolchain sweep.

What that turned up is a release about checks that were reporting something other than what they said. Three of them, found independently, each green the entire time.


A monitor that could never say “up to date”

Section titled “A monitor that could never say “up to date””

upstream-monitor.yml opened #129 on 2026-09-06: “New CyberChef release v11.4.0 available”, listing one new operation and thirty modified ones.

This repository was already on v11.4.0. The operation sets are identical:

upstream v11.4.0: 505 .mjs local: 506 .mjs
in local, not upstream: index.mjs (generated by grunt, gitignored)
in upstream, not local: (none)

And ParsePGPKey.mjs, the “new operation”, is not in the v11.4.0 tag at all — the API returns 404 for it at that ref. It exists on upstream master, unreleased.

The workflow reads two different refs and prints them as one. The clone tracks master (git pull origin master), the headline comes from git describe --tags, and the file comparison runs against the clone’s working tree — which is master. So the title names a release while the numbers underneath describe unreleased work. There is a real difference; it is just not the difference the issue claims. A maintainer acting on it would start a sync with nothing to sync.

That was the visible defect. The one underneath it is worse:

Terminal window
CYBERCHEF_VERSION=$(node -p "require('./package.json').version") # 3.8.0 -- this fork's semver
...
CURRENT="v${CYBERCHEF_VERSION}" # "v3.8.0"
if [ "$LATEST" != "$CURRENT" ]; then # "v11.4.0" != "v3.8.0"

package.json has carried two versions since v2.2.0: version (this fork) and cyberchefUpstreamVersion (the upstream base). The monitor read the wrong one, so the comparison was this fork’s semver against an upstream tag and could never be equal. new_version=true on every run, forever — the Already up-to-date branch is in the file and has never executed once. The same field also produced every issue’s “next steps” link, pointing at gchq/CyberChef/compare/v3.8.0...v11.4.0, a ref that does not exist.

Both fixed. The comparison is now pinned to the tag the headline names, master drift is reported separately as its own measurable thing, and a drift-only finding gets a different title from a new-release finding.


A ranking that put the answer below the box it came in

Section titled “A ranking that put the answer below the box it came in”

#130, found by exercising the published 3.8.0 image. A three-layer payload — Gzip, To Base64, To Hex — returned the fully decoded plaintext at rank 3, beneath raw gzip bytes at rank 1.

Every term of upstream’s comparator, recomputed:

Recipe lang score after clamps +len +entropy final
From Hex -> From Base64 421,308.72 500 (gzip clamp) 2 6.62 508.62
From Hex 4,853.05 4,753.05 1 5.79 4,759.84
From Hex -> From Base64 -> Gunzip 5,575.92 5,475.92 3 4.52 5,483.44

if (a.fileType && aScore > 500) aScore = 500 collapses a 421,308 score to 500, so any detected container outranks its own decoded contents — and worse the deeper the nesting, since deeper nesting means more intermediate containers. Separately, the chi-squared scorer rated the base64 string as more language-like than the actual English.

Recipe length is not the cause, though it looks like it. It contributes 3 against 1 — two points of a 723-point gap — and entropy favours the correct answer. That wrong cause was asserted first, off a plausible-looking aScore += a.recipe.length line, and the arithmetic above is what disproved it. Reading a mechanism off a line that fits the symptom is not the same as measuring it.

src/core/lib/Magic.mjs is mirrored from upstream verbatim and no fork patch touches it, so the fix is in the MCP layer: promoteDecodings lifts a UTF-8 result above a container whose recipe it extends, and does nothing else.

The first version promoted any UTF-8-producing extension. On the single-byte-XOR payload — which ranked correctly before the change — that handed rank 1 to From Base64 -> XOR -> From Hexdump, returning the single character "X" at entropy 0. A fix for a ranking bug had introduced a ranking bug pointing the other way.

Anchoring on the container is what makes the promotion mean “you decoded the container” rather than “you ran one more operation”. Requiring a positive language probability instead was checked and would also have been wrong: the correct answer in the issue’s own payload scores no language at all, so that test would suppress the exact promotion the fix exists to make.

Three regression tests, including the order-preserving claim. Verified by disabling the re-rank.


The surface figures were wrong when v3.8.0 shipped

Section titled “The surface figures were wrong when v3.8.0 shipped”

The index moved from 44,406 to 44,493 bytes this release, +87 across all three surfaces. The obvious reading is that v3.9.0 changed something.

It did not. A/B against the published v3.8.0 image, through a real MCP client:

ghcr.io/doublegate/cyberchef-mcp_v3:3.8.0 index: 42 tools, 44493 bytes
this tree (v3.9.0) index: 42 tools, 44493 bytes
per-tool byte maps IDENTICAL

The shipped v3.8.0 artefact serves 44,493. Its release notes say “Surfaces re-measured: 42 tools / 44,406 bytes”. The figure was already wrong at the tag — measured during development, changed before the tag, never re-measured. Which is verbatim what the AGENTS.md row warns about, in the row’s own words: “recorded during its development and never re-measured before the tag, which is this row’s own warning happening to this row.” It happened to that row again, one release later.

The gate could not catch it, and had said so about itself. Four tests compared every document to the canonical table in tool-catalog.mjs — establishing agreement, not truth. When the table is stale, every document agrees with a stale number and all four pass. registry-tool-docs.test.mjs wrote the warning in v3.8.0: “three documents can be consistently stale and it passes.” The thing it warned about was already true when it was written.

A fifth test now measures. Real client, three surfaces, exact comparison against the table — no tolerance, because these are deterministic serialisations and a tolerance is where this drift would accumulate next time.


Dependencies: what moved, and the three that deliberately did not

Section titled “Dependencies: what moved, and the three that deliberately did not”

Every package that can go to its latest compatible version has. What remains outstanding is exactly the set that should be.

Taken: @noble/hashes 2.3.0 → 2.4.0 and otpauth 9.5.1 → 9.5.2 — both of which are alignment with upstream rather than independent drift, since upstream moved to the same versions. Plus a lockfile refresh carrying @codemirror/state, @codemirror/view, autoprefixer, cspell, dompurify, eslint, eslint-plugin-jsdoc, postcss, tinybench, webpack-bundle-analyzer and zod to their latest in-range releases.

Held, with each hold re-verified rather than assumed. .github/dependabot.yml ignores three majors and states the re-evaluation trigger as “re-evaluate when upstream moves”. Upstream has not moved:

Package Here Upstream master Why
@xmldom/xmldom ^0.8.15 ^0.8.15 0.9 was tried: CSS selector returns 0 matches and XPath expression 1 of 2 through the real pipeline. Behavioural, not a rename.
geodesy 1.1.3 1.1.3 v2’s LatLon subclasses do not compose; ConvertCoordinates.mjs needs toUtm and toOsGrid on one object and v2 splits them across modules.
jq-web ^0.5.1 ^0.5.1 0.6’s module namespace exports then, making it a thenable — await import("jq-web") throws before any operation runs. Broke 10 test files.

Taking any of them means a fork patch against upstream-owned code that upstream keeps changing, which is how the SafeRegex mitigation was lost for four releases.

vitest 5, taken seven days into a fourteen-day cooldown

Section titled “vitest 5, taken seven days into a fourteen-day cooldown”

vitest and @vitest/coverage-v8 reached 5.0.0 on 2026-09-03. There is no Dependabot PR for it because this project sets semver-major-days: 14, reasoning that “nothing here is urgent enough to justify being the first to find out.”

Taking it early needs a better argument than “latest”, and the argument is that the cooldown exists to avoid being the first to find out — and running the full suite is finding out. That is cheap and immediate for a test runner in a way it is not for the cryptography and parser libraries the policy was written for. The cooldown itself is unchanged.

The one breaking change that could have moved a gate is coverage.include/exclude no longer matching by “contains”. Measured:

96.56 lines / 89.61 branches / 96.6 functions / 95.53 statements exit 0

Against v3.8.0’s 96.6 / 89.5 / 96.5 / 95.5 — unchanged to within a rounding step. perFile losing glob inheritance is inert here because perFile is deliberately set nowhere; clearMocks defaulting to true broke nothing; no unawaited assertion surfaced.


Three majors of one action, in one repository

Section titled “Three majors of one action, in one repository”

The four Dependabot PRs name four actions. The sweep found the drift is wider:

actions/checkout v7 x16 v6 x3 v5 x2 + one SHA pin
actions/setup-node v7 x10 v6 x1 v5 x2

Dependabot reports the lowest version it finds and raises one PR for it, so #125 and #126 would both have merged green with helm-chart.yml and benchmark-baseline.yml still on v6. Converging an ecosystem one PR at a time is slow, and more to the point never reports done.

Everything is now on one version each, docker/setup-qemu-action is on v4 and azure/setup-helm on v5 (both Node-24 runtime bumps, no input changes), and a new gate asserts it — discovering every uses: rather than naming any action, because three hand-written file lists went stale during v3.8.0 and at the third occurrence the fix is the mechanism.

The SHA pin stays, and was verified rather than assumed. antigravity-review.yml pins actions/checkout because that job runs on a self-hosted runner:

pinned SHA 3d3c42e5aac5ba805825da76410c181273ba90b1
tags/v7 resolves 3d3c42e5aac5ba805825da76410c181273ba90b1
tags/v7.0.1 resolves 3d3c42e5aac5ba805825da76410c181273ba90b1

Exactly current. A blanket @v7 rewrite would have weakened it, which is why the sweep is per-file.

The same gate bash -n-parses all 137 shell blocks across 16 workflows. It says plainly what it does not catch: bash -n parses but does not evaluate, so it caught a broken-quoting error introduced while fixing the monitor and did not catch an over-escaped backtick in the same edit, which is valid syntax that fails at runtime. Both were verified by reintroduction.


Chainguard latest moved to Node v26.8.2; both digests are re-pinned, prose comments included — the field that was missed at v2.8.0, v2.8.1 and v2.9.0.

latest-slim is still Node v25.9.0, unchanged from where v3.8.0 found it and a full major behind what ships. Only latest, latest-dev and latest-slim are pullable anonymously; every versioned tag is behind the paid tier. The shell-free base image is declined a third consecutive release, on the same measurement, and its trigger stands as written.

There is no Node 27, so engines: ">=24 <27" still spans exactly the supported range.


Re-checked because the ritual requires it, and because the last three releases each found something here:

@modelcontextprotocol/server / node / client 2.0.0 unchanged
@modelcontextprotocol/sdk 1.30.0 unchanged
@modelcontextprotocol/conformance 0.2.0-alpha.11 unchanged
server.json schema 2025-12-11 still newest
MCP specification 2026-07-28 still Current

The schema was checked two ways rather than one — the registry’s own internal/validators/schemas/ directory tops out at 2025-12-11.json, and no later dated URL resolves. v4.0.0 stays unscheduled because it is defined by a specification revision that has not happened.


npm run lint zero errors
npm run check:versions every location agrees
npm run check:server-json validates against 2025-12-11
npm test 241/241 Node API, 2289/2289 operations
npm run test:mcp 1714/1714 across 74 files
npm run test:coverage exit 0 -- 96.56 / 89.61 / 96.6 / 95.53

Surfaces measured, not recorded: 42 tools / 44,493 bytes index, 120 / 107,739 curated, 545 / 424,897 all.

Nothing breaks. cyberchef_magic returns its candidates in a different order — a decoding is no longer ranked below the container it came out of — and every candidate that was present before is still present, because this is a re-ranking and not a filter. Nothing else in the tool surface, the schemas, the error codes or the protocol moves.