bastion agent 0.1.17
This commit is contained in:
@@ -1 +1 @@
|
||||
0.1.16
|
||||
0.1.17
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user