Skip to content

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

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.

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 UTF8
Entropy: 3.89

That 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 to
cyberchef_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 B
hex 2 candidates 5,348 B -> 870 B
gzip+base64 6 candidates 16,033 B -> 2,583 B

The 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 4th
a representative French sample (100 B) -> English
the same French sample (40 B and 160 B) -> French, correctly

A 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.

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 parse

A “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.

The schema advertised four arguments and explained none:

depth: number
intensive_mode: boolean
extensive_language_support: boolean
crib_known_plaintext_string_or_regex: string

crib 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.

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.

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.

npm run lint clean
npm run test:mcp 1373 passed (53 files)
npm test 241 Node-API + 2289 operations passing
npm run test:coverage 95.62 stmts / 90.03 branches / 96.59 funcs / 96.40 lines -- gate passes
round trip every recipe Magic recommends is executed through bake, and the best
candidate reproduces the advertised plaintext
image path Generate QR Code still returns an image block, asserted alongside the fix