Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/regulated/authorization-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,32 @@ and this pack says nothing about it.
| Web fetch and search | Out | Permission gate | `lib/trinity/tools/` |
| MCP server requests | In | OAuth 2.1 resource server | `lib/trinity/mcp/auth.ex` |
| Receipt forwarding | Out, if configured | Optional authority callback | `lib/trinity/receipts/forwarder.ex` |
| Gateway messages | Out and in, if configured | **Allow-list under `:regulated`**, then the channel cap | `lib/trinity/gateways/`, `lib/trinity/profile.ex` |

## Gateways: an unnamed channel does not start

A gateway carries conversation text to somewhere that is not this host. That is the same egress a
model endpoint is, with the same contents, so under `TRINITY_PROFILE=regulated` it is held to the
same rule: **every adapter in `config :trinity, :gateways`'s `:adapters` must be named in
`TRINITY_REGULATED_GATEWAYS`, or the node refuses to boot.** The refusal names the adapter, or names
the variable when nothing is set, and it happens before any child starts.

A node with no gateway configured needs no allow-list. Requiring the variable where nothing reaches
out would make every operator set one they do not use, which is how a control becomes a formality.

**This is not a ban.** An authorizable channel exists, and the control is aimed at the accidental
one rather than the category:

| | |
|---|---|
| Refused unless named | any adapter, including the `Console` adapter this tree ships |
| Why not simply ban them | Mattermost runs at IL4, IL5 and IL6, holds a Certificate to Field under Platform One's continuous ATO, authenticates with CAC through SAML, and carries the interactive dialogs an approval round-trip needs. Banning the category would forbid what the deployment target itself runs |
| What the control actually stops | a consumer cloud, reached by configuration, that no boundary authorizes. **No such adapter ships in this tree** |

Beside the allow-list, what an approval arriving from a channel may authorize is capped
independently of it: `lib/trinity/gateways/cap.ex` admits `:read`, `:network` and `:write` from a
paired channel and refuses `:exec` and `:destructive`, whatever the profile. The allow-list decides
whether a channel exists at all; the cap decides what it may do.

A witness or transparency service that would let a third party detect backdated forgery without
trusting the host is **NOT IN TREE**.
8 changes: 8 additions & 0 deletions lib/trinity/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ defmodule Trinity.Application do
profile,
Trinity.Profile.raw_endpoints(),
Keyword.get(llm, :models, [])
),
# A gateway carries conversation text off this host, which is the same egress a model
# endpoint is, so it is held to the same allow-list rather than to nothing. Asked here with
# the others, before any child exists.
Trinity.Profile.check_gateways(
profile,
Trinity.Profile.raw_gateways(),
Trinity.Gateways.adapters()
)
]
|> Enum.each(fn
Expand Down
74 changes: 74 additions & 0 deletions lib/trinity/profile.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ defmodule Trinity.Profile do

@env "TRINITY_PROFILE"
@endpoints_env "TRINITY_REGULATED_LLM_ENDPOINTS"
@gateways_env "TRINITY_REGULATED_GATEWAYS"

@doc """
The profile in force: `#{@env}`, else `config :trinity, :profile`, else `:default`.
Expand Down Expand Up @@ -62,6 +63,14 @@ defmodule Trinity.Profile do
@spec endpoints_env() :: String.t()
def endpoints_env, do: @endpoints_env

@doc "The raw value of `#{@gateways_env}`, unparsed."
@spec raw_gateways() :: String.t() | nil
def raw_gateways, do: System.get_env(@gateways_env)

@doc "The name of the gateway allow-list variable."
@spec gateways_env() :: String.t()
def gateways_env, do: @gateways_env

@doc """
AC1. Under `:regulated` the MCP authorization profile must be `:production`.

Expand Down Expand Up @@ -127,6 +136,71 @@ defmodule Trinity.Profile do
def check_receipts(:regulated, {:error, reason}),
do: {:error, {:regulated_requires_receipts, reason}}

@doc """
Under `:regulated` every configured gateway adapter must be named on the allow-list.

A gateway carries conversation text to somewhere that is not this host. The profile already
refuses a model endpoint that is not named, and a chat channel is the same kind of egress with
the same contents, so it is held to the same rule rather than to a weaker one.

**This is not a ban on gateways.** An authorizable channel exists: Mattermost runs at IL4, IL5
and IL6, holds a Certificate to Field under Platform One's continuous ATO, authenticates with CAC
through SAML, and offers the interactive dialogs an approval round-trip needs. Refusing every
gateway would forbid what the deployment target itself runs. What the profile refuses is an
**unnamed** one, which is how a consumer cloud gets in by accident.

`adapters` is `config :trinity, :gateways`'s `:adapters` list, and `raw` is `#{@gateways_env}`, a
comma separated list of module names with or without the `Elixir.` prefix.

**No gateway configured needs no allow-list.** A regulated node that reaches nothing does not
have to name what it may reach; requiring the variable there would make every operator set one
they do not use, which is how a variable becomes a formality.
"""
@spec check_gateways(t(), String.t() | nil, [module()]) :: :ok | {:error, term()}
def check_gateways(:default, _raw, _adapters), do: :ok
def check_gateways(:regulated, _raw, []), do: :ok

def check_gateways(:regulated, raw, _adapters) when raw in [nil, ""],
do: {:error, {:regulated_gateways_unset, @gateways_env}}

def check_gateways(:regulated, raw, adapters) do
case allowed_gateways(raw) do
[] ->
{:error, {:regulated_gateways_unset, @gateways_env}}

allowed ->
Enum.reduce_while(adapters, :ok, &first_unlisted(&1, &2, allowed))
end
end

defp first_unlisted(adapter, :ok, allowed) do
if module_name(adapter) in allowed,
do: {:cont, :ok},
else: {:halt, {:error, {:regulated_gateway_not_allowed, adapter}}}
end

@doc """
Parses the gateway allow-list into module names, normalised so that `Foo.Bar` and `Elixir.Foo.Bar`
are the same entry. Nothing is resolved to an atom: a name that matches no loaded module simply
matches no configured adapter.
"""
@spec allowed_gateways(String.t() | nil) :: [String.t()]
def allowed_gateways(nil), do: []

def allowed_gateways(raw) when is_binary(raw) do
raw
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.map(&strip_elixir_prefix/1)
end

defp module_name(module) when is_atom(module),
do: module |> Atom.to_string() |> strip_elixir_prefix()

defp strip_elixir_prefix("Elixir." <> rest), do: rest
defp strip_elixir_prefix(name), do: name

@doc """
AC3. Under `:regulated` every configured model must name an endpoint on the allow-list.

Expand Down
33 changes: 28 additions & 5 deletions lib/trinity/receipts/key_custody.ex
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,20 @@ defmodule Trinity.Receipts.KeyCustody do

@doc """
Selects the algorithm, ensures its key and registry row exist, and records the selection.
Returns the selection, or `{:error, reason}` when no approved algorithm can sign here (the
chain then denies every effect, and the boot receipt cannot be written).
Returns the selection, or `{:error, reason}` when no approved algorithm can sign here, or when the
keys directory cannot be made (the chain then denies every effect, and the boot receipt cannot be
written).

**It returns, and does not raise.** `Trinity.Receipts.Supervisor` fails open on an error tuple:
it logs, sets the alarm, and lets the tree start with every effect denied. A raise is not an error
tuple, so it fails the supervisor's start and takes the boot down under **every** profile,
including `:default`, where the documented promise is the opposite. That was SCR-815, measured
from a spawned `:default` boot as `%File.Error{reason: :enotdir}`.
"""
@spec boot!(Path.t() | nil) :: {:ok, selection()} | {:error, term()}
def boot!(dir \\ nil) do
dir = dir || keys_dir()

with {:ok, algorithm} <- select(),
with {:ok, dir} <- resolve_keys_dir(dir),
{:ok, algorithm} <- select(),
impl = Signer.impl(algorithm),
{:ok, key_id} <- ensure_key(dir, impl) do
selection = %{
Expand All @@ -85,6 +91,23 @@ defmodule Trinity.Receipts.KeyCustody do
@spec selected() :: selection() | {:unavailable, term()} | nil
def selected, do: :persistent_term.get(@key, nil)

# Both ways of resolving the directory create it with the raising `File` functions: `keys_dir/0`
# calls `File.mkdir_p!/1` on a configured path, and `Trinity.Paths.keys_dir/0` calls
# `File.mkdir_p!/1` and `File.chmod!/2` on the default one. Either raises `%File.Error{}` where a
# regular file sits in the path, or the data directory is not writable.
#
# `boot!/1`'s contract is an error tuple and the supervisor only fails open on one, so the rescue
# belongs exactly here, at the boundary where the contract changes, rather than at either call
# site. An explicit directory is the caller's own: it is passed through, and a bad one surfaces
# from `ensure_key/2` as `{:key_file, reason}` without raising either.
defp resolve_keys_dir(dir) when is_binary(dir), do: {:ok, dir}

defp resolve_keys_dir(nil) do
{:ok, keys_dir()}
rescue
e in File.Error -> {:error, {:keys_dir, e.reason}}
end

@doc """
The algorithm the rules select on this runtime: the configured one when it is available,
else P-384 in FIPS mode, else Ed25519; `{:error, :no_approved_signer}` when the selected
Expand Down
43 changes: 43 additions & 0 deletions test/trinity/profile_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,49 @@ defmodule Trinity.ProfileTest do
end
end

describe "regulated refuses a gateway that is not on the allow-list" do
@adapters [Trinity.Gateways.Console]

test "no gateway configured needs no allow-list" do
# A regulated node that reaches nothing does not have to name what it may reach. Requiring
# the variable here would make every regulated operator set one they do not use.
assert :ok = Profile.check_gateways(:regulated, nil, [])
end

test "a configured gateway with no allow-list refuses, naming the variable" do
for raw <- [nil, ""] do
assert {:error, {:regulated_gateways_unset, var}} =
Profile.check_gateways(:regulated, raw, @adapters)

assert var == Profile.gateways_env()
end
end

test "an adapter on the list is accepted, with or without the Elixir prefix" do
assert :ok = Profile.check_gateways(:regulated, "Trinity.Gateways.Console", @adapters)

assert :ok =
Profile.check_gateways(:regulated, "Elixir.Trinity.Gateways.Console", @adapters)
end

test "an adapter off the list is refused, naming the adapter" do
assert {:error, {:regulated_gateway_not_allowed, Trinity.Gateways.Console}} =
Profile.check_gateways(:regulated, "MyOrg.Mattermost", @adapters)
end

test "every adapter must be listed, not just one of them" do
assert {:error, {:regulated_gateway_not_allowed, Trinity.Gateways.Console}} =
Profile.check_gateways(:regulated, "MyOrg.Mattermost", [
MyOrg.Mattermost,
Trinity.Gateways.Console
])
end

test "default accepts any adapter at all, unchanged" do
assert :ok = Profile.check_gateways(:default, nil, [SomeChatCloud.Adapter])
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} =
Expand Down
52 changes: 52 additions & 0 deletions test/trinity/receipts/key_custody_keys_dir_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.Receipts.KeyCustodyKeysDirTest do
@moduledoc """
SCR-815: a keys directory that cannot be created is an error, not a raise.

`Trinity.Receipts.Supervisor` fails open for every profile that is not `:regulated` — it logs,
sets `Trinity.Receipts.Alarm`, and lets the tree start with every effect denied until a signer
returns. It does that on an **error tuple** from `KeyCustody.boot!/1`.

Both ways of resolving the keys directory create it with the raising `File` functions:
`KeyCustody.keys_dir/0` calls `File.mkdir_p!/1` on a configured path, and
`Trinity.Paths.keys_dir/0` calls `File.mkdir_p!/1` and `File.chmod!/2` on the default one. A raise
is not an error tuple, so it never reaches the code that decides to fail open, and the boot goes
down **whatever the profile** — measured from a spawned `:default` boot as
`%File.Error{reason: :enotdir}` out of `failed_to_start_child, Trinity.Receipts.Supervisor`.

The shape is ordinary: a regular file where the keys directory belongs, left by a sync client, a
restore, or a packaging mistake.

**`async: false`, and not out of politeness.** `boot!/1` writes its answer to `:persistent_term`
on both paths, including `{:unavailable, reason}` on failure, so an async version of this poisons
the signer for the rest of the run. The real signer is restored in `on_exit`.
"""
use ExUnit.Case, async: false

alias Trinity.Receipts.KeyCustody

setup do
original = Application.get_env(:trinity, :receipts, [])
path = Path.join(System.tmp_dir!(), "keys-dir-as-file-#{System.unique_integer([:positive])}")
File.write!(path, "a file where the keys directory should be")

on_exit(fn ->
Application.put_env(:trinity, :receipts, original)
{:ok, _} = KeyCustody.boot!()
File.rm(path)
end)

Application.put_env(:trinity, :receipts, Keyword.put(original, :keys_dir, path))
{:ok, path: path}
end

test "a keys directory that is a regular file is reported, not raised" do
assert {:error, {:keys_dir, :enotdir}} = KeyCustody.boot!()
end

test "the failure is recorded as unavailable, so the chain denies every effect" do
{:error, reason} = KeyCustody.boot!()
assert {:unavailable, ^reason} = KeyCustody.selected()
end
end
71 changes: 71 additions & 0 deletions test/trinity/regulated_boot_node_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,77 @@ defmodule Trinity.RegulatedBootNodeTest do
end
end

describe "7. regulated refuses a gateway that is not on the allow-list" do
test "a configured adapter refuses without the allow-list and boots with it" do
# Both directions in one case, as case 5 does for two profiles. Without the second half a
# refusal proves only that the check fires, not that it can be satisfied.
configured = """
Application.put_env(:trinity, :gateways, adapters: [Trinity.Gateways.Console])
"""

{root, tag} = case_setup("regulated_gateway_unlisted")

{out, status} =
boot(
[
{"XDG_DATA_HOME", root},
{"TRINITY_BOOT_TAG", tag},
{"TRINITY_PROFILE", "regulated"},
{"TRINITY_AUTHORITY", "RegulatedBootFixture"},
{"TRINITY_REGULATED_LLM_ENDPOINTS", "https://models.internal"}
],
"""
#{@fixture}
#{production_mcp_auth()}
#{allowed_models()}
#{configured}
#{@report}
"""
)

assert status == 3, "the node started with an unlisted gateway:\n#{out}"
reason = refusal_line(out)

assert reason =~ "regulated_gateways_unset",
"it refused, but the printed reason is not the one under test:\n#{reason}"

assert reason =~ "TRINITY_REGULATED_GATEWAYS"

# Named, and it starts. The adapter is unchanged; only the allow-list differs.
{root2, tag2} = case_setup("regulated_gateway_listed")

{out2, status2} =
boot(
[
{"XDG_DATA_HOME", root2},
{"TRINITY_BOOT_TAG", tag2},
{"TRINITY_PROFILE", "regulated"},
{"TRINITY_AUTHORITY", "RegulatedBootFixture"},
{"TRINITY_REGULATED_LLM_ENDPOINTS", "https://models.internal"},
{"TRINITY_REGULATED_GATEWAYS", "Trinity.Gateways.Console"}
],
"""
#{@fixture}
#{production_mcp_auth()}
#{allowed_models()}
#{configured}
#{@report}
"""
)

assert status2 == 0,
"""
the allow-list named the configured adapter and the node still refused. If this fails \
while the half above passes, the profile refuses every gateway rather than an unnamed \
one, which is not the rule.

#{String.slice(out2, -3000, 3000)}
"""

assert out2 =~ "BOOT_OK"
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, tag} = case_setup("regulated-signer-down")
Expand Down
Loading