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
7 changes: 6 additions & 1 deletion .github/workflows/headless-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,13 @@ jobs:
run: |
docker run --rm --read-only --tmpfs /tmp "$IMAGE" /opt/trinity/bin/headless eval \
'IO.puts("otp=#{System.otp_release()} fips_provider=#{inspect(:crypto.info()[:fips_provider_available])} node=#{inspect(Node.self())}")'
# Slice 136: the image binds 0.0.0.0, which is not the loopback, so the node refuses to
# boot until TRINITY_WEB_AUTH says how the web pages authenticate. A shared token is the
# one mode that needs no identity provider (and is refused under the regulated profile).
cid=$(docker run -d --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
-p 127.0.0.1:4000:4000 -e TRINITY_MCP_SERVER_TOKEN=ci-token "$IMAGE")
-p 127.0.0.1:4000:4000 -e TRINITY_MCP_SERVER_TOKEN=ci-token \
-e TRINITY_WEB_AUTH=local_token -e TRINITY_WEB_AUTH_TOKEN=ci-web-token-not-a-secret-0001 \
"$IMAGE")
for _ in $(seq 1 60); do
curl -s -o /dev/null http://127.0.0.1:4000/ && break
sleep 1
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,31 @@ evidence for each increment is retained by the maintainers and summarised here.

## 2026-10-08

### `slice/136`: the web pages ask who is there

Every page and route now answers only someone who has signed in with the role it needs, or, on the
desktop, a browser on the same machine. Three roles: `view` reads and converses, `approve` decides
approvals, `administer` changes what Trinity is (MCP servers, gateways, settings and keys, the
export, the dashboards). Signing in is an OpenID Connect login at your identity provider, with roles
read from Keycloak, Microsoft Entra ID, Okta or a claim you name; a sign-in whose roles cannot be
read is refused with a page that says why. On a home network a single shared token can stand in,
and every page then says that it identifies a token and not a person.

A request whose Host is not this machine's own is refused before anything else sees it, and a
websocket from another site's page is refused; this is what keeps the desktop, which has no login,
from being read by a web page through DNS rebinding. A server bound to anything but the loopback
refuses to start until it is told how the pages authenticate, and the regulated profile refuses a
shared token or no login there, and refuses to believe a forwarded protocol header unless a trusted
proxy is declared. Sessions end on the server when revoked, after 30 idle minutes, after 12 hours,
or on sign-out. Every approval now records who gave it, and every approval and privileged action is
a signed receipt carrying the person's subject, issuer and the role used.

Verified by the test suite: a census of every route and every page event, an unauthenticated sweep
of every route, the login against an identity provider running inside the test, the role and
receipt checks through the pages, and the refusals at boot in separate operating system processes.
An Entra tenant with a user in more than 200 groups, and the desktop build refusing a foreign Host,
are listed for manual verification.

### `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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,13 @@ enforces it.
protected server's `401` with "authorize" on the `/mcp` page and keeps the token it obtains.
Trinity issues no production authority: the personal issuer refuses to start under an
external authority adapter, by construction (`docs/07-security-model.md`).
- **Asks who is at the page.** Every page answers only someone signed in with the role it needs
(`view`, `approve`, `administer`) through an OpenID Connect login at your identity provider,
or, on the desktop, a browser on the same machine: a request naming any other host is refused
before it is routed, and a websocket from another site's page is refused. A server bound
beyond the loopback will not start until it is told how the pages authenticate. Every approval
records who gave it, as a signed receipt with their subject, issuer and role
(`docs/07-security-model.md`, "Web pages").
- **Reachable from elsewhere.** A gateway is a channel Trinity answers from: the adapter carries
text, and everything else (the session, the gate, the receipts) is the same machinery the
desktop uses. A sender Trinity does not know gets a pairing code shown on the `/gateways` page
Expand Down
5 changes: 4 additions & 1 deletion THIRD_PARTY_LICENSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
hand: the gate regenerates it and fails when this file and the bill disagree. -->
# Third-party licences

134 components, derived from `sbom.cdx.json`, the CycloneDX bill this project's
137 components, derived from `sbom.cdx.json`, the CycloneDX bill this project's
quality gate generates on every commit. This file and that bill are the same data rendered
twice, so they cannot disagree.

Expand Down Expand Up @@ -97,6 +97,8 @@ it, and a reader should not infer from an entry here that the component is in a
| `oban` | `2.24.1` | Apache-2.0 | bom |
| `oban_met` | `1.3.1` | Apache-2.0 | bom |
| `oban_web` | `2.13.0` | Apache-2.0 | bom |
| `oidcc` | `3.9.0` | Apache-2.0 | bom |
| `oidcc_plug` | `0.5.1` | Apache-2.0 | bom |
| `optimus` | `0.6.1` | MIT | bom |
| `owl` | `0.13.1` | Apache-2.0 | bom |
| `pgvector` | `0.4.1` | MIT | bom |
Expand Down Expand Up @@ -137,6 +139,7 @@ it, and a reader should not infer from an entry here that the component is in a
| `telemetry` | `1.4.2` | Apache-2.0 | bom |
| `telemetry_metrics` | `1.2.0` | Apache-2.0 | bom |
| `telemetry_poller` | `1.3.0` | Apache-2.0 | bom |
| `telemetry_registry` | `0.3.2` | Apache-2.0 | bom |
| `text_diff` | `0.1.0` | MIT | bom |
| `texture` | `1.2.1` | Apache-2.0 | bom |
| `thousand_island` | `1.5.0` | MIT | bom |
Expand Down
2 changes: 2 additions & 0 deletions VERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ never pin a version hex marks as retired or vulnerable.
| `luerl` | ~> 1.5 | ✅ in `mix.lock` | Lua 5.3 implemented in Erlang, for the in-VM sandbox: a skill's script runs inside this node with no OS process, no file handle and no socket. Apache-2.0, **no dependencies of its own**; 1.5.1 measured on hex.pm 2026-09-24, released 2025-12-03. `:luerl_sandbox` is a module of this package and not a package of its own, which slice 110's SLICE.md assumed wrongly. Its `max_reductions` is polled with a 100 ms sleep between checks, so it cannot bound a tight loop below tens of millions of reductions; Trinity runs the interpreter under its own process with `max_heap_size` and a timeout instead, and reports reductions as a statistic (slice 110 NOTES F1). Added at Slice 110. |
| `stream_data` | ~> 1.4 | ✅ in `mix.lock` | Property-based testing: generated input for `test/property`. Apache-2.0; 1.4.0 measured on hex.pm 2026-09-24, released 2026-07-14. Test only, so it ships in no artifact. Elixir has no focused fuzzing library and property-based testing is what the OpenSSF gold criterion `dynamic_analysis` accepts in its place, which is why this is a dependency rather than a habit. Added at Slice 004. |
| `jose` | ~> 1.11 | ✅ in `mix.lock` | JOSE (JWK, JWS, JWKS) over OTP's crypto, for the MCP resource server's token validation and the personal profile's embedded authorization server. MIT; 1.11.12 measured on hex.pm 2026-09-22. Not joken, a layer over it this tree does not need. Added at Slice 062. |
| `oidcc` | ~> 3.9 | ✅ in `mix.lock` | OpenID Connect relying party for the web pages' login (`TrinityWeb.Auth.OIDC`): authorization code with PKCE S256, `state`, `nonce`, ID token validation. EEF Security WG, OpenID Certified; Apache-2.0. 3.9.0 measured on hex.pm 2026-10-08, released 2026-08-30; it fixes CVE-2026-75759 (GHSA-533g-4vf3-xwrj, high: an encrypted but unsigned ID token was accepted, affected >= 3.2.0-beta.1 and < 3.9.0), so the floor is 3.9 and a test asserts it. Brings `telemetry_registry` 0.3.2 (Apache-2.0). Added at Slice 136. |
| `oidcc_plug` | ~> 0.5.1 | ✅ in `mix.lock` | Plugs over oidcc for the login's redirect and callback. Apache-2.0. 0.5.1 measured on hex.pm 2026-10-08, released 2026-08-04; 0.5.0 fixed CVE-2026-66884 (GHSA-fg66-w5gp-22cr, state check broken) and CVE-2026-66883 (GHSA-w5r8-m75h-98fc, user agent binding inert) and is itself retired on hex.pm (invalid igniter requirement), so the floor is 0.5.1 and a test asserts it. Ships no LiveView `on_mount`; `TrinityWeb.Auth` is that. Added at Slice 136. |
| `oban_web` | ~> 2.13 | ✅ in `mix.lock` | The Oban dashboard, a LiveView mounted at /oban (dev, and behind `config :trinity, :oban_web, true` elsewhere). Apache-2.0 on hex.pm since the 2.12 line (2.13.0 measured 2026-09-22); the plan of 2026-09-05 wrote it in when it was commercial. Added at Slice 050. |
| `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. |
| `req_llm` | ~> 1.22 | ✅ in `mix.lock` | Provider layer (streaming, tools, structured output, usage). ⚠️ The pin was `~> 1.10` against a recorded latest of 1.10.0; the real latest was twelve minors ahead. Check event shapes against the current version at Slice 011, not against this file's prose. Added at Slice 011. |
Expand Down
5 changes: 4 additions & 1 deletion ci/ironbank/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
# exception, and it reaches only the UBI repositories, which Iron Bank proxies.
#
# scripts/headless_image.sh build trinity-headless:hardened
# docker run --rm -p 4000:4000 -e TRINITY_MCP_SERVER_TOKEN=... trinity-headless:hardened
# docker run --rm -p 4000:4000 -e TRINITY_MCP_SERVER_TOKEN=... -e TRINITY_WEB_AUTH=oidc \
# -e TRINITY_WEB_AUTH_ISSUER=... -e TRINITY_WEB_AUTH_CLIENT_ID=... trinity-headless:hardened

ARG BASE_REGISTRY=registry1.dso.mil/ironbank
ARG BASE_IMAGE=redhat/ubi/ubi9-micro
Expand Down Expand Up @@ -140,6 +141,8 @@ COPY --chown=0:0 scripts/entrypoint.sh /usr/local/bin/trinity-entrypoint
# 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).
# Slice 136: 0.0.0.0 is not the loopback, so the node refuses to boot until TRINITY_WEB_AUTH says
# how the web pages authenticate (docs/mcp-server.md).
ENV LANG=C.UTF-8 \
TRINITY_MODE=headless \
TRINITY_BIND=0.0.0.0 \
Expand Down
7 changes: 7 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,15 @@ if System.get_env("TRINITY_DB", "sqlite") == "sqlite" do
end

# Configure the endpoint
#
# Slice 136: `check_origin` is an explicit list in every environment, never `false` and never
# `:conn` (which compares the Origin with the request's own Host, a value a DNS rebinding page
# controls). The loopback names here; config/runtime.exs adds the configured host in :prod. A
# list entry with no scheme and no port matches any scheme and port, which the desktop's
# ephemeral port needs.
config :trinity, TrinityWeb.Endpoint,
url: [host: "localhost"],
check_origin: ["//localhost", "//127.0.0.1", "//[::1]"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: TrinityWeb.ErrorHTML, json: TrinityWeb.ErrorJSON],
Expand Down
1 change: 0 additions & 1 deletion config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ config :trinity, TrinityWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "pEceiX84jrYwBFOP3ahGMX2Fwkza/3kU0AHhXBIi2JNnbu7muM20VzLmWLCddSwi",
Expand Down
43 changes: 41 additions & 2 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,42 @@ if config_env() != :test and auth_profile not in [nil, ""] do
client_metadata_url: present.("TRINITY_MCP_AUTH_CLIENT_METADATA_URL")
end

# Slice 136: who the web pages answer (`Trinity.WebAuth`). `TRINITY_WEB_AUTH` is `none`, `oidc` or
# `local_token`; unset, it is `none` on a loopback bind and a refusal to boot on any other.
# `oidc` logs in at `TRINITY_WEB_AUTH_ISSUER` (default: the MCP authorization issuer, the one
# `:regulated` already requires) as `TRINITY_WEB_AUTH_CLIENT_ID` with `TRINITY_WEB_AUTH_CLIENT_SECRET`
# when the client is confidential; the callback is `TRINITY_WEB_AUTH_REDIRECT_URI`, else
# `https://<PHX_HOST>/auth/callback`. `TRINITY_WEB_AUTH_ROLE_CLAIM` names the claim roles are read
# from when it is not one of the four known shapes. `local_token` takes `TRINITY_WEB_AUTH_TOKEN`
# (at least 22 characters). The suite sets `config :trinity, :web_auth` itself.
if config_env() != :test do
present = fn name ->
case System.get_env(name) do
nil -> nil
"" -> nil
value -> value
end
end

int = fn name, default ->
case present.(name) do
nil -> default
value -> String.to_integer(value)
end
end

config :trinity, :web_auth,
mode: present.("TRINITY_WEB_AUTH"),
issuer: present.("TRINITY_WEB_AUTH_ISSUER") || present.("TRINITY_MCP_AUTH_ISSUER"),
client_id: present.("TRINITY_WEB_AUTH_CLIENT_ID"),
client_secret: present.("TRINITY_WEB_AUTH_CLIENT_SECRET"),
redirect_uri: present.("TRINITY_WEB_AUTH_REDIRECT_URI"),
role_claim: present.("TRINITY_WEB_AUTH_ROLE_CLAIM"),
token: present.("TRINITY_WEB_AUTH_TOKEN"),
idle_timeout_ms: int.("TRINITY_WEB_AUTH_IDLE_TIMEOUT_MS", 1_800_000),
absolute_timeout_ms: int.("TRINITY_WEB_AUTH_ABSOLUTE_TIMEOUT_MS", 43_200_000)
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 Expand Up @@ -226,8 +262,8 @@ if config_env() == :prod do
# default: a server on a LAN is the operator's decision, made by setting it) on `PORT`,
# 4000 by default, since nobody reads an ephemeral port off a headless machine. The bearer
# on /mcp (`TRINITY_MCP_SERVER_TOKEN`, or the generated token file) is required whatever
# the bind; the web pages carry no authentication yet, which is why the default stays on
# the loopback (docs/mcp-server.md).
# the bind. Slice 136: a bind other than the loopback needs `TRINITY_WEB_AUTH` set, or the
# node refuses to boot (`Trinity.WebAuth`).
{bind_ip, bind_port} =
if System.get_env("TRINITY_MODE") == "headless" do
ip =
Expand All @@ -246,9 +282,12 @@ if config_env() == :prod do
{{127, 0, 0, 1}, desktop_port}
end

# Slice 136: the websocket origins are the loopback names and the configured host, an explicit
# list, in every profile (config/config.exs says why never `false` and never `:conn`).
config :trinity, TrinityWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [ip: bind_ip, port: bind_port],
check_origin: Enum.uniq(["//localhost", "//127.0.0.1", "//[::1]", "//" <> host]),
secret_key_base: secret_key_base

# ## SSL Support
Expand Down
12 changes: 12 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,18 @@ config :trinity, TrinityWeb.Endpoint,
secret_key_base: "PAlioLgquSvnIrD4YjwqUt4LEP1x1E5d56Z7KY/QhgmyRUFR9ynpk3oz/hITAYwN",
server: true

# Slice 136: the suite binds the loopback, so the web pages run in `:none` unless a test says
# otherwise (those tests are synchronous and put their own `:web_auth`). `www.example.com` is the
# Host `Phoenix.ConnTest.build_conn/0` sends; it is the suite's configured host for the Host
# allow-list, as `PHX_HOST` is a deployment's. The OIDC tests run their issuer on a loopback
# port over plain HTTP, which oidcc refuses unless told; nothing outside this file tells it.
config :trinity, :web_auth,
extra_hosts: ["www.example.com"],
allow_unsafe_http: true,
idle_timeout_ms: 1_800_000,
absolute_timeout_ms: 43_200_000,
token_rate_window_ms: 2_000

# Slice 090: the LiveDashboard routes are compiled in the test environment too. They are
# `compile_env`-gated and were dev-only, which meant the custom sessions page had no test and no
# way to be screenshotted without running a dev server against a dev database. A route that exists
Expand Down
Loading
Loading