bastion agent 0.1.17
ci / personal-data (push) Successful in 3s
ci / test (push) Successful in 45s
ci / reproducible (push) Successful in 3s

This commit is contained in:
Privatae
2026-10-29 21:42:06 +00:00
parent e3074f50b0
commit 3ae3a2841c
22 changed files with 226 additions and 81 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.16
0.1.17
+6 -5
View File
@@ -1,6 +1,6 @@
"""bastion agent `catalog` stage — the curated dataset.
The catalog is what a host reasons over and what `seal` ships. One card
The catalog is what a host reasons over and what `seal` packs. One card
per file, one per directory, small and structured, built from:
walk.jsonl metadata (path, size, mtime, mime, hash)
@@ -22,9 +22,10 @@ masked as [PII_TYPE] in the card. Contextual PII in a filename
("Marisol_medical.pdf") is not caught by rules — the honest limit of the
sanitize stage applies here too, and the doc says so.
The optional summarizer (manifest `summarize`) is a local chat model — a
sensor: it fills the `summary` string and nothing else, is reached via
SUMMARIZE_URL (never the manifest), sees only sanitized text, and is
The optional summarizer (manifest `summarize`) is a chat model at the
server SUMMARIZE_URL names (never the manifest; by default the Ollama port
on this machine, 127.0.0.1:11434) — a sensor: it fills the `summary`
string and nothing else, is sent only sanitized text, and is
cached per (document, model, version). The first failure stops
summarizing (cards still get written, `summary_error` set, exit 1).
"""
@@ -151,7 +152,7 @@ def mask_name(name, detect):
def mask_path(path, detect):
"""Every component of a relative path through mask_name — a parent
directory can carry an address as easily as a filename, and the file
card's path is what seal ships. Returns (masked
card's path is what serve returns and seal packs. Returns (masked
path, number of components changed)."""
if not path:
return path, 0
+3 -2
View File
@@ -539,9 +539,10 @@ def validate_document(doc, require_signed=False):
errors.append("summarize: requires the 'catalog' stage (summaries live in the catalog)")
if stages is not None:
if sink is not None and sink.catalog == "host" and "seal" not in stages:
errors.append("sink: catalog 'host' requires the 'seal' stage — nothing leaves unsealed")
errors.append("sink: catalog 'host' requires the 'seal' stage — the copy meant for the host "
"is the sealed bundle")
if grants and "seal" not in stages:
errors.append("grants: any grant requires the 'seal' stage — nothing is served unsealed")
errors.append("grants: any grant requires the 'seal' stage")
if sink is not None and sink.seal_to and "seal" not in stages:
errors.append("sink: seal_to given but 'seal' is not in stages")
+9 -2
View File
@@ -12,8 +12,15 @@ SECOND store:
<out>/sanitize.summary.json
From here on, later stages read ONLY the sanitized store. The extract
store keeps the plaintext and stays on this machine; the vault is the
only way back (`/rehydrate` semantics, local).
store keeps the plaintext. This agent never serves it, but it sits in
<out>, by default <root>/.agent/ inside the folder, where a sync provider
can reach it. A bastion from 0.58.1 on never serves a folder named .agent
to a host; an older bastion does to a host whose folder grant has `read`
unless the grant excludes .agent, and any bastion serves an <out> inside
the folder under another name.
The vault is the only way back from a token to a value (`/rehydrate`
semantics): a file on this machine in-process, the service's own with
PII_ADDR.
Two ways to reach the sterilizer, chosen from the environment, never the
manifest (no addresses in manifests — docs/pii-sterilization.md):
+8 -6
View File
@@ -1,9 +1,10 @@
"""bastion agent query endpoint — the container on the mesh.
The other half of "access moves, data does not". Serves the owner's host
and the manifest's grantees over MUTUAL TLS; nothing here ever reads the
extract store, the vault, or the vectors' raw table for a client — the
only bytes that leave are sanitized chunks and catalog cards.
Serves the owner's host and the manifest's grantees over MUTUAL TLS;
nothing here ever reads the extract store, the vault, or the vectors' raw
table for a client. What it returns is sanitized chunks, catalog cards, the
run state and health. (The bastion's own `read` op is not this server: it
reads files in the granted folder as they are on disk.)
Identity is the presented client certificate:
- the TLS layer requires a client cert that chains to FREEAGENT_CA_ROOT
@@ -16,7 +17,7 @@ Ops (JSON over HTTPS):
POST /search {query, k?} search (owner, grantee w/ op)
POST /read_range {doc, start, end} read_range (owner, grantee w/ op)
from the SANITIZED store only,
<= READ_RANGE_MAX bytes
<= READ_RANGE_MAX characters
Both answer with `sanitize_mode`, the mode the sanitize stage recorded for
the text in the reply ("flag" or "sterilize"; the weakest across a
search's hits; "unknown" when the record does not vouch for that text; a
@@ -414,7 +415,8 @@ class Handler(BaseHTTPRequestHandler):
start = int(req.get("start", 0))
end = int(req["end"])
if not isinstance(doc, str) or len(doc) != 64 or not all(c in "0123456789abcdef" for c in doc):
raise ValueError("doc must be a sanitized-document sha256")
raise ValueError("doc must be a document id: the original file's sha256, "
"64 lowercase hex")
if start < 0 or end <= start:
raise ValueError("start/end out of order")
end = min(end, start + READ_RANGE_MAX)
+4 -2
View File
@@ -189,7 +189,8 @@ class StageTests(Case):
def test_catalog_carries_no_values(self):
_, files, dirs = self.run_stage(manifest(self.root))
blob = json.dumps(files) + json.dumps(dirs)
for leak in ("m@example.com", "4111"):
# the whole card: four digits alone also turn up inside hashes and times
for leak in ("m@example.com", "4111 1111 1111 1111", "4111111111111111"):
self.assertNotIn(leak, blob)
def test_summaries_file_and_directory_with_cache(self):
@@ -205,7 +206,8 @@ class StageTests(Case):
# the model only ever saw sanitized text
for _, _, prompt in _StubChat.calls:
self.assertNotIn("m@example.com", prompt)
self.assertNotIn("4111", prompt)
for card in ("4111 1111 1111 1111", "4111111111111111"):
self.assertNotIn(card, prompt)
# second run: all summaries from cache, no calls
_StubChat.calls = []
s2, _, _ = self.run_stage(m)
+8 -2
View File
@@ -123,12 +123,18 @@ class DoctrineTests(unittest.TestCase):
def test_host_catalog_needs_seal(self):
doc = with_(FULL, stages=["walk", "extract", "sanitize", "chunk", "embed", "catalog"],
grants=None, sink={"catalog": "host", "vectors": "local", "plaintext": "never"})
self.assertTrue(any("unsealed" in e for e in errors_of(doc)))
errs = errors_of(doc)
self.assertTrue(any("sink: catalog 'host' requires the 'seal' stage" in e for e in errs))
# serve's replies are never sealed, so the message must not claim that
# nothing leaves, or is served, unsealed
self.assertFalse(any("unsealed" in e for e in errs), errs)
def test_grant_needs_seal(self):
doc = with_(FULL, stages=["walk", "extract", "sanitize", "chunk", "embed", "catalog"],
sink={"catalog": "local", "vectors": "local", "plaintext": "never"})
self.assertTrue(any("grants: any grant requires the 'seal'" in e for e in errors_of(doc)))
errs = errors_of(doc)
self.assertTrue(any("grants: any grant requires the 'seal'" in e for e in errs))
self.assertFalse(any("unsealed" in e for e in errs), errs)
def test_grant_search_needs_mask_search(self):
doc = with_(FULL, mask=["read", "index", "summarize"])
+5 -2
View File
@@ -104,7 +104,8 @@ class FlagModeTests(Case):
s, idx = self.run_stage(manifest(self.root, "flag"))
out = self.text_of(idx["note.txt"])
self.assertNotIn("m@example.com", out)
self.assertNotIn("4111", out)
for card in ("4111 1111 1111 1111", "4111111111111111"):
self.assertNotIn(card, out)
self.assertIn("Marisol", out) # contextual PII is NOT caught by flag mode
self.assertIn("[PII_EMAIL_1]", out)
self.assertIn("[PII_CREDIT_CARD_1]", out)
@@ -121,7 +122,9 @@ class FlagModeTests(Case):
s, idx = self.run_stage(manifest(self.root, "flag"))
blob = json.dumps(idx) + json.dumps(s)
self.assertNotIn("m@example.com", blob)
self.assertNotIn("4111", blob)
# the whole card: four digits alone also turn up inside hashes and times
for card in ("4111 1111 1111 1111", "4111111111111111"):
self.assertNotIn(card, blob)
@unittest.skipIf(IS_WINDOWS, "POSIX permission bits")
def test_vault_is_private(self):
+5 -2
View File
@@ -112,9 +112,12 @@ class FailClosedTests(Case):
for forbidden in ("walk.jsonl", "extract.jsonl", "sanitize.jsonl", "vectors.db", "vault.db"):
self.assertFalse(any(n.endswith(forbidden) for n in names), forbidden)
self.assertFalse(any(n.startswith(("extract/", "sanitize/")) for n in names))
# nothing in the bundle is a value
# nothing in the bundle is a value. The card is looked for whole, spaced
# or not: four of its digits also turn up by chance inside the bundle's
# sha256 hex (CI run 2483 failed on "...81934111").
self.assertNotIn(b"m@example.com", plain)
self.assertNotIn(b"4111", plain)
self.assertNotIn(b"4111 1111 1111 1111", plain)
self.assertNotIn(b"4111111111111111", plain)
self.assertIn(b"[PII_EMAIL_1]", plain) # sanitized head is there, as designed
self.assertEqual(set(inv["members"]), set(names))
+4 -1
View File
@@ -233,7 +233,10 @@ class ServeTests(unittest.TestCase):
self.assertIn("m@example.com", fh.read()) # the plaintext exists on disk; it has no route
st, _ = self.call("owner", "POST", "/read_range", {"doc": "0" * 64, "start": 0, "end": 10})
self.assertEqual(st, 404)
self.assertEqual(self.call("owner", "POST", "/read_range", {"doc": "zz", "start": 0, "end": 1})[0], 400)
st, body = self.call("owner", "POST", "/read_range", {"doc": "zz", "start": 0, "end": 1})
self.assertEqual(st, 400)
# the id is the original file's hash (the extract and sanitized stores share it)
self.assertIn("original file's sha256", body["error"])
self.assertEqual(self.call("owner", "POST", "/search", {"query": ""})[0], 400)
self.assertEqual(self.call("owner", "POST", "/nope", {})[0], 404)