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 .mjsin 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:
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 guard that the first fix lacked
Section titled “The guard that the first fix lacked”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 bytesthis tree (v3.9.0) index: 42 tools, 44493 bytesper-tool byte maps IDENTICALThe 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 0Against 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 pinactions/setup-node v7 x10 v6 x1 v5 x2Dependabot 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 3d3c42e5aac5ba805825da76410c181273ba90b1tags/v7 resolves 3d3c42e5aac5ba805825da76410c181273ba90b1tags/v7.0.1 resolves 3d3c42e5aac5ba805825da76410c181273ba90b1Exactly 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.
Base image
Section titled “Base image”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.
Nothing else moved
Section titled “Nothing else moved”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 unchangedserver.json schema 2025-12-11 still newestMCP specification 2026-07-28 still CurrentThe 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.
Verification
Section titled “Verification”npm run lint zero errorsnpm run check:versions every location agreesnpm run check:server-json validates against 2025-12-11npm test 241/241 Node API, 2289/2289 operationsnpm run test:mcp 1714/1714 across 74 filesnpm run test:coverage exit 0 -- 96.56 / 89.61 / 96.6 / 95.53Surfaces measured, not recorded: 42 tools / 44,493 bytes index, 120 / 107,739 curated, 545 / 424,897 all.
Upgrading
Section titled “Upgrading”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.