diff --git a/docs/07-security-model.md b/docs/07-security-model.md index 29ced1d..e1a9f4d 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -377,6 +377,33 @@ conversation state is a gate an attacker can argue with, and the argument does n a person: it has to move one boolean. Keeping the decision blind to the conversation is what makes prompt injection a problem about what is *proposed* rather than about what is *authorized*. +## Context tightens, and cannot loosen (Slice 028, as built) + +`Trinity.Permissions.Policy.State` is a one-way modifier applied **after** the gate has decided and +never instead of it. A state raises what a call requires. No state changes the tool's tier, which +stays a function of the tool's name, and no state can turn a refusal into a permission. + +The constraint is in the types rather than in a review. `requires/1` returns `:ask` or `:deny` and +has no clause that can return `:allow`, so no state is ever a licence; `tighten/2` takes the +strictest of the gate's decision and every active state's requirement, so adding a state can only +move the result one way. The test is exhaustive over every decision, every subset of the state set +and every ordering, because a modifier that happened to tighten for the cases someone thought of +would pass an example test. + +**Why one-way is the design and not a preference.** Every state is derived from something an +attacker may be able to influence: whether the turn has read untrusted content, whether a budget is +exhausted, whether anyone is at the machine. If any of those could widen authority, the useful move +would be to arrange the state rather than to argue with the gate, and arranging state is quieter. + +This is the second axis of attenuation in the tree. Slice 070 caps by **surface**: a messaging +channel may approve less than the desktop. Slice 028 caps by **context**. Both are applied after the +gate's own decision, and both name what tightened in the basis the receipt records. + +The state set is closed, and an atom outside it is a refusal rather than a no-op. Of the four, +`:over_budget` is measured today, from the slice 090 cost ledger in `Trinity.Tools.Runner`. +`:receipts_degraded`, `:untrusted_context` and `:unattended` are defined with no producer yet and are +named here rather than omitted, because the gap being stated is what lets someone close it. + ## Data at rest - SQLite file under the OS data dir with 0600 perms. Optional at-rest encryption is a later slice (SQLCipher via exqlite build flag), noted rather than planned. diff --git a/lib/trinity/permissions.ex b/lib/trinity/permissions.ex index adc19f0..894c774 100644 --- a/lib/trinity/permissions.ex +++ b/lib/trinity/permissions.ex @@ -83,17 +83,29 @@ defmodule Trinity.Permissions do decision end - @doc "The decision and the layer that made it (slice 030); `\"policy\"` for a policy that answers a bare decision." + @doc """ + The decision and the layer that made it (slice 030); `"policy"` for a policy that answers a bare + decision. + + Slice 028: `state:` is a list of `Trinity.Permissions.Policy.State` kinds the caller has measured. + They are applied **after** the policy has decided and never instead of it, and they can only make + the result stricter. The basis names the ones that actually moved the decision, so a receipt can + say why a call needed more than its tier implies rather than that some state was present. + """ @spec decide_with_basis(String.t() | nil, String.t(), map(), keyword()) :: {decision(), String.t()} def decide_with_basis(session_id, tool, args, opts \\ []) do - case impl().decide(session_id, tool, args, opts) do - {decision, basis} when decision in [:allow, :deny, :ask] and is_binary(basis) -> - {decision, basis} + {decision, basis} = + case impl().decide(session_id, tool, args, opts) do + {decision, basis} when decision in [:allow, :deny, :ask] and is_binary(basis) -> + {decision, basis} - decision when decision in [:allow, :deny, :ask] -> - {decision, "policy"} - end + decision when decision in [:allow, :deny, :ask] -> + {decision, "policy"} + end + + {tightened, applied} = Policy.State.tighten(decision, Keyword.get(opts, :state, [])) + {tightened, Policy.State.basis(basis, applied)} end @doc "The fingerprint of a call as this session would bind it." diff --git a/lib/trinity/permissions/policy/state.ex b/lib/trinity/permissions/policy/state.ex new file mode 100644 index 0000000..d49bc71 --- /dev/null +++ b/lib/trinity/permissions/policy/state.ex @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.Policy.State do + @moduledoc """ + Context can make the gate stricter. It can never make it looser (slice 028). + + From the connectome research report's A7: hunger, arousal and the neuromodulators act as global + gain on an insect's circuits. They change the threshold at which a behaviour fires; they do not + rewire the circuit. The wiring is fixed and the gain is state. + + Trinity's wiring is the tier, which is a function of the tool's **name** alone, and this module is + the gain. A state raises what a call requires. No state changes what the tool *is*, and no state + can turn a refusal into a permission. + + ## Why the one-way constraint is the whole design + + A context input that can loosen is an input worth attacking. Every state this module names is + derived from something an attacker may be able to influence: whether the turn has read untrusted + content, whether a budget is exhausted, whether anyone is at the machine. If any of those could + *widen* authority, the useful move for an attacker would be to arrange the state rather than to + argue with the gate, and arranging state is quieter. + + So the constraint is in the types rather than in a review. `requires/1` returns `:ask` or `:deny` and + has no clause that can return `:allow`, which means no state is ever a licence. `tighten/2` takes the + strictest of the gate's own decision and every active state's floor, so adding a state to the list + can only move the result one way. The property test is the criterion: an example test would pass on + a modifier that happened to tighten for the cases someone thought of. + + ## Which states have a producer today + + `:over_budget` is measured in `Trinity.Tools.Runner` from the slice 090 cost ledger. The other + three are defined and have no producer yet: `:receipts_degraded` would read + `Trinity.Receipts.Alarm`, which is a boundary the runner does not depend on, and + `:untrusted_context` and `:unattended` need a turn-scoped signal the session does not carry. They + are named here rather than omitted because the set being closed is what makes an unknown atom a + refusal, and because a gap stated is a gap someone can close. + + ## This module measures nothing + + It is pure, and the caller supplies the active states. That is deliberate twice over: a pure + function is the thing you can prove monotone, and the signals live in boundaries this one does not + reach. A caller that can see the cost ledger or the receipt alarm passes what it sees. + + ## Naming + + `SLICE.md` calls this `Trinity.Policy.State` at `lib/trinity/policy/state.ex`. The tree's policy + seam is `Trinity.Permissions.Policy`, so it lives beside it; a second top-level `Trinity.Policy` + would be a different thing with almost the same name. + """ + # No `use Boundary` here: a module nested under `Trinity.Permissions` already belongs to that + # boundary, and `classify_to:` is only for Mix tasks and protocol implementations. + alias Trinity.Permissions + + @typedoc """ + The closed set of states. Each is derived from something the tree already measures: + + * `:untrusted_context` - this turn has consumed content from outside the machine (slice 060's + tool results, slice 070's channels, a fetched page). The content is marked untrusted at the + boundary already; this is that mark reaching the gate. + * `:over_budget` - the cost ledger says a budget is exceeded (slice 090). + * `:receipts_degraded` - the receipt chain's alarm is set, so an effect may not be recordable + (slice 024). An effect that cannot be receipted is an effect nobody can reconstruct. + * `:unattended` - nobody is at the machine to answer, so a call that would have asked should not + silently proceed. + """ + @type kind :: :untrusted_context | :over_budget | :receipts_degraded | :unattended + + @kinds [:untrusted_context, :over_budget, :receipts_degraded, :unattended] + + # Strictness order. `apply/2` takes the maximum, so the order is the whole mechanism. + @strictness %{allow: 0, ask: 1, deny: 2} + + @doc "Every state kind. The set is closed: an atom outside it is a refusal, not a state." + @spec kinds() :: [kind()] + def kinds, do: @kinds + + @doc """ + The weakest decision a state permits. + + There is no clause returning `:allow`, and that absence is the guarantee: a state can require a + decision, so it cannot grant one. + + Named `requires/1` rather than `floor/1`: `Kernel.floor/1` exists, and `tighten/2` beside it says + at every call site which direction this seam runs in. + """ + @spec requires(kind()) :: :ask | :deny + def requires(:untrusted_context), do: :ask + def requires(:over_budget), do: :deny + def requires(:receipts_degraded), do: :deny + def requires(:unattended), do: :ask + + def requires(other) do + raise ArgumentError, + "#{inspect(other)} is not a state. The set is closed (#{inspect(@kinds)}) so that a " <> + "state cannot be introduced at a call site without a requirement anyone can read. Add it " <> + "to Trinity.Permissions.Policy.State with what it requires, or do not pass it." + end + + @doc """ + The gate's decision, tightened by the active states. + + Returns the decision and the states that actually changed it, so the caller can record *why* a + call needed more than its tier implies rather than recording that some state was present. + """ + @spec tighten(Permissions.decision(), [kind()]) :: {Permissions.decision(), [kind()]} + def tighten(decision, states) when decision in [:allow, :deny, :ask] and is_list(states) do + Enum.reduce(states, {decision, []}, fn state, {current, applied} -> + case strictest(current, requires(state)) do + ^current -> {current, applied} + tighter -> {tighter, applied ++ [state]} + end + end) + end + + @doc "A basis string naming the states that tightened a decision, for the receipt and the approval." + @spec basis(String.t(), [kind()]) :: String.t() + def basis(policy_basis, []), do: policy_basis + def basis(policy_basis, applied), do: policy_basis <> "+state:" <> Enum.join(applied, ",") + + defp strictest(a, b), do: if(@strictness[a] >= @strictness[b], do: a, else: b) +end diff --git a/lib/trinity/tools/runner.ex b/lib/trinity/tools/runner.ex index 448120e..a924e7a 100644 --- a/lib/trinity/tools/runner.ex +++ b/lib/trinity/tools/runner.ex @@ -129,7 +129,8 @@ defmodule Trinity.Tools.Runner do case Permissions.decide_with_basis(ctx.session_id, name, args, persona: ctx.persona, cwd: ctx.cwd, - escalate: escalation(entry, args, ctx) + escalate: escalation(entry, args, ctx), + state: state(ctx) ) do {:allow, basis} -> {:allow, fp, basis} {:deny, basis} -> {:deny, fp, basis} @@ -137,6 +138,23 @@ defmodule Trinity.Tools.Runner do end end + # Slice 028: the context states this call is made under. They can only make the gate stricter + # (`Trinity.Permissions.Policy.State`), so measuring one wrongly costs an unnecessary approval + # and never an unearned one, which is why measuring here rather than in the policy is safe. + # + # Only `:over_budget` has a producer today. `:receipts_degraded` would be read from + # `Trinity.Receipts.Alarm`, which is a boundary this one does not depend on; `:untrusted_context` + # and `:unattended` need a turn-scoped signal the session does not yet carry. The set is closed + # and the gaps are named rather than left as an empty list nobody questions. + defp state(ctx) do + persona_id = ctx.persona && ctx.persona.id + + case Trinity.Telemetry.Costs.over(ctx.session_id, persona_id) do + [] -> [] + _over -> [:over_budget] + end + end + # An :ask with a session to ask becomes a pending approval (a row, then a broadcast) the # Session waits on; without a session there is nobody to ask, and the call is refused. defp ask(%Context{session_id: nil}, _entry, _args), do: :approval_required diff --git a/test/trinity/permissions/policy/state_budget_test.exs b/test/trinity/permissions/policy/state_budget_test.exs new file mode 100644 index 0000000..4dbd6cf --- /dev/null +++ b/test/trinity/permissions/policy/state_budget_test.exs @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.Policy.StateBudgetTest do + @moduledoc """ + Slice 028 AC4, end to end: a real budget, exceeded by real ledger rows, tightens a real call, and + the basis the gate returns names the state that did it. + + The unit tests prove the mechanism cannot loosen. This one proves the mechanism is *connected*: + that a state kind with no producer is a state kind that does nothing, and `:over_budget` has one. + """ + use Trinity.DataCase, async: false + + alias Trinity.Repo + alias Trinity.Tools.{Context, Runner} + + setup do + previous = Application.fetch_env(:trinity, :budgets) + Application.put_env(:trinity, :budgets, day: 0.01) + + on_exit(fn -> + case previous do + {:ok, value} -> Application.put_env(:trinity, :budgets, value) + :error -> Application.delete_env(:trinity, :budgets) + end + end) + + :ok + end + + defp spend!(amount) do + Repo.insert!(%Trinity.LLM.Usage{ + model_id: "fake:chat", + provider: "fake", + kind: "stream", + input_tokens: 10, + output_tokens: 20, + cached_tokens: 0, + reasoning_tokens: 0, + cost_usd: amount, + session_id: nil, + provider_meta: %{}, + inserted_at: DateTime.utc_now() + }) + end + + # `decide/3` answers a three-tuple for allow and deny and a four-tuple for ask, the extra element + # being what to ask with. The basis is last either way. + defp basis_of(result), do: result |> Tuple.to_list() |> List.last() + + test "under the budget the call decides as it always did, with no state in the basis" do + {:ok, entry} = Trinity.Tools.lookup("fs_read") + basis = basis_of(Runner.decide(entry, %{"path" => "/tmp/x"}, %Context{})) + + refute basis =~ "state:", + "no budget is exceeded, so nothing should have tightened this call: #{basis}" + end + + test "over the day budget the same call is denied, and the basis names the budget" do + spend!(0.05) + + {:ok, entry} = Trinity.Tools.lookup("fs_read") + + assert {:deny, _fp, basis} = Runner.decide(entry, %{"path" => "/tmp/x"}, %Context{}) + + assert basis =~ "state:over_budget", + "the call was denied but the basis does not say the budget did it: #{basis}. A refusal " <> + "whose reason is not recorded is a refusal the owner cannot act on" + end + + test "the tier of the tool did not move, only what the call required" do + spend!(0.05) + assert Trinity.Permissions.tier("fs_read") == :read + end +end diff --git a/test/trinity/permissions/policy/state_gate_test.exs b/test/trinity/permissions/policy/state_gate_test.exs new file mode 100644 index 0000000..d059dff --- /dev/null +++ b/test/trinity/permissions/policy/state_gate_test.exs @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.Policy.StateGateTest do + @moduledoc """ + Slice 028 AC2 and AC4: state reaches the gate's result and never the tool's tier. + + The separation is the claim. `Permissions.tier/1` is a function of the tool's **name**, and this + file asserts that it stays one under every state: the wiring does not move. What moves is the + decision, and only in one direction, which `state_test.exs` proves exhaustively. + """ + use Trinity.DataCase, async: false + + alias Trinity.Permissions + alias Trinity.Permissions.Policy + + setup do + # `Application.put_env(key, nil)` is not the same as never having set the key: `get_env/3` + # returns the stored `nil` rather than the default, so `impl()` becomes `nil` and every later + # test calls `nil.decide/4`. Restoring an absent key means deleting it. Found the expensive way: + # this file passed alone and took four MCP tests down with it in the full suite. + previous = Application.fetch_env(:trinity, :permissions_policy) + Application.put_env(:trinity, :permissions_policy, Policy.Default) + + on_exit(fn -> + case previous do + {:ok, value} -> Application.put_env(:trinity, :permissions_policy, value) + :error -> Application.delete_env(:trinity, :permissions_policy) + end + end) + + :ok + end + + test "the tier is a function of the tool name, identical under every state" do + # The tier function takes no state and this test is what stops it ever taking one. If someone + # threads state into `tier/1`, the two calls stop agreeing and this fails. + for name <- ["fs_read", "fs_write", "shell", "nonexistent_tool"] do + bare = Permissions.tier(name) + + for state <- Policy.State.kinds() do + assert Permissions.tier(name) == bare, + "the tier of #{name} moved when #{state} was in play; state changes what a tier " <> + "requires, never what the tier is" + end + end + end + + test "with no state the decision and basis are exactly what the policy said" do + assert {:allow, "policy"} = Permissions.decide_with_basis(nil, "fs_read", %{}, []) + end + + test "a state tightens the policy's allow and the basis names the state that did it" do + assert {:ask, "policy+state:untrusted_context"} = + Permissions.decide_with_basis(nil, "fs_read", %{}, state: [:untrusted_context]) + + assert {:deny, "policy+state:over_budget"} = + Permissions.decide_with_basis(nil, "fs_read", %{}, state: [:over_budget]) + end + + test "a state that does not move the decision leaves the basis alone" do + # The policy in force already denies nothing, so :deny cannot be reached from the policy here; + # the point is that a state which changes nothing is not recorded as though it had. + assert {:ask, "policy+state:untrusted_context"} = + Permissions.decide_with_basis(nil, "fs_read", %{}, + state: [:untrusted_context, :unattended] + ) + end + + test "an unknown state is refused at the gate rather than ignored there" do + assert_raise ArgumentError, ~r/not a state/, fn -> + Permissions.decide_with_basis(nil, "fs_read", %{}, state: [:convenient]) + end + end +end diff --git a/test/trinity/permissions/policy/state_test.exs b/test/trinity/permissions/policy/state_test.exs new file mode 100644 index 0000000..37efe07 --- /dev/null +++ b/test/trinity/permissions/policy/state_test.exs @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.Policy.StateTest do + @moduledoc """ + Slice 028: context tightens and never loosens. + + The first test is the criterion. Everything below it is an illustration, and an illustration is + what a modifier that happened to tighten for the cases someone thought of would also pass. + """ + use ExUnit.Case, async: true + + alias Trinity.Permissions.Policy.State + + @decisions [:allow, :ask, :deny] + @strictness %{allow: 0, ask: 1, deny: 2} + + describe "the property" do + test "no decision and no set of states produces a result weaker than the decision alone" do + # Every decision against every subset of the closed state set, in every order. The set is + # small enough that this is exhaustive rather than sampled, which is better than a property + # test: there is no seed and nothing to be unlucky with. + for decision <- @decisions, + states <- permutations_of_subsets(State.kinds()) do + {result, applied} = State.tighten(decision, states) + + assert @strictness[result] >= @strictness[decision], + "#{decision} with #{inspect(states)} produced #{result}, which is weaker" + + assert Enum.all?(applied, &(&1 in states)), + "#{inspect(applied)} names a state that was not active" + + if result == decision do + assert applied == [], + "nothing changed the decision but #{inspect(applied)} was recorded" + end + end + end + + test "adding a state to the list can only tighten, never relax, whatever is already there" do + for decision <- @decisions, + states <- permutations_of_subsets(State.kinds()), + extra <- State.kinds() do + {before, _} = State.tighten(decision, states) + {after_, _} = State.tighten(decision, states ++ [extra]) + assert @strictness[after_] >= @strictness[before] + end + end + + test "no state requires only :allow, which is why none of them can be a licence" do + for kind <- State.kinds(), do: assert(State.requires(kind) in [:ask, :deny]) + end + end + + describe "the closed set" do + test "a state outside the set is refused by name rather than ignored" do + assert_raise ArgumentError, ~r/:please_allow is not a state/, fn -> + State.requires(:please_allow) + end + + assert_raise ArgumentError, ~r/not a state/, fn -> + State.tighten(:deny, [:please_allow]) + end + end + + test "the refusal says what to do about it, because the reflex is to pass the atom anyway" do + message = + assert_raise(ArgumentError, fn -> State.requires(:invented) end) + |> Map.fetch!(:message) + + assert message =~ "Add it to Trinity.Permissions.Policy.State with what it requires" + end + end + + describe "what it records" do + test "a deny is unchanged by any state, and records nothing as having changed it" do + assert {:deny, []} = State.tighten(:deny, State.kinds()) + end + + test "an allow under untrusted context becomes an ask, and says which state did it" do + assert {:ask, [:untrusted_context]} = State.tighten(:allow, [:untrusted_context]) + end + + test "only the states that actually moved the decision are recorded" do + # :unattended would raise an allow to :ask, but :over_budget has already taken it to :deny. + assert {:deny, [:over_budget]} = State.tighten(:allow, [:over_budget, :unattended]) + end + + test "the basis names the states that tightened, and is untouched when none did" do + assert State.basis("session_grant", []) == "session_grant" + assert State.basis("persona", [:over_budget]) == "persona+state:over_budget" + end + end + + # Every subset of `kinds`, and every ordering of each subset: order must not matter, and the only + # honest way to assert that is to try them. + defp permutations_of_subsets(kinds) do + for n <- 0..length(kinds), subset <- combinations(kinds, n), p <- permutations(subset), do: p + end + + defp combinations(_, 0), do: [[]] + defp combinations([], _), do: [] + + defp combinations([h | t], n), + do: Enum.map(combinations(t, n - 1), &[h | &1]) ++ combinations(t, n) + + defp permutations([]), do: [[]] + defp permutations(list), do: for(h <- list, t <- permutations(list -- [h]), do: [h | t]) +end