Skip to content

Commit 00bb959

Browse files
committed
feat(s135): complete slice 135 (secrets out of the data directory, roots that fail closed)
A tool the agent calls could read Trinity's own keys: the data directory was always a filesystem root, the receipt key lived under it, and the default policy allows reads. With the default policy, fs_read of <data dir>/keys/receipts-ed25519.key returned the key. - Secrets leave the data directory for TRINITY_SECRETS_DIR (a per-OS default outside it); the boot moves existing keys, the MCP bearer token and the OAuth store there, never overwriting, and names the move on the boot receipt; keys keep their ids and earlier receipts verify. - Trinity.Tools.FS.Guard judges every path a tool is given: /proc and /dev, any symlink, any path under the data or secrets directory, special files and hard links are refused, and a boot-time (dev, inode) inventory catches hard links and bind mounts; the opened descriptor is checked again. A short deny-list runs last. A guard refusal is a hard deny no approval lifts. - A root equal to, above or below either directory refuses boot in every profile. - web_fetch asks once a session has read outside the roots; :regulated refuses network: :allow without an egress allow-list. - Every fs decision, denials included, is receipted with the canonical path and inode. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 2a7b8b7 commit 00bb959

55 files changed

Lines changed: 2959 additions & 232 deletions

Some content is hidden

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

‎CHANGELOG.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,32 @@ evidence for each increment is retained by the maintainers and summarised here.
2424

2525
## 2026-10-08
2626

27+
### `slice/135`: secrets out of the data directory, and filesystem roots that fail closed
28+
29+
A defect, fixed. The data directory was always a filesystem root and the receipt signing key lived
30+
inside it, so with the default policy a file read of the key returned it without asking. Now no tool
31+
can read or write Trinity's own keys or databases, by any path. The keys, the MCP bearer token and
32+
the MCP client's tokens live in a secrets directory of their own (`TRINITY_SECRETS_DIR`, with a
33+
default per operating system outside the data directory), and an existing installation's files move
34+
there on the first boot, receipted, with every earlier receipt still verifying. The data directory
35+
is no longer a root, and a root that is, contains or sits inside either directory refuses the boot
36+
in every profile and is refused by the Settings page.
37+
38+
Every path a tool is given is judged before the permission policy is asked, and a refusal cannot be
39+
approved away: no symbolic link is followed, `/proc` and `/dev` are refused, a hard link is refused,
40+
and the file's device and inode are compared against an inventory of Trinity's secrets and databases
41+
(which catches a bind mount) both before the open and on the open file. A short list of secret file
42+
names is the last check, not the first. Every filesystem decision, refusals included, is on the
43+
receipt chain with the paths, the inode, the rule and the turn. A session that read a file outside
44+
its folders asks before every web fetch after it, and a regulated deployment that allows network
45+
access without an egress allow-list refuses to boot.
46+
47+
Verified by the test suite: each route to the key (direct, `..`, a symbolic link, a hard link,
48+
`/proc/self/fd`) shown returning the key before the fix and refused after it; a census of every
49+
file in both directories against every tool that takes a path; and the boot refusals and the
50+
migration in separate operating system processes. The bind-mount case in the hardened container is
51+
listed for manual verification.
52+
2753
### `slice/100`: Trinity as a desktop application
2854

2955
The Tauri window now does what a desktop application is expected to do. A tray icon shows whether

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ enforces it.
9090
- **Acts.** Filesystem, web and shell tools behind a permission gate with fingerprint-bound
9191
approvals; every effect passes one membrane and leaves a signed, hash-chained receipt you can
9292
verify offline (`mix trinity.receipts.verify`). A FIPS build leg in CI proves the receipt scheme
93-
under OpenSSL's FIPS provider.
93+
under OpenSSL's FIPS provider. No tool can read or write Trinity's own keys or databases, by any
94+
path: the secrets live in their own directory outside the data directory, a filesystem root that
95+
overlaps either refuses the boot, and every path is checked for links, hard links, `/proc` and
96+
protected inodes before the file is opened and again on the open file (slice 135).
9497
- **Remembers.** A persona (its SOUL), a small always-on memory with a byte budget and a
9598
consolidator instead of truncation, full-text search over every past conversation, a semantic
9699
tier filled by an observer after each turn and recalled by meaning (local embeddings, never a

‎ci/headless/Containerfile‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
#
1212
# The container binds 0.0.0.0 inside its own network namespace (TRINITY_BIND) so the published
1313
# port reaches it; the bearer is required on /mcp whatever the bind. The data directory is
14-
# /data/trinity (XDG_DATA_HOME=/data, a volume): the databases, the receipt keys, the
15-
# envelope key, the generated token.
14+
# /data/trinity (XDG_DATA_HOME=/data, a volume): the databases. The secrets directory is
15+
# /data/secrets (TRINITY_SECRETS_DIR, slice 135), beside the data directory on the same volume
16+
# and never inside it: the receipt keys, the envelope key, the generated token. Point it at a
17+
# systemd credential or a Kubernetes Secret mount instead (docs/packaging.md).
1618
FROM hexpm/elixir:1.20.4-erlang-28.5.0.5-debian-bookworm-20260824-slim AS build
1719

1820
RUN apt-get update -y && apt-get install -y --no-install-recommends \
@@ -40,7 +42,8 @@ RUN apt-get update -y && apt-get install -y --no-install-recommends \
4042
&& sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
4143

4244
ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8
43-
ENV TRINITY_MODE=headless TRINITY_BIND=0.0.0.0 PORT=4000 XDG_DATA_HOME=/data
45+
ENV TRINITY_MODE=headless TRINITY_BIND=0.0.0.0 PORT=4000 XDG_DATA_HOME=/data \
46+
TRINITY_SECRETS_DIR=/data/secrets
4447
WORKDIR /app
4548
RUN useradd --create-home --uid 10001 trinity && mkdir -p /data && chown trinity:trinity /data /app
4649
COPY --from=build --chown=trinity:trinity /app/_build/prod/rel/headless ./

‎config/test.exs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,17 @@ config :trinity, :receipts,
258258
keys_dir: Path.expand("../tmp/test_keys", __DIR__),
259259
forward_interval_ms: 0
260260

261+
# Slice 135: the secrets directory for the suite, under the project's ignored tmp/ and per test
262+
# partition, never the machine's own. A test that needs the release layout sets
263+
# TRINITY_SECRETS_DIR, which wins over this.
264+
config :trinity,
265+
secrets_dir:
266+
Path.expand("../tmp/test_secrets-#{System.get_env("MIX_TEST_PARTITION", "0")}", __DIR__)
267+
268+
# Slice 135: the suite's application runs against the machine's own data directory, so the boot
269+
# migration is off here; the boot-node test turns it on in a child with its own data directory.
270+
config :trinity, Trinity.Secrets.Migration, enabled: false
271+
261272
# Slice 032: the suite embeds with the deterministic fake; nothing leaves the machine and no
262273
# model is needed.
263274
config :trinity, :memory, embedder: :fake, observer: false

‎docs/05-data-model.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ call is timed at the one place every call passes.
242242
| receipt_hash | binary | over the canonical signed bytes |
243243
| signed_payload | map | RFC 8785 canonical JSON. Field set is a legal-review question before Slice 024 |
244244
| 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) |
245-
| 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 |
245+
| 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 |
246246
| kind | string | "decision" \| "effect" \| "query" \| "boot" \| "cap" |
247247
| subject | map | refs to the session, tool call, approval or effect this receipts |
248248
Append-only. Never updated, never deleted. Signing unavailable means the effect is denied, not that an unsigned

‎docs/07-security-model.md‎

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ BEAM is not an OS sandbox. The shell is a `:catalog` effect (`Trinity.Effects.Ca
8484

8585
## Filesystem (Slice 022)
8686

87-
- Path allowlist (project roots + data dir). Writes outside → ask.
87+
- Path allowlist (project roots; the data directory is not one since slice 135). Writes outside → ask.
8888
- **Write-validation hook**: reject writes whose content contains truncation markers (`/* ... */`, `// ...`,
8989
`# ... rest unchanged`) unless the file is new or the tool is called with `allow_placeholders: true` after approval.
9090
- Atomic writes (temp + rename) and a per-file backup ring (last 5) under the data dir.
@@ -97,6 +97,62 @@ resolution through its nearest existing ancestor; outside the roots every filesy
9797
`allow_placeholders` raises the call to `:destructive`; backups live under `<data dir>/backups/<sha256 of the
9898
path>/`, five per file, `Trinity.Tools.FS.restore/2` puts one back through the same atomic write.
9999

100+
**As built at slice 135: secrets out of the data directory, roots that fail closed.** Until this slice
101+
the data directory was always a root, and the keys directory was `<data dir>/keys`, so with the
102+
default policy (`read: :allow`) `fs_read` of the receipt signing key returned it with no approval.
103+
The fix is in three layers, the deny-list last.
104+
105+
1. **Secrets leave the data directory.** `Trinity.Paths.secrets_dir/0` (`TRINITY_SECRETS_DIR`, else
106+
`config :trinity, :secrets_dir`, else `$XDG_CONFIG_HOME/trinity/secrets` on Linux,
107+
`~/Library/Application Support/Trinity Secrets` on macOS, `%LOCALAPPDATA%\Trinity\Secrets` on
108+
Windows; mode 0700) holds `keys/` (the receipt key when no keychain holds it, its registry, the MCP
109+
state key, the device id), the MCP bearer token and the MCP client's OAuth tokens.
110+
`Trinity.Secrets.Migration` moves an earlier release's files on boot, before the signer reads
111+
them, by rename, so the key keeps its id and every earlier receipt still verifies; a file already
112+
at the destination is never overwritten; the boot receipt names the move in its signed subject
113+
(`secrets_migration`). The process still *uses* its keys through the signing API; no tool reads
114+
them. The data directory is no longer a root, and nothing replaces it: skills are read by the
115+
skill tools, which are jailed to a skill's directory.
116+
2. **Roots fail closed, in every profile.** `Trinity.Tools.FS.Guard.verify_boot!/0` runs before any
117+
child: a configured root (`TRINITY_FS_ROOTS`, or a folder chosen in Settings) that is equal to,
118+
an ancestor of or a descendant of the data directory or the secrets directory refuses the boot,
119+
naming the root, the directory and the relation; the Settings page refuses to save such a folder.
120+
At every call the guard judges each host path a tool names (`Trinity.Tools.Tool.fs_paths/2`)
121+
before the policy is asked, and a refusal is a `deny` (basis `fs_guard`) no grant, rule or
122+
approval lifts: `/proc` and `/dev`; a symlink in any component (no link is followed, so the
123+
lexical path is the physical one; a configured root's own symlinked spelling is rewritten to its
124+
canonical form); a component whose `(st_dev, st_ino)` is in the inventory (every entry under the
125+
secrets directory and the configured keys directory, the databases and their journals, and the
126+
data and secrets directories themselves, taken fresh at each call), which is what catches a bind
127+
mount; a path under the data or secrets directory; a special file; a regular file with
128+
`st_nlink > 1` unless `config :trinity, :fs, allow_hard_links: true`. At open the file is opened
129+
and its descriptor `fstat`ed (`:file.read_file_info/1` on the open device is `fstat`, measured on
130+
OTP 28): an inventoried inode, a second link, a non-regular file or an inode other than the one
131+
the walk saw is refused. `fs_grep` and `fs_glob` judge every file they meet the same way.
132+
3. **The deny-list, second:** `*.key`, `*.db`, `*.db-wal`, `*.db-shm`, `*.sqlite*`, `.env*`, `id_*`,
133+
`*.pem`, on the canonical basename, after the structural checks.
134+
135+
**Receipts.** Every fs decision is on the chain with the tool, the requested and canonical paths,
136+
`(dev, ino)`, the root matched or none, the rule matched, nlink, the decision, the core policy hash,
137+
the principal and the turn id: the decision receipt carries them under `decision.fs`, and a refusal
138+
made at open (a file met inside a directory `fs_grep` walked, a swap since the decision) rides on the
139+
read's query receipt the same way. Never the file's contents.
140+
141+
**What is not covered, stated.** No NIF: `file:open/2` has no `O_NOFOLLOW` or `openat2`, and the
142+
post-open `fstat` closes the window between the walk and the open for every protected inode. A swap
143+
inside a root, between the walk and the open, to an ordinary file outside the roots that is neither
144+
inventoried nor hard-linked would be read; that is not one of Trinity's secrets, and it needs a
145+
process already able to write links inside the root at that moment. A write's parent directory
146+
swapped for a link between the check and the rename is the same residual for writes. The shell's
147+
path argument (its working directory) is judged; the command text is not, because no path check can
148+
read free text: the shell is `:exec`, asks every time, and the BEAM is not an OS sandbox (above).
149+
150+
**The network half.** A read outside the roots tags the session (`Trinity.Tools.Taint`), and every
151+
`web_fetch` in that session asks from then on: whether a request carries bytes read earlier cannot be
152+
decided against any encoding of them. The taint never gates the read itself. Under `:regulated`, a
153+
`network: :allow` default with no `TRINITY_REGULATED_EGRESS` refuses the boot (OWASP ASI02's egress
154+
allow-list), and with one, `web_fetch` asks for a host not on it.
155+
100156
## Provenance (Slice 022, M1 as built)
101157

102158
Every tool result that came from outside the app is a `Trinity.Content.Part` tainted `untrusted` with a SHA-256
@@ -274,7 +330,7 @@ regulated effect.
274330
issuer's metadata (its `issuer` must equal the one asked for), begins the code flow with PKCE, `state`
275331
and `resource`, sends the owner to the authorization server, and finishes at `/oauth/callback` (`iss`
276332
checked against the issuer, the code exchanged with the verifier). The token is stored per resource
277-
under `<data dir>/secrets/oauth/` (mode 0600, until slice 100's keychain) and the driver presents it,
333+
under `<secrets dir>/oauth/` (mode 0600; `<data dir>/secrets/oauth/` before slice 135) and the driver presents it,
278334
performing no flow of its own (a census over its files). The client identifies itself by the configured
279335
`client_id` (pre-registered at the enterprise server), by a Client ID Metadata Document URL, or by
280336
registering once when the server offers it and `dcr: true`.
@@ -315,9 +371,9 @@ regulated effect.
315371
and denies every effect, the fail-closed shape of slice 024; `TRINITY_KEYCHAIN_HELPER` pointed at
316372
a built shell binary restores it. An export made with the private key does not carry a
317373
keychain-held key; that is a follow-up of slice 034's backup story.
318-
- **Not yet in the keychain:** the MCP OAuth client tokens (`<data dir>/secrets/oauth/`, slice 062)
319-
and the MCP server's token and state key (slice 061). They stay files, mode 0600, until a slice
320-
moves them.
374+
- **Not yet in the keychain:** the MCP OAuth client tokens (`<secrets dir>/oauth/`, slice 062)
375+
and the MCP server's token and state key (slice 061). They stay files in the secrets directory
376+
(slice 135; under the data directory before it) until a slice moves them.
321377

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

@@ -393,7 +449,7 @@ The signature is over DSSE's PAE of a payload type and the canonical body, the t
393449
same key signs. `key_id` is the RFC 7638 thumbprint of the public key and sits inside the signed body; the
394450
registry row binds one algorithm to it and the verifier takes the algorithm from there, refusing a scheme whose
395451
family is not the row's before any signature check, and refusing schemes the caller did not allow. The key is a
396-
0600 file under the data directory's `keys/`, read on every sign and never cached, so a key removed mid-run is a
452+
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
397453
signer unavailable at the next receipt. What a file-backed key establishes: that the chain was not altered after
398454
the fact by anything lacking read access to that file, and nothing more. Selection is once, at boot: P-384 when
399455
`crypto:info_fips/0` is `enabled` (proven on the `fips` leg), Ed25519 otherwise, ML-DSA-87 by configuration

‎docs/mcp-server.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ configuration is refused at boot with a logged reason, and the tool never appear
1717
`tools/list`. The list is sorted, and it carries `ttlMs` (60 s by default) and `cacheScope`
1818
(`private`).
1919

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

2425
## Authorization profiles (slice 062)
2526

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

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

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

6667
**Claude Code** (its `mcp add` command, or `.mcp.json` in a project):

‎docs/packaging.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,37 @@ and is writable, created if absent and otherwise only looked at; binding, exit 6
8484
which the slice's criterion also names, is not in the bundle: slice 032 measured it incompatible
8585
with the pinned `nx` and chose the Elixir scorer, which `TRINITY_SMOKE_VEC` already exercises.
8686

87+
## The secrets directory (slice 135)
88+
89+
Trinity keeps its own secrets in a directory of their own, never inside the data directory: the
90+
receipt signing key when no keychain holds it and its registry (`keys/`), the MCP state key, the MCP
91+
bearer token (`mcp-server-token`) and the MCP client's OAuth tokens (`oauth/`). The directory is mode
92+
0700. No filesystem root may be, contain or sit inside it or the data directory: the boot refuses one
93+
that does, naming it.
94+
95+
| Where | Default |
96+
|---|---|
97+
| Linux | `$XDG_CONFIG_HOME/trinity/secrets`, else `~/.config/trinity/secrets` |
98+
| macOS | `~/Library/Application Support/Trinity Secrets` |
99+
| Windows | `%LOCALAPPDATA%\Trinity\Secrets` |
100+
| The headless container | `/data/secrets`, beside the data directory on the `/data` volume |
101+
102+
`TRINITY_SECRETS_DIR` overrides all of them. An installation made before slice 135 keeps these files
103+
under the data directory; the first boot after it moves them (a rename, so the key keeps its id and
104+
every receipt signed before the move still verifies), and the boot receipt names what moved.
105+
106+
**A systemd service** can hold the directory in a credential store: with
107+
`LoadCredential=` (or `LoadCredentialEncrypted=`) for each file, `$CREDENTIALS_DIRECTORY` is a
108+
private, read-only directory only the service can read, and `TRINITY_SECRETS_DIR=%d` points Trinity
109+
at it. **A Kubernetes pod** can mount a Secret as a volume (`tmpfs`, read-only, `defaultMode: 0400`)
110+
and set `TRINITY_SECRETS_DIR` to the mount path. Either way the directory is read-only, so it must
111+
already hold what Trinity would otherwise create on its first boot: run once with a writable secrets
112+
directory, then load the resulting files (`keys/receipts-<algorithm>.key`, `keys/registry.json`,
113+
`keys/mcp-state.key`, and `mcp-server-token` unless `TRINITY_MCP_SERVER_TOKEN` is set) into the
114+
credential store. A read-only directory also means the MCP client cannot store new OAuth tokens and a
115+
key rotation cannot write its registry rows; keep the secrets directory writable where either is
116+
needed.
117+
87118
## The desktop shell (slice 100)
88119

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

0 commit comments

Comments
 (0)