Nothing leaves the box
Route model traffic to runners on your own network, with outbound telemetry, usage reporting and entitlement validation disabled. Fail-closed: no cloud fallthrough, ever.
Air-gap mode is gated on a signed licence JWT at ~/.pretense/licence.jwt. Without it the proxy still boots and still prints the air-gap banner, but every request is refused with 403. On a free-tier install, every command on this page returns pretense_enterprise_required.
That is fail-closed and deliberate. A missing licence never degrades into sending your traffic to a cloud provider. Talk to us about an Enterprise licence.
Checked against the published @pretense/cli@0.6.52 on 2026-08-13 — the build you get from npm i -g @pretense/cli, not the source tree. Where a claim is not verified, it says so.
1. Prerequisites
A licence file — not a YAML tier
Air-gap reads a signed JWT at ~/.pretense/licence.jwt. It is not unlocked by plan.tier: enterprise in pretense.yaml, and the refusal is 403, not 402. Guidance describing a YAML tier plus a pt-ent-* request key applies to the mutation quota, not to air-gap.
Without the licence file the proxy boots, prints the banner, and refuses everything:
HTTP 403
{"error":{"type":"pretense_enterprise_required",
"message":"Air-gap mode (local model routing) requires an Enterprise licence. Resolved tier is \"free\". Install a signed enterprise licence at ~/.pretense/licence.jwt, or disable airgap.enabled to use a hosted provider.",
"tier":"free","licensed":false}}A runner on the OpenAI-compatible surface
ollama serve # binds 127.0.0.1:11434 ollama pull llama3.2
Confirm the runner answers on /v1/chat/completions before involving Pretense — that is the only surface Pretense speaks to:
curl http://127.0.0.1:11434/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"llama3.2","messages":[{"role":"user","content":"hi"}]}'Ollama, LM Studio, vLLM, llama.cpp's llama-server and LocalAI all expose this surface. Pretense does not have per-runner presets: you point baseUrl at whichever one you run, and it is the URL that matters, not the product name.
2. Configuration
Flip the switch, then declare where your model lives.
pretense config set airgap.enabled true✓ Set airgap.enabled = true in ./pretense.yamlExit 0. It creates pretense.yaml if there is not one yet, so it works before pretense init. It does not add a runner — do that by hand:
# pretense.yaml
airgap:
enabled: true
policy: secrets-pii-only # or: full
runners:
- name: local-ollama
baseUrl: http://127.0.0.1:11434
protocol: openai-compatible
models: ["llama*", "qwen*", "deepseek*", "gemma*", "mistral*"]protocol: openai-compatible is the only supported value, and it is the default. ollama-native is refused at boot and the whole runner is dropped— it will simply be missing from the banner's upstream list. Ollama's OpenAI-compatible API is on the same host and port, so the fix is to delete the line.
airgap.enabled: true with zero valid runners rejects every request with pretense_no_local_runner. A typo in baseUrl is a hard stop, not silent cloud egress.
Which model names reach your runner
Routing is by model name, matched against the models: globs. Omit models: and you get these six:
qwen*llama*deepseek*gemma*mistral*codestral*Each glob compiles to an anchored, case-insensitive regex — ^llama.*$, not .*llama.*. The glob has to be a prefix of the tag. Real tags, and where they land:
| Model tag you pulled | Matched by | Reaches your runner |
|---|---|---|
llama3.2 | llama* | yes |
llama3.3:70b | llama* | yes |
qwen2.5-coder | qwen* | yes |
deepseek-r1:8b | deepseek* | yes |
deepseek-coder-v2 | deepseek* | yes |
gemma2:9b | gemma* | yes |
gemma3:27b | gemma* | yes |
mistral-nemo | mistral* | yes |
codestral | codestral* | yes |
codellama | no default glob — does not start with llama | needs an entry |
mixtral | no default glob — does not start with mistral | needs an entry |
qwq | no default glob — Qwen's reasoning line drops the qwen prefix | needs an entry |
phi4 | no default glob — no phi glob in the defaults | needs an entry |
An explicit models: list replaces the six defaults, it does not extend them. If you add ["codellama*"] to route CodeLlama, you have just stopped routing llama3.2 — spell out every family you use. An unmatched model is refused with pretense_no_local_runner, which is the fail-closed outcome, not a cloud call.
3. Run
PRETENSE_UPSTREAM_ALLOW_LOCAL=1 pretense start# or, to launch a tool through it in one step:PRETENSE_UPSTREAM_ALLOW_LOCAL=1 pretense run claudePRETENSE_UPSTREAM_ALLOW_LOCAL=1 re-permits cleartext http:// loopback, which the SSRF and egress guard blocks by default. The proxy prints a loud warning when you set it. Correct for a runner on your own machine; never set it to reach something you do not control.
A model name is not a command. pretense run takes the name of an executable to launch. On 0.6.52, pretense run llama3.2 starts the proxy and then fails with x "llama3.2" is not on your PATH. — there is no built-in chat for a model name in this release. Route the model by pointing a client at the proxy, as below.
Do not reach for the runner's own CLI instead. ollama run llama3.2 talks straight to 127.0.0.1:11434 and never touches Pretense: no mutation, no reversal, no audit row. Your secrets arrive at the model verbatim. Air-gapped is not the same thing as safe — a local runner is still an upstream, and localhost is not an exemption.
Any OpenAI-compatible client works — put the model tag in the request and let the globs route it:
curl http://localhost:9339/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"llama3.2","messages":[{"role":"user","content":"hi"}]}'Read the boot banner
[pretense] AIR-GAP MODE — outbound telemetry, usage reporting and entitlement validation are disabled. Reachable upstreams: local-ollama=http://127.0.0.1:11434
| What you see | What it means |
|---|---|
| Runner missing from that list | Its config was dropped at boot — malformed baseUrl, missing name, or protocol: ollama-native. The reason is on stderr. |
| No banner at all | Air-gap never engaged. Check airgap.enabled and which pretense.yaml was loaded. |
| Banner present but every request 403s | The licence file is missing. The banner prints regardless of licence state, so the banner alone does not mean air-gap is working. |
The banner prints regardless of licence state, so the banner alone does not mean air-gap is working.
4. Verifying it actually works
Two checks. Each one can fail, which is the point.
Negative test — no cloud fallthrough
Ask for a model that matches no glob. It must be refused without dispatching anywhere:
curl -s http://localhost:9339/v1/messages \
-H 'content-type: application/json' -H 'anthropic-version: 2023-06-01' \
-d '{"model":"claude-opus-4-20250514","max_tokens":64,
"messages":[{"role":"user","content":"hi"}]}'Confirm it at the network layer — tcpdump, or a firewall deny rule on api.anthropic.com and api.openai.com— rather than trusting the proxy's own output. Silent fallthrough is the failure mode that matters, and a proxy that has been tampered with is not a trustworthy witness to its own egress.
On an unlicensed install the licence gate returns 403 before model routing runs, so a 403 here proves the licence gate, not the glob matching. Proving glob rejection specifically needs a valid licence.
Credential test — read the bytes
Ollama will not show you request headers, so point a runner at something that will:
nc -l 11434Send one request and read what arrives. Authorization and x-api-key must be absent— Pretense withholds the caller's provider credential from local runners (TASK-605, shipped in 0.6.51). This is the one test where success proves nothing on its own: a leaked header still produces a perfectly working request. You have to look at the bytes.
Then confirm your build contains the fix:
npm ls -g @pretense/cli # must be >= 0.6.51 grep -c isLocalRunner "$(npm root -g)/@pretense/cli/dist/index.js"
0.6.50 and earlier forward the caller's bearer token to local runners over cleartext loopback. Check the installed tree, not the source tree — they drift.
5. Known issues
Open against 0.6.52. Listed so you do not debug them twice.
| Issue | Detail | Ticket |
|---|---|---|
/health reports version: "0.2.0" | A hardcoded string left on a 0.6.51+ build. /health cannot be used to tell which build is deployed — use pretense --version. | TASK-622 |
--dashboard-port ignored | The banner prints the default 9340 and the dashboard binds neither port. | TASK-623 |
There is no dedicated air-gap flag and no doctor-style preflight command. Air-gap is configured entirely through pretense.yaml plus the licence file, which is why this page spends so long on the banner.
Next steps
Where to go deeper.