# 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](serve.md)). It is `bastion_agent/seal.py`. ## What it does `python3 -m bastion_agent seal [--out DIR] [--require-signed]` builds the bundle and encrypts it to the owner and the manifest's `sink.seal_to` recipients: ``` /sealed/-.tar.age the envelope (0600) /sealed/-.tar.age.sig optional detached signature /sealed/latest.json pointer to the newest envelope /seal.summary.json sizes, digests, recipients, members ``` `python3 -m bastion_agent unseal --identity --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. | | `.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 `.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`, an `age1…` 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 an `age1…` recipient or an `ssh-ed25519` or `ssh-rsa` public-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 `grants` entry 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=` 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](runtime.md); the query endpoint is [serve.md](serve.md).