diff --git a/CHANGELOG.md b/CHANGELOG.md index 320b326..0b4aed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,34 @@ evidence for each increment is retained by the maintainers and summarised here. ## 2026-09-24 +### `slice/042` — Rules your decisions imply +Trinity now notices when you have answered the same question the same way enough times, and offers to +write the rule so it stops asking. It offers it **in its own section, read in a calm moment**, and +never beside a request you are about to decide. + +That placement is the whole design. The research this came from proposed showing a learned +recommendation next to the pending decision; the evidence on anchoring says a recommendation shown +before a person forms their own view moves the decision, and this project's permission model rests on +that decision being yours. Two further findings pointed the same way: people accept automated +suggestions without scrutiny, and between half and nearly all safety alerts are dismissed in the +clinical literature, because the cause of that is volume. So what is learned is spent on asking less +often rather than on leaning on the answer. + +A rule is offered only when every past decision agreed. Nine allows and one refusal offers nothing, +because that rule would permit the case you refused; it is reported as a disagreement to look at +instead. Unanimity makes that guarantee true by construction rather than by arithmetic. + +Two things enforce the placement. The module that does the learning is not visible outside the +permissions boundary, so nothing else can reach it and the compiler says so rather than a reviewer; +and a test plants a realistic attempt to annotate a pending approval through the one public way in, +and fails until it is removed. It is not behind a setting, because a setting that could put a +recommendation back beside a decision would be the same harm with a delay. + +Accepting a proposal writes an ordinary rule, the kind you could have written by hand, and records it +in a new chain of its own for changes to standing authority, with the number of decisions it rested +on. A rule outlives the conversation that prompted it, so someone asking what this agent may do +without being asked, and since when, should not have to read every conversation to find out. + ### `slice/005` — Transactions take the write lock up front An intermittent "database busy" failure had been failing the build in a different test each time for two days, across five pull requests. It is gone, for the reason it happened rather than around it. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index ee4fb7c..d436b41 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -72,7 +72,7 @@ it, and a reader should not infer from an entry here that the component is in a | `jose` | `1.11.12` | MIT | bom | | `jsv` | `0.23.0` | Apache-2.0 | bom | | `kernel` | `10.6.3.3` | Apache-2.0 | bom | -| `lazy_html` | `0.1.12` | Apache-2.0 | bom | +| `lazy_html` | `0.1.13` | Apache-2.0 | bom | | `llm_db` | `2026.9.4` | Apache-2.0 | bom | | `logger` | `1.20.4` | Apache-2.0 | bom | | `luerl` | `1.5.1` | Apache-2.0 | bom | diff --git a/coverage.tsv b/coverage.tsv index 1ab1520..8842da1 100644 --- a/coverage.tsv +++ b/coverage.tsv @@ -34,3 +34,4 @@ slice_id percent sha date 014 80.31 1a5d480 2026-09-24 110 80.31 90c3d0a 2026-09-24 005 80.31 9e9259a 2026-09-24 +042 80.31 714784f 2026-09-25 diff --git a/lib/trinity/permissions.ex b/lib/trinity/permissions.ex index 894c774..d99dfb6 100644 --- a/lib/trinity/permissions.ex +++ b/lib/trinity/permissions.ex @@ -20,7 +20,7 @@ defmodule Trinity.Permissions do """ use Boundary, deps: [Trinity], exports: [Policy, Approval, Rule, Fingerprint, Gate] - alias Trinity.Permissions.{Approval, Fingerprint, Gate, Policy, Rule, Store} + alias Trinity.Permissions.{Approval, Fingerprint, Gate, Learner, Policy, Rule, Store} @type tier :: :read | :write | :exec | :network | :destructive | :ask @type decision :: :allow | :deny | :ask @@ -120,6 +120,27 @@ defmodule Trinity.Permissions do defp impl, do: Application.get_env(:trinity, :permissions_policy, Policy.Layered) + ## What past decisions imply (slice 042) + + @doc """ + Rules the owner's own decisions imply, strongest first. + + `Trinity.Permissions.Learner` is deliberately **not** exported from this boundary, so nothing + outside the permissions context can reach it and the boundary compiler says so rather than a + reviewer. This is the only way in, and what comes out is a suggestion of a rule the owner could + have written by hand. + """ + @spec proposals(keyword()) :: [Learner.proposal()] + def proposals(opts \\ []), do: Learner.proposals(opts) + + @doc "Tools whose past decisions did not agree, reported after the fact and never beside a pending one." + @spec divergences(keyword()) :: [Learner.divergence()] + def divergences(opts \\ []), do: Learner.divergences(opts) + + @doc "How many consistent decisions before a rule is worth proposing." + @spec proposal_threshold() :: pos_integer() + def proposal_threshold, do: Learner.threshold() + ## Requests and their decisions @doc "Creates a pending approval for a call (a row, then a broadcast) and returns it." diff --git a/lib/trinity/permissions/learner.ex b/lib/trinity/permissions/learner.ex new file mode 100644 index 0000000..d8e142b --- /dev/null +++ b/lib/trinity/permissions/learner.ex @@ -0,0 +1,156 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.Learner do + @moduledoc """ + What the owner's own decisions imply, spent on asking less often (slice 042). + + This is the connectome research report's A1 and A4, reshaped by the evidence rather than + implemented as written. The report puts a learned recommendation at the arbitration point, beside + the decision the owner is about to make. Three findings say that is the one place it must not go: + + * **Anchoring.** A recommendation shown before a person forms their own assessment moves the + decision. Trinity's permission model rests entirely on that decision being independent of what + the agent wants, so an anchor there is not a rough edge, it is an attack on the premise. + * **Automation bias.** Accepting suggestions without scrutiny, and then ceasing to monitor. + * **Approval fatigue.** Between 49% and 96% of safety alerts are dismissed in the clinical + literature, and human intervention succeeds in 9% to 26% of cases even once a problem has + surfaced. The cause is **volume**, so the intervention that helps reduces volume rather than + annotating it. + + So the learned signal is spent on proposing a **rule**, reviewed in a calm moment, and on reporting + **divergence** after the fact. Both are decisions about the population of future asks rather than + nudges on a pending one. `test/trinity/permissions/learner_census_test.exs` asserts that nothing + this module produces reaches an approval card, and that census is the reason the module exists in + this shape. + + ## A proposal is unanimous or it is not a proposal + + A rule is proposed only when every decision for that tool agreed. One dissent and it becomes a + divergence to look at instead, because a rule drawn from eleven allows and one deny would permit + the case the owner refused. That is the whole of `AC3`: a proposal can never imply a permission + broader than the decisions it was drawn from, and unanimity is what makes that true by + construction rather than by arithmetic. + + ## It reads decisions, and writes nothing but proposals + + The source is the approvals table: rows the permission gate wrote when a person decided. The + learner has no write path into policy state or the effect catalogue, which a test asserts. What it + produces is a suggestion of a rule **the owner could have written by hand**, and accepting one + writes it through `Trinity.Permissions.put_rule/1`, the ordinary path, with no second kind of rule + anywhere in the tree. + """ + + import Ecto.Query + + alias Trinity.Permissions.Approval + alias Trinity.Repo + + @default_threshold 5 + @decided ~w(allowed denied) + + @type proposal :: %{ + tool: String.t(), + decision: String.t(), + count: pos_integer(), + first_seen: DateTime.t(), + last_seen: DateTime.t() + } + + @type divergence :: %{ + tool: String.t(), + counts: %{String.t() => pos_integer()}, + total: pos_integer() + } + + @doc "How many consistent decisions before a rule is worth proposing." + @spec threshold() :: pos_integer() + def threshold do + Application.get_env(:trinity, :permissions, []) + |> Keyword.get(:proposal_threshold, @default_threshold) + end + + @doc """ + The rules the owner's decisions imply, strongest first. + + A tool appears only when every decision for it agreed and there are at least `threshold/0` of them, + and only when no rule already covers it: proposing what is already true is noise, and noise is what + makes a person stop reading. + """ + @spec proposals(keyword()) :: [proposal()] + def proposals(opts \\ []) do + minimum = Keyword.get(opts, :threshold, threshold()) + covered = existing_rule_tools() + + decided() + |> Enum.group_by(& &1.tool) + |> Enum.reject(fn {tool, _rows} -> tool in covered end) + |> Enum.flat_map(&propose(&1, minimum)) + |> Enum.sort_by(& &1.count, :desc) + end + + @doc """ + Tools whose decisions did not agree, with the counts. + + Read-only and reported after the fact, which is what makes it safe: a count of what has already + been decided cannot anchor a decision that has already happened. + """ + @spec divergences(keyword()) :: [divergence()] + def divergences(_opts \\ []) do + decided() + |> Enum.group_by(& &1.tool) + |> Enum.flat_map(fn {tool, rows} -> + counts = Enum.frequencies_by(rows, & &1.status) + + if map_size(counts) > 1 do + [%{tool: tool, counts: counts, total: length(rows)}] + else + [] + end + end) + |> Enum.sort_by(& &1.total, :desc) + end + + defp propose({tool, rows}, minimum) do + counts = Enum.frequencies_by(rows, & &1.status) + + with 1 <- map_size(counts), + [{status, count}] <- Map.to_list(counts), + true <- count >= minimum do + times = rows |> Enum.map(& &1.inserted_at) |> Enum.sort(DateTime) + + [ + %{ + tool: tool, + decision: rule_decision(status), + count: count, + first_seen: List.first(times), + last_seen: List.last(times) + } + ] + else + _ -> [] + end + end + + # The approvals vocabulary is `allowed`/`denied`; the rules vocabulary is `allow`/`deny`. Mapped + # here rather than by trimming a letter somewhere, because two closed vocabularies that nearly + # match are exactly where a silent mistranslation lives. + defp rule_decision("allowed"), do: "allow" + defp rule_decision("denied"), do: "deny" + + defp decided do + Repo.all( + from a in Approval, + where: a.status in ^@decided, + select: %{ + tool: a.tool, + status: a.status, + inserted_at: a.inserted_at + } + ) + end + + defp existing_rule_tools do + Repo.all(from r in Trinity.Permissions.Rule, select: r.tool) |> MapSet.new() + end +end diff --git a/lib/trinity/receipts.ex b/lib/trinity/receipts.ex index 32b4a0e..c6425eb 100644 --- a/lib/trinity/receipts.ex +++ b/lib/trinity/receipts.ex @@ -32,6 +32,7 @@ defmodule Trinity.Receipts do @boot_key {__MODULE__, :boot_hash} @boot_scope "boot" + @policy_scope "policy" @doc "The scope of a session's chain." @spec session_scope(String.t()) :: String.t() @@ -41,6 +42,16 @@ defmodule Trinity.Receipts do @spec boot_scope() :: String.t() def boot_scope, do: @boot_scope + @doc """ + The chain for changes to standing authority (slice 042). + + A rule outlives the session that prompted it, so it does not belong in that session's chain: a + reader asking "what may this agent do without being asked, and since when" should not have to + walk every conversation to find out. Its own scope keeps the answer in one place. + """ + @spec policy_scope() :: String.t() + def policy_scope, do: @policy_scope + @doc "Appends a receipt to a scope, starting its writer if needed. See `ChainWriter.append/2`." @spec append(String.t(), map()) :: {:ok, Receipt.t()} | {:error, term()} def append(scope, attrs) when is_binary(scope) and is_map(attrs) do diff --git a/lib/trinity_web/live/permissions_live.ex b/lib/trinity_web/live/permissions_live.ex index 58dbfe2..7e10ba6 100644 --- a/lib/trinity_web/live/permissions_live.ex +++ b/lib/trinity_web/live/permissions_live.ex @@ -28,7 +28,12 @@ defmodule TrinityWeb.PermissionsLive do # Slice 029: tools held because a server changed their definition after the owner approved # them. They are here rather than on /mcp because this is the page Trinity asks the owner # things on, and a notice on a page visited only when adding a server is a notice missed. - drifting: Surface.drifting() + drifting: Surface.drifting(), + # Slice 042. These are decisions about the *population* of future asks, and they are rendered + # in their own sections precisely so they are read in a calm moment rather than beside a + # pending one. Nothing here reaches the approval card, which a census asserts. + proposals: Permissions.proposals(), + divergences: Permissions.divergences() ) end @@ -95,6 +100,31 @@ defmodule TrinityWeb.PermissionsLive do end end + # Slice 042: accepting a proposal writes an ordinary rule through the ordinary path. There is no + # second kind of rule, and the owner could have written this one by hand. + def handle_event("proposal_accept", %{"tool" => tool, "decision" => decision}, socket) do + case Permissions.put_rule(%{ + tool: tool, + pattern: "*", + decision: decision, + decided_by: "owner (from #{length(socket.assigns.proposals)} proposals)" + }) do + {:ok, rule} -> + receipt_proposal(rule, tool, decision, socket.assigns.proposals) + + {:noreply, + socket + |> put_flash( + :info, + gettext("Rule written. Trinity will stop asking about %{tool}.", tool: tool) + ) + |> load()} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, gettext("Could not write that rule."))} + end + end + def handle_event("drift_dismiss", %{"server" => server, "tool" => tool}, socket) do Surface.dismiss(server, tool) @@ -129,6 +159,30 @@ defmodule TrinityWeb.PermissionsLive do def handle_event("cancel", _params, socket), do: {:noreply, socket} + # Slice 042 AC6. A rule written from a proposal is a change to standing authority made by the + # owner, so it goes in the chain with the evidence it rested on: the count, and that a proposal is + # what prompted it. Without the count a reader cannot tell a rule the owner typed from one they + # accepted, and those are different acts. + defp receipt_proposal(rule, tool, decision, proposals) do + count = + case Enum.find(proposals, &(&1.tool == tool)) do + %{count: n} -> n + nil -> 0 + end + + Trinity.Receipts.append(Trinity.Receipts.policy_scope(), %{ + kind: "decision", + subject: %{"tool" => tool, "rule_id" => rule.id, "phase" => "rule"}, + decision: %{ + "outcome" => decision, + "basis" => "proposal", + "reason" => "accepted a proposal drawn from #{count} decisions that agreed" + }, + subject_ref: "rule:" <> tool, + meta: %{"decisions" => count, "pattern" => rule.pattern} + }) + end + @impl true def render(assigns) do ~H""" @@ -142,6 +196,60 @@ defmodule TrinityWeb.PermissionsLive do phx-hook="Shortcuts" class="mx-auto flex h-full max-w-4xl flex-col gap-6 overflow-y-auto px-4 py-6" > +
+

{gettext("Rules your decisions imply")}

+

+ {gettext( + "You have decided these the same way every time. Writing a rule stops Trinity asking again. Nothing here appears beside a pending request: a suggestion offered at the moment of deciding would change the decision." + )} +

+ +
+ {p.tool} + + {p.decision} + + + {gettext("every one of")} {p.count} {gettext("decisions")} + + + +
+
+ +
+

{gettext("Decisions that did not agree")}

+

+ {gettext( + "No rule is offered for these, because one would permit the case you refused. Shown so you can see where your own answers differ." + )} +

+ +
+

{gettext("Tool definitions that changed")}

diff --git a/mix.lock b/mix.lock index 9b0e691..c6bb41a 100644 --- a/mix.lock +++ b/mix.lock @@ -43,7 +43,7 @@ "jcs": {:hex, :jcs, "0.2.0", "e0524c23b576e8247f9f5f09d1b82cb3f92c7b132932b82d5d656461831c6c99", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f52e86571f56fab695682bf0ab7fd697768acb20de16b30809e9654d1ec1c9dd"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "jsv": {:hex, :jsv, "0.23.0", "db238039fc2e437bca3e226b09eacd72ebd588d6e014d0d4150330f6ca6762fa", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:idna, "~> 6.0 or ~> 7.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:texture, ">= 1.2.1", [hex: :texture, repo: "hexpm", optional: false]}], "hexpm", "3876f6ada437b3a7ec6214c9b942bd218f25d245b92a7970a034a7cf06dbe925"}, - "lazy_html": {:hex, :lazy_html, "0.1.12", "31a55ee622918fce988c94b06232227b42daa64e4eab14ac32081d0f3fd8db6f", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "8a0da594776caee58782c6f93b2abaa5bdb809daf8d43351a561f7de9dc2e2a8"}, + "lazy_html": {:hex, :lazy_html, "0.1.13", "860ea816f5bc7936d1ab5ccebc388f9d1031ba9c35ed81a4db9a1c3a50737704", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "9a8405d6785fe6f8423b86e0ec5f21806ef79941fe853eac3d14fbbb173c34e9"}, "llm_db": {:hex, :llm_db, "2026.9.4", "f56fdc8012477a0fa33247841f980c9a71e80408a5b69f63692ea238f46f8636", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}, {:zoi, "~> 0.10", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "196a162cfc8826746ee84eeb395dd3a17268bfff2154ba378799bc1511e722ce"}, "luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"}, "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, diff --git a/scripts/dev_proposals.sh b/scripts/dev_proposals.sh new file mode 100755 index 0000000..1656f96 --- /dev/null +++ b/scripts/dev_proposals.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# The rule proposals and the divergence view on /permissions, for slice 042's AC1 and AC4 +# screenshots. Same shape as the other dev run scripts. +# +# It seeds three tools: two the owner decided the same way every time, which become proposals, and +# one where a single refusal sits among the agreements, which becomes a divergence and is +# deliberately **not** proposed. The third is the point of the picture: a rule drawn from that +# history would permit the case the owner refused. +# +# PORT= is printed before any seeding and READY after it. +cd "$(dirname "$0")/.." +export MIX_ENV=test +exec systemd-run --user --scope -p MemoryMax=32G --quiet -- mix run --no-start --no-halt -e ' +repo = Application.get_env(:trinity, Trinity.Repo) |> Keyword.delete(:pool) |> Keyword.put(:database, "trinity_proposals.db") +Application.put_env(:trinity, Trinity.Repo, repo) +rrepo = Application.get_env(:trinity, Trinity.Repo.Receipts) |> Keyword.delete(:pool) |> Keyword.put(:database, "trinity_proposals_receipts.db") +Application.put_env(:trinity, Trinity.Repo.Receipts, rrepo) +endpoint = Application.get_env(:trinity, TrinityWeb.Endpoint) |> Keyword.put(:check_origin, false) +Application.put_env(:trinity, TrinityWeb.Endpoint, endpoint) +Application.put_env(:trinity, :mcp_boot, false) + +{:ok, _} = Application.ensure_all_started(:ecto_sql) +for r <- [Trinity.Repo, Trinity.Repo.Receipts] do + {:ok, pid} = r.start_link() + Ecto.Migrator.run(r, :up, all: true) + GenServer.stop(pid) +end +{:ok, _} = Application.ensure_all_started(:trinity) +Logger.configure(level: :warning) + +{:ok, {_, port}} = TrinityWeb.Endpoint.server_info(:http) +IO.puts("PORT=#{port}") + +alias Trinity.Permissions.Approval +alias Trinity.Repo + +{:ok, persona} = Trinity.Sessions.create_persona(%{name: "demo", soul: "demo"}) +{:ok, session} = Trinity.Sessions.create_session(%{persona_id: persona.id, title: "demo"}) + +decide = fn tool, status, n -> + for i <- 1..n do + Repo.insert!(%Approval{ + session_id: session.id, + tool: tool, + args: %{"path" => "/notes/#{i}.md"}, + risk: "write", + fingerprint: "fp-#{tool}-#{i}", + status: status, + decision: if(status == "allowed", do: "once", else: "deny"), + decided_at: DateTime.utc_now(), + expires_at: DateTime.add(DateTime.utc_now(), 86_400, :second) + }) + end +end + +# Two the owner has answered the same way every time. +decide.("fs_read", "allowed", 14) +decide.("session_search", "allowed", 8) + +# One where a single refusal sits among the agreements. No rule is offered for it. +decide.("fs_write", "allowed", 9) +decide.("fs_write", "denied", 1) + +IO.puts("READY") +' diff --git a/test/support/permissions/learner_on_approval_card.ex b/test/support/permissions/learner_on_approval_card.ex new file mode 100644 index 0000000..c6d1928 --- /dev/null +++ b/test/support/permissions/learner_on_approval_card.ex @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TestPermissions.LearnerOnApprovalCard do + @moduledoc """ + A planted violation for slice 042 AC2, and a realistic one. + + It does the tempting thing: takes a pending approval and annotates it with what the learner knows, + so the card could show "you have allowed this 11 times". That is the feature the connectome report + asked for and the research forbids, because a recommendation shown before a person forms their own + assessment moves the decision, and Trinity's permission model rests on that decision being + independent of what the agent wants. + + It reaches the learner through `Trinity.Permissions.proposals/1`, the public facade, because that + is the realistic mistake: `Trinity.Permissions.Learner` is not exported from its boundary, so a + direct reference to it does not compile and the boundary compiler is the first line here. The + census below is the second, for the way in that *is* legitimate. + + It is never called. Its only job is to be found by + `test/trinity/permissions/learner_census_test.exs`. If the census stops naming this file, the + census has stopped working. + """ + + alias Trinity.Permissions + + @doc "Annotates a pending approval with the learner's view, which is the thing that must not exist." + @spec hint(Trinity.Permissions.Approval.t()) :: String.t() | nil + def hint(%{tool: tool}) do + case Enum.find(Permissions.proposals(), &(&1.tool == tool)) do + nil -> nil + %{count: n, decision: d} -> "you have chosen to #{d} this #{n} times" + end + end +end diff --git a/test/trinity/permissions/learner_census_test.exs b/test/trinity/permissions/learner_census_test.exs new file mode 100644 index 0000000..e6cdb20 --- /dev/null +++ b/test/trinity/permissions/learner_census_test.exs @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.LearnerCensusTest do + @moduledoc """ + Slice 042 AC2: nothing the learner produces reaches a pending approval. + + **This census is the reason the slice has the shape it has.** The connectome research report asked + for a learned recommendation at the arbitration point; the anchoring, automation-bias and + approval-fatigue literature says that is the one place it must not go. A decision recorded in a + document is a decision somebody reverses in six months without noticing what it was for. This is + the same decision, written so the build holds it. + + It is not behind a setting, either. A flag that could put a recommendation back on the approval + card would be the harm with a delay. + """ + use ExUnit.Case, async: true + + # The surface a pending approval is rendered through. `approval_card/1` takes exactly two + # attributes, `approval` and `pattern`, which is what makes this checkable rather than hopeful. + @approval_path ["lib/trinity_web/components/approval_components.ex"] + @gate_path ["lib/trinity/permissions/gate.ex"] + @planted "test/support/permissions/learner_on_approval_card.ex" + + # Two layers, and this tests the second. `Trinity.Permissions.Learner` is not exported from its + # boundary, so a direct reference to it does not compile: the boundary compiler is the first line. + # What it cannot see is the public facade, `Permissions.proposals/1`, which anything may call. This + # census is about that call reaching the approval path. + defp mentions_learner?(source) do + source =~ "Permissions.Learner" or source =~ "proposals(" or source =~ "divergences(" + end + + defp population do + {out, 0} = System.cmd("git", ["ls-files" | @approval_path ++ @gate_path ++ [@planted]]) + String.split(out, "\n", trim: true) + end + + test "the population is what it claims to be" do + files = population() + assert @planted in files, "the planted violation is not tracked, so this census sees nothing" + assert hd(@approval_path) in files + assert hd(@gate_path) in files + end + + test "only the planted file lets the learner near a pending approval" do + offenders = for f <- population(), mentions_learner?(File.read!(f)), do: f + + assert offenders == [@planted], + "#{inspect(offenders -- [@planted])} puts the learner on the path that renders or " <> + "decides a pending approval. A recommendation shown before the owner forms their own " <> + "view moves the decision, and this project's permission model rests on it not being " <> + "moved" + end + + test "the planted file really does annotate an approval, or this census proves nothing" do + source = File.read!(@planted) + assert mentions_learner?(source) + assert source =~ "def hint" + assert source =~ "you have chosen to" + end + + describe "AC5: the learner reads and proposes, and writes nothing" do + test "it never writes a rule, a policy or the effect catalogue itself" do + source = File.read!("lib/trinity/permissions/learner.ex") + + # Call-shaped, with the parenthesis. The first version matched the bare name and flagged the + # module's own docstring, which names `put_rule/1` to say who does write a rule. A census that + # cannot tell a call from a sentence about a call trains people to reword their documentation. + for forbidden <- [ + "put_rule(", + "Repo.insert(", + "Repo.insert!(", + "Repo.update(", + "Repo.delete(", + "Catalog." + ] do + refute source =~ forbidden, + "the learner calls #{forbidden}. It may propose a rule the owner could have written " <> + "by hand; writing one is the permissions context's job and accepting one is the " <> + "owner's" + end + + assert source =~ "Repo.all(", + "the learner reads nothing, so this test is asserting the absence of writes in a " <> + "module that does not touch the database at all" + end + end +end diff --git a/test/trinity/permissions/learner_test.exs b/test/trinity/permissions/learner_test.exs new file mode 100644 index 0000000..d4a0133 --- /dev/null +++ b/test/trinity/permissions/learner_test.exs @@ -0,0 +1,120 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.LearnerTest do + @moduledoc """ + Slice 042 AC1 and AC3: what the owner's decisions imply, and the limit on what may be implied. + + AC3 is the one that matters. A proposal must never imply a permission broader than the decisions it + was drawn from, and unanimity is what makes that true by construction: eleven allows and one deny + would propose permitting the case the owner refused. + """ + use Trinity.DataCase, async: false + + alias Trinity.Permissions.{Approval, Learner} + alias Trinity.Repo + + defp decided!(tool, status, n) do + session = Trinity.Factory.session!() + + for i <- 1..n do + Repo.insert!(%Approval{ + session_id: session.id, + tool: tool, + args: %{"n" => i}, + risk: "write", + fingerprint: "fp-#{tool}-#{i}-#{System.unique_integer([:positive])}", + status: status, + decision: if(status == "allowed", do: "once", else: "deny"), + decided_at: DateTime.utc_now(), + expires_at: DateTime.add(DateTime.utc_now(), 3600, :second) + }) + end + end + + describe "AC1: a rule the decisions imply" do + test "a tool decided the same way enough times is proposed, with the count it is drawn from" do + decided!("fs_read", "allowed", 6) + + assert [%{tool: "fs_read", decision: "allow", count: 6} = p] = Learner.proposals() + assert p.first_seen + assert p.last_seen + end + + test "below the threshold nothing is proposed, because a habit is not yet a rule" do + decided!("fs_read", "allowed", 2) + assert [] = Learner.proposals(threshold: 5) + end + + test "a denial proposes a deny rule, not silence" do + decided!("shell", "denied", 7) + assert [%{tool: "shell", decision: "deny", count: 7}] = Learner.proposals() + end + + test "a tool a rule already covers is not proposed, because proposing what is already true is noise" do + decided!("fs_read", "allowed", 8) + + {:ok, rule} = + Trinity.Permissions.put_rule(%{tool: "fs_read", pattern: "*", decision: "allow"}) + + on_exit(fn -> Trinity.Permissions.revoke_rule(rule.id) end) + + assert [] = Learner.proposals() + end + + test "proposals come strongest first, so the most-evidenced is read first" do + decided!("fs_read", "allowed", 9) + decided!("fs_list", "allowed", 6) + + assert [%{tool: "fs_read"}, %{tool: "fs_list"}] = Learner.proposals() + end + end + + describe "AC3: a proposal can never be broader than its evidence" do + test "one dissent among many agreements proposes nothing at all" do + decided!("fs_write", "allowed", 11) + decided!("fs_write", "denied", 1) + + assert [] = Learner.proposals(), + "eleven allows and one deny proposed a rule. That rule would permit the case the " <> + "owner refused, which is the one thing a proposal may never do" + end + + test "the dissent becomes a divergence to look at instead of being discarded" do + decided!("fs_write", "allowed", 11) + decided!("fs_write", "denied", 1) + + assert [%{tool: "fs_write", counts: counts, total: 12}] = Learner.divergences() + assert counts["allowed"] == 11 + assert counts["denied"] == 1 + end + + test "a unanimous tool is not a divergence" do + decided!("fs_read", "allowed", 6) + assert [] = Learner.divergences() + end + + test "pending approvals are not decisions and are not counted" do + session = Trinity.Factory.session!() + + Repo.insert!(%Approval{ + session_id: session.id, + tool: "fs_read", + args: %{}, + risk: "read", + fingerprint: "pending-1", + status: "pending", + expires_at: DateTime.add(DateTime.utc_now(), 3600, :second) + }) + + decided!("fs_read", "allowed", 5) + + assert [%{count: 5}] = Learner.proposals(), + "a pending approval was counted as a decision, so the learner would propose from " <> + "questions nobody has answered" + end + end + + test "the threshold is configuration the code reads, not a constant in two places" do + assert is_integer(Learner.threshold()) and Learner.threshold() > 0 + end +end diff --git a/test/trinity_web/live/proposals_live_test.exs b/test/trinity_web/live/proposals_live_test.exs new file mode 100644 index 0000000..9a0f7af --- /dev/null +++ b/test/trinity_web/live/proposals_live_test.exs @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.ProposalsLiveTest do + @moduledoc """ + Slice 042 AC1 and AC4, as the owner sees them. + + The assertion that matters is not that a proposal renders. It is **where** it renders: in its own + section, read in a calm moment, and never beside a pending request. A recommendation offered at the + moment of deciding changes the decision, and this project's permission model rests on that decision + being the owner's own. + """ + use TrinityWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Trinity.Permissions + alias Trinity.Permissions.Approval + alias Trinity.Repo + + defp decided!(tool, status, n) do + session = Trinity.Factory.session!() + + for i <- 1..n do + Repo.insert!(%Approval{ + session_id: session.id, + tool: tool, + args: %{"n" => i}, + risk: "write", + fingerprint: "fp-#{tool}-#{i}-#{System.unique_integer([:positive])}", + status: status, + decision: if(status == "allowed", do: "once", else: "deny"), + decided_at: DateTime.utc_now(), + expires_at: DateTime.add(DateTime.utc_now(), 3600, :second) + }) + end + end + + test "with nothing decided neither section appears", %{conn: conn} do + {:ok, _live, html} = live(conn, ~p"/permissions") + refute html =~ "Rules your decisions imply" + refute html =~ "Decisions that did not agree" + end + + test "a unanimous history proposes a rule, with the count it rests on", %{conn: conn} do + decided!("fs_read", "allowed", 7) + + {:ok, _live, html} = live(conn, ~p"/permissions") + + assert html =~ "Rules your decisions imply" + assert html =~ "fs_read" + assert html =~ "7" + + assert html =~ "would change the decision" or html =~ "pending request", + "the section does not say why it is not beside the request, which is the whole reason " <> + "it is a separate section" + end + + test "accepting writes an ordinary rule and the proposal goes away", %{conn: conn} do + decided!("fs_read", "allowed", 7) + assert Permissions.list_rules() == [] + + {:ok, live, _html} = live(conn, ~p"/permissions") + + html = + live + |> element(~s{#proposal-fs_read button[phx-click="proposal_accept"]}) + |> render_click() + + assert [rule] = Permissions.list_rules() + assert rule.tool == "fs_read" + assert rule.decision == "allow" + assert rule.pattern == "*" + + refute html =~ "Rules your decisions imply", + "the proposal survived being accepted, so the owner would be asked to write it twice" + + # AC6. A rule outlives the session that prompted it, so it is receipted in its own scope with + # the evidence it rested on. Without the count a reader cannot tell a rule the owner typed from + # one they accepted, and those are different acts. + scope = Trinity.Receipts.policy_scope() + on_exit(fn -> Trinity.Receipts.stop_writer(scope) end) + + assert [receipt] = Trinity.Receipts.list(scope) + assert receipt.kind == "decision" + assert receipt.subject["tool"] == "fs_read" + assert receipt.meta["decisions"] == 7 + end + + test "a disagreement is reported rather than proposed", %{conn: conn} do + decided!("fs_write", "allowed", 9) + decided!("fs_write", "denied", 1) + + {:ok, _live, html} = live(conn, ~p"/permissions") + + assert html =~ "Decisions that did not agree" + assert html =~ "fs_write" + + refute html =~ "Rules your decisions imply", + "a rule was proposed from a history containing a refusal, which would permit the case " <> + "the owner refused" + end +end