tailkeep is a small Go program that checks text for secrets and sensitive content before it reaches a cloud model, and uses the caller's Tailscale identity to decide what happens next.
Built with tsnet, Tailscale's Go library for embedding a tailnet node in a program, it joins your tailnet as two nodes. The guard (tailkeep) finds sensitive content and applies the policy of whoever is asking. The model node (tailkeep-llm) is a reverse proxy in front of a local Ollama, reachable only by callers the policy allows. People use the guard through a web app, and Aperture can call it as a pre_request hook.
Users never log in and never hold a key. The tailnet tells tailkeep who is calling, and an app capability in the policy file says how that caller is to be treated.
tailkeep does not try to out-detect other tools. Its pattern detectors are a short fixed list. The part it adds is the wiring on Tailscale: callers are identified by the tailnet, policy lives in grants, the local model can be reached only over the tailnet, and Aperture can consult tailkeep before it forwards a request. Other tools are named under Related tools.
With the example policy in examples/policy.example.hujson, the same text containing a fake AWS key gives a different result for each caller:
| Caller | Result |
|---|---|
alice@example.com (action reroute) |
finding aws_access_key; the web app lets her ask the local model about the text, and the answer never leaves the tailnet |
bob@example.com (action block) |
finding aws_access_key; the text is not sent to any model for an answer |
alice@example.com (action reroute), through Aperture to a cloud model |
blocked with 451 "Sensitive content found (aws_access_key). Send this request to granite4.1:3b instead."; re-sent to the local model, it is allowed |
a device with no llm grant, calling the model node |
403, naming the device |
flowchart LR
B["person's browser<br/>(alice, bob)"] -->|HTTPS :443| G["tailkeep<br/>guard node, tag:tailkeep"]
C["LLM client"] --> A["Aperture<br/>tag:aperture"]
A -->|"pre_request hook<br/>HTTPS :8443"| G
G -->|classify, answer| L["tailkeep-llm<br/>model node, tag:tailkeep-llm"]
A -->|self-hosted provider| L
L -->|127.0.0.1:11434| O["Ollama<br/>never on the network"]
A -->|permitted requests| P["cloud provider"]
Every request to the web app, the hook and the model node follows the same order, written as one short function per entry point (scan, answer, handle, ServeHTTP):
- Resolve identity. WhoIs on the connection gives the caller's login or tags, device name and the tailkeep capabilities it holds toward this node.
- Load the policy from those capabilities (see Policy reference).
- Detect. The built-in detectors scan all of the text. If the policy has
model_checkon and no detector found anything, the local model also classifies the first 2,000 characters. - Decide. The policy turns findings and classification into one action.
- Audit and respond. One audit line is written (never the text), then the answer is sent.
The four actions:
| Action | Cloud model | Local model |
|---|---|---|
allow |
receives the text as written | may receive it |
redact |
receives the text with every finding swapped for a [REDACTED:<detector>] marker |
may receive it |
reroute |
receives nothing. The web app has the local model answer instead; through Aperture the request is refused with a note telling the sender to use the local model (see Using it with Aperture) | may receive it |
block |
receives nothing | is not asked for an answer. The local classifier may still read the first 2,000 characters when model_check is on |
"Block" means the text is not sent to any model for an answer, not that no model reads it. The full note is in docs/detectors-and-model.md.
Running two nodes from one program gives each its own address, tags and grants. That lets the policy admit Aperture to the model node and nobody else, and lets the guard call the model node as an ordinary peer.
Measured live on a real tailnet: the hook takes 0 to 1 ms inside tailkeep (about 310 ms end to end through Aperture), a policy change reached tailkeep in about 1 to 2 seconds with no restart (policy), and the local model check scored 17 of 20 with no false positives (why this model).
You need Go 1.27.1 or later (the version tailscale.com requires). Nothing else is needed for the first part.
go test ./...The tests never need a network, a tailnet, Ollama or Aperture. check reads text from standard input and prints the decision for a policy action (--action, default block), without the network. The key below is the public AWS documentation example, assembled in the shell so this file holds no key-like string:
printf 'aws_access_key_id = %s%s\n' AKIA IOSFODNN7EXAMPLE | go run ./cmd/tailkeep check --action blockfindings: 1
aws_access_key [20,40) AKIA…
decision: block (found aws_access_key)
You need a tailnet with MagicDNS and HTTPS certificates enabled, Ollama running on the same machine, and permission to edit the tailnet policy file.
- Policy. Merge the tags, grants and app grants from examples/policy.example.hujson into your policy file. At minimum you need the tags, the network grants, and the tailkeep app grants for the people you want to try.
- Auth keys. Create two reusable, pre-approved, non-ephemeral auth keys, one tagged
tag:tailkeepand one taggedtag:tailkeep-llm. Export them for the first start only:export TS_AUTHKEY_GUARD=<key tagged tag:tailkeep> export TS_AUTHKEY_LLM=<key tagged tag:tailkeep-llm>
- Ollama and the model. Install Ollama and pull
granite4.1:3b. - Doctor.
go run ./cmd/tailkeep doctorchecks each step in order, stops at the first failure and prints a fix. - Serve.
go run ./cmd/tailkeep serve, then openhttps://tailkeep.<tailnet>.ts.net. Paste text, press Check, and if the action is notblock, ask a question about it. The audit page is at/audit.
Flags, certificate timings and setup notes: policy, troubleshooting, check.
Start with these functions; the rest of the code supports them.
- internal/web/web.go
ServeHTTP: the web app's router, with the Host and cross-site checks. Itsscan(POST /api/scans) is the whole path of one check. - internal/web/answer.go
answer: POST /api/answers, the same path plus asking the local model; a block returns before the model. - internal/hook/hook.go
handle: the Aperture hook, from caller and end user to decision and response. - internal/llmproxy/llmproxy.go
(*Proxy).ServeHTTP: the model node, which checks thellmgrant, the method and path and the size before it forwards. - internal/policy/policy.go
MergeandDecide: how grants combine into one policy, and how findings turn into an action. - internal/detect/detect.go
Detect: runs every detector over the text and returns the findings in order.
Each point is explained in docs/security.md; 12 claims are backed by tests.
- Hook and model node are protected twice: by the network grant and by the capability check.
- No API keys: the only secrets are two tagged auth keys, never logged.
- Deny by default: no capability means
block. - Strictest wins when grants overlap; permissions add up.
- Unknown stays unknown: a model failure is never
clean. - The model node serves inference only; management paths are
404. - TLS only, no plain-HTTP listener.
- Secrets are never logged; the audit file is mode 0600.
- Tags come from the control plane, never from the user profile.
- Cross-site protection on the web app: Host and Origin checks, a strict Content-Security-Policy.
Main limits (see also out of scope):
- Detection is a short list of patterns plus a best-effort local model that can be talked out of a verdict (details).
- Only requests are scanned, not responses, and encoded secrets or secrets inside images and files are not found (hook limits).
- There is no rate limit; use network grants to decide who can reach the web app (endpoints).
Policy, detectors and model, Aperture, security, testing, troubleshooting, gate tally.
Several open source projects work on parts of this problem: Microsoft Presidio for personal data, LLM Guard for checks on prompts and replies, and secret scanners such as gitleaks. Each has a scope and a track record of its own and deserves its own evaluation. tailkeep's detectors are a short built-in list. The code has no plug-in mechanism for other engines today.
These are ideas. None of them exists in the code.
- A Docker Compose setup (an Ollama container plus tailkeep, CPU by default) to try tailkeep without installing anything on the host.
- Pluggable detector backends, such as Presidio or LLM Guard, behind a detector interface shared by the web app and the hook. Today only the web package has a small interface, and the hook calls the built-in detectors directly.
- Tailscale Services (
ListenService) so several guard instances can share one name. - Device posture: a stricter action for unmanaged devices.
tsidpfor identity in other tools.- Shipping the audit file to a log pipeline.
BSD-3-Clause. See LICENSE.