From 11cc617424bc20f3625951fce27c6c51c062c115 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 23 Sep 2026 13:48:39 -0400 Subject: [PATCH 1/2] The private key no longer reaches an exception report: sign/2 answers a mistyped call, and the key may be passed by reference Found by this package's own audit for the OpenSSF badge: a call that broke the documented types raised FunctionClauseError, which Elixir prints with the call's arguments, so the key reached whatever logged it. Measured on e5dd26e: sign("bytes", %{private_key: key}) printed "** (FunctionClauseError) no function clause matching in BeamMCP.Signer.Ed25519.sign/2 ... sign(\"bytes\", %{private_key: <<184, 26, ...>>})". beam_mcp's Canonical.signature/3 raises the same way on mistyped options, before this package is reached, so a fix here alone could not cover it unless the key stops being bytes in the options. Two layers, both here, core untouched: - sign/2 is total: a last clause answers {:error, :bad_arguments}, carrying nothing passed. - :private_key may be a zero-arity function returning the 32 bytes; it is called once per signature and not kept. A function prints as #Function<...>, so a key passed by reference appears in no exception, crash report or log line, core's frames included. The README, moduledoc and pages now pass it that way. The bytes form still works. Red first, the four tests added before the code: "14 tests, 2 failures" -- "a mistyped call is answered {:error, :bad_arguments}, never raised" (** (FunctionClauseError) no function clause matching in BeamMCP.Signer.Ed25519.sign/2) and "the key may be passed by reference" (refused as not_32_bytes). The other two pinned properties that already held: a reference not giving 32 bytes is refused; through core, a referenced key is printed nowhere while a key passed as bytes is (that assertion fails the day core answers a mistyped call). After: 14 tests, 0 failures. The census caught the new guard ([{:erlang, :is_function, 2}] outside the list); it is allowed with its reason, and the third census test now pins the zero-arity reference. Mutants, each red then restored: drop the answering clause (1 failure); drop the reference clause (2); echo the options in the error (1); resolve a function of any arity (2). Credo "found no issues". A minor at the next release (0.x): an accepted form added, and a raise that becomes an answer; the CHANGELOG says how to tell whether you are affected. Signed-off-by: Ayla Croft --- CHANGELOG.md | 18 ++++++ README.md | 12 +++- SECURITY.md | 14 +++-- docs/architecture.md | 10 ++-- docs/assurance-case.md | 37 ++++++++----- docs/roadmap.md | 6 +- lib/beam_mcp/signer/ed25519.ex | 59 +++++++++++++------- test/beam_mcp/signer/ed25519_test.exs | 61 +++++++++++++++++++++ test/beam_mcp/signer/no_key_source_test.exs | 5 +- 9 files changed, 173 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3016b44..0711aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ SPDX-License-Identifier: Apache-2.0 ## [Unreleased] +### Added + +- `:private_key` may be a zero-arity function returning the 32-byte key + (`private_key: fn -> key end`), and the README now passes it that way. Anything that prints + the options (an exception, a log line, a crash report) prints `#Function<...>` in place of + the key's bytes, including an exception raised in `beam_mcp`'s `Canonical.signature/3` on a + mistyped call. A function that does not return 32 bytes is `{:error, {:private_key, + :not_32_bytes}}`, as a key that is not 32 bytes is. + +### Changed + +- A mistyped call to `sign/2` (bytes that are not a binary, options that are not a list) is + answered `{:error, :bad_arguments}` instead of raising `FunctionClauseError`. Found by this + package's own audit: the raised error printed its arguments, the private key among them + (`sign("bytes", %{private_key: key})` printed all 32 bytes). **How to tell whether you are + affected:** only code that rescued `FunctionClauseError` from `sign/2` sees a difference; + a call through `Canonical.signature/3` with a keyword list never reached that clause. + ### Added: the project's pages and checks (no code change) - `SECURITY.md` (private reporting, commitments, what a host can rely on, one known limit), diff --git a/README.md b/README.md index bc46d5c..b051ce5 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,25 @@ core's `BeamMCP.Signer` behaviour (`sign/2`). `Canonical.signature/3` hands them over; the 64-byte signature comes back beside them. Nothing else: no envelope byte moves, no verdict or receipt is added. -**Who holds the key.** The host. It passes the 32-byte Ed25519 private key on every call: +**Who holds the key.** The host. It passes the 32-byte Ed25519 private key on every call, as +a zero-arity function that returns it: ```elixir {public_key, private_key} = :crypto.generate_key(:eddsa, :ed25519) # or a key the host keeps {:ok, %{signature: sig}} = - BeamMCP.Connectome.Canonical.signature(graph, BeamMCP.Signer.Ed25519, private_key: private_key) + BeamMCP.Connectome.Canonical.signature(graph, BeamMCP.Signer.Ed25519, + private_key: fn -> private_key end) true = :crypto.verify(:eddsa, :none, BeamMCP.Connectome.Canonical.encode!(graph), sig, [public_key, :ed25519]) ``` +**Why a function.** Options pass through the host's code and core's, and whatever prints +them (an exception raised on a mistyped call, a debug log line, a crash report) prints a key +passed as bytes, byte by byte. A function prints as `#Function<...>`. The 32 bytes themselves +are still accepted; the function is the form that nothing printed can reveal. A mistyped call +to this module is answered `{:error, :bad_arguments}` rather than raised, for the same reason. + This package reads `opts[:private_key]` and nothing else -- no environment variable, no file, no application config, no default. Where the key lives between calls is the host's decision. diff --git a/SECURITY.md b/SECURITY.md index a913af5..fef26a2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,12 +35,14 @@ The security requirements a host can rely on, each held by a test in `test/`: `opts[:private_key]` on each call and from nowhere else: no environment variable, file, application config, default key or store. A two-layer census holds it (`test/beam_mcp/signer/no_key_source_test.exs`). -- **The key does not leave in a result.** Every return value is a signature or a fixed error - term; neither carries the key. **One known limit:** a call that breaks the documented types - (bytes that are not a binary, or options that are not a keyword list, here or at - `beam_mcp`'s `Canonical.signature/3`) raises a `FunctionClauseError`, and Elixir prints that - exception with its arguments, the key among them, wherever it is logged. Pass a keyword - list. Closing it for misuse too is open work, stated in `docs/assurance-case.md`. +- **The key does not leave in a result or an exception.** Every return value is a signature or + a fixed error term; neither carries the key. A mistyped call is answered + `{:error, :bad_arguments}`, never raised, because a raised `FunctionClauseError` prints its + arguments. Passed by reference (`private_key: fn -> key end`, the README's form), the key + cannot be printed by anything: not an exception raised earlier in core's + `Canonical.signature/3` on a mistyped call, not a log line that inspects the options. Passed + as bytes, the key is as safe as every piece of code that handles the options; tests pin both + halves (`test/beam_mcp/signer/ed25519_test.exs`). - **The signature is standard Ed25519** (RFC 8032, FIPS 186-5) over exactly the canonical bytes `beam_mcp` produced, computed by Erlang/OTP's `:crypto` (OpenSSL), and verifiable by any Ed25519 implementation. diff --git a/docs/architecture.md b/docs/architecture.md index 581e5aa..57e6570 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,14 +9,14 @@ One module, one function, one dependency direction. ```text host application - | Canonical.signature(graph, BeamMCP.Signer.Ed25519, private_key: key, ...) + | Canonical.signature(graph, BeamMCP.Signer.Ed25519, private_key: fn -> key end, ...) v beam_mcp (BeamMCP.Connectome.Canonical) | encodes the graph to canonical bytes, then calls the signer through | the BeamMCP.Signer behaviour: sign(canonical_bytes, opts) v beam_mcp_signer (BeamMCP.Signer.Ed25519.sign/2) - | reads opts[:private_key] (32 bytes) and nothing else + | reads opts[:private_key] (32 bytes, or fn -> bytes end) and nothing else v Erlang/OTP :crypto.sign(:eddsa, :none, canonical_bytes, [key, :ed25519]) (OpenSSL) | @@ -28,8 +28,10 @@ One module, one function, one dependency direction. - **`BeamMCP.Signer.Ed25519`** (`lib/beam_mcp/signer/ed25519.ex`): implements `beam_mcp`'s `BeamMCP.Signer` behaviour. `sign/2` takes the canonical bytes and the host's options, reads - the private key from `opts[:private_key]`, checks it is a 32-byte binary, and returns - `{:ok, signature}`, `{:error, :no_private_key}` or `{:error, {:private_key, :not_32_bytes}}`. + the private key from `opts[:private_key]` (the 32 bytes, or a zero-arity function returning + them, which is called once), checks it is a 32-byte binary, and returns `{:ok, signature}`, + `{:error, :no_private_key}` or `{:error, {:private_key, :not_32_bytes}}`. A mistyped call is + answered `{:error, :bad_arguments}`, never raised, so no exception prints the options. - **`beam_mcp`** (a dependency, `~> 0.7`): defines the behaviour and produces the bytes. It holds no key, calls no signing primitive and does not depend on this package. - **Erlang/OTP `:crypto`**: performs Ed25519. This package implements no cryptography itself. diff --git a/docs/assurance-case.md b/docs/assurance-case.md index 629198f..05f0600 100644 --- a/docs/assurance-case.md +++ b/docs/assurance-case.md @@ -10,7 +10,7 @@ where they stop. ## The claim -**Given a 32-byte Ed25519 private key in the call's options, `sign/2` returns a standard +**Given a 32-byte Ed25519 private key in the call's options (or a zero-arity function returning it), `sign/2` returns a standard Ed25519 signature over exactly the bytes given, reads the key from nowhere else, keeps no copy of it, and returns nothing that contains it.** @@ -24,7 +24,7 @@ read, kept or misused beyond the host's own call. | The key read from a place the host did not choose (environment, file, config) | yes | one source, `opts[:private_key]`; census over source text and compiled calls | | The key retained after the call (process state, ETS, persistent term, app env) | yes | no state; a test compares all four before and after a call | | The key returned or placed in an error term | yes | returns are a signature or a fixed atom tuple; tests pin every error term | -| The key printed in an exception when the call breaks the documented types | **yes, not yet met** | see "Open work" below | +| The key printed in an exception or a log line | yes | a mistyped call is answered `{:error, :bad_arguments}`, never raised; the key passed by reference prints as `#Function<...>` wherever the options are printed, core's frames included (tested through core) | | A signature over bytes other than those given, or a non-standard signature | yes | the bytes go to `:crypto.sign/4` unaltered; tests verify with `:crypto.verify/5` and with core's `encode!/2` bytes | | Weak randomness | yes | none used: Ed25519 is deterministic (RFC 8032), no nonce or key is generated here | | The host's key storage, the node the host runs, OpenSSL defects | no | the host's and upstream's; stated in `SECURITY.md` | @@ -50,7 +50,7 @@ read, kept or misused beyond the host's own call. | Separation of privilege | the key-holding code is a separate package from the keyless core; the host opts in | | Least privilege | no file, network, environment, OS or application-config access, held by census | | Least common mechanism | no shared state between calls or callers | -| Psychological acceptability | two named error terms a host can match and act on | +| Psychological acceptability | three named error terms a host can match and act on | ## Common implementation weaknesses countered @@ -61,17 +61,26 @@ read, kept or misused beyond the host's own call. | CWE-338 / CWE-330 weak randomness | no randomness is used | | CWE-327 / CWE-326 broken algorithm, short key | Ed25519 only, 32-byte seeds only (about 128-bit security, NIST-approved in FIPS 186-5) | | CWE-347 improper signature verification | not applicable to signing; tests verify every signature with an independent call | -| CWE-209 / CWE-532 key in errors or logs | met for returns; open for exceptions on misuse (below) | - -## Open work - -A call that breaks the documented types (bytes that are not a binary, options that are not a -keyword list) raises `FunctionClauseError`, and Elixir formats that exception with the call's -arguments, so the key can reach a crash log. `beam_mcp`'s `Canonical.signature/3` has the same -shape before it reaches this package. It needs a correctly typed call to be safe today. -Closing it is a contract decision across both packages (answer misuse with an error term that -echoes nothing, or take the key by reference so no exception can carry its bytes) and is on -`docs/roadmap.md`. +| CWE-209 / CWE-532 key in errors or logs | no result echoes input; no clause can raise on its arguments; the reference form keeps the bytes out of every printed term (see "Closed" below) | + +## Closed: the key in an exception report (found 2026-09-23) + +Before this change, a call that broke the documented types (bytes that are not a binary, +options that are not a keyword list) raised `FunctionClauseError`, and Elixir formats that +exception with the call's arguments, so the key reached whatever logged it. Measured: +`sign("bytes", %{private_key: key})` printed the 32 bytes. `beam_mcp`'s +`Canonical.signature/3` raises the same way on mistyped options, before this package is +reached. Two layers close it, both in this package: + +1. `sign/2` is total: a mistyped call is answered `{:error, :bad_arguments}`, which carries + nothing that was passed. +2. `:private_key` may be a zero-arity function returning the key, and the README passes it + that way. A function prints as `#Function<...>`, so a key passed by reference cannot appear + in an exception from core, a crash report or a log line. + +What remains is stated: a host that passes the key as bytes and then calls core with mistyped +options still gets the bytes in core's exception. A test pins that fact, so it fails the day +core answers such a call instead of raising. ## How the case is kept true diff --git a/docs/roadmap.md b/docs/roadmap.md index c06cd43..571491e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,11 +10,11 @@ are not promised; the order is. ## Planned -1. **Close the misuse path** in `docs/assurance-case.md` ("Open work"), so a key cannot reach - an exception report even on a mistyped call, decided together with `beam_mcp`. +1. **Done: the misuse path** (`docs/assurance-case.md`, "Closed"). The key passed by reference + reaches no exception report; a mistyped call is answered, not raised. 2. **Follow `beam_mcp` to `1.0.0`.** The requirement is `~> 0.7` today; when core ships `1.0.0`, this package releases with a `~> 1.0` requirement, and then takes its own `1.0.0` - once its surface (one function, three results) has stood a release unchanged. + once its surface (one function, four results) has stood a release unchanged. 3. **Release tooling as core has it**: the canonical tarball script (in the tree) used for every publish, and a build-provenance attestation on each release tag. 4. **Keep current**: security fixes on `SECURITY.md`'s commitments, dependency updates through diff --git a/lib/beam_mcp/signer/ed25519.ex b/lib/beam_mcp/signer/ed25519.ex index 87e5f26..4dbebec 100644 --- a/lib/beam_mcp/signer/ed25519.ex +++ b/lib/beam_mcp/signer/ed25519.ex @@ -7,15 +7,22 @@ defmodule BeamMCP.Signer.Ed25519 do kept out of `beam_mcp` on purpose: core makes no signature of its own. The host hands the key in on every call, under `:private_key` in the options it passes to - `BeamMCP.Connectome.Canonical.signature/3`: + `BeamMCP.Connectome.Canonical.signature/3`, best as a reference: - Canonical.signature(graph, BeamMCP.Signer.Ed25519, private_key: seed) + Canonical.signature(graph, BeamMCP.Signer.Ed25519, private_key: fn -> seed end) `seed` is the 32-byte Ed25519 private key -- the second element of `:crypto.generate_key(:eddsa, :ed25519)`, or the private half of a key the host already - keeps. This module reads that one option and nothing else: no environment variable, no - file, no application config, no default key. Where the key lives between calls is the - host's decision, made outside this package. + keeps. `:private_key` is either those 32 bytes or a zero-arity function returning them. + **Pass the function.** Options travel through the host's code and core's, and anything that + prints them -- an exception raised on a mistyped call prints its arguments, a debug log + line, a crash report -- prints the bytes of a key passed as bytes, and `#Function<...>` for + a key passed by reference. This module calls the function once per signature and keeps + nothing. + + This module reads that one option and nothing else: no environment variable, no file, no + application config, no default key. Where the key lives between calls is the host's + decision, made outside this package. The signature is the 64-byte Ed25519 signature of exactly the bytes given, unhashed (Ed25519 hashes internally; `canonical_bytes` are what a verifier re-derives with @@ -27,29 +34,43 @@ defmodule BeamMCP.Signer.Ed25519 do @private_key_bytes 32 - @typedoc "The 32-byte Ed25519 private key (seed) the host hands in under `:private_key`." - @type private_key :: <<_::256>> + @typedoc """ + The 32-byte Ed25519 private key (seed) the host hands in under `:private_key`, or a + zero-arity function returning it (the form that no printed term can reveal). + """ + @type private_key :: <<_::256>> | (-> <<_::256>>) @doc """ Signs `canonical_bytes` with the Ed25519 key at `opts[:private_key]`. - Returns `{:ok, signature}` (64 bytes), or `{:error, :no_private_key}` when the option is - absent, or `{:error, {:private_key, :not_32_bytes}}` when it is present with another - shape. Every other option is the host's and is not read. + Returns `{:ok, signature}` (64 bytes); `{:error, :no_private_key}` when the option is + absent; `{:error, {:private_key, :not_32_bytes}}` when it is present and is not 32 bytes, + or is a function that does not return 32 bytes; and `{:error, :bad_arguments}` when + `canonical_bytes` is not a binary or `opts` is not a list. That last is an answer, not a + raise, on purpose: a `FunctionClauseError` is printed with the call's arguments, and the + options hold the key. No result carries any byte of what was passed. Every other option is + the host's and is not read. """ @impl BeamMCP.Signer @spec sign(binary(), keyword()) :: - {:ok, binary()} | {:error, :no_private_key | {:private_key, :not_32_bytes}} + {:ok, binary()} + | {:error, :no_private_key | {:private_key, :not_32_bytes} | :bad_arguments} def sign(canonical_bytes, opts) when is_binary(canonical_bytes) and is_list(opts) do case Keyword.fetch(opts, :private_key) do - {:ok, key} when is_binary(key) and byte_size(key) == @private_key_bytes -> - {:ok, :crypto.sign(:eddsa, :none, canonical_bytes, [key, :ed25519])} - - {:ok, _other} -> - {:error, {:private_key, :not_32_bytes}} - - :error -> - {:error, :no_private_key} + {:ok, key} -> sign_with(canonical_bytes, resolve(key)) + :error -> {:error, :no_private_key} end end + + def sign(_canonical_bytes, _opts), do: {:error, :bad_arguments} + + # A reference is called here and nowhere else, once per signature; its result is not kept. + defp resolve(key) when is_function(key, 0), do: key.() + defp resolve(key), do: key + + defp sign_with(canonical_bytes, key) + when is_binary(key) and byte_size(key) == @private_key_bytes, + do: {:ok, :crypto.sign(:eddsa, :none, canonical_bytes, [key, :ed25519])} + + defp sign_with(_canonical_bytes, _key), do: {:error, {:private_key, :not_32_bytes}} end diff --git a/test/beam_mcp/signer/ed25519_test.exs b/test/beam_mcp/signer/ed25519_test.exs index 2e42368..0834017 100644 --- a/test/beam_mcp/signer/ed25519_test.exs +++ b/test/beam_mcp/signer/ed25519_test.exs @@ -76,6 +76,67 @@ defmodule BeamMCP.Signer.Ed25519Test do {:error, {:private_key, :not_32_bytes}} end + # A FunctionClauseError is printed with the call's arguments, and the options hold the key: a + # raise here would put the key in the host's crash log. So a mistyped call is answered, never + # raised, and the answer carries no byte of what was passed. + test "a mistyped call is answered {:error, :bad_arguments}, never raised, and echoes nothing" do + {_pub, priv} = keypair() + + assert Ed25519.sign("abc", %{private_key: priv}) == {:error, :bad_arguments} + assert Ed25519.sign(:not_bytes, private_key: priv) == {:error, :bad_arguments} + assert Ed25519.sign(~c"abc", private_key: priv) == {:error, :bad_arguments} + assert Ed25519.sign("abc", nil) == {:error, :bad_arguments} + end + + test "the key may be passed by reference, a zero-arity function returning it, and signs as the bytes do" do + {pub, priv} = keypair() + bytes = :crypto.strong_rand_bytes(211) + + assert {:ok, sig} = Ed25519.sign(bytes, private_key: fn -> priv end) + assert {:ok, sig} == Ed25519.sign(bytes, private_key: priv) + assert :crypto.verify(:eddsa, :none, bytes, sig, [pub, :ed25519]) + + g = graph() + + assert {:ok, %{signature: through_core}} = + Canonical.signature(g, Ed25519, private_key: fn -> priv end) + + assert :crypto.verify(:eddsa, :none, Canonical.encode!(g), through_core, [pub, :ed25519]) + end + + test "a reference that does not give 32 bytes is refused as a key that is not 32 bytes" do + {_pub, priv} = keypair() + + for bad <- [fn -> "short" end, fn -> nil end, fn -> ~c"not a binary" end, fn _ -> priv end] do + assert Ed25519.sign("abc", private_key: bad) == {:error, {:private_key, :not_32_bytes}} + end + end + + # What the reference buys, measured through core: `Canonical.signature/3` given a map for its + # options raises in core before this package is reached, and the exception prints the options. + # Passed by reference, the key is printed as #Function<...>; passed as bytes, the bytes are + # printed. The second assertion is core's to change: the day core answers a mistyped call + # instead of raising, it fails, and the README's advice can soften. + test "passed by reference, the key reaches no exception report, even from a mistyped call into core" do + {_pub, priv} = keypair() + g = graph() + + report = fn opts -> + try do + Canonical.signature(g, Ed25519, opts) + flunk("core answered a mistyped call; the README's advice on references can be revisited") + rescue + e -> Exception.format(:error, e, __STACKTRACE__) + end + end + + by_reference = report.(%{private_key: fn -> priv end}) + refute by_reference =~ inspect(priv, limit: :infinity) + refute by_reference =~ priv |> :binary.bin_to_list() |> Enum.take(8) |> Enum.join(", ") + + assert report.(%{private_key: priv}) =~ inspect(priv, limit: :infinity) + end + test "reads :private_key and nothing else: other keys are neither read nor refused" do {pub, priv} = keypair() diff --git a/test/beam_mcp/signer/no_key_source_test.exs b/test/beam_mcp/signer/no_key_source_test.exs index d10df3d..9f286b0 100644 --- a/test/beam_mcp/signer/no_key_source_test.exs +++ b/test/beam_mcp/signer/no_key_source_test.exs @@ -19,6 +19,8 @@ defmodule BeamMCP.Signer.NoKeySourceTest do :byte_size, :is_binary, :is_list, + # the guard on a key passed by reference: a zero-arity function, called in the module + :is_function, :error, :==, :"=:=", @@ -64,10 +66,11 @@ defmodule BeamMCP.Signer.NoKeySourceTest do end) end - test "the private key is only ever a 32-byte binary, and the seed length is a module constant" do + test "the private key is 32 bytes, given directly or by a zero-arity reference, and the seed length is a module constant" do src = File.read!(hd(@lib)) assert src =~ "@private_key_bytes 32" assert src =~ "byte_size(key) == @private_key_bytes" + assert src =~ "is_function(key, 0), do: key.()" refute src =~ ~r/private_key:\s*< Date: Wed, 23 Sep 2026 13:48:51 -0400 Subject: [PATCH 2/2] README: the three project pages hexdocs does not ship (CONTRIBUTING, CODE_OF_CONDUCT, GOVERNANCE) linked by their GitHub URLs df5f76e linked them relatively, and ex_doc resolves a relative link only to a file in its extras, so mix docs printed six warnings (three links, two passes). The pull request that added them said "docs without warnings"; that was measured before the README edit and was not true of the merged tree. After: mix docs, 0 warnings. Signed-off-by: Ayla Croft --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b051ce5..322161c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,6 @@ What a host can rely on, and how to report a vulnerability: [`SECURITY.md`](SECU the security requirements hold: [`docs/assurance-case.md`](docs/assurance-case.md), with the design in [`docs/architecture.md`](docs/architecture.md). Checking a release's signature: [`docs/verifying-releases.md`](docs/verifying-releases.md). Contributing: -[`CONTRIBUTING.md`](CONTRIBUTING.md), under the [Code of Conduct](CODE_OF_CONDUCT.md). Who -decides, and what comes next: [`GOVERNANCE.md`](GOVERNANCE.md), +[`CONTRIBUTING.md`](https://github.com/ScriptKittyOS/beam_mcp_signer/blob/main/CONTRIBUTING.md), under the [Code of Conduct](https://github.com/ScriptKittyOS/beam_mcp_signer/blob/main/CODE_OF_CONDUCT.md). Who +decides, and what comes next: [`GOVERNANCE.md`](https://github.com/ScriptKittyOS/beam_mcp_signer/blob/main/GOVERNANCE.md), [`docs/roadmap.md`](docs/roadmap.md). Bugs and ideas: [issues](https://github.com/ScriptKittyOS/beam_mcp_signer/issues).