Skip to content
8 changes: 8 additions & 0 deletions lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: [])
Expand Down
74 changes: 73 additions & 1 deletion lib/trinity/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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() ++
[
Expand Down Expand Up @@ -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
Expand Down
196 changes: 196 additions & 0 deletions lib/trinity/profile.ex
Original file line number Diff line number Diff line change
@@ -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
11 changes: 10 additions & 1 deletion test/test_helper.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
59 changes: 59 additions & 0 deletions test/trinity/profile_signer_down_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading