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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,32 @@ evidence for each increment is retained by the maintainers and summarised here.

## 2026-10-08

### `slice/135`: secrets out of the data directory, and filesystem roots that fail closed

A defect, fixed. The data directory was always a filesystem root and the receipt signing key lived
inside it, so with the default policy a file read of the key returned it without asking. Now no tool
can read or write Trinity's own keys or databases, by any path. The keys, the MCP bearer token and
the MCP client's tokens live in a secrets directory of their own (`TRINITY_SECRETS_DIR`, with a
default per operating system outside the data directory), and an existing installation's files move
there on the first boot, receipted, with every earlier receipt still verifying. The data directory
is no longer a root, and a root that is, contains or sits inside either directory refuses the boot
in every profile and is refused by the Settings page.

Every path a tool is given is judged before the permission policy is asked, and a refusal cannot be
approved away: no symbolic link is followed, `/proc` and `/dev` are refused, a hard link is refused,
and the file's device and inode are compared against an inventory of Trinity's secrets and databases
(which catches a bind mount) both before the open and on the open file. A short list of secret file
names is the last check, not the first. Every filesystem decision, refusals included, is on the
receipt chain with the paths, the inode, the rule and the turn. A session that read a file outside
its folders asks before every web fetch after it, and a regulated deployment that allows network
access without an egress allow-list refuses to boot.

Verified by the test suite: each route to the key (direct, `..`, a symbolic link, a hard link,
`/proc/self/fd`) shown returning the key before the fix and refused after it; a census of every
file in both directories against every tool that takes a path; and the boot refusals and the
migration in separate operating system processes. The bind-mount case in the hardened container is
listed for manual verification.

### `slice/132`: a control mapping an assessor can check against the tree

The regulated pack explained the boundary, the data flow, the cryptography and who owns what, and
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ enforces it.
- **Acts.** Filesystem, web and shell tools behind a permission gate with fingerprint-bound
approvals; every effect passes one membrane and leaves a signed, hash-chained receipt you can
verify offline (`mix trinity.receipts.verify`). A FIPS build leg in CI proves the receipt scheme
under OpenSSL's FIPS provider.
under OpenSSL's FIPS provider. No tool can read or write Trinity's own keys or databases, by any
path: the secrets live in their own directory outside the data directory, a filesystem root that
overlaps either refuses the boot, and every path is checked for links, hard links, `/proc` and
protected inodes before the file is opened and again on the open file (slice 135).
- **Remembers.** A persona (its SOUL), a small always-on memory with a byte budget and a
consolidator instead of truncation, full-text search over every past conversation, a semantic
tier filled by an observer after each turn and recalled by meaning (local embeddings, never a
Expand Down
6 changes: 5 additions & 1 deletion ci/ironbank/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,16 @@ COPY --from=release --chown=0:0 /build/trinity/_build/prod/rel/headless /opt/tri
COPY --chown=0:0 scripts/entrypoint.sh /usr/local/bin/trinity-entrypoint

# Distribution is off: the image serves MCP and the web pages on PORT and nothing else. The data
# directory is /data (XDG_DATA_HOME), the only path the runtime user can write.
# directory is /data (XDG_DATA_HOME), the only path the runtime user can write. The secrets
# directory is /data/secrets (TRINITY_SECRETS_DIR, slice 135), beside the data directory on the
# same volume and never inside it: the receipt keys, the envelope key, the generated token. Point
# it at a systemd credential or a Kubernetes Secret mount instead (docs/packaging.md).
ENV LANG=C.UTF-8 \
TRINITY_MODE=headless \
TRINITY_BIND=0.0.0.0 \
PORT=4000 \
XDG_DATA_HOME=/data \
TRINITY_SECRETS_DIR=/data/secrets \
HOME=/data \
RELEASE_DISTRIBUTION=none \
RELEASE_TMP=/tmp
Expand Down
11 changes: 11 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,17 @@ config :trinity, :receipts,
keys_dir: Path.expand("../tmp/test_keys", __DIR__),
forward_interval_ms: 0

# Slice 135: the secrets directory for the suite, under the project's ignored tmp/ and per test
# partition, never the machine's own. A test that needs the release layout sets
# TRINITY_SECRETS_DIR, which wins over this.
config :trinity,
secrets_dir:
Path.expand("../tmp/test_secrets-#{System.get_env("MIX_TEST_PARTITION", "0")}", __DIR__)

# Slice 135: the suite's application runs against the machine's own data directory, so the boot
# migration is off here; the boot-node test turns it on in a child with its own data directory.
config :trinity, Trinity.Secrets.Migration, enabled: false

# Slice 032: the suite embeds with the deterministic fake; nothing leaves the machine and no
# model is needed.
config :trinity, :memory, embedder: :fake, observer: false
Expand Down
2 changes: 1 addition & 1 deletion docs/05-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ call is timed at the one place every call passes.
| receipt_hash | binary | over the canonical signed bytes |
| signed_payload | map | RFC 8785 canonical JSON. Field set is a legal-review question before Slice 024 |
| signature | binary | through the signer seam: Ed25519 by default, ECDSA P-384 in FIPS mode, ML-DSA-87 opt-in (slice 024 amendments 1 to 6) |
| key_id | string | inside the signed bytes; resolves in the key registry (as built: `<data dir>/keys/registry.json`, not `priv/`), whose row names the algorithm; the verifier reads the algorithm from there and nowhere else |
| key_id | string | inside the signed bytes; resolves in the key registry (as built: `<secrets dir>/keys/registry.json` since slice 135, `<data dir>/keys/registry.json` before it, never `priv/`), whose row names the algorithm; the verifier reads the algorithm from there and nowhere else |
| kind | string | "decision" \| "effect" \| "query" \| "boot" \| "cap" |
| subject | map | refs to the session, tool call, approval or effect this receipts |
Append-only. Never updated, never deleted. Signing unavailable means the effect is denied, not that an unsigned
Expand Down
68 changes: 62 additions & 6 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ BEAM is not an OS sandbox. The shell is a `:catalog` effect (`Trinity.Effects.Ca

## Filesystem (Slice 022)

- Path allowlist (project roots + data dir). Writes outside → ask.
- Path allowlist (project roots; the data directory is not one since slice 135). 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.
Expand All @@ -97,6 +97,62 @@ resolution through its nearest existing ancestor; outside the roots every filesy
`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.

**As built at slice 135: secrets out of the data directory, roots that fail closed.** Until this slice
the data directory was always a root, and the keys directory was `<data dir>/keys`, so with the
default policy (`read: :allow`) `fs_read` of the receipt signing key returned it with no approval.
The fix is in three layers, the deny-list last.

1. **Secrets leave the data directory.** `Trinity.Paths.secrets_dir/0` (`TRINITY_SECRETS_DIR`, else
`config :trinity, :secrets_dir`, else `$XDG_CONFIG_HOME/trinity/secrets` on Linux,
`~/Library/Application Support/Trinity Secrets` on macOS, `%LOCALAPPDATA%\Trinity\Secrets` on
Windows; mode 0700) holds `keys/` (the receipt key when no keychain holds it, its registry, the MCP
state key, the device id), the MCP bearer token and the MCP client's OAuth tokens.
`Trinity.Secrets.Migration` moves an earlier release's files on boot, before the signer reads
them, by rename, so the key keeps its id and every earlier receipt still verifies; a file already
at the destination is never overwritten; the boot receipt names the move in its signed subject
(`secrets_migration`). The process still *uses* its keys through the signing API; no tool reads
them. The data directory is no longer a root, and nothing replaces it: skills are read by the
skill tools, which are jailed to a skill's directory.
2. **Roots fail closed, in every profile.** `Trinity.Tools.FS.Guard.verify_boot!/0` runs before any
child: a configured root (`TRINITY_FS_ROOTS`, or a folder chosen in Settings) that is equal to,
an ancestor of or a descendant of the data directory or the secrets directory refuses the boot,
naming the root, the directory and the relation; the Settings page refuses to save such a folder.
At every call the guard judges each host path a tool names (`Trinity.Tools.Tool.fs_paths/2`)
before the policy is asked, and a refusal is a `deny` (basis `fs_guard`) no grant, rule or
approval lifts: `/proc` and `/dev`; a symlink in any component (no link is followed, so the
lexical path is the physical one; a configured root's own symlinked spelling is rewritten to its
canonical form); a component whose `(st_dev, st_ino)` is in the inventory (every entry under the
secrets directory and the configured keys directory, the databases and their journals, and the
data and secrets directories themselves, taken fresh at each call), which is what catches a bind
mount; a path under the data or secrets directory; a special file; a regular file with
`st_nlink > 1` unless `config :trinity, :fs, allow_hard_links: true`. At open the file is opened
and its descriptor `fstat`ed (`:file.read_file_info/1` on the open device is `fstat`, measured on
OTP 28): an inventoried inode, a second link, a non-regular file or an inode other than the one
the walk saw is refused. `fs_grep` and `fs_glob` judge every file they meet the same way.
3. **The deny-list, second:** `*.key`, `*.db`, `*.db-wal`, `*.db-shm`, `*.sqlite*`, `.env*`, `id_*`,
`*.pem`, on the canonical basename, after the structural checks.

**Receipts.** Every fs decision is on the chain with the tool, the requested and canonical paths,
`(dev, ino)`, the root matched or none, the rule matched, nlink, the decision, the core policy hash,
the principal and the turn id: the decision receipt carries them under `decision.fs`, and a refusal
made at open (a file met inside a directory `fs_grep` walked, a swap since the decision) rides on the
read's query receipt the same way. Never the file's contents.

**What is not covered, stated.** No NIF: `file:open/2` has no `O_NOFOLLOW` or `openat2`, and the
post-open `fstat` closes the window between the walk and the open for every protected inode. A swap
inside a root, between the walk and the open, to an ordinary file outside the roots that is neither
inventoried nor hard-linked would be read; that is not one of Trinity's secrets, and it needs a
process already able to write links inside the root at that moment. A write's parent directory
swapped for a link between the check and the rename is the same residual for writes. The shell's
path argument (its working directory) is judged; the command text is not, because no path check can
read free text: the shell is `:exec`, asks every time, and the BEAM is not an OS sandbox (above).

**The network half.** A read outside the roots tags the session (`Trinity.Tools.Taint`), and every
`web_fetch` in that session asks from then on: whether a request carries bytes read earlier cannot be
decided against any encoding of them. The taint never gates the read itself. Under `:regulated`, a
`network: :allow` default with no `TRINITY_REGULATED_EGRESS` refuses the boot (OWASP ASI02's egress
allow-list), and with one, `web_fetch` asks for a host not on it.

## 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
Expand Down Expand Up @@ -274,7 +330,7 @@ regulated effect.
issuer's metadata (its `issuer` must equal the one asked for), begins the code flow with PKCE, `state`
and `resource`, sends the owner to the authorization server, and finishes at `/oauth/callback` (`iss`
checked against the issuer, the code exchanged with the verifier). The token is stored per resource
under `<data dir>/secrets/oauth/` (mode 0600, until slice 100's keychain) and the driver presents it,
under `<secrets dir>/oauth/` (mode 0600; `<data dir>/secrets/oauth/` before slice 135) and the driver presents it,
performing no flow of its own (a census over its files). The client identifies itself by the configured
`client_id` (pre-registered at the enterprise server), by a Client ID Metadata Document URL, or by
registering once when the server offers it and `dcr: true`.
Expand Down Expand Up @@ -315,9 +371,9 @@ regulated effect.
and denies every effect, the fail-closed shape of slice 024; `TRINITY_KEYCHAIN_HELPER` pointed at
a built shell binary restores it. An export made with the private key does not carry a
keychain-held key; that is a follow-up of slice 034's backup story.
- **Not yet in the keychain:** the MCP OAuth client tokens (`<data dir>/secrets/oauth/`, slice 062)
and the MCP server's token and state key (slice 061). They stay files, mode 0600, until a slice
moves them.
- **Not yet in the keychain:** the MCP OAuth client tokens (`<secrets dir>/oauth/`, slice 062)
and the MCP server's token and state key (slice 061). They stay files in the secrets directory
(slice 135; under the data directory before it) until a slice moves them.

## The desktop shell's channel (slice 100)

Expand Down Expand Up @@ -393,7 +449,7 @@ The signature is over DSSE's PAE of a payload type and the canonical body, the t
same key signs. `key_id` is the RFC 7638 thumbprint of the public key and sits inside the signed body; the
registry row binds one algorithm to it and the verifier takes the algorithm from there, refusing a scheme whose
family is not the row's before any signature check, and refusing schemes the caller did not allow. The key is a
0600 file under the data directory's `keys/`, read on every sign and never cached, so a key removed mid-run is a
file under the secrets directory's `keys/` (the data directory's before slice 135), read on every sign and never cached, so a key removed mid-run is a
signer unavailable at the next receipt. What a file-backed key establishes: that the chain was not altered after
the fact by anything lacking read access to that file, and nothing more. Selection is once, at boot: P-384 when
`crypto:info_fips/0` is `enabled` (proven on the `fips` leg), Ed25519 otherwise, ML-DSA-87 by configuration
Expand Down
11 changes: 6 additions & 5 deletions docs/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ configuration is refused at boot with a logged reason, and the tool never appear
`tools/list`. The list is sorted, and it carries `ttlMs` (60 s by default) and `cacheScope`
(`private`).

Two files the server keeps in the data directory: `mcp-server-token`, the bearer clients present
(generated once, mode 0600; `TRINITY_MCP_SERVER_TOKEN` overrides it), and `keys/mcp-state.key`, the
key that seals the state a held call carries between round trips (docs/07, "MCP server").
Two files the server keeps in the secrets directory (`TRINITY_SECRETS_DIR`; under the data
directory before slice 135, and moved on the first boot after it): `mcp-server-token`, the bearer
clients present (generated once; `TRINITY_MCP_SERVER_TOKEN` overrides it), and `keys/mcp-state.key`,
the key that seals the state a held call carries between round trips (docs/07, "MCP server").

## Authorization profiles (slice 062)

Expand Down Expand Up @@ -50,7 +51,7 @@ and what you may ask for, never that a thing may happen.
Trinity as a client of a protected server: add the server on the `/mcp` page as usual; when it answers
`401` with resource metadata, the row shows the challenge and an "authorize" button that sends you to
that server's authorization server and back to `/oauth/callback`. The token is stored under
`<data dir>/secrets/oauth/` (mode 0600) and presented on every request; `TRINITY_MCP_AUTH_CLIENT_ID` is the
`<secrets dir>/oauth/` (mode 0600) and presented on every request; `TRINITY_MCP_AUTH_CLIENT_ID` is the
client id you registered there (or `_CLIENT_METADATA_URL` for a metadata document; `_DCR=1` to register on
the spot when the server allows it).

Expand All @@ -60,7 +61,7 @@ The URL is `http://127.0.0.1:<port>/mcp` (the port the app prints at start, `400
profile) and the bearer is the token above:

```
$ cat ~/.local/share/trinity/mcp-server-token # Linux; the data directory differs per OS
$ cat ~/.config/trinity/secrets/mcp-server-token # Linux; the secrets directory differs per OS
```

**Claude Code** (its `mcp add` command, or `.mcp.json` in a project):
Expand Down
31 changes: 31 additions & 0 deletions docs/packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,37 @@ and is writable, created if absent and otherwise only looked at; binding, exit 6
which the slice's criterion also names, is not in the bundle: slice 032 measured it incompatible
with the pinned `nx` and chose the Elixir scorer, which `TRINITY_SMOKE_VEC` already exercises.

## The secrets directory (slice 135)

Trinity keeps its own secrets in a directory of their own, never inside the data directory: the
receipt signing key when no keychain holds it and its registry (`keys/`), the MCP state key, the MCP
bearer token (`mcp-server-token`) and the MCP client's OAuth tokens (`oauth/`). The directory is mode
0700. No filesystem root may be, contain or sit inside it or the data directory: the boot refuses one
that does, naming it.

| Where | Default |
|---|---|
| Linux | `$XDG_CONFIG_HOME/trinity/secrets`, else `~/.config/trinity/secrets` |
| macOS | `~/Library/Application Support/Trinity Secrets` |
| Windows | `%LOCALAPPDATA%\Trinity\Secrets` |
| The headless container | `/data/secrets`, beside the data directory on the `/data` volume |

`TRINITY_SECRETS_DIR` overrides all of them. An installation made before slice 135 keeps these files
under the data directory; the first boot after it moves them (a rename, so the key keeps its id and
every receipt signed before the move still verifies), and the boot receipt names what moved.

**A systemd service** can hold the directory in a credential store: with
`LoadCredential=` (or `LoadCredentialEncrypted=`) for each file, `$CREDENTIALS_DIRECTORY` is a
private, read-only directory only the service can read, and `TRINITY_SECRETS_DIR=%d` points Trinity
at it. **A Kubernetes pod** can mount a Secret as a volume (`tmpfs`, read-only, `defaultMode: 0400`)
and set `TRINITY_SECRETS_DIR` to the mount path. Either way the directory is read-only, so it must
already hold what Trinity would otherwise create on its first boot: run once with a writable secrets
directory, then load the resulting files (`keys/receipts-<algorithm>.key`, `keys/registry.json`,
`keys/mcp-state.key`, and `mcp-server-token` unless `TRINITY_MCP_SERVER_TOKEN` is set) into the
credential store. A read-only directory also means the MCP client cannot store new OAuth tokens and a
key rotation cannot write its registry rows; keep the secrets directory writable where either is
needed.

## The desktop shell (slice 100)

`src-tauri/` is the Tauri shell. It starts the sidecar (the Burrito binary, or in development a
Expand Down
Loading
Loading