# bastion agent job manifest: schema The manifest is the contract between the agent and whoever directs it: what to read, what to do with it, and who may query the result. Every part of the agent validates against it. The loader is `bastion_agent/manifest.py`; the signature is checked by the runtime ([runtime.md](runtime.md)) with `bastion_agent/x509gate.py`. ## What a bastion agent is A bastion agent is a program that runs on the owner's own machine and does the data work that must stay there: walk a folder, extract text, sterilize personal data, chunk, embed with a local model, catalog, and seal. A model on the machine may serve as a sensor (for recall of personal data, embeddings and summaries), and that is all: the same rule as the PII sterilizer ([pii-sterilization.md](pii-sterilization.md)), a deterministic program owns the control flow and every change, and a model proposes, never acts. The owner's host writes a **job manifest**, and it is signed (by the bastion, in the managed install). The agent executes exactly that manifest and nothing else. The output is a **catalog** (small, structured cards per file and per directory) plus local vectors. The files themselves never move; a host reasons over them by querying the agent. Sharing is a grant that lets another host query this catalog: access moves, data does not. ## Rules the schema enforces 1. **Plaintext never leaves.** `sink.plaintext` is the constant `never`. Any other value is a validation error, not a setting. 2. **Nothing runs that was not granted.** `mask` is the ceiling; every stage must be covered by an op in the mask, or the manifest is refused before it runs. 3. **Nothing leaves unsealed.** A catalog sent to the host, `seal_to` recipients, or any grant requires the `seal` stage. 4. **No addresses.** The sanitizer and the embedder are found from the machine's environment when the agent runs. The manifest never carries a host:port. 5. **Signed by a CA-issued identity.** The runtime refuses a manifest whose signing certificate does not chain to the root in `FREEAGENT_CA_ROOT`, and refuses to judge at all without that root, because "signed by the key in the certificate next to the manifest" proves nothing. The signing certificate must carry exactly one CN. The loader checks the shape and computes the signing payload; the runtime checks the signature and the chain in the standard library (ECDSA P-256 / SHA-256), so the agent needs neither openssl nor a third-party package on the machine. ## File layout ```yaml freeagent: 1 id: owner: host: # the authoring host's cert CN scope: roots: [, ...] exclude: [, ...] # optional follow_symlinks: false # optional max_file_bytes: 268435456 # optional mask: [read, search, index, summarize] # subset; 'read' is mandatory stages: [walk, extract, sanitize, chunk, embed, catalog, seal] # ordered subset sanitize: # required iff 'sanitize' in stages mode: sterilize | flag scope: # optional, default = id verify: false # optional embed: # required iff 'embed' in stages model: dim: chunk: size_tokens: overlap_tokens: summarize: # optional; needs 'summarize' in mask model: per: [file, directory] sweep: 15m | hourly | daily | manual sink: catalog: local | host vectors: local plaintext: never seal_to: [, ...] # optional grants: # optional, default [] - host: ops: [search, read_range] until: signature: # optional in the file; runtime requires it alg: ecdsa-p256-sha256 cert: value: ``` The first key, `freeagent: 1`, keeps the program's first name on purpose: the bastion writes it into every manifest it generates, and renaming it would make the agent refuse them. ## Fields ### Top level | Field | Required | Rules | |---|---|---| | `freeagent` | yes | Schema version. Integer `1`. | | `id` | yes | Lowercase slug `[a-z0-9-]+`, 1–63 characters. Names the catalog and is the default token scope for personal data. | | `owner.host` | yes | Slug, 1–63 characters. The authoring host's certificate CN; the runtime checks the signing certificate's CN against it. | Unknown keys anywhere are a validation error, so a misspelled `excludes:` fails loudly instead of silently indexing everything. ### `scope` | Field | Required | Rules | |---|---|---| | `roots` | yes | Non-empty list of paths. Each must be absolute (POSIX `/srv/a`, a Windows drive `C:\Users\me` or `C:/Users/me`, UNC `\\server\share`) or start with `~/` (or be exactly `~`), which expands to the running user's home on every OS. Relative paths are refused, so a manifest means the same thing wherever it lands. No duplicates. | | `exclude` | no | List of glob strings, matched against the `/`-separated path relative to each root (`**` spans directories; a pattern with no `/` also matches any single path segment, so `node_modules` prunes it at any depth). Case-insensitive on Windows only. Default `[]`. See [walk.md](walk.md). | | `follow_symlinks` | no | Boolean, default `false`. | | `max_file_bytes` | no | Positive integer, default `268435456` (256 MiB). Larger files are catalogued by metadata only, never read. | ### `mask`: the ceiling A non-empty list from `read`, `search`, `index`, `summarize`, with no duplicates. - `read`: **mandatory**; every stage reads. - `search`: the agent may answer `search` queries over the vectors. - `index`: the agent may write vectors and catalog entries. Required by `embed` and `catalog`. - `summarize`: a local model may write summaries. Required by the `summarize` section. There is no `write` and no `delete`: an agent never changes the files it reads. Any other value is an error. ### `stages`: what runs, in order A non-empty list, a subset of the canonical order, in canonical order: ``` walk → extract → sanitize → chunk → embed → catalog → seal ``` | Stage | Needs | Does | |---|---|---| | `walk` | (always first, always present) | The file system and metadata map: path, size, mtime, type, hash. The cheapest thing a host can plan from. | | `extract` | `walk` | Text out of documents, code and (with a local OCR tool) images. | | `sanitize` | `extract` | Personal data replaced by tokens in the text every later stage reads; never in the source files. | | `chunk` | `sanitize` | Splits the sanitized text for embedding. | | `embed` | `chunk`; mask `index` | Vectors from a local model, stored locally. | | `catalog` | `sanitize`; mask `index` | Per-file and per-directory cards: what it is, when, entity counts, summary. **This is the curated dataset.** | | `seal` | `catalog` | Encrypts the catalog to the owner's key and `sink.seal_to`. | Out-of-order, duplicate or unknown stage names are errors, as is a stage whose prerequisite stage is absent. ### `sanitize` Present **if and only if** `sanitize` is in `stages`. | Field | Required | Rules | |---|---|---| | `mode` | yes | `sterilize`: the full pipeline (rules, local-model recall, vault). `flag`: the rules pass only; deterministic, no model, and **not** "no personal data" (see [sanitize.md](sanitize.md)). | | `scope` | no | Token scope, 1–63 characters. Default: the manifest `id`. The same (scope, type, value) gets the same token; a different scope gets a different token, so two catalogs cannot be linked through placeholders. | | `verify` | no | Boolean, default `false`. A second, adversarial model pass (`sterilize` only; an error with `flag`). | Where the sterilizer runs is never in the manifest; the agent reads it from its environment and fails closed if it is absent. ### `embed` Present **if and only if** `embed` is in `stages`. | Field | Required | Rules | |---|---|---| | `model` | yes | Model name, 1–128 characters. | | `dim` | yes | Integer 1–8192. The stage refuses to run if the local endpoint's vectors have another dimension. Vectors are stored per model, so a later model change never discards existing vectors. | | `chunk.size_tokens` | yes | Integer 1–8192. | | `chunk.overlap_tokens` | yes | Integer, 0 ≤ overlap < size. | There is no endpoint field. Embedding is local, always. ### `summarize` Optional. Requires `summarize` in `mask` and `catalog` in `stages`. | Field | Required | Rules | |---|---|---| | `model` | yes | Local model name, 1–128 characters. | | `per` | yes | Non-empty subset of `file`, `directory`. | ### `sweep` One of `15m`, `hourly`, `daily`, `manual`. ### `sink` | Field | Required | Rules | |---|---|---| | `catalog` | yes | `local`: the sealed catalog stays here, and the host queries it. `host`: the sealed catalog is sent to the owner's host, so it can plan while this machine is asleep. Requires the `seal` stage. | | `vectors` | yes | `local`, the only value. | | `plaintext` | yes | `never`, the only value, ever. It is there so the rule is written down in every manifest and can be searched for, not remembered. | | `seal_to` | no | List of recipient keys, 1–128 characters each, no duplicates. Requires the `seal` stage. The owner's key is always added by the agent and need not be listed. | ### `grants` An optional list, default `[]`. Each entry lets another host query this catalog. Requires the `seal` stage. | Field | Required | Rules | |---|---|---| | `host` | yes | Slug, 1–63 characters: the grantee host's certificate CN. Must not equal `owner.host`. Unique across grants. | | `ops` | yes | Non-empty subset of `search`, `read_range`. `search` requires `search` in the mask; `read_range` requires `read` (always present). | | `until` | yes | `YYYY-MM-DD` expiry, a real calendar date. | Revoking is removing the entry and re-signing; `serve` stops serving that host on its next request. The grantee's client certificate must also chain to `FREEAGENT_CA_ROOT` ([serve.md](serve.md)). ### `signature` Optional in the file, so a manifest can be written, validated and reviewed before it is signed. **The runtime refuses an unsigned manifest.** `python3 -m bastion_agent validate --require-signed` enforces that here. | Field | Required | Rules | |---|---|---| | `alg` | yes | `ecdsa-p256-sha256`. | | `cert` | yes | Path to the signing host's PEM certificate, 1–256 characters, read relative to the manifest. | | `value` | yes | Base64 signature over the signing payload. | **Signing payload** = the manifest with `signature` removed, serialized as canonical JSON (sorted keys, no whitespace, UTF-8, dates as ISO strings). `bastion_agent.signing_payload(doc)` produces it and `signing_digest(doc)` its SHA-256 hex, so the signer and the runtime agree byte for byte. Any ECDSA P-256 / SHA-256 signer will do; the managed install signs in the bastion and records the result with `sign --value`. By hand, with openssl: ``` python3 -m bastion_agent payload manifests/home-documents.yaml > payload.json openssl dgst -sha256 -sign payload.json | base64 -w0 python3 -m bastion_agent sign manifests/home-documents.yaml --value --cert ``` (`sign --key --cert ` does the same in one step.) The gate, in the standard library: exactly one CN, equal to `owner.host`; the certificate inside its validity window and either identical to a certificate in `FREEAGENT_CA_ROOT` (the managed install: its certificate is its own root, and its self-signature must verify) or signed directly by one that is a CA (basicConstraints, and keyCertSign when the root has a keyUsage extension); and the signature over this payload. It is a one-level PKI only: longer chains are not built. ## Loader `bastion_agent/manifest.py` (Python 3, PyYAML). Library API: ```python from bastion_agent import load_manifest, signing_payload, signing_digest m = load_manifest("manifests/home-documents.yaml") # raises ManifestError listing every problem m.id, m.owner_host, m.scope.roots, m.mask, m.stages # typed, frozen m.sanitize, m.embed, m.summarize # None when the section is absent m.sink.catalog, m.grants, m.signed # grants: tuple of Grant signing_payload(m.raw) # bytes the signature covers ``` CLI (from the repository root): ``` python3 -m bastion_agent validate [--require-signed] [...] python3 -m bastion_agent show # stages, mask, grants, signed? python3 -m bastion_agent payload # canonical signing payload to stdout ``` Validation collects **all** errors before failing, so one pass shows everything wrong with the file. `python3 -m bastion_agent validate manifests/*.yaml` validates every example. `python3 -m unittest bastion_agent.test_manifest` runs the loader's tests, which also load `manifests/home-documents.yaml`, so the example can never drift from the schema. ## Deliberately absent - A `write` op. An agent reads; adding a way to change files is a decision of its own, not a field. - An endpoint or address field anywhere, for the reason in rule 4.