Retrieved article excerpt
Open article Β· Retrieved 2026-09-20T15:22:25.538616+00:00
# preflight
**A local proxy that scans LLM requests and attachments for secrets before they reach the model.**
```
ββββββββββββββββββββββββββββββββββββββ
β preflight β
β β
coding harness βββββββΊβ /v1/responses β
(any supported β /v1/chat/completions ββββββΊ underclass βββββΊ model
OpenAI-compatible β /v1/models β
client) β β
β decode Β· inspect Β· redact/block β
β local OCR Β· content-addressed β
β cache Β· structured logging β
ββββββββββββββββββββββββββββββββββββββ
127.0.0.1:8081 127.0.0.1:8080
```
Someone pastes an `.env` file? Detected credentials become stable placeholders and the request continues. A screenshot or PDF contains a key? preflight decodes it, runs local extraction and OCR, and sanitizes it where supported. When inspection or safe rewriting is impossible, the request stays grounded.
---
## Why
Coding agents read source files, shell output, screenshots, and documents. Secrets can arrive through any of them. preflight sits between the harness and [underclass](https://github.com/ghuntley/underclass), inspecting the outbound copy before inference starts:
- **One base URL change** β keep the supported OpenAI HTTP endpoints, tool-call structure, session headers, and response streams.
- **Redact by default** β replace detected secrets with `[REDACTED:rule-id]` so ordinary pasted-key incidents do not stop the agent.
- **Attachments get inspected too** β decode images and PDFs locally; scan extracted text, metadata, barcodes, and OCR output.
- **No repeated OCR tax** β identical attachments reuse completed inspection results while their scope and inspection profile remain the same.
- **No generic randomness detector** β prompts and source dumps are entropy soup. Default rules use recognizable credential shapes.
- **Nothing forwarded halfway through inspection** β the whole request is checked before it goes to underclass.
## Quick start
With underclass running on `127.0.0.1:8080`, start preflight with its complete document-processing toolchain:
```
nix run github:ghuntley/preflight -- serve
```
Point the harness at:
```
http://127.0.0.1:8081/v1
```
Keep using the existing underclass API key. By default, preflight passes authorization through. You can also configure separate client-facing and upstream credentials.
For a local checkout:
```
devenv shell -- cargo build --locked --bins
devenv shell -- cargo run --locked --bin preflight -- serve
```
Building all binaries also builds the Rust attachment worker. Startup checks the sandboxed native toolchain before the proxy becomes ready.
## How inspection works
- **Parse first.** Walk decoded JSON string leaves, including messages, instructions, tool results, and nested JSON tool arguments. Duplicate object keys are rejected.
- **Match structured content.** Each text unit goes through keyword filtering, a Gitleaks-derived regex, optional entropy filtering, and the trusted allowlist. Ordered text parts and wrapped JWTs get mapped reconstruction passes.
- **Inspect attachments locally.** Resolve their bytes, decode them, extract text, render PDF pages, and run OCR. External attachment URLs never receive the underclass credential.
- **Apply one request-wide decision.** Redact supported spans, rebuild affected artifacts, or reject the request. Rebuilt attachments are inspected again before approval.
- **Forward approved content.** Enforcing modes send the exact approved bytes or sanitized replacement. Unchanged clean requests preserve their original body bytes; underclass's response streams through.
Preflight does not retry inference requests. Underclass owns provider routing and failover.
Design decisions and their trade-offs live in [`docs/adr/`](https://github.com/ghuntley/preflight/blob/main/docs/adr) β start with [ADR 0001](https://github.com/ghuntley/preflight/blob/main/docs/adr/0001-inspection-transaction.md) for the inspection transaction and [ADR 0002](https://github.com/ghuntley/preflight/blob/main/docs/adr/0002-rust-workers-and-content-cache.md) for workers and caching.
## Policy
| mode | what happens |
| --- | --- |
| `redact` | Default. Replace text findings and sanitize supported attachments. Forward the rewritten request; return HTTP 409 when a finding has no safe replacement. |
| `no-go` | Return HTTP 409 for any non-allowlisted finding. Nothing reaches underclass. The response contains opaque finding IDs, never the secret. |
| `advisory` | Report findings and forward the original content. Useful for tuning; detected secrets can reach the model in this mode. |
All modes reject acquisition or extraction failures. Inspection failures return `422`, malformed JSON `400`, body limits `413`, and deadline exhaustion `408`. Preflight-generated errors use static codes rather than matched content.
## Configuration
Optional TOML configuration, supplied explicitly:
```
preflight serve --config /path/to/config.toml
```
| key | default | meaning |
| --- | --- | --- |
| `bind` | `127.0.0.1:8081` | listen address |
| `upstream` | `http://127.0.0.1:8080` | underclass base URL, without `/v1` |
| `mode` | `redact` | enforcement policy |
| `sandbox` | `true` | isolate attachment workers with Bubblewrap |
| `allow_page_redaction` | `false` | permit blacking out a PDF page when a finding cannot be mapped to an OCR region |
| `max_body_bytes` | `67108864` | maximum request body size: 64 MiB |
| `request_timeout_secs` | `180` | deadline covering inspection and waiting for upstream response headers |
| `carnet` | unset | path to a JSON array of exact secret SHA-256 hashes |
| `stopwords` | empty | exact whole-secret exemptions |
| `upstream_entropy` | `false` | apply upstream entropy thresholds to enabled rules |
| `cache_dir` | `$XDG_CACHE_HOME/preflight` or `~/.cache/preflight` | persistent verdicts and sanitized artifacts |
| `control_socket` | `/tmp/preflight-control.sock` | local cache administration socket |
| `resolver.file_api_base` | unset | trusted OpenAI-compatible file API base, such as `https://api.openai.com/v1` |
Example:
```
bind = "127.0.0.1:8081"
upstream = "http://127.0.0.1:8080"
mode = "redact"
carnet = "/run/secrets/preflight-carnet.json"
[cache]
memory_entries = 50000
disk_entries = 100000
artifact_bytes = 1073741824
max_artifact_bytes = 67108864
ttl_secs = 604800
```
Runtime environment:
| variable | purpose |
| --- | --- |
| `PREFLIGHT_CONFIG` | configuration path for `serve` |
| `PREFLIGHT_CLIENT_KEY` | optional bearer credential required from clients |
| `PREFLIGHT_UPSTREAM_KEY` | optional replacement credential sent to underclass |
| `PREFLIGHT_FILE_API_KEY` | separate credential for the configured file API |
| `PREFLIGHT_CONTROL` | socket path for cache administration commands |
| `RUST_LOG` | logging filter; output is structured JSON |
Send SIGHUP to reload configuration and the carnet. A failed reload keeps the previous runtime active, and admitted requests retain their original snapshots. Listener, cache directory/budgets, and control-socket changes require a restart. On NixOS: `systemctl reload preflight`.
## CLI
```
preflight serve [--config PATH]
preflight check [--config PATH]
preflight cache status [--socket PATH]
preflight cache purge [--scope SCOPE_ID] [--socket PATH]
```
`check` validates configuration and compiles the detection rules. Cache commands talk to the running daemon through a mode-`0600` Unix socket. On NixOS, run administration as root.
## Endpoints
| route | auth | purpose |
| --- | --- | --- |
| `POST /v1/responses` | client key, if configured | inspect a Responses request, then stream through underclass |
| `POST /v1/chat/completions` | client key, if configured | inspect a Chat Completions request, then stream through underclass |
| `GET /v1/models` | client key, if configured | forward model discovery |
| `GET /healthz` | none | local liveness probe |
| `GET /readyz` | none | readiness after startup toolchain checks |
| `GET /metrics` | none | aggregate Prometheus counters and request-to-headers timings |
Every handled response carries `x-request-id`, including blocked requests, authentication failures, model discovery, and unmatched routes. Completed inspections also attach `x-preflight-finding-count`. JSON logs correlate requests and safe finding IDs without recording prompts or matched secrets. Unknown routes are not pass-through routes.
### Correlation with underclass
One request gets one ID across both proxies:
```
client β preflight β underclass
generates preserves
x-request-id: 9031b620-66aa-4a59-9228-bb7f40f567a1
```
Preflight creates a fresh UUIDv4 at ingress and sends it to underclass as `x-request-id`. Underclass validates and preserves it in its response, routing logs, retries, and request history. Both services use the log field `request_id`, so search that value in either service to follow the same request. Preflight replaces caller-supplied IDs; underclass generates its own when called directly with an absent, invalid, or duplicate ID.
Both services use `x-request-id` exclusively. IDs are diagnostic metadata, not authentication or proof of inspection. See [ADR 0005](https://github.com/ghuntley/preflight/blob/main/docs/adr/0005-cross-proxy-request-correlation.md).
## JSON logs
Preflight prints one JSON object per log line. These representative excerpts omit tracing's `span` and `spans` metadata for readability; in the full output, inspection events carry the request ID and inspection-profile fingerprint in their inspection span, nested under the request span. Timestamps, IDs, and timings below are illustrative.
The headings identify the configured policy. The current log schema does **not** include a `mode` or `action` field, and HTTP 200 alone does not distinguish redaction from advisory forwarding. Successful-forwarding examples assume underclass returns 200.
### No secret found β any mode
Inspection completes with zero findings, and the request continues normally:
```
{"timestamp":"2026-09-20T12:00:00.001Z","level":"INFO","fields":{"event":"inspection.completed","finding_count":0}}
{"timestamp":"2026-09-20T12:00:00.024Z","level":"INFO","fields":{"event":"request.policy_completed","request_id":"9031b620-66aa-4a59-9228-bb7f40f567a1","status":200,"duration_ms":24}}
```
There is no `inspection.finding` event for this request. An allowlisted fixture also contributes no finding.
### Secret found β `redact`
A text finding produces a warning with its rule and opaque finding ID. Preflight replaces the detected span with a token such as `[REDACTED:github-pat]`, then forwards the sanitized request:
```
{"timestamp":"2026-09-20T12:01:00.002Z","level":"INFO","fields":{"event":"inspection.completed","finding_count":1}}
{"timestamp":"2026-09-20T12:01:00.002Z","level":"WARN","fields":{"event":"inspection.finding","finding_id":"8a4f0571-4751-4d64-94da-55c3626a12de","rule_id":"github-pat"}}
{"timestamp":"2026-09-20T12:01:00.031Z","level":"INFO","fields":{"event":"request.policy_completed","request_id":"aa7445a8-b378-4b93-8ed1-b25ae80dfc01","status":200,"duration_ms":31}}
```
For rebuilt attachments, an additional `attachment.inspected` event includes `finding_count` and `rebuilt: true`. A finding that cannot be safely rewritten is blocked instead.
### Secret found β `no-go`
The finding is reported, then preflight returns 409 without sending the request to underclass:
```
{"timestamp":"2026-09-20T12:02:00.002Z","level":"INFO","fields":{"event":"inspection.completed","finding_count":1}}
{"timestamp":"2026-09-20T12:02:00.002Z","level":"WARN","fields":{"event":"inspection.finding","finding_id":"4b23f164-cc68-4