A Love Wins Media product

Engineering plans / Proposed (design only, no code yet) · November 2, 2026

Access hooks

Every reader of the corpus sees all of it today. The app’s Search page, each assistant registered on the MCP page, and a remote client reaching garage-mcp all get the same results for the same query. The only control is the egress rule, which keeps communications away from a model host that is not on this Mac (docs/privacy.md). That rule decides where content may be processed. It does not decide who may read it.

This plan adds hooks, not a policy. Garage learns to name the caller of every read, carries that name to every read path, and asks a registered access policy what that caller may see. Garage ships no policy. With none installed, every hook is a no-op, no SQL changes, and results are exactly what they are today. An extension supplies the policy, including its rules, their evaluation, its storage, its settings and its pages in the app.

Written against next at 71af3bd on 2026-11-02. The schema runs to 018_fact_anchors.sql.

Goals

Not in scope: rules, matchers, rule storage, a rules UI, and any decision about what a caller may see. Those belong to the extension. Nothing here makes a policy a security boundary against software running as the same user (see Callers).

The extension loader

There is no extension mechanism today. This plan adds a small one, which the access hooks use first:

# an extension's pyproject.toml
[project.entry-points."garage_rag.extensions"]
enterprise = "garage_enterprise.extension:register"

garage_rag/extensions.py loads every garage_rag.extensions entry point once at start-up, in name order. It calls each with a Registry, where the extension declares what it provides:

Registry call Used by
register_access_policy(policy) This plan. At most one policy; a second registration is a start-up error, not a silent override
register_config_section(section) config/SECTIONS, so the extension’s settings are known keys and appear in the generated schema
register_schema_dir(path) db/migrate.py applies it after data/sql, idempotently, as the rest is applied
register_grpc_servicer(add_fn) create_grpc_server adds the extension’s service beside GarageService, on the same socket and behind the same token
register_egress_destinations(provider) net/egress.py. Already listed as an upstream seam in the Enterprise architecture, and not otherwise part of this plan

Extensions load in-process, with the privileges of the process that loads them, so the loader adds no attack surface beyond installing a package into Garage’s Python. In the app, that Python is the one bundled in PythonXPCService.framework, so only a build that bundles an extension has one. GARAGE_EXTENSIONS=0 turns the loader off for tests and support.

test_egress_block.py keeps holding: an extension that imports a network client fails the AST scan like any other module in the site-packages the app bundles, unless it is listed.

Callers

garage_rag/access/caller.py defines the value every read carries:

@dataclass(frozen=True)
class Caller:
    kind: Literal["app", "cli", "mcp"]
    registration: Registration | None  # MCP: the registration its token proved, or None
    transport: Literal["grpc", "stdio", "http", "local"]
    attributes: Mapping[str, str]      # transport facts a policy may use; see below

@dataclass(frozen=True)
class Registration:
    id: str          # stable, minted with the registration, never reused; survives Reset token
    name: str        # display only; may be renamed
    client: str      # 'claude-desktop', 'cursor', 'garage-door', ...

Where each one comes from:

Caller kind / registration Source
The app’s pages (gRPC from the app) app The per-launch gRPC token (GARAGE_GRPC_TOKEN), which only the app has
garage on a terminal cli The process itself
An MCP assistant over stdio mcp / its registration The registration token in GARAGE_MCP_TOKEN, from the env block McpInstall writes into the assistant’s config
An MCP assistant over HTTP mcp / its registration The same kind of token, as Authorization: Bearer on each request
garage-mcp with no token, or one that matches nothing mcp / None Hand-edited configs, older registrations, a revoked token

Registrations carry a token

The registration’s token, not its name, decides who the caller is, and so which ACL a policy applies.

attributes is where a transport passes facts without Garage interpreting them. The HTTP server passes the request headers named by policy.caller_headers() and no others. That is how a tunnelling connector on the Mac can tell a policy which remote client is calling. Garage does not know or care what a door is. The policy honours such attributes only from registrations it trusts to set them.

What a token is worth. It proves which registration a caller holds, not which program is running. Software running as the same user can read an assistant’s config file and present that assistant’s token. It can then get what that assistant gets, but it cannot pick another registration’s rights, and it can never become app. The alternative was a name passed as an argument, which any process could set to anything. The token sits in a file the user’s assistant already trusts with its own secrets. A keychain cannot hold it instead, because the assistant starts garage-mcp from its config with no way to unlock anything. docs/privacy.md will say this plainly.

Preview as. gRPC read requests gain an optional caller field (a registration id, or app). The server honours it only from the app’s token, so the app can show what another caller would get. From anyone else the field is refused with PERMISSION_DENIED.

The access policy interface

class AccessPolicy(Protocol):
    # Callers (registrations are authenticated by Garage before the policy sees them)
    def caller_headers(self) -> Collection[str]: ...

    # Reads
    def search_filter(self, session: Session, caller: Caller) -> SqlFilter | None: ...
    def visible_document_ids(self, session: Session, caller: Caller, ids: Sequence[int]) -> set[int]: ...

    # Lifecycle (in the same transaction as the change, unless noted)
    def on_document_replaced(self, session: Session, document_id: int) -> None: ...
    def on_document_deleted(self, session: Session, document_id: int) -> None: ...
    def on_chunks_embedded(self, session: Session, model_slug: str, chunk_ids: Sequence[int]) -> None: ...
    def on_models_changed(self, session: Session) -> None: ...       # after commit
    def on_registration_removed(self, registration_id: str) -> None: ...  # after Uninstall commits

SqlFilter is a SQL fragment over the aliases the search already uses (d for documents, c for chunks, s for sources) plus its bind parameters, in their own namespace (acl_*), so they cannot collide with the search’s. None means no condition, and a policy returns it for a caller it does not restrict, so that caller’s query is today’s query.

Fail closed. If a policy raises, the read fails with an error. It never falls back to returning everything, and the error tells the caller nothing about the corpus.

garage_rag/access/__init__.py holds the registered policy and a NullPolicy, which allows everything and is used when none is registered. Read paths call module functions (access.search_filter(session, caller), access.visible(...)), never the policy object directly, so the null case stays one branch in one place.

Where the hooks go

Path Hook
search/hybrid.py:search (CLI, gRPC Search, rag_search) Takes caller. _filter_clause appends search_filter’s fragment inside both engines’ CTEs, so it applies before fusion. apply_search_tuning sets hnsw.iterative_scan = relaxed_order when a filter is present, so the vector side keeps walking until the filtered candidates fill its depth
rag_get_document, gRPC GetDocument visible_document_ids on the one id. A hidden document answers exactly as a missing one does (“no such document”), by id or by path
ListDocuments, ListFacts, GetFactStats search_filter on the document join
rag_list_sources, rag_list_authors, rag_stats, GetStats Counted with search_filter applied
rag_ask, rag_agent Retrieval and every tool call the model makes carry the calling Caller; the model can draw only on what that caller may see
Graph (GetGraphLabels, FindGraphVertices, GetGraphNeighborhood) visible_document_ids on each vertex’s supporting documents; a vertex is shown with its visible support only
ingest/gateway.py:replace_document, document deletion on_document_replaced / on_document_deleted, inside the document’s transaction
embed/ollama.py:store_vectors (and the facade’s UpdateEmbeddings) on_chunks_embedded, inside the batch’s transaction
Model register, drop, set default on_models_changed
MCP Uninstall on_registration_removed

tests/test_access_paths.py holds this table as data. It walks every @mcp.tool() and every GarageService handler and fails on a read path that is not listed with its hook, and on a lifecycle path that does not fire its event. A test policy that hides one document then runs every read path and checks that the document appears nowhere: not in results, counts, facts, graph or answers. test_postgres.py checks that a policy’s fragment sits inside both CTEs on the vector, halfvec and hnsw_bq paths, and that a filtered search still returns limit hits.

In the app

The app gets two seams, both empty in this repository:

The MCP page mints a token for every registration, as described in Registrations carry a token, and shows “Reset token” beside each. This happens whether or not a policy is installed, so a policy installed later can tell assistants apart without re-registering them.

Order of work

Step Contents
H1 extensions.py with the registry, Caller, NullPolicy, registration tokens (mcp_registrations, minting in McpInstall, checking in garage-mcp) and the Caller plumbing through MCP and gRPC
H2 search_filter and visible_document_ids on every read path, the path test and the hide-one-document test, plus the Postgres checks
H3 Lifecycle events, register_schema_dir, register_config_section, register_grpc_servicer
H4 The app’s seams: the sidebar label flag, Preview as, and Reset token on the MCP page

Decisions to confirm

  1. Entry points as the loading mechanism, rather than a list of module names in garage.json. Entry points mean only what is installed into Garage’s Python can load, never something a config file names.
  2. One policy at most, rather than several combined. Combining policies (all must allow) is easy to add later. Defining how two policies’ SQL fragments and lifecycle state interact is not worth doing before there is a second policy.
  3. A token for every registration, even with no policy installed. A missing or unknown token is still served as today until a policy says otherwise.