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 ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ standards register names the rows that ask for them.
| 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 | approved |
| 022 | Core tools: filesystem, web fetch/search, shell (MuonTrap) | 2 Tools | L | 021 | planned |
| 022 | Core tools: filesystem, web fetch/search, shell (MuonTrap) | 2 Tools | L | 021 | done |
| 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 |
| 025 | Encryption at rest, and the key-custody seam | 2 Tools | M | 010, 024 | planned |
Expand Down
4 changes: 2 additions & 2 deletions VERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ never pin a version hex marks as retired or vulnerable.

| Name | Pin | Verified | Note |
|---|---|---|---|
| `muontrap` | ~> 2.0 | 🔍 not yet a dependency | Shell tool. Linux cgroups optional. ⚠️ The pin was `~> 1.8`, which cannot resolve the current major. A major bump is an API review, not a version bump: re-read the child-kill guarantee against 2.0 before Slice 022. Added at Slice 022. |
| `floki` | ~> 0.38 | 🔍 not yet a dependency | HTML parsing. Added at Slice 022. |
| `muontrap` | ~> 2.0 | ✅ in `mix.lock` | The shell tool's process wrapper (`Trinity.Tools.Shell.Run`, Slice 022): a C port, SIGTERM then SIGKILL, the child dies with the port. Read against 2.0.0 at Slice 022: `cmd/3` takes `:timeout` (SIGTERM at expiry, `:timeout` as the status), `:delay_to_sigkill`, `:cd`, `:env`, optional cgroup v2 limits. ⚠️ POSIX only: declared in mix.exs on a Unix host alone; the shell tool is unavailable on Windows (NOTES.md, the Windows decision). |
| `floki` | ~> 0.38 | ✅ in `mix.lock` | HTML to text for `web_fetch` (Slice 022): script, style, nav, header, footer and aside dropped, the body's text taken. |
| `luerl (+ sandbox)` | latest | 🔍 not a single package | Slice 110 only. Two packages, so no single lock key. |
| `burrito` | ~> 1.6 | ✅ in `mix.lock` | ⚠️ ERTS availability drives the OTP pin, and Slice 000 measured it: only the OTP 28 line is fetchable for macOS and Linux. Corrected 2026-09-05: this row previously read `~> 1.5 / 1.5.0 ✅`; that mark was not measured. Added at Slice 001. |
| `ex_tauri` | ~> 0.2 | ✅ in `mix.lock` | ⚠️ Declares `otp_release: "~> 27.0"`, and Slice 000's probe refutes the reason it gives: OTP 28 macOS universal returns 200 and OTP 27 returns 404. Whether it runs on the pinned OTP is Slice 001's first measurement. ⚠️ 439 downloads all-time, so the ADR-0004 fallback matrix carries real weight. |
Expand Down
25 changes: 25 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,31 @@ if nif_target != "" do
config :mdex_native, MDExNative.Native, target: nif_target
end

# Slice 022: the core tools, every environment, and the toolsets they belong to. A tool is a
# module implementing Trinity.Tools.Tool plus a line here (docs/03). The shell answers
# available?/0 false on Windows and is skipped there with a logged reason.
config :trinity, :tools,
modules: [
Trinity.Tools.FS.Read,
Trinity.Tools.FS.Write,
Trinity.Tools.FS.Edit,
Trinity.Tools.FS.List,
Trinity.Tools.FS.Glob,
Trinity.Tools.FS.Grep,
Trinity.Tools.Web.Fetch,
Trinity.Tools.Web.Search,
Trinity.Tools.Shell.Run
],
toolsets: %{
fs: ["fs_read", "fs_write", "fs_edit", "fs_list", "fs_glob", "fs_grep"],
web: ["web_fetch", "web_search"],
shell: ["shell"]
}

# Slice 022: the filesystem roots beside the data directory (always a root) and the session's
# working directory. Empty here; config/runtime.exs reads TRINITY_FS_ROOTS (colon-separated).
config :trinity, :fs, roots: []

# Slice 011: the model registry lives in its own file so the live test suite can read it
# without evaluating the environment-specific imports below.
import_config "llm.exs"
Expand Down
12 changes: 12 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ if config_env() == :dev do
http: [port: String.to_integer(System.get_env("PORT", "4000"))]
end

# Slice 022: the filesystem roots a session may read and write without asking, beside the
# data directory and the session's working directory: TRINITY_FS_ROOTS, colon-separated.
if roots = System.get_env("TRINITY_FS_ROOTS") do
config :trinity, :fs, roots: String.split(roots, ":", trim: true)
end

# Slice 022: the search provider's key is read at call time from BRAVE_SEARCH_API_KEY; the
# provider module is configuration so a fake can stand in.
if config_env() != :test do
config :trinity, :web, search_provider: Trinity.Tools.Web.SearchProvider.Brave
end

# Slice 013. `TRINITY_FAKE_PROVIDER=1 mix phx.server` runs the chat on the scripted provider:
# the registry becomes the fake's two entries and a fresh stream answers with its markdown
# demo, so the UI can be exercised and screenshotted with no key and no egress. Development
Expand Down
22 changes: 20 additions & 2 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,29 @@ config :trinity, :tools,
Trinity.TestTools.Sleep,
Trinity.TestTools.Crash,
Trinity.TestTools.Big,
Trinity.TestTools.WriteNote
Trinity.TestTools.WriteNote,
# Slice 022: the core tools beside the test ones (this key replaces config.exs's list).
Trinity.Tools.FS.Read,
Trinity.Tools.FS.Write,
Trinity.Tools.FS.Edit,
Trinity.Tools.FS.List,
Trinity.Tools.FS.Glob,
Trinity.Tools.FS.Grep,
Trinity.Tools.Web.Fetch,
Trinity.Tools.Web.Search,
Trinity.Tools.Shell.Run
],
toolsets: %{core: ["echo", "sleep", "crash", "big", "write_note"]},
toolsets: %{
core: ["echo", "sleep", "crash", "big", "write_note"],
fs: ["fs_read", "fs_write", "fs_edit", "fs_list", "fs_glob", "fs_grep"],
web: ["web_fetch", "web_search"],
shell: ["shell"]
},
timeout_ms: 2_000

# Slice 022: the web search provider in tests is the fake; the tests' roots are set per test.
config :trinity, :web, search_provider: Trinity.Tools.Web.SearchProvider.Fake

# 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

Expand Down
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ slice_id percent sha date
013 64.41 080c543 2026-09-20
020 67.18 8a5b7ae 2026-09-20
021 72.45 1f3727f 2026-09-20
022 74.85 1fb1372 2026-09-20
29 changes: 29 additions & 0 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,42 @@ made against the arguments actually passed; a request left undecided expires int
- Dangerous-pattern allowlist/denylist (rm -rf /, curl|sh, sudo, chmod 777 …) forces `:destructive`.
- Working directory jailed to configured roots unless approved.

As built at slice 022, the guarantee per platform. **POSIX (Linux, macOS):** `/bin/sh -c` under a MuonTrap port,
SIGTERM at the timeout and SIGKILL 500 ms later, the child gone when the port is; the environment scrubbed to
`PATH`, `HOME`, `LANG`, `LC_ALL`, `TERM`, `TMPDIR` and `USER` (every other name unset, since a port's `env` adds
to the inherited environment); the timeout at most 600 s; the output capped at 1 MB with the head and the tail
kept; `Trinity.Tools.Shell.Dangerous` is the pattern list, a tripwire over text and stated as such. **Windows:**
no runtime in the tree keeps the kill guarantee, so the shell tool answers `available?/0` false and the registry
does not register it; a `System.cmd` fallback that could orphan a process would be a different tool under the
same name and is not offered. The approval card states which applies on the machine it runs on, and that the
BEAM is not an OS sandbox. The shell is a `:catalog` effect (`Trinity.Effects.Catalog`) at risk `:exec`.

## Filesystem (Slice 022)

- Path allowlist (project roots + data dir). Writes outside → ask.
- **Write-validation hook**: reject writes whose content contains truncation markers (`/* ... */`, `// ...`,
`# ... rest unchanged`) unless the file is new or the tool is called with `allow_placeholders: true` after approval.
- Atomic writes (temp + rename) and a per-file backup ring (last 5) under the data dir.

As built at slice 022: the roots are `config :trinity, :fs, roots:` (`TRINITY_FS_ROOTS` at runtime), the data
directory always, and the session's working directory; a path is judged after normalisation and symlink
resolution through its nearest existing ancestor; outside the roots every filesystem tool escalates the call to
`:ask` (the tier can only rise: `Tool.escalate/2`, `Permissions.effective_tier/2`); the placeholder hook is
`Trinity.Tools.FS.Placeholders`, applied to `fs_write`'s content and `fs_edit`'s replacement, and
`allow_placeholders` raises the call to `:destructive`; backups live under `<data dir>/backups/<sha256 of the
path>/`, five per file, `Trinity.Tools.FS.restore/2` puts one back through the same atomic write.

## Provenance (Slice 022, M1 as built)

Every tool result that came from outside the app is a `Trinity.Content.Part` tainted `untrusted` with a SHA-256
digest, stored on the `tool` row (`parts.content_parts`, `parts.taint`); the prompt builder renders it inside
`<untrusted source= ref= digest=>` and the system prompt states that instructions inside such blocks are data;
a turn's assistant row carries the maximum taint of everything the model read (its history and the turn's tool
results), so a summary of an untrusted page is itself untrusted, and every later turn in that session is too.
`blocked` parts are rendered as a placeholder; nothing writes one yet (024's receipts and the sentinel are where
a block comes from). `web_fetch` refuses no page by content, runs no JavaScript, and escalates a URL whose host
is not public (loopback, private, link-local) to `:ask`.

## Skills (Slice 041)

- Agent-authored skills land in `pending_approval` with a diff and rationale; nothing is active until approved.
Expand Down
3 changes: 2 additions & 1 deletion lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ defmodule Trinity do
Permissions,
Permissions.Approval,
Permissions.Rule,
Effects.Catalog
Effects.Catalog,
Content.Part
] ++
if(Mix.env() == :test, do: [DataCase, NetworkGuard, Factory], else: [])

Expand Down
89 changes: 89 additions & 0 deletions lib/trinity/content/part.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.Content.Part do
@moduledoc """
One piece of content with its provenance (docs/07, M1). Slice 022.

`origin` names where the bytes came from (`"tool:web_fetch"`, `"tool:shell"`, `"tool:fs_read"`,
`"model"`, `"user"`); `source_ref` the address (a URL, a path, a command); `digest` the SHA-256
of the text; `taint` whether the prompt may treat it as an instruction: `:trusted` (the user,
the persona, configuration), `:untrusted` (anything that came from outside the app: a page, a
file, a command's output, a model's answer that read one), `:blocked` (a part the prompt
builder replaces by a placeholder). A summary or a compaction carries the maximum taint of
its inputs (`max_taint/1`).

Stored on rows as string-keyed maps (`to_map/1`, `from_map/1`).
"""

@type taint :: :trusted | :untrusted | :blocked
@type t :: %__MODULE__{
origin: String.t(),
source_ref: String.t() | nil,
digest: String.t(),
taint: taint(),
text: String.t()
}

defstruct origin: "model", source_ref: nil, digest: "", taint: :trusted, text: ""

@order %{trusted: 0, untrusted: 1, blocked: 2}

@doc "A part over `text`, digest computed."
@spec new(String.t(), keyword()) :: t()
def new(text, opts \\ []) when is_binary(text) do
%__MODULE__{
origin: Keyword.get(opts, :origin, "model"),
source_ref: Keyword.get(opts, :source_ref),
digest: digest(text),
taint: Keyword.get(opts, :taint, :trusted),
text: text
}
end

@doc "SHA-256, hex."
@spec digest(String.t()) :: String.t()
def digest(text), do: :crypto.hash(:sha256, text) |> Base.encode16(case: :lower)

@doc "The highest taint among parts or taints; `:trusted` for none."
@spec max_taint([t() | taint()]) :: taint()
def max_taint(items) do
items
|> Enum.map(fn
%__MODULE__{taint: t} -> t
t when is_atom(t) -> t
end)
|> Enum.max_by(&Map.fetch!(@order, &1), fn -> :trusted end)
end

@doc "True when `a` is at least as tainted as `b`."
@spec at_least?(taint(), taint()) :: boolean()
def at_least?(a, b), do: Map.fetch!(@order, a) >= Map.fetch!(@order, b)

@doc "The string-keyed map a row stores."
@spec to_map(t()) :: map()
def to_map(%__MODULE__{} = p) do
%{
"origin" => p.origin,
"source_ref" => p.source_ref,
"digest" => p.digest,
"taint" => Atom.to_string(p.taint),
"text" => p.text
}
end

@doc "A part from a stored map; an unknown taint reads as `:untrusted`, the safe direction."
@spec from_map(map()) :: t()
def from_map(%{} = m) do
%__MODULE__{
origin: m["origin"] || "unknown",
source_ref: m["source_ref"],
digest: m["digest"] || "",
taint: taint_from(m["taint"]),
text: m["text"] || ""
}
end

defp taint_from("trusted"), do: :trusted
defp taint_from("blocked"), do: :blocked
defp taint_from(_), do: :untrusted
end
5 changes: 3 additions & 2 deletions lib/trinity/effects/catalog.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.Effects.Catalog do
@moduledoc """
The effect catalog, resolved at compile time (docs/07, M4). Slice 020 opens it empty.
The effect catalog, resolved at compile time (docs/07, M4). Slice 020 opened it empty;
slice 022 lists the shell, an external effect under local authority (its alignment note).

Every tool whose `effect/0` is `:catalog` (an external effect: send, spend, a provider
mutation) is listed here by name with its risk tier, in a module attribute and nowhere
Expand All @@ -12,7 +13,7 @@ defmodule Trinity.Effects.Catalog do
membrane read it.
"""

@catalog []
@catalog [{"shell", :exec}]

@doc "Every catalog tool as `{name, tier}`."
@spec all() :: [{String.t(), atom()}]
Expand Down
18 changes: 17 additions & 1 deletion lib/trinity/permissions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ defmodule Trinity.Permissions do
@spec tier(String.t()) :: tier()
def tier(name) when is_binary(name), do: Map.get(core_tiers(), name, :ask)

@doc """
The tier a call is judged at: the name's, raised by the tool's escalation when that is
higher (slice 022). An escalation can never lower a tier; `:ask` is the highest.
"""
@spec effective_tier(String.t(), tier() | nil) :: tier()
def effective_tier(name, nil), do: tier(name)

def effective_tier(name, escalation) do
base = tier(name)
if rank(escalation) > rank(base), do: escalation, else: base
end

@ranks %{read: 0, network: 1, write: 2, exec: 3, destructive: 4, ask: 5}
defp rank(tier), do: Map.fetch!(@ranks, tier)

@doc "The core names with a tier, for the census."
@spec mapped_names() :: [String.t()]
def mapped_names, do: core_tiers() |> Map.keys() |> Enum.sort()
Expand All @@ -59,7 +74,8 @@ defmodule Trinity.Permissions do

@doc """
The decision for one call, from the policy in force. `opts`: `persona:` (the row, for its
`settings["permissions"]`), `cwd:` (bound into the fingerprint).
`settings["permissions"]`), `cwd:` (bound into the fingerprint), `escalate:` (a tier the
tool raised the call to from its arguments; it can only raise).
"""
@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)
Expand Down
10 changes: 6 additions & 4 deletions lib/trinity/permissions/policy/layered.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ defmodule Trinity.Permissions.Policy.Layered do
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.
name; the tier is the name's raised by the tool's escalation when that is higher.

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.
Expand All @@ -33,7 +33,7 @@ defmodule Trinity.Permissions.Policy.Layered do
:next <- decided_approval(session_id, fp, now),
:next <- persona(Keyword.get(opts, :persona), tool),
:next <- global_rules(tool, args, fp, now) do
default(tool)
default(tool, Keyword.get(opts, :escalate))
end
end

Expand Down Expand Up @@ -82,12 +82,14 @@ defmodule Trinity.Permissions.Policy.Layered do
end
end

defp default(tool) do
# The default by tier, the name's raised by the tool's escalation (slice 022): a read outside
# the roots and a dangerous command reach here as `:ask` and `:destructive`, never lower.
defp default(tool, escalation) do
defaults =
Application.get_env(:trinity, :permissions, [])
|> Keyword.get(:default, @default)

case Permissions.tier(tool) do
case Permissions.effective_tier(tool, escalation) do
:ask -> :ask
tier -> Map.get(defaults, tier, :ask)
end
Expand Down
Loading
Loading