4.4 KiB
bastion agent seal stage: the catalog in one encrypted bundle
seal is the last stage of a run: it packs an allowlisted bundle of the
catalog into one file, encrypted with age to the owner and the manifest's named
recipients. It writes the bundle into the output folder and sends it nowhere.
serve answers queries with cards and excerpts, unsealed
(serve.md). It is bastion_agent/seal.py.
What it does
python3 -m bastion_agent seal <manifest> [--out DIR] [--require-signed]
builds the bundle and encrypts it to the owner and the manifest's
sink.seal_to recipients:
<out>/sealed/<id>-<stamp>.tar.age the envelope (0600)
<out>/sealed/<id>-<stamp>.tar.age.sig optional detached signature
<out>/sealed/latest.json pointer to the newest envelope
<out>/seal.summary.json sizes, digests, recipients, members
python3 -m bastion_agent unseal <file.tar.age> --identity <age key> --dest DIR
is the owner's side: it decrypts, refuses unsafe paths, and checks every
member's SHA-256 against the inventory the sealer wrote inside the bundle.
What is in the bundle, and what cannot be
| Member | Content |
|---|---|
manifest.json |
The manifest as executed (signature block removed). |
catalog.jsonl, catalog.dirs.jsonl |
The cards: sanitized text only. |
<stage>.summary.json |
The earlier stages' summaries: counts, digests, tool and model names. |
seal.manifest.json |
The inventory: sha256 and size per member, the manifest digest, the time of sealing. |
The bundle is built from an allowlist: the two catalog files
(BUNDLE_ALLOW), the stage summaries (names matching <stage>.summary.json),
and the two files the sealer writes itself. There is no code path that adds
the extract store, the sanitized store, the vault, vectors.db, or any other
*.jsonl index. A test opens the bundle and checks exactly that, and searches
it for the fixture's personal data. The tar is built with fixed member times
and a fixed order, so the same catalog sealed at the same moment is the same
plaintext.
Recipients
- Owner:
FREEAGENT_OWNER_RECIPIENT, anage1…public key, from the environment. Mandatory: no owner recipient, no seal. It is never in the manifest, because the manifest is written by the host and the owner's key is the machine's business. sink.seal_to: each entry is anage1…recipient or anssh-ed25519orssh-rsapublic-key line (age accepts both). Any other string is refused before anything is written.- Duplicates collapse; the owner is always first.
- Grantees are not recipients. A
grantsentry lets another host query this agent; it never receives the sealed catalog. Sharing is access, not a copy.
Why age
age (X25519 and ChaCha20-Poly1305) is a small, widely reviewed format with a single static binary for Mac, Windows and Linux. The envelope cannot be made with the standard library (it has no X25519 and no ChaCha20-Poly1305), so, unlike the extract stage's optional tools, age is required here: without it no bundle is written at all, and the stage says so and exits 1.
Signature (optional)
SEAL_SIGN_KEY=<path to a PEM private key> adds a detached
openssl dgst -sha256 signature over the envelope, so a receiving host can
check who sealed it. The signature covers that one envelope (age encrypts
differently every time), not the plaintext. The stage only produces the
signature; checking it against a certificate is the receiver's job. A test
proves it verifies against the public half.
Tests
python3 -m unittest bastion_agent.test_seal covers: no owner recipient,
refused with nothing written; malformed recipients refused and named; no age
on PATH, refused; seal not in the stages, refused; the bundle allowlist
(present members, forbidden members absent, no values, sanitized placeholders
present); reproducible plaintext; a round trip with two real recipients and a
stranger refused; owner and recipient de-duplication and an ECDSA P-256
signature that verifies with openssl; and a tampered member refused on unseal.
The round-trip tests are skipped where age or age-keygen is not installed.
Where it sits
walk → extract → sanitize → chunk → embed → catalog → seal, each usable on its
own and each with its own document here. The runtime around them (the sweep
scheduler and the manifest gate) is runtime.md; the query
endpoint is serve.md.