diff --git a/docs/regulated/authorization-boundary.md b/docs/regulated/authorization-boundary.md index 964d260..ba7d1c9 100644 --- a/docs/regulated/authorization-boundary.md +++ b/docs/regulated/authorization-boundary.md @@ -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**. diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index 150128c..59e47f3 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -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 diff --git a/lib/trinity/profile.ex b/lib/trinity/profile.ex index d4beb8e..63c24ca 100644 --- a/lib/trinity/profile.ex +++ b/lib/trinity/profile.ex @@ -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`. @@ -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`. @@ -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. diff --git a/lib/trinity/receipts/key_custody.ex b/lib/trinity/receipts/key_custody.ex index 7c623e7..059e9c8 100644 --- a/lib/trinity/receipts/key_custody.ex +++ b/lib/trinity/receipts/key_custody.ex @@ -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 = %{ @@ -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 diff --git a/test/trinity/profile_test.exs b/test/trinity/profile_test.exs index 776ba74..d1a3fc2 100644 --- a/test/trinity/profile_test.exs +++ b/test/trinity/profile_test.exs @@ -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} = diff --git a/test/trinity/receipts/key_custody_keys_dir_test.exs b/test/trinity/receipts/key_custody_keys_dir_test.exs new file mode 100644 index 0000000..902d36c --- /dev/null +++ b/test/trinity/receipts/key_custody_keys_dir_test.exs @@ -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 diff --git a/test/trinity/regulated_boot_node_test.exs b/test/trinity/regulated_boot_node_test.exs index 6e22d60..870aa8f 100644 --- a/test/trinity/regulated_boot_node_test.exs +++ b/test/trinity/regulated_boot_node_test.exs @@ -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")