Files
bastion-agent/docs/extract.md
T
Privatae 3ae3a2841c
ci / personal-data (push) Successful in 3s
ci / test (push) Successful in 45s
ci / reproducible (push) Successful in 3s
bastion agent 0.1.17
2026-10-29 21:42:06 +00:00

143 lines
8.2 KiB
Markdown

# bastion agent `extract` stage: text out of the walked files
The extract stage turns every walked file it can read into plain text, with
built-in readers or with local tools when they are installed. It is
`bastion_agent/extract.py` and `bastion_agent/pdftext.py`, standard library
only.
## What it produces
`python3 -m bastion_agent extract <manifest.yaml> [--out DIR] [--no-external] [--require-signed]`
reads `walk.jsonl` and `walk.summary.json` from `DIR` (the walk must have run;
the stage refuses otherwise) and writes:
- **`extract/<aa>/<sha256>.txt`**: the text store, one file per distinct
content hash, UTF-8, normalised (CRLF and lone CR become LF, NUL removed,
NFC). Content addressing is the cache: two copies of a document are
extracted once, and an unchanged file costs nothing on the next sweep.
- **`extract.jsonl`**: one record per walked file, sorted by `(root, path)`:
`sha256`, `mime`, `status`, `extractor`, `chars`, `error`, `v` (the
extractor version), `reused` when a prior verdict was kept, and on
`unsupported` records the `tools` that were present, so the verdict is
retried when a tool appears.
- **`extract.summary.json`**: counts, total characters, the tools found, the
extractor version, the manifest digest, and the manifest digest the walk
recorded, which ties the walk map and the text store to one manifest.
`DIR` defaults as for the walk (`<root>/.agent/` for one root). This text
store is the plaintext of every file in scope. The agent never serves it, but
by default it sits in the folder, so if the folder is synced, the sync provider
gets a copy. A bastion from 0.58.1 on never serves `.agent/` to a host. On an
older bastion, a host whose folder grant has `read` can read the store like
any other file unless the grant excludes `.agent`; see
[runtime.md](runtime.md#where-an-agent-lives-rootagent).
| `status` | Meaning |
|---|---|
| `ok` | Text written to the store. |
| `empty` | The reader ran and found no text (an empty file, a document with only pictures). |
| `unsupported` | No reader for this type on this machine; `error` names what would handle it. |
| `error` | The reader failed (a corrupt zip, bad XML, a tool failure); the stage continues. |
| `skipped` | The walk has no hash for it (too large, a read error, or a `--no-hash` walk). |
A record whose earlier verdict was kept carries `reused: true` and is counted
under `reused` in the summary.
Exit code: `0` clean; `1` the manifest is invalid, `extract` is not in its
stages, or there is no walk map; `2` completed with `error` records.
## Readers
Built in, no libraries:
| Type | How |
|---|---|
| `text/*`, JSON, YAML, TOML, XML, SQL, SVG, PEM, code | Decoded, and the encoding recorded (`text:utf-8`, `text:cp1252`, …): a byte-order mark first, then BOM-less UTF-16 by the share of NUL bytes, then strict UTF-8, then cp1252. |
| `text/html` | `html.parser`: scripts, styles, `noscript` and `template` dropped; block tags become line breaks; entities resolved. |
| CSV / TSV | `csv` module; each row written as tab-separated values. |
| `message/rfc822` | `email` (modern policy): From/To/Cc/Date/Subject lines, then the plain body, or the HTML body through the HTML reader. |
| RTF | A small reader of its own: control words, `\'hh` (cp1252), `\uN` (one fallback character skipped), and the groups that hold no body text (`fonttbl`, `colortbl`, `stylesheet`, `info`, `pict`, `object`, `header`, `footer`, and others) discarded. |
| DOCX | Paragraphs of `word/document.xml` (runs, tabs, breaks, text boxes after the paragraph that holds them), plus headers and footers. |
| XLSX | Shared strings, inline strings and values, one tab-separated line per row, a `# sheetN` line per sheet; empty rows dropped. |
| PPTX | Every paragraph on every slide, slides in numeric order, a `# slideN` line per slide. |
| ODT / ODS / ODP | `content.xml`: paragraphs and headings; table rows tab-separated. |
| PDF | **`pdftotext -layout -enc UTF-8` when poppler is on PATH.** Otherwise the built-in reader (below). |
| Images (PNG, JPEG, TIFF, BMP, GIF, WebP) | **`tesseract` when it is on PATH**; otherwise `unsupported`. |
Anything else (archives, executables, audio, video, unknown binaries) is
`unsupported`, with the MIME type named.
### The built-in PDF reader
Without poppler (on Windows, usually) PDFs are read by `pdftext.py`, which
walks the document far enough for text: the object table (a later definition
wins, so incremental updates are respected) and compressed object streams;
FlateDecode streams (a stream with any other filter is skipped); the page tree
in order with inherited resources; fonts through their `/ToUnicode` maps
(Type0 / Identity-H fonts, as browsers print them), or WinAnsi / MacRoman for
simple fonts without one; glyph widths, so a word placed by position still
gets its space; the text operators, and form XObjects. Pages are separated by
a form feed.
Encrypted files are refused. Output below 90% printable characters is refused
too, so a font without a usable map comes back as `unsupported` (`extractor:
pdf-builtin`, `needs pdftotext (poppler) on this machine`), never as byte salad
in the catalog.
It reads arbitrary files, so it reads on a budget per document: 60 s of wall
time, 256 MiB of decoded streams (64 MiB per stream), 5 million content
operators, 2,000 form invocations at most 6 deep, 200,000 CMap entries,
20 million tokens and 1 GiB lexed. Reaching a limit stops the read with the
pages read so far. Nothing in it raises out to the stage.
## Rules the stage enforces
- **External tools are sensors, not dependencies.** They are looked up on
PATH at run time, named in every record they produced (`extractor:
pdftotext`), and their absence is a recorded gap, never a failure.
`--no-external` (in a sweep, `FREEAGENT_NO_EXTERNAL=1`) uses the built-in
readers only. A manifest cannot name a tool or an address.
- **Nothing is written outside the output directory**, and every file is
written atomically.
- **Bounded reads.** One zip member is read to at most 64 MiB decompressed
(`ZIP_MEMBER_CAP`), so a hostile office file cannot balloon memory. Office
documents are parsed as a stream, each finished paragraph or row dropped
once read, and capped per document: 256 MiB of XML, 16 Mi characters of
text, 60 s, and 200,000 parts in one paragraph or row. Reaching a cap keeps
the text read so far and ends it with a line naming the cap. XML with a DTD
or an entity declaration is refused (Office files never carry one, and it is
how entity expansion gets in), as is XML that is neither UTF-8 nor UTF-16.
Tool calls time out at 300 s.
- **Errors are entries.** A corrupt document is one `error` record; the stage
finishes and reports the count.
- **Retry only when something changed.** An `unsupported` verdict is kept
while the machine's tool set is the same; install `pdftotext` and the next
sweep tries exactly those files again. `error` records are retried on every
sweep. Every record carries the extractor version (`v`, `EXTRACT_VERSION`,
now 2); raising it after a reader changes redoes every cached verdict and
text on the next sweep.
## Tests
`python3 -m unittest bastion_agent.test_extract bastion_agent.test_pdftext`.
`test_extract` runs every reader against a generated fixture (docx, xlsx,
pptx and odt built with `zipfile`, plain and Flate PDFs, RTF with hex and
Unicode escapes, plain and HTML e-mail, encodings), the Office streaming caps,
DTD refusal and UTF-16 input, the zip member cap, an oversized CSV field, the
whole stage over a mixed tree (statuses, counts, duplicates), reuse across
sweeps and its invalidation when content or the extractor version changes,
tool discovery and failure (mocked), and the walk-first guard. `test_pdftext`
builds browser-shaped PDFs with a small writer (Type0 fonts with ToUnicode
maps, per-run positioning, ligatures, inherited resources, form XObjects,
object streams) and hostile ones (reference cycles, deep nesting, malformed
tables and maps), and checks each budget.
The external-tool paths are tested with mocks; a real scanned PDF or image
through `pdftotext` or `tesseract` is not part of the suite.
## Not in this stage
Sanitizing (the next stage: the text store goes through the PII sterilizer),
chunking, and change detection beyond the content hash. Audio and video are not
extracted.