ModelSaar

Privacy and security of the gateway

Rendered from docs/privacy.md at commit b9d69f7299ed. The repository file is authoritative.

ModelSaar implements security as product behaviour. Defaults, what they guarantee, and what they cannot.

Defaults

control default where
Bind address 127.0.0.1 (loopback). Non-loopback requires security.allow_lan: true explicitly; config load fails otherwise server.host, security.allow_lan
Authentication Generated inference key(s) and a separate admin key (mode 600). No default/universal key exists keys/api_keys, keys/admin_key
Admin vs inference authority /admin/* requires the admin key; an inference key is rejected (403). The admin key is also accepted on inference routes (one-way separation): keep it on the host only security.py
Execution locality local_only: only deployments with locality local are eligible or advertised. Ollama cloud-forwarded models (remote_model/remote_host, :cloud names) are classified remote with the evidence recorded; generic endpoints carry the operator's declaration as a stated trust assumption security.locality_mode
Backend destinations Adapters only build paths relative to the configured base_url; absolute or protocol-relative paths raise; redirects are not followed; hosts are allow-listed adapters/base.py
Remote media Never fetched. Requests with http(s) image URLs are rejected with a remedy (send data: URLs) security.fetch_remote_media: false
Prompt logging Off. Decision records hold metadata only (task labels, token counts, capability flags) plus at most a short redacted upstream error excerpt (≤200 chars) when a backend fails. Error messages are redacted for key-like strings security.log_prompts, security.redact
Caches No response cache, no prompt-derived cache. Affinity is scoped per caller and holds only a deployment id routing/affinity.py
Telemetry / learning None. security.telemetry_opt_in exists for future opt-in features; nothing is sent anywhere
Request size 32 MiB limit; unknown fields rejected rather than forwarded server.max_request_bytes
Untrusted content Prompts, tool outputs, model cards and classifier output are data. Classifier output is schema-validated against a closed label set and can only refine a task label routing/classifier.py
Payload-bearing operations Nothing derived from a request leaves the gateway before policy filtering. Tokenizer counts (/tokenize) are requested only from deployments that already passed locality, enablement, health and capability filters; the optional classifier must be a local deployment unless routing.classifier.allow_remote and security.locality_mode: allow_remote are both set. Unknown content part types (audio_url, video_url, file, …) are rejected with 400 unsupported_content_type before any dispatch routing/router.py, protocol/openai.py
Code execution The gateway never executes model-generated commands. The evaluation harness runs model-written Python only inside bubblewrap (Linux; input allowlist: the Python runtime and system libraries, nothing else from the host), sandbox-exec (macOS; deny-by-default profile with runtime read roots) or a --network none --read-only Docker container with CPU/memory/output/time limits; a synthetic host file outside the work directory is tested to be unreadable. When none is available the task is skipped. There is no unsandboxed fallback and no flag to enable one. Limit: the sandbox does not protect the evaluator process from model code that targets it directly evaluation/sandbox.py
Dependencies Five pinned-range runtime deps; CI runs pip-audit and secret scanning; workflow permissions are read-only .github/workflows/ci.yml

LAN exposure

Set server.host: 0.0.0.0 and security.allow_lan: true. Keys then travel over plain HTTP: terminate TLS in front (Caddy/nginx) or use a trusted private tunnel (Tailscale/WireGuard). Rotate keys with modelsaar keys rotate. The admin key should never leave the host. modelsaar keys ensure creates missing key files (idempotent, never rotates) and is what the container runs before serve.

File handling

client-config --apply keeps the target file's permission bits (a 0600 file stays 0600; new files are created 0600 because the snippet carries the gateway key), writes temp files and backups private from creation and never reuses a backup name. modelsaar uninstall removes only the files ModelSaar created (config and its backups, its key files, its state database), refuses symlinked keys//state/ boundaries and leaves anything else in place.

Offline operation — scope of the claim

What was tested: with the fake backend on loopback and locality_mode: local_only, no request, classification or telemetry leaves the host; cloud aliases are excluded even when requested explicitly (tests/test_acceptance.py::test_local_only_blocks_cloud_even_when_explicit, test_prompt_injection_cannot_change_routing); a remote-labelled backend with a /tokenize endpoint receives zero bytes of message text (tests/test_hardening_2026_10_11.py::test_local_only_sends_no_message_text_to_remote_tokenizer).

What the gateway cannot prove: that an opaque backend is truly local. A localhost address can be a proxy; Ollama can forward :cloud models. ModelSaar records the evidence it has (locality_evidence in /admin/status) and treats operator declarations as trust assumptions.

What is outside the gateway: client tools (web search, browsers, code execution), auxiliary models (Open WebUI task model, OpenClaw utility model, Hermes compression model) and unrelated applications have their own network behaviour. Changing the main model endpoint does not make a whole application offline. To check: run modelsaar serve and the client with outbound network blocked (macOS: a firewall rule or Little Snitch; Linux: unshare -n / nftables) and confirm the client still completes a chat through the gateway.

Reporting

See SECURITY.md.