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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 19 additions & 7 deletions lib/trinity/permissions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
121 changes: 121 additions & 0 deletions lib/trinity/permissions/policy/state.ex
Original file line number Diff line number Diff line change
@@ -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
20 changes: 19 additions & 1 deletion lib/trinity/tools/runner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,32 @@ 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}
{:ask, basis} -> {:ask, ask(ctx, entry, args), fp, basis}
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
Expand Down
74 changes: 74 additions & 0 deletions test/trinity/permissions/policy/state_budget_test.exs
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions test/trinity/permissions/policy/state_gate_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading