Privacy and macOS permissions

The guarantee

Garage sends content only to approved destinations: this machine, and the Ollama or LM Studio server you configure. Communications never leave this machine. There is no cloud AI client in the codebase.

This is enforced structurally, in layers that are each tested on their own (garage_python/tests/test_egress_block.py). Removing any one of them fails the test suite.

Layer 1 — one choke point

garage_rag/net/egress.py is the only module that imports an outbound network client library (httpx, urllib.request, requests, raw socket, …) or a library that opens its own connections (the ollama SDK). Every outbound client is built there, after its destination is checked:

The test parses every source file’s AST, so a function-local or importlib.import_module import is caught too. Inbound and local infrastructure is exempt, and listed file by file rather than by pattern: the gRPC server and its generated stubs, the MCP server’s uvicorn, and psycopg’s connection to Postgres. The gRPC client of the app’s facade is listed too, and checks its address with the guard (loopback only).

Layer 2 — no cloud AI SDK

No source file imports a cloud AI SDK (anthropic, openai, google.genai, google.cloud, cohere, mistralai, boto3, upstream langextract and the like), and uv.lock contains none. Neither the openai nor the ollama package is a dependency: LM Studio’s and Ollama’s HTTP APIs are a few JSON routes, made by garage_rag/inference with the guard’s httpx client.

There used to be an optional Claude vision fallback for OCR. It has been removed: OCR is Tesseract only, on this machine, and the cloud settings section and the per-source allow_cloud_enrichment flag are retired (an older config that still has them loads with a warning).

Layer 3 — the destination allowlist

egress.check_destination runs before any client is built. It approves:

Anything else raises EgressBlocked. There is no setting that adds other hosts. A host name other than localhost is never treated as loopback, even if it resolves to loopback today, because a DNS answer can change; it is approved only when it is the configured server.

The clients the guard builds ignore the environment’s proxy variables and never follow a redirect, and each one refuses a request addressed to any origin but the one it was built for, so neither http_proxy nor a server’s Location header can send content elsewhere.

Layer 4 — communications stay on this machine

Content classified corpus_class = 'communication' never goes to a destination that is not loopback, even an approved one. The guard checks this before anything else when the caller says what it is sending:

Layer 5 — local fact extraction

Only the local part of LangExtract is shipped, vendored as enrich/langextract. Upstream chooses its backend by regex on the model name and would send a gemini-* or gpt-* model id to Google or OpenAI; that routing and those backends are not vendored, and enrich/facts.py refuses a cloud model id with a clear error. Every extraction runs on LocalLanguageModel, Garage’s own provider, through the same loopback-only client.

What these layers do not cover — MCP clients

The MCP server hands search results and document excerpts, communications included, to whichever client is connected to it, and that client may send them to its own model provider; see What connected agents receive. rag_search results carry each hit’s corpus_class so a client can tell communications apart.

macOS permissions (TCC)

Messages and Mail are protected by Transparency, Consent, and Control. Without Full Disk Access:

$ sqlite3 ~/Library/Messages/chat.db .tables
Error: unable to open database: authorization denied

$ ls ~/Library/Mail
ls: Operation not permitted

The file metadata is visible, so a naive walker sees plausible files and fails confusingly on every one. The pipeline detects this and reports it as a permissions problem rather than a parse failure.

To grant: System Settings → Privacy & Security → Full Disk Access, and add your terminal (or whichever process runs garage). Then re-run — idempotency means nothing already indexed is re-done.

If you would rather not grant blanket access, copy chat.db (plus -wal and -shm) to a working directory via Finder and register that copy as the source. Narrower grant, more friction per refresh.

Cloud placeholders and network traffic

~/Dropbox here is ~99% online-only stubs. Reading a stub asks Dropbox to download it — a naive walk would have quietly pulled ~230 GB.

placeholders.materialize controls this, and even when enabled, downloads are capped per run by placeholders.limit and placeholders.max_bytes. Every run reports what it fetched and what it deferred; nothing is silently truncated.

Serving over HTTP

The MCP server has no authentication. Over stdio that is fine: the client spawns it as a child process and nothing else can reach it. Over HTTP it is the whole security model, so three defences apply.

Loopback by default. mcp.host is 127.0.0.1. Binding anything else requires --allow-remote, and the refusal explains why rather than just erroring:

$ garage mcp-serve --http --host 0.0.0.0
refusing to bind 0.0.0.0: this server has no authentication and exposes your
entire corpus, including anything indexed from private communications.

DNS-rebinding protection, always on. Without it, a page you visit could resolve its own hostname to 127.0.0.1 and POST to your loopback server from your browser — reading your corpus without ever touching the network perimeter. The Host allowlist blocks it:

$ curl -H 'Host: evil.example.com' http://127.0.0.1:8787/mcp   # 421
$ curl -H 'Host: 127.0.0.1:8787'   http://127.0.0.1:8787/mcp   # 200

Browser clients additionally need their origin allowed explicitly, with --allow-origin https://example.com.

No transport-level encryption. Plain HTTP. Fine over loopback; if you expose it, terminate TLS and authenticate at a reverse proxy. Do not put this on a network you do not control.

What connected agents receive

The MCP server answers whichever client you connect: Claude Desktop, Claude Code, Cursor, or anything else you register. An agent receives the excerpts its searches return (rag_search, get_document, and the answers from rag_ask / rag_generate), only those, not the whole index. Most agents run their model in the cloud, so they send those excerpts, with your conversation, to their model provider. That includes excerpts from Messages and Mail if you have indexed them: the MCP tools serve communications like any other content, and the egress guard above governs Garage’s own cloud calls, not a client’s.

What happens to an excerpt after an agent receives it is governed by that agent’s terms and privacy policy, not Garage’s. If an indexed source should not reach a cloud model, do not connect a cloud-hosted agent, or leave that source out of the index.

What is stored, and where

Everything stays in your local Postgres rag database: extracted text in documents.content, chunk text in chunks.text, vectors in emb_*. No content leaves the machine except to the model servers you configure (communications never do), or through an MCP client or --allow-remote, described above.

The database is unencrypted at rest, as Postgres normally is. If you index private communications, the database file is as sensitive as the messages themselves — consider FileVault (on by default on recent macOS) and treat pg_dump output accordingly.