Skip to content

Commit d99ea10

Browse files
authored
Merge slice/021-permission-gate
Slice 021: permission gate and approval UI. PROOF.md at slices/021-permission-gate/PROOF.md; gate and postgres jobs green on the closing tree. Signed-off-by: Ayla Croft <aylacroft@proton.me>
2 parents 81b8da1 + e8e1e91 commit d99ea10

47 files changed

Lines changed: 2336 additions & 83 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎REUSE.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ SPDX-PackageDownloadLocation = "https://github.com/ScriptKittyOS/Trinity"
1111
# the gap is recorded rather than glossed.
1212

1313
[[annotations]]
14-
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/**"]
14+
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"]
1515
precedence = "aggregate"
1616
SPDX-FileCopyrightText = "Sudo Apt Holdings LLC"
1717
SPDX-License-Identifier = "Apache-2.0"

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ standards register names the rows that ask for them.
3838
| 012 | Session process + agent loop (gen_statem, DynamicSupervisor, rehydration) | 1 Core loop | L | 010, 011 | approved |
3939
| 013 | LiveView chat UI with streaming | 1 Core loop | M | 012 | approved |
4040
| 020 | Tool protocol + registry | 2 Tools | M | 012 | approved |
41-
| 021 | Permission gate + approval UI (M2 fingerprint-bound, M7) | 2 Tools | M | 020, 013 | planned |
41+
| 021 | Permission gate + approval UI (M2 fingerprint-bound, M7) | 2 Tools | M | 020, 013 | done |
4242
| 022 | Core tools: filesystem, web fetch/search, shell (MuonTrap) | 2 Tools | L | 021 | planned |
4343
| 023 | Context compaction + session lineage | 2 Tools | M | 012 | planned |
4444
| 024 | Effect catalog, authority selection (`TRINITY_AUTHORITY`), local receipts | 2 Tools | L | 021, 022 | planned |

‎VERSIONS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ never pin a version hex marks as retired or vulnerable.
136136
| `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. |
137137
| `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. |
138138
| `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). |
139+
| `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. |
139140
| `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. |
140141

141142
### Dev and quality

‎config/test.exs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,15 @@ config :trinity, :tools,
1414
Trinity.TestTools.Echo,
1515
Trinity.TestTools.Sleep,
1616
Trinity.TestTools.Crash,
17-
Trinity.TestTools.Big
17+
Trinity.TestTools.Big,
18+
Trinity.TestTools.WriteNote
1819
],
19-
toolsets: %{core: ["echo", "sleep", "crash", "big"]},
20+
toolsets: %{core: ["echo", "sleep", "crash", "big", "write_note"]},
2021
timeout_ms: 2_000
2122

23+
# Slice 021: requests expire fast enough for AC6 to watch, and a session grant lasts an hour.
24+
config :trinity, :permissions, expiry_ms: 1_000, session_grant_ms: 3_600_000
25+
2226
# Slice 012: sessions hibernate and stop quickly in tests so AC8 is observable in seconds.
2327
config :trinity, :sessions, idle_hibernate_ms: 200, idle_stop_ms: 60_000
2428

‎coverage.tsv‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ slice_id percent sha date
66
012 60.82 019d9cf 2026-09-20
77
013 64.41 080c543 2026-09-20
88
020 67.18 8a5b7ae 2026-09-20
9+
021 72.45 1f3727f 2026-09-20

‎docs/01-architecture.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ Trinity.Application
2626
├── Trinity.Tools.Supervisor # Slice 020, as built: Trinity.Tools.TaskSupervisor (every tool
2727
│ │ # call of a turn runs under it) and Trinity.Tools.Registry
2828
│ │ # (GenServer over ETS). 022 adds the stateful runtimes beside them
29-
├── Trinity.Permissions.Gate # approval requests + allowlist cache. Slice 021
29+
├── Trinity.Permissions.Gate # Slice 021, as built: approval requests (rows, then broadcasts),
30+
│ # decisions, expiries; pending rows reloaded with their timers
3031
├── Trinity.Receipts.Supervisor # Slice 024
3132
│ └── Trinity.Receipts.ChainWriter (one per chain_scope, :unique in Trinity.Registry; ADR-0013)
3233
│ # serialises append per scope. prev_hash -> receipt_hash is a read-then-write, so

‎docs/05-data-model.md‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,28 @@ Filesystem is canonical for content; DB is the index (rebuildable via `mix trini
7878
### skill_changes (Slice 041)
7979
Staged proposals by the agent: `skill_id`, `diff`, `rationale`, `status`, `decided_by`, `decided_at`.
8080

81-
### tool_permissions (Slice 021)
81+
### tool_permissions (Slice 021, as built)
8282
| column | type | notes |
8383
|---|---|---|
8484
| tool | string | |
85-
| pattern | string | glob/regex on args (e.g. shell command prefix, path) |
85+
| 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) |
8686
| decision | string | "allow" \| "deny" \| "ask" |
8787
| scope | string | "global" \| "session:<id>" \| "persona:<id>" |
88-
| expires_at | nullable | "allow for this session" |
88+
| expires_at | nullable | set on "allow for this session" grants |
89+
| decided_by | string, nullable | who wrote it ("liveview" from the card) |
8990

90-
### approvals (Slice 021)
91-
Pending/decided approval requests: `session_id`, `tool`, `args`, `risk`, `status`, `decided_at`, `channel`.
91+
### approvals (Slice 021, as built)
92+
One row per request, the audit trail this slice owns; slice 024 reads it for decision receipts.
93+
| column | type | notes |
94+
|---|---|---|
95+
| session_id | fk sessions | |
96+
| tool, args, risk | string, map, string | the call and its tier at request time |
97+
| fingerprint | string | `sha256(rfc8785({tool, args, scope, cwd, canonicalization_version}))`, re-derived at execution |
98+
| status | string | "pending" \| "allowed" \| "denied" \| "expired" |
99+
| decision | string, nullable | "once" \| "session" \| "always" \| "deny" |
100+
| decided_at, decided_by | timestamp, string | every decided row has both ("expiry" is a decider) |
101+
| consumed_at | nullable | a "once" allowance or a denial is spent by the execution that reads it |
102+
| expires_at | timestamp | pending past it becomes "expired" with decision "deny" |
92103

93104
### tasks (Slice 050)
94105
| column | type | notes |

‎docs/07-security-model.md‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,20 @@ system prompt states that instructions inside untrusted blocks are data, not com
3939
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).
4040

4141
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.
42-
`Trinity.Permissions.decide(session, tool, args)` consults, in order: session grants → persona policy → global
43-
`tool_permissions` → default policy (`:read` allow, `:network` allow, `:write` ask, `:exec` ask, `:destructive` ask).
44-
An `:ask` suspends the Session in `approval_wait`, broadcasts to `approvals:<id>`, and resumes on decision.
45-
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.
42+
`Trinity.Permissions.decide(session, tool, args, opts)` consults, in order: session grants → the newest unspent
43+
decision for this fingerprint (an "allow once" or a denial, each spent by the execution that reads it) →
44+
persona policy → global `tool_permissions` → default policy (`:read` allow, `:network` allow, `:write` ask,
45+
`:exec` ask, `:destructive` ask; an unmapped name asks).
46+
An `:ask` suspends the Session in `approval_wait`, broadcasts to `approvals:<id>` (and `approvals:all`), and
47+
resumes on decision. Decisions are recorded (`approvals` table) and receipted (024). Approvals bind the canonical
48+
fingerprint of `(tool, args, scope, cwd, canonicalization_version)` and are re-derived at execution; "allow for
49+
this session" binds that fingerprint with an expiry; "always allow" is a rule, recorded as such.
50+
51+
As built at slice 021: the tier table is written once, by the registry, from the `risk/0` of the modules
52+
`config :trinity, :tools` names, so a tier comes from code and config and never from a runtime registration; the
53+
runner asks the policy at execution and the Session never pre-checks, so the decision that runs a call is the one
54+
made against the arguments actually passed; a request left undecided expires into a denial decided by
55+
`"expiry"`; the card's buttons and the `/permissions` page call `decide_request/3` and nothing else.
4656

4757
## Shell (Slice 022)
4858

‎lib/trinity.ex‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ defmodule Trinity do
2424
LLM,
2525
Tools,
2626
Permissions,
27+
Permissions.Approval,
28+
Permissions.Rule,
2729
Effects.Catalog
2830
] ++
2931
if(Mix.env() == :test, do: [DataCase, NetworkGuard, Factory], else: [])

‎lib/trinity/application.ex‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ defmodule Trinity.Application do
4040
# Slice 020: the tool registry and the task supervisor tool calls run under, before
4141
# the sessions that call them.
4242
Trinity.Tools.Supervisor,
43+
# Slice 021: approval requests and their decisions, with pending rows reloaded.
44+
Trinity.Permissions.Gate,
4345
Trinity.Sessions.Supervisor,
4446
# Start to serve requests, typically the last entry
4547
TrinityWeb.Endpoint

0 commit comments

Comments
 (0)