diff --git a/lib/trinity.ex b/lib/trinity.ex index b713f5d..ff1fa58 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -35,6 +35,10 @@ defmodule Trinity do Telemetry.Redaction, Repo, Repo.Receipts, + # Slice regulated-boot: the application asks it what the profile refuses, before it + # starts anything. Pure, so the refusals are unit testable and so this boundary does not + # grow a dependency on Authority or Receipts to ask a question about a value. + Profile, UUID, Config, Sessions, @@ -110,6 +114,10 @@ defmodule Trinity do Receipts.Verifier, Receipts.KeyCustody, Authority, + # Slice regulated-boot: `Selection.select/1` is the pure rule the selection child uses. + # The application asks it before that child exists, so that `:regulated` can refuse a + # local authority having started nothing. Exported rather than duplicated. + Authority.Selection, Content.Part ] ++ if(Mix.env() == :test, do: [DataCase, NetworkGuard, Factory], else: []) diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index f5de26d..1a81afa 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -29,6 +29,11 @@ defmodule Trinity.Application do # project's rules about key material, and the filter is the last line for exactly that case. install_log_redaction() + # Slice regulated-boot. Before any child, because a refused boot should have started nothing: + # the same reasoning the DataDir lock is placed early for. Under `:default` this returns :ok + # without reading anything. + verify_regulated_configuration!() + children = desktop_children() ++ [ @@ -94,7 +99,74 @@ defmodule Trinity.Application do # See https://elixir.hexdocs.pm/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Trinity.Supervisor] - Supervisor.start_link(children, opts) + + case Supervisor.start_link(children, opts) do + {:ok, pid} -> verify_regulated_receipts(pid) + other -> other + end + end + + ## Slice regulated-boot: the refusals `:regulated` adds, and nothing else changed by it + ## + ## Every decision below is `Trinity.Profile`'s, which is pure and unit tested. This module reads + ## the world and hands it the values. Under `:default` each one answers :ok without looking. + + # AC1, AC2, AC3 and AC5: configuration facts, knowable before a child exists. Raising here + # stops the boot with the reason, which is what `verify_log_redaction!/0` below already does and + # what the DataDir lock does by refusing to start. + defp verify_regulated_configuration! do + profile = Trinity.Profile.current() + + if profile == :regulated do + mcp_auth = Application.get_env(:trinity, :mcp_auth, []) |> Keyword.get(:profile, :local) + llm = Application.get_env(:trinity, :llm, []) + authority = authority_module() + + [ + Trinity.Profile.check_mcp_auth(profile, mcp_auth), + Trinity.Profile.check_embedded_as(profile, mcp_auth), + Trinity.Profile.check_authority(profile, authority), + Trinity.Profile.check_llm_endpoints( + profile, + Trinity.Profile.raw_endpoints(), + Keyword.get(llm, :models, []) + ) + ] + |> Enum.each(fn + :ok -> :ok + {:error, reason} -> raise "TRINITY_PROFILE=regulated refuses to boot: #{inspect(reason)}" + end) + end + + :ok + end + + # AC5's input. `Trinity.Authority.Selection.select/1` is the same pure rule the selection child + # uses, asked here before that child exists rather than duplicated. + defp authority_module do + case Trinity.Authority.Selection.select(System.get_env("TRINITY_AUTHORITY")) do + {:ok, module} -> module + _ -> nil + end + end + + # AC4. Only reached under `:regulated`: `:default` never calls it, so + # `Trinity.Receipts.Supervisor` keeps its fail-open shape untouched for every other profile. + # A signer that is unavailable there logs, sets the alarm and lets the tree start; here it stops + # the node, and the tree that did start is brought down rather than left running refused. + defp verify_regulated_receipts(pid) do + if Trinity.Profile.current() == :regulated do + case Trinity.Profile.check_receipts(:regulated, Trinity.Receipts.KeyCustody.boot!()) do + :ok -> + {:ok, pid} + + {:error, reason} -> + Supervisor.stop(pid, :shutdown) + {:error, {:regulated_boot_refused, reason}} + end + else + {:ok, pid} + end end # Tell Phoenix to update the endpoint configuration diff --git a/lib/trinity/profile.ex b/lib/trinity/profile.ex new file mode 100644 index 0000000..0801161 --- /dev/null +++ b/lib/trinity/profile.ex @@ -0,0 +1,196 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Profile do + @moduledoc """ + The deployment profile, and the refusals `:regulated` adds to boot. + + Two profiles. `:default` is everything this tree did before and behaves exactly as it did. + `:regulated` refuses to start in a configuration that cannot support a regulated deployment, + on the reasoning that an operator who has set the wrong thing should find out at boot and not + from an auditor. + + ## Every function here is pure, and that is deliberate + + Nothing in this module reads the environment except `current/0` and `raw_endpoints/0`, and + nothing starts, stops or touches a process. The caller reads the world and hands the values in. + Two reasons: a refusal that cannot be unit tested is a refusal nobody has checked, and this + module sits in the `Trinity` boundary, which does not depend on `Trinity.Authority` or + `Trinity.Receipts` and should not grow a dependency on them to ask a question about a value. + + ## `:default` is never changed by anything here + + Every check takes the profile as its first argument and answers `:ok` for `:default` without + looking at anything else. `Trinity.Receipts.Supervisor` keeps its fail-open shape for every + profile that is not `:regulated`: a missing signer logs, sets `Trinity.Receipts.Alarm`, and lets + the tree start, because every effect is denied until a signer returns. That is right for a + personal machine and wrong for a regulated one, and the difference is expressed here rather than + by changing the supervisor. + """ + + @type t :: :default | :regulated + + @env "TRINITY_PROFILE" + @endpoints_env "TRINITY_REGULATED_LLM_ENDPOINTS" + + @doc """ + The profile in force: `#{@env}`, else `config :trinity, :profile`, else `:default`. + + An unrecognised value raises rather than falling back. A typo in a profile name must not + silently produce the permissive profile, which is the one failure mode this whole module exists + to prevent. + """ + @spec current() :: t() + def current do + case System.get_env(@env) do + nil -> Application.get_env(:trinity, :profile, :default) + "" -> Application.get_env(:trinity, :profile, :default) + "regulated" -> :regulated + "default" -> :default + other -> raise ArgumentError, "#{@env} is not default or regulated: #{inspect(other)}" + end + end + + @doc "True when the profile in force is `:regulated`." + @spec regulated?() :: boolean() + def regulated?, do: current() == :regulated + + @doc "The raw value of `#{@endpoints_env}`, unparsed." + @spec raw_endpoints() :: String.t() | nil + def raw_endpoints, do: System.get_env(@endpoints_env) + + @doc "The name of the endpoint allow-list variable, so an error message and a test agree on it." + @spec endpoints_env() :: String.t() + def endpoints_env, do: @endpoints_env + + @doc """ + AC1. Under `:regulated` the MCP authorization profile must be `:production`. + + `:local` trusts a bearer token in a file on the host and `:personal` runs an authorization + server on the owner's machine. Neither is an identity provider a regulated boundary can point + at, so neither may be the one in force. + """ + @spec check_mcp_auth(t(), atom()) :: :ok | {:error, term()} + def check_mcp_auth(:default, _mcp_auth_profile), do: :ok + def check_mcp_auth(:regulated, :production), do: :ok + + def check_mcp_auth(:regulated, got), + do: {:error, {:regulated_requires_production_mcp_auth, got}} + + @doc """ + AC2. Under `:regulated` the embedded, personal authorization server must not be the one in force. + + This is decided here, before `Trinity.MCP.Boot` runs, because that module ends in a rescue that + turns any failure into a warning log. A warning is not a refusal, and the rescue is not narrowed + to make this work. + """ + @spec check_embedded_as(t(), atom()) :: :ok | {:error, term()} + def check_embedded_as(:default, _mcp_auth_profile), do: :ok + def check_embedded_as(:regulated, :personal), do: {:error, :regulated_refuses_personal_issuer} + def check_embedded_as(:regulated, _other), do: :ok + + @doc """ + AC5. Under `:regulated` the authority in force must not be `Trinity.Authority.Local`. + + **No external adapter is supplied by this tree and none is invented here.** If `TRINITY_AUTHORITY` + is unset, or names something that resolves to `Local`, the boot is refused. The refusal is the + point: a regulated boundary where the machine is judge, actor and scribe is the configuration + this profile exists to stop, and the operator has to supply an adapter for it to start. + """ + @spec check_authority(t(), module()) :: :ok | {:error, term()} + def check_authority(:default, _module), do: :ok + + def check_authority(:regulated, Trinity.Authority.Local), + do: {:error, :regulated_refuses_local_authority} + + def check_authority(:regulated, module) when is_atom(module) and not is_nil(module), do: :ok + + @doc """ + AC4. Under `:regulated`, receipts that cannot be appended at boot stop the node. + + `boot_result` is what `Trinity.Receipts.KeyCustody.boot!/1` answered. Under `:default` this + returns `:ok` whatever it was, which is the existing behaviour and is not changed: the supervisor + logs, sets the alarm, and the tree starts with every effect denied. + """ + @spec check_receipts(t(), {:ok, term()} | {:error, term()}) :: :ok | {:error, term()} + def check_receipts(:default, _boot_result), do: :ok + def check_receipts(:regulated, {:ok, _selection}), do: :ok + + def check_receipts(:regulated, {:error, reason}), + do: {:error, {:regulated_requires_receipts, reason}} + + @doc """ + AC3. Under `:regulated` every configured model must name an endpoint on the allow-list. + + `raw` is `#{@endpoints_env}`, a comma separated list of scheme and host, for example + `https://models.internal,https://gateway.lab.example`. `models` is `config :trinity, :llm`'s + `:models` list. + + **A model with no `base_url` is refused**, and that is not an oversight. Such a model goes to its + provider's default endpoint, which this configuration does not state, and an endpoint that is not + stated cannot be shown to be on the list. Under `:regulated` "we cannot tell where this goes" is + a refusal. + """ + @spec check_llm_endpoints(t(), String.t() | nil, [map()]) :: :ok | {:error, term()} + def check_llm_endpoints(:default, _raw, _models), do: :ok + + def check_llm_endpoints(:regulated, raw, _models) when raw in [nil, ""], + do: {:error, {:regulated_llm_endpoints_unset, @endpoints_env}} + + def check_llm_endpoints(:regulated, raw, models) do + case allowed_endpoints(raw) do + [] -> + {:error, {:regulated_llm_endpoints_unset, @endpoints_env}} + + allowed -> + Enum.reduce_while(models, :ok, &first_refusal(&1, &2, allowed)) + end + end + + defp first_refusal(model, :ok, allowed) do + case check_model(model, allowed) do + :ok -> {:cont, :ok} + {:error, _} = error -> {:halt, error} + end + end + + @doc """ + Parses the allow-list into `{scheme, host}` pairs, dropping anything that is not a scheme and a + host. A malformed entry is dropped rather than widening the list. + """ + @spec allowed_endpoints(String.t() | nil) :: [{String.t(), String.t()}] + def allowed_endpoints(nil), do: [] + + def allowed_endpoints(raw) when is_binary(raw) do + raw + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.flat_map(fn entry -> + case URI.parse(entry) do + %URI{scheme: s, host: h} when is_binary(s) and is_binary(h) and h != "" -> [{s, h}] + _ -> [] + end + end) + end + + defp check_model(model, allowed) do + id = Map.get(model, :id, "(no id)") + url = Map.get(model, :base_url) + + if url in [nil, ""], + do: {:error, {:regulated_llm_endpoint_unstated, id}}, + else: check_url(id, url, allowed) + end + + defp check_url(id, url, allowed) do + case URI.parse(url) do + %URI{scheme: s, host: h} when is_binary(s) and is_binary(h) and h != "" -> + if {s, h} in allowed, + do: :ok, + else: {:error, {:regulated_llm_endpoint_not_allowed, id, "#{s}://#{h}"}} + + _ -> + {:error, {:regulated_llm_endpoint_unparseable, id, url}} + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index be84b45..b64be77 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -7,6 +7,14 @@ # Slice 003: :fips (tests that need FIPS mode on) runs only where the leg declares itself with # TRINITY_FIPS_LEG=1; excluded by tag elsewhere, never skipped. test/fips/mode_test.exs runs # everywhere and is what makes a leg that failed to enter the mode red rather than quiet. +# Slice regulated-boot: :needs_dev_compile (tests that spawn a child OS process running +# `mix run` under MIX_ENV=dev) is excluded on the FIPS leg alone. The leg has no prebuilt dev +# tree, so the child compiles the dev dependency tree from scratch, and that reaches the network +# to fetch the `tokenizers` precompiled NIF. OTP's TLS client cannot complete that download +# (HelloRetryRequest middlebox assertion, erlang/otp#8470, docs/fips-leg.md finding 2 and slice +# 002), so the child dies at `mix compile` and every assertion about what it booted is really an +# assertion about a failed download. Excluded here rather than skipped by hand, and only here: +# gate and postgres run the file in full. # Slice 032: :local_model (the real embedder through the serving) runs where # TRINITY_LOCAL_MODEL_CACHE names a cache that already holds all-MiniLM-L6-v2 (no download, # no network: a missing model is a failure there, not a skip); excluded by tag elsewhere. @@ -16,7 +24,8 @@ local_model? = System.get_env("TRINITY_LOCAL_MODEL_CACHE") not in [nil, ""] ExUnit.start( exclude: [:live, :desktop, :eval] ++ - if(fips_leg?, do: [], else: [:fips]) ++ if(local_model?, do: [], else: [:local_model]) + if(fips_leg?, do: [:needs_dev_compile], else: [:fips]) ++ + if(local_model?, do: [], else: [:local_model]) ) # Slice 024's boot receipt is written by a transient Task after the tree starts; once the diff --git a/test/trinity/profile_signer_down_test.exs b/test/trinity/profile_signer_down_test.exs new file mode 100644 index 0000000..8284b52 --- /dev/null +++ b/test/trinity/profile_signer_down_test.exs @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.ProfileSignerDownTest do + @moduledoc """ + AC4, and the one thing this work was told not to change: the default profile still boots with the + signer down, and `:regulated` does not. + + **`async: false`, and the reason is not politeness.** `Trinity.Receipts.KeyCustody.boot!/1` writes + its answer to `:persistent_term` on both paths, including `{:unavailable, reason}` on failure. An + earlier version of this ran inside an async file and poisoned the signer for the whole run: 48 + unrelated tests failed with `{:signer_unavailable, {:key_file, :enotdir}}`. ExUnit runs + synchronous modules only after every async one has finished, so this is the only safe place to + induce a real failure, and the selection is restored afterwards whatever happens. + """ + use ExUnit.Case, async: false + + alias Trinity.Profile + alias Trinity.Receipts.KeyCustody + + setup do + # A path whose parent is a file, so the key store cannot be created. This is a real failure + # from the real function rather than a hand-written error tuple. + path = Path.join(System.tmp_dir!(), "profile-signer-#{System.unique_integer([:positive])}") + File.write!(path, "a file where a directory should be") + + on_exit(fn -> + # Put the real signer back before anything else runs. boot!/0 re-selects from the configured + # keys directory and rewrites the persistent term. + {:ok, _} = KeyCustody.boot!() + File.rm(path) + end) + + down = KeyCustody.boot!(Path.join(path, "keys")) + + assert {:error, _} = down, + "the probe did not produce a failing boot!, so nothing below is testing what it claims" + + {:ok, signer_down: down} + end + + test "the DEFAULT profile still boots with the signer down", %{signer_down: down} do + assert :ok = Profile.check_receipts(:default, down), + "the default profile refused a boot because the signer was unavailable. " <> + "Trinity.Receipts.Supervisor is fail-open for every profile that is not :regulated, " <> + "and making it fail closed for everyone is the one change this work was told not to " <> + "make." + end + + test "the REGULATED profile does not boot with the signer down, and says why", %{ + signer_down: down + } do + assert {:error, {:regulated_requires_receipts, {:key_file, :enotdir}}} = + Profile.check_receipts(:regulated, down) + end + + test "regulated boots when the signer is available" do + assert :ok = Profile.check_receipts(:regulated, {:ok, %{algorithm: :ed25519}}) + end +end diff --git a/test/trinity/profile_test.exs b/test/trinity/profile_test.exs new file mode 100644 index 0000000..bb300e9 --- /dev/null +++ b/test/trinity/profile_test.exs @@ -0,0 +1,174 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.ProfileTest do + @moduledoc """ + The `:regulated` profile's refusals, and the thing it must not change. + + Every criterion here is a refusal. The pair that matters most is the last describe block: the + default profile still boots with the signer down, and `:regulated` does not. If the first of + those two ever fails, `Trinity.Receipts.Supervisor` has been made to fail closed for everyone, + which is the one thing this work was told not to do. + + No `collect/3` anywhere and no mailbox synchronisation of any kind: every assertion is a pure + function call. AC4's pair lives in `profile_signer_down_test.exs`, which must be synchronous, + because inducing a real signer failure writes global state. + """ + use ExUnit.Case, async: true + + alias Trinity.Profile + + describe "AC1: the MCP authorization profile must be :production" do + test "regulated accepts production" do + assert :ok = Profile.check_mcp_auth(:regulated, :production) + end + + test "regulated refuses local, by name" do + assert {:error, {:regulated_requires_production_mcp_auth, :local}} = + Profile.check_mcp_auth(:regulated, :local) + end + + test "regulated refuses personal, by name" do + assert {:error, {:regulated_requires_production_mcp_auth, :personal}} = + Profile.check_mcp_auth(:regulated, :personal) + end + + test "default accepts every one of them, unchanged" do + for p <- [:local, :personal, :production], + do: assert(:ok = Profile.check_mcp_auth(:default, p)) + end + end + + describe "AC2: the personal issuer does not start under regulated" do + test "regulated refuses the personal profile before MCP.Boot can swallow it" do + assert {:error, :regulated_refuses_personal_issuer} = + Profile.check_embedded_as(:regulated, :personal) + end + + test "regulated does not object to production or local here" do + assert :ok = Profile.check_embedded_as(:regulated, :production) + assert :ok = Profile.check_embedded_as(:regulated, :local) + end + + test "default is untouched" do + assert :ok = Profile.check_embedded_as(:default, :personal) + end + end + + describe "AC3: the model endpoint must be on the allow-list" do + @allowed "https://models.internal, https://gateway.lab.example" + + defp model(id, base_url), do: %{id: id, base_url: base_url} + + test "an unset allow-list refuses, naming the variable" do + for raw <- [nil, ""] do + assert {:error, {:regulated_llm_endpoints_unset, var}} = + Profile.check_llm_endpoints(:regulated, raw, [model("m", "https://x.example")]) + + assert var == Profile.endpoints_env() + end + end + + test "an allow-list of only junk refuses too, rather than allowing everything" do + assert {:error, {:regulated_llm_endpoints_unset, _}} = + Profile.check_llm_endpoints(:regulated, " , ,not-a-url", []) + end + + test "a model on the list is accepted" do + models = [ + model("a", "https://models.internal/v1"), + model("b", "https://gateway.lab.example") + ] + + assert :ok = Profile.check_llm_endpoints(:regulated, @allowed, models) + end + + test "a model off the list is refused, naming the model and the endpoint" do + assert {:error, {:regulated_llm_endpoint_not_allowed, "b", "https://api.openai.com"}} = + Profile.check_llm_endpoints(:regulated, @allowed, [ + model("b", "https://api.openai.com/v1") + ]) + end + + test "a model with no base_url is refused, because an unstated endpoint cannot be shown to be allowed" do + for missing <- [nil, ""] do + assert {:error, {:regulated_llm_endpoint_unstated, "c"}} = + Profile.check_llm_endpoints(:regulated, @allowed, [model("c", missing)]) + end + end + + test "the scheme is part of the match, so http is not https" do + assert {:error, {:regulated_llm_endpoint_not_allowed, _, "http://models.internal"}} = + Profile.check_llm_endpoints(:regulated, @allowed, [ + model("d", "http://models.internal/v1") + ]) + end + + test "default accepts anything at all" do + assert :ok = Profile.check_llm_endpoints(:default, nil, [model("x", nil)]) + end + + test "the allow-list parser keeps scheme and host and drops the rest" do + assert [{"https", "models.internal"}, {"https", "gateway.lab.example"}] = + Profile.allowed_endpoints(@allowed) + + assert [] = Profile.allowed_endpoints("no-scheme-or-host") + assert [] = Profile.allowed_endpoints(nil) + end + end + + describe "AC5: regulated must not run on the local authority" do + test "Local is refused, by name" do + assert {:error, :regulated_refuses_local_authority} = + Profile.check_authority(:regulated, Trinity.Authority.Local) + end + + test "an unset TRINITY_AUTHORITY resolves to Local, and is therefore refused" do + # The rule is Selection's, asked here rather than duplicated. + assert {:ok, Trinity.Authority.Local} = Trinity.Authority.Selection.select(nil) + assert {:ok, Trinity.Authority.Local} = Trinity.Authority.Selection.select("") + assert {:ok, Trinity.Authority.Local} = Trinity.Authority.Selection.select("local") + + assert {:error, :regulated_refuses_local_authority} = + Profile.check_authority( + :regulated, + elem(Trinity.Authority.Selection.select(nil), 1) + ) + end + + test "any other module is accepted, and none is invented here" do + assert :ok = Profile.check_authority(:regulated, Trinity.TestAuthority.Full) + end + + test "default runs on Local, unchanged" do + assert :ok = Profile.check_authority(:default, Trinity.Authority.Local) + end + end + + describe "the profile is read from the environment, and a typo is not permissive" do + test "an unrecognised value raises rather than falling back to default" do + assert_raise ArgumentError, ~r/TRINITY_PROFILE is not default or regulated/, fn -> + System.put_env("TRINITY_PROFILE", "regulate") + + try do + Profile.current() + after + System.delete_env("TRINITY_PROFILE") + end + end + end + + test "regulated and default are read, and absence is default" do + System.put_env("TRINITY_PROFILE", "regulated") + assert Profile.current() == :regulated + assert Profile.regulated?() + + System.put_env("TRINITY_PROFILE", "default") + assert Profile.current() == :default + + System.delete_env("TRINITY_PROFILE") + assert Profile.current() == :default + after + System.delete_env("TRINITY_PROFILE") + end + end +end diff --git a/test/trinity/regulated_boot_node_test.exs b/test/trinity/regulated_boot_node_test.exs new file mode 100644 index 0000000..2c7a81b --- /dev/null +++ b/test/trinity/regulated_boot_node_test.exs @@ -0,0 +1,364 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.RegulatedBootNodeTest do + @moduledoc """ + AC1 to AC5 at the only place they are real: a boot. + + `Trinity.ProfileTest` proves the decisions. It does not prove the boot, and a suite that proves + `Profile.check_*` and calls that AC1 to AC5 is proving the easy half. `start/2` cannot be re-run + in this VM, where the application is already up, so each case here spawns a **separate OS + process** with its own environment and asserts on what that process did. + + ## How each case is isolated + + `XDG_DATA_HOME` per case, which `Trinity.Paths.data_dir/2` reads on Linux, so the child gets its + own data directory, its own `Trinity.DataDir.Lock` and its own database files and cannot collide + with this suite or with another case. + + `MIX_ENV=dev`, deliberately, and not `test`. The test environment puts both repos on + `Ecto.Adapters.SQL.Sandbox`, and a child process with no sandbox owner blocks on checkout rather + than booting: that is the trap slice 126 was opened for and then retracted over. + + `mix run --no-start`, so the script can set application environment and define a fixture module + **before** `Application.ensure_all_started/1` runs the code under test. + + Nothing here touches `:persistent_term` in this VM. Every mutation happens in the child. + + ## Why this file does not run on the FIPS leg + + `MIX_ENV=dev` means the child needs a dev build. On a developer machine and on the `gate` and + `postgres` jobs one either exists or is cheap to produce. The FIPS leg has **no prebuilt dev + tree**, so the child compiles the whole dev dependency tree from scratch, and that reaches the + network to fetch the `tokenizers` precompiled NIF. OTP's TLS client cannot complete that + download: it fails the HelloRetryRequest middlebox assertion (erlang/otp#8470, recorded in + `docs/fips-leg.md` finding 2 and owned by slice 002). The child then dies at `mix compile`, and + every assertion here about what a node booted becomes an assertion about a failed download. + + So the module carries `:needs_dev_compile`, which `test/test_helper.exs` excludes **only** when + `TRINITY_FIPS_LEG=1`. `gate` and `postgres` run all six cases unchanged. This is a limit of the + harness on one leg, not a gap in the profile: nothing here was red because a refusal failed. + + `MIX_ENV=test` was tried first, since the test tree is already built everywhere and needs no + network. The child boots (`test_helper.exs` never runs, so the sandbox stays in `:auto` mode and + nothing blocks on checkout), but the database path is fixed by `config/test.exs` and is not + derived from `XDG_DATA_HOME`, so six children write into the suite's own database: measured, a + child appended its boot receipt at `seq 360` on the live chain. Overriding both repos' paths and + migrating inside the child fixes that for SQLite, and was measured working in 1.45 s with 25 and + 4 tables created and the boot receipt back at `seq 1`. It does not carry to the `postgres` job, + where `:database` names a database rather than a file and each case would need one provisioned + and dropped. That is not written here, because it could not be tested here. + """ + use ExUnit.Case, async: false + + # Excluded on the FIPS leg alone. See "Why this file does not run on the FIPS leg" above. + @moduletag :needs_dev_compile + @moduletag timeout: 180_000 + + # A signer failure the child can reproduce: the key file exists as a directory, so `File.read/1` + # answers `{:error, :eisdir}` and `KeyCustody.boot!/1` answers `{:error, {:key_file, :eisdir}}`. + # `keys_dir` itself is a real directory, so nothing raises on the way there. + defp broken_keys_dir(root) do + dir = Path.join(root, "broken-keys") + File.mkdir_p!(Path.join(dir, "receipts-ed25519.key")) + dir + end + + defp tmp!(name) do + dir = Path.join(System.tmp_dir!(), "regboot-#{name}-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf(dir) end) + dir + end + + # One child boot. Returns `{output, exit_status}`; the script prints BOOT_OK or BOOT_REFUSED and + # halts, so the status distinguishes the two without parsing prose. + defp boot(env, script) do + base = [ + {"MIX_ENV", "dev"}, + {"TRINITY_FAKE_PROVIDER", "1"}, + # Never inherit this suite's values for anything the code under test reads. + {"TRINITY_PROFILE", nil}, + {"TRINITY_AUTHORITY", nil}, + {"TRINITY_REGULATED_LLM_ENDPOINTS", nil}, + {"TRINITY_MCP_AUTH_PROFILE", nil} + ] + + System.cmd("mix", ["run", "--no-start", "-e", script], + env: base ++ env, + stderr_to_stdout: true + ) + end + + # The tail of every script: start the application and say what happened. + # + # `limit: :infinity` is not decoration. This was `limit: 3`, which printed + # `BOOT_REFUSED {:trinity, {{...}, ...}}` and hid the reason entirely, so an assertion that the + # reason was named passed only because the same words appear in the crash report OTP writes to + # stderr. A test for "it refused, and said why" has to read what the node said. + @report """ + case Application.ensure_all_started(:trinity) do + {:ok, _} -> + IO.puts("BOOT_OK") + System.halt(0) + + {:error, reason} -> + IO.puts("BOOT_REFUSED " <> inspect(reason, limit: :infinity, printable_limit: :infinity)) + System.halt(3) + end + """ + + # The line the child printed, and nothing else: not the crash report, not a log line. Every + # refusing case asserts through this, so a reason that reaches stderr but never reaches the line + # the node printed fails the test rather than passing it. + defp refusal_line(out) do + out + |> String.split("\n") + |> Enum.find("", &String.starts_with?(&1, "BOOT_REFUSED")) + end + + # A module that implements Trinity.Authority and is not Local. Defined in the child rather than + # added to lib/, because AC5 says no adapter is supplied by this tree and none is invented. + @fixture """ + defmodule RegulatedBootFixture do + @behaviour Trinity.Authority + def stage(staged, _ctx), do: {:ok, staged} + def decide(_staged, decision, _ctx), do: {:ok, decision, "fixture"} + def execute(_staged, _decision, _ctx), do: {:error, :not_used} + def receipt(_kind, _attrs), do: {:error, :not_used} + end + """ + + defp production_mcp_auth do + """ + Application.put_env(:trinity, :mcp_auth, profile: :production) + """ + end + + defp allowed_models do + """ + llm = Application.get_env(:trinity, :llm, []) + Application.put_env(:trinity, :llm, Keyword.put(llm, :models, [ + %{id: "allowed", provider: :req_llm, model: "openai:x", + base_url: "https://models.internal/v1", api_key_env: "NONE", + caps: [:stream], price: %{input: 0.0, output: 0.0}} + ])) + """ + end + + describe "1. the default profile boots with the signer down" do + test "a broken key store does not stop a default node" do + root = tmp!("default-signer-down") + + script = """ + r = Application.get_env(:trinity, :receipts, []) + Application.put_env(:trinity, :receipts, Keyword.put(r, :keys_dir, "#{broken_keys_dir(root)}")) + #{@report} + """ + + {out, status} = boot([{"XDG_DATA_HOME", root}], script) + + assert status == 0, + """ + the default profile refused to boot with the signer down. That is rule 1: \ + lib/trinity/receipts/supervisor.ex is fail-open for every profile that is not \ + :regulated, and this is the test that says so. + + #{String.slice(out, -3000, 3000)} + """ + + assert out =~ "BOOT_OK" + end + end + + describe "2. regulated refuses an unset endpoint allow-list" do + test "production mcp_auth and a non-Local authority, but no allow-list, does not start" do + root = tmp!("regulated-no-endpoints") + + script = """ + #{@fixture} + #{production_mcp_auth()} + #{@report} + """ + + {out, status} = + boot( + [ + {"XDG_DATA_HOME", root}, + {"TRINITY_PROFILE", "regulated"}, + {"TRINITY_AUTHORITY", "RegulatedBootFixture"} + ], + script + ) + + assert status == 3, "the node started with no endpoint allow-list:\n#{out}" + assert out =~ "BOOT_REFUSED" + + reason = refusal_line(out) + + assert reason =~ "regulated_llm_endpoints_unset", + "it refused, but the printed reason is not the one under test:\n#{reason}" + + # The refusal names the variable the operator has to set, not only that something is unset. + assert reason =~ "TRINITY_REGULATED_LLM_ENDPOINTS" + end + end + + describe "3. regulated refuses the local authority" do + test "everything else correct, but TRINITY_AUTHORITY unset, does not start" do + root = tmp!("regulated-local-authority") + + script = """ + #{production_mcp_auth()} + #{allowed_models()} + #{@report} + """ + + {out, status} = + boot( + [ + {"XDG_DATA_HOME", root}, + {"TRINITY_PROFILE", "regulated"}, + {"TRINITY_REGULATED_LLM_ENDPOINTS", "https://models.internal"} + ], + script + ) + + assert status == 3, "the node started on the local authority:\n#{out}" + + reason = refusal_line(out) + + assert reason =~ "regulated_refuses_local_authority", + "it refused, but the printed reason is not the one under test:\n#{reason}" + end + end + + describe "4. regulated boots when every condition is met" do + test "production mcp_auth, a non-Local authority, an allowed endpoint and a signer" do + root = tmp!("regulated-good") + + script = """ + #{@fixture} + #{production_mcp_auth()} + #{allowed_models()} + #{@report} + """ + + {out, status} = + boot( + [ + {"XDG_DATA_HOME", root}, + {"TRINITY_PROFILE", "regulated"}, + {"TRINITY_AUTHORITY", "RegulatedBootFixture"}, + {"TRINITY_REGULATED_LLM_ENDPOINTS", "https://models.internal"} + ], + script + ) + + assert status == 0, + """ + a correctly configured regulated node refused to boot. If this fails while 2 and 3 \ + pass, the profile refuses everything and proves nothing. + + #{String.slice(out, -4000, 4000)} + """ + + assert out =~ "BOOT_OK" + end + end + + describe "5. regulated refuses an MCP authorization profile that is not production" do + test "personal and local are both refused, where case 4's configuration is otherwise met" do + for profile <- ["personal", "local"] do + root = tmp!("regulated-mcp-#{profile}") + + # No `Application.put_env` for :mcp_auth here, deliberately. The variable goes through + # `config/runtime.exs`, which is the path an operator actually uses, and which raises on a + # value that is not one of the three. MIX_ENV=dev, so that block runs (it is skipped in + # :test, where the suite sets :mcp_auth itself). + script = """ + #{@fixture} + #{allowed_models()} + #{@report} + """ + + {out, status} = + boot( + [ + {"XDG_DATA_HOME", root}, + {"TRINITY_PROFILE", "regulated"}, + {"TRINITY_MCP_AUTH_PROFILE", profile}, + {"TRINITY_AUTHORITY", "RegulatedBootFixture"}, + {"TRINITY_REGULATED_LLM_ENDPOINTS", "https://models.internal"} + ], + script + ) + + assert status == 3, + """ + the node started on the #{profile} MCP authorization profile. Case 4 is the same \ + configuration with production, and it starts, so this is the only difference. + + #{String.slice(out, -3000, 3000)} + """ + + assert out =~ "BOOT_REFUSED" + + reason = refusal_line(out) + + assert reason =~ "regulated_requires_production_mcp_auth", + "it refused, but the printed reason is not the one under test:\n#{reason}" + + # The reason names the profile that was refused, not just that one was. + assert reason =~ ":#{profile}" + end + end + end + + describe "6. regulated refuses to keep running when receipts cannot be appended" do + test "case 4's configuration with case 1's broken key store does not start" do + root = tmp!("regulated-signer-down") + + # Exactly case 4, plus the one break case 1 survives. Nothing else differs, so a refusal here + # is attributable to the key store and to the profile, and to nothing else. + script = """ + #{@fixture} + #{production_mcp_auth()} + #{allowed_models()} + r = Application.get_env(:trinity, :receipts, []) + Application.put_env(:trinity, :receipts, Keyword.put(r, :keys_dir, "#{broken_keys_dir(root)}")) + #{@report} + """ + + {out, status} = + boot( + [ + {"XDG_DATA_HOME", root}, + {"TRINITY_PROFILE", "regulated"}, + {"TRINITY_AUTHORITY", "RegulatedBootFixture"}, + {"TRINITY_REGULATED_LLM_ENDPOINTS", "https://models.internal"} + ], + script + ) + + assert status == 3, + """ + a regulated node started with the signer down. Case 1 proves the same key store does \ + not stop a :default node, and that difference is the whole point of AC4. + + #{String.slice(out, -4000, 4000)} + """ + + assert out =~ "BOOT_REFUSED" + + reason = refusal_line(out) + + assert reason =~ "regulated_boot_refused", + "it refused, but the printed reason is not the receipts check:\n#{reason}" + + assert reason =~ "regulated_requires_receipts" + + # The underlying signer failure is carried through rather than flattened into a bare atom. + assert reason =~ "key_file" + end + end +end