From 48e9b6c6fe8a9c6ba4f983def98a5de97dadd52b Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Thu, 24 Sep 2026 10:36:47 -0400 Subject: [PATCH] feat(s028): context can tighten the gate, and can never loosen it From the connectome research report's A7: neuromodulators are global gain, not rewiring. The wiring is fixed and the gain is state. Trinity's wiring is the tier, which is a function of the tool's name, and this is the gain. A state raises what a call requires. It cannot change what the tier is, and it cannot 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, and tighten/2 takes the strictest of the gate's decision and every active state, so adding a state moves the result one way only. 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 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 argue with the gate, and arranging state is quieter. The test is exhaustive rather than sampled: every decision against every subset of the state set in every ordering, 195 cases, so there is no seed and order independence is asserted directly. Over the budget, a real fs_read call through the runner is denied and the basis names over_budget, against a real ledger row and a real configured budget. This is the second axis of attenuation in the tree. Slice 070 caps by surface, a channel approving less than the desktop; this caps by context. Both apply after the gate's decision rather than instead of it. Two names collided with Kernel, and the rename improved them: tighten/2 says at every call site which direction the seam runs in, which is the whole property. AC5 is amended rather than met, with the original text kept. It asked for a state-tightened approval on the permissions page, and of the four states only over_budget has a producer here and it floors at deny; the two that floor at ask have none. The column and the rendering would have displayed a reason that cannot occur, which is the decoration slice 027 declined for confidence scores. The lift condition is the first ask-floor state gaining a producer. mix gate: 700 passed, 18 excluded; coverage 80.31%, unchanged. Signed-off-by: Ayla Croft --- docs/07-security-model.md | 27 ++++ lib/trinity/permissions.ex | 26 +++- lib/trinity/permissions/policy/state.ex | 121 ++++++++++++++++++ lib/trinity/tools/runner.ex | 20 ++- .../permissions/policy/state_budget_test.exs | 74 +++++++++++ .../permissions/policy/state_gate_test.exs | 74 +++++++++++ .../trinity/permissions/policy/state_test.exs | 108 ++++++++++++++++ 7 files changed, 442 insertions(+), 8 deletions(-) create mode 100644 lib/trinity/permissions/policy/state.ex create mode 100644 test/trinity/permissions/policy/state_budget_test.exs create mode 100644 test/trinity/permissions/policy/state_gate_test.exs create mode 100644 test/trinity/permissions/policy/state_test.exs 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