From d5f4036b55696e4edbc2b4b3a142f72aa0dd511f Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Mon, 19 Oct 2026 15:36:53 -0400 Subject: [PATCH 1/3] Hand the HTTP adapter's own exceptions back to the adapter 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 --- lib/beam_mcp/transport/http.ex | 201 +++++++----- test/beam_mcp/boundary/no_catalog_test.exs | 4 +- test/beam_mcp/boundary/package_reach_test.exs | 3 + .../transport/adapter_boundary_test.exs | 286 ++++++++++++++++++ test/beam_mcp/transport/http_test.exs | 199 ++++++++++++ 5 files changed, 622 insertions(+), 71 deletions(-) create mode 100644 test/beam_mcp/transport/adapter_boundary_test.exs diff --git a/lib/beam_mcp/transport/http.ex b/lib/beam_mcp/transport/http.ex index 05305d34..8f03c9eb 100644 --- a/lib/beam_mcp/transport/http.ex +++ b/lib/beam_mcp/transport/http.ex @@ -75,14 +75,15 @@ if Code.ensure_loaded?(Plug) do given what remains, so a client that has sent its headers and then drips the body is answered `408` when it lapses, however many bytes arrived and however the adapter splits the reads (over HTTP/2 the adapter's reader is asked for less than one frame, so every - DATA frame, an empty one included, returns to this clock; a stream kept open by control - frames alone, a WINDOW_UPDATE or a HEADERS without END_STREAM, is held past the - deadline by the adapter's own wait, which nothing outside it can end through an - interface the adapter offers: one frame per deadline holds a stream process - indefinitely, and whatever body then comes is refused; a WINDOW_UPDATE costs the client - thirteen bytes and the host nothing accumulated, a HEADERS without END_STREAM writes a - warning line per frame to the host's log carrying the client's header bytes; the - threat model states both, and the two `Bandit` listener options that bound the + DATA frame, an empty one included, returns to this clock; a stream kept open by + WINDOW_UPDATE frames alone is held past the deadline by the adapter's own wait, which + nothing outside it can end through an interface the adapter offers: one frame per + deadline holds a stream process indefinitely, and whatever body then comes is refused; + each frame costs the client thirteen bytes and the host nothing accumulated. An + exception the adapter raises (on `Bandit` 1.13, a HEADERS without END_STREAM in the + middle of a body, a malformed request) is handed back to the adapter, which resets the + stream; it is not answered as `-32603`. The threat model states the residue, and the + two `Bandit` listener options that bound the exposure: `http_2_options: [default_local_settings: [max_concurrent_streams: n]]` caps the held streams per connection, `http_2_options: [enabled: false]` removes HTTP/2 from the listener). That per-stream hold the adapter owns is bounded in @@ -367,24 +368,65 @@ if Code.ensure_loaded?(Plug) do } end + # THE ADAPTER'S OWN EXCEPTIONS GO BACK TO THE ADAPTER, AS THEMSELVES. + # + # Whose exception it is decides whose answer it is, and an exception raised inside a call + # this transport makes into the adapter is the adapter's: a malformed request it has + # rejected, a socket it has lost. Its own pipeline already handles that class -- Bandit's + # matches `Bandit.HTTPError`, `Bandit.TransportError` and its HTTP/2 stream and connection + # errors, and resets the stream, logs by its own `log_protocol_errors` setting and emits its + # telemetry. Measured on Bandit 1.13.1: a mid-stream HTTP/2 HEADERS without END_STREAM + # (malformed, RFC 9113 8.1) raised `StreamError` out of `read_body`, the rescue below took + # it for a fault of ours because it carries no `:plug_status`, and the client got 500 / + # -32603 with an error-level stacktrace in the host's log. "Carries no status" is a + # property of the exception; who raised it is the property that decides. + # + # `adapter/1` is the one boundary: every call into the adapter goes through it (a census + # holds that, its population derived from `Plug.Conn.Adapter`'s callbacks), and an + # exception raised there is thrown past every rescue in this module tagged with its + # original stacktrace, then re-raised here so the adapter's pipeline sees its own struct. + # The 408 a read timeout raises is the one the transport still answers itself (029a's + # contract), in `read_by_deadline/5`, by name of its status rather than of its module. + # + # The boundary is a `Plug.Conn` function that reaches the adapter, so it includes what Plug + # runs on the way: `send_resp` first runs the `before_send` callbacks plugs upstream of this + # one registered, and Plug's own cookie checks. Their exceptions are handed back too. That is + # their owner: upstream plug code whose exception reaches the adapter's handler in any Plug + # stack. Narrowing the boundary would mean reordering Plug's private send sequence. + # + # Exceptions only: an adapter that EXITS is still answered as a fault of ours (no Bandit + # 1.13 path found that exits while the stream process lives; recorded, not decided here). + @adapter_raised {__MODULE__, :adapter_raised} + @impl Plug def call(%Plug.Conn{} = conn, opts) do + serve(conn, opts) + catch + :throw, {@adapter_raised, exception, stacktrace} -> reraise exception, stacktrace + end + + defp adapter(fun) do + fun.() + rescue + exception -> throw({@adapter_raised, exception, __STACKTRACE__}) + end + + defp serve(conn, opts) do handle(conn, opts) rescue exception -> - # An exception carrying a status is the SERVER SIGNALLING, not a fault: Bandit raises - # Bandit.HTTPError with plug_status :request_timeout for a read timeout and - # :bad_request for a malformed transfer coding, and its own pipeline turns that into - # the response. Catching it turned a 408 into a 500, a 400 into a 500, and every - # stalled connection into an unauthenticated 5xx with an error-level stacktrace -- a - # regression this rescue introduced by widening to cover the crash class. - # - # The test is Plug.Exception.status/1, Plug's own protocol, which reads any exception's - # :plug_status field. It therefore holds for adapters other than Bandit and needs no - # reference to a module that may not be loaded. 500 means "carries no status of its - # own", which is the only case this transport should be answering for. + # Whatever reaches this rescue was raised by this package's own code: the adapter's + # exceptions go back through `adapter/1`'s tag, and the host's code runs only under + # `host_call/1` and `dispatch/3`'s host-fault rescue. So it is ours to answer, inside the + # envelope. (This rescue once handed status-carrying exceptions on to the adapter, by + # `Plug.Exception.status/1`, because the adapter's own read timeout and malformed-coding + # errors arrived here; who raised it is now the rule, and the status branch has no + # producer left.) fault_response(conn, exception, __STACKTRACE__, nil) catch + :throw, {@adapter_raised, _exception, _stacktrace} = raised -> + throw(raised) + kind, reason -> Logger.error(Exception.format(kind, reason, arities(__STACKTRACE__))) crash_response(conn) @@ -477,25 +519,14 @@ if Code.ensure_loaded?(Plug) do # Answering a crash must not itself crash: the payload is a constant, so there is nothing # in it that can fail to encode. - # An exception carrying a status is the SERVER SIGNALLING, not a fault: Bandit raises - # Bandit.HTTPError with plug_status :request_timeout for a read timeout and :bad_request - # for a malformed transfer coding, and its own pipeline turns that into the response. - # Catching it turned a 408 into a 500, a 400 into a 500, and every stalled connection into - # an unauthenticated 5xx with an error-level stacktrace -- a regression introduced by - # widening the rescue to cover the crash class, and measured against the previous commit - # rather than argued. - # - # The test is Plug.Exception.status/1, Plug's own protocol, which reads any exception's - # :plug_status. It therefore holds for adapters other than Bandit and needs no reference to - # a module that may not be loaded. 500 means "carries no status of its own", which is the - # only case this transport should be answering for. + # A fault of this package's own code: logged with arities only, answered -32603 inside the + # envelope. The status it may carry is not consulted: the adapter's status-carrying + # exceptions (its read timeout, a malformed transfer coding) no longer arrive here, they go + # back through `adapter/1`, and a status on an exception this package raised is not the + # adapter speaking. defp fault_response(conn, exception, stacktrace, id) do - if Plug.Exception.status(exception) == 500 do - Logger.error(Exception.format(:error, exception, arities(stacktrace))) - send_json(conn, 500, error(id, -32_603, "Internal error")) - else - reraise exception, stacktrace - end + Logger.error(Exception.format(:error, exception, arities(stacktrace))) + send_json(conn, 500, error(id, -32_603, "Internal error")) end defp crash_response(conn) do @@ -718,16 +749,21 @@ if Code.ensure_loaded?(Plug) do if remaining <= 0 do timed_out(conn, read_timeout) else - http1 = get_http_protocol(conn) != :"HTTP/2" + http1 = http_protocol(conn) != :"HTTP/2" length = piece_length(http1, size) try do - read_body(conn, length: length, read_length: @read_piece, read_timeout: remaining) - rescue - exception -> + adapter(fn -> + read_body(conn, length: length, read_length: @read_piece, read_timeout: remaining) + end) + catch + # The adapter's read timeout is the one adapter exception this transport answers + # itself: 029a's whole-body deadline is the package's contract, and its 408 is a + # JSON-RPC refusal the client can read. Every other one goes back to the adapter. + :throw, {@adapter_raised, exception, _stacktrace} = raised -> if Plug.Exception.status(exception) == 408, do: timed_out(conn, read_timeout), - else: reraise(exception, __STACKTRACE__) + else: throw(raised) else {:ok, piece, conn} when size + byte_size(piece) > @max_body_bytes -> too_large(conn) @@ -764,7 +800,7 @@ if Code.ensure_loaded?(Plug) do # still blocked and no other stream on the connection is still within its own deadline. # Over HTTP/1 there is no residue -- the loop bounds every read -- so nothing is armed. defp arm_connection_watchdog(conn, connection_timeout) do - if get_http_protocol(conn) == :"HTTP/2" and is_integer(connection_timeout) do + if http_protocol(conn) == :"HTTP/2" and is_integer(connection_timeout) do arm_watchdog_on(connection_pid(), connection_timeout) else :not_armed @@ -888,20 +924,20 @@ if Code.ensure_loaded?(Plug) do # remains". A body at the cap is then refused by the size rule on the next frame, or # accepted when the stream ends. # - # What this cannot reach: a frame that carries no DATA. A WINDOW_UPDATE, or a HEADERS - # without END_STREAM, re-arms the adapter's own wait for the whole of what this call gave - # it -- one such frame per deadline holds the stream indefinitely -- and that wait is a + # What this cannot reach: a frame that carries no DATA. A WINDOW_UPDATE re-arms the + # adapter's own wait for the whole of what this call gave it -- one such frame per + # deadline holds the stream indefinitely -- and that wait is a # receive on the adapter's messages that nothing outside it can end through an interface # the adapter offers. Two levers exist and both are declined: forging the adapter's own # message into the stream process's mailbox couples this package to a private protocol # that a release of the adapter can change silently; ending the stream process gives the # client no answer at all (the adapter forgets the stream and drops its frames). So a # stream kept open by control frames alone is held by the adapter past the deadline, and - # whatever body comes is refused here, since the clock is read on return. The cost - # differs by frame: a WINDOW_UPDATE is thirteen bytes and nothing accumulates; a HEADERS - # without END_STREAM -- a malformed request under RFC 9113, 8.1, which the adapter reads - # as trailers -- writes a warning line per frame to the host's log carrying the client's - # header bytes. The host's cost is one stream process held for as long as the frames + # whatever body comes is refused here, since the clock is read on return. A WINDOW_UPDATE + # is thirteen bytes and nothing accumulates. (A HEADERS without END_STREAM -- a malformed + # request under RFC 9113, 8.1 -- was the second such frame until `Bandit` 1.13, which now + # raises a stream error for it; `adapter/1` hands that back to the adapter, which resets + # the stream.) The host's cost is one stream process held for as long as the frames # keep coming, up to the adapter's streams-per-connection setting -- but bounded in # DURATION by the connection watchdog above: the stream itself cannot be ended from # outside the adapter, so at the connection deadline the whole connection is closed with @@ -1566,14 +1602,31 @@ if Code.ensure_loaded?(Plug) do # The response carries NO detail. The reason reaches the logger, where the host can see it; # an HTTP caller -- possibly unauthenticated, since authorize/1 is the host's and may # permit anyone -- learns only that the request failed. + # + # THE HOST'S CODE AND THE ADAPTER'S CALL ARE NOT UNDER ONE `try`. The answer is computed -- + # the core's `handle_message/2` (which runs the host's dispatch) and the encoding of what it + # returned -- under the host-fault rescue, and only then sent, outside it. With both under + # one `try`, the rescue had to let the adapter's tagged exception through, and a host that + # threw that private tag itself could choose the wire status through it (a review lane's + # probe: a forged throw answered a bare 400, no envelope). Here the host's code never runs + # where a tag is passed on, so a forged one is just a host throw: -32603, with the id. defp dispatch(conn, message, opts) do - do_dispatch(conn, message, opts) + case host_answer(message, opts) do + {:answer, status, body} -> + deliver(conn, status, body) + + {:fault, kind, reason, stacktrace} -> + host_fault(conn, kind, reason, stacktrace, message["id"]) + end + end + + defp host_answer(message, opts) do + {status, body} = answer(message, opts) + {:answer, status, body} rescue - exception -> - host_fault(conn, :error, exception, __STACKTRACE__, message["id"]) + exception -> {:fault, :error, exception, __STACKTRACE__} catch - kind, reason -> - host_fault(conn, kind, reason, __STACKTRACE__, message["id"]) + kind, reason -> {:fault, kind, reason, __STACKTRACE__} end # NOT call/2's rule, and what separates them is WHOSE exception it is. @@ -1643,22 +1696,22 @@ if Code.ensure_loaded?(Plug) do send_json(conn, 500, error(id, -32_603, "Internal error")) end - defp do_dispatch(conn, %{"method" => method} = message, _opts) - when method in @removed_in_modern do + # `{status, encoded body | nil}` for the request: computed, never sent, here. + defp answer(%{"method" => method} = message, _opts) when method in @removed_in_modern do case message["id"] do - nil -> send_resp(conn, 202, "") - id -> send_json(conn, 404, error(id, -32_601, "#{@method_not_found} #{method}")) + nil -> {202, nil} + id -> {404, Jason.encode!(error(id, -32_601, "#{@method_not_found} #{method}"))} end end - defp do_dispatch(conn, message, opts) do + defp answer(message, opts) do # Nothing is stamped: `check_headers/3` has held the body's `params._meta` to the header # and to the schema, so the core reads the era from the message the client actually sent. # Through `opts.server` and no literal: a call naming BeamMCP.Server here would serve the # default whatever the host passed, and a census over the transports refuses one. case opts.server.handle_message(opts.server.new(opts.server_opts), message) do {_state, nil} -> - send_resp(conn, 202, "") + {202, nil} {_state, %{"error" => %{"code" => -32_601, "message" => @method_not_found <> _}} = @@ -1676,19 +1729,29 @@ if Code.ensure_loaded?(Plug) do # The coupling to the core's wording is deliberate and pinned: a test asserts # `Server.handle_message/2` still prefixes this case with @method_not_found, so a # reword fails that test instead of silently turning every 404 back into a 200. - send_json(conn, 404, response) + {404, Jason.encode!(response)} {_state, response} -> - send_json(conn, 200, response) + {200, Jason.encode!(response)} end end - defp send_json(conn, status, payload) do - conn - |> put_resp_content_type("application/json") - |> send_resp(status, Jason.encode!(payload)) + defp deliver(conn, status, nil), do: send_empty(conn, status) + defp deliver(conn, status, body), do: send_encoded(conn, status, body) + + # The encode stays OUTSIDE the adapter boundary: a payload this package cannot encode is + # its own (or the host's) fault and is answered as one; only the send is the adapter's. + defp send_json(conn, status, payload), do: send_encoded(conn, status, Jason.encode!(payload)) + + defp send_encoded(conn, status, encoded) do + conn = put_resp_content_type(conn, "application/json") + adapter(fn -> send_resp(conn, status, encoded) end) end + defp send_empty(conn, status), do: adapter(fn -> send_resp(conn, status, "") end) + + defp http_protocol(conn), do: adapter(fn -> get_http_protocol(conn) end) + # Refusing before the body is read leaves the connection with an unread remainder. Saying # so makes the refusal clean rather than leaving the adapter to drain a body this server # has already declined, or to drop the connection without telling the client. One caller, @@ -1698,7 +1761,7 @@ if Code.ensure_loaded?(Plug) do # (RFC 9113, 8.2.2): a client answered that way saw a stream reset in place of the 403 # (a review lane's measurement). The header is HTTP/1's. defp close_after(conn) do - case get_http_protocol(conn) do + case http_protocol(conn) do :"HTTP/2" -> conn _ -> put_resp_header(conn, "connection", "close") end diff --git a/test/beam_mcp/boundary/no_catalog_test.exs b/test/beam_mcp/boundary/no_catalog_test.exs index 79b94213..e511a24a 100644 --- a/test/beam_mcp/boundary/no_catalog_test.exs +++ b/test/beam_mcp/boundary/no_catalog_test.exs @@ -128,8 +128,8 @@ defmodule BeamMCP.Boundary.NoCatalogTest do # option's module, `BeamMCP.Server` by default -- HTTP's `new/1` and # `handle_message/2` per request, stdio's the same plus `shutdown?/1` after each # message -- and never by name, pinned by the no-server-literal census. - {{BeamMCP.Transport.HTTP, :do_dispatch, 3}, :handle_message, 2}, - {{BeamMCP.Transport.HTTP, :do_dispatch, 3}, :new, 1}, + {{BeamMCP.Transport.HTTP, :answer, 2}, :handle_message, 2}, + {{BeamMCP.Transport.HTTP, :answer, 2}, :new, 1}, {{BeamMCP.Transport.Stdio, :answer, 3}, :handle_message, 2}, {{BeamMCP.Transport.Stdio, :loop, 2}, :shutdown?, 1}, {{BeamMCP.Transport.Stdio, :run, 1}, :new, 1} diff --git a/test/beam_mcp/boundary/package_reach_test.exs b/test/beam_mcp/boundary/package_reach_test.exs index 9a7c2501..c6409836 100644 --- a/test/beam_mcp/boundary/package_reach_test.exs +++ b/test/beam_mcp/boundary/package_reach_test.exs @@ -134,6 +134,9 @@ defmodule BeamMCP.Boundary.PackageReachTest do send: 2, spawn: 1, system_time: 0, + # The adapter's own exception carried past this package's rescues to be re-raised as itself + # (Transport.HTTP's adapter/1); a non-local return inside one process, reaching nothing. + throw: 1, # A float with no fraction to the integer it equals, so 1.0 compares as 1 (Schema.json_form/1). trunc: 1, tuple_size: 1, diff --git a/test/beam_mcp/transport/adapter_boundary_test.exs b/test/beam_mcp/transport/adapter_boundary_test.exs new file mode 100644 index 00000000..a752bacd --- /dev/null +++ b/test/beam_mcp/transport/adapter_boundary_test.exs @@ -0,0 +1,286 @@ +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 + +defmodule BeamMCP.Transport.AdapterBoundaryTest do + # Every call this package makes into the HTTP adapter goes through `adapter/1`. + # + # An exception raised inside a call into the adapter is the adapter's, and goes back to it as + # itself (`BeamMCP.Transport.HTTP`, the comment above `call/2`). That holds only if every such + # call is inside the boundary function: one call outside it and that call's exceptions are + # answered as faults of ours again, which is the defect Bandit 1.13 exposed. + # + # READ FROM THE COMPILED CODE, NOT THE SOURCE TEXT. A first version read the source, and two + # review lanes planted calls it could not see: Plug's default arguments (`read_body(conn)`, + # a bodiless `send_file/5` head), captures, an alias, an `Elixir.`-prefixed module, `apply/3`, + # a module calling Plug by name without importing it, a closure built inside `adapter/1` and + # run outside it. In the abstract code every one of those is the same thing -- a remote call + # to `Plug.Conn`, a fun reference to it, or `erlang:apply/3` naming it -- so the census reads + # that, and a new spelling of the same call is not a new case. + # + # THE POPULATION IS DERIVED FROM THE AUTHORITY, NOT LISTED: `Plug.Conn.Adapter`'s callbacks, + # and the functions in Plug.Conn's own compiled forms that call one of them through the + # conn's adapter module, closed over the functions that call those. Every arity a default + # argument generates is its own function there, so defaults need no reading. + # + # Out of reach of this census, and held elsewhere: a call through a variable module (the + # adapter module taken out of `conn.adapter`, a module computed at runtime). Every such call + # under lib/ is pinned by name in `test/beam_mcp/boundary/no_catalog_test.exs`, so a new one + # fails there. + use ExUnit.Case, async: true + alias BeamMCP.Boundary + alias Plug.Conn.Adapter + + @tag_atom :adapter_raised + + defp forms_of(beam) do + {:ok, {_, [{:abstract_code, {:raw_abstract_v1, forms}}]}} = + :beam_lib.chunks(beam, [:abstract_code]) + + forms + end + + # The beam on disk in the build's ebin, not `:code.which/1` (which answers `cover_compiled` + # under `mix test --cover`). + defp lib_beam(m), + do: Mix.Project.compile_path() |> Path.join("#{m}.beam") |> String.to_charlist() + + @doc false + def adapter_reaching_functions do + callbacks = Adapter.behaviour_info(:callbacks) |> Enum.map(&elem(&1, 0)) |> MapSet.new() + + defs = + for {:function, _, name, arity, clauses} <- forms_of(:code.which(Plug.Conn)), + into: %{}, + do: {{name, arity}, clauses} + + direct = + for {key, clauses} <- defs, calls_adapter?(clauses, callbacks), into: MapSet.new(), do: key + + reach = + Stream.iterate(direct, &widen(&1, defs)) + |> Enum.reduce_while(nil, fn r, prev -> if r == prev, do: {:halt, r}, else: {:cont, r} end) + + MapSet.intersection(reach, MapSet.new(Plug.Conn.__info__(:functions))) + end + + # A remote call through a variable module whose function is an adapter callback: Plug's own + # `adapter.send_resp(payload, ...)` with `{adapter, payload} = conn.adapter`. + defp calls_adapter?(clauses, callbacks) do + any_node?(clauses, fn + {:call, _, {:remote, _, {:var, _, _}, {:atom, _, cb}}, _} -> MapSet.member?(callbacks, cb) + _ -> false + end) + end + + defp widen(reach, defs) do + for {key, clauses} <- defs, calls_known?(clauses, reach), into: reach, do: key + end + + defp calls_known?(clauses, reach) do + any_node?(clauses, fn + {:call, _, {:atom, _, name}, args} -> MapSet.member?(reach, {name, length(args)}) + {:fun, _, {:function, name, arity}} -> MapSet.member?(reach, {name, arity}) + _ -> false + end) + end + + defp any_node?(term, pred) do + pred.(term) or + case term do + t when is_tuple(t) -> t |> Tuple.to_list() |> any_node?(pred) + l when is_list(l) -> Enum.any?(l, &any_node?(&1, pred)) + _ -> false + end + end + + # Every reach of the population in a module's forms, as `{name, arity, line, inside?}`. + # Inside means: in the body of the fun passed directly to a local `adapter/1`. A fun nested + # deeper is a closure that runs wherever it is applied, so its body is outside again. + @doc false + def adapter_calls(forms, pop), do: walk(forms, false, pop, []) + + defp walk({:call, _, {:atom, _, :adapter}, [{:fun, _, {:clauses, clauses}}]}, _in, pop, acc), + do: walk(clauses, true, pop, acc) + + defp walk( + {:call, loc, {:remote, _, {:atom, _, Plug.Conn}, {:atom, _, f}}, args}, + inside, + pop, + acc + ) do + acc = if {f, length(args)} in pop, do: [{f, length(args), line(loc), inside} | acc], else: acc + walk(args, inside, pop, acc) + end + + defp walk( + {:call, loc, {:remote, _, {:atom, _, :erlang}, {:atom, _, :apply}}, + [{:atom, _, Plug.Conn}, {:atom, _, f} | _] = args}, + inside, + pop, + acc + ) do + acc = + if Enum.any?(pop, &(elem(&1, 0) == f)), + do: [{f, :apply, line(loc), false} | acc], + else: acc + + walk(args, inside, pop, acc) + end + + defp walk( + {:fun, loc, {:function, {:atom, _, Plug.Conn}, {:atom, _, f}, {:integer, _, a}}}, + _in, + pop, + acc + ), + do: if({f, a} in pop, do: [{f, a, line(loc), false} | acc], else: acc) + + defp walk({:fun, _, {:clauses, clauses}}, _inside, pop, acc), do: walk(clauses, false, pop, acc) + defp walk({:named_fun, _, _, clauses}, _inside, pop, acc), do: walk(clauses, false, pop, acc) + defp walk(t, inside, pop, acc) when is_tuple(t), do: walk(Tuple.to_list(t), inside, pop, acc) + + defp walk(l, inside, pop, acc) when is_list(l), + do: Enum.reduce(l, acc, &walk(&1, inside, pop, &2)) + + defp walk(_leaf, _inside, _pop, acc), do: acc + + defp line({l, _}), do: l + defp line(l), do: l + + # The boundary function itself: `adapter/1` must catch what it runs and THROW it tagged with + # its module. A look-alike `defp adapter(f), do: f.()` would satisfy the placement census and + # hand nothing back, so the definition is held to its shape. + @doc false + def boundary_defined?(forms, module) do + Enum.any?(forms, fn + {:function, _, :adapter, 1, clauses} -> + any_node?(clauses, fn + {:try, _, _body, _else, catches, _after} -> + Enum.any?(catches, &throws_tag?(&1, module)) + + _ -> + false + end) + + _ -> + false + end) + end + + defp throws_tag?(clause, module) do + any_node?(clause, fn + {:call, _, {:remote, _, {:atom, _, :erlang}, {:atom, _, :throw}}, + [{:tuple, _, [{:tuple, _, [{:atom, _, ^module}, {:atom, _, @tag_atom}]} | _]}]} -> + true + + _ -> + false + end) + end + + test "the population is derived from Plug's compiled forms and holds the calls this package makes" do + pop = adapter_reaching_functions() + + # Not the census's list: a check that the derivation reads Plug the way it says it does, + # defaults and bodiless heads included (both found missing by review lanes in the + # source-reading version). + for f <- [ + {:read_body, 1}, + {:read_body, 2}, + {:send_file, 3}, + {:send_file, 4}, + {:send_file, 5}, + {:inform, 2}, + {:push, 2}, + {:send_resp, 1}, + {:send_resp, 3}, + {:get_http_protocol, 1}, + {:send_chunked, 2} + ], + do: assert(f in pop, "#{inspect(f)} not derived from Plug.Conn's compiled forms") + + refute {:put_resp_header, 3} in pop, "a pure conn function was taken for an adapter call" + end + + test "every call into the adapter under lib/ sits inside adapter/1, and adapter/1 is the boundary" do + pop = adapter_reaching_functions() + + {outside, callers} = + Enum.reduce(Boundary.lib_modules(), {[], []}, fn m, {out, callers} -> + calls = m |> lib_beam() |> forms_of() |> adapter_calls(pop) + bad = for {f, a, line, false} <- calls, do: "#{inspect(m)} line #{line} #{f}/#{a}" + {out ++ bad, if(calls == [], do: callers, else: [m | callers])} + end) + + assert outside == [], + "calls into the adapter outside adapter/1:\n " <> Enum.join(outside, "\n ") + + assert callers != [], "no module under lib/ calls into the adapter: the census reads nothing" + + for m <- callers do + assert boundary_defined?(forms_of(lib_beam(m)), m), + "#{inspect(m)}: adapter/1 does not catch and throw the tagged exception" + end + end + + test "the census sees every shape of a call into the adapter that review lanes planted" do + pop = adapter_reaching_functions() + + planted = + Module.concat([BeamMCP, PlantedAdapterCalls, "M#{System.unique_integer([:positive])}"]) + + source = """ + defmodule #{inspect(planted)} do + import Plug.Conn + alias Plug.Conn, as: C + + def defaults(conn), do: read_body(conn) + def bodiless_head(conn), do: send_file(conn, 200, "f") + def aliased(conn), do: C.inform(conn, 103, []) + def by_name(conn), do: Plug.Conn.send_resp(conn, 200, "") + def prefixed(conn), do: :"Elixir.Plug.Conn".send_resp(conn, 200, "") + def captured(conns), do: Enum.map(conns, &get_http_protocol/1) + def remote_capture, do: &Plug.Conn.send_resp/3 + def applied(conn), do: apply(Plug.Conn, :send_resp, [conn, 200, ""]) + def piped(conn), do: conn |> send_resp(200, "") + def deferred(conn), do: adapter(fn -> fn -> send_resp(conn, 200, "") end end) + def wrapped(conn), do: adapter(fn -> send_resp(conn, 200, "") end) + defp adapter(fun), do: fun.() + end + """ + + # The planted module needs its abstract code, which `Code.compile_string/1` keeps only with + # `debug_info` on; the option is restored at once. + previous = Code.get_compiler_option(:debug_info) + Code.put_compiler_option(:debug_info, true) + [{^planted, beam}] = Code.compile_string(source) + Code.put_compiler_option(:debug_info, previous) + + try do + calls = beam |> forms_of() |> adapter_calls(pop) + outside = for {f, a, _l, false} <- calls, do: {f, a} + inside = for {f, a, _l, true} <- calls, do: {f, a} + + assert Enum.sort(outside) == + Enum.sort([ + {:read_body, 1}, + {:send_file, 3}, + {:inform, 3}, + {:send_resp, 3}, + {:send_resp, 3}, + {:get_http_protocol, 1}, + {:send_resp, 3}, + {:send_resp, :apply}, + {:send_resp, 3}, + {:send_resp, 3} + ]) + + assert inside == [{:send_resp, 3}] + # The look-alike `adapter/1` above runs its fun and catches nothing: not the boundary. + refute boundary_defined?(forms_of(beam), planted) + after + :code.purge(planted) + :code.delete(planted) + end + end +end diff --git a/test/beam_mcp/transport/http_test.exs b/test/beam_mcp/transport/http_test.exs index 28add261..a1ea42e1 100644 --- a/test/beam_mcp/transport/http_test.exs +++ b/test/beam_mcp/transport/http_test.exs @@ -1535,6 +1535,12 @@ defmodule BeamMCP.Transport.HTTPTest do # slice; it is recorded in slice 003's findings (internal) under "Open, recorded rather # than fixed", not described here as filed -- the last time this comment said something was # filed, grep found exactly one hit and it was the comment. + # + # SUPERSEDED, 2026-10-19 (appended; the record above stands). The re-raise branch no longer + # exists. An adapter's exception, status-carrying or not, is handed back through + # `adapter/1`'s tag before any rescue here sees it (the planted adapters below hold that), + # so `fault_response/4` answers every exception that reaches it, and the "NEVER re-raises" + # mutant has nothing left to mutate. test "an exception with no status of its own is still answered as -32603" do o = opts(dispatch: fn _, _, _ -> raise "ordinary fault" end) @@ -1557,6 +1563,199 @@ defmodule BeamMCP.Transport.HTTPTest do end end + # THE ADAPTER'S OWN EXCEPTIONS ARE THE ADAPTER'S. A planted adapter stands in for the case + # `Plug.Test` cannot produce: its `read_body/2` is a `:binary.part` of an in-memory binary, so + # nothing in this suite could make the adapter raise (the comment above records the cost: the + # "fault_response NEVER re-raises" mutant survived for that reason; the branch it mutated is + # gone, since an adapter's exception no longer reaches `fault_response/4`). Each planted adapter is + # `Plug.Adapters.Test.Conn` with one callback replaced by a raise. + # + # Why it matters, measured: Bandit 1.13 raises `Bandit.HTTP2.Errors.StreamError` out of + # `read_body` for a malformed HTTP/2 request. It carries no `:plug_status`, so `call/2`'s + # rescue answered it 500 / -32603 and logged an error-level stacktrace, where Bandit's own + # pipeline would have reset the stream. Who RAISED the exception decides whose answer it is, + # not whether it carries a status. + defmodule AdapterSignal do + defexception message: "the adapter's own signal" + end + + defmodule AdapterStatusSignal do + defexception message: "the adapter's own 400", plug_status: 400 + end + + defmodule PlantedAdapter do + @moduledoc false + # `{raise_in, exception, inner_state}`: raise `exception` from callback `raise_in`, and + # delegate everything else to Plug's test adapter with its own state. + @behaviour Plug.Conn.Adapter + alias Plug.Adapters.Test.Conn, as: T + + def wrap(%Plug.Conn{adapter: {T, state}} = conn, raise_in, exception), + do: %{conn | adapter: {__MODULE__, {raise_in, exception, state}}} + + defp go({cb, exception, _}, cb, _fun), do: raise(exception) + + defp go({_, _, state} = s, _cb, fun) do + case fun.(state) do + {:ok, body, state} -> {:ok, body, put(s, state)} + {:more, body, state} -> {:more, body, put(s, state)} + {:ok, state} when is_map(state) -> {:ok, put(s, state)} + other -> other + end + end + + defp put({cb, exception, _}, state), do: {cb, exception, state} + + @impl true + def send_resp(s, st, h, b), do: go(s, :send_resp, &T.send_resp(&1, st, h, b)) + @impl true + def read_req_body(s, o), do: go(s, :read_req_body, &T.read_req_body(&1, o)) + @impl true + def send_file(s, st, h, p, o, l), do: go(s, :send_file, &T.send_file(&1, st, h, p, o, l)) + @impl true + def send_chunked(s, st, h), do: go(s, :send_chunked, &T.send_chunked(&1, st, h)) + @impl true + def chunk(s, b), do: go(s, :chunk, &T.chunk(&1, b)) + @impl true + def inform(s, st, h), do: go(s, :inform, &T.inform(&1, st, h)) + @impl true + def upgrade(s, p, o), do: go(s, :upgrade, &T.upgrade(&1, p, o)) + @impl true + def push(s, p, h), do: go(s, :push, &T.push(&1, p, h)) + @impl true + def get_peer_data({_, _, state}), do: T.get_peer_data(state) + @impl true + def get_sock_data({_, _, state}), do: T.get_sock_data(state) + @impl true + def get_ssl_data({_, _, state}), do: T.get_ssl_data(state) + @impl true + def get_http_protocol({_, _, state}), do: T.get_http_protocol(state) + end + + describe "an exception the adapter raises goes back to the adapter as itself" do + defp planted_post(raise_in, exception) do + body = call_body(%{}) + + conn = + :post + |> conn("/mcp", Jason.encode!(body)) + |> put_req_header("content-type", "application/json") + + conn = Enum.reduce(call_headers([]), conn, fn {k, v}, c -> put_req_header(c, k, v) end) + PlantedAdapter.wrap(conn, raise_in, exception) + end + + # Returns the exception and stacktrace `HTTP.call/2` raised, and what was logged meanwhile. + defp raised_by_call(conn) do + parent = self() + + log = + ExUnit.CaptureLog.capture_log(fn -> + try do + HTTP.call(conn, opts()) + send(parent, {:returned, :no_raise}) + rescue + exception -> send(parent, {:raised, exception, __STACKTRACE__}) + end + end) + + receive do + {:raised, exception, stacktrace} -> {exception, stacktrace, log} + {:returned, :no_raise} -> flunk("HTTP.call/2 answered instead of handing the error back") + end + end + + test "a read that raises with no status of its own is handed back unchanged, not answered as -32603" do + planted = %AdapterSignal{message: "malformed, says the adapter"} + {exception, stacktrace, log} = raised_by_call(planted_post(:read_req_body, planted)) + + assert exception == planted + # The original stacktrace, not a re-raise from inside this package: its top frame is + # the planted adapter, where the exception was raised. + assert {PlantedAdapter, _fun, _arity, _loc} = hd(stacktrace) + refute log =~ "[error]", "the transport logged the adapter's error as a fault: " <> log + end + + test "a read that raises with a status of its own is handed back too" do + # A status-carrying exception raised by the adapter goes back through `adapter/1`'s tag + # like any other: the adapter's pipeline answers it, not this one. (Before, such an + # exception reached `fault_response/4`, whose re-raise branch nothing here could reach; + # that branch no longer exists.) + planted = %AdapterStatusSignal{} + {exception, _stacktrace, log} = raised_by_call(planted_post(:read_req_body, planted)) + + assert exception == planted + refute log =~ "[error]" + end + + test "a send that raises is handed back unchanged, and is not logged as a host fault" do + # A response the adapter cannot send (on Bandit, a closed socket) is the adapter's to + # report. Before, it surfaced through dispatch/3's host-fault branch and call/2's fault + # branch: two error lines blaming the host's code and this one for the adapter's state. + planted = %AdapterSignal{message: "the peer went away"} + {exception, _stacktrace, log} = raised_by_call(planted_post(:send_resp, planted)) + + assert exception == planted + refute log =~ "[error]", "the adapter's send error was logged as a fault: " <> log + end + + test "the host's own exceptions are still ours to answer, inside the envelope" do + # The other side of the line: the rule is WHO raised it. A host dispatch that raises the + # very same exception struct is a host fault, -32603 with the request's id, as before. + o = opts(dispatch: fn _, _, _ -> raise AdapterSignal, "raised by the host" end) + body = call_body(%{}) |> Map.put("id", 4244) + + log = + ExUnit.CaptureLog.capture_log(fn -> + conn = post(body, call_headers([]), o) + assert conn.status == 500 + assert body!(conn)["error"]["code"] == -32_603 + assert body!(conn)["id"] == 4244 + end) + + assert log =~ "raised by the host" + end + + test "a host that throws the transport's private tag is still a host fault, and cannot choose the status" do + # A review lane's probe: the tag that carries the adapter's exception is a constant term, + # and a dispatch throwing it with Plug.BadRequestError got a bare adapter 400 with no + # envelope. The host's code no longer runs anywhere a tag is passed on, so the throw is + # just a throw out of the host: -32603 in the envelope, with the request's id. + forged = + {{HTTP, :adapter_raised}, %Plug.BadRequestError{}, []} + + o = opts(dispatch: fn _, _, _ -> throw(forged) end) + body = call_body(%{}) |> Map.put("id", 4245) + + ExUnit.CaptureLog.capture_log(fn -> + conn = post(body, call_headers([]), o) + assert conn.status == 500 + assert body!(conn)["error"]["code"] == -32_603 + assert body!(conn)["id"] == 4245 + end) + end + + test "a read timeout the adapter raises (status 408) is still answered by this transport, as its JSON-RPC 408" do + # The one adapter exception this package answers itself: the whole-body deadline is its + # contract (the drip tests hold it on a real listener over HTTP/1.1; this holds the branch + # without a socket, so relaxing that one test cannot let the branch go unpinned). + timeout = %Plug.TimeoutError{} + assert Plug.Exception.status(timeout) == 408 + + conn = planted_post(:read_req_body, timeout) + + log = + ExUnit.CaptureLog.capture_log(fn -> + conn = HTTP.call(conn, opts()) + assert conn.status == 408 + assert body!(conn)["error"]["code"] == -32_600 + assert body!(conn)["error"]["message"] =~ "Request body not received within" + end) + + refute log =~ "[error]" + end + end + describe ":authorize_body/2: the post-read hook, and the bytes it is handed" do # `authorize/1` runs before the body is read, which is what lets it refuse an # unauthenticated caller without buffering megabytes for them. The cost is that it cannot From ecba182aa1b10b0a7222508728237b71a0ed45f8 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Mon, 19 Oct 2026 15:36:54 -0400 Subject: [PATCH 2/3] Require Bandit 1.13 for its two advisories 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 --- CHANGELOG.md | 43 +++++++++++ README.md | 13 ++-- docs/threat-model.md | 2 +- mix.exs | 6 +- mix.lock | 6 +- test/beam_mcp/transport/http_bandit_test.exs | 79 +++++++++++++++----- test/support/beam_mcp/h2c.ex | 5 +- 7 files changed, 123 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1438d95e..b46cbfb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,49 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Changed: a request the HTTP adapter rejects is the adapter's to answer + +- An exception raised inside a call this transport makes into the adapter (reading the + body, sending the response, asking the protocol) is handed back to the adapter unchanged, + so its own pipeline answers it. Before, one carrying no `:plug_status` was answered as a + fault of this package: `500`, `-32603` "Internal error", and an error-level stacktrace in + the host's log. **How to tell whether you are affected:** a host on `Bandit` 1.13 or later + serving HTTP/2. `Bandit` 1.13 rejects a mid-body HTTP/2 HEADERS frame without END_STREAM + as malformed (RFC 9113, 8.1) and now resets the stream with `PROTOCOL_ERROR`, logged by + `Bandit`'s own `log_protocol_errors` setting, where 0.10.1 answered `500`. Host code's own + exceptions are unchanged: still `-32603` inside the envelope, with the request's id. The + send counts as the adapter's call including the `before_send` callbacks that plugs ahead of + this one registered, which Plug runs inside `send_resp`: an exception from one of them now + reaches the adapter's error handling, as it would in any Plug stack, rather than this + package's `-32603`. A client that resets the connection before the answer is sent no longer + leaves error lines from this package in the host's log. +- The adapter's read timeout is still answered by this package as its `408` JSON-RPC + refusal: the whole-body deadline is this package's. + +### Security: Bandit 1.13 or later + +- The optional requirement moves from `{:bandit, "~> 1.5"}` to `{:bandit, "~> 1.13"}`, for + EEF-CVE-2026-105659 (HTTP/2 blocked-send deadlines reset on every WINDOW_UPDATE) and + EEF-CVE-2026-105660 (an HTTP/1 request target choosing `conn.host` and `conn.port`), both + fixed in `Bandit` 1.13.0. A host that has `Bandit` beside this package now resolves a fixed + one; a host on the stdio transport alone, or on another adapter, is unaffected (the + dependency stays optional). **If `mix deps.get` reports a conflict,** your own requirement + holds `Bandit` below 1.13: raise it to `"~> 1.13"`. `Bandit` 1.13 requires `plug` 1.18 or + later, `thousand_island` 1.6 and `hpax` 1.1, and Elixir 1.13 or later (this package's own + floor, 1.17, is above it). Its other changes a host may see: HTTP/1.1 requests without a + valid `Host` are refused, and idle connections that send nothing are closed without a `408` + (its CHANGELOG). This package reads the `Origin` header for its own check, never + `conn.host`; an `authorize/1` that reads `conn.host` or `conn.port` needed the fix. +- This repository's own lock moves `bandit` 1.12.5 to 1.13.1, with `thousand_island` 1.6.0 + and `hpax` 1.1.0. + +### Added: CVE IDs for 0.10.1's advisories + +- The Erlang Ecosystem Foundation's CNA assigned two of 0.10.1's CVEs: + CVE-2026-104634 is GHSA-wv7p-j6qh-3hj4 ("JSON `true`, `false` and `null` reach dispatch as + themselves"), and CVE-2026-88257 is GHSA-mrg2-4747-fmpw ("the advertised schema is enforced + at every depth"). GHSA-rq49-3p32-pggg and GHSA-x58h-5gfm-x4p8 have no CVE ID yet. + ### Changed: CVE IDs come from the EEF CNA (SECURITY.md) - `SECURITY.md` said GitHub assigns this package's CVEs. GitHub is a CNA but does not assign diff --git a/README.md b/README.md index 362f1801..ef77bf79 100644 --- a/README.md +++ b/README.md @@ -360,12 +360,13 @@ the connection is clean, and an ordinary response is possible. Measured through a real `Bandit` listener: `408` at 300, 301, 327 ms for a 300 ms deadline and 1,500, 1,500, 1,501 ms for 1,500 ms; over HTTP/2 a twenty-frame drip answered at the deadline. One residue is the adapter's, stated on the threat model's row with its cost: over - HTTP/2 a stream kept open by control frames alone (a WINDOW_UPDATE, or a HEADERS without - END_STREAM) is held past the deadline by the adapter's own wait, which nothing outside it - can end through an interface the adapter offers: one frame per deadline holds a stream - process indefinitely, whatever body then comes is refused, a WINDOW_UPDATE costs thirteen - bytes with nothing accumulated, and a HEADERS without END_STREAM writes a warning line per - frame to the host's log carrying the client's header bytes. The stream is the adapter's to + HTTP/2 a stream kept open by WINDOW_UPDATE frames alone is held past the deadline by the + adapter's own wait, which nothing outside it can end through an interface the adapter + offers: one frame per deadline holds a stream process indefinitely, whatever body then + comes is refused, and each frame costs thirteen bytes with nothing accumulated. (A HEADERS + without END_STREAM held a stream the same way until `Bandit` 1.13, which resets the stream + as a malformed request; this transport hands that error back to the adapter rather than + answering it.) The stream is the adapter's to end, but the **connection** is this package's: `connection_timeout:` (default twice `read_timeout`) closes a connection whose body read has been held that long, with nothing else on it still within its own deadline, using a `GOAWAY` the client can read, so the diff --git a/docs/threat-model.md b/docs/threat-model.md index f17757b9..c12223c3 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -55,7 +55,7 @@ reading of each entry's title, not a claim of coverage. | **Header injection; header–body disagreement** (the MCP-layer smuggling class) | REFUSED | Over HTTP every request carries `MCP-Protocol-Version`, `Mcp-Method`, and for the three methods that name a target `Mcp-Name`, plus any `Mcp-Param-{Name}` a tool's schema declares through `x-mcp-header`; each is held to the body and a disagreement, a missing header, or a second disagreeing value is `-32020` / `400`. A schema may annotate only primitive-typed properties; a tool whose schema annotates an object or a non-primitive is a host fault (every call on it is `500` / `-32603` until the schema is corrected, and the host's log names the header), while a caller's header that disagrees with the body is `-32020` naming the schema's header, never the caller's string. A request's `_meta` is read at `params._meta` only; the top level is refused with `-32602` naming the place, not accepted as a fallback. | `test/beam_mcp/transport/http_test.exs` "a header disagreeing with the body's _meta is a HeaderMismatch" "the header is matched to params._meta: a disagreement is -32020 with 400" "Mcp-Param-{Name}: a second, disagreeing value is not ignored" "a header the schema requires and the client omits is refused" "a refusal names the schema's header, never the caller's string" "an annotated `object` property is the same fault" "the refusal covers every header the transport reads, not just the version header" | ASI02 Tool Misuse (the wire half) | | **Request smuggling at the HTTP layer** (`Content-Length` / `Transfer-Encoding` disagreement, pipelining) | DELEGATED | The HTTP server frames requests; this Plug reads one framed body through `Plug.Conn.read_body/2` and nothing else. What it adds: a refusal issued before the body is read closes the connection, so a refused caller's body is never read as the next request. The framing itself is `Bandit`'s (or the host's server's). | `test/beam_mcp/transport/http_test.exs` "a body over the cap is refused and the connection closed" | none | | **DNS rebinding through `Origin`** | REFUSED | `allowed_origins:` is required at `init/1` (there is no default), and a disallowed or second disagreeing `Origin` is `403`, issued before the body is read. A request with no `Origin` is admitted: browsers send one on the cross-origin requests rebinding relies on, and a non-browser client (the usual MCP client) sends none; the host's `authorize/1` still runs. Binding the listener to localhost, which the specification says a local server SHOULD, is the HTTP server's option and the host's. | `test/beam_mcp/transport/http_test.exs` "init/1 raises without :allowed_origins" "a disallowed Origin gets 403" "Origin: a second, disallowed Origin is not ignored" "an absent Origin is not an invalid one" | ASI03 Identity & Privilege Abuse | -| **Slow clients; drip bodies** | BOUNDED | `read_timeout:` on `BeamMCP.Transport.HTTP` is one whole-body deadline, this package's own (the body is read in pieces against one monotonic clock, each read given what remains), so a client that has sent its headers and then drips the body is answered `408` when it lapses, however many bytes arrived and however the adapter splits the reads. The adapter's own `:read_timeout` is not that: a per-read clock, so a cap-sized body (two adapter reads) got two deadlines, 1,909 ms for 1,000; and over HTTP/2, which `Bandit` serves on the same plaintext listener by prior knowledge, its reader gathers DATA frames inside one read on a per-frame clock, and a one-byte-per-frame drip of a valid call was served after 20 s under a 300 ms deadline (both measured by review lanes, 2026-09-16; both closed: over HTTP/2 the reader is asked for less than one frame, so every DATA frame returns to this clock, an empty one included: under a length of zero a drip of empty frames had held the read, 2,321 ms for 300). Measured through a real `Bandit` listener: `408` at 300, 301, 327 ms for a 300 ms deadline and 1,500, 1,500, 1,501 ms for 1,500 ms; over HTTP/2 a twenty-frame drip answered at the deadline. **One residue is the adapter's:** over HTTP/2 a stream kept open by control frames alone (a WINDOW_UPDATE, or a HEADERS without END_STREAM) re-arms the adapter's own wait for the whole of what the read gave it, so one such frame per deadline holds the stream indefinitely (under the default, one frame per fifteen seconds), and that wait is a receive on the adapter's messages that nothing outside it can end through an interface the adapter offers. Two levers exist and both are declined: forging the adapter's own message into the stream's mailbox would end it and couples this package to a private protocol a release of the adapter can change silently; ending the stream process gives the client no answer at all: the adapter forgets the stream and drops its frames. **The connection, though, is this package's to end.** `connection_timeout:` (a positive integer of milliseconds, default twice `read_timeout`) closes the whole connection with a `GOAWAY` the client can read when a body read has been held that long and nothing else on the connection is still within its own body deadline: an OTP `GenServer.stop` on the socket handler (found by the `handle_shutdown/2` callback it exports, not by an adapter name), which runs the handler's own orderly termination: `GOAWAY(NO_ERROR)`, then the socket closed, not a reset. So the residue the adapter owns per stream is bounded in DURATION here: measured (2026-09-16), a stream pinned by WINDOW_UPDATE and one by HEADERS-without-END_STREAM, each fed a frame every 100 ms for three seconds under `connection_timeout: 600`, are closed with `GOAWAY(NO_ERROR)` at 607 and 603 ms, where without the bound (a high `connection_timeout`) each lived as long as the frames came, 3,341 and 3,333 ms. The cost is per connection and stated: the client's other streams still open on that connection end with the `GOAWAY`; a legitimate stream that already answered is unharmed (measured: on a shared connection the legitimate stream is answered `200`, then the held stream's `GOAWAY(NO_ERROR, last_stream_id 3)` at 602 ms), but a stream still in flight when the close fires dies with it, so a host multiplexing streams that outlive one body read raises `connection_timeout`. The deadline is twice the read deadline by default: one `read_timeout` for the body to arrive, a second before a still-blocked read is taken for a hold rather than a slow arrival (the loop answers slow arrivals per DATA frame within `read_timeout`, so a read still blocked at twice it received only control frames). What such a stream costs the client and the host, by frame: a WINDOW_UPDATE is thirteen bytes, nothing accumulates, and nothing is logged (measured, 2026-09-16: twenty windows at 100 ms under a 300 ms deadline, the body at 2,114 ms, `408` at 2,124 ms); a HEADERS without END_STREAM (a malformed request under RFC 9113 §8.1, which the adapter reads as trailers and ignores) holds the stream the same way and **writes a warning line per frame to the host's log, `Ignoring trailers …`, carrying the client's header bytes** (twenty frames, twenty lines, `408` at 2,118 ms; a review lane, 2026-09-16), the one hold on this row that reaches the log. What it costs the host: one stream process held for as long as the frames keep coming, up to the adapter's streams-per-connection setting (`http_2_options: [default_local_settings: [max_concurrent_streams: n]]` on the `Bandit` listener, unlimited under its defaults), the connection-flood row's delegation, reached by a client that never sends a body byte. `http_2_options`'s `max_concurrent_streams` caps how many held streams one connection can pin (count), `connection_timeout` bounds how long each is held (duration), and `http_2_options: [enabled: false]` removes HTTP/2 from the listener and with it this residue whole; no MCP client this package has been run against speaks HTTP/2 by prior knowledge. Whatever body then comes is refused, since the clock is read on return. A change in the adapter (a deadline for its read rather than a per-frame wait) lifts the per-stream residue the package now bounds at the connection; the two per-stream tests named below fail the day the adapter honours the deadline it is given, and they carry a high `connection_timeout` so the package's connection bound does not close them first. The `408` is this package's JSON-RPC refusal, with `connection: close` over HTTP/1.1 (over HTTP/2 the stream ends with the response; the header would be a malformed one there, and every pre-body refusal that carried it was a stream reset to an HTTP/2 client until this page); nothing is written to the host's log for it: the adapter's own error-level line at its read timeout no longer fires, since the deadline is this package's. The default is 15,000 ms, a chosen number with its reasoning beside the constant: a cap-sized body needs 68.3 KiB/s (~559 kbit/s) sustained to arrive under it (arithmetic, since every measurement this package has is on loopback), and a drip attack costs the host at most the body cap per connection for fifteen seconds (a legitimate request served in under 0.01 s with 12,000 drip connections opened against it, 2026-09-07; cumulative opens, not a steady state). It is a DoS control and the host's: longer behind a slow link, shorter facing the open internet. For two releases this package passed none and the value in force was `Bandit`'s default for such a call, not a server option, and not a choice. | `test/beam_mcp/transport/http_bandit_test.exs` "a drip client is answered 408 at the deadline set, at two values" "a body over the adapter's read length and under the cap is one deadline, not two" "over HTTP/2 a drip of one byte per frame is still answered at the deadline, and the refusal is readable" "over HTTP/2 an empty DATA frame returns to the deadline's clock like any other" "over HTTP/2 a stream kept open by WINDOW_UPDATE frames alone is held past the deadline by the adapter, and nothing late is served" "over HTTP/2 a stream kept open by HEADERS frames without END_STREAM is held the same way, each frame a warning line in the host's log" "over HTTP/2 a connection pinned by WINDOW_UPDATE frames alone is closed with a readable GOAWAY at the connection deadline" "over HTTP/2 a connection pinned by HEADERS frames without END_STREAM is closed the same way" "over HTTP/2 a legitimate stream on the pinned connection is served before the close, and the close is a GOAWAY not a reset" "init/1 takes connection_timeout: and refuses a value that is not a positive integer" "over HTTP/2 a whole body under the deadline is served, and a refusal before the body is a response, not a stream reset" "init/1 takes read_timeout: and refuses a value that is not a positive integer" "the default is the stated one, and a drip client under it is not answered inside a second" | LLM10:2025 | +| **Slow clients; drip bodies** | BOUNDED | `read_timeout:` on `BeamMCP.Transport.HTTP` is one whole-body deadline, this package's own (the body is read in pieces against one monotonic clock, each read given what remains), so a client that has sent its headers and then drips the body is answered `408` when it lapses, however many bytes arrived and however the adapter splits the reads. The adapter's own `:read_timeout` is not that: a per-read clock, so a cap-sized body (two adapter reads) got two deadlines, 1,909 ms for 1,000; and over HTTP/2, which `Bandit` serves on the same plaintext listener by prior knowledge, its reader gathers DATA frames inside one read on a per-frame clock, and a one-byte-per-frame drip of a valid call was served after 20 s under a 300 ms deadline (both measured by review lanes, 2026-09-16; both closed: over HTTP/2 the reader is asked for less than one frame, so every DATA frame returns to this clock, an empty one included: under a length of zero a drip of empty frames had held the read, 2,321 ms for 300). Measured through a real `Bandit` listener: `408` at 300, 301, 327 ms for a 300 ms deadline and 1,500, 1,500, 1,501 ms for 1,500 ms; over HTTP/2 a twenty-frame drip answered at the deadline. **One residue is the adapter's:** over HTTP/2 a stream kept open by WINDOW_UPDATE frames alone re-arms the adapter's own wait for the whole of what the read gave it, so one such frame per deadline holds the stream indefinitely (under the default, one frame per fifteen seconds), and that wait is a receive on the adapter's messages that nothing outside it can end through an interface the adapter offers. Two levers exist and both are declined: forging the adapter's own message into the stream's mailbox would end it and couples this package to a private protocol a release of the adapter can change silently; ending the stream process gives the client no answer at all: the adapter forgets the stream and drops its frames. **The connection, though, is this package's to end.** `connection_timeout:` (a positive integer of milliseconds, default twice `read_timeout`) closes the whole connection with a `GOAWAY` the client can read when a body read has been held that long and nothing else on the connection is still within its own body deadline: an OTP `GenServer.stop` on the socket handler (found by the `handle_shutdown/2` callback it exports, not by an adapter name), which runs the handler's own orderly termination: `GOAWAY(NO_ERROR)`, then the socket closed, not a reset. So the residue the adapter owns per stream is bounded in DURATION here: measured (2026-09-16), a stream pinned by WINDOW_UPDATE and one by HEADERS-without-END_STREAM, each fed a frame every 100 ms for three seconds under `connection_timeout: 600`, are closed with `GOAWAY(NO_ERROR)` at 607 and 603 ms, where without the bound (a high `connection_timeout`) each lived as long as the frames came, 3,341 and 3,333 ms (the HEADERS figures are `Bandit` before 1.13; on 1.13 that stream is reset at its first such frame, so the bound now applies to the WINDOW_UPDATE hold alone). The cost is per connection and stated: the client's other streams still open on that connection end with the `GOAWAY`; a legitimate stream that already answered is unharmed (measured: on a shared connection the legitimate stream is answered `200`, then the held stream's `GOAWAY(NO_ERROR, last_stream_id 3)` at 602 ms), but a stream still in flight when the close fires dies with it, so a host multiplexing streams that outlive one body read raises `connection_timeout`. The deadline is twice the read deadline by default: one `read_timeout` for the body to arrive, a second before a still-blocked read is taken for a hold rather than a slow arrival (the loop answers slow arrivals per DATA frame within `read_timeout`, so a read still blocked at twice it received only control frames). What such a stream costs the client and the host, by frame: a WINDOW_UPDATE is thirteen bytes, nothing accumulates, and nothing is logged (measured, 2026-09-16: twenty windows at 100 ms under a 300 ms deadline, the body at 2,114 ms, `408` at 2,124 ms); a HEADERS without END_STREAM (a malformed request under RFC 9113 §8.1) held the stream the same way on `Bandit` before 1.13 and wrote a warning line per frame to the host's log carrying the client's header bytes (twenty frames, twenty lines, `408` at 2,118 ms; a review lane, 2026-09-16). `Bandit` 1.13 resets that stream with `PROTOCOL_ERROR` at the first such frame, and this package hands the adapter's error back to the adapter instead of answering it `500` / `-32603`, which it did on 1.13 until it was measured (2026-10-19, `Bandit` 1.13.1: the stream reset in under 300 ms, a second request on the same connection served `200`). Every exception raised inside a call into the adapter is the adapter's to answer, a census holding every such call to one boundary function. What it costs the host: one stream process held for as long as the frames keep coming, up to the adapter's streams-per-connection setting (`http_2_options: [default_local_settings: [max_concurrent_streams: n]]` on the `Bandit` listener, unlimited under its defaults), the connection-flood row's delegation, reached by a client that never sends a body byte. `http_2_options`'s `max_concurrent_streams` caps how many held streams one connection can pin (count), `connection_timeout` bounds how long each is held (duration), and `http_2_options: [enabled: false]` removes HTTP/2 from the listener and with it this residue whole; no MCP client this package has been run against speaks HTTP/2 by prior knowledge. Whatever body then comes is refused, since the clock is read on return. A change in the adapter (a deadline for its read rather than a per-frame wait) lifts the per-stream residue the package now bounds at the connection; the per-stream WINDOW_UPDATE test named below fails the day the adapter honours the deadline it is given, and carries a high `connection_timeout` so the package's connection bound does not close it first. The `408` is this package's JSON-RPC refusal, with `connection: close` over HTTP/1.1 (over HTTP/2 the stream ends with the response; the header would be a malformed one there, and every pre-body refusal that carried it was a stream reset to an HTTP/2 client until this page); nothing is written to the host's log for it: the adapter's own error-level line at its read timeout no longer fires, since the deadline is this package's. The default is 15,000 ms, a chosen number with its reasoning beside the constant: a cap-sized body needs 68.3 KiB/s (~559 kbit/s) sustained to arrive under it (arithmetic, since every measurement this package has is on loopback), and a drip attack costs the host at most the body cap per connection for fifteen seconds (a legitimate request served in under 0.01 s with 12,000 drip connections opened against it, 2026-09-07; cumulative opens, not a steady state). It is a DoS control and the host's: longer behind a slow link, shorter facing the open internet. For two releases this package passed none and the value in force was `Bandit`'s default for such a call, not a server option, and not a choice. | `test/beam_mcp/transport/http_bandit_test.exs` "a drip client is answered 408 at the deadline set, at two values" "a body over the adapter's read length and under the cap is one deadline, not two" "over HTTP/2 a drip of one byte per frame is still answered at the deadline, and the refusal is readable" "over HTTP/2 an empty DATA frame returns to the deadline's clock like any other" "over HTTP/2 a stream kept open by WINDOW_UPDATE frames alone is held past the deadline by the adapter, and nothing late is served" "over HTTP/2 a HEADERS frame without END_STREAM in the middle of a body is reset by the adapter as malformed, not answered as our fault" "over HTTP/2 a connection pinned by WINDOW_UPDATE frames alone is closed with a readable GOAWAY at the connection deadline" "over HTTP/2 HEADERS frames without END_STREAM no longer pin a connection: the stream is reset and the connection still serves" "over HTTP/2 a legitimate stream on the pinned connection is served before the close, and the close is a GOAWAY not a reset" "init/1 takes connection_timeout: and refuses a value that is not a positive integer" "over HTTP/2 a whole body under the deadline is served, and a refusal before the body is a response, not a stream reset" "init/1 takes read_timeout: and refuses a value that is not a positive integer" "the default is the stated one, and a drip client under it is not answered inside a second" `test/beam_mcp/transport/http_test.exs` "a read that raises with no status of its own is handed back unchanged, not answered as -32603" `test/beam_mcp/transport/adapter_boundary_test.exs` "every call into the adapter under lib/ sits inside adapter/1, and adapter/1 is the boundary" "the census sees every shape of a call into the adapter that review lanes planted" | LLM10:2025 | | **A body that does not declare its length** (`transfer-encoding: chunked`) | REFUSED | `411` before the body is read, with `connection: close`. An MCP request is one complete JSON message under a 1 MiB cap and a chunked coding buys it nothing, while it defeats every whole-body bound: the adapter reads a chunked body chunk by chunk, each chunk on its own clock, gathering chunks until the length asked for is filled; a client sending one byte per chunk was served after 43 s under a 15 s deadline (a review lane, 2026-09-16). | `test/beam_mcp/transport/http_bandit_test.exs` "a chunked body is refused with 411 before it is read, so no drip can reset the deadline chunk by chunk" | LLM10:2025 | | **Connection floods; too many in flight** | DELEGATED | Each in-flight request at the body cap costs about 1.05 MiB, linear to 8,000 concurrent with no plateau (measured 2026-09-07); the ceiling on how many is `num_acceptors × num_connections` of the server (100 × 16,384 under `Bandit`'s defaults), the host's capacity decision. The nesting bound above is what keeps that per-request figure at the body's size rather than thirty-six times it. | none (a server setting) | LLM10:2025 | | **Header count and size; TLS** | DELEGATED | Both the HTTP server's. This Plug speaks plaintext to the server that terminates TLS for it; a deployment that needs TLS configures it there. | none | none | diff --git a/mix.exs b/mix.exs index abafc4b0..30be7578 100644 --- a/mix.exs +++ b/mix.exs @@ -138,7 +138,11 @@ defmodule BeamMCP.MixProject do # optional: a host using only the stdio transport must not be made to pull an HTTP # server in, and `optional: true` keeps them out of that host's dependency tree. {:plug, "~> 1.16", optional: true}, - {:bandit, "~> 1.5", optional: true}, + # 1.13 is a security floor, not a feature one: below it Bandit carries EEF-CVE-2026-105659 + # (HTTP/2 send deadlines reset by every WINDOW_UPDATE) and EEF-CVE-2026-105660 (an HTTP/1 + # request target choosing conn.host and conn.port). A host that pulls Bandit in beside this + # transport resolves to a fixed one; a host that does not is unaffected (optional). + {:bandit, "~> 1.13", optional: true}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:ex_doc, "~> 0.31", only: :dev, runtime: false}, # Property tests. Test-time only; it does not enter the published package. diff --git a/mix.lock b/mix.lock index 50f624d0..4e8905b6 100644 --- a/mix.lock +++ b/mix.lock @@ -1,11 +1,11 @@ %{ - "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, + "bandit": {:hex, :bandit, "1.13.1", "1ce25a7beb750ed3fa2e905962f1f9a679cb585f1da0ae45637d06e385ce2eae", [:mix], [{:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.6", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "39028f0b5a21404661410a6c19275060a9822c9dcca70b7c3e4e4fa50b11da58"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, "ex_doc": {:hex, :ex_doc, "0.40.4", "66f2e42bf588594d5a8aab31cad87f2ddad09d0da1b1a2f379340ec2c2e497cb", [:mix], [{:earmark_parser, "~> 1.4.46", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "6222b9e423d76584ee34df2c82a5ed72c2d53dc153f7f483ad28b378694186cc"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, @@ -16,6 +16,6 @@ "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, "stream_data": {:hex, :stream_data, "1.4.0", "026f929db613aabea6208012ae9b8970d3fd5f88b3bdf26831bc536f98c42036", [:mix], [], "hexpm", "2b0ee3a340dcce1c8cf6302a763ee757d1e01c54d6e16d9069062509d68b1dc9"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, - "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "thousand_island": {:hex, :thousand_island, "1.6.0", "d6ad7c2220c96a270cc8ca1586ec4d8bde648a4a3a53f003ce093f41beec0288", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4d4720470f091fc21a1311777c2ac66916c43621bffc068590c9f9bb3cd057a3"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, } diff --git a/test/beam_mcp/transport/http_bandit_test.exs b/test/beam_mcp/transport/http_bandit_test.exs index ed2387e6..d859184a 100644 --- a/test/beam_mcp/transport/http_bandit_test.exs +++ b/test/beam_mcp/transport/http_bandit_test.exs @@ -958,14 +958,16 @@ defmodule BeamMCP.Transport.HTTPBanditTest do "the answer came at #{elapsed} ms for a 300 ms deadline" end - test "over HTTP/2 a stream kept open by HEADERS frames without END_STREAM is held the same way, each frame a warning line in the host's log" do - # The other control-frame hold, and the one that reaches the log: the adapter reads a - # HEADERS without END_STREAM as trailers, ignores it with a warning carrying the - # client's header bytes, and waits again. Recorded like the WINDOW_UPDATE hold. + test "over HTTP/2 a HEADERS frame without END_STREAM in the middle of a body is reset by the adapter as malformed, not answered as our fault" do + # Until Bandit 1.13 this was the other control-frame hold: the adapter read a HEADERS + # without END_STREAM as trailers, ignored it with a warning and waited again, and this + # test pinned the hold so that it would fail the day Bandit bounded it. That day came + # (Bandit #682): RFC 9113 8.1 makes such a frame a malformed request, and the adapter now + # raises its own stream error out of `read_body`. Measured on 1.13.1 against the previous + # transport: the client got 500 / -32603 "Internal error", because `call/2`'s rescue took + # the adapter's signal for a fault of ours. The adapter's error is the adapter's to answer: + # a stream reset, PROTOCOL_ERROR, at the frame, not a JSON-RPC envelope at the deadline. body = call_json(1, 200) - # A high connection deadline so the per-stream residue below is what this records, not - # the package's connection-level bound (029b); the offer is that Bandit's per-stream - # hold still lives, and the day Bandit bounds it this fails. port = listen(read_timeout: 300, connection_timeout: 30_000) started = System.monotonic_time(:millisecond) sock = BeamMCP.H2C.open(port, h2_headers(byte_size(body))) @@ -978,14 +980,13 @@ defmodule BeamMCP.Transport.HTTPBanditTest do ExUnit.CaptureLog.capture_log(fn -> drip_h2(sock, frames, 100) {result, elapsed} = h2_answered_at(sock, started) - assert {:ok, headers, _body} = result - assert {":status", "408"} in headers - assert elapsed >= 1_000, "the hold ended at #{elapsed} ms: the adapter bounds it now" + assert {:rst, 0x1} = result + # The first offending frame goes out at about 100 ms; the read deadline is 300. + assert elapsed < 300, "the stream ended at #{elapsed} ms, not at the first bad frame" end) - lines = log |> String.split("\n") |> Enum.filter(&(&1 =~ "Ignoring trailers")) - assert length(lines) == 10 - assert Enum.all?(lines, &(&1 =~ "[warning]" and &1 =~ ~s({"x-lane", "b"}))) + # Nothing of ours answered it: no -32603 line from this transport's fault path. + refute log =~ "beam_mcp", "the transport logged the adapter's error as its own: " <> log end test "over HTTP/2 a body is refused at the frame that crosses the cap, not at the stream's end" do @@ -1066,19 +1067,28 @@ defmodule BeamMCP.Transport.HTTPBanditTest do "the connection closed at #{elapsed} ms for a 600 ms connection deadline" end - test "over HTTP/2 a connection pinned by HEADERS frames without END_STREAM is closed the same way" do + test "over HTTP/2 HEADERS frames without END_STREAM no longer pin a connection: the stream is reset and the connection still serves" do + # The connection-level twin of the test above. It used to close the pinned connection + # with a GOAWAY at the connection deadline (029b's watchdog). On Bandit 1.13 the first + # such frame ends the stream, so there is nothing left to pin: the stream is reset at + # once, and a second request on the same connection is served normally. body = call_json(1, 200) port = listen(read_timeout: 300, connection_timeout: 600) started = System.monotonic_time(:millisecond) sock = BeamMCP.H2C.open(port, h2_headers(byte_size(body))) - pin_stream(sock, 1, fn s, st -> BeamMCP.H2C.headers(s, [{"x-fill", "y"}], st) end, 3_000) + pin_stream(sock, 1, fn s, st -> BeamMCP.H2C.headers(s, [{"x-fill", "y"}], st) end, 300) result = BeamMCP.H2C.response(sock, 5_000) elapsed = System.monotonic_time(:millisecond) - started - BeamMCP.H2C.close(sock) + assert {:rst, 0x1} = result + assert elapsed < 600, "the stream ended at #{elapsed} ms, at the connection deadline" - assert {:goaway, 0, _last} = result - assert elapsed >= 600 and elapsed < 1_500 + second = call_json(2) + :ok = BeamMCP.H2C.request(sock, 3, h2_headers(byte_size(second))) + :ok = BeamMCP.H2C.data(sock, second, true, 3) + assert {:ok, headers, _body} = BeamMCP.H2C.response(sock, 5_000, 3) + assert {":status", "200"} in headers + BeamMCP.H2C.close(sock) end test "over HTTP/2 the connection is not closed while another stream on it is still within its own deadline" do @@ -1162,4 +1172,37 @@ defmodule BeamMCP.Transport.HTTPBanditTest do assert result == {:error, :timeout} end end + + describe "an error the adapter raises while this transport answers is the adapter's" do + test "over HTTP/1.1 a client that resets the socket before the answer leaves no fault line of ours" do + # The send half of the rule, on a real listener (a review lane's probe, made a test). + # The dispatch takes 300 ms; the client sends its request and resets the socket (linger + # 0) at 50 ms, so the answer meets a closed socket and the adapter raises its own + # transport error out of `send_resp`. Before, that error went through the host-fault + # branch and then this transport's fault branch: two error lines blaming the host's code + # and this package for the adapter's state. Now it is handed back; the adapter decides + # what to log (Bandit logs client closures only under its `log_client_closures`). + port = + listen( + dispatch: fn _n, a, _o -> + Process.sleep(300) + {:ok, a} + end + ) + + log = + ExUnit.CaptureLog.capture_log(fn -> + {:ok, sock} = + :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false, linger: {true, 0}]) + + :ok = :gen_tcp.send(sock, good_post(1)) + Process.sleep(50) + :gen_tcp.close(sock) + Process.sleep(800) + end) + + refute log =~ "beam_mcp", "the adapter's send error was logged as ours: " <> log + refute log =~ "Internal error" + end + end end diff --git a/test/support/beam_mcp/h2c.ex b/test/support/beam_mcp/h2c.ex index 25a924d8..f0f5e305 100644 --- a/test/support/beam_mcp/h2c.ex +++ b/test/support/beam_mcp/h2c.ex @@ -63,8 +63,9 @@ defmodule BeamMCP.H2C do do: :gen_tcp.send(sock, frame(0x8, 0x0, stream, <<0::1, increment::31>>)) # A second HEADERS on the stream with END_HEADERS and without END_STREAM: malformed under - # RFC 9113, 8.1 (trailers end the stream), which the adapter reads as trailers, ignores with - # a warning line, and re-arms its wait on -- the other way a stream is held. + # RFC 9113, 8.1 (trailers end the stream). Bandit before 1.13 read it as trailers, ignored it + # with a warning line and re-armed its wait (the other way a stream was held); 1.13 resets the + # stream with PROTOCOL_ERROR. def headers(sock, fields, stream \\ @stream), do: :gen_tcp.send(sock, frame(0x1, 0x4, stream, hpack(fields))) From 412f9c016d4c085b9d2505a3359d56f1ac5b2a68 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Mon, 19 Oct 2026 15:36:54 -0400 Subject: [PATCH 3/3] Pin one Hex for the release tarball; mix_sbom 0.12.0 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 --- .github/workflows/provenance.yml | 12 +++-- docs/provenance.md | 31 ++++++++----- test/beam_mcp/provenance_test.exs | 73 ++++++++++++++++++++++++++++--- tools/hex-version | 1 + tools/hex-version.license | 2 + tools/release_tarball.sh | 26 ++++++++--- tools/sbom.sh | 19 +++----- 7 files changed, 128 insertions(+), 36 deletions(-) create mode 100644 tools/hex-version create mode 100644 tools/hex-version.license diff --git a/.github/workflows/provenance.yml b/.github/workflows/provenance.yml index 58c73086..46604ba8 100644 --- a/.github/workflows/provenance.yml +++ b/.github/workflows/provenance.yml @@ -13,8 +13,9 @@ # order both change the bytes -- one commit gave a review lane three checksums on one day. So # the tarball is built by tools/release_tarball.sh, which makes the bytes canonical by # construction (`git archive` at the ref with tar.umask=022: every file 644, only tracked -# files; globs in mix.exs's `files:` for one entry order), and the publisher builds AND -# publishes with the same script (`--publish`). A release published any other way will not +# files; one Hex version, tools/hex-version, installed below and refused otherwise by the +# script, because Hex 2.5.2 and later sort the tarball's entries where 2.5.1 kept the globs' +# order), and the publisher builds AND publishes with the same script (`--publish`). A release published any other way will not # match this attestation, and the last step below says so, loudly, on the tag's run. # # THE OWNER TAGS AND PUBLISHES; THIS WORKFLOW ATTESTS. It never runs `mix hex.publish`. The @@ -27,7 +28,7 @@ # # THE SBOM (from 0.10.0). tools/sbom.sh generates a CycloneDX 1.6 SBOM of the runtime # dependency set from the same tree, outside mix.exs (the EEF's mix_sbom binary, pinned by -# version and digest; one named workaround, G-087), and actions/attest binds it to the +# version and digest; no workaround since mix_sbom 0.12.0), and actions/attest binds it to the # tarball's digest as a second attestation (predicate https://cyclonedx.org/bom). A consumer # verifies it with `gh attestation verify --predicate-type https://cyclonedx.org/bom` # and fetches it with `gh attestation download` (docs/provenance.md). @@ -70,6 +71,11 @@ jobs: with: otp-version: "28" elixir-version: "1.18" + # The Hex the publisher builds with, from the one file both read (tools/release_tarball.sh + # refuses any other): setup-beam installs the newest Hex, and Hex 2.5.2 changed the + # tarball's bytes for the same commit. + - name: the pinned Hex (tools/hex-version) + run: mix local.hex "$(tr -d '[:space:]' < tools/hex-version)" --force - name: the tarball, built the canonical way, its version and its sha256 (hex.pm's package checksum) id: build run: | diff --git a/docs/provenance.md b/docs/provenance.md index e11db6dd..9d037801 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -32,7 +32,7 @@ So the release tarball is built by one script on every seat: CI, the publisher, reproducing it: ```sh -tools/release_tarball.sh v0.10.1 beam_mcp-0.10.1.tar +RELEASE_HEX=2.5.1 tools/release_tarball.sh v0.10.1 beam_mcp-0.10.1.tar # 0.10.1 was built with Hex 2.5.1 ``` It `git archive`s the ref with `tar.umask=022` (every file `644` whatever the machine's umask, @@ -102,9 +102,10 @@ applications), generated from the tag's tree by `tools/sbom.sh` with the EEF's ` (pinned by version and by the release asset's SHA-256; nothing is added to `mix.exs` or `mix.lock`) and attested to the same tarball digest as the provenance. `plug` and `bandit` appear although they are optional dependencies of this package: the tool has no notion of an -optional dependency. One workaround is named in the script (G-087 in this project's records: -the tool's 0.11.0 cannot read the `tools: :optional` entry in `mix.exs`, so its scratch copy -reads `:tools`; the component list is the same). +optional dependency. Releases up to 0.10.1 were generated with `mix_sbom` 0.11.0, which could +not read the `tools: :optional` entry in `mix.exs`, so the script rewrote that one line to +`:tools` in its scratch copy (the component list is the same). From 0.12.0 the tool reads the +form and the script reads the tree exactly as tagged. ```sh gh attestation verify "beam_mcp-${v}.tar" --repo ScriptKittyOS/beam_mcp \ @@ -128,12 +129,22 @@ git clone https://github.com/ScriptKittyOS/beam_mcp && cd beam_mcp tools/release_tarball.sh "v${v}" "rebuilt-${v}.tar" # prints the sha256 = the package checksum on hex.pm ``` -The script needs Elixir, Erlang, Hex 2.x, bash, git and `sha256sum` or `shasum`. CI builds -with the pair `.tool-versions` names, copied into the workflow (Elixir 1.18 on OTP 28); the -tarball carries no compiled code, and Hex is what packages it: 2.4.0 and 2.5.1 gave -byte-identical tarballs of one commit (measured), and a packaging change in a later Hex would -show as a checksum the tag's run fails to match, not as a silent difference. The canonical -bytes assume ASCII file names: a non-ASCII name is encoded by the machine's locale, and a test +The script needs Elixir, Erlang, the Hex that `tools/hex-version` names, bash, git and +`sha256sum` or `shasum`, and it refuses any other Hex with the command that installs the right +one. CI builds with the pair `.tool-versions` names, copied into the workflow (Elixir 1.18 on +OTP 28), and installs the same Hex from the same file. The tarball carries no compiled code, and +Hex is what packages it, which is why Hex is pinned: 2.4.0 and 2.5.1 gave byte-identical +tarballs of one commit (measured), but 2.5.2 (hex_core 0.20) sorts the tarball's entries and its +metadata `files` list, so the same commit has a different checksum under 2.5.1 and under 2.5.2 +or later (measured 2026-10-19). **Releases up to and including 0.10.1 were built with Hex 2.5.1** +and reproduce byte for byte only under it: + +```sh +mix local.hex 2.5.1 --force # from inside the clone: a version manager keeps one Hex per Elixir +RELEASE_HEX=2.5.1 tools/release_tarball.sh v0.10.1 rebuilt-0.10.1.tar +``` + +The canonical bytes assume ASCII file names: a non-ASCII name is encoded by the machine's locale, and a test holds every packaged name to ASCII. ## Releases before this page diff --git a/test/beam_mcp/provenance_test.exs b/test/beam_mcp/provenance_test.exs index 01c22db3..c6ff3553 100644 --- a/test/beam_mcp/provenance_test.exs +++ b/test/beam_mcp/provenance_test.exs @@ -142,8 +142,13 @@ defmodule BeamMCP.ProvenanceTest do try do # HEAD's tree, the way CI and the publisher build it. The lock's dependencies are the # tree's own (the script resolves them; here they are already fetched). + # Under whatever Hex this machine has: the structure is the claim here, and the pin is + # the next test's. RELEASE_HEX is the script's own override, set to the installed version. {log, 0} = - System.cmd("bash", ["tools/release_tarball.sh", "HEAD", out], stderr_to_stdout: true) + System.cmd("bash", ["tools/release_tarball.sh", "HEAD", out], + stderr_to_stdout: true, + env: [{"RELEASE_HEX", installed_hex()}] + ) [_, printed] = Regex.run(~r/Package checksum: ([0-9a-f]{64})/, log) sha = :crypto.hash(:sha256, File.read!(out)) |> Base.encode16(case: :lower) @@ -160,11 +165,18 @@ defmodule BeamMCP.ProvenanceTest do modes = entries |> Enum.map(fn {_, m} -> Bitwise.band(m, 0o777) end) |> Enum.uniq() assert modes == [0o644], "entry modes are #{inspect(modes, base: :octal)}, not 644 alone" - # The order Hex gives a glob is Path.wildcard's, sorted; the tarball's is the globs' in - # files: order. A directory in files: would be readdir order and fail here on some machine. + # The order is the Hex that built it, read from the build's own line. Up to Hex 2.5.1 a + # glob's files came in Path.wildcard's order, sorted, and the globs in files: order (a + # directory in files: would be readdir order and fail here on some machine). From Hex + # 2.5.2 (hex_core 0.20) the tarball's entries are sorted by name whatever files: says -- + # measured 2026-10-19, the change that put this test red on every pull request. + [_, hex] = Regex.run(~r/built with Hex ([0-9][0-9.]*)/, log) + globbed = Enum.map(Mix.Project.config()[:package][:files], &Enum.sort(Path.wildcard(&1))) + expected = - Mix.Project.config()[:package][:files] - |> Enum.flat_map(fn glob -> glob |> Path.wildcard() |> Enum.sort() end) + if Version.compare(hex, "2.5.2") == :lt, + do: Enum.concat(globbed), + else: globbed |> Enum.concat() |> Enum.sort() assert Enum.map(entries, &elem(&1, 0)) == expected assert length(expected) >= 39 @@ -182,4 +194,55 @@ defmodule BeamMCP.ProvenanceTest do File.rm_rf!(dir) end end + + test "the release script builds only with the Hex tools/hex-version names, and says how to get it" do + # The pin is what makes the publisher's checksum and CI's attested one the same bytes. A + # version no machine has stands in for "not the pinned one", so this holds on any machine. + pinned = "tools/hex-version" |> File.read!() |> String.trim() + assert Version.parse!(pinned) + + out = + Path.join(System.tmp_dir!(), "beam_mcp-hexpin-#{System.unique_integer([:positive])}.tar") + + {log, status} = + System.cmd("bash", ["tools/release_tarball.sh", "HEAD", out], + stderr_to_stdout: true, + env: [{"RELEASE_HEX", "0.0.1"}] + ) + + assert status == 3 + assert log =~ "this build needs Hex 0.0.1" + assert log =~ "mix local.hex 0.0.1 --force" + refute File.exists?(out) + + # Without the override the script reads tools/hex-version itself: on a machine whose Hex is + # the pinned one it builds and says so; on any other it refuses naming the pinned version. + # Either way the version in the outcome is the file's, which a substring match on the + # script's text could not show. + {log, status} = + System.cmd("bash", ["tools/release_tarball.sh", "HEAD", out], + stderr_to_stdout: true, + env: [{"RELEASE_HEX", nil}] + ) + + if installed_hex() == pinned do + assert status == 0 + assert log =~ "built with Hex #{pinned}" + else + assert status == 3 + assert log =~ "this build needs Hex #{pinned}" + end + + File.rm(out) + + script = File.read!("tools/release_tarball.sh") + assert script =~ "tools/hex-version" + assert File.read!(".github/workflows/provenance.yml") =~ "< tools/hex-version" + end + + defp installed_hex do + {info, 0} = System.cmd("mix", ["hex.info"], stderr_to_stdout: true) + [_, version] = Regex.run(~r/^Hex: +([0-9][0-9.]*)/m, info) + version + end end diff --git a/tools/hex-version b/tools/hex-version new file mode 100644 index 00000000..aedc15bb --- /dev/null +++ b/tools/hex-version @@ -0,0 +1 @@ +2.5.3 diff --git a/tools/hex-version.license b/tools/hex-version.license new file mode 100644 index 00000000..f1cc2499 --- /dev/null +++ b/tools/hex-version.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +SPDX-License-Identifier: Apache-2.0 diff --git a/tools/release_tarball.sh b/tools/release_tarball.sh index 70e4a43e..43c897be 100755 --- a/tools/release_tarball.sh +++ b/tools/release_tarball.sh @@ -20,25 +20,41 @@ # runs this on the release tag and attests the result; the publisher runs it with --publish; # a stranger runs it to reproduce the checksum hex.pm shows. One instrument, three seats. # -# What it does not do: pin Hex. The runner's Hex is what erlef/setup-beam installs; the -# publisher's is theirs. Hex 2.4.0 and 2.5.1 packaged byte-identical tarballs of one commit -# (a review lane measured both); a packaging change in Hex is caught by the workflow's -# checksum comparison, not hidden by this script. Needs bash, git, and sha256sum or shasum. +# ONE HEX FOR EVERY SEAT, READ FROM tools/hex-version. This script once did not pin Hex, on +# the measurement that Hex 2.4.0 and 2.5.1 packaged byte-identical tarballs of one commit, and +# said a packaging change would be caught rather than hidden. One was (2026-10-19): Hex 2.5.2 +# vendors hex_core 0.20, which sorts the tarball's entries and its metadata `files` list, so +# the same commit gives a different checksum under 2.5.1 and under 2.5.2 or later. The +# publisher and CI must therefore build with the same Hex, and the script refuses any other +# with the command that installs the pinned one. RELEASE_HEX= overrides the pin to +# reproduce a release built before it: 0.10.1 and earlier were built with Hex 2.5.1. +# Needs bash, git, and sha256sum or shasum. set -euo pipefail ref=${1:?ref (a tag or commit)}; out=${2:?output tarball path}; publish=${3:-} [ -z "$publish" ] || [ "$publish" = "--publish" ] || { echo "third argument is --publish or nothing" >&2; exit 2; } case "$out" in /*) ;; *) out="$PWD/$out" ;; esac sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi | cut -d' ' -f1; } root=$(git rev-parse --show-toplevel) +want_hex=${RELEASE_HEX:-$(tr -d '[:space:]' < "$root/tools/hex-version")} work=$(mktemp -d "${TMPDIR:-/tmp}/beam_mcp-release.XXXXXX"); trap 'rm -rf "$work"' EXIT git -C "$root" -c tar.umask=022 archive --format=tar "$ref" | tar -xp -C "$work" cd "$work" +# Checked HERE, in the tree that builds: a version manager (asdf, mise) keeps one Hex per Elixir +# install and picks the Elixir from this tree's .tool-versions, which is the ref's, so the Hex +# that builds is this directory's, not the caller's. `|| true`: with no Hex at all, hex.info +# fails, and the refusal below must still print. +have_hex=$(mix hex.info 2>/dev/null | sed -nE 's/^Hex: +([0-9][0-9.]*).*/\1/p' || true) +[ "$have_hex" = "$want_hex" ] || { + echo "this build needs Hex ${want_hex}; the Hex in the tree's Elixir is ${have_hex:-none}." >&2 + echo " mix local.hex ${want_hex} --force # run it from inside the clone" >&2 + exit 3 +} mix deps.get >/dev/null mix hex.build -o "$out" | tee "$work/build.out" sha=$(sha256 "$out") grep -q "Package checksum: ${sha}" "$work/build.out" \ || { echo "the tarball's sha256 ${sha} is not the checksum hex printed" >&2; exit 1; } -echo "release tarball ${out}: sha256 ${sha} (= hex's package checksum) from ${ref} = $(git -C "$root" rev-parse "${ref}^{commit}")" +echo "release tarball ${out}: sha256 ${sha} (= hex's package checksum) from ${ref} = $(git -C "$root" rev-parse "${ref}^{commit}"), built with Hex ${have_hex}" if [ "$publish" = "--publish" ]; then mix hex.publish fi diff --git a/tools/sbom.sh b/tools/sbom.sh index 22a1ab7e..273c15c0 100755 --- a/tools/sbom.sh +++ b/tools/sbom.sh @@ -15,12 +15,10 @@ # the runtime dependency set a host inherits (plug and bandit included, though optional in # mix.exs -- the tool has no notion of an optional dependency and marks them required). # -# ONE WORKAROUND, NAMED (G-087). mix_sbom 0.11.0 crashes on the `tools: :optional` entry in -# mix.exs's `extra_applications` (FunctionClauseError in its normalize_dep/1; Mix documents -# the `{app, :optional}` form). In the scratch copy only, that one entry is rewritten to -# `:tools` before the tool reads it; the shipped mix.exs is untouched and the component list is -# the same. The step refuses to run if the line is not the one it expects, so a changed mix.exs -# is noticed rather than silently mis-read. Remove it when a mix_sbom release reads the form. +# No workaround. mix_sbom 0.11.0 crashed on the `tools: :optional` entry in mix.exs's +# `extra_applications` (FunctionClauseError in its normalize_dep/1), and this script rewrote that +# one line in a scratch copy. 0.12.0 reads the form (erlef/mix_sbom#157, fixed by #169), +# so the tool now reads the tree exactly as it is tagged. # # Not byte-reproducible by design: CycloneDX gives every document a fresh serialNumber and a # timestamp. What binds it is the attestation over the tarball's digest (provenance.yml). @@ -29,8 +27,8 @@ set -euo pipefail ref=${1:?ref (a tag or commit)}; out=${2:?output path} case "$out" in /*) ;; *) out="$PWD/$out" ;; esac -version=0.11.0 -digest=db1982c8599f9383c48e6bb61cd4dc7305d2afdf48273568839b7f1dc0d572de +version=0.12.0 +digest=5da833839dd80350e15ca5c2b02f233e0917dc4333efa520994acf0ed9cab19b root=$(git rev-parse --show-toplevel) work=$(mktemp -d "${TMPDIR:-/tmp}/beam_mcp-sbom.XXXXXX"); trap 'rm -rf "$work"' EXIT @@ -44,11 +42,6 @@ chmod +x "$bin" mkdir "$work/src" git -C "$root" -c tar.umask=022 archive --format=tar "$ref" | tar -x -C "$work/src" -expected=' [extra_applications: [:logger, :crypto, tools: :optional]]' -grep -qxF -- "$expected" "$work/src/mix.exs" \ - || { echo "mix.exs no longer has the extra_applications line the G-087 workaround rewrites" >&2; exit 1; } -sed -i 's/^ \[extra_applications: \[:logger, :crypto, tools: :optional\]\]$/ [extra_applications: [:logger, :crypto, :tools]]/' "$work/src/mix.exs" - (cd "$work/src" && mix deps.get >/dev/null) "$bin" cyclonedx --only prod --format json --schema 1.6 --force --output "$out" "$work/src" >/dev/null