diff --git a/REUSE.toml b/REUSE.toml index 44888a2..770def4 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -11,7 +11,7 @@ SPDX-PackageDownloadLocation = "https://github.com/ScriptKittyOS/Trinity" # the gap is recorded rather than glossed. [[annotations]] -path = ["mix.lock", "src-tauri/Cargo.lock", "**/*.md", "**/*.json", "**/*.tsv", "**/*.cff", "**/*.toml", ".tool-versions", ".gitignore", ".sobelow-skips", ".sobelow-skips.reasons", "priv/name_digests.txt", ".formatter.exs", "slices/**/proof/**"] +path = ["mix.lock", "src-tauri/Cargo.lock", "**/*.md", "**/*.json", "**/*.tsv", "**/*.cff", "**/*.toml", ".tool-versions", ".gitignore", ".sobelow-skips", ".sobelow-skips.reasons", "priv/name_digests.txt", ".formatter.exs", "slices/**/proof/**", "test/support/*.txt"] precedence = "aggregate" SPDX-FileCopyrightText = "Sudo Apt Holdings LLC" SPDX-License-Identifier = "Apache-2.0" diff --git a/ROADMAP.md b/ROADMAP.md index 6ebf70e..4e878da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,7 +38,7 @@ standards register names the rows that ask for them. | 012 | Session process + agent loop (gen_statem, DynamicSupervisor, rehydration) | 1 Core loop | L | 010, 011 | approved | | 013 | LiveView chat UI with streaming | 1 Core loop | M | 012 | approved | | 020 | Tool protocol + registry | 2 Tools | M | 012 | approved | -| 021 | Permission gate + approval UI (M2 fingerprint-bound, M7) | 2 Tools | M | 020, 013 | planned | +| 021 | Permission gate + approval UI (M2 fingerprint-bound, M7) | 2 Tools | M | 020, 013 | done | | 022 | Core tools: filesystem, web fetch/search, shell (MuonTrap) | 2 Tools | L | 021 | planned | | 023 | Context compaction + session lineage | 2 Tools | M | 012 | planned | | 024 | Effect catalog, authority selection (`TRINITY_AUTHORITY`), local receipts | 2 Tools | L | 021, 022 | planned | diff --git a/VERSIONS.md b/VERSIONS.md index 4c25af3..9bd93b2 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -136,6 +136,7 @@ never pin a version hex marks as retired or vulnerable. | `phoenix_streamdown` | not used (measured at Slice 013, 2026-09-20) | ๐Ÿ” not a single package | Streaming markdown renderer for LiveView. Measured at Slice 013: 1.0.0-beta.4 (2026-05-03) was still the latest release with no stable behind it, and this file's own rule forbids a pre-release. It is 801 lines over `mdex`, whose own `streaming: true` option completes fragments; the rest is a rendering rule Slice 013 keeps anyway. The row stays so the decision is visible where a reader would look for the package. | | `mdex` | ~> 0.13 | โœ… in `mix.lock` | Markdown renderer for the chat (`TrinityWeb.Markdown`), streaming fragments completed by its `streaming: true` option, raw HTML omitted (`unsafe: false`) and the default sanitizer on top. Added at Slice 013 after the measurement in its NOTES.md: `earmark` 1.4.49 is retired on hex with an open XSS advisory (EEF-CVE-2026-48591), which the gate refuses. โš ๏ธ A Rust NIF (`mdex_native`): the first in the bundle. Measured at Slice 013 (NOTES finding 13): neither precompiled artifact loads in Burrito's musl ERTS on Linux, so the linux package builds it from source for musl with Zig as the linker (`rustler` below, `scripts/zig-cc-musl`, the three settings in config/config.exs); macOS and Windows load the precompiled artifact. The `--smoke` path prints whether it rendered, and the `package` workflow reads that line on every target. | | `jsv` | ~> 0.23 | โœ… in `mix.lock` | JSON Schema (2020-12) validation of tool arguments in `Trinity.Tools.Schema`, with `cast: false` so a malformed call is refused and never repaired (docs/07). Was transitive through req_llm; direct since Slice 020 because a module of ours calls it (ADR-0009: Trinity's own tool behaviour, jsv for its schemas). | +| `jcs` | ~> 0.2 | โœ… in `mix.lock` | RFC 8785 canonical JSON, under every approval fingerprint (`Trinity.Permissions.Fingerprint`, Slice 021) and, at 024, under the receipts' signed payload. Chosen at Slice 021: it matches the RFC's own example vector byte for byte on this OTP, and `test/trinity/permissions/fingerprint_test.exs` keeps that vector so a release that stops matching fails the gate. โš ๏ธ Pre-1.0, released 2025-03-31 with nothing since (R11's trigger). The alternative, `rfc8785` 1.0.0, refuses OTP 28 and waits on the OTP pin. | | `rustler` | ~> 0.38 | โœ… in `mix.lock` | Build time only (`runtime: false`): what `rustler_precompiled` needs to compile `mdex_native` from source when `MDEX_NATIVE_BUILD=1`, which the linux package sets (owner decision 2026-09-20, Slice 013 NOTES finding 14). Nothing in the tree calls it. | ### Dev and quality diff --git a/config/test.exs b/config/test.exs index 8e373fa..3fbb856 100644 --- a/config/test.exs +++ b/config/test.exs @@ -14,11 +14,15 @@ config :trinity, :tools, Trinity.TestTools.Echo, Trinity.TestTools.Sleep, Trinity.TestTools.Crash, - Trinity.TestTools.Big + Trinity.TestTools.Big, + Trinity.TestTools.WriteNote ], - toolsets: %{core: ["echo", "sleep", "crash", "big"]}, + toolsets: %{core: ["echo", "sleep", "crash", "big", "write_note"]}, timeout_ms: 2_000 +# Slice 021: requests expire fast enough for AC6 to watch, and a session grant lasts an hour. +config :trinity, :permissions, expiry_ms: 1_000, session_grant_ms: 3_600_000 + # Slice 012: sessions hibernate and stop quickly in tests so AC8 is observable in seconds. config :trinity, :sessions, idle_hibernate_ms: 200, idle_stop_ms: 60_000 diff --git a/coverage.tsv b/coverage.tsv index fe89ee2..e2833d3 100644 --- a/coverage.tsv +++ b/coverage.tsv @@ -6,3 +6,4 @@ slice_id percent sha date 012 60.82 019d9cf 2026-09-20 013 64.41 080c543 2026-09-20 020 67.18 8a5b7ae 2026-09-20 +021 72.45 1f3727f 2026-09-20 diff --git a/docs/01-architecture.md b/docs/01-architecture.md index 1a223ab..b32bbdf 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -26,7 +26,8 @@ Trinity.Application โ”œโ”€โ”€ Trinity.Tools.Supervisor # Slice 020, as built: Trinity.Tools.TaskSupervisor (every tool โ”‚ โ”‚ # call of a turn runs under it) and Trinity.Tools.Registry โ”‚ โ”‚ # (GenServer over ETS). 022 adds the stateful runtimes beside them -โ”œโ”€โ”€ Trinity.Permissions.Gate # approval requests + allowlist cache. Slice 021 +โ”œโ”€โ”€ Trinity.Permissions.Gate # Slice 021, as built: approval requests (rows, then broadcasts), +โ”‚ # decisions, expiries; pending rows reloaded with their timers โ”œโ”€โ”€ Trinity.Receipts.Supervisor # Slice 024 โ”‚ โ””โ”€โ”€ Trinity.Receipts.ChainWriter (one per chain_scope, :unique in Trinity.Registry; ADR-0013) โ”‚ # serialises append per scope. prev_hash -> receipt_hash is a read-then-write, so diff --git a/docs/05-data-model.md b/docs/05-data-model.md index 34271e6..ad9911e 100644 --- a/docs/05-data-model.md +++ b/docs/05-data-model.md @@ -78,17 +78,28 @@ Filesystem is canonical for content; DB is the index (rebuildable via `mix trini ### skill_changes (Slice 041) Staged proposals by the agent: `skill_id`, `diff`, `rationale`, `status`, `decided_by`, `decided_at`. -### tool_permissions (Slice 021) +### tool_permissions (Slice 021, as built) | column | type | notes | |---|---|---| | tool | string | | -| pattern | string | glob/regex on args (e.g. shell command prefix, path) | +| pattern | string | `*`, `key=glob` (`*` in a segment, `**` across, `?` one character; a trailing `*` is a prefix), `fp:` (a session grant bound to a fingerprint), `re:` (hand-edited rows only) | | decision | string | "allow" \| "deny" \| "ask" | | scope | string | "global" \| "session:" \| "persona:" | -| expires_at | nullable | "allow for this session" | +| expires_at | nullable | set on "allow for this session" grants | +| decided_by | string, nullable | who wrote it ("liveview" from the card) | -### approvals (Slice 021) -Pending/decided approval requests: `session_id`, `tool`, `args`, `risk`, `status`, `decided_at`, `channel`. +### approvals (Slice 021, as built) +One row per request, the audit trail this slice owns; slice 024 reads it for decision receipts. +| column | type | notes | +|---|---|---| +| session_id | fk sessions | | +| tool, args, risk | string, map, string | the call and its tier at request time | +| fingerprint | string | `sha256(rfc8785({tool, args, scope, cwd, canonicalization_version}))`, re-derived at execution | +| status | string | "pending" \| "allowed" \| "denied" \| "expired" | +| decision | string, nullable | "once" \| "session" \| "always" \| "deny" | +| decided_at, decided_by | timestamp, string | every decided row has both ("expiry" is a decider) | +| consumed_at | nullable | a "once" allowance or a denial is spent by the execution that reads it | +| expires_at | timestamp | pending past it becomes "expired" with decision "deny" | ### tasks (Slice 050) | column | type | notes | diff --git a/docs/07-security-model.md b/docs/07-security-model.md index f105ea8..0e5a43e 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -39,10 +39,20 @@ system prompt states that instructions inside untrusted blocks are data, not com Every `Trinity.Tools.Tool` declares `effect/0` (`:none | :artifact | :catalog`) and the gate derives risk from the tool **name only** via `Trinity.Permissions.tier/1` (`:read | :write | :exec | :network | :destructive`; unmapped โ†’ ask). Name-only is deliberate: it makes the tier a code-owned function of a value no caller can influence. That holds only while the namespace is closed. Tools registered at runtime carry names chosen elsewhere, by an MCP server or a skill author, so **every dynamic tool is namespaced before the tier lookup**: `mcp::`, `skill:`. Core tool names are reserved and cannot be claimed. Without this a hostile server naming its tool `fs_read` inherits the `:read` tier and its allow-by-default policy, and the unmapped-goes-to-ask fallback does not catch it, because the name is mapped. -`Trinity.Permissions.decide(session, tool, args)` consults, in order: session grants โ†’ persona policy โ†’ global -`tool_permissions` โ†’ default policy (`:read` allow, `:network` allow, `:write` ask, `:exec` ask, `:destructive` ask). -An `:ask` suspends the Session in `approval_wait`, broadcasts to `approvals:`, and resumes on decision. -Decisions are recorded (`approvals` table) and receipted. Approvals bind the canonical fingerprint of `(tool, args, scope, cwd, canonicalization_version)` and are re-derived at execution; "allow for this session" binds that fingerprint with an expiry; "always allow" is a rule, recorded as such. +`Trinity.Permissions.decide(session, tool, args, opts)` consults, in order: session grants โ†’ the newest unspent +decision for this fingerprint (an "allow once" or a denial, each spent by the execution that reads it) โ†’ +persona policy โ†’ global `tool_permissions` โ†’ default policy (`:read` allow, `:network` allow, `:write` ask, +`:exec` ask, `:destructive` ask; an unmapped name asks). +An `:ask` suspends the Session in `approval_wait`, broadcasts to `approvals:` (and `approvals:all`), and +resumes on decision. Decisions are recorded (`approvals` table) and receipted (024). Approvals bind the canonical +fingerprint of `(tool, args, scope, cwd, canonicalization_version)` and are re-derived at execution; "allow for +this session" binds that fingerprint with an expiry; "always allow" is a rule, recorded as such. + +As built at slice 021: the tier table is written once, by the registry, from the `risk/0` of the modules +`config :trinity, :tools` names, so a tier comes from code and config and never from a runtime registration; the +runner asks the policy at execution and the Session never pre-checks, so the decision that runs a call is the one +made against the arguments actually passed; a request left undecided expires into a denial decided by +`"expiry"`; the card's buttons and the `/permissions` page call `decide_request/3` and nothing else. ## Shell (Slice 022) diff --git a/lib/trinity.ex b/lib/trinity.ex index 4c021d3..b834b0d 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -24,6 +24,8 @@ defmodule Trinity do LLM, Tools, Permissions, + Permissions.Approval, + Permissions.Rule, Effects.Catalog ] ++ if(Mix.env() == :test, do: [DataCase, NetworkGuard, Factory], else: []) diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index d38cfec..26ad11a 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -40,6 +40,8 @@ defmodule Trinity.Application do # Slice 020: the tool registry and the task supervisor tool calls run under, before # the sessions that call them. Trinity.Tools.Supervisor, + # Slice 021: approval requests and their decisions, with pending rows reloaded. + Trinity.Permissions.Gate, Trinity.Sessions.Supervisor, # Start to serve requests, typically the last entry TrinityWeb.Endpoint diff --git a/lib/trinity/permissions.ex b/lib/trinity/permissions.ex index 845a584..8f450f3 100644 --- a/lib/trinity/permissions.ex +++ b/lib/trinity/permissions.ex @@ -2,54 +2,132 @@ # SPDX-License-Identifier: Apache-2.0 defmodule Trinity.Permissions do @moduledoc """ - The permission gate's door. Slice 020 ships the shape; slice 021 the policy, the grants, the - approvals and the `approval_wait` path. - - `tier/1` is a function of the tool name alone (docs/07), read from a module attribute this - module owns: the core tool names 022 adds carry their tiers here, and any other name, a - namespaced dynamic tool included, is `:ask`. The map is code, not config, and it reads no - registry, so nothing a caller passes and nothing registered at runtime can move a tier. - `decide/3` consults the policy implementation in force (`config :trinity, - :permissions_policy`, default `Trinity.Permissions.Policy.Default`, which allows everything - until 021), and the runner calls it exactly once per tool call. + The permission gate (docs/07). Slice 020 shipped the shape; slice 021 the policy, the + grants, the approvals and their audit. + + **The tier is a function of the tool name alone.** Core tools hand their declared risk to + this module when the registry admits them (`put_core_tiers/1`, the one writer, called from + the registry's start with the modules named in config, never for a dynamic tool); `tier/1` + reads that table by name and answers `:ask` for any other name, a namespaced dynamic tool + included. Nothing a caller passes and nothing registered at runtime can move a tier. + + **A decision is data.** `decide/4` consults the policy in force (`Policy.Layered` by + default; a test may put a mock in `config :trinity, :permissions_policy`). An `:ask` + becomes an `approvals` row through the `Gate`, broadcast on `approvals:` and + `approvals:all` after it is written; the UI's only power is `decide_request/3`, which + records the decision, writes the grant or the rule it implies, and broadcasts. An approval + binds a fingerprint (`Trinity.Permissions.Fingerprint`) re-derived at execution. """ - use Boundary, deps: [Trinity], exports: [Policy] + use Boundary, deps: [Trinity], exports: [Policy, Approval, Rule, Fingerprint, Gate] - # Core tool names and their tiers. Empty at slice 020: no core tool lives in lib/ yet; 022 - # adds fs_read, fs_write, web_fetch, shell and the rest here, beside their modules. - @tiers %{} + alias Trinity.Permissions.{Approval, Fingerprint, Gate, Policy, Rule, Store} @type tier :: :read | :write | :exec | :network | :destructive | :ask @type decision :: :allow | :deny | :ask + @type request_decision :: :once | :session | :always | :deny - defmodule Policy do - @moduledoc "What decides a call. Slice 021 implements the layered policy; the default allows." - @callback decide(session_id :: String.t() | nil, tool :: String.t(), args :: map()) :: - Trinity.Permissions.decision() + @tiers_key {__MODULE__, :core_tiers} + @tier_values [:read, :write, :exec, :network, :destructive] - defmodule Default do - @moduledoc false - @behaviour Trinity.Permissions.Policy - @impl true - def decide(_session_id, _tool, _args), do: :allow - end - end + ## Tiers - @doc "The risk tier for a name: a mapped core name's tier, else `:ask`." + @doc "The risk tier for a name: a core tool's declared risk, else `:ask`." @spec tier(String.t()) :: tier() - def tier(name) when is_binary(name), do: Map.get(tiers(), name, :ask) + def tier(name) when is_binary(name), do: Map.get(core_tiers(), name, :ask) - @doc "The mapped core names, for the census." + @doc "The core names with a tier, for the census." @spec mapped_names() :: [String.t()] - def mapped_names, do: Map.keys(tiers()) + def mapped_names, do: core_tiers() |> Map.keys() |> Enum.sort() + + @doc """ + Records the core tools' tiers, name to risk, replacing the table. The registry is the one + caller, at its start, with the modules `config :trinity, :tools` names; a tier outside the + five is refused. + """ + @spec put_core_tiers(%{String.t() => tier()}) :: :ok + def put_core_tiers(tiers) when is_map(tiers) do + unless Enum.all?(tiers, fn {n, t} -> is_binary(n) and t in @tier_values end) do + raise ArgumentError, "core tiers must map names to one of #{inspect(@tier_values)}" + end + + :persistent_term.put(@tiers_key, tiers) + end + + defp core_tiers, do: :persistent_term.get(@tiers_key, %{}) + + ## Decisions + + @doc """ + The decision for one call, from the policy in force. `opts`: `persona:` (the row, for its + `settings["permissions"]`), `cwd:` (bound into the fingerprint). + """ + @spec decide(String.t() | nil, String.t(), map(), keyword()) :: decision() + def decide(session_id, tool, args, opts \\ []), do: impl().decide(session_id, tool, args, opts) + + @doc "The fingerprint of a call as this session would bind it." + @spec fingerprint(String.t() | nil, String.t(), map(), String.t() | nil) :: String.t() + def fingerprint(session_id, tool, args, cwd), + do: Fingerprint.of(tool, args, scope(session_id), cwd) + + @doc "The scope string a session's approvals and grants carry." + @spec scope(String.t() | nil) :: String.t() + def scope(nil), do: "session:none" + def scope(session_id), do: "session:" <> session_id + + defp impl, do: Application.get_env(:trinity, :permissions_policy, Policy.Layered) + + ## Requests and their decisions + + @doc "Creates a pending approval for a call (a row, then a broadcast) and returns it." + @spec request_approval(String.t(), String.t(), map(), keyword()) :: + {:ok, Approval.t()} | {:error, term()} + def request_approval(session_id, tool, args, opts \\ []), + do: Gate.request(session_id, tool, args, opts) + + @doc """ + Decides a pending request: `:once` allows this fingerprint one execution, `:session` grants + it for the session (a `tool_permissions` row scoped to it, with an expiry), `:always` writes + a global rule with `opts[:pattern]` (the pattern the user confirmed; `*` by default), `:deny` + denies. `opts[:by]` names the decider (`"liveview"` by default). Everything the UI does + goes through here, so a button carries no authority of its own (M7). + """ + @spec decide_request(String.t(), request_decision(), keyword()) :: + {:ok, Approval.t()} | {:error, term()} + def decide_request(id, decision, opts \\ []), do: Gate.decide(id, decision, opts) + + @doc "A pending request by id." + @spec get_approval(String.t()) :: Approval.t() | nil + def get_approval(id), do: Store.get_approval(id) + + @doc "Pending requests, oldest first; for one session or all." + @spec pending(String.t() | :all) :: [Approval.t()] + def pending(:all), do: Store.pending() + def pending(session_id), do: Store.pending_for(session_id) + + @doc "Approvals, newest first (`session_id:`, `limit:`)." + @spec list_approvals(keyword()) :: [Approval.t()] + def list_approvals(opts \\ []), do: Store.list_approvals(opts) + + @doc "Rules and grants, newest first (`scope:`)." + @spec list_rules(keyword()) :: [Rule.t()] + def list_rules(opts \\ []), do: Store.list_rules(opts) + + @doc "Writes a rule by hand (the settings path); the UI's always-allow goes through `decide_request/3`." + @spec put_rule(map()) :: {:ok, Rule.t()} | {:error, Ecto.Changeset.t()} + def put_rule(attrs), do: Store.insert_rule(attrs) + + @doc "Removes a rule or grant." + @spec revoke_rule(String.t()) :: :ok | {:error, :not_found} + def revoke_rule(id), do: Store.delete_rule(id) - # Through a function so an empty map at this slice reads as a map to the type checker, - # not as a constant it can fold to :ask. - defp tiers, do: Map.new(@tiers) + ## Topics - @doc "The decision for one call, from the policy in force." - @spec decide(String.t() | nil, String.t(), map()) :: decision() - def decide(session_id, tool, args), do: impl().decide(session_id, tool, args) + @doc "The PubSub topic of a session's approvals, or of all of them." + @spec topic(String.t() | :all) :: String.t() + def topic(:all), do: "approvals:all" + def topic(session_id), do: "approvals:" <> session_id - defp impl, do: Application.get_env(:trinity, :permissions_policy, Policy.Default) + @doc "Subscribes the caller to `{:approval, :requested | :decided, %Approval{}}` for a session or all." + @spec subscribe(String.t() | :all) :: :ok | {:error, term()} + def subscribe(which), do: Phoenix.PubSub.subscribe(Trinity.PubSub, topic(which)) end diff --git a/lib/trinity/permissions/approval.ex b/lib/trinity/permissions/approval.ex new file mode 100644 index 0000000..6210f70 --- /dev/null +++ b/lib/trinity/permissions/approval.ex @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.Approval do + @moduledoc """ + A row of `approvals` (slice 021): one request for one call, with the fingerprint it binds, + and its decision. The audit trail this slice owns: every decision has `decided_at` and + `decided_by`; slice 024 reads these rows when it builds decision receipts and writes none + of them. + """ + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, Trinity.UUID, autogenerate: true} + @foreign_key_type Trinity.UUID + @timestamps_opts [type: :utc_datetime_usec] + + @statuses ~w(pending allowed denied expired) + @decisions ~w(once session always deny) + + @type t :: %__MODULE__{} + + schema "approvals" do + field :tool, :string + field :args, :map, default: %{} + field :risk, :string + field :fingerprint, :string + field :status, :string, default: "pending" + field :decision, :string + field :decided_at, :utc_datetime_usec + field :decided_by, :string + field :consumed_at, :utc_datetime_usec + field :expires_at, :utc_datetime_usec + belongs_to :session, Trinity.Sessions.SessionRow + timestamps() + end + + @doc "The closed vocabularies." + @spec statuses() :: [String.t()] + def statuses, do: @statuses + @spec decisions() :: [String.t()] + def decisions, do: @decisions + + @doc false + @spec request_changeset(t(), map()) :: Ecto.Changeset.t() + def request_changeset(approval, attrs) do + approval + |> cast(attrs, [:session_id, :tool, :args, :risk, :fingerprint, :expires_at]) + |> validate_required([:session_id, :tool, :risk, :fingerprint, :expires_at]) + |> foreign_key_constraint(:session_id) + end + + @doc false + @spec decide_changeset(t(), map()) :: Ecto.Changeset.t() + def decide_changeset(approval, attrs) do + approval + |> cast(attrs, [:status, :decision, :decided_at, :decided_by, :consumed_at]) + |> validate_required([:status, :decision, :decided_at, :decided_by]) + |> validate_inclusion(:status, @statuses) + |> validate_inclusion(:decision, @decisions) + end +end diff --git a/lib/trinity/permissions/fingerprint.ex b/lib/trinity/permissions/fingerprint.ex new file mode 100644 index 0000000..f36e802 --- /dev/null +++ b/lib/trinity/permissions/fingerprint.ex @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.Fingerprint do + @moduledoc """ + The fingerprint an approval binds (docs/07, M2): SHA-256, hex, over the RFC 8785 canonical + form of `{tool, args, scope, cwd, canonicalization_version}`. Re-derived from the arguments + actually passed at execution; a divergence denies. Slice 021. + + `canonicalization_version` is 1 and is part of the digest, so a change of scheme is a new + version and never a silent difference in old fingerprints. + """ + + @version 1 + + @doc "The canonicalization version bound into every fingerprint." + @spec version() :: pos_integer() + def version, do: @version + + @doc "RFC 8785 canonical JSON of a term (maps with string keys, lists, strings, numbers, booleans, nil)." + @spec canonical(term()) :: String.t() + def canonical(term), do: Jcs.encode(term) + + @doc "The fingerprint of a call in a scope and working directory." + @spec of(String.t(), map(), String.t(), String.t() | nil) :: String.t() + def of(tool, args, scope, cwd) when is_binary(tool) and is_map(args) and is_binary(scope) do + payload = %{ + "tool" => tool, + "args" => args, + "scope" => scope, + "cwd" => cwd, + "canonicalization_version" => @version + } + + :crypto.hash(:sha256, canonical(payload)) |> Base.encode16(case: :lower) + end +end diff --git a/lib/trinity/permissions/gate.ex b/lib/trinity/permissions/gate.ex new file mode 100644 index 0000000..8fdad5f --- /dev/null +++ b/lib/trinity/permissions/gate.ex @@ -0,0 +1,190 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.Gate do + @moduledoc """ + The approval requests and their decisions. Slice 021. One GenServer in the application + tree: a request is a row, then a broadcast on `approvals:` and `approvals:all`, + then a timer; a decision is the row updated, the grant or rule it implies written, then a + broadcast; an expiry is a denial decided by `"expiry"`. Pending rows are reloaded at init + with their timers, so a restart of this process or of a Session loses no request (AC8). + """ + use GenServer + + require Logger + + alias Trinity.Permissions + alias Trinity.Permissions.{Approval, Store} + + @default_expiry_ms 600_000 + @default_session_grant_ms 86_400_000 + + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + + @doc "Creates a pending request; `opts`: `cwd:`, `risk:` (the tier by default)." + @spec request(String.t(), String.t(), map(), keyword()) :: + {:ok, Approval.t()} | {:error, term()} + def request(session_id, tool, args, opts \\ []), + do: GenServer.call(__MODULE__, {:request, session_id, tool, args, opts}) + + @doc "Decides a pending request (see `Trinity.Permissions.decide_request/3`)." + @spec decide(String.t(), Permissions.request_decision(), keyword()) :: + {:ok, Approval.t()} | {:error, term()} + def decide(id, decision, opts \\ []) when decision in [:once, :session, :always, :deny], + do: GenServer.call(__MODULE__, {:decide, id, decision, opts}) + + @doc "The configured expiry of a request, milliseconds." + @spec expiry_ms() :: pos_integer() + def expiry_ms, do: config(:expiry_ms, @default_expiry_ms) + + defp config(key, default), + do: Application.get_env(:trinity, :permissions, []) |> Keyword.get(key, default) + + ## GenServer + + # The pending rows are reloaded after init returns, and a database that cannot answer (no + # table yet: the postgres CI job boots the application before it migrates, run 35528824468) + # is a warning, not a boot failure: the chat starts, and a request made before the table + # exists fails on its own insert with a reason. + @impl true + def init(_opts), do: {:ok, %{timers: %{}}, {:continue, :reload}} + + @impl true + def handle_continue(:reload, state) do + timers = + try do + Map.new(Store.pending(), fn a -> {a.id, arm(a)} end) + rescue + e -> + Logger.warning( + "permissions gate: pending approvals not reloaded: #{Exception.message(e)}" + ) + + %{} + end + + {:noreply, %{state | timers: timers}} + end + + @impl true + def handle_call({:request, session_id, tool, args, opts}, _from, state) do + now = DateTime.utc_now() + + attrs = %{ + session_id: session_id, + tool: tool, + args: args, + risk: Atom.to_string(Keyword.get(opts, :risk, Permissions.tier(tool))), + fingerprint: Permissions.fingerprint(session_id, tool, args, Keyword.get(opts, :cwd)), + expires_at: DateTime.add(now, expiry_ms(), :millisecond) + } + + case Store.insert_approval(attrs) do + {:ok, approval} -> + broadcast(:requested, approval) + {:reply, {:ok, approval}, put_in(state.timers[approval.id], arm(approval))} + + {:error, _} = error -> + {:reply, error, state} + end + end + + def handle_call({:decide, id, decision, opts}, _from, state) do + case Store.get_approval(id) do + %Approval{status: "pending"} = approval -> + {reply, state} = apply_decision(approval, decision, opts, state) + {:reply, reply, state} + + %Approval{status: status} -> + {:reply, {:error, {:already_decided, status}}, state} + + nil -> + {:reply, {:error, :not_found}, state} + end + end + + @impl true + def handle_info({:expire, id}, state) do + state = + case Store.get_approval(id) do + %Approval{status: "pending"} = approval -> + {_, state} = apply_decision(approval, :deny, [by: "expiry", status: "expired"], state) + state + + _ -> + state + end + + {:noreply, %{state | timers: Map.delete(state.timers, id)}} + end + + ## The decision, as data + + defp apply_decision(approval, decision, opts, state) do + now = DateTime.utc_now() + by = Keyword.get(opts, :by, "liveview") + + status = + Keyword.get(opts, :status, if(decision == :deny, do: "denied", else: "allowed")) + + with :ok <- side_effect(approval, decision, opts, now, by), + {:ok, decided} <- + Store.update_approval(approval, %{ + status: status, + decision: Atom.to_string(decision), + decided_at: now, + decided_by: by + }) do + broadcast(:decided, decided) + cancel(state.timers[approval.id]) + {{:ok, decided}, %{state | timers: Map.delete(state.timers, approval.id)}} + else + {:error, _} = error -> {error, state} + end + end + + # "Allow for this session" is a grant bound to the fingerprint with an expiry; "always + # allow" is a global rule with the pattern the user confirmed. Once and deny write no rule. + defp side_effect(approval, :session, _opts, now, by) do + Store.insert_rule(%{ + tool: approval.tool, + pattern: "fp:" <> approval.fingerprint, + decision: "allow", + scope: Permissions.scope(approval.session_id), + expires_at: + DateTime.add(now, config(:session_grant_ms, @default_session_grant_ms), :millisecond), + decided_by: by + }) + |> ok() + end + + defp side_effect(approval, :always, opts, _now, by) do + Store.insert_rule(%{ + tool: approval.tool, + pattern: Keyword.get(opts, :pattern, "*"), + decision: "allow", + scope: "global", + decided_by: by + }) + |> ok() + end + + defp side_effect(_approval, _decision, _opts, _now, _by), do: :ok + + defp ok({:ok, _}), do: :ok + defp ok({:error, _} = error), do: error + + defp arm(%Approval{id: id, expires_at: at}) do + ms = max(DateTime.diff(at, DateTime.utc_now(), :millisecond), 0) + Process.send_after(self(), {:expire, id}, ms) + end + + defp cancel(nil), do: :ok + defp cancel(ref), do: Process.cancel_timer(ref) + + defp broadcast(kind, %Approval{session_id: session_id} = approval) do + message = {:approval, kind, approval} + Phoenix.PubSub.broadcast(Trinity.PubSub, Permissions.topic(session_id), message) + Phoenix.PubSub.broadcast(Trinity.PubSub, Permissions.topic(:all), message) + end +end diff --git a/lib/trinity/permissions/policy.ex b/lib/trinity/permissions/policy.ex new file mode 100644 index 0000000..fc45937 --- /dev/null +++ b/lib/trinity/permissions/policy.ex @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.Policy do + @moduledoc """ + What decides a call. The implementation in force is `config :trinity, :permissions_policy` + (`Trinity.Permissions.Policy.Layered` by default); `Default` allows everything and exists + for tests that want no gate. + """ + + @doc "The decision for a call. `opts`: `persona:`, `cwd:`." + @callback decide( + session_id :: String.t() | nil, + tool :: String.t(), + args :: map(), + opts :: keyword() + ) :: Trinity.Permissions.decision() + + defmodule Default do + @moduledoc "Allows everything. Slice 020's stub, kept for tests that want no gate." + @behaviour Trinity.Permissions.Policy + @impl true + def decide(_session_id, _tool, _args, _opts), do: :allow + end +end diff --git a/lib/trinity/permissions/policy/layered.ex b/lib/trinity/permissions/policy/layered.ex new file mode 100644 index 0000000..5d2761b --- /dev/null +++ b/lib/trinity/permissions/policy/layered.ex @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.Policy.Layered do + @moduledoc """ + The layered policy docs/07 specifies, in its order. Slice 021. + + 1. Session grants: `tool_permissions` rows scoped `session:` whose fingerprint pattern + is this call's fingerprint and whose expiry has not passed ("allow for this session"). + 2. The newest decided approval for this fingerprint in this session, if not yet consumed: + "allow once" allows and is consumed by the call; a denial denies and is consumed too, so + the same call asks again next time rather than staying refused. + 3. The persona's policy: `settings["permissions"]`, tool name to decision. + 4. Global rules: `tool_permissions` rows scoped `global`, an argument glob each. + 5. The default by tier (`config :trinity, :permissions, default:`), `:ask` for an unmapped + name. + + The fingerprint is re-derived here from the arguments actually passed (M2): a grant bound + to other arguments does not match, and the call asks again. + """ + @behaviour Trinity.Permissions.Policy + + alias Trinity.Permissions + alias Trinity.Permissions.{Rule, Store} + + @default %{read: :allow, network: :allow, write: :ask, exec: :ask, destructive: :ask} + + @impl true + def decide(session_id, tool, args, opts) do + now = DateTime.utc_now() + fp = Permissions.fingerprint(session_id, tool, args, Keyword.get(opts, :cwd)) + + with :next <- session_grants(session_id, tool, args, fp, now), + :next <- decided_approval(session_id, fp, now), + :next <- persona(Keyword.get(opts, :persona), tool), + :next <- global_rules(tool, args, fp, now) do + default(tool) + end + end + + defp session_grants(nil, _tool, _args, _fp, _now), do: :next + + defp session_grants(session_id, tool, args, fp, now) do + tool + |> Store.rules([Permissions.scope(session_id)], now) + |> first_match(args, fp) + end + + defp decided_approval(nil, _fp, _now), do: :next + + defp decided_approval(session_id, fp, now) do + case Store.decided_for(session_id, fp) do + [%{consumed_at: nil, status: "allowed", decision: "once"} = a | _] -> + if Store.consume_once(a, now), do: :allow, else: :next + + [%{consumed_at: nil, status: "denied"} = a | _] -> + if Store.consume_once(a, now), do: :deny, else: :next + + _ -> + :next + end + end + + defp persona(%{settings: %{"permissions" => perms}}, tool) when is_map(perms) do + case Map.get(perms, tool) do + "allow" -> :allow + "deny" -> :deny + "ask" -> :ask + _ -> :next + end + end + + defp persona(_, _), do: :next + + defp global_rules(tool, args, fp, now) do + tool |> Store.rules(["global"], now) |> first_match(args, fp) + end + + defp first_match(rules, args, fp) do + case Enum.find(rules, &Rule.matches?(&1.pattern, args, fp)) do + nil -> :next + %{decision: d} -> String.to_existing_atom(d) + end + end + + defp default(tool) do + defaults = + Application.get_env(:trinity, :permissions, []) + |> Keyword.get(:default, @default) + + case Permissions.tier(tool) do + :ask -> :ask + tier -> Map.get(defaults, tier, :ask) + end + end +end diff --git a/lib/trinity/permissions/rule.ex b/lib/trinity/permissions/rule.ex new file mode 100644 index 0000000..4552938 --- /dev/null +++ b/lib/trinity/permissions/rule.ex @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.Rule do + @moduledoc """ + A row of `tool_permissions` (slice 021): a rule (`global` or `persona:`, an argument + glob) or a session grant (`session:`, a fingerprint pattern `fp:`, an expiry). + + The pattern language: `*` matches any arguments; `=` matches when the argument + `key`, as text, matches the glob (`*` any run within a path segment, `**` across segments, + `?` one character); a trailing `*` on a command is the prefix rule docs/07 asks for. Regex + is not accepted from the UI; a hand-edited row may carry `re:`. + """ + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, Trinity.UUID, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + + @decisions ~w(allow deny ask) + + @type t :: %__MODULE__{} + + schema "tool_permissions" do + field :tool, :string + field :pattern, :string, default: "*" + field :decision, :string + field :scope, :string, default: "global" + field :expires_at, :utc_datetime_usec + field :decided_by, :string + timestamps() + end + + @doc false + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(rule, attrs) do + rule + |> cast(attrs, [:tool, :pattern, :decision, :scope, :expires_at, :decided_by]) + |> validate_required([:tool, :pattern, :decision, :scope]) + |> validate_inclusion(:decision, @decisions) + |> validate_format(:scope, ~r/^(global|session:.+|persona:.+)$/) + |> validate_change(:pattern, fn :pattern, p -> + if valid_pattern?(p), + do: [], + else: [pattern: "is not `*`, `key=glob`, `fp:` or `re:`"] + end) + end + + @doc "True when the arguments match the pattern (the fingerprint form takes the call's fingerprint)." + @spec matches?(String.t(), map(), String.t() | nil) :: boolean() + def matches?("*", _args, _fingerprint), do: true + def matches?("fp:" <> hex, _args, fingerprint), do: hex == fingerprint + + def matches?("re:" <> re, args, _fingerprint) do + case Regex.compile(re) do + {:ok, regex} -> Enum.any?(args, fn {_k, v} -> Regex.match?(regex, to_string(v)) end) + _ -> false + end + end + + def matches?(pattern, args, _fingerprint) do + case String.split(pattern, "=", parts: 2) do + [key, glob] when is_map_key(args, key) -> + Regex.match?(glob_to_regex(glob), to_string(args[key])) + + _ -> + false + end + end + + @doc "True for a pattern the changeset accepts." + @spec valid_pattern?(term()) :: boolean() + def valid_pattern?("*"), do: true + def valid_pattern?("fp:" <> hex), do: String.match?(hex, ~r/^[0-9a-f]{64}$/) + def valid_pattern?("re:" <> re), do: match?({:ok, _}, Regex.compile(re)) + def valid_pattern?(p) when is_binary(p), do: match?([_, _], String.split(p, "=", parts: 2)) + def valid_pattern?(_), do: false + + @doc "A glob as a regex: `**` any run, `*` any run without a separator, `?` one character." + @spec glob_to_regex(String.t()) :: Regex.t() + def glob_to_regex(glob) do + source = + glob + |> String.split(~r/(\*\*|\*|\?)/, include_captures: true, trim: true) + |> Enum.map_join(fn + "**" -> ".*" + "*" -> "[^/]*" + "?" -> "." + literal -> Regex.escape(literal) + end) + + Regex.compile!("^" <> source <> "$") + end +end diff --git a/lib/trinity/permissions/store.ex b/lib/trinity/permissions/store.ex new file mode 100644 index 0000000..df16076 --- /dev/null +++ b/lib/trinity/permissions/store.ex @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.Store do + @moduledoc false + # Every read and write of `tool_permissions` and `approvals`. Inside the Permissions + # boundary; nothing outside calls it. + + import Ecto.Query + + alias Trinity.Permissions.{Approval, Rule} + alias Trinity.Repo + + ## Rules and grants + + @spec insert_rule(map()) :: {:ok, Rule.t()} | {:error, Ecto.Changeset.t()} + def insert_rule(attrs), do: %Rule{} |> Rule.changeset(attrs) |> Repo.insert() + + @spec delete_rule(String.t()) :: :ok | {:error, :not_found} + def delete_rule(id) do + case Repo.get(Rule, id) do + nil -> {:error, :not_found} + rule -> Repo.delete!(rule) && :ok + end + end + + @doc "Unexpired rules for a tool in the given scopes, newest first." + @spec rules(String.t(), [String.t()], DateTime.t()) :: [Rule.t()] + def rules(tool, scopes, now) do + Repo.all( + from r in Rule, + where: r.tool == ^tool and r.scope in ^scopes, + where: is_nil(r.expires_at) or r.expires_at > ^now, + order_by: [desc: r.inserted_at] + ) + end + + @spec list_rules(keyword()) :: [Rule.t()] + def list_rules(opts) do + query = from r in Rule, order_by: [desc: r.inserted_at] + + query = + case Keyword.get(opts, :scope) do + nil -> query + scope -> from r in query, where: r.scope == ^scope + end + + Repo.all(query) + end + + ## Approvals + + @spec insert_approval(map()) :: {:ok, Approval.t()} | {:error, Ecto.Changeset.t()} + def insert_approval(attrs), + do: %Approval{} |> Approval.request_changeset(attrs) |> Repo.insert() + + @spec get_approval(String.t()) :: Approval.t() | nil + def get_approval(id), do: Repo.get(Approval, id) + + @spec update_approval(Approval.t(), map()) :: {:ok, Approval.t()} | {:error, Ecto.Changeset.t()} + def update_approval(approval, attrs), + do: approval |> Approval.decide_changeset(attrs) |> Repo.update() + + @doc "Marks a once approval consumed, if it was not; returns whether this call consumed it." + @spec consume_once(Approval.t(), DateTime.t()) :: boolean() + def consume_once(%Approval{id: id}, now) do + {n, _} = + Repo.update_all( + from(a in Approval, where: a.id == ^id and is_nil(a.consumed_at)), + set: [consumed_at: now] + ) + + n == 1 + end + + @spec pending() :: [Approval.t()] + def pending, + do: Repo.all(from a in Approval, where: a.status == "pending", order_by: a.inserted_at) + + @spec pending_for(String.t()) :: [Approval.t()] + def pending_for(session_id) do + Repo.all( + from a in Approval, + where: a.session_id == ^session_id and a.status == "pending", + order_by: a.inserted_at + ) + end + + @doc "Decided approvals for a fingerprint in a session, newest first." + @spec decided_for(String.t(), String.t()) :: [Approval.t()] + def decided_for(session_id, fingerprint) do + Repo.all( + from a in Approval, + where: a.session_id == ^session_id and a.fingerprint == ^fingerprint, + where: a.status in ["allowed", "denied"], + order_by: [desc: a.decided_at] + ) + end + + @spec list_approvals(keyword()) :: [Approval.t()] + def list_approvals(opts) do + limit = Keyword.get(opts, :limit, 200) + + query = from a in Approval, order_by: [desc: a.inserted_at], limit: ^limit + + query = + case Keyword.get(opts, :session_id) do + nil -> query + id -> from a in query, where: a.session_id == ^id + end + + Repo.all(query) + end +end diff --git a/lib/trinity/sessions/session.ex b/lib/trinity/sessions/session.ex index 109b041..a63c543 100644 --- a/lib/trinity/sessions/session.ex +++ b/lib/trinity/sessions/session.ex @@ -5,8 +5,9 @@ defmodule Trinity.Sessions.Session do One conversation, one `gen_statem`. Slice 012. States: `idle`, `thinking` (a model call streaming in a Task), `tool_wait` (tool calls running - in a Task), `approval_wait` and `compacting` (present for the machine's shape; nothing enters - them until slices 021 and 023), `error` (a failed turn, recorded, then back to `idle`). + in a Task), `approval_wait` (slice 021: calls the gate holds until the owner decides), + `compacting` (present for the machine's shape; nothing enters it until slice 023), `error` (a + failed turn, recorded, then back to `idle`). Rules this process keeps: every durable change is a row before it is a broadcast; the model and the tools run in Tasks under the session's own supervisor and talk back only by message; @@ -75,6 +76,8 @@ defmodule Trinity.Sessions.Session do session -> Process.flag(:trap_exit, true) + # Slice 021: the gate's decisions for this session's requests arrive here. + :ok = Trinity.Permissions.subscribe(session_id) {:ok, task_sup} = Task.Supervisor.start_link() data = %State{id: session_id, session: session, turn: nil, task_sup: task_sup} {:ok, :idle, data, [{:next_event, :internal, :rehydrate}]} @@ -151,7 +154,7 @@ defmodule Trinity.Sessions.Session do end def handle_event({:call, from}, :cancel, state, %State{} = data) - when state in [:thinking, :tool_wait] do + when state in [:thinking, :tool_wait, :approval_wait] do kill_task(data) data = flush_deltas(data) data = persist_final(data, %{"interrupted" => true}) @@ -178,23 +181,55 @@ defmodule Trinity.Sessions.Session do def handle_event(:info, :coalesce, :thinking, data), do: {:keep_state, flush_deltas(%{data | turn: %{data.turn | coalesce_timer: nil}})} - # Tool results arrive from the tool Task. + # Tool results arrive from the tool Task. A result that asks for an approval (slice 021) + # holds its call: the row is the gate's, the Session waits in approval_wait, and the held + # calls run again once every decision is in. def handle_event( :info, {:tools_done, ref, results}, :tool_wait, %State{turn: %{ref: ref}} = data ) do - data = record_tool_results(data, results) - turn = %{data.turn | pending: [], turns: data.turn.turns + 1} + {held, done} = + Enum.split_with(results, &match?({_, {:error, {:approval_required, _}, _}}, &1)) + + data = record_tool_results(data, done) + + case held do + [] -> + next_turn(data) + + _ -> + awaiting = + Map.new(held, fn {call, {:error, {:approval_required, id}, _}} -> {id, call} end) + + {:next_state, :approval_wait, + %{data | turn: %{data.turn | awaiting: awaiting, task: nil}}} + end + end + + def handle_event( + :info, + {:approval, :decided, %{id: id}}, + :approval_wait, + %State{turn: %{awaiting: awaiting}} = data + ) + when is_map_key(awaiting, id) do + # A decided request is still a held call: whether it runs or is refused is the policy's + # answer at execution, where the fingerprint is re-derived. + {call, rest} = Map.pop(awaiting, id) + turn = %{data.turn | awaiting: rest, held: data.turn.held ++ [call]} data = %{data | turn: turn} - case Caps.check(turn) do - :ok -> {:next_state, :thinking, start_model_call(data)} - {:cap, reason} -> cap_reached(data, reason) + if rest == %{} do + {:next_state, :tool_wait, start_tools(data, turn.held)} + else + {:keep_state, data} end end + def handle_event(:info, {:approval, _, _}, _state, _data), do: :keep_state_and_data + # The Task died: a crash is an error turn, an ordinary exit after its message is nothing. def handle_event( :info, @@ -411,21 +446,44 @@ defmodule Trinity.Sessions.Session do end end - defp start_tools(%State{id: id, task_sup: sup, turn: turn} = data) do + # After the tool rows: the next model call, or the cap. + defp next_turn(%State{turn: turn} = data) do + turn = %{turn | pending: [], held: [], turns: turn.turns + 1} + data = %{data | turn: turn} + + case Caps.check(turn) do + :ok -> {:next_state, :thinking, start_model_call(data)} + {:cap, reason} -> cap_reached(data, reason) + end + end + + defp start_tools(%State{turn: turn} = data), do: start_tools(data, turn.pending) + + defp start_tools(%State{id: id, session: session, task_sup: sup, turn: turn} = data, calls) do ref = make_ref() me = self() - calls = turn.pending + persona = session.persona_id && Store.get_persona(session.persona_id) + context = %{session_id: id, caller: id, persona: persona} # Slice 020: the turn's calls run at once through the runner in force. %Task{pid: pid} = Task.Supervisor.async_nolink(sup, fn -> - results = ToolRunner.run_all(calls, %{session_id: id, caller: id}) + results = ToolRunner.run_all(calls, context) send(me, {:tools_done, ref, results}) end) %{ data - | turn: %{turn | ref: ref, task: pid, draft_id: nil, buffer: [], text: "", finish: nil} + | turn: %{ + turn + | ref: ref, + task: pid, + held: [], + draft_id: nil, + buffer: [], + text: "", + finish: nil + } } end diff --git a/lib/trinity/sessions/state.ex b/lib/trinity/sessions/state.ex index 91d67bf..539c108 100644 --- a/lib/trinity/sessions/state.ex +++ b/lib/trinity/sessions/state.ex @@ -5,7 +5,8 @@ defmodule Trinity.Sessions.State do A Session's in-memory data, rebuilt from the database on init. Slice 012. Only the active turn lives here; everything durable is a row. A grant, an approval or a pending tool call never survives a restart, because none is written here from anywhere but the turn in flight, and a - fresh init starts with `turn` empty (AC9). + fresh init starts with `turn` empty (AC9). `awaiting` (slice 021) maps a pending approval's id + to the call it holds while the Session sits in `approval_wait`; the approval itself is a row. """ alias Trinity.Sessions.SessionRow @@ -26,7 +27,9 @@ defmodule Trinity.Sessions.State do tokens: non_neg_integer(), sentinel: [map()], coalesce_timer: reference() | nil, - surface: %{String.t() => String.t()} + surface: %{String.t() => String.t()}, + awaiting: %{String.t() => map()}, + held: [map()] } @type t :: %__MODULE__{ @@ -57,7 +60,9 @@ defmodule Trinity.Sessions.State do tokens: 0, sentinel: [], coalesce_timer: nil, - surface: %{} + surface: %{}, + awaiting: %{}, + held: [] } end end diff --git a/lib/trinity/tools/registry.ex b/lib/trinity/tools/registry.ex index 4931bd2..739d2a2 100644 --- a/lib/trinity/tools/registry.ex +++ b/lib/trinity/tools/registry.ex @@ -102,15 +102,23 @@ defmodule Trinity.Tools.Registry do config = Keyword.merge(Application.get_env(:trinity, :tools, []), opts) toolsets = Keyword.get(config, :toolsets, %{}) - for module <- Keyword.get(config, :modules, []) do - case admit(module, :core, toolsets) do - {:ok, entry} -> - :ets.insert(table, {entry.name, entry}) - - {:error, reason} -> - raise ArgumentError, "tool #{inspect(module)} refused: #{inspect(reason)}" + entries = + for module <- Keyword.get(config, :modules, []) do + case admit(module, :core, toolsets) do + {:ok, entry} -> + :ets.insert(table, {entry.name, entry}) + entry + + {:error, reason} -> + raise ArgumentError, "tool #{inspect(module)} refused: #{inspect(reason)}" + end end - end + + # Slice 021: the core tools' declared risks are the permission tiers, handed over here + # and nowhere else; a dynamic tool never reaches this line. Only the registry in the + # application tree writes them (a test's second registry would overwrite the table). + if table == @table, + do: Trinity.Permissions.put_core_tiers(Map.new(entries, &{&1.name, &1.risk})) {:ok, %{toolsets: toolsets}} end diff --git a/lib/trinity/tools/runner.ex b/lib/trinity/tools/runner.ex index bc7e7f5..badc185 100644 --- a/lib/trinity/tools/runner.ex +++ b/lib/trinity/tools/runner.ex @@ -74,17 +74,29 @@ defmodule Trinity.Tools.Runner do defp execute(%{name: name, args: args}, ctx) do with {:ok, entry} <- Registry.lookup(name), {:ok, args} <- validate(entry, args), - :allow <- Permissions.decide(ctx.session_id, name, args), + :allow <- + Permissions.decide(ctx.session_id, name, args, persona: ctx.persona, cwd: ctx.cwd), {:ok, %Result{} = result} <- call_tool(entry, args, ctx) do {:ok, Result.cap(result), meta(name)} else {:error, reason} -> {:error, reason, meta(name)} :deny -> {:error, :denied, meta(name)} - :ask -> {:error, :approval_required, meta(name)} + :ask -> {:error, ask(ctx, name, args), meta(name)} other -> {:error, {:bad_return, other}, meta(name)} end end + # An :ask with a session to ask becomes a pending approval (a row, then a broadcast) the + # Session waits on; without a session there is nobody to ask, and the call is refused. + defp ask(%Context{session_id: nil}, _name, _args), do: :approval_required + + defp ask(%Context{session_id: sid, cwd: cwd}, name, args) do + case Permissions.request_approval(sid, name, args, cwd: cwd) do + {:ok, approval} -> {:approval_required, approval.id} + {:error, reason} -> {:request_failed, reason} + end + end + defp validate(%{module: module}, args) do case Schema.validate(module.schema(), args) do {:ok, args} -> {:ok, args} diff --git a/lib/trinity/versions.ex b/lib/trinity/versions.ex index 487d2db..45e79c4 100644 --- a/lib/trinity/versions.ex +++ b/lib/trinity/versions.ex @@ -274,6 +274,13 @@ defmodule Trinity.Versions do note: "JSON Schema (2020-12) validation of tool arguments in `Trinity.Tools.Schema`, with `cast: false` so a malformed call is refused and never repaired (docs/07). Was transitive through req_llm; direct since Slice 020 because a module of ours calls it (ADR-0009: Trinity's own tool behaviour, jsv for its schemas)." }, + %{ + name: "jcs", + pin: "~> 0.2", + lock: "jcs", + note: + "RFC 8785 canonical JSON, under every approval fingerprint (`Trinity.Permissions.Fingerprint`, Slice 021) and, at 024, under the receipts' signed payload. Chosen at Slice 021: it matches the RFC's own example vector byte for byte on this OTP, and `test/trinity/permissions/fingerprint_test.exs` keeps that vector so a release that stops matching fails the gate. โš ๏ธ Pre-1.0, released 2025-03-31 with nothing since (R11's trigger). The alternative, `rfc8785` 1.0.0, refuses OTP 28 and waits on the OTP pin." + }, %{ name: "rustler", pin: "~> 0.38", diff --git a/lib/trinity_web/components/approval_components.ex b/lib/trinity_web/components/approval_components.ex new file mode 100644 index 0000000..2c28e51 --- /dev/null +++ b/lib/trinity_web/components/approval_components.ex @@ -0,0 +1,155 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.ApprovalComponents do + @moduledoc """ + The approval card and its neighbours. Slice 021, in the vocabulary slice 013 decided. + + A card shows the tool, its risk as a badge, the arguments pretty-printed, a plain sentence + about what `:exec` and `:destructive` mean, and four buttons. The buttons push events the + page turns into `Trinity.Permissions.decide_request/3`; nothing on the card carries authority + of its own (M7). "Always allow" shows the pattern that would be written, pre-filled from the + arguments and editable, so what is granted is what the person read. + """ + use Phoenix.Component + use Gettext, backend: TrinityWeb.Gettext + + import TrinityWeb.CoreComponents, only: [icon: 1] + + alias Trinity.Permissions.Approval + + @doc "One pending request." + attr :approval, Approval, required: true + + attr :pattern, :string, + default: nil, + doc: "the always-allow pattern as edited; the suggestion by default" + + def approval_card(assigns) do + assigns = assign(assigns, :pattern, assigns.pattern || suggest_pattern(assigns.approval)) + + ~H""" + + """ + end + + attr :id, :string, required: true + attr :decision, :string, required: true + attr :label, :string, required: true + attr :primary, :boolean, default: false + attr :danger, :boolean, default: false + + defp decision_button(assigns) do + ~H""" + + """ + end + + @doc "The risk tier as a badge." + attr :risk, :string, required: true + + def risk_badge(assigns) do + ~H""" + + {@risk} + + """ + end + + @doc "The header's pending indicator: nothing, or a count linking to the audit page." + attr :count, :integer, required: true + + def pending_indicator(assigns) do + ~H""" + <.link + :if={@count > 0} + id="pending-approvals" + navigate="/permissions" + class="flex items-center gap-1 rounded-pill bg-warning/20 px-2 py-0.5 text-meta text-warning" + title={gettext("Approvals waiting")} + > + <.icon name="hero-hand-raised-micro" class="size-4" /> {@count} + + """ + end + + @doc "The pattern proposed for always-allow: the first path-like argument as a glob on its directory, else `*`." + @spec suggest_pattern(Approval.t()) :: String.t() + def suggest_pattern(%Approval{args: args}) do + case Enum.find(args, fn {_k, v} -> is_binary(v) and String.starts_with?(v, "/") end) do + {key, path} -> "#{key}=#{Path.dirname(path)}/*" + nil -> "*" + end + end + + defp danger("exec"), + do: + gettext("This runs a program on your machine. Approve only if you understand the command.") + + defp danger("destructive"), + do: gettext("This can delete or overwrite data. Approve only if you meant it.") + + defp danger(_), do: "" +end diff --git a/lib/trinity_web/live/permissions_live.ex b/lib/trinity_web/live/permissions_live.ex new file mode 100644 index 0000000..eefe4a8 --- /dev/null +++ b/lib/trinity_web/live/permissions_live.ex @@ -0,0 +1,187 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.PermissionsLive do + @moduledoc """ + `/permissions`: the audit. Slice 021. Every request with its decision, decider and time, + pending ones first and decidable here too; then the rules and grants, each revocable. + Everything this page does is `Trinity.Permissions.decide_request/3` or `revoke_rule/1`. + """ + use TrinityWeb, :live_view + + import TrinityWeb.ApprovalComponents + + alias Trinity.Permissions + alias Trinity.Permissions.Approval + + @impl true + def mount(_params, _session, socket) do + if connected?(socket), do: :ok = Permissions.subscribe(:all) + {:ok, socket |> assign(page_title: gettext("Permissions"), patterns: %{}) |> load()} + end + + defp load(socket) do + assign(socket, + pending: Permissions.pending(:all), + approvals: Permissions.list_approvals(limit: 200), + rules: Permissions.list_rules() + ) + end + + @impl true + def handle_info({:approval, _kind, %Approval{}}, socket), do: {:noreply, load(socket)} + def handle_info(_other, socket), do: {:noreply, socket} + + @impl true + def handle_event("approval_decide", %{"id" => id, "decision" => decision}, socket) + when decision in ["once", "session", "always", "deny"] do + opts = + if decision == "always", do: [pattern: Map.get(socket.assigns.patterns, id, "*")], else: [] + + case Permissions.decide_request(id, String.to_existing_atom(decision), opts) do + {:ok, _} -> + {:noreply, load(socket)} + + {:error, reason} -> + {:noreply, + put_flash(socket, :error, gettext("Not decided: %{reason}", reason: inspect(reason)))} + end + end + + def handle_event("approval_pattern", %{"approval_id" => id, "pattern" => pattern}, socket), + do: {:noreply, assign(socket, patterns: Map.put(socket.assigns.patterns, id, pattern))} + + def handle_event("revoke", %{"id" => id}, socket) do + _ = Permissions.revoke_rule(id) + {:noreply, load(socket)} + end + + def handle_event("new_session", _params, socket), + do: {:noreply, TrinityWeb.SessionLive.Index.new_session(socket)} + + def handle_event("cancel", _params, socket), do: {:noreply, socket} + + @impl true + def render(assigns) do + ~H""" + + <:bar> + {gettext("Permissions")} + <.pending_indicator count={length(@pending)} /> + +
+
+

{gettext("Waiting for a decision")}

+ <.approval_card :for={a <- @pending} approval={a} pattern={Map.get(@patterns, a.id)} /> +
+ +
+

{gettext("Decisions")}

+

{gettext("No request has been made yet.")}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{gettext("When")}{gettext("Tool")}{gettext("Risk")}{gettext("Arguments")}{gettext("Decision")}{gettext("By")}
+ {stamp(a.decided_at || a.inserted_at)} + {a.tool}<.risk_badge risk={a.risk} /> +
{Jason.encode!(a.args)}
+
+ + {a.status}{if a.decision, do: " ยท " <> a.decision} + + {a.decided_by}
+
+ +
+

{gettext("Rules and grants")}

+

+ {gettext("None. Always allow and allow for this session write one.")} +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{gettext("Tool")}{gettext("Pattern")}{gettext("Decision")}{gettext("Scope")}{gettext("Expires")}
{r.tool} +
{r.pattern}
+
{r.decision}{r.scope}{stamp(r.expires_at)} + +
+
+
+
+ """ + end + + defp stamp(nil), do: "" + defp stamp(%DateTime{} = at), do: Calendar.strftime(at, "%Y-%m-%d %H:%M") <> " UTC" +end diff --git a/lib/trinity_web/live/session_live/show.ex b/lib/trinity_web/live/session_live/show.ex index c28fc25..9128331 100644 --- a/lib/trinity_web/live/session_live/show.ex +++ b/lib/trinity_web/live/session_live/show.ex @@ -18,9 +18,12 @@ defmodule TrinityWeb.SessionLive.Show do """ use TrinityWeb, :live_view + import TrinityWeb.ApprovalComponents import TrinityWeb.ChatComponents alias Trinity.LLM + alias Trinity.Permissions + alias Trinity.Permissions.Approval alias Trinity.Sessions alias Trinity.Sessions.Message @@ -48,7 +51,10 @@ defmodule TrinityWeb.SessionLive.Show do last_seq: 0, last_user_message: nil, models: LLM.models(), - default_model: LLM.default_model() + default_model: LLM.default_model(), + approvals: [], + patterns: %{}, + pending_count: 0 ) |> stream_configure(:messages, dom_id: &"message-#{&1.id}") |> stream(:messages, []) @@ -62,6 +68,9 @@ defmodule TrinityWeb.SessionLive.Show do defp connect(%{assigns: %{session: session}} = socket) do id = session.id :ok = Sessions.subscribe(id) + # Slice 021: this session's requests, and the count of everyone's for the header. + :ok = Permissions.subscribe(id) + :ok = Permissions.subscribe(:all) {status, text} = case Sessions.ensure_started(id) do @@ -85,6 +94,10 @@ defmodule TrinityWeb.SessionLive.Show do socket |> assign(status: status, draft: text, last_seq: last_seq) + |> assign( + approvals: Permissions.pending(id), + pending_count: length(Permissions.pending(:all)) + ) |> assign( last_user_message: done |> Enum.filter(&(&1.role == "user")) |> List.last() |> content() ) @@ -142,6 +155,34 @@ defmodule TrinityWeb.SessionLive.Show do def handle_event("new_session", _params, socket), do: {:noreply, TrinityWeb.SessionLive.Index.new_session(socket)} + # Slice 021: a button is a request for a decision; the record is the gate's. + def handle_event("approval_decide", %{"id" => id, "decision" => decision}, socket) + when decision in ["once", "session", "always", "deny"] do + opts = + if decision == "always", + do: [pattern: Map.get(socket.assigns.patterns, id) || suggested(socket, id)], + else: [] + + case Permissions.decide_request(id, String.to_existing_atom(decision), opts) do + {:ok, _} -> + {:noreply, socket} + + {:error, reason} -> + {:noreply, + put_flash(socket, :error, gettext("Not decided: %{reason}", reason: inspect(reason)))} + end + end + + def handle_event("approval_pattern", %{"approval_id" => id, "pattern" => pattern}, socket), + do: {:noreply, assign(socket, patterns: Map.put(socket.assigns.patterns, id, pattern))} + + defp suggested(socket, id) do + case Enum.find(socket.assigns.approvals, &(&1.id == id)) do + nil -> "*" + approval -> suggest_pattern(approval) + end + end + defp send_message(socket, content) do id = socket.assigns.session.id @@ -178,8 +219,31 @@ defmodule TrinityWeb.SessionLive.Show do {:noreply, apply_event(event, socket)} end + def handle_info({:approval, kind, %Approval{} = approval}, socket) do + socket = + if approval.session_id == socket.assigns.session.id, + do: apply_approval(kind, approval, socket), + else: socket + + {:noreply, assign(socket, pending_count: length(Permissions.pending(:all)))} + end + def handle_info(_other, socket), do: {:noreply, socket} + # The page hears each request twice (the session's topic and everyone's); one card. + defp apply_approval(:requested, approval, socket) do + if Enum.any?(socket.assigns.approvals, &(&1.id == approval.id)), + do: socket, + else: assign(socket, approvals: socket.assigns.approvals ++ [approval]) + end + + defp apply_approval(:decided, approval, socket) do + assign(socket, + approvals: Enum.reject(socket.assigns.approvals, &(&1.id == approval.id)), + patterns: Map.delete(socket.assigns.patterns, approval.id) + ) + end + defp apply_event({:user_message, %Message{} = m}, socket) do socket |> insert(m) @@ -243,6 +307,7 @@ defmodule TrinityWeb.SessionLive.Show do {@session.title || gettext("Untitled session")} <.status_pill status={@status} /> <.model_picker models={@models} value={@session.model} default={@default_model} /> + <.pending_indicator count={@pending_count} />
+ <.approval_card :for={a <- @approvals} approval={a} pattern={Map.get(@patterns, a.id)} /> <.banner kind={@banner} /> <.composer status={@status} disabled={@status != :idle} />
diff --git a/lib/trinity_web/router.ex b/lib/trinity_web/router.ex index 6e7cf67..79b9d19 100644 --- a/lib/trinity_web/router.ex +++ b/lib/trinity_web/router.ex @@ -25,6 +25,8 @@ defmodule TrinityWeb.Router do live_session :chat do live "/", SessionLive.Index, :index live "/s/:id", SessionLive.Show, :show + # Slice 021: the approvals audit and the rules. + live "/permissions", PermissionsLive, :index end end diff --git a/mix.exs b/mix.exs index 9786a0d..a416e5b 100644 --- a/mix.exs +++ b/mix.exs @@ -106,6 +106,9 @@ defmodule Trinity.MixProject do # Slice 020: JSON Schema validation of tool arguments (Trinity.Tools.Schema). Already in # the lock through req_llm; direct because a module of ours calls it (ADR-0009). {:jsv, "~> 0.23"}, + # Slice 021: RFC 8785 canonical JSON under every approval fingerprint (docs/07). Chosen + # by the measurement in the slice's NOTES.md; the RFC's vector is a test in the tree. + {:jcs, "~> 0.2"}, # Slice 013 (owner decision, 2026-09-20): the linux package builds mdex's NIF from # source for musl (MDEX_NATIVE_BUILD=1 and TRINITY_NIF_TARGET in config/config.exs), # because neither precompiled artifact loads in Burrito's musl ERTS (NOTES finding 13). diff --git a/mix.lock b/mix.lock index a1a6611..16976ce 100644 --- a/mix.lock +++ b/mix.lock @@ -32,6 +32,7 @@ "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, "igniter": {:hex, :igniter, "0.8.4", "f79f1bbdc2fb7b9ca030a22d12a585b060cbf5b94b9d3f23b1148578a9e05d11", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "a9b1cbec996ccb100b4f7d8130129b2dd3f18eb4224ac9a0e907e428ca90dbd7"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "jcs": {:hex, :jcs, "0.2.0", "e0524c23b576e8247f9f5f09d1b82cb3f92c7b132932b82d5d656461831c6c99", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f52e86571f56fab695682bf0ab7fd697768acb20de16b30809e9654d1ec1c9dd"}, "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"}, "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"}, diff --git a/priv/repo/migrations/20260920200000_create_permissions.exs b/priv/repo/migrations/20260920200000_create_permissions.exs new file mode 100644 index 0000000..7c20fdd --- /dev/null +++ b/priv/repo/migrations/20260920200000_create_permissions.exs @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Repo.Migrations.CreatePermissions do + @moduledoc """ + Slice 021. `tool_permissions` (rules and grants) and `approvals` (the audit of every request + and its decision) per docs/05 and docs/07. A session's grants carry `scope` `session:` + and a fingerprint pattern; a rule carries `global` or `persona:` and an argument glob. + """ + use Ecto.Migration + + def change do + create table(:tool_permissions, primary_key: false) do + add :id, :binary_id, primary_key: true + add :tool, :string, null: false + add :pattern, :string, null: false, default: "*" + add :decision, :string, null: false + add :scope, :string, null: false, default: "global" + add :expires_at, :utc_datetime_usec + add :decided_by, :string + timestamps(type: :utc_datetime_usec) + end + + create index(:tool_permissions, [:tool, :scope]) + create index(:tool_permissions, [:scope]) + + create table(:approvals, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :session_id, references(:sessions, type: :binary_id, on_delete: :delete_all), + null: false + + add :tool, :string, null: false + add :args, :map, null: false, default: %{} + add :risk, :string, null: false + add :fingerprint, :string, null: false + add :status, :string, null: false, default: "pending" + add :decision, :string + add :decided_at, :utc_datetime_usec + add :decided_by, :string + add :consumed_at, :utc_datetime_usec + add :expires_at, :utc_datetime_usec, null: false + timestamps(type: :utc_datetime_usec) + end + + create index(:approvals, [:session_id, :status]) + create index(:approvals, [:status, :expires_at]) + create index(:approvals, [:fingerprint]) + end +end diff --git a/scripts/dev_chat_on_test_registry.sh b/scripts/dev_chat_on_test_registry.sh new file mode 100755 index 0000000..1b641c1 --- /dev/null +++ b/scripts/dev_chat_on_test_registry.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# The chat on the test registry (the fake provider, the test tools, a scripted turn that calls +# the write-risk tool) for the slice 021 screenshots and the owner's manual queue. The test +# environment's Repo without its sandbox pool and with the check_origin off, the application +# started by hand; the port is printed as PORT=. Run `mix assets.build` first, and remove +# any stale `priv/static/assets/**/*.gz` (Plug.Static serves the gzip in this environment). +# Its database is trinity_screenshots.db, never the test suite's: rows written outside the +# sandbox broke four usage_events tests once (slice 021 NOTES). +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_screenshots.db") +Application.put_env(:trinity, Trinity.Repo, repo) +endpoint = Application.get_env(:trinity, TrinityWeb.Endpoint) |> Keyword.put(:check_origin, false) +Application.put_env(:trinity, TrinityWeb.Endpoint, endpoint) +Application.put_env(:trinity, :permissions, expiry_ms: 600_000, session_grant_ms: 3_600_000) +{:ok, _} = Application.ensure_all_started(:trinity) +call = [{:text_delta, "I will save that as a note. "}, {:sleep, 300}, {:tool_call_start, "c1", "write_note"}, {:tool_call_end, "c1", %{"path" => "/home/me/notes/today.md", "text" => "Buy oat milk, call the dentist, finish the slice."}}, {:usage, %{input_tokens: 12, output_tokens: 20}}, {:done, :tool_calls}] +final = fn text -> Enum.flat_map(String.split(text), &[{:text_delta, &1 <> " "}, {:sleep, 40}]) ++ [{:usage, %{input_tokens: 40, output_tokens: 30}}, {:done, :stop}] end +Trinity.LLM.Providers.Fake.scripts([ + call, + final.("Saved. The note is at **/home/me/notes/today.md** with three items: oat milk, the dentist, and the slice."), + call, + final.("Understood, I did not write the note. Tell me if you change your mind.") +]) +{:ok, {_, port}} = TrinityWeb.Endpoint.server_info(:http) +IO.puts("PORT=#{port}") +' diff --git a/slices/021-permission-gate/NOTES.md b/slices/021-permission-gate/NOTES.md new file mode 100644 index 0000000..c756580 --- /dev/null +++ b/slices/021-permission-gate/NOTES.md @@ -0,0 +1,135 @@ +# Slice 021: NOTES + +## The canonicalisation, measured 2026-09-20 before any code + +The fingerprint docs/07 and this slice's M2 rule name is `sha256(rfc8785_canonical({tool, args, scope, cwd, +canonicalization_version}))`. Two RFC 8785 packages on hex, read from their tarballs in a scratch directory and +run against the RFC's own example (section 3.2.3: the `numbers`, `string` and `literals` object): + +| Candidate | Result | Derived by | +|---|---|---| +| `rfc8785` 1.0.0 (2026-07-24, 935 downloads, no runtime deps, 1,377 lines, an ES6 number corpus in its own suite) | **Refuses to compile on this tree's OTP**: `rfc8785 requires Erlang/OTP 29 or later (found OTP 28)`. The OTP pin is 28.5.0.5 for Burrito's ERTS (VERSIONS.md), so it is out until the pin moves. | `mix deps.get && mix run` in the scratch project | +| `jcs` 0.2.0 (2025-03-31, 91,772 downloads, depends on jason, 402 lines, Apache-2.0) | **Matches the RFC vector byte for byte**, numbers included (`1e+30`, `4.5`, `0.002`, `1e-27`, and `2.0` as `2`), through `:erlang.float_to_binary(:short)`; keys sorted as the RFC asks. Pre-1.0 and eighteen months since its release: R11's trigger, noted. | the same, `Jcs.encode/1` against `expected.txt` | + +**Decision at G1: `jcs ~> 0.2`**, with the RFC vector as a test in the tree so a later version that stops +matching fails the gate, and `canonicalization_version` 1 bound into every fingerprint so a change of scheme is a +new version rather than a silent difference. Open to the owner's veto. + +## G1 plan, 2026-09-20 + +Tree at `81b8da1` on `main` (020 and 013 approved); branch `slice/021-permission-gate`; ROADMAP row 021 set to +`in_progress` in this commit. docs/07's permission gate section is the contract. Each line names its test. + +1. `chore(s021): add jcs ~> 0.2`; `Trinity.Permissions.Fingerprint`: `canonical/1` (jcs) and `of/4` + (`sha256` hex over `{tool, args, scope, cwd, canonicalization_version: 1}`). Tests: the RFC 8785 vector; the + same call twice is the same digest; a changed argument, scope or cwd is a different one. +2. Migrations: `tool_permissions` (tool, pattern, decision, scope, expires_at, decided_by, timestamps) and + `approvals` (session_id, tool, args, risk, fingerprint, status, decision, decided_at, decided_by, consumed_at, + expires_at, timestamps); schemas `Permissions.Rule` and `Permissions.Approval`; `Permissions.Store` inside the + boundary. docs/05 synced. Test: the two tables through the store on SQLite; the postgres job on both. +3. The tier's source. 020 left `Permissions.tier/1` an empty attribute because Permissions may not read the + registry; here the registry hands the core tools' declared risks to Permissions at admission + (`Permissions.put_core_tiers/1`, Tools โ†’ Permissions, the allowed direction), a dynamic tool never does, and + `tier/1` reads that table by name alone. Test: the census's "every mapped name is a core tool" gains content; + `tier("echo")` is `:read`, `tier("mcp:fake:echo")` is `:ask`. +4. `Permissions.Policy.Layered` (the implementation in force): session grants (rules scoped `session:`, a + fingerprint pattern `fp:`, unexpired) โ†’ an unconsumed "once" approval for the fingerprint (consumed on + use) โ†’ a denied approval for the fingerprint in this turn โ†’ persona policy (`settings.permissions`) โ†’ global + rules (a glob on one named argument, `key=glob`, `*` for any; prefix as a trailing `*`) โ†’ the default by + tier (config `permissions: [default: %{read: :allow, network: :allow, write: :ask, exec: :ask, destructive: + :ask}]`, unmapped `:ask`). Tests: each layer in isolation and the order between two. +5. `Permissions.Gate` (GenServer): `request/4` writes the pending row, broadcasts `{:approval, :requested, a}` on + `approvals:` and `approvals:all` (row before broadcast), arms the expiry + (`permissions: [expiry_ms: 600_000]`, short in test); `decide_request/3` (`:once | :session | :always | + :deny`) updates the row, writes the grant or the rule (always: the pattern the user confirmed), broadcasts + `{:approval, :decided, a}`; expiry decides `:deny` as `"expiry"`; pending rows are reloaded at init with their + timers. Tests: AC6 (expiry within the short timeout, the row `expired`, the decision `deny`), AC7's auto half + (every decision a row with `decided_at`), a decided row cannot be decided twice. +6. The runner: an `:ask` answer creates the request through the Gate and returns + `{:error, {:approval_required, approval_id}, meta}`; the Session, seeing one in the turn's results, holds those + calls, enters `approval_wait` (the state 012 left without an inbound transition), subscribes to + `approvals:` at init, and on the last decision re-runs the held calls, where the runner asks the policy + again: the once grant or the session grant allows, the denial denies, and the fingerprint is re-derived from + the arguments actually passed (a divergence is `:deny`). Tests: AC1 (Echo, `:read`, runs with the Gate's + `request/4` never called, a Mox on the Gate's behaviour), AC3 (a session grant: the second identical call + runs without asking, a different argument asks again, a new session asks), AC4 (an always rule with a glob: + a call inside runs, outside asks; the rule row exists), AC5 (deny: the tool row says denied, the turn goes + on to a final message), AC8 (kill during `approval_wait`: the row is pending after the restart, deciding it + updates the row and broadcasts; the restarted session is idle, as 012's reset-is-total requires). +7. LiveView: `TrinityWeb.ApprovalComponents.approval_card` (tool, risk badge, arguments pretty-printed, the danger + line for `:exec` and `:destructive`, the four buttons; "always" shows the pre-filled, editable pattern), + rendered in `SessionLive.Show` while a request is pending; a pending count in the shell's bar from + `approvals:all`; `PermissionsLive` at `/permissions` listing approvals (decided and pending) and rules with + a revoke. Everything the UI does is `Permissions.decide_request/3`; the buttons carry no authority (M7). + Tests: the card renders on `{:approval, :requested, _}`; each button decides; `/permissions` lists the row. +8. Screenshots for AC2 and AC7 from the dev server driven by chromium, as at 013, with a write-risk test tool + available in dev (the fake provider flag gains a tool script). docs/07 synced; docs/01 tree. +9. Gate, coverage row, PROOF.md, ROADMAP to `done`, pull request (signed merge body), tag. + +Manual verification queue, for the owner at G4: +- **AC2**: `TRINITY_FAKE_PROVIDER=1 mix phx.server`, a session, send "write"; the fake calls a write-risk tool; + the card appears; "Allow once"; the tool runs and the final message follows. Screenshot. +- **AC7**: after a few decisions, open `/permissions`: every decision listed with its time. Screenshot. + +Deviations from SLICE.md, stated before building: the runner asks the policy at execution and the Session never +pre-checks, so `decide/3` stays exactly once per execution attempt (020 AC7) and the M2 re-derivation is the same +call; "allow once" is an approval row consumed at its one use, not a rule; a denial is bound to the fingerprint +for the turn that asked and never outlives it; the persona layer reads `personas.settings["permissions"]` +(`tool => decision`), the smallest shape 030 can grow. + +## Lines 1 to 8, 2026-09-20: what was built, and what building it found + +**Built.** As planned: `Permissions.Fingerprint`, the migration with `Rule` and `Approval` and `Store`, +`Permissions` (the door: tiers, `decide/4`, requests, decisions, listings, topics), `Policy` with `Default` and +`Layered`, `Gate`, the runner's `:ask` path, the Session's `approval_wait` with `awaiting` and `held` on the turn, +`ApprovalComponents`, the card in the chat, `PermissionsLive`, the `write_note` test tool, the screenshot server +script. `jcs ~> 0.2` (its own commit). + +**Found while building, each recorded rather than smoothed.** + +1. **`decide/3` became `decide/4`.** The persona's policy needs the persona row and the fingerprint needs the + working directory, and Permissions may not read Sessions (a cycle), so the runner passes both from the tool + context as `opts`. 020's mocks moved to the four-arity callback; the count of one call per execution stands. +2. **The page hears each request twice.** The chat subscribes to the session's topic and to everyone's (for + the header count), and the Gate broadcasts on both, so the card doubled until the page ignored a second + arrival of the same id. LiveViewTest's duplicate-id check is what found it. +3. **A test expiry of 400 ms raced the tests that decide by hand**, so the expiry in test config is 1,000 ms + and AC6 waits up to 1,500. Two runs of the three suites green. +4. **The test-environment server for screenshots wrote into the test suite's database**: the next gate failed + four `usage_events` tests on rows nobody in the suite had written (012 found the same, its finding 6). The + script now names its own database file and the test one was reset; the script is in `scripts/` so the + owner's manual queue runs on the same thing the screenshots came from. +5. **Plug.Static serves a stale gzip.** With code reloading off (the test environment, and production) the + endpoint serves `app.css.gz` beside `app.css`, and `mix assets.build` rewrites only the latter, so the page + showed the previous slice's stylesheet while the file on disk was new. The script says to remove the `.gz` + files; `mix assets.deploy` regenerates them for a release. +6. **A table cell cannot truncate**: `truncate` on a `td` does nothing without `table-fixed` and a block child; + the audit page's arguments column pushed the decider off the screen until the table got fixed column widths + and a `div` per cell. +7. **The hidden `id` field in the always-allow form** collided with the form element's own id (a LiveView + compile warning, an error under the gate); it is `approval_id`. +8. **The `hero-hand-raised` icon did not render until the CSS was rebuilt**, which is finding 5 again from + the other side: a class used for the first time exists only after `mix assets.build`, and the gate builds + no assets (013 finding, unchanged). + +``` +$ mix test test/trinity/permissions test/trinity_web/live/approval_live_test.exs โ†’ 29 passed +$ mix gate โ†’ exit 0; 233 passed, 10 excluded; plan_check: PASS +$ mix test --cover โ†’ 72.45% total (Gate 88.68%, Layered 82.14%, Store 95.83%, PermissionsLive 89.23%) +$ mix credo --strict --all โ†’ 885 mods/funs, found no issues +``` + +## Follow-ups +- `Policy.Default` has no test of its own (0% coverage): it is the allow-all a test may configure; a one-line + test or its removal at 022. +- Gateway approvals (070) subscribe to the same topics; the card's four decisions map to four gateway replies. +- 024 reads `approvals` for decision receipts; the `consumed_at` column is what a receipt for an execution cites. +- The persona layer reads `settings["permissions"]`; 030's persona editor is where it gets a UI. +- `/permissions` lists the newest 200 approvals; a filter by session and a page size are 090's activity view. + +9. **The Gate took the application down when its table was missing.** The postgres job boots the application + (`mix run -e 'Ecto.Adapters.Postgres = Trinity.Repo.__adapter__()'`) before it migrates, and `init/1` read + `approvals` there: run 35528824468, `relation "approvals" does not exist`, the `gate` job green beside it. + The reload moved to a `handle_continue` that rescues and warns; a Gate that cannot read its table at boot + starts empty rather than stopping the chat, and a request made before the table exists fails on its own + insert with a reason. Found by CI, not here: this machine has no Postgres. diff --git a/slices/021-permission-gate/PROOF.md b/slices/021-permission-gate/PROOF.md new file mode 100644 index 0000000..30461f2 --- /dev/null +++ b/slices/021-permission-gate/PROOF.md @@ -0,0 +1,173 @@ +# Proof for slice 021: Permission gate + approval UI + +Agent: Trinity ยท Coding Agent ยท Date: 2026-09-20 ยท Branch: slice/021-permission-gate ยท Final commit: (the commit carrying this file; named in the closing correction) + +## Summary +The gate docs/07 specifies: a tier from the tool name alone (written once by the registry from the core +modules' declared risks), a layered policy (session grants, the unspent decision for the fingerprint, persona, +global rules, the default by tier), approvals as rows before broadcasts with a fingerprint over RFC 8785 +canonical JSON re-derived at execution, a Gate with expiries that survive restarts, the Session's +`approval_wait`, the card, the header indicator and the `/permissions` audit. Every decision is +`decide_request/3`; no button carries authority. The canonicalisation library was measured first (NOTES.md): +`rfc8785` refuses this tree's OTP, `jcs` matches the RFC's own vector, which is now a test. Eight findings in +NOTES.md, the sharpest being a screenshot server that wrote into the test database and a stale gzip the +endpoint served over a fresh stylesheet. + +## Gate +``` +$ mix gate (this machine, OTP 28.5.0.5, Elixir 1.20.4, under a 32 GiB cgroup, tree 1f3727f) +885 mods/funs, found no issues. +... SCAN COMPLETE ... (sobelow --exit --skip: no finding) +No retired or security advisory packages found +No vulnerabilities found. +Result: 233 passed, 10 excluded +trinity.coverage: 020 67.18% vs 013 64.41%: OK +plan_check: PASS +exit=0 +``` +`mix credo --strict --all`: 885 mods/funs, found no issues. `mix assets.build`: exit 0. + +## Tests +``` +$ mix test --cover (tree 1f3727f) +Result: 233 passed, 10 excluded + | 0.00% | Trinity.Permissions.Policy.Default | + | 60.00% | Trinity.Permissions.Approval | + | 82.14% | Trinity.Permissions.Policy.Layered | + | 86.36% | Trinity.Permissions.Rule | + | 88.68% | Trinity.Permissions.Gate | + | 89.23% | TrinityWeb.PermissionsLive | + | 89.80% | TrinityWeb.ApprovalComponents | + | 91.67% | Trinity.Permissions | + | 95.83% | Trinity.Permissions.Store | + | 100.00% | Trinity.Permissions.Fingerprint | + | 100.00% | Trinity.Permissions.Policy | + | 72.45% | Total | +``` +`coverage.tsv` row: `021 72.45 1f3727f 2026-09-20` (from 67.18 at 020). + +The 29 tests of `test/trinity/permissions` and `test/trinity_web/live/approval_live_test.exs`, with timings from `--trace`: +``` +test AC1: a read-risk tool runs with no request: the Gate sees nothing * test AC1: a read-risk tool runs with no request: the Gate sees nothing (4.0ms) [L#37] +test AC2 (LiveView half): the card renders with tool, risk and arguments; Allow once runs the tool and the final message follows * test AC2 (LiveView half): the card renders with tool, risk and arguments; Allow once runs the tool and the final message follows (12.2ms) [L#43] +test AC2 (test half): a write-risk call enters approval_wait; allow once runs it; the final message follows * test AC2 (test half): a write-risk call enters approval_wait; allow once runs it; the final message follows (5.0ms) [L#49] +test AC3: allow for session: the second identical call runs without asking; other arguments ask; a new session asks * test AC3: allow for session: the second identical call runs without asking; other arguments ask; a new session asks (1044.2ms) [L#74] +test AC5: deny: the tool row carries a denial the model reads; the turn goes on to a final message * test AC5: deny: the tool row carries a denial the model reads; the turn goes on to a final message (5.0ms) [L#108] +test AC8: killed in approval_wait, the request is still pending and decidable; the restarted session is idle * test AC8: killed in approval_wait, the request is still pending and decidable; the restarted session is idle (7.5ms) [L#136] +test Always allow writes the edited pattern as a rule; Deny denies * test Always allow writes the edited pattern as a rule; Deny denies (50.1ms) [L#70] +test jcs matches the RFC 8785 example vector byte for byte * test jcs matches the RFC 8785 example vector byte for byte (6.4ms) [L#9] +test key=glob: * within a segment, ** across, ? one character, a trailing * is a prefix * test key=glob: * within a segment, ** across, ? one character, a trailing * is a prefix (0.1ms) [L#15] +test keys are sorted and numbers are ES6 shortest, whatever the map's order * test keys are sorted and numbers are ES6 shortest, whatever the map's order (0.02ms) [L#15] +test * matches anything; fp: matches the fingerprint only * test * matches anything; fp: matches the fingerprint only (0.00ms) [L#9] +test /permissions lists every decision with its time and decider, the pending ones decidable, the rules revocable (AC7's page) * test /permissions lists every decision with its time and decider, the pending ones decidable, the rules revocable (AC7's page) (84.9ms) [L#110] +test re: is accepted from a hand-edited row; a broken regex matches nothing * test re: is accepted from a hand-edited row; a broken regex matches nothing (0.03ms) [L#26] +test the changeset refuses a pattern outside the language and a scope outside the vocabulary * test the changeset refuses a pattern outside the language and a scope outside the vocabulary (0.06ms) [L#31] +test the Gate AC6: an undecided request expires into a denial decided by expiry, within the configured timeout * test the Gate AC6: an undecided request expires into a denial decided by expiry, within the configured timeout (1001.6ms) [L#167] +test the Gate AC7: every decision is a row with decided_at, listed newest first * test the Gate AC7: every decision is a row with decided_at, listed newest first (0.8ms) [L#185] +test the Gate a decided request cannot be decided twice; an unknown id is not found * test the Gate a decided request cannot be decided twice; an unknown id is not found (0.5ms) [L#160] +test the Gate always: a global rule with the confirmed pattern; a call outside it still asks (AC4) * test the Gate always: a global rule with the confirmed pattern; a call outside it still asks (AC4) (32.5ms) [L#137] +test the Gate a request is a row, then a broadcast on the session's topic and on all * test the Gate a request is a row, then a broadcast on the session's topic and on all (0.8ms) [L#96] +test the Gate deny: the row is denied, the fingerprint is denied once, then asks again (AC5's core) * test the Gate deny: the row is denied, the fingerprint is denied once, then asks again (AC5's core) (2.2ms) [L#149] +test the Gate once: the row is allowed with decided_at and decider, the fingerprint allows one execution, then asks again * test the Gate once: the row is allowed with decided_at and decider, the fingerprint allows one execution, then asks again (0.8ms) [L#107] +test the Gate session: a grant row scoped to the session with an expiry; the second identical call allows * test the Gate session: a grant row scoped to the session with an expiry; the second identical call allows (0.7ms) [L#127] +test the header indicator counts everyone's pending requests on every page * test the header indicator counts everyone's pending requests on every page (61.4ms) [L#134] +test the layers a global rule with a glob allows inside the pattern and not outside (AC4's core) * test the layers a global rule with a glob allows inside the pattern and not outside (AC4's core) (0.6ms) [L#29] +test the layers an expired session grant no longer allows * test the layers an expired session grant no longer allows (0.4ms) [L#78] +test the layers a persona policy sits above global rules and below session grants * test the layers a persona policy sits above global rules and below session grants (0.7ms) [L#43] +test the layers a session grant is bound to the fingerprint: other arguments ask, another session asks (AC3's core) * test the layers a session grant is bound to the fingerprint: other arguments ask, another session asks (AC3's core) (0.8ms) [L#60] +test the layers the default by tier: read allows, write asks, an unmapped name asks * test the layers the default by tier: read allows, write asks, an unmapped name asks (0.7ms) [L#23] +test the same call is the same digest; a changed argument, scope or cwd is another * test the same call is the same digest; a changed argument, scope or cwd is another (6.0ms) [L#20] +``` + +## Acceptance criteria evidence + +### AC1 [auto]: a read-risk tool executes with no approval (the Gate not called) +`AC1: a read-risk tool runs with no request: the Gate sees nothing`: subscribed to `approvals:all`, a turn calling +`echo` never enters `approval_wait`, no `{:approval, _, _}` arrives, `list_approvals(session_id:)` is empty, the +tool row is the echo. The Gate is a process, not a behaviour, so "not called" is measured as "no row and no +broadcast", which is everything a call would produce. The policy's default: `the default by tier: read allows, +write asks, an unmapped name asks`. + +### AC2 [manual]: write-risk tool โ†’ approval_wait; the card renders; allow once โ†’ the tool runs โ†’ final message +Tests: `AC2 (test half): a write-risk call enters approval_wait; allow once runs it; the final message follows` +(Session: the request broadcast, `state/1` says `approval_wait`, `pending/1` lists it; after `:once` the tool row +reads "wrote 2 bytes", the final message arrives, the approval is `allowed` and consumed) and `AC2 (LiveView +half)` (the card with tool, risk badge and arguments, status `approval_wait`, the header indicator at 1, the +suggested pattern `path=/home/me/notes/*`; Allow once clears the card, the tool result and the final text are on +the page, the row is `allowed once liveview`). Screenshots from the chat on the test registry +(`scripts/dev_chat_on_test_registry.sh`): `proof/ac2-approval-card.png` and `proof/ac2-allow-once-final.png`. + +### AC3 [auto]: allow for session โ†’ the second identical call runs without asking; a new session asks again +`AC3: allow for session ...` (Session): after `:session`, the identical call runs with no request; other +arguments under the same grant ask again (M2: the fingerprint differs); a new session asks. Core: `a session +grant is bound to the fingerprint: other arguments ask, another session asks` and `an expired session grant no +longer allows`; the grant row: `session: a grant row scoped to the session with an expiry`. + +### AC4 [auto]: always allow with a path pattern โ†’ a persisted rule; a call outside the pattern still asks +`always: a global rule with the confirmed pattern; a call outside it still asks` (the rule row with the pattern; +inside allows, `/home/me/secrets/k` asks; revoked, it asks again) and, through the page, `Always allow writes +the edited pattern as a rule; Deny denies` (the pattern edited to `path=/home/me/**` before the click, the rule +row carries it, the next call runs without a card, a call at `/etc/hosts` asks). The pattern language: +`Trinity.Permissions.RuleTest`. + +### AC5 [auto]: deny โ†’ the tool message carries a denial the model can read; the session continues +`AC5: deny: the tool row carries a denial the model reads; the turn goes on to a final message`: two calls in +one turn, one denied and one echo; rows `["user", "assistant", "tool", "tool", "assistant"]`, the denied row +`error: :denied` with `ok: false`, the final message from the next model call. Screenshot +`proof/ac5-denied.png` (the fake's canned final text after the denial is the script's, not the model's). + +### AC6 [auto]: expiry โ†’ auto-deny after the configured timeout +`AC6: an undecided request expires into a denial decided by expiry, within the configured timeout`: with +`expiry_ms: 1_000`, the `{:approval, :decided, %{status: "expired", decision: "deny", decided_by: "expiry"}}` +broadcast arrives within 1,500 ms, the row has `decided_at`, and the call asks again afterwards. + +### AC7 [manual]: every decision has an approvals row with decided_at; /permissions lists them +`AC7: every decision is a row with decided_at, listed newest first` and `/permissions lists every decision with its +time and decider, the pending ones decidable, the rules revocable`. Screenshot `proof/ac7-permissions-audit.png` +(nine decisions from the screenshot runs, each with its time, tool, risk, arguments, decision and decider). + +### AC8 [auto]: killing the Session during approval_wait โ†’ after restart the approval is still pending and decidable +`AC8: killed in approval_wait, the request is still pending and decidable; the restarted session is idle`: +`Process.exit(pid, :kill)` in `approval_wait`; the supervisor restarts the session idle with nothing pending +(012's reset is total); `pending/1` still lists the row; `decide_request/3` records it and broadcasts; the tool +did not run (the turn died with the process) and the session takes the next message. + +### Platform alignment: M2 and M7 +M2: `Trinity.Permissions.FingerprintTest` (the RFC vector; the same call the same digest; a changed argument, +scope or cwd another) and the AC3 test's "other arguments ask again". M7: the card's buttons and the audit page +push `approval_decide`, which calls `decide_request/3`; the LiveView tests drive the buttons and read the rows. + +## Manual verification for the reviewer +1. AC2: `mix assets.build`, remove `priv/static/assets/**/*.gz`, then `scripts/dev_chat_on_test_registry.sh`; + open the printed port, New session, send anything. Expected: the reply starts, the card appears with + `write_note`, the `WRITE` badge, the arguments and the four buttons; Allow once; the tool result and the + final message follow. +2. AC7: send another message, Deny; open `/permissions`. Expected: both decisions listed with times and + `liveview` as the decider. + +## Deviations from SLICE.md +See NOTES.md: the four stated at G1 (no pre-check, once as a consumed approval, a denial bound to its turn, +the persona layer's shape) and finding 1 (`decide/4`). + +## Versions touched +`VERSIONS.md` updated: yes, `jcs ~> 0.2` added with its row (the `rfc8785` alternative and why not, in the row). +`mix hex.audit` and `mix deps.audit` clean. + +## Git +``` +$ git log --oneline main..HEAD +1f3727f feat(s021): the permission gate: layered policy, fingerprints, the Gate, approval_wait, the card and the audit +897be2b chore(s021): add jcs ~> 0.2 +c3125f8 docs(s021): G1 plan with the canonicalisation measured, and the slice opens +``` + +## Closing correction, 2026-09-20 +Supersedes the "Final commit" field in the header: the commit carrying this file is `a32d705` +(`feat(s021): complete slice 021 (permission gate and approval UI)`); the `git log` block above lists the +commits before it. The pull request, its merge commit (signed in its body) and the tag come after review. + +## Correction, 2026-09-20: the postgres job +The `postgres` check failed on the closing tree (run 35528824468): the Gate's `init/1` read the `approvals` +table in a job that boots the application before migrating (NOTES.md finding 9). Fixed in the commit after this +record: the reload is a `handle_continue` that rescues into a warning. The `gate` job was green on the same tree. +The run that closes this is named in the next correction. diff --git a/slices/021-permission-gate/proof/ac2-allow-once-final.png b/slices/021-permission-gate/proof/ac2-allow-once-final.png new file mode 100644 index 0000000..f4759e7 Binary files /dev/null and b/slices/021-permission-gate/proof/ac2-allow-once-final.png differ diff --git a/slices/021-permission-gate/proof/ac2-approval-card.png b/slices/021-permission-gate/proof/ac2-approval-card.png new file mode 100644 index 0000000..b168b1d Binary files /dev/null and b/slices/021-permission-gate/proof/ac2-approval-card.png differ diff --git a/slices/021-permission-gate/proof/ac5-denied.png b/slices/021-permission-gate/proof/ac5-denied.png new file mode 100644 index 0000000..8be276c Binary files /dev/null and b/slices/021-permission-gate/proof/ac5-denied.png differ diff --git a/slices/021-permission-gate/proof/ac7-permissions-audit.png b/slices/021-permission-gate/proof/ac7-permissions-audit.png new file mode 100644 index 0000000..3167027 Binary files /dev/null and b/slices/021-permission-gate/proof/ac7-permissions-audit.png differ diff --git a/test/support/rfc8785_expected.txt b/test/support/rfc8785_expected.txt new file mode 100644 index 0000000..29b720b --- /dev/null +++ b/test/support/rfc8785_expected.txt @@ -0,0 +1 @@ +{"literals":[null,true,false],"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27],"string":"โ‚ฌ$\u000f\nA'B\"\\\\\"/"} \ No newline at end of file diff --git a/test/support/rfc8785_input.json b/test/support/rfc8785_input.json new file mode 100644 index 0000000..153430f --- /dev/null +++ b/test/support/rfc8785_input.json @@ -0,0 +1 @@ +{"numbers":[333333333.33333329,1E30,4.50,2e-3,0.000000000000000000000000001],"string":"\u20ac$\u000F\u000aA'\u0042\u0022\u005c\\\"\u002f","literals":[null,true,false]} \ No newline at end of file diff --git a/test/support/tools/write_note.ex b/test/support/tools/write_note.ex new file mode 100644 index 0000000..36aed06 --- /dev/null +++ b/test/support/tools/write_note.ex @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TestTools.WriteNote do + @moduledoc "Slice 021: a write-risk tool that writes nothing; the gate asks before it runs." + @behaviour Trinity.Tools.Tool + + alias Trinity.Tools.Result + + @impl true + def name, do: "write_note" + @impl true + def description, do: "Pretends to write a note at a path." + @impl true + def schema, + do: %{ + "type" => "object", + "properties" => %{"path" => %{"type" => "string"}, "text" => %{"type" => "string"}}, + "required" => ["path"], + "additionalProperties" => false + } + + @impl true + def risk, do: :write + @impl true + def effect, do: :artifact + @impl true + def execute(%{"path" => path} = args, _ctx), + do: {:ok, Result.text("wrote #{byte_size(Map.get(args, "text", ""))} bytes to #{path}")} +end diff --git a/test/trinity/permissions/fingerprint_test.exs b/test/trinity/permissions/fingerprint_test.exs new file mode 100644 index 0000000..82a0bb7 --- /dev/null +++ b/test/trinity/permissions/fingerprint_test.exs @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.FingerprintTest do + @moduledoc "Slice 021 line 1: the canonical form is RFC 8785's (the RFC's own vector), and the fingerprint binds every field." + use ExUnit.Case, async: true + + alias Trinity.Permissions.Fingerprint + + test "jcs matches the RFC 8785 example vector byte for byte" do + input = "test/support/rfc8785_input.json" |> File.read!() |> Jason.decode!() + expected = File.read!("test/support/rfc8785_expected.txt") + assert Fingerprint.canonical(input) == expected + end + + test "keys are sorted and numbers are ES6 shortest, whatever the map's order" do + assert Fingerprint.canonical(%{"b" => 1, "a" => %{"z" => 2.0, "y" => [1, "x"]}}) == + ~s({"a":{"y":[1,"x"],"z":2},"b":1}) + end + + test "the same call is the same digest; a changed argument, scope or cwd is another" do + fp = Fingerprint.of("write_note", %{"path" => "/a", "text" => "t"}, "session:1", nil) + assert fp == Fingerprint.of("write_note", %{"text" => "t", "path" => "/a"}, "session:1", nil) + assert String.match?(fp, ~r/^[0-9a-f]{64}$/) + refute fp == Fingerprint.of("write_note", %{"path" => "/b", "text" => "t"}, "session:1", nil) + refute fp == Fingerprint.of("write_note", %{"path" => "/a", "text" => "t"}, "session:2", nil) + + refute fp == + Fingerprint.of("write_note", %{"path" => "/a", "text" => "t"}, "session:1", "/home") + + assert Fingerprint.version() == 1 + end +end diff --git a/test/trinity/permissions/gate_test.exs b/test/trinity/permissions/gate_test.exs new file mode 100644 index 0000000..7da62ca --- /dev/null +++ b/test/trinity/permissions/gate_test.exs @@ -0,0 +1,195 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.GateTest do + @moduledoc """ + Slice 021: the layered policy in isolation and in order, the Gate's rows and broadcasts, + AC6 (expiry) and AC7's auto half (every decision a row with decided_at). + """ + use Trinity.DataCase, async: false + + alias Trinity.Factory + alias Trinity.Permissions + alias Trinity.Permissions.Approval + + setup do + row = Factory.session!() + :ok = Permissions.subscribe(row.id) + {:ok, id: row.id} + end + + @args %{"path" => "/home/me/notes/a.md", "text" => "hi"} + + describe "the layers" do + test "the default by tier: read allows, write asks, an unmapped name asks", %{id: id} do + assert Permissions.decide(id, "echo", %{"text" => "x"}) == :allow + assert Permissions.decide(id, "write_note", @args) == :ask + assert Permissions.decide(id, "mcp:x:anything", %{}) == :ask + end + + test "a global rule with a glob allows inside the pattern and not outside (AC4's core)", %{ + id: id + } do + {:ok, _} = + Permissions.put_rule(%{ + tool: "write_note", + pattern: "path=/home/me/notes/*.md", + decision: "allow" + }) + + assert Permissions.decide(id, "write_note", @args) == :allow + assert Permissions.decide(id, "write_note", %{"path" => "/etc/hosts"}) == :ask + end + + test "a persona policy sits above global rules and below session grants", %{id: id} do + persona = %{settings: %{"permissions" => %{"write_note" => "deny"}}} + {:ok, _} = Permissions.put_rule(%{tool: "write_note", pattern: "*", decision: "allow"}) + assert Permissions.decide(id, "write_note", @args, persona: persona) == :deny + fp = Permissions.fingerprint(id, "write_note", @args, nil) + + {:ok, _} = + Permissions.put_rule(%{ + tool: "write_note", + pattern: "fp:" <> fp, + decision: "allow", + scope: Permissions.scope(id) + }) + + assert Permissions.decide(id, "write_note", @args, persona: persona) == :allow + end + + test "a session grant is bound to the fingerprint: other arguments ask, another session asks (AC3's core)", + %{id: id} do + fp = Permissions.fingerprint(id, "write_note", @args, nil) + + {:ok, _} = + Permissions.put_rule(%{ + tool: "write_note", + pattern: "fp:" <> fp, + decision: "allow", + scope: Permissions.scope(id) + }) + + assert Permissions.decide(id, "write_note", @args) == :allow + assert Permissions.decide(id, "write_note", Map.put(@args, "text", "changed")) == :ask + other = Factory.session!() + assert Permissions.decide(other.id, "write_note", @args) == :ask + end + + test "an expired session grant no longer allows", %{id: id} do + fp = Permissions.fingerprint(id, "write_note", @args, nil) + past = DateTime.add(DateTime.utc_now(), -1, :second) + + {:ok, _} = + Permissions.put_rule(%{ + tool: "write_note", + pattern: "fp:" <> fp, + decision: "allow", + scope: Permissions.scope(id), + expires_at: past + }) + + assert Permissions.decide(id, "write_note", @args) == :ask + end + end + + describe "the Gate" do + test "a request is a row, then a broadcast on the session's topic and on all", %{id: id} do + :ok = Permissions.subscribe(:all) + {:ok, a} = Permissions.request_approval(id, "write_note", @args) + assert %Approval{status: "pending", risk: "write", fingerprint: fp} = a + assert fp == Permissions.fingerprint(id, "write_note", @args, nil) + assert_receive {:approval, :requested, %Approval{id: aid}} + assert_receive {:approval, :requested, %Approval{id: ^aid}} + assert aid == a.id + assert [%Approval{id: ^aid}] = Permissions.pending(id) + end + + test "once: the row is allowed with decided_at and decider, the fingerprint allows one execution, then asks again", + %{id: id} do + {:ok, a} = Permissions.request_approval(id, "write_note", @args) + + assert {:ok, + %Approval{ + status: "allowed", + decision: "once", + decided_by: "liveview", + decided_at: %DateTime{} + }} = + Permissions.decide_request(a.id, :once) + + assert_receive {:approval, :decided, %Approval{id: aid, status: "allowed"}} + assert aid == a.id + assert Permissions.decide(id, "write_note", @args) == :allow + assert Permissions.decide(id, "write_note", @args) == :ask + assert Permissions.pending(id) == [] + end + + test "session: a grant row scoped to the session with an expiry; the second identical call allows", + %{id: id} do + {:ok, a} = Permissions.request_approval(id, "write_note", @args) + {:ok, _} = Permissions.decide_request(a.id, :session) + [rule] = Permissions.list_rules(scope: Permissions.scope(id)) + assert rule.pattern == "fp:" <> a.fingerprint and rule.expires_at != nil + assert Permissions.decide(id, "write_note", @args) == :allow + assert Permissions.decide(id, "write_note", @args) == :allow + end + + test "always: a global rule with the confirmed pattern; a call outside it still asks (AC4)", + %{id: id} do + {:ok, a} = Permissions.request_approval(id, "write_note", @args) + {:ok, _} = Permissions.decide_request(a.id, :always, pattern: "path=/home/me/notes/*.md") + [rule] = Permissions.list_rules(scope: "global") + assert rule.tool == "write_note" and rule.decision == "allow" + assert Permissions.decide(id, "write_note", @args) == :allow + assert Permissions.decide(id, "write_note", %{"path" => "/home/me/secrets/k"}) == :ask + assert :ok = Permissions.revoke_rule(rule.id) + assert Permissions.decide(id, "write_note", @args) == :ask + end + + test "deny: the row is denied, the fingerprint is denied once, then asks again (AC5's core)", + %{id: id} do + {:ok, a} = Permissions.request_approval(id, "write_note", @args) + + {:ok, %Approval{status: "denied", decision: "deny"}} = + Permissions.decide_request(a.id, :deny) + + assert Permissions.decide(id, "write_note", @args) == :deny + assert Permissions.decide(id, "write_note", @args) == :ask + end + + test "a decided request cannot be decided twice; an unknown id is not found", %{id: id} do + {:ok, a} = Permissions.request_approval(id, "write_note", @args) + {:ok, _} = Permissions.decide_request(a.id, :deny) + assert {:error, {:already_decided, "denied"}} = Permissions.decide_request(a.id, :once) + assert {:error, :not_found} = Permissions.decide_request(Trinity.UUID.generate(), :once) + end + + test "AC6: an undecided request expires into a denial decided by expiry, within the configured timeout", + %{id: id} do + {:ok, a} = Permissions.request_approval(id, "write_note", @args) + + assert_receive {:approval, :decided, + %Approval{ + id: aid, + status: "expired", + decision: "deny", + decided_by: "expiry" + }}, + 1_000 + 500 + + assert aid == a.id + assert %Approval{decided_at: %DateTime{}} = Permissions.get_approval(a.id) + assert Permissions.decide(id, "write_note", @args) == :ask + end + + test "AC7: every decision is a row with decided_at, listed newest first", %{id: id} do + {:ok, a1} = Permissions.request_approval(id, "write_note", @args) + {:ok, a2} = Permissions.request_approval(id, "write_note", %{"path" => "/b"}) + {:ok, _} = Permissions.decide_request(a1.id, :once) + {:ok, _} = Permissions.decide_request(a2.id, :deny) + rows = Permissions.list_approvals(session_id: id) + assert Enum.map(rows, & &1.id) == [a2.id, a1.id] + assert Enum.all?(rows, &(&1.decided_at != nil and &1.decided_by == "liveview")) + end + end +end diff --git a/test/trinity/permissions/rule_test.exs b/test/trinity/permissions/rule_test.exs new file mode 100644 index 0000000..b6624a9 --- /dev/null +++ b/test/trinity/permissions/rule_test.exs @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.RuleTest do + @moduledoc "Slice 021: the pattern language of tool_permissions rows." + use ExUnit.Case, async: true + + alias Trinity.Permissions.Rule + + test "* matches anything; fp: matches the fingerprint only" do + assert Rule.matches?("*", %{"x" => 1}, nil) + assert Rule.matches?("fp:abc", %{}, "abc") + refute Rule.matches?("fp:abc", %{}, "abd") + end + + test "key=glob: * within a segment, ** across, ? one character, a trailing * is a prefix" do + assert Rule.matches?("path=/home/me/notes/*.md", %{"path" => "/home/me/notes/a.md"}, nil) + refute Rule.matches?("path=/home/me/notes/*.md", %{"path" => "/home/me/notes/sub/a.md"}, nil) + assert Rule.matches?("path=/home/me/**", %{"path" => "/home/me/notes/sub/a.md"}, nil) + refute Rule.matches?("path=/home/me/**", %{"path" => "/etc/passwd"}, nil) + assert Rule.matches?("command=git *", %{"command" => "git status"}, nil) + refute Rule.matches?("command=git *", %{"command" => "rm -rf /"}, nil) + assert Rule.matches?("n=?", %{"n" => 7}, nil) + refute Rule.matches?("path=/a", %{"other" => "/a"}, nil) + end + + test "re: is accepted from a hand-edited row; a broken regex matches nothing" do + assert Rule.matches?("re:^git (status|log)$", %{"command" => "git log"}, nil) + refute Rule.matches?("re:[", %{"command" => "x"}, nil) + end + + test "the changeset refuses a pattern outside the language and a scope outside the vocabulary" do + ok = + Rule.changeset(%Rule{}, %{ + tool: "t", + pattern: "path=/x/*", + decision: "allow", + scope: "global" + }) + + assert ok.valid? + + bad = + Rule.changeset(%Rule{}, %{ + tool: "t", + pattern: "nonsense", + decision: "allow", + scope: "global" + }) + + refute bad.valid? + bad2 = Rule.changeset(%Rule{}, %{tool: "t", pattern: "*", decision: "maybe", scope: "team"}) + assert Keyword.has_key?(bad2.errors, :decision) and Keyword.has_key?(bad2.errors, :scope) + end +end diff --git a/test/trinity/permissions/session_flow_test.exs b/test/trinity/permissions/session_flow_test.exs new file mode 100644 index 0000000..4cbe1a2 --- /dev/null +++ b/test/trinity/permissions/session_flow_test.exs @@ -0,0 +1,162 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.SessionFlowTest do + @moduledoc """ + Slice 021 through a Session: AC1 (a read tool never reaches the Gate), AC2's test half + (approval_wait, allow once, the tool runs, the final message), AC3, AC5 and AC8. + """ + use Trinity.SessionCase + @moduletag :capture_log + + alias Trinity.Factory + alias Trinity.LLM.Providers.Fake + alias Trinity.Permissions + alias Trinity.Permissions.Approval + + setup do + row = Factory.session!() + :ok = Sessions.subscribe(row.id) + :ok = Permissions.subscribe(row.id) + {:ok, id: row.id} + end + + @args %{"path" => "/home/me/notes/a.md", "text" => "hi"} + + defp tool_turn(calls, final \\ "done ") do + starts = for {id, name, _} <- calls, do: {:tool_call_start, id, name} + ends = for {id, _, args} <- calls, do: {:tool_call_end, id, args} + + Fake.scripts([ + starts ++ ends ++ [{:usage, %{input_tokens: 1, output_tokens: 1}}, {:done, :tool_calls}], + script_deltas(2, final) + ]) + end + + defp tool_rows(id), do: id |> Sessions.history() |> Enum.filter(&(&1.role == "tool")) + + test "AC1: a read-risk tool runs with no request: the Gate sees nothing", %{id: id} do + :ok = Permissions.subscribe(:all) + tool_turn([{"c1", "echo", %{"text" => "plain"}}]) + {:ok, pid} = start_drained(id) + {:ok, _} = Session.send_user_message(pid, "go") + events = collect(id, &match?({:state, :idle}, &1)) + refute :approval_wait in for({:state, s} <- events, do: s) + refute_received {:approval, _, _} + assert Permissions.list_approvals(session_id: id) == [] + assert [%{content: "plain"}] = tool_rows(id) + end + + test "AC2 (test half): a write-risk call enters approval_wait; allow once runs it; the final message follows", + %{id: id} do + tool_turn([{"c1", "write_note", @args}]) + {:ok, pid} = start_drained(id) + {:ok, _} = Session.send_user_message(pid, "write") + + assert_receive {:approval, :requested, + %Approval{id: aid, tool: "write_note", status: "pending"}}, + 2_000 + + _ = collect(id, &match?({:state, :approval_wait}, &1)) + assert %{state: :approval_wait} = Session.state(pid) + assert [%Approval{id: ^aid}] = Permissions.pending(id) + + {:ok, _} = Permissions.decide_request(aid, :once) + events = collect(id, &match?({:state, :idle}, &1)) + + assert {:assistant_message, %{content: "done done "}} = + Enum.find(events, &match?({:assistant_message, _}, &1)) + + assert [row] = tool_rows(id) + assert row.parts["ok"] == true and row.content =~ "wrote 2 bytes" + assert %Approval{status: "allowed", consumed_at: %DateTime{}} = Permissions.get_approval(aid) + end + + test "AC3: allow for session: the second identical call runs without asking; other arguments ask; a new session asks", + %{id: id} do + tool_turn([{"c1", "write_note", @args}]) + {:ok, pid} = start_drained(id) + {:ok, _} = Session.send_user_message(pid, "one") + assert_receive {:approval, :requested, %Approval{id: aid}}, 2_000 + {:ok, _} = Permissions.decide_request(aid, :session) + _ = collect(id, &match?({:state, :idle}, &1)) + + # The same call again: no request. + tool_turn([{"c1", "write_note", @args}]) + {:ok, _} = Session.send_user_message(pid, "two") + events = collect(id, &match?({:state, :idle}, &1)) + refute :approval_wait in for({:state, s} <- events, do: s) + refute_received {:approval, :requested, _} + assert length(tool_rows(id)) == 2 + + # Other arguments under the same grant: a request again (M2: the fingerprint differs). + tool_turn([{"c1", "write_note", Map.put(@args, "text", "changed")}]) + {:ok, _} = Session.send_user_message(pid, "three") + assert_receive {:approval, :requested, %Approval{id: aid3}}, 2_000 + {:ok, _} = Permissions.decide_request(aid3, :deny) + _ = collect(id, &match?({:state, :idle}, &1)) + + # A new session: asks. + other = Factory.session!() + :ok = Permissions.subscribe(other.id) + tool_turn([{"c1", "write_note", @args}]) + {:ok, opid} = start_drained(other.id) + {:ok, _} = Session.send_user_message(opid, "one") + assert_receive {:approval, :requested, %Approval{session_id: sid}}, 2_000 + assert sid == other.id + end + + test "AC5: deny: the tool row carries a denial the model reads; the turn goes on to a final message", + %{id: id} do + tool_turn([{"c1", "write_note", @args}, {"c2", "echo", %{"text" => "fine"}}], "after denial ") + {:ok, pid} = start_drained(id) + {:ok, _} = Session.send_user_message(pid, "write") + assert_receive {:approval, :requested, %Approval{id: aid}}, 2_000 + {:ok, _} = Permissions.decide_request(aid, :deny) + events = collect(id, &match?({:state, :idle}, &1)) + + assert Enum.any?( + events, + &match?({:assistant_message, %{content: "after denial after denial "}}, &1) + ) + + rows = tool_rows(id) + assert Enum.map(rows, & &1.tool_call_id) == ["c2", "c1"] + denied = Enum.find(rows, &(&1.tool_call_id == "c1")) + assert denied.parts["ok"] == false and denied.content == "error: :denied" + + assert Enum.map(Sessions.history(id), & &1.role) == [ + "user", + "assistant", + "tool", + "tool", + "assistant" + ] + end + + test "AC8: killed in approval_wait, the request is still pending and decidable; the restarted session is idle", + %{id: id} do + tool_turn([{"c1", "write_note", @args}]) + {:ok, pid} = start_drained(id) + {:ok, _} = Session.send_user_message(pid, "write") + assert_receive {:approval, :requested, %Approval{id: aid}}, 2_000 + _ = collect(id, &match?({:state, :approval_wait}, &1)) + + Process.exit(pid, :kill) + _ = collect(id, &match?({:state, :idle}, &1)) + {:ok, new_pid} = Sessions.ensure_started(id) + assert new_pid != pid + assert %{state: :idle, pending: []} = Session.state(new_pid) + + assert [%Approval{id: ^aid, status: "pending"}] = Permissions.pending(id) + + assert {:ok, %Approval{status: "allowed", decided_at: %DateTime{}}} = + Permissions.decide_request(aid, :once) + + assert_receive {:approval, :decided, %Approval{id: ^aid}} + assert Permissions.pending(id) == [] + # The turn that asked is gone with the process (012 AC9); the grant was recorded, not run. + assert tool_rows(id) == [] + {:ok, _} = Session.send_user_message(new_pid, "again") + _ = collect(id, &match?({:state, :idle}, &1)) + end +end diff --git a/test/trinity/tools/registry_test.exs b/test/trinity/tools/registry_test.exs index 433c011..73e7cdc 100644 --- a/test/trinity/tools/registry_test.exs +++ b/test/trinity/tools/registry_test.exs @@ -19,7 +19,7 @@ defmodule Trinity.Tools.RegistryTest do describe "AC1: a module plus one config line" do test "the four test tools are listed, in the core toolset, with schemas that build and digests" do names = Enum.map(Tools.list(), & &1.name) - assert names == ["big", "crash", "echo", "sleep"] + assert names == ["big", "crash", "echo", "sleep", "write_note"] assert Enum.map(Tools.list(toolset: :core), & &1.name) == names for entry <- Tools.list() do @@ -31,7 +31,7 @@ defmodule Trinity.Tools.RegistryTest do test "to_llm_tools/0 carries name, description and parameters for each" do tools = Tools.to_llm_tools() - assert Enum.map(tools, & &1.name) == ["big", "crash", "echo", "sleep"] + assert Enum.map(tools, & &1.name) == ["big", "crash", "echo", "sleep", "write_note"] for t <- tools do assert is_binary(t.description) and t.description != "" @@ -66,7 +66,8 @@ defmodule Trinity.Tools.RegistryTest do assert {:ok, %{module: TestTools.Echo}} = Tools.lookup("echo") assert {:ok, _} = Tools.register(TestTools.DynamicEcho) assert Trinity.Permissions.tier("mcp:fake:echo") == :ask - assert Trinity.Permissions.tier("echo") == :ask, "no core name is mapped at this slice" + # Slice 021: a core tool's declared risk is its tier, handed over by the registry. + assert Trinity.Permissions.tier("echo") == :read end test "a runtime registration claiming :catalog is refused by name" do diff --git a/test/trinity/tools/runner_test.exs b/test/trinity/tools/runner_test.exs index 698ffce..90bcb9c 100644 --- a/test/trinity/tools/runner_test.exs +++ b/test/trinity/tools/runner_test.exs @@ -156,7 +156,7 @@ defmodule Trinity.Tools.RunnerTest do on_exit(fn -> Application.delete_env(:trinity, :permissions_policy) end) Trinity.Permissions.PolicyMock - |> expect(:decide, 2, fn ^id, name, %{"text" => _} when name == "echo" -> :allow end) + |> expect(:decide, 2, fn ^id, name, %{"text" => _}, _opts when name == "echo" -> :allow end) tool_turn([{"c1", "echo", %{"text" => "a"}}, {"c2", "echo", %{"text" => "b"}}]) {_events, _elapsed, history} = run_turn(id) @@ -166,7 +166,7 @@ defmodule Trinity.Tools.RunnerTest do test "a denied call is an error row and execute/2 is not reached", %{id: id} do Application.put_env(:trinity, :permissions_policy, Trinity.Permissions.PolicyMock) on_exit(fn -> Application.delete_env(:trinity, :permissions_policy) end) - expect(Trinity.Permissions.PolicyMock, :decide, fn _, "crash", _ -> :deny end) + expect(Trinity.Permissions.PolicyMock, :decide, fn _, "crash", _, _ -> :deny end) tool_turn([{"c1", "crash", %{}}]) {_events, _elapsed, history} = run_turn(id) [row] = tool_rows(history) @@ -179,7 +179,15 @@ defmodule Trinity.Tools.RunnerTest do {_events, _elapsed, history} = run_turn(id) first = Enum.at(history, 1) assert first.provider_meta["tool_surface"] == Tools.surface() - assert Map.keys(first.provider_meta["tool_surface"]) == ["big", "crash", "echo", "sleep"] + + assert Map.keys(first.provider_meta["tool_surface"]) == [ + "big", + "crash", + "echo", + "sleep", + "write_note" + ] + assert Tools.surface_diff(history) == [%{seq: 2, name: "get_weather", reason: :undeclared}] end end diff --git a/test/trinity_web/live/approval_live_test.exs b/test/trinity_web/live/approval_live_test.exs new file mode 100644 index 0000000..1752273 --- /dev/null +++ b/test/trinity_web/live/approval_live_test.exs @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.ApprovalLiveTest do + @moduledoc "Slice 021 line 7: the card renders on a request, each button decides through the gate, /permissions lists rows." + use TrinityWeb.ConnCase, async: false + @moduletag :capture_log + + import Phoenix.LiveViewTest + import Trinity.SessionCase, only: [script_deltas: 2, collect: 3] + + alias Trinity.Factory + alias Trinity.LLM.Providers.Fake + alias Trinity.Permissions + alias Trinity.Permissions.Approval + alias Trinity.Sessions + + setup do + Fake.clear() + on_exit(fn -> Trinity.SessionCase.stop_all_sessions() end) + row = Factory.session!() + :ok = Sessions.subscribe(row.id) + :ok = Permissions.subscribe(row.id) + {:ok, id: row.id} + end + + @args %{"path" => "/home/me/notes/a.md", "text" => "hi"} + + defp write_turn do + Fake.scripts([ + [ + {:tool_call_start, "c1", "write_note"}, + {:tool_call_end, "c1", @args}, + {:usage, %{input_tokens: 1, output_tokens: 1}}, + {:done, :tool_calls} + ], + script_deltas(2, "written ") + ]) + end + + defp send_message(view, text), + do: view |> form("#composer", %{"content" => text}) |> render_submit() + + test "AC2 (LiveView half): the card renders with tool, risk and arguments; Allow once runs the tool and the final message follows", + %{conn: conn, id: id} do + write_turn() + {:ok, view, _} = live(conn, ~p"/s/#{id}") + send_message(view, "write it") + assert_receive {:approval, :requested, %Approval{id: aid}}, 2_000 + _ = collect(id, &match?({:state, :approval_wait}, &1), 2_000) + html = render(view) + assert has_element?(view, "#approval-#{aid}") + assert html =~ "write_note" and html =~ "write" and html =~ "/home/me/notes/a.md" + assert has_element?(view, "#status[data-status=approval_wait]") + assert has_element?(view, "#pending-approvals", "1") + assert has_element?(view, "#pattern-#{aid}[value='path=/home/me/notes/*']") + + view |> element("#approval-#{aid} button", "Allow once") |> render_click() + _ = collect(id, &match?({:state, :idle}, &1), 5_000) + html = render(view) + refute has_element?(view, "#approval-#{aid}") + assert html =~ "wrote 2 bytes" + assert html =~ "written written" + + assert %Approval{status: "allowed", decision: "once", decided_by: "liveview"} = + Permissions.get_approval(aid) + + refute has_element?(view, "#pending-approvals") + end + + test "Always allow writes the edited pattern as a rule; Deny denies", %{conn: conn, id: id} do + write_turn() + {:ok, view, _} = live(conn, ~p"/s/#{id}") + send_message(view, "write it") + assert_receive {:approval, :requested, %Approval{id: aid}}, 2_000 + _ = collect(id, &match?({:state, :approval_wait}, &1), 2_000) + + view |> form("#always-#{aid}", %{"pattern" => "path=/home/me/**"}) |> render_change() + view |> element("#approval-#{aid} button", "Always allow") |> render_click() + _ = collect(id, &match?({:state, :idle}, &1), 5_000) + + assert [%{pattern: "path=/home/me/**", scope: "global", decision: "allow"}] = + Permissions.list_rules() + + # Under the rule the next call runs without a card. + write_turn() + send_message(view, "again") + events = collect(id, &match?({:state, :idle}, &1), 5_000) + refute :approval_wait in for({:state, s} <- events, do: s) + + # A call outside the rule asks, and Deny records the denial. + Fake.scripts([ + [ + {:tool_call_start, "c1", "write_note"}, + {:tool_call_end, "c1", %{"path" => "/etc/hosts"}}, + {:usage, %{input_tokens: 1, output_tokens: 1}}, + {:done, :tool_calls} + ], + script_deltas(1, "after") + ]) + + send_message(view, "outside") + assert_receive {:approval, :requested, %Approval{id: aid2}}, 2_000 + _ = collect(id, &match?({:state, :approval_wait}, &1), 2_000) + view |> element("#approval-#{aid2} button", "Deny") |> render_click() + _ = collect(id, &match?({:state, :idle}, &1), 5_000) + assert %Approval{status: "denied"} = Permissions.get_approval(aid2) + assert render(view) =~ "error: :denied" + end + + test "/permissions lists every decision with its time and decider, the pending ones decidable, the rules revocable (AC7's page)", + %{conn: conn, id: id} do + {:ok, a1} = Permissions.request_approval(id, "write_note", @args) + {:ok, _} = Permissions.decide_request(a1.id, :session) + {:ok, a2} = Permissions.request_approval(id, "write_note", %{"path" => "/b"}) + + {:ok, view, html} = live(conn, ~p"/permissions") + assert has_element?(view, "#approval-row-#{a1.id}", "allowed") + assert has_element?(view, "#approval-row-#{a1.id}", "liveview") + assert has_element?(view, "#approval-row-#{a2.id}", "pending") + assert has_element?(view, "#approval-#{a2.id}") + assert html =~ "fp:" <> a1.fingerprint + [rule] = Permissions.list_rules() + assert has_element?(view, "#rule-#{rule.id}") + + view |> element("#approval-#{a2.id} button", "Deny") |> render_click() + assert has_element?(view, "#approval-row-#{a2.id}", "denied") + refute has_element?(view, "#approval-#{a2.id}") + + view |> element("#rule-#{rule.id} button", "Revoke") |> render_click() + refute has_element?(view, "#rule-#{rule.id}") + assert Permissions.list_rules() == [] + end + + test "the header indicator counts everyone's pending requests on every page", %{ + conn: conn, + id: id + } do + other = Factory.session!() + {:ok, view, _} = live(conn, ~p"/s/#{id}") + refute has_element?(view, "#pending-approvals") + {:ok, a} = Permissions.request_approval(other.id, "write_note", @args) + assert_receive_approval(a.id) + assert has_element?(view, "#pending-approvals", "1") + refute has_element?(view, "#approval-#{a.id}"), "another session's card does not render here" + {:ok, _} = Permissions.decide_request(a.id, :deny) + _ = render(view) + refute has_element?(view, "#pending-approvals") + end + + defp assert_receive_approval(id) do + :ok = Permissions.subscribe(:all) + # The page received the broadcast before this process subscribed; a render after a call + # to the gate is a render after the page handled it. + Process.sleep(50) + _ = id + end +end