Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions VERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion docs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 16 additions & 5 deletions docs/05-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<hex>` (a session grant bound to a fingerprint), `re:<regex>` (hand-edited rows only) |
| decision | string | "allow" \| "deny" \| "ask" |
| scope | string | "global" \| "session:<id>" \| "persona:<id>" |
| 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 |
Expand Down
18 changes: 14 additions & 4 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<server>:<tool>`, `skill:<name>`. 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:<id>`, 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:<id>` (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)

Expand Down
2 changes: 2 additions & 0 deletions lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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: [])
Expand Down
2 changes: 2 additions & 0 deletions lib/trinity/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
150 changes: 114 additions & 36 deletions lib/trinity/permissions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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:<session_id>` 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
Loading
Loading