Skip to content

Gate green again: the adapter's errors are the adapter's, Bandit 1.13, one Hex for releases - #89

Merged
HackTuah merged 3 commits into
mainfrom
slice/045-gate-green-again
Oct 19, 2026
Merged

HackTuah merged 3 commits into
mainfrom
slice/045-gate-green-again

Conversation

@HackTuah

Copy link
Copy Markdown
Member

main's gate fails on every pull request for two outside reasons, and moving to the fixed Bandit exposed a defect in how the HTTP transport answers an adapter's own errors. This puts the gate back to green and fixes the defect.

What changes for a host

  • Bandit 1.13 or later is required when Bandit is present (EEF-CVE-2026-105659, EEF-CVE-2026-105660). A host holding Bandit below 1.13 gets a resolution conflict and raises its own requirement. A stdio-only host is unaffected.
  • An exception the HTTP adapter raises is the adapter's to answer. On Bandit 1.13 a malformed HTTP/2 request (a mid-body HEADERS frame without END_STREAM) is now a stream reset by Bandit, where 0.10.1 answered 500 / -32603 with an error-level stacktrace. A client that hangs up before the answer leaves no error line from this package. The host's own exceptions are unchanged: -32603 inside the envelope, with the request's id. The read-timeout 408 is unchanged.
  • The release tarball is built with one pinned Hex (tools/hex-version). Hex 2.5.2 changed the tarball's entry order, so the publisher and CI must agree.
  • The SBOM tool is mix_sbom 0.12.0, with no workaround.
  • The CHANGELOG carries the CVE IDs assigned so far to 0.10.1's advisories.

Evidence

  • Each behaviour change was seen red before its fix (commit messages).
  • A census over the compiled code holds every call into the adapter to one boundary function.
  • Seven mutants of the transport are each killed.
  • GATE_LEG=pinned GATE_AUDIT=require ./tools/gate.sh: Gate OK.

An exception raised inside a call this transport makes into the adapter
(reading the body, sending the response, asking the protocol) is the
adapter's, and now goes back to it as itself, with its own stacktrace.
call/2's rescue used to answer any exception without a :plug_status as a
fault of this package: 500, -32603, and an error-level stacktrace in the
host's log.

Measured on Bandit 1.13.1, which rejects a mid-body HTTP/2 HEADERS frame
without END_STREAM as malformed (RFC 9113 8.1) by raising its own stream
error out of read_body. Before this change, the client got:

    {":status", "500"} ... {"code":-32603,"message":"Internal error"}
    ** (Bandit.HTTP2.Errors.StreamError) Received trailers without END_STREAM
        lib/beam_mcp/transport/http.ex:725: read_by_deadline/5

After: the adapter resets the stream with PROTOCOL_ERROR at the frame.

How it holds:
- adapter/1 is the one boundary. An exception raised in it is thrown,
  tagged, past every rescue here, and re-raised by call/2. The adapter's
  read timeout (status 408) is still answered by this package, since its
  whole-body deadline is this package's contract.
- The host's code no longer runs anywhere a tag is passed on. dispatch/3
  computes the answer (the core's handle_message and its encoding) under
  the host-fault rescue and sends outside it. A host that throws the
  private tag is just a host throw: -32603, with the request's id.
- fault_response/4's re-raise branch is removed. Nothing reaches it any
  more except this package's own code, which is ours to answer.
- A census reads the compiled code. Every call into the adapter under
  lib/ must sit inside adapter/1, with the population derived from
  Plug.Conn's compiled forms (Plug.Conn.Adapter's callbacks, closed over
  their callers). adapter/1 itself must catch and throw the tag.

Red first, against the transport before this change: the planted
adapters (a read with no status, a send) and the census failed, the
census listing exactly the seven call sites (http.ex :721 :725 :767
:1649 :1661 :1689 :1701). A host throwing the tag was red against an
earlier version of this fix, in which the host's code ran under the
tag's pass-through.

Mutants, test/beam_mcp/transport/, 173 tests; each one killed:
- adapter/1 re-raises: 10 failures.
- call/2 drops the tag: 6 failures.
- One call site unwrapped: 1 failure (the census).
- The 408 branch removed: 2 failures.
- The host's answer passes a tag on: 1 failure.
- fault_response/4 re-raises everything: 1 failure.
- The encode moved inside the boundary: 1 failure.

Signed-off-by: Ayla Croft <aylacroft@proton.me>
The optional requirement moves from {:bandit, "~> 1.5"} to
{:bandit, "~> 1.13"}, and this repository's lock moves bandit 1.12.5 to
1.13.1 (thousand_island 1.6.0, hpax 1.1.0). Both advisories are fixed in
1.13.0:
- EEF-CVE-2026-105659: HTTP/2 blocked-send deadlines reset on every
  WINDOW_UPDATE.
- EEF-CVE-2026-105660: an HTTP/1 request target chooses conn.host and
  conn.port.

The gate's audit step was failing on every pull request:

    audit  FAIL (Found packages with security advisories;)
      bandit 1.12.5 - EEF-CVE-2026-105659 (HIGH)
      bandit 1.12.5 - EEF-CVE-2026-105660 (MEDIUM)

A host that holds Bandit below 1.13 now gets Hex's conflict at
resolution (measured with a scratch host on "~> 1.12.0"). A stdio-only
host is unaffected: tools/probe_optional_deps.sh still passes.

On 1.13.1, the two tests that pinned the HTTP/2 HEADERS-without-
END_STREAM hold failed, as written to fail the day the adapter ended it.
They now assert what happens: a stream reset with PROTOCOL_ERROR at the
first such frame, and a second request on the same connection served.
The WINDOW_UPDATE hold is unchanged on 1.13.1 (the advisory fixed the
send side), and the connection deadline still bounds it.

A real-listener test covers a client that resets the socket before the
answer: no error line of this package's in the host's log. The threat
model, the README and the CHANGELOG say what changed.

Signed-off-by: Ayla Croft <aylacroft@proton.me>
Hex 2.5.2 (hex_core 0.20) sorts a package tarball's entries and its
metadata files list, so one commit has a different checksum under Hex
2.5.1 and under 2.5.2 or later. Measured with isolated MIX_HOMEs:

    Hex 2.5.1: provenance_test 3 tests, 0 failures
    Hex 2.5.2: 1 failure (the entry order at provenance_test.exs:135)
    Hex 2.5.3: 1 failure

The publisher and CI must build with the same Hex, or the provenance
check fails at the next release:
- tools/hex-version names it (2.5.3).
- tools/release_tarball.sh refuses any other Hex, checked in the tree
  that builds, with the command that installs the right one.
- The provenance workflow installs it with mix local.hex.
- The test derives the expected entry order from the Hex that built the
  tarball, and checks that the script reads the pin file.
- docs/provenance.md says 0.10.1 and earlier reproduce only under Hex
  2.5.1.

tools/sbom.sh moves to mix_sbom 0.12.0 (its published digest), which
reads the tools: :optional entry in mix.exs, so the one-line rewrite in
the scratch copy is removed and the tool reads the tree as tagged.

Signed-off-by: Ayla Croft <aylacroft@proton.me>
@HackTuah
HackTuah requested a review from znmead October 19, 2026 19:36
@HackTuah
HackTuah merged commit 347ab1f into main Oct 19, 2026
19 checks passed
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.

1 participant