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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ do over the next year, and why the order is what it is.
| `VERSIONS.md` | The verified dependency versions, generated from `lib/trinity/versions.ex` |
| `docs/` | Vision, architecture, tech stack, conventions, data model, risks, security model, standards register; packaging, the FIPS leg, backup and restore, performance measurements |
| `docs/mcp-server.md` | Connecting a client to Trinity's MCP server (Claude Code, VS Code, Codex, goose), stdio, approvals over the wire, the headless profile |
| `docs/effects-catalog.md` | The published effect catalogue: every tool that can cause an effect, its risk tier and effect class, with a version that is a hash of its own rows. Generated, and the gate fails when it and the tree disagree |
| `docs/telemetry.md` | The telemetry event catalogue: every event this tree emits, its measurements and metadata, and the rule about what never appears in one |
| `docs/adr/` | Architecture decision records. One is added whenever a decision changes |
| `docs/10-assurance-case.md` | The structured argument that the security claims hold, with the evidence for each and the assumptions and limits named |
Expand Down
30 changes: 30 additions & 0 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,36 @@ where the runtime carries it; the boot receipt names the choice and the authorit
hash in unsigned metadata (the R21 default). `bin/verify_receipt.exs` verifies an export with `elixir` alone,
from an empty directory, with the exit vocabulary 0, 1, 2, 5, 6.

## The effect population, published (Slice 027, as built)

The set of things that can cause an effect is small, derived from the tree, and published as
[`docs/effects-catalog.md`](effects-catalog.md) with a version that is a SHA-256 over its own rows.
`mix trinity.effects.catalog --check` runs inside `scripts/prod_check.sh`, which the quality gate
runs on every commit, so a tool added without the artifact changing fails the build. The artifact is
built under `MIX_ENV=prod` and the task refuses to write or check outside it, because the test
configuration adds scaffolding tools to the same list and a catalogue that named them would be a
claim about something other than what ships.

Two censuses hold the population's edges, and both plant a violation so that a census which has
quietly stopped working fails rather than passes:

- **Callers of `execute/2`** (`test/trinity/effects/census_test.exs`, slice 024, extended at 027):
the allowed set is a map from path to the reason that path is allowed, so a third caller has
something to argue against rather than a bare list of two. `test/support/effects/bypass.ex` is a
real bypass that the census must name.
- **Session state is not an authority input**
(`test/trinity/permissions/session_state_census_test.exs`, slice 027): no module in
`lib/trinity/permissions`, `lib/trinity/effects` or `lib/trinity/authority` calls the Sessions
context for `history`, `get_session`, `list_sessions` or `state`.

The second one carries the distinction that matters. A session **id** is a scope key: a grant is
stored as `session:<id>` and an approval belongs to a session row. That is identity, and identity
narrows a grant. What the session has been **saying** is state, and state would steer a decision.
Every message in a session is a place an injected instruction can sit, so a gate that reads
conversation state is a gate an attacker can argue with, and the argument does not have to convince
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*.

## 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.
59 changes: 59 additions & 0 deletions docs/effects-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<!-- SPDX-FileCopyrightText: Sudo Apt Holdings LLC -->
<!-- SPDX-License-Identifier: Apache-2.0 -->
<!-- Generated by `mix trinity.effects.catalog --write`. Do not edit by hand: the gate
regenerates it and fails when this file and the tree disagree. -->
# The effect catalogue

Version `6de39824f78b4380`, which is a SHA-256 over the rows below rather than a number anyone
typed. Two trees with the same effect surface carry the same version. Built from `MIX_ENV=prod`,
which `--write` and `--check` require to be `prod`, because this is a statement about what ships.

This is the population that can cause an effect, in one place. It is otherwise spread across a
behaviour (`Trinity.Tools.Tool`), a configuration list, a closed catalogue module
(`Trinity.Tools.Catalog`) and an authority adapter, and a reader would have to assemble it to
check any claim this project makes about least privilege.

What is *not* here is the point of it. Tools registered at runtime, from an MCP server or a
skill, never appear: they can propose a call, and the membrane refuses an effectful one that is
not in this list. `mix trinity.effects.catalog --check` is in the quality gate, so a tool added
without this file changing fails the build.

| Tool | Risk tier | Effect class | In the closed catalogue | Can escalate | Module |
|---|---|---|---|---|---|
| `delegate` | `read` | `none` | no | no | `Trinity.Tools.Delegate` |
| `fs_edit` | `write` | `artifact` | no | yes | `Trinity.Tools.FS.Edit` |
| `fs_glob` | `read` | `none` | no | yes | `Trinity.Tools.FS.Glob` |
| `fs_grep` | `read` | `none` | no | yes | `Trinity.Tools.FS.Grep` |
| `fs_list` | `read` | `none` | no | yes | `Trinity.Tools.FS.List` |
| `fs_read` | `read` | `none` | no | yes | `Trinity.Tools.FS.Read` |
| `fs_write` | `write` | `artifact` | no | yes | `Trinity.Tools.FS.Write` |
| `learn` | `write` | `artifact` | no | no | `Trinity.Skills.Tools.Learn` |
| `memory` | `write` | `artifact` | no | no | `Trinity.Tools.Memory` |
| `recall` | `read` | `none` | no | no | `Trinity.Tools.Recall` |
| `session_search` | `read` | `none` | no | no | `Trinity.Tools.SessionSearch` |
| `shell` | `exec` | `catalog` | yes | yes | `Trinity.Tools.Shell.Run` |
| `skill_file` | `read` | `none` | no | no | `Trinity.Skills.Tools.File` |
| `skill_manage` | `write` | `artifact` | no | no | `Trinity.Skills.Tools.Manage` |
| `skill_view` | `read` | `none` | no | no | `Trinity.Skills.Tools.View` |
| `skills_list` | `read` | `none` | no | no | `Trinity.Skills.Tools.List` |
| `web_fetch` | `network` | `none` | no | yes | `Trinity.Tools.Web.Fetch` |
| `web_search` | `network` | `none` | no | no | `Trinity.Tools.Web.Search` |

## How to read the columns

- **Risk tier** is a function of the tool's *name* alone. Arguments can raise what a call
requires (the next column); they can never change the tier.
- **Effect class** is what the membrane reads. `:none` never reaches `Trinity.Effects`;
`:artifact` and `:catalog` both cross it and both leave a signed receipt.
- **In the closed catalogue** means the name is in `Trinity.Tools.Catalog`, the compile-time
list of external effects. The registry refuses a runtime registration claiming `:catalog`.
- **Can escalate** means the tool implements `escalate/2`, which may raise a single call's
required tier from its arguments. It is one-way by the behaviour's contract.

## Where execution happens

Under the default authority (`TRINITY_AUTHORITY=local`, ADR-0010) `Trinity.Authority.Local` is
the executor and runs the tool's own `execute/2`. Under an external authority adapter Trinity
keeps no executor for catalogued effects: it proposes, the adapter decides and acts, and the
census test for that mode asserts the absence rather than describing it. The mode is chosen
once at boot, recorded in the boot receipt, and no runtime path changes it.
187 changes: 187 additions & 0 deletions lib/mix/tasks/trinity.effects.catalog.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Mix.Tasks.Trinity.Effects.Catalog do
@shortdoc "Prints the effect catalogue, or checks the committed artifact against the tree"

@moduledoc """
The effect catalogue as a published, versioned artifact (slice 027 AC3 and AC4).

The population that can cause an effect is the thing a reader of this project most needs and
is least able to derive: it is spread across a behaviour, a config list, a closed catalogue
module and an authority adapter. This task collects it into one file, `docs/effects-catalog.md`,
and the gate fails when that file and the tree disagree. A reader does not have to trust the
file, because `mix trinity.effects.catalog --check` is the same check they can run.

**The version is the content.** It is a SHA-256 over the rows, not a number anyone types, so
two trees with the same effect surface have the same version and a tree that adds a tool has a
different one whether or not anybody remembered to say so.

**Read at compile time, not from the registry.** The population is the configured core modules
and their behaviour callbacks. The running registry also holds dynamic tools (MCP, skills),
which are exactly what this artifact is a statement *against*: they can propose, and they can
never be in this list. Reading the registry would let a runtime registration into a
compile-time claim.

**`--write` and `--check` refuse outside `MIX_ENV=prod`, and that is the point of them.** The
artifact is a claim about what *ships*. `config/test.exs` adds scaffolding tools to the same list,
so a catalogue generated under `:test` names them beside `fs_read`, and a gate running in `:test`
would then compare a test surface against a production one and fail forever, or, worse, be
"fixed" by committing the test surface as though it were the product. Measured while writing this
task: under `:test` the list is five entries longer. Printing works in any environment and stamps
the environment it read.

The first draft of this paragraph named one of those scaffolding modules as an example, and slice
020's census (`test/trinity/tools/registry_test.exs`, "no core module references a test tool")
failed the build for it. That census is right and the example was not worth it: a shipped tree
that names its own test scaffolding has a seam a reader has to check.

## Usage

mix trinity.effects.catalog # print
mix trinity.effects.catalog --check # exit 1 if docs/effects-catalog.md differs
mix trinity.effects.catalog --write # regenerate docs/effects-catalog.md
"""

use Boundary, classify_to: Trinity
use Mix.Task

@artifact "docs/effects-catalog.md"

@impl Mix.Task
def run(argv) do
Mix.Task.run("app.config")
rendered = render()

cond do
"--check" in argv -> prod!("--check") && check(rendered)
"--write" in argv -> prod!("--write") && write(rendered)
true -> Mix.shell().info(rendered)
end
end

defp prod!(flag) do
if Mix.env() == :prod do
true
else
Mix.raise(
"#{flag} needs MIX_ENV=prod. This artifact is a claim about what ships, and " <>
"config/test.exs adds test tools to the same list, so a catalogue built under " <>
"#{inspect(Mix.env())} would name them. Run `MIX_ENV=prod mix trinity.effects.catalog " <>
"#{flag}`."
)
end
end

@doc "Every configured core tool as a row, sorted by name."
@spec rows() :: [map()]
def rows do
:trinity
|> Application.get_env(:tools, [])
|> Keyword.get(:modules, [])
|> Enum.map(&row/1)
|> Enum.sort_by(& &1.name)
end

@doc "The catalogue's version: a SHA-256 over the rows, so the version is the content."
@spec version([map()]) :: String.t()
def version(rows) do
rows
|> Enum.map_join(
"\n",
&"#{&1.name}\t#{&1.risk}\t#{&1.effect}\t#{&1.catalogued}\t#{&1.escalates}"
)
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16(case: :lower)
|> binary_part(0, 16)
end

defp row(module) do
Code.ensure_loaded!(module)

%{
name: module.name(),
risk: module.risk(),
effect: module.effect(),
catalogued: module.name() in Trinity.Tools.Catalog.names(),
escalates: function_exported?(module, :escalate, 2),
module: inspect(module)
}
end

defp render do
rows = rows()

"""
<!-- SPDX-FileCopyrightText: Sudo Apt Holdings LLC -->
<!-- SPDX-License-Identifier: Apache-2.0 -->
<!-- Generated by `mix trinity.effects.catalog --write`. Do not edit by hand: the gate
regenerates it and fails when this file and the tree disagree. -->
# The effect catalogue

Version `#{version(rows)}`, which is a SHA-256 over the rows below rather than a number anyone
typed. Two trees with the same effect surface carry the same version. Built from `MIX_ENV=#{Mix.env()}`,
which `--write` and `--check` require to be `prod`, because this is a statement about what ships.

This is the population that can cause an effect, in one place. It is otherwise spread across a
behaviour (`Trinity.Tools.Tool`), a configuration list, a closed catalogue module
(`Trinity.Tools.Catalog`) and an authority adapter, and a reader would have to assemble it to
check any claim this project makes about least privilege.

What is *not* here is the point of it. Tools registered at runtime, from an MCP server or a
skill, never appear: they can propose a call, and the membrane refuses an effectful one that is
not in this list. `mix trinity.effects.catalog --check` is in the quality gate, so a tool added
without this file changing fails the build.

| Tool | Risk tier | Effect class | In the closed catalogue | Can escalate | Module |
|---|---|---|---|---|---|
#{Enum.map_join(rows, "\n", &line/1)}

## How to read the columns

- **Risk tier** is a function of the tool's *name* alone. Arguments can raise what a call
requires (the next column); they can never change the tier.
- **Effect class** is what the membrane reads. `:none` never reaches `Trinity.Effects`;
`:artifact` and `:catalog` both cross it and both leave a signed receipt.
- **In the closed catalogue** means the name is in `Trinity.Tools.Catalog`, the compile-time
list of external effects. The registry refuses a runtime registration claiming `:catalog`.
- **Can escalate** means the tool implements `escalate/2`, which may raise a single call's
required tier from its arguments. It is one-way by the behaviour's contract.

## Where execution happens

Under the default authority (`TRINITY_AUTHORITY=local`, ADR-0010) `Trinity.Authority.Local` is
the executor and runs the tool's own `execute/2`. Under an external authority adapter Trinity
keeps no executor for catalogued effects: it proposes, the adapter decides and acts, and the
census test for that mode asserts the absence rather than describing it. The mode is chosen
once at boot, recorded in the boot receipt, and no runtime path changes it.
"""
end

defp line(r) do
"| `#{r.name}` | `#{r.risk}` | `#{r.effect}` | #{yes(r.catalogued)} | #{yes(r.escalates)} | `#{r.module}` |"
end

defp yes(true), do: "yes"
defp yes(false), do: "no"

defp write(rendered) do
File.write!(@artifact, rendered)
Mix.shell().info("trinity.effects.catalog: wrote #{@artifact}")
end

defp check(rendered) do
case File.read(@artifact) do
{:ok, ^rendered} ->
Mix.shell().info("trinity.effects.catalog: OK. #{@artifact} matches the tree")

{:ok, _other} ->
Mix.raise(
"#{@artifact} does not match the tree. The effect surface changed and the published " <>
"artifact did not. Run `mix trinity.effects.catalog --write` and commit the result."
)

{:error, :enoent} ->
Mix.raise("#{@artifact} is missing. Run `mix trinity.effects.catalog --write`.")
end
end
end
10 changes: 9 additions & 1 deletion scripts/prod_check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,15 @@ echo "== 2. the headless release assembles =="
# `mix assets.deploy` is the packaging workflow's concern.
MIX_ENV=prod mix release headless --overwrite --quiet

echo "== 3. the release evaluates its runtime configuration =="
echo "== 3. the published effect catalogue matches the tree =="
# Slice 027 AC3. It lives here rather than in a gate row of its own because the artifact is a
# statement about what ships and so must be built in prod, and prod is already compiled by step 1;
# a separate gate row would compile prod a second time to learn the same thing. A tool added to
# config/config.exs without regenerating docs/effects-catalog.md fails here, which is the point:
# the published claim about what can cause an effect cannot drift from the tree that makes it.
MIX_ENV=prod mix trinity.effects.catalog --check

echo "== 4. the release evaluates its runtime configuration =="
# `eval` runs the release's config providers, so config/runtime.exs's prod branch is executed
# with no environment prepared for it, which is how a packaged binary is first started.
"$rel" eval 'IO.puts("prod boot config ok")'
Expand Down
23 changes: 23 additions & 0 deletions test/support/permissions/session_state_reader.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.TestPermissions.SessionStateReader do
@moduledoc """
A planted violation for slice 027 AC2, and it is a real one rather than a string in a comment.

It reads a session's conversation and decides from it, which is exactly what the census in
`test/trinity/permissions/session_state_census_test.exs` exists to forbid: a gate that becomes
more permissive because of what the conversation has been saying is a gate an injected message
can argue with.

It is never registered and nothing calls it in the tree. Its only job is to be found. If the
census stops naming this file, the census has stopped working.
"""

@doc "Decides from the conversation, which is the thing that must never happen."
@spec lenient?(Trinity.Sessions.session_id()) :: boolean()
def lenient?(session_id) do
session_id
|> Trinity.Sessions.history(limit: 10)
|> Enum.any?(&String.contains?(to_string(&1.content), "please allow"))
end
end
25 changes: 23 additions & 2 deletions test/trinity/effects/census_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,19 @@ defmodule Trinity.Effects.CensusTest do

alias Trinity.Tools.{Context, Runner}

@allowed ["lib/trinity/authority/local.ex", "lib/trinity/tools/runner.ex"]
# Slice 027 AC1: each allowed caller carries the reason it is allowed, because a bare list of two
# paths is a claim a reader has to take on trust, and the next person to add a third has nothing
# 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/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."
}
@planted ["test/support/effects/bypass.ex"]

test "the callers of execute/2 on a tool module are the two allowed and the one planted" do
Expand All @@ -35,7 +47,16 @@ defmodule Trinity.Effects.CensusTest do
|> Enum.any?(),
do: f

assert Enum.sort(callers) == Enum.sort(@allowed ++ @planted)
assert Enum.sort(callers) == Enum.sort(Map.keys(@allowed) ++ @planted)
end

test "every allowed caller carries a reason, and the reason says something" do
for {path, reason} <- @allowed do
assert is_binary(reason) and String.length(reason) > 40,
"#{path} is allowed to call execute/2 with no reason worth reading. A reader cannot " <>
"weigh a third caller's case against a list that does not say why the first two are " <>
"there"
end
end

test "Tools.Runner.call_tool/3 runs an effect: :none entry and refuses an effectful one by name" do
Expand Down
Loading
Loading