bastion agent 0.1.14

This commit is contained in:
2026-09-26 18:31:40 +00:00
commit 3f1d48bf9b
65 changed files with 15548 additions and 0 deletions
+238
View File
@@ -0,0 +1,238 @@
"""bastion agent `seal` stage — encrypt the catalog to the owner (and named
recipients) so it can leave the machine.
What is sealed — and ONLY this — is an allowlisted bundle:
manifest.json the manifest as executed (minus signature)
catalog.jsonl file cards
catalog.dirs.jsonl directory cards
*.summary.json every stage's summary — counts, digests,
tool names; never a value
seal.manifest.json bundle inventory with sha256 per member
Never in the bundle, by construction (the allowlist is the mechanism, a
test is the proof): the extract store (plaintext), the sanitized store,
the vault, vectors.db, chunks. `sink.plaintext: never` is enforced here
one more time by not having a code path that could add those files.
The envelope is age (X25519 + ChaCha20-Poly1305): a small, widely reviewed
format with a single static binary on Mac/Windows/Linux. It is REQUIRED for this stage
— the stdlib cannot produce the envelope, and a catalog that cannot be
sealed does not leave. Recipients:
owner FREEAGENT_OWNER_RECIPIENT (an age1… public key), from the
environment, never the manifest. Always present.
seal_to manifest sink.seal_to — each entry an age1… recipient (or
an ssh-ed25519/ssh-rsa public key line, which age accepts).
Grantees (manifest `grants`) are NOT recipients: they query the container
over the mesh; they never receive the sealed catalog.
Output: <out>/sealed/<id>-<stamp>.tar.age, <out>/sealed/latest.json (a
pointer, not a symlink — Windows), <out>/seal.summary.json. Optional
detached signature with the host's key: SEAL_SIGN_KEY (PEM path) →
<file>.sig via openssl, so a receiving host can check who sealed it once
the runtime's CA verification lands.
"""
import datetime as _dt
import hashlib
import io
import json
import os
import re
import shutil
import subprocess
import tarfile
import tempfile
from .manifest import signing_digest
SEAL_VERSION = 1
AGE_RECIPIENT_RE = re.compile(r"^(age1[a-z0-9]{58}|ssh-(ed25519|rsa) [A-Za-z0-9+/=]+( .*)?)$")
BUNDLE_ALLOW = ("catalog.jsonl", "catalog.dirs.jsonl")
SUMMARY_GLOB_RE = re.compile(r"^[a-z]+\.summary\.json$")
TOOL_TIMEOUT = 300
class SealError(Exception):
"""Fail closed: nothing leaves."""
def tool_path(name):
return shutil.which(name)
def run(argv, data=None, timeout=TOOL_TIMEOUT):
try:
proc = subprocess.run(argv, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=timeout, check=False)
except (OSError, subprocess.TimeoutExpired) as exc:
raise SealError("%s: %s" % (argv[0], exc))
if proc.returncode != 0:
err = proc.stderr.decode("utf-8", "replace").strip().splitlines()
raise SealError("%s exited %d: %s" % (argv[0], proc.returncode, err[-1] if err else ""))
return proc.stdout
def recipients_for(manifest, env):
owner = env.get("FREEAGENT_OWNER_RECIPIENT", "").strip()
if not owner:
raise SealError("FREEAGENT_OWNER_RECIPIENT is not set — the owner's age public key "
"is the mandatory recipient; refusing to seal to nobody")
recips = [owner] + list(manifest.sink.seal_to)
bad = [r for r in recips if not AGE_RECIPIENT_RE.match(r)]
if bad:
raise SealError("not an age recipient (age1… or ssh-ed25519/ssh-rsa public key): %s"
% ", ".join(r[:24] + "…" if len(r) > 24 else r for r in bad))
seen = set()
out = []
for r in recips:
if r not in seen:
seen.add(r)
out.append(r)
return out
def _sha256_file(path):
h = hashlib.sha256()
with open(path, "rb") as fh:
for buf in iter(lambda: fh.read(1024 * 1024), b""):
h.update(buf)
return h.hexdigest()
def build_bundle(manifest, out_dir, stamp):
"""The plaintext tar, in memory. Returns (bytes, inventory)."""
members = []
for name in BUNDLE_ALLOW:
path = os.path.join(out_dir, name)
if not os.path.exists(path):
raise SealError("no %s in %s — run the catalog stage first" % (name, out_dir))
members.append(name)
for name in sorted(os.listdir(out_dir)):
if SUMMARY_GLOB_RE.match(name) and name != "seal.summary.json":
members.append(name)
inventory = {"freeagent": 1, "seal_version": SEAL_VERSION, "manifest_id": manifest.id,
"manifest_digest": "sha256:" + signing_digest(manifest.raw),
"sealed_at": stamp, "members": {}}
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
def add_bytes(name, data):
info = tarfile.TarInfo(name)
info.size = len(data)
info.mtime = 0 # reproducible: the content is what is signed, not the clock
info.mode = 0o600
tar.addfile(info, io.BytesIO(data))
inventory["members"][name] = {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
body = {k: v for k, v in manifest.raw.items() if k != "signature"}
add_bytes("manifest.json", json.dumps(body, sort_keys=True, indent=2, default=str).encode("utf-8"))
for name in members:
with open(os.path.join(out_dir, name), "rb") as fh:
add_bytes(name, fh.read())
add_bytes("seal.manifest.json", json.dumps(inventory, sort_keys=True, indent=2).encode("utf-8"))
return buf.getvalue(), inventory
def seal(manifest, out_dir, env=None, now=None):
if "seal" not in manifest.stages:
raise SealError("'seal' is not in this manifest's stages")
env = dict(os.environ) if env is None else env
age = tool_path("age")
if not age:
raise SealError("`age` is not on PATH — the envelope cannot be produced, nothing leaves "
"(install age: https://age-encryption.org, one static binary)")
recips = recipients_for(manifest, env)
stamp = now or _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
plain, inventory = build_bundle(manifest, out_dir, stamp)
sealed_dir = os.path.join(out_dir, "sealed")
os.makedirs(sealed_dir, exist_ok=True)
fname = "%s-%s.tar.age" % (manifest.id, stamp)
target = os.path.join(sealed_dir, fname)
argv = [age, "--encrypt"]
for r in recips:
argv += ["-r", r]
argv += ["-o", target + ".tmp"]
run(argv, data=plain)
os.replace(target + ".tmp", target)
try:
os.chmod(target, 0o600)
except OSError:
pass
signature = None
sign_key = env.get("SEAL_SIGN_KEY", "").strip()
if sign_key:
openssl = tool_path("openssl")
if not openssl:
raise SealError("SEAL_SIGN_KEY set but openssl is not on PATH")
sig = run([openssl, "dgst", "-sha256", "-sign", sign_key, target])
with open(target + ".sig", "wb") as fh:
fh.write(sig)
signature = os.path.basename(target) + ".sig"
summary = {
"freeagent": 1, "stage": "seal", "seal_version": SEAL_VERSION,
"manifest_id": manifest.id,
"manifest_digest": inventory["manifest_digest"],
"sealed_at": stamp,
"file": os.path.join("sealed", fname),
"bytes_plain": len(plain),
"bytes_sealed": os.path.getsize(target),
"sha256_plain": hashlib.sha256(plain).hexdigest(),
"sha256_sealed": _sha256_file(target),
"recipients": recips, # public keys — safe to record
"members": sorted(inventory["members"]),
"signature": signature,
"catalog_sink": manifest.sink.catalog,
}
pointer = {"file": summary["file"], "sha256_sealed": summary["sha256_sealed"],
"sealed_at": stamp, "manifest_digest": summary["manifest_digest"]}
_atomic_write(os.path.join(sealed_dir, "latest.json"), json.dumps(pointer, indent=2, sort_keys=True) + "\n")
_atomic_write(os.path.join(out_dir, "seal.summary.json"), json.dumps(summary, indent=2, sort_keys=True) + "\n")
return summary
def unseal(sealed_path, identity_path, dest_dir):
"""Owner-side: open a sealed bundle with an age identity file into
dest_dir. Verifies every member against the inventory."""
age = tool_path("age")
if not age:
raise SealError("`age` is not on PATH")
plain = run([age, "--decrypt", "-i", identity_path, sealed_path])
os.makedirs(dest_dir, exist_ok=True)
with tarfile.open(fileobj=io.BytesIO(plain), mode="r") as tar:
names = tar.getnames()
if any(n.startswith("/") or ".." in n.split("/") for n in names):
raise SealError("bundle contains an unsafe path")
inv_member = tar.extractfile("seal.manifest.json")
if inv_member is None:
raise SealError("bundle has no seal.manifest.json")
inventory = json.loads(inv_member.read().decode("utf-8"))
for name in names:
data = tar.extractfile(name).read()
if name != "seal.manifest.json":
want = inventory["members"].get(name, {}).get("sha256")
if want != hashlib.sha256(data).hexdigest():
raise SealError("member %s does not match the inventory" % name)
with open(os.path.join(dest_dir, name), "wb") as fh:
fh.write(data)
return inventory
def _atomic_write(path, text):
d = os.path.dirname(path)
os.makedirs(d, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=d)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
fh.write(text)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise