Keel local

Keel is a retrieval and agent appliance that runs inside your own network.

An organisation points Keel at its own documents. People ask questions in ordinary language and get answers that cite the exact passage they came from, drawn only from the documents that person is cleared to read. It runs on a single machine on premise, on an isolated network, or inside the organisation's own Azure tenancy using managed identity. The documents stay where they started, and so do the questions.

Security is the design rather than a layer added over it. Entitlement filtering runs inside retrieval, so the model receives only passages the person asking may read. Air-gap mode refuses outbound connections at five layers of the Python stack. Write actions wait for a person to approve them. Every request appends to a hash-chained ledger that verifies from an exported file.

Written by Blake Rowlands-Mowle, reachable at service@flow-through.com.au and at github.com/BlakeR-M. Apache 2.0, Python 3.11 and up. The three demonstrations below run the production code path against this instance as you read them. The corpus here is five fixture documents holding invented content.

Demonstration one

Access control, applied inside retrieval

The corpus holds a salary-bands document tagged hr. Below, the same restricted question is asked twice, once as a user holding only public and once as hr-officer. Both are live requests through the same engine.

“What is the confidential review code for the 2026 pay round?”

How the filter works

The filter sits inside the retriever
The keyword search and the vector search each carry the entitlement filter in their own query, so the only candidates either one produces are passages the caller may read. Filtering happens before the two result sets are combined and before the reranker sees anything, which keeps a high similarity score from lifting a restricted passage into a prompt.
A refusal costs zero model calls
When the best entitled passage scores below the relevance line, Keel says the sources do not cover the question and stops there. The prompt is left unbuilt and the model is left untouched, so a follow-up question has nothing generated to steer.
The boundary is checked twice
Finding A7 of the review added a second entitlement check at the generation boundary. A retrieval bug on its own is now insufficient to put a restricted passage in front of the model.
Citations carry the same check
Each citation opens the passage it came from, and the source viewer applies the entitlement check again, so following a citation link stays inside the same boundary.

Code: keel/retrieval/bm25.py, keel/providers/local_index.py, keel/answer/engine.py. Tests: tests/redteam_acl_and_retrieval_leakage.py.

Demonstration two

Air-gap mode, guarded at five layers

Air-gap mode installs guards across the whole Python process. Enter a hostname below and Keel starts a child process with the guard enabled, points each guarded layer at that host, and reports what every layer did. The text in each result comes from the raised exception itself.

Try , , or .

Why each layer carries its own guard

Name resolution, ahead of the socket
Looking up <secret>.attacker.example hands the secret to a name server whether or not a connection follows, which makes resolution an exfiltration channel in its own right. Review finding G1 closed it: a name outside the allow list is refused at getaddrinfo, and the control test asserts the resolver was left unasked.
The event loop needs a guard of its own
On Windows the proactor loop connects through ConnectEx and bypasses socket.connect entirely, so guarding the socket alone would leave a path open. Both loop families are guarded at sock_connect.
Library layers refuse ahead of sending
urllib is checked at OpenerDirector.open, ahead of any handler. httpx is checked by a guarded transport and by a request hook, which is what covers the client handed to the OpenAI SDK.
This probe stays contained
A host outside the allow list is unreachable, which is the property on display. A host inside the allow list is answered from the policy alone, so loopback stays out of reach here as well.

Code: keel/airgap.py. Tests: tests/test_airgap.py and the air-gap section of the ledger and air-gap red-team file.

Demonstration three

Redaction of Australian identifiers

Tax file numbers, Medicare numbers and ABNs are validated against their own check digits rather than matched on shape alone. Check-digit validation is what leaves an invoice number intact beside a tax file number of the same length. Edit the text below and run it through the function the ingest pipeline uses.

Runs keel.safety.pii.redact in process. A pure function of its input, with the store and the model left out of it.

Code: keel/safety/pii.py. Tests: tests/test_safety.py, plus review findings S1 and S2 covering numbers wrapped across a line and email outside ASCII.

How a question is answered

Each stage below runs on the appliance itself. The Azure profile swaps the components for their tenancy equivalents and keeps the same shape.

  1. 1

    Documents arrive tagged and screened

    PDF, DOCX, Markdown, HTML and plain text load into section-aware passages, each carrying the entitlement tags of its document. Every passage is screened for prompt injection together with its own heading and its document title, because an instruction written into a heading rides into every prompt through the source label. A flagged passage goes to quarantine and waits there for a person to release it. Re-ingesting the same bytes is a no-op by design.

  2. 2

    Retrieval carries the entitlement filter

    A keyword search and a vector search each run with the entitlement filter written into their own query, so both result sets contain only passages the caller may read. Reciprocal rank fusion combines them, a cross-encoder reranks what survived, and the answer engine checks entitlement once more before anything reaches the model.

  3. 3

    The answer cites its sources

    A local model writes the answer from the entitled passages alone, and each claim carries a citation to the passage behind it. Where the entitled passages fall below the relevance line, Keel says the sources do not cover the question and skips the model call entirely.

  4. 4

    Tool calls run under a written policy

    Agent mode calls five typed tools: document search, a bounded calculator, read-only SQL, an allowlisted HTTP GET, and ticket creation. The policy is a file rather than an assumption. Read-only SQL runs under a SQLite authorizer with a size cap. HTTP GET reaches only the hosts you name and refuses ahead of connecting whenever air-gap mode is on. Write actions wait in an approval queue, and the policy is re-checked at the moment of execution, so an approval stays valid only while the policy that permitted it does.

  5. 5

    Everything appends to a hash-chained ledger

    Each request, retrieval, answer, tool call and approval appends a row hashing a canonical JSON array of the chained fields, so shifting a character across a field boundary changes the hash. The ledger exports as strict JSON lines and verifies from that file, away from the machine that produced it.

The full path: the architecture document. The attacks the design answers: the threat model.

The adversarial security review

Before Keel was first published I attacked its security-bearing paths myself, one attack at a time, across access control, tool policy, ledger integrity, injection screening and the air gap. This is a self-review rather than an external audit, and it is published whole so you can weigh it as one. It produced 105 attack tests, which parametrise to 174 cases in the suite, and 27 findings. Twenty-five are fixed in the same release, each with the test that proves it. Two stay open by choice, each carrying the control that covers it. Every attack was written as a test that stays in the default suite, so the review keeps running rather than expiring on the day it finished.

The two that stay open

Both are recorded as strict expected failures, so the suite reports it the day either one starts passing. The medium is a paraphrased injection carrying none of the trigger words the heuristics key on, and the language-model judge at ingest is the control that covers it. The low is a heuristic false positive on a genuine operations note, which an operator releases from the quarantine list. They are published as open rather than quietly closed, with the compensating control written into the test beside the reason.

All 27 findings The threat model

Controls, with the code and the test behind each

ControlWhere it livesWhat proves it
Entitlement filtering inside retrieval, re-checked at generation retrieval/bm25.py, providers/local_index.py redteam_acl_and_retrieval_leakage.py
Air-gap egress refusal at socket, resolver, event loop, urllib and httpx airgap.py test_airgap.py
Prompt-injection screening at ingest, with quarantine and an optional model judge safety/injection.py test_safety.py
PII detection and redaction, Australian formats with check digits safety/pii.py test_safety.py
Hash-chained ledger, strict JSON export, verification from a file safety/ledger.py redteam_ledger_….py
Typed tools under a written policy, writes held in an approval queue agent/policy.py, agent/approvals.py redteam_tool_policy_and_approvals.py
Identity from an authenticating proxy beyond loopback, ignoring the request body web/app.py test_web.py
Cloud profile on managed identity, with local key auth disabled in the template providers/azure.py, deploy/azure/main.bicep test_azure_provider.py
Answer quality measured, with a regression gate that fails a release evals/run.py test_evals.py

Where it runs

On premise, one machine

A Python process, a SQLite store and a local model server. Compose files and native runners ship in the repository, with a GPU variant for a larger model. Everything remains on that machine.

On-premise guide

Air-gapped

The same build with KEEL_AIRGAP=1. Egress is refused at five layers, model caches are read from disk with the offline flags set, and the allow list holds the hosts you name and those alone. Demonstration two above exercises this guard.

Offline operation

Your own Azure tenancy

Azure OpenAI and Azure AI Search reached through a user-assigned managed identity, keeping keys out of the code and out of the configuration. The Bicep template disables local key authentication on both resources, and continuous integration builds and lints it on every change.

Azure deployment

Running it yourself

Keel bundles no model and no cloud account. It runs against whatever you already have, so the first step is pointing it at one. keel up looks for a chat server already answering on your machine: Ollama, LM Studio, llama.cpp and vLLM all speak the same OpenAI-compatible API, so whichever you run is found and its model list read back to you. With nothing answering yet it says so and serves anyway, since retrieval and the security controls work without a model.

git clone https://github.com/BlakeR-M/keel.git
cd keel
python -m venv .venv

source .venv/bin/activate     # macOS and Linux
.venv\Scripts\activate        # Windows

pip install -e ".[dev]"
keel up

One command, and the browser opens on the question box. Add --empty to skip the fixture corpus and start with a store holding only what you put in it. The same run, step by step:

keel setup      # finds your model server and writes .env
keel doctor     # confirms the configuration, and names the fix for anything unready
keel serve      # http://127.0.0.1:8400

To name an endpoint rather than search for one, or to run inside your own Azure subscription on managed identity:

keel setup --base-url http://127.0.0.1:11434/v1 --model llama3.1:8b
keel setup --profile azure --azure-openai-endpoint https://<resource>.openai.azure.com            --azure-search-endpoint https://<search>.search.windows.net

All four ways to attach a model, and what Keel downloads for itself: the setup guide. The whole appliance end to end: the tutorial. Every command and flag: the CLI reference.

Documentation

Tutorial

Install it, load documents, ask a question, run the agent, verify the ledger.

Architecture

Every component, the data flow through a question, and the ledger hash recipe.

Threat model

Assets, trust boundaries, the attacks considered, and the control that answers each one.

Evaluation

The golden set, the scored metrics, and the regression gate that fails a release.

The live demonstration All documentation Source on GitHub