Skip to content

release: v2.0.0 — upstream v11.4.0, GPL-3.0-or-later, zero open security alerts - #83

Merged
doublegate merged 77 commits into
masterfrom
release/v2.0.0
Aug 31, 2026
Merged

release: v2.0.0 — upstream v11.4.0, GPL-3.0-or-later, zero open security alerts#83
doublegate merged 77 commits into
masterfrom
release/v2.0.0

Conversation

@doublegate

Copy link
Copy Markdown
Owner

The v2.0.0 release merge. 62 commits, 323 files, and master is a strict ancestor of this branch — no conflicts are possible.

Tagging follows from master, not from here. That is not a preference: mcp-release.yml only moves the Docker Hub latest tag when is_default_branch is true, so tagging the release branch would publish 2.0.0 while leaving the most-pulled tag on 1.9.0.


What v2.0.0 is

v1.9.0 v2.0.0
Upstream base 10.19.4 11.4.0
Operations 463 504
Tools in tools/list 483 524
Licence Apache-2.0 GPL-3.0-or-later
Node 22 >=24 <27
MCP tests 689 756
Open Dependabot alerts 194 0
Open code-scanning alerts 78 0

Closing a six-release upstream gap — and the reason it opened

The base sat at 10.19.4 while GCHQ shipped through 11.4.0. The cause was mechanical: the sync copied only src/core/operations/*.mjs, comparing flat basenames in one directory. That cannot express a major-version jump, which also changes src/core/lib/, the six upstream-owned src/node/*.mjs files and the config generators — and deletes src/core/lib/ImageManipulation.mjs. Syncing operations without lib/ orphans the library; syncing lib/ without operations breaks the build.

Rebuilt as an rsync -a --delete mirror so additions, modifications and deletions apply atomically, with fork changes as patches/fork/*.patch re-applied afterwards. A patch that stops applying fails the sync — the alarm that was missing when a ReDoS mitigation was silently reverted and stayed gone for four releases. Scope is checked against an allowlist, so anything out of scope fails the run rather than being caught only if someone thought to forbid it.

Relicensed to GPL-3.0-or-later

Uniquely determined by the reference tools v2.x admits: katana is GPL-3.0-or-later (ruling out GPLv2), John the Ripper is GPL-2.0-or-later, and upstream CyberChef is Apache-2.0 (compatible with GPLv3, not GPLv2).

Not a relicensing of GCHQ's code — upstream files keep their Apache-2.0 headers; the combined work is GPLv3, which Apache-2.0's one-way compatibility permits. v1.9.x stays Apache-2.0 through its LTS window. ADR 0001.

Security: 272 open findings → 0

Every one fixed, suppressed with a written justification, or dismissed with a reason — full disposition. Including CVE-2026-42615 (XSS, HIGH), minimatch (3 × HIGH), and an unauthenticated CWE-400 in the HTTP transport that the review of this release surfaced.

One .trivyignore entry, and it says why: elliptic has no fixed version anywhere and reaches the tree only through a webpack browser polyfill whose alias never applies on Node.

Issue #36 — Streamable HTTP serves multiple clients

One process-wide transport meant the first client worked and every one after it got Invalid Request: Server already initialized. Each session now gets its own Server and transport — the shape GHSA-345p-7cg4-v4c7 requires. Reproduced on the published _v1 image and verified fixed on the new build.

Three announced breaking changes withdrawn

DEP001/DEP007/DEP008 — the cyberchef_ prefix stays. Measured: removing it saves 2.6% of the tools/list payload while colliding 19 tool names in MCP's flat namespace and breaking every integration. Stated plainly in the migration guide rather than quietly dropped, because three planning documents had described the rename as happening.


Verification

release/v2.0.0 @ 931b96f0, all four workflows green:

Security Scan             success
CodeQL Analysis           success
MCP Server CI             success
Build and Test MCP Server success

Local, on the merge head:

npm run lint              0 errors
npm run test:mcp          756 passed (22 files)
npm test                  241 Node-API + 2,289 operation tests
npm run testnodeconsumer  green
actionlint                0 findings across 11 workflows

docker build              ok
tools/list                524 tools
image version             2.0.0
docker run --entrypoint id  uid=65532(node) gid=65532(node)
open Dependabot alerts     0
open code-scanning alerts  0

After this merges

  1. git tag -a v2.0.0 -F docs/releases/v2.0.0.md && git push origin v2.0.0from master.
  2. mcp-release.yml publishes ghcr.io/doublegate/cyberchef-mcp_v2 and Docker Hub, and moves latest.
  3. latest moving to 2.0.0 hands every unpinned user a GPL-licensed, breaking-change build. Correct for a major, and called out prominently in the release notes rather than discovered.

Still open, deliberately

#73 — five upstream defects for gchq/CyberChef, including a pooled-ArrayBuffer leak in two image operations and the @xmldom/xmldom 0.9 blocker holding back nine DoS advisories. Filing on a third-party repo is a maintainer call.

doublegate and others added 30 commits August 30, 2026 15:13
Security Scan has failed every weekly run since at least 2026-08-02. All three
jobs died at "Set up job" with:

    Unable to resolve action `aquasecurity/trivy-action@0.28.0`,
    unable to find version `0.28.0`

Aqua retagged the action to v-prefixed tags and the bare 0.28.0 tag no longer
exists (the ref 404s). Pin v0.36.0, the current release.

Also bump every action still on the Node 20 runtime, which GitHub force-migrated
to Node 24 on 2026-06-02 and which the last Docker build run warned about:
checkout v4->v7, setup-node v4->v7, upload-artifact v4->v7, github-script v7->v9,
setup-buildx v3->v4, build-push v5/v6->v7, login v3->v4, metadata v5->v6,
codecov v5->v7. codeql-action is already on v4, the current major.

Verified against each action's latest release. setup-node v5 added automatic
caching keyed on a `packageManager` field; package.json has none, so behaviour is
unchanged. checkout v7 only restricts fork checkout for pull_request_target and
workflow_run, neither of which this repo uses.

Separately, the Docker build workflow failed on its Trivy SARIF step, which ran
with exit-code 1 on CRITICAL,HIGH. A SARIF producer that exits non-zero can skip
its own upload, hiding the findings it exists to publish. Split reporting from
gating: the SARIF pass now always succeeds and still reports CRITICAL+HIGH to the
Security tab, and a separate table-format step gates the build on CRITICAL only.
The PR gate in security-scan.yml drops to CRITICAL for the same reason - with 272
findings open, gating on HIGH fails every PR in the v2.0.0 series before it can
fix anything. Both carry a TODO to restore HIGH once the backlog reaches zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
The repository had no .github/dependabot.yml, so it received only Dependabot
security updates (on by default, no config needed) and no proactive version
updates. That is how the alert backlog reached 194 with no corresponding PRs, and
how a yanked trivy-action tag sat unnoticed for months. Add npm, github-actions
and docker ecosystems on a weekly schedule, grouping minor+patch per ecosystem so
majors still get individual review. grunt* is ignored because the build toolchain
is adopted wholesale from upstream by the sync workflow.

Also install the Antigravity PR reviewer from the shared template: workflow,
review scripts, actionlint label declaration, and a project-tailored style guide
covering fork hygiene (never hand-edit src/core or generated files), MCP naming
and async-bake rules, and the per-session HTTP transport requirement.

The reviewer needs a repo-level self-hosted runner labelled 'agy' before it will
trigger; none is registered yet.

scripts/agy-review-selftest.sh passes (19 checks).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
Three tests have been failing in CI with no code change to explain it:

    PGP Decrypt and Verify: UTF8, Alice -> Bob
    PGP Decrypt: ASCII, Alice -> Bob
    PGP Verify: ASCII, Alice

all with "We don't have a key for the requested PGP ops (flags = 2/4)".

The cause is time, not code: the Keybase-generated test keys (Keybase OpenPGP
v2.0.77, signing dates in 2018-2019) have expired, and kbpgp correctly refuses to
use an expired subkey. That is why core-ci was green in February and red now.

Confirmed pre-existing and unrelated to this PR: a workflow_dispatch of core-ci on
release/v2.0.0 - which does not contain any of this branch's changes - fails
identically (run 33330477016).

Upstream fixed this in v11.1.0 (gchq/CyberChef#2461) by regenerating the fixtures
with Keybase OpenPGP v2.1.17. Take those regenerated keys and their dependent
message/expected-output fixtures verbatim, minus upstream's new
"PGP Sign/Verify: RSA, All bytes" case, which depends on operation behaviour this
tree will not have until the v11.4.0 sync (PR 5) and aborts the run here.

Verified on Node 22, matching CI:
  tests/operations  1716 passing, 0 failing (was 1713 passing, 3 failing)
  tests/node         217 passing
  npm run test:mcp   689 passing, 19 suites

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
…nventions

The guide asserted the v2.0.0 end state as if it were already in force: "no
cyberchef_ prefix" and "named object arguments". Both are DEP001/DEP005 targets
that do not land until the breaking-changes PR. As written the reviewer would have
flagged every correct call site in the current tree as a violation.

State both as current-vs-target, and say explicitly not to flag the existing form.

Caught by Copilot review on PR #41.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
… updates

Two inconsistencies between the config and the policy stated in its own header:

- The `security-patches` group matched every security update including majors,
  contradicting "majors are raised individually so each breaking change gets its
  own review". Restrict it to minor+patch so a major security bump still arrives
  as its own PR.
- The docker ecosystem had no `groups` block at all, so its minor/patch bumps
  would arrive ungrouped unlike npm and github-actions.

Both raised by CodeRabbit on PR #41.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
The style guide required a THIRD-PARTY-NOTICES.md row for ported code, but that
file does not exist until the GPL-3.0-or-later relicense PR creates it alongside
NOTICE and LICENSE.Apache-2.0. Say so, and scope the rule to newly ported code
(of which there is currently none).

Raised by CodeRabbit on PR #41.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
The review job failed on this very PR with:

    chmod: cannot access 'scripts/agy-review.sh': No such file or directory

The workflow checks out the DEFAULT branch to source the reviewer - deliberately,
so a PR cannot rewrite its own reviewer on a self-hosted runner - but on a first
install those scripts exist only on the PR branch. Copilot predicted this on this
PR; I deferred it as moot because no runner existed. A runner now exists, so it
stopped being moot and started blocking.

Fixed in the shared template (antigravity-pr-review e8854c0) and reinstalled here,
so the other five repositories using the template get it too rather than this copy
being forked.

The fallback fires only when the default branch has no reviewer at all AND the PR
head is in this same repository; a fork PR never reaches it. Trigger is
`pull_request`, not `pull_request_target`, and `ref` is head.sha rather than
head.ref. Once this merges to master the step never fires again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
CodeRabbit was silently skipping this PR. Its check reported:

    Review skipped: reviews are disabled for this base branch

By default CodeRabbit reviews only PRs targeting the default branch. The entire
v2.0.0 release train targets the long-lived `release/v2.0.0` integration branch,
so every PR in it would have gone unreviewed with the check still showing green.
There was no .coderabbit.yaml at all.

Add one, modelled on the sibling RustyNES/RustySNES/RustyN64 configs and validated
key-by-key against CodeRabbit's official schema.v2.json rather than assumed:
- reviews.auto_review.base_branches covers master, release/.*, and v1.9.x, which
  is the load-bearing fix.
- profile: assertive. This is a cryptography toolkit; a silently wrong decode is
  worse than a crash, so extra nitpicks are the right trade.
- pre_merge_checks encoding the project's real invariants: no hand-edits to synced
  src/core, no edits to generated files, async bake call sites, per-session HTTP
  transport, deprecation entry for breaking MCP changes, CHANGELOG entry.
- path_instructions and path_filters so ref-proj/ and generated files are ignored.

(The published docs place tone_instructions under general_settings; the actual
schema has it top-level. Followed the schema.)

Copilot: .github/copilot-instructions.md had drifted -- it claimed the image was
node:22-alpine (it is Chainguard distroless), pointed at v1.0.0 release notes, and
undercounted operations. Fixed those, and added path-specific
.github/instructions/*.instructions.md files, the current recommended format,
which the repo had none of:
- src/core/**        never hand-edit synced upstream code
- src/node/**        async bake boundary, tool-name derivation, transport isolation
- .github/workflows/** self-hosted runner threat model, SHA pinning, gating

Also commit AGENTS.md and the CLAUDE.md symlink from the Master-Claude setup:
Copilot reads AGENTS.md natively and gives it precedence, so leaving it untracked
withheld the project's own working agreement from the reviewer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
Prerequisite for dropping the obsolete Node flags. Without it, removing
--no-experimental-fetch kills the whole operations suite: argon2-browser calls
fetch() on a bare filesystem path, Node's built-in fetch rejects a non-URL string,
and jq-web converts the unhandled rejection into a hard abort(). Reproduced on
Node 24 AND 26, so it is not a Node-26-only quirk.

Port upstream's fix (v11.4.0):
- tests/lib/wasmFetchPolyfill.mjs wraps globalThis.fetch and serves absolute
  filesystem paths from fs as a synthetic Response.
- generateOpsIndex.mjs now emits tests/operations/index.mjs as well, with the
  polyfill imported at its head. Upstream gitignores that file; this fork carried
  a hand-maintained copy, so it is now generated and ignored to match.

Switching to the generated index links every test file, and surfaced four that the
hand-maintained index had silently omitted -- Fernet, IPv6Transition, ParseX509CRL
and XORChecksum. Three needed upstream's fixes to pass:

- Fernet.mjs imported "../TestRegister", a path that does not exist (the real one
  is ../../lib/TestRegister.mjs), so the file could never have run. Upstream fixed
  this in v10.24.0 (gchq/CyberChef#2335) and also loosened two expectedMatch
  regexes from /^gAAAAABce-/ to /^gAAA/ -- `ce-` encodes a 2019 timestamp, making
  them a time bomb of the same kind as the expired PGP keys.
- IPv6Transition.mjs expected outputs were missing a trailing newline.
- ParseX509CRL.mjs fixtures used tabs where the operation emits spaces.

Both operations were verified byte-identical to upstream v11.4.0 first, confirming
these are fixture bugs rather than masked behaviour changes.

Verified on Node 24 with upstream's flag set (no --no-experimental-fetch):
  tests/operations  1739 passing, 0 failing  (was 1716 with 4 files unlinked)
  tests/node         217 passing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
Upstream v11.4.0 declares engines {"node": ">=24 <27"}; match it exactly, since
that is the floor the synced core requires. This fork had no engines field at all,
and three mutually inconsistent Node signals: CI pinned 22, a gitignored
.node-version saying 25, and a Docker base floating on Chainguard latest.

Drop --experimental-modules, --experimental-json-modules,
--experimental-specifier-resolution=node and --no-experimental-fetch from every
script. Node 24 supports ESM, JSON import attributes and fetch natively, so the
first three are no-ops -- and --no-experimental-fetch is not merely redundant,
Node 26 rejects it outright:

    node: --no-experimental-fetch is an invalid negation because it is not a
    boolean option

which made the core suite unrunnable on any modern local Node. `test` and `repl`
now match upstream v11.4.0 verbatim.

CI moves 22 -> 24 across all seven workflows, and .nvmrc is committed so local
work agrees with CI rather than depending on a gitignored file.

Note the container ships Node 26 (Chainguard has no versioned Node tags on the
free tier) while CI tests on 24. Both sit inside >=24 <27; testing on the floor
catches accidental use of newer APIs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
Both stages used the floating `latest` / `latest-dev` tags, which silently follow
Node majors. Chainguard's free tier publishes no versioned Node tags, so the tag is
the only handle and it currently resolves to Node 26.8.1 -- fine today, but the
moment it moves to Node 27 an unpinned build would violate the engines constraint
added in the previous commit with no diff to show for it.

Pin both stages by digest with a comment recording the Node version and date.
Dependabot's docker ecosystem, added earlier in this series, keeps them current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
ci: restore green CI, add Dependabot config and Antigravity reviewer
build: move toolchain to Node 24 and generate the operations test index
BREAKING CHANGE: CyberChef-MCP v2.0.0 and later are distributed under
GPL-3.0-or-later. v1.9.x and earlier remain Apache-2.0 and are unaffected.

v2.0.0 incorporates algorithms ported from eight reference security tools, two of
which are GPL-licensed. Reading the licence texts rather than inferring them:

  katana              "either version 3 of the License, or ... any later"
                      -> GPL-3.0-or-later. Forces v3; GPLv2 is not an option.
  John the Ripper     "either version 2 of the License, or ... any later"
                      -> GPL-2.0-or-later, so lawfully usable under GPLv3.
  upstream CyberChef  Apache-2.0 -> one-way compatible into GPLv3 ONLY.
                      Apache-2.0 is incompatible with GPLv2, so GPLv2 was never
                      viable regardless of katana.

GPL-3.0-or-later is therefore not a preference among workable options; it is the
only licence admitting all three simultaneously.

The npm tree was audited: 1,694 packages, 1379 MIT / 131 ISC / 56 Apache-2.0 /
71 BSD. Six entries needed individual review (LGPL-3.0+, MPL-2.0, three dual
licences, CC-BY-SA-4.0) and each is GPLv3-compatible. No blocker found.

This does NOT relicense GCHQ's code and does not claim to. Upstream files keep
their Apache-2.0 headers and copyright; only the COMBINED work changes licence,
which Apache-2.0's one-way compatibility expressly permits. No contributor consent
was required: every prior contribution arrived under Apache-2.0, which already
allows incorporation into a GPLv3 work.

Landing this before any GPL-derived code is imported makes the change one
deliberate, dated commit rather than something that happens implicitly in a later
PR. It is effectively irreversible once that code arrives.

Changes:
- LICENSE -> GPL-3.0 text; the previous combined notice preserved verbatim as
  LICENSE.Apache-2.0
- NOTICE and THIRD-PARTY-NOTICES.md with per-component attribution, including the
  deliberately narrow scope taken from the unlicensed cyberchef-recipes corpus
- docs/adr/0001 recording why v3 specifically, and why AGPL was rejected
- package.json license, Dockerfile.mcp OCI label, README licensing section + badge
- SPDX-License-Identifier headers on the 31 files this project authored, applied
  via an allowlist with a hard guard refusing any file that exists in upstream
  v11.4.0 - verified no upstream header was touched
- upstream-sync-guide: the sync is now one-way by licence, not just convention

Verified: lint clean, 689 MCP tests, 217 node + 1739 operations tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
All three raised by the Antigravity review on this PR, all valid.

- CHANGELOG.md had no entry for a `feat!` breaking change. A relicence is about as
  user-visible as a change gets, and the project's own pre-merge checks require an
  [Unreleased] entry for user-visible behaviour. Added, with the practical effect
  spelled out: running it carries no obligation (GPLv3 has no network-use clause),
  distributing a derivative does, and v1.9.x stays Apache-2.0.
- package.json `copyright` said only "Crown copyright 2016", which no longer
  describes the combined work. Now credits both upstream and this fork's
  modifications.
- LICENSE.Apache-2.0 was mode 100755. The executable bit came from upstream's own
  LICENSE file and survived the `git mv`; a licence file has no business being
  executable.

The related `name`, `version` and `author` fields are deliberately left alone here:
they change together in the package-identity PR that renames this to cyberchef-mcp
for npm publication, and splitting that across two PRs would make neither coherent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
feat!: relicense to GPL-3.0-or-later
The release branch had drifted from the default branch and was carrying all ten
CRITICAL container vulnerabilities that #43 already fixed on master:

  basic-ftp         5.0.5 -> 5.3.1   (CVE-2026-27699 path traversal)
  jsonata           2.1.0 -> 2.2.2
  jsrsasign        11.1.0 -> 11.1.5
  protobufjs        7.5.4 -> 7.6.6
  shell-quote       1.8.3 -> 1.10.0
  websocket-driver  0.7.4 -> 0.7.5
  handlebars        4.7.8 -> 4.7.9

Left alone, the release train would have shipped v2.0.0 with criticals the default
branch had already cleared weeks earlier. Long-lived release branches need this
sync deliberately and repeatedly, not once at the end.

Two conflicts, both resolved toward the more correct text rather than by branch
precedence:

- .coderabbit.yaml (add/add): master's copy wins. The two are byte-identical apart
  from "Prioritise" -> "Prioritize", which master fixed to match `language: en-US`.
- .github/copilot-instructions.md: keep master's "Alpine Linux" -> Wolfi correction,
  but NOT its base-image line. Master says both stages use floating `latest` tags,
  which is true there and false here: #42 pinned both by digest on this branch.
  Taking master's text verbatim would have imported a factually wrong statement
  into the branch it describes. Rewritten to describe this branch.

Also carries master's `js-yaml` ^4.1.1 override, which stays correct here until the
upstream sync lands v11.4.0's version (^5.3.0 with named imports).

Verified after resolution: release invariants intact (GPL-3.0-or-later,
engines >=24 <27, both FROM lines digest-pinned, CI on Node 24, wasmFetchPolyfill
present), lint clean, 689 MCP tests, 217 node + 1739 operations tests on Node 24.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019t94R6Rir4ZsP45wm6oTgq
Two review findings, one of which was a direct contradiction between reviewers.

Copilot (on #43) said `Prioritise` should be `Prioritize` because .coderabbit.yaml
declares `language: en-US`. Antigravity (on this PR) said the opposite: keep
`Prioritise`, because the project follows GCHQ's British English. Both were half
right, and the actual defect was the declaration rather than the prose:

  .cspell.json          "language": "en,en-gb"   <- en-GB explicitly accepted
  src/core (upstream)   133 British vs 79 American spellings
  .coderabbit.yaml      language: en-US          <- the odd one out

So set `language: en-GB` (a valid value per CodeRabbit's schema.v2.json, which
lists en, en-US, en-AU, en-GB, en-CA, en-NZ, en-ZA) and restore `Prioritise`. That
resolves the conflict in favour of the project's real convention instead of
flip-flopping the prose to match a declaration nobody chose deliberately.

Separately, copilot-instructions.md still claimed "Node.js v22+" in six places
while this branch's package.json declares engines >=24 <27. That mismatch would
send a contributor to the wrong local Node and produce exactly the CI/local
divergence the file exists to prevent. All six corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
Reverses the en-GB change from 73efda9. That call was mine to make and I made it
wrong: I inferred the convention from .cspell.json and upstream's prose rather than
from the project's own preference, which is en-US.

Verified this cannot conflict with upstream, which was the open question:

- .cspell.json is upstream's file and declares "language": "en,en-gb". That loads
  BOTH dictionaries, so American spellings pass the grammar check and upstream's
  British prose in src/core passes too. There is no setting to reconcile.
- `lint:grammar` is not wired into any CI workflow, and reports 2,980 pre-existing
  issues across 366 files regardless. It gates nothing today.
- .coderabbit.yaml is ours alone and is never synced.

Added one carve-out that the en-US setting now makes necessary: the src/core
path_instructions explicitly tell the reviewer that British spellings in synced
upstream files are correct and must not be flagged. Without it, declaring en-US
invites exactly the src/core "corrections" that the fork-hygiene rules exist to
prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
chore: merge master into release/v2.0.0 (picks up 10 CRITICAL dependency fixes)
Pure refactor. No behaviour change, no test changes, no public surface change.

mcp-server.mjs was a 1,636-line monolith holding six classes, four helpers,
runServer(), and ~720 lines of logic inlined inside two setRequestHandler
callbacks. That structure is why it sits at 50.75% line coverage while every
already-extracted module sits at 95%+: inline handler bodies can only be exercised
end to end.

Extracted verbatim by line range, so the logic cannot drift:

  lib/config.mjs       17 env-derived constants, lifted so every consumer agrees
  lib/cache.mjs        LRUCache
  lib/memory.mjs       MemoryMonitor
  lib/telemetry.mjs    TelemetryCollector
  lib/rate-limit.mjs   RateLimiter
  lib/quota.mjs        ResourceQuotaTracker
  lib/batch.mjs        BatchProcessor
  lib/tool-schema.mjs  sanitizeToolName, mapArgsToZod, resolveArgValue,
                       validateInputSize

  mcp-server.mjs       1636 -> 992 lines

Everything is re-exported unchanged from mcp-server.mjs, so all 19 test files keep
importing exactly what they imported before. vitest.config.mjs already listed
src/node/lib/** in its coverage include, so the config anticipated this split.

The two setRequestHandler callbacks deliberately stay put: registerTool() in the
SDK v2 migration IS their decomposition, and doing it by hand first would be the
same work twice.

Verified before committing:
  tools/list  483 tools, 183,115 bytes - BYTE-IDENTICAL to the pre-refactor
              baseline captured on this same commit
  lint        clean

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
`npm run lint` was reporting 3 warnings alongside 0 errors. Warnings are not
failures here, but they are noise that trains people to ignore the linter.

- mcp-server.mjs:92,121 - "More than 2 blank lines". Introduced by the
  decomposition splice, which left the gaps where extracted blocks had been.
  Auto-fixed with `eslint --fix`.
- src/node/index.mjs - "File ignored because of a matching ignore pattern".
  eslint.config.mjs correctly ignores this generated file, but the Gruntfile glob
  still handed it to eslint, which then warned that it had ignored it. The two
  configs now agree: the glob excludes it.

`npm run lint` is now silent - 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
Every build printed:

    Browserslist: browsers data (caniuse-lite) is 8 months old. Please run:
      npx update-browserslist-db@latest

Ran it. "No target browser changes" - this is a data refresh only, no behaviour
change, and it silences the warning on every lint and build. Upstream CyberChef
does the same routinely ("Update Browserslist DB" appears throughout its history).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
refactor: decompose mcp-server.mjs into lib modules
Carries onto the release train:
- the Antigravity PR reviewer's security hardening (#47)
- removal of the vestigial root Dockerfile (#46), which release/v2.0.0 still had

Four add/add conflicts, all in reviewer files that both branches had installed
independently. Resolved toward master in every case, after confirming master is a
strict superset -- the only release-side content was the pre-hardening version of
exactly what #47 fixed:

- the `author_association` permission gate, replaced by a real collaborators-API
  check (a Triage-role user reports as COLLABORATOR with no write access)
- `git -c "http.extraheader=...bearer $GH_TOKEN"`, which put the token in argv
  where /proc/<pid>/cmdline is world-readable
- `gh api --paginate` without slurping, which returned one comment id per page and
  silently degraded the in-place edit into a duplicate post
- a comment claiming `@uri` does not encode `/` (it does: `ci/foo` -> `ci%2Ffoo`)
- a style-guide rule that flagged correct shell conventions as violations

CHANGELOG.md and .gitignore auto-merged. Verified after resolution: no conflict
markers anywhere, `bash -n` clean, the reviewer selftest passes, the workflow YAML
parses, the root Dockerfile is gone, and all eight src/node/lib modules from #48
are intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
Carries the CI work from #50 and #51 onto the release train:
- CI now runs on release/** pushes, so this branch is validated at all
- all 135 actionlint findings cleared across the workflows
Answers "if we still need SafeRegex, apply it where it survives and remains
useful and active". We do still need it, and the reason is measurable.

WHY IT IS STILL NEEDED

The MCP layer's OPERATION_TIMEOUT is `Promise.race` against a `setTimeout`. That
gives NO protection against catastrophic backtracking, because backtracking blocks
the V8 event loop synchronously and no JS timer can fire while it runs:

    const evil = /^(a+)+$/, input = "a".repeat(30) + "!";
    setTimeout(() => console.log("TIMEOUT FIRED"), 500);
    evil.test(input);        // process killed at 10s; "TIMEOUT FIRED" never printed

So an AI client can hang the server with one argument, and the timeout that looks
like it covers this does not. Worker threads would make a timeout enforceable, but
they are off by default (CYBERCHEF_ENABLE_WORKERS).

WHY THIS ONE SURVIVES WHERE THE OLD ONE DID NOT

- It lives in src/node/lib/, which is fork-owned and outside every sync allowlist.
  The original sat in src/core/lib/ and worked by having src/core/operations/ files
  import it -- and the sync overwrites that directory verbatim, so a later sync
  stripped every call site.
- It hooks `resolveArgValue`, the single point every user-supplied argument passes
  through, so one hook covers single-operation tools, cyberchef_bake and batch
  execution. No per-operation edits exist for a sync to revert.
- It ships with 24 tests, including a guard that fails if the screen is ever
  disconnected from the dispatch path and a check that fails if the argument
  heuristic stops matching the operations that compile user patterns. The original
  had none -- its own security report left "Add security-focused unit tests"
  unchecked -- which is exactly why nothing went red when it was disconnected.

WHAT IS DELIBERATELY NOT REIMPLEMENTED

The old module advertised "timeout-based validation (100ms) to detect catastrophic
backtracking". That cannot work, per the measurement above, and the reason is now a
comment in the file so nobody adds it back.

HONEST SCOPE

This is a static screen -- a length bound plus detection of exponential shapes. It
is defence in depth, not a decision procedure; deciding whether an arbitrary regex
backtracks catastrophically is not something a heuristic settles. It is tuned to
reject classic shapes while passing ordinary patterns, because a screen that fires
on normal input gets disabled by whoever trips over it. The durable fix is worker
execution with a terminable timeout.

Argument detection is derived from OperationConfig rather than a hardcoded list, so
operations added by a sync are covered automatically. Enumerated types (option,
argSelector, populateOption) are excluded, as is "Crib (known plaintext string)" on
Bombe/ROT13/ROT47/XOR Brute Force, which takes literal text. Magic's "Crib (known
plaintext string or regex)" IS screened. Currently 0.4% of all arguments match; a
test fails if that ever exceeds 5%.

Verified: the pattern that hung the process for >10s is rejected before execution;
`^[a-z]+$` passes unchanged. lint exit 0, 713/713 MCP tests pass across 20 files
(689 + 24 new).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
… patterns

Copilot flagged that the quantified-alternation rule carried a `(?![?+])` guard
which skipped lazy quantifiers. Measured, and it is right -- laziness changes the
ORDER the engine explores, not the size of the search space:

    26 a's + a failing char:
      (a|a)+   5383ms        (a|a)+?   5163ms
      (a+)+    2974ms        (a+)+?    2906ms

So the guard was a one-character bypass: append `?` and the screen let it through.

Testing that also exposed a second fault the review did not mention, and it is the
worse of the two. The same rule was a plain regex matching ANY quantified
alternation, so it rejected patterns that are perfectly ordinary and linear:

      (foo|bar)+   -> REJECTED        (a|a)+?   -> allowed
      (cat|dog)*   -> REJECTED

Wrong in both directions: blocking what is safe while passing what is not. And
false positives are not a cosmetic problem here -- a screen that fires on normal
input is a screen someone switches off, which is how the predecessor's protection
ended up absent for four releases.

Replaced the regex with an overlap test. A quantified alternation is only
dangerous when its branches can match the same text, which is decidable by
inspection for the shapes that actually occur:

    identical    (a|a)     both branches accept the same string
    prefix       (a|ab)    one accepts a prefix of the other's language
    quantified   (a+|b)    a variable-length branch absorbs the fixed one's matches

Disjoint branches cannot overlap, so `(foo|bar)+` is linear and now passes. The
quantifier match now explicitly allows the lazy form.

Verified across 17 patterns, 9 that must be rejected and 8 that must pass: zero
errors in either direction. Two new test cases pin both regressions -- the lazy
bypass and the disjoint-alternation false positive -- so neither can return.

Also from review: merged the duplicate `### Added` heading under [Unreleased]
(Keep a Changelog expects one per type) and fixed a dropped word in the test
file's header comment.

lint exit 0; 715/715 MCP tests pass across 20 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
feat(security): reinstate ReDoS screening where a sync cannot revert it
Carries onto the release train:
- removal of the dead src/core/lib/SafeRegex.mjs plus the documentation
  corrections (#52)
- zero Node runtime warnings at startup: punycode.js, kbpgp ^2.1.18 (#53)
- the benchmark workflow no longer fails on its own comment step, and its
  write-scoped token is isolated from PR-controlled code (#55)

The release branch already carries the REPLACEMENT for SafeRegex from #54
(src/node/lib/safe-regex.mjs, wired into resolveArgValue and covered by 26 tests),
so this merge removes the dead module while the working screen stays in place.

One conflict, in .github/workflows/performance-benchmarks.yml, resolved as a
combination rather than by taking a side -- each branch had a different half:

  master   the two-job split (read-only benchmark job, write-scoped report job),
           the permissions blocks, artifact hop, output capture and comment fixes
  release  newer action pins and Node 24

Took master's structure and lifted release's versions onto it: checkout@v7,
setup-node@v7, github-script@v9, upload/download-artifact@v6, node-version 24.
Taking either side wholesale would have lost real work.

Every pin was verified to exist via the tags API before committing. That check is
not ceremony here: Security Scan was dead for roughly five months in this
repository because `aquasecurity/trivy-action@0.28.0` had been retagged away, and
a plausible-looking version number is exactly how that recurs.

CHANGELOG.md, README.md, package.json and package-lock.json auto-merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Antigravity review (Gemini via Ultra)

This PR cuts the v2.0.0 release by syncing with upstream v11.4.0, relicensing to GPL-3.0-or-later, fixing the cross-client data leak by making the HTTP transport per-session, and introducing a pre-execution ReDoS screen.

Blocking issues

None found.

Suggestions

None found.

Nitpicks

None.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Earlier review rounds (newest first)
Round reviewed at 2026-08-31 14:06 UTC

Antigravity review (Gemini via Ultra)

Updates CyberChef-MCP to upstream v11.4.0, relicenses to GPL-3.0-or-later, adopts Node >=24, and resolves multiple security alerts including an HTTP transport leak and ReDoS vulnerability.

Blocking issues

None found.

Suggestions

  • src/node/lib/safe-regex.mjs in branchesOverlap: The regex /[+*]|\{\d+,\d*\}/ checks for variable-length branches but omits the ? quantifier. While it matches at most one character, it still makes a branch variable-length and can contribute to backtracking when nested inside an outer quantifier (e.g., (a?|b)+).

Nitpicks

  • src/node/lib/safe-regex.mjs in hasOverlappingQuantifiedAlternation: The regex (?:[+*]|\{\d+,\d*\}) technically matches {1}, which is fixed-length, though it's extremely unlikely to cause false positives in real-world patterns.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Earlier review rounds (newest first)
Round reviewed at 2026-08-31 13:59 UTC

Antigravity review (Gemini via Ultra)

This PR tracks upstream CyberChef v11.4.0, updates the project license to GPL-3.0-or-later, refactors the HTTP transport to isolate sessions natively, and implements a ReDoS screening mechanism for user regexes.

Blocking issues

  • Correctness: In src/node/transports.mjs, if the configured mcpPath is exactly /, mcpPath.replace(/\/+$/, "") will evaluate to "", while the requested path evaluates to "/" (due to the || "/" fallback). This mismatch causes every valid request to / to be incorrectly rejected with a 404. Consider changing the check to if (path !== (mcpPath.replace(/\/+$/, "") || "/")).

Suggestions

None found.

Nitpicks

  • In CHANGELOG.md, the "Security" section covering DNS rebinding and recipe storage changes was placed under the [Unreleased] header rather than the new [2.0.0] release block.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Earlier review rounds (newest first)
Round reviewed at 2026-08-31 13:50 UTC

Antigravity review (Gemini via Ultra)

This PR synchronises the core engine with upstream CyberChef v11.4.0, relicenses the project to GPL-3.0-or-later, resolves all open security alerts (including HTTP DNS rebinding and insecure temp files), and withdraws the cyberchef_ prefix deprecation.

Blocking issues

None found.

Suggestions

  • src/node/mcp-server.mjs (around line 25629): In csv(value, envName), if value is provided in options as a non-string and non-array primitive (e.g., a number), raw.split(",") will throw a TypeError. Consider using String(raw).split(",") as a defensive guard.
  • src/node/recipe-storage.mjs (around line 25215): The fs.stat(candidate) and subsequent fs.unlink(candidate) introduces a minor TOCTOU (Time-of-Check to Time-of-Use) race condition where the file could be modified or removed between the check and the unlink. Since the cleanup is best-effort and unlink failures are caught, this is not a blocking security issue, but standardising on unlinking based on the file descriptor if possible or just ignoring the race is standard. Given Node's fs limitations, the current implementation is acceptable but could be noted.

Nitpicks

  • src/node/recipe-storage.mjs (line 25168): STALE_TEMP_AFTER_MS = 60 * 60 * 1000; could have an inline comment // 1 hour appended for quicker readability.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Earlier review rounds (newest first)
Round reviewed at 2026-08-31 13:42 UTC

Antigravity review (Gemini via Ultra)

This PR updates the upstream CyberChef core to v11.4.0, correctly handles bake() and execute() as async, implements ReDoS screening for user-provided regexes, secures temp file handling in recipe storage, and makes the HTTP transport strictly per-session.

Blocking issues

  • DNS rebinding vulnerability (src/node/transports.mjs): The comment "DNS-rebinding protection is opt-in because the default bind is loopback, where it adds nothing" fundamentally misunderstands the attack. DNS rebinding specifically targets loopback/private interfaces by using a victim's browser as a proxy to bypass the firewall. Because a rebound request is considered same-origin by the browser, it sends no CORS preflight and the POST executes directly. By making Host validation opt-in, the default 127.0.0.1 bind is completely vulnerable to takeover by any malicious website the user visits. allowedHosts must default to a safe value (e.g., ["localhost", "127.0.0.1"]) rather than being disabled.

Suggestions

  • src/node/lib/safe-regex.mjs (line 122 / diff line 23681): The nested-quantifier regex /\([^)]*[+*]\)\s*[+*]/ checks for inner quantifiers + or *, but misses ?. A pattern like (a?)+ causes catastrophic exponential backtracking on non-matching strings, but will bypass this screen. Consider including ? in the inner character class: /\([^)]*[+*?]\)\s*[+*]/.
  • src/node/lib/safe-regex.mjs (line 148 / diff line 23708): Similarly, the nested-bounded-repeat regex /\([^)]*[+*][^)]*\)\s*\{\d+,?\d*\}/ misses ? for the inner quantifier. Consider updating it to: /\([^)]*[+*?][^)]*\)\s*\{\d+,?\d*\}/.

Nitpicks

  • src/node/recipe-storage.mjs (line 168 / diff line 25168): In cleanupStaleTempFiles(), a vanished file (ENOENT) is caught and logged at debug level. Checking if (error.code !== "ENOENT") { ... } and ignoring it entirely would be slightly cleaner than relying on the logger level to filter normal filesystem races.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Earlier review rounds (newest first)
Round reviewed at 2026-08-31 13:28 UTC

Antigravity review (Gemini via Ultra)

This PR upgrades the upstream CyberChef base to v11.4.0, relicenses the project to GPL-3.0-or-later, refactors the HTTP transport to be per-session to prevent cross-client data leaks, withdraws the removal of the cyberchef_ tool prefix, and adds robust ReDoS screening for user-supplied regular expressions in the MCP layer.

Blocking issues

None found.

Suggestions

  • src/node/recipe-storage.mjs: The STALE_TEMP_AFTER_MS constant is set to 1 hour. Since recipe saves complete in milliseconds, this cutoff could safely be lowered to 5 or 10 minutes to clean up orphan staging files sooner without risking interference with concurrent writes.

Nitpicks

  • .dockerignore line 38: Uses the British spelling "artefacts". While src/core/ is exempt from the en-US convention, this file is project-owned where American spelling is expected.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Earlier review rounds (newest first)
Round reviewed at 2026-08-31 13:21 UTC

Antigravity review (Gemini via Ultra)

This PR synchronises the upstream base to v11.4.0, relicenses the project to GPL-3.0-or-later, sets the Node.js floor to v24, secures HTTP sessions and recipe saving, adds ReDoS screening, and withdraws the cyberchef_ prefix deprecation.

Blocking issues

None found.

Suggestions

  • src/node/lib/batch.mjs: Inside executeOperation (around line 102), raw Error objects are thrown for invalid tool names and arguments. While these are caught by executeBatch and don't cross the MCP boundary as bare exceptions, using createInputError (which is already imported and used elsewhere in this file) would be more consistent with the rest of the codebase.

Nitpicks

None.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Earlier review rounds (newest first)
Round reviewed at 2026-08-31 13:16 UTC

Antigravity review (Gemini via Ultra)

Updates Node to 24, syncs with upstream v11.4.0, makes HTTP transport per-session to fix state leaks, adds Dependabot, and relicenses the fork to GPL-3.0-or-later while adding ReDoS protections for regex arguments.

Blocking issues

  • Unawaited promise / Silent failure: In src/node/streaming.mjs (around line 271 inside streamOperationWithProgress), the now-async bakeFunction is called with .then() instead of await. The .catch block executes throw err; inside a new Promise((resolve) => { ... }) executor that lacks a reject parameter. This results in an unhandled promise rejection and causes the progressPromise to hang indefinitely on error.

Suggestions

  • src/node/recipe-storage.mjs: In cleanupStaleTempFiles(), you could use await fs.rm(candidate, { force: true }) rather than fs.unlink() to gracefully handle vanished files without throwing/catching ENOENT when a concurrent sweep beats you to it.
  • src/node/transports.mjs: The closeSessionDetached function fires off closeSession without await, but since it explicitly handles errors in a .catch block and logs them, it is safe; however, wrapping it in an explicit void (e.g., void closeSession(...)) can signal intent more cleanly to linters.

Nitpicks

  • src/node/transports.mjs: httpServer.closeAllConnections?.() uses optional chaining, but since the required Node version is bumped to 24, this method is guaranteed to exist and the ?. is no longer necessary.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Earlier review rounds (newest first)
Round reviewed at 2026-08-31 13:08 UTC

Antigravity review (Gemini via Ultra)

This PR updates the project to upstream v11.4.0 (relicensing the combined work to GPL-3.0-or-later), fixes the HTTP transport shared-instance bug by correctly isolating per-session transports, and implements ReDoS screening natively in the MCP layer.

Blocking issues

None found.

Suggestions

  • src/node/transports.mjs (around line 122): Consider renaming normaliseSessionId to normalizeSessionId. As noted in .coderabbit.yaml, the src/node/ directory is fork-owned and should follow the project's en-US spelling convention.

Nitpicks

  • .dockerignore: test-results is added without an explanatory comment, unlike the carefully annotated tests exclusion directly above it.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Earlier review rounds (newest first)
Round reviewed at 2026-08-31 12:54 UTC

Antigravity review (Gemini via Ultra)

  1. This PR updates CyberChef-MCP to upstream v11.4.0, adds ReDoS screening for user-supplied regular expressions, relicenses the project to GPL-3.0-or-later, withdraws the deprecation of the cyberchef_ tool prefix, and fixes a cross-client data leak by making HTTP transports per-session.

Blocking issues

  • Unawaited promise / Swallowed errors: In tests/node/consumers/cjs-consumer.js, require("cyberchef").then(async chef => { ... }) creates a promise chain without a .catch(). If the assert.equal fails, it will cause an unhandled promise rejection rather than a graceful test failure.

Suggestions

  • tests/node/consumers/cjs-consumer.js: Add a .catch(console.error) to the end of the require("cyberchef").then(...) chain, or refactor the top level into an async IIFE (e.g. (async () => { const chef = await import("cyberchef"); ... })()) to properly await the execution and handle errors.

Nitpicks

None found.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

doublegate and others added 5 commits August 31, 2026 06:57
agy round 1 on #84 and one CodeRabbit nitpick.

agy reported that a failed save leaves an orphaned .tmp "permanently". Not quite:
the catch has always unlinked tempFile, so any error the save can observe is
already cleaned up. But the underlying concern is right, and it is a consequence
of MY change rather than a pre-existing one.

The fixed `<path>.tmp` had a property randomisation removed: a leaked file was
overwritten by the next save, so leaks self-healed. A unique name cannot be
overwritten, so a process killed between the write and the rename -- SIGKILL, a
crash, a container stopped mid-write -- now leaves an orphan that stays forever.

cleanupStaleTempFiles() sweeps matching siblings older than one hour, after the
rename so a failure in it cannot affect the save that just succeeded, and
best-effort throughout so it can never fail a save. The one-hour floor is well
beyond any live write (saves complete in milliseconds), so a concurrent save's
staging file is never a candidate -- asserted by a test that creates a FRESH temp
file and requires it to survive.

Three tests, covering what the "leaves no temp files behind" test did not: the
failure path (rename mocked to reject, nothing left behind), the sweep of a
two-hour-old orphan, and the non-sweep of a recent one.

Caught while writing this: the sweep call first landed in load(), not save().
Both methods contain the identical "Update cache / this.cache = storage;
this.lastLoadTime = Date.now();" lines, so the anchored replace hit the wrong
one. The test that failed is the one that proved it -- re-anchored on the rename,
which exists only in save().

CHANGELOG: blank line after ### Security (markdownlint MD022).

759 MCP tests; lint 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
… join

agy round 2 on #84. Blocking item adopted, two suggestions adopted, one nitpick
checked and found not to apply.

BLOCKING -- two empty catch blocks in cleanupStaleTempFiles(). The style guide
names "silent failure paths: swallowed errors" and agy is right that "the failure
is benign" is my judgement, not something a reader or an operator can see. Both
now log, and the levels carry the distinction:

  * ENOENT on a candidate is the ORDINARY case -- a concurrent sweep, or the
    owning process cleaning up after itself -- so it is debug.
  * Anything else is a genuine surprise about a path we were about to delete, so
    it is warn.
  * A failure to read the directory at all is warn: the sweep stays best-effort
    and never fails a save, but an unreadable storage directory is worth knowing
    about even when nothing depends on this sweep.

join(dir, name) instead of `${dir}/${name}`. The interpolation happened to work
because every caller so far is POSIX; join is correct on Windows and does not
depend on that.

opendir instead of readdir. Marginal for a directory holding one file plus its
temps -- but the directory is CALLER-SUPPLIED via CYBERCHEF_RECIPE_STORAGE and
may be somewhere large, which is the same reason this whole hardening exists.
Streaming keeps a sweep from materialising an arbitrary listing to find at most a
handful of siblings.

NITPICK CHECKED, NOT APPLIED: randomUUID is still used, at line 322 for recipe
ids. The import stays.

762 MCP tests; lint 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
… reach unlink

agy round 3: one suggestion adopted, one nitpick re-checked.

opendir yields Dirents, so the entry type is already known and `entry.isFile()`
costs no syscall. Without it a DIRECTORY named like a staging file would reach
unlink() and throw EISDIR -- which the catch would then log at warn as a genuine
surprise, since it is not ENOENT. Skipping non-files makes that impossible rather
than merely handled, and it also drops a needless stat() for every non-file that
happens to match.

Test added: a directory matching the pattern, aged two hours so it would
otherwise be swept, must survive a save -- and the save must still succeed.

NITPICK, re-checked: randomUUID is still used at line 322 for recipe ids, so the
import stays. Same answer as round 2; verified again rather than assumed, because
an unused-import removal that turns out to be load-bearing fails at runtime
rather than at lint.

763 MCP tests; lint 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
agy round 4: nitpick adopted, suggestion declined.

STALE_AFTER_MS was re-declared on every sweep. Hoisted to STALE_TEMP_AFTER_MS at
module scope, where the other configuration constants live, with the reason for
the one-hour value stated there rather than inside the loop that uses it.

DECLINED: dropping the `await` on cleanupStaleTempFiles(). This would reverse the
round-2 BLOCKING finding on this same PR -- "silent failure paths: swallowed
errors ... an unawaited promise" -- and agy itself noted last round that awaiting
is correct per that rule. Trading a rule the project enforces for a latency
concern needs the latency to be real, and it is not:

The sweep does no syscall per entry. `opendir` yields Dirents, so `isFile()` and
the two string comparisons are in-process; only a name that MATCHES the staging
pattern costs a stat, and those number in the handful. A directory with 100k
unrelated files is 100k string compares, not 100k stats.

Fire-and-forget would also detach the sweep from the save that scheduled it,
so a process exiting immediately after save() -- which is the normal shape for a
stdio MCP session -- would cancel the cleanup precisely when it is most needed.

763 MCP tests; lint 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
fix(security): stop staging recipe saves through a predictable temp file
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication of the agy review

Blocking: tests/node/consumers/cjs-consumer.js — promise chain with no .catch()

The finding is correct. The remedy is upstream's, not ours.

require("cyberchef").then(async chef => {
    ...
    assert.equal(...);
});

A failing assertion becomes an unhandled rejection rather than a clean failure. Node exits non-zero with a rejection warning, so the consumer test does fail — but for a reason its output does not explain, and via a path that depends on the runtime's unhandled-rejection mode rather than on the test.

The file is byte-identical to upstream v11.4.0:

$ diff -q tests/node/consumers/cjs-consumer.js ref-proj/CyberChef/tests/node/consumers/cjs-consumer.js
(no output)

tests/ was adopted once with the v11.4.0 landing and is deliberately not on the sync allowlist. So a local edit here would diverge from upstream's suite without the sync ever flagging it — strictly worse than the src/core/** case, where a fork patch that stops applying fails the run. Carrying a silent, permanent delta against upstream to fix upstream's test is the wrong trade.

Tracked as item 11 on #73 with your suggested remedy recorded verbatim, so it goes upstream rather than being lost:

#73 (comment)

Not treated as blocking for this merge. It is a pre-existing property of upstream's consumer test, unchanged by this PR, and npm run testnodeconsumer is green. Blocking a release on a defect the release did not introduce and cannot durably fix would leave the repository stuck on a six-release-old upstream base — which is the position this release exists to get out of.


Three Copilot findings on the same PR are answered in their threads, and all three are byte-identical to upstream v11.4.0 as well. One generalised usefully: MOD.mjs throwing a bare Error turns out to be three operations doing so against 246 that use OperationError, all three on user-input validation paths. That is now item 9 on #73 with the Recipe.mjs mechanism spelled out, since a report naming one of three gets half-fixed.

Release status

CodeQL                     pass   (was failing; the one fork-owned alert is fixed in #84, merged)
Trivy / Trivy Container    pass
Dependency Vulnerability   pass
Generate SBOM              pass
core-test / test           pass
build-and-test             pass
Analyze (javascript)       pass
CodeRabbit                 pass
codecov/patch              pass

Tagging follows from master once this merges — mcp-release.yml only moves the Docker Hub latest tag when is_default_branch is true, so tagging the release branch would publish 2.0.0 while leaving the most-pulled tag on 1.9.0.

doublegate added a commit that referenced this pull request Aug 31, 2026
…st read (#85)

* fix(ci): scale the reviewer's timeout with the size of the diff it must read

A fixed 5m --print-timeout is right for an ordinary PR and hopeless for a release
merge. Observed on #83 -- 323 files, 39,856 lines, 1.6 MB handed to agy as a file
-- where it hit the ceiling on all three attempts, twice in a row, at 5m01s each:

  [agy-review] running agy via unbuffer (allocates a PTY) [attempt 3/3]
  [agy-review] agy returned a backend error rather than a review (attempt 3/3):
               Error: timeout waiting for response

The guard behaved correctly: it refused to post a fake review and failed the job.
But the failure is INDISTINGUISHABLE from a backend outage, so nothing told the
reader that the cause was diff size and that retrying would never help. Six
attempts across two runs proved it deterministic rather than transient.

The timeout now scales with the bytes agy actually has to read -- deliberately
keyed on the diff, not on the PR's file count, since reading and reasoning over
the patch is what costs the time. Base plus 240s per MiB, capped at 1800s so a
pathological diff cannot pin the self-hosted runner. An explicit
AGY_PRINT_TIMEOUT still wins, so a caller can pin it.

Verified by extracting the function and driving it:

    48KiB    -> 5m      (unchanged; under a MiB keeps the base budget)
    1581KiB  -> 540s    (#83's diff -- the case that failed)
    4882KiB  -> 1260s
    19531KiB -> 1800s   (capped)
    1581KiB  -> 5m      (AGY_PRINT_TIMEOUT set explicitly: not scaled)

Lands on master because the workflow checks the reviewer scripts out from the
DEFAULT branch (`ref: ${{ github.event.repository.default_branch }}`), so a fix
anywhere else would not reach the run that needs it. Selftest: all checks passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs

* fix(ci): validate the timeout duration, scale from bytes, pass the size explicitly

Four review findings on #85, all in code this PR introduced, all adopted.

A non-numeric AGY_PRINT_TIMEOUT would have reached `$(( base_s + extra ))` and
died with a SYNTAX ERROR under `set -e` -- turning a mis-set variable into a
reviewer that never runs. duration_to_seconds() now parses and VALIDATES,
returning 1 rather than echoing a token the caller would feed to arithmetic; the
caller logs and leaves the timeout alone.

One correction to the finding as stated: an explicitly-set AGY_PRINT_TIMEOUT
returns early and is never parsed, so the hazard was reachable only by editing
the default in the script. Narrower than described, and worth fixing anyway --
"safe because of a subtle early return three lines up" is not a property that
survives editing.

`1h` is now accepted (agy). It fell through to the bare-integer case and produced
`$(( 1h + ... ))`; same crash, different door.

Scaling is computed from BYTES rather than truncated whole MiB (agy nitpick).
Integer division gave a 1.99 MiB diff exactly one MiB of budget -- the wrong side
to round on for the case this exists to fix. 1.99 MiB now gets 777s where it
previously got 540s.

diff_bytes is passed as an argument instead of read from the enclosing scope
(agy), so the function's inputs are visible at the call site.

The parser is marked for extraction and the selftest gained 13 cases -- four
accepted forms and NINE rejections, which are the point. Sourced from the script
rather than reimplemented, per the harness's own rule that a test which
reimplements its subject agrees with itself forever.

Selftest: all checks passed (27 cases). lint 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs

* fix(ci): reject non-numeric timeout env values, and force base 10 in the parser

Three findings on #85, all in code this PR introduced.

SECURITY (CodeRabbit, CWE-78): AGY_TIMEOUT_SECONDS_PER_MIB and
AGY_PRINT_TIMEOUT_MAX_SECONDS both reach `$(( ... ))`, and bash arithmetic
RECURSIVELY EXPANDS variable contents -- so a value naming another variable that
holds a command substitution executes it. Verified rather than assumed:

    V=a; a='$(echo PWNED >&2; echo 7)'; echo $(( V ))    ->  PWNED  /  7

These are workflow-set rather than attacker-set, so this is defence in depth
rather than a live hole. Fixing it anyway: a numeric setting that can run a
command is not a property to leave standing because today's callers are trusted.
Anything that is not a plain non-negative decimal integer now falls back to the
default, loudly.

OCTAL (agy): `08m` died with "value too great for base" -- bash reads a leading
zero as octal. `10#$n` forces base 10. The silent case is the worse one: without
it `010s` means 8 seconds rather than 10, a wrong answer instead of an error.

Three selftest cases added. The bare-integer one initially asserted 8, which was
me encoding the bug I was fixing; corrected to 10 with a comment saying why, since
that expectation is the whole point.

NITPICK (agy): 1048576 extracted to `readonly BYTES_PER_MIB`.

Selftest: all checks passed (30 cases). lint 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs

* fix(agy): canonicalise numeric env settings to base 10 before arithmetic

A digits-only check is not enough for a value that reaches $(( ... )). `09` passes it,
and bash then reads the leading zero as OCTAL:

    $(( 1048576 * 09 / 1048576 ))   ->  value too great for base

so a perfectly valid AGY_TIMEOUT_SECONDS_PER_MIB=09 took the whole script down under
`set -e`. The same trap was fixed inside duration_to_seconds with `10#` and missed here.

Fixed at the validation site rather than the arithmetic site: normalise_numeric_env now
canonicalises to base 10 once, so no downstream expansion has to remember `10#`. It keeps
the fallback that closes the recursive-expansion hazard (bash arithmetic expands variable
CONTENTS, so a value naming a variable holding a command substitution would execute it).

Also: scale_timeout_for_diff defaults its argument to 0 explicitly rather than relying on
$(( )) treating an empty name as 0, and duration_to_seconds emits a trailing newline.

10 selftest cases added (57 total), covering both hazards and asserting that the RAW value
would have crashed -- so the octal case cannot silently regress.

Mirrored from the shared template (antigravity-pr-review e9c5888) so every repo running the
reviewer gets it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs

* style(agy): write BYTES_PER_MIB as 1024 * 1024

The literal 1048576 is correct but has to be recognised; the product states the intent.
Constant-folded at parse time, so there is no cost.

Mirrored from the shared template (antigravity-pr-review 5145577).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs

* fix(agy): correct the CWE-78 rationale, and replace a vacuous selftest

Two corrections to the previous commit, both raised in review.

1. The comment claimed bash arithmetic EXECUTES a command substitution held in a variable's
   contents, citing a reproduction that printed PWNED. It does not, on bash 5.3: the recursion
   expands contents as a NAME (V=a, a=5 -> 5, at any depth), but a command substitution reaches
   the parser as a literal and is refused. The earlier reproduction was a nested-quoting artefact
   in the test harness, not a property of bash. The comment now records what was measured, and
   says plainly that the earlier assertion was wrong.

   The validation stays. Its demonstrated justification is the octal crash; refusing a value that
   silently names another variable is worth doing on its own terms.

2. The "payload never runs" check was VACUOUS -- the helper's own `2>&1 >/dev/null` swallowed the
   evidence, so it passed under a deliberately broken guard. Replaced with an assertion on stderr
   that a non-numeric value never reaches the arithmetic expansion.

Mutation-checked, which is the part that was missing: deleting the digits-only case now fails
4 checks (was 3, and none of them the injection case).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs

Mirrored from the shared template (antigravity-pr-review b7032c7).

* fix(agy): stop normalise_numeric_env shadowing the caller's variable

It assigns THROUGH a name the caller supplies, so a local of that same name silently wins:

    val=07; normalise_numeric_env val 240   ->  val stays 07, never canonicalised

Reproduced before fixing. The locals are now `_nne_`-prefixed. Three selftest cases, one per
colliding name; reverting the prefix fails all three.

Also in scale_timeout_for_diff: strip whitespace from the byte count BEFORE validating it, then
run it through the same guard. Some `wc` implementations pad their output, and validating first
would have quietly fallen back to 0 and disabled the scaling entirely -- a silent no-op, which is
worse than the crash it was guarding against, because the symptom is the very timeout this
feature exists to prevent.

Six scaling cases added behind a new SELFTEST-EXTRACT block, covering the padded count, the
ceiling, an absent argument, junk, and an explicitly-pinned timeout. The constant moved below the
duration-parser block: extraction ranges end at the first closing marker, so a block wrapped
around a nested one truncates and extracts WITHOUT the function under test -- which is exactly how
this first went in, silently passing three checks that never ran.

65 checks total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs

Mirrored from the shared template (antigravity-pr-review 15698a3).

* chore(agy): use :- expansions in the cleanup trap as belt-and-braces

Raised in review four times as an unbound-variable crash under `set -u`. It is not one: every
name in those loops is pre-declared before cleanup() is defined and the trap installed, and
firing the trap before any mktemp exits cleanly (verified, with the un-pre-declared control
aborting as predicted).

Taking the hardening anyway. cleanup() only ever runs while something else is already going
wrong, so the cost of a wrong assumption there is temp files left behind plus a confusing
`unbound variable` masking the real exit cause -- and a future edit that moves the trap above the
pre-declarations would introduce exactly that with nothing to catch it. The pre-declaration stays
and is still the actual guarantee; the comment says so, so this does not read as the mechanism.

Verified: cleanup() now survives being trapped with NO pre-declarations at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs

Mirrored from the shared template.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@doublegate

Copy link
Copy Markdown
Owner Author

/agy-review

Re-running now that #85 is on master. The previous failure here was not a finding — it was a deterministic 5m timeout on this PR's 1.6 MB diff (six attempts across two runs, every one at 5m01s), which is indistinguishable from a backend outage in the log. #85 scales --print-timeout with the diff size, so this diff now gets ~670s instead of 300s. The reviewer checks its scripts out from the default branch, which is why that fix had to land on master first.

… .dockerignore group

Two review follow-ups, no behaviour change.

normaliseSessionId was the only en-GB identifier in fork-owned code. The file it lives in already
uses `initialize` nine times, and `sanitizeToolName`/`sanitized` are en-US throughout src/node and
src/node/lib -- every en-GB spelling left in src/node (`sanitise`, `sanitisedOp`) is in an
upstream-owned file we mirror verbatim and must not touch. Renamed in the function, its call site,
its JSDoc, and the 13 references in the transport test suite.

.dockerignore: the coverage/.nyc_output/test-results group sat directly under a long comment about
`tests`, so it read as part of that entry. Given its own header saying what it is -- test OUTPUT
rather than test sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication of the agy review

First, the thing this run demonstrates: the previous review failure on this PR was never a finding. It was a deterministic 5-minute timeout on a 1.6 MB diff — six attempts across two runs, every one at 5m01s — reported as Error: timeout waiting for response, which is indistinguishable in the log from a backend outage. #85 scales --print-timeout with diff size (this diff now gets ~670s), and the review completed on the first attempt. The guard was working correctly the whole time; it just could not say why it was refusing to post.

Two rounds ran. Both adjudicated below.


Round 2 (current) — no blocking issues

Suggestion: rename normaliseSessionIdnormalizeSessionId — adopted.

Right, and the evidence is stronger than the style rule alone. Surveying every .mjs under src/node on this branch:

spelling where
initialize ×9, initializes ×1 transports.mjs — the same file
sanitizeToolName, sanitized mcp-server.mjs, lib/tool-schema.mjs, lib/batch.mjs
sanitise, sanitisedOp api.mjs, apiUtils.mjs, NodeRecipe.mjsall upstream-owned, mirrored verbatim, must not be touched

So every en-GB spelling remaining in src/node is in a file we cannot edit, and normaliseSessionId was the sole en-GB identifier in fork-owned code — sitting nine lines from initialize. Renamed in the function, its call site, its JSDoc prose, and 13 references in the transport suite. 232c1021.

Nitpick: test-results added without a comment — adopted.

Fair, and the reason it read oddly is that the group sat directly beneath a long comment about tests, so coverage / .nyc_output / test-results looked like a continuation of that entry rather than a separate one. Given its own header saying what it is: test output, not test sources.

npm run lint                                    0 errors
npx vitest run http-transport-sessions           32 passed

Round 1 (archived) — the cjs-consumer.js blocking finding: rejected, twice over

require("cyberchef").then(async chef => { ... }) creates a promise chain without a .catch(). If the assert.equal fails, it will cause an unhandled promise rejection rather than a graceful test failure.

1. The premise about the consequence is wrong. An unhandled rejection is fatal by default from Node 15 onward, and this repo requires >=24. Measured:

$ node -e 'Promise.resolve().then(()=>{ require("assert").equal(1,2); })'; echo $?
1

So npm run testnodeconsumer does fail the job when the assertion fails. The difference a .catch() would make is the tidiness of the message, not whether the failure is caught. The finding describes a swallowed error; nothing is swallowed.

2. The file is byte-identical to upstream v11.4.0. Verified against the reference checkout:

$ diff <(git show origin/release/v2.0.0:tests/node/consumers/cjs-consumer.js) \
       ref-proj/CyberChef/tests/node/consumers/cjs-consumer.js
$                                    # identical

The change in this PR is exactly upstream's — adding async/await, which is required, because bake() became async in v11.4.0 and the old synchronous form would assert against a Promise. Adopting upstream's text verbatim is the deliberate choice: it makes this file a no-op for the next sync. Adding a .catch() would create fork drift in a file that has no fork reason to differ, to fix a problem that does not exist.

If it is worth fixing at all, it is worth fixing upstream, where every CyberChef consumer benefits — which is where it will go, alongside the other defects already collected in #73.

doublegate and others added 2 commits August 31, 2026 09:02
One conflict, in scripts/agy-review.sh, resolved wholly toward master after checking rather than
assuming: master's copy is a strict superset. The only four lines release had that master lacked
are the pre-#85 `cleanup()` expansions that #85 deliberately replaced with `${var:-}` forms.

Both reviewer scripts now match master and the shared template byte-for-byte; selftest passes
(65 cases).

This is the merge PR 83 needed to stop conflicting -- #85 landed on master while 83 was open, and
GitHub suppresses `pull_request` events on a conflicting PR, so no CI ran against 83's head until
this was resolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
…, not bytes

core-test failed on this branch with:

    CipherSaber2 Encrypt
      Expected  /.{10}/s
      Received  ,áiɉwA$5

Nothing was wrong with the operation. CipherSaber2 prepends a 10-byte random IV, so the only
stable property of its output is the byte COUNT -- but the assertion counted characters of the
string the harness builds from those random bytes. When two bytes happen to form a decodable
multi-byte sequence the string is shorter than the byte count and the test fails on nothing. In
the CI value above, C9 89 decoded to a single U+0249, so 10 bytes rendered as 8 characters.

Measured through the real harness path before changing anything: ~0.2% for the empty-input case,
which is why it survived dozens of runs and then failed once. Raw UTF-8 decoding of 10 random
bytes collapses 37% of the time, so the harness conversion is doing most of the work already --
but not all of it, and "usually" is not a property a test suite should rest on.

Fixed by hex-encoding before asserting, so the assertion counts bytes -- which is what it always
meant. Strictly stronger than what it replaces: `/.{10}/s` is unanchored and passes on anything at
least 10 characters long, while `/^[0-9a-f]{20}$/` pins the length exactly.

The file was byte-identical to upstream v11.4.0, so this is now a fork edit -- deliberately.
`tests/` is not on the upstream-sync allowlist, so it will not be silently reverted, and the
defect belongs upstream too: added to the #73 collection rather than fixed only here.

Verified: operations suite 2289/2289, and 5,000 consecutive runs of the empty-input case with
zero failures (was ~10 expected at the old rate).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
@doublegate

Copy link
Copy Markdown
Owner Author

core-test went red — and it was not this PR

CipherSaber2 Encrypt
  Expected  /.{10}/s
  Received  ,áiɉwA$5

A flaky assertion, not a regression. CipherSaber2 prepends a 10-byte random IV, so the only stable property of its output is the byte count — but the assertion counted characters of the string the harness builds from those random bytes. When two bytes happen to form a decodable multi-byte sequence, the string is shorter than the byte count and the test fails on nothing. In the value above, C9 89 decoded to a single U+0249, so 10 bytes rendered as 8 characters.

Measured through the real harness path before touching anything, rather than assuming:

empty-input case, /.{10}/s 1 failure in 500 runs (~0.2%)
"Hello World" case, /.{21}/s 0 in 500
raw UTF-8 decode of 10 random bytes 37% collapse

The gap between 37% and 0.2% is the harness's own conversion absorbing most cases — but not all, which is exactly why this survived dozens of green runs and then failed once. "Usually passes" is not a property a suite should rest on, and at 0.2% it would have gone on failing roughly one release-CI run in five hundred with no explanation attached.

Fixed by asserting on bytes, which is what it always meant: hex-encode before matching.

expectedMatch: /^[0-9a-f]{20}$/,     // was /.{10}/s
recipeConfig: [
    { op: "CipherSaber2 Encrypt", args: [{ option: "Latin1", string: "" }, 20] },
    { op: "To Hex", args: ["None"] },
],

Strictly stronger than what it replaces — /.{10}/s is unanchored and passes on anything at least 10 characters long, whereas /^[0-9a-f]{20}$/ pins the length exactly.

On fork hygiene, since this file was byte-identical to upstream v11.4.0. This is now a deliberate fork edit. tests/ is not on the upstream-sync allowlist, so it cannot be silently reverted the way the SafeRegex mitigation was — but the defect is upstream's and belongs upstream, so it is added to the #73 collection rather than fixed only here.

operations suite                          2289 / 2289
5,000 consecutive runs of the fixed case  0 failures   (≈10 expected at the old rate)
npm run lint                              0 errors

914a5178.

Two defects, both first spotted by Copilot on #48 and deferred there on purpose -- #48's gate was
"changes nothing observable", and fixing them inside a pure-move diff would have destroyed the
property that made it reviewable. DEP003 (structured error responses) is where they belong, and
v2.0.0 enacts DEP003, so shipping without them would leave two holes in a headline change.

1. executeBatch() ran the BATCH_ENABLED guard first, and that guard interpolates
   `operations.length` into its error context. So a disabled-batch call with a missing or
   non-array `operations` threw a bare TypeError instead of the structured INVALID_INPUT the
   caller is promised -- the feature flag turning a validation error into a crash. Shape check
   now runs first.

2. executeOperation() threw bare `Error` for all three input-validation failures (invalid tool
   name, non-object arguments, unknown operation). Those reached the MCP boundary as errors
   classified by fromError()'s message heuristics rather than by a stable code. All three are now
   createInputError, matching the four call sites already in the same file, and each carries the
   offending value in its context.

Raised again by the reviewer on #83, which is a fair signal that "tracked in the plan" is not the
same as fixed.

Verified: MCP suite 763/763 across 22 files, lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication of the agy review (round 3)

Blocking: none. One suggestion, adopted — and it was half of a pair.

src/node/lib/batch.mjs: raw Error for input validation instead of createInputError

Correct. Worth saying where this came from rather than treating it as newly found: Copilot raised it on #48, and it was deferred there deliberately#48's gate was "changes nothing observable", and fixing it inside a pure-move diff would have destroyed the property that made that PR reviewable and bisectable. It was recorded against DEP003, and v2.0.0 enacts DEP003, so shipping this release with it still open would leave a hole in a headline change of the release itself.

The reviewer raising it again on the release PR is a fair signal that "tracked in the plan" is not the same as "fixed".

Fixed, and so was the second defect from the same pair, which nobody re-raised and which is the worse of the two:

// Before: the feature-flag guard ran FIRST, and interpolates operations.length into its context.
if (!BATCH_ENABLED) {
    throw createInputError("Batch processing is disabled", { batchSize: operations.length });
}
if (!Array.isArray(operations) || operations.length === 0) { ... }

A disabled-batch call with a missing or non-array operations therefore threw a bare TypeError instead of the structured INVALID_INPUT the caller is promised — the feature flag turning a validation error into a crash. The shape check now runs first.

And all three raw throws in executeOperation are now createInputError, matching the four call sites already in that file, each carrying the offending value:

throw createInputError(`Invalid tool name: ${op.tool}`, { tool: op.tool });
throw createInputError("Operation arguments must be an object", { tool: op.tool, received: typeof op.arguments });
throw createInputError(`Operation not found: ${toolName}`, { tool: toolName });

batch.mjs now has zero bare Error throws.

npx vitest run tests/mcp/     763 passed, 22 files
npm run lint                  0 errors

3680474b.

"artefacts" -> "artifacts". The en-US convention applies to project-owned files; the exemption is
for the upstream-mirrored tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication of the agy review (round 4)

Blocking: none. One suggestion declined with reasoning, one nitpick adopted.

Suggestion: lower STALE_TEMP_AFTER_MS from 1 hour to 5–10 minutes — declined

The reasoning offered is sound and the premise is one I wrote myself: a save does complete in milliseconds. But the constant is not sized against a typical save, it is sized against the worst case in which deleting the wrong file loses data.

What the sweep does is delete recipes.json.<random>.tmp siblings — files that may be a live staging write by another process. The cost of the two errors is asymmetric:

  • Too long: an orphan staging file survives an extra 50 minutes on disk. It is a few KB, it is invisible, and the next sweep gets it.
  • Too short: a concurrent save whose process was descheduled, suspended, or blocked on slow I/O has its staging file deleted out from under it, and the fs.rename that follows fails or — worse — renames a file another writer has since recreated.

A laptop lid closing mid-save, a container throttled to a fraction of a CPU, or an NFS-backed storage path all make "milliseconds" stop being true, and none of them are exotic. An hour buys immunity to all of them for a cost measured in kilobytes.

There is also no user-visible benefit on the other side of the trade: nothing polls for these files, no quota counts them, and the sweep runs on every save anyway. So the change would purchase faster deletion of something nobody is waiting on, at the price of a data-loss window. The comment on the constant already states the intent — "well beyond any live write" — and an hour is what makes that sentence true rather than aspirational.

Declined deliberately, not overlooked.

Nitpick: "artefacts" in .dockerignore — adopted

Right, and the distinction drawn is exactly the correct one: src/core/ is exempt because it is mirrored from upstream, but .dockerignore is project-owned and the en-US convention applies. Fixed; artefact no longer appears in any project-owned file.

This is the second en-GB spelling this review caught (normaliseSessionId was the first), which is a better hit rate than I would have guessed for a convention that had no automated check behind it.

…TP transport

It was opt-in, and the comment justifying that said: "DNS-rebinding protection is opt-in because
the default bind is loopback, where it adds nothing."

That has the attack backwards. DNS rebinding exists SPECIFICALLY to reach loopback and private
addresses, using the victim's own browser as the proxy a firewall cannot see:

  1. The victim loads evil.example, whose DNS answer has a 1-second TTL.
  2. The page fetches http://evil.example:3000/mcp. The browser re-resolves; the attacker now
     answers 127.0.0.1.
  3. The request lands on this server carrying `Host: evil.example:3000`.
  4. The browser considers it SAME-ORIGIN with the page -- origin and target are both
     http://evil.example:3000 -- so no preflight is sent, whatever the Content-Type, and the
     attacker's script can read the response.

Two consequences the old reasoning missed. CYBERCHEF_ALLOWED_ORIGINS does not help: the CORS
default-deny is never consulted, because the browser never treats this as cross-origin. And
`initialize` requires no session id, so a hostile page can open its own session and drive every
tool -- on a server whose recipe storage reaches the filesystem.

Default allowlist is now localhost / 127.0.0.1 / [::1], each with and without the port, resolved
AFTER listen() so `port: 0` is covered (the SDK compares the whole Host header by exact string, and
a browser includes the port whenever it is not the scheme default). CYBERCHEF_ALLOWED_HOSTS still
replaces the defaults for a non-loopback bind; CYBERCHEF_ALLOWED_HOSTS=* disables the check with a
startup warning, for someone running behind a proxy that validates Host itself.

Breaking for one configuration: a server bound to 0.0.0.0 and reached by a LAN name or IP now needs
CYBERCHEF_ALLOWED_HOSTS set. That is the secure-by-default trade, and it is what the MCP spec asks
for.

Four tests, speaking raw HTTP rather than fetch -- fetch refuses to let a caller set Host, and a
forged Host is the entire attack. They cover the rebound host (403), every loopback name with and
without the port (200), an explicit allowlist replacing the defaults, and the `*` opt-out.

Docs corrected in the same change, since http-transport.md and README.md both asserted the wrong
reasoning in prose.

Verified: MCP suite 767/767 across 22 files, lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication of the agy review (round 5)

The blocking finding is right, and it is the most valuable thing any bot has found on this PR. Adopted in full. The two regex suggestions are rejected on measurement, and the nitpick is declined with reasoning.


BLOCKING — DNS rebinding: adopted, and my comment was the bug

The comment being criticised was mine:

DNS-rebinding protection is opt-in because the default bind is loopback, where it adds nothing.

That is backwards, and the review's correction is exactly right: DNS rebinding exists specifically to reach loopback, using the victim's own browser as the proxy a firewall cannot see.

  1. Victim loads evil.example; its DNS answer carries a 1-second TTL.
  2. The page fetches http://evil.example:3000/mcp. The browser re-resolves — the attacker now answers 127.0.0.1.
  3. The request lands here with Host: evil.example:3000.
  4. The browser considers it same-origin with the page — origin and target are both http://evil.example:3000 — so no preflight is sent, whatever the Content-Type, and the script can read the response.

Two consequences my reasoning missed, both worse than the premise error:

  • CYBERCHEF_ALLOWED_ORIGINS is not a mitigation for this. I had treated the CORS default-deny as covering the browser case. It is never consulted, because the browser never classifies the request as cross-origin.
  • initialize requires no session id, so a hostile page opens its own session and drives every tool — on a server whose recipe storage reaches the filesystem via caller-supplied CYBERCHEF_RECIPE_STORAGE.

Fixed: protection is now on by default.

function effectiveAllowedHosts() {
    if (hostCheckDisabled) return undefined;          // explicit CYBERCHEF_ALLOWED_HOSTS=*
    if (configuredHosts) return configuredHosts;
    const bound = httpServer.address();
    const actualPort = (bound && typeof bound === "object" ? bound.port : null) ?? port;
    const names = new Set();
    for (const name of ["localhost", "127.0.0.1", "[::1]"]) {
        names.add(name);
        names.add(`${name}:${actualPort}`);
    }
    return [...names];
}

Two details worth stating, since both are easy to get wrong:

  • Resolved after listen(), not at construction. port: 0 asks the OS for an ephemeral port, so the real one is not known until the socket is bound. Computing the list eagerly would have produced an allowlist naming port 0, and the entire existing test suite runs on ephemeral ports — it would have failed closed on every test rather than protecting anything.
  • Every name is listed twice. The SDK compares the whole Host header by exact string (this._allowedHosts.includes(hostHeader)), and a browser includes the port whenever it is not the scheme default.

Four tests, speaking raw http rather than fetchfetch refuses to let a caller set Host, and a forged Host is the entire attack, so a fetch-based test could not have exercised this at all:

✓ is ON BY DEFAULT and rejects a rebound Host                          403 + "Invalid Host header"
✓ accepts the loopback names it is actually reached by, with and without the port
✓ honours an explicit allowlist instead of the default                 (defaults replaced, not merged)
✓ can be disabled outright with the explicit `*` opt-out

Breaking for one configuration, called out rather than buried: a server bound to 0.0.0.0 and reached by a LAN name or IP now needs CYBERCHEF_ALLOWED_HOSTS set. That is the secure-by-default trade, and it is what the MCP specification asks for.

The wrong reasoning was also written into prose in docs/guides/http-transport.md and README.md, so both are corrected in the same commit with the attack walkthrough — a comment fixed in code while the docs keep asserting the error is how the next reader gets it wrong again.

d267c5e6.


Suggestion: add ? to the nested-quantifier inner class — rejected, measured

The stated premise is that (a?)+ "causes catastrophic exponential backtracking on non-matching strings". It does not. V8 breaks a loop whose body matched empty, which defuses exactly this shape:

n     (a?)+$    (a?){10,}$   (a|a?)+$    (a+)+$
18       0.0          6.9      137.3       22.9
20       0.1          1.1       75.5       12.9
24       0.0          1.6     1203.5      209.6
28       0.0          2.2    19869.4       skip

(a?)+ is flat across the range; (a+)+ and (a|a?)+ are exponential. So the finding's own example is safe, and the screen is right to allow it.

The genuinely dangerous variant, (a|a?)+, is already rejected — by the correct rule:

(a?)+          ALLOWED
(a|a?)+        REJECTED (quantified-alternation)
(a+)+          REJECTED (nested-quantifier)
(a|a)+         REJECTED (quantified-alternation)
(a|ab)+        REJECTED (quantified-alternation)
(foo|bar)+     ALLOWED          <- disjoint branches, linear
(\w+\s?)+      ALLOWED

Its danger is overlapping alternation, which hasOverlappingQuantifiedAlternation decides per pattern, not the ?.

And the change would cost real false positives. [+*?] newly rejects (\w+\s?)+ and (a?b)+, both measured at 0.0 ms — and (\w+\s?)+ is about as ordinary a regex as exists. That is precisely the failure mode this file's own header warns against:

Each entry is deliberately narrow. A broad "contains nested quantifiers" test rejects legitimate patterns, and a screen that fires on ordinary input gets disabled by whoever trips over it — which is a worse outcome than not having it.

Suggestion: same ? change for nested-bounded-repeat — rejected, same measurement

(a?){10,} is linear (1–2 ms, flat as n grows). Rejecting it would buy nothing and cost the same class of false positives.

Nitpick: ignore ENOENT outright instead of logging it at debug — declined

cleanupStaleTempFiles sweeps files it did not stat atomically, so ENOENT is the expected race — another process finished its save between our readdir and our unlink — and the existing branch already treats it as benign by logging at debug while everything else goes to warn.

Silence and a debug line differ in exactly one situation, and it is the one that matters: an ENOENT storm means something is deleting staging files underneath us, and a level filter can be turned up to see it. An if (error.code !== "ENOENT") guard makes that unobservable at any log level, to save one line. The asymmetry favours keeping it.


npx vitest run tests/mcp/     767 passed, 22 files   (was 763; +4 rebinding tests)
npm run lint                  0 errors

`csv()` reads either an option or an env var. An env var is always a string, but the option comes
from a programmatic caller, so `{ allowedHosts: 3000 }` reached `raw.split(",")` and threw
`TypeError: raw.split is not a function` during construction -- a config typo taking the server
down rather than being ignored. `String(raw).split(",")` handles it.

Also: `// 1 hour` beside STALE_TEMP_AFTER_MS, so the value reads without doing the arithmetic.

Verified: MCP suite 767/767, lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication of the agy review (round 6)

Blocking: none. Two suggestions and a nitpick; two adopted, one acknowledged as already-correct.

Suggestion: csv() throws on a non-string, non-array value — adopted

Right, and reproducible:

$ (3000).split(",")
TypeError: raw.split is not a function

An env var is always a string, so this is unreachable from the environment — but value comes from a programmatic caller, where createTransport({ allowedHosts: 3000 }) is an easy typo, and the consequence is a TypeError thrown during construction. A config mistake should be ignored or reported, not take the server down. String(raw).split(",").

One correction to the location: csv() is in src/node/transports.mjs, not mcp-server.mjs. The finding is right regardless — noting it only because the line reference would send someone to the wrong file.

Suggestion: TOCTOU between fs.stat and fs.unlink in cleanupStaleTempFiles — acknowledged, no change

Agreed on both the analysis and the conclusion the review itself reaches. Adding the reasoning for why "acceptable" is the right verdict rather than a shrug:

The race is expected, not incidental. The sweep exists precisely because other processes are writing and renaming staging files concurrently, so a file vanishing between stat and unlink means a concurrent save finished — the good outcome. The ENOENT branch below already treats it that way.

And there is no fd-based fix available: funlink/unlinkat are not exposed by Node's fs, and opening the file first would only move the race, not close it. Nor is the failure mode dangerous — the worst case is unlinking a staging file a concurrent save is about to rename, which fails that save's rename with ENOENT and is exactly what the one-hour cutoff makes unreachable in practice.

Nitpick: // 1 hour beside STALE_TEMP_AFTER_MS — adopted

Fair. The JSDoc above explains why the value is what it is, but the value itself still needed arithmetic to read.


npx vitest run tests/mcp/     767 passed, 22 files
npm run lint                  0 errors

…r 2.0.0

CYBERCHEF_HTTP_PATH=/ made every request 404. The request path and the configured path were
normalized SEPARATELY, and only the request side mapped an empty result back to "/":

    request  "/"  -> split("?")[0].replace(/\/+$/,"") || "/"  ->  "/"
    config   "/"  ->              .replace(/\/+$/,"")         ->  ""

So the one path that could never work was the root, and the mismatch was invisible because the two
expressions looked equivalent. Both sides now go through normalizeEndpointPath(), which is the
whole point of extracting it -- they cannot drift again.

Two tests: the normalization table (including "//" and a query string), and a real server bound
with path "/" completing an initialize over a socket.

Also: the DNS-rebinding and temp-file security entries were sitting under [Unreleased] while this
branch is the 2.0.0 release. Moved into the 2.0.0 Security section where they ship, and the routing
fix added under Fixed.

Verified: MCP suite 769/769 across 22 files, lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication of the agy review (round 7)

Both adopted. The blocking finding is real and I confirmed it before fixing.

BLOCKING: CYBERCHEF_HTTP_PATH=/ 404s every request — adopted

Verified by running both normalizations side by side:

req        mcpPath    path     target   match?
/mcp       /mcp       /mcp     "/mcp"   OK
/mcp/      /mcp       /mcp     "/mcp"   OK
/          /          /        ""       404      <-- the finding
/other     /mcp       /other   "/mcp"   404

The cause is precisely as described: the two sides were normalized separately, and only the request side carried the || "/" fallback.

request  "/"  ->  split("?")[0].replace(/\/+$/, "") || "/"   ->  "/"
config   "/"  ->               .replace(/\/+$/, "")          ->  ""

So the one path that could never work was the root — and the mismatch was invisible on the page, because the two expressions read as equivalent.

Fixed by extracting normalizeEndpointPath() and running both sides through it, rather than by adding a second || "/". Two copies of a normalization rule that must agree is what produced this; one function cannot drift.

Two regression tests: the normalization table (/, //, "", undefined, trailing slashes, a query string), and a real server bound with path: "/" completing an initialize over a socket — the end-to-end case, since the unit table alone would not have caught a routing change.

Nitpick: the Security entries were under [Unreleased] — adopted, and it was more than cosmetic

Right, and worth more than "nitpick" on this particular PR: this branch is the 2.0.0 release and is about to be tagged, so shipping with those two entries under [Unreleased] would have published a changelog claiming that the DNS-rebinding hardening and the temp-file fix were not in the release that contains them.

Both moved into the 2.0.0 Security section, [Unreleased] reduced to "Nothing yet.", and the routing fix above added under Fixed.


npx vitest run tests/mcp/     769 passed, 22 files   (was 767)
npm run lint                  0 errors

…nning _v1

mcp-release.yml still hard-coded:

    GHCR_IMAGE_NAME: ${{ github.repository_owner }}/cyberchef-mcp_v1

So tagging v2.0.0 would have published the relicensed, breaking-change image into the package that
v2.0.0 declares FROZEN -- while README.md, the release notes, the user guide and the HTTP transport
guide all already tell users to pull `cyberchef-mcp_v2`. The docs and the workflow disagreed, and
the workflow would have won silently: nothing in the pipeline compares them.

The suffix is now derived from the tag's major version, so the v1.9.x maintenance line keeps
publishing security-only patches to `_v1` under Apache-2.0 without a second workflow, and any
future major needs no edit here at all. workflow_dispatch carries no tag, so it falls back to the
major from package.json's mcpVersion and emits a ::notice:: saying it did.

Verified: v2.0.0 -> _v2, v2.1.3 -> _v2, v1.9.4 -> _v1, v10.0.0 -> _v10, non-tag -> fallback (2).

DOCKER_HUB_SETUP.md was the last doc still naming _v1 as the current pull; corrected, with the
major-versioning rule stated so the next reader does not have to infer it from the workflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdpcvbjZMPmAxBkGJSsYvs
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication of the agy review (round 8)

Blocking: none. Both remaining items rejected on measurement — but chasing the first one turned up a genuine release blocker, recorded at the bottom.

Suggestion: branchesOverlap omits ? from its variable-length test — rejected

The review's own example does not hold up:

n     (a?|b)+$    (a|a?)+$    (a?|a)+$
20         0.0       544.5       629.0
24         0.1      1184.2      1146.3
28         0.0     18866.9     18272.2

(a?|b)+ is flat, and the reason is the one the rule is built on: a? and b are disjoint — no non-empty string matches both — so there is no ambiguity for the engine to explode on, whatever quantifier decorates a branch. What blows up is overlap, and both overlapping forms are already caught:

(a?|b)+      ALLOWED            <- linear, correctly allowed
(a?|a)+      REJECTED (quantified-alternation)
(a|a?)+      REJECTED (quantified-alternation)

Adding ? to the variable-length test would newly reject (a?|b)+, measured at 0.0 ms — a false positive, against a file whose header says in so many words that a screen firing on ordinary input is worse than no screen.

This is the third round the ? quantifier has come up, in three different rules. The measurements keep landing the same way, and I think the underlying reason is worth stating once: ? is what makes a branch nullable, and V8 breaks a loop whose body matched empty. Nullability is the thing that shape-based screens expect to be dangerous and that this engine specifically defuses.

Nitpick: (?:[+*]|\{\d+,\d*\}) matches {1} — rejected, it does not

The comma is mandatory in that alternative:

{1}    matches: false
{1,}   matches: true
{1,3}  matches: true

{1} has no comma, so it never reaches the \d*} tail. No change needed.


Found while verifying the release wiring: mcp-release.yml would have published v2.0.0 into _v1

Not from this review, but it belongs on this PR because it would have shipped:

GHCR_IMAGE_NAME: ${{ github.repository_owner }}/cyberchef-mcp_v1

Tagging v2.0.0 would have pushed the relicensed, breaking-change image into the package this very release declares frozen — while README.md, docs/releases/v2.0.0.md, the user guide and the HTTP transport guide all already tell users to pull cyberchef-mcp_v2. The docs and the workflow disagreed, and the workflow would have won silently, because nothing in the pipeline compares them.

The suffix is now derived from the tag's major version:

v2.0.0  -> cyberchef-mcp_v2      v1.9.4   -> cyberchef-mcp_v1   (maintenance line, unchanged)
v2.1.3  -> cyberchef-mcp_v2      v10.0.0  -> cyberchef-mcp_v10
workflow_dispatch (no tag) -> falls back to package.json mcpVersion's major, with a ::notice::

So the v1.9.x security-patch line keeps publishing to _v1 under Apache-2.0 without a second workflow, and no future major needs an edit here. DOCKER_HUB_SETUP.md was the last doc still naming _v1 as the current pull; corrected in the same commit.

c07960aa.

@doublegate
doublegate merged commit 57b14c1 into master Aug 31, 2026
15 checks passed
@doublegate
doublegate deleted the release/v2.0.0 branch August 31, 2026 16:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants