4.3 KiB
bastion agent seal stage: the catalog leaves, nothing else does
seal is the last stage of a run and the one way a dataset leaves the machine:
an allowlisted bundle of the catalog, encrypted with age to the owner and the
manifest's named recipients. 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: a catalog that cannot be sealed does not leave, 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.