+ {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." + )} +
+ ++ {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." + )} +
+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