diff --git a/README.md b/README.md index 3b23fb7..a2c5de6 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/07-security-model.md b/docs/07-security-model.md index def93c2..29ced1d 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -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:` 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. diff --git a/docs/effects-catalog.md b/docs/effects-catalog.md new file mode 100644 index 0000000..5ae0049 --- /dev/null +++ b/docs/effects-catalog.md @@ -0,0 +1,59 @@ + + + +# 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. diff --git a/lib/mix/tasks/trinity.effects.catalog.ex b/lib/mix/tasks/trinity.effects.catalog.ex new file mode 100644 index 0000000..57b1150 --- /dev/null +++ b/lib/mix/tasks/trinity.effects.catalog.ex @@ -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() + + """ + + + + # 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 diff --git a/scripts/prod_check.sh b/scripts/prod_check.sh index c333af1..61d9406 100755 --- a/scripts/prod_check.sh +++ b/scripts/prod_check.sh @@ -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")' diff --git a/test/support/permissions/session_state_reader.ex b/test/support/permissions/session_state_reader.ex new file mode 100644 index 0000000..d9f136b --- /dev/null +++ b/test/support/permissions/session_state_reader.ex @@ -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 diff --git a/test/trinity/effects/census_test.exs b/test/trinity/effects/census_test.exs index fa01de6..e7a6012 100644 --- a/test/trinity/effects/census_test.exs +++ b/test/trinity/effects/census_test.exs @@ -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 @@ -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 diff --git a/test/trinity/permissions/session_state_census_test.exs b/test/trinity/permissions/session_state_census_test.exs new file mode 100644 index 0000000..f5836c8 --- /dev/null +++ b/test/trinity/permissions/session_state_census_test.exs @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.SessionStateCensusTest do + @moduledoc """ + Slice 027 AC2: the decision path never reads session or task state. + + The connectome research report's A5 is the reason this is a test and not a convention. The fly's + central complex holds one heading at a time and the heading informs behaviour, but the heading is + not a motor command: the descending neurons are. Trinity's equivalent claim is that a session's + conversational state can inform what the assistant *proposes* and can never be an input to what + the gate *decides*. + + **Why it matters more here than the analogy suggests.** Every message in a session is a place an + injected instruction can sit. A gate that reads conversation state is a gate an attacker can + argue with, and the argument does not even have to be convincing to a person: it has to move one + boolean. Keeping the decision blind to the conversation is what makes prompt injection a + proposal problem rather than an authorization problem. + + **What is allowed, and the distinction is the whole test.** The session *id* is a scope key: a + grant is stored as `session:` and an approval belongs to a session row, and both are + identity, not state. What is forbidden is reading what the session has been *doing*: its history, + its row, its list. Identity narrows a grant; state would steer a decision. + + The population is the decision path from `git ls-files`, plus one planted reader that must be + found or this census is not looking. + """ + use ExUnit.Case, async: true + + # Reading any of these from the decision path means the decision can move with the conversation. + @state_calls ~w(history get_session list_sessions state) + + @globs ~w(lib/trinity/permissions lib/trinity/effects lib/trinity/authority) + @membrane "lib/trinity/effects.ex" + @planted "test/support/permissions/session_state_reader.ex" + + defp population do + {out, 0} = System.cmd("git", ["ls-files" | @globs ++ [@membrane, @planted]]) + out |> String.split("\n", trim: true) |> Enum.filter(&String.ends_with?(&1, ".ex")) + end + + # A call into the Sessions context for one of the state-bearing functions. Both `Sessions.history(` + # and the fully qualified `Trinity.Sessions.history(` match, which is why the lookbehind excludes + # a word character and not a dot: the first version of this excluded a dot too, and so could not + # see the fully qualified call that the planted reader actually makes. The planted file caught + # that, which is what it is for. `belongs_to :session, Trinity.Sessions.SessionRow` does not + # match: it is a schema association and carries no call. + defp reads_state?(source) do + Enum.any?(@state_calls, fn call -> + Regex.match?(~r/(?= 8, + "the census is looking at #{length(files)} files: #{inspect(files)}" + + assert @membrane in files + assert @planted in files + end + + test "only the planted reader reads session state from the decision path" do + readers = for f <- population(), reads_state?(File.read!(f)), do: f + + assert readers == [@planted], + "a module on the decision path reads session state: #{inspect(readers -- [@planted])}. " <> + "The session id is a scope key and is allowed; what the session has been saying is " <> + "not an input to a decision. If this is deliberate, it needs an ADR, not a passing test" + end + + test "the planted reader really does decide from the conversation, or this census proves nothing" do + source = File.read!(@planted) + assert reads_state?(source) + assert source =~ "def lenient?" + end +end