Files
bastion-agent/docs/serve.md
T
2026-09-26 18:31:40 +00:00

5.5 KiB
Raw Blame History

bastion agent query endpoint: serve

serve is how a host reads what the agent prepared without the data moving: a host (the owner's, or a grantee's) sends a query and gets sanitized chunks and catalog cards back. Nothing else has a route: not the extract store, not the vault, not the vectors table. It is bastion_agent/serve.py, mutual TLS with the standard library's ssl.

python3 -m bastion_agent serve <manifest> [--out DIR] [--bind HOST:PORT] [--port-file FILE] [--allow-unsigned]

Identity: the client certificate

TLS runs with ssl.CERT_REQUIRED against FREEAGENT_CA_ROOT, TLS 1.2 at least. Each connection's handshake runs in that connection's own thread with a 10 s timeout, never on the accept loop, so a peer that connects and says nothing costs its own thread and nothing else; an idle connection is closed after 60 s. A client with no certificate, or one from another CA, never reaches a handler: the handshake fails, one line goes to stderr, and there is no audit row (it never became a request). For a client that did chain, the certificate's CN is who they are:

CN Gets
equal to the manifest's owner.host search (if search is in the mask), read_range, catalog, status, health
a host in manifest.grants, on or before its until date exactly that entry's ops (search and/or read_range), plus health
anyone else who chained health only; everything else is 403, audited with the CN

There is no other credential: no token, no password, no header.

Routes

Route Body Returns
POST /search {query, k?} (k defaults to 5 and is held to 1–50) {hits, k}: the top k sanitized windows with score, document id, chunk id, index and offsets. The same search as the CLI, through the same EMBED_URL model (embed.md); 502 if the model cannot be reached.
POST /read_range {doc, start?, end} Up to 65,536 characters of the sanitized text of that document, with total.
GET /catalog, GET /catalog/dirs query prefix, offset, limit (default 500, at most 2,000) The file cards or the directory cards (owner only), a page at a time: {cards, kind, total, offset, limit, next_offset, prefix}, next_offset being null on the last page. 404 until the first sweep has written a catalog.
GET /status {state}: run.state.json (owner only).
GET /health {ok, id, serving} for any client that chained.

doc is the document's content hash (64 lowercase hex), the same id the extract and sanitized stores share. The route reads only the sanitize/ directory, so the plaintext for that id, which exists on disk, is never what comes back; a test checks both halves of that sentence.

The manifest is the policy, live

On each request the endpoint checks whether the manifest file changed, and if so re-reads it and runs the same gate as the scheduler: the signature over the canonical payload, the signing certificate's CN equal to owner.host, and the chain to FREEAGENT_CA_ROOT (required). So:

  • Revoke a grant: delete the entry and re-sign. The next request from that CN is 403.
  • Edit without re-signing: the endpoint serves nothing but /health (which reports serving: false), and answers everything else with 503 and the gate's reason until the manifest verifies again.
  • --allow-unsigned is for development only, as for sweep.

Audit

Every request that reached a handler is one line in <out>/serve.audit.jsonl: ts, cn, op, path, status, bytes; for search the first 16 hex characters of the query's sha256 (never the query) and the number of hits; for read_range the start of the document id and the characters served; for the catalog the page size and the total. A test checks that the query text is not in the file.

Bind and certificates

  • --bind or FREEAGENT_BIND: host:port or [v6]:port, default [::]:8470. --bind wins. An IPv6 address gets an AF_INET6 socket.
  • --port-file FILE writes the port actually bound. The bastion runs serve on 127.0.0.1:0 and reads the port from this file, so the endpoint is not reachable from the network; the bastion is its client. Run by hand for another machine to reach, bind the interface it reaches instead.
  • FREEAGENT_TLS_CERT and FREEAGENT_TLS_KEY: the machine's certificate and key. FREEAGENT_CA_ROOT: the CA that client certificates must chain to. All three are required; the server refuses to start without them.

Tests

python3 -m unittest bastion_agent.test_serve builds a small PKI per test class (a CA root, a server certificate, owner, grantee, expired-grant and unknown-CN client certificates, and a client certificate with the owner's CN from a different CA) and checks: the owner gets every route and sees sanitized heads in the catalog, paged; the grantee gets search and is 403 on read_range, catalog and status; an expired grant and an unknown CN are 403; no certificate, or a foreign CA, fails the handshake and leaves no audit row; a connection that never speaks does not stall the server; read_range is capped, returns sanitized text for the shared id while the plaintext sits on disk with no route, and gives 404 for an unknown id and 400 for malformed ids and queries; the audit hashes the query; a grant revoked by re-signing is refused on the next request; an edit without re-signing turns the endpoint into 503 with serving: false, and re-signing restores it; an unreadable manifest fails closed; and --port-file reports the bound port. Plus bind parsing, CN extraction, and the required TLS inputs.