release: v2.0.0 — upstream v11.4.0, GPL-3.0-or-later, zero open security alerts - #83
Conversation
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
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
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 issuesNone found. SuggestionsNone found. NitpicksNone. Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-31 14:06 UTCAntigravity 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 issuesNone found. Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-31 13:59 UTCAntigravity 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
SuggestionsNone found. Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-31 13:50 UTCAntigravity 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 Blocking issuesNone found. Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-31 13:42 UTCAntigravity review (Gemini via Ultra)This PR updates the upstream CyberChef core to v11.4.0, correctly handles Blocking issues
Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-31 13:28 UTCAntigravity 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 Blocking issuesNone found. Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-31 13:21 UTCAntigravity 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 Blocking issuesNone found. Suggestions
NitpicksNone. Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-31 13:16 UTCAntigravity 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
Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-31 13:08 UTCAntigravity 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 issuesNone found. Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-08-31 12:54 UTCAntigravity review (Gemini via Ultra)
Blocking issues
Suggestions
NitpicksNone found. Automated first-pass review by |
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
Adjudication of the
|
…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>
|
/agy-review Re-running now that #85 is on |
… .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
Adjudication of the
|
| 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.mjs — all 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 theassert.equalfails, 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.
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
|
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
Adjudication of the
|
"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
Adjudication of the
|
…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
Adjudication of the
|
`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
Adjudication of the
|
…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
Adjudication of the
|
…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
Adjudication of the
|
The v2.0.0 release merge. 62 commits, 323 files, and
masteris a strict ancestor of this branch — no conflicts are possible.Tagging follows from
master, not from here. That is not a preference:mcp-release.ymlonly moves the Docker Hublatesttag whenis_default_branchis 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
tools/listClosing 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 changessrc/core/lib/, the six upstream-ownedsrc/node/*.mjsfiles and the config generators — and deletessrc/core/lib/ImageManipulation.mjs. Syncing operations withoutlib/orphans the library; syncinglib/without operations breaks the build.Rebuilt as an
rsync -a --deletemirror so additions, modifications and deletions apply atomically, with fork changes aspatches/fork/*.patchre-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
.trivyignoreentry, 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 ownServerand transport — the shape GHSA-345p-7cg4-v4c7 requires. Reproduced on the published_v1image 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 thetools/listpayload 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:Local, on the merge head:
After this merges
git tag -a v2.0.0 -F docs/releases/v2.0.0.md && git push origin v2.0.0— frommaster.mcp-release.ymlpublishesghcr.io/doublegate/cyberchef-mcp_v2and Docker Hub, and moveslatest.latestmoving 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-ArrayBufferleak in two image operations and the@xmldom/xmldom0.9 blocker holding back nine DoS advisories. Filing on a third-party repo is a maintainer call.