v2.9.0 release notes
Release Date: 2026-09-02
Upstream Base: CyberChef v11.4.0 (unchanged)
Licence: GPL-3.0-or-later
Node: >=24 <27
Highlights
Section titled “Highlights”Results now come back as something a person can read and a model can act on.
Three defects, all in the same place: what this server hands back when the answer is not a plain
string. The headline one is that Magic recommended recipes that cyberchef_bake refused to
run, and the one found while checking whether that was a one-off is that JSON Beautify
returned invalid JSON.
Magic recommended recipes that could not be run
Section titled “Magic recommended recipes that could not be run”Magic is the operation an assistant reaches for first when handed an unknown blob, and it was the
one whose output an assistant could least act on. This is what a client received from the published
v2.8.1 image, verbatim:
Recipe (click to load) Result snippet Properties From_Base64('A-Za-z0-9+/=',true,false) The quick brown fox Valid UTF8Entropy: 3.89That is the web app’s results table with its markup stripped. “Recipe (click to load)” is a UI affordance with no meaning over MCP; the fields run together; nothing says which candidate is best.
The serious part is the recipe. It is rendered in CyberChef’s display form, and bake rejects it:
Couldn't find an operation with name 'From_Base64('A-Za-z0-9+/=',true,false)'.So the single most actionable field Magic produced was the one field a caller could not use. Acting
on Magic’s own suggestion meant reverse-engineering that string back into
[{op: "From Base64", args: [...]}] — guessing that underscores become spaces and that the quoted
arguments map positionally. A hallucination invitation sitting in the happy path.
Now:
Input: 88 bytes | entropy 5.08 (high, suggesting encrypted, compressed or random data) | valid UTF-8
3 candidate decodings, most likely first:
1. From Base64 then Gunzip Result: Meet me at the docks at midnight. Bring the documents. Signals: estimated language German (or English) | valid UTF-8 | entropy 3.91 Recipe: [{"op":"From Base64","args":["A-Za-z0-9+/=",true,false]},{"op":"Gunzip","args":[]}]
2. From Base64 Result: \x1f\x8b\x08\x00... Signals: file type application/gzip (.gz) | not valid UTF-8 | entropy 5.12 | further operations match: Gunzip Recipe: [{"op":"From Base64","args":["A-Za-z0-9+/=",true,false]}]
To reproduce any of these, pass its Recipe value unchanged as the `recipe` argument tocyberchef_bake, with the same input.The same facts also arrive as structuredContent, built from one value so the two halves cannot
disagree. The round trip is now a test: every recommended recipe is fed back to bake, and the
best one must reproduce the plaintext the report advertised.
Dropping languageScores — 39 entries per candidate, almost all with probability: 0 — is most of
the size difference:
base64 text 2 candidates 5,007 B -> 539 Bhex 2 candidates 5,348 B -> 870 Bgzip+base64 6 candidates 16,033 B -> 2,583 BThe language estimate was confidently wrong
Section titled “The language estimate was confidently wrong”Worth stating plainly, because the first implementation of the above looked right and was not.
detectLanguage compares byte frequencies with chi-squared. Its scores are always populated and
always sorted, so languageScores[0] names a language even for a PNG. The web presenter shows one
when probability > 0, and copying that gate is what the first version did. Measured:
"Attack at dawn" (14 B, English) -> German, probability 1.35e-8"just some ordinary words here" (29 B, English) -> Polish, probability 1, English 4tha representative French sample (100 B) -> Englishthe same French sample (40 B and 160 B) -> French, correctlyA probability of 1.35e-8 passes > 0, so the report stated “language German” for English text.
In a plain-text report an assistant relays verbatim, that reads as a determination.
Accuracy does not rise monotonically with length, so a length cutoff would throw away correct answers while still admitting wrong ones. The fix is wording, not thresholding: the report says “estimated language German (or English, Dutch)”, carries the runners-up, and closes by saying the estimate is sometimes wrong and the decoded text should be read before repeating it.
JSON Beautify returned invalid JSON
Section titled “JSON Beautify returned invalid JSON”Found while checking whether Magic was one operation’s problem or a class. It is a class.
44 operations declare a presentType different from their outputType. The server asks the engine
for the presented form, which is right for the ones whose payload lives only in the markup —
Generate QR Code emits <img src="data:image/png;base64,..."> and the bytes exist nowhere else.
For the rest, the presentation is a browser artefact that does not survive being reduced to text.
JSON Beautify renders a key as bare text inside <li>name<span class="json-colon">:</span>, so
the quotes around every key are markup structure rather than characters:
in: {"name":"alice","age":30}out: {name: "alice",age: 30} <- does not parseA “beautify” that emits unparseable output, with the indentation it exists to add also gone. Two more in the same class:
| operation | before | after |
|---|---|---|
JSON Beautify |
689 B of markup, invalid JSON | 53 B of correctly indented JSON |
Text Encoding Brute Force |
9,842 B table, headers fused to values (EncodingValueUTF-8 (65001)Hello) |
7,650 B of valid JSON |
Frequency distribution |
15,669 B opening with <canvas id='chart-area'> |
5,865 B of valid JSON |
Chef.bake already returns dish — “a raw version of the dish, unpresented” — from the same
execution, so preferring it costs no second bake. The swap happens only when the presented value
contains markup and carries no media, and it is resolved before the cache: a decision made
downstream of the cache would apply to a miss and not to a hit, which is exactly how
cyberchef_generate_qr_code once returned an image on the first call and text on every one after.
Both directions are pinned by tests. The data operations must return data; Generate QR Code must
still return an image block.
Magic’s arguments now say what they do
Section titled “Magic’s arguments now say what they do”The schema advertised four arguments and explained none:
depth: numberintensive_mode: booleanextensive_language_support: booleancrib_known_plaintext_string_or_regex: stringcrib is the most useful argument the operation has — a regex a decoding must match, which cuts
the candidate list down to decodings that actually produce plaintext you already know part of —
and nothing in the schema said so. depth gave no clue whether 1 or 10 is sensible.
The tempting implementation was a parser: CyberChef documents arguments inside the operation
description as <u>Depth:</u> .... Measured before building it, 7 of 504 operations use that
convention, matching 14 arguments — a fragile HTML parser over prose upstream can reword at any
time, for fourteen descriptions, that would still have missed the crib (documented as running
prose, not under a heading). So the descriptions are written, in a curated table keyed by operation
and argument.
This is charged on every tools/list, since Magic is the one operation always exposed:
cyberchef_magic 1,354 B of a 20,289 B index 6.7%Worth it for the operation most likely to be called first, and stated here as a number rather than
an adjective — the comment in tool-surface.mjs that called Magic’s schema “a few hundred bytes”
was true when written and was made false by this change, so it now carries the measurement.
What this costs
Section titled “What this costs”Index of Coincidence loses its explanatory prose, dropping from 1,072 B to the bare number
0.021848739495798318. The prose was static boilerplate wrapped around a <canvas> element the
client cannot draw. The number is the answer, and an assistant can explain what it means; reporting
it rather than burying it is the point of listing this here.
A caller who was parsing the old stripped-table output will need to change. That output was not
a documented format and could not be parsed reliably in the first place — the fused
EncodingValueUTF-8 (65001)Hello has no delimiter anywhere — but it did exist, and code depending
on it will see valid JSON instead.
A concurrency bug found while chasing a flaky test
Section titled “A concurrency bug found while chasing a flaky test”RecipeStorage.save() runs an optimistic generation check with a window between reading the
on-disk generation and committing the rename. Two overlapping saves in one process both read the
same generation, and whichever commits last fails with a message blaming “another process” –
which is not what happened. Two concurrent cyberchef_recipe_create calls are exactly that shape,
so this was reachable in ordinary use, not only in tests.
Saves are now serialised per instance. The generation check stays, guarding the multi-replica case it was written for; this only guarantees the process is never the one racing itself.
Stated precisely, because the first version of this claim was too strong: the window was observed once and is not reliably reproducible – re-running the same probe with the serialisation removed completed eight concurrent creates cleanly. The fix is justified by construction, not by a failing test, and the code comment says so.
Upgrading
Section titled “Upgrading”No configuration changes. No new environment variables, no new dependencies, no protocol change.
If you call cyberchef_magic, its result is now a readable report plus structuredContent; the
recipes it recommends can be passed straight to cyberchef_bake. If you call cyberchef_json_beautify,
cyberchef_text_encoding_brute_force or cyberchef_frequency_distribution, you now get their real
output instead of stripped browser markup.
Verification
Section titled “Verification”npm run lint cleannpm run test:mcp 1373 passed (53 files)npm test 241 Node-API + 2289 operations passingnpm run test:coverage 95.62 stmts / 90.03 branches / 96.59 funcs / 96.40 lines -- gate passesround trip every recipe Magic recommends is executed through bake, and the best candidate reproduces the advertised plaintextimage path Generate QR Code still returns an image block, asserted alongside the fix