Skip to content

Commit be75fbd

Browse files
authored
Merge pull request #86 from ScriptKittyOS/slice/027-authority-census-published
Slice 027: publish the effect catalogue, and make two censuses falsifiable
2 parents ac28d9e + f0b02f5 commit be75fbd

8 files changed

Lines changed: 409 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ do over the next year, and why the order is what it is.
279279
| `VERSIONS.md` | The verified dependency versions, generated from `lib/trinity/versions.ex` |
280280
| `docs/` | Vision, architecture, tech stack, conventions, data model, risks, security model, standards register; packaging, the FIPS leg, backup and restore, performance measurements |
281281
| `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 |
282+
| `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 |
282283
| `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 |
283284
| `docs/adr/` | Architecture decision records. One is added whenever a decision changes |
284285
| `docs/10-assurance-case.md` | The structured argument that the security claims hold, with the evidence for each and the assumptions and limits named |

‎docs/07-security-model.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,36 @@ where the runtime carries it; the boot receipt names the choice and the authorit
347347
hash in unsigned metadata (the R21 default). `bin/verify_receipt.exs` verifies an export with `elixir` alone,
348348
from an empty directory, with the exit vocabulary 0, 1, 2, 5, 6.
349349

350+
## The effect population, published (Slice 027, as built)
351+
352+
The set of things that can cause an effect is small, derived from the tree, and published as
353+
[`docs/effects-catalog.md`](effects-catalog.md) with a version that is a SHA-256 over its own rows.
354+
`mix trinity.effects.catalog --check` runs inside `scripts/prod_check.sh`, which the quality gate
355+
runs on every commit, so a tool added without the artifact changing fails the build. The artifact is
356+
built under `MIX_ENV=prod` and the task refuses to write or check outside it, because the test
357+
configuration adds scaffolding tools to the same list and a catalogue that named them would be a
358+
claim about something other than what ships.
359+
360+
Two censuses hold the population's edges, and both plant a violation so that a census which has
361+
quietly stopped working fails rather than passes:
362+
363+
- **Callers of `execute/2`** (`test/trinity/effects/census_test.exs`, slice 024, extended at 027):
364+
the allowed set is a map from path to the reason that path is allowed, so a third caller has
365+
something to argue against rather than a bare list of two. `test/support/effects/bypass.ex` is a
366+
real bypass that the census must name.
367+
- **Session state is not an authority input**
368+
(`test/trinity/permissions/session_state_census_test.exs`, slice 027): no module in
369+
`lib/trinity/permissions`, `lib/trinity/effects` or `lib/trinity/authority` calls the Sessions
370+
context for `history`, `get_session`, `list_sessions` or `state`.
371+
372+
The second one carries the distinction that matters. A session **id** is a scope key: a grant is
373+
stored as `session:<id>` and an approval belongs to a session row. That is identity, and identity
374+
narrows a grant. What the session has been **saying** is state, and state would steer a decision.
375+
Every message in a session is a place an injected instruction can sit, so a gate that reads
376+
conversation state is a gate an attacker can argue with, and the argument does not have to convince
377+
a person: it has to move one boolean. Keeping the decision blind to the conversation is what makes
378+
prompt injection a problem about what is *proposed* rather than about what is *authorized*.
379+
350380
## Data at rest
351381

352382
- 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.

‎docs/effects-catalog.md‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<!-- SPDX-FileCopyrightText: Sudo Apt Holdings LLC -->
2+
<!-- SPDX-License-Identifier: Apache-2.0 -->
3+
<!-- Generated by `mix trinity.effects.catalog --write`. Do not edit by hand: the gate
4+
regenerates it and fails when this file and the tree disagree. -->
5+
# The effect catalogue
6+
7+
Version `6de39824f78b4380`, which is a SHA-256 over the rows below rather than a number anyone
8+
typed. Two trees with the same effect surface carry the same version. Built from `MIX_ENV=prod`,
9+
which `--write` and `--check` require to be `prod`, because this is a statement about what ships.
10+
11+
This is the population that can cause an effect, in one place. It is otherwise spread across a
12+
behaviour (`Trinity.Tools.Tool`), a configuration list, a closed catalogue module
13+
(`Trinity.Tools.Catalog`) and an authority adapter, and a reader would have to assemble it to
14+
check any claim this project makes about least privilege.
15+
16+
What is *not* here is the point of it. Tools registered at runtime, from an MCP server or a
17+
skill, never appear: they can propose a call, and the membrane refuses an effectful one that is
18+
not in this list. `mix trinity.effects.catalog --check` is in the quality gate, so a tool added
19+
without this file changing fails the build.
20+
21+
| Tool | Risk tier | Effect class | In the closed catalogue | Can escalate | Module |
22+
|---|---|---|---|---|---|
23+
| `delegate` | `read` | `none` | no | no | `Trinity.Tools.Delegate` |
24+
| `fs_edit` | `write` | `artifact` | no | yes | `Trinity.Tools.FS.Edit` |
25+
| `fs_glob` | `read` | `none` | no | yes | `Trinity.Tools.FS.Glob` |
26+
| `fs_grep` | `read` | `none` | no | yes | `Trinity.Tools.FS.Grep` |
27+
| `fs_list` | `read` | `none` | no | yes | `Trinity.Tools.FS.List` |
28+
| `fs_read` | `read` | `none` | no | yes | `Trinity.Tools.FS.Read` |
29+
| `fs_write` | `write` | `artifact` | no | yes | `Trinity.Tools.FS.Write` |
30+
| `learn` | `write` | `artifact` | no | no | `Trinity.Skills.Tools.Learn` |
31+
| `memory` | `write` | `artifact` | no | no | `Trinity.Tools.Memory` |
32+
| `recall` | `read` | `none` | no | no | `Trinity.Tools.Recall` |
33+
| `session_search` | `read` | `none` | no | no | `Trinity.Tools.SessionSearch` |
34+
| `shell` | `exec` | `catalog` | yes | yes | `Trinity.Tools.Shell.Run` |
35+
| `skill_file` | `read` | `none` | no | no | `Trinity.Skills.Tools.File` |
36+
| `skill_manage` | `write` | `artifact` | no | no | `Trinity.Skills.Tools.Manage` |
37+
| `skill_view` | `read` | `none` | no | no | `Trinity.Skills.Tools.View` |
38+
| `skills_list` | `read` | `none` | no | no | `Trinity.Skills.Tools.List` |
39+
| `web_fetch` | `network` | `none` | no | yes | `Trinity.Tools.Web.Fetch` |
40+
| `web_search` | `network` | `none` | no | no | `Trinity.Tools.Web.Search` |
41+
42+
## How to read the columns
43+
44+
- **Risk tier** is a function of the tool's *name* alone. Arguments can raise what a call
45+
requires (the next column); they can never change the tier.
46+
- **Effect class** is what the membrane reads. `:none` never reaches `Trinity.Effects`;
47+
`:artifact` and `:catalog` both cross it and both leave a signed receipt.
48+
- **In the closed catalogue** means the name is in `Trinity.Tools.Catalog`, the compile-time
49+
list of external effects. The registry refuses a runtime registration claiming `:catalog`.
50+
- **Can escalate** means the tool implements `escalate/2`, which may raise a single call's
51+
required tier from its arguments. It is one-way by the behaviour's contract.
52+
53+
## Where execution happens
54+
55+
Under the default authority (`TRINITY_AUTHORITY=local`, ADR-0010) `Trinity.Authority.Local` is
56+
the executor and runs the tool's own `execute/2`. Under an external authority adapter Trinity
57+
keeps no executor for catalogued effects: it proposes, the adapter decides and acts, and the
58+
census test for that mode asserts the absence rather than describing it. The mode is chosen
59+
once at boot, recorded in the boot receipt, and no runtime path changes it.
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Mix.Tasks.Trinity.Effects.Catalog do
4+
@shortdoc "Prints the effect catalogue, or checks the committed artifact against the tree"
5+
6+
@moduledoc """
7+
The effect catalogue as a published, versioned artifact (slice 027 AC3 and AC4).
8+
9+
The population that can cause an effect is the thing a reader of this project most needs and
10+
is least able to derive: it is spread across a behaviour, a config list, a closed catalogue
11+
module and an authority adapter. This task collects it into one file, `docs/effects-catalog.md`,
12+
and the gate fails when that file and the tree disagree. A reader does not have to trust the
13+
file, because `mix trinity.effects.catalog --check` is the same check they can run.
14+
15+
**The version is the content.** It is a SHA-256 over the rows, not a number anyone types, so
16+
two trees with the same effect surface have the same version and a tree that adds a tool has a
17+
different one whether or not anybody remembered to say so.
18+
19+
**Read at compile time, not from the registry.** The population is the configured core modules
20+
and their behaviour callbacks. The running registry also holds dynamic tools (MCP, skills),
21+
which are exactly what this artifact is a statement *against*: they can propose, and they can
22+
never be in this list. Reading the registry would let a runtime registration into a
23+
compile-time claim.
24+
25+
**`--write` and `--check` refuse outside `MIX_ENV=prod`, and that is the point of them.** The
26+
artifact is a claim about what *ships*. `config/test.exs` adds scaffolding tools to the same list,
27+
so a catalogue generated under `:test` names them beside `fs_read`, and a gate running in `:test`
28+
would then compare a test surface against a production one and fail forever, or, worse, be
29+
"fixed" by committing the test surface as though it were the product. Measured while writing this
30+
task: under `:test` the list is five entries longer. Printing works in any environment and stamps
31+
the environment it read.
32+
33+
The first draft of this paragraph named one of those scaffolding modules as an example, and slice
34+
020's census (`test/trinity/tools/registry_test.exs`, "no core module references a test tool")
35+
failed the build for it. That census is right and the example was not worth it: a shipped tree
36+
that names its own test scaffolding has a seam a reader has to check.
37+
38+
## Usage
39+
40+
mix trinity.effects.catalog # print
41+
mix trinity.effects.catalog --check # exit 1 if docs/effects-catalog.md differs
42+
mix trinity.effects.catalog --write # regenerate docs/effects-catalog.md
43+
"""
44+
45+
use Boundary, classify_to: Trinity
46+
use Mix.Task
47+
48+
@artifact "docs/effects-catalog.md"
49+
50+
@impl Mix.Task
51+
def run(argv) do
52+
Mix.Task.run("app.config")
53+
rendered = render()
54+
55+
cond do
56+
"--check" in argv -> prod!("--check") && check(rendered)
57+
"--write" in argv -> prod!("--write") && write(rendered)
58+
true -> Mix.shell().info(rendered)
59+
end
60+
end
61+
62+
defp prod!(flag) do
63+
if Mix.env() == :prod do
64+
true
65+
else
66+
Mix.raise(
67+
"#{flag} needs MIX_ENV=prod. This artifact is a claim about what ships, and " <>
68+
"config/test.exs adds test tools to the same list, so a catalogue built under " <>
69+
"#{inspect(Mix.env())} would name them. Run `MIX_ENV=prod mix trinity.effects.catalog " <>
70+
"#{flag}`."
71+
)
72+
end
73+
end
74+
75+
@doc "Every configured core tool as a row, sorted by name."
76+
@spec rows() :: [map()]
77+
def rows do
78+
:trinity
79+
|> Application.get_env(:tools, [])
80+
|> Keyword.get(:modules, [])
81+
|> Enum.map(&row/1)
82+
|> Enum.sort_by(& &1.name)
83+
end
84+
85+
@doc "The catalogue's version: a SHA-256 over the rows, so the version is the content."
86+
@spec version([map()]) :: String.t()
87+
def version(rows) do
88+
rows
89+
|> Enum.map_join(
90+
"\n",
91+
&"#{&1.name}\t#{&1.risk}\t#{&1.effect}\t#{&1.catalogued}\t#{&1.escalates}"
92+
)
93+
|> then(&:crypto.hash(:sha256, &1))
94+
|> Base.encode16(case: :lower)
95+
|> binary_part(0, 16)
96+
end
97+
98+
defp row(module) do
99+
Code.ensure_loaded!(module)
100+
101+
%{
102+
name: module.name(),
103+
risk: module.risk(),
104+
effect: module.effect(),
105+
catalogued: module.name() in Trinity.Tools.Catalog.names(),
106+
escalates: function_exported?(module, :escalate, 2),
107+
module: inspect(module)
108+
}
109+
end
110+
111+
defp render do
112+
rows = rows()
113+
114+
"""
115+
<!-- SPDX-FileCopyrightText: Sudo Apt Holdings LLC -->
116+
<!-- SPDX-License-Identifier: Apache-2.0 -->
117+
<!-- Generated by `mix trinity.effects.catalog --write`. Do not edit by hand: the gate
118+
regenerates it and fails when this file and the tree disagree. -->
119+
# The effect catalogue
120+
121+
Version `#{version(rows)}`, which is a SHA-256 over the rows below rather than a number anyone
122+
typed. Two trees with the same effect surface carry the same version. Built from `MIX_ENV=#{Mix.env()}`,
123+
which `--write` and `--check` require to be `prod`, because this is a statement about what ships.
124+
125+
This is the population that can cause an effect, in one place. It is otherwise spread across a
126+
behaviour (`Trinity.Tools.Tool`), a configuration list, a closed catalogue module
127+
(`Trinity.Tools.Catalog`) and an authority adapter, and a reader would have to assemble it to
128+
check any claim this project makes about least privilege.
129+
130+
What is *not* here is the point of it. Tools registered at runtime, from an MCP server or a
131+
skill, never appear: they can propose a call, and the membrane refuses an effectful one that is
132+
not in this list. `mix trinity.effects.catalog --check` is in the quality gate, so a tool added
133+
without this file changing fails the build.
134+
135+
| Tool | Risk tier | Effect class | In the closed catalogue | Can escalate | Module |
136+
|---|---|---|---|---|---|
137+
#{Enum.map_join(rows, "\n", &line/1)}
138+
139+
## How to read the columns
140+
141+
- **Risk tier** is a function of the tool's *name* alone. Arguments can raise what a call
142+
requires (the next column); they can never change the tier.
143+
- **Effect class** is what the membrane reads. `:none` never reaches `Trinity.Effects`;
144+
`:artifact` and `:catalog` both cross it and both leave a signed receipt.
145+
- **In the closed catalogue** means the name is in `Trinity.Tools.Catalog`, the compile-time
146+
list of external effects. The registry refuses a runtime registration claiming `:catalog`.
147+
- **Can escalate** means the tool implements `escalate/2`, which may raise a single call's
148+
required tier from its arguments. It is one-way by the behaviour's contract.
149+
150+
## Where execution happens
151+
152+
Under the default authority (`TRINITY_AUTHORITY=local`, ADR-0010) `Trinity.Authority.Local` is
153+
the executor and runs the tool's own `execute/2`. Under an external authority adapter Trinity
154+
keeps no executor for catalogued effects: it proposes, the adapter decides and acts, and the
155+
census test for that mode asserts the absence rather than describing it. The mode is chosen
156+
once at boot, recorded in the boot receipt, and no runtime path changes it.
157+
"""
158+
end
159+
160+
defp line(r) do
161+
"| `#{r.name}` | `#{r.risk}` | `#{r.effect}` | #{yes(r.catalogued)} | #{yes(r.escalates)} | `#{r.module}` |"
162+
end
163+
164+
defp yes(true), do: "yes"
165+
defp yes(false), do: "no"
166+
167+
defp write(rendered) do
168+
File.write!(@artifact, rendered)
169+
Mix.shell().info("trinity.effects.catalog: wrote #{@artifact}")
170+
end
171+
172+
defp check(rendered) do
173+
case File.read(@artifact) do
174+
{:ok, ^rendered} ->
175+
Mix.shell().info("trinity.effects.catalog: OK. #{@artifact} matches the tree")
176+
177+
{:ok, _other} ->
178+
Mix.raise(
179+
"#{@artifact} does not match the tree. The effect surface changed and the published " <>
180+
"artifact did not. Run `mix trinity.effects.catalog --write` and commit the result."
181+
)
182+
183+
{:error, :enoent} ->
184+
Mix.raise("#{@artifact} is missing. Run `mix trinity.effects.catalog --write`.")
185+
end
186+
end
187+
end

‎scripts/prod_check.sh‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,15 @@ echo "== 2. the headless release assembles =="
4040
# `mix assets.deploy` is the packaging workflow's concern.
4141
MIX_ENV=prod mix release headless --overwrite --quiet
4242

43-
echo "== 3. the release evaluates its runtime configuration =="
43+
echo "== 3. the published effect catalogue matches the tree =="
44+
# Slice 027 AC3. It lives here rather than in a gate row of its own because the artifact is a
45+
# statement about what ships and so must be built in prod, and prod is already compiled by step 1;
46+
# a separate gate row would compile prod a second time to learn the same thing. A tool added to
47+
# config/config.exs without regenerating docs/effects-catalog.md fails here, which is the point:
48+
# the published claim about what can cause an effect cannot drift from the tree that makes it.
49+
MIX_ENV=prod mix trinity.effects.catalog --check
50+
51+
echo "== 4. the release evaluates its runtime configuration =="
4452
# `eval` runs the release's config providers, so config/runtime.exs's prod branch is executed
4553
# with no environment prepared for it, which is how a packaged binary is first started.
4654
"$rel" eval 'IO.puts("prod boot config ok")'
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.TestPermissions.SessionStateReader do
4+
@moduledoc """
5+
A planted violation for slice 027 AC2, and it is a real one rather than a string in a comment.
6+
7+
It reads a session's conversation and decides from it, which is exactly what the census in
8+
`test/trinity/permissions/session_state_census_test.exs` exists to forbid: a gate that becomes
9+
more permissive because of what the conversation has been saying is a gate an injected message
10+
can argue with.
11+
12+
It is never registered and nothing calls it in the tree. Its only job is to be found. If the
13+
census stops naming this file, the census has stopped working.
14+
"""
15+
16+
@doc "Decides from the conversation, which is the thing that must never happen."
17+
@spec lenient?(Trinity.Sessions.session_id()) :: boolean()
18+
def lenient?(session_id) do
19+
session_id
20+
|> Trinity.Sessions.history(limit: 10)
21+
|> Enum.any?(&String.contains?(to_string(&1.content), "please allow"))
22+
end
23+
end

‎test/trinity/effects/census_test.exs‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,19 @@ defmodule Trinity.Effects.CensusTest do
1414

1515
alias Trinity.Tools.{Context, Runner}
1616

17-
@allowed ["lib/trinity/authority/local.ex", "lib/trinity/tools/runner.ex"]
17+
# Slice 027 AC1: each allowed caller carries the reason it is allowed, because a bare list of two
18+
# paths is a claim a reader has to take on trust, and the next person to add a third has nothing
19+
# to weigh their case against. A path with no reason is not allowed: `@allowed` is a map and the
20+
# test below asserts every reason is present and says something.
21+
@allowed %{
22+
"lib/trinity/authority/local.ex" =>
23+
"the executor under TRINITY_AUTHORITY=local: the membrane admits an effect and this is what " <>
24+
"runs the tool's own execute/2. Under an external authority adapter this file is not the " <>
25+
"executor and the effect never reaches it.",
26+
"lib/trinity/tools/runner.ex" =>
27+
"the non-effect path: call_tool/3 is guarded to effect: :none, which the second test in this " <>
28+
"file proves by calling it with an effectful tool and reading the refusal by name."
29+
}
1830
@planted ["test/support/effects/bypass.ex"]
1931

2032
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
3547
|> Enum.any?(),
3648
do: f
3749

38-
assert Enum.sort(callers) == Enum.sort(@allowed ++ @planted)
50+
assert Enum.sort(callers) == Enum.sort(Map.keys(@allowed) ++ @planted)
51+
end
52+
53+
test "every allowed caller carries a reason, and the reason says something" do
54+
for {path, reason} <- @allowed do
55+
assert is_binary(reason) and String.length(reason) > 40,
56+
"#{path} is allowed to call execute/2 with no reason worth reading. A reader cannot " <>
57+
"weigh a third caller's case against a list that does not say why the first two are " <>
58+
"there"
59+
end
3960
end
4061

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

0 commit comments

Comments
 (0)