diff --git a/README.md b/README.md index 978af77..f53a544 100644 --- a/README.md +++ b/README.md @@ -350,10 +350,11 @@ somewhere outside the host, on any schedule, makes backdated forgery detectable without needing a transparency service. This is not built yet and is named here as the gap it is. **An external authority plane proves the decision was never the machine's to make.** Point -`TRINITY_AUTHORITY` at an adapter and Trinity keeps no executor for the effects that adapter -governs, which is asserted by a census over the tree rather than by intent. The decision is made -elsewhere by something with its own key, so relying on it does not require trusting this host at -all. +`TRINITY_AUTHORITY` at an adapter and the decision is made elsewhere, by something with its own +key, so relying on it does not require trusting this host to have decided honestly. There is no +model-side executor: after the adapter allows, the tool runs in this VM, behind the same membrane +and the same admission receipt as always, and a census over the tree asserts that there is exactly +one place it can run. The honest one-line version: **standalone proves what happened to someone who trusts the machine, a witness proves it to someone who does not, and an external authority proves the decision was never @@ -373,10 +374,12 @@ the three fields that have no equivalent, is in Trinity runs standalone, and it is also one component of **Sanction OS**, the platform formed by Requisition and Ultraviolet. -**Requisition** is the authority layer. Point `TRINITY_AUTHORITY` at the adapter module and Trinity delegates every -catalogued effect to it: Trinity proposes, Requisition decides, and Trinity keeps no executor for those effects. -Trinity refuses to start if the module is absent or does not implement `Trinity.Authority`. See -`docs/adr/0008-authority-is-an-adapter.md` and `docs/adr/0010-authority-selection-at-boot.md`. +**Requisition** is the authority layer. Point `TRINITY_AUTHORITY` at the adapter module and every catalogued effect +is put to it for a decision: anything may propose, the authority layer authorizes, and after an allow this node's +Elixir runs the tool. The adapter does not run it and may not change the arguments it was asked about. Trinity +refuses to start if the module is absent or does not implement `Trinity.Authority`. See +`docs/authority-adapter.md` for the contract, and `docs/adr/0008-authority-is-an-adapter.md` and +`docs/adr/0010-authority-selection-at-boot.md` for the decisions behind it. **Ultraviolet** is the purple-team tool. Add it on the `/mcp` page like any other MCP server (since slice 060 a server is a row, not a config entry). Its read tools return query-receipted results, its proposal tools return proposal ids rather than effects, and content diff --git a/docs/adr/0008-authority-is-an-adapter.md b/docs/adr/0008-authority-is-an-adapter.md index 24fb151..1576350 100644 --- a/docs/adr/0008-authority-is-an-adapter.md +++ b/docs/adr/0008-authority-is-an-adapter.md @@ -20,8 +20,10 @@ whether the thing you asked for is allowed to happen. behind `Trinity.MCP.Auth`. That answers who is calling and with what scopes. Whether an effect happens is answered by the gate, or by whichever `Trinity.Authority` implementation is in force. The auth layer is never described as an authority layer. -5. When an external adapter is in force, Trinity keeps **no executor** for the effects that adapter governs. The - absence is asserted by a census over the tree, not by intent. +5. **Amended 2026-09-30, see below.** ~~When an external adapter is in force, Trinity keeps **no executor** for the + effects that adapter governs. The absence is asserted by a census over the tree, not by intent.~~ + There is **no model-side executor**, and after a `decide/3` allow **this VM runs the tool**. The census over the + tree still asserts the shape, and now asserts one executor rather than none. ## Consequences - Adapters can be written against a published behaviour by anyone, including for authority layers this project @@ -64,3 +66,39 @@ record. That is deliberate and belongs in this ADR because it is a statement abo can and cannot deliver: after a partition, two devices hold two true accounts of what each did, and nothing available afterwards turns them into one account of what happened. A system that claimed otherwise would be inventing the part it could not know. + +## Amended 2026-09-30 — point 5: one executor, here, not none + +**Source: the owner, 2026-09-30.** Point 5 as accepted said that an external adapter meant Trinity kept no executor +for the effects that adapter governed, and the census test asserted the absence by finding exactly one file able to +call a tool's `execute/2` and noting that the effect never reached it under an adapter. + +That is no longer the design. The split is: + +> Any actor proposes. The authority in force authorizes. This node's Elixir executes Trinity tools after an allow. + +The adapter **does not run the tool** and **does not change the arguments**. It answers `:allow` or `:deny` with a +basis, and stops. + +### Why the earlier shape was worse + +It put the effect in the adapter's hands, in another repository, on the far side of the membrane. Every check the +membrane makes, the effect class, the catalogue, the fingerprint, the idempotency key, the admission receipt, applied +to something that then happened somewhere else. A second executor is a second place for the rules to be different. + +It also made `stage/2` load-bearing in a way nothing checked: the membrane verified the fingerprint, handed the +`Staged` to the authority, and used whatever came back. An adapter could change `args` after the fingerprint had been +verified, or keep `args` and change `module`, which no fingerprint covers at all. + +### What is true now, with where it is enforced + +| claim | enforced at | +| --- | --- | +| the tool runs in this VM, after `decide/3` allow and after the admission receipt is in the chain | `lib/trinity/effects.ex`, `run_tool/2` | +| exactly one file calls a tool's `execute/2` on the effect path | `test/trinity/effects/census_test.exs` | +| `stage/2` may set `staged_at` and `basis` and nothing else | `lib/trinity/effects.ex`, `check_stage_kept_the_subject/2` | +| the fingerprint is re-derived after `stage/2` against the one the gate bound | `lib/trinity/effects.ex`, `check_fingerprint_against/2` | +| an adapter's `execute/3` is on no path and must not run a tool | `lib/trinity/authority.ex`, `lib/trinity/authority/local.ex` | + +Decisions 1 to 4 are unchanged. The behaviour still has four callbacks, because removing `execute/3` would silently +accept an adapter written against the older contract, where returning a result from it was how the effect happened. diff --git a/docs/authority-adapter.md b/docs/authority-adapter.md new file mode 100644 index 0000000..41540b7 --- /dev/null +++ b/docs/authority-adapter.md @@ -0,0 +1,156 @@ + +# Writing an authority adapter + +How to make an external authority layer the one that decides for this node. + +**Every claim here is a path in this repository, or says NOT IN TREE.** A sentence you cannot follow +to a file is a sentence you should not trust. + +## The split + +> Anything may propose. The authority in force authorizes. After an allow, this node's Elixir runs +> the tool. + +The adapter **decides**. It does not run the tool, and it does not change what it was asked about. + +## One module, chosen once, at boot + +`TRINITY_AUTHORITY` names one module. It is read once, by `Trinity.Authority.Selection` +(`lib/trinity/authority/selection.ex`), as the first child of the application after the data +directory lock, and the selection is kept in `:persistent_term`. No runtime path changes it. + +- unset, `""`, or `local` selects `Trinity.Authority.Local`. +- any other value is an Elixir module name, with or without the `Elixir.` prefix. +- the module must be **loaded** and must export every callback, or the boot stops and the refusal + names which: `{:not_loaded, name}` or `{:missing_callback, module, {name, arity}}`. +- a name that is not an existing atom is reported as not loaded, and no atom is created from the + environment's text. + +Selection is **synchronous**: `Selection.start/0` completes before any later child starts, so there +is no window in which the node is running with the selection still being made. Before that, +`Trinity.Authority.impl/0` (`lib/trinity/authority.ex`) answers `Local` under `:default` and +**raises** under `:regulated`, because a brief fallback to the local authority is still a fallback. + +`Trinity.Authority.Local` (`lib/trinity/authority/local.ex`) is **the only implementation in this +tree**. Everything else is NOT IN TREE: this repository holds no client, no envelope, no schema and +no transport for any particular authority layer, and a test asserts that under `local` no adapter +module is loaded (`test/trinity/authority/selection_test.exs`). + +## The regulated profile refuses the local authority + +Under `TRINITY_PROFILE=regulated` the node refuses to boot on `Trinity.Authority.Local`: + +- the rule is `Trinity.Profile.check_authority/2` in `lib/trinity/profile.ex`, which answers + `{:error, :regulated_refuses_local_authority}`. +- it is asked at boot, before any child exists, by `verify_regulated_configuration!/0` in + `lib/trinity/application.ex`. +- an unset `TRINITY_AUTHORITY` resolves to `Local` and is therefore refused too. +- a name that cannot be resolved is refused as `{:regulated_authority_unselectable, reason}`, + carrying the selection's own reason, and `check_authority/2` is total, so a caller that loses the + reason still gets `{:regulated_authority_unresolved, value}` rather than a crash. + +The refusals are tested in `test/trinity/profile_test.exs`, and at an actual boot, in spawned OS +processes, in `test/trinity/regulated_boot_node_test.exs`. + +## The callbacks + +Four are required. `Trinity.Authority.callbacks/0` is the list the selection checks against, in +`lib/trinity/authority.ex`. + +| callback | answer | what it is for | +| --- | --- | --- | +| `stage/2` | `{:ok, Staged.t()}` or `{:error, term()}` | record that an effect is about to happen | +| `decide/3` | `{:ok, :allow \| :deny, basis}` or `{:error, term()}` | **the decision**, with a basis saying why | +| `execute/3` | `{:error, :not_the_effect_path}` | reserved; see below | +| `receipt/2` | `{:ok, term()}` or `{:error, term()}` | record a receipt of a kind | + +And one optional: + +| callback | answer | what it is for | +| --- | --- | --- | +| `forward_receipt/2` | `:ok` or `{:error, term()}` | acknowledge a queued receipt envelope | + +### `decide/3` returns allow or deny, and never falls back + +`:allow` or `:deny`. An adapter that cannot reach its far side returns `{:error, reason}` or +`:deny`; it must **not** decide locally, and it must not answer as though the local gate had +decided. The membrane treats `{:ok, :deny, basis}` as a denial with a receipt, and `{:error, _}` as +a denial with a receipt. Both are safe. A fallback to `Local` is the one answer that is not. + +### `execute/3` must not run a tool + +It is **not** the place the effect happens, and nothing on the effect path calls it. After a +`decide/3` allow and after the admission receipt is in the chain, `Trinity.Effects.run_tool/2` +(`lib/trinity/effects.ex`) calls the tool's own `execute/2`, in this VM. + +It stays in the behaviour so that an adapter written against the older contract, where returning a +result from it was how the effect happened, is not silently accepted. Answer +`{:error, :not_the_effect_path}`, which is what `Trinity.Authority.Local` answers. + +### `stage/2` may not change the subject + +Set `staged_at` and `basis`. Nothing else. Every other field of the `Trinity.Authority.Staged` +(`lib/trinity/authority/staged.ex`) you return must be the one you were given. + +After `stage/2` the membrane re-derives the fingerprint over the arguments you returned and compares +it to the one the gate bound, not to the one your struct now carries, so rewriting `args` and +`fingerprint` to agree with each other does not pass. It then compares every remaining field, +because the fingerprint covers the session, the tool name, the arguments and the working directory, +and covers `module` not at all: an adapter could otherwise leave every fingerprinted field alone and +still change which code runs. + +Both refusals are tested against adapters that actually attempt them, in +`test/trinity/effects/membrane_test.exs` with `Trinity.TestAuthority.MutatesArgs` and +`Trinity.TestAuthority.SwapsModule` (`test/support/authority/adapters.ex`), alongside a control +adapter that changes nothing and whose effect runs. + +## Receipts: Trinity writes them, the adapter acknowledges them + +Trinity signs and appends its own receipts, locally, around the effect: an `admit` before the tool +runs and a `done` after it, or a `denied` instead. That is `Trinity.Effects` and +`Trinity.Receipts`. **The adapter does not write Trinity's receipts.** + +What an adapter may do is receive them. `forward_receipt/2` is offered the exported envelope +**byte for byte, exactly as it was signed**. An implementation must not re-sign it, rebuild it or +alter a leaf: the point is that a verifier checks the signature over those bytes offline, which is +what lets a queue wrap, delay and re-deliver an envelope without the far side knowing a queue +exists. `:ok` acknowledges; any error leaves the entry pending to be offered again, oldest first. +An implementation that does not export it is one this machine never forwards to, and the queue for +its scopes simply does not drain. See `lib/trinity/receipts/queue.ex` and +`test/trinity/receipts/queue_test.exs`. + +## `act_hash` is opaque + +An authority layer may bind its decision to an act identifier. **Trinity treats it as opaque.** It +does not parse it, does not recompute it and does not depend on its shape; it carries it and stores +it beside the decision. + +Where an adapter records one, it should record the algorithm beside it, as `act_hash` and +`act_hash_alg`, so a reader is never guessing which construction produced the digest. The value +this contract expects is `sha256+rfc8785-v1`: SHA-256 over the [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) +canonical JSON form. Trinity already uses RFC 8785 for its own approval fingerprints and holds a +conformance vector for it (`test/support/rfc8785_input.json`, `test/support/rfc8785_expected.txt`), +so the canonicalisation is not a new dependency here. + +**`act_hash` is NOT IN TREE.** No field, column or function of that name exists in this repository. +It is named here so an adapter and this node agree on the word, not because Trinity reads it. + +## Packaging an adapter + +The adapter lives **outside this repository**, and the release that contains both is built outside +it too. This tree builds a Trinity that can be pointed at one; it does not build, vendor or depend +on any. A wrapper release adds the adapter module to the code path and sets `TRINITY_AUTHORITY`. +Anything more than that is NOT IN TREE. + +## Checklist + +- [ ] the module is loaded in the release, and `TRINITY_AUTHORITY` names it +- [ ] `stage/2`, `decide/3`, `execute/3`, `receipt/2` are exported +- [ ] `stage/2` changes nothing but `staged_at` and `basis` +- [ ] `decide/3` answers `:allow` or `:deny`, and never falls back to a local decision +- [ ] `execute/3` runs no tool +- [ ] `forward_receipt/2` acknowledges bytes it did not alter, or is absent on purpose +- [ ] under `TRINITY_PROFILE=regulated`, the node boots, which means it did not select `Local` diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index 1a81afa..150128c 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -120,12 +120,11 @@ defmodule Trinity.Application do 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), + check_authority(profile), Trinity.Profile.check_llm_endpoints( profile, Trinity.Profile.raw_endpoints(), @@ -143,10 +142,19 @@ defmodule Trinity.Application do # 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 + # + # Owner ruling 2026-09-30: a refusal is reported with the selection's own reason. This used to + # answer `nil` for every refusal, and `nil` reached `check_authority/2` as an argument no clause + # matched, so `TRINITY_AUTHORITY=SomethingNotLoaded` under `:regulated` stopped the boot with a + # FunctionClauseError in `Trinity.Profile` rather than with the name of the module that could + # not be loaded. + defp check_authority(profile) do case Trinity.Authority.Selection.select(System.get_env("TRINITY_AUTHORITY")) do - {:ok, module} -> module - _ -> nil + {:ok, module} -> + Trinity.Profile.check_authority(profile, module) + + {:error, reason} -> + {:error, {:regulated_authority_unselectable, reason}} end end diff --git a/lib/trinity/authority.ex b/lib/trinity/authority.ex index 5665787..96581c8 100644 --- a/lib/trinity/authority.ex +++ b/lib/trinity/authority.ex @@ -4,11 +4,22 @@ defmodule Trinity.Authority do @moduledoc """ For every effect, something decides whether it may happen (ADR-0008). This behaviour is that something's shape: `stage/2` records an effect as about to happen, `decide/3` answers - whether it may, `execute/3` makes it happen, `receipt/2` records what happened. One - implementation ships in this tree, `Trinity.Authority.Local` (the permission gate decides, - the tool runs here, the receipt is local). An external authority layer is an adapter in - another repository, named by `TRINITY_AUTHORITY`, selected once at boot (ADR-0010) by - `Trinity.Authority.Selection`, and never changed afterwards. + whether it may, `receipt/2` records what happened. One implementation ships in this tree, + `Trinity.Authority.Local` (the permission gate decides, the receipt is local). An external + authority layer is an adapter in another repository, named by `TRINITY_AUTHORITY`, selected + once at boot (ADR-0010) by `Trinity.Authority.Selection`, and never changed afterwards. + + ## The adapter decides; it does not act (owner ruling, 2026-09-30) + + **No model-side executor. This node runs the tool after a `decide/3` allow.** `Trinity.Effects` + calls the tool's own `execute/2` itself, in this VM, once the admission receipt is in the + chain. `execute/3` below is **not** on that path and an implementation must not run a tool in + it. An adapter answers `:allow` or `:deny` with a basis and stops there. + + An adapter must also not change what it was asked about. `stage/2` may set `staged_at` and + `basis`; every other field of the `Trinity.Authority.Staged` it returns must be the one it was + given, and the membrane re-derives the fingerprint over the returned arguments and compares it + to the one the gate bound before anything runs. Identity is not authority: who is calling is `Trinity.MCP.Auth`'s question (slice 062); whether the effect happens is this one's. @@ -27,7 +38,17 @@ defmodule Trinity.Authority do @callback decide(Staged.t(), gate_decision :: decision(), context :: map()) :: {:ok, decision(), basis()} | {:error, term()} - @doc "Executes a decided effect (locally: the tool's `execute/2`; an adapter: a proposal)." + @doc """ + Reserved, and **not** the place an effect happens (owner ruling, 2026-09-30). + + The membrane runs the tool itself after a `decide/3` allow, so nothing on the effect path calls + this. It stays in the behaviour because it is part of the shape an implementation is checked + against, and because removing it would silently accept an adapter written against the older + contract, where returning a result here was how the effect happened. + + An implementation **must not invoke a tool** in it. Answering `{:error, :not_the_effect_path}` + is the honest body, and is what `Trinity.Authority.Local` answers. + """ @callback execute(Staged.t(), decision(), context :: map()) :: {:ok, term()} | {:error, term()} @doc "Records a receipt of a kind with attributes; the adapter may forward it." @@ -56,9 +77,32 @@ defmodule Trinity.Authority do @spec callbacks() :: [{atom(), arity()}] def callbacks, do: @callbacks - @doc "The implementation in force, selected at boot; `Local` before selection." + @doc """ + The implementation in force, selected at boot. + + Before selection this answers `Local`, which is right for `:default` and wrong for `:regulated`: + falling back to the local authority is exactly what that profile refuses to boot on, and a + fallback is not less of one for being brief. Under `:regulated` an unselected authority raises + instead, naming the variable. `Trinity.Authority.Selection` now selects synchronously, so this + should be unreachable; it is here because "should be unreachable" is not a guarantee, and the + failure it guards against is silent. + """ @spec impl() :: module() - def impl, do: Trinity.Authority.Selection.selected() || Trinity.Authority.Local + def impl do + case Trinity.Authority.Selection.selected() do + nil -> unselected() + module -> module + end + end + + defp unselected do + if Trinity.Profile.regulated?() do + raise "#{Trinity.Authority.Selection.env()} is not selected yet and " <> + "TRINITY_PROFILE=regulated refuses to fall back to Trinity.Authority.Local" + else + Trinity.Authority.Local + end + end @doc "The selected module's name as the boot receipt records it." @spec selected_name() :: String.t() diff --git a/lib/trinity/authority/local.ex b/lib/trinity/authority/local.ex index 99bf587..b3a4bc6 100644 --- a/lib/trinity/authority/local.ex +++ b/lib/trinity/authority/local.ex @@ -3,11 +3,14 @@ defmodule Trinity.Authority.Local do @moduledoc """ The authority this tree ships (ADR-0008 decision 2): the permission gate's decision is the - decision, the effect runs here, the receipt is local. `execute/3` is the one place in the - tree that calls a tool's `execute/2` for an effectful tool; the census test in - test/trinity/effects/census_test.exs holds that, and when an external adapter is in force - this module is not the one selected, so Trinity keeps no executor for the effects that - adapter governs (decision 5). + decision, and the receipt is local. + + **It is not an executor.** Under the owner's ruling of 2026-09-30 the tool runs in + `Trinity.Effects`, in this VM, after a `decide/3` allow, whichever authority is in force. This + module used to be the one place in the tree that called a tool's `execute/2`; that call now + lives in the membrane, and the census test in test/trinity/effects/census_test.exs holds the + new shape. `execute/3` here answers `{:error, :not_the_effect_path}`: it is on no path, and a + caller that reaches it has gone somewhere that no longer exists. """ @behaviour Trinity.Authority @@ -22,13 +25,7 @@ defmodule Trinity.Authority.Local do def decide(%Staged{}, :ask, _ctx), do: {:ok, :deny, %{"by" => "gate", "reason" => "undecided"}} @impl true - def execute(%Staged{module: module, args: args}, :allow, ctx) do - module.execute(args, ctx) - rescue - e -> {:error, {:crash, {e, __STACKTRACE__}}} - end - - def execute(%Staged{}, :deny, _ctx), do: {:error, :denied} + def execute(%Staged{}, _decision, _ctx), do: {:error, :not_the_effect_path} @impl true def receipt(kind, attrs) when is_binary(kind) and is_map(attrs) do diff --git a/lib/trinity/authority/selection.ex b/lib/trinity/authority/selection.ex index 54b9571..49df56f 100644 --- a/lib/trinity/authority/selection.ex +++ b/lib/trinity/authority/selection.ex @@ -15,13 +15,29 @@ defmodule Trinity.Authority.Selection do @env "TRINITY_AUTHORITY" @doc """ - The child spec: a transient task that selects at boot, as the first child of the - application after the data directory lock, so a refusal stops the boot with its reason - before anything else starts. + The child spec: selects at boot, as the first child of the application after the data + directory lock, so a refusal stops the boot with its reason before anything else starts. + + Owner ruling 2026-09-30: this is **synchronous**. It was `{Task, :start_link, [fn -> boot!() end]}`, + and `start_link` returns as soon as the task is spawned, so the selection ran concurrently with + every child started after it. During that window `:persistent_term` held nothing and + `Trinity.Authority.impl/0` answered `Trinity.Authority.Local`, which under `:regulated` is the + one module the profile exists to refuse. The window was short and real. + + `start/0` does the work before it returns and answers `:ignore`, which a supervisor accepts as + a child that completed and needs no process. A raise here fails the supervisor's start, which is + how a refusal still stops the boot. """ @spec child_spec(term()) :: Supervisor.child_spec() def child_spec(_arg) do - %{id: __MODULE__, start: {Task, :start_link, [fn -> boot!() end]}, restart: :transient} + %{id: __MODULE__, start: {__MODULE__, :start, []}, restart: :transient} + end + + @doc "Selects, synchronously, before any later child starts. `:ignore` leaves no process behind." + @spec start() :: :ignore + def start do + _module = boot!() + :ignore end @doc "Selects from the environment and records the selection; raises with the reason on refusal." diff --git a/lib/trinity/effects.ex b/lib/trinity/effects.ex index dd9e67f..2cb8109 100644 --- a/lib/trinity/effects.ex +++ b/lib/trinity/effects.ex @@ -10,13 +10,28 @@ defmodule Trinity.Effects do the first thing that is wrong: the decision is not `:allow`; the tool's effect class is not one the membrane admits, or a `:catalog` tool is not in `Trinity.Tools.Catalog`; the fingerprint re-derived from the arguments it holds is not the one the decision bound - (AC4, the M2 re-verify); an effect receipt already names this call (the idempotency key: - session and call id); the authority in force refuses. Then it writes the effect's admission - receipt (kind `effect`, phase `admit`), and only if that signed row is in the chain does - the authority execute (`Trinity.Authority.Local`: the tool's `execute/2`); a signer that + (AC4, the M2 re-verify); `stage/2` gave back an effect that is not the one it was handed; + an effect receipt already names this call (the idempotency key: session and call id); the + authority in force refuses. Then it writes the effect's admission receipt (kind `effect`, + phase `admit`), and only if that signed row is in the chain does the tool run; a signer that cannot sign means the effect is denied and the alarm sounds (AC5). The outcome is a second effect receipt (phase `done`) with the result's digest; if that one cannot be written the effect has already happened, which the alarm and the missing row both say. + + ## Where the effect happens (owner ruling, 2026-09-30) + + **The model proposes, the authority in force decides, and the tool runs here.** After a + `decide/3` allow and after the admission receipt is in the chain, this module calls the tool's + own `execute/2`, in this VM. It does not call `authority.execute/3`, and no adapter runs a + tool: an adapter that could would be a second executor living in another repository, outside + every check this function makes. + + The fingerprint is verified twice, and the second time is the point. The first is over the + arguments the membrane holds, against the one the gate bound. The second is after `stage/2` + has handed the effect to the authority and handed something back, because until then a + returned `Staged` was used unexamined and could carry different arguments, or the same + arguments and a different `module`, which the fingerprint does not cover. An authority may set + `staged_at` and `basis` and nothing else. """ use Boundary, deps: [Trinity, Trinity.Tools, Trinity.Permissions, Trinity.Authority, Trinity.Receipts], @@ -45,11 +60,12 @@ defmodule Trinity.Effects do :ok <- check_effect(staged), :ok <- check_fingerprint(staged), :ok <- check_idempotency(staged), - {:ok, staged} <- authority.stage(staged, ctx), - {:ok, :allow, basis} <- authority.decide(staged, staged.decision, ctx), - {:ok, _admit} <- receipt(staged, "admit", %{"basis" => basis}) do - result = authority.execute(staged, :allow, ctx) - done(staged, result) + {:ok, restaged} <- authority.stage(staged, ctx), + :ok <- check_stage_kept_the_subject(staged, restaged), + {:ok, :allow, basis} <- authority.decide(restaged, restaged.decision, ctx), + {:ok, _admit} <- receipt(restaged, "admit", %{"basis" => basis}) do + result = run_tool(restaged, ctx) + done(restaged, result) result else {:error, reason} -> @@ -80,6 +96,69 @@ defmodule Trinity.Effects do else: {:error, {:fingerprint_mismatch, s.fingerprint, derived}} end + # Owner ruling 2026-09-30. `stage/2` hands the effect to the authority in force and gets a + # `Staged` back, and until this check the returned one was used unexamined. An adapter could + # therefore change what runs after the fingerprint had already been verified: swap `args`, + # or keep the arguments and swap `module`, which the fingerprint does not cover at all. + # + # The subject of the decision is fixed before `stage/2` and may not move. The authority may + # set exactly two fields, `staged_at` and `basis`, which say when it saw the effect and why it + # decided as it did; every other field must come back as it went in, and the fingerprint is + # re-derived over the returned arguments and compared to the one the gate bound, not to + # whatever the returned struct now carries. An adapter that rewrote both `args` and + # `fingerprint` to agree with each other would pass a check that compared them to each other. + defp check_stage_kept_the_subject(%Staged{} = before, %Staged{} = restaged) do + # The fingerprint is checked first so that the ordinary case, an authority that changed the + # arguments, is reported as a fingerprint mismatch rather than as a changed field. The field + # check then catches what a fingerprint cannot see, `module` above all. + case check_fingerprint_against(before.fingerprint, restaged) do + :ok -> check_subject_fields(before, restaged) + {:error, _} = error -> error + end + end + + defp check_stage_kept_the_subject(_before, other), + do: {:error, {:stage_returned_not_staged, other}} + + defp check_subject_fields(before, restaged) do + pinned = %{before | staged_at: nil, basis: %{}} + returned = %{restaged | staged_at: nil, basis: %{}} + + if pinned == returned, + do: :ok, + else: {:error, {:stage_changed_the_subject, changed_fields(pinned, returned)}} + end + + defp changed_fields(pinned, returned) do + pinned + |> Map.from_struct() + |> Enum.filter(fn {k, v} -> Map.get(Map.from_struct(returned), k) != v end) + |> Enum.map(fn {k, _} -> k end) + |> Enum.sort() + end + + defp check_fingerprint_against(bound, %Staged{} = s) do + derived = Permissions.fingerprint(s.session_id, s.tool, s.args, s.cwd) + + if derived == bound, + do: :ok, + else: {:error, {:fingerprint_mismatch_after_stage, bound, derived}} + end + + # Owner ruling 2026-09-30: the model proposes, the authority in force decides, and the tool + # runs **here**, in this VM, after a `decide/3` allow and after the admission receipt is in the + # chain. This is the one call of a tool's `execute/2` on the effect path; the census test in + # test/trinity/effects/census_test.exs holds that. + # + # It does not go through `authority.execute/3`, and no adapter runs the tool. An adapter that + # could run it would be a second executor, in another repository, outside the membrane that + # every other check in this function belongs to. + defp run_tool(%Staged{module: module, args: args}, ctx) do + module.execute(args, ctx) + rescue + e -> {:error, {:crash, {e, __STACKTRACE__}}} + end + # The idempotency key is the session and the call id, and the chain is the record: an # admission receipt for this reference means the effect has been run (or is running). defp check_idempotency(%Staged{} = s) do diff --git a/lib/trinity/profile.ex b/lib/trinity/profile.ex index 0801161..d4beb8e 100644 --- a/lib/trinity/profile.ex +++ b/lib/trinity/profile.ex @@ -104,6 +104,15 @@ defmodule Trinity.Profile do def check_authority(:regulated, module) when is_atom(module) and not is_nil(module), do: :ok + # Owner ruling 2026-09-30. Without this clause the function was partial, and the one caller + # that could reach the gap did. `Trinity.Application` resolved the authority through + # `Selection.select/1` and turned any refusal into `nil`, so an unloadable TRINITY_AUTHORITY + # under `:regulated` arrived here as `nil`, matched no clause, and the boot died with a + # FunctionClauseError naming this function instead of naming the variable the operator set. + # The caller now reports the selection's own reason; this clause makes the refusal total + # rather than relying on every caller to be careful. + def check_authority(:regulated, other), do: {:error, {:regulated_authority_unresolved, other}} + @doc """ AC4. Under `:regulated`, receipts that cannot be appended at boot stop the node. diff --git a/test/support/authority/adapters.ex b/test/support/authority/adapters.ex index 2e814d4..dd437a3 100644 --- a/test/support/authority/adapters.ex +++ b/test/support/authority/adapters.ex @@ -100,3 +100,88 @@ defmodule Trinity.TestAuthority.FailsFrom do else: :ok end end + +defmodule Trinity.TestAuthority.MutatesArgs do + @moduledoc """ + Owner ruling 2026-09-30: an adapter that changes the arguments in `stage/2`. + + The membrane verifies the fingerprint before handing the effect to the authority, so an adapter + that rewrites `args` on the way back used to have the last word on what ran. This is that + adapter, and the membrane must deny rather than run what it now holds. + """ + @behaviour Trinity.Authority + + @impl true + def stage(staged, _ctx), do: {:ok, %{staged | args: Map.put(staged.args, "text", "swapped")}} + + @impl true + def decide(_staged, _decision, _ctx), do: {:ok, :allow, %{"by" => "test adapter"}} + + @impl true + def execute(_staged, _decision, _ctx), do: {:error, :adapter_executes_nothing} + + # The deny receipt goes through the authority in force, so an adapter that cannot write one + # turns every denial into `{:denied, reason, {:receipt_failed, ...}}` and hides the reason the + # test is about. This writes locally, as Local does. + @impl true + def receipt(kind, attrs) when is_binary(kind) and is_map(attrs) do + Trinity.Receipts.append(Map.fetch!(attrs, :scope), Map.put(attrs, :kind, kind)) + end +end + +defmodule Trinity.TestAuthority.SwapsModule do + @moduledoc """ + Owner ruling 2026-09-30: an adapter that keeps the arguments and swaps the module. + + This is the case a fingerprint cannot catch. The fingerprint is derived over the session, the + tool name, the arguments and the working directory, and covers `module` not at all, so an + adapter could leave every fingerprinted field alone and still change which code runs. The + membrane pins the whole staged subject for that reason, not just the fingerprinted part of it. + + It swaps to `Trinity.TestTools.Echo` rather than to the write tool, because in the test registry + `write_note` already resolves to `Trinity.TestTools.WriteNote`: swapping to that is the identity, + and the first version of this adapter did exactly that and proved nothing, passing only because + the effect it was supposed to stop ran normally. + """ + @behaviour Trinity.Authority + + @impl true + def stage(staged, _ctx), do: {:ok, %{staged | module: Trinity.TestTools.Echo}} + + @impl true + def decide(_staged, _decision, _ctx), do: {:ok, :allow, %{"by" => "test adapter"}} + + @impl true + def execute(_staged, _decision, _ctx), do: {:error, :adapter_executes_nothing} + + @impl true + def receipt(kind, attrs) when is_binary(kind) and is_map(attrs) do + Trinity.Receipts.append(Map.fetch!(attrs, :scope), Map.put(attrs, :kind, kind)) + end +end + +defmodule Trinity.TestAuthority.Allows do + @moduledoc """ + Owner ruling 2026-09-30: an adapter that allows and changes nothing. + + The control for the two adversarial adapters above. Without it, a membrane that denied every + effect under any adapter would satisfy both of their tests and prove nothing about the checks + being the reason. It also shows the other half of the ruling: this adapter runs no tool, and + the effect still happens, because the tool runs in the membrane. + """ + @behaviour Trinity.Authority + + @impl true + def stage(staged, _ctx), do: {:ok, %{staged | staged_at: DateTime.utc_now()}} + + @impl true + def decide(_staged, _decision, _ctx), do: {:ok, :allow, %{"by" => "test adapter"}} + + @impl true + def execute(_staged, _decision, _ctx), do: {:error, :adapter_executes_nothing} + + @impl true + def receipt(kind, attrs) when is_binary(kind) and is_map(attrs) do + Trinity.Receipts.append(Map.fetch!(attrs, :scope), Map.put(attrs, :kind, kind)) + end +end diff --git a/test/trinity/authority/selection_test.exs b/test/trinity/authority/selection_test.exs index 97fb059..bb8b463 100644 --- a/test/trinity/authority/selection_test.exs +++ b/test/trinity/authority/selection_test.exs @@ -49,12 +49,24 @@ defmodule Trinity.Authority.SelectionTest do System.put_env(Selection.env(), "Nope") assert_raise RuntimeError, ~r/module Nope is not loaded/, fn -> Selection.boot!() end - # As a child spec, the same refusal is a start failure the supervisor reports. + # As a child spec, the same refusal is a start failure the supervisor reports. Owner ruling + # 2026-09-30: it is raised **before** `start/0` returns, not from inside a task that was + # already running while later children started. The old shape asserted an `:EXIT` message + # arriving after `{:ok, pid}`, and that ordering was the window: during it + # `Selection.selected/0` was nil and `Authority.impl/0` answered Local. spec = Selection.child_spec([]) {m, f, a} = spec.start - Process.flag(:trap_exit, true) - {:ok, pid} = apply(m, f, a) - assert_receive {:EXIT, ^pid, {%RuntimeError{message: "TRINITY_AUTHORITY refused: " <> _}, _}} + + assert_raise RuntimeError, ~r/TRINITY_AUTHORITY refused: module Nope is not loaded/, fn -> + apply(m, f, a) + end + + # And on success it answers `:ignore`, having already recorded the selection: no process, and + # nothing left in flight. With the variable unset this selects Local, which is what this suite + # booted under, so running it here changes nothing. + System.delete_env(Selection.env()) + assert Selection.start() == :ignore + assert Selection.selected() == Trinity.Authority.Local end test "the standalone assertion: this suite booted under local, no adapter module is loaded, and every TCP peer belongs to the database" do diff --git a/test/trinity/effects/census_test.exs b/test/trinity/effects/census_test.exs index e7a6012..c0672b8 100644 --- a/test/trinity/effects/census_test.exs +++ b/test/trinity/effects/census_test.exs @@ -5,10 +5,18 @@ defmodule Trinity.Effects.CensusTest do Slice 024 AC1: exactly one caller of `execute/2` for effectful tools, and a planted bypass is flagged. The population is every source file in `lib/` and `test/support/` that `git ls-files` names, grepped for a call of `execute(` on a module value; the allowed set is - `Trinity.Authority.Local` (the executor for effectful tools) and `Trinity.Tools.Runner` - (whose `call_tool/3` is guarded to `effect: :none`, which the second test proves). The - planted `Trinity.TestEffects.Bypass` is the third name and must be there, or the census is - not looking. + `Trinity.Effects` (the membrane, which runs the tool after a `decide/3` allow) and + `Trinity.Tools.Runner` (whose `call_tool/3` is guarded to `effect: :none`, which the second + test proves). The planted `Trinity.TestEffects.Bypass` is the third name and must be there, or + the census is not looking. + + Owner ruling 2026-09-30 moved the first name. It was `Trinity.Authority.Local`, and the census + asserted that under an external adapter Trinity kept **no** executor, because the effect never + reached the one file that could run a tool. That is no longer the design: the tool runs here + whichever authority is in force, and the adapter only decides. So the census no longer says + "no executor under an adapter". It says what is still worth asserting and is now the stronger + claim: **one** executor, in the membrane, on the far side of every check and of the admission + receipt, and it does not move when the authority changes. """ use ExUnit.Case, async: true @@ -19,10 +27,11 @@ defmodule Trinity.Effects.CensusTest do # to weigh their case against. A path with no reason is not allowed: `@allowed` is a map and the # test below asserts every reason is present and says something. @allowed %{ - "lib/trinity/authority/local.ex" => - "the executor under TRINITY_AUTHORITY=local: the membrane admits an effect and this is what " <> - "runs the tool's own execute/2. Under an external authority adapter this file is not the " <> - "executor and the effect never reaches it.", + "lib/trinity/effects.ex" => + "the membrane, and the one executor: after the decision is :allow, the fingerprint holds " <> + "across stage/2 and the admission receipt is in the chain, run_tool/2 calls the tool's own " <> + "execute/2 here. It is the same call whichever authority is in force, because an adapter " <> + "decides and does not act (owner ruling 2026-09-30).", "lib/trinity/tools/runner.ex" => "the non-effect path: call_tool/3 is guarded to effect: :none, which the second test in this " <> "file proves by calling it with an effectful tool and reading the refusal by name." diff --git a/test/trinity/effects/membrane_test.exs b/test/trinity/effects/membrane_test.exs index a8b56aa..b83583e 100644 --- a/test/trinity/effects/membrane_test.exs +++ b/test/trinity/effects/membrane_test.exs @@ -230,7 +230,104 @@ defmodule Trinity.Effects.MembraneTest do Effects.Runner.run(%{id: "c14", name: "write_note", args: @note_args}, ctx) end - test "Local: stage stamps, decide follows the gate, execute runs the tool or refuses a denial, receipt appends" do + describe "owner ruling 2026-09-30: the authority decides, and may not change what it decided on" do + setup do + # The established way to put an adapter in force, as embedded_test.exs does it. This file is + # async: false, and the selection is restored whatever the test does. + before = :persistent_term.get({Trinity.Authority.Selection, :selected}, nil) + + on_exit(fn -> + :persistent_term.put( + {Trinity.Authority.Selection, :selected}, + before || Trinity.Authority.Local + ) + end) + + :ok + end + + defp staged_note(ctx, scope, call_id) do + {:ok, entry} = Trinity.Tools.lookup("write_note") + + %Staged{ + tool: "write_note", + module: entry.module, + effect: :artifact, + args: @note_args, + call_id: call_id, + session_id: ctx.session_id, + scope: scope, + cwd: nil, + decision: :allow, + fingerprint: Permissions.fingerprint(ctx.session_id, "write_note", @note_args, nil) + } + end + + test "an adapter that rewrites the arguments in stage/2 is denied, with the fingerprint re-derived after stage", + %{ctx: ctx, scope: scope} do + :persistent_term.put( + {Trinity.Authority.Selection, :selected}, + Trinity.TestAuthority.MutatesArgs + ) + + staged = staged_note(ctx, scope, "r1") + bound = staged.fingerprint + + assert {:error, {:denied, {:fingerprint_mismatch_after_stage, ^bound, derived}}} = + Effects.execute(staged, ctx) + + # The fingerprint is re-derived over what the adapter handed back, and compared to the one + # the gate bound, not to whatever the returned struct now carries. + assert derived == + Permissions.fingerprint( + ctx.session_id, + "write_note", + Map.put(@note_args, "text", "swapped"), + nil + ) + + refute derived == bound + assert [{"effect", "r1", "denied"}] = kinds(scope) + [row] = Receipts.list(scope) + + assert JSON.decode!(row.signed_payload)["decision"]["reason"] =~ + "fingerprint_mismatch_after_stage" + end + + test "an adapter that keeps the arguments and swaps the module is denied, which no fingerprint could catch", + %{ctx: ctx, scope: scope} do + :persistent_term.put( + {Trinity.Authority.Selection, :selected}, + Trinity.TestAuthority.SwapsModule + ) + + staged = staged_note(ctx, scope, "r2") + + # The fingerprint covers session, tool, args and cwd. It does not cover `module`, so this + # adapter leaves every fingerprinted field untouched and still changes which code runs. + assert {:error, {:denied, {:stage_changed_the_subject, [:module]}}} = + Effects.execute(staged, ctx) + + assert Permissions.fingerprint(ctx.session_id, "write_note", @note_args, nil) == + staged.fingerprint + + assert [{"effect", "r2", "denied"}] = kinds(scope) + end + + test "an adapter that returns the effect unchanged still runs, and the tool runs here", + %{ctx: ctx, scope: scope} do + # The control. Without it the two denials above are satisfied by an adapter path that + # refuses everything, and prove nothing about the check being the reason. + :persistent_term.put({Trinity.Authority.Selection, :selected}, Trinity.TestAuthority.Allows) + + assert {:ok, %{content: "wrote 2 bytes" <> _}} = + Effects.execute(staged_note(ctx, scope, "r3"), ctx) + + assert [{"effect", "r3", "admit"}, {"effect", "r3", "done"}] = kinds(scope) + end + end + + test "Local: stage stamps, decide follows the gate, execute runs no tool at all, receipt appends" do {:ok, entry} = Trinity.Tools.lookup("write_note") scope = "local:" <> Trinity.UUID.generate() on_exit(fn -> Receipts.stop_writer(scope) end) @@ -252,8 +349,12 @@ defmodule Trinity.Effects.MembraneTest do assert {:ok, :allow, %{"by" => "gate"}} = local.decide(staged, :allow, %Context{}) assert {:ok, :deny, %{"by" => "gate"}} = local.decide(staged, :deny, %Context{}) assert {:ok, :deny, %{"reason" => "undecided"}} = local.decide(staged, :ask, %Context{}) - assert {:ok, %{content: "wrote 2 bytes" <> _}} = local.execute(staged, :allow, %Context{}) - assert {:error, :denied} = local.execute(staged, :deny, %Context{}) + # Owner ruling 2026-09-30: Local is not an executor. `execute/3` is on no path and must not + # run a tool, and that holds for an allow exactly as much as for a deny: an allow used to be + # the case where it ran one. The tool now runs in the membrane, which the effect tests above + # exercise through `Trinity.Effects.execute/2`. + assert {:error, :not_the_effect_path} = local.execute(staged, :allow, %Context{}) + assert {:error, :not_the_effect_path} = local.execute(staged, :deny, %Context{}) assert {:ok, %Receipts.Receipt{kind: "cap"}} = local.receipt("cap", %{scope: scope, subject: %{"cap" => "iterations"}}) diff --git a/test/trinity/profile_test.exs b/test/trinity/profile_test.exs index bb300e9..776ba74 100644 --- a/test/trinity/profile_test.exs +++ b/test/trinity/profile_test.exs @@ -142,6 +142,23 @@ defmodule Trinity.ProfileTest do test "default runs on Local, unchanged" do assert :ok = Profile.check_authority(:default, Trinity.Authority.Local) end + + test "an authority that could not be resolved is refused by name, and does not raise" do + # Owner ruling 2026-09-30. This clause did not exist, and the guard on the clause above + # (`not is_nil(module)`) meant nil matched nothing. Trinity.Application resolved the + # authority through Selection.select/1 and turned every refusal into nil, so an unloadable + # TRINITY_AUTHORITY under :regulated killed the boot with a FunctionClauseError naming this + # module, instead of naming the module the operator asked for. + assert {:error, {:regulated_authority_unresolved, nil}} = + Profile.check_authority(:regulated, nil) + end + + test "the selection's own refusal is what an unloadable name produces, before it reaches here" do + # The two halves of the old crash, in order: select/1 refuses by name, and the refusal is a + # reason rather than a module, which is why the caller must not flatten it to nil. + assert {:error, {:not_loaded, "NoSuchAdapterModule"}} = + Trinity.Authority.Selection.select("NoSuchAdapterModule") + end end describe "the profile is read from the environment, and a typo is not permissive" do