Skip to content

Commit 2c4fe1f

Browse files
committed
feat(s025): seal the blob classes a deployment names, and close the slice
Trinity.Vault gains per-class sealing, off by default. The skills staging seals staged changes on write and opens either kind on read, so the only place that has to know is the one that writes; the archive export seals its manifest the same way. Off by default is a decision rather than an omission, and the module says why. A sealed export restores only where the key is, which is exactly what a regulated deployment wants and exactly what someone moving their data to a new laptop does not, and a sealed skill file cannot be edited in a text editor. The mechanism is built and proven; which blobs it covers is the deployment's call, and open/1 passes unsealed blobs through so the choice can change later without a migration. maybe_seal!/2 keeps the call site reading the same whether sealing is on or off, because a path that branches on whether encryption is enabled is a path where one branch is less tested than the other. The boundary refused the first version of this, correctly: Trinity.Vault had to be exported before the skills and archive sub-boundaries could call it. mix gate exit 0; 610 passed; coverage 80.31%. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 1d46845 commit 2c4fe1f

6 files changed

Lines changed: 93 additions & 3 deletions

File tree

‎coverage.tsv‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ slice_id percent sha date
2323
061 80.05 c6b6faa 2026-09-22
2424
050 80.31 c01466d 2026-09-22
2525
002 80.31 d65c48c 2026-09-23
26+
025 80.31 PENDING 2026-09-23

‎lib/trinity.ex‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ defmodule Trinity do
2121
exports:
2222
[
2323
Paths,
24+
# Slice 025: the skills staging and the archive export seal through it.
25+
Vault,
2426
Repo,
2527
Repo.Receipts,
2628
UUID,

‎lib/trinity/archive.ex‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ defmodule Trinity.Archive do
4242
try do
4343
with {:ok, staged} <- stage(layout, tmp, keys?),
4444
manifest = Manifest.build(staged, schema_versions(layout), keys?),
45-
:ok <- File.write(Path.join(tmp, Manifest.file_name()), Manifest.encode(manifest)),
45+
:ok <-
46+
File.write(
47+
Path.join(tmp, Manifest.file_name()),
48+
Trinity.Vault.maybe_seal!(Manifest.encode(manifest), :exports)
49+
),
4650
entries = [
4751
{~c"manifest.json", String.to_charlist(Path.join(tmp, Manifest.file_name()))}
4852
| staged_entries(staged)

‎lib/trinity/skills/staging.ex‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,10 @@ defmodule Trinity.Skills.Staging do
124124
|> Path.join("**")
125125
|> Path.wildcard(match_dot: true)
126126
|> Enum.filter(&File.regular?/1)
127-
|> Map.new(fn p -> {Path.relative_to(p, dir), File.read!(p)} end)
127+
|> Map.new(fn p ->
128+
{:ok, content} = Trinity.Vault.open(File.read!(p))
129+
{Path.relative_to(p, dir), content}
130+
end)
128131
end
129132

130133
@doc "Removes a change's staged files (a rejection, or after a promotion)."
@@ -295,7 +298,10 @@ defmodule Trinity.Skills.Staging do
295298
for {rel, content} <- tree do
296299
path = Path.join(dir, rel)
297300
File.mkdir_p!(Path.dirname(path))
298-
File.write!(path, content)
301+
# Slice 025: staged changes are one of the blob classes the vault can seal. Sealing is off
302+
# unless a deployment turns it on, and `read_tree/1` opens either kind, so this is the only
303+
# place that has to know.
304+
File.write!(path, Trinity.Vault.maybe_seal!(content, :staged_skills))
299305
end
300306

301307
:ok

‎lib/trinity/vault.ex‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,36 @@ defmodule Trinity.Vault do
3636
@tag_bytes 16
3737
@key_bytes 32
3838

39+
@doc """
40+
Whether a class of blob is sealed on write.
41+
42+
Configured per class and **off by default**:
43+
44+
config :trinity, :vault, seal: [:staged_skills, :exports]
45+
46+
Off by default because sealing is not free in ways that matter to the person using this. A
47+
sealed export can only be restored where the key is, which is exactly what a regulated
48+
deployment wants and exactly what someone moving their data to a new laptop does not. A sealed
49+
skill file cannot be edited in a text editor. The mechanism is built, tested and available; which
50+
blobs it applies to is the deployment's call rather than this slice's, and `open/1` passes
51+
unsealed blobs through unchanged so the choice can be made later without a migration.
52+
"""
53+
@spec sealing?(atom()) :: boolean()
54+
def sealing?(class) when is_atom(class) do
55+
class in (Application.get_env(:trinity, :vault, [])[:seal] || [])
56+
end
57+
58+
@doc """
59+
Seals a blob if its class is configured for sealing, and returns it unchanged if not.
60+
61+
The call site reads the same either way, which is the point: a path that has to branch on whether
62+
encryption is on is a path where one branch is less tested than the other.
63+
"""
64+
@spec maybe_seal!(binary(), atom()) :: binary()
65+
def maybe_seal!(plaintext, class) when is_binary(plaintext) and is_atom(class) do
66+
if sealing?(class), do: seal!(plaintext), else: plaintext
67+
end
68+
3969
@doc "The marker a sealed blob starts with, so a caller can tell one without decrypting it."
4070
@spec version() :: binary()
4171
def version, do: @version

‎test/trinity/vault_test.exs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,53 @@ defmodule Trinity.VaultTest do
117117
assert {:error, :malformed_blob} = Vault.open(Vault.version() <> <<200::8>> <> "short")
118118
end
119119

120+
describe "AC2: the blob classes, sealed only where a deployment asks" do
121+
test "sealing is off unless configured, and the call site reads the same either way" do
122+
refute Vault.sealing?(:staged_skills)
123+
assert Vault.maybe_seal!(@secret, :staged_skills) == @secret
124+
125+
Application.put_env(:trinity, :vault, seal: [:staged_skills])
126+
on_exit(fn -> Application.delete_env(:trinity, :vault) end)
127+
128+
assert Vault.sealing?(:staged_skills)
129+
sealed = Vault.maybe_seal!(@secret, :staged_skills)
130+
assert Vault.sealed?(sealed)
131+
assert {:ok, @secret} = Vault.open(sealed)
132+
133+
# A class that was not named is still untouched, so turning one on does not turn on the rest.
134+
refute Vault.sealing?(:exports)
135+
assert Vault.maybe_seal!(@secret, :exports) == @secret
136+
end
137+
138+
test "a staged skill change is ciphertext on disk and reads back through the seam", %{
139+
dir: dir
140+
} do
141+
Application.put_env(:trinity, :vault, seal: [:staged_skills])
142+
on_exit(fn -> Application.delete_env(:trinity, :vault) end)
143+
144+
# The shape the staging writes and reads: a tree of relative paths to contents.
145+
change_dir = Path.join(dir, "change-1")
146+
File.mkdir_p!(change_dir)
147+
path = Path.join(change_dir, "SKILL.md")
148+
File.write!(path, Vault.maybe_seal!(@secret, :staged_skills))
149+
150+
refute String.contains?(File.read!(path), @secret)
151+
assert Vault.sealed?(File.read!(path))
152+
assert {:ok, @secret} = Vault.open(File.read!(path))
153+
end
154+
155+
test "an export manifest is ciphertext on disk when exports are sealed", %{dir: dir} do
156+
Application.put_env(:trinity, :vault, seal: [:exports])
157+
on_exit(fn -> Application.delete_env(:trinity, :vault) end)
158+
159+
path = Path.join(dir, "manifest.json")
160+
File.write!(path, Vault.maybe_seal!(@secret, :exports))
161+
162+
refute String.contains?(File.read!(path), @secret)
163+
assert {:ok, @secret} = Vault.open(File.read!(path))
164+
end
165+
end
166+
120167
test "an unsealed blob passes through, so the two paths are the same code" do
121168
refute Vault.sealed?("plain text written before this slice")
122169

0 commit comments

Comments
 (0)