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
92 changes: 91 additions & 1 deletion .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,47 @@ on:
workflow_dispatch:

jobs:
# Slice 002. The bill of materials is derived from `mix.lock` alone: it is the same document
# whichever runner produces it, and `mix trinity.sbom` needs the project compiled in the dev
# environment, which the three packaging jobs do not otherwise do. Generating it once here and
# handing it to each of them keeps the bill travelling with every binary without paying for a
# second full compile on three operating systems, and without giving Windows a chance to
# produce a bill that differs from Linux's for no reason a reader could act on.
sbom:
name: bill of materials
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
with:
version-file: .tool-versions
version-type: strict

- run: mix deps.get

- name: Generate the bill of materials
run: mix trinity.sbom

- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sbom
path: sbom.cdx.json
if-no-files-found: error

package:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
needs: sbom
permissions:
contents: read
# Slice 002: build provenance is an attestation signed through GitHub's OIDC identity for
# this workflow run, recorded in the public transparency log. `id-token` mints that
# identity and `attestations` writes the record; neither grants anything else.
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -319,12 +357,64 @@ jobs:
[ -n "$CHILD" ] && kill "$CHILD" 2>/dev/null || true
fi

# Slice 002. The bill of materials travels with the binary rather than being published
# somewhere a reader has to go and find: whoever has the artifact has the list of what is
# in it, and the bill states in its own metadata what it does not cover.
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: sbom
path: burrito_out

# The bill is placed beside the binary rather than listed as a second upload path, so that
# both land at the root of the artifact. Listing them separately makes upload-artifact take
# the workspace as their common ancestor and file the binary under `burrito_out/` inside the
# zip, which is a layout change every earlier release's consumers would have to absorb for
# no benefit.
#
# Corrected 2026-09-23, because the first version of this comment named a cause that is not
# true. It said `gh run download --name` refuses the two-path layout with "would result in
# path traversal". It does refuse, but the layout has nothing to do with it: gh 2.45.0 (the
# version in Ubuntu's archive) raises that error for any artifact when extracting into the
# current directory, including a pre-slice-002 single-file one, and passing `-D <dir>`
# succeeds for both. gh 2.63.2 downloads both without `-D`. The error was measured against
# unlike invocations and read as a regression it was not.
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ${{ matrix.artifact }}
path: burrito_out/${{ matrix.artifact }}
path: |
burrito_out/${{ matrix.artifact }}
burrito_out/sbom.cdx.json
if-no-files-found: error

# Build provenance: a signed statement of which workflow, at which commit, produced these
# exact bytes. It proves where the artifact came from, not what it does. Verified in this
# same job, because an attestation nobody checks is a file rather than an assurance.
- name: Attest the artifact's provenance
id: attest
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: burrito_out/${{ matrix.artifact }}

- name: Verify the attestation, and record the result in the run summary
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -o pipefail
gh attestation verify "burrito_out/${{ matrix.artifact }}" \
--repo "${{ github.repository }}" 2>&1 | tee attestation.txt
# `gh attestation verify` draws its result with carriage returns, which the log
# viewer collapses to nothing. One plain line the log keeps, reached only because
# `set -e` and `pipefail` end the step on a failed verification.
echo "gh attestation verify: PASS for ${{ matrix.artifact }}"
{
echo "### Build provenance: ${{ matrix.artifact }}"
echo
echo '```'
cat attestation.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ trinity-*.tar
npm-debug.log
/assets/node_modules/
*.db-*

# Slice 002: the bill of materials is generated by the gate and attached to releases,
# never committed: it is a statement about a build, not a source file.
/sbom.cdx.json
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ documents themselves for consistency.
Packaging as a single binary is documented in `docs/packaging.md`, with measured sizes and
start-up times for each target; `docs/fips-leg.md` describes the FIPS build leg.

**Transport and message authentication.** Outbound connections use Erlang/OTP's TLS, whose default
client versions on the pinned toolchain are TLS 1.3 and TLS 1.2: **1.2 is the floor**, and Trinity
does not lower it (TLS 1.1 and 1.0 are available in the runtime but are not in the default set). The
only message authentication code Trinity itself mints is the **AES-256-GCM** tag over the state
envelope a held tool call carries between round trips, which is an approved algorithm in **FIPS**
mode. Both statements are pinned by a test against the runtime and the source rather than against
the sentence, so the line cannot outlive the fact.

## How the work is organised

Trinity is built in reviewed increments. Each one is small enough to review in one sitting and
Expand Down Expand Up @@ -178,8 +186,8 @@ rather than described by it.
| Auditability | Decisions and effects are recorded in an Ed25519-signed hash chain with checkpoints and a verifier, so an operator can reconstruct what was done, by whom and under what decision. |
| Untrusted content | Anything arriving from outside the machine is marked untrusted at the boundary and is never treated as instruction. |
| Approved cryptography | A dedicated CI leg builds from source and runs the cryptographic properties inside a FIPS-mode container, so statements about approved algorithms are measured on that leg rather than asserted. |
| Supply chain | Dependency and licence audits run on every commit; dependency versions are pinned in `VERSIONS.md` and verified against the lock file by the gate. |
| Provenance | Every commit carries a Developer Certificate of Origin sign-off, enforced by a hook and independently by CI. |
| Supply chain | Dependency and licence audits run on every commit; dependency versions are pinned in `VERSIONS.md` and verified against the lock file by the gate. A CycloneDX bill of materials is generated by the gate on every commit and ships beside every packaged binary, so whoever holds the artifact holds the list of what is in it; the bill states its own coverage inside the document, including what it does not cover. |
| Provenance | Every commit carries a Developer Certificate of Origin sign-off, enforced by a hook and independently by CI. Every packaged binary carries build provenance attested through the workflow's own identity and recorded in a public transparency log, verified in the same run that produced it, so a holder can check where the bytes were built without trusting this page. |
| Independent self-certification | The project holds the [OpenSSF Best Practices passing badge](https://www.bestpractices.dev/projects/14772), assessed against the Open Source Security Foundation's published criteria for basics, change control, reporting, quality, security and analysis. Three suggested criteria are recorded as unmet rather than stretched: semantic versioning, which begins at the first supported release, and two concerning dynamic analysis tooling, which the project does not yet run. |
| Argued, not asserted | `docs/10-assurance-case.md` decomposes the top-level security claim into ten claims, each with its argument, the evidence a reader can check, and the limit on what it covers; the assumptions the case rests on are named rather than implied. |
| Claim discipline | `docs/09-standards-register.md` records one row per control a regulated deployment might ask about, each with an evidence path and a status. No public claim about a regulation or requirement is made without a row there saying it is true. |
Expand Down
1 change: 1 addition & 0 deletions VERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ never pin a version hex marks as retired or vulnerable.
| `postgrex` | >= 0.0.0 (optional) | ✅ in `mix.lock` | Secondary DB driver, `optional: true` so the desktop build carries none of it; compiled in only under `TRINITY_DB=postgres`, which the CI job proves. Added at Slice 010. Was one row with pgvector; pgvector keeps its own row below. |
| `pgvector` | ~> 0.4.1 | ✅ in `mix.lock` | Vectors on the Postgres path: `memories.embedding_vector vector(384)` under an HNSW cosine index, searched by `Trinity.Memory.VectorStores.Pgvector` (Slice 032). The postgres job runs on the `pgvector/pgvector:pg17` image. Split from the postgrex row at Slice 010. |
| `oban` | ~> 2.24 | ✅ in `mix.lock` | Uses `Oban.Engines.Lite` on SQLite and `Oban.Engines.Basic` on Postgres (the adapter is compile time, ADR-0002, so the engine is too). ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050 (2.24.1 measured on hex.pm 2026-09-22, Apache-2.0). |
| `sbom` | ~> 0.11 | ✅ in `mix.lock` | CycloneDX software bill of materials, generated by the gate from mix.lock (`mix sbom.cyclonedx`). Maintained by erlef, the Erlang Ecosystem Foundation; BSD-3-Clause, CC0-1.0, MIT and Apache-2.0; 0.11.0 measured on hex.pm 2026-09-23, released 2026-09-22. Dev and test only: it ships in no artifact, and the gate validates its output, so a defect in it fails the build rather than producing a quietly wrong bill. Added at Slice 002. |
| `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. |
| `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. |
Expand Down
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ slice_id percent sha date
060 80.32 49f40a4 2026-09-21
061 80.05 c6b6faa 2026-09-22
050 80.31 c01466d 2026-09-22
002 80.31 d65c48c 2026-09-23
26 changes: 21 additions & 5 deletions docs/10-assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,19 +137,35 @@ algorithms are measured on a dedicated build rather than asserted.

## C8. The supply chain is controlled and the provenance of changes is established

**Argument.** Dependencies are pinned and audited; every change carries an attested author; and no
change reaches the main branch without the full gate.
**Argument.** Dependencies are pinned and audited; what is in a build is enumerated in the build;
where a released binary came from can be checked by whoever holds it; every change carries an
attested author; and no change reaches the main branch without the full gate.

**Evidence.**
- Versions pinned in `VERSIONS.md` and verified against the lock file by the gate; `hex.audit` and
`deps.audit` run on every commit.
- A CycloneDX bill of materials is generated by the gate on every commit (`mix trinity.sbom`) and
uploaded with each platform's artifacts, so whoever has the binary has the list of what is in
it. The bill states its own coverage in a `metadata.properties` entry rather than in a document
beside it.
- Each released artifact carries build provenance attested through GitHub's workflow identity and
recorded in a public transparency log, and `gh attestation verify` checks it in the same job
that produced it, with its output in the run summary. An attestation nobody checks is a file
rather than an assurance.
- Every commit carries a Developer Certificate of Origin sign-off, enforced by a local hook and
independently by CI, so a bypassed hook still fails the build.
- The main branch is protected: changes merge only through a pull request with the gate green.

**Gap, stated rather than omitted.** The project does not yet publish a signed Software Bill of
Materials. It is planned work, and until it exists this claim is weaker than current federal
guidance asks for.
**Gaps, stated rather than omitted.** Two remain, and they are narrower than the one this document
stated before slice 002 rather than absent.

- The bill covers Hex dependencies resolved from `mix.lock` and does not cover the Rust crates the
desktop shell links. A reader who misses the coverage property would conclude there is no Rust
in the product, which is false. Slice 121 adds the crate half.
- The bill is not itself signed with a project-held key, and releases are not signed. Provenance
establishes where a binary was built; it does not establish that the project intended to publish
it. Signing is slice 101, and until it exists this claim is weaker than current federal guidance
asks for.

## C9. Memory safety by construction

Expand Down
125 changes: 125 additions & 0 deletions lib/mix/tasks/trinity.sbom.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Mix.Tasks.Trinity.Sbom do
@shortdoc "Generates the CycloneDX software bill of materials, and says what it is blind to"
@moduledoc """
Slice 002. Runs `mix sbom.cyclonedx` over `mix.lock` and writes `sbom.cdx.json`, then makes two
corrections to what it generated. Neither is decoration: without the first the bill is read as
saying more than it knows, and without the second it is not a valid CycloneDX document at all.

**What the bill covers, and what it does not.** It is generated from `mix.lock`, so it is exact
for Hex dependencies and blind to the Rust crates the desktop shell links. A reader who takes it
for the whole picture would conclude the product has no Rust in it, which is false. Rather than
leave that to a paragraph in a document nobody reads beside the file, the coverage is written
into the bill itself as a `metadata.properties` entry, where a tool or a person reading the
artifact alone will find it.

**Licence identifiers that SPDX does not define.** CycloneDX validates `license.id` against the
SPDX enumeration and rejects a bill carrying anything else, while a Hex package's `licenses`
metadata is free text that its author typed. `sbom` emits a lone licence string as an `id`
without checking it (`deps/sbom/lib/sbom/cyclonedx.ex`, `license: {:id, license}`), so one
dependency writing `BSD 2-Clause` for `BSD-2-Clause` makes the whole document fail validation:
measured, not supposed, on the first bill this project generated. Any identifier this project
cannot find in the vendored SPDX list is therefore moved to `license.name`, which is where
CycloneDX puts a licence it cannot resolve to an identifier. Nothing is dropped and nothing is
guessed: `BSD 2-Clause` stays `BSD 2-Clause`, recorded as a name rather than asserted as an
identifier it is not.

The gate runs this, so a bill that cannot be generated fails the build rather than being missing
from a release nobody checked.
"""
use Boundary, classify_to: Trinity
use Mix.Task

@output "sbom.cdx.json"
@spdx_ids_path "priv/spdx/license-ids.txt"
@coverage_property "trinity:coverage"
@coverage_value "Hex dependencies resolved from mix.lock. This bill does NOT cover the Rust " <>
"crates linked by the desktop shell (src-tauri/Cargo.lock); that half is " <>
"slice 121. Absence of a component here is not evidence of its absence " <>
"from the product."

@impl Mix.Task
def run(argv) do
output = Enum.at(argv, 0, @output)
Mix.Task.run("sbom.cyclonedx", ["--force", "--pretty", "--output", output])
annotate!(output)
Mix.shell().info("trinity.sbom: #{output} written and annotated with its coverage")
end

@doc """
The SPDX licence identifiers CycloneDX accepts, as a set.

Read from the vendored list rather than from a dependency: see the file's own header for where
it came from and for which way it fails when it goes stale.
"""
@spec spdx_ids() :: MapSet.t(String.t())
def spdx_ids do
Application.app_dir(:trinity, @spdx_ids_path)
|> then(fn path -> if File.exists?(path), do: path, else: @spdx_ids_path end)
|> File.read!()
|> String.split("\n", trim: true)
|> Enum.reject(&String.starts_with?(&1, "#"))
|> MapSet.new()
end

@doc "The property name the bill carries its coverage statement under."
@spec coverage_property() :: String.t()
def coverage_property, do: @coverage_property

@doc "The coverage statement written into the bill."
@spec coverage_value() :: String.t()
def coverage_value, do: @coverage_value

# Read, add the property, write back. A generation that produced something unreadable fails
# here rather than shipping: the point of the bill is that it can be read.
defp annotate!(output) do
bom =
case output |> File.read!() |> Jason.decode() do
{:ok, %{"bomFormat" => "CycloneDX"} = bom} ->
bom

{:ok, other} ->
Mix.raise("#{output} is not a CycloneDX document: #{inspect(Map.keys(other))}")

{:error, reason} ->
Mix.raise("#{output} is not readable as JSON: #{inspect(reason)}")
end

metadata = Map.get(bom, "metadata", %{})

properties =
metadata
|> Map.get("properties", [])
|> Enum.reject(&(Map.get(&1, "name") == @coverage_property))
|> Kernel.++([%{"name" => @coverage_property, "value" => @coverage_value}])

annotated =
bom
|> Map.put("metadata", Map.put(metadata, "properties", properties))
|> Map.replace_lazy("components", &normalise_licenses(&1, spdx_ids()))

File.write!(output, Jason.encode_to_iodata!(annotated, pretty: true))
end

# An identifier SPDX does not define is not an identifier. It keeps its text and changes field.
defp normalise_licenses(components, ids) when is_list(components) do
Enum.map(components, fn component ->
Map.replace_lazy(component, "licenses", fn choices ->
Enum.map(choices, &normalise_choice(&1, ids))
end)
end)
end

defp normalise_licenses(components, _ids), do: components

defp normalise_choice(%{"license" => %{"id" => id} = license} = choice, ids) do
if MapSet.member?(ids, id) do
choice
else
%{choice | "license" => license |> Map.delete("id") |> Map.put("name", id)}
end
end

defp normalise_choice(choice, _ids), do: choice
end
Loading
Loading